This commit is contained in:
agra
2026-03-02 19:47:25 +02:00
parent 812bc6d6ec
commit e63c946116
33 changed files with 32185 additions and 202 deletions

108
ui/animation.sx Normal file
View File

@@ -0,0 +1,108 @@
#import "modules/std.sx";
#import "modules/math";
// --- 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;
}
}

View File

@@ -4,6 +4,7 @@
#import "ui/events.sx";
#import "ui/view.sx";
#import "ui/label.sx";
#import "ui/font.sx";
ButtonStyle :: struct {
background: Color;
@@ -37,12 +38,10 @@ Button :: struct {
impl View for Button {
size_that_fits :: (self: *Button, proposal: ProposedSize) -> Size {
scale := self.font_size / GLYPH_HEIGHT_APPROX;
text_w := xx self.label.len * GLYPH_WIDTH_APPROX * scale;
text_h := self.font_size;
text_size := measure_text(self.label, self.font_size);
Size.{
width = text_w + self.style.padding.horizontal(),
height = text_h + self.style.padding.vertical()
width = text_size.width + self.style.padding.horizontal(),
height = text_size.height + self.style.padding.vertical()
};
}
@@ -56,31 +55,29 @@ impl View for Button {
ctx.add_rounded_rect(frame, bg, self.style.corner_radius);
// Text centered in frame
scale := self.font_size / GLYPH_HEIGHT_APPROX;
text_w := xx self.label.len * GLYPH_WIDTH_APPROX * scale;
text_h := self.font_size;
text_x := frame.origin.x + (frame.size.width - text_w) * 0.5;
text_y := frame.origin.y + (frame.size.height - text_h) * 0.5;
text_frame := Frame.make(text_x, text_y, text_w, text_h);
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.type == {
case .mouse_moved: {
self.hovered = frame.contains(event.position);
if event.* == {
case .mouse_moved: (d) {
self.hovered = frame.contains(d.position);
return false;
}
case .mouse_down: {
if frame.contains(event.position) {
case .mouse_down: (d) {
if frame.contains(d.position) {
self.pressed = true;
return true;
}
}
case .mouse_up: {
case .mouse_up: (d) {
if self.pressed {
self.pressed = false;
if frame.contains(event.position) {
if frame.contains(d.position) {
if handler := self.on_tap {
handler();
}

View File

@@ -2,19 +2,6 @@
#import "modules/sdl3.sx";
#import "ui/types.sx";
EventType :: enum {
none;
mouse_down;
mouse_up;
mouse_moved;
mouse_wheel;
key_down;
key_up;
text_input;
window_resize;
quit;
}
MouseButton :: enum {
none;
left;
@@ -22,85 +9,89 @@ MouseButton :: enum {
right;
}
Event :: struct {
type: EventType;
position: Point;
delta: Point;
button: MouseButton;
key: u32;
text: string;
timestamp: u64;
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; }
make :: (type: EventType) -> Event {
e : Event = ---;
memset(@e, 0, size_of(Event));
e.type = type;
e;
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 Event.make(.quit);
return .quit;
}
case .key_down: (data) {
e := Event.make(.key_down);
e.key = xx data.key;
e.timestamp = data.timestamp;
return e;
return .key_down(KeyData.{ key = xx data.key });
}
case .key_up: (data) {
e := Event.make(.key_up);
e.key = xx data.key;
e.timestamp = data.timestamp;
return e;
return .key_up(KeyData.{ key = xx data.key });
}
case .mouse_motion: (data) {
e := Event.make(.mouse_moved);
e.position = Point.{ x = data.x, y = data.y };
e.delta = Point.{ x = data.xrel, y = data.yrel };
e.timestamp = data.timestamp;
return e;
return .mouse_moved(MouseMotionData.{
position = Point.{ x = data.x, y = data.y },
delta = Point.{ x = data.xrel, y = data.yrel }
});
}
case .mouse_button_down: (data) {
e := Event.make(.mouse_down);
e.position = Point.{ x = data.x, y = data.y };
e.button = if data.button == {
print(" mouse_down raw: x={} y={} btn={}\n", data.x, data.y, data.button);
btn :MouseButton = if data.button == {
case 1: .left;
case 2: .middle;
case 3: .right;
else: .none;
};
e.timestamp = data.timestamp;
return e;
return .mouse_down(MouseButtonData.{
position = Point.{ x = data.x, y = data.y },
button = btn
});
}
case .mouse_button_up: (data) {
e := Event.make(.mouse_up);
e.position = Point.{ x = data.x, y = data.y };
e.button = if data.button == {
btn :MouseButton = if data.button == {
case 1: .left;
case 2: .middle;
case 3: .right;
else: .none;
};
e.timestamp = data.timestamp;
return e;
return .mouse_up(MouseButtonData.{
position = Point.{ x = data.x, y = data.y },
button = btn
});
}
case .mouse_wheel: (data) {
e := Event.make(.mouse_wheel);
e.delta = Point.{ x = data.x, y = data.y };
e.position = Point.{ x = data.mouse_x, y = data.mouse_y };
e.timestamp = data.timestamp;
return e;
return .mouse_wheel(MouseWheelData.{
position = Point.{ x = data.mouse_x, y = data.mouse_y },
delta = Point.{ x = data.x, y = data.y }
});
}
case .window_resized: (data) {
e := Event.make(.window_resize);
e.position = Point.{ x = xx data.data1, y = xx data.data2 };
e.timestamp = data.timestamp;
return e;
return .window_resize(ResizeData.{
size = Size.{ width = xx data.data1, height = xx data.data2 }
});
}
}
Event.make(.none);
.none;
}

104
ui/font.sx Normal file
View File

@@ -0,0 +1,104 @@
#import "modules/std.sx";
#import "ui/types.sx";
FIRST_CHAR :s32: 32;
NUM_CHARS :s32: 96;
ATLAS_W :s32: 512;
ATLAS_H :s32: 512;
// Matches stbtt_bakedchar memory layout
BakedChar :: struct {
x0, y0, x1, y1: u16;
xoff, yoff, xadvance: f32;
}
// Matches stbtt_aligned_quad memory layout
AlignedQuad :: struct {
x0, y0, s0, t0: f32;
x1, y1, s1, t1: f32;
}
FontAtlas :: struct {
texture_id: u32;
font_size: f32;
char_data: [*]BakedChar;
bitmap: [*]u8;
ascent: f32;
descent: f32;
line_height: f32;
// Bake font glyphs into a bitmap. Call upload_texture() after GL is ready.
init :: (self: *FontAtlas, path: [:0]u8, size: f32) {
file_size : s32 = 0;
font_data := read_file_bytes(path, @file_size);
if xx font_data == 0 {
out("Failed to load font: ");
out(path);
out("\n");
return;
}
self.font_size = size;
// Allocate baked char data (96 entries for ASCII 32..127)
self.char_data = xx context.allocator.alloc(xx NUM_CHARS * size_of(BakedChar));
// Bake font bitmap (512x512 single-channel alpha)
bitmap_size : s64 = xx ATLAS_W * xx ATLAS_H;
self.bitmap = xx context.allocator.alloc(bitmap_size);
stbtt_BakeFontBitmap(font_data, 0, size, self.bitmap, ATLAS_W, ATLAS_H, FIRST_CHAR, NUM_CHARS, xx self.char_data);
// Get font vertical metrics
fontinfo : [256]u8 = ---;
stbtt_InitFont(xx @fontinfo, font_data, 0);
ascent_i : s32 = 0;
descent_i : s32 = 0;
linegap_i : s32 = 0;
stbtt_GetFontVMetrics(xx @fontinfo, @ascent_i, @descent_i, @linegap_i);
scale := stbtt_ScaleForPixelHeight(xx @fontinfo, size);
self.ascent = xx ascent_i * scale;
self.descent = xx descent_i * scale;
self.line_height = self.ascent - self.descent + xx linegap_i * scale;
font_data_ptr : *void = xx font_data;
free(font_data_ptr);
out("Font loaded: ");
out(path);
out("\n");
}
measure_text :: (self: *FontAtlas, text: string, font_size: f32) -> Size {
if self.char_data == null { return Size.zero(); }
scale := font_size / self.font_size;
xpos : f32 = 0.0;
ypos : f32 = 0.0;
q : AlignedQuad = ---;
i : s64 = 0;
while i < text.len {
ch : s32 = xx text[i];
if ch >= FIRST_CHAR and ch < FIRST_CHAR + NUM_CHARS {
stbtt_GetBakedQuad(xx self.char_data, ATLAS_W, ATLAS_H, ch - FIRST_CHAR, @xpos, @ypos, xx @q, 1);
}
i += 1;
}
Size.{ width = xpos * scale, height = self.line_height * scale };
}
}
// Global font atlas pointer for views (Label, Button) to access
g_font : *FontAtlas = xx 0;
set_global_font :: (font: *FontAtlas) {
g_font = font;
}
// Convenience measurement function for views
measure_text :: (text: string, font_size: f32) -> Size {
if xx g_font == 0 {
// 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
ui/gesture.sx Normal file
View File

@@ -0,0 +1,128 @@
#import "modules/std.sx";
#import "modules/math";
#import "ui/types.sx";
#import "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;
}
}

36
ui/image.sx Normal file
View File

@@ -0,0 +1,36 @@
#import "modules/std.sx";
#import "ui/types.sx";
#import "ui/render.sx";
#import "ui/events.sx";
#import "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;
}
}

View File

@@ -3,9 +3,7 @@
#import "ui/render.sx";
#import "ui/events.sx";
#import "ui/view.sx";
GLYPH_WIDTH_APPROX :f32: 8.0;
GLYPH_HEIGHT_APPROX :f32: 16.0;
#import "ui/font.sx";
Label :: struct {
text: string;
@@ -23,11 +21,7 @@ Label :: struct {
impl View for Label {
size_that_fits :: (self: *Label, proposal: ProposedSize) -> Size {
// Approximate: chars × avg glyph width, scaled by font size
scale := self.font_size / GLYPH_HEIGHT_APPROX;
w := xx self.text.len * GLYPH_WIDTH_APPROX * scale;
h := self.font_size;
Size.{ width = w, height = h };
measure_text(self.text, self.font_size);
}
layout :: (self: *Label, bounds: Frame) {

296
ui/modifier.sx Normal file
View File

@@ -0,0 +1,296 @@
#import "modules/std.sx";
#import "modules/math";
#import "ui/types.sx";
#import "ui/render.sx";
#import "ui/events.sx";
#import "ui/view.sx";
#import "ui/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 = ViewChild.{ view = view }, insets = insets };
}
fixed_frame :: (view: View, width: ?f32, height: ?f32) -> FrameModifier {
FrameModifier.{ child = ViewChild.{ view = view }, width = width, height = height };
}
background :: (view: View, color: Color, corner_radius: f32) -> BackgroundModifier {
BackgroundModifier.{ child = ViewChild.{ view = view }, color = color, corner_radius = corner_radius };
}
with_opacity :: (view: View, alpha: f32) -> OpacityModifier {
OpacityModifier.{ child = ViewChild.{ view = view }, alpha = alpha };
}
clip :: (view: View, corner_radius: f32) -> ClipModifier {
ClipModifier.{ child = ViewChild.{ view = view }, corner_radius = corner_radius };
}
hidden :: (view: View, is_hidden: bool) -> HiddenModifier {
HiddenModifier.{ child = ViewChild.{ view = view }, is_hidden = is_hidden };
}
on_tap :: (view: View, handler: Closure()) -> TapGestureModifier {
TapGestureModifier.{
child = ViewChild.{ view = view },
gesture = TapGesture.{ count = 1, on_tap = handler }
};
}
on_drag :: (view: View, on_changed: ?Closure(DragValue), on_ended: ?Closure(DragValue)) -> DragGestureModifier {
DragGestureModifier.{
child = ViewChild.{ view = view },
gesture = DragGesture.{ min_distance = 10.0, on_changed = on_changed, on_ended = on_ended }
};
}

View File

@@ -9,6 +9,7 @@
UIPipeline :: struct {
renderer: UIRenderer;
render_tree: RenderTree;
font: FontAtlas;
screen_width: f32;
screen_height: f32;
root: ViewChild;
@@ -22,8 +23,14 @@ UIPipeline :: struct {
self.has_root = false;
}
init_font :: (self: *UIPipeline, path: [:0]u8, size: f32) {
self.font.init(path, size);
upload_font_texture(@self.font);
set_global_font(@self.font);
}
set_root :: (self: *UIPipeline, view: View) {
self.root = ViewChild.make(view);
self.root = ViewChild.{ view = view };
self.has_root = true;
}
@@ -51,6 +58,7 @@ UIPipeline :: struct {
origin = Point.zero(),
size = root_size
};
print("tick: computed_frame=({},{},{},{})\n", self.root.computed_frame.origin.x, self.root.computed_frame.origin.y, self.root.computed_frame.size.width, self.root.computed_frame.size.height);
self.root.view.layout(self.root.computed_frame);
// Render to tree

View File

@@ -1,4 +1,5 @@
#import "modules/std.sx";
#import "modules/compiler.sx";
#import "modules/opengl.sx";
#import "modules/math";
#import "ui/types.sx";
@@ -14,17 +15,23 @@ UIRenderer :: struct {
vbo: u32;
shader: u32;
proj_loc: s32;
tex_loc: s32;
vertices: [*]f32;
vertex_count: s64;
screen_width: f32;
screen_height: f32;
white_texture: u32;
current_texture: u32;
init :: (self: *UIRenderer) {
// Create shader
self.shader = create_program(UI_VERT_SRC, UI_FRAG_SRC);
// 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;
@@ -62,6 +69,14 @@ UIRenderer :: struct {
self.screen_width = width;
self.screen_height = height;
self.vertex_count = 0;
self.current_texture = self.white_texture;
}
bind_texture :: (self: *UIRenderer, tex: u32) {
if tex != self.current_texture {
self.flush();
self.current_texture = tex;
}
}
// Emit a quad (2 triangles = 6 vertices)
@@ -122,13 +137,14 @@ UIRenderer :: struct {
self.push_quad(node.frame, node.fill_color, node.corner_radius, node.stroke_width);
}
case .text: {
// TODO: text rendering via glyph atlas
// For now, render a placeholder rect
self.push_quad(node.frame, node.text_color, 0.0, 0.0);
if xx g_font != 0 and g_font.char_data != null {
self.render_text(node);
}
}
case .image: {
// TODO: textured quad
self.bind_texture(node.texture_id);
self.push_quad(node.frame, COLOR_WHITE, 0.0, 0.0);
self.bind_texture(self.white_texture);
}
case .clip_push: {
self.flush();
@@ -160,6 +176,11 @@ UIRenderer :: struct {
proj := Mat4.ortho(0.0, self.screen_width, self.screen_height, 0.0, -1.0, 1.0);
glUniformMatrix4fv(self.proj_loc, 1, 0, proj.data);
// Bind current texture
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, self.current_texture);
glUniform1i(self.tex_loc, 0);
glBindVertexArray(self.vao);
glBindBuffer(GL_ARRAY_BUFFER, self.vbo);
@@ -171,39 +192,66 @@ UIRenderer :: struct {
glBindVertexArray(0);
self.vertex_count = 0;
}
render_text :: (self: *UIRenderer, node: RenderNode) {
font := g_font;
scale := node.font_size / font.font_size;
self.bind_texture(font.texture_id);
r := node.text_color.rf();
g := node.text_color.gf();
b := node.text_color.bf();
a := node.text_color.af();
// stbtt_GetBakedQuad works at baked size; we scale output positions
xpos : f32 = 0.0;
ypos : f32 = 0.0;
q : AlignedQuad = ---;
i : s64 = 0;
while i < node.text.len {
ch : s32 = xx node.text[i];
if ch >= FIRST_CHAR and ch < FIRST_CHAR + NUM_CHARS {
stbtt_GetBakedQuad(xx font.char_data, ATLAS_W, ATLAS_H, ch - FIRST_CHAR, @xpos, @ypos, xx @q, 1);
// Scale and offset to frame position
// ypos=0 means baseline is at y=0; glyphs go above (negative yoff)
// Add ascent so top of text aligns with frame top
gx0 := node.frame.origin.x + q.x0 * scale;
gy0 := node.frame.origin.y + font.ascent * scale + q.y0 * scale;
gx1 := node.frame.origin.x + q.x1 * scale;
gy1 := node.frame.origin.y + font.ascent * scale + q.y1 * scale;
if self.vertex_count + 6 > MAX_UI_VERTICES {
self.flush();
}
// corner_radius = -1.0 signals "text mode" to the fragment shader
self.write_vertex(gx0, gy0, q.s0, q.t0, r, g, b, a, 0.0 - 1.0, 0.0, 0.0, 0.0);
self.write_vertex(gx1, gy0, q.s1, q.t0, r, g, b, a, 0.0 - 1.0, 0.0, 0.0, 0.0);
self.write_vertex(gx0, gy1, q.s0, q.t1, r, g, b, a, 0.0 - 1.0, 0.0, 0.0, 0.0);
self.write_vertex(gx1, gy0, q.s1, q.t0, r, g, b, a, 0.0 - 1.0, 0.0, 0.0, 0.0);
self.write_vertex(gx1, gy1, q.s1, q.t1, r, g, b, a, 0.0 - 1.0, 0.0, 0.0, 0.0);
self.write_vertex(gx0, gy1, q.s0, q.t1, r, g, b, a, 0.0 - 1.0, 0.0, 0.0, 0.0);
}
i += 1;
}
self.bind_texture(self.white_texture);
}
}
// Additional GL functions needed for UI renderer
GL_SCISSOR_TEST :u32: 0x0C11;
GL_DYNAMIC_DRAW :u32: 0x88E8;
GL_TEXTURE_2D :u32: 0x0DE1;
GL_TEXTURE_MIN_FILTER :u32: 0x2801;
GL_TEXTURE_MAG_FILTER :u32: 0x2800;
GL_NEAREST :u32: 0x2600;
GL_RGBA :u32: 0x1908;
GL_UNSIGNED_BYTE :u32: 0x1401;
GL_SRC_ALPHA :u32: 0x0302;
GL_ONE_MINUS_SRC_ALPHA :u32: 0x0303;
glScissor : (s32, s32, s32, s32) -> void = ---;
glBufferSubData : (u32, s64, s64, *void) -> void = ---;
glGenTextures : (s32, *u32) -> void = ---;
glBindTexture : (u32, u32) -> void = ---;
glTexImage2D : (u32, s32, s32, s32, s32, s32, u32, u32, *void) -> void = ---;
glTexParameteri : (u32, u32, s32) -> void = ---;
glBlendFunc : (u32, u32) -> void = ---;
glReadPixels : (s32, s32, s32, s32, u32, u32, *void) -> void = ---;
// Load additional GL functions (call after load_gl)
load_gl_ui :: (get_proc: ([:0]u8) -> *void) {
glScissor = xx get_proc("glScissor");
glBufferSubData = xx get_proc("glBufferSubData");
glGenTextures = xx get_proc("glGenTextures");
glBindTexture = xx get_proc("glBindTexture");
glTexImage2D = xx get_proc("glTexImage2D");
glTexParameteri = xx get_proc("glTexParameteri");
glBlendFunc = xx get_proc("glBlendFunc");
glReadPixels = xx get_proc("glReadPixels");
// Upload font atlas bitmap as GL texture (called after GL init)
upload_font_texture :: (font: *FontAtlas) {
if font.bitmap == null { return; }
glGenTextures(1, @font.texture_id);
glBindTexture(GL_TEXTURE_2D, font.texture_id);
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
glTexImage2D(GL_TEXTURE_2D, 0, xx GL_R8, ATLAS_W, ATLAS_H, 0, GL_RED, GL_UNSIGNED_BYTE, font.bitmap);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, xx GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, xx GL_LINEAR);
context.allocator.dealloc(font.bitmap);
font.bitmap = null;
}
create_white_texture :: () -> u32 {
@@ -219,7 +267,9 @@ create_white_texture :: () -> u32 {
// --- UI Shaders ---
UI_VERT_SRC :: #string GLSL
// --- 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;
@@ -240,11 +290,13 @@ void main() {
}
GLSL;
UI_FRAG_SRC :: #string GLSL
UI_FRAG_SRC_CORE :: #string GLSL
#version 330 core
in vec2 vUV;
in vec4 vColor;
in vec4 vParams; // corner_radius, border_width, rect_w, rect_h
in vec4 vParams;
uniform sampler2D uTex;
out vec4 FragColor;
@@ -258,7 +310,11 @@ void main() {
float border = vParams.y;
vec2 rectSize = vParams.zw;
if (radius > 0.0 || border > 0.0) {
if (radius < 0.0) {
float textAlpha = texture(uTex, vUV).r;
FragColor = vec4(vColor.rgb, vColor.a * textAlpha);
} else if (radius > 0.0 || border > 0.0) {
vec4 texColor = texture(uTex, vUV);
vec2 half_size = rectSize * 0.5;
vec2 center = (vUV - vec2(0.5)) * rectSize;
float dist = roundedBoxSDF(center, half_size, radius);
@@ -271,9 +327,80 @@ void main() {
alpha = alpha * max(border_alpha, 0.0);
}
FragColor = vec4(vColor.rgb, vColor.a * alpha);
FragColor = vec4(texColor.rgb * vColor.rgb, texColor.a * vColor.a * alpha);
} else {
FragColor = vColor;
vec4 texColor = texture(uTex, vUV);
FragColor = texColor * 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 radius = vParams.x;
float border = vParams.y;
vec2 rectSize = vParams.zw;
if (radius < 0.0) {
float textAlpha = texture(uTex, vUV).r;
FragColor = vec4(vColor.rgb, vColor.a * textAlpha);
} else if (radius > 0.0 || border > 0.0) {
vec4 texColor = texture(uTex, vUV);
vec2 half_size = rectSize * 0.5;
vec2 center = (vUV - vec2(0.5)) * rectSize;
float dist = roundedBoxSDF(center, half_size, radius);
float aa = fwidth(dist);
float alpha = 1.0 - smoothstep(-aa, aa, dist);
if (border > 0.0) {
float inner = roundedBoxSDF(center, half_size - vec2(border), max(radius - border, 0.0));
float border_alpha = smoothstep(-aa, aa, inner);
alpha = alpha * max(border_alpha, 0.0);
}
FragColor = vec4(texColor.rgb * vColor.rgb, texColor.a * vColor.a * alpha);
} else {
vec4 texColor = texture(uTex, vUV);
FragColor = texColor * vColor;
}
}
GLSL;

148
ui/scroll_view.sx Normal file
View File

@@ -0,0 +1,148 @@
#import "modules/std.sx";
#import "modules/math";
#import "ui/types.sx";
#import "ui/render.sx";
#import "ui/events.sx";
#import "ui/view.sx";
ScrollAxes :: enum { vertical; horizontal; both; }
ScrollView :: struct {
child: ViewChild;
offset: Point;
content_size: Size;
viewport_size: Size;
axes: ScrollAxes;
dragging: bool;
drag_pending: bool;
drag_start: Point;
drag_offset: Point;
SCROLL_SPEED :f32: 20.0;
DRAG_THRESHOLD :f32: 4.0;
clamp_offset :: (self: *ScrollView) {
max_x := max(0.0, self.content_size.width - self.viewport_size.width);
max_y := max(0.0, self.content_size.height - self.viewport_size.height);
if self.axes == .vertical or self.axes == .both {
self.offset.y = clamp(self.offset.y, 0.0, max_y);
} else {
self.offset.y = 0.0;
}
if self.axes == .horizontal or self.axes == .both {
self.offset.x = clamp(self.offset.x, 0.0, max_x);
} else {
self.offset.x = 0.0;
}
}
}
impl View for ScrollView {
size_that_fits :: (self: *ScrollView, proposal: ProposedSize) -> Size {
// ScrollView takes all proposed space
Size.{
width = proposal.width ?? 0.0,
height = proposal.height ?? 0.0
};
}
layout :: (self: *ScrollView, bounds: Frame) {
self.viewport_size = bounds.size;
// Measure child with infinite space on scroll axes
child_proposal := ProposedSize.{
width = if self.axes == .horizontal or self.axes == .both then null else bounds.size.width,
height = if self.axes == .vertical or self.axes == .both then null else bounds.size.height
};
self.content_size = self.child.view.size_that_fits(child_proposal);
self.clamp_offset();
// Layout child offset by scroll position
self.child.computed_frame = Frame.make(
bounds.origin.x - self.offset.x,
bounds.origin.y - self.offset.y,
self.content_size.width,
self.content_size.height
);
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 {
if pos := event_position(event) {
print(" ScrollView.handle_event: pos=({},{}) frame=({},{},{},{})\n", pos.x, pos.y, frame.origin.x, frame.origin.y, frame.size.width, frame.size.height);
if !frame.contains(pos) { print(" -> outside frame\n"); return false; }
}
if event.* == {
case .mouse_wheel: (d) {
if self.axes == .vertical or self.axes == .both {
self.offset.y -= d.delta.y * ScrollView.SCROLL_SPEED;
}
if self.axes == .horizontal or self.axes == .both {
self.offset.x -= d.delta.x * ScrollView.SCROLL_SPEED;
}
self.clamp_offset();
return true;
}
case .mouse_down: (d) {
// Always record drag start (like Zig preHandleEvent)
self.drag_pending = true;
self.drag_start = d.position;
self.drag_offset = self.offset;
// Forward to children — let buttons/tappables handle too
self.child.view.handle_event(event, self.child.computed_frame);
return true;
}
case .mouse_moved: (d) {
// Activate drag once movement exceeds threshold
if self.drag_pending and !self.dragging {
dx := d.position.x - self.drag_start.x;
dy := d.position.y - self.drag_start.y;
dist := sqrt(dx * dx + dy * dy);
if dist >= ScrollView.DRAG_THRESHOLD {
self.dragging = true;
self.drag_pending = false;
// Cancel child press state — position far outside so on_tap won't fire
cancel :Event = .mouse_up(MouseButtonData.{
position = Point.{ x = 0.0 - 10000.0, y = 0.0 - 10000.0 },
button = .none
});
self.child.view.handle_event(@cancel, self.child.computed_frame);
}
}
if self.dragging {
if self.axes == .vertical or self.axes == .both {
self.offset.y = self.drag_offset.y - (d.position.y - self.drag_start.y);
}
if self.axes == .horizontal or self.axes == .both {
self.offset.x = self.drag_offset.x - (d.position.x - self.drag_start.x);
}
self.clamp_offset();
return true;
}
// Forward mouse_moved to children (for hover effects)
return self.child.view.handle_event(event, self.child.computed_frame);
}
case .mouse_up: {
was_dragging := self.dragging;
self.dragging = false;
self.drag_pending = false;
if was_dragging {
return true;
}
// Forward to children (for tap completion)
return self.child.view.handle_event(event, self.child.computed_frame);
}
}
// Forward other events to child
self.child.view.handle_event(event, self.child.computed_frame);
}
}

View File

@@ -12,7 +12,7 @@ VStack :: struct {
alignment: HAlignment;
add :: (self: *VStack, view: View) {
self.children.append(ViewChild.make(view));
self.children.append(ViewChild.{ view = view });
}
}
@@ -54,7 +54,7 @@ HStack :: struct {
alignment: VAlignment;
add :: (self: *HStack, view: View) {
self.children.append(ViewChild.make(view));
self.children.append(ViewChild.{ view = view });
}
}
@@ -94,7 +94,7 @@ ZStack :: struct {
alignment: Alignment;
add :: (self: *ZStack, view: View) {
self.children.append(ViewChild.make(view));
self.children.append(ViewChild.{ view = view });
}
}

59
ui/state.sx Normal file
View File

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

View File

@@ -108,6 +108,15 @@ Color :: struct {
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

View File

@@ -19,12 +19,5 @@ View :: protocol {
// A child view with its computed frame (set during layout)
ViewChild :: struct {
view: View;
computed_frame: Frame;
make :: (view: View) -> ViewChild {
ViewChild.{
view = view,
computed_frame = Frame.zero()
};
}
computed_frame: Frame = .zero();
}