Import Oxide engine (Stages 0–10) under MIT license

Full project snapshot migrated to new Gitea remote without history:
engine, editor, physics, script, examples, tests, docs, and assets.
Relicensed from GPLv3 to MIT and updated repo URLs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Homer Simpson
2026-07-05 20:41:02 +02:00
parent 2afb56b329
commit 9eead719b0
157 changed files with 47270 additions and 2 deletions
+763
View File
@@ -0,0 +1,763 @@
//! Layout algorithm — turns a [`Widget`] tree into resolved screen rects.
//!
//! [`layout`] is a single recursive top-down pass that mixes a one-shot
//! intrinsic-size measurement (for `FitContent` and `Grow` accounting) with
//! the actual placement. The resulting [`LayoutTree`] is a flat `Vec` of
//! [`LayoutNode`]s; each node records its own `rect`, `content_rect`
//! (padding-inset), and the indices of its direct children. The layout
//! function itself has no GPU, no input, no allocation outside the result —
//! every test in this stage runs headlessly.
//!
//! # Slot vs rect, and why anchor children skip resizing
//!
//! The recursion uses two entry points:
//!
//! - [`arrange_in_slot`] is for stack / grid children and the root: the slot
//! is the **outer space** the widget can occupy; the algorithm applies the
//! widget's margin, sizing, and alignment to derive its rect.
//! - [`arrange_in_rect`] is for anchor children: the rect is *already* what
//! the anchor decided; the widget's margin / sizing / alignment are skipped
//! so the anchor is authoritative. Padding still applies (it's an inside-
//! the-rect concern). This matches the Unity/Godot convention that "anchor
//! determines rect" — sizing knobs would let the child silently disagree
//! with the anchor it was placed by.
//!
//! # DPI scale factor
//!
//! Every linear input (sizing, padding, margin, gaps, anchor offsets) is in
//! logical pixels and multiplied by [`layout`]'s `scale` argument at resolve
//! time. The widget tree is DPI-independent; the layout call is where the
//! display's scale factor enters.
use glam::Vec2;
use serde::{Deserialize, Serialize};
use crate::math::Rect;
use super::style::{Align, Insets, LayoutStyle, Sizing};
use super::widget::{AnchorGroup, Grid, Stack, StackDirection, Widget, WidgetId, WidgetKind};
/// One node in a resolved [`LayoutTree`] — the widget's id and its on-screen
/// rectangles.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct LayoutNode {
/// Mirror of [`Widget::id`].
pub id: WidgetId,
/// The outer rectangle the widget occupies, after margin / sizing /
/// alignment.
pub rect: Rect,
/// `rect` minus the widget's padding — the area children are arranged
/// inside.
pub content_rect: Rect,
/// Indices into [`LayoutTree::nodes`] of the direct children, in the same
/// order as on the input widget.
pub children: Vec<u32>,
}
/// Result of laying out a widget tree — a flat array of [`LayoutNode`]s with
/// the root at index 0.
#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
pub struct LayoutTree {
nodes: Vec<LayoutNode>,
}
impl LayoutTree {
/// All nodes, root first, in the pre-order produced by [`layout`].
pub fn nodes(&self) -> &[LayoutNode] {
&self.nodes
}
/// The root node (always present after a successful layout).
pub fn root(&self) -> Option<&LayoutNode> {
self.nodes.first()
}
/// Look up the first node with the given non-empty id.
///
/// Returns `None` if `id` is empty or no node matches. Linear scan — fine
/// for the dozens-of-widgets trees Stage 8 currently targets; a hash map
/// can be added if a profile says it's hot.
pub fn find(&self, id: &WidgetId) -> Option<&LayoutNode> {
if id.is_empty() {
return None;
}
self.nodes.iter().find(|n| n.id == *id)
}
/// The direct children of the node at `index`.
pub fn children_of(&self, index: usize) -> impl Iterator<Item = &LayoutNode> + '_ {
self.nodes[index]
.children
.iter()
.map(move |i| &self.nodes[*i as usize])
}
}
/// Lay out `root` inside `viewport` at the given DPI `scale`, producing a
/// [`LayoutTree`] with one entry per widget in pre-order.
pub fn layout(root: &Widget, viewport: Rect, scale: f32) -> LayoutTree {
let mut nodes = Vec::with_capacity(root.node_count());
arrange_in_slot(root, viewport, scale, &mut nodes);
LayoutTree { nodes }
}
// ---------- internal: recursive arrangement ----------
fn arrange_in_slot(widget: &Widget, slot: Rect, scale: f32, out: &mut Vec<LayoutNode>) -> u32 {
let margin = widget.style.margin.scaled(scale);
let outer = shrink(slot, margin);
let outer_size = outer.size();
let intrinsic = measure(widget, outer_size, scale);
let resolved_w = resolve_axis(widget.style.width, outer_size.x, intrinsic.x, scale);
let resolved_h = resolve_axis(widget.style.height, outer_size.y, intrinsic.y, scale);
let resolved = Vec2::new(resolved_w, resolved_h);
let extra = (outer_size - resolved).max(Vec2::ZERO);
let offset = Vec2::new(
align_offset(widget.style.align_horizontal, extra.x),
align_offset(widget.style.align_vertical, extra.y),
);
let rect = Rect::from_min_size(outer.min + offset, resolved);
arrange_in_rect(widget, rect, scale, out)
}
fn arrange_in_rect(widget: &Widget, rect: Rect, scale: f32, out: &mut Vec<LayoutNode>) -> u32 {
let padding = widget.style.padding.scaled(scale);
let content_rect = shrink(rect, padding);
let my_idx = out.len() as u32;
out.push(LayoutNode {
id: widget.id.clone(),
rect,
content_rect,
children: Vec::new(),
});
match &widget.kind {
WidgetKind::Leaf { .. } => {}
WidgetKind::Stack(stack) => arrange_stack(my_idx, stack, content_rect, scale, out),
WidgetKind::Grid(grid) => arrange_grid(my_idx, grid, content_rect, scale, out),
WidgetKind::Anchor(group) => arrange_anchor(my_idx, group, content_rect, scale, out),
}
my_idx
}
fn arrange_stack(
parent_idx: u32,
stack: &Stack,
content: Rect,
scale: f32,
out: &mut Vec<LayoutNode>,
) {
let n = stack.children.len();
if n == 0 {
return;
}
let gap = stack.gap * scale;
let total_gap = gap * n.saturating_sub(1) as f32;
let content_main = main_extent(stack.direction, content.size());
let content_cross = cross_extent(stack.direction, content.size());
// Pass 1: compute each child's main-axis size (fixed/fit) and tally
// grow weights.
let mut main_sizes: Vec<f32> = Vec::with_capacity(n);
let mut grow_weights: Vec<Option<f32>> = Vec::with_capacity(n);
let mut fixed_main_total = 0.0_f32;
let mut total_grow = 0.0_f32;
for child in &stack.children {
let margin = child.style.margin.scaled(scale);
let margin_main = main_extent(
stack.direction,
Vec2::new(margin.horizontal(), margin.vertical()),
);
let main_sizing = match stack.direction {
StackDirection::Row => child.style.width,
StackDirection::Column => child.style.height,
};
let (inner_main, weight) = match main_sizing {
Sizing::Fixed(v) => (v * scale, None),
Sizing::FitContent => {
let m = measure(child, content.size(), scale);
(main_extent(stack.direction, m), None)
}
Sizing::Grow(w) => (0.0, Some(w.max(0.0))),
};
if let Some(w) = weight {
total_grow += w;
}
grow_weights.push(weight);
main_sizes.push(inner_main + margin_main);
fixed_main_total += inner_main + margin_main;
}
let leftover = (content_main - fixed_main_total - total_gap).max(0.0);
if total_grow > 0.0 {
for (i, w) in grow_weights.iter().enumerate() {
if let Some(w) = w {
main_sizes[i] += leftover * (*w / total_grow);
}
}
}
// After distributing Grow, any remaining slack is positioned via the
// stack's `main_align`. (If any child grew, slack is zero.)
let used_main: f32 = main_sizes.iter().sum::<f32>() + total_gap;
let extra = (content_main - used_main).max(0.0);
let start_offset = align_offset(stack.main_align, extra);
// Pass 2: place each child in its slot.
let mut cursor = start_offset;
let mut child_indices = Vec::with_capacity(n);
for (i, child) in stack.children.iter().enumerate() {
let slot_main = main_sizes[i];
let slot = make_slot(stack.direction, content, cursor, slot_main, content_cross);
cursor += slot_main + gap;
child_indices.push(arrange_in_slot(child, slot, scale, out));
}
out[parent_idx as usize].children = child_indices;
}
fn arrange_grid(
parent_idx: u32,
grid: &Grid,
content: Rect,
scale: f32,
out: &mut Vec<LayoutNode>,
) {
if grid.cols == 0 || grid.rows == 0 || grid.children.is_empty() {
return;
}
let gap = grid.gap * scale;
let total_gap_x = gap.x * grid.cols.saturating_sub(1) as f32;
let total_gap_y = gap.y * grid.rows.saturating_sub(1) as f32;
let cell_w = ((content.width() - total_gap_x) / grid.cols as f32).max(0.0);
let cell_h = ((content.height() - total_gap_y) / grid.rows as f32).max(0.0);
let cells = grid.cols * grid.rows;
let mut child_indices = Vec::with_capacity(grid.children.len().min(cells as usize));
for (i, child) in grid.children.iter().enumerate() {
if i as u32 >= cells {
break;
}
let row = i as u32 / grid.cols;
let col = i as u32 % grid.cols;
let cell_origin =
content.min + Vec2::new(col as f32 * (cell_w + gap.x), row as f32 * (cell_h + gap.y));
let slot = Rect::from_min_size(cell_origin, Vec2::new(cell_w, cell_h));
child_indices.push(arrange_in_slot(child, slot, scale, out));
}
out[parent_idx as usize].children = child_indices;
}
fn arrange_anchor(
parent_idx: u32,
group: &AnchorGroup,
content: Rect,
scale: f32,
out: &mut Vec<LayoutNode>,
) {
let size = content.size();
let mut child_indices = Vec::with_capacity(group.children.len());
for child in &group.children {
let a = child.style.anchor;
let min = content.min + size * a.min + a.offset_min * scale;
let max = content.min + size * a.max + a.offset_max * scale;
let target = Rect::new(min, max);
child_indices.push(arrange_in_rect(child, target, scale, out));
}
out[parent_idx as usize].children = child_indices;
}
// ---------- internal: measurement ----------
fn measure(widget: &Widget, available: Vec2, scale: f32) -> Vec2 {
match &widget.kind {
WidgetKind::Leaf { intrinsic } => *intrinsic * scale,
WidgetKind::Stack(stack) => measure_stack(&widget.style, stack, available, scale),
WidgetKind::Grid(grid) => measure_grid(&widget.style, grid, available, scale),
// Anchor parents derive their children's rects from the parent's size,
// so they can't propose an intrinsic "fit" size; FitContent on an
// anchor parent collapses to zero.
WidgetKind::Anchor(_) => Vec2::ZERO,
}
}
/// Outer footprint of a child (the slot it would consume in its parent),
/// including its own margin.
fn measure_outer(widget: &Widget, available: Vec2, scale: f32) -> Vec2 {
let intrinsic = measure(widget, available, scale);
let w = match widget.style.width {
Sizing::Fixed(v) => v * scale,
Sizing::FitContent => intrinsic.x,
Sizing::Grow(_) => 0.0,
};
let h = match widget.style.height {
Sizing::Fixed(v) => v * scale,
Sizing::FitContent => intrinsic.y,
Sizing::Grow(_) => 0.0,
};
let m = widget.style.margin.scaled(scale);
Vec2::new(w + m.horizontal(), h + m.vertical())
}
fn measure_stack(parent_style: &LayoutStyle, stack: &Stack, available: Vec2, scale: f32) -> Vec2 {
let mut main = 0.0_f32;
let mut cross = 0.0_f32;
let n = stack.children.len();
for child in &stack.children {
let s = measure_outer(child, available, scale);
main += main_extent(stack.direction, s);
cross = cross.max(cross_extent(stack.direction, s));
}
if n > 1 {
main += stack.gap * scale * (n - 1) as f32;
}
let p = parent_style.padding.scaled(scale);
match stack.direction {
StackDirection::Row => Vec2::new(main + p.horizontal(), cross + p.vertical()),
StackDirection::Column => Vec2::new(cross + p.horizontal(), main + p.vertical()),
}
}
fn measure_grid(parent_style: &LayoutStyle, grid: &Grid, available: Vec2, scale: f32) -> Vec2 {
if grid.cols == 0 || grid.rows == 0 {
return Vec2::ZERO;
}
let mut cell_w = 0.0_f32;
let mut cell_h = 0.0_f32;
for child in &grid.children {
let s = measure_outer(child, available, scale);
cell_w = cell_w.max(s.x);
cell_h = cell_h.max(s.y);
}
let gap = grid.gap * scale;
let total = Vec2::new(
cell_w * grid.cols as f32 + gap.x * grid.cols.saturating_sub(1) as f32,
cell_h * grid.rows as f32 + gap.y * grid.rows.saturating_sub(1) as f32,
);
let p = parent_style.padding.scaled(scale);
Vec2::new(total.x + p.horizontal(), total.y + p.vertical())
}
// ---------- internal: small helpers ----------
fn shrink(r: Rect, i: Insets) -> Rect {
let min = r.min + Vec2::new(i.left, i.top);
let max = r.max - Vec2::new(i.right, i.bottom);
Rect::new(min, max)
}
fn align_offset(align: Align, extra: f32) -> f32 {
match align {
Align::Start => 0.0,
Align::Center => extra * 0.5,
Align::End => extra,
}
}
fn resolve_axis(sizing: Sizing, available: f32, intrinsic: f32, scale: f32) -> f32 {
match sizing {
Sizing::Fixed(v) => (v * scale).min(available),
Sizing::Grow(_) => available,
Sizing::FitContent => intrinsic.min(available),
}
}
fn main_extent(dir: StackDirection, v: Vec2) -> f32 {
match dir {
StackDirection::Row => v.x,
StackDirection::Column => v.y,
}
}
fn cross_extent(dir: StackDirection, v: Vec2) -> f32 {
match dir {
StackDirection::Row => v.y,
StackDirection::Column => v.x,
}
}
fn make_slot(dir: StackDirection, content: Rect, cursor: f32, main: f32, cross: f32) -> Rect {
match dir {
StackDirection::Row => {
Rect::from_min_size(content.min + Vec2::new(cursor, 0.0), Vec2::new(main, cross))
}
StackDirection::Column => {
Rect::from_min_size(content.min + Vec2::new(0.0, cursor), Vec2::new(cross, main))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ui::style::Anchor;
fn vp(w: f32, h: f32) -> Rect {
Rect::from_min_size(Vec2::ZERO, Vec2::new(w, h))
}
/// `Grow(1.0)` on both axes — the common "fill the parent" style for
/// container tests where intrinsic sizing would collapse the root.
fn grow_both() -> LayoutStyle {
LayoutStyle {
width: Sizing::Grow(1.0),
height: Sizing::Grow(1.0),
..Default::default()
}
}
#[test]
fn single_leaf_takes_intrinsic_size_at_origin() {
let w = Widget::leaf(Vec2::new(40.0, 20.0)).with_id("a");
let tree = layout(&w, vp(800.0, 600.0), 1.0);
let n = tree.find(&"a".into()).unwrap();
assert_eq!(
n.rect,
Rect::from_min_size(Vec2::ZERO, Vec2::new(40.0, 20.0))
);
assert_eq!(n.content_rect, n.rect);
assert_eq!(tree.nodes().len(), 1);
}
#[test]
fn dpi_scale_doubles_sizes() {
let w = Widget::leaf(Vec2::new(40.0, 20.0));
let tree = layout(&w, vp(800.0, 600.0), 2.0);
let n = tree.root().unwrap();
assert_eq!(n.rect.size(), Vec2::new(80.0, 40.0));
}
#[test]
fn row_stack_places_fixed_children_with_gap() {
let row = Widget::row()
.with_id("row")
.with_gap(4.0)
.with_child(Widget::leaf(Vec2::new(30.0, 20.0)).with_id("a"))
.with_child(Widget::leaf(Vec2::new(50.0, 20.0)).with_id("b"))
.with_child(Widget::leaf(Vec2::new(10.0, 20.0)).with_id("c"));
let tree = layout(&row, vp(200.0, 100.0), 1.0);
let a = tree.find(&"a".into()).unwrap().rect;
let b = tree.find(&"b".into()).unwrap().rect;
let c = tree.find(&"c".into()).unwrap().rect;
assert_eq!(a, Rect::from_min_size(Vec2::ZERO, Vec2::new(30.0, 20.0)));
assert_eq!(
b,
Rect::from_min_size(Vec2::new(34.0, 0.0), Vec2::new(50.0, 20.0))
);
assert_eq!(
c,
Rect::from_min_size(Vec2::new(88.0, 0.0), Vec2::new(10.0, 20.0))
);
}
#[test]
fn row_stack_grow_fills_leftover_space() {
// 200 wide; A=30 fixed, B=Grow, C=10 fixed → B gets 160 wide.
let row =
Widget::row()
.with_id("row")
.with_style(grow_both())
.with_child(Widget::leaf(Vec2::new(30.0, 20.0)).with_id("a"))
.with_child(Widget::leaf(Vec2::new(0.0, 20.0)).with_id("b").with_style(
LayoutStyle {
width: Sizing::Grow(1.0),
height: Sizing::Fixed(20.0),
..Default::default()
},
))
.with_child(Widget::leaf(Vec2::new(10.0, 20.0)).with_id("c"));
let tree = layout(&row, vp(200.0, 100.0), 1.0);
let b = tree.find(&"b".into()).unwrap().rect;
assert_eq!(b.min.x, 30.0);
assert_eq!(b.width(), 160.0);
assert_eq!(tree.find(&"c".into()).unwrap().rect.min.x, 190.0);
}
#[test]
fn row_stack_grow_weights_split_proportionally() {
// 300 wide root; A=Grow(1), B=Grow(2) → A gets 100, B gets 200.
let row = Widget::row()
.with_style(grow_both())
.with_child(Widget::default().with_id("a").with_style(LayoutStyle {
width: Sizing::Grow(1.0),
height: Sizing::Grow(1.0),
..Default::default()
}))
.with_child(Widget::default().with_id("b").with_style(LayoutStyle {
width: Sizing::Grow(2.0),
height: Sizing::Grow(1.0),
..Default::default()
}));
let tree = layout(&row, vp(300.0, 30.0), 1.0);
let a = tree.find(&"a".into()).unwrap().rect;
let b = tree.find(&"b".into()).unwrap().rect;
assert_eq!(a.width(), 100.0);
assert_eq!(b.width(), 200.0);
assert_eq!(b.min.x, 100.0);
// Cross axis Grow fills full height.
assert_eq!(a.height(), 30.0);
assert_eq!(b.height(), 30.0);
}
#[test]
fn row_stack_main_align_center_splits_extra() {
// Two 30-wide children with gap 0 → main extent 60; viewport 200 →
// 140 extra, centered → 70 each side.
let row = Widget::row()
.with_main_align(Align::Center)
.with_style(grow_both())
.with_child(Widget::leaf(Vec2::new(30.0, 10.0)).with_id("a"))
.with_child(Widget::leaf(Vec2::new(30.0, 10.0)).with_id("b"));
let tree = layout(&row, vp(200.0, 20.0), 1.0);
assert_eq!(tree.find(&"a".into()).unwrap().rect.min.x, 70.0);
assert_eq!(tree.find(&"b".into()).unwrap().rect.min.x, 100.0);
}
#[test]
fn row_stack_cross_align_end_docks_to_bottom() {
// Child is 30x10 in a 100-wide row with 40 tall → align End → top=30.
let row = Widget::row().with_style(grow_both()).with_child(
Widget::leaf(Vec2::new(30.0, 10.0))
.with_id("a")
.with_style(LayoutStyle {
align_vertical: Align::End,
..Default::default()
}),
);
let tree = layout(&row, vp(100.0, 40.0), 1.0);
let a = tree.find(&"a".into()).unwrap().rect;
assert_eq!(a.min.y, 30.0);
assert_eq!(a.max.y, 40.0);
}
#[test]
fn column_stack_flows_top_to_bottom() {
let col = Widget::column()
.with_gap(2.0)
.with_child(Widget::leaf(Vec2::new(20.0, 10.0)).with_id("a"))
.with_child(Widget::leaf(Vec2::new(20.0, 30.0)).with_id("b"));
let tree = layout(&col, vp(100.0, 100.0), 1.0);
let a = tree.find(&"a".into()).unwrap().rect;
let b = tree.find(&"b".into()).unwrap().rect;
assert_eq!(a.min, Vec2::ZERO);
assert_eq!(a.max.y, 10.0);
assert_eq!(b.min.y, 12.0);
assert_eq!(b.max.y, 42.0);
}
#[test]
fn grid_two_by_three_makes_six_equal_cells() {
// 100x60 content, 2 cols × 3 rows, no gap → cells 50x20.
let grid = Widget::grid(2, 3)
.with_style(grow_both())
.with_children((0..6).map(|i| Widget::leaf(Vec2::ZERO).with_id(format!("c{i}"))));
let tree = layout(&grid, vp(100.0, 60.0), 1.0);
for i in 0..6 {
let row = i / 2;
let col = i % 2;
let n = tree.find(&format!("c{i}").into()).unwrap();
// Default FitContent of zero intrinsic ⇒ children collapse to
// (col*50, row*20)(col*50, row*20) at Start align inside the
// cell. Verify the *cell origin* via the node's `rect.min`.
assert_eq!(n.rect.min, Vec2::new(col as f32 * 50.0, row as f32 * 20.0));
}
}
#[test]
fn grid_gap_subtracts_from_cell_size() {
let grid = Widget::grid(2, 2)
.with_style(grow_both())
.with_grid_gap(Vec2::new(10.0, 10.0))
.with_children((0..4).map(|i| {
Widget::default()
.with_id(format!("c{i}"))
.with_style(LayoutStyle {
width: Sizing::Grow(1.0),
height: Sizing::Grow(1.0),
..Default::default()
})
}));
let tree = layout(&grid, vp(110.0, 110.0), 1.0);
// (110 - 10 gap) / 2 = 50 per cell.
for i in 0..4 {
let n = tree.find(&format!("c{i}").into()).unwrap();
assert_eq!(n.rect.size(), Vec2::new(50.0, 50.0));
}
// Second column starts at 60 (50 + 10 gap).
assert_eq!(tree.find(&"c1".into()).unwrap().rect.min.x, 60.0);
// Second row starts at 60.
assert_eq!(tree.find(&"c2".into()).unwrap().rect.min.y, 60.0);
}
#[test]
fn anchor_fill_makes_child_match_parent_content() {
let parent = Widget::anchor()
.with_id("p")
.with_style(LayoutStyle {
width: Sizing::Grow(1.0),
height: Sizing::Grow(1.0),
..Default::default()
})
.with_child(Widget::leaf(Vec2::ZERO).with_id("child"));
let tree = layout(&parent, vp(200.0, 100.0), 1.0);
let child = tree.find(&"child".into()).unwrap();
assert_eq!(
child.rect,
Rect::from_min_size(Vec2::ZERO, Vec2::new(200.0, 100.0))
);
}
#[test]
fn anchor_top_right_with_offsets_places_child_relative_to_corner() {
// Pin the child's top-right at the parent's top-right, then push the
// top-left corner 80 pixels left and 24 pixels down → 80×24 child in
// the top-right corner.
let parent = Widget::anchor()
.with_style(LayoutStyle {
width: Sizing::Grow(1.0),
height: Sizing::Grow(1.0),
..Default::default()
})
.with_child(
Widget::leaf(Vec2::ZERO)
.with_id("c")
.with_style(LayoutStyle {
anchor: Anchor::TOP_RIGHT
.with_offsets(Vec2::new(-80.0, 0.0), Vec2::new(0.0, 24.0)),
..Default::default()
}),
);
let tree = layout(&parent, vp(300.0, 200.0), 1.0);
let c = tree.find(&"c".into()).unwrap().rect;
assert_eq!(c.min, Vec2::new(220.0, 0.0));
assert_eq!(c.max, Vec2::new(300.0, 24.0));
}
#[test]
fn anchor_dpi_scales_offsets() {
let parent = Widget::anchor()
.with_style(LayoutStyle {
width: Sizing::Grow(1.0),
height: Sizing::Grow(1.0),
..Default::default()
})
.with_child(
Widget::leaf(Vec2::ZERO)
.with_id("c")
.with_style(LayoutStyle {
anchor: Anchor::TOP_LEFT.with_offsets(Vec2::ZERO, Vec2::new(40.0, 20.0)),
..Default::default()
}),
);
let tree = layout(&parent, vp(400.0, 400.0), 2.0);
let c = tree.find(&"c".into()).unwrap().rect;
assert_eq!(c.min, Vec2::ZERO);
assert_eq!(c.max, Vec2::new(80.0, 40.0));
}
#[test]
fn padding_shrinks_content_rect_and_offsets_children() {
let row = Widget::row()
.with_id("row")
.with_style(LayoutStyle {
width: Sizing::Grow(1.0),
height: Sizing::Grow(1.0),
padding: Insets::all(10.0),
..Default::default()
})
.with_child(Widget::leaf(Vec2::new(50.0, 20.0)).with_id("a"));
let tree = layout(&row, vp(200.0, 100.0), 1.0);
let row_node = tree.find(&"row".into()).unwrap();
assert_eq!(
row_node.rect,
Rect::from_min_size(Vec2::ZERO, Vec2::new(200.0, 100.0))
);
assert_eq!(
row_node.content_rect,
Rect::from_min_size(Vec2::splat(10.0), Vec2::new(180.0, 80.0))
);
let a = tree.find(&"a".into()).unwrap().rect;
assert_eq!(a.min, Vec2::splat(10.0));
assert_eq!(a.size(), Vec2::new(50.0, 20.0));
}
#[test]
fn margin_reserves_space_outside_widget() {
let row =
Widget::row().with_child(Widget::leaf(Vec2::new(40.0, 20.0)).with_id("a").with_style(
LayoutStyle {
margin: Insets::symmetric(5.0, 0.0),
..Default::default()
},
));
let tree = layout(&row, vp(200.0, 100.0), 1.0);
let a = tree.find(&"a".into()).unwrap().rect;
// 5px left margin → child starts at 5, width 40.
assert_eq!(a.min.x, 5.0);
assert_eq!(a.max.x, 45.0);
}
#[test]
fn fit_content_stack_sums_children_plus_padding() {
// Two 30x10 fixed children, no gap, padding=8 → root 76 x 26.
let row = Widget::row()
.with_id("root")
.with_style(LayoutStyle {
padding: Insets::all(8.0),
..Default::default()
})
.with_child(Widget::leaf(Vec2::new(30.0, 10.0)))
.with_child(Widget::leaf(Vec2::new(30.0, 10.0)));
let tree = layout(&row, vp(1000.0, 1000.0), 1.0);
let r = tree.find(&"root".into()).unwrap().rect;
assert_eq!(r.size(), Vec2::new(76.0, 26.0));
}
#[test]
fn layout_tree_round_trips_through_ron() {
let w = Widget::row()
.with_id("root")
.with_gap(4.0)
.with_child(Widget::leaf(Vec2::new(20.0, 10.0)).with_id("a"));
let tree = layout(&w, vp(100.0, 50.0), 1.0);
let text = ron::ser::to_string(&tree).unwrap();
let decoded: LayoutTree = ron::de::from_str(&text).unwrap();
assert_eq!(tree, decoded);
}
#[test]
fn find_rejects_empty_id() {
let w = Widget::leaf(Vec2::ONE);
let tree = layout(&w, vp(10.0, 10.0), 1.0);
assert!(tree.find(&WidgetId::default()).is_none());
}
#[test]
fn children_of_iterates_direct_children_only() {
let tree = layout(
&Widget::row()
.with_child(Widget::leaf(Vec2::new(10.0, 10.0)).with_id("a"))
.with_child(
Widget::column()
.with_id("col")
.with_child(Widget::leaf(Vec2::new(10.0, 10.0)).with_id("inner")),
),
vp(100.0, 100.0),
1.0,
);
let ids: Vec<_> = tree
.children_of(0)
.map(|n| n.id.as_str().to_owned())
.collect();
assert_eq!(ids, vec!["a".to_string(), "col".to_string()]);
}
}
+88
View File
@@ -0,0 +1,88 @@
//! In-game UI system — widget tree, layout, styling, text, input routing.
//!
//! Stage 8 builds the engine's **in-game** UI — what an exported game uses
//! to draw its menus, HUDs, and tools. This is intentionally distinct from
//! the editor's `egui` (which stays editor-only): a shipped game cannot pull
//! in `egui`, so the runtime owns its own widget tree, lays it out, batches
//! it through the Stage-5 render pipeline, and routes input through the
//! Stage-7 model.
//!
//! Stage 8 is shipped in pieces:
//!
//! 1. **Piece 1 — widget tree + layout (this module, right now).** A flat
//! [`Widget`] data structure, three layout modes ([`Stack`], [`Grid`],
//! [`AnchorGroup`]), and a pure-logic [`layout`] function that turns a
//! tree into a [`LayoutTree`] of resolved screen rects. No rendering,
//! no input, fully testable headlessly.
//! 2. Piece 2 — styling & theming (`Style` / `Theme` + RON dual-edit).
//! 3. Piece 3 — text shaping & glyph atlas.
//! 4. Piece 4 — 2D overlay render pass.
//! 5. Piece 5 — input routing (hit-test, hover/focus/press).
//! 6. Piece 6 — events + data binding.
//! 7. Pieces 79 — GUI tail (`examples/ui_menu`, `examples/ui_hud`, editor
//! UI canvas).
//!
//! # Worked example
//!
//! ```
//! use glam::Vec2;
//! use oxide_engine::math::Rect;
//! use oxide_engine::ui::{layout, Insets, LayoutStyle, Sizing, Widget};
//!
//! // A toolbar with two buttons and a stretching spacer between them.
//! let toolbar = Widget::row()
//! .with_id("toolbar")
//! .with_gap(8.0)
//! .with_style(LayoutStyle {
//! width: Sizing::Grow(1.0),
//! height: Sizing::Fixed(32.0),
//! padding: Insets::all(4.0),
//! ..Default::default()
//! })
//! .with_child(Widget::leaf(Vec2::new(64.0, 24.0)).with_id("file"))
//! .with_child(
//! Widget::default()
//! .with_id("spacer")
//! .with_style(LayoutStyle {
//! width: Sizing::Grow(1.0),
//! height: Sizing::Grow(1.0),
//! ..Default::default()
//! }),
//! )
//! .with_child(Widget::leaf(Vec2::new(64.0, 24.0)).with_id("help"));
//!
//! let viewport = Rect::from_min_size(Vec2::ZERO, Vec2::new(800.0, 600.0));
//! let tree = layout(&toolbar, viewport, 1.0);
//! let toolbar_rect = tree.root().unwrap().rect;
//! assert_eq!(toolbar_rect.height(), 32.0);
//! let help_rect = tree.find(&"help".into()).unwrap().rect;
//! assert_eq!(help_rect.max.x, 800.0 - 4.0); // padding on the right
//! ```
mod layout;
pub mod paint;
mod panel;
pub mod routing;
mod style;
pub mod text;
mod theme;
mod value;
mod visual;
mod widget;
pub use layout::{layout, LayoutNode, LayoutTree};
pub use paint::{paint, DrawCommand, PaintedFrame};
pub use panel::UiPanel;
pub use routing::{hit_test, Router, RouterEvent, RouterFrame};
pub use style::{Align, Anchor, Insets, LayoutStyle, Sizing};
pub use text::{
shape, shape_runs, AtlasEntry, Font, FontError, FontId, FontLoader, FontStore, GlyphAtlas,
GlyphId, GlyphKey, RasterizedGlyph, ShapeParams, ShapedGlyph, ShapedLine, ShapedText,
TextAlign, TextRun, TextStyle,
};
pub use theme::Theme;
pub use value::WidgetValue;
pub use visual::{Border, FontRef, FontWeight, VisualStyle};
pub use widget::{
AnchorGroup, Grid, Stack, StackDirection, Widget, WidgetId, WidgetKind, WidgetPath,
};
+319
View File
@@ -0,0 +1,319 @@
//! Paint — turn a laid-out widget tree into a flat list of draw commands.
//!
//! Layout (piece 1) is purely geometric: rects in, rects out. Paint (piece 4)
//! adds the *visual* dimension: solid fills for backgrounds, textured quads
//! for text. The output is a [`PaintedFrame`] — a flat list of
//! [`DrawCommand`]s the [`UiOverlayPass`](super::super::render::UiOverlayPass)
//! consumes directly. Keeping paint pure-CPU and the GPU pass downstream
//! lets every paint test run headlessly; the GPU pass only has to know how
//! to *consume* commands, not how to derive them.
//!
//! # Algorithm
//!
//! 1. Walk the [`LayoutTree`] in node order (root first, children after).
//! 2. For each laid-out node:
//! - Resolve its [`VisualStyle`] under the active [`Theme`].
//! - If the resolved style has a background, emit one [`DrawCommand::Quad`]
//! filling `node.rect`.
//! - If the source widget has `text`, shape it inside `node.content_rect`
//! with the resolved font / size / color, then emit one
//! [`DrawCommand::Glyph`] per non-space glyph.
//! 3. The frame's overall `size` mirrors the layout root's rect so the GPU
//! pass knows how big the viewport for this batch is.
//!
//! Render order is the layout order: parents before children, so the
//! children draw *on top of* their parents (matching standard UI layering).
use glam::Vec2;
use super::layout::LayoutTree;
use super::text::{shape, FontStore, GlyphKey, ShapeParams, TextStyle};
use super::theme::Theme;
use super::visual::VisualStyle;
use super::widget::Widget;
use crate::math::{Color, Rect};
/// One draw call in a painted UI frame.
///
/// All commands share a single GPU pipeline and one texture (the glyph
/// atlas). Solid quads emit a sentinel UV the shader recognises as
/// "untextured" so a single fragment path handles both cases.
#[derive(Debug, Clone, PartialEq)]
pub enum DrawCommand {
/// A solid-colored axis-aligned rectangle.
Quad { rect: Rect, color: Color },
/// One glyph quad — the renderer turns the [`GlyphKey`] into an atlas
/// region at draw time. `pen_position` is the **baseline** point; the
/// atlas's per-glyph bearing positions the quad relative to it.
Glyph {
key: GlyphKey,
pen_position: Vec2,
color: Color,
},
}
/// Output of [`paint`] — the size of the painted area and the ordered list
/// of draw commands.
#[derive(Debug, Default, Clone, PartialEq)]
pub struct PaintedFrame {
/// Size of the painted area in (post-scale) pixels — usually the
/// layout root's rect size.
pub size: Vec2,
/// Draw commands in the order they should be submitted (back-to-front).
pub commands: Vec<DrawCommand>,
}
/// Walk a laid-out widget tree under a theme and produce the draw commands
/// for one frame.
///
/// `scale` matches the value passed to
/// [`layout`](super::layout::layout) — paint uses it to pass the same DPI
/// factor to [`shape`] for text.
pub fn paint(
root: &Widget,
tree: &LayoutTree,
theme: &Theme,
fonts: &FontStore,
scale: f32,
) -> PaintedFrame {
let mut commands = Vec::new();
paint_widget(root, tree, 0, theme, fonts, scale, &mut commands);
let size = tree
.root()
.map(|node| node.rect.size())
.unwrap_or(Vec2::ZERO);
PaintedFrame { size, commands }
}
fn paint_widget(
widget: &Widget,
tree: &LayoutTree,
node_index: usize,
theme: &Theme,
fonts: &FontStore,
scale: f32,
out: &mut Vec<DrawCommand>,
) {
let node = &tree.nodes()[node_index];
let resolved = widget.resolve_visual(theme);
// Background fill — only emit if the rect has area and a background was
// resolved. A `corner_radius` is captured in the resolved style for
// future use but ignored by piece-4's rectangular renderer.
if let Some(bg) = resolved.background {
if !node.rect.is_empty() {
out.push(DrawCommand::Quad {
rect: node.rect,
color: bg,
});
}
}
// Text — shape inside `content_rect` (so padding is respected) and emit
// one glyph per non-empty position.
if let Some(text) = widget.text.as_ref() {
paint_text(text, node.content_rect, &resolved, fonts, scale, out);
}
// Children draw on top of self.
for (child_widget, child_index) in widget.children().iter().zip(node.children.iter()) {
paint_widget(
child_widget,
tree,
*child_index as usize,
theme,
fonts,
scale,
out,
);
}
}
fn paint_text(
text: &str,
content_rect: Rect,
resolved: &VisualStyle,
fonts: &FontStore,
scale: f32,
out: &mut Vec<DrawCommand>,
) {
let Some(font_ref) = resolved.font.as_ref() else {
return;
};
let Some(font_id) = fonts.resolve(font_ref) else {
return;
};
let size_px = resolved.font_size.unwrap_or(14.0);
let color = resolved.foreground.unwrap_or(Color::BLACK);
let style = TextStyle {
font: font_id,
size_px,
};
let params = ShapeParams {
max_width: Some(content_rect.width()),
scale,
..ShapeParams::default()
};
let shaped = shape(text, style, &params, fonts);
for line in &shaped.lines {
for g in &line.glyphs {
out.push(DrawCommand::Glyph {
key: g.key,
pen_position: content_rect.min + g.position,
color,
});
}
}
}
#[cfg(test)]
mod tests {
use super::super::layout::layout;
use super::super::text::{common_system_font_paths, Font};
use super::super::visual::FontRef;
use super::super::widget::Widget;
use super::*;
use glam::Vec2;
fn viewport(w: f32, h: f32) -> Rect {
Rect::from_min_size(Vec2::ZERO, Vec2::new(w, h))
}
fn solid_panel(color: Color, w: f32, h: f32) -> Widget {
Widget::leaf(Vec2::new(w, h)).with_visual(VisualStyle {
background: Some(color),
..VisualStyle::EMPTY
})
}
#[test]
fn solid_widget_emits_one_quad_at_its_rect() {
let root = solid_panel(Color::RED, 40.0, 20.0);
let tree = layout(&root, viewport(100.0, 50.0), 1.0);
let theme = Theme::new();
let fonts = FontStore::new();
let painted = paint(&root, &tree, &theme, &fonts, 1.0);
assert_eq!(painted.size, Vec2::new(40.0, 20.0));
assert_eq!(painted.commands.len(), 1);
match &painted.commands[0] {
DrawCommand::Quad { rect, color } => {
assert_eq!(
*rect,
Rect::from_min_size(Vec2::ZERO, Vec2::new(40.0, 20.0))
);
assert_eq!(*color, Color::RED);
}
_ => panic!("expected a Quad"),
}
}
#[test]
fn widget_without_visual_emits_no_quads() {
// Default widget has empty visual — nothing to paint.
let root = Widget::leaf(Vec2::new(40.0, 20.0));
let tree = layout(&root, viewport(100.0, 50.0), 1.0);
let painted = paint(&root, &tree, &Theme::new(), &FontStore::new(), 1.0);
assert!(painted.commands.is_empty());
}
#[test]
fn child_quad_is_emitted_after_parent_quad() {
let root = Widget::row()
.with_visual(VisualStyle {
background: Some(Color::WHITE),
..VisualStyle::EMPTY
})
.with_style(super::super::style::LayoutStyle {
width: super::super::style::Sizing::Fixed(100.0),
height: super::super::style::Sizing::Fixed(50.0),
..Default::default()
})
.with_child(solid_panel(Color::RED, 40.0, 20.0));
let tree = layout(&root, viewport(200.0, 100.0), 1.0);
let painted = paint(&root, &tree, &Theme::new(), &FontStore::new(), 1.0);
assert_eq!(painted.commands.len(), 2);
// Parent (white) painted before child (red), so child draws on top.
match &painted.commands[0] {
DrawCommand::Quad { color, .. } => assert_eq!(*color, Color::WHITE),
_ => panic!(),
}
match &painted.commands[1] {
DrawCommand::Quad { color, .. } => assert_eq!(*color, Color::RED),
_ => panic!(),
}
}
fn try_load_font() -> Option<Font> {
for path in common_system_font_paths() {
if std::path::Path::new(path).exists() {
if let Ok(font) = Font::from_path(path) {
return Some(font);
}
}
}
eprintln!("SKIP: no system font available for paint tests");
None
}
#[test]
fn text_emits_one_glyph_per_visible_char() {
let Some(font) = try_load_font() else {
return;
};
let descriptor = FontRef::regular("Sys");
let mut fonts = FontStore::new();
fonts.insert_with_descriptor(descriptor.clone(), font);
let theme = Theme::new().with_default(VisualStyle {
font: Some(descriptor),
font_size: Some(14.0),
foreground: Some(Color::BLACK),
..VisualStyle::EMPTY
});
let root = Widget::leaf(Vec2::new(80.0, 20.0))
.with_id("label")
.with_text("Hi")
.with_style(super::super::style::LayoutStyle {
width: super::super::style::Sizing::Fixed(80.0),
height: super::super::style::Sizing::Fixed(20.0),
..Default::default()
});
let tree = layout(&root, viewport(200.0, 100.0), 1.0);
let painted = paint(&root, &tree, &theme, &fonts, 1.0);
// "Hi" → 2 glyphs (H, i). No background → no Quad commands.
let glyph_count = painted
.commands
.iter()
.filter(|c| matches!(c, DrawCommand::Glyph { .. }))
.count();
let quad_count = painted
.commands
.iter()
.filter(|c| matches!(c, DrawCommand::Quad { .. }))
.count();
assert_eq!(glyph_count, 2);
assert_eq!(quad_count, 0);
// Both glyphs sit at the same baseline.
let baselines: Vec<f32> = painted
.commands
.iter()
.filter_map(|c| match c {
DrawCommand::Glyph { pen_position, .. } => Some(pen_position.y),
_ => None,
})
.collect();
assert_eq!(baselines[0], baselines[1]);
}
#[test]
fn text_without_font_in_theme_silently_emits_nothing() {
// No font registered → text resolves but shape returns no lines.
// Paint must not panic.
let root = Widget::leaf(Vec2::new(40.0, 20.0)).with_text("Hi");
let tree = layout(&root, viewport(100.0, 50.0), 1.0);
let painted = paint(&root, &tree, &Theme::new(), &FontStore::new(), 1.0);
assert!(painted.commands.is_empty());
}
}
+231
View File
@@ -0,0 +1,231 @@
//! World-space UI panels — a [`Widget`] tree rendered onto a quad in 3D.
//!
//! Stage 8's UI is "in-game UI" — what the exported game uses to draw
//! menus and HUDs. Most of the time those are **screen-space**: pixel-
//! anchored, drawn over the 3D scene by piece 4a's
//! [`UiOverlayPass`](super::super::render::UiOverlayPass) using an
//! orthographic projection. A [`UiPanel`] is the world-space alternative —
//! the same `Widget` tree, but laid out on a flat panel that sits in the
//! 3D world at some [`Transform`].
//!
//! This is what gives game projects:
//!
//! - **Diegetic UI** — terminal screens, signs, dashboards, control
//! panels — the player sees them rendered inside the world rather than
//! pasted over it.
//! - **Editor previews** — the UI canvas (piece 9) can drop a panel into
//! the scene to preview a document at scale, on the same hardware path
//! the shipped game uses.
//! - **VR / room-scale UI** later — once Stage-13 head-mounted display
//! support lands, world-space panels are the only sensible way to
//! present interactive UI.
//!
//! # How the math works
//!
//! A panel describes itself in two coordinate spaces:
//!
//! - **Pixel space** — where the layout algorithm operates. `pixel_size`
//! is the resolution the `Widget` tree is laid out at (e.g.,
//! `Vec2::new(1024.0, 768.0)`). Glyphs are rasterized at this scale.
//! - **World space** — where the panel sits in 3D. `world_size` is its
//! physical size in world units (e.g., `Vec2::new(2.0, 1.5)` for a
//! 2 m × 1.5 m monitor).
//!
//! The piece-4 vertex format carries 2D pixel-space positions. To draw
//! that on a 3D quad, [`UiBatch::world_space`](super::super::render::UiBatch::world_space)
//! builds a single MVP that composes:
//!
//! ```text
//! mvp = camera_view_projection
//! * panel_transform // world placement
//! * scale(world.x / pixel.x, -world.y / pixel.y, 1) // pixels → world (and flip y, since UI is y-down)
//! * translate(-pixel.x / 2, -pixel.y / 2, 0) // recentre pixel origin
//! ```
//!
//! The same `UiOverlayPass` then draws the panel using the same shader
//! and the same R8 atlas — the only thing that distinguishes a screen-
//! space batch from a world-space one is which constructor built it.
//!
//! # Overlay semantics for piece 4b
//!
//! World-space panels in piece 4b render as **overlays**: no depth test,
//! no depth write — they draw on top of whatever's already in the color
//! target. That keeps the implementation simple and matches the common
//! "always-visible" use case (player nameplates, mission markers,
//! editor canvas previews).
//!
//! A future depth-aware mode (where a panel behind a wall is properly
//! hidden) is in [`PLAN.md`](../../../../PLAN.md)'s Stage-8 backlog and
//! slots in by attaching a depth attachment to a second pass of the
//! same pipeline.
use glam::Mat4;
use serde::{Deserialize, Serialize};
use super::layout::layout;
use super::paint::paint;
use super::text::FontStore;
use super::theme::Theme;
use super::widget::Widget;
use crate::math::{Rect, Transform, Vec2};
/// A widget tree placed on a 3D quad.
///
/// `UiPanel` carries pure data: the document, its pixel resolution, and
/// its world size. The host owns the panel's [`Transform`] separately
/// (typically as an ECS component on the same entity), the active
/// [`Theme`], and the [`FontStore`] — all three are needed at render
/// time to build the panel's [`UiBatch`](super::super::render::UiBatch).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct UiPanel {
/// The UI document on this panel.
pub root: Widget,
/// Resolution to lay out the UI at, in logical pixels. Drives the
/// pixel size of every glyph rasterization (so a higher
/// `pixel_size.x` on the same `world_size.x` produces a crisper
/// panel at a cost of more atlas memory).
pub pixel_size: Vec2,
/// Panel dimensions in world units. Together with `pixel_size` this
/// gives the pixels-per-world-unit ratio the MVP uses.
pub world_size: Vec2,
}
impl UiPanel {
/// Build a panel with the given UI document and dimensions. Equivalent
/// to the struct literal; kept as a function so the API can grow
/// validation later without breaking callers.
pub fn new(root: Widget, pixel_size: Vec2, world_size: Vec2) -> Self {
Self {
root,
pixel_size,
world_size,
}
}
/// Convenience: lay out + paint this panel and build the
/// [`UiBatch`](super::super::render::UiBatch) the
/// [`UiOverlayPass`](super::super::render::UiOverlayPass) consumes.
///
/// Returns `None` if the panel's `pixel_size` is non-positive — the
/// caller didn't configure the panel and there's no meaningful
/// rendering to do.
pub fn build_batch(
&self,
theme: &Theme,
fonts: &FontStore,
panel_transform: &Transform,
view_projection: Mat4,
) -> Option<super::super::render::UiBatch> {
if self.pixel_size.x <= 0.0 || self.pixel_size.y <= 0.0 {
return None;
}
let viewport = Rect::from_min_size(Vec2::ZERO, self.pixel_size);
let tree = layout(&self.root, viewport, 1.0);
let painted = paint(&self.root, &tree, theme, fonts, 1.0);
Some(super::super::render::UiBatch::world_space(
painted,
self.pixel_size,
self.world_size,
panel_transform,
view_projection,
))
}
}
#[cfg(test)]
mod tests {
use super::super::style::{LayoutStyle, Sizing};
use super::super::visual::VisualStyle;
use super::*;
use crate::math::{Color, Vec3};
#[test]
fn build_batch_returns_none_on_zero_pixel_size() {
let panel = UiPanel::new(Widget::default(), Vec2::ZERO, Vec2::new(2.0, 2.0));
let theme = Theme::new();
let fonts = FontStore::new();
let result = panel.build_batch(&theme, &fonts, &Transform::default(), Mat4::IDENTITY);
assert!(result.is_none());
}
#[test]
fn build_batch_succeeds_with_valid_panel() {
let root = Widget::default()
.with_style(LayoutStyle {
width: Sizing::Grow(1.0),
height: Sizing::Grow(1.0),
..Default::default()
})
.with_visual(VisualStyle {
background: Some(Color::RED),
..VisualStyle::EMPTY
});
let panel = UiPanel::new(root, Vec2::new(256.0, 128.0), Vec2::new(2.0, 1.0));
let theme = Theme::new();
let fonts = FontStore::new();
let batch = panel
.build_batch(&theme, &fonts, &Transform::default(), Mat4::IDENTITY)
.expect("valid panel should build a batch");
// The batch's painted frame matches the panel's pixel size and has
// one Quad command (the red background).
assert_eq!(batch.frame.size, Vec2::new(256.0, 128.0));
assert_eq!(batch.frame.commands.len(), 1);
}
#[test]
fn panel_round_trips_through_ron() {
let panel = UiPanel::new(
Widget::row().with_id("hud").with_visual(VisualStyle {
background: Some(Color::WHITE),
..VisualStyle::EMPTY
}),
Vec2::new(1024.0, 768.0),
Vec2::new(4.0, 3.0),
);
let text = ron::ser::to_string_pretty(&panel, ron::ser::PrettyConfig::default()).unwrap();
let decoded: UiPanel = ron::de::from_str(&text).unwrap();
assert_eq!(panel, decoded);
}
#[test]
fn identity_mvp_keeps_pixel_origin_at_panel_centre() {
// Sanity: with an identity view-projection and default panel
// transform, a vertex at (0, 0) in pixel space lands at the top-
// left of the panel in world space, which under our MVP becomes
// (-world.x/2, +world.y/2, 0) (y-down → y-up).
let panel = UiPanel::new(
Widget::default()
.with_style(LayoutStyle {
width: Sizing::Grow(1.0),
height: Sizing::Grow(1.0),
..Default::default()
})
.with_visual(VisualStyle {
background: Some(Color::RED),
..VisualStyle::EMPTY
}),
Vec2::new(2.0, 2.0),
Vec2::new(2.0, 2.0),
);
let batch = panel
.build_batch(
&Theme::new(),
&FontStore::new(),
&Transform::default(),
Mat4::IDENTITY,
)
.unwrap();
// Apply the MVP to the pixel-space top-left (0, 0, 0, 1).
let top_left = batch.mvp * Vec3::new(0.0, 0.0, 0.0).extend(1.0);
assert!(
(top_left.x - -1.0).abs() < 1e-5 && (top_left.y - 1.0).abs() < 1e-5,
"top-left should map to (-1, 1) under identity MVP, got {top_left:?}"
);
// Bottom-right pixel maps to (+world.x/2, -world.y/2).
let bottom_right = batch.mvp * Vec3::new(2.0, 2.0, 0.0).extend(1.0);
assert!(
(bottom_right.x - 1.0).abs() < 1e-5 && (bottom_right.y - -1.0).abs() < 1e-5,
"bottom-right should map to (1, -1), got {bottom_right:?}"
);
}
}
+642
View File
@@ -0,0 +1,642 @@
//! Input routing — hit-test the UI against the cursor, track hover / press /
//! focus per widget, and tell the host whether the UI captured the frame's
//! input so the game can decide whether to also handle it.
//!
//! Stage 8's UI must *consume input before the game* (PLAN.md): if the
//! cursor is over a button, clicking shouldn't also fire the game-world
//! action bound to that mouse button. The [`Router`] gives the host one
//! object to drive each frame:
//!
//! ```text
//! game loop:
//! input.handle_event(e); ...
//! let frame = router.process(&layout_tree, &input);
//! if !frame.captured_mouse { /* game receives mouse input */ }
//! if !frame.captured_keyboard { /* game receives keys */ }
//! for event in &frame.events { /* run widget callbacks (piece 6) */ }
//! ```
//!
//! The router is purely a state machine over the Stage-7 [`InputState`] and
//! the Stage-8 [`LayoutTree`] — no GPU, no widget callbacks (those land in
//! piece 6). Tests run headlessly.
//!
//! # Hit-test order
//!
//! Hit testing walks [`LayoutTree::nodes`] in **reverse order**. That order
//! matches the paint order (parents-before-children, earlier siblings
//! before later ones — see [`super::paint`]) — so the *last* node drawn
//! is the *first* one tested, which is exactly the topmost interactive
//! widget under the cursor.
//!
//! Anonymous widgets (`WidgetId::default()`) are treated as transparent
//! for hit-test purposes: the router skips them and looks deeper, so a
//! decorative container without an id doesn't block clicks reaching the
//! button inside it.
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use winit::event::MouseButton;
use super::layout::{LayoutNode, LayoutTree};
use super::widget::WidgetId;
use crate::input::InputState;
use crate::math::Vec2;
/// One event emitted by [`Router::process`] for the current frame.
///
/// Events are ordered: hover changes come first, then per-button press /
/// release / click, then focus changes. Callers in piece 6 will dispatch
/// each event to the matching widget's registered callback.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum RouterEvent {
/// The cursor moved onto this widget this frame.
Hovered(WidgetId),
/// The cursor moved off this widget this frame.
Unhovered(WidgetId),
/// A mouse button was pressed while the cursor was over this widget.
Pressed(WidgetId, MouseButton),
/// A mouse button was released while the cursor was over this widget.
/// May or may not be accompanied by a [`Clicked`](Self::Clicked); see
/// the comment on that variant.
Released(WidgetId, MouseButton),
/// A click completed on this widget: the press *and* release happened
/// over the same widget without the cursor leaving in between.
/// Dragging off cancels the click.
Clicked(WidgetId, MouseButton),
/// This widget became the focused widget.
FocusGained(WidgetId),
/// This widget lost focus.
FocusLost(WidgetId),
}
/// What [`Router::process`] produces for one frame.
#[derive(Debug, Default, Clone, PartialEq)]
pub struct RouterFrame {
/// Events emitted this frame, in the order they were observed.
pub events: Vec<RouterEvent>,
/// `true` if the cursor is over any (non-anonymous) widget — the game
/// should not also process this frame's mouse input.
pub captured_mouse: bool,
/// `true` if a widget currently has keyboard focus — the game should
/// not also process this frame's key events.
pub captured_keyboard: bool,
}
impl RouterFrame {
/// `true` if this frame contains a `Clicked` event on `id` for the
/// given mouse button. The immediate-mode pattern: game code calls
/// `if frame.clicked("play", MouseButton::Left) { start_game() }`
/// instead of registering a callback.
pub fn clicked(&self, id: impl AsRef<str>, button: MouseButton) -> bool {
let id = id.as_ref();
self.events.iter().any(|e| match e {
RouterEvent::Clicked(w, b) => w.as_str() == id && *b == button,
_ => false,
})
}
/// Shorthand for [`clicked`](Self::clicked) with the left button.
pub fn clicked_left(&self, id: impl AsRef<str>) -> bool {
self.clicked(id, MouseButton::Left)
}
/// `true` if this frame contains a `Pressed` event on `id` with the
/// given mouse button.
pub fn pressed(&self, id: impl AsRef<str>, button: MouseButton) -> bool {
let id = id.as_ref();
self.events.iter().any(|e| match e {
RouterEvent::Pressed(w, b) => w.as_str() == id && *b == button,
_ => false,
})
}
/// `true` if this frame contains a `Released` event on `id` with the
/// given mouse button.
pub fn released(&self, id: impl AsRef<str>, button: MouseButton) -> bool {
let id = id.as_ref();
self.events.iter().any(|e| match e {
RouterEvent::Released(w, b) => w.as_str() == id && *b == button,
_ => false,
})
}
/// `true` if the cursor entered `id` this frame.
pub fn hovered_in(&self, id: impl AsRef<str>) -> bool {
let id = id.as_ref();
self.events.iter().any(|e| match e {
RouterEvent::Hovered(w) => w.as_str() == id,
_ => false,
})
}
/// `true` if the cursor left `id` this frame.
pub fn hovered_out(&self, id: impl AsRef<str>) -> bool {
let id = id.as_ref();
self.events.iter().any(|e| match e {
RouterEvent::Unhovered(w) => w.as_str() == id,
_ => false,
})
}
/// `true` if `id` gained focus this frame.
pub fn focus_gained(&self, id: impl AsRef<str>) -> bool {
let id = id.as_ref();
self.events.iter().any(|e| match e {
RouterEvent::FocusGained(w) => w.as_str() == id,
_ => false,
})
}
/// `true` if `id` lost focus this frame.
pub fn focus_lost(&self, id: impl AsRef<str>) -> bool {
let id = id.as_ref();
self.events.iter().any(|e| match e {
RouterEvent::FocusLost(w) => w.as_str() == id,
_ => false,
})
}
}
/// Per-widget input state machine. Persists hover / focus / pending-press
/// across frames so click-detection (press *and* release on the same
/// widget) works correctly across the multiple frames a click typically
/// spans.
#[derive(Debug, Default)]
pub struct Router {
hovered: Option<WidgetId>,
focused: Option<WidgetId>,
/// Per-button: the widget that received the most recent un-released
/// press. A click completes if the release happens over the same
/// widget; otherwise the press is cancelled (drag-off semantics).
pending: HashMap<MouseButton, WidgetId>,
}
impl Router {
/// Build an empty router with no hover, no focus, and no pending presses.
pub fn new() -> Self {
Self::default()
}
/// The currently hovered widget, or `None` when the cursor is not over
/// any addressable widget.
pub fn hovered(&self) -> Option<&WidgetId> {
self.hovered.as_ref()
}
/// The currently focused widget, or `None` if none.
pub fn focused(&self) -> Option<&WidgetId> {
self.focused.as_ref()
}
/// Explicitly focus a widget (e.g., from game code after opening a
/// menu). Emits no event — the caller decided to do this.
pub fn set_focused(&mut self, id: Option<WidgetId>) {
self.focused = id;
}
/// Run the input pipeline against one frame's [`InputState`] and the
/// current [`LayoutTree`]. Updates internal state, returns events plus
/// the capture flags.
pub fn process(&mut self, tree: &LayoutTree, input: &InputState) -> RouterFrame {
let mut frame = RouterFrame::default();
let new_hover = input
.cursor()
.and_then(|c| hit_test(tree, c))
.map(|node| node.id.clone());
// Hover transitions.
if new_hover != self.hovered {
if let Some(old) = self.hovered.take() {
frame.events.push(RouterEvent::Unhovered(old));
}
if let Some(new) = new_hover.clone() {
frame.events.push(RouterEvent::Hovered(new));
}
}
self.hovered = new_hover;
frame.captured_mouse = self.hovered.is_some();
// Mouse press / release per button. The Stage-7 InputState
// exposes "buttons held" + per-button edge flags; we walk the
// currently-relevant buttons (those held this frame *or* present
// as pending from previous frames).
let mut buttons = std::collections::HashSet::new();
buttons.extend(input.mouse_buttons_held());
buttons.extend(self.pending.keys().copied());
// Common buttons that may have just pressed/released without being
// held now (release edge happens after the held set has cleared
// the button).
for b in [MouseButton::Left, MouseButton::Right, MouseButton::Middle] {
if input.mouse_pressed(b) || input.mouse_released(b) {
buttons.insert(b);
}
}
for button in buttons {
if input.mouse_pressed(button) {
if let Some(target) = self.hovered.clone() {
frame
.events
.push(RouterEvent::Pressed(target.clone(), button));
self.pending.insert(button, target.clone());
self.update_focus(Some(target), &mut frame);
} else {
// Click outside any widget clears focus.
self.update_focus(None, &mut frame);
}
}
if input.mouse_released(button) {
if let Some(pending_id) = self.pending.remove(&button) {
if let Some(current) = self.hovered.clone() {
frame
.events
.push(RouterEvent::Released(current.clone(), button));
if current == pending_id {
frame.events.push(RouterEvent::Clicked(current, button));
}
} else {
// Drag-off then release: cancel the click. No
// Released event has a target either, since we
// require a hovered widget for that.
}
}
}
}
frame.captured_keyboard = self.focused.is_some();
frame
}
/// Move focus to `next` (or clear it when `None`), emitting `FocusLost`
/// / `FocusGained` events. Idempotent when `next` matches the current
/// focus.
fn update_focus(&mut self, next: Option<WidgetId>, frame: &mut RouterFrame) {
if next == self.focused {
return;
}
if let Some(old) = self.focused.take() {
frame.events.push(RouterEvent::FocusLost(old));
}
if let Some(new) = next.clone() {
frame.events.push(RouterEvent::FocusGained(new));
}
self.focused = next;
}
}
/// Hit-test `point` against the laid-out widgets. Returns the topmost
/// (most-recently-painted) [`LayoutNode`] with a non-empty id whose `rect`
/// contains the point, or `None` if no addressable widget is under the
/// point.
///
/// Anonymous widgets (empty `id`) are skipped so a decorative container
/// doesn't block hits on the button it contains. Iteration is in reverse
/// node order — children and later siblings (drawn on top) are tested
/// before their parents.
pub fn hit_test(tree: &LayoutTree, point: Vec2) -> Option<&LayoutNode> {
for node in tree.nodes().iter().rev() {
if node.id.is_empty() {
continue;
}
if node.rect.contains_point(point) {
return Some(node);
}
}
None
}
#[cfg(test)]
mod tests {
use super::super::layout::layout;
use super::super::style::{LayoutStyle, Sizing};
use super::super::widget::Widget;
use super::*;
use crate::math::Rect;
fn viewport(w: f32, h: f32) -> Rect {
Rect::from_min_size(Vec2::ZERO, Vec2::new(w, h))
}
fn make_tree() -> (Widget, LayoutTree) {
// Root container with two side-by-side leaves: "left" and "right".
let root = Widget::row()
.with_id("root")
.with_style(LayoutStyle {
width: Sizing::Fixed(200.0),
height: Sizing::Fixed(100.0),
..Default::default()
})
.with_child(
Widget::leaf(Vec2::new(100.0, 100.0))
.with_id("left")
.with_style(LayoutStyle {
width: Sizing::Fixed(100.0),
height: Sizing::Fixed(100.0),
..Default::default()
}),
)
.with_child(
Widget::leaf(Vec2::new(100.0, 100.0))
.with_id("right")
.with_style(LayoutStyle {
width: Sizing::Fixed(100.0),
height: Sizing::Fixed(100.0),
..Default::default()
}),
);
let tree = layout(&root, viewport(400.0, 200.0), 1.0);
(root, tree)
}
#[test]
fn hit_test_returns_topmost_widget_with_id() {
let (_root, tree) = make_tree();
// Cursor over the left child → returns "left", not "root".
let hit = hit_test(&tree, Vec2::new(50.0, 50.0)).unwrap();
assert_eq!(hit.id.as_str(), "left");
// Cursor over the right child → "right".
let hit = hit_test(&tree, Vec2::new(150.0, 50.0)).unwrap();
assert_eq!(hit.id.as_str(), "right");
}
#[test]
fn hit_test_falls_back_to_parent_when_children_dont_cover() {
// Root 200×100 with 20-pixel padding, containing one 80×60 button.
// The padding gutter is "root-only" space — clicks there should
// resolve to "root", not the button.
let root = Widget::row()
.with_id("root")
.with_style(LayoutStyle {
width: Sizing::Fixed(200.0),
height: Sizing::Fixed(100.0),
padding: super::super::style::Insets::all(20.0),
..Default::default()
})
.with_child(
Widget::leaf(Vec2::new(80.0, 60.0))
.with_id("button")
.with_style(LayoutStyle {
width: Sizing::Fixed(80.0),
height: Sizing::Fixed(60.0),
..Default::default()
}),
);
let tree = layout(&root, viewport(400.0, 200.0), 1.0);
// Inside the button.
let hit = hit_test(&tree, Vec2::new(60.0, 50.0)).unwrap();
assert_eq!(hit.id.as_str(), "button");
// Inside root's padding gutter (10, 50) → root, not button.
let hit = hit_test(&tree, Vec2::new(10.0, 50.0)).unwrap();
assert_eq!(hit.id.as_str(), "root");
}
#[test]
fn hit_test_skips_anonymous_widgets() {
// A button buried inside two anonymous containers should still hit.
let root = Widget::row()
.with_style(LayoutStyle {
width: Sizing::Fixed(200.0),
height: Sizing::Fixed(100.0),
..Default::default()
})
.with_child(
Widget::row()
.with_style(LayoutStyle {
width: Sizing::Fixed(100.0),
height: Sizing::Fixed(100.0),
..Default::default()
})
.with_child(
Widget::leaf(Vec2::new(80.0, 80.0))
.with_id("button")
.with_style(LayoutStyle {
width: Sizing::Fixed(80.0),
height: Sizing::Fixed(80.0),
..Default::default()
}),
),
);
let tree = layout(&root, viewport(200.0, 100.0), 1.0);
let hit = hit_test(&tree, Vec2::new(20.0, 20.0)).unwrap();
assert_eq!(hit.id.as_str(), "button");
}
#[test]
fn hit_test_returns_none_outside_root() {
let (_root, tree) = make_tree();
let hit = hit_test(&tree, Vec2::new(500.0, 500.0));
assert!(hit.is_none());
}
#[test]
fn cursor_moving_onto_widget_emits_hovered_event() {
let (_root, tree) = make_tree();
let mut router = Router::new();
let mut input = InputState::new();
// First frame: cursor outside, no hover.
input.set_cursor(Vec2::new(500.0, 500.0));
let f = router.process(&tree, &input);
assert!(f.events.is_empty());
assert!(!f.captured_mouse);
// Move into the left widget.
input.set_cursor(Vec2::new(50.0, 50.0));
let f = router.process(&tree, &input);
assert_eq!(f.events, vec![RouterEvent::Hovered("left".into())]);
assert!(f.captured_mouse);
assert_eq!(router.hovered(), Some(&"left".into()));
}
#[test]
fn cursor_moving_off_emits_unhovered() {
let (_root, tree) = make_tree();
let mut router = Router::new();
let mut input = InputState::new();
input.set_cursor(Vec2::new(50.0, 50.0));
router.process(&tree, &input);
// Move off the widget.
input.set_cursor(Vec2::new(500.0, 500.0));
let f = router.process(&tree, &input);
assert_eq!(f.events, vec![RouterEvent::Unhovered("left".into())]);
assert!(!f.captured_mouse);
}
#[test]
fn cursor_moving_between_widgets_swaps_hover() {
let (_root, tree) = make_tree();
let mut router = Router::new();
let mut input = InputState::new();
input.set_cursor(Vec2::new(50.0, 50.0));
router.process(&tree, &input);
input.set_cursor(Vec2::new(150.0, 50.0));
let f = router.process(&tree, &input);
// Unhover left, then hover right (both this frame).
assert_eq!(
f.events,
vec![
RouterEvent::Unhovered("left".into()),
RouterEvent::Hovered("right".into()),
]
);
}
#[test]
fn pressing_over_widget_emits_pressed_and_focuses() {
let (_root, tree) = make_tree();
let mut router = Router::new();
let mut input = InputState::new();
input.set_cursor(Vec2::new(50.0, 50.0));
// Frame 1: hover only.
router.process(&tree, &input);
// Frame 2: press the left button while hovering.
input.press_mouse(MouseButton::Left);
let f = router.process(&tree, &input);
assert!(f
.events
.contains(&RouterEvent::Pressed("left".into(), MouseButton::Left)));
assert!(f.events.contains(&RouterEvent::FocusGained("left".into())));
assert_eq!(router.focused(), Some(&"left".into()));
assert!(f.captured_keyboard);
}
#[test]
fn press_then_release_on_same_widget_emits_click() {
let (_root, tree) = make_tree();
let mut router = Router::new();
let mut input = InputState::new();
input.set_cursor(Vec2::new(50.0, 50.0));
router.process(&tree, &input);
input.press_mouse(MouseButton::Left);
router.process(&tree, &input);
input.end_frame(); // clear the press edge
input.release_mouse(MouseButton::Left);
let f = router.process(&tree, &input);
// Released and Clicked, both on "left".
assert!(f
.events
.contains(&RouterEvent::Released("left".into(), MouseButton::Left)));
assert!(f
.events
.contains(&RouterEvent::Clicked("left".into(), MouseButton::Left)));
}
#[test]
fn press_then_drag_off_then_release_does_not_emit_click() {
let (_root, tree) = make_tree();
let mut router = Router::new();
let mut input = InputState::new();
input.set_cursor(Vec2::new(50.0, 50.0));
router.process(&tree, &input);
input.press_mouse(MouseButton::Left);
router.process(&tree, &input);
input.end_frame();
// Drag onto the right widget, then release.
input.set_cursor(Vec2::new(150.0, 50.0));
input.release_mouse(MouseButton::Left);
let f = router.process(&tree, &input);
let clicked = f
.events
.iter()
.any(|e| matches!(e, RouterEvent::Clicked(_, _)));
assert!(!clicked, "drag-off should cancel the click");
}
#[test]
fn pressing_outside_any_widget_clears_focus() {
let (_root, tree) = make_tree();
let mut router = Router::new();
router.set_focused(Some("left".into()));
let mut input = InputState::new();
input.set_cursor(Vec2::new(500.0, 500.0));
input.press_mouse(MouseButton::Left);
let f = router.process(&tree, &input);
assert!(f.events.contains(&RouterEvent::FocusLost("left".into())));
assert_eq!(router.focused(), None);
}
#[test]
fn captured_flags_match_state() {
let (_root, tree) = make_tree();
let mut router = Router::new();
let mut input = InputState::new();
// No cursor, no focus → nothing captured.
let f = router.process(&tree, &input);
assert!(!f.captured_mouse);
assert!(!f.captured_keyboard);
// Cursor over a widget → captures mouse.
input.set_cursor(Vec2::new(50.0, 50.0));
let f = router.process(&tree, &input);
assert!(f.captured_mouse);
assert!(!f.captured_keyboard);
// Press → focuses, captures keyboard too.
input.press_mouse(MouseButton::Left);
let f = router.process(&tree, &input);
assert!(f.captured_keyboard);
}
#[test]
fn cursor_off_screen_does_not_hover() {
let (_root, tree) = make_tree();
let mut router = Router::new();
let input = InputState::new(); // cursor unset
let f = router.process(&tree, &input);
assert!(f.events.is_empty());
assert!(!f.captured_mouse);
}
#[test]
fn router_frame_clicked_query_matches_button_and_id() {
let (_root, tree) = make_tree();
let mut router = Router::new();
let mut input = InputState::new();
input.set_cursor(Vec2::new(50.0, 50.0));
router.process(&tree, &input);
input.press_mouse(MouseButton::Left);
router.process(&tree, &input);
input.end_frame();
input.release_mouse(MouseButton::Left);
let f = router.process(&tree, &input);
// Immediate-mode query: was "left" clicked with Left?
assert!(f.clicked_left("left"));
assert!(f.clicked("left", MouseButton::Left));
// Different id or different button → false.
assert!(!f.clicked_left("right"));
assert!(!f.clicked("left", MouseButton::Right));
}
#[test]
fn router_frame_query_methods_cover_each_event_kind() {
// Build a frame manually with one of each event variant and
// verify each query method matches exactly one.
let f = RouterFrame {
events: vec![
RouterEvent::Hovered("a".into()),
RouterEvent::Unhovered("b".into()),
RouterEvent::Pressed("c".into(), MouseButton::Right),
RouterEvent::Released("d".into(), MouseButton::Middle),
RouterEvent::Clicked("e".into(), MouseButton::Left),
RouterEvent::FocusGained("f".into()),
RouterEvent::FocusLost("g".into()),
],
captured_mouse: true,
captured_keyboard: true,
};
assert!(f.hovered_in("a"));
assert!(f.hovered_out("b"));
assert!(f.pressed("c", MouseButton::Right));
assert!(f.released("d", MouseButton::Middle));
assert!(f.clicked("e", MouseButton::Left));
assert!(f.focus_gained("f"));
assert!(f.focus_lost("g"));
// Negative checks.
assert!(!f.hovered_in("b"));
assert!(!f.clicked_left("c")); // Pressed, not Clicked
}
}
+354
View File
@@ -0,0 +1,354 @@
//! Layout style primitives — sizing, padding, margin, alignment, and anchors.
//!
//! Every Stage-8 widget carries a [`LayoutStyle`] that tells the layout
//! algorithm how to size and position it inside its parent's content rect.
//! The primitives here are deliberately small and orthogonal so they compose
//! into the three layout modes (stack, grid, anchor) without each mode
//! introducing its own bespoke parameters.
//!
//! All linear measurements (`Sizing::Fixed`, [`Insets`] fields, anchor
//! offsets, stack/grid gaps) are in **logical pixels**. The layout function
//! takes a separate `scale` factor (typically the window's DPI scale) and
//! multiplies these values at resolve time, so one widget tree lays out
//! sensibly on a 1× laptop and a 2× HiDPI monitor without per-widget rewrites.
use glam::Vec2;
use serde::{Deserialize, Serialize};
/// How a widget asks to be sized along one axis.
///
/// Sizing interacts with the parent's layout mode:
///
/// - In a stack, the **main axis** sums all `Fixed` and `FitContent` sizes,
/// then divides leftover space among `Grow` siblings by weight. The
/// **cross axis** sizes each child independently (`Grow` fills the parent's
/// cross extent; the other variants behave like the main axis).
/// - In a grid, every child fills its cell, but `Fixed`/`FitContent` cap the
/// child's drawn size and let [`LayoutStyle::align_horizontal`] /
/// [`LayoutStyle::align_vertical`] position the smaller rect inside the
/// cell.
/// - In an anchor parent, child sizing is **ignored** along axes the anchor
/// actually constrains; the anchor + offsets fully determine the child's
/// rect.
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
pub enum Sizing {
/// A fixed size in logical pixels. Multiplied by the layout scale factor.
Fixed(f32),
/// Take a share of the parent's leftover space, weighted by `f32`.
///
/// Two siblings with `Grow(1.0)` split leftover space evenly; `Grow(2.0)`
/// next to `Grow(1.0)` takes 2/3 of it. A non-positive weight contributes
/// nothing and the child collapses to zero on that axis.
Grow(f32),
/// Size to fit the widget's own content — the intrinsic size for leaves,
/// the recursive content extent for containers.
#[default]
FitContent,
}
/// Per-side spacing in logical pixels — used for both padding (inside) and
/// margin (outside).
///
/// Padding shrinks a widget's `content_rect` (children draw inside it); margin
/// reserves space *around* the widget so siblings don't touch it. Both are
/// scaled by the layout scale factor at resolve time.
#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
pub struct Insets {
pub left: f32,
pub right: f32,
pub top: f32,
pub bottom: f32,
}
impl Insets {
pub const ZERO: Self = Self {
left: 0.0,
right: 0.0,
top: 0.0,
bottom: 0.0,
};
/// Same value on every side.
pub const fn all(v: f32) -> Self {
Self {
left: v,
right: v,
top: v,
bottom: v,
}
}
/// Symmetric: one value for left+right, another for top+bottom.
pub const fn symmetric(horizontal: f32, vertical: f32) -> Self {
Self {
left: horizontal,
right: horizontal,
top: vertical,
bottom: vertical,
}
}
/// Combined horizontal extent (`left + right`).
#[inline]
pub fn horizontal(&self) -> f32 {
self.left + self.right
}
/// Combined vertical extent (`top + bottom`).
#[inline]
pub fn vertical(&self) -> f32 {
self.top + self.bottom
}
/// Component-wise scale (used internally by the layout algorithm to apply
/// the DPI factor; exposed for tests that want to verify the scaling).
#[inline]
pub fn scaled(&self, scale: f32) -> Self {
Self {
left: self.left * scale,
right: self.right * scale,
top: self.top * scale,
bottom: self.bottom * scale,
}
}
}
/// Alignment along one axis when a widget is smaller than its slot.
///
/// In a row stack, `align_vertical` decides whether a short child docks to the
/// top, middle, or bottom of the row's content rect. The stack's own
/// [`Stack::main_align`](super::widget::Stack::main_align) does the analogous
/// thing along the **main** axis when all children are sized but don't sum to
/// the full main extent.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum Align {
/// Top / left edge.
#[default]
Start,
/// Centered in the available space.
Center,
/// Bottom / right edge.
End,
}
/// How a child positions itself inside an [`AnchorGroup`](super::widget::AnchorGroup)
/// parent.
///
/// Anchors are two normalized points in `[0, 1]²` (the **anchor rectangle**)
/// plus per-corner offsets in logical pixels. The child's resulting rect is:
///
/// ```text
/// rect.min = parent.content.min + parent.content.size * anchor.min + offset_min * scale
/// rect.max = parent.content.min + parent.content.size * anchor.max + offset_max * scale
/// ```
///
/// This is the standard Unity / Godot anchor formulation: pick two anchor
/// corners (a single point for "follow that corner", a full rectangle for
/// "dock to this edge / fill"), then nudge with offsets. The default is
/// [`Anchor::FILL`].
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Anchor {
pub min: Vec2,
pub max: Vec2,
pub offset_min: Vec2,
pub offset_max: Vec2,
}
impl Anchor {
/// Fill the parent's content rect exactly. The default for new widgets.
pub const FILL: Self = Self {
min: Vec2::ZERO,
max: Vec2::ONE,
offset_min: Vec2::ZERO,
offset_max: Vec2::ZERO,
};
/// Pin to the top-left corner with `offset_max` controlling the child's
/// size (which is otherwise zero because `min == max`).
pub const TOP_LEFT: Self = Self {
min: Vec2::ZERO,
max: Vec2::ZERO,
offset_min: Vec2::ZERO,
offset_max: Vec2::ZERO,
};
/// Pin to the top-right corner.
pub const TOP_RIGHT: Self = Self {
min: Vec2::new(1.0, 0.0),
max: Vec2::new(1.0, 0.0),
offset_min: Vec2::ZERO,
offset_max: Vec2::ZERO,
};
/// Pin to the bottom-left corner.
pub const BOTTOM_LEFT: Self = Self {
min: Vec2::new(0.0, 1.0),
max: Vec2::new(0.0, 1.0),
offset_min: Vec2::ZERO,
offset_max: Vec2::ZERO,
};
/// Pin to the bottom-right corner.
pub const BOTTOM_RIGHT: Self = Self {
min: Vec2::ONE,
max: Vec2::ONE,
offset_min: Vec2::ZERO,
offset_max: Vec2::ZERO,
};
/// Dock to the top edge — full width, child height controlled by
/// `offset_max.y`.
pub const TOP: Self = Self {
min: Vec2::ZERO,
max: Vec2::new(1.0, 0.0),
offset_min: Vec2::ZERO,
offset_max: Vec2::ZERO,
};
/// Dock to the bottom edge — full width, child height controlled by
/// `offset_min.y` (negative pushes the top edge upward).
pub const BOTTOM: Self = Self {
min: Vec2::new(0.0, 1.0),
max: Vec2::ONE,
offset_min: Vec2::ZERO,
offset_max: Vec2::ZERO,
};
/// Dock to the left edge — full height, child width via `offset_max.x`.
pub const LEFT: Self = Self {
min: Vec2::ZERO,
max: Vec2::new(0.0, 1.0),
offset_min: Vec2::ZERO,
offset_max: Vec2::ZERO,
};
/// Dock to the right edge — full height, child width via `offset_min.x`
/// (negative widens the child leftward).
pub const RIGHT: Self = Self {
min: Vec2::new(1.0, 0.0),
max: Vec2::ONE,
offset_min: Vec2::ZERO,
offset_max: Vec2::ZERO,
};
/// Construct an anchor with explicit corner pair (offsets zero).
pub const fn between(min: Vec2, max: Vec2) -> Self {
Self {
min,
max,
offset_min: Vec2::ZERO,
offset_max: Vec2::ZERO,
}
}
/// Add fixed offsets in logical pixels to the resolved corners.
pub const fn with_offsets(mut self, offset_min: Vec2, offset_max: Vec2) -> Self {
self.offset_min = offset_min;
self.offset_max = offset_max;
self
}
}
impl Default for Anchor {
fn default() -> Self {
Self::FILL
}
}
/// Combined style controlling how a widget sizes, spaces, and aligns itself
/// inside its parent's slot.
///
/// `LayoutStyle` is deliberately one flat struct (rather than per-axis or
/// per-mode sub-structs) because every widget needs the same fields and most
/// of them are zero by default. Tests and authors can write
/// `LayoutStyle::default()` and only set the fields they care about.
#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
pub struct LayoutStyle {
/// Horizontal sizing rule.
pub width: Sizing,
/// Vertical sizing rule.
pub height: Sizing,
/// Space *inside* this widget's rect, before children are arranged.
pub padding: Insets,
/// Space *outside* this widget's rect, reserved in the parent's layout
/// before computing leftover space.
pub margin: Insets,
/// Horizontal alignment when this widget's resolved width is smaller than
/// the slot the parent gave it.
pub align_horizontal: Align,
/// Vertical alignment when this widget's resolved height is smaller than
/// the slot the parent gave it.
pub align_vertical: Align,
/// Anchor — only consulted when this widget's parent is an
/// [`AnchorGroup`](super::widget::AnchorGroup); ignored otherwise.
pub anchor: Anchor,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_layout_style_is_fit_content_fill_anchor() {
let s = LayoutStyle::default();
assert_eq!(s.width, Sizing::FitContent);
assert_eq!(s.height, Sizing::FitContent);
assert_eq!(s.padding, Insets::ZERO);
assert_eq!(s.margin, Insets::ZERO);
assert_eq!(s.align_horizontal, Align::Start);
assert_eq!(s.align_vertical, Align::Start);
assert_eq!(s.anchor, Anchor::FILL);
}
#[test]
fn insets_helpers_are_correct() {
let i = Insets::all(4.0);
assert_eq!(i.left, 4.0);
assert_eq!(i.right, 4.0);
assert_eq!(i.top, 4.0);
assert_eq!(i.bottom, 4.0);
assert_eq!(i.horizontal(), 8.0);
assert_eq!(i.vertical(), 8.0);
let s = Insets::symmetric(2.0, 6.0);
assert_eq!(s.horizontal(), 4.0);
assert_eq!(s.vertical(), 12.0);
let scaled = i.scaled(2.0);
assert_eq!(scaled, Insets::all(8.0));
}
#[test]
fn anchor_constants_match_doc_corners() {
// FILL spans the whole parent.
assert_eq!(Anchor::FILL.min, Vec2::ZERO);
assert_eq!(Anchor::FILL.max, Vec2::ONE);
// Each corner pin collapses to a point.
assert_eq!(Anchor::TOP_LEFT.min, Anchor::TOP_LEFT.max);
assert_eq!(Anchor::TOP_RIGHT.min, Vec2::new(1.0, 0.0));
assert_eq!(Anchor::BOTTOM_LEFT.max, Vec2::new(0.0, 1.0));
assert_eq!(Anchor::BOTTOM_RIGHT.min, Vec2::ONE);
// Edge docks span one full axis.
assert_eq!(Anchor::TOP.min, Vec2::ZERO);
assert_eq!(Anchor::TOP.max, Vec2::new(1.0, 0.0));
assert_eq!(Anchor::BOTTOM.min, Vec2::new(0.0, 1.0));
assert_eq!(Anchor::LEFT.max, Vec2::new(0.0, 1.0));
assert_eq!(Anchor::RIGHT.min, Vec2::new(1.0, 0.0));
}
#[test]
fn layout_style_round_trips_through_ron() {
let s = LayoutStyle {
width: Sizing::Grow(2.0),
height: Sizing::Fixed(48.0),
padding: Insets::all(8.0),
margin: Insets::symmetric(4.0, 2.0),
align_horizontal: Align::Center,
align_vertical: Align::End,
anchor: Anchor::TOP_RIGHT.with_offsets(Vec2::new(-100.0, 0.0), Vec2::ZERO),
};
let text = ron::ser::to_string_pretty(&s, ron::ser::PrettyConfig::default()).unwrap();
let decoded: LayoutStyle = ron::de::from_str(&text).unwrap();
assert_eq!(s, decoded);
}
}
+426
View File
@@ -0,0 +1,426 @@
//! Glyph atlas — packs rasterized glyphs into one R8 alpha texture, caches
//! them by (font, glyph, size), and exposes UV regions the renderer draws as
//! textured quads.
//!
//! The atlas **is** the cache: every glyph is rasterized exactly once per
//! `(FontId, GlyphId, size_px)` triple and reused for the rest of the
//! process's lifetime. The performance discussion in the Stage-8 design
//! notes assumes this — a HUD that repaints the same characters every frame
//! never re-rasterizes after warm-up.
//!
//! # Packer choice
//!
//! Piece 3 uses a **shelf packer**: glyphs are arranged in horizontal rows
//! ("shelves") whose height is the height of the first glyph that opened the
//! shelf. Subsequent glyphs either fit horizontally on an existing shelf
//! (height ≤ shelf height) or start a new shelf below. This is the standard
//! choice for monotonically-growing glyph atlases — simple, deterministic,
//! near-optimal density for typically-uniform glyph heights, and easy to
//! grow (later: multi-page atlases) when full.
//!
//! Piece 3 does **not** evict. With a 1024×1024 R8 atlas the typical Western
//! UI uses a single-digit-percent fraction; CJK or many-size scenarios that
//! actually run out are handled by piece-4 follow-ups (multi-page atlases
//! or LRU per page).
use std::collections::HashMap;
use glam::Vec2;
use serde::{Deserialize, Serialize};
use super::font::{FontId, FontStore, GlyphId};
/// Cache key for one rasterized glyph.
///
/// `size_px` is rounded to the nearest pixel before being used as the key —
/// distinct 23.4-pixel and 23.6-pixel renderings would otherwise produce
/// different atlas entries despite being visually indistinguishable.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct GlyphKey {
pub font: FontId,
pub glyph: GlyphId,
pub size_px: u16,
}
impl GlyphKey {
/// Build a key, rounding `size_px` to the nearest pixel.
pub fn new(font: FontId, glyph: GlyphId, size_px: f32) -> Self {
Self {
font,
glyph,
size_px: size_px.round().max(1.0) as u16,
}
}
}
/// One glyph's packed location inside the atlas plus the metrics the
/// renderer needs to position its quad on a baseline.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AtlasEntry {
/// Top-left UV (normalized to `[0, 1]`).
pub uv_min: Vec2,
/// Bottom-right UV.
pub uv_max: Vec2,
/// Width / height of the packed region in **pixels**, so the renderer
/// can size the quad without re-querying the atlas dimensions.
pub size_px: Vec2,
/// Offset from the glyph's pen position to the top-left of the quad,
/// in pixels (`bearing.x` left/right, `bearing.y` from the **baseline**;
/// negative `y` means the glyph extends above the baseline).
pub bearing: Vec2,
/// Horizontal advance for the next glyph at this size.
pub advance_px: f32,
}
/// CPU-side glyph atlas — owns the alpha buffer, the packer state, and the
/// `(GlyphKey -> AtlasEntry)` cache.
///
/// A piece-4 GPU follow-up will upload [`pixels`](Self::pixels) into a
/// single R8 texture and re-upload only the dirty region when new glyphs are
/// packed. Piece 3 stays pixel-buffer-only so every test runs headlessly.
#[derive(Debug)]
pub struct GlyphAtlas {
width: u32,
height: u32,
pixels: Vec<u8>,
cache: HashMap<GlyphKey, AtlasEntry>,
packer: ShelfPacker,
dirty: bool,
}
impl GlyphAtlas {
/// Allocate a fresh `width × height` R8 atlas (one byte per pixel,
/// initially zero).
pub fn new(width: u32, height: u32) -> Self {
Self {
width,
height,
pixels: vec![0u8; (width as usize) * (height as usize)],
cache: HashMap::new(),
packer: ShelfPacker::new(width, height),
dirty: false,
}
}
/// `(width, height)` in pixels.
pub fn size(&self) -> (u32, u32) {
(self.width, self.height)
}
/// Raw alpha buffer (`width * height` bytes, row-major). The piece-4
/// render pass will upload this into an R8 texture; tests assert on it
/// directly.
pub fn pixels(&self) -> &[u8] {
&self.pixels
}
/// Look up an entry, rasterizing and packing if not yet present.
///
/// Returns `None` if the glyph has no outline (e.g., a space — the
/// shaper still positions it via the font's advance) **or** the atlas
/// has no room for the rasterized bitmap. A space-glyph miss is
/// indistinguishable from a packing failure by signature; in practice
/// the shaper handles both the same way (skip the quad, keep the
/// advance).
pub fn get_or_rasterize(&mut self, key: GlyphKey, fonts: &FontStore) -> Option<AtlasEntry> {
if let Some(entry) = self.cache.get(&key) {
return Some(*entry);
}
let font = fonts.get(key.font)?;
let raster = font.rasterize(key.glyph, key.size_px as f32)?;
let (x, y) = self.packer.pack(raster.width, raster.height)?;
// Blit the alpha mask into the atlas at (x, y).
let aw = self.width as usize;
for row in 0..raster.height as usize {
let src_start = row * raster.width as usize;
let dst_start = (y as usize + row) * aw + x as usize;
self.pixels[dst_start..dst_start + raster.width as usize]
.copy_from_slice(&raster.bitmap[src_start..src_start + raster.width as usize]);
}
self.dirty = true;
let w = self.width as f32;
let h = self.height as f32;
let entry = AtlasEntry {
uv_min: Vec2::new(x as f32 / w, y as f32 / h),
uv_max: Vec2::new(
(x + raster.width) as f32 / w,
(y + raster.height) as f32 / h,
),
size_px: Vec2::new(raster.width as f32, raster.height as f32),
bearing: Vec2::new(raster.bearing_x, raster.bearing_y),
advance_px: raster.advance_x,
};
self.cache.insert(key, entry);
Some(entry)
}
/// Borrow an entry that's already cached, without triggering
/// rasterization. Useful when the renderer wants to draw only glyphs the
/// atlas already knows.
pub fn get(&self, key: &GlyphKey) -> Option<&AtlasEntry> {
self.cache.get(key)
}
/// Number of cached glyphs.
pub fn len(&self) -> usize {
self.cache.len()
}
/// `true` if no glyphs are cached.
pub fn is_empty(&self) -> bool {
self.cache.is_empty()
}
/// `true` if [`get_or_rasterize`](Self::get_or_rasterize) added at least
/// one glyph since the last [`clear_dirty`](Self::clear_dirty). The
/// piece-4 render pass checks this before re-uploading the texture.
pub fn dirty(&self) -> bool {
self.dirty
}
/// Clear the dirty flag. Call after uploading the texture.
pub fn clear_dirty(&mut self) {
self.dirty = false;
}
}
// ---------- shelf packer ----------
#[derive(Debug)]
struct ShelfPacker {
width: u32,
height: u32,
shelves: Vec<Shelf>,
next_y: u32,
}
#[derive(Debug)]
struct Shelf {
y: u32,
height: u32,
cursor_x: u32,
}
impl ShelfPacker {
fn new(width: u32, height: u32) -> Self {
Self {
width,
height,
shelves: Vec::new(),
next_y: 0,
}
}
fn pack(&mut self, w: u32, h: u32) -> Option<(u32, u32)> {
if w > self.width || h > self.height {
return None;
}
// Prefer the tightest-fitting existing shelf that still has
// horizontal room — keeps shelf heights stable and packs short
// glyphs against short glyphs.
let mut best: Option<usize> = None;
let mut best_waste = u32::MAX;
for (i, shelf) in self.shelves.iter().enumerate() {
if shelf.cursor_x + w <= self.width && h <= shelf.height {
let waste = shelf.height - h;
if waste < best_waste {
best = Some(i);
best_waste = waste;
}
}
}
if let Some(i) = best {
let shelf = &mut self.shelves[i];
let x = shelf.cursor_x;
let y = shelf.y;
shelf.cursor_x += w;
return Some((x, y));
}
// No existing shelf fits — open a new one at `next_y` if there's
// vertical room.
if self.next_y + h > self.height {
return None;
}
let y = self.next_y;
self.next_y += h;
self.shelves.push(Shelf {
y,
height: h,
cursor_x: w,
});
Some((0, y))
}
}
#[cfg(test)]
mod tests {
use super::super::font::try_load_system_font;
use super::*;
use crate::ui::visual::FontRef;
#[test]
fn key_rounds_size_to_nearest_pixel() {
let k1 = GlyphKey::new(FontId(0), GlyphId(1), 23.4);
let k2 = GlyphKey::new(FontId(0), GlyphId(1), 23.6);
assert_eq!(k1.size_px, 23);
assert_eq!(k2.size_px, 24);
assert_ne!(k1, k2);
}
#[test]
fn key_clamps_sub_pixel_size_to_one() {
// A 0.4-pixel font would otherwise round to zero, producing a useless
// key. The packer requires width ≥ 1.
let k = GlyphKey::new(FontId(0), GlyphId(1), 0.4);
assert_eq!(k.size_px, 1);
}
#[test]
fn shelf_packer_fits_glyphs_in_order() {
let mut p = ShelfPacker::new(64, 64);
// First glyph opens a shelf at y=0 with height 10.
assert_eq!(p.pack(20, 10), Some((0, 0)));
// Second glyph fits on the same shelf — same y, advanced cursor.
assert_eq!(p.pack(20, 10), Some((20, 0)));
// Third glyph: doesn't fit horizontally on shelf 0; opens shelf 1
// at y=10.
assert_eq!(p.pack(40, 8), Some((0, 10)));
// Tall glyph that fits horizontally on neither existing shelf opens
// shelf 2 at y=18.
assert_eq!(p.pack(64, 20), Some((0, 18)));
}
#[test]
fn shelf_packer_prefers_tight_fit_among_existing_shelves() {
let mut p = ShelfPacker::new(64, 64);
// Open shelf 0 at y=0 with height 20, occupying width 50.
assert_eq!(p.pack(50, 20), Some((0, 0)));
// A 50-wide 8-tall glyph won't fit horizontally on shelf 0
// (50 + 50 = 100 > 64) — that forces shelf 1 open at y=20 with
// height 8.
assert_eq!(p.pack(50, 8), Some((0, 20)));
// Now pack a 10×8 glyph: shelf 0 (waste 12) and shelf 1 (waste 0)
// both fit horizontally, so the tight-fit shelf 1 wins.
assert_eq!(p.pack(10, 8), Some((50, 20)));
}
#[test]
fn shelf_packer_rejects_overflow() {
let mut p = ShelfPacker::new(32, 32);
// First fills almost all the vertical room.
assert_eq!(p.pack(32, 30), Some((0, 0)));
// 4-tall glyph won't fit vertically.
assert_eq!(p.pack(8, 4), None);
// Anything wider than the atlas is also rejected.
let mut p2 = ShelfPacker::new(32, 32);
assert_eq!(p2.pack(40, 4), None);
}
#[test]
fn empty_atlas_has_no_dirty_no_entries() {
let atlas = GlyphAtlas::new(64, 64);
assert_eq!(atlas.size(), (64, 64));
assert!(!atlas.dirty());
assert!(atlas.is_empty());
assert!(atlas.pixels().iter().all(|&p| p == 0));
}
#[test]
fn dirty_flag_lifecycle() {
let Some(font) = try_load_system_font() else {
return;
};
let mut store = FontStore::new();
let id = store.insert(font);
let mut atlas = GlyphAtlas::new(256, 256);
assert!(!atlas.dirty());
let glyph = store.get(id).unwrap().glyph_id('A');
atlas
.get_or_rasterize(GlyphKey::new(id, glyph, 24.0), &store)
.unwrap();
assert!(atlas.dirty());
atlas.clear_dirty();
assert!(!atlas.dirty());
// Second lookup of the same key is a cache hit — no rasterization,
// no new dirty.
atlas
.get_or_rasterize(GlyphKey::new(id, glyph, 24.0), &store)
.unwrap();
assert!(!atlas.dirty());
}
#[test]
fn distinct_glyphs_get_distinct_regions() {
let Some(font) = try_load_system_font() else {
return;
};
let mut store = FontStore::new();
let id = store.insert_with_descriptor(FontRef::regular("System"), font);
let mut atlas = GlyphAtlas::new(512, 512);
let a = store.get(id).unwrap().glyph_id('A');
let b = store.get(id).unwrap().glyph_id('B');
let e_a = atlas
.get_or_rasterize(GlyphKey::new(id, a, 24.0), &store)
.unwrap();
let e_b = atlas
.get_or_rasterize(GlyphKey::new(id, b, 24.0), &store)
.unwrap();
// Different glyphs → different UV rects.
assert_ne!(e_a.uv_min, e_b.uv_min);
// UV rects stay inside `[0, 1]`.
assert!(e_a.uv_min.x >= 0.0 && e_a.uv_max.x <= 1.0);
assert!(e_a.uv_min.y >= 0.0 && e_a.uv_max.y <= 1.0);
assert_eq!(atlas.len(), 2);
}
#[test]
fn space_glyph_returns_none_but_does_not_corrupt_atlas() {
let Some(font) = try_load_system_font() else {
return;
};
let mut store = FontStore::new();
let id = store.insert(font);
let space = store.get(id).unwrap().glyph_id(' ');
let mut atlas = GlyphAtlas::new(128, 128);
assert!(atlas
.get_or_rasterize(GlyphKey::new(id, space, 24.0), &store)
.is_none());
assert!(atlas.is_empty());
assert!(!atlas.dirty());
}
#[test]
fn atlas_pixels_match_rasterized_bitmap_at_packed_region() {
let Some(font) = try_load_system_font() else {
return;
};
let mut store = FontStore::new();
let id = store.insert(font);
let mut atlas = GlyphAtlas::new(128, 128);
let glyph = store.get(id).unwrap().glyph_id('A');
let entry = atlas
.get_or_rasterize(GlyphKey::new(id, glyph, 24.0), &store)
.unwrap();
// Convert the entry's UV back to a pixel rect and verify *some*
// pixel inside it is opaque (i.e., the blit actually happened).
let x = (entry.uv_min.x * 128.0).round() as usize;
let y = (entry.uv_min.y * 128.0).round() as usize;
let w = entry.size_px.x as usize;
let h = entry.size_px.y as usize;
let mut had_opaque = false;
for row in 0..h {
for col in 0..w {
if atlas.pixels()[(y + row) * 128 + (x + col)] > 200 {
had_opaque = true;
}
}
}
assert!(had_opaque, "blitted region should contain opaque pixels");
}
}
+429
View File
@@ -0,0 +1,429 @@
//! Font loading and per-glyph metrics — thin wrapper over [`ab_glyph::FontVec`].
//!
//! The text system stays a layer above the font crate so it can swap
//! rasterizers later (an SDF generator, a different parser) without churning
//! the public Stage-8 API. Every text query a [`super::shape::shape`] or
//! [`super::atlas::GlyphAtlas`] call needs goes through [`Font`]'s methods —
//! `ab_glyph` is never visible to consumers of the engine.
use std::collections::HashMap;
use std::path::Path;
use ab_glyph::{Font as AbFont, FontVec, PxScale, ScaleFont};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use super::super::visual::FontRef;
/// Errors returned from font loading.
#[derive(Debug, Error)]
pub enum FontError {
/// Reading the font file from disk failed.
#[error("font file read failed: {0}")]
Io(#[from] std::io::Error),
/// The bytes were not a valid TTF / OTF font.
#[error("not a valid TTF/OTF font")]
InvalidFont,
}
/// Stable, opaque identifier for a font registered in a [`FontStore`].
///
/// Held in [`GlyphKey`](super::atlas::GlyphKey)s in the atlas and in
/// [`TextStyle`](super::shape::TextStyle)s passed to the shaper, so a font's
/// id never changes once registered. `Copy` + `Hash` so it indexes hash maps
/// cheaply.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct FontId(pub u32);
/// One loaded font — a parsed TTF/OTF that can report metrics and rasterize
/// individual glyphs.
pub struct Font {
inner: FontVec,
}
impl std::fmt::Debug for Font {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Font").finish_non_exhaustive()
}
}
/// Result of rasterizing one glyph at a specific pixel size — the alpha mask
/// plus enough metrics to position it on a baseline.
#[derive(Debug, Clone, PartialEq)]
pub struct RasterizedGlyph {
/// Width of the alpha mask in pixels.
pub width: u32,
/// Height of the alpha mask in pixels.
pub height: u32,
/// X offset from the glyph's pen position to the mask's left edge.
pub bearing_x: f32,
/// Y offset from the glyph's baseline to the mask's top edge (negative
/// for glyphs that extend above the baseline, which is most of them).
pub bearing_y: f32,
/// How far to advance the pen along the baseline before the next glyph.
pub advance_x: f32,
/// Row-major alpha bytes (`width * height` bytes, `0 = transparent`,
/// `255 = opaque`).
pub bitmap: Vec<u8>,
}
impl Font {
/// Parse a TTF/OTF font from raw bytes. Bytes are owned by the [`Font`].
pub fn from_bytes(bytes: Vec<u8>) -> Result<Self, FontError> {
FontVec::try_from_vec(bytes)
.map(|inner| Self { inner })
.map_err(|_| FontError::InvalidFont)
}
/// Load and parse a TTF/OTF file from disk.
pub fn from_path(path: impl AsRef<Path>) -> Result<Self, FontError> {
let bytes = std::fs::read(path.as_ref())?;
Self::from_bytes(bytes)
}
/// The glyph id for a `char`. Returns the font's `notdef` glyph (id `0`)
/// for characters the font does not contain — same behavior as
/// `ab_glyph`.
pub fn glyph_id(&self, ch: char) -> GlyphId {
GlyphId(self.inner.glyph_id(ch).0)
}
/// Horizontal advance for the next glyph at `size_px` logical pixels.
pub fn h_advance_px(&self, glyph: GlyphId, size_px: f32) -> f32 {
self.inner
.as_scaled(PxScale::from(size_px))
.h_advance(ab_glyph::GlyphId(glyph.0))
}
/// Ascender height in pixels at the given size.
pub fn ascent_px(&self, size_px: f32) -> f32 {
self.inner.as_scaled(PxScale::from(size_px)).ascent()
}
/// Descender depth in pixels at the given size. Negative for fonts where
/// the descender sits below the baseline (the common case).
pub fn descent_px(&self, size_px: f32) -> f32 {
self.inner.as_scaled(PxScale::from(size_px)).descent()
}
/// Line gap in pixels — extra leading the font recommends between lines.
pub fn line_gap_px(&self, size_px: f32) -> f32 {
self.inner.as_scaled(PxScale::from(size_px)).line_gap()
}
/// Total recommended line height at `size_px` (ascent descent +
/// line_gap). Multiplied by `TextStyle`'s line-height factor by the
/// shaper.
pub fn line_height_px(&self, size_px: f32) -> f32 {
let scaled = self.inner.as_scaled(PxScale::from(size_px));
scaled.ascent() - scaled.descent() + scaled.line_gap()
}
/// Rasterize a single glyph to an alpha bitmap. Returns `None` for
/// glyphs with no outline (e.g., the space character) — the caller still
/// gets the advance via [`Font::h_advance_px`] and should treat the
/// glyph as zero-area.
pub fn rasterize(&self, glyph: GlyphId, size_px: f32) -> Option<RasterizedGlyph> {
let scale = PxScale::from(size_px);
let scaled = self.inner.as_scaled(scale);
let advance_x = scaled.h_advance(ab_glyph::GlyphId(glyph.0));
let mut positioned = ab_glyph::GlyphId(glyph.0).with_scale(scale);
positioned.position = ab_glyph::point(0.0, 0.0);
let outlined = self.inner.outline_glyph(positioned)?;
let bounds = outlined.px_bounds();
let width = bounds.width().ceil().max(1.0) as u32;
let height = bounds.height().ceil().max(1.0) as u32;
let mut bitmap = vec![0u8; (width as usize) * (height as usize)];
outlined.draw(|x, y, coverage| {
if x < width && y < height {
let idx = (y as usize) * (width as usize) + (x as usize);
bitmap[idx] = (coverage * 255.0).round().clamp(0.0, 255.0) as u8;
}
});
Some(RasterizedGlyph {
width,
height,
bearing_x: bounds.min.x,
bearing_y: bounds.min.y,
advance_x,
bitmap,
})
}
}
/// [`AssetLoader`](crate::asset::AssetLoader) for TTF/OTF fonts.
///
/// Registered by default on every [`AssetServer`](crate::asset::AssetServer), so
/// a font file under a project's `assets/fonts/` can be loaded by path and an
/// [`AssetRef<Font>`](crate::asset::AssetRef) resolved to a [`Handle<Font>`](crate::asset::Handle)
/// — the link that lets the UI canvas pick a font asset and the runtime draw with it.
pub struct FontLoader;
impl crate::asset::AssetLoader for FontLoader {
type Asset = Font;
fn extensions(&self) -> &'static [&'static str] {
&["ttf", "otf"]
}
fn load(&self, path: &Path) -> Result<Font, crate::asset::AssetError> {
Font::from_path(path).map_err(|err| crate::asset::AssetError::Load {
path: path.to_path_buf(),
message: err.to_string(),
})
}
}
/// Opaque per-font glyph index. Mirrors `ab_glyph::GlyphId` but is the only
/// glyph type exposed by the engine, so consumers do not need an `ab_glyph`
/// dependency.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct GlyphId(pub u16);
/// Registry of loaded fonts, indexed by [`FontId`] and (optionally) by
/// [`FontRef`] descriptor.
///
/// Why a descriptor index: piece-2 [`Theme`](super::super::theme::Theme)s
/// store fonts by family + weight + italic (`FontRef`), not by raw bytes.
/// `FontStore::resolve(&font_ref)` turns the descriptor into a [`FontId`] the
/// shaper can use, so a theme like `{ font: Some(FontRef::bold("Inter")) }`
/// works end-to-end as soon as the matching face has been registered.
#[derive(Default)]
pub struct FontStore {
fonts: Vec<Font>,
by_descriptor: HashMap<FontRef, FontId>,
}
impl std::fmt::Debug for FontStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FontStore")
.field("len", &self.fonts.len())
.field("descriptors", &self.by_descriptor.len())
.finish()
}
}
impl FontStore {
/// Create an empty store.
pub fn new() -> Self {
Self::default()
}
/// Register a font with no descriptor — accessible only by its returned
/// [`FontId`]. Useful for one-off uses where the font isn't part of a
/// theme cascade.
pub fn insert(&mut self, font: Font) -> FontId {
let id = FontId(self.fonts.len() as u32);
self.fonts.push(font);
id
}
/// Register a font and associate it with a descriptor.
///
/// Re-registering the same descriptor replaces the previous association
/// but does not free the previous [`FontId`] — both ids continue to
/// reference the now-distinct font. This matches Stage-7 `ActionMap`
/// re-registration semantics: ids are stable, names can be remapped.
pub fn insert_with_descriptor(&mut self, descriptor: FontRef, font: Font) -> FontId {
let id = self.insert(font);
self.by_descriptor.insert(descriptor, id);
id
}
/// Look up a font by `FontId`.
pub fn get(&self, id: FontId) -> Option<&Font> {
self.fonts.get(id.0 as usize)
}
/// Resolve a [`FontRef`] descriptor (piece-2 theme value) to a
/// [`FontId`], if the matching face has been registered.
pub fn resolve(&self, descriptor: &FontRef) -> Option<FontId> {
self.by_descriptor.get(descriptor).copied()
}
/// Number of registered fonts.
pub fn len(&self) -> usize {
self.fonts.len()
}
/// `true` if no fonts are registered.
pub fn is_empty(&self) -> bool {
self.fonts.is_empty()
}
}
/// Common system paths a Linux-style host is likely to have a sans-serif
/// TTF at. Used by tests (and the eventual editor "no theme font set" path)
/// to find *some* font without bundling one.
///
/// Returned in priority order; the first existing path is the one to try.
/// Empty on hosts the search doesn't know about — the caller must handle
/// "no candidate found" gracefully.
pub fn common_system_font_paths() -> &'static [&'static str] {
&[
// Linux distributions:
"/usr/share/fonts/liberation/LiberationSans-Regular.ttf",
"/usr/share/fonts/TTF/DejaVuSans.ttf",
"/usr/share/fonts/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/Adwaita/AdwaitaSans-Regular.ttf",
// macOS:
"/Library/Fonts/Arial.ttf",
"/System/Library/Fonts/Helvetica.ttc",
]
}
/// Try to load a sans-serif font from a well-known system path. Returns
/// `None` (and prints `SKIP:`) if no candidate exists — the same pattern
/// the Stage-4 GPU tests use for "no adapter".
///
/// Test-only helper shared between the `font`, `atlas`, and `shape` modules
/// so the same "skip when no system font" branch isn't duplicated.
#[cfg(test)]
pub(crate) fn try_load_system_font() -> Option<Font> {
for path in common_system_font_paths() {
if Path::new(path).exists() {
match Font::from_path(path) {
Ok(font) => return Some(font),
Err(err) => {
eprintln!("SKIP-candidate: {path} present but failed to load: {err}");
}
}
}
}
eprintln!("SKIP: no system font available at any common Linux/macOS path");
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_garbage_bytes() {
let err = Font::from_bytes(vec![0u8; 32]).unwrap_err();
assert!(matches!(err, FontError::InvalidFont));
}
#[test]
fn missing_file_returns_io_error() {
let err = Font::from_path("/nonexistent/font.ttf").unwrap_err();
assert!(matches!(err, FontError::Io(_)));
}
#[test]
fn font_loader_loads_through_the_asset_server() {
use crate::asset::{AssetRef, AssetServer, AssetUid};
// Find a real font file on disk; skip cleanly if the host has none.
let Some(path) = common_system_font_paths()
.iter()
.map(std::path::Path::new)
.find(|p| p.exists())
else {
eprintln!("SKIP: no system font path available");
return;
};
// The default-registered FontLoader makes `.ttf`/`.otf` loadable.
let server = AssetServer::new();
let handle = server.load::<Font>(path);
assert!(handle.is_loaded(), "font should load: {:?}", handle.error());
// An asset reference to a hypothetical uid resolves to a handle when the
// database hands back this path (proven in asset::database tests); here
// we just confirm the loaded Font is usable.
assert!(handle.get().unwrap().h_advance_px(GlyphId(0), 16.0) >= 0.0);
// AssetRef<Font> is constructible (the field type the UI canvas uses).
let _ = AssetRef::<Font>::new(AssetUid(1));
}
#[test]
fn store_assigns_distinct_ids() {
let Some(a) = try_load_system_font() else {
return;
};
let Some(b) = try_load_system_font() else {
return;
};
let mut store = FontStore::new();
let id_a = store.insert(a);
let id_b = store.insert(b);
assert_ne!(id_a, id_b);
assert_eq!(store.len(), 2);
assert!(store.get(id_a).is_some());
assert!(store.get(id_b).is_some());
assert!(store.get(FontId(99)).is_none());
}
#[test]
fn descriptor_resolves_to_registered_font() {
let Some(font) = try_load_system_font() else {
return;
};
let descriptor = FontRef::regular("System");
let mut store = FontStore::new();
let id = store.insert_with_descriptor(descriptor.clone(), font);
assert_eq!(store.resolve(&descriptor), Some(id));
// A different descriptor with no associated font is None.
assert_eq!(store.resolve(&FontRef::bold("System")), None);
}
#[test]
fn metrics_are_finite_and_non_zero() {
let Some(font) = try_load_system_font() else {
return;
};
let advance = font.h_advance_px(font.glyph_id('A'), 24.0);
assert!(advance.is_finite());
assert!(advance > 0.0);
let ascent = font.ascent_px(24.0);
let descent = font.descent_px(24.0);
assert!(ascent > 0.0);
// ab_glyph's `descent` is negative for descenders below the baseline.
assert!(descent <= 0.0);
assert!(font.line_height_px(24.0) > 0.0);
}
#[test]
fn rasterize_produces_bitmap_for_solid_glyph() {
let Some(font) = try_load_system_font() else {
return;
};
let raster = font
.rasterize(font.glyph_id('A'), 24.0)
.expect("'A' outlines");
assert!(raster.width > 0 && raster.height > 0);
assert_eq!(
raster.bitmap.len(),
(raster.width as usize) * (raster.height as usize)
);
// A capital A at 24px should have at least one fully-opaque pixel
// near its central stroke.
assert!(raster.bitmap.iter().any(|&p| p > 200));
// And some transparent pixels (it's not a solid square).
assert!(raster.bitmap.iter().any(|&p| p < 10));
}
#[test]
fn rasterize_space_returns_none_but_advance_works() {
let Some(font) = try_load_system_font() else {
return;
};
let space = font.glyph_id(' ');
// Space has no outline — rasterize returns None.
assert!(font.rasterize(space, 24.0).is_none());
// But the advance is still positive so the shaper can lay it out.
assert!(font.h_advance_px(space, 24.0) > 0.0);
}
#[test]
fn common_system_font_paths_returns_some_candidates() {
let paths = common_system_font_paths();
assert!(!paths.is_empty());
// Every entry should be an absolute path so the existence check is
// unambiguous on the host.
for p in paths {
assert!(p.starts_with('/'), "{p:?} should be an absolute path");
}
}
}
+60
View File
@@ -0,0 +1,60 @@
//! Text shaping + glyph atlas — piece 3 of the Stage-8 in-game UI system.
//!
//! Three sub-modules cooperate:
//!
//! - [`font`] wraps `ab_glyph::FontVec` behind an engine-owned [`Font`] /
//! [`FontStore`] surface so consumers never see the font crate directly.
//! Adds descriptor-based lookup keyed by the piece-2
//! [`FontRef`](super::visual::FontRef), so a theme's `font: Some(...)`
//! resolves to a [`FontId`] the shaper can use.
//! - [`atlas`] packs rasterized glyphs into one R8 alpha texture via a
//! shelf packer and caches them by [`GlyphKey`]. The atlas **is** the
//! cache — the chosen library never re-rasterizes a glyph that's already
//! been packed, which is why this stage's choice between ab_glyph and
//! fontdue is a one-time-startup decision, not a per-frame one.
//! - [`shape`] turns a sequence of [`TextRun`]s into positioned
//! [`ShapedGlyph`]s with line wrapping, alignment, multi-font runs, and
//! DPI scaling. Pure CPU; never touches the atlas. The renderer
//! (piece 4) walks the [`ShapedText`] output and queries the atlas per
//! glyph to emit textured quads.
//!
//! # End-to-end shape → atlas
//!
//! ```no_run
//! use oxide_engine::ui::text::{
//! shape, FontStore, GlyphAtlas, ShapeParams, ShapedText, TextStyle,
//! };
//! # use oxide_engine::ui::text::Font;
//! # fn load_font() -> Font { todo!() }
//!
//! let mut fonts = FontStore::new();
//! let id = fonts.insert(load_font());
//! let style = TextStyle { font: id, size_px: 16.0 };
//! let shaped: ShapedText = shape("Hello world", style, &ShapeParams::default(), &fonts);
//!
//! let mut atlas = GlyphAtlas::new(1024, 1024);
//! for line in &shaped.lines {
//! for glyph in &line.glyphs {
//! // get_or_rasterize returns None for glyphs with no outline (e.g.
//! // the space character). Real renderers skip emitting a quad.
//! if let Some(entry) = atlas.get_or_rasterize(glyph.key, &fonts) {
//! let _quad_top_left = glyph.position + entry.bearing;
//! let _quad_size = entry.size_px;
//! }
//! }
//! }
//! ```
pub mod atlas;
pub mod font;
pub mod shape;
pub use atlas::{AtlasEntry, GlyphAtlas, GlyphKey};
pub use font::{
common_system_font_paths, Font, FontError, FontId, FontLoader, FontStore, GlyphId,
RasterizedGlyph,
};
pub use shape::{
shape, shape_runs, ShapeParams, ShapedGlyph, ShapedLine, ShapedText, TextAlign, TextRun,
TextStyle,
};
+726
View File
@@ -0,0 +1,726 @@
//! Text shaping — turns a sequence of [`TextRun`]s into positioned glyphs,
//! laid out on baselines, wrapped to a width, and aligned.
//!
//! The shaper does **not** rasterize: it only consults [`Font`](super::font::Font)
//! metrics (ascender, descender, advance width). Each output [`ShapedGlyph`]
//! carries a [`GlyphKey`] the renderer (piece 4) feeds into the atlas to
//! resolve to a textured quad. This split keeps the shaper purely
//! deterministic and CPU-cheap — every test in this module runs without a
//! GPU and most without a font.
//!
//! # Algorithm
//!
//! 1. **Tokenize** each run into items: a `Word` (maximal run of non-
//! whitespace), a `Whitespace` stretch, or a `Break` (`\n`). Each
//! word/whitespace item caches its own width, computed once from the
//! font's per-glyph advance.
//! 2. **Greedy line break**: keep adding items to the current line; on a
//! word that would overflow `max_width`, flush the line and start a new
//! one. Pending inter-word whitespace at the wrap point is **discarded**
//! (it was the gap between lines, not part of either line); leading
//! whitespace on a wrapped line is dropped for the same reason. `\n`
//! forces a flush regardless of width.
//! 3. **Position**: for each line, find the line's `max_ascent` (across the
//! fonts used on it) — that's the baseline offset from the line's top
//! edge — then walk items left-to-right, emitting `ShapedGlyph`s at
//! `(pen_x, baseline_y)` and advancing `pen_x` by each glyph's advance.
//! 4. **Align**: per line, shift glyphs by `align_offset(max_width
//! line_width)` — Left/Center/Right. Without a `max_width`, alignment
//! is degenerate (everything is left-aligned).
//!
//! # Multi-font runs
//!
//! Lines may mix items from different runs (and therefore different fonts).
//! Line metrics (ascent, descent, line height) are taken from the *largest*
//! contribution among the line's items. This is the CSS behavior: a small
//! superscript run on the same line as body text doesn't collapse the
//! baseline.
//!
//! # Limitations (deliberate, scoped to piece 3)
//!
//! - One glyph per `char` (no ligatures, no combining marks, no shaping).
//! ab_glyph does not shape; full Unicode shaping is a `rustybuzz` /
//! `harfbuzz` follow-up.
//! - No BiDi or RTL — text flows left-to-right.
//! - No hyphenation or character-level fallback inside an overflowing word.
//! - Whitespace is ASCII (` `, `\t`, `\r`). `\t` and `\r` are treated as
//! regular spaces.
use glam::Vec2;
use serde::{Deserialize, Serialize};
use super::atlas::GlyphKey;
use super::font::{FontId, FontStore};
/// Per-run style — which font and what point size.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct TextStyle {
pub font: FontId,
/// Logical font size in pixels. Multiplied by [`ShapeParams::scale`] at
/// shape time, so the same `TextStyle` produces correctly-sized output
/// at 1×, 2×, or any other DPI factor.
pub size_px: f32,
}
/// One run of text with a single [`TextStyle`].
///
/// `shape` takes a single run; `shape_runs` takes many for mixed styles
/// (different fonts/sizes/etc. on the same line).
#[derive(Debug, Clone, Copy)]
pub struct TextRun<'a> {
pub text: &'a str,
pub style: TextStyle,
}
/// Horizontal alignment of each line within `max_width`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum TextAlign {
#[default]
Left,
Center,
Right,
}
/// Parameters that apply to the whole shape call: wrapping width, alignment,
/// line-height factor, and the DPI scale factor.
#[derive(Debug, Clone, Copy)]
pub struct ShapeParams {
/// Maximum line width in **post-scale** pixels. `None` disables
/// wrapping (and makes alignment a no-op).
pub max_width: Option<f32>,
/// Horizontal alignment within `max_width`.
pub align: TextAlign,
/// Multiplier applied to each line's natural line height. `1.0` is the
/// font's own recommendation; `1.4` is a comfortable reading default.
pub line_height: f32,
/// DPI scale factor — multiplies every logical `size_px` from the
/// runs. Same role as [`super::super::layout::layout`]'s `scale`.
pub scale: f32,
}
impl Default for ShapeParams {
fn default() -> Self {
Self {
max_width: None,
align: TextAlign::Left,
line_height: 1.0,
scale: 1.0,
}
}
}
/// One positioned glyph in the shaped output.
///
/// `position` is the **pen position at the baseline** — the renderer adds
/// the atlas's per-glyph bearing to convert it into the top-left of the
/// glyph quad. Keeping it at the baseline (rather than at the top-left) is
/// what makes hit testing and caret positioning straightforward in pieces
/// 56.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ShapedGlyph {
pub key: GlyphKey,
pub position: Vec2,
}
/// One shaped line — the glyphs, the line's content width (trailing
/// whitespace excluded), and the line's baseline / total height.
#[derive(Debug, Clone, PartialEq)]
pub struct ShapedLine {
pub glyphs: Vec<ShapedGlyph>,
pub width: f32,
pub baseline_y: f32,
pub line_height: f32,
}
/// Full shaped output — `lines` in vertical order and the overall bounding
/// box `size`. `size.x` is the widest line's width (not `max_width`);
/// `size.y` is the sum of line heights, which equals the height of the
/// rectangle the text fits in.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ShapedText {
pub lines: Vec<ShapedLine>,
pub size: Vec2,
}
/// Shape a single run of text. Convenience wrapper around [`shape_runs`].
pub fn shape(text: &str, style: TextStyle, params: &ShapeParams, fonts: &FontStore) -> ShapedText {
shape_runs(&[TextRun { text, style }], params, fonts)
}
/// Shape one or more runs into a single output. Items from different runs
/// share lines and share alignment, just as if they were one continuous
/// string with mixed styles.
pub fn shape_runs(runs: &[TextRun], params: &ShapeParams, fonts: &FontStore) -> ShapedText {
let mut items: Vec<Item> = Vec::new();
for run in runs {
tokenize_run(run, params.scale, fonts, &mut items);
}
let raw_lines = break_lines(items, params.max_width);
position_lines(raw_lines, params, fonts)
}
// ---------- internals ----------
#[derive(Debug, Clone)]
enum Item {
Word {
font: FontId,
size_px: f32,
width: f32,
// (char, glyph id, advance) — kept so the positioner doesn't have to
// re-walk the source string.
glyphs: Vec<GlyphAdvance>,
},
Whitespace {
font: FontId,
size_px: f32,
width: f32,
},
Break,
}
#[derive(Debug, Clone, Copy)]
struct GlyphAdvance {
glyph: super::font::GlyphId,
advance: f32,
}
impl Item {
fn width(&self) -> f32 {
match self {
Item::Word { width, .. } | Item::Whitespace { width, .. } => *width,
Item::Break => 0.0,
}
}
fn font_size(&self) -> Option<(FontId, f32)> {
match self {
Item::Word { font, size_px, .. } | Item::Whitespace { font, size_px, .. } => {
Some((*font, *size_px))
}
Item::Break => None,
}
}
fn is_whitespace(&self) -> bool {
matches!(self, Item::Whitespace { .. })
}
}
fn is_break(c: char) -> bool {
c == '\n'
}
fn is_space_like(c: char) -> bool {
matches!(c, ' ' | '\t' | '\r')
}
fn tokenize_run(run: &TextRun, scale: f32, fonts: &FontStore, out: &mut Vec<Item>) {
let style = run.style;
let size_px = style.size_px * scale;
let Some(font) = fonts.get(style.font) else {
// Unknown font id — skip the run rather than panicking. Tests in
// piece 4 will catch missing fonts before rendering; for piece 3
// we want shape to remain a total function.
return;
};
let mut buf_word: Vec<GlyphAdvance> = Vec::new();
let mut buf_word_width: f32 = 0.0;
let mut buf_ws_width: f32 = 0.0;
let mut state = TokState::Empty;
for c in run.text.chars() {
if is_break(c) {
flush_buffers(
&mut state,
&mut buf_word,
&mut buf_word_width,
&mut buf_ws_width,
style.font,
size_px,
out,
);
out.push(Item::Break);
continue;
}
if is_space_like(c) {
if matches!(state, TokState::Word) {
out.push(Item::Word {
font: style.font,
size_px,
width: buf_word_width,
glyphs: std::mem::take(&mut buf_word),
});
buf_word_width = 0.0;
}
state = TokState::Whitespace;
let glyph = font.glyph_id(' ');
buf_ws_width += font.h_advance_px(glyph, size_px);
continue;
}
// Non-whitespace.
if matches!(state, TokState::Whitespace) {
out.push(Item::Whitespace {
font: style.font,
size_px,
width: buf_ws_width,
});
buf_ws_width = 0.0;
}
state = TokState::Word;
let glyph = font.glyph_id(c);
let advance = font.h_advance_px(glyph, size_px);
buf_word.push(GlyphAdvance { glyph, advance });
buf_word_width += advance;
}
flush_buffers(
&mut state,
&mut buf_word,
&mut buf_word_width,
&mut buf_ws_width,
style.font,
size_px,
out,
);
}
#[derive(PartialEq)]
enum TokState {
Empty,
Word,
Whitespace,
}
fn flush_buffers(
state: &mut TokState,
word: &mut Vec<GlyphAdvance>,
word_width: &mut f32,
ws_width: &mut f32,
font: FontId,
size_px: f32,
out: &mut Vec<Item>,
) {
match state {
TokState::Word => {
out.push(Item::Word {
font,
size_px,
width: *word_width,
glyphs: std::mem::take(word),
});
*word_width = 0.0;
}
TokState::Whitespace => {
out.push(Item::Whitespace {
font,
size_px,
width: *ws_width,
});
*ws_width = 0.0;
}
TokState::Empty => {}
}
*state = TokState::Empty;
}
fn break_lines(items: Vec<Item>, max_width: Option<f32>) -> Vec<Vec<Item>> {
let mut raw_lines: Vec<Vec<Item>> = Vec::new();
let mut current: Vec<Item> = Vec::new();
let mut current_width: f32 = 0.0;
let mut pending_ws: Vec<Item> = Vec::new();
let mut pending_ws_width: f32 = 0.0;
for item in items {
match item {
Item::Break => {
raw_lines.push(std::mem::take(&mut current));
current_width = 0.0;
pending_ws.clear();
pending_ws_width = 0.0;
}
Item::Whitespace { width, .. } => {
pending_ws_width += width;
pending_ws.push(item);
}
Item::Word { width, .. } => {
let fits = match max_width {
Some(max) => {
current.is_empty() || current_width + pending_ws_width + width <= max
}
None => true,
};
if fits {
current.append(&mut pending_ws);
current_width += pending_ws_width;
current_width += width;
current.push(item);
} else {
raw_lines.push(std::mem::take(&mut current));
// Leading whitespace on a wrapped line is dropped.
pending_ws.clear();
current_width = width;
current.push(item);
}
pending_ws_width = 0.0;
}
}
}
if !current.is_empty() {
raw_lines.push(current);
}
raw_lines
}
fn position_lines(
raw_lines: Vec<Vec<Item>>,
params: &ShapeParams,
fonts: &FontStore,
) -> ShapedText {
let mut lines: Vec<ShapedLine> = Vec::new();
let mut cursor_y: f32 = 0.0;
let mut widest: f32 = 0.0;
for line_items in raw_lines {
// Line metrics from the largest contributing item.
let mut max_ascent: f32 = 0.0;
let mut min_descent: f32 = 0.0;
let mut max_line_height: f32 = 0.0;
for item in &line_items {
if let Some((font_id, size_px)) = item.font_size() {
if let Some(font) = fonts.get(font_id) {
max_ascent = max_ascent.max(font.ascent_px(size_px));
min_descent = min_descent.min(font.descent_px(size_px));
max_line_height = max_line_height.max(font.line_height_px(size_px));
}
}
}
let _ = min_descent; // descent reserved for vertical-extent queries later
let line_height = max_line_height * params.line_height;
// Trailing whitespace is excluded from line width.
let mut content_width: f32 = 0.0;
let last_non_ws = line_items
.iter()
.enumerate()
.rev()
.find(|(_, it)| !it.is_whitespace())
.map(|(i, _)| i);
if let Some(end) = last_non_ws {
for it in &line_items[..=end] {
content_width += it.width();
}
}
// Horizontal alignment offset.
let align_pad = match params.max_width {
Some(max) => {
let extra = (max - content_width).max(0.0);
match params.align {
TextAlign::Left => 0.0,
TextAlign::Center => extra * 0.5,
TextAlign::Right => extra,
}
}
None => 0.0,
};
let baseline_y = cursor_y + max_ascent;
let mut pen_x = align_pad;
let mut glyphs: Vec<ShapedGlyph> = Vec::new();
for item in &line_items {
match item {
Item::Word {
font,
size_px,
glyphs: g,
..
} => {
for ga in g {
glyphs.push(ShapedGlyph {
key: GlyphKey::new(*font, ga.glyph, *size_px),
position: Vec2::new(pen_x, baseline_y),
});
pen_x += ga.advance;
}
}
Item::Whitespace { width, .. } => {
pen_x += *width;
}
Item::Break => {}
}
}
lines.push(ShapedLine {
glyphs,
width: content_width,
baseline_y,
line_height,
});
cursor_y += line_height;
widest = widest.max(content_width);
}
ShapedText {
lines,
size: Vec2::new(widest, cursor_y),
}
}
#[cfg(test)]
mod tests {
use super::super::font::try_load_system_font;
use super::*;
fn make_store_and_style(size_px: f32) -> Option<(FontStore, TextStyle)> {
let font = try_load_system_font()?;
let mut store = FontStore::new();
let id = store.insert(font);
Some((store, TextStyle { font: id, size_px }))
}
#[test]
fn empty_text_produces_no_lines() {
let Some((store, style)) = make_store_and_style(16.0) else {
return;
};
let out = shape("", style, &ShapeParams::default(), &store);
assert!(out.lines.is_empty());
assert_eq!(out.size, Vec2::ZERO);
}
#[test]
fn single_word_emits_one_line_with_correct_glyph_count() {
let Some((store, style)) = make_store_and_style(16.0) else {
return;
};
let out = shape("Hello", style, &ShapeParams::default(), &store);
assert_eq!(out.lines.len(), 1);
assert_eq!(out.lines[0].glyphs.len(), 5);
// Glyphs are at the same baseline.
let baseline = out.lines[0].baseline_y;
for g in &out.lines[0].glyphs {
assert_eq!(g.position.y, baseline);
}
// x positions are monotonically increasing.
for w in out.lines[0].glyphs.windows(2) {
assert!(w[1].position.x > w[0].position.x);
}
// Line width matches the last glyph's pen-end (advance sum).
assert!(out.lines[0].width > 0.0);
assert!(out.size.x >= out.lines[0].width);
}
#[test]
fn explicit_newline_starts_new_line() {
let Some((store, style)) = make_store_and_style(16.0) else {
return;
};
let out = shape("a\nb", style, &ShapeParams::default(), &store);
assert_eq!(out.lines.len(), 2);
assert_eq!(out.lines[0].glyphs.len(), 1);
assert_eq!(out.lines[1].glyphs.len(), 1);
// Second baseline is below the first by one line height.
assert!(out.lines[1].baseline_y > out.lines[0].baseline_y);
}
#[test]
fn word_wrap_splits_into_multiple_lines() {
let Some((store, style)) = make_store_and_style(16.0) else {
return;
};
// A line wide enough for "Hello" but not "Hello world".
let one_word_width = shape("Hello", style, &ShapeParams::default(), &store).lines[0].width;
let params = ShapeParams {
max_width: Some(one_word_width + 2.0),
..ShapeParams::default()
};
let out = shape("Hello world", style, &params, &store);
assert_eq!(out.lines.len(), 2);
// First line is just "Hello" (5 glyphs).
assert_eq!(out.lines[0].glyphs.len(), 5);
// Second line is "world" (5 glyphs); leading whitespace dropped.
assert_eq!(out.lines[1].glyphs.len(), 5);
// Second line starts at x = 0 (Left align by default; no leading
// whitespace consumed pen space).
assert_eq!(out.lines[1].glyphs[0].position.x, 0.0);
}
#[test]
fn trailing_whitespace_excluded_from_line_width() {
let Some((store, style)) = make_store_and_style(16.0) else {
return;
};
let bare = shape("Hi", style, &ShapeParams::default(), &store);
let trailing = shape("Hi ", style, &ShapeParams::default(), &store);
assert_eq!(bare.lines[0].width, trailing.lines[0].width);
}
#[test]
fn alignment_shifts_glyph_positions_within_max_width() {
let Some((store, style)) = make_store_and_style(16.0) else {
return;
};
let left = shape(
"Hi",
style,
&ShapeParams {
max_width: Some(200.0),
align: TextAlign::Left,
..ShapeParams::default()
},
&store,
);
let center = shape(
"Hi",
style,
&ShapeParams {
max_width: Some(200.0),
align: TextAlign::Center,
..ShapeParams::default()
},
&store,
);
let right = shape(
"Hi",
style,
&ShapeParams {
max_width: Some(200.0),
align: TextAlign::Right,
..ShapeParams::default()
},
&store,
);
let l = left.lines[0].glyphs[0].position.x;
let c = center.lines[0].glyphs[0].position.x;
let r = right.lines[0].glyphs[0].position.x;
assert_eq!(l, 0.0);
assert!(c > l && c < r);
// Centered + right cases place the line within `max_width = 200`.
let width = left.lines[0].width;
assert!((c - (200.0 - width) * 0.5).abs() < 0.001);
assert!((r - (200.0 - width)).abs() < 0.001);
}
#[test]
fn dpi_scale_doubles_advance_widths_and_baseline_drop() {
let Some((store, style)) = make_store_and_style(16.0) else {
return;
};
let at_1x = shape("Hello", style, &ShapeParams::default(), &store);
let at_2x = shape(
"Hello",
style,
&ShapeParams {
scale: 2.0,
..ShapeParams::default()
},
&store,
);
// Line width at 2× is ~2× at 1×.
let ratio = at_2x.lines[0].width / at_1x.lines[0].width;
assert!((ratio - 2.0).abs() < 0.05, "ratio = {ratio}");
// First glyph's baseline drops at 2× by ~2× the 1× drop.
let baseline_ratio = at_2x.lines[0].baseline_y / at_1x.lines[0].baseline_y;
assert!((baseline_ratio - 2.0).abs() < 0.1);
}
#[test]
fn line_height_multiplier_increases_vertical_spacing() {
let Some((store, style)) = make_store_and_style(16.0) else {
return;
};
let single = shape("a\nb", style, &ShapeParams::default(), &store);
let spaced = shape(
"a\nb",
style,
&ShapeParams {
line_height: 2.0,
..ShapeParams::default()
},
&store,
);
let gap_1 = single.lines[1].baseline_y - single.lines[0].baseline_y;
let gap_2 = spaced.lines[1].baseline_y - spaced.lines[0].baseline_y;
// Doubling the line-height factor roughly doubles inter-baseline
// distance — exact ratio depends on the font's gap fraction.
assert!(
(gap_2 / gap_1 - 2.0).abs() < 0.05,
"gap_2/gap_1 = {}",
gap_2 / gap_1
);
}
#[test]
fn glyph_keys_are_stable_across_calls() {
let Some((store, style)) = make_store_and_style(16.0) else {
return;
};
let a = shape("X", style, &ShapeParams::default(), &store);
let b = shape("X", style, &ShapeParams::default(), &store);
assert_eq!(a.lines[0].glyphs[0].key, b.lines[0].glyphs[0].key);
}
#[test]
fn multi_font_run_takes_max_ascent_from_largest_size() {
let Some(font) = try_load_system_font() else {
return;
};
let mut store = FontStore::new();
let id = store.insert(font);
let small = TextStyle {
font: id,
size_px: 12.0,
};
let big = TextStyle {
font: id,
size_px: 32.0,
};
let mixed = shape_runs(
&[
TextRun {
text: "Hi ",
style: small,
},
TextRun {
text: "X",
style: big,
},
],
&ShapeParams::default(),
&store,
);
let small_only = shape("Hi", small, &ShapeParams::default(), &store);
// The big-size baseline must be at least as deep as the small-size
// baseline because the line's ascent is the max of contributions.
assert!(mixed.lines[0].baseline_y >= small_only.lines[0].baseline_y);
}
#[test]
fn unknown_font_id_does_not_panic() {
// No font registered → shape returns no lines instead of panicking.
let store = FontStore::new();
let style = TextStyle {
font: FontId(99),
size_px: 16.0,
};
let out = shape("Hello", style, &ShapeParams::default(), &store);
assert!(out.lines.is_empty());
}
#[test]
fn no_wrap_when_max_width_is_none() {
let Some((store, style)) = make_store_and_style(16.0) else {
return;
};
let out = shape(
"one two three four five",
style,
&ShapeParams::default(),
&store,
);
assert_eq!(out.lines.len(), 1);
}
}
+217
View File
@@ -0,0 +1,217 @@
//! Theme — reusable named [`VisualStyle`]s plus a default fallback.
//!
//! A [`Theme`] is what a project ships to give every UI document a consistent
//! look without hand-styling every widget. The resolution rule is a strict
//! left-to-right cascade:
//!
//! 1. Start with `theme.default` (a `VisualStyle` whose `Some` fields are the
//! project-wide defaults — body text color, border weight, …).
//! 2. If the widget specifies `theme_style: Some("button")` and the theme
//! contains a `"button"` entry, merge that on top.
//! 3. Merge the widget's per-instance `visual` on top.
//!
//! Each merge is field-by-field: a `Some` on the right replaces the field;
//! a `None` keeps what was there. The result is a single [`VisualStyle`]
//! where any field that's still `None` means "the renderer's own hard-coded
//! fallback applies" — that fallback lives in piece 4 (the 2D overlay pass).
//!
//! Why named styles instead of CSS-like selectors: it makes per-widget
//! attribution explicit in the UI document (`theme_style: "button-primary"`)
//! and keeps theme resolution constant-time per widget. CSS selectors and
//! cascading rules are a richer model but their authoring cost dwarfs what
//! Stage-8 game UIs actually need.
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use super::visual::VisualStyle;
/// A named-style theme. Holds a `default` style applied to every widget plus
/// a map of named styles widgets can opt into by their `theme_style` field.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Theme {
/// Project-wide defaults — applied first to every widget before its
/// `theme_style` and per-instance overrides.
#[serde(default, skip_serializing_if = "VisualStyle::is_empty")]
pub default: VisualStyle,
/// Named style buckets — `theme_style: "button"` on a widget pulls the
/// `"button"` entry here on top of `default`.
///
/// Stored as a `BTreeMap` (not `HashMap`) so RON output is in a
/// deterministic order — important for diff-friendly UI documents and
/// reproducible RON snapshots in tests.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub styles: BTreeMap<String, VisualStyle>,
}
impl Theme {
/// Empty theme — no default fields, no named styles. Every widget under
/// this theme inherits only the renderer's hard-coded fallback.
pub const fn new() -> Self {
Self {
default: VisualStyle::EMPTY,
styles: BTreeMap::new(),
}
}
/// Insert (or replace) a named style. Chainable for builder-style theme
/// construction in tests and examples.
pub fn with_style(mut self, name: impl Into<String>, style: VisualStyle) -> Self {
self.styles.insert(name.into(), style);
self
}
/// Replace the project-wide default style.
pub fn with_default(mut self, default: VisualStyle) -> Self {
self.default = default;
self
}
/// Resolve the effective visual style for a widget that opts into
/// `style_ref` (if any) and provides its own `override_with` per-instance
/// fields.
///
/// Cascade: `self.default` → (`self.styles[style_ref]` if present) →
/// `override_with`. A missing named style is treated as empty (no
/// contribution) rather than an error — UI documents stay valid when a
/// theme is swapped for a smaller one mid-development.
pub fn resolve(&self, style_ref: Option<&str>, override_with: &VisualStyle) -> VisualStyle {
let mut resolved = self.default.clone();
if let Some(name) = style_ref {
if let Some(named) = self.styles.get(name) {
resolved = resolved.merged(named);
}
}
resolved.merged(override_with)
}
/// Serialize this theme to a pretty-printed RON string.
pub fn to_ron(&self) -> Result<String, ron::Error> {
ron::ser::to_string_pretty(self, ron::ser::PrettyConfig::default())
}
/// Parse a theme from a RON string.
pub fn from_ron(text: &str) -> Result<Self, ron::de::SpannedError> {
ron::de::from_str(text)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::math::Color;
use crate::ui::visual::{Border, FontRef};
fn theme_with_three_styles() -> Theme {
Theme::new()
.with_default(VisualStyle {
foreground: Some(Color::BLACK),
background: Some(Color::WHITE),
font: Some(FontRef::regular("Inter")),
font_size: Some(14.0),
..VisualStyle::EMPTY
})
.with_style(
"button",
VisualStyle {
background: Some(Color::rgb(0.85, 0.85, 0.9)),
border: Some(Border::new(Color::rgb(0.6, 0.6, 0.7), 1.0)),
corner_radius: Some(4.0),
..VisualStyle::EMPTY
},
)
.with_style(
"button-primary",
VisualStyle {
background: Some(Color::rgb(0.2, 0.4, 0.8)),
foreground: Some(Color::WHITE),
..VisualStyle::EMPTY
},
)
.with_style(
"label",
VisualStyle {
foreground: Some(Color::rgb(0.2, 0.2, 0.2)),
..VisualStyle::EMPTY
},
)
}
#[test]
fn resolve_returns_default_for_no_style_or_overrides() {
let theme = theme_with_three_styles();
let resolved = theme.resolve(None, &VisualStyle::EMPTY);
assert_eq!(resolved.foreground, Some(Color::BLACK));
assert_eq!(resolved.background, Some(Color::WHITE));
assert_eq!(resolved.font_size, Some(14.0));
}
#[test]
fn named_style_overrides_default() {
let theme = theme_with_three_styles();
let resolved = theme.resolve(Some("button"), &VisualStyle::EMPTY);
assert_eq!(resolved.background, Some(Color::rgb(0.85, 0.85, 0.9)));
// Foreground not set on "button" → kept from default.
assert_eq!(resolved.foreground, Some(Color::BLACK));
assert_eq!(resolved.corner_radius, Some(4.0));
}
#[test]
fn per_instance_override_takes_final_precedence() {
let theme = theme_with_three_styles();
let overlay = VisualStyle {
background: Some(Color::RED),
..VisualStyle::EMPTY
};
let resolved = theme.resolve(Some("button-primary"), &overlay);
// Per-instance background wins over the named style.
assert_eq!(resolved.background, Some(Color::RED));
// The named style's foreground (WHITE) still beats the default (BLACK).
assert_eq!(resolved.foreground, Some(Color::WHITE));
}
#[test]
fn unknown_named_style_falls_back_to_default() {
let theme = theme_with_three_styles();
let resolved = theme.resolve(Some("does-not-exist"), &VisualStyle::EMPTY);
// Same as resolve(None, &EMPTY).
assert_eq!(resolved, theme.resolve(None, &VisualStyle::EMPTY));
}
#[test]
fn theme_round_trips_through_ron() {
let theme = theme_with_three_styles();
let text = theme.to_ron().unwrap();
let decoded = Theme::from_ron(&text).unwrap();
assert_eq!(theme, decoded);
// Named styles are alphabetised by BTreeMap, so "button" precedes
// "button-primary" precedes "label" in the serialized form.
let button_pos = text.find("\"button\"").unwrap();
let primary_pos = text.find("\"button-primary\"").unwrap();
let label_pos = text.find("\"label\"").unwrap();
assert!(button_pos < primary_pos);
assert!(primary_pos < label_pos);
}
#[test]
fn empty_theme_round_trips_to_empty_ron() {
let empty = Theme::new();
let text = empty.to_ron().unwrap();
let decoded = Theme::from_ron(&text).unwrap();
assert_eq!(empty, decoded);
// The empty theme should not mention either field.
assert!(!text.contains("default:"));
assert!(!text.contains("styles:"));
}
#[test]
fn builder_chaining_inserts_styles_in_order() {
let t = Theme::new()
.with_style("a", VisualStyle::EMPTY)
.with_style("b", VisualStyle::EMPTY);
assert_eq!(t.styles.len(), 2);
assert!(t.styles.contains_key("a"));
assert!(t.styles.contains_key("b"));
}
}
+158
View File
@@ -0,0 +1,158 @@
//! Per-widget typed value — the state interactive widgets carry.
//!
//! Stage 8's UI is data-driven: a slider knows its current position, a
//! text input knows the string the user has typed, a checkbox knows
//! whether it's checked. Rather than encoding "which kind of state does
//! this widget have" inside the layout enum, every [`Widget`](super::widget::Widget)
//! has an optional `value: Option<WidgetValue>` orthogonal to its `kind`.
//! That keeps the layout algorithm simple (it doesn't care about state)
//! and lets the same `Leaf` form a button (no value) or a checkbox
//! (`Bool` value).
//!
//! # Data binding model
//!
//! Stage-8 piece-6 uses the **immediate-mode** pattern (the same as
//! `egui` and Bevy UI): the widget tree is the source of truth for the
//! frame. Each frame the host:
//!
//! 1. Pulls latest game data into the matching widget values (e.g.,
//! `root.set_value("volume", WidgetValue::Float(audio.master_volume as f64))`).
//! 2. Runs the [`Router`](super::routing::Router).
//! 3. Reads back any widget values that interactive widgets may have
//! changed, and pushes them into game data
//! (`audio.master_volume = root.value("volume")?.as_float()? as f32`).
//!
//! No callback storage, no `Rc<RefCell<...>>` for state, no lifetime
//! gymnastics — exactly what a game's main loop wants.
use serde::{Deserialize, Serialize};
/// A typed value carried on an interactive widget — the slider's
/// position, a checkbox's check, a text-input's string.
///
/// Variants are intentionally minimal; richer types (Color, Vec2, etc.)
/// can be added as widget needs grow. RON round-trips so a UI document
/// can ship default values inline.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum WidgetValue {
Bool(bool),
Int(i64),
Float(f64),
Text(String),
}
impl WidgetValue {
/// Borrow as a bool if this is a [`Bool`](Self::Bool).
pub fn as_bool(&self) -> Option<bool> {
match self {
Self::Bool(v) => Some(*v),
_ => None,
}
}
/// Borrow as an i64 if this is an [`Int`](Self::Int).
pub fn as_int(&self) -> Option<i64> {
match self {
Self::Int(v) => Some(*v),
_ => None,
}
}
/// Borrow as an f64 if this is a [`Float`](Self::Float).
pub fn as_float(&self) -> Option<f64> {
match self {
Self::Float(v) => Some(*v),
_ => None,
}
}
/// Borrow as a string slice if this is a [`Text`](Self::Text).
pub fn as_text(&self) -> Option<&str> {
match self {
Self::Text(s) => Some(s.as_str()),
_ => None,
}
}
}
impl From<bool> for WidgetValue {
fn from(v: bool) -> Self {
Self::Bool(v)
}
}
impl From<i64> for WidgetValue {
fn from(v: i64) -> Self {
Self::Int(v)
}
}
impl From<i32> for WidgetValue {
fn from(v: i32) -> Self {
Self::Int(v as i64)
}
}
impl From<f64> for WidgetValue {
fn from(v: f64) -> Self {
Self::Float(v)
}
}
impl From<f32> for WidgetValue {
fn from(v: f32) -> Self {
Self::Float(v as f64)
}
}
impl From<String> for WidgetValue {
fn from(v: String) -> Self {
Self::Text(v)
}
}
impl From<&str> for WidgetValue {
fn from(v: &str) -> Self {
Self::Text(v.to_owned())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn as_accessors_match_variants() {
assert_eq!(WidgetValue::Bool(true).as_bool(), Some(true));
assert_eq!(WidgetValue::Bool(true).as_int(), None);
assert_eq!(WidgetValue::Int(42).as_int(), Some(42));
assert_eq!(WidgetValue::Float(1.5).as_float(), Some(1.5));
assert_eq!(WidgetValue::Text("hi".into()).as_text(), Some("hi"));
}
#[test]
fn primitive_conversions() {
let v: WidgetValue = true.into();
assert_eq!(v, WidgetValue::Bool(true));
let v: WidgetValue = 7_i32.into();
assert_eq!(v, WidgetValue::Int(7));
let v: WidgetValue = 1.5_f32.into();
assert!((v.as_float().unwrap() - 1.5_f64).abs() < 1e-5);
let v: WidgetValue = "label".into();
assert_eq!(v.as_text(), Some("label"));
}
#[test]
fn ron_round_trips_each_variant() {
for v in [
WidgetValue::Bool(true),
WidgetValue::Int(-99),
WidgetValue::Float(0.42),
WidgetValue::Text("hello".into()),
] {
let text = ron::ser::to_string(&v).unwrap();
let decoded: WidgetValue = ron::de::from_str(&text).unwrap();
assert_eq!(v, decoded);
}
}
}
+324
View File
@@ -0,0 +1,324 @@
//! Visual style — colors, borders, fonts. The *what does it look like* layer.
//!
//! [`VisualStyle`] is orthogonal to the Stage-8 [`LayoutStyle`](super::style::LayoutStyle):
//! layout decides where a widget *is*; visual decides what it *looks like*.
//! Every field is `Option<T>`. `None` means **inherit** — from a [`Theme`](super::theme::Theme)
//! when present, otherwise from the renderer's hard-coded fallback in piece 4.
//! `Some` means **override**: this widget (or this named theme style) wants
//! exactly this value, regardless of what the theme provides.
//!
//! Why optional fields instead of full values: it lets a tiny per-widget
//! override stay tiny in RON (one line for "button-pressed has a brighter
//! background") without re-stating every color/border/font the theme already
//! provides. The same merging rule works equally well for theme cascades
//! (default → named style → per-instance) and for runtime state changes
//! (hover/focus/press overlays in piece 5).
use serde::{Deserialize, Serialize};
use crate::asset::AssetRef;
use crate::math::Color;
use super::text::Font;
/// Optional per-widget visual properties. `None` on a field means "inherit";
/// `Some` means "override".
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct VisualStyle {
/// Filled background color drawn behind the widget's `content_rect`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub background: Option<Color>,
/// Foreground color — text, icons, anything drawn *on top of* the
/// background.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub foreground: Option<Color>,
/// Border drawn around the widget's `rect`. `Some(border)` with a
/// `width <= 0.0` is treated as "no border" by the renderer, the same as
/// `None`, but the value still serializes — useful for theme overrides
/// that explicitly *suppress* a border.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub border: Option<Border>,
/// Corner radius in logical pixels (zero means square). Applies to both
/// background fill and border.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub corner_radius: Option<f32>,
/// Font family + weight + italic flag. Piece 3 turns this into a
/// shaped glyph stream.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub font: Option<FontRef>,
/// A specific font **asset** to draw with, chosen in the editor's UI canvas
/// from the project's `fonts/`. When set it takes precedence over the
/// portable [`font`](Self::font) descriptor (the renderer resolves the
/// [`AssetRef`] to a loaded face via the asset database); when `None` the
/// descriptor / theme path applies as before. This is the engine's first
/// `AssetRef<T>` field — the asset-picker's end-to-end target.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub font_asset: Option<AssetRef<Font>>,
/// Font size in logical pixels.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub font_size: Option<f32>,
}
impl VisualStyle {
/// Empty style — every field `None`. Equivalent to [`Default::default`];
/// `EMPTY` exists as a `const` for places that want it as an associated
/// constant.
pub const EMPTY: Self = Self {
background: None,
foreground: None,
border: None,
corner_radius: None,
font: None,
font_asset: None,
font_size: None,
};
/// Returns a style where every `Some` field in `override_with` replaces
/// the corresponding field in `self`.
///
/// This is the merge primitive themes and runtime state use: build a
/// resolved style by cascading default → named-style → per-instance →
/// state-overlay, each call replacing only the fields the caller cared
/// about.
pub fn merged(&self, override_with: &VisualStyle) -> VisualStyle {
VisualStyle {
background: override_with.background.or(self.background),
foreground: override_with.foreground.or(self.foreground),
border: override_with.border.or(self.border),
corner_radius: override_with.corner_radius.or(self.corner_radius),
font: override_with.font.clone().or_else(|| self.font.clone()),
font_asset: override_with.font_asset.or(self.font_asset),
font_size: override_with.font_size.or(self.font_size),
}
}
/// True if every field is `None`. Handy as a `skip_serializing_if` test
/// when embedding a `VisualStyle` in a host struct that wants the empty
/// case to vanish from RON entirely.
pub fn is_empty(&self) -> bool {
*self == Self::EMPTY
}
}
/// Border drawn around a widget's `rect`.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Border {
pub color: Color,
pub width: f32,
}
impl Border {
pub const fn new(color: Color, width: f32) -> Self {
Self { color, width }
}
}
/// Reference to a font face the renderer will load and shape with.
///
/// Piece 2 stores the descriptor only; piece 3 (text shaping & glyph atlas)
/// resolves it to an actual loaded face. Keeping the descriptor as plain
/// `family` + `weight` + `italic` (rather than a path or a handle) means UI
/// documents are portable: a theme can ask for `"Inter"` and the runtime can
/// pick the platform's best match for that name without rewriting the
/// document.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct FontRef {
pub family: String,
#[serde(default, skip_serializing_if = "FontWeight::is_default")]
pub weight: FontWeight,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub italic: bool,
}
impl FontRef {
/// Regular-weight, upright font of the given family.
pub fn regular(family: impl Into<String>) -> Self {
Self {
family: family.into(),
weight: FontWeight::Regular,
italic: false,
}
}
/// Bold-weight, upright font of the given family.
pub fn bold(family: impl Into<String>) -> Self {
Self {
family: family.into(),
weight: FontWeight::Bold,
italic: false,
}
}
}
/// Font weight — the named buckets the OpenType weight axis snaps to.
///
/// Stored as a discrete enum (rather than a `u16` 100900) because the
/// editor's style inspector and a hand-edited RON file both want
/// `weight: Bold` to round-trip exactly. Renderers can map each variant to
/// its OpenType weight value in piece 3.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
pub enum FontWeight {
Thin,
Light,
#[default]
Regular,
Medium,
Bold,
Black,
}
impl FontWeight {
/// OpenType weight value (100..=900) for this bucket.
pub fn opentype_value(self) -> u16 {
match self {
Self::Thin => 100,
Self::Light => 300,
Self::Regular => 400,
Self::Medium => 500,
Self::Bold => 700,
Self::Black => 900,
}
}
fn is_default(&self) -> bool {
*self == Self::default()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_style_has_no_set_fields() {
let s = VisualStyle::default();
assert!(s.is_empty());
assert_eq!(s, VisualStyle::EMPTY);
}
#[test]
fn merge_overrides_only_set_fields() {
let base = VisualStyle {
background: Some(Color::WHITE),
foreground: Some(Color::BLACK),
border: Some(Border::new(Color::BLACK, 1.0)),
corner_radius: Some(4.0),
font: Some(FontRef::regular("Inter")),
font_asset: None,
font_size: Some(14.0),
};
let overlay = VisualStyle {
background: Some(Color::rgb(0.9, 0.9, 0.9)),
font_size: Some(16.0),
..VisualStyle::EMPTY
};
let merged = base.merged(&overlay);
assert_eq!(merged.background, Some(Color::rgb(0.9, 0.9, 0.9))); // overlaid
assert_eq!(merged.foreground, Some(Color::BLACK)); // kept from base
assert_eq!(merged.font_size, Some(16.0)); // overlaid
assert_eq!(merged.corner_radius, Some(4.0)); // kept from base
assert_eq!(merged.font, Some(FontRef::regular("Inter")));
}
#[test]
fn merge_with_empty_overlay_is_identity() {
let base = VisualStyle {
background: Some(Color::WHITE),
foreground: Some(Color::BLACK),
..VisualStyle::EMPTY
};
assert_eq!(base.merged(&VisualStyle::EMPTY), base);
}
#[test]
fn merge_into_empty_base_takes_overlay() {
let overlay = VisualStyle {
background: Some(Color::RED),
..VisualStyle::EMPTY
};
assert_eq!(VisualStyle::EMPTY.merged(&overlay), overlay);
}
#[test]
fn font_ref_helpers_match_fields() {
let r = FontRef::regular("Inter");
assert_eq!(r.family, "Inter");
assert_eq!(r.weight, FontWeight::Regular);
assert!(!r.italic);
let b = FontRef::bold("Inter");
assert_eq!(b.weight, FontWeight::Bold);
}
#[test]
fn font_weight_opentype_value() {
assert_eq!(FontWeight::Thin.opentype_value(), 100);
assert_eq!(FontWeight::Regular.opentype_value(), 400);
assert_eq!(FontWeight::Bold.opentype_value(), 700);
assert_eq!(FontWeight::Black.opentype_value(), 900);
}
#[test]
fn visual_style_round_trips_through_ron_compactly() {
let s = VisualStyle {
background: Some(Color::WHITE),
corner_radius: Some(8.0),
font: Some(FontRef::bold("Inter")),
..VisualStyle::EMPTY
};
let text = ron::ser::to_string(&s).unwrap();
// Fields that are `None` must not appear in the serialized form.
assert!(!text.contains("foreground"));
assert!(!text.contains("border"));
assert!(!text.contains("font_size"));
let decoded: VisualStyle = ron::de::from_str(&text).unwrap();
assert_eq!(s, decoded);
}
#[test]
fn font_asset_overrides_and_round_trips() {
use crate::asset::{AssetRef, AssetUid};
// An overlay's font_asset replaces the base's, like the other fields.
let base = VisualStyle {
font_asset: Some(AssetRef::new(AssetUid(1))),
..VisualStyle::EMPTY
};
let overlay = VisualStyle {
font_asset: Some(AssetRef::new(AssetUid(2))),
..VisualStyle::EMPTY
};
assert_eq!(
base.merged(&overlay).font_asset,
Some(AssetRef::new(AssetUid(2)))
);
// An empty overlay keeps the base reference (inherit semantics).
assert_eq!(base.merged(&VisualStyle::EMPTY).font_asset, base.font_asset);
// Round-trips compactly and is skipped when unset.
let text = ron::ser::to_string(&base).unwrap();
assert!(text.contains("font_asset"));
assert_eq!(ron::de::from_str::<VisualStyle>(&text).unwrap(), base);
let empty_text = ron::ser::to_string(&VisualStyle::EMPTY).unwrap();
assert!(!empty_text.contains("font_asset"));
}
#[test]
fn empty_style_round_trips_to_empty_ron() {
let text = ron::ser::to_string(&VisualStyle::EMPTY).unwrap();
// No fields set → the struct should serialize to its empty form.
let decoded: VisualStyle = ron::de::from_str(&text).unwrap();
assert_eq!(decoded, VisualStyle::EMPTY);
}
#[test]
fn font_ref_defaults_skip_in_ron() {
let f = FontRef::regular("Inter");
let text = ron::ser::to_string(&f).unwrap();
// Regular weight and non-italic should be skipped.
assert!(!text.contains("Regular"));
assert!(!text.contains("italic"));
let decoded: FontRef = ron::de::from_str(&text).unwrap();
assert_eq!(f, decoded);
}
}
+933
View File
@@ -0,0 +1,933 @@
//! Widget tree — the data structure laid out by [`super::layout`].
//!
//! Stage 8 splits widgets cleanly into **what** (the [`WidgetKind`]) and
//! **how** (the [`LayoutStyle`] held on every node). The kind decides whether
//! a node has children and how they're arranged; the style is the same fields
//! on every widget so the layout algorithm has one place to look.
//!
//! Piece 1 ships only what the layout algorithm needs: a [`Leaf`](WidgetKind::Leaf)
//! placeholder with an intrinsic size, and three container kinds — [`Stack`]
//! (row/column), [`Grid`], and [`AnchorGroup`]. Interactive widgets (button,
//! checkbox, slider, text input, …) are layered on top in later pieces by
//! decorating leaves with kind-specific style/state; they all participate in
//! the same layout pass without the algorithm having to know about them.
//!
//! # Building a tree
//!
//! ```
//! use glam::Vec2;
//! use oxide_engine::ui::{Insets, LayoutStyle, Sizing, Widget};
//!
//! let panel = Widget::row()
//! .with_id("toolbar")
//! .with_style(LayoutStyle {
//! width: Sizing::Grow(1.0),
//! height: Sizing::Fixed(32.0),
//! padding: Insets::all(4.0),
//! ..Default::default()
//! })
//! .with_child(Widget::leaf(Vec2::new(80.0, 24.0)).with_id("file"))
//! .with_child(Widget::leaf(Vec2::new(80.0, 24.0)).with_id("edit"));
//! assert_eq!(panel.children().len(), 2);
//! ```
use glam::Vec2;
use serde::{Deserialize, Serialize};
use super::style::LayoutStyle;
use super::value::WidgetValue;
use super::visual::VisualStyle;
/// Stable identifier for a widget — used to look up its laid-out rect in a
/// [`LayoutTree`](super::layout::LayoutTree) and (in later pieces) to wire up
/// input routing and data binding.
///
/// Stored as `String` so UI documents can ship author-facing names (`"play"`,
/// `"volume-slider"`) straight through RON. The empty id (`""`) is the default
/// and means "anonymous"; multiple anonymous widgets are allowed and lookups
/// by empty id are rejected by [`LayoutTree::find`](super::layout::LayoutTree::find).
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct WidgetId(pub String);
impl WidgetId {
/// `true` if the id string is empty.
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
/// Borrow the underlying string.
pub fn as_str(&self) -> &str {
&self.0
}
}
impl From<&str> for WidgetId {
fn from(s: &str) -> Self {
Self(s.to_owned())
}
}
/// A path from a root [`Widget`] to one of its descendants: the sequence of
/// child indices to follow from the root. The **empty** path denotes the root
/// itself.
///
/// Unlike [`WidgetId`] (optional, author-facing, possibly absent or duplicated)
/// a path addresses *exactly one* node positionally, so it is what the editor's
/// UI canvas uses to target structural edits — insert, remove, move — and to
/// record them on the undo stack. Paths are only valid against the tree they
/// were derived from; an edit that changes sibling order invalidates the paths
/// after it (the move helper accounts for this itself).
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct WidgetPath(pub Vec<usize>);
impl WidgetPath {
/// The root path (addresses the tree's root widget).
pub fn root() -> Self {
Self(Vec::new())
}
/// Whether this path addresses the root (is empty).
pub fn is_root(&self) -> bool {
self.0.is_empty()
}
/// Depth from the root (number of indices).
pub fn len(&self) -> usize {
self.0.len()
}
/// Whether the path is empty — alias of [`is_root`](Self::is_root), provided
/// for the clippy `len`/`is_empty` pairing.
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
/// A child path one level deeper, selecting child `index`.
pub fn child(&self, index: usize) -> Self {
let mut v = self.0.clone();
v.push(index);
Self(v)
}
/// Splits into `(parent_path, last_index)`, or `None` for the root.
pub fn split_last(&self) -> Option<(WidgetPath, usize)> {
let (last, rest) = self.0.split_last()?;
Some((WidgetPath(rest.to_vec()), *last))
}
/// Whether `self` is `other` or lies underneath it (prefix test). Used to
/// reject moving a subtree into its own descendant.
pub fn starts_with(&self, other: &WidgetPath) -> bool {
self.0.starts_with(&other.0)
}
}
impl From<String> for WidgetId {
fn from(s: String) -> Self {
Self(s)
}
}
/// A widget tree node — id, layout style, optional visual style + theme
/// reference, and a kind that decides what children it holds.
///
/// `style` (Stage-8 piece 1) controls layout — where the widget is.
/// `visual` (piece 2) carries per-instance visual overrides — what the
/// widget looks like — and `theme_style` opts into a named entry in the
/// project's [`Theme`](super::theme::Theme). Both default to empty so a
/// piece-1 UI document still parses unchanged.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Widget {
#[serde(default, skip_serializing_if = "WidgetId::is_empty")]
pub id: WidgetId,
#[serde(default)]
pub style: LayoutStyle,
#[serde(default, skip_serializing_if = "VisualStyle::is_empty")]
pub visual: VisualStyle,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub theme_style: Option<String>,
/// Text content shaped inside this widget's `content_rect`. Orthogonal
/// to `kind`: a button is a `Leaf` with `text` + `visual.background`; a
/// label is a `Leaf` with `text` only. Renderers shape this string
/// against the resolved [`VisualStyle::font`] and [`VisualStyle::font_size`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
/// Per-widget typed state — `Bool` for a checkbox, `Float` for a
/// slider, `Text` for a text input. Orthogonal to `kind`; absent
/// means "no state". See [`super::value::WidgetValue`] and the
/// piece-6 [`Widget::value`](Self::value) / [`set_value`](Self::set_value)
/// helpers.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub value: Option<WidgetValue>,
pub kind: WidgetKind,
}
/// What a widget *is* — leaf or one of three container layout modes.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum WidgetKind {
/// A childless node with an intrinsic logical size. Real interactive
/// widgets (label, button, image) layer on top of this in later pieces.
Leaf { intrinsic: Vec2 },
/// Row or column container.
Stack(Stack),
/// Equal-cell grid container.
Grid(Grid),
/// Container that positions each child via the child's own
/// [`Anchor`](super::style::Anchor).
Anchor(AnchorGroup),
}
impl Default for WidgetKind {
fn default() -> Self {
Self::Leaf {
intrinsic: Vec2::ZERO,
}
}
}
/// Stack container — arranges children along a main axis.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Stack {
pub direction: StackDirection,
/// Logical-pixel gap between adjacent children.
#[serde(default)]
pub gap: f32,
/// How leftover space on the main axis is distributed *after* children
/// have been sized. Ignored when any child uses [`Sizing::Grow`](super::style::Sizing::Grow),
/// since `Grow` consumes the leftover space directly.
#[serde(default)]
pub main_align: super::style::Align,
#[serde(default)]
pub children: Vec<Widget>,
}
/// Direction of a [`Stack`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum StackDirection {
/// Children flow left-to-right.
#[default]
Row,
/// Children flow top-to-bottom.
Column,
}
/// Equal-cell grid container — `cols × rows` cells filled in row-major order.
///
/// Piece-1 grids are intentionally simple: every cell is the same size,
/// computed from the parent's content rect. More flexible grids (auto-sized
/// rows/columns, spans) are a follow-up; the use cases the editor's Stage-7
/// preferences page and the Stage-8 settings examples actually need are all
/// served by the equal-cell case.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Grid {
pub cols: u32,
pub rows: u32,
/// `gap.x` between columns, `gap.y` between rows (logical pixels).
#[serde(default)]
pub gap: Vec2,
#[serde(default)]
pub children: Vec<Widget>,
}
/// Anchor container — each child is placed according to its own
/// [`LayoutStyle::anchor`](super::style::LayoutStyle::anchor).
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct AnchorGroup {
#[serde(default)]
pub children: Vec<Widget>,
}
impl Widget {
/// Build a leaf widget with the given intrinsic logical size.
pub fn leaf(intrinsic: Vec2) -> Self {
Self {
kind: WidgetKind::Leaf { intrinsic },
..Default::default()
}
}
/// Build an empty stack with the given direction (gap 0, default align).
pub fn stack(direction: StackDirection) -> Self {
Self {
kind: WidgetKind::Stack(Stack {
direction,
..Default::default()
}),
..Default::default()
}
}
/// Shortcut for `Widget::stack(StackDirection::Row)`.
pub fn row() -> Self {
Self::stack(StackDirection::Row)
}
/// Shortcut for `Widget::stack(StackDirection::Column)`.
pub fn column() -> Self {
Self::stack(StackDirection::Column)
}
/// Build an empty grid container.
pub fn grid(cols: u32, rows: u32) -> Self {
Self {
kind: WidgetKind::Grid(Grid {
cols,
rows,
..Default::default()
}),
..Default::default()
}
}
/// Build an empty anchor container.
pub fn anchor() -> Self {
Self {
kind: WidgetKind::Anchor(AnchorGroup::default()),
..Default::default()
}
}
/// Set the widget id (builder).
pub fn with_id(mut self, id: impl Into<WidgetId>) -> Self {
self.id = id.into();
self
}
/// Replace the whole [`LayoutStyle`] (builder).
pub fn with_style(mut self, style: LayoutStyle) -> Self {
self.style = style;
self
}
/// Replace the per-instance [`VisualStyle`] (builder).
pub fn with_visual(mut self, visual: VisualStyle) -> Self {
self.visual = visual;
self
}
/// Opt this widget into a named entry of the active
/// [`Theme`](super::theme::Theme) (builder). Pass `""` or call
/// [`Widget::clear_theme_style`] to remove the reference.
pub fn with_theme_style(mut self, name: impl Into<String>) -> Self {
let name = name.into();
self.theme_style = if name.is_empty() { None } else { Some(name) };
self
}
/// Drop any `theme_style` reference (builder).
pub fn clear_theme_style(mut self) -> Self {
self.theme_style = None;
self
}
/// Set this widget's text content (builder). Pass `""` to clear it. The
/// text is shaped at paint time against the widget's resolved font and
/// font size from the active theme.
pub fn with_text(mut self, text: impl Into<String>) -> Self {
let s = text.into();
self.text = if s.is_empty() { None } else { Some(s) };
self
}
/// Set this widget's typed value (builder).
pub fn with_value(mut self, value: impl Into<WidgetValue>) -> Self {
self.value = Some(value.into());
self
}
/// Set the stack gap (builder). Panics if not a stack — surfaces author
/// mistakes during construction rather than producing a silently
/// misshapen UI at layout time.
pub fn with_gap(mut self, gap: f32) -> Self {
match &mut self.kind {
WidgetKind::Stack(s) => s.gap = gap,
_ => panic!("with_gap is only valid on Stack widgets"),
}
self
}
/// Set the stack main-axis alignment (builder). Panics if not a stack.
pub fn with_main_align(mut self, align: super::style::Align) -> Self {
match &mut self.kind {
WidgetKind::Stack(s) => s.main_align = align,
_ => panic!("with_main_align is only valid on Stack widgets"),
}
self
}
/// Set the grid gap vector (builder). Panics if not a grid.
pub fn with_grid_gap(mut self, gap: Vec2) -> Self {
match &mut self.kind {
WidgetKind::Grid(g) => g.gap = gap,
_ => panic!("with_grid_gap is only valid on Grid widgets"),
}
self
}
/// Append a single child to a container widget (builder). Panics on a
/// leaf so the misuse is caught at construction.
pub fn with_child(mut self, child: Widget) -> Self {
children_mut(&mut self.kind, |c| c.push(child));
self
}
/// Append many children (builder).
pub fn with_children(mut self, children: impl IntoIterator<Item = Widget>) -> Self {
children_mut(&mut self.kind, |c| c.extend(children));
self
}
/// Borrow the direct children of this widget. Empty for leaves.
pub fn children(&self) -> &[Widget] {
match &self.kind {
WidgetKind::Leaf { .. } => &[],
WidgetKind::Stack(s) => &s.children,
WidgetKind::Grid(g) => &g.children,
WidgetKind::Anchor(a) => &a.children,
}
}
/// Borrow the direct children mutably. Empty slice for leaves.
///
/// Underpins [`find_by_id_mut`](Self::find_by_id_mut) and the piece-6
/// data-binding helpers; safer than reaching into `kind` because all
/// container kinds funnel through one accessor.
pub fn children_mut(&mut self) -> &mut [Widget] {
match &mut self.kind {
WidgetKind::Leaf { .. } => &mut [],
WidgetKind::Stack(s) => &mut s.children,
WidgetKind::Grid(g) => &mut g.children,
WidgetKind::Anchor(a) => &mut a.children,
}
}
/// Borrow this widget's children as the owning `Vec`, or `None` for a
/// [`Leaf`](WidgetKind::Leaf) (which cannot hold children). Unlike
/// [`children_mut`](Self::children_mut) this exposes the `Vec` itself, so
/// callers can insert/remove — the basis of the structural edits below.
pub fn children_vec_mut(&mut self) -> Option<&mut Vec<Widget>> {
match &mut self.kind {
WidgetKind::Leaf { .. } => None,
WidgetKind::Stack(s) => Some(&mut s.children),
WidgetKind::Grid(g) => Some(&mut g.children),
WidgetKind::Anchor(a) => Some(&mut a.children),
}
}
/// Whether this widget is a container (can hold children) rather than a leaf.
pub fn is_container(&self) -> bool {
!matches!(self.kind, WidgetKind::Leaf { .. })
}
/// Borrow the widget addressed by `path` (the root for the empty path), or
/// `None` if any index along the way is out of range.
pub fn get_path(&self, path: &WidgetPath) -> Option<&Widget> {
let mut node = self;
for &i in &path.0 {
node = node.children().get(i)?;
}
Some(node)
}
/// Mutable counterpart of [`get_path`](Self::get_path).
pub fn get_path_mut(&mut self, path: &WidgetPath) -> Option<&mut Widget> {
let mut node = self;
for &i in &path.0 {
node = node.children_mut().get_mut(i)?;
}
Some(node)
}
/// Inserts `child` at `index` among the children of the widget addressed by
/// `parent`, returning whether it succeeded. `index` is clamped to the
/// child count (so it can append). Fails if `parent` does not resolve or is
/// a leaf.
pub fn insert_child(&mut self, parent: &WidgetPath, index: usize, child: Widget) -> bool {
let Some(parent) = self.get_path_mut(parent) else {
return false;
};
let Some(children) = parent.children_vec_mut() else {
return false;
};
children.insert(index.min(children.len()), child);
true
}
/// Appends `child` to the children of the widget addressed by `parent`.
/// Convenience over [`insert_child`](Self::insert_child) with a trailing
/// index.
pub fn push_child_at(&mut self, parent: &WidgetPath, child: Widget) -> bool {
self.insert_child(parent, usize::MAX, child)
}
/// Removes and returns the widget addressed by `path`. The root cannot be
/// removed (returns `None` for the empty path), nor can an out-of-range or
/// unreachable path.
pub fn remove_path(&mut self, path: &WidgetPath) -> Option<Widget> {
let (parent, index) = path.split_last()?;
let children = self.get_path_mut(&parent)?.children_vec_mut()?;
(index < children.len()).then(|| children.remove(index))
}
/// Moves the subtree at `from` to be child `index` of `to_parent`,
/// returning whether it succeeded. Rejects moving the root, or moving a node
/// into itself or one of its own descendants. Sibling indices shift when the
/// node is detached, so both `to_parent` and `index` are adjusted internally
/// to mean what the caller intended *before* the move.
pub fn move_subtree(
&mut self,
from: &WidgetPath,
to_parent: &WidgetPath,
index: usize,
) -> bool {
if from.is_root() || to_parent.starts_with(from) {
return false;
}
// The destination must exist and be a container; check before detaching
// (removing `from`, which is not an ancestor of `to_parent`, leaves the
// destination node itself unchanged — only its path may shift).
if !self.get_path(to_parent).is_some_and(Widget::is_container) {
return false;
}
let Some(node) = self.remove_path(from) else {
return false;
};
let to_parent = adjust_path_for_removal(to_parent, from);
let (from_parent, from_index) = from.split_last().expect("non-root checked above");
// Inserting back into the same parent after the detach point shifts the
// target slot down by one.
let index = if from_parent.0 == to_parent.0 && from_index < index {
index - 1
} else {
index
};
self.insert_child(&to_parent, index, node)
}
/// Find a descendant (or self) with this id. Returns the first match
/// in pre-order. `None` if no widget matches (or `id` is empty).
pub fn find_by_id(&self, id: &WidgetId) -> Option<&Widget> {
if id.is_empty() {
return None;
}
if self.id == *id {
return Some(self);
}
for child in self.children() {
if let Some(found) = child.find_by_id(id) {
return Some(found);
}
}
None
}
/// Mutable counterpart of [`find_by_id`](Self::find_by_id).
pub fn find_by_id_mut(&mut self, id: &WidgetId) -> Option<&mut Widget> {
if id.is_empty() {
return None;
}
if self.id == *id {
return Some(self);
}
for child in self.children_mut() {
if let Some(found) = child.find_by_id_mut(id) {
return Some(found);
}
}
None
}
/// Borrow the [`WidgetValue`] of the descendant with this id, if any.
/// One half of the piece-6 data-binding loop: read what the UI says.
pub fn value(&self, id: &WidgetId) -> Option<&WidgetValue> {
self.find_by_id(id).and_then(|w| w.value.as_ref())
}
/// Set the [`WidgetValue`] of the descendant with this id, returning
/// `true` if such a widget exists. The other half of the piece-6
/// data-binding loop: write game state into the UI.
pub fn set_value(&mut self, id: &WidgetId, value: impl Into<WidgetValue>) -> bool {
match self.find_by_id_mut(id) {
Some(w) => {
w.value = Some(value.into());
true
}
None => false,
}
}
/// Recursive count of nodes including `self`. Handy for sanity checks
/// in tests when comparing against a [`LayoutTree::nodes`](super::layout::LayoutTree::nodes)
/// length.
pub fn node_count(&self) -> usize {
1 + self
.children()
.iter()
.map(Widget::node_count)
.sum::<usize>()
}
/// Resolve this widget's effective [`VisualStyle`] under a given theme,
/// cascading `theme.default` → `theme.styles[self.theme_style]` →
/// `self.visual`. See [`Theme::resolve`](super::theme::Theme::resolve)
/// for the merge rules. Children are *not* recursively resolved here —
/// piece 4 walks the tree pairing each [`super::layout::LayoutNode`] with
/// its resolved style.
pub fn resolve_visual(&self, theme: &super::theme::Theme) -> VisualStyle {
theme.resolve(self.theme_style.as_deref(), &self.visual)
}
/// Serialize this widget tree to a pretty-printed RON string — the
/// canonical UI-document format an editor saves and the runtime loads.
pub fn to_ron(&self) -> Result<String, ron::Error> {
ron::ser::to_string_pretty(self, ron::ser::PrettyConfig::default())
}
/// Parse a widget tree from a RON string produced by [`to_ron`](Self::to_ron).
pub fn from_ron(text: &str) -> Result<Self, ron::de::SpannedError> {
ron::de::from_str(text)
}
}
/// Rewrites `path` to stay valid after the widget at `removed` is detached.
///
/// Detaching shifts the later siblings of `removed` down by one. A path is
/// affected only if it descends through `removed`'s parent and its index at
/// that depth is *after* the removed index; then that one index decrements.
/// `path` must not be `removed` or beneath it (the caller guarantees this).
fn adjust_path_for_removal(path: &WidgetPath, removed: &WidgetPath) -> WidgetPath {
let Some((removed_parent, removed_index)) = removed.split_last() else {
return path.clone();
};
let depth = removed_parent.0.len();
let mut out = path.0.clone();
if out.len() > depth && out[..depth] == removed_parent.0[..] && out[depth] > removed_index {
out[depth] -= 1;
}
WidgetPath(out)
}
fn children_mut(kind: &mut WidgetKind, f: impl FnOnce(&mut Vec<Widget>)) {
match kind {
WidgetKind::Stack(s) => f(&mut s.children),
WidgetKind::Grid(g) => f(&mut g.children),
WidgetKind::Anchor(a) => f(&mut a.children),
WidgetKind::Leaf { .. } => panic!("cannot add children to a Leaf widget"),
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A row root with three leaf children id'd "a","b","c".
fn abc_tree() -> Widget {
Widget::row()
.with_id("root")
.with_child(Widget::leaf(Vec2::ZERO).with_id("a"))
.with_child(Widget::leaf(Vec2::ZERO).with_id("b"))
.with_child(Widget::leaf(Vec2::ZERO).with_id("c"))
}
fn ids_of(children: &[Widget]) -> Vec<&str> {
children.iter().map(|w| w.id.as_str()).collect()
}
#[test]
fn get_path_addresses_nodes() {
let root = abc_tree();
assert_eq!(
root.get_path(&WidgetPath::root()).unwrap().id.as_str(),
"root"
);
assert_eq!(
root.get_path(&WidgetPath(vec![1])).unwrap().id.as_str(),
"b"
);
assert!(root.get_path(&WidgetPath(vec![9])).is_none());
}
#[test]
fn insert_and_remove_children_by_path() {
let mut root = abc_tree();
// Insert "x" between a and b.
assert!(root.insert_child(
&WidgetPath::root(),
1,
Widget::leaf(Vec2::ZERO).with_id("x")
));
assert_eq!(ids_of(root.children()), ["a", "x", "b", "c"]);
// Append "z" via the clamping path.
assert!(root.push_child_at(&WidgetPath::root(), Widget::leaf(Vec2::ZERO).with_id("z")));
assert_eq!(ids_of(root.children()), ["a", "x", "b", "c", "z"]);
// A leaf rejects children; the root cannot be removed.
assert!(!root.insert_child(&WidgetPath(vec![0]), 0, Widget::default()));
assert!(root.remove_path(&WidgetPath::root()).is_none());
// Remove "x".
let removed = root.remove_path(&WidgetPath(vec![1])).unwrap();
assert_eq!(removed.id.as_str(), "x");
assert_eq!(ids_of(root.children()), ["a", "b", "c", "z"]);
}
#[test]
fn move_subtree_reorders_within_parent() {
let mut root = abc_tree();
// Move "a" (index 0) to the end (index 3 in pre-removal terms).
assert!(root.move_subtree(&WidgetPath(vec![0]), &WidgetPath::root(), 3));
assert_eq!(ids_of(root.children()), ["b", "c", "a"]);
}
#[test]
fn move_subtree_across_branches_adjusts_paths() {
// root[ col(0) [a], b(1), c(2) ]: move c into the column before a.
let mut root = Widget::row()
.with_id("root")
.with_child(
Widget::column()
.with_id("col")
.with_child(Widget::leaf(Vec2::ZERO).with_id("a")),
)
.with_child(Widget::leaf(Vec2::ZERO).with_id("b"))
.with_child(Widget::leaf(Vec2::ZERO).with_id("c"));
assert!(root.move_subtree(&WidgetPath(vec![2]), &WidgetPath(vec![0]), 0));
// c now leads the column; root has col + b left.
assert_eq!(
ids_of(root.get_path(&WidgetPath(vec![0])).unwrap().children()),
["c", "a"]
);
assert_eq!(ids_of(root.children()), ["col", "b"]);
}
#[test]
fn move_subtree_rejects_into_own_descendant_and_root() {
let mut root = Widget::row().with_id("root").with_child(
Widget::column()
.with_id("col")
.with_child(Widget::leaf(Vec2::ZERO).with_id("a")),
);
// Can't move "col" (path [0]) under its own child "a" (path [0,0]).
assert!(!root.move_subtree(&WidgetPath(vec![0]), &WidgetPath(vec![0, 0]), 0));
// Can't move the root.
assert!(!root.move_subtree(&WidgetPath::root(), &WidgetPath(vec![0]), 0));
// Tree is unchanged.
assert_eq!(ids_of(root.children()), ["col"]);
}
#[test]
fn widget_id_from_str_and_string() {
let a: WidgetId = "abc".into();
let b: WidgetId = String::from("abc").into();
assert_eq!(a, b);
assert_eq!(a.as_str(), "abc");
assert!(!a.is_empty());
assert!(WidgetId::default().is_empty());
}
#[test]
fn default_widget_is_zero_leaf() {
let w = Widget::default();
assert_eq!(w.id, WidgetId::default());
assert_eq!(w.style, LayoutStyle::default());
assert!(matches!(w.kind, WidgetKind::Leaf { intrinsic } if intrinsic == Vec2::ZERO));
}
#[test]
fn builder_methods_compose() {
let w = Widget::row()
.with_id("toolbar")
.with_gap(4.0)
.with_main_align(super::super::style::Align::Center)
.with_child(Widget::leaf(Vec2::new(10.0, 10.0)).with_id("a"))
.with_children([Widget::leaf(Vec2::new(20.0, 10.0)).with_id("b")]);
assert_eq!(w.id.as_str(), "toolbar");
let WidgetKind::Stack(s) = &w.kind else {
panic!("expected stack");
};
assert_eq!(s.direction, StackDirection::Row);
assert_eq!(s.gap, 4.0);
assert_eq!(s.main_align, super::super::style::Align::Center);
assert_eq!(s.children.len(), 2);
assert_eq!(s.children[0].id.as_str(), "a");
assert_eq!(s.children[1].id.as_str(), "b");
}
#[test]
#[should_panic(expected = "cannot add children to a Leaf widget")]
fn adding_child_to_leaf_panics() {
let _ = Widget::leaf(Vec2::new(1.0, 1.0)).with_child(Widget::leaf(Vec2::ONE));
}
#[test]
#[should_panic(expected = "with_gap is only valid on Stack widgets")]
fn gap_on_non_stack_panics() {
let _ = Widget::grid(2, 2).with_gap(4.0);
}
#[test]
fn node_count_recurses() {
let tree = Widget::row()
.with_child(Widget::leaf(Vec2::ONE))
.with_child(
Widget::column()
.with_child(Widget::leaf(Vec2::ONE))
.with_child(Widget::leaf(Vec2::ONE)),
);
// root + leaf + (column + 2 leaves) = 5
assert_eq!(tree.node_count(), 5);
}
#[test]
fn widget_round_trips_through_ron() {
let w = Widget::row()
.with_id("root")
.with_gap(8.0)
.with_child(Widget::leaf(Vec2::new(40.0, 20.0)).with_id("a"))
.with_child(Widget::anchor().with_child(Widget::leaf(Vec2::new(10.0, 10.0))));
let text = ron::ser::to_string_pretty(&w, ron::ser::PrettyConfig::default()).unwrap();
let decoded: Widget = ron::de::from_str(&text).unwrap();
assert_eq!(w, decoded);
}
#[test]
fn visual_and_theme_style_builders_set_fields() {
use super::super::visual::VisualStyle;
use crate::math::Color;
let w = Widget::leaf(Vec2::ONE)
.with_id("a")
.with_visual(VisualStyle {
background: Some(Color::RED),
..VisualStyle::EMPTY
})
.with_theme_style("button");
assert_eq!(w.visual.background, Some(Color::RED));
assert_eq!(w.theme_style.as_deref(), Some("button"));
// Passing an empty string drops the reference.
let cleared = w.clone().with_theme_style("");
assert_eq!(cleared.theme_style, None);
let explicitly_cleared = w.clear_theme_style();
assert_eq!(explicitly_cleared.theme_style, None);
}
#[test]
fn resolve_visual_cascades_theme_named_overrides() {
use super::super::theme::Theme;
use super::super::visual::VisualStyle;
use crate::math::Color;
let theme = Theme::new()
.with_default(VisualStyle {
foreground: Some(Color::BLACK),
background: Some(Color::WHITE),
..VisualStyle::EMPTY
})
.with_style(
"button",
VisualStyle {
background: Some(Color::rgb(0.85, 0.85, 0.9)),
..VisualStyle::EMPTY
},
);
let w = Widget::leaf(Vec2::ONE)
.with_theme_style("button")
.with_visual(VisualStyle {
foreground: Some(Color::RED),
..VisualStyle::EMPTY
});
let resolved = w.resolve_visual(&theme);
assert_eq!(resolved.foreground, Some(Color::RED)); // per-instance
assert_eq!(resolved.background, Some(Color::rgb(0.85, 0.85, 0.9))); // named
}
#[test]
fn widget_with_visual_and_theme_style_round_trips_through_ron() {
use super::super::visual::{FontRef, VisualStyle};
use crate::math::Color;
let w = Widget::row()
.with_id("toolbar")
.with_theme_style("toolbar")
.with_visual(VisualStyle {
background: Some(Color::rgb(0.1, 0.1, 0.1)),
font: Some(FontRef::bold("Inter")),
..VisualStyle::EMPTY
})
.with_child(Widget::leaf(Vec2::new(40.0, 20.0)).with_theme_style("button"));
let text = w.to_ron().unwrap();
let decoded = Widget::from_ron(&text).unwrap();
assert_eq!(w, decoded);
}
#[test]
fn default_widget_serializes_without_new_fields() {
// The new `visual` and `theme_style` fields skip when empty/None, so
// a piece-1 default widget should still serialize to the piece-1
// form (no `visual:` or `theme_style:` keys in the output).
let w = Widget::default();
let text = w.to_ron().unwrap();
assert!(!text.contains("visual:"));
assert!(!text.contains("theme_style:"));
// And re-parsing yields the same value.
assert_eq!(Widget::from_ron(&text).unwrap(), w);
}
#[test]
fn find_by_id_walks_the_subtree() {
let tree = Widget::row()
.with_id("root")
.with_child(Widget::leaf(Vec2::ONE).with_id("a"))
.with_child(
Widget::column()
.with_id("group")
.with_child(Widget::leaf(Vec2::ONE).with_id("buried")),
);
assert_eq!(tree.find_by_id(&"root".into()).unwrap().id.as_str(), "root");
assert_eq!(tree.find_by_id(&"a".into()).unwrap().id.as_str(), "a");
assert_eq!(
tree.find_by_id(&"buried".into()).unwrap().id.as_str(),
"buried"
);
assert!(tree.find_by_id(&"missing".into()).is_none());
// Empty id is never a match.
assert!(tree.find_by_id(&WidgetId::default()).is_none());
}
#[test]
fn set_value_updates_a_descendant() {
let mut tree = Widget::row()
.with_id("root")
.with_child(Widget::leaf(Vec2::ONE).with_id("volume"))
.with_child(Widget::leaf(Vec2::ONE).with_id("invert_y"));
assert!(tree.set_value(&"volume".into(), 0.75_f32));
assert!(tree.set_value(&"invert_y".into(), true));
assert_eq!(
tree.value(&"volume".into()).and_then(|v| v.as_float()),
Some(0.75_f32 as f64)
);
assert_eq!(
tree.value(&"invert_y".into()).and_then(|v| v.as_bool()),
Some(true)
);
// Unknown id: returns false, tree unchanged.
assert!(!tree.set_value(&"missing".into(), 0.0_f32));
}
#[test]
fn with_value_builder_sets_value() {
let w = Widget::leaf(Vec2::ONE).with_id("checkbox").with_value(true);
assert_eq!(w.value.as_ref().unwrap().as_bool(), Some(true));
}
#[test]
fn value_round_trips_through_widget_ron() {
use super::super::value::WidgetValue;
let w = Widget::leaf(Vec2::ONE)
.with_id("slider")
.with_value(WidgetValue::Float(0.42));
let text = w.to_ron().unwrap();
let decoded = Widget::from_ron(&text).unwrap();
assert_eq!(w, decoded);
}
}