feat(ui): Preview in the New Document window

A Preview checkbox beside Close and Create shows a document with the
window's settings in the workspace and rebuilds it as they change: size,
orientation, pages, facing pages, margins, columns, gutter, bleed and slug.
It is off by default and kept for the session. Fields that don't hold a
value yet, and sizes file.new refuses, keep the last preview.

The engine keeps the preview document (Session::preview_new_document,
end_new_document_preview): it replaces itself in its tab, takes no
Untitled number and is never written as recovery data. Create removes it
and makes the document with file.new, so it starts with a clean undo
history. Close, Escape, ui.dialog.cancel, turning Preview off or another
dialog replacing the window remove it and show the previously active
document again, unchanged.
This commit is contained in:
aiongg
2026-10-13 03:47:57 +00:00
parent 9c0006150e
commit 5da112a9cb
9 changed files with 249 additions and 3 deletions
+1
View File
@@ -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.
+90 -1
View File
@@ -145,13 +145,19 @@ fn find_preset(s: &Session, name: &str) -> Option<NewDocument> {
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<Value> {
/// 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<NewDocument> {
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<Value> {
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<Value> {
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<u64> {
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<u64> {
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<u64> {
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<Value> {
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)]
+4
View File
@@ -278,6 +278,9 @@ pub struct Session {
pub book: Option<cmd::book::Book>,
/// Content Collector conveyor: collected objects as snippets (name, bytes).
pub conveyor: Vec<(String, Vec<u8>)>,
/// 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<u64>)>,
}
impl Default for Session {
@@ -312,6 +315,7 @@ impl Session {
conveyor: Vec::new(),
book: None,
untitled: 0,
new_document_preview: None,
}
}
+2 -1
View File
@@ -350,7 +350,8 @@ pub fn save(s: &Session, dir: &Path) -> Result<usize> {
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;
}
+1
View File
@@ -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)
+10
View File
@@ -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<Value, String> {
}
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() {
+5
View File
@@ -246,6 +246,9 @@ pub struct UiState {
pub custom_workspaces: Vec<SavedWorkspace>,
/// File › New › Document › Recent: the settings of the last documents made, newest first.
pub recent_new_documents: Vec<designcraft_doc::build::NewDocument>,
/// 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();
}
+135
View File
@@ -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<Value> {
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<u64>) {
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();
+1 -1
View File
@@ -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.