The UI was hardcoded to one Catppuccin Mocha palette (color literals in view() + theme() pinned to Dark). It now sources colours from a chosen theme's palette, selectable in Settings. - theme.rs (NEW): Palette (13 semantic colour roles) + AppTheme enum (Catppuccin Mocha/Macchiato/Frappe/Latte, Dracula, Nord, Tokyo Night, Gruvbox Dark, Solarized Light, Gruvbox Light). Pure palette()/label()/ ALL/base_theme()/is_dark() + WCAG relative_luminance()/contrast_ratio(). - config: theme: AppTheme field (serde-default Mocha) + backward-compat. - app: theme() returns config.theme.base_theme() (iced widget chrome); view() + with_layout_picker() source colours from the palette; new ThemeSwatch canvas widget; a Theme section of clickable swatches in Settings; SelectTheme applies live + persists. Canvas widgets already take colours as data, so they re-theme for free. Tests written alongside (+7 theme, +2 config; 135 -> 144 lib): every palette clears WCAG AA text-on-base contrast (4.5:1) with subtext/accent >= 3:1, is_dark matches luminance direction, variants distinct/labeled, serde round-trips, default = Mocha. Verified live: the Settings swatch grid renders all 10 palettes and the whole UI re-themes (screenshot-checked Latte light + Dracula dark). clippy --all-targets clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
401 lines
14 KiB
Rust
401 lines
14 KiB
Rust
//! UI theming: a set of selectable colour palettes.
|
|
//!
|
|
//! The UI uses 13 semantic colour roles (background tiers + accents). Each
|
|
//! [`AppTheme`] maps to a [`Palette`] of those roles via the pure
|
|
//! [`AppTheme::palette`] fn — that's the testable seam: palettes are plain data
|
|
//! and the legibility guarantees below are unit-tested. `view()` reads the
|
|
//! active palette instead of hardcoded literals, so every widget (incl. the
|
|
//! canvas widgets that already take colours as data) re-themes automatically.
|
|
//!
|
|
//! `AppTheme` also maps to an `iced::Theme` ([`AppTheme::base_theme`]) so iced's
|
|
//! built-in widget chrome (scrollbars, text-input selection, dropdown menus)
|
|
//! matches the chosen palette.
|
|
|
|
use iced::Color;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
/// 13 semantic colour roles the UI draws with. Names follow the Catppuccin
|
|
/// vocabulary (the original palette), but each theme supplies its own values.
|
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
pub struct Palette {
|
|
/// Darkest background (window backdrop behind everything).
|
|
pub crust: Color,
|
|
/// Slightly raised background.
|
|
pub mantle: Color,
|
|
/// Main content background.
|
|
pub base: Color,
|
|
/// Raised panel / card surface.
|
|
pub surface: Color,
|
|
/// Muted surface — placeholders, disabled, faint dividers.
|
|
pub overlay: Color,
|
|
/// Primary foreground text.
|
|
pub text: Color,
|
|
/// Secondary / hint text.
|
|
pub subtext: Color,
|
|
/// Primary accent (titles, primary buttons, selection borders).
|
|
pub blue: Color,
|
|
/// Secondary accent.
|
|
pub lavender: Color,
|
|
/// Danger / record / error.
|
|
pub red: Color,
|
|
/// Red variant (a warmer alternative to `red`).
|
|
pub maroon: Color,
|
|
/// Success / "transmitting" / speaking.
|
|
pub green: Color,
|
|
/// Warning / "reconnecting".
|
|
pub yellow: Color,
|
|
}
|
|
|
|
/// A selectable UI theme. The default is `Mocha` (the project's original look).
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
|
pub enum AppTheme {
|
|
#[default]
|
|
Mocha,
|
|
Macchiato,
|
|
Frappe,
|
|
Latte,
|
|
Dracula,
|
|
Nord,
|
|
TokyoNight,
|
|
GruvboxDark,
|
|
SolarizedLight,
|
|
GruvboxLight,
|
|
}
|
|
|
|
/// Build a `Color` from a packed `0xRRGGBB` literal — keeps the palette tables
|
|
/// compact and greppable against each theme's canonical hex values.
|
|
fn hex(c: u32) -> Color {
|
|
Color::from_rgb8((c >> 16) as u8, (c >> 8) as u8, c as u8)
|
|
}
|
|
|
|
impl AppTheme {
|
|
/// Every theme, in picker order.
|
|
pub const ALL: [AppTheme; 10] = [
|
|
AppTheme::Mocha,
|
|
AppTheme::Macchiato,
|
|
AppTheme::Frappe,
|
|
AppTheme::Latte,
|
|
AppTheme::Dracula,
|
|
AppTheme::Nord,
|
|
AppTheme::TokyoNight,
|
|
AppTheme::GruvboxDark,
|
|
AppTheme::SolarizedLight,
|
|
AppTheme::GruvboxLight,
|
|
];
|
|
|
|
/// Human-readable name for the picker.
|
|
pub fn label(self) -> &'static str {
|
|
match self {
|
|
AppTheme::Mocha => "Mocha",
|
|
AppTheme::Macchiato => "Macchiato",
|
|
AppTheme::Frappe => "Frappé",
|
|
AppTheme::Latte => "Latte",
|
|
AppTheme::Dracula => "Dracula",
|
|
AppTheme::Nord => "Nord",
|
|
AppTheme::TokyoNight => "Tokyo Night",
|
|
AppTheme::GruvboxDark => "Gruvbox Dark",
|
|
AppTheme::SolarizedLight => "Solarized Light",
|
|
AppTheme::GruvboxLight => "Gruvbox Light",
|
|
}
|
|
}
|
|
|
|
/// True for dark themes (dark background, light text).
|
|
pub fn is_dark(self) -> bool {
|
|
!matches!(
|
|
self,
|
|
AppTheme::Latte | AppTheme::SolarizedLight | AppTheme::GruvboxLight
|
|
)
|
|
}
|
|
|
|
/// The matching iced built-in theme, for built-in widget chrome.
|
|
pub fn base_theme(self) -> iced::Theme {
|
|
match self {
|
|
AppTheme::Mocha => iced::Theme::CatppuccinMocha,
|
|
AppTheme::Macchiato => iced::Theme::CatppuccinMacchiato,
|
|
AppTheme::Frappe => iced::Theme::CatppuccinFrappe,
|
|
AppTheme::Latte => iced::Theme::CatppuccinLatte,
|
|
AppTheme::Dracula => iced::Theme::Dracula,
|
|
AppTheme::Nord => iced::Theme::Nord,
|
|
AppTheme::TokyoNight => iced::Theme::TokyoNight,
|
|
AppTheme::GruvboxDark => iced::Theme::GruvboxDark,
|
|
AppTheme::SolarizedLight => iced::Theme::SolarizedLight,
|
|
AppTheme::GruvboxLight => iced::Theme::GruvboxLight,
|
|
}
|
|
}
|
|
|
|
/// The 13-role colour palette for this theme. Pure data — colours are each
|
|
/// theme's canonical hex values mapped onto our semantic roles.
|
|
pub fn palette(self) -> Palette {
|
|
match self {
|
|
// Catppuccin Mocha — the project's original palette.
|
|
AppTheme::Mocha => Palette {
|
|
crust: hex(0x11111b),
|
|
mantle: hex(0x181825),
|
|
base: hex(0x1e1e2e),
|
|
surface: hex(0x313244),
|
|
overlay: hex(0x6c7086),
|
|
text: hex(0xcdd6f4),
|
|
subtext: hex(0xa6adc8),
|
|
blue: hex(0x89b4fa),
|
|
lavender: hex(0xb4befe),
|
|
red: hex(0xf38ba8),
|
|
maroon: hex(0xeba0ac),
|
|
green: hex(0xa6e3a1),
|
|
yellow: hex(0xf9e2af),
|
|
},
|
|
AppTheme::Macchiato => Palette {
|
|
crust: hex(0x181926),
|
|
mantle: hex(0x1e2030),
|
|
base: hex(0x24273a),
|
|
surface: hex(0x363a4f),
|
|
overlay: hex(0x6e738d),
|
|
text: hex(0xcad3f5),
|
|
subtext: hex(0xa5adcb),
|
|
blue: hex(0x8aadf4),
|
|
lavender: hex(0xb7bdf8),
|
|
red: hex(0xed8796),
|
|
maroon: hex(0xee99a0),
|
|
green: hex(0xa6da95),
|
|
yellow: hex(0xeed49f),
|
|
},
|
|
AppTheme::Frappe => Palette {
|
|
crust: hex(0x232634),
|
|
mantle: hex(0x292c3c),
|
|
base: hex(0x303446),
|
|
surface: hex(0x414559),
|
|
overlay: hex(0x737994),
|
|
text: hex(0xc6d0f5),
|
|
subtext: hex(0xa5adce),
|
|
blue: hex(0x8caaee),
|
|
lavender: hex(0xbabbf1),
|
|
red: hex(0xe78284),
|
|
maroon: hex(0xea999c),
|
|
green: hex(0xa6d189),
|
|
yellow: hex(0xe5c890),
|
|
},
|
|
// Catppuccin Latte — light.
|
|
AppTheme::Latte => Palette {
|
|
crust: hex(0xdce0e8),
|
|
mantle: hex(0xe6e9ef),
|
|
base: hex(0xeff1f5),
|
|
surface: hex(0xccd0da),
|
|
overlay: hex(0x9ca0b0),
|
|
text: hex(0x4c4f69),
|
|
subtext: hex(0x6c6f85),
|
|
blue: hex(0x1e66f5),
|
|
lavender: hex(0x7287fd),
|
|
red: hex(0xd20f39),
|
|
maroon: hex(0xe64553),
|
|
green: hex(0x40a02b),
|
|
yellow: hex(0xdf8e1d),
|
|
},
|
|
AppTheme::Dracula => Palette {
|
|
crust: hex(0x191a21),
|
|
mantle: hex(0x21222c),
|
|
base: hex(0x282a36),
|
|
surface: hex(0x44475a),
|
|
overlay: hex(0x6272a4),
|
|
text: hex(0xf8f8f2),
|
|
subtext: hex(0xbdc0d4),
|
|
blue: hex(0xbd93f9), // Dracula's signature purple as the primary accent
|
|
lavender: hex(0x8be9fd), // cyan
|
|
red: hex(0xff5555),
|
|
maroon: hex(0xff79c6), // pink
|
|
green: hex(0x50fa7b),
|
|
yellow: hex(0xf1fa8c),
|
|
},
|
|
AppTheme::Nord => Palette {
|
|
crust: hex(0x272c36),
|
|
mantle: hex(0x2b313c),
|
|
base: hex(0x2e3440),
|
|
surface: hex(0x3b4252),
|
|
overlay: hex(0x4c566a),
|
|
text: hex(0xeceff4),
|
|
subtext: hex(0xd8dee9),
|
|
blue: hex(0x88c0d0),
|
|
lavender: hex(0xb48ead),
|
|
red: hex(0xbf616a),
|
|
maroon: hex(0xd08770),
|
|
green: hex(0xa3be8c),
|
|
yellow: hex(0xebcb8b),
|
|
},
|
|
AppTheme::TokyoNight => Palette {
|
|
crust: hex(0x16161e),
|
|
mantle: hex(0x1b1c29),
|
|
base: hex(0x1a1b26),
|
|
surface: hex(0x24283b),
|
|
overlay: hex(0x565f89),
|
|
text: hex(0xc0caf5),
|
|
subtext: hex(0x9aa5ce),
|
|
blue: hex(0x7aa2f7),
|
|
lavender: hex(0xbb9af7),
|
|
red: hex(0xf7768e),
|
|
maroon: hex(0xff9e64),
|
|
green: hex(0x9ece6a),
|
|
yellow: hex(0xe0af68),
|
|
},
|
|
AppTheme::GruvboxDark => Palette {
|
|
crust: hex(0x1d2021),
|
|
mantle: hex(0x242424),
|
|
base: hex(0x282828),
|
|
surface: hex(0x3c3836),
|
|
overlay: hex(0x665c54),
|
|
text: hex(0xebdbb2),
|
|
subtext: hex(0xbdae93),
|
|
blue: hex(0x83a598),
|
|
lavender: hex(0xd3869b),
|
|
red: hex(0xfb4934),
|
|
maroon: hex(0xfe8019),
|
|
green: hex(0xb8bb26),
|
|
yellow: hex(0xfabd2f),
|
|
},
|
|
// Solarized Light — light.
|
|
AppTheme::SolarizedLight => Palette {
|
|
crust: hex(0xe6dfc8),
|
|
mantle: hex(0xf3ecd6),
|
|
base: hex(0xfdf6e3),
|
|
surface: hex(0xeee8d5),
|
|
overlay: hex(0x93a1a1),
|
|
text: hex(0x586e75),
|
|
subtext: hex(0x657b83),
|
|
blue: hex(0x268bd2),
|
|
lavender: hex(0x6c71c4),
|
|
red: hex(0xdc322f),
|
|
maroon: hex(0xcb4b16),
|
|
green: hex(0x859900),
|
|
yellow: hex(0xb58900),
|
|
},
|
|
// Gruvbox Light — light.
|
|
AppTheme::GruvboxLight => Palette {
|
|
crust: hex(0xf2e5bc),
|
|
mantle: hex(0xf9f5d7),
|
|
base: hex(0xfbf1c7),
|
|
surface: hex(0xebdbb2),
|
|
overlay: hex(0xbdae93),
|
|
text: hex(0x3c3836),
|
|
subtext: hex(0x504945),
|
|
blue: hex(0x076678),
|
|
lavender: hex(0x8f3f71),
|
|
red: hex(0x9d0006),
|
|
maroon: hex(0xaf3a03),
|
|
green: hex(0x79740e),
|
|
yellow: hex(0xb57614),
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
/// WCAG relative luminance of a colour (sRGB → linear, 0.0..=1.0).
|
|
pub fn relative_luminance(c: Color) -> f32 {
|
|
fn lin(ch: f32) -> f32 {
|
|
if ch <= 0.03928 {
|
|
ch / 12.92
|
|
} else {
|
|
((ch + 0.055) / 1.055).powf(2.4)
|
|
}
|
|
}
|
|
0.2126 * lin(c.r) + 0.7152 * lin(c.g) + 0.0722 * lin(c.b)
|
|
}
|
|
|
|
/// WCAG contrast ratio between two colours (1.0..=21.0). Order-independent.
|
|
pub 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)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn contrast_ratio_known_values() {
|
|
// Black on white is the maximum 21:1; a colour against itself is 1:1.
|
|
assert!((contrast_ratio(Color::BLACK, Color::WHITE) - 21.0).abs() < 0.1);
|
|
assert!((contrast_ratio(Color::WHITE, Color::WHITE) - 1.0).abs() < 0.001);
|
|
// Symmetric.
|
|
let a = hex(0x89b4fa);
|
|
let b = hex(0x1e1e2e);
|
|
assert!((contrast_ratio(a, b) - contrast_ratio(b, a)).abs() < 0.001);
|
|
}
|
|
|
|
#[test]
|
|
fn every_theme_has_legible_body_text() {
|
|
// Primary text on the main background must clear WCAG AA (4.5:1).
|
|
for theme in AppTheme::ALL {
|
|
let p = theme.palette();
|
|
let ratio = contrast_ratio(p.text, p.base);
|
|
assert!(
|
|
ratio >= 4.5,
|
|
"{}: text-on-base contrast {ratio:.2} < 4.5 (AA)",
|
|
theme.label()
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn every_theme_has_readable_subtext_and_accents() {
|
|
// Secondary text clears AA-large (3:1); the title/primary accent must be
|
|
// readable on the background too (it's used for headings + button text).
|
|
for theme in AppTheme::ALL {
|
|
let p = theme.palette();
|
|
let sub = contrast_ratio(p.subtext, p.base);
|
|
assert!(sub >= 3.0, "{}: subtext contrast {sub:.2} < 3.0", theme.label());
|
|
let accent = contrast_ratio(p.blue, p.base);
|
|
assert!(
|
|
accent >= 3.0,
|
|
"{}: primary-accent contrast {accent:.2} < 3.0",
|
|
theme.label()
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn is_dark_matches_luminance_direction() {
|
|
// Dark themes: text brighter than base. Light themes: text darker.
|
|
for theme in AppTheme::ALL {
|
|
let p = theme.palette();
|
|
let text_brighter = relative_luminance(p.text) > relative_luminance(p.base);
|
|
assert_eq!(
|
|
text_brighter,
|
|
theme.is_dark(),
|
|
"{}: is_dark={} but text_brighter={}",
|
|
theme.label(),
|
|
theme.is_dark(),
|
|
text_brighter
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn all_themes_distinct_and_labeled() {
|
|
// ALL covers exactly the variants once, each with a unique non-empty label
|
|
// and a distinct base colour (so swatches don't look identical).
|
|
assert_eq!(AppTheme::ALL.len(), 10);
|
|
let mut labels: Vec<&str> = AppTheme::ALL.iter().map(|t| t.label()).collect();
|
|
labels.sort_unstable();
|
|
labels.dedup();
|
|
assert_eq!(labels.len(), 10, "labels must be unique + non-empty");
|
|
assert!(labels.iter().all(|l| !l.is_empty()));
|
|
}
|
|
|
|
#[test]
|
|
fn serde_round_trips_every_variant() {
|
|
for theme in AppTheme::ALL {
|
|
let json = serde_json::to_string(&theme).unwrap();
|
|
let back: AppTheme = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(theme, back);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn default_is_mocha() {
|
|
assert_eq!(AppTheme::default(), AppTheme::Mocha);
|
|
// Default palette matches the project's original literals.
|
|
let p = AppTheme::default().palette();
|
|
assert_eq!(p.base, Color::from_rgb8(30, 30, 46));
|
|
assert_eq!(p.text, Color::from_rgb8(205, 214, 244));
|
|
assert_eq!(p.blue, Color::from_rgb8(137, 180, 250));
|
|
}
|
|
}
|