//! 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); // `.weak()` text resolves via `weak_text_color()`, which derives from // `text` unless this is set — so without it the weak-text field is dead. v.weak_text_color = Some(self.weak_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)); } } }