ui: port game UI framework into library/modules/ui

20 files (~3,830 lines): view protocol, layout, renderer, glyph cache,
fonts, gestures, animation, scroll, stacks, modifiers, etc.

Internal imports rewritten from "ui/..." to "modules/ui/...".
Consumers now `#import "modules/ui"` from any project; no symlink
hacks needed. Verified by compiling game/main.sx without its local
ui/ — resolves via the Phase 6 stdlib fallback.
This commit is contained in:
agra
2026-05-17 13:54:11 +03:00
parent c027e1969b
commit dc8529e3ea
20 changed files with 3830 additions and 0 deletions

167
library/modules/ui/animation.sx Executable file
View File

@@ -0,0 +1,167 @@
#import "modules/std.sx";
#import "modules/math";
// --- Lerpable protocol (inline — static dispatch, no vtable) ---
Lerpable :: protocol #inline {
lerp :: (b: Self, t: f32) -> Self;
}
// --- Easing Functions ---
ease_linear :: (t: f32) -> f32 { t; }
ease_in_quad :: (t: f32) -> f32 { t * t; }
ease_out_quad :: (t: f32) -> f32 { t * (2.0 - t); }
ease_in_out_quad :: (t: f32) -> f32 {
if t < 0.5 then 2.0 * t * t
else -1.0 + (4.0 - 2.0 * t) * t;
}
ease_out_cubic :: (t: f32) -> f32 { u := t - 1.0; u * u * u + 1.0; }
// --- AnimatedFloat — duration-based ---
AnimatedFloat :: struct {
current: f32;
from: f32;
to: f32;
elapsed: f32;
duration: f32;
easing: ?Closure(f32) -> f32;
active: bool;
make :: (value: f32) -> AnimatedFloat {
AnimatedFloat.{
current = value,
from = value,
to = value,
elapsed = 0.0,
duration = 0.0,
easing = null,
active = false
};
}
animate_to :: (self: *AnimatedFloat, target: f32, dur: f32, ease: Closure(f32) -> f32) {
self.from = self.current;
self.to = target;
self.elapsed = 0.0;
self.duration = dur;
self.easing = ease;
self.active = true;
}
tick :: (self: *AnimatedFloat, dt: f32) {
if !self.active { return; }
self.elapsed += dt;
t := clamp(self.elapsed / self.duration, 0.0, 1.0);
eased := if ease := self.easing { ease(t); } else { t; };
self.current = self.from + (self.to - self.from) * eased;
if t >= 1.0 {
self.current = self.to;
self.active = false;
}
}
}
// --- SpringFloat — physics-based ---
SpringFloat :: struct {
current: f32;
velocity: f32;
target: f32;
stiffness: f32;
damping: f32;
mass: f32;
threshold: f32;
make :: (value: f32) -> SpringFloat {
SpringFloat.{
current = value,
velocity = 0.0,
target = value,
stiffness = 200.0,
damping = 20.0,
mass = 1.0,
threshold = 0.01
};
}
snappy :: (value: f32) -> SpringFloat {
SpringFloat.{
current = value,
velocity = 0.0,
target = value,
stiffness = 300.0,
damping = 25.0,
mass = 1.0,
threshold = 0.01
};
}
tick :: (self: *SpringFloat, dt: f32) {
if self.is_settled() { return; }
force := 0.0 - self.stiffness * (self.current - self.target);
damping_force := 0.0 - self.damping * self.velocity;
accel := (force + damping_force) / self.mass;
self.velocity += accel * dt;
self.current += self.velocity * dt;
}
is_settled :: (self: *SpringFloat) -> bool {
abs(self.current - self.target) < self.threshold
and abs(self.velocity) < self.threshold;
}
}
// --- Animated(T) — generic duration-based animation for any Lerpable type ---
Animated :: struct ($T: Lerpable) {
current: T;
from: T;
to: T;
elapsed: f32;
duration: f32;
active: bool;
make :: (value: T) -> Animated(T) {
Animated(T).{
current = value,
from = value,
to = value,
elapsed = 0.0,
duration = 0.0,
active = false
};
}
// Jump immediately to value (no animation). Used to avoid animating from zero on first layout.
set_immediate :: (self: *Animated(T), value: T) {
self.current = value;
self.from = value;
self.to = value;
self.elapsed = 0.0;
self.active = false;
}
// Start animating towards target.
animate_to :: (self: *Animated(T), target: T, dur: f32) {
self.from = self.current;
self.to = target;
self.elapsed = 0.0;
self.duration = dur;
self.active = true;
}
tick :: (self: *Animated(T), dt: f32) {
if !self.active { return; }
self.elapsed += dt;
t := clamp(self.elapsed / self.duration, 0.0, 1.0);
self.current = self.from.lerp(self.to, t);
if t >= 1.0 {
self.current = self.to;
self.active = false;
}
}
is_animating :: (self: *Animated(T)) -> bool { self.active; }
}

91
library/modules/ui/button.sx Executable file
View File

@@ -0,0 +1,91 @@
#import "modules/std.sx";
#import "modules/ui/types.sx";
#import "modules/ui/render.sx";
#import "modules/ui/events.sx";
#import "modules/ui/view.sx";
#import "modules/ui/label.sx";
#import "modules/ui/font.sx";
ButtonStyle :: struct {
background: Color;
foreground: Color;
hover_bg: Color;
pressed_bg: Color;
corner_radius: f32;
padding: EdgeInsets;
default :: () -> ButtonStyle {
ButtonStyle.{
background = COLOR_BLUE,
foreground = COLOR_WHITE,
hover_bg = Color.rgb(0, 100, 220),
pressed_bg = Color.rgb(0, 80, 180),
corner_radius = 6.0,
padding = EdgeInsets.symmetric(16.0, 8.0)
};
}
}
Button :: struct {
label: string;
font_size: f32;
style: ButtonStyle;
on_tap: ?Closure();
hovered: bool;
pressed: bool;
}
impl View for Button {
size_that_fits :: (self: *Button, proposal: ProposedSize) -> Size {
text_size := measure_text(self.label, self.font_size);
Size.{
width = text_size.width + self.style.padding.horizontal(),
height = text_size.height + self.style.padding.vertical()
};
}
layout :: (self: *Button, bounds: Frame) {}
render :: (self: *Button, ctx: *RenderContext, frame: Frame) {
bg := if self.pressed then self.style.pressed_bg
else if self.hovered then self.style.hover_bg
else self.style.background;
ctx.add_rounded_rect(frame, bg, self.style.corner_radius);
// Text centered in frame
text_size := measure_text(self.label, self.font_size);
text_x := frame.origin.x + (frame.size.width - text_size.width) * 0.5;
text_y := frame.origin.y + (frame.size.height - text_size.height) * 0.5;
text_frame := Frame.make(text_x, text_y, text_size.width, text_size.height);
ctx.add_text(text_frame, self.label, self.font_size, self.style.foreground);
}
handle_event :: (self: *Button, event: *Event, frame: Frame) -> bool {
if event.* == {
case .mouse_moved: (d) {
self.hovered = frame.contains(d.position);
return false;
}
case .mouse_down: (d) {
if frame.contains(d.position) {
self.pressed = true;
return true;
}
}
case .mouse_up: (d) {
if self.pressed {
self.pressed = false;
if frame.contains(d.position) {
if handler := self.on_tap {
handler();
}
}
return true;
}
}
}
false;
}
}

697
library/modules/ui/dock.sx Executable file
View File

