diff --git a/crates/oa-desktop/src/app/saved_removal/tests.rs b/crates/oa-desktop/src/app/saved_removal/tests.rs index 9f0103f6..731f1c0b 100644 --- a/crates/oa-desktop/src/app/saved_removal/tests.rs +++ b/crates/oa-desktop/src/app/saved_removal/tests.rs @@ -8,10 +8,22 @@ fn point(x: f32, y: f32) -> Position { } fn saved(project: &mut Project, layer: Layer, projection: ProjectionKind) -> Id { - let id = project.ensure_diagram(layer); - let view = project.diagrams.get_mut(&id).unwrap(); - view.projection = projection; - view.description = "Authored view metadata must survive".into(); + let id = Id::new_v4(); + project.diagrams.insert( + id, + Diagram { + id, + alias: format!("saved_{}", id.simple()), + name: "Saved view".into(), + description: "Authored view metadata must survive".into(), + layer, + projection, + nodes: Default::default(), + routes: vec![], + layout: None, + view_options: Default::default(), + }, + ); id } @@ -70,6 +82,153 @@ fn source_matches(app: &App) { ); } +fn mounted_fixture(state_view: bool) -> App { + if state_view { + let mut f = state_fixture(Layer::Sa); + choose( + &mut f.app, + &[Target::Placement(f.na), Target::Placement(f.nb)], + ); + f.app + } else { + architecture_fixture(Layer::Sa).app + } +} + +fn focus_canvas(mounted: &mut crate::behavior::canvas_workspace::tests::Mounted, app: &App) { + use iced::advanced::widget::{self, operation::Focusable}; + struct Focus; + impl widget::Operation for Focus { + fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) { + visit(self); + } + fn focusable( + &mut self, + id: Option<&widget::Id>, + _: iced::Rectangle, + state: &mut dyn Focusable, + ) { + if id == Some(&widget::Id::from("diagram-keyboard-owner")) { + state.focus(); + } else { + state.unfocus(); + } + } + } + mounted.operate(app, &mut Focus); +} + +fn shift_delete() -> iced::Event { + let mut event = + crate::behavior::canvas_workspace::tests::key(iced::keyboard::key::Named::Delete); + if let iced::Event::Keyboard(iced::keyboard::Event::KeyPressed { modifiers, .. }) = &mut event { + *modifiers = iced::keyboard::Modifiers::SHIFT; + } + event +} + +#[tokio::test] +async fn saved_removal_mounted_shift_delete_review_query_and_stamped_escape_preserve_source() { + use crate::behavior::canvas_workspace::tests::{Mounted, key}; + use iced::{keyboard::key::Named, mouse}; + for state_view in [false, true] { + let mut app = mounted_fixture(state_view); + let mut mounted = Mounted::new(&mut app).await; + focus_canvas(&mut mounted, &app); + let selected = app.multiselect_snapshot().unwrap(); + let before = app.document.snapshot(); + let messages = mounted + .batch(&mut app, &[shift_delete()], mouse::Cursor::Unavailable) + .await; + assert!(messages.iter().any(|message| matches!(message, Message::SavedRemoval(event) if matches!(&**event, Event::Begin { snapshot, .. } if snapshot.targets() == selected.targets()))), "{state_view}: {messages:?}"); + let confirmation = app.delete_target.as_ref().unwrap(); + assert!(app.saved_removal_review_valid(confirmation)); + let token = confirmation.snapshot.token; + send( + &mut app, + Event::Query { + token, + query: "no matching record".into(), + }, + ); + mounted.settle(&mut app).await; + assert!(app.saved_removal_review_valid(app.delete_target.as_ref().unwrap())); + let messages = mounted + .batch(&mut app, &[key(Named::Escape)], mouse::Cursor::Unavailable) + .await; + assert!(messages.iter().any(|message| matches!(message, Message::SavedRemoval(event) if matches!(&**event, Event::Cancel { token: t } if *t == token))), "{messages:?}"); + assert!(app.delete_target.is_none()); + assert!(Arc::ptr_eq(&before, &app.document.snapshot())); + assert_eq!( + app.multiselect_snapshot().unwrap().targets(), + selected.targets() + ); + } +} + +#[tokio::test] +async fn saved_removal_mounted_later_press_or_tab_revokes_queued_review_before_app() { + use crate::behavior::canvas_workspace::tests::{Mounted, key}; + use iced::{Event as Input, keyboard::key::Named, mouse}; + for state_view in [false, true] { + for tab in [false, true] { + let mut app = mounted_fixture(state_view); + let mut mounted = Mounted::new(&mut app).await; + focus_canvas(&mut mounted, &app); + let geometry = mounted.geometry(&app); + let bounds = geometry + .focused + .iter() + .find(|(id, _, _)| { + id.as_ref() == Some(&iced::advanced::widget::Id::from("diagram-keyboard-owner")) + }) + .unwrap() + .1; + let at = iced::Point::new( + bounds.x + bounds.width - 12., + bounds.y + bounds.height - 12., + ); + let before = app.document.snapshot(); + let boundary = if tab { + key(Named::Tab) + } else { + Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Middle)) + }; + let messages = mounted + .batch( + &mut app, + &[ + shift_delete(), + Input::Mouse(mouse::Event::CursorMoved { position: at }), + boundary, + ], + mouse::Cursor::Available(at), + ) + .await; + assert!(messages.iter().any(|message| matches!(message, Message::SavedRemoval(event) if matches!(&**event, Event::Begin { .. }))), "{state_view}/{tab}: {messages:?}"); + assert!( + app.delete_target.is_none(), + "later input must retire the queued request" + ); + assert!(Arc::ptr_eq(&before, &app.document.snapshot())); + if !tab { + assert!(begin(&app).is_none(), "active pan owns an input lease"); + mounted + .batch( + &mut app, + &[Input::Window(iced::window::Event::Unfocused)], + mouse::Cursor::Unavailable, + ) + .await; + assert!( + app.saved_removal_input.idle().is_some(), + "abandoned gesture releases its lease" + ); + } + } + } +} + struct Architecture { app: App, diagram: Id, diff --git a/crates/oa-desktop/src/behavior/state_canvas.rs b/crates/oa-desktop/src/behavior/state_canvas.rs index 8dbc76ac..d63fbea3 100644 --- a/crates/oa-desktop/src/behavior/state_canvas.rs +++ b/crates/oa-desktop/src/behavior/state_canvas.rs @@ -1529,9 +1529,8 @@ fn kind_for_tool(tool: Tool) -> Kind { } } -impl canvas::Program for StateCanvas<'_> { - type State = State; - fn update( +impl StateCanvas<'_> { + fn update_input( &self, state: &mut State, event: &canvas::Event, diff --git a/crates/oa-desktop/src/diagram.rs b/crates/oa-desktop/src/diagram.rs index da4b6cb4..0e239f06 100644 --- a/crates/oa-desktop/src/diagram.rs +++ b/crates/oa-desktop/src/diagram.rs @@ -610,9 +610,8 @@ impl DiagramCanvas<'_> { }); } } -impl canvas::Program for DiagramCanvas<'_> { - type State = State; - fn update( +impl DiagramCanvas<'_> { + fn update_input( &self, state: &mut State, event: &canvas::Event, diff --git a/crates/oa-desktop/src/diagram/focus.rs b/crates/oa-desktop/src/diagram/focus.rs index 3c93e87e..7e8f91c2 100644 --- a/crates/oa-desktop/src/diagram/focus.rs +++ b/crates/oa-desktop/src/diagram/focus.rs @@ -206,6 +206,16 @@ impl Widget for Owner<'_, Mes // The focus task runs after the native batch. Retain focus for its // traversal count, but reject later keys in this same batch now. state.traversing = true; + self.content.as_widget_mut().update( + &mut tree.children[0], + &InputEvent::Window(iced::window::Event::Unfocused), + layout, + cursor, + renderer, + clipboard, + shell, + viewport, + ); shell.publish((self.traverse)(modifiers.shift())); shell.capture_event(); return; diff --git a/crates/oa-desktop/src/removal_input.rs b/crates/oa-desktop/src/removal_input.rs index 4d49125f..a6b79fe4 100644 --- a/crates/oa-desktop/src/removal_input.rs +++ b/crates/oa-desktop/src/removal_input.rs @@ -113,4 +113,20 @@ mod tests { lifetime.revoke_idle(); assert_ne!(lifetime.idle(), after_drop); } + + #[test] + fn removal_input_saturation_and_replaced_owner_cannot_reuse_a_packet() { + let terminal = Lifetime(Arc::new(AtomicU64::new(u64::MAX - 1))); + terminal.revoke_idle(); + assert!(terminal.idle().is_none() && terminal.acquire().is_none()); + let original = Lifetime::default(); + let replacement = Lifetime::default(); + let captured = original.idle(); + let mut lease = original.acquire(); + replacement.sync(&mut lease, true); + assert_ne!(original.idle(), captured); + assert!(replacement.idle().is_none()); + drop(lease); + assert!(replacement.idle().is_some()); + } }