1use std::collections::{HashMap, HashSet};
10use std::path::{Path, PathBuf};
11
12use rux_layout::Node as LayoutNode;
13use rux_parser::Sfc;
14use rux_script::{Builder, Engine};
15use rux_style::BindingRegistry;
16pub use rux_reactive::json_string;
19pub use rux_style::{InteractionState, Viewport, Warning};
20
21pub struct Document {
24 sfc: Sfc,
25 components: HashMap<String, Sfc>,
26 engine: Engine,
27 base: PathBuf,
29 focus: Option<Focus>,
32 registry: BindingRegistry,
36 state: InteractionState,
40 viewport: Viewport,
42 diagnostics: Diagnostics,
44 pub root: LayoutNode,
45}
46
47#[derive(Clone, Debug, Default, PartialEq)]
57pub struct Diagnostics {
58 pub error: Option<String>,
60 pub stale: bool,
63 pub warnings: Vec<Warning>,
64}
65
66impl Diagnostics {
67 pub fn is_empty(&self) -> bool {
68 self.error.is_none() && self.warnings.is_empty()
69 }
70}
71
72#[derive(Clone, Debug, PartialEq)]
79pub struct Focus {
80 pub model: String,
81 pub caret: usize,
82 pub anchor: usize,
83 pub preedit: Option<(usize, usize)>,
87}
88
89impl Focus {
90 pub fn at(model: impl Into<String>, caret: usize) -> Self {
92 let model = model.into();
93 Self { model, caret, anchor: caret, preedit: None }
94 }
95
96 pub fn range(&self) -> (usize, usize) {
98 (self.caret.min(self.anchor), self.caret.max(self.anchor))
99 }
100
101 pub fn is_collapsed(&self) -> bool {
102 self.caret == self.anchor
103 }
104}
105
106fn apply_focus(node: &mut LayoutNode, focus: Option<&Focus>) {
114 if node.model.is_some() {
115 if let Some(text) = node.children.first_mut().and_then(|c| c.text.as_mut()) {
116 let mine = focus.filter(|f| node.model.as_deref() == Some(f.model.as_str()));
117 text.caret = mine.map(|f| f.caret.min(text.text.len()));
119 text.selection = mine.filter(|f| !f.is_collapsed()).map(|f| {
120 let (start, end) = f.range();
121 (start.min(text.text.len()), end.min(text.text.len()))
122 });
123 text.preedit = mine.and_then(|f| f.preedit).map(|(start, end)| {
124 (start.min(text.text.len()), end.min(text.text.len()))
125 });
126 }
127 }
128 for child in &mut node.children {
129 apply_focus(child, focus);
130 }
131}
132
133fn divergence(a: Option<&[usize]>, b: Option<&[usize]>) -> Vec<usize> {
144 match (a, b) {
145 (Some(a), Some(b)) => a.iter().zip(b).take_while(|(x, y)| x == y).map(|(x, _)| *x).collect(),
146 _ => Vec::new(),
147 }
148}
149
150fn collect_warnings() -> Vec<Warning> {
154 let mut warnings = rux_style::take_warnings();
155 warnings.extend(rux_script::take_warnings());
156 warnings
157}
158
159pub fn take_warnings() -> Vec<Warning> {
166 collect_warnings()
167}
168
169pub fn set_stderr_echo(on: bool) {
172 rux_script::set_stderr_echo(on);
173 rux_style::set_stderr_echo(on);
174}
175
176pub fn is_entry_point(path: impl AsRef<Path>) -> Option<bool> {
190 let src = std::fs::read_to_string(path.as_ref()).ok()?;
191 let sfc = rux_parser::parse_sfc(&src).ok()?;
192 Some(sfc.template.tag == "screen")
193}
194
195fn resolve_images(node: &mut LayoutNode, base: &Path) {
199 if let Some(img) = &mut node.image {
200 if !img.src.is_empty() {
201 let path = base.join(&img.src);
202 if let Ok((w, h)) = image::image_dimensions(&path) {
203 img.intrinsic = (w as f32, h as f32);
204 } else {
205 eprintln!("rux: cannot read image {}", path.display());
206 }
207 img.src = path.to_string_lossy().into_owned();
208 }
209 }
210 if let Some(rux_layout::Background::Image(src)) = &mut node.style.background {
213 if !src.is_empty() {
214 *src = base.join(&*src).to_string_lossy().into_owned();
215 }
216 }
217 for child in &mut node.children {
218 resolve_images(child, base);
219 }
220}
221
222#[derive(Clone, Debug, PartialEq)]
229pub struct LoadError {
230 pub message: String,
231 pub file: Option<PathBuf>,
234 pub line: Option<usize>,
235 pub column: Option<usize>,
236 parse: bool,
239}
240
241impl LoadError {
242 fn plain(message: String) -> Self {
243 Self { message, file: None, line: None, column: None, parse: false }
244 }
245
246 fn parse(err: rux_parser::ParseError, file: Option<&Path>) -> Self {
249 Self {
250 message: err.message,
251 file: file.map(Path::to_path_buf),
252 line: err.line,
253 column: err.column,
254 parse: true,
255 }
256 }
257}
258
259impl std::fmt::Display for LoadError {
260 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
261 if !self.parse {
262 return write!(f, "{}", self.message);
263 }
264 match (self.line, self.column) {
265 (Some(l), Some(c)) => {
266 write!(f, "parse error at line {l}, column {c}: {}", self.message)
267 }
268 _ => write!(f, "parse error: {}", self.message),
269 }
270 }
271}
272
273impl std::error::Error for LoadError {}
274
275impl Document {
276 pub fn load(path: impl AsRef<Path>) -> Result<Self, String> {
278 Self::load_checked(path).map_err(|e| e.to_string())
279 }
280
281 pub fn load_checked(path: impl AsRef<Path>) -> Result<Self, LoadError> {
284 let path = path.as_ref();
285 let src = std::fs::read_to_string(path)
286 .map_err(|e| LoadError::plain(format!("reading {}: {e}", path.display())))?;
287 let sfc = rux_parser::parse_sfc(&src).map_err(|e| LoadError::parse(e, Some(path)))?;
288
289 let base = path.parent().unwrap_or_else(|| Path::new("."));
291 let (main_script, imports) = extract_imports(&sfc.script);
292
293 let mut components = HashMap::new();
294 let mut combined_script = main_script;
295 for import in imports {
296 let comp_path = base.join(&import.file);
297 let comp_src = std::fs::read_to_string(&comp_path).map_err(|e| {
298 LoadError::plain(format!("reading component {}: {e}", comp_path.display()))
299 })?;
300 let comp_sfc =
301 rux_parser::parse_sfc(&comp_src).map_err(|e| LoadError::parse(e, Some(&comp_path)))?;
302 let (comp_script, _nested) = extract_imports(&comp_sfc.script);
303 combined_script.push('\n');
305 combined_script.push_str(&comp_script);
306 components.insert(import.tag, comp_sfc);
307 }
308
309 let mut engine = build_engine(&combined_script).map_err(LoadError::plain)?;
310 let (mut root, registry) = rux_style::build_styled_tree_tracked(&sfc, &components, &mut engine)
311 .map_err(LoadError::plain)?;
312 resolve_images(&mut root, base);
313 Ok(Self {
314 sfc,
315 components,
316 engine,
317 base: base.to_path_buf(),
318 focus: None,
319 registry,
320 state: InteractionState::default(),
321 viewport: Viewport::default(),
322 diagnostics: Diagnostics {
324 warnings: collect_warnings(),
325 ..Diagnostics::default()
326 },
327 root,
328 })
329 }
330
331 pub fn from_source(src: &str) -> Result<Self, String> {
333 Self::from_source_checked(src).map_err(|e| e.to_string())
334 }
335
336 pub fn from_source_checked(src: &str) -> Result<Self, LoadError> {
342 let sfc = rux_parser::parse_sfc(src).map_err(|e| LoadError::parse(e, None))?;
343 let (main_script, _imports) = extract_imports(&sfc.script);
344 let mut engine = build_engine(&main_script).map_err(LoadError::plain)?;
345 let (mut root, registry) =
346 rux_style::build_styled_tree_tracked(&sfc, &HashMap::new(), &mut engine)
347 .map_err(LoadError::plain)?;
348 let base = PathBuf::from(".");
349 resolve_images(&mut root, &base);
350 Ok(Self {
351 sfc,
352 components: HashMap::new(),
353 engine,
354 base,
355 focus: None,
356 registry,
357 state: InteractionState::default(),
358 viewport: Viewport::default(),
359 diagnostics: Diagnostics {
360 warnings: collect_warnings(),
361 ..Diagnostics::default()
362 },
363 root,
364 })
365 }
366
367 pub fn engine_mut(&mut self) -> &mut Engine {
369 &mut self.engine
370 }
371
372 pub fn diagnostics(&self) -> &Diagnostics {
374 &self.diagnostics
375 }
376
377 pub fn set_load_error(&mut self, error: impl Into<String>) {
381 self.diagnostics.error = Some(error.into());
382 self.diagnostics.stale = true;
383 }
384
385 pub fn clear_stale(&mut self) {
388 self.diagnostics.stale = false;
389 }
390
391 pub fn replace_with(&mut self, mut fresh: Document) {
394 fresh.viewport = self.viewport;
397 fresh.state = self.state.clone();
398 fresh.rebuild();
399 *self = fresh;
400 }
401
402 pub fn set_focus(&mut self, focus: Option<Focus>) {
404 self.focus = focus;
405 apply_focus(&mut self.root, self.focus.as_ref());
406 }
407
408 pub fn interaction(&self) -> &InteractionState {
410 &self.state
411 }
412
413 pub fn set_interaction(&mut self, next: InteractionState) -> bool {
423 if next == self.state {
424 return false;
425 }
426 let mut roots: Vec<Vec<usize>> = Vec::new();
431 if next.focused_model == self.state.focused_model {
432 roots.push(divergence(self.state.hovered.as_deref(), next.hovered.as_deref()));
433 roots.push(divergence(self.state.active.as_deref(), next.active.as_deref()));
434 } else {
435 roots.push(Vec::new());
436 }
437 self.state = next;
438 self.restyle(&roots);
439 true
440 }
441
442 pub fn set_viewport(&mut self, viewport: Viewport) -> bool {
452 if viewport == self.viewport {
453 return false;
454 }
455 let before = self.media_state(self.viewport);
456 let after = self.media_state(viewport);
457 self.viewport = viewport;
458 if before == after {
459 return false;
460 }
461 self.rebuild();
464 true
465 }
466
467 fn media_state(&self, viewport: Viewport) -> Vec<bool> {
470 let mut out = rux_style::media_matches(&self.sfc.style, viewport);
471 let mut tags: Vec<&String> = self.components.keys().collect();
473 tags.sort();
474 for tag in tags {
475 out.extend(rux_style::media_matches(&self.components[tag].style, viewport));
476 }
477 out
478 }
479
480 fn restyle(&mut self, roots: &[Vec<usize>]) {
483 let Ok((mut fresh_root, fresh_reg)) = rux_style::build_styled_tree_stateful(
484 &self.sfc,
485 &self.components,
486 &mut self.engine,
487 &self.state,
488 self.viewport,
489 ) else {
490 return;
491 };
492 resolve_images(&mut fresh_root, &self.base);
493 for path in roots {
494 let Some(fresh) = node_at(&fresh_root, path) else { continue };
495 let fresh_node = fresh.clone();
496 if let Some(live) = node_at_mut(&mut self.root, path) {
497 *live = fresh_node;
498 apply_focus(live, self.focus.as_ref());
499 }
500 }
501 self.registry = fresh_reg;
502 }
503
504 pub fn rebuild(&mut self) {
506 if let Ok((mut root, registry)) = rux_style::build_styled_tree_stateful(
507 &self.sfc,
508 &self.components,
509 &mut self.engine,
510 &self.state,
511 self.viewport,
512 ) {
513 resolve_images(&mut root, &self.base);
514 apply_focus(&mut root, self.focus.as_ref());
515 self.registry = registry;
516 self.root = root;
517 self.diagnostics.warnings = collect_warnings();
521 }
522 }
523
524 #[must_use]
532 pub fn patch(&mut self, changed: &HashSet<String>) -> bool {
533 if changed.is_empty() {
534 return true; }
536 if !self.registry.structural.is_disjoint(changed) {
539 return false;
540 }
541 self.reconcile(changed);
543 self.patch_values(changed);
545 true
546 }
547
548 fn patch_values(&mut self, changed: &HashSet<String>) {
551 for binding in &self.registry.text {
552 if binding.deps.is_disjoint(changed) {
553 continue;
554 }
555 let text = rux_style::eval_text_binding(binding, &mut self.engine);
556 if let Some(node) = node_at_mut(&mut self.root, &binding.path) {
557 if let Some(content) = node.text.as_mut() {
558 content.text = text;
559 }
560 }
561 }
562 for binding in &self.registry.value {
566 if binding.deps.is_disjoint(changed) {
567 continue;
568 }
569 let (text, color) = rux_style::eval_value_binding(binding, &mut self.engine);
570 if let Some(node) = node_at_mut(&mut self.root, &binding.path) {
571 if let Some(content) = node.children.first_mut().and_then(|c| c.text.as_mut()) {
572 content.text = text;
573 content.color = color;
574 }
575 }
576 }
577 for binding in &self.registry.show {
579 if binding.deps.is_disjoint(changed) {
580 continue;
581 }
582 let visible = self.engine.eval_bool(&binding.cond, &binding.locals);
583 if let Some(node) = node_at_mut(&mut self.root, &binding.path) {
584 node.hidden = !visible;
585 }
586 }
587 for binding in &self.registry.src {
589 if binding.deps.is_disjoint(changed) {
590 continue;
591 }
592 let raw = rux_style::eval_src_binding(binding, &mut self.engine);
593 if let Some(node) = node_at_mut(&mut self.root, &binding.path) {
594 if let Some(img) = node.image.as_mut() {
595 img.src = raw;
596 }
597 resolve_images(node, &self.base);
598 }
599 }
600 for binding in &self.registry.options {
602 if binding.deps.is_disjoint(changed) {
603 continue;
604 }
605 let opts = rux_style::eval_options_binding(binding, &mut self.engine);
606 if let Some(node) = node_at_mut(&mut self.root, &binding.path) {
607 node.options = Some(opts);
608 }
609 }
610 }
611
612 fn reconcile(&mut self, changed: &HashSet<String>) {
619 let mut affected: Vec<Vec<usize>> = self
622 .registry
623 .structural_parents
624 .iter()
625 .filter(|p| !p.deps.is_disjoint(changed))
626 .map(|p| p.tree_path.clone())
627 .collect();
628 let toggles: Vec<Vec<usize>> = self
629 .registry
630 .toggles
631 .iter()
632 .filter(|t| !t.deps.is_disjoint(changed))
633 .map(|t| t.path.clone())
634 .collect();
635 let mut node_splices: Vec<Vec<usize>> = self
638 .registry
639 .components
640 .iter()
641 .filter(|c| !c.deps.is_disjoint(changed))
642 .map(|c| c.path.clone())
643 .collect();
644 node_splices.extend(
645 self.registry
646 .styled
647 .iter()
648 .filter(|s| !s.deps.is_disjoint(changed))
649 .map(|s| s.path.clone()),
650 );
651 if affected.is_empty() && toggles.is_empty() && node_splices.is_empty() {
652 return;
653 }
654 affected.sort_by_key(Vec::len);
655 let mut roots: Vec<Vec<usize>> = Vec::new();
656 for p in affected {
657 if !roots.iter().any(|r| p.starts_with(r.as_slice())) {
658 roots.push(p);
659 }
660 }
661
662 let Ok((mut fresh_root, fresh_reg)) = rux_style::build_styled_tree_stateful(
663 &self.sfc,
664 &self.components,
665 &mut self.engine,
666 &self.state,
667 self.viewport,
668 ) else {
669 return;
670 };
671 resolve_images(&mut fresh_root, &self.base);
672 for p in &roots {
674 let Some(fresh) = node_at(&fresh_root, p) else { continue };
675 let fresh_children = fresh.children.clone();
676 if let Some(live) = node_at_mut(&mut self.root, p) {
677 live.children = fresh_children;
678 apply_focus(live, self.focus.as_ref());
680 }
681 }
682 for p in &toggles {
685 if roots.iter().any(|r| p.starts_with(r.as_slice())) {
686 continue; }
688 if let Some(fresh) = node_at(&fresh_root, p) {
689 let fresh_node = fresh.clone();
690 if let Some(live) = node_at_mut(&mut self.root, p) {
691 *live = fresh_node;
692 }
693 }
694 }
695 for p in &node_splices {
698 if roots.iter().any(|r| p.starts_with(r.as_slice())) {
699 continue;
700 }
701 if let Some(fresh) = node_at(&fresh_root, p) {
702 let fresh_node = fresh.clone();
703 if let Some(live) = node_at_mut(&mut self.root, p) {
704 *live = fresh_node;
705 apply_focus(live, self.focus.as_ref());
706 }
707 }
708 }
709 self.registry = fresh_reg;
710 }
711
712 pub fn apply_edit(&mut self, model: &str, value: &str) {
717 self.engine.set_string(model, value);
718 let changed: HashSet<String> = std::iter::once(model.to_string()).collect();
719 self.apply_change(&changed);
720 }
721
722 pub fn apply_handler(&mut self, src: &str) -> bool {
727 let changed = self.engine.run_handler_tracked(src);
728 if changed.is_empty() {
729 return false;
730 }
731 self.apply_change(&changed);
732 true
733 }
734
735 fn apply_change(&mut self, changed: &HashSet<String>) {
739 let patched = self.patch(changed);
740 if !patched {
741 self.rebuild();
742 }
743 if std::env::var_os("RUX_TRACE").is_some() {
744 let mut names: Vec<&str> = changed.iter().map(String::as_str).collect();
745 names.sort_unstable();
746 eprintln!(
747 "rux: change {names:?} → {}",
748 if patched { "patched in place (no rebuild)" } else { "rebuilt (structural)" }
749 );
750 }
751 }
752}
753
754fn node_at<'a>(root: &'a LayoutNode, path: &[usize]) -> Option<&'a LayoutNode> {
756 let mut node = root;
757 for &i in path {
758 node = node.children.get(i)?;
759 }
760 Some(node)
761}
762
763fn node_at_mut<'a>(root: &'a mut LayoutNode, path: &[usize]) -> Option<&'a mut LayoutNode> {
765 let mut node = root;
766 for &i in path {
767 node = node.children.get_mut(i)?;
768 }
769 Some(node)
770}
771
772struct Import {
774 tag: String,
776 file: String,
778}
779
780fn extract_imports(script: &str) -> (String, Vec<Import>) {
783 let mut cleaned = String::new();
784 let mut imports = Vec::new();
785
786 for line in script.lines() {
787 let trimmed = line.trim();
788 if let Some(rest) = trimmed.strip_prefix("use ") {
789 if let Some(path) = rest.strip_suffix(';').map(str::trim).filter(|p| {
792 !p.is_empty() && !p.contains(char::is_whitespace) && !p.contains(';')
793 }) {
794 let segments: Vec<&str> = path.split("::").collect();
795 let file = format!("{}.rux", segments.join("/"));
796 let tag = segments
797 .last()
798 .map(|s| s.replace('_', "-"))
799 .unwrap_or_default();
800 imports.push(Import { tag, file });
801 continue; }
803 }
804 cleaned.push_str(line);
805 cleaned.push('\n');
806 }
807 (cleaned, imports)
808}
809
810fn build_engine(script: &str) -> Result<Engine, String> {
813 let mut builder = Builder::new();
814 builder.host_number("full", || 100.0);
815 builder.build(script)
816}
817
818#[cfg(test)]
819mod tests {
820 use super::*;
821
822 fn find_text(node: &LayoutNode, needle: &str) -> bool {
823 if let Some(t) = &node.text {
824 if t.text.contains(needle) {
825 return true;
826 }
827 }
828 node.children.iter().any(|c| find_text(c, needle))
829 }
830
831 #[test]
832 fn loads_document_and_expands_imported_component() {
833 use std::fs;
836 let dir = std::env::temp_dir().join(format!("rux_test_{}", std::process::id()));
837 let comp_dir = dir.join("components");
838 fs::create_dir_all(&comp_dir).unwrap();
839 fs::write(
840 comp_dir.join("stat.rux"),
841 r#"<template><view><text>{{ label }}: {{ value }}</text></view></template>"#,
842 )
843 .unwrap();
844 fs::write(
845 dir.join("app.rux"),
846 "<template><screen><stat :label=\"title\" :value=\"n\" /></screen></template>\n\
847 <script>\n\
848 use components::stat;\n\
849 let title = signal(\"Battery\");\n\
850 let n = signal(82);\n\
851 </script>",
852 )
853 .unwrap();
854
855 let doc = Document::load(dir.join("app.rux")).expect("load app");
856 assert!(find_text(&doc.root, "Battery"), "component label prop rendered");
857 assert!(find_text(&doc.root, "82"), "component value prop rendered");
858
859 let _ = fs::remove_dir_all(&dir);
860 }
861
862 #[test]
866 fn resolves_image_src_and_intrinsic_size() {
867 use std::fs;
868 let dir = std::env::temp_dir().join(format!("rux_img_{}", std::process::id()));
869 fs::create_dir_all(dir.join("assets")).unwrap();
870
871 let png = dir.join("assets/dot.png");
873 image::RgbaImage::from_pixel(2, 1, image::Rgba([255, 0, 0, 255]))
874 .save(&png)
875 .unwrap();
876 fs::write(
877 dir.join("app.rux"),
878 r#"<template><screen><image src="assets/dot.png" /></screen></template>"#,
879 )
880 .unwrap();
881
882 let doc = Document::load(dir.join("app.rux")).expect("load app");
883 let img = doc.root.children[0].image.as_ref().expect("image node");
884 assert_eq!(img.intrinsic, (2.0, 1.0));
885 assert_eq!(Path::new(&img.src), png, "src resolved against the .rux dir");
886
887 let _ = fs::remove_dir_all(&dir);
888 }
889
890 fn caret_of(node: &LayoutNode, model: &str) -> Option<usize> {
891 if node.model.as_deref() == Some(model) {
892 return node.children.first()?.text.as_ref()?.caret;
893 }
894 node.children.iter().find_map(|c| caret_of(c, model))
895 }
896
897 #[test]
901 fn focus_moves_the_caret_out_of_the_old_input() {
902 let mut doc = Document::from_source(
903 "<template><screen> <input r-model=\"name\" /><input r-model=\"city\" /> </screen></template>
904 <script>let name = signal(\"abc\"); let city = signal(\"xyz\");</script>",
905 )
906 .expect("load");
907
908 doc.set_focus(Some(Focus::at("name", 2)));
909 assert_eq!(caret_of(&doc.root, "name"), Some(2));
910 assert_eq!(caret_of(&doc.root, "city"), None);
911
912 doc.set_focus(Some(Focus::at("city", 1)));
915 assert_eq!(caret_of(&doc.root, "name"), None, "old input kept its caret");
916 assert_eq!(caret_of(&doc.root, "city"), Some(1));
917
918 doc.set_focus(None);
920 assert_eq!(caret_of(&doc.root, "name"), None);
921 assert_eq!(caret_of(&doc.root, "city"), None);
922 }
923
924 fn selection_of(node: &LayoutNode, model: &str) -> Option<(usize, usize)> {
925 if node.model.as_deref() == Some(model) {
926 return node.children.first()?.text.as_ref()?.selection;
927 }
928 node.children.iter().find_map(|c| selection_of(c, model))
929 }
930
931 fn preedit_of(node: &LayoutNode, model: &str) -> Option<(usize, usize)> {
932 if node.model.as_deref() == Some(model) {
933 return node.children.first()?.text.as_ref()?.preedit;
934 }
935 node.children.iter().find_map(|c| preedit_of(c, model))
936 }
937
938 fn two_inputs() -> Document {
939 Document::from_source(
940 "<template><screen> <input r-model=\"name\" /><input r-model=\"city\" /> </screen></template>
941 <script>let name = signal(\"abc\"); let city = signal(\"xyz\");</script>",
942 )
943 .expect("load")
944 }
945
946 #[test]
949 fn selection_paints_only_in_the_focused_input() {
950 let mut doc = two_inputs();
951
952 doc.set_focus(Some(Focus { model: "name".into(), caret: 3, anchor: 1, preedit: None }));
953 assert_eq!(selection_of(&doc.root, "name"), Some((1, 3)));
954 assert_eq!(selection_of(&doc.root, "city"), None);
955
956 doc.set_focus(Some(Focus { model: "name".into(), caret: 1, anchor: 3, preedit: None }));
958 assert_eq!(selection_of(&doc.root, "name"), Some((1, 3)));
959 }
960
961 #[test]
965 fn focus_moves_the_selection_out_of_the_old_input() {
966 let mut doc = two_inputs();
967
968 doc.set_focus(Some(Focus { model: "name".into(), caret: 3, anchor: 0, preedit: None }));
969 assert_eq!(selection_of(&doc.root, "name"), Some((0, 3)));
970
971 doc.set_focus(Some(Focus { model: "city".into(), caret: 2, anchor: 0, preedit: None }));
972 assert_eq!(selection_of(&doc.root, "name"), None, "old input kept its selection");
973 assert_eq!(selection_of(&doc.root, "city"), Some((0, 2)));
974
975 doc.set_focus(None);
976 assert_eq!(selection_of(&doc.root, "name"), None);
977 assert_eq!(selection_of(&doc.root, "city"), None);
978 }
979
980 #[test]
983 fn a_collapsed_selection_is_none() {
984 let mut doc = two_inputs();
985 doc.set_focus(Some(Focus::at("name", 2)));
986 assert_eq!(caret_of(&doc.root, "name"), Some(2));
987 assert_eq!(selection_of(&doc.root, "name"), None);
988 }
989
990 #[test]
993 fn selection_survives_a_rebuild() {
994 let mut doc = two_inputs();
995 doc.set_focus(Some(Focus { model: "name".into(), caret: 3, anchor: 1, preedit: None }));
996 doc.rebuild();
997 assert_eq!(selection_of(&doc.root, "name"), Some((1, 3)));
998 assert_eq!(caret_of(&doc.root, "name"), Some(3));
999 assert_eq!(selection_of(&doc.root, "city"), None);
1000 }
1001
1002 #[test]
1007 fn a_composition_marks_only_the_focused_input() {
1008 let mut doc = two_inputs();
1009
1010 doc.set_focus(Some(Focus {
1011 model: "name".into(),
1012 caret: 3,
1013 anchor: 3,
1014 preedit: Some((1, 3)),
1015 }));
1016 assert_eq!(preedit_of(&doc.root, "name"), Some((1, 3)));
1017 assert_eq!(preedit_of(&doc.root, "city"), None);
1018
1019 doc.set_focus(Some(Focus::at("city", 1)));
1020 assert_eq!(preedit_of(&doc.root, "name"), None, "old input kept its composition");
1021 assert_eq!(preedit_of(&doc.root, "city"), None);
1022 }
1023
1024 #[test]
1028 fn a_composition_survives_a_rebuild() {
1029 let mut doc = two_inputs();
1030 doc.set_focus(Some(Focus {
1031 model: "name".into(),
1032 caret: 2,
1033 anchor: 2,
1034 preedit: Some((0, 2)),
1035 }));
1036 doc.rebuild();
1037 assert_eq!(preedit_of(&doc.root, "name"), Some((0, 2)));
1038 }
1039
1040 fn patch_doc() -> Document {
1041 Document::from_source(
1044 "<template><screen><text class=\"c\">{{ n }}</text><input r-model=\"name\" /></screen></template>
1045 <script>let n = signal(0); let name = signal(\"hi\");</script>",
1046 )
1047 .expect("load")
1048 }
1049
1050 #[test]
1053 fn patch_updates_text_and_preserves_caret() {
1054 let mut doc = patch_doc();
1055 doc.set_focus(Some(Focus::at("name", 1)));
1056
1057 let changed = doc.engine_mut().run_handler_tracked("n = n + 1");
1058 assert!(doc.patch(&changed), "a display-only change patches in place");
1059 assert_eq!(doc.root.children[0].text.as_ref().unwrap().text, "1");
1060 assert_eq!(caret_of(&doc.root, "name"), Some(1));
1062 }
1063
1064 #[test]
1067 fn patch_updates_input_value_in_place() {
1068 let mut doc = patch_doc();
1069 let changed = doc.engine_mut().run_handler_tracked("name = \"yo\"");
1070 assert!(doc.patch(&changed), "an input value change patches in place");
1071 assert_eq!(doc.root.children[1].children[0].text.as_ref().unwrap().text, "yo");
1073 assert_eq!(doc.root.children[0].text.as_ref().unwrap().text, "0");
1074 }
1075
1076 fn input_text(doc: &Document) -> &str {
1077 &doc.root.children[0].children[0].text.as_ref().unwrap().text
1079 }
1080
1081 #[test]
1084 fn typing_patches_the_input_value_in_place() {
1085 let mut doc = Document::from_source(
1086 "<template><screen><input r-model=\"name\" placeholder=\"type…\" /></screen></template>
1087 <script>let name = signal(\"ab\");</script>",
1088 )
1089 .expect("load");
1090 doc.set_focus(Some(Focus::at("name", 2)));
1091 assert_eq!(input_text(&doc), "ab");
1092
1093 doc.engine_mut().set_string("name", "abc");
1094 let changed: HashSet<String> = std::iter::once("name".to_string()).collect();
1095 assert!(doc.patch(&changed), "value-only input edit patches in place");
1096 assert_eq!(input_text(&doc), "abc");
1097
1098 doc.engine_mut().set_string("name", "");
1100 assert!(doc.patch(&changed));
1101 assert_eq!(input_text(&doc), "type…");
1102 }
1103
1104 #[test]
1106 fn options_patch_in_place() {
1107 let mut doc = Document::from_source(
1108 "<template><screen><input type=\"select\" r-model=\"fruit\" :options=\"fruits\" /></screen></template>
1109 <script>let fruit = signal(\"a\"); let fruits = signal([\"a\", \"b\"]);</script>",
1110 )
1111 .expect("load");
1112 assert_eq!(doc.root.children[0].options.as_ref().unwrap().len(), 2);
1113
1114 let changed = doc.engine_mut().run_handler_tracked("fruits = [\"a\", \"b\", \"c\"]");
1115 assert!(doc.patch(&changed), "an :options change patches in place");
1116 assert_eq!(doc.root.children[0].options.as_ref().unwrap().len(), 3, "list grew in place");
1117 }
1118
1119 #[test]
1122 fn component_prop_reconciles_in_place() {
1123 use std::fs;
1124 let dir = std::env::temp_dir().join(format!("rux_prop_{}", std::process::id()));
1125 let comp_dir = dir.join("components");
1126 fs::create_dir_all(&comp_dir).unwrap();
1127 fs::write(
1128 comp_dir.join("stat.rux"),
1129 r#"<template><view><text>{{ value }}</text></view></template>"#,
1130 )
1131 .unwrap();
1132 fs::write(
1133 dir.join("app.rux"),
1134 "<template><screen><stat :value=\"n\" /></screen></template>\n\
1135 <script>\nuse components::stat;\nlet n = signal(1);\n</script>",
1136 )
1137 .unwrap();
1138
1139 let mut doc = Document::load(dir.join("app.rux")).expect("load app");
1140 assert!(find_text(&doc.root, "1"), "prop starts at 1");
1141 let changed = doc.engine_mut().run_handler_tracked("n = 2");
1142 assert!(doc.patch(&changed), "a component prop change reconciles in place");
1143 assert!(find_text(&doc.root, "2"), "component re-expanded with the new prop");
1144
1145 let _ = fs::remove_dir_all(&dir);
1146 }
1147
1148 #[test]
1152 fn toggle_reconciles_and_preserves_an_outside_caret() {
1153 let mut doc = Document::from_source(
1154 "<template><screen>\
1155 <input r-model=\"name\" />\
1156 <input type=\"checkbox\" class=\"box\" r-model=\"on\" />\
1157 </screen></template>
1158 <style>.box { background: #000000; } .box.checked { background: #00ff00; }</style>
1159 <script>let name = signal(\"ab\"); let on = signal(false);</script>",
1160 )
1161 .expect("load");
1162 doc.set_focus(Some(Focus::at("name", 1)));
1163 let green = |n: &LayoutNode| matches!(&n.style.background, Some(rux_layout::Background::Color(c)) if c.g == 1.0);
1164 assert!(!green(&doc.root.children[1]), "unchecked → not green");
1165
1166 let changed = doc.engine_mut().run_handler_tracked("on = true");
1167 assert!(doc.patch(&changed), "a toggle reconciles in place");
1168 assert!(green(&doc.root.children[1]), "checked → .box.checked (green) applied");
1169 assert!(doc.root.children[1].children.len() == 1, "checkmark added");
1170 assert_eq!(caret_of(&doc.root, "name"), Some(1));
1173 }
1174
1175 #[test]
1182 fn warnings_are_collected_for_the_overlay() {
1183 let doc = Document::from_source(
1184 "<template><screen><view class=\"card\" /></screen></template>
1185 <style>.card { filter: blur(2px); background: var(--nope); }</style>",
1186 )
1187 .expect("load");
1188 let warnings = &doc.diagnostics().warnings;
1189 assert!(
1190 warnings.iter().any(|w| w.message.contains("filter")),
1191 "unhonored property reported: {warnings:?}"
1192 );
1193 assert!(
1194 warnings.iter().any(|w| w.message.contains("--nope")),
1195 "undefined var reported: {warnings:?}"
1196 );
1197 assert!(doc.diagnostics().error.is_none(), "the document still built");
1198 }
1199
1200 #[test]
1202 fn a_clean_document_has_no_diagnostics() {
1203 let doc = Document::from_source(
1204 "<template><screen><view class=\"card\" /></screen></template>
1205 <style>.card { background: #313244; }</style>",
1206 )
1207 .expect("load");
1208 assert!(doc.diagnostics().is_empty(), "{:?}", doc.diagnostics());
1209 }
1210
1211 #[test]
1214 fn a_failed_reload_keeps_the_last_good_tree() {
1215 let mut doc = Document::from_source(
1216 "<template><screen><text>hello</text></screen></template>",
1217 )
1218 .expect("load");
1219 let before = doc.root.children.len();
1220
1221 doc.set_load_error("parse error at line 6, column 13: mismatched closing tag");
1222 assert_eq!(doc.root.children.len(), before, "the tree is untouched");
1223 assert!(doc.diagnostics().error.is_some());
1224 assert!(doc.diagnostics().stale, "what's on screen predates the error");
1225 }
1226
1227 #[test]
1229 fn a_successful_reload_clears_the_error() {
1230 let mut doc = Document::from_source("<template><screen><text>old</text></screen></template>")
1231 .expect("load");
1232 doc.set_load_error("something was wrong");
1233
1234 let fresh = Document::from_source("<template><screen><text>new</text></screen></template>")
1235 .expect("load");
1236 doc.replace_with(fresh);
1237 assert!(doc.diagnostics().error.is_none(), "error cleared");
1238 assert!(!doc.diagnostics().stale);
1239 assert_eq!(doc.root.children[0].text.as_ref().unwrap().text, "new");
1240 }
1241
1242 #[test]
1245 fn a_reload_keeps_the_window_viewport() {
1246 let mut doc = media_doc();
1247 doc.set_viewport(Viewport { width: 480.0, height: 800.0 });
1248 assert!(is_red(&doc.root.children[0]));
1249
1250 let fresh = Document::from_source(
1251 "<template><screen><view class=\"card\" /></screen></template>
1252 <style>
1253 .card { background: #00ff00; }
1254 @media (max-width: 600px) { .card { background: #ff0000; } }
1255 </style>",
1256 )
1257 .expect("load");
1258 doc.replace_with(fresh);
1259 assert!(
1260 is_red(&doc.root.children[0]),
1261 "still narrow after the reload, so the @media rule still applies"
1262 );
1263 }
1264
1265 fn media_doc() -> Document {
1268 Document::from_source(
1269 "<template><screen><view class=\"card\" /></screen></template>
1270 <style>
1271 .card { background: #00ff00; }
1272 @media (max-width: 600px) { .card { background: #ff0000; } }
1273 </style>",
1274 )
1275 .expect("load")
1276 }
1277
1278 fn is_red(n: &LayoutNode) -> bool {
1279 matches!(&n.style.background, Some(rux_layout::Background::Color(c)) if c.r == 1.0 && c.g == 0.0)
1280 }
1281
1282 #[test]
1284 fn resize_across_a_breakpoint_restyles() {
1285 let mut doc = media_doc();
1286 assert!(!is_red(&doc.root.children[0]), "the default viewport is wide");
1287
1288 assert!(doc.set_viewport(Viewport { width: 480.0, height: 800.0 }), "breakpoint crossed");
1289 assert!(is_red(&doc.root.children[0]), "narrow → the @media rule applies");
1290
1291 assert!(doc.set_viewport(Viewport { width: 1000.0, height: 800.0 }), "crossed back");
1292 assert!(!is_red(&doc.root.children[0]), "wide again → the base rule");
1293 }
1294
1295 #[test]
1298 fn resize_within_a_breakpoint_is_not_a_change() {
1299 let mut doc = media_doc();
1300 doc.set_viewport(Viewport { width: 400.0, height: 800.0 });
1301 assert!(
1302 !doc.set_viewport(Viewport { width: 500.0, height: 800.0 }),
1303 "still under 600px, nothing to redo"
1304 );
1305 assert!(is_red(&doc.root.children[0]), "and the styling is still correct");
1306 }
1307
1308 #[test]
1310 fn resize_does_nothing_without_media_queries() {
1311 let mut doc = Document::from_source(
1312 "<template><screen><view class=\"card\" /></screen></template>
1313 <style>.card { background: #00ff00; }</style>",
1314 )
1315 .expect("load");
1316 assert!(!doc.set_viewport(Viewport { width: 320.0, height: 480.0 }));
1317 assert!(!doc.set_viewport(Viewport { width: 1600.0, height: 900.0 }));
1318 }
1319
1320 fn hover_doc() -> Document {
1323 Document::from_source(
1324 "<template><screen>\
1325 <view class=\"card\"><text>one</text></view>\
1326 <view class=\"card\"><input r-model=\"name\" /></view>\
1327 </screen></template>
1328 <style>.card { background: #000000; } .card:hover { background: #00ff00; }</style>
1329 <script>let name = signal(\"ab\");</script>",
1330 )
1331 .expect("load")
1332 }
1333
1334 fn hovering(path: &[usize]) -> InteractionState {
1335 InteractionState { hovered: Some(path.to_vec()), ..InteractionState::default() }
1336 }
1337
1338 fn is_green(n: &LayoutNode) -> bool {
1339 matches!(&n.style.background, Some(rux_layout::Background::Color(c)) if c.g == 1.0)
1340 }
1341
1342 #[test]
1345 fn hover_restyles_only_the_hovered_element() {
1346 let mut doc = hover_doc();
1347 assert!(!is_green(&doc.root.children[0]), "nothing hovered → no green");
1348
1349 assert!(doc.set_interaction(hovering(&[0])), "entering a card restyles");
1350 assert!(is_green(&doc.root.children[0]), "hovered card is green");
1351 assert!(!is_green(&doc.root.children[1]), "its sibling is NOT");
1352
1353 assert!(doc.set_interaction(InteractionState::default()), "leaving restyles");
1354 assert!(!is_green(&doc.root.children[0]), "hover ends → back to black");
1355 }
1356
1357 #[test]
1360 fn same_hover_target_is_not_a_change() {
1361 let mut doc = hover_doc();
1362 assert!(doc.set_interaction(hovering(&[0])));
1363 assert!(
1364 !doc.set_interaction(hovering(&[0])),
1365 "re-reporting the same target does no work"
1366 );
1367 }
1368
1369 #[test]
1372 fn hover_change_preserves_a_caret_elsewhere() {
1373 let mut doc = hover_doc();
1374 doc.set_focus(Some(Focus::at("name", 1)));
1375 assert_eq!(caret_of(&doc.root, "name"), Some(1));
1376
1377 assert!(doc.set_interaction(hovering(&[0])));
1378 assert_eq!(caret_of(&doc.root, "name"), Some(1), "caret survives a hover change");
1379 assert!(is_green(&doc.root.children[0]));
1380 }
1381
1382 #[test]
1387 fn clearing_pointer_state_unstyles_the_hovered_element() {
1388 let mut doc = hover_doc();
1389 doc.set_interaction(InteractionState {
1390 hovered: Some(vec![0]),
1391 active: Some(vec![0]),
1392 ..InteractionState::default()
1393 });
1394 assert!(is_green(&doc.root.children[0]));
1395
1396 assert!(doc.set_interaction(InteractionState::default()), "clearing restyles");
1397 assert!(!is_green(&doc.root.children[0]), "nothing is hovered any more");
1398 }
1399
1400 #[test]
1403 fn hover_applies_to_the_ancestor_chain() {
1404 let mut doc = Document::from_source(
1405 "<template><screen>\
1406 <view class=\"card\"><view class=\"inner\"><text>x</text></view></view>\
1407 </screen></template>
1408 <style>\
1409 .card { background: #000000; } .card:hover { background: #00ff00; }\
1410 .inner:hover { background: #0000ff; }\
1411 </style>",
1412 )
1413 .expect("load");
1414 assert!(doc.set_interaction(hovering(&[0, 0])));
1416 assert!(is_green(&doc.root.children[0]), "the ancestor card is hovered too");
1417 let inner = &doc.root.children[0].children[0];
1418 assert!(
1419 matches!(&inner.style.background, Some(rux_layout::Background::Color(c)) if c.b == 1.0),
1420 "the inner box is hovered"
1421 );
1422 }
1423
1424 #[test]
1427 fn no_pointer_rules_means_no_state_regions() {
1428 let doc = Document::from_source(
1429 "<template><screen><view class=\"card\"><text>x</text></view></screen></template>
1430 <style>.card { background: #000000; }</style>",
1431 )
1432 .expect("load");
1433 fn any_marked(n: &LayoutNode) -> bool {
1434 n.state_path.is_some() || n.children.iter().any(any_marked)
1435 }
1436 assert!(!any_marked(&doc.root), "no :hover/:active rule → nothing to track");
1437 }
1438
1439 #[test]
1442 fn hoverable_elements_are_marked_for_the_shell() {
1443 let doc = hover_doc();
1444 assert_eq!(doc.root.children[0].state_path.as_deref(), Some(&[0][..]));
1445 assert_eq!(doc.root.children[1].state_path.as_deref(), Some(&[1][..]));
1446 assert!(doc.root.state_path.is_none(), "the screen has no :hover rule");
1447 }
1448
1449 #[test]
1452 fn r_show_toggles_hidden_in_place() {
1453 let mut doc = Document::from_source(
1454 "<template><screen><text r-show=\"on\">hi</text></screen></template>
1455 <script>let on = signal(true);</script>",
1456 )
1457 .expect("load");
1458 assert!(!doc.root.children[0].hidden, "on=true → visible");
1459
1460 let changed = doc.engine_mut().run_handler_tracked("on = false");
1461 assert!(doc.patch(&changed), "r-show change patches in place");
1462 assert!(doc.root.children[0].hidden, "on=false → hidden");
1463
1464 let changed = doc.engine_mut().run_handler_tracked("on = true");
1465 assert!(doc.patch(&changed));
1466 assert!(!doc.root.children[0].hidden, "on=true → visible again");
1467 }
1468
1469 #[test]
1473 fn r_if_reconciles_and_preserves_an_outside_caret() {
1474 let mut doc = Document::from_source(
1475 "<template><screen>\
1476 <view class=\"top\"><input r-model=\"name\" /></view>\
1477 <view class=\"list\"><text r-if=\"show\">secret</text></view>\
1478 </screen></template>
1479 <script>let name = signal(\"ab\"); let show = signal(false);</script>",
1480 )
1481 .expect("load");
1482 doc.set_focus(Some(Focus::at("name", 1)));
1483 assert_eq!(caret_of(&doc.root, "name"), Some(1));
1484 assert!(!find_text(&doc.root, "secret"), "hidden while show=false");
1485
1486 let changed = doc.engine_mut().run_handler_tracked("show = true");
1488 assert!(doc.patch(&changed), "an r-if change reconciles in place");
1489 assert!(find_text(&doc.root, "secret"), "branch now shown");
1490 assert_eq!(caret_of(&doc.root, "name"), Some(1), "outside caret survived");
1493
1494 let changed = doc.engine_mut().run_handler_tracked("show = false");
1496 assert!(doc.patch(&changed));
1497 assert!(!find_text(&doc.root, "secret"));
1498 assert_eq!(caret_of(&doc.root, "name"), Some(1));
1499 }
1500
1501 #[test]
1503 fn r_for_reconciles_row_count() {
1504 let mut doc = Document::from_source(
1505 "<template><screen><view class=\"list\"><text r-for=\"n in nums\">{{ n }}</text></view></screen></template>
1506 <script>let nums = signal([1, 2]);</script>",
1507 )
1508 .expect("load");
1509 assert_eq!(doc.root.children[0].children.len(), 2, "two rows initially");
1510
1511 let changed = doc.engine_mut().run_handler_tracked("nums = [1, 2, 3, 4]");
1512 assert!(doc.patch(&changed), "an r-for change reconciles in place");
1513 assert_eq!(doc.root.children[0].children.len(), 4, "grew to four rows");
1514 assert!(find_text(&doc.root, "4"), "new row content present");
1515 }
1516
1517 #[test]
1520 fn label_for_inherits_the_targets_tap() {
1521 let doc = Document::from_source(
1522 "<template><screen>\
1523 <input type=\"checkbox\" id=\"chk\" r-model=\"on\" />\
1524 <text for=\"chk\">Remember me</text>\
1525 </screen></template>
1526 <script>let on = signal(false);</script>",
1527 )
1528 .expect("load");
1529 assert_eq!(
1531 doc.root.children[1].on_tap.as_deref(),
1532 Some("on = !on"),
1533 "label with for= inherits the checkbox's @tap"
1534 );
1535 let doc2 = Document::from_source(
1537 "<template><screen>\
1538 <input type=\"checkbox\" id=\"chk\" r-model=\"on\" />\
1539 <text for=\"chk\" @tap=\"on = true\">Set</text>\
1540 </screen></template>
1541 <script>let on = signal(false);</script>",
1542 )
1543 .expect("load");
1544 assert_eq!(doc2.root.children[1].on_tap.as_deref(), Some("on = true"));
1545 }
1546
1547 #[test]
1550 fn label_for_focuses_a_text_input() {
1551 let doc = Document::from_source(
1552 "<template><screen>\
1553 <input id=\"nm\" r-model=\"name\" />\
1554 <text for=\"nm\">Name</text>\
1555 </screen></template>
1556 <script>let name = signal(\"\");</script>",
1557 )
1558 .expect("load");
1559 let label = &doc.root.children[1];
1560 assert_eq!(label.on_tap, None, "a text-input label has no tap handler");
1561 assert_eq!(
1562 label.focus_model.as_deref(),
1563 Some("name"),
1564 "label focuses the text input's model"
1565 );
1566 }
1567
1568 fn bg_rgb(n: &LayoutNode) -> Option<(f32, f32, f32)> {
1569 match &n.style.background {
1570 Some(rux_layout::Background::Color(c)) => Some((c.r, c.g, c.b)),
1571 _ => None,
1572 }
1573 }
1574
1575 #[test]
1578 fn dynamic_class_reconciles() {
1579 let mut doc = Document::from_source(
1580 "<template><screen><view class=\"chip\" :class=\"tone\" /></screen></template>
1581 <style>.hot { background: #ff0000; } .cool { background: #0000ff; }</style>
1582 <script>let tone = signal(\"hot\");</script>",
1583 )
1584 .expect("load");
1585 assert_eq!(bg_rgb(&doc.root.children[0]), Some((1.0, 0.0, 0.0)), ":class=hot → .hot");
1586
1587 let changed = doc.engine_mut().run_handler_tracked("tone = \"cool\"");
1588 assert!(doc.patch(&changed), ":class change reconciles in place");
1589 assert_eq!(bg_rgb(&doc.root.children[0]), Some((0.0, 0.0, 1.0)), "reconciled to .cool");
1590 }
1591
1592 #[test]
1595 fn dynamic_inline_style_interpolates_and_reconciles() {
1596 let mut doc = Document::from_source(
1597 "<template><screen><view :style=\"`background: ${col}`\" /></screen></template>
1598 <script>let col = signal(\"#00ff00\");</script>",
1599 )
1600 .expect("load");
1601 assert_eq!(bg_rgb(&doc.root.children[0]), Some((0.0, 1.0, 0.0)), ":style set green");
1602
1603 let changed = doc.engine_mut().run_handler_tracked("col = \"#ff0000\"");
1604 assert!(doc.patch(&changed));
1605 assert_eq!(bg_rgb(&doc.root.children[0]), Some((1.0, 0.0, 0.0)), "reconciled to red");
1606 }
1607
1608 #[test]
1611 fn r_for_chip_styles() {
1612 let doc = Document::from_source(
1613 "<template><screen><view class=\"chips\">\
1614 <view class=\"chip\" r-for=\"c in colors\" :style=\"`background: ${c}`\"><text>{{ c }}</text></view>\
1615 </view></screen></template>
1616 <script>let colors = signal([\"#ff0000\", \"#00ff00\"]);</script>",
1617 )
1618 .expect("load");
1619 let chips = &doc.root.children[0];
1620 assert_eq!(bg_rgb(&chips.children[0]), Some((1.0, 0.0, 0.0)), "first chip red");
1621 assert_eq!(bg_rgb(&chips.children[1]), Some((0.0, 1.0, 0.0)), "second chip green");
1622 }
1623
1624 #[test]
1627 fn conditional_class_object_form() {
1628 let mut doc = Document::from_source(
1629 "<template><screen><view class=\"chip\" :class=\"#{ hot: warm, cool: !warm }\" /></screen></template>
1630 <style>.hot { background: #ff0000; } .cool { background: #0000ff; }</style>
1631 <script>let warm = signal(true);</script>",
1632 )
1633 .expect("load");
1634 assert_eq!(bg_rgb(&doc.root.children[0]), Some((1.0, 0.0, 0.0)), "warm → .hot");
1635
1636 let changed = doc.engine_mut().run_handler_tracked("warm = false");
1637 assert!(doc.patch(&changed), "conditional class change reconciles");
1638 assert_eq!(bg_rgb(&doc.root.children[0]), Some((0.0, 0.0, 1.0)), "!warm → .cool");
1639 }
1640
1641 #[test]
1644 fn css_showcase_example_builds() {
1645 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../examples/css-showcase.rux");
1646 let doc = Document::load(path).expect("css-showcase.rux builds");
1647 assert!(find_text(&doc.root, "teal"), "a :style-coloured chip rendered");
1648 }
1649
1650 #[test]
1652 fn style_object_form() {
1653 let doc = Document::from_source(
1654 "<template><screen><view :style=\"#{ background: col }\" /></screen></template>
1655 <script>let col = signal(\"#00ff00\");</script>",
1656 )
1657 .expect("load");
1658 assert_eq!(bg_rgb(&doc.root.children[0]), Some((0.0, 1.0, 0.0)), ":style object → green");
1659 }
1660
1661 #[test]
1664 fn checked_toggles_get_a_checked_class() {
1665 let doc = Document::from_source(
1666 "<template><screen> <input type=\"checkbox\" class=\"box\" r-model=\"on\" /> <input type=\"radio\" class=\"box\" r-model=\"plan\" value=\"pro\" /> <input type=\"radio\" class=\"box\" r-model=\"plan\" value=\"free\" /> </screen></template>
1667 <style>.box { background: #000000; } .box.checked { background: #00ff00; }</style>
1668 <script>let on = signal(true); let plan = signal(\"pro\");</script>",
1669 )
1670 .expect("load");
1671
1672 let green = |n: &LayoutNode| {
1673 matches!(&n.style.background, Some(rux_layout::Background::Color(c)) if c.g == 1.0)
1674 };
1675 let boxes = &doc.root.children;
1676 assert!(green(&boxes[0]), "checked checkbox should match .checked");
1677 assert!(green(&boxes[1]), "radio whose value == signal is checked");
1678 assert!(!green(&boxes[2]), "the other radio is not checked");
1679
1680 assert_eq!(boxes[0].children.len(), 1);
1682 assert_eq!(boxes[1].children.len(), 1);
1683 assert_eq!(boxes[2].children.len(), 0);
1684 }
1685}
1686