ui pipeline

This commit is contained in:
agra
2026-02-24 19:22:05 +02:00
parent 435afceeb4
commit 6b4d7fbc54
22 changed files with 1388 additions and 1273 deletions

94
ui/button.sx Normal file
View File

@@ -0,0 +1,94 @@
#import "modules/std.sx";
#import "ui/types.sx";
#import "ui/render.sx";
#import "ui/events.sx";
#import "ui/view.sx";
#import "ui/label.sx";
ButtonStyle :: struct {
background: Color;
foreground: Color;
hover_bg: Color;
pressed_bg: Color;
corner_radius: f32;
padding: EdgeInsets;
default :: () -> ButtonStyle {
ButtonStyle.{
background = COLOR_BLUE,
foreground = COLOR_WHITE,
hover_bg = Color.rgb(0, 100, 220),
pressed_bg = Color.rgb(0, 80, 180),
corner_radius = 6.0,
padding = EdgeInsets.symmetric(16.0, 8.0)
};
}
}
Button :: struct {
label: string;
font_size: f32;
style: ButtonStyle;
on_tap: ?Closure();
hovered: bool;
pressed: bool;
}
impl View for Button {
size_that_fits :: (self: *Button, proposal: ProposedSize) -> Size {
scale := self.font_size / GLYPH_HEIGHT_APPROX;
text_w := xx self.label.len * GLYPH_WIDTH_APPROX * scale;
text_h := self.font_size;
Size.{
width = text_w + self.style.padding.horizontal(),
height = text_h + self.style.padding.vertical()
};
}
layout :: (self: *Button, bounds: Frame) {}
render :: (self: *Button, ctx: *RenderContext, frame: Frame) {
bg := if self.pressed then self.style.pressed_bg
else if self.hovered then self.style.hover_bg
else self.style.background;
ctx.add_rounded_rect(frame, bg, self.style.corner_radius);
// Text centered in frame
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);
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);
return false;
}
case .mouse_down: {
if frame.contains(event.position) {
self.pressed = true;
return true;
}
}
case .mouse_up: {
if self.pressed {
self.pressed = false;
if frame.contains(event.position) {
if handler := self.on_tap {
handler();
}
}
return true;
}
}
}
false;
}
}

106
ui/events.sx Normal file
View File

@@ -0,0 +1,106 @@
#import "modules/std.sx";
#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;
middle;
right;
}
Event :: struct {
type: EventType;
position: Point;
delta: Point;
button: MouseButton;
key: u32;
text: string;
timestamp: u64;
make :: (type: EventType) -> Event {
e : Event = ---;
memset(@e, 0, size_of(Event));
e.type = type;
e;
}
}
// Translate SDL_Event → our Event type
translate_sdl_event :: (sdl: *SDL_Event) -> Event {
if sdl.* == {
case .quit: {
return Event.make(.quit);
}
case .key_down: (data) {
e := Event.make(.key_down);
e.key = xx data.key;
e.timestamp = data.timestamp;
return e;
}
case .key_up: (data) {
e := Event.make(.key_up);
e.key = xx data.key;
e.timestamp = data.timestamp;
return e;
}
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;
}
case .mouse_button_down: (data) {
e := Event.make(.mouse_down);
e.position = Point.{ x = data.x, y = data.y };
e.button = if data.button == {
case 1: .left;
case 2: .middle;
case 3: .right;
else: .none;
};
e.timestamp = data.timestamp;
return e;
}
case .mouse_button_up: (data) {
e := Event.make(.mouse_up);
e.position = Point.{ x = data.x, y = data.y };
e.button = if data.button == {
case 1: .left;
case 2: .middle;
case 3: .right;
else: .none;
};
e.timestamp = data.timestamp;
return e;
}
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;
}
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;
}
}
Event.make(.none);
}

44
ui/label.sx Normal file
View File

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

152
ui/layout.sx Normal file
View File

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

72
ui/pipeline.sx Normal file
View File

