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
@@ -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",