diff --git a/crates/ui-egui/src/doc_windows.rs b/crates/ui-egui/src/doc_windows.rs index 2f620a7df..619c563e4 100644 --- a/crates/ui-egui/src/doc_windows.rs +++ b/crates/ui-egui/src/doc_windows.rs @@ -340,6 +340,14 @@ fn native(app: &VectorcraftApp, ctx: &egui::Context) -> bool { true } +/// Gives the main window the keyboard when a floating document's OS window has it. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn focus_main_window(app: &VectorcraftApp, ctx: &egui::Context) { + if native(app, ctx) && app.ui.floating.iter().any(|w| w.focused) { + ctx.send_viewport_cmd_to(egui::ViewportId::ROOT, egui::ViewportCommand::Focus); + } +} + /// The OS window of document window `w`. fn viewport_id(w: &DocWindow) -> egui::ViewportId { egui::ViewportId::from_hash_of(("vectorcraft-document-window", w.uid, w.number)) diff --git a/crates/ui-egui/src/doc_windows/tests.rs b/crates/ui-egui/src/doc_windows/tests.rs index 4311d8bbe..64a693a09 100644 --- a/crates/ui-egui/src/doc_windows/tests.rs +++ b/crates/ui-egui/src/doc_windows/tests.rs @@ -451,6 +451,41 @@ fn closing_an_os_window_keeps_it_while_its_unsaved_document_stays_open() { ); } +/// The update prompt, opening on its own, takes the keyboard once from a document's OS window that +/// has it, and leaves the keyboard alone when none of those windows has it. +#[test] +#[cfg(not(target_arch = "wasm32"))] +fn the_update_prompt_takes_the_keyboard_from_a_document_window() { + let _host = native_host(); + let ctx = egui::Context::default(); + ctx.set_embed_viewports(false); + let mut app = app_with(2); + if !native(&app, &ctx) { + // Wayland: the windows are inside the main window. + return; + } + frame(&mut app, &ctx, vec![]); + app.run("window.arrange.floatInWindow", json!({})).unwrap(); + frame(&mut app, &ctx, vec![]); + let focus_commands = |app: &mut VectorcraftApp| { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| crate::updates_ui::show(app, ui.ctx())); + out.textures_delta.clear(); + out.viewport_output.get(&egui::ViewportId::ROOT).map_or(0, |v| v.commands.iter().filter(|c| **c == egui::ViewportCommand::Focus).count()) + }; + + app.ui.floating[0].focused = true; + assert_eq!(focus_commands(&mut app), 0, "no prompt is open"); + crate::updates_ui::open_prompt_for_test(&mut app); + assert_eq!(focus_commands(&mut app), 1, "the main window gets the keyboard"); + assert_eq!(focus_commands(&mut app), 0, "once"); + + crate::updates_ui::close_dialogs_for_test(&mut app); + assert_eq!(focus_commands(&mut app), 0, "the prompt closed"); + app.ui.floating[0].focused = false; + crate::updates_ui::open_prompt_for_test(&mut app); + assert_eq!(focus_commands(&mut app), 0, "no document window has the keyboard"); +} + /// Where the last frame drew the title of `title`'s tab in the tab strip. fn tab_at(shapes: &[Shape], title: &str) -> Pos2 { shapes diff --git a/crates/ui-egui/src/ime.rs b/crates/ui-egui/src/ime.rs index 95c0c0323..7f4893c6f 100644 --- a/crates/ui-egui/src/ime.rs +++ b/crates/ui-egui/src/ime.rs @@ -109,7 +109,7 @@ pub(crate) fn install_hooks(ctx: &Context) { } /// The base eligibility every custom-descriptor consumer shares: a hidden viewport, the Home -/// screen, a modal dialog, the palette, a file sheet, an unfocused window, another egui keyboard +/// screen, a modal dialog, an update dialog, the palette, a file sheet, an unfocused window, another egui keyboard /// owner or no Type-tool text edit all mean this pass publishes a stock descriptor or none. One /// helper so the pre-UI route test, the post-UI finalizer and the end-of-pass observation cannot /// drift apart; Home in particular is an ineligible route even though the Type tool keeps its @@ -118,6 +118,7 @@ fn pass_eligible(app: &VectorcraftApp, ctx: &Context) -> bool { !app.viewport_hidden(ctx) && !crate::menus::home_showing(app) && app.ui.dialog.is_none() + && !crate::updates_ui::blocks_input(app) && !app.ui.palette_open && !app.file_sheet_open() && ctx.input(|input| input.focused) @@ -801,6 +802,31 @@ pub(crate) fn write_output(ctx: &Context, update: Option, rect #[cfg(test)] mod tests { use super::*; + use serde_json::json; + use vectorcraft_engine::Session; + + /// The update prompt ends the Type tool's IME route, as a dialog does. + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn the_update_prompt_ends_the_type_tools_route() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + let id = app.run("text.create", json!({"x": 20, "y": 80, "text": "Text", "size": 12, "color": "#000000"})).unwrap()["id"].as_u64().unwrap(); + let view = app.view_info(); + app.session.select_tool("type", view).unwrap(); + app.session.set_tool_option("textSelection", &json!({"id": id, "start": 4, "end": 4})); + assert!(app.session.tool_wants_text()); + let ctx = Context::default(); + let eligible = |app: &VectorcraftApp| { + let mut eligible = false; + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| eligible = pass_eligible(app, ui.ctx())); + out.textures_delta.clear(); + eligible + }; + assert!(eligible(&app)); + crate::updates_ui::open_prompt_for_test(&mut app); + assert!(!eligible(&app), "no canvas descriptor behind the prompt"); + } /// A changed context generation or IME purpose for the same owner requires a replacement reset, /// while an unchanged descriptor keeps the existing request. diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index 270c46a11..a74f47b6a 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -1204,8 +1204,8 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { } } -/// Whether a modal is open: a dialog, About, the command palette, a native file dialog, or a -/// window behind the modal backdrop last frame (export progress, plug-in runs). Floating panels +/// Whether a modal is open: a dialog, About, the command palette, a native file dialog, an update +/// dialog, or a window behind the modal backdrop last frame (export progress, plug-in runs). Floating panels /// in OS windows of their own take no input meanwhile, as the main window behind it takes none. /// The window level and visibility a palette window takes: hidden under a modal dialog (a click /// must not bring it back over the dialog), at the normal level under a menu or another popup drawn @@ -1260,6 +1260,7 @@ pub(crate) fn modal_open(app: &VectorcraftApp, ctx: &egui::Context) -> bool { || app.ui.about || app.ui.palette_open || app.file_dialog_open() + || crate::updates_ui::blocks_input(app) || ctx.memory(|m| m.areas().is_visible(&crate::dialogs::modal::backdrop())) } diff --git a/crates/ui-egui/src/panel_docking/native_tests.rs b/crates/ui-egui/src/panel_docking/native_tests.rs index 26614634a..abb7f2a63 100644 --- a/crates/ui-egui/src/panel_docking/native_tests.rs +++ b/crates/ui-egui/src/panel_docking/native_tests.rs @@ -642,6 +642,33 @@ fn a_modal_blocks_native_windows_and_takes_the_keyboard_from_them() { assert_eq!(h.app.session.tool_id(), "pen", "the window takes keys again once the modal closes"); } +/// The update prompt and the result window of a check block a floating panel's OS window as any +/// modal does. +#[test] +#[cfg(not(target_arch = "wasm32"))] +fn the_update_dialogs_block_native_windows_and_take_the_keyboard_from_them() { + type Open = fn(&mut Harness); + let opens: [(&str, Open); 2] = [ + ("the prompt", |h| crate::updates_ui::open_prompt_for_test(&mut h.app)), + ("the result window", |h| crate::updates_ui::open_result_window_for_test(&mut h.app, &h.ctx)), + ]; + for (name, open) in opens { + let mut h = Harness::new(); + let viewport = h.float("swatches"); + open(&mut h); + h.frames(6); + let focus_commands = h.host.borrow().commands.iter().filter(|(id, c)| *id == ViewportId::ROOT && *c == ViewportCommand::Focus).count(); + assert_eq!(focus_commands, 1, "{name}: the main window gets the keyboard"); + h.key_in(viewport, Key::P); + assert_eq!(h.app.session.tool_id(), "selection", "{name}: a key typed in the blocked window does nothing"); + + crate::updates_ui::close_dialogs_for_test(&mut h.app); + h.frames(3); + h.key_in(viewport, Key::P); + assert_eq!(h.app.session.tool_id(), "pen", "{name}: the window takes keys again once the dialog closes"); + } +} + /// The level and visibility a palette window takes under a dialog or a menu, and otherwise: a /// palette stays out of the way of anything drawn in the main window. #[test] diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 5987050c0..6d7ac9474 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -210,6 +210,11 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { crate::ime::controls_without_editor(app, ctx); return; } + // The update prompt and the modal result window draw over everything and read Escape themselves. + if crate::updates_ui::blocks_input(app) { + crate::ime::controls_without_editor(app, ctx); + return; + } if app.ui.dialog.is_some() || app.ui.palette_open { crate::ime::controls_without_editor(app, ctx); if app.ui.dialog.as_ref().is_some_and(crate::dialogs::revolve_gizmo::active) && crate::dialogs::revolve_gizmo::dragging(ctx) { diff --git a/crates/ui-egui/src/shortcuts/held_tool.rs b/crates/ui-egui/src/shortcuts/held_tool.rs index a3a809b96..5d69422a0 100644 --- a/crates/ui-egui/src/shortcuts/held_tool.rs +++ b/crates/ui-egui/src/shortcuts/held_tool.rs @@ -91,9 +91,9 @@ pub(super) fn follow(app: &mut VectorcraftApp, ctx: &egui::Context, focused: boo // Each pass looks at the key held last; one that goes back hands over to the key under it. loop { let gesture = app.session.tool_busy() || app.session.pointer_held(); - // A dialog, or a canvas drag that does not reach the tool (a pan, zoom or turn of the view), - // defers the decision. - let dialog = app.ui.dialog.is_some() || crate::canvas::dragging(ctx); + // A dialog, an update dialog, or a canvas drag that does not reach the tool (a pan, zoom or + // turn of the view), defers the decision. + let dialog = app.ui.dialog.is_some() || crate::updates_ui::blocks_input(app) || crate::canvas::dragging(ctx); let active = app.session.tool_id(); let on = app.session.prefs.hold_tool_keys; let Some(held) = app.held_tool.last_mut() else { return }; diff --git a/crates/ui-egui/src/shortcuts/held_tool/tests.rs b/crates/ui-egui/src/shortcuts/held_tool/tests.rs index e2217b822..c272024ad 100644 --- a/crates/ui-egui/src/shortcuts/held_tool/tests.rs +++ b/crates/ui-egui/src/shortcuts/held_tool/tests.rs @@ -75,6 +75,21 @@ fn pen_app() -> VectorcraftApp { app } +/// S let go while the update prompt is open leaves Scale chosen until the prompt closes. +#[test] +#[cfg(not(target_arch = "wasm32"))] +fn a_tool_key_let_go_behind_the_update_prompt_returns_after_it() { + let mut app = pen_app(); + let keys = Keyboard::new(); + keys.press_s(&mut app, 0.0); + crate::updates_ui::open_prompt_for_test(&mut app); + keys.release_s(&mut app, 1.0); + assert_eq!(app.session.tool_id(), "scale", "Scale stays behind the prompt"); + crate::updates_ui::close_dialogs_for_test(&mut app); + keys.frame(&mut app, 1.1, vec![], None); + assert_eq!(app.session.tool_id(), "pen", "the Pen is back after the prompt"); +} + /// S held from 0 s, its repeats at `repeats`, let go at `release`. fn hold_s(app: &mut VectorcraftApp, keys: &Keyboard, repeats: &[f64], release: f64) { keys.press_s(app, 0.0); diff --git a/crates/ui-egui/src/updates_ui.rs b/crates/ui-egui/src/updates_ui.rs index c8dade12b..77a00c33a 100644 --- a/crates/ui-egui/src/updates_ui.rs +++ b/crates/ui-egui/src/updates_ui.rs @@ -20,6 +20,9 @@ pub(crate) struct Runtime { /// The context of the last frame, to wake the UI when a check is queued from a menu callback. #[cfg(not(target_arch = "wasm32"))] repaint: Option, + /// Whether an update dialog held the input in the last frame. + #[cfg(not(target_arch = "wasm32"))] + was_blocking: bool, /// The web build has no updater; its window only says so. #[cfg(target_arch = "wasm32")] pub(crate) manual_window_open: bool, @@ -38,6 +41,8 @@ impl Default for Runtime { check_requested: false, #[cfg(not(target_arch = "wasm32"))] repaint: None, + #[cfg(not(target_arch = "wasm32"))] + was_blocking: false, #[cfg(target_arch = "wasm32")] manual_window_open: false, #[cfg(target_arch = "wasm32")] @@ -103,12 +108,52 @@ fn result_window_shows(status: &Status, prompt_open: bool) -> bool { !prompt_open && !matches!(status, Status::Idle) } +/// Whether the update prompt or the modal result window is open: keys, tool keys and clipboard +/// events then stay out of the documents behind it, and the app's other windows take no input. +pub(crate) fn blocks_input(app: &VectorcraftApp) -> bool { + #[cfg(not(target_arch = "wasm32"))] + { + let updater = &app.updates_rt.updater; + holds_keyboard(updater.status(), updater.prompt_open()) + } + #[cfg(target_arch = "wasm32")] + { + let _ = app; + false + } +} + +/// The prompt is modal, and so is the result window for every status but [`Status::Updated`], a +/// notice that leaves the app usable behind it. +#[cfg(not(target_arch = "wasm32"))] +fn holds_keyboard(status: &Status, prompt_open: bool) -> bool { + prompt_open || !matches!(status, Status::Idle | Status::Updated(_)) +} + +/// Whether an update may install and close the app: no document holds unsaved work, no save, +/// export, file opening, plug-in filter or Image Trace is running, no action is playing, no dialog +/// or file dialog is open, and no tool is in the middle of a drag or an edit it computes on +/// another thread. +#[cfg(not(target_arch = "wasm32"))] +fn safe_to_close(app: &VectorcraftApp) -> bool { + let session = &app.session; + !crate::unsaved::any_dirty(app) + && app.background.jobs.is_empty() + && !crate::opening::long_operation_busy(app) + && app.ui.action_playing.is_none() + && app.ui.dialog.is_none() + && !app.file_dialog_open() + && !session.in_interaction() + && !session.tool_busy() + && !session.tool_editing() +} + pub(crate) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { #[cfg(not(target_arch = "wasm32"))] { - // The close for a ready update waits until no document holds unsaved work. - let clean = !crate::unsaved::any_dirty(app); - match app.updates_rt.updater.show_if(ctx, clean) { + // An installation, and the close for a ready update, wait for unsaved and running work. + let safe = safe_to_close(app); + match app.updates_rt.updater.show_if(ctx, safe) { Action::OpenUrl(url) => app.open_url(&url), Action::Close => ctx.send_viewport_cmd(egui::ViewportCommand::Close), Action::None => {} @@ -124,6 +169,12 @@ pub(crate) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { CheckResultResponse::RestartToUpdate => rt.updater.restart_to_update(), } } + // A check at startup can open the prompt while a floating document's window covers it. + let blocking = blocks_input(app); + if blocking && !app.updates_rt.was_blocking { + crate::doc_windows::focus_main_window(app, ctx); + } + app.updates_rt.was_blocking = blocking; } #[cfg(target_arch = "wasm32")] @@ -150,10 +201,109 @@ pub(crate) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { } } +/// Opens the update prompt for a test in another module. +#[cfg(all(test, not(target_arch = "wasm32")))] +pub(crate) fn open_prompt_for_test(app: &mut VectorcraftApp) { + tests::offer(app); +} + +/// Opens the result window of a running check for a test in another module. +#[cfg(all(test, not(target_arch = "wasm32")))] +pub(crate) fn open_result_window_for_test(app: &mut VectorcraftApp, ctx: &egui::Context) { + app.updates_rt.updater = tests::isolated_updater(env!("CARGO_PKG_VERSION"), "result"); + app.updates_rt.updater.check_now(ctx); +} + +/// Closes the update prompt and the result window for a test in another module. +#[cfg(all(test, not(target_arch = "wasm32")))] +pub(crate) fn close_dialogs_for_test(app: &mut VectorcraftApp) { + app.updates_rt.updater = tests::isolated_updater(env!("CARGO_PKG_VERSION"), "closed"); +} + #[cfg(test)] mod tests { use super::*; + use serde_json::json; use vectorcraft_engine::Session; + use vectorcraft_tools::{PointerEvent, PointerKind}; + + /// The folder that holds `name`'s update records. + #[cfg(not(target_arch = "wasm32"))] + fn update_root(name: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!("vectorcraft-updates-{}-{name}", std::process::id())) + } + + /// An updater that reads its records and the launcher's pins only under [`update_root`]. + #[cfg(not(target_arch = "wasm32"))] + pub(super) fn isolated_updater(version: &str, name: &str) -> Updater { + let root = update_root(name); + Updater::new(&crate::UPDATE_APP, version) + .with_source(std::sync::Arc::new(|| Ok(None))) + .with_paths(craft_update::Paths::under(&root)) + .with_pins_path(root.join("pins.json")) + } + + #[cfg(not(target_arch = "wasm32"))] + pub(super) fn offer(app: &mut VectorcraftApp) { + app.updates_rt.updater = isolated_updater(env!("CARGO_PKG_VERSION"), "prompt"); + app.updates_rt.updater.offer(craft_update::Release { + tag: "v99.0.0".into(), + version: craft_update::parse_version("99.0.0"), + name: "VectorCraft 99.0.0".into(), + html_url: "https://github.com/storytold/vectorcraft/releases/tag/v99.0.0".into(), + ..Default::default() + }); + } + + /// An app with one saved document holding a selected rectangle. + #[cfg(not(target_arch = "wasm32"))] + fn app_with_saved_object() -> VectorcraftApp { + let services = crate::Services { write: Some(Box::new(|_: &str, _: &[u8]| Ok(()))), ..Default::default() }; + let mut app = VectorcraftApp::new(Session::new(), services); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap(); + app.run("file.save", json!({"path": "/docs/saved.vectorcraft"})).unwrap(); + assert!(!crate::unsaved::any_dirty(&app)); + app + } + + #[cfg(not(target_arch = "wasm32"))] + fn press(key: egui::Key) -> egui::Event { + egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE } + } + + /// One frame in the app's order: the shortcuts read the input, then the update dialogs draw. + #[cfg(not(target_arch = "wasm32"))] + fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) { + let mut output = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| { + crate::shortcuts::handle(app, ui.ctx()); + show(app, ui.ctx()); + }); + output.textures_delta.clear(); + } + + /// A Copy, the Pen tool's key and Delete, in one frame each: none of them may act. + #[cfg(not(target_arch = "wasm32"))] + fn assert_keys_do_nothing(app: &mut VectorcraftApp, ctx: &egui::Context) { + let tool = app.session.tool_id(); + let mut reached = Vec::new(); + frame(app, ctx, vec![egui::Event::Copy]); + if !app.session.clipboard.is_empty() { + reached.push("Copy"); + } + let tool_key = + egui::Event::Key { key: egui::Key::P, physical_key: Some(egui::Key::P), pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + frame(app, ctx, vec![tool_key, egui::Event::Text("p".into())]); + if app.session.tool_id() != tool { + reached.push("the tool key"); + } + frame(app, ctx, vec![press(egui::Key::Delete)]); + if app.session.doc().unwrap().selection.is_empty() { + reached.push("Delete"); + } + assert_eq!(reached, Vec::<&str>::new(), "these reached the document"); + assert!(!crate::unsaved::any_dirty(app), "a key edited the document"); + } #[test] fn default_runtime_is_idle() { @@ -208,4 +358,164 @@ mod tests { output.textures_delta.clear(); assert!(app.updates_rt.updater.prompt_open()); } + + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn the_prompt_and_the_result_window_hold_the_keyboard_but_the_updated_notice_does_not() { + for status in [ + Status::Checking, + Status::UpToDate, + Status::Failed("offline".into()), + Status::Installing, + Status::Ready, + Status::Managed, + Status::InstallFailed("locked".into()), + ] { + assert!(holds_keyboard(&status, false), "{status:?}"); + } + assert!(!holds_keyboard(&Status::Idle, false)); + assert!(!holds_keyboard(&Status::Updated("99.0.0".into()), false)); + assert!(holds_keyboard(&Status::Idle, true), "the prompt"); + } + + /// The update prompt, drawn: keys stop at it, Escape closes it, and the next key acts again. + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn keys_do_not_reach_the_document_behind_the_update_prompt() { + let mut app = app_with_saved_object(); + let ctx = egui::Context::default(); + offer(&mut app); + frame(&mut app, &ctx, vec![]); + assert_keys_do_nothing(&mut app, &ctx); + assert!(app.updates_rt.updater.prompt_open()); + + frame(&mut app, &ctx, vec![press(egui::Key::Escape)]); + assert!(!app.updates_rt.updater.prompt_open(), "Escape closes the prompt"); + assert!(!app.session.doc().unwrap().selection.is_empty(), "Escape stops at the prompt"); + frame(&mut app, &ctx, vec![press(egui::Key::Delete)]); + assert!(crate::unsaved::any_dirty(&app), "Delete acts once the prompt has closed"); + } + + /// The result window of a check that is still running, drawn: keys stop at it too. + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn keys_do_not_reach_the_document_behind_the_check_result_window() { + let mut app = app_with_saved_object(); + let ctx = egui::Context::default(); + app.updates_rt.updater = isolated_updater("0.0.1", "checking"); + app.updates_rt.updater.check_now(&ctx); + assert_eq!(app.updates_rt.updater.status(), &Status::Checking); + frame(&mut app, &ctx, vec![]); + assert_keys_do_nothing(&mut app, &ctx); + } + + /// The notice of a finished update, drawn: the keys act behind it. + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn keys_reach_the_document_behind_the_updated_notice() { + let mut app = app_with_saved_object(); + let ctx = egui::Context::default(); + let root = update_root("updated"); + let records = craft_update::Paths::under(&root).data; + std::fs::create_dir_all(&records).unwrap(); + std::fs::write(records.join("self-update-done.txt"), "v99.0.0").unwrap(); + app.updates_rt.updater = isolated_updater("99.0.0", "updated"); + app.updates_rt.updater.check_at_start(&ctx); + assert_eq!(app.updates_rt.updater.status(), &Status::Updated("99.0.0".into())); + frame(&mut app, &ctx, vec![]); + frame(&mut app, &ctx, vec![press(egui::Key::Delete)]); + let _ = std::fs::remove_dir_all(&root); + assert!(crate::unsaved::any_dirty(&app), "Delete acts behind the notice"); + } + + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn unsaved_changes_dialogs_and_running_work_hold_an_update_back() { + let mut app = app_with_saved_object(); + assert!(safe_to_close(&app)); + + app.run("shape.rectangle", json!({"x": 70, "y": 10, "width": 50, "height": 50})).unwrap(); + assert!(!safe_to_close(&app), "unsaved changes"); + app.run("file.save", json!({"path": "/docs/saved.vectorcraft"})).unwrap(); + assert!(safe_to_close(&app)); + + app.ui.dialog = Some(crate::state::Dialog::new("about", json!({}))); + assert!(!safe_to_close(&app), "a dialog"); + app.ui.dialog = None; + + let (shown, _) = crate::tests_picks::off_the_ui_thread(&mut app.services); + // The command asks again with the answer: until then it reports that it has none. + assert!(app.run("file.open", json!({})).is_err()); + assert!(app.file_dialog_open()); + assert!(!safe_to_close(&app), "a file dialog"); + shown.answer(&[]); + crate::picks::poll(&mut app, &egui::Context::default()); + assert!(!app.file_dialog_open()); + assert!(safe_to_close(&app)); + + app.services.write_shared = Some(std::sync::Arc::new(|_: &str, _: &[u8]| Ok(()))); + let (release, held) = std::sync::mpsc::channel::<()>(); + let work = move |_: crate::background::Writer| { + let _ = held.recv(); + Ok(Value::Null) + }; + // No label: a job the status bar does not list holds the close back too. + crate::background::run(&mut app, true, String::new(), None, work, |_, r| r).unwrap(); + assert!(!safe_to_close(&app), "a background save"); + let _ = release.send(()); + crate::background::wait_all(&mut app); + assert!(safe_to_close(&app)); + + crate::opening::start(&mut app, "/art/Wait.vectorcraft", vectorcraft_format::save(&vectorcraft_doc::Document::new(10.0, 10.0), false)) + .unwrap(); + assert!(!safe_to_close(&app), "a file opening"); + app.opening.pending.clear(); + assert!(safe_to_close(&app)); + + use crate::panels::actions::{Action, ActionSet, PlaybackSpeed, play}; + let step = ("object.move".to_owned(), json!({"dx": 1, "dy": 0})); + app.ui.action_sets = vec![ActionSet::new("Test", vec![Action::new("Move", vec![step.clone(), step])])]; + app.ui.action_playback.speed = PlaybackSpeed::Step; + assert_eq!(play(&mut app, 0, 0).unwrap()["pending"], json!(1)); + app.run("file.save", json!({"path": "/docs/saved.vectorcraft"})).unwrap(); + crate::background::wait_all(&mut app); + assert!(!safe_to_close(&app), "an action playing"); + app.ui.action_playing = None; + assert!(safe_to_close(&app)); + } + + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn a_gesture_or_a_tools_edit_holds_an_update_back() { + let mut app = app_with_saved_object(); + let view = app.view_info(); + app.session.begin_interaction("Move").unwrap(); + assert!(!safe_to_close(&app), "an interaction"); + app.session.cancel_interaction().unwrap(); + assert!(safe_to_close(&app)); + + app.select_tool("selection"); + app.session.pointer(&PointerEvent::new(PointerKind::Down, 150.0, 150.0), view).unwrap(); + app.session.pointer(&PointerEvent::new(PointerKind::Drag, 180.0, 180.0), view).unwrap(); + assert!(app.session.tool_busy() && !app.session.in_interaction()); + assert!(!safe_to_close(&app), "a drag with a tool"); + app.session.pointer(&PointerEvent::new(PointerKind::Up, 180.0, 180.0), view).unwrap(); + assert!(!app.session.tool_busy()); + assert!(safe_to_close(&app)); + + // Enough segments for the merge to run on another thread. + for k in 0..40 { + app.run("shape.rectangle", json!({"x": 10 * k, "y": 100, "width": 20, "height": 20})).unwrap(); + } + app.run("file.save", json!({"path": "/docs/saved.vectorcraft"})).unwrap(); + app.run("select.all", json!({})).unwrap(); + app.select_tool("shapeBuilder"); + app.session.pointer(&PointerEvent::new(PointerKind::Move, 15.0, 110.0), view).unwrap(); + app.session.tool_poll(view, true).unwrap(); + app.session.pointer(&PointerEvent::new(PointerKind::Down, 15.0, 110.0), view).unwrap(); + app.session.pointer(&PointerEvent::new(PointerKind::Up, 15.0, 110.0), view).unwrap(); + assert!(app.session.tool_editing() && !crate::unsaved::any_dirty(&app), "the click starts a merge on another thread"); + assert!(!safe_to_close(&app), "a tool's edit"); + crate::unsaved::finish_pending(&mut app); + } }