diff --git a/crates/pstr-app/src/app.rs b/crates/pstr-app/src/app.rs index 9daac28..e083de9 100644 --- a/crates/pstr-app/src/app.rs +++ b/crates/pstr-app/src/app.rs @@ -715,6 +715,7 @@ impl App { let (engine, events) = Engine::new(runtime, dirs, cc.egui_ctx.clone())?; theme::install_fonts(&cc.egui_ctx); theme::apply(&cc.egui_ctx, engine.appearance()); + cc.egui_ctx.set_zoom_factor(engine.desktop_prefs().zoom()); // Paint from the catalog immediately; the network catches up. A library // that was crawled yesterday is on screen before the shares open. @@ -1829,7 +1830,10 @@ impl App { self.note(ctx, text, false); } } - Action::SetDesktopPrefs(prefs) => self.engine.set_desktop_prefs(prefs), + Action::SetDesktopPrefs(prefs) => { + ctx.set_zoom_factor(prefs.zoom()); + self.engine.set_desktop_prefs(prefs); + } Action::SetTitleWatched { key, watched } => { let Some(title) = self.library.get(&key) else { return; @@ -2101,6 +2105,14 @@ impl eframe::App for App { let ctx = ui.ctx().clone(); let ctx = &ctx; self.pump(ctx, frame); + // Ctrl+plus and Ctrl+minus zoom too, through egui: keep what they + // settled on, so Settings shows it and the next launch opens at it. + let mut desktop = self.engine.desktop_prefs(); + if (ctx.zoom_factor() - desktop.zoom()).abs() > 0.005 { + desktop.text_size = ((ctx.zoom_factor() * 100.0).round() as u16).clamp(50, 200); + ctx.set_zoom_factor(desktop.zoom()); + self.engine.set_desktop_prefs(desktop); + } // The window manager can leave fullscreen without asking (F11, a // double-click on a title bar), and `F` then toggled the wrong way. if let Some(actual) = ctx.input(|input| input.viewport().fullscreen) { diff --git a/crates/pstr-app/src/desktop_prefs.rs b/crates/pstr-app/src/desktop_prefs.rs index 5f1e807..c6890a3 100644 --- a/crates/pstr-app/src/desktop_prefs.rs +++ b/crates/pstr-app/src/desktop_prefs.rs @@ -11,6 +11,9 @@ use serde::{Deserialize, Serialize}; /// What the settings page offers for the streaming cache, in GiB. pub const CACHE_CHOICES: [u32; 5] = [1, 2, 4, 8, 16]; +/// What the settings page offers for the size of everything, in percent. +pub const TEXT_SIZE_CHOICES: [u16; 5] = [90, 100, 115, 130, 150]; + #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] // A file from an older version, or edited by hand, missing a field: the rest // still counts, and the missing one takes its default. @@ -27,6 +30,9 @@ pub struct DesktopPrefs { pub grouped: bool, /// The library's offer of posters and synopses was turned down. pub metadata_offer_dismissed: bool, + /// How large text and controls are drawn, in percent. Per computer, not + /// synced: what reads well depends on the screen and how far away it is. + pub text_size: u16, } impl Default for DesktopPrefs { @@ -40,11 +46,17 @@ impl Default for DesktopPrefs { // of the same picture otherwise. grouped: true, metadata_offer_dismissed: false, + text_size: 100, } } } impl DesktopPrefs { + /// [`Self::text_size`] as egui's zoom factor, kept to what is offered. + pub fn zoom(&self) -> f32 { + f32::from(self.text_size.clamp(50, 200)) / 100.0 + } + pub fn cache_bytes(&self) -> u64 { u64::from(self.cache_gib.clamp(1, 64)) << 30 } @@ -105,6 +117,17 @@ mod tests { assert!(prefs.hardware_decoding); } + #[test] + fn a_text_size_from_an_older_file_is_full_size_and_a_wild_one_is_kept_in_range() { + let prefs: DesktopPrefs = serde_json::from_str(r#"{ "cache_gib": 8 }"#).unwrap(); + assert_eq!(prefs.zoom(), 1.0); + let wild = DesktopPrefs { + text_size: 900, + ..DesktopPrefs::default() + }; + assert_eq!(wild.zoom(), 2.0); + } + #[test] fn an_unparseable_file_is_never_overwritten() { let path = std::env::temp_dir().join(format!("pstr-desktop-{}.json", std::process::id())); diff --git a/crates/pstr-app/src/ui/settings.rs b/crates/pstr-app/src/ui/settings.rs index efd9464..c589c57 100644 --- a/crates/pstr-app/src/ui/settings.rs +++ b/crates/pstr-app/src/ui/settings.rs @@ -56,7 +56,7 @@ pub fn show( ui.vertical(|ui| { ui.set_width(width); ui.add_space(theme::space::M); - appearance_form(ui, prefs.appearance, actions); + appearance_form(ui, prefs.appearance, prefs.desktop, actions); playback_form(ui, prefs, actions); storage_form(ui, prefs, actions); metadata_form(ui, settings, api_key, actions); @@ -71,7 +71,12 @@ pub fn show( /// Every control here repaints the whole window the moment it is clicked — /// there is no "apply" — because the preview *is* the app. A swatch row that /// only changed a small square would be asking someone to imagine the result. -fn appearance_form(ui: &mut egui::Ui, appearance: Appearance, actions: &mut Vec) { +fn appearance_form( + ui: &mut egui::Ui, + appearance: Appearance, + desktop: DesktopPrefs, + actions: &mut Vec, +) { ui::widgets::settings_group(ui, "Appearance", None, |ui| { ui.add_space(theme::space::L); ui.label(theme::Role::Body.rich("Palette").color(theme::text())); @@ -108,6 +113,27 @@ fn appearance_form(ui: &mut egui::Ui, appearance: Appearance, actions: &mut Vec< } }); + ui::widgets::settings_row( + ui, + "Text size", + Some("Everything grows with it. Kept on this computer only."), + |ui| { + let choices: Vec<(u16, String)> = TEXT_SIZE_CHOICES + .iter() + .map(|&percent| (percent, format!("{percent}%"))) + .collect(); + let labels: Vec<(u16, &str)> = choices + .iter() + .map(|(percent, label)| (*percent, label.as_str())) + .collect(); + if let Some(text_size) = ui::widgets::segmented(ui, desktop.text_size, &labels) { + actions.push(Action::SetDesktopPrefs(DesktopPrefs { + text_size, + ..desktop + })); + } + }, + ); ui::widgets::settings_row( ui, "Library tiles",