diff --git a/crates/oa-desktop/src/app/behavior_removal_tests.rs b/crates/oa-desktop/src/app/behavior_removal_tests.rs index f6d6d609..b4db350a 100644 --- a/crates/oa-desktop/src/app/behavior_removal_tests.rs +++ b/crates/oa-desktop/src/app/behavior_removal_tests.rs @@ -179,7 +179,7 @@ async fn mounted_scenario_use_delete_keeps_definition_and_second_use_and_rejects let token = delete(&mut mounted, &mut app, true).await; send(&mut mounted, &mut app, Message::ConfirmDelete(stale)).await; assert!(app.delete_pending, "old token does not cancel new dialog"); - // Keep selection and epoch deliberately unchanged: owner is an independent witness. + // Keep selection and epoch unchanged while moving to another Scenario owner. let captured = app.scenario_context().unwrap(); assert_eq!(captured.scenario, owners[0]); assert_eq!( @@ -197,9 +197,28 @@ async fn mounted_scenario_use_delete_keeps_definition_and_second_use_and_rejects assert_eq!(other.epoch, captured.epoch); assert_eq!(app.selected, uses[0]); assert!( - app.delete_target.as_ref().unwrap().snapshot.valid(&app), - "the independent owner guard must reject an otherwise current token" + !app.delete_target.as_ref().unwrap().snapshot.valid(&app), + "the real captured snapshot rejects the changed Scenario destination" ); + // Isolate the independent behavior-owner predicate: a current, unqualified + // object request has no old Scenario envelope or destination to reject it. + // This test-only replacement does not refresh the captured behavior owner. + let otherwise_current = crate::model_menu::Opened::new( + &app, + crate::model_menu::Request::object(uses[0], Point::ORIGIN), + token, + ) + .unwrap(); + assert!(otherwise_current.valid(&app)); + let target = app.delete_target.as_ref().unwrap(); + assert_eq!(target.selection, app.selected); + assert_eq!(target.placement, None); + assert_eq!(app.selected_node, None); + assert_eq!(target.behavior_owner, Some(owners[0])); + assert_eq!(diagram_removal::behavior_owner(&app), Some(owners[1])); + assert!(app.delete_pending); + assert!(!app.behavior.has_pending() && !app.source_dirty() && !app.draft_dirty()); + app.delete_target.as_mut().unwrap().snapshot = otherwise_current; send(&mut mounted, &mut app, Message::ConfirmDelete(token)).await; assert_eq!(app.document.bundle(), &original); assert!(!app.delete_pending && app.delete_target.is_none()); diff --git a/crates/oa-desktop/src/app/scenario_multiselect.rs b/crates/oa-desktop/src/app/scenario_multiselect.rs index dbc0ce9f..7f3d1491 100644 --- a/crates/oa-desktop/src/app/scenario_multiselect.rs +++ b/crates/oa-desktop/src/app/scenario_multiselect.rs @@ -20,6 +20,7 @@ impl App { return; }; let scope = selection::context(context).scope; + let before = self.multiselect_snapshot(); let visible = selection::visible(self.project(), context, self.selected); let old = self .diagram_selection @@ -45,6 +46,13 @@ impl App { selected.clear(); } } + if before.is_some() && self.multiselect_snapshot() != before { + // Legacy singleton selection also retires captured spacing input. + // This must not change the camera lifetime: blank selection input + // and a new background pan can share the same pointer press. + self.behavior.scenario.cancel_spacing(); + self.canvas_epoch = self.canvas_epoch.wrapping_add(1); + } } pub(super) fn inspect_scenario_multiselect_primary(&mut self) { @@ -120,8 +128,6 @@ impl App { // Every generation change, including a secondary membership ABA, // retires old singleton spacing packets as well as group captures. self.canvas_epoch = self.canvas_epoch.wrapping_add(1); - self.camera_epoch = self.camera_epoch.wrapping_add(1); - self.camera_boundary_epoch = self.canvas_epoch; self.inspect_scenario_multiselect_primary(); } Task::none() diff --git a/crates/oa-desktop/src/app/scenario_multiselect/tests.rs b/crates/oa-desktop/src/app/scenario_multiselect/tests.rs index 6b9e932b..b92e178c 100644 --- a/crates/oa-desktop/src/app/scenario_multiselect/tests.rs +++ b/crates/oa-desktop/src/app/scenario_multiselect/tests.rs @@ -314,6 +314,49 @@ fn secondary_selection_aba_retires_old_spacing_without_canceling_a_new_gesture() ); } +#[test] +fn legacy_singleton_selection_aba_retires_old_spacing_begin() { + for canvas in [false, true] { + let (mut app, _, _, lane, message) = setup(true); + choose(&mut app, message, multiselect::Choice::Add); + let old = spacing::controller::tests::packet( + app.project(), + &app.behavior.scenario, + app.scenario_context().unwrap(), + app.camera_epoch, + spacing::Target::Message(message), + ); + for id in [lane, message] { + let event = if canvas { + Message::ScenarioCanvas(CanvasEvent::Select(app.scenario_context().unwrap(), id)) + } else { + Message::Select(id) + }; + let _ = app.update(event); + assert_eq!(app.selected, id); + assert_eq!( + app.multiselect_snapshot().unwrap().targets(), + &[Target::ScenarioObject(id)] + ); + } + assert_ne!(app.scenario_context().unwrap(), old.stamp().context); + inert( + &mut app, + vec![Message::ScenarioCanvas(CanvasEvent::Spacing(old))], + true, + ); + let current = spacing::controller::tests::packet( + app.project(), + &app.behavior.scenario, + app.scenario_context().unwrap(), + app.camera_epoch, + spacing::Target::Message(message), + ); + let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(current))); + assert_eq!(app.behavior.scenario.spacing_token(), Some(current.token())); + } +} + #[test] fn pending_primary_edit_is_not_applied_by_selection_input() { let (mut app, _, _, lane, message) = setup(true); diff --git a/crates/oa-desktop/src/behavior/scenario/workspace.rs b/crates/oa-desktop/src/behavior/scenario/workspace.rs index f0b853a0..4fc8d210 100644 --- a/crates/oa-desktop/src/behavior/scenario/workspace.rs +++ b/crates/oa-desktop/src/behavior/scenario/workspace.rs @@ -198,7 +198,12 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a container(text("No supported canvas geometry is available. Inspect the model or open its source to repair this view.")) .id("scenario-viewport-unavailable").padding(16).width(Fill).height(Fill).style(style::card).into() }; - let mut navigation = row![out.width(Fill)].spacing(6); + let selected_count = snapshot.as_ref().map_or(0, |s| s.targets().len()); + let mut navigation = row![ + out.width(Fill), + text(format!("{selected_count} selected")).size(12) + ] + .spacing(6); if state.can_back(p, context) { navigation = navigation.push(button(text("Back").size(12)).padding(6).on_press_maybe( @@ -264,7 +269,9 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a let entries = [ ( "S", - if matches!(context.representation, Representation::Saved { .. }) { + if selected_count > 1 { + "Shift-click to change selection; click a member to select only it" + } else if matches!(context.representation, Representation::Saved { .. }) { "Select / move messages and lifelines" } else { "Select / reorder occurrences" @@ -345,7 +352,15 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a }), ); let mut details = column![ - text(format!("Selected: {}", p.named(app.selected))).size(12), + text(if selected_count > 1 { + format!( + "{selected_count} selected; properties: {}", + p.named(app.selected) + ) + } else { + format!("Selected: {}", p.named(app.selected)) + }) + .size(12), container(button(text("Properties").size(12)).on_press_maybe( (clean && properties_target(p, context, app.selected)).then_some( Message::ScenarioCanvas(CanvasEvent::Properties(context, app.selected)), diff --git a/crates/oa-desktop/src/behavior/scenario/workspace/details_tests.rs b/crates/oa-desktop/src/behavior/scenario/workspace/details_tests.rs index 33622c7d..20b48445 100644 --- a/crates/oa-desktop/src/behavior/scenario/workspace/details_tests.rs +++ b/crates/oa-desktop/src/behavior/scenario/workspace/details_tests.rs @@ -443,3 +443,175 @@ fn opening_scenario_properties_cancels_held_spacing_and_rejects_its_late_finish( assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot())); assert!(!app.document.can_undo() && !app.document.can_redo()); } + +#[tokio::test] +async fn saved_selection_shift_survives_properties_focus_busy_rebuild_and_clears_on_window_loss() { + use crate::app::multiselect::Choice; + use crate::behavior::canvas_workspace::tests::focused; + use crate::diagram_usage::Target; + + fn at(mounted: &mut Mounted, app: &App, target: Id) -> Point { + let context = app.scenario_context().unwrap(); + let geometry = resolve(app.project(), context.representation, app.selected).unwrap(); + let line = geometry + .scene + .lines + .iter() + .find(|line| line.id == target) + .unwrap(); + let world = line.label_at + Vector::new(8., 8.); + assert_eq!(geometry.scene.hit_item(world).unwrap().id, target); + let bounds = mounted.geometry(app).bounds("scenario-viewport"); + let camera = app.behavior.scenario.presentation.camera(geometry.bounds); + let point = bounds.position() + camera.pan + Vector::new(world.x, world.y) * camera.scale; + assert!(bounds.contains(point)); + point + } + async fn focus_name(mounted: &mut Mounted, app: &mut App) -> Point { + let shown = mounted.geometry(app); + let (inspector, offset) = shown.scrolls[&widget::Id::new("inspector")]; + let input = shown + .text_inputs + .iter() + .map(|bounds| bounds.center() - offset) + .filter(|point| inspector.contains(*point)) + .min_by(|a, b| a.y.total_cmp(&b.y)) + .expect("visible Name input"); + mounted.click(app, input).await; + assert!(!focused(mounted, app, "viewport")); + assert!( + mounted + .geometry(app) + .focused + .iter() + .any(|(_, bounds, yes)| *yes && bounds.contains(input + offset)) + ); + assert!(!app.draft_dirty()); + input + } + let shift = |held| { + iced::Event::Keyboard(keyboard::Event::ModifiersChanged(if held { + keyboard::Modifiers::SHIFT + } else { + keyboard::Modifiers::empty() + })) + }; + let (mut app, diagram, first) = setup(); + let second = id(&app, "updated_report"); + let mut mounted = Mounted::new(&mut app).await; + open_details(&mut app); + mounted.settle(&mut app).await; + let shown = mounted.geometry(&app); + let (details, offset) = shown.scrolls[&widget::Id::new("scenario-details")]; + let properties = shown.bounds("scenario-properties").center() - offset; + assert!(details.contains(properties)); + mounted.click(&mut app, properties).await; + mounted.settle(&mut app).await; + let input = focus_name(&mut mounted, &mut app).await; + let before = app.document.snapshot(); + let camera = stored_camera(&app); + let context = app.scenario_context().unwrap(); + let selected = app.multiselect_snapshot().unwrap(); + assert_eq!(selected.targets(), &[Target::ScenarioObject(first)]); + let passive = mounted + .batch(&mut app, &[shift(true)], mouse::Cursor::Available(input)) + .await; + assert!( + !passive + .iter() + .any(|message| matches!(message, Message::ScenarioCanvas(_))) + ); + assert!(!focused(&mut mounted, &app, "viewport")); + assert_eq!(app.multiselect_snapshot().unwrap(), selected); + let second_at = at(&mut mounted, &app, second); + let messages = mounted.click(&mut app, second_at).await; + assert_eq!(messages.iter().filter(|message| matches!(message, + Message::ScenarioCanvas(CanvasEvent::Selection(event)) + if matches!(&event.intent, selection::Intent::Choose(Target::ScenarioObject(id), Choice::Toggle) if *id == second))).count(), 1); + assert!(!messages.iter().any(|message| matches!( + message, + Message::ScenarioCanvas(CanvasEvent::Spacing(_) | CanvasEvent::Reorder { .. }) + ))); + assert_eq!( + app.multiselect_snapshot().unwrap().targets(), + &[ + Target::ScenarioObject(first), + Target::ScenarioObject(second) + ] + ); + assert_eq!( + app.multiselect_snapshot().unwrap().primary(), + Some(&Target::ScenarioObject(second)) + ); + + app.busy = true; + mounted.settle(&mut app).await; + mounted + .batch( + &mut app, + &[shift(false)], + mouse::Cursor::Available(second_at), + ) + .await; + app.busy = false; + mounted.settle(&mut app).await; + let first_at = at(&mut mounted, &app, first); + let messages = mounted.click(&mut app, first_at).await; + assert!(messages.iter().any(|message| matches!(message, + Message::ScenarioCanvas(CanvasEvent::Selection(event)) + if matches!(&event.intent, selection::Intent::Collapse(Target::ScenarioObject(id)) if *id == first)))); + assert_eq!( + app.multiselect_snapshot().unwrap().targets(), + &[Target::ScenarioObject(first)] + ); + + let input = focus_name(&mut mounted, &mut app).await; + app.busy = true; + mounted.settle(&mut app).await; + mounted + .batch(&mut app, &[shift(true)], mouse::Cursor::Available(input)) + .await; + app.busy = false; + mounted.settle(&mut app).await; + let second_at = at(&mut mounted, &app, second); + mounted.click(&mut app, second_at).await; + assert_eq!( + app.multiselect_snapshot().unwrap().targets(), + &[ + Target::ScenarioObject(first), + Target::ScenarioObject(second) + ] + ); + assert_eq!( + app.multiselect_snapshot().unwrap().primary(), + Some(&Target::ScenarioObject(second)) + ); + mounted + .batch( + &mut app, + &[iced::Event::Window(iced::window::Event::Unfocused)], + mouse::Cursor::Unavailable, + ) + .await; + let first_at = at(&mut mounted, &app, first); + mounted.click(&mut app, first_at).await; + assert_eq!( + app.multiselect_snapshot().unwrap().targets(), + &[Target::ScenarioObject(first)] + ); + assert_eq!( + app.multiselect_snapshot().unwrap().primary(), + Some(&Target::ScenarioObject(first)) + ); + assert_eq!(app.active_diagram, Some(diagram)); + assert_eq!( + app.scenario_context().unwrap().representation, + context.representation + ); + assert_eq!(app.scenario_context().unwrap().visit, context.visit); + assert_eq!(stored_camera(&app), camera); + assert!(Arc::ptr_eq(&before, &app.document.snapshot())); + assert_eq!(app.document.bundle(), &before.bundle); + assert!(!app.document.can_undo() && !app.document.can_redo()); + assert!(app.behavior.scenario.spacing_token().is_none()); +} diff --git a/crates/oa-desktop/src/behavior/scenario/workspace/spacing_tests.rs b/crates/oa-desktop/src/behavior/scenario/workspace/spacing_tests.rs index 5f3bd640..e662f2d1 100644 --- a/crates/oa-desktop/src/behavior/scenario/workspace/spacing_tests.rs +++ b/crates/oa-desktop/src/behavior/scenario/workspace/spacing_tests.rs @@ -436,3 +436,204 @@ async fn saved_drag_return_to_origin_preserves_redo_selection_and_original_fit_c assert_eq!(app.project(), &redo.decoded.project); assert_eq!(app.document.bundle(), &redo.bundle); } + +#[tokio::test] +async fn saved_first_press_spaces_unselected_message_with_existing_singleton_selection() { + use crate::diagram_usage::Target; + let (mut app, mut mounted, diagram) = setup(false).await; + let initial = id(&app, "initial_report"); + let target = id(&app, "updated_report"); + let context = app.scenario_context().unwrap(); + let snapshot = app.multiselect_snapshot().unwrap(); + let task = app.update(selection::message( + context, + &snapshot, + selection::Intent::Choose( + Target::ScenarioObject(initial), + crate::app::multiselect::Choice::Replace, + ), + )); + mounted.perform(&mut app, task).await; + mounted.settle(&mut app).await; + assert_eq!( + app.multiselect_snapshot().unwrap().targets(), + &[Target::ScenarioObject(initial)] + ); + let before = app.document.snapshot(); + let revision = app.document.revision(); + let context = app.scenario_context().unwrap(); + let camera = stored_camera(&app); + let projected = screen(&mut mounted, &app, Point::new(400., 252.)); + let start = Point::new(projected.x.round(), projected.y.round()); + let geometry = resolved(&app); + let effective = app.behavior.scenario.presentation.camera(geometry.bounds); + assert_eq!(effective.scale, 0.5); + let bounds = mounted.geometry(&app).bounds("scenario-viewport"); + let world = Point::new( + (start.x - bounds.x - effective.pan.x) / effective.scale, + (start.y - bounds.y - effective.pan.y) / effective.scale, + ); + let hit = geometry.scene.hit_item(world).unwrap(); + assert_eq!(hit.id, target); + assert_eq!(hit.role, crate::behavior::SceneHitRole::MessageBody); + let messages = press(&mut mounted, &mut app, start).await; + assert_eq!( + messages + .iter() + .filter(|message| matches!( + message, + Message::ScenarioCanvas(CanvasEvent::Spacing(spacing::Event::Begin { .. })) + )) + .count(), + 1 + ); + assert!( + !messages + .iter() + .any(|message| matches!(message, Message::ScenarioCanvas(CanvasEvent::Selection(_)))) + ); + let token = app + .behavior + .scenario + .spacing_token() + .expect("first press starts saved spacing despite different selected singleton"); + assert!(Arc::ptr_eq(&before, &app.document.snapshot())); + let end = start + Vector::new(0., 10.); + move_to(&mut mounted, &mut app, end).await; + mounted.settle(&mut app).await; + assert_eq!(app.behavior.scenario.spacing_token(), Some(token)); + let messages = release(&mut mounted, &mut app, end).await; + assert_eq!( + messages + .iter() + .filter(|message| matches!( + message, + Message::ScenarioCanvas(CanvasEvent::Spacing(spacing::Event::Finish { .. })) + )) + .count(), + 1 + ); + let mut expected = before.decoded.project.clone(); + set_expected_message(&mut expected, diagram, target, 272.); + assert_eq!(app.project(), &expected); + assert_eq!(app.document.revision(), revision + 1); + assert_eq!( + app.multiselect_snapshot().unwrap().targets(), + &[Target::ScenarioObject(target)] + ); + assert_eq!( + app.multiselect_snapshot().unwrap().primary(), + Some(&Target::ScenarioObject(target)) + ); + assert!(app.behavior.scenario.spacing_token().is_none()); + assert_eq!(stored_camera(&app), camera); + assert_eq!( + app.scenario_context().unwrap().representation, + context.representation + ); + assert_eq!(app.scenario_context().unwrap().visit, context.visit); + assert_reopens_exactly(&app); + let after = app.document.snapshot(); + let revision = app.document.revision(); + let duplicate = release(&mut mounted, &mut app, end).await; + assert!(!duplicate.iter().any(|message| matches!( + message, + Message::ScenarioCanvas(CanvasEvent::Spacing(spacing::Event::Finish { .. })) + ))); + assert_eq!(app.document.revision(), revision); + assert!(Arc::ptr_eq(&after, &app.document.snapshot())); + let task = app.update(Message::Undo); + mounted.perform(&mut app, task).await; + assert_eq!(app.project(), &before.decoded.project); + assert_eq!(app.document.bundle(), &before.bundle); + assert!(!app.document.can_undo() && app.document.can_redo()); + let task = app.update(Message::Redo); + mounted.perform(&mut app, task).await; + assert_eq!(app.project(), &after.decoded.project); + assert_eq!(app.document.bundle(), &after.bundle); +} + +#[tokio::test] +async fn saved_blank_left_drag_clears_selection_and_pans_on_that_same_press() { + use crate::diagram_usage::Target; + for group in [false, true] { + for same_batch in [false, true] { + let (mut app, mut mounted, _) = setup(false).await; + let first = id(&app, "initial_report"); + let second = id(&app, "updated_report"); + for target in [Some(first), group.then_some(second)].into_iter().flatten() { + let context = app.scenario_context().unwrap(); + let snapshot = app.multiselect_snapshot().unwrap(); + let task = app.update(selection::message( + context, + &snapshot, + selection::Intent::Choose( + Target::ScenarioObject(target), + crate::app::multiselect::Choice::Add, + ), + )); + mounted.perform(&mut app, task).await; + } + mounted.settle(&mut app).await; + assert_eq!( + app.multiselect_snapshot().unwrap().targets().len(), + if group { 2 } else { 1 } + ); + let before = app.document.snapshot(); + let context = app.scenario_context().unwrap(); + let bounds = mounted.geometry(&app).bounds("scenario-viewport"); + let start = bounds.position() + Vector::new(bounds.width - 25., bounds.height - 25.); + let delta = Vector::new(-25., -20.); + let end = start + delta; + let geometry = resolved(&app); + let camera = app.behavior.scenario.presentation.camera(geometry.bounds); + let world = Point::new( + (start.x - bounds.x - camera.pan.x) / camera.scale, + (start.y - bounds.y - camera.pan.y) / camera.scale, + ); + assert!( + geometry.scene.hit_item(world).is_none(), + "test must start on actual blank geometry" + ); + let stored = stored_camera(&app); + let messages = if same_batch { + mounted + .batch( + &mut app, + &[ + mouse_event(mouse::Event::CursorMoved { position: start }), + mouse_event(mouse::Event::ButtonPressed(mouse::Button::Left)), + mouse_event(mouse::Event::CursorMoved { position: end }), + mouse_event(mouse::Event::ButtonReleased(mouse::Button::Left)), + ], + mouse::Cursor::Available(end), + ) + .await + } else { + let mut messages = press(&mut mounted, &mut app, start).await; + assert!(app.multiselect_snapshot().unwrap().targets().is_empty()); + mounted.settle(&mut app).await; + messages.extend(move_to(&mut mounted, &mut app, end).await); + messages.extend(release(&mut mounted, &mut app, end).await); + messages + }; + assert_eq!(messages.iter().filter(|message| matches!(message, Message::ScenarioCanvas(CanvasEvent::Selection(event)) if matches!(&event.intent, selection::Intent::Clear))).count(), 1); + assert!(app.multiselect_snapshot().unwrap().targets().is_empty()); + let actual = stored_camera(&app); + let difference = actual.0 - (stored.0 + delta); + assert!( + difference.x.hypot(difference.y) < 0.01, + "same-press pan lost after Clear: group={group}, same_batch={same_batch}, {stored:?} -> {actual:?}" + ); + assert_eq!(actual.1, stored.1); + assert_eq!( + app.scenario_context().unwrap().representation, + context.representation + ); + assert_eq!(app.scenario_context().unwrap().visit, context.visit); + assert!(Arc::ptr_eq(&before, &app.document.snapshot())); + assert_eq!(app.document.bundle(), &before.bundle); + assert!(!app.document.can_undo() && !app.document.can_redo()); + } + } +}