4091 lines
153 KiB
Rust
4091 lines
153 KiB
Rust
//! 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,
|
|
widget::{
|
|
Action,
|
|
canvas::{self, Frame, Path, Stroke, Text},
|
|
},
|
|
};
|
|
use oa_core::{
|
|
projection::{self, ProjectedNode, ProjectedView},
|
|
routing::{self, RoutedView},
|
|
*,
|
|
};
|
|
use std::{
|
|
cell::RefCell,
|
|
collections::{BTreeMap, BTreeSet},
|
|
sync::Arc,
|
|
};
|
|
|
|
/// The scene is clipped below this strip, keeping guidance legible at every pan.
|
|
pub const GUIDANCE_HEIGHT: f32 = 42.;
|
|
|
|
pub struct StateCanvas<'a> {
|
|
pub removal_input: &'a crate::removal_input::Lifetime,
|
|
pub project: &'a Project,
|
|
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,
|
|
pub zoom: f32,
|
|
pub pan: Vector,
|
|
pub editable: bool,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
pub struct State {
|
|
removal_lease: Option<crate::removal_input::Lease>,
|
|
removal_viewport: Option<(f32, Vector, Rectangle)>,
|
|
context: Option<Context>,
|
|
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>,
|
|
bend: Option<BendGesture>,
|
|
bend_preview: Option<EdgeRoute>,
|
|
pan_from: Option<(mouse::Button, Point)>,
|
|
viewport: Option<Size>,
|
|
attachment_viewport: Option<(f32, Vector, Rectangle)>,
|
|
scene: RefCell<Option<CachedScene>>,
|
|
geometry: canvas::Cache,
|
|
paint_key: RefCell<Option<PaintKey>>,
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
enum Press {
|
|
// Starts are window coordinates: contextual controls can move the canvas
|
|
// between input events without moving the physical pointer.
|
|
Move {
|
|
node: Id,
|
|
start: Point,
|
|
original: Position,
|
|
},
|
|
Create {
|
|
owner: Id,
|
|
kind: Kind,
|
|
start: Point,
|
|
},
|
|
Connect {
|
|
source: Id,
|
|
first: bool,
|
|
start: Point,
|
|
dragged: bool,
|
|
},
|
|
Reconnect {
|
|
relation: Id,
|
|
endpoint: Endpoint,
|
|
/// Handles require a drag; an explicitly chosen reconnect tool accepts a click.
|
|
start: Option<Point>,
|
|
},
|
|
Attachment {
|
|
relation: Id,
|
|
end: EdgeEnd,
|
|
placement: Id,
|
|
start: Point,
|
|
},
|
|
Bend {
|
|
relation: Id,
|
|
},
|
|
}
|
|
|
|
struct BendGesture {
|
|
start: Point,
|
|
route: EdgeRoute,
|
|
index: usize,
|
|
baseline: Arc<Scene>,
|
|
}
|
|
|
|
#[derive(Clone, Copy, PartialEq)]
|
|
struct AttachmentPreview {
|
|
relation: Id,
|
|
end: EdgeEnd,
|
|
placement: Id,
|
|
attachment: EdgeAttachment,
|
|
}
|
|
|
|
struct Scene {
|
|
view: ProjectedView,
|
|
routed: RoutedView,
|
|
symbol_labels: BTreeMap<Id, Rectangle>,
|
|
}
|
|
struct CachedScene {
|
|
context: Context,
|
|
group_preview: Option<(Snapshot, Position)>,
|
|
preview: Option<(Id, Position)>,
|
|
attachment_preview: Option<AttachmentPreview>,
|
|
bend_preview: Option<EdgeRoute>,
|
|
scene: Arc<Scene>,
|
|
}
|
|
#[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>,
|
|
selected: Id,
|
|
zoom: f32,
|
|
pan: Vector,
|
|
}
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
enum Hit {
|
|
Node { placement: Id, element: Id },
|
|
Transition(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;
|
|
self.bend_preview = None;
|
|
self.attachment_viewport = None;
|
|
}
|
|
fn cancel_gesture(&mut self) {
|
|
self.cancel_edit();
|
|
self.pan_from = None;
|
|
}
|
|
fn reset(&mut self) {
|
|
self.cancel_gesture();
|
|
self.source = None;
|
|
self.pointer = None;
|
|
}
|
|
}
|
|
|
|
fn point(p: Position) -> Point {
|
|
Point::new(p.x, p.y)
|
|
}
|
|
fn position(p: Point) -> Position {
|
|
Position { x: p.x, y: p.y }
|
|
}
|
|
fn rectangle(n: &ProjectedNode) -> Rectangle {
|
|
Rectangle::new(point(n.position), Size::new(n.size.width, n.size.height))
|
|
}
|
|
fn expanded(r: Rectangle, amount: f32) -> Rectangle {
|
|
Rectangle::new(
|
|
Point::new(r.x - amount, r.y - amount),
|
|
Size::new(r.width + 2. * amount, r.height + 2. * amount),
|
|
)
|
|
}
|
|
|
|
fn overlap_area(a: Rectangle, b: Rectangle) -> f32 {
|
|
((a.x + a.width).min(b.x + b.width) - a.x.max(b.x)).max(0.)
|
|
* ((a.y + a.height).min(b.y + b.height) - a.y.max(b.y)).max(0.)
|
|
}
|
|
|
|
fn segment_crosses(rect: Rectangle, a: Position, b: Position) -> bool {
|
|
let mut enter: f32 = 0.;
|
|
let mut exit: f32 = 1.;
|
|
for (start, delta, low, high) in [
|
|
(a.x, b.x - a.x, rect.x, rect.x + rect.width),
|
|
(a.y, b.y - a.y, rect.y, rect.y + rect.height),
|
|
] {
|
|
if delta.abs() < f32::EPSILON {
|
|
if start < low || start > high {
|
|
return false;
|
|
}
|
|
} else {
|
|
let first = (low - start) / delta;
|
|
let last = (high - start) / delta;
|
|
enter = enter.max(first.min(last));
|
|
exit = exit.min(first.max(last));
|
|
if enter > exit {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
true
|
|
}
|
|
|
|
/// Twelve nearby candidates, settled in stable placement-ID order. This is
|
|
/// derived scene geometry: names follow previews without changing shared routes.
|
|
fn symbol_labels(
|
|
project: &Project,
|
|
view: &ProjectedView,
|
|
routed: &RoutedView,
|
|
) -> BTreeMap<Id, Rectangle> {
|
|
let nodes: BTreeMap<_, _> = view.nodes.iter().map(|n| (n.id, n)).collect();
|
|
let headers: BTreeMap<_, _> = view
|
|
.nodes
|
|
.iter()
|
|
.map(|n| {
|
|
(
|
|
n.id,
|
|
Rectangle::new(
|
|
point(n.position),
|
|
Size::new(
|
|
n.size.width,
|
|
projection::state_header_height(project, n.element).min(n.size.height),
|
|
),
|
|
),
|
|
)
|
|
})
|
|
.collect();
|
|
let segments: Vec<_> = routed
|
|
.edges
|
|
.values()
|
|
.flat_map(|edge| edge.points.windows(2).map(|pair| (pair[0], pair[1])))
|
|
.collect();
|
|
let edge_labels: Vec<_> = routed
|
|
.edges
|
|
.values()
|
|
.filter(|edge| !edge.label.text.is_empty())
|
|
.map(|edge| {
|
|
Rectangle::new(
|
|
point(edge.label.bounds.position),
|
|
Size::new(edge.label.bounds.size.width, edge.label.bounds.size.height),
|
|
)
|
|
})
|
|
.collect();
|
|
let mut placed: BTreeMap<Id, Rectangle> = BTreeMap::new();
|
|
for node in nodes
|
|
.values()
|
|
.copied()
|
|
.filter(|n| projection::state_symbol_size(project.elements[&n.element].kind()).is_some())
|
|
{
|
|
let r = rectangle(node);
|
|
let name = short(&project.elements[&node.element].name, 220., 11.);
|
|
let size = Size::new((name.chars().count() as f32 * 6.6).ceil().max(7.), 18.);
|
|
let mut ancestors = BTreeSet::new();
|
|
let mut parent = node.visual_parent;
|
|
while let Some(id) = parent {
|
|
if !ancestors.insert(id) {
|
|
break;
|
|
}
|
|
parent = nodes.get(&id).and_then(|node| node.visual_parent);
|
|
}
|
|
let obstacles: Vec<_> = nodes
|
|
.values()
|
|
.map(|n| {
|
|
if ancestors.contains(&n.id) {
|
|
headers[&n.id]
|
|
} else {
|
|
rectangle(n)
|
|
}
|
|
})
|
|
.collect();
|
|
let center = r.center();
|
|
let sides = |gap| {
|
|
[
|
|
Point::new(r.x + r.width + gap, center.y - size.height / 2.),
|
|
Point::new(center.x - size.width / 2., r.y + r.height + gap),
|
|
Point::new(r.x - gap - size.width, center.y - size.height / 2.),
|
|
Point::new(center.x - size.width / 2., r.y - gap - size.height),
|
|
]
|
|
};
|
|
let candidates = sides(7.)
|
|
.into_iter()
|
|
.chain([
|
|
Point::new(r.x + r.width + 7., r.y - size.height - 7.),
|
|
Point::new(r.x + r.width + 7., r.y + r.height + 7.),
|
|
Point::new(r.x - size.width - 7., r.y - size.height - 7.),
|
|
Point::new(r.x - size.width - 7., r.y + r.height + 7.),
|
|
])
|
|
.chain(sides(25.));
|
|
let score = |label: Rectangle| {
|
|
let clear = expanded(label, 3.);
|
|
let mut collisions = segments
|
|
.iter()
|
|
.filter(|(a, b)| segment_crosses(clear, *a, *b))
|
|
.count();
|
|
let mut area = 0.;
|
|
for obstacle in obstacles
|
|
.iter()
|
|
.chain(edge_labels.iter())
|
|
.chain(placed.values())
|
|
{
|
|
let overlap = overlap_area(clear, *obstacle);
|
|
collisions += usize::from(overlap > 0.);
|
|
area += overlap;
|
|
}
|
|
for ancestor in &ancestors {
|
|
let outside = (label.width * label.height
|
|
- overlap_area(label, rectangle(nodes[ancestor])))
|
|
.max(0.);
|
|
collisions += usize::from(outside > 0.1);
|
|
area += outside;
|
|
}
|
|
(collisions, area)
|
|
};
|
|
let chosen = candidates
|
|
.map(|at| Rectangle::new(at, size))
|
|
.enumerate()
|
|
.map(|(index, label)| (score(label), index, label))
|
|
.min_by(|a, b| {
|
|
a.0.0
|
|
.cmp(&b.0.0)
|
|
.then(a.0.1.total_cmp(&b.0.1))
|
|
.then(a.1.cmp(&b.1))
|
|
})
|
|
.expect("the fixed candidate list is nonempty")
|
|
.2;
|
|
placed.insert(node.id, chosen);
|
|
}
|
|
placed
|
|
}
|
|
fn label_text(frame: &mut Frame, value: impl Into<String>, at: Point, size: f32, color: Color) {
|
|
frame.fill_text(Text {
|
|
content: value.into(),
|
|
position: at,
|
|
size: size.into(),
|
|
color,
|
|
..Default::default()
|
|
});
|
|
}
|
|
fn short(value: &str, width: f32, font: f32) -> String {
|
|
let count = (width / (font * 0.6)).max(1.) as usize;
|
|
if value.chars().count() <= count {
|
|
value.into()
|
|
} else {
|
|
value
|
|
.chars()
|
|
.take(count.saturating_sub(1))
|
|
.chain(['…'])
|
|
.collect()
|
|
}
|
|
}
|
|
fn line(a: Point, b: Point) -> Path {
|
|
Path::new(|p| {
|
|
p.move_to(a);
|
|
p.line_to(b);
|
|
})
|
|
}
|
|
fn polyline(points: &[Position]) -> Path {
|
|
Path::new(|path| {
|
|
if let Some(first) = points.first() {
|
|
path.move_to(point(*first));
|
|
for next in &points[1..] {
|
|
path.line_to(point(*next));
|
|
}
|
|
}
|
|
})
|
|
}
|
|
fn internal_rows<'a>(project: &'a Project, node: &ProjectedNode) -> Vec<(&'a Relation, Rectangle)> {
|
|
if node.collapsed
|
|
|| !project
|
|
.elements
|
|
.get(&node.element)
|
|
.is_some_and(|e| e.kind().is_stable_state())
|
|
{
|
|
return vec![];
|
|
}
|
|
let relations: Vec<_> = project
|
|
.relations
|
|
.values()
|
|
.filter(|r| {
|
|
matches!(&r.body,
|
|
RelationBody::StateTransition { source, transition_kind: TransitionKind::Internal, .. }
|
|
if *source == node.element)
|
|
})
|
|
.collect();
|
|
let top = node.position.y + projection::state_header_height(project, node.element)
|
|
- relations.len() as f32 * 56.;
|
|
relations
|
|
.into_iter()
|
|
.enumerate()
|
|
.map(|(i, relation)| {
|
|
(
|
|
relation,
|
|
Rectangle::new(
|
|
Point::new(node.position.x + 8., top + i as f32 * 56.),
|
|
Size::new((node.size.width - 16.).max(1.), 52.),
|
|
),
|
|
)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// These rows represent maintained StateAction relations, not containment of
|
|
/// the referenced work. Their identity remains selectable independently.
|
|
fn state_action_rows<'a>(
|
|
project: &'a Project,
|
|
node: &ProjectedNode,
|
|
) -> Vec<(&'a Relation, Rectangle)> {
|
|
if node.collapsed
|
|
|| !project
|
|
.elements
|
|
.get(&node.element)
|
|
.is_some_and(|e| e.kind().is_stable_state())
|
|
{
|
|
return vec![];
|
|
}
|
|
let mut actions: Vec<_> = project.relations.values().filter(|r| matches!(r.body, RelationBody::StateAction { source, .. } if source == node.element)).collect();
|
|
actions.sort_by_key(|r| match r.body {
|
|
RelationBody::StateAction { phase, .. } => (
|
|
match phase {
|
|
StateActionKind::Entry => 0,
|
|
StateActionKind::Do => 1,
|
|
StateActionKind::Exit => 2,
|
|
},
|
|
r.id,
|
|
),
|
|
_ => unreachable!(),
|
|
});
|
|
let internal_height = internal_rows(project, node).len() as f32 * 56.;
|
|
let top = node.position.y + projection::state_header_height(project, node.element)
|
|
- internal_height
|
|
- actions.len() as f32 * 28.;
|
|
actions
|
|
.into_iter()
|
|
.enumerate()
|
|
.map(|(index, relation)| {
|
|
(
|
|
relation,
|
|
Rectangle::new(
|
|
Point::new(node.position.x + 8., top + index as f32 * 28.),
|
|
Size::new((node.size.width - 16.).max(1.), 26.),
|
|
),
|
|
)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Action and internal-transition rows are selectable relations, not edges or
|
|
/// placements. Match the exact rows rendered in this dedicated machine view.
|
|
pub(crate) fn visible_row_relation(
|
|
project: &Project,
|
|
diagram: Id,
|
|
view: &ProjectedView,
|
|
id: Id,
|
|
) -> bool {
|
|
let Some(machine) = project
|
|
.diagrams
|
|
.get(&diagram)
|
|
.and_then(|d| d.projection.state_machine())
|
|
else {
|
|
return false;
|
|
};
|
|
let Some(relation) = project.relations.get(&id) else {
|
|
return false;
|
|
};
|
|
let source = match relation.body {
|
|
RelationBody::StateAction { source, .. } => source,
|
|
RelationBody::StateTransition {
|
|
source,
|
|
target,
|
|
transition_kind: TransitionKind::Internal,
|
|
..
|
|
} if projection::state_machine_contains(project, machine, target) => source,
|
|
_ => return false,
|
|
};
|
|
if !projection::state_machine_contains(project, machine, source) {
|
|
return false;
|
|
}
|
|
view.nodes
|
|
.iter()
|
|
.filter(|node| node.element == source)
|
|
.any(|node| {
|
|
state_action_rows(project, node)
|
|
.into_iter()
|
|
.chain(internal_rows(project, node))
|
|
.any(|(row, _)| row.id == id)
|
|
})
|
|
}
|
|
|
|
/// 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();
|
|
};
|
|
let phase = match phase {
|
|
StateActionKind::Entry => "entry",
|
|
StateActionKind::Do => "do",
|
|
StateActionKind::Exit => "exit",
|
|
};
|
|
format!("{phase} / {}", project.named(target))
|
|
}
|
|
|
|
/// Match a typed label field by its structural offset, never by a display-name
|
|
/// substring (names may duplicate or also occur in free-form guards).
|
|
fn retained_label_field(full: &str, painted: &str, start: usize) -> bool {
|
|
start
|
|
< full
|
|
.chars()
|
|
.zip(painted.chars())
|
|
.take_while(|(a, b)| a == b)
|
|
.map(|(c, _)| c.len_utf8())
|
|
.sum::<usize>()
|
|
}
|
|
|
|
fn transition_label_role(
|
|
project: &Project,
|
|
relation: &Relation,
|
|
subject: Id,
|
|
width: f32,
|
|
) -> Option<&'static str> {
|
|
let full = routing::state_transition_summary(project, relation);
|
|
transition_label_role_in_text(project, relation, subject, &short(&full, width, 10.))
|
|
}
|
|
|
|
fn transition_label_role_in_text(
|
|
project: &Project,
|
|
relation: &Relation,
|
|
subject: Id,
|
|
painted: &str,
|
|
) -> Option<&'static str> {
|
|
let RelationBody::StateTransition {
|
|
transition_kind,
|
|
triggers,
|
|
effects,
|
|
effect,
|
|
..
|
|
} = &relation.body
|
|
else {
|
|
return None;
|
|
};
|
|
if project.named(subject).trim().is_empty() {
|
|
return None;
|
|
}
|
|
let full = routing::state_transition_summary(project, relation);
|
|
let mut offset = format!("{transition_kind:?} · ").len();
|
|
for id in triggers {
|
|
if *id == subject && retained_label_field(&full, painted, offset) {
|
|
return Some("transition trigger");
|
|
}
|
|
offset += project.named(*id).len() + " OR ".len();
|
|
}
|
|
if !effects.is_empty() {
|
|
// The canonical routing summary appends typed effects before optional
|
|
// authored effect prose. Derive its offset from that exact suffix.
|
|
let mut suffix = format!(
|
|
" / {}",
|
|
effects
|
|
.iter()
|
|
.map(|id| project.named(*id))
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
);
|
|
if !effect.is_empty() {
|
|
suffix.push_str(&format!(" / {effect}"));
|
|
}
|
|
if let Some(prefix) = full.strip_suffix(&suffix) {
|
|
offset = prefix.len() + " / ".len();
|
|
for id in effects {
|
|
if *id == subject && retained_label_field(&full, painted, offset) {
|
|
return Some("transition effect");
|
|
}
|
|
offset += project.named(*id).len() + ", ".len();
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Actual state compartments and painted transition-reference labels. Generic
|
|
/// projected node/edge identities are handled by the shared saved-view query.
|
|
pub(crate) fn diagram_usages(
|
|
project: &Project,
|
|
diagram: &Diagram,
|
|
view: &ProjectedView,
|
|
subject: Id,
|
|
) -> Vec<crate::diagram_usage::Usage> {
|
|
use crate::diagram_usage::{Surface, Target, Usage};
|
|
if diagram.projection.state_machine().is_none() {
|
|
return vec![];
|
|
}
|
|
let mut result = Vec::new();
|
|
for node in &view.nodes {
|
|
for (relation, bounds) in state_action_rows(project, node) {
|
|
let label = state_action_label(project, relation);
|
|
let painted = short(&label, bounds.width - 12., 11.);
|
|
let role = if relation.id == subject {
|
|
Some("state action")
|
|
} else if relation.target() == subject
|
|
&& retained_label_field(
|
|
&label,
|
|
&painted,
|
|
label.len() - project.named(subject).len(),
|
|
)
|
|
{
|
|
Some("state action work")
|
|
} else {
|
|
None
|
|
};
|
|
if let Some(role) = role {
|
|
result.push(Usage {
|
|
subject,
|
|
surface: Surface::Saved(diagram.id),
|
|
target: Target::StateRow {
|
|
placement: node.id,
|
|
relation: relation.id,
|
|
},
|
|
role: role.into(),
|
|
bounds,
|
|
});
|
|
}
|
|
}
|
|
for (relation, bounds) in internal_rows(project, node) {
|
|
let role = if relation.id == subject {
|
|
Some("internal transition")
|
|
} else {
|
|
transition_label_role(project, relation, subject, bounds.width - 12.)
|
|
};
|
|
if let Some(role) = role {
|
|
result.push(Usage {
|
|
subject,
|
|
surface: Surface::Saved(diagram.id),
|
|
target: Target::StateRow {
|
|
placement: node.id,
|
|
relation: relation.id,
|
|
},
|
|
role: role.into(),
|
|
bounds,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
for edge in routing::route_diagram(project, diagram, view)
|
|
.edges
|
|
.values()
|
|
{
|
|
let Some(relation) = project.relations.get(&edge.id) else {
|
|
continue;
|
|
};
|
|
if edge.id == subject || edge.label.text.is_empty() {
|
|
continue;
|
|
}
|
|
// Routing already shortens external summaries before the canvas applies
|
|
// its width limit. Compare against precisely that doubly shortened text.
|
|
let painted = short(&edge.label.text, edge.label.bounds.size.width - 10., 10.);
|
|
if let Some(role) = transition_label_role_in_text(project, relation, subject, &painted) {
|
|
result.push(Usage {
|
|
subject,
|
|
surface: Surface::Saved(diagram.id),
|
|
target: Target::Edge(edge.id),
|
|
role: role.into(),
|
|
bounds: crate::diagram_usage::edge_focus_bounds(edge),
|
|
});
|
|
}
|
|
}
|
|
result
|
|
}
|
|
|
|
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))?;
|
|
if *modifiers == keyboard::Modifiers::SHIFT {
|
|
let Some(input_generation) = self.removal_input.idle() else {
|
|
return Some(Action::capture());
|
|
};
|
|
return Some(
|
|
Action::publish(Event::ModelRemoval(Box::new(
|
|
crate::app::saved_removal::Event::Begin {
|
|
input_generation,
|
|
context: self.context,
|
|
snapshot: Box::new(snapshot),
|
|
},
|
|
)))
|
|
.and_capture(),
|
|
);
|
|
}
|
|
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 {
|
|
"Use Shift+Delete to review model deletion or 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.)
|
|
} else {
|
|
1.
|
|
}
|
|
}
|
|
fn offset(&self) -> Vector {
|
|
self.pan
|
|
}
|
|
fn world(&self, local: Point) -> Point {
|
|
let translated = local - self.offset();
|
|
Point::new(translated.x / self.scale(), translated.y / self.scale())
|
|
}
|
|
fn content(bounds: Rectangle) -> Rectangle {
|
|
Rectangle::new(
|
|
Point::new(0., GUIDANCE_HEIGHT),
|
|
Size::new(bounds.width, (bounds.height - GUIDANCE_HEIGHT).max(0.)),
|
|
)
|
|
}
|
|
fn valid(&self) -> bool {
|
|
self.diagram == self.context.diagram
|
|
&& state_edit::validate_context(self.project, self.context, self.context).is_ok()
|
|
}
|
|
fn scene(&self, state: &State) -> Option<Arc<Scene>> {
|
|
self.scene_with_bounds(state, None)
|
|
}
|
|
fn scene_with_bounds(&self, state: &State, bounds: Option<Rectangle>) -> Option<Arc<Scene>> {
|
|
if !self.valid() {
|
|
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)
|
|
.flatten();
|
|
let attachment_preview = route_active.then_some(state.attachment_preview).flatten();
|
|
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
|
|
{
|
|
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());
|
|
}
|
|
if let Some(preview) = attachment_preview {
|
|
let mut route = crate::route_edit::stored_route(&diagram, preview.relation);
|
|
route.set_attachment(preview.end, Some(preview.attachment));
|
|
crate::route_edit::with_route(&mut diagram, route);
|
|
}
|
|
if let Some((id, at)) = preview
|
|
&& let Some(node) = diagram.nodes.get(&id)
|
|
{
|
|
let delta = Position {
|
|
x: at.x - node.position.x,
|
|
y: at.y - node.position.y,
|
|
};
|
|
let mut moved = projection::descendant_nodes(self.project, self.diagram, id);
|
|
moved.push(id);
|
|
let deltas: BTreeMap<_, _> = moved.iter().map(|id| (*id, delta)).collect();
|
|
let routes = projection::translated_routes(self.project, &diagram, &deltas);
|
|
for id in moved {
|
|
if let Some(node) = diagram.nodes.get_mut(&id) {
|
|
node.position.x += delta.x;
|
|
node.position.y += delta.y;
|
|
}
|
|
}
|
|
for route in routes {
|
|
if let Some(stored) = diagram.routes.iter_mut().find(|r| r.edge == route.edge) {
|
|
*stored = route;
|
|
}
|
|
}
|
|
}
|
|
let view = projection::project_diagram(self.project, &diagram);
|
|
let routed = if let Some(gesture) = state.bend.as_ref().filter(|_| bend_preview.is_some()) {
|
|
routing::route_edge_preview(
|
|
self.project,
|
|
&diagram,
|
|
&view,
|
|
&gesture.baseline.routed,
|
|
gesture.route.edge,
|
|
)
|
|
} else {
|
|
routing::route_diagram(self.project, &diagram, &view)
|
|
};
|
|
let symbol_labels = symbol_labels(self.project, &view, &routed);
|
|
let scene = Arc::new(Scene {
|
|
view,
|
|
routed,
|
|
symbol_labels,
|
|
});
|
|
*state.scene.borrow_mut() = Some(CachedScene {
|
|
context: self.context,
|
|
group_preview,
|
|
preview,
|
|
attachment_preview,
|
|
bend_preview,
|
|
scene: scene.clone(),
|
|
});
|
|
Some(scene)
|
|
}
|
|
fn symbol_hit(&self, node: &ProjectedNode, at: Point) -> bool {
|
|
let Some(element) = self.project.elements.get(&node.element) else {
|
|
return false;
|
|
};
|
|
let rect = rectangle(node);
|
|
if projection::state_symbol_size(element.kind()).is_none() {
|
|
return rect.contains(at);
|
|
}
|
|
let slop = 5. / self.scale();
|
|
let center = rect.center();
|
|
let delta = at - center;
|
|
match element.kind() {
|
|
Kind::Fork | Kind::Join => expanded(rect, slop).contains(at),
|
|
Kind::Choice => {
|
|
delta.x.abs() / (rect.width / 2. + slop) + delta.y.abs() / (rect.height / 2. + slop)
|
|
<= 1.
|
|
}
|
|
Kind::Terminate => {
|
|
expanded(rect, slop).contains(at)
|
|
&& ((delta.x - delta.y).abs().min((delta.x + delta.y).abs()) <= slop + 2.)
|
|
}
|
|
_ => delta.x.hypot(delta.y) <= rect.width / 2. + slop,
|
|
}
|
|
}
|
|
fn node_at<'a>(&self, scene: &'a Scene, world: Point) -> Option<&'a ProjectedNode> {
|
|
scene
|
|
.view
|
|
.nodes
|
|
.iter()
|
|
.filter(|n| {
|
|
self.symbol_hit(n, world)
|
|
|| scene
|
|
.symbol_labels
|
|
.get(&n.id)
|
|
.is_some_and(|label| label.contains(world))
|
|
})
|
|
.max_by_key(|n| (n.depth, n.id))
|
|
}
|
|
fn vertex_at(&self, scene: &Scene, world: Point) -> Option<Id> {
|
|
self.node_at(scene, world)
|
|
.filter(|n| {
|
|
self.project
|
|
.elements
|
|
.get(&n.element)
|
|
.is_some_and(|e| e.kind().is_state())
|
|
})
|
|
.map(|n| n.element)
|
|
}
|
|
fn hit(&self, scene: &Scene, world: Point) -> Option<Hit> {
|
|
let node = self.node_at(scene, world);
|
|
if let Some(node) = node {
|
|
if let Some((relation, _)) = state_action_rows(self.project, node)
|
|
.into_iter()
|
|
.find(|(_, r)| r.contains(world))
|
|
{
|
|
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::Row {
|
|
placement: node.id,
|
|
relation: relation.id,
|
|
});
|
|
}
|
|
let container = self
|
|
.project
|
|
.elements
|
|
.get(&node.element)
|
|
.is_some_and(|e| matches!(e.kind(), Kind::Region | Kind::StateMachine))
|
|
|| scene
|
|
.view
|
|
.nodes
|
|
.iter()
|
|
.any(|n| n.visual_parent == Some(node.id));
|
|
let rect = rectangle(node);
|
|
let border = 5. / self.scale();
|
|
if !container
|
|
|| world.y <= rect.y + projection::state_header_height(self.project, node.element)
|
|
|| world.x < rect.x + border
|
|
|| world.x > rect.x + rect.width - border
|
|
|| world.y > rect.y + rect.height - border
|
|
{
|
|
return Some(Hit::Node {
|
|
placement: node.id,
|
|
element: node.element,
|
|
});
|
|
}
|
|
}
|
|
let edge = scene
|
|
.routed
|
|
.edges
|
|
.values()
|
|
.filter_map(|edge| {
|
|
let label = Rectangle::new(
|
|
point(edge.label.bounds.position),
|
|
Size::new(edge.label.bounds.size.width, edge.label.bounds.size.height),
|
|
);
|
|
let distance = if label.contains(world) {
|
|
0.
|
|
} else {
|
|
routing::project_onto_polyline(&edge.points, position(world))?.distance
|
|
};
|
|
(distance <= 6. / self.scale()).then_some((edge.id, distance))
|
|
})
|
|
.min_by(|a, b| {
|
|
a.1.total_cmp(&b.1)
|
|
.then_with(|| (a.0 != self.selected).cmp(&(b.0 != self.selected)))
|
|
.then(a.0.cmp(&b.0))
|
|
});
|
|
edge.map(|(id, _)| Hit::Transition(id)).or_else(|| {
|
|
node.map(|n| Hit::Node {
|
|
placement: n.id,
|
|
element: n.element,
|
|
})
|
|
})
|
|
}
|
|
fn endpoint_at(&self, scene: &Scene, world: Point) -> Option<(Id, Endpoint)> {
|
|
let route = scene.routed.edges.get(&self.selected)?;
|
|
[
|
|
(Endpoint::Source, route.points.first()),
|
|
(Endpoint::Target, route.points.last()),
|
|
]
|
|
.into_iter()
|
|
.filter_map(|(endpoint, p)| p.map(|p| (endpoint, world.distance(point(*p)))))
|
|
.filter(|(_, distance)| *distance <= 8. / self.scale())
|
|
.min_by(|a, b| a.1.total_cmp(&b.1))
|
|
.map(|(endpoint, _)| (route.id, endpoint))
|
|
}
|
|
fn bend_at(&self, scene: &Scene, world: Point) -> Option<usize> {
|
|
let edge = scene.routed.edges.get(&self.selected)?;
|
|
if edge.custom_suspended {
|
|
return None;
|
|
}
|
|
edge.pins
|
|
.iter()
|
|
.enumerate()
|
|
.filter_map(|(index, pin)| {
|
|
let distance = world.distance(point(*pin));
|
|
(distance <= 8. / self.scale()).then_some((index, distance))
|
|
})
|
|
.min_by(|a, b| a.1.total_cmp(&b.1).then(a.0.cmp(&b.0)))
|
|
.map(|(index, _)| index)
|
|
}
|
|
/// Preserve the rendered traversal index; overlapping runs can have the
|
|
/// same world point but different positions among the authored pins.
|
|
fn segment_at(&self, scene: &Scene, relation: Id, world: Point) -> Option<(usize, Position)> {
|
|
let edge = scene.routed.edges.get(&relation)?;
|
|
if edge.custom_suspended {
|
|
return None;
|
|
}
|
|
edge.points
|
|
.windows(2)
|
|
.enumerate()
|
|
.filter(|(index, _)| *index > 0 && index + 2 < edge.points.len())
|
|
.filter_map(|(index, span)| {
|
|
let projection = routing::project_onto_polyline(span, position(world))?;
|
|
(projection.distance <= 7. / self.scale()).then_some((index, projection.distance))
|
|
})
|
|
.min_by(|a, b| a.1.total_cmp(&b.1).then(a.0.cmp(&b.0)))
|
|
.map(|(index, _)| (index, position(world)))
|
|
}
|
|
fn begin_bend(
|
|
&self,
|
|
state: &mut State,
|
|
scene: Arc<Scene>,
|
|
window: Point,
|
|
route: EdgeRoute,
|
|
index: usize,
|
|
bounds: Rectangle,
|
|
) {
|
|
state.press = Some(Press::Bend {
|
|
relation: route.edge,
|
|
});
|
|
state.attachment_viewport = Some((self.zoom, self.pan, bounds));
|
|
state.bend = Some(BendGesture {
|
|
start: window,
|
|
route,
|
|
index,
|
|
baseline: scene,
|
|
});
|
|
state.bend_preview = None;
|
|
}
|
|
fn move_bend_pointer(&self, state: &mut State, window: Point, local: Point) {
|
|
let Some(gesture) = &state.bend else {
|
|
return;
|
|
};
|
|
if window.distance(gesture.start) < 3. {
|
|
// Also discard a provisional insertion. The exact original route
|
|
// comes from the model, so an off-grid pin is never quantized here.
|
|
state.bend_preview = None;
|
|
return;
|
|
}
|
|
let world = self.world(local);
|
|
if !world.x.is_finite() || !world.y.is_finite() {
|
|
state.cancel_gesture();
|
|
return;
|
|
}
|
|
let mut changed = gesture.route.clone();
|
|
changed.waypoints[gesture.index] = Position {
|
|
x: (world.x / 5.).round() * 5.,
|
|
y: (world.y / 5.).round() * 5.,
|
|
};
|
|
let original =
|
|
crate::route_edit::stored_route(&self.project.diagrams[&self.diagram], changed.edge);
|
|
state.bend_preview = (changed != original).then_some(changed);
|
|
}
|
|
fn owner_at(&self, scene: &Scene, world: Point, kind: Kind) -> Option<Id> {
|
|
self.node_at(scene, world)
|
|
.filter(|n| state_edit::eligible_owner(self.project, self.diagram, n.element, kind))
|
|
.map(|n| n.element)
|
|
}
|
|
fn move_pointer(&self, state: &mut State, window: Point) {
|
|
match state.press.as_mut() {
|
|
Some(Press::Move {
|
|
node,
|
|
start,
|
|
original,
|
|
}) if window.distance(*start) >= 3. || state.preview.is_some() => {
|
|
let delta = (window - *start) / self.scale();
|
|
state.preview = Some((
|
|
*node,
|
|
Position {
|
|
x: original.x + delta.x,
|
|
y: original.y + delta.y,
|
|
},
|
|
));
|
|
}
|
|
Some(Press::Connect { start, dragged, .. }) => {
|
|
*dragged |= window.distance(*start) >= 3.
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
fn gesture_active(&self, state: &State) -> bool {
|
|
self.editable
|
|
&& state.context == Some(self.context)
|
|
&& state.tool == Some(self.tool)
|
|
&& state
|
|
.bend
|
|
.as_ref()
|
|
.is_none_or(|g| g.route.edge == self.selected)
|
|
}
|
|
fn route_viewport_current(&self, state: &State, bounds: Option<Rectangle>) -> bool {
|
|
state
|
|
.attachment_viewport
|
|
.is_none_or(|(zoom, pan, captured)| {
|
|
zoom == self.zoom
|
|
&& pan == self.pan
|
|
&& bounds.is_none_or(|current| current == captured)
|
|
})
|
|
}
|
|
fn move_attachment_pointer(&self, state: &mut State, window: Point, local: Point) {
|
|
let Some(Press::Attachment {
|
|
relation,
|
|
end,
|
|
placement,
|
|
start,
|
|
}) = state.press
|
|
else {
|
|
return;
|
|
};
|
|
if window.distance(start) < 3. {
|
|
// Returning to the press restores the exact persisted optional value,
|
|
// without quantizing an existing attachment or pinning an automatic one.
|
|
state.attachment_preview = None;
|
|
return;
|
|
}
|
|
let Some(owner) = state_edit::attachment_node(self.project, self.diagram, relation, end)
|
|
.filter(|owner| owner.id == placement)
|
|
else {
|
|
state.cancel_gesture();
|
|
return;
|
|
};
|
|
let diagram = &self.project.diagrams[&self.diagram];
|
|
let previous = state
|
|
.attachment_preview
|
|
.map(|preview| preview.attachment.side)
|
|
.or_else(|| {
|
|
diagram
|
|
.routes
|
|
.iter()
|
|
.find(|route| route.edge == relation)
|
|
.and_then(|route| route.attachment(end))
|
|
.map(|attachment| attachment.side)
|
|
});
|
|
let world = self.world(local);
|
|
if !world.x.is_finite() || !world.y.is_finite() {
|
|
state.cancel_gesture();
|
|
return;
|
|
}
|
|
let attachment =
|
|
routing::attachment_from_point(&owner, position(world), previous, 6. / self.scale());
|
|
state.attachment_preview = Some(AttachmentPreview {
|
|
relation,
|
|
end,
|
|
placement,
|
|
attachment,
|
|
});
|
|
}
|
|
fn guidance(&self, state: &State, scene: &Scene, world: Option<Point>) -> String {
|
|
if !self.editable {
|
|
return "Pending edits · apply or cancel them before editing the diagram".into();
|
|
}
|
|
if state.bend.is_some()
|
|
&& self.gesture_active(state)
|
|
&& self.route_viewport_current(state, None)
|
|
{
|
|
return "Move bend · release to keep · Esc cancels".into();
|
|
}
|
|
if self.tool == Tool::Select
|
|
&& state.press.is_none()
|
|
&& scene
|
|
.routed
|
|
.edges
|
|
.get(&self.selected)
|
|
.is_some_and(|edge| !edge.custom_suspended)
|
|
{
|
|
return "Drag line to add a bend · squares move bends · right-click a square to remove"
|
|
.into();
|
|
}
|
|
if let Tool::AdjustAttachment { relation, end } = self.tool {
|
|
return format!(
|
|
"Adjust {end:?} attachment of {} · drag that tip around its existing vertex boundary · Esc cancels · relation unchanged",
|
|
self.project.named(relation)
|
|
);
|
|
}
|
|
let hovered = world.and_then(|p| self.vertex_at(scene, p));
|
|
let press = self.gesture_active(state).then_some(state.press).flatten();
|
|
let source = self.gesture_active(state).then_some(state.source).flatten();
|
|
let reconnect = match press {
|
|
Some(Press::Reconnect {
|
|
relation, endpoint, ..
|
|
}) => Some((relation, endpoint)),
|
|
_ => {
|
|
if let Tool::Reconnect { relation, endpoint } = self.tool {
|
|
Some((relation, endpoint))
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
};
|
|
if let Some((relation, endpoint)) = reconnect {
|
|
return match hovered {
|
|
Some(vertex)
|
|
if state_edit::can_reconnect(
|
|
self.project,
|
|
self.diagram,
|
|
relation,
|
|
endpoint,
|
|
vertex,
|
|
) =>
|
|
{
|
|
format!(
|
|
"Reconnect {endpoint:?} of {} to {} · release to apply",
|
|
self.project.named(relation),
|
|
self.project.named(vertex)
|
|
)
|
|
}
|
|
_ => format!(
|
|
"Reconnect {endpoint:?} of {} · choose a compatible vertex · Esc cancels",
|
|
self.project.named(relation)
|
|
),
|
|
};
|
|
}
|
|
match self.tool {
|
|
Tool::Vertex(_) | Tool::Region => {
|
|
let kind = kind_for_tool(self.tool);
|
|
world.and_then(|p| self.owner_at(scene, p, kind)).map_or_else(
|
|
|| format!("Create {} · click its explicit owning {} · Esc cancels", kind.label(), if kind == Kind::Region { "machine or composite state" } else { "region" }),
|
|
|owner| format!("Create {} inside {} · click to place", kind.label(), self.project.named(owner)))
|
|
}
|
|
Tool::Transition => match source {
|
|
Some(source) => match hovered {
|
|
Some(target) if state_edit::can_connect(self.project, self.diagram, source, target) =>
|
|
format!("{} → {} · compatible target · click or release to connect", self.project.named(source), self.project.named(target)),
|
|
_ => format!("Source: {} · choose a compatible target · Esc cancels", self.project.named(source)),
|
|
},
|
|
None => "Draw transition · click a source, then a compatible target, or drag between vertices".into(),
|
|
},
|
|
_ => 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::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(),
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
fn kind_for_tool(tool: Tool) -> Kind {
|
|
if let Tool::Vertex(kind) = tool {
|
|
kind
|
|
} else {
|
|
Kind::Region
|
|
}
|
|
}
|
|
|
|
impl StateCanvas<'_> {
|
|
fn update_input(
|
|
&self,
|
|
state: &mut State,
|
|
event: &canvas::Event,
|
|
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;
|
|
}
|
|
state.reset();
|
|
state.context = Some(self.context);
|
|
state.tool = Some(self.tool);
|
|
}
|
|
if !self.editable {
|
|
// Pending edits block model changes, but read-only panning remains
|
|
// available and must survive successive pointer events.
|
|
state.cancel_edit();
|
|
state.source = None;
|
|
}
|
|
if state
|
|
.bend
|
|
.as_ref()
|
|
.is_some_and(|g| g.route.edge != self.selected)
|
|
{
|
|
state.cancel_gesture();
|
|
}
|
|
if state
|
|
.attachment_viewport
|
|
.is_some_and(|view| view != (self.zoom, self.pan, bounds))
|
|
{
|
|
state.cancel_gesture();
|
|
}
|
|
// Winit positions are ordered events. The cursor supplied by Iced is the
|
|
// final cursor for an entire batch and cannot reinterpret earlier clicks.
|
|
let cursor = match event {
|
|
canvas::Event::Mouse(mouse::Event::CursorMoved { position }) => {
|
|
state.pointer = Some(*position);
|
|
mouse::Cursor::Available(*position)
|
|
}
|
|
canvas::Event::Mouse(mouse::Event::CursorLeft) => {
|
|
state.pointer = None;
|
|
if state.bend.is_some() {
|
|
state.cancel_gesture();
|
|
}
|
|
cursor
|
|
}
|
|
canvas::Event::Mouse(
|
|
mouse::Event::ButtonPressed(_) | mouse::Event::ButtonReleased(_),
|
|
) => state.pointer.map_or(cursor, mouse::Cursor::Available),
|
|
canvas::Event::Window(iced::window::Event::RedrawRequested(_)) => {
|
|
state.pointer = cursor.position();
|
|
let size = bounds.size();
|
|
if state.viewport != Some(size) {
|
|
state.viewport = Some(size);
|
|
return Some(Action::publish(Event::Viewport(self.context, size)));
|
|
}
|
|
cursor
|
|
}
|
|
canvas::Event::Window(iced::window::Event::Unfocused) => {
|
|
state.modifiers = keyboard::Modifiers::default();
|
|
state.reset();
|
|
return None;
|
|
}
|
|
canvas::Event::Window(iced::window::Event::Resized(_)) => {
|
|
state.cancel_gesture();
|
|
state.viewport = None;
|
|
return None;
|
|
}
|
|
_ => cursor,
|
|
};
|
|
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) {
|
|
state.reset();
|
|
return Some(Action::publish(Event::Choose(Tool::Select)).and_capture());
|
|
}
|
|
if *key == Key::Named(Named::Delete)
|
|
&& self.editable
|
|
&& self.tool == Tool::Select
|
|
&& state.press.is_none()
|
|
&& state.pan_from.is_none()
|
|
&& state.source.is_none()
|
|
&& (modifiers.is_empty() || *modifiers == keyboard::Modifiers::SHIFT)
|
|
{
|
|
let scene = self.scene(state)?;
|
|
let placement = scene
|
|
.view
|
|
.nodes
|
|
.iter()
|
|
.find(|node| node.element == self.selected || node.id == self.selected);
|
|
let object = placement.map_or(self.selected, |node| node.element);
|
|
if placement.is_none()
|
|
&& !scene.view.edges.iter().any(|edge| edge.id == object)
|
|
&& !visible_row_relation(self.project, self.diagram, &scene.view, object)
|
|
{
|
|
return None;
|
|
}
|
|
return Some(
|
|
Action::publish(Event::Remove(crate::app::diagram_removal::Intent {
|
|
context: self.context,
|
|
object,
|
|
placement: placement.map(|node| node.id),
|
|
model: modifiers.shift(),
|
|
}))
|
|
.and_capture(),
|
|
);
|
|
}
|
|
if modifiers.is_empty() {
|
|
let pan = match key.as_ref() {
|
|
Key::Named(Named::ArrowLeft) => Some(Vector::new(60., 0.)),
|
|
Key::Named(Named::ArrowRight) => Some(Vector::new(-60., 0.)),
|
|
Key::Named(Named::ArrowUp) => Some(Vector::new(0., 60.)),
|
|
Key::Named(Named::ArrowDown) => Some(Vector::new(0., -60.)),
|
|
_ => None,
|
|
};
|
|
if let Some(delta) = pan {
|
|
state.cancel_gesture();
|
|
return Some(Action::publish(Event::Pan(self.context, delta)).and_capture());
|
|
}
|
|
}
|
|
}
|
|
let scene = self.scene(state)?;
|
|
match event {
|
|
canvas::Event::Mouse(mouse::Event::ButtonPressed(button)) => {
|
|
if !inside {
|
|
return None;
|
|
}
|
|
let local = local?;
|
|
let window = window?;
|
|
let world = self.world(local);
|
|
if *button == mouse::Button::Middle {
|
|
state.cancel_gesture();
|
|
state.pan_from = Some((mouse::Button::Middle, window));
|
|
return Some(Action::capture());
|
|
}
|
|
if *button == mouse::Button::Right {
|
|
state.reset();
|
|
// A canceled provisional pin shifts indices in its preview.
|
|
// Menu commands must target the committed route instead.
|
|
let scene = self.scene(state)?;
|
|
let bend = self.bend_at(&scene, world);
|
|
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 @ 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() {
|
|
self.segment_at(&scene, object, world)
|
|
} else {
|
|
None
|
|
};
|
|
return Some(
|
|
Action::publish(Event::ContextMenu {
|
|
context: self.context,
|
|
object,
|
|
placement,
|
|
selection_target,
|
|
anchor: window,
|
|
bend,
|
|
route_segment,
|
|
})
|
|
.and_capture(),
|
|
);
|
|
}
|
|
if *button != mouse::Button::Left || !self.editable || state.pan_from.is_some() {
|
|
return None;
|
|
}
|
|
match self.tool {
|
|
Tool::Select => {
|
|
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,
|
|
start: Some(window),
|
|
});
|
|
return Some(Action::capture());
|
|
}
|
|
if singleton && let Some(index) = self.bend_at(&scene, world) {
|
|
let route = crate::route_edit::stored_route(
|
|
&self.project.diagrams[&self.diagram],
|
|
self.selected,
|
|
);
|
|
self.begin_bend(state, scene.clone(), window, route, index, bounds);
|
|
return Some(Action::capture());
|
|
}
|
|
match self.hit(&scene, world) {
|
|
Some(hit @ Hit::Node { placement, element }) => {
|
|
let original = self
|
|
.project
|
|
.diagrams
|
|
.get(&self.diagram)?
|
|
.nodes
|
|
.get(&placement)?
|
|
.position;
|
|
state.press = Some(Press::Move {
|
|
node: placement,
|
|
start: window,
|
|
original,
|
|
});
|
|
Some(Action::publish(self.select_event(hit, element)).and_capture())
|
|
}
|
|
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),
|
|
Size::new(
|
|
edge.label.bounds.size.width,
|
|
edge.label.bounds.size.height,
|
|
),
|
|
)
|
|
.contains(world)
|
|
&& let Some((segment, at)) = self.segment_at(&scene, id, world)
|
|
{
|
|
let mut route = crate::route_edit::stored_route(
|
|
&self.project.diagrams[&self.diagram],
|
|
id,
|
|
);
|
|
if let Some(index) = crate::route_edit::insert_pin_on_segment(
|
|
&mut route, edge, segment, at,
|
|
) {
|
|
self.begin_bend(
|
|
state,
|
|
scene.clone(),
|
|
window,
|
|
route,
|
|
index,
|
|
bounds,
|
|
);
|
|
return Some(Action::capture());
|
|
}
|
|
}
|
|
Some(Action::publish(self.select_event(hit, id)).and_capture())
|
|
}
|
|
None => {
|
|
state.pan_from = Some((mouse::Button::Left, window));
|
|
Some(Action::capture())
|
|
}
|
|
}
|
|
}
|
|
Tool::Vertex(_) | Tool::Region => {
|
|
let kind = kind_for_tool(self.tool);
|
|
let owner = self.owner_at(&scene, world, kind)?;
|
|
state.press = Some(Press::Create {
|
|
owner,
|
|
kind,
|
|
start: window,
|
|
});
|
|
Some(Action::capture())
|
|
}
|
|
Tool::Transition => {
|
|
let first = state.source.is_none();
|
|
let source = state.source.or_else(|| {
|
|
self.vertex_at(&scene, world)
|
|
.filter(|id| state_edit::can_start(self.project, self.diagram, *id))
|
|
})?;
|
|
state.source = Some(source);
|
|
state.press = Some(Press::Connect {
|
|
source,
|
|
first,
|
|
start: window,
|
|
dragged: false,
|
|
});
|
|
Some(Action::capture())
|
|
}
|
|
Tool::Reconnect { relation, endpoint } => {
|
|
state.press = Some(Press::Reconnect {
|
|
relation,
|
|
endpoint,
|
|
start: None,
|
|
});
|
|
Some(Action::capture())
|
|
}
|
|
Tool::AdjustAttachment { relation, end } => {
|
|
let owner =
|
|
state_edit::attachment_node(self.project, self.diagram, relation, end)?;
|
|
let route = scene.routed.edges.get(&relation)?;
|
|
let tip = match end {
|
|
EdgeEnd::Source => route.points.first(),
|
|
EdgeEnd::Target => route.points.last(),
|
|
}?;
|
|
if world.distance(point(*tip)) > 10. / self.scale() {
|
|
return None;
|
|
}
|
|
state.attachment_viewport = Some((self.zoom, self.pan, bounds));
|
|
state.press = Some(Press::Attachment {
|
|
relation,
|
|
end,
|
|
placement: owner.id,
|
|
start: window,
|
|
});
|
|
Some(Action::capture())
|
|
}
|
|
}
|
|
}
|
|
canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) => {
|
|
let window = window?;
|
|
if let Some((button, previous)) = state.pan_from {
|
|
state.pan_from = Some((button, window));
|
|
if window != previous {
|
|
return Some(
|
|
Action::publish(Event::Pan(self.context, window - previous))
|
|
.and_capture(),
|
|
);
|
|
}
|
|
}
|
|
self.move_pointer(state, window);
|
|
if let Some(local) = local {
|
|
self.move_attachment_pointer(state, window, local);
|
|
self.move_bend_pointer(state, window, local);
|
|
}
|
|
Some(Action::request_redraw())
|
|
}
|
|
canvas::Event::Mouse(mouse::Event::ButtonReleased(button)) => {
|
|
if let Some((held, previous)) = state.pan_from
|
|
&& *button == held
|
|
{
|
|
state.pan_from = None;
|
|
return Some(match window {
|
|
Some(window) if window != previous => {
|
|
Action::publish(Event::Pan(self.context, window - previous))
|
|
.and_capture()
|
|
}
|
|
_ => Action::capture(),
|
|
});
|
|
}
|
|
if *button != mouse::Button::Left {
|
|
return None;
|
|
}
|
|
if let Some(window) = window {
|
|
self.move_pointer(state, window);
|
|
if let Some(local) = local {
|
|
self.move_attachment_pointer(state, window, local);
|
|
self.move_bend_pointer(state, window, local);
|
|
}
|
|
}
|
|
let press = state.press.take()?;
|
|
state.attachment_viewport = None;
|
|
let preview = state.preview.take();
|
|
let attachment_preview = state.attachment_preview.take();
|
|
let bend_preview = state.bend_preview.take();
|
|
state.bend = None;
|
|
let world = local.map(|p| self.world(p));
|
|
let event = match press {
|
|
Press::Bend { relation } if inside => bend_preview
|
|
.filter(|route| route.edge == relation)
|
|
.map(|route| Event::SetBends {
|
|
context: self.context,
|
|
relation,
|
|
waypoints: route.waypoints,
|
|
}),
|
|
Press::Move { node, .. } => preview.map(|(_, position)| Event::Move {
|
|
context: self.context,
|
|
node,
|
|
position,
|
|
}),
|
|
Press::Attachment {
|
|
relation,
|
|
end,
|
|
placement,
|
|
..
|
|
} if inside => attachment_preview
|
|
.filter(|preview| {
|
|
preview.relation == relation
|
|
&& preview.end == end
|
|
&& preview.placement == placement
|
|
})
|
|
.map(|preview| Event::SetAttachment {
|
|
context: self.context,
|
|
relation,
|
|
end,
|
|
placement,
|
|
attachment: Some(preview.attachment),
|
|
}),
|
|
Press::Create { owner, kind, start }
|
|
if inside
|
|
&& window?.distance(start) < 3.
|
|
&& self.owner_at(&scene, world?, kind) == Some(owner) =>
|
|
{
|
|
Some(Event::CreateAt {
|
|
context: self.context,
|
|
owner,
|
|
kind,
|
|
position: position(world?),
|
|
})
|
|
}
|
|
Press::Connect {
|
|
source,
|
|
first,
|
|
dragged,
|
|
..
|
|
} if inside && (!first || dragged) => self
|
|
.vertex_at(&scene, world?)
|
|
.filter(|target| {
|
|
state_edit::can_connect(self.project, self.diagram, source, *target)
|
|
})
|
|
.map(|target| {
|
|
state.source = None;
|
|
Event::Connect {
|
|
context: self.context,
|
|
source,
|
|
target,
|
|
}
|
|
}),
|
|
Press::Reconnect {
|
|
relation,
|
|
endpoint,
|
|
start,
|
|
} if inside
|
|
&& start.is_none_or(|start| {
|
|
window.is_some_and(|p| p.distance(start) >= 3.)
|
|
}) =>
|
|
{
|
|
self.vertex_at(&scene, world?)
|
|
.filter(|vertex| {
|
|
state_edit::can_reconnect(
|
|
self.project,
|
|
self.diagram,
|
|
relation,
|
|
endpoint,
|
|
*vertex,
|
|
)
|
|
})
|
|
.map(|vertex| Event::Reconnect {
|
|
context: self.context,
|
|
relation,
|
|
endpoint,
|
|
vertex,
|
|
})
|
|
}
|
|
_ => None,
|
|
};
|
|
Some(event.map_or_else(Action::capture, |event| {
|
|
Action::publish(event).and_capture()
|
|
}))
|
|
}
|
|
canvas::Event::Mouse(mouse::Event::WheelScrolled { delta }) if inside => {
|
|
state.cancel_gesture();
|
|
let amount = match delta {
|
|
mouse::ScrollDelta::Lines { y, .. } => *y,
|
|
mouse::ScrollDelta::Pixels { y, .. } => *y / 50.,
|
|
};
|
|
Some(
|
|
Action::publish(Event::Zoom(
|
|
self.context,
|
|
self.scale() * 1.12_f32.powf(amount.clamp(-8., 8.)),
|
|
))
|
|
.and_capture(),
|
|
)
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
impl canvas::Program<Event> for StateCanvas<'_> {
|
|
type State = State;
|
|
fn update(
|
|
&self,
|
|
state: &mut State,
|
|
event: &canvas::Event,
|
|
bounds: Rectangle,
|
|
cursor: mouse::Cursor,
|
|
) -> Option<Action<Event>> {
|
|
self.removal_input.sync(
|
|
&mut state.removal_lease,
|
|
state.press.is_some()
|
|
|| state.group.is_some()
|
|
|| state.source.is_some()
|
|
|| state.bend.is_some()
|
|
|| state.pan_from.is_some(),
|
|
);
|
|
self.removal_input.observe(event);
|
|
let viewport = (self.zoom, self.pan, bounds);
|
|
if state
|
|
.removal_viewport
|
|
.is_some_and(|previous| previous != viewport)
|
|
{
|
|
self.removal_input.revoke_idle();
|
|
}
|
|
state.removal_viewport = Some(viewport);
|
|
let action = self.update_input(state, event, bounds, cursor);
|
|
if matches!(
|
|
event,
|
|
canvas::Event::Window(iced::window::Event::Unfocused | iced::window::Event::Resized(_))
|
|
| canvas::Event::Mouse(mouse::Event::CursorLeft)
|
|
) {
|
|
state.reset();
|
|
}
|
|
self.removal_input.sync(
|
|
&mut state.removal_lease,
|
|
state.press.is_some()
|
|
|| state.group.is_some()
|
|
|| state.source.is_some()
|
|
|| state.bend.is_some()
|
|
|| state.pan_from.is_some(),
|
|
);
|
|
action
|
|
}
|
|
fn draw(
|
|
&self,
|
|
state: &State,
|
|
renderer: &Renderer,
|
|
_: &Theme,
|
|
bounds: Rectangle,
|
|
cursor: mouse::Cursor,
|
|
) -> Vec<canvas::Geometry> {
|
|
let Some(scene) = self.scene_with_bounds(state, Some(bounds)) else {
|
|
return vec![];
|
|
};
|
|
let route_active =
|
|
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)
|
|
.flatten(),
|
|
attachment_preview: route_active.then_some(state.attachment_preview).flatten(),
|
|
bend_preview: route_active.then(|| state.bend_preview.clone()).flatten(),
|
|
selected: self.selected,
|
|
zoom: self.scale(),
|
|
pan: self.pan,
|
|
};
|
|
if state.paint_key.borrow().as_ref() != Some(&key) {
|
|
state.geometry.clear();
|
|
*state.paint_key.borrow_mut() = Some(key);
|
|
}
|
|
let content = Self::content(bounds);
|
|
let geometry = state.geometry.draw(renderer, bounds.size(), |frame| {
|
|
// WGPU appends parent meshes after clipped child meshes. Keep the
|
|
// parent background outside the content clip so it cannot cover nodes.
|
|
frame.fill_rectangle(
|
|
Point::ORIGIN,
|
|
Size::new(bounds.width, GUIDANCE_HEIGHT),
|
|
style::BG,
|
|
);
|
|
frame.with_clip(content, |frame| {
|
|
frame.fill_rectangle(content.position(), content.size(), style::BG);
|
|
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,
|
|
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 = 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 {
|
|
if primary { 3.2 } else { 2.0 }
|
|
} else {
|
|
1.4
|
|
}),
|
|
);
|
|
if let [.., previous, end] = edge.points.as_slice() {
|
|
let direction = point(*end) - point(*previous);
|
|
let length = direction.x.hypot(direction.y).max(0.001);
|
|
let direction = direction / length;
|
|
let side = Vector::new(-direction.y, direction.x) * 4.;
|
|
frame.stroke(
|
|
&Path::new(|p| {
|
|
p.move_to(point(*end) - direction * 10. + side);
|
|
p.line_to(point(*end));
|
|
p.line_to(point(*end) - direction * 10. - side);
|
|
}),
|
|
Stroke::default().with_color(color).with_width(1.5),
|
|
);
|
|
}
|
|
let label = &edge.label;
|
|
frame.fill_rectangle(
|
|
point(label.bounds.position),
|
|
Size::new(label.bounds.size.width, label.bounds.size.height),
|
|
Color::WHITE,
|
|
);
|
|
let width = label.bounds.size.width - 10.;
|
|
label_text(
|
|
frame,
|
|
short(self.project.named(edge.id), width, 10.),
|
|
point(label.bounds.position) + Vector::new(5., 0.),
|
|
10.,
|
|
color,
|
|
);
|
|
label_text(
|
|
frame,
|
|
short(&label.text, width, 10.),
|
|
point(label.bounds.position) + Vector::new(5., 12.),
|
|
10.,
|
|
style::MUTED,
|
|
);
|
|
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();
|
|
let handle = Path::rectangle(
|
|
point(*pin) - Vector::new(radius, radius),
|
|
Size::new(radius * 2., radius * 2.),
|
|
);
|
|
frame.fill(&handle, Color::WHITE);
|
|
frame.stroke(
|
|
&handle,
|
|
Stroke::default()
|
|
.with_color(style::TEAL)
|
|
.with_width(1.6 / self.scale()),
|
|
);
|
|
}
|
|
}
|
|
for end in [edge.points.first(), edge.points.last()]
|
|
.into_iter()
|
|
.flatten()
|
|
{
|
|
frame.fill(&Path::circle(point(*end), 5. / self.scale()), Color::WHITE);
|
|
frame.stroke(
|
|
&Path::circle(point(*end), 5. / self.scale()),
|
|
Stroke::default()
|
|
.with_color(style::TEAL)
|
|
.with_width(1.6 / self.scale()),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
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());
|
|
let local = cursor.position_in(bounds).filter(|p| content.contains(*p));
|
|
let world = local.map(|p| self.world(p));
|
|
overlay.with_clip(content, |frame| {
|
|
frame.translate(self.offset());
|
|
frame.scale(self.scale());
|
|
if let Some(bounds) = self.navigation_focus {
|
|
frame.stroke(
|
|
&Path::rectangle(bounds.position(), bounds.size()),
|
|
Stroke::default()
|
|
.with_color(style::TEAL)
|
|
.with_width(2.5 / self.scale()),
|
|
);
|
|
}
|
|
if let Tool::AdjustAttachment { relation, end } = self.tool
|
|
&& let Some(route) = scene.routed.edges.get(&relation)
|
|
&& let Some(tip) = match end {
|
|
EdgeEnd::Source => route.points.first(),
|
|
EdgeEnd::Target => route.points.last(),
|
|
}
|
|
{
|
|
frame.stroke(
|
|
&Path::circle(point(*tip), 9. / self.scale()),
|
|
Stroke::default()
|
|
.with_color(style::TEAL)
|
|
.with_width(2. / self.scale()),
|
|
);
|
|
}
|
|
if let Some(at) = world
|
|
&& self.editable
|
|
{
|
|
let owner = match self.tool {
|
|
Tool::Vertex(_) | Tool::Region => {
|
|
self.owner_at(&scene, at, kind_for_tool(self.tool))
|
|
}
|
|
_ => None,
|
|
};
|
|
if let Some(owner) = owner
|
|
&& let Some(node) = scene.view.nodes.iter().find(|n| n.element == owner)
|
|
{
|
|
let r = rectangle(node);
|
|
frame.stroke(
|
|
&Path::rectangle(r.position(), r.size()),
|
|
Stroke::default()
|
|
.with_color(style::TEAL)
|
|
.with_width(2. / self.scale()),
|
|
);
|
|
}
|
|
let press = self.gesture_active(state).then_some(state.press).flatten();
|
|
let source = self.gesture_active(state).then_some(state.source).flatten();
|
|
let from = if let Some(Press::Reconnect {
|
|
relation, endpoint, ..
|
|
}) = press
|
|
{
|
|
scene
|
|
.routed
|
|
.edges
|
|
.get(&relation)
|
|
.and_then(|r| {
|
|
if endpoint == Endpoint::Source {
|
|
r.points.last()
|
|
} else {
|
|
r.points.first()
|
|
}
|
|
})
|
|
.copied()
|
|
} else if let Tool::Reconnect { relation, endpoint } = self.tool {
|
|
scene
|
|
.routed
|
|
.edges
|
|
.get(&relation)
|
|
.and_then(|r| {
|
|
if endpoint == Endpoint::Source {
|
|
r.points.last()
|
|
} else {
|
|
r.points.first()
|
|
}
|
|
})
|
|
.copied()
|
|
} else {
|
|
source
|
|
.and_then(|id| scene.view.nodes.iter().find(|n| n.element == id))
|
|
.map(|n| position(rectangle(n).center()))
|
|
};
|
|
if let Some(from) = from {
|
|
let mut stroke = Stroke::default()
|
|
.with_color(style::TEAL)
|
|
.with_width(1.8 / self.scale());
|
|
stroke.line_dash = canvas::LineDash {
|
|
segments: &[6., 4.],
|
|
offset: 0,
|
|
};
|
|
frame.stroke(&line(point(from), at), stroke);
|
|
frame.stroke(
|
|
&Path::circle(at, 5. / self.scale()),
|
|
Stroke::default().with_color(style::TEAL),
|
|
);
|
|
}
|
|
}
|
|
});
|
|
label_text(
|
|
&mut overlay,
|
|
short(
|
|
&self.guidance(state, &scene, world),
|
|
bounds.width - 24.,
|
|
12.,
|
|
),
|
|
Point::new(12., 12.),
|
|
12.,
|
|
style::INK,
|
|
);
|
|
overlay.stroke(
|
|
&line(
|
|
Point::new(0., GUIDANCE_HEIGHT - 1.),
|
|
Point::new(bounds.width, GUIDANCE_HEIGHT - 1.),
|
|
),
|
|
Stroke::default().with_color(style::LINE),
|
|
);
|
|
vec![geometry, overlay.into_geometry()]
|
|
}
|
|
fn mouse_interaction(
|
|
&self,
|
|
state: &State,
|
|
bounds: Rectangle,
|
|
cursor: mouse::Cursor,
|
|
) -> mouse::Interaction {
|
|
if state.pan_from.is_some() {
|
|
return mouse::Interaction::Grabbing;
|
|
}
|
|
if state.press.is_some() {
|
|
return mouse::Interaction::Grabbing;
|
|
}
|
|
if !cursor
|
|
.position_in(bounds)
|
|
.is_some_and(|p| Self::content(bounds).contains(p))
|
|
{
|
|
return mouse::Interaction::default();
|
|
}
|
|
if self.editable && self.tool != Tool::Select {
|
|
mouse::Interaction::Crosshair
|
|
} else {
|
|
mouse::Interaction::Pointer
|
|
}
|
|
}
|
|
}
|
|
|
|
fn paint_node(
|
|
frame: &mut Frame,
|
|
project: &Project,
|
|
node: &ProjectedNode,
|
|
selected: Id,
|
|
symbol_label: Option<&Rectangle>,
|
|
) {
|
|
let Some(element) = project.elements.get(&node.element) else {
|
|
return;
|
|
};
|
|
let r = rectangle(node);
|
|
let color = if element.id == selected {
|
|
style::TEAL
|
|
} else {
|
|
style::INK
|
|
};
|
|
let stroke = Stroke::default()
|
|
.with_color(color)
|
|
.with_width(if element.id == selected { 2.4 } else { 1.3 });
|
|
if projection::state_symbol_size(element.kind()).is_some() {
|
|
let center = r.center();
|
|
let radius = r.width / 2.;
|
|
let circle = Path::circle(center, radius);
|
|
match element.kind() {
|
|
Kind::Initial | Kind::Junction => frame.fill(&circle, color),
|
|
Kind::Final => {
|
|
frame.fill(&circle, Color::WHITE);
|
|
frame.stroke(&circle, stroke);
|
|
frame.fill(&Path::circle(center, radius - 4.), color);
|
|
}
|
|
Kind::Fork | Kind::Join => frame.fill_rectangle(r.position(), r.size(), color),
|
|
Kind::Choice => {
|
|
let path = Path::new(|p| {
|
|
p.move_to(Point::new(center.x, r.y));
|
|
p.line_to(Point::new(r.x + r.width, center.y));
|
|
p.line_to(Point::new(center.x, r.y + r.height));
|
|
p.line_to(Point::new(r.x, center.y));
|
|
p.close();
|
|
});
|
|
frame.fill(&path, Color::WHITE);
|
|
frame.stroke(&path, stroke);
|
|
}
|
|
Kind::Terminate => {
|
|
frame.stroke(
|
|
&line(r.position(), Point::new(r.x + r.width, r.y + r.height)),
|
|
stroke,
|
|
);
|
|
frame.stroke(
|
|
&line(
|
|
Point::new(r.x + r.width, r.y),
|
|
Point::new(r.x, r.y + r.height),
|
|
),
|
|
stroke,
|
|
);
|
|
}
|
|
_ => {
|
|
frame.fill(&circle, Color::WHITE);
|
|
frame.stroke(&circle, stroke);
|
|
match element.kind() {
|
|
Kind::ShallowHistory | Kind::DeepHistory => label_text(
|
|
frame,
|
|
if element.kind() == Kind::DeepHistory {
|
|
"H*"
|
|
} else {
|
|
"H"
|
|
},
|
|
center
|
|
- Vector::new(
|
|
if element.kind() == Kind::DeepHistory {
|
|
9.
|
|
} else {
|
|
5.
|
|
},
|
|
9.,
|
|
),
|
|
14.,
|
|
color,
|
|
),
|
|
Kind::ExitPoint => {
|
|
let d = radius * 0.6;
|
|
frame.stroke(
|
|
&line(center - Vector::new(d, d), center + Vector::new(d, d)),
|
|
stroke,
|
|
);
|
|
frame.stroke(
|
|
&line(center + Vector::new(d, -d), center + Vector::new(-d, d)),
|
|
stroke,
|
|
);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
if let Some(label) = symbol_label {
|
|
label_text(
|
|
frame,
|
|
short(&element.name, 220., 11.),
|
|
label.position(),
|
|
11.,
|
|
color,
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
let path = if element.kind() == Kind::Region {
|
|
Path::rectangle(r.position(), r.size())
|
|
} else {
|
|
Path::rounded_rectangle(r.position(), r.size(), 10.into())
|
|
};
|
|
frame.fill(
|
|
&path,
|
|
if element.kind() == Kind::Region {
|
|
Color::from_rgb8(247, 250, 253)
|
|
} else if element.kind() == Kind::StateMachine {
|
|
Color::from_rgb8(234, 241, 248)
|
|
} else {
|
|
Color::WHITE
|
|
},
|
|
);
|
|
let mut border = stroke;
|
|
if element.kind() == Kind::Region {
|
|
border.line_dash = canvas::LineDash {
|
|
segments: &[7., 4.],
|
|
offset: 0,
|
|
};
|
|
}
|
|
frame.stroke(&path, border);
|
|
label_text(
|
|
frame,
|
|
short(&element.name, r.width - 24., 14.),
|
|
r.position() + Vector::new(12., 10.),
|
|
14.,
|
|
color,
|
|
);
|
|
label_text(
|
|
frame,
|
|
if node.collapsed {
|
|
format!("{} · collapsed", element.kind().label())
|
|
} else {
|
|
element.kind().label().into()
|
|
},
|
|
r.position() + Vector::new(12., 33.),
|
|
10.,
|
|
style::MUTED,
|
|
);
|
|
frame.stroke(
|
|
&line(
|
|
Point::new(r.x + 1., r.y + 55.),
|
|
Point::new(r.x + r.width - 1., r.y + 55.),
|
|
),
|
|
Stroke::default().with_color(style::LINE),
|
|
);
|
|
if node.collapsed {
|
|
return;
|
|
}
|
|
if let Some(SharedBody::State {
|
|
entry,
|
|
exit,
|
|
do_activity,
|
|
..
|
|
}) = element.shared()
|
|
{
|
|
let actions = [("entry", entry), ("do", do_activity), ("exit", exit)]
|
|
.into_iter()
|
|
.filter(|(_, text)| !text.is_empty())
|
|
.map(|(kind, text)| format!("{kind} / {text}"))
|
|
.collect::<Vec<_>>()
|
|
.join(" · ");
|
|
if !actions.is_empty() {
|
|
label_text(
|
|
frame,
|
|
short(&actions, r.width - 24., 10.),
|
|
r.position() + Vector::new(12., 65.),
|
|
10.,
|
|
style::MUTED,
|
|
);
|
|
}
|
|
}
|
|
for (relation, row) in state_action_rows(project, node) {
|
|
if relation.id == selected {
|
|
paint_selected_row(frame, row);
|
|
}
|
|
label_text(
|
|
frame,
|
|
short(&state_action_label(project, relation), row.width - 12., 11.),
|
|
row.position() + Vector::new(6., 5.),
|
|
11.,
|
|
if relation.id == selected {
|
|
style::TEAL
|
|
} else {
|
|
style::MUTED
|
|
},
|
|
);
|
|
}
|
|
for (relation, row) in internal_rows(project, node) {
|
|
if relation.id == selected {
|
|
paint_selected_row(frame, row);
|
|
}
|
|
frame.stroke(
|
|
&line(row.position(), Point::new(row.x + row.width, row.y)),
|
|
Stroke::default().with_color(style::LINE),
|
|
);
|
|
label_text(
|
|
frame,
|
|
short(
|
|
&format!("INTERNAL · {}", relation.name),
|
|
row.width - 12.,
|
|
11.,
|
|
),
|
|
row.position() + Vector::new(6., 6.),
|
|
11.,
|
|
if relation.id == selected {
|
|
style::TEAL
|
|
} else {
|
|
color
|
|
},
|
|
);
|
|
label_text(
|
|
frame,
|
|
short(
|
|
&routing::state_transition_summary(project, relation),
|
|
row.width - 12.,
|
|
10.,
|
|
),
|
|
row.position() + Vector::new(6., 27.),
|
|
10.,
|
|
style::MUTED,
|
|
);
|
|
}
|
|
}
|
|
|
|
fn paint_selected_row(frame: &mut Frame, row: Rectangle) {
|
|
frame.fill_rectangle(row.position(), row.size(), Color::from_rgb8(224, 247, 242));
|
|
if row.width <= 2. || row.height <= 2. {
|
|
return;
|
|
}
|
|
// Selection remains visible without hue. Keep the outline inside the
|
|
// existing row so it cannot cover its separator or change its hit bounds.
|
|
frame.stroke(
|
|
&Path::rectangle(
|
|
row.position() + Vector::new(1., 1.),
|
|
Size::new(row.width - 2., row.height - 2.),
|
|
),
|
|
Stroke::default().with_color(style::TEAL).with_width(1.5),
|
|
);
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::document::Document;
|
|
|
|
include!("state_bend_tests.rs");
|
|
include!("state_multiselect_canvas_tests.rs");
|
|
|
|
struct Fixture {
|
|
removal_input: crate::removal_input::Lifetime,
|
|
project: Project,
|
|
diagram: Id,
|
|
machine: Id,
|
|
region: Id,
|
|
initial: Id,
|
|
a: Id,
|
|
b: Id,
|
|
c: Id,
|
|
leaf: Id,
|
|
}
|
|
|
|
fn fixture() -> Fixture {
|
|
let mut project = Project::empty();
|
|
let mut add =
|
|
|kind, name, parent| project.add_element(kind, Layer::Sa, name, parent).unwrap();
|
|
let machine = add(Kind::StateMachine, "Operating modes", None);
|
|
let region = add(Kind::Region, "Main region", Some(machine));
|
|
let initial = add(Kind::Initial, "Start", Some(region));
|
|
let a = add(Kind::State, "Operating", Some(region));
|
|
let b = add(Kind::State, "Standby", Some(region));
|
|
let c = add(Kind::State, "Stopped", Some(region));
|
|
let inner = add(Kind::Region, "Nested region", Some(a));
|
|
let leaf = add(Kind::State, "Working", Some(inner));
|
|
let diagram = Id::new_v4();
|
|
project.diagrams.insert(
|
|
diagram,
|
|
Diagram {
|
|
id: diagram,
|
|
alias: "operating_view".into(),
|
|
name: "Operating view".into(),
|
|
description: String::new(),
|
|
layer: Layer::Sa,
|
|
projection: ProjectionKind::StateMachine { machine },
|
|
nodes: BTreeMap::new(),
|
|
layout: None,
|
|
routes: vec![],
|
|
view_options: Default::default(),
|
|
},
|
|
);
|
|
for (element, x, y, width, height) in [
|
|
(machine, 0., 0., 1100., 900.),
|
|
(region, 24., 64., 1000., 800.),
|
|
(initial, 60., 160., 224., 94.),
|
|
(a, 180., 180., 440., 500.),
|
|
(b, 760., 180., 224., 94.),
|
|
(c, 760., 430., 224., 94.),
|
|
(inner, 204., 350., 380., 300.),
|
|
(leaf, 310., 450., 224., 94.),
|
|
] {
|
|
let node = project.place(diagram, element, Position { x, y });
|
|
project
|
|
.diagrams
|
|
.get_mut(&diagram)
|
|
.unwrap()
|
|
.nodes
|
|
.get_mut(&node)
|
|
.unwrap()
|
|
.size = oa_core::Size { width, height };
|
|
}
|
|
validate_candidate(&project).unwrap();
|
|
Fixture {
|
|
removal_input: Default::default(),
|
|
project,
|
|
diagram,
|
|
machine,
|
|
region,
|
|
initial,
|
|
a,
|
|
b,
|
|
c,
|
|
leaf,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn diagram_usage_state_rows_and_label_references_follow_actual_projection() {
|
|
use crate::diagram_usage::Target;
|
|
let mut f = fixture();
|
|
let work = f
|
|
.project
|
|
.add_element(Kind::Function, Layer::Sa, "Act", None)
|
|
.unwrap();
|
|
let event = f
|
|
.project
|
|
.add_element(Kind::Event, Layer::Sa, "E", None)
|
|
.unwrap();
|
|
let unused = f
|
|
.project
|
|
.add_element(Kind::Event, Layer::Sa, "E", 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);
|
|
let external = f
|
|
.project
|
|
.relate(RelationKind::StateTransition, "External", f.a, f.b);
|
|
for (id, kind) in [
|
|
(internal, TransitionKind::Internal),
|
|
(external, TransitionKind::External),
|
|
] {
|
|
if let RelationBody::StateTransition {
|
|
transition_kind,
|
|
triggers,
|
|
effects,
|
|
..
|
|
} = &mut f.project.relations.get_mut(&id).unwrap().body
|
|
{
|
|
*transition_kind = kind;
|
|
*triggers = vec![event];
|
|
*effects = vec![work];
|
|
}
|
|
}
|
|
let before = f.project.clone();
|
|
let diagram = &f.project.diagrams[&f.diagram];
|
|
let view = projection::project_diagram(&f.project, diagram);
|
|
let placement = view.nodes.iter().find(|n| n.element == f.a).unwrap().id;
|
|
let action_target = Target::StateRow {
|
|
placement,
|
|
relation: action,
|
|
};
|
|
let internal_target = Target::StateRow {
|
|
placement,
|
|
relation: internal,
|
|
};
|
|
assert_eq!(
|
|
diagram_usages(&f.project, diagram, &view, action)[0].target,
|
|
action_target
|
|
);
|
|
assert_eq!(
|
|
diagram_usages(&f.project, diagram, &view, internal)[0].target,
|
|
internal_target
|
|
);
|
|
let events = diagram_usages(&f.project, diagram, &view, event);
|
|
assert_eq!(events.len(), 2);
|
|
assert!(events.iter().any(|u| u.target == internal_target));
|
|
assert!(events.iter().any(|u| u.target == Target::Edge(external)));
|
|
assert!(diagram_usages(&f.project, diagram, &view, unused).is_empty());
|
|
// External edge identity belongs to the shared saved-view adapter.
|
|
assert!(diagram_usages(&f.project, diagram, &view, external).is_empty());
|
|
let work_usages = diagram_usages(&f.project, diagram, &view, work);
|
|
assert!(work_usages.iter().any(|u| u.target == action_target));
|
|
assert!(work_usages.iter().any(|u| u.target == internal_target));
|
|
let row = work_usages
|
|
.iter()
|
|
.find(|u| u.target == action_target)
|
|
.unwrap();
|
|
let canvas = canvas(&f, Tool::Select, 1.);
|
|
let scene = canvas.scene(&State::default()).unwrap();
|
|
assert_eq!(
|
|
canvas.hit(&scene, row.bounds.center()),
|
|
Some(Hit::Row {
|
|
placement,
|
|
relation: action
|
|
})
|
|
);
|
|
assert_eq!(f.project, before);
|
|
f.project
|
|
.diagrams
|
|
.get_mut(&f.diagram)
|
|
.unwrap()
|
|
.nodes
|
|
.get_mut(&placement)
|
|
.unwrap()
|
|
.collapsed = true;
|
|
let diagram = &f.project.diagrams[&f.diagram];
|
|
let folded = projection::project_diagram(&f.project, diagram);
|
|
assert!(diagram_usages(&f.project, diagram, &folded, action).is_empty());
|
|
assert!(diagram_usages(&f.project, diagram, &folded, internal).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn diagram_usage_transition_labels_do_not_match_prose_or_fields_beyond_cutoff() {
|
|
let mut f = fixture();
|
|
let early = f
|
|
.project
|
|
.add_element(Kind::Event, Layer::Sa, "A", None)
|
|
.unwrap();
|
|
let late = f
|
|
.project
|
|
.add_element(Kind::Event, Layer::Sa, "B", None)
|
|
.unwrap();
|
|
let relation = f
|
|
.project
|
|
.relate(RelationKind::StateTransition, "Transition", f.a, f.b);
|
|
if let RelationBody::StateTransition {
|
|
triggers, guard, ..
|
|
} = &mut f.project.relations.get_mut(&relation).unwrap().body
|
|
{
|
|
*triggers = vec![early, late];
|
|
*guard = "B".into();
|
|
}
|
|
let relation = &f.project.relations[&relation];
|
|
assert_eq!(
|
|
transition_label_role(&f.project, relation, early, 84.),
|
|
Some("transition trigger")
|
|
);
|
|
assert_eq!(transition_label_role(&f.project, relation, late, 84.), None);
|
|
assert_eq!(
|
|
transition_label_role(&f.project, relation, late, 800.),
|
|
Some("transition trigger")
|
|
);
|
|
assert!(!retained_label_field("1234A678", "1234…", 4));
|
|
}
|
|
|
|
#[test]
|
|
fn diagram_usage_external_labels_obey_routing_cutoff_while_internal_rows_retain_full_summary() {
|
|
use crate::diagram_usage::Target;
|
|
let mut f = fixture();
|
|
let first = f
|
|
.project
|
|
.add_element(Kind::Event, Layer::Sa, &"A".repeat(35), None)
|
|
.unwrap();
|
|
let second = f
|
|
.project
|
|
.add_element(Kind::Event, Layer::Sa, "Second", None)
|
|
.unwrap();
|
|
let external = f
|
|
.project
|
|
.relate(RelationKind::StateTransition, "External", f.a, f.b);
|
|
let internal = f
|
|
.project
|
|
.relate(RelationKind::StateTransition, "Internal", f.a, f.a);
|
|
for (id, kind) in [
|
|
(external, TransitionKind::External),
|
|
(internal, TransitionKind::Internal),
|
|
] {
|
|
if let RelationBody::StateTransition {
|
|
transition_kind,
|
|
triggers,
|
|
..
|
|
} = &mut f.project.relations.get_mut(&id).unwrap().body
|
|
{
|
|
*transition_kind = kind;
|
|
*triggers = vec![first, second];
|
|
}
|
|
}
|
|
let diagram = &f.project.diagrams[&f.diagram];
|
|
let view = projection::project_diagram(&f.project, diagram);
|
|
let routed = routing::route_diagram(&f.project, diagram, &view);
|
|
let edge = &routed.edges[&external];
|
|
let painted = short(&edge.label.text, edge.label.bounds.size.width - 10., 10.);
|
|
// Independent painted-text oracle: 11 prefix characters + 35 As, then
|
|
// only the space before OR survives the routing limit.
|
|
assert_eq!(edge.label.text, format!("External · {} …", "A".repeat(35)));
|
|
assert!(!painted.contains("Second"));
|
|
let usages = diagram_usages(&f.project, diagram, &view, second);
|
|
assert!(!usages.iter().any(|u| u.target == Target::Edge(external)));
|
|
assert!(usages.iter().any(
|
|
|u| matches!(u.target, Target::StateRow { relation, .. } if relation == internal)
|
|
));
|
|
assert!(
|
|
diagram_usages(&f.project, diagram, &view, first)
|
|
.iter()
|
|
.any(|u| u.target == Target::Edge(external))
|
|
);
|
|
}
|
|
|
|
fn canvas(f: &Fixture, tool: Tool, zoom: f32) -> StateCanvas<'_> {
|
|
StateCanvas {
|
|
removal_input: &f.removal_input,
|
|
project: &f.project,
|
|
diagram: f.diagram,
|
|
context: Context {
|
|
document: Id::new_v4(),
|
|
diagram: f.diagram,
|
|
revision: 7,
|
|
epoch: 3,
|
|
},
|
|
selected: f.machine,
|
|
selection: None,
|
|
navigation_focus: None,
|
|
tool,
|
|
zoom,
|
|
pan: Vector::new(20., 30.),
|
|
editable: true,
|
|
}
|
|
}
|
|
|
|
fn bounds() -> Rectangle {
|
|
Rectangle::new(Point::new(120., 80.), Size::new(2400., 1800.))
|
|
}
|
|
|
|
fn screen(canvas: &StateCanvas<'_>, world: Point) -> Point {
|
|
Point::new(world.x * canvas.scale(), world.y * canvas.scale())
|
|
+ canvas.offset()
|
|
+ Vector::new(bounds().x, bounds().y)
|
|
}
|
|
|
|
fn header(canvas: &StateCanvas<'_>, element: Id) -> Point {
|
|
let scene = canvas.scene(&State::default()).unwrap();
|
|
let node = scene
|
|
.view
|
|
.nodes
|
|
.iter()
|
|
.find(|n| n.element == element)
|
|
.unwrap();
|
|
point(node.position) + Vector::new(20., 20.)
|
|
}
|
|
|
|
fn placement(f: &Fixture, element: Id) -> Id {
|
|
f.project.diagrams[&f.diagram]
|
|
.nodes
|
|
.values()
|
|
.find(|n| n.element == element)
|
|
.unwrap()
|
|
.id
|
|
}
|
|
|
|
fn input(
|
|
canvas: &StateCanvas<'_>,
|
|
state: &mut State,
|
|
event: mouse::Event,
|
|
batch_cursor: Point,
|
|
) -> Option<Event> {
|
|
input_at_bounds(canvas, state, event, batch_cursor, bounds())
|
|
}
|
|
|
|
fn input_at_bounds(
|
|
canvas: &StateCanvas<'_>,
|
|
state: &mut State,
|
|
event: mouse::Event,
|
|
batch_cursor: Point,
|
|
bounds: Rectangle,
|
|
) -> Option<Event> {
|
|
canvas::Program::update(
|
|
canvas,
|
|
state,
|
|
&canvas::Event::Mouse(event),
|
|
bounds,
|
|
mouse::Cursor::Available(batch_cursor),
|
|
)
|
|
.and_then(|action| action.into_inner().0)
|
|
}
|
|
|
|
#[test]
|
|
fn selection_relayout_does_not_move_nodes_or_change_drag_distance() {
|
|
for zoom in [0.5, 1., 2.] {
|
|
for delta in [Vector::ZERO, Vector::new(36., 24.)] {
|
|
let mut f = fixture();
|
|
let transition = f.project.relate(
|
|
RelationKind::StateTransition,
|
|
"Selected transition",
|
|
f.a,
|
|
f.c,
|
|
);
|
|
let mut canvas = canvas(&f, Tool::Select, zoom);
|
|
canvas.selected = transition;
|
|
let mut state = State::default();
|
|
let start = screen(&canvas, header(&canvas, f.b));
|
|
motion(&canvas, &mut state, start, start);
|
|
assert!(matches!(input(&canvas, &mut state,
|
|
mouse::Event::ButtonPressed(mouse::Button::Left), start),
|
|
Some(Event::Select(_, id)) if id == f.b));
|
|
// App selection removes contextual controls above the canvas.
|
|
// Context stays valid, but subsequent events have new bounds.
|
|
canvas.selected = f.b;
|
|
let shifted =
|
|
Rectangle::new(bounds().position() - Vector::new(22., 38.), bounds().size());
|
|
let release = start + delta * zoom;
|
|
input_at_bounds(
|
|
&canvas,
|
|
&mut state,
|
|
mouse::Event::CursorMoved { position: release },
|
|
release,
|
|
shifted,
|
|
);
|
|
let event = input_at_bounds(
|
|
&canvas,
|
|
&mut state,
|
|
mouse::Event::ButtonReleased(mouse::Button::Left),
|
|
release,
|
|
shifted,
|
|
);
|
|
if delta == Vector::ZERO {
|
|
assert!(
|
|
event.is_none(),
|
|
"A stationary selection must not edit the model"
|
|
);
|
|
} else {
|
|
let node = placement(&f, f.b);
|
|
let original = f.project.diagrams[&f.diagram].nodes[&node].position;
|
|
assert!(
|
|
matches!(event, Some(Event::Move { node: moved, position, .. })
|
|
if moved == node && position == Position {
|
|
x: original.x + delta.x, y: original.y + delta.y,
|
|
})
|
|
);
|
|
}
|
|
assert!(state.press.is_none() && state.preview.is_none());
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn relayout_does_not_turn_a_transition_click_into_a_drag_or_pan() {
|
|
let f = fixture();
|
|
let mut canvas = canvas(&f, Tool::Transition, 1.);
|
|
let mut state = State::default();
|
|
let start = screen(&canvas, header(&canvas, f.b));
|
|
let shifted = Rectangle::new(bounds().position() - Vector::new(0., 24.), bounds().size());
|
|
motion(&canvas, &mut state, start, start);
|
|
input(
|
|
&canvas,
|
|
&mut state,
|
|
mouse::Event::ButtonPressed(mouse::Button::Left),
|
|
start,
|
|
);
|
|
assert!(
|
|
input_at_bounds(
|
|
&canvas,
|
|
&mut state,
|
|
mouse::Event::ButtonReleased(mouse::Button::Left),
|
|
start,
|
|
shifted
|
|
)
|
|
.is_none()
|
|
);
|
|
assert_eq!(state.source, Some(f.b));
|
|
|
|
canvas.tool = Tool::Select;
|
|
input(
|
|
&canvas,
|
|
&mut state,
|
|
mouse::Event::ButtonPressed(mouse::Button::Middle),
|
|
start,
|
|
);
|
|
assert!(
|
|
input_at_bounds(
|
|
&canvas,
|
|
&mut state,
|
|
mouse::Event::CursorMoved { position: start },
|
|
start,
|
|
shifted
|
|
)
|
|
.is_none()
|
|
);
|
|
assert!(
|
|
input_at_bounds(
|
|
&canvas,
|
|
&mut state,
|
|
mouse::Event::ButtonReleased(mouse::Button::Middle),
|
|
start,
|
|
shifted
|
|
)
|
|
.is_none()
|
|
);
|
|
assert!(state.pan_from.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn context_menu_keeps_window_anchor_and_explicit_placement_identity() {
|
|
let f = fixture();
|
|
let canvas = canvas(&f, Tool::Select, 1.);
|
|
let mut state = State::default();
|
|
let at = screen(&canvas, header(&canvas, f.b));
|
|
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: Some(node), anchor, .. })
|
|
if context == canvas.context && object == f.b
|
|
&& node == placement(&f, f.b) && anchor == at));
|
|
let background = Point::new(
|
|
bounds().x + bounds().width - 10.,
|
|
bounds().y + bounds().height - 10.,
|
|
);
|
|
motion(&canvas, &mut state, background, background);
|
|
assert!(matches!(input(&canvas, &mut state,
|
|
mouse::Event::ButtonPressed(mouse::Button::Right), background),
|
|
Some(Event::ContextMenu { context, object, placement: None, anchor, .. })
|
|
if context == canvas.context && object == f.diagram && anchor == background));
|
|
}
|
|
|
|
fn motion(
|
|
canvas: &StateCanvas<'_>,
|
|
state: &mut State,
|
|
actual: Point,
|
|
batch_cursor: Point,
|
|
) -> Option<Event> {
|
|
input(
|
|
canvas,
|
|
state,
|
|
mouse::Event::CursorMoved { position: actual },
|
|
batch_cursor,
|
|
)
|
|
}
|
|
|
|
fn click(canvas: &StateCanvas<'_>, state: &mut State, at: Point) -> Vec<Event> {
|
|
let at = screen(canvas, at);
|
|
motion(canvas, state, at, at)
|
|
.into_iter()
|
|
.chain(input(
|
|
canvas,
|
|
state,
|
|
mouse::Event::ButtonPressed(mouse::Button::Left),
|
|
at,
|
|
))
|
|
.chain(input(
|
|
canvas,
|
|
state,
|
|
mouse::Event::ButtonReleased(mouse::Button::Left),
|
|
at,
|
|
))
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn ordered_stationary_click_does_not_move_on_later_motion_in_the_same_batch() {
|
|
let f = fixture();
|
|
let canvas = canvas(&f, Tool::Select, 1.);
|
|
let mut state = State::default();
|
|
let press = screen(&canvas, header(&canvas, f.a));
|
|
let later = screen(&canvas, header(&canvas, f.b));
|
|
assert!(motion(&canvas, &mut state, press, later).is_none());
|
|
assert!(matches!(input(&canvas, &mut state,
|
|
mouse::Event::ButtonPressed(mouse::Button::Left), later),
|
|
Some(Event::Select(context, id)) if context == canvas.context && id == f.a));
|
|
assert!(
|
|
input(
|
|
&canvas,
|
|
&mut state,
|
|
mouse::Event::ButtonReleased(mouse::Button::Left),
|
|
later
|
|
)
|
|
.is_none()
|
|
);
|
|
assert!(motion(&canvas, &mut state, later, later).is_none());
|
|
assert!(state.press.is_none() && state.preview.is_none() && state.pan_from.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn drag_uses_ordered_release_at_each_zoom_and_moves_descendants_in_one_undo() {
|
|
for zoom in [0.5, 1., 2.] {
|
|
let f = fixture();
|
|
let mut canvas = canvas(&f, Tool::Select, zoom);
|
|
let mut state = State::default();
|
|
let press = screen(&canvas, header(&canvas, f.a));
|
|
let release = press + Vector::new(36., 24.) * zoom;
|
|
let later = release + Vector::new(150., 90.);
|
|
motion(&canvas, &mut state, press, later);
|
|
assert!(matches!(input(&canvas, &mut state,
|
|
mouse::Event::ButtonPressed(mouse::Button::Left), later),
|
|
Some(Event::Select(_, id)) if id == f.a));
|
|
// App selection changes are not a document/diagram change.
|
|
canvas.selected = f.a;
|
|
assert!(motion(&canvas, &mut state, release, later).is_none());
|
|
let event = input(
|
|
&canvas,
|
|
&mut state,
|
|
mouse::Event::ButtonReleased(mouse::Button::Left),
|
|
later,
|
|
)
|
|
.unwrap();
|
|
let node = placement(&f, f.a);
|
|
let original = f.project.diagrams[&f.diagram].nodes[&node].position;
|
|
assert!(
|
|
matches!(&event, Event::Move { context, node: moved, position }
|
|
if *context == canvas.context && *moved == node
|
|
&& *position == Position { x: original.x + 36., y: original.y + 24. })
|
|
);
|
|
assert!(motion(&canvas, &mut state, later, later).is_none());
|
|
assert!(
|
|
input(
|
|
&canvas,
|
|
&mut state,
|
|
mouse::Event::ButtonReleased(mouse::Button::Left),
|
|
later
|
|
)
|
|
.is_none()
|
|
);
|
|
let plan = state_edit::plan(&f.project, canvas.context, &event).unwrap();
|
|
assert_eq!(
|
|
plan.selected, f.a,
|
|
"Selection remains semantic, not the placement UUID"
|
|
);
|
|
let mut document =
|
|
Document::from_project(f.project.clone(), Default::default()).unwrap();
|
|
let source = document.bundle().clone();
|
|
document.execute(plan.command).unwrap();
|
|
let leaf = placement(&f, f.leaf);
|
|
let before = f.project.diagrams[&f.diagram].nodes[&leaf].position;
|
|
assert_eq!(
|
|
document.project().diagrams[&f.diagram].nodes[&leaf].position,
|
|
Position {
|
|
x: before.x + 36.,
|
|
y: before.y + 24.
|
|
}
|
|
);
|
|
assert!(document.undo());
|
|
assert_eq!(document.project(), &f.project);
|
|
assert_eq!(document.bundle(), &source);
|
|
assert!(
|
|
!document.undo(),
|
|
"A drag produces exactly one committed history entry"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn context_tool_and_pending_form_changes_cancel_mutating_gestures() {
|
|
let f = fixture();
|
|
for reset in 0..5 {
|
|
let mut canvas = canvas(&f, Tool::Select, 1.);
|
|
let mut state = State::default();
|
|
let press = screen(&canvas, header(&canvas, f.a));
|
|
let release = press + Vector::new(40., 30.);
|
|
motion(&canvas, &mut state, press, release);
|
|
input(
|
|
&canvas,
|
|
&mut state,
|
|
mouse::Event::ButtonPressed(mouse::Button::Left),
|
|
release,
|
|
);
|
|
motion(&canvas, &mut state, release, release);
|
|
assert!(state.preview.is_some());
|
|
match reset {
|
|
0 => canvas.context.document = Id::new_v4(),
|
|
1 => canvas.context.revision += 1,
|
|
2 => canvas.context.epoch += 1,
|
|
3 => canvas.tool = Tool::Transition,
|
|
_ => canvas.editable = false,
|
|
}
|
|
// A draw before the next input must not expose the old preview.
|
|
assert!(!canvas.gesture_active(&state));
|
|
let scene = canvas.scene(&state).unwrap();
|
|
let original = projection::project_view(&f.project, f.diagram);
|
|
assert_eq!(scene.view.nodes, original.nodes);
|
|
assert!(
|
|
input(
|
|
&canvas,
|
|
&mut state,
|
|
mouse::Event::ButtonReleased(mouse::Button::Left),
|
|
release
|
|
)
|
|
.is_none()
|
|
);
|
|
assert!(state.press.is_none() && state.preview.is_none() && state.source.is_none());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn transition_supports_click_click_and_drag_without_coalesced_accidental_edges() {
|
|
let f = fixture();
|
|
for drag in [false, true] {
|
|
let canvas = canvas(&f, Tool::Transition, 1.);
|
|
let mut state = State::default();
|
|
let source = screen(&canvas, header(&canvas, f.a));
|
|
let target = screen(&canvas, header(&canvas, f.c));
|
|
motion(&canvas, &mut state, source, target);
|
|
assert!(
|
|
input(
|
|
&canvas,
|
|
&mut state,
|
|
mouse::Event::ButtonPressed(mouse::Button::Left),
|
|
target
|
|
)
|
|
.is_none()
|
|
);
|
|
if !drag {
|
|
assert!(
|
|
input(
|
|
&canvas,
|
|
&mut state,
|
|
mouse::Event::ButtonReleased(mouse::Button::Left),
|
|
target
|
|
)
|
|
.is_none()
|
|
);
|
|
assert_eq!(state.source, Some(f.a));
|
|
}
|
|
assert!(motion(&canvas, &mut state, target, target).is_none());
|
|
if !drag {
|
|
input(
|
|
&canvas,
|
|
&mut state,
|
|
mouse::Event::ButtonPressed(mouse::Button::Left),
|
|
target,
|
|
);
|
|
}
|
|
let event = input(
|
|
&canvas,
|
|
&mut state,
|
|
mouse::Event::ButtonReleased(mouse::Button::Left),
|
|
target,
|
|
)
|
|
.unwrap();
|
|
assert!(matches!(event, Event::Connect { context, source, target }
|
|
if context == canvas.context && source == f.a && target == f.c));
|
|
assert!(state.source.is_none() && state.press.is_none());
|
|
}
|
|
let canvas = canvas(&f, Tool::Transition, 1.);
|
|
let mut state = State::default();
|
|
assert!(click(&canvas, &mut state, header(&canvas, f.b)).is_empty());
|
|
let events = click(&canvas, &mut state, header(&canvas, f.b));
|
|
assert!(
|
|
matches!(events.as_slice(), [Event::Connect { source, target, .. }]
|
|
if *source == f.b && *target == f.b),
|
|
"A deliberate second click can create a self-loop"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn creation_uses_the_explicit_deepest_owner_and_never_a_fallback_ancestor() {
|
|
let f = fixture();
|
|
let mut canvas = canvas(&f, Tool::Vertex(Kind::State), 1.);
|
|
let mut state = State::default();
|
|
let owner = header(&canvas, f.region);
|
|
assert!(matches!(click(&canvas, &mut state, owner).as_slice(),
|
|
[Event::CreateAt { context, owner: id, kind: Kind::State, position: at }]
|
|
if *context == canvas.context && *id == f.region && *at == position(owner)));
|
|
assert!(
|
|
click(&canvas, &mut state, header(&canvas, f.a)).is_empty(),
|
|
"Clicking a state must not silently create a sibling in its ancestor region"
|
|
);
|
|
assert!(click(&canvas, &mut state, Point::new(1400., 1000.)).is_empty());
|
|
canvas.tool = Tool::Region;
|
|
assert!(
|
|
matches!(click(&canvas, &mut state, header(&canvas, f.a)).as_slice(),
|
|
[Event::CreateAt { owner, kind: Kind::Region, .. }] if *owner == f.a)
|
|
);
|
|
assert!(
|
|
matches!(click(&canvas, &mut state, header(&canvas, f.machine)).as_slice(),
|
|
[Event::CreateAt { owner, kind: Kind::Region, .. }] if *owner == f.machine)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn explicit_attachment_drag_is_view_only_and_one_source_history_operation() {
|
|
for end in [EdgeEnd::Source, EdgeEnd::Target] {
|
|
for zoom in [0.5, 1., 2.] {
|
|
let mut f = fixture();
|
|
let relation =
|
|
f.project
|
|
.relate(RelationKind::StateTransition, "Go standby", f.a, f.b);
|
|
let second = state_edit::create_view(&f.project, f.machine).unwrap();
|
|
let other_id = second.diagram;
|
|
f.project = prepare_command(&f.project, &second.command)
|
|
.unwrap()
|
|
.project;
|
|
let other = f.project.diagrams[&other_id].clone();
|
|
let mut canvas = canvas(&f, Tool::AdjustAttachment { relation, end }, zoom);
|
|
canvas.selected = relation;
|
|
let mut state = State::default();
|
|
let scene = canvas.scene(&state).unwrap();
|
|
let route = &scene.routed.edges[&relation];
|
|
let tip = match end {
|
|
EdgeEnd::Source => route.points.first(),
|
|
EdgeEnd::Target => route.points.last(),
|
|
}
|
|
.unwrap();
|
|
let handle = screen(&canvas, point(*tip));
|
|
motion(&canvas, &mut state, handle, handle);
|
|
input(
|
|
&canvas,
|
|
&mut state,
|
|
mouse::Event::ButtonPressed(mouse::Button::Left),
|
|
handle,
|
|
);
|
|
assert!(
|
|
input(
|
|
&canvas,
|
|
&mut state,
|
|
mouse::Event::ButtonReleased(mouse::Button::Left),
|
|
handle
|
|
)
|
|
.is_none()
|
|
);
|
|
assert!(
|
|
state.attachment_preview.is_none(),
|
|
"A click must not materialize an automatic endpoint"
|
|
);
|
|
let owner =
|
|
state_edit::attachment_node(&f.project, f.diagram, relation, end).unwrap();
|
|
let destination = screen(
|
|
&canvas,
|
|
Point::new(owner.position.x + owner.size.width * 0.25, owner.position.y),
|
|
);
|
|
input(
|
|
&canvas,
|
|
&mut state,
|
|
mouse::Event::ButtonPressed(mouse::Button::Left),
|
|
handle,
|
|
);
|
|
motion(&canvas, &mut state, destination, destination);
|
|
assert!(state.attachment_preview.is_some());
|
|
assert!(f.project.diagrams[&f.diagram].routes.is_empty());
|
|
let event = input(
|
|
&canvas,
|
|
&mut state,
|
|
mouse::Event::ButtonReleased(mouse::Button::Left),
|
|
destination,
|
|
)
|
|
.unwrap();
|
|
assert!(matches!(event, Event::SetAttachment { end: actual, .. } if actual == end));
|
|
let plan = state_edit::plan(&f.project, canvas.context, &event).unwrap();
|
|
let mut document =
|
|
Document::from_project(f.project.clone(), Default::default()).unwrap();
|
|
let source = document.bundle().clone();
|
|
document.execute(plan.command).unwrap();
|
|
assert_eq!(document.project().relations, f.project.relations);
|
|
assert_eq!(document.project().diagrams[&other_id], other);
|
|
let route = &document.project().diagrams[&f.diagram].routes[0];
|
|
assert_eq!(
|
|
route.attachment(end),
|
|
Some(EdgeAttachment {
|
|
side: PortAnchorSide::Top,
|
|
offset: 0.25
|
|
})
|
|
);
|
|
let edited = document.bundle().clone();
|
|
assert!(document.undo());
|
|
assert_eq!(document.bundle(), &source);
|
|
assert!(!document.undo());
|
|
assert!(document.redo());
|
|
assert_eq!(document.bundle(), &edited);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn attachment_return_to_press_restores_exact_automatic_or_fractional_original() {
|
|
for original in [
|
|
None,
|
|
Some(EdgeAttachment {
|
|
side: PortAnchorSide::Top,
|
|
offset: 0.234567,
|
|
}),
|
|
] {
|
|
for end in [EdgeEnd::Source, EdgeEnd::Target] {
|
|
let mut f = fixture();
|
|
let relation =
|
|
f.project
|
|
.relate(RelationKind::StateTransition, "Go standby", f.a, f.b);
|
|
if original.is_some() {
|
|
let mut route =
|
|
crate::route_edit::stored_route(&f.project.diagrams[&f.diagram], relation);
|
|
route.set_attachment(end, original);
|
|
f.project
|
|
.diagrams
|
|
.get_mut(&f.diagram)
|
|
.unwrap()
|
|
.routes
|
|
.push(route);
|
|
}
|
|
let canvas = canvas(&f, Tool::AdjustAttachment { relation, end }, 1.);
|
|
let mut state = State::default();
|
|
let before = canvas.scene(&state).unwrap();
|
|
let edge = &before.routed.edges[&relation];
|
|
let tip = match end {
|
|
EdgeEnd::Source => edge.points.first(),
|
|
EdgeEnd::Target => edge.points.last(),
|
|
}
|
|
.unwrap();
|
|
let start = screen(&canvas, point(*tip));
|
|
let away = start + Vector::new(80., 40.);
|
|
motion(&canvas, &mut state, start, start);
|
|
input(
|
|
&canvas,
|
|
&mut state,
|
|
mouse::Event::ButtonPressed(mouse::Button::Left),
|
|
start,
|
|
);
|
|
motion(&canvas, &mut state, away, away);
|
|
assert!(state.attachment_preview.is_some());
|
|
motion(&canvas, &mut state, start, start);
|
|
assert!(state.attachment_preview.is_none());
|
|
assert_eq!(
|
|
canvas.scene(&state).unwrap().routed.edges[&relation].points,
|
|
edge.points
|
|
);
|
|
assert!(
|
|
input(
|
|
&canvas,
|
|
&mut state,
|
|
mouse::Event::ButtonReleased(mouse::Button::Left),
|
|
start
|
|
)
|
|
.is_none()
|
|
);
|
|
assert_eq!(
|
|
crate::route_edit::stored_route(&f.project.diagrams[&f.diagram], relation)
|
|
.attachment(end),
|
|
original
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn attachment_drag_cancels_on_camera_context_tool_pending_and_focus_changes() {
|
|
let mut f = fixture();
|
|
let relation = f
|
|
.project
|
|
.relate(RelationKind::StateTransition, "Go standby", f.a, f.b);
|
|
for cancel in 0..9 {
|
|
let mut canvas = canvas(
|
|
&f,
|
|
Tool::AdjustAttachment {
|
|
relation,
|
|
end: EdgeEnd::Source,
|
|
},
|
|
1.,
|
|
);
|
|
canvas.selected = relation;
|
|
let mut state = State::default();
|
|
let scene = canvas.scene(&state).unwrap();
|
|
let handle = screen(&canvas, point(scene.routed.edges[&relation].points[0]));
|
|
let destination = handle + Vector::new(40., 30.);
|
|
motion(&canvas, &mut state, handle, handle);
|
|
input(
|
|
&canvas,
|
|
&mut state,
|
|
mouse::Event::ButtonPressed(mouse::Button::Left),
|
|
handle,
|
|
);
|
|
motion(&canvas, &mut state, destination, destination);
|
|
assert!(state.attachment_preview.is_some());
|
|
match cancel {
|
|
0 => canvas.zoom = 2.,
|
|
1 => canvas.pan.x += 10.,
|
|
2 => canvas.context.epoch += 1,
|
|
3 => canvas.tool = Tool::Select,
|
|
4 => canvas.editable = false,
|
|
5 | 6 => {
|
|
input(
|
|
&canvas,
|
|
&mut state,
|
|
mouse::Event::ButtonPressed(if cancel == 5 {
|
|
mouse::Button::Right
|
|
} else {
|
|
mouse::Button::Middle
|
|
}),
|
|
destination,
|
|
);
|
|
}
|
|
7 => {
|
|
canvas::Program::update(
|
|
&canvas,
|
|
&mut state,
|
|
&canvas::Event::Window(iced::window::Event::Unfocused),
|
|
bounds(),
|
|
mouse::Cursor::Available(destination),
|
|
);
|
|
}
|
|
_ => {
|
|
motion(&canvas, &mut state, Point::new(-100., -100.), destination);
|
|
}
|
|
}
|
|
assert!(
|
|
input(
|
|
&canvas,
|
|
&mut state,
|
|
mouse::Event::ButtonReleased(mouse::Button::Left),
|
|
destination
|
|
)
|
|
.is_none()
|
|
);
|
|
assert!(state.attachment_preview.is_none());
|
|
assert!(f.project.diagrams[&f.diagram].routes.is_empty());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn selected_endpoint_reconnect_preserves_identity_and_requires_handle_motion() {
|
|
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 mut state = State::default();
|
|
let scene = canvas.scene(&state).unwrap();
|
|
let end = point(*scene.routed.edges[&relation].points.last().unwrap());
|
|
let handle = screen(&canvas, end);
|
|
motion(&canvas, &mut state, handle, handle);
|
|
input(
|
|
&canvas,
|
|
&mut state,
|
|
mouse::Event::ButtonPressed(mouse::Button::Left),
|
|
handle,
|
|
);
|
|
assert!(
|
|
input(
|
|
&canvas,
|
|
&mut state,
|
|
mouse::Event::ButtonReleased(mouse::Button::Left),
|
|
handle
|
|
)
|
|
.is_none()
|
|
);
|
|
let target = screen(&canvas, header(&canvas, f.c));
|
|
motion(&canvas, &mut state, handle, target);
|
|
input(
|
|
&canvas,
|
|
&mut state,
|
|
mouse::Event::ButtonPressed(mouse::Button::Left),
|
|
target,
|
|
);
|
|
motion(&canvas, &mut state, target, target);
|
|
let event = input(
|
|
&canvas,
|
|
&mut state,
|
|
mouse::Event::ButtonReleased(mouse::Button::Left),
|
|
target,
|
|
)
|
|
.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 accepted = prepare_command(&f.project, &plan.command).unwrap().project;
|
|
assert_eq!(plan.selected, relation);
|
|
assert_eq!(accepted.relations[&relation].source(), f.a);
|
|
assert_eq!(accepted.relations[&relation].target(), f.c);
|
|
}
|
|
|
|
#[test]
|
|
fn typed_state_actions_are_named_selectable_rows_before_internal_transitions_and_regions() {
|
|
let mut f = fixture();
|
|
let mut action_ids = vec![];
|
|
for (phase, name) in [
|
|
(StateActionKind::Exit, "Release resources"),
|
|
(StateActionKind::Entry, "Restore settings"),
|
|
(StateActionKind::Do, "Monitor pressure"),
|
|
] {
|
|
let work = f
|
|
.project
|
|
.add_element(Kind::Function, Layer::Sa, name, None)
|
|
.unwrap();
|
|
let id = f
|
|
.project
|
|
.relate(RelationKind::StateAction, "Action link", f.a, work);
|
|
if let RelationBody::StateAction { phase: value, .. } =
|
|
&mut f.project.relations.get_mut(&id).unwrap().body
|
|
{
|
|
*value = phase;
|
|
}
|
|
action_ids.push(id);
|
|
}
|
|
if let Some(SharedBody::State { entry, .. }) =
|
|
f.project.elements.get_mut(&f.a).unwrap().shared_mut()
|
|
{
|
|
*entry = "Authored entry notes".into();
|
|
}
|
|
let internal = f
|
|
.project
|
|
.relate(RelationKind::StateTransition, "Update status", f.a, f.a);
|
|
if let RelationBody::StateTransition {
|
|
transition_kind, ..
|
|
} = &mut f.project.relations.get_mut(&internal).unwrap().body
|
|
{
|
|
*transition_kind = TransitionKind::Internal;
|
|
}
|
|
let before = f.project.clone();
|
|
let canvas = canvas(&f, Tool::Select, 0.75);
|
|
let scene = canvas.scene(&State::default()).unwrap();
|
|
let a = scene.view.nodes.iter().find(|n| n.element == f.a).unwrap();
|
|
let rows = state_action_rows(&f.project, a);
|
|
assert_eq!(
|
|
rows.iter()
|
|
.map(|(r, _)| state_action_label(&f.project, r))
|
|
.collect::<Vec<_>>(),
|
|
[
|
|
"entry / Restore settings",
|
|
"do / Monitor pressure",
|
|
"exit / Release resources"
|
|
]
|
|
);
|
|
assert_eq!(
|
|
projection::state_header_height(&f.project, f.a),
|
|
64. + 28. + 3. * 28. + 56.
|
|
);
|
|
let internal_top = internal_rows(&f.project, a)[0].1.y;
|
|
assert!(rows[0].1.y >= a.position.y + 92.);
|
|
assert!(rows.last().unwrap().1.y + 28. <= internal_top);
|
|
let child_region = scene
|
|
.view
|
|
.nodes
|
|
.iter()
|
|
.find(|n| n.visual_parent == Some(a.id))
|
|
.unwrap();
|
|
assert!(
|
|
child_region.position.y
|
|
>= a.position.y + projection::state_header_height(&f.project, f.a)
|
|
);
|
|
for (relation, row) in rows {
|
|
assert_eq!(
|
|
canvas.hit(&scene, row.center()),
|
|
Some(Hit::Row {
|
|
placement: a.id,
|
|
relation: relation.id
|
|
})
|
|
);
|
|
let events = click(&canvas, &mut State::default(), row.center());
|
|
assert!(
|
|
matches!(events.as_slice(), [Event::Select(context, id)] if *context == canvas.context && *id == relation.id),
|
|
"Clicking a typed action row must select its relation in this view: {events:?}"
|
|
);
|
|
assert!(
|
|
!scene
|
|
.view
|
|
.nodes
|
|
.iter()
|
|
.any(|n| n.element == relation.target())
|
|
);
|
|
assert!(!scene.routed.edges.contains_key(&relation.id));
|
|
}
|
|
let mut collapsed = a.clone();
|
|
collapsed.collapsed = true;
|
|
assert!(state_action_rows(&f.project, &collapsed).is_empty());
|
|
assert_eq!(
|
|
f.project, before,
|
|
"Reading and selecting action rows cannot mutate work or ownership"
|
|
);
|
|
let target = f.project.relations[&action_ids[1]].target();
|
|
f.project.elements.get_mut(&target).unwrap().name = "Reload settings".into();
|
|
assert_eq!(
|
|
state_action_label(&f.project, &f.project.relations[&action_ids[1]]),
|
|
"entry / Reload settings"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn internal_rows_and_pseudostate_hits_use_shared_persisted_geometry() {
|
|
let mut f = fixture();
|
|
for name in ["Check pressure", "Update status"] {
|
|
let id = f
|
|
.project
|
|
.relate(RelationKind::StateTransition, name, f.a, f.a);
|
|
if let RelationBody::StateTransition {
|
|
transition_kind, ..
|
|
} = &mut f.project.relations.get_mut(&id).unwrap().body
|
|
{
|
|
*transition_kind = TransitionKind::Internal;
|
|
}
|
|
}
|
|
let edge = f
|
|
.project
|
|
.relate(RelationKind::StateTransition, "Remain standby", f.b, f.b);
|
|
let canvas = canvas(&f, Tool::Select, 3.);
|
|
let scene = canvas.scene(&State::default()).unwrap();
|
|
let a = scene.view.nodes.iter().find(|n| n.element == f.a).unwrap();
|
|
let rows = internal_rows(&f.project, a);
|
|
assert_eq!(rows.len(), 2);
|
|
assert_eq!(rows[1].1.y - rows[0].1.y, 56.);
|
|
assert_eq!(
|
|
rows[1].1.y + 56.,
|
|
a.position.y + projection::state_header_height(&f.project, f.a)
|
|
);
|
|
for (relation, rect) in rows {
|
|
assert_eq!(
|
|
canvas.hit(&scene, rect.center()),
|
|
Some(Hit::Row {
|
|
placement: a.id,
|
|
relation: relation.id
|
|
})
|
|
);
|
|
}
|
|
let initial = scene
|
|
.view
|
|
.nodes
|
|
.iter()
|
|
.find(|n| n.element == f.initial)
|
|
.unwrap();
|
|
assert_eq!(
|
|
initial.size,
|
|
projection::state_symbol_size(Kind::Initial).unwrap()
|
|
);
|
|
assert!(canvas.symbol_hit(initial, rectangle(initial).center()));
|
|
assert!(
|
|
!canvas.symbol_hit(initial, point(initial.position)),
|
|
"The invisible square corner is not part of the circular initial symbol"
|
|
);
|
|
let loop_route = &scene.routed.edges[&edge];
|
|
assert!(loop_route.points.len() >= 4);
|
|
assert!(
|
|
loop_route
|
|
.points
|
|
.windows(2)
|
|
.all(|pair| pair[0].x == pair[1].x || pair[0].y == pair[1].y)
|
|
);
|
|
assert!(!scene.routed.edges.values().any(|r| matches!(
|
|
f.project.relations[&r.id].body,
|
|
RelationBody::StateTransition {
|
|
transition_kind: TransitionKind::Internal,
|
|
..
|
|
}
|
|
)));
|
|
}
|
|
|
|
#[test]
|
|
fn pseudostate_names_avoid_rightward_and_downward_routes_and_share_hit_bounds() {
|
|
for rightward in [true, false] {
|
|
let mut f = fixture();
|
|
let initial = placement(&f, f.initial);
|
|
let target = placement(&f, f.b);
|
|
let diagram = f.project.diagrams.get_mut(&f.diagram).unwrap();
|
|
diagram
|
|
.nodes
|
|
.retain(|_, n| [f.machine, f.region, f.initial, f.b].contains(&n.element));
|
|
diagram.nodes.get_mut(&initial).unwrap().position = Position { x: 250., y: 220. };
|
|
diagram.nodes.get_mut(&target).unwrap().position = if rightward {
|
|
Position { x: 500., y: 181. }
|
|
} else {
|
|
Position { x: 146., y: 450. }
|
|
};
|
|
let transition =
|
|
f.project
|
|
.relate(RelationKind::StateTransition, "Begin", f.initial, f.b);
|
|
let canvas = canvas(&f, Tool::Select, 1.);
|
|
let scene = canvas.scene(&State::default()).unwrap();
|
|
let symbol = scene.view.nodes.iter().find(|n| n.id == initial).unwrap();
|
|
let legacy = Rectangle::new(
|
|
Point::new(
|
|
symbol.position.x + symbol.size.width + 7.,
|
|
symbol.position.y,
|
|
),
|
|
Size::new(33., 18.),
|
|
);
|
|
let route = &scene.routed.edges[&transition];
|
|
if rightward {
|
|
assert!(
|
|
route
|
|
.points
|
|
.windows(2)
|
|
.any(|p| segment_crosses(legacy, p[0], p[1])),
|
|
"The regression fixture must cross the former fixed right-hand name"
|
|
);
|
|
} else {
|
|
assert!(
|
|
route.points[1].y > route.points[0].y,
|
|
"The second case leaves the Initial downward"
|
|
);
|
|
}
|
|
let label = scene.symbol_labels[&initial];
|
|
assert!(scene.routed.edges.values().all(|edge| {
|
|
edge.points
|
|
.windows(2)
|
|
.all(|p| !segment_crosses(expanded(label, 3.), p[0], p[1]))
|
|
}));
|
|
assert_eq!(canvas.vertex_at(&scene, label.center()), Some(f.initial));
|
|
assert_eq!(
|
|
canvas.hit(&scene, label.center()),
|
|
Some(Hit::Node {
|
|
placement: initial,
|
|
element: f.initial
|
|
})
|
|
);
|
|
assert_eq!(
|
|
symbol.size,
|
|
projection::state_symbol_size(Kind::Initial).unwrap()
|
|
);
|
|
assert_eq!(
|
|
f.project.diagrams[&f.diagram].nodes[&initial].position,
|
|
Position { x: 250., y: 220. }
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn stacked_symbol_names_avoid_each_other_and_are_independent_of_input_order() {
|
|
let mut f = fixture();
|
|
let initial = placement(&f, f.initial);
|
|
f.project
|
|
.diagrams
|
|
.get_mut(&f.diagram)
|
|
.unwrap()
|
|
.nodes
|
|
.retain(|_, n| [f.machine, f.region, f.initial].contains(&n.element));
|
|
f.project
|
|
.diagrams
|
|
.get_mut(&f.diagram)
|
|
.unwrap()
|
|
.nodes
|
|
.get_mut(&initial)
|
|
.unwrap()
|
|
.position = Position { x: 250., y: 220. };
|
|
let junction = f
|
|
.project
|
|
.add_element(Kind::Junction, Layer::Sa, "Second marker", Some(f.region))
|
|
.unwrap();
|
|
f.project
|
|
.place(f.diagram, junction, Position { x: 250., y: 240. });
|
|
let canvas = canvas(&f, Tool::Select, 1.);
|
|
let scene = canvas.scene(&State::default()).unwrap();
|
|
let labels: Vec<_> = scene.symbol_labels.values().copied().collect();
|
|
assert_eq!(labels.len(), 2);
|
|
assert_eq!(overlap_area(expanded(labels[0], 3.), labels[1]), 0.);
|
|
for node in scene
|
|
.view
|
|
.nodes
|
|
.iter()
|
|
.filter(|n| scene.symbol_labels.contains_key(&n.id))
|
|
{
|
|
let label = scene.symbol_labels[&node.id];
|
|
assert_eq!(canvas.vertex_at(&scene, label.center()), Some(node.element));
|
|
for other in scene.view.nodes.iter().filter(|other| {
|
|
projection::state_symbol_size(f.project.elements[&other.element].kind()).is_some()
|
|
}) {
|
|
assert_eq!(overlap_area(expanded(label, 3.), rectangle(other)), 0.);
|
|
}
|
|
}
|
|
let mut reversed = scene.view.clone();
|
|
reversed.nodes.reverse();
|
|
assert_eq!(
|
|
symbol_labels(&f.project, &reversed, &scene.routed),
|
|
scene.symbol_labels
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn pan_is_button_scoped_read_only_and_hover_does_not_reproject_the_scene() {
|
|
let f = fixture();
|
|
let mut canvas = canvas(&f, Tool::Select, 1.);
|
|
canvas.editable = false;
|
|
let mut state = State::default();
|
|
let start = screen(&canvas, header(&canvas, f.a));
|
|
let end = start + Vector::new(32., 21.);
|
|
assert!(motion(&canvas, &mut state, start, start).is_none());
|
|
let scene = canvas.scene(&state).unwrap();
|
|
assert!(Arc::ptr_eq(&scene, &canvas.scene(&state).unwrap()));
|
|
input(
|
|
&canvas,
|
|
&mut state,
|
|
mouse::Event::ButtonPressed(mouse::Button::Middle),
|
|
end,
|
|
);
|
|
assert!(
|
|
input(
|
|
&canvas,
|
|
&mut state,
|
|
mouse::Event::ButtonReleased(mouse::Button::Left),
|
|
end
|
|
)
|
|
.is_none()
|
|
);
|
|
assert!(matches!(motion(&canvas, &mut state, end, end),
|
|
Some(Event::Pan(context, delta)) if context == canvas.context && delta == Vector::new(32., 21.)));
|
|
assert!(
|
|
input(
|
|
&canvas,
|
|
&mut state,
|
|
mouse::Event::ButtonReleased(mouse::Button::Middle),
|
|
end
|
|
)
|
|
.is_none()
|
|
);
|
|
assert!(state.pan_from.is_none());
|
|
canvas.pan += Vector::new(32., 21.);
|
|
canvas.zoom = 0.5;
|
|
assert!(
|
|
Arc::ptr_eq(&scene, &canvas.scene(&state).unwrap()),
|
|
"Pan, zoom, selection, and hover reuse the persisted projection and routing"
|
|
);
|
|
assert!(motion(&canvas, &mut state, start, start).is_none());
|
|
assert!(state.pan_from.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn escape_and_view_switch_discard_the_armed_transition_source() {
|
|
let f = fixture();
|
|
let mut canvas = canvas(&f, Tool::Transition, 1.);
|
|
let mut state = State::default();
|
|
click(&canvas, &mut state, header(&canvas, f.a));
|
|
assert_eq!(state.source, Some(f.a));
|
|
let escape = canvas::Event::Keyboard(keyboard::Event::KeyPressed {
|
|
key: keyboard::Key::Named(keyboard::key::Named::Escape),
|
|
modified_key: keyboard::Key::Named(keyboard::key::Named::Escape),
|
|
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Escape),
|
|
location: keyboard::Location::Standard,
|
|
modifiers: keyboard::Modifiers::empty(),
|
|
text: None,
|
|
repeat: false,
|
|
});
|
|
let result = canvas::Program::update(
|
|
&canvas,
|
|
&mut state,
|
|
&escape,
|
|
bounds(),
|
|
mouse::Cursor::Unavailable,
|
|
)
|
|
.and_then(|action| action.into_inner().0);
|
|
assert!(matches!(result, Some(Event::Choose(Tool::Select))));
|
|
assert!(state.source.is_none() && state.press.is_none());
|
|
click(&canvas, &mut state, header(&canvas, f.a));
|
|
assert_eq!(state.source, Some(f.a));
|
|
let at = screen(&canvas, header(&canvas, f.b));
|
|
canvas.diagram = Id::new_v4();
|
|
canvas.context.diagram = canvas.diagram;
|
|
assert!(motion(&canvas, &mut state, at, at).is_none());
|
|
assert!(state.source.is_none());
|
|
assert!(canvas.scene(&state).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn viewport_uses_full_canvas_coordinates_and_republishes_for_a_new_context() {
|
|
let f = fixture();
|
|
let mut canvas = canvas(&f, Tool::Select, 1.);
|
|
let mut state = State::default();
|
|
let redraw = canvas::Event::Window(iced::window::Event::RedrawRequested(
|
|
std::time::Instant::now(),
|
|
));
|
|
let mut redraw_message = |canvas: &StateCanvas<'_>| {
|
|
canvas::Program::update(
|
|
canvas,
|
|
&mut state,
|
|
&redraw,
|
|
bounds(),
|
|
mouse::Cursor::Unavailable,
|
|
)
|
|
.and_then(|action| action.into_inner().0)
|
|
};
|
|
let expected = bounds().size();
|
|
assert!(
|
|
matches!(redraw_message(&canvas), Some(Event::Viewport(context, size))
|
|
if context == canvas.context && size == expected)
|
|
);
|
|
assert!(redraw_message(&canvas).is_none());
|
|
canvas.context.document = Id::new_v4();
|
|
assert!(
|
|
matches!(redraw_message(&canvas), Some(Event::Viewport(context, size))
|
|
if context == canvas.context && size == expected)
|
|
);
|
|
}
|
|
}
|