Complete canvas input wrappers and verify saved-removal event batches

This commit is contained in:
2026-09-24 22:18:12 -07:00
parent 3ab63ef028
commit 1b683ea336
5 changed files with 193 additions and 10 deletions
@@ -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,
@@ -1529,9 +1529,8 @@ fn kind_for_tool(tool: Tool) -> Kind {
}
}
impl canvas::Program<Event> for StateCanvas<'_> {
type State = State;
fn update(
impl StateCanvas<'_> {
fn update_input(
&self,
state: &mut State,
event: &canvas::Event,
+2 -3
View File
@@ -610,9 +610,8 @@ impl DiagramCanvas<'_> {
});
}
}
impl canvas::Program<Message> for DiagramCanvas<'_> {
type State = State;
fn update(
impl DiagramCanvas<'_> {
fn update_input(
&self,
state: &mut State,
event: &canvas::Event,
+10
View File
@@ -206,6 +206,16 @@ impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> 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;
+16
View File
@@ -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());
}
}