From 40960c7476da0506fbbf69cce8c47513fd8b54e4 Mon Sep 17 00:00:00 2001 From: Mollusk Date: Fri, 29 May 2026 02:51:03 -0400 Subject: [PATCH] feat(gui): apply themes live + theme picker and in-app editor Load the saved theme at startup and apply it to egui's visuals (cloning the global style so the font scaling is preserved); the egui context persists across the hide/show window cycle, so it sticks. Route the previously hardcoded status colours (streaming/waiting/success/warning/error) through the active theme so a theme re-skins the whole app, not just the chrome (the QR code stays black-on-white so it remains scannable). Settings gains an Appearance section: a picker that switches themes live and persists the choice, and an editor with a colour button per palette field, a live preview, and Save (writes a .toml). The picker refreshes from disk when Settings opens, so dropped-in files appear without a restart. Co-Authored-By: Claude Opus 4.8 --- src/gui/mod.rs | 248 ++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 237 insertions(+), 11 deletions(-) diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 7f646f5..eb7954e 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -37,6 +37,7 @@ //! dropped and no egui frame is running. mod child; +mod theme; mod tray; use std::num::NonZeroU32; @@ -604,6 +605,10 @@ pub fn run(relay: Option) -> anyhow::Result<()> { .map(|c| c.gui) .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 { screen: Screen::default(), host: HostState::default(), @@ -612,6 +617,14 @@ pub fn run(relay: Option) -> anyhow::Result<()> { close_to_tray: gui_settings.close_to_tray, show_qr: gui_settings.show_qr, relay, + theme: ThemeState { + active, + names, + dirty: true, // apply on the first frame + editing: false, + draft, + status: None, + }, waker, }; 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. #[derive(Default, PartialEq)] enum Screen { @@ -853,10 +874,49 @@ struct PixelPassApp { /// The parent's `--relay` flag, forwarded to host/viewer children so the /// flag form reaches them (env-var form is inherited automatically). relay: Option, + /// 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. 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, + /// 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, +} + +/// 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 { /// 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 @@ -869,6 +929,16 @@ impl PixelPassApp { /// Render the current screen. Called from inside the egui frame. 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; + } match self.screen { Screen::Menu => self.menu(ui), Screen::Host => self.host(ui), @@ -924,6 +994,9 @@ impl PixelPassApp { } ui.add_space(20.0); 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; } }); @@ -977,10 +1050,163 @@ impl PixelPassApp { if !self.tray.as_ref().is_some_and(TrayHandle::registered) { ui.add_space(8.0); 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.", ); } + + 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 = 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(); + } + 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 ────────────────────────────────────────────────────── @@ -1011,7 +1237,7 @@ impl PixelPassApp { fn host_form(&mut self, ui: &mut egui::Ui) { 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); } @@ -1069,9 +1295,9 @@ impl PixelPassApp { fn host_running(&mut self, ui: &mut egui::Ui) { 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() { - ui.colored_label(egui::Color32::YELLOW, "● Waiting for viewers…"); + ui.colored_label(self.theme.active.waiting, "● Waiting for viewers…"); } else { ui.label("Starting…"); } @@ -1146,7 +1372,7 @@ impl PixelPassApp { self.copy_to_clipboard(&ticket); } 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 { @@ -1197,7 +1423,7 @@ impl PixelPassApp { if let Some(reason) = &self.host.last_refusal { 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); @@ -1387,7 +1613,7 @@ impl PixelPassApp { fn viewer_form(&mut self, ui: &mut egui::Ui) { 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); } @@ -1429,11 +1655,11 @@ impl PixelPassApp { ui.add_space(4.0); match &decoded_id { Some(id) => ui.colored_label( - egui::Color32::LIGHT_GREEN, + self.theme.active.success, format!("→ endpoint {}", short_id(id)), ), None => ui.colored_label( - egui::Color32::from_rgb(220, 160, 60), + self.theme.active.warning, "⚠ This doesn't look like a share code.", ), }; @@ -1470,7 +1696,7 @@ impl PixelPassApp { fn viewer_running(&mut self, ui: &mut egui::Ui) { 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."); } else if self.viewer.url.is_some() { ui.label("Connected — launching player…"); @@ -1479,7 +1705,7 @@ impl PixelPassApp { Some(id) => format!("● Connecting to {id}…"), None => "● Connecting…".to_string(), }; - ui.colored_label(egui::Color32::YELLOW, msg); + ui.colored_label(self.theme.active.waiting, msg); } ui.add_space(16.0);