Save prompt on quit: "Save changes to <name>?" for every unsaved design
The title bar's ×, File ▸ Quit, the OS closing the window (Alt+F4, Cmd+Q, the window manager:
close_requested is cancelled until the designs are answered) and app.quit / ui.window close all
go through documents::request_quit, which asks Save / Don't Save / Cancel once per design with
unsaved changes. Save saves (Save As for a never-saved design) and quits only if it saved; Don't
Save discards (no recovery copy is kept); Cancel or Esc stays open. Closing a tab asks the same
question (now titled "Save changes to <name>?"); New and Open never replace an unsaved design
(they open a tab). The web app asks through beforeunload while a design is unsaved. Control:
ui.documents {action: quit, answer: save|dont_save|cancel}, app.quit {force}. Tests: quit with
Save then Don't Save across two tabs, Cancel, a Save that saves nothing, Save As; scenario
s43_quit_asks_to_save.
Fixes #33
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d2bc8e9847
commit
4562c8e5ba
10 files changed
+240
-30
No files matched your search
@@ -29,4 +29,5 @@ web-sys = { version = "0.3", features = [
|
|||||||
"Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "Navigator", "StorageManager",
|
"Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "Navigator", "StorageManager",
|
||||||
"FileSystemHandle", "FileSystemDirectoryHandle", "FileSystemFileHandle", "FileSystemGetFileOptions", "FileSystemWritableFileStream",
|
"FileSystemHandle", "FileSystemDirectoryHandle", "FileSystemFileHandle", "FileSystemGetFileOptions", "FileSystemWritableFileStream",
|
||||||
"WritableStream", "File", "FileList", "Blob", "BlobPropertyBag", "Url", "HtmlAnchorElement", "HtmlInputElement", "Storage",
|
"WritableStream", "File", "FileList", "Blob", "BlobPropertyBag", "Url", "HtmlAnchorElement", "HtmlInputElement", "Storage",
|
||||||
|
"BeforeUnloadEvent", "Event", "EventTarget",
|
||||||
] }
|
] }
|
||||||
@@ -27,6 +27,25 @@ mod web {
|
|||||||
/// Seconds between autosaves of unsaved work.
|
/// Seconds between autosaves of unsaved work.
|
||||||
const AUTOSAVE_S: f64 = 30.0;
|
const AUTOSAVE_S: f64 = 30.0;
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
/// An open design has unsaved changes: leaving the page asks first.
|
||||||
|
static UNSAVED: std::cell::Cell<bool> = 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::<dyn FnMut(web_sys::BeforeUnloadEvent)>::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 {
|
struct Shell {
|
||||||
app: SolveApp,
|
app: SolveApp,
|
||||||
files: Rc<Files>,
|
files: Rc<Files>,
|
||||||
@@ -291,6 +310,7 @@ mod web {
|
|||||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||||
self.app.logic(ctx);
|
self.app.logic(ctx);
|
||||||
self.files_tick(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) {
|
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||||
files::take_drops(&self.files, raw);
|
files::take_drops(&self.files, raw);
|
||||||
@@ -317,7 +337,9 @@ mod web {
|
|||||||
pub fn start() {
|
pub fn start() {
|
||||||
eframe::WebLogger::init(log::LevelFilter::Info).ok();
|
eframe::WebLogger::init(log::LevelFilter::Info).ok();
|
||||||
wasm_bindgen_futures::spawn_local(async {
|
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::<web_sys::HtmlCanvasElement>().ok()) else {
|
let Some(canvas) = document.get_element_by_id(CANVAS_ID).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok()) else {
|
||||||
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
|
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ struct App(SolveApp);
|
|||||||
|
|
||||||
impl eframe::App for App {
|
impl eframe::App for App {
|
||||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
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);
|
self.0.logic(ctx);
|
||||||
if self.0.quit_requested {
|
if self.0.quit_requested {
|
||||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||||
|
|||||||
@@ -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},
|
"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,
|
"home": app.home.open,
|
||||||
"documents": app.docs.count(),
|
"documents": app.docs.count(),
|
||||||
|
"quit_requested": app.quit_requested,
|
||||||
|
"quitting": app.docs.quitting.is_some(),
|
||||||
})),
|
})),
|
||||||
"ui.documents" => ok(crate::documents::control(app, p)),
|
"ui.documents" => ok(crate::documents::control(app, p)),
|
||||||
"ui.home" => {
|
"ui.home" => {
|
||||||
@@ -459,9 +461,16 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) ->
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
"app.quit" => {
|
"app.quit" => {
|
||||||
app.quit_requested = true;
|
// `force`: quit without asking about unsaved designs.
|
||||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
if p.get("force").and_then(Value::as_bool).unwrap_or(false) {
|
||||||
ok(Value::Null)
|
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}`")),
|
other => err(format!("unknown method `{other}`")),
|
||||||
}
|
}
|
||||||
|
|||||||
+168
-20
@@ -1,7 +1,8 @@
|
|||||||
//! Several open designs, as tabs in the application bar. The active design lives in the app as
|
//! 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,
|
//! 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.
|
//! 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 egui::{Align2, Color32, FontId, Rect, Sense, Stroke, pos2, vec2};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
@@ -48,6 +49,8 @@ pub struct Documents {
|
|||||||
pub active: usize,
|
pub active: usize,
|
||||||
/// A tab waiting for the answer to "save changes?".
|
/// A tab waiting for the answer to "save changes?".
|
||||||
pub closing: Option<usize>,
|
pub closing: Option<usize>,
|
||||||
|
/// Quitting: the tabs answered so far with Don't Save (`None`: not quitting).
|
||||||
|
pub quitting: Option<Vec<usize>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Documents {
|
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 {
|
fn dirty(app: &SolveApp, i: usize) -> bool {
|
||||||
if i == app.docs.active || app.docs.slots.is_empty() {
|
if i == app.docs.active || app.docs.slots.is_empty() {
|
||||||
return app.session.is_dirty();
|
return app.session.is_dirty();
|
||||||
@@ -236,18 +277,28 @@ pub fn tabs_info(app: &SolveApp) -> Vec<(String, bool)> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The control channel's `ui.documents`: list, `new`, `switch` or `close` (by index; `force`
|
/// The control channel's `ui.documents`: list, `new`, `switch`, `close` (by index; `force`
|
||||||
/// closes without asking).
|
/// closes without asking) or `quit`; `answer`: save | dont_save | cancel answers "save changes?".
|
||||||
pub fn control(app: &mut SolveApp, p: &Value) -> Value {
|
pub fn control(app: &mut SolveApp, p: &Value) -> Value {
|
||||||
let i = p.get("index").and_then(Value::as_u64).map(|x| x as usize);
|
let i = p.get("index").and_then(Value::as_u64).map(|x| x as usize);
|
||||||
match p.get("action").and_then(Value::as_str) {
|
match p.get("action").and_then(Value::as_str) {
|
||||||
Some("new") => new_design(app),
|
Some("new") => new_design(app),
|
||||||
Some("switch") => switch(app, i.unwrap_or(app.docs.active)),
|
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("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<Value> = tabs_info(app).into_iter().map(|(name, dirty)| json!({"name": name, "dirty": dirty})).collect();
|
let tabs: Vec<Value> = 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,
|
/// 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)
|
(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) {
|
pub fn prompt(app: &mut SolveApp, ctx: &egui::Context) {
|
||||||
|
quit_step(app);
|
||||||
let Some(i) = app.docs.closing else { return };
|
let Some(i) = app.docs.closing else { return };
|
||||||
let name = tabs_info(app).get(i).map(|x| x.0.clone()).unwrap_or_default();
|
let name = tabs_info(app).get(i).map(|x| x.0.clone()).unwrap_or_default();
|
||||||
let mut answer: Option<u8> = None;
|
let mut answer: Option<Answer> = None;
|
||||||
crate::frame::window(ctx, "Save changes?", crate::frame::Width::Normal)
|
crate::frame::window(ctx, format!("Save changes to {name}?"), crate::frame::Width::Normal)
|
||||||
.id(egui::Id::new("sc_close_prompt"))
|
.id(egui::Id::new("sc_close_prompt"))
|
||||||
.collapsible(false)
|
.collapsible(false)
|
||||||
.resizable(false)
|
.resizable(false)
|
||||||
.anchor(Align2::CENTER_CENTER, vec2(0.0, 0.0))
|
.anchor(Align2::CENTER_CENTER, vec2(0.0, 0.0))
|
||||||
.show(ctx, |ui| {
|
.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.add_space(8.0);
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
if ui.button("Save").clicked() {
|
if ui.button("Save").clicked() {
|
||||||
answer = Some(0);
|
answer = Some(Answer::Save);
|
||||||
}
|
}
|
||||||
if ui.button("Don't Save").clicked() {
|
if ui.button("Don't Save").clicked() {
|
||||||
answer = Some(1);
|
answer = Some(Answer::DontSave);
|
||||||
}
|
}
|
||||||
if ui.button("Cancel").clicked() {
|
if ui.button("Cancel").clicked() {
|
||||||
answer = Some(2);
|
answer = Some(Answer::Cancel);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
if ctx.input(|x| x.key_pressed(egui::Key::Escape)) {
|
if ctx.input(|x| x.key_pressed(egui::Key::Escape)) {
|
||||||
answer = Some(2);
|
answer = Some(Answer::Cancel);
|
||||||
app.esc_handled = true;
|
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 {
|
match answer {
|
||||||
Some(0) => {
|
Answer::Save => {
|
||||||
switch(app, i);
|
switch(app, i);
|
||||||
crate::toolbar::save(app);
|
crate::toolbar::save(app);
|
||||||
// Saved (not cancelled in the file picker): close it.
|
let saved = !app.session.is_dirty();
|
||||||
if !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);
|
close(app, app.docs.active, true);
|
||||||
} else {
|
|
||||||
app.docs.closing = None;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(1) => close(app, i, true),
|
Answer::DontSave => {
|
||||||
Some(2) => app.docs.closing = None,
|
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)]
|
#[cfg(test)]
|
||||||
@@ -473,6 +555,72 @@ mod tests {
|
|||||||
assert!(active_is_blank(&a) && a.home.open);
|
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.
|
/// A design built elsewhere replaces a blank one and gets a tab otherwise.
|
||||||
#[test]
|
#[test]
|
||||||
fn adopt_reuses_a_blank_design() {
|
fn adopt_reuses_a_blank_design() {
|
||||||
|
|||||||
@@ -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"]));
|
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" => {
|
"documents" => {
|
||||||
if ui["documents"] != *v {
|
if ui["documents"] != *v {
|
||||||
return Err(format!("open designs: got {}, want {v}", ui["documents"]));
|
return Err(format!("open designs: got {}, want {v}", ui["documents"]));
|
||||||
|
|||||||
@@ -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)),
|
"restore" => ctx.send_viewport_cmd(ViewportCommand::Maximized(false)),
|
||||||
"toggle" => toggle_maximize(ctx),
|
"toggle" => toggle_maximize(ctx),
|
||||||
"close" => {
|
"close" => {
|
||||||
app.quit_requested = true;
|
// Unsaved designs are asked about first; the window closes once they are answered.
|
||||||
ctx.send_viewport_cmd(ViewportCommand::Close);
|
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)")),
|
other => return Err(format!("unknown window action `{other}` (minimize, maximize, restore, toggle, close)")),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) {
|
|||||||
close = true;
|
close = true;
|
||||||
}
|
}
|
||||||
if item(ui, "Quit", "") {
|
if item(ui, "Quit", "") {
|
||||||
app.quit_requested = true;
|
crate::documents::request_quit(app);
|
||||||
close = true;
|
close = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
[
|
||||||
|
{"note": "Issue #33: quitting with unsaved changes asks \"Save changes to <name>?\"; 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}}
|
||||||
|
]
|
||||||
@@ -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.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.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.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.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.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.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.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.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` | |
|
| `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
|
## MCP
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user