From 0a4bb554e91dfdbb6995c754422393b3dfac2310 Mon Sep 17 00:00:00 2001 From: Mollusk Date: Fri, 29 May 2026 02:51:03 -0400 Subject: [PATCH] feat(gui): add a colour theme model with built-ins and file I/O New gui::theme module: a Theme is a curated semantic palette (backgrounds, text, accent, button, and the status colours) that serialises to TOML with #rrggbb hex colours and builds an egui::Visuals. Missing fields fall back to the built-in Default Dark via #[serde(default)], so partial/hand-trimmed files still load. Three built-ins ship (Default Dark, Catppuccin Mocha, Catppuccin Latte); user themes live as *.toml in ~/.config/pixelpass/themes/ and a user file overrides a built-in of the same name. Adds a `theme` field to the GUI config (default "Default Dark"). Zero new deps (toml + a few lines of hex parsing). 6 unit tests. Co-Authored-By: Claude Opus 4.8 --- src/common/config.rs | 9 + src/gui/theme.rs | 453 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 462 insertions(+) create mode 100644 src/gui/theme.rs diff --git a/src/common/config.rs b/src/common/config.rs index 9c0f978..3edebc6 100644 --- a/src/common/config.rs +++ b/src/common/config.rs @@ -32,6 +32,10 @@ pub struct GuiSettings { /// text-only host screen. #[serde(default = "default_true")] pub show_qr: bool, + /// Name of the active GUI colour theme (a built-in, or a user file in + /// `~/.config/pixelpass/themes/`). Defaults to the built-in Default Dark. + #[serde(default = "default_theme")] + pub theme: String, } impl Default for GuiSettings { @@ -39,6 +43,7 @@ impl Default for GuiSettings { Self { close_to_tray: false, show_qr: true, + theme: default_theme(), } } } @@ -47,6 +52,10 @@ fn default_true() -> bool { true } +fn default_theme() -> String { + "Default Dark".to_string() +} + /// Result of the first-run upstream measurement. /// /// `status = "unmeasured"` means we've never asked the user — show the diff --git a/src/gui/theme.rs b/src/gui/theme.rs new file mode 100644 index 0000000..6376657 --- /dev/null +++ b/src/gui/theme.rs @@ -0,0 +1,453 @@ +//! User-customisable colour themes for the GUI. +//! +//! A theme is a small, curated *semantic* palette — backgrounds, text, an +//! accent, and the handful of status colours the app uses (streaming, waiting, +//! success, warning, error). That's deliberately a fixed set rather than a +//! passthrough of every [`egui::Visuals`] field: it's easy to author by hand, +//! covers the whole look of the app, and stays stable across egui upgrades. +//! +//! Themes serialise to TOML with colours as `#rrggbb` hex strings. Three +//! themes ship built in; users drop their own `*.toml` files in +//! `~/.config/pixelpass/themes/` (or save one from the in-app editor) and they +//! show up alongside the built-ins. A user file whose `name` matches a built-in +//! overrides it. + +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use directories::ProjectDirs; +use eframe::egui::{self, Color32}; +use serde::{Deserialize, Serialize}; + +/// One colour theme: a curated semantic palette. +/// +/// `#[serde(default)]` on the container means any field missing from a TOML +/// file falls back to the corresponding field of [`Theme::default`] (the +/// built-in Default Dark), so a partial or hand-trimmed file still loads. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct Theme { + /// Display name, shown in the picker and used as the file stem on save. + pub name: String, + /// Base egui defaults to start from before applying the palette overrides. + pub dark: bool, + + // ── chrome ──────────────────────────────────────────────────────── + /// Window background. + #[serde(with = "hex")] + pub window_bg: Color32, + /// Panel / frame background. + #[serde(with = "hex")] + pub panel_bg: Color32, + /// Text-input and read-only field background (the ticket box, etc.). + #[serde(with = "hex")] + pub input_bg: Color32, + /// Primary text. + #[serde(with = "hex")] + pub text: Color32, + /// Secondary / de-emphasised text (hints, the version line). + #[serde(with = "hex")] + pub weak_text: Color32, + /// Accent: selection, hyperlinks, and the active/pressed widget fill. + #[serde(with = "hex")] + pub accent: Color32, + /// Button (and other interactive widget) resting background. + #[serde(with = "hex")] + pub button_bg: Color32, + /// Button background on hover. + #[serde(with = "hex")] + pub button_hovered: Color32, + + // ── semantic status colours ─────────────────────────────────────── + /// "● Streaming" indicator. + #[serde(with = "hex")] + pub streaming: Color32, + /// "● Waiting for viewers…" indicator. + #[serde(with = "hex")] + pub waiting: Color32, + /// Success notes, e.g. "✓ Copied to clipboard". + #[serde(with = "hex")] + pub success: Color32, + /// Non-fatal warnings, e.g. a host-full refusal. + #[serde(with = "hex")] + pub warning: Color32, + /// Errors. + #[serde(with = "hex")] + pub error: Color32, +} + +impl Default for Theme { + fn default() -> Self { + default_dark() + } +} + +impl Theme { + /// Build the egui [`Visuals`](egui::Visuals) this theme describes. Starts + /// from egui's dark or light defaults (so anything the palette doesn't name + /// stays sensible) and overrides the curated fields. + pub fn visuals(&self) -> egui::Visuals { + use egui::{Stroke, Visuals}; + + let mut v = if self.dark { + Visuals::dark() + } else { + Visuals::light() + }; + v.dark_mode = self.dark; + + v.window_fill = self.window_bg; + v.panel_fill = self.panel_bg; + v.faint_bg_color = self.panel_bg; + v.extreme_bg_color = self.input_bg; + v.override_text_color = Some(self.text); + v.hyperlink_color = self.accent; + v.error_fg_color = self.error; + v.warn_fg_color = self.warning; + + // A translucent accent reads well as a selection highlight on either a + // light or dark base. + v.selection.bg_fill = + Color32::from_rgba_unmultiplied(self.accent.r(), self.accent.g(), self.accent.b(), 96); + v.selection.stroke = Stroke::new(1.0, self.accent); + + let text_stroke = Stroke::new(1.0, self.text); + let weak_stroke = Stroke::new(1.0, self.weak_text); + + v.widgets.noninteractive.bg_fill = self.panel_bg; + v.widgets.noninteractive.weak_bg_fill = self.panel_bg; + v.widgets.noninteractive.fg_stroke = weak_stroke; + + v.widgets.inactive.bg_fill = self.button_bg; + v.widgets.inactive.weak_bg_fill = self.button_bg; + v.widgets.inactive.fg_stroke = text_stroke; + + v.widgets.hovered.bg_fill = self.button_hovered; + v.widgets.hovered.weak_bg_fill = self.button_hovered; + v.widgets.hovered.fg_stroke = text_stroke; + + v.widgets.active.bg_fill = self.accent; + v.widgets.active.weak_bg_fill = self.accent; + v.widgets.active.fg_stroke = text_stroke; + + v + } +} + +// ── built-in themes ─────────────────────────────────────────────────────── + +/// Names of the built-in themes, in picker order. +pub const BUILTIN_NAMES: [&str; 3] = ["Default Dark", "Catppuccin Mocha", "Catppuccin Latte"]; + +/// Parse a built-in's hex literal, panicking on a typo (these are compile-time +/// constants we control, so a bad value is a bug, not user input). +fn c(hex: &str) -> Color32 { + parse_hex(hex).expect("built-in theme hex is valid") +} + +/// The default theme — a neutral dark palette. Also [`Theme::default`]. +pub fn default_dark() -> Theme { + Theme { + name: "Default Dark".to_string(), + dark: true, + window_bg: c("#1b1b1f"), + panel_bg: c("#242429"), + input_bg: c("#141417"), + text: c("#e6e6ea"), + weak_text: c("#a0a0a8"), + accent: c("#5aa0f2"), + button_bg: c("#33333a"), + button_hovered: c("#44444d"), + streaming: c("#6fdc8c"), + waiting: c("#f2c14e"), + success: c("#6fdc8c"), + warning: c("#f0a85a"), + error: c("#f2756f"), + } +} + +/// Catppuccin Mocha (dark). +fn catppuccin_mocha() -> Theme { + Theme { + name: "Catppuccin Mocha".to_string(), + dark: true, + window_bg: c("#1e1e2e"), + panel_bg: c("#181825"), + input_bg: c("#11111b"), + text: c("#cdd6f4"), + weak_text: c("#a6adc8"), + accent: c("#cba6f7"), + button_bg: c("#313244"), + button_hovered: c("#45475a"), + streaming: c("#a6e3a1"), + waiting: c("#f9e2af"), + success: c("#a6e3a1"), + warning: c("#fab387"), + error: c("#f38ba8"), + } +} + +/// Catppuccin Latte (light). +fn catppuccin_latte() -> Theme { + Theme { + name: "Catppuccin Latte".to_string(), + dark: false, + window_bg: c("#eff1f5"), + panel_bg: c("#e6e9ef"), + input_bg: c("#dce0e8"), + text: c("#4c4f69"), + weak_text: c("#6c6f85"), + accent: c("#8839ef"), + button_bg: c("#ccd0da"), + button_hovered: c("#bcc0cc"), + streaming: c("#40a02b"), + waiting: c("#df8e1d"), + success: c("#40a02b"), + warning: c("#fe640b"), + error: c("#d20f39"), + } +} + +/// The built-in themes, in [`BUILTIN_NAMES`] order. +pub fn builtins() -> Vec { + vec![default_dark(), catppuccin_mocha(), catppuccin_latte()] +} + +/// Whether `name` is one of the built-ins (which are read-only — the editor +/// nudges you to save under a new name). +pub fn is_builtin(name: &str) -> bool { + BUILTIN_NAMES.contains(&name) +} + +// ── on-disk themes ────────────────────────────────────────────────────────── + +/// `~/.config/pixelpass/themes/` (or the XDG equivalent). Not created until a +/// theme is saved. +pub fn themes_dir() -> Result { + let dirs = ProjectDirs::from("", "", "pixelpass") + .context("could not locate a config directory for pixelpass")?; + Ok(dirs.config_dir().join("themes")) +} + +/// Parse every `*.toml` in the themes dir into a [`Theme`]. A file that fails +/// to parse is logged and skipped rather than aborting the whole list, so one +/// bad file can't hide the rest. Returns themes sorted by name. +pub fn list_user_themes() -> Vec { + let Ok(dir) = themes_dir() else { + return Vec::new(); + }; + let Ok(entries) = std::fs::read_dir(&dir) else { + return Vec::new(); // dir doesn't exist yet → no user themes + }; + + let mut out = Vec::new(); + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("toml") { + continue; + } + match std::fs::read_to_string(&path) { + Ok(s) => match toml::from_str::(&s) { + Ok(mut t) => { + // Fall back to the file stem if the file omits a name. + if t.name.trim().is_empty() { + t.name = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("Unnamed") + .to_string(); + } + out.push(t); + } + Err(e) => tracing::warn!("skipping theme {}: {e}", path.display()), + }, + Err(e) => tracing::warn!("could not read theme {}: {e}", path.display()), + } + } + out.sort_by_key(|t| t.name.to_lowercase()); + out +} + +/// Built-ins plus user themes, in picker order: built-ins first (a user file +/// with a matching `name` overrides the built-in's colours in place), then any +/// remaining user themes alphabetically. +pub fn all_themes() -> Vec { + let users = list_user_themes(); + let mut out: Vec = builtins() + .into_iter() + .map(|b| { + users + .iter() + .find(|u| u.name == b.name) + .cloned() + .unwrap_or(b) + }) + .collect(); + for u in users { + if !is_builtin(&u.name) { + out.push(u); + } + } + out +} + +/// The theme with this `name`, or Default Dark if it can't be found (e.g. the +/// config names a theme whose file was deleted). +pub fn load_named(name: &str) -> Theme { + all_themes() + .into_iter() + .find(|t| t.name == name) + .unwrap_or_else(default_dark) +} + +/// Write `theme` to `/.toml` and return the path. Overwrites +/// an existing file with the same slug (i.e. saving a tweaked theme under the +/// same name updates it in place). +pub fn save_theme(theme: &Theme) -> Result { + let dir = themes_dir()?; + std::fs::create_dir_all(&dir).with_context(|| format!("failed to create {}", dir.display()))?; + + let slug = slugify(&theme.name); + let path = dir.join(format!("{slug}.toml")); + let body = toml::to_string_pretty(theme).context("failed to serialise theme to TOML")?; + let contents = format!( + "# PixelPass theme. Colours are #rrggbb hex strings.\n\ + # Edit and re-pick it in Settings, or drop more .toml files in this folder.\n\n\ + {body}" + ); + std::fs::write(&path, contents) + .with_context(|| format!("failed to write {}", path.display()))?; + Ok(path) +} + +/// Lowercase, replace runs of non-alphanumerics with a single hyphen, trim +/// hyphens. Empty input becomes `theme`. +fn slugify(name: &str) -> String { + let mut slug = String::new(); + let mut prev_hyphen = false; + for ch in name.trim().chars() { + if ch.is_ascii_alphanumeric() { + slug.push(ch.to_ascii_lowercase()); + prev_hyphen = false; + } else if !prev_hyphen { + slug.push('-'); + prev_hyphen = true; + } + } + let slug = slug.trim_matches('-').to_string(); + if slug.is_empty() { + "theme".to_string() + } else { + slug + } +} + +// ── hex colour parsing ──────────────────────────────────────────────────── + +/// Parse `#rrggbb` into an opaque [`Color32`] (the leading `#` is optional). +/// An 8-digit `#rrggbbaa` is accepted leniently but its alpha is ignored — +/// theme colours are opaque, and `Color32`'s premultiplied storage can't +/// round-trip a straight alpha losslessly anyway. Returns `None` on malformed +/// input. +pub fn parse_hex(s: &str) -> Option { + let s = s.trim(); + let s = s.strip_prefix('#').unwrap_or(s); + if !matches!(s.len(), 6 | 8) || !s.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } + let byte = |i: usize| u8::from_str_radix(&s[i..i + 2], 16).ok(); + Some(Color32::from_rgb(byte(0)?, byte(2)?, byte(4)?)) +} + +/// Format a [`Color32`] as opaque `#rrggbb`. +pub fn to_hex(c: Color32) -> String { + let [r, g, b, _] = c.to_srgba_unmultiplied(); + format!("#{r:02x}{g:02x}{b:02x}") +} + +/// serde adaptor so `Color32` fields round-trip as hex strings in TOML. +mod hex { + use super::{parse_hex, to_hex}; + use eframe::egui::Color32; + use serde::{Deserialize, Deserializer, Serializer, de::Error}; + + pub fn serialize(c: &Color32, s: S) -> Result { + s.serialize_str(&to_hex(*c)) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result { + let s = String::deserialize(d)?; + parse_hex(&s).ok_or_else(|| { + D::Error::custom(format!( + "invalid hex colour {s:?} (expected #rrggbb or #rrggbbaa)" + )) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hex_round_trips() { + for (input, expect) in [ + ("#1e1e2e", Color32::from_rgb(0x1e, 0x1e, 0x2e)), + ("aabbcc", Color32::from_rgb(0xaa, 0xbb, 0xcc)), + // 8-digit is accepted but the alpha is dropped (opaque rgb). + ("#11223344", Color32::from_rgb(0x11, 0x22, 0x33)), + ] { + assert_eq!(parse_hex(input).expect("parses"), expect); + } + assert_eq!(to_hex(Color32::from_rgb(0x1e, 0x1e, 0x2e)), "#1e1e2e"); + // Opaque colours round-trip exactly. + let c = Color32::from_rgb(0xab, 0xcd, 0xef); + assert_eq!(parse_hex(&to_hex(c)), Some(c)); + } + + #[test] + fn hex_rejects_garbage() { + for bad in ["", "#fff", "#12345", "nothex", "#gggggg", "#1234567"] { + assert!(parse_hex(bad).is_none(), "{bad:?} should not parse"); + } + } + + #[test] + fn theme_toml_round_trips() { + let original = catppuccin_mocha(); + let toml = toml::to_string_pretty(&original).unwrap(); + let parsed: Theme = toml::from_str(&toml).unwrap(); + assert_eq!(original, parsed); + // Colours serialise as hex strings, not RGBA tables. + assert!(toml.contains("window_bg = \"#1e1e2e\""), "{toml}"); + } + + #[test] + fn partial_toml_fills_from_default() { + // Only a name and one colour; everything else must fall back to Default Dark. + let parsed: Theme = toml::from_str("name = \"Partial\"\naccent = \"#ff0000\"").unwrap(); + let base = default_dark(); + assert_eq!(parsed.name, "Partial"); + assert_eq!(parsed.accent, Color32::from_rgb(0xff, 0, 0)); + assert_eq!(parsed.window_bg, base.window_bg); // filled from default + assert_eq!(parsed.text, base.text); + } + + #[test] + fn slugify_is_filesystem_safe() { + assert_eq!(slugify("Catppuccin Mocha"), "catppuccin-mocha"); + assert_eq!(slugify(" My Theme!! "), "my-theme"); + assert_eq!(slugify("***"), "theme"); + assert_eq!(slugify("Solarized/Dark"), "solarized-dark"); + } + + #[test] + fn builtins_match_names() { + let names: Vec = builtins().iter().map(|t| t.name.clone()).collect(); + let expected: Vec = BUILTIN_NAMES.iter().map(|s| s.to_string()).collect(); + assert_eq!(names, expected); + for t in builtins() { + assert!(is_builtin(&t.name)); + } + } +}