@@ -0,0 +1,697 @@
#import "modules/std.sx";
#import "modules/math";
#import "modules/ui/types.sx";
#import "modules/ui/render.sx";
#import "modules/ui/events.sx";
#import "modules/ui/view.sx";
#import "modules/ui/animation.sx";
#import "modules/ui/font.sx";
// =============================================================================
// DockZone — where a panel can be dropped
// =============================================================================
DockZone :: enum {
floating;
fill;
center;
top;
bottom;
left;
right;
top_left;
top_right;
bottom_left;
bottom_right;
}
dock_zone_get_hint_frame :: (zone: DockZone, bounds: Frame, hint_size: f32) -> Frame {
pad :f32: 8.0;
cx := bounds.origin.x + (bounds.size.width - hint_size) * 0.5;
cy := bounds.origin.y + (bounds.size.height - hint_size) * 0.5;
if zone == {
case .floating: Frame.zero();
case .fill: Frame.make(cx, cy, hint_size * 1.2, hint_size * 1.2);
case .center: Frame.make(cx, cy, hint_size, hint_size);
case .top: Frame.make(cx, bounds.origin.y + pad, hint_size, hint_size);
case .bottom: Frame.make(cx, bounds.max_y() - hint_size - pad, hint_size, hint_size);
case .left: Frame.make(bounds.origin.x + pad, cy, hint_size, hint_size);
case .right: Frame.make(bounds.max_x() - hint_size - pad, cy, hint_size, hint_size);
case .top_left: Frame.make(bounds.origin.x + pad, bounds.origin.y + pad, hint_size, hint_size);
case .top_right: Frame.make(bounds.max_x() - hint_size - pad, bounds.origin.y + pad, hint_size, hint_size);
case .bottom_left: Frame.make(bounds.origin.x + pad, bounds.max_y() - hint_size - pad, hint_size, hint_size);
case .bottom_right:Frame.make(bounds.max_x() - hint_size - pad, bounds.max_y() - hint_size - pad, hint_size, hint_size);
}
}
dock_zone_get_preview_frame :: (zone: DockZone, bounds: Frame) -> Frame {
hw := bounds.size.width * 0.5;
hh := bounds.size.height * 0.5;
if zone == {
case .floating: Frame.zero();
case .fill: bounds;
case .center: Frame.make(bounds.origin.x + bounds.size.width * 0.25, bounds.origin.y + bounds.size.height * 0.25, bounds.size.width * 0.5, bounds.size.height * 0.5);
case .top: Frame.make(bounds.origin.x, bounds.origin.y, bounds.size.width, hh);
case .bottom: Frame.make(bounds.origin.x, bounds.origin.y + hh, bounds.size.width, hh);
case .left: Frame.make(bounds.origin.x, bounds.origin.y, hw, bounds.size.height);
case .right: Frame.make(bounds.origin.x + hw, bounds.origin.y, hw, bounds.size.height);
case .top_left: Frame.make(bounds.origin.x, bounds.origin.y, hw, hh);
case .top_right: Frame.make(bounds.origin.x + hw, bounds.origin.y, hw, hh);
case .bottom_left: Frame.make(bounds.origin.x, bounds.origin.y + hh, hw, hh);
case .bottom_right:Frame.make(bounds.origin.x + hw, bounds.origin.y + hh, hw, hh);
}
}
dock_zone_to_alignment :: (zone: DockZone) -> ?Alignment {
if zone == {
case .floating: null;
case .fill: ALIGN_CENTER;
case .center: ALIGN_CENTER;
case .top: ALIGN_TOP;
case .bottom: ALIGN_BOTTOM;
case .left: ALIGN_LEADING;
case .right: ALIGN_TRAILING;
case .top_left: ALIGN_TOP_LEADING;
case .top_right: ALIGN_TOP_TRAILING;
case .bottom_left: ALIGN_BOTTOM_LEADING;
case .bottom_right: ALIGN_BOTTOM_TRAILING;
}
}
dock_zone_should_fill :: (zone: DockZone) -> bool {
zone == .fill;
}
// =============================================================================
// DockInteraction — persistent drag state for the dock
// =============================================================================
DOCK_ANIM_DURATION :f32: 0.19; // 190ms
DockInteraction :: struct {
// Drag state
dragging_child: s64; // -1 = none
drag_start_pos: Point;
drag_offset: Point;
click_fraction_x: f32;
click_fraction_y: f32;
hovered_zone: s64; // -1 = none, else DockZone ordinal
// Per-child state
natural_sizes: List(Size);
alignment_overrides: List(Alignment);
has_alignment_override: List(bool);
is_floating: List(bool);
is_fill: List(bool);
floating_positions: List(Point);
child_bounds: List(Frame);
anim_sizes: List(Animated(Size));
header_pressed: List(bool);
child_count: s64;
parent_allocator: Allocator; // GPA — used for persistent list growth
init :: (self: *DockInteraction) {
self.dragging_child = -1;
self.drag_start_pos = Point.zero();
self.drag_offset = Point.zero();
self.click_fraction_x = 0.0;
self.click_fraction_y = 0.0;
self.hovered_zone = -1;
self.child_count = 0;
self.parent_allocator = context.allocator; // capture GPA at init time
self.natural_sizes = List(Size).{};
self.alignment_overrides = List(Alignment).{};
self.has_alignment_override = List(bool).{};
self.is_floating = List(bool).{};
self.is_fill = List(bool).{};
self.floating_positions = List(Point).{};
self.child_bounds = List(Frame).{};
self.anim_sizes = List(Animated(Size)).{};
self.header_pressed = List(bool).{};
}
// BLOCKED on issue-0009: should use push instead of manual save/restore
ensure_capacity :: (self: *DockInteraction, count: s64) {
if self.child_count >= count { return; }
push Context.{ allocator = self.parent_allocator, data = context.data } {
while self.child_count < count {
self.natural_sizes.append(Size.zero());
self.alignment_overrides.append(ALIGN_CENTER);
self.has_alignment_override.append(false);
self.is_floating.append(false);
self.is_fill.append(false);
self.floating_positions.append(Point.zero());
self.child_bounds.append(Frame.zero());
self.anim_sizes.append(Animated(Size).make(Size.zero()));
self.header_pressed.append(false);
self.child_count += 1;
}
}
}
set_target_size :: (self: *DockInteraction, index: s64, target: Size) {
if index >= self.child_count { return; }
anim := @self.anim_sizes.items[index];
cur := anim.to;
// First time (target is ~0): jump immediately, don't animate from zero
if cur.width < 1.0 and cur.height < 1.0 {
anim.set_immediate(target);
return;
}
// Only animate if target changed significantly
if abs(cur.width - target.width) > 0.5 or abs(cur.height - target.height) > 0.5 {
anim.animate_to(target, DOCK_ANIM_DURATION);
}
}
get_animated_size :: (self: *DockInteraction, index: s64) -> Size {
if index >= self.child_count { return Size.zero(); }
(@self.anim_sizes.items[index]).current;
}
tick_animations :: (self: *DockInteraction, dt: f32) {
i := 0;
while i < self.child_count {
anim := @self.anim_sizes.items[i];
anim.tick(dt);
i += 1;
}
}
get_hovered_dock_zone :: (self: *DockInteraction) -> ?DockZone {
if self.hovered_zone < 0 { return null; }
// Map ordinal back to DockZone
cast(DockZone) self.hovered_zone;
}
set_hovered_dock_zone :: (self: *DockInteraction, zone: ?DockZone) {
if z := zone {
self.hovered_zone = xx z;
} else {
self.hovered_zone = -1;
}
}
}
start_dragging :: (interaction: *DockInteraction, child_index: s64, pos: Point, panel_frame: Frame) {
interaction.dragging_child = child_index;
interaction.drag_start_pos = pos;
interaction.drag_offset = Point.zero();
if panel_frame.size.width > 0.0 {
interaction.click_fraction_x = (pos.x - panel_frame.origin.x) / panel_frame.size.width;
} else {
interaction.click_fraction_x = 0.0;
}
if panel_frame.size.height > 0.0 {
interaction.click_fraction_y = (pos.y - panel_frame.origin.y) / panel_frame.size.height;
} else {
interaction.click_fraction_y = 0.0;
}
}
// =============================================================================
// Helper functions
// =============================================================================
zone_by_index :: (i: s64) -> DockZone {
if i == {
case 0: .fill;
case 1: .center;
case 2: .top;
case 3: .bottom;
case 4: .left;
case 5: .right;
case 6: .top_left;
case 7: .top_right;
case 8: .bottom_left;
case 9: .bottom_right;
}
}
find_hovered_zone :: (bounds: Frame, pos: Point, hint_size: f32, enable_corners: bool) -> ?DockZone {
count : s64 = if enable_corners then 10 else 6;
i : s64 = 0;
while i < count {
zone := zone_by_index(i);
hint := dock_zone_get_hint_frame(zone, bounds, hint_size);
expanded := hint.expand(8.0);
if expanded.contains(pos) { return zone; }
i += 1;
}
null;
}
calculate_origin :: (bounds: Frame, child_size: Size, alignment: Alignment) -> Point {
x : f32 = bounds.origin.x;
if alignment.h == .center {
x = bounds.origin.x + (bounds.size.width - child_size.width) * 0.5;
}
if alignment.h == .trailing {
x = bounds.origin.x + bounds.size.width - child_size.width;
}
y : f32 = bounds.origin.y;
if alignment.v == .center {
y = bounds.origin.y + (bounds.size.height - child_size.height) * 0.5;
}
if alignment.v == .bottom {
y = bounds.origin.y + bounds.size.height - child_size.height;
}
Point.{ x = x, y = y };
}
get_size_proposal_for_alignment :: (alignment: Alignment, bounds_size: Size, is_fill: bool) -> ProposedSize {
if is_fill { return ProposedSize.fixed(bounds_size.width, bounds_size.height); }
// Edge docking: constrain one axis, leave other natural
if alignment.h == .leading or alignment.h == .trailing {
if alignment.v == .center {
return ProposedSize.{ width = null, height = bounds_size.height };
}
}
if alignment.v == .top or alignment.v == .bottom {
if alignment.h == .center {
return ProposedSize.{ width = bounds_size.width, height = null };
}
}
// Center or corners: natural size
ProposedSize.flexible();
}
get_final_size_for_alignment :: (alignment: Alignment, child_size: Size, bounds_size: Size, is_fill: bool) -> Size {
if is_fill { return bounds_size; }
// Left/Right edges: fill height
if alignment.h == .leading or alignment.h == .trailing {
if alignment.v == .center {
return Size.{ width = child_size.width, height = bounds_size.height };
}
}
// Top/Bottom edges: fill width
if alignment.v == .top or alignment.v == .bottom {
if alignment.h == .center {
return Size.{ width = bounds_size.width, height = child_size.height };
}
}
// Center or corners: natural size
child_size;
}
draw_zone_indicator :: (ctx: *RenderContext, frame: Frame, zone: DockZone, color: Color) {
indicator_size := frame.size.width * 0.4;
cx := frame.mid_x();
cy := frame.mid_y();
offset := frame.size.width * 0.15;
if zone == {
case .floating: {}
case .fill: {
s := indicator_size * 0.8;
ctx.add_rect(Frame.make(cx - s * 0.5, cy - s * 0.5, s, s), color);
}
case .center: {
s := indicator_size * 0.5;
ctx.add_rect(Frame.make(cx - s * 0.5, cy - s * 0.5, s, s), color);
}
case .top: {
ctx.add_rect(Frame.make(cx - indicator_size * 0.5, cy - offset - indicator_size * 0.25, indicator_size, indicator_size * 0.5), color);
}
case .bottom: {
ctx.add_rect(Frame.make(cx - indicator_size * 0.5, cy + offset - indicator_size * 0.25, indicator_size, indicator_size * 0.5), color);
}
case .left: {
ctx.add_rect(Frame.make(cx - offset - indicator_size * 0.25, cy - indicator_size * 0.5, indicator_size * 0.5, indicator_size), color);
}
case .right: {
ctx.add_rect(Frame.make(cx + offset - indicator_size * 0.25, cy - indicator_size * 0.5, indicator_size * 0.5, indicator_size), color);
}
case .top_left: {
s := indicator_size * 0.5;
ctx.add_rect(Frame.make(cx - offset, cy - offset, s, s), color);
}
case .top_right: {
s := indicator_size * 0.5;
ctx.add_rect(Frame.make(cx + offset - s, cy - offset, s, s), color);
}
case .bottom_left: {
s := indicator_size * 0.5;
ctx.add_rect(Frame.make(cx - offset, cy + offset - s, s, s), color);
}
case .bottom_right: {
s := indicator_size * 0.5;
ctx.add_rect(Frame.make(cx + offset - s, cy + offset - s, s, s), color);
}
}
}
// =============================================================================
// DockPanel — a draggable panel within a Dock
// =============================================================================
DockPanel :: struct {
child: ViewChild;
title: string;
dock: Alignment;
fill: bool;
background: Color;
header_background: Color;
header_text_color: Color;
corner_radius: f32;
header_height: f32;
dock_interaction: *DockInteraction;
panel_index: s64;
DEFAULT_BG :Color: Color.rgba(26, 26, 31, 242);
DEFAULT_HEADER_BG :Color: Color.rgba(38, 38, 46, 255);
DEFAULT_HEADER_TEXT:Color: COLOR_WHITE;
DEFAULT_RADIUS :f32: 8.0;
DEFAULT_HEADER_H :f32: 28.0;
make :: (title: string, dock: Alignment, content: View) -> DockPanel {
DockPanel.{
child = .{ view = content },
title = title,
dock = dock,
fill = false,
background = DockPanel.DEFAULT_BG,
header_background = DockPanel.DEFAULT_HEADER_BG,
header_text_color = DockPanel.DEFAULT_HEADER_TEXT,
corner_radius = DockPanel.DEFAULT_RADIUS,
header_height = DockPanel.DEFAULT_HEADER_H,
dock_interaction = null, // set by Dock.add_panel
panel_index = 0
};
}
}
impl View for DockPanel {
size_that_fits :: (self: *DockPanel, proposal: ProposedSize) -> Size {
content_size := self.child.view.size_that_fits(ProposedSize.{ width = proposal.width, height = null });
w := if pw := proposal.width { min(content_size.width, pw); } else { content_size.width; };
Size.{ width = w, height = content_size.height + self.header_height };
}
layout :: (self: *DockPanel, bounds: Frame) {
content_frame := Frame.make(
bounds.origin.x,
bounds.origin.y + self.header_height,
bounds.size.width,
bounds.size.height - self.header_height
);
self.child.computed_frame = content_frame;
self.child.view.layout(content_frame);
}
render :: (self: *DockPanel, ctx: *RenderContext, frame: Frame) {
// Panel background
ctx.add_rounded_rect(frame, self.background, self.corner_radius);
// Header background
header_frame := Frame.make(frame.origin.x, frame.origin.y, frame.size.width, self.header_height);
ctx.add_rounded_rect(header_frame, self.header_background, self.corner_radius);
// Title text
title_size := measure_text(self.title, 12.0);
title_x := header_frame.origin.x + (header_frame.size.width - title_size.width) * 0.5;
title_y := header_frame.origin.y + (header_frame.size.height - title_size.height) * 0.5;
title_frame := Frame.make(title_x, title_y, title_size.width, title_size.height);
ctx.add_text(title_frame, self.title, 12.0, self.header_text_color);
// Child content
self.child.view.render(ctx, self.child.computed_frame);
}
handle_event :: (self: *DockPanel, event: *Event, frame: Frame) -> bool {
header_frame := Frame.make(frame.origin.x, frame.origin.y, frame.size.width, self.header_height);
idx := self.panel_index;
if event.* == {
case .mouse_down: (d) {
if header_frame.contains(d.position) {
self.dock_interaction.header_pressed.items[idx] = true;
start_dragging(self.dock_interaction, idx, d.position, frame);
return true;
}
}
case .mouse_up: (d) {
if self.dock_interaction.header_pressed.items[idx] {
self.dock_interaction.header_pressed.items[idx] = false;
}
}
}
// Forward to child content
self.child.view.handle_event(event, self.child.computed_frame);
}
}
// =============================================================================
// Dock — dockable container with drag-and-drop zones
// =============================================================================
// Global delta_time pointer — set by main.sx
g_dock_delta_time : *f32 = null;
Dock :: struct {
children: List(ViewChild);
alignments: List(Alignment);
interaction: *DockInteraction; // heap-allocated, shared with DockPanels
// Config
background: ?Color;
corner_radius: f32;
hint_size: f32;
hint_color: Color;
hint_active_color: Color;
preview_color: Color;
enable_corners: bool;
on_dock: ?Closure(s64, DockZone);
make :: (interaction: *DockInteraction) -> Dock {
d : Dock = ---;
d.children = List(ViewChild).{};
d.alignments = List(Alignment).{};
d.interaction = interaction;
d.background = null;
d.corner_radius = 0.0;
d.hint_size = 40.0;
d.hint_color = Color.rgba(77, 153, 255, 153);
d.hint_active_color = Color.rgba(77, 153, 255, 230);
d.preview_color = Color.rgba(77, 153, 255, 64);
d.enable_corners = true;
d.on_dock = null;
d;
}
add_panel :: (self: *Dock, panel: DockPanel) {
idx := self.children.len;
p := panel;
p.dock_interaction = self.interaction; // share heap pointer
p.panel_index = idx;
self.alignments.append(panel.dock);
self.children.append(.{ view = p });
// Apply initial fill flag
if panel.fill {
self.interaction.ensure_capacity(idx + 1);
self.interaction.is_fill.items[idx] = true;
}
}
}
impl View for Dock {
size_that_fits :: (self: *Dock, proposal: ProposedSize) -> Size {
Size.{
width = proposal.width ?? 800.0,
height = proposal.height ?? 600.0
};
}
layout :: (self: *Dock, bounds: Frame) {
interaction := self.interaction;
interaction.ensure_capacity(self.children.len);
// Tick animations (g_dock_delta_time is always set before main loop)
dt : f32 = g_dock_delta_time.*;
interaction.tick_animations(dt);
i : s64 = 0;
while i < self.children.len {
child := @self.children.items[i];
natural_size := child.view.size_that_fits(ProposedSize.flexible());
interaction.natural_sizes.items[i] = natural_size;
is_being_dragged := interaction.dragging_child == i;
fl_val : bool = interaction.is_floating.items[i];
if fl_val {
// Floating: use natural size, position from stored floating pos
child_size := natural_size;
origin := interaction.floating_positions.items[i];
origin.x += bounds.origin.x;
origin.y += bounds.origin.y;
// Store bounds for hit testing (before drag offset)
interaction.child_bounds.items[i] = Frame.make(origin.x, origin.y, child_size.width, child_size.height);
// Apply drag offset if this is the dragged child
if is_being_dragged {
origin.x += interaction.drag_offset.x;
origin.y += interaction.drag_offset.y;
}
child.computed_frame = Frame.make(origin.x, origin.y, child_size.width, child_size.height);
child.view.layout(child.computed_frame);
} else {
// Docked: use alignment-based sizing
has_ovr : bool = interaction.has_alignment_override.items[i];
alignment := if has_ovr
then interaction.alignment_overrides.items[i]
else self.alignments.items[i];
is_fill := interaction.is_fill.items[i] and !is_being_dragged;
size_proposal := if is_being_dragged
then ProposedSize.flexible()
else get_size_proposal_for_alignment(alignment, bounds.size, is_fill);
child_size := child.view.size_that_fits(size_proposal);
target_size := if is_being_dragged
then child_size
else get_final_size_for_alignment(alignment, child_size, bounds.size, is_fill);
// Animate size transitions
interaction.set_target_size(i, target_size);
final_size := interaction.get_animated_size(i);
// Position
origin : Point = ---;
if is_being_dragged {
current_touch := interaction.drag_start_pos.add(interaction.drag_offset);
origin.x = current_touch.x - interaction.click_fraction_x * final_size.width;
origin.y = current_touch.y - interaction.click_fraction_y * final_size.height;
} else {
origin = calculate_origin(bounds, final_size, alignment);
}
if !is_being_dragged {
interaction.child_bounds.items[i] = Frame.make(origin.x, origin.y, final_size.width, final_size.height);
}
child.computed_frame = Frame.make(origin.x, origin.y, final_size.width, final_size.height);
child.view.layout(child.computed_frame);
}
i += 1;
}
}
render :: (self: *Dock, ctx: *RenderContext, frame: Frame) {
// Background
if bg := self.background {
if self.corner_radius > 0.0 {
ctx.add_rounded_rect(frame, bg, self.corner_radius);
} else {
ctx.add_rect(frame, bg);
}
}
// Draw children
i : s64 = 0;
while i < self.children.len {
child := @self.children.items[i];
child.view.render(ctx, child.computed_frame);
i += 1;
}
// Draw drag overlay when dragging
if self.interaction.dragging_child >= 0 {
// Preview overlay for hovered zone
if zone := self.interaction.get_hovered_dock_zone() {
preview_frame := dock_zone_get_preview_frame(zone, frame);
ctx.add_rounded_rect(preview_frame, self.preview_color, 8.0);
}
// Zone hint indicators
count : s64 = if self.enable_corners then 10 else 6;
j : s64 = 0;
while j < count {
zone := zone_by_index(j);
hint_frame := dock_zone_get_hint_frame(zone, frame, self.hint_size);
is_hovered := self.interaction.hovered_zone == xx zone;
color := if is_hovered then self.hint_active_color else self.hint_color;
ctx.add_rounded_rect(hint_frame, color, self.hint_size * 0.25);
draw_zone_indicator(ctx, hint_frame, zone, COLOR_WHITE);
j += 1;
}
}
}
handle_event :: (self: *Dock, event: *Event, frame: Frame) -> bool {
interaction := self.interaction;
// Pre-handle: intercept drag events when actively dragging
if interaction.dragging_child >= 0 {
if event.* == {
case .mouse_moved: (d) {
interaction.drag_offset = d.position.sub(interaction.drag_start_pos);
// Update hovered zone
zone := find_hovered_zone(frame, d.position, self.hint_size, self.enable_corners);
interaction.set_hovered_dock_zone(zone);
return true;
}
case .mouse_up: (d) {
child_idx := interaction.dragging_child;
if child_idx >= 0 and child_idx < self.children.len {
if zone := interaction.get_hovered_dock_zone() {
// Dock to zone
if alignment := dock_zone_to_alignment(zone) {
interaction.alignment_overrides.items[child_idx] = alignment;
interaction.has_alignment_override.items[child_idx] = true;
}
interaction.is_floating.items[child_idx] = false;
interaction.is_fill.items[child_idx] = dock_zone_should_fill(zone);
} else {
// Float: compute floating position from current cursor
natural_size := interaction.natural_sizes.items[child_idx];
fp_x := d.position.x - interaction.click_fraction_x * natural_size.width - frame.origin.x;
fp_y := d.position.y - interaction.click_fraction_y * natural_size.height - frame.origin.y;
interaction.floating_positions.items[child_idx] = Point.{ x = fp_x, y = fp_y };
interaction.is_floating.items[child_idx] = true;
interaction.is_fill.items[child_idx] = false;
}
}
// Reset drag state
interaction.dragging_child = -1;
interaction.drag_offset = Point.zero();
interaction.click_fraction_x = 0.0;
interaction.click_fraction_y = 0.0;
interaction.hovered_zone = -1;
return true;
}
}
}
// Forward to children (reverse order: last drawn = top = first to handle)
i := self.children.len - 1;
while i >= 0 {
child := @self.children.items[i];
if child.view.handle_event(event, child.computed_frame) {
return true;
}
i -= 1;
}
false;
}
}

