diff --git a/apps/deckcraft/src/main.rs b/apps/deckcraft/src/main.rs index 7b5e48bb..49b55cec 100644 --- a/apps/deckcraft/src/main.rs +++ b/apps/deckcraft/src/main.rs @@ -52,24 +52,16 @@ fn config_dir() -> Option { } } -fn prefs_path() -> Option { - config_dir().map(|b| b.join("ui.json")) -} - fn load_prefs(app: &mut SlideApp) { if std::env::var_os("DECKCRAFT_NO_PREFS").is_some() { return; } - if let Some(p) = prefs_path() - && let Ok(bytes) = std::fs::read(&p) - && let Ok(ui) = serde_json::from_slice::(&bytes) - { + let Some(dir) = config_dir() else { return }; + let (ui, prefs) = deckcraft_ui_egui::settings::load(&dir); + if let Some(ui) = ui { app.ui = ui; } - if let Some(p) = prefs_path().map(|p| p.with_file_name("prefs.json")) - && let Ok(bytes) = std::fs::read(&p) - && let Ok(prefs) = serde_json::from_slice::(&bytes) - { + if let Some(prefs) = prefs { app.session.prefs = prefs; } } @@ -78,14 +70,8 @@ fn save_prefs(app: &SlideApp) { if std::env::var_os("DECKCRAFT_NO_PREFS").is_some() { return; } - if let Some(p) = prefs_path() { - let _ = std::fs::create_dir_all(p.parent().unwrap_or(std::path::Path::new("."))); - if let Ok(bytes) = serde_json::to_vec_pretty(&app.ui) { - let _ = std::fs::write(&p, bytes); - } - if let Ok(bytes) = serde_json::to_vec_pretty(&app.session.prefs) { - let _ = std::fs::write(p.with_file_name("prefs.json"), bytes); - } + if let Some(dir) = config_dir() { + deckcraft_ui_egui::settings::save(&dir, &app.ui, &app.session.prefs); } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c2b56c7f..7d23a42c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -269,6 +269,29 @@ impl Default for Prefs { } } +impl Prefs { + /// The preferences with every value in a range the editor can use: `prefs.json` is a file + /// on disk, and a hand-edited or damaged one (a zero grid, a huge paste offset or undo + /// limit) must not break editing or exhaust memory. Out-of-range numbers take their + /// defaults or the nearest usable value. + pub fn sanitized(mut self) -> Prefs { + let d = Prefs::default(); + let within = |v: f64, lo: f64, hi: f64, default: f64| if v.is_finite() { v.clamp(lo, hi) } else { default }; + self.grid_spacing = + if self.grid_spacing.is_finite() && self.grid_spacing > 0.0 { self.grid_spacing.clamp(0.5, 1000.0) } else { d.grid_spacing }; + self.duplicate_offset = within(self.duplicate_offset, -1000.0, 1000.0, d.duplicate_offset); + self.undo_limit = self.undo_limit.min(MAX_UNDO_LIMIT); + self.recovery_minutes = within(self.recovery_minutes, 0.1, 24.0 * 60.0, d.recovery_minutes); + if self.author.chars().count() > 256 { + self.author = self.author.chars().take(256).collect(); + } + self + } +} + +/// The most undo steps [`Prefs::undo_limit`] can keep. +pub const MAX_UNDO_LIMIT: usize = 10_000; + /// Things a command or tool asks the UI to do (open a dialog, show a pane, start the show…). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", tag = "kind")] diff --git a/crates/engine/src/recovery.rs b/crates/engine/src/recovery.rs index 30daa81e..391a0374 100644 --- a/crates/engine/src/recovery.rs +++ b/crates/engine/src/recovery.rs @@ -25,6 +25,20 @@ pub fn default_dir() -> Option { std::env::var_os("XDG_DATA_HOME").map(PathBuf::from).or_else(|| home.map(|h| h.join(".local/share"))).map(|d| d.join("deckcraft/recovery")) } +/// Presentations reopened per start; the rest stay on disk for the next one, so a folder +/// full of entries can't stall start-up. +pub const MAX_REOPEN: usize = 32; +/// Largest entry read back. +const MAX_ENTRY_BYTES: u64 = 2 << 30; +const MAX_META_BYTES: u64 = 64 << 10; + +/// `p` as bytes when it's a regular file no bigger than `max`: reading a FIFO left where an +/// entry belongs would block start-up forever. +fn read_regular(p: &Path, max: u64) -> Option> { + let m = std::fs::metadata(p).ok()?; + (m.is_file() && m.len() <= max).then(|| std::fs::read(p).ok()).flatten() +} + fn files(dir: &Path, uid: u64) -> (PathBuf, PathBuf) { (dir.join(format!("{uid}.deckcraft")), dir.join(format!("{uid}.json"))) } @@ -78,11 +92,13 @@ pub fn list(dir: &Path) -> Vec<(u64, Value)> { if p.extension().and_then(|x| x.to_str()) != Some("deckcraft") { continue; } + if !std::fs::metadata(&p).is_ok_and(|m| m.is_file()) { + continue; + } let Some(uid) = p.file_stem().and_then(|s| s.to_str()).and_then(|s| s.parse::().ok()) else { continue }; // Metadata is a file on disk: anything but an object (corrupt, hand-edited) counts as none. - let meta = std::fs::read_to_string(p.with_extension("json")) - .ok() - .and_then(|t| serde_json::from_str::(&t).ok()) + let meta = read_regular(&p.with_extension("json"), MAX_META_BYTES) + .and_then(|t| serde_json::from_slice::(&t).ok()) .filter(Value::is_object) .unwrap_or(json!({})); out.push((uid, meta)); @@ -92,12 +108,17 @@ pub fn list(dir: &Path) -> Vec<(u64, Value)> { } /// Reopen every entry as an unsaved presentation and remove the entries (this session writes its -/// own). Unreadable entries are left in place. Returns the opened documents' indices. +/// own), the newest [`MAX_REOPEN`] first. Unreadable entries and those past the cap are left in place. Returns the opened documents' indices. pub fn open(s: &mut Session, dir: &Path) -> Result> { let mut opened = Vec::new(); - for (uid, meta) in list(dir) { + let mut entries = list(dir); + entries.sort_by_key(|(uid, meta)| std::cmp::Reverse((meta.get("saved").and_then(Value::as_u64).unwrap_or(0), *uid))); + for (uid, meta) in entries { + if opened.len() >= MAX_REOPEN { + break; + } let (doc, _) = files(dir, uid); - let Ok(bytes) = std::fs::read(&doc) else { continue }; + let Some(bytes) = read_regular(&doc, MAX_ENTRY_BYTES) else { continue }; let Ok(mut d) = deckcraft_format::load(&bytes) else { continue }; deckcraft_format::repair(&mut d); let path = meta.get("path").and_then(Value::as_str).map(str::to_string); @@ -162,4 +183,34 @@ mod tests { assert_eq!(v.as_array().map(Vec::len), Some(1)); let _ = std::fs::remove_dir_all(&dir); } + + /// A FIFO where an entry or its metadata belongs is skipped rather than read (that blocked + /// start-up forever), and a folder full of entries reopens only [`MAX_REOPEN`] per start. + #[cfg(unix)] + #[test] + fn hostile_recovery_folders_dont_stall_start_up() { + let dir = temp("recovery-hostile"); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + save(&s, &dir).unwrap(); + let (good, _) = files(&dir, s.doc().unwrap().uid); + for i in 1..=(MAX_REOPEN as u64 + 3) { + std::fs::copy(&good, dir.join(format!("{}.deckcraft", 100_000 + i))).unwrap(); + } + for name in ["5.deckcraft", "6.json"] { + let _ = std::process::Command::new("mkfifo").arg(dir.join(name)).status(); + } + std::fs::write(dir.join("6.deckcraft"), std::fs::read(&good).unwrap()).unwrap(); + let (tx, rx) = std::sync::mpsc::channel(); + let d = dir.clone(); + std::thread::spawn(move || { + let mut s2 = Session::new(); + let _ = tx.send(open(&mut s2, &d).map(|v| v.len()).unwrap_or(0)); + }); + let n = rx.recv_timeout(std::time::Duration::from_secs(60)).expect("recovery hung"); + assert_eq!(n, MAX_REOPEN); + assert_eq!(list(&dir).len(), 5, "the rest wait for the next start"); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 5425c022..059db7f1 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -444,6 +444,22 @@ fn file_face_names(path: &std::path::Path) -> Vec<(String, String)> { .collect() } +/// Folder depth below a font folder that the system scan descends. +#[cfg(not(target_arch = "wasm32"))] +const MAX_SCAN_DEPTH: usize = 12; +/// Directory entries the system scan looks at in all. +#[cfg(not(target_arch = "wasm32"))] +const MAX_SCAN_ENTRIES: usize = 200_000; +/// The largest font file read (the biggest CJK collections are a few hundred MB). +#[cfg(not(target_arch = "wasm32"))] +pub const MAX_FONT_FILE: u64 = 512 << 20; + +/// A regular file of a size a font can have. +#[cfg(not(target_arch = "wasm32"))] +fn font_file_ok(meta: &std::fs::Metadata) -> bool { + meta.is_file() && meta.len() > 12 && meta.len() <= MAX_FONT_FILE +} + /// The platform's font folders (the system's and the user's), scanned by [`FontDb::global`]. /// Empty on wasm. pub fn system_font_dirs() -> Vec { @@ -804,24 +820,41 @@ impl FontDb { #[cfg(not(target_arch = "wasm32"))] fn scan_font_dirs(&self) -> usize { let mut found = Vec::new(); - let mut stack = self.font_dirs.clone(); + let mut stack: Vec<(std::path::PathBuf, usize)> = self.font_dirs.iter().map(|d| (d.clone(), 0)).collect(); // Each folder once, however links lead back to it. let mut visited = std::collections::HashSet::new(); - while let Some(d) = stack.pop() { + let mut entries = 0usize; + while let Some((d, depth)) = stack.pop() { if !visited.insert(std::fs::canonicalize(&d).unwrap_or_else(|_| d.clone())) { continue; } let Ok(rd) = std::fs::read_dir(&d) else { continue }; for e in rd.flatten() { + // A link to `/` or a vast tree mustn't turn start-up into a disk crawl. + entries += 1; + if entries > MAX_SCAN_ENTRIES { + log::warn!("font scan stopped after {MAX_SCAN_ENTRIES} entries"); + stack.clear(); + break; + } let p = e.path(); - if p.is_dir() { - stack.push(p); + // Follows links: what matters is what the entry leads to. + let Ok(meta) = std::fs::metadata(&p) else { continue }; + if meta.is_dir() { + if depth < MAX_SCAN_DEPTH { + stack.push((p, depth + 1)); + } continue; } let ext = p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()); if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) { continue; } + // Only regular files: opening a FIFO named like a font blocks until something + // writes to it (start-up hung), and a device never ends. + if !font_file_ok(&meta) { + continue; + } for (family, style) in file_face_names(&p) { found.push(CatalogEntry { family, style, path: p.clone() }); } @@ -845,7 +878,9 @@ impl FontDb { }; let mut any = false; for p in paths { - if let Ok(data) = std::fs::read(&p) { + if std::fs::metadata(&p).is_ok_and(|m| font_file_ok(&m)) + && let Ok(data) = std::fs::read(&p) + { any |= self.add_font(data) > 0; } } diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 978661bb..4c6759e4 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -299,4 +299,30 @@ mod tests { assert_eq!(db.face("Calibri", "Bold").family, "Carlito"); } } + + /// A FIFO, a folder and a link loop dressed up as font files: the system scan skips them + /// instead of blocking forever on the FIFO (start-up hung when one sat in `~/.fonts`), and + /// still finds the real font beside them. + #[cfg(unix)] + #[test] + fn the_font_scan_skips_fifos_folders_and_link_loops() { + let dir = std::env::temp_dir().join(format!("deckcraft-fontscan-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("sub")).unwrap(); + std::fs::copy(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/legacy-semibold.ttf"), dir.join("sub/real.ttf")).unwrap(); + let fifo = std::process::Command::new("mkfifo").arg(dir.join("trap.ttf")).status().is_ok_and(|s| s.success()); + std::fs::create_dir(dir.join("folder.ttf")).unwrap(); + std::fs::write(dir.join("empty.otf"), b"").unwrap(); + std::os::unix::fs::symlink(&dir, dir.join("sub/loop")).unwrap(); + let (tx, rx) = std::sync::mpsc::channel(); + let scan_dir = dir.clone(); + std::thread::spawn(move || { + let db = FontDb::with_font_dirs(vec![scan_dir]); + let _ = tx.send(db.load_system_fonts()); + }); + let n = rx.recv_timeout(std::time::Duration::from_secs(30)).expect("the font scan hung"); + assert!(n >= 1, "the real font was found"); + assert!(fifo || n >= 1); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 253765bf..c7290b35 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -41,7 +41,8 @@ pub fn layout(app: &SlideApp, avail: Rect) -> (Rect, f32) { let (sw, sh) = (d.doc.slide_size.width as f32, d.doc.slide_size.height as f32); let margin = 28.0; let fit = ((avail.width() - margin * 2.0) / sw).min((avail.height() - margin * 2.0) / sh).max(0.05); - let scale = app.ui.zoom.unwrap_or(fit); + // A zoom from outside the editor's own controls (settings, the control protocol) is kept in range. + let scale = app.ui.zoom.filter(|z| z.is_finite() && *z > 0.0).map_or(fit, |z| z.clamp(*crate::ZOOM_RANGE.start(), *crate::ZOOM_RANGE.end())); let size = vec2(sw * scale, sh * scale); let mut r = Rect::from_center_size(avail.center(), size); if size.x > avail.width() { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index d344d912..704de448 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -229,7 +229,11 @@ pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) -> }; } if let Some(z) = p.get("zoom") { - app.ui.zoom = z.as_f64().map(|v| v as f32); + app.ui.zoom = z + .as_f64() + .map(|v| v as f32) + .filter(|v| v.is_finite() && *v > 0.0) + .map(|v| v.clamp(*crate::ZOOM_RANGE.start(), *crate::ZOOM_RANGE.end())); } if let Some(d) = p.get("dark").and_then(Value::as_bool) { let _ = app.run("view.dark", json!({"on": d})); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ec97e6e6..b40daf7b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -18,6 +18,7 @@ pub mod menus; pub mod panes; pub mod ribbon; pub mod selpane; +pub mod settings; pub mod show; pub mod sorter; pub mod status; @@ -131,6 +132,41 @@ impl Default for UiState { } } +/// The editor's zoom range (1 = 100 %). +pub const ZOOM_RANGE: std::ops::RangeInclusive = 0.1..=4.0; + +impl UiState { + /// The state with every value usable: `ui.json` is a file on disk, and a damaged or + /// hand-edited one must not stop the window from opening. A zoom of 10,000 % asked the + /// renderer for a texture larger than memory at start-up; panel sizes, scales and names are + /// brought into range the same way, and the recent list is cut to its usual length. + pub fn sanitized(mut self) -> UiState { + let d = UiState::default(); + let within = |v: f32, lo: f32, hi: f32, default: f32| if v.is_finite() { v.clamp(lo, hi) } else { default }; + self.zoom = self.zoom.filter(|z| z.is_finite() && *z > 0.0).map(|z| z.clamp(*ZOOM_RANGE.start(), *ZOOM_RANGE.end())); + self.sorter_zoom = within(self.sorter_zoom, 0.5, 3.0, d.sorter_zoom); + self.ui_scale = within(self.ui_scale, 0.5, 3.0, d.ui_scale); + self.notes_height = within(self.notes_height, 0.0, 2000.0, d.notes_height); + self.thumbs_width = within(self.thumbs_width, 60.0, 1000.0, d.thumbs_width); + self.pane_width = within(self.pane_width, 240.0, 520.0, d.pane_width); + let short = |s: &String| s.len() <= 64; + if !short(&self.tab) { + self.tab = d.tab; + } + if !short(&self.format_tab) { + self.format_tab = d.format_tab; + } + self.pane = self.pane.filter(short); + self.recent.retain(|p| !p.is_empty() && p.len() <= 4096 && !p.contains('\0')); + self.recent.dedup(); + self.recent.truncate(RECENT_MAX); + self + } +} + +/// How many recent files File › Open and Home list. +pub const RECENT_MAX: usize = 12; + /// UI-only commands (id, label, shortcut, params). pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("view.normal", "Normal", None, "{}"), @@ -575,7 +611,7 @@ impl SlideApp { self.session.execute("file.open", &json!({"path": path})).map_err(|e| e.to_string())?; self.ui.recent.retain(|p| p != path); self.ui.recent.insert(0, path.to_string()); - self.ui.recent.truncate(12); + self.ui.recent.truncate(RECENT_MAX); self.ui.view = ViewMode::Normal; Ok(()) } diff --git a/crates/ui-egui/src/settings.rs b/crates/ui-egui/src/settings.rs new file mode 100644 index 00000000..9a1b8b62 --- /dev/null +++ b/crates/ui-egui/src/settings.rs @@ -0,0 +1,170 @@ +//! The desktop app's settings files, `ui.json` ([`UiState`]) and `prefs.json` +//! ([`deckcraft_engine::Prefs`]), in the per-user settings folder. They are files on disk that a +//! crash, a full disk or a hand edit can leave damaged, so reading never fails and never trusts +//! them: an unreadable, oversized or malformed file gives the defaults, and the values of a +//! well-formed one are brought into range ([`UiState::sanitized`], `Prefs::sanitized`). Writing +//! goes through a temporary file and a rename, so a crash mid-write leaves the old file. + +use std::path::Path; + +use deckcraft_engine::Prefs; + +use crate::UiState; + +pub const UI_FILE: &str = "ui.json"; +pub const PREFS_FILE: &str = "prefs.json"; +/// Larger settings files are not settings. +pub const MAX_BYTES: u64 = 4 << 20; + +/// The JSON in `path`, when it is a regular file of a sane size that holds a `T`. +fn read(path: &Path) -> Option { + let meta = std::fs::metadata(path).ok()?; + if !meta.is_file() || meta.len() > MAX_BYTES { + log::warn!("{}: not a settings file; using the defaults", path.display()); + return None; + } + let bytes = std::fs::read(path).ok()?; + match serde_json::from_slice(&bytes) { + Ok(v) => Some(v), + Err(e) => { + log::warn!("{}: {e}; using the defaults", path.display()); + None + } + } +} + +/// The saved UI state and preferences in `dir` (each `None` when missing or unusable). +pub fn load(dir: &Path) -> (Option, Option) { + (read::(&dir.join(UI_FILE)).map(UiState::sanitized), read::(&dir.join(PREFS_FILE)).map(Prefs::sanitized)) +} + +/// Write `bytes` to `path` through a temporary file beside it. +fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + let tmp = path.with_extension("json.tmp"); + std::fs::write(&tmp, bytes)?; + std::fs::rename(&tmp, path).inspect_err(|_| { + let _ = std::fs::remove_file(&tmp); + }) +} + +/// Save both files into `dir` (created when missing). Failures are logged, not fatal. +pub fn save(dir: &Path, ui: &UiState, prefs: &Prefs) { + if let Err(e) = std::fs::create_dir_all(dir) { + log::warn!("{}: {e}; settings not saved", dir.display()); + return; + } + for (name, json) in [(UI_FILE, serde_json::to_vec_pretty(ui)), (PREFS_FILE, serde_json::to_vec_pretty(prefs))] { + let path = dir.join(name); + if let Err(e) = json.map_err(std::io::Error::other).and_then(|b| write_atomic(&path, &b)) { + log::warn!("{}: {e}", path.display()); + } + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used)] + use super::*; + + fn dir(name: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("deckcraft-settings-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d + } + + #[test] + fn damaged_settings_files_give_the_defaults() { + let d = dir("damaged"); + let cases: [&[u8]; 6] = [b"", b"{", b"\xFF\xFE garbage", b"[1, 2, 3]", b"{\"zoom\": \"big\"}", b"null"]; + for bytes in cases { + std::fs::write(d.join(UI_FILE), bytes).unwrap(); + std::fs::write(d.join(PREFS_FILE), bytes).unwrap(); + let (ui, prefs) = load(&d); + // Prefs has no `zoom`, so that case is a valid (default) Prefs. + assert!(ui.is_none(), "{:?}", String::from_utf8_lossy(bytes)); + assert!(prefs.is_none() || bytes.starts_with(b"{\"zoom"), "{:?}", String::from_utf8_lossy(bytes)); + } + // A folder where a file should be, and an oversized file. + std::fs::remove_file(d.join(UI_FILE)).unwrap(); + std::fs::create_dir(d.join(UI_FILE)).unwrap(); + std::fs::write(d.join(PREFS_FILE), vec![b' '; MAX_BYTES as usize + 1]).unwrap(); + assert!(matches!(load(&d), (None, None))); + // Nothing there at all. + assert!(matches!(load(&d.join("missing")), (None, None))); + } + + #[test] + fn out_of_range_values_are_brought_into_range() { + let d = dir("ranges"); + let recent: Vec = (0..5000).map(|i| format!("/tmp/deck{i}.pptx")).collect(); + let ui = serde_json::json!({ + "zoom": 1.0e30, "sorterZoom": -4.0, "uiScale": 1.0e9, "notesHeight": 1.0e30, + "thumbsWidth": -1.0e30, "paneWidth": 3.4e38, "tab": "x".repeat(10_000), "pane": "y".repeat(500), + "recent": recent, + }); + let prefs = serde_json::json!({"gridSpacing": 0.0, "duplicateOffset": 1.0e300, "undoLimit": u64::MAX, "recoveryMinutes": -5.0, "author": "a".repeat(100_000)}); + std::fs::write(d.join(UI_FILE), ui.to_string()).unwrap(); + std::fs::write(d.join(PREFS_FILE), prefs.to_string()).unwrap(); + let (ui, prefs) = load(&d); + let (ui, prefs) = (ui.unwrap(), prefs.unwrap()); + assert_eq!(ui.zoom, Some(*crate::ZOOM_RANGE.end())); + assert_eq!((ui.sorter_zoom, ui.ui_scale), (0.5, 3.0)); + assert!(ui.notes_height <= 2000.0 && ui.thumbs_width >= 60.0 && ui.pane_width <= 520.0); + assert_eq!((ui.tab.as_str(), ui.pane.as_deref()), ("Home", None)); + assert_eq!(ui.recent.len(), crate::RECENT_MAX); + assert_eq!(prefs.grid_spacing, Prefs::default().grid_spacing); + assert_eq!(prefs.duplicate_offset, 1000.0); + assert_eq!(prefs.undo_limit, deckcraft_engine::MAX_UNDO_LIMIT); + assert_eq!(prefs.recovery_minutes, 0.1); + assert_eq!(prefs.author.chars().count(), 256); + // A zero or negative zoom means "fit". + std::fs::write(d.join(UI_FILE), r#"{"zoom": 0}"#).unwrap(); + assert_eq!(load(&d).0.unwrap().zoom, None); + } + + #[test] + fn saving_round_trips_and_leaves_no_temporary_file() { + let d = dir("save"); + let ui = UiState { zoom: Some(1.5), recent: vec!["/a.pptx".into()], ..Default::default() }; + let prefs = Prefs { author: "Ann".into(), ..Default::default() }; + save(&d.join("nested"), &ui, &prefs); + let (u, p) = load(&d.join("nested")); + assert_eq!(u.unwrap().zoom, Some(1.5)); + assert_eq!(p.unwrap().author, "Ann"); + let names: Vec = + std::fs::read_dir(d.join("nested")).unwrap().flatten().map(|e| e.file_name().to_string_lossy().to_string()).collect(); + assert!(names.iter().all(|n| !n.ends_with(".tmp")), "{names:?}"); + // A settings folder that is a file: nothing saved, no panic. + std::fs::write(d.join("blocked"), "file").unwrap(); + save(&d.join("blocked"), &ui, &prefs); + } + + /// The window opens with the worst `ui.json` values once they are sanitized; a zoom of + /// 10,000 % used to panic in egui and 1e30 exhausted memory at start-up. + #[test] + fn the_window_opens_with_hostile_saved_state() { + let hostile = UiState { + zoom: Some(1.0e30), + notes_height: 1.0e30, + thumbs_width: -1.0e30, + pane: Some("format".into()), + pane_width: 1.0e30, + ..Default::default() + }; + for ui in [hostile.clone().sanitized(), UiState { zoom: Some(1.0e4), ..Default::default() }] { + let mut app = crate::SlideApp::new(deckcraft_engine::Session::with_new(), crate::Services::default()); + // The second case is not sanitized: the canvas keeps a stray zoom in range itself. + app.ui = ui; + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut crate::SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + assert!(h.state().canvas_scale <= *crate::ZOOM_RANGE.end() + 1e-3, "{}", h.state().canvas_scale); + } + } +} diff --git a/crates/ui-egui/src/status.rs b/crates/ui-egui/src/status.rs index 8e915b71..11efe3f7 100644 --- a/crates/ui-egui/src/status.rs +++ b/crates/ui-egui/src/status.rs @@ -39,7 +39,7 @@ pub fn status_bar(app: &mut SlideApp, ui: &mut Ui) { if small_icon(&mut right, Icon::FitSmall, "Fit slide to current window", false).clicked() { app.ui.zoom = None; } - let pct = (app.ui.zoom.unwrap_or(app.canvas_scale) * 100.0).round(); + let pct = (app.canvas_scale * 100.0).round(); let lbl = right.add(egui::Label::new(egui::RichText::new(format!("{pct}%")).size(12.0).color(t.text_dim)).sense(Sense::click())); if lbl.clicked() { app.dialog = Some(crate::dialogs::Dialog::new("zoom")); diff --git a/crates/ui-egui/src/textures.rs b/crates/ui-egui/src/textures.rs index 40498422..b28f227f 100644 --- a/crates/ui-egui/src/textures.rs +++ b/crates/ui-egui/src/textures.rs @@ -116,7 +116,9 @@ impl Textures { Some((i, d)) if i == index => estimate.union(d), _ => estimate, }; - let want = deckcraft_render::pasteboard_region(size, scale, reach, visible); + // The GPU's (or software fallback's) texture limit: a larger canvas texture panics in egui. + let max_side = ctx.input(|i| i.max_texture_side).max(1) as u32; + let want = fit_texture(deckcraft_render::pasteboard_region(size, scale, reach, visible), visible, max_side); let key = Key { slide: Arc::as_ptr(&slide) as usize, master: master.as_ref().map(|m| Arc::as_ptr(m) as usize).unwrap_or(0), @@ -142,7 +144,8 @@ impl Textures { // Text spilling out of its box can reach further than the boxes said. Once drawn, the // region it calls for is known (and is next frame's key): render again only if it shows // more than this render covered. - let key = Key { want: deckcraft_render::pasteboard_region(size, scale, estimate.union(drawn), visible), ..key }; + let key = + Key { want: fit_texture(deckcraft_render::pasteboard_region(size, scale, estimate.union(drawn), visible), visible, max_side), ..key }; if !region.contains(&key.want) { region = key.want; img = render(region).0; @@ -213,3 +216,33 @@ impl Textures { Some(tex) } } + +/// `r` cut to at most `max` pixels a side, keeping as much of `visible` as fits. +pub(crate) fn fit_texture(mut r: Region, visible: Region, max: u32) -> Region { + fn axis(start: &mut i32, len: &mut u32, vis: i32, max: u32) { + if *len > max { + let hi = *start as i64 + (*len - max) as i64; + *start = (vis as i64).clamp(*start as i64, hi) as i32; + *len = max; + } + } + axis(&mut r.x, &mut r.w, visible.x, max); + axis(&mut r.y, &mut r.h, visible.y, max); + r +} + +#[cfg(test)] +mod fit_tests { + use super::*; + + #[test] + fn canvas_textures_stay_within_the_texture_limit() { + let r = Region { x: -100, y: -50, w: 5000, h: 1000 }; + let f = fit_texture(r, Region { x: 300, y: 0, w: 800, h: 600 }, 2048); + assert_eq!((f.x, f.y, f.w, f.h), (300, -50, 2048, 1000)); + // Visible past the far end: the cut is pinned there. + let f = fit_texture(r, Region { x: 4800, y: 0, w: 800, h: 600 }, 2048); + assert_eq!((f.x, f.w), (-100 + 5000 - 2048, 2048)); + assert_eq!(fit_texture(r, r, 8192), r); + } +}