Merge pull request #6291 from dotCooCoo/fix/update-dialogs-block-input

Updates: the update dialogs hold the keyboard and floating windows, and updates wait for running work
This commit is contained in:
BFlat
2026-11-09 16:21:02 +08:00
committed by GitHub
9 changed files with 436 additions and 9 deletions
+8
View File
@@ -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))
+35
View File
@@ -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
+27 -1
View File
@@ -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<ImeContextUpdate>, 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.
+3 -2
View File
@@ -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()))
}
@@ -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]
+5
View File
@@ -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) {
+3 -3
View File
@@ -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 };
@@ -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);
+313 -3
View File
@@ -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<egui::Context>,
/// 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<egui::Event>) {
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);
}
}