Skip to main content

rosace_widgets/template/
descriptor.rs

1//! The template descriptor data model (D103 / D102 Tier 1 — rollout step 2).
2//!
3//! A `view!` tree splits into two halves (see `.steering/HOT_RELOAD.md`):
4//!   - **shape** — which widgets, their nesting, and literal props → travels as
5//!     data ([`Template`]); hot-swappable.
6//!   - **logic** — the `{expr}` bits (a `count.to_string()`, an `on_press`
7//!     closure) → stays compiled machine code on the device, never travels;
8//!     it fills numbered [`PropValue::Hole`]s.
9
10/// A literal prop value that travels as **data** — the wire-friendly subset of
11/// what a `view!` prop can be. Anything that is not one of these (a computed
12/// expression, a closure, a struct value) is a [`PropValue::Hole`] filled by
13/// compiled code, not carried in the template.
14///
15/// Kept intentionally primitive so the descriptor has a trivial JSON form when
16/// the transport step needs one (see module docs for the serde deferral).
17#[derive(Clone, Debug, PartialEq)]
18pub enum StaticValue {
19    Bool(bool),
20    /// Integer literal. Widened to `i64` so every `view!` integer literal fits
21    /// one variant; the registry narrows per-setter at inflate time.
22    Int(i64),
23    Float(f64),
24    Str(String),
25}
26
27/// A prop's value in a template: either a compile-time literal that travels as
28/// data, or a numbered **hole** filled at runtime by the already-compiled
29/// `{expr}` at that slot.
30///
31/// Holes are **positional** (index into the frame's hole array) — that is all
32/// hot reload needs, because the dev build recompiles the same source so the
33/// hole order is stable. Name-based binding (what server-driven UI needs, so a
34/// remote `"onSave"` can resolve to a compiled handler) is a documented future
35/// extension, not built here.
36#[derive(Clone, Debug, PartialEq)]
37pub enum PropValue {
38    Static(StaticValue),
39    Hole(usize),
40}
41
42/// One node in a template tree: a widget **kind by name** (the string the
43/// registry maps to a constructor), its props, and its children.
44///
45/// The name is a `String`, not a widget type — inflating it is the interpreter
46/// + registry's job (step 3). This node knows nothing about how it paints.
47#[derive(Clone, Debug, PartialEq)]
48pub struct TemplateNode {
49    /// Widget kind, e.g. `"Column"`, `"Button"` — the registry key.
50    pub widget: String,
51    /// Positional **constructor arguments** (the `("Hi")` in `Text("Hi")`), in
52    /// order — they fill `Widget::new(...)`. Hole slots for args come before
53    /// prop/child slots, matching the macro's traversal order.
54    pub args: Vec<PropValue>,
55    /// `key: value` props, in source order.
56    pub props: Vec<(String, PropValue)>,
57    /// Nested child nodes, in source order.
58    pub children: Vec<TemplateNode>,
59}
60
61impl TemplateNode {
62    /// A leaf node of the given widget kind with no args, props, or children.
63    pub fn new(widget: impl Into<String>) -> Self {
64        Self { widget: widget.into(), args: Vec::new(), props: Vec::new(), children: Vec::new() }
65    }
66
67    /// Add a static (literal) positional constructor arg.
68    pub fn with_arg_static(mut self, value: StaticValue) -> Self {
69        self.args.push(PropValue::Static(value));
70        self
71    }
72
73    /// Add a positional constructor arg bound to a hole slot.
74    pub fn with_arg_hole(mut self, index: usize) -> Self {
75        self.args.push(PropValue::Hole(index));
76        self
77    }
78
79    /// Add a static (literal) prop.
80    pub fn with_static(mut self, key: impl Into<String>, value: StaticValue) -> Self {
81        self.props.push((key.into(), PropValue::Static(value)));
82        self
83    }
84
85    /// Add a hole prop bound to `index` in the frame's hole array.
86    pub fn with_hole(mut self, key: impl Into<String>, index: usize) -> Self {
87        self.props.push((key.into(), PropValue::Hole(index)));
88        self
89    }
90
91    /// Add a child node.
92    pub fn with_child(mut self, child: TemplateNode) -> Self {
93        self.children.push(child);
94        self
95    }
96
97    /// The highest hole index referenced by this node or any descendant, plus
98    /// one — i.e. the number of hole slots the subtree expects. `0` when the
99    /// subtree is fully static.
100    fn hole_extent(&self) -> usize {
101        let hole_idx = |v: &PropValue| match v {
102            PropValue::Hole(i) => Some(i + 1),
103            PropValue::Static(_) => None,
104        };
105        let in_args = self.args.iter().filter_map(hole_idx).max().unwrap_or(0);
106        let in_props = self.props.iter().filter_map(|(_, v)| hole_idx(v)).max().unwrap_or(0);
107        let below = self.children.iter().map(TemplateNode::hole_extent).max().unwrap_or(0);
108        in_args.max(in_props).max(below)
109    }
110}
111
112/// Source-location key identifying a single `view!` site, so the dev watcher
113/// can match an edited template against the one currently running and diff them
114/// (D103's `location!()` key). Stable across a rebuild of the same source.
115#[derive(Clone, Debug, PartialEq, Eq, Hash)]
116pub struct TemplateKey {
117    /// Source file path (from `file!()`).
118    pub file: String,
119    /// 1-based line (from `line!()`).
120    pub line: u32,
121    /// 1-based column (from `column!()`).
122    pub col: u32,
123}
124
125impl TemplateKey {
126    pub fn new(file: impl Into<String>, line: u32, col: u32) -> Self {
127        Self { file: file.into(), line, col }
128    }
129}
130
131impl std::fmt::Display for TemplateKey {
132    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
133        write!(f, "{}:{}:{}", self.file, self.line, self.col)
134    }
135}
136
137/// A full template: the root [`TemplateNode`] plus the number of hole slots it
138/// references, keyed by source location for diffing across hot reloads.
139///
140/// `hole_count` is the **slot signature's** core: a template edit that keeps
141/// the same holes (reorder/wrap/retext static elements) is hot-swappable; one
142/// that changes the hole count adds/removes compiled logic and must escalate
143/// (Tier 2 dylib or Tier 0 restart) — D103's boundary.
144#[derive(Clone, Debug, PartialEq)]
145pub struct Template {
146    pub key: TemplateKey,
147    pub root: TemplateNode,
148    pub hole_count: usize,
149}
150
151impl Template {
152    /// Build a template from its root node, deriving `hole_count` from the
153    /// holes the tree actually references.
154    pub fn new(key: TemplateKey, root: TemplateNode) -> Self {
155        let hole_count = root.hole_extent();
156        Self { key, root, hole_count }
157    }
158
159    /// Whether `self` and `other` share the same hole signature — the cheap
160    /// test for "is this edit hot-swappable, or does it need code?" A full
161    /// per-slot signature comparison arrives with the diff step; hole count is
162    /// the necessary first gate (a changed count is always an escalation).
163    pub fn hole_signature_matches(&self, other: &Template) -> bool {
164        self.hole_count == other.hole_count
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    fn key() -> TemplateKey {
173        TemplateKey::new("src/app.rs", 12, 5)
174    }
175
176    #[test]
177    fn leaf_node_has_no_props_or_children() {
178        let n = TemplateNode::new("Text");
179        assert_eq!(n.widget, "Text");
180        assert!(n.props.is_empty());
181        assert!(n.children.is_empty());
182    }
183
184    #[test]
185    fn builder_records_statics_holes_and_children_in_order() {
186        let n = TemplateNode::new("Button")
187            .with_static("label", StaticValue::Str("Save".into()))
188            .with_hole("on_press", 0)
189            .with_child(TemplateNode::new("Icon"));
190        assert_eq!(n.props.len(), 2);
191        assert_eq!(n.props[0], ("label".to_string(), PropValue::Static(StaticValue::Str("Save".into()))));
192        assert_eq!(n.props[1], ("on_press".to_string(), PropValue::Hole(0)));
193        assert_eq!(n.children.len(), 1);
194        assert_eq!(n.children[0].widget, "Icon");
195    }
196
197    #[test]
198    fn hole_count_is_zero_for_a_fully_static_tree() {
199        let root = TemplateNode::new("Column")
200            .with_static("spacing", StaticValue::Int(12))
201            .with_child(TemplateNode::new("Text").with_static("content", StaticValue::Str("Hi".into())));
202        let t = Template::new(key(), root);
203        assert_eq!(t.hole_count, 0);
204    }
205
206    #[test]
207    fn hole_count_is_max_index_plus_one_across_the_whole_tree() {
208        // Holes 0 and 1 on children, none on the root → count 2.
209        let root = TemplateNode::new("Column")
210            .with_child(TemplateNode::new("Button").with_hole("on_press", 0))
211            .with_child(TemplateNode::new("Button").with_hole("on_press", 1));
212        let t = Template::new(key(), root);
213        assert_eq!(t.hole_count, 2);
214    }
215
216    #[test]
217    fn hole_count_uses_the_highest_index_even_when_sparse() {
218        // Only hole 3 is referenced (0..2 filled by other slots elsewhere) →
219        // the subtree still expects 4 slots so index 3 is addressable.
220        let root = TemplateNode::new("Text").with_hole("content", 3);
221        let t = Template::new(key(), root);
222        assert_eq!(t.hole_count, 4);
223    }
224
225    #[test]
226    fn signature_matches_only_when_hole_counts_are_equal() {
227        // Same shape, one static text differs → same hole count → swappable.
228        let a = Template::new(
229            key(),
230            TemplateNode::new("Text").with_static("content", StaticValue::Str("A".into())),
231        );
232        let b = Template::new(
233            key(),
234            TemplateNode::new("Text").with_static("content", StaticValue::Str("B".into())),
235        );
236        assert!(a.hole_signature_matches(&b));
237
238        // Adding a hole (new compiled logic) changes the count → escalation.
239        let c = Template::new(key(), TemplateNode::new("Text").with_hole("content", 0));
240        assert!(!a.hole_signature_matches(&c));
241    }
242
243    #[test]
244    fn key_displays_as_file_line_col() {
245        assert_eq!(TemplateKey::new("src/app.rs", 12, 5).to_string(), "src/app.rs:12:5");
246    }
247
248    #[test]
249    fn key_equality_and_hashing_identify_a_view_site() {
250        use std::collections::HashSet;
251        let mut seen = HashSet::new();
252        seen.insert(TemplateKey::new("src/app.rs", 12, 5));
253        assert!(seen.contains(&TemplateKey::new("src/app.rs", 12, 5)));
254        assert!(!seen.contains(&TemplateKey::new("src/app.rs", 12, 6)));
255    }
256}