Compare commits
5
Commits
7e470fb2c5
...
ccd8c33f81
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ccd8c33f81 | ||
|
|
c876c61ec6 | ||
|
|
b1d73caedf | ||
|
|
40960c7476 | ||
|
|
0a4bb554e9 |
@@ -242,6 +242,46 @@ since the GUI's child host/viewer processes inherit it; the `--gui --relay`
|
|||||||
flag form is forwarded to them too. Both ends must use the same relay to
|
flag form is forwarded to them too. Both ends must use the same relay to
|
||||||
find each other.
|
find each other.
|
||||||
|
|
||||||
|
## Themes
|
||||||
|
|
||||||
|
The `--gui` front-end ships three colour themes — **Default Dark**,
|
||||||
|
**Catppuccin Mocha**, and **Catppuccin Latte** — and you can add your own.
|
||||||
|
Pick one under **Settings → Appearance**; the choice is remembered.
|
||||||
|
|
||||||
|
A theme is a small TOML file of named colours:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
name = "My Theme"
|
||||||
|
dark = true # base egui defaults to start from (dark or light)
|
||||||
|
|
||||||
|
window_bg = "#1b1b1f" # window background
|
||||||
|
panel_bg = "#242429" # panels / frames
|
||||||
|
input_bg = "#141417" # text fields, the ticket box
|
||||||
|
text = "#e6e6ea" # primary text
|
||||||
|
weak_text = "#a0a0a8" # hints, secondary text
|
||||||
|
accent = "#5aa0f2" # selection, links, the active control
|
||||||
|
button_bg = "#33333a" # buttons at rest
|
||||||
|
button_hovered = "#44444d"
|
||||||
|
streaming = "#6fdc8c" # "● Streaming"
|
||||||
|
waiting = "#f2c14e" # "● Waiting for viewers…"
|
||||||
|
success = "#6fdc8c" # "✓ Copied", valid-code confirmation
|
||||||
|
warning = "#f0a85a" # non-fatal warnings
|
||||||
|
error = "#f2756f" # errors
|
||||||
|
```
|
||||||
|
|
||||||
|
Colours are `#rrggbb` hex strings. Any field you leave out falls back to
|
||||||
|
Default Dark, so partial files are fine.
|
||||||
|
|
||||||
|
Two ways to make one:
|
||||||
|
|
||||||
|
- **In the app:** Settings → Appearance → *Edit / create a theme* gives you a
|
||||||
|
colour picker per field with a live preview, and **Save** writes a `.toml`.
|
||||||
|
- **By hand:** drop a `.toml` into `~/.config/pixelpass/themes/` (the XDG
|
||||||
|
config dir). It appears in the picker next time you open Settings.
|
||||||
|
|
||||||
|
Sharing a theme is just sending someone the file. A user theme whose `name`
|
||||||
|
matches a built-in overrides that built-in.
|
||||||
|
|
||||||
## Audio
|
## Audio
|
||||||
|
|
||||||
By default pixelpass captures the default sink's monitor — the viewer
|
By default pixelpass captures the default sink's monitor — the viewer
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ pub struct GuiSettings {
|
|||||||
/// text-only host screen.
|
/// text-only host screen.
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
pub show_qr: bool,
|
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 {
|
impl Default for GuiSettings {
|
||||||
@@ -39,6 +43,7 @@ impl Default for GuiSettings {
|
|||||||
Self {
|
Self {
|
||||||
close_to_tray: false,
|
close_to_tray: false,
|
||||||
show_qr: true,
|
show_qr: true,
|
||||||
|
theme: default_theme(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -47,6 +52,10 @@ fn default_true() -> bool {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_theme() -> String {
|
||||||
|
"Default Dark".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
/// Result of the first-run upstream measurement.
|
/// Result of the first-run upstream measurement.
|
||||||
///
|
///
|
||||||
/// `status = "unmeasured"` means we've never asked the user — show the
|
/// `status = "unmeasured"` means we've never asked the user — show the
|
||||||
|
|||||||
+262
-11
@@ -37,6 +37,7 @@
|
|||||||
//! dropped and no egui frame is running.
|
//! dropped and no egui frame is running.
|
||||||
|
|
||||||
mod child;
|
mod child;
|
||||||
|
mod theme;
|
||||||
mod tray;
|
mod tray;
|
||||||
|
|
||||||
use std::num::NonZeroU32;
|
use std::num::NonZeroU32;
|
||||||
@@ -604,6 +605,10 @@ pub fn run(relay: Option<String>) -> anyhow::Result<()> {
|
|||||||
.map(|c| c.gui)
|
.map(|c| c.gui)
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let active = theme::load_named(&gui_settings.theme);
|
||||||
|
let names = theme::all_themes().into_iter().map(|t| t.name).collect();
|
||||||
|
let draft = active.clone();
|
||||||
|
|
||||||
let state = PixelPassApp {
|
let state = PixelPassApp {
|
||||||
screen: Screen::default(),
|
screen: Screen::default(),
|
||||||
host: HostState::default(),
|
host: HostState::default(),
|
||||||
@@ -612,6 +617,14 @@ pub fn run(relay: Option<String>) -> anyhow::Result<()> {
|
|||||||
close_to_tray: gui_settings.close_to_tray,
|
close_to_tray: gui_settings.close_to_tray,
|
||||||
show_qr: gui_settings.show_qr,
|
show_qr: gui_settings.show_qr,
|
||||||
relay,
|
relay,
|
||||||
|
theme: ThemeState {
|
||||||
|
active,
|
||||||
|
names,
|
||||||
|
dirty: true, // apply on the first frame
|
||||||
|
editing: false,
|
||||||
|
draft,
|
||||||
|
status: None,
|
||||||
|
},
|
||||||
waker,
|
waker,
|
||||||
};
|
};
|
||||||
let mut app = App {
|
let mut app = App {
|
||||||
@@ -705,6 +718,14 @@ fn persist_show_qr(value: bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn persist_theme(name: &str) {
|
||||||
|
let mut cfg = crate::common::config::load().unwrap_or_default();
|
||||||
|
cfg.gui.theme = name.to_string();
|
||||||
|
if let Err(e) = crate::common::config::save(&cfg) {
|
||||||
|
tracing::warn!("failed to save settings: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Which screen the single window is currently showing.
|
/// Which screen the single window is currently showing.
|
||||||
#[derive(Default, PartialEq)]
|
#[derive(Default, PartialEq)]
|
||||||
enum Screen {
|
enum Screen {
|
||||||
@@ -853,10 +874,49 @@ struct PixelPassApp {
|
|||||||
/// The parent's `--relay` flag, forwarded to host/viewer children so the
|
/// The parent's `--relay` flag, forwarded to host/viewer children so the
|
||||||
/// flag form reaches them (env-var form is inherited automatically).
|
/// flag form reaches them (env-var form is inherited automatically).
|
||||||
relay: Option<String>,
|
relay: Option<String>,
|
||||||
|
/// The active colour theme (applied to egui's visuals) and the supporting
|
||||||
|
/// picker/editor state.
|
||||||
|
theme: ThemeState,
|
||||||
/// Wakes the winit loop when a spawned child emits/exits.
|
/// Wakes the winit loop when a spawned child emits/exits.
|
||||||
waker: Waker,
|
waker: Waker,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The active theme plus the Settings picker/editor working state.
|
||||||
|
struct ThemeState {
|
||||||
|
/// Currently applied theme (persisted by name in the config).
|
||||||
|
active: theme::Theme,
|
||||||
|
/// Theme names shown in the picker, refreshed when Settings opens so files
|
||||||
|
/// added on disk appear without a restart.
|
||||||
|
names: Vec<String>,
|
||||||
|
/// Set when `active` needs (re)applying to egui's visuals.
|
||||||
|
dirty: bool,
|
||||||
|
/// Whether the in-app editor is open. While open its `draft` is applied as a
|
||||||
|
/// live preview instead of `active`; closing without saving restores `active`.
|
||||||
|
editing: bool,
|
||||||
|
/// The editor's working copy.
|
||||||
|
draft: theme::Theme,
|
||||||
|
/// Last save result or error, shown under the editor.
|
||||||
|
status: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply `theme`'s palette to egui's visuals, preserving the font scaling and
|
||||||
|
/// fonts already baked into the global style (we replace only `visuals`).
|
||||||
|
fn apply_theme(ctx: &egui::Context, theme: &theme::Theme) {
|
||||||
|
let mut style = (*ctx.global_style()).clone();
|
||||||
|
style.visuals = theme.visuals();
|
||||||
|
ctx.set_global_style(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One row of the theme editor: a label and a colour picker. Theme colours are
|
||||||
|
/// opaque, so any alpha the picker introduces is clamped straight back out.
|
||||||
|
fn color_row(ui: &mut egui::Ui, label: &str, color: &mut egui::Color32) {
|
||||||
|
ui.label(label);
|
||||||
|
ui.color_edit_button_srgba(color);
|
||||||
|
let [r, g, b, _] = color.to_srgba_unmultiplied();
|
||||||
|
*color = egui::Color32::from_rgb(r, g, b);
|
||||||
|
ui.end_row();
|
||||||
|
}
|
||||||
|
|
||||||
impl PixelPassApp {
|
impl PixelPassApp {
|
||||||
/// Drain child output and reflect it into the tray. Runs on every wake,
|
/// Drain child output and reflect it into the tray. Runs on every wake,
|
||||||
/// whether or not a window is shown, so notifications and the tray tooltip
|
/// whether or not a window is shown, so notifications and the tray tooltip
|
||||||
@@ -869,6 +929,27 @@ impl PixelPassApp {
|
|||||||
|
|
||||||
/// Render the current screen. Called from inside the egui frame.
|
/// Render the current screen. Called from inside the egui frame.
|
||||||
fn draw(&mut self, ui: &mut egui::Ui) {
|
fn draw(&mut self, ui: &mut egui::Ui) {
|
||||||
|
// Apply whichever theme should be visible this frame. While the editor
|
||||||
|
// is open its draft is previewed live; otherwise the active theme is
|
||||||
|
// applied once (when dirty) and then sticks — the egui context persists
|
||||||
|
// across the hide/show window cycle, so it survives close-to-tray.
|
||||||
|
if self.theme.editing {
|
||||||
|
apply_theme(ui.ctx(), &self.theme.draft);
|
||||||
|
} else if self.theme.dirty {
|
||||||
|
apply_theme(ui.ctx(), &self.theme.active);
|
||||||
|
self.theme.dirty = false;
|
||||||
|
}
|
||||||
|
// Paint the themed window background behind everything. The app draws on
|
||||||
|
// egui's bare background layer with no panel, so `window_fill` is never
|
||||||
|
// shown — without this the only backdrop is the GL clear colour, which
|
||||||
|
// the theme can't reach. Painted first, so it sits behind the widgets.
|
||||||
|
let bg = if self.theme.editing {
|
||||||
|
self.theme.draft.window_bg
|
||||||
|
} else {
|
||||||
|
self.theme.active.window_bg
|
||||||
|
};
|
||||||
|
ui.painter()
|
||||||
|
.rect_filled(ui.ctx().content_rect(), egui::CornerRadius::ZERO, bg);
|
||||||
match self.screen {
|
match self.screen {
|
||||||
Screen::Menu => self.menu(ui),
|
Screen::Menu => self.menu(ui),
|
||||||
Screen::Host => self.host(ui),
|
Screen::Host => self.host(ui),
|
||||||
@@ -924,6 +1005,9 @@ impl PixelPassApp {
|
|||||||
}
|
}
|
||||||
ui.add_space(20.0);
|
ui.add_space(20.0);
|
||||||
if ui.button("⚙ Settings").clicked() {
|
if ui.button("⚙ Settings").clicked() {
|
||||||
|
// Refresh the picker so themes added to the folder since launch
|
||||||
|
// (or last visit) show up without a restart.
|
||||||
|
self.theme.names = theme::all_themes().into_iter().map(|t| t.name).collect();
|
||||||
self.screen = Screen::Settings;
|
self.screen = Screen::Settings;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -937,6 +1021,13 @@ impl PixelPassApp {
|
|||||||
ui.heading("Settings");
|
ui.heading("Settings");
|
||||||
});
|
});
|
||||||
ui.separator();
|
ui.separator();
|
||||||
|
// Body scrolls; the header above stays pinned. The Appearance editor
|
||||||
|
// (13 colour rows + Save) overflows a short window otherwise — you'd
|
||||||
|
// have to resize the window to reach the Save button.
|
||||||
|
egui::ScrollArea::vertical().show(ui, |ui| self.settings_body(ui));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn settings_body(&mut self, ui: &mut egui::Ui) {
|
||||||
ui.add_space(4.0);
|
ui.add_space(4.0);
|
||||||
|
|
||||||
let resp = ui.checkbox(
|
let resp = ui.checkbox(
|
||||||
@@ -977,10 +1068,170 @@ impl PixelPassApp {
|
|||||||
if !self.tray.as_ref().is_some_and(TrayHandle::registered) {
|
if !self.tray.as_ref().is_some_and(TrayHandle::registered) {
|
||||||
ui.add_space(8.0);
|
ui.add_space(8.0);
|
||||||
ui.colored_label(
|
ui.colored_label(
|
||||||
egui::Color32::from_rgb(220, 160, 60),
|
self.theme.active.warning,
|
||||||
"⚠ No system tray detected — this option has no effect right now.",
|
"⚠ No system tray detected — this option has no effect right now.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ui.add_space(16.0);
|
||||||
|
ui.separator();
|
||||||
|
ui.add_space(4.0);
|
||||||
|
self.appearance(ui);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The "Appearance" block of the Settings screen: the theme picker, or the
|
||||||
|
/// in-app editor when it's open.
|
||||||
|
fn appearance(&mut self, ui: &mut egui::Ui) {
|
||||||
|
ui.heading("Appearance");
|
||||||
|
ui.add_space(6.0);
|
||||||
|
|
||||||
|
if self.theme.editing {
|
||||||
|
self.theme_editor(ui);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Theme picker. Collect any selection first so we're not holding an
|
||||||
|
// immutable borrow of `self.theme.names` when we mutate `self.theme`.
|
||||||
|
let current = self.theme.active.name.clone();
|
||||||
|
let mut pick: Option<String> = None;
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Theme");
|
||||||
|
egui::ComboBox::from_id_salt("theme_picker")
|
||||||
|
.selected_text(¤t)
|
||||||
|
.show_ui(ui, |ui| {
|
||||||
|
for name in &self.theme.names {
|
||||||
|
if ui.selectable_label(*name == current, name).clicked() {
|
||||||
|
pick = Some(name.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if let Some(name) = pick {
|
||||||
|
self.select_theme(&name);
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.add_space(8.0);
|
||||||
|
if ui.button("✎ Edit / create a theme").clicked() {
|
||||||
|
self.start_theme_edit();
|
||||||
|
}
|
||||||
|
ui.add_space(4.0);
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(
|
||||||
|
"Themes live as .toml files in your config folder \
|
||||||
|
(themes/). Drop one in to share or install it.",
|
||||||
|
)
|
||||||
|
.small()
|
||||||
|
.weak(),
|
||||||
|
);
|
||||||
|
if let Some(status) = &self.theme.status {
|
||||||
|
ui.add_space(4.0);
|
||||||
|
ui.label(egui::RichText::new(status).small().weak());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The in-app theme editor: a colour picker per palette field with a live
|
||||||
|
/// preview, plus Save (writes a .toml) / Cancel.
|
||||||
|
fn theme_editor(&mut self, ui: &mut egui::Ui) {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Name");
|
||||||
|
ui.text_edit_singleline(&mut self.theme.draft.name);
|
||||||
|
});
|
||||||
|
ui.checkbox(
|
||||||
|
&mut self.theme.draft.dark,
|
||||||
|
"Dark base (sets the fallback for anything the palette doesn't name)",
|
||||||
|
);
|
||||||
|
ui.add_space(6.0);
|
||||||
|
|
||||||
|
egui::Grid::new("theme_editor_grid")
|
||||||
|
.num_columns(2)
|
||||||
|
.spacing([12.0, 6.0])
|
||||||
|
.show(ui, |ui| {
|
||||||
|
let d = &mut self.theme.draft;
|
||||||
|
color_row(ui, "Window background", &mut d.window_bg);
|
||||||
|
color_row(ui, "Panel background", &mut d.panel_bg);
|
||||||
|
color_row(ui, "Input background", &mut d.input_bg);
|
||||||
|
color_row(ui, "Text", &mut d.text);
|
||||||
|
color_row(ui, "Secondary text", &mut d.weak_text);
|
||||||
|
color_row(ui, "Accent", &mut d.accent);
|
||||||
|
color_row(ui, "Button", &mut d.button_bg);
|
||||||
|
color_row(ui, "Button (hover)", &mut d.button_hovered);
|
||||||
|
color_row(ui, "Streaming", &mut d.streaming);
|
||||||
|
color_row(ui, "Waiting", &mut d.waiting);
|
||||||
|
color_row(ui, "Success", &mut d.success);
|
||||||
|
color_row(ui, "Warning", &mut d.warning);
|
||||||
|
color_row(ui, "Error", &mut d.error);
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.add_space(10.0);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if ui.button("💾 Save").clicked() {
|
||||||
|
self.save_draft_theme();
|
||||||
|
}
|
||||||
|
// Reset the draft to the original Default Dark palette. Previews
|
||||||
|
// live (the editor applies the draft each frame), so it snaps back
|
||||||
|
// immediately; Save persists it, Cancel discards.
|
||||||
|
if ui.button("↺ Defaults").clicked() {
|
||||||
|
self.theme.draft = theme::default_dark();
|
||||||
|
self.theme.status = Some("Reset to the Default Dark palette.".to_string());
|
||||||
|
}
|
||||||
|
if ui.button("Cancel").clicked() {
|
||||||
|
self.cancel_theme_edit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ui.add_space(4.0);
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(
|
||||||
|
"Changes preview live. Save writes a .toml to your themes folder; \
|
||||||
|
rename it to keep a built-in alongside your own version.",
|
||||||
|
)
|
||||||
|
.small()
|
||||||
|
.weak(),
|
||||||
|
);
|
||||||
|
if let Some(status) = &self.theme.status {
|
||||||
|
ui.add_space(4.0);
|
||||||
|
ui.label(egui::RichText::new(status).small().weak());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Switch to the named theme: apply it, persist the choice, and reset the
|
||||||
|
/// editor draft to match.
|
||||||
|
fn select_theme(&mut self, name: &str) {
|
||||||
|
self.theme.active = theme::load_named(name);
|
||||||
|
self.theme.draft = self.theme.active.clone();
|
||||||
|
self.theme.dirty = true;
|
||||||
|
self.theme.status = None;
|
||||||
|
persist_theme(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_theme_edit(&mut self) {
|
||||||
|
self.theme.draft = self.theme.active.clone();
|
||||||
|
self.theme.editing = true;
|
||||||
|
self.theme.status = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cancel_theme_edit(&mut self) {
|
||||||
|
self.theme.editing = false;
|
||||||
|
self.theme.draft = self.theme.active.clone();
|
||||||
|
self.theme.dirty = true; // discard the live preview, restore the active theme
|
||||||
|
self.theme.status = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_draft_theme(&mut self) {
|
||||||
|
if self.theme.draft.name.trim().is_empty() {
|
||||||
|
self.theme.status = Some("Give the theme a name before saving.".to_string());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match theme::save_theme(&self.theme.draft) {
|
||||||
|
Ok(path) => {
|
||||||
|
self.theme.active = self.theme.draft.clone();
|
||||||
|
self.theme.editing = false;
|
||||||
|
self.theme.dirty = true;
|
||||||
|
self.theme.names = theme::all_themes().into_iter().map(|t| t.name).collect();
|
||||||
|
self.theme.status = Some(format!("Saved to {}", path.display()));
|
||||||
|
persist_theme(&self.theme.active.name);
|
||||||
|
}
|
||||||
|
Err(e) => self.theme.status = Some(format!("Couldn't save: {e}")),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Host screen ──────────────────────────────────────────────────────
|
// ── Host screen ──────────────────────────────────────────────────────
|
||||||
@@ -1011,7 +1262,7 @@ impl PixelPassApp {
|
|||||||
|
|
||||||
fn host_form(&mut self, ui: &mut egui::Ui) {
|
fn host_form(&mut self, ui: &mut egui::Ui) {
|
||||||
if let Some(err) = &self.host.error {
|
if let Some(err) = &self.host.error {
|
||||||
ui.colored_label(egui::Color32::LIGHT_RED, err);
|
ui.colored_label(self.theme.active.error, err);
|
||||||
ui.add_space(8.0);
|
ui.add_space(8.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1069,9 +1320,9 @@ impl PixelPassApp {
|
|||||||
|
|
||||||
fn host_running(&mut self, ui: &mut egui::Ui) {
|
fn host_running(&mut self, ui: &mut egui::Ui) {
|
||||||
if self.host.capturing {
|
if self.host.capturing {
|
||||||
ui.colored_label(egui::Color32::LIGHT_GREEN, "● Streaming");
|
ui.colored_label(self.theme.active.streaming, "● Streaming");
|
||||||
} else if self.host.ticket.is_some() {
|
} else if self.host.ticket.is_some() {
|
||||||
ui.colored_label(egui::Color32::YELLOW, "● Waiting for viewers…");
|
ui.colored_label(self.theme.active.waiting, "● Waiting for viewers…");
|
||||||
} else {
|
} else {
|
||||||
ui.label("Starting…");
|
ui.label("Starting…");
|
||||||
}
|
}
|
||||||
@@ -1146,7 +1397,7 @@ impl PixelPassApp {
|
|||||||
self.copy_to_clipboard(&ticket);
|
self.copy_to_clipboard(&ticket);
|
||||||
}
|
}
|
||||||
if self.host.copied {
|
if self.host.copied {
|
||||||
ui.colored_label(egui::Color32::LIGHT_GREEN, "✓ Copied to clipboard");
|
ui.colored_label(self.theme.active.success, "✓ Copied to clipboard");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if !self.host.copied {
|
if !self.host.copied {
|
||||||
@@ -1197,7 +1448,7 @@ impl PixelPassApp {
|
|||||||
|
|
||||||
if let Some(reason) = &self.host.last_refusal {
|
if let Some(reason) = &self.host.last_refusal {
|
||||||
ui.add_space(8.0);
|
ui.add_space(8.0);
|
||||||
ui.colored_label(egui::Color32::from_rgb(220, 160, 60), format!("⚠ {reason}"));
|
ui.colored_label(self.theme.active.warning, format!("⚠ {reason}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.add_space(16.0);
|
ui.add_space(16.0);
|
||||||
@@ -1387,7 +1638,7 @@ impl PixelPassApp {
|
|||||||
|
|
||||||
fn viewer_form(&mut self, ui: &mut egui::Ui) {
|
fn viewer_form(&mut self, ui: &mut egui::Ui) {
|
||||||
if let Some(err) = &self.viewer.error {
|
if let Some(err) = &self.viewer.error {
|
||||||
ui.colored_label(egui::Color32::LIGHT_RED, err);
|
ui.colored_label(self.theme.active.error, err);
|
||||||
ui.add_space(8.0);
|
ui.add_space(8.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1429,11 +1680,11 @@ impl PixelPassApp {
|
|||||||
ui.add_space(4.0);
|
ui.add_space(4.0);
|
||||||
match &decoded_id {
|
match &decoded_id {
|
||||||
Some(id) => ui.colored_label(
|
Some(id) => ui.colored_label(
|
||||||
egui::Color32::LIGHT_GREEN,
|
self.theme.active.success,
|
||||||
format!("→ endpoint {}", short_id(id)),
|
format!("→ endpoint {}", short_id(id)),
|
||||||
),
|
),
|
||||||
None => ui.colored_label(
|
None => ui.colored_label(
|
||||||
egui::Color32::from_rgb(220, 160, 60),
|
self.theme.active.warning,
|
||||||
"⚠ This doesn't look like a share code.",
|
"⚠ This doesn't look like a share code.",
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
@@ -1470,7 +1721,7 @@ impl PixelPassApp {
|
|||||||
|
|
||||||
fn viewer_running(&mut self, ui: &mut egui::Ui) {
|
fn viewer_running(&mut self, ui: &mut egui::Ui) {
|
||||||
if self.viewer.launched {
|
if self.viewer.launched {
|
||||||
ui.colored_label(egui::Color32::LIGHT_GREEN, "● Streaming");
|
ui.colored_label(self.theme.active.streaming, "● Streaming");
|
||||||
ui.label("Player launched. Close it or disconnect to stop.");
|
ui.label("Player launched. Close it or disconnect to stop.");
|
||||||
} else if self.viewer.url.is_some() {
|
} else if self.viewer.url.is_some() {
|
||||||
ui.label("Connected — launching player…");
|
ui.label("Connected — launching player…");
|
||||||
@@ -1479,7 +1730,7 @@ impl PixelPassApp {
|
|||||||
Some(id) => format!("● Connecting to {id}…"),
|
Some(id) => format!("● Connecting to {id}…"),
|
||||||
None => "● Connecting…".to_string(),
|
None => "● Connecting…".to_string(),
|
||||||
};
|
};
|
||||||
ui.colored_label(egui::Color32::YELLOW, msg);
|
ui.colored_label(self.theme.active.waiting, msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.add_space(16.0);
|
ui.add_space(16.0);
|
||||||
|
|||||||
@@ -0,0 +1,456 @@
|
|||||||
|
//! 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). <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