1use 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};
35pub use rux_reactive::json_string;
38pub use rux_style::{InteractionState, Viewport, Warning};
39
40pub struct Document {
43 sfc: Sfc,
44 components: HashMap<String, Sfc>,
45 engine: Engine,
46 base: PathBuf,
48 focus: Option<Focus>,
51 registry: BindingRegistry,
55 state: InteractionState,
59 viewport: Viewport,
61 diagnostics: Diagnostics,
63 instances: Instances,
66 computeds: Vec<Computed>,
69 effects: Vec<Effect>,
71 history: History,
73 pending_scroll: Option<Vec<Offset>>,
82 pub root: LayoutNode,
83}
84
85#[derive(Clone, Debug)]
92struct History {
93 entries: Vec<Entry>,
94 at: usize,
95}
96
97#[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 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 fn current(&self) -> &str {
138 &self.entries[self.at].location
139 }
140
141 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 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 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 fn back(&mut self) -> bool {
185 if self.at == 0 {
186 return false;
187 }
188 self.at -= 1;
189 true
190 }
191
192 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
202pub const ROOT_PATH: &str = "/";
204
205#[derive(Clone, Debug, Default, PartialEq)]
215pub struct Diagnostics {
216 pub error: Option<String>,
218 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#[derive(Clone, Debug, PartialEq)]
237pub struct Focus {
238 pub model: String,
239 pub row: Option<String>,
247 pub caret: usize,
248 pub anchor: usize,
249 pub preedit: Option<(usize, usize)>,
253}
254
255impl Focus {
256 pub fn at(model: impl Into<String>, caret: usize) -> Self {
258 Self::at_row(model, None, caret)
259 }
260
261 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 pub fn is(&self, model: &str, row: Option<&str>) -> bool {
269 self.model == model && self.row.as_deref() == row
270 }
271
272 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
282fn apply_focus(node: &mut LayoutNode, focus: Option<&Focus>) {
290 apply_focus_in(node, focus, None);
291}
292
293fn 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 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
321fn 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
338fn collect_warnings() -> Vec<Warning> {
342 let mut warnings = rux_style::take_warnings();
343 warnings.extend(rux_script::take_warnings());
344 warnings
345}
346
347pub fn take_warnings() -> Vec<Warning> {
354 collect_warnings()
355}
356
357pub fn set_stderr_echo(on: bool) {
360 rux_script::set_stderr_echo(on);
361 rux_style::set_stderr_echo(on);
362}
363
364pub 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
383fn 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 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#[derive(Clone, Debug, PartialEq)]
417pub struct LoadError {
418 pub message: String,
419 pub file: Option<PathBuf>,
422 pub line: Option<usize>,
423 pub column: Option<usize>,
424 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 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 pub fn load(path: impl AsRef<Path>) -> Result<Self, String> {
466 Self::load_checked(path).map_err(|e| e.to_string())
467 }
468
469 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 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 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 let (comp_script, _c, _e) = extract_reactives(&comp_script);
501 combined_script.push('\n');
507 combined_script.push_str(&component_functions(&comp_script));
508 components.insert(import.tag, comp_sfc);
509 }
510
511 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 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 pub fn from_source(src: &str) -> Result<Self, String> {
546 Self::from_source_checked(src).map_err(|e| e.to_string())
547 }
548
549 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 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 pub fn engine_mut(&mut self) -> &mut Engine {
600 &mut self.engine
601 }
602
603 pub fn diagnostics(&self) -> &Diagnostics {
605 &self.diagnostics
606 }
607
608 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 pub fn clear_stale(&mut self) {
619 self.diagnostics.stale = false;
620 }
621
622 pub fn replace_with(&mut self, mut fresh: Document) {
625 fresh.viewport = self.viewport;
628 fresh.state = self.state.clone();
629 fresh.rebuild();
630 *self = fresh;
631 }
632
633 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 pub fn interaction(&self) -> &InteractionState {
641 &self.state
642 }
643
644 pub fn set_interaction(&mut self, next: InteractionState) -> bool {
654 if next == self.state {
655 return false;
656 }
657 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 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 self.rebuild();
697 true
698 }
699
700 fn media_state(&self, viewport: Viewport) -> Vec<bool> {
703 let mut out = rux_style::media_matches(&self.sfc.style, viewport);
704 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 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 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 self.diagnostics.warnings = collect_warnings();
757 }
758 }
759
760 #[must_use]
768 pub fn patch(&mut self, changed: &HashSet<String>) -> bool {
769 if changed.is_empty() {
770 return true; }
772 if !self.registry.structural.is_disjoint(changed) {
775 return false;
776 }
777 self.reconcile(changed);
779 self.patch_values(changed);
781 true
782 }
783
784 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 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 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 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 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 fn reconcile(&mut self, changed: &HashSet<String>) {
855 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 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 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 apply_focus_in(live, self.focus.as_ref(), row.as_deref());
920 }
921 }
922 for p in &toggles {
925 if roots.iter().any(|r| p.starts_with(r.as_slice())) {
926 continue; }
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 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 pub fn apply_edit(&mut self, model: &str, value: &str) {
958 self.apply_edit_in(model, None, value);
959 }
960
961 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 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 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 pub fn apply_handler(&mut self, src: &str) -> bool {
1001 self.apply_handler_in(src, None)
1002 }
1003
1004 pub fn apply_handler_in(&mut self, src: &str, instance: Option<&str>) -> bool {
1015 let _ = rux_script::take_emissions();
1020 let _ = rux_script::take_navigations();
1021 let ran = self.dispatch_handler(src, instance, 0);
1022 self.apply_navigations() || ran
1026 }
1027
1028 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 pub fn route(&self) -> &str {
1051 rux_script::split_query(self.history.current()).0
1052 }
1053
1054 pub fn location(&self) -> &str {
1060 self.history.current()
1061 }
1062
1063 pub fn navigate(&mut self, path: &str) -> bool {
1069 if !self.history.push(path) {
1070 return false;
1071 }
1072 self.set_scroll_intent(None);
1074 self.show_current_route()
1075 }
1076
1077 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 pub fn history_position(&self) -> (usize, usize) {
1098 (self.history.at, self.history.entries.len())
1099 }
1100
1101 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 pub fn replace(&mut self, path: &str) -> bool {
1121 if !self.history.replace(path) {
1122 return false;
1123 }
1124 self.set_scroll_intent(None);
1126 self.show_current_route()
1127 }
1128
1129 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 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 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 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 pub fn take_scroll(&mut self) -> Option<Vec<Offset>> {
1174 self.pending_scroll.take()
1175 }
1176
1177 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 _ => Some(Vec::new()),
1191 };
1192 }
1193
1194 fn show_current_route(&mut self) -> bool {
1196 let location = self.history.current().to_string();
1197 let path = rux_script::split_query(&location).0.to_string();
1200 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 self.apply_change(&HashSet::from_iter(
1223 rux_script::ROUTER_SIGNALS.iter().map(|s| s.to_string()),
1224 ));
1225 true
1226 }
1227
1228 fn publish_route(&mut self, location: &str) -> bool {
1239 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 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 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 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 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 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 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 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 self.apply_change_depth(&writes, 1);
1376 }
1377 }
1378
1379 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 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 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 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
1469fn 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
1478fn 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
1495fn 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
1504fn 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
1529fn 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#[derive(Clone, Debug)]
1545struct Computed {
1546 name: String,
1547 expr: String,
1548 deps: HashSet<String>,
1550}
1551
1552#[derive(Clone, Debug)]
1554struct Effect {
1555 body: String,
1556 deps: HashSet<String>,
1559}
1560
1561fn 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 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 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'); j += 1;
1620 if depth <= 0 {
1621 closed = true;
1622 break;
1623 }
1624 }
1625 if closed {
1626 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 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
1648fn 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
1656fn 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
1670fn 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
1707struct Import {
1709 tag: String,
1711 file: String,
1713}
1714
1715fn 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 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; }
1738 }
1739 cleaned.push_str(line);
1740 cleaned.push('\n');
1741 }
1742 (cleaned, imports)
1743}
1744
1745fn 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 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 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 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 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 #[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 #[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 #[test]
1887 fn an_include_from_source_warns_instead_of_failing() {
1888 let _ = take_warnings(); 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 #[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 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 #[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 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 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 #[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 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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 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 #[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 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 #[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=\""a"\" /><card :label=\""b"\" />\
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 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 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 #[test]
2268 fn an_emitted_event_runs_the_callers_handler() {
2269 let mut doc = with_component(
2270 "<template><view>\
2271 <view @tap=\"emit("bumped")\"><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 #[test]
2290 fn an_event_carries_its_payload() {
2291 let mut doc = with_component(
2292 "<template><view>\
2293 <view @tap=\"emit("picked", label)\"><text>pick</text></view>\
2294 </view></template>",
2295 "<template><screen>\
2296 <text>chose {{ chosen }}</text>\
2297 <card :label=\""blue"\" @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 #[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("bumped")\"><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 #[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("bumped")\"><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 #[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("bumped")\"><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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[test]
2541 fn a_handler_can_replace_the_current_page() {
2542 let mut doc = with_router(
2543 "<template><screen>\
2544 <view @tap=\"replace("/settings")\"><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 #[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 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 #[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 doc.navigate("/user/1");
2605 assert_eq!(doc.value_in("can_go_forward", None), "false");
2606 }
2607
2608 #[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 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 #[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("who", #{ id: "7" }))\">\
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 #[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("who", \
2673 #{ id: "7", tab: "posts" }))\">\
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 #[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("who", \
2701 #{ id: "a/b", q: "x&y z" }))\">\
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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 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 #[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 assert_eq!(doc.value_in("count", None), "", "{:?}", text_of(&doc.root));
2938 }
2939
2940 #[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 = "dark"\"><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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 assert!(shown.contains(&"2.5".to_string()), "{shown:?}");
3114 assert!(shown.contains(&"half is 2.5".to_string()), "{shown:?}");
3115 }
3116
3117 #[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 #[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 #[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 #[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 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 #[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 #[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 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 #[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 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 #[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 #[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 #[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 #[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 #[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 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 #[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 assert_eq!(caret_of(&doc.root, "name"), Some(1));
3416 }
3417
3418 #[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 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 &doc.root.children[0].children[0].text.as_ref().unwrap().text
3433 }
3434
3435 #[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 doc.engine_mut().set_string("name", "");
3454 assert!(doc.patch(&changed));
3455 assert_eq!(input_text(&doc), "type…");
3456 }
3457
3458 #[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 #[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 #[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 assert_eq!(caret_of(&doc.root, "name"), Some(1));
3527 }
3528
3529 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 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 assert_eq!(caret_of(&doc.root, "name"), Some(1), "outside caret survived");
3847
3848 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 #[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 #[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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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