1use crate::layout_ir::{
20 Attributes, BgSpec, DisplaySpec, EasingIr, Edges, Element, FlexAlign, FlexJustify, LayoutIR,
21 LengthSpec, LineHeightSpec, OverflowSpec, ParseError, PositionSpec, Rgba, ScrollAxisSpec,
22 ScrollbarWidthSpec, ShadowSpec, TextAlignSpec, TextWrapSpec, TrackSizeSpec, TransitionIr,
23 TransitionPropertyIr,
24};
25use crate::values::{
26 bad, parse_bg, parse_color, parse_edges, parse_f32, parse_i32, parse_inset_edges,
27 parse_inset_term, parse_length,
28};
29use std::rc::Rc;
30pub fn palette_root_css() -> String {
53 use lumen_core::palette::Palette;
54 let mut light: Vec<(String, String)> =
55 Palette::adwaita_light().root_vars().into_iter().collect();
56 light.sort();
57 let mut dark: Vec<(String, String)> = Palette::adwaita_dark().root_vars().into_iter().collect();
58 dark.sort();
59 let mut out = String::from(":root {\n");
60 for (name, value) in &light {
61 out.push_str(&format!(" --{name}: {value};\n"));
62 }
63 out.push_str("}\n@media (prefers-color-scheme: dark) {\n :root {\n");
64 for (name, value) in &dark {
65 out.push_str(&format!(" --{name}: {value};\n"));
66 }
67 out.push_str(" }\n}\n");
68 out
69}
70
71#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
75pub struct AtRule {
76 pub name: String,
78 pub prelude: String,
81 pub body: String,
83 pub media: Option<MediaQuery>,
85}
86
87pub const CARRIED_AT_RULES: &[&str] = &["keyframes", "font-face"];
94
95#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)]
97pub struct Stylesheet {
98 pub rules: Vec<Rule>,
101 pub at_rules: Vec<AtRule>,
104}
105
106impl Stylesheet {
107 pub fn class_invalidation_set(&self) -> std::collections::HashSet<String> {
112 let mut set = std::collections::HashSet::new();
113 for rule in &self.rules {
114 for sel in &rule.selectors {
115 sel.collect_classes(&mut set);
116 }
117 }
118 set
119 }
120
121 pub fn uses_sibling_combinators(&self) -> bool {
125 fn walk(sel: &SelectorBuf) -> bool {
126 sel.chain.iter().any(|(link, compound)| {
127 matches!(
128 link,
129 Combinator::AdjacentSibling | Combinator::GeneralSibling
130 ) || compound.pseudo_classes.iter().any(|p| match p {
131 PseudoClass::Is(args) | PseudoClass::Where(args) | PseudoClass::Not(args) => {
132 args.iter().any(walk)
133 }
134 _ => false,
135 })
136 })
137 }
138 self.rules.iter().flat_map(|r| &r.selectors).any(walk)
139 }
140
141 pub fn root_vars(&self) -> std::collections::HashMap<String, String> {
164 let mut out = std::collections::HashMap::new();
165 for rule in &self.rules {
166 let is_root_pseudo = rule.selectors.iter().any(|sel| {
173 sel.chain.len() == 1
174 && sel.chain.iter().all(|(_, c)| {
175 c.tag.is_none()
176 && c.id.is_none()
177 && c.classes.is_empty()
178 && c.pseudo_classes.len() == 1
179 && matches!(c.pseudo_classes[0], PseudoClass::Root)
180 })
181 });
182 if rule.media.is_some() || !is_root_pseudo {
183 continue;
184 }
185 for decl in &rule.declarations {
186 if let Some(name) = decl.name.strip_prefix("--") {
187 out.insert(name.to_string(), decl.value.clone());
188 }
189 }
190 }
191 out
192 }
193
194 pub fn resolve_root_var(&self, name: &str) -> Option<String> {
199 let vars = self.root_vars();
200 crate::css_vars::resolve(vars.get(name)?, &vars, "").ok()
201 }
202}
203
204#[derive(
214 Debug,
215 Default,
216 Clone,
217 Copy,
218 PartialEq,
219 Eq,
220 PartialOrd,
221 Ord,
222 Hash,
223 serde::Serialize,
224 serde::Deserialize,
225)]
226pub enum Origin {
227 UserAgent,
229 #[default]
231 Author,
232}
233
234#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
236pub struct Rule {
237 pub selectors: Vec<SelectorBuf>,
240 pub declarations: Vec<Declaration>,
243 #[serde(default)]
247 pub origin: Origin,
248 pub source_order: usize,
251 pub media: Option<MediaQuery>,
253 pub selector: LegacySelectorShim,
258}
259
260#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
262pub struct LegacySelectorShim {
263 pub tag: Option<String>,
265 pub classes: Vec<String>,
267}
268
269#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
271pub struct Declaration {
272 pub name: String,
274 pub value: String,
276 pub important: bool,
278}
279
280#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
296pub struct SelectorBuf {
297 pub chain: Vec<(Combinator, CompoundSelector)>,
299}
300
301impl SelectorBuf {
302 pub fn specificity(&self) -> Specificity {
306 let mut spec = Specificity::default();
307 for (_, c) in &self.chain {
308 spec = spec.add(c.specificity());
309 }
310 spec
311 }
312
313 fn collect_classes(&self, out: &mut std::collections::HashSet<String>) {
314 for (_, c) in &self.chain {
315 c.collect_classes(out);
316 }
317 }
318}
319
320#[derive(
322 Debug,
323 Default,
324 Clone,
325 Copy,
326 PartialEq,
327 Eq,
328 PartialOrd,
329 Ord,
330 serde::Serialize,
331 serde::Deserialize,
332)]
333pub struct Specificity {
334 pub a: u32,
336 pub b: u32,
338 pub c: u32,
340}
341
342impl Specificity {
343 pub const fn add(self, o: Self) -> Self {
345 Self {
346 a: self.a + o.a,
347 b: self.b + o.b,
348 c: self.c + o.c,
349 }
350 }
351
352 fn promote(self, o: Self) -> Self {
355 if self > o { self } else { o }
356 }
357}
358
359#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
361pub enum Combinator {
362 Subject,
364 Descendant,
366 Child,
368 AdjacentSibling,
370 GeneralSibling,
372}
373
374#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
376pub struct CompoundSelector {
377 pub tag: Option<String>,
379 pub id: Option<String>,
381 pub classes: Vec<String>,
383 pub pseudo_classes: Vec<PseudoClass>,
385}
386
387impl CompoundSelector {
388 fn specificity(&self) -> Specificity {
389 let mut spec = Specificity::default();
390 if self.id.is_some() {
391 spec.a += 1;
392 }
393 spec.b += self.classes.len() as u32;
394 for p in &self.pseudo_classes {
395 spec = spec.add(p.specificity());
396 }
397 if self.tag.is_some() {
398 spec.c += 1;
399 }
400 spec
401 }
402
403 fn collect_classes(&self, out: &mut std::collections::HashSet<String>) {
404 for c in &self.classes {
405 out.insert(c.clone());
406 }
407 for p in &self.pseudo_classes {
408 p.collect_classes(out);
409 }
410 }
411
412 pub fn is_empty(&self) -> bool {
416 self.tag.is_none()
417 && self.id.is_none()
418 && self.classes.is_empty()
419 && self.pseudo_classes.is_empty()
420 }
421}
422
423#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
425pub enum PseudoClass {
426 Hover,
428 Focus,
430 FocusVisible,
434 Active,
436 Disabled,
439 Checked,
441 Selected,
446 DragOver,
450 Root,
452 FirstChild,
454 LastChild,
456 OnlyChild,
458 Empty,
460 NthChild(AnB),
462 Is(Vec<SelectorBuf>),
464 Where(Vec<SelectorBuf>),
466 Not(Vec<SelectorBuf>),
468}
469
470impl PseudoClass {
471 fn specificity(&self) -> Specificity {
472 match self {
473 Self::Hover
474 | Self::Focus
475 | Self::FocusVisible
476 | Self::Active
477 | Self::Disabled
478 | Self::Checked
479 | Self::Selected
480 | Self::DragOver
481 | Self::Root
482 | Self::FirstChild
483 | Self::LastChild
484 | Self::OnlyChild
485 | Self::Empty
486 | Self::NthChild(_) => Specificity { a: 0, b: 1, c: 0 },
487 Self::Is(args) | Self::Not(args) => {
488 let mut acc = Specificity::default();
489 for s in args {
490 acc = acc.promote(s.specificity());
491 }
492 acc
493 }
494 Self::Where(_) => Specificity::default(),
495 }
496 }
497
498 fn collect_classes(&self, out: &mut std::collections::HashSet<String>) {
499 match self {
500 Self::Is(args) | Self::Where(args) | Self::Not(args) => {
501 for s in args {
502 s.collect_classes(out);
503 }
504 }
505 _ => {}
506 }
507 }
508}
509
510#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
512pub struct AnB {
513 pub a: i32,
515 pub b: i32,
517}
518
519impl AnB {
520 pub fn matches(self, index: i32) -> bool {
522 if self.a == 0 {
523 return index == self.b;
524 }
525 let diff = index as i64 - self.b as i64;
527 let a = self.a as i64;
528 if (diff < 0 && a > 0) || (diff > 0 && a < 0) {
529 return false;
530 }
531 diff % a == 0
532 }
533}
534
535#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
541pub struct MediaQuery {
542 pub features: Vec<MediaFeature>,
544}
545
546#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
548pub enum MediaFeature {
549 PrefersColorScheme(ColorSchemePreference),
551 PrefersReducedMotion(MotionPreference),
553 PrefersContrast(ContrastPreference),
555 MinWidth(f32),
557 MaxWidth(f32),
559 Width(f32),
561}
562
563#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
565pub enum ColorSchemePreference {
566 Dark,
568 Light,
570 NoPreference,
572}
573
574#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
576pub enum MotionPreference {
577 Reduce,
579 NoPreference,
581}
582
583#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
585pub enum ContrastPreference {
586 More,
588 Less,
590 Custom,
592 NoPreference,
594}
595
596#[derive(Debug, Clone, Copy)]
599pub struct MediaContext {
600 pub color_scheme: Option<ColorSchemePreference>,
602 pub reduced_motion: MotionPreference,
604 pub contrast: ContrastPreference,
606 pub viewport_width: Option<f32>,
609}
610
611impl Default for MediaContext {
612 fn default() -> Self {
613 Self {
614 color_scheme: None,
615 reduced_motion: MotionPreference::NoPreference,
616 contrast: ContrastPreference::NoPreference,
617 viewport_width: None,
618 }
619 }
620}
621
622impl MediaQuery {
623 pub fn matches(&self, ctx: &MediaContext) -> bool {
625 self.features.iter().all(|f| f.matches(ctx))
626 }
627}
628
629impl MediaFeature {
630 fn matches(&self, ctx: &MediaContext) -> bool {
631 match self {
632 Self::PrefersColorScheme(want) => match (ctx.color_scheme, *want) {
633 (Some(cs), w) => cs == w,
634 (None, ColorSchemePreference::NoPreference) => true,
635 (None, _) => false,
636 },
637 Self::PrefersReducedMotion(want) => ctx.reduced_motion == *want,
638 Self::PrefersContrast(want) => ctx.contrast == *want,
639 Self::MinWidth(min) => ctx.viewport_width.is_some_and(|w| w >= *min),
640 Self::MaxWidth(max) => ctx.viewport_width.is_some_and(|w| w <= *max),
641 Self::Width(w) => ctx.viewport_width.is_some_and(|vp| (vp - *w).abs() < 0.5),
642 }
643 }
644}
645pub fn apply_css(ir: &mut LayoutIR, css: &Stylesheet) -> Result<Vec<CssWarning>, ParseError> {
654 apply_css_with_media(ir, css, &MediaContext::default())
655}
656
657pub fn apply_css_with_media(
666 ir: &mut LayoutIR,
667 css: &Stylesheet,
668 media: &MediaContext,
669) -> Result<Vec<CssWarning>, ParseError> {
670 let parents: Vec<ElementRef> = Vec::new();
671 let mut warnings = Vec::new();
672 apply_to_element(
673 &mut ir.root,
674 css,
675 media,
676 &Scope::default(),
677 &parents,
678 1,
679 1,
680 None,
681 &mut warnings,
682 );
683 let mut seen = std::collections::HashSet::new();
684 warnings.retain(|w| seen.insert((w.selector.clone(), w.property.clone(), w.message.clone())));
685 Ok(warnings)
686}
687
688#[derive(Debug, Clone, PartialEq, Eq)]
691pub struct CssWarning {
692 pub selector: String,
694 pub property: String,
696 pub message: String,
698}
699
700impl std::fmt::Display for CssWarning {
701 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
702 write!(
703 f,
704 "css warning: {} {{ {} }} - {}",
705 self.selector, self.property, self.message
706 )
707 }
708}
709
710#[derive(Debug, Clone)]
713struct ElementRef {
714 tag: String,
715 classes: Vec<String>,
716 id: Option<String>,
717 child_index: i32,
719 sibling_count: i32,
721 siblings: Option<Rc<[ElementRef]>>,
728}
729
730impl ElementRef {
731 fn from_element(el: &Element, child_index: i32, sibling_count: i32) -> Self {
732 Self {
733 tag: el.tag.clone(),
734 classes: el.attrs.classes.clone(),
735 id: el.attrs.id.clone(),
736 child_index,
737 sibling_count,
738 siblings: None,
739 }
740 }
741
742 fn with_siblings(mut self, siblings: Option<Rc<[ElementRef]>>) -> Self {
744 self.siblings = siblings;
745 self
746 }
747
748 fn sibling_index(&self) -> usize {
750 (self.child_index.max(1) - 1) as usize
751 }
752}
753
754#[derive(Clone, Copy)]
758struct NodeCtx<'a> {
759 el: &'a ElementRef,
760 ancestors: &'a [ElementRef],
762 siblings: Option<&'a [ElementRef]>,
765 sibling_index: usize,
766 has_element_children: bool,
767 text_body: Option<&'a str>,
768 is_root: bool,
769}
770
771impl<'a> NodeCtx<'a> {
772 fn ancestor(parents: &'a [ElementRef], k: usize) -> Self {
776 let a = &parents[k];
777 Self {
778 el: a,
779 ancestors: &parents[..k],
780 siblings: a.siblings.as_deref(),
781 sibling_index: a.sibling_index(),
782 has_element_children: true,
783 text_body: None,
784 is_root: k == 0,
785 }
786 }
787}
788
789#[allow(clippy::too_many_arguments)]
790fn apply_to_element(
791 el: &mut Element,
792 css: &Stylesheet,
793 media: &MediaContext,
794 scope: &Scope,
795 parents: &[ElementRef],
796 child_index: i32,
797 sibling_count: i32,
798 siblings: Option<Rc<[ElementRef]>>,
799 warnings: &mut Vec<CssWarning>,
800) {
801 let me = ElementRef::from_element(el, child_index, sibling_count).with_siblings(siblings);
802 let has_element_children = !el.children.is_empty();
803 let text_body = el.attrs.text.take();
806 let child_scope = cascade_one(
807 &mut el.attrs,
808 css,
809 media,
810 scope,
811 parents,
812 &me,
813 has_element_children,
814 text_body.as_deref(),
815 warnings,
816 );
817 el.attrs.text = text_body;
818
819 let mut new_parents: Vec<ElementRef> = parents.to_vec();
821 new_parents.push(me);
822 let n = el.children.len() as i32;
823 let child_refs: Option<Rc<[ElementRef]>> = if el.children.is_empty() {
826 None
827 } else {
828 Some(
829 el.children
830 .iter()
831 .enumerate()
832 .map(|(i, c)| ElementRef::from_element(c, (i as i32) + 1, n))
833 .collect(),
834 )
835 };
836 for (i, child) in el.children.iter_mut().enumerate() {
837 apply_to_element(
838 child,
839 css,
840 media,
841 &child_scope,
842 &new_parents,
843 (i as i32) + 1,
844 n,
845 child_refs.clone(),
846 warnings,
847 );
848 }
849}
850
851#[derive(Debug, Clone, Default)]
854struct Scope {
855 vars: std::collections::HashMap<String, String>,
856 text: InheritedText,
857}
858
859#[allow(clippy::too_many_arguments)]
868fn cascade_one(
869 attrs: &mut Attributes,
870 css: &Stylesheet,
871 media: &MediaContext,
872 scope: &Scope,
873 parents: &[ElementRef],
874 me: &ElementRef,
875 has_element_children: bool,
876 text_body: Option<&str>,
877 warnings: &mut Vec<CssWarning>,
878) -> Scope {
879 let inherited_vars = &scope.vars;
880 let inherited_text = &scope.text;
881 let is_root = parents.is_empty();
882
883 let inline_snapshot = attrs.clone();
886
887 inherited_text.apply_base(attrs);
892
893 let matched = collect_matching_rules(
895 css,
896 media,
897 me,
898 parents,
899 has_element_children,
900 text_body,
901 is_root,
902 );
903
904 let mut vars = inherited_vars.clone();
909 {
910 let mut var_units: Vec<&Declaration> = Vec::new();
911 for m in &matched {
912 for decl in &css.rules[m.rule_idx].declarations {
913 if decl.name.starts_with("--") {
914 var_units.push(decl);
915 }
916 }
917 }
918 var_units.sort_by_key(|d| d.important);
919 for decl in var_units {
920 if let Some(name) = decl.name.strip_prefix("--") {
921 vars.insert(name.to_string(), decl.value.clone());
922 }
923 }
924 }
925
926 let mut units: Vec<(&MatchedRule, &Declaration)> = Vec::new();
934 for m in &matched {
935 for decl in &css.rules[m.rule_idx].declarations {
936 if decl.name.starts_with("--") {
937 continue;
938 }
939 units.push((m, decl));
940 }
941 }
942 units.sort_by_key(|(_, decl)| decl.important);
943
944 let mut plain_bg_origin: Option<Origin> = None;
949 let mut hover_bg_origin: Option<Origin> = None;
950 let mut press_bg_origin: Option<Origin> = None;
951
952 for (m, decl) in units {
954 let ctx = describe_selector_for_error(&css.rules[m.rule_idx].selectors, m.selector_idx);
955 let resolved = match resolve_vars(&ctx, &decl.name, &decl.value, &vars) {
956 Ok(v) => v,
957 Err(e) => {
958 warnings.push(CssWarning {
959 selector: ctx.clone(),
960 property: decl.name.clone(),
961 message: e.to_string(),
962 });
963 continue;
964 }
965 };
966 match apply_decl_for_pseudo(&ctx, &decl.name, &resolved, &m.matched_pseudo, attrs) {
967 Ok(true) => {
968 if canonical_property_name(&decl.name) == "bg" {
969 let p = &m.matched_pseudo;
970 if !p.any() {
971 plain_bg_origin = Some(m.origin);
972 } else if p.active {
973 press_bg_origin = Some(m.origin);
974 } else if !(p.checked || p.selected || p.disabled || p.drag_over) {
975 hover_bg_origin = Some(m.origin);
976 }
977 }
978 }
979 Ok(false) => warnings.push(CssWarning {
980 selector: ctx.clone(),
981 property: decl.name.clone(),
982 message: "unknown property (declaration ignored)".to_string(),
983 }),
984 Err(e) => warnings.push(CssWarning {
985 selector: ctx.clone(),
986 property: decl.name.clone(),
987 message: e.to_string(),
988 }),
989 }
990 }
991
992 restore_inline_origin(attrs, &inline_snapshot);
995
996 let authored_bg =
1009 matches!(plain_bg_origin, Some(Origin::Author)) || inline_snapshot.bg.is_some();
1010 if authored_bg {
1011 if hover_bg_origin == Some(Origin::UserAgent) {
1012 attrs.hover_bg = None;
1013 }
1014 if press_bg_origin == Some(Origin::UserAgent) {
1015 attrs.press_bg = None;
1016 }
1017 }
1018
1019 if let Some(tooltip) = attrs.tooltip.as_mut() {
1028 if tooltip.delay_ms.is_none() {
1029 tooltip.delay_ms = vars
1030 .get("lumen-tooltip-delay")
1031 .and_then(|v| v.trim().parse::<u32>().ok());
1032 }
1033 if tooltip.offset.is_none() {
1034 tooltip.offset = vars
1035 .get("lumen-tooltip-offset")
1036 .and_then(|v| v.trim().parse::<f32>().ok());
1037 }
1038 }
1039
1040 let text = InheritedText::from_computed(attrs, inherited_text);
1043 Scope { vars, text }
1044}
1045
1046#[derive(Debug, Clone, Default)]
1052struct InheritedText {
1053 text_color: Option<Rgba>,
1054 selection_color: Option<Rgba>,
1055 caret_color: Option<Rgba>,
1056 selection_text_color: Option<Rgba>,
1057 font_size: Option<f32>,
1058 font_family: Option<String>,
1059 font_weight: Option<u16>,
1060 text_align: Option<TextAlignSpec>,
1061 text_wrap: Option<TextWrapSpec>,
1062 max_lines: Option<u32>,
1063 line_height: Option<LineHeightSpec>,
1064}
1065
1066impl InheritedText {
1067 fn apply_base(&self, attrs: &mut Attributes) {
1071 if attrs.text_color.is_none() {
1072 attrs.text_color = self.text_color;
1073 }
1074 if attrs.selection_color.is_none() {
1075 attrs.selection_color = self.selection_color;
1076 }
1077 if attrs.caret_color.is_none() {
1078 attrs.caret_color = self.caret_color;
1079 }
1080 if attrs.selection_text_color.is_none() {
1081 attrs.selection_text_color = self.selection_text_color;
1082 }
1083 if attrs.font_size.is_none() {
1084 attrs.font_size = self.font_size;
1085 }
1086 if attrs.font_family.is_none() {
1087 attrs.font_family = self.font_family.clone();
1088 }
1089 if attrs.font_weight.is_none() {
1090 attrs.font_weight = self.font_weight;
1091 }
1092 if attrs.text_align.is_none() {
1093 attrs.text_align = self.text_align;
1094 }
1095 if attrs.text_wrap.is_none() {
1096 attrs.text_wrap = self.text_wrap;
1097 }
1098 if attrs.max_lines.is_none() {
1099 attrs.max_lines = self.max_lines;
1100 }
1101 if attrs.line_height.is_none() {
1102 attrs.line_height = self.line_height;
1103 }
1104 }
1105
1106 fn from_computed(attrs: &Attributes, parent: &InheritedText) -> Self {
1110 Self {
1111 text_color: attrs.text_color.or(parent.text_color),
1112 selection_color: attrs.selection_color.or(parent.selection_color),
1113 caret_color: attrs.caret_color.or(parent.caret_color),
1114 selection_text_color: attrs.selection_text_color.or(parent.selection_text_color),
1115 font_size: attrs.font_size.or(parent.font_size),
1116 font_family: attrs
1117 .font_family
1118 .clone()
1119 .or_else(|| parent.font_family.clone()),
1120 font_weight: attrs.font_weight.or(parent.font_weight),
1121 text_align: attrs.text_align.or(parent.text_align),
1122 text_wrap: attrs.text_wrap.or(parent.text_wrap),
1123 max_lines: attrs.max_lines.or(parent.max_lines),
1124 line_height: attrs.line_height.or(parent.line_height),
1125 }
1126 }
1127}
1128
1129#[derive(Debug, Clone)]
1130struct MatchedRule {
1131 rule_idx: usize,
1132 selector_idx: usize,
1133 origin: Origin,
1134 source_order: usize,
1135 specificity: Specificity,
1136 matched_pseudo: SubjectPseudo,
1137}
1138
1139#[derive(Debug, Clone, Default)]
1140struct SubjectPseudo {
1141 hover: bool,
1142 focus: bool,
1143 focus_visible: bool,
1144 active: bool,
1145 disabled: bool,
1146 checked: bool,
1147 selected: bool,
1148 drag_over: bool,
1149}
1150
1151impl SubjectPseudo {
1152 fn any(&self) -> bool {
1153 self.hover
1154 || self.focus
1155 || self.focus_visible
1156 || self.active
1157 || self.disabled
1158 || self.checked
1159 || self.selected
1160 || self.drag_over
1161 }
1162}
1163
1164fn collect_matching_rules(
1165 css: &Stylesheet,
1166 media: &MediaContext,
1167 me: &ElementRef,
1168 parents: &[ElementRef],
1169 has_element_children: bool,
1170 text_body: Option<&str>,
1171 is_root: bool,
1172) -> Vec<MatchedRule> {
1173 let subject_ctx = NodeCtx {
1174 el: me,
1175 ancestors: parents,
1176 siblings: me.siblings.as_deref(),
1177 sibling_index: me.sibling_index(),
1178 has_element_children,
1179 text_body,
1180 is_root,
1181 };
1182 let mut matched: Vec<MatchedRule> = Vec::new();
1183 for (rule_idx, rule) in css.rules.iter().enumerate() {
1184 if let Some(mq) = &rule.media
1185 && !mq.matches(media)
1186 {
1187 continue;
1188 }
1189 for (sel_idx, sel) in rule.selectors.iter().enumerate() {
1190 if let Some(subject) = match_selector(sel, &subject_ctx) {
1191 matched.push(MatchedRule {
1192 rule_idx,
1193 selector_idx: sel_idx,
1194 origin: rule.origin,
1195 source_order: rule.source_order,
1196 specificity: sel.specificity(),
1197 matched_pseudo: subject,
1198 });
1199 }
1200 }
1201 }
1202 matched.sort_by(|a, b| {
1211 a.origin
1212 .cmp(&b.origin)
1213 .then_with(|| a.specificity.cmp(&b.specificity))
1214 .then_with(|| a.source_order.cmp(&b.source_order))
1215 .then_with(|| a.selector_idx.cmp(&b.selector_idx))
1216 });
1217 matched
1218}
1219
1220fn match_selector(sel: &SelectorBuf, subject: &NodeCtx) -> Option<SubjectPseudo> {
1230 let last = sel.chain.last()?;
1231 let subject_compound = &last.1;
1232 let subject_pseudo = extract_subject_pseudo(subject_compound);
1233 if !match_compound(subject_compound, subject) {
1234 return None;
1235 }
1236 let parents = subject.ancestors;
1237 let mut anc_cursor: isize = parents.len() as isize - 1;
1243 let mut sibs: Option<&[ElementRef]> = subject.siblings;
1244 let mut sib_idx: usize = subject.sibling_index;
1245 for i in (0..sel.chain.len().saturating_sub(1)).rev() {
1246 let link = sel.chain[i + 1].0;
1250 let compound = &sel.chain[i].1;
1251 match link {
1252 Combinator::Subject => unreachable!("only leftmost is Subject"),
1253 Combinator::Descendant => {
1254 let mut found = false;
1255 while anc_cursor >= 0 {
1256 let k = anc_cursor as usize;
1257 anc_cursor -= 1;
1258 let ctx = NodeCtx::ancestor(parents, k);
1259 if match_compound(compound, &ctx) {
1260 sibs = ctx.siblings;
1261 sib_idx = ctx.sibling_index;
1262 found = true;
1263 break;
1264 }
1265 }
1266 if !found {
1267 return None;
1268 }
1269 }
1270 Combinator::Child => {
1271 if anc_cursor < 0 {
1272 return None;
1273 }
1274 let k = anc_cursor as usize;
1275 anc_cursor -= 1;
1276 let ctx = NodeCtx::ancestor(parents, k);
1277 if !match_compound(compound, &ctx) {
1278 return None;
1279 }
1280 sibs = ctx.siblings;
1281 sib_idx = ctx.sibling_index;
1282 }
1283 Combinator::AdjacentSibling | Combinator::GeneralSibling => {
1284 let list = sibs?;
1288 if sib_idx == 0 || sib_idx > list.len() {
1289 return None;
1290 }
1291 let anc_len = (anc_cursor + 1).max(0) as usize;
1292 let mut hit: Option<usize> = None;
1293 let mut j = sib_idx;
1294 while j > 0 {
1295 j -= 1;
1296 let ctx = NodeCtx {
1297 el: &list[j],
1298 ancestors: &parents[..anc_len],
1299 siblings: Some(list),
1300 sibling_index: j,
1301 has_element_children: true,
1302 text_body: None,
1303 is_root: false,
1304 };
1305 if match_compound(compound, &ctx) {
1306 hit = Some(j);
1307 break;
1308 }
1309 if link == Combinator::AdjacentSibling {
1310 break;
1311 }
1312 }
1313 sib_idx = hit?;
1314 }
1315 }
1316 }
1317 Some(subject_pseudo)
1318}
1319
1320fn extract_subject_pseudo(c: &CompoundSelector) -> SubjectPseudo {
1321 let mut sp = SubjectPseudo::default();
1322 for p in &c.pseudo_classes {
1323 match p {
1324 PseudoClass::Hover => sp.hover = true,
1325 PseudoClass::Focus => sp.focus = true,
1326 PseudoClass::FocusVisible => sp.focus_visible = true,
1327 PseudoClass::Active => sp.active = true,
1328 PseudoClass::Disabled => sp.disabled = true,
1329 PseudoClass::Checked => sp.checked = true,
1330 PseudoClass::Selected => sp.selected = true,
1331 PseudoClass::DragOver => sp.drag_over = true,
1332 _ => {}
1333 }
1334 }
1335 sp
1336}
1337
1338fn match_compound(compound: &CompoundSelector, node: &NodeCtx) -> bool {
1339 let el = node.el;
1340 if let Some(t) = &compound.tag
1341 && t != &el.tag
1342 {
1343 return false;
1344 }
1345 if let Some(id) = &compound.id
1346 && el.id.as_deref() != Some(id.as_str())
1347 {
1348 return false;
1349 }
1350 for c in &compound.classes {
1351 if !el.classes.iter().any(|x| x == c) {
1352 return false;
1353 }
1354 }
1355 for p in &compound.pseudo_classes {
1356 if !matches_pseudo(p, node) {
1357 return false;
1358 }
1359 }
1360 true
1361}
1362
1363fn matches_pseudo(p: &PseudoClass, node: &NodeCtx) -> bool {
1364 let el = node.el;
1365 let is_root = node.is_root;
1366 let has_element_children = node.has_element_children;
1367 let text_body = node.text_body;
1368 match p {
1369 PseudoClass::Hover
1373 | PseudoClass::Focus
1374 | PseudoClass::FocusVisible
1375 | PseudoClass::Active
1376 | PseudoClass::Disabled
1377 | PseudoClass::Checked
1378 | PseudoClass::Selected
1379 | PseudoClass::DragOver => true,
1380 PseudoClass::Root => is_root || el.tag == "root",
1381 PseudoClass::FirstChild => el.child_index == 1,
1382 PseudoClass::LastChild => el.child_index == el.sibling_count,
1383 PseudoClass::OnlyChild => el.sibling_count == 1,
1384 PseudoClass::Empty => {
1385 let no_text = text_body.is_none_or(|s| s.trim().is_empty());
1386 !has_element_children && no_text
1387 }
1388 PseudoClass::NthChild(anb) => anb.matches(el.child_index),
1389 PseudoClass::Is(args) => args.iter().any(|s| match_selector(s, node).is_some()),
1396 PseudoClass::Where(args) => args.iter().any(|s| match_selector(s, node).is_some()),
1397 PseudoClass::Not(args) => !args.iter().any(|s| match_selector(s, node).is_some()),
1398 }
1399}
1400
1401pub fn parse_selector_list(src: &str) -> Result<Vec<SelectorBuf>, String> {
1416 sel_parse_list(src, 0)
1417}
1418
1419pub fn selector_matches(
1429 sel: &SelectorBuf,
1430 subject: &AncestorInfo,
1431 ancestors: &[AncestorInfo],
1432) -> bool {
1433 let me = subject.to_ref();
1434 let parents: Vec<ElementRef> = ancestors.iter().map(AncestorInfo::to_ref).collect();
1435 let is_root = ancestors.is_empty();
1436 let ctx = NodeCtx {
1437 el: &me,
1438 ancestors: &parents,
1439 siblings: me.siblings.as_deref(),
1440 sibling_index: me.sibling_index(),
1441 has_element_children: subject.has_element_children,
1442 text_body: subject.text.as_deref(),
1443 is_root,
1444 };
1445 match_selector(sel, &ctx).is_some()
1446}
1447
1448const SELECTOR_MAX_NEST_DEPTH: u32 = 8;
1451
1452fn sel_parse_list(src: &str, depth: u32) -> Result<Vec<SelectorBuf>, String> {
1453 if depth > SELECTOR_MAX_NEST_DEPTH {
1454 return Err(format!(
1455 "css: selector nesting (:not/:is/:where) exceeded depth {SELECTOR_MAX_NEST_DEPTH}"
1456 ));
1457 }
1458 let trimmed = src.trim();
1459 if trimmed.is_empty() {
1460 return Err("css: empty selector".into());
1461 }
1462 let mut out = Vec::new();
1463 for piece in split_top_level_commas(trimmed) {
1464 let p = piece.trim();
1465 if p.is_empty() {
1466 return Err("css: empty selector in list".into());
1467 }
1468 out.push(sel_parse_one(p, depth)?);
1469 }
1470 Ok(out)
1471}
1472
1473fn sel_parse_one(src: &str, depth: u32) -> Result<SelectorBuf, String> {
1474 let tokens = sel_tokenize(src)?;
1475 if tokens.is_empty() {
1476 return Err(format!("css: empty selector '{src}'"));
1477 }
1478 let mut chain: Vec<(Combinator, CompoundSelector)> = Vec::new();
1479 let mut pending = Combinator::Subject;
1480 let mut i = 0usize;
1481 loop {
1482 while matches!(tokens.get(i), Some(SelTok::Whitespace)) {
1483 i += 1;
1484 }
1485 if i >= tokens.len() {
1486 break;
1487 }
1488 let (compound, consumed) = sel_read_compound(&tokens[i..], depth)?;
1489 if compound.tag.is_none()
1490 && compound.id.is_none()
1491 && compound.classes.is_empty()
1492 && compound.pseudo_classes.is_empty()
1493 {
1494 return Err(format!("css: empty compound in '{src}'"));
1495 }
1496 chain.push((pending, compound));
1497 i += consumed;
1498 let mut ws_seen = false;
1499 while matches!(tokens.get(i), Some(SelTok::Whitespace)) {
1500 ws_seen = true;
1501 i += 1;
1502 }
1503 if i >= tokens.len() {
1504 break;
1505 }
1506 pending = match tokens.get(i) {
1507 Some(SelTok::ChildCombinator) => {
1508 i += 1;
1509 Combinator::Child
1510 }
1511 Some(SelTok::AdjacentSibling) => {
1512 i += 1;
1513 Combinator::AdjacentSibling
1514 }
1515 Some(SelTok::GeneralSibling) => {
1516 i += 1;
1517 Combinator::GeneralSibling
1518 }
1519 _ if ws_seen => Combinator::Descendant,
1520 _ => Combinator::Descendant,
1521 };
1522 while matches!(tokens.get(i), Some(SelTok::Whitespace)) {
1523 i += 1;
1524 }
1525 }
1526 if chain.is_empty() {
1527 return Err(format!("css: empty selector '{src}'"));
1528 }
1529 Ok(SelectorBuf { chain })
1530}
1531
1532#[derive(Debug, Clone)]
1533enum SelTok {
1534 Whitespace,
1535 Tag(String),
1536 Class(String),
1537 Id(String),
1538 Pseudo(String, Option<String>),
1539 ChildCombinator,
1540 AdjacentSibling,
1541 GeneralSibling,
1542}
1543
1544fn sel_tokenize(src: &str) -> Result<Vec<SelTok>, String> {
1545 let bytes = src.as_bytes();
1546 let mut out = Vec::new();
1547 let mut i = 0;
1548 while i < bytes.len() {
1549 let c = bytes[i];
1550 match c {
1551 b' ' | b'\t' | b'\n' | b'\r' => {
1552 out.push(SelTok::Whitespace);
1553 while i < bytes.len() && matches!(bytes[i], b' ' | b'\t' | b'\n' | b'\r') {
1554 i += 1;
1555 }
1556 }
1557 b'>' => {
1558 out.push(SelTok::ChildCombinator);
1559 i += 1;
1560 }
1561 b'+' => {
1562 out.push(SelTok::AdjacentSibling);
1563 i += 1;
1564 }
1565 b'~' => {
1566 out.push(SelTok::GeneralSibling);
1567 i += 1;
1568 }
1569 b'.' => {
1570 i += 1;
1571 let s = sel_read_ident(bytes, &mut i)?;
1572 out.push(SelTok::Class(s));
1573 }
1574 b'#' => {
1575 i += 1;
1576 let s = sel_read_ident(bytes, &mut i)?;
1577 out.push(SelTok::Id(s));
1578 }
1579 b':' => {
1580 i += 1;
1581 if i < bytes.len() && bytes[i] == b':' {
1582 return Err(format!(
1583 "css: pseudo-elements not supported (got '::') in '{src}'"
1584 ));
1585 }
1586 let name = sel_read_ident(bytes, &mut i)?;
1587 let args = if i < bytes.len() && bytes[i] == b'(' {
1588 let close = sel_find_matching_paren(&src[i..])
1589 .ok_or_else(|| format!("css: unterminated '(' in pseudo ':{name}'"))?;
1590 let inner = src[i + 1..i + close].to_string();
1591 i += close + 1;
1592 Some(inner)
1593 } else {
1594 None
1595 };
1596 out.push(SelTok::Pseudo(name, args));
1597 }
1598 b'*' => {
1599 out.push(SelTok::Tag("*".into()));
1600 i += 1;
1601 }
1602 _ if sel_is_ident_start(c) => {
1603 let s = sel_read_ident(bytes, &mut i)?;
1604 out.push(SelTok::Tag(s));
1605 }
1606 other => {
1607 return Err(format!(
1608 "css: unexpected char '{}' in selector '{src}'",
1609 other as char
1610 ));
1611 }
1612 }
1613 }
1614 Ok(out)
1615}
1616
1617fn sel_is_ident_start(c: u8) -> bool {
1618 c.is_ascii_alphabetic() || c == b'_' || c == b'-'
1619}
1620
1621fn sel_is_ident_cont(c: u8) -> bool {
1622 c.is_ascii_alphanumeric() || c == b'_' || c == b'-'
1623}
1624
1625fn sel_read_ident(bytes: &[u8], i: &mut usize) -> Result<String, String> {
1626 let start = *i;
1627 while *i < bytes.len() && sel_is_ident_cont(bytes[*i]) {
1628 *i += 1;
1629 }
1630 if *i == start {
1631 return Err(format!("css: expected identifier at byte {start}"));
1632 }
1633 Ok(std::str::from_utf8(&bytes[start..*i])
1634 .map_err(|e| format!("css: non-utf8 selector: {e}"))?
1635 .to_string())
1636}
1637
1638fn sel_find_matching_paren(s: &str) -> Option<usize> {
1639 let bytes = s.as_bytes();
1640 let mut depth: i32 = 0;
1641 for (i, &c) in bytes.iter().enumerate() {
1642 match c {
1643 b'(' => depth += 1,
1644 b')' => {
1645 depth -= 1;
1646 if depth == 0 {
1647 return Some(i);
1648 }
1649 }
1650 _ => {}
1651 }
1652 }
1653 None
1654}
1655
1656fn sel_read_compound(toks: &[SelTok], depth: u32) -> Result<(CompoundSelector, usize), String> {
1657 let mut out = CompoundSelector::default();
1658 let mut i = 0;
1659 while i < toks.len() {
1660 match &toks[i] {
1661 SelTok::Tag(t) => {
1662 if out.tag.is_some() || !out.classes.is_empty() || out.id.is_some() {
1663 break;
1664 }
1665 out.tag = if t == "*" { None } else { Some(t.clone()) };
1666 i += 1;
1667 }
1668 SelTok::Class(c) => {
1669 out.classes.push(c.clone());
1670 i += 1;
1671 }
1672 SelTok::Id(id) => {
1673 out.id = Some(id.clone());
1674 i += 1;
1675 }
1676 SelTok::Pseudo(name, args) => {
1677 let p = sel_parse_pseudo(name, args.as_deref(), depth)?;
1678 out.pseudo_classes.push(p);
1679 i += 1;
1680 }
1681 _ => break,
1682 }
1683 }
1684 Ok((out, i))
1685}
1686
1687fn sel_parse_pseudo(name: &str, args: Option<&str>, depth: u32) -> Result<PseudoClass, String> {
1688 match name {
1689 "hover" => sel_no_args(name, args).map(|_| PseudoClass::Hover),
1690 "focus" => sel_no_args(name, args).map(|_| PseudoClass::Focus),
1691 "focus-visible" => sel_no_args(name, args).map(|_| PseudoClass::FocusVisible),
1692 "active" => sel_no_args(name, args).map(|_| PseudoClass::Active),
1693 "disabled" => sel_no_args(name, args).map(|_| PseudoClass::Disabled),
1694 "checked" => sel_no_args(name, args).map(|_| PseudoClass::Checked),
1695 "selected" => sel_no_args(name, args).map(|_| PseudoClass::Selected),
1696 "drag-over" => sel_no_args(name, args).map(|_| PseudoClass::DragOver),
1697 "root" => sel_no_args(name, args).map(|_| PseudoClass::Root),
1698 "first-child" => sel_no_args(name, args).map(|_| PseudoClass::FirstChild),
1699 "last-child" => sel_no_args(name, args).map(|_| PseudoClass::LastChild),
1700 "only-child" => sel_no_args(name, args).map(|_| PseudoClass::OnlyChild),
1701 "empty" => sel_no_args(name, args).map(|_| PseudoClass::Empty),
1702 "nth-child" => {
1703 let a = args.ok_or_else(|| "css: :nth-child requires '(an+b)'".to_string())?;
1704 Ok(PseudoClass::NthChild(sel_parse_anb(a)?))
1705 }
1706 "is" => {
1707 let a = args.ok_or_else(|| "css: :is requires '(selector-list)'".to_string())?;
1708 Ok(PseudoClass::Is(sel_parse_list(a, depth + 1)?))
1709 }
1710 "where" => {
1711 let a = args.ok_or_else(|| "css: :where requires '(selector-list)'".to_string())?;
1712 Ok(PseudoClass::Where(sel_parse_list(a, depth + 1)?))
1713 }
1714 "not" => {
1715 let a = args.ok_or_else(|| "css: :not requires '(selector-list)'".to_string())?;
1716 Ok(PseudoClass::Not(sel_parse_list(a, depth + 1)?))
1717 }
1718 other => Err(format!(
1719 "css: unknown pseudo-class ':{other}' (supported: :hover, :focus, :focus-visible, :active, :disabled, :checked, :selected, :drag-over, :root, :first-child, :last-child, :only-child, :empty, :nth-child, :is, :where, :not)"
1720 )),
1721 }
1722}
1723
1724fn sel_no_args(name: &str, args: Option<&str>) -> Result<(), String> {
1725 if args.is_some() {
1726 Err(format!("css: ':{name}' takes no arguments"))
1727 } else {
1728 Ok(())
1729 }
1730}
1731
1732fn sel_parse_anb(src: &str) -> Result<AnB, String> {
1733 let s = src.trim().to_ascii_lowercase();
1734 match s.as_str() {
1735 "odd" => return Ok(AnB { a: 2, b: 1 }),
1736 "even" => return Ok(AnB { a: 2, b: 0 }),
1737 _ => {}
1738 }
1739 if let Some(idx) = s.find('n') {
1740 let a_part = s[..idx].trim();
1741 let b_part = s[idx + 1..].trim();
1742 let a = if a_part.is_empty() || a_part == "+" {
1743 1
1744 } else if a_part == "-" {
1745 -1
1746 } else {
1747 a_part
1748 .parse::<i32>()
1749 .map_err(|e| format!("css: bad :nth-child a-coefficient '{a_part}': {e}"))?
1750 };
1751 let b = if b_part.is_empty() {
1752 0
1753 } else {
1754 let bp = b_part.replace(' ', "");
1755 bp.parse::<i32>()
1756 .map_err(|e| format!("css: bad :nth-child b-coefficient '{b_part}': {e}"))?
1757 };
1758 Ok(AnB { a, b })
1759 } else {
1760 let b = s
1761 .parse::<i32>()
1762 .map_err(|e| format!("css: bad :nth-child '{s}': {e}"))?;
1763 Ok(AnB { a: 0, b })
1764 }
1765}
1766
1767fn restore_inline_origin(target: &mut Attributes, inline: &Attributes) {
1770 overlay_set_fields(target, inline);
1771}
1772
1773fn overlay_set_fields(target: &mut Attributes, inline: &Attributes) {
1782 if inline.flex.is_some() {
1783 target.flex = inline.flex;
1784 }
1785 if inline.width.is_some() {
1786 target.width = inline.width;
1787 }
1788 if inline.height.is_some() {
1789 target.height = inline.height;
1790 }
1791 if inline.bg.is_some() {
1792 target.bg = inline.bg.clone();
1793 }
1794 if inline.radius.is_some() {
1795 target.radius = inline.radius;
1796 }
1797 if inline.padding.is_some() {
1798 target.padding = inline.padding;
1799 }
1800 if inline.margin.is_some() {
1801 target.margin = inline.margin;
1802 }
1803 if inline.text_color.is_some() {
1804 target.text_color = inline.text_color;
1805 }
1806 if inline.selection_color.is_some() {
1807 target.selection_color = inline.selection_color;
1808 }
1809 if inline.caret_color.is_some() {
1810 target.caret_color = inline.caret_color;
1811 }
1812 if inline.selection_text_color.is_some() {
1813 target.selection_text_color = inline.selection_text_color;
1814 }
1815 if inline.hover_bg.is_some() {
1816 target.hover_bg = inline.hover_bg;
1817 }
1818 if inline.scroll.is_some() {
1819 target.scroll = inline.scroll;
1820 }
1821 if inline.sensitivity.is_some() {
1822 target.sensitivity = inline.sensitivity;
1823 }
1824 if inline.inertia.is_some() {
1825 target.inertia = inline.inertia;
1826 }
1827 if inline.tab_index.is_some() {
1828 target.tab_index = inline.tab_index;
1829 }
1830 if inline.press_bg.is_some() {
1831 target.press_bg = inline.press_bg;
1832 }
1833 if inline.font_size.is_some() {
1834 target.font_size = inline.font_size;
1835 }
1836 if inline.font_family.is_some() {
1837 target.font_family = inline.font_family.clone();
1838 }
1839 if inline.font_weight.is_some() {
1840 target.font_weight = inline.font_weight;
1841 }
1842 if inline.knob_color.is_some() {
1843 target.knob_color = inline.knob_color;
1844 }
1845 if inline.radius_corners.is_some() {
1846 target.radius_corners = inline.radius_corners;
1847 }
1848 if inline.gap.is_some() {
1849 target.gap = inline.gap;
1850 }
1851 if inline.gap_row.is_some() {
1852 target.gap_row = inline.gap_row;
1853 }
1854 if inline.gap_column.is_some() {
1855 target.gap_column = inline.gap_column;
1856 }
1857 if inline.display.is_some() {
1858 target.display = inline.display;
1859 }
1860 if inline.grid_template.is_some() {
1861 target.grid_template = inline.grid_template.clone();
1862 }
1863 if inline.grid_row.is_some() {
1864 target.grid_row = inline.grid_row;
1865 }
1866 if inline.grid_column.is_some() {
1867 target.grid_column = inline.grid_column;
1868 }
1869 if inline.align_self.is_some() {
1870 target.align_self = inline.align_self;
1871 }
1872 if inline.justify_items.is_some() {
1873 target.justify_items = inline.justify_items;
1874 }
1875 if inline.justify_self.is_some() {
1876 target.justify_self = inline.justify_self;
1877 }
1878 if inline.grow.is_some() {
1879 target.grow = inline.grow;
1880 }
1881 if inline.align.is_some() {
1882 target.align = inline.align;
1883 }
1884 if inline.justify.is_some() {
1885 target.justify = inline.justify;
1886 }
1887 if inline.text_align.is_some() {
1888 target.text_align = inline.text_align;
1889 }
1890 if inline.focus_outline.is_some() {
1891 target.focus_outline = inline.focus_outline;
1892 }
1893 if inline.border_width.is_some() {
1894 target.border_width = inline.border_width;
1895 }
1896 if inline.border_color.is_some() {
1897 target.border_color = inline.border_color;
1898 }
1899 if inline.border_style.is_some() {
1900 target.border_style = inline.border_style;
1901 }
1902 if inline.box_sizing.is_some() {
1903 target.box_sizing = inline.box_sizing;
1904 }
1905 if inline.hover_border.is_some() {
1906 target.hover_border = inline.hover_border;
1907 }
1908 if inline.focus_border.is_some() {
1909 target.focus_border = inline.focus_border;
1910 }
1911 if inline.shrink.is_some() {
1912 target.shrink = inline.shrink;
1913 }
1914 if inline.basis.is_some() {
1915 target.basis = inline.basis;
1916 }
1917 if inline.flex_wrap.is_some() {
1918 target.flex_wrap = inline.flex_wrap;
1919 }
1920 if inline.align_content.is_some() {
1921 target.align_content = inline.align_content;
1922 }
1923 if inline.z_index.is_some() {
1924 target.z_index = inline.z_index;
1925 }
1926 if inline.gap_pct.is_some() {
1927 target.gap_pct = inline.gap_pct;
1928 }
1929 if inline.gap_row_pct.is_some() {
1930 target.gap_row_pct = inline.gap_row_pct;
1931 }
1932 if inline.gap_column_pct.is_some() {
1933 target.gap_column_pct = inline.gap_column_pct;
1934 }
1935 if inline.text_wrap.is_some() {
1936 target.text_wrap = inline.text_wrap;
1937 }
1938 if inline.max_lines.is_some() {
1939 target.max_lines = inline.max_lines;
1940 }
1941 if inline.position.is_some() {
1942 target.position = inline.position;
1943 }
1944 if inline.inset.is_some() {
1945 target.inset = inline.inset;
1946 }
1947 if inline.min_width.is_some() {
1948 target.min_width = inline.min_width;
1949 }
1950 if inline.min_height.is_some() {
1951 target.min_height = inline.min_height;
1952 }
1953 if inline.max_width.is_some() {
1954 target.max_width = inline.max_width;
1955 }
1956 if inline.max_height.is_some() {
1957 target.max_height = inline.max_height;
1958 }
1959 if inline.aspect_ratio.is_some() {
1960 target.aspect_ratio = inline.aspect_ratio;
1961 }
1962 if inline.opacity.is_some() {
1963 target.opacity = inline.opacity;
1964 }
1965 if inline.image_fit.is_some() {
1966 target.image_fit = inline.image_fit;
1967 }
1968 if inline.bg_fit.is_some() {
1969 target.bg_fit = inline.bg_fit;
1970 }
1971 if inline.style_role.is_some() {
1972 target.style_role = inline.style_role.clone();
1973 }
1974 if inline.draggable {
1975 target.draggable = true;
1976 }
1977 if !inline.shadows.is_empty() {
1978 target.shadows = inline.shadows.clone();
1979 }
1980 if !inline.transitions.is_empty() {
1981 target.transitions = inline.transitions.clone();
1982 }
1983 if inline.transition_property.is_some() {
1984 target.transition_property = inline.transition_property.clone();
1985 }
1986 if inline.transition_duration.is_some() {
1987 target.transition_duration = inline.transition_duration.clone();
1988 }
1989 if inline.transition_timing.is_some() {
1990 target.transition_timing = inline.transition_timing.clone();
1991 }
1992 if inline.scrollbar_color.is_some() {
1993 target.scrollbar_color = inline.scrollbar_color;
1994 }
1995 if inline.scrollbar_width.is_some() {
1996 target.scrollbar_width = inline.scrollbar_width;
1997 }
1998 if inline.overflow.is_some() {
1999 target.overflow = inline.overflow;
2000 }
2001 if inline.overflow_x.is_some() {
2002 target.overflow_x = inline.overflow_x;
2003 }
2004 if inline.overflow_y.is_some() {
2005 target.overflow_y = inline.overflow_y;
2006 }
2007 if inline.line_height.is_some() {
2008 target.line_height = inline.line_height;
2009 }
2010 if inline.text_overflow.is_some() {
2011 target.text_overflow = inline.text_overflow;
2012 }
2013 if inline.layout_boundary {
2014 target.layout_boundary = true;
2015 }
2016 if inline.border_color_top.is_some() {
2017 target.border_color_top = inline.border_color_top;
2018 }
2019 if inline.border_color_right.is_some() {
2020 target.border_color_right = inline.border_color_right;
2021 }
2022 if inline.border_color_bottom.is_some() {
2023 target.border_color_bottom = inline.border_color_bottom;
2024 }
2025 if inline.border_color_left.is_some() {
2026 target.border_color_left = inline.border_color_left;
2027 }
2028 if inline.outline_offset.is_some() {
2029 target.outline_offset = inline.outline_offset;
2030 }
2031 if inline.focus_visible_outline.is_some() {
2032 target.focus_visible_outline = inline.focus_visible_outline;
2033 }
2034 if inline.caret_width.is_some() {
2035 target.caret_width = inline.caret_width;
2036 }
2037 if inline.caret_blink_ms.is_some() {
2038 target.caret_blink_ms = inline.caret_blink_ms;
2039 }
2040 if inline.password_character.is_some() {
2041 target.password_character = inline.password_character;
2042 }
2043 if inline.knob_inset.is_some() {
2044 target.knob_inset = inline.knob_inset;
2045 }
2046 if inline.thumb_size.is_some() {
2047 target.thumb_size = inline.thumb_size;
2048 }
2049 if inline.popup_gap.is_some() {
2050 target.popup_gap = inline.popup_gap;
2051 }
2052 if inline.disabled_opacity_default.is_some() {
2053 target.disabled_opacity_default = inline.disabled_opacity_default;
2054 }
2055 if inline.progress_duration.is_some() {
2056 target.progress_duration = inline.progress_duration;
2057 }
2058 if inline.progress_chunk.is_some() {
2059 target.progress_chunk = inline.progress_chunk;
2060 }
2061 if inline.scrollbar_thickness.is_some() {
2062 target.scrollbar_thickness = inline.scrollbar_thickness;
2063 }
2064 if inline.scrollbar_thickness_thin.is_some() {
2065 target.scrollbar_thickness_thin = inline.scrollbar_thickness_thin;
2066 }
2067 if inline.scrollbar_margin.is_some() {
2068 target.scrollbar_margin = inline.scrollbar_margin;
2069 }
2070 if inline.scrollbar_min_thumb.is_some() {
2071 target.scrollbar_min_thumb = inline.scrollbar_min_thumb;
2072 }
2073 if inline.scrollbar_track_hover.is_some() {
2074 target.scrollbar_track_hover = inline.scrollbar_track_hover;
2075 }
2076 if inline.scrollbar_hover_boost.is_some() {
2077 target.scrollbar_hover_boost = inline.scrollbar_hover_boost;
2078 }
2079 if inline.scrollbar_fade_delay_ms.is_some() {
2080 target.scrollbar_fade_delay_ms = inline.scrollbar_fade_delay_ms;
2081 }
2082 if inline.scrollbar_fade_duration_ms.is_some() {
2083 target.scrollbar_fade_duration_ms = inline.scrollbar_fade_duration_ms;
2084 }
2085 if inline.hover_text_color.is_some() {
2091 target.hover_text_color = inline.hover_text_color;
2092 }
2093 if inline.active_text_color.is_some() {
2094 target.active_text_color = inline.active_text_color;
2095 }
2096 if inline.focus_text_color.is_some() {
2097 target.focus_text_color = inline.focus_text_color;
2098 }
2099 if inline.focus_visible_text_color.is_some() {
2100 target.focus_visible_text_color = inline.focus_visible_text_color;
2101 }
2102 if inline.disabled_text_color.is_some() {
2103 target.disabled_text_color = inline.disabled_text_color;
2104 }
2105 if inline.drag_over_text_color.is_some() {
2106 target.drag_over_text_color = inline.drag_over_text_color;
2107 }
2108 if inline.hover_opacity.is_some() {
2109 target.hover_opacity = inline.hover_opacity;
2110 }
2111 if inline.active_opacity.is_some() {
2112 target.active_opacity = inline.active_opacity;
2113 }
2114 if inline.focus_opacity.is_some() {
2115 target.focus_opacity = inline.focus_opacity;
2116 }
2117 if inline.focus_visible_opacity.is_some() {
2118 target.focus_visible_opacity = inline.focus_visible_opacity;
2119 }
2120 if inline.disabled_opacity.is_some() {
2121 target.disabled_opacity = inline.disabled_opacity;
2122 }
2123 if inline.drag_over_opacity.is_some() {
2124 target.drag_over_opacity = inline.drag_over_opacity;
2125 }
2126 if inline.hover_shadows.is_some() {
2127 target.hover_shadows = inline.hover_shadows.clone();
2128 }
2129 if inline.active_shadows.is_some() {
2130 target.active_shadows = inline.active_shadows.clone();
2131 }
2132 if inline.focus_shadows.is_some() {
2133 target.focus_shadows = inline.focus_shadows.clone();
2134 }
2135 if inline.focus_visible_shadows.is_some() {
2136 target.focus_visible_shadows = inline.focus_visible_shadows.clone();
2137 }
2138 if inline.disabled_shadows.is_some() {
2139 target.disabled_shadows = inline.disabled_shadows.clone();
2140 }
2141 if inline.drag_over_shadows.is_some() {
2142 target.drag_over_shadows = inline.drag_over_shadows.clone();
2143 }
2144 if inline.checked_bg.is_some() {
2145 target.checked_bg = inline.checked_bg;
2146 }
2147 if inline.selected_bg.is_some() {
2148 target.selected_bg = inline.selected_bg;
2149 }
2150 if inline.disabled_bg.is_some() {
2151 target.disabled_bg = inline.disabled_bg;
2152 }
2153 if inline.drag_over_bg.is_some() {
2154 target.drag_over_bg = inline.drag_over_bg;
2155 }
2156}
2157
2158pub fn reapply_detached(el: &mut Element, css: &Stylesheet, media: &MediaContext) {
2170 let mut seed = Scope::default();
2171 for rule in &css.rules {
2172 for decl in &rule.declarations {
2173 if let Some(name) = decl.name.strip_prefix("--") {
2174 seed.vars
2175 .entry(name.to_string())
2176 .or_insert_with(|| decl.value.clone());
2177 }
2178 }
2179 }
2180 let node = AncestorInfo::new(
2181 el.tag.clone(),
2182 el.attrs.classes.clone(),
2183 el.attrs.id.clone(),
2184 )
2185 .with_content(!el.children.is_empty(), el.attrs.text.clone())
2186 .with_declarations(el.attrs.markup_styles.clone());
2187 let mut walk = RestyleWalk {
2188 css,
2189 media,
2190 parents: Vec::new(),
2191 scopes: vec![seed],
2192 last: None,
2193 warnings: Vec::new(),
2194 };
2195 let resolved = walk.resolve(&node);
2196 overlay_set_fields(&mut el.attrs, &resolved);
2197}
2198
2199#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2205pub struct SiblingPosition {
2206 pub child_index: i32,
2208 pub sibling_count: i32,
2210}
2211
2212impl SiblingPosition {
2213 pub fn new(child_index: i32, sibling_count: i32) -> Self {
2215 Self {
2216 child_index,
2217 sibling_count,
2218 }
2219 }
2220}
2221
2222impl Default for SiblingPosition {
2223 fn default() -> Self {
2226 Self::new(1, 1)
2227 }
2228}
2229
2230#[derive(Clone)]
2234pub struct Siblings(Rc<[ElementRef]>);
2235
2236impl Siblings {
2237 pub fn new(items: impl IntoIterator<Item = (String, Vec<String>, Option<String>)>) -> Self {
2239 let items: Vec<(String, Vec<String>, Option<String>)> = items.into_iter().collect();
2240 let count = items.len() as i32;
2241 Self(
2242 items
2243 .into_iter()
2244 .enumerate()
2245 .map(|(i, (tag, classes, id))| ElementRef {
2246 tag,
2247 classes,
2248 id,
2249 child_index: i as i32 + 1,
2250 sibling_count: count,
2251 siblings: None,
2252 })
2253 .collect(),
2254 )
2255 }
2256}
2257
2258impl std::fmt::Debug for Siblings {
2259 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2260 f.debug_list()
2261 .entries(self.0.iter().map(|s| (&s.tag, &s.classes, &s.id)))
2262 .finish()
2263 }
2264}
2265
2266#[derive(Debug, Clone)]
2271pub struct AncestorInfo {
2272 pub tag: String,
2276 pub classes: Vec<String>,
2278 pub id: Option<String>,
2280 pub child_index: i32,
2284 pub sibling_count: i32,
2286 pub siblings: Option<Siblings>,
2289 pub has_element_children: bool,
2291 pub text: Option<String>,
2293 pub declarations: Vec<(String, String)>,
2297}
2298
2299impl AncestorInfo {
2300 pub fn new(tag: impl Into<String>, classes: Vec<String>, id: Option<String>) -> Self {
2304 Self {
2305 tag: tag.into(),
2306 classes,
2307 id,
2308 child_index: 1,
2309 sibling_count: 1,
2310 siblings: None,
2311 has_element_children: true,
2312 text: None,
2313 declarations: Vec::new(),
2314 }
2315 }
2316
2317 pub fn with_position(mut self, position: SiblingPosition) -> Self {
2320 self.child_index = position.child_index;
2321 self.sibling_count = position.sibling_count;
2322 self
2323 }
2324
2325 pub fn with_siblings(mut self, siblings: Option<Siblings>) -> Self {
2327 self.siblings = siblings;
2328 self
2329 }
2330
2331 pub fn with_content(mut self, has_element_children: bool, text: Option<String>) -> Self {
2334 self.has_element_children = has_element_children;
2335 self.text = text;
2336 self
2337 }
2338
2339 pub fn with_declarations(mut self, declarations: Vec<(String, String)>) -> Self {
2341 self.declarations = declarations;
2342 self
2343 }
2344
2345 fn to_ref(&self) -> ElementRef {
2346 ElementRef {
2347 tag: self.tag.clone(),
2348 classes: self.classes.clone(),
2349 id: self.id.clone(),
2350 child_index: self.child_index,
2351 sibling_count: self.sibling_count,
2352 siblings: self.siblings.as_ref().map(|s| s.0.clone()),
2353 }
2354 }
2355}
2356
2357pub struct RestyleWalk<'a> {
2368 css: &'a Stylesheet,
2369 media: &'a MediaContext,
2370 parents: Vec<ElementRef>,
2372 scopes: Vec<Scope>,
2374 last: Option<(ElementRef, Scope)>,
2376 warnings: Vec<CssWarning>,
2377}
2378
2379impl<'a> RestyleWalk<'a> {
2380 pub fn new(css: &'a Stylesheet, media: &'a MediaContext, ancestors: &[AncestorInfo]) -> Self {
2385 let mut walk = Self {
2386 css,
2387 media,
2388 parents: Vec::with_capacity(ancestors.len() + 8),
2389 scopes: vec![Scope::default()],
2390 last: None,
2391 warnings: Vec::new(),
2392 };
2393 for ancestor in ancestors {
2394 walk.resolve(ancestor);
2395 walk.enter();
2396 }
2397 walk
2398 }
2399
2400 pub fn resolve(&mut self, node: &AncestorInfo) -> Attributes {
2405 let mut attrs = Attributes {
2406 classes: node.classes.clone(),
2407 id: node.id.clone(),
2408 ..Default::default()
2409 };
2410 for (property, value) in &node.declarations {
2411 match apply_declaration("markup", property, value, &mut attrs) {
2412 Ok(true) => {}
2413 Ok(false) => self.warnings.push(CssWarning {
2414 selector: format!("<{}>", node.tag),
2415 property: property.clone(),
2416 message: "unknown property (declaration ignored)".to_string(),
2417 }),
2418 Err(e) => self.warnings.push(CssWarning {
2419 selector: format!("<{}>", node.tag),
2420 property: property.clone(),
2421 message: e.to_string(),
2422 }),
2423 }
2424 }
2425 let me = node.to_ref();
2426 let scope = cascade_one(
2427 &mut attrs,
2428 self.css,
2429 self.media,
2430 self.scopes.last().expect("a walk always has a scope"),
2431 &self.parents,
2432 &me,
2433 node.has_element_children,
2434 node.text.as_deref(),
2435 &mut self.warnings,
2436 );
2437 self.last = Some((me, scope));
2438 attrs
2439 }
2440
2441 pub fn enter(&mut self) {
2443 let (me, scope) = self
2444 .last
2445 .take()
2446 .expect("enter() follows the resolve() of the element entered");
2447 self.parents.push(me);
2448 self.scopes.push(scope);
2449 }
2450
2451 pub fn leave(&mut self) {
2453 self.parents.pop();
2454 if self.scopes.len() > 1 {
2455 self.scopes.pop();
2456 }
2457 self.last = None;
2458 }
2459
2460 pub fn take_warnings(&mut self) -> Vec<CssWarning> {
2462 let mut seen = std::collections::HashSet::new();
2463 let mut out = std::mem::take(&mut self.warnings);
2464 out.retain(|w| seen.insert((w.selector.clone(), w.property.clone(), w.message.clone())));
2465 out
2466 }
2467}
2468
2469pub fn resolve_element(
2473 node: &AncestorInfo,
2474 css: &Stylesheet,
2475 media: &MediaContext,
2476 ancestors: &[AncestorInfo],
2477) -> Attributes {
2478 RestyleWalk::new(css, media, ancestors).resolve(node)
2479}
2480
2481pub fn apply_inline_declaration(
2490 name: &str,
2491 value: &str,
2492 attrs: &mut Attributes,
2493) -> Result<bool, ParseError> {
2494 apply_declaration("inline-style", name, value, attrs)
2495}
2496
2497pub fn apply_markup_declaration(
2509 name: &str,
2510 value: &str,
2511 attrs: &mut Attributes,
2512) -> Result<bool, ParseError> {
2513 let applied = apply_declaration("markup", name, value, attrs)?;
2514 if applied {
2515 let property = canonical_style_property(name).unwrap_or(name);
2516 attrs
2517 .markup_styles
2518 .push((property.to_string(), value.to_string()));
2519 }
2520 Ok(applied)
2521}
2522
2523pub fn computed_property(attrs: &Attributes, name: &str) -> Option<String> {
2529 fn hex(c: &Rgba) -> String {
2530 let to = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
2531 let (r, g, b, a) = (to(c.r), to(c.g), to(c.b), to(c.a));
2532 if a == 0xff {
2533 format!("#{r:02x}{g:02x}{b:02x}")
2534 } else {
2535 format!("#{r:02x}{g:02x}{b:02x}{a:02x}")
2536 }
2537 }
2538 fn len(l: &LengthSpec) -> String {
2539 match l {
2540 LengthSpec::Auto => "auto".to_string(),
2541 LengthSpec::Px(v) => format!("{v}px"),
2542 LengthSpec::Percent(v) => format!("{v}%"),
2543 }
2544 }
2545 fn edges(e: &Edges) -> String {
2546 format!("{}px {}px {}px {}px", e.top, e.right, e.bottom, e.left)
2547 }
2548 match canonical_property_name(name) {
2549 "text-color" => attrs.text_color.as_ref().map(hex),
2550 "selection-color" => attrs.selection_color.as_ref().map(hex),
2551 "caret-color" => attrs.caret_color.as_ref().map(hex),
2552 "bg" => attrs.bg.as_ref().and_then(|b| match b {
2553 BgSpec::Solid(c) => Some(hex(c)),
2554 BgSpec::Image(path) => Some(format!("url(\"{path}\")")),
2555 _ => None,
2556 }),
2557 "width" => attrs.width.as_ref().map(len),
2558 "height" => attrs.height.as_ref().map(len),
2559 "padding" => attrs.padding.as_ref().map(edges),
2560 "margin" => attrs.margin.as_ref().map(edges),
2561 "font-size" => attrs.font_size.map(|v| format!("{v}px")),
2562 "font-weight" => attrs.font_weight.map(|v| v.to_string()),
2563 "font-family" => attrs.font_family.clone(),
2564 "text-align" => attrs.text_align.map(|a| match a {
2565 TextAlignSpec::Start => "start".to_string(),
2566 TextAlignSpec::Center => "center".to_string(),
2567 TextAlignSpec::End => "end".to_string(),
2568 }),
2569 "opacity" => attrs.opacity.map(|v| v.to_string()),
2570 "radius" => attrs.radius.map(|v| format!("{v}px")),
2571 _ => None,
2572 }
2573}
2574
2575pub const COMPUTED_STYLE_PROPERTIES: &[&str] = &[
2579 "width",
2580 "height",
2581 "padding",
2582 "margin",
2583 "bg",
2584 "text-color",
2585 "selection-color",
2586 "caret-color",
2587 "font-size",
2588 "font-weight",
2589 "font-family",
2590 "text-align",
2591 "opacity",
2592 "radius",
2593];
2594
2595pub fn computed_style_map(attrs: &Attributes) -> Vec<(String, String)> {
2600 COMPUTED_STYLE_PROPERTIES
2601 .iter()
2602 .filter_map(|&p| computed_property(attrs, p).map(|v| (p.to_string(), v)))
2603 .collect()
2604}
2605
2606#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2615pub struct WebNames<'a> {
2616 pub tag_class_prefix: &'a str,
2619 pub selected: &'a str,
2621 pub checked: &'a str,
2624 pub disabled: &'a str,
2627 pub drag_over: &'a str,
2629 pub checkbox_part: &'a str,
2632 pub radio_part: &'a str,
2634}
2635
2636enum Spelling<'a> {
2638 Lumen,
2640 Web(WebNames<'a>),
2642}
2643
2644pub fn selector_to_css(sel: &SelectorBuf) -> String {
2649 let mut out = String::new();
2650 write_selector(&mut out, sel, &Spelling::Lumen);
2651 out
2652}
2653
2654pub fn selector_to_web(sel: &SelectorBuf, names: &WebNames<'_>) -> String {
2675 let mut out = String::new();
2676 write_selector(&mut out, sel, &Spelling::Web(*names));
2677 out
2678}
2679
2680fn write_selector(out: &mut String, sel: &SelectorBuf, spelling: &Spelling<'_>) {
2681 for (comb, comp) in &sel.chain {
2682 match comb {
2683 Combinator::Subject => {}
2684 Combinator::Descendant => out.push(' '),
2685 Combinator::Child => out.push_str(" > "),
2686 Combinator::AdjacentSibling => out.push_str(" + "),
2687 Combinator::GeneralSibling => out.push_str(" ~ "),
2688 }
2689 write_compound(out, comp, spelling);
2690 }
2691}
2692
2693fn write_compound(out: &mut String, c: &CompoundSelector, spelling: &Spelling<'_>) {
2694 let part = match (spelling, c.tag.as_deref()) {
2698 (Spelling::Web(names), Some("checkbox")) => Some(names.checkbox_part),
2699 (Spelling::Web(names), Some("radio")) => Some(names.radio_part),
2700 _ => None,
2701 };
2702 if let Some(tag) = &c.tag {
2703 match spelling {
2704 Spelling::Lumen => out.push_str(tag),
2705 Spelling::Web(names) => {
2706 out.push_str(":where(.");
2707 out.push_str(names.tag_class_prefix);
2708 out.push_str(tag);
2709 out.push(')');
2710 }
2711 }
2712 } else if c.id.is_none() && c.classes.is_empty() && c.pseudo_classes.is_empty() {
2713 out.push('*');
2714 }
2715 if let Some(id) = &c.id {
2716 out.push('#');
2717 out.push_str(id);
2718 }
2719 for class in &c.classes {
2720 out.push('.');
2721 out.push_str(class);
2722 }
2723 let (on_control, on_element): (Vec<&PseudoClass>, Vec<&PseudoClass>) = c
2724 .pseudo_classes
2725 .iter()
2726 .partition(|p| part.is_some() && matches!(p, PseudoClass::Checked | PseudoClass::Selected));
2727 for p in on_element {
2728 match p {
2729 PseudoClass::Focus if part.is_some() => out.push_str(":focus-within"),
2732 PseudoClass::FocusVisible if part.is_some() => out.push_str(":has(:focus-visible)"),
2733 _ => write_pseudo(out, p, spelling),
2734 }
2735 }
2736 if let Some(part) = part.filter(|_| !on_control.is_empty()) {
2737 out.push_str(" > .");
2738 out.push_str(part);
2739 for p in on_control {
2740 write_pseudo(out, p, spelling);
2741 }
2742 }
2743}
2744
2745fn write_pseudo(out: &mut String, p: &PseudoClass, spelling: &Spelling<'_>) {
2746 let names = match spelling {
2747 Spelling::Lumen => None,
2748 Spelling::Web(names) => Some(names),
2749 };
2750 match p {
2751 PseudoClass::Hover => out.push_str(":hover"),
2752 PseudoClass::Focus => out.push_str(":focus"),
2753 PseudoClass::FocusVisible => out.push_str(":focus-visible"),
2754 PseudoClass::Active => out.push_str(":active"),
2755 PseudoClass::Disabled => match names {
2756 Some(names) => {
2757 out.push_str(":is(:disabled, [");
2758 out.push_str(names.disabled);
2759 out.push_str("])");
2760 }
2761 None => out.push_str(":disabled"),
2762 },
2763 PseudoClass::Checked => match names {
2764 Some(names) => {
2765 out.push_str(":is(:checked, [");
2766 out.push_str(names.checked);
2767 out.push_str("])");
2768 }
2769 None => out.push_str(":checked"),
2770 },
2771 PseudoClass::Selected => match names {
2772 Some(names) => {
2773 out.push('[');
2774 out.push_str(names.selected);
2775 out.push(']');
2776 }
2777 None => out.push_str(":selected"),
2778 },
2779 PseudoClass::DragOver => match names {
2780 Some(names) => {
2781 out.push('[');
2782 out.push_str(names.drag_over);
2783 out.push(']');
2784 }
2785 None => out.push_str(":drag-over"),
2786 },
2787 PseudoClass::Root => out.push_str(":root"),
2788 PseudoClass::FirstChild => out.push_str(":first-child"),
2789 PseudoClass::LastChild => out.push_str(":last-child"),
2790 PseudoClass::OnlyChild => out.push_str(":only-child"),
2791 PseudoClass::Empty => out.push_str(":empty"),
2792 PseudoClass::NthChild(anb) => {
2793 out.push_str(":nth-child(");
2794 out.push_str(&anb_to_css(*anb));
2795 out.push(')');
2796 }
2797 PseudoClass::Is(args) => write_pseudo_list(out, ":is(", args, spelling),
2798 PseudoClass::Where(args) => write_pseudo_list(out, ":where(", args, spelling),
2799 PseudoClass::Not(args) => write_pseudo_list(out, ":not(", args, spelling),
2800 }
2801}
2802
2803fn write_pseudo_list(out: &mut String, open: &str, args: &[SelectorBuf], spelling: &Spelling<'_>) {
2804 out.push_str(open);
2805 for (i, arg) in args.iter().enumerate() {
2806 if i > 0 {
2807 out.push_str(", ");
2808 }
2809 write_selector(out, arg, spelling);
2810 }
2811 out.push(')');
2812}
2813
2814fn anb_to_css(anb: AnB) -> String {
2817 if anb.a == 0 {
2818 return anb.b.to_string();
2819 }
2820 let sign = if anb.b < 0 { '-' } else { '+' };
2821 format!("{}n{sign}{}", anb.a, anb.b.unsigned_abs())
2822}
2823
2824pub fn media_query_to_css(mq: &MediaQuery) -> String {
2833 fn feature(f: &MediaFeature) -> String {
2834 match f {
2835 MediaFeature::PrefersColorScheme(v) => {
2836 let v = match v {
2837 ColorSchemePreference::Dark => "dark",
2838 ColorSchemePreference::Light => "light",
2839 ColorSchemePreference::NoPreference => "no-preference",
2840 };
2841 format!("(prefers-color-scheme: {v})")
2842 }
2843 MediaFeature::PrefersReducedMotion(v) => {
2844 let v = match v {
2845 MotionPreference::Reduce => "reduce",
2846 MotionPreference::NoPreference => "no-preference",
2847 };
2848 format!("(prefers-reduced-motion: {v})")
2849 }
2850 MediaFeature::PrefersContrast(v) => {
2851 let v = match v {
2852 ContrastPreference::More => "more",
2853 ContrastPreference::Less => "less",
2854 ContrastPreference::Custom => "custom",
2855 ContrastPreference::NoPreference => "no-preference",
2856 };
2857 format!("(prefers-contrast: {v})")
2858 }
2859 MediaFeature::MinWidth(px) => format!("(min-width: {px}px)"),
2860 MediaFeature::MaxWidth(px) => format!("(max-width: {px}px)"),
2861 MediaFeature::Width(px) => format!("(width: {px}px)"),
2862 }
2863 }
2864 if mq.features.is_empty() {
2865 return "all".to_string();
2866 }
2867 mq.features
2868 .iter()
2869 .map(feature)
2870 .collect::<Vec<_>>()
2871 .join(" and ")
2872}
2873
2874#[derive(Debug, Clone)]
2877pub struct MatchedRuleInfo {
2878 pub selector: String,
2881 pub specificity: Specificity,
2883 pub origin: Origin,
2885 pub source_order: usize,
2887 pub declarations: Vec<(String, String)>,
2890}
2891
2892pub fn matched_rules_for(
2898 subject: &AncestorInfo,
2899 css: &Stylesheet,
2900 media: &MediaContext,
2901 ancestors: &[AncestorInfo],
2902 has_element_children: bool,
2903 text_body: Option<&str>,
2904) -> Vec<MatchedRuleInfo> {
2905 let me = subject.to_ref();
2906 let parents: Vec<ElementRef> = ancestors.iter().map(AncestorInfo::to_ref).collect();
2907 let is_root = ancestors.is_empty();
2908 let matched = collect_matching_rules(
2909 css,
2910 media,
2911 &me,
2912 &parents,
2913 has_element_children,
2914 text_body,
2915 is_root,
2916 );
2917 matched
2918 .into_iter()
2919 .map(|m| {
2920 let rule = &css.rules[m.rule_idx];
2921 MatchedRuleInfo {
2922 selector: selector_to_css(&rule.selectors[m.selector_idx]),
2923 specificity: m.specificity,
2924 origin: m.origin,
2925 source_order: m.source_order,
2926 declarations: rule
2927 .declarations
2928 .iter()
2929 .map(|d| (d.name.clone(), d.value.clone()))
2930 .collect(),
2931 }
2932 })
2933 .collect()
2934}
2935
2936fn resolve_vars(
2937 ctx: &str,
2938 decl_name: &str,
2939 value: &str,
2940 vars: &std::collections::HashMap<String, String>,
2941) -> Result<String, ParseError> {
2942 let prefix = format!("{ctx} '{decl_name}' = '{value}': ");
2943 crate::css_vars::resolve(value, vars, &prefix).map_err(ParseError::Xml)
2944}
2945
2946fn describe_selector_for_error(sels: &[SelectorBuf], idx: usize) -> String {
2947 let Some(sel) = sels.get(idx) else {
2948 return "*".into();
2949 };
2950 let mut out = String::new();
2951 for (i, (combinator, c)) in sel.chain.iter().enumerate() {
2952 if i > 0 {
2953 match combinator {
2954 Combinator::Descendant => out.push(' '),
2955 Combinator::Child => out.push_str(" > "),
2956 Combinator::AdjacentSibling => out.push_str(" + "),
2957 Combinator::GeneralSibling => out.push_str(" ~ "),
2958 Combinator::Subject => {}
2959 }
2960 }
2961 if let Some(t) = &c.tag {
2962 out.push_str(t);
2963 }
2964 if let Some(id) = &c.id {
2965 out.push('#');
2966 out.push_str(id);
2967 }
2968 for cls in &c.classes {
2969 out.push('.');
2970 out.push_str(cls);
2971 }
2972 for p in &c.pseudo_classes {
2973 out.push(':');
2974 out.push_str(pseudo_name(p));
2975 }
2976 }
2977 if out.is_empty() {
2978 out = "*".into();
2979 }
2980 out
2981}
2982
2983fn pseudo_name(p: &PseudoClass) -> &'static str {
2984 match p {
2985 PseudoClass::Hover => "hover",
2986 PseudoClass::Focus => "focus",
2987 PseudoClass::FocusVisible => "focus-visible",
2988 PseudoClass::Active => "active",
2989 PseudoClass::Disabled => "disabled",
2990 PseudoClass::Checked => "checked",
2991 PseudoClass::Selected => "selected",
2992 PseudoClass::DragOver => "drag-over",
2993 PseudoClass::Root => "root",
2994 PseudoClass::FirstChild => "first-child",
2995 PseudoClass::LastChild => "last-child",
2996 PseudoClass::OnlyChild => "only-child",
2997 PseudoClass::Empty => "empty",
2998 PseudoClass::NthChild(_) => "nth-child",
2999 PseudoClass::Is(_) => "is",
3000 PseudoClass::Where(_) => "where",
3001 PseudoClass::Not(_) => "not",
3002 }
3003}
3004
3005fn apply_decl_for_pseudo(
3011 ctx: &str,
3012 name: &str,
3013 value: &str,
3014 pseudo: &SubjectPseudo,
3015 attrs: &mut Attributes,
3016) -> Result<bool, ParseError> {
3017 let name = canonical_property_name(name);
3018 if !pseudo.any() {
3019 return apply_declaration(ctx, name, value, attrs);
3020 }
3021 match name {
3025 "bg" => {
3026 let c = parse_color(ctx, name, value)?;
3027 if pseudo.active {
3028 attrs.press_bg = Some(c);
3029 } else if pseudo.checked {
3030 attrs.checked_bg = Some(c);
3031 } else if pseudo.selected {
3032 attrs.selected_bg = Some(c);
3033 } else if pseudo.disabled {
3034 attrs.disabled_bg = Some(c);
3035 } else if pseudo.drag_over {
3036 attrs.drag_over_bg = Some(c);
3037 } else {
3038 attrs.hover_bg = Some(c);
3041 }
3042 Ok(true)
3043 }
3044 "outline" if pseudo.focus || pseudo.focus_visible => {
3045 let parts: Vec<&str> = value.split_whitespace().collect();
3046 if parts.len() != 2 {
3047 return Err(crate::values::bad(
3048 ctx,
3049 name,
3050 value,
3051 "expected '<width-px> <#color>'".into(),
3052 ));
3053 }
3054 let spec = crate::layout_ir::OutlineSpec {
3055 width: parse_f32(ctx, name, parts[0])?,
3056 color: parse_color(ctx, name, parts[1])?,
3057 offset: attrs.outline_offset.unwrap_or(0.0),
3058 };
3059 if pseudo.focus_visible {
3060 attrs.focus_visible_outline = Some(spec);
3061 } else {
3062 attrs.focus_outline = Some(spec);
3063 }
3064 Ok(true)
3065 }
3066 "outline-offset" if pseudo.focus || pseudo.focus_visible => {
3067 let off = parse_f32(ctx, name, value)?;
3068 attrs.outline_offset = Some(off);
3069 if let Some(o) = attrs.focus_outline.as_mut() {
3072 o.offset = off;
3073 }
3074 if let Some(o) = attrs.focus_visible_outline.as_mut() {
3075 o.offset = off;
3076 }
3077 Ok(true)
3078 }
3079 "border" if pseudo.hover || pseudo.focus || pseudo.focus_visible => {
3080 let sh = crate::values::parse_border_shorthand(ctx, name, value)?;
3083 let spec = border_shorthand_to_paint(&sh);
3084 if pseudo.focus || pseudo.focus_visible {
3085 attrs.focus_border = spec;
3086 } else {
3087 attrs.hover_border = spec;
3088 }
3089 Ok(true)
3090 }
3091 "text-color" => {
3092 let c = parse_color(ctx, name, value)?;
3093 if pseudo.active {
3094 attrs.active_text_color = Some(c);
3095 } else if pseudo.disabled {
3096 attrs.disabled_text_color = Some(c);
3097 } else if pseudo.drag_over {
3098 attrs.drag_over_text_color = Some(c);
3099 } else if pseudo.focus_visible {
3100 attrs.focus_visible_text_color = Some(c);
3101 } else if pseudo.focus {
3102 attrs.focus_text_color = Some(c);
3103 } else if pseudo.hover {
3104 attrs.hover_text_color = Some(c);
3105 }
3106 Ok(true)
3107 }
3108 "opacity" => {
3109 let v = parse_f32(ctx, name, value)?.clamp(0.0, 1.0);
3110 if pseudo.active {
3111 attrs.active_opacity = Some(v);
3112 } else if pseudo.disabled {
3113 attrs.disabled_opacity = Some(v);
3114 } else if pseudo.drag_over {
3115 attrs.drag_over_opacity = Some(v);
3116 } else if pseudo.focus_visible {
3117 attrs.focus_visible_opacity = Some(v);
3118 } else if pseudo.focus {
3119 attrs.focus_opacity = Some(v);
3120 } else if pseudo.hover {
3121 attrs.hover_opacity = Some(v);
3122 }
3123 Ok(true)
3124 }
3125 "shadow" | "box-shadow" => {
3126 let shadows = parse_box_shadow(ctx, name, value)?;
3127 if pseudo.active {
3128 attrs.active_shadows = Some(shadows);
3129 } else if pseudo.disabled {
3130 attrs.disabled_shadows = Some(shadows);
3131 } else if pseudo.drag_over {
3132 attrs.drag_over_shadows = Some(shadows);
3133 } else if pseudo.focus_visible {
3134 attrs.focus_visible_shadows = Some(shadows);
3135 } else if pseudo.focus {
3136 attrs.focus_shadows = Some(shadows);
3137 } else if pseudo.hover {
3138 attrs.hover_shadows = Some(shadows);
3139 }
3140 Ok(true)
3141 }
3142 _ => Ok(true),
3145 }
3146}
3147
3148pub const STYLE_PROPERTIES: &[&str] = &[
3158 "width",
3159 "height",
3160 "bg",
3161 "radius",
3162 "border-top-left-radius",
3163 "border-top-right-radius",
3164 "border-bottom-right-radius",
3165 "border-bottom-left-radius",
3166 "padding",
3167 "margin",
3168 "text-color",
3169 "selection-color",
3170 "caret-color",
3171 "selection-text-color",
3172 "caret-width",
3173 "caret-blink",
3174 "password-character",
3175 "hover-bg",
3176 "scroll",
3177 "sensitivity",
3178 "inertia",
3179 "tab-index",
3180 "draggable",
3181 "press-bg",
3182 "font-size",
3183 "font-family",
3184 "font-weight",
3185 "line-height",
3186 "gap",
3187 "row-gap",
3188 "column-gap",
3189 "grow",
3190 "flex-shrink",
3191 "flex-basis",
3192 "flex-wrap",
3193 "align-content",
3194 "flex-direction",
3195 "flex",
3196 "z-index",
3197 "border",
3198 "border-width",
3199 "border-color",
3200 "border-top-color",
3201 "border-right-color",
3202 "border-bottom-color",
3203 "border-left-color",
3204 "border-top",
3205 "border-right",
3206 "border-bottom",
3207 "border-left",
3208 "border-style",
3209 "border-top-width",
3210 "border-right-width",
3211 "border-bottom-width",
3212 "border-left-width",
3213 "box-sizing",
3214 "hover-border",
3215 "focus-border",
3216 "focus-outline",
3217 "outline-offset",
3218 "knob-color",
3219 "knob-inset",
3220 "thumb-size",
3221 "popup-gap",
3222 "display",
3223 "grid-template-rows",
3224 "grid-template-columns",
3225 "grid-row",
3226 "grid-column",
3227 "align",
3228 "align-items",
3229 "align-self",
3230 "justify-items",
3231 "justify-self",
3232 "justify",
3233 "text-align",
3234 "wrap",
3235 "max-lines",
3236 "progress-duration",
3237 "progress-chunk",
3238 "text-overflow",
3239 "position",
3240 "inset",
3241 "min-width",
3242 "min-height",
3243 "max-width",
3244 "max-height",
3245 "aspect-ratio",
3246 "opacity",
3247 "disabled-opacity",
3248 "shadow",
3249 "box-shadow",
3250 "fit",
3251 "bg-fit",
3252 "transition",
3253 "transition-property",
3254 "transition-delay",
3255 "transition-duration",
3256 "transition-timing-function",
3257 "animation",
3258 "animation-name",
3259 "animation-duration",
3260 "animation-timing-function",
3261 "animation-delay",
3262 "animation-iteration-count",
3263 "animation-direction",
3264 "animation-fill-mode",
3265 "animation-play-state",
3266 "scrollbar-color",
3267 "scrollbar-width",
3268 "scrollbar-thickness",
3269 "scrollbar-thickness-thin",
3270 "scrollbar-margin",
3271 "scrollbar-min-thumb",
3272 "scrollbar-track-hover",
3273 "scrollbar-hover-boost",
3274 "scrollbar-fade-delay",
3275 "scrollbar-fade-duration",
3276 "padding-inline-start",
3277 "padding-inline-end",
3278 "padding-block-start",
3279 "padding-block-end",
3280 "margin-inline-start",
3281 "margin-inline-end",
3282 "margin-block-start",
3283 "margin-block-end",
3284 "inset-inline-start",
3285 "inset-inline-end",
3286 "inset-block-start",
3287 "inset-block-end",
3288 "border-inline-start-width",
3289 "border-inline-end-width",
3290 "border-block-start-width",
3291 "border-block-end-width",
3292 "overflow",
3293 "overflow-x",
3294 "overflow-y",
3295 "layout-boundary",
3296];
3297
3298pub fn is_style_property(name: &str) -> bool {
3303 canonical_style_property(name).is_some()
3304}
3305
3306pub fn canonical_style_property(name: &str) -> Option<&'static str> {
3315 let canonical = canonical_property_name(name);
3316 STYLE_PROPERTIES.iter().copied().find(|p| *p == canonical)
3317}
3318
3319pub fn canonical_property_name(name: &str) -> &str {
3322 match name {
3323 "color" => "text-color",
3324 "background" | "background-color" => "bg",
3325 "border-radius" => "radius",
3326 "flex-grow" => "grow",
3327 "justify-content" => "justify",
3332 "object-fit" => "fit",
3333 "shrink" => "flex-shrink",
3334 "white-space" => "wrap",
3337 n => n,
3338 }
3339}
3340
3341fn border_shorthand_to_paint(
3345 sh: &crate::values::BorderShorthand,
3346) -> Option<crate::layout_ir::BorderPaintSpec> {
3347 use crate::layout_ir::{BorderStyleSpec, Edges, Rgba};
3348 match sh.style {
3349 Some(BorderStyleSpec::Solid) => {
3350 let w = sh.width.unwrap_or(3.0);
3351 if w <= 0.0 {
3352 return None;
3353 }
3354 Some(crate::layout_ir::BorderPaintSpec {
3355 widths: Edges::all(w),
3356 color: sh.color.unwrap_or(Rgba {
3357 r: 0.0,
3358 g: 0.0,
3359 b: 0.0,
3360 a: 1.0,
3361 }),
3362 })
3363 }
3364 _ => None,
3365 }
3366}
3367
3368#[allow(clippy::collapsible_match, clippy::collapsible_if)]
3371fn apply_declaration(
3372 ctx: &str,
3373 name: &str,
3374 value: &str,
3375 attrs: &mut Attributes,
3376) -> Result<bool, ParseError> {
3377 let name = canonical_property_name(name);
3378 match name {
3381 "width" => attrs.width = Some(parse_length(ctx, name, value)?),
3382 "height" => attrs.height = Some(parse_length(ctx, name, value)?),
3383 "bg" => attrs.bg = Some(parse_bg(ctx, name, value)?),
3384 "radius" => {
3385 let parts: Vec<&str> = value.split_whitespace().collect();
3390 match parts.as_slice() {
3391 [one] => {
3392 attrs.radius = Some(parse_f32(ctx, name, one)?);
3393 attrs.radius_corners = None;
3394 }
3395 _ => {
3396 let corners = crate::values::parse_corner_radii(ctx, name, value)?;
3397 attrs.radius = Some(corners.iter().copied().fold(0.0_f32, f32::max));
3398 attrs.radius_corners = Some(corners);
3399 }
3400 }
3401 }
3402 "border-top-left-radius"
3403 | "border-top-right-radius"
3404 | "border-bottom-right-radius"
3405 | "border-bottom-left-radius" => {
3406 let v = parse_f32(ctx, name, value)?;
3407 let base = attrs.radius.unwrap_or(0.0);
3408 let corners = attrs.radius_corners.get_or_insert([base; 4]);
3409 match name {
3410 "border-top-left-radius" => corners[0] = v,
3411 "border-top-right-radius" => corners[1] = v,
3412 "border-bottom-right-radius" => corners[2] = v,
3413 "border-bottom-left-radius" => corners[3] = v,
3414 _ => unreachable!(),
3415 }
3416 attrs.radius = Some(corners.iter().copied().fold(0.0_f32, f32::max));
3417 }
3418 "padding" => attrs.padding = Some(parse_edges(ctx, name, value)?),
3419 "margin" => attrs.margin = Some(parse_edges(ctx, name, value)?),
3420 "text-color" => attrs.text_color = Some(parse_color(ctx, name, value)?),
3421 "selection-color" => attrs.selection_color = Some(parse_color(ctx, name, value)?),
3422 "caret-color" => attrs.caret_color = Some(parse_color(ctx, name, value)?),
3423 "selection-text-color" => attrs.selection_text_color = Some(parse_color(ctx, name, value)?),
3424 "caret-width" => attrs.caret_width = Some(parse_f32(ctx, name, value)?),
3427 "caret-blink" => attrs.caret_blink_ms = Some(parse_duration_ms(ctx, name, value.trim())?),
3428 "password-character" => {
3433 let mut chars = value.chars();
3434 let c = chars
3435 .next()
3436 .ok_or_else(|| bad(ctx, name, value, "expected a single character".into()))?;
3437 if chars.next().is_some() {
3438 return Err(bad(
3439 ctx,
3440 name,
3441 value,
3442 "expected exactly one character".into(),
3443 ));
3444 }
3445 attrs.password_character = Some(c);
3446 }
3447 "hover-bg" => attrs.hover_bg = Some(parse_color(ctx, name, value)?),
3448 "scroll" => {
3449 attrs.scroll = Some(match value {
3450 "y" => ScrollAxisSpec::Y,
3451 "x" => ScrollAxisSpec::X,
3452 "both" => ScrollAxisSpec::Both,
3453 other => {
3454 return Err(bad(
3455 ctx,
3456 name,
3457 value,
3458 format!("unknown scroll axis '{other}'"),
3459 ));
3460 }
3461 });
3462 }
3463 "sensitivity" => attrs.sensitivity = Some(parse_f32(ctx, name, value)?),
3464 "inertia" => attrs.inertia = Some(parse_f32(ctx, name, value)?),
3465 "tab-index" => attrs.tab_index = Some(parse_i32(ctx, name, value)?),
3466 "draggable" => attrs.draggable = matches!(value, "true" | "yes"),
3467 "press-bg" => attrs.press_bg = Some(parse_color(ctx, name, value)?),
3468 "font-size" => attrs.font_size = Some(parse_f32(ctx, name, value)?),
3469 "font-family" => {
3470 attrs.font_family = Some(value.trim().to_string());
3471 }
3472 "font-weight" => {
3473 attrs.font_weight = Some(crate::values::parse_font_weight(ctx, name, value)?);
3474 }
3475 "line-height" => {
3481 let v = value.trim();
3482 attrs.line_height = Some(if let Some(rest) = v.strip_suffix("px") {
3483 LineHeightSpec::Px(parse_f32(ctx, name, rest)?)
3484 } else {
3485 let n = parse_f32(ctx, name, v)?;
3486 if n < 0.0 {
3487 return Err(bad(
3488 ctx,
3489 name,
3490 value,
3491 "line-height must be \u{2265} 0".into(),
3492 ));
3493 }
3494 LineHeightSpec::Multiplier(n)
3495 });
3496 }
3497 "gap" => {
3498 let one_term = |attr_px: &mut Option<f32>, attr_pct: &mut Option<f32>, s: &str| {
3505 if let Some(rest) = s.trim().strip_suffix('%') {
3506 let p = parse_f32(ctx, name, rest)?;
3507 *attr_pct = Some(p);
3508 } else {
3509 *attr_px = Some(parse_f32(ctx, name, s)?);
3510 }
3511 Ok::<(), ParseError>(())
3512 };
3513 let parts: Vec<&str> = value.split_whitespace().collect();
3514 match parts.as_slice() {
3515 [one] => one_term(&mut attrs.gap, &mut attrs.gap_pct, one)?,
3516 [r, c] => {
3517 one_term(&mut attrs.gap_row, &mut attrs.gap_row_pct, r)?;
3518 one_term(&mut attrs.gap_column, &mut attrs.gap_column_pct, c)?;
3519 }
3520 _ => {
3521 return Err(bad(
3522 ctx,
3523 name,
3524 value,
3525 "expected '<row+col>' or '<row> <col>'".into(),
3526 ));
3527 }
3528 }
3529 }
3530 "row-gap" => {
3531 if let Some(rest) = value.trim().strip_suffix('%') {
3532 attrs.gap_row_pct = Some(parse_f32(ctx, name, rest)?);
3533 } else {
3534 attrs.gap_row = Some(parse_f32(ctx, name, value)?);
3535 }
3536 }
3537 "column-gap" => {
3538 if let Some(rest) = value.trim().strip_suffix('%') {
3539 attrs.gap_column_pct = Some(parse_f32(ctx, name, rest)?);
3540 } else {
3541 attrs.gap_column = Some(parse_f32(ctx, name, value)?);
3542 }
3543 }
3544 "grow" => attrs.grow = Some(parse_f32(ctx, name, value)?),
3545 "flex-shrink" => attrs.shrink = Some(parse_f32(ctx, name, value)?),
3546 "flex-basis" => attrs.basis = Some(parse_length(ctx, name, value)?),
3547 "flex-wrap" => {
3548 attrs.flex_wrap = Some(match value.trim() {
3549 "nowrap" => crate::layout_ir::FlexWrapSpec::NoWrap,
3550 "wrap" => crate::layout_ir::FlexWrapSpec::Wrap,
3551 "wrap-reverse" => crate::layout_ir::FlexWrapSpec::WrapReverse,
3552 other => {
3553 return Err(bad(
3554 ctx,
3555 name,
3556 value,
3557 format!("unknown flex-wrap '{other}'"),
3558 ));
3559 }
3560 });
3561 }
3562 "align-content" => {
3563 use crate::layout_ir::AlignContentSpec as A;
3564 attrs.align_content = Some(match value.trim() {
3565 "start" | "flex-start" => A::Start,
3566 "end" | "flex-end" => A::End,
3567 "center" => A::Center,
3568 "stretch" | "normal" => A::Stretch,
3569 "space-between" => A::SpaceBetween,
3570 "space-around" => A::SpaceAround,
3571 "space-evenly" => A::SpaceEvenly,
3572 other => {
3573 return Err(bad(
3574 ctx,
3575 name,
3576 value,
3577 format!("unknown align-content '{other}'"),
3578 ));
3579 }
3580 });
3581 }
3582 "flex-direction" => {
3583 attrs.flex = Some(match value.trim() {
3584 "row" => crate::layout_ir::FlexAxis::Row,
3585 "column" => crate::layout_ir::FlexAxis::Column,
3586 "row-reverse" => crate::layout_ir::FlexAxis::RowReverse,
3587 "column-reverse" => crate::layout_ir::FlexAxis::ColumnReverse,
3588 other => {
3589 return Err(bad(
3590 ctx,
3591 name,
3592 value,
3593 format!("unknown flex-direction '{other}'"),
3594 ));
3595 }
3596 });
3597 }
3598 "flex" => {
3599 let v = value.trim();
3603 match v {
3604 "none" => {
3605 attrs.grow = Some(0.0);
3606 attrs.shrink = Some(0.0);
3607 attrs.basis = Some(crate::layout_ir::LengthSpec::Auto);
3608 }
3609 "auto" => {
3610 attrs.grow = Some(1.0);
3611 attrs.shrink = Some(1.0);
3612 attrs.basis = Some(crate::layout_ir::LengthSpec::Auto);
3613 }
3614 "initial" => {
3615 attrs.grow = Some(0.0);
3616 attrs.shrink = Some(1.0);
3617 attrs.basis = Some(crate::layout_ir::LengthSpec::Auto);
3618 }
3619 _ => {
3620 let parts: Vec<&str> = v.split_whitespace().collect();
3621 match parts.as_slice() {
3622 [g] => {
3623 attrs.grow = Some(parse_f32(ctx, name, g)?);
3624 attrs.shrink = Some(1.0);
3625 attrs.basis = Some(crate::layout_ir::LengthSpec::Percent(0.0));
3626 }
3627 [g, s] => {
3628 attrs.grow = Some(parse_f32(ctx, name, g)?);
3629 if let Ok(shrink) = s.parse::<f32>() {
3632 attrs.shrink = Some(shrink);
3633 attrs.basis = Some(crate::layout_ir::LengthSpec::Percent(0.0));
3634 } else {
3635 attrs.shrink = Some(1.0);
3636 attrs.basis = Some(parse_length(ctx, name, s)?);
3637 }
3638 }
3639 [g, s, b] => {
3640 attrs.grow = Some(parse_f32(ctx, name, g)?);
3641 attrs.shrink = Some(parse_f32(ctx, name, s)?);
3642 attrs.basis = Some(parse_length(ctx, name, b)?);
3643 }
3644 _ => {
3645 return Err(bad(
3646 ctx,
3647 name,
3648 value,
3649 "expected 'flex: <grow> [<shrink>] [<basis>]'".into(),
3650 ));
3651 }
3652 }
3653 }
3654 }
3655 }
3656 "z-index" => {
3657 attrs.z_index = Some(if value.trim() == "auto" {
3658 0
3659 } else {
3660 parse_i32(ctx, name, value)?
3661 });
3662 }
3663 "border" => {
3664 let sh = crate::values::parse_border_shorthand(ctx, name, value)?;
3665 attrs.border_style = sh.style;
3669 attrs.border_width = sh.width.map(crate::layout_ir::Edges::all);
3670 attrs.border_color = sh.color;
3671 }
3672 "border-width" => {
3673 attrs.border_width = Some(crate::values::parse_border_width_edges(ctx, name, value)?);
3674 }
3675 "border-color" => attrs.border_color = Some(parse_color(ctx, name, value)?),
3676 "border-top-color" => attrs.border_color_top = Some(parse_color(ctx, name, value)?),
3677 "border-right-color" => attrs.border_color_right = Some(parse_color(ctx, name, value)?),
3678 "border-bottom-color" => attrs.border_color_bottom = Some(parse_color(ctx, name, value)?),
3679 "border-left-color" => attrs.border_color_left = Some(parse_color(ctx, name, value)?),
3680 "border-top" | "border-right" | "border-bottom" | "border-left" => {
3681 let sh = crate::values::parse_border_shorthand(ctx, name, value)?;
3687 let is_none = matches!(sh.style, Some(crate::layout_ir::BorderStyleSpec::None));
3688 let w = if is_none {
3689 0.0
3690 } else {
3691 sh.width.unwrap_or(3.0)
3692 };
3693 let edges = attrs
3694 .border_width
3695 .get_or_insert_with(crate::layout_ir::Edges::default);
3696 let side_color = if is_none { None } else { sh.color };
3697 match name {
3698 "border-top" => {
3699 edges.top = w;
3700 if let Some(c) = side_color {
3701 attrs.border_color_top = Some(c);
3702 }
3703 }
3704 "border-right" => {
3705 edges.right = w;
3706 if let Some(c) = side_color {
3707 attrs.border_color_right = Some(c);
3708 }
3709 }
3710 "border-bottom" => {
3711 edges.bottom = w;
3712 if let Some(c) = side_color {
3713 attrs.border_color_bottom = Some(c);
3714 }
3715 }
3716 "border-left" => {
3717 edges.left = w;
3718 if let Some(c) = side_color {
3719 attrs.border_color_left = Some(c);
3720 }
3721 }
3722 _ => unreachable!(),
3723 }
3724 if !is_none {
3725 attrs.border_style = Some(crate::layout_ir::BorderStyleSpec::Solid);
3726 }
3727 }
3728 "border-style" => {
3729 attrs.border_style = Some(crate::values::parse_border_style(ctx, name, value)?);
3730 }
3731 "border-top-width" | "border-right-width" | "border-bottom-width" | "border-left-width" => {
3732 let px = crate::values::parse_border_width_term(ctx, name, value, value)?;
3733 let edges = attrs
3734 .border_width
3735 .get_or_insert_with(crate::layout_ir::Edges::default);
3736 match name {
3737 "border-top-width" => edges.top = px,
3738 "border-right-width" => edges.right = px,
3739 "border-bottom-width" => edges.bottom = px,
3740 "border-left-width" => edges.left = px,
3741 _ => unreachable!(),
3742 }
3743 }
3744 "box-sizing" => {
3745 attrs.box_sizing = Some(match value.trim() {
3746 "border-box" => crate::layout_ir::BoxSizingSpec::BorderBox,
3747 "content-box" => crate::layout_ir::BoxSizingSpec::ContentBox,
3748 other => {
3749 return Err(bad(
3750 ctx,
3751 name,
3752 value,
3753 format!("unknown box-sizing '{other}'"),
3754 ));
3755 }
3756 });
3757 }
3758 "hover-border" => {
3762 let sh = crate::values::parse_border_shorthand(ctx, name, value)?;
3763 attrs.hover_border = border_shorthand_to_paint(&sh);
3764 }
3765 "focus-border" => {
3766 let sh = crate::values::parse_border_shorthand(ctx, name, value)?;
3767 attrs.focus_border = border_shorthand_to_paint(&sh);
3768 }
3769 "focus-outline" => {
3770 let parts: Vec<&str> = value.split_whitespace().collect();
3771 if parts.len() != 2 {
3772 return Err(crate::values::bad(
3773 ctx,
3774 name,
3775 value,
3776 "expected '<width-px> <#color>'".into(),
3777 ));
3778 }
3779 attrs.focus_outline = Some(crate::layout_ir::OutlineSpec {
3780 width: parse_f32(ctx, name, parts[0])?,
3781 color: parse_color(ctx, name, parts[1])?,
3782 offset: attrs.outline_offset.unwrap_or(0.0),
3783 });
3784 }
3785 "outline-offset" => {
3786 let off = parse_f32(ctx, name, value)?;
3787 attrs.outline_offset = Some(off);
3788 if let Some(o) = attrs.focus_outline.as_mut() {
3789 o.offset = off;
3790 }
3791 if let Some(o) = attrs.focus_visible_outline.as_mut() {
3792 o.offset = off;
3793 }
3794 }
3795 "knob-color" => {
3796 attrs.knob_color = Some(parse_color(ctx, name, value)?);
3797 }
3798 "knob-inset" => attrs.knob_inset = Some(parse_f32(ctx, name, value)?),
3801 "thumb-size" => attrs.thumb_size = Some(parse_f32(ctx, name, value)?),
3802 "popup-gap" => attrs.popup_gap = Some(parse_f32(ctx, name, value)?),
3803 "display" => {
3804 attrs.display = Some(match value.trim() {
3805 "flex" => DisplaySpec::Flex,
3806 "grid" => DisplaySpec::Grid,
3807 "none" => DisplaySpec::None,
3808 other => {
3809 return Err(bad(ctx, name, value, format!("unknown display '{other}'")));
3810 }
3811 });
3812 }
3813 "grid-template-rows" => {
3814 let rows = parse_track_list(ctx, name, value)?;
3815 let mut gt = attrs.grid_template.clone().unwrap_or_default();
3816 gt.rows = rows;
3817 attrs.grid_template = Some(gt);
3818 }
3819 "grid-template-columns" => {
3820 let cols = parse_track_list(ctx, name, value)?;
3821 let mut gt = attrs.grid_template.clone().unwrap_or_default();
3822 gt.columns = cols;
3823 attrs.grid_template = Some(gt);
3824 }
3825 "grid-row" => attrs.grid_row = Some(parse_grid_line_pair(ctx, name, value)?),
3826 "grid-column" => attrs.grid_column = Some(parse_grid_line_pair(ctx, name, value)?),
3827 "align" | "align-items" => {
3828 attrs.align = Some(parse_flex_align(ctx, name, value)?);
3829 }
3830 "align-self" => {
3831 attrs.align_self = Some(parse_flex_align(ctx, name, value)?);
3832 }
3833 "justify-items" => {
3834 attrs.justify_items = Some(parse_flex_align(ctx, name, value)?);
3835 }
3836 "justify-self" => {
3837 attrs.justify_self = Some(parse_flex_align(ctx, name, value)?);
3838 }
3839 "justify" => {
3840 attrs.justify = Some(match value {
3841 "start" => FlexJustify::Start,
3842 "end" => FlexJustify::End,
3843 "center" => FlexJustify::Center,
3844 "between" | "space-between" => FlexJustify::SpaceBetween,
3845 "around" | "space-around" => FlexJustify::SpaceAround,
3846 "evenly" | "space-evenly" => FlexJustify::SpaceEvenly,
3847 other => {
3848 return Err(bad(ctx, name, value, format!("unknown justify '{other}'")));
3849 }
3850 });
3851 }
3852 "text-align" => {
3853 attrs.text_align = Some(match value {
3854 "start" | "left" => TextAlignSpec::Start,
3855 "center" => TextAlignSpec::Center,
3856 "end" | "right" => TextAlignSpec::End,
3857 other => {
3858 return Err(bad(
3859 ctx,
3860 name,
3861 value,
3862 format!("unknown text-align '{other}'"),
3863 ));
3864 }
3865 });
3866 }
3867 "wrap" => {
3868 attrs.text_wrap = Some(match value {
3869 "none" | "nowrap" => TextWrapSpec::None,
3870 "word" | "normal" => TextWrapSpec::Word,
3871 "glyph" | "char" => TextWrapSpec::Glyph,
3872 other => return Err(bad(ctx, name, value, format!("unknown wrap '{other}'"))),
3873 });
3874 }
3875 "max-lines" => {
3876 let n: i64 = value
3877 .trim()
3878 .parse()
3879 .map_err(|e: std::num::ParseIntError| bad(ctx, name, value, e.to_string()))?;
3880 if n < 0 {
3881 return Err(bad(
3882 ctx,
3883 name,
3884 value,
3885 "max-lines must be \u{2265} 0".to_string(),
3886 ));
3887 }
3888 attrs.max_lines = Some(n as u32);
3889 }
3890 "progress-duration" => {
3894 let n: i64 = value
3895 .trim()
3896 .parse()
3897 .map_err(|e: std::num::ParseIntError| bad(ctx, name, value, e.to_string()))?;
3898 if n <= 0 {
3899 return Err(bad(
3900 ctx,
3901 name,
3902 value,
3903 "progress-duration must be > 0 (ms)".to_string(),
3904 ));
3905 }
3906 attrs.progress_duration = Some(n as u32);
3907 }
3908 "progress-chunk" => {
3911 let v = parse_f32(ctx, name, value)?;
3912 if !(0.0..=1.0).contains(&v) {
3913 return Err(bad(
3914 ctx,
3915 name,
3916 value,
3917 "progress-chunk must be between 0.0 and 1.0".to_string(),
3918 ));
3919 }
3920 attrs.progress_chunk = Some(v);
3921 }
3922 "text-overflow" => {
3923 attrs.text_overflow = Some(match value.trim() {
3924 "clip" => crate::layout_ir::TextOverflowSpec::Clip,
3925 "ellipsis" => crate::layout_ir::TextOverflowSpec::Ellipsis,
3926 other => {
3927 return Err(bad(
3928 ctx,
3929 name,
3930 value,
3931 format!("unknown text-overflow '{other}' (supported: clip, ellipsis)"),
3932 ));
3933 }
3934 });
3935 }
3936 "position" => {
3937 attrs.position = Some(match value {
3938 "relative" => PositionSpec::Relative,
3939 "absolute" => PositionSpec::Absolute,
3940 other => {
3941 return Err(bad(ctx, name, value, format!("unknown position '{other}'")));
3942 }
3943 });
3944 }
3945 "inset" => attrs.inset = Some(parse_inset_edges(ctx, name, value)?),
3946 "min-width" => attrs.min_width = Some(parse_length(ctx, name, value)?),
3947 "min-height" => attrs.min_height = Some(parse_length(ctx, name, value)?),
3948 "max-width" => attrs.max_width = Some(parse_length(ctx, name, value)?),
3949 "max-height" => attrs.max_height = Some(parse_length(ctx, name, value)?),
3950 "aspect-ratio" => attrs.aspect_ratio = Some(parse_f32(ctx, name, value)?),
3951 "opacity" => {
3952 let v = parse_f32(ctx, name, value)?;
3953 attrs.opacity = Some(v.clamp(0.0, 1.0));
3954 }
3955 "disabled-opacity" => {
3962 let v = parse_f32(ctx, name, value)?;
3963 attrs.disabled_opacity_default = Some(v.clamp(0.0, 1.0));
3964 }
3965 "shadow" | "box-shadow" => {
3966 attrs.shadows = parse_box_shadow(ctx, name, value)?;
3967 }
3968 "fit" => attrs.image_fit = Some(crate::values::parse_image_fit(ctx, name, value)?),
3969 "bg-fit" => attrs.bg_fit = Some(crate::values::parse_image_fit(ctx, name, value)?),
3970 "transition" => {
3971 attrs.transitions = parse_transition(ctx, name, value)?;
3972 }
3973 "transition-property" => {
3976 let v = value.trim();
3977 if v == "none" {
3978 attrs.transition_property = Some(Vec::new());
3979 } else {
3980 let mut props = Vec::new();
3981 for entry in v.split(',') {
3982 let entry = entry.trim();
3983 if entry.is_empty() {
3984 continue;
3985 }
3986 props.extend(transition_properties_for(entry));
3987 }
3988 attrs.transition_property = Some(props);
3989 }
3990 }
3991 "transition-delay" => {
3996 tracing::warn!(
3997 target: "lumenc::css",
3998 "transition-delay '{}' ignored - transitions start immediately",
3999 value.trim()
4000 );
4001 }
4002 "transition-duration" => {
4003 let mut out = Vec::new();
4004 for entry in value.split(',') {
4005 let entry = entry.trim();
4006 if entry.is_empty() {
4007 continue;
4008 }
4009 out.push(parse_duration_ms(ctx, name, entry)?);
4010 }
4011 attrs.transition_duration = Some(out);
4012 }
4013 "transition-timing-function" => {
4014 let mut out = Vec::new();
4015 for entry in split_top_level_commas(value) {
4016 let entry = entry.trim();
4017 if entry.is_empty() {
4018 continue;
4019 }
4020 out.push(parse_easing(ctx, name, &[entry])?);
4021 }
4022 attrs.transition_timing = Some(out);
4023 }
4024 "animation"
4030 | "animation-name"
4031 | "animation-duration"
4032 | "animation-timing-function"
4033 | "animation-delay"
4034 | "animation-iteration-count"
4035 | "animation-direction"
4036 | "animation-fill-mode"
4037 | "animation-play-state" => {}
4038 "scrollbar-color" => {
4042 let v = value.trim();
4043 if v == "auto" {
4044 attrs.scrollbar_color = None;
4045 } else {
4046 let parts: Vec<&str> = v.split_whitespace().collect();
4047 if parts.is_empty() || parts.len() > 2 {
4048 return Err(bad(
4049 ctx,
4050 name,
4051 value,
4052 "expected 'auto' or '<thumb-color> [<track-color>]'".into(),
4053 ));
4054 }
4055 let thumb = parse_color(ctx, name, parts[0])?;
4056 let track = match parts.get(1) {
4057 Some(t) => Some(parse_color(ctx, name, t)?),
4058 None => None,
4059 };
4060 attrs.scrollbar_color = Some((thumb, track));
4061 }
4062 }
4063 "scrollbar-width" => {
4064 attrs.scrollbar_width = Some(match value.trim() {
4065 "auto" => ScrollbarWidthSpec::Auto,
4066 "thin" => ScrollbarWidthSpec::Thin,
4067 "none" => ScrollbarWidthSpec::None,
4068 other => {
4069 return Err(bad(
4070 ctx,
4071 name,
4072 value,
4073 format!("unknown scrollbar-width '{other}' (supported: auto, thin, none)"),
4074 ));
4075 }
4076 });
4077 }
4078 "scrollbar-thickness" => attrs.scrollbar_thickness = Some(parse_f32(ctx, name, value)?),
4082 "scrollbar-thickness-thin" => {
4083 attrs.scrollbar_thickness_thin = Some(parse_f32(ctx, name, value)?);
4084 }
4085 "scrollbar-margin" => attrs.scrollbar_margin = Some(parse_f32(ctx, name, value)?),
4086 "scrollbar-min-thumb" => attrs.scrollbar_min_thumb = Some(parse_f32(ctx, name, value)?),
4087 "scrollbar-track-hover" => {
4091 attrs.scrollbar_track_hover = Some(parse_color(ctx, name, value)?);
4092 }
4093 "scrollbar-hover-boost" => {
4094 attrs.scrollbar_hover_boost = Some(parse_f32(ctx, name, value)?);
4095 }
4096 "scrollbar-fade-delay" => {
4097 attrs.scrollbar_fade_delay_ms = Some(parse_duration_ms(ctx, name, value.trim())?);
4098 }
4099 "scrollbar-fade-duration" => {
4100 attrs.scrollbar_fade_duration_ms = Some(parse_duration_ms(ctx, name, value.trim())?);
4101 }
4102 "padding-inline-start"
4110 | "padding-inline-end"
4111 | "padding-block-start"
4112 | "padding-block-end" => {
4113 let px = parse_f32(ctx, name, value)?;
4114 let edges = attrs
4115 .padding
4116 .get_or_insert_with(crate::layout_ir::Edges::default);
4117 match name {
4118 "padding-inline-start" => edges.inline_start = Some(px),
4119 "padding-inline-end" => edges.inline_end = Some(px),
4120 "padding-block-start" => edges.block_start = Some(px),
4121 "padding-block-end" => edges.block_end = Some(px),
4122 _ => unreachable!(),
4123 }
4124 }
4125 "margin-inline-start" | "margin-inline-end" | "margin-block-start" | "margin-block-end" => {
4126 let px = parse_f32(ctx, name, value)?;
4127 let edges = attrs
4128 .margin
4129 .get_or_insert_with(crate::layout_ir::Edges::default);
4130 match name {
4131 "margin-inline-start" => edges.inline_start = Some(px),
4132 "margin-inline-end" => edges.inline_end = Some(px),
4133 "margin-block-start" => edges.block_start = Some(px),
4134 "margin-block-end" => edges.block_end = Some(px),
4135 _ => unreachable!(),
4136 }
4137 }
4138 "inset-inline-start" | "inset-inline-end" | "inset-block-start" | "inset-block-end" => {
4139 let px = parse_inset_term(ctx, name, value)?;
4140 let edges = attrs
4142 .inset
4143 .get_or_insert_with(crate::layout_ir::Edges::auto);
4144 match name {
4145 "inset-inline-start" => edges.inline_start = Some(px),
4146 "inset-inline-end" => edges.inline_end = Some(px),
4147 "inset-block-start" => edges.block_start = Some(px),
4148 "inset-block-end" => edges.block_end = Some(px),
4149 _ => unreachable!(),
4150 }
4151 }
4152 "border-inline-start-width"
4153 | "border-inline-end-width"
4154 | "border-block-start-width"
4155 | "border-block-end-width" => {
4156 let px = crate::values::parse_border_width_term(ctx, name, value, value)?;
4157 let edges = attrs
4158 .border_width
4159 .get_or_insert_with(crate::layout_ir::Edges::default);
4160 match name {
4161 "border-inline-start-width" => edges.inline_start = Some(px),
4162 "border-inline-end-width" => edges.inline_end = Some(px),
4163 "border-block-start-width" => edges.block_start = Some(px),
4164 "border-block-end-width" => edges.block_end = Some(px),
4165 _ => unreachable!(),
4166 }
4167 }
4168 "overflow" | "overflow-x" | "overflow-y" => {
4169 let o = match value.trim() {
4170 "visible" => OverflowSpec::Visible,
4171 "hidden" => OverflowSpec::Hidden,
4172 "scroll" => OverflowSpec::Scroll,
4173 other => {
4174 return Err(bad(
4175 ctx,
4176 name,
4177 value,
4178 format!("unknown overflow '{other}' (supported: visible, hidden, scroll)"),
4179 ));
4180 }
4181 };
4182 match name {
4183 "overflow" => attrs.overflow = Some(o),
4184 "overflow-x" => attrs.overflow_x = Some(o),
4185 "overflow-y" => attrs.overflow_y = Some(o),
4186 _ => unreachable!(),
4187 }
4188 }
4189 "layout-boundary" => attrs.layout_boundary = matches!(value.trim(), "true" | "yes"),
4190 _ => return Ok(false),
4191 }
4192 Ok(true)
4193}
4194
4195fn parse_flex_align(ctx: &str, name: &str, value: &str) -> Result<FlexAlign, ParseError> {
4203 Ok(match value.trim() {
4204 "start" | "flex-start" => FlexAlign::Start,
4205 "end" | "flex-end" => FlexAlign::End,
4206 "center" => FlexAlign::Center,
4207 "stretch" => FlexAlign::Stretch,
4208 "baseline" => FlexAlign::Baseline,
4209 other => return Err(bad(ctx, name, value, format!("unknown align '{other}'"))),
4210 })
4211}
4212
4213fn parse_track_size(ctx: &str, name: &str, value: &str) -> Result<TrackSizeSpec, ParseError> {
4217 let v = value.trim();
4218 if let Some(inner) = v.strip_prefix("minmax(").and_then(|s| s.strip_suffix(')')) {
4219 let mut depth = 0_i32;
4222 let mut split: Option<usize> = None;
4223 for (i, c) in inner.char_indices() {
4224 match c {
4225 '(' => depth += 1,
4226 ')' => depth -= 1,
4227 ',' if depth == 0 => {
4228 split = Some(i);
4229 break;
4230 }
4231 _ => {}
4232 }
4233 }
4234 let Some(comma) = split else {
4235 return Err(bad(
4236 ctx,
4237 name,
4238 value,
4239 "minmax() requires two arguments separated by ','".into(),
4240 ));
4241 };
4242 let lhs = parse_track_size(ctx, name, &inner[..comma])?;
4243 let rhs = parse_track_size(ctx, name, &inner[comma + 1..])?;
4244 return Ok(TrackSizeSpec::MinMax(Box::new(lhs), Box::new(rhs)));
4245 }
4246 if v == "auto" {
4247 return Ok(TrackSizeSpec::Auto);
4248 }
4249 if v == "min-content" {
4250 return Ok(TrackSizeSpec::MinContent);
4251 }
4252 if v == "max-content" {
4253 return Ok(TrackSizeSpec::MaxContent);
4254 }
4255 if let Some(num) = v.strip_suffix("fr") {
4256 let f = parse_f32(ctx, name, num.trim())?;
4257 return Ok(TrackSizeSpec::Fr(f));
4258 }
4259 if let Some(num) = v.strip_suffix("px") {
4260 let n = parse_f32(ctx, name, num.trim())?;
4261 return Ok(TrackSizeSpec::Fixed(n));
4262 }
4263 if v.parse::<f32>().is_ok() {
4265 let n = parse_f32(ctx, name, v)?;
4266 return Ok(TrackSizeSpec::Fixed(n));
4267 }
4268 Err(bad(ctx, name, value, format!("unknown track size '{v}'")))
4269}
4270
4271fn parse_track_list(ctx: &str, name: &str, value: &str) -> Result<Vec<TrackSizeSpec>, ParseError> {
4275 let mut tokens: Vec<String> = Vec::new();
4279 let mut cur = String::new();
4280 let mut depth = 0_i32;
4281 for ch in value.chars() {
4282 match ch {
4283 '(' => {
4284 depth += 1;
4285 cur.push(ch);
4286 }
4287 ')' => {
4288 depth -= 1;
4289 cur.push(ch);
4290 }
4291 c if c.is_whitespace() && depth == 0 => {
4292 if !cur.is_empty() {
4293 tokens.push(std::mem::take(&mut cur));
4294 }
4295 }
4296 _ => cur.push(ch),
4297 }
4298 }
4299 if !cur.is_empty() {
4300 tokens.push(cur);
4301 }
4302 let mut out = Vec::with_capacity(tokens.len());
4303 for t in tokens {
4304 out.push(parse_track_size(ctx, name, &t)?);
4305 }
4306 Ok(out)
4307}
4308
4309pub fn parse_grid_line_pair(ctx: &str, name: &str, value: &str) -> Result<(i16, i16), ParseError> {
4318 let v = value.trim();
4319 if let Some(idx) = v.find('/') {
4320 let lhs = v[..idx].trim();
4321 let rhs = v[idx + 1..].trim();
4322 let s = grid_line_to_i16(ctx, name, parse_i32(ctx, name, lhs)?);
4323 let e = grid_line_to_i16(ctx, name, parse_i32(ctx, name, rhs)?);
4324 return Ok((s, e));
4325 }
4326 let s = grid_line_to_i16(ctx, name, parse_i32(ctx, name, v)?);
4327 Ok((s, 0))
4328}
4329
4330fn grid_line_to_i16(ctx: &str, name: &str, v: i32) -> i16 {
4334 if v < i16::MIN as i32 || v > i16::MAX as i32 {
4335 let clamped = v.clamp(i16::MIN as i32, i16::MAX as i32) as i16;
4336 tracing::warn!(
4337 target: "lumenc::css",
4338 "{ctx} {{ {name} }}: grid line {v} out of range [{}, {}] - clamped to {clamped}",
4339 i16::MIN,
4340 i16::MAX,
4341 );
4342 clamped
4343 } else {
4344 v as i16
4345 }
4346}
4347
4348const ALL_TRANSITION_PROPERTIES: [TransitionPropertyIr; 4] = [
4355 TransitionPropertyIr::Opacity,
4356 TransitionPropertyIr::BackgroundColor,
4357 TransitionPropertyIr::TextColor,
4358 TransitionPropertyIr::BorderColor,
4359];
4360
4361const DEFAULT_EASING: EasingIr = EasingIr::CubicBezier(0.25, 0.1, 0.25, 1.0);
4364
4365fn transition_properties_for(term: &str) -> Vec<TransitionPropertyIr> {
4372 if term == "all" {
4373 return ALL_TRANSITION_PROPERTIES.to_vec();
4374 }
4375 match TransitionPropertyIr::from_css_name(term) {
4376 Some(p) => vec![p],
4377 None => {
4378 tracing::warn!(
4379 target: "lumenc::css",
4380 "transition: property '{term}' is not animatable (animatable: all, opacity, \
4381 background-color, color, border-color) - entry ignored"
4382 );
4383 Vec::new()
4384 }
4385 }
4386}
4387
4388fn parse_transition(ctx: &str, name: &str, value: &str) -> Result<Vec<TransitionIr>, ParseError> {
4389 let mut out = Vec::new();
4390 for entry in split_top_level_commas(value) {
4391 let entry = entry.trim();
4392 if entry.is_empty() {
4393 continue;
4394 }
4395 let parts: Vec<&str> = entry.split_whitespace().collect();
4396 if parts.len() < 2 {
4397 return Err(bad(
4398 ctx,
4399 name,
4400 value,
4401 format!("expected '<property> <duration> [<easing>]' in entry '{entry}'"),
4402 ));
4403 }
4404 let properties = transition_properties_for(parts[0]);
4405 if properties.is_empty() {
4406 continue;
4407 }
4408 let mut duration_ms: Option<u32> = None;
4414 let mut delay_term: Option<&str> = None;
4415 let mut easing_terms: Vec<&str> = Vec::new();
4416 for term in &parts[1..] {
4417 match parse_duration_ms(ctx, name, term) {
4418 Ok(ms) if duration_ms.is_none() => duration_ms = Some(ms),
4419 Ok(_) => delay_term = Some(term),
4420 Err(_) => easing_terms.push(term),
4421 }
4422 }
4423 let Some(duration_ms) = duration_ms else {
4424 return Err(bad(
4425 ctx,
4426 name,
4427 value,
4428 format!("expected a duration ('Nms' or 'Ns') in entry '{entry}'"),
4429 ));
4430 };
4431 if let Some(delay) = delay_term {
4432 tracing::warn!(
4433 target: "lumenc::css",
4434 "transition: delay '{delay}' ignored - transitions start immediately"
4435 );
4436 }
4437 let easing = if easing_terms.is_empty() {
4438 DEFAULT_EASING
4439 } else {
4440 parse_easing(ctx, name, &easing_terms)?
4441 };
4442 for property in properties {
4443 out.push(TransitionIr {
4444 property,
4445 duration_ms,
4446 easing,
4447 });
4448 }
4449 }
4450 Ok(out)
4451}
4452
4453fn parse_box_shadow(ctx: &str, name: &str, value: &str) -> Result<Vec<ShadowSpec>, ParseError> {
4454 let mut out = Vec::new();
4455 for entry in split_top_level_commas(value) {
4456 let entry = entry.trim();
4457 if entry.is_empty() {
4458 continue;
4459 }
4460 let mut toks: Vec<&str> = entry.split_whitespace().collect();
4461 let inner = if toks.first().is_some_and(|t| *t == "inset") {
4462 toks.remove(0);
4463 true
4464 } else {
4465 false
4466 };
4467 if toks.len() != 4 && toks.len() != 5 {
4468 return Err(bad(
4469 ctx,
4470 name,
4471 value,
4472 format!("expected '[inset] <x> <y> <blur> [<spread>] <#color>' in entry '{entry}'"),
4473 ));
4474 }
4475 let spread = if toks.len() == 5 {
4476 parse_f32(ctx, name, toks[3])?
4477 } else {
4478 0.0
4479 };
4480 out.push(ShadowSpec {
4481 offset_x: parse_f32(ctx, name, toks[0])?,
4482 offset_y: parse_f32(ctx, name, toks[1])?,
4483 blur: parse_f32(ctx, name, toks[2])?,
4484 spread,
4485 color: parse_color(ctx, name, toks[if toks.len() == 5 { 4 } else { 3 }])?,
4486 inner,
4487 });
4488 }
4489 Ok(out)
4490}
4491
4492pub fn parse_duration_ms(ctx: &str, name: &str, raw: &str) -> Result<u32, ParseError> {
4499 if let Some(stripped) = raw.strip_suffix("ms") {
4500 let v: f32 = stripped
4501 .parse()
4502 .map_err(|e: std::num::ParseFloatError| bad(ctx, name, raw, e.to_string()))?;
4503 if v < 0.0 {
4504 return Err(bad(ctx, name, raw, "duration must be \u{2265} 0".into()));
4505 }
4506 return Ok(v.round() as u32);
4507 }
4508 if let Some(stripped) = raw.strip_suffix('s') {
4509 let v: f32 = stripped
4510 .parse()
4511 .map_err(|e: std::num::ParseFloatError| bad(ctx, name, raw, e.to_string()))?;
4512 if v < 0.0 {
4513 return Err(bad(ctx, name, raw, "duration must be \u{2265} 0".into()));
4514 }
4515 return Ok((v * 1000.0).round() as u32);
4516 }
4517 Err(bad(
4518 ctx,
4519 name,
4520 raw,
4521 format!("expected 'Nms' or 'Ns'; got '{raw}'"),
4522 ))
4523}
4524
4525fn parse_easing(ctx: &str, name: &str, parts: &[&str]) -> Result<EasingIr, ParseError> {
4526 let joined = parts.join(" ");
4527 let raw = joined.trim();
4528 match raw {
4529 "linear" => Ok(EasingIr::Linear),
4530 "ease" => Ok(DEFAULT_EASING),
4531 "ease-in" => Ok(EasingIr::EaseIn),
4532 "ease-out" => Ok(EasingIr::EaseOut),
4533 "ease-in-out" => Ok(EasingIr::EaseInOut),
4534 other if other.starts_with("cubic-bezier(") && other.ends_with(')') => {
4535 let inside = &other["cubic-bezier(".len()..other.len() - 1];
4536 let nums: Vec<f32> = inside
4537 .split(',')
4538 .map(|p| {
4539 p.trim()
4540 .parse::<f32>()
4541 .map_err(|e| bad(ctx, name, raw, e.to_string()))
4542 })
4543 .collect::<Result<_, _>>()?;
4544 if nums.len() != 4 {
4545 return Err(bad(
4546 ctx,
4547 name,
4548 raw,
4549 "cubic-bezier expects 4 comma-separated numbers".into(),
4550 ));
4551 }
4552 Ok(EasingIr::CubicBezier(nums[0], nums[1], nums[2], nums[3]))
4553 }
4554 other => Err(bad(ctx, name, raw, format!("unknown easing '{other}'"))),
4555 }
4556}
4557
4558#[derive(Debug, Clone, PartialEq)]
4565pub struct CascadeDivergence {
4566 pub selector: String,
4568 pub property: String,
4570 pub first_wins: String,
4572 pub last_wins: String,
4574}
4575
4576pub fn cascade_lint(css: &Stylesheet) -> Vec<CascadeDivergence> {
4585 use std::collections::BTreeMap;
4586 let mut buckets: BTreeMap<String, BTreeMap<String, Vec<String>>> = BTreeMap::new();
4587 for rule in &css.rules {
4588 for (sel_idx, _) in rule.selectors.iter().enumerate() {
4589 let sel_text = describe_selector_for_error(&rule.selectors, sel_idx);
4590 let entry = buckets.entry(sel_text).or_default();
4591 for decl in &rule.declarations {
4592 if decl.name.starts_with("--") {
4593 continue;
4594 }
4595 entry
4596 .entry(decl.name.clone())
4597 .or_default()
4598 .push(decl.value.clone());
4599 }
4600 }
4601 }
4602 let mut out = Vec::new();
4603 for (selector, props) in buckets {
4604 for (property, values) in props {
4605 if values.len() < 2 {
4606 continue;
4607 }
4608 let first = values.first().unwrap();
4609 let last = values.last().unwrap();
4610 if first != last {
4611 out.push(CascadeDivergence {
4612 selector: selector.clone(),
4613 property,
4614 first_wins: first.clone(),
4615 last_wins: last.clone(),
4616 });
4617 }
4618 }
4619 }
4620 out
4621}
4622
4623fn split_top_level_commas(s: &str) -> Vec<&str> {
4629 let mut out = Vec::new();
4630 let bytes = s.as_bytes();
4631 let mut depth: i32 = 0;
4632 let mut start = 0usize;
4633 let mut i = 0usize;
4634 while i < bytes.len() {
4635 match bytes[i] {
4636 b'(' | b'[' => depth += 1,
4637 b')' | b']' => depth -= 1,
4638 b',' if depth == 0 => {
4639 out.push(&s[start..i]);
4640 start = i + 1;
4641 }
4642 _ => {}
4643 }
4644 i += 1;
4645 }
4646 out.push(&s[start..]);
4647 out
4648}
4649
4650#[cfg(test)]
4651mod transition_tests {
4652 use super::*;
4653
4654 #[test]
4655 fn parses_single_opacity_transition() {
4656 let out = parse_transition("ctx", "transition", "opacity 200ms ease-out").unwrap();
4657 assert_eq!(out.len(), 1);
4658 assert_eq!(out[0].property, TransitionPropertyIr::Opacity);
4659 assert_eq!(out[0].duration_ms, 200);
4660 assert_eq!(out[0].easing, EasingIr::EaseOut);
4661 }
4662
4663 #[test]
4664 fn parses_comma_separated_entries() {
4665 let out = parse_transition(
4666 "ctx",
4667 "transition",
4668 "opacity 100ms linear, opacity 300ms ease-in",
4669 )
4670 .unwrap();
4671 assert_eq!(out.len(), 2);
4672 assert_eq!(out[1].easing, EasingIr::EaseIn);
4673 assert_eq!(out[1].duration_ms, 300);
4674 }
4675
4676 #[test]
4677 fn drops_unsupported_property() {
4678 let out = parse_transition(
4681 "ctx",
4682 "transition",
4683 "width 100ms ease-out, opacity 200ms ease-out",
4684 )
4685 .unwrap();
4686 assert_eq!(out.len(), 1);
4687 assert_eq!(out[0].property, TransitionPropertyIr::Opacity);
4688 }
4689
4690 #[test]
4691 fn parses_color_properties() {
4692 let out = parse_transition(
4693 "ctx",
4694 "transition",
4695 "bg 130ms ease, color 100ms linear, border-color 100ms linear",
4696 )
4697 .unwrap();
4698 assert_eq!(out.len(), 3);
4699 assert_eq!(out[0].property, TransitionPropertyIr::BackgroundColor);
4700 assert_eq!(out[1].property, TransitionPropertyIr::TextColor);
4701 assert_eq!(out[2].property, TransitionPropertyIr::BorderColor);
4702 assert!(matches!(out[0].easing, EasingIr::CubicBezier(..)));
4704 }
4705
4706 #[test]
4707 fn longhands_combine_with_cycled_durations() {
4708 use crate::layout_ir::Attributes;
4709 let mut attrs = Attributes::default();
4710 apply_declaration(
4711 "ctx",
4712 "transition-property",
4713 "opacity, bg, border-color",
4714 &mut attrs,
4715 )
4716 .unwrap();
4717 apply_declaration("ctx", "transition-duration", "100ms, 200ms", &mut attrs).unwrap();
4718 apply_declaration("ctx", "transition-timing-function", "linear", &mut attrs).unwrap();
4719 let eff = attrs.effective_transitions();
4720 assert_eq!(eff.len(), 3);
4721 assert_eq!(eff[0].duration_ms, 100);
4723 assert_eq!(eff[1].duration_ms, 200);
4724 assert_eq!(eff[2].duration_ms, 100);
4725 assert!(eff.iter().all(|t| t.easing == EasingIr::Linear));
4726 }
4727
4728 #[test]
4729 fn shorthand_wins_over_longhands() {
4730 use crate::layout_ir::Attributes;
4731 let mut attrs = Attributes::default();
4732 apply_declaration("ctx", "transition-property", "bg", &mut attrs).unwrap();
4733 apply_declaration("ctx", "transition-duration", "999ms", &mut attrs).unwrap();
4734 apply_declaration("ctx", "transition", "opacity 150ms ease-out", &mut attrs).unwrap();
4735 let eff = attrs.effective_transitions();
4736 assert_eq!(eff.len(), 1);
4737 assert_eq!(eff[0].property, TransitionPropertyIr::Opacity);
4738 assert_eq!(eff[0].duration_ms, 150);
4739 }
4740
4741 #[test]
4742 fn accepts_seconds_duration() {
4743 let out = parse_transition("ctx", "transition", "opacity 0.25s").unwrap();
4744 assert_eq!(out[0].duration_ms, 250);
4745 }
4746
4747 #[test]
4748 fn defaults_easing_to_ease() {
4749 let out = parse_transition("ctx", "transition", "opacity 200ms").unwrap();
4751 assert_eq!(out[0].easing, DEFAULT_EASING);
4752 assert_eq!(out[0].easing, parse_easing("ctx", "t", &["ease"]).unwrap());
4753 }
4754
4755 #[test]
4756 fn all_expands_to_every_animatable_property() {
4757 let out = parse_transition("ctx", "transition", "all 200ms linear").unwrap();
4758 assert_eq!(out.len(), ALL_TRANSITION_PROPERTIES.len());
4759 let props: Vec<_> = out.iter().map(|t| t.property).collect();
4760 assert_eq!(props, ALL_TRANSITION_PROPERTIES.to_vec());
4761 assert!(out.iter().all(|t| t.duration_ms == 200));
4762 assert!(out.iter().all(|t| t.easing == EasingIr::Linear));
4763 }
4764
4765 #[test]
4766 fn all_shorthand_overrides_longhands() {
4767 let mut attrs = Attributes {
4770 transition_property: Some(vec![TransitionPropertyIr::Opacity]),
4771 transition_duration: Some(vec![900]),
4772 ..Default::default()
4773 };
4774 apply_declaration("ctx", "transition", "all 120ms", &mut attrs).unwrap();
4775 let eff = attrs.effective_transitions();
4776 assert_eq!(eff.len(), ALL_TRANSITION_PROPERTIES.len());
4777 assert!(eff.iter().all(|t| t.duration_ms == 120));
4778 }
4779
4780 #[test]
4781 fn longhand_property_accepts_all() {
4782 let mut attrs = Attributes::default();
4783 apply_declaration("ctx", "transition-property", "all", &mut attrs).unwrap();
4784 assert_eq!(
4785 attrs.transition_property.as_deref(),
4786 Some(&ALL_TRANSITION_PROPERTIES[..])
4787 );
4788 }
4789
4790 #[test]
4791 fn delay_term_is_ignored_not_fatal() {
4792 let out = parse_transition("ctx", "transition", "opacity 200ms 50ms").unwrap();
4795 assert_eq!(out.len(), 1);
4796 assert_eq!(out[0].duration_ms, 200);
4797 assert_eq!(out[0].easing, DEFAULT_EASING);
4798
4799 let out = parse_transition("ctx", "transition", "opacity 200ms ease-in 50ms").unwrap();
4800 assert_eq!(out[0].duration_ms, 200);
4801 assert_eq!(out[0].easing, EasingIr::EaseIn);
4802 }
4803
4804 #[test]
4805 fn delay_longhand_is_recognized_and_ignored() {
4806 let mut attrs = Attributes::default();
4807 assert!(apply_declaration("ctx", "transition-delay", "50ms", &mut attrs).unwrap());
4808 assert!(attrs.transitions.is_empty());
4809 }
4810
4811 #[test]
4812 fn the_animation_family_is_recognized_and_stores_nothing() {
4813 let mut attrs = Attributes::default();
4817 for name in [
4818 "animation",
4819 "animation-name",
4820 "animation-duration",
4821 "animation-timing-function",
4822 "animation-delay",
4823 "animation-iteration-count",
4824 "animation-direction",
4825 "animation-fill-mode",
4826 "animation-play-state",
4827 ] {
4828 assert!(
4829 apply_declaration("ctx", name, "spin 2s linear infinite", &mut attrs).unwrap(),
4830 "`{name}` must be a property the cascade recognizes"
4831 );
4832 }
4833 assert!(attrs.transitions.is_empty());
4834 }
4835
4836 #[test]
4837 fn cubic_bezier_with_spaces_survives_the_term_scan() {
4838 let out = parse_transition(
4839 "ctx",
4840 "transition",
4841 "opacity 200ms cubic-bezier(0.4, 0, 0.2, 1)",
4842 )
4843 .unwrap();
4844 assert_eq!(out[0].easing, EasingIr::CubicBezier(0.4, 0.0, 0.2, 1.0));
4845 }
4846
4847 #[test]
4848 fn rejects_missing_duration() {
4849 assert!(parse_transition("ctx", "transition", "opacity").is_err());
4850 }
4851
4852 #[test]
4853 fn parses_cubic_bezier() {
4854 let out = parse_transition(
4855 "ctx",
4856 "transition",
4857 "opacity 200ms cubic-bezier(0.4, 0, 0.2, 1)",
4858 )
4859 .unwrap();
4860 assert_eq!(out[0].easing, EasingIr::CubicBezier(0.4, 0.0, 0.2, 1.0));
4861 }
4862}
4863
4864#[cfg(test)]
4865mod cascade_origin_tests {
4866 use super::*;
4867
4868 fn subject(compound: CompoundSelector) -> SelectorBuf {
4869 SelectorBuf {
4870 chain: vec![(Combinator::Subject, compound)],
4871 }
4872 }
4873
4874 fn rule(selector: SelectorBuf, origin: Origin, source_order: usize) -> Rule {
4875 Rule {
4876 selectors: vec![selector],
4877 declarations: Vec::new(),
4878 origin,
4879 source_order,
4880 media: None,
4881 selector: LegacySelectorShim::default(),
4882 }
4883 }
4884
4885 #[test]
4891 fn author_class_beats_user_agent_pseudo_despite_specificity() {
4892 let ua = rule(
4894 subject(CompoundSelector {
4895 tag: Some("textarea".to_string()),
4896 id: None,
4897 classes: Vec::new(),
4898 pseudo_classes: vec![PseudoClass::Hover],
4899 }),
4900 Origin::UserAgent,
4901 0,
4902 );
4903 let author = rule(
4905 subject(CompoundSelector {
4906 tag: None,
4907 id: None,
4908 classes: vec!["editor".to_string()],
4909 pseudo_classes: Vec::new(),
4910 }),
4911 Origin::Author,
4912 1,
4913 );
4914
4915 assert!(
4918 ua.selectors[0].specificity() > author.selectors[0].specificity(),
4919 "test premise: UA textarea:hover must out-specific author .editor"
4920 );
4921
4922 let css = Stylesheet {
4923 rules: vec![ua, author],
4924 ..Default::default()
4925 };
4926 let me = ElementRef {
4928 tag: "textarea".to_string(),
4929 classes: vec!["editor".to_string()],
4930 id: None,
4931 child_index: 1,
4932 sibling_count: 1,
4933 siblings: None,
4934 };
4935 let matched =
4936 collect_matching_rules(&css, &MediaContext::default(), &me, &[], false, None, false);
4937
4938 assert_eq!(matched.len(), 2, "both rules should match");
4940 assert_eq!(
4943 matched.last().unwrap().rule_idx,
4944 1,
4945 "author .editor must win over UA textarea:hover"
4946 );
4947 assert_eq!(matched.last().unwrap().origin, Origin::Author);
4948 }
4949
4950 #[test]
4953 fn specificity_still_wins_within_same_origin() {
4954 let low = rule(
4955 subject(CompoundSelector {
4956 tag: Some("textarea".to_string()),
4957 id: None,
4958 classes: Vec::new(),
4959 pseudo_classes: Vec::new(),
4960 }),
4961 Origin::Author,
4962 0,
4963 );
4964 let high = rule(
4965 subject(CompoundSelector {
4966 tag: None,
4967 id: None,
4968 classes: vec!["editor".to_string()],
4969 pseudo_classes: Vec::new(),
4970 }),
4971 Origin::Author,
4972 1,
4973 );
4974 let css = Stylesheet {
4975 rules: vec![low, high],
4976 ..Default::default()
4977 };
4978 let me = ElementRef {
4979 tag: "textarea".to_string(),
4980 classes: vec!["editor".to_string()],
4981 id: None,
4982 child_index: 1,
4983 sibling_count: 1,
4984 siblings: None,
4985 };
4986 let matched =
4987 collect_matching_rules(&css, &MediaContext::default(), &me, &[], false, None, false);
4988 assert_eq!(matched.len(), 2);
4989 assert_eq!(matched.last().unwrap().rule_idx, 1);
4991 }
4992}
4993
4994#[cfg(test)]
4995mod query_surface_tests {
4996 use super::*;
4997
4998 fn anc(tag: &str, classes: &[&str], id: Option<&str>) -> AncestorInfo {
4999 AncestorInfo::new(
5000 tag,
5001 classes.iter().map(|s| s.to_string()).collect(),
5002 id.map(|s| s.to_string()),
5003 )
5004 }
5005
5006 fn one(src: &str) -> SelectorBuf {
5007 let list = parse_selector_list(src).unwrap();
5008 assert_eq!(list.len(), 1, "expected single selector for '{src}'");
5009 list.into_iter().next().unwrap()
5010 }
5011
5012 #[test]
5013 fn parse_round_trips_basic_shapes() {
5014 assert_eq!(parse_selector_list("#save").unwrap().len(), 1);
5015 assert_eq!(parse_selector_list(".row").unwrap().len(), 1);
5016 assert_eq!(parse_selector_list("button").unwrap().len(), 1);
5017 assert_eq!(parse_selector_list("button.primary").unwrap().len(), 1);
5018 assert_eq!(one(".card .row").chain.len(), 2);
5020 assert_eq!(one(".card > .row").chain.len(), 2);
5021 assert_eq!(parse_selector_list("#a, .b, c").unwrap().len(), 3);
5023 }
5024
5025 #[test]
5026 fn parse_rejects_garbage() {
5027 assert!(parse_selector_list("").is_err());
5028 assert!(parse_selector_list(" ").is_err());
5029 assert!(parse_selector_list("#a,").is_err());
5030 }
5031
5032 #[test]
5033 fn matches_id_class_tag() {
5034 let subject = anc("button", &["primary", "big"], Some("save"));
5035 assert!(selector_matches(&one("#save"), &subject, &[]));
5036 assert!(selector_matches(&one(".primary"), &subject, &[]));
5037 assert!(selector_matches(&one("button"), &subject, &[]));
5038 assert!(selector_matches(&one("button.primary"), &subject, &[]));
5039 assert!(selector_matches(&one(".primary.big"), &subject, &[]));
5040 assert!(!selector_matches(&one("#other"), &subject, &[]));
5041 assert!(!selector_matches(&one(".missing"), &subject, &[]));
5042 assert!(!selector_matches(&one("label"), &subject, &[]));
5043 }
5044
5045 #[test]
5046 fn descendant_and_child_combinators() {
5047 let ancestors = [anc("root", &["app"], None), anc("div", &["card"], None)];
5049 let subject = anc("button", &[], Some("save"));
5050 assert!(selector_matches(&one(".card button"), &subject, &ancestors));
5051 assert!(selector_matches(
5052 &one(".app .card #save"),
5053 &subject,
5054 &ancestors
5055 ));
5056 assert!(selector_matches(
5057 &one(".card > #save"),
5058 &subject,
5059 &ancestors
5060 ));
5061 assert!(!selector_matches(
5063 &one(".app > #save"),
5064 &subject,
5065 &ancestors
5066 ));
5067 assert!(!selector_matches(
5069 &one(".sidebar #save"),
5070 &subject,
5071 &ancestors
5072 ));
5073 }
5074
5075 #[test]
5076 fn selector_matches_agrees_with_cascade_match_selector() {
5077 let ancestors = [anc("root", &["theme-dark"], None)];
5080 let subject = anc("div", &["card"], None);
5081 let sel = one(".theme-dark .card");
5082 let me = subject.to_ref();
5083 let parents: Vec<ElementRef> = ancestors.iter().map(AncestorInfo::to_ref).collect();
5084 let ctx = NodeCtx {
5085 el: &me,
5086 ancestors: &parents,
5087 siblings: None,
5088 sibling_index: 0,
5089 has_element_children: true,
5090 text_body: None,
5091 is_root: false,
5092 };
5093 let via_private = match_selector(&sel, &ctx).is_some();
5094 let via_public = selector_matches(&sel, &subject, &ancestors);
5095 assert_eq!(via_private, via_public);
5096 assert!(via_public);
5097 }
5098
5099 #[test]
5100 fn sibling_combinators_conservatively_fail() {
5101 let subject = anc("button", &["b"], None);
5102 let ancestors = [anc("root", &[], None)];
5103 assert!(!selector_matches(&one(".a + .b"), &subject, &ancestors));
5106 assert!(!selector_matches(&one(".a ~ .b"), &subject, &ancestors));
5107 }
5108
5109 #[test]
5110 fn root_pseudo_matches_only_root() {
5111 let root = anc("root", &[], None);
5112 assert!(selector_matches(&one(":root"), &root, &[]));
5113 let child = anc("div", &[], None);
5114 assert!(!selector_matches(
5115 &one(":root"),
5116 &child,
5117 &[anc("root", &[], None)]
5118 ));
5119 }
5120}
5121
5122#[cfg(test)]
5123mod inline_style_tests {
5124 use super::*;
5125
5126 #[test]
5127 fn inline_declaration_overrides_a_cascaded_value() {
5128 let mut attrs = Attributes::default();
5130 apply_declaration("rule", "color", "#0000ff", &mut attrs).unwrap();
5131 assert_eq!(
5132 computed_property(&attrs, "color").as_deref(),
5133 Some("#0000ff")
5134 );
5135 assert!(apply_inline_declaration("color", "#ff0000", &mut attrs).unwrap());
5137 assert_eq!(
5138 computed_property(&attrs, "color").as_deref(),
5139 Some("#ff0000")
5140 );
5141 }
5142
5143 #[test]
5144 fn computed_property_renders_common_props() {
5145 let mut attrs = Attributes::default();
5146 apply_inline_declaration("width", "120px", &mut attrs).unwrap();
5147 apply_inline_declaration("opacity", "0.5", &mut attrs).unwrap();
5148 assert_eq!(computed_property(&attrs, "width").as_deref(), Some("120px"));
5149 assert_eq!(computed_property(&attrs, "opacity").as_deref(), Some("0.5"));
5150 assert_eq!(computed_property(&attrs, "z-index"), None);
5152 }
5153}
5154
5155#[cfg(test)]
5161mod skin_token_property_tests {
5162 use super::*;
5163
5164 #[test]
5165 fn knob_inset_parses_px() {
5166 let mut attrs = Attributes::default();
5167 apply_declaration("ctx", "knob-inset", "2px", &mut attrs).unwrap();
5168 assert_eq!(attrs.knob_inset, Some(2.0));
5169 }
5170
5171 #[test]
5172 fn thumb_size_parses_bare_number_as_px() {
5173 let mut attrs = Attributes::default();
5174 apply_declaration("ctx", "thumb-size", "20", &mut attrs).unwrap();
5175 assert_eq!(attrs.thumb_size, Some(20.0));
5176 }
5177
5178 #[test]
5179 fn popup_gap_parses_px() {
5180 let mut attrs = Attributes::default();
5181 apply_declaration("ctx", "popup-gap", "4px", &mut attrs).unwrap();
5182 assert_eq!(attrs.popup_gap, Some(4.0));
5183 }
5184
5185 #[test]
5186 fn length_px_rejects_non_numeric() {
5187 let mut attrs = Attributes::default();
5191 assert!(apply_declaration("ctx", "knob-inset", "wide", &mut attrs).is_err());
5192 }
5193
5194 #[test]
5195 fn progress_chunk_parses_fraction() {
5196 let mut attrs = Attributes::default();
5197 apply_declaration("ctx", "progress-chunk", "0.35", &mut attrs).unwrap();
5198 assert_eq!(attrs.progress_chunk, Some(0.35));
5199 }
5200
5201 #[test]
5202 fn progress_chunk_rejects_out_of_range() {
5203 let mut attrs = Attributes::default();
5206 assert!(apply_declaration("ctx", "progress-chunk", "1.5", &mut attrs).is_err());
5207 }
5208
5209 #[test]
5210 fn progress_chunk_rejects_non_numeric() {
5211 let mut attrs = Attributes::default();
5212 assert!(apply_declaration("ctx", "progress-chunk", "lots", &mut attrs).is_err());
5213 }
5214
5215 #[test]
5216 fn disabled_opacity_parses_and_clamps() {
5217 let mut attrs = Attributes::default();
5221 apply_declaration("ctx", "disabled-opacity", "0.4", &mut attrs).unwrap();
5222 assert_eq!(attrs.disabled_opacity_default, Some(0.4));
5223 apply_declaration("ctx", "disabled-opacity", "2.0", &mut attrs).unwrap();
5224 assert_eq!(attrs.disabled_opacity_default, Some(1.0));
5225 }
5226
5227 #[test]
5228 fn disabled_opacity_is_distinct_from_pseudo_disabled_opacity() {
5229 let mut attrs = Attributes::default();
5233 apply_declaration("ctx", "disabled-opacity", "0.3", &mut attrs).unwrap();
5234 assert_eq!(attrs.disabled_opacity_default, Some(0.3));
5235 assert_eq!(attrs.disabled_opacity, None);
5236 }
5237
5238 #[test]
5239 fn caret_width_parses_px() {
5240 let mut attrs = Attributes::default();
5241 apply_declaration("ctx", "caret-width", "2px", &mut attrs).unwrap();
5242 assert_eq!(attrs.caret_width, Some(2.0));
5243 }
5244
5245 #[test]
5246 fn caret_blink_parses_ms() {
5247 let mut attrs = Attributes::default();
5248 apply_declaration("ctx", "caret-blink", "530ms", &mut attrs).unwrap();
5249 assert_eq!(attrs.caret_blink_ms, Some(530));
5250 }
5251
5252 #[test]
5253 fn caret_blink_parses_seconds() {
5254 let mut attrs = Attributes::default();
5255 apply_declaration("ctx", "caret-blink", "0.5s", &mut attrs).unwrap();
5256 assert_eq!(attrs.caret_blink_ms, Some(500));
5257 }
5258
5259 #[test]
5260 fn duration_rejects_missing_unit() {
5261 let mut attrs = Attributes::default();
5265 assert!(apply_declaration("ctx", "caret-blink", "500", &mut attrs).is_err());
5266 }
5267
5268 #[test]
5269 fn password_character_parses_single_char() {
5270 let mut attrs = Attributes::default();
5271 apply_declaration("ctx", "password-character", "*", &mut attrs).unwrap();
5272 assert_eq!(attrs.password_character, Some('*'));
5273 }
5274
5275 #[test]
5276 fn password_character_accepts_non_ascii_scalar() {
5277 let mut attrs = Attributes::default();
5278 apply_declaration("ctx", "password-character", "\u{2022}", &mut attrs).unwrap();
5279 assert_eq!(attrs.password_character, Some('\u{2022}'));
5280 }
5281
5282 #[test]
5283 fn password_character_rejects_multiple_characters() {
5284 let mut attrs = Attributes::default();
5286 assert!(apply_declaration("ctx", "password-character", "**", &mut attrs).is_err());
5287 }
5288
5289 #[test]
5290 fn password_character_rejects_empty() {
5291 let mut attrs = Attributes::default();
5292 assert!(apply_declaration("ctx", "password-character", "", &mut attrs).is_err());
5293 }
5294
5295 #[test]
5296 fn line_height_parses_unitless_multiplier() {
5297 let mut attrs = Attributes::default();
5298 apply_declaration("ctx", "line-height", "1.2", &mut attrs).unwrap();
5299 assert_eq!(attrs.line_height, Some(LineHeightSpec::Multiplier(1.2)));
5300 }
5301
5302 #[test]
5303 fn line_height_parses_px() {
5304 let mut attrs = Attributes::default();
5305 apply_declaration("ctx", "line-height", "19px", &mut attrs).unwrap();
5306 assert_eq!(attrs.line_height, Some(LineHeightSpec::Px(19.0)));
5307 }
5308
5309 #[test]
5310 fn line_height_unitless_and_px_are_distinct_variants() {
5311 let mut a = Attributes::default();
5314 let mut b = Attributes::default();
5315 apply_declaration("ctx", "line-height", "1.2", &mut a).unwrap();
5316 apply_declaration("ctx", "line-height", "1.2px", &mut b).unwrap();
5317 assert_ne!(a.line_height, b.line_height);
5318 }
5319
5320 #[test]
5321 fn line_height_rejects_negative() {
5322 let mut attrs = Attributes::default();
5324 assert!(apply_declaration("ctx", "line-height", "-1", &mut attrs).is_err());
5325 }
5326
5327 #[test]
5328 fn line_height_inherits_to_children() {
5329 let mut ir = LayoutIR {
5333 root: Element {
5334 tag: "column".to_string(),
5335 children: vec![Element {
5336 tag: "label".to_string(),
5337 ..Default::default()
5338 }],
5339 ..Default::default()
5340 },
5341 ..Default::default()
5342 };
5343 let css = Stylesheet {
5344 rules: vec![Rule {
5345 selectors: vec![SelectorBuf {
5346 chain: vec![(
5347 Combinator::Subject,
5348 CompoundSelector {
5349 tag: Some("column".to_string()),
5350 id: None,
5351 classes: Vec::new(),
5352 pseudo_classes: Vec::new(),
5353 },
5354 )],
5355 }],
5356 declarations: vec![Declaration {
5357 name: "line-height".to_string(),
5358 value: "1.5".to_string(),
5359 important: false,
5360 }],
5361 origin: Origin::Author,
5362 source_order: 0,
5363 media: None,
5364 selector: LegacySelectorShim::default(),
5365 }],
5366 ..Default::default()
5367 };
5368 apply_css(&mut ir, &css).unwrap();
5369 assert_eq!(
5370 ir.root.children[0].attrs.line_height,
5371 Some(LineHeightSpec::Multiplier(1.5))
5372 );
5373 }
5374
5375 #[test]
5376 fn scrollbar_thickness_parses_px() {
5377 let mut attrs = Attributes::default();
5378 apply_declaration("ctx", "scrollbar-thickness", "10px", &mut attrs).unwrap();
5379 assert_eq!(attrs.scrollbar_thickness, Some(10.0));
5380 }
5381
5382 #[test]
5383 fn scrollbar_thickness_thin_parses_px() {
5384 let mut attrs = Attributes::default();
5385 apply_declaration("ctx", "scrollbar-thickness-thin", "6px", &mut attrs).unwrap();
5386 assert_eq!(attrs.scrollbar_thickness_thin, Some(6.0));
5387 }
5388
5389 #[test]
5390 fn scrollbar_margin_parses_px() {
5391 let mut attrs = Attributes::default();
5392 apply_declaration("ctx", "scrollbar-margin", "2px", &mut attrs).unwrap();
5393 assert_eq!(attrs.scrollbar_margin, Some(2.0));
5394 }
5395
5396 #[test]
5397 fn scrollbar_min_thumb_parses_px() {
5398 let mut attrs = Attributes::default();
5399 apply_declaration("ctx", "scrollbar-min-thumb", "24px", &mut attrs).unwrap();
5400 assert_eq!(attrs.scrollbar_min_thumb, Some(24.0));
5401 }
5402
5403 #[test]
5404 fn scrollbar_track_hover_parses_color() {
5405 let mut attrs = Attributes::default();
5406 apply_declaration("ctx", "scrollbar-track-hover", "#334455", &mut attrs).unwrap();
5407 let c = attrs.scrollbar_track_hover.unwrap();
5408 assert!((c.g - (0x44 as f32 / 255.0)).abs() < 1e-6);
5409 }
5410
5411 #[test]
5412 fn color_rejects_non_hex() {
5413 let mut attrs = Attributes::default();
5415 assert!(apply_declaration("ctx", "scrollbar-track-hover", "blue", &mut attrs).is_err());
5416 }
5417
5418 #[test]
5419 fn scrollbar_hover_boost_parses_number() {
5420 let mut attrs = Attributes::default();
5421 apply_declaration("ctx", "scrollbar-hover-boost", "1.4", &mut attrs).unwrap();
5422 assert_eq!(attrs.scrollbar_hover_boost, Some(1.4));
5423 }
5424
5425 #[test]
5426 fn scrollbar_fade_delay_parses_ms() {
5427 let mut attrs = Attributes::default();
5428 apply_declaration("ctx", "scrollbar-fade-delay", "800ms", &mut attrs).unwrap();
5429 assert_eq!(attrs.scrollbar_fade_delay_ms, Some(800));
5430 }
5431
5432 #[test]
5433 fn scrollbar_fade_duration_parses_seconds() {
5434 let mut attrs = Attributes::default();
5435 apply_declaration("ctx", "scrollbar-fade-duration", "0.2s", &mut attrs).unwrap();
5436 assert_eq!(attrs.scrollbar_fade_duration_ms, Some(200));
5437 }
5438
5439 #[test]
5440 fn none_of_the_batch_is_pseudo_routed() {
5441 let mut attrs = Attributes::default();
5449 let pseudo = SubjectPseudo {
5450 hover: true,
5451 ..Default::default()
5452 };
5453 assert!(apply_decl_for_pseudo("ctx", "knob-inset", "2px", &pseudo, &mut attrs).unwrap());
5454 assert_eq!(attrs.knob_inset, None);
5455 }
5456}
5457
5458#[cfg(test)]
5459mod root_vars_tests {
5460 use super::*;
5461
5462 fn root_rule(decls: &[(&str, &str)], order: usize) -> Rule {
5470 Rule {
5471 selectors: vec![SelectorBuf {
5472 chain: vec![(
5473 Combinator::Subject,
5474 CompoundSelector {
5475 tag: None,
5476 id: None,
5477 classes: Vec::new(),
5478 pseudo_classes: vec![PseudoClass::Root],
5479 },
5480 )],
5481 }],
5482 declarations: decls
5483 .iter()
5484 .map(|(name, value)| Declaration {
5485 name: name.to_string(),
5486 value: value.to_string(),
5487 important: false,
5488 })
5489 .collect(),
5490 origin: Origin::UserAgent,
5491 source_order: order,
5492 media: None,
5493 selector: LegacySelectorShim {
5494 tag: Some("root".to_string()),
5495 classes: Vec::new(),
5496 },
5497 }
5498 }
5499
5500 #[test]
5501 fn root_vars_collects_custom_properties() {
5502 let sheet = Stylesheet {
5503 rules: vec![root_rule(&[("--lumen-window-bg", "#101012")], 0)],
5504 ..Default::default()
5505 };
5506 let vars = sheet.root_vars();
5507 assert_eq!(
5508 vars.get("lumen-window-bg").map(String::as_str),
5509 Some("#101012")
5510 );
5511 }
5512
5513 #[test]
5514 fn later_root_rule_wins_over_earlier_for_same_name() {
5515 let sheet = Stylesheet {
5519 rules: vec![
5520 root_rule(&[("--lumen-window-bg", "#111111")], 0), root_rule(&[("--lumen-window-bg", "#222222")], 1), ],
5523 ..Default::default()
5524 };
5525 assert_eq!(
5526 sheet.resolve_root_var("lumen-window-bg").as_deref(),
5527 Some("#222222")
5528 );
5529 }
5530
5531 #[test]
5532 fn resolve_root_var_follows_nested_var_chain() {
5533 let sheet = Stylesheet {
5534 rules: vec![root_rule(
5535 &[
5536 ("--lumen-accent", "#33c7ce"),
5537 ("--lumen-window-bg", "var(--lumen-accent)"),
5538 ],
5539 0,
5540 )],
5541 ..Default::default()
5542 };
5543 assert_eq!(
5544 sheet.resolve_root_var("lumen-window-bg").as_deref(),
5545 Some("#33c7ce")
5546 );
5547 }
5548
5549 #[test]
5550 fn resolve_root_var_none_when_undefined() {
5551 let sheet = Stylesheet {
5552 rules: vec![root_rule(&[("--lumen-accent", "#33c7ce")], 0)],
5553 ..Default::default()
5554 };
5555 assert_eq!(sheet.resolve_root_var("lumen-window-bg"), None);
5556 }
5557
5558 #[test]
5559 fn structural_root_variant_is_not_a_defining_rule() {
5560 let mut rule = root_rule(&[("--lumen-window-bg", "#000000")], 0);
5564 rule.selectors[0].chain[0]
5569 .1
5570 .classes
5571 .push("dark".to_string());
5572 let sheet = Stylesheet {
5573 rules: vec![rule],
5574 ..Default::default()
5575 };
5576 assert_eq!(sheet.resolve_root_var("lumen-window-bg"), None);
5577 }
5578
5579 #[test]
5580 fn media_guarded_root_rule_is_excluded_even_when_it_sorts_last() {
5581 let base = root_rule(&[("--lumen-window-bg", "#fafafb")], 0);
5588 let mut dark = root_rule(&[("--lumen-window-bg", "#222226")], 1);
5589 dark.media = Some(MediaQuery {
5590 features: vec![MediaFeature::PrefersColorScheme(
5591 ColorSchemePreference::Dark,
5592 )],
5593 });
5594 let sheet = Stylesheet {
5595 rules: vec![base, dark],
5596 ..Default::default()
5597 };
5598 assert_eq!(
5599 sheet.resolve_root_var("lumen-window-bg").as_deref(),
5600 Some("#fafafb"),
5601 "a @media-guarded :root rule must not win over the unconditioned one"
5602 );
5603 }
5604}
5605
5606#[cfg(test)]
5607mod palette_root_css_tests {
5608 use super::*;
5614
5615 #[test]
5616 fn contains_a_base_root_block_and_a_dark_media_override() {
5617 let css = palette_root_css();
5618 assert!(
5619 css.starts_with(":root {"),
5620 "expected the light theme as the base :root block: {css}"
5621 );
5622 assert!(
5623 css.contains("@media (prefers-color-scheme: dark)"),
5624 "expected a dark override block: {css}"
5625 );
5626 }
5627
5628 #[test]
5629 fn every_palette_role_appears_as_a_hyphenated_custom_property() {
5630 let css = palette_root_css();
5631 assert!(css.contains("--accent-color:"), "{css}");
5632 assert!(css.contains("--window-bg-color:"), "{css}");
5633 assert!(
5634 !css.contains("accent_color"),
5635 "role names must be hyphenated, not underscored"
5636 );
5637 }
5638
5639 #[test]
5640 fn is_deterministic_across_calls() {
5641 assert_eq!(palette_root_css(), palette_root_css());
5646 }
5647
5648 #[test]
5649 fn light_and_dark_blocks_disagree_on_window_bg() {
5650 let css = palette_root_css();
5655 let (light_part, dark_part) = css.split_once("@media").expect("has a dark block");
5656 assert!(light_part.contains("--window-bg-color: #fafafbff"));
5657 assert!(dark_part.contains("--window-bg-color: #222226ff"));
5658 }
5659}
5660
5661#[cfg(test)]
5662mod media_query_text_tests {
5663 use super::*;
5664
5665 #[test]
5666 fn every_feature_serializes_to_the_spelling_the_parser_reads() {
5667 let mq = MediaQuery {
5668 features: vec![
5669 MediaFeature::PrefersColorScheme(ColorSchemePreference::Dark),
5670 MediaFeature::PrefersReducedMotion(MotionPreference::Reduce),
5671 MediaFeature::PrefersContrast(ContrastPreference::More),
5672 MediaFeature::MinWidth(600.0),
5673 MediaFeature::MaxWidth(1200.5),
5674 MediaFeature::Width(800.0),
5675 ],
5676 };
5677 assert_eq!(
5678 media_query_to_css(&mq),
5679 "(prefers-color-scheme: dark) and (prefers-reduced-motion: reduce) and \
5680 (prefers-contrast: more) and (min-width: 600px) and (max-width: 1200.5px) and \
5681 (width: 800px)"
5682 );
5683 }
5684
5685 #[test]
5686 fn the_no_preference_values_keep_their_own_spelling() {
5687 let one = |f: MediaFeature| media_query_to_css(&MediaQuery { features: vec![f] });
5688 assert_eq!(
5689 one(MediaFeature::PrefersColorScheme(
5690 ColorSchemePreference::NoPreference
5691 )),
5692 "(prefers-color-scheme: no-preference)"
5693 );
5694 assert_eq!(
5695 one(MediaFeature::PrefersColorScheme(
5696 ColorSchemePreference::Light
5697 )),
5698 "(prefers-color-scheme: light)"
5699 );
5700 assert_eq!(
5701 one(MediaFeature::PrefersReducedMotion(
5702 MotionPreference::NoPreference
5703 )),
5704 "(prefers-reduced-motion: no-preference)"
5705 );
5706 assert_eq!(
5707 one(MediaFeature::PrefersContrast(ContrastPreference::Less)),
5708 "(prefers-contrast: less)"
5709 );
5710 assert_eq!(
5711 one(MediaFeature::PrefersContrast(ContrastPreference::Custom)),
5712 "(prefers-contrast: custom)"
5713 );
5714 assert_eq!(
5715 one(MediaFeature::PrefersContrast(
5716 ContrastPreference::NoPreference
5717 )),
5718 "(prefers-contrast: no-preference)"
5719 );
5720 }
5721
5722 #[test]
5724 fn a_query_with_no_features_is_all() {
5725 let mq = MediaQuery { features: vec![] };
5726 assert!(mq.matches(&MediaContext::default()));
5727 assert_eq!(media_query_to_css(&mq), "all");
5728 }
5729}
5730
5731#[cfg(test)]
5732mod style_property_tests {
5733 use super::*;
5734
5735 #[test]
5739 fn style_property_table_matches_the_cascade() {
5740 for &name in STYLE_PROPERTIES {
5741 let mut attrs = Attributes::default();
5742 let applied = apply_declaration("test", name, "1", &mut attrs);
5743 assert!(
5744 !matches!(applied, Ok(false)),
5745 "`{name}` is in STYLE_PROPERTIES but the cascade does not apply it"
5746 );
5747 assert!(
5748 is_style_property(name),
5749 "`{name}` is in STYLE_PROPERTIES but is_style_property says otherwise"
5750 );
5751 }
5752 }
5753
5754 #[test]
5755 fn standard_css_spellings_answer_for_their_lumen_slot() {
5756 for name in [
5757 "color",
5758 "background",
5759 "background-color",
5760 "border-radius",
5761 "flex-grow",
5762 "justify-content",
5763 "object-fit",
5764 "shrink",
5765 "white-space",
5766 ] {
5767 assert!(is_style_property(name), "`{name}` is a style property");
5768 }
5769 }
5770
5771 #[test]
5772 fn anything_the_cascade_ignores_is_not_a_style_property() {
5773 for name in ["--brand", "float", "content", "", "bg-color"] {
5774 assert!(
5775 !is_style_property(name),
5776 "`{name}` is not a property the cascade applies"
5777 );
5778 }
5779 }
5780}
5781
5782#[cfg(test)]
5783mod selector_text_tests {
5784 use super::*;
5785
5786 const WEB: WebNames<'static> = WebNames {
5790 tag_class_prefix: "lm-",
5791 selected: "data-lm-selected",
5792 checked: "data-lm-checked",
5793 disabled: "data-lm-disabled",
5794 drag_over: "data-lm-drag-over",
5795 checkbox_part: "checkbox-box",
5796 radio_part: "radio-dot",
5797 };
5798
5799 fn one(src: &str) -> SelectorBuf {
5800 parse_selector_list(src)
5801 .unwrap_or_else(|e| panic!("`{src}` does not parse: {e}"))
5802 .remove(0)
5803 }
5804
5805 fn web(src: &str) -> String {
5806 selector_to_web(&one(src), &WEB)
5807 }
5808
5809 fn universal() -> SelectorBuf {
5812 SelectorBuf {
5813 chain: vec![(Combinator::Subject, CompoundSelector::default())],
5814 }
5815 }
5816
5817 #[test]
5818 fn the_universal_selector_is_a_star_in_both_spellings() {
5819 assert_eq!(selector_to_css(&universal()), "*");
5820 assert_eq!(selector_to_web(&universal(), &WEB), "*");
5821 }
5822
5823 #[test]
5824 fn lumen_spelling_round_trips_what_was_authored() {
5825 for src in [
5826 "button",
5827 "button#save.primary:hover",
5828 ".outer .inner > .x",
5829 ".a + .b ~ .c",
5830 ".row:not(.list > .row)",
5831 ":is(button, a):focus-visible",
5832 ] {
5833 assert_eq!(selector_to_css(&one(src)), src);
5834 }
5835 }
5836
5837 #[test]
5838 fn a_tag_becomes_its_class_at_no_specificity() {
5839 assert_eq!(web("button"), ":where(.lm-button)");
5840 assert_eq!(web("root"), ":where(.lm-root)");
5841 assert_eq!(web("row > label"), ":where(.lm-row) > :where(.lm-label)");
5842 assert_eq!(
5845 one("button").specificity(),
5846 Specificity { a: 0, b: 0, c: 1 }
5847 );
5848 }
5849
5850 #[test]
5851 fn classes_ids_and_structural_pseudos_are_left_alone() {
5852 assert_eq!(web("#save"), "#save");
5853 assert_eq!(web(".card:first-child"), ".card:first-child");
5854 assert_eq!(
5855 web("tile.card#main:hover"),
5856 ":where(.lm-tile)#main.card:hover"
5857 );
5858 for src in [":focus", ":focus-visible", ":active", ":empty", ":root"] {
5859 assert_eq!(web(&format!(".x{src}")), format!(".x{src}"));
5860 }
5861 }
5862
5863 #[test]
5864 fn the_states_the_browser_cannot_see_become_their_mirrors() {
5865 assert_eq!(web(".tab:selected"), ".tab[data-lm-selected]");
5866 assert_eq!(web(".drop:drag-over"), ".drop[data-lm-drag-over]");
5867 assert_eq!(web(".t:checked"), ".t:is(:checked, [data-lm-checked])");
5870 assert_eq!(web(".t:disabled"), ".t:is(:disabled, [data-lm-disabled])");
5871 }
5872
5873 #[test]
5874 fn a_checkbox_state_follows_the_element_it_is_on() {
5875 assert_eq!(
5878 web("checkbox:checked"),
5879 ":where(.lm-checkbox) > .checkbox-box:is(:checked, [data-lm-checked])"
5880 );
5881 assert_eq!(
5882 web("radio:selected"),
5883 ":where(.lm-radio) > .radio-dot[data-lm-selected]"
5884 );
5885 assert_eq!(web("checkbox:focus"), ":where(.lm-checkbox):focus-within");
5886 assert_eq!(
5887 web("radio:focus-visible"),
5888 ":where(.lm-radio):has(:focus-visible)"
5889 );
5890 assert_eq!(web("checkbox:hover"), ":where(.lm-checkbox):hover");
5893 assert_eq!(
5894 web("checkbox:disabled"),
5895 ":where(.lm-checkbox):is(:disabled, [data-lm-disabled])"
5896 );
5897 assert_eq!(
5898 web("checkbox.dense:hover:checked"),
5899 ":where(.lm-checkbox).dense:hover > .checkbox-box:is(:checked, [data-lm-checked])"
5900 );
5901 assert_eq!(web(".t:checked"), ".t:is(:checked, [data-lm-checked])");
5903 }
5904
5905 #[test]
5906 fn a_redirected_state_keeps_the_rest_of_the_selector() {
5907 assert_eq!(
5910 web("column > checkbox:checked"),
5911 ":where(.lm-column) > :where(.lm-checkbox) > .checkbox-box:is(:checked, [data-lm-checked])"
5912 );
5913 assert_eq!(
5914 web(".panel radio:checked"),
5915 ".panel :where(.lm-radio) > .radio-dot:is(:checked, [data-lm-checked])"
5916 );
5917 assert_eq!(
5920 web("radio:selected:disabled"),
5921 ":where(.lm-radio):is(:disabled, [data-lm-disabled]) > .radio-dot[data-lm-selected]"
5922 );
5923 assert_eq!(
5925 web("checkbox:checked:selected"),
5926 ":where(.lm-checkbox) > .checkbox-box:is(:checked, [data-lm-checked])[data-lm-selected]"
5927 );
5928 }
5929
5930 #[test]
5931 fn selector_list_arguments_are_rewritten_too() {
5932 assert_eq!(
5933 web(":is(button, a)"),
5934 ":is(:where(.lm-button), :where(.lm-a))"
5935 );
5936 assert_eq!(
5937 web(".row:not(.list > row:selected)"),
5938 ".row:not(.list > :where(.lm-row)[data-lm-selected])"
5939 );
5940 assert_eq!(
5941 web(":where(:not(label))"),
5942 ":where(:not(:where(.lm-label)))"
5943 );
5944 }
5945
5946 #[test]
5947 fn nth_child_is_written_the_way_css_reads_it() {
5948 assert_eq!(web(":nth-child(2n+1)"), ":nth-child(2n+1)");
5949 assert_eq!(web(":nth-child(odd)"), ":nth-child(2n+1)");
5950 assert_eq!(web(":nth-child(3)"), ":nth-child(3)");
5951 assert_eq!(web(":nth-child(2n-1)"), ":nth-child(2n-1)");
5953 assert_eq!(
5954 selector_to_css(&one(":nth-child(2n-1)")),
5955 ":nth-child(2n-1)"
5956 );
5957 }
5958}