...
3
.gitignore
vendored
@@ -1,2 +1,3 @@
|
||||
modules/
|
||||
.sx-cache
|
||||
.sx-cache
|
||||
sx-out/
|
||||
BIN
assets/fonts/default.ttf
Normal file
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 25 KiB |
BIN
goldens/test_after_drag.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
253
main.sx
@@ -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();
|
||||
|
||||
108
ui/animation.sx
Normal 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;
|
||||
}
|
||||
}
|
||||
33
ui/button.sx
@@ -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();
|
||||
}
|
||||
|
||||
111
ui/events.sx
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
10
ui/label.sx
@@ -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
@@ -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 }
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
215
ui/renderer.sx
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
[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);
|
||||
}
|
||||
```
|
||||
BIN
vendors/kb_text_shape/kb/images/arabic.png
vendored
Normal file
|
After Width: | Height: | Size: 9.1 KiB |
BIN
vendors/kb_text_shape/kb/images/gunjala_gondi.png
vendored
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
vendors/kb_text_shape/kb/images/hindi.png
vendored
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
vendors/kb_text_shape/kb/images/khmer.png
vendored
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
vendors/kb_text_shape/kb/images/myanmar.png
vendored
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
BIN
vendors/kb_text_shape/kb/images/smallcaps.png
vendored
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
30737
vendors/kb_text_shape/kb/kb_text_shape.h
vendored
Normal file
2
vendors/kb_text_shape/kb_text_shape_impl.c
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
#define KB_TEXT_SHAPE_IMPLEMENTATION
|
||||
#include "kb/kb_text_shape.h"
|
||||