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 <noreply@anthropic.com>
This commit is contained in:
+237
-11
@@ -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<String>) -> 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<String>) -> 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<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.
|
||||
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 {
|
||||
/// 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<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();
|
||||
}
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user