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

3
.gitignore vendored
View File

@@ -1,2 +1,3 @@
modules/
.sx-cache
.sx-cache
sx-out/

BIN
assets/fonts/default.ttf Normal file

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 25 KiB

BIN
goldens/test_after_drag.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

253
main.sx
View File

@@ -1,13 +1,65 @@
#import "modules/std.sx";
#import "modules/compiler.sx";
#import "modules/sdl3.sx";
#import "modules/opengl.sx";
#import "modules/math";
#import "modules/stb.sx";
#import "modules/stb_truetype.sx";
#import "ui";
configure_build :: () {
opts := build_options();
inline if OS == {
case .wasm: {
inline if POINTER_SIZE == 4 {
opts.set_output_path("sx-out/wasm32/game.html");
} else {
opts.set_output_path("sx-out/wasm64/game.html");
}
opts.add_link_flag("-sUSE_SDL=3");
opts.add_link_flag("-sMAX_WEBGL_VERSION=2");
opts.add_link_flag("-sFULL_ES3=1");
opts.add_link_flag("--preload-file assets");
opts.add_link_flag("-sALLOW_MEMORY_GROWTH=1");
}
case .macos: {
opts.set_output_path("sx-out/macos/game");
}
}
}
#run configure_build();
libc :: #library "c";
emscripten_set_main_loop :: (func: *void, fps: s32, sim_infinite: s32) #foreign libc;
WIDTH :f32: 800;
HEIGHT :f32: 600;
// --- Frame state (globals for emscripten callback) ---
g_window : *void = ---;
g_pipeline : *UIPipeline = ---;
g_running : bool = true;
load_texture :: (path: [:0]u8) -> u32 {
w : s32 = 0;
h : s32 = 0;
ch : s32 = 0;
pixels := stbi_load(path, @w, @h, @ch, 4);
if xx pixels == 0 { return 0; }
tex : u32 = 0;
glGenTextures(1, @tex);
glBindTexture(GL_TEXTURE_2D, tex);
glTexImage2D(GL_TEXTURE_2D, 0, xx GL_RGBA, w, h, 0, GL_RGBA, GL_UNSIGNED_BYTE, pixels);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, xx GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, xx GL_LINEAR);
stbi_image_free(pixels);
tex;
}
save_snapshot :: (path: [:0]u8, w: s32, h: s32) {
stride : s64 = w * 4;
buf_size : s64 = stride * h;
@@ -32,13 +84,114 @@ save_snapshot :: (path: [:0]u8, w: s32, h: s32) {
out("\n");
}
run_ui_tests :: (pipeline: *UIPipeline) {
// Do a layout pass first so frames are computed
glClearColor(0.12, 0.12, 0.15, 1.0);
glClear(GL_COLOR_BUFFER_BIT);
pipeline.tick();
// Button "Click Me" is roughly at center x=400, y=165 based on golden
btn_x : f32 = 400.0;
btn_y : f32 = 165.0;
btn_pos := Point.{ x = btn_x, y = btn_y };
// Empty area below the rects
empty_x : f32 = 400.0;
empty_y : f32 = 500.0;
// --- Test 1: Click button (no drag) ---
out("=== Test 1: button click ===\n");
out("expect: Button tapped!\n");
e :Event = .mouse_down(MouseButtonData.{ position = btn_pos, button = .left });
pipeline.dispatch_event(@e);
e = .mouse_up(MouseButtonData.{ position = btn_pos, button = .left });
pipeline.dispatch_event(@e);
out("=== end test 1 ===\n\n");
// --- Test 2: Drag from button area (should scroll, NOT tap) ---
out("=== Test 2: drag from button ===\n");
out("expect: NO Button tapped, scroll should move\n");
e = .mouse_down(MouseButtonData.{ position = btn_pos, button = .left });
pipeline.dispatch_event(@e);
// Move down 50px in steps (exceeds 4px threshold)
i : s32 = 1;
while i <= 10 {
e = .mouse_moved(MouseMotionData.{
position = Point.{ x = btn_x, y = btn_y + xx i * 5.0 },
delta = Point.{ x = 0.0, y = 5.0 }
});
pipeline.dispatch_event(@e);
i += 1;
}
e = .mouse_up(MouseButtonData.{ position = Point.{ x = btn_x, y = btn_y + 50.0 }, button = .left });
pipeline.dispatch_event(@e);
out("=== end test 2 ===\n\n");
// --- Test 3: Drag from empty area (should scroll) ---
out("=== Test 3: drag from empty area ===\n");
out("expect: scroll should move\n");
e = .mouse_down(MouseButtonData.{ position = Point.{ x = empty_x, y = empty_y }, button = .left });
pipeline.dispatch_event(@e);
i = 1;
while i <= 10 {
e = .mouse_moved(MouseMotionData.{
position = Point.{ x = empty_x, y = empty_y - xx i * 5.0 },
delta = Point.{ x = 0.0, y = 0.0 - 5.0 }
});
pipeline.dispatch_event(@e);
i += 1;
}
e = .mouse_up(MouseButtonData.{ position = Point.{ x = empty_x, y = empty_y - 50.0 }, button = .left });
pipeline.dispatch_event(@e);
out("=== end test 3 ===\n\n");
// Render after tests and save snapshot to see scrolled state
glClear(GL_COLOR_BUFFER_BIT);
pipeline.tick();
save_snapshot("goldens/test_after_drag.png", xx WIDTH, xx HEIGHT);
}
// One frame of the main loop — called repeatedly by emscripten or desktop while-loop
frame :: () {
sdl_event : SDL_Event = .none;
while SDL_PollEvent(sdl_event) {
print("SDL event: {}\n", sdl_event.tag);
if sdl_event == {
case .quit: { g_running = false; }
case .key_up: (e) {
if e.key == { case .escape: { g_running = false; } }
}
}
ui_event := translate_sdl_event(@sdl_event);
if ui_event != .none {
print(" ui event dispatched\n");
g_pipeline.*.dispatch_event(@ui_event);
} else {
print(" -> .none\n");
}
}
glClearColor(0.12, 0.12, 0.15, 1.0);
glClear(GL_COLOR_BUFFER_BIT);
g_pipeline.*.tick();
SDL_GL_SwapWindow(g_window);
}
main :: () -> void {
SDL_Init(SDL_INIT_VIDEO);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 3);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_CORE);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_FLAGS, SDL_GL_CONTEXT_FORWARD_COMPATIBLE_FLAG);
inline if OS == .wasm {
SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_ES);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 0);
} else {
SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_CORE);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 3);
}
SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1);
SDL_GL_SetAttribute(SDL_GL_DEPTH_SIZE, 24);
@@ -47,73 +200,55 @@ main :: () -> void {
SDL_GL_MakeCurrent(window, gl_ctx);
SDL_GL_SetSwapInterval(1);
load_gl(SDL_GL_GetProcAddress);
load_gl_ui(SDL_GL_GetProcAddress);
load_gl(xx SDL_GL_GetProcAddress);
// --- Build UI ---
pipeline : UIPipeline = ---;
pipeline.init(WIDTH, HEIGHT);
pipeline.init_font("assets/fonts/default.ttf", 32.0);
// Create a simple layout: VStack with colored rects and a button
root := VStack.{ spacing = 10.0, alignment = .center };
scroll_content := VStack.{ spacing = 10.0, alignment = .center } {
self.add(
Label.{ text = "Hello, SX!", font_size = 24.0, color = COLOR_WHITE }
|> padding(EdgeInsets.all(8.0))
);
self.add(
RectView.{ color = COLOR_YELLOW, preferred_height = 80.0, corner_radius = 8.0 }
|> padding(EdgeInsets.all(8.0))
|> on_tap(closure(() { out("Yellow tapped!\n"); }))
);
self.add(
Button.{ label = "Click Me", font_size = 14.0, style = ButtonStyle.default(), on_tap = closure(() { out("Button tapped!\n"); }) }
);
self.add(HStack.{ spacing = 10.0, alignment = .center } {
self.add(RectView.{ color = COLOR_RED, preferred_width = 200.0, preferred_height = 300.0, corner_radius = 4.0 });
self.add(RectView.{ color = COLOR_GREEN, preferred_width = 200.0, preferred_height = 300.0, corner_radius = 4.0 });
});
self.add(
RectView.{ color = COLOR_DARK_GRAY, preferred_height = 60.0 }
|> padding(EdgeInsets.symmetric(16.0, 8.0))
|> background(COLOR_BLUE, 8.0)
);
self.add(RectView.{ color = COLOR_ORANGE, preferred_height = 120.0, corner_radius = 12.0 });
self.add(RectView.{ color = COLOR_GRAY, preferred_height = 200.0, corner_radius = 8.0 });
};
header := RectView.{ color = COLOR_YELLOW, preferred_height = 80.0, corner_radius = 8.0 };
root.add(xx header);
root := ScrollView.{ child = ViewChild.{ view = scroll_content }, axes = .vertical };
pipeline.set_root(root);
btn := Button.{ label = "Click Me", font_size = 14.0, style = ButtonStyle.default(), on_tap = null };
root.add(xx btn);
body := HStack.{ spacing = 10.0, alignment = .center };
left := RectView.{ color = COLOR_RED, preferred_width = 200.0, preferred_height = 300.0, corner_radius = 4.0 };
body.add(xx left);
right := RectView.{ color = COLOR_GREEN, preferred_width = 200.0, preferred_height = 300.0, corner_radius = 4.0 };
body.add(xx right);
root.add(xx body);
footer := RectView.{ color = COLOR_DARK_GRAY, preferred_height = 60.0 };
root.add(xx footer);
pipeline.set_root(xx root);
// Store state in globals for frame callback
g_window = xx window;
g_pipeline = @pipeline;
// --- Main loop ---
running := true;
sdl_event : SDL_Event = .none;
while running {
while SDL_PollEvent(sdl_event) {
if sdl_event == {
case .quit: running = false;
case .key_up: (e) {
if e.key == {
case .escape: running = false;
}
}
}
// Forward to UI
ui_event := translate_sdl_event(@sdl_event);
if ui_event.type != .none {
pipeline.dispatch_event(@ui_event);
}
inline if OS == .wasm {
emscripten_set_main_loop(xx frame, 0, 1);
} else {
while g_running {
frame();
}
glClearColor(0.12, 0.12, 0.15, 1.0);
glClear(GL_COLOR_BUFFER_BIT);
pipeline.tick();
SDL_GL_SwapWindow(window);
}
// Re-render one frame for snapshot (back buffer is stale after swap)
glClearColor(0.12, 0.12, 0.15, 1.0);
glClear(GL_COLOR_BUFFER_BIT);
pipeline.tick();
save_snapshot("goldens/last_frame.png", xx WIDTH, xx HEIGHT);
SDL_GL_DestroyContext(gl_ctx);
SDL_DestroyWindow(window);
SDL_Quit();

1
modules Symbolic link
View File

@@ -0,0 +1 @@
/Users/agra/swipelab/sx/examples/modules

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

14
vendors/file_utils/file_utils.c vendored Normal file
View File

@@ -0,0 +1,14 @@
#include <stdio.h>
#include <stdlib.h>
unsigned char* read_file_bytes(const char* path, int* out_size) {
FILE* f = fopen(path, "rb");
if (!f) return 0;
fseek(f, 0, SEEK_END);
*out_size = (int)ftell(f);
fseek(f, 0, SEEK_SET);
unsigned char* buf = (unsigned char*)malloc(*out_size);
fread(buf, 1, *out_size, f);
fclose(f);
return buf;
}

6
vendors/file_utils/file_utils.h vendored Normal file
View File

@@ -0,0 +1,6 @@
#ifndef FILE_UTILS_H
#define FILE_UTILS_H
unsigned char* read_file_bytes(const char* path, int* out_size);
#endif

19
vendors/kb_text_shape/kb/LICENSE vendored Normal file
View File

@@ -0,0 +1,19 @@
zlib License
(C) Copyright 2024-2025 Jimmy Lefevre
This software is provided 'as-is', without any express or implied
warranty. In no event will the authors be held liable for any damages
arising from the use of this software.
Permission is granted to anyone to use this software for any purpose,
including commercial applications, and to alter it and redistribute it
freely, subject to the following restrictions:
1. The origin of this software must not be misrepresented; you must not
claim that you wrote the original software. If you use this software
in a product, an acknowledgment in the product documentation would be
appreciated but is not required.
2. Altered source versions must be plainly marked as such, and must not be
misrepresented as being the original software.
3. This notice may not be removed or altered from any source distribution.

70
vendors/kb_text_shape/kb/README.md vendored Normal file
View File

@@ -0,0 +1,70 @@
# kb
[Single-header](https://github.com/nothings/stb/blob/master/docs/stb_howto.txt) permissively-licensed libraries for C/C++.
## Libraries
- [kb\_text\_shape.h](./kb_text_shape.h): Unicode text segmentation and OpenType shaping
## kb_text_shape.h
![Example of Arabic shaping with stb_truetype](./images/arabic.png)
![Example of Hindi shaping with stb_truetype](./images/hindi.png)
![Example of Khmer shaping with stb_truetype](./images/khmer.png)
![Example of Myanmar shaping with stb_truetype](./images/myanmar.png)
![Example of Gunjala Gondi shaping with stb_truetype](./images/gunjala_gondi.png)
![Example of toggling the smallcaps font feature](./images/smallcaps.png)
[kb\_text\_shape.h](./kb_text_shape.h) provides:
- ICU-like text segmentation (i.e. breaking Unicode text by direction, line, script, word and grapheme).
- Harfbuzz-like text shaping for OpenType fonts, which means it is capable of handling complex script layout and ligatures, among other things.
- Font coverage checking: know if a font can display a given string.
It does **not** handle rasterization. It does **not** handle paragraph layout. It does **not** handle selection and loading of system fonts. It will only help you know which glyphs to display where on a single, infinitely-long line, using the fonts you have provided!
(See https://www.newroadoldway.com/text1.html for an explanation of the different steps of text processing.)
For an in-depth usage example, check out [refpad](https://github.com/JimmyLefevre/refpad).
```c
// Yours to provide:
void DrawGlyph(kbts_u16 GlyphId, kbts_s32 GlyphOffsetX, kbts_s32 GlyphOffsetY, kbts_s32 GlyphAdvanceX, kbts_s32 GlyphAdvanceY,
kbts_direction ParagraphDirection, kbts_direction RunDirection, kbts_script Script, kbts_font *Font);
void NextLine(void);
void *CreateRenderFont(const char *FontPath);
void HandleText(kbts_shape_context *Context, const char *Text, kbts_language Language)
{
kbts_ShapeBegin(Context, KBTS_DIRECTION_DONT_KNOW, Language);
kbts_ShapeUtf8(Context, Text, (int)strlen(Text), KBTS_USER_ID_GENERATION_MODE_CODEPOINT_INDEX);
kbts_ShapeEnd(Context);
kbts_run Run;
while(kbts_ShapeRun(Context, &Run))
{
if(Run.Flags & KBTS_BREAK_FLAG_LINE_HARD)
{
NextLine();
}
kbts_glyph *Glyph;
while(kbts_GlyphIteratorNext(&Run.Glyphs, &Glyph))
{
DrawGlyph(Glyph->Id, Glyph->OffsetX, Glyph->OffsetY, Glyph->AdvanceX, Glyph->AdvanceY,
Run.ParagraphDirection, Run.Direction, Run.Script, Run.Font);
}
}
}
void Example(void)
{
kbts_shape_context *Context = kbts_CreateShapeContext(0, 0);
kbts_font *FontA = kbts_ShapePushFontFromFile(Context, "NotoSansMyanmar-Regular.ttf", 0);
kbts_font *FontB = kbts_ShapePushFontFromFile(Context, "NotoSansArabic-Regular.ttf", 0);
FontA->UserData = CreateRenderFont("NotoSansMyanmar-Regular.ttf");
FontB->UserData = CreateRenderFont("NotoSansArabic-Regular.ttf");
HandleText(Context, (const char *)u8"یکအမည်မရှိیک", KBTS_LANGUAGE_ARABIC);
}
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

30737
vendors/kb_text_shape/kb/kb_text_shape.h vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,2 @@
#define KB_TEXT_SHAPE_IMPLEMENTATION
#include "kb/kb_text_shape.h"