96
library/modules/ui/events.sx Executable file
View File

@@ -0,0 +1,96 @@
#import "modules/std.sx";
#import "modules/sdl3.sx";
#import "modules/ui/types.sx";
MouseButton :: enum {
none;
left;
middle;
right;
}
MouseButtonData :: struct { position: Point; button: MouseButton; }
MouseMotionData :: struct { position: Point; delta: Point; }
MouseWheelData :: struct { position: Point; delta: Point; }
KeyData :: struct { key: u32; }
ResizeData :: struct { size: Size; }
Event :: enum {
none;
quit;
mouse_down: MouseButtonData;
mouse_up: MouseButtonData;
mouse_moved: MouseMotionData;
mouse_wheel: MouseWheelData;
key_down: KeyData;
key_up: KeyData;
text_input: string;
window_resize: ResizeData;
}
event_position :: (e: *Event) -> ?Point {
if e.* == {
case .mouse_down: (d) { return d.position; }
case .mouse_up: (d) { return d.position; }
case .mouse_moved: (d) { return d.position; }
case .mouse_wheel: (d) { return d.position; }
}
null;
}
// Translate SDL_Event → our Event type
translate_sdl_event :: (sdl: *SDL_Event) -> Event {
if sdl.* == {
case .quit: {
return .quit;
}
case .key_down: (data) {
return .key_down(.{ key = xx data.key });
}
case .key_up: (data) {
return .key_up(.{ key = xx data.key });
}
case .mouse_motion: (data) {
return .mouse_moved(.{
position = .{ x = data.x, y = data.y },
delta = .{ x = data.xrel, y = data.yrel }
});
}
case .mouse_button_down: (data) {
btn :MouseButton = if data.button == {
case 1: .left;
case 2: .middle;
case 3: .right;
else: .none;
};
return .mouse_down(.{
position = .{ x = data.x, y = data.y },
button = btn
});
}
case .mouse_button_up: (data) {
btn :MouseButton = if data.button == {
case 1: .left;
case 2: .middle;
case 3: .right;
else: .none;
};
return .mouse_up(.{
position = .{ x = data.x, y = data.y },
button = btn
});
}
case .mouse_wheel: (data) {
return .mouse_wheel(.{
position = .{ x = data.mouse_x, y = data.mouse_y },
delta = .{ x = data.x, y = data.y }
});
}
case .window_resized: (data) {
return .window_resize(.{
size = .{ width = xx data.data1, height = xx data.data2 }
});
}
}
.none;
}

20
library/modules/ui/font.sx Executable file
View File

@@ -0,0 +1,20 @@
#import "modules/std.sx";
#import "modules/ui/types.sx";
#import "modules/ui/glyph_cache.sx";
// Global glyph cache pointer for views (Label, Button) to access
g_font : *GlyphCache = null;
set_global_font :: (font: *GlyphCache) {
g_font = font;
}
// Convenience measurement function for views
measure_text :: (text: string, font_size: f32) -> Size {
if g_font == null {
// Fallback approximate measurement
scale := font_size / 16.0;
return Size.{ width = xx text.len * 8.0 * scale, height = font_size };
}
g_font.measure_text(text, font_size);
}

128
library/modules/ui/gesture.sx Executable file
View File

@@ -0,0 +1,128 @@
#import "modules/std.sx";
#import "modules/math";
#import "modules/ui/types.sx";
#import "modules/ui/events.sx";
GesturePhase :: enum {
possible;
began;
changed;
ended;
cancelled;
failed;
}
// --- TapGesture ---
TapValue :: struct {
location: Point;
count: s32;
}
TapGesture :: struct {
count: s32;
on_tap: ?Closure();
phase: GesturePhase;
tap_count: s32;
start_position: Point;
TAP_THRESHOLD :f32: 10.0;
handle_event :: (self: *TapGesture, event: *Event, frame: Frame) -> bool {
if event.* == {
case .mouse_down: (d) {
if frame.contains(d.position) {
self.phase = .began;
self.start_position = d.position;
return true;
}
}
case .mouse_moved: (d) {
if self.phase == .began {
if self.start_position.distance(d.position) > TapGesture.TAP_THRESHOLD {
self.phase = .failed;
}
}
}
case .mouse_up: (d) {
if self.phase == .began {
if frame.contains(d.position) {
self.tap_count += 1;
if self.tap_count >= self.count {
if handler := self.on_tap { handler(); }
self.tap_count = 0;
}
}
}
self.phase = .possible;
return true;
}
}
false;
}
}
// --- DragGesture ---
DragValue :: struct {
location: Point;
start_location: Point;
translation: Point;
}
DragGesture :: struct {
min_distance: f32;
on_changed: ?Closure(DragValue);
on_ended: ?Closure(DragValue);
phase: GesturePhase;
start_location: Point;
current_location: Point;
make_value :: (self: *DragGesture) -> DragValue {
DragValue.{
location = self.current_location,
start_location = self.start_location,
translation = self.current_location.sub(self.start_location)
};
}
handle_event :: (self: *DragGesture, event: *Event, frame: Frame) -> bool {
if event.* == {
case .mouse_down: (d) {
if frame.contains(d.position) {
self.phase = .possible;
self.start_location = d.position;
self.current_location = d.position;
return true;
}
}
case .mouse_moved: (d) {
if self.phase == .possible {
self.current_location = d.position;
if self.start_location.distance(d.position) >= self.min_distance {
self.phase = .began;
if handler := self.on_changed { handler(self.make_value()); }
}
return true;
}
if self.phase == .began or self.phase == .changed {
self.current_location = d.position;
self.phase = .changed;
if handler := self.on_changed { handler(self.make_value()); }
return true;
}
}
case .mouse_up: (d) {
if self.phase == .began or self.phase == .changed {
self.current_location = d.position;
self.phase = .ended;
if handler := self.on_ended { handler(self.make_value()); }
self.phase = .possible;
return true;
}
self.phase = .possible;
}
}
false;
}
}

613
library/modules/ui/glyph_cache.sx Executable file
View File

