1use std::collections::{HashMap, HashSet};
14use std::rc::Rc;
15
16use lightningcss::rules::CssRule;
17use lightningcss::stylesheet::{ParserOptions, PrinterOptions, StyleSheet};
18use lightningcss::traits::ToCss;
19use rux_layout::{
20 Access, AccessRole, Align, Axis, Background, BoxShadow, Cursor, Display, Gradient, GridPlace, ImageContent, Justify,
21 Len, Node as LayoutNode, Overflow, Position, Rgba, Sides, Style, TextAlign, TextContent,
22 TextWrap, Track, TrackSide,
23};
24use rux_layout::{GradientKind, GridFlow, Transform};
25use rux_parser::{Element, Node as TplNode, Sfc};
26use rux_reactive::Value;
27pub use rux_reactive::Warning;
30use rux_script::Engine;
31
32type Locals = Vec<(String, Value)>;
35
36thread_local! {
39 static WARNINGS: std::cell::RefCell<Vec<Warning>> = const { std::cell::RefCell::new(Vec::new()) };
48
49 static AT_LINE: std::cell::Cell<Option<usize>> = const { std::cell::Cell::new(None) };
57}
58
59fn located<T>(line: Option<usize>, f: impl FnOnce() -> T) -> T {
62 let previous = AT_LINE.with(|l| l.replace(line));
63 let out = f();
64 AT_LINE.with(|l| l.set(previous));
65 out
66}
67
68fn warn(message: String) {
69 let warning = Warning::maybe_at(message, AT_LINE.with(|l| l.get()));
70 WARNINGS.with(|w| {
71 let mut w = w.borrow_mut();
72 if !w.contains(&warning) {
76 w.push(warning);
77 }
78 });
79}
80
81pub fn warn_stylesheet(message: impl Into<String>) {
89 warn(message.into());
90}
91
92pub fn take_warnings() -> Vec<Warning> {
94 WARNINGS.with(|w| std::mem::take(&mut *w.borrow_mut()))
95}
96
97thread_local! {
98 static ECHO: std::cell::Cell<bool> = const { std::cell::Cell::new(true) };
106}
107
108pub fn set_stderr_echo(on: bool) {
116 ECHO.with(|e| e.set(on));
117}
118
119fn echo(message: &str) {
121 if ECHO.with(|e| e.get()) {
122 eprintln!("rux: {message}");
123 }
124}
125
126#[derive(Clone, Debug)]
132pub struct TextBinding {
133 pub path: Vec<usize>,
135 pub template: String,
137 pub locals: Vec<(String, Value)>,
139 pub deps: HashSet<String>,
141}
142
143#[derive(Clone, Debug)]
148pub struct ValueBinding {
149 pub path: Vec<usize>,
151 pub model: String,
153 pub row: Option<String>,
158 pub placeholder: String,
160 pub color: Rgba,
162 pub placeholder_color: Rgba,
164 pub locals: Vec<(String, Value)>,
166 pub deps: HashSet<String>,
168}
169
170#[derive(Clone, Debug)]
174pub struct ShowBinding {
175 pub path: Vec<usize>,
177 pub cond: String,
179 pub locals: Vec<(String, Value)>,
181 pub deps: HashSet<String>,
183}
184
185#[derive(Clone, Debug)]
191pub struct StructuralParent {
192 pub tree_path: Vec<usize>,
194 pub tpl_path: Vec<usize>,
196 pub deps: HashSet<String>,
198}
199
200#[derive(Clone, Debug)]
204pub struct ToggleBinding {
205 pub path: Vec<usize>,
207 pub deps: HashSet<String>,
209}
210
211#[derive(Clone, Debug)]
215pub struct ComponentBinding {
216 pub path: Vec<usize>,
218 pub deps: HashSet<String>,
220}
221
222#[derive(Clone, Debug)]
227pub struct StyledBinding {
228 pub path: Vec<usize>,
230 pub deps: HashSet<String>,
232}
233
234#[derive(Clone, Debug)]
237pub struct AttrBinding {
238 pub path: Vec<usize>,
240 pub expr: String,
242 pub locals: Vec<(String, Value)>,
244 pub deps: HashSet<String>,
246}
247
248#[derive(Clone, Debug, Default)]
254pub struct BindingRegistry {
255 pub text: Vec<TextBinding>,
256 pub value: Vec<ValueBinding>,
257 pub show: Vec<ShowBinding>,
258 pub src: Vec<AttrBinding>,
260 pub options: Vec<AttrBinding>,
262 pub structural_parents: Vec<StructuralParent>,
263 pub toggles: Vec<ToggleBinding>,
264 pub components: Vec<ComponentBinding>,
265 pub styled: Vec<StyledBinding>,
266 pub structural: HashSet<String>,
270}
271
272
273fn bind_locals(src: &str, locals: &Locals) -> String {
277 if locals.is_empty() {
278 return src.to_string();
279 }
280 let mut out = String::new();
281 for (name, value) in locals {
282 out.push_str("let ");
283 out.push_str(name);
284 out.push_str(" = ");
285 out.push_str(&value.to_rhai_literal());
286 out.push_str("; ");
287 }
288 out.push_str(src);
289 out
290}
291
292struct Component {
295 template: Element,
296 rules: Vec<Rule>,
297 script: String,
301}
302
303#[derive(Clone, Debug, Default)]
311pub struct Instance {
312 pub state: Vec<(String, Value)>,
313 pub props: Vec<(String, Value)>,
314 pub listeners: Vec<(String, String)>,
319 pub caller: Option<String>,
323 pub route: Option<String>,
329 pub touched: bool,
337}
338
339pub type Instances = HashMap<String, Instance>;
342
343fn instance_key(tpl_path: &[usize], row: Option<&str>) -> String {
350 let mut key = String::new();
351 for step in tpl_path {
352 key.push_str(&step.to_string());
353 key.push('.');
354 }
355 if let Some(row) = row {
356 key.push('#');
357 key.push_str(row);
358 }
359 key
360}
361
362fn component_statements(script: &str) -> String {
369 let mut out = String::new();
370 let lines: Vec<&str> = script.lines().collect();
371 let mut i = 0;
372 while i < lines.len() {
373 let trimmed = lines[i].trim();
374 if trimmed.starts_with("use ") {
375 i += 1;
376 continue;
377 }
378 if trimmed.starts_with("fn ") {
379 let mut depth = 0i32;
382 let mut seen = false;
383 while i < lines.len() {
384 for c in lines[i].chars() {
385 match c {
386 '{' => {
387 depth += 1;
388 seen = true;
389 }
390 '}' => depth -= 1,
391 _ => {}
392 }
393 }
394 i += 1;
395 if seen && depth <= 0 {
396 break;
397 }
398 }
399 continue;
400 }
401 out.push_str(lines[i]);
402 out.push('\n');
403 i += 1;
404 }
405 out
406}
407
408#[derive(Clone, Copy)]
418struct Slot<'a> {
419 children: &'a [&'a Element],
420 locals: &'a Locals,
421 rules: &'a [Rule],
422}
423
424fn element_children(el: &Element) -> Vec<&Element> {
427 el.children
428 .iter()
429 .filter_map(|n| match n {
430 TplNode::Element(child) => Some(child),
431 TplNode::Text(_) => None,
432 })
433 .collect()
434}
435
436type Components = HashMap<String, Component>;
438
439const DEFAULT_COLOR: Rgba = Rgba::new(0.804, 0.839, 0.957, 1.0);
442const DEFAULT_FONT_SIZE: f32 = 16.0;
443
444#[derive(Clone)]
447struct Inherited {
448 color: Rgba,
449 font_size: f32,
450 font_family: Option<String>,
451 vars: Vars,
454}
455
456type Vars = Rc<HashMap<String, String>>;
465
466const MAX_VAR_DEPTH: usize = 16;
470
471fn resolve_vars(value: &str, vars: &HashMap<String, String>, depth: usize) -> String {
479 if depth >= MAX_VAR_DEPTH || !value.contains("var(") {
480 return value.to_string();
481 }
482 let mut out = String::with_capacity(value.len());
483 let mut rest = value;
484 while let Some(start) = rest.find("var(") {
485 out.push_str(&rest[..start]);
486 let after = &rest[start + 4..];
487 let mut depth_parens = 1i32;
490 let mut end = None;
491 for (i, c) in after.char_indices() {
492 match c {
493 '(' => depth_parens += 1,
494 ')' => {
495 depth_parens -= 1;
496 if depth_parens == 0 {
497 end = Some(i);
498 break;
499 }
500 }
501 _ => {}
502 }
503 }
504 let Some(end) = end else {
505 out.push_str("var(");
507 out.push_str(after);
508 return out;
509 };
510 let inner = &after[..end];
511 let (name, fallback) = match inner.split_once(',') {
512 Some((n, f)) => (n.trim(), Some(f.trim())),
513 None => (inner.trim(), None),
514 };
515 match vars.get(name) {
516 Some(v) => out.push_str(&resolve_vars(v, vars, depth + 1)),
518 None => match fallback {
519 Some(f) => out.push_str(&resolve_vars(f, vars, depth + 1)),
520 None => {
521 warn_undefined_var(name);
522 out.push_str("var(");
523 out.push_str(inner);
524 out.push(')');
525 }
526 },
527 }
528 rest = &after[end + 1..];
529 }
530 out.push_str(rest);
531 out
532}
533
534fn take_vars(props: &mut HashMap<String, String>, inherited: &Vars) -> Vars {
544 let declared: Vec<String> = props.keys().filter(|k| k.starts_with("--")).cloned().collect();
545 if declared.is_empty() {
546 return Rc::clone(inherited);
547 }
548 let mut vars = (**inherited).clone();
549 for name in declared {
550 let Some(value) = props.remove(&name) else { continue };
553 let value = resolve_vars(&value, &vars, 0);
554 vars.insert(name, value);
555 }
556 Rc::new(vars)
557}
558
559fn warn_undefined_var(name: &str) {
561 use std::sync::{Mutex, OnceLock};
562 static SEEN: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
563 let message = format!(
564 "custom property `{name}` is not defined, the declaration using var({name}) is \
565 ignored (give it a fallback: `var({name}, …)`)"
566 );
567 warn(message.clone());
568 let seen = SEEN.get_or_init(|| Mutex::new(HashSet::new()));
569 let Ok(mut seen) = seen.lock() else { return };
570 if seen.insert(name.to_string()) {
571 echo(&message);
572 }
573}
574
575const CIRCLE: f32 = 9999.0;
578
579#[derive(Clone)]
582struct Toggle {
583 radio: bool,
584 checked: bool,
585 deps: HashSet<String>,
586}
587
588impl Toggle {
589 fn of(el: &Element, engine: &mut Engine, locals: &Locals) -> Option<Self> {
590 if el.tag != "input" {
591 return None;
592 }
593 let radio = match el.attr("type") {
594 Some("radio") => true,
595 Some("checkbox") => false,
596 _ => return None,
597 };
598 let model = el.attr("r-model").unwrap_or_default();
599 let (checked, deps) = if model.is_empty() {
602 (false, HashSet::new())
603 } else if radio {
604 let (v, deps) = engine.eval_display_tracked(model, locals);
605 (v == el.attr("value").unwrap_or_default(), deps)
606 } else {
607 engine.eval_bool_tracked(model, locals)
608 };
609 Some(Self { radio, checked, deps })
610 }
611}
612
613pub fn build_styled_tree(
618 sfc: &Sfc,
619 components: &HashMap<String, Sfc>,
620 engine: &mut Engine,
621) -> Result<LayoutNode, String> {
622 let mut instances = Instances::new();
623 build_styled_tree_tracked(sfc, components, engine, &mut instances).map(|(node, _)| node)
624}
625
626pub fn eval_text_binding(binding: &TextBinding, engine: &mut Engine) -> String {
629 interpolate_tracked(&binding.template, engine, &binding.locals).0
630}
631
632fn class_list(value: &Value) -> Vec<String> {
636 match value {
637 Value::Text(s) => s.split_whitespace().map(str::to_string).collect(),
638 Value::List(items) => items
639 .iter()
640 .flat_map(|i| i.to_display().split_whitespace().map(str::to_string).collect::<Vec<_>>())
641 .collect(),
642 Value::Map(entries) => entries
644 .iter()
645 .filter(|(_, v)| v.is_truthy())
646 .flat_map(|(k, _)| k.split_whitespace().map(str::to_string).collect::<Vec<_>>())
647 .collect(),
648 _ => Vec::new(),
649 }
650}
651
652fn merge_inline_style(props: &mut HashMap<String, String>, css: &str) {
656 for decl in css.split(';') {
657 if let Some((name, value)) = decl.split_once(':') {
658 let name = name.trim().to_ascii_lowercase();
659 let value = value.trim();
660 if !name.is_empty() && !value.is_empty() {
661 props.insert(name, value.to_string());
662 }
663 }
664 }
665}
666
667type LabelTarget = (Option<String>, Option<String>);
673
674fn explicit_access_role(el: &Element) -> Option<AccessRole> {
681 let role = el.role()?.to_ascii_lowercase();
682 Some(match role.as_str() {
683 "heading" => AccessRole::Heading,
684 "button" => AccessRole::Button,
685 "label" | "text" | "paragraph" => AccessRole::Label,
686 "link" => AccessRole::Link,
687 "checkbox" => AccessRole::CheckBox,
688 "radio" => AccessRole::RadioButton,
689 "textbox" | "textfield" => AccessRole::TextInput,
690 "combobox" | "listbox" | "select" => AccessRole::ComboBox,
691 "image" | "img" => AccessRole::Image,
692 _ => AccessRole::Group,
693 })
694}
695
696fn authored_label(el: &Element) -> Option<String> {
700 el.attr("label")
701 .or_else(|| el.attr("alt"))
702 .filter(|v| !v.trim().is_empty())
703 .map(str::to_string)
704}
705
706fn subtree_text(node: &LayoutNode) -> String {
709 let mut out = String::new();
710 collect_subtree_text(node, &mut out);
711 out
712}
713
714fn collect_subtree_text(node: &LayoutNode, out: &mut String) {
715 if let Some(text) = &node.text {
716 if !text.text.trim().is_empty() {
717 if !out.is_empty() {
718 out.push(' ');
719 }
720 out.push_str(text.text.trim());
721 }
722 }
723 for child in &node.children {
724 collect_subtree_text(child, out);
725 }
726}
727
728fn link_labels(root: &mut LayoutNode) {
729 let mut targets: HashMap<String, LabelTarget> = HashMap::new();
730 collect_label_targets(root, &mut targets);
731 if !targets.is_empty() {
732 apply_label_targets(root, &targets);
733 }
734 let mut names: HashMap<String, String> = HashMap::new();
738 collect_label_names(root, &mut names);
739 if !names.is_empty() {
740 apply_label_names(root, &names);
741 }
742}
743
744fn collect_label_names(node: &LayoutNode, names: &mut HashMap<String, String>) {
746 if let Some(target) = &node.label_for {
747 let text = subtree_text(node);
748 if !text.is_empty() {
749 names.entry(target.clone()).or_insert(text);
750 }
751 }
752 for child in &node.children {
753 collect_label_names(child, names);
754 }
755}
756
757fn apply_label_names(node: &mut LayoutNode, names: &HashMap<String, String>) {
761 if node.access.label.is_none() {
762 if let Some(name) = node.id.as_ref().and_then(|id| names.get(id)) {
763 node.access.label = Some(name.clone());
764 }
765 }
766 for child in &mut node.children {
767 apply_label_names(child, names);
768 }
769}
770
771fn collect_label_targets(node: &LayoutNode, targets: &mut HashMap<String, LabelTarget>) {
772 if let Some(id) = &node.id {
773 targets
774 .entry(id.clone())
775 .or_insert_with(|| (node.on_tap.clone(), node.model.clone()));
776 }
777 for child in &node.children {
778 collect_label_targets(child, targets);
779 }
780}
781
782fn apply_label_targets(node: &mut LayoutNode, targets: &HashMap<String, LabelTarget>) {
783 if node.on_tap.is_none() && node.focus_model.is_none() {
784 if let Some((tap, model)) = node.label_for.as_ref().and_then(|t| targets.get(t)) {
785 if let Some(tap) = tap {
786 node.on_tap = Some(tap.clone());
788 } else if let Some(model) = model {
789 node.focus_model = Some(model.clone());
791 }
792 }
793 }
794 for child in &mut node.children {
795 apply_label_targets(child, targets);
796 }
797}
798
799pub fn eval_src_binding(binding: &AttrBinding, engine: &mut Engine) -> String {
801 engine.eval_display(&binding.expr, &binding.locals)
802}
803
804pub fn eval_options_binding(binding: &AttrBinding, engine: &mut Engine) -> Vec<String> {
806 engine
807 .eval_value(&binding.expr, &binding.locals)
808 .and_then(|v| v.as_list().map(|items| items.iter().map(Value::to_display).collect()))
809 .unwrap_or_default()
810}
811
812pub fn eval_value_binding(binding: &ValueBinding, engine: &mut Engine) -> (String, Rgba) {
815 let value = engine.eval_display(&binding.model, &binding.locals);
816 if value.is_empty() {
817 (binding.placeholder.clone(), binding.placeholder_color)
818 } else {
819 (value, binding.color)
820 }
821}
822
823pub fn build_styled_tree_tracked(
827 sfc: &Sfc,
828 components: &HashMap<String, Sfc>,
829 engine: &mut Engine,
830 instances: &mut Instances,
831) -> Result<(LayoutNode, BindingRegistry), String> {
832 build_styled_tree_stateful(
833 sfc,
834 components,
835 engine,
836 instances,
837 &InteractionState::default(),
838 Viewport::default(),
839 )
840}
841
842pub fn build_styled_tree_stateful(
847 sfc: &Sfc,
848 components: &HashMap<String, Sfc>,
849 engine: &mut Engine,
850 instances: &mut Instances,
851 state: &InteractionState,
852 viewport: Viewport,
853) -> Result<(LayoutNode, BindingRegistry), String> {
854 let rules = parse_document_rules(sfc, viewport);
861 let comps: Components = components
862 .iter()
863 .map(|(tag, c)| {
864 (
865 tag.clone(),
866 Component {
867 template: c.template.clone(),
868 rules: parse_component_rules(c, viewport),
869 script: component_statements(&c.script),
870 },
871 )
872 })
873 .collect();
874
875 for instance in instances.values_mut() {
881 instance.touched = false;
882 }
883
884 let mut ancestors: Vec<AncNode> = Vec::new();
885 let locals = Locals::new();
886 let mut reg = BindingRegistry::default();
887 let mut node = build_node(
888 &sfc.template,
889 &rules,
890 &comps,
891 &mut ancestors,
892 &[],
893 &Inherited {
894 color: DEFAULT_COLOR,
895 font_size: DEFAULT_FONT_SIZE,
896 font_family: None,
897 vars: Vars::default(),
898 },
899 engine,
900 &locals,
901 &[],
902 &[],
903 &mut reg,
904 state,
905 instances,
906 None, None, None, );
910 link_labels(&mut node);
911 instances.retain(|_, instance| instance.touched);
912 Ok((node, reg))
913}
914
915fn interpolate_tracked(
920 text: &str,
921 engine: &mut Engine,
922 locals: &Locals,
923) -> (String, HashSet<String>) {
924 let mut out = String::new();
925 let mut deps = HashSet::new();
926 let mut rest = text;
927 while let Some(start) = rest.find("{{") {
928 out.push_str(&decode_entities(&rest[..start]));
929 let after = &rest[start + 2..];
930 match after.find("}}") {
931 Some(end) => {
932 let (value, d) = engine.eval_display_tracked(after[..end].trim(), locals);
933 out.push_str(&value);
934 deps.extend(d);
935 rest = &after[end + 2..];
936 }
937 None => {
938 out.push_str("{{");
939 rest = after;
940 }
941 }
942 }
943 out.push_str(&decode_entities(rest));
944 (out, deps)
945}
946
947fn text_template(el: &Element) -> String {
950 el.children
951 .iter()
952 .filter_map(|c| match c {
953 TplNode::Text(t) => Some(t.trim()),
954 _ => None,
955 })
956 .filter(|t| !t.is_empty())
957 .collect::<Vec<_>>()
958 .join(" ")
959}
960
961use rux_parser::decode_entities;
965
966#[derive(Debug, Clone, Default)]
970struct Compound {
971 tag: Option<String>,
972 id: Option<String>,
973 classes: Vec<String>,
974 role: Option<String>,
975 pseudos: Vec<Pseudo>,
976}
977
978#[derive(Debug, Clone, PartialEq, Eq)]
987enum Pseudo {
988 Hover,
989 Focus,
990 Active,
991 Checked,
992 Current,
993 Unknown(String),
994}
995
996#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1001pub struct ElemStates {
1002 pub hover: bool,
1003 pub focus: bool,
1004 pub active: bool,
1005 pub checked: bool,
1006 pub current: bool,
1009}
1010
1011#[derive(Clone, Debug, Default, PartialEq, Eq)]
1019pub struct InteractionState {
1020 pub hovered: Option<Vec<usize>>,
1022 pub active: Option<Vec<usize>>,
1024 pub focused_model: Option<String>,
1026 pub focused_row: Option<String>,
1030}
1031
1032impl InteractionState {
1033 fn hovers(&self, path: &[usize]) -> bool {
1038 self.hovered.as_ref().is_some_and(|h| h.starts_with(path))
1039 }
1040
1041 fn activates(&self, path: &[usize]) -> bool {
1043 self.active.as_ref().is_some_and(|a| a.starts_with(path))
1044 }
1045}
1046
1047impl Pseudo {
1048 fn is_pointer_state(&self) -> bool {
1052 matches!(self, Self::Hover | Self::Active)
1053 }
1054
1055 fn parse(name: &str) -> Self {
1056 match name.to_ascii_lowercase().as_str() {
1057 "hover" => Self::Hover,
1058 "focus" => Self::Focus,
1059 "active" => Self::Active,
1060 "checked" => Self::Checked,
1061 "current" => Self::Current,
1062 other => Self::Unknown(other.to_string()),
1063 }
1064 }
1065
1066 fn holds(&self, s: &ElemStates) -> bool {
1067 match self {
1068 Self::Hover => s.hover,
1069 Self::Focus => s.focus,
1070 Self::Active => s.active,
1071 Self::Checked => s.checked,
1072 Self::Current => s.current,
1073 Self::Unknown(_) => false,
1075 }
1076 }
1077}
1078
1079#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1081enum Combinator {
1082 Descendant,
1084 Child,
1086 NextSibling,
1088 SubsequentSibling,
1090}
1091
1092#[derive(Debug, Clone)]
1096struct Rule {
1097 chain: Vec<Compound>,
1098 combs: Vec<Combinator>,
1099 specificity: (u32, u32, u32),
1100 order: usize,
1101 decls: Vec<(String, String)>,
1102}
1103
1104#[derive(Debug, Clone)]
1106struct ElemDesc {
1107 tag: String,
1108 id: Option<String>,
1109 classes: Vec<String>,
1110 role: Option<String>,
1111 states: ElemStates,
1112}
1113
1114#[derive(Debug, Clone)]
1119struct AncNode {
1120 desc: ElemDesc,
1121 prev: Vec<ElemDesc>,
1122}
1123
1124impl ElemDesc {
1125 fn of(el: &Element) -> Self {
1126 Self {
1127 tag: el.tag.clone(),
1128 id: el.id().map(str::to_string),
1129 classes: el.classes().into_iter().map(str::to_string).collect(),
1130 role: el.role().map(str::to_string),
1131 states: ElemStates::default(),
1132 }
1133 }
1134}
1135
1136#[derive(Clone, Copy, Debug, PartialEq)]
1142pub struct Viewport {
1143 pub width: f32,
1144 pub height: f32,
1145}
1146
1147impl Default for Viewport {
1148 fn default() -> Self {
1151 Self { width: 1280.0, height: 800.0 }
1152 }
1153}
1154
1155#[derive(Debug, Clone, Copy, PartialEq)]
1158enum Cmp {
1159 Le,
1160 Lt,
1161 Ge,
1162 Gt,
1163 Eq,
1164}
1165
1166impl Cmp {
1167 fn holds(self, actual: f32, bound: f32) -> bool {
1168 match self {
1169 Self::Le => actual <= bound,
1170 Self::Lt => actual < bound,
1171 Self::Ge => actual >= bound,
1172 Self::Gt => actual > bound,
1173 Self::Eq => (actual - bound).abs() < f32::EPSILON,
1174 }
1175 }
1176
1177 fn flipped(self) -> Self {
1179 match self {
1180 Self::Le => Self::Ge,
1181 Self::Lt => Self::Gt,
1182 Self::Ge => Self::Le,
1183 Self::Gt => Self::Lt,
1184 Self::Eq => Self::Eq,
1185 }
1186 }
1187}
1188
1189#[derive(Debug, Clone, Copy, PartialEq)]
1193enum Feature {
1194 Width(Cmp, f32),
1195 Height(Cmp, f32),
1196 Portrait,
1197 Landscape,
1198 Always,
1200 Never,
1202}
1203
1204impl Feature {
1205 fn holds(&self, vp: Viewport) -> bool {
1206 match *self {
1207 Self::Width(cmp, v) => cmp.holds(vp.width, v),
1208 Self::Height(cmp, v) => cmp.holds(vp.height, v),
1209 Self::Portrait => vp.height >= vp.width,
1210 Self::Landscape => vp.width > vp.height,
1211 Self::Always => true,
1212 Self::Never => false,
1213 }
1214 }
1215}
1216
1217#[derive(Debug, Clone, Default)]
1220struct MediaCond {
1221 any: Vec<Vec<Feature>>,
1222}
1223
1224impl MediaCond {
1225 fn holds(&self, vp: Viewport) -> bool {
1226 self.any.iter().any(|all| all.iter().all(|f| f.holds(vp)))
1227 }
1228
1229 fn parse(text: &str) -> Self {
1232 let any = text
1233 .split(',')
1234 .map(|alternative| {
1235 alternative
1236 .split(" and ")
1237 .flat_map(|token| parse_media_feature(token.trim()))
1238 .collect()
1239 })
1240 .collect();
1241 Self { any }
1242 }
1243}
1244
1245fn parse_media_feature(token: &str) -> Vec<Feature> {
1254 let inner = token.trim();
1255 if !inner.starts_with('(') {
1257 return vec![match inner.to_ascii_lowercase().as_str() {
1258 "screen" | "all" => Feature::Always,
1259 other => {
1262 warn_unsupported_media(other);
1263 Feature::Never
1264 }
1265 }];
1266 }
1267 let body = inner.trim_start_matches('(').trim_end_matches(')').trim();
1268
1269 let parts = split_on_comparators(body);
1271 if parts.len() >= 3 {
1272 return parse_range(&parts);
1273 }
1274
1275 let Some((name, value)) = body.split_once(':') else {
1276 warn_unsupported_media(body);
1278 return vec![Feature::Never];
1279 };
1280 let name = name.trim().to_ascii_lowercase();
1281 let value = value.trim();
1282 vec![match name.as_str() {
1283 "orientation" => match value.to_ascii_lowercase().as_str() {
1284 "portrait" => Feature::Portrait,
1285 "landscape" => Feature::Landscape,
1286 _ => Feature::Never,
1287 },
1288 "min-width" | "max-width" | "min-height" | "max-height" => {
1289 let Some(px) = parse_px(value) else {
1292 warn_unsupported_media(&format!("{name}: {value}"));
1293 return vec![Feature::Never];
1294 };
1295 match name.as_str() {
1296 "min-width" => Feature::Width(Cmp::Ge, px),
1297 "max-width" => Feature::Width(Cmp::Le, px),
1298 "min-height" => Feature::Height(Cmp::Ge, px),
1299 _ => Feature::Height(Cmp::Le, px),
1300 }
1301 }
1302 other => {
1303 warn_unsupported_media(other);
1304 Feature::Never
1305 }
1306 }]
1307}
1308
1309enum RangePart {
1311 Operand(String),
1312 Op(Cmp),
1313}
1314
1315fn split_on_comparators(body: &str) -> Vec<RangePart> {
1318 let mut parts = Vec::new();
1319 let mut current = String::new();
1320 let mut chars = body.chars().peekable();
1321 let mut saw_op = false;
1322 while let Some(c) = chars.next() {
1323 let op = match c {
1324 '<' if chars.peek() == Some(&'=') => {
1325 chars.next();
1326 Some(Cmp::Le)
1327 }
1328 '>' if chars.peek() == Some(&'=') => {
1329 chars.next();
1330 Some(Cmp::Ge)
1331 }
1332 '<' => Some(Cmp::Lt),
1333 '>' => Some(Cmp::Gt),
1334 '=' => Some(Cmp::Eq),
1335 _ => None,
1336 };
1337 match op {
1338 Some(op) => {
1339 parts.push(RangePart::Operand(current.trim().to_string()));
1340 parts.push(RangePart::Op(op));
1341 current = String::new();
1342 saw_op = true;
1343 }
1344 None => current.push(c),
1345 }
1346 }
1347 if !saw_op {
1348 return Vec::new();
1349 }
1350 parts.push(RangePart::Operand(current.trim().to_string()));
1351 parts
1352}
1353
1354fn parse_range(parts: &[RangePart]) -> Vec<Feature> {
1357 let feature = |axis: &str, cmp: Cmp, value: &str| -> Feature {
1359 let Some(px) = parse_px(value) else {
1360 warn_unsupported_media(value);
1361 return Feature::Never;
1362 };
1363 match axis {
1364 "width" => Feature::Width(cmp, px),
1365 "height" => Feature::Height(cmp, px),
1366 other => {
1367 warn_unsupported_media(other);
1368 Feature::Never
1369 }
1370 }
1371 };
1372 let operand = |i: usize| match &parts[i] {
1373 RangePart::Operand(s) => s.to_ascii_lowercase(),
1374 RangePart::Op(_) => String::new(),
1375 };
1376 let op = |i: usize| match &parts[i] {
1377 RangePart::Op(c) => *c,
1378 RangePart::Operand(_) => Cmp::Eq,
1379 };
1380
1381 match parts.len() {
1382 3 => {
1383 let (left, right) = (operand(0), operand(2));
1384 if left == "width" || left == "height" {
1385 vec![feature(&left, op(1), &right)]
1386 } else {
1387 vec![feature(&right, op(1).flipped(), &left)]
1389 }
1390 }
1391 5 => {
1393 let axis = operand(2);
1394 vec![
1395 feature(&axis, op(1).flipped(), &operand(0)),
1396 feature(&axis, op(3), &operand(4)),
1397 ]
1398 }
1399 _ => vec![Feature::Never],
1400 }
1401}
1402
1403fn warn_unsupported_media(what: &str) {
1407 use std::sync::{Mutex, OnceLock};
1408 static SEEN: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
1409 let message = format!(
1410 "`@media` condition `{what}` is not supported, its rules will never apply \
1411 (supported: screen/all, min-/max-width, min-/max-height, orientation)"
1412 );
1413 warn(message.clone());
1414 let seen = SEEN.get_or_init(|| Mutex::new(HashSet::new()));
1415 let Ok(mut seen) = seen.lock() else { return };
1416 if seen.insert(what.to_string()) {
1417 echo(&message);
1418 }
1419}
1420
1421pub fn media_matches(css: &str, vp: Viewport) -> Vec<bool> {
1425 let Ok(sheet) = StyleSheet::parse(css, ParserOptions::default()) else {
1426 return Vec::new();
1427 };
1428 let mut out = Vec::new();
1429 collect_media_matches(&sheet.rules.0, vp, &mut out);
1430 out
1431}
1432
1433fn collect_media_matches(rules: &[CssRule], vp: Viewport, out: &mut Vec<bool>) {
1434 for rule in rules {
1435 if let CssRule::Media(media) = rule {
1436 let text = media
1437 .query
1438 .to_css_string(PrinterOptions::default())
1439 .unwrap_or_default();
1440 out.push(MediaCond::parse(&text).holds(vp));
1441 collect_media_matches(&media.rules.0, vp, out);
1442 }
1443 }
1444}
1445
1446fn parse_rules(css: &str, vp: Viewport) -> Vec<Rule> {
1452 parse_rules_at(css, vp, None)
1453}
1454
1455fn parse_document_rules(sfc: &Sfc, vp: Viewport) -> Vec<Rule> {
1467 if sfc.style_includes.is_empty() {
1468 return parse_rules_at(&sfc.style, vp, Some(sfc.style_line));
1471 }
1472 let mut rules = Vec::new();
1473 for include in &sfc.style_includes {
1474 rules.extend(parse_rules(&include.css, vp));
1475 }
1476 rules.extend(parse_rules_at(&sfc.style, vp, Some(sfc.style_line)));
1477 renumber(&mut rules);
1478 rules
1479}
1480
1481fn parse_component_rules(sfc: &Sfc, vp: Viewport) -> Vec<Rule> {
1484 if sfc.style_includes.is_empty() {
1485 return parse_rules(&sfc.style, vp);
1486 }
1487 let mut rules = Vec::new();
1488 for include in &sfc.style_includes {
1489 rules.extend(parse_rules(&include.css, vp));
1490 }
1491 rules.extend(parse_rules(&sfc.style, vp));
1492 renumber(&mut rules);
1493 rules
1494}
1495
1496fn renumber(rules: &mut [Rule]) {
1503 for (i, rule) in rules.iter_mut().enumerate() {
1504 rule.order = i;
1505 }
1506}
1507
1508fn parse_rules_at(css: &str, vp: Viewport, base: Option<usize>) -> Vec<Rule> {
1509 let sheet = match StyleSheet::parse(css, ParserOptions::default()) {
1510 Ok(s) => s,
1511 Err(_) => return Vec::new(),
1512 };
1513
1514 let mut rules = Vec::new();
1515 let mut order = 0usize;
1516 collect_rules(&sheet.rules.0, vp, &mut rules, &mut order, base, css);
1517 rules
1518}
1519
1520fn collect_rules(
1526 rules: &[CssRule],
1527 vp: Viewport,
1528 out: &mut Vec<Rule>,
1529 order: &mut usize,
1530 base: Option<usize>,
1531 css: &str,
1532) {
1533 for rule in rules {
1534 match rule {
1535 CssRule::Media(media) => {
1536 let text = media
1537 .query
1538 .to_css_string(PrinterOptions::default())
1539 .unwrap_or_default();
1540 let holds = located(file_line(base, media.loc.line), || {
1542 MediaCond::parse(&text).holds(vp)
1543 });
1544 if holds {
1545 collect_rules(&media.rules.0, vp, out, order, base, css);
1546 }
1547 }
1548 CssRule::Style(style) => collect_style_rule(style, out, order, base, css),
1549 _ => {}
1550 }
1551 }
1552}
1553
1554fn file_line(base: Option<usize>, relative: u32) -> Option<usize> {
1558 base.map(|b| b + relative as usize)
1559}
1560
1561fn decl_line(css: &str, rule_line: u32, property: &str) -> Option<u32> {
1574 let mut depth = 0usize;
1575 let mut entered = false;
1576 for (offset, text) in css.lines().enumerate().skip(rule_line as usize) {
1577 if entered {
1579 let trimmed = text.trim_start();
1580 if let Some(rest) = trimmed.strip_prefix(property) {
1581 if rest.trim_start().starts_with(':') {
1582 return u32::try_from(offset).ok();
1583 }
1584 }
1585 }
1586 for ch in text.chars() {
1587 match ch {
1588 '{' => {
1589 depth += 1;
1590 entered = true;
1591 }
1592 '}' => {
1593 depth = depth.saturating_sub(1);
1594 if entered && depth == 0 {
1596 return None;
1597 }
1598 }
1599 _ => {}
1600 }
1601 }
1602 }
1603 None
1604}
1605
1606fn collect_style_rule(
1607 style: &lightningcss::rules::style::StyleRule,
1608 out: &mut Vec<Rule>,
1609 order: &mut usize,
1610 base: Option<usize>,
1611 css: &str,
1612) {
1613 located(file_line(base, style.loc.line), || {
1614 let mut decls = Vec::new();
1616 for prop in &style.declarations.declarations {
1617 if let Ok(text) = prop.to_css_string(false, PrinterOptions::default()) {
1618 if let Some((k, v)) = text.split_once(':') {
1619 let key = k.trim().to_lowercase();
1620 let at = decl_line(css, style.loc.line, &key).unwrap_or(style.loc.line);
1628 located(file_line(base, at), || warn_if_unhonored(&key));
1629 decls.push((
1630 key,
1631 v.trim().trim_end_matches(';').trim().to_string(),
1632 ));
1633 }
1634 }
1635 }
1636
1637 for selector in &style.selectors.0 {
1639 if let Ok(text) = selector.to_css_string(PrinterOptions::default()) {
1640 if let Some((chain, combs, specificity)) = parse_selector(&text) {
1641 out.push(Rule {
1642 chain,
1643 combs,
1644 specificity,
1645 order: *order,
1646 decls: decls.clone(),
1647 });
1648 }
1649 }
1650 *order += 1;
1651 }
1652 });
1653}
1654
1655const HONORED_PROPERTIES: &[&str] = &[
1660 "display", "width", "height", "gap",
1662 "min-width", "max-width", "min-height", "max-height",
1663 "padding", "padding-top", "padding-right", "padding-bottom", "padding-left",
1664 "margin", "margin-top", "margin-right", "margin-bottom", "margin-left",
1665 "border", "border-width", "border-color", "border-radius",
1666 "border-top-left-radius", "border-top-right-radius",
1667 "border-bottom-right-radius", "border-bottom-left-radius",
1668 "border-top", "border-right", "border-bottom", "border-left",
1669 "border-top-width", "border-right-width", "border-bottom-width", "border-left-width",
1670 "overflow", "overflow-x", "overflow-y", "opacity", "cursor", "box-shadow", "transform",
1671 "flex", "flex-grow", "flex-shrink", "flex-basis", "flex-wrap", "flex-direction",
1673 "justify-content", "align-items", "align-self", "justify-self", "justify-items",
1674 "align-content", "row-gap", "column-gap",
1675 "grid-template-columns", "grid-template-rows",
1676 "grid-column", "grid-row",
1677 "grid-column-start", "grid-column-end", "grid-row-start", "grid-row-end",
1678 "grid-auto-flow", "grid-auto-rows", "grid-auto-columns",
1679 "position", "top", "right", "bottom", "left", "aspect-ratio",
1681 "background", "background-color", "background-image",
1683 "color", "font-size", "font-weight", "font-family", "font-style", "text-align",
1685 "letter-spacing", "word-spacing", "line-height", "white-space",
1686 "text-decoration", "text-decoration-line",
1687 "overflow-wrap", "word-wrap", "word-break",
1688];
1689
1690fn is_honored(property: &str) -> bool {
1691 HONORED_PROPERTIES.contains(&property)
1692}
1693
1694fn warn_if_unhonored(property: &str) {
1698 use std::collections::HashSet;
1699 use std::sync::{Mutex, OnceLock};
1700 static SEEN: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
1701
1702 if property.starts_with("--") || is_honored(property) {
1705 return;
1706 }
1707 let message =
1708 format!("CSS property `{property}` is parsed but not yet honored, it will have no effect");
1709 warn(message.clone());
1710 let seen = SEEN.get_or_init(|| Mutex::new(HashSet::new()));
1711 let Ok(mut seen) = seen.lock() else { return };
1712 if seen.insert(property.to_string()) {
1713 echo(&message);
1714 }
1715}
1716
1717fn parse_selector(text: &str) -> Option<(Vec<Compound>, Vec<Combinator>, (u32, u32, u32))> {
1723 let chars: Vec<char> = text.chars().collect();
1724 let mut i = 0;
1725 let mut chain = Vec::new();
1726 let mut combs = Vec::new();
1727 let mut spec = (0u32, 0u32, 0u32);
1728 let mut pending: Option<Combinator> = None;
1730
1731 while i < chars.len() {
1732 let c = chars[i];
1733 if c.is_whitespace() {
1734 i += 1;
1735 continue;
1736 }
1737 if let Some(comb) = combinator_of(c) {
1738 pending = Some(comb);
1739 i += 1;
1740 continue;
1741 }
1742 let start = i;
1745 let mut depth = 0i32;
1746 while i < chars.len() {
1747 let d = chars[i];
1748 if d == '[' || d == '(' {
1749 depth += 1;
1750 } else if d == ']' || d == ')' {
1751 depth -= 1;
1752 } else if depth == 0 && (d.is_whitespace() || combinator_of(d).is_some()) {
1753 break;
1754 }
1755 i += 1;
1756 }
1757 let token: String = chars[start..i].iter().collect();
1758 let compound = parse_compound(&token, &mut spec)?;
1759 if !chain.is_empty() {
1760 combs.push(pending.take().unwrap_or(Combinator::Descendant));
1762 }
1763 pending = None;
1764 chain.push(compound);
1765 }
1766 if chain.is_empty() {
1767 return None;
1768 }
1769 Some((chain, combs, spec))
1770}
1771
1772fn combinator_of(c: char) -> Option<Combinator> {
1773 match c {
1774 '>' => Some(Combinator::Child),
1775 '+' => Some(Combinator::NextSibling),
1776 '~' => Some(Combinator::SubsequentSibling),
1777 _ => None,
1778 }
1779}
1780
1781fn parse_compound(token: &str, spec: &mut (u32, u32, u32)) -> Option<Compound> {
1782 let mut c = Compound::default();
1783 let chars: Vec<char> = token.chars().collect();
1784 let mut i = 0;
1785
1786 let mut tag = String::new();
1788 while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '-' || chars[i] == '*') {
1789 tag.push(chars[i]);
1790 i += 1;
1791 }
1792 if !tag.is_empty() && tag != "*" {
1793 c.tag = Some(tag);
1794 spec.2 += 1;
1795 }
1796
1797 while i < chars.len() {
1798 match chars[i] {
1799 '.' => {
1800 i += 1;
1801 let mut cls = String::new();
1802 while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '-' || chars[i] == '_') {
1803 cls.push(chars[i]);
1804 i += 1;
1805 }
1806 if !cls.is_empty() {
1807 c.classes.push(cls);
1808 spec.1 += 1;
1809 }
1810 }
1811 '#' => {
1812 i += 1;
1813 let mut id = String::new();
1814 while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '-' || chars[i] == '_') {
1815 id.push(chars[i]);
1816 i += 1;
1817 }
1818 if !id.is_empty() {
1819 c.id = Some(id);
1820 spec.0 += 1;
1821 }
1822 }
1823 '[' => {
1824 let end = token.find(']')?;
1826 let inner = &token[i + 1..end];
1827 if let Some(rest) = inner.strip_prefix("role") {
1828 let val = rest
1829 .trim_start_matches('=')
1830 .trim_matches(|ch| ch == '"' || ch == '\'');
1831 c.role = Some(val.to_string());
1832 spec.1 += 1;
1833 }
1834 i = end + 1;
1835 }
1836 ':' => {
1837 i += 1;
1841 let mut name = String::new();
1842 if i < chars.len() && chars[i] == ':' {
1846 name.push(':');
1847 i += 1;
1848 }
1849 while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '-') {
1850 name.push(chars[i]);
1851 i += 1;
1852 }
1853 if i < chars.len() && chars[i] == '(' {
1856 let mut depth = 0i32;
1857 while i < chars.len() {
1858 if chars[i] == '(' {
1859 depth += 1;
1860 } else if chars[i] == ')' {
1861 depth -= 1;
1862 }
1863 name.push(chars[i]);
1864 i += 1;
1865 if depth == 0 {
1866 break;
1867 }
1868 }
1869 }
1870 if !name.is_empty() {
1871 let pseudo = Pseudo::parse(&name);
1872 if let Pseudo::Unknown(n) = &pseudo {
1873 warn_unknown_pseudo(n);
1874 }
1875 c.pseudos.push(pseudo);
1876 spec.1 += 1;
1878 }
1879 }
1880 _ => break,
1881 }
1882 }
1883 Some(c)
1884}
1885
1886fn warn_unknown_pseudo(name: &str) {
1890 use std::sync::{Mutex, OnceLock};
1891 static SEEN: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
1892 let message = format!(
1893 "pseudo-class `:{name}` is not supported, rules using it will never match \
1894 (supported: :hover, :focus, :active, :checked)"
1895 );
1896 warn(message.clone());
1897 let seen = SEEN.get_or_init(|| Mutex::new(HashSet::new()));
1898 let Ok(mut seen) = seen.lock() else { return };
1899 if seen.insert(name.to_string()) {
1900 echo(&message);
1901 }
1902}
1903
1904fn matches_compound(c: &Compound, el: &ElemDesc) -> bool {
1907 if let Some(t) = &c.tag {
1908 if *t != el.tag {
1909 return false;
1910 }
1911 }
1912 if let Some(id) = &c.id {
1913 if Some(id.as_str()) != el.id.as_deref() {
1914 return false;
1915 }
1916 }
1917 for cls in &c.classes {
1918 if !el.classes.iter().any(|x| x == cls) {
1919 return false;
1920 }
1921 }
1922 if let Some(r) = &c.role {
1923 if !el.role.as_deref().is_some_and(|er| er.eq_ignore_ascii_case(r)) {
1925 return false;
1926 }
1927 }
1928 if !c.pseudos.iter().all(|p| p.holds(&el.states)) {
1930 return false;
1931 }
1932 true
1933}
1934
1935fn matches_chain(
1946 chain: &[Compound],
1947 combs: &[Combinator],
1948 el: &ElemDesc,
1949 ancestors: &[AncNode],
1950 prev: &[ElemDesc],
1951) -> bool {
1952 let Some((last, rest)) = chain.split_last() else {
1953 return false;
1954 };
1955 if !matches_compound(last, el) {
1956 return false;
1957 }
1958 if rest.is_empty() {
1959 return true;
1960 }
1961 let (comb, rest_combs) = combs.split_last().expect("combs matches chain length");
1964 match comb {
1965 Combinator::Descendant => (0..ancestors.len()).rev().any(|i| {
1966 matches_chain(rest, rest_combs, &ancestors[i].desc, &ancestors[..i], &ancestors[i].prev)
1967 }),
1968 Combinator::Child => {
1969 let Some((parent, up)) = ancestors.split_last() else {
1970 return false;
1971 };
1972 matches_chain(rest, rest_combs, &parent.desc, up, &parent.prev)
1973 }
1974 Combinator::NextSibling => {
1975 let Some((sib, earlier)) = prev.split_last() else {
1976 return false;
1977 };
1978 matches_chain(rest, rest_combs, sib, ancestors, earlier)
1979 }
1980 Combinator::SubsequentSibling => (0..prev.len())
1981 .rev()
1982 .any(|i| matches_chain(rest, rest_combs, &prev[i], ancestors, &prev[..i])),
1983 }
1984}
1985
1986fn pointer_state_sensitive(desc: &ElemDesc, rules: &[Rule]) -> bool {
2000 let probe = ElemDesc {
2003 states: ElemStates { hover: true, active: true, ..desc.states },
2004 ..desc.clone()
2005 };
2006 rules.iter().any(|rule| {
2007 rule.chain.iter().any(|compound| {
2008 compound.pseudos.iter().any(Pseudo::is_pointer_state)
2009 && matches_compound(compound, &probe)
2010 })
2011 })
2012}
2013
2014fn matched_props(
2016 desc: &ElemDesc,
2017 ancestors: &[AncNode],
2018 prev: &[ElemDesc],
2019 rules: &[Rule],
2020) -> HashMap<String, String> {
2021 let mut matched: Vec<&Rule> = rules
2022 .iter()
2023 .filter(|r| matches_chain(&r.chain, &r.combs, desc, ancestors, prev))
2024 .collect();
2025 matched.sort_by(|a, b| a.specificity.cmp(&b.specificity).then(a.order.cmp(&b.order)));
2026
2027 let mut props: HashMap<String, String> = HashMap::new();
2028 for rule in matched {
2029 for (k, v) in &rule.decls {
2030 props.insert(k.clone(), v.clone());
2031 }
2032 }
2033 props
2034}
2035
2036#[allow(clippy::too_many_arguments)]
2044fn build_node(
2045 el: &Element,
2046 rules: &[Rule],
2047 comps: &Components,
2048 ancestors: &mut Vec<AncNode>,
2049 prev: &[ElemDesc],
2050 inherited: &Inherited,
2051 engine: &mut Engine,
2052 locals: &Locals,
2053 path: &[usize],
2054 tpl_path: &[usize],
2055 reg: &mut BindingRegistry,
2056 state: &InteractionState,
2057 instances: &mut Instances,
2058 instance: Option<&str>,
2060 slot: Option<Slot>,
2061 row: Option<&str>,
2065) -> LayoutNode {
2066 if let Some(component) = comps.get(&el.tag) {
2068 return expand_component(
2069 el, component, comps, inherited, engine, locals, path, tpl_path, reg, state, rules,
2070 instances, instance, row, &Locals::new(), None,
2071 );
2072 }
2073
2074 let mut desc = ElemDesc::of(el);
2075 let toggle = Toggle::of(el, engine, locals);
2080 if toggle.as_ref().is_some_and(|t| t.checked) {
2081 desc.states.checked = true;
2082 desc.classes.push("checked".to_string());
2083 }
2084 desc.states.hover = state.hovers(path);
2088 desc.states.active = state.activates(path);
2089 desc.states.focus = match (&state.focused_model, el.attr("r-model")) {
2092 (Some(focused), Some(model)) => focused == model && state.focused_row.as_deref() == row,
2093 _ => false,
2094 };
2095 let mut dyn_deps: HashSet<String> = HashSet::new();
2098 let to = el.attr("to").map(str::to_string).or_else(|| {
2102 let expr = el.attr(":to")?;
2103 let (value, deps) = engine.eval_value_tracked(expr, locals);
2104 dyn_deps.extend(deps);
2105 value.map(|v| v.to_display())
2106 });
2107 if let Some(to) = &to {
2112 let (value, deps) = engine.eval_value_tracked(rux_script::ROUTE_SIGNAL, locals);
2113 dyn_deps.extend(deps);
2114 desc.states.current =
2115 value.is_some_and(|v| match_route(to, &v.to_display()).is_some());
2116 }
2117 if let Some(expr) = el.attr(":class") {
2118 let (value, deps) = engine.eval_value_tracked(expr, locals);
2119 dyn_deps.extend(deps);
2120 if let Some(v) = value {
2121 desc.classes.extend(class_list(&v));
2122 }
2123 }
2124
2125 let state_path = pointer_state_sensitive(&desc, rules).then(|| path.to_vec());
2129
2130 let mut props = matched_props(&desc, ancestors, prev, rules);
2131 if let Some(s) = el.attr("style") {
2134 merge_inline_style(&mut props, s);
2135 }
2136 if let Some(expr) = el.attr(":style") {
2137 let (value, deps) = engine.eval_value_tracked(expr, locals);
2138 dyn_deps.extend(deps);
2139 match value {
2140 Some(Value::Map(entries)) => {
2142 for (k, v) in entries {
2143 props.insert(k.to_ascii_lowercase(), v.to_display());
2144 }
2145 }
2146 Some(v) => merge_inline_style(&mut props, &v.to_display()),
2148 None => {}
2149 }
2150 }
2151 if !dyn_deps.is_empty() {
2153 reg.styled.push(StyledBinding { path: path.to_vec(), deps: dyn_deps });
2154 }
2155
2156 let vars = take_vars(&mut props, &inherited.vars);
2161 for value in props.values_mut() {
2166 if value.contains("var(") {
2167 *value = resolve_vars(value, &vars, 0);
2168 }
2169 }
2170
2171 let style = interpret(&props);
2172 let on_tap = el.attr("@tap").map(|h| bind_locals(h, locals)).or_else(|| {
2181 to.as_ref().map(|p| format!("navigate({})", Value::Text(p.clone()).to_rhai_literal()))
2182 });
2183 let hidden = el.attr("r-show").is_some_and(|e| {
2187 let (v, deps) = engine.eval_bool_tracked(e, locals);
2188 reg.show.push(ShowBinding {
2189 path: path.to_vec(),
2190 cond: e.to_string(),
2191 locals: locals.clone(),
2192 deps,
2193 });
2194 !v
2195 });
2196
2197 let color = props
2199 .get("color")
2200 .and_then(|v| parse_color(v))
2201 .unwrap_or(inherited.color);
2202 let font_size = props
2203 .get("font-size")
2204 .and_then(|v| parse_px(first(v)))
2205 .unwrap_or(inherited.font_size);
2206 let font_family = props
2209 .get("font-family")
2210 .filter(|v| !v.trim().is_empty() && v.trim() != "inherit")
2211 .map(|v| v.trim().to_string())
2212 .or_else(|| inherited.font_family.clone());
2213 let letter_spacing = props.get("letter-spacing").and_then(|v| parse_spacing(v));
2216 let word_spacing = props.get("word-spacing").and_then(|v| parse_spacing(v));
2217 let line_height = props.get("line-height").and_then(|v| parse_line_height(v, font_size));
2220 let italic = props
2221 .get("font-style")
2222 .is_some_and(|v| matches!(v.trim(), "italic" | "oblique"));
2223 let decoration = props.get("text-decoration-line").or_else(|| props.get("text-decoration"));
2225 let underline = decoration.is_some_and(|v| v.split_whitespace().any(|t| t == "underline"));
2226 let strikethrough = decoration.is_some_and(|v| v.split_whitespace().any(|t| t == "line-through"));
2227 let nowrap = props
2230 .get("white-space")
2231 .is_some_and(|v| matches!(v.trim(), "nowrap" | "pre"));
2232
2233 if el.tag == "text" {
2234 let weight = props.get("font-weight").and_then(|v| parse_weight(v)).unwrap_or(400);
2235 let align = props
2236 .get("text-align")
2237 .map(|v| parse_text_align(v))
2238 .unwrap_or_default();
2239 let wrap = style.text_wrap;
2240 let template = text_template(el);
2244 let (text, deps) = interpolate_tracked(&template, engine, locals);
2245 if template.contains("{{") {
2246 reg.text.push(TextBinding {
2247 path: path.to_vec(),
2248 template,
2249 locals: locals.clone(),
2250 deps,
2251 });
2252 }
2253 let mut node = LayoutNode::text(
2254 style,
2255 TextContent {
2256 text,
2257 font_size,
2258 weight,
2259 color,
2260 align,
2261 wrap,
2262 font_family: font_family.clone(),
2263 letter_spacing,
2264 word_spacing,
2265 line_height,
2266 italic,
2267 underline,
2268 strikethrough,
2269 nowrap,
2270 caret: None,
2271 selection: None,
2272 preedit: None,
2273 },
2274 );
2275 node.on_tap = on_tap;
2276 node.hidden = hidden;
2277 node.id = el.attr("id").map(str::to_string);
2278 node.label_for = el.attr("for").map(str::to_string);
2279 node.state_path = state_path.clone();
2280 node.access = Access {
2284 role: explicit_access_role(el).unwrap_or(if to.is_some() {
2285 AccessRole::Link
2286 } else if node.on_tap.is_some() {
2287 AccessRole::Button
2288 } else {
2289 AccessRole::Label
2290 }),
2291 label: authored_label(el).or_else(|| {
2292 node.text.as_ref().map(|t| t.text.trim().to_string()).filter(|t| !t.is_empty())
2293 }),
2294 ..Access::default()
2295 };
2296 return node;
2297 }
2298
2299 if el.tag == "image" {
2303 let src = el
2304 .attr(":src")
2305 .map(|e| {
2306 let (v, deps) = engine.eval_display_tracked(e, locals);
2309 reg.src.push(AttrBinding {
2310 path: path.to_vec(),
2311 expr: e.to_string(),
2312 locals: locals.clone(),
2313 deps,
2314 });
2315 v
2316 })
2317 .or_else(|| el.attr("src").map(str::to_string))
2318 .unwrap_or_default();
2319 let mut node = LayoutNode::image(
2320 style,
2321 ImageContent {
2322 src,
2323 intrinsic: (0.0, 0.0),
2324 },
2325 );
2326 node.on_tap = on_tap;
2327 node.hidden = hidden;
2328 node.id = el.attr("id").map(str::to_string);
2329 node.label_for = el.attr("for").map(str::to_string);
2330 node.state_path = state_path.clone();
2331 node.access = Access {
2334 role: explicit_access_role(el).unwrap_or(AccessRole::Image),
2335 label: authored_label(el),
2336 ..Access::default()
2337 };
2338 return node;
2339 }
2340
2341 if let Some(Toggle { radio, checked, deps }) = toggle {
2347 reg.toggles.push(ToggleBinding { path: path.to_vec(), deps });
2349 let model = el.attr("r-model").unwrap_or_default().to_string();
2350 let value = el.attr("value").unwrap_or_default().to_string();
2351
2352 let mut style = style;
2353 if style.display == Display::Block {
2355 style.display = Display::Flex;
2356 }
2357 style.justify.get_or_insert(Justify::Center);
2358 style.align.get_or_insert(Align::Center);
2359 if radio && style.radius == [0.0; 4] {
2361 style.radius = [CIRCLE; 4];
2362 }
2363
2364 let mut node = LayoutNode::new(style);
2365 if checked {
2366 node.children.push(if radio {
2367 LayoutNode::new(Style {
2369 display: Display::Flex,
2370 width: Some(Len::Pct(0.5)),
2371 height: Some(Len::Pct(0.5)),
2372 background: Some(Background::Color(color)),
2373 radius: [CIRCLE; 4],
2374 ..Default::default()
2375 })
2376 } else {
2377 let mut mark = LayoutNode::new(Style {
2380 display: Display::Flex,
2381 width: Some(Len::Pct(0.68)),
2382 height: Some(Len::Pct(0.68)),
2383 ..Default::default()
2384 });
2385 mark.tick = Some(color);
2386 mark
2387 });
2388 }
2389 node.on_tap = on_tap.or_else(|| {
2390 if model.is_empty() {
2391 None
2392 } else if radio {
2393 Some(format!("{model} = \"{value}\""))
2394 } else {
2395 Some(format!("{model} = !{model}"))
2396 }
2397 });
2398 node.hidden = hidden;
2399 node.id = el.attr("id").map(str::to_string);
2400 node.label_for = el.attr("for").map(str::to_string);
2401 node.state_path = state_path.clone();
2402 node.access = Access {
2405 role: if radio { AccessRole::RadioButton } else { AccessRole::CheckBox },
2406 label: authored_label(el),
2407 placeholder: None,
2408 checked: Some(checked),
2409 value: None,
2410 };
2411 return node;
2412 }
2413
2414 if el.tag == "input" {
2418 let mut style = style;
2419 let multiline = el.attr("type") == Some("textarea");
2420 if style.width.is_none() {
2424 style.width = Some(Len::Pct(1.0));
2425 }
2426 if style.overflow == Overflow::Visible {
2427 style.overflow = if multiline { Overflow::Scroll } else { Overflow::Clip };
2428 }
2429 let options = (el.attr("type") == Some("select"))
2432 .then(|| {
2433 el.attr(":options")
2434 .and_then(|e| {
2435 let (v, deps) = engine.eval_value_tracked(e, locals);
2437 reg.options.push(AttrBinding {
2438 path: path.to_vec(),
2439 expr: e.to_string(),
2440 locals: locals.clone(),
2441 deps,
2442 });
2443 v
2444 })
2445 .and_then(|v| v.as_list().map(|items| items.iter().map(Value::to_display).collect()))
2446 .unwrap_or_default()
2447 });
2448 let model = el.attr("r-model").map(str::to_string);
2449 let placeholder = el.attr("placeholder").unwrap_or_default().to_string();
2450 const PLACEHOLDER_COLOR: Rgba = Rgba::new(0.42, 0.44, 0.52, 1.0); let value = model
2456 .as_deref()
2457 .map(|m| {
2458 let (v, deps) = engine.eval_display_tracked(m, locals);
2459 reg.value.push(ValueBinding {
2460 path: path.to_vec(),
2461 model: m.to_string(),
2462 row: row.map(str::to_string),
2463 placeholder: placeholder.clone(),
2464 color,
2465 placeholder_color: PLACEHOLDER_COLOR,
2466 locals: locals.clone(),
2467 deps,
2468 });
2469 v
2470 })
2471 .unwrap_or_default();
2472 let (shown, shown_color) = if value.is_empty() {
2473 (placeholder.clone(), PLACEHOLDER_COLOR)
2474 } else {
2475 (value, color)
2476 };
2477 let text_child = LayoutNode::text(
2478 Style::default(),
2479 TextContent {
2480 text: shown,
2481 font_size,
2482 weight: 400,
2483 color: shown_color,
2484 align: TextAlign::Start,
2485 wrap: style.text_wrap,
2486 font_family: font_family.clone(),
2487 letter_spacing,
2488 word_spacing,
2489 line_height,
2490 italic,
2491 underline,
2492 strikethrough,
2493 nowrap: !multiline,
2495 caret: None,
2497 selection: None,
2498 preedit: None,
2499 },
2500 );
2501 let mut node = LayoutNode::new(style);
2502 node.children.push(text_child);
2503 node.model = model;
2504 node.multiline = multiline;
2505 node.options = options;
2506 node.on_tap = on_tap;
2507 node.hidden = hidden;
2508 node.id = el.attr("id").map(str::to_string);
2509 node.label_for = el.attr("for").map(str::to_string);
2510 node.state_path = state_path.clone();
2511 node.access = Access {
2515 role: explicit_access_role(el).unwrap_or(if node.options.is_some() {
2516 AccessRole::ComboBox
2517 } else if multiline {
2518 AccessRole::MultilineTextInput
2519 } else {
2520 AccessRole::TextInput
2521 }),
2522 label: authored_label(el),
2523 placeholder: (!placeholder.is_empty()).then(|| placeholder.clone()),
2526 value: node
2527 .model
2528 .as_deref()
2529 .map(|m| engine.eval_display(m, locals))
2530 .filter(|v| !v.is_empty()),
2531 checked: None,
2532 };
2533 return node;
2534 }
2535
2536 ancestors.push(AncNode { desc, prev: prev.to_vec() });
2537 let element_children = element_children(el);
2538 let (children, structural_deps) = build_children(
2539 &element_children,
2540 rules,
2541 comps,
2542 ancestors,
2543 &Inherited { color, font_size, font_family, vars: Rc::clone(&vars) },
2544 engine,
2545 locals,
2546 path,
2547 tpl_path,
2548 reg,
2549 state,
2550 instances,
2551 instance,
2552 slot,
2555 row,
2556 );
2557 ancestors.pop();
2558 if !structural_deps.is_empty() {
2561 reg.structural_parents.push(StructuralParent {
2562 tree_path: path.to_vec(),
2563 tpl_path: tpl_path.to_vec(),
2564 deps: structural_deps,
2565 });
2566 }
2567
2568 let mut node = LayoutNode {
2569 style,
2570 text: None,
2571 image: None,
2572 tick: None,
2573 children,
2574 on_tap,
2575 model: None,
2576 multiline: false,
2577 options: None,
2578 hidden,
2579 id: el.attr("id").map(str::to_string),
2580 label_for: el.attr("for").map(str::to_string),
2581 focus_model: None,
2582 state_path,
2583 access: Access::default(),
2584 instance: instance.map(str::to_string),
2587 key: None,
2590 };
2591 let role = explicit_access_role(el).unwrap_or(if to.is_some() {
2596 AccessRole::Link
2597 } else if node.on_tap.is_some() {
2598 AccessRole::Button
2599 } else if node.style.overflow == Overflow::Scroll {
2600 AccessRole::ScrollView
2601 } else {
2602 AccessRole::None
2603 });
2604 if role.is_meaningful() {
2605 let label = authored_label(el).or_else(|| {
2606 let text = subtree_text(&node);
2607 (!text.is_empty()).then_some(text)
2608 });
2609 node.access = Access { role, label, ..Access::default() };
2610 }
2611 node
2612}
2613
2614#[allow(clippy::too_many_arguments)]
2618fn expand_component(
2619 el: &Element,
2620 component: &Component,
2621 comps: &Components,
2622 inherited: &Inherited,
2623 engine: &mut Engine,
2624 parent_locals: &Locals,
2625 path: &[usize],
2626 tpl_path: &[usize],
2627 reg: &mut BindingRegistry,
2628 state: &InteractionState,
2629 caller_rules: &[Rule],
2631 instances: &mut Instances,
2632 caller: Option<&str>,
2634 row: Option<&str>,
2635 extra_props: &Locals,
2638 route: Option<&str>,
2641) -> LayoutNode {
2642 let mut props: Locals = Vec::new();
2643 let mut prop_deps: HashSet<String> = HashSet::new();
2644 let mut listeners: Vec<(String, String)> = Vec::new();
2645 for (key, expr) in &el.attrs {
2646 if let Some(name) = key.strip_prefix('@') {
2651 listeners.push((name.to_string(), bind_locals(expr, parent_locals)));
2652 continue;
2653 }
2654 if let Some(name) = key.strip_prefix(':') {
2655 let (value, deps) = engine.eval_value_tracked(expr, parent_locals);
2658 prop_deps.extend(deps);
2659 if let Some(value) = value {
2660 props.push((name.to_string(), value));
2661 }
2662 }
2663 }
2664 props.extend(extra_props.iter().cloned());
2669 if !prop_deps.is_empty() {
2671 reg.components.push(ComponentBinding {
2672 path: path.to_vec(),
2673 deps: prop_deps,
2674 });
2675 }
2676
2677 let supplied = element_children(el);
2681 let slot = Slot { children: &supplied, locals: parent_locals, rules: caller_rules };
2682
2683 let key = instance_key(tpl_path, row);
2688 let entry = instances.entry(key.clone()).or_insert_with(|| Instance {
2689 state: engine.init_scope(&component.script),
2690 ..Instance::default()
2691 });
2692 entry.touched = true;
2693 entry.props = props.clone();
2694 entry.listeners = listeners;
2697 entry.caller = caller.map(str::to_string);
2698 entry.route = route.map(str::to_string);
2699 let mut locals: Locals = entry.state.clone();
2702 locals.extend(props);
2703
2704 let mut ancestors: Vec<AncNode> = Vec::new();
2707 build_node(
2708 &component.template,
2709 &component.rules,
2710 comps,
2711 &mut ancestors,
2712 &[],
2713 inherited,
2714 engine,
2715 &locals,
2716 path,
2717 tpl_path,
2718 reg,
2719 state,
2720 instances,
2721 Some(key.as_str()),
2722 Some(slot),
2723 row,
2725 )
2726}
2727
2728fn match_route(pattern: &str, path: &str) -> Option<Locals> {
2739 fn segments(s: &str) -> Vec<&str> {
2740 s.split('/').filter(|p| !p.is_empty()).collect()
2741 }
2742 let pat = segments(pattern);
2743 let cur = segments(path);
2744 if pat.len() != cur.len() {
2745 return None;
2746 }
2747 let mut params: Locals = Vec::new();
2748 for (p, c) in pat.iter().zip(cur.iter()) {
2749 match p.strip_prefix(':') {
2750 Some(name) => params.push((
2754 name.to_string(),
2755 Value::Text(rux_script::percent_decode(c)),
2756 )),
2757 None if p == c => {}
2759 None => return None,
2760 }
2761 }
2762 Some(params)
2763}
2764
2765pub fn route_params(template: &Element, path: &str) -> Vec<(String, Value)> {
2780 let Some(router) = find_router(template) else { return Vec::new() };
2781 element_children(router)
2782 .into_iter()
2783 .filter(|r| r.tag == "route")
2784 .find_map(|r| match_route(r.attr("path")?, path))
2787 .unwrap_or_default()
2788}
2789
2790fn find_router(el: &Element) -> Option<&Element> {
2792 if el.tag == "router" {
2793 return Some(el);
2794 }
2795 element_children(el).into_iter().find_map(find_router)
2796}
2797
2798pub fn named_routes(template: &Element) -> Vec<(String, String)> {
2805 let Some(router) = find_router(template) else { return Vec::new() };
2806 element_children(router)
2807 .into_iter()
2808 .filter(|r| r.tag == "route")
2809 .filter_map(|r| Some((r.attr("name")?.to_string(), r.attr("path")?.to_string())))
2810 .collect()
2811}
2812
2813pub fn restore_scroll(template: &Element) -> bool {
2820 find_router(template)
2821 .and_then(|r| r.attr("restore-scroll"))
2822 .is_none_or(|v| v.trim() != "false")
2823}
2824
2825fn parse_for(expr: &str) -> Option<(&str, &str)> {
2827 let (var, coll) = expr.split_once(" in ")?;
2828 Some((var.trim(), coll.trim()))
2829}
2830
2831#[allow(clippy::too_many_arguments)]
2834fn build_children(
2835 elements: &[&Element],
2836 rules: &[Rule],
2837 comps: &Components,
2838 ancestors: &mut Vec<AncNode>,
2839 inherited: &Inherited,
2840 engine: &mut Engine,
2841 locals: &Locals,
2842 path: &[usize],
2843 tpl_path: &[usize],
2844 reg: &mut BindingRegistry,
2845 state: &InteractionState,
2846 instances: &mut Instances,
2847 instance: Option<&str>,
2848 slot: Option<Slot>,
2849 row: Option<&str>,
2850) -> (Vec<LayoutNode>, HashSet<String>) {
2851 let mut out = Vec::new();
2852 let mut structural_deps: HashSet<String> = HashSet::new();
2855 let mut prev: Vec<ElemDesc> = Vec::new();
2859 let mut in_chain = false;
2861 let mut chain_satisfied = false;
2862
2863 let child_path = |out: &Vec<LayoutNode>| -> Vec<usize> {
2866 path.iter().copied().chain(std::iter::once(out.len())).collect()
2867 };
2868 let child_tpl = |ti: usize| -> Vec<usize> {
2869 tpl_path.iter().copied().chain(std::iter::once(ti)).collect()
2870 };
2871
2872 for (ti, el) in elements.iter().enumerate() {
2873 let ctp = child_tpl(ti);
2874 if el.attr("r-key").is_some() && el.attr("r-for").is_none() {
2875 warn(format!(
2878 "`r-key` on <{}> does nothing without `r-for` on the same element",
2879 el.tag
2880 ));
2881 }
2882 if el.tag == "slot" {
2887 in_chain = false;
2888 let ctp = child_tpl(ti);
2889 let filled = slot.filter(|s| !s.children.is_empty());
2890 match filled {
2891 Some(s) => {
2892 for (si, child) in s.children.iter().enumerate() {
2893 let cp = child_path(&out);
2894 let mut ctp = ctp.clone();
2895 ctp.push(si);
2896 out.push(build_node(
2901 child, s.rules, comps, ancestors, &prev, inherited, engine, s.locals,
2902 &cp, &ctp, reg, state, instances, instance, None, row,
2903 ));
2904 prev.push(ElemDesc::of(child));
2905 }
2906 }
2907 None => {
2908 if slot.is_none() {
2912 warn(
2913 "`<slot>` outside a component renders its own children and nothing \
2914 else; only a component has a caller to take content from"
2915 .to_string(),
2916 );
2917 }
2918 for (si, child) in element_children(el).iter().enumerate() {
2919 let cp = child_path(&out);
2920 let mut ctp = ctp.clone();
2921 ctp.push(si);
2922 out.push(build_node(
2923 child, rules, comps, ancestors, &prev, inherited, engine, locals, &cp,
2924 &ctp, reg, state, instances, instance, slot, row,
2925 ));
2926 prev.push(ElemDesc::of(child));
2927 }
2928 }
2929 }
2930 continue;
2931 }
2932
2933 if el.tag == "router" {
2937 in_chain = false;
2938 let (value, deps) = engine.eval_value_tracked(rux_script::ROUTE_SIGNAL, locals);
2942 structural_deps.extend(deps);
2943 let current = value.map(|v| v.to_display()).unwrap_or_default();
2944
2945 let routes = element_children(el);
2946 for r in &routes {
2947 if r.tag != "route" {
2948 warn(format!(
2949 "<{}> inside <router> is ignored; a router's children are <route> \
2950 elements",
2951 r.tag
2952 ));
2953 }
2954 }
2955 let matched = routes.iter().enumerate().find_map(|(ri, r)| {
2958 if r.tag != "route" {
2959 return None;
2960 }
2961 let params = match_route(r.attr("path")?, ¤t)?;
2962 Some((ri, *r, params))
2963 });
2964 let chosen = matched.or_else(|| {
2965 routes
2966 .iter()
2967 .enumerate()
2968 .find(|(_, r)| r.tag == "route" && r.attr("fallback").is_some())
2969 .map(|(ri, r)| (ri, *r, Locals::new()))
2970 });
2971
2972 match chosen {
2973 Some((ri, route_el, params)) => {
2974 let Some(view) = route_el.attr("view") else {
2975 warn(format!(
2976 "<route path=\"{current}\"> has no `view`, so there is nothing to \
2977 render for it"
2978 ));
2979 continue;
2980 };
2981 let Some(component) = comps.get(view) else {
2982 warn(format!(
2983 "<route> names the view `{view}`, which is not imported; add \
2984 `use components::{view};` to the script"
2985 ));
2986 continue;
2987 };
2988 let cp = child_path(&out);
2989 let mut rtp = ctp.clone();
2990 rtp.push(ri);
2991 out.push(expand_component(
2995 route_el, component, comps, inherited, engine, locals, &cp, &rtp, reg,
2996 state, rules, instances, instance, row, ¶ms, Some(¤t),
2997 ));
2998 prev.push(ElemDesc::of(route_el));
2999 }
3000 None => {
3001 warn(format!(
3005 "no <route> matches `{current}`, and there is no `fallback` route, so \
3006 the router rendered nothing"
3007 ));
3008 }
3009 }
3010 continue;
3011 }
3012
3013 if let Some(for_expr) = el.attr("r-for") {
3016 in_chain = false;
3017 if let Some((var, coll)) = parse_for(for_expr) {
3018 let (value, deps) = engine.eval_value_tracked(coll, locals);
3022 structural_deps.extend(deps);
3023 let items = value.and_then(|v| v.as_list().map(<[Value]>::to_vec));
3024 if let Some(items) = items {
3025 let key_expr = el.attr("r-key");
3028 let mut seen_keys: Vec<String> = Vec::new();
3029 for item in items {
3030 let mut child_locals = locals.clone();
3031 child_locals.push((var.to_string(), item));
3032 let cp = child_path(&out);
3033 let key = key_expr.map(|expr| {
3039 let key = engine.eval_display(expr, &child_locals);
3040 if key.is_empty() {
3041 warn(format!(
3042 "`r-key=\"{expr}\"` evaluated to nothing on one row, so that \
3043 row has no identity and will be treated as a new one"
3044 ));
3045 } else if seen_keys.contains(&key) {
3046 warn(format!(
3050 "`r-key=\"{expr}\"` produced the duplicate key `{key}`; keys \
3051 must be unique within a list, or rows cannot be told apart"
3052 ));
3053 } else {
3054 seen_keys.push(key.clone());
3055 }
3056 key
3057 });
3058 let mut node = build_node(
3059 el, rules, comps, ancestors, &prev, inherited, engine, &child_locals,
3060 &cp, &ctp, reg, state, instances, instance, slot,
3061 key.as_deref().or(row),
3064 );
3065 node.key = key;
3066 out.push(node);
3067 prev.push(ElemDesc::of(el));
3068 }
3069 }
3070 }
3071 continue;
3072 }
3073
3074 if let Some(cond) = el.attr("r-if") {
3076 in_chain = true;
3077 let (v, deps) = engine.eval_bool_tracked(cond, locals);
3078 structural_deps.extend(deps);
3079 chain_satisfied = v;
3080 if chain_satisfied {
3081 let cp = child_path(&out);
3082 out.push(build_node(el, rules, comps, ancestors, &prev, inherited, engine, locals, &cp, &ctp, reg, state, instances, instance, slot, row));
3083 prev.push(ElemDesc::of(el));
3084 }
3085 continue;
3086 }
3087 if let Some(cond) = el.attr("r-elif") {
3088 let taken = if in_chain && !chain_satisfied {
3089 let (v, deps) = engine.eval_bool_tracked(cond, locals);
3090 structural_deps.extend(deps);
3091 v
3092 } else {
3093 false
3094 };
3095 if taken {
3096 chain_satisfied = true;
3097 let cp = child_path(&out);
3098 out.push(build_node(el, rules, comps, ancestors, &prev, inherited, engine, locals, &cp, &ctp, reg, state, instances, instance, slot, row));
3099 prev.push(ElemDesc::of(el));
3100 }
3101 continue;
3102 }
3103 if el.attr("r-else").is_some() {
3104 if in_chain && !chain_satisfied {
3105 let cp = child_path(&out);
3106 out.push(build_node(el, rules, comps, ancestors, &prev, inherited, engine, locals, &cp, &ctp, reg, state, instances, instance, slot, row));
3107 prev.push(ElemDesc::of(el));
3108 }
3109 in_chain = false;
3110 continue;
3111 }
3112
3113 in_chain = false;
3115 let cp = child_path(&out);
3116 out.push(build_node(el, rules, comps, ancestors, &prev, inherited, engine, locals, &cp, &ctp, reg, state, instances, instance, slot, row));
3117 prev.push(ElemDesc::of(el));
3118 }
3119 (out, structural_deps)
3120}
3121
3122fn interpret(p: &HashMap<String, String>) -> Style {
3125 let mut st = Style::default();
3126 if let Some(v) = p.get("display") {
3127 st.display = match v.trim() {
3128 "flex" => Display::Flex,
3129 "grid" => Display::Grid,
3130 "inline" => Display::Inline,
3131 "none" => Display::None,
3132 _ => Display::Block,
3133 };
3134 }
3135 if let Some(v) = p.get("width") {
3136 st.width = parse_len(first(v));
3137 }
3138 if let Some(v) = p.get("height") {
3139 st.height = parse_len(first(v));
3140 }
3141 st.padding = box_sides(p, "padding");
3142 st.margin = box_sides(p, "margin");
3143 interpret_border(p, &mut st);
3144 if let Some(v) = p.get("gap") {
3145 if let Some(px) = parse_px(first(v)) {
3146 st.gap = px;
3147 }
3148 }
3149 if let Some(v) = p.get("min-width") {
3150 st.min_width = parse_len(first(v));
3151 }
3152 if let Some(v) = p.get("max-width") {
3153 st.max_width = parse_len(first(v));
3154 }
3155 if let Some(v) = p.get("min-height") {
3156 st.min_height = parse_len(first(v));
3157 }
3158 if let Some(v) = p.get("max-height") {
3159 st.max_height = parse_len(first(v));
3160 }
3161 if let Some(v) = p.get("grid-template-columns") {
3162 st.grid_columns = parse_tracks(v);
3163 }
3164 if let Some(v) = p.get("grid-template-rows") {
3165 st.grid_rows = parse_tracks(v);
3166 }
3167 if let Some(v) = p.get("grid-column") {
3170 st.grid_column = parse_grid_shorthand(v);
3171 }
3172 if let Some(v) = p.get("grid-row") {
3173 st.grid_row = parse_grid_shorthand(v);
3174 }
3175 if let Some(v) = p.get("grid-column-start") {
3176 st.grid_column.0 = parse_grid_place(v);
3177 }
3178 if let Some(v) = p.get("grid-column-end") {
3179 st.grid_column.1 = parse_grid_place(v);
3180 }
3181 if let Some(v) = p.get("grid-row-start") {
3182 st.grid_row.0 = parse_grid_place(v);
3183 }
3184 if let Some(v) = p.get("grid-row-end") {
3185 st.grid_row.1 = parse_grid_place(v);
3186 }
3187 if let Some(v) = p.get("grid-auto-flow") {
3188 let v = v.trim();
3189 let dense = v.contains("dense");
3190 st.grid_auto_flow = if v.contains("column") {
3191 if dense { GridFlow::ColumnDense } else { GridFlow::Column }
3192 } else if dense {
3193 GridFlow::RowDense
3194 } else {
3195 GridFlow::Row
3196 };
3197 }
3198 if let Some(v) = p.get("grid-auto-rows") {
3199 st.grid_auto_rows = parse_tracks(v);
3200 }
3201 if let Some(v) = p.get("grid-auto-columns") {
3202 st.grid_auto_columns = parse_tracks(v);
3203 }
3204 if let Some(v) = p.get("flex") {
3206 interpret_flex_shorthand(v.trim(), &mut st);
3207 }
3208 if let Some(v) = p.get("flex-grow") {
3209 if let Ok(g) = first(v).parse::<f32>() {
3210 st.grow = g;
3211 }
3212 }
3213 if let Some(v) = p.get("flex-shrink") {
3214 if let Ok(s) = first(v).parse::<f32>() {
3215 st.shrink = s.max(0.0);
3216 }
3217 }
3218 if let Some(v) = p.get("flex-basis") {
3219 st.basis = match first(v) {
3220 "auto" | "content" => None,
3221 l => parse_len(l),
3222 };
3223 }
3224 if let Some(v) = p.get("flex-wrap") {
3225 st.wrap = matches!(v.trim(), "wrap" | "wrap-reverse");
3226 }
3227 if let Some(v) = p.get("overflow-wrap").or_else(|| p.get("word-wrap")) {
3228 st.text_wrap = match v.trim() {
3229 "break-word" | "anywhere" => TextWrap::BreakWord,
3230 _ => TextWrap::Normal,
3231 };
3232 }
3233 if let Some(v) = p.get("word-break") {
3236 if v.trim() == "break-all" {
3237 st.text_wrap = TextWrap::Anywhere;
3238 }
3239 }
3240 if let Some(v) = p.get("opacity") {
3241 if let Ok(o) = first(v).parse::<f32>() {
3242 st.opacity = o.clamp(0.0, 1.0);
3243 }
3244 }
3245 if let Some(v) = p.get("flex-direction") {
3246 st.axis = if v.trim() == "column" { Axis::Column } else { Axis::Row };
3247 }
3248 if let Some(v) = p.get("justify-content") {
3249 st.justify = parse_justify(v);
3250 }
3251 if let Some(v) = p.get("align-items") {
3252 st.align = parse_align(v);
3253 }
3254 if let Some(v) = p.get("align-self") {
3256 st.align_self = parse_align(v);
3257 }
3258 if let Some(v) = p.get("justify-self") {
3259 st.justify_self = parse_align(v);
3260 }
3261 if let Some(v) = p.get("justify-items") {
3262 st.justify_items = parse_align(v);
3263 }
3264 if let Some(v) = p.get("align-content") {
3265 st.align_content = parse_justify(v);
3266 }
3267 if let Some(px) = p.get("row-gap").and_then(|v| parse_px(first(v))) {
3269 st.row_gap = Some(px);
3270 }
3271 if let Some(px) = p.get("column-gap").and_then(|v| parse_px(first(v))) {
3272 st.column_gap = Some(px);
3273 }
3274 if let Some(v) = p.get("position") {
3275 st.position = match v.trim() {
3276 "absolute" | "fixed" => Position::Absolute,
3277 _ => Position::Relative,
3278 };
3279 }
3280 for (i, side) in ["top", "right", "bottom", "left"].iter().enumerate() {
3281 if let Some(v) = p.get(*side) {
3282 st.inset[i] = if first(v) == "auto" { None } else { parse_len(first(v)) };
3283 }
3284 }
3285 if let Some(v) = p.get("aspect-ratio") {
3286 st.aspect_ratio = parse_aspect_ratio(v);
3287 }
3288 if let Some(v) = p
3291 .get("background")
3292 .or_else(|| p.get("background-image"))
3293 .or_else(|| p.get("background-color"))
3294 {
3295 st.background = parse_background(v);
3296 }
3297 if let Some(v) = p.get("transform") {
3298 st.transform = parse_transform(v);
3299 }
3300 if let Some(v) = p.get("box-shadow") {
3301 st.box_shadow = parse_box_shadow(v);
3302 }
3303 if let Some(v) = p.get("border-radius") {
3306 st.radius = parse_border_radius(v);
3307 }
3308 for (i, corner) in [
3309 "border-top-left-radius",
3310 "border-top-right-radius",
3311 "border-bottom-right-radius",
3312 "border-bottom-left-radius",
3313 ]
3314 .iter()
3315 .enumerate()
3316 {
3317 if let Some(px) = p.get(*corner).and_then(|v| parse_px(first(v))) {
3318 st.radius[i] = px;
3319 }
3320 }
3321 let values = ["overflow", "overflow-x", "overflow-y"]
3324 .iter()
3325 .filter_map(|k| p.get(*k))
3326 .map(|v| v.trim());
3327 for v in values {
3328 match v {
3329 "auto" | "scroll" => st.overflow = Overflow::Scroll,
3330 "hidden" | "clip" if st.overflow != Overflow::Scroll => st.overflow = Overflow::Clip,
3331 _ => {}
3332 }
3333 }
3334 if let Some(v) = p.get("cursor") {
3335 st.cursor = match v.trim() {
3338 "pointer" => Cursor::Pointer,
3339 _ => Cursor::Default,
3340 };
3341 }
3342 st
3343}
3344
3345fn interpret_flex_shorthand(v: &str, st: &mut Style) {
3349 match v {
3350 "none" => {
3351 st.grow = 0.0;
3352 st.shrink = 0.0;
3353 st.basis = None;
3354 return;
3355 }
3356 "auto" => {
3357 st.grow = 1.0;
3358 st.shrink = 1.0;
3359 st.basis = None;
3360 return;
3361 }
3362 "initial" => {
3363 st.grow = 0.0;
3364 st.shrink = 1.0;
3365 st.basis = None;
3366 return;
3367 }
3368 _ => {}
3369 }
3370
3371 let parts: Vec<&str> = v.split_whitespace().collect();
3372 let Some(grow) = parts.first().and_then(|g| g.parse::<f32>().ok()) else {
3373 return;
3374 };
3375 st.grow = grow;
3376 st.shrink = parts
3377 .get(1)
3378 .and_then(|s| s.parse::<f32>().ok())
3379 .unwrap_or(1.0)
3380 .max(0.0);
3381 st.basis = match parts.get(2) {
3382 Some(&"auto") | Some(&"content") => None,
3383 Some(b) => parse_len(b),
3384 None => Some(Len::Px(0.0)),
3386 };
3387}
3388
3389fn parse_align(v: &str) -> Option<Align> {
3391 match v.trim() {
3392 "center" => Some(Align::Center),
3393 "flex-end" | "end" => Some(Align::End),
3394 "stretch" => Some(Align::Stretch),
3395 "flex-start" | "start" => Some(Align::Start),
3396 _ => None,
3397 }
3398}
3399
3400fn parse_justify(v: &str) -> Option<Justify> {
3402 match v.trim() {
3403 "center" => Some(Justify::Center),
3404 "flex-end" | "end" => Some(Justify::End),
3405 "space-between" => Some(Justify::SpaceBetween),
3406 "space-around" => Some(Justify::SpaceAround),
3407 "flex-start" | "start" => Some(Justify::Start),
3408 _ => None,
3409 }
3410}
3411
3412fn parse_background(value: &str) -> Option<Background> {
3415 let v = value.trim();
3416 if let Some(inner) = gradient_args(v, "linear-gradient") {
3417 return parse_linear_gradient(inner).map(Background::Gradient);
3418 }
3419 if let Some(inner) = gradient_args(v, "radial-gradient") {
3420 return parse_radial_gradient(inner).map(Background::Gradient);
3421 }
3422 if let Some(inner) = gradient_args(v, "url") {
3423 let src = inner.trim().trim_matches(|c| c == '"' || c == '\'');
3425 if !src.is_empty() {
3426 return Some(Background::Image(src.to_string()));
3427 }
3428 }
3429 parse_color(v).map(Background::Color)
3430}
3431
3432fn gradient_args<'a>(v: &'a str, name: &str) -> Option<&'a str> {
3434 v.strip_prefix(name)?.trim_start().strip_prefix('(')?.strip_suffix(')')
3435}
3436
3437fn parse_linear_gradient(inner: &str) -> Option<Gradient> {
3440 let mut parts = split_top_level_commas(inner);
3441 if parts.is_empty() {
3442 return None;
3443 }
3444 let angle = parse_gradient_angle(parts[0].trim());
3446 if angle.is_some() {
3447 parts.remove(0);
3448 }
3449 let stops = parse_stops(&parts)?;
3450 Some(Gradient {
3451 kind: GradientKind::Linear {
3452 angle: angle.unwrap_or(std::f32::consts::PI), },
3454 stops,
3455 })
3456}
3457
3458fn parse_radial_gradient(inner: &str) -> Option<Gradient> {
3462 let mut parts = split_top_level_commas(inner);
3463 if parts.is_empty() {
3464 return None;
3465 }
3466 if parse_color(first(parts[0].trim())).is_none() && !parts[0].trim().is_empty() {
3468 parts.remove(0);
3469 }
3470 let stops = parse_stops(&parts)?;
3471 Some(Gradient { kind: GradientKind::Radial, stops })
3472}
3473
3474fn parse_gradient_angle(tok: &str) -> Option<f32> {
3477 if let Some(deg) = tok.strip_suffix("deg") {
3478 return deg.trim().parse::<f32>().ok().map(f32::to_radians);
3479 }
3480 if tok == "turn" {
3481 return None;
3482 }
3483 if let Some(rest) = tok.strip_suffix("turn") {
3484 return rest.trim().parse::<f32>().ok().map(|t| t * std::f32::consts::TAU);
3485 }
3486 let side = tok.strip_prefix("to ")?.trim();
3487 let deg = match side {
3489 "top" => 0.0,
3490 "right" => 90.0,
3491 "bottom" => 180.0,
3492 "left" => 270.0,
3493 "top right" | "right top" => 45.0,
3494 "bottom right" | "right bottom" => 135.0,
3495 "bottom left" | "left bottom" => 225.0,
3496 "top left" | "left top" => 315.0,
3497 _ => return None,
3498 };
3499 Some(f32::to_radians(deg))
3500}
3501
3502fn parse_stops(parts: &[&str]) -> Option<Vec<(Rgba, f32)>> {
3506 let mut colors = Vec::new();
3507 let mut positions: Vec<Option<f32>> = Vec::new();
3508 for part in parts {
3509 let part = part.trim();
3510 let mut toks = part.split_whitespace();
3511 let color = parse_color(toks.next()?)?;
3512 let pos = toks
3513 .next()
3514 .and_then(|p| p.strip_suffix('%'))
3515 .and_then(|p| p.trim().parse::<f32>().ok())
3516 .map(|p| (p / 100.0).clamp(0.0, 1.0));
3517 colors.push(color);
3518 positions.push(pos);
3519 }
3520 if colors.len() < 2 {
3521 return None;
3522 }
3523 let n = positions.len();
3525 positions[0].get_or_insert(0.0);
3526 positions[n - 1].get_or_insert(1.0);
3527 let mut i = 0;
3528 while i < n {
3529 if positions[i].is_some() {
3530 i += 1;
3531 continue;
3532 }
3533 let start = i - 1;
3534 let mut j = i;
3535 while j < n && positions[j].is_none() {
3536 j += 1;
3537 }
3538 let p0 = positions[start].unwrap();
3539 let p1 = positions[j].unwrap();
3540 let gap = j - start;
3541 for (k, slot) in (start + 1..j).enumerate() {
3542 positions[slot] = Some(p0 + (p1 - p0) * (k as f32 + 1.0) / gap as f32);
3543 }
3544 i = j;
3545 }
3546 Some(colors.into_iter().zip(positions.into_iter().map(Option::unwrap)).collect())
3547}
3548
3549fn split_top_level_commas(value: &str) -> Vec<&str> {
3551 let mut out = Vec::new();
3552 let mut depth = 0i32;
3553 let mut start = 0;
3554 for (i, c) in value.char_indices() {
3555 match c {
3556 '(' => depth += 1,
3557 ')' => depth -= 1,
3558 ',' if depth == 0 => {
3559 out.push(value[start..i].trim());
3560 start = i + 1;
3561 }
3562 _ => {}
3563 }
3564 }
3565 let last = value[start..].trim();
3566 if !last.is_empty() {
3567 out.push(last);
3568 }
3569 out
3570}
3571
3572fn parse_transform(value: &str) -> Option<Transform> {
3577 let mut m = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]; let mut any = false;
3579 let mut rest = value.trim();
3580 while let Some(open) = rest.find('(') {
3581 let name = rest[..open].trim().to_ascii_lowercase();
3582 let close = rest[open..].find(')')? + open;
3583 let args = &rest[open + 1..close];
3584 if let Some(f) = transform_fn(&name, args) {
3585 m = mat_mul(m, f);
3586 any = true;
3587 }
3588 rest = rest[close + 1..].trim_start();
3589 }
3590 any.then_some(m)
3591}
3592
3593fn transform_fn(name: &str, args: &str) -> Option<Transform> {
3595 let nums: Vec<&str> = args.split(',').map(str::trim).filter(|s| !s.is_empty()).collect();
3596 let num = |i: usize| nums.get(i).and_then(|s| s.parse::<f32>().ok());
3597 match name {
3598 "translate" => {
3599 let tx = parse_px(nums.first()?)?;
3600 let ty = nums.get(1).and_then(|s| parse_px(s)).unwrap_or(0.0);
3601 Some([1.0, 0.0, 0.0, 1.0, tx, ty])
3602 }
3603 "translatex" => Some([1.0, 0.0, 0.0, 1.0, parse_px(nums.first()?)?, 0.0]),
3604 "translatey" => Some([1.0, 0.0, 0.0, 1.0, 0.0, parse_px(nums.first()?)?]),
3605 "scale" => {
3606 let sx = num(0)?;
3607 let sy = num(1).unwrap_or(sx);
3608 Some([sx, 0.0, 0.0, sy, 0.0, 0.0])
3609 }
3610 "scalex" => Some([num(0)?, 0.0, 0.0, 1.0, 0.0, 0.0]),
3611 "scaley" => Some([1.0, 0.0, 0.0, num(0)?, 0.0, 0.0]),
3612 "rotate" => {
3613 let (sin, cos) = parse_angle(nums.first()?)?.sin_cos();
3614 Some([cos, sin, -sin, cos, 0.0, 0.0])
3615 }
3616 _ => None,
3617 }
3618}
3619
3620fn mat_mul(a: Transform, b: Transform) -> Transform {
3622 let [a1, b1, c1, d1, e1, f1] = a;
3623 let [a2, b2, c2, d2, e2, f2] = b;
3624 [
3625 a1 * a2 + c1 * b2,
3626 b1 * a2 + d1 * b2,
3627 a1 * c2 + c1 * d2,
3628 b1 * c2 + d1 * d2,
3629 a1 * e2 + c1 * f2 + e1,
3630 b1 * e2 + d1 * f2 + f1,
3631 ]
3632}
3633
3634fn parse_angle(s: &str) -> Option<f32> {
3636 let s = s.trim();
3637 if let Some(v) = s.strip_suffix("deg") {
3638 return v.trim().parse::<f32>().ok().map(f32::to_radians);
3639 }
3640 if let Some(v) = s.strip_suffix("grad") {
3641 return v.trim().parse::<f32>().ok().map(|g| g * std::f32::consts::PI / 200.0);
3642 }
3643 if let Some(v) = s.strip_suffix("turn") {
3644 return v.trim().parse::<f32>().ok().map(|t| t * std::f32::consts::TAU);
3645 }
3646 if let Some(v) = s.strip_suffix("rad") {
3647 return v.trim().parse::<f32>().ok();
3648 }
3649 s.parse::<f32>().ok().map(f32::to_radians)
3650}
3651
3652fn parse_box_shadow(value: &str) -> Option<BoxShadow> {
3656 let first = value.split(',').next().unwrap_or(value).trim();
3657 if first.is_empty() || first == "none" {
3658 return None;
3659 }
3660 let mut lengths = Vec::new();
3661 let mut color_parts = Vec::new();
3662 let mut inset = false;
3663 for tok in first.split_whitespace() {
3664 if tok == "inset" {
3665 inset = true;
3666 } else if let Some(px) = parse_px(tok) {
3667 lengths.push(px);
3668 } else {
3669 color_parts.push(tok);
3670 }
3671 }
3672 if lengths.len() < 2 {
3674 return None;
3675 }
3676 let color = parse_color(&color_parts.join(" ")).unwrap_or(Rgba::new(0.0, 0.0, 0.0, 1.0));
3677 Some(BoxShadow {
3678 dx: lengths[0],
3679 dy: lengths[1],
3680 blur: lengths.get(2).copied().unwrap_or(0.0),
3681 spread: lengths.get(3).copied().unwrap_or(0.0),
3682 color,
3683 inset,
3684 })
3685}
3686
3687fn parse_line_height(v: &str, font_size: f32) -> Option<f32> {
3690 let s = first(v);
3691 if s == "normal" {
3692 return None;
3693 }
3694 if s.ends_with("px") || s.ends_with("rem") || s.ends_with("em") {
3695 if let Some(em) = s.strip_suffix("em").filter(|e| !e.ends_with('r')) {
3697 return em.parse::<f32>().ok().map(|n| n * font_size);
3698 }
3699 return parse_len(s).and_then(|l| match l {
3700 Len::Px(px) => Some(px),
3701 _ => None,
3702 });
3703 }
3704 s.parse::<f32>().ok().map(|n| n * font_size)
3706}
3707
3708fn parse_spacing(v: &str) -> Option<f32> {
3710 match first(v) {
3711 "normal" => None,
3712 s => parse_px(s),
3713 }
3714}
3715
3716fn parse_aspect_ratio(v: &str) -> Option<f32> {
3718 if let Some((w, h)) = v.split_once('/') {
3719 let (w, h) = (w.trim().parse::<f32>().ok()?, h.trim().parse::<f32>().ok()?);
3720 return (h != 0.0).then_some(w / h);
3721 }
3722 v.trim().parse::<f32>().ok().filter(|r| *r > 0.0)
3723}
3724
3725fn first(s: &str) -> &str {
3726 s.split_whitespace().next().unwrap_or(s)
3727}
3728
3729fn parse_px(s: &str) -> Option<f32> {
3730 let s = s.trim();
3731 let s = s.strip_suffix("px").unwrap_or(s);
3732 s.parse::<f32>().ok()
3733}
3734
3735const REM_PX: f32 = 16.0;
3737
3738fn parse_len(s: &str) -> Option<Len> {
3741 let s = s.trim();
3742 if let Some(pct) = s.strip_suffix('%') {
3743 return pct.trim().parse::<f32>().ok().map(|v| Len::Pct(v / 100.0));
3744 }
3745 if let Some(n) = s.strip_suffix("dvh").or_else(|| s.strip_suffix("vh")) {
3746 return n.trim().parse::<f32>().ok().map(Len::Vh);
3747 }
3748 if let Some(n) = s.strip_suffix("vw") {
3749 return n.trim().parse::<f32>().ok().map(Len::Vw);
3750 }
3751 if let Some(n) = s.strip_suffix("rem") {
3752 return n.trim().parse::<f32>().ok().map(|v| Len::Px(v * REM_PX));
3753 }
3754 let n = s.strip_suffix("px").unwrap_or(s);
3755 n.parse::<f32>().ok().map(Len::Px)
3756}
3757
3758fn parse_grid_shorthand(value: &str) -> (GridPlace, GridPlace) {
3764 let mut parts = value.splitn(2, '/');
3765 let start = parts.next().map(parse_grid_place).unwrap_or_default();
3766 let end = parts.next().map(parse_grid_place).unwrap_or_default();
3767 (start, end)
3768}
3769
3770fn parse_grid_place(side: &str) -> GridPlace {
3773 let s = side.trim();
3774 if let Some(rest) = s.strip_prefix("span") {
3775 return rest.trim().parse::<u16>().ok().map_or(GridPlace::Auto, GridPlace::Span);
3776 }
3777 match s.parse::<i16>() {
3778 Ok(i) if i != 0 => GridPlace::Line(i),
3779 _ => GridPlace::Auto,
3780 }
3781}
3782
3783fn parse_tracks(value: &str) -> Vec<Track> {
3784 split_top_level(value)
3785 .into_iter()
3786 .map(|tok| {
3787 if let Some(args) = tok
3788 .strip_prefix("minmax(")
3789 .and_then(|s| s.strip_suffix(')'))
3790 {
3791 let mut parts = args.split(',');
3792 let lo = parts.next().map(parse_track_side).unwrap_or(TrackSide::Auto);
3793 let hi = parts.next().map(parse_track_side).unwrap_or(TrackSide::Auto);
3794 Track::MinMax(lo, hi)
3795 } else {
3796 match parse_track_side(tok) {
3797 TrackSide::Px(v) => Track::Px(v),
3798 TrackSide::Fr(f) => Track::Fr(f),
3799 TrackSide::Auto => Track::Auto,
3800 }
3801 }
3802 })
3803 .collect()
3804}
3805
3806fn parse_track_side(tok: &str) -> TrackSide {
3808 let tok = tok.trim();
3809 if let Some(fr) = tok.strip_suffix("fr") {
3810 TrackSide::Fr(fr.trim().parse().unwrap_or(1.0))
3811 } else if tok == "auto" {
3812 TrackSide::Auto
3813 } else {
3814 parse_px(tok).map(TrackSide::Px).unwrap_or(TrackSide::Auto)
3815 }
3816}
3817
3818fn split_top_level(value: &str) -> Vec<&str> {
3821 let mut out = Vec::new();
3822 let mut depth = 0i32;
3823 let mut start: Option<usize> = None;
3824 for (i, c) in value.char_indices() {
3825 if c == '(' {
3826 depth += 1;
3827 } else if c == ')' {
3828 depth -= 1;
3829 }
3830 if c.is_whitespace() && depth == 0 {
3831 if let Some(s) = start.take() {
3832 out.push(value[s..i].trim());
3833 }
3834 } else if start.is_none() {
3835 start = Some(i);
3836 }
3837 }
3838 if let Some(s) = start {
3839 out.push(value[s..].trim());
3840 }
3841 out.into_iter().filter(|t| !t.is_empty()).collect()
3842}
3843
3844fn parse_shorthand_sides(value: &str) -> Sides {
3847 let v: Vec<f32> = value
3848 .split_whitespace()
3849 .filter_map(parse_px)
3850 .collect();
3851 match v.len() {
3852 1 => Sides::uniform(v[0]),
3853 2 => Sides {
3854 top: v[0],
3855 right: v[1],
3856 bottom: v[0],
3857 left: v[1],
3858 },
3859 3 => Sides {
3860 top: v[0],
3861 right: v[1],
3862 bottom: v[2],
3863 left: v[1],
3864 },
3865 n if n >= 4 => Sides {
3866 top: v[0],
3867 right: v[1],
3868 bottom: v[2],
3869 left: v[3],
3870 },
3871 _ => Sides::default(),
3872 }
3873}
3874
3875fn parse_border_radius(value: &str) -> [f32; 4] {
3880 let horizontal = value.split('/').next().unwrap_or(value);
3881 let v: Vec<f32> = horizontal.split_whitespace().filter_map(parse_px).collect();
3882 match v.len() {
3883 1 => [v[0]; 4],
3884 2 => [v[0], v[1], v[0], v[1]],
3885 3 => [v[0], v[1], v[2], v[1]],
3886 n if n >= 4 => [v[0], v[1], v[2], v[3]],
3887 _ => [0.0; 4],
3888 }
3889}
3890
3891fn box_sides(p: &HashMap<String, String>, prop: &str) -> Sides {
3894 let mut sides = p
3895 .get(prop)
3896 .map(|v| parse_shorthand_sides(v))
3897 .unwrap_or_default();
3898 for side in ["top", "right", "bottom", "left"] {
3899 if let Some(v) = p.get(&format!("{prop}-{side}")) {
3900 if let Some(px) = parse_px(first(v)) {
3901 set_side(&mut sides, side, px);
3902 }
3903 }
3904 }
3905 sides
3906}
3907
3908fn interpret_border(p: &HashMap<String, String>, st: &mut Style) {
3911 if let Some(v) = p.get("border") {
3913 let (w, c) = parse_border(v);
3914 st.border = Sides::uniform(w);
3915 if c.is_some() {
3916 st.border_color = c;
3917 }
3918 }
3919 if let Some(v) = p.get("border-width") {
3920 st.border = parse_shorthand_sides(v);
3921 }
3922 if let Some(v) = p.get("border-color") {
3923 st.border_color = parse_color(v);
3924 }
3925 for side in ["top", "right", "bottom", "left"] {
3926 if let Some(v) = p.get(&format!("border-{side}")) {
3927 let (w, c) = parse_border(v);
3928 set_side(&mut st.border, side, w);
3929 if c.is_some() {
3930 st.border_color = c;
3931 }
3932 }
3933 if let Some(v) = p.get(&format!("border-{side}-width")) {
3934 if let Some(px) = parse_px(first(v)) {
3935 set_side(&mut st.border, side, px);
3936 }
3937 }
3938 }
3939}
3940
3941fn set_side(sides: &mut Sides, side: &str, value: f32) {
3942 match side {
3943 "top" => sides.top = value,
3944 "right" => sides.right = value,
3945 "bottom" => sides.bottom = value,
3946 "left" => sides.left = value,
3947 _ => {}
3948 }
3949}
3950
3951fn parse_border(value: &str) -> (f32, Option<Rgba>) {
3953 let mut width = 0.0;
3954 let mut color = None;
3955 for token in value.split_whitespace() {
3956 if let Some(px) = parse_px(token) {
3957 width = px;
3958 } else if let Some(c) = parse_color(token) {
3959 color = Some(c);
3960 }
3961 }
3962 (width, color)
3963}
3964
3965fn parse_weight(s: &str) -> Option<u16> {
3967 match s.trim() {
3968 "normal" => Some(400),
3969 "bold" => Some(700),
3970 "lighter" => Some(300),
3971 "bolder" => Some(800),
3972 other => other.parse::<u16>().ok(),
3973 }
3974}
3975
3976fn parse_text_align(s: &str) -> TextAlign {
3978 match s.trim() {
3979 "center" => TextAlign::Center,
3980 "right" | "end" => TextAlign::End,
3981 "justify" => TextAlign::Justify,
3982 _ => TextAlign::Start,
3983 }
3984}
3985
3986fn parse_color(s: &str) -> Option<Rgba> {
3987 let s = s.trim();
3988 if let Some(hex) = s.strip_prefix('#') {
3989 return parse_hex(hex);
3990 }
3991 if s.starts_with("rgb") {
3992 return parse_rgb(s);
3993 }
3994 if s.eq_ignore_ascii_case("transparent") {
3995 return Some(Rgba::new(0.0, 0.0, 0.0, 0.0));
3996 }
3997 named_color(&s.to_ascii_lowercase()).and_then(parse_hex)
4001}
4002
4003fn named_color(name: &str) -> Option<&'static str> {
4006 let hex = match name {
4007 "aliceblue" => "f0f8ff", "antiquewhite" => "faebd7", "aqua" => "00ffff",
4008 "aquamarine" => "7fffd4", "azure" => "f0ffff", "beige" => "f5f5dc",
4009 "bisque" => "ffe4c4", "black" => "000000", "blanchedalmond" => "ffebcd",
4010 "blue" => "0000ff", "blueviolet" => "8a2be2", "brown" => "a52a2a",
4011 "burlywood" => "deb887", "cadetblue" => "5f9ea0", "chartreuse" => "7fff00",
4012 "chocolate" => "d2691e", "coral" => "ff7f50", "cornflowerblue" => "6495ed",
4013 "cornsilk" => "fff8dc", "crimson" => "dc143c", "cyan" => "00ffff",
4014 "darkblue" => "00008b", "darkcyan" => "008b8b", "darkgoldenrod" => "b8860b",
4015 "darkgray" | "darkgrey" => "a9a9a9", "darkgreen" => "006400",
4016 "darkkhaki" => "bdb76b", "darkmagenta" => "8b008b", "darkolivegreen" => "556b2f",
4017 "darkorange" => "ff8c00", "darkorchid" => "9932cc", "darkred" => "8b0000",
4018 "darksalmon" => "e9967a", "darkseagreen" => "8fbc8f", "darkslateblue" => "483d8b",
4019 "darkslategray" | "darkslategrey" => "2f4f4f", "darkturquoise" => "00ced1",
4020 "darkviolet" => "9400d3", "deeppink" => "ff1493", "deepskyblue" => "00bfff",
4021 "dimgray" | "dimgrey" => "696969", "dodgerblue" => "1e90ff",
4022 "firebrick" => "b22222", "floralwhite" => "fffaf0", "forestgreen" => "228b22",
4023 "fuchsia" => "ff00ff", "gainsboro" => "dcdcdc", "ghostwhite" => "f8f8ff",
4024 "gold" => "ffd700", "goldenrod" => "daa520", "gray" | "grey" => "808080",
4025 "green" => "008000", "greenyellow" => "adff2f", "honeydew" => "f0fff0",
4026 "hotpink" => "ff69b4", "indianred" => "cd5c5c", "indigo" => "4b0082",
4027 "ivory" => "fffff0", "khaki" => "f0e68c", "lavender" => "e6e6fa",
4028 "lavenderblush" => "fff0f5", "lawngreen" => "7cfc00", "lemonchiffon" => "fffacd",
4029 "lightblue" => "add8e6", "lightcoral" => "f08080", "lightcyan" => "e0ffff",
4030 "lightgoldenrodyellow" => "fafad2", "lightgray" | "lightgrey" => "d3d3d3",
4031 "lightgreen" => "90ee90", "lightpink" => "ffb6c1", "lightsalmon" => "ffa07a",
4032 "lightseagreen" => "20b2aa", "lightskyblue" => "87cefa", "lightslategray" | "lightslategrey" => "778899",
4033 "lightsteelblue" => "b0c4de", "lightyellow" => "ffffe0", "lime" => "00ff00",
4034 "limegreen" => "32cd32", "linen" => "faf0e6", "magenta" => "ff00ff",
4035 "maroon" => "800000", "mediumaquamarine" => "66cdaa", "mediumblue" => "0000cd",
4036 "mediumorchid" => "ba55d3", "mediumpurple" => "9370db", "mediumseagreen" => "3cb371",
4037 "mediumslateblue" => "7b68ee", "mediumspringgreen" => "00fa9a", "mediumturquoise" => "48d1cc",
4038 "mediumvioletred" => "c71585", "midnightblue" => "191970", "mintcream" => "f5fffa",
4039 "mistyrose" => "ffe4e1", "moccasin" => "ffe4b5", "navajowhite" => "ffdead",
4040 "navy" => "000080", "oldlace" => "fdf5e6", "olive" => "808000",
4041 "olivedrab" => "6b8e23", "orange" => "ffa500", "orangered" => "ff4500",
4042 "orchid" => "da70d6", "palegoldenrod" => "eee8aa", "palegreen" => "98fb98",
4043 "paleturquoise" => "afeeee", "palevioletred" => "db7093", "papayawhip" => "ffefd5",
4044 "peachpuff" => "ffdab9", "peru" => "cd853f", "pink" => "ffc0cb",
4045 "plum" => "dda0dd", "powderblue" => "b0e0e6", "purple" => "800080",
4046 "rebeccapurple" => "663399", "red" => "ff0000", "rosybrown" => "bc8f8f",
4047 "royalblue" => "4169e1", "saddlebrown" => "8b4513", "salmon" => "fa8072",
4048 "sandybrown" => "f4a460", "seagreen" => "2e8b57", "seashell" => "fff5ee",
4049 "sienna" => "a0522d", "silver" => "c0c0c0", "skyblue" => "87ceeb",
4050 "slateblue" => "6a5acd", "slategray" | "slategrey" => "708090", "snow" => "fffafa",
4051 "springgreen" => "00ff7f", "steelblue" => "4682b4", "tan" => "d2b48c",
4052 "teal" => "008080", "thistle" => "d8bfd8", "tomato" => "ff6347",
4053 "turquoise" => "40e0d0", "violet" => "ee82ee", "wheat" => "f5deb3",
4054 "white" => "ffffff", "whitesmoke" => "f5f5f5", "yellow" => "ffff00",
4055 "yellowgreen" => "9acd32",
4056 _ => return None,
4057 };
4058 Some(hex)
4059}
4060
4061fn parse_hex(hex: &str) -> Option<Rgba> {
4062 let expand = |c: char| -> u8 { u8::from_str_radix(&format!("{c}{c}"), 16).unwrap_or(0) };
4063 let bytes: Vec<char> = hex.chars().collect();
4064 let (r, g, b, a) = match bytes.len() {
4065 3 => (expand(bytes[0]), expand(bytes[1]), expand(bytes[2]), 255),
4066 6 => (
4067 u8::from_str_radix(&hex[0..2], 16).ok()?,
4068 u8::from_str_radix(&hex[2..4], 16).ok()?,
4069 u8::from_str_radix(&hex[4..6], 16).ok()?,
4070 255,
4071 ),
4072 8 => (
4073 u8::from_str_radix(&hex[0..2], 16).ok()?,
4074 u8::from_str_radix(&hex[2..4], 16).ok()?,
4075 u8::from_str_radix(&hex[4..6], 16).ok()?,
4076 u8::from_str_radix(&hex[6..8], 16).ok()?,
4077 ),
4078 _ => return None,
4079 };
4080 Some(Rgba::new(
4081 r as f32 / 255.0,
4082 g as f32 / 255.0,
4083 b as f32 / 255.0,
4084 a as f32 / 255.0,
4085 ))
4086}
4087
4088#[cfg(test)]
4089mod tests {
4090 use super::{build_styled_tree, build_styled_tree_tracked, interpolate_tracked, interpret, Len, Locals};
4091 use rux_script::{Builder, Engine};
4092 use std::collections::HashMap;
4093
4094 #[test]
4099 fn css_warnings_carry_the_line_of_the_file() {
4100 let src = "<template>\n <screen class=\"a\"></screen>\n</template>\n\n<style>\n .a { display: flex; }\n .b { float: left; }\n\n .c:nope { color: red; }\n\n @media (hover: hover) { .a { gap: 4px; } }\n</style>\n";
4101 let sfc = rux_parser::parse_sfc(src).expect("parses");
4102 let mut engine = Builder::new().build("").expect("engine");
4103
4104 let _ = super::take_warnings(); let _ = build_styled_tree(&sfc, &HashMap::new(), &mut engine).expect("builds");
4106 let warnings = super::take_warnings();
4107
4108 let line_for = |needle: &str| {
4109 warnings
4110 .iter()
4111 .find(|w| w.message.contains(needle))
4112 .unwrap_or_else(|| panic!("no warning mentioning {needle}: {warnings:?}"))
4113 .line
4114 };
4115 assert_eq!(line_for("float"), Some(7));
4116 assert_eq!(line_for(":nope"), Some(9));
4117 assert_eq!(line_for("@media"), Some(11));
4118
4119 let line_of = |n: usize| src.lines().nth(n - 1).unwrap();
4121 assert!(line_of(7).contains("float"));
4122 assert!(line_of(9).contains(":nope"));
4123 assert!(line_of(11).contains("@media"));
4124 }
4125
4126 #[test]
4131 fn a_warning_in_an_expanded_rule_names_the_declaration_not_the_selector() {
4132 let src = concat!(
4133 "<template>\n",
4134 " <screen class=\"a\"></screen>\n",
4135 "</template>\n",
4136 "\n",
4137 "<style>\n",
4138 " .a {\n",
4139 " display: flex;\n",
4140 " padding: 8px;\n",
4141 " float: left;\n",
4142 " }\n",
4143 "\n",
4144 " .b {\n",
4145 " color: red;\n",
4146 " zoom: 2;\n",
4147 " }\n",
4148 "</style>\n",
4149 );
4150 let sfc = rux_parser::parse_sfc(src).expect("parses");
4151 let mut engine = Builder::new().build("").expect("engine");
4152
4153 let _ = super::take_warnings();
4154 let _ = build_styled_tree(&sfc, &HashMap::new(), &mut engine).expect("builds");
4155 let warnings = super::take_warnings();
4156
4157 let line_for = |needle: &str| {
4158 warnings
4159 .iter()
4160 .find(|w| w.message.contains(needle))
4161 .unwrap_or_else(|| panic!("no warning mentioning {needle}: {warnings:?}"))
4162 .line
4163 };
4164 assert_eq!(line_for("float"), Some(9));
4167 assert_eq!(line_for("zoom"), Some(14));
4169
4170 let line_of = |n: usize| src.lines().nth(n - 1).unwrap();
4171 assert!(line_of(9).contains("float"));
4172 assert!(line_of(14).contains("zoom"));
4173 }
4174
4175 #[test]
4179 fn a_components_css_warning_is_left_unplaced() {
4180 let main = rux_parser::parse_sfc(
4181 "<template>\n <screen><my-row /></screen>\n</template>\n<script>\nuse components::row;\n</script>\n",
4182 )
4183 .expect("parses");
4184 let component = rux_parser::parse_sfc(
4185 "<template>\n <view class=\"r\"></view>\n</template>\n<style>\n .r { float: left; }\n</style>\n",
4186 )
4187 .expect("parses");
4188 let mut components = HashMap::new();
4189 components.insert("my-row".to_string(), component);
4190 let mut engine = Builder::new().build("").expect("engine");
4191
4192 let _ = super::take_warnings();
4193 let _ = build_styled_tree(&main, &components, &mut engine).expect("builds");
4194 let warnings = super::take_warnings();
4195
4196 let float = warnings
4197 .iter()
4198 .find(|w| w.message.contains("float"))
4199 .expect("the component's unhonored property is still reported");
4200 assert_eq!(float.line, None, "but without a line from another file");
4201 }
4202
4203 #[test]
4204 fn box_model_shorthand_sides_and_border() {
4205 let mut p = HashMap::new();
4206 p.insert("padding".to_string(), "4px 8px".to_string()); p.insert("padding-left".to_string(), "20px".to_string()); p.insert("margin".to_string(), "10px".to_string());
4209 p.insert("border".to_string(), "2px solid #ff0000".to_string());
4210 p.insert("border-bottom-width".to_string(), "5px".to_string());
4211
4212 let st = interpret(&p);
4213 assert_eq!((st.padding.top, st.padding.right, st.padding.bottom, st.padding.left), (4.0, 8.0, 4.0, 20.0));
4214 assert_eq!(st.margin.top, 10.0);
4215 assert_eq!(st.border.top, 2.0);
4216 assert_eq!(st.border.bottom, 5.0); assert_eq!(st.border_color.map(|c| c.r), Some(1.0)); }
4219
4220 #[test]
4221 fn flex_longhands_and_shorthand() {
4222 let flex = |v: &str| {
4223 let mut p = HashMap::new();
4224 p.insert("flex".to_string(), v.to_string());
4225 let st = interpret(&p);
4226 (st.grow, st.shrink, st.basis)
4227 };
4228 assert_eq!(flex("1"), (1.0, 1.0, Some(Len::Px(0.0))));
4231 assert_eq!(flex("1 0 auto"), (1.0, 0.0, None));
4232 assert_eq!(flex("2 3 120px"), (2.0, 3.0, Some(Len::Px(120.0))));
4233 assert_eq!(flex("none"), (0.0, 0.0, None));
4234
4235 let mut p = HashMap::new();
4236 p.insert("flex".to_string(), "1".to_string());
4237 p.insert("flex-shrink".to_string(), "0".to_string()); p.insert("flex-wrap".to_string(), "wrap".to_string());
4239 p.insert("opacity".to_string(), "0.45".to_string());
4240 let st = interpret(&p);
4241 assert_eq!(st.shrink, 0.0);
4242 assert!(st.wrap);
4243 assert_eq!(st.opacity, 0.45);
4244 }
4245
4246 #[test]
4247 fn border_radius_shorthand_diagonal_grouping_and_longhands() {
4248 assert_eq!(super::parse_border_radius("8px"), [8.0, 8.0, 8.0, 8.0]);
4251 assert_eq!(super::parse_border_radius("8px 4px"), [8.0, 4.0, 8.0, 4.0]);
4252 assert_eq!(super::parse_border_radius("1px 2px 3px"), [1.0, 2.0, 3.0, 2.0]);
4253 assert_eq!(super::parse_border_radius("1px 2px 3px 4px"), [1.0, 2.0, 3.0, 4.0]);
4254 assert_eq!(super::parse_border_radius("10px / 20px"), [10.0, 10.0, 10.0, 10.0]);
4256
4257 let mut p = HashMap::new();
4259 p.insert("border-radius".to_string(), "5px".to_string());
4260 p.insert("border-top-right-radius".to_string(), "12px".to_string());
4261 assert_eq!(interpret(&p).radius, [5.0, 12.0, 5.0, 5.0]);
4262 }
4263
4264 #[test]
4265 fn grid_placement_parses_lines_and_spans() {
4266 use super::GridPlace;
4267 let place = |css: &str| {
4268 let mut p = HashMap::new();
4269 p.insert("grid-column".to_string(), css.to_string());
4270 interpret(&p).grid_column
4271 };
4272 assert_eq!(place("1 / 3"), (GridPlace::Line(1), GridPlace::Line(3)));
4273 assert_eq!(place("2"), (GridPlace::Line(2), GridPlace::Auto));
4274 assert_eq!(place("span 2"), (GridPlace::Span(2), GridPlace::Auto));
4275 assert_eq!(place("1 / span 2"), (GridPlace::Line(1), GridPlace::Span(2)));
4276 assert_eq!(place("-1"), (GridPlace::Line(-1), GridPlace::Auto));
4277
4278 let mut p = HashMap::new();
4280 p.insert("grid-row".to_string(), "1 / 2".to_string());
4281 p.insert("grid-row-end".to_string(), "span 3".to_string());
4282 assert_eq!(interpret(&p).grid_row, (GridPlace::Line(1), GridPlace::Span(3)));
4283 }
4284
4285 #[test]
4286 fn named_and_hex_colors_resolve() {
4287 use super::parse_color;
4288 assert_eq!(parse_color("red").map(|c| (c.r, c.g, c.b)), Some((1.0, 0.0, 0.0)));
4291 assert!(parse_color("REBECCApurple").is_some()); assert_eq!(parse_color("#000000").map(|c| c.r), Some(0.0));
4293 assert_eq!(parse_color("transparent").map(|c| c.a), Some(0.0));
4294 assert!(parse_color("notacolor").is_none());
4295 }
4296
4297 #[test]
4298 fn decodes_html_entities_in_text() {
4299 use super::decode_entities;
4300 assert_eq!(decode_entities("A & B"), "A & B");
4301 assert_eq!(decode_entities("<tag> "q""), "<tag> \"q\"");
4302 assert_eq!(decode_entities("& &"), "& &");
4303 assert_eq!(decode_entities("plain text"), "plain text");
4304 assert_eq!(decode_entities("R&D, AT&T"), "R&D, AT&T");
4306 assert_eq!(decode_entities("¬anentity;"), "¬anentity;");
4307 }
4308
4309 #[test]
4310 fn parses_and_composes_transforms() {
4311 use super::parse_transform;
4312 assert_eq!(parse_transform("translate(10px, 20px)").unwrap(), [1.0, 0.0, 0.0, 1.0, 10.0, 20.0]);
4313 assert_eq!(parse_transform("scale(2, 3)").unwrap(), [2.0, 0.0, 0.0, 3.0, 0.0, 0.0]);
4314
4315 let r = parse_transform("rotate(90deg)").unwrap();
4317 assert!(r[0].abs() < 1e-4 && (r[1] - 1.0).abs() < 1e-4);
4318 assert!((r[2] + 1.0).abs() < 1e-4 && r[3].abs() < 1e-4);
4319
4320 let c = parse_transform("rotate(90deg) translate(10px, 0)").unwrap();
4323 assert!(c[4].abs() < 1e-3 && (c[5] - 10.0).abs() < 1e-3);
4324
4325 assert!(parse_transform("none").is_none());
4326 }
4327
4328 #[test]
4332 fn records_structural_parent_for_reconcile() {
4333 let src = r#"
4334 <template>
4335 <screen>
4336 <text>title</text>
4337 <view r-for="n in nums"><text>{{ n }}</text></view>
4338 <text r-if="level < 5">low</text>
4339 </screen>
4340 </template>
4341 <script> let nums = signal([1, 2, 3]); let level = signal(10); </script>
4342 "#;
4343 let sfc = rux_parser::parse_sfc(src).unwrap();
4344 let mut engine = Builder::new().build(&sfc.script).unwrap();
4345 let mut instances = super::Instances::new();
4346 let (_root, reg) =
4347 build_styled_tree_tracked(&sfc, &HashMap::new(), &mut engine, &mut instances).unwrap();
4348
4349 assert_eq!(reg.structural_parents.len(), 1, "the screen is the one structural parent");
4350 let sp = ®.structural_parents[0];
4351 assert_eq!(sp.tree_path, Vec::<usize>::new(), "screen is the root");
4352 assert_eq!(sp.tpl_path, Vec::<usize>::new());
4353 let mut deps: Vec<&str> = sp.deps.iter().map(String::as_str).collect();
4354 deps.sort_unstable();
4355 assert_eq!(deps, ["level", "nums"], "both directive signals are captured");
4356 }
4357
4358 #[test]
4359 fn parses_gradients_direction_and_stops() {
4360 use super::parse_background;
4361 use rux_layout::{Background, GradientKind};
4362 use std::f32::consts::{FRAC_PI_2, PI};
4363
4364 let grad = |css: &str| match parse_background(css) {
4365 Some(Background::Gradient(g)) => g,
4366 other => panic!("expected a gradient, got {other:?}"),
4367 };
4368
4369 let g = grad("linear-gradient(90deg, red, blue)");
4371 assert!(matches!(g.kind, GradientKind::Linear { angle } if (angle - FRAC_PI_2).abs() < 1e-4));
4372 assert_eq!(g.stops.len(), 2);
4373 assert_eq!(g.stops[0].1, 0.0);
4374 assert_eq!(g.stops[1].1, 1.0);
4375 assert_eq!(g.stops[0].0.r, 1.0); assert_eq!(g.stops[1].0.b, 1.0); let g = grad("linear-gradient(red, lime, blue)");
4380 assert!(matches!(g.kind, GradientKind::Linear { angle } if (angle - PI).abs() < 1e-4));
4381 assert!((g.stops[1].1 - 0.5).abs() < 1e-4);
4382
4383 let g = grad("linear-gradient(to right, red 10%, blue 80%)");
4385 assert!(matches!(g.kind, GradientKind::Linear { angle } if (angle - FRAC_PI_2).abs() < 1e-4));
4386 assert!((g.stops[0].1 - 0.1).abs() < 1e-4);
4387 assert!((g.stops[1].1 - 0.8).abs() < 1e-4);
4388
4389 let g = grad("radial-gradient(circle, red, blue)");
4391 assert!(matches!(g.kind, GradientKind::Radial));
4392 assert_eq!(g.stops.len(), 2);
4393
4394 assert!(matches!(parse_background("#123456"), Some(Background::Color(_))));
4396
4397 assert!(matches!(parse_background("url(assets/logo.png)"), Some(Background::Image(s)) if s == "assets/logo.png"));
4399 assert!(matches!(parse_background("url('a b.png')"), Some(Background::Image(s)) if s == "a b.png"));
4400 }
4401
4402 #[test]
4403 fn maps_alignment_gap_position_and_aspect_ratio() {
4404 use super::{Align, Justify, Len, Position};
4405 let mut p = HashMap::new();
4406 p.insert("align-self".to_string(), "center".to_string());
4407 p.insert("justify-self".to_string(), "end".to_string());
4408 p.insert("align-content".to_string(), "space-between".to_string());
4409 p.insert("row-gap".to_string(), "8px".to_string());
4410 p.insert("column-gap".to_string(), "12px".to_string());
4411 p.insert("position".to_string(), "absolute".to_string());
4412 p.insert("top".to_string(), "10px".to_string());
4413 p.insert("left".to_string(), "auto".to_string());
4414 p.insert("aspect-ratio".to_string(), "16 / 9".to_string());
4415
4416 let st = interpret(&p);
4417 assert!(matches!(st.align_self, Some(Align::Center)));
4418 assert!(matches!(st.justify_self, Some(Align::End)));
4419 assert!(matches!(st.align_content, Some(Justify::SpaceBetween)));
4420 assert_eq!(st.row_gap, Some(8.0));
4421 assert_eq!(st.column_gap, Some(12.0));
4422 assert!(matches!(st.position, Position::Absolute));
4423 assert!(matches!(st.inset[0], Some(Len::Px(v)) if v == 10.0)); assert!(st.inset[3].is_none()); assert!(st.aspect_ratio.is_some_and(|r| (r - 16.0 / 9.0).abs() < 1e-4));
4426 }
4427
4428 #[test]
4429 fn parses_grid_tracks_including_minmax() {
4430 use super::{parse_tracks, Track, TrackSide};
4431 let tracks = parse_tracks("minmax(0, 1fr) 100px auto minmax(120px, 1fr)");
4432 assert_eq!(tracks.len(), 4);
4433 assert!(matches!(
4434 tracks[0],
4435 Track::MinMax(TrackSide::Px(0.0), TrackSide::Fr(f)) if f == 1.0
4436 ));
4437 assert!(matches!(tracks[1], Track::Px(v) if v == 100.0));
4438 assert!(matches!(tracks[2], Track::Auto));
4439 assert!(matches!(
4440 tracks[3],
4441 Track::MinMax(TrackSide::Px(v), TrackSide::Fr(_)) if v == 120.0
4442 ));
4443 }
4444
4445 #[test]
4446 fn image_element_carries_its_src() {
4447 let src = r#"<template><screen><image src="assets/logo.png" /></screen></template>"#;
4448 let sfc = rux_parser::parse_sfc(src).unwrap();
4449 let mut e = Builder::new().build("").unwrap();
4450 let root = build_styled_tree(&sfc, &HashMap::new(), &mut e).unwrap();
4451 let img = root.children[0].image.as_ref().expect("image node");
4452 assert_eq!(img.src, "assets/logo.png");
4453 }
4454
4455 #[test]
4456 fn interpolates_bindings() {
4457 let mut e = Builder::new()
4458 .build(r#"let level = signal(82); let who = signal("Cam");"#)
4459 .unwrap();
4460 let locals = Locals::new();
4461 let interp = |e: &mut Engine, s: &str| interpolate_tracked(s, e, &locals).0;
4462 assert_eq!(interp(&mut e, "{{ level }}%"), "82%");
4463 assert_eq!(interp(&mut e, "Hi {{ who }}!"), "Hi Cam!");
4464 assert_eq!(interp(&mut e, "plain text"), "plain text");
4465 assert_eq!(interp(&mut e, "{{ missing }}!"), "!"); }
4467
4468 #[test]
4469 fn expands_r_for_and_r_if_chain() {
4470 let src = r#"
4471 <template>
4472 <screen>
4473 <view r-for="n in nums"><text>{{ n }}</text></view>
4474 <text r-if="level < 5">low</text>
4475 <text r-elif="level < 50">mid</text>
4476 <text r-else>high</text>
4477 </screen>
4478 </template>
4479 <script> let nums = signal([1, 2, 3]); let level = signal(10); </script>
4480 "#;
4481 let sfc = rux_parser::parse_sfc(src).unwrap();
4482 let mut engine = Builder::new().build(&sfc.script).unwrap();
4483 let root = build_styled_tree(&sfc, &HashMap::new(), &mut engine).unwrap();
4484
4485 assert_eq!(root.children.len(), 4);
4487 let mid = root.children[3].text.as_ref().unwrap();
4488 assert_eq!(mid.text, "mid");
4489 }
4490
4491 #[test]
4492 fn r_for_tap_handler_captures_the_loop_variable() {
4493 let src = r#"
4494 <template>
4495 <screen>
4496 <view r-for="item in items" @tap="picked = item">
4497 <text>{{ item }}</text>
4498 </view>
4499 </screen>
4500 </template>
4501 <script> let items = signal(["Alpha", "Bravo", "Charlie"]); let picked = signal(""); </script>
4502 "#;
4503 let sfc = rux_parser::parse_sfc(src).unwrap();
4504 let mut engine = Builder::new().build(&sfc.script).unwrap();
4505 let root = build_styled_tree(&sfc, &HashMap::new(), &mut engine).unwrap();
4506
4507 let handler = root.children[1].on_tap.clone().expect("row has @tap");
4510 assert!(
4511 handler.contains("let item = \"Bravo\""),
4512 "loop value not baked into handler: {handler}"
4513 );
4514
4515 assert_eq!(engine.get_string("picked"), "");
4518 let third = root.children[2].on_tap.clone().unwrap();
4519 assert!(engine.run_handler(&third), "handler ran");
4520 assert_eq!(engine.get_string("picked"), "Charlie");
4521 }
4522
4523 #[test]
4524 fn input_binds_model_and_shows_placeholder_then_value() {
4525 let src = r#"<template><screen>
4526 <input r-model="name" placeholder="Type here" />
4527 </screen></template>
4528 <script> let name = signal(""); </script>"#;
4529 let sfc = rux_parser::parse_sfc(src).unwrap();
4530 let mut engine = Builder::new().build(&sfc.script).unwrap();
4531
4532 let root = build_styled_tree(&sfc, &HashMap::new(), &mut engine).unwrap();
4533 let input = &root.children[0];
4534 assert_eq!(input.model.as_deref(), Some("name"), "r-model bound");
4535 assert_eq!(input.children[0].text.as_ref().unwrap().text, "Type here");
4537
4538 engine.set_string("name", "Cam");
4540 let root = build_styled_tree(&sfc, &HashMap::new(), &mut engine).unwrap();
4541 let input = &root.children[0];
4542 assert_eq!(input.children[0].text.as_ref().unwrap().text, "Cam");
4543 }
4544
4545 #[test]
4546 fn select_carries_options_and_textarea_is_multiline() {
4547 let src = r#"<template><screen>
4548 <input type="select" r-model="fruit" :options="fruits" />
4549 <input type="textarea" r-model="notes" />
4550 </screen></template>
4551 <script>
4552 let fruit = signal("pear");
4553 let fruits = signal(["apple", "pear", "plum"]);
4554 let notes = signal("");
4555 </script>"#;
4556 let sfc = rux_parser::parse_sfc(src).unwrap();
4557 let mut engine = Builder::new().build(&sfc.script).unwrap();
4558 let root = build_styled_tree(&sfc, &HashMap::new(), &mut engine).unwrap();
4559
4560 let select = &root.children[0];
4562 assert_eq!(select.model.as_deref(), Some("fruit"));
4563 assert_eq!(
4564 select.options.as_ref().expect("select has options"),
4565 &vec!["apple".to_string(), "pear".to_string(), "plum".to_string()]
4566 );
4567 assert!(!select.multiline);
4568 assert_eq!(select.children[0].text.as_ref().unwrap().text, "pear");
4569
4570 let textarea = &root.children[1];
4572 assert!(textarea.multiline);
4573 assert!(textarea.options.is_none());
4574 }
4575
4576 #[test]
4577 fn expands_component_with_props() {
4578 let main = rux_parser::parse_sfc(
4579 r#"<template>
4580 <screen><stat :label="title" :value="level" /></screen>
4581 </template>
4582 <script> let level = signal(82); let title = signal("Battery"); </script>"#,
4583 )
4584 .unwrap();
4585 let stat = rux_parser::parse_sfc(
4586 r#"<template>
4587 <view><text>{{ label }}: {{ value }}</text></view>
4588 </template>"#,
4589 )
4590 .unwrap();
4591
4592 let mut components = HashMap::new();
4593 components.insert("stat".to_string(), stat);
4594
4595 let mut engine = Builder::new().build(&main.script).unwrap();
4596 let root = build_styled_tree(&main, &components, &mut engine).unwrap();
4597
4598 let view = &root.children[0];
4600 let text = view.children[0].text.as_ref().unwrap();
4601 assert_eq!(text.text, "Battery: 82");
4602 }
4603
4604 use super::{matches_chain, parse_selector, AncNode, ElemDesc, ElemStates};
4610
4611 fn el(spec: &str) -> ElemDesc {
4612 let mut d = ElemDesc {
4614 tag: String::new(),
4615 id: None,
4616 classes: Vec::new(),
4617 role: None,
4618 states: ElemStates::default(),
4619 };
4620 let mut rest = spec;
4621 while let Some(pos) = rest.find(['.', '#']) {
4622 if pos > 0 {
4623 d.tag = rest[..pos].to_string();
4624 }
4625 let marker = rest.as_bytes()[pos];
4626 let after = &rest[pos + 1..];
4627 let end = after.find(['.', '#']).unwrap_or(after.len());
4628 let name = after[..end].to_string();
4629 if marker == b'.' {
4630 d.classes.push(name);
4631 } else {
4632 d.id = Some(name);
4633 }
4634 rest = &after[end..];
4635 }
4636 if !rest.is_empty() && d.tag.is_empty() {
4637 d.tag = rest.to_string();
4638 }
4639 d
4640 }
4641
4642 use super::AccessRole;
4645
4646 fn built(src: &str) -> rux_layout::Node {
4647 let sfc = rux_parser::parse_sfc(src).unwrap();
4648 let mut engine = Builder::new().build(&sfc.script).unwrap();
4649 build_styled_tree(&sfc, &HashMap::new(), &mut engine).unwrap()
4650 }
4651
4652 #[test]
4655 fn controls_get_their_implicit_roles() {
4656 let root = built(
4657 r#"<template><screen>
4658 <text>a heading</text>
4659 <input r-model="name" />
4660 <input type="textarea" r-model="notes" />
4661 <input type="checkbox" r-model="agree" />
4662 <input type="radio" r-model="plan" value="pro" />
4663 <view @tap="n = n + 1"><text>Save</text></view>
4664 <image src="logo.png" alt="the logo" />
4665 </screen></template>
4666 <script>let name = signal(""); let notes = signal(""); let agree = signal(false);
4667 let plan = signal("free"); let n = signal(0);</script>"#,
4668 );
4669 let roles: Vec<AccessRole> = root.children.iter().map(|c| c.access.role).collect();
4670 assert_eq!(
4671 roles,
4672 vec![
4673 AccessRole::Label,
4674 AccessRole::TextInput,
4675 AccessRole::MultilineTextInput,
4676 AccessRole::CheckBox,
4677 AccessRole::RadioButton,
4678 AccessRole::Button,
4679 AccessRole::Image,
4680 ]
4681 );
4682 }
4683
4684 #[test]
4687 fn a_tappable_box_is_named_by_its_content() {
4688 let root = built(
4689 r#"<template><screen><view @tap="n = n + 1"><text>Save</text></view></screen></template>
4690 <script>let n = signal(0);</script>"#,
4691 );
4692 let button = &root.children[0];
4693 assert_eq!(button.access.role, AccessRole::Button);
4694 assert_eq!(button.access.label.as_deref(), Some("Save"));
4695 }
4696
4697 #[test]
4700 fn a_for_label_names_its_control() {
4701 let root = built(
4702 r#"<template><screen>
4703 <text for="email">Email address</text>
4704 <input id="email" r-model="email" />
4705 </screen></template>
4706 <script>let email = signal("");</script>"#,
4707 );
4708 let input = &root.children[1];
4709 assert_eq!(input.access.role, AccessRole::TextInput);
4710 assert_eq!(input.access.label.as_deref(), Some("Email address"));
4711 }
4712
4713 #[test]
4716 fn explicit_role_and_label_win() {
4717 let root = built(
4718 r#"<template><screen>
4719 <text role="heading">Dashboard</text>
4720 <view @tap="n = n + 1" label="Save changes"><text>OK</text></view>
4721 </screen></template>
4722 <script>let n = signal(0);</script>"#,
4723 );
4724 assert_eq!(root.children[0].access.role, AccessRole::Heading);
4725 assert_eq!(root.children[1].access.label.as_deref(), Some("Save changes"));
4726 }
4727
4728 #[test]
4731 fn a_toggle_reports_its_checked_state() {
4732 let root = built(
4733 r#"<template><screen>
4734 <input type="checkbox" r-model="on" />
4735 <input type="checkbox" r-model="off" />
4736 </screen></template>
4737 <script>let on = signal(true); let off = signal(false);</script>"#,
4738 );
4739 assert_eq!(root.children[0].access.checked, Some(true));
4740 assert_eq!(root.children[1].access.checked, Some(false));
4741 }
4742
4743 #[test]
4746 fn an_input_exposes_value_but_not_its_placeholder_as_value() {
4747 let root = built(
4748 r#"<template><screen>
4749 <input r-model="name" placeholder="Your name" />
4750 <input r-model="city" placeholder="Your city" />
4751 </screen></template>
4752 <script>let name = signal("Ada"); let city = signal("");</script>"#,
4753 );
4754 let filled = &root.children[0];
4755 assert_eq!(filled.access.value.as_deref(), Some("Ada"));
4756 assert_eq!(
4757 filled.access.name(),
4758 Some("Your name"),
4759 "an unlabelled field falls back to its placeholder for a name"
4760 );
4761
4762 let empty = &root.children[1];
4763 assert_eq!(empty.access.value, None, "an empty field has no value");
4764 assert_eq!(empty.access.name(), Some("Your city"));
4765 }
4766
4767 #[test]
4770 fn a_for_label_outranks_a_placeholder() {
4771 let root = built(
4772 r#"<template><screen>
4773 <text for="notes">Notes</text>
4774 <input id="notes" r-model="notes" placeholder="Type a few lines…" />
4775 </screen></template>
4776 <script>let notes = signal("");</script>"#,
4777 );
4778 let input = &root.children[1];
4779 assert_eq!(input.access.name(), Some("Notes"), "the label wins");
4780 assert_eq!(
4781 input.access.placeholder.as_deref(),
4782 Some("Type a few lines…"),
4783 "the placeholder is still available as a hint"
4784 );
4785 }
4786
4787 #[test]
4790 fn plain_boxes_are_not_exposed() {
4791 let root = built(
4792 r#"<template><screen><view class="row"><view class="col" /></view></screen></template>"#,
4793 );
4794 assert_eq!(root.children[0].access.role, AccessRole::None);
4795 assert_eq!(root.children[0].children[0].access.role, AccessRole::None);
4796 assert!(!AccessRole::None.is_meaningful());
4797 }
4798
4799 use super::{media_matches, parse_rules, InteractionState, Viewport};
4802
4803 fn vp(width: f32, height: f32) -> Viewport {
4804 Viewport { width, height }
4805 }
4806
4807 fn bg_at_vp(src: &str, viewport: Viewport) -> Option<Background> {
4809 let sfc = rux_parser::parse_sfc(src).unwrap();
4810 let mut engine = Builder::new().build(&sfc.script).unwrap();
4811 let mut instances = super::Instances::new();
4812 let root = super::build_styled_tree_stateful(
4813 &sfc,
4814 &HashMap::new(),
4815 &mut engine,
4816 &mut instances,
4817 &InteractionState::default(),
4818 viewport,
4819 )
4820 .unwrap();
4821 root.0.children[0].style.background.clone()
4822 }
4823
4824 const MEDIA_DOC: &str = r#"<template><screen><view class="target" /></screen></template>
4825 <style>
4826 .target { background: #00ff00; }
4827 @media (max-width: 600px) { .target { background: #ff0000; } }
4828 </style>"#;
4829
4830 #[test]
4832 fn media_query_gates_its_rules_on_the_viewport() {
4833 assert!(is_red(&bg_at_vp(MEDIA_DOC, vp(480.0, 800.0))), "narrow → the @media rule");
4834 let wide = bg_at_vp(MEDIA_DOC, vp(1200.0, 800.0));
4835 assert!(
4836 matches!(&wide, Some(Background::Color(c)) if c.g == 1.0),
4837 "wide → the base rule, as if the block weren't there"
4838 );
4839 }
4840
4841 #[test]
4844 fn media_rules_cascade_by_order_not_by_being_in_a_block() {
4845 let src = r#"<template><screen><view class="target" id="t" /></screen></template>
4846 <style>
4847 #t { background: #00ff00; }
4848 @media (max-width: 600px) { .target { background: #ff0000; } }
4849 </style>"#;
4850 let narrow = bg_at_vp(src, vp(480.0, 800.0));
4851 assert!(
4852 matches!(&narrow, Some(Background::Color(c)) if c.g == 1.0),
4853 "#id still beats a .class inside @media"
4854 );
4855 }
4856
4857 #[test]
4859 fn media_conditions_evaluate() {
4860 let and = r#"<template><screen><view class="target" /></screen></template>
4861 <style>@media screen and (min-width: 400px) and (max-width: 600px) {
4862 .target { background: #ff0000; } }</style>"#;
4863 assert!(is_red(&bg_at_vp(and, vp(500.0, 800.0))), "inside the band");
4864 assert!(bg_at_vp(and, vp(700.0, 800.0)).is_none(), "outside the band");
4865
4866 let either = r#"<template><screen><view class="target" /></screen></template>
4867 <style>@media (max-width: 400px), (min-width: 1000px) {
4868 .target { background: #ff0000; } }</style>"#;
4869 assert!(is_red(&bg_at_vp(either, vp(300.0, 800.0))), "first alternative");
4870 assert!(is_red(&bg_at_vp(either, vp(1200.0, 800.0))), "second alternative");
4871 assert!(bg_at_vp(either, vp(600.0, 800.0)).is_none(), "neither");
4872
4873 let portrait = r#"<template><screen><view class="target" /></screen></template>
4874 <style>@media (orientation: portrait) { .target { background: #ff0000; } }</style>"#;
4875 assert!(is_red(&bg_at_vp(portrait, vp(400.0, 800.0))), "taller than wide");
4876 assert!(bg_at_vp(portrait, vp(800.0, 400.0)).is_none(), "wider than tall");
4877 }
4878
4879 #[test]
4882 fn unsupported_media_condition_never_applies() {
4883 let src = r#"<template><screen><view class="target" /></screen></template>
4884 <style>@media (min-resolution: 2dppx) { .target { background: #ff0000; } }</style>"#;
4885 assert!(bg_at_vp(src, vp(800.0, 600.0)).is_none());
4886 }
4887
4888 #[test]
4891 fn media_matches_reports_each_block() {
4892 let css = "@media (max-width: 600px) { .a { color: red } } \
4893 @media (min-width: 1000px) { .b { color: red } }";
4894 assert_eq!(media_matches(css, vp(500.0, 800.0)), vec![true, false]);
4895 assert_eq!(media_matches(css, vp(800.0, 800.0)), vec![false, false]);
4896 assert_eq!(media_matches(css, vp(1200.0, 800.0)), vec![false, true]);
4897 assert_eq!(media_matches(css, vp(700.0, 800.0)), media_matches(css, vp(900.0, 800.0)));
4900 assert!(media_matches(".a { color: red }", vp(800.0, 600.0)).is_empty());
4901 }
4902
4903 #[test]
4905 fn plain_rules_are_viewport_independent() {
4906 let css = ".a { color: red }";
4907 assert_eq!(parse_rules(css, vp(320.0, 480.0)).len(), parse_rules(css, vp(1600.0, 900.0)).len());
4908 }
4909
4910 use super::{Background, Vars};
4913
4914 fn bg_at(src: &str, path: &[usize]) -> Option<Background> {
4916 let sfc = rux_parser::parse_sfc(src).unwrap();
4917 let mut engine = Builder::new().build(&sfc.script).unwrap();
4918 let root = build_styled_tree(&sfc, &HashMap::new(), &mut engine).unwrap();
4919 let mut node = &root;
4920 for i in path {
4921 node = &node.children[*i];
4922 }
4923 node.style.background.clone()
4924 }
4925
4926 fn is_red(bg: &Option<Background>) -> bool {
4927 matches!(bg, Some(Background::Color(c)) if c.r == 1.0 && c.g == 0.0 && c.b == 0.0)
4928 }
4929
4930 #[test]
4933 fn custom_property_inherits_down_the_tree() {
4934 let bg = bg_at(
4935 r#"<template><screen class="app"><view><view class="target" /></view></screen></template>
4936 <style>
4937 .app { --brand: #ff0000; }
4938 .target { background: var(--brand); }
4939 </style>"#,
4940 &[0, 0],
4941 );
4942 assert!(is_red(&bg), "var() resolved from an ancestor's declaration");
4943 }
4944
4945 #[test]
4947 fn nearer_declaration_shadows_the_inherited_one() {
4948 let src = r#"<template><screen class="app">
4949 <view class="panel"><view class="target" /></view>
4950 <view><view class="target" /></view>
4951 </screen></template>
4952 <style>
4953 .app { --brand: #00ff00; }
4954 .panel { --brand: #ff0000; }
4955 .target { background: var(--brand); }
4956 </style>"#;
4957 assert!(is_red(&bg_at(src, &[0, 0])), "inside .panel the nearer value wins");
4958 let outside = bg_at(src, &[1, 0]);
4959 assert!(
4960 matches!(&outside, Some(Background::Color(c)) if c.g == 1.0),
4961 "outside .panel the root value still applies, the override didn't leak"
4962 );
4963 }
4964
4965 #[test]
4967 fn custom_property_can_reference_another() {
4968 let bg = bg_at(
4969 r#"<template><screen class="app"><view class="target" /></screen></template>
4970 <style>
4971 .app { --red: #ff0000; --brand: var(--red); }
4972 .target { background: var(--brand); }
4973 </style>"#,
4974 &[0],
4975 );
4976 assert!(is_red(&bg));
4977 }
4978
4979 #[test]
4982 fn var_falls_back_when_undefined() {
4983 let bg = bg_at(
4984 r#"<template><screen><view class="target" /></screen></template>
4985 <style>.target { background: var(--nope, #ff0000); }</style>"#,
4986 &[0],
4987 );
4988 assert!(is_red(&bg), "the fallback is used");
4989
4990 let bg = bg_at(
4991 r#"<template><screen><view class="target" /></screen></template>
4992 <style>.target { background: var(--nope, rgb(255, 0, 0)); }</style>"#,
4993 &[0],
4994 );
4995 assert!(is_red(&bg), "a fallback with its own parens survives");
4996 }
4997
4998 #[test]
5001 fn undefined_var_without_fallback_drops_the_declaration() {
5002 let bg = bg_at(
5003 r#"<template><screen><view class="target" /></screen></template>
5004 <style>.target { background: var(--nope); }</style>"#,
5005 &[0],
5006 );
5007 assert!(bg.is_none(), "no background, rather than a wrong one");
5008 }
5009
5010 #[test]
5012 fn circular_variables_terminate() {
5013 let bg = bg_at(
5014 r#"<template><screen class="app"><view class="target" /></screen></template>
5015 <style>
5016 .app { --a: var(--b); --b: var(--a); }
5017 .target { background: var(--a); }
5018 </style>"#,
5019 &[0],
5020 );
5021 assert!(bg.is_none(), "a cycle resolves to nothing, and returns");
5022 }
5023
5024 #[test]
5027 fn var_resolves_in_inline_style() {
5028 let bg = bg_at(
5029 r#"<template><screen class="app"><view style="background: var(--brand)" /></screen></template>
5030 <style>.app { --brand: #ff0000; }</style>"#,
5031 &[0],
5032 );
5033 assert!(is_red(&bg));
5034 }
5035
5036 #[test]
5039 fn custom_property_is_not_treated_as_a_property() {
5040 assert!(!super::is_honored("--brand"));
5041 let mut props: HashMap<String, String> = HashMap::new();
5042 props.insert("--brand".into(), "#ff0000".into());
5043 props.insert("background".into(), "var(--brand)".into());
5044 let vars = super::take_vars(&mut props, &Vars::default());
5045 assert!(!props.contains_key("--brand"), "stripped out of the property map");
5046 assert_eq!(vars.get("--brand").map(String::as_str), Some("#ff0000"));
5047 }
5048
5049 fn hits_state(selector: &str, target: &str, states: ElemStates) -> bool {
5058 let (chain, combs, _) = parse_selector(selector).expect("selector parses");
5059 let mut d = el(target);
5060 d.states = states;
5061 matches_chain(&chain, &combs, &d, &[], &[])
5062 }
5063
5064 fn hovered() -> ElemStates {
5065 ElemStates { hover: true, ..ElemStates::default() }
5066 }
5067
5068 #[test]
5069 fn pseudo_class_matches_only_in_that_state() {
5070 assert!(hits_state(".box:hover", ".box", hovered()));
5071 assert!(
5072 !hits_state(".box:hover", ".box", ElemStates::default()),
5073 "an unhovered element must NOT match :hover (it used to match always)"
5074 );
5075 assert!(hits_state(".box", ".box", hovered()));
5077 }
5078
5079 #[test]
5080 fn each_pseudo_reads_its_own_state() {
5081 let s = ElemStates {
5082 hover: false,
5083 focus: true,
5084 active: false,
5085 checked: true,
5086 current: false,
5087 };
5088 assert!(hits_state("input:focus", "input", s));
5089 assert!(hits_state("input:checked", "input", s));
5090 assert!(!hits_state("input:hover", "input", s));
5091 assert!(!hits_state("input:active", "input", s));
5092 assert!(!hits_state("input:current", "input", s));
5093 }
5094
5095 #[test]
5096 fn stacked_pseudos_all_have_to_hold() {
5097 let hover_only = hovered();
5098 let both = ElemStates { hover: true, active: true, ..ElemStates::default() };
5099 assert!(!hits_state(".btn:hover:active", ".btn", hover_only));
5100 assert!(hits_state(".btn:hover:active", ".btn", both));
5101 }
5102
5103 #[test]
5106 fn unknown_pseudo_never_matches() {
5107 let all_on = ElemStates {
5108 hover: true,
5109 focus: true,
5110 active: true,
5111 checked: true,
5112 current: true,
5113 };
5114 assert!(!hits_state(".box:disabled", ".box", all_on));
5115 assert!(!hits_state(".box:nth-child(2)", ".box", all_on));
5116 assert!(!hits_state(".box::selection", ".box", all_on));
5117 }
5118
5119 #[test]
5122 fn pseudo_class_adds_class_specificity() {
5123 let (_, _, plain) = parse_selector(".box").unwrap();
5124 let (_, _, with_pseudo) = parse_selector(".box:hover").unwrap();
5125 assert_eq!(plain, (0, 1, 0));
5126 assert_eq!(with_pseudo, (0, 2, 0));
5127 assert!(with_pseudo > plain);
5128 }
5129
5130 #[test]
5133 fn pseudo_class_stays_within_its_compound() {
5134 let (chain, combs, _) = parse_selector(".card > .btn:hover").unwrap();
5135 assert_eq!(chain.len(), 2, "two compounds, not three");
5136 assert_eq!(combs.len(), 1);
5137 let hover = hovered();
5139 let mut btn = el(".btn");
5140 btn.states = hover;
5141 let card = anc(".card", &[]);
5142 assert!(matches_chain(&chain, &combs, &btn, &[card.clone()], &[]));
5143 let plain_btn = el(".btn");
5144 assert!(!matches_chain(&chain, &combs, &plain_btn, &[card], &[]));
5145 }
5146
5147 #[test]
5150 fn checked_pseudo_styles_a_ticked_toggle() {
5151 let src = r#"
5152 <template>
5153 <screen>
5154 <input type="checkbox" class="box" r-model="on" />
5155 <input type="checkbox" class="box" r-model="off" />
5156 </screen>
5157 </template>
5158 <style>
5159 .box { background: #000000; }
5160 .box:checked { background: #00ff00; }
5161 </style>
5162 <script> let on = signal(true); let off = signal(false); </script>
5163 "#;
5164 let sfc = rux_parser::parse_sfc(src).unwrap();
5165 let mut engine = Builder::new().build(&sfc.script).unwrap();
5166 let root = build_styled_tree(&sfc, &HashMap::new(), &mut engine).unwrap();
5167
5168 let green = |n: &rux_layout::Node| {
5169 matches!(&n.style.background, Some(rux_layout::Background::Color(c)) if c.g == 1.0)
5170 };
5171 assert!(green(&root.children[0]), "ticked box matches .box:checked");
5172 assert!(!green(&root.children[1]), "unticked box does not");
5173 }
5174
5175 fn anc(spec: &str, prev: &[&str]) -> AncNode {
5176 AncNode { desc: el(spec), prev: prev.iter().map(|s| el(s)).collect() }
5177 }
5178
5179 fn hits(selector: &str, target: &str, ancestors: &[AncNode], prev: &[&str]) -> bool {
5182 let (chain, combs, _) = parse_selector(selector).expect("selector parses");
5183 let prev: Vec<ElemDesc> = prev.iter().map(|s| el(s)).collect();
5184 matches_chain(&chain, &combs, &el(target), ancestors, &prev)
5185 }
5186
5187 #[test]
5188 fn lightningcss_serialization_round_trips_to_our_combinators() {
5189 use super::{parse_rules, Combinator};
5193 let css = ".card > text { color: #111 } .a + .b { color: #222 } .a ~ .b { color: #333 }";
5194 let rules = parse_rules(css, Viewport::default());
5195 let combs: Vec<&[Combinator]> = rules.iter().map(|r| r.combs.as_slice()).collect();
5196 assert_eq!(combs[0], &[Combinator::Child]);
5197 assert_eq!(combs[1], &[Combinator::NextSibling]);
5198 assert_eq!(combs[2], &[Combinator::SubsequentSibling]);
5199 }
5200
5201 #[test]
5202 fn child_combinator_styles_the_right_element_end_to_end() {
5203 let src = r#"
5209 <template>
5210 <screen>
5211 <text>direct</text>
5212 <view><text>nested</text></view>
5213 </screen>
5214 </template>
5215 <style>
5216 screen > text { color: #080808 }
5217 </style>
5218 "#;
5219 let sfc = rux_parser::parse_sfc(src).unwrap();
5220 let mut engine = Builder::new().build("").unwrap();
5221 let root = build_styled_tree(&sfc, &HashMap::new(), &mut engine).unwrap();
5222
5223 let direct = root.children[0].text.as_ref().unwrap();
5224 let nested = root.children[1].children[0].text.as_ref().unwrap();
5225 assert!(direct.color.r < 0.1, "direct child of screen got the #080808 color");
5226 assert!(nested.color.r > 0.5, "grandchild is NOT matched by `screen > text`");
5227 }
5228
5229 #[test]
5230 fn child_combinator_only_matches_direct_children() {
5231 assert!(hits("*.card > text", "text", &[anc("view.card", &[])], &[]));
5234 assert!(!hits(
5235 "*.card > text",
5236 "text",
5237 &[anc("view.card", &[]), anc("view.inner", &[])],
5238 &[],
5239 ));
5240 assert!(hits(
5242 "*.card text",
5243 "text",
5244 &[anc("view.card", &[]), anc("view.inner", &[])],
5245 &[],
5246 ));
5247 }
5248
5249 #[test]
5250 fn next_sibling_combinator_needs_immediate_predecessor() {
5251 assert!(hits("*.a + *.b", "view.b", &[], &["view.a"]));
5253 assert!(hits("*.a + *.b", "view.b", &[], &["view.x", "view.a"]));
5254 assert!(!hits("*.a + *.b", "view.b", &[], &["view.a", "view.x"]));
5256 assert!(!hits("*.a + *.b", "view.b", &[], &[]));
5257 }
5258
5259 #[test]
5260 fn subsequent_sibling_combinator_matches_any_earlier_sibling() {
5261 assert!(hits("*.a ~ *.b", "view.b", &[], &["view.a", "view.x"]));
5263 assert!(hits("*.a ~ *.b", "view.b", &[], &["view.a"]));
5264 assert!(!hits("*.a ~ *.b", "view.b", &[], &["view.x"]));
5265 }
5266
5267 #[test]
5268 fn combinators_compose() {
5269 let ancestors = [anc("view.card", &[])];
5271 assert!(hits("*.card > *.a + *.b", "view.b", &ancestors, &["view.a"]));
5272 let ancestors = [anc("view.b", &["view.a"])];
5275 assert!(hits("*.a ~ *.b *.c", "view.c", &ancestors, &[]));
5276 let ancestors = [anc("view.b", &["view.x"])];
5278 assert!(!hits("*.a ~ *.b *.c", "view.c", &ancestors, &[]));
5279 }
5280}
5281
5282fn parse_rgb(s: &str) -> Option<Rgba> {
5283 let inner = s.trim_start_matches("rgba").trim_start_matches("rgb");
5284 let inner = inner.trim().trim_start_matches('(').trim_end_matches(')');
5285 let parts: Vec<&str> = inner.split([',', ' ', '/']).filter(|p| !p.is_empty()).collect();
5286 if parts.len() < 3 {
5287 return None;
5288 }
5289 let r = parts[0].parse::<f32>().ok()? / 255.0;
5290 let g = parts[1].parse::<f32>().ok()? / 255.0;
5291 let b = parts[2].parse::<f32>().ok()? / 255.0;
5292 let a = parts.get(3).and_then(|v| v.parse::<f32>().ok()).unwrap_or(1.0);
5293 Some(Rgba::new(r, g, b, a))
5294}