Cancel menu-armed State attachment tool on Escape
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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<Message> {
|
||||
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()));
|
||||
}
|
||||
@@ -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},
|
||||
|
||||
@@ -158,6 +158,14 @@ impl Mounted {
|
||||
events: &[iced::Event],
|
||||
cursor: mouse::Cursor,
|
||||
) -> Vec<Message> {
|
||||
self.input_with_status(app, events, cursor).0
|
||||
}
|
||||
pub fn input_with_status(
|
||||
&mut self,
|
||||
app: &App,
|
||||
events: &[iced::Event],
|
||||
cursor: mouse::Cursor,
|
||||
) -> (Vec<Message>, Vec<iced::event::Status>) {
|
||||
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<Message>) {
|
||||
let mut ready = vec![];
|
||||
|
||||
Reference in New Issue
Block a user