@@ -0,0 +1,613 @@
#import "modules/std.sx";
#import "modules/opengl.sx";
#import "modules/stb_truetype.sx";
#import "modules/ui/types.sx";
// Cached glyph data with UV coordinates into the atlas texture
CachedGlyph :: struct {
uv_x: f32;
uv_y: f32;
uv_w: f32;
uv_h: f32;
width: f32;
height: f32;
offset_x: f32;
offset_y: f32;
advance: f32;
}
// Cache entry: key + glyph data
GlyphEntry :: struct {
key: u32;
glyph: CachedGlyph;
}
// Quantize font size to half-point increments to limit cache entries.
// e.g., 13.0 -> 26, 13.5 -> 27, 14.0 -> 28
quantize_size :: (font_size: f32) -> u16 {
xx (font_size * 2.0 + 0.5);
}
dequantize_size :: (q: u16) -> f32 {
xx q / 2.0;
}
// Pack (glyph_index, size_quantized) into a single u32 for fast comparison
make_glyph_key :: (glyph_index: u16, size_quantized: u16) -> u32 {
(xx glyph_index << 16) | xx size_quantized;
}
// Shaped glyph — output of text shaping (positioned glyph with index)
ShapedGlyph :: struct {
glyph_index: u16;
x: f32; // horizontal position (logical units, cumulative)
y: f32; // vertical offset (logical units)
advance: f32; // advance width (logical units)
}
is_ascii :: (text: string) -> bool {
i : s64 = 0;
while i < text.len {
if text[i] >= 128 { return false; }
i += 1;
}
true;
}
// kbts constants (C enum values)
KBTS_DIRECTION_DONT_KNOW :u32: 0;
KBTS_LANGUAGE_DONT_KNOW :u32: 0;
KBTS_USER_ID_GENERATION_MODE_CODEPOINT_INDEX :u32: 0;
// SX structs matching kbts C struct layouts (64-bit).
// We define these in SX to access fields directly, casting from opaque C pointers.
KbtsGlyphIterator :: struct {
glyph_storage: *void;
current_glyph: *void;
last_advance_x: s32;
x: s32;
y: s32;
}
KbtsRun :: struct {
font: *void;
script: u32;
paragraph_direction: u32;
direction: u32;
flags: u32;
glyphs: KbtsGlyphIterator;
}
KbtsGlyph :: struct {
prev: *void;
next: *void;
codepoint: u32;
id: u16;
uid: u16;
user_id_or_codepoint_index: s32;
offset_x: s32;
offset_y: s32;
advance_x: s32;
advance_y: s32;
}
// kbts_font_info2 base (simplified — we only need the Size field for dispatch)
KBTS_FONT_INFO_STRING_ID_COUNT :s32: 7;
KbtsFontInfo2 :: struct {
size: u32;
strings: [7]*void; // char* array
string_lengths: [7]u16;
style_flags: u32;
weight: u32;
width: u32;
}
KbtsFontInfo2_1 :: struct {
base: KbtsFontInfo2;
units_per_em: u16;
x_min: s16;
y_min: s16;
x_max: s16;
y_max: s16;
ascent: s16;
descent: s16;
line_gap: s16;
}
GLYPH_ATLAS_W :s32: 1024;
GLYPH_ATLAS_H :s32: 1024;
FONTINFO_SIZE :s64: 256;
PackResult :: struct {
x, y: s32;
}
// Dynamic glyph cache with on-demand rasterization and texture atlas packing.
GlyphCache :: struct {
// Font data
font_info: *void; // heap-allocated stbtt_fontinfo (256 bytes)
font_data: *void; // raw TTF file bytes (kept alive for stbtt)
// Atlas texture (GPU)
texture_id: u32;
atlas_width: s32;
atlas_height: s32;
// Atlas bitmap (CPU-side for updates)
bitmap: [*]u8;
// Shelf packer state
shelf_y: s32;
shelf_height: s32;
cursor_x: s32;
padding: s32;
// Glyph lookup cache
entries: List(GlyphEntry);
// Hash table for O(1) glyph lookup (open addressing, linear probing)
hash_keys: [*]u32; // key per slot (0 = empty sentinel)
hash_vals: [*]s32; // index into entries list
hash_cap: s64; // table capacity (power of 2)
// Dirty tracking for texture upload
dirty: bool;
// Font vertical metrics (at reference size 1.0 — scale by font_size)
ascent: f32;
descent: f32;
line_gap: f32;
// HiDPI: physical pixels per logical pixel (e.g. 2.0 on Retina)
dpi_scale: f32;
inv_dpi: f32;
// Text shaping (kb_text_shape)
shape_ctx: *void;
shape_font: *void;
units_per_em: u16;
font_data_size: s32;
shaped_buf: List(ShapedGlyph);
// Shape cache: skip reshaping if same text + size as last call
last_shape_ptr: [*]u8;
last_shape_len: s64;
last_shape_size_q: u16;
init :: (self: *GlyphCache, path: [:0]u8, default_size: f32) {
// Zero out the entire struct first (parent may be uninitialized with = ---)
memset(self, 0, size_of(GlyphCache));
// Load font file
file_size : s32 = 0;
font_data := read_file_bytes(path, @file_size);
if font_data == null {
out("Failed to load font: ");
out(path);
out("\n");
return;
}
self.font_data = xx font_data;
self.font_data_size = file_size;
// Init stbtt_fontinfo
self.font_info = context.allocator.alloc(FONTINFO_SIZE);
memset(self.font_info, 0, FONTINFO_SIZE);
stbtt_InitFont(self.font_info, font_data, 0);
// Get font vertical metrics (in unscaled font units)
ascent_i : s32 = 0;
descent_i : s32 = 0;
linegap_i : s32 = 0;
stbtt_GetFontVMetrics(self.font_info, @ascent_i, @descent_i, @linegap_i);
// Store unscaled metrics — we'll scale per font_size in measure_text
self.ascent = xx ascent_i;
self.descent = xx descent_i;
self.line_gap = xx linegap_i;
// Init text shaping context
self.shape_ctx = xx kbts_CreateShapeContext(xx 0, xx 0);
if self.shape_ctx != null {
self.shape_font = xx kbts_ShapePushFontFromMemory(xx self.shape_ctx, self.font_data, file_size, 0);
// Get font metrics (units_per_em) from kbts
kb_info : KbtsFontInfo2_1 = ---;
memset(@kb_info, 0, size_of(KbtsFontInfo2_1));
kb_info.base.size = xx size_of(KbtsFontInfo2_1);
kbts_GetFontInfo2(xx self.shape_font, xx @kb_info);
self.units_per_em = kb_info.units_per_em;
}
// Allocate atlas bitmap
self.atlas_width = GLYPH_ATLAS_W;
self.atlas_height = GLYPH_ATLAS_H;
bitmap_size : s64 = xx self.atlas_width * xx self.atlas_height;
self.bitmap = xx context.allocator.alloc(bitmap_size);
memset(self.bitmap, 0, bitmap_size);
// Shelf packer init
self.shelf_y = 0;
self.shelf_height = 0;
self.cursor_x = 0;
self.padding = 1;
self.dirty = false;
self.dpi_scale = 1.0;
self.inv_dpi = 1.0;
// Init hash table (256 slots)
self.hash_cap = 256;
hash_bytes : s64 = self.hash_cap * 4; // u32 per slot
self.hash_keys = xx context.allocator.alloc(hash_bytes);
memset(self.hash_keys, 0, hash_bytes);
val_bytes : s64 = self.hash_cap * 8; // s64 per slot (s32 would suffice but alignment)
self.hash_vals = xx context.allocator.alloc(val_bytes);
// Create OpenGL texture
glGenTextures(1, @self.texture_id);
glBindTexture(GL_TEXTURE_2D, self.texture_id);
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
glTexImage2D(GL_TEXTURE_2D, 0, xx GL_R8, self.atlas_width, self.atlas_height, 0, GL_RED, GL_UNSIGNED_BYTE, self.bitmap);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, xx GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, xx GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, xx GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, xx GL_CLAMP_TO_EDGE);
out("GlyphCache initialized: ");
out(path);
out("\n");
}
// Look up or rasterize a glyph, returning a pointer to its cached entry.
// Returns null for glyphs with no outline AND zero advance (shouldn't happen for valid chars).
get_or_rasterize :: (self: *GlyphCache, glyph_index: u16, font_size: f32) -> *CachedGlyph {
size_q := quantize_size(font_size);
key := make_glyph_key(glyph_index, size_q);
// Hash table lookup (open addressing, linear probing)
mask := self.hash_cap - 1;
slot : s64 = xx ((key * 2654435761) >> 24) & xx mask;
while self.hash_keys[slot] != 0 {
if self.hash_keys[slot] == key {
return @self.entries.items[self.hash_vals[slot]].glyph;
}
slot = (slot + 1) & mask;
}
// Cache miss — rasterize
actual_size := dequantize_size(size_q);
scale := stbtt_ScaleForPixelHeight(self.font_info, actual_size);
// Get glyph bounding box
x0 : s32 = 0;
y0 : s32 = 0;
x1 : s32 = 0;
y1 : s32 = 0;
stbtt_GetGlyphBitmapBox(self.font_info, xx glyph_index, scale, scale, @x0, @y0, @x1, @y1);
glyph_w := if x1 > x0 then x1 - x0 else 0;
glyph_h := if y1 > y0 then y1 - y0 else 0;
// Get horizontal metrics
advance_i : s32 = 0;
lsb_i : s32 = 0;
stbtt_GetGlyphHMetrics(self.font_info, xx glyph_index, @advance_i, @lsb_i);
advance : f32 = xx advance_i * scale;
// Zero-size glyph (e.g. space) — cache with advance only
if glyph_w == 0 or glyph_h == 0 {
entry := GlyphEntry.{
key = key,
glyph = CachedGlyph.{
uv_x = 0.0, uv_y = 0.0, uv_w = 0.0, uv_h = 0.0,
width = 0.0, height = 0.0,
offset_x = xx x0, offset_y = xx y0,
advance = advance
}
};
self.entries.append(entry);
self.hash_insert(key, self.entries.len - 1);
return @self.entries.items[self.entries.len - 1].glyph;
}
// Pack into atlas
pack := self.try_pack(glyph_w, glyph_h);
if pack.x < 0 {
// Atlas full — grow and retry
self.grow();
return self.get_or_rasterize(glyph_index, font_size);
}
// Rasterize directly into atlas bitmap
dest_offset : s64 = xx pack.y * xx self.atlas_width + xx pack.x;
stbtt_MakeGlyphBitmap(
self.font_info,
@self.bitmap[dest_offset],
glyph_w, glyph_h,
self.atlas_width,
scale, scale,
xx glyph_index
);
self.dirty = true;
// Compute normalized UV coordinates
atlas_wf : f32 = xx self.atlas_width;
atlas_hf : f32 = xx self.atlas_height;
entry := GlyphEntry.{
key = key,
glyph = CachedGlyph.{
uv_x = xx pack.x / atlas_wf,
uv_y = xx pack.y / atlas_hf,
uv_w = xx glyph_w / atlas_wf,
uv_h = xx glyph_h / atlas_hf,
width = xx glyph_w,
height = xx glyph_h,
offset_x = xx x0,
offset_y = xx y0,
advance = advance
}
};
self.entries.append(entry);
self.hash_insert(key, self.entries.len - 1);
return @self.entries.items[self.entries.len - 1].glyph;
}
// Insert a key→index mapping into the hash table, growing if needed
hash_insert :: (self: *GlyphCache, key: u32, index: s64) {
// Grow if load factor > 70%
if self.entries.len * 10 > self.hash_cap * 7 {
self.hash_grow();
}
mask := self.hash_cap - 1;
slot : s64 = xx ((key * 2654435761) >> 24) & xx mask;
while self.hash_keys[slot] != 0 {
slot = (slot + 1) & mask;
}
self.hash_keys[slot] = key;
self.hash_vals[slot] = xx index;
}
// Double the hash table and rehash all entries
hash_grow :: (self: *GlyphCache) {
old_cap := self.hash_cap;
old_keys := self.hash_keys;
old_vals := self.hash_vals;
self.hash_cap = old_cap * 2;
hash_bytes : s64 = self.hash_cap * 4;
self.hash_keys = xx context.allocator.alloc(hash_bytes);
memset(self.hash_keys, 0, hash_bytes);
val_bytes : s64 = self.hash_cap * 8;
self.hash_vals = xx context.allocator.alloc(val_bytes);
// Rehash
mask := self.hash_cap - 1;
i : s64 = 0;
while i < old_cap {
k := old_keys[i];
if k != 0 {
slot : s64 = xx ((k * 2654435761) >> 24) & xx mask;
while self.hash_keys[slot] != 0 {
slot = (slot + 1) & mask;
}
self.hash_keys[slot] = k;
self.hash_vals[slot] = old_vals[i];
}
i += 1;
}
context.allocator.dealloc(old_keys);
context.allocator.dealloc(old_vals);
}
// Upload dirty atlas to GPU
flush :: (self: *GlyphCache) {
if self.dirty == false { return; }
glBindTexture(GL_TEXTURE_2D, self.texture_id);
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, self.atlas_width, self.atlas_height, GL_RED, GL_UNSIGNED_BYTE, self.bitmap);
self.dirty = false;
}
// Shelf-based rectangle packer.
// Returns PackResult with x >= 0 on success, x = -1 if no space.
try_pack :: (self: *GlyphCache, w: s32, h: s32) -> PackResult {
padded_w := w + self.padding;
padded_h := h + self.padding;
// Try fitting on the current shelf
eff_h := if self.shelf_height > padded_h then self.shelf_height else padded_h;
if self.cursor_x + padded_w <= self.atlas_width and self.shelf_y + eff_h <= self.atlas_height {
result := PackResult.{ x = self.cursor_x, y = self.shelf_y };
self.cursor_x += padded_w;
if padded_h > self.shelf_height {
self.shelf_height = padded_h;
}
return result;
}
// Start a new shelf
new_shelf_y := self.shelf_y + self.shelf_height;
if new_shelf_y + padded_h <= self.atlas_height and padded_w <= self.atlas_width {
self.shelf_y = new_shelf_y;
self.shelf_height = padded_h;
self.cursor_x = padded_w;
return PackResult.{ x = 0, y = new_shelf_y };
}
// No space
PackResult.{ x = 0 - 1, y = 0 - 1 };
}
// Grow the atlas by doubling dimensions
grow :: (self: *GlyphCache) {
new_w := self.atlas_width * 2;
new_h := self.atlas_height * 2;
new_size : s64 = xx new_w * xx new_h;
new_bitmap : [*]u8 = xx context.allocator.alloc(new_size);
memset(new_bitmap, 0, new_size);
// Copy old rows into new bitmap
y : s32 = 0;
while y < self.atlas_height {
old_off : s64 = xx y * xx self.atlas_width;
new_off : s64 = xx y * xx new_w;
memcpy(@new_bitmap[new_off], @self.bitmap[old_off], xx self.atlas_width);
y += 1;
}
context.allocator.dealloc(self.bitmap);
self.bitmap = new_bitmap;
self.atlas_width = new_w;
self.atlas_height = new_h;
// Recreate GL texture
glDeleteTextures(1, @self.texture_id);
glGenTextures(1, @self.texture_id);
glBindTexture(GL_TEXTURE_2D, self.texture_id);
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
glTexImage2D(GL_TEXTURE_2D, 0, xx GL_R8, new_w, new_h, 0, GL_RED, GL_UNSIGNED_BYTE, new_bitmap);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, xx GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, xx GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, xx GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, xx GL_CLAMP_TO_EDGE);
// Recompute UV coordinates for all cached glyphs
atlas_wf : f32 = xx new_w;
atlas_hf : f32 = xx new_h;
i : s64 = 0;
while i < self.entries.len {
g := @self.entries.items[i].glyph;
if g.width > 0.0 {
g.uv_x = g.uv_x / 2.0;
g.uv_y = g.uv_y / 2.0;
g.uv_w = g.width / atlas_wf;
g.uv_h = g.height / atlas_hf;
}
i += 1;
}
self.dirty = false;
out("GlyphCache atlas grown\n");
}
set_dpi_scale :: (self: *GlyphCache, scale: f32) {
self.dpi_scale = scale;
self.inv_dpi = 1.0 / scale;
}
// Get the scale factor for a logical font size
scale_for_size :: (self: *GlyphCache, font_size: f32) -> f32 {
stbtt_ScaleForPixelHeight(self.font_info, font_size);
}
// Get scaled ascent for a logical font size
get_ascent :: (self: *GlyphCache, font_size: f32) -> f32 {
self.ascent * self.scale_for_size(font_size);
}
// Get scaled line height for a logical font size
get_line_height :: (self: *GlyphCache, font_size: f32) -> f32 {
s := self.scale_for_size(font_size);
(self.ascent - self.descent + self.line_gap) * s;
}
// Shape text into positioned glyphs.
// Uses ASCII fast-path (stbtt byte-by-byte) for pure ASCII,
// full kb_text_shape pipeline for Unicode/complex scripts.
// Results stored in self.shaped_buf (reused across calls).
shape_text :: (self: *GlyphCache, text: string, font_size: f32) {
// Check shape cache: skip if same text + size as last call
size_q := quantize_size(font_size);
if text.len > 0 and text.ptr == self.last_shape_ptr and text.len == self.last_shape_len and size_q == self.last_shape_size_q {
return; // shaped_buf already has the result
}
self.shaped_buf.len = 0;
if text.len == 0 { return; }
if is_ascii(text) {
self.shape_ascii(text, font_size);
} else {
self.shape_with_kb(text, font_size);
}
// Update shape cache
self.last_shape_ptr = text.ptr;
self.last_shape_len = text.len;
self.last_shape_size_q = size_q;
}
shape_ascii :: (self: *GlyphCache, text: string, font_size: f32) {
scale := stbtt_ScaleForPixelHeight(self.font_info, font_size);
total : f32 = 0.0;
i : s64 = 0;
while i < text.len {
ch : s32 = xx text[i];
glyph_index : u16 = xx stbtt_FindGlyphIndex(self.font_info, ch);
advance_i : s32 = 0;
lsb_i : s32 = 0;
stbtt_GetGlyphHMetrics(self.font_info, xx glyph_index, @advance_i, @lsb_i);
adv : f32 = xx advance_i * scale;
self.shaped_buf.append(ShapedGlyph.{
glyph_index = glyph_index,
x = total,
y = 0.0,
advance = adv
});
total += adv;
i += 1;
}
}
shape_with_kb :: (self: *GlyphCache, text: string, font_size: f32) {
if self.shape_ctx == null {
self.shape_ascii(text, font_size);
return;
}
scale : f32 = font_size / xx self.units_per_em;
total : f32 = 0.0;
kbts_ShapeBegin(xx self.shape_ctx, KBTS_DIRECTION_DONT_KNOW, KBTS_LANGUAGE_DONT_KNOW);
kbts_ShapeUtf8(xx self.shape_ctx, xx text.ptr, xx text.len, KBTS_USER_ID_GENERATION_MODE_CODEPOINT_INDEX);
kbts_ShapeEnd(xx self.shape_ctx);
run : KbtsRun = ---;
while kbts_ShapeRun(xx self.shape_ctx, xx @run) != 0 {
glyph_ptr : *KbtsGlyph = null;
while kbts_GlyphIteratorNext(xx @run.glyphs, xx @glyph_ptr) != 0 {
if glyph_ptr == null { continue; }
gx := total + xx glyph_ptr.offset_x * scale;
gy : f32 = xx glyph_ptr.offset_y * scale;
adv : f32 = xx glyph_ptr.advance_x * scale;
self.shaped_buf.append(ShapedGlyph.{
glyph_index = glyph_ptr.id,
x = gx,
y = gy,
advance = adv
});
total += adv;
}
}
}
// Measure text at a logical font size using text shaping.
// Rasterizes at physical resolution (font_size * dpi_scale), returns logical dimensions.
measure_text :: (self: *GlyphCache, text: string, font_size: f32) -> Size {
self.shape_text(text, font_size);
width : f32 = 0.0;
i : s64 = 0;
while i < self.shaped_buf.len {
width += self.shaped_buf.items[i].advance;
i += 1;
}
Size.{ width = width, height = self.get_line_height(font_size) };
}
}

36
library/modules/ui/image.sx Executable file
View File

@@ -0,0 +1,36 @@
#import "modules/std.sx";
#import "modules/ui/types.sx";
#import "modules/ui/render.sx";
#import "modules/ui/events.sx";
#import "modules/ui/view.sx";
ImageView :: struct {
texture_id: u32;
width: f32;
height: f32;
tint: Color;
}
impl View for ImageView {
size_that_fits :: (self: *ImageView, proposal: ProposedSize) -> Size {
pw := proposal.width ?? self.width;
ph := proposal.height ?? self.height;
// Maintain aspect ratio: fit within proposal
aspect := self.width / self.height;
if pw / ph > aspect {
Size.{ width = ph * aspect, height = ph };
} else {
Size.{ width = pw, height = pw / aspect };
}
}
layout :: (self: *ImageView, bounds: Frame) {}
render :: (self: *ImageView, ctx: *RenderContext, frame: Frame) {
ctx.add_image(frame, self.texture_id);
}
handle_event :: (self: *ImageView, event: *Event, frame: Frame) -> bool {
false;
}
}

38
library/modules/ui/label.sx Executable file
View File

@@ -0,0 +1,38 @@
#import "modules/std.sx";
#import "modules/ui/types.sx";
#import "modules/ui/render.sx";
#import "modules/ui/events.sx";
#import "modules/ui/view.sx";
#import "modules/ui/font.sx";
Label :: struct {
text: string;
font_size: f32;
color: Color;
make :: (text: string) -> Label {
Label.{
text = text,
font_size = 14.0,
color = COLOR_WHITE
};
}
}
impl View for Label {
size_that_fits :: (self: *Label, proposal: ProposedSize) -> Size {
measure_text(self.text, self.font_size);
}
layout :: (self: *Label, bounds: Frame) {
// Leaf view — nothing to place
}
render :: (self: *Label, ctx: *RenderContext, frame: Frame) {
ctx.add_text(frame, self.text, self.font_size, self.color);
}
handle_event :: (self: *Label, event: *Event, frame: Frame) -> bool {
false;
}
}

152
library/modules/ui/layout.sx Executable file
View File

