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
+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,
});