//! Production canvas event regressions for exact multi-selection and group gestures. use super::{DiagramCanvas, State}; use crate::app::{ Message, multiselect::{Choice, Event as SelectionEvent, context}, }; use crate::diagram_selection::Selection; use crate::diagram_usage::Target; use iced::widget::canvas::{self, Program}; use iced::{Point, Rectangle, Size, Vector, event::Status, keyboard, mouse}; use oa_core::*; fn position(x: f32, y: f32) -> Position { Position { x, y } } fn bounds() -> Rectangle { Rectangle::new(Point::new(260., 100.), Size::new(2400., 1800.)) } struct Fixture { project: Project, diagram: Id, a: Id, b: Id, child: Id, internal: Id, context: crate::route_edit::Context, } impl Fixture { fn new() -> Self { let mut project = Project::empty(); let a = project .add_element(Kind::Activity, Layer::Oa, "A", None) .unwrap(); let b = project .add_element(Kind::Activity, Layer::Oa, "B", None) .unwrap(); let c = project .add_element(Kind::Activity, Layer::Oa, "C", None) .unwrap(); let child = project .add_element(Kind::Activity, Layer::Oa, "Child", Some(a)) .unwrap(); let internal = project.relate(RelationKind::Interaction, "Internal", a, b); let external = project.relate(RelationKind::Interaction, "External", b, c); let diagram = project.ensure_diagram(Layer::Oa); project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Overview; let a = project.place(diagram, a, position(100., 200.)); let b = project.place(diagram, b, position(500., 200.)); let child = project.place(diagram, child, position(120., 280.)); project.place(diagram, c, position(900., 200.)); let view = project.diagrams.get_mut(&diagram).unwrap(); view.nodes.get_mut(&a).unwrap().collapsed = true; view.routes = [ (internal, position(400., 320.)), (external, position(800., 340.)), ] .into_iter() .map(|(edge, point)| EdgeRoute { edge, source_attachment: None, target_attachment: None, waypoints: vec![point], label: Some(EdgeLabel { fraction: 0.4, offset: position(3., -9.), }), }) .collect(); validate_candidate(&project).unwrap(); Self { project, diagram, a, b, child, internal, context: crate::route_edit::Context { document: Id::new_v4(), diagram, revision: 7, epoch: 11, }, } } fn selection(&self, targets: impl IntoIterator) -> Selection { let mut selection = Selection::new(context(self.context).scope); for target in targets { assert!(selection.add(target, |_| true)); } selection } fn pair(&self) -> Selection { self.selection([Target::Placement(self.a), Target::Placement(self.b)]) } fn canvas<'a>(&'a self, selection: &'a Selection, zoom: f32) -> DiagramCanvas<'a> { let selected = match selection.primary() { Some(Target::Placement(id)) => self.project.diagrams[&self.diagram].nodes[id].element, Some(Target::Edge(id)) => *id, Some(Target::Port { port, .. }) => *port, _ => self.diagram, }; DiagramCanvas { selection: Some(selection), project: &self.project, view_index: None, diagram: self.diagram, selected, navigation_focus: None, zoom, pan: Vector::new(20., 30.), context: self.context, selected_bend: None, selected_attachment: None, editable: true, placing: false, connection: None, allocation: None, } } fn screen(&self, placement: Id, zoom: f32) -> Point { let p = self.project.diagrams[&self.diagram].nodes[&placement].position; Point::new( bounds().x + (p.x + 30.) * zoom + 20., bounds().y + (p.y + 25.) * zoom + 30., ) } fn expected_move(&self) -> Diagram { // Independent literal expectations: the folded child moves with A, // only the internal edge's authored world pin translates. let mut expected = self.project.diagrams[&self.diagram].clone(); expected.nodes.get_mut(&self.a).unwrap().position = position(140., 260.); expected.nodes.get_mut(&self.b).unwrap().position = position(540., 260.); expected.nodes.get_mut(&self.child).unwrap().position = position(160., 340.); expected.routes[0].waypoints = vec![position(440., 380.)]; expected } } fn input( c: &DiagramCanvas<'_>, state: &mut State, event: canvas::Event, viewport: Rectangle, final_cursor: Point, ) -> (Option, Status) { c.update( state, &event, viewport, mouse::Cursor::Available(final_cursor), ) .map_or((None, Status::Ignored), |action| { let (message, _, status) = action.into_inner(); (message, status) }) } fn pointer( c: &DiagramCanvas<'_>, state: &mut State, event: mouse::Event, final_cursor: Point, ) -> (Option, Status) { input( c, state, canvas::Event::Mouse(event), bounds(), final_cursor, ) } fn start(c: &DiagramCanvas<'_>, state: &mut State, press: Point, final_cursor: Point) { assert!( pointer( c, state, mouse::Event::CursorMoved { position: press }, final_cursor ) .0 .is_none() ); let (message, status) = pointer( c, state, mouse::Event::ButtonPressed(mouse::Button::Left), final_cursor, ); assert!(message.is_none()); assert_eq!(status, Status::Captured); assert!(state.group.is_some()); assert!(state.drag.is_none()); } fn key(named: keyboard::key::Named) -> canvas::Event { canvas::Event::Keyboard(keyboard::Event::KeyPressed { key: keyboard::Key::Named(named), modified_key: keyboard::Key::Named(named), physical_key: keyboard::key::Physical::Code(if named == keyboard::key::Named::Escape { keyboard::key::Code::Escape } else { keyboard::key::Code::Delete }), location: keyboard::Location::Standard, modifiers: keyboard::Modifiers::empty(), text: None, repeat: false, }) } fn expect_move( message: Option, selection: &Selection, current: crate::route_edit::Context, expected: Position, ) -> crate::diagram_selection::Snapshot { let Some(Message::DiagramSelection(SelectionEvent::Move(snapshot, delta))) = message else { panic!("expected one captured group Move"); }; assert_eq!(delta, expected); assert!(selection.matches(&snapshot, context(current))); snapshot } #[test] fn shift_press_uses_ordered_hit_not_native_batch_final_cursor() { let f = Fixture::new(); let selection = f.selection([Target::Placement(f.b)]); let before = selection.snapshot(context(f.context)).unwrap(); let c = f.canvas(&selection, 1.); let mut state = State::default(); let at_a = f.screen(f.a, 1.); let final_cursor = f.screen(f.b, 1.); assert!( input( &c, &mut state, canvas::Event::Keyboard(keyboard::Event::ModifiersChanged( keyboard::Modifiers::SHIFT )), bounds(), final_cursor ) .0 .is_none() ); assert!( pointer( &c, &mut state, mouse::Event::CursorMoved { position: at_a }, final_cursor ) .0 .is_none() ); let (message, status) = pointer( &c, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), final_cursor, ); assert_eq!(status, Status::Captured); assert!( matches!(message, Some(Message::DiagramSelection(SelectionEvent::Choose(captured, Target::Placement(id), Choice::Toggle))) if captured == context(f.context) && id == f.a) ); assert!( state.group.is_none() && state.drag.is_none() && state.route_drag.is_none() && state.port_drag.is_none() ); assert!( pointer( &c, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), final_cursor ) .0 .is_none() ); assert!( selection.matches(&before, context(f.context)), "canvas emits intent without mutating selection" ); } #[test] fn group_canvas_preview_and_release_match_independent_expected_geometry() { for zoom in [0.5, 1., 2.] { let f = Fixture::new(); let selection = f.pair(); let captured = selection.snapshot(context(f.context)).unwrap(); let c = f.canvas(&selection, zoom); let mut state = State::default(); let press = f.screen(f.a, zoom); let end = press + Vector::new(40. * zoom, 60. * zoom); start(&c, &mut state, press, end); assert_eq!(c.preview_diagram(&state), f.project.diagrams[&f.diagram]); let (message, status) = pointer( &c, &mut state, mouse::Event::CursorMoved { position: end }, end, ); assert!(message.is_none()); assert_eq!(status, Status::Captured); assert_eq!(c.preview_diagram(&state), f.expected_move()); let (message, status) = pointer( &c, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), end, ); assert_eq!(status, Status::Captured); let snapshot = expect_move(message, &selection, f.context, position(40., 60.)); assert_eq!(snapshot, captured); assert!(state.group.is_none()); let ids = crate::app::multiselect::placements(&snapshot).unwrap(); let command = diagram_edit::translate_placements(&f.project, f.diagram, &ids, position(40., 60.)) .unwrap(); let actual = prepare_command(&f.project, &command).unwrap().project; let mut expected = f.project.clone(); expected.diagrams.insert(f.diagram, f.expected_move()); assert_eq!(actual, expected); assert!(selection.matches(&captured, context(f.context))); } } #[test] fn stationary_group_click_does_not_use_motion_after_release_in_same_batch() { let f = Fixture::new(); let selection = f.pair(); let c = f.canvas(&selection, 1.); let mut state = State::default(); let press = f.screen(f.a, 1.); let end = press + Vector::new(80., 70.); start(&c, &mut state, press, end); let (message, _) = pointer( &c, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), end, ); assert!( matches!(message, Some(Message::DiagramSelection(SelectionEvent::Collapse(captured, Target::Placement(id)))) if selection.matches(&captured, context(f.context)) && id == f.a) ); assert!( pointer( &c, &mut state, mouse::Event::CursorMoved { position: end }, end ) .0 .is_none() ); assert_eq!(c.preview_diagram(&state), f.project.diagrams[&f.diagram]); } #[test] fn mixed_set_keeps_click_lifecycle_and_refuses_only_actual_drag() { for moved in [false, true] { let f = Fixture::new(); let selection = f.selection([Target::Placement(f.a), Target::Edge(f.internal)]); let captured = selection.snapshot(context(f.context)).unwrap(); let c = f.canvas(&selection, 1.); let mut state = State::default(); let press = f.screen(f.a, 1.); let end = if moved { press + Vector::new(40., 60.) } else { press + Vector::new(2., 1.) }; start(&c, &mut state, press, end); assert!( pointer( &c, &mut state, mouse::Event::CursorMoved { position: end }, end ) .0 .is_none() ); assert_eq!(c.preview_diagram(&state), f.project.diagrams[&f.diagram]); let (message, status) = pointer( &c, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), end, ); assert_eq!(status, Status::Captured); if moved { assert!( matches!(message, Some(Message::DiagramSelection(SelectionEvent::Refuse(snapshot, reason))) if snapshot == captured && reason.contains("only placed elements")) ); } else { assert!( matches!(message, Some(Message::DiagramSelection(SelectionEvent::Collapse(current, Target::Placement(id)))) if current == captured && id == f.a) ); } assert!(state.group.is_none()); assert!(selection.matches(&captured, context(f.context))); } } #[test] fn escape_cancels_live_group_without_clearing_or_publishing_a_model_intent() { let f = Fixture::new(); let selection = f.pair(); let captured = selection.snapshot(context(f.context)).unwrap(); let c = f.canvas(&selection, 1.); let mut state = State::default(); let press = f.screen(f.a, 1.); let end = press + Vector::new(40., 60.); start(&c, &mut state, press, end); assert!( pointer( &c, &mut state, mouse::Event::CursorMoved { position: end }, end ) .0 .is_none() ); assert_eq!(c.preview_diagram(&state), f.expected_move()); let (message, status) = input( &c, &mut state, key(keyboard::key::Named::Escape), bounds(), end, ); assert!(message.is_none()); assert_eq!(status, Status::Captured); assert!(state.group.is_none()); assert_eq!(c.preview_diagram(&state), f.project.diagrams[&f.diagram]); assert!( pointer( &c, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), end ) .0 .is_none() ); assert!(selection.matches(&captured, context(f.context))); // A subsequent Escape, after the gesture is idle, may clear selection. assert!(matches!( input( &c, &mut state, key(keyboard::key::Named::Escape), bounds(), end ) .0, Some(Message::DiagramSelection(SelectionEvent::Clear(_))) )); } #[test] fn delete_is_captured_without_hiding_during_live_group_then_release_moves_once() { let f = Fixture::new(); let selection = f.pair(); let c = f.canvas(&selection, 1.); let mut state = State::default(); let press = f.screen(f.a, 1.); let end = press + Vector::new(40., 60.); start(&c, &mut state, press, end); assert!( pointer( &c, &mut state, mouse::Event::CursorMoved { position: end }, end ) .0 .is_none() ); let (message, status) = input( &c, &mut state, key(keyboard::key::Named::Delete), bounds(), end, ); assert!(message.is_none()); assert_eq!(status, Status::Captured); assert!(state.group.is_some()); assert_eq!(c.preview_diagram(&state), f.expected_move()); expect_move( pointer( &c, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), end, ) .0, &selection, f.context, position(40., 60.), ); } #[test] fn middle_pan_excludes_group_start_and_delete_even_when_left_is_pressed() { let f = Fixture::new(); let selection = f.pair(); let captured = selection.snapshot(context(f.context)).unwrap(); let c = f.canvas(&selection, 1.); let mut state = State::default(); let press = f.screen(f.a, 1.); let end = press + Vector::new(40., 60.); assert!( pointer( &c, &mut state, mouse::Event::CursorMoved { position: press }, end ) .0 .is_none() ); assert!( pointer( &c, &mut state, mouse::Event::ButtonPressed(mouse::Button::Middle), end ) .0 .is_none() ); let (message, status) = pointer( &c, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), end, ); assert!(message.is_none()); assert_eq!(status, Status::Captured); assert!(state.group.is_none() && state.drag.is_none()); assert!( input( &c, &mut state, key(keyboard::key::Named::Delete), bounds(), end ) .0 .is_none() ); assert!( matches!(pointer(&c, &mut state, mouse::Event::CursorMoved { position: end }, end).0, Some(Message::Pan(delta)) if delta == Vector::new(40., 60.)) ); assert!( pointer( &c, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), end ) .0 .is_none() ); assert!( pointer( &c, &mut state, mouse::Event::ButtonReleased(mouse::Button::Middle), end ) .0 .is_none() ); assert_eq!(c.preview_diagram(&state), f.project.diagrams[&f.diagram]); assert!(selection.matches(&captured, context(f.context))); } #[test] fn changed_viewport_or_context_retires_group_without_a_release_intent() { for change in 0..7 { let f = Fixture::new(); let selection = f.pair(); let c = f.canvas(&selection, 1.); let mut state = State::default(); let press = f.screen(f.a, 1.); let end = press + Vector::new(40., 60.); start(&c, &mut state, press, end); assert!( pointer( &c, &mut state, mouse::Event::CursorMoved { position: end }, end ) .0 .is_none() ); let mut changed = c; let mut viewport = bounds(); match change { 0 => viewport.x += 1., 1 => viewport.width -= 1., 2 => changed.pan.x += 1., 3 => changed.zoom = 2., 4 => changed.context.epoch += 1, 5 => changed.context.revision += 1, _ => changed.context.document = Id::new_v4(), } let (message, _) = input( &changed, &mut state, canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)), viewport, end, ); assert!( message.is_none(), "changed viewport/context {change} must not publish a commit or click" ); assert!(state.group.is_none()); assert_eq!( changed.preview_diagram(&state), f.project.diagrams[&f.diagram] ); } } #[test] fn changed_selection_generation_rejects_press_even_after_membership_is_restored() { let f = Fixture::new(); let mut selection = f.pair(); let press = f.screen(f.a, 1.); let end = press + Vector::new(40., 60.); let mut state = State::default(); { let c = f.canvas(&selection, 1.); start(&c, &mut state, press, end); assert!( pointer( &c, &mut state, mouse::Event::CursorMoved { position: end }, end ) .0 .is_none() ); } assert!(selection.remove(&Target::Placement(f.b))); assert!(selection.add(Target::Placement(f.b), |_| true)); let c = f.canvas(&selection, 1.); let (message, status) = pointer( &c, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), end, ); assert!(message.is_none()); assert_eq!(status, Status::Captured); assert!(state.group.is_none()); assert_eq!(c.preview_diagram(&state), f.project.diagrams[&f.diagram]); } #[test] fn initial_press_without_pointer_history_consumes_release_only_displacement() { let f = Fixture::new(); let selection = f.pair(); let c = f.canvas(&selection, 1.); let mut state = State::default(); let press = f.screen(f.a, 1.); let end = press + Vector::new(40., 60.); // With no earlier ordered move, the supplied release cursor is authoritative. assert!( pointer( &c, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), press ) .0 .is_none() ); assert!(state.group.is_some()); expect_move( pointer( &c, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), end, ) .0, &selection, f.context, position(40., 60.), ); } #[test] fn threshold_crossing_then_return_to_origin_emits_zero_move_not_singleton_click() { let f = Fixture::new(); let selection = f.pair(); let c = f.canvas(&selection, 1.); let mut state = State::default(); let press = f.screen(f.a, 1.); let end = press + Vector::new(40., 60.); start(&c, &mut state, press, end); assert!( pointer( &c, &mut state, mouse::Event::CursorMoved { position: end }, end ) .0 .is_none() ); assert!( pointer( &c, &mut state, mouse::Event::CursorMoved { position: press }, end ) .0 .is_none() ); assert_eq!(c.preview_diagram(&state), f.project.diagrams[&f.diagram]); expect_move( pointer( &c, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), end, ) .0, &selection, f.context, position(0., 0.), ); assert_eq!(selection.targets().len(), 2); }