Merge ordinary and State multiselect into the desktop candidate
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user