Startup: survive damaged settings, font folders, recovery folders and small texture limits

- ui.json / prefs.json are read through a new settings module: regular files only, 4 MiB
  cap, parse errors logged, values sanitized (a saved zoom of 1e30 aborted start-up,
  1e4 panicked in egui); writes are atomic.
- The canvas and the control protocol clamp stray zooms; the status bar shows the zoom in use.
- The canvas texture is cut to the GPU's max texture side (zoom 400% on a 2048 limit panicked).
- The system font scan opens only regular files of sane size (a FIFO named *.ttf in ~/.fonts
  hung start-up), with depth and entry caps.
- AutoRecover skips non-regular entries and reopens at most 32 per start, newest first.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nikita Rakitin
2026-10-14 03:27:57 +03:00
co-authored by Cursor
parent 457406a7a5
commit c583480010
11 changed files with 402 additions and 37 deletions
+6 -20
View File
@@ -52,24 +52,16 @@ fn config_dir() -> Option<std::path::PathBuf> {
}
}
fn prefs_path() -> Option<std::path::PathBuf> {
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::<deckcraft_ui_egui::UiState>(&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::<deckcraft_engine::Prefs>(&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);
}
}
+23
View File
@@ -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")]
+57 -6
View File
@@ -25,6 +25,20 @@ pub fn default_dir() -> Option<PathBuf> {
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<Vec<u8>> {
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::<u64>().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::<Value>(&t).ok())
let meta = read_regular(&p.with_extension("json"), MAX_META_BYTES)
.and_then(|t| serde_json::from_slice::<Value>(&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<Vec<usize>> {
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);
}
}
+40 -5
View File
@@ -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<std::path::PathBuf> {
@@ -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;
}
}
+26
View File
@@ -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);
}
}
+2 -1
View File
@@ -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() {
+5 -1
View File
@@ -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}));
+37 -1
View File
@@ -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<f32> = 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(())
}
+170
View File
@@ -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<T: serde::de::DeserializeOwned>(path: &Path) -> Option<T> {
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<UiState>, Option<Prefs>) {
(read::<UiState>(&dir.join(UI_FILE)).map(UiState::sanitized), read::<Prefs>(&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<String> = (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<String> =
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);
}
}
}
+1 -1
View File
@@ -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"));
+35 -2
View File
@@ -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);
}
}