Skip to main content

oxiui_theme/
stylesheet.rs

1//! CSS-subset stylesheet parser with cascading specificity resolution.
2//!
3//! Implements a hand-written recursive-descent parser for a CSS-like syntax
4//! supporting type, class, and id selectors plus compound and grouped
5//! selectors.  Malformed rules are skipped with diagnostics rather than
6//! panicking, ensuring forward progress through the input.
7//!
8//! # Supported grammar subset
9//! ```text
10//! stylesheet   = rule*
11//! rule         = selector_list '{' declarations '}'
12//! selector_list= selector (',' selector)*
13//! selector     = simple_selector+
14//! simple_selector = type_selector? (class | id)*
15//! type_selector= IDENT
16//! class        = '.' IDENT
17//! id           = '#' IDENT
18//! declarations = declaration*
19//! declaration  = property ':' value ';'
20//! property     = 'color' | 'background' | 'background-color' | 'padding' |
21//!                'margin' | 'font-size' | 'font-weight' | 'border-color' |
22//!                'border-width' | 'opacity'
23//! value        = hex_color | rgb() | rgba() | number | IDENT
24//! ```
25
26use oxiui_core::Color;
27
28// ── Value types ────────────────────────────────────────────────────────────────
29
30/// A parsed CSS property value.
31#[derive(Debug, Clone, PartialEq)]
32pub enum CssValue {
33    /// A colour value (e.g. `#ff0000`, `rgb(255, 0, 0)`).
34    Color(Color),
35    /// A numeric value in pixels or unitless (e.g. `14`, `8px`).
36    Number(f32),
37    /// An unrecognised keyword (e.g. `bold`, `auto`).
38    Keyword(String),
39    /// The CSS `inherit` keyword.
40    Inherit,
41    /// The CSS `initial` keyword.
42    Initial,
43    /// The CSS `unset` keyword.
44    Unset,
45}
46
47/// A set of parsed CSS declarations for a single element or rule block.
48#[derive(Debug, Clone, Default, PartialEq)]
49pub struct ComputedStyle {
50    /// The `color` property (text / foreground colour).
51    pub color: Option<CssValue>,
52    /// The `background-color` / `background` property.
53    pub background_color: Option<CssValue>,
54    /// The `padding` property in logical pixels.
55    pub padding: Option<f32>,
56    /// The `margin` property in logical pixels.
57    pub margin: Option<f32>,
58    /// The `font-size` property in logical pixels.
59    pub font_size: Option<f32>,
60    /// The `font-weight` property (e.g. `400`, `700`).
61    pub font_weight: Option<f32>,
62    /// The `border-color` property.
63    pub border_color: Option<CssValue>,
64    /// The `border-width` property in logical pixels.
65    pub border_width: Option<f32>,
66    /// The `opacity` property in the range `[0.0, 1.0]`.
67    pub opacity: Option<f32>,
68}
69
70// ── Selector types ─────────────────────────────────────────────────────────────
71
72/// Selector specificity expressed as `(id_count, class_count, type_count)`.
73///
74/// Higher tuples win in a CSS cascade.  The ordering is lexicographic, which
75/// matches the CSS specification.
76#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
77pub struct Specificity(pub u32, pub u32, pub u32);
78
79impl Specificity {
80    /// Increment the id component (each `#id` selector part).
81    pub fn add_id(&mut self) {
82        self.0 += 1;
83    }
84
85    /// Increment the class component (each `.class` selector part).
86    pub fn add_class(&mut self) {
87        self.1 += 1;
88    }
89
90    /// Increment the type component (each element-type selector part).
91    pub fn add_type(&mut self) {
92        self.2 += 1;
93    }
94}
95
96/// A single part of a compound selector.
97#[derive(Debug, Clone)]
98pub enum SelectorPart {
99    /// An element-type selector (e.g. `button`).
100    Type(String),
101    /// A class selector (e.g. `.primary`).
102    Class(String),
103    /// An id selector (e.g. `#submit`).
104    Id(String),
105}
106
107/// A parsed CSS selector with pre-computed specificity.
108#[derive(Debug, Clone)]
109pub struct Selector {
110    /// The ordered parts that make up this compound selector.
111    pub parts: Vec<SelectorPart>,
112    /// Pre-computed specificity used for cascade ordering.
113    pub specificity: Specificity,
114}
115
116/// A parsed CSS rule: one or more selectors paired with a declarations block.
117#[derive(Debug, Clone)]
118pub struct Rule {
119    /// The selectors that trigger this rule.
120    pub selectors: Vec<Selector>,
121    /// The computed style declared in the rule block.
122    pub style: ComputedStyle,
123    /// Insertion index within the stylesheet; used to break specificity ties.
124    pub source_order: usize,
125}
126
127/// A parsed stylesheet containing zero or more rules.
128#[derive(Debug, Clone, Default)]
129pub struct StyleSheet {
130    /// The rules parsed from the CSS source, in source order.
131    pub rules: Vec<Rule>,
132}
133
134/// A single non-fatal parse error.
135#[derive(Debug, Clone)]
136pub struct ParseDiagnostic {
137    /// Byte offset in the source where the error was detected.
138    pub offset: usize,
139    /// Human-readable description of the problem.
140    pub message: String,
141}
142
143/// The result of parsing a stylesheet.
144#[derive(Debug, Clone, Default)]
145pub struct ParseResult {
146    /// The successfully parsed stylesheet (may contain fewer rules than the
147    /// source if some rules triggered diagnostics).
148    pub stylesheet: StyleSheet,
149    /// Non-fatal parse errors encountered during parsing.
150    pub diagnostics: Vec<ParseDiagnostic>,
151}
152
153// ── StyleSheet impl ────────────────────────────────────────────────────────────
154
155impl StyleSheet {
156    /// Parse a CSS-subset string.
157    ///
158    /// Malformed rules are skipped with [`ParseDiagnostic`]s rather than
159    /// causing a panic.  Valid rules following a malformed one are still
160    /// parsed.
161    pub fn parse(input: &str) -> ParseResult {
162        let mut parser = Parser::new(input);
163        parser.parse_stylesheet()
164    }
165
166    /// Find all rules that match a widget described by type, classes, and id.
167    ///
168    /// Returns rules sorted ascending by `(specificity, source_order)` so that
169    /// the caller can apply them in order and the last write wins (standard CSS
170    /// cascade).
171    pub fn matching_rules<'a>(
172        &'a self,
173        widget_type: &str,
174        classes: &[&str],
175        id: Option<&str>,
176    ) -> Vec<(&'a Rule, Specificity)> {
177        let mut matches = Vec::new();
178        for rule in &self.rules {
179            for selector in &rule.selectors {
180                if selector_matches(selector, widget_type, classes, id) {
181                    matches.push((rule, selector.specificity));
182                    break; // first matching selector for this rule is sufficient
183                }
184            }
185        }
186        matches.sort_by(|a, b| a.1.cmp(&b.1).then(a.0.source_order.cmp(&b.0.source_order)));
187        matches
188    }
189
190    /// Compute the final [`ComputedStyle`] for a widget by cascading all
191    /// matching rules.
192    ///
193    /// Rules are applied in ascending specificity / source-order, so the last
194    /// (most specific) rule wins for each property.
195    pub fn compute_style(
196        &self,
197        widget_type: &str,
198        classes: &[&str],
199        id: Option<&str>,
200    ) -> ComputedStyle {
201        let mut result = ComputedStyle::default();
202        for (rule, _) in self.matching_rules(widget_type, classes, id) {
203            apply_rule(&mut result, &rule.style);
204        }
205        result
206    }
207}
208
209/// Returns `true` if every part of `sel` matches the described widget.
210pub(crate) fn selector_matches(
211    sel: &Selector,
212    widget_type: &str,
213    classes: &[&str],
214    id: Option<&str>,
215) -> bool {
216    for part in &sel.parts {
217        match part {
218            SelectorPart::Type(t) => {
219                if t != widget_type {
220                    return false;
221                }
222            }
223            SelectorPart::Class(c) => {
224                if !classes.contains(&c.as_str()) {
225                    return false;
226                }
227            }
228            SelectorPart::Id(i) => {
229                if id != Some(i.as_str()) {
230                    return false;
231                }
232            }
233        }
234    }
235    true
236}
237
238/// Apply all set properties from `source` into `target` (last-writer-wins).
239pub(crate) fn apply_rule(target: &mut ComputedStyle, source: &ComputedStyle) {
240    if let Some(v) = &source.color {
241        target.color = Some(v.clone());
242    }
243    if let Some(v) = &source.background_color {
244        target.background_color = Some(v.clone());
245    }
246    if let Some(v) = source.padding {
247        target.padding = Some(v);
248    }
249    if let Some(v) = source.margin {
250        target.margin = Some(v);
251    }
252    if let Some(v) = source.font_size {
253        target.font_size = Some(v);
254    }
255    if let Some(v) = source.font_weight {
256        target.font_weight = Some(v);
257    }
258    if let Some(v) = &source.border_color {
259        target.border_color = Some(v.clone());
260    }
261    if let Some(v) = source.border_width {
262        target.border_width = Some(v);
263    }
264    if let Some(v) = source.opacity {
265        target.opacity = Some(v);
266    }
267}
268
269// ── Parser ─────────────────────────────────────────────────────────────────────
270
271struct Parser<'a> {
272    input: &'a str,
273    pos: usize,
274    source_order: usize,
275}
276
277impl<'a> Parser<'a> {
278    fn new(input: &'a str) -> Self {
279        Self {
280            input,
281            pos: 0,
282            source_order: 0,
283        }
284    }
285
286    fn remaining(&self) -> &str {
287        &self.input[self.pos..]
288    }
289
290    fn is_eof(&self) -> bool {
291        self.pos >= self.input.len()
292    }
293
294    fn skip_whitespace(&mut self) {
295        while !self.is_eof() {
296            let ch = self.remaining().chars().next().unwrap_or('\0');
297            if ch.is_whitespace() {
298                self.pos += ch.len_utf8();
299            } else if self.remaining().starts_with("/*") {
300                if let Some(end) = self.remaining().find("*/") {
301                    self.pos += end + 2;
302                } else {
303                    self.pos = self.input.len();
304                }
305            } else {
306                break;
307            }
308        }
309    }
310
311    fn parse_ident(&mut self) -> Option<String> {
312        self.skip_whitespace();
313        let start = self.pos;
314        let mut end = start;
315        for (i, ch) in self.remaining().char_indices() {
316            if ch.is_alphanumeric() || ch == '-' || ch == '_' {
317                end = start + i + ch.len_utf8();
318            } else {
319                break;
320            }
321        }
322        if end > start {
323            let ident = self.input[start..end].to_owned();
324            self.pos = end;
325            Some(ident)
326        } else {
327            None
328        }
329    }
330
331    fn consume_char(&mut self, expected: char) -> bool {
332        self.skip_whitespace();
333        if self.remaining().starts_with(expected) {
334            self.pos += expected.len_utf8();
335            true
336        } else {
337            false
338        }
339    }
340
341    fn parse_stylesheet(&mut self) -> ParseResult {
342        let mut rules = Vec::new();
343        let mut diagnostics = Vec::new();
344        self.skip_whitespace();
345        while !self.is_eof() {
346            let before = self.pos;
347            match self.parse_rule() {
348                Ok(Some(rule)) => rules.push(rule),
349                Ok(None) => {}
350                Err(d) => {
351                    diagnostics.push(d);
352                    self.recover_to_next_rule();
353                }
354            }
355            // Guarantee forward progress so we never loop on unmovable input.
356            if self.pos == before {
357                let step = self
358                    .remaining()
359                    .chars()
360                    .next()
361                    .map(char::len_utf8)
362                    .unwrap_or(1);
363                self.pos += step;
364            }
365            self.skip_whitespace();
366        }
367        ParseResult {
368            stylesheet: StyleSheet { rules },
369            diagnostics,
370        }
371    }
372
373    fn recover_to_next_rule(&mut self) {
374        while !self.is_eof() {
375            if self.remaining().starts_with('}') {
376                self.pos += 1;
377                break;
378            }
379            self.pos += 1;
380        }
381    }
382
383    fn parse_rule(&mut self) -> Result<Option<Rule>, ParseDiagnostic> {
384        let selectors = self.parse_selector_list()?;
385        if selectors.is_empty() {
386            return Ok(None);
387        }
388        if !self.consume_char('{') {
389            return Err(ParseDiagnostic {
390                offset: self.pos,
391                message: "expected '{'".into(),
392            });
393        }
394        let style = self.parse_declarations();
395        self.consume_char('}');
396        let order = self.source_order;
397        self.source_order += 1;
398        Ok(Some(Rule {
399            selectors,
400            style,
401            source_order: order,
402        }))
403    }
404
405    fn parse_selector_list(&mut self) -> Result<Vec<Selector>, ParseDiagnostic> {
406        let mut selectors = Vec::new();
407        loop {
408            self.skip_whitespace();
409            if self.is_eof() || self.remaining().starts_with('{') {
410                break;
411            }
412            if let Some(sel) = self.parse_selector() {
413                selectors.push(sel);
414            }
415            self.skip_whitespace();
416            if self.remaining().starts_with(',') {
417                self.pos += 1;
418            } else {
419                break;
420            }
421        }
422        Ok(selectors)
423    }
424
425    fn parse_selector(&mut self) -> Option<Selector> {
426        self.skip_whitespace();
427        let mut parts = Vec::new();
428        let mut spec = Specificity::default();
429        loop {
430            self.skip_whitespace();
431            if self.is_eof()
432                || self.remaining().starts_with('{')
433                || self.remaining().starts_with(',')
434            {
435                break;
436            }
437            if self.remaining().starts_with('.') {
438                self.pos += 1;
439                if let Some(class) = self.parse_ident() {
440                    spec.add_class();
441                    parts.push(SelectorPart::Class(class));
442                }
443            } else if self.remaining().starts_with('#') {
444                self.pos += 1;
445                if let Some(id) = self.parse_ident() {
446                    spec.add_id();
447                    parts.push(SelectorPart::Id(id));
448                }
449            } else if let Some(ident) = self.parse_ident() {
450                spec.add_type();
451                parts.push(SelectorPart::Type(ident));
452            } else {
453                break;
454            }
455        }
456        if parts.is_empty() {
457            None
458        } else {
459            Some(Selector {
460                parts,
461                specificity: spec,
462            })
463        }
464    }
465
466    fn parse_declarations(&mut self) -> ComputedStyle {
467        let mut style = ComputedStyle::default();
468        loop {
469            self.skip_whitespace();
470            if self.is_eof() || self.remaining().starts_with('}') {
471                break;
472            }
473            self.parse_declaration(&mut style);
474        }
475        style
476    }
477
478    fn parse_declaration(&mut self, style: &mut ComputedStyle) {
479        self.skip_whitespace();
480        let prop = match self.parse_ident() {
481            Some(p) => p,
482            None => {
483                self.skip_to_semicolon();
484                return;
485            }
486        };
487        self.skip_whitespace();
488        if !self.consume_char(':') {
489            self.skip_to_semicolon();
490            return;
491        }
492        self.skip_whitespace();
493        let value = self.parse_value();
494        self.skip_to_semicolon();
495        if let Some(v) = value {
496            match prop.as_str() {
497                "color" => style.color = Some(v),
498                "background" | "background-color" => style.background_color = Some(v),
499                "padding" => {
500                    if let CssValue::Number(n) = &v {
501                        style.padding = Some(*n);
502                    }
503                }
504                "margin" => {
505                    if let CssValue::Number(n) = &v {
506                        style.margin = Some(*n);
507                    }
508                }
509                "font-size" => {
510                    if let CssValue::Number(n) = &v {
511                        style.font_size = Some(*n);
512                    }
513                }
514                "font-weight" => {
515                    if let CssValue::Number(n) = &v {
516                        style.font_weight = Some(*n);
517                    }
518                }
519                "border-color" => style.border_color = Some(v),
520                "border-width" => {
521                    if let CssValue::Number(n) = &v {
522                        style.border_width = Some(*n);
523                    }
524                }
525                "opacity" => {
526                    if let CssValue::Number(n) = &v {
527                        style.opacity = Some(*n);
528                    }
529                }
530                _ => {} // unknown property silently ignored
531            }
532        }
533    }
534
535    fn parse_value(&mut self) -> Option<CssValue> {
536        self.skip_whitespace();
537        if self.remaining().starts_with('#') {
538            self.pos += 1;
539            return self.parse_hex_color();
540        }
541        if self.remaining().starts_with("rgba(") || self.remaining().starts_with("rgb(") {
542            return self.parse_rgb_color();
543        }
544        // Try number (with optional `px` suffix).
545        let start = self.pos;
546        let mut end = start;
547        let mut has_digit = false;
548        let mut has_dot = false;
549        for (i, ch) in self.remaining().char_indices() {
550            if ch.is_ascii_digit() {
551                has_digit = true;
552                end = start + i + 1;
553            } else if ch == '.' && !has_dot {
554                has_dot = true;
555                end = start + i + 1;
556            } else if ch == 'p' || ch == 'x' {
557                end = start + i + 1; // consume 'px' suffix
558            } else {
559                break;
560            }
561        }
562        if has_digit {
563            let num_str: String = self.input[start..end]
564                .chars()
565                .filter(|c| c.is_ascii_digit() || *c == '.')
566                .collect();
567            self.pos = end;
568            return num_str.parse::<f32>().ok().map(CssValue::Number);
569        }
570        // Keyword (inherit / initial / unset / other).
571        if let Some(ident) = self.parse_ident() {
572            return Some(match ident.as_str() {
573                "inherit" => CssValue::Inherit,
574                "initial" => CssValue::Initial,
575                "unset" => CssValue::Unset,
576                _ => CssValue::Keyword(ident),
577            });
578        }
579        None
580    }
581
582    fn parse_hex_color(&mut self) -> Option<CssValue> {
583        let start = self.pos;
584        let hex: String = self
585            .remaining()
586            .chars()
587            .take_while(|c| c.is_ascii_hexdigit())
588            .collect();
589        self.pos += hex.len();
590        let color = match hex.len() {
591            6 => {
592                let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
593                let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
594                let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
595                Color(r, g, b, 255)
596            }
597            8 => {
598                let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
599                let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
600                let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
601                let a = u8::from_str_radix(&hex[6..8], 16).ok()?;
602                Color(r, g, b, a)
603            }
604            3 => {
605                let r = u8::from_str_radix(&hex[0..1].repeat(2), 16).ok()?;
606                let g = u8::from_str_radix(&hex[1..2].repeat(2), 16).ok()?;
607                let b = u8::from_str_radix(&hex[2..3].repeat(2), 16).ok()?;
608                Color(r, g, b, 255)
609            }
610            _ => {
611                self.pos = start;
612                return None;
613            }
614        };
615        Some(CssValue::Color(color))
616    }
617
618    fn parse_rgb_color(&mut self) -> Option<CssValue> {
619        let skip = if self.remaining().starts_with("rgba(") {
620            5
621        } else {
622            4
623        };
624        self.pos += skip;
625        let r = self.parse_number_u8()?;
626        self.consume_char(',');
627        let g = self.parse_number_u8()?;
628        self.consume_char(',');
629        let b = self.parse_number_u8()?;
630        let a = if self.remaining().trim_start().starts_with(',') {
631            self.consume_char(',');
632            self.skip_whitespace();
633            let alpha_str: String = self
634                .remaining()
635                .chars()
636                .take_while(|c| c.is_ascii_digit() || *c == '.')
637                .collect();
638            self.pos += alpha_str.len();
639            (alpha_str.parse::<f32>().unwrap_or(1.0) * 255.0) as u8
640        } else {
641            255
642        };
643        self.consume_char(')');
644        Some(CssValue::Color(Color(r, g, b, a)))
645    }
646
647    fn parse_number_u8(&mut self) -> Option<u8> {
648        self.skip_whitespace();
649        let digits: String = self
650            .remaining()
651            .chars()
652            .take_while(|c| c.is_ascii_digit())
653            .collect();
654        if digits.is_empty() {
655            return None;
656        }
657        self.pos += digits.len();
658        digits.parse::<u8>().ok()
659    }
660
661    fn skip_to_semicolon(&mut self) {
662        while !self.is_eof() {
663            if self.remaining().starts_with(';') {
664                self.pos += 1;
665                break;
666            }
667            if self.remaining().starts_with('}') {
668                break; // do not consume '}'
669            }
670            self.pos += 1;
671        }
672    }
673}