@@ -0,0 +1,152 @@
#import "modules/std.sx";
#import "modules/math";
#import "modules/ui/types.sx";
#import "modules/ui/view.sx";
// VStack layout: measure all children, stack vertically
// Width is constrained from parent; height is unspecified (children choose)
layout_vstack :: (children: *List(ViewChild), bounds: Frame, spacing: f32, alignment: HAlignment) {
n := children.len;
if n == 0 { return; }
content_width := bounds.size.width;
y := bounds.origin.y;
i := 0;
while i < n {
child := @children.items[i];
child_size := child.view.size_that_fits(ProposedSize.{
width = content_width,
height = null
});
x_offset := align_h(alignment, child_size.width, content_width);
child.computed_frame = Frame.{
origin = Point.{ x = bounds.origin.x + x_offset, y = y },
size = child_size
};
child.view.layout(child.computed_frame);
y = y + child_size.height + spacing;
i += 1;
}
}
// HStack layout: measure all children, stack horizontally
// Height is constrained from parent; width is unspecified (children choose)
layout_hstack :: (children: *List(ViewChild), bounds: Frame, spacing: f32, alignment: VAlignment) {
n := children.len;
if n == 0 { return; }
content_height := bounds.size.height;
x := bounds.origin.x;
i := 0;
while i < n {
child := @children.items[i];
child_size := child.view.size_that_fits(ProposedSize.{
width = null,
height = content_height
});
y_offset := align_v(alignment, child_size.height, content_height);
child.computed_frame = Frame.{
origin = Point.{ x = x, y = bounds.origin.y + y_offset },
size = child_size
};
child.view.layout(child.computed_frame);
x = x + child_size.width + spacing;
i += 1;
}
}
// ZStack layout: all children get same bounds, aligned
layout_zstack :: (children: *List(ViewChild), bounds: Frame, alignment: Alignment) {
n := children.len;
if n == 0 { return; }
proposal := ProposedSize.{
width = bounds.size.width,
height = bounds.size.height
};
i := 0;
while i < n {
child := @children.items[i];
child_size := child.view.size_that_fits(proposal);
x_offset := align_h(alignment.h, child_size.width, bounds.size.width);
y_offset := align_v(alignment.v, child_size.height, bounds.size.height);
child.computed_frame = Frame.{
origin = Point.{ x = bounds.origin.x + x_offset, y = bounds.origin.y + y_offset },
size = child_size
};
child.view.layout(child.computed_frame);
i += 1;
}
}
// Measure helpers — compute stack size from children
measure_vstack :: (children: *List(ViewChild), proposal: ProposedSize, spacing: f32) -> Size {
n := children.len;
if n == 0 { return Size.zero(); }
max_width : f32 = 0.0;
total_height : f32 = 0.0;
// Measure children: constrain width, leave height unspecified
child_proposal := ProposedSize.{ width = proposal.width, height = null };
i := 0;
while i < n {
child_size := children.items[i].view.size_that_fits(child_proposal);
children.items[i].computed_frame.size = child_size;
if child_size.width > max_width { max_width = child_size.width; }
total_height = total_height + child_size.height;
i += 1;
}
total_height = total_height + spacing * xx (n - 1);
result_width := min(proposal.width ?? max_width, max_width);
Size.{ width = result_width, height = total_height };
}
measure_hstack :: (children: *List(ViewChild), proposal: ProposedSize, spacing: f32) -> Size {
n := children.len;
if n == 0 { return Size.zero(); }
total_width : f32 = 0.0;
max_height : f32 = 0.0;
// Measure children: constrain height, leave width unspecified
child_proposal := ProposedSize.{ width = null, height = proposal.height };
i := 0;
while i < n {
child_size := children.items[i].view.size_that_fits(child_proposal);
children.items[i].computed_frame.size = child_size;
total_width = total_width + child_size.width;
if child_size.height > max_height { max_height = child_size.height; }
i += 1;
}
total_width = total_width + spacing * xx (n - 1);
result_height := min(proposal.height ?? max_height, max_height);
Size.{ width = total_width, height = result_height };
}
measure_zstack :: (children: *List(ViewChild), proposal: ProposedSize) -> Size {
n := children.len;
if n == 0 { return Size.zero(); }
max_width : f32 = 0.0;
max_height : f32 = 0.0;
i := 0;
while i < n {
child_size := children.items[i].view.size_that_fits(proposal);
children.items[i].computed_frame.size = child_size;
if child_size.width > max_width { max_width = child_size.width; }
if child_size.height > max_height { max_height = child_size.height; }
i += 1;
}
Size.{ width = max_width, height = max_height };
}

296
library/modules/ui/modifier.sx Executable file
View File

@@ -0,0 +1,296 @@
#import "modules/std.sx";
#import "modules/math";
#import "modules/ui/types.sx";
#import "modules/ui/render.sx";
#import "modules/ui/events.sx";
#import "modules/ui/view.sx";
#import "modules/ui/gesture.sx";
// --- PaddingModifier ---
PaddingModifier :: struct {
child: ViewChild;
insets: EdgeInsets;
}
impl View for PaddingModifier {
size_that_fits :: (self: *PaddingModifier, proposal: ProposedSize) -> Size {
iw: ?f32 = null;
ih: ?f32 = null;
if w := proposal.width { iw = w - self.insets.horizontal(); }
if h := proposal.height { ih = h - self.insets.vertical(); }
inner := ProposedSize.{ width = iw, height = ih };
child_size := self.child.view.size_that_fits(inner);
Size.{
width = child_size.width + self.insets.horizontal(),
height = child_size.height + self.insets.vertical()
};
}
layout :: (self: *PaddingModifier, bounds: Frame) {
self.child.computed_frame = bounds.inset(self.insets);
self.child.view.layout(self.child.computed_frame);
}
render :: (self: *PaddingModifier, ctx: *RenderContext, frame: Frame) {
self.child.view.render(ctx, self.child.computed_frame);
}
handle_event :: (self: *PaddingModifier, event: *Event, frame: Frame) -> bool {
self.child.view.handle_event(event, self.child.computed_frame);
}
}
// --- FrameModifier ---
FrameModifier :: struct {
child: ViewChild;
width: ?f32;
height: ?f32;
}
impl View for FrameModifier {
size_that_fits :: (self: *FrameModifier, proposal: ProposedSize) -> Size {
pw := self.width ?? proposal.width ?? 0.0;
ph := self.height ?? proposal.height ?? 0.0;
child_proposal := ProposedSize.{ width = pw, height = ph };
child_size := self.child.view.size_that_fits(child_proposal);
Size.{
width = self.width ?? child_size.width,
height = self.height ?? child_size.height
};
}
layout :: (self: *FrameModifier, bounds: Frame) {
child_size := self.child.view.size_that_fits(ProposedSize.{
width = self.width ?? bounds.size.width,
height = self.height ?? bounds.size.height
});
// Center child within bounds
cx := bounds.origin.x + (bounds.size.width - child_size.width) * 0.5;
cy := bounds.origin.y + (bounds.size.height - child_size.height) * 0.5;
self.child.computed_frame = Frame.make(cx, cy, child_size.width, child_size.height);
self.child.view.layout(self.child.computed_frame);
}
render :: (self: *FrameModifier, ctx: *RenderContext, frame: Frame) {
self.child.view.render(ctx, self.child.computed_frame);
}
handle_event :: (self: *FrameModifier, event: *Event, frame: Frame) -> bool {
self.child.view.handle_event(event, self.child.computed_frame);
}
}
// --- BackgroundModifier ---
BackgroundModifier :: struct {
child: ViewChild;
color: Color;
corner_radius: f32;
}
impl View for BackgroundModifier {
size_that_fits :: (self: *BackgroundModifier, proposal: ProposedSize) -> Size {
self.child.view.size_that_fits(proposal);
}
layout :: (self: *BackgroundModifier, bounds: Frame) {
self.child.computed_frame = bounds;
self.child.view.layout(bounds);
}
render :: (self: *BackgroundModifier, ctx: *RenderContext, frame: Frame) {
if self.corner_radius > 0.0 {
ctx.add_rounded_rect(frame, self.color, self.corner_radius);
} else {
ctx.add_rect(frame, self.color);
}
self.child.view.render(ctx, self.child.computed_frame);
}
handle_event :: (self: *BackgroundModifier, event: *Event, frame: Frame) -> bool {
self.child.view.handle_event(event, self.child.computed_frame);
}
}
// --- OpacityModifier ---
OpacityModifier :: struct {
child: ViewChild;
alpha: f32;
}
impl View for OpacityModifier {
size_that_fits :: (self: *OpacityModifier, proposal: ProposedSize) -> Size {
self.child.view.size_that_fits(proposal);
}
layout :: (self: *OpacityModifier, bounds: Frame) {
self.child.computed_frame = bounds;
self.child.view.layout(bounds);
}
render :: (self: *OpacityModifier, ctx: *RenderContext, frame: Frame) {
prev := ctx.opacity;
ctx.push_opacity(self.alpha);
self.child.view.render(ctx, self.child.computed_frame);
ctx.pop_opacity(prev);
}
handle_event :: (self: *OpacityModifier, event: *Event, frame: Frame) -> bool {
self.child.view.handle_event(event, self.child.computed_frame);
}
}
// --- ClipModifier ---
ClipModifier :: struct {
child: ViewChild;
corner_radius: f32;
}
impl View for ClipModifier {
size_that_fits :: (self: *ClipModifier, proposal: ProposedSize) -> Size {
self.child.view.size_that_fits(proposal);
}
layout :: (self: *ClipModifier, bounds: Frame) {
self.child.computed_frame = bounds;
self.child.view.layout(bounds);
}
render :: (self: *ClipModifier, ctx: *RenderContext, frame: Frame) {
ctx.push_clip(frame);
self.child.view.render(ctx, self.child.computed_frame);
ctx.pop_clip();
}
handle_event :: (self: *ClipModifier, event: *Event, frame: Frame) -> bool {
self.child.view.handle_event(event, self.child.computed_frame);
}
}
// --- HiddenModifier ---
HiddenModifier :: struct {
child: ViewChild;
is_hidden: bool;
}
impl View for HiddenModifier {
size_that_fits :: (self: *HiddenModifier, proposal: ProposedSize) -> Size {
if self.is_hidden { return Size.zero(); }
self.child.view.size_that_fits(proposal);
}
layout :: (self: *HiddenModifier, bounds: Frame) {
if self.is_hidden { return; }
self.child.computed_frame = bounds;
self.child.view.layout(bounds);
}
render :: (self: *HiddenModifier, ctx: *RenderContext, frame: Frame) {
if self.is_hidden { return; }
self.child.view.render(ctx, self.child.computed_frame);
}
handle_event :: (self: *HiddenModifier, event: *Event, frame: Frame) -> bool {
if self.is_hidden { return false; }
self.child.view.handle_event(event, self.child.computed_frame);
}
}
// --- TapGestureModifier ---
TapGestureModifier :: struct {
child: ViewChild;
gesture: TapGesture;
}
impl View for TapGestureModifier {
size_that_fits :: (self: *TapGestureModifier, proposal: ProposedSize) -> Size {
self.child.view.size_that_fits(proposal);
}
layout :: (self: *TapGestureModifier, bounds: Frame) {
self.child.computed_frame = bounds;
self.child.view.layout(bounds);
}
render :: (self: *TapGestureModifier, ctx: *RenderContext, frame: Frame) {
self.child.view.render(ctx, self.child.computed_frame);
}
handle_event :: (self: *TapGestureModifier, event: *Event, frame: Frame) -> bool {
if self.gesture.handle_event(event, frame) { return true; }
self.child.view.handle_event(event, self.child.computed_frame);
}
}
// --- DragGestureModifier ---
DragGestureModifier :: struct {
child: ViewChild;
gesture: DragGesture;
}
impl View for DragGestureModifier {
size_that_fits :: (self: *DragGestureModifier, proposal: ProposedSize) -> Size {
self.child.view.size_that_fits(proposal);
}
layout :: (self: *DragGestureModifier, bounds: Frame) {
self.child.computed_frame = bounds;
self.child.view.layout(bounds);
}
render :: (self: *DragGestureModifier, ctx: *RenderContext, frame: Frame) {
self.child.view.render(ctx, self.child.computed_frame);
}
handle_event :: (self: *DragGestureModifier, event: *Event, frame: Frame) -> bool {
if self.gesture.handle_event(event, frame) { return true; }
self.child.view.handle_event(event, self.child.computed_frame);
}
}
// --- Convenience functions ---
padding :: (view: View, insets: EdgeInsets) -> PaddingModifier {
PaddingModifier.{ child = .{ view = view }, insets = insets };
}
fixed_frame :: (view: View, width: ?f32, height: ?f32) -> FrameModifier {
FrameModifier.{ child = .{ view = view }, width = width, height = height };
}
background :: (view: View, color: Color, corner_radius: f32) -> BackgroundModifier {
BackgroundModifier.{ child = .{ view = view }, color = color, corner_radius = corner_radius };
}
with_opacity :: (view: View, alpha: f32) -> OpacityModifier {
OpacityModifier.{ child = .{ view = view }, alpha = alpha };
}
clip :: (view: View, corner_radius: f32) -> ClipModifier {
ClipModifier.{ child = .{ view = view }, corner_radius = corner_radius };
}
hidden :: (view: View, is_hidden: bool) -> HiddenModifier {
HiddenModifier.{ child = .{ view = view }, is_hidden = is_hidden };
}
on_tap :: (view: View, handler: Closure()) -> TapGestureModifier {
TapGestureModifier.{
child = .{ view = view },
gesture = TapGesture.{ count = 1, on_tap = handler }
};
}
on_drag :: (view: View, on_changed: ?Closure(DragValue), on_ended: ?Closure(DragValue)) -> DragGestureModifier {
DragGestureModifier.{
child = .{ view = view },
gesture = DragGesture.{ min_distance = 10.0, on_changed = on_changed, on_ended = on_ended }
};
}

162
library/modules/ui/pipeline.sx Executable file
View File

