mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-14 11:17:14 -04:00
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:
15 files changed
+501
-4
No files matched your search
+2
-1
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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", "添加锚点"]),
|
||||
|
||||
@@ -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 ", " ملف جديد "),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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", "アンカーポイントを追加"),
|
||||
|
||||
@@ -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 "),
|
||||
|
||||
@@ -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", "Додати опорну точку"),
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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) |
|
||||
|
||||
|
||||
Reference in new issue
Block a user