Start screen: recent files show a picture of the first page (#706)

Save and Save a Copy put a picture of the first page in the .designcraft
zip as preview.png (320 px on the longer side). The start screen reads
only that entry and shows the last eight recent files as cards with the
picture and the name, below the community card. Files saved before have
a blank page until they are saved again.

The package fallback-font test writes its fixture without Save's preview:
drawing it before the document's fonts load pulled in a system font that
covers the test's characters (Unifont CSUR) ahead of the document's own.
This commit is contained in:
PiMinusMax
2026-10-13 17:01:57 +02:00
parent 1c82a5b892
commit 0fb3d677cc
8 changed files with 179 additions and 22 deletions
+1 -1
View File
@@ -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.
+67 -2
View File
@@ -37,7 +37,7 @@ pub fn specs() -> Vec<CommandSpec> {
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(&copy);
let bytes = to_bytes_with_preview(&copy, &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<u8> {
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<u8> {
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<Document> {
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<u8>,
}
/// 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<RecentPreview> {
#[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<Value> {
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(&copy.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();
+1 -1
View File
@@ -310,5 +310,5 @@ pub fn file_from(b: &[u8]) -> Result<designcraft_doc::Document> {
}
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;
+4 -1
View File
@@ -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);
+42
View File
@@ -5,6 +5,7 @@
//! - `assets/<id>.<ext>`: 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<Vec<u8>, 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<Vec<u8>, FormatError> {
let mut compact = doc.clone();
compact.compact_assets();
let doc = &compact;
@@ -60,6 +66,10 @@ pub fn save(doc: &Document) -> Result<Vec<u8>, 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<Vec<u8>, 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<R: Read + std::io::Seek>(file: R) -> Option<Vec<u8>> {
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<Document, FormatError> {
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());
+62 -15
View File
@@ -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<std::time::SystemTime>) -> Option<egui::TextureHandle> {
type Cached = (Option<std::time::SystemTime>, Option<egui::TextureHandle>);
let key = egui::Id::new(("recent_preview", path));
if let Some((m, tex)) = ctx.data(|d| d.get_temp::<Cached>(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::<Cached>(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());
+1 -1
View File
@@ -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:?}");
}
}
+1 -1
View File
@@ -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 |