Skip to main content

textual_rs/css/
selector.rs

1//! TCSS selector parsing and matching: type, class, ID, and pseudo-class selectors.
2
3use std::iter;
4
5use cssparser::{ParseError, Parser, Token};
6
7use crate::css::types::PseudoClass;
8use crate::widget::context::AppContext;
9use crate::widget::WidgetId;
10
11/// TCSS selector — a single selector or combinator expression.
12#[derive(Debug, Clone, PartialEq)]
13pub enum Selector {
14    /// Matches widgets by type name, e.g. `Button`
15    Type(String),
16    /// Matches widgets with a CSS class, e.g. `.active`
17    Class(String),
18    /// Matches widgets with a specific ID, e.g. `#sidebar`
19    Id(String),
20    /// Matches any widget (`*`)
21    Universal,
22    /// Matches widgets with a given pseudo-class, e.g. `:focus`
23    PseudoClass(PseudoClass),
24    /// Matches a widget that is any descendant of the left selector, e.g. `Screen Button`
25    Descendant(Box<Selector>, Box<Selector>),
26    /// Matches a widget that is a direct child of the left selector, e.g. `Container > Button`
27    Child(Box<Selector>, Box<Selector>),
28    /// Multiple simple selectors that all apply to the same element, e.g. `Button.active:focus`
29    Compound(Vec<Selector>),
30}
31
32/// CSS specificity as (id_count, class_count, type_count).
33#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
34pub struct Specificity(pub u32, pub u32, pub u32);
35
36impl Selector {
37    /// Calculate specificity for this selector.
38    pub fn specificity(&self) -> Specificity {
39        match self {
40            Selector::Id(_) => Specificity(1, 0, 0),
41            Selector::Class(_) => Specificity(0, 1, 0),
42            Selector::PseudoClass(_) => Specificity(0, 1, 0),
43            Selector::Type(_) => Specificity(0, 0, 1),
44            Selector::Universal => Specificity(0, 0, 0),
45            Selector::Compound(parts) => {
46                let mut total = Specificity(0, 0, 0);
47                for s in parts {
48                    let Specificity(a, b, c) = s.specificity();
49                    total = Specificity(total.0 + a, total.1 + b, total.2 + c);
50                }
51                total
52            }
53            Selector::Descendant(left, right) => {
54                let Specificity(a1, b1, c1) = left.specificity();
55                let Specificity(a2, b2, c2) = right.specificity();
56                Specificity(a1 + a2, b1 + b2, c1 + c2)
57            }
58            Selector::Child(left, right) => {
59                let Specificity(a1, b1, c1) = left.specificity();
60                let Specificity(a2, b2, c2) = right.specificity();
61                Specificity(a1 + a2, b1 + b2, c1 + c2)
62            }
63        }
64    }
65}
66
67/// Iterate over ancestors of a widget, from immediate parent upwards.
68fn ancestors(id: WidgetId, ctx: &AppContext) -> impl Iterator<Item = WidgetId> + '_ {
69    iter::successors(ctx.parent.get(id).and_then(|p| *p), move |&cur| {
70        ctx.parent.get(cur).and_then(|p| *p)
71    })
72}
73
74/// Test if a selector matches a specific widget in the given context.
75pub fn selector_matches(sel: &Selector, id: WidgetId, ctx: &AppContext) -> bool {
76    match sel {
77        Selector::Universal => true,
78        Selector::Type(name) => ctx
79            .arena
80            .get(id)
81            .is_some_and(|w| w.widget_type_name() == name.as_str()),
82        Selector::Class(cls) => ctx
83            .arena
84            .get(id)
85            .is_some_and(|w| w.classes().contains(&cls.as_str())),
86        Selector::Id(expected_id) => ctx
87            .arena
88            .get(id)
89            .is_some_and(|w| w.id() == Some(expected_id.as_str())),
90        Selector::PseudoClass(pc) => ctx
91            .pseudo_classes
92            .get(id)
93            .is_some_and(|set| set.contains(pc)),
94        Selector::Compound(parts) => parts.iter().all(|s| selector_matches(s, id, ctx)),
95        Selector::Descendant(ancestor_sel, subject_sel) => {
96            if !selector_matches(subject_sel, id, ctx) {
97                return false;
98            }
99            ancestors(id, ctx).any(|anc_id| selector_matches(ancestor_sel, anc_id, ctx))
100        }
101        Selector::Child(parent_sel, subject_sel) => {
102            if !selector_matches(subject_sel, id, ctx) {
103                return false;
104            }
105            // Get the direct parent
106            ctx.parent
107                .get(id)
108                .and_then(|p| *p)
109                .is_some_and(|parent_id| selector_matches(parent_sel, parent_id, ctx))
110        }
111    }
112}
113
114/// Error type for selector parsing.
115#[derive(Debug, Clone)]
116pub struct SelectorParseError(pub String);
117
118/// Parse a pseudo-class name to PseudoClass variant.
119fn parse_pseudo_class_name(name: &str) -> Option<PseudoClass> {
120    match name {
121        "focus" => Some(PseudoClass::Focus),
122        "hover" => Some(PseudoClass::Hover),
123        "disabled" => Some(PseudoClass::Disabled),
124        _ => None,
125    }
126}
127
128/// Parse a compound selector (sequence of simple selectors without whitespace/combinator).
129fn parse_compound_selector_tokens<'i>(
130    input: &mut Parser<'i, '_>,
131) -> Result<Option<Selector>, ParseError<'i, SelectorParseError>> {
132    let mut simples: Vec<Selector> = Vec::new();
133
134    loop {
135        let state = input.state();
136        let location = input.current_source_location();
137        match input.next_including_whitespace() {
138            Ok(Token::Ident(name)) => {
139                simples.push(Selector::Type(name.to_string()));
140            }
141            Ok(Token::Delim('*')) => {
142                simples.push(Selector::Universal);
143            }
144            Ok(Token::Delim('.')) => {
145                let class_name = input.expect_ident_cloned().map_err(|_| {
146                    location.new_custom_error(SelectorParseError(
147                        "expected class name after '.'".to_string(),
148                    ))
149                })?;
150                simples.push(Selector::Class(class_name.to_string()));
151            }
152            Ok(Token::IDHash(id_val)) => {
153                simples.push(Selector::Id(id_val.to_string()));
154            }
155            Ok(Token::Colon) => {
156                let pc_name = input.expect_ident_cloned().map_err(|_| {
157                    location.new_custom_error(SelectorParseError(
158                        "expected pseudo-class name after ':'".to_string(),
159                    ))
160                })?;
161                match parse_pseudo_class_name(pc_name.as_ref()) {
162                    Some(pc) => simples.push(Selector::PseudoClass(pc)),
163                    None => {
164                        return Err(location.new_custom_error(SelectorParseError(format!(
165                            "unknown pseudo-class: {}",
166                            pc_name
167                        ))));
168                    }
169                }
170            }
171            // Whitespace, comma, combinator, or EOF — stop compound
172            Ok(Token::WhiteSpace(_)) | Ok(Token::Comma) | Ok(Token::Delim('>')) | Err(_) => {
173                input.reset(&state);
174                break;
175            }
176            _ => {
177                input.reset(&state);
178                break;
179            }
180        }
181    }
182
183    if simples.is_empty() {
184        return Ok(None);
185    }
186    if simples.len() == 1 {
187        Ok(Some(simples.remove(0)))
188    } else {
189        Ok(Some(Selector::Compound(simples)))
190    }
191}
192
193/// Parse a single selector (possibly with combinators) from the input.
194fn parse_single_selector<'i>(
195    input: &mut Parser<'i, '_>,
196) -> Result<Option<Selector>, ParseError<'i, SelectorParseError>> {
197    // Skip leading whitespace
198    input.skip_whitespace();
199
200    let first = match parse_compound_selector_tokens(input)? {
201        Some(s) => s,
202        None => return Ok(None),
203    };
204
205    let mut current = first;
206
207    loop {
208        // Check what combinator follows
209        let state = input.state();
210        match input.next_including_whitespace() {
211            Ok(Token::WhiteSpace(_)) => {
212                // Could be descendant combinator OR followed by >
213                // Check if next non-whitespace is >
214                input.skip_whitespace();
215                let state2 = input.state();
216                match input.next_including_whitespace() {
217                    Ok(Token::Delim('>')) => {
218                        // Descendant + > = child combinator (same as direct >)
219                        input.skip_whitespace();
220                        match parse_compound_selector_tokens(input)? {
221                            Some(right) => {
222                                current = Selector::Child(Box::new(current), Box::new(right));
223                            }
224                            None => break,
225                        }
226                    }
227                    Ok(Token::Comma) | Err(_) => {
228                        input.reset(&state2);
229                        break;
230                    }
231                    _ => {
232                        // Descendant combinator — reset and parse right-hand compound
233                        input.reset(&state2);
234                        match parse_compound_selector_tokens(input)? {
235                            Some(right) => {
236                                current = Selector::Descendant(Box::new(current), Box::new(right));
237                            }
238                            None => break,
239                        }
240                    }
241                }
242            }
243            Ok(Token::Delim('>')) => {
244                // Child combinator
245                input.skip_whitespace();
246                match parse_compound_selector_tokens(input)? {
247                    Some(right) => {
248                        current = Selector::Child(Box::new(current), Box::new(right));
249                    }
250                    None => break,
251                }
252            }
253            Ok(Token::Comma) | Err(_) => {
254                input.reset(&state);
255                break;
256            }
257            _ => {
258                input.reset(&state);
259                break;
260            }
261        }
262    }
263
264    Ok(Some(current))
265}
266
267/// Selector parser that operates on a cssparser input stream.
268pub struct SelectorParser;
269
270impl SelectorParser {
271    /// Parse a comma-separated list of selectors.
272    pub fn parse_selector_list<'i>(
273        input: &mut Parser<'i, '_>,
274    ) -> Result<Vec<Selector>, ParseError<'i, SelectorParseError>> {
275        let mut selectors = Vec::new();
276
277        loop {
278            input.skip_whitespace();
279            if input.is_exhausted() {
280                break;
281            }
282
283            match parse_single_selector(input)? {
284                Some(sel) => selectors.push(sel),
285                None => break,
286            }
287
288            input.skip_whitespace();
289            if input.is_exhausted() {
290                break;
291            }
292
293            // Try to consume comma for next selector
294            let state = input.state();
295            match input.next() {
296                Ok(Token::Comma) => {
297                    // Continue to next selector
298                }
299                _ => {
300                    input.reset(&state);
301                    break;
302                }
303            }
304        }
305
306        Ok(selectors)
307    }
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313    use crate::css::types::{ComputedStyle, PseudoClassSet};
314    use crate::widget::context::AppContext;
315    use ratatui::{buffer::Buffer, layout::Rect};
316
317    fn parse_selector(input: &str) -> Vec<Selector> {
318        let mut parser_input = cssparser::ParserInput::new(input);
319        let mut parser = cssparser::Parser::new(&mut parser_input);
320        SelectorParser::parse_selector_list(&mut parser).expect("parse failed")
321    }
322
323    // Minimal widget for tests
324    struct TypeWidget(&'static str);
325    impl crate::widget::Widget for TypeWidget {
326        fn render(&self, _: &AppContext, _: Rect, _: &mut Buffer) {}
327        fn widget_type_name(&self) -> &'static str {
328            self.0
329        }
330    }
331
332    fn make_single_widget_ctx(w: Box<dyn crate::widget::Widget>) -> (AppContext, WidgetId) {
333        let mut ctx = AppContext::new();
334        let id = ctx.arena.insert(w);
335        ctx.parent.insert(id, None);
336        ctx.pseudo_classes.insert(id, PseudoClassSet::default());
337        ctx.computed_styles.insert(id, ComputedStyle::default());
338        ctx.inline_styles.insert(id, Vec::new());
339        (ctx, id)
340    }
341
342    #[test]
343    fn parse_type_selector() {
344        let sels = parse_selector("Button");
345        assert_eq!(sels, vec![Selector::Type("Button".to_string())]);
346    }
347
348    #[test]
349    fn parse_class_selector() {
350        let sels = parse_selector(".highlight");
351        assert_eq!(sels, vec![Selector::Class("highlight".to_string())]);
352    }
353
354    #[test]
355    fn parse_id_selector() {
356        let sels = parse_selector("#sidebar");
357        assert_eq!(sels, vec![Selector::Id("sidebar".to_string())]);
358    }
359
360    #[test]
361    fn parse_compound_selector_type_class() {
362        let sels = parse_selector("Button.active");
363        assert_eq!(
364            sels,
365            vec![Selector::Compound(vec![
366                Selector::Type("Button".to_string()),
367                Selector::Class("active".to_string()),
368            ])]
369        );
370    }
371
372    #[test]
373    fn parse_descendant_selector() {
374        let sels = parse_selector("Screen Button");
375        assert_eq!(
376            sels,
377            vec![Selector::Descendant(
378                Box::new(Selector::Type("Screen".to_string())),
379                Box::new(Selector::Type("Button".to_string())),
380            )]
381        );
382    }
383
384    #[test]
385    fn parse_child_selector() {
386        let sels = parse_selector("Container > Button");
387        assert_eq!(
388            sels,
389            vec![Selector::Child(
390                Box::new(Selector::Type("Container".to_string())),
391                Box::new(Selector::Type("Button".to_string())),
392            )]
393        );
394    }
395
396    #[test]
397    fn parse_pseudo_class_selector() {
398        let sels = parse_selector("Button:focus");
399        assert_eq!(
400            sels,
401            vec![Selector::Compound(vec![
402                Selector::Type("Button".to_string()),
403                Selector::PseudoClass(PseudoClass::Focus),
404            ])]
405        );
406    }
407
408    #[test]
409    fn parse_universal_selector() {
410        let sels = parse_selector("*");
411        assert_eq!(sels, vec![Selector::Universal]);
412    }
413
414    #[test]
415    fn selector_matches_type_correct() {
416        let (ctx, id) = make_single_widget_ctx(Box::new(TypeWidget("Button")));
417        assert!(selector_matches(
418            &Selector::Type("Button".to_string()),
419            id,
420            &ctx
421        ));
422    }
423
424    #[test]
425    fn selector_matches_type_wrong() {
426        let (ctx, id) = make_single_widget_ctx(Box::new(TypeWidget("Label")));
427        assert!(!selector_matches(
428            &Selector::Type("Button".to_string()),
429            id,
430            &ctx
431        ));
432    }
433
434    #[test]
435    fn selector_matches_descendant() {
436        let mut ctx = AppContext::new();
437        let screen = ctx
438            .arena
439            .insert(Box::new(TypeWidget("Screen")) as Box<dyn crate::widget::Widget>);
440        let button = ctx
441            .arena
442            .insert(Box::new(TypeWidget("Button")) as Box<dyn crate::widget::Widget>);
443        ctx.parent.insert(screen, None);
444        ctx.parent.insert(button, Some(screen));
445        ctx.pseudo_classes.insert(screen, PseudoClassSet::default());
446        ctx.pseudo_classes.insert(button, PseudoClassSet::default());
447        ctx.children.insert(screen, vec![button]);
448
449        let sel = Selector::Descendant(
450            Box::new(Selector::Type("Screen".to_string())),
451            Box::new(Selector::Type("Button".to_string())),
452        );
453        assert!(selector_matches(&sel, button, &ctx));
454    }
455
456    #[test]
457    fn selector_matches_child_non_parent_returns_false() {
458        // Screen -> Container -> Button; test "Screen > Button" against Button (should be false)
459        let mut ctx = AppContext::new();
460        let screen = ctx
461            .arena
462            .insert(Box::new(TypeWidget("Screen")) as Box<dyn crate::widget::Widget>);
463        let container = ctx
464            .arena
465            .insert(Box::new(TypeWidget("Container")) as Box<dyn crate::widget::Widget>);
466        let button = ctx
467            .arena
468            .insert(Box::new(TypeWidget("Button")) as Box<dyn crate::widget::Widget>);
469        ctx.parent.insert(screen, None);
470        ctx.parent.insert(container, Some(screen));
471        ctx.parent.insert(button, Some(container));
472        ctx.pseudo_classes.insert(screen, PseudoClassSet::default());
473        ctx.pseudo_classes
474            .insert(container, PseudoClassSet::default());
475        ctx.pseudo_classes.insert(button, PseudoClassSet::default());
476
477        let sel = Selector::Child(
478            Box::new(Selector::Type("Screen".to_string())),
479            Box::new(Selector::Type("Button".to_string())),
480        );
481        // Screen is NOT the direct parent of Button, so should be false
482        assert!(!selector_matches(&sel, button, &ctx));
483    }
484
485    #[test]
486    fn selector_matches_pseudo_class_focus() {
487        let mut ctx = AppContext::new();
488        let btn = ctx
489            .arena
490            .insert(Box::new(TypeWidget("Button")) as Box<dyn crate::widget::Widget>);
491        ctx.parent.insert(btn, None);
492        let mut pcs = PseudoClassSet::default();
493        pcs.insert(PseudoClass::Focus);
494        ctx.pseudo_classes.insert(btn, pcs);
495
496        assert!(selector_matches(
497            &Selector::PseudoClass(PseudoClass::Focus),
498            btn,
499            &ctx
500        ));
501    }
502
503    #[test]
504    fn specificity_ordering() {
505        let type_spec = Selector::Type("Button".to_string()).specificity();
506        let class_spec = Selector::Class("active".to_string()).specificity();
507        let id_spec = Selector::Id("main".to_string()).specificity();
508
509        assert!(
510            type_spec < class_spec,
511            "type should have lower specificity than class"
512        );
513        assert!(
514            class_spec < id_spec,
515            "class should have lower specificity than id"
516        );
517        assert_eq!(type_spec, Specificity(0, 0, 1));
518        assert_eq!(class_spec, Specificity(0, 1, 0));
519        assert_eq!(id_spec, Specificity(1, 0, 0));
520    }
521}