3220 lines
122 KiB
Rust
3220 lines
122 KiB
Rust
use crate::{
|
|
app::Message,
|
|
route_edit::{self, Context, Event as RouteEvent, Gesture, Handle, point, position},
|
|
style,
|
|
views::short,
|
|
};
|
|
use iced::{
|
|
Color, Point, Rectangle, Renderer, Size, Theme, Vector, mouse,
|
|
widget::{
|
|
Action,
|
|
canvas::{self, Frame, Path, Stroke, Text},
|
|
},
|
|
};
|
|
use oa_core::{
|
|
projection::{ProjectedNode, ProjectedView, project_view},
|
|
routing::{RoutedView, route_diagram},
|
|
*,
|
|
};
|
|
use std::{
|
|
cell::RefCell,
|
|
collections::{BTreeMap, BTreeSet},
|
|
sync::Arc,
|
|
};
|
|
#[cfg(test)]
|
|
#[path = "diagram/allocation_group_tests.rs"]
|
|
mod allocation_group_tests;
|
|
#[cfg(test)]
|
|
#[path = "diagram/attachment_tests.rs"]
|
|
mod attachment_tests;
|
|
#[cfg(test)]
|
|
#[path = "diagram/lifecycle_tests.rs"]
|
|
mod lifecycle_tests;
|
|
#[cfg(test)]
|
|
#[path = "diagram/port_hit_tests.rs"]
|
|
mod port_hit_tests;
|
|
#[cfg(test)]
|
|
#[path = "diagram/view_options_tests.rs"]
|
|
mod view_options_tests;
|
|
|
|
#[path = "diagram/port_labels.rs"]
|
|
mod port_labels;
|
|
pub use port_labels::PortLabels;
|
|
#[path = "diagram/focus.rs"]
|
|
pub(crate) mod focus;
|
|
pub(crate) mod group_gesture;
|
|
mod multiselect;
|
|
#[cfg(test)]
|
|
mod multiselect_tests;
|
|
#[path = "diagram/work_drag.rs"]
|
|
mod work_drag;
|
|
pub use work_drag::WorkFeedback;
|
|
|
|
#[derive(Clone, Copy)]
|
|
pub struct DiagramCanvas<'a> {
|
|
pub selection: Option<&'a crate::diagram_selection::Selection>,
|
|
pub allocation: Option<crate::app::allocation_drag::Capability<'a>>,
|
|
pub project: &'a Project,
|
|
pub view_index: Option<&'a oa_core::view_options::ViewIndex>,
|
|
pub diagram: Id,
|
|
pub selected: Id,
|
|
/// Presentation-only navigation marker; never used by hit tests or edits.
|
|
pub navigation_focus: Option<Rectangle>,
|
|
pub zoom: f32,
|
|
pub pan: Vector,
|
|
pub context: Context,
|
|
pub selected_bend: Option<usize>,
|
|
pub selected_attachment: Option<EdgeEnd>,
|
|
pub editable: bool,
|
|
pub placing: bool,
|
|
pub connection: Option<&'a crate::connection_edit::Session>,
|
|
}
|
|
#[derive(Default)]
|
|
pub struct State {
|
|
modifiers: iced::keyboard::Modifiers,
|
|
group: Option<multiselect::Press>,
|
|
work: Option<Arc<crate::app::allocation_drag::Press>>,
|
|
work_cache: RefCell<Option<Arc<Scene>>>,
|
|
pointer: Option<Point>,
|
|
drag: Option<(Id, Point, Position)>,
|
|
preview: Option<Position>,
|
|
pan_start: Option<(mouse::Button, Point)>,
|
|
context: Option<Context>,
|
|
route_drag: Option<Gesture>,
|
|
attachment_viewport: Option<(f32, Vector, Rectangle)>,
|
|
connection_press: Option<(Id, Point)>,
|
|
connection_hover: Option<Point>,
|
|
port_drag: Option<crate::port_edit::Gesture>,
|
|
cache: RefCell<Option<CachedScene>>,
|
|
}
|
|
struct Scene {
|
|
work: Option<Arc<crate::app::allocation_drag::Preview>>,
|
|
view: ProjectedView,
|
|
routed: Arc<RoutedView>,
|
|
}
|
|
struct CachedScene {
|
|
context: Context,
|
|
preview: Option<Position>,
|
|
route: Option<EdgeRoute>,
|
|
port: Option<(Id, PortAnchor)>,
|
|
scene: Arc<Scene>,
|
|
}
|
|
/// Make room along the inserted node's containment path, moving only colliding
|
|
/// later siblings and their descendants. Existing semantic ownership is unchanged.
|
|
pub fn insertion_adjustments(p: &Project, diagram_id: Id, element: Id) -> Vec<Command> {
|
|
let mut diagram = p.diagrams[&diagram_id].clone();
|
|
// Reflow folded subtrees at their expanded size, without changing the
|
|
// user's fold state. Their hidden placements move with their container.
|
|
for node in diagram.nodes.values_mut() {
|
|
node.collapsed = false;
|
|
}
|
|
let view = oa_core::projection::project_diagram(p, &diagram);
|
|
let Some(inserted) = view.nodes.iter().find(|n| n.element == element) else {
|
|
return vec![];
|
|
};
|
|
let mut groups = Vec::new();
|
|
let mut affected = inserted.id;
|
|
let mut parent = inserted.visual_parent;
|
|
loop {
|
|
groups.push((parent, affected));
|
|
let Some(id) = parent else {
|
|
break;
|
|
};
|
|
affected = id;
|
|
parent = view
|
|
.nodes
|
|
.iter()
|
|
.find(|n| n.id == id)
|
|
.and_then(|n| n.visual_parent);
|
|
}
|
|
for (group, affected) in groups {
|
|
let view = oa_core::projection::project_diagram(p, &diagram);
|
|
let mut siblings: Vec<_> = view
|
|
.nodes
|
|
.iter()
|
|
.filter(|n| n.visual_parent == group)
|
|
.collect();
|
|
siblings.sort_by(|a, b| {
|
|
a.position
|
|
.y
|
|
.total_cmp(&b.position.y)
|
|
.then(a.position.x.total_cmp(&b.position.x))
|
|
.then(a.id.cmp(&b.id))
|
|
});
|
|
let ids: Vec<_> = siblings.iter().map(|n| n.id).collect();
|
|
let mut settled: Vec<(Rectangle, bool)> = Vec::new();
|
|
for id in ids {
|
|
let current = oa_core::projection::project_diagram(p, &diagram);
|
|
let node = current.nodes.iter().find(|n| n.id == id).unwrap();
|
|
let mut rect = Rectangle::new(
|
|
Point::new(node.position.x, node.position.y),
|
|
Size::new(node.size.width, node.size.height),
|
|
);
|
|
let original_y = rect.y;
|
|
let displaced = id == affected
|
|
|| settled
|
|
.iter()
|
|
.any(|(other, moved)| *moved && rect.intersects(other));
|
|
if displaced {
|
|
loop {
|
|
let next_y = settled
|
|
.iter()
|
|
.filter(|(other, _)| rect.intersects(other))
|
|
.map(|(other, _)| other.y + other.height + 24.)
|
|
.fold(rect.y, f32::max);
|
|
if next_y == rect.y {
|
|
break;
|
|
}
|
|
rect.y = next_y;
|
|
}
|
|
}
|
|
let dy = rect.y - original_y;
|
|
if dy > 0. {
|
|
let mut moved = oa_core::projection::descendant_nodes(p, diagram_id, id);
|
|
moved.push(id);
|
|
for moved in moved {
|
|
diagram.nodes.get_mut(&moved).unwrap().position.y += dy;
|
|
}
|
|
}
|
|
settled.push((rect, displaced));
|
|
}
|
|
}
|
|
let deltas = diagram
|
|
.nodes
|
|
.values()
|
|
.filter_map(|n| {
|
|
let original = &p.diagrams[&diagram_id].nodes[&n.id];
|
|
(n.position != original.position).then_some((
|
|
n.id,
|
|
Position {
|
|
x: n.position.x - original.position.x,
|
|
y: n.position.y - original.position.y,
|
|
},
|
|
))
|
|
})
|
|
.collect();
|
|
let routes = oa_core::projection::translated_routes(p, &p.diagrams[&diagram_id], &deltas);
|
|
let mut commands: Vec<_> = diagram
|
|
.nodes
|
|
.into_values()
|
|
.map(|mut node| {
|
|
node.collapsed = p.diagrams[&diagram_id].nodes[&node.id].collapsed;
|
|
node
|
|
})
|
|
.filter(|n| p.diagrams[&diagram_id].nodes[&n.id] != *n)
|
|
.map(|node| Command::EditNode {
|
|
diagram: diagram_id,
|
|
original: node.id,
|
|
node,
|
|
})
|
|
.collect();
|
|
commands.extend(routes.into_iter().map(|route| Command::SetEdgeRoute {
|
|
diagram: diagram_id,
|
|
route,
|
|
}));
|
|
commands
|
|
}
|
|
impl DiagramCanvas<'_> {
|
|
/// Enlarged hit regions overlap when zoomed out. The closest real glyph
|
|
/// wins; selection and stable identity only resolve coincident centers.
|
|
fn hit_port<'a>(
|
|
&self,
|
|
view: &'a ProjectedView,
|
|
local: Point,
|
|
) -> Option<&'a projection::ProjectedPort> {
|
|
let world = self.world(local);
|
|
let radius = (10. / self.zoom).max(10.);
|
|
view.ports
|
|
.iter()
|
|
.filter(|port| {
|
|
(world.x - port.position.x).abs() <= radius
|
|
&& (world.y - port.position.y).abs() <= radius
|
|
})
|
|
.min_by(|a, b| {
|
|
world
|
|
.distance(point(a.position))
|
|
.total_cmp(&world.distance(point(b.position)))
|
|
.then_with(|| (a.element != self.selected).cmp(&(b.element != self.selected)))
|
|
.then_with(|| a.element.cmp(&b.element))
|
|
.then_with(|| a.owner.cmp(&b.owner))
|
|
})
|
|
}
|
|
fn endpoint_at(&self, view: &ProjectedView, local: Point) -> Option<Id> {
|
|
self.hit_port(view, local).map(|p| p.element).or_else(|| {
|
|
self.hit(view, local)
|
|
.and_then(|id| view.nodes.iter().find(|n| n.id == id).map(|n| n.element))
|
|
})
|
|
}
|
|
fn selection_context_request(
|
|
&self,
|
|
state: &State,
|
|
bounds: Rectangle,
|
|
) -> crate::model_menu::Request {
|
|
let scene = self.scene(state);
|
|
let mut request = crate::model_menu::Request::object(self.diagram, bounds.center());
|
|
request.canvas = Some(self.context);
|
|
request.creation_point = Some(position(
|
|
self.world(Point::new(bounds.width / 2., bounds.height / 2.)),
|
|
));
|
|
let anchor =
|
|
if let Some(port) = scene.view.ports.iter().find(|p| p.element == self.selected) {
|
|
request.object = port.element;
|
|
Some(port.position)
|
|
} else if let Some(node) = scene
|
|
.view
|
|
.nodes
|
|
.iter()
|
|
.find(|n| n.element == self.selected || n.id == self.selected)
|
|
{
|
|
request.object = node.element;
|
|
request.placement = Some(node.id);
|
|
Some(Position {
|
|
x: node.position.x + 20.,
|
|
y: node.position.y + 20.,
|
|
})
|
|
} else if let Some(edge) = scene.routed.edges.get(&self.selected) {
|
|
request.object = edge.id;
|
|
request.bend = self
|
|
.selected_bend
|
|
.filter(|i| !edge.custom_suspended && *i < edge.pins.len());
|
|
if let Some(index) = request.bend {
|
|
Some(edge.pins[index])
|
|
} else {
|
|
request.route_point = Some(edge.label.anchor);
|
|
Some(edge.label.center)
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
if let Some(anchor) = anchor {
|
|
request.creation_point = Some(anchor);
|
|
request.anchor = Point::new(
|
|
bounds.x + anchor.x * self.zoom + self.pan.x,
|
|
bounds.y + anchor.y * self.zoom + self.pan.y,
|
|
);
|
|
}
|
|
request
|
|
}
|
|
fn context_request(
|
|
&self,
|
|
state: &State,
|
|
local: Point,
|
|
bounds: Rectangle,
|
|
) -> crate::model_menu::Request {
|
|
let scene = self.scene(state);
|
|
let world = self.world(local);
|
|
let mut request = crate::model_menu::Request::object(
|
|
self.diagram,
|
|
Point::new(bounds.x + local.x, bounds.y + local.y),
|
|
);
|
|
request.canvas = Some(self.context);
|
|
request.creation_point = Some(position(world));
|
|
if self.attachment_at(&scene, world).is_some() {
|
|
request.object = self.selected;
|
|
request.route_point = Some(position(world));
|
|
return request;
|
|
}
|
|
if let Some(edge) = scene.routed.edges.get(&self.selected)
|
|
&& !edge.custom_suspended
|
|
&& let Some(index) = edge
|
|
.pins
|
|
.iter()
|
|
.position(|pin| world.distance(point(*pin)) <= 8. / self.zoom)
|
|
{
|
|
request.object = edge.id;
|
|
request.bend = Some(index);
|
|
return request;
|
|
}
|
|
if let Some(port) = self.hit_port(&scene.view, local) {
|
|
request.object = port.element;
|
|
} else if let Some(node) = self.hit(&scene.view, local) {
|
|
request.object = self.project.diagrams[&self.diagram].nodes[&node].element;
|
|
request.placement = Some(node);
|
|
} else if let Some(edge) = self
|
|
.hit_label(&scene.routed, local)
|
|
.or_else(|| self.hit_association_label(&scene.routed, local))
|
|
.or_else(|| self.hit_edge(&scene.routed, local).map(|(id, _)| id))
|
|
{
|
|
request.object = edge;
|
|
request.route_point = oa_core::routing::project_onto_polyline(
|
|
&scene.routed.edges[&edge].points,
|
|
position(world),
|
|
)
|
|
.map(|p| p.point);
|
|
}
|
|
request
|
|
}
|
|
fn world(&self, p: Point) -> Point {
|
|
Point::new(
|
|
(p.x - self.pan.x) / self.zoom,
|
|
(p.y - self.pan.y) / self.zoom,
|
|
)
|
|
}
|
|
fn hit(&self, view: &ProjectedView, p: Point) -> Option<Id> {
|
|
let p = self.world(p);
|
|
view.nodes
|
|
.iter()
|
|
.rev()
|
|
.find(|n| {
|
|
Rectangle::new(
|
|
Point::new(n.position.x, n.position.y),
|
|
Size::new(n.size.width, n.size.height),
|
|
)
|
|
.contains(p)
|
|
&& (!view
|
|
.nodes
|
|
.iter()
|
|
.any(|child| child.visual_parent == Some(n.id))
|
|
|| p.y < n.position.y + 62.)
|
|
})
|
|
.map(|n| n.id)
|
|
}
|
|
fn hit_edge(&self, routed: &RoutedView, p: Point) -> Option<(Id, usize)> {
|
|
let p = self.world(p);
|
|
routed
|
|
.edges
|
|
.values()
|
|
.flat_map(|edge| {
|
|
edge.points.windows(2).enumerate().map(move |(i, span)| {
|
|
(
|
|
edge.id,
|
|
i,
|
|
distance_to_segment(p, point(span[0]), point(span[1])),
|
|
)
|
|
})
|
|
})
|
|
.filter(|(_, _, distance)| *distance <= 7. / self.zoom)
|
|
.min_by(|a, b| {
|
|
a.2.total_cmp(&b.2)
|
|
.then((a.0 != self.selected).cmp(&(b.0 != self.selected)))
|
|
.then(a.0.cmp(&b.0))
|
|
})
|
|
.map(|(id, segment, _)| (id, segment))
|
|
}
|
|
fn attachment_at<'b>(
|
|
&self,
|
|
scene: &'b Scene,
|
|
world: Point,
|
|
) -> Option<(EdgeEnd, &'b ProjectedNode)> {
|
|
let edge = scene.routed.edges.get(&self.selected)?;
|
|
if edge.custom_suspended {
|
|
return None;
|
|
}
|
|
[
|
|
(EdgeEnd::Source, edge.points.first()),
|
|
(EdgeEnd::Target, edge.points.last()),
|
|
]
|
|
.into_iter()
|
|
.filter_map(|(end, at)| {
|
|
let distance = world.distance(point(*at?));
|
|
let owner = route_edit::attachment_node(
|
|
self.project,
|
|
&self.project.diagrams[&self.diagram],
|
|
&scene.view,
|
|
self.selected,
|
|
end,
|
|
)?;
|
|
(distance <= 8. / self.zoom).then_some((end, owner, distance))
|
|
})
|
|
.min_by(|a, b| {
|
|
a.2.total_cmp(&b.2).then_with(|| {
|
|
(Some(a.0) != self.selected_attachment)
|
|
.cmp(&(Some(b.0) != self.selected_attachment))
|
|
})
|
|
})
|
|
.map(|(end, owner, _)| (end, owner))
|
|
}
|
|
fn hit_label(&self, routed: &RoutedView, p: Point) -> Option<Id> {
|
|
let p = self.world(p);
|
|
routed
|
|
.edges
|
|
.values()
|
|
.filter(|edge| {
|
|
!edge.label.text.is_empty()
|
|
&& Rectangle::new(
|
|
point(edge.label.bounds.position),
|
|
Size::new(edge.label.bounds.size.width, edge.label.bounds.size.height),
|
|
)
|
|
.contains(p)
|
|
})
|
|
.min_by(|a, b| {
|
|
p.distance(point(a.label.center))
|
|
.total_cmp(&p.distance(point(b.label.center)))
|
|
})
|
|
.map(|edge| edge.id)
|
|
}
|
|
fn hit_association_label(&self, routed: &RoutedView, p: Point) -> Option<Id> {
|
|
let p = self.world(p);
|
|
routed
|
|
.edges
|
|
.values()
|
|
.flat_map(|edge| {
|
|
self.project
|
|
.relations
|
|
.get(&edge.id)
|
|
.into_iter()
|
|
.flat_map(move |relation| routing::association_end_geometry(relation, edge))
|
|
.map(move |end| (edge.id, end.label))
|
|
})
|
|
.filter(|(_, label)| {
|
|
Rectangle::new(
|
|
point(label.bounds.position),
|
|
Size::new(label.bounds.size.width, label.bounds.size.height),
|
|
)
|
|
.contains(p)
|
|
})
|
|
.min_by(|a, b| {
|
|
p.distance(point(a.1.center))
|
|
.total_cmp(&p.distance(point(b.1.center)))
|
|
.then(a.0.cmp(&b.0))
|
|
})
|
|
.map(|(id, _)| id)
|
|
}
|
|
fn preview_diagram(&self, state: &State) -> Diagram {
|
|
let mut diagram = self.project.diagrams[&self.diagram].clone();
|
|
self.group_preview(state, &mut diagram);
|
|
if let (Some((id, _, original)), Some(preview)) = (state.drag, state.preview) {
|
|
let delta = Position {
|
|
x: preview.x - original.x,
|
|
y: preview.y - original.y,
|
|
};
|
|
let mut moved = oa_core::projection::descendant_nodes(self.project, self.diagram, id);
|
|
moved.push(id);
|
|
let deltas: BTreeMap<_, _> = moved.iter().map(|id| (*id, delta)).collect();
|
|
for route in oa_core::projection::translated_routes(self.project, &diagram, &deltas) {
|
|
route_edit::with_route(&mut diagram, route);
|
|
}
|
|
for id in moved {
|
|
if let Some(n) = diagram.nodes.get_mut(&id) {
|
|
n.position.x += delta.x;
|
|
n.position.y += delta.y;
|
|
}
|
|
}
|
|
}
|
|
if let Some(gesture) = &state.route_drag {
|
|
route_edit::with_route(&mut diagram, gesture.route.clone());
|
|
}
|
|
if let Some(gesture) = &state.port_drag
|
|
&& gesture.changed
|
|
{
|
|
crate::port_edit::with_anchor(&mut diagram, gesture.owner.id, gesture.anchor.clone());
|
|
}
|
|
diagram
|
|
}
|
|
#[cfg(test)]
|
|
fn preview_view(&self, state: &State) -> ProjectedView {
|
|
self.projected(&self.preview_diagram(state))
|
|
}
|
|
fn projected(&self, diagram: &Diagram) -> ProjectedView {
|
|
match self.view_index {
|
|
Some(index) => {
|
|
oa_core::projection::project_diagram_with_index(self.project, diagram, index)
|
|
}
|
|
None => oa_core::projection::project_diagram(self.project, diagram),
|
|
}
|
|
}
|
|
fn scene(&self, state: &State) -> Arc<Scene> {
|
|
if let Some(preview) = self.work_preview()
|
|
&& state
|
|
.work
|
|
.as_ref()
|
|
.is_some_and(|p| p.token == preview.token)
|
|
{
|
|
if let Some(scene) = state.work_cache.borrow().as_ref()
|
|
&& scene.work.as_ref().is_some_and(|p| Arc::ptr_eq(p, preview))
|
|
{
|
|
return scene.clone();
|
|
}
|
|
let scene = Arc::new(Scene {
|
|
work: Some(preview.clone()),
|
|
view: preview.placement.view.clone(),
|
|
routed: preview.routed.clone(),
|
|
});
|
|
*state.work_cache.borrow_mut() = Some(scene.clone());
|
|
return scene;
|
|
}
|
|
*state.work_cache.borrow_mut() = None;
|
|
let preview = if state.context == Some(self.context) {
|
|
state.preview
|
|
} else {
|
|
None
|
|
};
|
|
let route = state
|
|
.route_drag
|
|
.as_ref()
|
|
.filter(|g| g.context == self.context)
|
|
.map(|g| g.route.clone());
|
|
let port = state
|
|
.port_drag
|
|
.as_ref()
|
|
.filter(|g| g.context == self.context && g.changed)
|
|
.map(|g| (g.owner.id, g.anchor.clone()));
|
|
if let Some(cache) = state.cache.borrow().as_ref()
|
|
&& cache.context == self.context
|
|
&& cache.preview == preview
|
|
&& cache.route == route
|
|
&& cache.port == port
|
|
{
|
|
return cache.scene.clone();
|
|
}
|
|
let diagram = if state.context == Some(self.context) {
|
|
self.preview_diagram(state)
|
|
} else {
|
|
self.project.diagrams[&self.diagram].clone()
|
|
};
|
|
let view = self.projected(&diagram);
|
|
let routed = Arc::new(
|
|
if let Some(gesture) = state
|
|
.route_drag
|
|
.as_ref()
|
|
.filter(|g| g.context == self.context)
|
|
{
|
|
oa_core::routing::route_edge_preview(
|
|
self.project,
|
|
&diagram,
|
|
&view,
|
|
&gesture.baseline,
|
|
gesture.route.edge,
|
|
)
|
|
} else {
|
|
route_diagram(self.project, &diagram, &view)
|
|
},
|
|
);
|
|
let scene = Arc::new(Scene {
|
|
work: None,
|
|
view,
|
|
routed,
|
|
});
|
|
*state.cache.borrow_mut() = Some(CachedScene {
|
|
context: self.context,
|
|
preview,
|
|
route,
|
|
port,
|
|
scene: scene.clone(),
|
|
});
|
|
scene
|
|
}
|
|
fn begin_route(&self, state: &mut State, world: Point, handle: Handle, route: EdgeRoute) {
|
|
let baseline = self.scene(state).routed.clone();
|
|
state.route_drag = Some(Gesture {
|
|
context: self.context,
|
|
start: world,
|
|
handle,
|
|
route,
|
|
changed: false,
|
|
baseline,
|
|
});
|
|
}
|
|
}
|
|
impl canvas::Program<Message> for DiagramCanvas<'_> {
|
|
type State = State;
|
|
fn update(
|
|
&self,
|
|
state: &mut State,
|
|
event: &canvas::Event,
|
|
bounds: Rectangle,
|
|
cursor: mouse::Cursor,
|
|
) -> Option<Action<Message>> {
|
|
if let canvas::Event::Keyboard(iced::keyboard::Event::ModifiersChanged(modifiers)) = event {
|
|
state.modifiers = *modifiers;
|
|
return None;
|
|
}
|
|
if matches!(event, canvas::Event::Window(iced::window::Event::Unfocused)) {
|
|
state.modifiers = iced::keyboard::Modifiers::default();
|
|
}
|
|
// An attachment belongs to the boundary geometry captured at press.
|
|
// Camera/layout changes must not reinterpret its pointer in another space.
|
|
if matches!(
|
|
state.route_drag.as_ref().map(|gesture| &gesture.handle),
|
|
Some(Handle::Attachment { .. })
|
|
) && (state.attachment_viewport != Some((self.zoom, self.pan, bounds))
|
|
|| matches!(
|
|
event,
|
|
canvas::Event::Window(
|
|
iced::window::Event::Unfocused | iced::window::Event::Resized(_)
|
|
) | canvas::Event::Mouse(mouse::Event::CursorLeft)
|
|
))
|
|
{
|
|
state.route_drag = None;
|
|
state.attachment_viewport = None;
|
|
state.pointer = None;
|
|
return Some(Action::request_redraw());
|
|
}
|
|
// A completed work click retires App's allocation epoch before the
|
|
// retained canvas sees a redraw. Its already-idle press must not eat
|
|
// the first Delete. A live or not-yet-dispatched press stays guarded.
|
|
if matches!(event, canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed { key, .. })
|
|
if *key == iced::keyboard::Key::Named(iced::keyboard::key::Named::Delete))
|
|
&& self.allocation.is_some_and(|capability| {
|
|
capability.clean
|
|
&& state
|
|
.work
|
|
.as_ref()
|
|
.is_some_and(|press| press.stamp.epoch != capability.state.epoch)
|
|
})
|
|
{
|
|
state.work = None;
|
|
}
|
|
if let Some(action) = self.work_lifetime(state, event, bounds) {
|
|
return Some(action);
|
|
}
|
|
if state.context != Some(self.context) {
|
|
state.group = None;
|
|
state.work = None;
|
|
state.pointer = None;
|
|
state.port_drag = None;
|
|
state.connection_press = None;
|
|
state.connection_hover = None;
|
|
state.drag = None;
|
|
state.preview = None;
|
|
state.route_drag = None;
|
|
state.pan_start = None;
|
|
state.context = Some(self.context);
|
|
}
|
|
// Iced passes the final window cursor to every event in a native batch.
|
|
// Use ordered positions for presses and releases. A later move in the
|
|
// same batch must not turn a stationary click into a drag. Redraw is a
|
|
// checkpoint for another drag without an intervening move.
|
|
// A context change or a menu opening invalidates this continuity because
|
|
// an overlay may have consumed subsequent motion and release events.
|
|
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;
|
|
cursor
|
|
}
|
|
canvas::Event::Mouse(mouse::Event::ButtonReleased(_))
|
|
| canvas::Event::Mouse(mouse::Event::ButtonPressed(_)) => {
|
|
state.pointer.map_or(cursor, mouse::Cursor::Available)
|
|
}
|
|
canvas::Event::Window(iced::window::Event::RedrawRequested(_)) => {
|
|
state.pointer = cursor.position();
|
|
cursor
|
|
}
|
|
_ => cursor,
|
|
};
|
|
if self.connection.is_none() || !self.editable {
|
|
state.connection_press = None;
|
|
state.connection_hover = None;
|
|
}
|
|
if self.connection.is_some() {
|
|
state.port_drag = None;
|
|
state.drag = None;
|
|
state.preview = None;
|
|
state.route_drag = None;
|
|
if state
|
|
.pan_start
|
|
.is_some_and(|(button, _)| button != mouse::Button::Middle)
|
|
{
|
|
state.pan_start = None;
|
|
}
|
|
}
|
|
if state
|
|
.route_drag
|
|
.as_ref()
|
|
.is_some_and(|g| g.route.edge != self.selected)
|
|
|| !self.editable
|
|
{
|
|
state.route_drag = None;
|
|
}
|
|
if !self.editable {
|
|
state.port_drag = None;
|
|
}
|
|
let local = cursor
|
|
.position()
|
|
.map(|p| Point::new(p.x - bounds.x, p.y - bounds.y));
|
|
if let Some(action) = self.group_input(state, event, bounds, cursor) {
|
|
return Some(action);
|
|
}
|
|
match event {
|
|
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right))
|
|
if cursor.is_over(bounds) =>
|
|
{
|
|
state.pointer = None;
|
|
state.port_drag = None;
|
|
state.drag = None;
|
|
state.preview = None;
|
|
state.route_drag = None;
|
|
state.pan_start = None;
|
|
Some(
|
|
Action::publish(Message::Menu(crate::model_menu::Event::Open(
|
|
self.context_request(state, local?, bounds),
|
|
)))
|
|
.and_capture(),
|
|
)
|
|
}
|
|
canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed {
|
|
key, modifiers, ..
|
|
}) => {
|
|
// The mounted focus::Owner is the sole keyboard admission gate.
|
|
use iced::keyboard::{Key, key::Named};
|
|
match key.as_ref() {
|
|
Key::Named(Named::F2)
|
|
if self.editable && self.connection.is_none() && modifiers.is_empty() =>
|
|
{
|
|
let scene = self.scene(state);
|
|
let node = scene
|
|
.view
|
|
.nodes
|
|
.iter()
|
|
.find(|n| n.element == self.selected || n.id == self.selected)?;
|
|
Some(
|
|
Action::publish(Message::Architecture(
|
|
crate::app::architecture_authoring::Event::Rename(
|
|
self.context,
|
|
node.element,
|
|
Position {
|
|
x: node.position.x + 14.,
|
|
y: node.position.y + 35.,
|
|
},
|
|
),
|
|
))
|
|
.and_capture(),
|
|
)
|
|
}
|
|
Key::Named(Named::ContextMenu) | Key::Named(Named::F10)
|
|
if *key == Key::Named(Named::ContextMenu) || modifiers.shift() =>
|
|
{
|
|
state.pointer = None;
|
|
Some(
|
|
Action::publish(Message::Menu(crate::model_menu::Event::Open(
|
|
self.selection_context_request(state, bounds),
|
|
)))
|
|
.and_capture(),
|
|
)
|
|
}
|
|
Key::Named(key) if self.connection.is_some() && modifiers.is_empty() => {
|
|
let delta = match key {
|
|
Named::ArrowLeft => Vector::new(80., 0.),
|
|
Named::ArrowRight => Vector::new(-80., 0.),
|
|
Named::ArrowUp => Vector::new(0., 80.),
|
|
Named::ArrowDown => Vector::new(0., -80.),
|
|
_ => return None,
|
|
};
|
|
Some(Action::publish(Message::Pan(delta)).and_capture())
|
|
}
|
|
Key::Named(Named::Insert)
|
|
if self.editable && self.connection.is_none() && modifiers.is_empty() =>
|
|
{
|
|
Some(Action::publish(Message::Route(RouteEvent::AddBend)).and_capture())
|
|
}
|
|
Key::Named(Named::Delete)
|
|
if self.editable
|
|
&& self.connection.is_none()
|
|
&& !self.placing
|
|
&& state.drag.is_none()
|
|
&& state.route_drag.is_none()
|
|
&& state.port_drag.is_none()
|
|
&& state.pan_start.is_none()
|
|
&& state.work.is_none()
|
|
&& (modifiers.is_empty()
|
|
|| *modifiers == iced::keyboard::Modifiers::SHIFT) =>
|
|
{
|
|
// An invalid/stale bend never falls through to whole-item removal.
|
|
if self.selected_bend.is_some() {
|
|
return modifiers.is_empty().then(|| {
|
|
Action::publish(Message::Route(RouteEvent::RemoveBend))
|
|
.and_capture()
|
|
});
|
|
}
|
|
let request = self.selection_context_request(state, bounds);
|
|
if request.object == self.diagram {
|
|
return None;
|
|
}
|
|
Some(
|
|
Action::publish(Message::DiagramRemove(
|
|
crate::app::diagram_removal::Intent {
|
|
context: self.context,
|
|
object: request.object,
|
|
placement: request.placement,
|
|
model: modifiers.shift(),
|
|
},
|
|
))
|
|
.and_capture(),
|
|
)
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Middle))
|
|
if cursor.is_over(bounds) =>
|
|
{
|
|
state.port_drag = None;
|
|
state.drag = None;
|
|
state.preview = None;
|
|
state.route_drag = None;
|
|
state.connection_press = None;
|
|
state.pan_start = Some((mouse::Button::Middle, local?));
|
|
Some(Action::capture())
|
|
}
|
|
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Middle)) => {
|
|
if let Some((mouse::Button::Middle, start)) = state.pan_start {
|
|
state.pan_start = None;
|
|
return Some(match local {
|
|
Some(p) if p != start => {
|
|
Action::publish(Message::Pan(p - start)).and_capture()
|
|
}
|
|
_ => Action::capture(),
|
|
});
|
|
}
|
|
None
|
|
}
|
|
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
|
|
if cursor.is_over(bounds) =>
|
|
{
|
|
if state
|
|
.pan_start
|
|
.is_some_and(|(button, _)| button == mouse::Button::Middle)
|
|
{
|
|
return Some(Action::capture());
|
|
}
|
|
let p = local?;
|
|
let scene = self.scene(state);
|
|
let view = &scene.view;
|
|
let world = self.world(p);
|
|
if self.work_dirty_press(view, &scene.routed, p) {
|
|
state.work = None;
|
|
state.drag = None;
|
|
state.preview = None;
|
|
state.port_drag = None;
|
|
state.route_drag = None;
|
|
return Some(
|
|
Action::publish(Message::WorkDrag(
|
|
crate::app::allocation_drag::Event::Refused(
|
|
self.allocation.unwrap().state.epoch,
|
|
crate::app::allocation_drag::FINISH_EDIT.into(),
|
|
),
|
|
))
|
|
.and_capture(),
|
|
);
|
|
}
|
|
if self.placing && self.editable {
|
|
return Some(
|
|
Action::publish(Message::Architecture(
|
|
crate::app::architecture_authoring::Event::Click(
|
|
self.context,
|
|
position(world),
|
|
),
|
|
))
|
|
.and_capture(),
|
|
);
|
|
}
|
|
if self.connection.is_some() && self.editable {
|
|
let event = if let Some(id) = self.endpoint_at(view, p) {
|
|
state.connection_press = Some((id, p));
|
|
crate::connection_edit::Event::Endpoint(self.context, id)
|
|
} else {
|
|
crate::connection_edit::Event::Restart(self.context)
|
|
};
|
|
state.drag = None;
|
|
state.preview = None;
|
|
state.route_drag = None;
|
|
state.pan_start = None;
|
|
return Some(Action::publish(Message::Connection(event)).and_capture());
|
|
}
|
|
// group_input handles production selection first (including
|
|
// Shift). This also closes legacy movement when selection or
|
|
// Allocation capability is absent: never emit a bare MoveNode.
|
|
if self.allocation_surface()
|
|
&& let Some(target) = self.allocation_press_target(&scene, p)
|
|
&& self.allocation_handles_press(&target)
|
|
{
|
|
return Some(self.begin_allocation(state, target, cursor.position()?, bounds));
|
|
}
|
|
if self.editable
|
|
&& let Some(edge) = scene.routed.edges.get(&self.selected)
|
|
&& !edge.custom_suspended
|
|
{
|
|
let mut route = route_edit::stored_route(
|
|
&self.project.diagrams[&self.diagram],
|
|
self.selected,
|
|
);
|
|
if let Some((end, owner)) = self.attachment_at(&scene, world) {
|
|
let original = route.attachment(end);
|
|
self.begin_route(
|
|
state,
|
|
world,
|
|
Handle::Attachment {
|
|
end,
|
|
owner: owner.clone(),
|
|
original,
|
|
},
|
|
route,
|
|
);
|
|
state.attachment_viewport = Some((self.zoom, self.pan, bounds));
|
|
return Some(Action::capture());
|
|
}
|
|
if let Some(index) = edge
|
|
.pins
|
|
.iter()
|
|
.position(|pin| world.distance(point(*pin)) <= 8. / self.zoom)
|
|
{
|
|
self.begin_route(state, world, Handle::Bend(index), route);
|
|
return Some(
|
|
Action::publish(Message::Route(RouteEvent::SelectBend(
|
|
self.selected,
|
|
index,
|
|
)))
|
|
.and_capture(),
|
|
);
|
|
}
|
|
let label = &edge.label;
|
|
if self.hit(view, p).is_none()
|
|
&& !label.text.is_empty()
|
|
&& Rectangle::new(
|
|
point(label.bounds.position),
|
|
Size::new(label.bounds.size.width, label.bounds.size.height),
|
|
)
|
|
.contains(world)
|
|
{
|
|
let original = route.label.as_ref().map(|l| l.offset).unwrap_or(Position {
|
|
x: label.center.x - label.anchor.x,
|
|
y: label.center.y - label.anchor.y,
|
|
});
|
|
route.label.get_or_insert(EdgeLabel {
|
|
fraction: label.fraction,
|
|
offset: original,
|
|
});
|
|
self.begin_route(state, world, Handle::Label { original }, route);
|
|
return Some(Action::capture());
|
|
}
|
|
}
|
|
if let Some(port) = self.hit_port(view, p) {
|
|
if self.editable
|
|
&& let Some(owner) = view.nodes.iter().find(|n| n.id == port.owner)
|
|
{
|
|
state.port_drag = Some(crate::port_edit::Gesture::new(
|
|
self.context,
|
|
owner.clone(),
|
|
port,
|
|
world,
|
|
));
|
|
}
|
|
return Some(Action::publish(Message::Select(port.element)).and_capture());
|
|
}
|
|
if let Some(id) = self.hit(view, p) {
|
|
let position = self
|
|
.project
|
|
.diagrams
|
|
.get(&self.diagram)?
|
|
.nodes
|
|
.get(&id)?
|
|
.position;
|
|
state.drag = Some((id, p, position));
|
|
state.preview = Some(position);
|
|
Some(Action::publish(Message::SelectNode(id)).and_capture())
|
|
} else if let Some(id) = self
|
|
.hit_label(&scene.routed, p)
|
|
.or_else(|| self.hit_association_label(&scene.routed, p))
|
|
{
|
|
Some(Action::publish(Message::Select(id)).and_capture())
|
|
} else if let Some((id, segment)) = self.hit_edge(&scene.routed, p) {
|
|
if id == self.selected
|
|
&& self.editable
|
|
&& let Some(edge) = scene.routed.edges.get(&id)
|
|
&& !edge.custom_suspended
|
|
&& edge.pins.len() < 128
|
|
{
|
|
let mut route =
|
|
route_edit::stored_route(&self.project.diagrams[&self.diagram], id);
|
|
let index =
|
|
oa_core::routing::pin_insertion_index(edge, segment, position(world));
|
|
route.waypoints.insert(index, position(world));
|
|
self.begin_route(state, world, Handle::Bend(index), route);
|
|
Some(Action::capture())
|
|
} else {
|
|
Some(Action::publish(Message::Select(id)).and_capture())
|
|
}
|
|
} else {
|
|
state.pan_start = Some((mouse::Button::Left, p));
|
|
Some(Action::capture())
|
|
}
|
|
}
|
|
canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) => {
|
|
if let Some(press) = &state.work {
|
|
return Some(
|
|
Action::publish(Message::WorkDrag(
|
|
crate::app::allocation_drag::Event::Preview(
|
|
press.stamp,
|
|
press.token,
|
|
cursor.position(),
|
|
),
|
|
))
|
|
.and_capture(),
|
|
);
|
|
}
|
|
let p = local?;
|
|
if let Some((button, start)) = state.pan_start {
|
|
state.pan_start = Some((button, p));
|
|
return Some(Action::publish(Message::Pan(p - start)).and_capture());
|
|
}
|
|
if let Some(gesture) = &mut state.port_drag {
|
|
gesture.move_to(self.world(p), self.zoom);
|
|
return Some(Action::request_redraw().and_capture());
|
|
}
|
|
if self.connection.is_some() {
|
|
state.connection_hover = Some(p);
|
|
return Some(Action::request_redraw());
|
|
}
|
|
if let Some(gesture) = &mut state.route_drag {
|
|
gesture.move_attachment_to(self.world(p), self.zoom);
|
|
return Some(Action::request_redraw().and_capture());
|
|
}
|
|
if let Some((_, start, original)) = state.drag {
|
|
let delta = p - start;
|
|
state.preview = Some(if delta.x * delta.x + delta.y * delta.y > 9. {
|
|
Position {
|
|
x: original.x + delta.x / self.zoom,
|
|
y: original.y + delta.y / self.zoom,
|
|
}
|
|
} else {
|
|
original
|
|
});
|
|
return Some(Action::request_redraw());
|
|
}
|
|
None
|
|
}
|
|
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
|
|
if let Some(press) = state.work.take() {
|
|
return Some(
|
|
Action::publish(Message::WorkDrag(
|
|
crate::app::allocation_drag::Event::Release(
|
|
press.stamp,
|
|
press.token,
|
|
cursor.position(),
|
|
),
|
|
))
|
|
.and_capture(),
|
|
);
|
|
}
|
|
if let Some((button, start)) = state.pan_start {
|
|
if button == mouse::Button::Middle {
|
|
return Some(Action::capture());
|
|
}
|
|
state.pan_start = None;
|
|
return Some(match local {
|
|
Some(p) if p != start => {
|
|
Action::publish(Message::Pan(p - start)).and_capture()
|
|
}
|
|
_ => Action::capture(),
|
|
});
|
|
}
|
|
if let Some(mut gesture) = state.port_drag.take() {
|
|
if let Some(p) = local {
|
|
gesture.move_to(self.world(p), self.zoom);
|
|
}
|
|
if gesture.changed {
|
|
return Some(
|
|
Action::publish(Message::PortAnchor(crate::port_edit::Event::Commit(
|
|
gesture.context,
|
|
gesture.owner.id,
|
|
gesture.anchor,
|
|
)))
|
|
.and_capture(),
|
|
);
|
|
}
|
|
return Some(Action::capture());
|
|
}
|
|
if let Some(session) = self.connection {
|
|
if let Some((source, start)) = state.connection_press.take()
|
|
&& (session.source == Some(source)
|
|
|| (session.source.is_none() && session.candidates.contains(&source)))
|
|
&& let Some(p) = local.filter(|p| p.distance(start) > 3.)
|
|
&& cursor.is_over(bounds)
|
|
&& let Some(target) = self.endpoint_at(&self.scene(state).view, p)
|
|
&& target != source
|
|
{
|
|
return Some(
|
|
Action::publish(Message::Connection(
|
|
crate::connection_edit::Event::Endpoint(self.context, target),
|
|
))
|
|
.and_capture(),
|
|
);
|
|
}
|
|
return Some(Action::capture());
|
|
}
|
|
if let Some(mut gesture) = state.route_drag.take() {
|
|
if matches!(gesture.handle, Handle::Attachment { .. }) {
|
|
state.attachment_viewport = None;
|
|
if !cursor.is_over(bounds) {
|
|
return Some(Action::request_redraw().and_capture());
|
|
}
|
|
}
|
|
// `local` uses the ordered pointer position at release,
|
|
// before any later motion in the same native event batch.
|
|
if let Some(p) = local {
|
|
gesture.move_attachment_to(self.world(p), self.zoom);
|
|
}
|
|
if !gesture.changed {
|
|
return Some(Action::request_redraw().and_capture());
|
|
}
|
|
if let Handle::Attachment { end, owner, .. } = &gesture.handle {
|
|
if gesture.route
|
|
== route_edit::stored_route(
|
|
&self.project.diagrams[&self.diagram],
|
|
self.selected,
|
|
)
|
|
{
|
|
return Some(Action::request_redraw().and_capture());
|
|
}
|
|
return Some(
|
|
Action::publish(Message::Route(RouteEvent::CommitAttachment(
|
|
gesture.context,
|
|
gesture.route,
|
|
*end,
|
|
owner.id,
|
|
)))
|
|
.and_capture(),
|
|
);
|
|
}
|
|
let bend = match gesture.handle {
|
|
Handle::Bend(index) => Some(index),
|
|
Handle::Label { .. } => None,
|
|
Handle::Attachment { .. } => unreachable!(),
|
|
};
|
|
return Some(
|
|
Action::publish(Message::Route(RouteEvent::Commit(
|
|
gesture.context,
|
|
gesture.route,
|
|
bend,
|
|
)))
|
|
.and_capture(),
|
|
);
|
|
}
|
|
if let Some((id, start, original)) = state.drag.take() {
|
|
let preview = state.preview.take()?;
|
|
let pos = local.map_or(preview, |p| {
|
|
let delta = p - start;
|
|
if delta.x * delta.x + delta.y * delta.y > 9. {
|
|
Position {
|
|
x: original.x + delta.x / self.zoom,
|
|
y: original.y + delta.y / self.zoom,
|
|
}
|
|
} else {
|
|
original
|
|
}
|
|
});
|
|
if pos == original {
|
|
return Some(Action::capture());
|
|
}
|
|
Some(
|
|
Action::publish(Message::MoveNode(
|
|
id,
|
|
Position {
|
|
x: (pos.x / 10.).round() * 10.,
|
|
y: (pos.y / 10.).round() * 10.,
|
|
},
|
|
))
|
|
.and_capture(),
|
|
)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
canvas::Event::Mouse(mouse::Event::WheelScrolled { delta })
|
|
if cursor.is_over(bounds) =>
|
|
{
|
|
// Cancel before publishing Zoom: a release may arrive in this
|
|
// same event batch, before App applies the new camera.
|
|
if matches!(
|
|
state.route_drag.as_ref().map(|gesture| &gesture.handle),
|
|
Some(Handle::Attachment { .. })
|
|
) {
|
|
state.route_drag = None;
|
|
state.attachment_viewport = None;
|
|
}
|
|
let y = match delta {
|
|
mouse::ScrollDelta::Lines { y, .. } | mouse::ScrollDelta::Pixels { y, .. } => {
|
|
*y
|
|
}
|
|
};
|
|
if y == 0. {
|
|
None
|
|
} else {
|
|
Some(
|
|
Action::publish(Message::Zoom(if y > 0. { 1.1 } else { 1. / 1.1 }))
|
|
.and_capture(),
|
|
)
|
|
}
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
fn draw(
|
|
&self,
|
|
state: &State,
|
|
renderer: &Renderer,
|
|
_: &Theme,
|
|
bounds: Rectangle,
|
|
_: mouse::Cursor,
|
|
) -> Vec<canvas::Geometry> {
|
|
let mut f = Frame::new(renderer, bounds.size());
|
|
f.fill_rectangle(
|
|
Point::ORIGIN,
|
|
bounds.size(),
|
|
Color::from_rgb8(246, 248, 251),
|
|
);
|
|
let step = (24. * self.zoom).max(14.);
|
|
let ox = self.pan.x.rem_euclid(step);
|
|
let oy = self.pan.y.rem_euclid(step);
|
|
for x in 0..(bounds.width / step) as i32 + 1 {
|
|
for y in 0..(bounds.height / step) as i32 + 1 {
|
|
f.fill(
|
|
&Path::circle(Point::new(ox + x as f32 * step, oy + y as f32 * step), 0.65),
|
|
style::LINE,
|
|
);
|
|
}
|
|
}
|
|
f.translate(self.pan);
|
|
f.scale(self.zoom);
|
|
let scene = self.scene(state);
|
|
let project = scene.work.as_ref().map_or(self.project, |p| p.project());
|
|
let view = &scene.view;
|
|
let containers = view
|
|
.nodes
|
|
.iter()
|
|
.filter_map(|n| n.visual_parent)
|
|
.collect::<BTreeSet<_>>();
|
|
for n in &view.nodes {
|
|
if containers.contains(&n.id) {
|
|
paint_node(
|
|
&mut f,
|
|
project,
|
|
n,
|
|
if self.selection_contains(crate::diagram_usage::Target::Placement(n.id)) {
|
|
n.element
|
|
} else {
|
|
self.selected
|
|
},
|
|
true,
|
|
);
|
|
}
|
|
}
|
|
let highlighted = highlighted_exchanges(project, self.selected);
|
|
for edge in &view.edges {
|
|
let Some(routed) = scene.routed.edges.get(&edge.id) else {
|
|
continue;
|
|
};
|
|
let Some(&end) = routed.points.last() else {
|
|
continue;
|
|
};
|
|
let end = point(end);
|
|
let active = edge.id == self.selected
|
|
|| highlighted.contains(&edge.id)
|
|
|| self.selection_contains(crate::diagram_usage::Target::Edge(edge.id));
|
|
let color = if active {
|
|
style::TEAL
|
|
} else if routed
|
|
.warnings
|
|
.iter()
|
|
.any(|w| !matches!(w, oa_core::routing::RouteWarning::PromotedEndpoints))
|
|
{
|
|
Color::from_rgb(0.72, 0.47, 0.10)
|
|
} else if edge.trace {
|
|
Color::from_rgb8(134, 107, 168)
|
|
} else {
|
|
style::BOUNDARY
|
|
};
|
|
let capability_label = relationship_stereotype(project, edge.id);
|
|
let mut stroke =
|
|
Stroke::default()
|
|
.with_color(color)
|
|
.with_width(if active { 3. } else { 1.4 });
|
|
if matches!(capability_label, Some("«include»" | "«extend»")) {
|
|
stroke.line_dash = canvas::LineDash {
|
|
segments: &[6., 4.],
|
|
offset: 0,
|
|
};
|
|
}
|
|
f.stroke(&polyline(&routed.points), stroke);
|
|
let previous = point(routed.points[routed.points.len().saturating_sub(2)]);
|
|
let direction = end - previous;
|
|
let length = (direction.x * direction.x + direction.y * direction.y)
|
|
.sqrt()
|
|
.max(0.001);
|
|
let direction = direction / length;
|
|
let tip1 = end - direction * 9. + Vector::new(-direction.y, direction.x) * 4.;
|
|
let tip2 = end - direction * 9. - Vector::new(-direction.y, direction.x) * 4.;
|
|
if edge.oriented {
|
|
f.stroke(
|
|
&Path::new(|p| {
|
|
p.move_to(tip1);
|
|
p.line_to(end);
|
|
p.line_to(tip2);
|
|
}),
|
|
Stroke::default().with_color(color).with_width(1.7),
|
|
);
|
|
}
|
|
if !routed.label.text.is_empty() {
|
|
let label = &routed.label;
|
|
f.fill_rectangle(
|
|
point(label.bounds.position),
|
|
Size::new(label.bounds.size.width, label.bounds.size.height),
|
|
Color::WHITE,
|
|
);
|
|
f.fill_text(Text {
|
|
content: label.text.clone(),
|
|
position: point(label.center),
|
|
color: if active { style::TEAL } else { style::MUTED },
|
|
size: 13.into(),
|
|
align_x: iced::widget::text::Alignment::Center,
|
|
align_y: iced::alignment::Vertical::Center,
|
|
max_width: f32::INFINITY,
|
|
..Default::default()
|
|
});
|
|
}
|
|
if let Some(relation) = project.relations.get(&edge.id) {
|
|
for end in routing::association_end_geometry(relation, routed) {
|
|
if end.navigable {
|
|
let [a, tip, b] = navigation_mark(end.tip, end.inward);
|
|
f.stroke(
|
|
&Path::new(|p| {
|
|
p.move_to(a);
|
|
p.line_to(tip);
|
|
p.line_to(b);
|
|
}),
|
|
Stroke::default().with_color(color).with_width(1.7),
|
|
);
|
|
}
|
|
let label = &end.label;
|
|
f.fill_rectangle(
|
|
point(label.bounds.position),
|
|
Size::new(label.bounds.size.width, label.bounds.size.height),
|
|
Color::WHITE,
|
|
);
|
|
f.fill_text(Text {
|
|
content: label.text.clone(),
|
|
position: point(label.center),
|
|
color: if active { style::TEAL } else { style::MUTED },
|
|
size: 13.into(),
|
|
align_x: iced::widget::text::Alignment::Center,
|
|
align_y: iced::alignment::Vertical::Center,
|
|
max_width: f32::INFINITY,
|
|
..Default::default()
|
|
});
|
|
}
|
|
}
|
|
}
|
|
for n in &view.nodes {
|
|
if !containers.contains(&n.id) {
|
|
paint_node(
|
|
&mut f,
|
|
project,
|
|
n,
|
|
if self.selection_contains(crate::diagram_usage::Target::Placement(n.id)) {
|
|
n.element
|
|
} else {
|
|
self.selected
|
|
},
|
|
false,
|
|
);
|
|
} else {
|
|
// Container headers stay readable when relationships traverse
|
|
// the allocation enclosure. Children remain on top.
|
|
let mut header = n.clone();
|
|
header.size.height = 62.;
|
|
paint_node(
|
|
&mut f,
|
|
project,
|
|
&header,
|
|
if self.selection_contains(crate::diagram_usage::Target::Placement(n.id)) {
|
|
n.element
|
|
} else {
|
|
self.selected
|
|
},
|
|
true,
|
|
);
|
|
}
|
|
}
|
|
for port in &view.ports {
|
|
let selected = self.selected == port.element
|
|
|| self.selection_contains(crate::diagram_usage::Target::Port {
|
|
port: port.element,
|
|
placement: port.owner,
|
|
});
|
|
let color = if selected {
|
|
style::TEAL
|
|
} else {
|
|
style::kind_color(
|
|
project.elements[&port.element]
|
|
.parent
|
|
.and_then(|id| project.elements.get(&id))
|
|
.map(|e| e.kind())
|
|
.unwrap_or(Kind::Port),
|
|
)
|
|
};
|
|
let origin = Point::new(port.position.x - 7., port.position.y - 7.);
|
|
let glyph = port_glyph(&project.elements[&port.element]);
|
|
let path = if glyph == PortGlyph::Function {
|
|
let corners = port_triangle(&project.elements[&port.element], port);
|
|
Path::new(|p| {
|
|
p.move_to(corners[0]);
|
|
p.line_to(corners[1]);
|
|
p.line_to(corners[2]);
|
|
p.close();
|
|
})
|
|
} else {
|
|
Path::rectangle(origin, Size::new(14., 14.))
|
|
};
|
|
f.fill(&path, Color::WHITE);
|
|
let mut stroke =
|
|
Stroke::default()
|
|
.with_color(color)
|
|
.with_width(if selected { 2.5 } else { 1.5 });
|
|
if glyph == PortGlyph::Legacy {
|
|
stroke.line_dash = canvas::LineDash {
|
|
segments: &[3., 2.],
|
|
offset: 0,
|
|
};
|
|
}
|
|
f.stroke(&path, stroke);
|
|
let center = Point::new(port.position.x, port.position.y);
|
|
if matches!(glyph, PortGlyph::Flow(_)) {
|
|
f.stroke(
|
|
&Path::new(|p| {
|
|
for [a, b] in flow_mark(&project.elements[&port.element], port) {
|
|
p.move_to(a);
|
|
p.line_to(b);
|
|
}
|
|
}),
|
|
Stroke::default().with_color(color),
|
|
);
|
|
}
|
|
if matches!(glyph, PortGlyph::Unspecified | PortGlyph::Legacy) {
|
|
f.fill(&Path::circle(center, 1.5), color);
|
|
}
|
|
}
|
|
if let Some(bounds) = self.navigation_focus {
|
|
f.stroke(
|
|
&Path::rectangle(bounds.position(), bounds.size()),
|
|
Stroke::default()
|
|
.with_color(style::TEAL)
|
|
.with_width(2.5 / self.zoom),
|
|
);
|
|
}
|
|
if let Some(edge) = scene.routed.edges.get(&self.selected)
|
|
&& !edge.custom_suspended
|
|
{
|
|
for (end, at) in [
|
|
(EdgeEnd::Source, edge.points.first()),
|
|
(EdgeEnd::Target, edge.points.last()),
|
|
] {
|
|
if let Some(at) = at
|
|
&& route_edit::attachment_node(
|
|
project,
|
|
&project.diagrams[&self.diagram],
|
|
view,
|
|
self.selected,
|
|
end,
|
|
)
|
|
.is_some()
|
|
{
|
|
let at = point(*at);
|
|
let handle = Path::rectangle(
|
|
at - Vector::new(5.5 / self.zoom, 5.5 / self.zoom),
|
|
Size::new(11. / self.zoom, 11. / self.zoom),
|
|
);
|
|
f.fill(
|
|
&handle,
|
|
if self.selected_attachment == Some(end) {
|
|
style::TEAL
|
|
} else {
|
|
Color::WHITE
|
|
},
|
|
);
|
|
f.stroke(
|
|
&handle,
|
|
Stroke::default()
|
|
.with_color(style::TEAL)
|
|
.with_width(1.7 / self.zoom),
|
|
);
|
|
}
|
|
}
|
|
for (index, pin) in edge.pins.iter().enumerate() {
|
|
let handle = Path::circle(point(*pin), 5.5 / self.zoom);
|
|
f.fill(
|
|
&handle,
|
|
if self.selected_bend == Some(index) {
|
|
style::TEAL
|
|
} else {
|
|
Color::WHITE
|
|
},
|
|
);
|
|
f.stroke(
|
|
&handle,
|
|
Stroke::default()
|
|
.with_color(style::TEAL)
|
|
.with_width(1.7 / self.zoom),
|
|
);
|
|
}
|
|
}
|
|
if let Some(session) = self.connection {
|
|
let candidate_color = Color::from_rgb8(45, 123, 202);
|
|
for node in &view.nodes {
|
|
if session.candidates.contains(&node.element)
|
|
|| session.source == Some(node.element)
|
|
{
|
|
f.stroke(
|
|
&Path::rectangle(
|
|
Point::new(
|
|
node.position.x - 3. / self.zoom,
|
|
node.position.y - 3. / self.zoom,
|
|
),
|
|
Size::new(
|
|
node.size.width + 6. / self.zoom,
|
|
node.size.height + 6. / self.zoom,
|
|
),
|
|
),
|
|
Stroke::default()
|
|
.with_color(if session.source == Some(node.element) {
|
|
style::TEAL
|
|
} else {
|
|
candidate_color
|
|
})
|
|
.with_width(2. / self.zoom),
|
|
);
|
|
}
|
|
}
|
|
for port in &view.ports {
|
|
if session.candidates.contains(&port.element)
|
|
|| session.source == Some(port.element)
|
|
{
|
|
f.stroke(
|
|
&Path::circle(point(port.position), 12. / self.zoom),
|
|
Stroke::default()
|
|
.with_color(if session.source == Some(port.element) {
|
|
style::TEAL
|
|
} else {
|
|
candidate_color
|
|
})
|
|
.with_width(2. / self.zoom),
|
|
);
|
|
}
|
|
}
|
|
if let Some(source) = session.source
|
|
&& let Some(hover) = state.connection_hover
|
|
&& let Some(start) = endpoint_position(view, source)
|
|
{
|
|
let hit = self
|
|
.endpoint_at(view, hover)
|
|
.filter(|id| session.candidates.contains(id));
|
|
let end = hit
|
|
.and_then(|id| endpoint_position(view, id))
|
|
.unwrap_or(position(self.world(hover)));
|
|
let mid = (start.x + end.x) / 2.;
|
|
let mut stroke = Stroke::default()
|
|
.with_color(if hit.is_some() {
|
|
style::TEAL
|
|
} else {
|
|
candidate_color
|
|
})
|
|
.with_width(2. / self.zoom);
|
|
stroke.line_dash = canvas::LineDash {
|
|
segments: &[6., 4.],
|
|
offset: 0,
|
|
};
|
|
f.stroke(
|
|
&polyline(&[
|
|
start,
|
|
Position { x: mid, y: start.y },
|
|
Position { x: mid, y: end.y },
|
|
end,
|
|
]),
|
|
stroke,
|
|
);
|
|
}
|
|
}
|
|
let mut overlay = Frame::new(renderer, bounds.size());
|
|
let warning = scene
|
|
.routed
|
|
.edges
|
|
.get(&self.selected)
|
|
.and_then(|e| {
|
|
if e.custom_suspended {
|
|
None // The fixed routing toolbar already explains the folded state.
|
|
} else if !e.warnings.is_empty() {
|
|
Some(e.warnings[0].to_string())
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.or_else(|| {
|
|
let count = scene
|
|
.routed
|
|
.edges
|
|
.values()
|
|
.filter(|e| {
|
|
e.warnings.iter().any(|w| {
|
|
!matches!(w, oa_core::routing::RouteWarning::PromotedEndpoints)
|
|
})
|
|
})
|
|
.count();
|
|
(count > 0).then(|| {
|
|
format!("{count} connection routes need attention · select an amber line")
|
|
})
|
|
});
|
|
if let Some(warning) = warning {
|
|
overlay.fill_rectangle(
|
|
Point::new(8., 8.),
|
|
Size::new((bounds.width - 16.).max(0.), 30.),
|
|
Color::WHITE,
|
|
);
|
|
overlay.fill_text(Text {
|
|
content: warning,
|
|
position: Point::new(16., 15.),
|
|
size: 12.into(),
|
|
color: style::MUTED,
|
|
..Default::default()
|
|
});
|
|
}
|
|
vec![f.into_geometry(), overlay.into_geometry()]
|
|
}
|
|
fn mouse_interaction(
|
|
&self,
|
|
state: &State,
|
|
bounds: Rectangle,
|
|
cursor: mouse::Cursor,
|
|
) -> mouse::Interaction {
|
|
if (self.connection.is_some() || self.placing) && cursor.is_over(bounds) {
|
|
mouse::Interaction::Crosshair
|
|
} else if state.work.is_some()
|
|
|| state.drag.is_some()
|
|
|| state.pan_start.is_some()
|
|
|| state.route_drag.is_some()
|
|
|| state.port_drag.is_some()
|
|
{
|
|
mouse::Interaction::Grabbing
|
|
} else if cursor.is_over(bounds) {
|
|
mouse::Interaction::Grab
|
|
} else {
|
|
mouse::Interaction::default()
|
|
}
|
|
}
|
|
}
|
|
fn endpoint_position(view: &ProjectedView, id: Id) -> Option<Position> {
|
|
view.ports
|
|
.iter()
|
|
.find(|p| p.element == id)
|
|
.map(|p| p.position)
|
|
.or_else(|| {
|
|
view.nodes
|
|
.iter()
|
|
.find(|n| n.element == id)
|
|
.map(|n| Position {
|
|
x: n.position.x + n.size.width / 2.,
|
|
y: n.position.y + n.size.height / 2.,
|
|
})
|
|
})
|
|
}
|
|
fn navigation_mark(tip: Position, inward: Position) -> [Point; 3] {
|
|
let tip = point(tip);
|
|
let delta = point(inward) - tip;
|
|
let direction = delta / delta.x.hypot(delta.y).max(0.001);
|
|
let normal = Vector::new(-direction.y, direction.x) * 4.;
|
|
[
|
|
tip + direction * 9. + normal,
|
|
tip,
|
|
tip + direction * 9. - normal,
|
|
]
|
|
}
|
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
|
enum PortGlyph {
|
|
Function,
|
|
Standard,
|
|
Flow(bool),
|
|
Unspecified,
|
|
Legacy,
|
|
}
|
|
fn port_marker_point(
|
|
element: &oa_core::Element,
|
|
port: &projection::ProjectedPort,
|
|
along: f32,
|
|
across: f32,
|
|
) -> Point {
|
|
let inward = matches!(
|
|
port_contract(element),
|
|
Some(
|
|
PortContract::FunctionInput { .. }
|
|
| PortContract::Component {
|
|
orientation: ComponentPortOrientation::In,
|
|
..
|
|
}
|
|
)
|
|
);
|
|
let normal = port.side.normal();
|
|
let direction = Vector::new(normal.x, normal.y) * if inward { -1. } else { 1. };
|
|
point(port.position) + direction * along + Vector::new(-direction.y, direction.x) * across
|
|
}
|
|
fn port_triangle(element: &oa_core::Element, port: &projection::ProjectedPort) -> [Point; 3] {
|
|
[(-7., -7.), (7., 0.), (-7., 7.)]
|
|
.map(|(along, across)| port_marker_point(element, port, along, across))
|
|
}
|
|
fn flow_mark(element: &oa_core::Element, port: &projection::ProjectedPort) -> Vec<[Point; 2]> {
|
|
let mut lines = vec![
|
|
[(-4., 0.), (4., 0.)],
|
|
[(1., -3.), (4., 0.)],
|
|
[(4., 0.), (1., 3.)],
|
|
];
|
|
if port_glyph(element) == PortGlyph::Flow(true) {
|
|
lines.extend([[(-1., -3.), (-4., 0.)], [(-4., 0.), (-1., 3.)]]);
|
|
}
|
|
lines
|
|
.into_iter()
|
|
.map(|line| line.map(|(along, across)| port_marker_point(element, port, along, across)))
|
|
.collect()
|
|
}
|
|
|
|
fn port_glyph(element: &oa_core::Element) -> PortGlyph {
|
|
match port_contract(element) {
|
|
Some(PortContract::FunctionInput { .. } | PortContract::FunctionOutput { .. }) => {
|
|
PortGlyph::Function
|
|
}
|
|
Some(PortContract::Component {
|
|
kind: ComponentPortKind::Standard,
|
|
..
|
|
}) => PortGlyph::Standard,
|
|
Some(PortContract::Component {
|
|
orientation: ComponentPortOrientation::Unset,
|
|
..
|
|
}) => PortGlyph::Unspecified,
|
|
Some(PortContract::Component { orientation, .. }) => {
|
|
PortGlyph::Flow(*orientation == ComponentPortOrientation::InOut)
|
|
}
|
|
_ => PortGlyph::Legacy,
|
|
}
|
|
}
|
|
|
|
fn paint_node(f: &mut Frame, p: &Project, n: &ProjectedNode, selected: Id, container: bool) {
|
|
let Some(e) = p.elements.get(&n.element) else {
|
|
return;
|
|
};
|
|
let color = style::kind_color(e.kind());
|
|
let origin = Point::new(n.position.x, n.position.y);
|
|
let size = Size::new(n.size.width, n.size.height);
|
|
let path = Path::rounded_rectangle(origin, size, 10.into());
|
|
let tint = Color {
|
|
r: 0.94 + color.r * 0.06,
|
|
g: 0.94 + color.g * 0.06,
|
|
b: 0.94 + color.b * 0.06,
|
|
a: 1.,
|
|
};
|
|
f.fill(&path, if container { tint } else { Color::WHITE });
|
|
let mut stroke = Stroke::default()
|
|
.with_color(if e.id == selected {
|
|
color
|
|
} else {
|
|
style::BOUNDARY
|
|
})
|
|
.with_width(if e.id == selected { 2.6 } else { 1.2 });
|
|
if n.allocation_summary {
|
|
stroke.line_dash = canvas::LineDash {
|
|
segments: &[6., 4.],
|
|
offset: 0,
|
|
};
|
|
}
|
|
f.stroke(&path, stroke);
|
|
f.fill_rectangle(
|
|
Point::new(origin.x + 14., origin.y + 15.),
|
|
Size::new(4., 12.),
|
|
color,
|
|
);
|
|
f.fill_text(Text {
|
|
content: node_header(e.kind(), n),
|
|
position: Point::new(origin.x + 25., origin.y + 14.),
|
|
color,
|
|
size: 11.into(),
|
|
max_width: f32::INFINITY,
|
|
..Default::default()
|
|
});
|
|
let max_chars = ((size.width - 28.) / 8.0).max(8.) as usize;
|
|
f.fill_text(Text {
|
|
content: short(&e.name, max_chars),
|
|
position: Point::new(origin.x + 14., origin.y + 35.),
|
|
color: style::INK,
|
|
size: 16.into(),
|
|
font: iced::Font {
|
|
style: if n.allocation_summary {
|
|
iced::font::Style::Italic
|
|
} else {
|
|
iced::font::Style::Normal
|
|
},
|
|
..iced::Font::DEFAULT
|
|
},
|
|
max_width: f32::INFINITY,
|
|
..Default::default()
|
|
});
|
|
}
|
|
fn node_header(kind: Kind, node: &ProjectedNode) -> String {
|
|
let header = if node.allocation_summary {
|
|
format!(
|
|
"{} · SUMMARY",
|
|
if kind == Kind::Function {
|
|
"FUNCTION"
|
|
} else {
|
|
"ACTIVITY"
|
|
}
|
|
)
|
|
} else {
|
|
kind.label().to_uppercase()
|
|
};
|
|
format!("{header}{}", if node.collapsed { " +" } else { "" })
|
|
}
|
|
fn highlighted_exchanges(p: &Project, selected: Id) -> BTreeSet<Id> {
|
|
p.elements
|
|
.get(&selected)
|
|
.and_then(|e| e.steps())
|
|
.into_iter()
|
|
.flatten()
|
|
.filter_map(|id| match p.elements.get(id)?.shared()? {
|
|
SharedBody::Occurrence {
|
|
body: OccurrenceBody::Exchange { exchange, .. },
|
|
..
|
|
} => *exchange,
|
|
_ => None,
|
|
})
|
|
.collect()
|
|
}
|
|
fn relationship_stereotype(p: &Project, id: Id) -> Option<&'static str> {
|
|
match p.relations.get(&id)?.kind() {
|
|
RelationKind::CapabilityInclude => Some("«include»"),
|
|
RelationKind::CapabilityExtend => Some("«extend»"),
|
|
RelationKind::CapabilityGeneralization => Some("specializes"),
|
|
RelationKind::MissionCapability => Some("exploits"),
|
|
RelationKind::MissionInvolvement | RelationKind::CapabilityInvolvement => {
|
|
Some("participates")
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
fn polyline(points: &[Position]) -> Path {
|
|
Path::new(|p| {
|
|
if let Some(first) = points.first() {
|
|
p.move_to(point(*first));
|
|
for next in &points[1..] {
|
|
p.line_to(point(*next));
|
|
}
|
|
}
|
|
})
|
|
}
|
|
pub fn distance_to_segment(p: Point, a: Point, b: Point) -> f32 {
|
|
let ab = b - a;
|
|
let ap = p - a;
|
|
let len = ab.x * ab.x + ab.y * ab.y;
|
|
let t = if len > 0. {
|
|
((ap.x * ab.x + ap.y * ab.y) / len).clamp(0., 1.)
|
|
} else {
|
|
0.
|
|
};
|
|
let delta = p - (a + ab * t);
|
|
(delta.x * delta.x + delta.y * delta.y).sqrt()
|
|
}
|
|
fn escape(s: &str) -> String {
|
|
s.replace('&', "&")
|
|
.replace('<', "<")
|
|
.replace('>', ">")
|
|
.replace('"', """)
|
|
.replace('\'', "'")
|
|
}
|
|
fn hex(c: Color) -> String {
|
|
format!(
|
|
"#{:02x}{:02x}{:02x}",
|
|
(c.r * 255.) as u8,
|
|
(c.g * 255.) as u8,
|
|
(c.b * 255.) as u8
|
|
)
|
|
}
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum ExportError {
|
|
MissingDiagram(Id),
|
|
UnsupportedScenario(Id),
|
|
}
|
|
impl std::fmt::Display for ExportError {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
Self::MissingDiagram(id) => write!(f, "Cannot export: diagram {id} no longer exists."),
|
|
Self::UnsupportedScenario(_) => {
|
|
f.write_str("SVG export of saved Scenario views is not available yet.")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
impl std::error::Error for ExportError {}
|
|
|
|
pub fn export_svg(p: &Project, diagram: Id) -> Result<String, ExportError> {
|
|
let d = p
|
|
.diagrams
|
|
.get(&diagram)
|
|
.ok_or(ExportError::MissingDiagram(diagram))?;
|
|
if d.projection.scenario().is_some() {
|
|
return Err(ExportError::UnsupportedScenario(diagram));
|
|
}
|
|
let view = project_view(p, diagram);
|
|
let routed = route_diagram(p, d, &view);
|
|
let left = routed.bounds.position.x - 30.;
|
|
let top = routed.bounds.position.y - 30.;
|
|
let right = routed.bounds.position.x + routed.bounds.size.width + 30.;
|
|
let bottom = routed.bounds.position.y + routed.bounds.size.height + 30.;
|
|
let mut svg = format!(
|
|
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"{left} {top} {} {}\"><title>{}</title><defs><marker id=\"arrow\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\"><path d=\"M 0 0 L 10 5 L 0 10\" fill=\"#8999a9\"/></marker></defs><rect x=\"{left}\" y=\"{top}\" width=\"{}\" height=\"{}\" fill=\"#f6f8fb\"/>",
|
|
right - left,
|
|
bottom - top,
|
|
escape(p.named(diagram)),
|
|
right - left,
|
|
bottom - top
|
|
);
|
|
let containers = view
|
|
.nodes
|
|
.iter()
|
|
.filter_map(|n| n.visual_parent)
|
|
.collect::<BTreeSet<_>>();
|
|
let node_svg = |n: &ProjectedNode| {
|
|
let e = &p.elements[&n.element];
|
|
let color = hex(style::kind_color(e.kind()));
|
|
let x = n.position.x;
|
|
let y = n.position.y;
|
|
let (border, name_style) = if n.allocation_summary {
|
|
(" stroke-dasharray=\"6 4\"", " font-style=\"italic\"")
|
|
} else {
|
|
("", "")
|
|
};
|
|
format!(
|
|
"<g><rect x=\"{x}\" y=\"{y}\" width=\"{}\" height=\"{}\" rx=\"10\" fill=\"{}\" stroke=\"{color}\"{border}/><text x=\"{}\" y=\"{}\" font-family=\"sans-serif\" font-size=\"11\" fill=\"{color}\">{}</text><text x=\"{}\" y=\"{}\" font-family=\"sans-serif\" font-size=\"16\" fill=\"#223044\"{name_style}>{}</text></g>",
|
|
n.size.width,
|
|
n.size.height,
|
|
if containers.contains(&n.id) {
|
|
"#edf3f7"
|
|
} else {
|
|
"white"
|
|
},
|
|
x + 14.,
|
|
y + 25.,
|
|
escape(&node_header(e.kind(), n)),
|
|
x + 14.,
|
|
y + 51.,
|
|
escape(&short(
|
|
&e.name,
|
|
((n.size.width - 28.) / 8.).max(8.) as usize
|
|
))
|
|
)
|
|
};
|
|
for n in &view.nodes {
|
|
if containers.contains(&n.id) {
|
|
svg.push_str(&node_svg(n));
|
|
}
|
|
}
|
|
for edge in &view.edges {
|
|
let Some(route) = routed.edges.get(&edge.id) else {
|
|
continue;
|
|
};
|
|
let stereotype = relationship_stereotype(p, edge.id);
|
|
let dashed = if matches!(stereotype, Some("«include»" | "«extend»")) {
|
|
" stroke-dasharray=\"6 4\""
|
|
} else {
|
|
""
|
|
};
|
|
let arrow = if edge.oriented {
|
|
" marker-end=\"url(#arrow)\""
|
|
} else {
|
|
""
|
|
};
|
|
let path = route
|
|
.points
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, p)| format!("{} {} {}", if i == 0 { "M" } else { "L" }, p.x, p.y))
|
|
.collect::<Vec<_>>()
|
|
.join(" ");
|
|
svg.push_str(&format!("<path data-edge=\"{}\" d=\"{path}\" fill=\"none\" stroke=\"#8999a9\" stroke-width=\"1.5\"{dashed}{arrow}><title>{}</title></path>",edge.id,escape(p.named(edge.id))));
|
|
let label = &route.label;
|
|
if !label.text.is_empty() {
|
|
svg.push_str(&format!("<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"white\"/><text x=\"{}\" y=\"{}\" text-anchor=\"middle\" dominant-baseline=\"central\" font-family=\"sans-serif\" font-size=\"13\" fill=\"#637487\">{}</text>",label.bounds.position.x,label.bounds.position.y,label.bounds.size.width,label.bounds.size.height,label.center.x,label.center.y,escape(&label.text)));
|
|
}
|
|
if let Some(relation) = p.relations.get(&edge.id) {
|
|
for end in routing::association_end_geometry(relation, route) {
|
|
let side = if end.source_end { "source" } else { "target" };
|
|
svg.push_str(&format!(
|
|
"<g data-association=\"{}\" data-end=\"{side}\">",
|
|
edge.id
|
|
));
|
|
if end.navigable {
|
|
let [a, tip, b] = navigation_mark(end.tip, end.inward);
|
|
svg.push_str(&format!("<path data-navigation=\"{side}\" d=\"M {} {} L {} {} L {} {}\" fill=\"none\" stroke=\"#8999a9\" stroke-width=\"1.7\"/>",a.x,a.y,tip.x,tip.y,b.x,b.y));
|
|
}
|
|
let label = &end.label;
|
|
svg.push_str(&format!("<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"white\"/><text x=\"{}\" y=\"{}\" text-anchor=\"middle\" dominant-baseline=\"central\" font-family=\"sans-serif\" font-size=\"13\" fill=\"#637487\">{}</text></g>",label.bounds.position.x,label.bounds.position.y,label.bounds.size.width,label.bounds.size.height,label.center.x,label.center.y,escape(&label.text)));
|
|
}
|
|
}
|
|
}
|
|
for n in &view.nodes {
|
|
if !containers.contains(&n.id) {
|
|
svg.push_str(&node_svg(n));
|
|
} else {
|
|
let mut header = n.clone();
|
|
header.size.height = 62.;
|
|
svg.push_str(&node_svg(&header));
|
|
}
|
|
}
|
|
for port in &view.ports {
|
|
let glyph = port_glyph(&p.elements[&port.element]);
|
|
let (x, y) = (port.position.x, port.position.y);
|
|
svg.push_str(&format!(
|
|
"<g><title>{}</title>",
|
|
escape(p.named(port.element))
|
|
));
|
|
if glyph == PortGlyph::Function {
|
|
let [a, b, c] = port_triangle(&p.elements[&port.element], port);
|
|
svg.push_str(&format!("<path data-port-kind=\"function\" d=\"M {} {} L {} {} L {} {} Z\" fill=\"white\" stroke=\"#147d7b\"/>", a.x, a.y, b.x, b.y, c.x, c.y));
|
|
} else {
|
|
let dash = if glyph == PortGlyph::Legacy {
|
|
" stroke-dasharray=\"3 2\""
|
|
} else {
|
|
""
|
|
};
|
|
svg.push_str(&format!("<rect x=\"{}\" y=\"{}\" width=\"14\" height=\"14\" fill=\"white\" stroke=\"#147d7b\"{dash}/>",x-7.,y-7.));
|
|
if matches!(glyph, PortGlyph::Flow(_)) {
|
|
for [a, b] in flow_mark(&p.elements[&port.element], port) {
|
|
svg.push_str(&format!(
|
|
"<path d=\"M {} {} L {} {}\" fill=\"none\" stroke=\"#147d7b\"/>",
|
|
a.x, a.y, b.x, b.y
|
|
));
|
|
}
|
|
} else if matches!(glyph, PortGlyph::Unspecified | PortGlyph::Legacy) {
|
|
svg.push_str(&format!(
|
|
"<circle cx=\"{x}\" cy=\"{y}\" r=\"1.5\" fill=\"#147d7b\"/>"
|
|
));
|
|
}
|
|
}
|
|
svg.push_str("</g>");
|
|
}
|
|
svg.push_str("</svg>");
|
|
Ok(svg)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "diagram/pointer_tests.rs"]
|
|
mod pointer_tests;
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn svg_rejects_saved_scenario_with_absent_or_authored_layout_without_mutation() {
|
|
let f = crate::behavior::scenario::direct_tests::fixture();
|
|
let mut p = f.project;
|
|
let diagram = p.ensure_diagram(Layer::Oa);
|
|
let d = p.diagrams.get_mut(&diagram).unwrap();
|
|
d.projection = ProjectionKind::Scenario { scenario: f.owner };
|
|
d.nodes.clear();
|
|
d.routes.clear();
|
|
for layout in [
|
|
None,
|
|
Some(DiagramLayout::Scenario(ScenarioLayout {
|
|
lanes: vec![ScenarioLanePosition {
|
|
lifeline: f.sender,
|
|
x: 240.,
|
|
}],
|
|
messages: vec![ScenarioMessagePosition {
|
|
address: OccurrenceEndpoint {
|
|
references: vec![],
|
|
occurrence: f.call,
|
|
},
|
|
send_y: 180.,
|
|
}],
|
|
})),
|
|
] {
|
|
p.diagrams.get_mut(&diagram).unwrap().layout = layout;
|
|
let admitted = execute_batch(&p, &[]).expect("valid saved Scenario fixture");
|
|
assert_eq!(admitted, p);
|
|
let before = p.clone();
|
|
assert_eq!(
|
|
export_svg(&p, diagram),
|
|
Err(ExportError::UnsupportedScenario(diagram))
|
|
);
|
|
assert_eq!(p, before);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn svg_rejects_missing_diagram_without_mutation() {
|
|
let p = Project::demo();
|
|
let missing = Id::new_v4();
|
|
let before = p.clone();
|
|
assert_eq!(
|
|
export_svg(&p, missing),
|
|
Err(ExportError::MissingDiagram(missing))
|
|
);
|
|
assert_eq!(p, before);
|
|
}
|
|
|
|
#[test]
|
|
fn svg_accepts_populated_and_empty_architecture_views() {
|
|
let mut p = Project::demo();
|
|
let diagram = p
|
|
.diagrams
|
|
.values()
|
|
.find(|d| d.projection.is_architecture() && !d.nodes.is_empty())
|
|
.unwrap()
|
|
.id;
|
|
let before = p.clone();
|
|
let svg = export_svg(&p, diagram).expect("populated architecture");
|
|
assert!(svg.starts_with("<svg "));
|
|
assert!(svg.contains("<text "));
|
|
assert!(svg.ends_with("</svg>"));
|
|
assert_eq!(p, before);
|
|
let d = p.diagrams.get_mut(&diagram).unwrap();
|
|
d.projection = ProjectionKind::Overview;
|
|
d.nodes.clear();
|
|
d.routes.clear();
|
|
assert_eq!(execute_batch(&p, &[]).expect("valid empty architecture"), p);
|
|
let before = p.clone();
|
|
let svg = export_svg(&p, diagram).expect("empty architecture is supported");
|
|
assert!(svg.contains("viewBox=\"-30 -30 60 60\""));
|
|
assert!(svg.ends_with("</svg>"));
|
|
assert_eq!(p, before);
|
|
}
|
|
|
|
#[test]
|
|
fn typed_function_glyphs_and_unoriented_standard_connections_have_distinct_svg() {
|
|
let p = Project::demo();
|
|
let boundary = p
|
|
.relations
|
|
.values()
|
|
.find(|r| r.kind() == RelationKind::BoundaryExchange)
|
|
.unwrap();
|
|
let mut commands = vec![];
|
|
for id in [boundary.source(), boundary.target()] {
|
|
let mut port = p.elements[&id].clone();
|
|
let Some(SharedBody::Port { contract, .. }) = port.shared_mut() else {
|
|
panic!("component port");
|
|
};
|
|
*contract = PortContract::Component {
|
|
kind: ComponentPortKind::Standard,
|
|
orientation: ComponentPortOrientation::Unset,
|
|
};
|
|
commands.push(Command::EditElement(id, port));
|
|
}
|
|
let mut connection = boundary.clone();
|
|
let RelationBody::BoundaryExchange { oriented, .. } = &mut connection.body else {
|
|
unreachable!()
|
|
};
|
|
*oriented = false;
|
|
commands.push(Command::EditRelation(boundary.id, connection));
|
|
let p = execute_batch(&p, &commands).unwrap();
|
|
let diagram = p
|
|
.diagrams
|
|
.values()
|
|
.find(|d| d.layer == Layer::Sa && d.projection == ProjectionKind::Allocation)
|
|
.unwrap()
|
|
.id;
|
|
let svg = export_svg(&p, diagram).expect("supported diagram");
|
|
let title = format!("<title>{}</title>", escape(&boundary.name));
|
|
let edge_path = svg
|
|
.split(&title)
|
|
.next()
|
|
.unwrap()
|
|
.rsplit("<path ")
|
|
.next()
|
|
.unwrap();
|
|
assert!(
|
|
!edge_path.contains("marker-end"),
|
|
"Unoriented Standard connection has no arrow"
|
|
);
|
|
let view = project_view(&p, diagram);
|
|
for port in &view.ports {
|
|
let title = format!("<g><title>{}</title>", escape(p.named(port.element)));
|
|
let body = svg
|
|
.split(&title)
|
|
.nth(1)
|
|
.unwrap()
|
|
.split("</g>")
|
|
.next()
|
|
.unwrap();
|
|
match port_contract(&p.elements[&port.element]) {
|
|
Some(PortContract::FunctionInput { .. } | PortContract::FunctionOutput { .. }) => {
|
|
assert!(body.contains("data-port-kind=\"function\""))
|
|
}
|
|
Some(PortContract::Component {
|
|
kind: ComponentPortKind::Standard,
|
|
..
|
|
}) => {
|
|
assert!(body.contains("<rect "));
|
|
assert!(
|
|
!body.contains("<path "),
|
|
"Standard port has no inferred Flow arrow"
|
|
);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
#[test]
|
|
fn svg_distinguishes_inferred_work_allocation_from_direct_allocation() {
|
|
for (layer, performer_kind, work_kind, header) in [
|
|
(
|
|
Layer::Oa,
|
|
Kind::Entity,
|
|
Kind::Activity,
|
|
"ACTIVITY · SUMMARY",
|
|
),
|
|
(
|
|
Layer::Sa,
|
|
Kind::System,
|
|
Kind::Function,
|
|
"FUNCTION · SUMMARY",
|
|
),
|
|
] {
|
|
let mut p = Project::empty();
|
|
let owner = p
|
|
.add_element(performer_kind, layer, "Performer", None)
|
|
.unwrap();
|
|
let parent = p
|
|
.add_element(work_kind, layer, "Grouped work", None)
|
|
.unwrap();
|
|
let child = p
|
|
.add_element(work_kind, layer, "Allocated leaf", Some(parent))
|
|
.unwrap();
|
|
p.relate(RelationKind::Allocation, "Performs", owner, child);
|
|
let diagram = p.ensure_diagram(layer);
|
|
p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Allocation;
|
|
for id in [owner, parent, child] {
|
|
p.place(diagram, id, Position { x: 60., y: 60. });
|
|
}
|
|
let p = execute_batch(&p, &[Command::Layout(diagram)]).unwrap();
|
|
let svg = export_svg(&p, diagram).expect("supported diagram");
|
|
let group = svg
|
|
.split("<g>")
|
|
.find(|g| g.contains(">Grouped work</text>"))
|
|
.unwrap();
|
|
assert!(group.contains(&format!(">{header}</text>")));
|
|
assert!(group.contains("stroke-dasharray=\"6 4\""));
|
|
assert!(group.contains("font-style=\"italic\""));
|
|
let leaf = svg
|
|
.split("<g>")
|
|
.find(|g| g.contains(">Allocated leaf</text>"))
|
|
.unwrap();
|
|
assert!(!leaf.contains("SUMMARY"));
|
|
assert!(!leaf.contains("stroke-dasharray"));
|
|
assert!(!leaf.contains("font-style=\"italic\""));
|
|
assert!(
|
|
!p.relations
|
|
.values()
|
|
.any(|r| r.kind() == RelationKind::Allocation && r.target() == parent)
|
|
);
|
|
}
|
|
}
|
|
#[test]
|
|
fn dragging_a_child_previews_the_same_enclosure_and_ports_as_commit() {
|
|
let p = Project::demo();
|
|
let diagram = p
|
|
.diagrams
|
|
.values()
|
|
.find(|d| d.layer == Layer::Sa && d.projection == ProjectionKind::Allocation)
|
|
.unwrap()
|
|
.id;
|
|
let view = project_view(&p, diagram);
|
|
let child = view
|
|
.nodes
|
|
.iter()
|
|
.find(|n| n.visual_parent.is_some())
|
|
.unwrap()
|
|
.id;
|
|
let original = p.diagrams[&diagram].nodes[&child].position;
|
|
let target = Position {
|
|
x: original.x - 400.,
|
|
y: original.y - 200.,
|
|
};
|
|
let canvas = DiagramCanvas {
|
|
selection: None,
|
|
project: &p,
|
|
view_index: None,
|
|
context: Context {
|
|
document: p.id,
|
|
diagram,
|
|
revision: 0,
|
|
epoch: 0,
|
|
},
|
|
selected_bend: None,
|
|
selected_attachment: None,
|
|
editable: true,
|
|
placing: false,
|
|
connection: None,
|
|
allocation: None,
|
|
diagram,
|
|
selected: p.id,
|
|
navigation_focus: None,
|
|
zoom: 1.,
|
|
pan: Vector::ZERO,
|
|
};
|
|
let state = State {
|
|
drag: Some((child, Point::ORIGIN, original)),
|
|
preview: Some(target),
|
|
..State::default()
|
|
};
|
|
let preview = canvas.preview_view(&state);
|
|
let committed = execute_batch(
|
|
&p,
|
|
&[Command::MoveNode {
|
|
diagram,
|
|
node: child,
|
|
position: target,
|
|
with_descendants: true,
|
|
}],
|
|
)
|
|
.unwrap();
|
|
assert_eq!(preview, project_view(&committed, diagram));
|
|
}
|
|
#[test]
|
|
fn svg_preserves_visible_exchange_labels_and_capability_stereotypes() {
|
|
let mut p = Project::demo();
|
|
let diagram = p
|
|
.diagrams
|
|
.values()
|
|
.find(|d| d.layer == Layer::Sa && d.projection == ProjectionKind::Allocation)
|
|
.unwrap()
|
|
.id;
|
|
let view = project_view(&p, diagram);
|
|
let svg = export_svg(&p, diagram).expect("supported diagram");
|
|
for edge in &view.edges {
|
|
assert!(svg.contains(&format!(">{}</text>", escape(&short(p.named(edge.id), 32)))));
|
|
}
|
|
let a = p
|
|
.add_element(Kind::Capability, Layer::Oa, "Primary", None)
|
|
.unwrap();
|
|
let b = p
|
|
.add_element(Kind::Capability, Layer::Oa, "Included", None)
|
|
.unwrap();
|
|
let d = p.ensure_diagram(Layer::Oa);
|
|
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::Capabilities;
|
|
p.place(d, a, Position { x: 0., y: 0. });
|
|
p.place(d, b, Position { x: 400., y: 0. });
|
|
p.relate(RelationKind::CapabilityInclude, "Uses", a, b);
|
|
let svg = export_svg(&p, d).expect("supported diagram");
|
|
assert!(svg.contains("«include»</text>"));
|
|
assert!(svg.contains("stroke-dasharray=\"6 4\""));
|
|
}
|
|
#[test]
|
|
fn implicit_port_click_selects_semantics_and_drag_preview_svg_share_anchors() {
|
|
let p = Project::demo();
|
|
let diagram = p
|
|
.diagrams
|
|
.values()
|
|
.find(|d| d.layer == Layer::Sa && d.projection == ProjectionKind::Allocation)
|
|
.unwrap()
|
|
.id;
|
|
let canvas = DiagramCanvas {
|
|
selection: None,
|
|
project: &p,
|
|
view_index: None,
|
|
context: Context {
|
|
document: p.id,
|
|
diagram,
|
|
revision: 0,
|
|
epoch: 0,
|
|
},
|
|
selected_bend: None,
|
|
selected_attachment: None,
|
|
editable: true,
|
|
placing: false,
|
|
connection: None,
|
|
allocation: None,
|
|
diagram,
|
|
selected: p.id,
|
|
navigation_focus: None,
|
|
zoom: 0.6,
|
|
pan: Vector::new(10., 20.),
|
|
};
|
|
let view = project_view(&p, diagram);
|
|
let port = &view.ports[0];
|
|
let click = Point::new(port.position.x * 0.6 + 10., port.position.y * 0.6 + 20.);
|
|
let mut state = State::default();
|
|
let selected = canvas::Program::update(
|
|
&canvas,
|
|
&mut state,
|
|
&canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
|
Rectangle::new(Point::ORIGIN, Size::new(1600., 1000.)),
|
|
mouse::Cursor::Available(click),
|
|
)
|
|
.unwrap()
|
|
.into_inner()
|
|
.0;
|
|
assert!(matches!(selected,Some(Message::Select(id)) if id==port.element));
|
|
let request = canvas.context_request(
|
|
&state,
|
|
click,
|
|
Rectangle::new(Point::new(260., 180.), Size::new(1600., 1000.)),
|
|
);
|
|
assert_eq!(
|
|
request.object, port.element,
|
|
"Right-click keeps the port target where it overlaps its owner's boundary"
|
|
);
|
|
assert_eq!(request.placement, None);
|
|
assert_eq!(request.anchor, click + Vector::new(260., 180.));
|
|
let selected_port_canvas = DiagramCanvas {
|
|
selected: port.element,
|
|
..canvas
|
|
};
|
|
assert_eq!(
|
|
selected_port_canvas
|
|
.selection_context_request(&state, Rectangle::with_size(Size::new(1600., 1000.)))
|
|
.object,
|
|
port.element,
|
|
"Keyboard context uses the selected port identity"
|
|
);
|
|
assert!(
|
|
state.drag.is_none(),
|
|
"Port selection must not drag its parent"
|
|
);
|
|
let original = p.diagrams[&diagram].nodes[&port.owner].position;
|
|
state.drag = Some((port.owner, Point::ORIGIN, original));
|
|
state.preview = Some(Position {
|
|
x: original.x + 100.,
|
|
y: original.y + 70.,
|
|
});
|
|
let preview = canvas.preview_view(&state);
|
|
let moved = preview
|
|
.ports
|
|
.iter()
|
|
.find(|p| p.element == port.element)
|
|
.unwrap();
|
|
assert_eq!(moved.position.x, port.position.x + 100.);
|
|
assert_eq!(moved.position.y, port.position.y + 70.);
|
|
let svg = export_svg(&p, diagram).expect("supported diagram");
|
|
for port in &view.ports {
|
|
assert!(svg.contains(&format!("<title>{}</title>", escape(p.named(port.element)))));
|
|
}
|
|
for edge in &view.edges {
|
|
let routed = route_diagram(&p, &p.diagrams[&diagram], &view);
|
|
let points = &routed.edges[&edge.id].points;
|
|
let start = points.first().unwrap();
|
|
let end = points.last().unwrap();
|
|
assert!(svg.contains(&format!("M {} {} L", start.x, start.y)));
|
|
assert!(svg.contains(&format!("L {} {}", end.x, end.y)));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn connection_gestures_pick_real_ports_and_commit_coalesced_drag_at_every_zoom() {
|
|
use crate::{
|
|
connection_edit::{Event, Session},
|
|
connection_tool::Tool,
|
|
};
|
|
use iced::widget::canvas::Program;
|
|
let p = Project::demo();
|
|
let diagram = p
|
|
.diagrams
|
|
.values()
|
|
.find(|d| d.layer == Layer::Sa && d.projection == ProjectionKind::Allocation)
|
|
.unwrap()
|
|
.id;
|
|
let view = project_view(&p, diagram);
|
|
let source = view
|
|
.ports
|
|
.iter()
|
|
.find(|port| {
|
|
matches!(
|
|
port_contract(&p.elements[&port.element]),
|
|
Some(PortContract::FunctionOutput { .. })
|
|
)
|
|
})
|
|
.unwrap();
|
|
let target = view
|
|
.ports
|
|
.iter()
|
|
.find(|port| Tool::FunctionalExchange.can_connect(&p, source.element, port.element))
|
|
.unwrap();
|
|
for zoom in [0.4, 1., 2.] {
|
|
let context = Context {
|
|
document: p.id,
|
|
diagram,
|
|
revision: 0,
|
|
epoch: 0,
|
|
};
|
|
let session = Session::new(&p, &view, context, Tool::FunctionalExchange);
|
|
let canvas = DiagramCanvas {
|
|
selection: None,
|
|
project: &p,
|
|
view_index: None,
|
|
diagram,
|
|
context,
|
|
selected: p.id,
|
|
navigation_focus: None,
|
|
selected_bend: None,
|
|
selected_attachment: None,
|
|
zoom,
|
|
pan: Vector::new(20., 30.),
|
|
editable: true,
|
|
placing: false,
|
|
connection: Some(&session),
|
|
allocation: None,
|
|
};
|
|
let bounds = Rectangle::new(Point::new(260., 300.), Size::new(2400., 1600.));
|
|
let at = |port: &oa_core::projection::ProjectedPort| {
|
|
mouse::Cursor::Available(Point::new(
|
|
bounds.x + port.position.x * zoom + 20.,
|
|
bounds.y + port.position.y * zoom + 30.,
|
|
))
|
|
};
|
|
let mut state = State::default();
|
|
let _ = canvas.update(
|
|
&mut state,
|
|
&canvas::Event::Mouse(mouse::Event::CursorMoved {
|
|
position: at(source).position().unwrap(),
|
|
}),
|
|
bounds,
|
|
at(target),
|
|
);
|
|
let press = canvas
|
|
.update(
|
|
&mut state,
|
|
&canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
|
bounds,
|
|
at(target),
|
|
)
|
|
.unwrap()
|
|
.into_inner()
|
|
.0;
|
|
assert!(
|
|
matches!(press, Some(Message::Connection(Event::Endpoint(c,id))) if c == context && id == source.element)
|
|
);
|
|
assert!(state.drag.is_none() && state.route_drag.is_none());
|
|
let _ = canvas.update(
|
|
&mut state,
|
|
&canvas::Event::Mouse(mouse::Event::CursorMoved {
|
|
position: at(target).position().unwrap(),
|
|
}),
|
|
bounds,
|
|
at(target),
|
|
);
|
|
// Native backends may deliver the whole drag before the app can
|
|
// rebuild with the source-selection message. Both endpoint messages
|
|
// still enter the queue in order and must complete one connection.
|
|
let batched_release = canvas
|
|
.update(
|
|
&mut state,
|
|
&canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
|
bounds,
|
|
at(target),
|
|
)
|
|
.unwrap()
|
|
.into_inner()
|
|
.0;
|
|
assert!(
|
|
matches!(batched_release, Some(Message::Connection(Event::Endpoint(c,id))) if c == context && id == target.element)
|
|
);
|
|
let _ = canvas.update(
|
|
&mut state,
|
|
&canvas::Event::Mouse(mouse::Event::CursorMoved {
|
|
position: at(source).position().unwrap(),
|
|
}),
|
|
bounds,
|
|
at(source),
|
|
);
|
|
let _ = canvas.update(
|
|
&mut state,
|
|
&canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
|
bounds,
|
|
at(source),
|
|
);
|
|
let mut armed = session.clone();
|
|
armed.start(&p, &view, source.element);
|
|
let armed_canvas = DiagramCanvas {
|
|
connection: Some(&armed),
|
|
allocation: None,
|
|
..canvas
|
|
};
|
|
// Winit updates cursor coordinates through CursorMoved, not the
|
|
// following MouseInput. Include that ordered final motion when the
|
|
// application rebuilds its session between press and release.
|
|
let _ = armed_canvas.update(
|
|
&mut state,
|
|
&canvas::Event::Mouse(mouse::Event::CursorMoved {
|
|
position: at(target).position().unwrap(),
|
|
}),
|
|
bounds,
|
|
at(target),
|
|
);
|
|
let release = armed_canvas
|
|
.update(
|
|
&mut state,
|
|
&canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
|
bounds,
|
|
at(target),
|
|
)
|
|
.unwrap()
|
|
.into_inner()
|
|
.0;
|
|
assert!(
|
|
matches!(release, Some(Message::Connection(Event::Endpoint(c,id))) if c == context && id == target.element)
|
|
);
|
|
// A tool cancellation or navigation invalidates a queued release.
|
|
let cancelled = DiagramCanvas {
|
|
connection: None,
|
|
allocation: None,
|
|
context: Context {
|
|
epoch: 1,
|
|
..context
|
|
},
|
|
..armed_canvas
|
|
};
|
|
let release = cancelled.update(
|
|
&mut state,
|
|
&canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
|
bounds,
|
|
at(target),
|
|
);
|
|
assert!(release.is_none());
|
|
}
|
|
}
|
|
fn route_fixture() -> (Project, Id, Id) {
|
|
let mut p = Project::empty();
|
|
let a = p.add(Kind::Activity, "Send", 0., 0.);
|
|
let b = p.add(Kind::Activity, "Receive", 650., 0.);
|
|
let edge = p.relate(RelationKind::Interaction, "Report", a, b);
|
|
let diagram = p.ensure_diagram(Layer::Oa);
|
|
let p = execute_batch(
|
|
&p,
|
|
&[Command::SetEdgeRoute {
|
|
diagram,
|
|
route: EdgeRoute {
|
|
edge,
|
|
source_attachment: None,
|
|
target_attachment: None,
|
|
waypoints: vec![Position { x: 400., y: -100. }],
|
|
label: None,
|
|
},
|
|
}],
|
|
)
|
|
.unwrap();
|
|
(p, diagram, edge)
|
|
}
|
|
fn pointer(
|
|
canvas: &DiagramCanvas<'_>,
|
|
state: &mut State,
|
|
event: mouse::Event,
|
|
cursor: Point,
|
|
) -> Option<Message> {
|
|
canvas::Program::update(
|
|
canvas,
|
|
state,
|
|
&canvas::Event::Mouse(event),
|
|
Rectangle::new(Point::ORIGIN, Size::new(1800., 1200.)),
|
|
mouse::Cursor::Available(cursor),
|
|
)
|
|
.and_then(|a| a.into_inner().0)
|
|
}
|
|
#[test]
|
|
fn bend_gestures_use_screen_hit_radius_and_commit_one_undo_at_three_zooms() {
|
|
for zoom in [0.5, 1., 1.5] {
|
|
let (p, diagram, edge) = route_fixture();
|
|
let canvas = DiagramCanvas {
|
|
selection: None,
|
|
project: &p,
|
|
view_index: None,
|
|
diagram,
|
|
selected: edge,
|
|
navigation_focus: None,
|
|
zoom,
|
|
pan: Vector::new(150., 300.),
|
|
context: Context {
|
|
document: p.id,
|
|
diagram,
|
|
revision: 0,
|
|
epoch: 0,
|
|
},
|
|
selected_bend: None,
|
|
selected_attachment: None,
|
|
editable: true,
|
|
placing: false,
|
|
connection: None,
|
|
allocation: None,
|
|
};
|
|
let mut state = State::default();
|
|
let pin = Point::new(400. * zoom + 150., -100. * zoom + 300.);
|
|
let request =
|
|
canvas.context_request(&state, pin, Rectangle::with_size(Size::new(1800., 1200.)));
|
|
assert_eq!((request.object, request.bend), (edge, Some(0)));
|
|
let selected_bend_canvas = DiagramCanvas {
|
|
selected_bend: Some(0),
|
|
..canvas
|
|
};
|
|
let request = selected_bend_canvas
|
|
.selection_context_request(&state, Rectangle::with_size(Size::new(1800., 1200.)));
|
|
assert_eq!(
|
|
(request.object, request.bend),
|
|
(edge, Some(0)),
|
|
"Keyboard context preserves the selected bend"
|
|
);
|
|
let selected = pointer(
|
|
&canvas,
|
|
&mut state,
|
|
mouse::Event::ButtonPressed(mouse::Button::Left),
|
|
pin + Vector::new(6., 0.),
|
|
);
|
|
assert!(
|
|
matches!(selected,Some(Message::Route(RouteEvent::SelectBend(id,0))) if id==edge)
|
|
);
|
|
let target = pin + Vector::new(50. * zoom, 40. * zoom);
|
|
pointer(
|
|
&canvas,
|
|
&mut state,
|
|
mouse::Event::CursorMoved { position: target },
|
|
target,
|
|
);
|
|
assert_eq!(
|
|
p.diagrams[&diagram].routes[0].waypoints[0],
|
|
Position { x: 400., y: -100. }
|
|
);
|
|
let Some(Message::Route(RouteEvent::Commit(context, route, Some(0)))) = pointer(
|
|
&canvas,
|
|
&mut state,
|
|
mouse::Event::ButtonReleased(mouse::Button::Left),
|
|
target,
|
|
) else {
|
|
panic!("Drag must commit route");
|
|
};
|
|
assert_eq!(context, canvas.context);
|
|
assert_eq!(route.waypoints[0], Position { x: 450., y: -60. });
|
|
let mut session = Session::new(p.clone());
|
|
session
|
|
.execute(Command::SetEdgeRoute { diagram, route })
|
|
.unwrap();
|
|
session.undo();
|
|
assert_eq!(session.project(), &p);
|
|
}
|
|
}
|
|
#[test]
|
|
fn fast_drags_commit_the_release_cursor_without_a_final_motion_event() {
|
|
let (p, diagram, edge) = route_fixture();
|
|
let canvas = DiagramCanvas {
|
|
selection: None,
|
|
project: &p,
|
|
view_index: None,
|
|
diagram,
|
|
selected: edge,
|
|
navigation_focus: None,
|
|
zoom: 0.5,
|
|
pan: Vector::new(150., 300.),
|
|
context: Context {
|
|
document: p.id,
|
|
diagram,
|
|
revision: 0,
|
|
epoch: 0,
|
|
},
|
|
selected_bend: None,
|
|
selected_attachment: None,
|
|
editable: true,
|
|
placing: false,
|
|
connection: None,
|
|
allocation: None,
|
|
};
|
|
let mut state = State::default();
|
|
let pin = Point::new(350., 250.);
|
|
pointer(
|
|
&canvas,
|
|
&mut state,
|
|
mouse::Event::ButtonPressed(mouse::Button::Left),
|
|
pin,
|
|
);
|
|
let target = pin + Vector::new(30., 20.);
|
|
let Some(Message::Route(RouteEvent::Commit(_, route, Some(0)))) = pointer(
|
|
&canvas,
|
|
&mut state,
|
|
mouse::Event::ButtonReleased(mouse::Button::Left),
|
|
target,
|
|
) else {
|
|
panic!("Release must retain the final bend position");
|
|
};
|
|
assert_eq!(route.waypoints[0], Position { x: 460., y: -60. });
|
|
|
|
let scene = canvas.scene(&State::default());
|
|
let center = scene.routed.edges[&edge].label.center;
|
|
let label = Point::new(center.x * canvas.zoom, center.y * canvas.zoom) + canvas.pan;
|
|
state = State::default();
|
|
pointer(
|
|
&canvas,
|
|
&mut state,
|
|
mouse::Event::ButtonPressed(mouse::Button::Left),
|
|
label,
|
|
);
|
|
let Some(Message::Route(RouteEvent::Commit(_, route, None))) = pointer(
|
|
&canvas,
|
|
&mut state,
|
|
mouse::Event::ButtonReleased(mouse::Button::Left),
|
|
label + Vector::new(15., 10.),
|
|
) else {
|
|
panic!("Release must retain the final label position");
|
|
};
|
|
let geometry = &scene.routed.edges[&edge].label;
|
|
assert_eq!(
|
|
route.label.unwrap().offset,
|
|
Position {
|
|
x: geometry.center.x - geometry.anchor.x + 30.,
|
|
y: geometry.center.y - geometry.anchor.y + 20.,
|
|
}
|
|
);
|
|
|
|
state = State::default();
|
|
let node = &scene.view.nodes[0];
|
|
let start = Point::new(
|
|
(node.position.x + 40.) * canvas.zoom,
|
|
(node.position.y + 30.) * canvas.zoom,
|
|
) + canvas.pan;
|
|
pointer(
|
|
&canvas,
|
|
&mut state,
|
|
mouse::Event::ButtonPressed(mouse::Button::Left),
|
|
start,
|
|
);
|
|
let Some(Message::MoveNode(id, position)) = pointer(
|
|
&canvas,
|
|
&mut state,
|
|
mouse::Event::ButtonReleased(mouse::Button::Left),
|
|
start + Vector::new(30., 20.),
|
|
) else {
|
|
panic!("Release must retain the final node position");
|
|
};
|
|
assert_eq!(id, node.id);
|
|
assert_eq!(
|
|
position,
|
|
Position {
|
|
x: node.position.x + 60.,
|
|
y: node.position.y + 40.
|
|
}
|
|
);
|
|
}
|
|
#[test]
|
|
fn route_cache_ignores_pan_zoom_and_cancelled_drag_cannot_commit() {
|
|
let (p, diagram, edge) = route_fixture();
|
|
let mut canvas = DiagramCanvas {
|
|
selection: None,
|
|
project: &p,
|
|
view_index: None,
|
|
diagram,
|
|
selected: edge,
|
|
navigation_focus: None,
|
|
zoom: 1.,
|
|
pan: Vector::new(150., 300.),
|
|
context: Context {
|
|
document: p.id,
|
|
diagram,
|
|
revision: 0,
|
|
epoch: 0,
|
|
},
|
|
selected_bend: None,
|
|
selected_attachment: None,
|
|
editable: true,
|
|
placing: false,
|
|
connection: None,
|
|
allocation: None,
|
|
};
|
|
let mut state = State::default();
|
|
let first = canvas.scene(&state);
|
|
canvas.zoom = 0.5;
|
|
canvas.pan = Vector::new(20., 300.);
|
|
assert!(
|
|
Arc::ptr_eq(&first, &canvas.scene(&state)),
|
|
"Pan and zoom reuse world routing"
|
|
);
|
|
let pin = Point::new(220., 250.);
|
|
pointer(
|
|
&canvas,
|
|
&mut state,
|
|
mouse::Event::ButtonPressed(mouse::Button::Left),
|
|
pin,
|
|
);
|
|
pointer(
|
|
&canvas,
|
|
&mut state,
|
|
mouse::Event::CursorMoved {
|
|
position: pin + Vector::new(40., 30.),
|
|
},
|
|
pin + Vector::new(40., 30.),
|
|
);
|
|
canvas.context.epoch += 1;
|
|
assert!(
|
|
pointer(
|
|
&canvas,
|
|
&mut state,
|
|
mouse::Event::ButtonReleased(mouse::Button::Left),
|
|
pin
|
|
)
|
|
.is_none()
|
|
);
|
|
assert!(state.route_drag.is_none());
|
|
}
|
|
#[test]
|
|
fn routed_svg_has_same_polyline_label_and_bounds_as_canvas_scene() {
|
|
let (mut p, diagram, edge) = route_fixture();
|
|
p.diagrams.get_mut(&diagram).unwrap().routes[0].label = Some(EdgeLabel {
|
|
fraction: 0.3,
|
|
offset: Position { x: -500., y: -400. },
|
|
});
|
|
let view = project_view(&p, diagram);
|
|
let geometry = route_diagram(&p, &p.diagrams[&diagram], &view);
|
|
let svg = export_svg(&p, diagram).expect("supported diagram");
|
|
let route = &geometry.edges[&edge];
|
|
let path = route
|
|
.points
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, p)| format!("{} {} {}", if i == 0 { "M" } else { "L" }, p.x, p.y))
|
|
.collect::<Vec<_>>()
|
|
.join(" ");
|
|
assert!(svg.contains(&format!("d=\"{path}\"")));
|
|
assert!(svg.contains(&format!(
|
|
"x=\"{}\" y=\"{}\" text-anchor",
|
|
route.label.center.x, route.label.center.y
|
|
)));
|
|
assert!(geometry.bounds.position.x <= route.label.bounds.position.x);
|
|
assert!(geometry.bounds.position.y <= route.label.bounds.position.y);
|
|
}
|
|
#[test]
|
|
fn association_end_contracts_share_svg_geometry_and_select_without_moving_name_label() {
|
|
for self_loop in [false, true] {
|
|
let mut p = Project::empty();
|
|
let a = p.add(Kind::DataType, "Tree", 100., 100.);
|
|
let b = if self_loop {
|
|
a
|
|
} else {
|
|
p.add(Kind::DataType, "Branch", 650., 100.)
|
|
};
|
|
for id in BTreeSet::from([a, b]) {
|
|
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataType {
|
|
definition: DataTypeDefinition::Record { supertype: None },
|
|
default: None,
|
|
unit: String::new(),
|
|
};
|
|
}
|
|
let edge = p.relate(RelationKind::DataAssociation, "Hierarchy", a, b);
|
|
p.relations.get_mut(&edge).unwrap().body = RelationBody::DataAssociation {
|
|
source: a,
|
|
target: b,
|
|
source_role: "parent".into(),
|
|
target_role: "children".into(),
|
|
source_minimum: 0,
|
|
source_maximum: Some(1),
|
|
target_minimum: 0,
|
|
target_maximum: None,
|
|
source_navigable: false,
|
|
target_navigable: true,
|
|
};
|
|
let diagram = p.ensure_diagram(Layer::Oa);
|
|
let projected = project_view(&p, diagram);
|
|
assert!(
|
|
!projected
|
|
.edges
|
|
.iter()
|
|
.find(|e| e.id == edge)
|
|
.unwrap()
|
|
.oriented
|
|
);
|
|
let routed = route_diagram(&p, &p.diagrams[&diagram], &projected);
|
|
let ends = routing::association_end_geometry(&p.relations[&edge], &routed.edges[&edge]);
|
|
assert_eq!(ends.len(), 2);
|
|
let svg = export_svg(&p, diagram).expect("supported diagram");
|
|
assert!(svg.contains("parent [0..1]</text>"));
|
|
assert!(svg.contains("children [0..*]</text>"));
|
|
assert!(!svg.contains("data-navigation=\"source\""));
|
|
assert!(svg.contains("data-navigation=\"target\""));
|
|
let mut canvas = DiagramCanvas {
|
|
selection: None,
|
|
project: &p,
|
|
view_index: None,
|
|
diagram,
|
|
selected: edge,
|
|
navigation_focus: None,
|
|
zoom: 0.7,
|
|
pan: Vector::new(350., 350.),
|
|
context: Context {
|
|
document: p.id,
|
|
diagram,
|
|
revision: 0,
|
|
epoch: 0,
|
|
},
|
|
selected_bend: None,
|
|
selected_attachment: None,
|
|
editable: true,
|
|
placing: false,
|
|
connection: None,
|
|
allocation: None,
|
|
};
|
|
for end in ends {
|
|
assert!(svg.contains(&format!(
|
|
"x=\"{}\" y=\"{}\" text-anchor",
|
|
end.label.center.x, end.label.center.y
|
|
)));
|
|
let at = Point::new(
|
|
end.label.center.x * canvas.zoom + canvas.pan.x,
|
|
end.label.center.y * canvas.zoom + canvas.pan.y,
|
|
);
|
|
let mut state = State::default();
|
|
assert_eq!(canvas.hit_association_label(&routed, at), Some(edge));
|
|
assert!(
|
|
matches!(pointer(&canvas,&mut state,mouse::Event::ButtonPressed(mouse::Button::Left),at),Some(Message::Select(id)) if id==edge)
|
|
);
|
|
assert!(
|
|
state.route_drag.is_none(),
|
|
"Endpoint contract labels never drag the association name"
|
|
);
|
|
assert_eq!(
|
|
canvas
|
|
.context_request(&state, at, Rectangle::with_size(Size::new(1800., 1200.)))
|
|
.object,
|
|
edge
|
|
);
|
|
canvas.selected = p.id;
|
|
}
|
|
}
|
|
}
|
|
#[test]
|
|
fn detached_label_selects_connection_and_occluding_card_owns_its_hit() {
|
|
let (mut p, diagram, edge) = route_fixture();
|
|
p.diagrams.get_mut(&diagram).unwrap().routes[0].label = Some(EdgeLabel {
|
|
fraction: 0.5,
|
|
offset: Position { x: 0., y: -160. },
|
|
});
|
|
let mut canvas = DiagramCanvas {
|
|
selection: None,
|
|
project: &p,
|
|
view_index: None,
|
|
diagram,
|
|
selected: p.id,
|
|
navigation_focus: None,
|
|
zoom: 1.,
|
|
pan: Vector::new(100., 400.),
|
|
context: Context {
|
|
document: p.id,
|
|
diagram,
|
|
revision: 0,
|
|
epoch: 0,
|
|
},
|
|
selected_bend: None,
|
|
selected_attachment: None,
|
|
editable: true,
|
|
placing: false,
|
|
connection: None,
|
|
allocation: None,
|
|
};
|
|
let mut state = State::default();
|
|
let scene = canvas.scene(&state);
|
|
let center = scene.routed.edges[&edge].label.center;
|
|
let screen = point(center) + canvas.pan;
|
|
assert!(
|
|
matches!(pointer(&canvas,&mut state,mouse::Event::ButtonPressed(mouse::Button::Left),screen),Some(Message::Select(id)) if id==edge)
|
|
);
|
|
canvas.selected = edge;
|
|
pointer(
|
|
&canvas,
|
|
&mut state,
|
|
mouse::Event::ButtonPressed(mouse::Button::Left),
|
|
screen,
|
|
);
|
|
let moved = screen + Vector::new(50., 30.);
|
|
pointer(
|
|
&canvas,
|
|
&mut state,
|
|
mouse::Event::CursorMoved { position: moved },
|
|
moved,
|
|
);
|
|
let preview = canvas.scene(&state);
|
|
assert_eq!(
|
|
preview.routed.edges[&edge].label.center,
|
|
Position {
|
|
x: center.x + 50.,
|
|
y: center.y + 30.
|
|
}
|
|
);
|
|
assert_eq!(
|
|
preview.routed.edges[&edge].points, scene.routed.edges[&edge].points,
|
|
"Label moves do not reroute the connection"
|
|
);
|
|
let Some(Message::Route(RouteEvent::Commit(_, route, _))) = pointer(
|
|
&canvas,
|
|
&mut state,
|
|
mouse::Event::ButtonReleased(mouse::Button::Left),
|
|
moved,
|
|
) else {
|
|
panic!("Label drag must commit");
|
|
};
|
|
assert!(route.label.is_some());
|
|
let node = scene.view.nodes[0].clone();
|
|
let cover = Position {
|
|
x: node.position.x + 40.,
|
|
y: node.position.y + 30.,
|
|
};
|
|
let anchor = scene.routed.edges[&edge].label.anchor;
|
|
p.diagrams.get_mut(&diagram).unwrap().routes[0].label = Some(EdgeLabel {
|
|
fraction: scene.routed.edges[&edge].label.fraction,
|
|
offset: Position {
|
|
x: cover.x - anchor.x,
|
|
y: cover.y - anchor.y,
|
|
},
|
|
});
|
|
let canvas = DiagramCanvas {
|
|
selection: None,
|
|
project: &p,
|
|
view_index: None,
|
|
diagram,
|
|
selected: edge,
|
|
navigation_focus: None,
|
|
zoom: 1.,
|
|
pan: Vector::new(100., 400.),
|
|
context: Context {
|
|
document: p.id,
|
|
diagram,
|
|
revision: 1,
|
|
epoch: 0,
|
|
},
|
|
selected_bend: None,
|
|
selected_attachment: None,
|
|
editable: true,
|
|
placing: false,
|
|
connection: None,
|
|
allocation: None,
|
|
};
|
|
assert!(
|
|
matches!(pointer(&canvas,&mut state,mouse::Event::ButtonPressed(mouse::Button::Left),point(cover)+canvas.pan),Some(Message::SelectNode(id)) if id==node.id)
|
|
);
|
|
}
|
|
#[test]
|
|
fn coincident_line_hit_retains_the_selected_connection() {
|
|
let (p, diagram, edge) = route_fixture();
|
|
let view = project_view(&p, diagram);
|
|
let mut routed = route_diagram(&p, &p.diagrams[&diagram], &view);
|
|
let duplicate = Id::new_v4();
|
|
let mut duplicate_route = routed.edges[&edge].clone();
|
|
duplicate_route.id = duplicate;
|
|
routed.edges.insert(duplicate, duplicate_route);
|
|
let selected = edge.max(duplicate); // UUID ordering must not override selection.
|
|
let canvas = DiagramCanvas {
|
|
selection: None,
|
|
project: &p,
|
|
view_index: None,
|
|
diagram,
|
|
selected,
|
|
navigation_focus: None,
|
|
zoom: 1.,
|
|
pan: Vector::ZERO,
|
|
context: Context {
|
|
document: p.id,
|
|
diagram,
|
|
revision: 0,
|
|
epoch: 0,
|
|
},
|
|
selected_bend: None,
|
|
selected_attachment: None,
|
|
editable: true,
|
|
placing: false,
|
|
connection: None,
|
|
allocation: None,
|
|
};
|
|
let span = &routed.edges[&edge].points;
|
|
let pick = point(oa_core::routing::point_along_polyline(span, 0.4).unwrap());
|
|
assert_eq!(canvas.hit_edge(&routed, pick).unwrap().0, selected);
|
|
}
|
|
}
|