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() {
|
} else if self.behavior.creation.has_pending() {
|
||||||
self.behavior.creation.acknowledge_commit();
|
self.behavior.creation.acknowledge_commit();
|
||||||
self.status = "Behavior creation cancelled".into();
|
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() {
|
if self.connection.take().is_some() {
|
||||||
self.status = "Drawing cancelled · Select / move is active".into();
|
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.
|
//! App transaction and draft boundaries for captured state-canvas messages.
|
||||||
use super::*;
|
use super::*;
|
||||||
|
#[path = "state_attachment_escape_tests.rs"]
|
||||||
|
mod attachment_escape;
|
||||||
use crate::behavior::{
|
use crate::behavior::{
|
||||||
self,
|
self,
|
||||||
state_edit::{self, Context, Endpoint, Event, Tool},
|
state_edit::{self, Context, Endpoint, Event, Tool},
|
||||||
|
|||||||
@@ -158,6 +158,14 @@ impl Mounted {
|
|||||||
events: &[iced::Event],
|
events: &[iced::Event],
|
||||||
cursor: mouse::Cursor,
|
cursor: mouse::Cursor,
|
||||||
) -> Vec<Message> {
|
) -> 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 messages = vec![];
|
||||||
let mut ui = UserInterface::build(
|
let mut ui = UserInterface::build(
|
||||||
app.view(),
|
app.view(),
|
||||||
@@ -165,7 +173,7 @@ impl Mounted {
|
|||||||
std::mem::take(&mut self.cache),
|
std::mem::take(&mut self.cache),
|
||||||
&mut self.renderer,
|
&mut self.renderer,
|
||||||
);
|
);
|
||||||
let _ = ui.update(
|
let (_, statuses) = ui.update(
|
||||||
events,
|
events,
|
||||||
cursor,
|
cursor,
|
||||||
&mut self.renderer,
|
&mut self.renderer,
|
||||||
@@ -173,7 +181,7 @@ impl Mounted {
|
|||||||
&mut messages,
|
&mut messages,
|
||||||
);
|
);
|
||||||
self.cache = ui.into_cache();
|
self.cache = ui.into_cache();
|
||||||
messages
|
(messages, statuses)
|
||||||
}
|
}
|
||||||
pub async fn perform(&mut self, app: &mut App, task: iced::Task<Message>) {
|
pub async fn perform(&mut self, app: &mut App, task: iced::Task<Message>) {
|
||||||
let mut ready = vec![];
|
let mut ready = vec![];
|
||||||
|
|||||||
Reference in New Issue
Block a user