From dc8529e3ea89332a459350f50a7169b7dd54a5a2 Mon Sep 17 00:00:00 2001 From: agra Date: Sun, 17 May 2026 13:54:11 +0300 Subject: [PATCH] ui: port game UI framework into library/modules/ui MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 20 files (~3,830 lines): view protocol, layout, renderer, glyph cache, fonts, gestures, animation, scroll, stacks, modifiers, etc. Internal imports rewritten from "ui/..." to "modules/ui/...". Consumers now `#import "modules/ui"` from any project; no symlink hacks needed. Verified by compiling game/main.sx without its local ui/ — resolves via the Phase 6 stdlib fallback. --- library/modules/ui/animation.sx | 167 +++++++ library/modules/ui/button.sx | 91 ++++ library/modules/ui/dock.sx | 697 ++++++++++++++++++++++++++++++ library/modules/ui/events.sx | 96 ++++ library/modules/ui/font.sx | 20 + library/modules/ui/gesture.sx | 128 ++++++ library/modules/ui/glyph_cache.sx | 613 ++++++++++++++++++++++++++ library/modules/ui/image.sx | 36 ++ library/modules/ui/label.sx | 38 ++ library/modules/ui/layout.sx | 152 +++++++ library/modules/ui/modifier.sx | 296 +++++++++++++ library/modules/ui/pipeline.sx | 162 +++++++ library/modules/ui/render.sx | 179 ++++++++ library/modules/ui/renderer.sx | 460 ++++++++++++++++++++ library/modules/ui/scroll_view.sx | 149 +++++++ library/modules/ui/stacks.sx | 181 ++++++++ library/modules/ui/state.sx | 59 +++ library/modules/ui/stats_panel.sx | 63 +++ library/modules/ui/types.sx | 220 ++++++++++ library/modules/ui/view.sx | 23 + 20 files changed, 3830 insertions(+) create mode 100755 library/modules/ui/animation.sx create mode 100755 library/modules/ui/button.sx create mode 100755 library/modules/ui/dock.sx create mode 100755 library/modules/ui/events.sx create mode 100755 library/modules/ui/font.sx create mode 100755 library/modules/ui/gesture.sx create mode 100755 library/modules/ui/glyph_cache.sx create mode 100755 library/modules/ui/image.sx create mode 100755 library/modules/ui/label.sx create mode 100755 library/modules/ui/layout.sx create mode 100755 library/modules/ui/modifier.sx create mode 100755 library/modules/ui/pipeline.sx create mode 100755 library/modules/ui/render.sx create mode 100755 library/modules/ui/renderer.sx create mode 100755 library/modules/ui/scroll_view.sx create mode 100755 library/modules/ui/stacks.sx create mode 100755 library/modules/ui/state.sx create mode 100755 library/modules/ui/stats_panel.sx create mode 100755 library/modules/ui/types.sx create mode 100755 library/modules/ui/view.sx diff --git a/library/modules/ui/animation.sx b/library/modules/ui/animation.sx new file mode 100755 index 0000000..9757789 --- /dev/null +++ b/library/modules/ui/animation.sx @@ -0,0 +1,167 @@ +#import "modules/std.sx"; +#import "modules/math"; + +// --- Lerpable protocol (inline — static dispatch, no vtable) --- + +Lerpable :: protocol #inline { + lerp :: (b: Self, t: f32) -> Self; +} + +// --- Easing Functions --- + +ease_linear :: (t: f32) -> f32 { t; } +ease_in_quad :: (t: f32) -> f32 { t * t; } +ease_out_quad :: (t: f32) -> f32 { t * (2.0 - t); } +ease_in_out_quad :: (t: f32) -> f32 { + if t < 0.5 then 2.0 * t * t + else -1.0 + (4.0 - 2.0 * t) * t; +} +ease_out_cubic :: (t: f32) -> f32 { u := t - 1.0; u * u * u + 1.0; } + +// --- AnimatedFloat — duration-based --- + +AnimatedFloat :: struct { + current: f32; + from: f32; + to: f32; + elapsed: f32; + duration: f32; + easing: ?Closure(f32) -> f32; + active: bool; + + make :: (value: f32) -> AnimatedFloat { + AnimatedFloat.{ + current = value, + from = value, + to = value, + elapsed = 0.0, + duration = 0.0, + easing = null, + active = false + }; + } + + animate_to :: (self: *AnimatedFloat, target: f32, dur: f32, ease: Closure(f32) -> f32) { + self.from = self.current; + self.to = target; + self.elapsed = 0.0; + self.duration = dur; + self.easing = ease; + self.active = true; + } + + tick :: (self: *AnimatedFloat, dt: f32) { + if !self.active { return; } + self.elapsed += dt; + t := clamp(self.elapsed / self.duration, 0.0, 1.0); + eased := if ease := self.easing { ease(t); } else { t; }; + self.current = self.from + (self.to - self.from) * eased; + if t >= 1.0 { + self.current = self.to; + self.active = false; + } + } +} + +// --- SpringFloat — physics-based --- + +SpringFloat :: struct { + current: f32; + velocity: f32; + target: f32; + stiffness: f32; + damping: f32; + mass: f32; + threshold: f32; + + make :: (value: f32) -> SpringFloat { + SpringFloat.{ + current = value, + velocity = 0.0, + target = value, + stiffness = 200.0, + damping = 20.0, + mass = 1.0, + threshold = 0.01 + }; + } + + snappy :: (value: f32) -> SpringFloat { + SpringFloat.{ + current = value, + velocity = 0.0, + target = value, + stiffness = 300.0, + damping = 25.0, + mass = 1.0, + threshold = 0.01 + }; + } + + tick :: (self: *SpringFloat, dt: f32) { + if self.is_settled() { return; } + force := 0.0 - self.stiffness * (self.current - self.target); + damping_force := 0.0 - self.damping * self.velocity; + accel := (force + damping_force) / self.mass; + self.velocity += accel * dt; + self.current += self.velocity * dt; + } + + is_settled :: (self: *SpringFloat) -> bool { + abs(self.current - self.target) < self.threshold + and abs(self.velocity) < self.threshold; + } +} + +// --- Animated(T) — generic duration-based animation for any Lerpable type --- + +Animated :: struct ($T: Lerpable) { + current: T; + from: T; + to: T; + elapsed: f32; + duration: f32; + active: bool; + + make :: (value: T) -> Animated(T) { + Animated(T).{ + current = value, + from = value, + to = value, + elapsed = 0.0, + duration = 0.0, + active = false + }; + } + + // Jump immediately to value (no animation). Used to avoid animating from zero on first layout. + set_immediate :: (self: *Animated(T), value: T) { + self.current = value; + self.from = value; + self.to = value; + self.elapsed = 0.0; + self.active = false; + } + + // Start animating towards target. + animate_to :: (self: *Animated(T), target: T, dur: f32) { + self.from = self.current; + self.to = target; + self.elapsed = 0.0; + self.duration = dur; + self.active = true; + } + + tick :: (self: *Animated(T), dt: f32) { + if !self.active { return; } + self.elapsed += dt; + t := clamp(self.elapsed / self.duration, 0.0, 1.0); + self.current = self.from.lerp(self.to, t); + if t >= 1.0 { + self.current = self.to; + self.active = false; + } + } + + is_animating :: (self: *Animated(T)) -> bool { self.active; } +} diff --git a/library/modules/ui/button.sx b/library/modules/ui/button.sx new file mode 100755 index 0000000..0403c8f --- /dev/null +++ b/library/modules/ui/button.sx @@ -0,0 +1,91 @@ +#import "modules/std.sx"; +#import "modules/ui/types.sx"; +#import "modules/ui/render.sx"; +#import "modules/ui/events.sx"; +#import "modules/ui/view.sx"; +#import "modules/ui/label.sx"; +#import "modules/ui/font.sx"; + +ButtonStyle :: struct { + background: Color; + foreground: Color; + hover_bg: Color; + pressed_bg: Color; + corner_radius: f32; + padding: EdgeInsets; + + default :: () -> ButtonStyle { + ButtonStyle.{ + background = COLOR_BLUE, + foreground = COLOR_WHITE, + hover_bg = Color.rgb(0, 100, 220), + pressed_bg = Color.rgb(0, 80, 180), + corner_radius = 6.0, + padding = EdgeInsets.symmetric(16.0, 8.0) + }; + } +} + +Button :: struct { + label: string; + font_size: f32; + style: ButtonStyle; + on_tap: ?Closure(); + hovered: bool; + pressed: bool; + +} + +impl View for Button { + size_that_fits :: (self: *Button, proposal: ProposedSize) -> Size { + text_size := measure_text(self.label, self.font_size); + Size.{ + width = text_size.width + self.style.padding.horizontal(), + height = text_size.height + self.style.padding.vertical() + }; + } + + layout :: (self: *Button, bounds: Frame) {} + + render :: (self: *Button, ctx: *RenderContext, frame: Frame) { + bg := if self.pressed then self.style.pressed_bg + else if self.hovered then self.style.hover_bg + else self.style.background; + + ctx.add_rounded_rect(frame, bg, self.style.corner_radius); + + // Text centered in frame + text_size := measure_text(self.label, self.font_size); + text_x := frame.origin.x + (frame.size.width - text_size.width) * 0.5; + text_y := frame.origin.y + (frame.size.height - text_size.height) * 0.5; + text_frame := Frame.make(text_x, text_y, text_size.width, text_size.height); + ctx.add_text(text_frame, self.label, self.font_size, self.style.foreground); + } + + handle_event :: (self: *Button, event: *Event, frame: Frame) -> bool { + if event.* == { + case .mouse_moved: (d) { + self.hovered = frame.contains(d.position); + return false; + } + case .mouse_down: (d) { + if frame.contains(d.position) { + self.pressed = true; + return true; + } + } + case .mouse_up: (d) { + if self.pressed { + self.pressed = false; + if frame.contains(d.position) { + if handler := self.on_tap { + handler(); + } + } + return true; + } + } + } + false; + } +} diff --git a/library/modules/ui/dock.sx b/library/modules/ui/dock.sx new file mode 100755 index 0000000..6c55ee8 --- /dev/null +++ b/library/modules/ui/dock.sx @@ -0,0 +1,697 @@ +#import "modules/std.sx"; +#import "modules/math"; +#import "modules/ui/types.sx"; +#import "modules/ui/render.sx"; +#import "modules/ui/events.sx"; +#import "modules/ui/view.sx"; +#import "modules/ui/animation.sx"; +#import "modules/ui/font.sx"; + +// ============================================================================= +// DockZone — where a panel can be dropped +// ============================================================================= + +DockZone :: enum { + floating; + fill; + center; + top; + bottom; + left; + right; + top_left; + top_right; + bottom_left; + bottom_right; +} + +dock_zone_get_hint_frame :: (zone: DockZone, bounds: Frame, hint_size: f32) -> Frame { + pad :f32: 8.0; + cx := bounds.origin.x + (bounds.size.width - hint_size) * 0.5; + cy := bounds.origin.y + (bounds.size.height - hint_size) * 0.5; + + if zone == { + case .floating: Frame.zero(); + case .fill: Frame.make(cx, cy, hint_size * 1.2, hint_size * 1.2); + case .center: Frame.make(cx, cy, hint_size, hint_size); + case .top: Frame.make(cx, bounds.origin.y + pad, hint_size, hint_size); + case .bottom: Frame.make(cx, bounds.max_y() - hint_size - pad, hint_size, hint_size); + case .left: Frame.make(bounds.origin.x + pad, cy, hint_size, hint_size); + case .right: Frame.make(bounds.max_x() - hint_size - pad, cy, hint_size, hint_size); + case .top_left: Frame.make(bounds.origin.x + pad, bounds.origin.y + pad, hint_size, hint_size); + case .top_right: Frame.make(bounds.max_x() - hint_size - pad, bounds.origin.y + pad, hint_size, hint_size); + case .bottom_left: Frame.make(bounds.origin.x + pad, bounds.max_y() - hint_size - pad, hint_size, hint_size); + case .bottom_right:Frame.make(bounds.max_x() - hint_size - pad, bounds.max_y() - hint_size - pad, hint_size, hint_size); + } +} + +dock_zone_get_preview_frame :: (zone: DockZone, bounds: Frame) -> Frame { + hw := bounds.size.width * 0.5; + hh := bounds.size.height * 0.5; + + if zone == { + case .floating: Frame.zero(); + case .fill: bounds; + case .center: Frame.make(bounds.origin.x + bounds.size.width * 0.25, bounds.origin.y + bounds.size.height * 0.25, bounds.size.width * 0.5, bounds.size.height * 0.5); + case .top: Frame.make(bounds.origin.x, bounds.origin.y, bounds.size.width, hh); + case .bottom: Frame.make(bounds.origin.x, bounds.origin.y + hh, bounds.size.width, hh); + case .left: Frame.make(bounds.origin.x, bounds.origin.y, hw, bounds.size.height); + case .right: Frame.make(bounds.origin.x + hw, bounds.origin.y, hw, bounds.size.height); + case .top_left: Frame.make(bounds.origin.x, bounds.origin.y, hw, hh); + case .top_right: Frame.make(bounds.origin.x + hw, bounds.origin.y, hw, hh); + case .bottom_left: Frame.make(bounds.origin.x, bounds.origin.y + hh, hw, hh); + case .bottom_right:Frame.make(bounds.origin.x + hw, bounds.origin.y + hh, hw, hh); + } +} + +dock_zone_to_alignment :: (zone: DockZone) -> ?Alignment { + if zone == { + case .floating: null; + case .fill: ALIGN_CENTER; + case .center: ALIGN_CENTER; + case .top: ALIGN_TOP; + case .bottom: ALIGN_BOTTOM; + case .left: ALIGN_LEADING; + case .right: ALIGN_TRAILING; + case .top_left: ALIGN_TOP_LEADING; + case .top_right: ALIGN_TOP_TRAILING; + case .bottom_left: ALIGN_BOTTOM_LEADING; + case .bottom_right: ALIGN_BOTTOM_TRAILING; + } +} + +dock_zone_should_fill :: (zone: DockZone) -> bool { + zone == .fill; +} + +// ============================================================================= +// DockInteraction — persistent drag state for the dock +// ============================================================================= + +DOCK_ANIM_DURATION :f32: 0.19; // 190ms + +DockInteraction :: struct { + // Drag state + dragging_child: s64; // -1 = none + drag_start_pos: Point; + drag_offset: Point; + click_fraction_x: f32; + click_fraction_y: f32; + hovered_zone: s64; // -1 = none, else DockZone ordinal + + // Per-child state + natural_sizes: List(Size); + alignment_overrides: List(Alignment); + has_alignment_override: List(bool); + is_floating: List(bool); + is_fill: List(bool); + floating_positions: List(Point); + child_bounds: List(Frame); + anim_sizes: List(Animated(Size)); + header_pressed: List(bool); + + child_count: s64; + parent_allocator: Allocator; // GPA — used for persistent list growth + + init :: (self: *DockInteraction) { + self.dragging_child = -1; + self.drag_start_pos = Point.zero(); + self.drag_offset = Point.zero(); + self.click_fraction_x = 0.0; + self.click_fraction_y = 0.0; + self.hovered_zone = -1; + self.child_count = 0; + self.parent_allocator = context.allocator; // capture GPA at init time + + self.natural_sizes = List(Size).{}; + self.alignment_overrides = List(Alignment).{}; + self.has_alignment_override = List(bool).{}; + self.is_floating = List(bool).{}; + self.is_fill = List(bool).{}; + self.floating_positions = List(Point).{}; + self.child_bounds = List(Frame).{}; + self.anim_sizes = List(Animated(Size)).{}; + self.header_pressed = List(bool).{}; + } + + // BLOCKED on issue-0009: should use push instead of manual save/restore + ensure_capacity :: (self: *DockInteraction, count: s64) { + if self.child_count >= count { return; } + push Context.{ allocator = self.parent_allocator, data = context.data } { + while self.child_count < count { + self.natural_sizes.append(Size.zero()); + self.alignment_overrides.append(ALIGN_CENTER); + self.has_alignment_override.append(false); + self.is_floating.append(false); + self.is_fill.append(false); + self.floating_positions.append(Point.zero()); + self.child_bounds.append(Frame.zero()); + self.anim_sizes.append(Animated(Size).make(Size.zero())); + self.header_pressed.append(false); + self.child_count += 1; + } + } + } + + set_target_size :: (self: *DockInteraction, index: s64, target: Size) { + if index >= self.child_count { return; } + anim := @self.anim_sizes.items[index]; + cur := anim.to; + + // First time (target is ~0): jump immediately, don't animate from zero + if cur.width < 1.0 and cur.height < 1.0 { + anim.set_immediate(target); + return; + } + + // Only animate if target changed significantly + if abs(cur.width - target.width) > 0.5 or abs(cur.height - target.height) > 0.5 { + anim.animate_to(target, DOCK_ANIM_DURATION); + } + } + + get_animated_size :: (self: *DockInteraction, index: s64) -> Size { + if index >= self.child_count { return Size.zero(); } + (@self.anim_sizes.items[index]).current; + } + + tick_animations :: (self: *DockInteraction, dt: f32) { + i := 0; + while i < self.child_count { + anim := @self.anim_sizes.items[i]; + anim.tick(dt); + i += 1; + } + } + + get_hovered_dock_zone :: (self: *DockInteraction) -> ?DockZone { + if self.hovered_zone < 0 { return null; } + // Map ordinal back to DockZone + cast(DockZone) self.hovered_zone; + } + + set_hovered_dock_zone :: (self: *DockInteraction, zone: ?DockZone) { + if z := zone { + self.hovered_zone = xx z; + } else { + self.hovered_zone = -1; + } + } +} + +start_dragging :: (interaction: *DockInteraction, child_index: s64, pos: Point, panel_frame: Frame) { + interaction.dragging_child = child_index; + interaction.drag_start_pos = pos; + interaction.drag_offset = Point.zero(); + + if panel_frame.size.width > 0.0 { + interaction.click_fraction_x = (pos.x - panel_frame.origin.x) / panel_frame.size.width; + } else { + interaction.click_fraction_x = 0.0; + } + if panel_frame.size.height > 0.0 { + interaction.click_fraction_y = (pos.y - panel_frame.origin.y) / panel_frame.size.height; + } else { + interaction.click_fraction_y = 0.0; + } +} + +// ============================================================================= +// Helper functions +// ============================================================================= + +zone_by_index :: (i: s64) -> DockZone { + if i == { + case 0: .fill; + case 1: .center; + case 2: .top; + case 3: .bottom; + case 4: .left; + case 5: .right; + case 6: .top_left; + case 7: .top_right; + case 8: .bottom_left; + case 9: .bottom_right; + } +} + +find_hovered_zone :: (bounds: Frame, pos: Point, hint_size: f32, enable_corners: bool) -> ?DockZone { + count : s64 = if enable_corners then 10 else 6; + i : s64 = 0; + while i < count { + zone := zone_by_index(i); + hint := dock_zone_get_hint_frame(zone, bounds, hint_size); + expanded := hint.expand(8.0); + if expanded.contains(pos) { return zone; } + i += 1; + } + null; +} + +calculate_origin :: (bounds: Frame, child_size: Size, alignment: Alignment) -> Point { + x : f32 = bounds.origin.x; + if alignment.h == .center { + x = bounds.origin.x + (bounds.size.width - child_size.width) * 0.5; + } + if alignment.h == .trailing { + x = bounds.origin.x + bounds.size.width - child_size.width; + } + + y : f32 = bounds.origin.y; + if alignment.v == .center { + y = bounds.origin.y + (bounds.size.height - child_size.height) * 0.5; + } + if alignment.v == .bottom { + y = bounds.origin.y + bounds.size.height - child_size.height; + } + + Point.{ x = x, y = y }; +} + +get_size_proposal_for_alignment :: (alignment: Alignment, bounds_size: Size, is_fill: bool) -> ProposedSize { + if is_fill { return ProposedSize.fixed(bounds_size.width, bounds_size.height); } + + // Edge docking: constrain one axis, leave other natural + if alignment.h == .leading or alignment.h == .trailing { + if alignment.v == .center { + return ProposedSize.{ width = null, height = bounds_size.height }; + } + } + if alignment.v == .top or alignment.v == .bottom { + if alignment.h == .center { + return ProposedSize.{ width = bounds_size.width, height = null }; + } + } + // Center or corners: natural size + ProposedSize.flexible(); +} + +get_final_size_for_alignment :: (alignment: Alignment, child_size: Size, bounds_size: Size, is_fill: bool) -> Size { + if is_fill { return bounds_size; } + + // Left/Right edges: fill height + if alignment.h == .leading or alignment.h == .trailing { + if alignment.v == .center { + return Size.{ width = child_size.width, height = bounds_size.height }; + } + } + // Top/Bottom edges: fill width + if alignment.v == .top or alignment.v == .bottom { + if alignment.h == .center { + return Size.{ width = bounds_size.width, height = child_size.height }; + } + } + // Center or corners: natural size + child_size; +} + +draw_zone_indicator :: (ctx: *RenderContext, frame: Frame, zone: DockZone, color: Color) { + indicator_size := frame.size.width * 0.4; + cx := frame.mid_x(); + cy := frame.mid_y(); + offset := frame.size.width * 0.15; + + if zone == { + case .floating: {} + case .fill: { + s := indicator_size * 0.8; + ctx.add_rect(Frame.make(cx - s * 0.5, cy - s * 0.5, s, s), color); + } + case .center: { + s := indicator_size * 0.5; + ctx.add_rect(Frame.make(cx - s * 0.5, cy - s * 0.5, s, s), color); + } + case .top: { + ctx.add_rect(Frame.make(cx - indicator_size * 0.5, cy - offset - indicator_size * 0.25, indicator_size, indicator_size * 0.5), color); + } + case .bottom: { + ctx.add_rect(Frame.make(cx - indicator_size * 0.5, cy + offset - indicator_size * 0.25, indicator_size, indicator_size * 0.5), color); + } + case .left: { + ctx.add_rect(Frame.make(cx - offset - indicator_size * 0.25, cy - indicator_size * 0.5, indicator_size * 0.5, indicator_size), color); + } + case .right: { + ctx.add_rect(Frame.make(cx + offset - indicator_size * 0.25, cy - indicator_size * 0.5, indicator_size * 0.5, indicator_size), color); + } + case .top_left: { + s := indicator_size * 0.5; + ctx.add_rect(Frame.make(cx - offset, cy - offset, s, s), color); + } + case .top_right: { + s := indicator_size * 0.5; + ctx.add_rect(Frame.make(cx + offset - s, cy - offset, s, s), color); + } + case .bottom_left: { + s := indicator_size * 0.5; + ctx.add_rect(Frame.make(cx - offset, cy + offset - s, s, s), color); + } + case .bottom_right: { + s := indicator_size * 0.5; + ctx.add_rect(Frame.make(cx + offset - s, cy + offset - s, s, s), color); + } + } +} + +// ============================================================================= +// DockPanel — a draggable panel within a Dock +// ============================================================================= + +DockPanel :: struct { + child: ViewChild; + title: string; + dock: Alignment; + fill: bool; + background: Color; + header_background: Color; + header_text_color: Color; + corner_radius: f32; + header_height: f32; + dock_interaction: *DockInteraction; + panel_index: s64; + + DEFAULT_BG :Color: Color.rgba(26, 26, 31, 242); + DEFAULT_HEADER_BG :Color: Color.rgba(38, 38, 46, 255); + DEFAULT_HEADER_TEXT:Color: COLOR_WHITE; + DEFAULT_RADIUS :f32: 8.0; + DEFAULT_HEADER_H :f32: 28.0; + + make :: (title: string, dock: Alignment, content: View) -> DockPanel { + DockPanel.{ + child = .{ view = content }, + title = title, + dock = dock, + fill = false, + background = DockPanel.DEFAULT_BG, + header_background = DockPanel.DEFAULT_HEADER_BG, + header_text_color = DockPanel.DEFAULT_HEADER_TEXT, + corner_radius = DockPanel.DEFAULT_RADIUS, + header_height = DockPanel.DEFAULT_HEADER_H, + dock_interaction = null, // set by Dock.add_panel + panel_index = 0 + }; + } +} + +impl View for DockPanel { + size_that_fits :: (self: *DockPanel, proposal: ProposedSize) -> Size { + content_size := self.child.view.size_that_fits(ProposedSize.{ width = proposal.width, height = null }); + w := if pw := proposal.width { min(content_size.width, pw); } else { content_size.width; }; + Size.{ width = w, height = content_size.height + self.header_height }; + } + + layout :: (self: *DockPanel, bounds: Frame) { + content_frame := Frame.make( + bounds.origin.x, + bounds.origin.y + self.header_height, + bounds.size.width, + bounds.size.height - self.header_height + ); + self.child.computed_frame = content_frame; + self.child.view.layout(content_frame); + } + + render :: (self: *DockPanel, ctx: *RenderContext, frame: Frame) { + // Panel background + ctx.add_rounded_rect(frame, self.background, self.corner_radius); + + // Header background + header_frame := Frame.make(frame.origin.x, frame.origin.y, frame.size.width, self.header_height); + ctx.add_rounded_rect(header_frame, self.header_background, self.corner_radius); + + // Title text + title_size := measure_text(self.title, 12.0); + title_x := header_frame.origin.x + (header_frame.size.width - title_size.width) * 0.5; + title_y := header_frame.origin.y + (header_frame.size.height - title_size.height) * 0.5; + title_frame := Frame.make(title_x, title_y, title_size.width, title_size.height); + ctx.add_text(title_frame, self.title, 12.0, self.header_text_color); + + // Child content + self.child.view.render(ctx, self.child.computed_frame); + } + + handle_event :: (self: *DockPanel, event: *Event, frame: Frame) -> bool { + header_frame := Frame.make(frame.origin.x, frame.origin.y, frame.size.width, self.header_height); + idx := self.panel_index; + + if event.* == { + case .mouse_down: (d) { + if header_frame.contains(d.position) { + self.dock_interaction.header_pressed.items[idx] = true; + start_dragging(self.dock_interaction, idx, d.position, frame); + return true; + } + } + case .mouse_up: (d) { + if self.dock_interaction.header_pressed.items[idx] { + self.dock_interaction.header_pressed.items[idx] = false; + } + } + } + + // Forward to child content + self.child.view.handle_event(event, self.child.computed_frame); + } +} + +// ============================================================================= +// Dock — dockable container with drag-and-drop zones +// ============================================================================= + +// Global delta_time pointer — set by main.sx +g_dock_delta_time : *f32 = null; + +Dock :: struct { + children: List(ViewChild); + alignments: List(Alignment); + interaction: *DockInteraction; // heap-allocated, shared with DockPanels + + // Config + background: ?Color; + corner_radius: f32; + hint_size: f32; + hint_color: Color; + hint_active_color: Color; + preview_color: Color; + enable_corners: bool; + on_dock: ?Closure(s64, DockZone); + + make :: (interaction: *DockInteraction) -> Dock { + d : Dock = ---; + d.children = List(ViewChild).{}; + d.alignments = List(Alignment).{}; + d.interaction = interaction; + d.background = null; + d.corner_radius = 0.0; + d.hint_size = 40.0; + d.hint_color = Color.rgba(77, 153, 255, 153); + d.hint_active_color = Color.rgba(77, 153, 255, 230); + d.preview_color = Color.rgba(77, 153, 255, 64); + d.enable_corners = true; + d.on_dock = null; + d; + } + + add_panel :: (self: *Dock, panel: DockPanel) { + idx := self.children.len; + p := panel; + p.dock_interaction = self.interaction; // share heap pointer + p.panel_index = idx; + self.alignments.append(panel.dock); + self.children.append(.{ view = p }); + + // Apply initial fill flag + if panel.fill { + self.interaction.ensure_capacity(idx + 1); + self.interaction.is_fill.items[idx] = true; + } + } +} + +impl View for Dock { + size_that_fits :: (self: *Dock, proposal: ProposedSize) -> Size { + Size.{ + width = proposal.width ?? 800.0, + height = proposal.height ?? 600.0 + }; + } + + layout :: (self: *Dock, bounds: Frame) { + interaction := self.interaction; + interaction.ensure_capacity(self.children.len); + + // Tick animations (g_dock_delta_time is always set before main loop) + dt : f32 = g_dock_delta_time.*; + interaction.tick_animations(dt); + + i : s64 = 0; + while i < self.children.len { + child := @self.children.items[i]; + + natural_size := child.view.size_that_fits(ProposedSize.flexible()); + interaction.natural_sizes.items[i] = natural_size; + + is_being_dragged := interaction.dragging_child == i; + + fl_val : bool = interaction.is_floating.items[i]; + if fl_val { + // Floating: use natural size, position from stored floating pos + child_size := natural_size; + origin := interaction.floating_positions.items[i]; + origin.x += bounds.origin.x; + origin.y += bounds.origin.y; + + // Store bounds for hit testing (before drag offset) + interaction.child_bounds.items[i] = Frame.make(origin.x, origin.y, child_size.width, child_size.height); + + // Apply drag offset if this is the dragged child + if is_being_dragged { + origin.x += interaction.drag_offset.x; + origin.y += interaction.drag_offset.y; + } + + child.computed_frame = Frame.make(origin.x, origin.y, child_size.width, child_size.height); + child.view.layout(child.computed_frame); + } else { + // Docked: use alignment-based sizing + has_ovr : bool = interaction.has_alignment_override.items[i]; + alignment := if has_ovr + then interaction.alignment_overrides.items[i] + else self.alignments.items[i]; + + is_fill := interaction.is_fill.items[i] and !is_being_dragged; + + size_proposal := if is_being_dragged + then ProposedSize.flexible() + else get_size_proposal_for_alignment(alignment, bounds.size, is_fill); + + child_size := child.view.size_that_fits(size_proposal); + + target_size := if is_being_dragged + then child_size + else get_final_size_for_alignment(alignment, child_size, bounds.size, is_fill); + + // Animate size transitions + interaction.set_target_size(i, target_size); + final_size := interaction.get_animated_size(i); + + // Position + origin : Point = ---; + if is_being_dragged { + current_touch := interaction.drag_start_pos.add(interaction.drag_offset); + origin.x = current_touch.x - interaction.click_fraction_x * final_size.width; + origin.y = current_touch.y - interaction.click_fraction_y * final_size.height; + } else { + origin = calculate_origin(bounds, final_size, alignment); + } + + if !is_being_dragged { + interaction.child_bounds.items[i] = Frame.make(origin.x, origin.y, final_size.width, final_size.height); + } + + child.computed_frame = Frame.make(origin.x, origin.y, final_size.width, final_size.height); + child.view.layout(child.computed_frame); + } + + i += 1; + } + } + + render :: (self: *Dock, ctx: *RenderContext, frame: Frame) { + // Background + if bg := self.background { + if self.corner_radius > 0.0 { + ctx.add_rounded_rect(frame, bg, self.corner_radius); + } else { + ctx.add_rect(frame, bg); + } + } + + // Draw children + i : s64 = 0; + while i < self.children.len { + child := @self.children.items[i]; + child.view.render(ctx, child.computed_frame); + i += 1; + } + + // Draw drag overlay when dragging + if self.interaction.dragging_child >= 0 { + // Preview overlay for hovered zone + if zone := self.interaction.get_hovered_dock_zone() { + preview_frame := dock_zone_get_preview_frame(zone, frame); + ctx.add_rounded_rect(preview_frame, self.preview_color, 8.0); + } + + // Zone hint indicators + count : s64 = if self.enable_corners then 10 else 6; + j : s64 = 0; + while j < count { + zone := zone_by_index(j); + hint_frame := dock_zone_get_hint_frame(zone, frame, self.hint_size); + is_hovered := self.interaction.hovered_zone == xx zone; + color := if is_hovered then self.hint_active_color else self.hint_color; + ctx.add_rounded_rect(hint_frame, color, self.hint_size * 0.25); + draw_zone_indicator(ctx, hint_frame, zone, COLOR_WHITE); + j += 1; + } + } + } + + handle_event :: (self: *Dock, event: *Event, frame: Frame) -> bool { + interaction := self.interaction; + + // Pre-handle: intercept drag events when actively dragging + if interaction.dragging_child >= 0 { + if event.* == { + case .mouse_moved: (d) { + interaction.drag_offset = d.position.sub(interaction.drag_start_pos); + // Update hovered zone + zone := find_hovered_zone(frame, d.position, self.hint_size, self.enable_corners); + interaction.set_hovered_dock_zone(zone); + return true; + } + case .mouse_up: (d) { + child_idx := interaction.dragging_child; + if child_idx >= 0 and child_idx < self.children.len { + if zone := interaction.get_hovered_dock_zone() { + // Dock to zone + if alignment := dock_zone_to_alignment(zone) { + interaction.alignment_overrides.items[child_idx] = alignment; + interaction.has_alignment_override.items[child_idx] = true; + } + interaction.is_floating.items[child_idx] = false; + interaction.is_fill.items[child_idx] = dock_zone_should_fill(zone); + } else { + // Float: compute floating position from current cursor + natural_size := interaction.natural_sizes.items[child_idx]; + fp_x := d.position.x - interaction.click_fraction_x * natural_size.width - frame.origin.x; + fp_y := d.position.y - interaction.click_fraction_y * natural_size.height - frame.origin.y; + interaction.floating_positions.items[child_idx] = Point.{ x = fp_x, y = fp_y }; + interaction.is_floating.items[child_idx] = true; + interaction.is_fill.items[child_idx] = false; + } + } + + // Reset drag state + interaction.dragging_child = -1; + interaction.drag_offset = Point.zero(); + interaction.click_fraction_x = 0.0; + interaction.click_fraction_y = 0.0; + interaction.hovered_zone = -1; + return true; + } + } + } + + // Forward to children (reverse order: last drawn = top = first to handle) + i := self.children.len - 1; + while i >= 0 { + child := @self.children.items[i]; + if child.view.handle_event(event, child.computed_frame) { + return true; + } + i -= 1; + } + false; + } +} diff --git a/library/modules/ui/events.sx b/library/modules/ui/events.sx new file mode 100755 index 0000000..81b40ed --- /dev/null +++ b/library/modules/ui/events.sx @@ -0,0 +1,96 @@ +#import "modules/std.sx"; +#import "modules/sdl3.sx"; +#import "modules/ui/types.sx"; + +MouseButton :: enum { + none; + left; + middle; + right; +} + +MouseButtonData :: struct { position: Point; button: MouseButton; } +MouseMotionData :: struct { position: Point; delta: Point; } +MouseWheelData :: struct { position: Point; delta: Point; } +KeyData :: struct { key: u32; } +ResizeData :: struct { size: Size; } + +Event :: enum { + none; + quit; + mouse_down: MouseButtonData; + mouse_up: MouseButtonData; + mouse_moved: MouseMotionData; + mouse_wheel: MouseWheelData; + key_down: KeyData; + key_up: KeyData; + text_input: string; + window_resize: ResizeData; +} + +event_position :: (e: *Event) -> ?Point { + if e.* == { + case .mouse_down: (d) { return d.position; } + case .mouse_up: (d) { return d.position; } + case .mouse_moved: (d) { return d.position; } + case .mouse_wheel: (d) { return d.position; } + } + null; +} + +// Translate SDL_Event → our Event type +translate_sdl_event :: (sdl: *SDL_Event) -> Event { + if sdl.* == { + case .quit: { + return .quit; + } + case .key_down: (data) { + return .key_down(.{ key = xx data.key }); + } + case .key_up: (data) { + return .key_up(.{ key = xx data.key }); + } + case .mouse_motion: (data) { + return .mouse_moved(.{ + position = .{ x = data.x, y = data.y }, + delta = .{ x = data.xrel, y = data.yrel } + }); + } + case .mouse_button_down: (data) { + btn :MouseButton = if data.button == { + case 1: .left; + case 2: .middle; + case 3: .right; + else: .none; + }; + return .mouse_down(.{ + position = .{ x = data.x, y = data.y }, + button = btn + }); + } + case .mouse_button_up: (data) { + btn :MouseButton = if data.button == { + case 1: .left; + case 2: .middle; + case 3: .right; + else: .none; + }; + return .mouse_up(.{ + position = .{ x = data.x, y = data.y }, + button = btn + }); + } + case .mouse_wheel: (data) { + return .mouse_wheel(.{ + position = .{ x = data.mouse_x, y = data.mouse_y }, + delta = .{ x = data.x, y = data.y } + }); + } + case .window_resized: (data) { + return .window_resize(.{ + size = .{ width = xx data.data1, height = xx data.data2 } + }); + } + } + .none; +} diff --git a/library/modules/ui/font.sx b/library/modules/ui/font.sx new file mode 100755 index 0000000..c1eb056 --- /dev/null +++ b/library/modules/ui/font.sx @@ -0,0 +1,20 @@ +#import "modules/std.sx"; +#import "modules/ui/types.sx"; +#import "modules/ui/glyph_cache.sx"; + +// Global glyph cache pointer for views (Label, Button) to access +g_font : *GlyphCache = null; + +set_global_font :: (font: *GlyphCache) { + g_font = font; +} + +// Convenience measurement function for views +measure_text :: (text: string, font_size: f32) -> Size { + if g_font == null { + // Fallback approximate measurement + scale := font_size / 16.0; + return Size.{ width = xx text.len * 8.0 * scale, height = font_size }; + } + g_font.measure_text(text, font_size); +} diff --git a/library/modules/ui/gesture.sx b/library/modules/ui/gesture.sx new file mode 100755 index 0000000..8ba2d9a --- /dev/null +++ b/library/modules/ui/gesture.sx @@ -0,0 +1,128 @@ +#import "modules/std.sx"; +#import "modules/math"; +#import "modules/ui/types.sx"; +#import "modules/ui/events.sx"; + +GesturePhase :: enum { + possible; + began; + changed; + ended; + cancelled; + failed; +} + +// --- TapGesture --- + +TapValue :: struct { + location: Point; + count: s32; +} + +TapGesture :: struct { + count: s32; + on_tap: ?Closure(); + phase: GesturePhase; + tap_count: s32; + start_position: Point; + + TAP_THRESHOLD :f32: 10.0; + + handle_event :: (self: *TapGesture, event: *Event, frame: Frame) -> bool { + if event.* == { + case .mouse_down: (d) { + if frame.contains(d.position) { + self.phase = .began; + self.start_position = d.position; + return true; + } + } + case .mouse_moved: (d) { + if self.phase == .began { + if self.start_position.distance(d.position) > TapGesture.TAP_THRESHOLD { + self.phase = .failed; + } + } + } + case .mouse_up: (d) { + if self.phase == .began { + if frame.contains(d.position) { + self.tap_count += 1; + if self.tap_count >= self.count { + if handler := self.on_tap { handler(); } + self.tap_count = 0; + } + } + } + self.phase = .possible; + return true; + } + } + false; + } +} + +// --- DragGesture --- + +DragValue :: struct { + location: Point; + start_location: Point; + translation: Point; +} + +DragGesture :: struct { + min_distance: f32; + on_changed: ?Closure(DragValue); + on_ended: ?Closure(DragValue); + phase: GesturePhase; + start_location: Point; + current_location: Point; + + make_value :: (self: *DragGesture) -> DragValue { + DragValue.{ + location = self.current_location, + start_location = self.start_location, + translation = self.current_location.sub(self.start_location) + }; + } + + handle_event :: (self: *DragGesture, event: *Event, frame: Frame) -> bool { + if event.* == { + case .mouse_down: (d) { + if frame.contains(d.position) { + self.phase = .possible; + self.start_location = d.position; + self.current_location = d.position; + return true; + } + } + case .mouse_moved: (d) { + if self.phase == .possible { + self.current_location = d.position; + if self.start_location.distance(d.position) >= self.min_distance { + self.phase = .began; + if handler := self.on_changed { handler(self.make_value()); } + } + return true; + } + if self.phase == .began or self.phase == .changed { + self.current_location = d.position; + self.phase = .changed; + if handler := self.on_changed { handler(self.make_value()); } + return true; + } + } + case .mouse_up: (d) { + if self.phase == .began or self.phase == .changed { + self.current_location = d.position; + self.phase = .ended; + if handler := self.on_ended { handler(self.make_value()); } + self.phase = .possible; + return true; + } + self.phase = .possible; + } + } + false; + } +} diff --git a/library/modules/ui/glyph_cache.sx b/library/modules/ui/glyph_cache.sx new file mode 100755 index 0000000..1e16e0b --- /dev/null +++ b/library/modules/ui/glyph_cache.sx @@ -0,0 +1,613 @@ +#import "modules/std.sx"; +#import "modules/opengl.sx"; +#import "modules/stb_truetype.sx"; +#import "modules/ui/types.sx"; + +// Cached glyph data with UV coordinates into the atlas texture +CachedGlyph :: struct { + uv_x: f32; + uv_y: f32; + uv_w: f32; + uv_h: f32; + width: f32; + height: f32; + offset_x: f32; + offset_y: f32; + advance: f32; +} + +// Cache entry: key + glyph data +GlyphEntry :: struct { + key: u32; + glyph: CachedGlyph; +} + +// Quantize font size to half-point increments to limit cache entries. +// e.g., 13.0 -> 26, 13.5 -> 27, 14.0 -> 28 +quantize_size :: (font_size: f32) -> u16 { + xx (font_size * 2.0 + 0.5); +} + +dequantize_size :: (q: u16) -> f32 { + xx q / 2.0; +} + +// Pack (glyph_index, size_quantized) into a single u32 for fast comparison +make_glyph_key :: (glyph_index: u16, size_quantized: u16) -> u32 { + (xx glyph_index << 16) | xx size_quantized; +} + +// Shaped glyph — output of text shaping (positioned glyph with index) +ShapedGlyph :: struct { + glyph_index: u16; + x: f32; // horizontal position (logical units, cumulative) + y: f32; // vertical offset (logical units) + advance: f32; // advance width (logical units) +} + +is_ascii :: (text: string) -> bool { + i : s64 = 0; + while i < text.len { + if text[i] >= 128 { return false; } + i += 1; + } + true; +} + +// kbts constants (C enum values) +KBTS_DIRECTION_DONT_KNOW :u32: 0; +KBTS_LANGUAGE_DONT_KNOW :u32: 0; +KBTS_USER_ID_GENERATION_MODE_CODEPOINT_INDEX :u32: 0; + +// SX structs matching kbts C struct layouts (64-bit). +// We define these in SX to access fields directly, casting from opaque C pointers. + +KbtsGlyphIterator :: struct { + glyph_storage: *void; + current_glyph: *void; + last_advance_x: s32; + x: s32; + y: s32; +} + +KbtsRun :: struct { + font: *void; + script: u32; + paragraph_direction: u32; + direction: u32; + flags: u32; + glyphs: KbtsGlyphIterator; +} + +KbtsGlyph :: struct { + prev: *void; + next: *void; + codepoint: u32; + id: u16; + uid: u16; + user_id_or_codepoint_index: s32; + offset_x: s32; + offset_y: s32; + advance_x: s32; + advance_y: s32; +} + +// kbts_font_info2 base (simplified — we only need the Size field for dispatch) +KBTS_FONT_INFO_STRING_ID_COUNT :s32: 7; + +KbtsFontInfo2 :: struct { + size: u32; + strings: [7]*void; // char* array + string_lengths: [7]u16; + style_flags: u32; + weight: u32; + width: u32; +} + +KbtsFontInfo2_1 :: struct { + base: KbtsFontInfo2; + units_per_em: u16; + x_min: s16; + y_min: s16; + x_max: s16; + y_max: s16; + ascent: s16; + descent: s16; + line_gap: s16; +} + +GLYPH_ATLAS_W :s32: 1024; +GLYPH_ATLAS_H :s32: 1024; +FONTINFO_SIZE :s64: 256; + +PackResult :: struct { + x, y: s32; +} + +// Dynamic glyph cache with on-demand rasterization and texture atlas packing. +GlyphCache :: struct { + // Font data + font_info: *void; // heap-allocated stbtt_fontinfo (256 bytes) + font_data: *void; // raw TTF file bytes (kept alive for stbtt) + + // Atlas texture (GPU) + texture_id: u32; + atlas_width: s32; + atlas_height: s32; + + // Atlas bitmap (CPU-side for updates) + bitmap: [*]u8; + + // Shelf packer state + shelf_y: s32; + shelf_height: s32; + cursor_x: s32; + padding: s32; + + // Glyph lookup cache + entries: List(GlyphEntry); + + // Hash table for O(1) glyph lookup (open addressing, linear probing) + hash_keys: [*]u32; // key per slot (0 = empty sentinel) + hash_vals: [*]s32; // index into entries list + hash_cap: s64; // table capacity (power of 2) + + // Dirty tracking for texture upload + dirty: bool; + + // Font vertical metrics (at reference size 1.0 — scale by font_size) + ascent: f32; + descent: f32; + line_gap: f32; + + // HiDPI: physical pixels per logical pixel (e.g. 2.0 on Retina) + dpi_scale: f32; + inv_dpi: f32; + + // Text shaping (kb_text_shape) + shape_ctx: *void; + shape_font: *void; + units_per_em: u16; + font_data_size: s32; + shaped_buf: List(ShapedGlyph); + + // Shape cache: skip reshaping if same text + size as last call + last_shape_ptr: [*]u8; + last_shape_len: s64; + last_shape_size_q: u16; + + init :: (self: *GlyphCache, path: [:0]u8, default_size: f32) { + // Zero out the entire struct first (parent may be uninitialized with = ---) + memset(self, 0, size_of(GlyphCache)); + + // Load font file + file_size : s32 = 0; + font_data := read_file_bytes(path, @file_size); + if font_data == null { + out("Failed to load font: "); + out(path); + out("\n"); + return; + } + self.font_data = xx font_data; + self.font_data_size = file_size; + + // Init stbtt_fontinfo + self.font_info = context.allocator.alloc(FONTINFO_SIZE); + memset(self.font_info, 0, FONTINFO_SIZE); + stbtt_InitFont(self.font_info, font_data, 0); + + // Get font vertical metrics (in unscaled font units) + ascent_i : s32 = 0; + descent_i : s32 = 0; + linegap_i : s32 = 0; + stbtt_GetFontVMetrics(self.font_info, @ascent_i, @descent_i, @linegap_i); + + // Store unscaled metrics — we'll scale per font_size in measure_text + self.ascent = xx ascent_i; + self.descent = xx descent_i; + self.line_gap = xx linegap_i; + + // Init text shaping context + self.shape_ctx = xx kbts_CreateShapeContext(xx 0, xx 0); + if self.shape_ctx != null { + self.shape_font = xx kbts_ShapePushFontFromMemory(xx self.shape_ctx, self.font_data, file_size, 0); + // Get font metrics (units_per_em) from kbts + kb_info : KbtsFontInfo2_1 = ---; + memset(@kb_info, 0, size_of(KbtsFontInfo2_1)); + kb_info.base.size = xx size_of(KbtsFontInfo2_1); + kbts_GetFontInfo2(xx self.shape_font, xx @kb_info); + self.units_per_em = kb_info.units_per_em; + } + + // Allocate atlas bitmap + self.atlas_width = GLYPH_ATLAS_W; + self.atlas_height = GLYPH_ATLAS_H; + bitmap_size : s64 = xx self.atlas_width * xx self.atlas_height; + self.bitmap = xx context.allocator.alloc(bitmap_size); + memset(self.bitmap, 0, bitmap_size); + + // Shelf packer init + self.shelf_y = 0; + self.shelf_height = 0; + self.cursor_x = 0; + self.padding = 1; + + self.dirty = false; + self.dpi_scale = 1.0; + self.inv_dpi = 1.0; + + // Init hash table (256 slots) + self.hash_cap = 256; + hash_bytes : s64 = self.hash_cap * 4; // u32 per slot + self.hash_keys = xx context.allocator.alloc(hash_bytes); + memset(self.hash_keys, 0, hash_bytes); + val_bytes : s64 = self.hash_cap * 8; // s64 per slot (s32 would suffice but alignment) + self.hash_vals = xx context.allocator.alloc(val_bytes); + + // Create OpenGL texture + glGenTextures(1, @self.texture_id); + glBindTexture(GL_TEXTURE_2D, self.texture_id); + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + glTexImage2D(GL_TEXTURE_2D, 0, xx GL_R8, self.atlas_width, self.atlas_height, 0, GL_RED, GL_UNSIGNED_BYTE, self.bitmap); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, xx GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, xx GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, xx GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, xx GL_CLAMP_TO_EDGE); + + out("GlyphCache initialized: "); + out(path); + out("\n"); + } + + // Look up or rasterize a glyph, returning a pointer to its cached entry. + // Returns null for glyphs with no outline AND zero advance (shouldn't happen for valid chars). + get_or_rasterize :: (self: *GlyphCache, glyph_index: u16, font_size: f32) -> *CachedGlyph { + size_q := quantize_size(font_size); + key := make_glyph_key(glyph_index, size_q); + + // Hash table lookup (open addressing, linear probing) + mask := self.hash_cap - 1; + slot : s64 = xx ((key * 2654435761) >> 24) & xx mask; + while self.hash_keys[slot] != 0 { + if self.hash_keys[slot] == key { + return @self.entries.items[self.hash_vals[slot]].glyph; + } + slot = (slot + 1) & mask; + } + + // Cache miss — rasterize + actual_size := dequantize_size(size_q); + scale := stbtt_ScaleForPixelHeight(self.font_info, actual_size); + + // Get glyph bounding box + x0 : s32 = 0; + y0 : s32 = 0; + x1 : s32 = 0; + y1 : s32 = 0; + stbtt_GetGlyphBitmapBox(self.font_info, xx glyph_index, scale, scale, @x0, @y0, @x1, @y1); + + glyph_w := if x1 > x0 then x1 - x0 else 0; + glyph_h := if y1 > y0 then y1 - y0 else 0; + + // Get horizontal metrics + advance_i : s32 = 0; + lsb_i : s32 = 0; + stbtt_GetGlyphHMetrics(self.font_info, xx glyph_index, @advance_i, @lsb_i); + advance : f32 = xx advance_i * scale; + + // Zero-size glyph (e.g. space) — cache with advance only + if glyph_w == 0 or glyph_h == 0 { + entry := GlyphEntry.{ + key = key, + glyph = CachedGlyph.{ + uv_x = 0.0, uv_y = 0.0, uv_w = 0.0, uv_h = 0.0, + width = 0.0, height = 0.0, + offset_x = xx x0, offset_y = xx y0, + advance = advance + } + }; + self.entries.append(entry); + self.hash_insert(key, self.entries.len - 1); + return @self.entries.items[self.entries.len - 1].glyph; + } + + // Pack into atlas + pack := self.try_pack(glyph_w, glyph_h); + if pack.x < 0 { + // Atlas full — grow and retry + self.grow(); + return self.get_or_rasterize(glyph_index, font_size); + } + + // Rasterize directly into atlas bitmap + dest_offset : s64 = xx pack.y * xx self.atlas_width + xx pack.x; + stbtt_MakeGlyphBitmap( + self.font_info, + @self.bitmap[dest_offset], + glyph_w, glyph_h, + self.atlas_width, + scale, scale, + xx glyph_index + ); + self.dirty = true; + + // Compute normalized UV coordinates + atlas_wf : f32 = xx self.atlas_width; + atlas_hf : f32 = xx self.atlas_height; + + entry := GlyphEntry.{ + key = key, + glyph = CachedGlyph.{ + uv_x = xx pack.x / atlas_wf, + uv_y = xx pack.y / atlas_hf, + uv_w = xx glyph_w / atlas_wf, + uv_h = xx glyph_h / atlas_hf, + width = xx glyph_w, + height = xx glyph_h, + offset_x = xx x0, + offset_y = xx y0, + advance = advance + } + }; + self.entries.append(entry); + self.hash_insert(key, self.entries.len - 1); + return @self.entries.items[self.entries.len - 1].glyph; + } + + // Insert a key→index mapping into the hash table, growing if needed + hash_insert :: (self: *GlyphCache, key: u32, index: s64) { + // Grow if load factor > 70% + if self.entries.len * 10 > self.hash_cap * 7 { + self.hash_grow(); + } + mask := self.hash_cap - 1; + slot : s64 = xx ((key * 2654435761) >> 24) & xx mask; + while self.hash_keys[slot] != 0 { + slot = (slot + 1) & mask; + } + self.hash_keys[slot] = key; + self.hash_vals[slot] = xx index; + } + + // Double the hash table and rehash all entries + hash_grow :: (self: *GlyphCache) { + old_cap := self.hash_cap; + old_keys := self.hash_keys; + old_vals := self.hash_vals; + + self.hash_cap = old_cap * 2; + hash_bytes : s64 = self.hash_cap * 4; + self.hash_keys = xx context.allocator.alloc(hash_bytes); + memset(self.hash_keys, 0, hash_bytes); + val_bytes : s64 = self.hash_cap * 8; + self.hash_vals = xx context.allocator.alloc(val_bytes); + + // Rehash + mask := self.hash_cap - 1; + i : s64 = 0; + while i < old_cap { + k := old_keys[i]; + if k != 0 { + slot : s64 = xx ((k * 2654435761) >> 24) & xx mask; + while self.hash_keys[slot] != 0 { + slot = (slot + 1) & mask; + } + self.hash_keys[slot] = k; + self.hash_vals[slot] = old_vals[i]; + } + i += 1; + } + + context.allocator.dealloc(old_keys); + context.allocator.dealloc(old_vals); + } + + // Upload dirty atlas to GPU + flush :: (self: *GlyphCache) { + if self.dirty == false { return; } + glBindTexture(GL_TEXTURE_2D, self.texture_id); + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, self.atlas_width, self.atlas_height, GL_RED, GL_UNSIGNED_BYTE, self.bitmap); + self.dirty = false; + } + + // Shelf-based rectangle packer. + // Returns PackResult with x >= 0 on success, x = -1 if no space. + try_pack :: (self: *GlyphCache, w: s32, h: s32) -> PackResult { + padded_w := w + self.padding; + padded_h := h + self.padding; + + // Try fitting on the current shelf + eff_h := if self.shelf_height > padded_h then self.shelf_height else padded_h; + if self.cursor_x + padded_w <= self.atlas_width and self.shelf_y + eff_h <= self.atlas_height { + result := PackResult.{ x = self.cursor_x, y = self.shelf_y }; + self.cursor_x += padded_w; + if padded_h > self.shelf_height { + self.shelf_height = padded_h; + } + return result; + } + + // Start a new shelf + new_shelf_y := self.shelf_y + self.shelf_height; + if new_shelf_y + padded_h <= self.atlas_height and padded_w <= self.atlas_width { + self.shelf_y = new_shelf_y; + self.shelf_height = padded_h; + self.cursor_x = padded_w; + return PackResult.{ x = 0, y = new_shelf_y }; + } + + // No space + PackResult.{ x = 0 - 1, y = 0 - 1 }; + } + + // Grow the atlas by doubling dimensions + grow :: (self: *GlyphCache) { + new_w := self.atlas_width * 2; + new_h := self.atlas_height * 2; + new_size : s64 = xx new_w * xx new_h; + new_bitmap : [*]u8 = xx context.allocator.alloc(new_size); + memset(new_bitmap, 0, new_size); + + // Copy old rows into new bitmap + y : s32 = 0; + while y < self.atlas_height { + old_off : s64 = xx y * xx self.atlas_width; + new_off : s64 = xx y * xx new_w; + memcpy(@new_bitmap[new_off], @self.bitmap[old_off], xx self.atlas_width); + y += 1; + } + + context.allocator.dealloc(self.bitmap); + self.bitmap = new_bitmap; + self.atlas_width = new_w; + self.atlas_height = new_h; + + // Recreate GL texture + glDeleteTextures(1, @self.texture_id); + glGenTextures(1, @self.texture_id); + glBindTexture(GL_TEXTURE_2D, self.texture_id); + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + glTexImage2D(GL_TEXTURE_2D, 0, xx GL_R8, new_w, new_h, 0, GL_RED, GL_UNSIGNED_BYTE, new_bitmap); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, xx GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, xx GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, xx GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, xx GL_CLAMP_TO_EDGE); + + // Recompute UV coordinates for all cached glyphs + atlas_wf : f32 = xx new_w; + atlas_hf : f32 = xx new_h; + i : s64 = 0; + while i < self.entries.len { + g := @self.entries.items[i].glyph; + if g.width > 0.0 { + g.uv_x = g.uv_x / 2.0; + g.uv_y = g.uv_y / 2.0; + g.uv_w = g.width / atlas_wf; + g.uv_h = g.height / atlas_hf; + } + i += 1; + } + + self.dirty = false; + out("GlyphCache atlas grown\n"); + } + + set_dpi_scale :: (self: *GlyphCache, scale: f32) { + self.dpi_scale = scale; + self.inv_dpi = 1.0 / scale; + } + + // Get the scale factor for a logical font size + scale_for_size :: (self: *GlyphCache, font_size: f32) -> f32 { + stbtt_ScaleForPixelHeight(self.font_info, font_size); + } + + // Get scaled ascent for a logical font size + get_ascent :: (self: *GlyphCache, font_size: f32) -> f32 { + self.ascent * self.scale_for_size(font_size); + } + + // Get scaled line height for a logical font size + get_line_height :: (self: *GlyphCache, font_size: f32) -> f32 { + s := self.scale_for_size(font_size); + (self.ascent - self.descent + self.line_gap) * s; + } + + // Shape text into positioned glyphs. + // Uses ASCII fast-path (stbtt byte-by-byte) for pure ASCII, + // full kb_text_shape pipeline for Unicode/complex scripts. + // Results stored in self.shaped_buf (reused across calls). + shape_text :: (self: *GlyphCache, text: string, font_size: f32) { + // Check shape cache: skip if same text + size as last call + size_q := quantize_size(font_size); + if text.len > 0 and text.ptr == self.last_shape_ptr and text.len == self.last_shape_len and size_q == self.last_shape_size_q { + return; // shaped_buf already has the result + } + + self.shaped_buf.len = 0; + if text.len == 0 { return; } + + if is_ascii(text) { + self.shape_ascii(text, font_size); + } else { + self.shape_with_kb(text, font_size); + } + + // Update shape cache + self.last_shape_ptr = text.ptr; + self.last_shape_len = text.len; + self.last_shape_size_q = size_q; + } + + shape_ascii :: (self: *GlyphCache, text: string, font_size: f32) { + scale := stbtt_ScaleForPixelHeight(self.font_info, font_size); + total : f32 = 0.0; + i : s64 = 0; + while i < text.len { + ch : s32 = xx text[i]; + glyph_index : u16 = xx stbtt_FindGlyphIndex(self.font_info, ch); + + advance_i : s32 = 0; + lsb_i : s32 = 0; + stbtt_GetGlyphHMetrics(self.font_info, xx glyph_index, @advance_i, @lsb_i); + adv : f32 = xx advance_i * scale; + + self.shaped_buf.append(ShapedGlyph.{ + glyph_index = glyph_index, + x = total, + y = 0.0, + advance = adv + }); + total += adv; + i += 1; + } + } + + shape_with_kb :: (self: *GlyphCache, text: string, font_size: f32) { + if self.shape_ctx == null { + self.shape_ascii(text, font_size); + return; + } + + scale : f32 = font_size / xx self.units_per_em; + total : f32 = 0.0; + + kbts_ShapeBegin(xx self.shape_ctx, KBTS_DIRECTION_DONT_KNOW, KBTS_LANGUAGE_DONT_KNOW); + kbts_ShapeUtf8(xx self.shape_ctx, xx text.ptr, xx text.len, KBTS_USER_ID_GENERATION_MODE_CODEPOINT_INDEX); + kbts_ShapeEnd(xx self.shape_ctx); + + run : KbtsRun = ---; + while kbts_ShapeRun(xx self.shape_ctx, xx @run) != 0 { + glyph_ptr : *KbtsGlyph = null; + while kbts_GlyphIteratorNext(xx @run.glyphs, xx @glyph_ptr) != 0 { + if glyph_ptr == null { continue; } + gx := total + xx glyph_ptr.offset_x * scale; + gy : f32 = xx glyph_ptr.offset_y * scale; + adv : f32 = xx glyph_ptr.advance_x * scale; + + self.shaped_buf.append(ShapedGlyph.{ + glyph_index = glyph_ptr.id, + x = gx, + y = gy, + advance = adv + }); + total += adv; + } + } + } + + // Measure text at a logical font size using text shaping. + // Rasterizes at physical resolution (font_size * dpi_scale), returns logical dimensions. + measure_text :: (self: *GlyphCache, text: string, font_size: f32) -> Size { + self.shape_text(text, font_size); + width : f32 = 0.0; + i : s64 = 0; + while i < self.shaped_buf.len { + width += self.shaped_buf.items[i].advance; + i += 1; + } + Size.{ width = width, height = self.get_line_height(font_size) }; + } +} diff --git a/library/modules/ui/image.sx b/library/modules/ui/image.sx new file mode 100755 index 0000000..3f3c199 --- /dev/null +++ b/library/modules/ui/image.sx @@ -0,0 +1,36 @@ +#import "modules/std.sx"; +#import "modules/ui/types.sx"; +#import "modules/ui/render.sx"; +#import "modules/ui/events.sx"; +#import "modules/ui/view.sx"; + +ImageView :: struct { + texture_id: u32; + width: f32; + height: f32; + tint: Color; +} + +impl View for ImageView { + size_that_fits :: (self: *ImageView, proposal: ProposedSize) -> Size { + pw := proposal.width ?? self.width; + ph := proposal.height ?? self.height; + // Maintain aspect ratio: fit within proposal + aspect := self.width / self.height; + if pw / ph > aspect { + Size.{ width = ph * aspect, height = ph }; + } else { + Size.{ width = pw, height = pw / aspect }; + } + } + + layout :: (self: *ImageView, bounds: Frame) {} + + render :: (self: *ImageView, ctx: *RenderContext, frame: Frame) { + ctx.add_image(frame, self.texture_id); + } + + handle_event :: (self: *ImageView, event: *Event, frame: Frame) -> bool { + false; + } +} diff --git a/library/modules/ui/label.sx b/library/modules/ui/label.sx new file mode 100755 index 0000000..72f33df --- /dev/null +++ b/library/modules/ui/label.sx @@ -0,0 +1,38 @@ +#import "modules/std.sx"; +#import "modules/ui/types.sx"; +#import "modules/ui/render.sx"; +#import "modules/ui/events.sx"; +#import "modules/ui/view.sx"; +#import "modules/ui/font.sx"; + +Label :: struct { + text: string; + font_size: f32; + color: Color; + + make :: (text: string) -> Label { + Label.{ + text = text, + font_size = 14.0, + color = COLOR_WHITE + }; + } +} + +impl View for Label { + size_that_fits :: (self: *Label, proposal: ProposedSize) -> Size { + measure_text(self.text, self.font_size); + } + + layout :: (self: *Label, bounds: Frame) { + // Leaf view — nothing to place + } + + render :: (self: *Label, ctx: *RenderContext, frame: Frame) { + ctx.add_text(frame, self.text, self.font_size, self.color); + } + + handle_event :: (self: *Label, event: *Event, frame: Frame) -> bool { + false; + } +} diff --git a/library/modules/ui/layout.sx b/library/modules/ui/layout.sx new file mode 100755 index 0000000..035e9d7 --- /dev/null +++ b/library/modules/ui/layout.sx @@ -0,0 +1,152 @@ +#import "modules/std.sx"; +#import "modules/math"; +#import "modules/ui/types.sx"; +#import "modules/ui/view.sx"; + +// VStack layout: measure all children, stack vertically +// Width is constrained from parent; height is unspecified (children choose) +layout_vstack :: (children: *List(ViewChild), bounds: Frame, spacing: f32, alignment: HAlignment) { + n := children.len; + if n == 0 { return; } + + content_width := bounds.size.width; + + y := bounds.origin.y; + i := 0; + while i < n { + child := @children.items[i]; + child_size := child.view.size_that_fits(ProposedSize.{ + width = content_width, + height = null + }); + x_offset := align_h(alignment, child_size.width, content_width); + child.computed_frame = Frame.{ + origin = Point.{ x = bounds.origin.x + x_offset, y = y }, + size = child_size + }; + child.view.layout(child.computed_frame); + y = y + child_size.height + spacing; + i += 1; + } +} + +// HStack layout: measure all children, stack horizontally +// Height is constrained from parent; width is unspecified (children choose) +layout_hstack :: (children: *List(ViewChild), bounds: Frame, spacing: f32, alignment: VAlignment) { + n := children.len; + if n == 0 { return; } + + content_height := bounds.size.height; + + x := bounds.origin.x; + i := 0; + while i < n { + child := @children.items[i]; + child_size := child.view.size_that_fits(ProposedSize.{ + width = null, + height = content_height + }); + y_offset := align_v(alignment, child_size.height, content_height); + child.computed_frame = Frame.{ + origin = Point.{ x = x, y = bounds.origin.y + y_offset }, + size = child_size + }; + child.view.layout(child.computed_frame); + x = x + child_size.width + spacing; + i += 1; + } +} + +// ZStack layout: all children get same bounds, aligned +layout_zstack :: (children: *List(ViewChild), bounds: Frame, alignment: Alignment) { + n := children.len; + if n == 0 { return; } + + proposal := ProposedSize.{ + width = bounds.size.width, + height = bounds.size.height + }; + + i := 0; + while i < n { + child := @children.items[i]; + child_size := child.view.size_that_fits(proposal); + x_offset := align_h(alignment.h, child_size.width, bounds.size.width); + y_offset := align_v(alignment.v, child_size.height, bounds.size.height); + child.computed_frame = Frame.{ + origin = Point.{ x = bounds.origin.x + x_offset, y = bounds.origin.y + y_offset }, + size = child_size + }; + child.view.layout(child.computed_frame); + i += 1; + } +} + +// Measure helpers — compute stack size from children + +measure_vstack :: (children: *List(ViewChild), proposal: ProposedSize, spacing: f32) -> Size { + n := children.len; + if n == 0 { return Size.zero(); } + + max_width : f32 = 0.0; + total_height : f32 = 0.0; + + // Measure children: constrain width, leave height unspecified + child_proposal := ProposedSize.{ width = proposal.width, height = null }; + i := 0; + while i < n { + child_size := children.items[i].view.size_that_fits(child_proposal); + children.items[i].computed_frame.size = child_size; + if child_size.width > max_width { max_width = child_size.width; } + total_height = total_height + child_size.height; + i += 1; + } + + total_height = total_height + spacing * xx (n - 1); + + result_width := min(proposal.width ?? max_width, max_width); + Size.{ width = result_width, height = total_height }; +} + +measure_hstack :: (children: *List(ViewChild), proposal: ProposedSize, spacing: f32) -> Size { + n := children.len; + if n == 0 { return Size.zero(); } + + total_width : f32 = 0.0; + max_height : f32 = 0.0; + + // Measure children: constrain height, leave width unspecified + child_proposal := ProposedSize.{ width = null, height = proposal.height }; + i := 0; + while i < n { + child_size := children.items[i].view.size_that_fits(child_proposal); + children.items[i].computed_frame.size = child_size; + total_width = total_width + child_size.width; + if child_size.height > max_height { max_height = child_size.height; } + i += 1; + } + + total_width = total_width + spacing * xx (n - 1); + + result_height := min(proposal.height ?? max_height, max_height); + Size.{ width = total_width, height = result_height }; +} + +measure_zstack :: (children: *List(ViewChild), proposal: ProposedSize) -> Size { + n := children.len; + if n == 0 { return Size.zero(); } + + max_width : f32 = 0.0; + max_height : f32 = 0.0; + + i := 0; + while i < n { + child_size := children.items[i].view.size_that_fits(proposal); + children.items[i].computed_frame.size = child_size; + if child_size.width > max_width { max_width = child_size.width; } + if child_size.height > max_height { max_height = child_size.height; } + i += 1; + } + + Size.{ width = max_width, height = max_height }; +} diff --git a/library/modules/ui/modifier.sx b/library/modules/ui/modifier.sx new file mode 100755 index 0000000..019c4e5 --- /dev/null +++ b/library/modules/ui/modifier.sx @@ -0,0 +1,296 @@ +#import "modules/std.sx"; +#import "modules/math"; +#import "modules/ui/types.sx"; +#import "modules/ui/render.sx"; +#import "modules/ui/events.sx"; +#import "modules/ui/view.sx"; +#import "modules/ui/gesture.sx"; + +// --- PaddingModifier --- + +PaddingModifier :: struct { + child: ViewChild; + insets: EdgeInsets; +} + +impl View for PaddingModifier { + size_that_fits :: (self: *PaddingModifier, proposal: ProposedSize) -> Size { + iw: ?f32 = null; + ih: ?f32 = null; + if w := proposal.width { iw = w - self.insets.horizontal(); } + if h := proposal.height { ih = h - self.insets.vertical(); } + inner := ProposedSize.{ width = iw, height = ih }; + child_size := self.child.view.size_that_fits(inner); + Size.{ + width = child_size.width + self.insets.horizontal(), + height = child_size.height + self.insets.vertical() + }; + } + + layout :: (self: *PaddingModifier, bounds: Frame) { + self.child.computed_frame = bounds.inset(self.insets); + self.child.view.layout(self.child.computed_frame); + } + + render :: (self: *PaddingModifier, ctx: *RenderContext, frame: Frame) { + self.child.view.render(ctx, self.child.computed_frame); + } + + handle_event :: (self: *PaddingModifier, event: *Event, frame: Frame) -> bool { + self.child.view.handle_event(event, self.child.computed_frame); + } +} + +// --- FrameModifier --- + +FrameModifier :: struct { + child: ViewChild; + width: ?f32; + height: ?f32; +} + +impl View for FrameModifier { + size_that_fits :: (self: *FrameModifier, proposal: ProposedSize) -> Size { + pw := self.width ?? proposal.width ?? 0.0; + ph := self.height ?? proposal.height ?? 0.0; + child_proposal := ProposedSize.{ width = pw, height = ph }; + child_size := self.child.view.size_that_fits(child_proposal); + Size.{ + width = self.width ?? child_size.width, + height = self.height ?? child_size.height + }; + } + + layout :: (self: *FrameModifier, bounds: Frame) { + child_size := self.child.view.size_that_fits(ProposedSize.{ + width = self.width ?? bounds.size.width, + height = self.height ?? bounds.size.height + }); + // Center child within bounds + cx := bounds.origin.x + (bounds.size.width - child_size.width) * 0.5; + cy := bounds.origin.y + (bounds.size.height - child_size.height) * 0.5; + self.child.computed_frame = Frame.make(cx, cy, child_size.width, child_size.height); + self.child.view.layout(self.child.computed_frame); + } + + render :: (self: *FrameModifier, ctx: *RenderContext, frame: Frame) { + self.child.view.render(ctx, self.child.computed_frame); + } + + handle_event :: (self: *FrameModifier, event: *Event, frame: Frame) -> bool { + self.child.view.handle_event(event, self.child.computed_frame); + } +} + +// --- BackgroundModifier --- + +BackgroundModifier :: struct { + child: ViewChild; + color: Color; + corner_radius: f32; +} + +impl View for BackgroundModifier { + size_that_fits :: (self: *BackgroundModifier, proposal: ProposedSize) -> Size { + self.child.view.size_that_fits(proposal); + } + + layout :: (self: *BackgroundModifier, bounds: Frame) { + self.child.computed_frame = bounds; + self.child.view.layout(bounds); + } + + render :: (self: *BackgroundModifier, ctx: *RenderContext, frame: Frame) { + if self.corner_radius > 0.0 { + ctx.add_rounded_rect(frame, self.color, self.corner_radius); + } else { + ctx.add_rect(frame, self.color); + } + self.child.view.render(ctx, self.child.computed_frame); + } + + handle_event :: (self: *BackgroundModifier, event: *Event, frame: Frame) -> bool { + self.child.view.handle_event(event, self.child.computed_frame); + } +} + +// --- OpacityModifier --- + +OpacityModifier :: struct { + child: ViewChild; + alpha: f32; +} + +impl View for OpacityModifier { + size_that_fits :: (self: *OpacityModifier, proposal: ProposedSize) -> Size { + self.child.view.size_that_fits(proposal); + } + + layout :: (self: *OpacityModifier, bounds: Frame) { + self.child.computed_frame = bounds; + self.child.view.layout(bounds); + } + + render :: (self: *OpacityModifier, ctx: *RenderContext, frame: Frame) { + prev := ctx.opacity; + ctx.push_opacity(self.alpha); + self.child.view.render(ctx, self.child.computed_frame); + ctx.pop_opacity(prev); + } + + handle_event :: (self: *OpacityModifier, event: *Event, frame: Frame) -> bool { + self.child.view.handle_event(event, self.child.computed_frame); + } +} + +// --- ClipModifier --- + +ClipModifier :: struct { + child: ViewChild; + corner_radius: f32; +} + +impl View for ClipModifier { + size_that_fits :: (self: *ClipModifier, proposal: ProposedSize) -> Size { + self.child.view.size_that_fits(proposal); + } + + layout :: (self: *ClipModifier, bounds: Frame) { + self.child.computed_frame = bounds; + self.child.view.layout(bounds); + } + + render :: (self: *ClipModifier, ctx: *RenderContext, frame: Frame) { + ctx.push_clip(frame); + self.child.view.render(ctx, self.child.computed_frame); + ctx.pop_clip(); + } + + handle_event :: (self: *ClipModifier, event: *Event, frame: Frame) -> bool { + self.child.view.handle_event(event, self.child.computed_frame); + } +} + +// --- HiddenModifier --- + +HiddenModifier :: struct { + child: ViewChild; + is_hidden: bool; +} + +impl View for HiddenModifier { + size_that_fits :: (self: *HiddenModifier, proposal: ProposedSize) -> Size { + if self.is_hidden { return Size.zero(); } + self.child.view.size_that_fits(proposal); + } + + layout :: (self: *HiddenModifier, bounds: Frame) { + if self.is_hidden { return; } + self.child.computed_frame = bounds; + self.child.view.layout(bounds); + } + + render :: (self: *HiddenModifier, ctx: *RenderContext, frame: Frame) { + if self.is_hidden { return; } + self.child.view.render(ctx, self.child.computed_frame); + } + + handle_event :: (self: *HiddenModifier, event: *Event, frame: Frame) -> bool { + if self.is_hidden { return false; } + self.child.view.handle_event(event, self.child.computed_frame); + } +} + +// --- TapGestureModifier --- + +TapGestureModifier :: struct { + child: ViewChild; + gesture: TapGesture; +} + +impl View for TapGestureModifier { + size_that_fits :: (self: *TapGestureModifier, proposal: ProposedSize) -> Size { + self.child.view.size_that_fits(proposal); + } + + layout :: (self: *TapGestureModifier, bounds: Frame) { + self.child.computed_frame = bounds; + self.child.view.layout(bounds); + } + + render :: (self: *TapGestureModifier, ctx: *RenderContext, frame: Frame) { + self.child.view.render(ctx, self.child.computed_frame); + } + + handle_event :: (self: *TapGestureModifier, event: *Event, frame: Frame) -> bool { + if self.gesture.handle_event(event, frame) { return true; } + self.child.view.handle_event(event, self.child.computed_frame); + } +} + +// --- DragGestureModifier --- + +DragGestureModifier :: struct { + child: ViewChild; + gesture: DragGesture; +} + +impl View for DragGestureModifier { + size_that_fits :: (self: *DragGestureModifier, proposal: ProposedSize) -> Size { + self.child.view.size_that_fits(proposal); + } + + layout :: (self: *DragGestureModifier, bounds: Frame) { + self.child.computed_frame = bounds; + self.child.view.layout(bounds); + } + + render :: (self: *DragGestureModifier, ctx: *RenderContext, frame: Frame) { + self.child.view.render(ctx, self.child.computed_frame); + } + + handle_event :: (self: *DragGestureModifier, event: *Event, frame: Frame) -> bool { + if self.gesture.handle_event(event, frame) { return true; } + self.child.view.handle_event(event, self.child.computed_frame); + } +} + +// --- Convenience functions --- + +padding :: (view: View, insets: EdgeInsets) -> PaddingModifier { + PaddingModifier.{ child = .{ view = view }, insets = insets }; +} + +fixed_frame :: (view: View, width: ?f32, height: ?f32) -> FrameModifier { + FrameModifier.{ child = .{ view = view }, width = width, height = height }; +} + +background :: (view: View, color: Color, corner_radius: f32) -> BackgroundModifier { + BackgroundModifier.{ child = .{ view = view }, color = color, corner_radius = corner_radius }; +} + +with_opacity :: (view: View, alpha: f32) -> OpacityModifier { + OpacityModifier.{ child = .{ view = view }, alpha = alpha }; +} + +clip :: (view: View, corner_radius: f32) -> ClipModifier { + ClipModifier.{ child = .{ view = view }, corner_radius = corner_radius }; +} + +hidden :: (view: View, is_hidden: bool) -> HiddenModifier { + HiddenModifier.{ child = .{ view = view }, is_hidden = is_hidden }; +} + +on_tap :: (view: View, handler: Closure()) -> TapGestureModifier { + TapGestureModifier.{ + child = .{ view = view }, + gesture = TapGesture.{ count = 1, on_tap = handler } + }; +} + +on_drag :: (view: View, on_changed: ?Closure(DragValue), on_ended: ?Closure(DragValue)) -> DragGestureModifier { + DragGestureModifier.{ + child = .{ view = view }, + gesture = DragGesture.{ min_distance = 10.0, on_changed = on_changed, on_ended = on_ended } + }; +} diff --git a/library/modules/ui/pipeline.sx b/library/modules/ui/pipeline.sx new file mode 100755 index 0000000..9a10e46 --- /dev/null +++ b/library/modules/ui/pipeline.sx @@ -0,0 +1,162 @@ +#import "modules/std.sx"; +#import "modules/allocators.sx"; +#import "modules/opengl.sx"; +#import "modules/ui/types.sx"; +#import "modules/ui/render.sx"; +#import "modules/ui/events.sx"; +#import "modules/ui/view.sx"; +#import "modules/ui/renderer.sx"; + +UIPipeline :: struct { + renderer: UIRenderer; + render_tree: RenderTree; + font: GlyphCache; + screen_width: f32; + screen_height: f32; + root: ViewChild; + has_root: bool; + + // Frame arena infrastructure + arena_a: Arena; + arena_b: Arena; + frame_index: s64; + body: Closure() -> View; + has_body: bool; + parent_allocator: Allocator; + + init :: (self: *UIPipeline, width: f32, height: f32) { + self.render_tree = RenderTree.init(); + self.renderer.init(); + self.screen_width = width; + self.screen_height = height; + self.has_root = false; + self.has_body = false; + self.frame_index = 0; + } + + init_font :: (self: *UIPipeline, path: [:0]u8, size: f32, dpi_scale: f32) { + self.font.init(path, size); + self.font.set_dpi_scale(dpi_scale); + self.renderer.dpi_scale = dpi_scale; + set_global_font(@self.font); + } + + set_root :: (self: *UIPipeline, view: View) { + self.root = .{ view = view }; + self.has_root = true; + } + + set_body :: (self: *UIPipeline, body_fn: Closure() -> View) { + self.body = body_fn; + self.has_body = true; + self.parent_allocator = context.allocator; + // Initialize both arenas (256KB initial, grows automatically) + self.arena_a.create(self.parent_allocator, 262144); + self.arena_b.create(self.parent_allocator, 262144); + self.frame_index = 0; + } + + resize :: (self: *UIPipeline, width: f32, height: f32) { + self.screen_width = width; + self.screen_height = height; + } + + // Re-layout and re-render the existing view tree at current screen size. + // Does NOT rebuild from body — safe to call from C callbacks (no arena/context needed). + tick_relayout :: (self: *UIPipeline) { + if self.has_root == false { return; } + + proposal := ProposedSize.fixed(self.screen_width, self.screen_height); + self.root.view.size_that_fits(proposal); + self.root.computed_frame = Frame.{ + origin = Point.zero(), + size = Size.{ width = self.screen_width, height = self.screen_height } + }; + self.root.view.layout(self.root.computed_frame); + + self.render_tree.clear(); + ctx := RenderContext.init(@self.render_tree); + self.root.view.render(@ctx, self.root.computed_frame); + + self.commit_gpu(); + } + + // Process a single event through the view tree + dispatch_event :: (self: *UIPipeline, event: *Event) -> bool { + if self.has_root == false { return false; } + self.root.view.handle_event(event, self.root.computed_frame); + } + + // Run one frame: layout → render → commit + tick :: (self: *UIPipeline) { + if self.has_body { + self.tick_with_body(); + return; + } + if self.has_root == false { return; } + + proposal := ProposedSize.fixed(self.screen_width, self.screen_height); + + // Layout + self.root.view.size_that_fits(proposal); + self.root.computed_frame = Frame.{ + origin = Point.zero(), + size = Size.{ width = self.screen_width, height = self.screen_height } + }; + self.root.view.layout(self.root.computed_frame); + + // Render to tree + self.render_tree.clear(); + ctx := RenderContext.init(@self.render_tree); + self.root.view.render(@ctx, self.root.computed_frame); + + // Commit to GPU + self.commit_gpu(); + } + + tick_with_body :: (self: *UIPipeline) { + build_arena : *Arena = if self.frame_index & 1 == 0 then @self.arena_a else @self.arena_b; + build_arena.reset(); + + // Reset render_tree nodes (backing is stale after arena reset) + self.render_tree.nodes.items = null; + self.render_tree.nodes.len = 0; + self.render_tree.nodes.cap = 0; + + push Context.{ allocator = xx build_arena, data = context.data } { + // Workaround: self.body() crashes through struct field (issue-0010) + body_fn := self.body; + root_view := body_fn(); + self.root = .{ view = root_view }; + self.has_root = true; + + proposal := ProposedSize.fixed(self.screen_width, self.screen_height); + self.root.view.size_that_fits(proposal); + self.root.computed_frame = Frame.{ + origin = Point.zero(), + size = Size.{ width = self.screen_width, height = self.screen_height } + }; + self.root.view.layout(self.root.computed_frame); + + self.render_tree.clear(); + ctx := RenderContext.init(@self.render_tree); + self.root.view.render(@ctx, self.root.computed_frame); + + self.commit_gpu(); + } + + self.frame_index += 1; + } + + commit_gpu :: (self: *UIPipeline) { + glEnable(GL_BLEND); + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + glDisable(GL_DEPTH_TEST); + + self.renderer.begin(self.screen_width, self.screen_height, self.font.texture_id); + self.renderer.process(@self.render_tree); + self.renderer.flush(); + + glDisable(GL_BLEND); + } +} diff --git a/library/modules/ui/render.sx b/library/modules/ui/render.sx new file mode 100755 index 0000000..4473245 --- /dev/null +++ b/library/modules/ui/render.sx @@ -0,0 +1,179 @@ +#import "modules/std.sx"; +#import "modules/ui/types.sx"; + +RenderNodeType :: enum { + rect; + rounded_rect; + text; + image; + clip_push; + clip_pop; + opacity_push; + opacity_pop; +} + +RenderNode :: struct { + type: RenderNodeType; + frame: Frame; + + // Rect / rounded_rect + fill_color: Color; + stroke_color: Color; + stroke_width: f32; + corner_radius: f32; + + // Text + text: string; + font_size: f32; + text_color: Color; + + // Image + texture_id: u32; + uv_min: Point; + uv_max: Point; + + // Opacity + opacity: f32; + + depth: s64; +} + +RenderTree :: struct { + nodes: List(RenderNode); + generation: s64; + + init :: () -> RenderTree { + RenderTree.{ generation = 0 }; + } + + clear :: (self: *RenderTree) { + self.nodes.len = 0; + self.generation += 1; + } + + add :: (self: *RenderTree, node: RenderNode) -> s64 { + idx := self.nodes.len; + self.nodes.append(node); + idx; + } +} + +// Stateful builder — views use this to emit render nodes + +RenderContext :: struct { + tree: *RenderTree; + clip_depth: s64; + opacity: f32; + depth: s64; + + init :: (tree: *RenderTree) -> RenderContext { + RenderContext.{ + tree = tree, + clip_depth = 0, + opacity = 1.0, + depth = 0 + }; + } + + add_rect :: (self: *RenderContext, frame: Frame, fill: Color) { + self.tree.add(.{ + type = .rect, + frame = frame, + fill_color = fill, + opacity = self.opacity, + depth = self.depth, + }); + self.depth += 1; + } + + add_rounded_rect :: (self: *RenderContext, frame: Frame, fill: Color, radius: f32) { + self.tree.add(.{ + type = .rounded_rect, + frame = frame, + fill_color = fill, + corner_radius = radius, + opacity = self.opacity, + depth = self.depth, + }); + self.depth += 1; + } + + add_stroked_rect :: (self: *RenderContext, frame: Frame, fill: Color, stroke: Color, stroke_w: f32, radius: f32) { + self.tree.add(.{ + type = .rounded_rect, + frame = frame, + fill_color = fill, + stroke_color = stroke, + stroke_width = stroke_w, + corner_radius = radius, + opacity = self.opacity, + depth = self.depth, + }); + self.depth += 1; + } + + add_text :: (self: *RenderContext, frame: Frame, text: string, font_size: f32, color: Color) { + self.tree.add(.{ + type = .text, + frame = frame, + text = text, + font_size = font_size, + text_color = color, + opacity = self.opacity, + depth = self.depth, + }); + self.depth += 1; + } + + add_image :: (self: *RenderContext, frame: Frame, texture_id: u32) { + self.add_image_uv(frame, texture_id, Point.zero(), Point.{ x = 1.0, y = 1.0 }); + } + + add_image_uv :: (self: *RenderContext, frame: Frame, texture_id: u32, uv_min: Point, uv_max: Point) { + self.tree.add(.{ + type = .image, + frame = frame, + texture_id = texture_id, + uv_min = uv_min, + uv_max = uv_max, + opacity = self.opacity, + depth = self.depth, + }); + self.depth += 1; + } + + push_clip :: (self: *RenderContext, frame: Frame) { + self.tree.add(.{ + type = .clip_push, + frame = frame, + depth = self.depth, + }); + self.clip_depth += 1; + } + + pop_clip :: (self: *RenderContext) { + self.tree.add(.{ + type = .clip_pop, + depth = self.depth, + }); + self.clip_depth -= 1; + } + + push_opacity :: (self: *RenderContext, alpha: f32) { + prev := self.opacity; + self.opacity = prev * alpha; + self.tree.add(.{ + type = .opacity_push, + opacity = self.opacity, + depth = self.depth, + }); + } + + pop_opacity :: (self: *RenderContext, prev_opacity: f32) { + self.tree.add(.{ + type = .opacity_pop, + depth = self.depth, + }); + self.opacity = prev_opacity; + } +} diff --git a/library/modules/ui/renderer.sx b/library/modules/ui/renderer.sx new file mode 100755 index 0000000..9d6d67d --- /dev/null +++ b/library/modules/ui/renderer.sx @@ -0,0 +1,460 @@ +#import "modules/std.sx"; +#import "modules/compiler.sx"; +#import "modules/opengl.sx"; +#import "modules/math"; +#import "modules/ui/types.sx"; +#import "modules/ui/render.sx"; +#import "modules/ui/glyph_cache.sx"; +#import "modules/ui/font.sx"; + +// Vertex: pos(2) + uv(2) + color(4) + params(4) = 12 floats +UI_VERTEX_FLOATS :s64: 12; +UI_VERTEX_BYTES :s64: 48; +MAX_UI_VERTICES :s64: 16384; + +UIRenderer :: struct { + vao: u32; + vbo: u32; + shader: u32; + proj_loc: s32; + tex_loc: s32; + vertices: [*]f32; + vertex_count: s64; + screen_width: f32; + screen_height: f32; + dpi_scale: f32; + white_texture: u32; + current_texture: u32; + draw_calls: s64; + + init :: (self: *UIRenderer) { + // Create shader (ES for WASM/WebGL2, Core for desktop) + inline if OS == .wasm { + self.shader = create_program(UI_VERT_SRC_ES, UI_FRAG_SRC_ES); + } else { + self.shader = create_program(UI_VERT_SRC_CORE, UI_FRAG_SRC_CORE); + } + self.proj_loc = glGetUniformLocation(self.shader, "uProj"); + self.tex_loc = glGetUniformLocation(self.shader, "uTex"); + + // Allocate vertex buffer (CPU side) + buf_size := MAX_UI_VERTICES * UI_VERTEX_BYTES; + self.vertices = xx context.allocator.alloc(buf_size); + memset(self.vertices, 0, buf_size); + self.vertex_count = 0; + + // Create VAO/VBO + glGenVertexArrays(1, @self.vao); + glGenBuffers(1, @self.vbo); + glBindVertexArray(self.vao); + glBindBuffer(GL_ARRAY_BUFFER, self.vbo); + glBufferData(GL_ARRAY_BUFFER, xx buf_size, null, GL_DYNAMIC_DRAW); + + // pos (2 floats) + glVertexAttribPointer(0, 2, GL_FLOAT, 0, xx UI_VERTEX_BYTES, xx 0); + glEnableVertexAttribArray(0); + // uv (2 floats) + glVertexAttribPointer(1, 2, GL_FLOAT, 0, xx UI_VERTEX_BYTES, xx 8); + glEnableVertexAttribArray(1); + // color (4 floats) + glVertexAttribPointer(2, 4, GL_FLOAT, 0, xx UI_VERTEX_BYTES, xx 16); + glEnableVertexAttribArray(2); + // params: corner_radius, border_width, rect_w, rect_h + glVertexAttribPointer(3, 4, GL_FLOAT, 0, xx UI_VERTEX_BYTES, xx 32); + glEnableVertexAttribArray(3); + + glBindVertexArray(0); + + self.dpi_scale = 1.0; + + // 1x1 white texture for solid rects + self.white_texture = create_white_texture(); + } + + begin :: (self: *UIRenderer, width: f32, height: f32, font_texture: u32) { + self.screen_width = width; + self.screen_height = height; + self.vertex_count = 0; + self.current_texture = font_texture; + self.draw_calls = 0; + + // Set up GL state once for the entire frame + glUseProgram(self.shader); + proj := Mat4.ortho(0.0, width, height, 0.0, -1.0, 1.0); + glUniformMatrix4fv(self.proj_loc, 1, 0, proj.data); + glUniform1i(self.tex_loc, 0); + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, font_texture); + glBindVertexArray(self.vao); + glBindBuffer(GL_ARRAY_BUFFER, self.vbo); + } + + bind_texture :: (self: *UIRenderer, tex: u32) { + if tex != self.current_texture { + self.flush(); + self.current_texture = tex; + } + } + + // Emit a quad (2 triangles = 6 vertices) + push_quad :: (self: *UIRenderer, frame: Frame, color: Color, radius: f32, border_w: f32) { + if self.vertex_count + 6 > MAX_UI_VERTICES { + self.flush(); + } + + x0 := frame.origin.x; + y0 := frame.origin.y; + x1 := x0 + frame.size.width; + y1 := y0 + frame.size.height; + + r := color.rf(); + g := color.gf(); + b := color.bf(); + a := color.af(); + + w := frame.size.width; + h := frame.size.height; + + // 6 vertices for quad: TL, TR, BL, TR, BR, BL + self.write_vertex(x0, y0, 0.0, 0.0, r, g, b, a, radius, border_w, w, h); + self.write_vertex(x1, y0, 1.0, 0.0, r, g, b, a, radius, border_w, w, h); + self.write_vertex(x0, y1, 0.0, 1.0, r, g, b, a, radius, border_w, w, h); + self.write_vertex(x1, y0, 1.0, 0.0, r, g, b, a, radius, border_w, w, h); + self.write_vertex(x1, y1, 1.0, 1.0, r, g, b, a, radius, border_w, w, h); + self.write_vertex(x0, y1, 0.0, 1.0, r, g, b, a, radius, border_w, w, h); + } + + // Emit a quad with custom UV coordinates (for sprite sheet sub-textures) + push_quad_uv :: (self: *UIRenderer, frame: Frame, color: Color, radius: f32, border_w: f32, uv_min: Point, uv_max: Point) { + if self.vertex_count + 6 > MAX_UI_VERTICES { + self.flush(); + } + + x0 := frame.origin.x; + y0 := frame.origin.y; + x1 := x0 + frame.size.width; + y1 := y0 + frame.size.height; + + r := color.rf(); + g := color.gf(); + b := color.bf(); + a := color.af(); + + w := frame.size.width; + h := frame.size.height; + + u0 := uv_min.x; + v0 := uv_min.y; + u1 := uv_max.x; + v1 := uv_max.y; + + self.write_vertex(x0, y0, u0, v0, r, g, b, a, radius, border_w, w, h); + self.write_vertex(x1, y0, u1, v0, r, g, b, a, radius, border_w, w, h); + self.write_vertex(x0, y1, u0, v1, r, g, b, a, radius, border_w, w, h); + self.write_vertex(x1, y0, u1, v0, r, g, b, a, radius, border_w, w, h); + self.write_vertex(x1, y1, u1, v1, r, g, b, a, radius, border_w, w, h); + self.write_vertex(x0, y1, u0, v1, r, g, b, a, radius, border_w, w, h); + } + + write_vertex :: (self: *UIRenderer, x: f32, y: f32, u: f32, v: f32, r: f32, g: f32, b: f32, a: f32, cr: f32, bw: f32, rw: f32, rh: f32) { + off := self.vertex_count * UI_VERTEX_FLOATS; + self.vertices[off + 0] = x; + self.vertices[off + 1] = y; + self.vertices[off + 2] = u; + self.vertices[off + 3] = v; + self.vertices[off + 4] = r; + self.vertices[off + 5] = g; + self.vertices[off + 6] = b; + self.vertices[off + 7] = a; + self.vertices[off + 8] = cr; + self.vertices[off + 9] = bw; + self.vertices[off + 10] = rw; + self.vertices[off + 11] = rh; + self.vertex_count += 1; + } + + // Walk the render tree and emit quads + process :: (self: *UIRenderer, tree: *RenderTree) { + i := 0; + while i < tree.nodes.len { + node := tree.nodes.items[i]; + if node.type == { + case .rect: { + self.push_quad(node.frame, node.fill_color, 0.0, 0.0); + } + case .rounded_rect: { + self.push_quad(node.frame, node.fill_color, node.corner_radius, node.stroke_width); + } + case .text: { + if g_font != null { + self.render_text(node); + } + } + case .image: { + self.bind_texture(node.texture_id); + neg2 : f32 = 0.0 - 2.0; + self.push_quad_uv(node.frame, COLOR_WHITE, neg2, 0.0, node.uv_min, node.uv_max); + // Re-bind font atlas after image + font := g_font; + if font != null { + self.bind_texture(font.texture_id); + } + } + case .clip_push: { + self.flush(); + glEnable(GL_SCISSOR_TEST); + dpi := self.dpi_scale; + glScissor( + xx (node.frame.origin.x * dpi), + xx ((self.screen_height - node.frame.origin.y - node.frame.size.height) * dpi), + xx (node.frame.size.width * dpi), + xx (node.frame.size.height * dpi) + ); + } + case .clip_pop: { + self.flush(); + glDisable(GL_SCISSOR_TEST); + } + case .opacity_push: {} + case .opacity_pop: {} + } + i += 1; + } + } + + flush :: (self: *UIRenderer) { + if self.vertex_count == 0 { return; } + + // Only bind the current texture (program, projection, VAO already bound in begin()) + glBindTexture(GL_TEXTURE_2D, self.current_texture); + + upload_size : s64 = self.vertex_count * UI_VERTEX_BYTES; + // Use glBufferData to orphan the old buffer and avoid GPU sync stalls + glBufferData(GL_ARRAY_BUFFER, xx upload_size, self.vertices, GL_DYNAMIC_DRAW); + glDrawArrays(GL_TRIANGLES, 0, xx self.vertex_count); + + self.vertex_count = 0; + self.draw_calls += 1; + } + + render_text :: (self: *UIRenderer, node: RenderNode) { + font := g_font; + if font == null { return; } + + // Shape text into positioned glyphs + font.shape_text(node.text, node.font_size); + + // Flush any new glyphs to the atlas texture (no texture switch needed — atlas is already bound) + font.flush(); + + r := node.text_color.rf(); + g := node.text_color.gf(); + b := node.text_color.bf(); + a := node.text_color.af(); + + ascent := font.get_ascent(node.font_size); + raster_size := node.font_size * font.dpi_scale; + inv_dpi := font.inv_dpi; + + i : s64 = 0; + while i < font.shaped_buf.len { + shaped := font.shaped_buf.items[i]; + cached := font.get_or_rasterize(shaped.glyph_index, raster_size); + + if cached != null { + if cached.width > 0.0 { + // Scale physical pixel dimensions back to logical units + gx0 := node.frame.origin.x + shaped.x + cached.offset_x * inv_dpi; + gy0 := node.frame.origin.y + ascent + shaped.y + cached.offset_y * inv_dpi; + gx1 := gx0 + cached.width * inv_dpi; + gy1 := gy0 + cached.height * inv_dpi; + + u0 := cached.uv_x; + v0 := cached.uv_y; + u1 := cached.uv_x + cached.uv_w; + v1 := cached.uv_y + cached.uv_h; + + if self.vertex_count + 6 > MAX_UI_VERTICES { + self.flush(); + } + + // corner_radius = -1.0 signals "text mode" to the fragment shader + neg1 : f32 = 0.0 - 1.0; + self.write_vertex(gx0, gy0, u0, v0, r, g, b, a, neg1, 0.0, 0.0, 0.0); + self.write_vertex(gx1, gy0, u1, v0, r, g, b, a, neg1, 0.0, 0.0, 0.0); + self.write_vertex(gx0, gy1, u0, v1, r, g, b, a, neg1, 0.0, 0.0, 0.0); + self.write_vertex(gx1, gy0, u1, v0, r, g, b, a, neg1, 0.0, 0.0, 0.0); + self.write_vertex(gx1, gy1, u1, v1, r, g, b, a, neg1, 0.0, 0.0, 0.0); + self.write_vertex(gx0, gy1, u0, v1, r, g, b, a, neg1, 0.0, 0.0, 0.0); + } + } + i += 1; + } + + // Flush any glyphs rasterized during this text draw + font.flush(); + } +} + +create_white_texture :: () -> u32 { + tex : u32 = 0; + glGenTextures(1, @tex); + glBindTexture(GL_TEXTURE_2D, tex); + pixel : [4]u8 = .[255, 255, 255, 255]; + glTexImage2D(GL_TEXTURE_2D, 0, xx GL_RGBA, 1, 1, 0, GL_RGBA, GL_UNSIGNED_BYTE, @pixel); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, xx GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, xx GL_NEAREST); + tex; +} + +// --- UI Shaders --- + +// --- Desktop (Core Profile 3.3) shaders --- + +UI_VERT_SRC_CORE :: #string GLSL +#version 330 core +layout(location = 0) in vec2 aPos; +layout(location = 1) in vec2 aUV; +layout(location = 2) in vec4 aColor; +layout(location = 3) in vec4 aParams; + +uniform mat4 uProj; + +out vec2 vUV; +out vec4 vColor; +out vec4 vParams; + +void main() { + gl_Position = uProj * vec4(aPos, 0.0, 1.0); + vUV = aUV; + vColor = aColor; + vParams = aParams; +} +GLSL; + +UI_FRAG_SRC_CORE :: #string GLSL +#version 330 core +in vec2 vUV; +in vec4 vColor; +in vec4 vParams; + +uniform sampler2D uTex; + +out vec4 FragColor; + +float roundedBoxSDF(vec2 center, vec2 half_size, float radius) { + vec2 q = abs(center) - half_size + vec2(radius); + return length(max(q, vec2(0.0))) + min(max(q.x, q.y), 0.0) - radius; +} + +void main() { + float mode = vParams.x; + float border = vParams.y; + vec2 rectSize = vParams.zw; + + if (mode < -1.5) { + // Image mode (mode == -2.0): sample texture + FragColor = texture(uTex, vUV) * vColor; + } else if (mode < 0.0) { + // Text mode (mode == -1.0): sample glyph atlas .r as alpha + float alpha = texture(uTex, vUV).r; + float ew = fwidth(alpha) * 0.7; + alpha = smoothstep(0.5 - ew, 0.5 + ew, alpha); + FragColor = vec4(vColor.rgb, vColor.a * pow(alpha, 0.9)); + } else if (mode > 0.0 || border > 0.0) { + // Rounded rect: SDF alpha, vertex color only (no texture sample) + vec2 half_size = rectSize * 0.5; + vec2 center = (vUV - vec2(0.5)) * rectSize; + float dist = roundedBoxSDF(center, half_size, mode); + float aa = fwidth(dist); + float alpha = 1.0 - smoothstep(-aa, aa, dist); + + if (border > 0.0) { + float inner = roundedBoxSDF(center, half_size - vec2(border), max(mode - border, 0.0)); + float border_alpha = smoothstep(-aa, aa, inner); + alpha = alpha * max(border_alpha, 0.0); + } + + FragColor = vec4(vColor.rgb, vColor.a * alpha); + } else { + // Plain rect: vertex color only (no texture sample) + FragColor = vColor; + } +} +GLSL; + +// --- WASM (ES 3.0 / WebGL2) shaders --- + +UI_VERT_SRC_ES :: #string GLSL +#version 300 es +precision mediump float; +layout(location = 0) in vec2 aPos; +layout(location = 1) in vec2 aUV; +layout(location = 2) in vec4 aColor; +layout(location = 3) in vec4 aParams; + +uniform mat4 uProj; + +out vec2 vUV; +out vec4 vColor; +out vec4 vParams; + +void main() { + gl_Position = uProj * vec4(aPos, 0.0, 1.0); + vUV = aUV; + vColor = aColor; + vParams = aParams; +} +GLSL; + +UI_FRAG_SRC_ES :: #string GLSL +#version 300 es +precision mediump float; +in vec2 vUV; +in vec4 vColor; +in vec4 vParams; + +uniform sampler2D uTex; + +out vec4 FragColor; + +float roundedBoxSDF(vec2 center, vec2 half_size, float radius) { + vec2 q = abs(center) - half_size + vec2(radius); + return length(max(q, vec2(0.0))) + min(max(q.x, q.y), 0.0) - radius; +} + +void main() { + float mode = vParams.x; + float border = vParams.y; + vec2 rectSize = vParams.zw; + + if (mode < -1.5) { + // Image mode (mode == -2.0): sample texture + FragColor = texture(uTex, vUV) * vColor; + } else if (mode < 0.0) { + // Text mode (mode == -1.0): sample glyph atlas .r as alpha + float alpha = texture(uTex, vUV).r; + float ew = fwidth(alpha) * 0.7; + alpha = smoothstep(0.5 - ew, 0.5 + ew, alpha); + FragColor = vec4(vColor.rgb, vColor.a * pow(alpha, 0.9)); + } else if (mode > 0.0 || border > 0.0) { + // Rounded rect: SDF alpha, vertex color only + vec2 half_size = rectSize * 0.5; + vec2 center = (vUV - vec2(0.5)) * rectSize; + float dist = roundedBoxSDF(center, half_size, mode); + float aa = fwidth(dist); + float alpha = 1.0 - smoothstep(-aa, aa, dist); + + if (border > 0.0) { + float inner = roundedBoxSDF(center, half_size - vec2(border), max(mode - border, 0.0)); + float border_alpha = smoothstep(-aa, aa, inner); + alpha = alpha * max(border_alpha, 0.0); + } + + FragColor = vec4(vColor.rgb, vColor.a * alpha); + } else { + // Plain rect: vertex color only + FragColor = vColor; + } +} +GLSL; diff --git a/library/modules/ui/scroll_view.sx b/library/modules/ui/scroll_view.sx new file mode 100755 index 0000000..6f7ffb1 --- /dev/null +++ b/library/modules/ui/scroll_view.sx @@ -0,0 +1,149 @@ +#import "modules/std.sx"; +#import "modules/math"; +#import "modules/ui/types.sx"; +#import "modules/ui/render.sx"; +#import "modules/ui/events.sx"; +#import "modules/ui/view.sx"; + +ScrollAxes :: enum { vertical; horizontal; both; } + +// Persistent scroll state — lives outside the frame arena +ScrollState :: struct { + offset: Point; + content_size: Size; + viewport_size: Size; + dragging: bool; + drag_pending: bool; + drag_start: Point; + drag_offset: Point; +} + +ScrollView :: struct { + child: ViewChild; + state: *ScrollState; + axes: ScrollAxes; + + SCROLL_SPEED :f32: 20.0; + DRAG_THRESHOLD :f32: 4.0; + + clamp_offset :: (self: *ScrollView) { + s := self.state; + max_x := max(0.0, s.content_size.width - s.viewport_size.width); + max_y := max(0.0, s.content_size.height - s.viewport_size.height); + + if self.axes == .vertical or self.axes == .both { + s.offset.y = clamp(s.offset.y, 0.0, max_y); + } else { + s.offset.y = 0.0; + } + + if self.axes == .horizontal or self.axes == .both { + s.offset.x = clamp(s.offset.x, 0.0, max_x); + } else { + s.offset.x = 0.0; + } + } +} + +impl View for ScrollView { + size_that_fits :: (self: *ScrollView, proposal: ProposedSize) -> Size { + // ScrollView takes all proposed space (default 200 if unspecified) + Size.{ + width = proposal.width ?? 200.0, + height = proposal.height ?? 200.0 + }; + } + + layout :: (self: *ScrollView, bounds: Frame) { + s := self.state; + s.viewport_size = bounds.size; + + // Measure child with infinite space on scroll axes + child_proposal := ProposedSize.{ + width = if self.axes == .horizontal or self.axes == .both then null else bounds.size.width, + height = if self.axes == .vertical or self.axes == .both then null else bounds.size.height + }; + s.content_size = self.child.view.size_that_fits(child_proposal); + self.clamp_offset(); + + // Layout child offset by scroll position + self.child.computed_frame = Frame.make( + bounds.origin.x - s.offset.x, + bounds.origin.y - s.offset.y, + s.content_size.width, + s.content_size.height + ); + self.child.view.layout(self.child.computed_frame); + } + + render :: (self: *ScrollView, ctx: *RenderContext, frame: Frame) { + ctx.push_clip(frame); + self.child.view.render(ctx, self.child.computed_frame); + ctx.pop_clip(); + } + + handle_event :: (self: *ScrollView, event: *Event, frame: Frame) -> bool { + s := self.state; + + if pos := event_position(event) { + if !frame.contains(pos) { return false; } + } + + if event.* == { + case .mouse_wheel: (d) { + if self.axes == .vertical or self.axes == .both { + s.offset.y -= d.delta.y * ScrollView.SCROLL_SPEED; + } + if self.axes == .horizontal or self.axes == .both { + s.offset.x -= d.delta.x * ScrollView.SCROLL_SPEED; + } + self.clamp_offset(); + return true; + } + case .mouse_down: (d) { + s.drag_pending = true; + s.drag_start = d.position; + s.drag_offset = s.offset; + self.child.view.handle_event(event, self.child.computed_frame); + return true; + } + case .mouse_moved: (d) { + if s.drag_pending and !s.dragging { + dx := d.position.x - s.drag_start.x; + dy := d.position.y - s.drag_start.y; + dist := sqrt(dx * dx + dy * dy); + if dist >= ScrollView.DRAG_THRESHOLD { + s.dragging = true; + s.drag_pending = false; + cancel :Event = .mouse_up(.{ + position = .{ x = 0.0 - 10000.0, y = 0.0 - 10000.0 }, + button = .none + }); + self.child.view.handle_event(@cancel, self.child.computed_frame); + } + } + if s.dragging { + if self.axes == .vertical or self.axes == .both { + s.offset.y = s.drag_offset.y - (d.position.y - s.drag_start.y); + } + if self.axes == .horizontal or self.axes == .both { + s.offset.x = s.drag_offset.x - (d.position.x - s.drag_start.x); + } + self.clamp_offset(); + return true; + } + return self.child.view.handle_event(event, self.child.computed_frame); + } + case .mouse_up: { + was_dragging := s.dragging; + s.dragging = false; + s.drag_pending = false; + if was_dragging { + return true; + } + return self.child.view.handle_event(event, self.child.computed_frame); + } + } + self.child.view.handle_event(event, self.child.computed_frame); + } +} diff --git a/library/modules/ui/stacks.sx b/library/modules/ui/stacks.sx new file mode 100755 index 0000000..821333a --- /dev/null +++ b/library/modules/ui/stacks.sx @@ -0,0 +1,181 @@ +#import "modules/std.sx"; +#import "modules/math"; +#import "modules/ui/types.sx"; +#import "modules/ui/render.sx"; +#import "modules/ui/events.sx"; +#import "modules/ui/view.sx"; +#import "modules/ui/layout.sx"; + +VStack :: struct { + children: List(ViewChild); + spacing: f32; + alignment: HAlignment; + + add :: (self: *VStack, view: View) { + self.children.append(.{ view = view }); + } +} + +impl View for VStack { + size_that_fits :: (self: *VStack, proposal: ProposedSize) -> Size { + measure_vstack(@self.children, proposal, self.spacing); + } + + layout :: (self: *VStack, bounds: Frame) { + layout_vstack(@self.children, bounds, self.spacing, self.alignment); + } + + render :: (self: *VStack, ctx: *RenderContext, frame: Frame) { + i := 0; + while i < self.children.len { + child := @self.children.items[i]; + child.view.render(ctx, child.computed_frame); + i += 1; + } + } + + handle_event :: (self: *VStack, event: *Event, frame: Frame) -> bool { + // Iterate children in reverse (front-to-back for overlapping) + i := self.children.len - 1; + while i >= 0 { + child := @self.children.items[i]; + if child.view.handle_event(event, child.computed_frame) { + return true; + } + i -= 1; + } + false; + } +} + +HStack :: struct { + children: List(ViewChild); + spacing: f32; + alignment: VAlignment; + + add :: (self: *HStack, view: View) { + self.children.append(.{ view = view }); + } +} + +impl View for HStack { + size_that_fits :: (self: *HStack, proposal: ProposedSize) -> Size { + measure_hstack(@self.children, proposal, self.spacing); + } + + layout :: (self: *HStack, bounds: Frame) { + layout_hstack(@self.children, bounds, self.spacing, self.alignment); + } + + render :: (self: *HStack, ctx: *RenderContext, frame: Frame) { + i := 0; + while i < self.children.len { + child := @self.children.items[i]; + child.view.render(ctx, child.computed_frame); + i += 1; + } + } + + handle_event :: (self: *HStack, event: *Event, frame: Frame) -> bool { + i := self.children.len - 1; + while i >= 0 { + child := @self.children.items[i]; + if child.view.handle_event(event, child.computed_frame) { + return true; + } + i -= 1; + } + false; + } +} + +ZStack :: struct { + children: List(ViewChild); + alignment: Alignment; + + add :: (self: *ZStack, view: View) { + self.children.append(.{ view = view }); + } +} + +impl View for ZStack { + size_that_fits :: (self: *ZStack, proposal: ProposedSize) -> Size { + measure_zstack(@self.children, proposal); + } + + layout :: (self: *ZStack, bounds: Frame) { + layout_zstack(@self.children, bounds, self.alignment); + } + + render :: (self: *ZStack, ctx: *RenderContext, frame: Frame) { + // Render back-to-front (first child is bottommost) + i := 0; + while i < self.children.len { + child := @self.children.items[i]; + child.view.render(ctx, child.computed_frame); + i += 1; + } + } + + handle_event :: (self: *ZStack, event: *Event, frame: Frame) -> bool { + // Handle front-to-back (last child is topmost) + i := self.children.len - 1; + while i >= 0 { + child := @self.children.items[i]; + if child.view.handle_event(event, child.computed_frame) { + return true; + } + i -= 1; + } + false; + } +} + +// Spacer — fills available space + +Spacer :: struct { + min_length: f32; + +} + +impl View for Spacer { + size_that_fits :: (self: *Spacer, proposal: ProposedSize) -> Size { + w := proposal.width ?? self.min_length; + h := proposal.height ?? self.min_length; + Size.{ width = max(w, self.min_length), height = max(h, self.min_length) }; + } + + layout :: (self: *Spacer, bounds: Frame) {} + render :: (self: *Spacer, ctx: *RenderContext, frame: Frame) {} + handle_event :: (self: *Spacer, event: *Event, frame: Frame) -> bool { false; } +} + +// Rect — simple colored rectangle view + +RectView :: struct { + color: Color; + corner_radius: f32; + preferred_width: f32; + preferred_height: f32; + +} + +impl View for RectView { + size_that_fits :: (self: *RectView, proposal: ProposedSize) -> Size { + w := proposal.width ?? self.preferred_width; + h := proposal.height ?? self.preferred_height; + Size.{ width = w, height = h }; + } + + layout :: (self: *RectView, bounds: Frame) {} + + render :: (self: *RectView, ctx: *RenderContext, frame: Frame) { + if self.corner_radius > 0.0 { + ctx.add_rounded_rect(frame, self.color, self.corner_radius); + } else { + ctx.add_rect(frame, self.color); + } + } + + handle_event :: (self: *RectView, event: *Event, frame: Frame) -> bool { false; } +} diff --git a/library/modules/ui/state.sx b/library/modules/ui/state.sx new file mode 100755 index 0000000..943e49c --- /dev/null +++ b/library/modules/ui/state.sx @@ -0,0 +1,59 @@ +#import "modules/std.sx"; + +// --- State(T) — a handle to persistent storage --- + +State :: struct ($T: Type) { + ptr: *T; + + get :: (self: State(T)) -> T { self.ptr.*; } + + set :: (self: State(T), val: T) { self.ptr.* = val; } +} + +// --- StateEntry — type-erased storage --- + +StateEntry :: struct { + id: s64; + data: [*]u8; + size: s64; + generation: s64; +} + +// --- StateStore — manages persistent state --- + +StateStore :: struct { + entries: List(StateEntry); + current_generation: s64; + + init :: (self: *StateStore) { + self.entries = List(StateEntry).{}; + self.current_generation = 0; + } + + get_or_create :: (self: *StateStore, id: s64, $T: Type, default: T) -> State(T) { + // Search for existing entry + i : s64 = 0; + while i < self.entries.len { + if self.entries.items[i].id == id { + self.entries.items[i].generation = self.current_generation; + return State(T).{ ptr = xx self.entries.items[i].data }; + } + i += 1; + } + + // Create new entry + data : [*]u8 = xx context.allocator.alloc(size_of(T)); + memcpy(data, @default, size_of(T)); + self.entries.append(.{ + id = id, + data = data, + size = size_of(T), + generation = self.current_generation + }); + State(T).{ ptr = xx data }; + } + + next_frame :: (self: *StateStore) { + self.current_generation += 1; + } +} diff --git a/library/modules/ui/stats_panel.sx b/library/modules/ui/stats_panel.sx new file mode 100755 index 0000000..fde756d --- /dev/null +++ b/library/modules/ui/stats_panel.sx @@ -0,0 +1,63 @@ +#import "modules/std.sx"; +#import "modules/math"; +#import "modules/ui/types.sx"; +#import "modules/ui/render.sx"; +#import "modules/ui/events.sx"; +#import "modules/ui/view.sx"; +#import "modules/ui/font.sx"; + +StatsPanel :: struct { + delta_time: *f32; + font_size: f32; + + PADDING :f32: 12.0; + LINE_SPACING :f32: 4.0; + TITLE_SIZE :f32: 12.0; + VALUE_SIZE :f32: 11.0; + BG_COLOR :Color: Color.rgba(30, 30, 38, 200); + CORNER_RADIUS:f32: 12.0; +} + +impl View for StatsPanel { + size_that_fits :: (self: *StatsPanel, proposal: ProposedSize) -> Size { + title_size := measure_text("Statistics", StatsPanel.TITLE_SIZE); + fps_size := measure_text("FPS: 0000", StatsPanel.VALUE_SIZE); + w := max(title_size.width, fps_size.width) + StatsPanel.PADDING * 2.0; + h := title_size.height + StatsPanel.LINE_SPACING + fps_size.height + StatsPanel.PADDING * 2.0; + Size.{ width = w, height = h }; + } + + layout :: (self: *StatsPanel, bounds: Frame) {} + + render :: (self: *StatsPanel, ctx: *RenderContext, frame: Frame) { + // Background + ctx.add_rounded_rect(frame, StatsPanel.BG_COLOR, StatsPanel.CORNER_RADIUS); + + // Title + title_size := measure_text("Statistics", StatsPanel.TITLE_SIZE); + title_frame := Frame.make( + frame.origin.x + StatsPanel.PADDING, + frame.origin.y + StatsPanel.PADDING, + title_size.width, + title_size.height + ); + ctx.add_text(title_frame, "Statistics", StatsPanel.TITLE_SIZE, COLOR_WHITE); + + // FPS value + dt := self.delta_time.*; + fps : s64 = if dt > 0.0 then xx (1.0 / dt) else 0; + fps_text := format("FPS: {}", fps); + fps_size := measure_text(fps_text, StatsPanel.VALUE_SIZE); + fps_frame := Frame.make( + frame.origin.x + StatsPanel.PADDING, + title_frame.max_y() + StatsPanel.LINE_SPACING, + fps_size.width, + fps_size.height + ); + ctx.add_text(fps_frame, fps_text, StatsPanel.VALUE_SIZE, Color.rgba(180, 180, 190, 255)); + } + + handle_event :: (self: *StatsPanel, event: *Event, frame: Frame) -> bool { + false; + } +} diff --git a/library/modules/ui/types.sx b/library/modules/ui/types.sx new file mode 100755 index 0000000..88c2371 --- /dev/null +++ b/library/modules/ui/types.sx @@ -0,0 +1,220 @@ +#import "modules/std.sx"; +#import "modules/math"; + +Point :: struct { + x, y: f32; + + zero :: () -> Point => .{ x = 0.0, y = 0.0 }; + + add :: (self: Point, b: Point) -> Point { + Point.{ x = self.x + b.x, y = self.y + b.y }; + } + + sub :: (self: Point, b: Point) -> Point { + Point.{ x = self.x - b.x, y = self.y - b.y }; + } + + scale :: (self: Point, s: f32) -> Point { + Point.{ x = self.x * s, y = self.y * s }; + } + + distance :: (self: Point, b: Point) -> f32 { + dx := self.x - b.x; + dy := self.y - b.y; + sqrt(dx * dx + dy * dy); + } +} + +Size :: struct { + width, height: f32; + + zero :: () -> Size => .{ width = 0.0, height = 0.0 }; + + contains :: (self: Size, point: Point) -> bool { + point.x >= 0.0 and point.x <= self.width and point.y >= 0.0 and point.y <= self.height; + } +} + +Frame :: struct { + origin: Point; + size: Size; + + zero :: () -> Frame { Frame.{ origin = Point.zero(), size = Size.zero() }; } + + make :: (x: f32, y: f32, w: f32, h: f32) -> Frame { + Frame.{ origin = Point.{ x = x, y = y }, size = Size.{ width = w, height = h } }; + } + + max_x :: (self: Frame) -> f32 { self.origin.x + self.size.width; } + max_y :: (self: Frame) -> f32 { self.origin.y + self.size.height; } + mid_x :: (self: Frame) -> f32 { self.origin.x + self.size.width * 0.5; } + mid_y :: (self: Frame) -> f32 { self.origin.y + self.size.height * 0.5; } + + contains :: (self: Frame, point: Point) -> bool { + point.x >= self.origin.x and point.x <= self.max_x() + and point.y >= self.origin.y and point.y <= self.max_y(); + } + + intersection :: (self: Frame, other: Frame) -> Frame { + x1 := max(self.origin.x, other.origin.x); + y1 := max(self.origin.y, other.origin.y); + x2 := min(self.max_x(), other.max_x()); + y2 := min(self.max_y(), other.max_y()); + if x2 <= x1 or y2 <= y1 + then .zero() + else .make(x1, y1, x2 - x1, y2 - y1); + } + + inset :: (self: Frame, insets: EdgeInsets) -> Frame { + Frame.make( + self.origin.x + insets.left, + self.origin.y + insets.top, + self.size.width - insets.left - insets.right, + self.size.height - insets.top - insets.bottom + ); + } + + expand :: (self: Frame, amount: f32) -> Frame { + Frame.make( + self.origin.x - amount, + self.origin.y - amount, + self.size.width + amount * 2.0, + self.size.height + amount * 2.0 + ); + } +} + +EdgeInsets :: struct { + top, left, bottom, right: f32; + + zero :: () -> EdgeInsets { EdgeInsets.{ top = 0.0, left = 0.0, bottom = 0.0, right = 0.0 }; } + + all :: (v: f32) -> EdgeInsets { + EdgeInsets.{ top = v, left = v, bottom = v, right = v }; + } + + symmetric :: (h: f32, v: f32) -> EdgeInsets { + EdgeInsets.{ top = v, left = h, bottom = v, right = h }; + } + + horizontal :: (self: EdgeInsets) -> f32 { self.left + self.right; } + vertical :: (self: EdgeInsets) -> f32 { self.top + self.bottom; } +} + +Color :: struct { + r, g, b, a: u8; + + rgba :: (r: u8, g: u8, b: u8, a: u8) -> Color { + Color.{ r = r, g = g, b = b, a = a }; + } + + rgb :: (r: u8, g: u8, b: u8) -> Color { + Color.{ r = r, g = g, b = b, a = 255 }; + } + + rf :: (self: Color) -> f32 { xx self.r / 255.0; } + gf :: (self: Color) -> f32 { xx self.g / 255.0; } + bf :: (self: Color) -> f32 { xx self.b / 255.0; } + af :: (self: Color) -> f32 { xx self.a / 255.0; } + + with_alpha :: (self: Color, a: u8) -> Color { + Color.{ r = self.r, g = self.g, b = self.b, a = a }; + } + + lerp :: (self: Color, b: Color, t: f32) -> Color { + Color.{ + r = xx (self.r + (b.r - self.r) * t), + g = xx (self.g + (b.g - self.g) * t), + b = xx (self.b + (b.b - self.b) * t), + a = xx (self.a + (b.a - self.a) * t), + }; + } +} + +// Named color constants +COLOR_WHITE :: Color.{ r = 255, g = 255, b = 255, a = 255 }; +COLOR_BLACK :: Color.{ r = 0, g = 0, b = 0, a = 255 }; +COLOR_RED :: Color.{ r = 255, g = 59, b = 48, a = 255 }; +COLOR_GREEN :: Color.{ r = 52, g = 199, b = 89, a = 255 }; +COLOR_BLUE :: Color.{ r = 0, g = 122, b = 255, a = 255 }; +COLOR_YELLOW :: Color.{ r = 255, g = 204, b = 0, a = 255 }; +COLOR_ORANGE :: Color.{ r = 255, g = 149, b = 0, a = 255 }; +COLOR_GRAY :: Color.{ r = 142, g = 142, b = 147, a = 255 }; +COLOR_DARK_GRAY :: Color.{ r = 44, g = 44, b = 46, a = 255 }; +COLOR_LIGHT_GRAY :: Color.{ r = 209, g = 209, b = 214, a = 255 }; +COLOR_TRANSPARENT :: Color.{ r = 0, g = 0, b = 0, a = 0 }; + +// Size proposal — optional dimensions (null = flexible) +ProposedSize :: struct { + width: ?f32; + height: ?f32; + + fixed :: (w: f32, h: f32) -> ProposedSize { + ProposedSize.{ width = w, height = h }; + } + + flexible :: () -> ProposedSize { + ProposedSize.{ width = null, height = null }; + } +} + +HAlignment :: enum { + leading; + center; + trailing; +} + +VAlignment :: enum { + top; + center; + bottom; +} + +Alignment :: struct { + h: HAlignment; + v: VAlignment; +} + +ALIGN_CENTER :: Alignment.{ h = .center, v = .center }; +ALIGN_TOP_LEADING :: Alignment.{ h = .leading, v = .top }; +ALIGN_TOP :: Alignment.{ h = .center, v = .top }; +ALIGN_TOP_TRAILING :: Alignment.{ h = .trailing, v = .top }; +ALIGN_LEADING :: Alignment.{ h = .leading, v = .center }; +ALIGN_TRAILING :: Alignment.{ h = .trailing, v = .center }; +ALIGN_BOTTOM :: Alignment.{ h = .center, v = .bottom }; +ALIGN_BOTTOM_LEADING :: Alignment.{ h = .leading, v = .bottom }; +ALIGN_BOTTOM_TRAILING :: Alignment.{ h = .trailing, v = .bottom }; + +// Compute x offset for a child of child_width inside container_width +align_h :: (alignment: HAlignment, child_width: f32, container_width: f32) -> f32 { + if alignment == { + case .leading: 0.0; + case .center: { (container_width - child_width) * 0.5; } + case .trailing: container_width - child_width; + } +} + +// Compute y offset for a child of child_height inside container_height +align_v :: (alignment: VAlignment, child_height: f32, container_height: f32) -> f32 { + if alignment == { + case .top: 0.0; + case .center: { (container_height - child_height) * 0.5; } + case .bottom: container_height - child_height; + } +} + +// --- Lerpable implementations --- + +#import "modules/ui/animation.sx"; + +impl Lerpable for Point { + lerp :: (self: Point, b: Point, t: f32) -> Point { + Point.{ x = self.x + (b.x - self.x) * t, y = self.y + (b.y - self.y) * t }; + } +} + +impl Lerpable for Size { + lerp :: (self: Size, b: Size, t: f32) -> Size { + Size.{ width = self.width + (b.width - self.width) * t, height = self.height + (b.height - self.height) * t }; + } +} diff --git a/library/modules/ui/view.sx b/library/modules/ui/view.sx new file mode 100755 index 0000000..cd94e33 --- /dev/null +++ b/library/modules/ui/view.sx @@ -0,0 +1,23 @@ +#import "modules/ui/types.sx"; +#import "modules/ui/render.sx"; +#import "modules/ui/events.sx"; + +View :: protocol { + // Measure: given a size proposal, return desired size + size_that_fits :: (proposal: ProposedSize) -> Size; + + // Place: position children within the given bounds + layout :: (bounds: Frame); + + // Render: emit render nodes + render :: (ctx: *RenderContext, frame: Frame); + + // Event handling: return true if the event was consumed + handle_event :: (event: *Event, frame: Frame) -> bool; +} + +// A child view with its computed frame (set during layout) +ViewChild :: struct { + view: View; + computed_frame: Frame = .zero(); +}