Files
ARCADIA-Studio/crates/oa-desktop/src/diagram.rs
T

3061 lines
116 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/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;
#[path = "diagram/work_drag.rs"]
mod work_drag;
pub use work_drag::WorkFeedback;
#[derive(Clone, Copy)]
pub struct DiagramCanvas<'a> {
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 {
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();
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>> {
// 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.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));
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());
}
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) {
if self.work_eligible(id) {
return Some(self.begin_work(
state,
id,
cursor.position()?,
bounds,
view,
&scene.routed,
));
}
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.semantic.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, 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);
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, self.selected, false);
} else {
// Container headers stay readable when relationships traverse
// the allocation enclosure. Children remain on top.
let mut header = n.clone();
header.size.height = 62.;
paint_node(&mut f, project, &header, self.selected, true);
}
}
for port in &view.ports {
let color = if self.selected == port.element {
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 self.selected == port.element {
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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
fn hex(c: Color) -> String {
format!(
"#{:02x}{:02x}{:02x}",
(c.r * 255.) as u8,
(c.g * 255.) as u8,
(c.b * 255.) as u8
)
}
pub fn export_svg(p: &Project, diagram: Id) -> String {
let view = project_view(p, diagram);
let Some(d) = p.diagrams.get(&diagram) else {
return String::new();
};
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>");
svg
}
#[cfg(test)]
#[path = "diagram/pointer_tests.rs"]
mod pointer_tests;
#[cfg(test)]
mod tests {
use super::*;
#[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);
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);
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 {
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);
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);
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 {
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);
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 {
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 {
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 {
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 {
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);
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);
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 {
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 {
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 {
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 {
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);
}
}