9eead719b0
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>
934 lines
34 KiB
Rust
934 lines
34 KiB
Rust
//! 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);
|
||
}
|
||
}
|