@@ -0,0 +1,72 @@
#import "modules/std.sx";
#import "modules/opengl.sx";
#import "ui/types.sx";
#import "ui/render.sx";
#import "ui/events.sx";
#import "ui/view.sx";
#import "ui/renderer.sx";
UIPipeline :: struct {
renderer: UIRenderer;
render_tree: RenderTree;
screen_width: f32;
screen_height: f32;
root: ViewChild;
has_root: bool;
init :: (self: *UIPipeline, width: f32, height: f32) {
self.render_tree = RenderTree.init();
self.renderer.init();
self.screen_width = width;
self.screen_height = height;
self.has_root = false;
}
set_root :: (self: *UIPipeline, view: View) {
self.root = ViewChild.make(view);
self.has_root = true;
}
resize :: (self: *UIPipeline, width: f32, height: f32) {
self.screen_width = width;
self.screen_height = height;
}
// Process a single event through the view tree
dispatch_event :: (self: *UIPipeline, event: *Event) -> bool {
if self.has_root == false { return false; }
self.root.view.handle_event(event, self.root.computed_frame);
}
// Run one frame: layout → render → commit
tick :: (self: *UIPipeline) {
if self.has_root == false { return; }
screen := Frame.make(0.0, 0.0, self.screen_width, self.screen_height);
proposal := ProposedSize.fixed(self.screen_width, self.screen_height);
// Layout
root_size := self.root.view.size_that_fits(proposal);
self.root.computed_frame = Frame.{
origin = Point.zero(),
size = root_size
};
self.root.view.layout(self.root.computed_frame);
// Render to tree
self.render_tree.clear();
ctx := RenderContext.init(@self.render_tree);
self.root.view.render(@ctx, self.root.computed_frame);
// Commit to GPU
glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
glDisable(GL_DEPTH_TEST);
self.renderer.begin(self.screen_width, self.screen_height);
self.renderer.process(@self.render_tree);
self.renderer.flush();
glDisable(GL_BLEND);
}
}

180
ui/render.sx Normal file
View File

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

279
ui/renderer.sx Normal file
View File

