1use 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
10fn 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#[derive(Debug, Clone, Default)]
45pub struct Stylesheet {
46 pub rules: Vec<Rule>,
48}
49
50impl Stylesheet {
51 pub fn parse(css: &str) -> (Self, Vec<String>) {
53 let (rules, errors) = parse_stylesheet(css);
54 (Stylesheet { rules }, errors)
55 }
56
57 pub fn empty() -> Self {
59 Stylesheet { rules: Vec::new() }
60 }
61}
62
63pub fn stylesheet_from_css_strings(css_strings: &[&str]) -> (Stylesheet, Vec<String>) {
65 let combined = css_strings.join("\n");
66 Stylesheet::parse(&combined)
67}
68
69pub fn resolve_cascade(
76 widget_id: WidgetId,
77 stylesheets: &[Stylesheet],
78 ctx: &AppContext,
79) -> ComputedStyle {
80 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 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 matched.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
103
104 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 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
120pub fn apply_cascade_to_tree(
122 screen_id: WidgetId,
123 stylesheets: &[Stylesheet],
124 ctx: &mut AppContext,
125) {
126 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 for &child in children.iter().rev() {
135 stack.push(child);
136 }
137 }
138 }
139
140 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 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 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 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 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 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 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 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()); 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 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 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 let (default_sheet, _) = Stylesheet::parse("Button { color: red; }");
356 let (user_sheet, _) = Stylesheet::parse("Button { color: #0000ff; }");
358
359 let style = resolve_cascade(id, &[default_sheet, user_sheet], &ctx);
360 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 assert_eq!(style.color, TcssColor::Rgb(255, 0, 0));
393 assert_eq!(style.display, TcssDisplay::Block);
394 assert_eq!(style.border, BorderStyle::Rounded);
396 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 #[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 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 assert_ne!(style.background, TcssColor::Reset);
468 assert_ne!(style.background, TcssColor::Rgb(1, 120, 212));
469 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 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 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 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 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 assert_eq!(style.border, BorderStyle::Tall);
556 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 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}