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 d2f5919..2cfd479 100644 Binary files a/goldens/last_frame.png and b/goldens/last_frame.png differ diff --git a/main.sx b/main.sx index 934cc62..0b4f7ea 100644 --- a/main.sx +++ b/main.sx @@ -20,6 +20,58 @@ g_height : s32 = 600; g_pixel_w : s32 = 800; // physical pixel size g_pixel_h : s32 = 600; +// --- FPS / delta time tracking --- +g_delta_time : f32 = 0.008; +g_last_perf : u64 = 0; +g_frame_count : u64 = 0; +g_total_time : f64 = 0.0; +g_min_fps : f32 = 999999.0; +g_max_fps : f32 = 0.0; + +// --- Persistent UI state (survives arena resets) --- +g_scroll_state : ScrollState = ---; +g_dock_interaction : *DockInteraction = xx 0; + +FPS_REGRESSION_THRESHOLD :f32: 1400.0; + +update_delta_time :: () { + current := SDL_GetPerformanceCounter(); + freq := SDL_GetPerformanceFrequency(); + if freq > 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 }; + } +}