//! Theme color generator for Bevy Feathers. //! //! # Main entrypoint //! //! [`build`] takes [`Seeds`] and a [`Mode`] and returns a complete Feathers //! [`ThemeProps`](bevy::feathers::theme::ThemeProps). //! //! ```rust,ignore //! // Auto-assign roles from a 5-swatch Coolors palette (ranked by chroma). //! let seeds = Seeds::from_coolors([color1, color2, color3, color4, color5]); //! let theme = build(seeds, Mode::Dark); //! // Hand `theme` to Feathers. //! ``` //! //! Construct [`Seeds`] directly if you need manual role assignment. use bevy::color::{Alpha, Color, Oklcha}; use bevy::feathers::theme::{ThemeProps, ThemeToken}; use bevy::feathers::tokens as ft; use bevy::platform::collections::HashMap; // --------------------------------------------------------------------------- // Color math // --------------------------------------------------------------------------- fn relative_luminance(c: Color) -> f32 { let lin = c.to_linear(); 0.2126 * lin.red + 0.7152 * lin.green + 0.0722 * lin.blue } fn contrast_ratio(a: Color, b: Color) -> f32 { let (la, lb) = (relative_luminance(a), relative_luminance(b)); let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) }; (hi + 0.05) / (lo + 0.05) } /// Pick the softest foreground at the given hue/chroma that meets `target` /// WCAG contrast on `bg`. Falls back to pure white/black if nothing passes. fn on_color(bg: Color, hue: f32, chroma: f32, target: f32) -> Color { let bg_lum = relative_luminance(bg); let mut best: Option<(f32, Color)> = None; for i in 0..=100 { let l = i as f32 / 100.0; let cand = Color::oklcha(l, chroma, hue, 1.0); if contrast_ratio(cand, bg) >= target { let d = (relative_luminance(cand) - bg_lum).abs(); if best.map_or(true, |(bd, _)| d < bd) { best = Some((d, cand)); } } } best.map(|(_, c)| c) .unwrap_or_else(|| Color::oklcha(if bg_lum < 0.5 { 1.0 } else { 0.0 }, 0.0, hue, 1.0)) } /// Lighter in dark mode, darker in light mode — keeps hue/chroma. fn elevate(c: Color, mode: Mode, amount: f32) -> Color { let o = Oklcha::from(c); let l = match mode { Mode::Dark => o.lightness + amount, Mode::Light => o.lightness - amount, }; Color::from(o.with_lightness(l.clamp(0.0, 1.0))) } // --------------------------------------------------------------------------- // Public types // --------------------------------------------------------------------------- #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum Mode { Dark, Light, } /// The three seed colors the generator needs. Use `from_coolors` to /// auto-assign roles from a 5-swatch palette. #[derive(Clone, Copy, Debug)] pub struct Seeds { pub primary: Color, pub secondary: Color, pub accent: Color, } impl Seeds { /// Assign roles from an unordered 5-swatch palette (e.g. from Coolors). /// Most-chromatic swatch becomes accent, next primary, next secondary. pub fn from_coolors(palette: [Color; 5]) -> Self { let mut by_chroma: Vec = palette.iter().map(|c| Oklcha::from(*c)).collect(); by_chroma.sort_by(|a, b| b.chroma.total_cmp(&a.chroma)); let pick = |o: Oklcha| Color::from(o); Seeds { accent: pick(by_chroma[0]), primary: pick(by_chroma[1]), secondary: pick(by_chroma[2]), } } } // --------------------------------------------------------------------------- // Lightness anchors // --------------------------------------------------------------------------- struct Anchors { window: f32, sunken: f32, surface: f32, raised: f32, border: f32, control: f32, text: f32, text_dim: f32, text_disabled: f32, accent: f32, status: f32, } impl Anchors { fn for_mode(mode: Mode) -> Self { match mode { Mode::Dark => Anchors { window: 0.15, sunken: 0.12, surface: 0.19, raised: 0.24, border: 0.32, control: 0.27, text: 0.96, text_dim: 0.74, text_disabled: 0.46, accent: 0.62, status: 0.62, }, Mode::Light => Anchors { window: 0.965, sunken: 0.93, surface: 0.99, raised: 1.00, border: 0.85, control: 0.96, text: 0.22, text_dim: 0.45, text_disabled: 0.70, accent: 0.55, status: 0.52, }, } } } // --------------------------------------------------------------------------- // Theme builder // --------------------------------------------------------------------------- /// Build a complete Feathers `ThemeProps` from seed colors. /// /// All colors are derived in OKLCH so hue never drifts, neutrals carry a /// whisper of the primary hue, and text colors are WCAG-contrast-checked. pub fn build(seeds: Seeds, mode: Mode) -> ThemeProps { let a = Anchors::for_mode(mode); let primary = Oklcha::from(seeds.primary); let accent = Oklcha::from(seeds.accent); let neutral_hue = primary.hue; let neutral = |l: f32| Color::oklcha(l, 0.012, neutral_hue, 1.0); let shade = |seed: Oklcha, l: f32| Color::oklcha(l, seed.chroma, seed.hue, 1.0); let status_chroma = ((primary.chroma + accent.chroma) * 0.5).clamp(0.08, 0.16); let status = |hue: f32| Color::oklcha(a.status, status_chroma, hue, 1.0); let control = neutral(a.control); let sunken = neutral(a.sunken); let border = neutral(a.border); let text = neutral(a.text); let text_dim = neutral(a.text_dim); let text_disabled = neutral(a.text_disabled); let accent_bg = shade(accent, a.accent); let accent_hover = elevate(accent_bg, mode, 0.05); let accent_pressed = elevate(accent_bg, mode, 0.10); let focus_ring = shade(accent, if mode == Mode::Dark { 0.70 } else { 0.55 }); let btn_text = on_color(control, neutral_hue, 0.0, 4.5); let accent_text = on_color(accent_bg, accent.hue, 0.03, 4.5); let mut colors: HashMap = HashMap::new(); macro_rules! set { ($tok:expr, $color:expr) => { colors.insert($tok.clone(), $color) }; } // Window / pane surfaces set!(ft::WINDOW_BG, neutral(a.window)); set!(ft::PANE_HEADER_BG, neutral(a.window)); set!(ft::PANE_HEADER_BORDER, border); set!(ft::PANE_HEADER_TEXT, text); set!(ft::PANE_HEADER_DIVIDER, border); set!(ft::PANE_BODY_BG, neutral(a.surface)); set!(ft::SUBPANE_HEADER_BG, neutral(a.raised)); set!(ft::SUBPANE_HEADER_BORDER, border); set!(ft::SUBPANE_HEADER_TEXT, text); set!(ft::SUBPANE_BODY_BG, neutral(a.surface)); set!(ft::SUBPANE_BODY_BORDER, border); set!(ft::GROUP_HEADER_BG, neutral(a.raised)); set!(ft::GROUP_HEADER_BORDER, border); set!(ft::GROUP_HEADER_TEXT, text); set!(ft::GROUP_BODY_BG, neutral(a.raised)); set!(ft::GROUP_BODY_BORDER, border); // Focus ring set!(ft::FOCUS_RING, focus_ring); // Text set!(ft::TEXT_MAIN, text); set!(ft::TEXT_DIM, text_dim); // Normal buttons set!(ft::BUTTON_BG, control); set!(ft::BUTTON_BG_HOVER, elevate(control, mode, 0.04)); set!(ft::BUTTON_BG_PRESSED, elevate(control, mode, 0.08)); set!(ft::BUTTON_BG_DISABLED, control.with_alpha(0.4)); set!(ft::BUTTON_TEXT, btn_text); set!(ft::BUTTON_TEXT_DISABLED, btn_text.with_alpha(0.5)); // Primary (accent) buttons set!(ft::BUTTON_PRIMARY_BG, accent_bg); set!(ft::BUTTON_PRIMARY_BG_HOVER, accent_hover); set!(ft::BUTTON_PRIMARY_BG_PRESSED, accent_pressed); set!(ft::BUTTON_PRIMARY_BG_DISABLED, accent_bg.with_alpha(0.4)); set!(ft::BUTTON_PRIMARY_TEXT, accent_text); set!( ft::BUTTON_PRIMARY_TEXT_DISABLED, accent_text.with_alpha(0.5) ); // Plain (transparent) buttons set!(ft::BUTTON_PLAIN_BG, Color::NONE); set!(ft::BUTTON_PLAIN_BG_HOVER, neutral(a.raised)); set!(ft::BUTTON_PLAIN_BG_PRESSED, control); set!(ft::BUTTON_PLAIN_BG_DISABLED, Color::NONE); // Sliders set!(ft::SLIDER_BG, sunken); set!(ft::SLIDER_BG_HOVER, elevate(sunken, mode, 0.03)); set!(ft::SLIDER_BG_PRESSED, elevate(sunken, mode, 0.06)); set!(ft::SLIDER_BG_DISABLED, sunken.with_alpha(0.5)); set!(ft::SLIDER_BAR, accent_bg); set!(ft::SLIDER_BAR_HOVER, accent_hover); set!(ft::SLIDER_BAR_PRESSED, accent_pressed); set!(ft::SLIDER_BAR_DISABLED, accent_bg.with_alpha(0.5)); set!(ft::SLIDER_TEXT, on_color(sunken, neutral_hue, 0.0, 4.5)); set!(ft::SLIDER_TEXT_DISABLED, text_disabled.with_alpha(0.5)); // Scrollbar set!(ft::SCROLLBAR_BG, sunken); set!(ft::SCROLLBAR_THUMB, accent_bg); set!(ft::SCROLLBAR_THUMB_HOVER, accent_hover); // Checkboxes set!(ft::CHECKBOX_BG, control); set!(ft::CHECKBOX_BG_HOVER, elevate(control, mode, 0.04)); set!(ft::CHECKBOX_BG_PRESSED, elevate(control, mode, 0.08)); set!(ft::CHECKBOX_BG_DISABLED, control.with_alpha(0.4)); set!(ft::CHECKBOX_BG_CHECKED, accent_bg); set!(ft::CHECKBOX_BG_CHECKED_HOVER, accent_hover); set!(ft::CHECKBOX_BG_CHECKED_PRESSED, accent_pressed); set!(ft::CHECKBOX_BG_CHECKED_DISABLED, accent_bg.with_alpha(0.4)); set!(ft::CHECKBOX_BORDER, border); set!(ft::CHECKBOX_BORDER_HOVER, elevate(border, mode, 0.04)); set!(ft::CHECKBOX_BORDER_PRESSED, elevate(border, mode, 0.08)); set!(ft::CHECKBOX_BORDER_DISABLED, border.with_alpha(0.4)); set!(ft::CHECKBOX_BORDER_CHECKED, accent_bg); set!(ft::CHECKBOX_BORDER_CHECKED_HOVER, accent_hover); set!(ft::CHECKBOX_BORDER_CHECKED_PRESSED, accent_pressed); set!( ft::CHECKBOX_BORDER_CHECKED_DISABLED, accent_bg.with_alpha(0.4) ); set!(ft::CHECKBOX_MARK, accent_text); set!(ft::CHECKBOX_MARK_DISABLED, text_disabled); set!(ft::CHECKBOX_TEXT, text); set!(ft::CHECKBOX_TEXT_DISABLED, text_disabled.with_alpha(0.5)); // Radio buttons set!(ft::RADIO_BORDER, border); set!(ft::RADIO_BORDER_HOVER, elevate(border, mode, 0.04)); set!(ft::RADIO_BORDER_PRESSED, elevate(border, mode, 0.08)); set!(ft::RADIO_BORDER_DISABLED, border.with_alpha(0.4)); set!(ft::RADIO_BORDER_CHECKED, accent_bg); set!(ft::RADIO_BORDER_CHECKED_HOVER, accent_hover); set!(ft::RADIO_BORDER_CHECKED_PRESSED, accent_pressed); set!(ft::RADIO_BORDER_CHECKED_DISABLED, accent_bg.with_alpha(0.4)); set!(ft::RADIO_MARK, accent_bg); set!(ft::RADIO_MARK_HOVER, accent_hover); set!(ft::RADIO_MARK_PRESSED, accent_pressed); set!(ft::RADIO_MARK_DISABLED, border.with_alpha(0.4)); set!(ft::RADIO_TEXT, text); set!(ft::RADIO_TEXT_DISABLED, text_disabled.with_alpha(0.5)); // Toggle switches let slide = text; // high-contrast nub that slides set!(ft::SWITCH_BG, control); set!(ft::SWITCH_BG_HOVER, elevate(control, mode, 0.04)); set!(ft::SWITCH_BG_PRESSED, elevate(control, mode, 0.08)); set!(ft::SWITCH_BG_DISABLED, control.with_alpha(0.4)); set!(ft::SWITCH_BG_CHECKED, accent_bg); set!(ft::SWITCH_BG_CHECKED_HOVER, accent_hover); set!(ft::SWITCH_BG_CHECKED_PRESSED, accent_pressed); set!(ft::SWITCH_BG_CHECKED_DISABLED, accent_bg.with_alpha(0.4)); set!(ft::SWITCH_BORDER, border); set!(ft::SWITCH_BORDER_HOVER, elevate(border, mode, 0.04)); set!(ft::SWITCH_BORDER_PRESSED, elevate(border, mode, 0.08)); set!(ft::SWITCH_BORDER_DISABLED, border.with_alpha(0.4)); set!(ft::SWITCH_BORDER_CHECKED, accent_bg); set!(ft::SWITCH_BORDER_CHECKED_HOVER, accent_hover); set!(ft::SWITCH_BORDER_CHECKED_PRESSED, accent_pressed); set!( ft::SWITCH_BORDER_CHECKED_DISABLED, accent_bg.with_alpha(0.4) ); set!(ft::SWITCH_SLIDE_BG, slide); set!(ft::SWITCH_SLIDE_BG_HOVER, slide); set!(ft::SWITCH_SLIDE_BG_PRESSED, slide); set!(ft::SWITCH_SLIDE_BG_DISABLED, text_disabled); set!(ft::SWITCH_SLIDE_BG_CHECKED, slide); set!(ft::SWITCH_SLIDE_BG_CHECKED_HOVER, slide); set!(ft::SWITCH_SLIDE_BG_CHECKED_PRESSED, slide); set!(ft::SWITCH_SLIDE_BG_CHECKED_DISABLED, text_disabled); set!(ft::SWITCH_SLIDE_BORDER, slide); set!(ft::SWITCH_SLIDE_BORDER_HOVER, slide); set!(ft::SWITCH_SLIDE_BORDER_PRESSED, slide); set!(ft::SWITCH_SLIDE_BORDER_DISABLED, text_disabled); set!(ft::SWITCH_SLIDE_BORDER_CHECKED, slide); set!(ft::SWITCH_SLIDE_BORDER_CHECKED_HOVER, slide); set!(ft::SWITCH_SLIDE_BORDER_CHECKED_PRESSED, slide); set!(ft::SWITCH_SLIDE_BORDER_CHECKED_DISABLED, text_disabled); // Color plane set!(ft::COLOR_PLANE_BG, sunken); // Menus let menu_bg = neutral(a.surface); set!(ft::MENU_BG, menu_bg); set!(ft::MENU_BORDER, border); set!(ft::MENUITEM_BG_HOVER, elevate(menu_bg, mode, 0.03)); set!(ft::MENUITEM_BG_PRESSED, elevate(menu_bg, mode, 0.06)); set!(ft::MENUITEM_BG_FOCUSED, elevate(menu_bg, mode, 0.06)); set!(ft::MENUITEM_TEXT, text); set!(ft::MENUITEM_TEXT_DISABLED, text_disabled.with_alpha(0.5)); // Text input set!(ft::TEXT_INPUT_BG, sunken); set!(ft::TEXT_INPUT_LABEL_BG, control); set!(ft::TEXT_INPUT_TEXT, text); set!(ft::TEXT_INPUT_TEXT_DISABLED, text_disabled.with_alpha(0.5)); set!(ft::TEXT_INPUT_CURSOR, focus_ring); set!(ft::TEXT_INPUT_SELECTION, accent_bg.with_alpha(0.5)); set!(ft::TEXT_INPUT_SELECTION_UNFOCUSED, Color::NONE); set!(ft::TEXT_INPUT_X_AXIS, status(27.0)); set!(ft::TEXT_INPUT_Y_AXIS, status(150.0)); set!(ft::TEXT_INPUT_Z_AXIS, status(270.0)); // Listview set!(ft::LISTROW_BG, Color::NONE); set!(ft::LISTROW_BG_HOVER, control.with_alpha(0.4)); set!(ft::LISTROW_BG_SELECTED, control); set!(ft::LISTROW_TEXT, text); set!(ft::LISTROW_TEXT_DISABLED, text_disabled.with_alpha(0.5)); ThemeProps { color: colors } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use bevy::color::Srgba; use bevy::feathers::tokens as ft; fn hex(s: &str) -> Color { Color::from(Srgba::hex(s).unwrap()) } fn demo_seeds() -> Seeds { Seeds::from_coolors([ hex("264653"), hex("2A9D8F"), hex("E9C46A"), hex("F4A261"), hex("E76F51"), ]) } #[test] fn text_meets_contrast_in_both_modes() { for mode in [Mode::Dark, Mode::Light] { let t = build(demo_seeds(), mode); let get = |tok: &ThemeToken| *t.color.get(tok).unwrap(); assert!(contrast_ratio(get(&ft::TEXT_MAIN), get(&ft::WINDOW_BG)) >= 4.5); assert!( contrast_ratio(get(&ft::BUTTON_PRIMARY_TEXT), get(&ft::BUTTON_PRIMARY_BG)) >= 4.5 ); assert!(contrast_ratio(get(&ft::BUTTON_TEXT), get(&ft::BUTTON_BG)) >= 4.5); } } }