Desktop: global UI scale (egui zoom_factor), persisted

The card-density slider only resized the channel-grid cards — the
sidebar, settings, detail panel, and all text stayed fixed. Add a real
global scale that grows/shrinks the *entire* UI crisply.

egui already handles Ctrl + / Ctrl - / Ctrl 0 via zoom_factor (it
re-rasterizes fonts rather than bitmap-stretching), but it wasn't
persisted and had no visible control. Now:

- New `[ui] ui_scale` config (default 1.0), applied with
  set_zoom_factor at startup.
- update() mirrors ctx.zoom_factor() back into config each frame and
  saves on a 500ms debounce, so both the slider and the built-in
  keyboard zoom persist across restarts without writing config.toml
  every frame of a drag.
- A "UI scale" slider (0.5–3.0×) + Reset button in the Settings screen,
  driving set_zoom_factor live.

The card-density "Size:" slider stays as a separate grid-density
preference (it composes with the global zoom). 95 tests pass; GUI
verified running with the per-frame zoom-sync path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Luna 2026-06-04 19:14:23 -07:00
parent fd9063b5cb
commit b8a742535b
2 changed files with 61 additions and 0 deletions

View file

@ -138,6 +138,10 @@ pub struct App {
plex_status: String, plex_status: String,
db: Database, db: Database,
card_density: f32, card_density: f32,
/// When set, the global UI zoom changed and config should be saved at
/// this instant (debounced so a slider drag / key-repeat doesn't write
/// config.toml every frame). Synced from `ctx.zoom_factor()` in update().
scale_save_at: Option<Instant>,
sort_mode: SortMode, sort_mode: SortMode,
watched: HashSet<String>, watched: HashSet<String>,
/// Per-video flag sets (bookmark/favourite/waiting/archive). Loaded from /// Per-video flag sets (bookmark/favourite/waiting/archive). Loaded from
@ -283,6 +287,10 @@ impl App {
}; };
theme::apply(&cc.egui_ctx, &config.ui.theme); theme::apply(&cc.egui_ctx, &config.ui.theme);
// Restore the persisted global UI zoom. egui's built-in Ctrl +/-/0
// also drives zoom_factor; update() keeps config.ui.ui_scale synced
// and persists changes so the size survives restarts.
cc.egui_ctx.set_zoom_factor(config.ui.ui_scale.clamp(0.5, 3.0));
let channels_root = config.backup.directory.clone(); let channels_root = config.backup.directory.clone();
let settings_dir = channels_root.display().to_string(); let settings_dir = channels_root.display().to_string();
@ -433,6 +441,7 @@ impl App {
plex_status: String::new(), plex_status: String::new(),
db, db,
card_density: 1.0, card_density: 1.0,
scale_save_at: None,
sort_mode: SortMode::Title, sort_mode: SortMode::Title,
watched, watched,
flags, flags,
@ -2450,6 +2459,29 @@ impl App {
}); });
ui.end_row(); ui.end_row();
ui.label("UI scale:");
ui.horizontal(|ui| {
// Drive zoom_factor directly; update() mirrors it
// back into config.ui.ui_scale and persists it.
let mut scale = ctx.zoom_factor();
let resp = ui.add(
egui::Slider::new(&mut scale, 0.5_f32..=3.0_f32)
.step_by(0.1)
.suffix("×"),
);
if resp.changed() {
ctx.set_zoom_factor(scale);
}
if ui.button("Reset").on_hover_text("Back to 1.0× (Ctrl+0)").clicked() {
ctx.set_zoom_factor(1.0);
}
ui.label(
egui::RichText::new("scales the whole UI · also Ctrl + / Ctrl - / Ctrl 0")
.small().weak(),
);
});
ui.end_row();
ui.label("Minimize to tray:"); ui.label("Minimize to tray:");
ui.checkbox(&mut self.config.ui.minimize_to_tray, "hide window on close instead of quitting") ui.checkbox(&mut self.config.ui.minimize_to_tray, "hide window on close instead of quitting")
.on_hover_text("Requires a working StatusNotifier host (KDE native, GNOME with AppIndicator extension). Ctrl+Q always quits."); .on_hover_text("Requires a working StatusNotifier host (KDE native, GNOME with AppIndicator extension). Ctrl+Q always quits.");
@ -3581,6 +3613,26 @@ impl App {
impl eframe::App for App { impl eframe::App for App {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// ── Global UI zoom sync + persistence ───────────────────────────
// egui's built-in Ctrl +/-/0 (and our Settings slider) mutate
// ctx.zoom_factor() directly. Mirror it into config and persist on
// a short debounce so the chosen scale survives restarts without
// writing config.toml on every frame of a slider drag / key repeat.
let zoom = ctx.zoom_factor();
if (zoom - self.config.ui.ui_scale).abs() > f32::EPSILON {
self.config.ui.ui_scale = zoom;
self.scale_save_at =
Some(Instant::now() + std::time::Duration::from_millis(500));
// Ensure the debounce fires even if the UI would otherwise idle.
ctx.request_repaint_after(std::time::Duration::from_millis(550));
}
if let Some(at) = self.scale_save_at {
if Instant::now() >= at {
let _ = self.config.save(&self.config_path);
self.scale_save_at = None;
}
}
// ── System-tray event drain ───────────────────────────────────── // ── System-tray event drain ─────────────────────────────────────
// Tray menu activations arrive on a background-thread channel. // Tray menu activations arrive on a background-thread channel.
// Drain them all each frame and translate into viewport commands. // Drain them all each frame and translate into viewport commands.

View file

@ -161,13 +161,22 @@ pub struct UiSection {
/// hidden window. /// hidden window.
#[serde(default)] #[serde(default)]
pub minimize_to_tray: bool, pub minimize_to_tray: bool,
/// Global egui zoom factor — scales the *entire* desktop UI (fonts,
/// spacing, widgets), not just the card grid. 1.0 = native. Persisted
/// here so the chosen size survives restarts and is kept in sync with
/// the built-in Ctrl + / Ctrl - / Ctrl 0 keyboard zoom.
#[serde(default = "default_ui_scale")]
pub ui_scale: f32,
} }
fn default_ui_scale() -> f32 { 1.0 }
impl Default for UiSection { impl Default for UiSection {
fn default() -> Self { fn default() -> Self {
Self { Self {
theme: default_theme(), theme: default_theme(),
minimize_to_tray: false, minimize_to_tray: false,
ui_scale: default_ui_scale(),
} }
} }
} }