diff --git a/ROADMAP.md b/ROADMAP.md index 8e0b7a06..9cf408a4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,7 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. -- **2026-10-13** · File ▸ Open Recent lists the documents last opened or saved (newest first, no duplicates; Save a Copy and exports don't count), with Clear Recent Files; the start screen shows the last eight. A file that is gone is dimmed and says so when chosen, and stays listed. Preferences ▸ File Handling sets how many are kept (default 20, 0–100). `file.openRecent {index}`, `file.recentFiles`, `file.clearRecent` (#706). No readiness percentage change. +- **2026-10-13** · File ▸ Open Recent lists the documents last opened or saved (newest first, no duplicates; Save a Copy and exports don't count), with Clear Recent Files; the start screen shows the last eight, below the community card, as cards with a picture of the first page: saving puts one in the file (`preview.png`), so the start screen reads it without opening the document. A file that is gone is dimmed and says so when chosen, and stays listed. Preferences ▸ File Handling sets how many are kept (default 20, 0–100). `file.openRecent {index}`, `file.recentFiles`, `file.clearRecent` (#706). No readiness percentage change. - **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 98e3aa9d..4c9e7e36 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -37,7 +37,7 @@ pub fn specs() -> Vec { let path = str_param(p, "path").ok_or_else(|| bad("file.saveACopy", "missing `path`"))?.to_string(); let mut copy = (*s.doc()?.doc).clone(); super::datamerge::refresh_relative_paths(&mut copy, std::path::Path::new(&path)); - let bytes = to_bytes(©); + let bytes = to_bytes_with_preview(©, &s.cache); #[cfg(not(target_arch = "wasm32"))] crate::atomic::write_file(&path, &bytes)?; Ok(json!({"path": path, "bytes": bytes.len()})) @@ -123,6 +123,22 @@ pub fn to_bytes(d: &Document) -> Vec { designcraft_format::save(d).unwrap_or_default() } +/// The longer side of the first-page picture saved in the file, in pixels. +const PREVIEW_PX: f64 = 320.0; + +/// [`to_bytes`] with a picture of the first page, which the start screen shows next to the file +/// in its recent files. A page that can't be drawn just leaves the picture out. +fn to_bytes_with_preview(d: &Document, cache: &crate::Cache) -> Vec { + let preview = d.page_loc(0).and_then(|(si, pi)| { + let b = d.spreads.get(si)?.pages.get(pi)?.bounds(); + let side = b.width().max(b.height()); + (side.is_finite() && side > 0.0).then_some(PREVIEW_PX / side) + }); + let png = + preview.and_then(|scale| designcraft_render::Renderer::new().render_page(d, cache, 0, scale, false, &Default::default())).map(|r| r.to_png()); + designcraft_format::save_with_preview(d, png.as_deref()).unwrap_or_default() +} + pub fn from_bytes(b: &[u8]) -> Result { designcraft_format::load(b).map_err(|e| EngineError::Other(e.to_string())) } @@ -172,6 +188,31 @@ fn has_recent(s: &Session) -> std::result::Result<(), String> { } /// One File › Open Recent row: the path, its file name and whether it is still there. +/// A recent file's first-page picture as straight RGBA, for the start screen. +pub struct RecentPreview { + pub width: u32, + pub height: u32, + pub rgba: Vec, +} + +/// The picture a `.designcraft` file saved of its first page; None when the file is gone, isn't +/// ours, or was saved before files had one (it gets one when saved again). +pub fn recent_preview(path: &str) -> Option { + #[cfg(not(target_arch = "wasm32"))] + { + let file = std::fs::File::open(path).ok()?; + let png = designcraft_format::read_preview(std::io::BufReader::new(file))?; + let img = designcraft_images::decode_rgba(&png)?; + let (width, height) = img.dimensions(); + Some(RecentPreview { width, height, rgba: img.into_raw() }) + } + #[cfg(target_arch = "wasm32")] + { + let _ = path; + None + } +} + fn recent_entry(path: &str) -> Value { let name = std::path::Path::new(path).file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().to_string()); #[cfg(not(target_arch = "wasm32"))] @@ -293,9 +334,10 @@ fn file_save(s: &mut Session, p: &Value) -> Result { super::datamerge::refresh_relative_paths(&mut saved, std::path::Path::new(&path)); let paths_changed = saved.data_merge != st.doc.data_merge; let saved = if paths_changed { Arc::new(saved) } else { st.doc.clone() }; - let bytes = to_bytes(&saved); + let bytes = to_bytes_with_preview(&saved, &s.cache); #[cfg(not(target_arch = "wasm32"))] crate::atomic::write_file(&path, &bytes)?; + let st = s.doc_mut()?; if paths_changed { st.doc = saved; st.revision = st.revision.saturating_add(1); @@ -1101,6 +1143,29 @@ mod recent_tests { let _ = std::fs::remove_dir_all(&dir); } + /// Saving puts a small picture of the first page in the file, which the start screen reads + /// without opening the document. + #[test] + fn saved_files_carry_a_first_page_preview() { + let dir = temp_dir("preview"); + let mut s = Session::new(); + let a = saved(&mut s, &dir.join("a.designcraft")); + let p = super::recent_preview(&a).expect("a preview"); + assert_eq!(p.width.max(p.height), super::PREVIEW_PX as u32); + assert_eq!(p.rgba.len(), (p.width * p.height * 4) as usize); + assert!(p.rgba.as_chunks::<4>().0.iter().any(|px| px[3] == 255), "the page is drawn, not left transparent"); + let copy = dir.join("copy.designcraft"); + s.execute("file.saveACopy", &json!({"path": copy.to_string_lossy()})).unwrap(); + assert!(super::recent_preview(©.to_string_lossy()).is_some(), "Save a Copy has one too"); + // Files without one (saved before previews), missing files and other files have none. + let old = dir.join("old.designcraft"); + std::fs::write(&old, super::to_bytes(&s.doc().unwrap().doc)).unwrap(); + for path in [old, dir.join("gone.designcraft"), dir.clone()] { + assert!(super::recent_preview(&path.to_string_lossy()).is_none(), "{path:?}"); + } + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn the_list_keeps_at_most_the_preferred_count() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 31e4544f..80fc298f 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -310,5 +310,5 @@ pub fn file_from(b: &[u8]) -> Result { } pub(crate) use datamerge::sync_placeholders; pub(crate) use file::load_document_fonts; -pub use file::{base64_decode, base64_encode, from_bytes, to_bytes}; +pub use file::{RecentPreview, base64_decode, base64_encode, from_bytes, recent_preview, to_bytes}; pub(crate) use place_text::autoflow as place_text_autoflow; diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index 25f09537..8587db47 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -590,7 +590,10 @@ mod tests { s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": text.len()})).unwrap(); s.execute("type.char", &json!({"fontFamily": NAMED, "fontStyle": "Regular"})).unwrap(); let src = dir.join("src").join("Fallback.designcraft"); - s.execute("file.save", &json!({"path": src.to_string_lossy()})).unwrap(); + // Written without File › Save's preview: drawing it before the document's fonts load would + // pull in a system font that covers these characters (Unifont CSUR does) and draws them + // ahead of the document's own fonts in this process. + std::fs::write(&src, crate::cmd::to_bytes(&s.doc().unwrap().doc)).unwrap(); let mut s = Session::new(); assert_eq!(s.execute("file.open", &json!({"path": src.to_string_lossy()})).unwrap()["documentFonts"], 3); diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 408bb3cc..149945ef 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -5,6 +5,7 @@ //! - `assets/.`: embedded placed files, byte-for-byte: one per distinct file, and only //! the ones the document references (see [`Document::compact_assets`]) //! - `meta.json`: format version and generator +//! - `preview.png` (optional): a small picture of the first page, for the start screen's recent files //! //! Older single-file JSON documents (assets as base64 in `assetData`) still open. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] @@ -46,6 +47,11 @@ fn ext_for(mime: &str) -> &'static str { /// Serialize a document to `.designcraft` bytes. The file holds each distinct placed file once /// and leaves out assets nothing references; `doc` itself keeps them (undo can bring them back). pub fn save(doc: &Document) -> Result, FormatError> { + save_with_preview(doc, None) +} + +/// [`save`], plus `preview` (PNG bytes) as `preview.png`. +pub fn save_with_preview(doc: &Document, preview: Option<&[u8]>) -> Result, FormatError> { let mut compact = doc.clone(); compact.compact_assets(); let doc = &compact; @@ -60,6 +66,10 @@ pub fn save(doc: &Document) -> Result, FormatError> { z.start_file("meta.json", deflate).map_err(io)?; let meta = json!({"format": "designcraft", "version": VERSION, "generator": concat!("DesignCraft ", env!("CARGO_PKG_VERSION"))}); z.write_all(meta.to_string().as_bytes()).map_err(|e| FormatError::Io(e.to_string()))?; + if let Some(png) = preview { + z.start_file(PREVIEW, stored).map_err(io)?; + z.write_all(png).map_err(|e| FormatError::Io(e.to_string()))?; + } z.start_file("document.json", deflate).map_err(io)?; let json = serde_json::to_vec_pretty(doc).map_err(|e| FormatError::Io(e.to_string()))?; z.write_all(&json).map_err(|e| FormatError::Io(e.to_string()))?; @@ -77,6 +87,25 @@ pub fn save(doc: &Document) -> Result, FormatError> { Ok(buf.into_inner()) } +/// The zip entry holding the first page's picture. +pub const PREVIEW: &str = "preview.png"; + +/// The PNG bytes of a `.designcraft` file's preview, reading only the zip's directory and that +/// entry. None for files without one (saved before previews, or legacy JSON) or that aren't ours. +pub fn read_preview(file: R) -> Option> { + let mut z = zip::ZipArchive::new(file).ok()?; + let mut f = z.by_name(PREVIEW).ok()?; + // A preview is small; a huge entry is not one we wrote. + if f.size() > MAX_PREVIEW_BYTES { + return None; + } + let mut v = Vec::new(); + f.read_to_end(&mut v).ok()?; + Some(v) +} + +const MAX_PREVIEW_BYTES: u64 = 4 << 20; + /// Read `.designcraft` bytes (zip, or the legacy single JSON file). pub fn load(bytes: &[u8]) -> Result { if bytes.starts_with(b"PK") { @@ -406,6 +435,19 @@ mod tests { assert!(load(&serde_json::to_vec(&v).unwrap()).unwrap().settings.glyph_fallback); } + #[test] + fn the_preview_is_read_back_without_the_document() { + let d = Document::new(&NewDocument::default()); + let png = b"\x89PNG fake".to_vec(); + let bytes = save_with_preview(&d, Some(&png)).unwrap(); + assert_eq!(read_preview(Cursor::new(&bytes)), Some(png)); + assert!(load(&bytes).is_ok(), "the preview doesn't get in the way of opening"); + // Files without one, legacy JSON and garbage have none. + assert_eq!(read_preview(Cursor::new(save(&d).unwrap())), None); + assert_eq!(read_preview(Cursor::new(b"{}".to_vec())), None); + assert_eq!(read_preview(Cursor::new(Vec::new())), None); + } + #[test] fn rejects_garbage_and_newer_versions() { assert!(load(b"nope").is_err()); diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 3720821d..3ce1dbef 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -710,9 +710,9 @@ pub fn start_screen(app: &mut DesignApp, ui: &mut egui::Ui) { let _ = app.run("file.newSample", json!({})); } }); - recent_files(app, ui); ui.add_space(20.0); community_card(app, ui); + recent_files(app, ui); ui.add_space(24.0); ui.label(crate::rtl::widget( ui, @@ -748,8 +748,9 @@ pub fn start_screen(app: &mut DesignApp, ui: &mut egui::Ui) { /// Recent files shown on the start screen (File › Open Recent has them all). const START_SCREEN_RECENT: usize = 8; -/// Start screen: the most recent files (name, then folder); a file that is gone is dimmed and says -/// so when clicked. Nothing when the list is empty. +/// Start screen: the most recent files as cards with the first page's picture (saved in the file) +/// and the name; the full path is the tooltip. A file that is gone is dimmed and says so when +/// clicked. Nothing when the list is empty. fn recent_files(app: &mut DesignApp, ui: &mut egui::Ui) { if app.session.prefs.recent_files.is_empty() { return; @@ -760,23 +761,69 @@ fn recent_files(app: &mut DesignApp, ui: &mut egui::Ui) { ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Recent files")).font(semibold(15.0)).color(t.text_strong), )); - ui.add_space(6.0); + ui.add_space(8.0); let rows: Vec<(usize, String)> = app.session.prefs.recent_files.iter().take(START_SCREEN_RECENT).cloned().enumerate().collect(); - for (i, path) in rows { - let p = std::path::Path::new(&path); - let there = p.is_file(); - let folder = p.parent().map(|d| d.display().to_string()).unwrap_or_default(); - let mut job = egui::text::LayoutJob::default(); - let name_color = if there { t.text_strong } else { t.text_dim }; - job.append(&crate::menus::recent_label(&path), 0.0, egui::TextFormat { font_id: semibold(13.0), color: name_color, ..Default::default() }); - job.append(&folder, 10.0, egui::TextFormat { font_id: egui::FontId::proportional(12.0), color: t.text_dim, ..Default::default() }); - let r = ui.add(egui::Button::new(job).frame(false).min_size(vec2(0.0, 24.0))).on_hover_text(&path); - if r.clicked() { - let _ = app.run("app.openRecent", json!({"index": i})); + let mut open = None; + ui.horizontal_wrapped(|ui| { + for (i, path) in rows { + let modified = std::fs::metadata(&path).ok().filter(|m| m.is_file()).map(|m| m.modified().ok()); + let there = modified.is_some(); + let (cr, resp) = ui.allocate_exact_size(vec2(140.0, 170.0), Sense::click()); + let resp = resp.on_hover_text(&path); + ui.painter().rect_filled(cr, 6.0, if resp.hovered() { t.hover } else { t.panel }); + let area = egui::Rect::from_min_size(cr.min + vec2(20.0, 12.0), vec2(100.0, 108.0)); + match modified.and_then(|m| recent_preview_texture(ui.ctx(), &path, m)) { + Some(tex) => { + let size = tex.size_vec2(); + let k = (area.width() / size.x).min(area.height() / size.y); + let pr = egui::Rect::from_center_size(area.center(), size * k); + ui.painter().image(tex.id(), pr, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), Color32::WHITE); + ui.painter().rect_stroke(pr, 0.0, Stroke::new(1.0, t.border), egui::StrokeKind::Outside); + } + // No picture (saved before files had one) or the file is gone: a blank page. + None => { + let pr = egui::Rect::from_center_size(area.center(), vec2(62.0, 80.0)); + ui.painter().rect_filled(pr, 0.0, if there { Color32::from_gray(245) } else { Color32::from_gray(110) }); + } + } + let color = if there { t.text_strong } else { t.text_dim }; + // The name without `.designcraft`, on up to two lines (broken after a space or dash). + let label = crate::menus::recent_label(&path); + let name = label.strip_suffix(".designcraft").unwrap_or(&label).to_string(); + let mut job = egui::text::LayoutJob::simple(name, semibold(12.0), color, cr.width() - 12.0); + job.wrap.max_rows = 2; + job.halign = egui::Align::Center; + let galley = ui.painter().layout_job(job); + ui.painter().galley(egui::pos2(cr.center().x, area.max.y + 8.0), galley, color); + if resp.clicked() { + open = Some(i); + } } + }); + if let Some(i) = open { + let _ = app.run("app.openRecent", json!({"index": i})); } } +/// The texture of a recent file's saved first-page picture, read again when the file changes. +fn recent_preview_texture(ctx: &egui::Context, path: &str, modified: Option) -> Option { + type Cached = (Option, Option); + let key = egui::Id::new(("recent_preview", path)); + if let Some((m, tex)) = ctx.data(|d| d.get_temp::(key)) + && m == modified + { + return tex; + } + let tex = designcraft_engine::cmd::recent_preview(path).and_then(|p| { + let size = [usize::try_from(p.width).ok()?, usize::try_from(p.height).ok()?]; + (size[0].checked_mul(size[1])?.checked_mul(4)? == p.rgba.len()).then(|| { + ctx.load_texture(format!("recent_preview:{path}"), egui::ColorImage::from_rgba_unmultiplied(size, &p.rgba), egui::TextureOptions::LINEAR) + }) + }); + ctx.data_mut(|d| d.insert_temp::(key, (modified, tex.clone()))); + tex +} + /// Start screen: the ArtCraft community (Discord first) and project links. fn community_card(app: &mut DesignApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index e617d59f..b9744f4b 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -3736,6 +3736,6 @@ mod tests { app.ui.dialog = None; let texts = start_screen_texts(&mut app); assert!(texts.iter().any(|t| t == "Recent files"), "{texts:?}"); - assert!(texts.iter().any(|t| t.starts_with("gone.designcraft")), "{texts:?}"); + assert!(texts.iter().any(|t| t == "gone"), "the card names it without `.designcraft`: {texts:?}"); } } diff --git a/docs/architecture.md b/docs/architecture.md index 39a9039d..38e46ca7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -25,7 +25,7 @@ lines of Rust, 1,122 tests (2026-10-10). Pure Rust, no C dependencies by default | L3 | `idml` | IDML import and export (designmap, spreads, parents, stories, styles, graphics, preferences; CJK and Arabic attributes) | 8.0k | | L3 | `epub` | EPUB 3 reflowable and fixed-layout export | 0.9k | | L3 | `textimport` | Word (.docx), RTF, Excel (.xlsx), Tagged Text import; text export | 1.8k | -| L3 | `format` | the native `.designcraft` zip (`document.json`, `assets/`, `meta.json`), versioned | 0.3k | +| L3 | `format` | the native `.designcraft` zip (`document.json`, `assets/`, `meta.json`, optional `preview.png`), versioned | 0.3k | | L4 | `tools` | tool state machines: pointer events and modifiers to commands and overlays; snapping and smart guides | 5.9k | | L5 | `engine` | `Session`: documents, history, selection, the command registry (446 commands in `cmd/`), compose cache, preflight, find/change, scripts, recovery, links | 29.3k | | L6 | `ui-egui` | the InDesign-style egui frontend: menus, Control panel, Tools, dock and panels, dialogs, canvas, theme tokens, i18n, the control channel | 33.4k |