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>
This commit is contained in:
@@ -115,6 +115,7 @@ covers internals). When you ship a feature, add it here.
|
||||
| Chat drawer toggle (drawer layout) | 🧪 | Toggle + restart-persistence not explicitly confirmed. |
|
||||
| Responsive full-window Settings screen | ✅ | |
|
||||
| Canvas-drawn icon set | ✅ | 18 icons (`Icon`/`IconKind` in `src/app/mod.rs`), no image/font dep, theme-recolored. Replaces all emoji; SVG design source in `~/Documents/peerspeak-mockups/icons/`. |
|
||||
| Selectable UI themes | ✅ | 10 palettes (`src/theme.rs`): Catppuccin Mocha (default)/Macchiato/Frappé/Latte, Dracula, Nord, Tokyo Night, Gruvbox Dark, Solarized Light, Gruvbox Light. Picked via canvas palette swatches in Settings (`ThemeSwatch`), applied live + persisted (`config.theme`). All palettes unit-tested for WCAG-AA text legibility. |
|
||||
|
||||
## Persistence
|
||||
|
||||
|
||||
+145
-29
@@ -3,6 +3,7 @@ use crate::network::PeerState;
|
||||
use crate::notify::{self, Sound};
|
||||
use crate::audio::pw_cli::{AudioDevice, enumerate_audio_devices};
|
||||
use crate::config::{AppConfig, NetworkMode, RoomLayout};
|
||||
use crate::theme::{AppTheme, Palette};
|
||||
|
||||
use iced::widget::{
|
||||
container, column, row, text, button, text_input, scrollable, slider, checkbox, pick_list,
|
||||
@@ -150,6 +151,8 @@ pub enum AppMessage {
|
||||
CloseLayoutPicker,
|
||||
/// Choose a room layout (applied live + persisted, closes the popup).
|
||||
SelectRoomLayout(RoomLayout),
|
||||
/// Choose a UI theme (applied live + persisted).
|
||||
SelectTheme(AppTheme),
|
||||
/// Toggle the Chat drawer open/closed (drawer layout).
|
||||
ToggleDrawerChat,
|
||||
/// Start/stop sharing our own screen (spawns/kills a pixelpass host).
|
||||
@@ -317,8 +320,8 @@ impl Default for AppState {
|
||||
}
|
||||
}
|
||||
|
||||
fn theme(_state: &AppState) -> Theme {
|
||||
Theme::Dark
|
||||
fn theme(state: &AppState) -> Theme {
|
||||
state.config.theme.base_theme()
|
||||
}
|
||||
|
||||
pub fn run_gui() -> iced::Result {
|
||||
@@ -742,6 +745,10 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
||||
state.config.save();
|
||||
state.layout_picker_open = false;
|
||||
}
|
||||
AppMessage::SelectTheme(theme) => {
|
||||
state.config.theme = theme;
|
||||
state.config.save();
|
||||
}
|
||||
AppMessage::ToggleDrawerChat => {
|
||||
state.drawer_chat_open = !state.drawer_chat_open;
|
||||
}
|
||||
@@ -901,19 +908,22 @@ fn vertical_space(height: f32) -> iced::widget::Space {
|
||||
}
|
||||
|
||||
fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
// Theme Colors
|
||||
let color_crust = Color::from_rgb8(17, 17, 27);
|
||||
let color_mantle = Color::from_rgb8(24, 24, 37);
|
||||
let color_base = Color::from_rgb8(30, 30, 46);
|
||||
let color_surface = Color::from_rgb8(49, 50, 68);
|
||||
let color_text = Color::from_rgb8(205, 214, 244);
|
||||
let color_subtext = Color::from_rgb8(166, 173, 200);
|
||||
let color_blue = Color::from_rgb8(137, 180, 250);
|
||||
let color_lavender = Color::from_rgb8(180, 190, 254);
|
||||
let color_red = Color::from_rgb8(243, 139, 168);
|
||||
let color_maroon = Color::from_rgb8(233, 146, 160);
|
||||
let color_green = Color::from_rgb8(166, 227, 161);
|
||||
let color_yellow = Color::from_rgb8(249, 226, 175);
|
||||
// Theme colours — sourced from the active palette (see `src/theme.rs`), so
|
||||
// all styling below re-themes when the user picks a different theme.
|
||||
let pal = state.config.theme.palette();
|
||||
let color_crust = pal.crust;
|
||||
let color_mantle = pal.mantle;
|
||||
let color_base = pal.base;
|
||||
let color_surface = pal.surface;
|
||||
let color_overlay = pal.overlay;
|
||||
let color_text = pal.text;
|
||||
let color_subtext = pal.subtext;
|
||||
let color_blue = pal.blue;
|
||||
let color_lavender = pal.lavender;
|
||||
let color_red = pal.red;
|
||||
let color_maroon = pal.maroon;
|
||||
let color_green = pal.green;
|
||||
let color_yellow = pal.yellow;
|
||||
|
||||
// Style Helpers
|
||||
let c_style = move |bg: Color, b_color: Color, radius: f32| {
|
||||
@@ -957,7 +967,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
radius: 6.0.into(),
|
||||
},
|
||||
icon: color_subtext,
|
||||
placeholder: Color::from_rgb8(108, 112, 134),
|
||||
placeholder: color_overlay,
|
||||
value: color_text,
|
||||
selection: color_blue,
|
||||
}
|
||||
@@ -997,7 +1007,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
|
||||
column![
|
||||
row![
|
||||
text(label).size(12).color(Color::from_rgb8(180, 180, 180)),
|
||||
text(label).size(12).color(color_subtext),
|
||||
horizontal_space(),
|
||||
validation_widget,
|
||||
].align_y(iced::alignment::Vertical::Center),
|
||||
@@ -1080,7 +1090,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
selected,
|
||||
base: color_base,
|
||||
surface: color_surface,
|
||||
overlay: Color::from_rgb8(69, 71, 90),
|
||||
overlay: color_overlay,
|
||||
border: if selected { color_blue } else { color_surface },
|
||||
})
|
||||
.width(iced::Length::Fixed(132.0))
|
||||
@@ -1097,6 +1107,46 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
.into()
|
||||
};
|
||||
|
||||
// Inline theme chooser — a clickable palette-preview swatch per theme.
|
||||
// Same SelectTheme message, applied live + persisted.
|
||||
let theme_choice = |t: AppTheme| -> Element<'_, AppMessage> {
|
||||
let selected = state.config.theme == t;
|
||||
let tile = Canvas::new(ThemeSwatch {
|
||||
palette: t.palette(),
|
||||
selected,
|
||||
border: if selected { color_blue } else { color_surface },
|
||||
})
|
||||
.width(iced::Length::Fixed(120.0))
|
||||
.height(iced::Length::Fixed(64.0));
|
||||
column![
|
||||
button(tile)
|
||||
.on_press(AppMessage::SelectTheme(t))
|
||||
.padding(2)
|
||||
.style(b_style(Color::TRANSPARENT, color_surface, color_text, 8.0)),
|
||||
text(t.label())
|
||||
.size(11)
|
||||
.color(if selected { color_blue } else { color_subtext }),
|
||||
]
|
||||
.spacing(4)
|
||||
.align_x(iced::alignment::Horizontal::Center)
|
||||
.into()
|
||||
};
|
||||
// 10 themes laid out as two rows of five (no flex-wrap in iced 0.14).
|
||||
let theme_row1: Vec<Element<'_, AppMessage>> =
|
||||
AppTheme::ALL[0..5].iter().map(|&t| theme_choice(t)).collect();
|
||||
let theme_row2: Vec<Element<'_, AppMessage>> =
|
||||
AppTheme::ALL[5..].iter().map(|&t| theme_choice(t)).collect();
|
||||
let theme_section = column![
|
||||
text("Theme").size(14).color(color_subtext),
|
||||
iced::widget::Row::with_children(theme_row1).spacing(12),
|
||||
iced::widget::Row::with_children(theme_row2).spacing(12),
|
||||
text("Colour theme for the whole UI. Applies live.")
|
||||
.size(11)
|
||||
.color(color_subtext),
|
||||
]
|
||||
.spacing(10)
|
||||
.width(iced::Length::Fill);
|
||||
|
||||
let settings_content = scrollable(
|
||||
column![
|
||||
text("Settings").size(24).color(color_blue),
|
||||
@@ -1105,7 +1155,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
vertical_space(6.0),
|
||||
row![
|
||||
column![
|
||||
text("Input Device").size(12).color(Color::from_rgb8(180, 180, 180)),
|
||||
text("Input Device").size(12).color(color_subtext),
|
||||
pick_list(
|
||||
&state.input_devices[..],
|
||||
state.selected_input.as_ref(),
|
||||
@@ -1117,7 +1167,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
.on_release(AppMessage::PersistConfig),
|
||||
].spacing(4).width(iced::Length::Fill),
|
||||
column![
|
||||
text("Output Device").size(12).color(Color::from_rgb8(180, 180, 180)),
|
||||
text("Output Device").size(12).color(color_subtext),
|
||||
pick_list(
|
||||
&state.output_devices[..],
|
||||
state.selected_output.as_ref(),
|
||||
@@ -1163,6 +1213,8 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
text("How the in-call room is arranged. Applies live.").size(11).color(color_subtext),
|
||||
].spacing(8).width(iced::Length::Fill),
|
||||
vertical_space(12.0),
|
||||
theme_section,
|
||||
vertical_space(12.0),
|
||||
column![
|
||||
text("Notification Chimes").size(14).color(color_subtext),
|
||||
checkbox(state.config.notifications_enabled)
|
||||
@@ -2089,15 +2141,16 @@ fn with_layout_picker<'a>(
|
||||
if !state.layout_picker_open {
|
||||
return base;
|
||||
}
|
||||
let crust = Color::from_rgb8(17, 17, 27);
|
||||
let mantle = Color::from_rgb8(24, 24, 37);
|
||||
let base_c = Color::from_rgb8(30, 30, 46);
|
||||
let surface = Color::from_rgb8(49, 50, 68);
|
||||
let overlay = Color::from_rgb8(69, 71, 90);
|
||||
let text_c = Color::from_rgb8(205, 214, 244);
|
||||
let subtext = Color::from_rgb8(166, 173, 200);
|
||||
let blue = Color::from_rgb8(137, 180, 250);
|
||||
let green = Color::from_rgb8(166, 227, 161);
|
||||
let pal = state.config.theme.palette();
|
||||
let crust = pal.crust;
|
||||
let mantle = pal.mantle;
|
||||
let base_c = pal.base;
|
||||
let surface = pal.surface;
|
||||
let overlay = pal.overlay;
|
||||
let text_c = pal.text;
|
||||
let subtext = pal.subtext;
|
||||
let blue = pal.blue;
|
||||
let green = pal.green;
|
||||
|
||||
let backdrop = mouse_area(
|
||||
container(horizontal_space())
|
||||
@@ -2292,6 +2345,69 @@ impl Program<AppMessage> for LayoutThumb {
|
||||
}
|
||||
}
|
||||
|
||||
/// A small palette-preview tile for the theme picker (modeled on `LayoutThumb`):
|
||||
/// the theme's background, a surface panel with two "text" lines to preview
|
||||
/// legibility, and a stack of accent colours. Border highlights the selection.
|
||||
struct ThemeSwatch {
|
||||
palette: Palette,
|
||||
selected: bool,
|
||||
border: Color,
|
||||
}
|
||||
|
||||
impl Program<AppMessage> for ThemeSwatch {
|
||||
type State = ();
|
||||
fn draw(
|
||||
&self,
|
||||
_state: &(),
|
||||
renderer: &Renderer,
|
||||
_theme: &Theme,
|
||||
bounds: Rectangle,
|
||||
_cursor: mouse::Cursor,
|
||||
) -> Vec<Geometry> {
|
||||
let mut f = Frame::new(renderer, bounds.size());
|
||||
let (w, h) = (bounds.width, bounds.height);
|
||||
let p = &self.palette;
|
||||
let pad = 7.0;
|
||||
|
||||
// Background fill.
|
||||
f.fill(&Path::rectangle(Point::ORIGIN, Size::new(w, h)), p.base);
|
||||
|
||||
// Surface panel on the left half, with two text-colour lines on it.
|
||||
let panel_w = (w - 2.0 * pad) * 0.52;
|
||||
f.fill(
|
||||
&Path::rectangle(Point::new(pad, pad), Size::new(panel_w, h - 2.0 * pad)),
|
||||
p.surface,
|
||||
);
|
||||
let mut line = |y: f32, ww: f32, col: Color| {
|
||||
f.fill(
|
||||
&Path::rectangle(Point::new(pad + 6.0, y), Size::new(ww, 3.0)),
|
||||
col,
|
||||
);
|
||||
};
|
||||
line(pad + 9.0, panel_w * 0.66, p.text);
|
||||
line(pad + 17.0, panel_w * 0.48, p.subtext);
|
||||
|
||||
// Accent swatches stacked on the right.
|
||||
let ax = pad + panel_w + 6.0;
|
||||
let aw = (w - pad - ax).max(2.0);
|
||||
let accents = [p.blue, p.green, p.yellow, p.red, p.lavender];
|
||||
let gap = 3.0;
|
||||
let ah = ((h - 2.0 * pad) - gap * (accents.len() as f32 - 1.0)) / accents.len() as f32;
|
||||
for (i, c) in accents.iter().enumerate() {
|
||||
let ay = pad + i as f32 * (ah + gap);
|
||||
f.fill(&Path::rectangle(Point::new(ax, ay), Size::new(aw, ah)), *c);
|
||||
}
|
||||
|
||||
// Border (thicker + accent when selected).
|
||||
let bw: f32 = if self.selected { 2.0 } else { 1.0 };
|
||||
f.stroke(
|
||||
&Path::rectangle(Point::new(bw / 2.0, bw / 2.0), Size::new(w - bw, h - bw)),
|
||||
canvas::Stroke::default().with_color(self.border).with_width(bw),
|
||||
);
|
||||
vec![f.into_geometry()]
|
||||
}
|
||||
}
|
||||
|
||||
/// The PeerSpeak icon set, drawn on a `canvas` so it needs no image/font asset
|
||||
/// and recolors with the theme (consistent with `LayoutThumb`/`GateMeter`).
|
||||
/// Each icon is authored in a 24×24 space and scaled to the widget size.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::theme::AppTheme;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
@@ -137,6 +138,9 @@ pub struct AppConfig {
|
||||
/// Chosen arrangement of the in-call room screen.
|
||||
#[serde(default)]
|
||||
pub room_layout: RoomLayout,
|
||||
/// Chosen UI colour theme.
|
||||
#[serde(default)]
|
||||
pub theme: AppTheme,
|
||||
#[serde(default)]
|
||||
pub custom_sound_self_join: Option<String>,
|
||||
#[serde(default)]
|
||||
@@ -190,6 +194,7 @@ impl Default for AppConfig {
|
||||
controls_width: default_controls_width(),
|
||||
chat_drawer_width: default_chat_drawer_width(),
|
||||
room_layout: RoomLayout::default(),
|
||||
theme: AppTheme::default(),
|
||||
custom_sound_self_join: None,
|
||||
custom_sound_peer_join: None,
|
||||
custom_sound_peer_leave: None,
|
||||
@@ -318,6 +323,28 @@ mod tests {
|
||||
assert_eq!(back.window_y, Some(-50));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_theme_field() {
|
||||
// Default theme is Mocha (the original look).
|
||||
assert_eq!(AppConfig::default().theme, AppTheme::Mocha);
|
||||
// A chosen theme round-trips.
|
||||
let cfg = AppConfig {
|
||||
theme: AppTheme::Nord,
|
||||
..AppConfig::default()
|
||||
};
|
||||
let json = serde_json::to_string(&cfg).unwrap();
|
||||
let back: AppConfig = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.theme, AppTheme::Nord);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_config_without_theme_loads() {
|
||||
// Configs predating the theme picker load with the default (Mocha).
|
||||
let json = r#"{"input_device":"","output_device":"","noise_gate_threshold":0.01}"#;
|
||||
let cfg: AppConfig = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(cfg.theme, AppTheme::Mocha);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_config_without_position_loads() {
|
||||
// A config written before window_x/window_y existed still deserializes
|
||||
|
||||
@@ -4,6 +4,7 @@ pub mod network;
|
||||
pub mod core;
|
||||
pub mod app;
|
||||
pub mod config;
|
||||
pub mod theme;
|
||||
pub mod notify;
|
||||
pub mod screenshare;
|
||||
|
||||
|
||||
+400
@@ -0,0 +1,400 @@
|
||||
//! 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user