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//!
9//! [`Document`] is the unit everything else works in terms of. `rux-shell` owns
10//! one and asks it for a tree each frame; `rux check` builds one and throws the
11//! tree away, which is why checking a file needs no window and no GPU and runs
12//! in CI.
13//!
14//! Handling an event does not rebuild the document. `rux-script` records which
15//! signals each binding reads and which ones a handler writes, so a state change
16//! patches the nodes that the written signals actually reach and reconciles the
17//! lists that changed shape. A full rebuild is the fallback, not the path.
18//!
19//! Two things are collected rather than printed, because the same document is
20//! loaded by a CLI, a window and a browser, and only the caller knows where
21//! output belongs. [`Diagnostics`] carries the errors and warnings a load
22//! produced; [`take_warnings`] drains the ones raised out of band. Loading a
23//! document that cannot be parsed is not a panic: it is a [`LoadError`], so the
24//! window can keep the last good tree on screen and show the overlay instead of
25//! dying on a half-typed edit.
26
27use std::collections::{HashMap, HashSet};
28use std::path::{Path, PathBuf};
29
30use rux_layout::{Node as LayoutNode, Offset};
31use rux_parser::{Sfc, StyleInclude};
32use rux_reactive::Value;
33use rux_script::{Builder, Engine};
34use rux_style::{BindingRegistry, Instances};
35/// Re-exported so the shell can hand pointer/focus state and the window size in
36/// without depending on `rux-style` directly.
37pub use rux_reactive::json_string;
38pub use rux_style::{InteractionState, Viewport, Warning};
39
40/// A loaded `.rux` document: parsed source, imported components (by tag), the
41/// script engine, and the current tree.
42pub struct Document {
43    sfc: Sfc,
44    components: HashMap<String, Sfc>,
45    engine: Engine,
46    /// Directory the document was loaded from, `<image src>` resolves against it.
47    base: PathBuf,
48    /// The focused input, with its caret and selection, if any. Re-applied on
49    /// every rebuild so both survive a state change.
50    focus: Option<Focus>,
51    /// Where each patchable text binding lives and which signals force a rebuild,
52    /// refreshed on every full build. Lets [`Document::patch`] update value
53    /// bindings in place instead of throwing the tree away.
54    registry: BindingRegistry,
55    /// What the pointer is over / pressing, and which input has focus, the state
56    /// `:hover`, `:active` and `:focus` match against. Owned here so every build
57    /// (rebuild, reconcile, hot-reload) reproduces the same styling.
58    state: InteractionState,
59    /// The window size `@media` queries are evaluated against.
60    viewport: Viewport,
61    /// What is currently wrong with this document, for the dev overlay.
62    diagnostics: Diagnostics,
63    /// Every component instance's private state, kept here because the tree it
64    /// belongs to is rebuilt constantly and the state must not be.
65    instances: Instances,
66    /// `computed` declarations, in declaration order, so one may read another
67    /// declared above it and a single pass refreshes them all.
68    computeds: Vec<Computed>,
69    /// `effect` blocks, with what each read when it last ran.
70    effects: Vec<Effect>,
71    /// Where navigation has been, and where it is in that. See [`History`].
72    history: History,
73    /// The scroll offsets the next frame should adopt, set by a navigation and
74    /// taken by the shell.
75    ///
76    /// An empty vector means the top, which is what a *new* navigation gets: a
77    /// fresh page opens at its beginning. Back and forward carry the offsets
78    /// recorded on the entry being returned to. `None` means no navigation has
79    /// happened since the shell last looked, so whatever the user has scrolled
80    /// to stands.
81    pending_scroll: Option<Vec<Offset>>,
82    pub root: LayoutNode,
83}
84
85/// The paths visited, and where along them we are.
86///
87/// A cursor into one list rather than two stacks, because that is the model a
88/// user already has: going back then somewhere new drops the forward entries,
89/// and going back and forward again returns to exactly where it was. Two stacks
90/// express the same thing and make the truncation easy to get wrong.
91#[derive(Clone, Debug)]
92struct History {
93    entries: Vec<Entry>,
94    at: usize,
95}
96
97/// One place the user has been: where it was, and how far down it they were.
98///
99/// The scroll offsets belong to the *entry* rather than to the route, which is
100/// what makes restoring them simple. A scroll region is identified by its index
101/// among the scrollable boxes in tree order, so the ids only line up when the
102/// tree has the same shape; an entry is always one route, so by the time these
103/// are read back the shape is the one they were recorded against.
104#[derive(Clone, Debug)]
105struct Entry {
106    location: String,
107    scroll: Vec<Offset>,
108}
109
110impl Entry {
111    fn new(location: impl Into<String>) -> Self {
112        Self { location: location.into(), scroll: Vec::new() }
113    }
114}
115
116impl Default for History {
117    fn default() -> Self {
118        Self { entries: vec![Entry::new(ROOT_PATH)], at: 0 }
119    }
120}
121
122impl History {
123    /// A history that begins somewhere other than `/`.
124    ///
125    /// One entry, not two: arriving straight at `/user/7` from a link or a
126    /// command line means there is nowhere to go *back* to. Seeding `/` beneath
127    /// it would invent a page the user never visited and make Back leave the
128    /// app's opening screen showing without them ever having asked for it.
129    /// An empty path is not a path: a page served from a `file://` URL or an
130    /// odd host can report one, and it would match no route at all.
131    fn starting_at(path: &str) -> Self {
132        let path = if path.is_empty() { ROOT_PATH } else { path };
133        Self { entries: vec![Entry::new(path)], at: 0 }
134    }
135
136    /// Where we are now. Never empty, so this always answers.
137    fn current(&self) -> &str {
138        &self.entries[self.at].location
139    }
140
141    /// Jump straight to an entry by index, the browser's model of Back and
142    /// Forward. A browser hands back *where it landed*, not which way it went,
143    /// and a long-press on Back can move several entries at once, so stepping
144    /// one at a time cannot express it. Out of range is refused rather than
145    /// clamped: it means the two histories have drifted, and quietly landing
146    /// somewhere near would hide that.
147    fn go_to(&mut self, index: usize) -> bool {
148        if index >= self.entries.len() || index == self.at {
149            return false;
150        }
151        self.at = index;
152        true
153    }
154
155    /// Go somewhere new, dropping anything that was ahead. Navigating to where
156    /// we already are is not a visit: it would otherwise fill the history with
157    /// repeats of whatever link a user tapped twice, and make Back do nothing
158    /// visible the first time it was pressed.
159    fn push(&mut self, path: &str) -> bool {
160        if self.current() == path {
161            return false;
162        }
163        self.entries.truncate(self.at + 1);
164        self.entries.push(Entry::new(path));
165        self.at = self.entries.len() - 1;
166        true
167    }
168
169    /// Go somewhere *instead of* where we are: overwrite the current entry.
170    ///
171    /// Anything ahead is dropped, as it is for a push. This is still a
172    /// navigation, and what is ahead was reached from the page being replaced,
173    /// which is no longer on the way there.
174    fn replace(&mut self, path: &str) -> bool {
175        if self.current() == path && self.at + 1 == self.entries.len() {
176            return false;
177        }
178        self.entries.truncate(self.at + 1);
179        self.entries[self.at] = Entry::new(path);
180        true
181    }
182
183    /// Step back, if there is anywhere to step back to.
184    fn back(&mut self) -> bool {
185        if self.at == 0 {
186            return false;
187        }
188        self.at -= 1;
189        true
190    }
191
192    /// Step forward into somewhere already visited and stepped back from.
193    fn forward(&mut self) -> bool {
194        if self.at + 1 >= self.entries.len() {
195            return false;
196        }
197        self.at += 1;
198        true
199    }
200}
201
202/// Where a document starts before anything navigates.
203pub const ROOT_PATH: &str = "/";
204
205/// What is wrong with the document right now, the model behind the dev overlay.
206///
207/// An **error** means the file could not be loaded at all: there is no tree to
208/// show, so the window would otherwise be blank (or, on hot-reload, silently
209/// stale). A **warning** means the document built, but something in it does
210/// nothing, an unhonored property, an unknown pseudo-class, an undefined
211/// `var()`, an unsupported `@media`.
212///
213/// Both used to go only to stderr, which nobody running a GUI app is watching.
214#[derive(Clone, Debug, Default, PartialEq)]
215pub struct Diagnostics {
216    /// The load/parse failure, if the document is currently broken.
217    pub error: Option<String>,
218    /// Whether the tree on screen predates that error (a failed hot-reload keeps
219    /// the last good UI rather than blanking the window).
220    pub stale: bool,
221    pub warnings: Vec<Warning>,
222}
223
224impl Diagnostics {
225    pub fn is_empty(&self) -> bool {
226        self.error.is_none() && self.warnings.is_empty()
227    }
228}
229
230/// Which input has keyboard focus, and where its caret and selection are.
231///
232/// The selection is the range between `anchor` (where it started) and `caret`
233/// (where it has been dragged/extended to); `anchor == caret` means no selection,
234/// just a caret. Either may be the smaller, dragging leftwards puts the caret
235/// before the anchor, so consumers normalize with [`Focus::range`].
236#[derive(Clone, Debug, PartialEq)]
237pub struct Focus {
238    pub model: String,
239    /// The `r-key` of the `r-for` row holding this input, when it is in one.
240    ///
241    /// `model` is the `r-model` expression as written, so every row of a list
242    /// shares it and cannot identify which row the caret is in. The row's key
243    /// is the other half. It also makes the caret follow its row when the list
244    /// reorders: the identity is the row, not the position, so nothing has to
245    /// be remapped afterwards.
246    pub row: Option<String>,
247    pub caret: usize,
248    pub anchor: usize,
249    /// The byte range of an in-progress IME composition, if one is running. The
250    /// composed text is already in the bound value; this only marks which part
251    /// of it is provisional, so the painter can underline it.
252    pub preedit: Option<(usize, usize)>,
253}
254
255impl Focus {
256    /// A plain caret with nothing selected, in an input that is not in a list.
257    pub fn at(model: impl Into<String>, caret: usize) -> Self {
258        Self::at_row(model, None, caret)
259    }
260
261    /// The same, in a known `r-for` row.
262    pub fn at_row(model: impl Into<String>, row: Option<String>, caret: usize) -> Self {
263        Self { model: model.into(), row, caret, anchor: caret, preedit: None }
264    }
265
266    /// Whether this focus is the input bound to `model` in row `row`. Both
267    /// halves matter: a list's rows all share one model.
268    pub fn is(&self, model: &str, row: Option<&str>) -> bool {
269        self.model == model && self.row.as_deref() == row
270    }
271
272    /// The selected range, low to high.
273    pub fn range(&self) -> (usize, usize) {
274        (self.caret.min(self.anchor), self.caret.max(self.anchor))
275    }
276
277    pub fn is_collapsed(&self) -> bool {
278        self.caret == self.anchor
279    }
280}
281
282/// Mark the focused input's text child with the caret position and selection, so
283/// it paints them, and clear every other input's.
284///
285/// Clearing matters: this runs against the *existing* tree when focus moves, not
286/// only against a freshly built one. Setting without clearing left the caret
287/// showing in the input you just left, until some unrelated rebuild wiped it.
288/// The selection is one more thing that can be left behind the same way.
289fn apply_focus(node: &mut LayoutNode, focus: Option<&Focus>) {
290    apply_focus_in(node, focus, None);
291}
292
293/// [`apply_focus`], carrying the `r-key` of the row being walked.
294///
295/// A splice starts partway down the tree, so the row a subtree sits in cannot be
296/// recovered from the subtree itself; `row` is what the caller already knew. It
297/// is `None` at the root and everywhere outside a keyed list.
298fn apply_focus_in(node: &mut LayoutNode, focus: Option<&Focus>, row: Option<&str>) {
299    let row = node.key.as_deref().or(row);
300    if node.model.is_some() {
301        if let Some(text) = node.children.first_mut().and_then(|c| c.text.as_mut()) {
302            let mine = focus.filter(|f| {
303                node.model.as_deref().is_some_and(|m| f.is(m, row))
304            });
305            // An empty input shows its placeholder; the caret still sits at 0.
306            text.caret = mine.map(|f| f.caret.min(text.text.len()));
307            text.selection = mine.filter(|f| !f.is_collapsed()).map(|f| {
308                let (start, end) = f.range();
309                (start.min(text.text.len()), end.min(text.text.len()))
310            });
311            text.preedit = mine.and_then(|f| f.preedit).map(|(start, end)| {
312                (start.min(text.text.len()), end.min(text.text.len()))
313            });
314        }
315    }
316    for child in &mut node.children {
317        apply_focus_in(child, focus, row);
318    }
319}
320
321/// The deepest node whose subtree covers both paths, where the old and new
322/// pointer targets diverge. Re-cascading from here restyles every element that
323/// gained or lost the state and nothing else, because `:hover`/`:active` hold for
324/// the whole chain from the root down to the pointer, and the two chains are
325/// identical above the divergence.
326///
327/// When one side is `None` the pointer entered from (or left to) nothing, and the
328/// entire chain changed state, including ancestors, so the splice starts at the
329/// root. That is a full re-cascade, but only on entering/leaving all interactive
330/// boxes, and only in documents that use pointer-state rules at all.
331fn divergence(a: Option<&[usize]>, b: Option<&[usize]>) -> Vec<usize> {
332    match (a, b) {
333        (Some(a), Some(b)) => a.iter().zip(b).take_while(|(x, y)| x == y).map(|(x, _)| *x).collect(),
334        _ => Vec::new(),
335    }
336}
337
338/// Drain both warning sinks, the cascade's (unhonored properties, unknown
339/// pseudo-classes, undefined `var()`s, unsupported `@media`) and the script's
340/// (expressions that failed to compile or evaluate).
341fn collect_warnings() -> Vec<Warning> {
342    let mut warnings = rux_style::take_warnings();
343    warnings.extend(rux_script::take_warnings());
344    warnings
345}
346
347/// Drain the warning sinks without building anything.
348///
349/// The sinks are global and are only emptied by a *successful* build, so a load
350/// that fails partway leaves whatever it managed to warn about sitting there,
351/// ready to be misattributed to the next file. Anything checking more than one
352/// document in a row needs to be able to clear them between files.
353pub fn take_warnings() -> Vec<Warning> {
354    collect_warnings()
355}
356
357/// Stop mirroring warnings to stderr as they are raised. Covers both sinks, so a
358/// tool that formats them itself does not have to know there are two.
359pub fn set_stderr_echo(on: bool) {
360    rux_script::set_stderr_echo(on);
361    rux_style::set_stderr_echo(on);
362}
363
364/// Whether this file is a document in its own right, rather than a component
365/// meant to be used by one. `None` means the question could not be answered,
366/// because the file would not read or parse.
367///
368/// The test is the one the spec already sets: "the application entry point is a
369/// component whose root is `<screen>`". Anything else is a fragment expecting a
370/// parent.
371///
372/// A checker needs the distinction. A component's `{{ prop }}` bindings are
373/// supplied by whoever uses it, so loading one on its own reports every prop as
374/// an undefined variable: failures that say nothing about whether the file is
375/// correct. Going by the root rather than by who imports what also catches a
376/// component that nothing currently uses.
377pub fn is_entry_point(path: impl AsRef<Path>) -> Option<bool> {
378    let src = std::fs::read_to_string(path.as_ref()).ok()?;
379    let sfc = rux_parser::parse_sfc(&src).ok()?;
380    Some(sfc.template.tag == "screen")
381}
382
383/// Resolve every `<image src>` in the tree against `base` and read its intrinsic
384/// size, so a sizeless `<image>` lays out at its natural pixel dimensions. Only
385/// the file header is read, not the pixels; the painter decodes and caches those.
386fn resolve_images(node: &mut LayoutNode, base: &Path) {
387    if let Some(img) = &mut node.image {
388        if !img.src.is_empty() {
389            let path = base.join(&img.src);
390            if let Ok((w, h)) = image::image_dimensions(&path) {
391                img.intrinsic = (w as f32, h as f32);
392            } else {
393                eprintln!("rux: cannot read image {}", path.display());
394            }
395            img.src = path.to_string_lossy().into_owned();
396        }
397    }
398    // `background-image: url(…)` resolves against the .rux file too. The painter
399    // sizes it to the box, so no intrinsic size is needed here.
400    if let Some(rux_layout::Background::Image(src)) = &mut node.style.background {
401        if !src.is_empty() {
402            *src = base.join(&*src).to_string_lossy().into_owned();
403        }
404    }
405    for child in &mut node.children {
406        resolve_images(child, base);
407    }
408}
409
410/// What went wrong loading a document, with the position kept when there is one.
411///
412/// [`Document::load`] flattens this to a string, which is what the dev overlay
413/// wants: prose in a panel. A checker wants the parts separately, because an
414/// editor cannot put a squiggle under a sentence. Same failure, two audiences,
415/// so the structure is preserved here and thrown away at the last moment.
416#[derive(Clone, Debug, PartialEq)]
417pub struct LoadError {
418    pub message: String,
419    /// The file the error is actually in, which is not always the file that was
420    /// asked for: a component is reached through its parent's `use`.
421    pub file: Option<PathBuf>,
422    pub line: Option<usize>,
423    pub column: Option<usize>,
424    /// Whether this came from the parser, which is the only stage that knows a
425    /// position. Kept so the flattened form reads the way it always has.
426    parse: bool,
427}
428
429impl LoadError {
430    fn plain(message: String) -> Self {
431        Self { message, file: None, line: None, column: None, parse: false }
432    }
433
434    /// `file` is `None` when the source did not come from one, which is the
435    /// playground's case: it has a buffer, not a path.
436    fn parse(err: rux_parser::ParseError, file: Option<&Path>) -> Self {
437        Self {
438            message: err.message,
439            file: file.map(Path::to_path_buf),
440            line: err.line,
441            column: err.column,
442            parse: true,
443        }
444    }
445}
446
447impl std::fmt::Display for LoadError {
448    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
449        if !self.parse {
450            return write!(f, "{}", self.message);
451        }
452        match (self.line, self.column) {
453            (Some(l), Some(c)) => {
454                write!(f, "parse error at line {l}, column {c}: {}", self.message)
455            }
456            _ => write!(f, "parse error: {}", self.message),
457        }
458    }
459}
460
461impl std::error::Error for LoadError {}
462
463impl Document {
464    /// Load a document, flattening any failure to a sentence.
465    pub fn load(path: impl AsRef<Path>) -> Result<Self, String> {
466        Self::load_checked(path).map_err(|e| e.to_string())
467    }
468
469    /// Load a document, keeping the failure's position so a checker can point at
470    /// it. [`Document::load`] is this with the structure discarded.
471    pub fn load_checked(path: impl AsRef<Path>) -> Result<Self, LoadError> {
472        let path = path.as_ref();
473        let src = std::fs::read_to_string(path)
474            .map_err(|e| LoadError::plain(format!("reading {}: {e}", path.display())))?;
475        let mut sfc = rux_parser::parse_sfc(&src).map_err(|e| LoadError::parse(e, Some(path)))?;
476
477        // Resolve `use module::component;` imports relative to this file.
478        let base = path.parent().unwrap_or_else(|| Path::new("."));
479        resolve_style_includes(&mut sfc, base)?;
480        let (main_script, imports) = extract_imports(&sfc.script);
481        let (main_script, computeds, effects) = extract_reactives(&main_script);
482
483        let mut components = HashMap::new();
484        let mut combined_script = main_script;
485        for import in imports {
486            let comp_path = base.join(&import.file);
487            let comp_src = std::fs::read_to_string(&comp_path).map_err(|e| {
488                LoadError::plain(format!("reading component {}: {e}", comp_path.display()))
489            })?;
490            let mut comp_sfc =
491                rux_parser::parse_sfc(&comp_src).map_err(|e| LoadError::parse(e, Some(&comp_path)))?;
492            // A component's `src` is relative to the component, not to whoever
493            // imported it. Anything else would make a component unusable from a
494            // second directory, which is the whole point of having one.
495            let comp_base = comp_path.parent().unwrap_or_else(|| Path::new(".")).to_path_buf();
496            resolve_style_includes(&mut comp_sfc, &comp_base)?;
497            let (comp_script, _nested) = extract_imports(&comp_sfc.script);
498            // A component's own computed/effect declarations are not
499            // supported yet; strip them so the merged script still compiles.
500            let (comp_script, _c, _e) = extract_reactives(&comp_script);
501            // Only its *functions* join the shared engine. Its `let`s do not:
502            // they are the state each instance gets a private copy of, so
503            // merging them here would put one shared variable behind every
504            // instance, which is exactly the bug this is fixing. `rux-style`
505            // runs the same split and hands the statements to `init_scope`.
506            combined_script.push('\n');
507            combined_script.push_str(&component_functions(&comp_script));
508            components.insert(import.tag, comp_sfc);
509        }
510
511        // Before the first build: a `:to` calling `path_for` is evaluated
512        // during that build, so the names have to be known by then.
513        rux_script::set_routes(rux_style::named_routes(&sfc.template));
514        let mut engine = build_engine(&combined_script).map_err(LoadError::plain)?;
515        let mut instances = Instances::new();
516        let (mut root, registry) = rux_style::build_styled_tree_tracked(&sfc, &components, &mut engine, &mut instances)
517            .map_err(LoadError::plain)?;
518        resolve_images(&mut root, base);
519        let mut doc = Self {
520            sfc,
521            components,
522            engine,
523            base: base.to_path_buf(),
524            focus: None,
525            registry,
526            state: InteractionState::default(),
527            viewport: Viewport::default(),
528            // Whatever the build just complained about, ready for the overlay.
529            diagnostics: Diagnostics {
530                warnings: collect_warnings(),
531                ..Diagnostics::default()
532            },
533            instances,
534            computeds,
535            effects,
536            history: History::default(),
537            pending_scroll: None,
538            root,
539        };
540        doc.init_reactive();
541        Ok(doc)
542    }
543
544    /// Process `.rux` source with no import resolution (used for fallbacks/tests).
545    pub fn from_source(src: &str) -> Result<Self, String> {
546        Self::from_source_checked(src).map_err(|e| e.to_string())
547    }
548
549    /// [`Document::from_source`], keeping the failure's position.
550    ///
551    /// The playground needs this: it has no file to point at, so a parse error
552    /// with no line was all it could ever show, and "something is wrong
553    /// somewhere" is not much of an editor.
554    pub fn from_source_checked(src: &str) -> Result<Self, LoadError> {
555        let sfc = rux_parser::parse_sfc(src).map_err(|e| LoadError::parse(e, None))?;
556        // There is no file here, so there is nothing for `src` to be relative
557        // to and nothing to read it from. Warn rather than fail: the document
558        // still renders, just without the sheet it asked for, and a playground
559        // that refused to show anything would be worse than one that shows the
560        // document and says what is missing.
561        for path in &sfc.style_src {
562            warn_unresolvable_include(path);
563        }
564        let (main_script, _imports) = extract_imports(&sfc.script);
565        let (main_script, computeds, effects) = extract_reactives(&main_script);
566        rux_script::set_routes(rux_style::named_routes(&sfc.template));
567        let mut engine = build_engine(&main_script).map_err(LoadError::plain)?;
568        let mut instances = Instances::new();
569        let (mut root, registry) =
570            rux_style::build_styled_tree_tracked(&sfc, &HashMap::new(), &mut engine, &mut instances)
571                .map_err(LoadError::plain)?;
572        let base = PathBuf::from(".");
573        resolve_images(&mut root, &base);
574        let mut doc = Self {
575            sfc,
576            components: HashMap::new(),
577            engine,
578            base,
579            focus: None,
580            registry,
581            state: InteractionState::default(),
582            viewport: Viewport::default(),
583            diagnostics: Diagnostics {
584                warnings: collect_warnings(),
585                ..Diagnostics::default()
586            },
587            instances,
588            computeds,
589            effects,
590            history: History::default(),
591            pending_scroll: None,
592            root,
593        };
594        doc.init_reactive();
595        Ok(doc)
596    }
597
598    /// The script engine, for running `@tap` handlers.
599    pub fn engine_mut(&mut self) -> &mut Engine {
600        &mut self.engine
601    }
602
603    /// What is currently wrong with this document, for the dev overlay.
604    pub fn diagnostics(&self) -> &Diagnostics {
605        &self.diagnostics
606    }
607
608    /// Record that a re-load failed. The tree on screen stays as it was, so the
609    /// app keeps working while the file is broken; the overlay says so, and marks
610    /// what you're looking at as stale.
611    pub fn set_load_error(&mut self, error: impl Into<String>) {
612        self.diagnostics.error = Some(error.into());
613        self.diagnostics.stale = true;
614    }
615
616    /// Mark the visible tree as *not* a leftover from before the error, used when
617    /// the very first load failed, so there is no earlier version being shown.
618    pub fn clear_stale(&mut self) {
619        self.diagnostics.stale = false;
620    }
621
622    /// Adopt a freshly loaded document's tree and state, keeping this one's
623    /// identity. Used by hot-reload so a successful load clears the error.
624    pub fn replace_with(&mut self, mut fresh: Document) {
625        // A reload rebuilds from scratch, so focus is legitimately reset, but the
626        // viewport and pointer state belong to the window, not the file.
627        fresh.viewport = self.viewport;
628        fresh.state = self.state.clone();
629        fresh.rebuild();
630        *self = fresh;
631    }
632
633    /// Focus an input (by `r-model`), with its caret and selection. `None` clears.
634    pub fn set_focus(&mut self, focus: Option<Focus>) {
635        self.focus = focus;
636        apply_focus(&mut self.root, self.focus.as_ref());
637    }
638
639    /// The pointer/focus state pseudo-class selectors match against.
640    pub fn interaction(&self) -> &InteractionState {
641        &self.state
642    }
643
644    /// Update the interaction state (`:hover` / `:active` / `:focus`) and restyle
645    /// what it affects. Returns whether anything was restyled, so the shell knows
646    /// whether to repaint, `false` for the overwhelmingly common case of the
647    /// pointer moving within the same element.
648    ///
649    /// Only the affected subtree is spliced, not the whole tree: hover moving
650    /// between two siblings re-cascades their common parent's subtree, so a caret
651    /// or selection anywhere else survives by node identity, the same reconcile
652    /// discipline signal changes use.
653    pub fn set_interaction(&mut self, next: InteractionState) -> bool {
654        if next == self.state {
655            return false;
656        }
657        // A focus move can restyle anything (`:focus` is matched by model, not by
658        // path, and `.field:focus .hint` reaches elsewhere), so it re-cascades from
659        // the root. It is rare, one click or Tab, and the caret is being moved
660        // anyway, so there is no ephemeral state left to preserve.
661        let mut roots: Vec<Vec<usize>> = Vec::new();
662        if next.focused_model == self.state.focused_model
663            && next.focused_row == self.state.focused_row
664        {
665            roots.push(divergence(self.state.hovered.as_deref(), next.hovered.as_deref()));
666            roots.push(divergence(self.state.active.as_deref(), next.active.as_deref()));
667        } else {
668            roots.push(Vec::new());
669        }
670        self.state = next;
671        self.restyle(&roots);
672        true
673    }
674
675    /// Tell the document the window size, for `@media`. Returns whether any query
676    /// changed answer, i.e. whether the rule set moved and the tree had to be
677    /// re-cascaded.
678    ///
679    /// A resize fires continuously, and almost every one crosses no breakpoint, so
680    /// the common case must be free: the media conditions are evaluated at the old
681    /// and new size and compared, and the tree is only rebuilt when that vector
682    /// actually differs. A document with no `@media` at all compares two empty
683    /// vectors and never rebuilds.
684    pub fn set_viewport(&mut self, viewport: Viewport) -> bool {
685        if viewport == self.viewport {
686            return false;
687        }
688        let before = self.media_state(self.viewport);
689        let after = self.media_state(viewport);
690        self.viewport = viewport;
691        if before == after {
692            return false;
693        }
694        // A breakpoint was crossed: re-cascade everything. Focus is re-applied by
695        // `rebuild`, and scroll offsets live in the shell, so nothing is lost.
696        self.rebuild();
697        true
698    }
699
700    /// Whether each `@media` block, in the document and in every component,
701    /// applies at `viewport`.
702    fn media_state(&self, viewport: Viewport) -> Vec<bool> {
703        let mut out = rux_style::media_matches(&self.sfc.style, viewport);
704        // Components are keyed by tag in a HashMap, so sort for a stable order.
705        let mut tags: Vec<&String> = self.components.keys().collect();
706        tags.sort();
707        for tag in tags {
708            out.extend(rux_style::media_matches(&self.components[tag].style, viewport));
709        }
710        out
711    }
712
713    /// Rebuild the given subtrees against the current interaction state and splice
714    /// them into the live tree, re-applying focus scoped to each.
715    fn restyle(&mut self, roots: &[Vec<usize>]) {
716        let Ok((mut fresh_root, fresh_reg)) = rux_style::build_styled_tree_stateful(
717            &self.sfc,
718            &self.components,
719            &mut self.engine,
720            &mut self.instances,
721            &self.state,
722            self.viewport,
723        ) else {
724            return;
725        };
726        resolve_images(&mut fresh_root, &self.base);
727        for path in roots {
728            let Some(fresh) = node_at(&fresh_root, path) else { continue };
729            let fresh_node = fresh.clone();
730            let row = row_at(&fresh_root, path);
731            if let Some(live) = node_at_mut(&mut self.root, path) {
732                *live = fresh_node;
733                apply_focus_in(live, self.focus.as_ref(), row.as_deref());
734            }
735        }
736        self.registry = fresh_reg;
737    }
738
739    /// Rebuild the layout tree from the engine's current state.
740    pub fn rebuild(&mut self) {
741        if let Ok((mut root, registry)) = rux_style::build_styled_tree_stateful(
742            &self.sfc,
743            &self.components,
744            &mut self.engine,
745            &mut self.instances,
746            &self.state,
747            self.viewport,
748        ) {
749            resolve_images(&mut root, &self.base);
750            apply_focus(&mut root, self.focus.as_ref());
751            self.registry = registry;
752            self.root = root;
753            // Refresh what the overlay lists: a rebuild re-runs the cascade and
754            // every binding, so it re-raises exactly what this document still has
755            // wrong.
756            self.diagnostics.warnings = collect_warnings();
757        }
758    }
759
760    /// Apply a set of changed signals *in place* where possible: re-evaluate the
761    /// text bindings that read them and write the new strings into their nodes,
762    /// without rebuilding the tree (so ephemeral state, caret, scroll, survives
763    /// untouched). Returns `false` when the change can't be patched, it touched a
764    /// signal that drives structure, an attribute, an input value, or a component
765    /// prop, in which case the caller must [`rebuild`](Self::rebuild). Nothing is
766    /// mutated on the `false` path.
767    #[must_use]
768    pub fn patch(&mut self, changed: &HashSet<String>) -> bool {
769        if changed.is_empty() {
770            return true; // nothing changed → nothing to do, and no rebuild needed
771        }
772        // A non-reconcilable structural read (component prop, `:src`/`:options`, or
773        // a toggle's `checked` class) still needs a full rebuild.
774        if !self.registry.structural.is_disjoint(changed) {
775            return false;
776        }
777        // r-if/r-for: reconcile just the affected subtrees in place.
778        self.reconcile(changed);
779        // Text, input values, and r-show update in place.
780        self.patch_values(changed);
781        true
782    }
783
784    /// Re-evaluate the value bindings (text `{{ }}`, input values, `r-show`) whose
785    /// deps changed and write them into their nodes, no shape change.
786    fn patch_values(&mut self, changed: &HashSet<String>) {
787        for binding in &self.registry.text {
788            if binding.deps.is_disjoint(changed) {
789                continue;
790            }
791            let text = rux_style::eval_text_binding(binding, &mut self.engine);
792            if let Some(node) = node_at_mut(&mut self.root, &binding.path) {
793                if let Some(content) = node.text.as_mut() {
794                    content.text = text;
795                }
796            }
797        }
798        // Input values live in the input's first child; patch their text + colour
799        // so a keystroke doesn't rebuild. The caret/selection on that child are
800        // left untouched (the shell sets them via `set_focus`).
801        for binding in &self.registry.value {
802            if binding.deps.is_disjoint(changed) {
803                continue;
804            }
805            let (text, color) = rux_style::eval_value_binding(binding, &mut self.engine);
806            if let Some(node) = node_at_mut(&mut self.root, &binding.path) {
807                if let Some(content) = node.children.first_mut().and_then(|c| c.text.as_mut()) {
808                    content.text = text;
809                    content.color = color;
810                }
811            }
812        }
813        // `r-show` only flips paint on/off, rewrite the `hidden` bool in place.
814        for binding in &self.registry.show {
815            if binding.deps.is_disjoint(changed) {
816                continue;
817            }
818            let visible = self.engine.eval_bool(&binding.cond, &binding.locals);
819            if let Some(node) = node_at_mut(&mut self.root, &binding.path) {
820                node.hidden = !visible;
821            }
822        }
823        // `:src`: rewrite the image source and re-resolve it (path + intrinsic size).
824        for binding in &self.registry.src {
825            if binding.deps.is_disjoint(changed) {
826                continue;
827            }
828            let raw = rux_style::eval_src_binding(binding, &mut self.engine);
829            if let Some(node) = node_at_mut(&mut self.root, &binding.path) {
830                if let Some(img) = node.image.as_mut() {
831                    img.src = raw;
832                }
833                resolve_images(node, &self.base);
834            }
835        }
836        // `:options`: rewrite a select's option list in place.
837        for binding in &self.registry.options {
838            if binding.deps.is_disjoint(changed) {
839                continue;
840            }
841            let opts = rux_style::eval_options_binding(binding, &mut self.engine);
842            if let Some(node) = node_at_mut(&mut self.root, &binding.path) {
843                node.options = Some(opts);
844            }
845        }
846    }
847
848    /// Reconcile the `r-if`/`r-for` parents whose deps changed: build a fresh tree,
849    /// splice its affected subtrees into the live one, and re-apply focus *scoped*
850    /// to those subtrees. Unaffected subtrees keep their live node identity, so a
851    /// caret (or any ephemeral state) elsewhere survives with no whole-tree
852    /// restore. Refreshes the registry to the fresh build. No-op if nothing
853    /// structural changed.
854    fn reconcile(&mut self, changed: &HashSet<String>) {
855        // Outermost affected structural parents (a nested one is regenerated by its
856        // ancestor's splice), and the toggle nodes whose bound signal changed.
857        let mut affected: Vec<Vec<usize>> = self
858            .registry
859            .structural_parents
860            .iter()
861            .filter(|p| !p.deps.is_disjoint(changed))
862            .map(|p| p.tree_path.clone())
863            .collect();
864        let toggles: Vec<Vec<usize>> = self
865            .registry
866            .toggles
867            .iter()
868            .filter(|t| !t.deps.is_disjoint(changed))
869            .map(|t| t.path.clone())
870            .collect();
871        // Components and `:class`/`:style` nodes both reconcile by node-splice with
872        // scoped focus (their subtrees may hold inputs).
873        let mut node_splices: Vec<Vec<usize>> = self
874            .registry
875            .components
876            .iter()
877            .filter(|c| !c.deps.is_disjoint(changed))
878            .map(|c| c.path.clone())
879            .collect();
880        node_splices.extend(
881            self.registry
882                .styled
883                .iter()
884                .filter(|s| !s.deps.is_disjoint(changed))
885                .map(|s| s.path.clone()),
886        );
887        if affected.is_empty() && toggles.is_empty() && node_splices.is_empty() {
888            return;
889        }
890        affected.sort_by_key(Vec::len);
891        let mut roots: Vec<Vec<usize>> = Vec::new();
892        for p in affected {
893            if !roots.iter().any(|r| p.starts_with(r.as_slice())) {
894                roots.push(p);
895            }
896        }
897
898        let Ok((mut fresh_root, fresh_reg)) = rux_style::build_styled_tree_stateful(
899            &self.sfc,
900            &self.components,
901            &mut self.engine,
902            &mut self.instances,
903            &self.state,
904            self.viewport,
905        ) else {
906            return;
907        };
908        resolve_images(&mut fresh_root, &self.base);
909        // Structural parents: replace the affected parent's children wholesale.
910        for p in &roots {
911            let Some(fresh) = node_at(&fresh_root, p) else { continue };
912            let fresh_children = fresh.children.clone();
913            let row = row_at(&fresh_root, p);
914            if let Some(live) = node_at_mut(&mut self.root, p) {
915                live.children = fresh_children;
916                // Put the caret back only within this rebuilt subtree. The rows
917                // carry their own keys, so a caret in a row that moved lands in
918                // that row rather than in the position it used to hold.
919                apply_focus_in(live, self.focus.as_ref(), row.as_deref());
920            }
921        }
922        // Toggles: replace just the single node (its checked style + mark). No
923        // shape change and no caret on a toggle, so no scoped focus is needed.
924        for p in &toggles {
925            if roots.iter().any(|r| p.starts_with(r.as_slice())) {
926                continue; // already covered by a parent splice above
927            }
928            if let Some(fresh) = node_at(&fresh_root, p) {
929                let fresh_node = fresh.clone();
930                if let Some(live) = node_at_mut(&mut self.root, p) {
931                    *live = fresh_node;
932                }
933            }
934        }
935        // Components and :class/:style nodes: replace the whole node subtree,
936        // re-applying focus scoped to it (the subtree may hold inputs).
937        for p in &node_splices {
938            if roots.iter().any(|r| p.starts_with(r.as_slice())) {
939                continue;
940            }
941            if let Some(fresh) = node_at(&fresh_root, p) {
942                let fresh_node = fresh.clone();
943                let row = row_at(&fresh_root, p);
944                if let Some(live) = node_at_mut(&mut self.root, p) {
945                    *live = fresh_node;
946                    apply_focus_in(live, self.focus.as_ref(), row.as_deref());
947                }
948            }
949        }
950        self.registry = fresh_reg;
951    }
952
953    /// Apply an input edit (a keystroke's new value for `model`) and reflect it the
954    /// cheapest correct way: patch the input's shown value in place, falling back
955    /// to a rebuild only when `model` is also read structurally. The caller sets
956    /// the caret afterward via [`set_focus`](Self::set_focus).
957    pub fn apply_edit(&mut self, model: &str, value: &str) {
958        self.apply_edit_in(model, None, value);
959    }
960
961    /// [`apply_edit`](Self::apply_edit) for an input in a known `r-for` row.
962    ///
963    /// The row matters because an `r-model` is recorded as written: inside a
964    /// list it can mention the loop variable, which only exists in that row's
965    /// scope. The scope was captured when the input was built, so this looks it
966    /// up rather than reconstructing it.
967    pub fn apply_edit_in(&mut self, model: &str, row: Option<&str>, value: &str) {
968        let locals = self.locals_for(model, row);
969        let changed = self.engine.assign_string(model, value, &locals);
970        if changed.is_empty() {
971            return;
972        }
973        self.apply_change(&changed);
974    }
975
976    /// An input's current value, read in its own row's scope.
977    pub fn value_in(&mut self, model: &str, row: Option<&str>) -> String {
978        let locals = self.locals_for(model, row);
979        self.engine.get_string_in(model, &locals)
980    }
981
982    /// The loop variables that were in scope where this input was built.
983    ///
984    /// Matched on model *and* row, since a list's rows all record the same
985    /// model. Empty for an input outside a list, which is the common case and
986    /// needs nothing.
987    fn locals_for(&self, model: &str, row: Option<&str>) -> Vec<(String, rux_reactive::Value)> {
988        self.registry
989            .value
990            .iter()
991            .find(|b| b.model == model && b.row.as_deref() == row)
992            .map(|b| b.locals.clone())
993            .unwrap_or_default()
994    }
995
996    /// Run an `@tap` handler and reflect its effect the cheapest correct way:
997    /// patch the changed bindings in place, falling back to a full rebuild only
998    /// when the change is structural. Returns whether anything changed, so the
999    /// shell knows whether to repaint.
1000    pub fn apply_handler(&mut self, src: &str) -> bool {
1001        self.apply_handler_in(src, None)
1002    }
1003
1004    /// Run a handler that was written inside a component instance.
1005    ///
1006    /// The instance's state and props are in scope, and whatever the handler
1007    /// leaves them at is written back: that is what makes a component's own
1008    /// state writable, and what keeps two instances of one component apart.
1009    ///
1010    /// A change to instance state rebuilds rather than patches. The state is
1011    /// not a signal, so the binding registry has nothing to look it up by, and
1012    /// claiming otherwise would mean bindings quietly missing updates. A
1013    /// component is a subtree, so the rebuild is bounded in practice.
1014    pub fn apply_handler_in(&mut self, src: &str, instance: Option<&str>) -> bool {
1015        // Anything emitted before this handler was not emitted *by* it: a build
1016        // evaluates every binding, and a stray `emit` in one of those would
1017        // otherwise be delivered to the first tap that happened afterwards. The
1018        // same goes for a `navigate` evaluated during a build.
1019        let _ = rux_script::take_emissions();
1020        let _ = rux_script::take_navigations();
1021        let ran = self.dispatch_handler(src, instance, 0);
1022        // Navigation is applied once, after the handler and everything it set
1023        // off have finished. A handler that navigates and then writes a signal
1024        // would otherwise render the old route with the new state in it.
1025        self.apply_navigations() || ran
1026    }
1027
1028    /// Apply whatever `navigate`/`back`/`forward` the last run asked for.
1029    ///
1030    /// Later calls win: a handler that navigates twice ends up where the second
1031    /// one pointed, and the intermediate path is not a place the user visited.
1032    fn apply_navigations(&mut self) -> bool {
1033        let mut moved = false;
1034        for nav in rux_script::take_navigations() {
1035            moved |= match nav {
1036                rux_script::Nav::To(path) => self.navigate(&path),
1037                rux_script::Nav::Replace(path) => self.replace(&path),
1038                rux_script::Nav::Back => self.back(),
1039                rux_script::Nav::Forward => self.forward(),
1040            };
1041        }
1042        moved
1043    }
1044
1045    /// The path the document is on, without any query string.
1046    ///
1047    /// The same thing the `route` signal holds, so `doc.route()` and
1048    /// `{{ route }}` cannot disagree. For the whole address, query included,
1049    /// see [`Document::location`].
1050    pub fn route(&self) -> &str {
1051        rux_script::split_query(self.history.current()).0
1052    }
1053
1054    /// The whole address the document is on, query string included.
1055    ///
1056    /// What a URL bar shows and what `--route` accepts. The history stores
1057    /// this rather than the bare path, so going back to a search restores what
1058    /// was being searched for.
1059    pub fn location(&self) -> &str {
1060        self.history.current()
1061    }
1062
1063    /// Go to `path`, recording it in the history.
1064    ///
1065    /// Returns whether anything changed, so the shell knows whether to repaint.
1066    /// Navigating to where we already are changes nothing, which is what makes
1067    /// tapping the current link in a nav bar a no-op rather than a rebuild.
1068    pub fn navigate(&mut self, path: &str) -> bool {
1069        if !self.history.push(path) {
1070            return false;
1071        }
1072        // A page being opened, not returned to, so it opens at its beginning.
1073        self.set_scroll_intent(None);
1074        self.show_current_route()
1075    }
1076
1077    /// Open the document *at* `path` instead of at `/`.
1078    ///
1079    /// This is what a deep link arrives as: a browser tab opened straight at
1080    /// `/user/7`, or `rux run app.rux --route /user/7`. It replaces the history
1081    /// rather than adding to it, so the arrival page is the first page and Back
1082    /// has nowhere to go, which is what actually happened.
1083    ///
1084    /// Called before the first frame. Calling it later would silently discard
1085    /// wherever the user had got to.
1086    pub fn start_at(&mut self, path: &str) -> bool {
1087        self.history = History::starting_at(path);
1088        self.set_scroll_intent(None);
1089        self.show_current_route()
1090    }
1091
1092    /// How far along the history the document is, and how long the history is.
1093    ///
1094    /// The pair is what a host history (the browser's) needs to mirror this
1095    /// one: the index is stamped into each entry it pushes, and comes back
1096    /// untouched when the user presses Back. See [`Document::go_to`].
1097    pub fn history_position(&self) -> (usize, usize) {
1098        (self.history.at, self.history.entries.len())
1099    }
1100
1101    /// Move to the history entry at `index`, without recording a visit.
1102    ///
1103    /// The browser's Back button reports *where it landed*, not which way it
1104    /// went, and it can move several entries at once. Returns whether the
1105    /// document moved; `false` means the index was out of range or already
1106    /// current, and the caller's history has drifted from this one.
1107    pub fn go_to(&mut self, index: usize) -> bool {
1108        if !self.history.go_to(index) {
1109            return false;
1110        }
1111        self.restore_scroll_here();
1112        self.show_current_route()
1113    }
1114
1115    /// Go to `path` *instead of* where we are, overwriting the current entry.
1116    ///
1117    /// What a redirect needs. `navigate` would leave the redirecting page in
1118    /// the history, so Back would land on it and be redirected forward again,
1119    /// which reads as the Back button being broken.
1120    pub fn replace(&mut self, path: &str) -> bool {
1121        if !self.history.replace(path) {
1122            return false;
1123        }
1124        // Still an arrival rather than a return: a redirect lands at the top.
1125        self.set_scroll_intent(None);
1126        self.show_current_route()
1127    }
1128
1129    /// Step back through the history. Returns whether there was anywhere to go.
1130    pub fn back(&mut self) -> bool {
1131        if !self.history.back() {
1132            return false;
1133        }
1134        self.restore_scroll_here();
1135        self.show_current_route()
1136    }
1137
1138    /// Step forward again. Returns whether there was anywhere to go.
1139    pub fn forward(&mut self) -> bool {
1140        if !self.history.forward() {
1141            return false;
1142        }
1143        self.restore_scroll_here();
1144        self.show_current_route()
1145    }
1146
1147    /// Ask for the offsets recorded on the entry just arrived at. Only called
1148    /// after a step through the history, which is the one way to arrive
1149    /// somewhere you have already been.
1150    fn restore_scroll_here(&mut self) {
1151        let recorded = self.history.entries[self.history.at].scroll.clone();
1152        self.set_scroll_intent(Some(recorded));
1153    }
1154
1155    /// Remember how far down the current page the user is.
1156    ///
1157    /// Called once a frame by the shell, which owns the offsets, rather than at
1158    /// each place that could navigate: a `navigate()` inside a handler moves the
1159    /// history before the shell hears about it, so the position has to have been
1160    /// recorded already. A scroll causes a repaint, so the last frame's record is
1161    /// current.
1162    pub fn record_scroll(&mut self, offsets: &[Offset]) {
1163        let entry = &mut self.history.entries[self.history.at];
1164        if entry.scroll != offsets {
1165            entry.scroll = offsets.to_vec();
1166        }
1167    }
1168
1169    /// The offsets the next frame should adopt, if a navigation has chosen some.
1170    ///
1171    /// Empty means the top. Taking it clears it, so a frame that has already
1172    /// obeyed does not keep being told.
1173    pub fn take_scroll(&mut self) -> Option<Vec<Offset>> {
1174        self.pending_scroll.take()
1175    }
1176
1177    /// Decide where the page that is arriving should sit.
1178    ///
1179    /// The flag means *remember*, not *always restore*: on a new navigation you
1180    /// go to the top, and only back or forward puts you back where you were.
1181    /// Which of the two it is comes from **how you arrived**, not from a
1182    /// preference, and that is what every platform does. A flag meaning "always
1183    /// restore" would drop you into the middle of a page you had just opened
1184    /// for the first time, which reads as a bug rather than a feature.
1185    fn set_scroll_intent(&mut self, restored: Option<Vec<Offset>>) {
1186        let remembering = rux_style::restore_scroll(&self.sfc.template);
1187        self.pending_scroll = match restored {
1188            Some(offsets) if remembering => Some(offsets),
1189            // Turned off, or a page being opened rather than returned to.
1190            _ => Some(Vec::new()),
1191        };
1192    }
1193
1194    /// Move the document to whatever the history now points at.
1195    fn show_current_route(&mut self) -> bool {
1196        let location = self.history.current().to_string();
1197        // The *path*, because that is what a view records itself against: a
1198        // query is an argument to a page and does not make it a different one.
1199        let path = rux_script::split_query(&location).0.to_string();
1200        // A route's views are dropped when we leave it, so visiting one a second
1201        // time starts fresh instead of resuming where it was left. That is what
1202        // every router does, and the alternative here would be accidental:
1203        // instance state is keyed by template position and would otherwise
1204        // simply still be sitting there.
1205        //
1206        // The build's own pruning does not cover this case and does not replace
1207        // it: going from `/user/7` to `/user/12` expands the *same* view at the
1208        // same template position, so the build reaches that instance and keeps
1209        // it. Only the recorded route can tell the two visits apart.
1210        self.instances.retain(|_, i| i.route.as_deref().is_none_or(|r| r == path));
1211        let moved = self.publish_route(&location);
1212        if !moved {
1213            return false;
1214        }
1215        // The route is a signal like any other, so the ordinary change path
1216        // decides between a reconcile and a rebuild. A `<router>` records itself
1217        // as a structural read, so this reconciles the router's subtree.
1218        //
1219        // All four names, not just the path: a breadcrumb bound to `params.id`
1220        // or a Back button bound to `can_go_back` subscribed to *that* name,
1221        // and invalidating only `route` would leave them showing the last page.
1222        self.apply_change(&HashSet::from_iter(
1223            rux_script::ROUTER_SIGNALS.iter().map(|s| s.to_string()),
1224        ));
1225        true
1226    }
1227
1228    /// Put the path and everything derived from it in scope, and say whether
1229    /// any of it moved.
1230    ///
1231    /// The three companions to `route` are all set here rather than where each
1232    /// is caused, so there is one place where the router's view of the world is
1233    /// assembled and no way for them to disagree with the path.
1234    ///
1235    /// `params` is matched against the template's routes *before* the build.
1236    /// Falling out of the build instead would leave `{{ params.id }}` written
1237    /// outside the router rendering one navigation behind.
1238    fn publish_route(&mut self, location: &str) -> bool {
1239        // A query is an argument to a page, not a different page, so matching
1240        // and the `route` signal both see the path alone.
1241        let (path, query) = rux_script::split_query(location);
1242        let params = rux_style::route_params(&self.sfc.template, path);
1243        let (at, len) = (self.history.at, self.history.entries.len());
1244        let mut moved = self.engine.set_route(path);
1245        moved |= self.engine.set_provided(rux_script::PARAMS_SIGNAL, Value::Map(params));
1246        moved |= self
1247            .engine
1248            .set_provided(rux_script::QUERY_SIGNAL, Value::Map(rux_script::parse_query(query)));
1249        moved |= self.engine.set_provided(rux_script::CAN_BACK_SIGNAL, Value::Bool(at > 0));
1250        moved |=
1251            self.engine.set_provided(rux_script::CAN_FORWARD_SIGNAL, Value::Bool(at + 1 < len));
1252        moved
1253    }
1254
1255    /// One handler run, plus whatever its `emit` calls set off. `depth` is the
1256    /// length of that chain: a component listened to by a component that emits
1257    /// back is a cycle, and stopping it at a bound is better than a window that
1258    /// never repaints.
1259    fn dispatch_handler(&mut self, src: &str, instance: Option<&str>, depth: usize) -> bool {
1260        const MAX_EVENT_DEPTH: usize = 8;
1261        if depth > MAX_EVENT_DEPTH {
1262            rux_script::warn_script(format!(
1263                "an event chain is still going after {MAX_EVENT_DEPTH} rounds and has been \
1264                 stopped; a component is probably emitting an event that comes back to it"
1265            ));
1266            return false;
1267        }
1268
1269        let Some(key) = instance.filter(|k| self.instances.contains_key(*k)) else {
1270            let changed = self.engine.run_handler_tracked(src);
1271            // An `emit` outside a component has nobody to tell: the document is
1272            // the top of the tree. Say so rather than dropping it, since the
1273            // author plainly expected something to happen.
1274            for (event, _) in rux_script::take_emissions() {
1275                rux_script::warn_script(format!(
1276                    "`emit(\"{event}\")` outside a component has no caller to receive it"
1277                ));
1278            }
1279            if changed.is_empty() {
1280                return false;
1281            }
1282            self.apply_change(&changed);
1283            return true;
1284        };
1285
1286        let entry = &self.instances[key];
1287        let mut locals = entry.state.clone();
1288        locals.extend(entry.props.iter().cloned());
1289        let (after, changed) = self.engine.run_scoped_handler(src, &locals);
1290
1291        // Only the component's own names are written back. A prop belongs to the
1292        // caller: assigning to one inside a component would look like it worked
1293        // and be forgotten on the next build, which is worse than not allowing it.
1294        let state_names: Vec<String> =
1295            self.instances[key].state.iter().map(|(n, _)| n.clone()).collect();
1296        let mut moved = false;
1297        for (name, value) in after {
1298            if !state_names.contains(&name) {
1299                continue;
1300            }
1301            let slot = self
1302                .instances
1303                .get_mut(key)
1304                .and_then(|i| i.state.iter_mut().find(|(n, _)| *n == name));
1305            if let Some(slot) = slot {
1306                if slot.1 != value {
1307                    slot.1 = value;
1308                    moved = true;
1309                }
1310            }
1311        }
1312
1313        // Deliver whatever the handler emitted. After the state write-back
1314        // above, so a listener that rebuilds rebuilds against the state the
1315        // handler left, not the state it started from.
1316        let mut fired = false;
1317        for (event, payload) in rux_script::take_emissions() {
1318            let listener = self.instances[key]
1319                .listeners
1320                .iter()
1321                .find(|(name, _)| *name == event)
1322                .map(|(_, body)| body.clone());
1323            // An event nobody listens to is ordinary, not a mistake: a
1324            // component offers events and a caller takes the ones it wants.
1325            let Some(body) = listener else { continue };
1326            let caller = self.instances[key].caller.clone();
1327            fired |= self.dispatch_handler(&with_event(&body, payload.as_ref()), caller.as_deref(), depth + 1);
1328        }
1329
1330        if !changed.is_empty() {
1331            self.apply_change(&changed);
1332            return true;
1333        }
1334        if moved {
1335            self.rebuild();
1336            return true;
1337        }
1338        fired
1339    }
1340
1341    /// Reflect a set of changed signals: patch in place, or rebuild when the change
1342    /// is structural. `RUX_TRACE=1` prints which path was taken, so the reactivity
1343    /// behavior is observable while driving (the pixels are identical either way).
1344    /// Record what the computeds and effects read, and run every effect once.
1345    ///
1346    /// Effects run on load rather than only on the first change, which is the
1347    /// only way an effect can *establish* something (a title, a saved value)
1348    /// rather than merely react to it. It is also where their dependency sets
1349    /// come from: an effect subscribes to what it read, so it has to read first.
1350    fn init_reactive(&mut self) {
1351        for i in 0..self.computeds.len() {
1352            let (name, expr) = (self.computeds[i].name.clone(), self.computeds[i].expr.clone());
1353            let (_, deps) = self.engine.recompute(&name, &expr);
1354            self.computeds[i].deps = deps;
1355        }
1356        let mut writes: HashSet<String> = HashSet::new();
1357        for i in 0..self.effects.len() {
1358            let body = self.effects[i].body.clone();
1359            let (mut reads, wrote) = self.engine.run_effect_tracked(&body);
1360            // An effect is never woken by its own writes. Assigning to a signal
1361            // resolves its name, so the tracker sees a write as a read too, and
1362            // every effect that wrote anything would immediately re-trigger
1363            // itself: harmless when the result settles, an eight-round pile-up
1364            // when it does not. The cost is that an effect which writes X will
1365            // not re-run when someone *else* changes X, which is the right way
1366            // round: that effect is the one deciding what X is.
1367            reads.retain(|n| !wrote.contains(n));
1368            self.effects[i].deps = reads;
1369            writes.extend(wrote);
1370        }
1371        self.diagnostics.warnings.extend(collect_warnings());
1372        if !writes.is_empty() {
1373            // An effect that set something on load has to be reflected, or the
1374            // first frame shows the state it was written to replace.
1375            self.apply_change_depth(&writes, 1);
1376        }
1377    }
1378
1379    /// Bring the computeds up to date, adding any that actually changed to
1380    /// `changed` so the bindings reading them are patched too.
1381    ///
1382    /// One pass in declaration order, which is enough for a computed that reads
1383    /// another declared above it, and is where the ordering rule comes from: a
1384    /// computed may only read computeds declared before it. The alternative is
1385    /// iterating to a fixpoint, which turns a typo into a hang.
1386    fn refresh_computed(&mut self, changed: &mut HashSet<String>) {
1387        for i in 0..self.computeds.len() {
1388            let stale = !self.computeds[i].deps.is_disjoint(changed);
1389            if !stale {
1390                continue;
1391            }
1392            let (name, expr) = (self.computeds[i].name.clone(), self.computeds[i].expr.clone());
1393            let (moved, deps) = self.engine.recompute(&name, &expr);
1394            self.computeds[i].deps = deps;
1395            if moved {
1396                changed.insert(name);
1397            }
1398        }
1399    }
1400
1401    /// Run the effects whose dependencies changed, and report what they wrote.
1402    fn run_effects(&mut self, changed: &HashSet<String>) -> HashSet<String> {
1403        let mut writes = HashSet::new();
1404        for i in 0..self.effects.len() {
1405            if self.effects[i].deps.is_disjoint(changed) {
1406                continue;
1407            }
1408            let body = self.effects[i].body.clone();
1409            let (mut reads, wrote) = self.engine.run_effect_tracked(&body);
1410            // An effect is never woken by its own writes. Assigning to a signal
1411            // resolves its name, so the tracker sees a write as a read too, and
1412            // every effect that wrote anything would immediately re-trigger
1413            // itself: harmless when the result settles, an eight-round pile-up
1414            // when it does not. The cost is that an effect which writes X will
1415            // not re-run when someone *else* changes X, which is the right way
1416            // round: that effect is the one deciding what X is.
1417            reads.retain(|n| !wrote.contains(n));
1418            self.effects[i].deps = reads;
1419            writes.extend(wrote);
1420        }
1421        writes
1422    }
1423
1424    fn apply_change(&mut self, changed: &HashSet<String>) {
1425        self.apply_change_depth(changed, 0);
1426    }
1427
1428    /// [`apply_change`](Self::apply_change), counting how many times an effect
1429    /// has caused another round.
1430    ///
1431    /// An effect that writes a signal it also reads is a loop. It is stopped and
1432    /// reported rather than followed: a window that hangs tells you nothing,
1433    /// while a warning naming the effect is the whole diagnosis.
1434    fn apply_change_depth(&mut self, changed: &HashSet<String>, depth: u32) {
1435        const MAX_EFFECT_ROUNDS: u32 = 8;
1436        let mut changed = changed.clone();
1437        self.refresh_computed(&mut changed);
1438        let patched = self.patch(&changed);
1439        if !patched {
1440            self.rebuild();
1441        }
1442        let writes = self.run_effects(&changed);
1443        if !writes.is_empty() {
1444            if depth + 1 >= MAX_EFFECT_ROUNDS {
1445                let mut names: Vec<&str> = writes.iter().map(String::as_str).collect();
1446                names.sort_unstable();
1447                rux_style::warn_stylesheet(format!(
1448                    "an `effect` keeps re-triggering itself (still writing {names:?} after \
1449                     {MAX_EFFECT_ROUNDS} rounds); it was stopped. An effect must not write a \
1450                     signal it also reads."
1451                ));
1452                self.diagnostics.warnings.extend(collect_warnings());
1453                return;
1454            }
1455            self.apply_change_depth(&writes, depth + 1);
1456            return;
1457        }
1458        if std::env::var_os("RUX_TRACE").is_some() {
1459            let mut names: Vec<&str> = changed.iter().map(String::as_str).collect();
1460            names.sort_unstable();
1461            eprintln!(
1462                "rux: change {names:?} → {}",
1463                if patched { "patched in place (no rebuild)" } else { "rebuilt (structural)" }
1464            );
1465        }
1466    }
1467}
1468
1469/// Follow a child-index path from the root to a node.
1470fn node_at<'a>(root: &'a LayoutNode, path: &[usize]) -> Option<&'a LayoutNode> {
1471    let mut node = root;
1472    for &i in path {
1473        node = node.children.get(i)?;
1474    }
1475    Some(node)
1476}
1477
1478/// The `r-key` of the row `path` lands inside, if any.
1479///
1480/// A splice re-applies focus to a subtree, and the subtree cannot tell you which
1481/// row it is in: the key is on an ancestor that the splice never looks at. This
1482/// walks down from the root to recover it.
1483fn row_at(root: &LayoutNode, path: &[usize]) -> Option<String> {
1484    let mut node = root;
1485    let mut row = node.key.clone();
1486    for &i in path {
1487        node = node.children.get(i)?;
1488        if node.key.is_some() {
1489            row = node.key.clone();
1490        }
1491    }
1492    row
1493}
1494
1495/// Follow a child-index path from the root to a node, mutably.
1496fn node_at_mut<'a>(root: &'a mut LayoutNode, path: &[usize]) -> Option<&'a mut LayoutNode> {
1497    let mut node = root;
1498    for &i in path {
1499        node = node.children.get_mut(i)?;
1500    }
1501    Some(node)
1502}
1503
1504/// Read the stylesheets a document asked for with `<style src="…">`, relative
1505/// to the file that asked.
1506///
1507/// A missing stylesheet is a load failure, not a warning, and deliberately the
1508/// same kind of failure as a missing component: both are a file naming another
1509/// file that is not there, and a document that silently renders unstyled looks
1510/// like a layout bug rather than a typo in a path. The window keeps the last
1511/// good tree on screen and puts the message in the overlay, so the cost of
1512/// being strict is a red panel and not a closed window.
1513fn resolve_style_includes(sfc: &mut Sfc, base: &Path) -> Result<(), LoadError> {
1514    if sfc.style_src.is_empty() {
1515        return Ok(());
1516    }
1517    let mut includes = Vec::with_capacity(sfc.style_src.len());
1518    for relative in &sfc.style_src {
1519        let path = base.join(relative);
1520        let css = std::fs::read_to_string(&path).map_err(|e| {
1521            LoadError::plain(format!("reading stylesheet {}: {e}", path.display()))
1522        })?;
1523        includes.push(StyleInclude { path: relative.clone(), css });
1524    }
1525    sfc.style_includes = includes;
1526    Ok(())
1527}
1528
1529/// Say that an include could not be resolved because there is no file to be
1530/// relative to. Only the browser reaches this.
1531fn warn_unresolvable_include(path: &str) {
1532    rux_style::warn_stylesheet(format!(
1533        "`<style src=\"{path}\">` was ignored: this document was loaded from source, \
1534         not from a file, so there is nothing for the path to be relative to"
1535    ));
1536}
1537
1538/// A `computed name = expr;` declaration.
1539///
1540/// Kept beside the script rather than inside it because it has to be
1541/// *re-evaluated*, and rhai has no lazy value: the line is rewritten to a plain
1542/// `let` so the name becomes an ordinary signal, and the expression is kept here
1543/// so the runtime can run it again when something it reads changes.
1544#[derive(Clone, Debug)]
1545struct Computed {
1546    name: String,
1547    expr: String,
1548    /// Signals the expression read when it last ran.
1549    deps: HashSet<String>,
1550}
1551
1552/// An `effect { … }` block: statements to run when what they read changes.
1553#[derive(Clone, Debug)]
1554struct Effect {
1555    body: String,
1556    /// Signals the body read when it last ran. Recorded per run, so an effect
1557    /// whose reads depend on a condition subscribes to what it actually touched.
1558    deps: HashSet<String>,
1559}
1560
1561/// Pull `computed` and `effect` declarations out of a script.
1562///
1563/// Returns the script rhai should see, with every consumed line replaced by a
1564/// blank one so line numbers still match the file: a warning pointing at the
1565/// wrong line is worse than one pointing nowhere.
1566///
1567/// `computed x = expr;` becomes `let x = expr;`, which is what makes a computed
1568/// an ordinary signal, initialised in declaration order alongside the rest.
1569fn extract_reactives(script: &str) -> (String, Vec<Computed>, Vec<Effect>) {
1570    let mut cleaned = String::new();
1571    let mut computeds = Vec::new();
1572    let mut effects = Vec::new();
1573
1574    let lines: Vec<&str> = script.lines().collect();
1575    let mut i = 0;
1576    while i < lines.len() {
1577        let line = lines[i];
1578        let trimmed = line.trim();
1579
1580        if let Some(rest) = trimmed.strip_prefix("computed ") {
1581            if let Some((name, expr)) = rest.split_once('=') {
1582                let name = name.trim();
1583                let expr = expr.trim().trim_end_matches(';').trim();
1584                if is_identifier(name) && !expr.is_empty() {
1585                    computeds.push(Computed {
1586                        name: name.to_string(),
1587                        expr: expr.to_string(),
1588                        deps: HashSet::new(),
1589                    });
1590                    // Declared, not stripped: the value has to exist before any
1591                    // binding reads it, and being a `let` is what makes it a
1592                    // signal the rest of the pipeline already understands.
1593                    cleaned.push_str(&format!("let {name} = {expr};\n"));
1594                    i += 1;
1595                    continue;
1596                }
1597            }
1598        }
1599
1600        if trimmed == "effect {" || trimmed.starts_with("effect {") {
1601            // Take the block by counting braces, so an effect can hold an `if`.
1602            let mut depth = 0i32;
1603            let mut body = String::new();
1604            let mut j = i;
1605            let mut closed = false;
1606            while j < lines.len() {
1607                let l = lines[j];
1608                for c in l.chars() {
1609                    match c {
1610                        '{' => depth += 1,
1611                        '}' => depth -= 1,
1612                        _ => {}
1613                    }
1614                }
1615                let start = if j == i { l.find('{').map(|p| p + 1).unwrap_or(0) } else { 0 };
1616                body.push_str(&l[start..]);
1617                body.push('\n');
1618                cleaned.push('\n'); // keep the file's line numbering
1619                j += 1;
1620                if depth <= 0 {
1621                    closed = true;
1622                    break;
1623                }
1624            }
1625            if closed {
1626                // Drop the trailing `}` the loop consumed with the last line.
1627                let body = body.trim_end();
1628                let body = body.strip_suffix('}').unwrap_or(body).to_string();
1629                effects.push(Effect { body, deps: HashSet::new() });
1630                i = j;
1631                continue;
1632            }
1633            // Unterminated: leave it to rhai to complain about, with its lines.
1634            rux_style::warn_stylesheet(
1635                "an `effect {` block is never closed; it was ignored".to_string(),
1636            );
1637            i = j;
1638            continue;
1639        }
1640
1641        cleaned.push_str(line);
1642        cleaned.push('\n');
1643        i += 1;
1644    }
1645    (cleaned, computeds, effects)
1646}
1647
1648/// Whether `s` is a plain identifier, so `computed 2 + 2 = x;` is left for rhai
1649/// to reject rather than quietly becoming a declaration.
1650fn is_identifier(s: &str) -> bool {
1651    !s.is_empty()
1652        && !s.starts_with(|c: char| c.is_ascii_digit())
1653        && s.chars().all(|c| c.is_alphanumeric() || c == '_')
1654}
1655
1656/// A listener body with the emitted payload bound to `event`.
1657///
1658/// Baked in as a `let` prelude rather than passed as an argument, the same
1659/// trick that carries an `r-for` row into an `@tap`: the body is statements the
1660/// caller wrote inline, not a function, so there is no parameter list to put it
1661/// in. `emit("change")` with no payload leaves `event` undeclared, so reading it
1662/// is a lookup failure rather than a silent empty value.
1663fn with_event(body: &str, payload: Option<&rux_reactive::Value>) -> String {
1664    match payload {
1665        Some(value) => format!("let event = {}; {body}", value.to_rhai_literal()),
1666        None => body.to_string(),
1667    }
1668}
1669
1670/// Just the `fn` definitions from a component's script.
1671///
1672/// The complement of `rux-style`'s `component_statements`: functions are code
1673/// and are shared across instances, everything else is state and is not.
1674fn component_functions(script: &str) -> String {
1675    let mut out = String::new();
1676    let lines: Vec<&str> = script.lines().collect();
1677    let mut i = 0;
1678    while i < lines.len() {
1679        if !lines[i].trim().starts_with("fn ") {
1680            i += 1;
1681            continue;
1682        }
1683        let mut depth = 0i32;
1684        let mut seen = false;
1685        while i < lines.len() {
1686            for c in lines[i].chars() {
1687                match c {
1688                    '{' => {
1689                        depth += 1;
1690                        seen = true;
1691                    }
1692                    '}' => depth -= 1,
1693                    _ => {}
1694                }
1695            }
1696            out.push_str(lines[i]);
1697            out.push('\n');
1698            i += 1;
1699            if seen && depth <= 0 {
1700                break;
1701            }
1702        }
1703    }
1704    out
1705}
1706
1707/// A resolved component import.
1708struct Import {
1709    /// Custom-element tag (last path segment, `_` → `-`).
1710    tag: String,
1711    /// File path relative to the importing document (`a::b` → `a/b.rux`).
1712    file: String,
1713}
1714
1715/// Split `use a::b;` lines out of a script, returning the cleaned script (which
1716/// `rhai` can parse) and the resolved imports.
1717fn extract_imports(script: &str) -> (String, Vec<Import>) {
1718    let mut cleaned = String::new();
1719    let mut imports = Vec::new();
1720
1721    for line in script.lines() {
1722        let trimmed = line.trim();
1723        if let Some(rest) = trimmed.strip_prefix("use ") {
1724            // A `use` must be its own statement on its own line; a path with
1725            // spaces or extra `;` is malformed, leave it for rhai to reject.
1726            if let Some(path) = rest.strip_suffix(';').map(str::trim).filter(|p| {
1727                !p.is_empty() && !p.contains(char::is_whitespace) && !p.contains(';')
1728            }) {
1729                let segments: Vec<&str> = path.split("::").collect();
1730                let file = format!("{}.rux", segments.join("/"));
1731                let tag = segments
1732                    .last()
1733                    .map(|s| s.replace('_', "-"))
1734                    .unwrap_or_default();
1735                imports.push(Import { tag, file });
1736                continue; // strip the import line
1737            }
1738        }
1739        cleaned.push_str(line);
1740        cleaned.push('\n');
1741    }
1742    (cleaned, imports)
1743}
1744
1745/// Build the script engine and register host functions (the native-capability
1746/// boundary; a real app registers its own here).
1747fn build_engine(script: &str) -> Result<Engine, String> {
1748    let mut builder = Builder::new();
1749    builder.host_number("full", || 100.0);
1750    let mut engine = builder.build(script)?;
1751    // The route has to be in scope before the first build, because a `<router>`
1752    // reads it during that build. A document that declared `route` itself is
1753    // told rather than quietly overwritten on the first navigation.
1754    for name in rux_script::ROUTER_SIGNALS {
1755        if engine.declares(name) {
1756            rux_script::warn_script(format!(
1757                "`{name}` is the router's and is provided for you; a `let {name}` of your own is \
1758                 overwritten on every navigation"
1759            ));
1760        }
1761    }
1762    engine.set_route(ROOT_PATH);
1763    // The companions need to exist before the first build too, or a document
1764    // reading `params.id` or `can_go_back` on its opening screen would fail to
1765    // resolve the name rather than see the empty answer that is the truth.
1766    engine.set_provided(rux_script::PARAMS_SIGNAL, Value::Map(Vec::new()));
1767    engine.set_provided(rux_script::QUERY_SIGNAL, Value::Map(Vec::new()));
1768    engine.set_provided(rux_script::CAN_BACK_SIGNAL, Value::Bool(false));
1769    engine.set_provided(rux_script::CAN_FORWARD_SIGNAL, Value::Bool(false));
1770    Ok(engine)
1771}
1772
1773#[cfg(test)]
1774mod tests {
1775    use super::*;
1776
1777    /// Every string the tree renders, for a failure message that says what was
1778    /// actually on screen rather than dumping the whole node.
1779    fn text_of(node: &LayoutNode) -> Vec<String> {
1780        let mut out: Vec<String> = node.text.iter().map(|t| t.text.clone()).collect();
1781        for child in &node.children {
1782            out.extend(text_of(child));
1783        }
1784        out
1785    }
1786
1787    fn find_text(node: &LayoutNode, needle: &str) -> bool {
1788        if let Some(t) = &node.text {
1789            if t.text.contains(needle) {
1790                return true;
1791            }
1792        }
1793        node.children.iter().any(|c| find_text(c, needle))
1794    }
1795
1796    #[test]
1797    fn loads_document_and_expands_imported_component() {
1798        // Self-contained fixtures (not the mutable examples): exercises import
1799        // resolution, component file loading, engine merge, and expansion.
1800        use std::fs;
1801        let dir = std::env::temp_dir().join(format!("rux_test_{}", std::process::id()));
1802        let comp_dir = dir.join("components");
1803        fs::create_dir_all(&comp_dir).unwrap();
1804        fs::write(
1805            comp_dir.join("stat.rux"),
1806            r#"<template><view><text>{{ label }}: {{ value }}</text></view></template>"#,
1807        )
1808        .unwrap();
1809        fs::write(
1810            dir.join("app.rux"),
1811            "<template><screen><stat :label=\"title\" :value=\"n\" /></screen></template>\n\
1812             <script>\n\
1813             use components::stat;\n\
1814             let title = signal(\"Battery\");\n\
1815             let n = signal(82);\n\
1816             </script>",
1817        )
1818        .unwrap();
1819
1820        let doc = Document::load(dir.join("app.rux")).expect("load app");
1821        assert!(find_text(&doc.root, "Battery"), "component label prop rendered");
1822        assert!(find_text(&doc.root, "82"), "component value prop rendered");
1823
1824        let _ = fs::remove_dir_all(&dir);
1825    }
1826
1827    /// An included sheet styles the document, and the document's own `<style>`
1828    /// wins a tie. That order is the whole point: you include a palette in
1829    /// order to override part of it, and needing `!important` to do so would
1830    /// mean the include had been bolted on top rather than cascaded under.
1831    #[test]
1832    fn included_stylesheets_cascade_under_the_document() {
1833        use std::fs;
1834        let dir = std::env::temp_dir().join(format!("rux_css_{}", std::process::id()));
1835        fs::create_dir_all(&dir).unwrap();
1836        fs::write(
1837            dir.join("theme.css"),
1838            ".card { background: #ff0000; } .plain { background: #0000ff; }",
1839        )
1840        .unwrap();
1841        fs::write(
1842            dir.join("app.rux"),
1843            "<template><screen><view class=\"card\" /><view class=\"plain\" /></screen></template>\n\
1844             <style src=\"theme.css\">\n  .card { background: #00ff00; }\n</style>",
1845        )
1846        .unwrap();
1847
1848        let doc = Document::load(dir.join("app.rux")).expect("load app");
1849        let bg = |i: usize| doc.root.children[i].style.background.clone();
1850        assert!(
1851            matches!(bg(0), Some(rux_layout::Background::Color(c)) if c.g == 1.0),
1852            "same specificity, so the document's own rule wins on source order"
1853        );
1854        assert!(
1855            matches!(bg(1), Some(rux_layout::Background::Color(c)) if c.b == 1.0),
1856            "and what the document says nothing about still comes from the include"
1857        );
1858
1859        let _ = fs::remove_dir_all(&dir);
1860    }
1861
1862    /// A stylesheet that is not there fails the load, the same way a missing
1863    /// component does. A document that renders unstyled reads as a layout bug,
1864    /// which is a much longer walk back to the typo in the path.
1865    #[test]
1866    fn a_missing_stylesheet_fails_the_load() {
1867        use std::fs;
1868        let dir = std::env::temp_dir().join(format!("rux_css_missing_{}", std::process::id()));
1869        fs::create_dir_all(&dir).unwrap();
1870        fs::write(
1871            dir.join("app.rux"),
1872            "<template><screen /></template>\n<style src=\"nope.css\">.a{color:red}</style>",
1873        )
1874        .unwrap();
1875
1876        let Err(err) = Document::load(dir.join("app.rux")) else {
1877            panic!("a document naming a stylesheet that is not there must not load");
1878        };
1879        assert!(err.contains("nope.css"), "names the file that is missing: {err}");
1880
1881        let _ = fs::remove_dir_all(&dir);
1882    }
1883
1884    /// From source there is no file, so there is nothing for the path to be
1885    /// relative to. The document still renders; it just says what it lost.
1886    #[test]
1887    fn an_include_from_source_warns_instead_of_failing() {
1888        let _ = take_warnings(); // the sinks are global; start from a known state
1889        let doc = Document::from_source(
1890            "<template><screen /></template>\n<style src=\"theme.css\">.a{color:red}</style>",
1891        )
1892        .expect("renders anyway");
1893        assert!(
1894            doc.diagnostics.warnings.iter().any(|w| w.message.contains("theme.css")),
1895            "the warning names the sheet that was ignored: {:?}",
1896            doc.diagnostics.warnings
1897        );
1898    }
1899
1900    /// `<image src>` is relative to the .rux file, not the working directory,
1901    /// and the intrinsic size comes from the file itself so a sizeless image
1902    /// lays out at its natural dimensions.
1903    #[test]
1904    fn resolves_image_src_and_intrinsic_size() {
1905        use std::fs;
1906        let dir = std::env::temp_dir().join(format!("rux_img_{}", std::process::id()));
1907        fs::create_dir_all(dir.join("assets")).unwrap();
1908
1909        // A 2x1 PNG, written by the same decoder the painter uses.
1910        let png = dir.join("assets/dot.png");
1911        image::RgbaImage::from_pixel(2, 1, image::Rgba([255, 0, 0, 255]))
1912            .save(&png)
1913            .unwrap();
1914        fs::write(
1915            dir.join("app.rux"),
1916            r#"<template><screen><image src="assets/dot.png" /></screen></template>"#,
1917        )
1918        .unwrap();
1919
1920        let doc = Document::load(dir.join("app.rux")).expect("load app");
1921        let img = doc.root.children[0].image.as_ref().expect("image node");
1922        assert_eq!(img.intrinsic, (2.0, 1.0));
1923        assert_eq!(Path::new(&img.src), png, "src resolved against the .rux dir");
1924
1925        let _ = fs::remove_dir_all(&dir);
1926    }
1927
1928    fn caret_of(node: &LayoutNode, model: &str) -> Option<usize> {
1929        if node.model.as_deref() == Some(model) {
1930            return node.children.first()?.text.as_ref()?.caret;
1931        }
1932        node.children.iter().find_map(|c| caret_of(c, model))
1933    }
1934
1935    /// Moving focus must clear the caret in the input you left. It used to only
1936    /// ever *set* one, so the old input kept painting a caret until some
1937    /// unrelated rebuild happened to wipe it.
1938    #[test]
1939    fn focus_moves_the_caret_out_of_the_old_input() {
1940        let mut doc = Document::from_source(
1941            "<template><screen>             <input r-model=\"name\" /><input r-model=\"city\" />             </screen></template>
1942             <script>let name = signal(\"abc\"); let city = signal(\"xyz\");</script>",
1943        )
1944        .expect("load");
1945
1946        doc.set_focus(Some(Focus::at("name", 2)));
1947        assert_eq!(caret_of(&doc.root, "name"), Some(2));
1948        assert_eq!(caret_of(&doc.root, "city"), None);
1949
1950        // Focus the other field: the first one must lose its caret immediately,
1951        // with no rebuild in between.
1952        doc.set_focus(Some(Focus::at("city", 1)));
1953        assert_eq!(caret_of(&doc.root, "name"), None, "old input kept its caret");
1954        assert_eq!(caret_of(&doc.root, "city"), Some(1));
1955
1956        // Tapping outside clears both.
1957        doc.set_focus(None);
1958        assert_eq!(caret_of(&doc.root, "name"), None);
1959        assert_eq!(caret_of(&doc.root, "city"), None);
1960    }
1961
1962    fn selection_of(node: &LayoutNode, model: &str) -> Option<(usize, usize)> {
1963        if node.model.as_deref() == Some(model) {
1964            return node.children.first()?.text.as_ref()?.selection;
1965        }
1966        node.children.iter().find_map(|c| selection_of(c, model))
1967    }
1968
1969    fn preedit_of(node: &LayoutNode, model: &str) -> Option<(usize, usize)> {
1970        if node.model.as_deref() == Some(model) {
1971            return node.children.first()?.text.as_ref()?.preedit;
1972        }
1973        node.children.iter().find_map(|c| preedit_of(c, model))
1974    }
1975
1976    fn two_inputs() -> Document {
1977        Document::from_source(
1978            "<template><screen>             <input r-model=\"name\" /><input r-model=\"city\" />             </screen></template>
1979             <script>let name = signal(\"abc\"); let city = signal(\"xyz\");</script>",
1980        )
1981        .expect("load")
1982    }
1983
1984    /// The selection is the range between anchor and caret, either way round, and
1985    /// only the focused input has one.
1986    #[test]
1987    fn selection_paints_only_in_the_focused_input() {
1988        let mut doc = two_inputs();
1989
1990        doc.set_focus(Some(Focus { model: "name".into(), row: None, caret: 3, anchor: 1, preedit: None }));
1991        assert_eq!(selection_of(&doc.root, "name"), Some((1, 3)));
1992        assert_eq!(selection_of(&doc.root, "city"), None);
1993
1994        // Dragging leftwards puts the caret *before* the anchor; same range.
1995        doc.set_focus(Some(Focus { model: "name".into(), row: None, caret: 1, anchor: 3, preedit: None }));
1996        assert_eq!(selection_of(&doc.root, "name"), Some((1, 3)));
1997    }
1998
1999    /// The negative case, which is where the caret bug lived: moving focus must
2000    /// *clear* the old input's selection, not just set the new one's. A rebuild
2001    /// isn't required to notice.
2002    #[test]
2003    fn focus_moves_the_selection_out_of_the_old_input() {
2004        let mut doc = two_inputs();
2005
2006        doc.set_focus(Some(Focus { model: "name".into(), row: None, caret: 3, anchor: 0, preedit: None }));
2007        assert_eq!(selection_of(&doc.root, "name"), Some((0, 3)));
2008
2009        doc.set_focus(Some(Focus { model: "city".into(), row: None, caret: 2, anchor: 0, preedit: None }));
2010        assert_eq!(selection_of(&doc.root, "name"), None, "old input kept its selection");
2011        assert_eq!(selection_of(&doc.root, "city"), Some((0, 2)));
2012
2013        doc.set_focus(None);
2014        assert_eq!(selection_of(&doc.root, "name"), None);
2015        assert_eq!(selection_of(&doc.root, "city"), None);
2016    }
2017
2018    /// `r-key` stamps each row with what it stands for, and the stamp survives
2019    /// a reorder: after reversing the data, the row carrying key `b` is the one
2020    /// at the front. Nothing consumes this yet (see the note on
2021    /// `LayoutNode::key`), but it is the only thing in a document that says a
2022    /// row is the same row.
2023    #[test]
2024    fn a_key_identifies_a_row_across_a_reorder() {
2025        let mut doc = Document::from_source(
2026            "<template><screen>\
2027               <text r-for=\"row in rows\" r-key=\"row.id\">{{ row.text }}</text>\
2028             </screen></template>
2029             <script>\
2030               let rows = signal([\
2031                 #{ id: \"a\", text: \"alpha\" },\
2032                 #{ id: \"b\", text: \"bravo\" }\
2033               ]);\
2034               </script>",
2035        )
2036        .expect("load");
2037        let keys = |d: &Document| -> Vec<Option<String>> {
2038            d.root.children.iter().map(|c| c.key.clone()).collect()
2039        };
2040        assert_eq!(keys(&doc), vec![Some("a".into()), Some("b".into())]);
2041
2042        assert!(
2043            doc.apply_handler(
2044                "rows = [#{ id: \"b\", text: \"bravo\" }, #{ id: \"a\", text: \"alpha\" }];"
2045            ),
2046            "the reorder changed a signal"
2047        );
2048        assert_eq!(
2049            keys(&doc),
2050            vec![Some("b".into()), Some("a".into())],
2051            "the keys moved with their rows"
2052        );
2053        assert!(find_text(&doc.root, "bravo"));
2054    }
2055
2056    /// Every row of a list is bound to the same `r-model` text, so a model on
2057    /// its own cannot say which row the caret is in. Before the row was part of
2058    /// focus, setting the caret in one row put a caret in *every* row of the
2059    /// list at once.
2060    fn keyed_inputs() -> Document {
2061        Document::from_source(
2062            "<template><screen>\
2063               <view r-for=\"row in rows\" r-key=\"row.id\">\
2064                 <input r-model=\"draft\" />\
2065               </view>\
2066             </screen></template>
2067             <script>\
2068               let rows = signal([#{ id: \"a\" }, #{ id: \"b\" }]);\
2069               let draft = signal(\"hello\");\
2070             </script>",
2071        )
2072        .expect("load")
2073    }
2074
2075    /// The caret inside a keyed row belongs to that row alone.
2076    #[test]
2077    fn only_the_focused_row_gets_a_caret() {
2078        let mut doc = keyed_inputs();
2079        doc.set_focus(Some(Focus::at_row("draft", Some("b".into()), 2)));
2080
2081        let carets: Vec<Option<usize>> = doc
2082            .root
2083            .children
2084            .iter()
2085            .map(|row| caret_of(row, "draft"))
2086            .collect();
2087        assert_eq!(
2088            carets,
2089            vec![None, Some(2)],
2090            "the caret is in row b only, not in every row bound to `draft`"
2091        );
2092    }
2093
2094    /// And it stays with its row when the list is reordered, rather than with
2095    /// the position the row used to hold. Nothing is remapped to achieve this:
2096    /// the identity *is* the row, so the caret lands wherever that row went.
2097    #[test]
2098    fn the_caret_follows_its_row_across_a_reorder() {
2099        let mut doc = keyed_inputs();
2100        doc.set_focus(Some(Focus::at_row("draft", Some("b".into()), 2)));
2101
2102        assert!(
2103            doc.apply_handler("rows = [#{ id: \"b\" }, #{ id: \"a\" }];"),
2104            "the reorder changed a signal"
2105        );
2106        assert_eq!(doc.root.children[0].key.as_deref(), Some("b"), "row b is first now");
2107
2108        let carets: Vec<Option<usize>> = doc
2109            .root
2110            .children
2111            .iter()
2112            .map(|row| caret_of(row, "draft"))
2113            .collect();
2114        assert_eq!(
2115            carets,
2116            vec![Some(2), None],
2117            "the caret moved with row b instead of staying in the first slot"
2118        );
2119    }
2120
2121    /// A row's field can be read and written, which needs that row's loop
2122    /// variable in scope: the `r-model` is recorded as written, so
2123    /// `rows[row.at.to_int()].note` is not an expression without `row`. Reading
2124    /// it raw returned "" and warned `Variable not found`, and writing it set a
2125    /// scope variable *named* `rows[row.at.to_int()].note`, leaving the real
2126    /// target untouched. Typing into a row's field did nothing at all.
2127    #[test]
2128    fn a_rows_field_reads_and_writes_in_its_own_scope() {
2129        let mut doc = Document::from_source(
2130            "<template><screen>\
2131               <input r-for=\"row in rows\" r-key=\"row.id\" r-model=\"rows[row.at.to_int()].note\" />\
2132             </screen></template>
2133             <script>\
2134               let rows = signal([\
2135                 #{ id: \"a\", at: 0, note: \"alpha\" },\
2136                 #{ id: \"b\", at: 1, note: \"bravo\" }\
2137               ]);\
2138             </script>",
2139        )
2140        .expect("load");
2141        let model = "rows[row.at.to_int()].note";
2142
2143        assert_eq!(doc.value_in(model, Some("a")), "alpha");
2144        assert_eq!(doc.value_in(model, Some("b")), "bravo", "each row reads its own value");
2145
2146        doc.apply_edit_in(model, Some("b"), "bravo!");
2147        assert_eq!(doc.value_in(model, Some("b")), "bravo!", "the edit landed");
2148        assert_eq!(doc.value_in(model, Some("a")), "alpha", "and only in that row");
2149    }
2150
2151    /// An `r-model` that is a path rather than a bare signal is written through
2152    /// too. This never worked, in or out of a list.
2153    #[test]
2154    fn a_path_model_is_assigned_not_shadowed() {
2155        let mut doc = Document::from_source(
2156            "<template><screen><input r-model=\"user.name\" /></screen></template>
2157             <script>let user = signal(#{ name: \"ada\" });</script>",
2158        )
2159        .expect("load");
2160
2161        doc.apply_edit("user.name", "grace");
2162        assert_eq!(doc.value_in("user.name", None), "grace");
2163    }
2164
2165    /// A value containing quotes and backslashes survives being written, since
2166    /// the write runs as script and the text is a person's typing.
2167    #[test]
2168    fn an_awkward_value_survives_the_round_trip() {
2169        let mut doc = two_inputs();
2170        let awkward = "she said \"hi\" \\ then left";
2171        doc.apply_edit("name", awkward);
2172        assert_eq!(doc.value_in("name", None), awkward);
2173    }
2174
2175    /// Write a component with a `<slot />` and a document that fills it, in a
2176    /// temp dir, then load it. Returns the document.
2177    fn with_component(component: &str, app: &str) -> Document {
2178        use std::fs;
2179        let dir = std::env::temp_dir().join(format!(
2180            "rux_slot_{}_{}",
2181            std::process::id(),
2182            std::time::SystemTime::now()
2183                .duration_since(std::time::UNIX_EPOCH)
2184                .unwrap()
2185                .as_nanos()
2186        ));
2187        fs::create_dir_all(dir.join("components")).unwrap();
2188        fs::write(dir.join("components/card.rux"), component).unwrap();
2189        fs::write(dir.join("app.rux"), app).unwrap();
2190        let doc = Document::load(dir.join("app.rux")).expect("load");
2191        let _ = fs::remove_dir_all(&dir);
2192        doc
2193    }
2194
2195    /// The point of scoping: two instances of one component are two separate
2196    /// sets of state. Their scripts used to be merged into the single shared
2197    /// script, so both `<counter>` elements counted the same number.
2198    #[test]
2199    fn two_instances_keep_their_own_state() {
2200        let doc = with_component(
2201            "<template><view class=\"card\">\
2202               <text>{{ count }}</text>\
2203               <view @tap=\"count = count + 1\"><text>add</text></view>\
2204             </view></template>\n\
2205             <script>\nlet count = signal(0);\n</script>",
2206            "<template><screen><card /><card /></screen></template>\n\
2207             <script>\nuse components::card;\n</script>",
2208        );
2209        // Two instances, two keys, and neither is a document signal.
2210        assert_eq!(doc.instances.len(), 2, "one entry per instance: {:?}", doc.instances);
2211        assert!(
2212            doc.instances.values().all(|i| i.state.iter().any(|(n, _)| n == "count")),
2213            "each holds its own `count`: {:?}",
2214            doc.instances
2215        );
2216    }
2217
2218    /// Tapping inside one instance moves that instance's state and no other's.
2219    #[test]
2220    fn a_handler_moves_only_its_own_instance() {
2221        let mut doc = with_component(
2222            "<template><view>\
2223               <text>{{ label }}:{{ count }}</text>\
2224               <view @tap=\"count = count + 1\"><text>add</text></view>\
2225             </view></template>\n\
2226             <script>\nlet count = signal(0);\n</script>",
2227            "<template><screen>\
2228               <card :label=\"&quot;a&quot;\" /><card :label=\"&quot;b&quot;\" />\
2229             </screen></template>\n\
2230             <script>\nuse components::card;\n</script>",
2231        );
2232        assert!(find_text(&doc.root, "a:0"), "{:?}", text_of(&doc.root));
2233        assert!(find_text(&doc.root, "b:0"), "{:?}", text_of(&doc.root));
2234
2235        // The second card's handler, found the way the shell finds it: by the
2236        // instance recorded on the node it was tapped on.
2237        let second = doc.root.children[1].clone();
2238        let button = second.children.iter().find(|c| c.on_tap.is_some()).expect("a tappable box");
2239        let (src, instance) = (button.on_tap.clone().unwrap(), button.instance.clone());
2240        assert!(instance.is_some(), "the node knows which instance it is in");
2241
2242        assert!(doc.apply_handler_in(&src, instance.as_deref()), "the tap changed state");
2243        assert!(find_text(&doc.root, "b:1"), "the tapped card counted: {:?}", text_of(&doc.root));
2244        assert!(
2245            find_text(&doc.root, "a:0"),
2246            "and the other one did not: {:?}",
2247            text_of(&doc.root)
2248        );
2249    }
2250
2251    /// Tap the one tappable box in a subtree, the way the shell would: with the
2252    /// instance recorded on the node it was tapped on.
2253    fn tap(doc: &mut Document, node: &LayoutNode) -> bool {
2254        fn find(node: &LayoutNode) -> Option<&LayoutNode> {
2255            if node.on_tap.is_some() {
2256                return Some(node);
2257            }
2258            node.children.iter().find_map(find)
2259        }
2260        let button = find(node).expect("a tappable box").clone();
2261        doc.apply_handler_in(&button.on_tap.clone().unwrap(), button.instance.as_deref())
2262    }
2263
2264    /// The other half of props: something comes back out. Without this a
2265    /// component can only ever be told things, so every piece of state a caller
2266    /// cares about has to be hoisted out of the component that owns it.
2267    #[test]
2268    fn an_emitted_event_runs_the_callers_handler() {
2269        let mut doc = with_component(
2270            "<template><view>\
2271               <view @tap=\"emit(&quot;bumped&quot;)\"><text>add</text></view>\
2272             </view></template>",
2273            "<template><screen>\
2274               <text>total {{ total }}</text>\
2275               <card @bumped=\"total = total + 1\" />\
2276             </screen></template>\n\
2277             <script>\nuse components::card;\nlet total = signal(0);\n</script>",
2278        );
2279        let card = doc.root.children[1].clone();
2280        assert!(tap(&mut doc, &card), "the tap reached the caller");
2281        assert!(find_text(&doc.root, "total 1"), "{:?}", text_of(&doc.root));
2282        let card = doc.root.children[1].clone();
2283        assert!(tap(&mut doc, &card), "and again");
2284        assert!(find_text(&doc.root, "total 2"), "{:?}", text_of(&doc.root));
2285    }
2286
2287    /// A payload arrives as `event`, so a component can say *what* happened
2288    /// rather than only that something did.
2289    #[test]
2290    fn an_event_carries_its_payload() {
2291        let mut doc = with_component(
2292            "<template><view>\
2293               <view @tap=\"emit(&quot;picked&quot;, label)\"><text>pick</text></view>\
2294             </view></template>",
2295            "<template><screen>\
2296               <text>chose {{ chosen }}</text>\
2297               <card :label=\"&quot;blue&quot;\" @picked=\"chosen = event\" />\
2298             </screen></template>\n\
2299             <script>\nuse components::card;\nlet chosen = signal(\"nothing\");\n</script>",
2300        );
2301        let card = doc.root.children[1].clone();
2302        assert!(tap(&mut doc, &card), "the tap reached the caller");
2303        assert!(find_text(&doc.root, "chose blue"), "{:?}", text_of(&doc.root));
2304    }
2305
2306    /// The listener body is the *caller's* code and must run in the caller's
2307    /// scope. Run in the instance's, it would write to a variable of the same
2308    /// name inside the component, or to nothing at all, and look like it worked.
2309    #[test]
2310    fn a_listener_runs_in_the_callers_scope() {
2311        let mut doc = with_component(
2312            "<template><view>\
2313               <text>inner {{ total }}</text>\
2314               <view @tap=\"emit(&quot;bumped&quot;)\"><text>add</text></view>\
2315             </view></template>\n\
2316             <script>\nlet total = signal(100);\n</script>",
2317            "<template><screen>\
2318               <text>outer {{ total }}</text>\
2319               <card @bumped=\"total = total + 1\" />\
2320             </screen></template>\n\
2321             <script>\nuse components::card;\nlet total = signal(0);\n</script>",
2322        );
2323        let card = doc.root.children[1].clone();
2324        assert!(tap(&mut doc, &card));
2325        assert!(find_text(&doc.root, "outer 1"), "the caller's own: {:?}", text_of(&doc.root));
2326        assert!(
2327            find_text(&doc.root, "inner 100"),
2328            "the component's like-named state is untouched: {:?}",
2329            text_of(&doc.root)
2330        );
2331    }
2332
2333    /// A component offers events; a caller takes the ones it wants. Emitting
2334    /// one nobody listens to is ordinary, and must not fail or warn.
2335    #[test]
2336    fn an_event_with_no_listener_is_ignored() {
2337        let _ = take_warnings();
2338        let mut doc = with_component(
2339            "<template><view>\
2340               <view @tap=\"count = count + 1; emit(&quot;bumped&quot;)\"><text>add</text></view>\
2341               <text>{{ count }}</text>\
2342             </view></template>\n\
2343             <script>\nlet count = signal(0);\n</script>",
2344            "<template><screen><card /></screen></template>\n\
2345             <script>\nuse components::card;\n</script>",
2346        );
2347        let card = doc.root.children[0].clone();
2348        assert!(tap(&mut doc, &card), "the handler's own work still happened");
2349        assert!(find_text(&doc.root, "1"), "{:?}", text_of(&doc.root));
2350        assert!(take_warnings().is_empty(), "an unheard event is not a mistake");
2351    }
2352
2353    /// `emit` outside a component has nobody to tell. Silence there would be a
2354    /// handler that plainly expected something to happen and did nothing.
2355    #[test]
2356    fn emit_outside_a_component_warns() {
2357        let _ = take_warnings();
2358        let mut doc = Document::from_source(
2359            "<template><screen><view @tap=\"emit(&quot;bumped&quot;)\"><text>go</text></view></screen></template>",
2360        )
2361        .expect("loads");
2362        let button = doc.root.children[0].clone();
2363        doc.apply_handler_in(&button.on_tap.clone().unwrap(), None);
2364        let warnings = take_warnings();
2365        assert!(
2366            warnings.iter().any(|w| w.message.contains("no caller")),
2367            "the warning says why nothing happened: {warnings:?}"
2368        );
2369    }
2370
2371    /// Write a document with a router over three views, in a temp dir.
2372    ///
2373    /// `home` and `settings` are plain; `user` reads an `:id` captured from the
2374    /// path and counts taps, so a test can tell whether a view's state survived
2375    /// a navigation.
2376    fn with_router(app: &str) -> Document {
2377        use std::fs;
2378        let dir = std::env::temp_dir().join(format!(
2379            "rux_router_{}_{}",
2380            std::process::id(),
2381            std::time::SystemTime::now()
2382                .duration_since(std::time::UNIX_EPOCH)
2383                .unwrap()
2384                .as_nanos()
2385        ));
2386        fs::create_dir_all(dir.join("components")).unwrap();
2387        fs::write(dir.join("components/home.rux"), "<template><text>the home page</text></template>")
2388            .unwrap();
2389        fs::write(
2390            dir.join("components/settings.rux"),
2391            "<template><text>settings live here</text></template>",
2392        )
2393        .unwrap();
2394        fs::write(
2395            dir.join("components/user.rux"),
2396            "<template><view>\
2397               <text>user {{ id }} seen {{ seen }}</text>\
2398               <view @tap=\"seen = seen + 1\"><text>look</text></view>\
2399             </view></template>\n\
2400             <script>\nlet seen = signal(0);\n</script>",
2401        )
2402        .unwrap();
2403        fs::write(
2404            dir.join("components/missing.rux"),
2405            "<template><text>no such page</text></template>",
2406        )
2407        .unwrap();
2408        fs::write(dir.join("app.rux"), app).unwrap();
2409        let doc = Document::load(dir.join("app.rux")).expect("load");
2410        let _ = fs::remove_dir_all(&dir);
2411        doc
2412    }
2413
2414    /// The standard three-route app, used by most of the router tests.
2415    fn router_app() -> Document {
2416        with_router(
2417            "<template><screen>\
2418               <text to=\"/\">home</text>\
2419               <text to=\"/settings\">settings</text>\
2420               <router>\
2421                 <route path=\"/\" view=\"home\" />\
2422                 <route path=\"/settings\" view=\"settings\" />\
2423                 <route path=\"/user/:id\" view=\"user\" />\
2424                 <route fallback view=\"missing\" />\
2425               </router>\
2426             </screen></template>\n\
2427             <script>\nuse components::home;\nuse components::settings;\n\
2428             use components::user;\nuse components::missing;\n</script>",
2429        )
2430    }
2431
2432    /// The whole point: one path renders one view, and only that one.
2433    #[test]
2434    fn the_router_renders_the_matching_route() {
2435        let mut doc = router_app();
2436        assert!(find_text(&doc.root, "the home page"), "{:?}", text_of(&doc.root));
2437        assert!(!find_text(&doc.root, "settings live here"), "and not the others");
2438
2439        assert!(doc.navigate("/settings"), "navigating changed something");
2440        assert!(find_text(&doc.root, "settings live here"), "{:?}", text_of(&doc.root));
2441        assert!(!find_text(&doc.root, "the home page"), "the old view is gone");
2442    }
2443
2444    /// A `:segment` is captured and handed to the view as a prop, which is what
2445    /// makes a list-detail app expressible at all.
2446    #[test]
2447    fn a_path_parameter_reaches_the_view() {
2448        let mut doc = router_app();
2449        doc.navigate("/user/7");
2450        assert!(find_text(&doc.root, "user 7 seen 0"), "{:?}", text_of(&doc.root));
2451        doc.navigate("/user/12");
2452        assert!(find_text(&doc.root, "user 12 seen 0"), "{:?}", text_of(&doc.root));
2453    }
2454
2455    /// A path nothing matches renders the fallback, not a blank screen.
2456    #[test]
2457    fn an_unmatched_path_falls_back() {
2458        let mut doc = router_app();
2459        doc.navigate("/nowhere");
2460        assert!(find_text(&doc.root, "no such page"), "{:?}", text_of(&doc.root));
2461    }
2462
2463    /// A deep link opens the app on the page it names, not on `/`. Without
2464    /// this a shared URL always landed on the home page, whatever it said.
2465    #[test]
2466    fn a_document_can_start_somewhere_other_than_root() {
2467        let mut doc = router_app();
2468        assert!(doc.start_at("/user/7"), "the document moved off the home page");
2469        assert_eq!(doc.route(), "/user/7");
2470        assert!(find_text(&doc.root, "user 7 seen 0"), "{:?}", text_of(&doc.root));
2471    }
2472
2473    /// The arrival page is the *first* page. Seeding `/` underneath it would
2474    /// invent a visit that never happened and give Back somewhere to go.
2475    #[test]
2476    fn starting_at_a_path_leaves_nothing_behind_it() {
2477        let mut doc = router_app();
2478        doc.start_at("/settings");
2479        assert_eq!(doc.history_position(), (0, 1));
2480        assert!(!doc.back(), "there is nowhere back to");
2481        assert_eq!(doc.route(), "/settings");
2482    }
2483
2484    /// An empty path matches no route at all, and a browser can report one from
2485    /// a `file://` URL. It means the root.
2486    #[test]
2487    fn starting_at_nothing_starts_at_the_root() {
2488        let mut doc = router_app();
2489        doc.start_at("");
2490        assert_eq!(doc.route(), ROOT_PATH);
2491        assert!(find_text(&doc.root, "the home page"), "{:?}", text_of(&doc.root));
2492    }
2493
2494    /// What a host history mirrors with: it stamps an index on each entry and
2495    /// hands the same index back, because the browser reports where Back landed
2496    /// rather than which way it went, and can move several entries at once.
2497    #[test]
2498    fn the_history_can_be_walked_by_index() {
2499        let mut doc = router_app();
2500        doc.navigate("/settings");
2501        doc.navigate("/user/3");
2502        assert_eq!(doc.history_position(), (2, 3));
2503
2504        assert!(doc.go_to(0), "jumped two entries at once, as a long-press Back does");
2505        assert_eq!(doc.route(), ROOT_PATH);
2506        assert_eq!(doc.history_position(), (0, 3), "jumping is not a visit: nothing was dropped");
2507
2508        assert!(doc.go_to(2), "and forward again to where it had been");
2509        assert_eq!(doc.route(), "/user/3");
2510    }
2511
2512    /// An index that is not there means the two histories have drifted. Refused
2513    /// rather than clamped: landing somewhere near would hide the drift.
2514    #[test]
2515    fn an_out_of_range_history_index_is_refused() {
2516        let mut doc = router_app();
2517        doc.navigate("/settings");
2518        assert!(!doc.go_to(9), "there is no ninth entry");
2519        assert!(!doc.go_to(1), "already there");
2520        assert_eq!(doc.route(), "/settings");
2521    }
2522
2523    /// `replace` goes somewhere instead of here, which is what a redirect
2524    /// needs. Done with `navigate`, the redirecting page stays in the history,
2525    /// so Back lands on it and is redirected forward again and the user is
2526    /// stuck. There is no way to work around that in userland.
2527    #[test]
2528    fn replace_leaves_no_entry_to_go_back_to() {
2529        let mut doc = router_app();
2530        doc.navigate("/settings");
2531        assert!(doc.replace("/user/1"), "the document moved");
2532        assert_eq!(doc.route(), "/user/1");
2533        assert_eq!(doc.history_position(), (1, 2), "it took the entry, it did not add one");
2534
2535        assert!(doc.back(), "back goes past the page that was replaced");
2536        assert_eq!(doc.route(), ROOT_PATH, "and lands on the one before it");
2537    }
2538
2539    /// A redirect written the way it actually gets written.
2540    #[test]
2541    fn a_handler_can_replace_the_current_page() {
2542        let mut doc = with_router(
2543            "<template><screen>\
2544               <view @tap=\"replace(&quot;/settings&quot;)\"><text>go</text></view>\
2545               <router>\
2546                 <route path=\"/\" view=\"home\" />\
2547                 <route path=\"/settings\" view=\"settings\" />\
2548                 <route fallback view=\"missing\" />\
2549               </router>\
2550             </screen></template>\n\
2551             <script>\nuse components::home;\nuse components::settings;\n\
2552             use components::missing;\n</script>",
2553        );
2554        let button = doc.root.children[0].clone();
2555        assert!(doc.apply_handler(&button.on_tap.clone().expect("a tap")));
2556        assert_eq!(doc.route(), "/settings");
2557        assert_eq!(doc.history_position(), (0, 1), "nothing was added to go back to");
2558    }
2559
2560    /// The matched view already gets its parameters as props. Anything *around*
2561    /// the router did not: a title bar is in the document's own layout, so the
2562    /// `id` in `/user/7` was invisible to it. That is what `params` is for.
2563    #[test]
2564    fn params_are_readable_outside_the_matched_view() {
2565        let mut doc = with_router(
2566            "<template><screen>\
2567               <text>looking at {{ params.id }}</text>\
2568               <router>\
2569                 <route path=\"/\" view=\"home\" />\
2570                 <route path=\"/user/:id\" view=\"user\" />\
2571                 <route fallback view=\"missing\" />\
2572               </router>\
2573             </screen></template>\n\
2574             <script>\nuse components::home;\nuse components::user;\n\
2575             use components::missing;\n</script>",
2576        );
2577        doc.navigate("/user/7");
2578        assert!(find_text(&doc.root, "looking at 7"), "{:?}", text_of(&doc.root));
2579        // And it empties again when the next route captures nothing, rather
2580        // than keeping the last page's answer.
2581        doc.navigate("/");
2582        assert!(find_text(&doc.root, "looking at"), "{:?}", text_of(&doc.root));
2583        assert!(!find_text(&doc.root, "looking at 7"), "{:?}", text_of(&doc.root));
2584    }
2585
2586    /// What a Back button binds to in order to grey itself out. Signals rather
2587    /// than functions, because disabling a button is a `:class`, and a `:class`
2588    /// reads signals.
2589    #[test]
2590    fn the_history_says_whether_it_can_be_walked() {
2591        let mut doc = router_app();
2592        assert_eq!(doc.value_in("can_go_back", None), "false", "nothing behind the first page");
2593        assert_eq!(doc.value_in("can_go_forward", None), "false");
2594
2595        doc.navigate("/settings");
2596        assert_eq!(doc.value_in("can_go_back", None), "true");
2597        assert_eq!(doc.value_in("can_go_forward", None), "false", "nothing ahead of the last page");
2598
2599        doc.back();
2600        assert_eq!(doc.value_in("can_go_back", None), "false");
2601        assert_eq!(doc.value_in("can_go_forward", None), "true", "the page just left is ahead");
2602
2603        // Somewhere new drops what was ahead, so forward closes again.
2604        doc.navigate("/user/1");
2605        assert_eq!(doc.value_in("can_go_forward", None), "false");
2606    }
2607
2608    /// A query is an argument to a page, not a different page. So it does not
2609    /// take part in matching, and `route` does not carry it: every
2610    /// `route == "/search"` already written keeps meaning what it says.
2611    #[test]
2612    fn a_query_is_readable_and_does_not_change_the_page() {
2613        let mut doc = with_router(
2614            "<template><screen>\
2615               <text>looking for {{ query.q }}</text>\
2616               <router>\
2617                 <route path=\"/\" view=\"home\" />\
2618                 <route path=\"/settings\" view=\"settings\" />\
2619                 <route fallback view=\"missing\" />\
2620               </router>\
2621             </screen></template>\n\
2622             <script>\nuse components::home;\nuse components::settings;\n\
2623             use components::missing;\n</script>",
2624        );
2625        doc.navigate("/settings?q=dark+mode&page=2");
2626        assert_eq!(doc.route(), "/settings", "the path alone");
2627        assert_eq!(doc.location(), "/settings?q=dark+mode&page=2", "the whole address");
2628        assert!(find_text(&doc.root, "settings live here"), "it matched: {:?}", text_of(&doc.root));
2629        assert!(find_text(&doc.root, "looking for dark mode"), "{:?}", text_of(&doc.root));
2630
2631        // And the history holds the whole address, so Back restores what was
2632        // being searched for rather than a bare page.
2633        doc.navigate("/");
2634        assert!(!find_text(&doc.root, "looking for dark mode"), "{:?}", text_of(&doc.root));
2635        doc.back();
2636        assert_eq!(doc.location(), "/settings?q=dark+mode&page=2");
2637        assert!(find_text(&doc.root, "looking for dark mode"), "{:?}", text_of(&doc.root));
2638    }
2639
2640    /// A path is written into every link that leads to it, so a URL scheme that
2641    /// can never be changed afterwards is not much of a scheme. `path_for`
2642    /// returns a string, so it composes with `navigate`, `replace`, `to` and
2643    /// `:to` rather than needing a second form of each.
2644    #[test]
2645    fn a_named_route_builds_its_own_path() {
2646        let mut doc = with_router(
2647            "<template><screen>\
2648               <view @tap=\"navigate(path_for(&quot;who&quot;, #{ id: &quot;7&quot; }))\">\
2649                 <text>go</text>\
2650               </view>\
2651               <router>\
2652                 <route path=\"/\" view=\"home\" />\
2653                 <route name=\"who\" path=\"/user/:id\" view=\"user\" />\
2654                 <route fallback view=\"missing\" />\
2655               </router>\
2656             </screen></template>\n\
2657             <script>\nuse components::home;\nuse components::user;\n\
2658             use components::missing;\n</script>",
2659        );
2660        let button = doc.root.children[0].clone();
2661        assert!(doc.apply_handler(&button.on_tap.clone().expect("a tap")));
2662        assert_eq!(doc.route(), "/user/7");
2663        assert!(find_text(&doc.root, "user 7 seen 0"), "{:?}", text_of(&doc.root));
2664    }
2665
2666    /// Whatever the pattern does not take becomes a query, which is what makes
2667    /// `path_for` usable for a route with no path parameters at all.
2668    #[test]
2669    fn path_for_puts_what_is_left_over_in_the_query() {
2670        let mut doc = with_router(
2671            "<template><screen>\
2672               <view @tap=\"navigate(path_for(&quot;who&quot;, \
2673                 #{ id: &quot;7&quot;, tab: &quot;posts&quot; }))\">\
2674                 <text>go</text>\
2675               </view>\
2676               <text>tab {{ query.tab }}</text>\
2677               <router>\
2678                 <route path=\"/\" view=\"home\" />\
2679                 <route name=\"who\" path=\"/user/:id\" view=\"user\" />\
2680                 <route fallback view=\"missing\" />\
2681               </router>\
2682             </screen></template>\n\
2683             <script>\nuse components::home;\nuse components::user;\n\
2684             use components::missing;\n</script>",
2685        );
2686        let button = doc.root.children[0].clone();
2687        doc.apply_handler(&button.on_tap.clone().expect("a tap"));
2688        assert_eq!(doc.location(), "/user/7?tab=posts");
2689        assert_eq!(doc.route(), "/user/7", "the leftover did not become a path segment");
2690        assert!(find_text(&doc.root, "tab posts"), "{:?}", text_of(&doc.root));
2691    }
2692
2693    /// A value carrying a `/` or a `&` must survive being put in a URL and read
2694    /// back, or it would silently become extra path segments or extra
2695    /// parameters.
2696    #[test]
2697    fn path_for_escapes_what_it_is_given() {
2698        let mut doc = with_router(
2699            "<template><screen>\
2700               <view @tap=\"navigate(path_for(&quot;who&quot;, \
2701                 #{ id: &quot;a/b&quot;, q: &quot;x&amp;y z&quot; }))\">\
2702                 <text>go</text>\
2703               </view>\
2704               <text>q is {{ query.q }}</text>\
2705               <router>\
2706                 <route path=\"/\" view=\"home\" />\
2707                 <route name=\"who\" path=\"/user/:id\" view=\"user\" />\
2708                 <route fallback view=\"missing\" />\
2709               </router>\
2710             </screen></template>\n\
2711             <script>\nuse components::home;\nuse components::user;\n\
2712             use components::missing;\n</script>",
2713        );
2714        let button = doc.root.children[0].clone();
2715        doc.apply_handler(&button.on_tap.clone().expect("a tap"));
2716        assert_eq!(doc.location(), "/user/a%2Fb?q=x%26y%20z");
2717        assert!(find_text(&doc.root, "user a/b"), "the id came back whole: {:?}", text_of(&doc.root));
2718        assert!(find_text(&doc.root, "q is x&y z"), "and so did the query: {:?}", text_of(&doc.root));
2719    }
2720
2721    fn at_y(y: f32) -> Vec<Offset> {
2722        vec![Offset { x: 0.0, y }]
2723    }
2724
2725    /// The flag means *remember*, not *always restore*. Which of the two you
2726    /// get is decided by how you arrived: a page you open starts at the top, a
2727    /// page you go back to comes back where you left it. Always restoring would
2728    /// drop you into the middle of a page you had just opened for the first
2729    /// time, which reads as a bug.
2730    #[test]
2731    fn back_returns_to_where_the_page_was_left() {
2732        let mut doc = router_app();
2733        doc.record_scroll(&at_y(120.0));
2734
2735        doc.navigate("/settings");
2736        assert_eq!(doc.take_scroll(), Some(Vec::new()), "a page being opened starts at the top");
2737        doc.record_scroll(&at_y(40.0));
2738
2739        doc.back();
2740        assert_eq!(doc.take_scroll(), Some(at_y(120.0)), "and one returned to does not");
2741        doc.forward();
2742        assert_eq!(doc.take_scroll(), Some(at_y(40.0)), "forward is a return too");
2743    }
2744
2745    /// Nothing to obey when nothing navigated, or every frame would drag the
2746    /// page back to wherever the last navigation put it.
2747    #[test]
2748    fn scrolling_alone_asks_for_nothing() {
2749        let mut doc = router_app();
2750        doc.navigate("/settings");
2751        assert!(doc.take_scroll().is_some(), "the navigation spoke");
2752        doc.record_scroll(&at_y(80.0));
2753        assert_eq!(doc.take_scroll(), None, "and then stopped speaking");
2754    }
2755
2756    /// Turned off, every arrival is the top, including a return.
2757    #[test]
2758    fn restore_scroll_false_always_starts_at_the_top() {
2759        let mut doc = with_router(
2760            "<template><screen>\
2761               <router restore-scroll=\"false\">\
2762                 <route path=\"/\" view=\"home\" />\
2763                 <route path=\"/settings\" view=\"settings\" />\
2764                 <route fallback view=\"missing\" />\
2765               </router>\
2766             </screen></template>\n\
2767             <script>\nuse components::home;\nuse components::settings;\n\
2768             use components::missing;\n</script>",
2769        );
2770        doc.record_scroll(&at_y(150.0));
2771        doc.navigate("/settings");
2772        assert_eq!(doc.take_scroll(), Some(Vec::new()));
2773        doc.back();
2774        assert_eq!(doc.take_scroll(), Some(Vec::new()), "a return is the top too, when off");
2775    }
2776
2777    /// A redirect is an arrival, not a return, so it lands at the top.
2778    #[test]
2779    fn a_replace_lands_at_the_top() {
2780        let mut doc = router_app();
2781        doc.record_scroll(&at_y(90.0));
2782        doc.replace("/settings");
2783        assert_eq!(doc.take_scroll(), Some(Vec::new()));
2784    }
2785
2786    /// A trailing slash is not a different path. Anyone typing one by hand
2787    /// produces both spellings and means the same place.
2788    #[test]
2789    fn a_trailing_slash_is_the_same_path() {
2790        let mut doc = router_app();
2791        doc.navigate("/settings/");
2792        assert!(find_text(&doc.root, "settings live here"), "{:?}", text_of(&doc.root));
2793    }
2794
2795    /// `to="/path"` navigates when tapped: the spec's promise since v0.1.
2796    #[test]
2797    fn a_link_navigates_when_tapped() {
2798        let mut doc = router_app();
2799        let link = doc.root.children[1].clone();
2800        assert_eq!(link.access.role, rux_layout::AccessRole::Link, "announced as a link");
2801        assert!(doc.apply_handler(&link.on_tap.clone().expect("a link taps")));
2802        assert_eq!(doc.route(), "/settings");
2803        assert!(find_text(&doc.root, "settings live here"), "{:?}", text_of(&doc.root));
2804    }
2805
2806    /// Back and forward walk the same list, and forward is only available after
2807    /// going back.
2808    #[test]
2809    fn history_walks_both_ways() {
2810        let mut doc = router_app();
2811        doc.navigate("/settings");
2812        doc.navigate("/user/3");
2813        assert!(!doc.forward(), "nothing ahead of the newest entry");
2814
2815        assert!(doc.back(), "back to settings");
2816        assert_eq!(doc.route(), "/settings");
2817        assert!(doc.back(), "back to home");
2818        assert_eq!(doc.route(), "/");
2819        assert!(!doc.back(), "and no further");
2820
2821        assert!(doc.forward(), "forward again");
2822        assert_eq!(doc.route(), "/settings");
2823        assert!(find_text(&doc.root, "settings live here"), "{:?}", text_of(&doc.root));
2824    }
2825
2826    /// Going somewhere new after going back drops what was ahead, which is the
2827    /// behaviour a back button has everywhere else.
2828    #[test]
2829    fn a_new_path_after_going_back_drops_the_forward_entries() {
2830        let mut doc = router_app();
2831        doc.navigate("/settings");
2832        doc.back();
2833        doc.navigate("/user/1");
2834        assert!(!doc.forward(), "settings is no longer ahead: {:?}", doc.history);
2835        assert!(doc.back(), "but home is still behind");
2836        assert_eq!(doc.route(), "/");
2837    }
2838
2839    /// Navigating to where we already are is not a visit. Otherwise tapping the
2840    /// current link fills the history with repeats and Back does nothing.
2841    #[test]
2842    fn navigating_to_the_current_path_is_not_a_visit() {
2843        let mut doc = router_app();
2844        doc.navigate("/settings");
2845        assert!(!doc.navigate("/settings"), "no change, so nothing to repaint");
2846        assert!(doc.back());
2847        assert_eq!(doc.route(), "/", "one Back is enough to leave");
2848    }
2849
2850    /// A view keeps its state while you are on it, and starts fresh when you
2851    /// come back to it. Instance state is keyed by template position, so
2852    /// *keeping* it across a visit is what would happen by accident.
2853    #[test]
2854    fn a_route_view_is_fresh_on_a_second_visit() {
2855        let mut doc = router_app();
2856        doc.navigate("/user/7");
2857
2858        let view = doc.root.children[2].clone();
2859        let button = view.children.iter().find(|c| c.on_tap.is_some()).expect("the look button");
2860        let (src, instance) = (button.on_tap.clone().unwrap(), button.instance.clone());
2861        doc.apply_handler_in(&src, instance.as_deref());
2862        assert!(find_text(&doc.root, "user 7 seen 1"), "state moves while here: {:?}", text_of(&doc.root));
2863
2864        doc.navigate("/settings");
2865        doc.navigate("/user/7");
2866        assert!(
2867            find_text(&doc.root, "user 7 seen 0"),
2868            "and starts over on return: {:?}",
2869            text_of(&doc.root)
2870        );
2871    }
2872
2873    /// `:current` is how a nav bar shows where you are. It reads the route, so
2874    /// the link restyles on navigation without the tree being rebuilt.
2875    #[test]
2876    fn the_current_link_matches_the_current_pseudo() {
2877        let mut doc = with_router(
2878            "<template><screen>\
2879               <text to=\"/\" class=\"nav\">home</text>\
2880               <text to=\"/settings\" class=\"nav\">settings</text>\
2881               <router><route path=\"/\" view=\"home\" />\
2882                 <route path=\"/settings\" view=\"settings\" /></router>\
2883             </screen></template>\n\
2884             <style>.nav { color: #888888; } .nav:current { color: #ff0000; }</style>\n\
2885             <script>\nuse components::home;\nuse components::settings;\n</script>",
2886        );
2887        // As a tuple, since `Rgba` is deliberately not `PartialEq`.
2888        let lit = |n: &LayoutNode| -> Option<(f32, f32, f32)> {
2889            n.text.as_ref().map(|t| (t.color.r, t.color.g, t.color.b))
2890        };
2891        assert_ne!(lit(&doc.root.children[0]), lit(&doc.root.children[1]), "one of them is current");
2892        let home_on_home = lit(&doc.root.children[0]);
2893
2894        doc.navigate("/settings");
2895        assert_eq!(
2896            lit(&doc.root.children[1]),
2897            home_on_home,
2898            "the current colour moved to the settings link"
2899        );
2900        assert_ne!(lit(&doc.root.children[0]), home_on_home, "and off the home link");
2901    }
2902
2903    /// A route naming a view that was never imported is a mistake worth saying
2904    /// out loud: the screen would otherwise just be empty.
2905    #[test]
2906    fn a_route_naming_an_unimported_view_warns() {
2907        let _ = take_warnings();
2908        let doc = with_router(
2909            "<template><screen><router>\
2910               <route path=\"/\" view=\"nowhere\" />\
2911             </router></screen></template>\n\
2912             <script>\nuse components::home;\n</script>",
2913        );
2914        // A load drains the sink into its own diagnostics, which is where the
2915        // overlay reads from.
2916        let warnings = &doc.diagnostics.warnings;
2917        assert!(
2918            warnings.iter().any(|w| w.message.contains("nowhere")),
2919            "the warning names the view: {warnings:?}"
2920        );
2921    }
2922
2923    /// A component's state is not a document signal, so the app cannot reach in
2924    /// and read it by name. That isolation is the reason a component can be
2925    /// used in a second app at all.
2926    #[test]
2927    fn component_state_is_not_a_document_signal() {
2928        let mut doc = with_component(
2929            "<template><view><text>{{ count }}</text></view></template>\n\
2930             <script>\nlet count = signal(7);\n</script>",
2931            "<template><screen><card /><text>{{ count }}</text></screen></template>\n\
2932             <script>\nuse components::card;\n</script>",
2933        );
2934        assert!(find_text(&doc.root, "7"), "the component sees its own: {:?}", text_of(&doc.root));
2935        // The document's own `{{ count }}` has nothing to read, and says so
2936        // rather than borrowing the component's.
2937        assert_eq!(doc.value_in("count", None), "", "{:?}", text_of(&doc.root));
2938    }
2939
2940    /// The isolation is one-directional, and the docs claimed otherwise. A
2941    /// component's *script* runs in a fresh scope, so its `let`s are private.
2942    /// Its template and handlers do not: they run against the document's scope
2943    /// with the instance's names pushed on top, so an un-shadowed document
2944    /// signal is both readable and writable from inside. The router depends on
2945    /// it (`{{ route }}` inside a route view).
2946    #[test]
2947    fn a_component_reads_and_writes_an_unshadowed_document_signal() {
2948        let mut doc = with_component(
2949            "<template><view>\
2950               <text>saw {{ theme }}</text>\
2951               <view @tap=\"theme = &quot;dark&quot;\"><text>go</text></view>\
2952             </view></template>\n\
2953             <script>\nlet count = signal(0);\n</script>",
2954            "<template><screen><card /></screen></template>\n\
2955             <script>\nuse components::card;\nlet theme = signal(\"light\");\n</script>",
2956        );
2957        assert!(
2958            find_text(&doc.root, "saw light"),
2959            "the document's signal is visible inside: {:?}",
2960            text_of(&doc.root)
2961        );
2962        let card = doc.root.children[0].clone();
2963        assert!(tap(&mut doc, &card), "the handler wrote a document signal");
2964        assert_eq!(doc.value_in("theme", None), "dark");
2965        assert!(find_text(&doc.root, "saw dark"), "{:?}", text_of(&doc.root));
2966    }
2967
2968    /// A component that leaves the screen leaves the instance map with it.
2969    ///
2970    /// Nothing said an instance had gone before this: the router's was the only
2971    /// removal anywhere, so an `r-if` that closed over a component kept its
2972    /// state for the life of the process and handed it back on the way in. The
2973    /// map grew and never shrank, and a hidden component was quietly different
2974    /// from a route view, which does start fresh.
2975    #[test]
2976    fn hiding_a_component_drops_its_state() {
2977        let mut doc = with_component(
2978            "<template><view @tap=\"count = count + 1\"><text>n {{ count }}</text></view>\
2979             </template>\n\
2980             <script>\nlet count = signal(0);\n</script>",
2981            "<template><screen><card r-if=\"shown\" /></screen></template>\n\
2982             <script>\nuse components::card;\nlet shown = signal(true);\n</script>",
2983        );
2984        let card = doc.root.children[0].clone();
2985        tap(&mut doc, &card);
2986        assert!(find_text(&doc.root, "n 1"), "it counted: {:?}", text_of(&doc.root));
2987
2988        doc.apply_handler("shown = false");
2989        assert!(doc.instances.is_empty(), "gone from the map: {:?}", doc.instances.keys());
2990
2991        doc.apply_handler("shown = true");
2992        assert!(
2993            find_text(&doc.root, "n 0"),
2994            "and comes back new, not where it was left: {:?}",
2995            text_of(&doc.root)
2996        );
2997    }
2998
2999    /// The same for a row of a list, whose instance is keyed by its `r-key`.
3000    /// Without this a long-lived list leaks one instance per row ever shown.
3001    #[test]
3002    fn a_row_that_goes_away_takes_its_instance_with_it() {
3003        let mut doc = with_component(
3004            "<template><view><text>{{ label }}</text></view></template>\n\
3005             <script>\nlet seen = signal(0);\n</script>",
3006            "<template><screen>\
3007               <card r-for=\"row in rows\" r-key=\"row.id\" :label=\"row.id\" />\
3008             </screen></template>\n\
3009             <script>\nuse components::card;\n\
3010             let rows = signal([#{ id: \"a\" }, #{ id: \"b\" }, #{ id: \"c\" }]);\n</script>",
3011        );
3012        assert_eq!(doc.instances.len(), 3, "one per row: {:?}", doc.instances.keys());
3013
3014        doc.apply_handler("rows = [#{ id: \"a\" }]");
3015        assert_eq!(doc.instances.len(), 1, "two rows left, so two instances did: {:?}", doc.instances.keys());
3016        assert!(find_text(&doc.root, "a"), "{:?}", text_of(&doc.root));
3017    }
3018
3019    /// The whole point of a slot: a component can wrap markup it never saw.
3020    /// Before this, the children written between the tags were silently thrown
3021    /// away, so a component could only ever be a fixed shape.
3022    #[test]
3023    fn a_slot_renders_the_callers_children() {
3024        let doc = with_component(
3025            "<template><view class=\"card\"><text>title</text><slot /></view></template>",
3026            "<template><screen>\
3027               <card><text>from the caller</text></card>\
3028             </screen></template>\n\
3029             <script>\nuse components::card;\n</script>",
3030        );
3031        assert!(find_text(&doc.root, "title"), "the component's own markup: {:?}", text_of(&doc.root));
3032        assert!(
3033            find_text(&doc.root, "from the caller"),
3034            "and the children it was handed: {:?}",
3035            text_of(&doc.root)
3036        );
3037    }
3038
3039    /// Slot content is the caller's, so it is evaluated in the caller's scope,
3040    /// which includes the caller's own instance state the component cannot see.
3041    #[test]
3042    fn slot_content_reads_the_callers_scope() {
3043        let doc = with_component(
3044            "<template><view><slot /></view></template>",
3045            "<template><screen>\
3046               <card><text>{{ greeting }}</text></card>\
3047             </screen></template>\n\
3048             <script>\nuse components::card;\nlet greeting = signal(\"hello there\");\n</script>",
3049        );
3050        assert!(
3051            find_text(&doc.root, "hello there"),
3052            "the caller's signal resolved inside the slot: {:?}",
3053            text_of(&doc.root)
3054        );
3055    }
3056
3057    /// An unfilled slot falls back to its own children, as in HTML, so a
3058    /// component can offer a default without the caller writing one.
3059    #[test]
3060    fn an_empty_slot_falls_back_to_its_own_children() {
3061        let doc = with_component(
3062            "<template><view><slot><text>nothing here yet</text></slot></view></template>",
3063            "<template><screen><card /></screen></template>\n\
3064             <script>\nuse components::card;\n</script>",
3065        );
3066        assert!(
3067            find_text(&doc.root, "nothing here yet"),
3068            "the fallback showed: {:?}",
3069            text_of(&doc.root)
3070        );
3071    }
3072
3073    /// The slot leaves no box of its own: the caller's children sit exactly
3074    /// where the `<slot />` was, so a component adds no wrapper nobody wrote.
3075    #[test]
3076    fn a_slot_adds_no_node_of_its_own() {
3077        let doc = with_component(
3078            "<template><view><slot /></view></template>",
3079            "<template><screen>\
3080               <card><text>a</text><text>b</text></card>\
3081             </screen></template>\n\
3082             <script>\nuse components::card;\n</script>",
3083        );
3084        // screen > view(card root) > the two texts, with nothing in between.
3085        let card = &doc.root.children[0];
3086        assert_eq!(card.children.len(), 2, "two children, no wrapper: {:?}", text_of(card));
3087        assert!(card.children.iter().all(|c| c.text.is_some()));
3088    }
3089
3090    /// A number reads the same whether it went through `{{ }}` or through
3091    /// string concatenation in a handler. Every Rux number is an f64, so rhai
3092    /// rendered a whole one as "32.0" beside the same value shown as "32" a
3093    /// line above it.
3094    #[test]
3095    fn numbers_render_the_same_in_text_and_in_script() {
3096        let doc = Document::from_source(
3097            "<template><screen>\
3098               <text>{{ total }}</text>\
3099               <text>{{ \"total is \" + total }}</text>\
3100               <text>{{ half }}</text>\
3101               <text>{{ \"half is \" + half }}</text>\
3102             </screen></template>
3103             <script>\n\
3104               let total = signal(32);\n\
3105               let half = signal(2.5);\n\
3106             </script>",
3107        )
3108        .expect("load");
3109        let shown = text_of(&doc.root);
3110        assert!(shown.contains(&"32".to_string()), "{shown:?}");
3111        assert!(shown.contains(&"total is 32".to_string()), "{shown:?}");
3112        // A fraction still shows its fraction; only the ".0" tail goes.
3113        assert!(shown.contains(&"2.5".to_string()), "{shown:?}");
3114        assert!(shown.contains(&"half is 2.5".to_string()), "{shown:?}");
3115    }
3116
3117    /// A computed is derived state: it is written once, read anywhere, and
3118    /// keeps itself current. Before this the only "computed" was a `{{ }}`
3119    /// expression, so the same derivation was retyped at every use.
3120    #[test]
3121    fn a_computed_tracks_what_it_reads() {
3122        let mut doc = Document::from_source(
3123            "<template><screen><text>{{ total }} for {{ count }}</text></screen></template>
3124             <script>\
3125               let count = signal(2);\n\
3126               let price = signal(10);\n\
3127               computed total = count * price;\n\
3128             </script>",
3129        )
3130        .expect("load");
3131        assert!(find_text(&doc.root, "20 for 2"), "computed on load: {:?}", doc.root);
3132
3133        assert!(doc.apply_handler("count = 3;"), "the handler changed a signal");
3134        assert!(find_text(&doc.root, "30 for 3"), "and the computed followed");
3135    }
3136
3137    /// A computed may read one declared above it, and the refresh is a single
3138    /// pass in declaration order, so a chain settles in one go.
3139    #[test]
3140    fn a_computed_may_read_an_earlier_computed() {
3141        let mut doc = Document::from_source(
3142            "<template><screen><text>{{ shout }}</text></screen></template>
3143             <script>\n\
3144               let name = signal(\"ada\");\n\
3145               computed greeting = \"hi \" + name;\n\
3146               computed shout = greeting + \"!\";\n\
3147             </script>",
3148        )
3149        .expect("load");
3150        assert!(find_text(&doc.root, "hi ada!"));
3151
3152        assert!(doc.apply_handler("name = \"grace\";"));
3153        assert!(find_text(&doc.root, "hi grace!"), "the whole chain refreshed");
3154    }
3155
3156    /// An effect runs on load, so it can *establish* something rather than only
3157    /// react to a later change, and again whenever what it read changes.
3158    #[test]
3159    fn an_effect_runs_on_load_and_on_change() {
3160        let mut doc = Document::from_source(
3161            "<template><screen><text>{{ mirror }}</text></screen></template>
3162             <script>\n\
3163               let count = signal(1);\n\
3164               let mirror = signal(0);\n\
3165               effect {\n\
3166                 mirror = count * 100;\n\
3167               }\n\
3168             </script>",
3169        )
3170        .expect("load");
3171        assert!(find_text(&doc.root, "100"), "ran once on load: {:?}", text_of(&doc.root));
3172
3173        assert!(doc.apply_handler("count = 2;"));
3174        assert!(
3175            find_text(&doc.root, "200"),
3176            "ran again when count changed: {:?}",
3177            text_of(&doc.root)
3178        );
3179    }
3180
3181    /// An effect only re-runs for what it actually read. A signal it never
3182    /// touches must not wake it, or "runs when its dependencies change" is just
3183    /// "runs on everything".
3184    #[test]
3185    fn an_effect_ignores_signals_it_never_read() {
3186        let mut doc = Document::from_source(
3187            "<template><screen><text>{{ mirror }}</text></screen></template>
3188             <script>\n\
3189               let watched = signal(1);\n\
3190               let other = signal(1);\n\
3191               let mirror = signal(0);\n\
3192               effect {\n\
3193                 mirror = watched * 100;\n\
3194               }\n\
3195             </script>",
3196        )
3197        .expect("load");
3198        // The subscription itself, which is the thing under test: an effect
3199        // that subscribed to everything would still pass a behavioural check.
3200        assert_eq!(doc.effects.len(), 1);
3201        assert!(doc.effects[0].deps.contains("watched"), "it read `watched`");
3202        assert!(!doc.effects[0].deps.contains("other"), "it never read `other`");
3203        assert!(
3204            !doc.effects[0].deps.contains("mirror"),
3205            "writing a signal is not reading it, or every effect would feed itself"
3206        );
3207
3208        assert!(doc.apply_handler("other = 2;"));
3209        assert!(find_text(&doc.root, "100"), "an unread signal leaves it alone");
3210
3211        assert!(doc.apply_handler("watched = 3;"));
3212        assert!(find_text(&doc.root, "300"), "the one it read wakes it");
3213    }
3214
3215    /// An effect that writes the signal it reads settles instead of looping,
3216    /// because its own writes do not wake it.
3217    #[test]
3218    fn an_effect_is_not_woken_by_its_own_writes() {
3219        let _ = take_warnings();
3220        let mut doc = Document::from_source(
3221            "<template><screen><text>{{ n }}</text></screen></template>
3222             <script>\n\
3223               let n = signal(0);\n\
3224               effect {\n\
3225                 n = n + 1;\n\
3226               }\n\
3227             </script>",
3228        )
3229        .expect("load");
3230        assert!(find_text(&doc.root, "1"), "ran once: {:?}", text_of(&doc.root));
3231
3232        assert!(doc.apply_handler("n = 100;"));
3233        assert!(
3234            find_text(&doc.root, "100"),
3235            "an outside write is not chased by the effect that owns n: {:?}",
3236            text_of(&doc.root)
3237        );
3238        assert!(doc.diagnostics.warnings.is_empty(), "{:?}", doc.diagnostics.warnings);
3239    }
3240
3241    /// Two effects feeding each other still loop, and that is stopped and
3242    /// reported: a window that hangs says nothing at all.
3243    #[test]
3244    fn effects_that_feed_each_other_are_stopped_and_reported() {
3245        let _ = take_warnings();
3246        let doc = Document::from_source(
3247            "<template><screen><text>{{ a }}</text></screen></template>
3248             <script>\n\
3249               let a = signal(0);\n\
3250               let b = signal(0);\n\
3251               effect {\n\
3252                 b = a + 1;\n\
3253               }\n\
3254               effect {\n\
3255                 a = b + 1;\n\
3256               }\n\
3257             </script>",
3258        )
3259        .expect("load");
3260        // Reaching here at all is half the assertion: the loop is bounded.
3261        assert!(
3262            doc.diagnostics.warnings.iter().any(|w| w.message.contains("re-triggering")),
3263            "the cycle is named rather than hung on: {:?}",
3264            doc.diagnostics.warnings
3265        );
3266    }
3267
3268    /// A `<select>` in each row is a separate select. The layout has to say so,
3269    /// or the shell opens the first row's dropdown wherever you tapped, draws it
3270    /// over that row, and writes the chosen option into it.
3271    #[test]
3272    fn each_rows_select_is_its_own() {
3273        let doc = Document::from_source(
3274            "<template><screen>\
3275               <input r-for=\"row in rows\" r-key=\"row.id\" type=\"select\" \
3276                      r-model=\"pick\" :options=\"row.options\" />\
3277             </screen></template>
3278             <script>\
3279               let pick = signal(\"a\");\
3280               let rows = signal([\
3281                 #{ id: \"one\", options: [\"a\", \"b\"] },\
3282                 #{ id: \"two\", options: [\"c\", \"d\"] }\
3283               ]);\
3284             </script>",
3285        )
3286        .expect("load");
3287
3288        let mut measure = |_: &rux_layout::TextContent, _: Option<f32>| (10.0, 10.0);
3289        let out = rux_layout::layout(&doc.root, 800.0, 600.0, &mut measure);
3290        let rows: Vec<Option<String>> = out.selects.iter().map(|s| s.row.clone()).collect();
3291        assert_eq!(
3292            rows,
3293            vec![Some("one".to_string()), Some("two".to_string())],
3294            "two selects, each stamped with the row it is in"
3295        );
3296        // Same model text on both, which is exactly why the row is needed.
3297        assert_eq!(out.selects[0].model, out.selects[1].model);
3298        assert_eq!(out.selects[0].options, vec!["a", "b"]);
3299        assert_eq!(out.selects[1].options, vec!["c", "d"]);
3300    }
3301
3302    /// Two rows claiming one identity is worse than none, so it is said out
3303    /// loud. Same for a key on an element that is not a row at all.
3304    #[test]
3305    fn keys_that_cannot_work_are_warned_about() {
3306        let _ = take_warnings();
3307        let doc = Document::from_source(
3308            "<template><screen>\
3309               <text r-for=\"row in rows\" r-key=\"row.id\">{{ row.id }}</text>\
3310             </screen></template>
3311             <script>let rows = signal([#{ id: \"a\" }, #{ id: \"a\" }]);</script>",
3312        )
3313        .expect("load");
3314        assert!(
3315            doc.diagnostics.warnings.iter().any(|w| w.message.contains("duplicate key")),
3316            "duplicate keys are reported: {:?}",
3317            doc.diagnostics.warnings
3318        );
3319
3320        let _ = take_warnings();
3321        let doc = Document::from_source(
3322            "<template><screen><text r-key=\"x\">hi</text></screen></template>",
3323        )
3324        .expect("load");
3325        assert!(
3326            doc.diagnostics.warnings.iter().any(|w| w.message.contains("without `r-for`")),
3327            "a key with no list is reported: {:?}",
3328            doc.diagnostics.warnings
3329        );
3330    }
3331
3332    /// A collapsed selection is no selection: a plain caret must not paint a
3333    /// zero-width highlight.
3334    #[test]
3335    fn a_collapsed_selection_is_none() {
3336        let mut doc = two_inputs();
3337        doc.set_focus(Some(Focus::at("name", 2)));
3338        assert_eq!(caret_of(&doc.root, "name"), Some(2));
3339        assert_eq!(selection_of(&doc.root, "name"), None);
3340    }
3341
3342    /// Both caret and selection are re-applied after a rebuild, the whole-tree
3343    /// rebuild throws the tree away, so anything ephemeral must be put back.
3344    #[test]
3345    fn selection_survives_a_rebuild() {
3346        let mut doc = two_inputs();
3347        doc.set_focus(Some(Focus { model: "name".into(), row: None, caret: 3, anchor: 1, preedit: None }));
3348        doc.rebuild();
3349        assert_eq!(selection_of(&doc.root, "name"), Some((1, 3)));
3350        assert_eq!(caret_of(&doc.root, "name"), Some(3));
3351        assert_eq!(selection_of(&doc.root, "city"), None);
3352    }
3353
3354    /// Text being composed through an input method is marked on the focused
3355    /// input only, and is cleared the same way a selection is when focus moves.
3356    /// Without the clearing, leaving a field mid-composition left the underline
3357    /// behind on text that had since been committed.
3358    #[test]
3359    fn a_composition_marks_only_the_focused_input() {
3360        let mut doc = two_inputs();
3361
3362        doc.set_focus(Some(Focus {
3363            model: "name".into(),
3364            row: None,
3365            caret: 3,
3366            anchor: 3,
3367            preedit: Some((1, 3)),
3368        }));
3369        assert_eq!(preedit_of(&doc.root, "name"), Some((1, 3)));
3370        assert_eq!(preedit_of(&doc.root, "city"), None);
3371
3372        doc.set_focus(Some(Focus::at("city", 1)));
3373        assert_eq!(preedit_of(&doc.root, "name"), None, "old input kept its composition");
3374        assert_eq!(preedit_of(&doc.root, "city"), None);
3375    }
3376
3377    /// A composition outlives the rebuild that showing it causes: the shell
3378    /// writes the composed text into the bound signal, and that edit can rebuild
3379    /// the tree, so a range applied before it must be put back after.
3380    #[test]
3381    fn a_composition_survives_a_rebuild() {
3382        let mut doc = two_inputs();
3383        doc.set_focus(Some(Focus {
3384            model: "name".into(),
3385            row: None,
3386            caret: 2,
3387            anchor: 2,
3388            preedit: Some((0, 2)),
3389        }));
3390        doc.rebuild();
3391        assert_eq!(preedit_of(&doc.root, "name"), Some((0, 2)));
3392    }
3393
3394    fn patch_doc() -> Document {
3395        // `n` is displayed only in a `{{ }}` text binding (patchable); `name` is
3396        // read by an input's r-model value (structural → forces a rebuild).
3397        Document::from_source(
3398            "<template><screen><text class=\"c\">{{ n }}</text><input r-model=\"name\" /></screen></template>
3399             <script>let n = signal(0); let name = signal(\"hi\");</script>",
3400        )
3401        .expect("load")
3402    }
3403
3404    /// A display-only change patches the text node in place, no rebuild, so the
3405    /// caret in an unrelated input survives without any restore pass running.
3406    #[test]
3407    fn patch_updates_text_and_preserves_caret() {
3408        let mut doc = patch_doc();
3409        doc.set_focus(Some(Focus::at("name", 1)));
3410
3411        let changed = doc.engine_mut().run_handler_tracked("n = n + 1");
3412        assert!(doc.patch(&changed), "a display-only change patches in place");
3413        assert_eq!(doc.root.children[0].text.as_ref().unwrap().text, "1");
3414        // The caret survived: patch never touched focus, and no rebuild happened.
3415        assert_eq!(caret_of(&doc.root, "name"), Some(1));
3416    }
3417
3418    /// Changing an input-bound signal patches the input's value in place (it is a
3419    /// patchable value binding, not structural), leaving the sibling display alone.
3420    #[test]
3421    fn patch_updates_input_value_in_place() {
3422        let mut doc = patch_doc();
3423        let changed = doc.engine_mut().run_handler_tracked("name = \"yo\"");
3424        assert!(doc.patch(&changed), "an input value change patches in place");
3425        // The input (child 1) shows the new value; the `{{ n }}` display is untouched.
3426        assert_eq!(doc.root.children[1].children[0].text.as_ref().unwrap().text, "yo");
3427        assert_eq!(doc.root.children[0].text.as_ref().unwrap().text, "0");
3428    }
3429
3430    fn input_text(doc: &Document) -> &str {
3431        // screen → input → text child.
3432        &doc.root.children[0].children[0].text.as_ref().unwrap().text
3433    }
3434
3435    /// A keystroke patches the input's shown value in place, `patch` returns true
3436    /// (no rebuild needed) and the text updates, and the caret survives.
3437    #[test]
3438    fn typing_patches_the_input_value_in_place() {
3439        let mut doc = Document::from_source(
3440            "<template><screen><input r-model=\"name\" placeholder=\"type…\" /></screen></template>
3441             <script>let name = signal(\"ab\");</script>",
3442        )
3443        .expect("load");
3444        doc.set_focus(Some(Focus::at("name", 2)));
3445        assert_eq!(input_text(&doc), "ab");
3446
3447        doc.engine_mut().set_string("name", "abc");
3448        let changed: HashSet<String> = std::iter::once("name".to_string()).collect();
3449        assert!(doc.patch(&changed), "value-only input edit patches in place");
3450        assert_eq!(input_text(&doc), "abc");
3451
3452        // Emptying the field falls back to the placeholder (patched, not rebuilt).
3453        doc.engine_mut().set_string("name", "");
3454        assert!(doc.patch(&changed));
3455        assert_eq!(input_text(&doc), "type…");
3456    }
3457
3458    /// `:options` rewrites a select's option list in place, no rebuild.
3459    #[test]
3460    fn options_patch_in_place() {
3461        let mut doc = Document::from_source(
3462            "<template><screen><input type=\"select\" r-model=\"fruit\" :options=\"fruits\" /></screen></template>
3463             <script>let fruit = signal(\"a\"); let fruits = signal([\"a\", \"b\"]);</script>",
3464        )
3465        .expect("load");
3466        assert_eq!(doc.root.children[0].options.as_ref().unwrap().len(), 2);
3467
3468        let changed = doc.engine_mut().run_handler_tracked("fruits = [\"a\", \"b\", \"c\"]");
3469        assert!(doc.patch(&changed), "an :options change patches in place");
3470        assert_eq!(doc.root.children[0].options.as_ref().unwrap().len(), 3, "list grew in place");
3471    }
3472
3473    /// A component prop change reconciles the instance subtree in place: the
3474    /// re-expanded component shows the new prop value, no wholesale rebuild.
3475    #[test]
3476    fn component_prop_reconciles_in_place() {
3477        use std::fs;
3478        let dir = std::env::temp_dir().join(format!("rux_prop_{}", std::process::id()));
3479        let comp_dir = dir.join("components");
3480        fs::create_dir_all(&comp_dir).unwrap();
3481        fs::write(
3482            comp_dir.join("stat.rux"),
3483            r#"<template><view><text>{{ value }}</text></view></template>"#,
3484        )
3485        .unwrap();
3486        fs::write(
3487            dir.join("app.rux"),
3488            "<template><screen><stat :value=\"n\" /></screen></template>\n\
3489             <script>\nuse components::stat;\nlet n = signal(1);\n</script>",
3490        )
3491        .unwrap();
3492
3493        let mut doc = Document::load(dir.join("app.rux")).expect("load app");
3494        assert!(find_text(&doc.root, "1"), "prop starts at 1");
3495        let changed = doc.engine_mut().run_handler_tracked("n = 2");
3496        assert!(doc.patch(&changed), "a component prop change reconciles in place");
3497        assert!(find_text(&doc.root, "2"), "component re-expanded with the new prop");
3498
3499        let _ = fs::remove_dir_all(&dir);
3500    }
3501
3502    /// Toggling a checkbox reconciles just that node (its checked style + mark) in
3503    /// place, and a caret on an input elsewhere survives with no whole-tree
3504    /// restore.
3505    #[test]
3506    fn toggle_reconciles_and_preserves_an_outside_caret() {
3507        let mut doc = Document::from_source(
3508            "<template><screen>\
3509               <input r-model=\"name\" />\
3510               <input type=\"checkbox\" class=\"box\" r-model=\"on\" />\
3511             </screen></template>
3512             <style>.box { background: #000000; } .box.checked { background: #00ff00; }</style>
3513             <script>let name = signal(\"ab\"); let on = signal(false);</script>",
3514        )
3515        .expect("load");
3516        doc.set_focus(Some(Focus::at("name", 1)));
3517        let green = |n: &LayoutNode| matches!(&n.style.background, Some(rux_layout::Background::Color(c)) if c.g == 1.0);
3518        assert!(!green(&doc.root.children[1]), "unchecked → not green");
3519
3520        let changed = doc.engine_mut().run_handler_tracked("on = true");
3521        assert!(doc.patch(&changed), "a toggle reconciles in place");
3522        assert!(green(&doc.root.children[1]), "checked → .box.checked (green) applied");
3523        assert!(doc.root.children[1].children.len() == 1, "checkmark added");
3524        // Only the toggle node was spliced; the sibling input node is untouched, so
3525        // its caret persists by identity.
3526        assert_eq!(caret_of(&doc.root, "name"), Some(1));
3527    }
3528
3529    // ── Pointer state (`:hover` / `:active`) ────────────────────────────────
3530
3531    // ── Diagnostics / dev overlay ───────────────────────────────────────────
3532
3533    /// A document that builds but whose CSS partly does nothing reports it,
3534    /// instead of the silence that made unknown CSS the worst failure mode here.
3535    #[test]
3536    fn warnings_are_collected_for_the_overlay() {
3537        let doc = Document::from_source(
3538            "<template><screen><view class=\"card\" /></screen></template>
3539             <style>.card { filter: blur(2px); background: var(--nope); }</style>",
3540        )
3541        .expect("load");
3542        let warnings = &doc.diagnostics().warnings;
3543        assert!(
3544            warnings.iter().any(|w| w.message.contains("filter")),
3545            "unhonored property reported: {warnings:?}"
3546        );
3547        assert!(
3548            warnings.iter().any(|w| w.message.contains("--nope")),
3549            "undefined var reported: {warnings:?}"
3550        );
3551        assert!(doc.diagnostics().error.is_none(), "the document still built");
3552    }
3553
3554    /// A clean document reports nothing, so the overlay stays out of the way.
3555    #[test]
3556    fn a_clean_document_has_no_diagnostics() {
3557        let doc = Document::from_source(
3558            "<template><screen><view class=\"card\" /></screen></template>
3559             <style>.card { background: #313244; }</style>",
3560        )
3561        .expect("load");
3562        assert!(doc.diagnostics().is_empty(), "{:?}", doc.diagnostics());
3563    }
3564
3565    /// A failed reload keeps the tree that is on screen and marks it stale,
3566    /// a typo mid-edit must not blank the window.
3567    #[test]
3568    fn a_failed_reload_keeps_the_last_good_tree() {
3569        let mut doc = Document::from_source(
3570            "<template><screen><text>hello</text></screen></template>",
3571        )
3572        .expect("load");
3573        let before = doc.root.children.len();
3574
3575        doc.set_load_error("parse error at line 6, column 13: mismatched closing tag");
3576        assert_eq!(doc.root.children.len(), before, "the tree is untouched");
3577        assert!(doc.diagnostics().error.is_some());
3578        assert!(doc.diagnostics().stale, "what's on screen predates the error");
3579    }
3580
3581    /// Loading a good document over a broken one clears the error.
3582    #[test]
3583    fn a_successful_reload_clears_the_error() {
3584        let mut doc = Document::from_source("<template><screen><text>old</text></screen></template>")
3585            .expect("load");
3586        doc.set_load_error("something was wrong");
3587
3588        let fresh = Document::from_source("<template><screen><text>new</text></screen></template>")
3589            .expect("load");
3590        doc.replace_with(fresh);
3591        assert!(doc.diagnostics().error.is_none(), "error cleared");
3592        assert!(!doc.diagnostics().stale);
3593        assert_eq!(doc.root.children[0].text.as_ref().unwrap().text, "new");
3594    }
3595
3596    /// The window owns the viewport, not the file, a reload must not reset it,
3597    /// or a hot-reload in a narrow window would come back with desktop styling.
3598    #[test]
3599    fn a_reload_keeps_the_window_viewport() {
3600        let mut doc = media_doc();
3601        doc.set_viewport(Viewport { width: 480.0, height: 800.0 });
3602        assert!(is_red(&doc.root.children[0]));
3603
3604        let fresh = Document::from_source(
3605            "<template><screen><view class=\"card\" /></screen></template>
3606             <style>
3607               .card { background: #00ff00; }
3608               @media (max-width: 600px) { .card { background: #ff0000; } }
3609             </style>",
3610        )
3611        .expect("load");
3612        doc.replace_with(fresh);
3613        assert!(
3614            is_red(&doc.root.children[0]),
3615            "still narrow after the reload, so the @media rule still applies"
3616        );
3617    }
3618
3619    // ── @media / viewport ───────────────────────────────────────────────────
3620
3621    fn media_doc() -> Document {
3622        Document::from_source(
3623            "<template><screen><view class=\"card\" /></screen></template>
3624             <style>
3625               .card { background: #00ff00; }
3626               @media (max-width: 600px) { .card { background: #ff0000; } }
3627             </style>",
3628        )
3629        .expect("load")
3630    }
3631
3632    fn is_red(n: &LayoutNode) -> bool {
3633        matches!(&n.style.background, Some(rux_layout::Background::Color(c)) if c.r == 1.0 && c.g == 0.0)
3634    }
3635
3636    /// Crossing a breakpoint re-cascades; crossing back restores.
3637    #[test]
3638    fn resize_across_a_breakpoint_restyles() {
3639        let mut doc = media_doc();
3640        assert!(!is_red(&doc.root.children[0]), "the default viewport is wide");
3641
3642        assert!(doc.set_viewport(Viewport { width: 480.0, height: 800.0 }), "breakpoint crossed");
3643        assert!(is_red(&doc.root.children[0]), "narrow → the @media rule applies");
3644
3645        assert!(doc.set_viewport(Viewport { width: 1000.0, height: 800.0 }), "crossed back");
3646        assert!(!is_red(&doc.root.children[0]), "wide again → the base rule");
3647    }
3648
3649    /// The case that has to stay free: a resize crossing no breakpoint reports no
3650    /// change, so dragging a window edge doesn't re-cascade on every pixel.
3651    #[test]
3652    fn resize_within_a_breakpoint_is_not_a_change() {
3653        let mut doc = media_doc();
3654        doc.set_viewport(Viewport { width: 400.0, height: 800.0 });
3655        assert!(
3656            !doc.set_viewport(Viewport { width: 500.0, height: 800.0 }),
3657            "still under 600px, nothing to redo"
3658        );
3659        assert!(is_red(&doc.root.children[0]), "and the styling is still correct");
3660    }
3661
3662    /// A document with no `@media` at all never re-cascades on resize.
3663    #[test]
3664    fn resize_does_nothing_without_media_queries() {
3665        let mut doc = Document::from_source(
3666            "<template><screen><view class=\"card\" /></screen></template>
3667             <style>.card { background: #00ff00; }</style>",
3668        )
3669        .expect("load");
3670        assert!(!doc.set_viewport(Viewport { width: 320.0, height: 480.0 }));
3671        assert!(!doc.set_viewport(Viewport { width: 1600.0, height: 900.0 }));
3672    }
3673
3674    /// Two sibling cards, only the second of which holds an input, plus a
3675    /// `:hover` rule that repaints a card green.
3676    fn hover_doc() -> Document {
3677        Document::from_source(
3678            "<template><screen>\
3679               <view class=\"card\"><text>one</text></view>\
3680               <view class=\"card\"><input r-model=\"name\" /></view>\
3681             </screen></template>
3682             <style>.card { background: #000000; } .card:hover { background: #00ff00; }</style>
3683             <script>let name = signal(\"ab\");</script>",
3684        )
3685        .expect("load")
3686    }
3687
3688    fn hovering(path: &[usize]) -> InteractionState {
3689        InteractionState { hovered: Some(path.to_vec()), ..InteractionState::default() }
3690    }
3691
3692    fn is_green(n: &LayoutNode) -> bool {
3693        matches!(&n.style.background, Some(rux_layout::Background::Color(c)) if c.g == 1.0)
3694    }
3695
3696    /// The hovered element restyles, its unhovered sibling does not, and leaving
3697    /// puts it back, the negative case is the point.
3698    #[test]
3699    fn hover_restyles_only_the_hovered_element() {
3700        let mut doc = hover_doc();
3701        assert!(!is_green(&doc.root.children[0]), "nothing hovered → no green");
3702
3703        assert!(doc.set_interaction(hovering(&[0])), "entering a card restyles");
3704        assert!(is_green(&doc.root.children[0]), "hovered card is green");
3705        assert!(!is_green(&doc.root.children[1]), "its sibling is NOT");
3706
3707        assert!(doc.set_interaction(InteractionState::default()), "leaving restyles");
3708        assert!(!is_green(&doc.root.children[0]), "hover ends → back to black");
3709    }
3710
3711    /// The pointer moving *within* the same element is not a state change, so it
3712    /// must not restyle anything, this is the every-mouse-move path.
3713    #[test]
3714    fn same_hover_target_is_not_a_change() {
3715        let mut doc = hover_doc();
3716        assert!(doc.set_interaction(hovering(&[0])));
3717        assert!(
3718            !doc.set_interaction(hovering(&[0])),
3719            "re-reporting the same target does no work"
3720        );
3721    }
3722
3723    /// Hover moves between siblings while a caret sits in an input elsewhere: the
3724    /// caret survives, because only the diverging subtree is spliced.
3725    #[test]
3726    fn hover_change_preserves_a_caret_elsewhere() {
3727        let mut doc = hover_doc();
3728        doc.set_focus(Some(Focus::at("name", 1)));
3729        assert_eq!(caret_of(&doc.root, "name"), Some(1));
3730
3731        assert!(doc.set_interaction(hovering(&[0])));
3732        assert_eq!(caret_of(&doc.root, "name"), Some(1), "caret survives a hover change");
3733        assert!(is_green(&doc.root.children[0]));
3734    }
3735
3736    /// Clearing the pointer state (the pointer left the window) un-styles what was
3737    /// hovered or pressed. Found by driving it: leaving the window fires
3738    /// `CursorLeft`, not a `CursorMoved`, so a hovered button stayed lit after the
3739    /// pointer was gone. This is the "assert it is *cleared*" half of the rule.
3740    #[test]
3741    fn clearing_pointer_state_unstyles_the_hovered_element() {
3742        let mut doc = hover_doc();
3743        doc.set_interaction(InteractionState {
3744            hovered: Some(vec![0]),
3745            active: Some(vec![0]),
3746            ..InteractionState::default()
3747        });
3748        assert!(is_green(&doc.root.children[0]));
3749
3750        assert!(doc.set_interaction(InteractionState::default()), "clearing restyles");
3751        assert!(!is_green(&doc.root.children[0]), "nothing is hovered any more");
3752    }
3753
3754    /// `:hover` holds for the whole chain under the pointer, as in CSS: hovering a
3755    /// child leaves its ancestor hovered too.
3756    #[test]
3757    fn hover_applies_to_the_ancestor_chain() {
3758        let mut doc = Document::from_source(
3759            "<template><screen>\
3760               <view class=\"card\"><view class=\"inner\"><text>x</text></view></view>\
3761             </screen></template>
3762             <style>\
3763               .card { background: #000000; } .card:hover { background: #00ff00; }\
3764               .inner:hover { background: #0000ff; }\
3765             </style>",
3766        )
3767        .expect("load");
3768        // Pointer over the inner box (path [0, 0]).
3769        assert!(doc.set_interaction(hovering(&[0, 0])));
3770        assert!(is_green(&doc.root.children[0]), "the ancestor card is hovered too");
3771        let inner = &doc.root.children[0].children[0];
3772        assert!(
3773            matches!(&inner.style.background, Some(rux_layout::Background::Color(c)) if c.b == 1.0),
3774            "the inner box is hovered"
3775        );
3776    }
3777
3778    /// A document with no pointer-state rules emits no state regions, so hover
3779    /// costs nothing at all.
3780    #[test]
3781    fn no_pointer_rules_means_no_state_regions() {
3782        let doc = Document::from_source(
3783            "<template><screen><view class=\"card\"><text>x</text></view></screen></template>
3784             <style>.card { background: #000000; }</style>",
3785        )
3786        .expect("load");
3787        fn any_marked(n: &LayoutNode) -> bool {
3788            n.state_path.is_some() || n.children.iter().any(any_marked)
3789        }
3790        assert!(!any_marked(&doc.root), "no :hover/:active rule → nothing to track");
3791    }
3792
3793    /// The element a `:hover` rule could match carries a path, so the layout emits
3794    /// a region the shell can hit-test.
3795    #[test]
3796    fn hoverable_elements_are_marked_for_the_shell() {
3797        let doc = hover_doc();
3798        assert_eq!(doc.root.children[0].state_path.as_deref(), Some(&[0][..]));
3799        assert_eq!(doc.root.children[1].state_path.as_deref(), Some(&[1][..]));
3800        assert!(doc.root.state_path.is_none(), "the screen has no :hover rule");
3801    }
3802
3803    /// `r-show` flips the node's `hidden` flag in place, no shape change, no
3804    /// rebuild, both ways.
3805    #[test]
3806    fn r_show_toggles_hidden_in_place() {
3807        let mut doc = Document::from_source(
3808            "<template><screen><text r-show=\"on\">hi</text></screen></template>
3809             <script>let on = signal(true);</script>",
3810        )
3811        .expect("load");
3812        assert!(!doc.root.children[0].hidden, "on=true → visible");
3813
3814        let changed = doc.engine_mut().run_handler_tracked("on = false");
3815        assert!(doc.patch(&changed), "r-show change patches in place");
3816        assert!(doc.root.children[0].hidden, "on=false → hidden");
3817
3818        let changed = doc.engine_mut().run_handler_tracked("on = true");
3819        assert!(doc.patch(&changed));
3820        assert!(!doc.root.children[0].hidden, "on=true → visible again");
3821    }
3822
3823    /// An `r-if` toggling patches its owning subtree in place, and a caret on an
3824    /// input in a *different* subtree survives untouched, with no whole-tree
3825    /// `apply_focus`. This is the reconciliation payoff.
3826    #[test]
3827    fn r_if_reconciles_and_preserves_an_outside_caret() {
3828        let mut doc = Document::from_source(
3829            "<template><screen>\
3830               <view class=\"top\"><input r-model=\"name\" /></view>\
3831               <view class=\"list\"><text r-if=\"show\">secret</text></view>\
3832             </screen></template>
3833             <script>let name = signal(\"ab\"); let show = signal(false);</script>",
3834        )
3835        .expect("load");
3836        doc.set_focus(Some(Focus::at("name", 1)));
3837        assert_eq!(caret_of(&doc.root, "name"), Some(1));
3838        assert!(!find_text(&doc.root, "secret"), "hidden while show=false");
3839
3840        // Reveal the r-if branch: reconciles the `.list` subtree only.
3841        let changed = doc.engine_mut().run_handler_tracked("show = true");
3842        assert!(doc.patch(&changed), "an r-if change reconciles in place");
3843        assert!(find_text(&doc.root, "secret"), "branch now shown");
3844        // The input is in `.top`, an untouched subtree, its caret persists with no
3845        // whole-tree restore.
3846        assert_eq!(caret_of(&doc.root, "name"), Some(1), "outside caret survived");
3847
3848        // And hiding it again removes the branch.
3849        let changed = doc.engine_mut().run_handler_tracked("show = false");
3850        assert!(doc.patch(&changed));
3851        assert!(!find_text(&doc.root, "secret"));
3852        assert_eq!(caret_of(&doc.root, "name"), Some(1));
3853    }
3854
3855    /// An `r-for` list change reconciles the row count in place.
3856    #[test]
3857    fn r_for_reconciles_row_count() {
3858        let mut doc = Document::from_source(
3859            "<template><screen><view class=\"list\"><text r-for=\"n in nums\">{{ n }}</text></view></screen></template>
3860             <script>let nums = signal([1, 2]);</script>",
3861        )
3862        .expect("load");
3863        assert_eq!(doc.root.children[0].children.len(), 2, "two rows initially");
3864
3865        let changed = doc.engine_mut().run_handler_tracked("nums = [1, 2, 3, 4]");
3866        assert!(doc.patch(&changed), "an r-for change reconciles in place");
3867        assert_eq!(doc.root.children[0].children.len(), 4, "grew to four rows");
3868        assert!(find_text(&doc.root, "4"), "new row content present");
3869    }
3870
3871    /// A label with `for="id"` inherits the `@tap` of the input with that `id`, so
3872    /// tapping the label toggles the input, even though the label doesn't wrap it.
3873    #[test]
3874    fn label_for_inherits_the_targets_tap() {
3875        let doc = Document::from_source(
3876            "<template><screen>\
3877               <input type=\"checkbox\" id=\"chk\" r-model=\"on\" />\
3878               <text for=\"chk\">Remember me</text>\
3879             </screen></template>
3880             <script>let on = signal(false);</script>",
3881        )
3882        .expect("load");
3883        // The label (child 1) picks up the checkbox's auto-generated toggle handler.
3884        assert_eq!(
3885            doc.root.children[1].on_tap.as_deref(),
3886            Some("on = !on"),
3887            "label with for= inherits the checkbox's @tap"
3888        );
3889        // An authored @tap on a label is not overridden.
3890        let doc2 = Document::from_source(
3891            "<template><screen>\
3892               <input type=\"checkbox\" id=\"chk\" r-model=\"on\" />\
3893               <text for=\"chk\" @tap=\"on = true\">Set</text>\
3894             </screen></template>
3895             <script>let on = signal(false);</script>",
3896        )
3897        .expect("load");
3898        assert_eq!(doc2.root.children[1].on_tap.as_deref(), Some("on = true"));
3899    }
3900
3901    /// A label whose `for=` targets a *text* input (no `@tap`) gets a `focus_model`
3902    /// instead, so the shell focuses that input when the label is tapped.
3903    #[test]
3904    fn label_for_focuses_a_text_input() {
3905        let doc = Document::from_source(
3906            "<template><screen>\
3907               <input id=\"nm\" r-model=\"name\" />\
3908               <text for=\"nm\">Name</text>\
3909             </screen></template>
3910             <script>let name = signal(\"\");</script>",
3911        )
3912        .expect("load");
3913        let label = &doc.root.children[1];
3914        assert_eq!(label.on_tap, None, "a text-input label has no tap handler");
3915        assert_eq!(
3916            label.focus_model.as_deref(),
3917            Some("name"),
3918            "label focuses the text input's model"
3919        );
3920    }
3921
3922    fn bg_rgb(n: &LayoutNode) -> Option<(f32, f32, f32)> {
3923        match &n.style.background {
3924            Some(rux_layout::Background::Color(c)) => Some((c.r, c.g, c.b)),
3925            _ => None,
3926        }
3927    }
3928
3929    /// `:class` feeds a signal-driven class into the cascade, and a change to that
3930    /// signal reconciles the node's style in place.
3931    #[test]
3932    fn dynamic_class_reconciles() {
3933        let mut doc = Document::from_source(
3934            "<template><screen><view class=\"chip\" :class=\"tone\" /></screen></template>
3935             <style>.hot { background: #ff0000; } .cool { background: #0000ff; }</style>
3936             <script>let tone = signal(\"hot\");</script>",
3937        )
3938        .expect("load");
3939        assert_eq!(bg_rgb(&doc.root.children[0]), Some((1.0, 0.0, 0.0)), ":class=hot → .hot");
3940
3941        let changed = doc.engine_mut().run_handler_tracked("tone = \"cool\"");
3942        assert!(doc.patch(&changed), ":class change reconciles in place");
3943        assert_eq!(bg_rgb(&doc.root.children[0]), Some((0.0, 0.0, 1.0)), "reconciled to .cool");
3944    }
3945
3946    /// `:style` with a rhai backtick template literal (string interpolation) sets an
3947    /// inline style, overriding the cascade, and reconciles on change.
3948    #[test]
3949    fn dynamic_inline_style_interpolates_and_reconciles() {
3950        let mut doc = Document::from_source(
3951            "<template><screen><view :style=\"`background: ${col}`\" /></screen></template>
3952             <script>let col = signal(\"#00ff00\");</script>",
3953        )
3954        .expect("load");
3955        assert_eq!(bg_rgb(&doc.root.children[0]), Some((0.0, 1.0, 0.0)), ":style set green");
3956
3957        let changed = doc.engine_mut().run_handler_tracked("col = \"#ff0000\"");
3958        assert!(doc.patch(&changed));
3959        assert_eq!(bg_rgb(&doc.root.children[0]), Some((1.0, 0.0, 0.0)), "reconciled to red");
3960    }
3961
3962    /// The chip example: `:style` reads the `r-for` loop variable, so each item gets
3963    /// its own colour; the `r-for` drives it (no per-node binding needed).
3964    #[test]
3965    fn r_for_chip_styles() {
3966        let doc = Document::from_source(
3967            "<template><screen><view class=\"chips\">\
3968               <view class=\"chip\" r-for=\"c in colors\" :style=\"`background: ${c}`\"><text>{{ c }}</text></view>\
3969             </view></screen></template>
3970             <script>let colors = signal([\"#ff0000\", \"#00ff00\"]);</script>",
3971        )
3972        .expect("load");
3973        let chips = &doc.root.children[0];
3974        assert_eq!(bg_rgb(&chips.children[0]), Some((1.0, 0.0, 0.0)), "first chip red");
3975        assert_eq!(bg_rgb(&chips.children[1]), Some((0.0, 1.0, 0.0)), "second chip green");
3976    }
3977
3978    /// `:class` object/conditional form (`#{ hot: cond }`), keys whose value is
3979    /// truthy become classes; a change flips them and reconciles.
3980    #[test]
3981    fn conditional_class_object_form() {
3982        let mut doc = Document::from_source(
3983            "<template><screen><view class=\"chip\" :class=\"#{ hot: warm, cool: !warm }\" /></screen></template>
3984             <style>.hot { background: #ff0000; } .cool { background: #0000ff; }</style>
3985             <script>let warm = signal(true);</script>",
3986        )
3987        .expect("load");
3988        assert_eq!(bg_rgb(&doc.root.children[0]), Some((1.0, 0.0, 0.0)), "warm → .hot");
3989
3990        let changed = doc.engine_mut().run_handler_tracked("warm = false");
3991        assert!(doc.patch(&changed), "conditional class change reconciles");
3992        assert_eq!(bg_rgb(&doc.root.children[0]), Some((0.0, 0.0, 1.0)), "!warm → .cool");
3993    }
3994
3995    /// The shipped `css-showcase.rux` (the `:class`/`:style` chip demo) loads and
3996    /// builds, a smoke test that the example stays valid.
3997    #[test]
3998    fn css_showcase_example_builds() {
3999        let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../examples/css-showcase.rux");
4000        let doc = Document::load(path).expect("css-showcase.rux builds");
4001        assert!(find_text(&doc.root, "teal"), "a :style-coloured chip rendered");
4002    }
4003
4004    /// `:style` object form (`#{ background: c }`), each entry a declaration.
4005    #[test]
4006    fn style_object_form() {
4007        let doc = Document::from_source(
4008            "<template><screen><view :style=\"#{ background: col }\" /></screen></template>
4009             <script>let col = signal(\"#00ff00\");</script>",
4010        )
4011        .expect("load");
4012        assert_eq!(bg_rgb(&doc.root.children[0]), Some((0.0, 1.0, 0.0)), ":style object → green");
4013    }
4014
4015    /// A checked box gets a synthetic `checked` class, so its checked look is
4016    /// plain CSS. A radio matches on its `value`.
4017    #[test]
4018    fn checked_toggles_get_a_checked_class() {
4019        let doc = Document::from_source(
4020            "<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>
4021             <style>.box { background: #000000; } .box.checked { background: #00ff00; }</style>
4022             <script>let on = signal(true); let plan = signal(\"pro\");</script>",
4023        )
4024        .expect("load");
4025
4026        let green = |n: &LayoutNode| {
4027            matches!(&n.style.background, Some(rux_layout::Background::Color(c)) if c.g == 1.0)
4028        };
4029        let boxes = &doc.root.children;
4030        assert!(green(&boxes[0]), "checked checkbox should match .checked");
4031        assert!(green(&boxes[1]), "radio whose value == signal is checked");
4032        assert!(!green(&boxes[2]), "the other radio is not checked");
4033
4034        // ...and the checked ones carry a mark, the unchecked one doesn't.
4035        assert_eq!(boxes[0].children.len(), 1);
4036        assert_eq!(boxes[1].children.len(), 1);
4037        assert_eq!(boxes[2].children.len(), 0);
4038    }
4039}
4040