Integrate typed port authoring and reviewable exchange workflows

This commit is contained in:
2026-09-05 20:00:21 +03:00
parent 2dd17aa693
commit fa096cefd3
21 changed files with 6449 additions and 2154 deletions
+189 -32
View File
@@ -356,14 +356,16 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
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.;
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 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 edge.id == self.selected
|| capability_label.is_some()
|| matches!(
@@ -421,34 +423,54 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
)
};
let origin = Point::new(port.position.x - 7., port.position.y - 7.);
let path = Path::rectangle(origin, Size::new(14., 14.));
let glyph = port_glyph(&self.project.elements[&port.element]);
let path = if glyph == PortGlyph::Function {
Path::new(|p| {
p.move_to(origin);
p.line_to(origin + Vector::new(14., 7.));
p.line_to(origin + Vector::new(0., 14.));
p.close();
})
} else {
Path::rectangle(origin, Size::new(14., 14.))
};
f.fill(&path, Color::WHITE);
f.stroke(
&path,
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);
f.stroke(
&Path::new(|p| {
p.move_to(center - Vector::new(4., 0.));
p.line_to(center + Vector::new(4., 0.));
p.move_to(center + Vector::new(1., -3.));
p.line_to(center + Vector::new(4., 0.));
p.line_to(center + Vector::new(1., 3.));
if port.direction == PortDirection::InOut {
p.move_to(center + Vector::new(-1., -3.));
p.line_to(center - Vector::new(4., 0.));
p.line_to(center + Vector::new(-1., 3.));
}
}),
Stroke::default().with_color(color),
);
if matches!(glyph, PortGlyph::Flow(_)) {
f.stroke(
&Path::new(|p| {
p.move_to(center - Vector::new(4., 0.));
p.line_to(center + Vector::new(4., 0.));
p.move_to(center + Vector::new(1., -3.));
p.line_to(center + Vector::new(4., 0.));
p.line_to(center + Vector::new(1., 3.));
if glyph == PortGlyph::Flow(true) {
p.move_to(center + Vector::new(-1., -3.));
p.line_to(center - Vector::new(4., 0.));
p.line_to(center + Vector::new(-1., 3.));
}
}),
Stroke::default().with_color(color),
);
}
if matches!(glyph, PortGlyph::Unspecified | PortGlyph::Legacy) {
f.fill(&Path::circle(center, 1.5), color);
}
if self.selected == port.element {
f.fill_text(Text {
content: short(self.project.named(port.element), 40),
@@ -476,6 +498,35 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
}
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum PortGlyph {
Function,
Standard,
Flow(bool),
Unspecified,
Legacy,
}
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;
@@ -731,7 +782,12 @@ pub fn export_svg(p: &Project, diagram: Id) -> String {
} else {
""
};
svg.push_str(&format!("<path d=\"M {} {} C {mid} {}, {mid} {}, {} {}\" fill=\"none\" stroke=\"#8999a9\" stroke-width=\"1.5\"{dashed} marker-end=\"url(#arrow)\"><title>{}</title></path>",a.x,a.y,a.y,b.y,b.x,b.y,escape(p.named(edge.id))));
let arrow = if edge.oriented {
" marker-end=\"url(#arrow)\""
} else {
""
};
svg.push_str(&format!("<path d=\"M {} {} C {mid} {}, {mid} {}, {} {}\" fill=\"none\" stroke=\"#8999a9\" stroke-width=\"1.5\"{dashed}{arrow}><title>{}</title></path>",a.x,a.y,a.y,b.y,b.x,b.y,escape(p.named(edge.id))));
if stereotype.is_some()
|| matches!(
p.relations.get(&edge.id).map(|r| r.kind()),
@@ -754,10 +810,37 @@ pub fn export_svg(p: &Project, diagram: Id) -> String {
}
}
for port in &view.ports {
svg.push_str(&format!("<g><title>{}</title><rect x=\"{}\" y=\"{}\" width=\"14\" height=\"14\" fill=\"white\" stroke=\"#147d7b\"/><path d=\"M {} {} h 8 l -3 -3 m 3 3 l -3 3\" fill=\"none\" stroke=\"#147d7b\"/></g>",escape(p.named(port.element)),port.position.x-7.,port.position.y-7.,port.position.x-4.,port.position.y));
if port.direction == PortDirection::InOut {
svg.push_str(&format!("<path d=\"M {} {} l -3 -3 m 3 3 l -3 3\" transform=\"rotate(180 {} {})\" fill=\"none\" stroke=\"#147d7b\"/>",port.position.x+4.,port.position.y,port.position.x,port.position.y));
let glyph = port_glyph(&p.elements[&port.element]);
let (x, y) = (port.position.x, port.position.y);
svg.push_str(&format!(
"<g><title>{}</title>",
escape(p.named(port.element))
));
if glyph == PortGlyph::Function {
svg.push_str(&format!("<path data-port-kind=\"function\" d=\"M {} {} l 14 7 l -14 7 Z\" fill=\"white\" stroke=\"#147d7b\"/>", x-7., y-7.));
} else {
let dash = if glyph == PortGlyph::Legacy {
" stroke-dasharray=\"3 2\""
} else {
""
};
svg.push_str(&format!("<rect x=\"{}\" y=\"{}\" width=\"14\" height=\"14\" fill=\"white\" stroke=\"#147d7b\"{dash}/>",x-7.,y-7.));
if matches!(glyph, PortGlyph::Flow(_)) {
svg.push_str(&format!("<path d=\"M {} {y} h 8 l -3 -3 m 3 3 l -3 3\" fill=\"none\" stroke=\"#147d7b\"/>",x-4.));
if glyph == PortGlyph::Flow(true) {
svg.push_str(&format!(
"<path d=\"M {} {} l -3 3 l 3 3\" fill=\"none\" stroke=\"#147d7b\"/>",
x - 1.,
y - 3.
));
}
} else if matches!(glyph, PortGlyph::Unspecified | PortGlyph::Legacy) {
svg.push_str(&format!(
"<circle cx=\"{x}\" cy=\"{y}\" r=\"1.5\" fill=\"#147d7b\"/>"
));
}
}
svg.push_str("</g>");
}
svg.push_str("</svg>");
svg
@@ -767,6 +850,80 @@ pub fn export_svg(p: &Project, diagram: Id) -> String {
mod tests {
use super::*;
#[test]
fn typed_function_glyphs_and_unoriented_standard_connections_have_distinct_svg() {
let p = Project::demo();
let boundary = p
.relations
.values()
.find(|r| r.kind() == RelationKind::BoundaryExchange)
.unwrap();
let mut commands = vec![];
for id in [boundary.source(), boundary.target()] {
let mut port = p.elements[&id].clone();
let Some(SharedBody::Port { contract, .. }) = port.shared_mut() else {
panic!("component port");
};
*contract = PortContract::Component {
kind: ComponentPortKind::Standard,
orientation: ComponentPortOrientation::Unset,
};
commands.push(Command::EditElement(id, port));
}
let mut connection = boundary.clone();
let RelationBody::BoundaryExchange { oriented, .. } = &mut connection.body else {
unreachable!()
};
*oriented = false;
commands.push(Command::EditRelation(boundary.id, connection));
let p = execute_batch(&p, &commands).unwrap();
let diagram = p
.diagrams
.values()
.find(|d| d.layer == Layer::Sa && d.projection == ProjectionKind::Allocation)
.unwrap()
.id;
let svg = export_svg(&p, diagram);
let title = format!("<title>{}</title>", escape(&boundary.name));
let edge_path = svg
.split(&title)
.next()
.unwrap()
.rsplit("<path ")
.next()
.unwrap();
assert!(
!edge_path.contains("marker-end"),
"Unoriented Standard connection has no arrow"
);
let view = project_view(&p, diagram);
for port in &view.ports {
let title = format!("<g><title>{}</title>", escape(p.named(port.element)));
let body = svg
.split(&title)
.nth(1)
.unwrap()
.split("</g>")
.next()
.unwrap();
match port_contract(&p.elements[&port.element]) {
Some(PortContract::FunctionInput { .. } | PortContract::FunctionOutput { .. }) => {
assert!(body.contains("data-port-kind=\"function\""))
}
Some(PortContract::Component {
kind: ComponentPortKind::Standard,
..
}) => {
assert!(body.contains("<rect "));
assert!(
!body.contains("<path "),
"Standard port has no inferred Flow arrow"
);
}
_ => {}
}
}
}
#[test]
fn svg_distinguishes_inferred_work_allocation_from_direct_allocation() {
for (layer, performer_kind, work_kind, header) in [
(