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:
167
library/modules/ui/animation.sx
Executable file
167
library/modules/ui/animation.sx
Executable 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
91
library/modules/ui/button.sx
Executable 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
697
library/modules/ui/dock.sx
Executable 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
96
library/modules/ui/events.sx
Executable 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
20
library/modules/ui/font.sx
Executable 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
128
library/modules/ui/gesture.sx
Executable 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
613
library/modules/ui/glyph_cache.sx
Executable 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
36
library/modules/ui/image.sx
Executable 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
38
library/modules/ui/label.sx
Executable 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
152
library/modules/ui/layout.sx
Executable 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
296
library/modules/ui/modifier.sx
Executable 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
162
library/modules/ui/pipeline.sx
Executable 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
179
library/modules/ui/render.sx
Executable 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
460
library/modules/ui/renderer.sx
Executable 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
149
library/modules/ui/scroll_view.sx
Executable 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
181
library/modules/ui/stacks.sx
Executable 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
59
library/modules/ui/state.sx
Executable 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
63
library/modules/ui/stats_panel.sx
Executable file
63
library/modules/ui/stats_panel.sx
Executable 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
220
library/modules/ui/types.sx
Executable 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
23
library/modules/ui/view.sx
Executable 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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user