From 25881a8e03cb48dff4673873e13a1fd90c02a23d Mon Sep 17 00:00:00 2001 From: Pranav <53838599+prxnvt@users.noreply.github.com> Date: Mon, 2 Nov 2026 01:04:10 -0500 Subject: [PATCH] UI: an unsaved macro-editor draft counts as unsaved work when its workbook closes (#492) * UI: an unsaved macro-editor draft counts as unsaved work when its workbook closes Closing a workbook (File > Close, Cmd+W, the Backstage Close, a window's close button) while the macro editor holds unsaved changes now asks about the draft first, with the editor's own Save changes to macro question, even if the workbook itself is saved. Before, the workbook closed and the draft was discarded without a question. Save or Don't Save goes on to close the workbook, which then asks about its own unsaved changes (a saved macro is one); Cancel keeps both open. A draft that cannot be saved keeps its workbook open with the reason shown. A new macro that has not been edited does not ask, and a close that leaves the workbook open (one of several windows) or a forced close goes ahead as before. * UI: a new macro's name change alone counts as an unsaved macro-editor draft The check compared the draft with an untouched new macro that took the draft's current name, so a new macro whose only change was its name compared equal, and closing its workbook discarded the name without asking. It now compares with the name the editor opened with. * UI: Quit asks about an unsaved macro-editor draft before quitting Quit (Cmd+Q, the macOS Quit menu, a quit from the Dock) went straight to the unsaved workbooks and never consulted the macro step editor, so with every workbook saved the app quit and discarded the editor's unsaved steps. It now lets the editor ask first, as the window's close button already does: Save or Don't Save goes on with the quit, Cancel stops it. With one native window per workbook, the editor goes on with the app's quit directly once it has closed: a close sent to the window it is drawn in would close only that workbook's window. * UI: a refused macro save while quitting stops the quit When the macro editor's Save changes question was answered Save during a quit (the window's close button or Quit) and the save was refused (a new macro with a name already in use, a limit), the editor stayed open with the reason shown but kept its quit intent. Closing the editor later on its own then quit the app. A refused save now stops the quit, as Cancel does. * UI: a quit takes over a workbook close waiting on the macro editor When File > Close was waiting on the macro editor's question and Quit came in (or the other way round), closing the editor went on with the quit and then replayed the close. With one native window per workbook, the quit had already moved to the next unsaved workbook's window, so the replayed close acted on that workbook and replaced the quit's Save Changes question with an ordinary close question. The quit now takes over: it asks about every unsaved workbook itself, and the waiting close is dropped. * UI: an untouched new macro closes without asking, however the editor closes Closing a workbook already left an untouched new macro alone, but the editor's own close checks still read its dirty flag, which a new macro starts with: its close button, a quit, the control channel's cancel and the close after a stopped debug run asked Save changes to macro (or refused) for a macro nobody had edited. Every close check now asks whether the draft has unsaved changes, so all of them agree. * UI: closing the window that shows the macro editor asks about its draft The editor's unsaved draft was consulted only when its workbook would close. Closing the window that shows the editor while the workbook stayed open in another window (window.close, or File > Close in that window) went ahead: the window's dialog went with it, and the editor's state was left behind with no window to show it, so the next macro opened in the editor replaced the draft without asking. That close now asks first, like a workbook close. An editor left with no window to show it (a saved one, or a forced close) is now dropped, as when its workbook closes. A window that has closed no longer counts as showing a dialog while its parked state is still kept. * UI: a close waiting on the macro editor runs in the window it was asked in A workbook or window close that waited on the editor's Save changes question was replayed with the command and its parameters alone, so file.close acted on whichever window was the session's active one when the editor closed. The editor is drawn with its own window active, but nothing tied the replay to it. The pending close now records the editor's window and enters it before running again; if that window has closed meanwhile, nothing else is closed and a message says so. * UI: a close asked while the macro editor is asking waits for the answer A second file.close or window.close for the editor's workbook or window while its Save changes question stood replaced the pending close with the new one: the answer then closed whatever the last request asked for, not what the question had been opened for. A further request for the same close now waits on the open question (no second question, and the close runs once); a request for a different close (the workbook while only the editor's window was closing, or the reverse) is refused with the reason until the question is answered. A quit keeps taking over as before. * UI: test the quit with a macro draft in one window and unsaved cells in another The quit asks about the editor's draft first, in its window, then about each unsaved workbook in its window; Cancel at either question stops it with every window and workbook open, and a draft discarded with Don't Save before the quit was cancelled stays discarded. Covered in the single-window harness, which presses the real buttons, and in the native-windows harness, which now takes a key press in the root window. --------- Co-authored-by: FredApps --- crates/ui-egui/src/app/commands.rs | 8 + crates/ui-egui/src/app/quit.rs | 15 +- crates/ui-egui/src/macro_editor.rs | 130 ++++++- crates/ui-egui/src/macro_ui.rs | 12 +- crates/ui-egui/src/window_ui.rs | 13 + crates/ui-egui/tests/doc_windows.rs | 138 +++++++ crates/ui-egui/tests/it/macro_editor.rs | 498 +++++++++++++++++++++++- docs/macros.md | 20 +- 8 files changed, 813 insertions(+), 21 deletions(-) diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index c384f89c8..7855700ea 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -46,6 +46,14 @@ impl SheetApp { if let Some(r) = self.run_ui_command(id, ¶ms) { return r; } + // The macro editor's unsaved draft is part of its workbook: closing the workbook asks + // about it first, and goes on once the editor has closed. A close asked while it is + // asking waits for the answer, or is refused when it asks for a different close. + if matches!(id, "file.close" | "window.close") + && let Some(r) = crate::macro_editor::ask_before_closing(self, id, ¶ms) + { + return r; + } let before = self.session.active_window(); let zoom_from = matches!(id, "view.zoom" | "view.zoom100" | "view.zoomToSelection").then(|| crate::grid::Trace::key(self)); let r = self.session.run(id, params); diff --git a/crates/ui-egui/src/app/quit.rs b/crates/ui-egui/src/app/quit.rs index ffa230c34..7f364829f 100644 --- a/crates/ui-egui/src/app/quit.rs +++ b/crates/ui-egui/src/app/quit.rs @@ -81,8 +81,9 @@ impl SheetApp { true } - /// Quit (the macOS app menu, ⌘Q): commits every window's cell draft as Enter would, then asks - /// about each unsaved workbook in its window; Cancel on any of them stops the quit. A draft its + /// Quit (the macOS app menu, ⌘Q): commits every window's cell draft as Enter would, lets the + /// macro step editor ask about its unsaved steps, then asks about each unsaved workbook in its + /// window; Cancel on any of them stops the quit. A draft its /// workbook refuses stops the quit too, with its window in front and the reason shown. pub fn request_quit(&mut self, ctx: &egui::Context) { while let Some(window) = self.parked_window_with_draft() { @@ -94,6 +95,16 @@ impl SheetApp { if self.settle_drafts().is_err() { return; } + // The macro step editor asks about its own unsaved steps and debug run first, as for the + // window's close button, in the window it is shown in; once it has closed, the quit goes + // on, and Cancel stops it. + if let Some(window) = self.window_with_dialog("macroEditor") { + if self.ui_window() != Some(window) { + self.enter_window(window); + } + crate::macro_editor::request_close(self, true); + return; + } if let Some(d) = self.dialog.as_mut().filter(|d| d.name() == "saveChanges") { // Already asking about a workbook (File › Close): answering it goes on to quit. d.values.insert("quit".into(), json!(true)); diff --git a/crates/ui-egui/src/macro_editor.rs b/crates/ui-egui/src/macro_editor.rs index c74153c1b..1f64d55ad 100644 --- a/crates/ui-egui/src/macro_editor.rs +++ b/crates/ui-egui/src/macro_editor.rs @@ -57,6 +57,9 @@ pub struct Editor { pub draft: Macro, /// The name the macro is saved under (`None` until it is first saved). pub saved_as: Option, + /// The name the editor opened with: a new macro that still has it and nothing else counts + /// as untouched. + pub opened_as: String, pub dirty: bool, /// The selected row: a step path (`3`, `2.1`, `4.then.1`) or an If branch (`4.then`). pub selected: Option, @@ -79,6 +82,9 @@ pub struct Editor { pub close_requested: bool, /// The window is quitting: once the editor has closed, the window close goes on. pub quit: bool, + /// The workbook (or the editor's window) is closing: once the editor has closed, this close + /// runs again, in the window it was asked in. + pub then_close: Option, /// Asking whether to save before closing. pub ask_close: bool, /// Start debugging on the first frame (Macros › Step Into). @@ -94,6 +100,20 @@ pub struct Editor { pub refs: RefPick, } +/// A workbook or window close (`file.close` or `window.close`, with its params) waiting on the +/// editor's question about its draft. Once the editor has closed, it runs again in the window it +/// was asked in, whichever window became active meanwhile. +#[derive(Clone, Debug, PartialEq)] +pub struct PendingClose { + /// The editor's window, where the close was asked. + pub window: u64, + /// Whether the workbook closes (its last window, or `file.close {document: true}`), rather + /// than the editor's window alone while the workbook stays open in another. + pub whole: bool, + pub id: String, + pub params: Json, +} + /// Which reference box grid selections write into, and the selection to put back on closing. #[derive(Clone, Debug, Default)] pub struct RefPick { @@ -523,6 +543,7 @@ pub fn open(app: &mut SheetApp, params: &Json) -> Option { let doc = doc.uid; app.macros.editor = Some(Editor { doc, + opened_as: draft.name.clone(), draft, dirty: saved_as.is_none(), saved_as, @@ -538,6 +559,7 @@ pub fn open(app: &mut SheetApp, params: &Json) -> Option { closing: false, close_requested: false, quit: false, + then_close: None, ask_close: false, start_debug, start_check: params["check"].as_bool() == Some(true), @@ -612,6 +634,77 @@ fn ensure_saved(app: &mut SheetApp, ed: &mut Editor) -> bool { if ed.dirty || ed.saved_as.is_none() { save(app, ed) } else { true } } +impl Editor { + /// The draft holds changes not yet saved into the workbook. A new macro counts once it + /// differs from the empty one the editor started with, its name included. + pub fn has_unsaved_changes(&self) -> bool { + let untouched = Macro { name: self.opened_as.clone(), inputs: vec![NamedInput::default()], ..Default::default() }; + self.dirty && (self.saved_as.is_some() || self.draft != untouched) + } +} + +/// Closing the editor's workbook (File › Close, Cmd+W, the Backstage Close, a window's close +/// button) while the draft has unsaved changes: the editor asks about them first, as its own +/// close button does, and the close (`id` with `params`) goes on once the editor has closed, in +/// the window it was asked in; Cancel keeps both. Closing the window that shows the editor asks +/// too, even when the workbook stays open in another window. A close of another of the +/// workbook's windows, or a forced one, is none of the editor's business (`None`) and goes ahead +/// as before. +/// +/// While the editor is asking, one question stands for one close: a further request for the +/// same close (Cmd+W pressed again, the close button after Cmd+W) waits for the answer without a +/// second question, and the pending close then runs once. A request for a different close (the +/// workbook while only the editor's window was closing, or the reverse) is refused with the +/// reason until the question is answered, so that the answer never closes more or less than +/// what was asked. +pub fn ask_before_closing(app: &mut SheetApp, id: &str, params: &Json) -> Option> { + let flag = |k: &str| params.get(k).and_then(Json::as_bool) == Some(true); + if flag("force") { + return None; + } + let doc = app.macros.editor.as_ref().filter(|ed| ed.has_unsaved_changes()).map(|ed| ed.doc)?; + let window = match id { + "file.close" => app.session.active_window(), + "window.close" => gridcraft_engine::Target::from_json(params).ok().and_then(|t| app.session.target_window(t).ok()), + _ => None, + }; + let owner = window.and_then(|w| app.session.window(w)).map(|w| w.doc_uid)?; + // The editor asks in the window it is shown in. + let home = app.window_with_dialog("macroEditor")?; + let only = app.session.windows().iter().filter(|w| w.doc_uid == owner).count() <= 1; + // The workbook closes, or the window that shows the editor does (taking the editor with it). + let whole = only || (id == "file.close" && flag("document")); + if owner != doc || !(whole || window == Some(home)) { + return None; + } + if app.ui_window() != Some(home) { + app.enter_window(home); + } + let ed = app.macros.editor.as_mut()?; + let waiting = json!({"closed": false, "dirty": true, "macroDraft": true}); + // Quitting: the quit asks about every unsaved workbook itself once the editor has closed, + // and Cancel keeps them all. + if ed.quit { + return Some(Ok(waiting)); + } + match &ed.then_close { + Some(pending) if pending.whole == whole => {} + Some(pending) => { + let what = |whole: bool| if whole { "the workbook" } else { "its window" }; + return Some(Err(format!( + "The macro step editor is asking whether to save its draft before {} closes. Answer that first, then close {}.", + what(pending.whole), + what(whole) + ))); + } + None => { + ed.then_close = Some(PendingClose { window: home, whole, id: id.to_string(), params: params.clone() }); + ed.close_requested = true; + } + } + Some(Ok(waiting)) +} + /// The inputs a run from the editor uses: the first input's example. fn run_inputs(ed: &Editor) -> Json { match ed.draft.inputs.first() { @@ -863,7 +956,7 @@ pub fn window(app: &mut SheetApp, ctx: &egui::Context, d: &mut Dialog, open: &mu if app.session.macro_debug.is_some() { ed.ask_stop = true; ed.closing = true; - } else if ed.dirty { + } else if ed.has_unsaved_changes() { ed.ask_close = true; } else { *open = false; @@ -882,14 +975,32 @@ pub fn window(app: &mut SheetApp, ctx: &egui::Context, d: &mut Dialog, open: &mu restore_home(app, &ed.refs); } if ed.quit { - ctx.send_viewport_cmd(egui::ViewportCommand::Close); + // A quit takes over a workbook close waiting on the editor: it asks about every + // unsaved workbook itself, and the close replayed after it would act on whichever + // workbook the quit is asking about. + if app.native_windows { + // Each workbook window is a native window whose close button closes only it: + // the quit goes on directly. + app.continue_quit(ctx); + } else { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + } else if let Some(close) = ed.then_close.take() { + // The close runs in the window it was asked in, not in whichever window the person + // activated meanwhile: `file.close` acts on the active window and workbook. + if app.session.window(close.window).is_some() { + app.enter_window(close.window); + app.run_or_alert(&close.id, close.params); + } else { + app.message = Some(("Close".into(), "The window that was closing has closed already; nothing else was closed.".into())); + } } } } /// Asks the open step editor to close, as its close button does: it asks about a debug run or -/// unsaved steps first. With `quit` (the window is closing), the window close goes on once the -/// editor has closed; a Cancel in its questions keeps both open. +/// unsaved steps first. With `quit` (the window is closing, or the app is quitting), the close or +/// quit goes on once the editor has closed; a Cancel in its questions keeps both open. pub fn request_close(app: &mut SheetApp, quit: bool) { if let Some(ed) = app.macros.editor.as_mut() { ed.close_requested = true; @@ -904,7 +1015,7 @@ pub fn close_now(app: &mut SheetApp) -> Result<(), String> { if app.session.macro_debug.is_some() { return Err("A macro is being debugged: stop it first (macro.debug {\"action\": \"stop\"}).".into()); } - if app.macros.editor.as_ref().is_some_and(|ed| ed.dirty) { + if app.macros.editor.as_ref().is_some_and(Editor::has_unsaved_changes) { return Err("The macro step editor has unsaved steps: save them or close the editor in its window.".into()); } if let Some(ed) = app.macros.editor.take() @@ -1018,7 +1129,7 @@ fn prompts(app: &mut SheetApp, ctx: &egui::Context, ed: &mut Editor, open: &mut ed.ask_stop = false; stop(app, ed, keep); if ed.closing { - if ed.dirty { ed.ask_close = true } else { *open = false } + if ed.has_unsaved_changes() { ed.ask_close = true } else { *open = false } } ed.closing = false; } @@ -1026,6 +1137,7 @@ fn prompts(app: &mut SheetApp, ctx: &egui::Context, ed: &mut Editor, open: &mut ed.ask_stop = false; ed.closing = false; ed.quit = false; + ed.then_close = None; } None => {} } @@ -1054,6 +1166,11 @@ fn prompts(app: &mut SheetApp, ctx: &egui::Context, ed: &mut Editor, open: &mut ed.ask_close = false; if save(app, ed) { *open = false; + } else { + // The draft stays open with the reason shown; the workbook is not closed and + // the app does not quit, then or when the editor closes later. + ed.then_close = None; + ed.quit = false; } } Some(Some(false)) => { @@ -1063,6 +1180,7 @@ fn prompts(app: &mut SheetApp, ctx: &egui::Context, ed: &mut Editor, open: &mut Some(None) => { ed.ask_close = false; ed.quit = false; + ed.then_close = None; } None => {} } diff --git a/crates/ui-egui/src/macro_ui.rs b/crates/ui-egui/src/macro_ui.rs index 4277721b8..9d1d74f33 100644 --- a/crates/ui-egui/src/macro_ui.rs +++ b/crates/ui-egui/src/macro_ui.rs @@ -181,10 +181,16 @@ pub fn note_result(app: &mut SheetApp, id: &str, params: &Json, v: &Json) { app.toast = Some((text, crate::now_ms())); } -/// Drops the step editor once its workbook is closed. The editor's dialog stays with its -/// workbook's window, so it may not be drawn again to notice. +/// Drops the step editor once its workbook is closed, or once no window shows it (the window it +/// was shown in closed while the workbook stays open in another). The editor's dialog stays with +/// its window, so it may not be drawn again to notice. pub fn forget_closed_editor(app: &mut SheetApp) { - if app.macros.editor.as_ref().is_some_and(|ed| !app.session.documents().iter().any(|d| d.uid == ed.doc)) { + if app + .macros + .editor + .as_ref() + .is_some_and(|ed| !app.session.documents().iter().any(|d| d.uid == ed.doc) || app.window_with_dialog("macroEditor").is_none()) + { app.macros.editor = None; app.grid.macro_overlay = None; } diff --git a/crates/ui-egui/src/window_ui.rs b/crates/ui-egui/src/window_ui.rs index dbabe9b28..1c20feb5e 100644 --- a/crates/ui-egui/src/window_ui.rs +++ b/crates/ui-egui/src/window_ui.rs @@ -145,6 +145,19 @@ impl SheetApp { } } + /// The window that shows the dialog `name`: the current one or a parked one. A window that has + /// closed shows nothing, even while its parked state is still kept. + pub(crate) fn window_with_dialog(&self, name: &str) -> Option { + if self.dialog.as_ref().is_some_and(|d| d.name == name) { + return self.ui_window; + } + self.parked + .iter() + .filter(|(id, w)| w.dialog.as_ref().is_some_and(|d| d.name == name) && self.session.window(**id).is_some()) + .map(|(id, _)| *id) + .min() + } + /// Whether a window other than the current one has a cell or text-box draft open. pub fn parked_window_with_draft(&self) -> Option { self.parked.iter().filter(|(_, w)| w.editor.is_some() || w.text_box_editor.is_some()).map(|(id, _)| *id).min() diff --git a/crates/ui-egui/tests/doc_windows.rs b/crates/ui-egui/tests/doc_windows.rs index 1d72739e3..595f50707 100644 --- a/crates/ui-egui/tests/doc_windows.rs +++ b/crates/ui-egui/tests/doc_windows.rs @@ -18,6 +18,8 @@ thread_local! { static INFOS: RefCell> = RefCell::new(egui::ViewportIdMap::default()); /// The viewport commands the app sent this frame. static SENT: RefCell> = const { RefCell::new(Vec::new()) }; + /// Input events for the root window's next frame (a key pressed in it). + static EVENTS: RefCell> = const { RefCell::new(Vec::new()) }; } fn record(mut out: egui::FullOutput) { @@ -65,6 +67,7 @@ impl Desk { let mut input = RawInput { viewport_id: ViewportId::ROOT, ..RawInput::default() }; input.viewports = INFOS.with(|i| i.borrow().clone()); input.viewports.entry(ViewportId::ROOT).or_default(); + input.events = EVENTS.with(|e| std::mem::take(&mut *e.borrow_mut())); let Desk { ctx, app, windows } = self; let mut act = Some(act); let out = ctx.clone().run_ui(input, |ui| { @@ -115,6 +118,28 @@ impl Desk { let uid = self.app.session.documents()[doc].uid; self.app.session.doc_window(uid).unwrap_or_default() } + + /// The user presses `key` in the root window. + fn key(&mut self, key: egui::Key) -> Vec<(ViewportId, ViewportCommand)> { + EVENTS.with(|e| { + e.borrow_mut().push(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }) + }); + let mut sent = self.frame_with(|_, _| {}); + sent.extend(self.frames(2)); + sent + } + + /// Opens the step editor on a new macro in the active window and edits it, so the draft has + /// unsaved changes. + fn edited_new_macro(&mut self) { + self.app.open_dialog("macroEditor", json!({})); + self.frames(3); + if let Some(ed) = self.app.macros.editor.as_mut() { + ed.draft.steps.push(gridcraft_engine::model::Step::command("cell.set", json!({"cell": "A1", "input": "7"}))); + ed.dirty = true; + } + self.frames(2); + } } fn sent_to(sent: &[(ViewportId, ViewportCommand)], vp: ViewportId, cmd: &ViewportCommand) -> bool { @@ -437,3 +462,116 @@ fn arrange_all_lays_out_the_native_windows_active_first() { let r = |x, y, w, h| craft_window::Rect::new(x, y, w, h); assert_eq!(tiled, [r(0.0, 0.0, 500.0, 400.0), r(500.0, 0.0, 500.0, 400.0), r(0.0, 400.0, 1000.0, 400.0)]); } + +#[test] +fn quit_with_the_macro_editor_open_goes_on_once_the_editor_has_closed() { + let mut desk = Desk::new(false); + desk.app.run("macro.save", json!({"macro": {"name": "Kept", "steps": []}})).unwrap(); + let d = desk.app.session.active_mut().unwrap(); + d.saved = d.wb.clone(); + desk.app.open_dialog("macroEditor", json!({"macro": "Kept"})); + desk.frames(3); + assert!(desk.app.macros.editor.is_some()); + // The editor has nothing unsaved, so it closes; then the quit goes on as the app's quit, not + // as the editor window's close button (which would close only that workbook's window). + let mut sent = desk.frame_with(|app, ctx| app.request_quit(ctx)); + sent.extend(desk.frames(2)); + assert!(desk.app.macros.editor.is_none()); + assert!(desk.app.allow_close, "the quit goes on: {sent:?}"); + assert!(sent_to(&sent, ViewportId::ROOT, &ViewportCommand::Close), "{sent:?}"); + assert_eq!(desk.app.session.documents().len(), 1, "the workbook was not closed on its own"); +} + +#[test] +fn quit_takes_over_a_workbook_close_waiting_on_the_macro_editor() { + let mut desk = Desk::new(false); + let w1 = desk.window_of(0); + desk.app.open_dialog("macroEditor", json!({})); + desk.frames(3); + if let Some(ed) = desk.app.macros.editor.as_mut() { + ed.draft.steps.push(gridcraft_engine::model::Step::command("cell.set", json!({"cell": "A1", "input": "7"}))); + ed.dirty = true; + } + desk.frames(2); + // Book2 has unsaved changes; Book1, with the editor, is saved. + desk.app.run("file.new", json!({})).unwrap(); + desk.app.run("cell.set", json!({"cell": "A1", "input": "2"})).unwrap(); + desk.frames(3); + let w2 = desk.window_of(1); + desk.app.enter_window(w1); + desk.frames(2); + // File › Close on Book1 waits on the editor's question; then Quit takes over. + let r = desk.app.run("file.close", json!({})).unwrap(); + assert_eq!(r["closed"], false); + desk.frames(3); + desk.frame_with(|app, ctx| app.request_quit(ctx)); + // Don't Save (the question's answer closes the editor without saving). + if let Some(ed) = desk.app.macros.editor.as_mut() { + ed.ask_close = false; + ed.dirty = false; + ed.close_requested = true; + } + desk.frames(3); + assert!(desk.app.macros.editor.is_none()); + // The quit asks about Book2 in its window, as the quit's question, not as a File › Close. + assert_eq!(desk.app.session.active_window(), Some(w2)); + let d = desk.app.dialog.as_ref().expect("Book2 is asked about"); + assert_eq!(d.name(), "saveChanges"); + assert_eq!(d.values.get("quit"), Some(&json!(true)), "{:?}", d.values); + assert_eq!(desk.app.session.documents().len(), 2, "nothing closed before the answer"); +} + +#[test] +fn quit_with_a_draft_in_one_window_and_unsaved_cells_in_another_asks_in_order_and_cancel_stops_it() { + let mut desk = Desk::new(false); + // Book1 (the root window), saved, with an unsaved draft; Book2 with unsaved cells. + let w1 = desk.window_of(0); + desk.edited_new_macro(); + desk.app.run("file.new", json!({})).unwrap(); + desk.app.run("cell.set", json!({"cell": "A1", "input": "2"})).unwrap(); + desk.frames(3); + let w2 = desk.window_of(1); + assert_eq!(desk.app.session.active_window(), Some(w2)); + // Quit from Book2's window: the editor's question comes first, in Book1's window. + let mut sent = desk.frame_with(|app, ctx| app.request_quit(ctx)); + sent.extend(desk.frames(2)); + assert!(!sent_to(&sent, ViewportId::ROOT, &ViewportCommand::Close) && !desk.app.allow_close, "{sent:?}"); + assert_eq!(desk.app.session.active_window(), Some(w1), "the editor's window comes forward"); + assert!(desk.app.macros.editor.as_ref().is_some_and(|ed| ed.ask_close && ed.quit), "the editor asks"); + assert_eq!(desk.app.dialog.as_ref().map(|d| d.name()), Some("macroEditor"), "no workbook is asked about yet"); + // Don't Save (the question's answer closes the editor without saving). + if let Some(ed) = desk.app.macros.editor.as_mut() { + ed.ask_close = false; + ed.dirty = false; + ed.close_requested = true; + } + desk.frames(3); + assert!(desk.app.macros.editor.is_none()); + // Book2 is asked about next, in its window, as the quit's question. + assert_eq!(desk.app.session.active_window(), Some(w2), "the unsaved workbook's window comes forward"); + let d = desk.app.dialog.as_ref().expect("Book2 is asked about"); + assert_eq!(d.name(), "saveChanges"); + assert_eq!(d.values.get("title").and_then(serde_json::Value::as_str), Some("Book2")); + assert_eq!(d.values.get("quit"), Some(&json!(true)), "{:?}", d.values); + assert_eq!(desk.app.session.documents().len(), 2, "nothing closed before the answer"); + // Cancel: the quit stops with both windows and workbooks open; the discarded draft stays + // discarded. + let sent = desk.answer(SaveAnswer::Cancel); + assert!(!sent_to(&sent, ViewportId::ROOT, &ViewportCommand::Close) && !desk.app.allow_close, "{sent:?}"); + assert_eq!(desk.app.session.documents().len(), 2); + assert_eq!(desk.windows.shown_windows(), [w1, w2]); + assert!(desk.app.dialog.is_none() && desk.app.macros.editor.is_none()); + assert!(desk.app.session.documents()[1].is_dirty(), "Book2 keeps its changes"); + // A quit cancelled at the editor's question (Escape) stops before any workbook is asked about. + desk.app.enter_window(w1); + desk.edited_new_macro(); + let mut sent = desk.frame_with(|app, ctx| app.request_quit(ctx)); + sent.extend(desk.frames(2)); + assert!(desk.app.macros.editor.as_ref().is_some_and(|ed| ed.ask_close && ed.quit), "the editor asks"); + sent.extend(desk.key(egui::Key::Escape)); + assert!(!sent_to(&sent, ViewportId::ROOT, &ViewportCommand::Close) && !desk.app.allow_close, "{sent:?}"); + assert!(desk.app.macros.editor.as_ref().is_some_and(|ed| !ed.ask_close && !ed.quit && ed.draft.steps.len() == 1), "the draft is kept"); + assert_eq!(desk.app.dialog.as_ref().map(|d| d.name()), Some("macroEditor"), "no Save Changes question followed"); + assert_eq!(desk.app.session.documents().len(), 2); + assert_eq!(desk.windows.shown_windows(), [w1, w2]); +} diff --git a/crates/ui-egui/tests/it/macro_editor.rs b/crates/ui-egui/tests/it/macro_editor.rs index f2130fcb6..6523dd039 100644 --- a/crates/ui-egui/tests/it/macro_editor.rs +++ b/crates/ui-egui/tests/it/macro_editor.rs @@ -5,16 +5,20 @@ use egui::{Event, Key, Modifiers, PointerButton}; use egui_kittest::kittest::Queryable as _; use gridcraft_engine::Session; use gridcraft_engine::core::{CellRef, Value}; -use gridcraft_engine::model::StepKind; -use gridcraft_ui_egui::SheetApp; +use gridcraft_engine::model::{Step, StepKind}; +use gridcraft_ui_egui::{Services, SheetApp}; use serde_json::json; type Harness = egui_kittest::Harness<'static, SheetApp>; fn harness() -> Harness { + harness_with(Default::default()) +} + +fn harness_with(services: Services) -> Harness { let mut session = Session::new(); session.new_workbook(); - let mut app = SheetApp::new(session, Default::default()); + let mut app = SheetApp::new(session, services); app.ui.ribbon_tab = "Automate".into(); let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_ui_state( |ui, app: &mut SheetApp| { @@ -280,7 +284,7 @@ fn a_new_macro_never_replaces_one_with_its_name() { { let ed = h.state_mut().macros.editor.as_mut().unwrap(); ed.draft.name = "three".into(); - ed.draft.steps = vec![gridcraft_engine::model::Step::command("cell.set", json!({"cell": "B1", "input": "x"}))]; + ed.draft.steps = vec![Step::command("cell.set", json!({"cell": "B1", "input": "x"}))]; ed.dirty = true; } h.run_steps(2); @@ -306,7 +310,7 @@ fn a_rename_that_lands_before_a_failed_save_is_remembered() { let ed = h.state_mut().macros.editor.as_mut().unwrap(); ed.draft.name = "Renamed".into(); // Too large to keep in the workbook: the rename lands, the save after it fails. - ed.draft.steps.push(gridcraft_engine::model::Step::command("cell.set", json!({"cell": "A4", "input": big}))); + ed.draft.steps.push(Step::command("cell.set", json!({"cell": "A4", "input": big}))); ed.dirty = true; } h.run_steps(2); @@ -366,7 +370,7 @@ fn the_editor_works_only_on_the_workbook_it_opened_in() { // A new macro whose one step writes A1, not saved yet. h.state_mut().open_dialog("macroEditor", json!({"new": "Fresh"})); h.run_steps(3); - let step = gridcraft_engine::model::Step::command("cell.set", json!({"cell": "A1", "input": "ran"})); + let step = Step::command("cell.set", json!({"cell": "A1", "input": "ran"})); h.state_mut().macros.editor.as_mut().unwrap().draft.steps = vec![step]; let book1 = h.state().session.active().unwrap().uid; // Another workbook becomes active while the editor is open (File › New, Switch Window, an agent). @@ -406,6 +410,55 @@ fn the_editor_works_only_on_the_workbook_it_opened_in() { assert!(h.state().dialog.as_ref().is_none_or(|d| d.name() != "macroEditor")); } +#[test] +fn a_deferred_close_acts_on_the_editors_window_whichever_window_is_active_meanwhile() { + let mut h = harness(); + edited_new_macro(&mut h); + let name = h.state().macros.editor.as_ref().unwrap().draft.name.clone(); + let book1 = h.state().session.active().unwrap().uid; + let home = h.state().session.active_window().unwrap(); + // Another workbook in a window of its own. + h.state_mut().run("file.new", json!({})).unwrap(); + h.run_steps(2); + let book2 = h.state().session.active().unwrap().uid; + let other = h.state().session.active_window().unwrap(); + assert_ne!(other, home); + // Back in the editor's window, File › Close asks about the draft and waits. + h.state_mut().enter_window(home); + h.run_steps(2); + let r = h.state_mut().run("file.close", json!({})).unwrap(); + assert_eq!(r["closed"], false); + h.run_steps(3); + assert!(asks_about(&h, &name)); + assert_eq!(h.state().macros.editor.as_ref().and_then(|e| e.then_close.as_ref()).map(|c| c.window), Some(home)); + // The other window is the session's active one when the question is answered (a native + // window the person clicked on while the question stood), while the editor's window draws + // the question: the answer is given through the editor's own frame, as its button gives it. + let ctx = h.ctx.clone(); + let app = h.state_mut(); + let mut d = app.dialog.take().expect("the editor is its window's dialog"); + assert_eq!(d.name(), "macroEditor"); + app.session.activate_window(other); + assert_eq!(app.session.active().map(|d| d.uid), Some(book2)); + // Don't Save: the question's answer closes the editor without saving. + let ed = app.macros.editor.as_mut().unwrap(); + ed.ask_close = false; + ed.dirty = false; + ed.close_requested = true; + let mut open = true; + let mut out = ctx.run_pass(egui::RawInput::default(), |ctx| gridcraft_ui_egui::macro_editor::window(app, ctx, &mut d, &mut open)); + out.textures_delta.clear(); // not painted + assert!(!open, "the editor closed"); + // The close that was asked in the editor's window closes that workbook, not the other. + assert!(!is_open(&h, book1), "the editor's workbook closed"); + assert!(is_open(&h, book2), "the workbook of the window activated meanwhile stays open"); + assert!(h.state().session.window(other).is_some()); + assert!(h.state().macros.editor.is_none()); + assert!(h.state().message.is_none(), "{:?}", h.state().message); + h.run_steps(2); + assert!(h.state().dialog.is_none()); +} + /// Asks the window to close (the close button, Alt+F4, quitting) and returns the commands the /// app answered with over the next frames. fn request_window_close(h: &mut Harness) -> Vec { @@ -552,3 +605,436 @@ fn a_macro_at_the_step_limit_opens_without_freezing_the_editor() { assert!(ms < 10_000.0, "five frames took {ms} ms"); assert!(h.query_by_label_contains("Step 1: ").is_some()); } + +/// Opens the editor on a new macro and gives its draft a step, as editing it does. +fn edited_new_macro(h: &mut Harness) { + h.state_mut().open_dialog("macroEditor", json!({})); + h.run_steps(3); + let ed = h.state_mut().macros.editor.as_mut().unwrap(); + ed.draft.steps.push(Step::command("cell.set", json!({"cell": "A1", "input": "7"}))); + ed.dirty = true; + h.run_steps(2); +} + +/// Clicks a button of the editor's Save changes question: the question's buttons are drawn last +/// (the formula bar has a Cancel and the editor a Save of their own). +fn answer(h: &mut Harness, label: &str) { + h.get_all_by_role_and_label(egui::Role::Button, label).last().unwrap().click(); + h.run_steps(4); +} + +/// Whether the workbook `uid` is still open (the app starts a blank one when the last closes). +fn is_open(h: &Harness, uid: u64) -> bool { + h.state().session.documents().iter().any(|d| d.uid == uid) +} + +fn asks_about(h: &Harness, name: &str) -> bool { + h.query_by_label_contains(&format!("Save changes to macro “{name}”")).is_some() +} + +#[test] +fn closing_the_workbook_asks_about_an_unsaved_draft_first() { + let mut h = harness(); + edited_new_macro(&mut h); + let name = h.state().macros.editor.as_ref().unwrap().draft.name.clone(); + let book = h.state().session.active().unwrap().uid; + assert!(!h.state().session.active().unwrap().is_dirty()); + // The workbook is saved, but the draft is not: File › Close asks about the draft and waits. + let r = h.state_mut().run("file.close", json!({})).unwrap(); + assert_eq!(r["closed"], false); + h.run_steps(3); + assert!(asks_about(&h, &name)); + assert!(is_open(&h, book)); + // Cancel keeps the workbook and the draft. + answer(&mut h, "Cancel"); + assert!(is_open(&h, book)); + assert_eq!(h.state().macros.editor.as_ref().map(|e| e.draft.steps.len()), Some(1)); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("macroEditor")); + // A window's close button (`window.close`) asks the same way. + let window = h.state().session.active_window().unwrap(); + let r = h.state_mut().run("window.close", json!({"window": window})).unwrap(); + assert_eq!(r["closed"], false); + h.run_steps(3); + assert!(asks_about(&h, &name)); + answer(&mut h, "Cancel"); + assert!(is_open(&h, book)); + // Don't Save closes the editor, then the workbook (which has nothing of its own to ask about). + h.state_mut().run("file.close", json!({})).unwrap(); + h.run_steps(3); + answer(&mut h, "Don't Save"); + assert!(h.state().macros.editor.is_none()); + assert!(!is_open(&h, book)); + assert!(h.state().dialog.is_none()); +} + +#[test] +fn save_in_the_question_saves_the_macro_then_the_workbook_asks_about_itself() { + let path = std::env::temp_dir().join(format!("gridcraft-macro-draft-{}.xlsx", std::process::id())); + let _ = std::fs::remove_file(&path); + let target = path.to_string_lossy().to_string(); + let mut h = harness_with(Services { pick_save: Some(Box::new(move |_| Some(target.clone()))), ..Default::default() }); + edited_new_macro(&mut h); + let book = h.state().session.active().unwrap().uid; + h.state_mut().run("file.close", json!({})).unwrap(); + h.run_steps(3); + answer(&mut h, "Save"); + // The macro is in the workbook now, which makes it unsaved: closing it goes on and asks. + assert!(h.state().macros.editor.is_none()); + assert_eq!(h.state().session.active().unwrap().wb.macros.len(), 1); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("saveChanges")); + h.get_all_by_role_and_label(egui::Role::Button, "Save").last().unwrap().click(); + h.run_steps(4); + assert!(!is_open(&h, book)); + let mut s = Session::new(); + s.execute("file.open", json!({"path": path.to_str().unwrap()})).unwrap(); + let wb = &s.active().unwrap().wb; + assert_eq!(wb.macros.len(), 1); + assert_eq!(wb.macros[0].steps.len(), 1); + let _ = std::fs::remove_file(&path); +} + +#[test] +fn a_draft_that_cannot_be_saved_keeps_its_workbook_open() { + let mut h = harness(); + h.state_mut().run("macro.save", json!({"macro": {"name": "Taken", "steps": []}})).unwrap(); + let d = h.state_mut().session.active_mut().unwrap(); + d.saved = d.wb.clone(); + edited_new_macro(&mut h); + let book = h.state().session.active().unwrap().uid; + // A new macro never replaces one with its name, so Save is refused. + h.state_mut().macros.editor.as_mut().unwrap().draft.name = "Taken".into(); + h.state_mut().run("file.close", json!({})).unwrap(); + h.run_steps(3); + answer(&mut h, "Save"); + assert!(h.state().message.is_some()); + h.state_mut().message = None; + h.run_steps(2); + assert!(h.state().macros.editor.is_some()); + // Closing the editor itself later never closes the workbook the earlier question was about. + h.state_mut().macros.editor.as_mut().unwrap().close_requested = true; + h.run_steps(3); + answer(&mut h, "Don't Save"); + assert!(h.state().macros.editor.is_none()); + assert!(is_open(&h, book)); +} + +#[test] +fn an_untouched_new_macro_does_not_make_closing_ask() { + let mut h = harness(); + h.state_mut().open_dialog("macroEditor", json!({})); + h.run_steps(3); + assert!(h.state().macros.editor.as_ref().unwrap().dirty); + let book = h.state().session.active().unwrap().uid; + let r = h.state_mut().run("file.close", json!({})).unwrap(); + assert_eq!(r["closed"], true); + assert!(!is_open(&h, book)); + h.run_steps(3); + assert!(h.state().macros.editor.is_none()); + assert!(h.state().dialog.is_none()); +} + +#[test] +fn a_new_macros_name_change_alone_makes_closing_ask() { + let mut h = harness(); + h.state_mut().open_dialog("macroEditor", json!({})); + h.run_steps(3); + let book = h.state().session.active().unwrap().uid; + // Only the name changes, as typing in the Name box does. + let ed = h.state_mut().macros.editor.as_mut().unwrap(); + ed.draft.name = "Renamed".into(); + ed.dirty = true; + h.run_steps(2); + let r = h.state_mut().run("file.close", json!({})).unwrap(); + assert_eq!(r["closed"], false); + h.run_steps(3); + assert!(asks_about(&h, "Renamed")); + assert!(is_open(&h, book)); +} + +/// Quit (Cmd+Q, the macOS Quit menu, a Dock quit) and the commands the app answered with over the +/// next frames. +fn request_quit(h: &mut Harness) -> Vec { + let ctx = h.ctx.clone(); + h.state_mut().request_quit(&ctx); + step_commands(h) +} + +#[test] +fn quit_asks_about_the_editors_unsaved_steps_first() { + let mut h = harness(); + edited_new_macro(&mut h); + let name = h.state().macros.editor.as_ref().unwrap().draft.name.clone(); + assert!(!h.state().session.active().unwrap().is_dirty()); + // Every workbook is saved, but the draft is not: the app stays and the editor asks. + let sent = request_quit(&mut h); + assert!(!sent.contains(&egui::ViewportCommand::Close), "{sent:?}"); + assert!(!h.state().allow_close); + assert!(asks_about(&h, &name)); + // Cancel keeps the app and the draft. + answer(&mut h, "Cancel"); + let sent = step_commands(&mut h); + assert!(!sent.contains(&egui::ViewportCommand::Close), "{sent:?}"); + assert_eq!(h.state().macros.editor.as_ref().map(|e| e.draft.steps.len()), Some(1)); + // Asked again, Don't Save closes the editor and the quit goes on. + request_quit(&mut h); + assert!(asks_about(&h, &name)); + h.get_by_label("Don't Save").click(); + let sent = step_commands(&mut h); + assert!(h.state().macros.editor.is_none()); + assert!(sent.contains(&egui::ViewportCommand::Close), "{sent:?}"); +} + +#[test] +fn a_refused_save_while_quitting_stops_the_quit() { + let mut h = harness(); + h.state_mut().run("macro.save", json!({"macro": {"name": "Taken", "steps": []}})).unwrap(); + let d = h.state_mut().session.active_mut().unwrap(); + d.saved = d.wb.clone(); + edited_new_macro(&mut h); + // A new macro never replaces one with its name, so the question's Save is refused. + h.state_mut().macros.editor.as_mut().unwrap().draft.name = "Taken".into(); + for quit in [request_quit, request_window_close] { + h.run_steps(2); + let sent = quit(&mut h); + assert!(sent.contains(&egui::ViewportCommand::CancelClose) || !h.state().allow_close, "{sent:?}"); + assert!(asks_about(&h, "Taken")); + answer(&mut h, "Save"); + assert!(h.state().message.is_some()); + h.state_mut().message = None; + assert!(h.state().macros.editor.is_some()); + // Closing the editor later, on its own, no longer quits the app. + h.state_mut().macros.editor.as_mut().unwrap().close_requested = true; + h.run_steps(3); + h.get_by_label("Don't Save").click(); + let sent = step_commands(&mut h); + assert!(h.state().macros.editor.is_none()); + assert!(!sent.contains(&egui::ViewportCommand::Close), "the app keeps running: {sent:?}"); + assert!(!h.state().allow_close); + // The next round starts with the same draft in the editor again. + edited_new_macro(&mut h); + h.state_mut().macros.editor.as_mut().unwrap().draft.name = "Taken".into(); + } +} + +#[test] +fn an_untouched_new_macro_closes_without_asking_however_the_editor_closes() { + let mut h = harness(); + let ctx = h.ctx.clone(); + let open_new = |h: &mut Harness| { + h.state_mut().open_dialog("macroEditor", json!({})); + h.run_steps(3); + assert!(h.state().macros.editor.as_ref().is_some_and(|e| e.dirty && e.saved_as.is_none())); + }; + // Its own close button. + open_new(&mut h); + h.state_mut().macros.editor.as_mut().unwrap().close_requested = true; + h.run_steps(3); + assert!(h.state().macros.editor.is_none(), "no Save changes question for a macro nobody edited"); + // The control channel's cancel. + open_new(&mut h); + let r = gridcraft_ui_egui::control::handle(h.state_mut(), &ctx, "ui.dialog.cancel", &json!({})); + assert!(matches!(&r, gridcraft_ui_egui::control::Outcome::Done(v) if v.get("error").is_none())); + h.run_steps(2); + assert!(h.state().macros.editor.is_none()); + // Quit, with every workbook saved: the editor closes and the quit goes on. + open_new(&mut h); + let sent = request_quit(&mut h); + assert!(h.state().macros.editor.is_none()); + assert!(sent.contains(&egui::ViewportCommand::Close), "{sent:?}"); +} + +#[test] +fn closing_the_window_that_shows_the_editor_asks_about_its_draft() { + let mut h = harness(); + edited_new_macro(&mut h); + let name = h.state().macros.editor.as_ref().unwrap().draft.name.clone(); + let home = h.state().session.active_window().unwrap(); + h.state_mut().run("view.newWindow", json!({})).unwrap(); + h.run_steps(3); + let other = h.state().session.active_window().unwrap(); + assert_ne!(other, home); + // The workbook stays open in its other window, but the editor's window would take the draft. + let r = h.state_mut().run("window.close", json!({"window": home})).unwrap(); + assert_eq!(r["closed"], false); + h.run_steps(3); + assert_eq!(h.state().session.active_window(), Some(home), "the editor's window comes forward to ask"); + assert!(asks_about(&h, &name)); + answer(&mut h, "Cancel"); + assert!(h.state().session.window(home).is_some()); + assert_eq!(h.state().macros.editor.as_ref().map(|e| e.draft.steps.len()), Some(1)); + // File › Close in the editor's window asks too; Don't Save closes that window and the editor. + let r = h.state_mut().run("file.close", json!({})).unwrap(); + assert_eq!(r["closed"], false); + h.run_steps(3); + assert!(asks_about(&h, &name)); + answer(&mut h, "Don't Save"); + assert!(h.state().macros.editor.is_none()); + assert!(h.state().session.window(home).is_none()); + assert!(h.state().session.window(other).is_some(), "the workbook stays open in its other window"); +} + +#[test] +fn closing_the_window_that_shows_a_saved_editor_closes_the_editor_with_it() { + let mut h = harness(); + three(&mut h); + let home = h.state().session.active_window().unwrap(); + h.state_mut().run("view.newWindow", json!({})).unwrap(); + h.run_steps(3); + let r = h.state_mut().run("window.close", json!({"window": home})).unwrap(); + assert_eq!(r["closed"], true); + h.run_steps(3); + // Nothing is left of an editor no window shows: a new one opens, and nothing was lost. + assert!(h.state().macros.editor.is_none()); + h.state_mut().open_dialog("macroEditor", json!({"macro": "Three"})); + h.run_steps(3); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("macroEditor")); +} + +#[test] +fn a_close_request_during_the_editors_save_dialog_waits_for_it() { + let mut h = harness(); + edited_new_macro(&mut h); + let name = h.state().macros.editor.as_ref().unwrap().draft.name.clone(); + let book1 = h.state().session.active().unwrap().uid; + let home = h.state().session.active_window().unwrap(); + h.state_mut().run("file.new", json!({})).unwrap(); + h.run_steps(2); + let book2 = h.state().session.active().unwrap().uid; + h.state_mut().enter_window(home); + h.run_steps(2); + // Cmd+W: the editor asks about the draft and the close waits. + let r = h.state_mut().run("file.close", json!({})).unwrap(); + assert_eq!(r["closed"], false); + h.run_steps(3); + assert!(asks_about(&h, &name)); + // Cmd+W again, then the window's close button, while the question stands: the same close, + // so both wait on the same question; it is not asked twice and nothing closes under it. + let r = h.state_mut().run("file.close", json!({})).unwrap(); + assert_eq!(r["closed"], false); + let r = h.state_mut().run("window.close", json!({"window": home})).unwrap(); + assert_eq!(r["closed"], false); + h.run_steps(3); + assert_eq!(h.get_all_by_label_contains("Save changes to macro").count(), 1, "one question"); + assert!(is_open(&h, book1) && is_open(&h, book2)); + assert!(h.state().message.is_none(), "{:?}", h.state().message); + let pending = h.state().macros.editor.as_ref().and_then(|e| e.then_close.as_ref()).expect("the close waits"); + assert_eq!((pending.id.as_str(), pending.whole), ("file.close", true), "the close that opened the question is the pending one"); + // Don't Save: the editor closes and the workbook closes once; the other workbook stays. + answer(&mut h, "Don't Save"); + assert!(h.state().macros.editor.is_none()); + assert!(!is_open(&h, book1)); + assert!(is_open(&h, book2), "the close ran once"); + assert!(h.state().dialog.is_none()); + assert!(h.state().message.is_none(), "{:?}", h.state().message); +} + +#[test] +fn a_different_close_during_the_editors_question_is_refused_until_it_is_answered() { + let mut h = harness(); + edited_new_macro(&mut h); + let name = h.state().macros.editor.as_ref().unwrap().draft.name.clone(); + let book = h.state().session.active().unwrap().uid; + let home = h.state().session.active_window().unwrap(); + h.state_mut().run("view.newWindow", json!({})).unwrap(); + h.run_steps(3); + let other = h.state().session.active_window().unwrap(); + // The editor's window closes while the workbook stays open in the other: the editor asks. + let r = h.state_mut().run("window.close", json!({"window": home})).unwrap(); + assert_eq!(r["closed"], false); + h.run_steps(3); + assert!(asks_about(&h, &name)); + // The Backstage Close (the whole workbook) while that question stands asks for more than + // the question was asked for: refused with the reason; the question and the workbook stay. + let e = h.state_mut().run("file.close", json!({"document": true})).unwrap_err(); + assert!(e.contains("asking whether to save") && e.contains("its window closes"), "{e}"); + h.run_steps(2); + assert_eq!(h.get_all_by_label_contains("Save changes to macro").count(), 1, "one question"); + assert!(is_open(&h, book)); + assert_eq!(h.state().session.windows().len(), 2); + // Don't Save closes the editor's window alone, as the question was asked for. + answer(&mut h, "Don't Save"); + assert!(h.state().macros.editor.is_none()); + assert!(h.state().session.window(home).is_none()); + assert!(h.state().session.window(other).is_some()); + assert!(is_open(&h, book), "the workbook stays open in its other window"); + // The reverse: the workbook is closing when a close of the editor's window alone arrives. + h.state_mut().run("view.newWindow", json!({})).unwrap(); + h.run_steps(3); + h.state_mut().enter_window(other); + edited_new_macro(&mut h); + let name = h.state().macros.editor.as_ref().unwrap().draft.name.clone(); + let r = h.state_mut().run("file.close", json!({"document": true})).unwrap(); + assert_eq!(r["closed"], false); + h.run_steps(3); + assert!(asks_about(&h, &name)); + let e = h.state_mut().run("window.close", json!({"window": other})).unwrap_err(); + assert!(e.contains("the workbook closes"), "{e}"); + // Cancel keeps the workbook, both windows and the draft. + answer(&mut h, "Cancel"); + assert!(is_open(&h, book)); + assert_eq!(h.state().session.windows().len(), 2); + assert_eq!(h.state().macros.editor.as_ref().map(|e| e.draft.steps.len()), Some(1)); + assert!(h.state().macros.editor.as_ref().is_some_and(|e| e.then_close.is_none())); +} + +#[test] +fn quitting_with_a_draft_in_one_window_and_unsaved_cells_in_another_asks_in_order_and_cancel_stops_it() { + let mut h = harness(); + // Book1, saved, with an unsaved draft in its window; Book2 with unsaved cells in its own. + edited_new_macro(&mut h); + let name = h.state().macros.editor.as_ref().unwrap().draft.name.clone(); + let book1 = h.state().session.active().unwrap().uid; + let a = h.state().session.active_window().unwrap(); + assert!(!h.state().session.active().unwrap().is_dirty()); + h.state_mut().run("file.new", json!({})).unwrap(); + h.state_mut().run("cell.set", json!({"cell": "Z9", "input": "unsaved"})).unwrap(); + h.run_steps(2); + let book2 = h.state().session.active().unwrap().uid; + let title2 = h.state().session.active().unwrap().display_title(); + let b = h.state().session.active_window().unwrap(); + assert_ne!(a, b); + let both_open = + |h: &Harness| is_open(h, book1) && is_open(h, book2) && h.state().session.window(a).is_some() && h.state().session.window(b).is_some(); + // Quit from Book2's window: the editor's question comes first, in its own window. + let sent = request_quit(&mut h); + assert!(!sent.contains(&egui::ViewportCommand::Close) && !h.state().allow_close, "{sent:?}"); + assert_eq!(h.state().session.active_window(), Some(a), "the editor's window comes forward"); + assert!(asks_about(&h, &name)); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("macroEditor"), "no workbook is asked about yet"); + // Don't Save: the draft goes, the editor closes and the quit goes on (the window asks to + // close again, which a test harness has to do by hand). + h.get_all_by_role_and_label(egui::Role::Button, "Don't Save").last().unwrap().click(); + let sent = step_commands(&mut h); + assert!(h.state().macros.editor.is_none()); + assert!(sent.contains(&egui::ViewportCommand::Close), "{sent:?}"); + request_window_close(&mut h); + // Book2 is asked about next, in its window, as the quit's question. + assert_eq!(h.state().session.active_window(), Some(b), "the unsaved workbook's window comes forward"); + let d = h.state().dialog.as_ref().expect("Book2 is asked about"); + assert_eq!(d.name(), "saveChanges"); + assert_eq!(d.values.get("title").and_then(serde_json::Value::as_str), Some(title2.as_str())); + assert_eq!(d.values.get("quit"), Some(&json!(true)), "{:?}", d.values); + assert!(both_open(&h)); + // Cancel: the quit stops with both windows and workbooks open and Book2's changes kept; the + // draft the person discarded stays discarded. + h.get_all_by_role_and_label(egui::Role::Button, "Cancel").last().unwrap().click(); + let sent = step_commands(&mut h); + assert!(!sent.contains(&egui::ViewportCommand::Close) && !h.state().allow_close, "{sent:?}"); + assert!(h.state().dialog.is_none()); + assert!(both_open(&h)); + assert!(h.state().session.documents().iter().find(|d| d.uid == book2).unwrap().is_dirty()); + assert!(h.state().macros.editor.is_none()); + // A quit cancelled at the editor's question stops before any workbook is asked about. + h.state_mut().enter_window(a); + edited_new_macro(&mut h); + let name = h.state().macros.editor.as_ref().unwrap().draft.name.clone(); + let sent = request_quit(&mut h); + assert!(!sent.contains(&egui::ViewportCommand::Close), "{sent:?}"); + assert!(asks_about(&h, &name)); + answer(&mut h, "Cancel"); + let sent = step_commands(&mut h); + assert!(!sent.contains(&egui::ViewportCommand::Close) && !h.state().allow_close, "{sent:?}"); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("macroEditor"), "no Save Changes question followed"); + assert_eq!(h.state().macros.editor.as_ref().map(|e| e.draft.steps.len()), Some(1), "the draft is kept"); + assert!(both_open(&h)); +} diff --git a/docs/macros.md b/docs/macros.md index 3335b5ecc..1cd2cfbb5 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -1,6 +1,6 @@ # Macros -> **Last updated:** 2026-10-25 · **Change:** minor (recording macros and the management commands; Record Macro takes a name, shortcut and description; the macro interface: Automate › Macros, the Macros and Record Macro dialogs, the step editor, shortcuts) +> **Last updated:** 2026-11-01 · **Change:** minor (a close that waits on the step editor's question runs in the editor's window, and a further close asked meanwhile waits for the answer or is refused; earlier: closing a workbook or quitting asks about the step editor's unsaved draft first; recording macros and the management commands; Record Macro takes a name, shortcut and description; the macro interface: Automate › Macros, the Macros and Record Macro dialogs, the step editor, shortcuts) GridCraft macros are typed, declarative lists of commands, not VBA. Every step runs a GridCraft command (the same ids the ribbon, CLI and MCP use) or one of three bounded control steps. A macro @@ -294,9 +294,21 @@ other macros open; a macro that was left out is not in the saved file. the Output tab sets the anchor and what to do when results would land on data. The editor belongs to the workbook it opened in: while another workbook is active it says so and Save, Check, Run and Debug wait until that workbook is active again, and closing the workbook closes - the editor. It keeps its window until it closes itself: another dialog asked for meanwhile is - refused with a message, and closing the app first asks about the editor's debug run and unsaved - steps, then about unsaved workbooks. + the editor. A draft with unsaved changes counts as unsaved work when its workbook closes (File > + Close, Cmd+W, a window's close button), or the window that shows the editor closes while the + workbook stays open in another: the editor first asks Save changes to the macro, even if the + workbook itself is saved; Save or Don't Save goes on with the close, in the window it was asked + in (whichever window was activated meanwhile), and a workbook that closes then asks about its + own unsaved changes, and Cancel keeps both open. While the question stands, a further request + for the same close (Cmd+W again, the window's close button) waits for the answer without a + second question, and the close then runs once; a request for a different close (the whole + workbook while only the editor's window was closing, or the reverse) is refused with the reason + until the question is answered. A new macro that has not been edited does not ask. It keeps its + window until it closes itself: another dialog asked for meanwhile is refused with a message, and + closing the app (its window's close button, Quit, Cmd+Q) first asks about the editor's debug + run and unsaved steps, in the editor's window, then about each unsaved workbook in its window; + Cancel at any of these questions stops the quit with every window open (a draft already + discarded with Don't Save stays discarded). - **Debug bar.** Check dry-runs the macro and colours the grid: inputs blue, the cells it writes green, the cells among them that hold data red. Run runs it as one undo step. Step, Continue and Run to Here start the debugger when needed (`macro.debug` with the editor's breakpoints and