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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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). <https://github.com/catppuccin/catppuccin>
|
||||
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). <https://github.com/catppuccin/catppuccin>
|
||||
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<Theme> {
|
||||
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<PathBuf> {
|
||||
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<Theme> {
|
||||
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::<Theme>(&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<Theme> {
|
||||
let users = list_user_themes();
|
||||
let mut out: Vec<Theme> = 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 `<themes_dir>/<slug>.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<PathBuf> {
|
||||
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<Color32> {
|
||||
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<S: Serializer>(c: &Color32, s: S) -> Result<S::Ok, S::Error> {
|
||||
s.serialize_str(&to_hex(*c))
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Color32, D::Error> {
|
||||
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<String> = builtins().iter().map(|t| t.name.clone()).collect();
|
||||
let expected: Vec<String> = BUILTIN_NAMES.iter().map(|s| s.to_string()).collect();
|
||||
assert_eq!(names, expected);
|
||||
for t in builtins() {
|
||||
assert!(is_builtin(&t.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user