Merge ordinary and State multiselect into the desktop candidate
This commit is contained in:
@@ -60,6 +60,7 @@ impl Root<'_> {
|
||||
return false;
|
||||
}
|
||||
let canvas = DiagramCanvas {
|
||||
selection: self.app.diagram_selection.as_ref(),
|
||||
allocation: Some(self.app.allocation_capability()),
|
||||
project: self.app.project(),
|
||||
view_index: Some(self.app.document.view_index()),
|
||||
|
||||
@@ -15,6 +15,9 @@ mod diagram_reveal;
|
||||
mod diagram_selection_tests;
|
||||
mod diagram_usages;
|
||||
mod hosted;
|
||||
pub(crate) mod multiselect;
|
||||
#[cfg(test)]
|
||||
mod multiselect_tests;
|
||||
mod reference_authoring;
|
||||
#[cfg(test)]
|
||||
mod review_navigation_tests;
|
||||
@@ -26,6 +29,10 @@ mod scenario_spacing;
|
||||
mod source_indentation_tests;
|
||||
pub(crate) mod source_rename;
|
||||
mod source_symbols;
|
||||
#[cfg(test)]
|
||||
mod state_multiselect_focus_tests;
|
||||
#[cfg(test)]
|
||||
mod state_multiselect_tests;
|
||||
pub(crate) mod text_workspace;
|
||||
#[cfg(test)]
|
||||
mod transition_context_tests;
|
||||
@@ -371,6 +378,7 @@ pub enum Message {
|
||||
Select(Id),
|
||||
EditProperties(Id),
|
||||
SelectNode(Id),
|
||||
DiagramSelection(multiselect::Event),
|
||||
SelectProject,
|
||||
Search(String),
|
||||
StartTransition,
|
||||
@@ -515,6 +523,7 @@ pub struct App {
|
||||
pub layer: Layer,
|
||||
pub selected: Id,
|
||||
pub selected_node: Option<Id>,
|
||||
pub diagram_selection: Option<crate::diagram_selection::Selection>,
|
||||
pub draft: Option<Draft>,
|
||||
pub active_diagram: Option<Id>,
|
||||
pub search: String,
|
||||
@@ -636,6 +645,7 @@ impl App {
|
||||
layer,
|
||||
selected,
|
||||
selected_node: None,
|
||||
diagram_selection: None,
|
||||
draft,
|
||||
active_diagram,
|
||||
search: String::new(),
|
||||
@@ -1337,6 +1347,38 @@ impl App {
|
||||
])
|
||||
}
|
||||
pub fn update(&mut self, message: Message) -> Task<Message> {
|
||||
let selection_boundary = self.multiselect_boundary();
|
||||
// Reject delayed gestures before they can retire newer input ownership.
|
||||
if let Message::DiagramSelection(event)
|
||||
| Message::StateDiagram(crate::behavior::state_edit::Event::Selection(event)) = &message
|
||||
&& !self.multiselect_envelope(event)
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
if let Message::StateDiagram(event) = &message
|
||||
&& let Some(captured) = event.context()
|
||||
&& self.state_context().is_none_or(|current| {
|
||||
crate::behavior::state_edit::validate_context(self.project(), captured, current)
|
||||
.is_err()
|
||||
})
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
if matches!(
|
||||
&message,
|
||||
Message::StateDiagram(
|
||||
crate::behavior::state_edit::Event::Select(..)
|
||||
| crate::behavior::state_edit::Event::Move { .. }
|
||||
| crate::behavior::state_edit::Event::Remove(_)
|
||||
)
|
||||
) && self
|
||||
.multiselect_snapshot()
|
||||
.is_some_and(|s| s.targets().len() > 1)
|
||||
{
|
||||
// A queued singleton cannot retire the current group's navigation
|
||||
// or allocation ownership before its edit is refused.
|
||||
return Task::none();
|
||||
}
|
||||
// Captured Text commands validate their origin before they can
|
||||
// invalidate that same origin or pass into existing command handlers.
|
||||
if let Message::TextWorkspace(event) = message {
|
||||
@@ -1439,7 +1481,11 @@ impl App {
|
||||
self.reconcile_pending_diagram_reveal(&message);
|
||||
self.allocation_before(&message);
|
||||
if let Message::WorkDrag(event) = message {
|
||||
return self.allocation_event(event);
|
||||
let task = self.allocation_event(event);
|
||||
if self.multiselect_boundary() != selection_boundary {
|
||||
self.reconcile_multiselect();
|
||||
}
|
||||
return task;
|
||||
}
|
||||
let allocation_before = self.allocation_boundary();
|
||||
if let Message::BehaviorControl(intent) = message {
|
||||
@@ -1480,6 +1526,11 @@ impl App {
|
||||
self.camera_epoch = self.camera_epoch.wrapping_add(1);
|
||||
self.camera_boundary_epoch = self.canvas_epoch;
|
||||
}
|
||||
if self.multiselect_boundary() != selection_boundary
|
||||
|| (self.diagram_selection.is_none() && self.multiselect_context().is_some())
|
||||
{
|
||||
self.reconcile_multiselect();
|
||||
}
|
||||
task
|
||||
}
|
||||
fn behavior_control(&mut self, intent: behavior::canvas_workspace::Intent) -> Task<Message> {
|
||||
@@ -1847,6 +1898,7 @@ impl App {
|
||||
message,
|
||||
Message::Page(_) | Message::Layer(_) | Message::Diagram(_)
|
||||
) {
|
||||
self.diagram_selection = None;
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
self.selected_bend = None;
|
||||
}
|
||||
@@ -2563,8 +2615,17 @@ impl App {
|
||||
"Apply or revert pending edits before using modeling actions.".into();
|
||||
return Task::none();
|
||||
}
|
||||
if matches!(*action, Message::DiagramSelection(_)) {
|
||||
return self.update(*action);
|
||||
}
|
||||
self.select(menu.request.object);
|
||||
self.selected_node = menu.request.placement;
|
||||
if self.page == Page::Behavior && menu.request.canvas.is_some() {
|
||||
self.behavior.diagram.placement = menu.request.placement;
|
||||
self.selected_node = None;
|
||||
} else {
|
||||
self.selected_node = menu.request.placement;
|
||||
}
|
||||
self.reconcile_multiselect();
|
||||
self.selected_bend = menu.request.bend.map(|i| (menu.request.object, i));
|
||||
return self.update(*action);
|
||||
}
|
||||
@@ -2722,6 +2783,7 @@ impl App {
|
||||
self.status = "Property draft reverted".into();
|
||||
}
|
||||
Message::Select(id) => self.select(id),
|
||||
Message::DiagramSelection(event) => self.multiselect_event(event),
|
||||
Message::EditProperties(id) => {
|
||||
self.select(id);
|
||||
self.inspector_tab = crate::navigation::InspectorTab::Properties;
|
||||
@@ -3395,7 +3457,21 @@ impl App {
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::DiagramRemove(intent) => self.remove_from_diagram(intent),
|
||||
Message::DiagramRemove(intent) => {
|
||||
if self
|
||||
.multiselect_snapshot()
|
||||
.is_some_and(|s| s.targets().len() > 1)
|
||||
{
|
||||
// A pre-redraw singleton Delete cannot remove just one
|
||||
// member of a selection changed earlier in the same batch.
|
||||
if Some(intent.context) == self.route_context() {
|
||||
self.status =
|
||||
"Selection changed. Use Hide selected or press Delete again.".into();
|
||||
}
|
||||
} else {
|
||||
self.remove_from_diagram(intent);
|
||||
}
|
||||
}
|
||||
Message::DiagramTraverse(back) => {
|
||||
return if back {
|
||||
iced::widget::operation::focus_previous()
|
||||
@@ -3404,6 +3480,13 @@ impl App {
|
||||
};
|
||||
}
|
||||
Message::Delete => {
|
||||
if self
|
||||
.multiselect_snapshot()
|
||||
.is_some_and(|s| s.targets().len() > 1)
|
||||
{
|
||||
self.status = "Group model-deletion review is not available yet. Select one element or use Hide selected.".into();
|
||||
return Task::none();
|
||||
}
|
||||
if self.selected != self.project().id && self.apply() {
|
||||
self.begin_model_delete(None);
|
||||
}
|
||||
@@ -3556,6 +3639,13 @@ impl App {
|
||||
}
|
||||
}
|
||||
Message::HideSelected => {
|
||||
if let Some(snapshot) = self
|
||||
.multiselect_snapshot()
|
||||
.filter(|s| s.targets().len() > 1)
|
||||
{
|
||||
self.multiselect_event(multiselect::Event::Hide(snapshot));
|
||||
return Task::none();
|
||||
}
|
||||
if self.apply()
|
||||
&& let Some(node) = self.selected_node
|
||||
{
|
||||
@@ -3585,6 +3675,13 @@ impl App {
|
||||
}
|
||||
Message::Route(event) => self.edit_route(event),
|
||||
Message::MoveNode(node, position) => {
|
||||
if self
|
||||
.multiselect_snapshot()
|
||||
.is_some_and(|s| s.targets().len() > 1)
|
||||
{
|
||||
self.status = "Selection changed. Start the group drag again.".into();
|
||||
return Task::none();
|
||||
}
|
||||
let original = self.selected;
|
||||
if self.apply()
|
||||
&& let Some(diagram) = self.active_diagram
|
||||
|
||||
@@ -59,6 +59,7 @@ fn fixture(layer: Layer) -> Fixture {
|
||||
}
|
||||
fn canvas(app: &App) -> DiagramCanvas<'_> {
|
||||
DiagramCanvas {
|
||||
selection: None,
|
||||
allocation: Some(app.allocation_capability()),
|
||||
project: app.project(),
|
||||
view_index: Some(app.document.view_index()),
|
||||
|
||||
@@ -66,6 +66,7 @@ fn architecture_f2_routes_selected_node_to_inline_rename_and_exact_undo() {
|
||||
let camera = (app.zoom, app.pan);
|
||||
let bounds = iced::Rectangle::new(iced::Point::ORIGIN, iced::Size::new(600., 380.));
|
||||
let canvas = crate::diagram::DiagramCanvas {
|
||||
selection: None,
|
||||
project: app.project(),
|
||||
view_index: None,
|
||||
diagram: app.active_diagram.unwrap(),
|
||||
|
||||
@@ -221,6 +221,7 @@ fn architecture_canvas_left_click_uses_world_coordinate_once_at_zoom_and_pan() {
|
||||
y: (local.y - app.pan.y) / app.zoom,
|
||||
};
|
||||
let canvas = crate::diagram::DiagramCanvas {
|
||||
selection: None,
|
||||
project: app.project(),
|
||||
view_index: None,
|
||||
diagram: app.active_diagram.unwrap(),
|
||||
@@ -298,6 +299,7 @@ fn architecture_f2_rename_preserves_identity_placement_and_atomic_history() {
|
||||
let diagrams = app.project().diagrams.clone();
|
||||
let camera = (app.zoom, app.pan);
|
||||
let canvas = crate::diagram::DiagramCanvas {
|
||||
selection: None,
|
||||
project: app.project(),
|
||||
view_index: None,
|
||||
diagram: app.active_diagram.unwrap(),
|
||||
|
||||
@@ -154,3 +154,440 @@ async fn mounted_f2_requires_canvas_focus_and_yields_to_clean_inspector() {
|
||||
}
|
||||
}
|
||||
}
|
||||
async fn modifier_focus_fixture() -> (App, Mounted, Id, Id, Id) {
|
||||
let mut project = Project::empty();
|
||||
let a = project
|
||||
.add_element(Kind::Entity, Layer::Oa, "First team", None)
|
||||
.unwrap();
|
||||
let b = project
|
||||
.add_element(Kind::Entity, Layer::Oa, "Second team", None)
|
||||
.unwrap();
|
||||
let diagram = project.ensure_diagram(Layer::Oa);
|
||||
let first = project.place(diagram, a, Position { x: 80., y: 80. });
|
||||
let second = project.place(diagram, b, Position { x: 500., y: 80. });
|
||||
let mut app = App::with_project(project);
|
||||
app.page = Page::Diagram;
|
||||
app.layer = Layer::Oa;
|
||||
app.active_diagram = Some(diagram);
|
||||
app.window_size = Size::new(2000., 1400.);
|
||||
let _ = app.update(Message::Select(a));
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
app.zoom = 1.;
|
||||
app.pan = Vector::ZERO;
|
||||
app.fit_to_view = false;
|
||||
mounted.settle(&mut app).await;
|
||||
(app, mounted, diagram, first, second)
|
||||
}
|
||||
|
||||
fn modifier_node_point(mounted: &mut Mounted, app: &App, diagram: Id, placement: Id) -> Point {
|
||||
let bounds = mounted
|
||||
.geometry(app)
|
||||
.bounds(crate::allocation_input::CANVAS_ID);
|
||||
let node = projection::project_view(app.project(), diagram)
|
||||
.nodes
|
||||
.into_iter()
|
||||
.find(|n| n.id == placement)
|
||||
.unwrap();
|
||||
Point::new(
|
||||
bounds.x + node.position.x + 40.,
|
||||
bounds.y + node.position.y + 35.,
|
||||
)
|
||||
}
|
||||
|
||||
fn modifier_canvas_focused(mounted: &mut Mounted, app: &App) -> bool {
|
||||
mounted
|
||||
.geometry(app)
|
||||
.focused
|
||||
.iter()
|
||||
.find(|(id, _, _)| {
|
||||
id.as_ref() == Some(&iced::advanced::widget::Id::from("diagram-keyboard-owner"))
|
||||
})
|
||||
.expect("production focus::Owner is mounted")
|
||||
.2
|
||||
}
|
||||
|
||||
fn modifier_event(shift: bool) -> iced::Event {
|
||||
iced::Event::Keyboard(iced::keyboard::Event::ModifiersChanged(if shift {
|
||||
iced::keyboard::Modifiers::SHIFT
|
||||
} else {
|
||||
iced::keyboard::Modifiers::default()
|
||||
}))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_shift_before_canvas_focus_toggles_exact_node_without_granting_keyboard_focus() {
|
||||
use crate::diagram_usage::Target;
|
||||
let (mut app, mut mounted, diagram, first, second) = modifier_focus_fixture().await;
|
||||
let before = app.document.snapshot();
|
||||
let field = mounted
|
||||
.geometry(&app)
|
||||
.text_inputs
|
||||
.into_iter()
|
||||
.filter(|bounds| bounds.x > app.window_size.width - 320.)
|
||||
.min_by(|a, b| a.y.total_cmp(&b.y))
|
||||
.expect("clean Inspector name input");
|
||||
mounted.operate(&app, &mut FocusField(field));
|
||||
assert!(!modifier_canvas_focused(&mut mounted, &app));
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::Placement(first)]
|
||||
);
|
||||
mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[modifier_event(true)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
!modifier_canvas_focused(&mut mounted, &app),
|
||||
"modifier tracking must not acquire focus"
|
||||
);
|
||||
let at = modifier_node_point(&mut mounted, &app, diagram, second);
|
||||
let messages = mounted.click(&mut app, at).await;
|
||||
assert!(messages.iter().any(|m| matches!(m,
|
||||
Message::DiagramSelection(multiselect::Event::Choose(_, Target::Placement(id), multiselect::Choice::Toggle)) if *id == second
|
||||
)), "Shift already held before the first canvas press must reach its exact-target adapter");
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::Placement(first), Target::Placement(second)]
|
||||
);
|
||||
assert_eq!(app.selected_node, Some(second));
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
assert!(!app.draft_dirty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_unfocused_typing_and_delete_remain_denied_after_passive_modifiers() {
|
||||
use crate::diagram_usage::Target;
|
||||
let (mut app, mut mounted, diagram, first, second) = modifier_focus_fixture().await;
|
||||
mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[modifier_event(true)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
let at = modifier_node_point(&mut mounted, &app, diagram, second);
|
||||
mounted.click(&mut app, at).await;
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::Placement(first), Target::Placement(second)]
|
||||
);
|
||||
// Revoke every field's focus through the real widget operation. No widget
|
||||
// has this off-window rectangle, so neither Inspector nor canvas owns keys.
|
||||
mounted.operate(
|
||||
&app,
|
||||
&mut FocusField(Rectangle {
|
||||
x: -1000.,
|
||||
y: -1000.,
|
||||
width: 1.,
|
||||
height: 1.,
|
||||
}),
|
||||
);
|
||||
assert!(!modifier_canvas_focused(&mut mounted, &app));
|
||||
let before = app.document.snapshot();
|
||||
let selection = app.multiselect_snapshot().unwrap();
|
||||
let messages = mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[
|
||||
modifier_event(false),
|
||||
crate::behavior::canvas_workspace::tests::character("x"),
|
||||
key(Named::Delete),
|
||||
key(Named::F2),
|
||||
],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
assert!(!messages.iter().any(|m| matches!(
|
||||
m,
|
||||
Message::DiagramSelection(_)
|
||||
| Message::DiagramRemove(_)
|
||||
| Message::HideSelected
|
||||
| Message::Delete
|
||||
)));
|
||||
assert!(!rename_emitted(&messages));
|
||||
assert!(!modifier_canvas_focused(&mut mounted, &app));
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
selection.targets()
|
||||
);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
assert!(!app.draft_dirty());
|
||||
assert!(!app.delete_pending);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_suspended_modifiers_are_passive_and_survive_until_resumed_click() {
|
||||
use crate::diagram_usage::Target;
|
||||
let (mut app, mut mounted, diagram, first, second) = modifier_focus_fixture().await;
|
||||
let before = app.document.snapshot();
|
||||
let at = modifier_node_point(&mut mounted, &app, diagram, second);
|
||||
app.busy = true;
|
||||
mounted.settle(&mut app).await;
|
||||
assert!(!modifier_canvas_focused(&mut mounted, &app));
|
||||
mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[modifier_event(true), key(Named::Delete)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
mounted.click(&mut app, at).await;
|
||||
assert!(
|
||||
!modifier_canvas_focused(&mut mounted, &app),
|
||||
"passive I/O cannot grant canvas focus"
|
||||
);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::Placement(first)]
|
||||
);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
app.busy = false;
|
||||
mounted.settle(&mut app).await;
|
||||
assert!(!modifier_canvas_focused(&mut mounted, &app));
|
||||
mounted.click(&mut app, at).await;
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::Placement(first), Target::Placement(second)]
|
||||
);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_shift_held_before_suspension_survives_without_another_modifier_event() {
|
||||
use crate::diagram_usage::Target;
|
||||
let (mut app, mut mounted, diagram, first, second) = modifier_focus_fixture().await;
|
||||
let before = app.document.snapshot();
|
||||
let field = mounted
|
||||
.geometry(&app)
|
||||
.text_inputs
|
||||
.into_iter()
|
||||
.filter(|bounds| bounds.x > app.window_size.width - 320.)
|
||||
.min_by(|a, b| a.y.total_cmp(&b.y))
|
||||
.expect("clean Inspector name input");
|
||||
mounted.operate(&app, &mut FocusField(field));
|
||||
mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[modifier_event(true)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
assert!(!modifier_canvas_focused(&mut mounted, &app));
|
||||
app.busy = true;
|
||||
mounted.settle(&mut app).await;
|
||||
assert!(!modifier_canvas_focused(&mut mounted, &app));
|
||||
app.busy = false;
|
||||
mounted.settle(&mut app).await;
|
||||
assert!(
|
||||
!modifier_canvas_focused(&mut mounted, &app),
|
||||
"restoring passive modifiers must not grant keyboard focus"
|
||||
);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::Placement(first)]
|
||||
);
|
||||
// No modifier transition follows the subtree replacement during I/O.
|
||||
let at = modifier_node_point(&mut mounted, &app, diagram, second);
|
||||
let messages = mounted.click(&mut app, at).await;
|
||||
assert!(messages.iter().any(|m| matches!(m,
|
||||
Message::DiagramSelection(multiselect::Event::Choose(_, Target::Placement(id), multiselect::Choice::Toggle)) if *id == second
|
||||
)));
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::Placement(first), Target::Placement(second)]
|
||||
);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
assert!(!app.draft_dirty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_shift_release_or_window_unfocus_during_suspension_does_not_restore_stale_shift() {
|
||||
use crate::diagram_usage::Target;
|
||||
for release in [
|
||||
modifier_event(false),
|
||||
iced::Event::Window(iced::window::Event::Unfocused),
|
||||
] {
|
||||
let (mut app, mut mounted, diagram, first, second) = modifier_focus_fixture().await;
|
||||
let before = app.document.snapshot();
|
||||
mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[modifier_event(true)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
app.busy = true;
|
||||
mounted.settle(&mut app).await;
|
||||
mounted
|
||||
.batch(&mut app, &[release], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
assert!(!modifier_canvas_focused(&mut mounted, &app));
|
||||
app.busy = false;
|
||||
mounted.settle(&mut app).await;
|
||||
// A further I/O cycle must restore the updated owner state too.
|
||||
app.busy = true;
|
||||
mounted.settle(&mut app).await;
|
||||
app.busy = false;
|
||||
mounted.settle(&mut app).await;
|
||||
assert!(!modifier_canvas_focused(&mut mounted, &app));
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::Placement(first)]
|
||||
);
|
||||
let at = modifier_node_point(&mut mounted, &app, diagram, second);
|
||||
let messages = mounted.click(&mut app, at).await;
|
||||
assert!(!messages.iter().any(|m| matches!(
|
||||
m,
|
||||
Message::DiagramSelection(multiselect::Event::Choose(
|
||||
_,
|
||||
_,
|
||||
multiselect::Choice::Toggle
|
||||
))
|
||||
)));
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::Placement(second)]
|
||||
);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
assert!(!app.draft_dirty());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_window_unfocus_resets_held_shift_before_next_canvas_press() {
|
||||
use crate::diagram_usage::Target;
|
||||
let (mut app, mut mounted, diagram, first, second) = modifier_focus_fixture().await;
|
||||
let at_first = modifier_node_point(&mut mounted, &app, diagram, first);
|
||||
mounted.click(&mut app, at_first).await;
|
||||
let before = app.document.snapshot();
|
||||
mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[
|
||||
modifier_event(true),
|
||||
iced::Event::Window(iced::window::Event::Unfocused),
|
||||
],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
assert!(!modifier_canvas_focused(&mut mounted, &app));
|
||||
// No ModifiersChanged(empty) follows: a lost window must itself reset it.
|
||||
let at_second = modifier_node_point(&mut mounted, &app, diagram, second);
|
||||
mounted.click(&mut app, at_second).await;
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::Placement(second)]
|
||||
);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_selection_strip_keeps_canvas_and_fitted_camera_fixed_for_empty_single_group_and_mixed()
|
||||
{
|
||||
use crate::diagram_usage::Target;
|
||||
let mut project = Project::empty();
|
||||
let a = project
|
||||
.add_element(Kind::Activity, Layer::Oa, "First activity", None)
|
||||
.unwrap();
|
||||
let b = project
|
||||
.add_element(
|
||||
Kind::Activity,
|
||||
Layer::Oa,
|
||||
&format!(
|
||||
"Second activity {}",
|
||||
"with a very long primary name ".repeat(20)
|
||||
),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let edge = project.relate(RelationKind::Interaction, "Activity exchange", a, b);
|
||||
let diagram = project.ensure_diagram(Layer::Oa);
|
||||
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Overview;
|
||||
let first = project.place(diagram, a, Position { x: 80., y: 80. });
|
||||
let second = project.place(diagram, b, Position { x: 500., y: 80. });
|
||||
let mut app = App::with_project(project);
|
||||
app.page = Page::Diagram;
|
||||
app.layer = Layer::Oa;
|
||||
app.active_diagram = Some(diagram);
|
||||
app.window_size = Size::new(1442., 932.);
|
||||
let _ = app.update(Message::Select(diagram));
|
||||
app.fit_to_view = true;
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
let baseline = app.document.snapshot();
|
||||
let bounds = mounted
|
||||
.geometry(&app)
|
||||
.bounds(crate::allocation_input::CANVAS_ID);
|
||||
let camera = (app.zoom, app.pan, app.fit_to_view, app.canvas_size);
|
||||
assert!(camera.2, "exercise the native resize-triggered refit path");
|
||||
assert!(app.multiselect_snapshot().unwrap().targets().is_empty());
|
||||
assert!(bounds.width > 500. && bounds.height > 300.);
|
||||
for (target, expected) in [
|
||||
(
|
||||
Some(Target::Placement(first)),
|
||||
vec![Target::Placement(first)],
|
||||
),
|
||||
(
|
||||
Some(Target::Placement(second)),
|
||||
vec![Target::Placement(first), Target::Placement(second)],
|
||||
),
|
||||
(
|
||||
Some(Target::Edge(edge)),
|
||||
vec![
|
||||
Target::Placement(first),
|
||||
Target::Placement(second),
|
||||
Target::Edge(edge),
|
||||
],
|
||||
),
|
||||
(
|
||||
Some(Target::Edge(edge)),
|
||||
vec![Target::Placement(first), Target::Placement(second)],
|
||||
),
|
||||
(
|
||||
Some(Target::Placement(second)),
|
||||
vec![Target::Placement(first)],
|
||||
),
|
||||
(None, vec![]),
|
||||
] {
|
||||
let context = app.multiselect_context().unwrap();
|
||||
let event = match target {
|
||||
Some(target) => {
|
||||
multiselect::Event::Choose(context, target, multiselect::Choice::Toggle)
|
||||
}
|
||||
None => multiselect::Event::Clear(context),
|
||||
};
|
||||
let task = app.update(Message::DiagramSelection(event));
|
||||
mounted.perform(&mut app, task).await;
|
||||
mounted.settle(&mut app).await;
|
||||
assert_eq!(app.multiselect_snapshot().unwrap().targets(), expected);
|
||||
assert_eq!(
|
||||
mounted
|
||||
.geometry(&app)
|
||||
.bounds(crate::allocation_input::CANVAS_ID),
|
||||
bounds,
|
||||
"selection feedback must not change the measured canvas rectangle"
|
||||
);
|
||||
assert_eq!(
|
||||
(app.zoom, app.pan, app.fit_to_view, app.canvas_size),
|
||||
camera
|
||||
);
|
||||
assert_eq!(app.project(), &baseline.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &baseline.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
assert!(!app.draft_dirty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
//! Exact saved-view selection and atomic group commands.
|
||||
use super::*;
|
||||
use crate::diagram_selection::{Context, Scope, Selection, Snapshot};
|
||||
use crate::diagram_usage::{Surface, Target};
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) enum Choice {
|
||||
Replace,
|
||||
Add,
|
||||
Remove,
|
||||
Toggle,
|
||||
Inspect,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) enum Event {
|
||||
Choose(Context, Target, Choice),
|
||||
Collapse(Snapshot, Target),
|
||||
Clear(Context),
|
||||
Move(Snapshot, Position),
|
||||
Hide(Snapshot),
|
||||
Refuse(Snapshot, String),
|
||||
}
|
||||
|
||||
pub(crate) fn context(c: crate::route_edit::Context) -> Context {
|
||||
Context {
|
||||
scope: Scope {
|
||||
document: c.document,
|
||||
surface: Surface::Saved(c.diagram),
|
||||
},
|
||||
revision: c.revision,
|
||||
epoch: c.epoch,
|
||||
}
|
||||
}
|
||||
|
||||
/// The entire typed set must support a placement action. Never filter a mixture.
|
||||
pub(crate) fn placements(snapshot: &Snapshot) -> Option<std::collections::BTreeSet<Id>> {
|
||||
snapshot.primary()?;
|
||||
if snapshot.targets().is_empty() {
|
||||
return None;
|
||||
}
|
||||
snapshot
|
||||
.targets()
|
||||
.iter()
|
||||
.map(|target| match target {
|
||||
Target::Placement(id) => Some(*id),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn visible(
|
||||
project: &Project,
|
||||
diagram: Id,
|
||||
view: &projection::ProjectedView,
|
||||
target: &Target,
|
||||
) -> bool {
|
||||
match target {
|
||||
Target::Placement(id) => view.nodes.iter().any(|n| n.id == *id),
|
||||
Target::Port { port, placement } => view
|
||||
.ports
|
||||
.iter()
|
||||
.any(|p| p.element == *port && p.owner == *placement),
|
||||
Target::Edge(id) => view.edges.iter().any(|e| e.id == *id),
|
||||
Target::StateRow {
|
||||
placement,
|
||||
relation,
|
||||
} => crate::behavior::state_canvas::visible_row_target(
|
||||
project, diagram, view, *placement, *relation,
|
||||
),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub(crate) fn multiselect_boundary(&self) -> impl PartialEq + use<> {
|
||||
(
|
||||
self.canvas_document,
|
||||
self.document.revision(),
|
||||
self.page,
|
||||
self.active_diagram,
|
||||
self.selected,
|
||||
self.selected_node,
|
||||
(self.page == Page::Behavior).then(|| self.state_view_identity()),
|
||||
(self.page == Page::Behavior).then_some(self.behavior.diagram.placement),
|
||||
)
|
||||
}
|
||||
pub(crate) fn multiselect_context(&self) -> Option<Context> {
|
||||
match self.page {
|
||||
Page::Diagram => self.route_context().map(context),
|
||||
Page::Behavior => {
|
||||
let current = self.state_context()?;
|
||||
crate::behavior::state_edit::validate_context(self.project(), current, current)
|
||||
.ok()?;
|
||||
Some(context(current))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn multiselect_target_visible(&self, target: &Target) -> bool {
|
||||
let Some(Context {
|
||||
scope:
|
||||
Scope {
|
||||
surface: Surface::Saved(diagram),
|
||||
..
|
||||
},
|
||||
..
|
||||
}) = self.multiselect_context()
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let view = projection::project_view_with_index(
|
||||
self.project(),
|
||||
diagram,
|
||||
self.document.view_index(),
|
||||
);
|
||||
visible(self.project(), diagram, &view, target)
|
||||
}
|
||||
|
||||
pub(crate) fn multiselect_snapshot(&self) -> Option<Snapshot> {
|
||||
self.diagram_selection
|
||||
.as_ref()?
|
||||
.snapshot(self.multiselect_context()?)
|
||||
}
|
||||
|
||||
pub(crate) fn multiselect_envelope(&self, event: &Event) -> bool {
|
||||
let Some(current) = self.multiselect_context() else {
|
||||
return false;
|
||||
};
|
||||
match event {
|
||||
Event::Choose(c, ..) | Event::Clear(c) => *c == current,
|
||||
Event::Move(s, _) | Event::Hide(s) | Event::Refuse(s, _) | Event::Collapse(s, _) => {
|
||||
self.diagram_selection
|
||||
.as_ref()
|
||||
.is_some_and(|selection| selection.matches(s, current))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn legacy_target(&self, diagram: Id, view: &projection::ProjectedView) -> Option<Target> {
|
||||
let placement = if self.page == Page::Behavior {
|
||||
self.behavior.diagram.placement
|
||||
} else {
|
||||
self.selected_node
|
||||
};
|
||||
if let Some(id) = placement
|
||||
&& view.nodes.iter().any(|node| {
|
||||
node.id == id && (node.element == self.selected || node.id == self.selected)
|
||||
})
|
||||
{
|
||||
return Some(Target::Placement(id));
|
||||
}
|
||||
if self.page == Page::Behavior {
|
||||
// Keep a row's exact occurrence while inspecting its relation.
|
||||
// Its owner placement is never a node-inspection or Hide target.
|
||||
if let Some(target @ Target::StateRow { relation, .. }) =
|
||||
self.diagram_selection.as_ref().and_then(|s| s.primary())
|
||||
&& *relation == self.selected
|
||||
&& visible(self.project(), diagram, view, target)
|
||||
{
|
||||
return Some(target.clone());
|
||||
}
|
||||
if let Some(node) = view.nodes.iter().find(|node| {
|
||||
crate::behavior::state_canvas::visible_row_target(
|
||||
self.project(),
|
||||
diagram,
|
||||
view,
|
||||
node.id,
|
||||
self.selected,
|
||||
)
|
||||
}) {
|
||||
return Some(Target::StateRow {
|
||||
placement: node.id,
|
||||
relation: self.selected,
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(port) = view.ports.iter().find(|port| port.element == self.selected) {
|
||||
return Some(Target::Port {
|
||||
port: port.element,
|
||||
placement: port.owner,
|
||||
});
|
||||
}
|
||||
view.edges
|
||||
.iter()
|
||||
.any(|edge| edge.id == self.selected)
|
||||
.then_some(Target::Edge(self.selected))
|
||||
}
|
||||
|
||||
pub(crate) fn reconcile_multiselect(&mut self) {
|
||||
let Some(current) = self.multiselect_context() else {
|
||||
self.diagram_selection = None;
|
||||
return;
|
||||
};
|
||||
let Surface::Saved(diagram) = current.scope.surface else {
|
||||
return;
|
||||
};
|
||||
let view = projection::project_view_with_index(
|
||||
self.project(),
|
||||
diagram,
|
||||
self.document.view_index(),
|
||||
);
|
||||
let legacy = self.legacy_target(diagram, &view);
|
||||
let lost_primary = self
|
||||
.diagram_selection
|
||||
.as_ref()
|
||||
.filter(|s| s.scope() == current.scope)
|
||||
.and_then(|s| s.primary())
|
||||
.is_some_and(|target| !visible(self.project(), diagram, &view, target));
|
||||
let primary_placement = if self.page == Page::Behavior {
|
||||
self.behavior.diagram.placement
|
||||
} else {
|
||||
self.selected_node
|
||||
};
|
||||
let held_primary = self
|
||||
.diagram_selection
|
||||
.as_ref()
|
||||
.filter(|s| s.scope() == current.scope)
|
||||
.and_then(|s| s.primary())
|
||||
.is_some_and(|target| match target {
|
||||
Target::Placement(id) => primary_placement == Some(*id),
|
||||
Target::Port { port, .. } => self.selected == *port,
|
||||
Target::Edge(id) => self.selected == *id,
|
||||
Target::StateRow { relation, .. } => {
|
||||
self.selected == *relation && primary_placement.is_none()
|
||||
}
|
||||
_ => false,
|
||||
});
|
||||
let project = self.document.project();
|
||||
let selection = self
|
||||
.diagram_selection
|
||||
.get_or_insert_with(|| Selection::new(current.scope));
|
||||
selection.reconcile(current.scope, |target| {
|
||||
visible(project, diagram, &view, target)
|
||||
});
|
||||
// Navigation/property selection remains explicit. A removed exact target
|
||||
// is never rebound through its semantic identity to a different placement.
|
||||
if held_primary || lost_primary {
|
||||
if selection.primary() != legacy.as_ref() {
|
||||
self.inspect_multiselect_primary();
|
||||
}
|
||||
} else if selection.primary() != legacy.as_ref() {
|
||||
if let Some(target) = legacy {
|
||||
selection.replace(target, |target| visible(project, diagram, &view, target));
|
||||
} else {
|
||||
selection.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn inspect_multiselect_primary(&mut self) {
|
||||
let diagram = self
|
||||
.multiselect_context()
|
||||
.and_then(|c| match c.scope.surface {
|
||||
Surface::Saved(diagram) => Some(diagram),
|
||||
_ => None,
|
||||
});
|
||||
let primary = self
|
||||
.diagram_selection
|
||||
.as_ref()
|
||||
.and_then(|s| s.primary())
|
||||
.cloned();
|
||||
self.selected_node = None;
|
||||
if self.page == Page::Behavior {
|
||||
self.behavior.diagram.placement = None;
|
||||
}
|
||||
match primary {
|
||||
Some(Target::Placement(id)) => {
|
||||
if let Some(node) = diagram
|
||||
.and_then(|d| self.project().diagrams.get(&d))
|
||||
.and_then(|d| d.nodes.get(&id))
|
||||
{
|
||||
self.selected = node.element;
|
||||
if self.page == Page::Behavior {
|
||||
self.behavior.diagram.placement = Some(id);
|
||||
} else {
|
||||
self.selected_node = Some(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Target::Port { port, .. }) => self.selected = port,
|
||||
Some(Target::Edge(id)) => self.selected = id,
|
||||
Some(Target::StateRow { relation, .. }) => self.selected = relation,
|
||||
_ => self.selected = diagram.unwrap_or(self.project().id),
|
||||
}
|
||||
self.selected_bend = None;
|
||||
self.selected_attachment = None;
|
||||
self.refresh_draft();
|
||||
}
|
||||
|
||||
pub(crate) fn multiselect_event(&mut self, event: Event) {
|
||||
if !self.multiselect_envelope(&event) {
|
||||
return;
|
||||
}
|
||||
if !diagram_removal::enabled(self)
|
||||
|| self.architecture.is_some()
|
||||
|| self.connection.is_some()
|
||||
|| (self.page == Page::Behavior
|
||||
&& self.behavior.diagram.tool != crate::behavior::state_edit::Tool::Select)
|
||||
{
|
||||
self.status =
|
||||
"Finish or cancel the active edit before changing the diagram selection.".into();
|
||||
return;
|
||||
}
|
||||
let current = self.multiselect_context().unwrap();
|
||||
let Surface::Saved(diagram) = current.scope.surface else {
|
||||
return;
|
||||
};
|
||||
let view = projection::project_view_with_index(
|
||||
self.project(),
|
||||
diagram,
|
||||
self.document.view_index(),
|
||||
);
|
||||
match event {
|
||||
Event::Collapse(_, target) => {
|
||||
if !visible(self.project(), diagram, &view, &target) {
|
||||
return;
|
||||
}
|
||||
let project = self.document.project();
|
||||
self.diagram_selection
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.replace(target, |target| visible(project, diagram, &view, target));
|
||||
self.inspect_multiselect_primary();
|
||||
}
|
||||
Event::Choose(_, target, choice) => {
|
||||
if !visible(self.project(), diagram, &view, &target) {
|
||||
return;
|
||||
}
|
||||
self.reconcile_multiselect();
|
||||
let project = self.document.project();
|
||||
let selection = self.diagram_selection.as_mut().unwrap();
|
||||
let valid = |target: &Target| visible(project, diagram, &view, target);
|
||||
match choice {
|
||||
Choice::Replace => {
|
||||
selection.replace(target, valid);
|
||||
}
|
||||
Choice::Add => {
|
||||
selection.add(target, valid);
|
||||
}
|
||||
Choice::Remove => {
|
||||
selection.remove(&target);
|
||||
}
|
||||
Choice::Toggle => {
|
||||
selection.toggle(target, valid);
|
||||
}
|
||||
Choice::Inspect => {
|
||||
selection.set_primary(&target);
|
||||
}
|
||||
}
|
||||
self.inspect_multiselect_primary();
|
||||
}
|
||||
Event::Clear(_) => {
|
||||
if let Some(selection) = &mut self.diagram_selection {
|
||||
selection.clear();
|
||||
}
|
||||
self.inspect_multiselect_primary();
|
||||
}
|
||||
Event::Refuse(_, reason) => self.status = reason,
|
||||
Event::Move(snapshot, delta) => {
|
||||
let Some(ids) = placements(&snapshot) else {
|
||||
self.status = "Move requires only placed elements. Remove ports, rows and connections from the selection.".into();
|
||||
return;
|
||||
};
|
||||
if !snapshot
|
||||
.targets()
|
||||
.iter()
|
||||
.all(|target| visible(self.project(), diagram, &view, target))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if self.project().diagrams[&diagram].projection == ProjectionKind::Allocation
|
||||
&& ids.iter().any(|id| {
|
||||
self.project().elements
|
||||
[&self.project().diagrams[&diagram].nodes[id].element]
|
||||
.kind()
|
||||
.is_work()
|
||||
})
|
||||
{
|
||||
self.status =
|
||||
"Group allocation drag is not available yet. The selection was retained."
|
||||
.into();
|
||||
return;
|
||||
}
|
||||
if delta.x == 0. && delta.y == 0. {
|
||||
return;
|
||||
}
|
||||
match oa_core::diagram_edit::translate_placements(
|
||||
self.project(),
|
||||
diagram,
|
||||
&ids,
|
||||
delta,
|
||||
) {
|
||||
Ok(command) => {
|
||||
if self.execute(command) {
|
||||
self.inspect_multiselect_primary();
|
||||
self.status = format!(
|
||||
"Moved {} selected elements · Undo restores the group",
|
||||
ids.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(error) => self.status = error.to_string(),
|
||||
}
|
||||
}
|
||||
Event::Hide(snapshot) => {
|
||||
let Some(ids) = placements(&snapshot) else {
|
||||
self.status =
|
||||
"Hide requires only placed elements. Model objects remain unchanged."
|
||||
.into();
|
||||
return;
|
||||
};
|
||||
if !snapshot
|
||||
.targets()
|
||||
.iter()
|
||||
.all(|target| visible(self.project(), diagram, &view, target))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if self.execute(Command::Batch(
|
||||
ids.iter().copied().map(Command::Delete).collect(),
|
||||
)) {
|
||||
if let Some(selection) = &mut self.diagram_selection {
|
||||
selection.clear();
|
||||
}
|
||||
self.inspect_multiselect_primary();
|
||||
self.status = format!("Hid {} placements · Model objects retained", ids.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,602 @@
|
||||
//! Saved-view group operations through the real App message boundary.
|
||||
use super::multiselect::{Choice as GroupChoice, Event as GroupEvent};
|
||||
use super::*;
|
||||
use crate::diagram_usage::Target;
|
||||
|
||||
fn point(x: f32, y: f32) -> Position {
|
||||
Position { x, y }
|
||||
}
|
||||
|
||||
struct Fixture {
|
||||
app: App,
|
||||
diagram: Id,
|
||||
a: Id,
|
||||
b: Id,
|
||||
child: Id,
|
||||
edge: Id,
|
||||
}
|
||||
|
||||
fn fixture() -> Fixture {
|
||||
let mut p = Project::empty();
|
||||
let a = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Send", None)
|
||||
.unwrap();
|
||||
let b = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Receive", None)
|
||||
.unwrap();
|
||||
let child = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Prepare", Some(a))
|
||||
.unwrap();
|
||||
let edge = p.relate(RelationKind::FunctionalExchange, "Signal", a, b);
|
||||
let diagram = p.ensure_diagram(Layer::Sa);
|
||||
p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::FunctionalDataflow;
|
||||
let a = p.place(diagram, a, point(10., 20.));
|
||||
let b = p.place(diagram, b, point(500., 20.));
|
||||
let child = p.place(diagram, child, point(40., 100.));
|
||||
p.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.routes
|
||||
.push(EdgeRoute {
|
||||
edge,
|
||||
source_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Bottom,
|
||||
offset: 0.3,
|
||||
}),
|
||||
target_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Top,
|
||||
offset: 0.7,
|
||||
}),
|
||||
waypoints: vec![point(370., 150.)],
|
||||
label: Some(EdgeLabel {
|
||||
fraction: 0.4,
|
||||
offset: point(5., -9.),
|
||||
}),
|
||||
});
|
||||
let other = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Operational work", None)
|
||||
.unwrap();
|
||||
let other_diagram = p.ensure_diagram(Layer::Oa);
|
||||
p.place(other_diagram, other, point(-50., -100.));
|
||||
let mut app = App::with_project(p);
|
||||
app.page = Page::Diagram;
|
||||
app.layer = Layer::Sa;
|
||||
app.active_diagram = Some(diagram);
|
||||
app.selected = diagram;
|
||||
app.selected_node = None;
|
||||
app.refresh_draft();
|
||||
app.reconcile_multiselect();
|
||||
Fixture {
|
||||
app,
|
||||
diagram,
|
||||
a,
|
||||
b,
|
||||
child,
|
||||
edge,
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch(app: &mut App, event: GroupEvent) {
|
||||
let _ = app.update(Message::DiagramSelection(event));
|
||||
}
|
||||
fn choose(app: &mut App, target: Target, choice: GroupChoice) {
|
||||
let context = app.multiselect_context().unwrap();
|
||||
dispatch(app, GroupEvent::Choose(context, target, choice));
|
||||
}
|
||||
fn select_pair(f: &mut Fixture) {
|
||||
choose(&mut f.app, Target::Placement(f.a), GroupChoice::Replace);
|
||||
choose(&mut f.app, Target::Placement(f.b), GroupChoice::Toggle);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::Placement(f.a), Target::Placement(f.b)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queued_singleton_move_or_delete_cannot_partially_edit_a_new_group() {
|
||||
for model in [false, true] {
|
||||
let mut f = fixture();
|
||||
select_pair(&mut f);
|
||||
let before = f.app.document.snapshot();
|
||||
let selected = f.app.multiselect_snapshot().unwrap();
|
||||
let object = f.app.project().diagrams[&f.diagram].nodes[&f.b].element;
|
||||
for message in [
|
||||
Message::MoveNode(f.b, point(700., 600.)),
|
||||
Message::DiagramRemove(super::diagram_removal::Intent {
|
||||
context: f.app.route_context().unwrap(),
|
||||
object,
|
||||
placement: Some(f.b),
|
||||
model,
|
||||
}),
|
||||
Message::Delete,
|
||||
] {
|
||||
let _ = f.app.update(message);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
selected.targets()
|
||||
);
|
||||
assert!(!f.app.delete_pending);
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_hide_action_applies_to_the_complete_visible_group() {
|
||||
let mut f = fixture();
|
||||
select_pair(&mut f);
|
||||
let before = f.app.document.snapshot();
|
||||
let mut expected = f.app.project().clone();
|
||||
let view = expected.diagrams.get_mut(&f.diagram).unwrap();
|
||||
view.nodes.remove(&f.a);
|
||||
view.nodes.remove(&f.b);
|
||||
let _ = f.app.update(Message::HideSelected);
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert_source_matches_model(&f.app);
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
fn assert_source_matches_model(app: &App) {
|
||||
let compiled = oa_core::source_project::compile_bundle(app.document.bundle(), LIMITS).unwrap();
|
||||
assert_eq!(&compiled.decoded.project, app.project());
|
||||
assert_eq!(&compiled.bundle, app.document.bundle());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggled_group_moves_model_and_source_once_and_keeps_primary_through_history() {
|
||||
let mut f = fixture();
|
||||
select_pair(&mut f);
|
||||
let before = f.app.document.snapshot();
|
||||
let snapshot = f.app.multiselect_snapshot().unwrap();
|
||||
assert_eq!(snapshot.primary(), Some(&Target::Placement(f.b)));
|
||||
let mut expected = f.app.project().clone();
|
||||
let view = expected.diagrams.get_mut(&f.diagram).unwrap();
|
||||
view.nodes.get_mut(&f.a).unwrap().position = point(50., 80.);
|
||||
view.nodes.get_mut(&f.b).unwrap().position = point(540., 80.);
|
||||
view.nodes.get_mut(&f.child).unwrap().position = point(80., 160.);
|
||||
view.routes[0].waypoints = vec![point(410., 210.)];
|
||||
dispatch(
|
||||
&mut f.app,
|
||||
GroupEvent::Move(snapshot.clone(), point(40., 60.)),
|
||||
);
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert_source_matches_model(&f.app);
|
||||
assert_eq!(f.app.selected_node, Some(f.b));
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
snapshot.targets()
|
||||
);
|
||||
let after = f.app.document.snapshot();
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(!f.app.document.can_undo());
|
||||
let _ = f.app.update(Message::Redo);
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert_eq!(f.app.document.bundle(), &after.bundle);
|
||||
assert!(!f.app.document.can_redo());
|
||||
assert_eq!(f.app.selected_node, Some(f.b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ancestor_and_child_selection_moves_child_exactly_once() {
|
||||
let mut f = fixture();
|
||||
choose(&mut f.app, Target::Placement(f.a), GroupChoice::Replace);
|
||||
choose(&mut f.app, Target::Placement(f.child), GroupChoice::Toggle);
|
||||
let snapshot = f.app.multiselect_snapshot().unwrap();
|
||||
assert_eq!(snapshot.targets().len(), 2);
|
||||
let mut expected = f.app.project().clone();
|
||||
let view = expected.diagrams.get_mut(&f.diagram).unwrap();
|
||||
view.nodes.get_mut(&f.a).unwrap().position = point(0., 45.);
|
||||
view.nodes.get_mut(&f.child).unwrap().position = point(30., 125.);
|
||||
dispatch(&mut f.app, GroupEvent::Move(snapshot, point(-10., 25.)));
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert_eq!(f.app.selected_node, Some(f.child));
|
||||
assert_source_matches_model(&f.app);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_edge_or_port_selection_refuses_the_entire_move() {
|
||||
for include_port in [false, true] {
|
||||
let mut f = fixture();
|
||||
choose(&mut f.app, Target::Placement(f.a), GroupChoice::Replace);
|
||||
let extra = if include_port {
|
||||
let view = projection::project_view(f.app.project(), f.diagram);
|
||||
let port = view
|
||||
.ports
|
||||
.iter()
|
||||
.find(|p| p.owner == f.a)
|
||||
.expect("functional exchange exposes its typed owner port");
|
||||
Target::Port {
|
||||
port: port.element,
|
||||
placement: port.owner,
|
||||
}
|
||||
} else {
|
||||
Target::Edge(f.edge)
|
||||
};
|
||||
choose(&mut f.app, extra.clone(), GroupChoice::Toggle);
|
||||
let selection = f.app.multiselect_snapshot().unwrap();
|
||||
assert_eq!(selection.targets(), &[Target::Placement(f.a), extra]);
|
||||
let before = f.app.document.snapshot();
|
||||
let revision = f.app.document.revision();
|
||||
dispatch(
|
||||
&mut f.app,
|
||||
GroupEvent::Move(selection.clone(), point(40., 60.)),
|
||||
);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert_eq!(f.app.document.revision(), revision);
|
||||
assert!(!f.app.document.can_undo());
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
selection.targets()
|
||||
);
|
||||
assert!(f.app.status.contains("Move requires only placed elements"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_operation_envelopes_have_no_selection_history_status_or_allocation_side_effects() {
|
||||
// Isolate each lifetime component, including the same visible set recreated
|
||||
// by a remove/re-add cycle. Accepted setup changes are outside the assertion.
|
||||
for stale_by in ["epoch", "revision", "document", "selection generation"] {
|
||||
for action in ["move", "hide", "refuse", "collapse"] {
|
||||
let mut f = fixture();
|
||||
select_pair(&mut f);
|
||||
let stale = f.app.multiselect_snapshot().unwrap();
|
||||
match stale_by {
|
||||
"epoch" => f.app.canvas_epoch += 1,
|
||||
"revision" => {
|
||||
let semantic = f.app.project().diagrams[&f.diagram].nodes[&f.a].element;
|
||||
// Advance only the document revision, retaining this canvas
|
||||
// and selection epoch to independently test revision checking.
|
||||
f.app
|
||||
.document
|
||||
.execute(Command::Describe(semantic, "New revision".into()))
|
||||
.unwrap();
|
||||
}
|
||||
"document" => f.app.canvas_document = Id::new_v4(),
|
||||
_ => {
|
||||
choose(&mut f.app, Target::Placement(f.b), GroupChoice::Toggle);
|
||||
choose(&mut f.app, Target::Placement(f.b), GroupChoice::Toggle);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
stale.targets()
|
||||
);
|
||||
}
|
||||
}
|
||||
f.app.status = "Current feedback must survive".into();
|
||||
f.app.allocation_drag.arrange_next = true;
|
||||
let before = f.app.document.snapshot();
|
||||
let selection = f.app.multiselect_snapshot();
|
||||
let legacy = (f.app.selected, f.app.selected_node);
|
||||
let revision = f.app.document.revision();
|
||||
let epochs = (
|
||||
f.app.canvas_epoch,
|
||||
f.app.camera_epoch,
|
||||
f.app.allocation_drag.epoch,
|
||||
);
|
||||
let history = (f.app.document.can_undo(), f.app.document.can_redo());
|
||||
let draft = f
|
||||
.app
|
||||
.draft
|
||||
.as_ref()
|
||||
.map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap());
|
||||
let event = match action {
|
||||
"move" => GroupEvent::Move(stale, point(99., 100.)),
|
||||
"hide" => GroupEvent::Hide(stale),
|
||||
"collapse" => GroupEvent::Collapse(stale, Target::Placement(f.a)),
|
||||
_ => GroupEvent::Refuse(stale, "Obsolete refusal".into()),
|
||||
};
|
||||
dispatch(&mut f.app, event);
|
||||
assert_eq!(
|
||||
f.app.project(),
|
||||
&before.decoded.project,
|
||||
"{stale_by}/{action}"
|
||||
);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert_eq!(f.app.document.revision(), revision);
|
||||
assert_eq!(f.app.multiselect_snapshot(), selection);
|
||||
assert_eq!((f.app.selected, f.app.selected_node), legacy);
|
||||
assert_eq!(
|
||||
(
|
||||
f.app.canvas_epoch,
|
||||
f.app.camera_epoch,
|
||||
f.app.allocation_drag.epoch
|
||||
),
|
||||
epochs
|
||||
);
|
||||
assert_eq!(
|
||||
(f.app.document.can_undo(), f.app.document.can_redo()),
|
||||
history
|
||||
);
|
||||
assert_eq!(f.app.status, "Current feedback must survive");
|
||||
assert!(f.app.allocation_drag.arrange_next);
|
||||
assert_eq!(
|
||||
f.app
|
||||
.draft
|
||||
.as_ref()
|
||||
.map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap()),
|
||||
draft
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_valid_and_invalid_property_drafts_refuse_group_edits_without_applying_them() {
|
||||
for (path, value) in [("/name", "Unapplied name"), ("/id", "-")] {
|
||||
for hide in [false, true] {
|
||||
let mut f = fixture();
|
||||
select_pair(&mut f);
|
||||
let _ = f.app.update(Message::Field(path.into(), value.into()));
|
||||
assert!(f.app.draft_dirty());
|
||||
let snapshot = f.app.multiselect_snapshot().unwrap();
|
||||
let before = f.app.document.snapshot();
|
||||
let draft =
|
||||
serde_json::to_value(f.app.draft.as_ref().unwrap().recovery_snapshot()).unwrap();
|
||||
let event = if hide {
|
||||
GroupEvent::Hide(snapshot.clone())
|
||||
} else {
|
||||
GroupEvent::Move(snapshot.clone(), point(20., 30.))
|
||||
};
|
||||
dispatch(&mut f.app, event);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(!f.app.document.can_undo());
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
snapshot.targets()
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(f.app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
|
||||
draft
|
||||
);
|
||||
assert!(f.app.status.contains("Finish or cancel"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hiding_a_group_removes_only_exact_placements_and_roundtrips_in_one_history_step() {
|
||||
let mut f = fixture();
|
||||
select_pair(&mut f);
|
||||
let snapshot = f.app.multiselect_snapshot().unwrap();
|
||||
let before = f.app.document.snapshot();
|
||||
let mut expected = f.app.project().clone();
|
||||
let view = expected.diagrams.get_mut(&f.diagram).unwrap();
|
||||
view.nodes.remove(&f.a);
|
||||
view.nodes.remove(&f.b);
|
||||
dispatch(&mut f.app, GroupEvent::Hide(snapshot));
|
||||
assert_eq!(
|
||||
f.app.project(),
|
||||
&expected,
|
||||
"engineering objects, descendant placement, authored route and opposite-layer view survive"
|
||||
);
|
||||
assert!(f.app.multiselect_snapshot().unwrap().targets().is_empty());
|
||||
assert_source_matches_model(&f.app);
|
||||
let after = f.app.document.snapshot();
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(!f.app.document.can_undo());
|
||||
let _ = f.app.update(Message::Redo);
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert_eq!(f.app.document.bundle(), &after.bundle);
|
||||
assert!(!f.app.document.can_redo());
|
||||
}
|
||||
fn assert_surviving_primary(f: &Fixture) {
|
||||
let selection = f.app.multiselect_snapshot().unwrap();
|
||||
assert_eq!(selection.targets(), &[Target::Placement(f.a)]);
|
||||
assert_eq!(selection.primary(), Some(&Target::Placement(f.a)));
|
||||
assert_eq!(f.app.selected_node, Some(f.a));
|
||||
assert_eq!(
|
||||
f.app.selected,
|
||||
f.app.project().diagrams[&f.diagram].nodes[&f.a].element
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_primary_placement_from_model_preserves_exact_survivor_after_undo() {
|
||||
let mut f = fixture();
|
||||
select_pair(&mut f);
|
||||
let before = f.app.document.snapshot();
|
||||
let _ = f.app.update(Message::Command(Command::Delete(f.b)));
|
||||
assert!(
|
||||
!f.app.project().diagrams[&f.diagram]
|
||||
.nodes
|
||||
.contains_key(&f.b)
|
||||
);
|
||||
assert_surviving_primary(&f);
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert_surviving_primary(&f);
|
||||
let _ = f.app.update(Message::Redo);
|
||||
assert_surviving_primary(&f);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_primary_model_object_then_undo_does_not_restore_removed_selection_membership() {
|
||||
let mut f = fixture();
|
||||
select_pair(&mut f);
|
||||
let before = f.app.document.snapshot();
|
||||
let semantic = f.app.project().diagrams[&f.diagram].nodes[&f.b].element;
|
||||
let _ = f.app.update(Message::Command(Command::Delete(semantic)));
|
||||
assert!(!f.app.project().elements.contains_key(&semantic));
|
||||
assert_surviving_primary(&f);
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert_surviving_primary(&f);
|
||||
let _ = f.app.update(Message::Redo);
|
||||
assert_surviving_primary(&f);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacing_primary_occurrence_with_same_semantic_placement_does_not_retarget_selection() {
|
||||
let mut f = fixture();
|
||||
select_pair(&mut f);
|
||||
let mut candidate = f.app.project().clone();
|
||||
let view = candidate.diagrams.get_mut(&f.diagram).unwrap();
|
||||
let mut replacement = view.nodes.remove(&f.b).unwrap();
|
||||
let replacement_id = Id::new_v4();
|
||||
replacement.id = replacement_id;
|
||||
replacement.alias = "replacement_receive".into();
|
||||
replacement.position = point(800., 300.);
|
||||
view.nodes.insert(replacement_id, replacement);
|
||||
// Replace preserves the semantic inspector identity. Its normal sync used
|
||||
// to rediscover the new occurrence through that identity and steal the set.
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::Command(Command::Replace(candidate.clone())));
|
||||
assert_eq!(f.app.project(), &candidate);
|
||||
assert_surviving_primary(&f);
|
||||
assert!(
|
||||
!f.app
|
||||
.multiselect_snapshot()
|
||||
.unwrap()
|
||||
.targets()
|
||||
.contains(&Target::Placement(replacement_id))
|
||||
);
|
||||
assert_source_matches_model(&f.app);
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert!(
|
||||
f.app.project().diagrams[&f.diagram]
|
||||
.nodes
|
||||
.contains_key(&f.b)
|
||||
);
|
||||
assert_surviving_primary(&f);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_surface_navigation_and_return_never_resurrect_the_group() {
|
||||
for destination in [Page::Catalog, Page::Behavior] {
|
||||
let mut f = fixture();
|
||||
select_pair(&mut f);
|
||||
let before = f.app.document.snapshot();
|
||||
let stale = f.app.multiselect_snapshot().unwrap();
|
||||
let _ = f.app.update(Message::Page(destination));
|
||||
assert!(f.app.multiselect_snapshot().is_none());
|
||||
let _ = f.app.update(Message::Page(Page::Diagram));
|
||||
let live = f.app.multiselect_snapshot().unwrap();
|
||||
assert!(
|
||||
live.targets().len() <= 1,
|
||||
"navigation may inspect one exact member but must not restore the group"
|
||||
);
|
||||
dispatch(&mut f.app, GroupEvent::Move(stale, point(400., 500.)));
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
}
|
||||
|
||||
fn open_placement_menu(f: &mut Fixture, placement: Id) {
|
||||
let semantic = f.app.project().diagrams[&f.diagram].nodes[&placement].element;
|
||||
let mut request = crate::model_menu::Request::object(semantic, iced::Point::new(100., 100.));
|
||||
request.placement = Some(placement);
|
||||
request.canvas = f.app.route_context();
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::Menu(crate::model_menu::Event::Open(request)));
|
||||
assert!(f.app.model_menu.is_some());
|
||||
}
|
||||
|
||||
fn menu_action(app: &App, caption: &str) -> Message {
|
||||
crate::model_menu::popup(app)
|
||||
.unwrap()
|
||||
.entries
|
||||
.into_iter()
|
||||
.find_map(|entry| match entry {
|
||||
crate::context_menu::Entry::Action { label, message, .. } if label == caption => {
|
||||
message
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| panic!("enabled menu action {caption:?} must exist"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_menu_dismissal_preserves_group_and_add_keeps_pre_menu_members() {
|
||||
let mut f = fixture();
|
||||
select_pair(&mut f);
|
||||
let before = f.app.document.snapshot();
|
||||
let selected = f.app.multiselect_snapshot().unwrap();
|
||||
let child = f.child;
|
||||
open_placement_menu(&mut f, child);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
selected.targets()
|
||||
);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().primary(),
|
||||
selected.primary()
|
||||
);
|
||||
let _ = f.app.update(Message::Menu(crate::model_menu::Event::Close));
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
selected.targets()
|
||||
);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().primary(),
|
||||
selected.primary()
|
||||
);
|
||||
open_placement_menu(&mut f, child);
|
||||
let add = menu_action(&f.app, "Add to selection");
|
||||
let _ = f.app.update(add);
|
||||
let selected = f.app.multiselect_snapshot().unwrap();
|
||||
assert_eq!(
|
||||
selected.targets(),
|
||||
&[
|
||||
Target::Placement(f.a),
|
||||
Target::Placement(f.b),
|
||||
Target::Placement(child)
|
||||
]
|
||||
);
|
||||
assert_eq!(selected.primary(), Some(&Target::Placement(child)));
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_member_context_menu_exposes_and_executes_group_hide_not_singleton_deletion() {
|
||||
let mut f = fixture();
|
||||
select_pair(&mut f);
|
||||
let before = f.app.document.snapshot();
|
||||
let member = f.a;
|
||||
open_placement_menu(&mut f, member);
|
||||
let popup = crate::model_menu::popup(&f.app).unwrap();
|
||||
for entry in &popup.entries {
|
||||
if let crate::context_menu::Entry::Action {
|
||||
message: Some(Message::Menu(crate::model_menu::Event::Invoke(_, action))),
|
||||
..
|
||||
} = entry
|
||||
{
|
||||
assert!(
|
||||
!matches!(
|
||||
action.as_ref(),
|
||||
Message::HideSelected | Message::Delete | Message::DiagramRemove(_)
|
||||
),
|
||||
"a selected-member group menu must not expose singleton removal"
|
||||
);
|
||||
}
|
||||
}
|
||||
let hide = menu_action(&f.app, "Hide selected placements");
|
||||
let _ = f.app.update(hide);
|
||||
let mut expected = before.decoded.project.clone();
|
||||
let view = expected.diagrams.get_mut(&f.diagram).unwrap();
|
||||
view.nodes.remove(&f.a);
|
||||
view.nodes.remove(&f.b);
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert!(!f.app.delete_pending);
|
||||
assert!(f.app.model_menu.is_none());
|
||||
assert_source_matches_model(&f.app);
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
@@ -53,6 +53,7 @@ async fn armed() -> (App, Mounted, Id) {
|
||||
context,
|
||||
object: relation,
|
||||
placement: None,
|
||||
selection_target: None,
|
||||
anchor: iced::Point::new(450., 400.),
|
||||
bend: None,
|
||||
route_segment: None,
|
||||
|
||||
@@ -79,6 +79,7 @@ fn state_bend_menu_carries_exact_segment_and_pin_without_changing_delete_action(
|
||||
context,
|
||||
object: relation,
|
||||
placement: None,
|
||||
selection_target: None,
|
||||
anchor: iced::Point::ORIGIN,
|
||||
bend: Some(1),
|
||||
route_segment: Some((2, point)),
|
||||
|
||||
@@ -115,6 +115,7 @@ impl App {
|
||||
}
|
||||
}
|
||||
match event {
|
||||
Event::Selection(event) => self.multiselect_event(event),
|
||||
Event::Remove(intent) => self.remove_from_diagram(intent),
|
||||
Event::Viewport(_, size) => {
|
||||
if size.width.is_finite()
|
||||
@@ -157,6 +158,7 @@ impl App {
|
||||
context,
|
||||
object,
|
||||
placement,
|
||||
selection_target,
|
||||
anchor,
|
||||
bend,
|
||||
route_segment,
|
||||
@@ -164,6 +166,7 @@ impl App {
|
||||
let mut request = crate::model_menu::Request::object(object, anchor);
|
||||
request.canvas = Some(context);
|
||||
request.placement = placement;
|
||||
request.selection_target = selection_target.map(Box::new);
|
||||
request.bend = bend;
|
||||
request.route_segment = route_segment;
|
||||
return self.handle(Message::Menu(crate::model_menu::Event::Open(request)));
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
//! State selection layout and modifier admission on the mounted production App.
|
||||
use super::*;
|
||||
use crate::behavior::{
|
||||
canvas_workspace::tests::{Mounted, key},
|
||||
state_edit,
|
||||
};
|
||||
use crate::diagram_usage::Target;
|
||||
use iced::advanced::widget::{Operation, operation};
|
||||
use iced::{Point, Rectangle, Size, Vector, keyboard, mouse};
|
||||
|
||||
struct Fixture {
|
||||
app: App,
|
||||
diagram: Id,
|
||||
a: Id,
|
||||
b: Id,
|
||||
a_element: Id,
|
||||
b_element: Id,
|
||||
edge: Id,
|
||||
action: Id,
|
||||
}
|
||||
|
||||
fn fixture() -> Fixture {
|
||||
let mut p = Project::empty();
|
||||
let machine = p
|
||||
.add_element(Kind::StateMachine, Layer::Oa, "Operating modes", None)
|
||||
.unwrap();
|
||||
let region = p
|
||||
.add_element(Kind::Region, Layer::Oa, "Main region", Some(machine))
|
||||
.unwrap();
|
||||
let a_element = p
|
||||
.add_element(Kind::State, Layer::Oa, "First state", Some(region))
|
||||
.unwrap();
|
||||
let b_element = p
|
||||
.add_element(
|
||||
Kind::State,
|
||||
Layer::Oa,
|
||||
&format!("Second state {}", "with a long primary name ".repeat(20)),
|
||||
Some(region),
|
||||
)
|
||||
.unwrap();
|
||||
let work = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Reusable work", None)
|
||||
.unwrap();
|
||||
let action = p.relate(RelationKind::StateAction, "On entry", a_element, work);
|
||||
let edge = p.relate(
|
||||
RelationKind::StateTransition,
|
||||
"Next state",
|
||||
a_element,
|
||||
b_element,
|
||||
);
|
||||
let diagram = p.ensure_diagram(Layer::Oa);
|
||||
p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::StateMachine { machine };
|
||||
let mut placements = vec![];
|
||||
// All State elements, including machine/Region, are initially placed.
|
||||
// A fixture already missing its containers would not test picker insertion.
|
||||
for (element, x, y, width, height) in [
|
||||
(machine, 0., 0., 800., 500.),
|
||||
(region, 20., 70., 760., 410.),
|
||||
(a_element, 60., 160., 230., 180.),
|
||||
(b_element, 430., 160., 260., 180.),
|
||||
] {
|
||||
let node = p.place(diagram, element, Position { x, y });
|
||||
p.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&node)
|
||||
.unwrap()
|
||||
.size = oa_core::Size { width, height };
|
||||
placements.push(node);
|
||||
}
|
||||
let a = placements[2];
|
||||
let b = placements[3];
|
||||
validate_candidate(&p).unwrap();
|
||||
assert!(state_edit::unplaced(&p, diagram).is_empty());
|
||||
let mut app = App::with_project(p);
|
||||
app.page = Page::Behavior;
|
||||
app.layer = Layer::Oa;
|
||||
app.behavior.active_owner = Some(machine);
|
||||
app.behavior.diagram = crate::behavior::state_view::State {
|
||||
diagram: Some(diagram),
|
||||
fit: true,
|
||||
..Default::default()
|
||||
};
|
||||
app.selected = diagram;
|
||||
app.selected_node = None;
|
||||
app.window_size = Size::new(1442., 932.);
|
||||
app.refresh_draft();
|
||||
app.reconcile_multiselect();
|
||||
assert_eq!(app.state_context().unwrap().diagram, diagram);
|
||||
Fixture {
|
||||
app,
|
||||
diagram,
|
||||
a,
|
||||
b,
|
||||
a_element,
|
||||
b_element,
|
||||
edge,
|
||||
action,
|
||||
}
|
||||
}
|
||||
|
||||
fn owner(mounted: &mut Mounted, app: &App) -> (Rectangle, bool) {
|
||||
let geometry = mounted.geometry(app);
|
||||
let (_, bounds, focused) = geometry
|
||||
.focused
|
||||
.into_iter()
|
||||
.find(|(id, _, _)| {
|
||||
id.as_ref() == Some(&iced::advanced::widget::Id::from("diagram-keyboard-owner"))
|
||||
})
|
||||
.expect("real State focus::Owner rectangle must be measurable");
|
||||
(bounds, focused)
|
||||
}
|
||||
fn camera(app: &App) -> (f32, Vector, bool, Option<Size>) {
|
||||
let state = &app.behavior.diagram;
|
||||
(state.zoom, state.pan, state.fit, state.viewport)
|
||||
}
|
||||
async fn selection(mounted: &mut Mounted, app: &mut App, target: Option<Target>) {
|
||||
let context = app.multiselect_context().unwrap();
|
||||
let event = match target {
|
||||
Some(target) => multiselect::Event::Choose(context, target, multiselect::Choice::Toggle),
|
||||
None => multiselect::Event::Clear(context),
|
||||
};
|
||||
let task = app.update(Message::DiagramSelection(event));
|
||||
mounted.perform(app, task).await;
|
||||
mounted.settle(app).await;
|
||||
}
|
||||
fn node_point(mounted: &mut Mounted, f: &Fixture, placement: Id) -> Point {
|
||||
let bounds = owner(mounted, &f.app).0;
|
||||
let node = &f.app.project().diagrams[&f.diagram].nodes[&placement];
|
||||
let state = &f.app.behavior.diagram;
|
||||
Point::new(
|
||||
bounds.x + (node.position.x + 40.) * state.zoom + state.pan.x,
|
||||
bounds.y + (node.position.y + 25.) * state.zoom + state.pan.y,
|
||||
)
|
||||
}
|
||||
fn modifiers(shift: bool) -> iced::Event {
|
||||
iced::Event::Keyboard(keyboard::Event::ModifiersChanged(if shift {
|
||||
keyboard::Modifiers::SHIFT
|
||||
} else {
|
||||
keyboard::Modifiers::default()
|
||||
}))
|
||||
}
|
||||
struct FocusField(Rectangle);
|
||||
impl Operation for FocusField {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) {
|
||||
visit(self);
|
||||
}
|
||||
fn focusable(
|
||||
&mut self,
|
||||
_: Option<&iced::advanced::widget::Id>,
|
||||
bounds: Rectangle,
|
||||
state: &mut dyn operation::Focusable,
|
||||
) {
|
||||
if bounds == self.0 {
|
||||
state.focus();
|
||||
} else {
|
||||
state.unfocus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_state_selection_and_missing_picker_never_resize_or_refit_the_canvas() {
|
||||
let mut f = fixture();
|
||||
let mut mounted = Mounted::new(&mut f.app).await;
|
||||
let baseline = f.app.document.snapshot();
|
||||
let bounds = owner(&mut mounted, &f.app).0;
|
||||
let fitted = camera(&f.app);
|
||||
assert!(
|
||||
fitted.2,
|
||||
"keep Fit active to expose any resize-triggered camera jump"
|
||||
);
|
||||
assert!(fitted.3.is_some());
|
||||
assert!(bounds.width > 400. && bounds.height > 200.);
|
||||
assert!(state_edit::unplaced(f.app.project(), f.diagram).is_empty());
|
||||
assert!(f.app.multiselect_snapshot().unwrap().targets().is_empty());
|
||||
for (target, expected) in [
|
||||
(Some(Target::Placement(f.a)), vec![Target::Placement(f.a)]),
|
||||
(
|
||||
Some(Target::Placement(f.b)),
|
||||
vec![Target::Placement(f.a), Target::Placement(f.b)],
|
||||
),
|
||||
(
|
||||
Some(Target::Edge(f.edge)),
|
||||
vec![
|
||||
Target::Placement(f.a),
|
||||
Target::Placement(f.b),
|
||||
Target::Edge(f.edge),
|
||||
],
|
||||
),
|
||||
(
|
||||
Some(Target::Edge(f.edge)),
|
||||
vec![Target::Placement(f.a), Target::Placement(f.b)],
|
||||
),
|
||||
(
|
||||
Some(Target::StateRow {
|
||||
placement: f.a,
|
||||
relation: f.action,
|
||||
}),
|
||||
vec![
|
||||
Target::Placement(f.a),
|
||||
Target::Placement(f.b),
|
||||
Target::StateRow {
|
||||
placement: f.a,
|
||||
relation: f.action,
|
||||
},
|
||||
],
|
||||
),
|
||||
(None, vec![]),
|
||||
] {
|
||||
selection(&mut mounted, &mut f.app, target).await;
|
||||
assert_eq!(f.app.multiselect_snapshot().unwrap().targets(), expected);
|
||||
assert_eq!(
|
||||
owner(&mut mounted, &f.app).0,
|
||||
bounds,
|
||||
"selection strip changed the measured State canvas rectangle"
|
||||
);
|
||||
assert_eq!(
|
||||
camera(&f.app),
|
||||
fitted,
|
||||
"selection feedback silently refitted the camera"
|
||||
);
|
||||
assert_eq!(f.app.project(), &baseline.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &baseline.bundle);
|
||||
assert!(!f.app.document.can_undo());
|
||||
assert!(!f.app.draft_dirty());
|
||||
}
|
||||
selection(&mut mounted, &mut f.app, Some(Target::Placement(f.a))).await;
|
||||
selection(&mut mounted, &mut f.app, Some(Target::Placement(f.b))).await;
|
||||
let captured = f.app.multiselect_snapshot().unwrap();
|
||||
let task = f
|
||||
.app
|
||||
.update(Message::DiagramSelection(multiselect::Event::Hide(
|
||||
captured,
|
||||
)));
|
||||
mounted.perform(&mut f.app, task).await;
|
||||
mounted.settle(&mut f.app).await;
|
||||
let missing: std::collections::BTreeSet<_> = state_edit::unplaced(f.app.project(), f.diagram)
|
||||
.into_iter()
|
||||
.collect();
|
||||
assert_eq!(
|
||||
missing,
|
||||
std::collections::BTreeSet::from([f.a_element, f.b_element])
|
||||
);
|
||||
let mut expected = baseline.decoded.project.clone();
|
||||
expected
|
||||
.diagrams
|
||||
.get_mut(&f.diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.remove(&f.a);
|
||||
expected
|
||||
.diagrams
|
||||
.get_mut(&f.diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.remove(&f.b);
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert_eq!(
|
||||
owner(&mut mounted, &f.app).0,
|
||||
bounds,
|
||||
"the first unplaced picker changed canvas bounds"
|
||||
);
|
||||
assert_eq!(camera(&f.app), fitted);
|
||||
let hidden_source = f.app.document.bundle().clone();
|
||||
let task = f.app.update(Message::Undo);
|
||||
mounted.perform(&mut f.app, task).await;
|
||||
mounted.settle(&mut f.app).await;
|
||||
assert!(state_edit::unplaced(f.app.project(), f.diagram).is_empty());
|
||||
assert_eq!(f.app.project(), &baseline.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &baseline.bundle);
|
||||
assert!(!f.app.document.can_undo());
|
||||
assert_eq!(
|
||||
owner(&mut mounted, &f.app).0,
|
||||
bounds,
|
||||
"removing the unplaced picker changed canvas bounds"
|
||||
);
|
||||
assert_eq!(camera(&f.app), fitted);
|
||||
let task = f.app.update(Message::Redo);
|
||||
mounted.perform(&mut f.app, task).await;
|
||||
mounted.settle(&mut f.app).await;
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert_eq!(f.app.document.bundle(), &hidden_source);
|
||||
assert_eq!(owner(&mut mounted, &f.app).0, bounds);
|
||||
assert_eq!(camera(&f.app), fitted);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_state_shift_held_in_clean_inspector_toggles_on_first_canvas_press() {
|
||||
let mut f = fixture();
|
||||
let mut mounted = Mounted::new(&mut f.app).await;
|
||||
selection(&mut mounted, &mut f.app, Some(Target::Placement(f.a))).await;
|
||||
let field = mounted
|
||||
.geometry(&f.app)
|
||||
.text_inputs
|
||||
.into_iter()
|
||||
.filter(|bounds| bounds.x > f.app.window_size.width - 320.)
|
||||
.min_by(|a, b| a.y.total_cmp(&b.y))
|
||||
.expect("State Inspector field is mounted");
|
||||
mounted.operate(&f.app, &mut FocusField(field));
|
||||
assert!(!owner(&mut mounted, &f.app).1);
|
||||
let before = f.app.document.snapshot();
|
||||
mounted
|
||||
.batch(&mut f.app, &[modifiers(true)], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
assert!(
|
||||
!owner(&mut mounted, &f.app).1,
|
||||
"passive Shift must not acquire State canvas focus"
|
||||
);
|
||||
let at = node_point(&mut mounted, &f, f.b);
|
||||
let messages = mounted.click(&mut f.app, at).await;
|
||||
assert!(messages.iter().any(|message| matches!(message,
|
||||
Message::DiagramSelection(multiselect::Event::Choose(_, Target::Placement(id), multiselect::Choice::Toggle)) if *id == f.b
|
||||
)), "StateCanvas must map its typed selection event to the common App boundary");
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::Placement(f.a), Target::Placement(f.b)]
|
||||
);
|
||||
assert_eq!(f.app.behavior.diagram.placement, Some(f.b));
|
||||
assert_eq!(f.app.selected_node, None);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(!f.app.document.can_undo());
|
||||
assert!(!f.app.draft_dirty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_state_busy_suspension_tracks_shift_without_granting_focus_or_commands() {
|
||||
let mut f = fixture();
|
||||
let mut mounted = Mounted::new(&mut f.app).await;
|
||||
selection(&mut mounted, &mut f.app, Some(Target::Placement(f.a))).await;
|
||||
// Explicitly revoke focus rather than relying on incidental initial focus.
|
||||
mounted.operate(
|
||||
&f.app,
|
||||
&mut FocusField(Rectangle {
|
||||
x: -1000.,
|
||||
y: -1000.,
|
||||
width: 1.,
|
||||
height: 1.,
|
||||
}),
|
||||
);
|
||||
let before = f.app.document.snapshot();
|
||||
let at = node_point(&mut mounted, &f, f.b);
|
||||
f.app.busy = true;
|
||||
mounted.settle(&mut f.app).await;
|
||||
mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
&[modifiers(true), key(keyboard::key::Named::Delete)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
mounted.click(&mut f.app, at).await;
|
||||
assert!(!owner(&mut mounted, &f.app).1);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::Placement(f.a)]
|
||||
);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
f.app.busy = false;
|
||||
mounted.settle(&mut f.app).await;
|
||||
assert!(!owner(&mut mounted, &f.app).1);
|
||||
// No second modifier event: the passive suspended update must survive.
|
||||
mounted.click(&mut f.app, at).await;
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::Placement(f.a), Target::Placement(f.b)]
|
||||
);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(!f.app.document.can_undo());
|
||||
assert!(!f.app.delete_pending);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -391,7 +391,7 @@ fn state_bends_context_menu_distinguishes_authored_pin_from_computed_segment() {
|
||||
motion(&canvas, &mut state, pin, pin);
|
||||
assert!(
|
||||
matches!(input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Right), pin),
|
||||
Some(Event::ContextMenu { context, object, placement: None, anchor, bend: Some(0), route_segment: None })
|
||||
Some(Event::ContextMenu { context, object, placement: None, anchor, bend: Some(0), route_segment: None, .. })
|
||||
if context == canvas.context && object == relation && anchor == pin)
|
||||
);
|
||||
let (index, midpoint) = bend_segment(&scene, relation);
|
||||
@@ -399,7 +399,7 @@ fn state_bends_context_menu_distinguishes_authored_pin_from_computed_segment() {
|
||||
motion(&canvas, &mut state, at, at);
|
||||
assert!(
|
||||
matches!(input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Right), at),
|
||||
Some(Event::ContextMenu { context, object, placement: None, anchor, bend: None, route_segment: Some((segment, world)) })
|
||||
Some(Event::ContextMenu { context, object, placement: None, anchor, bend: None, route_segment: Some((segment, world)), .. })
|
||||
if context == canvas.context && object == relation && anchor == at && segment == index && point(world).distance(midpoint) < 0.001)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
//! Persisted state-diagram geometry with context-bound, ordered pointer gestures.
|
||||
use super::state_edit::{self, Context, Endpoint, Event, Tool};
|
||||
use crate::app::multiselect::{Choice, Event as SelectionEvent, context as selection_context};
|
||||
use crate::diagram::group_gesture::{
|
||||
DisplacementPolicy, Gesture as GroupGesture, Outcome, Viewport,
|
||||
};
|
||||
use crate::diagram_selection::{Selection, Snapshot};
|
||||
use crate::diagram_usage::Target;
|
||||
use crate::style;
|
||||
use iced::{
|
||||
Color, Point, Rectangle, Renderer, Size, Theme, Vector, keyboard, mouse,
|
||||
@@ -27,6 +33,7 @@ pub struct StateCanvas<'a> {
|
||||
pub diagram: Id,
|
||||
pub context: Context,
|
||||
pub selected: Id,
|
||||
pub selection: Option<&'a Selection>,
|
||||
/// Presentation-only navigation marker; never an authoring placement.
|
||||
pub navigation_focus: Option<Rectangle>,
|
||||
pub tool: Tool,
|
||||
@@ -41,6 +48,8 @@ pub struct State {
|
||||
tool: Option<Tool>,
|
||||
pointer: Option<Point>,
|
||||
press: Option<Press>,
|
||||
group: Option<GroupPress>,
|
||||
modifiers: keyboard::Modifiers,
|
||||
source: Option<Id>,
|
||||
preview: Option<(Id, Position)>,
|
||||
attachment_preview: Option<AttachmentPreview>,
|
||||
@@ -113,6 +122,7 @@ struct Scene {
|
||||
}
|
||||
struct CachedScene {
|
||||
context: Context,
|
||||
group_preview: Option<(Snapshot, Position)>,
|
||||
preview: Option<(Id, Position)>,
|
||||
attachment_preview: Option<AttachmentPreview>,
|
||||
bend_preview: Option<EdgeRoute>,
|
||||
@@ -121,6 +131,8 @@ struct CachedScene {
|
||||
#[derive(PartialEq)]
|
||||
struct PaintKey {
|
||||
context: Context,
|
||||
group_preview: Option<(Snapshot, Position)>,
|
||||
selection: Option<Snapshot>,
|
||||
preview: Option<(Id, Position)>,
|
||||
attachment_preview: Option<AttachmentPreview>,
|
||||
bend_preview: Option<EdgeRoute>,
|
||||
@@ -132,12 +144,82 @@ struct PaintKey {
|
||||
enum Hit {
|
||||
Node { placement: Id, element: Id },
|
||||
Transition(Id),
|
||||
StateAction(Id),
|
||||
Row { placement: Id, relation: Id },
|
||||
}
|
||||
|
||||
impl Hit {
|
||||
fn target(self) -> Target {
|
||||
match self {
|
||||
Self::Node { placement, .. } => Target::Placement(placement),
|
||||
Self::Transition(id) => Target::Edge(id),
|
||||
Self::Row {
|
||||
placement,
|
||||
relation,
|
||||
} => Target::StateRow {
|
||||
placement,
|
||||
relation,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Unsupported mixtures retain the same click/cancel lifecycle as placement groups.
|
||||
struct GroupPress {
|
||||
snapshot: Snapshot,
|
||||
target: Target,
|
||||
start: Point,
|
||||
viewport: Viewport,
|
||||
movement: Option<GroupGesture>,
|
||||
crossed: bool,
|
||||
retired: bool,
|
||||
}
|
||||
|
||||
impl GroupPress {
|
||||
fn move_to(&mut self, pointer: Point) {
|
||||
if self.retired {
|
||||
return;
|
||||
}
|
||||
if !pointer.x.is_finite()
|
||||
|| !pointer.y.is_finite()
|
||||
|| !self.viewport.bounds.contains(pointer)
|
||||
{
|
||||
self.retired = true;
|
||||
return;
|
||||
}
|
||||
self.crossed |= pointer.distance(self.start) > 3.;
|
||||
if let Some(movement) = &mut self.movement
|
||||
&& !movement.move_to(pointer)
|
||||
{
|
||||
self.retired = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn release(mut self, pointer: Point) -> Option<SelectionEvent> {
|
||||
self.move_to(pointer);
|
||||
if self.retired {
|
||||
return None;
|
||||
}
|
||||
if !self.crossed {
|
||||
return Some(SelectionEvent::Collapse(self.snapshot, self.target));
|
||||
}
|
||||
let Some(movement) = self.movement else {
|
||||
return Some(SelectionEvent::Refuse(self.snapshot,
|
||||
"Move requires only placed elements. Remove rows, ports and transitions from the selection.".into()));
|
||||
};
|
||||
match movement.release(pointer)? {
|
||||
Outcome::Click(id) => Some(SelectionEvent::Collapse(
|
||||
self.snapshot,
|
||||
Target::Placement(id),
|
||||
)),
|
||||
Outcome::Move(snapshot, delta) => Some(SelectionEvent::Move(snapshot, delta)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl State {
|
||||
fn cancel_edit(&mut self) {
|
||||
self.press = None;
|
||||
self.group = None;
|
||||
self.preview = None;
|
||||
self.attachment_preview = None;
|
||||
self.bend = None;
|
||||
@@ -485,6 +567,32 @@ pub(crate) fn visible_row_relation(
|
||||
})
|
||||
}
|
||||
|
||||
/// Validate this occurrence, including the relation's current source. A surviving
|
||||
/// relation reassigned to another State must not rebound an old row selection.
|
||||
pub(crate) fn visible_row_target(
|
||||
project: &Project,
|
||||
diagram: Id,
|
||||
view: &ProjectedView,
|
||||
placement: Id,
|
||||
relation: Id,
|
||||
) -> bool {
|
||||
visible_row_relation(project, diagram, view, relation)
|
||||
&& view
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|node| node.id == placement)
|
||||
.is_some_and(|node| {
|
||||
project
|
||||
.relations
|
||||
.get(&relation)
|
||||
.is_some_and(|row| row.source() == node.element)
|
||||
&& state_action_rows(project, node)
|
||||
.into_iter()
|
||||
.chain(internal_rows(project, node))
|
||||
.any(|(row, _)| row.id == relation)
|
||||
})
|
||||
}
|
||||
|
||||
fn state_action_label(project: &Project, relation: &Relation) -> String {
|
||||
let RelationBody::StateAction { target, phase, .. } = relation.body else {
|
||||
return String::new();
|
||||
@@ -663,6 +771,210 @@ pub(crate) fn diagram_usages(
|
||||
}
|
||||
|
||||
impl StateCanvas<'_> {
|
||||
fn current_selection(&self) -> Option<&Selection> {
|
||||
self.selection
|
||||
.filter(|s| s.scope() == selection_context(self.context).scope)
|
||||
}
|
||||
|
||||
fn group_viewport(&self, bounds: Rectangle) -> Viewport {
|
||||
Viewport {
|
||||
zoom: self.scale(),
|
||||
pan: self.pan,
|
||||
bounds: Rectangle::new(
|
||||
Point::new(bounds.x, bounds.y + GUIDANCE_HEIGHT),
|
||||
Self::content(bounds).size(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn group_current(&self, group: &GroupPress, bounds: Option<Rectangle>) -> bool {
|
||||
!group.retired
|
||||
&& self.editable
|
||||
&& self.tool == Tool::Select
|
||||
&& group.viewport.zoom == self.scale()
|
||||
&& group.viewport.pan == self.pan
|
||||
&& bounds.is_none_or(|b| group.viewport == self.group_viewport(b))
|
||||
&& self
|
||||
.current_selection()
|
||||
.is_some_and(|s| s.matches(&group.snapshot, selection_context(self.context)))
|
||||
}
|
||||
|
||||
fn group_preview_key(
|
||||
&self,
|
||||
state: &State,
|
||||
bounds: Option<Rectangle>,
|
||||
) -> Option<(Snapshot, Position)> {
|
||||
let group = state.group.as_ref()?;
|
||||
if !self.group_current(group, bounds) || !group.crossed {
|
||||
return None;
|
||||
}
|
||||
Some((group.snapshot.clone(), group.movement.as_ref()?.delta()))
|
||||
}
|
||||
|
||||
fn group_input(
|
||||
&self,
|
||||
state: &mut State,
|
||||
event: &canvas::Event,
|
||||
bounds: Rectangle,
|
||||
cursor: mouse::Cursor,
|
||||
) -> Option<Action<Event>> {
|
||||
if let Some(group) = &state.group {
|
||||
let cancelled = matches!(
|
||||
event,
|
||||
canvas::Event::Mouse(
|
||||
mouse::Event::CursorLeft
|
||||
| mouse::Event::WheelScrolled { .. }
|
||||
| mouse::Event::ButtonPressed(mouse::Button::Right | mouse::Button::Middle)
|
||||
) | canvas::Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Named(keyboard::key::Named::Escape),
|
||||
..
|
||||
})
|
||||
);
|
||||
if !self.group_current(group, Some(bounds)) || cancelled {
|
||||
state.cancel_edit();
|
||||
return Some(Action::request_redraw().and_capture());
|
||||
}
|
||||
}
|
||||
if let Some(group) = &mut state.group {
|
||||
match event {
|
||||
canvas::Event::Mouse(mouse::Event::CursorMoved { position }) => {
|
||||
group.move_to(*position);
|
||||
return Some(Action::request_redraw().and_capture());
|
||||
}
|
||||
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
|
||||
let group = state.group.take().unwrap();
|
||||
return Some(
|
||||
cursor
|
||||
.position()
|
||||
.and_then(|p| group.release(p))
|
||||
.map_or_else(
|
||||
|| Action::request_redraw().and_capture(),
|
||||
|event| Action::publish(Event::Selection(event)).and_capture(),
|
||||
),
|
||||
);
|
||||
}
|
||||
canvas::Event::Keyboard(_)
|
||||
| canvas::Event::Mouse(mouse::Event::ButtonPressed(_)) => {
|
||||
return Some(Action::capture());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let selection = self.current_selection()?;
|
||||
if !self.editable || self.tool != Tool::Select || state.pan_from.is_some() {
|
||||
return None;
|
||||
}
|
||||
if let canvas::Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. }) = event {
|
||||
use keyboard::{Key, key::Named};
|
||||
if *key == Key::Named(Named::Escape) && state.press.is_none() && state.source.is_none()
|
||||
{
|
||||
return Some(
|
||||
Action::publish(Event::Selection(SelectionEvent::Clear(selection_context(
|
||||
self.context,
|
||||
))))
|
||||
.and_capture(),
|
||||
);
|
||||
}
|
||||
if selection.targets().len() > 1 && *key == Key::Named(Named::Delete) {
|
||||
let snapshot = selection.snapshot(selection_context(self.context))?;
|
||||
let event = if modifiers.is_empty()
|
||||
&& crate::app::multiselect::placements(&snapshot).is_some()
|
||||
{
|
||||
SelectionEvent::Hide(snapshot)
|
||||
} else {
|
||||
SelectionEvent::Refuse(snapshot, if modifiers.is_empty() {
|
||||
"Hide requires only placed elements. Rows, ports and transitions cannot be hidden as a placement group."
|
||||
} else {
|
||||
"Group model deletion requires an aggregate impact review and is not available yet. Use Hide selected for placements."
|
||||
}.into())
|
||||
};
|
||||
return Some(Action::publish(Event::Selection(event)).and_capture());
|
||||
}
|
||||
}
|
||||
if !matches!(
|
||||
event,
|
||||
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
let window = cursor
|
||||
.position()
|
||||
.filter(|p| self.group_viewport(bounds).bounds.contains(*p))?;
|
||||
let local = Point::new(window.x - bounds.x, window.y - bounds.y);
|
||||
let scene = self.scene(state)?;
|
||||
let world = self.world(local);
|
||||
// Route handles belong to the exact edge, including when an endpoint
|
||||
// overlaps a node. They cannot bypass Shift/group admission.
|
||||
let handle = self
|
||||
.endpoint_at(&scene, world)
|
||||
.map(|(id, _)| Target::Edge(id))
|
||||
.or_else(|| {
|
||||
self.bend_at(&scene, world)
|
||||
.map(|_| Target::Edge(self.selected))
|
||||
});
|
||||
let target = handle.or_else(|| self.hit(&scene, world).map(Hit::target))?;
|
||||
if state.modifiers.shift() {
|
||||
state.cancel_edit();
|
||||
return Some(
|
||||
Action::publish(Event::Selection(SelectionEvent::Choose(
|
||||
selection_context(self.context),
|
||||
target,
|
||||
Choice::Toggle,
|
||||
)))
|
||||
.and_capture(),
|
||||
);
|
||||
}
|
||||
if selection.targets().len() <= 1 || !selection.contains(&target) {
|
||||
return None;
|
||||
}
|
||||
let snapshot = selection.snapshot(selection_context(self.context))?;
|
||||
let viewport = self.group_viewport(bounds);
|
||||
let movement = if crate::app::multiselect::placements(&snapshot).is_some()
|
||||
&& let Target::Placement(anchor) = target
|
||||
{
|
||||
let original = self
|
||||
.project
|
||||
.diagrams
|
||||
.get(&self.diagram)?
|
||||
.nodes
|
||||
.get(&anchor)?
|
||||
.position;
|
||||
Some(GroupGesture::new_with_policy(
|
||||
snapshot.clone(),
|
||||
anchor,
|
||||
original,
|
||||
window,
|
||||
viewport,
|
||||
DisplacementPolicy::Unsnapped,
|
||||
)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
state.cancel_edit();
|
||||
state.group = Some(GroupPress {
|
||||
snapshot,
|
||||
target,
|
||||
start: window,
|
||||
viewport,
|
||||
movement,
|
||||
crossed: false,
|
||||
retired: false,
|
||||
});
|
||||
Some(Action::request_redraw().and_capture())
|
||||
}
|
||||
|
||||
fn select_event(&self, hit: Hit, semantic: Id) -> Event {
|
||||
if self.current_selection().is_some() {
|
||||
Event::Selection(SelectionEvent::Choose(
|
||||
selection_context(self.context),
|
||||
hit.target(),
|
||||
Choice::Replace,
|
||||
))
|
||||
} else {
|
||||
Event::Select(self.context, semantic)
|
||||
}
|
||||
}
|
||||
|
||||
fn scale(&self) -> f32 {
|
||||
if self.zoom.is_finite() {
|
||||
self.zoom.clamp(0.0001, 3.)
|
||||
@@ -695,6 +1007,7 @@ impl StateCanvas<'_> {
|
||||
return None;
|
||||
}
|
||||
let route_active = self.gesture_active(state) && self.route_viewport_current(state, bounds);
|
||||
let group_preview = self.group_preview_key(state, bounds);
|
||||
let preview = self
|
||||
.gesture_active(state)
|
||||
.then_some(state.preview)
|
||||
@@ -703,6 +1016,7 @@ impl StateCanvas<'_> {
|
||||
let bend_preview = route_active.then(|| state.bend_preview.clone()).flatten();
|
||||
if let Some(cached) = state.scene.borrow().as_ref()
|
||||
&& cached.context == self.context
|
||||
&& cached.group_preview == group_preview
|
||||
&& cached.preview == preview
|
||||
&& cached.attachment_preview == attachment_preview
|
||||
&& cached.bend_preview == bend_preview
|
||||
@@ -710,6 +1024,27 @@ impl StateCanvas<'_> {
|
||||
return Some(cached.scene.clone());
|
||||
}
|
||||
let mut diagram = self.project.diagrams.get(&self.diagram)?.clone();
|
||||
if let Some((snapshot, delta)) = &group_preview
|
||||
&& let Some(ids) = crate::app::multiselect::placements(snapshot)
|
||||
&& let Ok(Command::Batch(commands)) = oa_core::diagram_edit::translate_placements(
|
||||
self.project,
|
||||
self.diagram,
|
||||
&ids,
|
||||
*delta,
|
||||
)
|
||||
{
|
||||
for command in commands {
|
||||
match command {
|
||||
Command::EditNode { node, .. } => {
|
||||
diagram.nodes.insert(node.id, node);
|
||||
}
|
||||
Command::SetEdgeRoute { route, .. } => {
|
||||
crate::route_edit::with_route(&mut diagram, route)
|
||||
}
|
||||
_ => unreachable!("placement planner returned a non-geometry command"),
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(route) = &bend_preview {
|
||||
crate::route_edit::with_route(&mut diagram, route.clone());
|
||||
}
|
||||
@@ -761,6 +1096,7 @@ impl StateCanvas<'_> {
|
||||
});
|
||||
*state.scene.borrow_mut() = Some(CachedScene {
|
||||
context: self.context,
|
||||
group_preview,
|
||||
preview,
|
||||
attachment_preview,
|
||||
bend_preview,
|
||||
@@ -823,13 +1159,19 @@ impl StateCanvas<'_> {
|
||||
.into_iter()
|
||||
.find(|(_, r)| r.contains(world))
|
||||
{
|
||||
return Some(Hit::StateAction(relation.id));
|
||||
return Some(Hit::Row {
|
||||
placement: node.id,
|
||||
relation: relation.id,
|
||||
});
|
||||
}
|
||||
if let Some((relation, _)) = internal_rows(self.project, node)
|
||||
.into_iter()
|
||||
.find(|(_, r)| r.contains(world))
|
||||
{
|
||||
return Some(Hit::Transition(relation.id));
|
||||
return Some(Hit::Row {
|
||||
placement: node.id,
|
||||
relation: relation.id,
|
||||
});
|
||||
}
|
||||
let container = self
|
||||
.project
|
||||
@@ -1148,7 +1490,13 @@ impl StateCanvas<'_> {
|
||||
},
|
||||
_ => match world.and_then(|p| self.hit(scene, p)) {
|
||||
Some(Hit::Transition(id)) => self.project.relations.get(&id).map(|r| format!("{} · {} · right-click to edit", r.name, routing::state_transition_summary(self.project, r))).unwrap_or_default(),
|
||||
Some(Hit::StateAction(id)) => self.project.relations.get(&id).map(|r| format!("{} · select the action link to inspect its referenced work", state_action_label(self.project, r))).unwrap_or_default(),
|
||||
Some(Hit::Row { relation: id, .. }) => self.project.relations.get(&id).map(|r| {
|
||||
if matches!(r.body, RelationBody::StateAction { .. }) {
|
||||
format!("{} · select the action link to inspect its referenced work", state_action_label(self.project, r))
|
||||
} else {
|
||||
format!("{} · {} · right-click to edit", r.name, routing::state_transition_summary(self.project, r))
|
||||
}
|
||||
}).unwrap_or_default(),
|
||||
_ => "Select / move · drag a vertex · drag selected transition endpoints to reconnect · middle-drag to pan".into(),
|
||||
},
|
||||
}
|
||||
@@ -1172,6 +1520,15 @@ impl canvas::Program<Event> for StateCanvas<'_> {
|
||||
bounds: Rectangle,
|
||||
cursor: mouse::Cursor,
|
||||
) -> Option<Action<Event>> {
|
||||
if let canvas::Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) = event {
|
||||
// Passive observation must not capture the event or grant focus.
|
||||
state.modifiers = *modifiers;
|
||||
return None;
|
||||
}
|
||||
let retired_group = state.group.is_some()
|
||||
&& (state.context != Some(self.context)
|
||||
|| state.tool != Some(self.tool)
|
||||
|| !self.editable);
|
||||
if state.context != Some(self.context) || state.tool != Some(self.tool) {
|
||||
if state.context != Some(self.context) {
|
||||
state.viewport = None;
|
||||
@@ -1226,6 +1583,7 @@ impl canvas::Program<Event> for StateCanvas<'_> {
|
||||
cursor
|
||||
}
|
||||
canvas::Event::Window(iced::window::Event::Unfocused) => {
|
||||
state.modifiers = keyboard::Modifiers::default();
|
||||
state.reset();
|
||||
return None;
|
||||
}
|
||||
@@ -1239,6 +1597,14 @@ impl canvas::Program<Event> for StateCanvas<'_> {
|
||||
let window = cursor.position();
|
||||
let local = window.map(|p| Point::new(p.x - bounds.x, p.y - bounds.y));
|
||||
let inside = local.is_some_and(|p| Self::content(bounds).contains(p));
|
||||
if retired_group {
|
||||
// The event which retired a held gesture cannot be reinterpreted as
|
||||
// a fresh keyboard edit against the surviving selection.
|
||||
return Some(Action::request_redraw().and_capture());
|
||||
}
|
||||
if let Some(action) = self.group_input(state, event, bounds, cursor) {
|
||||
return Some(action);
|
||||
}
|
||||
if let canvas::Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. }) = event {
|
||||
use keyboard::{Key, key::Named};
|
||||
if *key == Key::Named(Named::Escape) {
|
||||
@@ -1310,13 +1676,17 @@ impl canvas::Program<Event> for StateCanvas<'_> {
|
||||
// Menu commands must target the committed route instead.
|
||||
let scene = self.scene(state)?;
|
||||
let bend = self.bend_at(&scene, world);
|
||||
let (object, placement) = if bend.is_some() {
|
||||
(self.selected, None)
|
||||
let (object, placement, selection_target) = if bend.is_some() {
|
||||
(self.selected, None, Some(Target::Edge(self.selected)))
|
||||
} else {
|
||||
match self.hit(&scene, world) {
|
||||
Some(Hit::Node { element, placement }) => (element, Some(placement)),
|
||||
Some(Hit::Transition(id) | Hit::StateAction(id)) => (id, None),
|
||||
None => (self.diagram, None),
|
||||
Some(hit @ Hit::Node { element, placement }) => {
|
||||
(element, Some(placement), Some(hit.target()))
|
||||
}
|
||||
Some(hit @ (Hit::Transition(id) | Hit::Row { relation: id, .. })) => {
|
||||
(id, None, Some(hit.target()))
|
||||
}
|
||||
None => (self.diagram, None, None),
|
||||
}
|
||||
};
|
||||
let route_segment = if bend.is_none() {
|
||||
@@ -1329,6 +1699,7 @@ impl canvas::Program<Event> for StateCanvas<'_> {
|
||||
context: self.context,
|
||||
object,
|
||||
placement,
|
||||
selection_target,
|
||||
anchor: window,
|
||||
bend,
|
||||
route_segment,
|
||||
@@ -1336,12 +1707,17 @@ impl canvas::Program<Event> for StateCanvas<'_> {
|
||||
.and_capture(),
|
||||
);
|
||||
}
|
||||
if *button != mouse::Button::Left || !self.editable {
|
||||
if *button != mouse::Button::Left || !self.editable || state.pan_from.is_some() {
|
||||
return None;
|
||||
}
|
||||
match self.tool {
|
||||
Tool::Select => {
|
||||
if let Some((relation, endpoint)) = self.endpoint_at(&scene, world) {
|
||||
let singleton = self
|
||||
.current_selection()
|
||||
.is_none_or(|s| s.targets().len() <= 1);
|
||||
if singleton
|
||||
&& let Some((relation, endpoint)) = self.endpoint_at(&scene, world)
|
||||
{
|
||||
state.press = Some(Press::Reconnect {
|
||||
relation,
|
||||
endpoint,
|
||||
@@ -1349,7 +1725,7 @@ impl canvas::Program<Event> for StateCanvas<'_> {
|
||||
});
|
||||
return Some(Action::capture());
|
||||
}
|
||||
if let Some(index) = self.bend_at(&scene, world) {
|
||||
if singleton && let Some(index) = self.bend_at(&scene, world) {
|
||||
let route = crate::route_edit::stored_route(
|
||||
&self.project.diagrams[&self.diagram],
|
||||
self.selected,
|
||||
@@ -1358,7 +1734,7 @@ impl canvas::Program<Event> for StateCanvas<'_> {
|
||||
return Some(Action::capture());
|
||||
}
|
||||
match self.hit(&scene, world) {
|
||||
Some(Hit::Node { placement, element }) => {
|
||||
Some(hit @ Hit::Node { placement, element }) => {
|
||||
let original = self
|
||||
.project
|
||||
.diagrams
|
||||
@@ -1371,13 +1747,11 @@ impl canvas::Program<Event> for StateCanvas<'_> {
|
||||
start: window,
|
||||
original,
|
||||
});
|
||||
Some(
|
||||
Action::publish(Event::Select(self.context, element))
|
||||
.and_capture(),
|
||||
)
|
||||
Some(Action::publish(self.select_event(hit, element)).and_capture())
|
||||
}
|
||||
Some(Hit::Transition(id) | Hit::StateAction(id)) => {
|
||||
if id == self.selected
|
||||
Some(hit @ (Hit::Transition(id) | Hit::Row { relation: id, .. })) => {
|
||||
if singleton
|
||||
&& id == self.selected
|
||||
&& let Some(edge) = scene.routed.edges.get(&id)
|
||||
&& !Rectangle::new(
|
||||
point(edge.label.bounds.position),
|
||||
@@ -1407,7 +1781,7 @@ impl canvas::Program<Event> for StateCanvas<'_> {
|
||||
return Some(Action::capture());
|
||||
}
|
||||
}
|
||||
Some(Action::publish(Event::Select(self.context, id)).and_capture())
|
||||
Some(Action::publish(self.select_event(hit, id)).and_capture())
|
||||
}
|
||||
None => {
|
||||
state.pan_from = Some((mouse::Button::Left, window));
|
||||
@@ -1643,6 +2017,10 @@ impl canvas::Program<Event> for StateCanvas<'_> {
|
||||
self.gesture_active(state) && self.route_viewport_current(state, Some(bounds));
|
||||
let key = PaintKey {
|
||||
context: self.context,
|
||||
group_preview: self.group_preview_key(state, Some(bounds)),
|
||||
selection: self
|
||||
.current_selection()
|
||||
.and_then(|s| s.snapshot(selection_context(self.context))),
|
||||
preview: self
|
||||
.gesture_active(state)
|
||||
.then_some(state.preview)
|
||||
@@ -1671,23 +2049,40 @@ impl canvas::Program<Event> for StateCanvas<'_> {
|
||||
frame.translate(self.offset());
|
||||
frame.scale(self.scale());
|
||||
for node in &scene.view.nodes {
|
||||
let selected =
|
||||
self.current_selection()
|
||||
.map_or(self.selected, |s| match s.primary() {
|
||||
Some(Target::Placement(id)) if *id == node.id => node.element,
|
||||
Some(Target::StateRow {
|
||||
placement,
|
||||
relation,
|
||||
}) if *placement == node.id => *relation,
|
||||
_ => Id::nil(),
|
||||
});
|
||||
paint_node(
|
||||
frame,
|
||||
self.project,
|
||||
node,
|
||||
self.selected,
|
||||
selected,
|
||||
scene.symbol_labels.get(&node.id),
|
||||
);
|
||||
}
|
||||
// Containers are already painted. Route bodies have shared
|
||||
// clearance from state headers and internal-transition rows.
|
||||
for edge in scene.routed.edges.values() {
|
||||
let active = edge.id == self.selected;
|
||||
let active = self
|
||||
.current_selection()
|
||||
.map_or(edge.id == self.selected, |s| {
|
||||
s.contains(&Target::Edge(edge.id))
|
||||
});
|
||||
let primary = self
|
||||
.current_selection()
|
||||
.map_or(active, |s| s.primary() == Some(&Target::Edge(edge.id)));
|
||||
let color = if active { style::TEAL } else { style::INK };
|
||||
frame.stroke(
|
||||
&polyline(&edge.points),
|
||||
Stroke::default().with_color(color).with_width(if active {
|
||||
2.4
|
||||
if primary { 3.2 } else { 2.0 }
|
||||
} else {
|
||||
1.4
|
||||
}),
|
||||
@@ -1727,7 +2122,11 @@ impl canvas::Program<Event> for StateCanvas<'_> {
|
||||
10.,
|
||||
style::MUTED,
|
||||
);
|
||||
if active {
|
||||
if active
|
||||
&& self
|
||||
.current_selection()
|
||||
.is_none_or(|s| s.targets().len() <= 1)
|
||||
{
|
||||
if !edge.custom_suspended && self.tool == Tool::Select {
|
||||
for pin in &edge.pins {
|
||||
let radius = 4. / self.scale();
|
||||
@@ -1758,6 +2157,42 @@ impl canvas::Program<Event> for StateCanvas<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(selection) = self.current_selection() {
|
||||
for target in selection.targets() {
|
||||
let rect = match target {
|
||||
Target::Placement(id) => {
|
||||
scene.view.nodes.iter().find(|n| n.id == *id).map(rectangle)
|
||||
}
|
||||
Target::StateRow {
|
||||
placement,
|
||||
relation,
|
||||
} => scene
|
||||
.view
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|n| n.id == *placement)
|
||||
.and_then(|node| {
|
||||
state_action_rows(self.project, node)
|
||||
.into_iter()
|
||||
.chain(internal_rows(self.project, node))
|
||||
.find(|(row, _)| row.id == *relation)
|
||||
.map(|(_, rect)| rect)
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(rect) = rect {
|
||||
let primary = selection.primary() == Some(target);
|
||||
let rect =
|
||||
expanded(rect, if primary { 3. } else { 1.5 } / self.scale());
|
||||
frame.stroke(
|
||||
&Path::rectangle(rect.position(), rect.size()),
|
||||
Stroke::default()
|
||||
.with_color(style::TEAL)
|
||||
.with_width(if primary { 3. } else { 1.5 } / self.scale()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
let mut overlay = Frame::new(renderer, bounds.size());
|
||||
@@ -2159,6 +2594,7 @@ mod tests {
|
||||
use crate::document::Document;
|
||||
|
||||
include!("state_bend_tests.rs");
|
||||
include!("state_multiselect_canvas_tests.rs");
|
||||
|
||||
struct Fixture {
|
||||
project: Project,
|
||||
@@ -2313,7 +2749,10 @@ mod tests {
|
||||
let scene = canvas.scene(&State::default()).unwrap();
|
||||
assert_eq!(
|
||||
canvas.hit(&scene, row.bounds.center()),
|
||||
Some(Hit::StateAction(action))
|
||||
Some(Hit::Row {
|
||||
placement,
|
||||
relation: action
|
||||
})
|
||||
);
|
||||
assert_eq!(f.project, before);
|
||||
f.project
|
||||
@@ -2428,6 +2867,7 @@ mod tests {
|
||||
epoch: 3,
|
||||
},
|
||||
selected: f.machine,
|
||||
selection: None,
|
||||
navigation_focus: None,
|
||||
tool,
|
||||
zoom,
|
||||
@@ -3246,7 +3686,10 @@ mod tests {
|
||||
for (relation, row) in rows {
|
||||
assert_eq!(
|
||||
canvas.hit(&scene, row.center()),
|
||||
Some(Hit::StateAction(relation.id))
|
||||
Some(Hit::Row {
|
||||
placement: a.id,
|
||||
relation: relation.id
|
||||
})
|
||||
);
|
||||
let events = click(&canvas, &mut State::default(), row.center());
|
||||
assert!(
|
||||
@@ -3307,7 +3750,10 @@ mod tests {
|
||||
for (relation, rect) in rows {
|
||||
assert_eq!(
|
||||
canvas.hit(&scene, rect.center()),
|
||||
Some(Hit::Transition(relation.id))
|
||||
Some(Hit::Row {
|
||||
placement: a.id,
|
||||
relation: relation.id
|
||||
})
|
||||
);
|
||||
}
|
||||
let initial = scene
|
||||
|
||||
@@ -25,6 +25,7 @@ pub enum Tool {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
Selection(crate::app::multiselect::Event),
|
||||
Remove(crate::app::diagram_removal::Intent),
|
||||
Choose(Tool),
|
||||
CreateView(Id),
|
||||
@@ -38,6 +39,7 @@ pub enum Event {
|
||||
context: Context,
|
||||
object: Id,
|
||||
placement: Option<Id>,
|
||||
selection_target: Option<crate::diagram_usage::Target>,
|
||||
anchor: iced::Point,
|
||||
bend: Option<usize>,
|
||||
route_segment: Option<(usize, Position)>,
|
||||
@@ -101,7 +103,8 @@ impl Event {
|
||||
pub fn context(&self) -> Option<Context> {
|
||||
match self {
|
||||
Self::Remove(intent) => Some(intent.context),
|
||||
Self::Choose(_) | Self::CreateView(_) => None,
|
||||
// Group events carry their own exact selection envelope.
|
||||
Self::Selection(_) | Self::Choose(_) | Self::CreateView(_) => None,
|
||||
Self::Select(c, _)
|
||||
| Self::Pan(c, _)
|
||||
| Self::Zoom(c, _)
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
// Included in state_canvas::tests. Drive production Program input and projection.
|
||||
|
||||
#[test]
|
||||
fn state_singleton_selection_bend_uses_five_unit_grid_and_escape_keeps_selection() {
|
||||
for zoom in [0.5, 1., 2.] {
|
||||
for cancel in [false, true] {
|
||||
let (f, relation) = bend_fixture(true);
|
||||
let original = f.project.diagrams[&f.diagram].routes[0].clone();
|
||||
let mut canvas = canvas(&f, Tool::Select, zoom);
|
||||
canvas.selected = relation;
|
||||
let selection = group_selection(&canvas, [Target::Edge(relation)]);
|
||||
let snapshot = selection.snapshot(selection_context(canvas.context)).unwrap();
|
||||
canvas.selection = Some(&selection);
|
||||
let mut state = State::default();
|
||||
let baseline = canvas.scene(&state).unwrap();
|
||||
let start = screen(&canvas, point(original.waypoints[0]));
|
||||
// A fractional pointer target distinguishes route snapping from the
|
||||
// unsnapped State placement policy, at every supported test zoom.
|
||||
let release = screen(&canvas, Point::new(683.75, 362.25));
|
||||
let expected_pin = Position { x: 685., y: 360. };
|
||||
motion(&canvas, &mut state, start, release);
|
||||
assert!(input(&canvas, &mut state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left), release).is_none());
|
||||
assert!(matches!(state.press, Some(Press::Bend { relation: id }) if id == relation));
|
||||
assert!(state.group.is_none());
|
||||
motion(&canvas, &mut state, release, release);
|
||||
let mut expected_route = original.clone();
|
||||
expected_route.waypoints[0] = expected_pin;
|
||||
assert_eq!(state.bend_preview.as_ref(), Some(&expected_route));
|
||||
assert_eq!(canvas.scene(&state).unwrap().routed.edges[&relation].pins, vec![expected_pin]);
|
||||
assert_eq!(f.project.diagrams[&f.diagram].routes[0], original);
|
||||
if cancel {
|
||||
let escape = group_key(&canvas, &mut state, keyboard::key::Named::Escape,
|
||||
keyboard::Modifiers::empty());
|
||||
assert!(matches!(escape, Some(Event::Choose(Tool::Select))),
|
||||
"Escape cancels the bend before it can emit selection Clear: {escape:?}");
|
||||
assert!(state.press.is_none() && state.bend.is_none() && state.bend_preview.is_none());
|
||||
assert_eq!(canvas.scene(&state).unwrap().routed.edges[&relation].points,
|
||||
baseline.routed.edges[&relation].points);
|
||||
assert!(input(&canvas, &mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left), release).is_none());
|
||||
} else {
|
||||
let event = input(&canvas, &mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left), release).unwrap();
|
||||
assert!(matches!(&event, Event::SetBends { context, relation: id, waypoints }
|
||||
if *context == canvas.context && *id == relation && *waypoints == vec![expected_pin]));
|
||||
let plan = state_edit::plan(&f.project, canvas.context, &event).unwrap();
|
||||
let actual = prepare_command(&f.project, &plan.command).unwrap().project;
|
||||
let mut expected = f.project.clone();
|
||||
expected.diagrams.get_mut(&f.diagram).unwrap().routes[0] = expected_route;
|
||||
assert_eq!(actual, expected);
|
||||
}
|
||||
assert_eq!(selection.snapshot(selection_context(canvas.context)).unwrap(), snapshot);
|
||||
assert!(state.group.is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_singleton_selection_endpoint_reconnect_stationary_cancel_and_commit() {
|
||||
for outcome in 0..3 {
|
||||
let mut f = fixture();
|
||||
let relation = f.project.relate(RelationKind::StateTransition, "Go standby", f.a, f.b);
|
||||
let mut canvas = canvas(&f, Tool::Select, 1.);
|
||||
canvas.selected = relation;
|
||||
let selection = group_selection(&canvas, [Target::Edge(relation)]);
|
||||
let snapshot = selection.snapshot(selection_context(canvas.context)).unwrap();
|
||||
canvas.selection = Some(&selection);
|
||||
let mut state = State::default();
|
||||
let baseline = canvas.scene(&state).unwrap();
|
||||
let handle = screen(&canvas, point(*baseline.routed.edges[&relation].points.last().unwrap()));
|
||||
let target = screen(&canvas, header(&canvas, f.c));
|
||||
motion(&canvas, &mut state, handle, target);
|
||||
assert!(input(&canvas, &mut state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left), target).is_none());
|
||||
assert!(matches!(state.press,
|
||||
Some(Press::Reconnect { relation: id, endpoint: Endpoint::Target, start: Some(at) })
|
||||
if id == relation && at == handle));
|
||||
assert!(state.group.is_none() && state.bend.is_none());
|
||||
if outcome > 0 { motion(&canvas, &mut state, target, target); }
|
||||
if outcome == 1 {
|
||||
let escape = group_key(&canvas, &mut state, keyboard::key::Named::Escape,
|
||||
keyboard::Modifiers::empty());
|
||||
assert!(matches!(escape, Some(Event::Choose(Tool::Select))),
|
||||
"Escape cancels reconnect without clearing the exact edge: {escape:?}");
|
||||
assert!(state.press.is_none());
|
||||
}
|
||||
let event = input(&canvas, &mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left), target);
|
||||
if outcome < 2 {
|
||||
assert!(event.is_none(), "stationary/cancelled reconnect produced {event:?}");
|
||||
} else {
|
||||
let event = event.unwrap();
|
||||
assert!(matches!(&event, Event::Reconnect { context, relation: id, endpoint: Endpoint::Target, vertex }
|
||||
if *context == canvas.context && *id == relation && *vertex == f.c));
|
||||
let plan = state_edit::plan(&f.project, canvas.context, &event).unwrap();
|
||||
let actual = prepare_command(&f.project, &plan.command).unwrap().project;
|
||||
assert_eq!(plan.selected, relation);
|
||||
assert_eq!(actual.relations[&relation].source(), f.a);
|
||||
assert_eq!(actual.relations[&relation].target(), f.c);
|
||||
assert_eq!(actual.elements, f.project.elements);
|
||||
}
|
||||
assert_eq!(f.project.relations[&relation].target(), f.b);
|
||||
assert_eq!(selection.snapshot(selection_context(canvas.context)).unwrap(), snapshot);
|
||||
assert!(state.group.is_none() && state.press.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_singleton_selection_shift_handles_toggle_exact_edge_before_route_tools() {
|
||||
let (f, relation) = bend_fixture(true);
|
||||
let mut canvas = canvas(&f, Tool::Select, 1.);
|
||||
canvas.selected = relation;
|
||||
let selection = group_selection(&canvas, [Target::Edge(relation)]);
|
||||
canvas.selection = Some(&selection);
|
||||
let baseline = canvas.scene(&State::default()).unwrap();
|
||||
let edge = &baseline.routed.edges[&relation];
|
||||
for world in [edge.pins[0], *edge.points.last().unwrap()] {
|
||||
let mut state = State::default();
|
||||
assert!(canvas::Program::update(&canvas, &mut state,
|
||||
&canvas::Event::Keyboard(keyboard::Event::ModifiersChanged(keyboard::Modifiers::SHIFT)),
|
||||
bounds(), mouse::Cursor::Unavailable).is_none());
|
||||
let at = screen(&canvas, point(world));
|
||||
motion(&canvas, &mut state, at, at);
|
||||
assert!(matches!(input(&canvas, &mut state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left), at),
|
||||
Some(Event::Selection(SelectionEvent::Choose(context, Target::Edge(id), Choice::Toggle)))
|
||||
if context == selection_context(canvas.context) && id == relation));
|
||||
assert!(state.group.is_none() && state.press.is_none() && state.bend.is_none());
|
||||
let away = at + Vector::new(35., 20.);
|
||||
motion(&canvas, &mut state, away, away);
|
||||
assert!(input(&canvas, &mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left), away).is_none());
|
||||
assert!(state.bend_preview.is_none());
|
||||
assert_eq!(canvas.scene(&state).unwrap().routed.edges[&relation].points, edge.points);
|
||||
}
|
||||
}
|
||||
|
||||
fn group_selection(canvas: &StateCanvas<'_>, targets: impl IntoIterator<Item = Target>) -> Selection {
|
||||
let mut selection = Selection::new(selection_context(canvas.context).scope);
|
||||
for target in targets { selection.add(target, |_| true); }
|
||||
selection
|
||||
}
|
||||
|
||||
fn group_key(canvas: &StateCanvas<'_>, state: &mut State, key: keyboard::key::Named,
|
||||
modifiers: keyboard::Modifiers) -> Option<Event>
|
||||
{
|
||||
canvas::Program::update(canvas, state, &canvas::Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Named(key), modified_key: keyboard::Key::Named(key),
|
||||
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Delete),
|
||||
location: keyboard::Location::Standard, modifiers, text: None, repeat: false,
|
||||
}), bounds(), mouse::Cursor::Unavailable).and_then(|action| action.into_inner().0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_group_ordered_press_preview_and_fractional_release_preserve_primary() {
|
||||
let (f, relation) = bend_fixture(true);
|
||||
for zoom in [0.5, 1., 2.] {
|
||||
let mut canvas = canvas(&f, Tool::Select, zoom);
|
||||
let b = placement(&f, f.b);
|
||||
let c = placement(&f, f.c);
|
||||
let selection = group_selection(&canvas, [Target::Placement(b), Target::Placement(c)]);
|
||||
canvas.selection = Some(&selection);
|
||||
canvas.selected = f.c;
|
||||
let before = selection.snapshot(selection_context(canvas.context)).unwrap();
|
||||
let mut state = State::default();
|
||||
let start = screen(&canvas, header(&canvas, f.b));
|
||||
let end = start + Vector::new(7.25 * zoom, 10.5 * zoom);
|
||||
motion(&canvas, &mut state, start, end);
|
||||
assert!(input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), end).is_none());
|
||||
assert!(state.group.is_some() && state.press.is_none());
|
||||
assert_eq!(selection.primary(), Some(&Target::Placement(c)));
|
||||
motion(&canvas, &mut state, end, end);
|
||||
let scene = canvas.scene_with_bounds(&state, Some(bounds())).unwrap();
|
||||
for id in [b, c] {
|
||||
let node = scene.view.nodes.iter().find(|n| n.id == id).unwrap();
|
||||
let original = &f.project.diagrams[&f.diagram].nodes[&id];
|
||||
assert_eq!(node.position, Position { x: original.position.x + 7.25, y: original.position.y + 10.5 });
|
||||
}
|
||||
assert_eq!(scene.routed.edges[&relation].pins, vec![Position { x: 657.5, y: 351.25 }]);
|
||||
assert!(matches!(input(&canvas, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), start),
|
||||
Some(Event::Selection(SelectionEvent::Move(snapshot, Position { x: 7.25, y: 10.5 }))) if snapshot == before));
|
||||
assert_eq!(selection.snapshot(selection_context(canvas.context)).unwrap(), before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_group_stationary_collapses_but_out_and_back_keeps_group() {
|
||||
let f = fixture();
|
||||
let mut canvas = canvas(&f, Tool::Select, 1.);
|
||||
let a = placement(&f, f.a);
|
||||
let b = placement(&f, f.b);
|
||||
let selection = group_selection(&canvas, [Target::Placement(a), Target::Placement(b)]);
|
||||
canvas.selection = Some(&selection);
|
||||
let start = screen(&canvas, header(&canvas, f.a));
|
||||
for dragged in [false, true] {
|
||||
let mut state = State::default();
|
||||
motion(&canvas, &mut state, start, start);
|
||||
assert!(input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), start).is_none());
|
||||
if dragged {
|
||||
motion(&canvas, &mut state, start + Vector::new(20., 0.), start);
|
||||
motion(&canvas, &mut state, start, start);
|
||||
}
|
||||
let event = input(&canvas, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), start);
|
||||
if dragged {
|
||||
assert!(matches!(event, Some(Event::Selection(SelectionEvent::Move(_, Position { x: 0., y: 0. })))));
|
||||
} else {
|
||||
assert!(matches!(event, Some(Event::Selection(SelectionEvent::Collapse(_, Target::Placement(id)))) if id == a));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_group_changed_authority_viewport_or_membership_retires_preview_and_release() {
|
||||
let f = fixture();
|
||||
for change in 0..7 {
|
||||
let mut canvas = canvas(&f, Tool::Select, 1.);
|
||||
let a = placement(&f, f.a);
|
||||
let b = placement(&f, f.b);
|
||||
let selection = group_selection(&canvas, [Target::Placement(a), Target::Placement(b)]);
|
||||
let mut replacement = group_selection(&canvas, [Target::Placement(a), Target::Placement(b)]);
|
||||
replacement.remove(&Target::Placement(a));
|
||||
replacement.add(Target::Placement(a), |_| true);
|
||||
canvas.selection = Some(&selection);
|
||||
let mut state = State::default();
|
||||
let start = screen(&canvas, header(&canvas, f.a));
|
||||
motion(&canvas, &mut state, start, start);
|
||||
input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), start);
|
||||
motion(&canvas, &mut state, start + Vector::new(12., 8.), start);
|
||||
assert!(canvas.group_preview_key(&state, Some(bounds())).is_some());
|
||||
let mut changed_bounds = bounds();
|
||||
match change {
|
||||
0 => canvas.context.revision += 1,
|
||||
1 => canvas.context.epoch += 1,
|
||||
2 => canvas.context.document = Id::new_v4(),
|
||||
3 => canvas.zoom = 1.1,
|
||||
4 => canvas.pan.x += 1.,
|
||||
5 => changed_bounds.x += 1.,
|
||||
_ => canvas.selection = Some(&replacement),
|
||||
}
|
||||
assert!(canvas.group_preview_key(&state, Some(changed_bounds)).is_none());
|
||||
if change < 3 {
|
||||
assert!(group_key(&canvas, &mut state, keyboard::key::Named::Delete, keyboard::Modifiers::empty()).is_none());
|
||||
}
|
||||
assert!(input_at_bounds(&canvas, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), start, changed_bounds).is_none());
|
||||
assert!(state.group.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_group_mixed_route_handle_refuses_drag_and_preserves_stationary_collapse() {
|
||||
let (f, relation) = bend_fixture(true);
|
||||
let mut canvas = canvas(&f, Tool::Select, 1.);
|
||||
let selection = group_selection(&canvas, [Target::Placement(placement(&f, f.b)), Target::Edge(relation)]);
|
||||
canvas.selection = Some(&selection);
|
||||
canvas.selected = relation;
|
||||
let scene = canvas.scene(&State::default()).unwrap();
|
||||
let at = screen(&canvas, point(scene.routed.edges[&relation].pins[0]));
|
||||
for dragged in [false, true] {
|
||||
let mut state = State::default();
|
||||
motion(&canvas, &mut state, at, at);
|
||||
input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), at);
|
||||
assert!(state.group.is_some() && state.bend.is_none() && state.press.is_none());
|
||||
if dragged {
|
||||
motion(&canvas, &mut state, at + Vector::new(25., 10.), at);
|
||||
motion(&canvas, &mut state, at, at);
|
||||
}
|
||||
let event = input(&canvas, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), at);
|
||||
if dragged {
|
||||
assert!(matches!(event, Some(Event::Selection(SelectionEvent::Refuse(_, _)))));
|
||||
} else {
|
||||
assert!(matches!(event, Some(Event::Selection(SelectionEvent::Collapse(_, Target::Edge(id)))) if id == relation));
|
||||
}
|
||||
assert!(state.bend_preview.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_group_shift_row_hit_menu_and_reassignment_keep_exact_identity() {
|
||||
let mut f = fixture();
|
||||
let work = f.project.add_element(Kind::Function, Layer::Sa, "Action work", None).unwrap();
|
||||
let action = f.project.relate(RelationKind::StateAction, "Entry", f.a, work);
|
||||
let internal = f.project.relate(RelationKind::StateTransition, "Internal", f.a, f.a);
|
||||
if let RelationBody::StateTransition { transition_kind, .. } = &mut f.project.relations.get_mut(&internal).unwrap().body {
|
||||
*transition_kind = TransitionKind::Internal;
|
||||
}
|
||||
let a = placement(&f, f.a);
|
||||
for relation in [action, internal] {
|
||||
let mut canvas = canvas(&f, Tool::Select, 1.);
|
||||
let selection = group_selection(&canvas, [Target::Placement(placement(&f, f.b))]);
|
||||
canvas.selection = Some(&selection);
|
||||
let scene = canvas.scene(&State::default()).unwrap();
|
||||
let node = scene.view.nodes.iter().find(|n| n.id == a).unwrap();
|
||||
let rect = state_action_rows(&f.project, node).into_iter().chain(internal_rows(&f.project, node))
|
||||
.find(|(row, _)| row.id == relation).unwrap().1;
|
||||
assert!(visible_row_target(&f.project, f.diagram, &scene.view, a, relation));
|
||||
let target = Target::StateRow { placement: a, relation };
|
||||
assert_eq!(canvas.hit(&scene, rect.center()).unwrap().target(), target);
|
||||
let mut state = State::default();
|
||||
let modifiers = canvas::Program::update(&canvas, &mut state,
|
||||
&canvas::Event::Keyboard(keyboard::Event::ModifiersChanged(keyboard::Modifiers::SHIFT)),
|
||||
bounds(), mouse::Cursor::Unavailable);
|
||||
assert!(modifiers.is_none());
|
||||
let at = screen(&canvas, rect.center());
|
||||
motion(&canvas, &mut state, at, at);
|
||||
assert!(matches!(input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), at),
|
||||
Some(Event::Selection(SelectionEvent::Choose(_, actual, Choice::Toggle))) if actual == target));
|
||||
assert!(matches!(input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Right), at),
|
||||
Some(Event::ContextMenu { object, placement: None, selection_target: Some(actual), .. }) if object == relation && actual == target));
|
||||
canvas::Program::update(&canvas, &mut state, &canvas::Event::Window(iced::window::Event::Unfocused), bounds(), mouse::Cursor::Unavailable);
|
||||
assert!(state.modifiers.is_empty());
|
||||
}
|
||||
if let RelationBody::StateAction { source, .. } = &mut f.project.relations.get_mut(&action).unwrap().body { *source = f.b; }
|
||||
if let RelationBody::StateTransition { source, target, .. } = &mut f.project.relations.get_mut(&internal).unwrap().body { *source = f.b; *target = f.b; }
|
||||
let view = projection::project_diagram(&f.project, &f.project.diagrams[&f.diagram]);
|
||||
for relation in [action, internal] {
|
||||
assert!(!visible_row_target(&f.project, f.diagram, &view, a, relation));
|
||||
assert!(visible_row_target(&f.project, f.diagram, &view, placement(&f, f.b), relation));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_group_delete_and_escape_respect_held_gesture_and_full_set() {
|
||||
let f = fixture();
|
||||
let mut canvas = canvas(&f, Tool::Select, 1.);
|
||||
let selection = group_selection(&canvas, [Target::Placement(placement(&f, f.a)), Target::Placement(placement(&f, f.b))]);
|
||||
canvas.selection = Some(&selection);
|
||||
let mut state = State::default();
|
||||
use keyboard::key::Named;
|
||||
assert!(matches!(group_key(&canvas, &mut state, Named::Delete, keyboard::Modifiers::empty()), Some(Event::Selection(SelectionEvent::Hide(_)))));
|
||||
assert!(matches!(group_key(&canvas, &mut state, Named::Delete, keyboard::Modifiers::SHIFT), Some(Event::Selection(SelectionEvent::Refuse(_, _)))));
|
||||
let at = screen(&canvas, header(&canvas, f.a));
|
||||
motion(&canvas, &mut state, at, at);
|
||||
input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), at);
|
||||
assert!(group_key(&canvas, &mut state, Named::Delete, keyboard::Modifiers::empty()).is_none());
|
||||
assert!(state.group.is_some());
|
||||
assert!(group_key(&canvas, &mut state, Named::Escape, keyboard::Modifiers::empty()).is_none());
|
||||
assert!(state.group.is_none());
|
||||
assert!(matches!(group_key(&canvas, &mut state, Named::Escape, keyboard::Modifiers::empty()), Some(Event::Selection(SelectionEvent::Clear(_)))));
|
||||
input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Middle), at);
|
||||
input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), at);
|
||||
assert!(state.group.is_none() && state.press.is_none() && state.pan_from.is_some());
|
||||
assert!(matches!(motion(&canvas, &mut state, at + Vector::new(20., 10.), at), Some(Event::Pan(_, _))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_group_entry_anchor_uses_authored_position_with_unsnapped_delta() {
|
||||
let mut f = fixture();
|
||||
let inner = f.project.elements[&f.leaf].parent.unwrap();
|
||||
let entry = f.project.add_element(Kind::EntryPoint, Layer::Sa, "Enter", Some(inner)).unwrap();
|
||||
let original = Position { x: 450.25, y: 410.75 };
|
||||
let entry_node = f.project.place(f.diagram, entry, original);
|
||||
let mut canvas = canvas(&f, Tool::Select, 1.);
|
||||
let selection = group_selection(&canvas, [Target::Placement(entry_node), Target::Placement(placement(&f, f.leaf))]);
|
||||
canvas.selection = Some(&selection);
|
||||
let scene = canvas.scene(&State::default()).unwrap();
|
||||
let shown = scene.view.nodes.iter().find(|n| n.id == entry_node).unwrap();
|
||||
assert_ne!(shown.position.x, original.x);
|
||||
let at = screen(&canvas, rectangle(shown).center());
|
||||
let mut state = State::default();
|
||||
motion(&canvas, &mut state, at, at);
|
||||
input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), at);
|
||||
assert!(state.group.is_some());
|
||||
let end = at + Vector::new(7.25, 8.5);
|
||||
motion(&canvas, &mut state, end, end);
|
||||
assert!(matches!(input(&canvas, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), end),
|
||||
Some(Event::Selection(SelectionEvent::Move(_, Position { x: 7.25, y: 8.5 })))));
|
||||
assert_eq!(f.project.diagrams[&f.diagram].nodes[&entry_node].position, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_group_row_and_port_mixtures_refuse_complete_set_without_owner_promotion() {
|
||||
let mut f = fixture();
|
||||
let work = f.project.add_element(Kind::Function, Layer::Sa, "Action work", None).unwrap();
|
||||
let action = f.project.relate(RelationKind::StateAction, "Entry", f.a, work);
|
||||
let a = placement(&f, f.a);
|
||||
for other in [Target::StateRow { placement: a, relation: action }, Target::Port { placement: a, port: work }] {
|
||||
let mut canvas = canvas(&f, Tool::Select, 1.);
|
||||
let selection = group_selection(&canvas, [Target::Placement(a), other]);
|
||||
canvas.selection = Some(&selection);
|
||||
let mut state = State::default();
|
||||
assert!(matches!(group_key(&canvas, &mut state, keyboard::key::Named::Delete, keyboard::Modifiers::empty()),
|
||||
Some(Event::Selection(SelectionEvent::Refuse(_, _)))));
|
||||
let at = screen(&canvas, header(&canvas, f.a));
|
||||
motion(&canvas, &mut state, at, at);
|
||||
input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), at);
|
||||
assert!(state.group.as_ref().is_some_and(|g| g.movement.is_none()));
|
||||
let end = at + Vector::new(12., 8.);
|
||||
motion(&canvas, &mut state, end, end);
|
||||
assert!(canvas.group_preview_key(&state, Some(bounds())).is_none());
|
||||
assert!(matches!(input(&canvas, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), end),
|
||||
Some(Event::Selection(SelectionEvent::Refuse(snapshot, _))) if snapshot.targets().len() == 2));
|
||||
assert!(state.press.is_none() && state.preview.is_none());
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,10 @@ pub fn diagram(app: &App, machine: Id) -> Option<Id> {
|
||||
}
|
||||
|
||||
fn event(e: Event) -> Message {
|
||||
Message::StateDiagram(e)
|
||||
match e {
|
||||
Event::Selection(selection) => Message::DiagramSelection(selection),
|
||||
other => Message::StateDiagram(other),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn view<'a>(app: &'a App, machine: Id, mut out: Column<'a, Message>) -> Element<'a, Message> {
|
||||
@@ -143,8 +146,9 @@ pub fn view<'a>(app: &'a App, machine: Id, mut out: Column<'a, Message>) -> Elem
|
||||
.collect::<Vec<_>>();
|
||||
// Keep the optional picker in its own stable slot. Inserting it as a
|
||||
// sibling before the canvas would replace the positional Iced focus tree.
|
||||
let missing_spacing = if missing.is_empty() { 0.0_f32 } else { 8.0 };
|
||||
let mut missing_controls = Column::new();
|
||||
// Reserve the picker height even when all elements are placed. Group Hide
|
||||
// and Undo must not resize the canvas and silently refit its camera.
|
||||
let mut missing_controls = Column::new().height(34);
|
||||
if !missing.is_empty() {
|
||||
let position = Position {
|
||||
x: (48. - state.pan.x) / state.zoom,
|
||||
@@ -169,6 +173,7 @@ pub fn view<'a>(app: &'a App, machine: Id, mut out: Column<'a, Message>) -> Elem
|
||||
diagram: id,
|
||||
context,
|
||||
selected: app.selected,
|
||||
selection: app.diagram_selection.as_ref(),
|
||||
navigation_focus: app
|
||||
.diagram_focus(crate::diagram_usage::Surface::Saved(id))
|
||||
.map(|usage| usage.bounds),
|
||||
@@ -183,6 +188,7 @@ pub fn view<'a>(app: &'a App, machine: Id, mut out: Column<'a, Message>) -> Elem
|
||||
out = out.push(
|
||||
column![
|
||||
missing_controls,
|
||||
selection_controls(app),
|
||||
crate::diagram::focus::wrap(
|
||||
canvas.map(event),
|
||||
context,
|
||||
@@ -191,7 +197,7 @@ pub fn view<'a>(app: &'a App, machine: Id, mut out: Column<'a, Message>) -> Elem
|
||||
Message::DiagramTraverse,
|
||||
)
|
||||
]
|
||||
.spacing(missing_spacing)
|
||||
.spacing(8)
|
||||
.height(Fill),
|
||||
);
|
||||
let base: Element<'_, Message> = container(out.spacing(8))
|
||||
@@ -202,6 +208,47 @@ pub fn view<'a>(app: &'a App, machine: Id, mut out: Column<'a, Message>) -> Elem
|
||||
with_form(app, machine, base)
|
||||
}
|
||||
|
||||
fn selection_controls(app: &App) -> Element<'_, Message> {
|
||||
use crate::app::multiselect::{Event as SelectionEvent, placements};
|
||||
let snapshot = app.multiselect_snapshot();
|
||||
let count = snapshot.as_ref().map_or(0, |s| s.targets().len());
|
||||
let mut controls = row![label(format!("{count} selected"))]
|
||||
.spacing(7)
|
||||
.align_y(iced::Center)
|
||||
.height(32);
|
||||
if let Some(snapshot) = snapshot.filter(|s| s.targets().len() > 1) {
|
||||
if placements(&snapshot).is_some() {
|
||||
controls = controls.push(action(
|
||||
"Hide selected",
|
||||
Message::DiagramSelection(SelectionEvent::Hide(snapshot.clone())),
|
||||
));
|
||||
}
|
||||
controls = controls
|
||||
.push(action(
|
||||
"Clear selection",
|
||||
Message::DiagramSelection(SelectionEvent::Clear(snapshot.context())),
|
||||
))
|
||||
.push(
|
||||
label(format!(
|
||||
"Properties edit only {}",
|
||||
crate::views::short(app.project().named(app.selected), 18)
|
||||
))
|
||||
.wrapping(iced::widget::text::Wrapping::None),
|
||||
);
|
||||
}
|
||||
let hint = if app.behavior.diagram.tool == Tool::Select {
|
||||
"Shift+click adds or removes from selection"
|
||||
} else {
|
||||
"Use Select / move to select multiple elements"
|
||||
};
|
||||
column![
|
||||
controls,
|
||||
label(hint).wrapping(iced::widget::text::Wrapping::None)
|
||||
]
|
||||
.height(56)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn transition_details(machine: Id, selected: Id) -> Element<'static, Message> {
|
||||
action(
|
||||
"New transition with details…",
|
||||
|
||||
@@ -39,12 +39,17 @@ mod port_labels;
|
||||
pub use port_labels::PortLabels;
|
||||
#[path = "diagram/focus.rs"]
|
||||
pub(crate) mod focus;
|
||||
pub(crate) mod group_gesture;
|
||||
mod multiselect;
|
||||
#[cfg(test)]
|
||||
mod multiselect_tests;
|
||||
#[path = "diagram/work_drag.rs"]
|
||||
mod work_drag;
|
||||
pub use work_drag::WorkFeedback;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct DiagramCanvas<'a> {
|
||||
pub selection: Option<&'a crate::diagram_selection::Selection>,
|
||||
pub allocation: Option<crate::app::allocation_drag::Capability<'a>>,
|
||||
pub project: &'a Project,
|
||||
pub view_index: Option<&'a oa_core::view_options::ViewIndex>,
|
||||
@@ -63,6 +68,8 @@ pub struct DiagramCanvas<'a> {
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
modifiers: iced::keyboard::Modifiers,
|
||||
group: Option<multiselect::Press>,
|
||||
work: Option<Arc<crate::app::allocation_drag::Press>>,
|
||||
work_cache: RefCell<Option<Arc<Scene>>>,
|
||||
pointer: Option<Point>,
|
||||
@@ -462,6 +469,7 @@ impl DiagramCanvas<'_> {
|
||||
}
|
||||
fn preview_diagram(&self, state: &State) -> Diagram {
|
||||
let mut diagram = self.project.diagrams[&self.diagram].clone();
|
||||
self.group_preview(state, &mut diagram);
|
||||
if let (Some((id, _, original)), Some(preview)) = (state.drag, state.preview) {
|
||||
let delta = Position {
|
||||
x: preview.x - original.x,
|
||||
@@ -604,6 +612,13 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
bounds: Rectangle,
|
||||
cursor: mouse::Cursor,
|
||||
) -> Option<Action<Message>> {
|
||||
if let canvas::Event::Keyboard(iced::keyboard::Event::ModifiersChanged(modifiers)) = event {
|
||||
state.modifiers = *modifiers;
|
||||
return None;
|
||||
}
|
||||
if matches!(event, canvas::Event::Window(iced::window::Event::Unfocused)) {
|
||||
state.modifiers = iced::keyboard::Modifiers::default();
|
||||
}
|
||||
// An attachment belongs to the boundary geometry captured at press.
|
||||
// Camera/layout changes must not reinterpret its pointer in another space.
|
||||
if matches!(
|
||||
@@ -641,6 +656,7 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
return Some(action);
|
||||
}
|
||||
if state.context != Some(self.context) {
|
||||
state.group = None;
|
||||
state.work = None;
|
||||
state.pointer = None;
|
||||
state.port_drag = None;
|
||||
@@ -707,6 +723,9 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
let local = cursor
|
||||
.position()
|
||||
.map(|p| Point::new(p.x - bounds.x, p.y - bounds.y));
|
||||
if let Some(action) = self.group_input(state, event, bounds, cursor) {
|
||||
return Some(action);
|
||||
}
|
||||
match event {
|
||||
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right))
|
||||
if cursor.is_over(bounds) =>
|
||||
@@ -1262,7 +1281,17 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
.collect::<BTreeSet<_>>();
|
||||
for n in &view.nodes {
|
||||
if containers.contains(&n.id) {
|
||||
paint_node(&mut f, project, n, self.selected, true);
|
||||
paint_node(
|
||||
&mut f,
|
||||
project,
|
||||
n,
|
||||
if self.selection_contains(crate::diagram_usage::Target::Placement(n.id)) {
|
||||
n.element
|
||||
} else {
|
||||
self.selected
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
let highlighted = highlighted_exchanges(project, self.selected);
|
||||
@@ -1274,7 +1303,9 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
continue;
|
||||
};
|
||||
let end = point(end);
|
||||
let active = edge.id == self.selected || highlighted.contains(&edge.id);
|
||||
let active = edge.id == self.selected
|
||||
|| highlighted.contains(&edge.id)
|
||||
|| self.selection_contains(crate::diagram_usage::Target::Edge(edge.id));
|
||||
let color = if active {
|
||||
style::TEAL
|
||||
} else if routed
|
||||
@@ -1370,17 +1401,42 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
}
|
||||
for n in &view.nodes {
|
||||
if !containers.contains(&n.id) {
|
||||
paint_node(&mut f, project, n, self.selected, false);
|
||||
paint_node(
|
||||
&mut f,
|
||||
project,
|
||||
n,
|
||||
if self.selection_contains(crate::diagram_usage::Target::Placement(n.id)) {
|
||||
n.element
|
||||
} else {
|
||||
self.selected
|
||||
},
|
||||
false,
|
||||
);
|
||||
} else {
|
||||
// Container headers stay readable when relationships traverse
|
||||
// the allocation enclosure. Children remain on top.
|
||||
let mut header = n.clone();
|
||||
header.size.height = 62.;
|
||||
paint_node(&mut f, project, &header, self.selected, true);
|
||||
paint_node(
|
||||
&mut f,
|
||||
project,
|
||||
&header,
|
||||
if self.selection_contains(crate::diagram_usage::Target::Placement(n.id)) {
|
||||
n.element
|
||||
} else {
|
||||
self.selected
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
for port in &view.ports {
|
||||
let color = if self.selected == port.element {
|
||||
let selected = self.selected == port.element
|
||||
|| self.selection_contains(crate::diagram_usage::Target::Port {
|
||||
port: port.element,
|
||||
placement: port.owner,
|
||||
});
|
||||
let color = if selected {
|
||||
style::TEAL
|
||||
} else {
|
||||
style::kind_color(
|
||||
@@ -1408,11 +1464,7 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
let mut stroke =
|
||||
Stroke::default()
|
||||
.with_color(color)
|
||||
.with_width(if self.selected == port.element {
|
||||
2.5
|
||||
} else {
|
||||
1.5
|
||||
});
|
||||
.with_width(if selected { 2.5 } else { 1.5 });
|
||||
if glyph == PortGlyph::Legacy {
|
||||
stroke.line_dash = canvas::LineDash {
|
||||
segments: &[3., 2.],
|
||||
@@ -2277,6 +2329,7 @@ mod tests {
|
||||
y: original.y - 200.,
|
||||
};
|
||||
let canvas = DiagramCanvas {
|
||||
selection: None,
|
||||
project: &p,
|
||||
view_index: None,
|
||||
context: Context {
|
||||
@@ -2354,6 +2407,7 @@ mod tests {
|
||||
.unwrap()
|
||||
.id;
|
||||
let canvas = DiagramCanvas {
|
||||
selection: None,
|
||||
project: &p,
|
||||
view_index: None,
|
||||
context: Context {
|
||||
@@ -2482,6 +2536,7 @@ mod tests {
|
||||
};
|
||||
let session = Session::new(&p, &view, context, Tool::FunctionalExchange);
|
||||
let canvas = DiagramCanvas {
|
||||
selection: None,
|
||||
project: &p,
|
||||
view_index: None,
|
||||
diagram,
|
||||
@@ -2657,6 +2712,7 @@ mod tests {
|
||||
for zoom in [0.5, 1., 1.5] {
|
||||
let (p, diagram, edge) = route_fixture();
|
||||
let canvas = DiagramCanvas {
|
||||
selection: None,
|
||||
project: &p,
|
||||
view_index: None,
|
||||
diagram,
|
||||
@@ -2735,6 +2791,7 @@ mod tests {
|
||||
fn fast_drags_commit_the_release_cursor_without_a_final_motion_event() {
|
||||
let (p, diagram, edge) = route_fixture();
|
||||
let canvas = DiagramCanvas {
|
||||
selection: None,
|
||||
project: &p,
|
||||
view_index: None,
|
||||
diagram,
|
||||
@@ -2834,6 +2891,7 @@ mod tests {
|
||||
fn route_cache_ignores_pan_zoom_and_cancelled_drag_cannot_commit() {
|
||||
let (p, diagram, edge) = route_fixture();
|
||||
let mut canvas = DiagramCanvas {
|
||||
selection: None,
|
||||
project: &p,
|
||||
view_index: None,
|
||||
diagram,
|
||||
@@ -2964,6 +3022,7 @@ mod tests {
|
||||
assert!(!svg.contains("data-navigation=\"source\""));
|
||||
assert!(svg.contains("data-navigation=\"target\""));
|
||||
let mut canvas = DiagramCanvas {
|
||||
selection: None,
|
||||
project: &p,
|
||||
view_index: None,
|
||||
diagram,
|
||||
@@ -3020,6 +3079,7 @@ mod tests {
|
||||
offset: Position { x: 0., y: -160. },
|
||||
});
|
||||
let mut canvas = DiagramCanvas {
|
||||
selection: None,
|
||||
project: &p,
|
||||
view_index: None,
|
||||
diagram,
|
||||
@@ -3096,6 +3156,7 @@ mod tests {
|
||||
},
|
||||
});
|
||||
let canvas = DiagramCanvas {
|
||||
selection: None,
|
||||
project: &p,
|
||||
view_index: None,
|
||||
diagram,
|
||||
@@ -3131,6 +3192,7 @@ mod tests {
|
||||
routed.edges.insert(duplicate, duplicate_route);
|
||||
let selected = edge.max(duplicate); // UUID ordering must not override selection.
|
||||
let canvas = DiagramCanvas {
|
||||
selection: None,
|
||||
project: &p,
|
||||
view_index: None,
|
||||
diagram,
|
||||
|
||||
@@ -34,6 +34,7 @@ fn fixture() -> App {
|
||||
|
||||
fn canvas(app: &App, zoom: f32) -> DiagramCanvas<'_> {
|
||||
DiagramCanvas {
|
||||
selection: None,
|
||||
allocation: None,
|
||||
project: app.project(),
|
||||
view_index: None,
|
||||
|
||||
@@ -12,6 +12,8 @@ struct Input {
|
||||
suspended: bool,
|
||||
traversing: bool,
|
||||
pointer: Option<Point>,
|
||||
modifiers: iced::keyboard::Modifiers,
|
||||
restore_modifiers: bool,
|
||||
}
|
||||
impl operation::Focusable for Input {
|
||||
fn is_focused(&self) -> bool {
|
||||
@@ -95,6 +97,7 @@ impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Owner<'_, Mes
|
||||
// canvas's transient gestures without retiring this focus owner.
|
||||
state.pointer = None;
|
||||
tree.children[0] = Tree::new(self.content.as_widget());
|
||||
state.restore_modifiers = true;
|
||||
} else {
|
||||
tree.diff_children(std::slice::from_ref(&self.content));
|
||||
}
|
||||
@@ -143,6 +146,14 @@ impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Owner<'_, Mes
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
let state = tree.state.downcast_mut::<Input>();
|
||||
if let InputEvent::Keyboard(iced::keyboard::Event::ModifiersChanged(modifiers)) = event {
|
||||
state.modifiers = *modifiers;
|
||||
state.restore_modifiers = false;
|
||||
}
|
||||
if matches!(event, InputEvent::Window(iced::window::Event::Unfocused)) {
|
||||
state.modifiers = iced::keyboard::Modifiers::default();
|
||||
state.restore_modifiers = true;
|
||||
}
|
||||
if let InputEvent::Mouse(mouse::Event::CursorMoved { position }) = event {
|
||||
state.pointer = Some(*position);
|
||||
}
|
||||
@@ -171,7 +182,14 @@ impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Owner<'_, Mes
|
||||
}
|
||||
// Save/export I/O denies commands and acquisition, while real focus
|
||||
// operations and outside clicks can still revoke the retained owner.
|
||||
if self.suspended && matches!(event, InputEvent::Keyboard(_) | InputEvent::Mouse(_)) {
|
||||
let passive_modifiers = matches!(
|
||||
event,
|
||||
InputEvent::Keyboard(iced::keyboard::Event::ModifiersChanged(_))
|
||||
);
|
||||
if self.suspended
|
||||
&& !passive_modifiers
|
||||
&& matches!(event, InputEvent::Keyboard(_) | InputEvent::Mouse(_))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if let InputEvent::Keyboard(iced::keyboard::Event::KeyPressed {
|
||||
@@ -193,13 +211,30 @@ impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Owner<'_, Mes
|
||||
return;
|
||||
}
|
||||
if matches!(event, InputEvent::Keyboard(_))
|
||||
&& !passive_modifiers
|
||||
&& (!state.focused || state.traversing || !self.enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if shell.is_event_captured() {
|
||||
if shell.is_event_captured() && !passive_modifiers {
|
||||
return;
|
||||
}
|
||||
if state.restore_modifiers {
|
||||
// Suspending I/O replaces the child to retire pointer gestures.
|
||||
// Held modifiers belong to this longer-lived passive owner, so
|
||||
// restore them before the first admitted event reaches the child.
|
||||
state.restore_modifiers = false;
|
||||
self.content.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
&InputEvent::Keyboard(iced::keyboard::Event::ModifiersChanged(state.modifiers)),
|
||||
layout,
|
||||
cursor,
|
||||
renderer,
|
||||
clipboard,
|
||||
shell,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
self.content.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
event,
|
||||
|
||||
@@ -0,0 +1,685 @@
|
||||
//! Saved-view pointer geometry. Selection, capability and model admission remain
|
||||
//! with the caller; this module never applies a selection or a model command.
|
||||
use crate::diagram_selection::Snapshot;
|
||||
use crate::diagram_usage::{Surface, Target};
|
||||
use iced::{Point, Rectangle, Vector};
|
||||
use oa_core::{Id, Position};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub(crate) struct Viewport {
|
||||
pub zoom: f32,
|
||||
pub pan: Vector,
|
||||
pub bounds: Rectangle,
|
||||
}
|
||||
|
||||
/// Node displacement policy; route-bend snapping remains outside this helper.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum DisplacementPolicy {
|
||||
/// Preserve the ordinary saved-view ten-unit anchor grid.
|
||||
Grid10,
|
||||
/// Preserve State views' fractional authored placement displacement.
|
||||
Unsnapped,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Gesture {
|
||||
snapshot: Snapshot,
|
||||
anchor: Id,
|
||||
original: Position,
|
||||
press: Point,
|
||||
viewport: Viewport,
|
||||
displacement: DisplacementPolicy,
|
||||
crossed_threshold: bool,
|
||||
delta: Position,
|
||||
retired: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub(crate) enum Outcome {
|
||||
Click(Id),
|
||||
Move(Snapshot, Position),
|
||||
}
|
||||
|
||||
impl Gesture {
|
||||
pub fn new(
|
||||
snapshot: Snapshot,
|
||||
anchor: Id,
|
||||
original: Position,
|
||||
press: Point,
|
||||
viewport: Viewport,
|
||||
) -> Option<Self> {
|
||||
Self::new_with_policy(
|
||||
snapshot,
|
||||
anchor,
|
||||
original,
|
||||
press,
|
||||
viewport,
|
||||
DisplacementPolicy::Grid10,
|
||||
)
|
||||
}
|
||||
|
||||
/// `original` is the authored placement position, not its projected symbol
|
||||
/// position (State entry/exit symbols can be displayed on another boundary).
|
||||
/// Pointer displacement comes from frozen screen geometry and zoom.
|
||||
pub fn new_with_policy(
|
||||
snapshot: Snapshot,
|
||||
anchor: Id,
|
||||
original: Position,
|
||||
press: Point,
|
||||
viewport: Viewport,
|
||||
displacement: DisplacementPolicy,
|
||||
) -> Option<Self> {
|
||||
if !matches!(snapshot.context().scope.surface, Surface::Saved(_))
|
||||
|| !snapshot.targets().contains(&Target::Placement(anchor))
|
||||
|| !snapshot
|
||||
.targets()
|
||||
.iter()
|
||||
.all(|target| matches!(target, Target::Placement(_)))
|
||||
|| !viewport.valid()
|
||||
|| !finite_position(original)
|
||||
|| !finite_point(press)
|
||||
|| !viewport.bounds.contains(press)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
snapshot,
|
||||
anchor,
|
||||
original,
|
||||
press,
|
||||
viewport,
|
||||
displacement,
|
||||
crossed_threshold: false,
|
||||
delta: Position { x: 0., y: 0. },
|
||||
retired: false,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> &Snapshot {
|
||||
&self.snapshot
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn anchor(&self) -> Id {
|
||||
self.anchor
|
||||
}
|
||||
|
||||
/// Compare before processing each event. On mismatch, the caller must drop
|
||||
/// this gesture, just as on Escape, focus loss or changed model authority.
|
||||
pub fn viewport_matches(&self, viewport: Viewport) -> bool {
|
||||
!self.retired && self.viewport == viewport
|
||||
}
|
||||
|
||||
pub fn crossed_threshold(&self) -> bool {
|
||||
self.crossed_threshold
|
||||
}
|
||||
|
||||
/// Common policy-adjusted world delta for preview and release; zero before a drag.
|
||||
pub fn delta(&self) -> Position {
|
||||
self.delta
|
||||
}
|
||||
|
||||
/// Process an absolute screen position. False permanently retires the
|
||||
/// gesture; the caller must remove its preview and produce no operation.
|
||||
pub fn move_to(&mut self, pointer: Point) -> bool {
|
||||
if self.retired {
|
||||
return false;
|
||||
}
|
||||
if !finite_point(pointer) || !self.viewport.bounds.contains(pointer) {
|
||||
self.retire();
|
||||
return false;
|
||||
}
|
||||
let screen = pointer - self.press;
|
||||
self.crossed_threshold |= screen.x.hypot(screen.y) > 3.;
|
||||
if !self.crossed_threshold {
|
||||
return true;
|
||||
}
|
||||
let unsnapped = Position {
|
||||
x: screen.x / self.viewport.zoom,
|
||||
y: screen.y / self.viewport.zoom,
|
||||
};
|
||||
let desired = Position {
|
||||
x: self.original.x + unsnapped.x,
|
||||
y: self.original.y + unsnapped.y,
|
||||
};
|
||||
if !finite_position(desired) {
|
||||
self.retire();
|
||||
return false;
|
||||
}
|
||||
// Returning to the original position must not align an off-grid anchor
|
||||
// as an accidental edit. It is still a drag, so selection stays grouped.
|
||||
let delta = match self.displacement {
|
||||
// Use the pointer delta directly, avoiding precision loss from
|
||||
// subtracting a large authored original from its desired position.
|
||||
DisplacementPolicy::Unsnapped => unsnapped,
|
||||
DisplacementPolicy::Grid10 if desired == self.original => Position { x: 0., y: 0. },
|
||||
DisplacementPolicy::Grid10 => Position {
|
||||
x: (desired.x / 10.).round() * 10. - self.original.x,
|
||||
y: (desired.y / 10.).round() * 10. - self.original.y,
|
||||
},
|
||||
};
|
||||
if !finite_position(delta) {
|
||||
self.retire();
|
||||
return false;
|
||||
}
|
||||
self.delta = delta;
|
||||
true
|
||||
}
|
||||
|
||||
/// Consume the final pointer too: a release can be the first displaced
|
||||
/// event. The caller must validate snapshot and viewport before acting on
|
||||
/// either outcome. Cancelling is dropping the gesture without calling this.
|
||||
pub fn release(mut self, pointer: Point) -> Option<Outcome> {
|
||||
if !self.move_to(pointer) {
|
||||
return None;
|
||||
}
|
||||
Some(if self.crossed_threshold {
|
||||
Outcome::Move(self.snapshot, self.delta)
|
||||
} else {
|
||||
Outcome::Click(self.anchor)
|
||||
})
|
||||
}
|
||||
|
||||
fn retire(&mut self) {
|
||||
self.retired = true;
|
||||
self.delta = Position { x: 0., y: 0. };
|
||||
}
|
||||
}
|
||||
|
||||
impl Viewport {
|
||||
fn valid(self) -> bool {
|
||||
self.zoom.is_finite()
|
||||
&& self.zoom > 0.
|
||||
&& self.pan.x.is_finite()
|
||||
&& self.pan.y.is_finite()
|
||||
&& self.bounds.x.is_finite()
|
||||
&& self.bounds.y.is_finite()
|
||||
&& self.bounds.width.is_finite()
|
||||
&& self.bounds.height.is_finite()
|
||||
&& self.bounds.width > 0.
|
||||
&& self.bounds.height > 0.
|
||||
&& (self.bounds.x + self.bounds.width).is_finite()
|
||||
&& (self.bounds.y + self.bounds.height).is_finite()
|
||||
}
|
||||
}
|
||||
|
||||
fn finite_point(point: Point) -> bool {
|
||||
point.x.is_finite() && point.y.is_finite()
|
||||
}
|
||||
|
||||
fn finite_position(position: Position) -> bool {
|
||||
position.x.is_finite() && position.y.is_finite()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::diagram_selection::{Context, Scope, Selection};
|
||||
|
||||
fn id(value: u128) -> Id {
|
||||
Id::from_u128(value)
|
||||
}
|
||||
|
||||
fn context() -> Context {
|
||||
Context {
|
||||
scope: Scope {
|
||||
document: id(1),
|
||||
surface: Surface::Saved(id(2)),
|
||||
},
|
||||
revision: 3,
|
||||
epoch: 4,
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot(targets: impl IntoIterator<Item = Target>) -> Snapshot {
|
||||
let mut selection = Selection::new(context().scope);
|
||||
for target in targets {
|
||||
selection.add(target, |_| true);
|
||||
}
|
||||
selection.snapshot(context()).unwrap()
|
||||
}
|
||||
|
||||
fn viewport() -> Viewport {
|
||||
Viewport {
|
||||
zoom: 1.,
|
||||
pan: Vector::new(17., -8.),
|
||||
bounds: Rectangle {
|
||||
x: 100.,
|
||||
y: 50.,
|
||||
width: 400.,
|
||||
height: 300.,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn press() -> Point {
|
||||
Point::new(150., 120.)
|
||||
}
|
||||
|
||||
fn gesture(original: Position) -> Gesture {
|
||||
Gesture::new(
|
||||
snapshot([Target::Placement(id(10)), Target::Placement(id(11))]),
|
||||
id(10),
|
||||
original,
|
||||
press(),
|
||||
viewport(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stationary_or_exact_threshold_release_is_click_without_snapping() {
|
||||
for pointer in [press(), Point::new(153., 120.), Point::new(152., 122.)] {
|
||||
let mut gesture = gesture(Position { x: 13., y: -7. });
|
||||
assert!(gesture.move_to(pointer));
|
||||
assert!(!gesture.crossed_threshold());
|
||||
assert_eq!(gesture.delta(), Position { x: 0., y: 0. });
|
||||
assert_eq!(gesture.release(pointer), Some(Outcome::Click(id(10))));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn threshold_latches_and_return_to_offgrid_origin_is_zero_move() {
|
||||
let mut gesture = gesture(Position { x: 13., y: -7. });
|
||||
let captured = gesture.snapshot().clone();
|
||||
assert!(gesture.move_to(Point::new(170., 140.)));
|
||||
assert!(gesture.crossed_threshold());
|
||||
assert!(gesture.move_to(press()));
|
||||
assert!(gesture.crossed_threshold());
|
||||
assert_eq!(gesture.delta(), Position { x: 0., y: 0. });
|
||||
assert_eq!(
|
||||
gesture.release(press()),
|
||||
Some(Outcome::Move(captured, Position { x: 0., y: 0. }))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_only_displacement_that_snaps_to_zero_still_preserves_group() {
|
||||
let gesture = gesture(Position { x: 20., y: 40. });
|
||||
let captured = gesture.snapshot().clone();
|
||||
assert_eq!(
|
||||
gesture.release(Point::new(154., 120.)),
|
||||
Some(Outcome::Move(captured, Position { x: 0., y: 0. }))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offgrid_anchor_yields_one_shared_delta_and_release_matches_preview() {
|
||||
let mut gesture = gesture(Position { x: 13., y: -7. });
|
||||
let captured = gesture.snapshot().clone();
|
||||
assert_eq!(gesture.anchor(), id(10));
|
||||
let pointer = Point::new(167., 133.);
|
||||
assert!(gesture.move_to(pointer));
|
||||
// Desired anchor (30,6) snaps to (30,10), hence shared delta (17,17).
|
||||
assert_eq!(gesture.delta(), Position { x: 17., y: 17. });
|
||||
let sibling = Position { x: -6., y: 24. };
|
||||
let translated = Position {
|
||||
x: sibling.x + gesture.delta().x,
|
||||
y: sibling.y + gesture.delta().y,
|
||||
};
|
||||
assert_eq!(translated, Position { x: 11., y: 41. });
|
||||
assert_eq!(
|
||||
gesture.release(pointer),
|
||||
Some(Outcome::Move(captured, Position { x: 17., y: 17. }))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_scales_world_delta_but_threshold_remains_in_screen_pixels() {
|
||||
let mut gesture = Gesture::new(
|
||||
snapshot([Target::Placement(id(10))]),
|
||||
id(10),
|
||||
Position { x: 20., y: 40. },
|
||||
press(),
|
||||
Viewport {
|
||||
zoom: 2.,
|
||||
..viewport()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(gesture.move_to(Point::new(153., 120.)));
|
||||
assert!(!gesture.crossed_threshold());
|
||||
assert!(gesture.move_to(Point::new(168., 112.)));
|
||||
// Screen (18,-8) means world (9,-4), snapped anchor (30,40).
|
||||
assert_eq!(gesture.delta(), Position { x: 10., y: 0. });
|
||||
assert!(gesture.crossed_threshold());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsnapped_fractional_pointer_delta_preserves_authored_offset() {
|
||||
let authored = Position {
|
||||
x: 1024.125,
|
||||
y: -321.5,
|
||||
};
|
||||
let mut gesture = Gesture::new_with_policy(
|
||||
snapshot([Target::Placement(id(10)), Target::Placement(id(11))]),
|
||||
id(10),
|
||||
authored,
|
||||
press(),
|
||||
Viewport {
|
||||
zoom: 2.,
|
||||
..viewport()
|
||||
},
|
||||
DisplacementPolicy::Unsnapped,
|
||||
)
|
||||
.unwrap();
|
||||
let captured = gesture.snapshot().clone();
|
||||
let pointer = Point::new(164.5, 114.5);
|
||||
assert!(gesture.move_to(pointer));
|
||||
assert_eq!(gesture.delta(), Position { x: 7.25, y: -2.75 });
|
||||
let translated_authored = Position {
|
||||
x: authored.x + gesture.delta().x,
|
||||
y: authored.y + gesture.delta().y,
|
||||
};
|
||||
assert_eq!(
|
||||
translated_authored,
|
||||
Position {
|
||||
x: 1031.375,
|
||||
y: -324.25
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
gesture.release(pointer),
|
||||
Some(Outcome::Move(captured, Position { x: 7.25, y: -2.75 })),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsnapped_threshold_and_return_to_origin_preserve_group_lifecycle() {
|
||||
let make = || {
|
||||
Gesture::new_with_policy(
|
||||
snapshot([Target::Placement(id(10)), Target::Placement(id(11))]),
|
||||
id(10),
|
||||
Position { x: 13.25, y: -7.5 },
|
||||
press(),
|
||||
viewport(),
|
||||
DisplacementPolicy::Unsnapped,
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
let mut click = make();
|
||||
assert!(click.move_to(Point::new(153., 120.)));
|
||||
assert!(!click.crossed_threshold());
|
||||
assert_eq!(click.delta(), Position { x: 0., y: 0. });
|
||||
assert_eq!(
|
||||
click.release(Point::new(153., 120.)),
|
||||
Some(Outcome::Click(id(10)))
|
||||
);
|
||||
let mut drag = make();
|
||||
let captured = drag.snapshot().clone();
|
||||
assert!(drag.move_to(Point::new(157.25, 117.25)));
|
||||
assert_eq!(drag.delta(), Position { x: 7.25, y: -2.75 });
|
||||
assert!(drag.move_to(press()));
|
||||
assert!(drag.crossed_threshold());
|
||||
assert_eq!(drag.delta(), Position { x: 0., y: 0. });
|
||||
assert_eq!(
|
||||
drag.release(press()),
|
||||
Some(Outcome::Move(captured, Position { x: 0., y: 0. }))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_grid_and_default_constructor_keep_existing_quantization() {
|
||||
let original = Position { x: 13., y: -7. };
|
||||
let mut default = gesture(original);
|
||||
let mut explicit = Gesture::new_with_policy(
|
||||
default.snapshot().clone(),
|
||||
id(10),
|
||||
original,
|
||||
press(),
|
||||
viewport(),
|
||||
DisplacementPolicy::Grid10,
|
||||
)
|
||||
.unwrap();
|
||||
let pointer = Point::new(167., 133.);
|
||||
assert!(default.move_to(pointer));
|
||||
assert!(explicit.move_to(pointer));
|
||||
// Existing ordinary behavior: desired (30,6), grid (30,10).
|
||||
assert_eq!(default.delta(), Position { x: 17., y: 17. });
|
||||
assert_eq!(explicit.delta(), Position { x: 17., y: 17. });
|
||||
assert_eq!(default.release(pointer), explicit.release(pointer));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsnapped_release_only_movement_and_invalid_pointer_retirement() {
|
||||
let make = || {
|
||||
Gesture::new_with_policy(
|
||||
snapshot([Target::Placement(id(10))]),
|
||||
id(10),
|
||||
Position {
|
||||
x: 20.125,
|
||||
y: 40.25,
|
||||
},
|
||||
press(),
|
||||
viewport(),
|
||||
DisplacementPolicy::Unsnapped,
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
let release_only = make();
|
||||
let captured = release_only.snapshot().clone();
|
||||
assert_eq!(
|
||||
release_only.release(Point::new(154.25, 120.)),
|
||||
Some(Outcome::Move(captured, Position { x: 4.25, y: 0. })),
|
||||
);
|
||||
for invalid in [Point::new(f32::NAN, 120.), Point::new(501., 120.)] {
|
||||
let mut gesture = make();
|
||||
assert!(gesture.move_to(Point::new(154.25, 120.)));
|
||||
assert!(!gesture.move_to(invalid));
|
||||
assert!(!gesture.viewport_matches(viewport()));
|
||||
assert_eq!(gesture.delta(), Position { x: 0., y: 0. });
|
||||
assert_eq!(gesture.release(press()), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_typed_selection_and_saved_surface_are_required() {
|
||||
for targets in [
|
||||
vec![],
|
||||
vec![Target::Placement(id(11))],
|
||||
vec![Target::Placement(id(10)), Target::Edge(id(20))],
|
||||
vec![
|
||||
Target::Placement(id(10)),
|
||||
Target::Port {
|
||||
port: id(20),
|
||||
placement: id(10),
|
||||
},
|
||||
],
|
||||
] {
|
||||
assert!(
|
||||
Gesture::new(
|
||||
snapshot(targets),
|
||||
id(10),
|
||||
Position { x: 0., y: 0. },
|
||||
press(),
|
||||
viewport()
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
let other = Context {
|
||||
scope: Scope {
|
||||
surface: Surface::Process(id(2)),
|
||||
..context().scope
|
||||
},
|
||||
..context()
|
||||
};
|
||||
let mut selection = Selection::new(other.scope);
|
||||
selection.add(Target::Placement(id(10)), |_| true);
|
||||
assert!(
|
||||
Gesture::new(
|
||||
selection.snapshot(other).unwrap(),
|
||||
id(10),
|
||||
Position { x: 0., y: 0. },
|
||||
press(),
|
||||
viewport()
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn any_frozen_viewport_change_is_detected() {
|
||||
let gesture = gesture(Position { x: 0., y: 0. });
|
||||
assert!(gesture.viewport_matches(viewport()));
|
||||
for changed in [
|
||||
Viewport {
|
||||
zoom: 2.,
|
||||
..viewport()
|
||||
},
|
||||
Viewport {
|
||||
pan: Vector::new(18., -8.),
|
||||
..viewport()
|
||||
},
|
||||
Viewport {
|
||||
pan: Vector::new(17., -7.),
|
||||
..viewport()
|
||||
},
|
||||
Viewport {
|
||||
bounds: Rectangle {
|
||||
x: 101.,
|
||||
..viewport().bounds
|
||||
},
|
||||
..viewport()
|
||||
},
|
||||
Viewport {
|
||||
bounds: Rectangle {
|
||||
y: 51.,
|
||||
..viewport().bounds
|
||||
},
|
||||
..viewport()
|
||||
},
|
||||
Viewport {
|
||||
bounds: Rectangle {
|
||||
width: 401.,
|
||||
..viewport().bounds
|
||||
},
|
||||
..viewport()
|
||||
},
|
||||
Viewport {
|
||||
bounds: Rectangle {
|
||||
height: 301.,
|
||||
..viewport().bounds
|
||||
},
|
||||
..viewport()
|
||||
},
|
||||
] {
|
||||
assert!(!gesture.viewport_matches(changed));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_initial_geometry_cannot_start() {
|
||||
let saved = snapshot([Target::Placement(id(10))]);
|
||||
for invalid in [
|
||||
Viewport {
|
||||
zoom: 0.,
|
||||
..viewport()
|
||||
},
|
||||
Viewport {
|
||||
zoom: -1.,
|
||||
..viewport()
|
||||
},
|
||||
Viewport {
|
||||
zoom: f32::NAN,
|
||||
..viewport()
|
||||
},
|
||||
Viewport {
|
||||
zoom: f32::INFINITY,
|
||||
..viewport()
|
||||
},
|
||||
Viewport {
|
||||
pan: Vector::new(f32::NAN, 0.),
|
||||
..viewport()
|
||||
},
|
||||
Viewport {
|
||||
bounds: Rectangle {
|
||||
width: 0.,
|
||||
..viewport().bounds
|
||||
},
|
||||
..viewport()
|
||||
},
|
||||
Viewport {
|
||||
bounds: Rectangle {
|
||||
height: -1.,
|
||||
..viewport().bounds
|
||||
},
|
||||
..viewport()
|
||||
},
|
||||
Viewport {
|
||||
bounds: Rectangle {
|
||||
x: f32::INFINITY,
|
||||
..viewport().bounds
|
||||
},
|
||||
..viewport()
|
||||
},
|
||||
] {
|
||||
assert!(
|
||||
Gesture::new(
|
||||
saved.clone(),
|
||||
id(10),
|
||||
Position { x: 0., y: 0. },
|
||||
press(),
|
||||
invalid
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
Gesture::new(
|
||||
saved.clone(),
|
||||
id(10),
|
||||
Position { x: f32::NAN, y: 0. },
|
||||
press(),
|
||||
viewport()
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
for invalid in [Point::new(f32::NAN, 120.), Point::new(99., 120.)] {
|
||||
assert!(
|
||||
Gesture::new(
|
||||
saved.clone(),
|
||||
id(10),
|
||||
Position { x: 0., y: 0. },
|
||||
invalid,
|
||||
viewport()
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_pointer_permanently_retires_even_if_pointer_returns() {
|
||||
for invalid in [Point::new(f32::NAN, 120.), Point::new(501., 120.)] {
|
||||
let mut gesture = gesture(Position { x: 20., y: 40. });
|
||||
assert!(gesture.move_to(Point::new(170., 140.)));
|
||||
assert!(!gesture.move_to(invalid));
|
||||
assert_eq!(gesture.delta(), Position { x: 0., y: 0. });
|
||||
assert!(!gesture.move_to(press()));
|
||||
assert!(!gesture.viewport_matches(viewport()));
|
||||
assert_eq!(gesture.release(press()), None);
|
||||
}
|
||||
assert_eq!(
|
||||
gesture(Position { x: 20., y: 40. }).release(Point::new(501., 120.)),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finite_inputs_that_overflow_world_geometry_retire_without_an_operation() {
|
||||
let mut gesture = Gesture::new(
|
||||
snapshot([Target::Placement(id(10))]),
|
||||
id(10),
|
||||
Position { x: 0., y: 0. },
|
||||
press(),
|
||||
Viewport {
|
||||
zoom: f32::MIN_POSITIVE,
|
||||
..viewport()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!gesture.move_to(Point::new(170., 140.)));
|
||||
assert_eq!(gesture.release(press()), None);
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,7 @@ fn bounds() -> Rectangle {
|
||||
}
|
||||
fn canvas(app: &App) -> DiagramCanvas<'_> {
|
||||
DiagramCanvas {
|
||||
selection: None,
|
||||
project: app.project(),
|
||||
view_index: None,
|
||||
diagram: app.active_diagram.unwrap(),
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
use super::group_gesture::{Gesture as GroupGesture, Outcome, Viewport};
|
||||
use super::*;
|
||||
use crate::app::multiselect::{Choice, Event, context};
|
||||
use crate::diagram_selection::Snapshot;
|
||||
use crate::diagram_usage::Target;
|
||||
|
||||
/// Even an unsupported mixed group must retain an ordinary click lifecycle.
|
||||
pub(super) struct Press {
|
||||
snapshot: Snapshot,
|
||||
target: Target,
|
||||
start: Point,
|
||||
viewport: Viewport,
|
||||
movement: Option<GroupGesture>,
|
||||
reason: Option<String>,
|
||||
crossed: bool,
|
||||
retired: bool,
|
||||
}
|
||||
impl Press {
|
||||
fn snapshot(&self) -> &Snapshot {
|
||||
self.movement
|
||||
.as_ref()
|
||||
.map_or(&self.snapshot, GroupGesture::snapshot)
|
||||
}
|
||||
fn viewport_matches(&self, viewport: Viewport) -> bool {
|
||||
!self.retired
|
||||
&& self.viewport == viewport
|
||||
&& self
|
||||
.movement
|
||||
.as_ref()
|
||||
.is_none_or(|movement| movement.viewport_matches(viewport))
|
||||
}
|
||||
fn crossed_threshold(&self) -> bool {
|
||||
self.movement
|
||||
.as_ref()
|
||||
.map_or(self.crossed, GroupGesture::crossed_threshold)
|
||||
}
|
||||
fn delta(&self) -> Position {
|
||||
self.movement
|
||||
.as_ref()
|
||||
.map_or(Position { x: 0., y: 0. }, GroupGesture::delta)
|
||||
}
|
||||
fn move_to(&mut self, pointer: Point) {
|
||||
if self.retired {
|
||||
return;
|
||||
}
|
||||
if !pointer.x.is_finite()
|
||||
|| !pointer.y.is_finite()
|
||||
|| !self.viewport.bounds.contains(pointer)
|
||||
{
|
||||
self.retired = true;
|
||||
self.movement = None;
|
||||
return;
|
||||
}
|
||||
self.crossed |= pointer.distance(self.start) > 3.;
|
||||
if let Some(movement) = &mut self.movement
|
||||
&& !movement.move_to(pointer)
|
||||
{
|
||||
self.retired = true;
|
||||
}
|
||||
}
|
||||
fn release(mut self, pointer: Point) -> Option<Event> {
|
||||
self.move_to(pointer);
|
||||
if self.retired {
|
||||
return None;
|
||||
}
|
||||
if !self.crossed {
|
||||
return Some(Event::Collapse(self.snapshot, self.target));
|
||||
}
|
||||
if let Some(reason) = self.reason {
|
||||
return Some(Event::Refuse(self.snapshot, reason));
|
||||
}
|
||||
match self.movement?.release(pointer)? {
|
||||
Outcome::Click(id) => Some(Event::Collapse(self.snapshot, Target::Placement(id))),
|
||||
Outcome::Move(snapshot, delta) => Some(Event::Move(snapshot, delta)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DiagramCanvas<'_> {
|
||||
pub(super) fn selection_contains(&self, target: Target) -> bool {
|
||||
self.selection
|
||||
.is_some_and(|s| s.scope() == context(self.context).scope && s.contains(&target))
|
||||
}
|
||||
|
||||
pub(super) fn group_input(
|
||||
&self,
|
||||
state: &mut State,
|
||||
event: &canvas::Event,
|
||||
bounds: Rectangle,
|
||||
cursor: mouse::Cursor,
|
||||
) -> Option<Action<Message>> {
|
||||
let viewport = Viewport {
|
||||
zoom: self.zoom,
|
||||
pan: self.pan,
|
||||
bounds,
|
||||
};
|
||||
if let Some(gesture) = &state.group {
|
||||
let valid = gesture.viewport_matches(viewport)
|
||||
&& self.editable
|
||||
&& self.connection.is_none()
|
||||
&& !self.placing
|
||||
&& self
|
||||
.selection
|
||||
.is_some_and(|s| s.matches(gesture.snapshot(), context(self.context)));
|
||||
let cancelled = matches!(
|
||||
event,
|
||||
canvas::Event::Window(
|
||||
iced::window::Event::Unfocused | iced::window::Event::Resized(_)
|
||||
) | canvas::Event::Mouse(
|
||||
mouse::Event::CursorLeft
|
||||
| mouse::Event::WheelScrolled { .. }
|
||||
| mouse::Event::ButtonPressed(mouse::Button::Right | mouse::Button::Middle)
|
||||
) | canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed {
|
||||
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
|
||||
..
|
||||
})
|
||||
);
|
||||
if !valid || cancelled {
|
||||
state.group = None;
|
||||
*state.cache.borrow_mut() = None;
|
||||
return Some(Action::request_redraw().and_capture());
|
||||
}
|
||||
}
|
||||
if let Some(gesture) = &mut state.group {
|
||||
match event {
|
||||
canvas::Event::Mouse(mouse::Event::CursorMoved { position }) => {
|
||||
gesture.move_to(*position);
|
||||
*state.cache.borrow_mut() = None;
|
||||
return Some(Action::request_redraw().and_capture());
|
||||
}
|
||||
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
|
||||
let gesture = state.group.take().unwrap();
|
||||
*state.cache.borrow_mut() = None;
|
||||
let outcome = cursor
|
||||
.position()
|
||||
.and_then(|pointer| gesture.release(pointer));
|
||||
return Some(match outcome {
|
||||
Some(event) => {
|
||||
Action::publish(Message::DiagramSelection(event)).and_capture()
|
||||
}
|
||||
None => Action::request_redraw().and_capture(),
|
||||
});
|
||||
}
|
||||
canvas::Event::Keyboard(_) => return Some(Action::capture()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let selection = self
|
||||
.selection
|
||||
.filter(|s| s.scope() == context(self.context).scope)?;
|
||||
if state.pan_start.is_some() {
|
||||
return None;
|
||||
}
|
||||
if self.connection.is_some() || self.placing {
|
||||
return None;
|
||||
}
|
||||
if let canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed {
|
||||
key, modifiers, ..
|
||||
}) = event
|
||||
{
|
||||
use iced::keyboard::{Key, key::Named};
|
||||
if *key == Key::Named(Named::Escape)
|
||||
&& state.drag.is_none()
|
||||
&& state.route_drag.is_none()
|
||||
&& state.port_drag.is_none()
|
||||
&& state.work.is_none()
|
||||
{
|
||||
return Some(
|
||||
Action::publish(Message::DiagramSelection(Event::Clear(context(
|
||||
self.context,
|
||||
))))
|
||||
.and_capture(),
|
||||
);
|
||||
}
|
||||
if selection.targets().len() > 1 && *key == Key::Named(Named::Delete) {
|
||||
let snapshot = selection.snapshot(context(self.context))?;
|
||||
let action = if modifiers.is_empty() {
|
||||
Event::Hide(snapshot)
|
||||
} else {
|
||||
Event::Refuse(snapshot, "Use Hide selected to remove placements. Group model-deletion review is not available yet.".into())
|
||||
};
|
||||
return Some(Action::publish(Message::DiagramSelection(action)).and_capture());
|
||||
}
|
||||
}
|
||||
if !matches!(
|
||||
event,
|
||||
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
|
||||
) || !cursor.is_over(bounds)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let pointer = cursor.position()?;
|
||||
let local = Point::new(pointer.x - bounds.x, pointer.y - bounds.y);
|
||||
let scene = self.scene(state);
|
||||
let target = if let Some(port) = self.hit_port(&scene.view, local) {
|
||||
Some(Target::Port {
|
||||
port: port.element,
|
||||
placement: port.owner,
|
||||
})
|
||||
} else if let Some(id) = self.hit(&scene.view, local) {
|
||||
Some(Target::Placement(id))
|
||||
} else {
|
||||
self.hit_label(&scene.routed, local)
|
||||
.or_else(|| self.hit_association_label(&scene.routed, local))
|
||||
.or_else(|| self.hit_edge(&scene.routed, local).map(|hit| hit.0))
|
||||
.map(Target::Edge)
|
||||
}?;
|
||||
if state.modifiers.shift() {
|
||||
state.drag = None;
|
||||
state.preview = None;
|
||||
state.port_drag = None;
|
||||
state.route_drag = None;
|
||||
return Some(
|
||||
Action::publish(Message::DiagramSelection(Event::Choose(
|
||||
context(self.context),
|
||||
target,
|
||||
Choice::Toggle,
|
||||
)))
|
||||
.and_capture(),
|
||||
);
|
||||
}
|
||||
if selection.targets().len() <= 1 || !selection.contains(&target) {
|
||||
return None;
|
||||
}
|
||||
let snapshot = selection.snapshot(context(self.context))?;
|
||||
if !self.editable {
|
||||
return Some(Action::capture());
|
||||
}
|
||||
let reason = if crate::app::multiselect::placements(&snapshot).is_none() {
|
||||
Some(
|
||||
"Move requires only placed elements. Remove ports and connections from the selection.",
|
||||
)
|
||||
} else if snapshot
|
||||
.targets()
|
||||
.iter()
|
||||
.any(|t| matches!(t, Target::Placement(id) if self.work_eligible(*id)))
|
||||
{
|
||||
Some("Group allocation drag is not available yet. The selection was retained.")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let movement = if reason.is_none()
|
||||
&& let Target::Placement(anchor) = target
|
||||
{
|
||||
let original = self.project.diagrams[&self.diagram].nodes[&anchor].position;
|
||||
GroupGesture::new(snapshot.clone(), anchor, original, pointer, viewport)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if movement.is_none() && reason.is_none() {
|
||||
return Some(Action::capture());
|
||||
}
|
||||
state.group = Some(Press {
|
||||
snapshot,
|
||||
target,
|
||||
start: pointer,
|
||||
viewport,
|
||||
movement,
|
||||
reason: reason.map(str::to_owned),
|
||||
crossed: false,
|
||||
retired: false,
|
||||
});
|
||||
*state.cache.borrow_mut() = None;
|
||||
Some(Action::request_redraw().and_capture())
|
||||
}
|
||||
|
||||
pub(super) fn group_preview(&self, state: &State, diagram: &mut Diagram) {
|
||||
let Some(gesture) = &state.group else {
|
||||
return;
|
||||
};
|
||||
if !gesture.crossed_threshold() {
|
||||
return;
|
||||
}
|
||||
let Some(ids) = crate::app::multiselect::placements(gesture.snapshot()) else {
|
||||
return;
|
||||
};
|
||||
let Ok(Command::Batch(commands)) = oa_core::diagram_edit::translate_placements(
|
||||
self.project,
|
||||
self.diagram,
|
||||
&ids,
|
||||
gesture.delta(),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
// Apply only the planner's presentation records. Full model validation
|
||||
// and source/history updates occur once, when App accepts the release.
|
||||
for command in commands {
|
||||
match command {
|
||||
Command::EditNode { node, .. } => {
|
||||
diagram.nodes.insert(node.id, node);
|
||||
}
|
||||
Command::SetEdgeRoute { route, .. } => route_edit::with_route(diagram, route),
|
||||
_ => unreachable!("placement planner returned a non-geometry command"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,758 @@
|
||||
//! 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<Item = Target>) -> 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<Message>, 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<Message>, 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<Message>,
|
||||
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);
|
||||
}
|
||||
@@ -61,6 +61,7 @@ fn screen(p: Position, zoom: f32) -> Point {
|
||||
}
|
||||
fn canvas(app: &App, zoom: f32) -> DiagramCanvas<'_> {
|
||||
DiagramCanvas {
|
||||
selection: None,
|
||||
project: app.project(),
|
||||
view_index: None,
|
||||
diagram: app.active_diagram.unwrap(),
|
||||
|
||||
@@ -2,6 +2,7 @@ use super::*;
|
||||
|
||||
fn canvas(project: &Project, diagram: Id, zoom: f32) -> DiagramCanvas<'_> {
|
||||
DiagramCanvas {
|
||||
selection: None,
|
||||
project,
|
||||
view_index: None,
|
||||
diagram,
|
||||
|
||||
@@ -177,6 +177,7 @@ mod tests {
|
||||
|
||||
fn program(app: &App) -> PortLabels<'_> {
|
||||
PortLabels(DiagramCanvas {
|
||||
selection: None,
|
||||
project: app.project(),
|
||||
view_index: None,
|
||||
diagram: app.active_diagram.unwrap(),
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
//! Transient exact-target selection; admission and model operations belong to adapters.
|
||||
use crate::diagram_usage::{Surface, Target};
|
||||
use oa_core::Id;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct Scope {
|
||||
/// Document-instance identity, not the persisted project UUID.
|
||||
pub document: Id,
|
||||
pub surface: Surface,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct Context {
|
||||
pub scope: Scope,
|
||||
pub revision: u64,
|
||||
pub epoch: u64,
|
||||
}
|
||||
|
||||
/// Insertion-ordered unique targets. Membership does not grant edit capability.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Selection {
|
||||
scope: Scope,
|
||||
targets: Vec<Target>,
|
||||
primary: Option<Target>,
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
/// Immutable operation input, including the selection lifetime that captured it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct Snapshot {
|
||||
context: Context,
|
||||
generation: u64,
|
||||
targets: Vec<Target>,
|
||||
primary: Option<Target>,
|
||||
}
|
||||
|
||||
impl Selection {
|
||||
pub fn new(scope: Scope) -> Self {
|
||||
Self {
|
||||
scope,
|
||||
targets: Vec::new(),
|
||||
primary: None,
|
||||
generation: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scope(&self) -> Scope {
|
||||
self.scope
|
||||
}
|
||||
|
||||
pub fn targets(&self) -> &[Target] {
|
||||
&self.targets
|
||||
}
|
||||
|
||||
pub fn primary(&self) -> Option<&Target> {
|
||||
self.primary.as_ref()
|
||||
}
|
||||
|
||||
pub fn contains(&self, target: &Target) -> bool {
|
||||
self.targets.contains(target)
|
||||
}
|
||||
|
||||
/// Replace with one valid target. Rejected admission preserves the old set.
|
||||
pub fn replace(&mut self, target: Target, valid: impl FnOnce(&Target) -> bool) -> bool {
|
||||
if !admitted(&target, valid) || (self.targets.len() == 1 && self.contains(&target)) {
|
||||
return false;
|
||||
}
|
||||
self.targets = vec![target.clone()];
|
||||
self.primary = Some(target);
|
||||
self.changed();
|
||||
true
|
||||
}
|
||||
|
||||
/// Add a new member as primary. Adding an existing exact target is a no-op.
|
||||
pub fn add(&mut self, target: Target, valid: impl FnOnce(&Target) -> bool) -> bool {
|
||||
if !admitted(&target, valid) || self.contains(&target) {
|
||||
return false;
|
||||
}
|
||||
self.targets.push(target.clone());
|
||||
self.primary = Some(target);
|
||||
self.changed();
|
||||
true
|
||||
}
|
||||
|
||||
/// Removal is allowed even if the target is no longer valid in the model.
|
||||
pub fn remove(&mut self, target: &Target) -> bool {
|
||||
let Some(index) = self.targets.iter().position(|member| member == target) else {
|
||||
return false;
|
||||
};
|
||||
self.targets.remove(index);
|
||||
if self.primary.as_ref() == Some(target) {
|
||||
self.primary = self.targets.last().cloned();
|
||||
}
|
||||
self.changed();
|
||||
true
|
||||
}
|
||||
|
||||
pub fn toggle(&mut self, target: Target, valid: impl FnOnce(&Target) -> bool) -> bool {
|
||||
if self.contains(&target) {
|
||||
self.remove(&target)
|
||||
} else {
|
||||
self.add(target, valid)
|
||||
}
|
||||
}
|
||||
|
||||
/// Inspect another selected member without changing membership or its order.
|
||||
pub fn set_primary(&mut self, target: &Target) -> bool {
|
||||
if !self.contains(target) || self.primary.as_ref() == Some(target) {
|
||||
return false;
|
||||
}
|
||||
self.primary = Some(target.clone());
|
||||
self.changed();
|
||||
true
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) -> bool {
|
||||
if self.targets.is_empty() {
|
||||
return false;
|
||||
}
|
||||
self.targets.clear();
|
||||
self.primary = None;
|
||||
self.changed();
|
||||
true
|
||||
}
|
||||
|
||||
/// Change owner or prune exact targets against the current rendered surface.
|
||||
/// The predicate checks identity/visibility, not eligibility for one action.
|
||||
/// Revision-only changes may retain survivors; no semantic retargeting occurs.
|
||||
pub fn reconcile(&mut self, scope: Scope, valid: impl FnMut(&Target) -> bool) -> bool {
|
||||
if self.scope != scope {
|
||||
self.scope = scope;
|
||||
self.targets.clear();
|
||||
self.primary = None;
|
||||
self.changed();
|
||||
return true;
|
||||
}
|
||||
let previous_len = self.targets.len();
|
||||
self.targets.retain(valid);
|
||||
if self.targets.len() == previous_len {
|
||||
return false;
|
||||
}
|
||||
if !self
|
||||
.primary
|
||||
.as_ref()
|
||||
.is_some_and(|target| self.contains(target))
|
||||
{
|
||||
self.primary = self.targets.last().cloned();
|
||||
}
|
||||
self.changed();
|
||||
true
|
||||
}
|
||||
|
||||
/// Capture input, including an empty selection before a first pointer press.
|
||||
/// Does not grant permission to mutate; adapters still guard drafts, modals,
|
||||
/// capability and geometry. A mismatched owner cannot capture a snapshot.
|
||||
pub fn snapshot(&self, context: Context) -> Option<Snapshot> {
|
||||
if context.scope != self.scope {
|
||||
return None;
|
||||
}
|
||||
Some(Snapshot {
|
||||
context,
|
||||
generation: self.generation,
|
||||
targets: self.targets.clone(),
|
||||
primary: self.primary.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Check before any operation side effect, using the caller's live context.
|
||||
/// Generation rejects remove/re-add and primary-change/restore ABA cycles.
|
||||
pub fn matches(&self, snapshot: &Snapshot, current: Context) -> bool {
|
||||
snapshot.context == current
|
||||
&& current.scope == self.scope
|
||||
&& snapshot.generation == self.generation
|
||||
&& snapshot.targets == self.targets
|
||||
&& self.primary == snapshot.primary
|
||||
}
|
||||
|
||||
fn changed(&mut self) {
|
||||
self.generation = self
|
||||
.generation
|
||||
.checked_add(1)
|
||||
.expect("selection generation exhausted");
|
||||
}
|
||||
}
|
||||
|
||||
impl Snapshot {
|
||||
pub fn context(&self) -> Context {
|
||||
self.context
|
||||
}
|
||||
|
||||
pub fn targets(&self) -> &[Target] {
|
||||
&self.targets
|
||||
}
|
||||
|
||||
pub fn primary(&self) -> Option<&Target> {
|
||||
self.primary.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
fn admitted(target: &Target, valid: impl FnOnce(&Target) -> bool) -> bool {
|
||||
!matches!(target, Target::Whole) && valid(target)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use oa_core::OccurrenceEndpoint;
|
||||
|
||||
fn id(value: u128) -> Id {
|
||||
Id::from_u128(value)
|
||||
}
|
||||
|
||||
fn scope() -> Scope {
|
||||
Scope {
|
||||
document: id(1),
|
||||
surface: Surface::Saved(id(2)),
|
||||
}
|
||||
}
|
||||
|
||||
fn context() -> Context {
|
||||
Context {
|
||||
scope: scope(),
|
||||
revision: 7,
|
||||
epoch: 11,
|
||||
}
|
||||
}
|
||||
|
||||
fn node(value: u128) -> Target {
|
||||
Target::Placement(id(value))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_duplicates_are_noops_but_distinct_visual_identities_survive() {
|
||||
let mut selection = Selection::new(scope());
|
||||
let port_a = Target::Port {
|
||||
port: id(30),
|
||||
placement: id(3),
|
||||
};
|
||||
let port_b = Target::Port {
|
||||
port: id(30),
|
||||
placement: id(4),
|
||||
};
|
||||
assert!(selection.add(node(3), |_| true));
|
||||
assert!(selection.add(port_a.clone(), |_| true));
|
||||
let captured = selection.snapshot(context()).unwrap();
|
||||
assert!(!selection.add(node(3), |_| true));
|
||||
assert!(!selection.add(port_a.clone(), |_| true));
|
||||
assert!(selection.matches(&captured, context()));
|
||||
assert!(selection.add(port_b.clone(), |_| true));
|
||||
assert_eq!(selection.targets(), &[node(3), port_a, port_b.clone()]);
|
||||
assert_eq!(selection.primary(), Some(&port_b));
|
||||
assert_eq!(
|
||||
captured.targets(),
|
||||
&[
|
||||
node(3),
|
||||
Target::Port {
|
||||
port: id(30),
|
||||
placement: id(3)
|
||||
}
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn primary_removal_uses_latest_survivor_without_reordering() {
|
||||
let mut selection = Selection::new(scope());
|
||||
for value in [3, 4, 5] {
|
||||
selection.add(node(value), |_| true);
|
||||
}
|
||||
assert!(selection.set_primary(&node(3)));
|
||||
assert_eq!(selection.targets(), &[node(3), node(4), node(5)]);
|
||||
assert!(!selection.set_primary(&node(99)));
|
||||
assert!(selection.remove(&node(4)));
|
||||
assert_eq!(selection.primary(), Some(&node(3)));
|
||||
assert!(selection.remove(&node(3)));
|
||||
assert_eq!(selection.primary(), Some(&node(5)));
|
||||
// A now-invalid member remains removable; validity applies to additions.
|
||||
assert!(selection.toggle(node(5), |_| false));
|
||||
assert!(selection.targets().is_empty());
|
||||
assert_eq!(selection.primary(), None);
|
||||
let empty = selection.snapshot(context()).unwrap();
|
||||
assert!(empty.targets().is_empty());
|
||||
assert_eq!(empty.primary(), None);
|
||||
assert!(selection.matches(&empty, context()));
|
||||
selection.add(node(5), |_| true);
|
||||
assert!(!selection.matches(&empty, context()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_and_rejected_targets_cannot_replace_an_existing_selection() {
|
||||
let mut selection = Selection::new(scope());
|
||||
selection.add(node(3), |_| true);
|
||||
let captured = selection.snapshot(context()).unwrap();
|
||||
assert!(!selection.replace(Target::Whole, |_| true));
|
||||
assert!(!selection.add(Target::Whole, |_| true));
|
||||
assert!(!selection.toggle(Target::Whole, |_| true));
|
||||
assert!(!selection.replace(node(4), |_| false));
|
||||
assert!(!selection.toggle(node(4), |_| false));
|
||||
assert!(selection.matches(&captured, context()));
|
||||
assert!(selection.replace(node(4), |_| true));
|
||||
assert_eq!(selection.targets(), &[node(4)]);
|
||||
assert_eq!(selection.primary(), Some(&node(4)));
|
||||
assert!(!selection.replace(node(4), |_| true));
|
||||
assert!(selection.clear());
|
||||
assert!(!selection.clear());
|
||||
assert_eq!(selection.primary(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reused_process_occurrences_reconcile_by_full_path_never_definition() {
|
||||
let owner = Scope {
|
||||
surface: Surface::Process(id(2)),
|
||||
..scope()
|
||||
};
|
||||
let mut selection = Selection::new(owner);
|
||||
let occurrence = |path| {
|
||||
Target::ProcessOccurrence(OccurrenceEndpoint {
|
||||
references: vec![id(path), id(50)],
|
||||
occurrence: id(60),
|
||||
})
|
||||
};
|
||||
let first = occurrence(10);
|
||||
let second = occurrence(20);
|
||||
selection.add(first.clone(), |_| true);
|
||||
selection.add(second.clone(), |_| true);
|
||||
assert_eq!(selection.targets(), &[first.clone(), second.clone()]);
|
||||
assert!(selection.reconcile(owner, |target| target == &first));
|
||||
assert_eq!(selection.targets(), std::slice::from_ref(&first));
|
||||
assert_eq!(selection.primary(), Some(&first));
|
||||
// A new call path with the same definition is not a replacement identity.
|
||||
let replacement = occurrence(70);
|
||||
assert!(selection.reconcile(owner, |target| target == &replacement));
|
||||
assert!(selection.targets().is_empty());
|
||||
assert_eq!(selection.primary(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revision_reconciliation_retains_exact_survivors_but_invalidates_operations() {
|
||||
let mut selection = Selection::new(scope());
|
||||
selection.add(node(3), |_| true);
|
||||
selection.add(node(4), |_| true);
|
||||
selection.set_primary(&node(3));
|
||||
let captured = selection.snapshot(context()).unwrap();
|
||||
let next = Context {
|
||||
revision: 8,
|
||||
..context()
|
||||
};
|
||||
assert!(!selection.reconcile(scope(), |_| true));
|
||||
assert_eq!(selection.targets(), &[node(3), node(4)]);
|
||||
assert_eq!(selection.primary(), Some(&node(3)));
|
||||
assert!(!selection.matches(&captured, next));
|
||||
let accepted = selection.snapshot(next).unwrap();
|
||||
assert!(selection.matches(&accepted, next));
|
||||
assert!(selection.reconcile(scope(), |target| target != &node(4)));
|
||||
assert_eq!(selection.primary(), Some(&node(3)));
|
||||
assert!(!selection.matches(&accepted, next));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changing_document_or_surface_clears_even_identical_target_ids() {
|
||||
for changed in [
|
||||
Scope {
|
||||
document: id(99),
|
||||
..scope()
|
||||
},
|
||||
Scope {
|
||||
surface: Surface::Saved(id(99)),
|
||||
..scope()
|
||||
},
|
||||
Scope {
|
||||
surface: Surface::Scenario(id(2)),
|
||||
..scope()
|
||||
},
|
||||
] {
|
||||
let mut selection = Selection::new(scope());
|
||||
selection.add(node(3), |_| true);
|
||||
let captured = selection.snapshot(context()).unwrap();
|
||||
assert!(selection.reconcile(changed, |_| true));
|
||||
assert_eq!(selection.scope(), changed);
|
||||
assert!(selection.targets().is_empty());
|
||||
assert_eq!(selection.primary(), None);
|
||||
assert!(!selection.matches(&captured, context()));
|
||||
assert!(selection.snapshot(context()).is_none());
|
||||
selection.reconcile(scope(), |_| true);
|
||||
selection.add(node(3), |_| true);
|
||||
assert!(!selection.matches(&captured, context()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_epoch_selection_and_primary_cycles_do_not_regain_authority() {
|
||||
let mut selection = Selection::new(scope());
|
||||
selection.add(node(3), |_| true);
|
||||
selection.add(node(4), |_| true);
|
||||
let captured = selection.snapshot(context()).unwrap();
|
||||
assert_eq!(captured.context(), context());
|
||||
assert_eq!(captured.primary(), Some(&node(4)));
|
||||
assert!(!selection.matches(
|
||||
&captured,
|
||||
Context {
|
||||
epoch: 12,
|
||||
..context()
|
||||
}
|
||||
));
|
||||
selection.set_primary(&node(3));
|
||||
selection.set_primary(&node(4));
|
||||
assert!(!selection.matches(&captured, context()));
|
||||
let restored_primary = selection.snapshot(context()).unwrap();
|
||||
selection.remove(&node(4));
|
||||
selection.add(node(4), |_| true);
|
||||
assert_eq!(selection.targets(), captured.targets());
|
||||
assert_eq!(selection.primary(), captured.primary());
|
||||
assert!(!selection.matches(&restored_primary, context()));
|
||||
assert!(selection.matches(&selection.snapshot(context()).unwrap(), context()));
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ mod data_values;
|
||||
mod data_view;
|
||||
mod diagnostic_input;
|
||||
mod diagram;
|
||||
mod diagram_selection;
|
||||
mod diagram_usage;
|
||||
mod document;
|
||||
mod explorer;
|
||||
|
||||
@@ -12,6 +12,7 @@ pub struct Request {
|
||||
pub anchor: Point,
|
||||
pub object: Id,
|
||||
pub placement: Option<Id>,
|
||||
pub selection_target: Option<Box<crate::diagram_usage::Target>>,
|
||||
pub bend: Option<usize>,
|
||||
pub route_point: Option<Position>,
|
||||
pub route_segment: Option<(usize, Position)>,
|
||||
@@ -25,6 +26,7 @@ impl Request {
|
||||
anchor,
|
||||
object,
|
||||
placement: None,
|
||||
selection_target: None,
|
||||
bend: None,
|
||||
route_point: None,
|
||||
route_segment: None,
|
||||
@@ -55,6 +57,7 @@ pub struct Opened {
|
||||
impl Opened {
|
||||
pub fn new(app: &App, request: Request, token: u64) -> Option<Self> {
|
||||
if !app.project().contains(request.object)
|
||||
|| !target_matches(app, &request)
|
||||
|| request
|
||||
.canvas
|
||||
.is_some_and(|c| Some(c) != current_canvas(app))
|
||||
@@ -80,6 +83,7 @@ impl Opened {
|
||||
&& self.layer == app.layer
|
||||
&& self.diagram == current_diagram(app)
|
||||
&& app.project().contains(self.request.object)
|
||||
&& target_matches(app, &self.request)
|
||||
&& self
|
||||
.request
|
||||
.canvas
|
||||
@@ -92,6 +96,32 @@ impl Opened {
|
||||
}
|
||||
}
|
||||
|
||||
fn target_matches(app: &App, request: &Request) -> bool {
|
||||
use crate::diagram_usage::Target;
|
||||
let Some(target) = request.selection_target.as_deref() else {
|
||||
return true;
|
||||
};
|
||||
if request.canvas.is_none() || !app.multiselect_target_visible(target) {
|
||||
return false;
|
||||
}
|
||||
match target {
|
||||
Target::Placement(id) => {
|
||||
request.placement == Some(*id)
|
||||
&& request
|
||||
.canvas
|
||||
.and_then(|context| app.project().diagrams.get(&context.diagram))
|
||||
.and_then(|diagram| diagram.nodes.get(id))
|
||||
.is_some_and(|node| node.element == request.object || node.id == request.object)
|
||||
}
|
||||
Target::Port { port, .. } => request.placement.is_none() && *port == request.object,
|
||||
Target::Edge(id) => request.placement.is_none() && *id == request.object,
|
||||
Target::StateRow { relation, .. } => {
|
||||
request.placement.is_none() && *relation == request.object
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn current_canvas(app: &App) -> Option<route_edit::Context> {
|
||||
if app.page == Page::Behavior {
|
||||
app.state_context()
|
||||
@@ -126,7 +156,22 @@ pub fn trigger(app: &App) -> iced::Element<'_, Message> {
|
||||
request.canvas = app.route_context();
|
||||
} else if app.page == Page::Behavior {
|
||||
request.canvas = app.state_context();
|
||||
if let Some(context) = request.canvas
|
||||
if let Some(target) = app.diagram_selection.as_ref().and_then(|s| s.primary()) {
|
||||
request.selection_target = Some(Box::new(target.clone()));
|
||||
request.placement = match target {
|
||||
crate::diagram_usage::Target::Placement(id) => {
|
||||
if let Some(node) = request
|
||||
.canvas
|
||||
.and_then(|c| app.project().diagrams.get(&c.diagram))
|
||||
.and_then(|d| d.nodes.get(id))
|
||||
{
|
||||
request.object = node.element;
|
||||
}
|
||||
Some(*id)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
} else if let Some(context) = request.canvas
|
||||
&& let Some(node) = app.project().diagrams[&context.diagram]
|
||||
.nodes
|
||||
.values()
|
||||
@@ -187,12 +232,112 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
|
||||
on_close: Message::Menu(Event::Close),
|
||||
});
|
||||
}
|
||||
if app.page == Page::Behavior
|
||||
&& let Some(menu) = crate::behavior::state_menu::popup(app, opened, clean)
|
||||
let mut entries = vec![Entry::title(crate::views::short(p.named(id), 38))];
|
||||
if request.canvas.is_some()
|
||||
&& let Some(context) = app.multiselect_context()
|
||||
{
|
||||
use crate::app::multiselect::{Choice, Event as SelectionEvent};
|
||||
use crate::diagram_usage::Target;
|
||||
let view = request
|
||||
.canvas
|
||||
.map(|context| context.diagram)
|
||||
.map(|d| projection::project_view_with_index(p, d, app.document.view_index()));
|
||||
let target = request
|
||||
.selection_target
|
||||
.as_deref()
|
||||
.cloned()
|
||||
.or_else(|| request.placement.map(Target::Placement))
|
||||
.or_else(|| {
|
||||
view.as_ref()?
|
||||
.ports
|
||||
.iter()
|
||||
.find(|port| port.element == id)
|
||||
.map(|port| Target::Port {
|
||||
port: port.element,
|
||||
placement: port.owner,
|
||||
})
|
||||
})
|
||||
.or_else(|| {
|
||||
view.as_ref()?
|
||||
.edges
|
||||
.iter()
|
||||
.any(|edge| edge.id == id)
|
||||
.then_some(Target::Edge(id))
|
||||
});
|
||||
if let Some(target) = target {
|
||||
let selected = app
|
||||
.diagram_selection
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.contains(&target));
|
||||
entries.push(item(
|
||||
if selected {
|
||||
"Remove from selection"
|
||||
} else {
|
||||
"Add to selection"
|
||||
},
|
||||
Message::DiagramSelection(SelectionEvent::Choose(
|
||||
context,
|
||||
target.clone(),
|
||||
if selected {
|
||||
Choice::Remove
|
||||
} else {
|
||||
Choice::Add
|
||||
},
|
||||
)),
|
||||
true,
|
||||
));
|
||||
if let Some(snapshot) = app.multiselect_snapshot().filter(|s| s.targets().len() > 1)
|
||||
&& selected
|
||||
{
|
||||
entries.push(Entry::title(format!(
|
||||
"{} selected",
|
||||
snapshot.targets().len()
|
||||
)));
|
||||
entries.push(item(
|
||||
"Inspect this element",
|
||||
Message::DiagramSelection(SelectionEvent::Choose(
|
||||
context,
|
||||
target.clone(),
|
||||
Choice::Inspect,
|
||||
)),
|
||||
true,
|
||||
));
|
||||
entries.push(item(
|
||||
"Select only this element",
|
||||
Message::DiagramSelection(SelectionEvent::Choose(
|
||||
context,
|
||||
target,
|
||||
Choice::Replace,
|
||||
)),
|
||||
true,
|
||||
));
|
||||
entries.push(item(
|
||||
"Hide selected placements",
|
||||
Message::DiagramSelection(SelectionEvent::Hide(snapshot.clone())),
|
||||
crate::app::multiselect::placements(&snapshot).is_some(),
|
||||
));
|
||||
entries.push(Entry::disabled(
|
||||
"Delete selected from model (not yet available)",
|
||||
));
|
||||
return Some(Menu {
|
||||
token: opened.token,
|
||||
anchor: request.anchor,
|
||||
entries,
|
||||
on_close: Message::Menu(Event::Close),
|
||||
});
|
||||
}
|
||||
entries.push(Entry::separator());
|
||||
}
|
||||
}
|
||||
if app.page == Page::Behavior
|
||||
&& let Some(mut menu) = crate::behavior::state_menu::popup(app, opened, clean)
|
||||
{
|
||||
if entries.len() > 1 {
|
||||
entries.extend(menu.entries.into_iter().skip(1));
|
||||
menu.entries = entries;
|
||||
}
|
||||
return Some(menu);
|
||||
}
|
||||
let mut entries = vec![Entry::title(crate::views::short(p.named(id), 38))];
|
||||
if !clean {
|
||||
entries.push(Entry::title("Apply or revert pending edits first"));
|
||||
}
|
||||
|
||||
@@ -468,6 +468,7 @@ fn diagram(app: &App) -> View<'_, Message> {
|
||||
.align_y(iced::Center);
|
||||
let canvas: View<'_, Message> = if let Some(id) = app.active_diagram {
|
||||
let program = DiagramCanvas {
|
||||
selection: app.diagram_selection.as_ref(),
|
||||
allocation: Some(app.allocation_capability()),
|
||||
project: app.project(),
|
||||
view_index: Some(app.document.view_index()),
|
||||
@@ -621,6 +622,37 @@ fn diagram(app: &App) -> View<'_, Message> {
|
||||
)
|
||||
.is_some_and(|(source, target)| source != edge.source || target != edge.target)
|
||||
});
|
||||
let selection = app.multiselect_snapshot();
|
||||
let count = selection.as_ref().map_or(0, |s| s.targets().len());
|
||||
let mut selection_controls =
|
||||
row![label(format!("{count} selected")).wrapping(widget::text::Wrapping::None)]
|
||||
.spacing(7)
|
||||
.align_y(iced::Center)
|
||||
.height(32);
|
||||
if let Some(snapshot) = selection.as_ref().filter(|s| s.targets().len() > 1) {
|
||||
if crate::app::multiselect::placements(snapshot).is_some() {
|
||||
selection_controls = selection_controls.push(action(
|
||||
"Hide selected",
|
||||
Message::DiagramSelection(crate::app::multiselect::Event::Hide(
|
||||
snapshot.clone(),
|
||||
)),
|
||||
));
|
||||
}
|
||||
selection_controls = selection_controls
|
||||
.push(action(
|
||||
"Clear selection",
|
||||
Message::DiagramSelection(crate::app::multiselect::Event::Clear(
|
||||
snapshot.context(),
|
||||
)),
|
||||
))
|
||||
.push(
|
||||
label(format!(
|
||||
"Properties edit only {}",
|
||||
short(app.project().named(app.selected), 36)
|
||||
))
|
||||
.wrapping(widget::text::Wrapping::None),
|
||||
);
|
||||
}
|
||||
let hint = if let Some(feedback) = &app.allocation_drag.feedback {
|
||||
feedback.text.as_str()
|
||||
} else if let Some(session) = &app.connection {
|
||||
@@ -633,16 +665,37 @@ fn diagram(app: &App) -> View<'_, Message> {
|
||||
} else {
|
||||
session.tool.hint()
|
||||
}
|
||||
} else if count > 1 {
|
||||
if selection
|
||||
.as_ref()
|
||||
.is_some_and(|s| crate::app::multiselect::placements(s).is_some())
|
||||
&& projection != Some(ProjectionKind::Allocation)
|
||||
{
|
||||
"Shift-click to add/remove · drag selected to move · right-click for actions · scroll to zoom"
|
||||
} else {
|
||||
"Shift-click to add/remove · right-click for selection actions · Esc clears selection"
|
||||
}
|
||||
} else if promoted {
|
||||
"Expand endpoint containers to edit this connection route"
|
||||
} else if edge.is_some() {
|
||||
"Right-click for routing · drag line / bend / label · Insert adds a bend · Esc cancels"
|
||||
} else {
|
||||
"Right-click for actions · drag to arrange · scroll to zoom"
|
||||
"Shift-click to add/remove · right-click for actions · drag to arrange · scroll to zoom"
|
||||
};
|
||||
// Status is outside the measured canvas. Clipping prevents long text
|
||||
// from painting over work headers or the dedicated destination slot.
|
||||
tools = tools.push(container(label(hint)).height(30).width(Fill).clip(true));
|
||||
// Selection changes must not resize the measured canvas or refit its
|
||||
// camera. Reserve both rows even when no group actions are available.
|
||||
tools = tools.push(
|
||||
container(
|
||||
column![
|
||||
selection_controls,
|
||||
label(hint).wrapping(widget::text::Wrapping::None)
|
||||
]
|
||||
.spacing(3),
|
||||
)
|
||||
.height(56)
|
||||
.width(Fill)
|
||||
.clip(true),
|
||||
);
|
||||
}
|
||||
column![container(tools).padding(14), canvas].into()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user