Updates: the update dialogs hold the keyboard and floating windows, and updates wait for running work

The update prompt and the modal result window were not treated as
modal: Delete, a tool key and Copy reached the document behind them,
and a floating panel's window kept taking keys. The shortcut handler,
modal_open, the IME route and the return from a held tool key now stop
for them as they do for a dialog. When a check at startup opens the
prompt while a floating document's OS window has the keyboard, the
main window takes it. The Updated notice is not modal.

An update now starts installing, and a ready update closes the app,
only when no save or export, file opening, plug-in filter, Image Trace
or action playback is running, no dialog or file dialog is open, and
no tool is dragging or computing an edit. Clicking the update button
during one of these shows the existing note "Save or close your open
work first; the update restarts the app."
This commit is contained in:
dotCooCoo
2026-11-08 23:37:57 -08:00
parent 4bde73708e
commit 8b52066f13
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);
}
}