diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 849426179..e4c7a6436 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1144,6 +1144,13 @@ impl SheetApp { ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); return; } + // The macro step editor asks about its own unsaved steps and debug run first; once it has + // closed, the window close goes on. + if self.dialog.as_ref().is_some_and(|d| d.name == "macroEditor") { + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + macro_editor::request_close(self, true); + return; + } if !self.session.documents().iter().any(|d| d.is_dirty()) { return; } diff --git a/crates/ui-egui/src/macro_editor.rs b/crates/ui-egui/src/macro_editor.rs index 51450470f..9af815675 100644 --- a/crates/ui-egui/src/macro_editor.rs +++ b/crates/ui-egui/src/macro_editor.rs @@ -75,6 +75,10 @@ pub struct Editor { /// Asking whether to keep what a stopped debug run did (and then close, if `closing`). pub ask_stop: bool, pub closing: bool, + /// Close as the title bar's close button does (asking first) on the next frame. + pub close_requested: bool, + /// The window is quitting: once the editor has closed, the window close goes on. + pub quit: bool, /// Asking whether to save before closing. pub ask_close: bool, /// Start debugging on the first frame (Macros › Step Into). @@ -503,6 +507,8 @@ pub fn open(app: &mut SheetApp, params: &Json) -> Option { errors: BTreeMap::new(), ask_stop: false, closing: false, + close_requested: false, + quit: false, ask_close: false, start_debug, start_check: params["check"].as_bool() == Some(true), @@ -815,7 +821,7 @@ pub fn window(app: &mut SheetApp, ctx: &egui::Context, d: &mut Dialog, open: &mu if ed.refs.pick.is_some() && !ed.refs.drawn { ed.refs.pick = None; } - if !win_open { + if !win_open || std::mem::take(&mut ed.close_requested) { if app.session.macro_debug.is_some() { ed.ask_stop = true; ed.closing = true; @@ -833,8 +839,23 @@ pub fn window(app: &mut SheetApp, ctx: &egui::Context, d: &mut Dialog, open: &mu app.grid.macro_overlay = if *open && home { overlay(app, &ed) } else { None }; if *open { app.macros.editor = Some(ed); - } else if home { - restore_home(app, &ed.refs); + } else { + if home { + restore_home(app, &ed.refs); + } + if ed.quit { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + } +} + +/// 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. +pub fn request_close(app: &mut SheetApp, quit: bool) { + if let Some(ed) = app.macros.editor.as_mut() { + ed.close_requested = true; + ed.quit = quit; } } @@ -966,6 +987,7 @@ fn prompts(app: &mut SheetApp, ctx: &egui::Context, ed: &mut Editor, open: &mut Some(None) => { ed.ask_stop = false; ed.closing = false; + ed.quit = false; } None => {} } @@ -1000,7 +1022,10 @@ fn prompts(app: &mut SheetApp, ctx: &egui::Context, ed: &mut Editor, open: &mut ed.ask_close = false; *open = false; } - Some(None) => ed.ask_close = false, + Some(None) => { + ed.ask_close = false; + ed.quit = false; + } None => {} } } diff --git a/crates/ui-egui/tests/it/macro_editor.rs b/crates/ui-egui/tests/it/macro_editor.rs index f716fcce6..418d36293 100644 --- a/crates/ui-egui/tests/it/macro_editor.rs +++ b/crates/ui-egui/tests/it/macro_editor.rs @@ -403,6 +403,67 @@ fn the_editor_works_only_on_the_workbook_it_opened_in() { assert!(h.state().dialog.as_ref().is_none_or(|d| d.name() != "macroEditor")); } +/// 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 { + h.input_mut().viewports.entry(egui::ViewportId::ROOT).or_default().events.push(egui::ViewportEvent::Close); + step_commands(h) +} + +fn step_commands(h: &mut Harness) -> Vec { + let mut out = Vec::new(); + for _ in 0..4 { + h.step(); + out.extend(h.output().viewport_output.get(&egui::ViewportId::ROOT).map(|v| v.commands.clone()).unwrap_or_default()); + } + out +} + +#[test] +fn quitting_asks_about_the_editors_unsaved_steps_first() { + let mut h = harness(); + three(&mut h); + h.state_mut().macros.editor.as_mut().unwrap().draft.name = "Unsaved".into(); + h.state_mut().macros.editor.as_mut().unwrap().dirty = true; + // As if the workbook had just been saved. + let d = h.state_mut().session.active_mut().unwrap(); + d.saved = d.wb.clone(); + h.run_steps(2); + // Every workbook is saved, but the draft is not: the window stays open and the editor asks. + let sent = request_window_close(&mut h); + assert!(sent.contains(&egui::ViewportCommand::CancelClose), "{sent:?}"); + assert!(!sent.contains(&egui::ViewportCommand::Close), "{sent:?}"); + assert!(h.query_by_label_contains("Save changes to macro “Unsaved”").is_some()); + // Cancel keeps the app and the draft. + h.get_by_label("Cancel").click(); + 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.name.as_str()), Some("Unsaved")); + // Asked again, Don't Save closes the editor and then the window. + request_window_close(&mut h); + 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 quitting_with_the_editor_open_still_asks_about_unsaved_workbooks() { + let mut h = harness(); + three(&mut h); + h.state_mut().run("cell.set", json!({"cell": "Z9", "input": "unsaved"})).unwrap(); + h.run_steps(2); + let sent = request_window_close(&mut h); + assert!(sent.contains(&egui::ViewportCommand::CancelClose), "{sent:?}"); + // The saved, unchanged editor closes and the window close goes on (the window then asks to + // close again, which a test harness has to do by hand); the workbook is asked about as + // without the editor. + assert!(h.state().macros.editor.is_none()); + assert!(sent.contains(&egui::ViewportCommand::Close), "{sent:?}"); + request_window_close(&mut h); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("saveChanges")); +} + #[test] fn a_dialog_asked_for_over_the_editor_says_why_it_did_not_open() { let mut h = harness(); diff --git a/docs/macros.md b/docs/macros.md index d79efcc38..a31ef5cc6 100644 --- a/docs/macros.md +++ b/docs/macros.md @@ -293,7 +293,9 @@ 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. + 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. - **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