Skip to main content

textual_rs/css/
cascade.rs

1//! CSS cascade resolution: applies stylesheets in specificity order to compute final widget styles.
2
3use crate::css::parser::{parse_stylesheet, Rule};
4use crate::css::selector::{selector_matches, Specificity};
5use crate::css::theme::Theme;
6use crate::css::types::{ComputedStyle, Declaration, TcssValue};
7use crate::widget::context::AppContext;
8use crate::widget::WidgetId;
9
10/// Resolve theme variable references in declarations.
11///
12/// Replaces `TcssValue::Variable(name)` with `TcssValue::Color(resolved)` using the theme.
13/// Unknown variables are left as Variable (silently ignored by apply_declarations).
14fn resolve_variables(decls: &[Declaration], theme: &Theme) -> Vec<Declaration> {
15    decls
16        .iter()
17        .map(|d| match &d.value {
18            TcssValue::Variable(ref name) => {
19                if let Some(color) = theme.resolve(name) {
20                    Declaration {
21                        property: d.property.clone(),
22                        value: TcssValue::Color(color),
23                    }
24                } else {
25                    d.clone()
26                }
27            }
28            TcssValue::BorderWithVariable(ref style, ref name) => {
29                if let Some(color) = theme.resolve(name) {
30                    Declaration {
31                        property: d.property.clone(),
32                        value: TcssValue::BorderWithColor(*style, color),
33                    }
34                } else {
35                    d.clone()
36                }
37            }
38            _ => d.clone(),
39        })
40        .collect()
41}
42
43/// A parsed TCSS stylesheet with its rules.
44#[derive(Debug, Clone, Default)]
45pub struct Stylesheet {
46    /// The ordered list of CSS rules parsed from the stylesheet text.
47    pub rules: Vec<Rule>,
48}
49
50impl Stylesheet {
51    /// Parse a CSS string into a Stylesheet, returning any parse errors.
52    pub fn parse(css: &str) -> (Self, Vec<String>) {
53        let (rules, errors) = parse_stylesheet(css);
54        (Stylesheet { rules }, errors)
55    }
56
57    /// Create an empty stylesheet.
58    pub fn empty() -> Self {
59        Stylesheet { rules: Vec::new() }
60    }
61}
62
63/// Build a stylesheet from multiple CSS strings (e.g. default CSS from widget types).
64pub fn stylesheet_from_css_strings(css_strings: &[&str]) -> (Stylesheet, Vec<String>) {
65    let combined = css_strings.join("\n");
66    Stylesheet::parse(&combined)
67}
68
69/// Resolve the cascade for a single widget, returning its ComputedStyle.
70///
71/// Stylesheets are applied in order (index 0 = lowest precedence, last = highest),
72/// within each stylesheet rules are applied in specificity order (lower specificity first),
73/// with source order as tiebreaker (later rule wins at equal specificity).
74/// Inline styles (ctx.inline_styles) are applied last, overriding all.
75pub fn resolve_cascade(
76    widget_id: WidgetId,
77    stylesheets: &[Stylesheet],
78    ctx: &AppContext,
79) -> ComputedStyle {
80    // Collect matching rules with their sort keys
81    // Sort key: (specificity, source_order) — lower values applied first (overridden by higher)
82    let mut matched: Vec<(Specificity, usize, &Vec<Declaration>)> = Vec::new();
83
84    for (sheet_idx, stylesheet) in stylesheets.iter().enumerate() {
85        for (rule_idx, rule) in stylesheet.rules.iter().enumerate() {
86            // Find the highest specificity matching selector for this rule
87            let max_spec = rule
88                .selectors
89                .iter()
90                .filter(|sel| selector_matches(sel, widget_id, ctx))
91                .map(|sel| sel.specificity())
92                .max();
93
94            if let Some(spec) = max_spec {
95                let source_order = sheet_idx * 100_000 + rule_idx;
96                matched.push((spec, source_order, &rule.declarations));
97            }
98        }
99    }
100
101    // Sort ascending: lower specificity/source_order first → applied first, then overridden
102    matched.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
103
104    // Apply declarations in order (later overwrites earlier), resolving theme variables
105    let mut style = ComputedStyle::default();
106    for (_, _, decls) in &matched {
107        let resolved = resolve_variables(decls, &ctx.theme);
108        style.apply_declarations(&resolved);
109    }
110
111    // Apply inline styles last (highest specificity), also resolving variables
112    if let Some(inline) = ctx.inline_styles.get(widget_id) {
113        let resolved = resolve_variables(inline, &ctx.theme);
114        style.apply_declarations(&resolved);
115    }
116
117    style
118}
119
120/// Walk the widget subtree rooted at `screen_id` in DFS order and compute styles for all widgets.
121pub fn apply_cascade_to_tree(
122    screen_id: WidgetId,
123    stylesheets: &[Stylesheet],
124    ctx: &mut AppContext,
125) {
126    // Collect all widget IDs in DFS order
127    let mut stack = vec![screen_id];
128    let mut order = Vec::new();
129
130    while let Some(id) = stack.pop() {
131        order.push(id);
132        if let Some(children) = ctx.children.get(id) {
133            // Push in reverse order so we process first child first
134            for &child in children.iter().rev() {
135                stack.push(child);
136            }
137        }
138    }
139
140    // Compute and store styles
141    for id in order {
142        let computed = resolve_cascade(id, stylesheets, ctx);
143        ctx.computed_styles.insert(id, computed);
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use crate::css::types::{
151        BorderStyle, ComputedStyle, Declaration, PseudoClass, PseudoClassSet, TcssColor,
152        TcssDisplay, TcssValue,
153    };
154    use crate::widget::context::AppContext;
155    use ratatui::{buffer::Buffer, layout::Rect};
156
157    // Test widgets
158    struct TestWidget {
159        type_name: &'static str,
160        classes: Vec<&'static str>,
161        id: Option<&'static str>,
162    }
163
164    impl crate::widget::Widget for TestWidget {
165        fn render(&self, _: &AppContext, _: Rect, _: &mut Buffer) {}
166        fn widget_type_name(&self) -> &'static str {
167            self.type_name
168        }
169        fn classes(&self) -> &[&str] {
170            &self.classes
171        }
172        fn id(&self) -> Option<&str> {
173            self.id
174        }
175    }
176
177    fn btn() -> Box<dyn crate::widget::Widget> {
178        Box::new(TestWidget {
179            type_name: "Button",
180            classes: vec![],
181            id: None,
182        })
183    }
184
185    fn btn_with_class(cls: &'static str) -> Box<dyn crate::widget::Widget> {
186        Box::new(TestWidget {
187            type_name: "Button",
188            classes: vec![cls],
189            id: None,
190        })
191    }
192
193    fn btn_with_id(id: &'static str) -> Box<dyn crate::widget::Widget> {
194        Box::new(TestWidget {
195            type_name: "Button",
196            classes: vec![],
197            id: Some(id),
198        })
199    }
200
201    fn setup_single_widget(w: Box<dyn crate::widget::Widget>) -> (AppContext, WidgetId) {
202        let mut ctx = AppContext::new();
203        let id = ctx.arena.insert(w);
204        ctx.parent.insert(id, None);
205        ctx.pseudo_classes.insert(id, PseudoClassSet::default());
206        ctx.computed_styles.insert(id, ComputedStyle::default());
207        ctx.inline_styles.insert(id, Vec::new());
208        (ctx, id)
209    }
210
211    #[test]
212    fn resolve_cascade_single_type_rule() {
213        let (ctx, id) = setup_single_widget(btn());
214        let (stylesheet, errors) = Stylesheet::parse("Button { color: #ff0000; }");
215        assert!(errors.is_empty());
216
217        let style = resolve_cascade(id, &[stylesheet], &ctx);
218        assert_eq!(style.color, TcssColor::Rgb(255, 0, 0));
219    }
220
221    #[test]
222    fn resolve_cascade_class_overrides_type() {
223        // Button selector (type specificity) and .active selector (class specificity)
224        // .active should win because it has higher specificity
225        let (ctx, id) = setup_single_widget(btn_with_class("active"));
226        let css = "Button { color: #ff0000; } .active { color: #0000ff; }";
227        let (stylesheet, errors) = Stylesheet::parse(css);
228        assert!(errors.is_empty(), "errors: {:?}", errors);
229
230        let style = resolve_cascade(id, &[stylesheet], &ctx);
231        // Class (.active) has higher specificity than type (Button), so blue should win
232        assert_eq!(
233            style.color,
234            TcssColor::Rgb(0, 0, 255),
235            "class should override type"
236        );
237    }
238
239    #[test]
240    fn resolve_cascade_id_overrides_class() {
241        let (ctx, id) = setup_single_widget(btn_with_id("main"));
242        let css = ".active { color: #0000ff; } #main { color: #00ff00; }";
243        // Only #main matches since this widget has no class "active"
244        // But let's make a widget with both to test ID beats class
245        let mut ctx2 = AppContext::new();
246        let id2 = ctx2.arena.insert(Box::new(TestWidget {
247            type_name: "Button",
248            classes: vec!["active"],
249            id: Some("main"),
250        }) as Box<dyn crate::widget::Widget>);
251        ctx2.parent.insert(id2, None);
252        ctx2.pseudo_classes.insert(id2, PseudoClassSet::default());
253        ctx2.computed_styles.insert(id2, ComputedStyle::default());
254        ctx2.inline_styles.insert(id2, Vec::new());
255
256        let (stylesheet, errors) = Stylesheet::parse(css);
257        assert!(errors.is_empty());
258
259        let style = resolve_cascade(id2, &[stylesheet], &ctx2);
260        assert_eq!(
261            style.color,
262            TcssColor::Rgb(0, 255, 0),
263            "ID should override class"
264        );
265    }
266
267    #[test]
268    fn resolve_cascade_inline_overrides_id() {
269        let mut ctx = AppContext::new();
270        let id = ctx.arena.insert(Box::new(TestWidget {
271            type_name: "Button",
272            classes: vec!["active"],
273            id: Some("main"),
274        }) as Box<dyn crate::widget::Widget>);
275        ctx.parent.insert(id, None);
276        ctx.pseudo_classes.insert(id, PseudoClassSet::default());
277        ctx.computed_styles.insert(id, ComputedStyle::default());
278        // Inline style sets color to red
279        ctx.inline_styles.insert(
280            id,
281            vec![Declaration {
282                property: "color".to_string(),
283                value: TcssValue::Color(TcssColor::Rgb(255, 0, 0)),
284            }],
285        );
286
287        let css = "#main { color: #0000ff; }";
288        let (stylesheet, errors) = Stylesheet::parse(css);
289        assert!(errors.is_empty());
290
291        let style = resolve_cascade(id, &[stylesheet], &ctx);
292        // Inline should win over ID selector
293        assert_eq!(
294            style.color,
295            TcssColor::Rgb(255, 0, 0),
296            "inline should override ID"
297        );
298    }
299
300    #[test]
301    fn resolve_cascade_same_specificity_later_source_wins() {
302        let (ctx, id) = setup_single_widget(btn());
303        // Two type selectors — same specificity; second rule (blue) should win
304        let css = "Button { color: red; } Button { color: #0000ff; }";
305        let (stylesheet, errors) = Stylesheet::parse(css);
306        assert!(errors.is_empty(), "errors: {:?}", errors);
307
308        let style = resolve_cascade(id, &[stylesheet], &ctx);
309        assert_eq!(
310            style.color,
311            TcssColor::Rgb(0, 0, 255),
312            "later source should win at equal specificity"
313        );
314    }
315
316    #[test]
317    fn resolve_cascade_focus_pseudo_class_only_when_focused() {
318        let mut ctx = AppContext::new();
319        let id = ctx.arena.insert(btn());
320        ctx.parent.insert(id, None);
321        ctx.pseudo_classes.insert(id, PseudoClassSet::default()); // no focus
322        ctx.computed_styles.insert(id, ComputedStyle::default());
323        ctx.inline_styles.insert(id, Vec::new());
324
325        let css = "Button { color: red; } Button:focus { color: #0000ff; }";
326        let (stylesheet, errors) = Stylesheet::parse(css);
327        assert!(errors.is_empty(), "errors: {:?}", errors);
328
329        // Without focus — should be red
330        let style = resolve_cascade(id, &[stylesheet.clone()], &ctx);
331        assert_eq!(
332            style.color,
333            TcssColor::Rgb(255, 0, 0),
334            "without focus should be red"
335        );
336
337        // Now add focus
338        let mut pcs = PseudoClassSet::default();
339        pcs.insert(PseudoClass::Focus);
340        ctx.pseudo_classes.insert(id, pcs);
341
342        let style = resolve_cascade(id, &[stylesheet], &ctx);
343        assert_eq!(
344            style.color,
345            TcssColor::Rgb(0, 0, 255),
346            "with focus should be blue"
347        );
348    }
349
350    #[test]
351    fn resolve_cascade_default_css_overridden_by_user() {
352        let (ctx, id) = setup_single_widget(btn());
353
354        // Default CSS at sheet index 0 (lowest)
355        let (default_sheet, _) = Stylesheet::parse("Button { color: red; }");
356        // User CSS at sheet index 1 (higher)
357        let (user_sheet, _) = Stylesheet::parse("Button { color: #0000ff; }");
358
359        let style = resolve_cascade(id, &[default_sheet, user_sheet], &ctx);
360        // User stylesheet (sheet 1) should override default (sheet 0) even at equal specificity
361        assert_eq!(
362            style.color,
363            TcssColor::Rgb(0, 0, 255),
364            "user CSS should override default CSS"
365        );
366    }
367
368    #[test]
369    fn full_roundtrip_parse_cascade_computed_style() {
370        let mut ctx = AppContext::new();
371        let id = ctx.arena.insert(Box::new(TestWidget {
372            type_name: "Button",
373            classes: vec!["active"],
374            id: Some("main"),
375        }) as Box<dyn crate::widget::Widget>);
376        ctx.parent.insert(id, None);
377        ctx.pseudo_classes.insert(id, PseudoClassSet::default());
378        ctx.computed_styles.insert(id, ComputedStyle::default());
379        ctx.inline_styles.insert(id, Vec::new());
380
381        let css = r#"
382            Button { color: red; display: block; }
383            .active { border: rounded; }
384            #main { width: 50%; }
385        "#;
386        let (stylesheet, errors) = Stylesheet::parse(css);
387        assert!(errors.is_empty(), "parse errors: {:?}", errors);
388
389        let style = resolve_cascade(id, &[stylesheet], &ctx);
390
391        // Type rule applied (lowest specificity)
392        assert_eq!(style.color, TcssColor::Rgb(255, 0, 0));
393        assert_eq!(style.display, TcssDisplay::Block);
394        // Class rule applied (middle specificity)
395        assert_eq!(style.border, BorderStyle::Rounded);
396        // ID rule applied (highest specificity from selector)
397        assert_eq!(style.width, crate::css::types::TcssDimension::Percent(50.0));
398    }
399
400    #[test]
401    fn apply_cascade_to_tree_sets_computed_styles() {
402        let mut ctx = AppContext::new();
403        let screen = ctx.arena.insert(Box::new(TestWidget {
404            type_name: "Screen",
405            classes: vec![],
406            id: None,
407        }) as Box<dyn crate::widget::Widget>);
408        let button = ctx.arena.insert(Box::new(TestWidget {
409            type_name: "Button",
410            classes: vec![],
411            id: None,
412        }) as Box<dyn crate::widget::Widget>);
413
414        ctx.parent.insert(screen, None);
415        ctx.parent.insert(button, Some(screen));
416        ctx.children.insert(screen, vec![button]);
417        ctx.children.insert(button, vec![]);
418        ctx.pseudo_classes.insert(screen, PseudoClassSet::default());
419        ctx.pseudo_classes.insert(button, PseudoClassSet::default());
420        ctx.computed_styles.insert(screen, ComputedStyle::default());
421        ctx.computed_styles.insert(button, ComputedStyle::default());
422        ctx.inline_styles.insert(screen, Vec::new());
423        ctx.inline_styles.insert(button, Vec::new());
424
425        let css = "Button { color: red; }";
426        let (stylesheet, errors) = Stylesheet::parse(css);
427        assert!(errors.is_empty());
428
429        apply_cascade_to_tree(screen, &[stylesheet], &mut ctx);
430
431        let button_style = ctx.computed_styles.get(button).unwrap();
432        assert_eq!(button_style.color, TcssColor::Rgb(255, 0, 0));
433    }
434
435    #[test]
436    fn stylesheet_from_css_strings_combines_multiple() {
437        let css1 = "Button { color: red; }";
438        let css2 = "Label { display: block; }";
439        let (stylesheet, errors) = stylesheet_from_css_strings(&[css1, css2]);
440        assert!(errors.is_empty(), "errors: {:?}", errors);
441        assert_eq!(stylesheet.rules.len(), 2);
442    }
443
444    // --- Theme variable resolution tests ---
445
446    #[test]
447    fn resolve_cascade_variable_primary_resolves_to_rgb() {
448        let (ctx, id) = setup_single_widget(btn());
449        let css = "Button { color: $primary; }";
450        let (stylesheet, errors) = Stylesheet::parse(css);
451        assert!(errors.is_empty(), "errors: {:?}", errors);
452
453        let style = resolve_cascade(id, &[stylesheet], &ctx);
454        // Default dark theme primary = (1, 120, 212)
455        assert_eq!(style.color, TcssColor::Rgb(1, 120, 212));
456    }
457
458    #[test]
459    fn resolve_cascade_variable_lighten() {
460        let (ctx, id) = setup_single_widget(btn());
461        let css = "Button { background: $primary-lighten-2; }";
462        let (stylesheet, errors) = Stylesheet::parse(css);
463        assert!(errors.is_empty(), "errors: {:?}", errors);
464
465        let style = resolve_cascade(id, &[stylesheet], &ctx);
466        // Should be a lighter shade of primary — not Reset, and not the base primary
467        assert_ne!(style.background, TcssColor::Reset);
468        assert_ne!(style.background, TcssColor::Rgb(1, 120, 212));
469        // Verify it's an Rgb value
470        assert!(matches!(style.background, TcssColor::Rgb(_, _, _)));
471    }
472
473    #[test]
474    fn resolve_cascade_variable_darken() {
475        let (ctx, id) = setup_single_widget(btn());
476        let css = "Button { color: $accent-darken-1; }";
477        let (stylesheet, errors) = Stylesheet::parse(css);
478        assert!(errors.is_empty(), "errors: {:?}", errors);
479
480        let style = resolve_cascade(id, &[stylesheet], &ctx);
481        // Should be a darker shade of accent — not Reset, not base accent
482        assert_ne!(style.color, TcssColor::Reset);
483        assert_ne!(style.color, TcssColor::Rgb(255, 166, 43));
484        assert!(matches!(style.color, TcssColor::Rgb(_, _, _)));
485    }
486
487    #[test]
488    fn resolve_cascade_unknown_variable_ignored() {
489        let (ctx, id) = setup_single_widget(btn());
490        let css = "Button { color: $nonexistent; }";
491        let (stylesheet, errors) = Stylesheet::parse(css);
492        assert!(errors.is_empty(), "errors: {:?}", errors);
493
494        let style = resolve_cascade(id, &[stylesheet], &ctx);
495        // Unknown variable should not be applied — color stays at default Reset
496        assert_eq!(style.color, TcssColor::Reset);
497    }
498
499    #[test]
500    fn resolve_cascade_custom_theme() {
501        let (mut ctx, id) = setup_single_widget(btn());
502        // Set a custom theme with a different primary color
503        ctx.theme.primary = (42, 42, 42);
504
505        let css = "Button { color: $primary; }";
506        let (stylesheet, errors) = Stylesheet::parse(css);
507        assert!(errors.is_empty(), "errors: {:?}", errors);
508
509        let style = resolve_cascade(id, &[stylesheet], &ctx);
510        assert_eq!(style.color, TcssColor::Rgb(42, 42, 42));
511    }
512
513    #[test]
514    fn resolve_cascade_all_base_variables() {
515        let (ctx, id) = setup_single_widget(btn());
516        let theme = &ctx.theme;
517
518        // Test each base variable resolves correctly
519        for (var_name, expected_rgb) in &[
520            ("primary", theme.primary),
521            ("secondary", theme.secondary),
522            ("accent", theme.accent),
523            ("surface", theme.surface),
524            ("panel", theme.panel),
525            ("background", theme.background),
526            ("foreground", theme.foreground),
527            ("success", theme.success),
528            ("warning", theme.warning),
529            ("error", theme.error),
530        ] {
531            let css = format!("Button {{ color: ${}; }}", var_name);
532            let (stylesheet, errors) = Stylesheet::parse(&css);
533            assert!(errors.is_empty(), "errors for {}: {:?}", var_name, errors);
534
535            let style = resolve_cascade(id, &[stylesheet], &ctx);
536            assert_eq!(
537                style.color,
538                TcssColor::Rgb(expected_rgb.0, expected_rgb.1, expected_rgb.2),
539                "variable ${} should resolve to {:?}",
540                var_name,
541                expected_rgb
542            );
543        }
544    }
545
546    #[test]
547    fn resolve_cascade_border_with_variable_resolves_to_color() {
548        let (ctx, id) = setup_single_widget(btn());
549        let css = "Button { border: tall $primary; }";
550        let (stylesheet, errors) = Stylesheet::parse(css);
551        assert!(errors.is_empty(), "errors: {:?}", errors);
552
553        let style = resolve_cascade(id, &[stylesheet], &ctx);
554        // border style should be Tall
555        assert_eq!(style.border, BorderStyle::Tall);
556        // color should be resolved from $primary (1, 120, 212)
557        assert_eq!(style.color, TcssColor::Rgb(1, 120, 212));
558    }
559
560    #[test]
561    fn appcontext_has_default_dark_theme() {
562        let ctx = AppContext::new();
563        assert_eq!(ctx.theme.name, "textual-dark");
564        assert_eq!(ctx.theme.primary, (1, 120, 212));
565    }
566
567    #[test]
568    fn full_roundtrip_variable_resolution() {
569        // Full pipeline: CSS text -> parse -> cascade with theme -> ComputedStyle with correct RGB
570        let mut ctx = AppContext::new();
571        let id = ctx.arena.insert(Box::new(TestWidget {
572            type_name: "Button",
573            classes: vec![],
574            id: None,
575        }) as Box<dyn crate::widget::Widget>);
576        ctx.parent.insert(id, None);
577        ctx.pseudo_classes.insert(id, PseudoClassSet::default());
578        ctx.computed_styles.insert(id, ComputedStyle::default());
579        ctx.inline_styles.insert(id, Vec::new());
580
581        let css = r#"
582            Button {
583                color: $primary;
584                background: $surface;
585            }
586        "#;
587        let (stylesheet, errors) = Stylesheet::parse(css);
588        assert!(errors.is_empty(), "parse errors: {:?}", errors);
589
590        let style = resolve_cascade(id, &[stylesheet], &ctx);
591        assert_eq!(style.color, TcssColor::Rgb(1, 120, 212));
592        assert_eq!(style.background, TcssColor::Rgb(30, 30, 30));
593    }
594}