This commit is contained in:
agra
2026-03-04 17:17:29 +02:00
parent 343ea4bf08
commit 0782353ffa
12 changed files with 1288 additions and 131 deletions

View File

@@ -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"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 208 KiB

After

Width:  |  Height:  |  Size: 222 KiB

227
main.sx
View File

@@ -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);

View File

@@ -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; }
}

697
ui/dock.sx Normal file
View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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();

View File

@@ -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;

View File

@@ -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);
}
}

63
ui/stats_panel.sx Normal file
View File

@@ -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;
}
}

View File

@@ -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 };
}
}