Add direct diagram connections, movable ports, and contextual authoring
This commit is contained in:
+88
-299
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user