@@ -0,0 +1,162 @@
#import "modules/std.sx";
#import "modules/allocators.sx";
#import "modules/opengl.sx";
#import "modules/ui/types.sx";
#import "modules/ui/render.sx";
#import "modules/ui/events.sx";
#import "modules/ui/view.sx";
#import "modules/ui/renderer.sx";
UIPipeline :: struct {
renderer: UIRenderer;
render_tree: RenderTree;
font: GlyphCache;
screen_width: f32;
screen_height: f32;
root: ViewChild;
has_root: bool;
// Frame arena infrastructure
arena_a: Arena;
arena_b: Arena;
frame_index: s64;
body: Closure() -> View;
has_body: bool;
parent_allocator: Allocator;
init :: (self: *UIPipeline, width: f32, height: f32) {
self.render_tree = RenderTree.init();
self.renderer.init();
self.screen_width = width;
self.screen_height = height;
self.has_root = false;
self.has_body = false;
self.frame_index = 0;
}
init_font :: (self: *UIPipeline, path: [:0]u8, size: f32, dpi_scale: f32) {
self.font.init(path, size);
self.font.set_dpi_scale(dpi_scale);
self.renderer.dpi_scale = dpi_scale;
set_global_font(@self.font);
}
set_root :: (self: *UIPipeline, view: View) {
self.root = .{ view = view };
self.has_root = true;
}
set_body :: (self: *UIPipeline, body_fn: Closure() -> View) {
self.body = body_fn;
self.has_body = true;
self.parent_allocator = context.allocator;
// Initialize both arenas (256KB initial, grows automatically)
self.arena_a.create(self.parent_allocator, 262144);
self.arena_b.create(self.parent_allocator, 262144);
self.frame_index = 0;
}
resize :: (self: *UIPipeline, width: f32, height: f32) {
self.screen_width = width;
self.screen_height = height;
}
// Re-layout and re-render the existing view tree at current screen size.
// Does NOT rebuild from body — safe to call from C callbacks (no arena/context needed).
tick_relayout :: (self: *UIPipeline) {
if self.has_root == false { return; }
proposal := ProposedSize.fixed(self.screen_width, self.screen_height);
self.root.view.size_that_fits(proposal);
self.root.computed_frame = Frame.{
origin = Point.zero(),
size = Size.{ width = self.screen_width, height = self.screen_height }
};
self.root.view.layout(self.root.computed_frame);
self.render_tree.clear();
ctx := RenderContext.init(@self.render_tree);
self.root.view.render(@ctx, self.root.computed_frame);
self.commit_gpu();
}
// Process a single event through the view tree
dispatch_event :: (self: *UIPipeline, event: *Event) -> bool {
if self.has_root == false { return false; }
self.root.view.handle_event(event, self.root.computed_frame);
}
// Run one frame: layout → render → commit
tick :: (self: *UIPipeline) {
if self.has_body {
self.tick_with_body();
return;
}
if self.has_root == false { return; }
proposal := ProposedSize.fixed(self.screen_width, self.screen_height);
// Layout
self.root.view.size_that_fits(proposal);
self.root.computed_frame = Frame.{
origin = Point.zero(),
size = Size.{ width = self.screen_width, height = self.screen_height }
};
self.root.view.layout(self.root.computed_frame);
// Render to tree
self.render_tree.clear();
ctx := RenderContext.init(@self.render_tree);
self.root.view.render(@ctx, self.root.computed_frame);
// Commit to GPU
self.commit_gpu();
}
tick_with_body :: (self: *UIPipeline) {
build_arena : *Arena = if self.frame_index & 1 == 0 then @self.arena_a else @self.arena_b;
build_arena.reset();
// Reset render_tree nodes (backing is stale after arena reset)
self.render_tree.nodes.items = null;
self.render_tree.nodes.len = 0;
self.render_tree.nodes.cap = 0;
push Context.{ allocator = xx build_arena, data = context.data } {
// Workaround: self.body() crashes through struct field (issue-0010)
body_fn := self.body;
root_view := body_fn();
self.root = .{ view = root_view };
self.has_root = true;
proposal := ProposedSize.fixed(self.screen_width, self.screen_height);
self.root.view.size_that_fits(proposal);
self.root.computed_frame = Frame.{
origin = Point.zero(),
size = Size.{ width = self.screen_width, height = self.screen_height }
};
self.root.view.layout(self.root.computed_frame);
self.render_tree.clear();
ctx := RenderContext.init(@self.render_tree);
self.root.view.render(@ctx, self.root.computed_frame);
self.commit_gpu();
}
self.frame_index += 1;
}
commit_gpu :: (self: *UIPipeline) {
glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
glDisable(GL_DEPTH_TEST);
self.renderer.begin(self.screen_width, self.screen_height, self.font.texture_id);
self.renderer.process(@self.render_tree);
self.renderer.flush();
glDisable(GL_BLEND);
}
}

179
library/modules/ui/render.sx Executable file
View File

@@ -0,0 +1,179 @@
#import "modules/std.sx";
#import "modules/ui/types.sx";
RenderNodeType :: enum {
rect;
rounded_rect;
text;
image;
clip_push;
clip_pop;
opacity_push;
opacity_pop;
}
RenderNode :: struct {
type: RenderNodeType;
frame: Frame;
// Rect / rounded_rect
fill_color: Color;
stroke_color: Color;
stroke_width: f32;
corner_radius: f32;
// Text
text: string;
font_size: f32;
text_color: Color;
// Image
texture_id: u32;
uv_min: Point;
uv_max: Point;
// Opacity
opacity: f32;
depth: s64;
}
RenderTree :: struct {
nodes: List(RenderNode);
generation: s64;
init :: () -> RenderTree {
RenderTree.{ generation = 0 };
}
clear :: (self: *RenderTree) {
self.nodes.len = 0;
self.generation += 1;
}
add :: (self: *RenderTree, node: RenderNode) -> s64 {
idx := self.nodes.len;
self.nodes.append(node);
idx;
}
}
// Stateful builder — views use this to emit render nodes
RenderContext :: struct {
tree: *RenderTree;
clip_depth: s64;
opacity: f32;
depth: s64;
init :: (tree: *RenderTree) -> RenderContext {
RenderContext.{
tree = tree,
clip_depth = 0,
opacity = 1.0,
depth = 0
};
}
add_rect :: (self: *RenderContext, frame: Frame, fill: Color) {
self.tree.add(.{
type = .rect,
frame = frame,
fill_color = fill,
opacity = self.opacity,
depth = self.depth,
});
self.depth += 1;
}
add_rounded_rect :: (self: *RenderContext, frame: Frame, fill: Color, radius: f32) {
self.tree.add(.{
type = .rounded_rect,
frame = frame,
fill_color = fill,
corner_radius = radius,
opacity = self.opacity,
depth = self.depth,
});
self.depth += 1;
}
add_stroked_rect :: (self: *RenderContext, frame: Frame, fill: Color, stroke: Color, stroke_w: f32, radius: f32) {
self.tree.add(.{
type = .rounded_rect,
frame = frame,
fill_color = fill,
stroke_color = stroke,
stroke_width = stroke_w,
corner_radius = radius,
opacity = self.opacity,
depth = self.depth,
});
self.depth += 1;
}
add_text :: (self: *RenderContext, frame: Frame, text: string, font_size: f32, color: Color) {
self.tree.add(.{
type = .text,
frame = frame,
text = text,
font_size = font_size,
text_color = color,
opacity = self.opacity,
depth = self.depth,
});
self.depth += 1;
}
add_image :: (self: *RenderContext, frame: Frame, texture_id: u32) {
self.add_image_uv(frame, texture_id, Point.zero(), Point.{ x = 1.0, y = 1.0 });
}
add_image_uv :: (self: *RenderContext, frame: Frame, texture_id: u32, uv_min: Point, uv_max: Point) {
self.tree.add(.{
type = .image,
frame = frame,
texture_id = texture_id,
uv_min = uv_min,
uv_max = uv_max,
opacity = self.opacity,
depth = self.depth,
});
self.depth += 1;
}
push_clip :: (self: *RenderContext, frame: Frame) {
self.tree.add(.{
type = .clip_push,
frame = frame,
depth = self.depth,
});
self.clip_depth += 1;
}
pop_clip :: (self: *RenderContext) {
self.tree.add(.{
type = .clip_pop,
depth = self.depth,
});
self.clip_depth -= 1;
}
push_opacity :: (self: *RenderContext, alpha: f32) {
prev := self.opacity;
self.opacity = prev * alpha;
self.tree.add(.{
type = .opacity_push,
opacity = self.opacity,
depth = self.depth,
});
}
pop_opacity :: (self: *RenderContext, prev_opacity: f32) {
self.tree.add(.{
type = .opacity_pop,
depth = self.depth,
});
self.opacity = prev_opacity;
}
}

460
library/modules/ui/renderer.sx Executable file
View File

