Merge ordinary and State multiselect into the desktop candidate

This commit is contained in:
2026-09-23 11:17:21 -07:00
139 changed files with 21151 additions and 72 deletions
@@ -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)
);
}
+473 -27
View File
@@ -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
+4 -1
View File
@@ -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());
}
}
+51 -4
View File
@@ -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…",