mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-28 04:06:54 -06: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 <aiongtaigi@gmail.com> Co-authored-by: skylar taylor-barrick <hi@skylar.cc>
This commit is contained in:
co-authored by
aiongg
skylar taylor-barrick
parent
328b4d5a52
commit
d020b4e85d
+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%)
|
**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
|
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.
|
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.
|
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** · 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** · 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.
|
- **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.
|
// 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 about = MenuItem::with_id("dc-about", "About DesignCraft", true, None);
|
||||||
let discord = MenuItem::with_id("dc-discord", "Join the ArtCraft Discord…", 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(&[
|
let _ = app_menu.append_items(&[
|
||||||
&about,
|
&about,
|
||||||
&discord,
|
&discord,
|
||||||
@@ -50,10 +52,11 @@ impl NativeMenu {
|
|||||||
&PredefinedMenuItem::hide_others(None),
|
&PredefinedMenuItem::hide_others(None),
|
||||||
&PredefinedMenuItem::show_all(None),
|
&PredefinedMenuItem::show_all(None),
|
||||||
&PredefinedMenuItem::separator(),
|
&PredefinedMenuItem::separator(),
|
||||||
&PredefinedMenuItem::quit(None),
|
&quit,
|
||||||
]);
|
]);
|
||||||
items.insert("dc-about".into(), ("help.about".into(), serde_json::Value::Null, Handle::Plain(about)));
|
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-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);
|
let _ = menu.append(&app_menu);
|
||||||
for (title, entries) in menus::menu_tree() {
|
for (title, entries) in menus::menu_tree() {
|
||||||
let sub = Submenu::new(designcraft_ui_egui::i18n::tr(&app.ui.language, title), true);
|
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.save" => wrap(app.run("file.save", json!({"path": s("path")}))),
|
||||||
"app.export" => wrap(app.run("app.exportPng", p.clone())),
|
"app.export" => wrap(app.run("app.exportPng", p.clone())),
|
||||||
"app.quit" => {
|
"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);
|
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||||
ok(Value::Null)
|
ok(Value::Null)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2078,6 +2078,9 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> {
|
|||||||
"closeDocument" => {
|
"closeDocument" => {
|
||||||
// The document may have moved in the tab row (or gone) while the dialog was open.
|
// 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);
|
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 index = |app: &DesignApp| app.session.documents().iter().position(|doc| Some(doc.uid) == uid);
|
||||||
let Some(i) = index(app) else { return Ok(Value::Null) };
|
let Some(i) = index(app) else { return Ok(Value::Null) };
|
||||||
if !d.b("discard") {
|
if !d.b("discard") {
|
||||||
|
|||||||
@@ -699,6 +699,7 @@ const TABLE: &[(&str, [&str; 5])] = &[
|
|||||||
),
|
),
|
||||||
("for", ["for", "pour", "por", "に対して", "行"]),
|
("for", ["for", "pour", "por", "に対して", "行"]),
|
||||||
("About DesignCraft", ["About DesignCraft", "About DesignCraft", "About DesignCraft", "About DesignCraft", "关于 DesignCraft"]),
|
("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", "接受所有更改"]),
|
("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", "激活文档"]),
|
("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", "添加锚点"]),
|
("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", "إشارات مرجعية"),
|
("Bookmarks", "إشارات مرجعية"),
|
||||||
("Show All Menu Items", "إظهار كل عناصر القائمة"),
|
("Show All Menu Items", "إظهار كل عناصر القائمة"),
|
||||||
("About DesignCraft", "حول DesignCraft"),
|
("About DesignCraft", "حول DesignCraft"),
|
||||||
|
("Quit DesignCraft", "إنهاء DesignCraft"),
|
||||||
("Welcome to DesignCraft", "مرحباً بك في DesignCraft"),
|
("Welcome to DesignCraft", "مرحباً بك في DesignCraft"),
|
||||||
("Page layout for print and screen — fast, open and scriptable.", "تخطيط الصفحات للطباعة والشاشة — سريع ومفتوح وقابل للبرمجة."),
|
("Page layout for print and screen — fast, open and scriptable.", "تخطيط الصفحات للطباعة والشاشة — سريع ومفتوح وقابل للبرمجة."),
|
||||||
(" New file ", " ملف جديد "),
|
(" 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"),
|
("Spreads of up to 10 pages", "Doppie pagine con al massimo 10 pagine"),
|
||||||
("for", "per"),
|
("for", "per"),
|
||||||
("About DesignCraft", "Informazioni su DesignCraft"),
|
("About DesignCraft", "Informazioni su DesignCraft"),
|
||||||
|
("Quit DesignCraft", "Esci da DesignCraft"),
|
||||||
("Accept All Changes", "Accetta tutte le modifiche"),
|
("Accept All Changes", "Accetta tutte le modifiche"),
|
||||||
("Activate Document", "Attiva documento"),
|
("Activate Document", "Attiva documento"),
|
||||||
("Add Anchor Point", "Aggiungi punto di ancoraggio"),
|
("Add Anchor Point", "Aggiungi punto di ancoraggio"),
|
||||||
|
|||||||
@@ -398,6 +398,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
|||||||
("Spreads of up to 10 pages", "最大10ページのスプレッド"),
|
("Spreads of up to 10 pages", "最大10ページのスプレッド"),
|
||||||
("for", "に対して"),
|
("for", "に対して"),
|
||||||
("About DesignCraft", "DesignCraft について"),
|
("About DesignCraft", "DesignCraft について"),
|
||||||
|
("Quit DesignCraft", "DesignCraft を終了"),
|
||||||
("Accept All Changes", "すべての変更を採用"),
|
("Accept All Changes", "すべての変更を採用"),
|
||||||
("Activate Document", "ドキュメントをアクティブ化"),
|
("Activate Document", "ドキュメントをアクティブ化"),
|
||||||
("Add Anchor Point", "アンカーポイントを追加"),
|
("Add Anchor Point", "アンカーポイントを追加"),
|
||||||
|
|||||||
@@ -149,6 +149,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
|||||||
("Bookmarks", "Indicadores"),
|
("Bookmarks", "Indicadores"),
|
||||||
("Show All Menu Items", "Mostrar todos os itens de menu"),
|
("Show All Menu Items", "Mostrar todos os itens de menu"),
|
||||||
("About DesignCraft", "Sobre o DesignCraft"),
|
("About DesignCraft", "Sobre o DesignCraft"),
|
||||||
|
("Quit DesignCraft", "Encerrar o DesignCraft"),
|
||||||
("Welcome to DesignCraft", "Bem-vindo ao 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."),
|
("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 "),
|
(" New file ", " Novo arquivo "),
|
||||||
|
|||||||
@@ -399,6 +399,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
|||||||
("Spreads of up to 10 pages", "Розвороти до 10 сторінок"),
|
("Spreads of up to 10 pages", "Розвороти до 10 сторінок"),
|
||||||
("for", "для"),
|
("for", "для"),
|
||||||
("About DesignCraft", "Про DesignCraft"),
|
("About DesignCraft", "Про DesignCraft"),
|
||||||
|
("Quit DesignCraft", "Завершити DesignCraft"),
|
||||||
("Accept All Changes", "Прийняти всі зміни"),
|
("Accept All Changes", "Прийняти всі зміни"),
|
||||||
("Activate Document", "Активувати документ"),
|
("Activate Document", "Активувати документ"),
|
||||||
("Add Anchor Point", "Додати опорну точку"),
|
("Add Anchor Point", "Додати опорну точку"),
|
||||||
|
|||||||
@@ -510,6 +510,18 @@ pub struct DesignApp {
|
|||||||
last_time: f64,
|
last_time: f64,
|
||||||
/// When recovery data was last written (seconds, egui time).
|
/// When recovery data was last written (seconds, egui time).
|
||||||
pub last_recovery: f64,
|
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 {
|
impl DesignApp {
|
||||||
@@ -547,6 +559,9 @@ impl DesignApp {
|
|||||||
native_shortcuts: Default::default(),
|
native_shortcuts: Default::default(),
|
||||||
last_time: 0.0,
|
last_time: 0.0,
|
||||||
last_recovery: 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);
|
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.
|
/// A file dropped on the window: documents open, anything else is placed.
|
||||||
@@ -1173,6 +1328,326 @@ mod tests {
|
|||||||
assert!(app.ui.dialog.is_none());
|
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
|
/// 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.
|
/// row hung below the panel and was cut off.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
|
|||||||
("app.placeDialog", "Place…", Some("Cmd+D"), "{}"),
|
("app.placeDialog", "Place…", Some("Cmd+D"), "{}"),
|
||||||
("app.saveDialog", "Save As…", Some("Cmd+Shift+S"), "{}"),
|
("app.saveDialog", "Save As…", Some("Cmd+Shift+S"), "{}"),
|
||||||
("app.save", "Save", Some("Cmd+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.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.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)"),
|
("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)
|
Ok(Value::Null)
|
||||||
}
|
}
|
||||||
|
"app.quit" => {
|
||||||
|
app.request_quit();
|
||||||
|
Ok(Value::Null)
|
||||||
|
}
|
||||||
"app.saveCopyDialog" => {
|
"app.saveCopyDialog" => {
|
||||||
let name = app.session.active().map(|d| format!("{} copy.designcraft", d.doc.title)).unwrap_or_default();
|
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)) {
|
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.resize`, `ui.focus` | | Window control |
|
||||||
| `ui.screenshot` | `{path?}` | PNG of the whole window |
|
| `ui.screenshot` | `{path?}` | PNG of the whole window |
|
||||||
| `ui.render` | `{path?, page?, scale?, bleed?}` | Render a page headlessly (PNG; base64 if no path) |
|
| `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` /
|
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
|
`.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).
|
- 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.
|
- 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`.
|
- 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.
|
- 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).
|
- 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`.
|
- 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) |
|
| 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 |
|
| Story Editor | ✓ | ✓ | ~ | style-name column, depth ruler missing |
|
||||||
| Undo granularity (a drag = one step) | ✓ | ✓ (Begin/Preview/Commit) | ✓ | |
|
| 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) | ~ | |
|
| UI scaling, System appearance | ✓ | scaling ✓ (#281); follows macOS light/dark ✗ (#237) | ~ | |
|
||||||
| Window controls, full screen | ✓ | ~ | ~ | misaligned on macOS (#333) |
|
| Window controls, full screen | ✓ | ~ | ~ | misaligned on macOS (#333) |
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user