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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Homer Simpson
2026-07-05 20:41:02 +02:00
parent 2afb56b329
commit 9eead719b0
157 changed files with 47270 additions and 2 deletions
+933
View File
@@ -0,0 +1,933 @@
//! Widget tree — the data structure laid out by [`super::layout`].
//!
//! Stage 8 splits widgets cleanly into **what** (the [`WidgetKind`]) and
//! **how** (the [`LayoutStyle`] held on every node). The kind decides whether
//! a node has children and how they're arranged; the style is the same fields
//! on every widget so the layout algorithm has one place to look.
//!
//! Piece 1 ships only what the layout algorithm needs: a [`Leaf`](WidgetKind::Leaf)
//! placeholder with an intrinsic size, and three container kinds — [`Stack`]
//! (row/column), [`Grid`], and [`AnchorGroup`]. Interactive widgets (button,
//! checkbox, slider, text input, …) are layered on top in later pieces by
//! decorating leaves with kind-specific style/state; they all participate in
//! the same layout pass without the algorithm having to know about them.
//!
//! # Building a tree
//!
//! ```
//! use glam::Vec2;
//! use oxide_engine::ui::{Insets, LayoutStyle, Sizing, Widget};
//!
//! let panel = Widget::row()
//! .with_id("toolbar")
//! .with_style(LayoutStyle {
//! width: Sizing::Grow(1.0),
//! height: Sizing::Fixed(32.0),
//! padding: Insets::all(4.0),
//! ..Default::default()
//! })
//! .with_child(Widget::leaf(Vec2::new(80.0, 24.0)).with_id("file"))
//! .with_child(Widget::leaf(Vec2::new(80.0, 24.0)).with_id("edit"));
//! assert_eq!(panel.children().len(), 2);
//! ```
use glam::Vec2;
use serde::{Deserialize, Serialize};
use super::style::LayoutStyle;
use super::value::WidgetValue;
use super::visual::VisualStyle;
/// Stable identifier for a widget — used to look up its laid-out rect in a
/// [`LayoutTree`](super::layout::LayoutTree) and (in later pieces) to wire up
/// input routing and data binding.
///
/// Stored as `String` so UI documents can ship author-facing names (`"play"`,
/// `"volume-slider"`) straight through RON. The empty id (`""`) is the default
/// and means "anonymous"; multiple anonymous widgets are allowed and lookups
/// by empty id are rejected by [`LayoutTree::find`](super::layout::LayoutTree::find).
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct WidgetId(pub String);
impl WidgetId {
/// `true` if the id string is empty.
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
/// Borrow the underlying string.
pub fn as_str(&self) -> &str {
&self.0
}
}
impl From<&str> for WidgetId {
fn from(s: &str) -> Self {
Self(s.to_owned())
}
}
/// A path from a root [`Widget`] to one of its descendants: the sequence of
/// child indices to follow from the root. The **empty** path denotes the root
/// itself.
///
/// Unlike [`WidgetId`] (optional, author-facing, possibly absent or duplicated)
/// a path addresses *exactly one* node positionally, so it is what the editor's
/// UI canvas uses to target structural edits — insert, remove, move — and to
/// record them on the undo stack. Paths are only valid against the tree they
/// were derived from; an edit that changes sibling order invalidates the paths
/// after it (the move helper accounts for this itself).
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct WidgetPath(pub Vec<usize>);
impl WidgetPath {
/// The root path (addresses the tree's root widget).
pub fn root() -> Self {
Self(Vec::new())
}
/// Whether this path addresses the root (is empty).
pub fn is_root(&self) -> bool {
self.0.is_empty()
}
/// Depth from the root (number of indices).
pub fn len(&self) -> usize {
self.0.len()
}
/// Whether the path is empty — alias of [`is_root`](Self::is_root), provided
/// for the clippy `len`/`is_empty` pairing.
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
/// A child path one level deeper, selecting child `index`.
pub fn child(&self, index: usize) -> Self {
let mut v = self.0.clone();
v.push(index);
Self(v)
}
/// Splits into `(parent_path, last_index)`, or `None` for the root.
pub fn split_last(&self) -> Option<(WidgetPath, usize)> {
let (last, rest) = self.0.split_last()?;
Some((WidgetPath(rest.to_vec()), *last))
}
/// Whether `self` is `other` or lies underneath it (prefix test). Used to
/// reject moving a subtree into its own descendant.
pub fn starts_with(&self, other: &WidgetPath) -> bool {
self.0.starts_with(&other.0)
}
}
impl From<String> for WidgetId {
fn from(s: String) -> Self {
Self(s)
}
}
/// A widget tree node — id, layout style, optional visual style + theme
/// reference, and a kind that decides what children it holds.
///
/// `style` (Stage-8 piece 1) controls layout — where the widget is.
/// `visual` (piece 2) carries per-instance visual overrides — what the
/// widget looks like — and `theme_style` opts into a named entry in the
/// project's [`Theme`](super::theme::Theme). Both default to empty so a
/// piece-1 UI document still parses unchanged.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Widget {
#[serde(default, skip_serializing_if = "WidgetId::is_empty")]
pub id: WidgetId,
#[serde(default)]
pub style: LayoutStyle,
#[serde(default, skip_serializing_if = "VisualStyle::is_empty")]
pub visual: VisualStyle,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub theme_style: Option<String>,
/// Text content shaped inside this widget's `content_rect`. Orthogonal
/// to `kind`: a button is a `Leaf` with `text` + `visual.background`; a
/// label is a `Leaf` with `text` only. Renderers shape this string
/// against the resolved [`VisualStyle::font`] and [`VisualStyle::font_size`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
/// Per-widget typed state — `Bool` for a checkbox, `Float` for a
/// slider, `Text` for a text input. Orthogonal to `kind`; absent
/// means "no state". See [`super::value::WidgetValue`] and the
/// piece-6 [`Widget::value`](Self::value) / [`set_value`](Self::set_value)
/// helpers.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub value: Option<WidgetValue>,
pub kind: WidgetKind,
}
/// What a widget *is* — leaf or one of three container layout modes.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum WidgetKind {
/// A childless node with an intrinsic logical size. Real interactive
/// widgets (label, button, image) layer on top of this in later pieces.
Leaf { intrinsic: Vec2 },
/// Row or column container.
Stack(Stack),
/// Equal-cell grid container.
Grid(Grid),
/// Container that positions each child via the child's own
/// [`Anchor`](super::style::Anchor).
Anchor(AnchorGroup),
}
impl Default for WidgetKind {
fn default() -> Self {
Self::Leaf {
intrinsic: Vec2::ZERO,
}
}
}
/// Stack container — arranges children along a main axis.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Stack {
pub direction: StackDirection,
/// Logical-pixel gap between adjacent children.
#[serde(default)]
pub gap: f32,
/// How leftover space on the main axis is distributed *after* children
/// have been sized. Ignored when any child uses [`Sizing::Grow`](super::style::Sizing::Grow),
/// since `Grow` consumes the leftover space directly.
#[serde(default)]
pub main_align: super::style::Align,
#[serde(default)]
pub children: Vec<Widget>,
}
/// Direction of a [`Stack`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum StackDirection {
/// Children flow left-to-right.
#[default]
Row,
/// Children flow top-to-bottom.
Column,
}
/// Equal-cell grid container — `cols × rows` cells filled in row-major order.
///
/// Piece-1 grids are intentionally simple: every cell is the same size,
/// computed from the parent's content rect. More flexible grids (auto-sized
/// rows/columns, spans) are a follow-up; the use cases the editor's Stage-7
/// preferences page and the Stage-8 settings examples actually need are all
/// served by the equal-cell case.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Grid {
pub cols: u32,
pub rows: u32,
/// `gap.x` between columns, `gap.y` between rows (logical pixels).
#[serde(default)]
pub gap: Vec2,
#[serde(default)]
pub children: Vec<Widget>,
}
/// Anchor container — each child is placed according to its own
/// [`LayoutStyle::anchor`](super::style::LayoutStyle::anchor).
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct AnchorGroup {
#[serde(default)]
pub children: Vec<Widget>,
}
impl Widget {
/// Build a leaf widget with the given intrinsic logical size.
pub fn leaf(intrinsic: Vec2) -> Self {
Self {
kind: WidgetKind::Leaf { intrinsic },
..Default::default()
}
}
/// Build an empty stack with the given direction (gap 0, default align).
pub fn stack(direction: StackDirection) -> Self {
Self {
kind: WidgetKind::Stack(Stack {
direction,
..Default::default()
}),
..Default::default()
}
}
/// Shortcut for `Widget::stack(StackDirection::Row)`.
pub fn row() -> Self {
Self::stack(StackDirection::Row)
}
/// Shortcut for `Widget::stack(StackDirection::Column)`.
pub fn column() -> Self {
Self::stack(StackDirection::Column)
}
/// Build an empty grid container.
pub fn grid(cols: u32, rows: u32) -> Self {
Self {
kind: WidgetKind::Grid(Grid {
cols,
rows,
..Default::default()
}),
..Default::default()
}
}
/// Build an empty anchor container.
pub fn anchor() -> Self {
Self {
kind: WidgetKind::Anchor(AnchorGroup::default()),
..Default::default()
}
}
/// Set the widget id (builder).
pub fn with_id(mut self, id: impl Into<WidgetId>) -> Self {
self.id = id.into();
self
}
/// Replace the whole [`LayoutStyle`] (builder).
pub fn with_style(mut self, style: LayoutStyle) -> Self {
self.style = style;
self
}
/// Replace the per-instance [`VisualStyle`] (builder).
pub fn with_visual(mut self, visual: VisualStyle) -> Self {
self.visual = visual;
self
}
/// Opt this widget into a named entry of the active
/// [`Theme`](super::theme::Theme) (builder). Pass `""` or call
/// [`Widget::clear_theme_style`] to remove the reference.
pub fn with_theme_style(mut self, name: impl Into<String>) -> Self {
let name = name.into();
self.theme_style = if name.is_empty() { None } else { Some(name) };
self
}
/// Drop any `theme_style` reference (builder).
pub fn clear_theme_style(mut self) -> Self {
self.theme_style = None;
self
}
/// Set this widget's text content (builder). Pass `""` to clear it. The
/// text is shaped at paint time against the widget's resolved font and
/// font size from the active theme.
pub fn with_text(mut self, text: impl Into<String>) -> Self {
let s = text.into();
self.text = if s.is_empty() { None } else { Some(s) };
self
}
/// Set this widget's typed value (builder).
pub fn with_value(mut self, value: impl Into<WidgetValue>) -> Self {
self.value = Some(value.into());
self
}
/// Set the stack gap (builder). Panics if not a stack — surfaces author
/// mistakes during construction rather than producing a silently
/// misshapen UI at layout time.
pub fn with_gap(mut self, gap: f32) -> Self {
match &mut self.kind {
WidgetKind::Stack(s) => s.gap = gap,
_ => panic!("with_gap is only valid on Stack widgets"),
}
self
}
/// Set the stack main-axis alignment (builder). Panics if not a stack.
pub fn with_main_align(mut self, align: super::style::Align) -> Self {
match &mut self.kind {
WidgetKind::Stack(s) => s.main_align = align,
_ => panic!("with_main_align is only valid on Stack widgets"),
}
self
}
/// Set the grid gap vector (builder). Panics if not a grid.
pub fn with_grid_gap(mut self, gap: Vec2) -> Self {
match &mut self.kind {
WidgetKind::Grid(g) => g.gap = gap,
_ => panic!("with_grid_gap is only valid on Grid widgets"),
}
self
}
/// Append a single child to a container widget (builder). Panics on a
/// leaf so the misuse is caught at construction.
pub fn with_child(mut self, child: Widget) -> Self {
children_mut(&mut self.kind, |c| c.push(child));
self
}
/// Append many children (builder).
pub fn with_children(mut self, children: impl IntoIterator<Item = Widget>) -> Self {
children_mut(&mut self.kind, |c| c.extend(children));
self
}
/// Borrow the direct children of this widget. Empty for leaves.
pub fn children(&self) -> &[Widget] {
match &self.kind {
WidgetKind::Leaf { .. } => &[],
WidgetKind::Stack(s) => &s.children,
WidgetKind::Grid(g) => &g.children,
WidgetKind::Anchor(a) => &a.children,
}
}
/// Borrow the direct children mutably. Empty slice for leaves.
///
/// Underpins [`find_by_id_mut`](Self::find_by_id_mut) and the piece-6
/// data-binding helpers; safer than reaching into `kind` because all
/// container kinds funnel through one accessor.
pub fn children_mut(&mut self) -> &mut [Widget] {
match &mut self.kind {
WidgetKind::Leaf { .. } => &mut [],
WidgetKind::Stack(s) => &mut s.children,
WidgetKind::Grid(g) => &mut g.children,
WidgetKind::Anchor(a) => &mut a.children,
}
}
/// Borrow this widget's children as the owning `Vec`, or `None` for a
/// [`Leaf`](WidgetKind::Leaf) (which cannot hold children). Unlike
/// [`children_mut`](Self::children_mut) this exposes the `Vec` itself, so
/// callers can insert/remove — the basis of the structural edits below.
pub fn children_vec_mut(&mut self) -> Option<&mut Vec<Widget>> {
match &mut self.kind {
WidgetKind::Leaf { .. } => None,
WidgetKind::Stack(s) => Some(&mut s.children),
WidgetKind::Grid(g) => Some(&mut g.children),
WidgetKind::Anchor(a) => Some(&mut a.children),
}
}
/// Whether this widget is a container (can hold children) rather than a leaf.
pub fn is_container(&self) -> bool {
!matches!(self.kind, WidgetKind::Leaf { .. })
}
/// Borrow the widget addressed by `path` (the root for the empty path), or
/// `None` if any index along the way is out of range.
pub fn get_path(&self, path: &WidgetPath) -> Option<&Widget> {
let mut node = self;
for &i in &path.0 {
node = node.children().get(i)?;
}
Some(node)
}
/// Mutable counterpart of [`get_path`](Self::get_path).
pub fn get_path_mut(&mut self, path: &WidgetPath) -> Option<&mut Widget> {
let mut node = self;
for &i in &path.0 {
node = node.children_mut().get_mut(i)?;
}
Some(node)
}
/// Inserts `child` at `index` among the children of the widget addressed by
/// `parent`, returning whether it succeeded. `index` is clamped to the
/// child count (so it can append). Fails if `parent` does not resolve or is
/// a leaf.
pub fn insert_child(&mut self, parent: &WidgetPath, index: usize, child: Widget) -> bool {
let Some(parent) = self.get_path_mut(parent) else {
return false;
};
let Some(children) = parent.children_vec_mut() else {
return false;
};
children.insert(index.min(children.len()), child);
true
}
/// Appends `child` to the children of the widget addressed by `parent`.
/// Convenience over [`insert_child`](Self::insert_child) with a trailing
/// index.
pub fn push_child_at(&mut self, parent: &WidgetPath, child: Widget) -> bool {
self.insert_child(parent, usize::MAX, child)
}
/// Removes and returns the widget addressed by `path`. The root cannot be
/// removed (returns `None` for the empty path), nor can an out-of-range or
/// unreachable path.
pub fn remove_path(&mut self, path: &WidgetPath) -> Option<Widget> {
let (parent, index) = path.split_last()?;
let children = self.get_path_mut(&parent)?.children_vec_mut()?;
(index < children.len()).then(|| children.remove(index))
}
/// Moves the subtree at `from` to be child `index` of `to_parent`,
/// returning whether it succeeded. Rejects moving the root, or moving a node
/// into itself or one of its own descendants. Sibling indices shift when the
/// node is detached, so both `to_parent` and `index` are adjusted internally
/// to mean what the caller intended *before* the move.
pub fn move_subtree(
&mut self,
from: &WidgetPath,
to_parent: &WidgetPath,
index: usize,
) -> bool {
if from.is_root() || to_parent.starts_with(from) {
return false;
}
// The destination must exist and be a container; check before detaching
// (removing `from`, which is not an ancestor of `to_parent`, leaves the
// destination node itself unchanged — only its path may shift).
if !self.get_path(to_parent).is_some_and(Widget::is_container) {
return false;
}
let Some(node) = self.remove_path(from) else {
return false;
};
let to_parent = adjust_path_for_removal(to_parent, from);
let (from_parent, from_index) = from.split_last().expect("non-root checked above");
// Inserting back into the same parent after the detach point shifts the
// target slot down by one.
let index = if from_parent.0 == to_parent.0 && from_index < index {
index - 1
} else {
index
};
self.insert_child(&to_parent, index, node)
}
/// Find a descendant (or self) with this id. Returns the first match
/// in pre-order. `None` if no widget matches (or `id` is empty).
pub fn find_by_id(&self, id: &WidgetId) -> Option<&Widget> {
if id.is_empty() {
return None;
}
if self.id == *id {
return Some(self);
}
for child in self.children() {
if let Some(found) = child.find_by_id(id) {
return Some(found);
}
}
None
}
/// Mutable counterpart of [`find_by_id`](Self::find_by_id).
pub fn find_by_id_mut(&mut self, id: &WidgetId) -> Option<&mut Widget> {
if id.is_empty() {
return None;
}
if self.id == *id {
return Some(self);
}
for child in self.children_mut() {
if let Some(found) = child.find_by_id_mut(id) {
return Some(found);
}
}
None
}
/// Borrow the [`WidgetValue`] of the descendant with this id, if any.
/// One half of the piece-6 data-binding loop: read what the UI says.
pub fn value(&self, id: &WidgetId) -> Option<&WidgetValue> {
self.find_by_id(id).and_then(|w| w.value.as_ref())
}
/// Set the [`WidgetValue`] of the descendant with this id, returning
/// `true` if such a widget exists. The other half of the piece-6
/// data-binding loop: write game state into the UI.
pub fn set_value(&mut self, id: &WidgetId, value: impl Into<WidgetValue>) -> bool {
match self.find_by_id_mut(id) {
Some(w) => {
w.value = Some(value.into());
true
}
None => false,
}
}
/// Recursive count of nodes including `self`. Handy for sanity checks
/// in tests when comparing against a [`LayoutTree::nodes`](super::layout::LayoutTree::nodes)
/// length.
pub fn node_count(&self) -> usize {
1 + self
.children()
.iter()
.map(Widget::node_count)
.sum::<usize>()
}
/// Resolve this widget's effective [`VisualStyle`] under a given theme,
/// cascading `theme.default` → `theme.styles[self.theme_style]` →
/// `self.visual`. See [`Theme::resolve`](super::theme::Theme::resolve)
/// for the merge rules. Children are *not* recursively resolved here —
/// piece 4 walks the tree pairing each [`super::layout::LayoutNode`] with
/// its resolved style.
pub fn resolve_visual(&self, theme: &super::theme::Theme) -> VisualStyle {
theme.resolve(self.theme_style.as_deref(), &self.visual)
}
/// Serialize this widget tree to a pretty-printed RON string — the
/// canonical UI-document format an editor saves and the runtime loads.
pub fn to_ron(&self) -> Result<String, ron::Error> {
ron::ser::to_string_pretty(self, ron::ser::PrettyConfig::default())
}
/// Parse a widget tree from a RON string produced by [`to_ron`](Self::to_ron).
pub fn from_ron(text: &str) -> Result<Self, ron::de::SpannedError> {
ron::de::from_str(text)
}
}
/// Rewrites `path` to stay valid after the widget at `removed` is detached.
///
/// Detaching shifts the later siblings of `removed` down by one. A path is
/// affected only if it descends through `removed`'s parent and its index at
/// that depth is *after* the removed index; then that one index decrements.
/// `path` must not be `removed` or beneath it (the caller guarantees this).
fn adjust_path_for_removal(path: &WidgetPath, removed: &WidgetPath) -> WidgetPath {
let Some((removed_parent, removed_index)) = removed.split_last() else {
return path.clone();
};
let depth = removed_parent.0.len();
let mut out = path.0.clone();
if out.len() > depth && out[..depth] == removed_parent.0[..] && out[depth] > removed_index {
out[depth] -= 1;
}
WidgetPath(out)
}
fn children_mut(kind: &mut WidgetKind, f: impl FnOnce(&mut Vec<Widget>)) {
match kind {
WidgetKind::Stack(s) => f(&mut s.children),
WidgetKind::Grid(g) => f(&mut g.children),
WidgetKind::Anchor(a) => f(&mut a.children),
WidgetKind::Leaf { .. } => panic!("cannot add children to a Leaf widget"),
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A row root with three leaf children id'd "a","b","c".
fn abc_tree() -> Widget {
Widget::row()
.with_id("root")
.with_child(Widget::leaf(Vec2::ZERO).with_id("a"))
.with_child(Widget::leaf(Vec2::ZERO).with_id("b"))
.with_child(Widget::leaf(Vec2::ZERO).with_id("c"))
}
fn ids_of(children: &[Widget]) -> Vec<&str> {
children.iter().map(|w| w.id.as_str()).collect()
}
#[test]
fn get_path_addresses_nodes() {
let root = abc_tree();
assert_eq!(
root.get_path(&WidgetPath::root()).unwrap().id.as_str(),
"root"
);
assert_eq!(
root.get_path(&WidgetPath(vec![1])).unwrap().id.as_str(),
"b"
);
assert!(root.get_path(&WidgetPath(vec![9])).is_none());
}
#[test]
fn insert_and_remove_children_by_path() {
let mut root = abc_tree();
// Insert "x" between a and b.
assert!(root.insert_child(
&WidgetPath::root(),
1,
Widget::leaf(Vec2::ZERO).with_id("x")
));
assert_eq!(ids_of(root.children()), ["a", "x", "b", "c"]);
// Append "z" via the clamping path.
assert!(root.push_child_at(&WidgetPath::root(), Widget::leaf(Vec2::ZERO).with_id("z")));
assert_eq!(ids_of(root.children()), ["a", "x", "b", "c", "z"]);
// A leaf rejects children; the root cannot be removed.
assert!(!root.insert_child(&WidgetPath(vec![0]), 0, Widget::default()));
assert!(root.remove_path(&WidgetPath::root()).is_none());
// Remove "x".
let removed = root.remove_path(&WidgetPath(vec![1])).unwrap();
assert_eq!(removed.id.as_str(), "x");
assert_eq!(ids_of(root.children()), ["a", "b", "c", "z"]);
}
#[test]
fn move_subtree_reorders_within_parent() {
let mut root = abc_tree();
// Move "a" (index 0) to the end (index 3 in pre-removal terms).
assert!(root.move_subtree(&WidgetPath(vec![0]), &WidgetPath::root(), 3));
assert_eq!(ids_of(root.children()), ["b", "c", "a"]);
}
#[test]
fn move_subtree_across_branches_adjusts_paths() {
// root[ col(0) [a], b(1), c(2) ]: move c into the column before a.
let mut root = Widget::row()
.with_id("root")
.with_child(
Widget::column()
.with_id("col")
.with_child(Widget::leaf(Vec2::ZERO).with_id("a")),
)
.with_child(Widget::leaf(Vec2::ZERO).with_id("b"))
.with_child(Widget::leaf(Vec2::ZERO).with_id("c"));
assert!(root.move_subtree(&WidgetPath(vec![2]), &WidgetPath(vec![0]), 0));
// c now leads the column; root has col + b left.
assert_eq!(
ids_of(root.get_path(&WidgetPath(vec![0])).unwrap().children()),
["c", "a"]
);
assert_eq!(ids_of(root.children()), ["col", "b"]);
}
#[test]
fn move_subtree_rejects_into_own_descendant_and_root() {
let mut root = Widget::row().with_id("root").with_child(
Widget::column()
.with_id("col")
.with_child(Widget::leaf(Vec2::ZERO).with_id("a")),
);
// Can't move "col" (path [0]) under its own child "a" (path [0,0]).
assert!(!root.move_subtree(&WidgetPath(vec![0]), &WidgetPath(vec![0, 0]), 0));
// Can't move the root.
assert!(!root.move_subtree(&WidgetPath::root(), &WidgetPath(vec![0]), 0));
// Tree is unchanged.
assert_eq!(ids_of(root.children()), ["col"]);
}
#[test]
fn widget_id_from_str_and_string() {
let a: WidgetId = "abc".into();
let b: WidgetId = String::from("abc").into();
assert_eq!(a, b);
assert_eq!(a.as_str(), "abc");
assert!(!a.is_empty());
assert!(WidgetId::default().is_empty());
}
#[test]
fn default_widget_is_zero_leaf() {
let w = Widget::default();
assert_eq!(w.id, WidgetId::default());
assert_eq!(w.style, LayoutStyle::default());
assert!(matches!(w.kind, WidgetKind::Leaf { intrinsic } if intrinsic == Vec2::ZERO));
}
#[test]
fn builder_methods_compose() {
let w = Widget::row()
.with_id("toolbar")
.with_gap(4.0)
.with_main_align(super::super::style::Align::Center)
.with_child(Widget::leaf(Vec2::new(10.0, 10.0)).with_id("a"))
.with_children([Widget::leaf(Vec2::new(20.0, 10.0)).with_id("b")]);
assert_eq!(w.id.as_str(), "toolbar");
let WidgetKind::Stack(s) = &w.kind else {
panic!("expected stack");
};
assert_eq!(s.direction, StackDirection::Row);
assert_eq!(s.gap, 4.0);
assert_eq!(s.main_align, super::super::style::Align::Center);
assert_eq!(s.children.len(), 2);
assert_eq!(s.children[0].id.as_str(), "a");
assert_eq!(s.children[1].id.as_str(), "b");
}
#[test]
#[should_panic(expected = "cannot add children to a Leaf widget")]
fn adding_child_to_leaf_panics() {
let _ = Widget::leaf(Vec2::new(1.0, 1.0)).with_child(Widget::leaf(Vec2::ONE));
}
#[test]
#[should_panic(expected = "with_gap is only valid on Stack widgets")]
fn gap_on_non_stack_panics() {
let _ = Widget::grid(2, 2).with_gap(4.0);
}
#[test]
fn node_count_recurses() {
let tree = Widget::row()
.with_child(Widget::leaf(Vec2::ONE))
.with_child(
Widget::column()
.with_child(Widget::leaf(Vec2::ONE))
.with_child(Widget::leaf(Vec2::ONE)),
);
// root + leaf + (column + 2 leaves) = 5
assert_eq!(tree.node_count(), 5);
}
#[test]
fn widget_round_trips_through_ron() {
let w = Widget::row()
.with_id("root")
.with_gap(8.0)
.with_child(Widget::leaf(Vec2::new(40.0, 20.0)).with_id("a"))
.with_child(Widget::anchor().with_child(Widget::leaf(Vec2::new(10.0, 10.0))));
let text = ron::ser::to_string_pretty(&w, ron::ser::PrettyConfig::default()).unwrap();
let decoded: Widget = ron::de::from_str(&text).unwrap();
assert_eq!(w, decoded);
}
#[test]
fn visual_and_theme_style_builders_set_fields() {
use super::super::visual::VisualStyle;
use crate::math::Color;
let w = Widget::leaf(Vec2::ONE)
.with_id("a")
.with_visual(VisualStyle {
background: Some(Color::RED),
..VisualStyle::EMPTY
})
.with_theme_style("button");
assert_eq!(w.visual.background, Some(Color::RED));
assert_eq!(w.theme_style.as_deref(), Some("button"));
// Passing an empty string drops the reference.
let cleared = w.clone().with_theme_style("");
assert_eq!(cleared.theme_style, None);
let explicitly_cleared = w.clear_theme_style();
assert_eq!(explicitly_cleared.theme_style, None);
}
#[test]
fn resolve_visual_cascades_theme_named_overrides() {
use super::super::theme::Theme;
use super::super::visual::VisualStyle;
use crate::math::Color;
let theme = Theme::new()
.with_default(VisualStyle {
foreground: Some(Color::BLACK),
background: Some(Color::WHITE),
..VisualStyle::EMPTY
})
.with_style(
"button",
VisualStyle {
background: Some(Color::rgb(0.85, 0.85, 0.9)),
..VisualStyle::EMPTY
},
);
let w = Widget::leaf(Vec2::ONE)
.with_theme_style("button")
.with_visual(VisualStyle {
foreground: Some(Color::RED),
..VisualStyle::EMPTY
});
let resolved = w.resolve_visual(&theme);
assert_eq!(resolved.foreground, Some(Color::RED)); // per-instance
assert_eq!(resolved.background, Some(Color::rgb(0.85, 0.85, 0.9))); // named
}
#[test]
fn widget_with_visual_and_theme_style_round_trips_through_ron() {
use super::super::visual::{FontRef, VisualStyle};
use crate::math::Color;
let w = Widget::row()
.with_id("toolbar")
.with_theme_style("toolbar")
.with_visual(VisualStyle {
background: Some(Color::rgb(0.1, 0.1, 0.1)),
font: Some(FontRef::bold("Inter")),
..VisualStyle::EMPTY
})
.with_child(Widget::leaf(Vec2::new(40.0, 20.0)).with_theme_style("button"));
let text = w.to_ron().unwrap();
let decoded = Widget::from_ron(&text).unwrap();
assert_eq!(w, decoded);
}
#[test]
fn default_widget_serializes_without_new_fields() {
// The new `visual` and `theme_style` fields skip when empty/None, so
// a piece-1 default widget should still serialize to the piece-1
// form (no `visual:` or `theme_style:` keys in the output).
let w = Widget::default();
let text = w.to_ron().unwrap();
assert!(!text.contains("visual:"));
assert!(!text.contains("theme_style:"));
// And re-parsing yields the same value.
assert_eq!(Widget::from_ron(&text).unwrap(), w);
}
#[test]
fn find_by_id_walks_the_subtree() {
let tree = Widget::row()
.with_id("root")
.with_child(Widget::leaf(Vec2::ONE).with_id("a"))
.with_child(
Widget::column()
.with_id("group")
.with_child(Widget::leaf(Vec2::ONE).with_id("buried")),
);
assert_eq!(tree.find_by_id(&"root".into()).unwrap().id.as_str(), "root");
assert_eq!(tree.find_by_id(&"a".into()).unwrap().id.as_str(), "a");
assert_eq!(
tree.find_by_id(&"buried".into()).unwrap().id.as_str(),
"buried"
);
assert!(tree.find_by_id(&"missing".into()).is_none());
// Empty id is never a match.
assert!(tree.find_by_id(&WidgetId::default()).is_none());
}
#[test]
fn set_value_updates_a_descendant() {
let mut tree = Widget::row()
.with_id("root")
.with_child(Widget::leaf(Vec2::ONE).with_id("volume"))
.with_child(Widget::leaf(Vec2::ONE).with_id("invert_y"));
assert!(tree.set_value(&"volume".into(), 0.75_f32));
assert!(tree.set_value(&"invert_y".into(), true));
assert_eq!(
tree.value(&"volume".into()).and_then(|v| v.as_float()),
Some(0.75_f32 as f64)
);
assert_eq!(
tree.value(&"invert_y".into()).and_then(|v| v.as_bool()),
Some(true)
);
// Unknown id: returns false, tree unchanged.
assert!(!tree.set_value(&"missing".into(), 0.0_f32));
}
#[test]
fn with_value_builder_sets_value() {
let w = Widget::leaf(Vec2::ONE).with_id("checkbox").with_value(true);
assert_eq!(w.value.as_ref().unwrap().as_bool(), Some(true));
}
#[test]
fn value_round_trips_through_widget_ron() {
use super::super::value::WidgetValue;
let w = Widget::leaf(Vec2::ONE)
.with_id("slider")
.with_value(WidgetValue::Float(0.42));
let text = w.to_ron().unwrap();
let decoded = Widget::from_ron(&text).unwrap();
assert_eq!(w, decoded);
}
}