Files
peerspeak/src/theme.rs
T
molluskandClaude Opus 4.8 c87fa3d45f feat(ui): selectable UI themes (10 palettes + swatch picker)
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>
2026-06-13 17:43:13 -04:00

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));
}
}