@@ -0,0 +1,279 @@
#import "modules/std.sx";
#import "modules/opengl.sx";
#import "modules/math";
#import "ui/types.sx";
#import "ui/render.sx";
// Vertex: pos(2) + uv(2) + color(4) + params(4) = 12 floats
UI_VERTEX_FLOATS :s64: 12;
UI_VERTEX_BYTES :s64: 48;
MAX_UI_VERTICES :s64: 16384;
UIRenderer :: struct {
vao: u32;
vbo: u32;
shader: u32;
proj_loc: s32;
vertices: [*]f32;
vertex_count: s64;
screen_width: f32;
screen_height: f32;
white_texture: u32;
init :: (self: *UIRenderer) {
// Create shader
self.shader = create_program(UI_VERT_SRC, UI_FRAG_SRC);
self.proj_loc = glGetUniformLocation(self.shader, "uProj");
// Allocate vertex buffer (CPU side)
buf_size := MAX_UI_VERTICES * UI_VERTEX_BYTES;
self.vertices = xx context.allocator.alloc(buf_size);
memset(self.vertices, 0, buf_size);
self.vertex_count = 0;
// Create VAO/VBO
glGenVertexArrays(1, @self.vao);
glGenBuffers(1, @self.vbo);
glBindVertexArray(self.vao);
glBindBuffer(GL_ARRAY_BUFFER, self.vbo);
glBufferData(GL_ARRAY_BUFFER, xx buf_size, null, GL_DYNAMIC_DRAW);
// pos (2 floats)
glVertexAttribPointer(0, 2, GL_FLOAT, 0, xx UI_VERTEX_BYTES, xx 0);
glEnableVertexAttribArray(0);
// uv (2 floats)
glVertexAttribPointer(1, 2, GL_FLOAT, 0, xx UI_VERTEX_BYTES, xx 8);
glEnableVertexAttribArray(1);
// color (4 floats)
glVertexAttribPointer(2, 4, GL_FLOAT, 0, xx UI_VERTEX_BYTES, xx 16);
glEnableVertexAttribArray(2);
// params: corner_radius, border_width, rect_w, rect_h
glVertexAttribPointer(3, 4, GL_FLOAT, 0, xx UI_VERTEX_BYTES, xx 32);
glEnableVertexAttribArray(3);
glBindVertexArray(0);
// 1x1 white texture for solid rects
self.white_texture = create_white_texture();
}
begin :: (self: *UIRenderer, width: f32, height: f32) {
self.screen_width = width;
self.screen_height = height;
self.vertex_count = 0;
}
// Emit a quad (2 triangles = 6 vertices)
push_quad :: (self: *UIRenderer, frame: Frame, color: Color, radius: f32, border_w: f32) {
if self.vertex_count + 6 > MAX_UI_VERTICES {
self.flush();
}
x0 := frame.origin.x;
y0 := frame.origin.y;
x1 := x0 + frame.size.width;
y1 := y0 + frame.size.height;
r := color.rf();
g := color.gf();
b := color.bf();
a := color.af();
w := frame.size.width;
h := frame.size.height;
// 6 vertices for quad: TL, TR, BL, TR, BR, BL
self.write_vertex(x0, y0, 0.0, 0.0, r, g, b, a, radius, border_w, w, h);
self.write_vertex(x1, y0, 1.0, 0.0, r, g, b, a, radius, border_w, w, h);
self.write_vertex(x0, y1, 0.0, 1.0, r, g, b, a, radius, border_w, w, h);
self.write_vertex(x1, y0, 1.0, 0.0, r, g, b, a, radius, border_w, w, h);
self.write_vertex(x1, y1, 1.0, 1.0, r, g, b, a, radius, border_w, w, h);
self.write_vertex(x0, y1, 0.0, 1.0, r, g, b, a, radius, border_w, w, h);
}
write_vertex :: (self: *UIRenderer, x: f32, y: f32, u: f32, v: f32, r: f32, g: f32, b: f32, a: f32, cr: f32, bw: f32, rw: f32, rh: f32) {
off := self.vertex_count * UI_VERTEX_FLOATS;
self.vertices[off + 0] = x;
self.vertices[off + 1] = y;
self.vertices[off + 2] = u;
self.vertices[off + 3] = v;
self.vertices[off + 4] = r;
self.vertices[off + 5] = g;
self.vertices[off + 6] = b;
self.vertices[off + 7] = a;
self.vertices[off + 8] = cr;
self.vertices[off + 9] = bw;
self.vertices[off + 10] = rw;
self.vertices[off + 11] = rh;
self.vertex_count += 1;
}
// Walk the render tree and emit quads
process :: (self: *UIRenderer, tree: *RenderTree) {
i := 0;
while i < tree.nodes.len {
node := tree.nodes.items[i];
if node.type == {
case .rect: {
self.push_quad(node.frame, node.fill_color, 0.0, 0.0);
}
case .rounded_rect: {
self.push_quad(node.frame, node.fill_color, node.corner_radius, node.stroke_width);
}
case .text: {
// TODO: text rendering via glyph atlas
// For now, render a placeholder rect
self.push_quad(node.frame, node.text_color, 0.0, 0.0);
}
case .image: {
// TODO: textured quad
self.push_quad(node.frame, COLOR_WHITE, 0.0, 0.0);
}
case .clip_push: {
self.flush();
glEnable(GL_SCISSOR_TEST);
glScissor(
xx node.frame.origin.x,
xx (self.screen_height - node.frame.origin.y - node.frame.size.height),
xx node.frame.size.width,
xx node.frame.size.height
);
}
case .clip_pop: {
self.flush();
glDisable(GL_SCISSOR_TEST);
}
case .opacity_push: {}
case .opacity_pop: {}
}
i += 1;
}
}
flush :: (self: *UIRenderer) {
if self.vertex_count == 0 { return; }
glUseProgram(self.shader);
// Orthographic projection: (0,0) top-left, (w,h) bottom-right
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);
glBindVertexArray(self.vao);
glBindBuffer(GL_ARRAY_BUFFER, self.vbo);
upload_size : s64 = self.vertex_count * UI_VERTEX_BYTES;
glBufferSubData(GL_ARRAY_BUFFER, 0, xx upload_size, self.vertices);
glDrawArrays(GL_TRIANGLES, 0, xx self.vertex_count);
glBindVertexArray(0);
self.vertex_count = 0;
}
}
// 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");
}
create_white_texture :: () -> u32 {
tex : u32 = 0;
glGenTextures(1, @tex);
glBindTexture(GL_TEXTURE_2D, tex);
pixel : [4]u8 = .[255, 255, 255, 255];
glTexImage2D(GL_TEXTURE_2D, 0, xx GL_RGBA, 1, 1, 0, GL_RGBA, GL_UNSIGNED_BYTE, @pixel);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, xx GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, xx GL_NEAREST);
tex;
}
// --- UI Shaders ---
UI_VERT_SRC :: #string GLSL
#version 330 core
layout(location = 0) in vec2 aPos;
layout(location = 1) in vec2 aUV;
layout(location = 2) in vec4 aColor;
layout(location = 3) in vec4 aParams;
uniform mat4 uProj;
out vec2 vUV;
out vec4 vColor;
out vec4 vParams;
void main() {
gl_Position = uProj * vec4(aPos, 0.0, 1.0);
vUV = aUV;
vColor = aColor;
vParams = aParams;
}
GLSL;
UI_FRAG_SRC :: #string GLSL
#version 330 core
in vec2 vUV;
in vec4 vColor;
in vec4 vParams; // corner_radius, border_width, rect_w, rect_h
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 || border > 0.0) {
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(vColor.rgb, vColor.a * alpha);
} else {
FragColor = vColor;
}
}
GLSL;

181
ui/stacks.sx Normal file
View File

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

181
ui/types.sx Normal file
View File

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

30
ui/view.sx Normal file
View File

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