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 take_warnings() -> Vec<Warning> {
83 WARNINGS.with(|w| std::mem::take(&mut *w.borrow_mut()))
84}
85
86thread_local! {
87 static ECHO: std::cell::Cell<bool> = const { std::cell::Cell::new(true) };
95}
96
97pub fn set_stderr_echo(on: bool) {
105 ECHO.with(|e| e.set(on));
106}
107
108fn echo(message: &str) {
110 if ECHO.with(|e| e.get()) {
111 eprintln!("rux: {message}");
112 }
113}
114
115#[derive(Clone, Debug)]
121pub struct TextBinding {
122 pub path: Vec<usize>,
124 pub template: String,
126 pub locals: Vec<(String, Value)>,
128 pub deps: HashSet<String>,
130}
131
132#[derive(Clone, Debug)]
137pub struct ValueBinding {
138 pub path: Vec<usize>,
140 pub model: String,
142 pub placeholder: String,
144 pub color: Rgba,
146 pub placeholder_color: Rgba,
148 pub locals: Vec<(String, Value)>,
150 pub deps: HashSet<String>,
152}
153
154#[derive(Clone, Debug)]
158pub struct ShowBinding {
159 pub path: Vec<usize>,
161 pub cond: String,
163 pub locals: Vec<(String, Value)>,
165 pub deps: HashSet<String>,
167}
168
169#[derive(Clone, Debug)]
175pub struct StructuralParent {
176 pub tree_path: Vec<usize>,
178 pub tpl_path: Vec<usize>,
180 pub deps: HashSet<String>,
182}
183
184#[derive(Clone, Debug)]
188pub struct ToggleBinding {
189 pub path: Vec<usize>,
191 pub deps: HashSet<String>,
193}
194
195#[derive(Clone, Debug)]
199pub struct ComponentBinding {
200 pub path: Vec<usize>,
202 pub deps: HashSet<String>,
204}
205
206#[derive(Clone, Debug)]
211pub struct StyledBinding {
212 pub path: Vec<usize>,
214 pub deps: HashSet<String>,
216}
217
218#[derive(Clone, Debug)]
221pub struct AttrBinding {
222 pub path: Vec<usize>,
224 pub expr: String,
226 pub locals: Vec<(String, Value)>,
228 pub deps: HashSet<String>,
230}
231
232#[derive(Clone, Debug, Default)]
238pub struct BindingRegistry {
239 pub text: Vec<TextBinding>,
240 pub value: Vec<ValueBinding>,
241 pub show: Vec<ShowBinding>,
242 pub src: Vec<AttrBinding>,
244 pub options: Vec<AttrBinding>,
246 pub structural_parents: Vec<StructuralParent>,
247 pub toggles: Vec<ToggleBinding>,
248 pub components: Vec<ComponentBinding>,
249 pub styled: Vec<StyledBinding>,
250 pub structural: HashSet<String>,
254}
255
256
257fn bind_locals(src: &str, locals: &Locals) -> String {
261 if locals.is_empty() {
262 return src.to_string();
263 }
264 let mut out = String::new();
265 for (name, value) in locals {
266 out.push_str("let ");
267 out.push_str(name);
268 out.push_str(" = ");
269 out.push_str(&value.to_rhai_literal());
270 out.push_str("; ");
271 }
272 out.push_str(src);
273 out
274}
275
276struct Component {
278 template: Element,
279 rules: Vec<Rule>,
280}
281
282type Components = HashMap<String, Component>;
284
285const DEFAULT_COLOR: Rgba = Rgba::new(0.804, 0.839, 0.957, 1.0);
288const DEFAULT_FONT_SIZE: f32 = 16.0;
289
290#[derive(Clone)]
293struct Inherited {
294 color: Rgba,
295 font_size: f32,
296 font_family: Option<String>,
297 vars: Vars,
300}
301
302type Vars = Rc<HashMap<String, String>>;
311
312const MAX_VAR_DEPTH: usize = 16;
316
317fn resolve_vars(value: &str, vars: &HashMap<String, String>, depth: usize) -> String {
325 if depth >= MAX_VAR_DEPTH || !value.contains("var(") {
326 return value.to_string();
327 }
328 let mut out = String::with_capacity(value.len());
329 let mut rest = value;
330 while let Some(start) = rest.find("var(") {
331 out.push_str(&rest[..start]);
332 let after = &rest[start + 4..];
333 let mut depth_parens = 1i32;
336 let mut end = None;
337 for (i, c) in after.char_indices() {
338 match c {
339 '(' => depth_parens += 1,
340 ')' => {
341 depth_parens -= 1;
342 if depth_parens == 0 {
343 end = Some(i);
344 break;
345 }
346 }
347 _ => {}
348 }
349 }
350 let Some(end) = end else {
351 out.push_str("var(");
353 out.push_str(after);
354 return out;
355 };
356 let inner = &after[..end];
357 let (name, fallback) = match inner.split_once(',') {
358 Some((n, f)) => (n.trim(), Some(f.trim())),
359 None => (inner.trim(), None),
360 };
361 match vars.get(name) {
362 Some(v) => out.push_str(&resolve_vars(v, vars, depth + 1)),
364 None => match fallback {
365 Some(f) => out.push_str(&resolve_vars(f, vars, depth + 1)),
366 None => {
367 warn_undefined_var(name);
368 out.push_str("var(");
369 out.push_str(inner);
370 out.push(')');
371 }
372 },
373 }
374 rest = &after[end + 1..];
375 }
376 out.push_str(rest);
377 out
378}
379
380fn take_vars(props: &mut HashMap<String, String>, inherited: &Vars) -> Vars {
390 let declared: Vec<String> = props.keys().filter(|k| k.starts_with("--")).cloned().collect();
391 if declared.is_empty() {
392 return Rc::clone(inherited);
393 }
394 let mut vars = (**inherited).clone();
395 for name in declared {
396 let Some(value) = props.remove(&name) else { continue };
399 let value = resolve_vars(&value, &vars, 0);
400 vars.insert(name, value);
401 }
402 Rc::new(vars)
403}
404
405fn warn_undefined_var(name: &str) {
407 use std::sync::{Mutex, OnceLock};
408 static SEEN: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
409 let message = format!(
410 "custom property `{name}` is not defined, the declaration using var({name}) is \
411 ignored (give it a fallback: `var({name}, …)`)"
412 );
413 warn(message.clone());
414 let seen = SEEN.get_or_init(|| Mutex::new(HashSet::new()));
415 let Ok(mut seen) = seen.lock() else { return };
416 if seen.insert(name.to_string()) {
417 echo(&message);
418 }
419}
420
421const CIRCLE: f32 = 9999.0;
424
425#[derive(Clone)]
428struct Toggle {
429 radio: bool,
430 checked: bool,
431 deps: HashSet<String>,
432}
433
434impl Toggle {
435 fn of(el: &Element, engine: &mut Engine, locals: &Locals) -> Option<Self> {
436 if el.tag != "input" {
437 return None;
438 }
439 let radio = match el.attr("type") {
440 Some("radio") => true,
441 Some("checkbox") => false,
442 _ => return None,
443 };
444 let model = el.attr("r-model").unwrap_or_default();
445 let (checked, deps) = if model.is_empty() {
448 (false, HashSet::new())
449 } else if radio {
450 let (v, deps) = engine.eval_display_tracked(model, locals);
451 (v == el.attr("value").unwrap_or_default(), deps)
452 } else {
453 engine.eval_bool_tracked(model, locals)
454 };
455 Some(Self { radio, checked, deps })
456 }
457}
458
459pub fn build_styled_tree(
464 sfc: &Sfc,
465 components: &HashMap<String, Sfc>,
466 engine: &mut Engine,
467) -> Result<LayoutNode, String> {
468 build_styled_tree_tracked(sfc, components, engine).map(|(node, _)| node)
469}
470
471pub fn eval_text_binding(binding: &TextBinding, engine: &mut Engine) -> String {
474 interpolate_tracked(&binding.template, engine, &binding.locals).0
475}
476
477fn class_list(value: &Value) -> Vec<String> {
481 match value {
482 Value::Text(s) => s.split_whitespace().map(str::to_string).collect(),
483 Value::List(items) => items
484 .iter()
485 .flat_map(|i| i.to_display().split_whitespace().map(str::to_string).collect::<Vec<_>>())
486 .collect(),
487 Value::Map(entries) => entries
489 .iter()
490 .filter(|(_, v)| v.is_truthy())
491 .flat_map(|(k, _)| k.split_whitespace().map(str::to_string).collect::<Vec<_>>())
492 .collect(),
493 _ => Vec::new(),
494 }
495}
496
497fn merge_inline_style(props: &mut HashMap<String, String>, css: &str) {
501 for decl in css.split(';') {
502 if let Some((name, value)) = decl.split_once(':') {
503 let name = name.trim().to_ascii_lowercase();
504 let value = value.trim();
505 if !name.is_empty() && !value.is_empty() {
506 props.insert(name, value.to_string());
507 }
508 }
509 }
510}
511
512type LabelTarget = (Option<String>, Option<String>);
518
519fn explicit_access_role(el: &Element) -> Option<AccessRole> {
526 let role = el.role()?.to_ascii_lowercase();
527 Some(match role.as_str() {
528 "heading" => AccessRole::Heading,
529 "button" => AccessRole::Button,
530 "label" | "text" | "paragraph" => AccessRole::Label,
531 "checkbox" => AccessRole::CheckBox,
532 "radio" => AccessRole::RadioButton,
533 "textbox" | "textfield" => AccessRole::TextInput,
534 "combobox" | "listbox" | "select" => AccessRole::ComboBox,
535 "image" | "img" => AccessRole::Image,
536 _ => AccessRole::Group,
537 })
538}
539
540fn authored_label(el: &Element) -> Option<String> {
544 el.attr("label")
545 .or_else(|| el.attr("alt"))
546 .filter(|v| !v.trim().is_empty())
547 .map(str::to_string)
548}
549
550fn subtree_text(node: &LayoutNode) -> String {
553 let mut out = String::new();
554 collect_subtree_text(node, &mut out);
555 out
556}
557
558fn collect_subtree_text(node: &LayoutNode, out: &mut String) {
559 if let Some(text) = &node.text {
560 if !text.text.trim().is_empty() {
561 if !out.is_empty() {
562 out.push(' ');
563 }
564 out.push_str(text.text.trim());
565 }
566 }
567 for child in &node.children {
568 collect_subtree_text(child, out);
569 }
570}
571
572fn link_labels(root: &mut LayoutNode) {
573 let mut targets: HashMap<String, LabelTarget> = HashMap::new();
574 collect_label_targets(root, &mut targets);
575 if !targets.is_empty() {
576 apply_label_targets(root, &targets);
577 }
578 let mut names: HashMap<String, String> = HashMap::new();
582 collect_label_names(root, &mut names);
583 if !names.is_empty() {
584 apply_label_names(root, &names);
585 }
586}
587
588fn collect_label_names(node: &LayoutNode, names: &mut HashMap<String, String>) {
590 if let Some(target) = &node.label_for {
591 let text = subtree_text(node);
592 if !text.is_empty() {
593 names.entry(target.clone()).or_insert(text);
594 }
595 }
596 for child in &node.children {
597 collect_label_names(child, names);
598 }
599}
600
601fn apply_label_names(node: &mut LayoutNode, names: &HashMap<String, String>) {
605 if node.access.label.is_none() {
606 if let Some(name) = node.id.as_ref().and_then(|id| names.get(id)) {
607 node.access.label = Some(name.clone());
608 }
609 }
610 for child in &mut node.children {
611 apply_label_names(child, names);
612 }
613}
614
615fn collect_label_targets(node: &LayoutNode, targets: &mut HashMap<String, LabelTarget>) {
616 if let Some(id) = &node.id {
617 targets
618 .entry(id.clone())
619 .or_insert_with(|| (node.on_tap.clone(), node.model.clone()));
620 }
621 for child in &node.children {
622 collect_label_targets(child, targets);
623 }
624}
625
626fn apply_label_targets(node: &mut LayoutNode, targets: &HashMap<String, LabelTarget>) {
627 if node.on_tap.is_none() && node.focus_model.is_none() {
628 if let Some((tap, model)) = node.label_for.as_ref().and_then(|t| targets.get(t)) {
629 if let Some(tap) = tap {
630 node.on_tap = Some(tap.clone());
632 } else if let Some(model) = model {
633 node.focus_model = Some(model.clone());
635 }
636 }
637 }
638 for child in &mut node.children {
639 apply_label_targets(child, targets);
640 }
641}
642
643pub fn eval_src_binding(binding: &AttrBinding, engine: &mut Engine) -> String {
645 engine.eval_display(&binding.expr, &binding.locals)
646}
647
648pub fn eval_options_binding(binding: &AttrBinding, engine: &mut Engine) -> Vec<String> {
650 engine
651 .eval_value(&binding.expr, &binding.locals)
652 .and_then(|v| v.as_list().map(|items| items.iter().map(Value::to_display).collect()))
653 .unwrap_or_default()
654}
655
656pub fn eval_value_binding(binding: &ValueBinding, engine: &mut Engine) -> (String, Rgba) {
659 let value = engine.eval_display(&binding.model, &binding.locals);
660 if value.is_empty() {
661 (binding.placeholder.clone(), binding.placeholder_color)
662 } else {
663 (value, binding.color)
664 }
665}
666
667pub fn build_styled_tree_tracked(
671 sfc: &Sfc,
672 components: &HashMap<String, Sfc>,
673 engine: &mut Engine,
674) -> Result<(LayoutNode, BindingRegistry), String> {
675 build_styled_tree_stateful(
676 sfc,
677 components,
678 engine,
679 &InteractionState::default(),
680 Viewport::default(),
681 )
682}
683
684pub fn build_styled_tree_stateful(
689 sfc: &Sfc,
690 components: &HashMap<String, Sfc>,
691 engine: &mut Engine,
692 state: &InteractionState,
693 viewport: Viewport,
694) -> Result<(LayoutNode, BindingRegistry), String> {
695 let rules = parse_rules_at(&sfc.style, viewport, Some(sfc.style_line));
702 let comps: Components = components
703 .iter()
704 .map(|(tag, c)| {
705 (
706 tag.clone(),
707 Component {
708 template: c.template.clone(),
709 rules: parse_rules(&c.style, viewport),
710 },
711 )
712 })
713 .collect();
714
715 let mut ancestors: Vec<AncNode> = Vec::new();
716 let locals = Locals::new();
717 let mut reg = BindingRegistry::default();
718 let mut node = build_node(
719 &sfc.template,
720 &rules,
721 &comps,
722 &mut ancestors,
723 &[],
724 &Inherited {
725 color: DEFAULT_COLOR,
726 font_size: DEFAULT_FONT_SIZE,
727 font_family: None,
728 vars: Vars::default(),
729 },
730 engine,
731 &locals,
732 &[],
733 &[],
734 &mut reg,
735 state,
736 );
737 link_labels(&mut node);
738 Ok((node, reg))
739}
740
741fn interpolate_tracked(
746 text: &str,
747 engine: &mut Engine,
748 locals: &Locals,
749) -> (String, HashSet<String>) {
750 let mut out = String::new();
751 let mut deps = HashSet::new();
752 let mut rest = text;
753 while let Some(start) = rest.find("{{") {
754 out.push_str(&decode_entities(&rest[..start]));
755 let after = &rest[start + 2..];
756 match after.find("}}") {
757 Some(end) => {
758 let (value, d) = engine.eval_display_tracked(after[..end].trim(), locals);
759 out.push_str(&value);
760 deps.extend(d);
761 rest = &after[end + 2..];
762 }
763 None => {
764 out.push_str("{{");
765 rest = after;
766 }
767 }
768 }
769 out.push_str(&decode_entities(rest));
770 (out, deps)
771}
772
773fn text_template(el: &Element) -> String {
776 el.children
777 .iter()
778 .filter_map(|c| match c {
779 TplNode::Text(t) => Some(t.trim()),
780 _ => None,
781 })
782 .filter(|t| !t.is_empty())
783 .collect::<Vec<_>>()
784 .join(" ")
785}
786
787use rux_parser::decode_entities;
791
792#[derive(Debug, Clone, Default)]
796struct Compound {
797 tag: Option<String>,
798 id: Option<String>,
799 classes: Vec<String>,
800 role: Option<String>,
801 pseudos: Vec<Pseudo>,
802}
803
804#[derive(Debug, Clone, PartialEq, Eq)]
813enum Pseudo {
814 Hover,
815 Focus,
816 Active,
817 Checked,
818 Unknown(String),
819}
820
821#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
826pub struct ElemStates {
827 pub hover: bool,
828 pub focus: bool,
829 pub active: bool,
830 pub checked: bool,
831}
832
833#[derive(Clone, Debug, Default, PartialEq, Eq)]
841pub struct InteractionState {
842 pub hovered: Option<Vec<usize>>,
844 pub active: Option<Vec<usize>>,
846 pub focused_model: Option<String>,
848}
849
850impl InteractionState {
851 fn hovers(&self, path: &[usize]) -> bool {
856 self.hovered.as_ref().is_some_and(|h| h.starts_with(path))
857 }
858
859 fn activates(&self, path: &[usize]) -> bool {
861 self.active.as_ref().is_some_and(|a| a.starts_with(path))
862 }
863}
864
865impl Pseudo {
866 fn is_pointer_state(&self) -> bool {
870 matches!(self, Self::Hover | Self::Active)
871 }
872
873 fn parse(name: &str) -> Self {
874 match name.to_ascii_lowercase().as_str() {
875 "hover" => Self::Hover,
876 "focus" => Self::Focus,
877 "active" => Self::Active,
878 "checked" => Self::Checked,
879 other => Self::Unknown(other.to_string()),
880 }
881 }
882
883 fn holds(&self, s: &ElemStates) -> bool {
884 match self {
885 Self::Hover => s.hover,
886 Self::Focus => s.focus,
887 Self::Active => s.active,
888 Self::Checked => s.checked,
889 Self::Unknown(_) => false,
891 }
892 }
893}
894
895#[derive(Debug, Clone, Copy, PartialEq, Eq)]
897enum Combinator {
898 Descendant,
900 Child,
902 NextSibling,
904 SubsequentSibling,
906}
907
908#[derive(Debug, Clone)]
912struct Rule {
913 chain: Vec<Compound>,
914 combs: Vec<Combinator>,
915 specificity: (u32, u32, u32),
916 order: usize,
917 decls: Vec<(String, String)>,
918}
919
920#[derive(Debug, Clone)]
922struct ElemDesc {
923 tag: String,
924 id: Option<String>,
925 classes: Vec<String>,
926 role: Option<String>,
927 states: ElemStates,
928}
929
930#[derive(Debug, Clone)]
935struct AncNode {
936 desc: ElemDesc,
937 prev: Vec<ElemDesc>,
938}
939
940impl ElemDesc {
941 fn of(el: &Element) -> Self {
942 Self {
943 tag: el.tag.clone(),
944 id: el.id().map(str::to_string),
945 classes: el.classes().into_iter().map(str::to_string).collect(),
946 role: el.role().map(str::to_string),
947 states: ElemStates::default(),
948 }
949 }
950}
951
952#[derive(Clone, Copy, Debug, PartialEq)]
958pub struct Viewport {
959 pub width: f32,
960 pub height: f32,
961}
962
963impl Default for Viewport {
964 fn default() -> Self {
967 Self { width: 1280.0, height: 800.0 }
968 }
969}
970
971#[derive(Debug, Clone, Copy, PartialEq)]
974enum Cmp {
975 Le,
976 Lt,
977 Ge,
978 Gt,
979 Eq,
980}
981
982impl Cmp {
983 fn holds(self, actual: f32, bound: f32) -> bool {
984 match self {
985 Self::Le => actual <= bound,
986 Self::Lt => actual < bound,
987 Self::Ge => actual >= bound,
988 Self::Gt => actual > bound,
989 Self::Eq => (actual - bound).abs() < f32::EPSILON,
990 }
991 }
992
993 fn flipped(self) -> Self {
995 match self {
996 Self::Le => Self::Ge,
997 Self::Lt => Self::Gt,
998 Self::Ge => Self::Le,
999 Self::Gt => Self::Lt,
1000 Self::Eq => Self::Eq,
1001 }
1002 }
1003}
1004
1005#[derive(Debug, Clone, Copy, PartialEq)]
1009enum Feature {
1010 Width(Cmp, f32),
1011 Height(Cmp, f32),
1012 Portrait,
1013 Landscape,
1014 Always,
1016 Never,
1018}
1019
1020impl Feature {
1021 fn holds(&self, vp: Viewport) -> bool {
1022 match *self {
1023 Self::Width(cmp, v) => cmp.holds(vp.width, v),
1024 Self::Height(cmp, v) => cmp.holds(vp.height, v),
1025 Self::Portrait => vp.height >= vp.width,
1026 Self::Landscape => vp.width > vp.height,
1027 Self::Always => true,
1028 Self::Never => false,
1029 }
1030 }
1031}
1032
1033#[derive(Debug, Clone, Default)]
1036struct MediaCond {
1037 any: Vec<Vec<Feature>>,
1038}
1039
1040impl MediaCond {
1041 fn holds(&self, vp: Viewport) -> bool {
1042 self.any.iter().any(|all| all.iter().all(|f| f.holds(vp)))
1043 }
1044
1045 fn parse(text: &str) -> Self {
1048 let any = text
1049 .split(',')
1050 .map(|alternative| {
1051 alternative
1052 .split(" and ")
1053 .flat_map(|token| parse_media_feature(token.trim()))
1054 .collect()
1055 })
1056 .collect();
1057 Self { any }
1058 }
1059}
1060
1061fn parse_media_feature(token: &str) -> Vec<Feature> {
1070 let inner = token.trim();
1071 if !inner.starts_with('(') {
1073 return vec![match inner.to_ascii_lowercase().as_str() {
1074 "screen" | "all" => Feature::Always,
1075 other => {
1078 warn_unsupported_media(other);
1079 Feature::Never
1080 }
1081 }];
1082 }
1083 let body = inner.trim_start_matches('(').trim_end_matches(')').trim();
1084
1085 let parts = split_on_comparators(body);
1087 if parts.len() >= 3 {
1088 return parse_range(&parts);
1089 }
1090
1091 let Some((name, value)) = body.split_once(':') else {
1092 warn_unsupported_media(body);
1094 return vec![Feature::Never];
1095 };
1096 let name = name.trim().to_ascii_lowercase();
1097 let value = value.trim();
1098 vec![match name.as_str() {
1099 "orientation" => match value.to_ascii_lowercase().as_str() {
1100 "portrait" => Feature::Portrait,
1101 "landscape" => Feature::Landscape,
1102 _ => Feature::Never,
1103 },
1104 "min-width" | "max-width" | "min-height" | "max-height" => {
1105 let Some(px) = parse_px(value) else {
1108 warn_unsupported_media(&format!("{name}: {value}"));
1109 return vec![Feature::Never];
1110 };
1111 match name.as_str() {
1112 "min-width" => Feature::Width(Cmp::Ge, px),
1113 "max-width" => Feature::Width(Cmp::Le, px),
1114 "min-height" => Feature::Height(Cmp::Ge, px),
1115 _ => Feature::Height(Cmp::Le, px),
1116 }
1117 }
1118 other => {
1119 warn_unsupported_media(other);
1120 Feature::Never
1121 }
1122 }]
1123}
1124
1125enum RangePart {
1127 Operand(String),
1128 Op(Cmp),
1129}
1130
1131fn split_on_comparators(body: &str) -> Vec<RangePart> {
1134 let mut parts = Vec::new();
1135 let mut current = String::new();
1136 let mut chars = body.chars().peekable();
1137 let mut saw_op = false;
1138 while let Some(c) = chars.next() {
1139 let op = match c {
1140 '<' if chars.peek() == Some(&'=') => {
1141 chars.next();
1142 Some(Cmp::Le)
1143 }
1144 '>' if chars.peek() == Some(&'=') => {
1145 chars.next();
1146 Some(Cmp::Ge)
1147 }
1148 '<' => Some(Cmp::Lt),
1149 '>' => Some(Cmp::Gt),
1150 '=' => Some(Cmp::Eq),
1151 _ => None,
1152 };
1153 match op {
1154 Some(op) => {
1155 parts.push(RangePart::Operand(current.trim().to_string()));
1156 parts.push(RangePart::Op(op));
1157 current = String::new();
1158 saw_op = true;
1159 }
1160 None => current.push(c),
1161 }
1162 }
1163 if !saw_op {
1164 return Vec::new();
1165 }
1166 parts.push(RangePart::Operand(current.trim().to_string()));
1167 parts
1168}
1169
1170fn parse_range(parts: &[RangePart]) -> Vec<Feature> {
1173 let feature = |axis: &str, cmp: Cmp, value: &str| -> Feature {
1175 let Some(px) = parse_px(value) else {
1176 warn_unsupported_media(value);
1177 return Feature::Never;
1178 };
1179 match axis {
1180 "width" => Feature::Width(cmp, px),
1181 "height" => Feature::Height(cmp, px),
1182 other => {
1183 warn_unsupported_media(other);
1184 Feature::Never
1185 }
1186 }
1187 };
1188 let operand = |i: usize| match &parts[i] {
1189 RangePart::Operand(s) => s.to_ascii_lowercase(),
1190 RangePart::Op(_) => String::new(),
1191 };
1192 let op = |i: usize| match &parts[i] {
1193 RangePart::Op(c) => *c,
1194 RangePart::Operand(_) => Cmp::Eq,
1195 };
1196
1197 match parts.len() {
1198 3 => {
1199 let (left, right) = (operand(0), operand(2));
1200 if left == "width" || left == "height" {
1201 vec![feature(&left, op(1), &right)]
1202 } else {
1203 vec![feature(&right, op(1).flipped(), &left)]
1205 }
1206 }
1207 5 => {
1209 let axis = operand(2);
1210 vec![
1211 feature(&axis, op(1).flipped(), &operand(0)),
1212 feature(&axis, op(3), &operand(4)),
1213 ]
1214 }
1215 _ => vec![Feature::Never],
1216 }
1217}
1218
1219fn warn_unsupported_media(what: &str) {
1223 use std::sync::{Mutex, OnceLock};
1224 static SEEN: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
1225 let message = format!(
1226 "`@media` condition `{what}` is not supported, its rules will never apply \
1227 (supported: screen/all, min-/max-width, min-/max-height, orientation)"
1228 );
1229 warn(message.clone());
1230 let seen = SEEN.get_or_init(|| Mutex::new(HashSet::new()));
1231 let Ok(mut seen) = seen.lock() else { return };
1232 if seen.insert(what.to_string()) {
1233 echo(&message);
1234 }
1235}
1236
1237pub fn media_matches(css: &str, vp: Viewport) -> Vec<bool> {
1241 let Ok(sheet) = StyleSheet::parse(css, ParserOptions::default()) else {
1242 return Vec::new();
1243 };
1244 let mut out = Vec::new();
1245 collect_media_matches(&sheet.rules.0, vp, &mut out);
1246 out
1247}
1248
1249fn collect_media_matches(rules: &[CssRule], vp: Viewport, out: &mut Vec<bool>) {
1250 for rule in rules {
1251 if let CssRule::Media(media) = rule {
1252 let text = media
1253 .query
1254 .to_css_string(PrinterOptions::default())
1255 .unwrap_or_default();
1256 out.push(MediaCond::parse(&text).holds(vp));
1257 collect_media_matches(&media.rules.0, vp, out);
1258 }
1259 }
1260}
1261
1262fn parse_rules(css: &str, vp: Viewport) -> Vec<Rule> {
1268 parse_rules_at(css, vp, None)
1269}
1270
1271fn parse_rules_at(css: &str, vp: Viewport, base: Option<usize>) -> Vec<Rule> {
1272 let sheet = match StyleSheet::parse(css, ParserOptions::default()) {
1273 Ok(s) => s,
1274 Err(_) => return Vec::new(),
1275 };
1276
1277 let mut rules = Vec::new();
1278 let mut order = 0usize;
1279 collect_rules(&sheet.rules.0, vp, &mut rules, &mut order, base, css);
1280 rules
1281}
1282
1283fn collect_rules(
1289 rules: &[CssRule],
1290 vp: Viewport,
1291 out: &mut Vec<Rule>,
1292 order: &mut usize,
1293 base: Option<usize>,
1294 css: &str,
1295) {
1296 for rule in rules {
1297 match rule {
1298 CssRule::Media(media) => {
1299 let text = media
1300 .query
1301 .to_css_string(PrinterOptions::default())
1302 .unwrap_or_default();
1303 let holds = located(file_line(base, media.loc.line), || {
1305 MediaCond::parse(&text).holds(vp)
1306 });
1307 if holds {
1308 collect_rules(&media.rules.0, vp, out, order, base, css);
1309 }
1310 }
1311 CssRule::Style(style) => collect_style_rule(style, out, order, base, css),
1312 _ => {}
1313 }
1314 }
1315}
1316
1317fn file_line(base: Option<usize>, relative: u32) -> Option<usize> {
1321 base.map(|b| b + relative as usize)
1322}
1323
1324fn decl_line(css: &str, rule_line: u32, property: &str) -> Option<u32> {
1337 let mut depth = 0usize;
1338 let mut entered = false;
1339 for (offset, text) in css.lines().enumerate().skip(rule_line as usize) {
1340 if entered {
1342 let trimmed = text.trim_start();
1343 if let Some(rest) = trimmed.strip_prefix(property) {
1344 if rest.trim_start().starts_with(':') {
1345 return u32::try_from(offset).ok();
1346 }
1347 }
1348 }
1349 for ch in text.chars() {
1350 match ch {
1351 '{' => {
1352 depth += 1;
1353 entered = true;
1354 }
1355 '}' => {
1356 depth = depth.saturating_sub(1);
1357 if entered && depth == 0 {
1359 return None;
1360 }
1361 }
1362 _ => {}
1363 }
1364 }
1365 }
1366 None
1367}
1368
1369fn collect_style_rule(
1370 style: &lightningcss::rules::style::StyleRule,
1371 out: &mut Vec<Rule>,
1372 order: &mut usize,
1373 base: Option<usize>,
1374 css: &str,
1375) {
1376 located(file_line(base, style.loc.line), || {
1377 let mut decls = Vec::new();
1379 for prop in &style.declarations.declarations {
1380 if let Ok(text) = prop.to_css_string(false, PrinterOptions::default()) {
1381 if let Some((k, v)) = text.split_once(':') {
1382 let key = k.trim().to_lowercase();
1383 let at = decl_line(css, style.loc.line, &key).unwrap_or(style.loc.line);
1391 located(file_line(base, at), || warn_if_unhonored(&key));
1392 decls.push((
1393 key,
1394 v.trim().trim_end_matches(';').trim().to_string(),
1395 ));
1396 }
1397 }
1398 }
1399
1400 for selector in &style.selectors.0 {
1402 if let Ok(text) = selector.to_css_string(PrinterOptions::default()) {
1403 if let Some((chain, combs, specificity)) = parse_selector(&text) {
1404 out.push(Rule {
1405 chain,
1406 combs,
1407 specificity,
1408 order: *order,
1409 decls: decls.clone(),
1410 });
1411 }
1412 }
1413 *order += 1;
1414 }
1415 });
1416}
1417
1418const HONORED_PROPERTIES: &[&str] = &[
1423 "display", "width", "height", "gap",
1425 "min-width", "max-width", "min-height", "max-height",
1426 "padding", "padding-top", "padding-right", "padding-bottom", "padding-left",
1427 "margin", "margin-top", "margin-right", "margin-bottom", "margin-left",
1428 "border", "border-width", "border-color", "border-radius",
1429 "border-top-left-radius", "border-top-right-radius",
1430 "border-bottom-right-radius", "border-bottom-left-radius",
1431 "border-top", "border-right", "border-bottom", "border-left",
1432 "border-top-width", "border-right-width", "border-bottom-width", "border-left-width",
1433 "overflow", "overflow-x", "overflow-y", "opacity", "cursor", "box-shadow", "transform",
1434 "flex", "flex-grow", "flex-shrink", "flex-basis", "flex-wrap", "flex-direction",
1436 "justify-content", "align-items", "align-self", "justify-self", "justify-items",
1437 "align-content", "row-gap", "column-gap",
1438 "grid-template-columns", "grid-template-rows",
1439 "grid-column", "grid-row",
1440 "grid-column-start", "grid-column-end", "grid-row-start", "grid-row-end",
1441 "grid-auto-flow", "grid-auto-rows", "grid-auto-columns",
1442 "position", "top", "right", "bottom", "left", "aspect-ratio",
1444 "background", "background-color", "background-image",
1446 "color", "font-size", "font-weight", "font-family", "font-style", "text-align",
1448 "letter-spacing", "word-spacing", "line-height", "white-space",
1449 "text-decoration", "text-decoration-line",
1450 "overflow-wrap", "word-wrap", "word-break",
1451];
1452
1453fn is_honored(property: &str) -> bool {
1454 HONORED_PROPERTIES.contains(&property)
1455}
1456
1457fn warn_if_unhonored(property: &str) {
1461 use std::collections::HashSet;
1462 use std::sync::{Mutex, OnceLock};
1463 static SEEN: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
1464
1465 if property.starts_with("--") || is_honored(property) {
1468 return;
1469 }
1470 let message =
1471 format!("CSS property `{property}` is parsed but not yet honored, it will have no effect");
1472 warn(message.clone());
1473 let seen = SEEN.get_or_init(|| Mutex::new(HashSet::new()));
1474 let Ok(mut seen) = seen.lock() else { return };
1475 if seen.insert(property.to_string()) {
1476 echo(&message);
1477 }
1478}
1479
1480fn parse_selector(text: &str) -> Option<(Vec<Compound>, Vec<Combinator>, (u32, u32, u32))> {
1486 let chars: Vec<char> = text.chars().collect();
1487 let mut i = 0;
1488 let mut chain = Vec::new();
1489 let mut combs = Vec::new();
1490 let mut spec = (0u32, 0u32, 0u32);
1491 let mut pending: Option<Combinator> = None;
1493
1494 while i < chars.len() {
1495 let c = chars[i];
1496 if c.is_whitespace() {
1497 i += 1;
1498 continue;
1499 }
1500 if let Some(comb) = combinator_of(c) {
1501 pending = Some(comb);
1502 i += 1;
1503 continue;
1504 }
1505 let start = i;
1508 let mut depth = 0i32;
1509 while i < chars.len() {
1510 let d = chars[i];
1511 if d == '[' || d == '(' {
1512 depth += 1;
1513 } else if d == ']' || d == ')' {
1514 depth -= 1;
1515 } else if depth == 0 && (d.is_whitespace() || combinator_of(d).is_some()) {
1516 break;
1517 }
1518 i += 1;
1519 }
1520 let token: String = chars[start..i].iter().collect();
1521 let compound = parse_compound(&token, &mut spec)?;
1522 if !chain.is_empty() {
1523 combs.push(pending.take().unwrap_or(Combinator::Descendant));
1525 }
1526 pending = None;
1527 chain.push(compound);
1528 }
1529 if chain.is_empty() {
1530 return None;
1531 }
1532 Some((chain, combs, spec))
1533}
1534
1535fn combinator_of(c: char) -> Option<Combinator> {
1536 match c {
1537 '>' => Some(Combinator::Child),
1538 '+' => Some(Combinator::NextSibling),
1539 '~' => Some(Combinator::SubsequentSibling),
1540 _ => None,
1541 }
1542}
1543
1544fn parse_compound(token: &str, spec: &mut (u32, u32, u32)) -> Option<Compound> {
1545 let mut c = Compound::default();
1546 let chars: Vec<char> = token.chars().collect();
1547 let mut i = 0;
1548
1549 let mut tag = String::new();
1551 while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '-' || chars[i] == '*') {
1552 tag.push(chars[i]);
1553 i += 1;
1554 }
1555 if !tag.is_empty() && tag != "*" {
1556 c.tag = Some(tag);
1557 spec.2 += 1;
1558 }
1559
1560 while i < chars.len() {
1561 match chars[i] {
1562 '.' => {
1563 i += 1;
1564 let mut cls = String::new();
1565 while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '-' || chars[i] == '_') {
1566 cls.push(chars[i]);
1567 i += 1;
1568 }
1569 if !cls.is_empty() {
1570 c.classes.push(cls);
1571 spec.1 += 1;
1572 }
1573 }
1574 '#' => {
1575 i += 1;
1576 let mut id = String::new();
1577 while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '-' || chars[i] == '_') {
1578 id.push(chars[i]);
1579 i += 1;
1580 }
1581 if !id.is_empty() {
1582 c.id = Some(id);
1583 spec.0 += 1;
1584 }
1585 }
1586 '[' => {
1587 let end = token.find(']')?;
1589 let inner = &token[i + 1..end];
1590 if let Some(rest) = inner.strip_prefix("role") {
1591 let val = rest
1592 .trim_start_matches('=')
1593 .trim_matches(|ch| ch == '"' || ch == '\'');
1594 c.role = Some(val.to_string());
1595 spec.1 += 1;
1596 }
1597 i = end + 1;
1598 }
1599 ':' => {
1600 i += 1;
1604 let mut name = String::new();
1605 if i < chars.len() && chars[i] == ':' {
1609 name.push(':');
1610 i += 1;
1611 }
1612 while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '-') {
1613 name.push(chars[i]);
1614 i += 1;
1615 }
1616 if i < chars.len() && chars[i] == '(' {
1619 let mut depth = 0i32;
1620 while i < chars.len() {
1621 if chars[i] == '(' {
1622 depth += 1;
1623 } else if chars[i] == ')' {
1624 depth -= 1;
1625 }
1626 name.push(chars[i]);
1627 i += 1;
1628 if depth == 0 {
1629 break;
1630 }
1631 }
1632 }
1633 if !name.is_empty() {
1634 let pseudo = Pseudo::parse(&name);
1635 if let Pseudo::Unknown(n) = &pseudo {
1636 warn_unknown_pseudo(n);
1637 }
1638 c.pseudos.push(pseudo);
1639 spec.1 += 1;
1641 }
1642 }
1643 _ => break,
1644 }
1645 }
1646 Some(c)
1647}
1648
1649fn warn_unknown_pseudo(name: &str) {
1653 use std::sync::{Mutex, OnceLock};
1654 static SEEN: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
1655 let message = format!(
1656 "pseudo-class `:{name}` is not supported, rules using it will never match \
1657 (supported: :hover, :focus, :active, :checked)"
1658 );
1659 warn(message.clone());
1660 let seen = SEEN.get_or_init(|| Mutex::new(HashSet::new()));
1661 let Ok(mut seen) = seen.lock() else { return };
1662 if seen.insert(name.to_string()) {
1663 echo(&message);
1664 }
1665}
1666
1667fn matches_compound(c: &Compound, el: &ElemDesc) -> bool {
1670 if let Some(t) = &c.tag {
1671 if *t != el.tag {
1672 return false;
1673 }
1674 }
1675 if let Some(id) = &c.id {
1676 if Some(id.as_str()) != el.id.as_deref() {
1677 return false;
1678 }
1679 }
1680 for cls in &c.classes {
1681 if !el.classes.iter().any(|x| x == cls) {
1682 return false;
1683 }
1684 }
1685 if let Some(r) = &c.role {
1686 if !el.role.as_deref().is_some_and(|er| er.eq_ignore_ascii_case(r)) {
1688 return false;
1689 }
1690 }
1691 if !c.pseudos.iter().all(|p| p.holds(&el.states)) {
1693 return false;
1694 }
1695 true
1696}
1697
1698fn matches_chain(
1709 chain: &[Compound],
1710 combs: &[Combinator],
1711 el: &ElemDesc,
1712 ancestors: &[AncNode],
1713 prev: &[ElemDesc],
1714) -> bool {
1715 let Some((last, rest)) = chain.split_last() else {
1716 return false;
1717 };
1718 if !matches_compound(last, el) {
1719 return false;
1720 }
1721 if rest.is_empty() {
1722 return true;
1723 }
1724 let (comb, rest_combs) = combs.split_last().expect("combs matches chain length");
1727 match comb {
1728 Combinator::Descendant => (0..ancestors.len()).rev().any(|i| {
1729 matches_chain(rest, rest_combs, &ancestors[i].desc, &ancestors[..i], &ancestors[i].prev)
1730 }),
1731 Combinator::Child => {
1732 let Some((parent, up)) = ancestors.split_last() else {
1733 return false;
1734 };
1735 matches_chain(rest, rest_combs, &parent.desc, up, &parent.prev)
1736 }
1737 Combinator::NextSibling => {
1738 let Some((sib, earlier)) = prev.split_last() else {
1739 return false;
1740 };
1741 matches_chain(rest, rest_combs, sib, ancestors, earlier)
1742 }
1743 Combinator::SubsequentSibling => (0..prev.len())
1744 .rev()
1745 .any(|i| matches_chain(rest, rest_combs, &prev[i], ancestors, &prev[..i])),
1746 }
1747}
1748
1749fn pointer_state_sensitive(desc: &ElemDesc, rules: &[Rule]) -> bool {
1763 let probe = ElemDesc {
1766 states: ElemStates { hover: true, active: true, ..desc.states },
1767 ..desc.clone()
1768 };
1769 rules.iter().any(|rule| {
1770 rule.chain.iter().any(|compound| {
1771 compound.pseudos.iter().any(Pseudo::is_pointer_state)
1772 && matches_compound(compound, &probe)
1773 })
1774 })
1775}
1776
1777fn matched_props(
1779 desc: &ElemDesc,
1780 ancestors: &[AncNode],
1781 prev: &[ElemDesc],
1782 rules: &[Rule],
1783) -> HashMap<String, String> {
1784 let mut matched: Vec<&Rule> = rules
1785 .iter()
1786 .filter(|r| matches_chain(&r.chain, &r.combs, desc, ancestors, prev))
1787 .collect();
1788 matched.sort_by(|a, b| a.specificity.cmp(&b.specificity).then(a.order.cmp(&b.order)));
1789
1790 let mut props: HashMap<String, String> = HashMap::new();
1791 for rule in matched {
1792 for (k, v) in &rule.decls {
1793 props.insert(k.clone(), v.clone());
1794 }
1795 }
1796 props
1797}
1798
1799#[allow(clippy::too_many_arguments)]
1807fn build_node(
1808 el: &Element,
1809 rules: &[Rule],
1810 comps: &Components,
1811 ancestors: &mut Vec<AncNode>,
1812 prev: &[ElemDesc],
1813 inherited: &Inherited,
1814 engine: &mut Engine,
1815 locals: &Locals,
1816 path: &[usize],
1817 tpl_path: &[usize],
1818 reg: &mut BindingRegistry,
1819 state: &InteractionState,
1820) -> LayoutNode {
1821 if let Some(component) = comps.get(&el.tag) {
1823 return expand_component(
1824 el, component, comps, inherited, engine, locals, path, tpl_path, reg, state,
1825 );
1826 }
1827
1828 let mut desc = ElemDesc::of(el);
1829 let toggle = Toggle::of(el, engine, locals);
1834 if toggle.as_ref().is_some_and(|t| t.checked) {
1835 desc.states.checked = true;
1836 desc.classes.push("checked".to_string());
1837 }
1838 desc.states.hover = state.hovers(path);
1842 desc.states.active = state.activates(path);
1843 desc.states.focus = match (&state.focused_model, el.attr("r-model")) {
1844 (Some(focused), Some(model)) => focused == model,
1845 _ => false,
1846 };
1847 let mut dyn_deps: HashSet<String> = HashSet::new();
1850 if let Some(expr) = el.attr(":class") {
1851 let (value, deps) = engine.eval_value_tracked(expr, locals);
1852 dyn_deps.extend(deps);
1853 if let Some(v) = value {
1854 desc.classes.extend(class_list(&v));
1855 }
1856 }
1857
1858 let state_path = pointer_state_sensitive(&desc, rules).then(|| path.to_vec());
1862
1863 let mut props = matched_props(&desc, ancestors, prev, rules);
1864 if let Some(s) = el.attr("style") {
1867 merge_inline_style(&mut props, s);
1868 }
1869 if let Some(expr) = el.attr(":style") {
1870 let (value, deps) = engine.eval_value_tracked(expr, locals);
1871 dyn_deps.extend(deps);
1872 match value {
1873 Some(Value::Map(entries)) => {
1875 for (k, v) in entries {
1876 props.insert(k.to_ascii_lowercase(), v.to_display());
1877 }
1878 }
1879 Some(v) => merge_inline_style(&mut props, &v.to_display()),
1881 None => {}
1882 }
1883 }
1884 if !dyn_deps.is_empty() {
1886 reg.styled.push(StyledBinding { path: path.to_vec(), deps: dyn_deps });
1887 }
1888
1889 let vars = take_vars(&mut props, &inherited.vars);
1894 for value in props.values_mut() {
1899 if value.contains("var(") {
1900 *value = resolve_vars(value, &vars, 0);
1901 }
1902 }
1903
1904 let style = interpret(&props);
1905 let on_tap = el.attr("@tap").map(|h| bind_locals(h, locals));
1910 let hidden = el.attr("r-show").is_some_and(|e| {
1914 let (v, deps) = engine.eval_bool_tracked(e, locals);
1915 reg.show.push(ShowBinding {
1916 path: path.to_vec(),
1917 cond: e.to_string(),
1918 locals: locals.clone(),
1919 deps,
1920 });
1921 !v
1922 });
1923
1924 let color = props
1926 .get("color")
1927 .and_then(|v| parse_color(v))
1928 .unwrap_or(inherited.color);
1929 let font_size = props
1930 .get("font-size")
1931 .and_then(|v| parse_px(first(v)))
1932 .unwrap_or(inherited.font_size);
1933 let font_family = props
1936 .get("font-family")
1937 .filter(|v| !v.trim().is_empty() && v.trim() != "inherit")
1938 .map(|v| v.trim().to_string())
1939 .or_else(|| inherited.font_family.clone());
1940 let letter_spacing = props.get("letter-spacing").and_then(|v| parse_spacing(v));
1943 let word_spacing = props.get("word-spacing").and_then(|v| parse_spacing(v));
1944 let line_height = props.get("line-height").and_then(|v| parse_line_height(v, font_size));
1947 let italic = props
1948 .get("font-style")
1949 .is_some_and(|v| matches!(v.trim(), "italic" | "oblique"));
1950 let decoration = props.get("text-decoration-line").or_else(|| props.get("text-decoration"));
1952 let underline = decoration.is_some_and(|v| v.split_whitespace().any(|t| t == "underline"));
1953 let strikethrough = decoration.is_some_and(|v| v.split_whitespace().any(|t| t == "line-through"));
1954 let nowrap = props
1957 .get("white-space")
1958 .is_some_and(|v| matches!(v.trim(), "nowrap" | "pre"));
1959
1960 if el.tag == "text" {
1961 let weight = props.get("font-weight").and_then(|v| parse_weight(v)).unwrap_or(400);
1962 let align = props
1963 .get("text-align")
1964 .map(|v| parse_text_align(v))
1965 .unwrap_or_default();
1966 let wrap = style.text_wrap;
1967 let template = text_template(el);
1971 let (text, deps) = interpolate_tracked(&template, engine, locals);
1972 if template.contains("{{") {
1973 reg.text.push(TextBinding {
1974 path: path.to_vec(),
1975 template,
1976 locals: locals.clone(),
1977 deps,
1978 });
1979 }
1980 let mut node = LayoutNode::text(
1981 style,
1982 TextContent {
1983 text,
1984 font_size,
1985 weight,
1986 color,
1987 align,
1988 wrap,
1989 font_family: font_family.clone(),
1990 letter_spacing,
1991 word_spacing,
1992 line_height,
1993 italic,
1994 underline,
1995 strikethrough,
1996 nowrap,
1997 caret: None,
1998 selection: None,
1999 preedit: None,
2000 },
2001 );
2002 node.on_tap = on_tap;
2003 node.hidden = hidden;
2004 node.id = el.attr("id").map(str::to_string);
2005 node.label_for = el.attr("for").map(str::to_string);
2006 node.state_path = state_path.clone();
2007 node.access = Access {
2010 role: explicit_access_role(el).unwrap_or(if node.on_tap.is_some() {
2011 AccessRole::Button
2012 } else {
2013 AccessRole::Label
2014 }),
2015 label: authored_label(el).or_else(|| {
2016 node.text.as_ref().map(|t| t.text.trim().to_string()).filter(|t| !t.is_empty())
2017 }),
2018 ..Access::default()
2019 };
2020 return node;
2021 }
2022
2023 if el.tag == "image" {
2027 let src = el
2028 .attr(":src")
2029 .map(|e| {
2030 let (v, deps) = engine.eval_display_tracked(e, locals);
2033 reg.src.push(AttrBinding {
2034 path: path.to_vec(),
2035 expr: e.to_string(),
2036 locals: locals.clone(),
2037 deps,
2038 });
2039 v
2040 })
2041 .or_else(|| el.attr("src").map(str::to_string))
2042 .unwrap_or_default();
2043 let mut node = LayoutNode::image(
2044 style,
2045 ImageContent {
2046 src,
2047 intrinsic: (0.0, 0.0),
2048 },
2049 );
2050 node.on_tap = on_tap;
2051 node.hidden = hidden;
2052 node.id = el.attr("id").map(str::to_string);
2053 node.label_for = el.attr("for").map(str::to_string);
2054 node.state_path = state_path.clone();
2055 node.access = Access {
2058 role: explicit_access_role(el).unwrap_or(AccessRole::Image),
2059 label: authored_label(el),
2060 ..Access::default()
2061 };
2062 return node;
2063 }
2064
2065 if let Some(Toggle { radio, checked, deps }) = toggle {
2071 reg.toggles.push(ToggleBinding { path: path.to_vec(), deps });
2073 let model = el.attr("r-model").unwrap_or_default().to_string();
2074 let value = el.attr("value").unwrap_or_default().to_string();
2075
2076 let mut style = style;
2077 if style.display == Display::Block {
2079 style.display = Display::Flex;
2080 }
2081 style.justify.get_or_insert(Justify::Center);
2082 style.align.get_or_insert(Align::Center);
2083 if radio && style.radius == [0.0; 4] {
2085 style.radius = [CIRCLE; 4];
2086 }
2087
2088 let mut node = LayoutNode::new(style);
2089 if checked {
2090 node.children.push(if radio {
2091 LayoutNode::new(Style {
2093 display: Display::Flex,
2094 width: Some(Len::Pct(0.5)),
2095 height: Some(Len::Pct(0.5)),
2096 background: Some(Background::Color(color)),
2097 radius: [CIRCLE; 4],
2098 ..Default::default()
2099 })
2100 } else {
2101 let mut mark = LayoutNode::new(Style {
2104 display: Display::Flex,
2105 width: Some(Len::Pct(0.68)),
2106 height: Some(Len::Pct(0.68)),
2107 ..Default::default()
2108 });
2109 mark.tick = Some(color);
2110 mark
2111 });
2112 }
2113 node.on_tap = on_tap.or_else(|| {
2114 if model.is_empty() {
2115 None
2116 } else if radio {
2117 Some(format!("{model} = \"{value}\""))
2118 } else {
2119 Some(format!("{model} = !{model}"))
2120 }
2121 });
2122 node.hidden = hidden;
2123 node.id = el.attr("id").map(str::to_string);
2124 node.label_for = el.attr("for").map(str::to_string);
2125 node.state_path = state_path.clone();
2126 node.access = Access {
2129 role: if radio { AccessRole::RadioButton } else { AccessRole::CheckBox },
2130 label: authored_label(el),
2131 placeholder: None,
2132 checked: Some(checked),
2133 value: None,
2134 };
2135 return node;
2136 }
2137
2138 if el.tag == "input" {
2142 let mut style = style;
2143 let multiline = el.attr("type") == Some("textarea");
2144 if style.width.is_none() {
2148 style.width = Some(Len::Pct(1.0));
2149 }
2150 if style.overflow == Overflow::Visible {
2151 style.overflow = if multiline { Overflow::Scroll } else { Overflow::Clip };
2152 }
2153 let options = (el.attr("type") == Some("select"))
2156 .then(|| {
2157 el.attr(":options")
2158 .and_then(|e| {
2159 let (v, deps) = engine.eval_value_tracked(e, locals);
2161 reg.options.push(AttrBinding {
2162 path: path.to_vec(),
2163 expr: e.to_string(),
2164 locals: locals.clone(),
2165 deps,
2166 });
2167 v
2168 })
2169 .and_then(|v| v.as_list().map(|items| items.iter().map(Value::to_display).collect()))
2170 .unwrap_or_default()
2171 });
2172 let model = el.attr("r-model").map(str::to_string);
2173 let placeholder = el.attr("placeholder").unwrap_or_default().to_string();
2174 const PLACEHOLDER_COLOR: Rgba = Rgba::new(0.42, 0.44, 0.52, 1.0); let value = model
2180 .as_deref()
2181 .map(|m| {
2182 let (v, deps) = engine.eval_display_tracked(m, locals);
2183 reg.value.push(ValueBinding {
2184 path: path.to_vec(),
2185 model: m.to_string(),
2186 placeholder: placeholder.clone(),
2187 color,
2188 placeholder_color: PLACEHOLDER_COLOR,
2189 locals: locals.clone(),
2190 deps,
2191 });
2192 v
2193 })
2194 .unwrap_or_default();
2195 let (shown, shown_color) = if value.is_empty() {
2196 (placeholder.clone(), PLACEHOLDER_COLOR)
2197 } else {
2198 (value, color)
2199 };
2200 let text_child = LayoutNode::text(
2201 Style::default(),
2202 TextContent {
2203 text: shown,
2204 font_size,
2205 weight: 400,
2206 color: shown_color,
2207 align: TextAlign::Start,
2208 wrap: style.text_wrap,
2209 font_family: font_family.clone(),
2210 letter_spacing,
2211 word_spacing,
2212 line_height,
2213 italic,
2214 underline,
2215 strikethrough,
2216 nowrap: !multiline,
2218 caret: None,
2220 selection: None,
2221 preedit: None,
2222 },
2223 );
2224 let mut node = LayoutNode::new(style);
2225 node.children.push(text_child);
2226 node.model = model;
2227 node.multiline = multiline;
2228 node.options = options;
2229 node.on_tap = on_tap;
2230 node.hidden = hidden;
2231 node.id = el.attr("id").map(str::to_string);
2232 node.label_for = el.attr("for").map(str::to_string);
2233 node.state_path = state_path.clone();
2234 node.access = Access {
2238 role: explicit_access_role(el).unwrap_or(if node.options.is_some() {
2239 AccessRole::ComboBox
2240 } else if multiline {
2241 AccessRole::MultilineTextInput
2242 } else {
2243 AccessRole::TextInput
2244 }),
2245 label: authored_label(el),
2246 placeholder: (!placeholder.is_empty()).then(|| placeholder.clone()),
2249 value: node
2250 .model
2251 .as_deref()
2252 .map(|m| engine.eval_display(m, locals))
2253 .filter(|v| !v.is_empty()),
2254 checked: None,
2255 };
2256 return node;
2257 }
2258
2259 ancestors.push(AncNode { desc, prev: prev.to_vec() });
2260 let element_children: Vec<&Element> = el
2261 .children
2262 .iter()
2263 .filter_map(|n| match n {
2264 TplNode::Element(child) => Some(child),
2265 TplNode::Text(_) => None,
2266 })
2267 .collect();
2268 let (children, structural_deps) = build_children(
2269 &element_children,
2270 rules,
2271 comps,
2272 ancestors,
2273 &Inherited { color, font_size, font_family, vars: Rc::clone(&vars) },
2274 engine,
2275 locals,
2276 path,
2277 tpl_path,
2278 reg,
2279 state,
2280 );
2281 ancestors.pop();
2282 if !structural_deps.is_empty() {
2285 reg.structural_parents.push(StructuralParent {
2286 tree_path: path.to_vec(),
2287 tpl_path: tpl_path.to_vec(),
2288 deps: structural_deps,
2289 });
2290 }
2291
2292 let mut node = LayoutNode {
2293 style,
2294 text: None,
2295 image: None,
2296 tick: None,
2297 children,
2298 on_tap,
2299 model: None,
2300 multiline: false,
2301 options: None,
2302 hidden,
2303 id: el.attr("id").map(str::to_string),
2304 label_for: el.attr("for").map(str::to_string),
2305 focus_model: None,
2306 state_path,
2307 access: Access::default(),
2308 };
2309 let role = explicit_access_role(el).unwrap_or(if node.on_tap.is_some() {
2314 AccessRole::Button
2315 } else if node.style.overflow == Overflow::Scroll {
2316 AccessRole::ScrollView
2317 } else {
2318 AccessRole::None
2319 });
2320 if role.is_meaningful() {
2321 let label = authored_label(el).or_else(|| {
2322 let text = subtree_text(&node);
2323 (!text.is_empty()).then_some(text)
2324 });
2325 node.access = Access { role, label, ..Access::default() };
2326 }
2327 node
2328}
2329
2330#[allow(clippy::too_many_arguments)]
2334fn expand_component(
2335 el: &Element,
2336 component: &Component,
2337 comps: &Components,
2338 inherited: &Inherited,
2339 engine: &mut Engine,
2340 parent_locals: &Locals,
2341 path: &[usize],
2342 tpl_path: &[usize],
2343 reg: &mut BindingRegistry,
2344 state: &InteractionState,
2345) -> LayoutNode {
2346 let mut props: Locals = Vec::new();
2347 let mut prop_deps: HashSet<String> = HashSet::new();
2348 for (key, expr) in &el.attrs {
2349 if let Some(name) = key.strip_prefix(':') {
2350 let (value, deps) = engine.eval_value_tracked(expr, parent_locals);
2353 prop_deps.extend(deps);
2354 if let Some(value) = value {
2355 props.push((name.to_string(), value));
2356 }
2357 }
2358 }
2359 if !prop_deps.is_empty() {
2361 reg.components.push(ComponentBinding {
2362 path: path.to_vec(),
2363 deps: prop_deps,
2364 });
2365 }
2366
2367 let mut ancestors: Vec<AncNode> = Vec::new();
2370 build_node(
2371 &component.template,
2372 &component.rules,
2373 comps,
2374 &mut ancestors,
2375 &[],
2376 inherited,
2377 engine,
2378 &props,
2379 path,
2380 tpl_path,
2381 reg,
2382 state,
2383 )
2384}
2385
2386fn parse_for(expr: &str) -> Option<(&str, &str)> {
2388 let (var, coll) = expr.split_once(" in ")?;
2389 Some((var.trim(), coll.trim()))
2390}
2391
2392#[allow(clippy::too_many_arguments)]
2395fn build_children(
2396 elements: &[&Element],
2397 rules: &[Rule],
2398 comps: &Components,
2399 ancestors: &mut Vec<AncNode>,
2400 inherited: &Inherited,
2401 engine: &mut Engine,
2402 locals: &Locals,
2403 path: &[usize],
2404 tpl_path: &[usize],
2405 reg: &mut BindingRegistry,
2406 state: &InteractionState,
2407) -> (Vec<LayoutNode>, HashSet<String>) {
2408 let mut out = Vec::new();
2409 let mut structural_deps: HashSet<String> = HashSet::new();
2412 let mut prev: Vec<ElemDesc> = Vec::new();
2416 let mut in_chain = false;
2418 let mut chain_satisfied = false;
2419
2420 let child_path = |out: &Vec<LayoutNode>| -> Vec<usize> {
2423 path.iter().copied().chain(std::iter::once(out.len())).collect()
2424 };
2425 let child_tpl = |ti: usize| -> Vec<usize> {
2426 tpl_path.iter().copied().chain(std::iter::once(ti)).collect()
2427 };
2428
2429 for (ti, el) in elements.iter().enumerate() {
2430 let ctp = child_tpl(ti);
2431 if let Some(for_expr) = el.attr("r-for") {
2434 in_chain = false;
2435 if let Some((var, coll)) = parse_for(for_expr) {
2436 let (value, deps) = engine.eval_value_tracked(coll, locals);
2440 structural_deps.extend(deps);
2441 let items = value.and_then(|v| v.as_list().map(<[Value]>::to_vec));
2442 if let Some(items) = items {
2443 for item in items {
2444 let mut child_locals = locals.clone();
2445 child_locals.push((var.to_string(), item));
2446 let cp = child_path(&out);
2447 out.push(build_node(el, rules, comps, ancestors, &prev, inherited, engine, &child_locals, &cp, &ctp, reg, state));
2448 prev.push(ElemDesc::of(el));
2449 }
2450 }
2451 }
2452 continue;
2453 }
2454
2455 if let Some(cond) = el.attr("r-if") {
2457 in_chain = true;
2458 let (v, deps) = engine.eval_bool_tracked(cond, locals);
2459 structural_deps.extend(deps);
2460 chain_satisfied = v;
2461 if chain_satisfied {
2462 let cp = child_path(&out);
2463 out.push(build_node(el, rules, comps, ancestors, &prev, inherited, engine, locals, &cp, &ctp, reg, state));
2464 prev.push(ElemDesc::of(el));
2465 }
2466 continue;
2467 }
2468 if let Some(cond) = el.attr("r-elif") {
2469 let taken = if in_chain && !chain_satisfied {
2470 let (v, deps) = engine.eval_bool_tracked(cond, locals);
2471 structural_deps.extend(deps);
2472 v
2473 } else {
2474 false
2475 };
2476 if taken {
2477 chain_satisfied = true;
2478 let cp = child_path(&out);
2479 out.push(build_node(el, rules, comps, ancestors, &prev, inherited, engine, locals, &cp, &ctp, reg, state));
2480 prev.push(ElemDesc::of(el));
2481 }
2482 continue;
2483 }
2484 if el.attr("r-else").is_some() {
2485 if in_chain && !chain_satisfied {
2486 let cp = child_path(&out);
2487 out.push(build_node(el, rules, comps, ancestors, &prev, inherited, engine, locals, &cp, &ctp, reg, state));
2488 prev.push(ElemDesc::of(el));
2489 }
2490 in_chain = false;
2491 continue;
2492 }
2493
2494 in_chain = false;
2496 let cp = child_path(&out);
2497 out.push(build_node(el, rules, comps, ancestors, &prev, inherited, engine, locals, &cp, &ctp, reg, state));
2498 prev.push(ElemDesc::of(el));
2499 }
2500 (out, structural_deps)
2501}
2502
2503fn interpret(p: &HashMap<String, String>) -> Style {
2506 let mut st = Style::default();
2507 if let Some(v) = p.get("display") {
2508 st.display = match v.trim() {
2509 "flex" => Display::Flex,
2510 "grid" => Display::Grid,
2511 "inline" => Display::Inline,
2512 "none" => Display::None,
2513 _ => Display::Block,
2514 };
2515 }
2516 if let Some(v) = p.get("width") {
2517 st.width = parse_len(first(v));
2518 }
2519 if let Some(v) = p.get("height") {
2520 st.height = parse_len(first(v));
2521 }
2522 st.padding = box_sides(p, "padding");
2523 st.margin = box_sides(p, "margin");
2524 interpret_border(p, &mut st);
2525 if let Some(v) = p.get("gap") {
2526 if let Some(px) = parse_px(first(v)) {
2527 st.gap = px;
2528 }
2529 }
2530 if let Some(v) = p.get("min-width") {
2531 st.min_width = parse_len(first(v));
2532 }
2533 if let Some(v) = p.get("max-width") {
2534 st.max_width = parse_len(first(v));
2535 }
2536 if let Some(v) = p.get("min-height") {
2537 st.min_height = parse_len(first(v));
2538 }
2539 if let Some(v) = p.get("max-height") {
2540 st.max_height = parse_len(first(v));
2541 }
2542 if let Some(v) = p.get("grid-template-columns") {
2543 st.grid_columns = parse_tracks(v);
2544 }
2545 if let Some(v) = p.get("grid-template-rows") {
2546 st.grid_rows = parse_tracks(v);
2547 }
2548 if let Some(v) = p.get("grid-column") {
2551 st.grid_column = parse_grid_shorthand(v);
2552 }
2553 if let Some(v) = p.get("grid-row") {
2554 st.grid_row = parse_grid_shorthand(v);
2555 }
2556 if let Some(v) = p.get("grid-column-start") {
2557 st.grid_column.0 = parse_grid_place(v);
2558 }
2559 if let Some(v) = p.get("grid-column-end") {
2560 st.grid_column.1 = parse_grid_place(v);
2561 }
2562 if let Some(v) = p.get("grid-row-start") {
2563 st.grid_row.0 = parse_grid_place(v);
2564 }
2565 if let Some(v) = p.get("grid-row-end") {
2566 st.grid_row.1 = parse_grid_place(v);
2567 }
2568 if let Some(v) = p.get("grid-auto-flow") {
2569 let v = v.trim();
2570 let dense = v.contains("dense");
2571 st.grid_auto_flow = if v.contains("column") {
2572 if dense { GridFlow::ColumnDense } else { GridFlow::Column }
2573 } else if dense {
2574 GridFlow::RowDense
2575 } else {
2576 GridFlow::Row
2577 };
2578 }
2579 if let Some(v) = p.get("grid-auto-rows") {
2580 st.grid_auto_rows = parse_tracks(v);
2581 }
2582 if let Some(v) = p.get("grid-auto-columns") {
2583 st.grid_auto_columns = parse_tracks(v);
2584 }
2585 if let Some(v) = p.get("flex") {
2587 interpret_flex_shorthand(v.trim(), &mut st);
2588 }
2589 if let Some(v) = p.get("flex-grow") {
2590 if let Ok(g) = first(v).parse::<f32>() {
2591 st.grow = g;
2592 }
2593 }
2594 if let Some(v) = p.get("flex-shrink") {
2595 if let Ok(s) = first(v).parse::<f32>() {
2596 st.shrink = s.max(0.0);
2597 }
2598 }
2599 if let Some(v) = p.get("flex-basis") {
2600 st.basis = match first(v) {
2601 "auto" | "content" => None,
2602 l => parse_len(l),
2603 };
2604 }
2605 if let Some(v) = p.get("flex-wrap") {
2606 st.wrap = matches!(v.trim(), "wrap" | "wrap-reverse");
2607 }
2608 if let Some(v) = p.get("overflow-wrap").or_else(|| p.get("word-wrap")) {
2609 st.text_wrap = match v.trim() {
2610 "break-word" | "anywhere" => TextWrap::BreakWord,
2611 _ => TextWrap::Normal,
2612 };
2613 }
2614 if let Some(v) = p.get("word-break") {
2617 if v.trim() == "break-all" {
2618 st.text_wrap = TextWrap::Anywhere;
2619 }
2620 }
2621 if let Some(v) = p.get("opacity") {
2622 if let Ok(o) = first(v).parse::<f32>() {
2623 st.opacity = o.clamp(0.0, 1.0);
2624 }
2625 }
2626 if let Some(v) = p.get("flex-direction") {
2627 st.axis = if v.trim() == "column" { Axis::Column } else { Axis::Row };
2628 }
2629 if let Some(v) = p.get("justify-content") {
2630 st.justify = parse_justify(v);
2631 }
2632 if let Some(v) = p.get("align-items") {
2633 st.align = parse_align(v);
2634 }
2635 if let Some(v) = p.get("align-self") {
2637 st.align_self = parse_align(v);
2638 }
2639 if let Some(v) = p.get("justify-self") {
2640 st.justify_self = parse_align(v);
2641 }
2642 if let Some(v) = p.get("justify-items") {
2643 st.justify_items = parse_align(v);
2644 }
2645 if let Some(v) = p.get("align-content") {
2646 st.align_content = parse_justify(v);
2647 }
2648 if let Some(px) = p.get("row-gap").and_then(|v| parse_px(first(v))) {
2650 st.row_gap = Some(px);
2651 }
2652 if let Some(px) = p.get("column-gap").and_then(|v| parse_px(first(v))) {
2653 st.column_gap = Some(px);
2654 }
2655 if let Some(v) = p.get("position") {
2656 st.position = match v.trim() {
2657 "absolute" | "fixed" => Position::Absolute,
2658 _ => Position::Relative,
2659 };
2660 }
2661 for (i, side) in ["top", "right", "bottom", "left"].iter().enumerate() {
2662 if let Some(v) = p.get(*side) {
2663 st.inset[i] = if first(v) == "auto" { None } else { parse_len(first(v)) };
2664 }
2665 }
2666 if let Some(v) = p.get("aspect-ratio") {
2667 st.aspect_ratio = parse_aspect_ratio(v);
2668 }
2669 if let Some(v) = p
2672 .get("background")
2673 .or_else(|| p.get("background-image"))
2674 .or_else(|| p.get("background-color"))
2675 {
2676 st.background = parse_background(v);
2677 }
2678 if let Some(v) = p.get("transform") {
2679 st.transform = parse_transform(v);
2680 }
2681 if let Some(v) = p.get("box-shadow") {
2682 st.box_shadow = parse_box_shadow(v);
2683 }
2684 if let Some(v) = p.get("border-radius") {
2687 st.radius = parse_border_radius(v);
2688 }
2689 for (i, corner) in [
2690 "border-top-left-radius",
2691 "border-top-right-radius",
2692 "border-bottom-right-radius",
2693 "border-bottom-left-radius",
2694 ]
2695 .iter()
2696 .enumerate()
2697 {
2698 if let Some(px) = p.get(*corner).and_then(|v| parse_px(first(v))) {
2699 st.radius[i] = px;
2700 }
2701 }
2702 let values = ["overflow", "overflow-x", "overflow-y"]
2705 .iter()
2706 .filter_map(|k| p.get(*k))
2707 .map(|v| v.trim());
2708 for v in values {
2709 match v {
2710 "auto" | "scroll" => st.overflow = Overflow::Scroll,
2711 "hidden" | "clip" if st.overflow != Overflow::Scroll => st.overflow = Overflow::Clip,
2712 _ => {}
2713 }
2714 }
2715 if let Some(v) = p.get("cursor") {
2716 st.cursor = match v.trim() {
2719 "pointer" => Cursor::Pointer,
2720 _ => Cursor::Default,
2721 };
2722 }
2723 st
2724}
2725
2726fn interpret_flex_shorthand(v: &str, st: &mut Style) {
2730 match v {
2731 "none" => {
2732 st.grow = 0.0;
2733 st.shrink = 0.0;
2734 st.basis = None;
2735 return;
2736 }
2737 "auto" => {
2738 st.grow = 1.0;
2739 st.shrink = 1.0;
2740 st.basis = None;
2741 return;
2742 }
2743 "initial" => {
2744 st.grow = 0.0;
2745 st.shrink = 1.0;
2746 st.basis = None;
2747 return;
2748 }
2749 _ => {}
2750 }
2751
2752 let parts: Vec<&str> = v.split_whitespace().collect();
2753 let Some(grow) = parts.first().and_then(|g| g.parse::<f32>().ok()) else {
2754 return;
2755 };
2756 st.grow = grow;
2757 st.shrink = parts
2758 .get(1)
2759 .and_then(|s| s.parse::<f32>().ok())
2760 .unwrap_or(1.0)
2761 .max(0.0);
2762 st.basis = match parts.get(2) {
2763 Some(&"auto") | Some(&"content") => None,
2764 Some(b) => parse_len(b),
2765 None => Some(Len::Px(0.0)),
2767 };
2768}
2769
2770fn parse_align(v: &str) -> Option<Align> {
2772 match v.trim() {
2773 "center" => Some(Align::Center),
2774 "flex-end" | "end" => Some(Align::End),
2775 "stretch" => Some(Align::Stretch),
2776 "flex-start" | "start" => Some(Align::Start),
2777 _ => None,
2778 }
2779}
2780
2781fn parse_justify(v: &str) -> Option<Justify> {
2783 match v.trim() {
2784 "center" => Some(Justify::Center),
2785 "flex-end" | "end" => Some(Justify::End),
2786 "space-between" => Some(Justify::SpaceBetween),
2787 "space-around" => Some(Justify::SpaceAround),
2788 "flex-start" | "start" => Some(Justify::Start),
2789 _ => None,
2790 }
2791}
2792
2793fn parse_background(value: &str) -> Option<Background> {
2796 let v = value.trim();
2797 if let Some(inner) = gradient_args(v, "linear-gradient") {
2798 return parse_linear_gradient(inner).map(Background::Gradient);
2799 }
2800 if let Some(inner) = gradient_args(v, "radial-gradient") {
2801 return parse_radial_gradient(inner).map(Background::Gradient);
2802 }
2803 if let Some(inner) = gradient_args(v, "url") {
2804 let src = inner.trim().trim_matches(|c| c == '"' || c == '\'');
2806 if !src.is_empty() {
2807 return Some(Background::Image(src.to_string()));
2808 }
2809 }
2810 parse_color(v).map(Background::Color)
2811}
2812
2813fn gradient_args<'a>(v: &'a str, name: &str) -> Option<&'a str> {
2815 v.strip_prefix(name)?.trim_start().strip_prefix('(')?.strip_suffix(')')
2816}
2817
2818fn parse_linear_gradient(inner: &str) -> Option<Gradient> {
2821 let mut parts = split_top_level_commas(inner);
2822 if parts.is_empty() {
2823 return None;
2824 }
2825 let angle = parse_gradient_angle(parts[0].trim());
2827 if angle.is_some() {
2828 parts.remove(0);
2829 }
2830 let stops = parse_stops(&parts)?;
2831 Some(Gradient {
2832 kind: GradientKind::Linear {
2833 angle: angle.unwrap_or(std::f32::consts::PI), },
2835 stops,
2836 })
2837}
2838
2839fn parse_radial_gradient(inner: &str) -> Option<Gradient> {
2843 let mut parts = split_top_level_commas(inner);
2844 if parts.is_empty() {
2845 return None;
2846 }
2847 if parse_color(first(parts[0].trim())).is_none() && !parts[0].trim().is_empty() {
2849 parts.remove(0);
2850 }
2851 let stops = parse_stops(&parts)?;
2852 Some(Gradient { kind: GradientKind::Radial, stops })
2853}
2854
2855fn parse_gradient_angle(tok: &str) -> Option<f32> {
2858 if let Some(deg) = tok.strip_suffix("deg") {
2859 return deg.trim().parse::<f32>().ok().map(f32::to_radians);
2860 }
2861 if tok == "turn" {
2862 return None;
2863 }
2864 if let Some(rest) = tok.strip_suffix("turn") {
2865 return rest.trim().parse::<f32>().ok().map(|t| t * std::f32::consts::TAU);
2866 }
2867 let side = tok.strip_prefix("to ")?.trim();
2868 let deg = match side {
2870 "top" => 0.0,
2871 "right" => 90.0,
2872 "bottom" => 180.0,
2873 "left" => 270.0,
2874 "top right" | "right top" => 45.0,
2875 "bottom right" | "right bottom" => 135.0,
2876 "bottom left" | "left bottom" => 225.0,
2877 "top left" | "left top" => 315.0,
2878 _ => return None,
2879 };
2880 Some(f32::to_radians(deg))
2881}
2882
2883fn parse_stops(parts: &[&str]) -> Option<Vec<(Rgba, f32)>> {
2887 let mut colors = Vec::new();
2888 let mut positions: Vec<Option<f32>> = Vec::new();
2889 for part in parts {
2890 let part = part.trim();
2891 let mut toks = part.split_whitespace();
2892 let color = parse_color(toks.next()?)?;
2893 let pos = toks
2894 .next()
2895 .and_then(|p| p.strip_suffix('%'))
2896 .and_then(|p| p.trim().parse::<f32>().ok())
2897 .map(|p| (p / 100.0).clamp(0.0, 1.0));
2898 colors.push(color);
2899 positions.push(pos);
2900 }
2901 if colors.len() < 2 {
2902 return None;
2903 }
2904 let n = positions.len();
2906 positions[0].get_or_insert(0.0);
2907 positions[n - 1].get_or_insert(1.0);
2908 let mut i = 0;
2909 while i < n {
2910 if positions[i].is_some() {
2911 i += 1;
2912 continue;
2913 }
2914 let start = i - 1;
2915 let mut j = i;
2916 while j < n && positions[j].is_none() {
2917 j += 1;
2918 }
2919 let p0 = positions[start].unwrap();
2920 let p1 = positions[j].unwrap();
2921 let gap = j - start;
2922 for (k, slot) in (start + 1..j).enumerate() {
2923 positions[slot] = Some(p0 + (p1 - p0) * (k as f32 + 1.0) / gap as f32);
2924 }
2925 i = j;
2926 }
2927 Some(colors.into_iter().zip(positions.into_iter().map(Option::unwrap)).collect())
2928}
2929
2930fn split_top_level_commas(value: &str) -> Vec<&str> {
2932 let mut out = Vec::new();
2933 let mut depth = 0i32;
2934 let mut start = 0;
2935 for (i, c) in value.char_indices() {
2936 match c {
2937 '(' => depth += 1,
2938 ')' => depth -= 1,
2939 ',' if depth == 0 => {
2940 out.push(value[start..i].trim());
2941 start = i + 1;
2942 }
2943 _ => {}
2944 }
2945 }
2946 let last = value[start..].trim();
2947 if !last.is_empty() {
2948 out.push(last);
2949 }
2950 out
2951}
2952
2953fn parse_transform(value: &str) -> Option<Transform> {
2958 let mut m = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]; let mut any = false;
2960 let mut rest = value.trim();
2961 while let Some(open) = rest.find('(') {
2962 let name = rest[..open].trim().to_ascii_lowercase();
2963 let close = rest[open..].find(')')? + open;
2964 let args = &rest[open + 1..close];
2965 if let Some(f) = transform_fn(&name, args) {
2966 m = mat_mul(m, f);
2967 any = true;
2968 }
2969 rest = rest[close + 1..].trim_start();
2970 }
2971 any.then_some(m)
2972}
2973
2974fn transform_fn(name: &str, args: &str) -> Option<Transform> {
2976 let nums: Vec<&str> = args.split(',').map(str::trim).filter(|s| !s.is_empty()).collect();
2977 let num = |i: usize| nums.get(i).and_then(|s| s.parse::<f32>().ok());
2978 match name {
2979 "translate" => {
2980 let tx = parse_px(nums.first()?)?;
2981 let ty = nums.get(1).and_then(|s| parse_px(s)).unwrap_or(0.0);
2982 Some([1.0, 0.0, 0.0, 1.0, tx, ty])
2983 }
2984 "translatex" => Some([1.0, 0.0, 0.0, 1.0, parse_px(nums.first()?)?, 0.0]),
2985 "translatey" => Some([1.0, 0.0, 0.0, 1.0, 0.0, parse_px(nums.first()?)?]),
2986 "scale" => {
2987 let sx = num(0)?;
2988 let sy = num(1).unwrap_or(sx);
2989 Some([sx, 0.0, 0.0, sy, 0.0, 0.0])
2990 }
2991 "scalex" => Some([num(0)?, 0.0, 0.0, 1.0, 0.0, 0.0]),
2992 "scaley" => Some([1.0, 0.0, 0.0, num(0)?, 0.0, 0.0]),
2993 "rotate" => {
2994 let (sin, cos) = parse_angle(nums.first()?)?.sin_cos();
2995 Some([cos, sin, -sin, cos, 0.0, 0.0])
2996 }
2997 _ => None,
2998 }
2999}
3000
3001fn mat_mul(a: Transform, b: Transform) -> Transform {
3003 let [a1, b1, c1, d1, e1, f1] = a;
3004 let [a2, b2, c2, d2, e2, f2] = b;
3005 [
3006 a1 * a2 + c1 * b2,
3007 b1 * a2 + d1 * b2,
3008 a1 * c2 + c1 * d2,
3009 b1 * c2 + d1 * d2,
3010 a1 * e2 + c1 * f2 + e1,
3011 b1 * e2 + d1 * f2 + f1,
3012 ]
3013}
3014
3015fn parse_angle(s: &str) -> Option<f32> {
3017 let s = s.trim();
3018 if let Some(v) = s.strip_suffix("deg") {
3019 return v.trim().parse::<f32>().ok().map(f32::to_radians);
3020 }
3021 if let Some(v) = s.strip_suffix("grad") {
3022 return v.trim().parse::<f32>().ok().map(|g| g * std::f32::consts::PI / 200.0);
3023 }
3024 if let Some(v) = s.strip_suffix("turn") {
3025 return v.trim().parse::<f32>().ok().map(|t| t * std::f32::consts::TAU);
3026 }
3027 if let Some(v) = s.strip_suffix("rad") {
3028 return v.trim().parse::<f32>().ok();
3029 }
3030 s.parse::<f32>().ok().map(f32::to_radians)
3031}
3032
3033fn parse_box_shadow(value: &str) -> Option<BoxShadow> {
3037 let first = value.split(',').next().unwrap_or(value).trim();
3038 if first.is_empty() || first == "none" {
3039 return None;
3040 }
3041 let mut lengths = Vec::new();
3042 let mut color_parts = Vec::new();
3043 let mut inset = false;
3044 for tok in first.split_whitespace() {
3045 if tok == "inset" {
3046 inset = true;
3047 } else if let Some(px) = parse_px(tok) {
3048 lengths.push(px);
3049 } else {
3050 color_parts.push(tok);
3051 }
3052 }
3053 if lengths.len() < 2 {
3055 return None;
3056 }
3057 let color = parse_color(&color_parts.join(" ")).unwrap_or(Rgba::new(0.0, 0.0, 0.0, 1.0));
3058 Some(BoxShadow {
3059 dx: lengths[0],
3060 dy: lengths[1],
3061 blur: lengths.get(2).copied().unwrap_or(0.0),
3062 spread: lengths.get(3).copied().unwrap_or(0.0),
3063 color,
3064 inset,
3065 })
3066}
3067
3068fn parse_line_height(v: &str, font_size: f32) -> Option<f32> {
3071 let s = first(v);
3072 if s == "normal" {
3073 return None;
3074 }
3075 if s.ends_with("px") || s.ends_with("rem") || s.ends_with("em") {
3076 if let Some(em) = s.strip_suffix("em").filter(|e| !e.ends_with('r')) {
3078 return em.parse::<f32>().ok().map(|n| n * font_size);
3079 }
3080 return parse_len(s).and_then(|l| match l {
3081 Len::Px(px) => Some(px),
3082 _ => None,
3083 });
3084 }
3085 s.parse::<f32>().ok().map(|n| n * font_size)
3087}
3088
3089fn parse_spacing(v: &str) -> Option<f32> {
3091 match first(v) {
3092 "normal" => None,
3093 s => parse_px(s),
3094 }
3095}
3096
3097fn parse_aspect_ratio(v: &str) -> Option<f32> {
3099 if let Some((w, h)) = v.split_once('/') {
3100 let (w, h) = (w.trim().parse::<f32>().ok()?, h.trim().parse::<f32>().ok()?);
3101 return (h != 0.0).then_some(w / h);
3102 }
3103 v.trim().parse::<f32>().ok().filter(|r| *r > 0.0)
3104}
3105
3106fn first(s: &str) -> &str {
3107 s.split_whitespace().next().unwrap_or(s)
3108}
3109
3110fn parse_px(s: &str) -> Option<f32> {
3111 let s = s.trim();
3112 let s = s.strip_suffix("px").unwrap_or(s);
3113 s.parse::<f32>().ok()
3114}
3115
3116const REM_PX: f32 = 16.0;
3118
3119fn parse_len(s: &str) -> Option<Len> {
3122 let s = s.trim();
3123 if let Some(pct) = s.strip_suffix('%') {
3124 return pct.trim().parse::<f32>().ok().map(|v| Len::Pct(v / 100.0));
3125 }
3126 if let Some(n) = s.strip_suffix("dvh").or_else(|| s.strip_suffix("vh")) {
3127 return n.trim().parse::<f32>().ok().map(Len::Vh);
3128 }
3129 if let Some(n) = s.strip_suffix("vw") {
3130 return n.trim().parse::<f32>().ok().map(Len::Vw);
3131 }
3132 if let Some(n) = s.strip_suffix("rem") {
3133 return n.trim().parse::<f32>().ok().map(|v| Len::Px(v * REM_PX));
3134 }
3135 let n = s.strip_suffix("px").unwrap_or(s);
3136 n.parse::<f32>().ok().map(Len::Px)
3137}
3138
3139fn parse_grid_shorthand(value: &str) -> (GridPlace, GridPlace) {
3145 let mut parts = value.splitn(2, '/');
3146 let start = parts.next().map(parse_grid_place).unwrap_or_default();
3147 let end = parts.next().map(parse_grid_place).unwrap_or_default();
3148 (start, end)
3149}
3150
3151fn parse_grid_place(side: &str) -> GridPlace {
3154 let s = side.trim();
3155 if let Some(rest) = s.strip_prefix("span") {
3156 return rest.trim().parse::<u16>().ok().map_or(GridPlace::Auto, GridPlace::Span);
3157 }
3158 match s.parse::<i16>() {
3159 Ok(i) if i != 0 => GridPlace::Line(i),
3160 _ => GridPlace::Auto,
3161 }
3162}
3163
3164fn parse_tracks(value: &str) -> Vec<Track> {
3165 split_top_level(value)
3166 .into_iter()
3167 .map(|tok| {
3168 if let Some(args) = tok
3169 .strip_prefix("minmax(")
3170 .and_then(|s| s.strip_suffix(')'))
3171 {
3172 let mut parts = args.split(',');
3173 let lo = parts.next().map(parse_track_side).unwrap_or(TrackSide::Auto);
3174 let hi = parts.next().map(parse_track_side).unwrap_or(TrackSide::Auto);
3175 Track::MinMax(lo, hi)
3176 } else {
3177 match parse_track_side(tok) {
3178 TrackSide::Px(v) => Track::Px(v),
3179 TrackSide::Fr(f) => Track::Fr(f),
3180 TrackSide::Auto => Track::Auto,
3181 }
3182 }
3183 })
3184 .collect()
3185}
3186
3187fn parse_track_side(tok: &str) -> TrackSide {
3189 let tok = tok.trim();
3190 if let Some(fr) = tok.strip_suffix("fr") {
3191 TrackSide::Fr(fr.trim().parse().unwrap_or(1.0))
3192 } else if tok == "auto" {
3193 TrackSide::Auto
3194 } else {
3195 parse_px(tok).map(TrackSide::Px).unwrap_or(TrackSide::Auto)
3196 }
3197}
3198
3199fn split_top_level(value: &str) -> Vec<&str> {
3202 let mut out = Vec::new();
3203 let mut depth = 0i32;
3204 let mut start: Option<usize> = None;
3205 for (i, c) in value.char_indices() {
3206 if c == '(' {
3207 depth += 1;
3208 } else if c == ')' {
3209 depth -= 1;
3210 }
3211 if c.is_whitespace() && depth == 0 {
3212 if let Some(s) = start.take() {
3213 out.push(value[s..i].trim());
3214 }
3215 } else if start.is_none() {
3216 start = Some(i);
3217 }
3218 }
3219 if let Some(s) = start {
3220 out.push(value[s..].trim());
3221 }
3222 out.into_iter().filter(|t| !t.is_empty()).collect()
3223}
3224
3225fn parse_shorthand_sides(value: &str) -> Sides {
3228 let v: Vec<f32> = value
3229 .split_whitespace()
3230 .filter_map(parse_px)
3231 .collect();
3232 match v.len() {
3233 1 => Sides::uniform(v[0]),
3234 2 => Sides {
3235 top: v[0],
3236 right: v[1],
3237 bottom: v[0],
3238 left: v[1],
3239 },
3240 3 => Sides {
3241 top: v[0],
3242 right: v[1],
3243 bottom: v[2],
3244 left: v[1],
3245 },
3246 n if n >= 4 => Sides {
3247 top: v[0],
3248 right: v[1],
3249 bottom: v[2],
3250 left: v[3],
3251 },
3252 _ => Sides::default(),
3253 }
3254}
3255
3256fn parse_border_radius(value: &str) -> [f32; 4] {
3261 let horizontal = value.split('/').next().unwrap_or(value);
3262 let v: Vec<f32> = horizontal.split_whitespace().filter_map(parse_px).collect();
3263 match v.len() {
3264 1 => [v[0]; 4],
3265 2 => [v[0], v[1], v[0], v[1]],
3266 3 => [v[0], v[1], v[2], v[1]],
3267 n if n >= 4 => [v[0], v[1], v[2], v[3]],
3268 _ => [0.0; 4],
3269 }
3270}
3271
3272fn box_sides(p: &HashMap<String, String>, prop: &str) -> Sides {
3275 let mut sides = p
3276 .get(prop)
3277 .map(|v| parse_shorthand_sides(v))
3278 .unwrap_or_default();
3279 for side in ["top", "right", "bottom", "left"] {
3280 if let Some(v) = p.get(&format!("{prop}-{side}")) {
3281 if let Some(px) = parse_px(first(v)) {
3282 set_side(&mut sides, side, px);
3283 }
3284 }
3285 }
3286 sides
3287}
3288
3289fn interpret_border(p: &HashMap<String, String>, st: &mut Style) {
3292 if let Some(v) = p.get("border") {
3294 let (w, c) = parse_border(v);
3295 st.border = Sides::uniform(w);
3296 if c.is_some() {
3297 st.border_color = c;
3298 }
3299 }
3300 if let Some(v) = p.get("border-width") {
3301 st.border = parse_shorthand_sides(v);
3302 }
3303 if let Some(v) = p.get("border-color") {
3304 st.border_color = parse_color(v);
3305 }
3306 for side in ["top", "right", "bottom", "left"] {
3307 if let Some(v) = p.get(&format!("border-{side}")) {
3308 let (w, c) = parse_border(v);
3309 set_side(&mut st.border, side, w);
3310 if c.is_some() {
3311 st.border_color = c;
3312 }
3313 }
3314 if let Some(v) = p.get(&format!("border-{side}-width")) {
3315 if let Some(px) = parse_px(first(v)) {
3316 set_side(&mut st.border, side, px);
3317 }
3318 }
3319 }
3320}
3321
3322fn set_side(sides: &mut Sides, side: &str, value: f32) {
3323 match side {
3324 "top" => sides.top = value,
3325 "right" => sides.right = value,
3326 "bottom" => sides.bottom = value,
3327 "left" => sides.left = value,
3328 _ => {}
3329 }
3330}
3331
3332fn parse_border(value: &str) -> (f32, Option<Rgba>) {
3334 let mut width = 0.0;
3335 let mut color = None;
3336 for token in value.split_whitespace() {
3337 if let Some(px) = parse_px(token) {
3338 width = px;
3339 } else if let Some(c) = parse_color(token) {
3340 color = Some(c);
3341 }
3342 }
3343 (width, color)
3344}
3345
3346fn parse_weight(s: &str) -> Option<u16> {
3348 match s.trim() {
3349 "normal" => Some(400),
3350 "bold" => Some(700),
3351 "lighter" => Some(300),
3352 "bolder" => Some(800),
3353 other => other.parse::<u16>().ok(),
3354 }
3355}
3356
3357fn parse_text_align(s: &str) -> TextAlign {
3359 match s.trim() {
3360 "center" => TextAlign::Center,
3361 "right" | "end" => TextAlign::End,
3362 "justify" => TextAlign::Justify,
3363 _ => TextAlign::Start,
3364 }
3365}
3366
3367fn parse_color(s: &str) -> Option<Rgba> {
3368 let s = s.trim();
3369 if let Some(hex) = s.strip_prefix('#') {
3370 return parse_hex(hex);
3371 }
3372 if s.starts_with("rgb") {
3373 return parse_rgb(s);
3374 }
3375 if s.eq_ignore_ascii_case("transparent") {
3376 return Some(Rgba::new(0.0, 0.0, 0.0, 0.0));
3377 }
3378 named_color(&s.to_ascii_lowercase()).and_then(parse_hex)
3382}
3383
3384fn named_color(name: &str) -> Option<&'static str> {
3387 let hex = match name {
3388 "aliceblue" => "f0f8ff", "antiquewhite" => "faebd7", "aqua" => "00ffff",
3389 "aquamarine" => "7fffd4", "azure" => "f0ffff", "beige" => "f5f5dc",
3390 "bisque" => "ffe4c4", "black" => "000000", "blanchedalmond" => "ffebcd",
3391 "blue" => "0000ff", "blueviolet" => "8a2be2", "brown" => "a52a2a",
3392 "burlywood" => "deb887", "cadetblue" => "5f9ea0", "chartreuse" => "7fff00",
3393 "chocolate" => "d2691e", "coral" => "ff7f50", "cornflowerblue" => "6495ed",
3394 "cornsilk" => "fff8dc", "crimson" => "dc143c", "cyan" => "00ffff",
3395 "darkblue" => "00008b", "darkcyan" => "008b8b", "darkgoldenrod" => "b8860b",
3396 "darkgray" | "darkgrey" => "a9a9a9", "darkgreen" => "006400",
3397 "darkkhaki" => "bdb76b", "darkmagenta" => "8b008b", "darkolivegreen" => "556b2f",
3398 "darkorange" => "ff8c00", "darkorchid" => "9932cc", "darkred" => "8b0000",
3399 "darksalmon" => "e9967a", "darkseagreen" => "8fbc8f", "darkslateblue" => "483d8b",
3400 "darkslategray" | "darkslategrey" => "2f4f4f", "darkturquoise" => "00ced1",
3401 "darkviolet" => "9400d3", "deeppink" => "ff1493", "deepskyblue" => "00bfff",
3402 "dimgray" | "dimgrey" => "696969", "dodgerblue" => "1e90ff",
3403 "firebrick" => "b22222", "floralwhite" => "fffaf0", "forestgreen" => "228b22",
3404 "fuchsia" => "ff00ff", "gainsboro" => "dcdcdc", "ghostwhite" => "f8f8ff",
3405 "gold" => "ffd700", "goldenrod" => "daa520", "gray" | "grey" => "808080",
3406 "green" => "008000", "greenyellow" => "adff2f", "honeydew" => "f0fff0",
3407 "hotpink" => "ff69b4", "indianred" => "cd5c5c", "indigo" => "4b0082",
3408 "ivory" => "fffff0", "khaki" => "f0e68c", "lavender" => "e6e6fa",
3409 "lavenderblush" => "fff0f5", "lawngreen" => "7cfc00", "lemonchiffon" => "fffacd",
3410 "lightblue" => "add8e6", "lightcoral" => "f08080", "lightcyan" => "e0ffff",
3411 "lightgoldenrodyellow" => "fafad2", "lightgray" | "lightgrey" => "d3d3d3",
3412 "lightgreen" => "90ee90", "lightpink" => "ffb6c1", "lightsalmon" => "ffa07a",
3413 "lightseagreen" => "20b2aa", "lightskyblue" => "87cefa", "lightslategray" | "lightslategrey" => "778899",
3414 "lightsteelblue" => "b0c4de", "lightyellow" => "ffffe0", "lime" => "00ff00",
3415 "limegreen" => "32cd32", "linen" => "faf0e6", "magenta" => "ff00ff",
3416 "maroon" => "800000", "mediumaquamarine" => "66cdaa", "mediumblue" => "0000cd",
3417 "mediumorchid" => "ba55d3", "mediumpurple" => "9370db", "mediumseagreen" => "3cb371",
3418 "mediumslateblue" => "7b68ee", "mediumspringgreen" => "00fa9a", "mediumturquoise" => "48d1cc",
3419 "mediumvioletred" => "c71585", "midnightblue" => "191970", "mintcream" => "f5fffa",
3420 "mistyrose" => "ffe4e1", "moccasin" => "ffe4b5", "navajowhite" => "ffdead",
3421 "navy" => "000080", "oldlace" => "fdf5e6", "olive" => "808000",
3422 "olivedrab" => "6b8e23", "orange" => "ffa500", "orangered" => "ff4500",
3423 "orchid" => "da70d6", "palegoldenrod" => "eee8aa", "palegreen" => "98fb98",
3424 "paleturquoise" => "afeeee", "palevioletred" => "db7093", "papayawhip" => "ffefd5",
3425 "peachpuff" => "ffdab9", "peru" => "cd853f", "pink" => "ffc0cb",
3426 "plum" => "dda0dd", "powderblue" => "b0e0e6", "purple" => "800080",
3427 "rebeccapurple" => "663399", "red" => "ff0000", "rosybrown" => "bc8f8f",
3428 "royalblue" => "4169e1", "saddlebrown" => "8b4513", "salmon" => "fa8072",
3429 "sandybrown" => "f4a460", "seagreen" => "2e8b57", "seashell" => "fff5ee",
3430 "sienna" => "a0522d", "silver" => "c0c0c0", "skyblue" => "87ceeb",
3431 "slateblue" => "6a5acd", "slategray" | "slategrey" => "708090", "snow" => "fffafa",
3432 "springgreen" => "00ff7f", "steelblue" => "4682b4", "tan" => "d2b48c",
3433 "teal" => "008080", "thistle" => "d8bfd8", "tomato" => "ff6347",
3434 "turquoise" => "40e0d0", "violet" => "ee82ee", "wheat" => "f5deb3",
3435 "white" => "ffffff", "whitesmoke" => "f5f5f5", "yellow" => "ffff00",
3436 "yellowgreen" => "9acd32",
3437 _ => return None,
3438 };
3439 Some(hex)
3440}
3441
3442fn parse_hex(hex: &str) -> Option<Rgba> {
3443 let expand = |c: char| -> u8 { u8::from_str_radix(&format!("{c}{c}"), 16).unwrap_or(0) };
3444 let bytes: Vec<char> = hex.chars().collect();
3445 let (r, g, b, a) = match bytes.len() {
3446 3 => (expand(bytes[0]), expand(bytes[1]), expand(bytes[2]), 255),
3447 6 => (
3448 u8::from_str_radix(&hex[0..2], 16).ok()?,
3449 u8::from_str_radix(&hex[2..4], 16).ok()?,
3450 u8::from_str_radix(&hex[4..6], 16).ok()?,
3451 255,
3452 ),
3453 8 => (
3454 u8::from_str_radix(&hex[0..2], 16).ok()?,
3455 u8::from_str_radix(&hex[2..4], 16).ok()?,
3456 u8::from_str_radix(&hex[4..6], 16).ok()?,
3457 u8::from_str_radix(&hex[6..8], 16).ok()?,
3458 ),
3459 _ => return None,
3460 };
3461 Some(Rgba::new(
3462 r as f32 / 255.0,
3463 g as f32 / 255.0,
3464 b as f32 / 255.0,
3465 a as f32 / 255.0,
3466 ))
3467}
3468
3469#[cfg(test)]
3470mod tests {
3471 use super::{build_styled_tree, build_styled_tree_tracked, interpolate_tracked, interpret, Len, Locals};
3472 use rux_script::{Builder, Engine};
3473 use std::collections::HashMap;
3474
3475 #[test]
3480 fn css_warnings_carry_the_line_of_the_file() {
3481 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";
3482 let sfc = rux_parser::parse_sfc(src).expect("parses");
3483 let mut engine = Builder::new().build("").expect("engine");
3484
3485 let _ = super::take_warnings(); let _ = build_styled_tree(&sfc, &HashMap::new(), &mut engine).expect("builds");
3487 let warnings = super::take_warnings();
3488
3489 let line_for = |needle: &str| {
3490 warnings
3491 .iter()
3492 .find(|w| w.message.contains(needle))
3493 .unwrap_or_else(|| panic!("no warning mentioning {needle}: {warnings:?}"))
3494 .line
3495 };
3496 assert_eq!(line_for("float"), Some(7));
3497 assert_eq!(line_for(":nope"), Some(9));
3498 assert_eq!(line_for("@media"), Some(11));
3499
3500 let line_of = |n: usize| src.lines().nth(n - 1).unwrap();
3502 assert!(line_of(7).contains("float"));
3503 assert!(line_of(9).contains(":nope"));
3504 assert!(line_of(11).contains("@media"));
3505 }
3506
3507 #[test]
3512 fn a_warning_in_an_expanded_rule_names_the_declaration_not_the_selector() {
3513 let src = concat!(
3514 "<template>\n",
3515 " <screen class=\"a\"></screen>\n",
3516 "</template>\n",
3517 "\n",
3518 "<style>\n",
3519 " .a {\n",
3520 " display: flex;\n",
3521 " padding: 8px;\n",
3522 " float: left;\n",
3523 " }\n",
3524 "\n",
3525 " .b {\n",
3526 " color: red;\n",
3527 " zoom: 2;\n",
3528 " }\n",
3529 "</style>\n",
3530 );
3531 let sfc = rux_parser::parse_sfc(src).expect("parses");
3532 let mut engine = Builder::new().build("").expect("engine");
3533
3534 let _ = super::take_warnings();
3535 let _ = build_styled_tree(&sfc, &HashMap::new(), &mut engine).expect("builds");
3536 let warnings = super::take_warnings();
3537
3538 let line_for = |needle: &str| {
3539 warnings
3540 .iter()
3541 .find(|w| w.message.contains(needle))
3542 .unwrap_or_else(|| panic!("no warning mentioning {needle}: {warnings:?}"))
3543 .line
3544 };
3545 assert_eq!(line_for("float"), Some(9));
3548 assert_eq!(line_for("zoom"), Some(14));
3550
3551 let line_of = |n: usize| src.lines().nth(n - 1).unwrap();
3552 assert!(line_of(9).contains("float"));
3553 assert!(line_of(14).contains("zoom"));
3554 }
3555
3556 #[test]
3560 fn a_components_css_warning_is_left_unplaced() {
3561 let main = rux_parser::parse_sfc(
3562 "<template>\n <screen><my-row /></screen>\n</template>\n<script>\nuse components::row;\n</script>\n",
3563 )
3564 .expect("parses");
3565 let component = rux_parser::parse_sfc(
3566 "<template>\n <view class=\"r\"></view>\n</template>\n<style>\n .r { float: left; }\n</style>\n",
3567 )
3568 .expect("parses");
3569 let mut components = HashMap::new();
3570 components.insert("my-row".to_string(), component);
3571 let mut engine = Builder::new().build("").expect("engine");
3572
3573 let _ = super::take_warnings();
3574 let _ = build_styled_tree(&main, &components, &mut engine).expect("builds");
3575 let warnings = super::take_warnings();
3576
3577 let float = warnings
3578 .iter()
3579 .find(|w| w.message.contains("float"))
3580 .expect("the component's unhonored property is still reported");
3581 assert_eq!(float.line, None, "but without a line from another file");
3582 }
3583
3584 #[test]
3585 fn box_model_shorthand_sides_and_border() {
3586 let mut p = HashMap::new();
3587 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());
3590 p.insert("border".to_string(), "2px solid #ff0000".to_string());
3591 p.insert("border-bottom-width".to_string(), "5px".to_string());
3592
3593 let st = interpret(&p);
3594 assert_eq!((st.padding.top, st.padding.right, st.padding.bottom, st.padding.left), (4.0, 8.0, 4.0, 20.0));
3595 assert_eq!(st.margin.top, 10.0);
3596 assert_eq!(st.border.top, 2.0);
3597 assert_eq!(st.border.bottom, 5.0); assert_eq!(st.border_color.map(|c| c.r), Some(1.0)); }
3600
3601 #[test]
3602 fn flex_longhands_and_shorthand() {
3603 let flex = |v: &str| {
3604 let mut p = HashMap::new();
3605 p.insert("flex".to_string(), v.to_string());
3606 let st = interpret(&p);
3607 (st.grow, st.shrink, st.basis)
3608 };
3609 assert_eq!(flex("1"), (1.0, 1.0, Some(Len::Px(0.0))));
3612 assert_eq!(flex("1 0 auto"), (1.0, 0.0, None));
3613 assert_eq!(flex("2 3 120px"), (2.0, 3.0, Some(Len::Px(120.0))));
3614 assert_eq!(flex("none"), (0.0, 0.0, None));
3615
3616 let mut p = HashMap::new();
3617 p.insert("flex".to_string(), "1".to_string());
3618 p.insert("flex-shrink".to_string(), "0".to_string()); p.insert("flex-wrap".to_string(), "wrap".to_string());
3620 p.insert("opacity".to_string(), "0.45".to_string());
3621 let st = interpret(&p);
3622 assert_eq!(st.shrink, 0.0);
3623 assert!(st.wrap);
3624 assert_eq!(st.opacity, 0.45);
3625 }
3626
3627 #[test]
3628 fn border_radius_shorthand_diagonal_grouping_and_longhands() {
3629 assert_eq!(super::parse_border_radius("8px"), [8.0, 8.0, 8.0, 8.0]);
3632 assert_eq!(super::parse_border_radius("8px 4px"), [8.0, 4.0, 8.0, 4.0]);
3633 assert_eq!(super::parse_border_radius("1px 2px 3px"), [1.0, 2.0, 3.0, 2.0]);
3634 assert_eq!(super::parse_border_radius("1px 2px 3px 4px"), [1.0, 2.0, 3.0, 4.0]);
3635 assert_eq!(super::parse_border_radius("10px / 20px"), [10.0, 10.0, 10.0, 10.0]);
3637
3638 let mut p = HashMap::new();
3640 p.insert("border-radius".to_string(), "5px".to_string());
3641 p.insert("border-top-right-radius".to_string(), "12px".to_string());
3642 assert_eq!(interpret(&p).radius, [5.0, 12.0, 5.0, 5.0]);
3643 }
3644
3645 #[test]
3646 fn grid_placement_parses_lines_and_spans() {
3647 use super::GridPlace;
3648 let place = |css: &str| {
3649 let mut p = HashMap::new();
3650 p.insert("grid-column".to_string(), css.to_string());
3651 interpret(&p).grid_column
3652 };
3653 assert_eq!(place("1 / 3"), (GridPlace::Line(1), GridPlace::Line(3)));
3654 assert_eq!(place("2"), (GridPlace::Line(2), GridPlace::Auto));
3655 assert_eq!(place("span 2"), (GridPlace::Span(2), GridPlace::Auto));
3656 assert_eq!(place("1 / span 2"), (GridPlace::Line(1), GridPlace::Span(2)));
3657 assert_eq!(place("-1"), (GridPlace::Line(-1), GridPlace::Auto));
3658
3659 let mut p = HashMap::new();
3661 p.insert("grid-row".to_string(), "1 / 2".to_string());
3662 p.insert("grid-row-end".to_string(), "span 3".to_string());
3663 assert_eq!(interpret(&p).grid_row, (GridPlace::Line(1), GridPlace::Span(3)));
3664 }
3665
3666 #[test]
3667 fn named_and_hex_colors_resolve() {
3668 use super::parse_color;
3669 assert_eq!(parse_color("red").map(|c| (c.r, c.g, c.b)), Some((1.0, 0.0, 0.0)));
3672 assert!(parse_color("REBECCApurple").is_some()); assert_eq!(parse_color("#000000").map(|c| c.r), Some(0.0));
3674 assert_eq!(parse_color("transparent").map(|c| c.a), Some(0.0));
3675 assert!(parse_color("notacolor").is_none());
3676 }
3677
3678 #[test]
3679 fn decodes_html_entities_in_text() {
3680 use super::decode_entities;
3681 assert_eq!(decode_entities("A & B"), "A & B");
3682 assert_eq!(decode_entities("<tag> "q""), "<tag> \"q\"");
3683 assert_eq!(decode_entities("& &"), "& &");
3684 assert_eq!(decode_entities("plain text"), "plain text");
3685 assert_eq!(decode_entities("R&D, AT&T"), "R&D, AT&T");
3687 assert_eq!(decode_entities("¬anentity;"), "¬anentity;");
3688 }
3689
3690 #[test]
3691 fn parses_and_composes_transforms() {
3692 use super::parse_transform;
3693 assert_eq!(parse_transform("translate(10px, 20px)").unwrap(), [1.0, 0.0, 0.0, 1.0, 10.0, 20.0]);
3694 assert_eq!(parse_transform("scale(2, 3)").unwrap(), [2.0, 0.0, 0.0, 3.0, 0.0, 0.0]);
3695
3696 let r = parse_transform("rotate(90deg)").unwrap();
3698 assert!(r[0].abs() < 1e-4 && (r[1] - 1.0).abs() < 1e-4);
3699 assert!((r[2] + 1.0).abs() < 1e-4 && r[3].abs() < 1e-4);
3700
3701 let c = parse_transform("rotate(90deg) translate(10px, 0)").unwrap();
3704 assert!(c[4].abs() < 1e-3 && (c[5] - 10.0).abs() < 1e-3);
3705
3706 assert!(parse_transform("none").is_none());
3707 }
3708
3709 #[test]
3713 fn records_structural_parent_for_reconcile() {
3714 let src = r#"
3715 <template>
3716 <screen>
3717 <text>title</text>
3718 <view r-for="n in nums"><text>{{ n }}</text></view>
3719 <text r-if="level < 5">low</text>
3720 </screen>
3721 </template>
3722 <script> let nums = signal([1, 2, 3]); let level = signal(10); </script>
3723 "#;
3724 let sfc = rux_parser::parse_sfc(src).unwrap();
3725 let mut engine = Builder::new().build(&sfc.script).unwrap();
3726 let (_root, reg) = build_styled_tree_tracked(&sfc, &HashMap::new(), &mut engine).unwrap();
3727
3728 assert_eq!(reg.structural_parents.len(), 1, "the screen is the one structural parent");
3729 let sp = ®.structural_parents[0];
3730 assert_eq!(sp.tree_path, Vec::<usize>::new(), "screen is the root");
3731 assert_eq!(sp.tpl_path, Vec::<usize>::new());
3732 let mut deps: Vec<&str> = sp.deps.iter().map(String::as_str).collect();
3733 deps.sort_unstable();
3734 assert_eq!(deps, ["level", "nums"], "both directive signals are captured");
3735 }
3736
3737 #[test]
3738 fn parses_gradients_direction_and_stops() {
3739 use super::parse_background;
3740 use rux_layout::{Background, GradientKind};
3741 use std::f32::consts::{FRAC_PI_2, PI};
3742
3743 let grad = |css: &str| match parse_background(css) {
3744 Some(Background::Gradient(g)) => g,
3745 other => panic!("expected a gradient, got {other:?}"),
3746 };
3747
3748 let g = grad("linear-gradient(90deg, red, blue)");
3750 assert!(matches!(g.kind, GradientKind::Linear { angle } if (angle - FRAC_PI_2).abs() < 1e-4));
3751 assert_eq!(g.stops.len(), 2);
3752 assert_eq!(g.stops[0].1, 0.0);
3753 assert_eq!(g.stops[1].1, 1.0);
3754 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)");
3759 assert!(matches!(g.kind, GradientKind::Linear { angle } if (angle - PI).abs() < 1e-4));
3760 assert!((g.stops[1].1 - 0.5).abs() < 1e-4);
3761
3762 let g = grad("linear-gradient(to right, red 10%, blue 80%)");
3764 assert!(matches!(g.kind, GradientKind::Linear { angle } if (angle - FRAC_PI_2).abs() < 1e-4));
3765 assert!((g.stops[0].1 - 0.1).abs() < 1e-4);
3766 assert!((g.stops[1].1 - 0.8).abs() < 1e-4);
3767
3768 let g = grad("radial-gradient(circle, red, blue)");
3770 assert!(matches!(g.kind, GradientKind::Radial));
3771 assert_eq!(g.stops.len(), 2);
3772
3773 assert!(matches!(parse_background("#123456"), Some(Background::Color(_))));
3775
3776 assert!(matches!(parse_background("url(assets/logo.png)"), Some(Background::Image(s)) if s == "assets/logo.png"));
3778 assert!(matches!(parse_background("url('a b.png')"), Some(Background::Image(s)) if s == "a b.png"));
3779 }
3780
3781 #[test]
3782 fn maps_alignment_gap_position_and_aspect_ratio() {
3783 use super::{Align, Justify, Len, Position};
3784 let mut p = HashMap::new();
3785 p.insert("align-self".to_string(), "center".to_string());
3786 p.insert("justify-self".to_string(), "end".to_string());
3787 p.insert("align-content".to_string(), "space-between".to_string());
3788 p.insert("row-gap".to_string(), "8px".to_string());
3789 p.insert("column-gap".to_string(), "12px".to_string());
3790 p.insert("position".to_string(), "absolute".to_string());
3791 p.insert("top".to_string(), "10px".to_string());
3792 p.insert("left".to_string(), "auto".to_string());
3793 p.insert("aspect-ratio".to_string(), "16 / 9".to_string());
3794
3795 let st = interpret(&p);
3796 assert!(matches!(st.align_self, Some(Align::Center)));
3797 assert!(matches!(st.justify_self, Some(Align::End)));
3798 assert!(matches!(st.align_content, Some(Justify::SpaceBetween)));
3799 assert_eq!(st.row_gap, Some(8.0));
3800 assert_eq!(st.column_gap, Some(12.0));
3801 assert!(matches!(st.position, Position::Absolute));
3802 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));
3805 }
3806
3807 #[test]
3808 fn parses_grid_tracks_including_minmax() {
3809 use super::{parse_tracks, Track, TrackSide};
3810 let tracks = parse_tracks("minmax(0, 1fr) 100px auto minmax(120px, 1fr)");
3811 assert_eq!(tracks.len(), 4);
3812 assert!(matches!(
3813 tracks[0],
3814 Track::MinMax(TrackSide::Px(0.0), TrackSide::Fr(f)) if f == 1.0
3815 ));
3816 assert!(matches!(tracks[1], Track::Px(v) if v == 100.0));
3817 assert!(matches!(tracks[2], Track::Auto));
3818 assert!(matches!(
3819 tracks[3],
3820 Track::MinMax(TrackSide::Px(v), TrackSide::Fr(_)) if v == 120.0
3821 ));
3822 }
3823
3824 #[test]
3825 fn image_element_carries_its_src() {
3826 let src = r#"<template><screen><image src="assets/logo.png" /></screen></template>"#;
3827 let sfc = rux_parser::parse_sfc(src).unwrap();
3828 let mut e = Builder::new().build("").unwrap();
3829 let root = build_styled_tree(&sfc, &HashMap::new(), &mut e).unwrap();
3830 let img = root.children[0].image.as_ref().expect("image node");
3831 assert_eq!(img.src, "assets/logo.png");
3832 }
3833
3834 #[test]
3835 fn interpolates_bindings() {
3836 let mut e = Builder::new()
3837 .build(r#"let level = signal(82); let who = signal("Cam");"#)
3838 .unwrap();
3839 let locals = Locals::new();
3840 let interp = |e: &mut Engine, s: &str| interpolate_tracked(s, e, &locals).0;
3841 assert_eq!(interp(&mut e, "{{ level }}%"), "82%");
3842 assert_eq!(interp(&mut e, "Hi {{ who }}!"), "Hi Cam!");
3843 assert_eq!(interp(&mut e, "plain text"), "plain text");
3844 assert_eq!(interp(&mut e, "{{ missing }}!"), "!"); }
3846
3847 #[test]
3848 fn expands_r_for_and_r_if_chain() {
3849 let src = r#"
3850 <template>
3851 <screen>
3852 <view r-for="n in nums"><text>{{ n }}</text></view>
3853 <text r-if="level < 5">low</text>
3854 <text r-elif="level < 50">mid</text>
3855 <text r-else>high</text>
3856 </screen>
3857 </template>
3858 <script> let nums = signal([1, 2, 3]); let level = signal(10); </script>
3859 "#;
3860 let sfc = rux_parser::parse_sfc(src).unwrap();
3861 let mut engine = Builder::new().build(&sfc.script).unwrap();
3862 let root = build_styled_tree(&sfc, &HashMap::new(), &mut engine).unwrap();
3863
3864 assert_eq!(root.children.len(), 4);
3866 let mid = root.children[3].text.as_ref().unwrap();
3867 assert_eq!(mid.text, "mid");
3868 }
3869
3870 #[test]
3871 fn r_for_tap_handler_captures_the_loop_variable() {
3872 let src = r#"
3873 <template>
3874 <screen>
3875 <view r-for="item in items" @tap="picked = item">
3876 <text>{{ item }}</text>
3877 </view>
3878 </screen>
3879 </template>
3880 <script> let items = signal(["Alpha", "Bravo", "Charlie"]); let picked = signal(""); </script>
3881 "#;
3882 let sfc = rux_parser::parse_sfc(src).unwrap();
3883 let mut engine = Builder::new().build(&sfc.script).unwrap();
3884 let root = build_styled_tree(&sfc, &HashMap::new(), &mut engine).unwrap();
3885
3886 let handler = root.children[1].on_tap.clone().expect("row has @tap");
3889 assert!(
3890 handler.contains("let item = \"Bravo\""),
3891 "loop value not baked into handler: {handler}"
3892 );
3893
3894 assert_eq!(engine.get_string("picked"), "");
3897 let third = root.children[2].on_tap.clone().unwrap();
3898 assert!(engine.run_handler(&third), "handler ran");
3899 assert_eq!(engine.get_string("picked"), "Charlie");
3900 }
3901
3902 #[test]
3903 fn input_binds_model_and_shows_placeholder_then_value() {
3904 let src = r#"<template><screen>
3905 <input r-model="name" placeholder="Type here" />
3906 </screen></template>
3907 <script> let name = signal(""); </script>"#;
3908 let sfc = rux_parser::parse_sfc(src).unwrap();
3909 let mut engine = Builder::new().build(&sfc.script).unwrap();
3910
3911 let root = build_styled_tree(&sfc, &HashMap::new(), &mut engine).unwrap();
3912 let input = &root.children[0];
3913 assert_eq!(input.model.as_deref(), Some("name"), "r-model bound");
3914 assert_eq!(input.children[0].text.as_ref().unwrap().text, "Type here");
3916
3917 engine.set_string("name", "Cam");
3919 let root = build_styled_tree(&sfc, &HashMap::new(), &mut engine).unwrap();
3920 let input = &root.children[0];
3921 assert_eq!(input.children[0].text.as_ref().unwrap().text, "Cam");
3922 }
3923
3924 #[test]
3925 fn select_carries_options_and_textarea_is_multiline() {
3926 let src = r#"<template><screen>
3927 <input type="select" r-model="fruit" :options="fruits" />
3928 <input type="textarea" r-model="notes" />
3929 </screen></template>
3930 <script>
3931 let fruit = signal("pear");
3932 let fruits = signal(["apple", "pear", "plum"]);
3933 let notes = signal("");
3934 </script>"#;
3935 let sfc = rux_parser::parse_sfc(src).unwrap();
3936 let mut engine = Builder::new().build(&sfc.script).unwrap();
3937 let root = build_styled_tree(&sfc, &HashMap::new(), &mut engine).unwrap();
3938
3939 let select = &root.children[0];
3941 assert_eq!(select.model.as_deref(), Some("fruit"));
3942 assert_eq!(
3943 select.options.as_ref().expect("select has options"),
3944 &vec!["apple".to_string(), "pear".to_string(), "plum".to_string()]
3945 );
3946 assert!(!select.multiline);
3947 assert_eq!(select.children[0].text.as_ref().unwrap().text, "pear");
3948
3949 let textarea = &root.children[1];
3951 assert!(textarea.multiline);
3952 assert!(textarea.options.is_none());
3953 }
3954
3955 #[test]
3956 fn expands_component_with_props() {
3957 let main = rux_parser::parse_sfc(
3958 r#"<template>
3959 <screen><stat :label="title" :value="level" /></screen>
3960 </template>
3961 <script> let level = signal(82); let title = signal("Battery"); </script>"#,
3962 )
3963 .unwrap();
3964 let stat = rux_parser::parse_sfc(
3965 r#"<template>
3966 <view><text>{{ label }}: {{ value }}</text></view>
3967 </template>"#,
3968 )
3969 .unwrap();
3970
3971 let mut components = HashMap::new();
3972 components.insert("stat".to_string(), stat);
3973
3974 let mut engine = Builder::new().build(&main.script).unwrap();
3975 let root = build_styled_tree(&main, &components, &mut engine).unwrap();
3976
3977 let view = &root.children[0];
3979 let text = view.children[0].text.as_ref().unwrap();
3980 assert_eq!(text.text, "Battery: 82");
3981 }
3982
3983 use super::{matches_chain, parse_selector, AncNode, ElemDesc, ElemStates};
3989
3990 fn el(spec: &str) -> ElemDesc {
3991 let mut d = ElemDesc {
3993 tag: String::new(),
3994 id: None,
3995 classes: Vec::new(),
3996 role: None,
3997 states: ElemStates::default(),
3998 };
3999 let mut rest = spec;
4000 while let Some(pos) = rest.find(['.', '#']) {
4001 if pos > 0 {
4002 d.tag = rest[..pos].to_string();
4003 }
4004 let marker = rest.as_bytes()[pos];
4005 let after = &rest[pos + 1..];
4006 let end = after.find(['.', '#']).unwrap_or(after.len());
4007 let name = after[..end].to_string();
4008 if marker == b'.' {
4009 d.classes.push(name);
4010 } else {
4011 d.id = Some(name);
4012 }
4013 rest = &after[end..];
4014 }
4015 if !rest.is_empty() && d.tag.is_empty() {
4016 d.tag = rest.to_string();
4017 }
4018 d
4019 }
4020
4021 use super::AccessRole;
4024
4025 fn built(src: &str) -> rux_layout::Node {
4026 let sfc = rux_parser::parse_sfc(src).unwrap();
4027 let mut engine = Builder::new().build(&sfc.script).unwrap();
4028 build_styled_tree(&sfc, &HashMap::new(), &mut engine).unwrap()
4029 }
4030
4031 #[test]
4034 fn controls_get_their_implicit_roles() {
4035 let root = built(
4036 r#"<template><screen>
4037 <text>a heading</text>
4038 <input r-model="name" />
4039 <input type="textarea" r-model="notes" />
4040 <input type="checkbox" r-model="agree" />
4041 <input type="radio" r-model="plan" value="pro" />
4042 <view @tap="n = n + 1"><text>Save</text></view>
4043 <image src="logo.png" alt="the logo" />
4044 </screen></template>
4045 <script>let name = signal(""); let notes = signal(""); let agree = signal(false);
4046 let plan = signal("free"); let n = signal(0);</script>"#,
4047 );
4048 let roles: Vec<AccessRole> = root.children.iter().map(|c| c.access.role).collect();
4049 assert_eq!(
4050 roles,
4051 vec![
4052 AccessRole::Label,
4053 AccessRole::TextInput,
4054 AccessRole::MultilineTextInput,
4055 AccessRole::CheckBox,
4056 AccessRole::RadioButton,
4057 AccessRole::Button,
4058 AccessRole::Image,
4059 ]
4060 );
4061 }
4062
4063 #[test]
4066 fn a_tappable_box_is_named_by_its_content() {
4067 let root = built(
4068 r#"<template><screen><view @tap="n = n + 1"><text>Save</text></view></screen></template>
4069 <script>let n = signal(0);</script>"#,
4070 );
4071 let button = &root.children[0];
4072 assert_eq!(button.access.role, AccessRole::Button);
4073 assert_eq!(button.access.label.as_deref(), Some("Save"));
4074 }
4075
4076 #[test]
4079 fn a_for_label_names_its_control() {
4080 let root = built(
4081 r#"<template><screen>
4082 <text for="email">Email address</text>
4083 <input id="email" r-model="email" />
4084 </screen></template>
4085 <script>let email = signal("");</script>"#,
4086 );
4087 let input = &root.children[1];
4088 assert_eq!(input.access.role, AccessRole::TextInput);
4089 assert_eq!(input.access.label.as_deref(), Some("Email address"));
4090 }
4091
4092 #[test]
4095 fn explicit_role_and_label_win() {
4096 let root = built(
4097 r#"<template><screen>
4098 <text role="heading">Dashboard</text>
4099 <view @tap="n = n + 1" label="Save changes"><text>OK</text></view>
4100 </screen></template>
4101 <script>let n = signal(0);</script>"#,
4102 );
4103 assert_eq!(root.children[0].access.role, AccessRole::Heading);
4104 assert_eq!(root.children[1].access.label.as_deref(), Some("Save changes"));
4105 }
4106
4107 #[test]
4110 fn a_toggle_reports_its_checked_state() {
4111 let root = built(
4112 r#"<template><screen>
4113 <input type="checkbox" r-model="on" />
4114 <input type="checkbox" r-model="off" />
4115 </screen></template>
4116 <script>let on = signal(true); let off = signal(false);</script>"#,
4117 );
4118 assert_eq!(root.children[0].access.checked, Some(true));
4119 assert_eq!(root.children[1].access.checked, Some(false));
4120 }
4121
4122 #[test]
4125 fn an_input_exposes_value_but_not_its_placeholder_as_value() {
4126 let root = built(
4127 r#"<template><screen>
4128 <input r-model="name" placeholder="Your name" />
4129 <input r-model="city" placeholder="Your city" />
4130 </screen></template>
4131 <script>let name = signal("Ada"); let city = signal("");</script>"#,
4132 );
4133 let filled = &root.children[0];
4134 assert_eq!(filled.access.value.as_deref(), Some("Ada"));
4135 assert_eq!(
4136 filled.access.name(),
4137 Some("Your name"),
4138 "an unlabelled field falls back to its placeholder for a name"
4139 );
4140
4141 let empty = &root.children[1];
4142 assert_eq!(empty.access.value, None, "an empty field has no value");
4143 assert_eq!(empty.access.name(), Some("Your city"));
4144 }
4145
4146 #[test]
4149 fn a_for_label_outranks_a_placeholder() {
4150 let root = built(
4151 r#"<template><screen>
4152 <text for="notes">Notes</text>
4153 <input id="notes" r-model="notes" placeholder="Type a few lines…" />
4154 </screen></template>
4155 <script>let notes = signal("");</script>"#,
4156 );
4157 let input = &root.children[1];
4158 assert_eq!(input.access.name(), Some("Notes"), "the label wins");
4159 assert_eq!(
4160 input.access.placeholder.as_deref(),
4161 Some("Type a few lines…"),
4162 "the placeholder is still available as a hint"
4163 );
4164 }
4165
4166 #[test]
4169 fn plain_boxes_are_not_exposed() {
4170 let root = built(
4171 r#"<template><screen><view class="row"><view class="col" /></view></screen></template>"#,
4172 );
4173 assert_eq!(root.children[0].access.role, AccessRole::None);
4174 assert_eq!(root.children[0].children[0].access.role, AccessRole::None);
4175 assert!(!AccessRole::None.is_meaningful());
4176 }
4177
4178 use super::{media_matches, parse_rules, InteractionState, Viewport};
4181
4182 fn vp(width: f32, height: f32) -> Viewport {
4183 Viewport { width, height }
4184 }
4185
4186 fn bg_at_vp(src: &str, viewport: Viewport) -> Option<Background> {
4188 let sfc = rux_parser::parse_sfc(src).unwrap();
4189 let mut engine = Builder::new().build(&sfc.script).unwrap();
4190 let root = super::build_styled_tree_stateful(
4191 &sfc,
4192 &HashMap::new(),
4193 &mut engine,
4194 &InteractionState::default(),
4195 viewport,
4196 )
4197 .unwrap();
4198 root.0.children[0].style.background.clone()
4199 }
4200
4201 const MEDIA_DOC: &str = r#"<template><screen><view class="target" /></screen></template>
4202 <style>
4203 .target { background: #00ff00; }
4204 @media (max-width: 600px) { .target { background: #ff0000; } }
4205 </style>"#;
4206
4207 #[test]
4209 fn media_query_gates_its_rules_on_the_viewport() {
4210 assert!(is_red(&bg_at_vp(MEDIA_DOC, vp(480.0, 800.0))), "narrow → the @media rule");
4211 let wide = bg_at_vp(MEDIA_DOC, vp(1200.0, 800.0));
4212 assert!(
4213 matches!(&wide, Some(Background::Color(c)) if c.g == 1.0),
4214 "wide → the base rule, as if the block weren't there"
4215 );
4216 }
4217
4218 #[test]
4221 fn media_rules_cascade_by_order_not_by_being_in_a_block() {
4222 let src = r#"<template><screen><view class="target" id="t" /></screen></template>
4223 <style>
4224 #t { background: #00ff00; }
4225 @media (max-width: 600px) { .target { background: #ff0000; } }
4226 </style>"#;
4227 let narrow = bg_at_vp(src, vp(480.0, 800.0));
4228 assert!(
4229 matches!(&narrow, Some(Background::Color(c)) if c.g == 1.0),
4230 "#id still beats a .class inside @media"
4231 );
4232 }
4233
4234 #[test]
4236 fn media_conditions_evaluate() {
4237 let and = r#"<template><screen><view class="target" /></screen></template>
4238 <style>@media screen and (min-width: 400px) and (max-width: 600px) {
4239 .target { background: #ff0000; } }</style>"#;
4240 assert!(is_red(&bg_at_vp(and, vp(500.0, 800.0))), "inside the band");
4241 assert!(bg_at_vp(and, vp(700.0, 800.0)).is_none(), "outside the band");
4242
4243 let either = r#"<template><screen><view class="target" /></screen></template>
4244 <style>@media (max-width: 400px), (min-width: 1000px) {
4245 .target { background: #ff0000; } }</style>"#;
4246 assert!(is_red(&bg_at_vp(either, vp(300.0, 800.0))), "first alternative");
4247 assert!(is_red(&bg_at_vp(either, vp(1200.0, 800.0))), "second alternative");
4248 assert!(bg_at_vp(either, vp(600.0, 800.0)).is_none(), "neither");
4249
4250 let portrait = r#"<template><screen><view class="target" /></screen></template>
4251 <style>@media (orientation: portrait) { .target { background: #ff0000; } }</style>"#;
4252 assert!(is_red(&bg_at_vp(portrait, vp(400.0, 800.0))), "taller than wide");
4253 assert!(bg_at_vp(portrait, vp(800.0, 400.0)).is_none(), "wider than tall");
4254 }
4255
4256 #[test]
4259 fn unsupported_media_condition_never_applies() {
4260 let src = r#"<template><screen><view class="target" /></screen></template>
4261 <style>@media (min-resolution: 2dppx) { .target { background: #ff0000; } }</style>"#;
4262 assert!(bg_at_vp(src, vp(800.0, 600.0)).is_none());
4263 }
4264
4265 #[test]
4268 fn media_matches_reports_each_block() {
4269 let css = "@media (max-width: 600px) { .a { color: red } } \
4270 @media (min-width: 1000px) { .b { color: red } }";
4271 assert_eq!(media_matches(css, vp(500.0, 800.0)), vec![true, false]);
4272 assert_eq!(media_matches(css, vp(800.0, 800.0)), vec![false, false]);
4273 assert_eq!(media_matches(css, vp(1200.0, 800.0)), vec![false, true]);
4274 assert_eq!(media_matches(css, vp(700.0, 800.0)), media_matches(css, vp(900.0, 800.0)));
4277 assert!(media_matches(".a { color: red }", vp(800.0, 600.0)).is_empty());
4278 }
4279
4280 #[test]
4282 fn plain_rules_are_viewport_independent() {
4283 let css = ".a { color: red }";
4284 assert_eq!(parse_rules(css, vp(320.0, 480.0)).len(), parse_rules(css, vp(1600.0, 900.0)).len());
4285 }
4286
4287 use super::{Background, Vars};
4290
4291 fn bg_at(src: &str, path: &[usize]) -> Option<Background> {
4293 let sfc = rux_parser::parse_sfc(src).unwrap();
4294 let mut engine = Builder::new().build(&sfc.script).unwrap();
4295 let root = build_styled_tree(&sfc, &HashMap::new(), &mut engine).unwrap();
4296 let mut node = &root;
4297 for i in path {
4298 node = &node.children[*i];
4299 }
4300 node.style.background.clone()
4301 }
4302
4303 fn is_red(bg: &Option<Background>) -> bool {
4304 matches!(bg, Some(Background::Color(c)) if c.r == 1.0 && c.g == 0.0 && c.b == 0.0)
4305 }
4306
4307 #[test]
4310 fn custom_property_inherits_down_the_tree() {
4311 let bg = bg_at(
4312 r#"<template><screen class="app"><view><view class="target" /></view></screen></template>
4313 <style>
4314 .app { --brand: #ff0000; }
4315 .target { background: var(--brand); }
4316 </style>"#,
4317 &[0, 0],
4318 );
4319 assert!(is_red(&bg), "var() resolved from an ancestor's declaration");
4320 }
4321
4322 #[test]
4324 fn nearer_declaration_shadows_the_inherited_one() {
4325 let src = r#"<template><screen class="app">
4326 <view class="panel"><view class="target" /></view>
4327 <view><view class="target" /></view>
4328 </screen></template>
4329 <style>
4330 .app { --brand: #00ff00; }
4331 .panel { --brand: #ff0000; }
4332 .target { background: var(--brand); }
4333 </style>"#;
4334 assert!(is_red(&bg_at(src, &[0, 0])), "inside .panel the nearer value wins");
4335 let outside = bg_at(src, &[1, 0]);
4336 assert!(
4337 matches!(&outside, Some(Background::Color(c)) if c.g == 1.0),
4338 "outside .panel the root value still applies, the override didn't leak"
4339 );
4340 }
4341
4342 #[test]
4344 fn custom_property_can_reference_another() {
4345 let bg = bg_at(
4346 r#"<template><screen class="app"><view class="target" /></screen></template>
4347 <style>
4348 .app { --red: #ff0000; --brand: var(--red); }
4349 .target { background: var(--brand); }
4350 </style>"#,
4351 &[0],
4352 );
4353 assert!(is_red(&bg));
4354 }
4355
4356 #[test]
4359 fn var_falls_back_when_undefined() {
4360 let bg = bg_at(
4361 r#"<template><screen><view class="target" /></screen></template>
4362 <style>.target { background: var(--nope, #ff0000); }</style>"#,
4363 &[0],
4364 );
4365 assert!(is_red(&bg), "the fallback is used");
4366
4367 let bg = bg_at(
4368 r#"<template><screen><view class="target" /></screen></template>
4369 <style>.target { background: var(--nope, rgb(255, 0, 0)); }</style>"#,
4370 &[0],
4371 );
4372 assert!(is_red(&bg), "a fallback with its own parens survives");
4373 }
4374
4375 #[test]
4378 fn undefined_var_without_fallback_drops_the_declaration() {
4379 let bg = bg_at(
4380 r#"<template><screen><view class="target" /></screen></template>
4381 <style>.target { background: var(--nope); }</style>"#,
4382 &[0],
4383 );
4384 assert!(bg.is_none(), "no background, rather than a wrong one");
4385 }
4386
4387 #[test]
4389 fn circular_variables_terminate() {
4390 let bg = bg_at(
4391 r#"<template><screen class="app"><view class="target" /></screen></template>
4392 <style>
4393 .app { --a: var(--b); --b: var(--a); }
4394 .target { background: var(--a); }
4395 </style>"#,
4396 &[0],
4397 );
4398 assert!(bg.is_none(), "a cycle resolves to nothing, and returns");
4399 }
4400
4401 #[test]
4404 fn var_resolves_in_inline_style() {
4405 let bg = bg_at(
4406 r#"<template><screen class="app"><view style="background: var(--brand)" /></screen></template>
4407 <style>.app { --brand: #ff0000; }</style>"#,
4408 &[0],
4409 );
4410 assert!(is_red(&bg));
4411 }
4412
4413 #[test]
4416 fn custom_property_is_not_treated_as_a_property() {
4417 assert!(!super::is_honored("--brand"));
4418 let mut props: HashMap<String, String> = HashMap::new();
4419 props.insert("--brand".into(), "#ff0000".into());
4420 props.insert("background".into(), "var(--brand)".into());
4421 let vars = super::take_vars(&mut props, &Vars::default());
4422 assert!(!props.contains_key("--brand"), "stripped out of the property map");
4423 assert_eq!(vars.get("--brand").map(String::as_str), Some("#ff0000"));
4424 }
4425
4426 fn hits_state(selector: &str, target: &str, states: ElemStates) -> bool {
4435 let (chain, combs, _) = parse_selector(selector).expect("selector parses");
4436 let mut d = el(target);
4437 d.states = states;
4438 matches_chain(&chain, &combs, &d, &[], &[])
4439 }
4440
4441 fn hovered() -> ElemStates {
4442 ElemStates { hover: true, ..ElemStates::default() }
4443 }
4444
4445 #[test]
4446 fn pseudo_class_matches_only_in_that_state() {
4447 assert!(hits_state(".box:hover", ".box", hovered()));
4448 assert!(
4449 !hits_state(".box:hover", ".box", ElemStates::default()),
4450 "an unhovered element must NOT match :hover (it used to match always)"
4451 );
4452 assert!(hits_state(".box", ".box", hovered()));
4454 }
4455
4456 #[test]
4457 fn each_pseudo_reads_its_own_state() {
4458 let s = ElemStates { hover: false, focus: true, active: false, checked: true };
4459 assert!(hits_state("input:focus", "input", s));
4460 assert!(hits_state("input:checked", "input", s));
4461 assert!(!hits_state("input:hover", "input", s));
4462 assert!(!hits_state("input:active", "input", s));
4463 }
4464
4465 #[test]
4466 fn stacked_pseudos_all_have_to_hold() {
4467 let hover_only = hovered();
4468 let both = ElemStates { hover: true, active: true, ..ElemStates::default() };
4469 assert!(!hits_state(".btn:hover:active", ".btn", hover_only));
4470 assert!(hits_state(".btn:hover:active", ".btn", both));
4471 }
4472
4473 #[test]
4476 fn unknown_pseudo_never_matches() {
4477 let all_on = ElemStates { hover: true, focus: true, active: true, checked: true };
4478 assert!(!hits_state(".box:disabled", ".box", all_on));
4479 assert!(!hits_state(".box:nth-child(2)", ".box", all_on));
4480 assert!(!hits_state(".box::selection", ".box", all_on));
4481 }
4482
4483 #[test]
4486 fn pseudo_class_adds_class_specificity() {
4487 let (_, _, plain) = parse_selector(".box").unwrap();
4488 let (_, _, with_pseudo) = parse_selector(".box:hover").unwrap();
4489 assert_eq!(plain, (0, 1, 0));
4490 assert_eq!(with_pseudo, (0, 2, 0));
4491 assert!(with_pseudo > plain);
4492 }
4493
4494 #[test]
4497 fn pseudo_class_stays_within_its_compound() {
4498 let (chain, combs, _) = parse_selector(".card > .btn:hover").unwrap();
4499 assert_eq!(chain.len(), 2, "two compounds, not three");
4500 assert_eq!(combs.len(), 1);
4501 let hover = hovered();
4503 let mut btn = el(".btn");
4504 btn.states = hover;
4505 let card = anc(".card", &[]);
4506 assert!(matches_chain(&chain, &combs, &btn, &[card.clone()], &[]));
4507 let plain_btn = el(".btn");
4508 assert!(!matches_chain(&chain, &combs, &plain_btn, &[card], &[]));
4509 }
4510
4511 #[test]
4514 fn checked_pseudo_styles_a_ticked_toggle() {
4515 let src = r#"
4516 <template>
4517 <screen>
4518 <input type="checkbox" class="box" r-model="on" />
4519 <input type="checkbox" class="box" r-model="off" />
4520 </screen>
4521 </template>
4522 <style>
4523 .box { background: #000000; }
4524 .box:checked { background: #00ff00; }
4525 </style>
4526 <script> let on = signal(true); let off = signal(false); </script>
4527 "#;
4528 let sfc = rux_parser::parse_sfc(src).unwrap();
4529 let mut engine = Builder::new().build(&sfc.script).unwrap();
4530 let root = build_styled_tree(&sfc, &HashMap::new(), &mut engine).unwrap();
4531
4532 let green = |n: &rux_layout::Node| {
4533 matches!(&n.style.background, Some(rux_layout::Background::Color(c)) if c.g == 1.0)
4534 };
4535 assert!(green(&root.children[0]), "ticked box matches .box:checked");
4536 assert!(!green(&root.children[1]), "unticked box does not");
4537 }
4538
4539 fn anc(spec: &str, prev: &[&str]) -> AncNode {
4540 AncNode { desc: el(spec), prev: prev.iter().map(|s| el(s)).collect() }
4541 }
4542
4543 fn hits(selector: &str, target: &str, ancestors: &[AncNode], prev: &[&str]) -> bool {
4546 let (chain, combs, _) = parse_selector(selector).expect("selector parses");
4547 let prev: Vec<ElemDesc> = prev.iter().map(|s| el(s)).collect();
4548 matches_chain(&chain, &combs, &el(target), ancestors, &prev)
4549 }
4550
4551 #[test]
4552 fn lightningcss_serialization_round_trips_to_our_combinators() {
4553 use super::{parse_rules, Combinator};
4557 let css = ".card > text { color: #111 } .a + .b { color: #222 } .a ~ .b { color: #333 }";
4558 let rules = parse_rules(css, Viewport::default());
4559 let combs: Vec<&[Combinator]> = rules.iter().map(|r| r.combs.as_slice()).collect();
4560 assert_eq!(combs[0], &[Combinator::Child]);
4561 assert_eq!(combs[1], &[Combinator::NextSibling]);
4562 assert_eq!(combs[2], &[Combinator::SubsequentSibling]);
4563 }
4564
4565 #[test]
4566 fn child_combinator_styles_the_right_element_end_to_end() {
4567 let src = r#"
4573 <template>
4574 <screen>
4575 <text>direct</text>
4576 <view><text>nested</text></view>
4577 </screen>
4578 </template>
4579 <style>
4580 screen > text { color: #080808 }
4581 </style>
4582 "#;
4583 let sfc = rux_parser::parse_sfc(src).unwrap();
4584 let mut engine = Builder::new().build("").unwrap();
4585 let root = build_styled_tree(&sfc, &HashMap::new(), &mut engine).unwrap();
4586
4587 let direct = root.children[0].text.as_ref().unwrap();
4588 let nested = root.children[1].children[0].text.as_ref().unwrap();
4589 assert!(direct.color.r < 0.1, "direct child of screen got the #080808 color");
4590 assert!(nested.color.r > 0.5, "grandchild is NOT matched by `screen > text`");
4591 }
4592
4593 #[test]
4594 fn child_combinator_only_matches_direct_children() {
4595 assert!(hits("*.card > text", "text", &[anc("view.card", &[])], &[]));
4598 assert!(!hits(
4599 "*.card > text",
4600 "text",
4601 &[anc("view.card", &[]), anc("view.inner", &[])],
4602 &[],
4603 ));
4604 assert!(hits(
4606 "*.card text",
4607 "text",
4608 &[anc("view.card", &[]), anc("view.inner", &[])],
4609 &[],
4610 ));
4611 }
4612
4613 #[test]
4614 fn next_sibling_combinator_needs_immediate_predecessor() {
4615 assert!(hits("*.a + *.b", "view.b", &[], &["view.a"]));
4617 assert!(hits("*.a + *.b", "view.b", &[], &["view.x", "view.a"]));
4618 assert!(!hits("*.a + *.b", "view.b", &[], &["view.a", "view.x"]));
4620 assert!(!hits("*.a + *.b", "view.b", &[], &[]));
4621 }
4622
4623 #[test]
4624 fn subsequent_sibling_combinator_matches_any_earlier_sibling() {
4625 assert!(hits("*.a ~ *.b", "view.b", &[], &["view.a", "view.x"]));
4627 assert!(hits("*.a ~ *.b", "view.b", &[], &["view.a"]));
4628 assert!(!hits("*.a ~ *.b", "view.b", &[], &["view.x"]));
4629 }
4630
4631 #[test]
4632 fn combinators_compose() {
4633 let ancestors = [anc("view.card", &[])];
4635 assert!(hits("*.card > *.a + *.b", "view.b", &ancestors, &["view.a"]));
4636 let ancestors = [anc("view.b", &["view.a"])];
4639 assert!(hits("*.a ~ *.b *.c", "view.c", &ancestors, &[]));
4640 let ancestors = [anc("view.b", &["view.x"])];
4642 assert!(!hits("*.a ~ *.b *.c", "view.c", &ancestors, &[]));
4643 }
4644}
4645
4646fn parse_rgb(s: &str) -> Option<Rgba> {
4647 let inner = s.trim_start_matches("rgba").trim_start_matches("rgb");
4648 let inner = inner.trim().trim_start_matches('(').trim_end_matches(')');
4649 let parts: Vec<&str> = inner.split([',', ' ', '/']).filter(|p| !p.is_empty()).collect();
4650 if parts.len() < 3 {
4651 return None;
4652 }
4653 let r = parts[0].parse::<f32>().ok()? / 255.0;
4654 let g = parts[1].parse::<f32>().ok()? / 255.0;
4655 let b = parts[2].parse::<f32>().ok()? / 255.0;
4656 let a = parts.get(3).and_then(|v| v.parse::<f32>().ok()).unwrap_or(1.0);
4657 Some(Rgba::new(r, g, b, a))
4658}