From d020b4e85d89ddf120933107a24b23b33036c8a0 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:31:24 -0400 Subject: [PATCH] fix(ui): preserve documents when quitting is cancelled Prompt for unsaved documents on window close and application/menu quit. Defer discarding documents until all approvals are current, retaining every tab when a later decision cancels quitting. Refs #544. Cancelable macOS Dock Quit remains separate. Co-authored-by: aiongg Co-authored-by: skylar taylor-barrick --- ROADMAP.md | 3 +- apps/designcraft/src/native_menu.rs | 5 +- crates/ui-egui/src/control.rs | 2 + crates/ui-egui/src/dialogs.rs | 3 + crates/ui-egui/src/i18n.rs | 1 + crates/ui-egui/src/i18n/ar.rs | 1 + crates/ui-egui/src/i18n/it.rs | 1 + crates/ui-egui/src/i18n/ja.rs | 1 + crates/ui-egui/src/i18n/pt_br.rs | 1 + crates/ui-egui/src/i18n/uk.rs | 1 + crates/ui-egui/src/lib.rs | 475 ++++++++++++++++++++++++++++ crates/ui-egui/src/menus.rs | 5 + docs/control-protocol.md | 3 +- docs/target-app-parity.md | 1 + docs/ui-parity.md | 2 +- 15 files changed, 501 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 15c9af8d..4b7163c7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: pre-alpha** · next: alpha, ~0% and ~15–25 h away; beta ~28 points and ~420–750 h beyond that (the core-workflow gate, not the percentage: ready for real work is already ~47%) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (isolated recovery for each running app) · **Target:** Adobe InDesign 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (window and menu quit confirmation) · **Target:** Adobe InDesign 2026 DesignCraft aims at full Adobe InDesign parity, and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web. @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **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. - **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change. diff --git a/apps/designcraft/src/native_menu.rs b/apps/designcraft/src/native_menu.rs index 8bf75b15..f51db197 100644 --- a/apps/designcraft/src/native_menu.rs +++ b/apps/designcraft/src/native_menu.rs @@ -40,6 +40,8 @@ impl NativeMenu { // Our own About splash (with the community links) instead of the stock panel. let about = MenuItem::with_id("dc-about", "About DesignCraft", true, None); let discord = MenuItem::with_id("dc-discord", "Join the ArtCraft Discord…", true, None); + // Not the predefined Quit: it terminates the app at once, past the unsaved-changes prompt. + let quit = MenuItem::with_id("dc-quit", designcraft_ui_egui::i18n::tr(&app.ui.language, "Quit DesignCraft"), true, accel("Cmd+Q")); let _ = app_menu.append_items(&[ &about, &discord, @@ -50,10 +52,11 @@ impl NativeMenu { &PredefinedMenuItem::hide_others(None), &PredefinedMenuItem::show_all(None), &PredefinedMenuItem::separator(), - &PredefinedMenuItem::quit(None), + &quit, ]); items.insert("dc-about".into(), ("help.about".into(), serde_json::Value::Null, Handle::Plain(about))); items.insert("dc-discord".into(), ("help.discord".into(), serde_json::Value::Null, Handle::Plain(discord))); + items.insert("dc-quit".into(), ("app.quit".into(), serde_json::Value::Null, Handle::Plain(quit))); let _ = menu.append(&app_menu); for (title, entries) in menus::menu_tree() { let sub = Submenu::new(designcraft_ui_egui::i18n::tr(&app.ui.language, title), true); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index f0d34a7b..12df640d 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -353,6 +353,8 @@ pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) -> "app.save" => wrap(app.run("file.save", json!({"path": s("path")}))), "app.export" => wrap(app.run("app.exportPng", p.clone())), "app.quit" => { + // Automation must not wait on a prompt: quit without asking about unsaved changes. + app.quit_without_asking = true; ctx.send_viewport_cmd(egui::ViewportCommand::Close); ok(Value::Null) } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 2ab7a3ba..14d2552e 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2078,6 +2078,9 @@ pub fn confirm(app: &mut DesignApp) -> Result { "closeDocument" => { // The document may have moved in the tab row (or gone) while the dialog was open. let uid = d.fields.get("uid").and_then(Value::as_u64); + if d.b("quitting") { + return app.confirm_quit_document(uid.ok_or("Missing document in quit confirmation")?, d.b("discard")); + } let index = |app: &DesignApp| app.session.documents().iter().position(|doc| Some(doc.uid) == uid); let Some(i) = index(app) else { return Ok(Value::Null) }; if !d.b("discard") { diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index e5e8a8d0..5976d7d0 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -699,6 +699,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ), ("for", ["for", "pour", "por", "に対して", "行"]), ("About DesignCraft", ["About DesignCraft", "About DesignCraft", "About DesignCraft", "About DesignCraft", "关于 DesignCraft"]), + ("Quit DesignCraft", ["DesignCraft beenden", "Quitter DesignCraft", "Salir de DesignCraft", "DesignCraft を終了", "退出 DesignCraft"]), ("Accept All Changes", ["Accept All Changes", "Accept All Changes", "Accept All Changes", "Accept All Changes", "接受所有更改"]), ("Activate Document", ["Activate Document", "Activate Document", "Activate Document", "Activate Document", "激活文档"]), ("Add Anchor Point", ["Add Anchor Point", "Add Anchor Point", "Add Anchor Point", "Add Anchor Point", "添加锚点"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 10e393cd..c3373486 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -149,6 +149,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Bookmarks", "إشارات مرجعية"), ("Show All Menu Items", "إظهار كل عناصر القائمة"), ("About DesignCraft", "حول DesignCraft"), + ("Quit DesignCraft", "إنهاء DesignCraft"), ("Welcome to DesignCraft", "مرحباً بك في DesignCraft"), ("Page layout for print and screen — fast, open and scriptable.", "تخطيط الصفحات للطباعة والشاشة — سريع ومفتوح وقابل للبرمجة."), (" New file ", " ملف جديد "), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index c91afd6b..34766cf2 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -396,6 +396,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Spreads of up to 10 pages", "Doppie pagine con al massimo 10 pagine"), ("for", "per"), ("About DesignCraft", "Informazioni su DesignCraft"), + ("Quit DesignCraft", "Esci da DesignCraft"), ("Accept All Changes", "Accetta tutte le modifiche"), ("Activate Document", "Attiva documento"), ("Add Anchor Point", "Aggiungi punto di ancoraggio"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index be6e2c4d..de57db5e 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -398,6 +398,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Spreads of up to 10 pages", "最大10ページのスプレッド"), ("for", "に対して"), ("About DesignCraft", "DesignCraft について"), + ("Quit DesignCraft", "DesignCraft を終了"), ("Accept All Changes", "すべての変更を採用"), ("Activate Document", "ドキュメントをアクティブ化"), ("Add Anchor Point", "アンカーポイントを追加"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index d25717dc..a4d06bbe 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -149,6 +149,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Bookmarks", "Indicadores"), ("Show All Menu Items", "Mostrar todos os itens de menu"), ("About DesignCraft", "Sobre o DesignCraft"), + ("Quit DesignCraft", "Encerrar o DesignCraft"), ("Welcome to DesignCraft", "Bem-vindo ao DesignCraft"), ("Page layout for print and screen — fast, open and scriptable.", "Layout de página para impressão e tela — rápido, aberto e scriptável."), (" New file ", " Novo arquivo "), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 9a9fb89b..9b70204a 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -399,6 +399,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Spreads of up to 10 pages", "Розвороти до 10 сторінок"), ("for", "для"), ("About DesignCraft", "Про DesignCraft"), + ("Quit DesignCraft", "Завершити DesignCraft"), ("Accept All Changes", "Прийняти всі зміни"), ("Activate Document", "Активувати документ"), ("Add Anchor Point", "Додати опорну точку"), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 3d94bdb0..e0fa1dff 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -510,6 +510,18 @@ pub struct DesignApp { last_time: f64, /// When recovery data was last written (seconds, egui time). pub last_recovery: f64, + /// Quit was chosen: close the window on the next frame, as its close button does. + quit_requested: bool, + /// Close the window without asking about unsaved changes (the control channel's `app.quit`). + pub(crate) quit_without_asking: bool, + /// No document is discarded until every unsaved document has an up-to-date approval. + quitting: Option, +} + +struct QuitState { + original_active: Option, + accepted: HashMap, + pending: Option, } impl DesignApp { @@ -547,6 +559,9 @@ impl DesignApp { native_shortcuts: Default::default(), last_time: 0.0, last_recovery: 0.0, + quit_requested: false, + quit_without_asking: false, + quitting: None, } } @@ -802,6 +817,146 @@ impl DesignApp { let _ = self.open_dropped(&p); } } + self.handle_quit(ctx); + } + + /// Quit: close the window the way its close button does, so unsaved documents ask first. + pub fn request_quit(&mut self) { + self.quit_requested = true; + } + + /// Ask about unsaved documents without closing any of them yet. A later Cancel must also + /// keep documents whose earlier answer was Don't Save, including their history and recovery. + fn handle_quit(&mut self, ctx: &egui::Context) { + if std::mem::take(&mut self.quit_requested) { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + if ctx.input(|i| i.viewport().close_requested()) { + if self.quit_without_asking { + self.quitting = None; + return; + } + if self.quitting.is_none() && !self.session.documents().iter().any(|d| d.is_dirty()) { + return; + } + let approved = + self.quitting.as_ref().is_some_and(|q| q.pending.is_none()) && self.ui.dialog.is_none() && self.next_unapproved_document().is_none(); + if approved { + // Commit only when the backend receives the final close, after checking for + // edits that arrived since the last answer. Closing also discards recovery data. + if let Err(e) = self.finish_confirmed_quit() { + self.status(e); + self.cancel_quit(); + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + } + return; + } + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + if self.quitting.is_none() { + self.quitting = Some(QuitState { original_active: self.session.active().map(|d| d.uid), accepted: HashMap::new(), pending: None }); + } + } + let Some(q) = self.quitting.as_ref() else { return }; + if let Some(uid) = q.pending { + let asking = self.ui.dialog.as_ref().is_some_and(|d| { + d.id == "closeDocument" + && d.fields.get("quitting").and_then(Value::as_bool) == Some(true) + && d.fields.get("uid").and_then(Value::as_u64) == Some(uid) + }); + if !asking { + self.cancel_quit(); + } + return; + } + if self.ui.dialog.is_some() { + self.cancel_quit(); + return; + } + self.ask_next_unsaved(ctx); + } + + /// Finalize a complete, still-current quit plan at an orderly host exit. The host can + /// terminate before egui receives its queued Close; an unfinished/stale plan leaves every + /// document live so the host can preserve its recovery data instead. + pub fn finish_confirmed_quit(&mut self) -> Result<(), String> { + let approved = + self.quitting.as_ref().is_some_and(|q| q.pending.is_none()) && self.ui.dialog.is_none() && self.next_unapproved_document().is_none(); + if !approved { + return Ok(()); + } + while !self.session.documents().is_empty() { + self.run("file.close", json!({"index": 0}))?; + } + self.quitting = None; + Ok(()) + } + + fn next_unapproved_document(&self) -> Option<(usize, u64)> { + let q = self.quitting.as_ref()?; + self.session + .documents() + .iter() + .enumerate() + .find(|(_, d)| d.is_dirty() && q.accepted.get(&d.uid) != Some(&d.revision)) + .map(|(i, d)| (i, d.uid)) + } + + fn cancel_quit(&mut self) { + let Some(q) = self.quitting.take() else { return }; + if let Some(i) = q.original_active.and_then(|uid| self.session.documents().iter().position(|d| d.uid == uid)) { + let _ = self.run("file.activate", json!({"index": i})); + } + } + + /// Save/Don't Save during Quit approves this UID's current revision, without destroying it. + pub(crate) fn confirm_quit_document(&mut self, uid: u64, discard: bool) -> Result { + if self.quitting.as_ref().and_then(|q| q.pending) != Some(uid) { + return Err("Quit confirmation is no longer active".into()); + } + let Some(i) = self.session.documents().iter().position(|d| d.uid == uid) else { + if let Some(q) = self.quitting.as_mut() { + q.pending = None; + } + return Ok(Value::Null); + }; + if !discard { + let saved = self.run("file.activate", json!({"index": i})).and_then(|_| self.run("app.save", json!({}))); + if let Err(e) = saved { + self.cancel_quit(); + return Err(e); + } + if self.session.documents().iter().find(|d| d.uid == uid).is_none_or(|d| d.is_dirty()) { + // No file chosen (or an asynchronous host save has not completed): stop quitting. + self.cancel_quit(); + return Ok(Value::Null); + } + } + if let Some(q) = self.quitting.as_mut() { + if let Some(d) = self.session.documents().iter().find(|d| d.uid == uid) { + q.accepted.insert(uid, d.revision); + } + q.pending = None; + } + Ok(Value::Null) + } + + /// Show the next unapproved document, or request a final close that revalidates all approvals. + fn ask_next_unsaved(&mut self, ctx: &egui::Context) { + let Some((i, uid)) = self.next_unapproved_document() else { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + return; + }; + if self.run("file.activate", json!({"index": i})).is_err() { + self.cancel_quit(); + return; + } + menus::close_document(self, Some(i)); + if let Some(d) = self.ui.dialog.as_mut() { + d.fields.insert("quitting".into(), json!(true)); + } + if let Some(q) = self.quitting.as_mut() { + q.pending = Some(uid); + } } /// A file dropped on the window: documents open, anything else is placed. @@ -1173,6 +1328,326 @@ mod tests { assert!(app.ui.dialog.is_none()); } + /// One frame of the quit handling, with the window asked to close when `close`; returns the + /// commands sent to the window. + fn quit_frame(app: &mut DesignApp, ctx: &egui::Context, close: bool) -> Vec { + let mut raw = egui::RawInput::default(); + if close { + raw.viewports.entry(egui::ViewportId::ROOT).or_default().events.push(egui::ViewportEvent::Close); + } + let mut out = ctx.run_ui(raw, |ui| app.handle_quit(ui.ctx())); + out.textures_delta.clear(); + out.viewport_output.get(&egui::ViewportId::ROOT).map(|v| v.commands.clone()).unwrap_or_default() + } + + fn dialog_uid(app: &DesignApp) -> Option { + app.ui.dialog.as_ref().filter(|d| d.id == "closeDocument").and_then(|d| d.fields.get("uid")?.as_u64()) + } + + /// Quitting closed the window at once, losing unsaved changes (#544). + #[test] + fn quitting_with_unsaved_changes_asks_and_cancel_keeps_everything() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + let cmds = quit_frame(&mut app, &ctx, true); + assert!(cmds.contains(&egui::ViewportCommand::CancelClose), "{cmds:?}"); + assert_eq!(dialog_uid(&app), Some(app.session.documents()[0].uid)); + // Cancel: the window and the document stay as they were. + app.ui.dialog = None; + for _ in 0..2 { + assert!(!quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close)); + } + assert_eq!(app.session.documents().len(), 1); + assert!(app.session.documents()[0].is_dirty()); + // A Save whose file picker is cancelled stops quitting too. + quit_frame(&mut app, &ctx, true); + dialogs::confirm(&mut app).unwrap(); + assert!(!quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close)); + assert!(app.session.documents()[0].is_dirty()); + } + + #[test] + fn quitting_asks_about_each_unsaved_document_then_closes() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect": [36, 36, 136, 136]})).unwrap(); + app.run("file.new", json!({})).unwrap(); + let uids: Vec = app.session.documents().iter().map(|d| d.uid).collect(); + assert!(quit_frame(&mut app, &ctx, true).contains(&egui::ViewportCommand::CancelClose)); + for (n, uid) in uids.iter().take(2).enumerate() { + assert_eq!(dialog_uid(&app), Some(*uid), "question {n}"); + assert_eq!(app.session.active().map(|d| d.uid), Some(*uid), "the document asked about is shown"); + app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true)); + dialogs::confirm(&mut app).unwrap(); + let cmds = quit_frame(&mut app, &ctx, false); + assert_eq!(cmds.contains(&egui::ViewportCommand::Close), n == 1, "{cmds:?}"); + } + // All documents stay open until the backend's final close commits the decision. + assert_eq!(app.session.documents().len(), 3); + assert!(quit_frame(&mut app, &ctx, true).is_empty()); + assert!(app.session.documents().is_empty()); + } + + /// A later Cancel must not destroy documents already approved for discard. + #[test] + fn quitting_later_cancel_retains_every_document_and_active_tab() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect": [20, 20, 120, 120]})).unwrap(); + let uids: Vec = app.session.documents().iter().map(|d| d.uid).collect(); + let active = app.session.active().unwrap().uid; + let revisions: Vec = app.session.documents().iter().map(|d| d.revision).collect(); + let docs: Vec<_> = app.session.documents().iter().map(|d| d.doc.clone()).collect(); + quit_frame(&mut app, &ctx, true); + app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true)); + dialogs::confirm(&mut app).unwrap(); + quit_frame(&mut app, &ctx, false); + assert_eq!(dialog_uid(&app), Some(uids[1])); + app.ui.dialog = None; + assert!(!quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close)); + assert_eq!(app.session.documents().iter().map(|d| d.uid).collect::>(), uids); + assert_eq!(app.session.active().unwrap().uid, active); + assert_eq!(app.session.documents().iter().map(|d| d.revision).collect::>(), revisions); + for (d, original) in app.session.documents().iter().zip(docs) { + assert!(std::sync::Arc::ptr_eq(&d.doc, &original)); + assert!(d.is_dirty()); + assert!(!d.history.undo.is_empty()); + } + } + + fn second_unsaved_document(app: &mut DesignApp) { + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect": [20, 20, 120, 120]})).unwrap(); + } + + fn discard_quit_dialog(app: &mut DesignApp) { + app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true)); + dialogs::confirm(app).unwrap(); + } + + #[test] + fn quitting_first_cancel_restores_original_clean_active_document() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + app.run("file.new", json!({})).unwrap(); + let original_active = app.session.active().unwrap().uid; + quit_frame(&mut app, &ctx, true); + assert_ne!(app.session.active().unwrap().uid, original_active); + app.ui.dialog = None; + assert!(!quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close)); + assert_eq!(app.session.active().unwrap().uid, original_active); + assert_eq!(app.session.documents().len(), 2); + } + + #[test] + fn quitting_save_as_cancel_after_discard_keeps_all_documents() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + second_unsaved_document(&mut app); + let active = app.session.active().unwrap().uid; + quit_frame(&mut app, &ctx, true); + discard_quit_dialog(&mut app); + quit_frame(&mut app, &ctx, false); + dialogs::confirm(&mut app).unwrap(); // No picker: Save As was cancelled. + assert!(!quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close)); + assert_eq!(app.session.documents().len(), 2); + assert!(app.session.documents().iter().all(|d| d.is_dirty())); + assert_eq!(app.session.active().unwrap().uid, active); + } + + #[test] + fn quitting_save_failure_after_discard_keeps_documents_and_can_retry() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + second_unsaved_document(&mut app); + let path = std::env::temp_dir() + .join(format!("designcraft-quit-missing-{}-{}", std::process::id(), app.session.active().unwrap().uid)) + .join("missing") + .join("document.designcraft"); + assert!(!path.parent().unwrap().exists()); + let picked = path.to_string_lossy().to_string(); + app.services.pick_save = Some(Box::new(move |_| Some(picked.clone()))); + quit_frame(&mut app, &ctx, true); + discard_quit_dialog(&mut app); + quit_frame(&mut app, &ctx, false); + assert!(dialogs::confirm(&mut app).is_err()); + assert!(!quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close)); + assert_eq!(app.session.documents().len(), 2); + assert!(app.session.documents().iter().all(|d| d.is_dirty())); + // Starting over must ask about the previously discarded first document again. + quit_frame(&mut app, &ctx, true); + assert_eq!(dialog_uid(&app), Some(app.session.documents()[0].uid)); + } + + #[test] + fn quitting_save_then_later_cancel_keeps_saved_document_open() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + second_unsaved_document(&mut app); + let first = app.session.documents()[0].uid; + let path = std::env::temp_dir().join(format!("designcraft-quit-save-{}-{first}.designcraft", std::process::id())); + let picked = path.to_string_lossy().to_string(); + app.services.pick_save = Some(Box::new(move |_| Some(picked.clone()))); + quit_frame(&mut app, &ctx, true); + dialogs::confirm(&mut app).unwrap(); + quit_frame(&mut app, &ctx, false); + assert_eq!(app.session.documents().len(), 2); + assert!(!app.session.documents()[0].is_dirty()); + assert!(path.exists()); + app.ui.dialog = None; + assert!(!quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close)); + assert_eq!(app.session.documents()[0].uid, first); + assert!(app.session.documents()[1].is_dirty()); + std::fs::remove_file(path).unwrap(); + } + + #[test] + fn quitting_rechecks_discard_approval_after_a_later_edit() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + second_unsaved_document(&mut app); + let first = app.session.documents()[0].uid; + quit_frame(&mut app, &ctx, true); + discard_quit_dialog(&mut app); + quit_frame(&mut app, &ctx, false); + app.run("file.activate", json!({"index": 0})).unwrap(); + app.run("frame.create", json!({"rect": [200, 200, 300, 300]})).unwrap(); + discard_quit_dialog(&mut app); // Approval is bound to the second UID, despite the switch. + assert!(!quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close)); + assert_eq!(dialog_uid(&app), Some(first), "new edit needs a fresh decision"); + assert_eq!(app.session.documents().len(), 2); + } + + #[test] + fn quitting_final_close_rechecks_new_unsaved_document() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + quit_frame(&mut app, &ctx, true); + discard_quit_dialog(&mut app); + assert!(quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close)); + second_unsaved_document(&mut app); // Arrives after Close was queued, before its event. + let new_uid = app.session.active().unwrap().uid; + assert!(quit_frame(&mut app, &ctx, true).contains(&egui::ViewportCommand::CancelClose)); + assert_eq!(dialog_uid(&app), Some(new_uid)); + assert_eq!(app.session.documents().len(), 2); + } + + #[test] + fn quitting_pending_save_uses_dialog_uid_after_tab_switch() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + second_unsaved_document(&mut app); + let first = app.session.documents()[0].uid; + let path = std::env::temp_dir().join(format!("designcraft-quit-uid-{}-{first}.designcraft", std::process::id())); + let picked = path.to_string_lossy().to_string(); + app.services.pick_save = Some(Box::new(move |_| Some(picked.clone()))); + quit_frame(&mut app, &ctx, true); + app.run("file.activate", json!({"index": 1})).unwrap(); + dialogs::confirm(&mut app).unwrap(); + assert_eq!(app.session.documents()[0].path.as_deref(), path.to_str()); + assert!(!app.session.documents()[0].is_dirty()); + assert!(app.session.documents()[1].is_dirty()); + quit_frame(&mut app, &ctx, false); + assert_eq!(dialog_uid(&app), Some(app.session.documents()[1].uid)); + std::fs::remove_file(path).unwrap(); + } + + #[test] + fn quitting_discard_preserves_recovery_until_final_commit() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + let uid = app.session.active().unwrap().uid; + let dir = std::env::temp_dir().join(format!("designcraft-quit-recovery-{}-{uid}", std::process::id())); + app.session.recovery_dir = Some(dir.clone()); + app.run("file.recovery.save", json!({})).unwrap(); + let entry = dir.join(format!("{uid}.designcraft")); + quit_frame(&mut app, &ctx, true); + discard_quit_dialog(&mut app); + assert!(quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close)); + assert!(entry.exists(), "a queued close is not a committed close"); + assert!(quit_frame(&mut app, &ctx, true).is_empty()); + assert!(!entry.exists(), "accepted Don't Save must not be recovered on restart"); + assert!(app.session.documents().is_empty()); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn quitting_native_exit_commits_completed_approvals_before_close_event() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + quit_frame(&mut app, &ctx, true); + discard_quit_dialog(&mut app); + assert!(quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close)); + assert_eq!(app.session.documents().len(), 1); + app.finish_confirmed_quit().unwrap(); + assert!(app.session.documents().is_empty()); + } + + #[test] + fn quitting_native_exit_retains_incomplete_stale_and_new_document_plans() { + for case in ["incomplete", "stale", "new document"] { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + if case == "incomplete" { + second_unsaved_document(&mut app); + } + quit_frame(&mut app, &ctx, true); + discard_quit_dialog(&mut app); + quit_frame(&mut app, &ctx, false); + if case == "stale" { + app.run("frame.create", json!({"rect": [200, 200, 300, 300]})).unwrap(); + } else if case == "new document" { + second_unsaved_document(&mut app); + } + let active = app.session.active().unwrap().uid; + let before: Vec<_> = app + .session + .documents() + .iter() + .map(|d| (d.uid, d.revision, d.doc.clone(), d.selection.items.clone(), d.history.undo.len(), d.history.redo.len())) + .collect(); + app.finish_confirmed_quit().unwrap(); + assert_eq!(app.session.documents().len(), before.len(), "{case}"); + assert_eq!(app.session.active().unwrap().uid, active, "{case}"); + for (d, (uid, revision, doc, selection, undo, redo)) in app.session.documents().iter().zip(before) { + assert_eq!( + (d.uid, d.revision, &d.selection.items, d.history.undo.len(), d.history.redo.len()), + (uid, revision, &selection, undo, redo), + "{case}" + ); + assert!(std::sync::Arc::ptr_eq(&d.doc, &doc), "{case}"); + } + } + } + + #[test] + fn quitting_without_unsaved_changes_closes_at_once() { + let ctx = egui::Context::default(); + let mut app = DesignApp::new(designcraft_engine::Session::new(), Services::default()); + app.run("file.new", json!({})).unwrap(); + // Quit (⌘Q) closes the window like its close button. + app.run("app.quit", json!({})).unwrap(); + assert!(quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close)); + assert!(quit_frame(&mut app, &ctx, true).is_empty()); + assert!(app.ui.dialog.is_none()); + } + + /// Scripts must not hang on a question: the control channel's quit never asks. + #[test] + fn control_channel_quit_does_not_ask() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + let (req, _rx) = control::ControlRequest::new("app.quit", json!({})); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + control::handle(&mut app, ui.ctx(), &req); + }); + out.textures_delta.clear(); + assert!(quit_frame(&mut app, &ctx, true).is_empty()); + assert!(app.ui.dialog.is_none()); + } + /// The Control panel's two-row groups were centred as if they were one row tall, so the second /// row hung below the panel and was cut off. #[test] diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index d123ec4e..cb142239 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -11,6 +11,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("app.placeDialog", "Place…", Some("Cmd+D"), "{}"), ("app.saveDialog", "Save As…", Some("Cmd+Shift+S"), "{}"), ("app.save", "Save", Some("Cmd+S"), "{}"), + ("app.quit", "Quit DesignCraft", Some("Cmd+Q"), "{} — close the window; each document with unsaved changes asks first"), ("app.saveCopyDialog", "Save a Copy…", Some("Cmd+Alt+S"), "{} — choose where to write a copy (the document stays as it is)"), ("app.loadSwatches", "Load Swatches…", None, "{} — pick a swatch exchange (.ase) file"), ("app.packageDialog", "Package…", Some("Cmd+Alt+Shift+P"), "{} — choose the package folder to create (file.package)"), @@ -769,6 +770,10 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option { + app.request_quit(); + Ok(Value::Null) + } "app.saveCopyDialog" => { let name = app.session.active().map(|d| format!("{} copy.designcraft", d.doc.title)).unwrap_or_default(); if let Some(path) = app.services.pick_save.as_mut().and_then(|f| f(&name)) { diff --git a/docs/control-protocol.md b/docs/control-protocol.md index d97c2bad..9e514561 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -46,7 +46,8 @@ so only enable it while you use it. Transport: `apps/designcraft/src/control_ser | `ui.resize`, `ui.focus` | | Window control | | `ui.screenshot` | `{path?}` | PNG of the whole window | | `ui.render` | `{path?, page?, scale?, bleed?}` | Render a page headlessly (PNG; base64 if no path) | -| `app.open` / `app.save` / `app.export` / `app.quit` | | Files | +| `app.open` / `app.save` / `app.export` | | Files | +| `app.quit` | | Quit without asking about unsaved changes (the `app.quit` command, like ⌘Q, asks) | Data merge runs through `engine.execute` like any command: `data.source.select`, `data.fields`, `data.placeholder.add` / `.remove`, `data.options`, `data.preview` / `data.preview.stop`, `data.merge`. `data.merge` creates and activates a new diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 97fb5d01..5705ee73 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -290,6 +290,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Damage-region repaint: after an edit the canvas re-renders only the frames whose composed text (or the items that) changed and patches its texture — pixel-identical to a full render; typing in a frame repaints in under a millisecond instead of a full-viewport render (22 ms measured on the sample). - Native engine saves, Save a Copy, snippets, books, libraries, swatches and engine/MCP exports use same-folder atomic replacement. Failed writes before replacement retain the original; existing Unix permissions apply before staging document bytes. Desktop Services writes and CLI raster exports still need this routing. Parent-folder flushing is best effort, so this is not a power-loss durability guarantee. - Crash recovery: unsaved documents are written every 30 s into a locked folder owned by that running app and reopened (unsaved, remembering their files) after a crash. Startup takes entries from ended runs and leaves live runs alone; writes and cleanup validate current folder and lock ownership. Failed isolation disables recovery with a warning. Concurrent older versions still use unlocked root entries; mixed-version isolation remains open. File › Revert and Save a Copy…; `file.recovery.*`, `file.revert`, `file.saveACopy`. +- Window close and application/menu Quit ask about every unsaved document. Cancel or a cancelled/failed save keeps all tabs open; Don't Save approvals discard nothing until the complete plan is current. Later edits and new documents are checked before closing. Control-channel quit remains immediate; a cancelable macOS Dock Quit prompt is still missing (#544). - Graphics backend chosen before the window exists (DirectX 12 on Windows, Vulkan on Linux, Metal on macOS, OpenGL as the fallback; `WGPU_BACKEND` overrides) with a start-up fallback: a backend whose start never showed a frame is skipped at the next start and the status bar says so (`gpu.json`). Found on an AMD hybrid-graphics laptop whose Vulkan driver faulted at the first present. - Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool). - Color panel (fill/stroke proxy, CMYK/RGB/Lab sliders with channel ramps, tint slider for swatches, spectrum ramp, Add to Swatches) and Color Picker (double-click the proxy); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index ee6db423..8805defc 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -50,7 +50,7 @@ basics of editing fail for too many users on Windows and Linux. | Workspaces | Essentials, Advanced, Book, Digital Publishing, Interactive for PDF, Printing and Proofing, Typography, Touch | ✓ named workspaces + custom | ~ | tab bar overlaps menus in Book/Advanced (#182) | | Story Editor | ✓ | ✓ | ~ | style-name column, depth ruler missing | | Undo granularity (a drag = one step) | ✓ | ✓ (Begin/Preview/Commit) | ✓ | | -| Closing with unsaved changes asks | ✓ | ✓ (#146) | ✓ | | +| Closing with unsaved changes asks | ✓ | ✓ File › Close and window/menu quit (#146, #545) | ✓ | macOS Dock Quit still bypasses the prompt (#544) | | UI scaling, System appearance | ✓ | scaling ✓ (#281); follows macOS light/dark ✗ (#237) | ~ | | | Window controls, full screen | ✓ | ~ | ~ | misaligned on macOS (#333) |