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:
@@ -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()]);
|
||||
}
|
||||
}
|
||||
@@ -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 7–9 — 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,
|
||||
};
|
||||
@@ -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, ¶ms, 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());
|
||||
}
|
||||
}
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
|
||||
/// 5–6.
|
||||
#[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, ¶ms, &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);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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` 100–900) 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user