diff --git a/ROADMAP.md b/ROADMAP.md index f832691af..5aca90625 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-13** · New Document has Preview (off by default, kept for the session): a document with the window's settings shows in the workspace and follows them as they change. Create keeps it with a clean undo history; Close removes it and shows the previous document again. The preview takes no Untitled number and writes no recovery data. No readiness percentage change. - **2026-10-12** · Window close and application/menu Quit ask about unsaved documents; later Cancel keeps every tab open, including earlier Don't Save choices. Approvals are rechecked after edits. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Each running app keeps recovery data in its own locked folder; current folder and lock ownership are checked before writes and cleanup. Failed isolation disables recovery with a warning instead of sharing document IDs. No readiness percentage change. - **2026-10-12** · Native engine saves and exports replace files atomically; failed writes retain the original, private staging preserves Unix permissions, and valid long filenames work. Desktop Services and CLI raster exports remain separate follow-ups. No readiness percentage change. diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 47ce669dc..d49ead237 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -145,13 +145,19 @@ fn find_preset(s: &Session, name: &str) -> Option { NewDocument::from_preset(name).or_else(|| s.prefs.document_presets.iter().find(|p| p.title.eq_ignore_ascii_case(name.trim())).cloned()) } -fn file_new(s: &mut Session, p: &Value) -> Result { +/// The settings `file.new` makes a document with for `p`: its preset, then the settings over it. +fn new_document_settings(s: &Session, p: &Value) -> Result { let mut nd = match str_param(p, "preset") { Some(name) => find_preset(s, name).ok_or_else(|| bad("file.new", format!("unknown preset `{name}`")))?, None => NewDocument::default(), }; apply_settings(&mut nd, p); check_size("file.new", &nd)?; + Ok(nd) +} + +fn file_new(s: &mut Session, p: &Value) -> Result { + let mut nd = new_document_settings(s, p)?; s.untitled += 1; nd.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| format!("Untitled-{}", s.untitled)); let d = Document::new(&nd); @@ -159,6 +165,64 @@ fn file_new(s: &mut Session, p: &Value) -> Result { Ok(json!({"index": i})) } +impl Session { + /// New Document's Preview: a document made as `file.new` would make it with `p`, shown while + /// the New Document window is open. It replaces the previous preview document in its tab and + /// takes no Untitled number, no undo step and no recovery entry. Settings `file.new` refuses + /// leave the previous preview as it was. Returns the preview document's uid. + pub fn preview_new_document(&mut self, p: &Value) -> Result { + let mut nd = new_document_settings(self, p)?; + nd.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| self.next_untitled_title()); + let st = DocState::new(Document::new(&nd), None); + let uid = st.uid; + let shown = self.new_document_preview.and_then(|(old, _)| self.docs.iter().position(|d| d.uid == old)); + let previous = match self.new_document_preview { + Some((_, previous)) => previous, + None => self.active().map(|d| d.uid), + }; + match shown.and_then(|i| self.docs.get_mut(i).map(|d| (i, d))) { + Some((i, d)) => { + let old = std::mem::replace(d, st); + #[cfg(not(target_arch = "wasm32"))] + if let Some(dir) = &self.recovery_dir { + crate::recovery::discard(dir, old.uid); + } + #[cfg(target_arch = "wasm32")] + let _ = old; + self.active = Some(i); + } + None => { + // Pushed without `add_document`: the document that was active stays as it is. + self.docs.push(st); + self.active = Some(self.docs.len() - 1); + } + } + self.new_document_preview = Some((uid, previous)); + Ok(uid) + } + + /// The uid of New Document's preview document, while one is shown. + pub fn new_document_preview(&self) -> Option { + self.new_document_preview.map(|(uid, _)| uid) + } + + /// Remove New Document's preview document and make the document that was active before it + /// active again. Returns the preview's uid. + pub fn end_new_document_preview(&mut self) -> Option { + let (uid, previous) = self.new_document_preview.take()?; + if let Some(i) = self.docs.iter().position(|d| d.uid == uid) { + #[cfg(not(target_arch = "wasm32"))] + if let Some(dir) = &self.recovery_dir { + crate::recovery::discard(dir, uid); + } + self.docs.remove(i); + } + let back = previous.and_then(|p| self.docs.iter().position(|d| d.uid == p)); + self.active = back.or_else(|| self.docs.len().checked_sub(1)); + Some(uid) + } +} + fn save_preset(s: &mut Session, p: &Value) -> Result { let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad("file.savePreset", "missing `name`"))?.to_string(); if name.chars().count() > MAX_PRESET_NAME { @@ -802,6 +866,31 @@ mod new_document_tests { assert_eq!(d.settings.bleed, [0.0; 4]); assert_eq!(d.settings.slug, [0.0, 1296.0, 0.0, 0.0]); } + + #[test] + fn a_new_document_preview_replaces_itself_and_leaves_the_session_as_it_was() { + let dir = std::env::temp_dir().join(format!("dc-new-doc-preview-{}", std::process::id())); + let mut s = Session::new(); + s.recovery_dir = Some(dir.clone()); + s.execute("file.new", &json!({})).unwrap(); + let first = s.doc().unwrap().uid; + let a = s.preview_new_document(&json!({"width": 300})).unwrap(); + assert_eq!((s.doc().unwrap().uid, s.doc().unwrap().doc.title.as_str()), (a, "Untitled-2")); + let b = s.preview_new_document(&json!({"width": 400, "columns": 3})).unwrap(); + assert_eq!(s.documents().len(), 2, "the preview replaces itself"); + assert!(s.preview_new_document(&json!({"width": 0})).is_err()); + let d = s.doc().unwrap(); + assert_eq!((d.uid, d.doc.settings.page_width, d.doc.page(0).unwrap().columns.count), (b, 400.0, 3), "a refused size keeps it"); + // Even edited, it never reaches the recovery folder. + s.execute("frame.create", &json!({"rect": [10, 10, 100, 100]})).unwrap(); + assert_eq!(crate::recovery::save(&s, &dir).unwrap(), 0); + assert_eq!(s.end_new_document_preview(), Some(b)); + let d = s.doc().unwrap(); + assert_eq!((s.documents().len(), d.uid, d.is_dirty(), d.history.undo.len()), (1, first, false, 0)); + s.execute("file.new", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.title, "Untitled-2", "the preview took no Untitled number"); + let _ = std::fs::remove_dir_all(&dir); + } } #[cfg(test)] diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 7bab07dad..d590dedbc 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -278,6 +278,9 @@ pub struct Session { pub book: Option, /// Content Collector conveyor: collected objects as snippets (name, bytes). pub conveyor: Vec<(String, Vec)>, + /// New Document's Preview: the preview document's uid and the uid of the document that was + /// active before it (see [`Session::preview_new_document`]). + pub(crate) new_document_preview: Option<(u64, Option)>, } impl Default for Session { @@ -312,6 +315,7 @@ impl Session { conveyor: Vec::new(), book: None, untitled: 0, + new_document_preview: None, } } diff --git a/crates/engine/src/recovery.rs b/crates/engine/src/recovery.rs index 2ab67ce44..c5d21dc84 100644 --- a/crates/engine/src/recovery.rs +++ b/crates/engine/src/recovery.rs @@ -350,7 +350,8 @@ pub fn save(s: &Session, dir: &Path) -> Result { let _ = created; let mut n = 0; for d in &s.docs { - if !d.is_dirty() { + // New Document's preview is not a document until Create makes it one. + if !d.is_dirty() || s.new_document_preview() == Some(d.uid) { discard(dir, d.uid); continue; } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 12df640da..d1a9f0a9e 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -302,6 +302,7 @@ pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) -> } "ui.dialog.confirm" => wrap(crate::dialogs::confirm(app)), "ui.dialog.cancel" => { + crate::new_document::end_preview(app); app.cancel_pdf_import(); app.ui.dialog = None; ok(Value::Null) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 162b68733..193c8a232 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1657,6 +1657,10 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { if cancel || (!popup_open && ui.input(|i| i.key_pressed(egui::Key::Escape))) { result = Some(false); } + if d.id == "newDocument" { + ui.add_space(8.0); + check(ui, &mut d, "preview", crate::i18n::tr(&app.ui.language, "Preview")); + } if d.id == "closeDocument" && ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Don't Save"))).clicked() { d.fields.insert("discard".into(), json!(true)); result = Some(true); @@ -1664,6 +1668,9 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { }); }); }); + if d.id == "newDocument" && result.is_none() { + crate::new_document::preview(app, &mut d); + } app.ui.dialog = Some(d); match result { Some(true) => { @@ -1671,6 +1678,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { } Some(false) => { app.ui.dialog = None; + crate::new_document::end_preview(app); app.cancel_pdf_import(); } None => {} @@ -1685,6 +1693,8 @@ pub fn confirm(app: &mut DesignApp) -> Result { } match d.id.as_str() { "newDocument" => { + // The preview goes; Create makes the document afresh, with no undo history. + crate::new_document::end_preview(app); let p = crate::new_document::params(&d); let r = app.run("file.new", p.clone()); if r.is_ok() { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 386c23790..6511e5892 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -246,6 +246,9 @@ pub struct UiState { pub custom_workspaces: Vec, /// File › New › Document › Recent: the settings of the last documents made, newest first. pub recent_new_documents: Vec, + /// File › New › Document › Preview, kept for the session. + #[serde(skip)] + pub new_document_preview: bool, /// Transform reference point (0..8, row-major; 0 = top-left). pub ref_point: u8, /// Align To target (`selection`, `keyObject`, `margins`, `page`, `spread`). @@ -344,6 +347,7 @@ impl Default for UiState { workspace: "Essentials".into(), custom_workspaces: Vec::new(), recent_new_documents: Vec::new(), + new_document_preview: false, ref_point: 0, align_to: "selection".into(), text_style_tab: 0, @@ -803,6 +807,7 @@ impl DesignApp { } self.last_time = now; self.drain_control(ctx); + new_document::end_stale_preview(self); if self.fonts_ready { self.drain_inbox(); } diff --git a/crates/ui-egui/src/new_document.rs b/crates/ui-egui/src/new_document.rs index b27e36b0c..c24cff67d 100644 --- a/crates/ui-egui/src/new_document.rs +++ b/crates/ui-egui/src/new_document.rs @@ -139,6 +139,69 @@ pub(crate) fn remember(app: &mut DesignApp, p: &Value) { recent.truncate(MAX_RECENT); } +/// The `file.new` params of the document Preview shows (set while it is shown). +const PREVIEWED: &str = "_previewed"; + +/// `file.new` params for Preview, or `None` while a field doesn't hold a value yet (empty, half +/// typed, negative): the preview then stays as it was. +fn preview_params(d: &Dialog) -> Option { + let measures = measure_keys().into_iter().all(|k| !d.fields.contains_key(&k) || measure(d, &k).is_some_and(|v| v >= 0.0)); + let counts = + ["pages", "startPage", "columns"].into_iter().all(|k| !d.fields.contains_key(k) || d.n(k).is_some_and(|v| v.is_finite() && v >= 1.0)); + (measures && counts).then(|| params(d)) +} + +/// Preview on: a document with the dialog's settings shows in the workspace, made again when a +/// setting changes. Settings `file.new` refuses (a zero width) keep the last one. Off: it goes. +pub(crate) fn preview(app: &mut DesignApp, d: &mut Dialog) { + let on = match d.fields.get("preview").and_then(Value::as_bool) { + Some(on) => on, + None => { + d.fields.insert("preview".into(), json!(app.ui.new_document_preview)); + app.ui.new_document_preview + } + }; + app.ui.new_document_preview = on; + if !on { + end_preview(app); + d.fields.remove(PREVIEWED); + return; + } + let Some(p) = preview_params(d) else { return }; + let shown = app.session.new_document_preview(); + if shown.is_some() && d.fields.get(PREVIEWED) == Some(&p) { + return; + } + if app.session.preview_new_document(&p).is_ok() { + forget_view(app, shown); + } + // Refused settings are not tried again until they change. + d.fields.insert(PREVIEWED.into(), p); +} + +/// Remove the preview document, if one is shown, and show the document that was active before. +pub(crate) fn end_preview(app: &mut DesignApp) { + let uid = app.session.end_new_document_preview(); + forget_view(app, uid); +} + +/// End a preview whose New Document window has gone or no longer previews (closed over the +/// control channel, or replaced by another dialog). +pub(crate) fn end_stale_preview(app: &mut DesignApp) { + let previewing = app.ui.dialog.as_ref().is_some_and(|d| d.id == "newDocument" && d.b("preview")); + if !previewing && app.session.new_document_preview().is_some() { + end_preview(app); + } +} + +/// Drop the views (one per pane) of a preview document that has gone. +fn forget_view(app: &mut DesignApp, uid: Option) { + if let Some(uid) = uid { + app.views.remove(&uid); + app.views.remove(&(uid ^ (1 << 63))); + } +} + /// Save the details as the document preset `name` (`file.savePreset`, which replaces one of that /// name) and show it on the Saved tab. fn save_preset(app: &mut DesignApp, d: &mut Dialog, name: &str) -> Result<(), String> { @@ -894,6 +957,78 @@ mod tests { assert!(h.state().app.ui.dialog.is_none()); } + fn set(app: &mut DesignApp, ctx: &egui::Context, fields: &[(&str, Value)]) { + let d = app.ui.dialog.as_mut().unwrap(); + for (k, v) in fields { + d.fields.insert((*k).into(), v.clone()); + } + frame(app, ctx, vec![]); + } + + fn shown(app: &DesignApp) -> (usize, f64, u32) { + let doc = &app.session.active().unwrap().doc; + (app.session.documents().len(), doc.settings.page_width, doc.page(0).unwrap().columns.count) + } + + #[test] + fn preview_shows_the_settings_live_and_close_restores_the_workspace() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"title": "A"})).unwrap(); + app.run("file.new", json!({"title": "B"})).unwrap(); + app.session.set_active(0); + let a = app.session.active().unwrap().clone(); + app.run("app.newDocumentDialog", json!({})).unwrap(); + let ctx = context(); + settle(&mut app, &ctx); + assert_eq!(app.session.documents().len(), 2, "Preview starts off"); + set(&mut app, &ctx, &[("preview", json!(true))]); + assert_eq!(shown(&app), (3, 612.0, 1), "a third tab shows the dialog's settings"); + set(&mut app, &ctx, &[("width", json!("300 pt")), ("columns", json!("3"))]); + assert_eq!(shown(&app), (3, 300.0, 3), "and follows them"); + // Half-typed and refused values keep the last preview. + for width in ["", "-5", "0"] { + set(&mut app, &ctx, &[("width", json!(width))]); + assert_eq!(shown(&app), (3, 300.0, 3), "{width:?}"); + } + // Escape closes the window: the preview goes and A is shown again, untouched. + frame( + &mut app, + &ctx, + vec![egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }], + ); + assert!(app.ui.dialog.is_none()); + let back = app.session.active().unwrap(); + assert_eq!(app.session.documents().len(), 2); + assert_eq!((back.uid, back.revision, back.history.undo.len(), back.is_dirty()), (a.uid, a.revision, 0, false)); + // Preview is remembered; turning it off removes the preview, as does closing the window + // any other way. + app.run("app.newDocumentDialog", json!({})).unwrap(); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.session.documents().len(), 3); + set(&mut app, &ctx, &[("preview", json!(false))]); + assert_eq!((app.session.documents().len(), app.session.active().unwrap().uid), (2, a.uid)); + set(&mut app, &ctx, &[("preview", json!(true))]); + app.ui.dialog = None; + end_stale_preview(&mut app); + assert_eq!((app.session.documents().len(), app.session.active().unwrap().uid), (2, a.uid)); + } + + #[test] + fn create_keeps_the_previewed_document_with_the_final_settings() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"title": "A"})).unwrap(); + app.run("app.newDocumentDialog", json!({})).unwrap(); + let ctx = context(); + set(&mut app, &ctx, &[("preview", json!(true)), ("width", json!("400 pt"))]); + set(&mut app, &ctx, &[("pages", json!("4")), ("facingPages", json!(false))]); + crate::dialogs::confirm(&mut app).unwrap(); + let st = app.session.active().unwrap(); + assert_eq!((app.session.documents().len(), st.doc.settings.page_width, st.doc.page_count()), (2, 400.0, 4)); + assert!(!st.doc.settings.facing_pages); + assert_eq!((st.doc.title.as_str(), st.history.undo.len(), st.is_dirty()), ("Untitled-2", 0, false)); + assert_eq!(app.session.new_document_preview(), None); + } + #[test] fn presets_are_saved_by_name_and_recent_documents_once() { let mut app = app(); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d9fdb7de6..345ec5ef7 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -420,7 +420,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - OpenType menu (Character panel): discretionary ligatures, fractions, ordinal, swash, titling, contextual alternates, slashed zero, the four figure styles and stylistic sets 1–20; `type.openType`; mapped to IDML's OTF attributes. - Add Anchor Point (=), Delete Anchor Point (-) and Convert Direction Point (⇧C) tools (click toggles smooth/corner, drag pulls out handles), with `path.addAnchor`, `path.deleteAnchor`, `path.convertAnchor`. - Gradients: the Gradient Swatch tool (G) drags a gradient's start and end across objects (Shift: 45°); the Gradient panel sets type, angle, reverse and edits stops on a ramp (drag, add, drag off to remove, location, colour). Edited gradients are unnamed swatches; `object.gradient`; the vector round-trips through IDML. -- New Document (⌘N) as in InDesign: Recent, Saved, Print, Web and Mobile tabs of page cards (double-click creates) beside the preset's details: document name, size with arrows, units, orientation, pages, start page #, facing pages, primary text frame, columns, margins, bleed and slug per edge (linked or not); Save Document Preset (`file.savePreset`, `file.deletePreset`; saved presets are kept with the preferences, listed by `file.presets` and made by `file.new {preset: name}`); `file.new` takes `intent`, `units`, `startPage` and per-edge `bleed`/`slug`. +- New Document (⌘N) as in InDesign: Recent, Saved, Print, Web and Mobile tabs of page cards (double-click creates) beside the preset's details: document name, size with arrows, units, orientation, pages, start page #, facing pages, primary text frame, columns, margins, bleed and slug per edge (linked or not); Save Document Preset (`file.savePreset`, `file.deletePreset`; saved presets are kept with the preferences, listed by `file.presets` and made by `file.new {preset: name}`); `file.new` takes `intent`, `units`, `startPage` and per-edge `bleed`/`slug`. Preview shows a document with the window's settings in the workspace while it is open; Create keeps it, Close removes it. - Document Setup (⌥⌘P): intent, number of pages, start page # (an even start begins with a left page), facing pages, size presets and orientation, bleed and slug per edge; `layout.documentSetup {}` reports the setup. - Stroke panel: cap, join, miter limit, alignment, type, Start/End arrowheads (12 kinds, drawn on screen and in PDF; the path is shortened under pointed heads) and gap colour under dashes and dots. - Preferences (⌘K: General, Type, Units & Increments, Grids, Guides & Pasteboard, Display Performance) backed by `prefs.set` (application) and `document.preferences` (units, increments, grids — undoable, saved with the document; right-click a ruler to pick its units, the horizontal and vertical rulers each have their own); scaling applies to content and scales stroke weights (Include Stroke Weight), and X/Y/W/H measure the stroke's outer edge (Dimensions Include Stroke Weight). Quick Apply (⌘Return) finds paragraph, character and object styles as well as commands.