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:
+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.
|
||||
|
||||
Reference in New Issue
Block a user