diff --git a/apps/solvecraft-web/Cargo.toml b/apps/solvecraft-web/Cargo.toml index e29ae198..3c02cb94 100644 --- a/apps/solvecraft-web/Cargo.toml +++ b/apps/solvecraft-web/Cargo.toml @@ -29,4 +29,5 @@ web-sys = { version = "0.3", features = [ "Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "Navigator", "StorageManager", "FileSystemHandle", "FileSystemDirectoryHandle", "FileSystemFileHandle", "FileSystemGetFileOptions", "FileSystemWritableFileStream", "WritableStream", "File", "FileList", "Blob", "BlobPropertyBag", "Url", "HtmlAnchorElement", "HtmlInputElement", "Storage", + "BeforeUnloadEvent", "Event", "EventTarget", ] } diff --git a/apps/solvecraft-web/src/main.rs b/apps/solvecraft-web/src/main.rs index 4dde3a15..ad31f8a2 100644 --- a/apps/solvecraft-web/src/main.rs +++ b/apps/solvecraft-web/src/main.rs @@ -27,6 +27,25 @@ mod web { /// Seconds between autosaves of unsaved work. const AUTOSAVE_S: f64 = 30.0; + thread_local! { + /// An open design has unsaved changes: leaving the page asks first. + static UNSAVED: std::cell::Cell = const { std::cell::Cell::new(false) }; + } + + /// The browser's "leave site? changes you made may not be saved" prompt while a design has + /// unsaved changes (the page can't show its own dialog when it is being closed). + fn ask_before_unload(window: &web_sys::Window) { + let on_unload = wasm_bindgen::closure::Closure::::new(|e: web_sys::BeforeUnloadEvent| { + if UNSAVED.with(std::cell::Cell::get) { + e.prevent_default(); + e.set_return_value("unsaved"); + } + }); + let _ = window.add_event_listener_with_callback("beforeunload", on_unload.as_ref().unchecked_ref()); + // The listener lives as long as the page. + on_unload.forget(); + } + struct Shell { app: SolveApp, files: Rc, @@ -291,6 +310,7 @@ mod web { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { self.app.logic(ctx); self.files_tick(ctx); + UNSAVED.with(|u| u.set(solvecraft_ui_egui::documents::any_dirty(&self.app))); } fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { files::take_drops(&self.files, raw); @@ -317,7 +337,9 @@ mod web { pub fn start() { eframe::WebLogger::init(log::LevelFilter::Info).ok(); wasm_bindgen_futures::spawn_local(async { - let Some(document) = web_sys::window().and_then(|w| w.document()) else { return }; + let Some(window) = web_sys::window() else { return }; + ask_before_unload(&window); + let Some(document) = window.document() else { return }; let Some(canvas) = document.get_element_by_id(CANVAS_ID).and_then(|e| e.dyn_into::().ok()) else { log::error!("missing "); return; diff --git a/apps/solvecraft/src/main.rs b/apps/solvecraft/src/main.rs index b78b746e..bfbea6a6 100644 --- a/apps/solvecraft/src/main.rs +++ b/apps/solvecraft/src/main.rs @@ -21,6 +21,12 @@ struct App(SolveApp); impl eframe::App for App { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + // The OS closing the window (Alt+F4, Cmd+Q, the window manager): ask about unsaved + // designs first; the window closes once they are answered (`quit_requested`). + if ctx.input(|i| i.viewport().close_requested()) && !self.0.quit_requested { + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + solvecraft_ui_egui::documents::request_quit(&mut self.0); + } self.0.logic(ctx); if self.0.quit_requested { ctx.send_viewport_cmd(egui::ViewportCommand::Close); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 1de6dc60..87351df7 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -111,6 +111,8 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) -> "preview": {"active": app.preview.active, "busy": app.preview.busy, "error": app.preview.error, "ms": app.preview.ms, "replaced": app.preview.replaced}, "home": app.home.open, "documents": app.docs.count(), + "quit_requested": app.quit_requested, + "quitting": app.docs.quitting.is_some(), })), "ui.documents" => ok(crate::documents::control(app, p)), "ui.home" => { @@ -459,9 +461,16 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) -> } } "app.quit" => { - app.quit_requested = true; - ctx.send_viewport_cmd(egui::ViewportCommand::Close); - ok(Value::Null) + // `force`: quit without asking about unsaved designs. + if p.get("force").and_then(Value::as_bool).unwrap_or(false) { + app.quit_requested = true; + } else { + crate::documents::request_quit(app); + } + if app.quit_requested { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + ok(json!({"quitting": app.quit_requested, "asking": app.docs.closing.is_some()})) } other => err(format!("unknown method `{other}`")), } diff --git a/crates/ui-egui/src/documents.rs b/crates/ui-egui/src/documents.rs index 26337777..c749c7c6 100644 --- a/crates/ui-egui/src/documents.rs +++ b/crates/ui-egui/src/documents.rs @@ -1,7 +1,8 @@ //! Several open designs, as tabs in the application bar. The active design lives in the app as //! always (`app.session`, `app.cam`…); the others wait here with their own session (undo, //! selection), camera and per-design view state, and are swapped in when their tab is picked. -//! Closing a design with unsaved changes asks first. +//! Closing a design with unsaved changes asks first, and so does quitting: one "save changes?" +//! per design with unsaved changes ([`request_quit`]). use egui::{Align2, Color32, FontId, Rect, Sense, Stroke, pos2, vec2}; use serde_json::{Value, json}; @@ -48,6 +49,8 @@ pub struct Documents { pub active: usize, /// A tab waiting for the answer to "save changes?". pub closing: Option, + /// Quitting: the tabs answered so far with Don't Save (`None`: not quitting). + pub quitting: Option>, } impl Documents { @@ -214,6 +217,44 @@ pub fn close(app: &mut SolveApp, i: usize, force: bool) { } } +/// Quit (the title bar's ×, File ▸ Quit, the window being closed): asks "save changes?" for each +/// design with unsaved changes, then sets `app.quit_requested` for the host to close the window. +/// Cancel, or a Save that didn't save, stays open. +pub fn request_quit(app: &mut SolveApp) { + if app.docs.quitting.is_none() { + app.docs.quitting = Some(Vec::new()); + } + quit_step(app); +} + +/// Ask about the next design with unsaved changes, or quit when none is left. +fn quit_step(app: &mut SolveApp) { + let Some(skip) = app.docs.quitting.clone() else { return }; + if app.docs.closing.is_some() { + return; + } + match (0..app.docs.count()).find(|i| !skip.contains(i) && dirty(app, *i)) { + Some(i) => { + switch(app, i); + app.docs.closing = Some(i); + } + None => { + // Don't Save means discard: no recovery copy is kept for the next launch either. + for i in skip { + switch(app, i); + app.session.mark_saved(); + } + app.docs.quitting = None; + app.quit_requested = true; + } + } +} + +/// Is any open design unsaved (the web page asks before it is left)? +pub fn any_dirty(app: &SolveApp) -> bool { + (0..app.docs.count()).any(|i| dirty(app, i)) +} + fn dirty(app: &SolveApp, i: usize) -> bool { if i == app.docs.active || app.docs.slots.is_empty() { return app.session.is_dirty(); @@ -236,18 +277,28 @@ pub fn tabs_info(app: &SolveApp) -> Vec<(String, bool)> { .collect() } -/// The control channel's `ui.documents`: list, `new`, `switch` or `close` (by index; `force` -/// closes without asking). +/// The control channel's `ui.documents`: list, `new`, `switch`, `close` (by index; `force` +/// closes without asking) or `quit`; `answer`: save | dont_save | cancel answers "save changes?". pub fn control(app: &mut SolveApp, p: &Value) -> Value { let i = p.get("index").and_then(Value::as_u64).map(|x| x as usize); match p.get("action").and_then(Value::as_str) { Some("new") => new_design(app), Some("switch") => switch(app, i.unwrap_or(app.docs.active)), Some("close") => close(app, i.unwrap_or(app.docs.active), p.get("force").and_then(Value::as_bool).unwrap_or(false)), + Some("quit") => request_quit(app), _ => {} } + let answer = match p.get("answer").and_then(Value::as_str) { + Some("save") => Some(Answer::Save), + Some("dont_save") => Some(Answer::DontSave), + Some("cancel") => Some(Answer::Cancel), + _ => None, + }; + if let Some(a) = answer { + answer_prompt(app, a); + } let tabs: Vec = tabs_info(app).into_iter().map(|(name, dirty)| json!({"name": name, "dirty": dirty})).collect(); - json!({"documents": tabs, "active": app.docs.active, "closing": app.docs.closing}) + json!({"documents": tabs, "active": app.docs.active, "closing": app.docs.closing, "quitting": app.docs.quitting.is_some()}) } /// The document tabs in the application bar, from `x0` to `x1`: click to switch, × to close, @@ -375,50 +426,81 @@ pub fn layout(n: usize, active: usize, room: f32) -> (usize, usize, f32) { (first, fit, MIN_TAB) } -/// The "save changes?" prompt for a tab being closed. +/// What "save changes?" was answered. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Answer { + Save, + DontSave, + Cancel, +} + +/// The "save changes?" prompt for a tab being closed, or for each unsaved design when quitting. pub fn prompt(app: &mut SolveApp, ctx: &egui::Context) { + quit_step(app); let Some(i) = app.docs.closing else { return }; let name = tabs_info(app).get(i).map(|x| x.0.clone()).unwrap_or_default(); - let mut answer: Option = None; - crate::frame::window(ctx, "Save changes?", crate::frame::Width::Normal) + let mut answer: Option = None; + crate::frame::window(ctx, format!("Save changes to {name}?"), crate::frame::Width::Normal) .id(egui::Id::new("sc_close_prompt")) .collapsible(false) .resizable(false) .anchor(Align2::CENTER_CENTER, vec2(0.0, 0.0)) .show(ctx, |ui| { - ui.label(format!("{name} has unsaved changes.")); + ui.label(format!("{name} has unsaved changes. If you don't save, they are lost.")); ui.add_space(8.0); ui.horizontal(|ui| { if ui.button("Save").clicked() { - answer = Some(0); + answer = Some(Answer::Save); } if ui.button("Don't Save").clicked() { - answer = Some(1); + answer = Some(Answer::DontSave); } if ui.button("Cancel").clicked() { - answer = Some(2); + answer = Some(Answer::Cancel); } }); }); if ctx.input(|x| x.key_pressed(egui::Key::Escape)) { - answer = Some(2); + answer = Some(Answer::Cancel); app.esc_handled = true; } + if let Some(a) = answer { + answer_prompt(app, a); + } +} + +/// Act on the "save changes?" answer (also the control channel's `ui.documents {answer}`). +pub fn answer_prompt(app: &mut SolveApp, answer: Answer) { + let Some(i) = app.docs.closing else { return }; match answer { - Some(0) => { + Answer::Save => { switch(app, i); crate::toolbar::save(app); - // Saved (not cancelled in the file picker): close it. - if !app.session.is_dirty() { + let saved = !app.session.is_dirty(); + app.docs.closing = None; + if app.docs.quitting.is_some() { + // Quit only once it is saved; a cancelled picker or a failed save stays open. + if !saved { + app.docs.quitting = None; + } + } else if saved { close(app, app.docs.active, true); - } else { - app.docs.closing = None; } } - Some(1) => close(app, i, true), - Some(2) => app.docs.closing = None, - _ => {} + Answer::DontSave => { + if let Some(skip) = app.docs.quitting.as_mut() { + skip.push(i); + app.docs.closing = None; + } else { + close(app, i, true); + } + } + Answer::Cancel => { + app.docs.closing = None; + app.docs.quitting = None; + } } + quit_step(app); } #[cfg(test)] @@ -473,6 +555,72 @@ mod tests { assert!(active_is_blank(&a) && a.home.open); } + /// Quitting asks once per design with unsaved changes: Don't Save moves on (and keeps no + /// recovery copy), Save saves and moves on, and the app quits after the last answer. + #[test] + fn quit_asks_about_each_unsaved_design_then_quits() { + let mut a = app(); + request_quit(&mut a); + assert!(a.quit_requested, "nothing unsaved: quits at once"); + + let mut a = app(); + let file = std::env::temp_dir().join(format!("solvecraft-quit-save-{}.solvecraft", std::process::id())); + let _ = std::fs::remove_file(&file); + a.run("solid.box", json!({"length": 10, "width": 10, "height": 10})).unwrap(); + a.run("file.save_as", json!({"path": file.to_string_lossy()})).unwrap(); + a.run("solid.sphere", json!({"diameter": 4})).unwrap(); + new_design(&mut a); + a.run("solid.sphere", json!({"diameter": 8})).unwrap(); + assert!(dirty(&a, 0) && dirty(&a, 1)); + + request_quit(&mut a); + assert!(!a.quit_requested); + assert_eq!(a.docs.closing, Some(0), "the first unsaved design is asked about first"); + assert_eq!(a.docs.active, 0, "and shown"); + answer_prompt(&mut a, Answer::Save); + assert!(!dirty(&a, 0), "saved"); + assert_eq!( + std::fs::read(&file).ok().and_then(|b| solvecraft_engine::io::read_design(&b).ok()).map(|d| d.features.len()), + Some(2), + "the file holds the change" + ); + assert_eq!(a.docs.closing, Some(1), "then the next one"); + assert!(!a.quit_requested); + answer_prompt(&mut a, Answer::DontSave); + assert_eq!(a.docs.closing, None); + assert!(a.quit_requested, "every design answered: quit"); + assert!(!any_dirty(&a), "Don't Save discards: nothing is kept for recovery"); + let _ = std::fs::remove_file(&file); + } + + /// Cancel, Esc's answer, or a Save that saves nothing (a never-saved design whose Save As + /// picker is cancelled) stay open with the changes. + #[test] + fn quit_cancelled_or_unsaved_stays_open() { + let mut a = app(); + a.run("solid.box", json!({"length": 10, "width": 10, "height": 10})).unwrap(); + request_quit(&mut a); + assert_eq!(a.docs.closing, Some(0)); + answer_prompt(&mut a, Answer::Cancel); + assert!(!a.quit_requested && a.docs.closing.is_none() && a.docs.quitting.is_none()); + assert!(a.session.is_dirty(), "the changes are still there"); + + // No Save As picker answer (the default services have none): not saved, so no quit. + request_quit(&mut a); + answer_prompt(&mut a, Answer::Save); + assert!(!a.quit_requested && a.docs.quitting.is_none() && a.session.is_dirty()); + + // A picked file: saved, then quit. + let file = std::env::temp_dir().join(format!("solvecraft-quit-saveas-{}.solvecraft", std::process::id())); + let _ = std::fs::remove_file(&file); + let to = file.to_string_lossy().to_string(); + a.services.pick_save = Some(Box::new(move |_: &str, _: &[&str]| Some(to.clone()))); + request_quit(&mut a); + answer_prompt(&mut a, Answer::Save); + assert!(a.quit_requested && file.exists()); + let _ = std::fs::remove_file(&file); + } + /// A design built elsewhere replaces a blank one and gets a tab otherwise. #[test] fn adopt_reuses_a_blank_design() { diff --git a/crates/ui-egui/src/scenario.rs b/crates/ui-egui/src/scenario.rs index 4ff05bcc..92b77ff6 100644 --- a/crates/ui-egui/src/scenario.rs +++ b/crates/ui-egui/src/scenario.rs @@ -738,6 +738,11 @@ pub fn check(h: &mut Harness, e: &Value) -> Result<(), String> { return Err(format!("start page open: got {}, want {v}", ui["home"])); } } + "quit_requested" | "quitting" => { + if ui[k] != *v { + return Err(format!("{k}: got {}, want {v}", ui[k])); + } + } "documents" => { if ui["documents"] != *v { return Err(format!("open designs: got {}, want {v}", ui["documents"])); diff --git a/crates/ui-egui/src/titlebar.rs b/crates/ui-egui/src/titlebar.rs index a909861f..910688bd 100644 --- a/crates/ui-egui/src/titlebar.rs +++ b/crates/ui-egui/src/titlebar.rs @@ -57,8 +57,11 @@ pub fn window_action(app: &mut SolveApp, ctx: &egui::Context, action: &str) -> R "restore" => ctx.send_viewport_cmd(ViewportCommand::Maximized(false)), "toggle" => toggle_maximize(ctx), "close" => { - app.quit_requested = true; - ctx.send_viewport_cmd(ViewportCommand::Close); + // Unsaved designs are asked about first; the window closes once they are answered. + crate::documents::request_quit(app); + if app.quit_requested { + ctx.send_viewport_cmd(ViewportCommand::Close); + } } other => return Err(format!("unknown window action `{other}` (minimize, maximize, restore, toggle, close)")), } diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 88ed4780..bf097c6a 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -194,7 +194,7 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) { close = true; } if item(ui, "Quit", "") { - app.quit_requested = true; + crate::documents::request_quit(app); close = true; } }); diff --git a/crates/ui-egui/tests/scenarios/s43_quit_asks_to_save.json b/crates/ui-egui/tests/scenarios/s43_quit_asks_to_save.json new file mode 100644 index 00000000..08ce24f0 --- /dev/null +++ b/crates/ui-egui/tests/scenarios/s43_quit_asks_to_save.json @@ -0,0 +1,16 @@ +[ + {"note": "Issue #33: quitting with unsaved changes asks \"Save changes to ?\"; Cancel stays open."}, + {"call": "engine.execute", "params": {"command": "solid.box", "params": {"length": 20, "width": 10, "height": 5}}}, + {"call": "ui.window", "params": {"action": "close"}}, + {"expect": {"shown": "Don't Save", "quitting": true, "quit_requested": false}}, + {"widget": "Cancel"}, + {"expect": {"not_shown": "Don't Save", "quitting": false, "quit_requested": false, "bodies": 1}}, + {"note": "The File menu's Quit asks the same; Esc answers Cancel."}, + {"call": "app.quit"}, + {"expect": {"shown": "Don't Save", "quit_requested": false}}, + {"key": "Escape"}, + {"expect": {"not_shown": "Don't Save", "quitting": false, "quit_requested": false}}, + {"call": "app.quit"}, + {"widget": "Don't Save"}, + {"expect": {"quit_requested": true}} +] diff --git a/docs/control-protocol.md b/docs/control-protocol.md index b0543a43..f9d18b6b 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -45,15 +45,15 @@ Commands never open dialogs when run this way. `solvecraft-cli commands` prints | `ui.screenshot` | `path?` | PNG of the window (needs a presented frame) | | `ui.render` | `path`, `width?`, `height?` | CPU render of the model with the current camera (no window needed) | | `ui.confirm` | `accept?`: bool | answers the Delete confirmation (shown when a delete takes other features with it or makes some fail); without `accept`, what it lists | -| `ui.documents` | `action?`: `new`, `switch`, `close`; `index?`; `force?` (close without asking) | the open designs (tabs): names, unsaved flags, the active one, a pending "save changes?" prompt | +| `ui.documents` | `action?`: `new`, `switch`, `close`, `quit`; `index?`; `force?` (close without asking); `answer?`: `save`, `dont_save`, `cancel` (answers "save changes?") | the open designs (tabs): names, unsaved flags, the active one, a pending "save changes?" prompt, whether a quit is asking | | `ui.home` | `open?`: bool; `sample?`: index | the start page: show or hide it, open a built-in sample (once built); returns `open` and the recent designs | | `ui.shortcut` | `command`, `key?`, `replace?` | a command's key, or rebinds it (`""` clears; a key another command uses is refused unless `replace`) | | `ui.help` | `item?`: `about`, `shortcuts` (read only), `report` (copies diagnostics) | the version, commit, build date and diagnostic text | | `ui.prefs` | any of `default_units`, `nav` (`fusion`, `solidworks`, `inventor`), `zoom_reverse`, `orbit_cursor`, `msaa`, `length_decimals`, `angle_decimals` | sets preferences (kept between runs), returns them all | | `ui.capture` | `action?`: `capture`, `revert`, `cancel` | answers the Capture Position question (asked when a command starts or the design is saved while components were moved without capturing); without `action`, what waits | -| `ui.window` | `action`: minimize, maximize, restore, toggle, close | what the title bar's caption buttons do (on Windows and Linux the application bar is the title bar) | +| `ui.window` | `action`: minimize, maximize, restore, toggle, close | what the title bar's caption buttons do (on Windows and Linux the application bar is the title bar); close asks about unsaved designs first | | `ui.resize` | `width`, `height` | | -| `app.quit` | — | | +| `app.quit` | `force?` (quit without asking about unsaved designs) | `quitting` (the window closes), `asking` (a "save changes?" prompt is open) | ## MCP