diff --git a/crates/oa-desktop/src/app.rs b/crates/oa-desktop/src/app.rs index 037a850b..5d1dcdc6 100644 --- a/crates/oa-desktop/src/app.rs +++ b/crates/oa-desktop/src/app.rs @@ -2334,6 +2334,15 @@ impl App { } else if self.behavior.creation.has_pending() { self.behavior.creation.acknowledge_commit(); self.status = "Behavior creation cancelled".into(); + } else if self.page == Page::Behavior + && matches!( + self.behavior.diagram.tool, + behavior::state_edit::Tool::AdjustAttachment { .. } + ) + { + // Menu arming invalidates canvas keyboard ownership. Escape + // must also cancel before the next canvas click grants it. + self.behavior.diagram.tool = behavior::state_edit::Tool::Select; } if self.connection.take().is_some() { self.status = "Drawing cancelled ยท Select / move is active".into(); diff --git a/crates/oa-desktop/src/app/state_attachment_escape_tests.rs b/crates/oa-desktop/src/app/state_attachment_escape_tests.rs new file mode 100644 index 00000000..552828a8 --- /dev/null +++ b/crates/oa-desktop/src/app/state_attachment_escape_tests.rs @@ -0,0 +1,158 @@ +//! Production App tree and its actual keyboard subscription, without a canvas click. +use super::*; +use crate::behavior::canvas_workspace::tests::{Mounted, key}; +use iced::{ + advanced::subscription, + futures::{StreamExt, stream, task::noop_waker_ref}, + keyboard::key::Named, + mouse, +}; +use std::task::{Context as PollContext, Poll}; + +async fn press(mounted: &mut Mounted, app: &mut App, named: Named) -> Vec { + let input = key(named); + let (mut messages, statuses) = mounted.input_with_status( + app, + std::slice::from_ref(&input), + mouse::Cursor::Unavailable, + ); + let interaction = subscription::Event::Interaction { + window: iced::window::Id::unique(), + event: input, + status: statuses[0], + }; + for recipe in subscription::into_recipes(app.subscription()) { + let mut output = recipe.stream(Box::pin(stream::iter([interaction.clone()]))); + let mut cx = PollContext::from_waker(noop_waker_ref()); + while let Poll::Ready(Some(message)) = output.poll_next_unpin(&mut cx) { + messages.push(message); + } + } + for message in messages.iter().cloned() { + let task = app.update(message); + mounted.perform(app, task).await; + } + mounted.settle(app).await; + messages +} + +async fn armed() -> (App, Mounted, Id) { + let mut f = fixture(); + let diagram = create_view(&mut f).diagram; + let mut project = f.app.project().clone(); + let relation = project.relate(RelationKind::StateTransition, "Switch", f.a, f.b); + let mut app = App::with_project(project); + app.window_size = iced::Size::new(1800., 1100.); + send(&mut app, Message::RevealInDiagram(diagram, relation)); + let mut mounted = Mounted::new(&mut app).await; + app.behavior.diagram.fit = false; + let context = app.state_context().unwrap(); + event( + &mut app, + Event::ContextMenu { + context, + object: relation, + placement: None, + anchor: iced::Point::new(450., 400.), + }, + ); + mounted.settle(&mut app).await; + let token = app.model_menu.as_ref().unwrap().token; + send( + &mut app, + Message::Menu(crate::model_menu::Event::Invoke( + token, + Box::new(Message::StateDiagram(Event::Choose( + Tool::AdjustAttachment { + relation, + end: EdgeEnd::Source, + }, + ))), + )), + ); + mounted.settle(&mut app).await; + assert!(app.model_menu.is_none()); + assert_eq!( + app.behavior.diagram.tool, + Tool::AdjustAttachment { + relation, + end: EdgeEnd::Source + } + ); + (app, mounted, relation) +} + +fn canvas_focus(mounted: &mut Mounted, app: &App) -> (iced::Rectangle, bool) { + let id = iced::advanced::widget::Id::from("diagram-keyboard-owner"); + let (_, bounds, focused) = mounted + .geometry(app) + .focused + .into_iter() + .find(|(candidate, _, _)| candidate.as_ref() == Some(&id)) + .unwrap(); + (bounds, focused) +} + +#[tokio::test] +async fn mounted_menu_armed_attachment_escape_without_canvas_click_preserves_document_and_focus() { + let (mut app, mut mounted, _) = armed().await; + let before = app.document.snapshot(); + let history = (app.document.can_undo(), app.document.can_redo()); + let camera = ( + app.behavior.diagram.zoom, + app.behavior.diagram.pan, + app.behavior.diagram.fit, + ); + assert!(!canvas_focus(&mut mounted, &app).1); + let messages = press(&mut mounted, &mut app, Named::Escape).await; + assert!(messages.iter().any(|m| matches!(m, Message::Escape))); + assert_eq!(app.behavior.diagram.tool, Tool::Select); + assert!(!canvas_focus(&mut mounted, &app).1); + press(&mut mounted, &mut app, Named::Delete).await; + assert!( + !app.delete_pending, + "Escape must not grant canvas Delete ownership" + ); + assert!(Arc::ptr_eq(&before, &app.document.snapshot())); + assert_eq!(app.document.bundle(), &before.bundle); + assert_eq!(app.project(), &before.decoded.project); + assert_eq!((app.document.can_undo(), app.document.can_redo()), history); + assert_eq!( + ( + app.behavior.diagram.zoom, + app.behavior.diagram.pan, + app.behavior.diagram.fit + ), + camera + ); +} + +#[tokio::test] +async fn mounted_attachment_escape_preserves_modal_priority_and_focused_canvas_control() { + let (mut app, mut mounted, _) = armed().await; + let before = app.document.snapshot(); + // Existing modal Escape consumes the first key without canceling the tool underneath. + send(&mut app, Message::Delete); + assert!(app.delete_pending && app.delete_target.is_some()); + mounted.settle(&mut app).await; + press(&mut mounted, &mut app, Named::Escape).await; + assert!(!app.delete_pending); + assert!(matches!( + app.behavior.diagram.tool, + Tool::AdjustAttachment { .. } + )); + // A real canvas click grants ownership; inner-canvas Escape remains live. + let bounds = canvas_focus(&mut mounted, &app).0; + mounted + .click(&mut app, iced::Point::new(bounds.x + 10., bounds.y + 10.)) + .await; + assert!(canvas_focus(&mut mounted, &app).1); + let messages = press(&mut mounted, &mut app, Named::Escape).await; + assert!( + messages + .iter() + .any(|m| matches!(m, Message::StateDiagram(Event::Choose(Tool::Select)))) + ); + assert_eq!(app.behavior.diagram.tool, Tool::Select); + assert!(Arc::ptr_eq(&before, &app.document.snapshot())); +} diff --git a/crates/oa-desktop/src/app/state_diagram_tests.rs b/crates/oa-desktop/src/app/state_diagram_tests.rs index 0be1b787..b3a7bb5b 100644 --- a/crates/oa-desktop/src/app/state_diagram_tests.rs +++ b/crates/oa-desktop/src/app/state_diagram_tests.rs @@ -1,5 +1,7 @@ //! App transaction and draft boundaries for captured state-canvas messages. use super::*; +#[path = "state_attachment_escape_tests.rs"] +mod attachment_escape; use crate::behavior::{ self, state_edit::{self, Context, Endpoint, Event, Tool}, diff --git a/crates/oa-desktop/src/behavior/canvas_workspace/tests.rs b/crates/oa-desktop/src/behavior/canvas_workspace/tests.rs index b9d9649d..26313eec 100644 --- a/crates/oa-desktop/src/behavior/canvas_workspace/tests.rs +++ b/crates/oa-desktop/src/behavior/canvas_workspace/tests.rs @@ -158,6 +158,14 @@ impl Mounted { events: &[iced::Event], cursor: mouse::Cursor, ) -> Vec { + self.input_with_status(app, events, cursor).0 + } + pub fn input_with_status( + &mut self, + app: &App, + events: &[iced::Event], + cursor: mouse::Cursor, + ) -> (Vec, Vec) { let mut messages = vec![]; let mut ui = UserInterface::build( app.view(), @@ -165,7 +173,7 @@ impl Mounted { std::mem::take(&mut self.cache), &mut self.renderer, ); - let _ = ui.update( + let (_, statuses) = ui.update( events, cursor, &mut self.renderer, @@ -173,7 +181,7 @@ impl Mounted { &mut messages, ); self.cache = ui.into_cache(); - messages + (messages, statuses) } pub async fn perform(&mut self, app: &mut App, task: iced::Task) { let mut ready = vec![];