From 0782353ffad9599ae2b7e50cf8e37901fd74528c Mon Sep 17 00:00:00 2001 From: agra Date: Wed, 4 Mar 2026 17:17:29 +0200 Subject: [PATCH] panels --- build.sx | 2 +- goldens/last_frame.png | Bin 212792 -> 227204 bytes main.sx | 227 +++++++++++--- ui/animation.sx | 59 ++++ ui/dock.sx | 697 +++++++++++++++++++++++++++++++++++++++++ ui/events.sx | 1 - ui/glyph_cache.sx | 94 +++++- ui/pipeline.sx | 66 +++- ui/renderer.sx | 92 +++--- ui/scroll_view.sx | 87 ++--- ui/stats_panel.sx | 63 ++++ ui/types.sx | 31 +- 12 files changed, 1288 insertions(+), 131 deletions(-) create mode 100644 ui/dock.sx create mode 100644 ui/stats_panel.sx diff --git a/build.sx b/build.sx index 8ba5658..1e94053 100644 --- a/build.sx +++ b/build.sx @@ -2,7 +2,7 @@ configure_build :: () { opts := build_options(); - inline if OS == { + if OS == { case .wasm: { output := if POINTER_SIZE == 4 then "sx-out/wasm32/index.html" diff --git a/goldens/last_frame.png b/goldens/last_frame.png index d2f5919f556907ac8b8bea674c422eec5ce0a123..2cfd4794608220623462ac6c5e33c6ca1d74ec49 100644 GIT binary patch delta 35737 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 delta 18700 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 XHcee0jWGuV_&IE3cJPIP 0 and g_last_perf > 0 { + g_delta_time = xx (current - g_last_perf) / xx freq; + } + g_last_perf = current; + + // Track FPS stats (skip first 10 frames for warmup) + if g_frame_count > 10 { + g_total_time += xx g_delta_time; + fps : f32 = 1.0 / g_delta_time; + if fps < g_min_fps { g_min_fps = fps; } + if fps > g_max_fps { g_max_fps = fps; } + } + g_frame_count += 1; +} + +print_fps_summary :: () { + if g_frame_count <= 11 or g_total_time <= 0.0 { return; } + measured : u64 = g_frame_count - 11; + if measured > 0 { + avg_fps : f32 = xx measured / xx g_total_time; + passed := avg_fps >= FPS_REGRESSION_THRESHOLD; + status := if passed then "PASS" else "FAIL"; + out("\n=== FPS Summary ===\n"); + print("Frames: {}\n", measured); + print("Time: {}s\n", g_total_time); + print("Avg: {} FPS\n", xx avg_fps); + print("Min: {} FPS\n", xx g_min_fps); + print("Max: {} FPS\n", xx g_max_fps); + out("-------------------\n"); + print("Threshold: {} FPS\n", xx FPS_REGRESSION_THRESHOLD); + print("Status: {}\n", status); + out("===================\n"); + } +} + load_texture :: (path: [:0]u8) -> u32 { w : s32 = 0; h : s32 = 0; @@ -62,6 +114,87 @@ save_snapshot :: (path: [:0]u8, w: s32, h: s32) { out("\n"); } +run_dock_drag_test :: (pipeline: *UIPipeline) { + out("=== Dock Drag Test: move Statistics panel to left zone ===\n"); + + // Initial layout pass + glClearColor(0.12, 0.12, 0.15, 1.0); + glClear(GL_COLOR_BUFFER_BIT); + pipeline.tick(); + + // Print the initial interaction state + print("BEFORE drag: has_override[1]={}, is_floating[1]={}\n", + g_dock_interaction.has_alignment_override.items[1], + g_dock_interaction.is_floating.items[1]); + print("BEFORE drag: child_bounds[1]=({},{} {}x{})\n", + g_dock_interaction.child_bounds.items[1].origin.x, + g_dock_interaction.child_bounds.items[1].origin.y, + g_dock_interaction.child_bounds.items[1].size.width, + g_dock_interaction.child_bounds.items[1].size.height); + + // Statistics panel (index 1) is at ALIGN_TOP_TRAILING. + // Its header is the top 28px of the panel frame. + // Use the actual child_bounds to find the header center. + panel_frame := g_dock_interaction.child_bounds.items[1]; + header_x := panel_frame.origin.x + panel_frame.size.width * 0.5; + header_y := panel_frame.origin.y + 14.0; // middle of 28px header + header_pos := Point.{ x = header_x, y = header_y }; + print("clicking header at ({}, {})\n", header_x, header_y); + + // Step 1: Mouse down on the Statistics panel header + e : Event = .mouse_down(MouseButtonData.{ position = header_pos, button = .left }); + pipeline.dispatch_event(@e); + print("after mouse_down: dragging_child={}\n", g_dock_interaction.dragging_child); + + // Step 2: Drag to the "left" zone. + // Left zone hint: (8, cy, 40, 40) where cy = (height - 40) / 2 + // Use actual screen size from pipeline + screen_w := pipeline.screen_width; + screen_h := pipeline.screen_height; + zone_cx := 8.0 + 20.0; // center of left zone hint + zone_cy := screen_h * 0.5; + print("screen={}x{}, left zone center=({}, {})\n", xx screen_w, xx screen_h, zone_cx, zone_cy); + target := Point.{ x = zone_cx, y = zone_cy }; + steps : s64 = 20; + i : s64 = 1; + while i <= steps { + t : f32 = xx i / xx steps; + cur_x := header_pos.x + (target.x - header_pos.x) * t; + cur_y := header_pos.y + (target.y - header_pos.y) * t; + e = .mouse_moved(MouseMotionData.{ + position = Point.{ x = cur_x, y = cur_y }, + delta = Point.{ x = (target.x - header_pos.x) / xx steps, y = (target.y - header_pos.y) / xx steps } + }); + pipeline.dispatch_event(@e); + i += 1; + } + print("after drag: hovered_zone={}\n", g_dock_interaction.hovered_zone); + + // Step 3: Mouse up at the target zone + e = .mouse_up(MouseButtonData.{ position = target, button = .left }); + pipeline.dispatch_event(@e); + + // Check the result + print("AFTER drop: has_override[1]={}, is_floating[1]={}, is_fill[1]={}\n", + g_dock_interaction.has_alignment_override.items[1], + g_dock_interaction.is_floating.items[1], + g_dock_interaction.is_fill.items[1]); + print("AFTER drop: dragging_child={}\n", g_dock_interaction.dragging_child); + + // Render with new layout and save snapshot + glClear(GL_COLOR_BUFFER_BIT); + pipeline.tick(); + save_snapshot("goldens/test_dock_drag.png", g_pixel_w, g_pixel_h); + + // Print final child_bounds to see where the panel ended up + print("FINAL: child_bounds[1]=({},{} {}x{})\n", + g_dock_interaction.child_bounds.items[1].origin.x, + g_dock_interaction.child_bounds.items[1].origin.y, + g_dock_interaction.child_bounds.items[1].size.width, + g_dock_interaction.child_bounds.items[1].size.height); + out("=== end dock drag test ===\n"); +} + run_ui_tests :: (pipeline: *UIPipeline) { // Do a layout pass first so frames are computed glClearColor(0.12, 0.12, 0.15, 1.0); @@ -130,10 +263,10 @@ run_ui_tests :: (pipeline: *UIPipeline) { // One frame of the main loop — called repeatedly by emscripten or desktop while-loop frame :: () { + update_delta_time(); + sdl_event : SDL_Event = .none; while SDL_PollEvent(@sdl_event) { - print("SDL event: {}\n", sdl_event.tag); - if sdl_event == { case .quit: { g_running = false; } case .key_up: (e) { @@ -149,10 +282,7 @@ frame :: () { ui_event := translate_sdl_event(@sdl_event); if ui_event != .none { - print(" ui event dispatched\n"); g_pipeline.dispatch_event(@ui_event); - } else { - print(" -> .none\n"); } } @@ -161,8 +291,50 @@ frame :: () { glClear(GL_COLOR_BUFFER_BIT); g_pipeline.*.tick(); - SDL_GL_SwapWindow(g_window); + + // Auto-quit after 300 frames for benchmarking + if g_frame_count > 300 { g_running = false; } +} + +// Body function — rebuilds the entire view tree each frame (arena-allocated) +build_ui :: () -> View { + scroll_content := VStack.{ spacing = 10.0, alignment = .center } { + self.add( + Label.{ text = "Hello, SX!", font_size = 24.0, color = COLOR_WHITE } + |> padding(EdgeInsets.all(8.0)) + ); + self.add( + RectView.{ color = COLOR_YELLOW, preferred_height = 80.0, corner_radius = 8.0 } + |> padding(EdgeInsets.all(8.0)) + |> on_tap(closure(() { out("Yellow tapped!\n"); })) + ); + self.add( + Button.{ label = "Click Me", font_size = 14.0, style = ButtonStyle.default(), on_tap = closure(() { out("Button tapped!\n"); }) } + ); + self.add(HStack.{ spacing = 10.0, alignment = .center } { + self.add(RectView.{ color = COLOR_RED, preferred_width = 200.0, preferred_height = 300.0, corner_radius = 4.0 }); + self.add(RectView.{ color = COLOR_GREEN, preferred_width = 200.0, preferred_height = 300.0, corner_radius = 4.0 }); + }); + self.add( + RectView.{ color = COLOR_DARK_GRAY, preferred_height = 60.0 } + |> padding(.symmetric(16.0, 8.0)) + |> background(COLOR_BLUE, 8.0) + ); + self.add(RectView.{ color = COLOR_ORANGE, preferred_height = 120.0, corner_radius = 12.0 }); + self.add(RectView.{ color = COLOR_GRAY, preferred_height = 200.0, corner_radius = 8.0 }); + }; + + scroll := ScrollView.{ child = ViewChild.{ view = scroll_content }, state = @g_scroll_state, axes = .vertical }; + stats := StatsPanel.{ delta_time = @g_delta_time, font_size = 12.0 }; + + dock := Dock.make(g_dock_interaction); + content_panel := DockPanel.make("Content", ALIGN_CENTER, scroll); + content_panel.fill = true; + dock.add_panel(content_panel); + dock.add_panel(DockPanel.make("Statistics", ALIGN_TOP_TRAILING, stats)); + + xx dock; } main :: () -> void { @@ -198,7 +370,7 @@ main :: () -> void { window := SDL_CreateWindow("SX UI Demo", init_w, init_h, SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE | SDL_WINDOW_HIGH_PIXEL_DENSITY); gl_ctx := SDL_GL_CreateContext(window); SDL_GL_MakeCurrent(window, gl_ctx); - SDL_GL_SetSwapInterval(1); + SDL_GL_SetSwapInterval(0); load_gl(xx SDL_GL_GetProcAddress); @@ -213,42 +385,24 @@ main :: () -> void { glViewport(0, 0, g_pixel_w, g_pixel_h); // --- Build UI --- - pipeline : UIPipeline = ---; + pipeline : *UIPipeline = xx context.allocator.alloc(size_of(UIPipeline)); pipeline.init(width_f, height_f); pipeline.init_font("assets/fonts/default.ttf", 32.0, dpi_scale); - scroll_content := VStack.{ spacing = 10.0, alignment = .center } { - self.add( - Label.{ text = "Hello, SX!", font_size = 24.0, color = COLOR_WHITE } - |> padding(EdgeInsets.all(8.0)) - ); - self.add( - RectView.{ color = COLOR_YELLOW, preferred_height = 80.0, corner_radius = 8.0 } - |> padding(EdgeInsets.all(8.0)) - |> on_tap(closure(() { out("Yellow tapped!\n"); })) - ); - self.add( - Button.{ label = "Click Me", font_size = 14.0, style = ButtonStyle.default(), on_tap = closure(() { out("Button tapped!\n"); }) } - ); - self.add(HStack.{ spacing = 10.0, alignment = .center } { - self.add(RectView.{ color = COLOR_RED, preferred_width = 200.0, preferred_height = 300.0, corner_radius = 4.0 }); - self.add(RectView.{ color = COLOR_GREEN, preferred_width = 200.0, preferred_height = 300.0, corner_radius = 4.0 }); - }); - self.add( - RectView.{ color = COLOR_DARK_GRAY, preferred_height = 60.0 } - |> padding(.symmetric(16.0, 8.0)) - |> background(COLOR_BLUE, 8.0) - ); - self.add(RectView.{ color = COLOR_ORANGE, preferred_height = 120.0, corner_radius = 12.0 }); - self.add(RectView.{ color = COLOR_GRAY, preferred_height = 200.0, corner_radius = 8.0 }); - }; + // Initialize persistent state (on GPA, before arena is active) + g_scroll_state = ScrollState.{}; + g_dock_interaction = xx context.allocator.alloc(size_of(DockInteraction)); + g_dock_interaction.init(); + g_dock_delta_time = @g_delta_time; - root := ScrollView.{ child = ViewChild.{ view = scroll_content }, axes = .vertical }; - pipeline.set_root(root); + pipeline.set_body(closure(build_ui)); // Store state in globals for frame callback g_window = xx window; - g_pipeline = @pipeline; + g_pipeline = pipeline; + + // Reset perf counter so first frame doesn't include init time + g_last_perf = SDL_GetPerformanceCounter(); // --- Main loop --- inline if OS == .wasm { @@ -259,6 +413,7 @@ main :: () -> void { } } + print_fps_summary(); save_snapshot("goldens/last_frame.png", g_pixel_w, g_pixel_h); SDL_GL_DestroyContext(gl_ctx); diff --git a/ui/animation.sx b/ui/animation.sx index fcb33cd..9757789 100644 --- a/ui/animation.sx +++ b/ui/animation.sx @@ -1,6 +1,12 @@ #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; } @@ -106,3 +112,56 @@ SpringFloat :: struct { 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/ui/dock.sx b/ui/dock.sx new file mode 100644 index 0000000..cd9254c --- /dev/null +++ b/ui/dock.sx @@ -0,0 +1,697 @@ +#import "modules/std.sx"; +#import "modules/math"; +#import "ui/types.sx"; +#import "ui/render.sx"; +#import "ui/events.sx"; +#import "ui/view.sx"; +#import "ui/animation.sx"; +#import "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 = ViewChild.{ 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 = xx 0, // 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 = xx 0; + +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(ViewChild.{ 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/ui/events.sx b/ui/events.sx index b8ef287..aca9d78 100644 --- a/ui/events.sx +++ b/ui/events.sx @@ -57,7 +57,6 @@ translate_sdl_event :: (sdl: *SDL_Event) -> Event { }); } case .mouse_button_down: (data) { - print(" mouse_down raw: x={} y={} btn={}\n", data.x, data.y, data.button); btn :MouseButton = if data.button == { case 1: .left; case 2: .middle; diff --git a/ui/glyph_cache.sx b/ui/glyph_cache.sx index f3af409..d37a907 100644 --- a/ui/glyph_cache.sx +++ b/ui/glyph_cache.sx @@ -143,9 +143,14 @@ GlyphCache :: struct { cursor_x: s32; padding: s32; - // Glyph lookup cache (flat list, linear scan) + // 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; @@ -165,6 +170,11 @@ GlyphCache :: struct { 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)); @@ -226,6 +236,14 @@ GlyphCache :: struct { 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); @@ -247,13 +265,14 @@ GlyphCache :: struct { size_q := quantize_size(font_size); key := make_glyph_key(glyph_index, size_q); - // Cache lookup (linear scan) - i : s64 = 0; - while i < self.entries.len { - if self.entries.items[i].key == key { - return @self.entries.items[i].glyph; + // 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; } - i += 1; + slot = (slot + 1) & mask; } // Cache miss — rasterize @@ -288,6 +307,7 @@ GlyphCache :: struct { } }; self.entries.append(entry); + self.hash_insert(key, self.entries.len - 1); return @self.entries.items[self.entries.len - 1].glyph; } @@ -330,9 +350,58 @@ GlyphCache :: struct { } }; 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; } @@ -450,6 +519,12 @@ GlyphCache :: struct { // 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; } @@ -458,6 +533,11 @@ GlyphCache :: struct { } 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) { diff --git a/ui/pipeline.sx b/ui/pipeline.sx index 026bbc5..4cbf265 100644 --- a/ui/pipeline.sx +++ b/ui/pipeline.sx @@ -1,4 +1,5 @@ #import "modules/std.sx"; +#import "modules/allocators.sx"; #import "modules/opengl.sx"; #import "ui/types.sx"; #import "ui/render.sx"; @@ -15,12 +16,22 @@ UIPipeline :: struct { 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) { @@ -35,6 +46,16 @@ UIPipeline :: struct { 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; @@ -48,9 +69,12 @@ UIPipeline :: struct { // Run one frame: layout → render → commit tick :: (self: *UIPipeline) { + if self.has_body { + self.tick_with_body(); + return; + } if self.has_root == false { return; } - screen := Frame.make(0.0, 0.0, self.screen_width, self.screen_height); proposal := ProposedSize.fixed(self.screen_width, self.screen_height); // Layout @@ -67,11 +91,49 @@ UIPipeline :: struct { 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 = xx 0; + 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 = ViewChild.{ view = root_view }; + self.has_root = true; + + proposal := ProposedSize.fixed(self.screen_width, self.screen_height); + root_size := self.root.view.size_that_fits(proposal); + self.root.computed_frame = Frame.{ + origin = Point.zero(), + size = root_size + }; + 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.renderer.begin(self.screen_width, self.screen_height, self.font.texture_id); self.renderer.process(@self.render_tree); self.renderer.flush(); diff --git a/ui/renderer.sx b/ui/renderer.sx index fd460ca..31a37a2 100644 --- a/ui/renderer.sx +++ b/ui/renderer.sx @@ -25,6 +25,7 @@ UIRenderer :: struct { dpi_scale: f32; white_texture: u32; current_texture: u32; + draw_calls: s64; init :: (self: *UIRenderer) { // Create shader (ES for WASM/WebGL2, Core for desktop) @@ -70,11 +71,22 @@ UIRenderer :: struct { self.white_texture = create_white_texture(); } - begin :: (self: *UIRenderer, width: f32, height: f32) { + 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 = self.white_texture; + 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) { @@ -148,8 +160,13 @@ UIRenderer :: struct { } case .image: { self.bind_texture(node.texture_id); - self.push_quad(node.frame, COLOR_WHITE, 0.0, 0.0); - self.bind_texture(self.white_texture); + neg2 : f32 = 0.0 - 2.0; + self.push_quad(node.frame, COLOR_WHITE, neg2, 0.0); + // Re-bind font atlas after image + font := g_font; + if xx font != 0 { + self.bind_texture(font.texture_id); + } } case .clip_push: { self.flush(); @@ -176,27 +193,16 @@ UIRenderer :: struct { flush :: (self: *UIRenderer) { if self.vertex_count == 0 { return; } - glUseProgram(self.shader); - - // Orthographic projection: (0,0) top-left, (w,h) bottom-right - proj := Mat4.ortho(0.0, self.screen_width, self.screen_height, 0.0, -1.0, 1.0); - glUniformMatrix4fv(self.proj_loc, 1, 0, proj.data); - - // Bind current texture - glActiveTexture(GL_TEXTURE0); + // Only bind the current texture (program, projection, VAO already bound in begin()) glBindTexture(GL_TEXTURE_2D, self.current_texture); - glUniform1i(self.tex_loc, 0); - - glBindVertexArray(self.vao); - glBindBuffer(GL_ARRAY_BUFFER, self.vbo); upload_size : s64 = self.vertex_count * UI_VERTEX_BYTES; - glBufferSubData(GL_ARRAY_BUFFER, 0, xx upload_size, self.vertices); - + // 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); - glBindVertexArray(0); self.vertex_count = 0; + self.draw_calls += 1; } render_text :: (self: *UIRenderer, node: RenderNode) { @@ -206,9 +212,8 @@ UIRenderer :: struct { // Shape text into positioned glyphs font.shape_text(node.text, node.font_size); - // Flush any new glyphs to the atlas texture before rendering + // Flush any new glyphs to the atlas texture (no texture switch needed — atlas is already bound) font.flush(); - self.bind_texture(font.texture_id); r := node.text_color.rf(); g := node.text_color.gf(); @@ -256,7 +261,6 @@ UIRenderer :: struct { // Flush any glyphs rasterized during this text draw font.flush(); - self.bind_texture(self.white_texture); } } @@ -312,33 +316,37 @@ float roundedBoxSDF(vec2 center, vec2 half_size, float radius) { } void main() { - float radius = vParams.x; + float mode = vParams.x; float border = vParams.y; vec2 rectSize = vParams.zw; - if (radius < 0.0) { + 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 (radius > 0.0 || border > 0.0) { - vec4 texColor = texture(uTex, vUV); + } 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, radius); + 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(radius - 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(texColor.rgb * vColor.rgb, texColor.a * vColor.a * alpha); + FragColor = vec4(vColor.rgb, vColor.a * alpha); } else { - vec4 texColor = texture(uTex, vUV); - FragColor = texColor * vColor; + // Plain rect: vertex color only (no texture sample) + FragColor = vColor; } } GLSL; @@ -384,33 +392,37 @@ float roundedBoxSDF(vec2 center, vec2 half_size, float radius) { } void main() { - float radius = vParams.x; + float mode = vParams.x; float border = vParams.y; vec2 rectSize = vParams.zw; - if (radius < 0.0) { + 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 (radius > 0.0 || border > 0.0) { - vec4 texColor = texture(uTex, vUV); + } 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, radius); + 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(radius - 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(texColor.rgb * vColor.rgb, texColor.a * vColor.a * alpha); + FragColor = vec4(vColor.rgb, vColor.a * alpha); } else { - vec4 texColor = texture(uTex, vUV); - FragColor = texColor * vColor; + // Plain rect: vertex color only + FragColor = vColor; } } GLSL; diff --git a/ui/scroll_view.sx b/ui/scroll_view.sx index 9bda1f6..4da898c 100644 --- a/ui/scroll_view.sx +++ b/ui/scroll_view.sx @@ -7,64 +7,71 @@ ScrollAxes :: enum { vertical; horizontal; both; } -ScrollView :: struct { - child: ViewChild; +// Persistent scroll state — lives outside the frame arena +ScrollState :: struct { offset: Point; content_size: Size; viewport_size: Size; - axes: ScrollAxes; 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) { - max_x := max(0.0, self.content_size.width - self.viewport_size.width); - max_y := max(0.0, self.content_size.height - self.viewport_size.height); + 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 { - self.offset.y = clamp(self.offset.y, 0.0, max_y); + s.offset.y = clamp(s.offset.y, 0.0, max_y); } else { - self.offset.y = 0.0; + s.offset.y = 0.0; } if self.axes == .horizontal or self.axes == .both { - self.offset.x = clamp(self.offset.x, 0.0, max_x); + s.offset.x = clamp(s.offset.x, 0.0, max_x); } else { - self.offset.x = 0.0; + s.offset.x = 0.0; } } } impl View for ScrollView { size_that_fits :: (self: *ScrollView, proposal: ProposedSize) -> Size { - // ScrollView takes all proposed space + // ScrollView takes all proposed space (default 200 if unspecified) Size.{ - width = proposal.width ?? 0.0, - height = proposal.height ?? 0.0 + width = proposal.width ?? 200.0, + height = proposal.height ?? 200.0 }; } layout :: (self: *ScrollView, bounds: Frame) { - self.viewport_size = bounds.size; + 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 }; - self.content_size = self.child.view.size_that_fits(child_proposal); + 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 - self.offset.x, - bounds.origin.y - self.offset.y, - self.content_size.width, - self.content_size.height + 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); } @@ -76,41 +83,38 @@ impl View for ScrollView { } handle_event :: (self: *ScrollView, event: *Event, frame: Frame) -> bool { + s := self.state; + if pos := event_position(event) { - print(" ScrollView.handle_event: pos=({},{}) frame=({},{},{},{})\n", pos.x, pos.y, frame.origin.x, frame.origin.y, frame.size.width, frame.size.height); - if !frame.contains(pos) { print(" -> outside frame\n"); return false; } + if !frame.contains(pos) { return false; } } if event.* == { case .mouse_wheel: (d) { if self.axes == .vertical or self.axes == .both { - self.offset.y -= d.delta.y * ScrollView.SCROLL_SPEED; + s.offset.y -= d.delta.y * ScrollView.SCROLL_SPEED; } if self.axes == .horizontal or self.axes == .both { - self.offset.x -= d.delta.x * ScrollView.SCROLL_SPEED; + s.offset.x -= d.delta.x * ScrollView.SCROLL_SPEED; } self.clamp_offset(); return true; } case .mouse_down: (d) { - // Always record drag start (like Zig preHandleEvent) - self.drag_pending = true; - self.drag_start = d.position; - self.drag_offset = self.offset; - // Forward to children — let buttons/tappables handle too + 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) { - // Activate drag once movement exceeds threshold - if self.drag_pending and !self.dragging { - dx := d.position.x - self.drag_start.x; - dy := d.position.y - self.drag_start.y; + 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 { - self.dragging = true; - self.drag_pending = false; - // Cancel child press state — position far outside so on_tap won't fire + s.dragging = true; + s.drag_pending = false; cancel :Event = .mouse_up(MouseButtonData.{ position = Point.{ x = 0.0 - 10000.0, y = 0.0 - 10000.0 }, button = .none @@ -118,31 +122,28 @@ impl View for ScrollView { self.child.view.handle_event(@cancel, self.child.computed_frame); } } - if self.dragging { + if s.dragging { if self.axes == .vertical or self.axes == .both { - self.offset.y = self.drag_offset.y - (d.position.y - self.drag_start.y); + s.offset.y = s.drag_offset.y - (d.position.y - s.drag_start.y); } if self.axes == .horizontal or self.axes == .both { - self.offset.x = self.drag_offset.x - (d.position.x - self.drag_start.x); + s.offset.x = s.drag_offset.x - (d.position.x - s.drag_start.x); } self.clamp_offset(); return true; } - // Forward mouse_moved to children (for hover effects) return self.child.view.handle_event(event, self.child.computed_frame); } case .mouse_up: { - was_dragging := self.dragging; - self.dragging = false; - self.drag_pending = false; + was_dragging := s.dragging; + s.dragging = false; + s.drag_pending = false; if was_dragging { return true; } - // Forward to children (for tap completion) return self.child.view.handle_event(event, self.child.computed_frame); } } - // Forward other events to child self.child.view.handle_event(event, self.child.computed_frame); } } diff --git a/ui/stats_panel.sx b/ui/stats_panel.sx new file mode 100644 index 0000000..e86b3ad --- /dev/null +++ b/ui/stats_panel.sx @@ -0,0 +1,63 @@ +#import "modules/std.sx"; +#import "modules/math"; +#import "ui/types.sx"; +#import "ui/render.sx"; +#import "ui/events.sx"; +#import "ui/view.sx"; +#import "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/ui/types.sx b/ui/types.sx index b030ec3..f60b24e 100644 --- a/ui/types.sx +++ b/ui/types.sx @@ -47,6 +47,8 @@ Frame :: struct { 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() @@ -71,6 +73,15 @@ Frame :: struct { 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 { @@ -170,7 +181,9 @@ 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 :: 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 { @@ -189,3 +202,19 @@ align_v :: (alignment: VAlignment, child_height: f32, container_height: f32) -> case .bottom: container_height - child_height; } } + +// --- Lerpable implementations --- + +#import "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 }; + } +}