@@ -0,0 +1,460 @@
#import "modules/std.sx";
#import "modules/compiler.sx";
#import "modules/opengl.sx";
#import "modules/math";
#import "modules/ui/types.sx";
#import "modules/ui/render.sx";
#import "modules/ui/glyph_cache.sx";
#import "modules/ui/font.sx";
// Vertex: pos(2) + uv(2) + color(4) + params(4) = 12 floats
UI_VERTEX_FLOATS :s64: 12;
UI_VERTEX_BYTES :s64: 48;
MAX_UI_VERTICES :s64: 16384;
UIRenderer :: struct {
vao: u32;
vbo: u32;
shader: u32;
proj_loc: s32;
tex_loc: s32;
vertices: [*]f32;
vertex_count: s64;
screen_width: f32;
screen_height: f32;
dpi_scale: f32;
white_texture: u32;
current_texture: u32;
draw_calls: s64;
init :: (self: *UIRenderer) {
// Create shader (ES for WASM/WebGL2, Core for desktop)
inline if OS == .wasm {
self.shader = create_program(UI_VERT_SRC_ES, UI_FRAG_SRC_ES);
} else {
self.shader = create_program(UI_VERT_SRC_CORE, UI_FRAG_SRC_CORE);
}
self.proj_loc = glGetUniformLocation(self.shader, "uProj");
self.tex_loc = glGetUniformLocation(self.shader, "uTex");
// Allocate vertex buffer (CPU side)
buf_size := MAX_UI_VERTICES * UI_VERTEX_BYTES;
self.vertices = xx context.allocator.alloc(buf_size);
memset(self.vertices, 0, buf_size);
self.vertex_count = 0;
// Create VAO/VBO
glGenVertexArrays(1, @self.vao);
glGenBuffers(1, @self.vbo);
glBindVertexArray(self.vao);
glBindBuffer(GL_ARRAY_BUFFER, self.vbo);
glBufferData(GL_ARRAY_BUFFER, xx buf_size, null, GL_DYNAMIC_DRAW);
// pos (2 floats)
glVertexAttribPointer(0, 2, GL_FLOAT, 0, xx UI_VERTEX_BYTES, xx 0);
glEnableVertexAttribArray(0);
// uv (2 floats)
glVertexAttribPointer(1, 2, GL_FLOAT, 0, xx UI_VERTEX_BYTES, xx 8);
glEnableVertexAttribArray(1);
// color (4 floats)
glVertexAttribPointer(2, 4, GL_FLOAT, 0, xx UI_VERTEX_BYTES, xx 16);
glEnableVertexAttribArray(2);
// params: corner_radius, border_width, rect_w, rect_h
glVertexAttribPointer(3, 4, GL_FLOAT, 0, xx UI_VERTEX_BYTES, xx 32);
glEnableVertexAttribArray(3);
glBindVertexArray(0);
self.dpi_scale = 1.0;
// 1x1 white texture for solid rects
self.white_texture = create_white_texture();
}
begin :: (self: *UIRenderer, width: f32, height: f32, font_texture: u32) {
self.screen_width = width;
self.screen_height = height;
self.vertex_count = 0;
self.current_texture = font_texture;
self.draw_calls = 0;
// Set up GL state once for the entire frame
glUseProgram(self.shader);
proj := Mat4.ortho(0.0, width, height, 0.0, -1.0, 1.0);
glUniformMatrix4fv(self.proj_loc, 1, 0, proj.data);
glUniform1i(self.tex_loc, 0);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, font_texture);
glBindVertexArray(self.vao);
glBindBuffer(GL_ARRAY_BUFFER, self.vbo);
}
bind_texture :: (self: *UIRenderer, tex: u32) {
if tex != self.current_texture {
self.flush();
self.current_texture = tex;
}
}
// Emit a quad (2 triangles = 6 vertices)
push_quad :: (self: *UIRenderer, frame: Frame, color: Color, radius: f32, border_w: f32) {
if self.vertex_count + 6 > MAX_UI_VERTICES {
self.flush();
}
x0 := frame.origin.x;
y0 := frame.origin.y;
x1 := x0 + frame.size.width;
y1 := y0 + frame.size.height;
r := color.rf();
g := color.gf();
b := color.bf();
a := color.af();
w := frame.size.width;
h := frame.size.height;
// 6 vertices for quad: TL, TR, BL, TR, BR, BL
self.write_vertex(x0, y0, 0.0, 0.0, r, g, b, a, radius, border_w, w, h);
self.write_vertex(x1, y0, 1.0, 0.0, r, g, b, a, radius, border_w, w, h);
self.write_vertex(x0, y1, 0.0, 1.0, r, g, b, a, radius, border_w, w, h);
self.write_vertex(x1, y0, 1.0, 0.0, r, g, b, a, radius, border_w, w, h);
self.write_vertex(x1, y1, 1.0, 1.0, r, g, b, a, radius, border_w, w, h);
self.write_vertex(x0, y1, 0.0, 1.0, r, g, b, a, radius, border_w, w, h);
}
// Emit a quad with custom UV coordinates (for sprite sheet sub-textures)
push_quad_uv :: (self: *UIRenderer, frame: Frame, color: Color, radius: f32, border_w: f32, uv_min: Point, uv_max: Point) {
if self.vertex_count + 6 > MAX_UI_VERTICES {
self.flush();
}
x0 := frame.origin.x;
y0 := frame.origin.y;
x1 := x0 + frame.size.width;
y1 := y0 + frame.size.height;
r := color.rf();
g := color.gf();
b := color.bf();
a := color.af();
w := frame.size.width;
h := frame.size.height;
u0 := uv_min.x;
v0 := uv_min.y;
u1 := uv_max.x;
v1 := uv_max.y;
self.write_vertex(x0, y0, u0, v0, r, g, b, a, radius, border_w, w, h);
self.write_vertex(x1, y0, u1, v0, r, g, b, a, radius, border_w, w, h);
self.write_vertex(x0, y1, u0, v1, r, g, b, a, radius, border_w, w, h);
self.write_vertex(x1, y0, u1, v0, r, g, b, a, radius, border_w, w, h);
self.write_vertex(x1, y1, u1, v1, r, g, b, a, radius, border_w, w, h);
self.write_vertex(x0, y1, u0, v1, r, g, b, a, radius, border_w, w, h);
}
write_vertex :: (self: *UIRenderer, x: f32, y: f32, u: f32, v: f32, r: f32, g: f32, b: f32, a: f32, cr: f32, bw: f32, rw: f32, rh: f32) {
off := self.vertex_count * UI_VERTEX_FLOATS;
self.vertices[off + 0] = x;
self.vertices[off + 1] = y;
self.vertices[off + 2] = u;
self.vertices[off + 3] = v;
self.vertices[off + 4] = r;
self.vertices[off + 5] = g;
self.vertices[off + 6] = b;
self.vertices[off + 7] = a;
self.vertices[off + 8] = cr;
self.vertices[off + 9] = bw;
self.vertices[off + 10] = rw;
self.vertices[off + 11] = rh;
self.vertex_count += 1;
}
// Walk the render tree and emit quads
process :: (self: *UIRenderer, tree: *RenderTree) {
i := 0;
while i < tree.nodes.len {
node := tree.nodes.items[i];
if node.type == {
case .rect: {
self.push_quad(node.frame, node.fill_color, 0.0, 0.0);
}
case .rounded_rect: {
self.push_quad(node.frame, node.fill_color, node.corner_radius, node.stroke_width);
}
case .text: {
if g_font != null {
self.render_text(node);
}
}
case .image: {
self.bind_texture(node.texture_id);
neg2 : f32 = 0.0 - 2.0;
self.push_quad_uv(node.frame, COLOR_WHITE, neg2, 0.0, node.uv_min, node.uv_max);
// Re-bind font atlas after image
font := g_font;
if font != null {
self.bind_texture(font.texture_id);
}
}
case .clip_push: {
self.flush();
glEnable(GL_SCISSOR_TEST);
dpi := self.dpi_scale;
glScissor(
xx (node.frame.origin.x * dpi),
xx ((self.screen_height - node.frame.origin.y - node.frame.size.height) * dpi),
xx (node.frame.size.width * dpi),
xx (node.frame.size.height * dpi)
);
}
case .clip_pop: {
self.flush();
glDisable(GL_SCISSOR_TEST);
}
case .opacity_push: {}
case .opacity_pop: {}
}
i += 1;
}
}
flush :: (self: *UIRenderer) {
if self.vertex_count == 0 { return; }
// Only bind the current texture (program, projection, VAO already bound in begin())
glBindTexture(GL_TEXTURE_2D, self.current_texture);
upload_size : s64 = self.vertex_count * UI_VERTEX_BYTES;
// Use glBufferData to orphan the old buffer and avoid GPU sync stalls
glBufferData(GL_ARRAY_BUFFER, xx upload_size, self.vertices, GL_DYNAMIC_DRAW);
glDrawArrays(GL_TRIANGLES, 0, xx self.vertex_count);
self.vertex_count = 0;
self.draw_calls += 1;
}
render_text :: (self: *UIRenderer, node: RenderNode) {
font := g_font;
if font == null { return; }
// Shape text into positioned glyphs
font.shape_text(node.text, node.font_size);
// Flush any new glyphs to the atlas texture (no texture switch needed — atlas is already bound)
font.flush();
r := node.text_color.rf();
g := node.text_color.gf();
b := node.text_color.bf();
a := node.text_color.af();
ascent := font.get_ascent(node.font_size);
raster_size := node.font_size * font.dpi_scale;
inv_dpi := font.inv_dpi;
i : s64 = 0;
while i < font.shaped_buf.len {
shaped := font.shaped_buf.items[i];
cached := font.get_or_rasterize(shaped.glyph_index, raster_size);
if cached != null {
if cached.width > 0.0 {
// Scale physical pixel dimensions back to logical units
gx0 := node.frame.origin.x + shaped.x + cached.offset_x * inv_dpi;
gy0 := node.frame.origin.y + ascent + shaped.y + cached.offset_y * inv_dpi;
gx1 := gx0 + cached.width * inv_dpi;
gy1 := gy0 + cached.height * inv_dpi;
u0 := cached.uv_x;
v0 := cached.uv_y;
u1 := cached.uv_x + cached.uv_w;
v1 := cached.uv_y + cached.uv_h;
if self.vertex_count + 6 > MAX_UI_VERTICES {
self.flush();
}
// corner_radius = -1.0 signals "text mode" to the fragment shader
neg1 : f32 = 0.0 - 1.0;
self.write_vertex(gx0, gy0, u0, v0, r, g, b, a, neg1, 0.0, 0.0, 0.0);
self.write_vertex(gx1, gy0, u1, v0, r, g, b, a, neg1, 0.0, 0.0, 0.0);
self.write_vertex(gx0, gy1, u0, v1, r, g, b, a, neg1, 0.0, 0.0, 0.0);
self.write_vertex(gx1, gy0, u1, v0, r, g, b, a, neg1, 0.0, 0.0, 0.0);
self.write_vertex(gx1, gy1, u1, v1, r, g, b, a, neg1, 0.0, 0.0, 0.0);
self.write_vertex(gx0, gy1, u0, v1, r, g, b, a, neg1, 0.0, 0.0, 0.0);
}
}
i += 1;
}
// Flush any glyphs rasterized during this text draw
font.flush();
}
}
create_white_texture :: () -> u32 {
tex : u32 = 0;
glGenTextures(1, @tex);
glBindTexture(GL_TEXTURE_2D, tex);
pixel : [4]u8 = .[255, 255, 255, 255];
glTexImage2D(GL_TEXTURE_2D, 0, xx GL_RGBA, 1, 1, 0, GL_RGBA, GL_UNSIGNED_BYTE, @pixel);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, xx GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, xx GL_NEAREST);
tex;
}
// --- UI Shaders ---
// --- Desktop (Core Profile 3.3) shaders ---
UI_VERT_SRC_CORE :: #string GLSL
#version 330 core
layout(location = 0) in vec2 aPos;
layout(location = 1) in vec2 aUV;
layout(location = 2) in vec4 aColor;
layout(location = 3) in vec4 aParams;
uniform mat4 uProj;
out vec2 vUV;
out vec4 vColor;
out vec4 vParams;
void main() {
gl_Position = uProj * vec4(aPos, 0.0, 1.0);
vUV = aUV;
vColor = aColor;
vParams = aParams;
}
GLSL;
UI_FRAG_SRC_CORE :: #string GLSL
#version 330 core
in vec2 vUV;
in vec4 vColor;
in vec4 vParams;
uniform sampler2D uTex;
out vec4 FragColor;
float roundedBoxSDF(vec2 center, vec2 half_size, float radius) {
vec2 q = abs(center) - half_size + vec2(radius);
return length(max(q, vec2(0.0))) + min(max(q.x, q.y), 0.0) - radius;
}
void main() {
float mode = vParams.x;
float border = vParams.y;
vec2 rectSize = vParams.zw;
if (mode < -1.5) {
// Image mode (mode == -2.0): sample texture
FragColor = texture(uTex, vUV) * vColor;
} else if (mode < 0.0) {
// Text mode (mode == -1.0): sample glyph atlas .r as alpha
float alpha = texture(uTex, vUV).r;
float ew = fwidth(alpha) * 0.7;
alpha = smoothstep(0.5 - ew, 0.5 + ew, alpha);
FragColor = vec4(vColor.rgb, vColor.a * pow(alpha, 0.9));
} else if (mode > 0.0 || border > 0.0) {
// Rounded rect: SDF alpha, vertex color only (no texture sample)
vec2 half_size = rectSize * 0.5;
vec2 center = (vUV - vec2(0.5)) * rectSize;
float dist = roundedBoxSDF(center, half_size, mode);
float aa = fwidth(dist);
float alpha = 1.0 - smoothstep(-aa, aa, dist);
if (border > 0.0) {
float inner = roundedBoxSDF(center, half_size - vec2(border), max(mode - border, 0.0));
float border_alpha = smoothstep(-aa, aa, inner);
alpha = alpha * max(border_alpha, 0.0);
}
FragColor = vec4(vColor.rgb, vColor.a * alpha);
} else {
// Plain rect: vertex color only (no texture sample)
FragColor = vColor;
}
}
GLSL;
// --- WASM (ES 3.0 / WebGL2) shaders ---
UI_VERT_SRC_ES :: #string GLSL
#version 300 es
precision mediump float;
layout(location = 0) in vec2 aPos;
layout(location = 1) in vec2 aUV;
layout(location = 2) in vec4 aColor;
layout(location = 3) in vec4 aParams;
uniform mat4 uProj;
out vec2 vUV;
out vec4 vColor;
out vec4 vParams;
void main() {
gl_Position = uProj * vec4(aPos, 0.0, 1.0);
vUV = aUV;
vColor = aColor;
vParams = aParams;
}
GLSL;
UI_FRAG_SRC_ES :: #string GLSL
#version 300 es
precision mediump float;
in vec2 vUV;
in vec4 vColor;
in vec4 vParams;
uniform sampler2D uTex;
out vec4 FragColor;
float roundedBoxSDF(vec2 center, vec2 half_size, float radius) {
vec2 q = abs(center) - half_size + vec2(radius);
return length(max(q, vec2(0.0))) + min(max(q.x, q.y), 0.0) - radius;
}
void main() {
float mode = vParams.x;
float border = vParams.y;
vec2 rectSize = vParams.zw;
if (mode < -1.5) {
// Image mode (mode == -2.0): sample texture
FragColor = texture(uTex, vUV) * vColor;
} else if (mode < 0.0) {
// Text mode (mode == -1.0): sample glyph atlas .r as alpha
float alpha = texture(uTex, vUV).r;
float ew = fwidth(alpha) * 0.7;
alpha = smoothstep(0.5 - ew, 0.5 + ew, alpha);
FragColor = vec4(vColor.rgb, vColor.a * pow(alpha, 0.9));
} else if (mode > 0.0 || border > 0.0) {
// Rounded rect: SDF alpha, vertex color only
vec2 half_size = rectSize * 0.5;
vec2 center = (vUV - vec2(0.5)) * rectSize;
float dist = roundedBoxSDF(center, half_size, mode);
float aa = fwidth(dist);
float alpha = 1.0 - smoothstep(-aa, aa, dist);
if (border > 0.0) {
float inner = roundedBoxSDF(center, half_size - vec2(border), max(mode - border, 0.0));
float border_alpha = smoothstep(-aa, aa, inner);
alpha = alpha * max(border_alpha, 0.0);
}
FragColor = vec4(vColor.rgb, vColor.a * alpha);
} else {
// Plain rect: vertex color only
FragColor = vColor;
}
}
GLSL;

149
library/modules/ui/scroll_view.sx Executable file
View File

@@ -0,0 +1,149 @@
#import "modules/std.sx";
#import "modules/math";
#import "modules/ui/types.sx";
#import "modules/ui/render.sx";
#import "modules/ui/events.sx";
#import "modules/ui/view.sx";
ScrollAxes :: enum { vertical; horizontal; both; }
// Persistent scroll state — lives outside the frame arena
ScrollState :: struct {
offset: Point;
content_size: Size;
viewport_size: Size;
dragging: bool;
drag_pending: bool;
drag_start: Point;
drag_offset: Point;
}
ScrollView :: struct {
child: ViewChild;
state: *ScrollState;
axes: ScrollAxes;
SCROLL_SPEED :f32: 20.0;
DRAG_THRESHOLD :f32: 4.0;
clamp_offset :: (self: *ScrollView) {
s := self.state;
max_x := max(0.0, s.content_size.width - s.viewport_size.width);
max_y := max(0.0, s.content_size.height - s.viewport_size.height);
if self.axes == .vertical or self.axes == .both {
s.offset.y = clamp(s.offset.y, 0.0, max_y);
} else {
s.offset.y = 0.0;
}
if self.axes == .horizontal or self.axes == .both {
s.offset.x = clamp(s.offset.x, 0.0, max_x);
} else {
s.offset.x = 0.0;
}
}
}
impl View for ScrollView {
size_that_fits :: (self: *ScrollView, proposal: ProposedSize) -> Size {
// ScrollView takes all proposed space (default 200 if unspecified)
Size.{
width = proposal.width ?? 200.0,
height = proposal.height ?? 200.0
};
}
layout :: (self: *ScrollView, bounds: Frame) {
s := self.state;
s.viewport_size = bounds.size;
// Measure child with infinite space on scroll axes
child_proposal := ProposedSize.{
width = if self.axes == .horizontal or self.axes == .both then null else bounds.size.width,
height = if self.axes == .vertical or self.axes == .both then null else bounds.size.height
};
s.content_size = self.child.view.size_that_fits(child_proposal);
self.clamp_offset();
// Layout child offset by scroll position
self.child.computed_frame = Frame.make(
bounds.origin.x - s.offset.x,
bounds.origin.y - s.offset.y,
s.content_size.width,
s.content_size.height
);
self.child.view.layout(self.child.computed_frame);
}
render :: (self: *ScrollView, ctx: *RenderContext, frame: Frame) {
ctx.push_clip(frame);
self.child.view.render(ctx, self.child.computed_frame);
ctx.pop_clip();
}
handle_event :: (self: *ScrollView, event: *Event, frame: Frame) -> bool {
s := self.state;
if pos := event_position(event) {
if !frame.contains(pos) { return false; }
}
if event.* == {
case .mouse_wheel: (d) {
if self.axes == .vertical or self.axes == .both {
s.offset.y -= d.delta.y * ScrollView.SCROLL_SPEED;
}
if self.axes == .horizontal or self.axes == .both {
s.offset.x -= d.delta.x * ScrollView.SCROLL_SPEED;
}
self.clamp_offset();
return true;
}
case .mouse_down: (d) {
s.drag_pending = true;
s.drag_start = d.position;
s.drag_offset = s.offset;
self.child.view.handle_event(event, self.child.computed_frame);
return true;
}
case .mouse_moved: (d) {
if s.drag_pending and !s.dragging {
dx := d.position.x - s.drag_start.x;
dy := d.position.y - s.drag_start.y;
dist := sqrt(dx * dx + dy * dy);
if dist >= ScrollView.DRAG_THRESHOLD {
s.dragging = true;
s.drag_pending = false;
cancel :Event = .mouse_up(.{
position = .{ x = 0.0 - 10000.0, y = 0.0 - 10000.0 },
button = .none
});
self.child.view.handle_event(@cancel, self.child.computed_frame);
}
}
if s.dragging {
if self.axes == .vertical or self.axes == .both {
s.offset.y = s.drag_offset.y - (d.position.y - s.drag_start.y);
}
if self.axes == .horizontal or self.axes == .both {
s.offset.x = s.drag_offset.x - (d.position.x - s.drag_start.x);
}
self.clamp_offset();
return true;
}
return self.child.view.handle_event(event, self.child.computed_frame);
}
case .mouse_up: {
was_dragging := s.dragging;
s.dragging = false;
s.drag_pending = false;
if was_dragging {
return true;
}
return self.child.view.handle_event(event, self.child.computed_frame);
}
}
self.child.view.handle_event(event, self.child.computed_frame);
}
}

181
library/modules/ui/stacks.sx Executable file
View File

