You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

412 lines
15 KiB
Rust

//! 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<Oklcha> = 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<ThemeToken, Color> = 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);
}
}
}