use std::collections::BTreeSet; use guiduck_component_core::registry::builtins; use guiduck_scene::FragmentStore; use guiduck_scene::geom::{Point, Size}; use guiduck_scene::paint::{Brush, Color}; use taffy::prelude::{length, percent}; use super::*; use crate::clipboard::NoClipboard; use crate::event::{PointerButton, PointerInput}; use crate::text::TextContext; use crate::widget::{Container, WidgetTree}; const THEME: &str = r##" (theme Test (tokens (accent "#4682b4") (accent-hover "#6495ed") (accent-active "#483d8b")) (rule container :background "#ffffff") (rule (container .button) :background accent :corner-radius 8) (rule (container .button :hover) :background accent-hover) (rule (container .button :active) :background accent-active) (rule text :font-size 14)) "##; fn theme() -> Theme { Theme::parse(THEME).expect("valid theme") } fn color(hex: u32) -> Brush { Color::from_rgba8((hex >> 16) as u8, (hex >> 8) as u8, hex as u8, 255).into() } #[test] fn resolution_merges_rules_in_file_order() { let theme = theme(); let plain = theme.resolve("container", &[], InteractionState::default()); assert_eq!(plain.brush("background"), Some(&color(0xffffff))); assert_eq!(plain.number("corner-radius"), None); let classes = vec!["button".to_owned()]; let button = theme.resolve("container", &classes, InteractionState::default()); assert_eq!(button.brush("background"), Some(&color(0x4682b4))); assert_eq!(button.number("corner-radius"), Some(8.0)); let hovered = theme.resolve( "container", &classes, InteractionState { hover: true, active: false, focus: false, disabled: false, }, ); assert_eq!(hovered.brush("background"), Some(&color(0x6495ed))); assert_eq!( hovered.number("corner-radius"), Some(8.0), "base properties persist under state rules" ); // Active wins over hover because its rule comes later in the file. let pressed = theme.resolve( "container", &classes, InteractionState { hover: true, active: true, focus: false, disabled: false, }, ); assert_eq!(pressed.brush("background"), Some(&color(0x483d8b))); } #[test] fn rules_are_stamped_with_dirt() { let theme = theme(); // text rules touch font-size → layout; container rules touch only paint. assert_eq!(theme.max_dirt_for("text"), crate::Dirt::LAYOUT); assert_eq!(theme.max_dirt_for("container"), crate::Dirt::PAINT); } /// End-to-end: theming a live tree, including hover/active restyling driven /// by pointer state and needs_frame gating. #[test] fn themed_tree_restyles_on_interaction() { let mut tree = WidgetTree::with_text_context(TextContext::hermetic([])); let root = tree.insert( Container::new(), taffy::Style { size: taffy::Size { width: percent(1.0_f32), height: percent(1.0_f32), }, padding: taffy::Rect::length(20.0_f32), ..Default::default() }, None, ); let button = tree.insert( Container::new(), taffy::Style { size: taffy::Size { width: length(100.0_f32), height: length(40.0_f32), }, ..Default::default() }, Some(root), ); tree.set_classes(button, ["button".to_owned()]); tree.set_theme(Some(theme())); let mut store = FragmentStore::new(); assert!(tree.needs_frame()); tree.render_frame(Size::new(300.0, 200.0)); assert!(!tree.needs_frame(), "quiescent after initial themed frame"); let base = tree .widget::(button) .unwrap() .current_background() .cloned(); assert_eq!(base, Some(color(0x4682b4))); // Hover on: engine-level restyle without any user handlers. tree.dispatch_pointer( PointerInput::Moved(Point::new(50.0, 30.0)), &mut NoClipboard, ); assert!(tree.needs_frame(), "hover state change schedules a frame"); store.clear(); tree.render_frame(Size::new(300.0, 200.0)); assert_eq!( tree.widget::(button) .unwrap() .current_background(), Some(&color(0x6495ed)) ); // Press: active style. tree.dispatch_pointer( PointerInput::Down { time: std::time::Instant::now(), pos: Point::new(50.0, 30.0), button: PointerButton::Left, }, &mut NoClipboard, ); store.clear(); tree.render_frame(Size::new(300.0, 200.0)); assert_eq!( tree.widget::(button) .unwrap() .current_background(), Some(&color(0x483d8b)) ); // Release + move away: back to base. tree.dispatch_pointer( PointerInput::Up { pos: Point::new(50.0, 30.0), button: PointerButton::Left, }, &mut NoClipboard, ); tree.dispatch_pointer(PointerInput::Moved(Point::new(5.0, 5.0)), &mut NoClipboard); store.clear(); tree.render_frame(Size::new(300.0, 200.0)); assert_eq!( tree.widget::(button) .unwrap() .current_background(), Some(&color(0x4682b4)) ); assert!(!tree.needs_frame()); // Dropping the app theme leaves the default theme (and the fallback // under it), which say nothing about a plain container — so hovering // still schedules a restyle, that restyle resolves nothing, and the tree // settles in one frame with its pixels unchanged. tree.set_theme(None); store.clear(); tree.render_frame(Size::new(300.0, 200.0)); tree.dispatch_pointer( PointerInput::Moved(Point::new(50.0, 30.0)), &mut NoClipboard, ); assert!(tree.needs_frame(), "hover schedules a restyle"); tree.render_frame(Size::new(300.0, 200.0)); assert_eq!( tree.widget::(button) .unwrap() .current_background(), Some(&color(0x4682b4)), "no rule matches a plain container: pixels unchanged" ); assert!(!tree.needs_frame(), "the restyle settles in one frame"); } #[test] fn theme_swap_restyles_everything() { const DARK: &str = r##" (theme Dark (rule container :background "#202020") (rule (container .button) :background "#334455")) "##; let mut tree = WidgetTree::with_text_context(TextContext::hermetic([])); let root = tree.insert( Container::new(), taffy::Style { size: taffy::Size { width: percent(1.0_f32), height: percent(1.0_f32), }, ..Default::default() }, None, ); tree.set_theme(Some(theme())); let mut store = FragmentStore::new(); tree.render_frame(Size::new(100.0, 100.0)); assert_eq!( tree.widget::(root).unwrap().current_background(), Some(&color(0xffffff)) ); tree.set_theme(Some(Theme::parse(DARK).expect("valid"))); assert!(tree.needs_frame()); store.clear(); tree.render_frame(Size::new(100.0, 100.0)); assert_eq!( tree.widget::(root).unwrap().current_background(), Some(&color(0x202020)) ); } #[test] fn disabled_state_rules_resolve() { let theme = Theme::parse( r##" (theme Disabled (rule (container .button) :background "#4682b4") (rule (container .button :disabled) :background "#c0c0c0")) "##, ) .expect("valid theme"); let classes = vec!["button".to_owned()]; let normal = theme.resolve("container", &classes, InteractionState::default()); assert_eq!(normal.brush("background"), Some(&color(0x4682b4))); let disabled = theme.resolve( "container", &classes, InteractionState { hover: false, active: false, focus: false, disabled: true, }, ); assert_eq!(disabled.brush("background"), Some(&color(0xc0c0c0))); } #[test] fn layering_puts_the_app_theme_over_the_base() { let base = Theme::parse( r##"(theme Base (rule container :background "#111111" :corner-radius 4))"##, ) .expect("valid"); let app = Theme::parse( r##"(theme App (rule container :background "#222222"))"##, ) .expect("valid"); let layered = app.over(&base); assert_eq!(layered.name, "App"); let computed = layered.resolve("container", &[], InteractionState::default()); // The app's rule wins where both speak; the base fills the rest. assert_eq!(computed.brush("background"), Some(&color(0x222222))); assert_eq!(computed.number("corner-radius"), Some(4.0)); } /// Every interaction state a rule can select on, for exhaustive checks. const EVERY_STATE: &[InteractionState] = &[ InteractionState { hover: true, active: false, focus: false, disabled: false, }, InteractionState { hover: false, active: true, focus: false, disabled: false, }, InteractionState { hover: false, active: false, focus: true, disabled: false, }, InteractionState { hover: false, active: false, focus: false, disabled: true, }, ]; fn token_names(theme: &Theme, widget: &str, state: InteractionState) -> BTreeSet { theme .resolve(widget, &[], state) .names() .map(str::to_owned) .collect() } #[test] fn the_fallback_theme_type_checks_against_the_registry() { use guiduck_component_core::registry::{ThemeValueType, builtin}; for rule in &fallback_theme().rules { let widget = builtin(&rule.widget).expect("a known widget"); for (name, value) in &rule.props { let expected = widget .theme_value_type(name) .unwrap_or_else(|| panic!("`{}` has no themable `{name}`", rule.widget)); let ok = match (expected, value) { (ThemeValueType::Brush, StyleValue::Brush(_)) | (ThemeValueType::Number, StyleValue::Number(_)) | (ThemeValueType::Str, StyleValue::Str(_)) | (ThemeValueType::Graphic, StyleValue::Graphic(_)) => true, _ => false, }; assert!( ok, "`{}` `{name}`: theme expects {expected:?}, fallback carries {value:?}", rule.widget ); } } } #[test] fn the_fallback_and_default_themes_cover_the_same_tokens() { // The two-way invariant the layering rests on. Fallback ⊇ default means a // default theme that failed to compile still leaves every control fully // dressed. Default ⊇ fallback means the fallback's deliberately plain // values never show through in a working build — which is why migrating // appearance into the default theme stayed byte-identical. let default_parsed = Theme::parse(DEFAULT_THEME_SOURCE).expect("the default theme compiles"); for widget in builtins() { let base = InteractionState::default(); assert_eq!( token_names(fallback_theme(), &widget.name, base), token_names(&default_parsed, &widget.name, base), "`{}`: the fallback and default themes must state the same base tokens", widget.name ); } } /// A widget with paint-only tokens must be dressed by both themes. /// /// The two-way test above compares the fallback against the default and is /// satisfied when both are empty — so a newly declared widget whose tokens no /// theme states passes it, and renders as nothing. This closes exactly that /// hole; between them, the two themes must state the *same* non-empty set. /// /// Non-emptiness rather than per-token completeness, because completeness is /// not decidable from here: a token that is also a settable property takes its /// value from the instance, and a typography token falls back to the widget's /// own default. Which tokens *must* be stated is a judgement per token; that /// none may be stated is not. #[test] fn a_widget_with_paint_only_tokens_is_dressed_by_both_themes() { let default_parsed = Theme::parse(DEFAULT_THEME_SOURCE).expect("the default theme compiles"); let base = InteractionState::default(); for widget in builtins() { let paint_only = widget .theme_tokens() .any(|(name, _)| widget.lookup(name).is_none()); if !paint_only { continue; } for (label, theme) in [("fallback", fallback_theme()), ("default", &default_parsed)] { assert!( !token_names(theme, &widget.name, base).is_empty(), "`{}` has appearance only a theme can give it, and the {label} \ theme says nothing about it", widget.name ); } } } #[test] fn every_fallback_state_rule_has_a_base_to_return_to() { // The compiler enforces this for parsed themes; the fallback is built in // code, so it gets the same check here. Without it, leaving a state would // have no value to restore. for widget in builtins() { let base = token_names(fallback_theme(), &widget.name, InteractionState::default()); for state in EVERY_STATE { let stated = token_names(fallback_theme(), &widget.name, *state); let uncovered: Vec<_> = stated.difference(&base).collect(); assert!( uncovered.is_empty(), "`{}` in {state:?}: {uncovered:?} have no stateless value", widget.name ); } } } #[test] fn the_fallback_theme_dresses_every_control_by_itself() { // The point of the fallback: with the default theme out of the picture // entirely, controls still resolve real appearance rather than nothing. let checkbox = fallback_theme().resolve("checkbox", &[], InteractionState::default()); assert!(checkbox.brush("box-fill").is_some()); assert!(checkbox.graphic("check-mark").is_some()); let focused = fallback_theme().resolve( "button", &[], InteractionState { focus: true, ..Default::default() }, ); // A focus ring the user can actually see is the difference between // "degraded" and "unusable" for keyboard operation. assert!(!crate::widget::is_transparent( focused.brush("focus-ring-color").expect("a ring color") )); } #[test] fn a_default_theme_that_will_not_compile_falls_back_instead_of_panicking() { // The recovery path `default_theme()` takes. A control must still resolve // a usable appearance: reporting the problem and running plainly beats // refusing to start. let recovered = compile_default(r##"(theme Default (rule button :background "not-a-color"))"##); let button = recovered.resolve("button", &[], InteractionState::default()); assert!(button.brush("background").is_some()); assert!(button.number("focus-ring-width").is_some()); let checkbox = recovered.resolve("checkbox", &[], InteractionState::default()); assert!(checkbox.graphic("check-mark").is_some()); } #[test] fn the_default_theme_layers_over_the_fallback() { // Every token the fallback states, the shipped default theme restates — // so what a control actually resolves is the default theme's value, and // the fallback is invisible in a working build. let resolved = default_theme().resolve("checkbox", &[], InteractionState::default()); let fallback = fallback_theme().resolve("checkbox", &[], InteractionState::default()); assert_eq!( resolved.number("box-corner-radius"), Some(4.0), "the default theme's radius" ); assert_eq!( fallback.number("box-corner-radius"), Some(0.0), "the fallback's plainer one" ); assert_eq!(default_theme().name, "Default"); }