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:
2026-06-13 17:43:13 -04:00
co-authored by Claude Opus 4.8
parent 53c1c112e6
commit c87fa3d45f
5 changed files with 574 additions and 29 deletions
+145 -29
View File
@@ -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.