Skip to main content

rux_runtime/
lib.rs

1//! Rux runtime, milestones M2–M9.
2//!
3//! The document model: loads a `.rux` file, resolves its `use` component imports
4//! (loading each imported `.rux`), builds the script [`Engine`] (merging the main
5//! and component scripts, registering host functions), and builds the renderable
6//! tree with bindings, directives, and component expansions resolved. Running an
7//! `@tap` handler mutates engine state; `rebuild` refreshes the tree.
8
9use std::collections::{HashMap, HashSet};
10use std::path::{Path, PathBuf};
11
12use rux_layout::Node as LayoutNode;
13use rux_parser::Sfc;
14use rux_script::{Builder, Engine};
15use rux_style::BindingRegistry;
16/// Re-exported so the shell can hand pointer/focus state and the window size in
17/// without depending on `rux-style` directly.
18pub use rux_reactive::json_string;
19pub use rux_style::{InteractionState, Viewport, Warning};
20
21/// A loaded `.rux` document: parsed source, imported components (by tag), the
22/// script engine, and the current tree.
23pub struct Document {
24    sfc: Sfc,
25    components: HashMap<String, Sfc>,
26    engine: Engine,
27    /// Directory the document was loaded from, `<image src>` resolves against it.
28    base: PathBuf,
29    /// The focused input, with its caret and selection, if any. Re-applied on
30    /// every rebuild so both survive a state change.
31    focus: Option<Focus>,
32    /// Where each patchable text binding lives and which signals force a rebuild,
33    /// refreshed on every full build. Lets [`Document::patch`] update value
34    /// bindings in place instead of throwing the tree away.
35    registry: BindingRegistry,
36    /// What the pointer is over / pressing, and which input has focus, the state
37    /// `:hover`, `:active` and `:focus` match against. Owned here so every build
38    /// (rebuild, reconcile, hot-reload) reproduces the same styling.
39    state: InteractionState,
40    /// The window size `@media` queries are evaluated against.
41    viewport: Viewport,
42    /// What is currently wrong with this document, for the dev overlay.
43    diagnostics: Diagnostics,
44    pub root: LayoutNode,
45}
46
47/// What is wrong with the document right now, the model behind the dev overlay.
48///
49/// An **error** means the file could not be loaded at all: there is no tree to
50/// show, so the window would otherwise be blank (or, on hot-reload, silently
51/// stale). A **warning** means the document built, but something in it does
52/// nothing, an unhonored property, an unknown pseudo-class, an undefined
53/// `var()`, an unsupported `@media`.
54///
55/// Both used to go only to stderr, which nobody running a GUI app is watching.
56#[derive(Clone, Debug, Default, PartialEq)]
57pub struct Diagnostics {
58    /// The load/parse failure, if the document is currently broken.
59    pub error: Option<String>,
60    /// Whether the tree on screen predates that error (a failed hot-reload keeps
61    /// the last good UI rather than blanking the window).
62    pub stale: bool,
63    pub warnings: Vec<Warning>,
64}
65
66impl Diagnostics {
67    pub fn is_empty(&self) -> bool {
68        self.error.is_none() && self.warnings.is_empty()
69    }
70}
71
72/// Which input has keyboard focus, and where its caret and selection are.
73///
74/// The selection is the range between `anchor` (where it started) and `caret`
75/// (where it has been dragged/extended to); `anchor == caret` means no selection,
76/// just a caret. Either may be the smaller, dragging leftwards puts the caret
77/// before the anchor, so consumers normalize with [`Focus::range`].
78#[derive(Clone, Debug, PartialEq)]
79pub struct Focus {
80    pub model: String,
81    pub caret: usize,
82    pub anchor: usize,
83    /// The byte range of an in-progress IME composition, if one is running. The
84    /// composed text is already in the bound value; this only marks which part
85    /// of it is provisional, so the painter can underline it.
86    pub preedit: Option<(usize, usize)>,
87}
88
89impl Focus {
90    /// A plain caret with nothing selected.
91    pub fn at(model: impl Into<String>, caret: usize) -> Self {
92        let model = model.into();
93        Self { model, caret, anchor: caret, preedit: None }
94    }
95
96    /// The selected range, low to high.
97    pub fn range(&self) -> (usize, usize) {
98        (self.caret.min(self.anchor), self.caret.max(self.anchor))
99    }
100
101    pub fn is_collapsed(&self) -> bool {
102        self.caret == self.anchor
103    }
104}
105
106/// Mark the focused input's text child with the caret position and selection, so
107/// it paints them, and clear every other input's.
108///
109/// Clearing matters: this runs against the *existing* tree when focus moves, not
110/// only against a freshly built one. Setting without clearing left the caret
111/// showing in the input you just left, until some unrelated rebuild wiped it.
112/// The selection is one more thing that can be left behind the same way.
113fn apply_focus(node: &mut LayoutNode, focus: Option<&Focus>) {
114    if node.model.is_some() {
115        if let Some(text) = node.children.first_mut().and_then(|c| c.text.as_mut()) {
116            let mine = focus.filter(|f| node.model.as_deref() == Some(f.model.as_str()));
117            // An empty input shows its placeholder; the caret still sits at 0.
118            text.caret = mine.map(|f| f.caret.min(text.text.len()));
119            text.selection = mine.filter(|f| !f.is_collapsed()).map(|f| {
120                let (start, end) = f.range();
121                (start.min(text.text.len()), end.min(text.text.len()))
122            });
123            text.preedit = mine.and_then(|f| f.preedit).map(|(start, end)| {
124                (start.min(text.text.len()), end.min(text.text.len()))
125            });
126        }
127    }
128    for child in &mut node.children {
129        apply_focus(child, focus);
130    }
131}
132
133/// The deepest node whose subtree covers both paths, where the old and new
134/// pointer targets diverge. Re-cascading from here restyles every element that
135/// gained or lost the state and nothing else, because `:hover`/`:active` hold for
136/// the whole chain from the root down to the pointer, and the two chains are
137/// identical above the divergence.
138///
139/// When one side is `None` the pointer entered from (or left to) nothing, and the
140/// entire chain changed state, including ancestors, so the splice starts at the
141/// root. That is a full re-cascade, but only on entering/leaving all interactive
142/// boxes, and only in documents that use pointer-state rules at all.
143fn divergence(a: Option<&[usize]>, b: Option<&[usize]>) -> Vec<usize> {
144    match (a, b) {
145        (Some(a), Some(b)) => a.iter().zip(b).take_while(|(x, y)| x == y).map(|(x, _)| *x).collect(),
146        _ => Vec::new(),
147    }
148}
149
150/// Drain both warning sinks, the cascade's (unhonored properties, unknown
151/// pseudo-classes, undefined `var()`s, unsupported `@media`) and the script's
152/// (expressions that failed to compile or evaluate).
153fn collect_warnings() -> Vec<Warning> {
154    let mut warnings = rux_style::take_warnings();
155    warnings.extend(rux_script::take_warnings());
156    warnings
157}
158
159/// Drain the warning sinks without building anything.
160///
161/// The sinks are global and are only emptied by a *successful* build, so a load
162/// that fails partway leaves whatever it managed to warn about sitting there,
163/// ready to be misattributed to the next file. Anything checking more than one
164/// document in a row needs to be able to clear them between files.
165pub fn take_warnings() -> Vec<Warning> {
166    collect_warnings()
167}
168
169/// Stop mirroring warnings to stderr as they are raised. Covers both sinks, so a
170/// tool that formats them itself does not have to know there are two.
171pub fn set_stderr_echo(on: bool) {
172    rux_script::set_stderr_echo(on);
173    rux_style::set_stderr_echo(on);
174}
175
176/// Whether this file is a document in its own right, rather than a component
177/// meant to be used by one. `None` means the question could not be answered,
178/// because the file would not read or parse.
179///
180/// The test is the one the spec already sets: "the application entry point is a
181/// component whose root is `<screen>`". Anything else is a fragment expecting a
182/// parent.
183///
184/// A checker needs the distinction. A component's `{{ prop }}` bindings are
185/// supplied by whoever uses it, so loading one on its own reports every prop as
186/// an undefined variable: failures that say nothing about whether the file is
187/// correct. Going by the root rather than by who imports what also catches a
188/// component that nothing currently uses.
189pub fn is_entry_point(path: impl AsRef<Path>) -> Option<bool> {
190    let src = std::fs::read_to_string(path.as_ref()).ok()?;
191    let sfc = rux_parser::parse_sfc(&src).ok()?;
192    Some(sfc.template.tag == "screen")
193}
194
195/// Resolve every `<image src>` in the tree against `base` and read its intrinsic
196/// size, so a sizeless `<image>` lays out at its natural pixel dimensions. Only
197/// the file header is read, not the pixels; the painter decodes and caches those.
198fn resolve_images(node: &mut LayoutNode, base: &Path) {
199    if let Some(img) = &mut node.image {
200        if !img.src.is_empty() {
201            let path = base.join(&img.src);
202            if let Ok((w, h)) = image::image_dimensions(&path) {
203                img.intrinsic = (w as f32, h as f32);
204            } else {
205                eprintln!("rux: cannot read image {}", path.display());
206            }
207            img.src = path.to_string_lossy().into_owned();
208        }
209    }
210    // `background-image: url(…)` resolves against the .rux file too. The painter
211    // sizes it to the box, so no intrinsic size is needed here.
212    if let Some(rux_layout::Background::Image(src)) = &mut node.style.background {
213        if !src.is_empty() {
214            *src = base.join(&*src).to_string_lossy().into_owned();
215        }
216    }
217    for child in &mut node.children {
218        resolve_images(child, base);
219    }
220}
221
222/// What went wrong loading a document, with the position kept when there is one.
223///
224/// [`Document::load`] flattens this to a string, which is what the dev overlay
225/// wants: prose in a panel. A checker wants the parts separately, because an
226/// editor cannot put a squiggle under a sentence. Same failure, two audiences,
227/// so the structure is preserved here and thrown away at the last moment.
228#[derive(Clone, Debug, PartialEq)]
229pub struct LoadError {
230    pub message: String,
231    /// The file the error is actually in, which is not always the file that was
232    /// asked for: a component is reached through its parent's `use`.
233    pub file: Option<PathBuf>,
234    pub line: Option<usize>,
235    pub column: Option<usize>,
236    /// Whether this came from the parser, which is the only stage that knows a
237    /// position. Kept so the flattened form reads the way it always has.
238    parse: bool,
239}
240
241impl LoadError {
242    fn plain(message: String) -> Self {
243        Self { message, file: None, line: None, column: None, parse: false }
244    }
245
246    /// `file` is `None` when the source did not come from one, which is the
247    /// playground's case: it has a buffer, not a path.
248    fn parse(err: rux_parser::ParseError, file: Option<&Path>) -> Self {
249        Self {
250            message: err.message,
251            file: file.map(Path::to_path_buf),
252            line: err.line,
253            column: err.column,
254            parse: true,
255        }
256    }
257}
258
259impl std::fmt::Display for LoadError {
260    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
261        if !self.parse {
262            return write!(f, "{}", self.message);
263        }
264        match (self.line, self.column) {
265            (Some(l), Some(c)) => {
266                write!(f, "parse error at line {l}, column {c}: {}", self.message)
267            }
268            _ => write!(f, "parse error: {}", self.message),
269        }
270    }
271}
272
273impl std::error::Error for LoadError {}
274
275impl Document {
276    /// Load a document, flattening any failure to a sentence.
277    pub fn load(path: impl AsRef<Path>) -> Result<Self, String> {
278        Self::load_checked(path).map_err(|e| e.to_string())
279    }
280
281    /// Load a document, keeping the failure's position so a checker can point at
282    /// it. [`Document::load`] is this with the structure discarded.
283    pub fn load_checked(path: impl AsRef<Path>) -> Result<Self, LoadError> {
284        let path = path.as_ref();
285        let src = std::fs::read_to_string(path)
286            .map_err(|e| LoadError::plain(format!("reading {}: {e}", path.display())))?;
287        let sfc = rux_parser::parse_sfc(&src).map_err(|e| LoadError::parse(e, Some(path)))?;
288
289        // Resolve `use module::component;` imports relative to this file.
290        let base = path.parent().unwrap_or_else(|| Path::new("."));
291        let (main_script, imports) = extract_imports(&sfc.script);
292
293        let mut components = HashMap::new();
294        let mut combined_script = main_script;
295        for import in imports {
296            let comp_path = base.join(&import.file);
297            let comp_src = std::fs::read_to_string(&comp_path).map_err(|e| {
298                LoadError::plain(format!("reading component {}: {e}", comp_path.display()))
299            })?;
300            let comp_sfc =
301                rux_parser::parse_sfc(&comp_src).map_err(|e| LoadError::parse(e, Some(&comp_path)))?;
302            let (comp_script, _nested) = extract_imports(&comp_sfc.script);
303            // Merge the component's (pure) functions into the shared engine.
304            combined_script.push('\n');
305            combined_script.push_str(&comp_script);
306            components.insert(import.tag, comp_sfc);
307        }
308
309        let mut engine = build_engine(&combined_script).map_err(LoadError::plain)?;
310        let (mut root, registry) = rux_style::build_styled_tree_tracked(&sfc, &components, &mut engine)
311            .map_err(LoadError::plain)?;
312        resolve_images(&mut root, base);
313        Ok(Self {
314            sfc,
315            components,
316            engine,
317            base: base.to_path_buf(),
318            focus: None,
319            registry,
320            state: InteractionState::default(),
321            viewport: Viewport::default(),
322            // Whatever the build just complained about, ready for the overlay.
323            diagnostics: Diagnostics {
324                warnings: collect_warnings(),
325                ..Diagnostics::default()
326            },
327            root,
328        })
329    }
330
331    /// Process `.rux` source with no import resolution (used for fallbacks/tests).
332    pub fn from_source(src: &str) -> Result<Self, String> {
333        Self::from_source_checked(src).map_err(|e| e.to_string())
334    }
335
336    /// [`Document::from_source`], keeping the failure's position.
337    ///
338    /// The playground needs this: it has no file to point at, so a parse error
339    /// with no line was all it could ever show, and "something is wrong
340    /// somewhere" is not much of an editor.
341    pub fn from_source_checked(src: &str) -> Result<Self, LoadError> {
342        let sfc = rux_parser::parse_sfc(src).map_err(|e| LoadError::parse(e, None))?;
343        let (main_script, _imports) = extract_imports(&sfc.script);
344        let mut engine = build_engine(&main_script).map_err(LoadError::plain)?;
345        let (mut root, registry) =
346            rux_style::build_styled_tree_tracked(&sfc, &HashMap::new(), &mut engine)
347                .map_err(LoadError::plain)?;
348        let base = PathBuf::from(".");
349        resolve_images(&mut root, &base);
350        Ok(Self {
351            sfc,
352            components: HashMap::new(),
353            engine,
354            base,
355            focus: None,
356            registry,
357            state: InteractionState::default(),
358            viewport: Viewport::default(),
359            diagnostics: Diagnostics {
360                warnings: collect_warnings(),
361                ..Diagnostics::default()
362            },
363            root,
364        })
365    }
366
367    /// The script engine, for running `@tap` handlers.
368    pub fn engine_mut(&mut self) -> &mut Engine {
369        &mut self.engine
370    }
371
372    /// What is currently wrong with this document, for the dev overlay.
373    pub fn diagnostics(&self) -> &Diagnostics {
374        &self.diagnostics
375    }
376
377    /// Record that a re-load failed. The tree on screen stays as it was, so the
378    /// app keeps working while the file is broken; the overlay says so, and marks
379    /// what you're looking at as stale.
380    pub fn set_load_error(&mut self, error: impl Into<String>) {
381        self.diagnostics.error = Some(error.into());
382        self.diagnostics.stale = true;
383    }
384
385    /// Mark the visible tree as *not* a leftover from before the error, used when
386    /// the very first load failed, so there is no earlier version being shown.
387    pub fn clear_stale(&mut self) {
388        self.diagnostics.stale = false;
389    }
390
391    /// Adopt a freshly loaded document's tree and state, keeping this one's
392    /// identity. Used by hot-reload so a successful load clears the error.
393    pub fn replace_with(&mut self, mut fresh: Document) {
394        // A reload rebuilds from scratch, so focus is legitimately reset, but the
395        // viewport and pointer state belong to the window, not the file.
396        fresh.viewport = self.viewport;
397        fresh.state = self.state.clone();
398        fresh.rebuild();
399        *self = fresh;
400    }
401
402    /// Focus an input (by `r-model`), with its caret and selection. `None` clears.
403    pub fn set_focus(&mut self, focus: Option<Focus>) {
404        self.focus = focus;
405        apply_focus(&mut self.root, self.focus.as_ref());
406    }
407
408    /// The pointer/focus state pseudo-class selectors match against.
409    pub fn interaction(&self) -> &InteractionState {
410        &self.state
411    }
412
413    /// Update the interaction state (`:hover` / `:active` / `:focus`) and restyle
414    /// what it affects. Returns whether anything was restyled, so the shell knows
415    /// whether to repaint, `false` for the overwhelmingly common case of the
416    /// pointer moving within the same element.
417    ///
418    /// Only the affected subtree is spliced, not the whole tree: hover moving
419    /// between two siblings re-cascades their common parent's subtree, so a caret
420    /// or selection anywhere else survives by node identity, the same reconcile
421    /// discipline signal changes use.
422    pub fn set_interaction(&mut self, next: InteractionState) -> bool {
423        if next == self.state {
424            return false;
425        }
426        // A focus move can restyle anything (`:focus` is matched by model, not by
427        // path, and `.field:focus .hint` reaches elsewhere), so it re-cascades from
428        // the root. It is rare, one click or Tab, and the caret is being moved
429        // anyway, so there is no ephemeral state left to preserve.
430        let mut roots: Vec<Vec<usize>> = Vec::new();
431        if next.focused_model == self.state.focused_model {
432            roots.push(divergence(self.state.hovered.as_deref(), next.hovered.as_deref()));
433            roots.push(divergence(self.state.active.as_deref(), next.active.as_deref()));
434        } else {
435            roots.push(Vec::new());
436        }
437        self.state = next;
438        self.restyle(&roots);
439        true
440    }
441
442    /// Tell the document the window size, for `@media`. Returns whether any query
443    /// changed answer, i.e. whether the rule set moved and the tree had to be
444    /// re-cascaded.
445    ///
446    /// A resize fires continuously, and almost every one crosses no breakpoint, so
447    /// the common case must be free: the media conditions are evaluated at the old
448    /// and new size and compared, and the tree is only rebuilt when that vector
449    /// actually differs. A document with no `@media` at all compares two empty
450    /// vectors and never rebuilds.
451    pub fn set_viewport(&mut self, viewport: Viewport) -> bool {
452        if viewport == self.viewport {
453            return false;
454        }
455        let before = self.media_state(self.viewport);
456        let after = self.media_state(viewport);
457        self.viewport = viewport;
458        if before == after {
459            return false;
460        }
461        // A breakpoint was crossed: re-cascade everything. Focus is re-applied by
462        // `rebuild`, and scroll offsets live in the shell, so nothing is lost.
463        self.rebuild();
464        true
465    }
466
467    /// Whether each `@media` block, in the document and in every component,
468    /// applies at `viewport`.
469    fn media_state(&self, viewport: Viewport) -> Vec<bool> {
470        let mut out = rux_style::media_matches(&self.sfc.style, viewport);
471        // Components are keyed by tag in a HashMap, so sort for a stable order.
472        let mut tags: Vec<&String> = self.components.keys().collect();
473        tags.sort();
474        for tag in tags {
475            out.extend(rux_style::media_matches(&self.components[tag].style, viewport));
476        }
477        out
478    }
479
480    /// Rebuild the given subtrees against the current interaction state and splice
481    /// them into the live tree, re-applying focus scoped to each.
482    fn restyle(&mut self, roots: &[Vec<usize>]) {
483        let Ok((mut fresh_root, fresh_reg)) = rux_style::build_styled_tree_stateful(
484            &self.sfc,
485            &self.components,
486            &mut self.engine,
487            &self.state,
488            self.viewport,
489        ) else {
490            return;
491        };
492        resolve_images(&mut fresh_root, &self.base);
493        for path in roots {
494            let Some(fresh) = node_at(&fresh_root, path) else { continue };
495            let fresh_node = fresh.clone();
496            if let Some(live) = node_at_mut(&mut self.root, path) {
497                *live = fresh_node;
498                apply_focus(live, self.focus.as_ref());
499            }
500        }
501        self.registry = fresh_reg;
502    }
503
504    /// Rebuild the layout tree from the engine's current state.
505    pub fn rebuild(&mut self) {
506        if let Ok((mut root, registry)) = rux_style::build_styled_tree_stateful(
507            &self.sfc,
508            &self.components,
509            &mut self.engine,
510            &self.state,
511            self.viewport,
512        ) {
513            resolve_images(&mut root, &self.base);
514            apply_focus(&mut root, self.focus.as_ref());
515            self.registry = registry;
516            self.root = root;
517            // Refresh what the overlay lists: a rebuild re-runs the cascade and
518            // every binding, so it re-raises exactly what this document still has
519            // wrong.
520            self.diagnostics.warnings = collect_warnings();
521        }
522    }
523
524    /// Apply a set of changed signals *in place* where possible: re-evaluate the
525    /// text bindings that read them and write the new strings into their nodes,
526    /// without rebuilding the tree (so ephemeral state, caret, scroll, survives
527    /// untouched). Returns `false` when the change can't be patched, it touched a
528    /// signal that drives structure, an attribute, an input value, or a component
529    /// prop, in which case the caller must [`rebuild`](Self::rebuild). Nothing is
530    /// mutated on the `false` path.
531    #[must_use]
532    pub fn patch(&mut self, changed: &HashSet<String>) -> bool {
533        if changed.is_empty() {
534            return true; // nothing changed → nothing to do, and no rebuild needed
535        }
536        // A non-reconcilable structural read (component prop, `:src`/`:options`, or
537        // a toggle's `checked` class) still needs a full rebuild.
538        if !self.registry.structural.is_disjoint(changed) {
539            return false;
540        }
541        // r-if/r-for: reconcile just the affected subtrees in place.
542        self.reconcile(changed);
543        // Text, input values, and r-show update in place.
544        self.patch_values(changed);
545        true
546    }
547
548    /// Re-evaluate the value bindings (text `{{ }}`, input values, `r-show`) whose
549    /// deps changed and write them into their nodes, no shape change.
550    fn patch_values(&mut self, changed: &HashSet<String>) {
551        for binding in &self.registry.text {
552            if binding.deps.is_disjoint(changed) {
553                continue;
554            }
555            let text = rux_style::eval_text_binding(binding, &mut self.engine);
556            if let Some(node) = node_at_mut(&mut self.root, &binding.path) {
557                if let Some(content) = node.text.as_mut() {
558                    content.text = text;
559                }
560            }
561        }
562        // Input values live in the input's first child; patch their text + colour
563        // so a keystroke doesn't rebuild. The caret/selection on that child are
564        // left untouched (the shell sets them via `set_focus`).
565        for binding in &self.registry.value {
566            if binding.deps.is_disjoint(changed) {
567                continue;
568            }
569            let (text, color) = rux_style::eval_value_binding(binding, &mut self.engine);
570            if let Some(node) = node_at_mut(&mut self.root, &binding.path) {
571                if let Some(content) = node.children.first_mut().and_then(|c| c.text.as_mut()) {
572                    content.text = text;
573                    content.color = color;
574                }
575            }
576        }
577        // `r-show` only flips paint on/off, rewrite the `hidden` bool in place.
578        for binding in &self.registry.show {
579            if binding.deps.is_disjoint(changed) {
580                continue;
581            }
582            let visible = self.engine.eval_bool(&binding.cond, &binding.locals);
583            if let Some(node) = node_at_mut(&mut self.root, &binding.path) {
584                node.hidden = !visible;
585            }
586        }
587        // `:src`: rewrite the image source and re-resolve it (path + intrinsic size).
588        for binding in &self.registry.src {
589            if binding.deps.is_disjoint(changed) {
590                continue;
591            }
592            let raw = rux_style::eval_src_binding(binding, &mut self.engine);
593            if let Some(node) = node_at_mut(&mut self.root, &binding.path) {
594                if let Some(img) = node.image.as_mut() {
595                    img.src = raw;
596                }
597                resolve_images(node, &self.base);
598            }
599        }
600        // `:options`: rewrite a select's option list in place.
601        for binding in &self.registry.options {
602            if binding.deps.is_disjoint(changed) {
603                continue;
604            }
605            let opts = rux_style::eval_options_binding(binding, &mut self.engine);
606            if let Some(node) = node_at_mut(&mut self.root, &binding.path) {
607                node.options = Some(opts);
608            }
609        }
610    }
611
612    /// Reconcile the `r-if`/`r-for` parents whose deps changed: build a fresh tree,
613    /// splice its affected subtrees into the live one, and re-apply focus *scoped*
614    /// to those subtrees. Unaffected subtrees keep their live node identity, so a
615    /// caret (or any ephemeral state) elsewhere survives with no whole-tree
616    /// restore. Refreshes the registry to the fresh build. No-op if nothing
617    /// structural changed.
618    fn reconcile(&mut self, changed: &HashSet<String>) {
619        // Outermost affected structural parents (a nested one is regenerated by its
620        // ancestor's splice), and the toggle nodes whose bound signal changed.
621        let mut affected: Vec<Vec<usize>> = self
622            .registry
623            .structural_parents
624            .iter()
625            .filter(|p| !p.deps.is_disjoint(changed))
626            .map(|p| p.tree_path.clone())
627            .collect();
628        let toggles: Vec<Vec<usize>> = self
629            .registry
630            .toggles
631            .iter()
632            .filter(|t| !t.deps.is_disjoint(changed))
633            .map(|t| t.path.clone())
634            .collect();
635        // Components and `:class`/`:style` nodes both reconcile by node-splice with
636        // scoped focus (their subtrees may hold inputs).
637        let mut node_splices: Vec<Vec<usize>> = self
638            .registry
639            .components
640            .iter()
641            .filter(|c| !c.deps.is_disjoint(changed))
642            .map(|c| c.path.clone())
643            .collect();
644        node_splices.extend(
645            self.registry
646                .styled
647                .iter()
648                .filter(|s| !s.deps.is_disjoint(changed))
649                .map(|s| s.path.clone()),
650        );
651        if affected.is_empty() && toggles.is_empty() && node_splices.is_empty() {
652            return;
653        }
654        affected.sort_by_key(Vec::len);
655        let mut roots: Vec<Vec<usize>> = Vec::new();
656        for p in affected {
657            if !roots.iter().any(|r| p.starts_with(r.as_slice())) {
658                roots.push(p);
659            }
660        }
661
662        let Ok((mut fresh_root, fresh_reg)) = rux_style::build_styled_tree_stateful(
663            &self.sfc,
664            &self.components,
665            &mut self.engine,
666            &self.state,
667            self.viewport,
668        ) else {
669            return;
670        };
671        resolve_images(&mut fresh_root, &self.base);
672        // Structural parents: replace the affected parent's children wholesale.
673        for p in &roots {
674            let Some(fresh) = node_at(&fresh_root, p) else { continue };
675            let fresh_children = fresh.children.clone();
676            if let Some(live) = node_at_mut(&mut self.root, p) {
677                live.children = fresh_children;
678                // Put the caret back only within this rebuilt subtree.
679                apply_focus(live, self.focus.as_ref());
680            }
681        }
682        // Toggles: replace just the single node (its checked style + mark). No
683        // shape change and no caret on a toggle, so no scoped focus is needed.
684        for p in &toggles {
685            if roots.iter().any(|r| p.starts_with(r.as_slice())) {
686                continue; // already covered by a parent splice above
687            }
688            if let Some(fresh) = node_at(&fresh_root, p) {
689                let fresh_node = fresh.clone();
690                if let Some(live) = node_at_mut(&mut self.root, p) {
691                    *live = fresh_node;
692                }
693            }
694        }
695        // Components and :class/:style nodes: replace the whole node subtree,
696        // re-applying focus scoped to it (the subtree may hold inputs).
697        for p in &node_splices {
698            if roots.iter().any(|r| p.starts_with(r.as_slice())) {
699                continue;
700            }
701            if let Some(fresh) = node_at(&fresh_root, p) {
702                let fresh_node = fresh.clone();
703                if let Some(live) = node_at_mut(&mut self.root, p) {
704                    *live = fresh_node;
705                    apply_focus(live, self.focus.as_ref());
706                }
707            }
708        }
709        self.registry = fresh_reg;
710    }
711
712    /// Apply an input edit (a keystroke's new value for `model`) and reflect it the
713    /// cheapest correct way: patch the input's shown value in place, falling back
714    /// to a rebuild only when `model` is also read structurally. The caller sets
715    /// the caret afterward via [`set_focus`](Self::set_focus).
716    pub fn apply_edit(&mut self, model: &str, value: &str) {
717        self.engine.set_string(model, value);
718        let changed: HashSet<String> = std::iter::once(model.to_string()).collect();
719        self.apply_change(&changed);
720    }
721
722    /// Run an `@tap` handler and reflect its effect the cheapest correct way:
723    /// patch the changed bindings in place, falling back to a full rebuild only
724    /// when the change is structural. Returns whether anything changed, so the
725    /// shell knows whether to repaint.
726    pub fn apply_handler(&mut self, src: &str) -> bool {
727        let changed = self.engine.run_handler_tracked(src);
728        if changed.is_empty() {
729            return false;
730        }
731        self.apply_change(&changed);
732        true
733    }
734
735    /// Reflect a set of changed signals: patch in place, or rebuild when the change
736    /// is structural. `RUX_TRACE=1` prints which path was taken, so the reactivity
737    /// behavior is observable while driving (the pixels are identical either way).
738    fn apply_change(&mut self, changed: &HashSet<String>) {
739        let patched = self.patch(changed);
740        if !patched {
741            self.rebuild();
742        }
743        if std::env::var_os("RUX_TRACE").is_some() {
744            let mut names: Vec<&str> = changed.iter().map(String::as_str).collect();
745            names.sort_unstable();
746            eprintln!(
747                "rux: change {names:?} → {}",
748                if patched { "patched in place (no rebuild)" } else { "rebuilt (structural)" }
749            );
750        }
751    }
752}
753
754/// Follow a child-index path from the root to a node.
755fn node_at<'a>(root: &'a LayoutNode, path: &[usize]) -> Option<&'a LayoutNode> {
756    let mut node = root;
757    for &i in path {
758        node = node.children.get(i)?;
759    }
760    Some(node)
761}
762
763/// Follow a child-index path from the root to a node, mutably.
764fn node_at_mut<'a>(root: &'a mut LayoutNode, path: &[usize]) -> Option<&'a mut LayoutNode> {
765    let mut node = root;
766    for &i in path {
767        node = node.children.get_mut(i)?;
768    }
769    Some(node)
770}
771
772/// A resolved component import.
773struct Import {
774    /// Custom-element tag (last path segment, `_` → `-`).
775    tag: String,
776    /// File path relative to the importing document (`a::b` → `a/b.rux`).
777    file: String,
778}
779
780/// Split `use a::b;` lines out of a script, returning the cleaned script (which
781/// `rhai` can parse) and the resolved imports.
782fn extract_imports(script: &str) -> (String, Vec<Import>) {
783    let mut cleaned = String::new();
784    let mut imports = Vec::new();
785
786    for line in script.lines() {
787        let trimmed = line.trim();
788        if let Some(rest) = trimmed.strip_prefix("use ") {
789            // A `use` must be its own statement on its own line; a path with
790            // spaces or extra `;` is malformed, leave it for rhai to reject.
791            if let Some(path) = rest.strip_suffix(';').map(str::trim).filter(|p| {
792                !p.is_empty() && !p.contains(char::is_whitespace) && !p.contains(';')
793            }) {
794                let segments: Vec<&str> = path.split("::").collect();
795                let file = format!("{}.rux", segments.join("/"));
796                let tag = segments
797                    .last()
798                    .map(|s| s.replace('_', "-"))
799                    .unwrap_or_default();
800                imports.push(Import { tag, file });
801                continue; // strip the import line
802            }
803        }
804        cleaned.push_str(line);
805        cleaned.push('\n');
806    }
807    (cleaned, imports)
808}
809
810/// Build the script engine and register host functions (the native-capability
811/// boundary; a real app registers its own here).
812fn build_engine(script: &str) -> Result<Engine, String> {
813    let mut builder = Builder::new();
814    builder.host_number("full", || 100.0);
815    builder.build(script)
816}
817
818#[cfg(test)]
819mod tests {
820    use super::*;
821
822    fn find_text(node: &LayoutNode, needle: &str) -> bool {
823        if let Some(t) = &node.text {
824            if t.text.contains(needle) {
825                return true;
826            }
827        }
828        node.children.iter().any(|c| find_text(c, needle))
829    }
830
831    #[test]
832    fn loads_document_and_expands_imported_component() {
833        // Self-contained fixtures (not the mutable examples): exercises import
834        // resolution, component file loading, engine merge, and expansion.
835        use std::fs;
836        let dir = std::env::temp_dir().join(format!("rux_test_{}", std::process::id()));
837        let comp_dir = dir.join("components");
838        fs::create_dir_all(&comp_dir).unwrap();
839        fs::write(
840            comp_dir.join("stat.rux"),
841            r#"<template><view><text>{{ label }}: {{ value }}</text></view></template>"#,
842        )
843        .unwrap();
844        fs::write(
845            dir.join("app.rux"),
846            "<template><screen><stat :label=\"title\" :value=\"n\" /></screen></template>\n\
847             <script>\n\
848             use components::stat;\n\
849             let title = signal(\"Battery\");\n\
850             let n = signal(82);\n\
851             </script>",
852        )
853        .unwrap();
854
855        let doc = Document::load(dir.join("app.rux")).expect("load app");
856        assert!(find_text(&doc.root, "Battery"), "component label prop rendered");
857        assert!(find_text(&doc.root, "82"), "component value prop rendered");
858
859        let _ = fs::remove_dir_all(&dir);
860    }
861
862    /// `<image src>` is relative to the .rux file, not the working directory,
863    /// and the intrinsic size comes from the file itself so a sizeless image
864    /// lays out at its natural dimensions.
865    #[test]
866    fn resolves_image_src_and_intrinsic_size() {
867        use std::fs;
868        let dir = std::env::temp_dir().join(format!("rux_img_{}", std::process::id()));
869        fs::create_dir_all(dir.join("assets")).unwrap();
870
871        // A 2x1 PNG, written by the same decoder the painter uses.
872        let png = dir.join("assets/dot.png");
873        image::RgbaImage::from_pixel(2, 1, image::Rgba([255, 0, 0, 255]))
874            .save(&png)
875            .unwrap();
876        fs::write(
877            dir.join("app.rux"),
878            r#"<template><screen><image src="assets/dot.png" /></screen></template>"#,
879        )
880        .unwrap();
881
882        let doc = Document::load(dir.join("app.rux")).expect("load app");
883        let img = doc.root.children[0].image.as_ref().expect("image node");
884        assert_eq!(img.intrinsic, (2.0, 1.0));
885        assert_eq!(Path::new(&img.src), png, "src resolved against the .rux dir");
886
887        let _ = fs::remove_dir_all(&dir);
888    }
889
890    fn caret_of(node: &LayoutNode, model: &str) -> Option<usize> {
891        if node.model.as_deref() == Some(model) {
892            return node.children.first()?.text.as_ref()?.caret;
893        }
894        node.children.iter().find_map(|c| caret_of(c, model))
895    }
896
897    /// Moving focus must clear the caret in the input you left. It used to only
898    /// ever *set* one, so the old input kept painting a caret until some
899    /// unrelated rebuild happened to wipe it.
900    #[test]
901    fn focus_moves_the_caret_out_of_the_old_input() {
902        let mut doc = Document::from_source(
903            "<template><screen>             <input r-model=\"name\" /><input r-model=\"city\" />             </screen></template>
904             <script>let name = signal(\"abc\"); let city = signal(\"xyz\");</script>",
905        )
906        .expect("load");
907
908        doc.set_focus(Some(Focus::at("name", 2)));
909        assert_eq!(caret_of(&doc.root, "name"), Some(2));
910        assert_eq!(caret_of(&doc.root, "city"), None);
911
912        // Focus the other field: the first one must lose its caret immediately,
913        // with no rebuild in between.
914        doc.set_focus(Some(Focus::at("city", 1)));
915        assert_eq!(caret_of(&doc.root, "name"), None, "old input kept its caret");
916        assert_eq!(caret_of(&doc.root, "city"), Some(1));
917
918        // Tapping outside clears both.
919        doc.set_focus(None);
920        assert_eq!(caret_of(&doc.root, "name"), None);
921        assert_eq!(caret_of(&doc.root, "city"), None);
922    }
923
924    fn selection_of(node: &LayoutNode, model: &str) -> Option<(usize, usize)> {
925        if node.model.as_deref() == Some(model) {
926            return node.children.first()?.text.as_ref()?.selection;
927        }
928        node.children.iter().find_map(|c| selection_of(c, model))
929    }
930
931    fn preedit_of(node: &LayoutNode, model: &str) -> Option<(usize, usize)> {
932        if node.model.as_deref() == Some(model) {
933            return node.children.first()?.text.as_ref()?.preedit;
934        }
935        node.children.iter().find_map(|c| preedit_of(c, model))
936    }
937
938    fn two_inputs() -> Document {
939        Document::from_source(
940            "<template><screen>             <input r-model=\"name\" /><input r-model=\"city\" />             </screen></template>
941             <script>let name = signal(\"abc\"); let city = signal(\"xyz\");</script>",
942        )
943        .expect("load")
944    }
945
946    /// The selection is the range between anchor and caret, either way round, and
947    /// only the focused input has one.
948    #[test]
949    fn selection_paints_only_in_the_focused_input() {
950        let mut doc = two_inputs();
951
952        doc.set_focus(Some(Focus { model: "name".into(), caret: 3, anchor: 1, preedit: None }));
953        assert_eq!(selection_of(&doc.root, "name"), Some((1, 3)));
954        assert_eq!(selection_of(&doc.root, "city"), None);
955
956        // Dragging leftwards puts the caret *before* the anchor; same range.
957        doc.set_focus(Some(Focus { model: "name".into(), caret: 1, anchor: 3, preedit: None }));
958        assert_eq!(selection_of(&doc.root, "name"), Some((1, 3)));
959    }
960
961    /// The negative case, which is where the caret bug lived: moving focus must
962    /// *clear* the old input's selection, not just set the new one's. A rebuild
963    /// isn't required to notice.
964    #[test]
965    fn focus_moves_the_selection_out_of_the_old_input() {
966        let mut doc = two_inputs();
967
968        doc.set_focus(Some(Focus { model: "name".into(), caret: 3, anchor: 0, preedit: None }));
969        assert_eq!(selection_of(&doc.root, "name"), Some((0, 3)));
970
971        doc.set_focus(Some(Focus { model: "city".into(), caret: 2, anchor: 0, preedit: None }));
972        assert_eq!(selection_of(&doc.root, "name"), None, "old input kept its selection");
973        assert_eq!(selection_of(&doc.root, "city"), Some((0, 2)));
974
975        doc.set_focus(None);
976        assert_eq!(selection_of(&doc.root, "name"), None);
977        assert_eq!(selection_of(&doc.root, "city"), None);
978    }
979
980    /// A collapsed selection is no selection: a plain caret must not paint a
981    /// zero-width highlight.
982    #[test]
983    fn a_collapsed_selection_is_none() {
984        let mut doc = two_inputs();
985        doc.set_focus(Some(Focus::at("name", 2)));
986        assert_eq!(caret_of(&doc.root, "name"), Some(2));
987        assert_eq!(selection_of(&doc.root, "name"), None);
988    }
989
990    /// Both caret and selection are re-applied after a rebuild, the whole-tree
991    /// rebuild throws the tree away, so anything ephemeral must be put back.
992    #[test]
993    fn selection_survives_a_rebuild() {
994        let mut doc = two_inputs();
995        doc.set_focus(Some(Focus { model: "name".into(), caret: 3, anchor: 1, preedit: None }));
996        doc.rebuild();
997        assert_eq!(selection_of(&doc.root, "name"), Some((1, 3)));
998        assert_eq!(caret_of(&doc.root, "name"), Some(3));
999        assert_eq!(selection_of(&doc.root, "city"), None);
1000    }
1001
1002    /// Text being composed through an input method is marked on the focused
1003    /// input only, and is cleared the same way a selection is when focus moves.
1004    /// Without the clearing, leaving a field mid-composition left the underline
1005    /// behind on text that had since been committed.
1006    #[test]
1007    fn a_composition_marks_only_the_focused_input() {
1008        let mut doc = two_inputs();
1009
1010        doc.set_focus(Some(Focus {
1011            model: "name".into(),
1012            caret: 3,
1013            anchor: 3,
1014            preedit: Some((1, 3)),
1015        }));
1016        assert_eq!(preedit_of(&doc.root, "name"), Some((1, 3)));
1017        assert_eq!(preedit_of(&doc.root, "city"), None);
1018
1019        doc.set_focus(Some(Focus::at("city", 1)));
1020        assert_eq!(preedit_of(&doc.root, "name"), None, "old input kept its composition");
1021        assert_eq!(preedit_of(&doc.root, "city"), None);
1022    }
1023
1024    /// A composition outlives the rebuild that showing it causes: the shell
1025    /// writes the composed text into the bound signal, and that edit can rebuild
1026    /// the tree, so a range applied before it must be put back after.
1027    #[test]
1028    fn a_composition_survives_a_rebuild() {
1029        let mut doc = two_inputs();
1030        doc.set_focus(Some(Focus {
1031            model: "name".into(),
1032            caret: 2,
1033            anchor: 2,
1034            preedit: Some((0, 2)),
1035        }));
1036        doc.rebuild();
1037        assert_eq!(preedit_of(&doc.root, "name"), Some((0, 2)));
1038    }
1039
1040    fn patch_doc() -> Document {
1041        // `n` is displayed only in a `{{ }}` text binding (patchable); `name` is
1042        // read by an input's r-model value (structural → forces a rebuild).
1043        Document::from_source(
1044            "<template><screen><text class=\"c\">{{ n }}</text><input r-model=\"name\" /></screen></template>
1045             <script>let n = signal(0); let name = signal(\"hi\");</script>",
1046        )
1047        .expect("load")
1048    }
1049
1050    /// A display-only change patches the text node in place, no rebuild, so the
1051    /// caret in an unrelated input survives without any restore pass running.
1052    #[test]
1053    fn patch_updates_text_and_preserves_caret() {
1054        let mut doc = patch_doc();
1055        doc.set_focus(Some(Focus::at("name", 1)));
1056
1057        let changed = doc.engine_mut().run_handler_tracked("n = n + 1");
1058        assert!(doc.patch(&changed), "a display-only change patches in place");
1059        assert_eq!(doc.root.children[0].text.as_ref().unwrap().text, "1");
1060        // The caret survived: patch never touched focus, and no rebuild happened.
1061        assert_eq!(caret_of(&doc.root, "name"), Some(1));
1062    }
1063
1064    /// Changing an input-bound signal patches the input's value in place (it is a
1065    /// patchable value binding, not structural), leaving the sibling display alone.
1066    #[test]
1067    fn patch_updates_input_value_in_place() {
1068        let mut doc = patch_doc();
1069        let changed = doc.engine_mut().run_handler_tracked("name = \"yo\"");
1070        assert!(doc.patch(&changed), "an input value change patches in place");
1071        // The input (child 1) shows the new value; the `{{ n }}` display is untouched.
1072        assert_eq!(doc.root.children[1].children[0].text.as_ref().unwrap().text, "yo");
1073        assert_eq!(doc.root.children[0].text.as_ref().unwrap().text, "0");
1074    }
1075
1076    fn input_text(doc: &Document) -> &str {
1077        // screen → input → text child.
1078        &doc.root.children[0].children[0].text.as_ref().unwrap().text
1079    }
1080
1081    /// A keystroke patches the input's shown value in place, `patch` returns true
1082    /// (no rebuild needed) and the text updates, and the caret survives.
1083    #[test]
1084    fn typing_patches_the_input_value_in_place() {
1085        let mut doc = Document::from_source(
1086            "<template><screen><input r-model=\"name\" placeholder=\"type…\" /></screen></template>
1087             <script>let name = signal(\"ab\");</script>",
1088        )
1089        .expect("load");
1090        doc.set_focus(Some(Focus::at("name", 2)));
1091        assert_eq!(input_text(&doc), "ab");
1092
1093        doc.engine_mut().set_string("name", "abc");
1094        let changed: HashSet<String> = std::iter::once("name".to_string()).collect();
1095        assert!(doc.patch(&changed), "value-only input edit patches in place");
1096        assert_eq!(input_text(&doc), "abc");
1097
1098        // Emptying the field falls back to the placeholder (patched, not rebuilt).
1099        doc.engine_mut().set_string("name", "");
1100        assert!(doc.patch(&changed));
1101        assert_eq!(input_text(&doc), "type…");
1102    }
1103
1104    /// `:options` rewrites a select's option list in place, no rebuild.
1105    #[test]
1106    fn options_patch_in_place() {
1107        let mut doc = Document::from_source(
1108            "<template><screen><input type=\"select\" r-model=\"fruit\" :options=\"fruits\" /></screen></template>
1109             <script>let fruit = signal(\"a\"); let fruits = signal([\"a\", \"b\"]);</script>",
1110        )
1111        .expect("load");
1112        assert_eq!(doc.root.children[0].options.as_ref().unwrap().len(), 2);
1113
1114        let changed = doc.engine_mut().run_handler_tracked("fruits = [\"a\", \"b\", \"c\"]");
1115        assert!(doc.patch(&changed), "an :options change patches in place");
1116        assert_eq!(doc.root.children[0].options.as_ref().unwrap().len(), 3, "list grew in place");
1117    }
1118
1119    /// A component prop change reconciles the instance subtree in place: the
1120    /// re-expanded component shows the new prop value, no wholesale rebuild.
1121    #[test]
1122    fn component_prop_reconciles_in_place() {
1123        use std::fs;
1124        let dir = std::env::temp_dir().join(format!("rux_prop_{}", std::process::id()));
1125        let comp_dir = dir.join("components");
1126        fs::create_dir_all(&comp_dir).unwrap();
1127        fs::write(
1128            comp_dir.join("stat.rux"),
1129            r#"<template><view><text>{{ value }}</text></view></template>"#,
1130        )
1131        .unwrap();
1132        fs::write(
1133            dir.join("app.rux"),
1134            "<template><screen><stat :value=\"n\" /></screen></template>\n\
1135             <script>\nuse components::stat;\nlet n = signal(1);\n</script>",
1136        )
1137        .unwrap();
1138
1139        let mut doc = Document::load(dir.join("app.rux")).expect("load app");
1140        assert!(find_text(&doc.root, "1"), "prop starts at 1");
1141        let changed = doc.engine_mut().run_handler_tracked("n = 2");
1142        assert!(doc.patch(&changed), "a component prop change reconciles in place");
1143        assert!(find_text(&doc.root, "2"), "component re-expanded with the new prop");
1144
1145        let _ = fs::remove_dir_all(&dir);
1146    }
1147
1148    /// Toggling a checkbox reconciles just that node (its checked style + mark) in
1149    /// place, and a caret on an input elsewhere survives with no whole-tree
1150    /// restore.
1151    #[test]
1152    fn toggle_reconciles_and_preserves_an_outside_caret() {
1153        let mut doc = Document::from_source(
1154            "<template><screen>\
1155               <input r-model=\"name\" />\
1156               <input type=\"checkbox\" class=\"box\" r-model=\"on\" />\
1157             </screen></template>
1158             <style>.box { background: #000000; } .box.checked { background: #00ff00; }</style>
1159             <script>let name = signal(\"ab\"); let on = signal(false);</script>",
1160        )
1161        .expect("load");
1162        doc.set_focus(Some(Focus::at("name", 1)));
1163        let green = |n: &LayoutNode| matches!(&n.style.background, Some(rux_layout::Background::Color(c)) if c.g == 1.0);
1164        assert!(!green(&doc.root.children[1]), "unchecked → not green");
1165
1166        let changed = doc.engine_mut().run_handler_tracked("on = true");
1167        assert!(doc.patch(&changed), "a toggle reconciles in place");
1168        assert!(green(&doc.root.children[1]), "checked → .box.checked (green) applied");
1169        assert!(doc.root.children[1].children.len() == 1, "checkmark added");
1170        // Only the toggle node was spliced; the sibling input node is untouched, so
1171        // its caret persists by identity.
1172        assert_eq!(caret_of(&doc.root, "name"), Some(1));
1173    }
1174
1175    // ── Pointer state (`:hover` / `:active`) ────────────────────────────────
1176
1177    // ── Diagnostics / dev overlay ───────────────────────────────────────────
1178
1179    /// A document that builds but whose CSS partly does nothing reports it,
1180    /// instead of the silence that made unknown CSS the worst failure mode here.
1181    #[test]
1182    fn warnings_are_collected_for_the_overlay() {
1183        let doc = Document::from_source(
1184            "<template><screen><view class=\"card\" /></screen></template>
1185             <style>.card { filter: blur(2px); background: var(--nope); }</style>",
1186        )
1187        .expect("load");
1188        let warnings = &doc.diagnostics().warnings;
1189        assert!(
1190            warnings.iter().any(|w| w.message.contains("filter")),
1191            "unhonored property reported: {warnings:?}"
1192        );
1193        assert!(
1194            warnings.iter().any(|w| w.message.contains("--nope")),
1195            "undefined var reported: {warnings:?}"
1196        );
1197        assert!(doc.diagnostics().error.is_none(), "the document still built");
1198    }
1199
1200    /// A clean document reports nothing, so the overlay stays out of the way.
1201    #[test]
1202    fn a_clean_document_has_no_diagnostics() {
1203        let doc = Document::from_source(
1204            "<template><screen><view class=\"card\" /></screen></template>
1205             <style>.card { background: #313244; }</style>",
1206        )
1207        .expect("load");
1208        assert!(doc.diagnostics().is_empty(), "{:?}", doc.diagnostics());
1209    }
1210
1211    /// A failed reload keeps the tree that is on screen and marks it stale,
1212    /// a typo mid-edit must not blank the window.
1213    #[test]
1214    fn a_failed_reload_keeps_the_last_good_tree() {
1215        let mut doc = Document::from_source(
1216            "<template><screen><text>hello</text></screen></template>",
1217        )
1218        .expect("load");
1219        let before = doc.root.children.len();
1220
1221        doc.set_load_error("parse error at line 6, column 13: mismatched closing tag");
1222        assert_eq!(doc.root.children.len(), before, "the tree is untouched");
1223        assert!(doc.diagnostics().error.is_some());
1224        assert!(doc.diagnostics().stale, "what's on screen predates the error");
1225    }
1226
1227    /// Loading a good document over a broken one clears the error.
1228    #[test]
1229    fn a_successful_reload_clears_the_error() {
1230        let mut doc = Document::from_source("<template><screen><text>old</text></screen></template>")
1231            .expect("load");
1232        doc.set_load_error("something was wrong");
1233
1234        let fresh = Document::from_source("<template><screen><text>new</text></screen></template>")
1235            .expect("load");
1236        doc.replace_with(fresh);
1237        assert!(doc.diagnostics().error.is_none(), "error cleared");
1238        assert!(!doc.diagnostics().stale);
1239        assert_eq!(doc.root.children[0].text.as_ref().unwrap().text, "new");
1240    }
1241
1242    /// The window owns the viewport, not the file, a reload must not reset it,
1243    /// or a hot-reload in a narrow window would come back with desktop styling.
1244    #[test]
1245    fn a_reload_keeps_the_window_viewport() {
1246        let mut doc = media_doc();
1247        doc.set_viewport(Viewport { width: 480.0, height: 800.0 });
1248        assert!(is_red(&doc.root.children[0]));
1249
1250        let fresh = Document::from_source(
1251            "<template><screen><view class=\"card\" /></screen></template>
1252             <style>
1253               .card { background: #00ff00; }
1254               @media (max-width: 600px) { .card { background: #ff0000; } }
1255             </style>",
1256        )
1257        .expect("load");
1258        doc.replace_with(fresh);
1259        assert!(
1260            is_red(&doc.root.children[0]),
1261            "still narrow after the reload, so the @media rule still applies"
1262        );
1263    }
1264
1265    // ── @media / viewport ───────────────────────────────────────────────────
1266
1267    fn media_doc() -> Document {
1268        Document::from_source(
1269            "<template><screen><view class=\"card\" /></screen></template>
1270             <style>
1271               .card { background: #00ff00; }
1272               @media (max-width: 600px) { .card { background: #ff0000; } }
1273             </style>",
1274        )
1275        .expect("load")
1276    }
1277
1278    fn is_red(n: &LayoutNode) -> bool {
1279        matches!(&n.style.background, Some(rux_layout::Background::Color(c)) if c.r == 1.0 && c.g == 0.0)
1280    }
1281
1282    /// Crossing a breakpoint re-cascades; crossing back restores.
1283    #[test]
1284    fn resize_across_a_breakpoint_restyles() {
1285        let mut doc = media_doc();
1286        assert!(!is_red(&doc.root.children[0]), "the default viewport is wide");
1287
1288        assert!(doc.set_viewport(Viewport { width: 480.0, height: 800.0 }), "breakpoint crossed");
1289        assert!(is_red(&doc.root.children[0]), "narrow → the @media rule applies");
1290
1291        assert!(doc.set_viewport(Viewport { width: 1000.0, height: 800.0 }), "crossed back");
1292        assert!(!is_red(&doc.root.children[0]), "wide again → the base rule");
1293    }
1294
1295    /// The case that has to stay free: a resize crossing no breakpoint reports no
1296    /// change, so dragging a window edge doesn't re-cascade on every pixel.
1297    #[test]
1298    fn resize_within_a_breakpoint_is_not_a_change() {
1299        let mut doc = media_doc();
1300        doc.set_viewport(Viewport { width: 400.0, height: 800.0 });
1301        assert!(
1302            !doc.set_viewport(Viewport { width: 500.0, height: 800.0 }),
1303            "still under 600px, nothing to redo"
1304        );
1305        assert!(is_red(&doc.root.children[0]), "and the styling is still correct");
1306    }
1307
1308    /// A document with no `@media` at all never re-cascades on resize.
1309    #[test]
1310    fn resize_does_nothing_without_media_queries() {
1311        let mut doc = Document::from_source(
1312            "<template><screen><view class=\"card\" /></screen></template>
1313             <style>.card { background: #00ff00; }</style>",
1314        )
1315        .expect("load");
1316        assert!(!doc.set_viewport(Viewport { width: 320.0, height: 480.0 }));
1317        assert!(!doc.set_viewport(Viewport { width: 1600.0, height: 900.0 }));
1318    }
1319
1320    /// Two sibling cards, only the second of which holds an input, plus a
1321    /// `:hover` rule that repaints a card green.
1322    fn hover_doc() -> Document {
1323        Document::from_source(
1324            "<template><screen>\
1325               <view class=\"card\"><text>one</text></view>\
1326               <view class=\"card\"><input r-model=\"name\" /></view>\
1327             </screen></template>
1328             <style>.card { background: #000000; } .card:hover { background: #00ff00; }</style>
1329             <script>let name = signal(\"ab\");</script>",
1330        )
1331        .expect("load")
1332    }
1333
1334    fn hovering(path: &[usize]) -> InteractionState {
1335        InteractionState { hovered: Some(path.to_vec()), ..InteractionState::default() }
1336    }
1337
1338    fn is_green(n: &LayoutNode) -> bool {
1339        matches!(&n.style.background, Some(rux_layout::Background::Color(c)) if c.g == 1.0)
1340    }
1341
1342    /// The hovered element restyles, its unhovered sibling does not, and leaving
1343    /// puts it back, the negative case is the point.
1344    #[test]
1345    fn hover_restyles_only_the_hovered_element() {
1346        let mut doc = hover_doc();
1347        assert!(!is_green(&doc.root.children[0]), "nothing hovered → no green");
1348
1349        assert!(doc.set_interaction(hovering(&[0])), "entering a card restyles");
1350        assert!(is_green(&doc.root.children[0]), "hovered card is green");
1351        assert!(!is_green(&doc.root.children[1]), "its sibling is NOT");
1352
1353        assert!(doc.set_interaction(InteractionState::default()), "leaving restyles");
1354        assert!(!is_green(&doc.root.children[0]), "hover ends → back to black");
1355    }
1356
1357    /// The pointer moving *within* the same element is not a state change, so it
1358    /// must not restyle anything, this is the every-mouse-move path.
1359    #[test]
1360    fn same_hover_target_is_not_a_change() {
1361        let mut doc = hover_doc();
1362        assert!(doc.set_interaction(hovering(&[0])));
1363        assert!(
1364            !doc.set_interaction(hovering(&[0])),
1365            "re-reporting the same target does no work"
1366        );
1367    }
1368
1369    /// Hover moves between siblings while a caret sits in an input elsewhere: the
1370    /// caret survives, because only the diverging subtree is spliced.
1371    #[test]
1372    fn hover_change_preserves_a_caret_elsewhere() {
1373        let mut doc = hover_doc();
1374        doc.set_focus(Some(Focus::at("name", 1)));
1375        assert_eq!(caret_of(&doc.root, "name"), Some(1));
1376
1377        assert!(doc.set_interaction(hovering(&[0])));
1378        assert_eq!(caret_of(&doc.root, "name"), Some(1), "caret survives a hover change");
1379        assert!(is_green(&doc.root.children[0]));
1380    }
1381
1382    /// Clearing the pointer state (the pointer left the window) un-styles what was
1383    /// hovered or pressed. Found by driving it: leaving the window fires
1384    /// `CursorLeft`, not a `CursorMoved`, so a hovered button stayed lit after the
1385    /// pointer was gone. This is the "assert it is *cleared*" half of the rule.
1386    #[test]
1387    fn clearing_pointer_state_unstyles_the_hovered_element() {
1388        let mut doc = hover_doc();
1389        doc.set_interaction(InteractionState {
1390            hovered: Some(vec![0]),
1391            active: Some(vec![0]),
1392            ..InteractionState::default()
1393        });
1394        assert!(is_green(&doc.root.children[0]));
1395
1396        assert!(doc.set_interaction(InteractionState::default()), "clearing restyles");
1397        assert!(!is_green(&doc.root.children[0]), "nothing is hovered any more");
1398    }
1399
1400    /// `:hover` holds for the whole chain under the pointer, as in CSS: hovering a
1401    /// child leaves its ancestor hovered too.
1402    #[test]
1403    fn hover_applies_to_the_ancestor_chain() {
1404        let mut doc = Document::from_source(
1405            "<template><screen>\
1406               <view class=\"card\"><view class=\"inner\"><text>x</text></view></view>\
1407             </screen></template>
1408             <style>\
1409               .card { background: #000000; } .card:hover { background: #00ff00; }\
1410               .inner:hover { background: #0000ff; }\
1411             </style>",
1412        )
1413        .expect("load");
1414        // Pointer over the inner box (path [0, 0]).
1415        assert!(doc.set_interaction(hovering(&[0, 0])));
1416        assert!(is_green(&doc.root.children[0]), "the ancestor card is hovered too");
1417        let inner = &doc.root.children[0].children[0];
1418        assert!(
1419            matches!(&inner.style.background, Some(rux_layout::Background::Color(c)) if c.b == 1.0),
1420            "the inner box is hovered"
1421        );
1422    }
1423
1424    /// A document with no pointer-state rules emits no state regions, so hover
1425    /// costs nothing at all.
1426    #[test]
1427    fn no_pointer_rules_means_no_state_regions() {
1428        let doc = Document::from_source(
1429            "<template><screen><view class=\"card\"><text>x</text></view></screen></template>
1430             <style>.card { background: #000000; }</style>",
1431        )
1432        .expect("load");
1433        fn any_marked(n: &LayoutNode) -> bool {
1434            n.state_path.is_some() || n.children.iter().any(any_marked)
1435        }
1436        assert!(!any_marked(&doc.root), "no :hover/:active rule → nothing to track");
1437    }
1438
1439    /// The element a `:hover` rule could match carries a path, so the layout emits
1440    /// a region the shell can hit-test.
1441    #[test]
1442    fn hoverable_elements_are_marked_for_the_shell() {
1443        let doc = hover_doc();
1444        assert_eq!(doc.root.children[0].state_path.as_deref(), Some(&[0][..]));
1445        assert_eq!(doc.root.children[1].state_path.as_deref(), Some(&[1][..]));
1446        assert!(doc.root.state_path.is_none(), "the screen has no :hover rule");
1447    }
1448
1449    /// `r-show` flips the node's `hidden` flag in place, no shape change, no
1450    /// rebuild, both ways.
1451    #[test]
1452    fn r_show_toggles_hidden_in_place() {
1453        let mut doc = Document::from_source(
1454            "<template><screen><text r-show=\"on\">hi</text></screen></template>
1455             <script>let on = signal(true);</script>",
1456        )
1457        .expect("load");
1458        assert!(!doc.root.children[0].hidden, "on=true → visible");
1459
1460        let changed = doc.engine_mut().run_handler_tracked("on = false");
1461        assert!(doc.patch(&changed), "r-show change patches in place");
1462        assert!(doc.root.children[0].hidden, "on=false → hidden");
1463
1464        let changed = doc.engine_mut().run_handler_tracked("on = true");
1465        assert!(doc.patch(&changed));
1466        assert!(!doc.root.children[0].hidden, "on=true → visible again");
1467    }
1468
1469    /// An `r-if` toggling patches its owning subtree in place, and a caret on an
1470    /// input in a *different* subtree survives untouched, with no whole-tree
1471    /// `apply_focus`. This is the reconciliation payoff.
1472    #[test]
1473    fn r_if_reconciles_and_preserves_an_outside_caret() {
1474        let mut doc = Document::from_source(
1475            "<template><screen>\
1476               <view class=\"top\"><input r-model=\"name\" /></view>\
1477               <view class=\"list\"><text r-if=\"show\">secret</text></view>\
1478             </screen></template>
1479             <script>let name = signal(\"ab\"); let show = signal(false);</script>",
1480        )
1481        .expect("load");
1482        doc.set_focus(Some(Focus::at("name", 1)));
1483        assert_eq!(caret_of(&doc.root, "name"), Some(1));
1484        assert!(!find_text(&doc.root, "secret"), "hidden while show=false");
1485
1486        // Reveal the r-if branch: reconciles the `.list` subtree only.
1487        let changed = doc.engine_mut().run_handler_tracked("show = true");
1488        assert!(doc.patch(&changed), "an r-if change reconciles in place");
1489        assert!(find_text(&doc.root, "secret"), "branch now shown");
1490        // The input is in `.top`, an untouched subtree, its caret persists with no
1491        // whole-tree restore.
1492        assert_eq!(caret_of(&doc.root, "name"), Some(1), "outside caret survived");
1493
1494        // And hiding it again removes the branch.
1495        let changed = doc.engine_mut().run_handler_tracked("show = false");
1496        assert!(doc.patch(&changed));
1497        assert!(!find_text(&doc.root, "secret"));
1498        assert_eq!(caret_of(&doc.root, "name"), Some(1));
1499    }
1500
1501    /// An `r-for` list change reconciles the row count in place.
1502    #[test]
1503    fn r_for_reconciles_row_count() {
1504        let mut doc = Document::from_source(
1505            "<template><screen><view class=\"list\"><text r-for=\"n in nums\">{{ n }}</text></view></screen></template>
1506             <script>let nums = signal([1, 2]);</script>",
1507        )
1508        .expect("load");
1509        assert_eq!(doc.root.children[0].children.len(), 2, "two rows initially");
1510
1511        let changed = doc.engine_mut().run_handler_tracked("nums = [1, 2, 3, 4]");
1512        assert!(doc.patch(&changed), "an r-for change reconciles in place");
1513        assert_eq!(doc.root.children[0].children.len(), 4, "grew to four rows");
1514        assert!(find_text(&doc.root, "4"), "new row content present");
1515    }
1516
1517    /// A label with `for="id"` inherits the `@tap` of the input with that `id`, so
1518    /// tapping the label toggles the input, even though the label doesn't wrap it.
1519    #[test]
1520    fn label_for_inherits_the_targets_tap() {
1521        let doc = Document::from_source(
1522            "<template><screen>\
1523               <input type=\"checkbox\" id=\"chk\" r-model=\"on\" />\
1524               <text for=\"chk\">Remember me</text>\
1525             </screen></template>
1526             <script>let on = signal(false);</script>",
1527        )
1528        .expect("load");
1529        // The label (child 1) picks up the checkbox's auto-generated toggle handler.
1530        assert_eq!(
1531            doc.root.children[1].on_tap.as_deref(),
1532            Some("on = !on"),
1533            "label with for= inherits the checkbox's @tap"
1534        );
1535        // An authored @tap on a label is not overridden.
1536        let doc2 = Document::from_source(
1537            "<template><screen>\
1538               <input type=\"checkbox\" id=\"chk\" r-model=\"on\" />\
1539               <text for=\"chk\" @tap=\"on = true\">Set</text>\
1540             </screen></template>
1541             <script>let on = signal(false);</script>",
1542        )
1543        .expect("load");
1544        assert_eq!(doc2.root.children[1].on_tap.as_deref(), Some("on = true"));
1545    }
1546
1547    /// A label whose `for=` targets a *text* input (no `@tap`) gets a `focus_model`
1548    /// instead, so the shell focuses that input when the label is tapped.
1549    #[test]
1550    fn label_for_focuses_a_text_input() {
1551        let doc = Document::from_source(
1552            "<template><screen>\
1553               <input id=\"nm\" r-model=\"name\" />\
1554               <text for=\"nm\">Name</text>\
1555             </screen></template>
1556             <script>let name = signal(\"\");</script>",
1557        )
1558        .expect("load");
1559        let label = &doc.root.children[1];
1560        assert_eq!(label.on_tap, None, "a text-input label has no tap handler");
1561        assert_eq!(
1562            label.focus_model.as_deref(),
1563            Some("name"),
1564            "label focuses the text input's model"
1565        );
1566    }
1567
1568    fn bg_rgb(n: &LayoutNode) -> Option<(f32, f32, f32)> {
1569        match &n.style.background {
1570            Some(rux_layout::Background::Color(c)) => Some((c.r, c.g, c.b)),
1571            _ => None,
1572        }
1573    }
1574
1575    /// `:class` feeds a signal-driven class into the cascade, and a change to that
1576    /// signal reconciles the node's style in place.
1577    #[test]
1578    fn dynamic_class_reconciles() {
1579        let mut doc = Document::from_source(
1580            "<template><screen><view class=\"chip\" :class=\"tone\" /></screen></template>
1581             <style>.hot { background: #ff0000; } .cool { background: #0000ff; }</style>
1582             <script>let tone = signal(\"hot\");</script>",
1583        )
1584        .expect("load");
1585        assert_eq!(bg_rgb(&doc.root.children[0]), Some((1.0, 0.0, 0.0)), ":class=hot → .hot");
1586
1587        let changed = doc.engine_mut().run_handler_tracked("tone = \"cool\"");
1588        assert!(doc.patch(&changed), ":class change reconciles in place");
1589        assert_eq!(bg_rgb(&doc.root.children[0]), Some((0.0, 0.0, 1.0)), "reconciled to .cool");
1590    }
1591
1592    /// `:style` with a rhai backtick template literal (string interpolation) sets an
1593    /// inline style, overriding the cascade, and reconciles on change.
1594    #[test]
1595    fn dynamic_inline_style_interpolates_and_reconciles() {
1596        let mut doc = Document::from_source(
1597            "<template><screen><view :style=\"`background: ${col}`\" /></screen></template>
1598             <script>let col = signal(\"#00ff00\");</script>",
1599        )
1600        .expect("load");
1601        assert_eq!(bg_rgb(&doc.root.children[0]), Some((0.0, 1.0, 0.0)), ":style set green");
1602
1603        let changed = doc.engine_mut().run_handler_tracked("col = \"#ff0000\"");
1604        assert!(doc.patch(&changed));
1605        assert_eq!(bg_rgb(&doc.root.children[0]), Some((1.0, 0.0, 0.0)), "reconciled to red");
1606    }
1607
1608    /// The chip example: `:style` reads the `r-for` loop variable, so each item gets
1609    /// its own colour; the `r-for` drives it (no per-node binding needed).
1610    #[test]
1611    fn r_for_chip_styles() {
1612        let doc = Document::from_source(
1613            "<template><screen><view class=\"chips\">\
1614               <view class=\"chip\" r-for=\"c in colors\" :style=\"`background: ${c}`\"><text>{{ c }}</text></view>\
1615             </view></screen></template>
1616             <script>let colors = signal([\"#ff0000\", \"#00ff00\"]);</script>",
1617        )
1618        .expect("load");
1619        let chips = &doc.root.children[0];
1620        assert_eq!(bg_rgb(&chips.children[0]), Some((1.0, 0.0, 0.0)), "first chip red");
1621        assert_eq!(bg_rgb(&chips.children[1]), Some((0.0, 1.0, 0.0)), "second chip green");
1622    }
1623
1624    /// `:class` object/conditional form (`#{ hot: cond }`), keys whose value is
1625    /// truthy become classes; a change flips them and reconciles.
1626    #[test]
1627    fn conditional_class_object_form() {
1628        let mut doc = Document::from_source(
1629            "<template><screen><view class=\"chip\" :class=\"#{ hot: warm, cool: !warm }\" /></screen></template>
1630             <style>.hot { background: #ff0000; } .cool { background: #0000ff; }</style>
1631             <script>let warm = signal(true);</script>",
1632        )
1633        .expect("load");
1634        assert_eq!(bg_rgb(&doc.root.children[0]), Some((1.0, 0.0, 0.0)), "warm → .hot");
1635
1636        let changed = doc.engine_mut().run_handler_tracked("warm = false");
1637        assert!(doc.patch(&changed), "conditional class change reconciles");
1638        assert_eq!(bg_rgb(&doc.root.children[0]), Some((0.0, 0.0, 1.0)), "!warm → .cool");
1639    }
1640
1641    /// The shipped `css-showcase.rux` (the `:class`/`:style` chip demo) loads and
1642    /// builds, a smoke test that the example stays valid.
1643    #[test]
1644    fn css_showcase_example_builds() {
1645        let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../examples/css-showcase.rux");
1646        let doc = Document::load(path).expect("css-showcase.rux builds");
1647        assert!(find_text(&doc.root, "teal"), "a :style-coloured chip rendered");
1648    }
1649
1650    /// `:style` object form (`#{ background: c }`), each entry a declaration.
1651    #[test]
1652    fn style_object_form() {
1653        let doc = Document::from_source(
1654            "<template><screen><view :style=\"#{ background: col }\" /></screen></template>
1655             <script>let col = signal(\"#00ff00\");</script>",
1656        )
1657        .expect("load");
1658        assert_eq!(bg_rgb(&doc.root.children[0]), Some((0.0, 1.0, 0.0)), ":style object → green");
1659    }
1660
1661    /// A checked box gets a synthetic `checked` class, so its checked look is
1662    /// plain CSS. A radio matches on its `value`.
1663    #[test]
1664    fn checked_toggles_get_a_checked_class() {
1665        let doc = Document::from_source(
1666            "<template><screen>             <input type=\"checkbox\" class=\"box\" r-model=\"on\" />             <input type=\"radio\" class=\"box\" r-model=\"plan\" value=\"pro\" />             <input type=\"radio\" class=\"box\" r-model=\"plan\" value=\"free\" />             </screen></template>
1667             <style>.box { background: #000000; } .box.checked { background: #00ff00; }</style>
1668             <script>let on = signal(true); let plan = signal(\"pro\");</script>",
1669        )
1670        .expect("load");
1671
1672        let green = |n: &LayoutNode| {
1673            matches!(&n.style.background, Some(rux_layout::Background::Color(c)) if c.g == 1.0)
1674        };
1675        let boxes = &doc.root.children;
1676        assert!(green(&boxes[0]), "checked checkbox should match .checked");
1677        assert!(green(&boxes[1]), "radio whose value == signal is checked");
1678        assert!(!green(&boxes[2]), "the other radio is not checked");
1679
1680        // ...and the checked ones carry a mark, the unchecked one doesn't.
1681        assert_eq!(boxes[0].children.len(), 1);
1682        assert_eq!(boxes[1].children.len(), 1);
1683        assert_eq!(boxes[2].children.len(), 0);
1684    }
1685}
1686