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/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>, pub project: &'a Project, pub view_index: Option<&'a oa_core::view_options::ViewIndex>, pub diagram: Id, pub selected: Id, pub zoom: f32, pub pan: Vector, pub context: Context, pub selected_bend: Option, pub editable: bool, pub placing: bool, pub connection: Option<&'a crate::connection_edit::Session>, } #[derive(Default)] pub struct State { work: Option>, work_cache: RefCell>>, pointer: Option, drag: Option<(Id, Point, Position)>, preview: Option, pan_start: Option<(mouse::Button, Point)>, context: Option, route_drag: Option, connection_press: Option<(Id, Point)>, connection_hover: Option, port_drag: Option, cache: RefCell>, } struct Scene { work: Option>, view: ProjectedView, routed: Arc, } struct CachedScene { context: Context, preview: Option, route: Option, port: Option<(Id, PortAnchor)>, scene: Arc, } /// 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 { 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 { 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 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 { 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 hit_label(&self, routed: &RoutedView, p: Point) -> Option { 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 { 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 { 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 for DiagramCanvas<'_> { type State = State; fn update( &self, state: &mut State, event: &canvas::Event, bounds: Rectangle, cursor: mouse::Cursor, ) -> Option> { // 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(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_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() { // `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_to(self.world(p), self.zoom); } if !gesture.changed { return Some(Action::request_redraw().and_capture()); } let bend = match gesture.handle { Handle::Bend(index) => Some(index), Handle::Label { .. } => None, }; 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) => { 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 { 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::>(); 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 { Color::from_rgb8(144, 158, 173) }; 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(edge) = scene.routed.edges.get(&self.selected) && !edge.custom_suspended { 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 { 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::LINE }) .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 { 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 ) } 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!( "{}", right - left, bottom - top, escape(p.named(diagram)), right - left, bottom - top ); let containers = view .nodes .iter() .filter_map(|n| n.visual_parent) .collect::>(); 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!( "{}{}", 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::>() .join(" "); svg.push_str(&format!("{}",edge.id,escape(p.named(edge.id)))); let label = &route.label; if !label.text.is_empty() { svg.push_str(&format!("{}",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!( "", edge.id )); if end.navigable { let [a, tip, b] = navigation_mark(end.tip, end.inward); svg.push_str(&format!("",a.x,a.y,tip.x,tip.y,b.x,b.y)); } let label = &end.label; svg.push_str(&format!("{}",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!( "{}", escape(p.named(port.element)) )); if glyph == PortGlyph::Function { let [a, b, c] = port_triangle(&p.elements[&port.element], port); svg.push_str(&format!("", 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!("",x-7.,y-7.)); if matches!(glyph, PortGlyph::Flow(_)) { for [a, b] in flow_mark(&p.elements[&port.element], port) { svg.push_str(&format!( "", a.x, a.y, b.x, b.y )); } } else if matches!(glyph, PortGlyph::Unspecified | PortGlyph::Legacy) { svg.push_str(&format!( "" )); } } svg.push_str(""); } svg.push_str(""); 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!("{}", escape(&boundary.name)); let edge_path = svg .split(&title) .next() .unwrap() .rsplit("{}", escape(p.named(port.element))); let body = svg .split(&title) .nth(1) .unwrap() .split("") .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(" {} } } } #[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("") .find(|g| g.contains(">Grouped work")) .unwrap(); assert!(group.contains(&format!(">{header}"))); assert!(group.contains("stroke-dasharray=\"6 4\"")); assert!(group.contains("font-style=\"italic\"")); let leaf = svg .split("") .find(|g| g.contains(">Allocated leaf")) .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, editable: true, placing: false, connection: None, allocation: None, diagram, selected: p.id, 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!(">{}", 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»")); 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, editable: true, placing: false, connection: None, allocation: None, diagram, selected: p.id, 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!("{}", 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, selected_bend: 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, 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 { 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, zoom, pan: Vector::new(150., 300.), context: Context { document: p.id, diagram, revision: 0, epoch: 0, }, selected_bend: 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, zoom: 0.5, pan: Vector::new(150., 300.), context: Context { document: p.id, diagram, revision: 0, epoch: 0, }, selected_bend: 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, zoom: 1., pan: Vector::new(150., 300.), context: Context { document: p.id, diagram, revision: 0, epoch: 0, }, selected_bend: 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::>() .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]")); assert!(svg.contains("children [0..*]")); assert!(!svg.contains("data-navigation=\"source\"")); assert!(svg.contains("data-navigation=\"target\"")); let mut canvas = DiagramCanvas { project: &p, view_index: None, diagram, selected: edge, zoom: 0.7, pan: Vector::new(350., 350.), context: Context { document: p.id, diagram, revision: 0, epoch: 0, }, selected_bend: 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, zoom: 1., pan: Vector::new(100., 400.), context: Context { document: p.id, diagram, revision: 0, epoch: 0, }, selected_bend: 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, zoom: 1., pan: Vector::new(100., 400.), context: Context { document: p.id, diagram, revision: 1, epoch: 0, }, selected_bend: 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, zoom: 1., pan: Vector::ZERO, context: Context { document: p.id, diagram, revision: 0, epoch: 0, }, selected_bend: 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); } }