@@ -0,0 +1,181 @@
#import "modules/std.sx";
#import "modules/math";
#import "modules/ui/types.sx";
#import "modules/ui/render.sx";
#import "modules/ui/events.sx";
#import "modules/ui/view.sx";
#import "modules/ui/layout.sx";
VStack :: struct {
children: List(ViewChild);
spacing: f32;
alignment: HAlignment;
add :: (self: *VStack, view: View) {
self.children.append(.{ view = view });
}
}
impl View for VStack {
size_that_fits :: (self: *VStack, proposal: ProposedSize) -> Size {
measure_vstack(@self.children, proposal, self.spacing);
}
layout :: (self: *VStack, bounds: Frame) {
layout_vstack(@self.children, bounds, self.spacing, self.alignment);
}
render :: (self: *VStack, ctx: *RenderContext, frame: Frame) {
i := 0;
while i < self.children.len {
child := @self.children.items[i];
child.view.render(ctx, child.computed_frame);
i += 1;
}
}
handle_event :: (self: *VStack, event: *Event, frame: Frame) -> bool {
// Iterate children in reverse (front-to-back for overlapping)
i := self.children.len - 1;
while i >= 0 {
child := @self.children.items[i];
if child.view.handle_event(event, child.computed_frame) {
return true;
}
i -= 1;
}
false;
}
}
HStack :: struct {
children: List(ViewChild);
spacing: f32;
alignment: VAlignment;
add :: (self: *HStack, view: View) {
self.children.append(.{ view = view });
}
}
impl View for HStack {
size_that_fits :: (self: *HStack, proposal: ProposedSize) -> Size {
measure_hstack(@self.children, proposal, self.spacing);
}
layout :: (self: *HStack, bounds: Frame) {
layout_hstack(@self.children, bounds, self.spacing, self.alignment);
}
render :: (self: *HStack, ctx: *RenderContext, frame: Frame) {
i := 0;
while i < self.children.len {
child := @self.children.items[i];
child.view.render(ctx, child.computed_frame);
i += 1;
}
}
handle_event :: (self: *HStack, event: *Event, frame: Frame) -> bool {
i := self.children.len - 1;
while i >= 0 {
child := @self.children.items[i];
if child.view.handle_event(event, child.computed_frame) {
return true;
}
i -= 1;
}
false;
}
}
ZStack :: struct {
children: List(ViewChild);
alignment: Alignment;
add :: (self: *ZStack, view: View) {
self.children.append(.{ view = view });
}
}
impl View for ZStack {
size_that_fits :: (self: *ZStack, proposal: ProposedSize) -> Size {
measure_zstack(@self.children, proposal);
}
layout :: (self: *ZStack, bounds: Frame) {
layout_zstack(@self.children, bounds, self.alignment);
}
render :: (self: *ZStack, ctx: *RenderContext, frame: Frame) {
// Render back-to-front (first child is bottommost)
i := 0;
while i < self.children.len {
child := @self.children.items[i];
child.view.render(ctx, child.computed_frame);
i += 1;
}
}
handle_event :: (self: *ZStack, event: *Event, frame: Frame) -> bool {
// Handle front-to-back (last child is topmost)
i := self.children.len - 1;
while i >= 0 {
child := @self.children.items[i];
if child.view.handle_event(event, child.computed_frame) {
return true;
}
i -= 1;
}
false;
}
}
// Spacer — fills available space
Spacer :: struct {
min_length: f32;
}
impl View for Spacer {
size_that_fits :: (self: *Spacer, proposal: ProposedSize) -> Size {
w := proposal.width ?? self.min_length;
h := proposal.height ?? self.min_length;
Size.{ width = max(w, self.min_length), height = max(h, self.min_length) };
}
layout :: (self: *Spacer, bounds: Frame) {}
render :: (self: *Spacer, ctx: *RenderContext, frame: Frame) {}
handle_event :: (self: *Spacer, event: *Event, frame: Frame) -> bool { false; }
}
// Rect — simple colored rectangle view
RectView :: struct {
color: Color;
corner_radius: f32;
preferred_width: f32;
preferred_height: f32;
}
impl View for RectView {
size_that_fits :: (self: *RectView, proposal: ProposedSize) -> Size {
w := proposal.width ?? self.preferred_width;
h := proposal.height ?? self.preferred_height;
Size.{ width = w, height = h };
}
layout :: (self: *RectView, bounds: Frame) {}
render :: (self: *RectView, ctx: *RenderContext, frame: Frame) {
if self.corner_radius > 0.0 {
ctx.add_rounded_rect(frame, self.color, self.corner_radius);
} else {
ctx.add_rect(frame, self.color);
}
}
handle_event :: (self: *RectView, event: *Event, frame: Frame) -> bool { false; }
}

59
library/modules/ui/state.sx Executable file
View File

@@ -0,0 +1,59 @@
#import "modules/std.sx";
// --- State(T) — a handle to persistent storage ---
State :: struct ($T: Type) {
ptr: *T;
get :: (self: State(T)) -> T { self.ptr.*; }
set :: (self: State(T), val: T) { self.ptr.* = val; }
}
// --- StateEntry — type-erased storage ---
StateEntry :: struct {
id: s64;
data: [*]u8;
size: s64;
generation: s64;
}
// --- StateStore — manages persistent state ---
StateStore :: struct {
entries: List(StateEntry);
current_generation: s64;
init :: (self: *StateStore) {
self.entries = List(StateEntry).{};
self.current_generation = 0;
}
get_or_create :: (self: *StateStore, id: s64, $T: Type, default: T) -> State(T) {
// Search for existing entry
i : s64 = 0;
while i < self.entries.len {
if self.entries.items[i].id == id {
self.entries.items[i].generation = self.current_generation;
return State(T).{ ptr = xx self.entries.items[i].data };
}
i += 1;
}
// Create new entry
data : [*]u8 = xx context.allocator.alloc(size_of(T));
memcpy(data, @default, size_of(T));
self.entries.append(.{
id = id,
data = data,
size = size_of(T),
generation = self.current_generation
});
State(T).{ ptr = xx data };
}
next_frame :: (self: *StateStore) {
self.current_generation += 1;
}
}

View File

@@ -0,0 +1,63 @@
#import "modules/std.sx";
#import "modules/math";
#import "modules/ui/types.sx";
#import "modules/ui/render.sx";
#import "modules/ui/events.sx";
#import "modules/ui/view.sx";
#import "modules/ui/font.sx";
StatsPanel :: struct {
delta_time: *f32;
font_size: f32;
PADDING :f32: 12.0;
LINE_SPACING :f32: 4.0;
TITLE_SIZE :f32: 12.0;
VALUE_SIZE :f32: 11.0;
BG_COLOR :Color: Color.rgba(30, 30, 38, 200);
CORNER_RADIUS:f32: 12.0;
}
impl View for StatsPanel {
size_that_fits :: (self: *StatsPanel, proposal: ProposedSize) -> Size {
title_size := measure_text("Statistics", StatsPanel.TITLE_SIZE);
fps_size := measure_text("FPS: 0000", StatsPanel.VALUE_SIZE);
w := max(title_size.width, fps_size.width) + StatsPanel.PADDING * 2.0;
h := title_size.height + StatsPanel.LINE_SPACING + fps_size.height + StatsPanel.PADDING * 2.0;
Size.{ width = w, height = h };
}
layout :: (self: *StatsPanel, bounds: Frame) {}
render :: (self: *StatsPanel, ctx: *RenderContext, frame: Frame) {
// Background
ctx.add_rounded_rect(frame, StatsPanel.BG_COLOR, StatsPanel.CORNER_RADIUS);
// Title
title_size := measure_text("Statistics", StatsPanel.TITLE_SIZE);
title_frame := Frame.make(
frame.origin.x + StatsPanel.PADDING,
frame.origin.y + StatsPanel.PADDING,
title_size.width,
title_size.height
);
ctx.add_text(title_frame, "Statistics", StatsPanel.TITLE_SIZE, COLOR_WHITE);
// FPS value
dt := self.delta_time.*;
fps : s64 = if dt > 0.0 then xx (1.0 / dt) else 0;
fps_text := format("FPS: {}", fps);
fps_size := measure_text(fps_text, StatsPanel.VALUE_SIZE);
fps_frame := Frame.make(
frame.origin.x + StatsPanel.PADDING,
title_frame.max_y() + StatsPanel.LINE_SPACING,
fps_size.width,
fps_size.height
);
ctx.add_text(fps_frame, fps_text, StatsPanel.VALUE_SIZE, Color.rgba(180, 180, 190, 255));
}
handle_event :: (self: *StatsPanel, event: *Event, frame: Frame) -> bool {
false;
}
}

220
library/modules/ui/types.sx Executable file
View File

@@ -0,0 +1,220 @@
#import "modules/std.sx";
#import "modules/math";
Point :: struct {
x, y: f32;
zero :: () -> Point => .{ x = 0.0, y = 0.0 };
add :: (self: Point, b: Point) -> Point {
Point.{ x = self.x + b.x, y = self.y + b.y };
}
sub :: (self: Point, b: Point) -> Point {
Point.{ x = self.x - b.x, y = self.y - b.y };
}
scale :: (self: Point, s: f32) -> Point {
Point.{ x = self.x * s, y = self.y * s };
}
distance :: (self: Point, b: Point) -> f32 {
dx := self.x - b.x;
dy := self.y - b.y;
sqrt(dx * dx + dy * dy);
}
}
Size :: struct {
width, height: f32;
zero :: () -> Size => .{ width = 0.0, height = 0.0 };
contains :: (self: Size, point: Point) -> bool {
point.x >= 0.0 and point.x <= self.width and point.y >= 0.0 and point.y <= self.height;
}
}
Frame :: struct {
origin: Point;
size: Size;
zero :: () -> Frame { Frame.{ origin = Point.zero(), size = Size.zero() }; }
make :: (x: f32, y: f32, w: f32, h: f32) -> Frame {
Frame.{ origin = Point.{ x = x, y = y }, size = Size.{ width = w, height = h } };
}
max_x :: (self: Frame) -> f32 { self.origin.x + self.size.width; }
max_y :: (self: Frame) -> f32 { self.origin.y + self.size.height; }
mid_x :: (self: Frame) -> f32 { self.origin.x + self.size.width * 0.5; }
mid_y :: (self: Frame) -> f32 { self.origin.y + self.size.height * 0.5; }
contains :: (self: Frame, point: Point) -> bool {
point.x >= self.origin.x and point.x <= self.max_x()
and point.y >= self.origin.y and point.y <= self.max_y();
}
intersection :: (self: Frame, other: Frame) -> Frame {
x1 := max(self.origin.x, other.origin.x);
y1 := max(self.origin.y, other.origin.y);
x2 := min(self.max_x(), other.max_x());
y2 := min(self.max_y(), other.max_y());
if x2 <= x1 or y2 <= y1
then .zero()
else .make(x1, y1, x2 - x1, y2 - y1);
}
inset :: (self: Frame, insets: EdgeInsets) -> Frame {
Frame.make(
self.origin.x + insets.left,
self.origin.y + insets.top,
self.size.width - insets.left - insets.right,
self.size.height - insets.top - insets.bottom
);
}
expand :: (self: Frame, amount: f32) -> Frame {
Frame.make(
self.origin.x - amount,
self.origin.y - amount,
self.size.width + amount * 2.0,
self.size.height + amount * 2.0
);
}
}
EdgeInsets :: struct {
top, left, bottom, right: f32;
zero :: () -> EdgeInsets { EdgeInsets.{ top = 0.0, left = 0.0, bottom = 0.0, right = 0.0 }; }
all :: (v: f32) -> EdgeInsets {
EdgeInsets.{ top = v, left = v, bottom = v, right = v };
}
symmetric :: (h: f32, v: f32) -> EdgeInsets {
EdgeInsets.{ top = v, left = h, bottom = v, right = h };
}
horizontal :: (self: EdgeInsets) -> f32 { self.left + self.right; }
vertical :: (self: EdgeInsets) -> f32 { self.top + self.bottom; }
}
Color :: struct {
r, g, b, a: u8;
rgba :: (r: u8, g: u8, b: u8, a: u8) -> Color {
Color.{ r = r, g = g, b = b, a = a };
}
rgb :: (r: u8, g: u8, b: u8) -> Color {
Color.{ r = r, g = g, b = b, a = 255 };
}
rf :: (self: Color) -> f32 { xx self.r / 255.0; }
gf :: (self: Color) -> f32 { xx self.g / 255.0; }
bf :: (self: Color) -> f32 { xx self.b / 255.0; }
af :: (self: Color) -> f32 { xx self.a / 255.0; }
with_alpha :: (self: Color, a: u8) -> Color {
Color.{ r = self.r, g = self.g, b = self.b, a = a };
}
lerp :: (self: Color, b: Color, t: f32) -> Color {
Color.{
r = xx (self.r + (b.r - self.r) * t),
g = xx (self.g + (b.g - self.g) * t),
b = xx (self.b + (b.b - self.b) * t),
a = xx (self.a + (b.a - self.a) * t),
};
}
}
// Named color constants
COLOR_WHITE :: Color.{ r = 255, g = 255, b = 255, a = 255 };
COLOR_BLACK :: Color.{ r = 0, g = 0, b = 0, a = 255 };
COLOR_RED :: Color.{ r = 255, g = 59, b = 48, a = 255 };
COLOR_GREEN :: Color.{ r = 52, g = 199, b = 89, a = 255 };
COLOR_BLUE :: Color.{ r = 0, g = 122, b = 255, a = 255 };
COLOR_YELLOW :: Color.{ r = 255, g = 204, b = 0, a = 255 };
COLOR_ORANGE :: Color.{ r = 255, g = 149, b = 0, a = 255 };
COLOR_GRAY :: Color.{ r = 142, g = 142, b = 147, a = 255 };
COLOR_DARK_GRAY :: Color.{ r = 44, g = 44, b = 46, a = 255 };
COLOR_LIGHT_GRAY :: Color.{ r = 209, g = 209, b = 214, a = 255 };
COLOR_TRANSPARENT :: Color.{ r = 0, g = 0, b = 0, a = 0 };
// Size proposal — optional dimensions (null = flexible)
ProposedSize :: struct {
width: ?f32;
height: ?f32;
fixed :: (w: f32, h: f32) -> ProposedSize {
ProposedSize.{ width = w, height = h };
}
flexible :: () -> ProposedSize {
ProposedSize.{ width = null, height = null };
}
}
HAlignment :: enum {
leading;
center;
trailing;
}
VAlignment :: enum {
top;
center;
bottom;
}
Alignment :: struct {
h: HAlignment;
v: VAlignment;
}
ALIGN_CENTER :: Alignment.{ h = .center, v = .center };
ALIGN_TOP_LEADING :: Alignment.{ h = .leading, v = .top };
ALIGN_TOP :: Alignment.{ h = .center, v = .top };
ALIGN_TOP_TRAILING :: Alignment.{ h = .trailing, v = .top };
ALIGN_LEADING :: Alignment.{ h = .leading, v = .center };
ALIGN_TRAILING :: Alignment.{ h = .trailing, v = .center };
ALIGN_BOTTOM :: Alignment.{ h = .center, v = .bottom };
ALIGN_BOTTOM_LEADING :: Alignment.{ h = .leading, v = .bottom };
ALIGN_BOTTOM_TRAILING :: Alignment.{ h = .trailing, v = .bottom };
// Compute x offset for a child of child_width inside container_width
align_h :: (alignment: HAlignment, child_width: f32, container_width: f32) -> f32 {
if alignment == {
case .leading: 0.0;
case .center: { (container_width - child_width) * 0.5; }
case .trailing: container_width - child_width;
}
}
// Compute y offset for a child of child_height inside container_height
align_v :: (alignment: VAlignment, child_height: f32, container_height: f32) -> f32 {
if alignment == {
case .top: 0.0;
case .center: { (container_height - child_height) * 0.5; }
case .bottom: container_height - child_height;
}
}
// --- Lerpable implementations ---
#import "modules/ui/animation.sx";
impl Lerpable for Point {
lerp :: (self: Point, b: Point, t: f32) -> Point {
Point.{ x = self.x + (b.x - self.x) * t, y = self.y + (b.y - self.y) * t };
}
}
impl Lerpable for Size {
lerp :: (self: Size, b: Size, t: f32) -> Size {
Size.{ width = self.width + (b.width - self.width) * t, height = self.height + (b.height - self.height) * t };
}
}

23
library/modules/ui/view.sx Executable file
View File

@@ -0,0 +1,23 @@
#import "modules/ui/types.sx";
#import "modules/ui/render.sx";
#import "modules/ui/events.sx";
View :: protocol {
// Measure: given a size proposal, return desired size
size_that_fits :: (proposal: ProposedSize) -> Size;
// Place: position children within the given bounds
layout :: (bounds: Frame);
// Render: emit render nodes
render :: (ctx: *RenderContext, frame: Frame);
// Event handling: return true if the event was consumed
handle_event :: (event: *Event, frame: Frame) -> bool;
}
// A child view with its computed frame (set during layout)
ViewChild :: struct {
view: View;
computed_frame: Frame = .zero();
}