Support per-view connection boundary attachments in ordinary and state diagrams

This commit is contained in:
2026-09-22 17:51:13 -07:00
parent 3f9e6a18af
commit 79cc9d5551
49 changed files with 3534 additions and 52 deletions
@@ -748,6 +748,8 @@ fn allocation_artifact_and_manual_trace_cascades_are_named_and_refused() {
fn route(edge: Id) -> EdgeRoute {
EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![xy(420., 400.), xy(420., 550.)],
label: Some(EdgeLabel {
fraction: 0.4,
@@ -70,6 +70,7 @@ impl Root<'_> {
pan: self.app.pan,
context: self.app.route_context().unwrap(),
selected_bend: None,
selected_attachment: None,
editable: false,
placing: false,
connection: None,
+75 -1
View File
@@ -493,6 +493,7 @@ pub struct App {
pub camera_epoch: u64,
camera_boundary_epoch: u64,
pub selected_bend: Option<(Id, usize)>,
pub selected_attachment: Option<(crate::route_edit::Context, Id, EdgeEnd)>,
pub exact_strings: bool,
pub document: Document,
pub page: Page,
@@ -613,6 +614,7 @@ impl App {
camera_epoch: 0,
camera_boundary_epoch: 0,
selected_bend: None,
selected_attachment: None,
exact_strings: false,
document,
page: Page::Diagram,
@@ -1103,6 +1105,9 @@ impl App {
previous != (self.layer, self.active_diagram)
}
fn select(&mut self, id: Id) {
if id != self.selected {
self.selected_attachment = None;
}
if self.selected == id {
return;
}
@@ -4523,7 +4528,7 @@ impl App {
}
fn edit_route(&mut self, event: crate::route_edit::Event) {
use crate::route_edit::{Event, insert_pin, stored_route};
if self.connection.is_some() {
if self.connection.is_some() || self.page != Page::Diagram {
return;
}
let Some(context) = self.route_context() else {
@@ -4551,6 +4556,75 @@ impl App {
.filter(|(id, _)| *id == self.selected)
.map(|(_, i)| i);
let command = match event {
Event::ChooseAttachment(end) => {
if crate::route_edit::attachment_node(
self.project(),
diagram,
&view,
self.selected,
end,
)
.is_some()
{
self.selected_attachment = Some((context, self.selected, end));
self.selected_bend = None;
self.status = format!(
"Drag the {} attachment handle along its element boundary · Esc cancels",
if end == EdgeEnd::Source {
"source"
} else {
"target"
}
);
}
return;
}
Event::ResetAttachment(end) => {
if crate::route_edit::attachment_node(
self.project(),
diagram,
&view,
self.selected,
end,
)
.is_none()
|| route.attachment(end).is_none()
{
return;
}
route.set_attachment(end, None);
selected_bend = None;
Command::SetEdgeRoute {
diagram: context.diagram,
route,
}
}
Event::CommitAttachment(expected, changed, end, owner) => {
if expected != context
|| changed.edge != self.selected
|| crate::route_edit::attachment_node(
self.project(),
diagram,
&view,
self.selected,
end,
)
.is_none_or(|node| node.id != owner)
{
return;
}
// A boundary gesture owns exactly one presentation field.
let mut intended = route.clone();
intended.set_attachment(end, changed.attachment(end));
if changed != intended || intended == route {
return;
}
selected_bend = None;
Command::SetEdgeRoute {
diagram: context.diagram,
route: intended,
}
}
Event::SelectBend(id, index) => {
if id == self.selected && index < route.waypoints.len() {
self.selected_bend = Some((id, index));
@@ -69,6 +69,7 @@ fn canvas(app: &App) -> DiagramCanvas<'_> {
pan: app.pan,
context: app.route_context().unwrap(),
selected_bend: None,
selected_attachment: None,
editable: !app.draft_dirty() && !app.source_dirty(),
placing: false,
connection: None,
@@ -75,6 +75,7 @@ fn architecture_f2_routes_selected_node_to_inline_rename_and_exact_undo() {
pan: app.pan,
context: app.route_context().unwrap(),
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -230,6 +230,7 @@ fn architecture_canvas_left_click_uses_world_coordinate_once_at_zoom_and_pan() {
pan: app.pan,
context: app.route_context().unwrap(),
selected_bend: None,
selected_attachment: None,
editable: true,
placing: true,
connection: None,
@@ -306,6 +307,7 @@ fn architecture_f2_rename_preserves_identity_placement_and_atomic_history() {
pan: app.pan,
context: app.route_context().unwrap(),
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -924,6 +924,8 @@ async fn mounted_bend_delete_has_priority_and_semantic_edge_delete_is_confirmed(
diagram,
route: EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![Position { x: 400., y: -100. }],
label: None,
},
@@ -172,6 +172,24 @@ impl App {
)));
}
Event::Choose(tool) => {
if let Tool::AdjustAttachment { relation, end } = tool {
if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty() {
self.status =
"Apply or cancel pending edits before adjusting an attachment.".into();
return Task::none();
}
let Some(context) = self.state_context() else {
return Task::none();
};
if state_edit::attachment_node(self.project(), context.diagram, relation, end)
.is_none()
{
return Task::none();
}
self.selected = relation;
self.selected_node = None;
self.refresh_draft();
}
if !self.apply() {
return Task::none();
}
@@ -210,6 +228,22 @@ impl App {
let Some(context) = self.state_context() else {
return Task::none();
};
if let Event::SetAttachment {
relation,
end,
attachment,
..
} = &mutation
{
let existing = self.project().diagrams[&context.diagram]
.routes
.iter()
.find(|route| route.edge == *relation)
.and_then(|route| route.attachment(*end));
if existing == *attachment {
return Task::none();
}
}
match state_edit::plan(self.project(), context, &mutation) {
Ok(plan) => {
if self.execute(plan.command) {
@@ -43,8 +43,10 @@ pub struct State {
press: Option<Press>,
source: Option<Id>,
preview: Option<(Id, Position)>,
attachment_preview: Option<AttachmentPreview>,
pan_from: Option<(mouse::Button, Point)>,
viewport: Option<Size>,
attachment_viewport: Option<(f32, Vector, Rectangle)>,
scene: RefCell<Option<CachedScene>>,
geometry: canvas::Cache,
paint_key: RefCell<Option<PaintKey>>,
@@ -76,6 +78,20 @@ enum Press {
/// Handles require a drag; an explicitly chosen reconnect tool accepts a click.
start: Option<Point>,
},
Attachment {
relation: Id,
end: EdgeEnd,
placement: Id,
start: Point,
},
}
#[derive(Clone, Copy, PartialEq)]
struct AttachmentPreview {
relation: Id,
end: EdgeEnd,
placement: Id,
attachment: EdgeAttachment,
}
struct Scene {
@@ -86,12 +102,14 @@ struct Scene {
struct CachedScene {
context: Context,
preview: Option<(Id, Position)>,
attachment_preview: Option<AttachmentPreview>,
scene: Arc<Scene>,
}
#[derive(PartialEq)]
struct PaintKey {
context: Context,
preview: Option<(Id, Position)>,
attachment_preview: Option<AttachmentPreview>,
selected: Id,
zoom: f32,
pan: Vector,
@@ -107,6 +125,8 @@ impl State {
fn cancel_gesture(&mut self) {
self.press = None;
self.preview = None;
self.attachment_preview = None;
self.attachment_viewport = None;
self.pan_from = None;
}
fn reset(&mut self) {
@@ -656,13 +676,23 @@ impl StateCanvas<'_> {
.gesture_active(state)
.then_some(state.preview)
.flatten();
let attachment_preview = self
.gesture_active(state)
.then_some(state.attachment_preview)
.flatten();
if let Some(cached) = state.scene.borrow().as_ref()
&& cached.context == self.context
&& cached.preview == preview
&& cached.attachment_preview == attachment_preview
{
return Some(cached.scene.clone());
}
let mut diagram = self.project.diagrams.get(&self.diagram)?.clone();
if let Some(preview) = attachment_preview {
let mut route = crate::route_edit::stored_route(&diagram, preview.relation);
route.set_attachment(preview.end, Some(preview.attachment));
crate::route_edit::with_route(&mut diagram, route);
}
if let Some((id, at)) = preview
&& let Some(node) = diagram.nodes.get(&id)
{
@@ -697,6 +727,7 @@ impl StateCanvas<'_> {
*state.scene.borrow_mut() = Some(CachedScene {
context: self.context,
preview,
attachment_preview,
scene: scene.clone(),
});
Some(scene)
@@ -858,10 +889,64 @@ impl StateCanvas<'_> {
fn gesture_active(&self, state: &State) -> bool {
self.editable && state.context == Some(self.context) && state.tool == Some(self.tool)
}
fn move_attachment_pointer(&self, state: &mut State, window: Point, local: Point) {
let Some(Press::Attachment {
relation,
end,
placement,
start,
}) = state.press
else {
return;
};
if window.distance(start) < 3. {
// Returning to the press restores the exact persisted optional value,
// without quantizing an existing attachment or pinning an automatic one.
state.attachment_preview = None;
return;
}
let Some(owner) = state_edit::attachment_node(self.project, self.diagram, relation, end)
.filter(|owner| owner.id == placement)
else {
state.cancel_gesture();
return;
};
let diagram = &self.project.diagrams[&self.diagram];
let previous = state
.attachment_preview
.map(|preview| preview.attachment.side)
.or_else(|| {
diagram
.routes
.iter()
.find(|route| route.edge == relation)
.and_then(|route| route.attachment(end))
.map(|attachment| attachment.side)
});
let world = self.world(local);
if !world.x.is_finite() || !world.y.is_finite() {
state.cancel_gesture();
return;
}
let attachment =
routing::attachment_from_point(&owner, position(world), previous, 6. / self.scale());
state.attachment_preview = Some(AttachmentPreview {
relation,
end,
placement,
attachment,
});
}
fn guidance(&self, state: &State, scene: &Scene, world: Option<Point>) -> String {
if !self.editable {
return "Pending edits · apply or cancel them before editing the diagram".into();
}
if let Tool::AdjustAttachment { relation, end } = self.tool {
return format!(
"Adjust {end:?} attachment of {} · drag that tip around its existing vertex boundary · Esc cancels · relation unchanged",
self.project.named(relation)
);
}
let hovered = world.and_then(|p| self.vertex_at(scene, p));
let press = self.gesture_active(state).then_some(state.press).flatten();
let source = self.gesture_active(state).then_some(state.source).flatten();
@@ -952,8 +1037,15 @@ impl canvas::Program<Event> for StateCanvas<'_> {
if !self.editable {
state.press = None;
state.preview = None;
state.attachment_preview = None;
state.source = None;
}
if state
.attachment_viewport
.is_some_and(|view| view != (self.zoom, self.pan, bounds))
{
state.cancel_gesture();
}
// Winit positions are ordered events. The cursor supplied by Iced is the
// final cursor for an entire batch and cannot reinterpret earlier clicks.
let cursor = match event {
@@ -981,6 +1073,11 @@ impl canvas::Program<Event> for StateCanvas<'_> {
state.reset();
return None;
}
canvas::Event::Window(iced::window::Event::Resized(_)) => {
state.cancel_gesture();
state.viewport = None;
return None;
}
_ => cursor,
};
let window = cursor.position();
@@ -1142,6 +1239,26 @@ impl canvas::Program<Event> for StateCanvas<'_> {
});
Some(Action::capture())
}
Tool::AdjustAttachment { relation, end } => {
let owner =
state_edit::attachment_node(self.project, self.diagram, relation, end)?;
let route = scene.routed.edges.get(&relation)?;
let tip = match end {
EdgeEnd::Source => route.points.first(),
EdgeEnd::Target => route.points.last(),
}?;
if world.distance(point(*tip)) > 10. / self.scale() {
return None;
}
state.attachment_viewport = Some((self.zoom, self.pan, bounds));
state.press = Some(Press::Attachment {
relation,
end,
placement: owner.id,
start: window,
});
Some(Action::capture())
}
}
}
canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) => {
@@ -1156,6 +1273,9 @@ impl canvas::Program<Event> for StateCanvas<'_> {
}
}
self.move_pointer(state, window);
if let Some(local) = local {
self.move_attachment_pointer(state, window, local);
}
Some(Action::request_redraw())
}
canvas::Event::Mouse(mouse::Event::ButtonReleased(button)) => {
@@ -1176,9 +1296,14 @@ impl canvas::Program<Event> for StateCanvas<'_> {
}
if let Some(window) = window {
self.move_pointer(state, window);
if let Some(local) = local {
self.move_attachment_pointer(state, window, local);
}
}
let press = state.press.take()?;
state.attachment_viewport = None;
let preview = state.preview.take();
let attachment_preview = state.attachment_preview.take();
let world = local.map(|p| self.world(p));
let event = match press {
Press::Move { node, .. } => preview.map(|(_, position)| Event::Move {
@@ -1186,6 +1311,24 @@ impl canvas::Program<Event> for StateCanvas<'_> {
node,
position,
}),
Press::Attachment {
relation,
end,
placement,
..
} if inside => attachment_preview
.filter(|preview| {
preview.relation == relation
&& preview.end == end
&& preview.placement == placement
})
.map(|preview| Event::SetAttachment {
context: self.context,
relation,
end,
placement,
attachment: Some(preview.attachment),
}),
Press::Create { owner, kind, start }
if inside
&& window?.distance(start) < 3.
@@ -1282,6 +1425,10 @@ impl canvas::Program<Event> for StateCanvas<'_> {
.gesture_active(state)
.then_some(state.preview)
.flatten(),
attachment_preview: self
.gesture_active(state)
.then_some(state.attachment_preview)
.flatten(),
selected: self.selected,
zoom: self.scale(),
pan: self.pan,
@@ -1391,6 +1538,20 @@ impl canvas::Program<Event> for StateCanvas<'_> {
.with_width(2.5 / self.scale()),
);
}
if let Tool::AdjustAttachment { relation, end } = self.tool
&& let Some(route) = scene.routed.edges.get(&relation)
&& let Some(tip) = match end {
EdgeEnd::Source => route.points.first(),
EdgeEnd::Target => route.points.last(),
}
{
frame.stroke(
&Path::circle(point(*tip), 9. / self.scale()),
Stroke::default()
.with_color(style::TEAL)
.with_width(2. / self.scale()),
);
}
if let Some(at) = world
&& self.editable
{
@@ -2483,6 +2644,242 @@ mod tests {
);
}
#[test]
fn explicit_attachment_drag_is_view_only_and_one_source_history_operation() {
for end in [EdgeEnd::Source, EdgeEnd::Target] {
for zoom in [0.5, 1., 2.] {
let mut f = fixture();
let relation =
f.project
.relate(RelationKind::StateTransition, "Go standby", f.a, f.b);
let second = state_edit::create_view(&f.project, f.machine).unwrap();
let other_id = second.diagram;
f.project = prepare_command(&f.project, &second.command)
.unwrap()
.project;
let other = f.project.diagrams[&other_id].clone();
let mut canvas = canvas(&f, Tool::AdjustAttachment { relation, end }, zoom);
canvas.selected = relation;
let mut state = State::default();
let scene = canvas.scene(&state).unwrap();
let route = &scene.routed.edges[&relation];
let tip = match end {
EdgeEnd::Source => route.points.first(),
EdgeEnd::Target => route.points.last(),
}
.unwrap();
let handle = screen(&canvas, point(*tip));
motion(&canvas, &mut state, handle, handle);
input(
&canvas,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Left),
handle,
);
assert!(
input(
&canvas,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
handle
)
.is_none()
);
assert!(
state.attachment_preview.is_none(),
"A click must not materialize an automatic endpoint"
);
let owner =
state_edit::attachment_node(&f.project, f.diagram, relation, end).unwrap();
let destination = screen(
&canvas,
Point::new(owner.position.x + owner.size.width * 0.25, owner.position.y),
);
input(
&canvas,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Left),
handle,
);
motion(&canvas, &mut state, destination, destination);
assert!(state.attachment_preview.is_some());
assert!(f.project.diagrams[&f.diagram].routes.is_empty());
let event = input(
&canvas,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
destination,
)
.unwrap();
assert!(matches!(event, Event::SetAttachment { end: actual, .. } if actual == end));
let plan = state_edit::plan(&f.project, canvas.context, &event).unwrap();
let mut document =
Document::from_project(f.project.clone(), Default::default()).unwrap();
let source = document.bundle().clone();
document.execute(plan.command).unwrap();
assert_eq!(document.project().relations, f.project.relations);
assert_eq!(document.project().diagrams[&other_id], other);
let route = &document.project().diagrams[&f.diagram].routes[0];
assert_eq!(
route.attachment(end),
Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.25
})
);
let edited = document.bundle().clone();
assert!(document.undo());
assert_eq!(document.bundle(), &source);
assert!(!document.undo());
assert!(document.redo());
assert_eq!(document.bundle(), &edited);
}
}
}
#[test]
fn attachment_return_to_press_restores_exact_automatic_or_fractional_original() {
for original in [
None,
Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.234567,
}),
] {
for end in [EdgeEnd::Source, EdgeEnd::Target] {
let mut f = fixture();
let relation =
f.project
.relate(RelationKind::StateTransition, "Go standby", f.a, f.b);
if original.is_some() {
let mut route =
crate::route_edit::stored_route(&f.project.diagrams[&f.diagram], relation);
route.set_attachment(end, original);
f.project
.diagrams
.get_mut(&f.diagram)
.unwrap()
.routes
.push(route);
}
let canvas = canvas(&f, Tool::AdjustAttachment { relation, end }, 1.);
let mut state = State::default();
let before = canvas.scene(&state).unwrap();
let edge = &before.routed.edges[&relation];
let tip = match end {
EdgeEnd::Source => edge.points.first(),
EdgeEnd::Target => edge.points.last(),
}
.unwrap();
let start = screen(&canvas, point(*tip));
let away = start + Vector::new(80., 40.);
motion(&canvas, &mut state, start, start);
input(
&canvas,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Left),
start,
);
motion(&canvas, &mut state, away, away);
assert!(state.attachment_preview.is_some());
motion(&canvas, &mut state, start, start);
assert!(state.attachment_preview.is_none());
assert_eq!(
canvas.scene(&state).unwrap().routed.edges[&relation].points,
edge.points
);
assert!(
input(
&canvas,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
start
)
.is_none()
);
assert_eq!(
crate::route_edit::stored_route(&f.project.diagrams[&f.diagram], relation)
.attachment(end),
original
);
}
}
}
#[test]
fn attachment_drag_cancels_on_camera_context_tool_pending_and_focus_changes() {
let mut f = fixture();
let relation = f
.project
.relate(RelationKind::StateTransition, "Go standby", f.a, f.b);
for cancel in 0..9 {
let mut canvas = canvas(
&f,
Tool::AdjustAttachment {
relation,
end: EdgeEnd::Source,
},
1.,
);
canvas.selected = relation;
let mut state = State::default();
let scene = canvas.scene(&state).unwrap();
let handle = screen(&canvas, point(scene.routed.edges[&relation].points[0]));
let destination = handle + Vector::new(40., 30.);
motion(&canvas, &mut state, handle, handle);
input(
&canvas,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Left),
handle,
);
motion(&canvas, &mut state, destination, destination);
assert!(state.attachment_preview.is_some());
match cancel {
0 => canvas.zoom = 2.,
1 => canvas.pan.x += 10.,
2 => canvas.context.epoch += 1,
3 => canvas.tool = Tool::Select,
4 => canvas.editable = false,
5 | 6 => {
input(
&canvas,
&mut state,
mouse::Event::ButtonPressed(if cancel == 5 {
mouse::Button::Right
} else {
mouse::Button::Middle
}),
destination,
);
}
7 => {
canvas::Program::update(
&canvas,
&mut state,
&canvas::Event::Window(iced::window::Event::Unfocused),
bounds(),
mouse::Cursor::Available(destination),
);
}
_ => {
motion(&canvas, &mut state, Point::new(-100., -100.), destination);
}
}
assert!(
input(
&canvas,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
destination
)
.is_none()
);
assert!(state.attachment_preview.is_none());
assert!(f.project.diagrams[&f.diagram].routes.is_empty());
}
}
#[test]
fn selected_endpoint_reconnect_preserves_identity_and_requires_handle_motion() {
let mut f = fixture();
+137 -1
View File
@@ -20,6 +20,7 @@ pub enum Tool {
Region,
Transition,
Reconnect { relation: Id, endpoint: Endpoint },
AdjustAttachment { relation: Id, end: EdgeEnd },
}
#[derive(Debug, Clone)]
@@ -66,6 +67,13 @@ pub enum Event {
endpoint: Endpoint,
vertex: Id,
},
SetAttachment {
context: Context,
relation: Id,
end: EdgeEnd,
placement: Id,
attachment: Option<EdgeAttachment>,
},
}
impl Event {
@@ -84,7 +92,8 @@ impl Event {
| Self::Place { context: c, .. }
| Self::Move { context: c, .. }
| Self::Connect { context: c, .. }
| Self::Reconnect { context: c, .. } => Some(*c),
| Self::Reconnect { context: c, .. }
| Self::SetAttachment { context: c, .. } => Some(*c),
}
}
}
@@ -249,6 +258,36 @@ pub fn can_reconnect(
.is_ok()
}
/// Only the actual visible transition owner can receive a visual attachment.
/// Folded promotion, semantic ports and internal-transition rows are excluded.
pub fn attachment_node(
project: &Project,
diagram: Id,
relation: Id,
end: EdgeEnd,
) -> Option<projection::ProjectedNode> {
machine(project, diagram).ok()?;
let relation_record = project.relations.get(&relation)?;
if relation_record.kind() != RelationKind::StateTransition
|| oa_core::routing::semantic_port_endpoint(project, relation, end).is_some()
{
return None;
}
let view = projection::project_view(project, diagram);
let edge = view.edges.iter().find(|edge| edge.id == relation)?;
let stored = project.diagrams.get(&diagram)?;
if projection::edge_endpoint_placements(project, stored, relation)?
!= (edge.source, edge.target)
{
return None;
}
let owner = match end {
EdgeEnd::Source => edge.source,
EdgeEnd::Target => edge.target,
};
view.nodes.iter().find(|node| node.id == owner).cloned()
}
fn checked(project: &Project, plan: Plan) -> Result<Plan, ModelError> {
prepare_command(project, &plan.command)?;
Ok(plan)
@@ -492,6 +531,22 @@ pub fn plan(project: &Project, current: Context, event: &Event) -> Result<Plan,
relation,
None,
),
Event::SetAttachment {
relation,
end,
placement,
attachment,
..
} => {
attachment_node(project, diagram, relation, end)
.filter(|owner| owner.id == placement)
.ok_or_else(|| {
error("Choose the actual visible transition endpoint in this view.")
})?;
let mut route = crate::route_edit::stored_route(&project.diagrams[&diagram], relation);
route.set_attachment(end, attachment);
(Command::SetEdgeRoute { diagram, route }, relation, None)
}
_ => {
return Err(error(
"Handle this navigation or tool request without a model command.",
@@ -580,6 +635,85 @@ mod tests {
plan.diagram
}
#[test]
fn attachment_reset_preserves_other_end_pins_label_and_rejects_ineligible_owners() {
let mut f = fixture();
let diagram = create(&mut f);
let c = context(diagram);
let edge = f.project.relate(
RelationKind::StateTransition,
"Leave work",
f.leaf,
f.target,
);
let attachment = EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.3,
};
let mut route = crate::route_edit::stored_route(&f.project.diagrams[&diagram], edge);
route.source_attachment = Some(attachment);
route.target_attachment = Some(attachment);
route.waypoints.push(Position { x: 700., y: 700. });
// Retain the exact label value alongside the other route components.
route.label = Some(EdgeLabel {
fraction: 0.5,
offset: Position { x: 10., y: 20. },
});
f.project
.diagrams
.get_mut(&diagram)
.unwrap()
.routes
.push(route.clone());
let owner = attachment_node(&f.project, diagram, edge, EdgeEnd::Source).unwrap();
let event = Event::SetAttachment {
context: c,
relation: edge,
end: EdgeEnd::Source,
placement: owner.id,
attachment: None,
};
let accepted = prepare_command(&f.project, &plan(&f.project, c, &event).unwrap().command)
.unwrap()
.project;
route.source_attachment = None;
assert_eq!(accepted.diagrams[&diagram].routes, vec![route]);
assert_eq!(accepted.relations, f.project.relations);
let wrong = Event::SetAttachment {
context: c,
relation: edge,
end: EdgeEnd::Source,
placement: node(&f.project, diagram, f.target),
attachment: Some(attachment),
};
assert!(plan(&f.project, c, &wrong).is_err());
let composite = node(&f.project, diagram, f.composite);
f.project
.diagrams
.get_mut(&diagram)
.unwrap()
.nodes
.get_mut(&composite)
.unwrap()
.collapsed = true;
assert!(attachment_node(&f.project, diagram, edge, EdgeEnd::Source).is_none());
assert!(plan(&f.project, c, &event).is_err());
let internal = f.project.relate(
RelationKind::StateTransition,
"Internal",
f.target,
f.target,
);
if let RelationBody::StateTransition {
transition_kind, ..
} = &mut f.project.relations.get_mut(&internal).unwrap().body
{
*transition_kind = TransitionKind::Internal;
}
assert!(attachment_node(&f.project, diagram, internal, EdgeEnd::Source).is_none());
assert!(attachment_node(&f.project, diagram, internal, EdgeEnd::Target).is_none());
}
#[test]
fn view_initializes_only_its_machine_in_one_source_and_model_history_entry() {
let f = fixture();
@@ -763,6 +897,8 @@ mod tests {
.routes
.push(EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![pin],
label: None,
});
@@ -178,6 +178,44 @@ pub fn popup(app: &App, opened: &Opened, clean: bool) -> Option<Menu<Message>> {
visible,
));
}
for (caption, reset, end) in [
(
"Adjust source attachment",
"Reset source attachment",
EdgeEnd::Source,
),
(
"Adjust target attachment",
"Reset target attachment",
EdgeEnd::Target,
),
] {
let owner = state_edit::attachment_node(project, context.diagram, id, end);
entries.push(item(
caption,
diagram_event(Event::Choose(Tool::AdjustAttachment { relation: id, end })),
owner.is_some(),
));
if let Some(owner) = owner {
let authored = project.diagrams[&context.diagram]
.routes
.iter()
.find(|route| route.edge == id)
.and_then(|route| route.attachment(end))
.is_some();
entries.push(item(
reset,
diagram_event(Event::SetAttachment {
context,
relation: id,
end,
placement: owner.id,
attachment: None,
}),
authored,
));
}
}
if !visible {
entries.push(Entry::title(
"Use transition details for hidden or internal endpoints",
+160 -2
View File
@@ -22,6 +22,9 @@ use std::{
sync::Arc,
};
#[cfg(test)]
#[path = "diagram/attachment_tests.rs"]
mod attachment_tests;
#[cfg(test)]
#[path = "diagram/lifecycle_tests.rs"]
mod lifecycle_tests;
#[cfg(test)]
@@ -53,6 +56,7 @@ pub struct DiagramCanvas<'a> {
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>,
@@ -67,6 +71,7 @@ pub struct State {
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>,
@@ -294,6 +299,11 @@ impl DiagramCanvas<'_> {
);
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
@@ -371,6 +381,39 @@ impl DiagramCanvas<'_> {
})
.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
@@ -561,6 +604,24 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
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.
@@ -840,6 +901,21 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
&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()
@@ -963,7 +1039,7 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
return Some(Action::request_redraw());
}
if let Some(gesture) = &mut state.route_drag {
gesture.move_to(self.world(p), self.zoom);
gesture.move_attachment_to(self.world(p), self.zoom);
return Some(Action::request_redraw().and_capture());
}
if let Some((_, start, original)) = state.drag {
@@ -1040,17 +1116,43 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
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_to(self.world(p), self.zoom);
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(
@@ -1094,6 +1196,15 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
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
@@ -1336,6 +1447,41 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
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(
@@ -2042,6 +2188,7 @@ mod tests {
epoch: 0,
},
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -2118,6 +2265,7 @@ mod tests {
epoch: 0,
},
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -2243,6 +2391,7 @@ mod tests {
selected: p.id,
navigation_focus: None,
selected_bend: None,
selected_attachment: None,
zoom,
pan: Vector::new(20., 30.),
editable: true,
@@ -2380,6 +2529,8 @@ mod tests {
diagram,
route: EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![Position { x: 400., y: -100. }],
label: None,
},
@@ -2422,6 +2573,7 @@ mod tests {
epoch: 0,
},
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -2499,6 +2651,7 @@ mod tests {
epoch: 0,
},
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -2597,6 +2750,7 @@ mod tests {
epoch: 0,
},
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -2726,6 +2880,7 @@ mod tests {
epoch: 0,
},
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -2781,6 +2936,7 @@ mod tests {
epoch: 0,
},
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -2856,6 +3012,7 @@ mod tests {
epoch: 0,
},
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -2890,6 +3047,7 @@ mod tests {
epoch: 0,
},
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -0,0 +1,446 @@
use super::*;
use crate::app::{App, Page};
use iced::widget::canvas::Program;
fn fixture() -> App {
let mut p = Project::empty();
let a = p.add(Kind::Activity, "Send", 100., 150.);
let b = p.add(Kind::Activity, "Receive", 650., 150.);
let edge = p.relate(RelationKind::Interaction, "Report", a, b);
let diagram = p.ensure_diagram(Layer::Oa);
p.diagrams
.get_mut(&diagram)
.unwrap()
.routes
.push(EdgeRoute {
edge,
waypoints: vec![Position { x: 450., y: 350. }],
label: Some(EdgeLabel {
fraction: 0.4,
offset: Position { x: 5., y: 10. },
}),
source_attachment: None,
target_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Left,
offset: 0.6,
}),
});
let mut app = App::with_project(p);
app.page = Page::Diagram;
app.active_diagram = Some(diagram);
let _ = app.update(Message::Select(edge));
app
}
fn canvas(app: &App, zoom: f32) -> DiagramCanvas<'_> {
DiagramCanvas {
allocation: None,
project: app.project(),
view_index: None,
diagram: app.active_diagram.unwrap(),
selected: app.selected,
navigation_focus: None,
zoom,
pan: Vector::new(40., 50.),
context: app.route_context().unwrap(),
selected_bend: None,
selected_attachment: app
.selected_attachment
.filter(|(c, e, _)| Some(*c) == app.route_context() && *e == app.selected)
.map(|(_, _, end)| end),
editable: !app.draft_dirty() && !app.source_dirty(),
placing: false,
connection: None,
}
}
fn bounds() -> Rectangle {
Rectangle::new(Point::new(120., 80.), Size::new(2400., 1800.))
}
fn screen(c: &DiagramCanvas<'_>, p: Position) -> Point {
Point::new(
bounds().x + p.x * c.zoom + c.pan.x,
bounds().y + p.y * c.zoom + c.pan.y,
)
}
fn send(
c: &DiagramCanvas<'_>,
state: &mut State,
event: mouse::Event,
final_cursor: Point,
) -> Option<Message> {
c.update(
state,
&canvas::Event::Mouse(event),
bounds(),
mouse::Cursor::Available(final_cursor),
)
.and_then(|a| a.into_inner().0)
}
fn positions(c: &DiagramCanvas<'_>) -> (Point, Point) {
let scene = c.scene(&State::default());
let start = *scene.routed.edges[&c.selected].points.first().unwrap();
let node = route_edit::attachment_node(
c.project,
&c.project.diagrams[&c.diagram],
&scene.view,
c.selected,
EdgeEnd::Source,
)
.unwrap();
let end = oa_core::routing::attachment_point(
c.project,
&c.project.diagrams[&c.diagram],
node,
&EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.25,
},
);
(screen(c, start), screen(c, end))
}
fn drag(app: &App, zoom: f32) -> Message {
let c = canvas(app, zoom);
let (start, end) = positions(&c);
let mut state = State::default();
let messages = [
mouse::Event::CursorMoved { position: start },
mouse::Event::ButtonPressed(mouse::Button::Left),
mouse::Event::CursorMoved { position: end },
mouse::Event::ButtonReleased(mouse::Button::Left),
]
.into_iter()
.filter_map(|e| send(&c, &mut state, e, end))
.collect::<Vec<_>>();
assert_eq!(messages.len(), 1);
messages.into_iter().next().unwrap()
}
#[test]
fn attachment_event_batches_commit_one_source_history_change_at_three_zooms() {
for zoom in [0.4, 1., 2.] {
let mut app = fixture();
let before = app.document.bundle().clone();
let project = app.project().clone();
let revision = app.document.revision();
let message = drag(&app, zoom);
let Message::Route(RouteEvent::CommitAttachment(_, ref route, EdgeEnd::Source, _)) =
message
else {
panic!("attachment commit required")
};
assert_eq!(
route.source_attachment,
Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.25
})
);
let intended = route.clone();
let _ = app.update(message);
assert_eq!(app.document.revision(), revision + 1);
let mut expected = project;
route_edit::with_route(
expected
.diagrams
.get_mut(&app.active_diagram.unwrap())
.unwrap(),
intended,
);
assert_eq!(
*app.project(),
expected,
"only the intended view attachment changes"
);
let after = app.document.bundle().clone();
assert_eq!(
oa_core::source_project::compile_bundle(&after, Default::default())
.unwrap()
.decoded
.project,
expected
);
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &before);
let _ = app.update(Message::Redo);
assert_eq!(app.document.bundle(), &after);
}
}
#[test]
fn attachment_stationary_return_and_escape_leave_source_unchanged() {
let mut app = fixture();
let before = app.document.bundle().clone();
let c = canvas(&app, 1.);
let (start, end) = positions(&c);
let mut state = State::default();
for event in [
mouse::Event::CursorMoved { position: start },
mouse::Event::ButtonPressed(mouse::Button::Left),
mouse::Event::CursorMoved { position: end },
mouse::Event::CursorMoved { position: start },
mouse::Event::ButtonReleased(mouse::Button::Left),
] {
assert!(send(&c, &mut state, event, start).is_none());
}
for event in [
mouse::Event::CursorMoved { position: start },
mouse::Event::ButtonPressed(mouse::Button::Left),
mouse::Event::CursorMoved { position: end },
] {
assert!(send(&c, &mut state, event, end).is_none());
}
let _ = app.update(Message::Escape);
assert!(
send(
&canvas(&app, 1.),
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
end
)
.is_none()
);
assert_eq!(app.document.bundle(), &before);
}
#[test]
fn attachment_camera_layout_focus_and_outside_release_cancel_after_motion() {
for cancel in 0..8 {
let app = fixture();
let before = app.document.bundle().clone();
let mut c = canvas(&app, 1.);
let (start, end) = positions(&c);
let mut state = State::default();
for event in [
mouse::Event::CursorMoved { position: start },
mouse::Event::ButtonPressed(mouse::Button::Left),
mouse::Event::CursorMoved { position: end },
] {
assert!(send(&c, &mut state, event, end).is_none());
}
assert!(state.route_drag.as_ref().is_some_and(|g| g.changed));
let mut current_bounds = bounds();
let mut release = end;
match cancel {
0 => c.zoom = 2.,
1 => c.pan.x += 20.,
2 => current_bounds.x += 20.,
3 => current_bounds.height -= 100.,
4..=6 => {
let event = match cancel {
4 => canvas::Event::Window(iced::window::Event::Unfocused),
5 => canvas::Event::Window(iced::window::Event::Resized(Size::new(800., 600.))),
_ => canvas::Event::Mouse(mouse::Event::CursorLeft),
};
let message = c
.update(
&mut state,
&event,
current_bounds,
mouse::Cursor::Available(end),
)
.and_then(|a| a.into_inner().0);
assert!(message.is_none());
}
_ => {
release = Point::new(-100., -100.);
assert!(
send(
&c,
&mut state,
mouse::Event::CursorMoved { position: release },
release
)
.is_none()
);
}
}
let message = c
.update(
&mut state,
&canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
current_bounds,
mouse::Cursor::Available(release),
)
.and_then(|a| a.into_inner().0);
assert!(message.is_none(), "cancellation case {cancel}");
assert!(state.route_drag.is_none(), "cancellation case {cancel}");
assert_eq!(app.document.bundle(), &before);
}
}
#[test]
fn attachment_wheel_and_middle_pan_cancel_before_same_batch_left_release() {
for wheel in [true, false] {
let mut app = fixture();
let before = app.document.bundle().clone();
let revision = app.document.revision();
let c = canvas(&app, 1.);
let (start, end) = positions(&c);
let mut state = State::default();
for event in [
mouse::Event::CursorMoved { position: start },
mouse::Event::ButtonPressed(mouse::Button::Left),
mouse::Event::CursorMoved { position: end },
] {
assert!(send(&c, &mut state, event, end).is_none());
}
let camera_message = send(
&c,
&mut state,
if wheel {
mouse::Event::WheelScrolled {
delta: mouse::ScrollDelta::Lines { x: 0., y: 1. },
}
} else {
mouse::Event::ButtonPressed(mouse::Button::Middle)
},
end,
);
assert_eq!(camera_message.is_some(), wheel);
assert!(state.route_drag.is_none());
// Deliberately retain the same canvas and defer App camera dispatch.
assert!(
send(
&c,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
end
)
.is_none()
);
if !wheel {
assert!(
send(
&c,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Middle),
end
)
.is_none()
);
}
if let Some(message) = camera_message {
assert!(matches!(message, Message::Zoom(_)));
let _ = app.update(message);
}
assert_eq!(app.document.revision(), revision);
assert_eq!(app.document.bundle(), &before);
}
}
#[test]
fn attachment_fast_release_uses_release_position_and_stays_in_one_view() {
let mut app = fixture();
let original = app.active_diagram.unwrap();
let _ = app.update(Message::DuplicateDiagram(original));
let other = app.active_diagram.unwrap();
assert_ne!(original, other);
app.active_diagram = Some(original);
let edge = app.project().diagrams[&original].routes[0].edge;
let _ = app.update(Message::Select(edge));
let untouched = app.project().diagrams[&other].clone();
let c = canvas(&app, 0.7);
let (start, end) = positions(&c);
let mut state = State::default();
assert!(
send(
&c,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Left),
start
)
.is_none()
);
let message = send(
&c,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
end,
)
.expect("release completes drag without final move");
let _ = app.update(message);
assert_eq!(
app.project().diagrams[&original].routes[0].source_attachment,
Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.25
})
);
assert_eq!(app.project().diagrams[&other], untouched);
}
#[test]
fn attachment_stale_context_wrong_placement_and_unowned_route_fields_are_rejected() {
let mut app = fixture();
let before = app.document.bundle().clone();
let message = drag(&app, 1.);
let Message::Route(RouteEvent::CommitAttachment(context, route, end, node)) = message else {
panic!()
};
let mut changed = route.clone();
changed.waypoints.clear();
let _ = app.update(Message::Route(RouteEvent::CommitAttachment(
context, changed, end, node,
)));
let _ = app.update(Message::Route(RouteEvent::CommitAttachment(
context,
route.clone(),
end,
Id::new_v4(),
)));
let _ = app.update(Message::Escape);
let _ = app.update(Message::Route(RouteEvent::CommitAttachment(
context, route, end, node,
)));
assert_eq!(app.document.bundle(), &before);
}
#[test]
fn attachment_reset_preserves_opposite_end_pins_label_and_semantics() {
let mut app = fixture();
let before = app.project().clone();
let _ = app.update(Message::Route(RouteEvent::ResetAttachment(EdgeEnd::Target)));
let mut expected = before;
expected
.diagrams
.get_mut(&app.active_diagram.unwrap())
.unwrap()
.routes[0]
.target_attachment = None;
assert_eq!(*app.project(), expected);
let revision = app.document.revision();
let _ = app.update(Message::Route(RouteEvent::ResetAttachment(EdgeEnd::Target)));
assert_eq!(app.document.revision(), revision);
}
#[test]
fn coincident_attachment_handles_honor_explicit_end_choice() {
let mut app = fixture();
let _ = app.update(Message::Route(RouteEvent::ChooseAttachment(
EdgeEnd::Target,
)));
let c = canvas(&app, 1.);
let mut scene = Scene {
work: None,
view: projection::project_view(c.project, c.diagram),
routed: Arc::new(route_diagram(
c.project,
&c.project.diagrams[&c.diagram],
&projection::project_view(c.project, c.diagram),
)),
};
let routed = Arc::make_mut(&mut scene.routed);
let edge = routed.edges.get_mut(&c.selected).unwrap();
let start = *edge.points.first().unwrap();
*edge.points.last_mut().unwrap() = start;
assert_eq!(
c.attachment_at(&scene, point(start)).unwrap().0,
EdgeEnd::Target
);
let mut source = c;
source.selected_attachment = Some(EdgeEnd::Source);
assert_eq!(
source.attachment_at(&scene, point(start)).unwrap().0,
EdgeEnd::Source
);
}
@@ -62,6 +62,7 @@ fn canvas(app: &App) -> DiagramCanvas<'_> {
pan: app.pan,
context: app.route_context().unwrap(),
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: app.connection.as_ref(),
@@ -70,6 +70,7 @@ fn canvas(app: &App, zoom: f32) -> DiagramCanvas<'_> {
pan: app.pan,
context: app.route_context().unwrap(),
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: app.connection.as_ref(),
@@ -16,6 +16,7 @@ fn canvas(project: &Project, diagram: Id, zoom: f32) -> DiagramCanvas<'_> {
epoch: 0,
},
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -186,6 +186,7 @@ mod tests {
pan: Vector::ZERO,
context: app.route_context().unwrap(),
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
+2 -2
View File
@@ -344,7 +344,7 @@ fn article(topic: Topic) -> Article {
&[
"Select an architecture connection and drag a segment to add a bend. Right-click a line for Add bend here; right-click a circle for Remove bend. With the canvas focused, Insert adds a bend and Delete removes the selected bend. Drag the label independently. Escape cancels a gesture; Undo reverses one completed drag. Shift+F10 opens the selected diagram item's context menu; use arrow keys and Enter to choose an action.",
"Automatic orthogonal routing avoids cards and container headers where space permits. Amber connections need attention: move an obstructing element or a bend. In Select / move, drag a port to any border of its owner. Manual positions are per diagram; right-click Reset automatic position restores connection-aware placement. Ports remain attached to their actual owners. Manual routes are saved separately for each diagram and appear in source, API and SVG export.",
"Right-click a connection and choose Reset routing to clear its pins and label position in this view. The Properties Actions menu provides the same commands. Auto-layout preserves pins. Moving one endpoint keeps pins fixed; moving a container carrying both endpoints translates their pins. When folding promotes an endpoint, expand it again to resume and edit its saved route.",
"Select a connection and drag a square endpoint handle along its element boundary to move its attachment in this view without changing what it connects. If handles overlap, right-click the selected connection or use its Actions menu and choose Move source attachment or Move target attachment, then drag the handle. Connections to semantic ports retain their port attachment; move the port instead. Reset source attachment or Reset target attachment restores that end's automatic placement while preserving the other end, pins and label. Reset routing clears all these route overrides in this view. Auto-layout preserves pins. Moving one endpoint keeps pins fixed; moving a container carrying both endpoints translates their pins. When folding promotes an endpoint, expand it again to resume and edit its saved route.",
],
),
explain(
@@ -564,7 +564,7 @@ fn article(topic: Topic) -> Article {
"Optionally select Create the first state view, then Create and open. The new machine starts without regions or states. For an existing machine, Create diagram places its existing regions and vertices in a saved view; + View creates another independent layout.",
"Choose + Region and click the highlighted machine, or a State/Mode for a nested region. Choose Initial, State, Mode or another kind from + State / symbol, then click its highlighted owning region. Rename the new element in Properties.",
"Choose Transition and drag from a source vertex to a compatible target, or click the source and then the target. In Select / move, drag states or containers to arrange them; a container moves its descendant placements without changing ownership.",
"Select a drawn transition and drag its endpoint handle to reconnect it. Its right-click menu also offers Reconnect source and Reconnect target; choose one, then click a compatible vertex.",
"Select a drawn transition and drag its endpoint handle to reconnect it. Its right-click menu also offers Reconnect source and Reconnect target; choose one, then click a compatible vertex. To change only where a line touches its existing vertex, choose Adjust source attachment or Adjust target attachment, then drag the highlighted tip along the boundary. Reset source attachment or Reset target attachment restores automatic placement for that end without changing the transition's meaning or other route settings. Escape cancels an attachment drag.",
"Right-click a state or the background for New transition with details… to create Local, Internal or timed behavior. Right-click an existing transition and choose Edit transition to edit its kind, triggers, guard and effects. EntryPoint outgoing transitions use Local. Apply accepts the complete form; invalid drafts remain available for correction.",
],
),
+57
View File
@@ -240,6 +240,33 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
.is_none_or(|(s, t)| s == e.source && t == e.target)
});
let route = diagram.and_then(|d| d.routes.iter().find(|r| r.edge == id));
if let Some((diagram, view)) = diagram.zip(view.as_ref()) {
for (end, choose, reset) in [
(
EdgeEnd::Source,
"Move source attachment",
"Reset source attachment",
),
(
EdgeEnd::Target,
"Move target attachment",
"Reset target attachment",
),
] {
let eligible = editable
&& route_edit::attachment_node(p, diagram, view, id, end).is_some();
entries.push(item(
choose,
Message::Route(route_edit::Event::ChooseAttachment(end)),
eligible,
));
entries.push(item(
reset,
Message::Route(route_edit::Event::ResetAttachment(end)),
eligible && route.is_some_and(|route| route.attachment(end).is_some()),
));
}
}
if let Some(point) = request.route_point {
entries.push(
item(
@@ -900,6 +927,36 @@ mod tests {
assert_eq!(app.document.bundle(), &changed);
}
#[test]
fn attachment_end_choice_is_captured_without_mutating_source_and_stale_menu_is_rejected() {
let mut project = Project::empty();
let source = project.add(Kind::Activity, "Send", 0., 0.);
let target = project.add(Kind::Activity, "Receive", 650., 0.);
let edge = project.relate(RelationKind::Interaction, "Report", source, target);
let mut app = App::with_project(project);
let before = app.document.bundle().clone();
let mut request = Request::object(edge, Point::ORIGIN);
request.canvas = app.route_context();
send(&mut app, Message::Menu(Event::Open(request.clone())));
let action = choose(&app, "Move target attachment");
send(&mut app, action);
assert_eq!(
app.selected_attachment,
Some((app.route_context().unwrap(), edge, EdgeEnd::Target))
);
assert_eq!(app.document.bundle(), &before);
request.canvas = app.route_context();
send(&mut app, Message::Menu(Event::Open(request)));
let stale = choose(&app, "Move source attachment");
send(&mut app, Message::Escape);
send(&mut app, stale);
assert!(
app.selected_attachment
.is_none_or(|(context, _, _)| Some(context) != app.route_context())
);
assert_eq!(app.document.bundle(), &before);
}
#[test]
fn show_from_explorer_places_unfolds_and_keeps_semantic_selection() {
let mut p = Project::empty();
+62 -1
View File
@@ -14,6 +14,9 @@ pub struct Context {
pub enum Event {
SelectBend(Id, usize),
Commit(Context, EdgeRoute, Option<usize>),
CommitAttachment(Context, EdgeRoute, EdgeEnd, Id),
ChooseAttachment(EdgeEnd),
ResetAttachment(EdgeEnd),
AddBend,
AddBendAt(oa_core::Position),
RemoveBend,
@@ -23,7 +26,14 @@ pub enum Event {
#[derive(Debug, Clone)]
pub enum Handle {
Bend(usize),
Label { original: Position },
Label {
original: Position,
},
Attachment {
end: EdgeEnd,
owner: projection::ProjectedNode,
original: Option<EdgeAttachment>,
},
}
#[derive(Debug, Clone)]
@@ -58,8 +68,57 @@ impl Gesture {
};
}
}
Handle::Attachment { .. } => {}
}
}
pub fn move_attachment_to(&mut self, point: Point, zoom: f32) {
let Handle::Attachment {
end,
ref owner,
original,
} = self.handle
else {
self.move_to(point, zoom);
return;
};
if point.distance(self.start) * zoom < 3. {
self.route.set_attachment(end, original);
self.changed = false;
return;
}
self.changed = true;
let attachment = attachment_from_point(
owner,
position(point),
self.route.attachment(end).map(|a| a.side),
6. / zoom,
);
self.route.set_attachment(end, Some(attachment));
}
}
/// The painted endpoint must still be the actual unported placement, not a folded proxy.
pub fn attachment_node<'a>(
project: &Project,
diagram: &Diagram,
view: &'a projection::ProjectedView,
edge: Id,
end: EdgeEnd,
) -> Option<&'a projection::ProjectedNode> {
if semantic_port_endpoint(project, edge, end).is_some() {
return None;
}
let projected = view.edges.iter().find(|e| e.id == edge)?;
let actual = projection::edge_endpoint_placements(project, diagram, edge)?;
if actual != (projected.source, projected.target) {
return None;
}
let placement = match end {
EdgeEnd::Source => projected.source,
EdgeEnd::Target => projected.target,
};
view.nodes.iter().find(|node| node.id == placement)
}
pub fn stored_route(diagram: &Diagram, edge: Id) -> EdgeRoute {
@@ -72,6 +131,8 @@ pub fn stored_route(diagram: &Diagram, edge: Id) -> EdgeRoute {
edge,
waypoints: Vec::new(),
label: None,
source_attachment: None,
target_attachment: None,
})
}
+2
View File
@@ -329,6 +329,8 @@ fn reopening_canvas_for_a_modal_preserves_viewport_after_route_edits() {
diagram,
route: EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![Position { x: 400., y: 400. }],
label: Some(EdgeLabel {
fraction: 0.5,
+2
View File
@@ -112,6 +112,8 @@ mod tests {
});
view.routes.push(EdgeRoute {
edge: exchange,
source_attachment: None,
target_attachment: None,
waypoints: vec![Position { x: 420., y: 440. }],
label: Some(EdgeLabel {
fraction: 0.4,
+6
View File
@@ -466,6 +466,12 @@ fn diagram(app: &App) -> View<'_, Message> {
zoom: app.zoom,
pan: app.pan,
context: app.route_context().unwrap(),
selected_attachment: app
.selected_attachment
.filter(|(context, edge, _)| {
Some(*context) == app.route_context() && *edge == app.selected
})
.map(|(_, _, end)| end),
selected_bend: app
.selected_bend
.filter(|(edge, _)| *edge == app.selected)
@@ -234,6 +234,8 @@ fn basic_transition_reconcile_keeps_existing_selected_view_placement_and_route_e
.routes
.push(EdgeRoute {
edge: trace,
source_attachment: None,
target_attachment: None,
waypoints: vec![Position { x: 301., y: 507. }],
label: Some(EdgeLabel {
fraction: 0.35,