From 7fdca7387bda750351d367db1f9aead57b74e52c Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 13 Oct 2026 04:06:10 +0000 Subject: [PATCH] feat(ui): New Document Preview follows side paths and keeps Create in step - A preview that a menu or script edits or saves while the window is open becomes a document of its own: Close keeps it and gives it its Untitled number. document.list and the control inspect mark the preview. - Close shows the previously active document again only if the preview was still active; a document opened meanwhile stays active. - Create refuses invalid fields with a status beside the buttons, keeping the window and the preview. When the settings are the previewed ones, the preview document becomes the new document; otherwise file.new makes it, and a failure puts the window back with the error. - The preview is rebuilt once the fields have stayed the same for 200 ms, and refused settings are not retried until they change. - No recovery discard for preview documents, which are never written. - The window's key is _previewedDoc, distinct from the interaction preview key, and ending the preview is a no-op without one. --- ROADMAP.md | 2 +- crates/engine/src/cmd/file.rs | 87 +++++++---- crates/engine/src/cmd/inspect.rs | 15 +- crates/ui-egui/src/control.rs | 13 +- crates/ui-egui/src/dialogs.rs | 17 +- crates/ui-egui/src/new_document.rs | 241 ++++++++++++++++++++++++----- 6 files changed, 299 insertions(+), 76 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 5aca90625..51a6ec95e 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** · 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-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 once typing pauses. Create keeps it with a clean undo history and refuses invalid fields; Close removes it and shows the previous document again. The preview takes no Untitled number and writes no recovery data; one edited by a menu or script stays open as a document. 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 d49ead237..650c775d1 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -172,23 +172,23 @@ impl Session { /// 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))) { + let shown = self.new_document_preview.and_then(|(old, _)| self.docs.iter().position(|d| d.uid == old)); + // A preview edited or saved by a menu or script while the window is open has become a + // document of its own: it stays, and the preview gets a tab of its own. + let replace = shown.filter(|&i| self.docs.get(i).is_some_and(|d| !d.is_dirty() && d.path.is_none())); + if replace.is_none() && shown.is_some() { + self.untitled += 1; + } + 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; + match replace.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; + *d = st; self.active = Some(i); } None => { @@ -206,21 +206,42 @@ impl Session { 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. + /// End New Document's preview. Its document goes, and if it was still the active document, + /// the one that was active before it is active again. A preview that a menu or script has + /// edited or saved stays open as a document, with an Untitled number. Returns the uid of the + /// document removed. Without a preview it does nothing. 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 i = self.docs.iter().position(|d| d.uid == uid)?; + let d = self.docs.get(i)?; + if d.is_dirty() || d.path.is_some() { + self.untitled += 1; + return None; } - 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)); + self.docs.remove(i); + self.active = match self.active { + Some(a) if a == i => previous.and_then(|p| self.docs.iter().position(|d| d.uid == p)).or_else(|| self.docs.len().checked_sub(1)), + Some(a) if a > i => Some(a - 1), + a => a, + }; Some(uid) } + + /// Create in the New Document window, with the settings the preview shows: the preview + /// document becomes the new document, as `file.new` would make it (the next Untitled number, + /// no undo history). Returns its index, or `None` when there is no unedited preview to keep. + pub fn keep_new_document_preview(&mut self) -> Option { + let (uid, _) = self.new_document_preview?; + let i = self.docs.iter().position(|d| d.uid == uid && !d.is_dirty() && d.path.is_none())?; + self.new_document_preview = None; + self.untitled += 1; + if let Some(d) = self.docs.get_mut(i) { + d.history.undo.clear(); + d.history.redo.clear(); + } + self.active = Some(i); + Some(i) + } } fn save_preset(s: &mut Session, p: &Value) -> Result { @@ -881,14 +902,28 @@ mod new_document_tests { 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); + let listed = s.execute("document.list", &json!({})).unwrap(); + assert_eq!((listed[0].get("preview"), &listed[1]["preview"]), (None, &json!(true))); 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)); + assert_eq!(s.end_new_document_preview(), None, "without a preview it does nothing"); + // Edited by a script while shown, it writes no recovery data while it is a preview, and + // stays as a document when the window closes, with its Untitled number. + let c = s.preview_new_document(&json!({})).unwrap(); + 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(), None); + assert_eq!((s.documents().len(), s.doc().unwrap().uid, s.doc().unwrap().doc.title.as_str()), (2, c, "Untitled-2")); s.execute("file.new", &json!({})).unwrap(); - assert_eq!(s.doc().unwrap().doc.title, "Untitled-2", "the preview took no Untitled number"); + assert_eq!(s.doc().unwrap().doc.title, "Untitled-3"); + // Create with the previewed settings keeps the preview as the document. + let d = s.preview_new_document(&json!({"pages": 2})).unwrap(); + assert_eq!(s.keep_new_document_preview(), Some(3)); + let st = s.doc().unwrap(); + assert_eq!((st.uid, st.doc.title.as_str(), s.new_document_preview()), (d, "Untitled-4", None)); + s.execute("file.new", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.title, "Untitled-5"); let _ = std::fs::remove_dir_all(&dir); } } diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs index f3c273368..89b0564c4 100644 --- a/crates/engine/src/cmd/inspect.rs +++ b/crates/engine/src/cmd/inspect.rs @@ -12,7 +12,20 @@ pub fn specs() -> Vec { cmd!(query "app.links", "Community & Project Links", [], None, "{} → {discord, website, appPage, github, issues} (ArtCraft Discord, website, DesignCraft page and repository)", always, |_, _| Ok(crate::links::all())), cmd!(query "document.list", "List Documents", [], None, "{}", always, |s, _| { - Ok(json!(s.documents().iter().enumerate().map(|(i, d)| json!({"index": i, "title": d.title(), "dirty": d.is_dirty(), "active": Some(i) == s.active_index()})).collect::>())) + let preview = s.new_document_preview(); + Ok(json!(s + .documents() + .iter() + .enumerate() + .map(|(i, d)| { + let mut v = json!({"index": i, "title": d.title(), "dirty": d.is_dirty(), "active": Some(i) == s.active_index()}); + // New Document's preview, not a document yet. + if preview == Some(d.uid) { + v["preview"] = json!(true); + } + v + }) + .collect::>())) }), cmd!(noundo "tool.select", "Select Tool", [], None, "{tool: selection|directSelection|type|rectangleFrame|…}", always, |s, p| { let t = p.get("tool").and_then(Value::as_str).unwrap_or("selection"); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index d1a9f0a9e..7f008fb25 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -76,7 +76,18 @@ pub fn inspect(app: &DesignApp, ctx: &egui::Context) -> Value { "view": app.view().map(|v| serde_json::to_value(v).unwrap_or_default()), "canvasRect": app.canvas_rect.map(|c| json!([c.left(), c.top(), c.width(), c.height()])), "window": [r.width(), r.height()], - "documents": app.session.documents().iter().map(|d| json!({"title": d.title(), "dirty": d.is_dirty()})).collect::>(), + "documents": app + .session + .documents() + .iter() + .map(|d| { + let mut v = json!({"title": d.title(), "dirty": d.is_dirty()}); + if app.session.new_document_preview() == Some(d.uid) { + v["preview"] = json!(true); + } + v + }) + .collect::>(), "currentPage": crate::canvas::current_page(app), "dialog": app.ui.dialog.as_ref().map(|d| serde_json::to_value(d).unwrap_or_default()), "activeDocument": app.session.active_index(), diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 193c8a232..f4592d2eb 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1660,6 +1660,10 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { if d.id == "newDocument" { ui.add_space(8.0); check(ui, &mut d, "preview", crate::i18n::tr(&app.ui.language, "Preview")); + if let Some(st) = d.fields.get("status").and_then(Value::as_str) { + ui.add_space(8.0); + crate::rtl::label(ui, egui::RichText::new(st).color(crate::theme::Tokens::get(ui.ctx()).text_dim)); + } } 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)); @@ -1669,7 +1673,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { }); }); if d.id == "newDocument" && result.is_none() { - crate::new_document::preview(app, &mut d); + crate::new_document::preview(app, &mut d, ctx); } app.ui.dialog = Some(d); match result { @@ -1692,16 +1696,7 @@ pub fn confirm(app: &mut DesignApp) -> Result { app.cancel_pdf_import(); } 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() { - crate::new_document::remember(app, &p); - } - r - } + "newDocument" => crate::new_document::create(app, d), "frameSize" => { let x = d.fields.get("x").and_then(Value::as_f64).unwrap_or(0.0); let y = d.fields.get("y").and_then(Value::as_f64).unwrap_or(0.0); diff --git a/crates/ui-egui/src/new_document.rs b/crates/ui-egui/src/new_document.rs index c24cff67d..879115daf 100644 --- a/crates/ui-egui/src/new_document.rs +++ b/crates/ui-egui/src/new_document.rs @@ -140,20 +140,41 @@ pub(crate) fn remember(app: &mut DesignApp, p: &Value) { } /// The `file.new` params of the document Preview shows (set while it is shown). -const PREVIEWED: &str = "_previewed"; +const PREVIEWED: &str = "_previewedDoc"; +/// The params Preview last tried, made or refused (refused ones are not tried again). +const TRIED: &str = "_previewTried"; +/// The params Create was refused with: the reason shows until a field changes. +const REFUSED: &str = "_refusedParams"; +/// Settings changed since the preview was made, and when they last changed (egui seconds). +const PENDING: &str = "_previewPending"; +/// How long the settings stay unchanged before the preview is made again. +const SETTLE_SECONDS: f64 = 0.2; -/// `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)) +/// The text of the first field that doesn't hold a value yet (empty, half typed, negative). +fn invalid_field(d: &Dialog) -> Option { + let bad_measure = measure_keys().into_iter().find(|k| d.fields.contains_key(k) && !measure(d, k).is_some_and(|v| v >= 0.0)); + let bad_count = || { + ["pages", "startPage", "columns"] + .into_iter() + .map(String::from) + .find(|k| d.fields.contains_key(k) && !d.n(k).is_some_and(|v| v.is_finite() && v >= 1.0)) + }; + bad_measure.or_else(bad_count).map(|k| d.s(&k)) } -/// 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) { +/// `file.new` params for Preview and Create, or `None` while a field doesn't hold a value yet. +fn preview_params(d: &Dialog) -> Option { + invalid_field(d).is_none().then(|| params(d)) +} + +/// Preview on: a document with the dialog's settings shows in the workspace. It is made at once, +/// then again once changed settings have stayed the same for [`SETTLE_SECONDS`]. Settings that +/// aren't values yet, or that `file.new` refuses (a zero width), keep the last one. Off: it goes. +pub(crate) fn preview(app: &mut DesignApp, d: &mut Dialog, ctx: &egui::Context) { + if d.fields.get(REFUSED).is_some_and(|r| *r != params(d)) { + d.fields.remove(REFUSED); + d.fields.remove("status"); + } let on = match d.fields.get("preview").and_then(Value::as_bool) { Some(on) => on, None => { @@ -164,22 +185,82 @@ pub(crate) fn preview(app: &mut DesignApp, d: &mut Dialog) { app.ui.new_document_preview = on; if !on { end_preview(app); - d.fields.remove(PREVIEWED); + for k in [PREVIEWED, TRIED, PENDING] { + d.fields.remove(k); + } 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) { + // Refused settings are not tried again until they change, and settings back to the shown + // ones keep that preview. + if d.fields.get(PREVIEWED) == Some(&p) && app.session.new_document_preview().is_some() { + d.fields.insert(TRIED.into(), p.clone()); + } + if d.fields.get(TRIED) == Some(&p) { + d.fields.remove(PENDING); return; } + let now = ctx.input(|i| i.time); + if d.fields.contains_key(TRIED) { + let since = match d.fields.get(PENDING) { + Some(pending) if pending.get("params") == Some(&p) => pending.get("since").and_then(Value::as_f64).unwrap_or(now), + _ => { + d.fields.insert(PENDING.into(), json!({"params": p, "since": now})); + now + } + }; + let wait = SETTLE_SECONDS - (now - since); + if wait > 0.0 { + ctx.request_repaint_after(std::time::Duration::from_secs_f64(wait)); + return; + } + } + let shown = app.session.new_document_preview(); if app.session.preview_new_document(&p).is_ok() { forget_view(app, shown); + d.fields.insert(PREVIEWED.into(), p.clone()); } - // Refused settings are not tried again until they change. - d.fields.insert(PREVIEWED.into(), p); + d.fields.remove(PENDING); + d.fields.insert(TRIED.into(), p); } -/// Remove the preview document, if one is shown, and show the document that was active before. +/// Create: refused while a field doesn't hold a value yet. With the settings the preview shows, +/// the preview document becomes the new document; otherwise `file.new` makes it and the preview +/// goes. A refusal leaves the window and the preview open, with the reason. +pub(crate) fn create(app: &mut DesignApp, mut d: Dialog) -> Result { + let refused = |app: &mut DesignApp, mut d: Dialog, e: String| { + app.status(e.clone()); + d.fields.insert("status".into(), json!(e)); + d.fields.insert(REFUSED.into(), params(&d)); + app.ui.dialog = Some(d); + Err(e) + }; + if let Some(text) = invalid_field(&d) { + let e = format!("{}: {}", crate::i18n::tr(&app.ui.language, "Invalid value"), text.trim()); + return refused(app, d, e); + } + let p = params(&d); + let shows = d.fields.get(PREVIEWED) == Some(&p) && app.session.new_document_preview().is_some(); + let kept = if d.b("preview") && shows { app.session.keep_new_document_preview() } else { None }; + let r = match kept { + Some(i) => Ok(json!({"index": i})), + None => app.run("file.new", p.clone()), + }; + match r { + Ok(v) => { + // The new document is active, so the preview's document goes without coming back. + end_preview(app); + remember(app, &p); + Ok(v) + } + Err(e) => { + d.fields.remove(PENDING); + refused(app, d, e) + } + } +} + +/// Remove the preview document, if one is shown. Without a preview it does nothing. pub(crate) fn end_preview(app: &mut DesignApp) { let uid = app.session.end_new_document_preview(); forget_view(app, uid); @@ -196,7 +277,7 @@ pub(crate) fn end_stale_preview(app: &mut DesignApp) { /// 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 { + if let Some(uid) = uid.filter(|uid| app.session.documents().iter().all(|d| d.uid != *uid)) { app.views.remove(&uid); app.views.remove(&(uid ^ (1 << 63))); } @@ -806,9 +887,14 @@ mod tests { /// One frame of the open dialog → the painted texts and where. fn frame(app: &mut DesignApp, ctx: &egui::Context, events: Vec) -> Vec<(String, Rect)> { + frame_after(app, ctx, events, 1.0 / 60.0) + } + + /// One frame `dt` seconds after the last. + fn frame_after(app: &mut DesignApp, ctx: &egui::Context, events: Vec, dt: f64) -> Vec<(String, Rect)> { let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(egui::Pos2::ZERO, vec2(1440.0, 900.0))), - time: Some(ctx.input(|i| i.time) + 1.0 / 60.0), + time: Some(ctx.input(|i| i.time) + dt), events, ..Default::default() }; @@ -957,12 +1043,14 @@ mod tests { assert!(h.state().app.ui.dialog.is_none()); } + /// Set dialog fields, then let the preview settle. 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![]); + frame_after(app, ctx, vec![], 2.0 * SETTLE_SECONDS); } fn shown(app: &DesignApp) -> (usize, f64, u32) { @@ -970,49 +1058,110 @@ mod tests { (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() { + fn escape(app: &mut DesignApp, ctx: &egui::Context) { + frame( + 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()); + } + + /// Documents A and B with A active, and the New Document window open over them. + fn two_documents() -> (DesignApp, egui::Context, designcraft_engine::DocState) { 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(); + (app, context(), a) + } + + #[test] + fn preview_shows_the_settings_live_and_close_restores_the_workspace() { + let (mut app, ctx, a) = two_documents(); 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"); + let uid = app.session.new_document_preview(); + settle(&mut app, &ctx); + assert_eq!(app.session.new_document_preview(), uid, "unchanged settings keep the same preview"); + // A change waits for the typing to stop. + app.ui.dialog.as_mut().unwrap().fields.insert("width".into(), json!("300 pt")); + frame(&mut app, &ctx, vec![]); + assert_eq!(shown(&app).1, 612.0); + set(&mut app, &ctx, &[("columns", json!("3"))]); + assert_eq!(shown(&app), (3, 300.0, 3), "and then 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()); + escape(&mut app, &ctx); 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. + // Preview is remembered; turning it off removes the preview. 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)); + // `ui.dialog.cancel` removes it too. set(&mut app, &ctx, &[("preview", json!(true))]); - app.ui.dialog = None; - end_stale_preview(&mut app); + assert_eq!(app.session.documents().len(), 3); + let (req, _) = crate::control::ControlRequest::new("ui.dialog.cancel", json!({})); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + crate::control::handle(&mut app, ui.ctx(), &req); + }); + out.textures_delta.clear(); assert_eq!((app.session.documents().len(), app.session.active().unwrap().uid), (2, a.uid)); } + #[test] + fn the_update_loop_ends_a_preview_whose_window_has_gone() { + let (mut app, ctx, a) = two_documents(); + set(&mut app, &ctx, &[("preview", json!(true))]); + assert_eq!(app.session.documents().len(), 3); + app.ui.dialog = None; + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| app.logic(ui.ctx())); + out.textures_delta.clear(); + assert_eq!((app.session.documents().len(), app.session.active().unwrap().uid), (2, a.uid)); + } + + #[test] + fn closing_leaves_a_document_activated_or_edited_meanwhile() { + let (mut app, ctx, _) = two_documents(); + set(&mut app, &ctx, &[("preview", json!(true))]); + // A file opened (here: made) by a script while the window is open stays active. + app.run("file.new", json!({"title": "Opened"})).unwrap(); + escape(&mut app, &ctx); + let titles: Vec = app.session.documents().iter().map(|d| d.title()).collect(); + assert_eq!((titles, app.session.active().unwrap().title()), (vec!["A".into(), "B".into(), "Opened".into()], "Opened".into())); + // A preview edited by a menu or script is kept as a document, with its Untitled number. + app.run("app.newDocumentDialog", json!({})).unwrap(); + frame(&mut app, &ctx, vec![]); + let preview = app.session.new_document_preview(); + app.run("frame.create", json!({"rect": [10, 10, 100, 100]})).unwrap(); + escape(&mut app, &ctx); + let st = app.session.active().unwrap(); + assert_eq!((app.session.documents().len(), Some(st.uid), st.is_dirty()), (4, preview, true)); + assert_ne!(st.doc.title, app.session.next_untitled_title()); + } + + #[test] + fn closing_the_start_screen_preview_leaves_no_document() { + let mut app = app(); + let ctx = context(); + set(&mut app, &ctx, &[("preview", json!(true))]); + assert_eq!(app.session.documents().len(), 1); + escape(&mut app, &ctx); + assert!(app.session.documents().is_empty() && app.session.active().is_none()); + } + #[test] fn create_keeps_the_previewed_document_with_the_final_settings() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); @@ -1021,12 +1170,32 @@ mod tests { let ctx = context(); set(&mut app, &ctx, &[("preview", json!(true)), ("width", json!("400 pt"))]); set(&mut app, &ctx, &[("pages", json!("4")), ("facingPages", json!(false))]); + let preview = app.session.new_document_preview(); + // An invalid field refuses Create: the window and the preview stay. + set(&mut app, &ctx, &[("pages", json!(""))]); + assert!(crate::dialogs::confirm(&mut app).is_err()); + assert!(app.ui.dialog.as_ref().unwrap().s("status").contains("Invalid value")); + assert_eq!((app.session.documents().len(), app.session.new_document_preview()), (2, preview)); + // A size file.new refuses also keeps them. + set(&mut app, &ctx, &[("pages", json!("4")), ("width", json!("0"))]); + assert!(crate::dialogs::confirm(&mut app).is_err()); + assert_eq!((app.session.documents().len(), app.session.new_document_preview()), (2, preview)); + // With the previewed settings, the preview becomes the document. + set(&mut app, &ctx, &[("width", json!("400 pt"))]); 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!(!st.doc.settings.facing_pages && app.ui.dialog.is_none()); + assert_eq!((Some(st.uid), st.doc.title.as_str(), st.history.undo.len(), st.is_dirty()), (preview, "Untitled-2", 0, false)); assert_eq!(app.session.new_document_preview(), None); + // Settings changed just before Create (the preview not made yet) make a new document. + app.run("app.newDocumentDialog", json!({})).unwrap(); + set(&mut app, &ctx, &[]); + app.ui.dialog.as_mut().unwrap().fields.insert("pages".into(), json!("2")); + frame(&mut app, &ctx, vec![]); + crate::dialogs::confirm(&mut app).unwrap(); + let st = app.session.active().unwrap(); + assert_eq!((app.session.documents().len(), st.doc.page_count(), st.doc.title.as_str()), (3, 2, "Untitled-3")); } #[test]