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 <[email protected]>
Co-authored-by: skylar taylor-barrick <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-12 09:31:24 -06:00
1 parent 328b4d5a52
commit d020b4e85d
15 files changed
+501 -4

No files matched your search

+2 -1
View File
@@ -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.
+4 -1
View File
@@ -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);
+2
View File
@@ -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)
}
+3
View File
@@ -2078,6 +2078,9 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> {
"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") {
+1
View File
@@ -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", "添加锚点"]),
+1
View File
@@ -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 ", " ملف جديد "),
+1
View File
@@ -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"),
+1
View File
@@ -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", "アンカーポイントを追加"),
+1
View File
@@ -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 "),
+1
View File
@@ -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", "Додати опорну точку"),
+475
View File
@@ -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<QuitState>,
}
struct QuitState {
original_active: Option<u64>,
accepted: HashMap<u64, u64>,
pending: Option<u64>,
}
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<Value, String> {
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<egui::ViewportCommand> {
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<u64> {
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<u64> = 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<u64> = app.session.documents().iter().map(|d| d.uid).collect();
let active = app.session.active().unwrap().uid;
let revisions: Vec<u64> = 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::<Vec<_>>(), uids);
assert_eq!(app.session.active().unwrap().uid, active);
assert_eq!(app.session.documents().iter().map(|d| d.revision).collect::<Vec<_>>(), 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]
+5
View File
@@ -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<Result<Value,
}
Ok(Value::Null)
}
"app.quit" => {
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)) {
+2 -1
View File
@@ -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
+1
View File
@@ -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`.
+1 -1
View File
@@ -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) |