ui pipeline
This commit is contained in:
94
ui/button.sx
Normal file
94
ui/button.sx
Normal 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
106
ui/events.sx
Normal 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
44
ui/label.sx
Normal 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
152
ui/layout.sx
Normal 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
72
ui/pipeline.sx
Normal 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
180
ui/render.sx
Normal 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
279
ui/renderer.sx
Normal 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
181
ui/stacks.sx
Normal 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
181
ui/types.sx
Normal 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
30
ui/view.sx
Normal 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()
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user