Add direct diagram connections, movable ports, and contextual authoring

This commit is contained in:
2026-09-06 02:13:49 +03:00
parent 35133de650
commit fef2e0dbce
39 changed files with 7076 additions and 499 deletions
+88 -299
View File
@@ -41,22 +41,6 @@ pub fn heading<'a>(title: &str, subtitle: &str) -> View<'a, Message> {
.into()
}
fn port_role(element: &oa_core::Element) -> String {
match port_contract(element) {
Some(PortContract::FunctionInput { .. }) => "Input".into(),
Some(PortContract::FunctionOutput { .. }) => "Output".into(),
Some(PortContract::Component {
kind: ComponentPortKind::Standard,
..
}) => "Standard".into(),
Some(PortContract::Component {
kind: ComponentPortKind::Flow,
orientation,
}) => format!("Flow · {orientation:?}"),
_ => "Legacy".into(),
}
}
pub fn view(app: &App) -> View<'_, Message> {
let p = app.project();
let header = container(
@@ -411,6 +395,7 @@ fn diagram(app: &App) -> View<'_, Message> {
.filter(|(edge, _)| *edge == app.selected)
.map(|(_, i)| i),
editable: !app.draft_dirty() && !app.source_dirty(),
connection: app.connection.as_ref(),
})
.width(Fill)
.height(Fill),
@@ -439,7 +424,20 @@ fn diagram(app: &App) -> View<'_, Message> {
.center(Fill)
.into()
};
let mut tools = column![toolbar, modes].spacing(8);
let choose = |tool: Option<crate::connection_tool::Tool>| {
let active = app.connection.as_ref().map(|s| s.tool) == tool;
button(text(tool.map_or("Select / move", |t| t.label())).size(12))
.padding([8, 10])
.on_press(Message::Connection(crate::connection_edit::Event::Choose(
tool,
)))
.style(if active { button::primary } else { style::nav })
};
let mut palette = row![choose(None)].spacing(4);
for tool in crate::connection_tool::Tool::for_layer(app.layer) {
palette = palette.push(choose(Some(*tool)));
}
let mut tools = column![toolbar, modes, palette].spacing(8);
if let Some(diagram) = app.active_diagram {
let view = oa_core::projection::project_view(app.project(), diagram);
let edge = view.edges.iter().find(|edge| edge.id == app.selected);
@@ -451,39 +449,24 @@ fn diagram(app: &App) -> View<'_, Message> {
)
.is_some_and(|(source, target)| source != edge.source || target != edge.target)
});
let enabled = edge.is_some() && !promoted && !app.draft_dirty() && !app.source_dirty();
let route = app.project().diagrams[&diagram]
.routes
.iter()
.find(|r| r.edge == app.selected);
let bend = app.selected_bend.filter(|(edge, index)| {
*edge == app.selected && route.is_some_and(|r| *index < r.waypoints.len())
});
// Keep canvas origin/size stable when switching between a node and edge.
tools = tools.push(
row![
button("Add bend").padding(8).on_press_maybe(
(enabled && route.is_none_or(|r| r.waypoints.len() < 128))
.then_some(Message::Route(crate::route_edit::Event::AddBend))
),
button("Remove bend").padding(8).on_press_maybe(
(enabled && bend.is_some())
.then_some(Message::Route(crate::route_edit::Event::RemoveBend))
),
button("Reset routing").padding(8).on_press_maybe(
(enabled && route.is_some())
.then_some(Message::Route(crate::route_edit::Event::Reset))
),
]
.spacing(7),
);
tools = tools.push(label(if promoted {
let hint = if let Some(session) = &app.connection {
if session.source.is_some() {
if session.candidates.is_empty() {
"No compatible targets are visible. Expand containers or show an endpoint · Esc cancels"
} else {
"Choose a highlighted target · middle-drag or arrow keys pan · Esc cancels"
}
} else {
session.tool.hint()
}
} else if promoted {
"Expand endpoint containers to edit this connection route"
} else if edge.is_some() {
"Drag a line to add a bend · drag circles or the label · Esc cancels"
"Right-click for routing · drag line / bend / label · Insert adds a bend · Esc cancels"
} else {
"Drag to arrange · select a connection to edit its route · scroll to zoom"
}));
"Right-click for actions · drag to arrange · scroll to zoom"
};
tools = tools.push(container(label(hint)).height(30));
}
column![container(tools).padding(14), canvas].into()
}
@@ -554,7 +537,7 @@ fn inspector(app: &App) -> View<'_, Message> {
row![
label("PROPERTIES"),
Space::new().width(Fill),
action("Source", Message::SourceForSelection)
crate::model_menu::trigger(app)
]
.align_y(iced::Center)
]
@@ -622,83 +605,47 @@ fn inspector(app: &App) -> View<'_, Message> {
.width(Fill),
);
}
if e.kind() == Kind::Port {
body = body.push(label(
"Shown automatically on the boundary of its visible owner.",
));
} else if app
.active_diagram
.and_then(|id| p.diagrams.get(&id))
.is_some_and(|d| !d.projection.includes(e.kind()))
{
body = body.push(label(
"This element is outside the selected view's content.",
));
body = body.push(action(
"Use all-relationships view",
Message::Projection(ProjectionKind::Overview),
));
} else if let Some(node) = app.selected_node {
body = body.push(
row![
action("Placement", Message::Select(node)),
action("Fold / unfold", Message::ToggleCollapse),
action("Hide", Message::HideSelected)
]
.spacing(2),
);
} else {
body = body.push(action("Show in this diagram", Message::PlaceSelected));
}
}
let kind = p.elements.get(&app.selected).map(Element::kind);
let port_actions = crate::port_workflow::actions(p, app.selected);
if !port_actions.is_empty() {
body = body.push(label("PORTS & EXCHANGES"));
for port_action in port_actions {
use crate::port_workflow::Action;
if matches!(port_action, Action::Flow | Action::Standard) {
continue;
}
let caption = match port_action {
Action::Component => "+ Add port…",
Action::Input => "+ Input port…",
Action::Output => "+ Output port…",
_ => port_action.label(),
};
body = body.push(action(caption, Message::PortAction(port_action)).width(Fill));
}
if matches!(kind, Some(Kind::System | Kind::SystemActor)) {
body = body.push(label(
"Ports belong to this participant and connect boundary exchanges.",
));
}
}
let ports: Vec<_> = p
.children(app.selected)
.filter(|e| e.kind() == Kind::Port)
.collect();
if !ports.is_empty() {
body = body.push(label(format!("OWNED PORTS · {}", ports.len())));
for port in ports {
body = body.push(
action(
&format!("{} · {}", port.name, port_role(port)),
Message::Select(port.id),
)
.width(Fill),
);
}
// Keep a discoverable primary action; secondary actions live in Actions.
if p.elements
.get(&app.selected)
.is_some_and(|e| matches!(e.kind(), Kind::System | Kind::SystemActor))
{
body = body.push(
button(text("+ Add port…").size(12))
.padding(9)
.width(Fill)
.on_press(Message::PortAction(crate::port_workflow::Action::Component)),
);
}
if p.elements
.get(&app.selected)
.is_some_and(|e| matches!(port_contract(e), Some(PortContract::Component { .. })))
{
let draw = app.page == Page::Diagram
&& crate::connection_tool::Tool::BoundaryExchange.can_start(p, app.selected)
&& app.active_diagram.is_some_and(|diagram| {
crate::connection_edit::visible(&projection::project_view(p, diagram))
.any(|id| id == app.selected)
});
body = body.push(
button(text("Connect boundary exchange…").size(12))
.padding(10)
.width(Fill)
.on_press(Message::AddRelationKind(
button(
text(if draw {
"Draw boundary exchange"
} else {
"Configure boundary exchange…"
})
.size(12),
)
.padding(9)
.width(Fill)
.on_press(if draw {
Message::Connection(crate::connection_edit::Event::Begin(
crate::connection_tool::Tool::BoundaryExchange,
app.selected,
))
} else {
Message::AddRelationKind(
RelationKind::BoundaryExchange,
p.elements.get(&app.selected).is_some_and(|e| {
matches!(
@@ -709,62 +656,8 @@ fn inspector(app: &App) -> View<'_, Message> {
})
)
}),
)),
);
body = body.push(label(
"Choose the other participant's port in the next step.",
));
}
let contextual: &[(&str, RelationKind, bool)] = match kind {
Some(Kind::OperationalMission | Kind::Mission) => &[
(
"Exploit capability…",
RelationKind::MissionCapability,
false,
),
(
"Involve participant…",
RelationKind::MissionInvolvement,
true,
),
],
Some(Kind::Capability | Kind::SystemCapability) => &[
(
"Include capability…",
RelationKind::CapabilityInclude,
false,
),
("Extend capability…", RelationKind::CapabilityExtend, false),
(
"Specialize capability…",
RelationKind::CapabilityGeneralization,
false,
),
(
"Involve participant or work…",
RelationKind::CapabilityInvolvement,
true,
),
],
_ => &[],
};
if !contextual.is_empty() {
let choices = contextual
.iter()
.map(|(label, kind, incoming)| crate::workflow::RelationAction {
label,
kind: *kind,
incoming: *incoming,
})
.collect::<Vec<_>>();
body = body.push(
pick_list(choices, None::<crate::workflow::RelationAction>, |choice| {
Message::AddRelationKind(choice.kind, choice.incoming)
})
.placeholder("Modeling actions…")
.padding(9)
.text_size(12)
.width(Fill),
)
}),
);
}
if let Some(r) = p.relations.get(&app.selected) {
@@ -788,82 +681,6 @@ fn inspector(app: &App) -> View<'_, Message> {
if let Some(d) = &app.draft {
body = body.push(forms::view(d, p));
}
let links = p
.relations
.values()
.filter(|r| r.source() == app.selected || r.target() == app.selected)
.collect::<Vec<_>>();
if !links.is_empty() {
body = body
.push(Space::new().height(6))
.push(label("RELATED ELEMENTS"));
for r in links {
let other = if r.source() == app.selected {
r.target()
} else {
r.source()
};
body = body.push(
container(
column![
action(
&format!("{} · {}", r.kind(), short(&r.name, 24)),
Message::Select(r.id)
)
.width(Fill),
action(
&format!("→ {}", short(p.named(other), 34)),
Message::Select(other)
)
.width(Fill)
]
.spacing(2),
)
.style(style::subtle),
);
}
}
for t in p
.traces
.values()
.filter(|t| t.oa == app.selected || t.sa == app.selected)
{
let other = if t.oa == app.selected { t.sa } else { t.oa };
body = body.push(
action(
&format!(
"{}: {}",
if t.oa == app.selected {
"Realized by"
} else {
"Realizes"
},
short(p.named(other), 29)
),
Message::Select(other),
)
.width(Fill),
);
}
if p.layer_of(app.selected).is_some() {
body = body
.push(action("+ Relationship", Message::AddRelation).width(Fill))
.push(
button(text("Create / update counterpart…").size(12))
.padding(10)
.on_press(Message::StartTransition)
.width(Fill),
);
}
body = body.push(action("+ Artifact reference", Message::AddArtifact).width(Fill));
if app.selected != p.id {
body = body.push(
button(text("Delete from model…").size(11))
.padding(9)
.style(button::danger)
.on_press(Message::Delete),
);
}
container(
column![
tabs,
@@ -912,7 +729,7 @@ fn data_catalog(app: &App) -> View<'_, Message> {
.color(style::kind_color(kind)),
);
for e in elements {
content = content.push(
content = content.push(crate::context_menu::Area::new(
button(
row![
column![
@@ -942,7 +759,8 @@ fn data_catalog(app: &App) -> View<'_, Message> {
style::nav
})
.on_press(Message::Select(e.id)),
);
move |anchor| crate::model_menu::open(e.id, anchor),
));
}
}
scrollable(content.padding(24)).into()
@@ -1036,58 +854,29 @@ fn traces(app: &App) -> View<'_, Message> {
fn source_files(app: &App) -> View<'_, Message> {
let workspace = &app.source_workspace;
let mut files = column![].spacing(3);
for (path, buffer) in &workspace.files {
let detached = !workspace.reachable.contains(path);
let errors = app
.source_diagnostics
.iter()
.filter(|issue| issue.path == *path)
.count();
let filename = path.rsplit('/').next().unwrap_or(path);
let detail: String = if detached {
"Detached · retained on disk".into()
} else if path == &workspace.entry {
"Project entry".into()
} else {
path.rsplit_once('/')
.map_or("Included source", |(parent, _)| parent)
.into()
};
let caption = format!(
"{}{}{}",
if buffer.dirty() { "* " } else { "" },
short(filename, 27),
if errors > 0 { " !" } else { "" }
);
files = files.push(
button(column![text(caption).size(12), label(detail)].spacing(3))
.padding(8)
.width(Fill)
.on_press(Message::SourceFile(path.clone()))
.style(if &workspace.active == path {
style::selected
} else {
style::nav
}),
);
if detached && !buffer.dirty() {
files = files.push(
action(
"Close detached buffer",
Message::SourceCloseFile(path.clone()),
)
.width(Fill),
);
}
}
let panel = column![
text("SOURCE FILES").size(13),
row![
text("SOURCE FILES").size(13).width(Fill),
crate::context_menu::Area::left(
action("Actions ▾", Message::Menu(crate::model_menu::Event::Close)),
move |anchor| Message::SourceTree(crate::source_tree::Event::FileMenu(
workspace.active.clone(),
anchor
))
)
]
.align_y(iced::Center),
label(format!(
"{} files · one system model",
workspace.files.len()
)),
scrollable(files).height(Fill),
row![
action("Collapse all", Message::SourceTreeCollapse),
action("Locate", Message::SourceTreeLocate)
]
.spacing(3),
crate::source_tree::view(workspace, &app.source_diagnostics, &app.source_tree)
.map(Message::SourceTree),
action("Load includes", Message::SourceLoadIncludes).width(Fill),
text_input("oa/new-file.arcadia", &workspace.new_path)
.size(12)