Files
Oxide/engine/src/ui/widget.rs
T
Homer Simpson 9eead719b0 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>
2026-07-05 20:41:02 +02:00

934 lines
34 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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);
}
}