Make participant ports and hierarchical model navigation discoverable
Add focused containment browsing, contextual paged search, clear outline depth and direct component-port creation and connection actions. Share exact boundary feasibility checks to avoid whole-project validation for each picker candidate. Preserve source/property drafts and interrupted-save recovery state, document source-project behavior, and record Windows/Linux native verification and independent review fixes.
This commit is contained in:
+119
-166
@@ -40,6 +40,22 @@ 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(
|
||||
@@ -145,6 +161,16 @@ pub fn view(app: &App) -> View<'_, Message> {
|
||||
&& p.elements
|
||||
.get(&app.selected)
|
||||
.is_some_and(|e| e.layer() == app.layer && e.kind().is_performer());
|
||||
if crate::port_workflow::actions(p, app.selected)
|
||||
.contains(&crate::port_workflow::Action::Component)
|
||||
{
|
||||
creation = creation.push(
|
||||
button(text("+ Add port to selected…").size(12))
|
||||
.padding(9)
|
||||
.width(Fill)
|
||||
.on_press(Message::PortAction(crate::port_workflow::Action::Component)),
|
||||
);
|
||||
}
|
||||
creation = creation.push(
|
||||
row![
|
||||
button(text("+ At root").size(11))
|
||||
@@ -180,76 +206,15 @@ pub fn view(app: &App) -> View<'_, Message> {
|
||||
.size(12)
|
||||
.padding(9),
|
||||
);
|
||||
let query = app.search.to_lowercase();
|
||||
let mut elements = p
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| {
|
||||
e.layer() == app.layer
|
||||
&& (query.is_empty()
|
||||
|| e.name.to_lowercase().contains(&query)
|
||||
|| e.alias
|
||||
.to_lowercase()
|
||||
.contains(query.trim_start_matches('@')))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
elements.sort_by_key(|e| (tree_path(p, e.id), e.alias.clone()));
|
||||
let mut explorer = column![].spacing(2);
|
||||
if elements.is_empty() {
|
||||
explorer = explorer.push(label(if query.is_empty() {
|
||||
"No elements in this perspective. Create your first element below."
|
||||
} else {
|
||||
"No matching elements in this perspective."
|
||||
}));
|
||||
}
|
||||
for e in elements {
|
||||
if query.is_empty() && tree_hidden(app, e.id) {
|
||||
continue;
|
||||
}
|
||||
let depth = ancestors(p, e.id).min(5);
|
||||
let children = p.children(e.id).next().is_some();
|
||||
explorer = explorer.push(row![
|
||||
button(
|
||||
text(if children {
|
||||
if app.explorer_collapsed.contains(&e.id) {
|
||||
"›"
|
||||
} else {
|
||||
"⌄"
|
||||
}
|
||||
} else {
|
||||
"·"
|
||||
})
|
||||
.size(14)
|
||||
.color(style::kind_color(e.kind()))
|
||||
)
|
||||
.style(style::nav)
|
||||
.padding([6, 3])
|
||||
.on_press_maybe(children.then_some(Message::CollapseExplorer(e.id))),
|
||||
button(column![text(short(&e.name, 29)).size(11), label(e.kind().label())].spacing(2))
|
||||
.padding(iced::Padding {
|
||||
left: 3. + depth as f32 * 8.,
|
||||
right: 6.,
|
||||
top: 6.,
|
||||
bottom: 6.
|
||||
})
|
||||
.width(Fill)
|
||||
.on_press(Message::Select(e.id))
|
||||
.style(if app.selected == e.id {
|
||||
style::selected
|
||||
} else {
|
||||
style::nav
|
||||
})
|
||||
]);
|
||||
}
|
||||
nav = nav
|
||||
.push(
|
||||
scrollable(explorer.padding(iced::Padding {
|
||||
right: 10.,
|
||||
..Default::default()
|
||||
}))
|
||||
.id("model-explorer")
|
||||
.height(Fill),
|
||||
row![
|
||||
action("Collapse all", Message::ExplorerCollapseAll),
|
||||
action("Locate", Message::ExplorerLocate)
|
||||
]
|
||||
.spacing(3),
|
||||
)
|
||||
.push(crate::explorer_view::sidebar(app))
|
||||
.push(creation)
|
||||
.push(action("Project properties", Message::SelectProject).width(Fill));
|
||||
let sidebar = container(if app.page == Page::Text {
|
||||
@@ -258,12 +223,13 @@ pub fn view(app: &App) -> View<'_, Message> {
|
||||
nav.into()
|
||||
})
|
||||
.padding(14)
|
||||
.width(230)
|
||||
.width(260)
|
||||
.height(Fill)
|
||||
.style(style::panel);
|
||||
let main = match app.page {
|
||||
Page::Diagram => diagram(app),
|
||||
Page::Catalog | Page::Data => catalog(app),
|
||||
Page::Catalog => crate::explorer_view::catalog(app),
|
||||
Page::Data => data_catalog(app),
|
||||
Page::Behavior => crate::behavior::view(app),
|
||||
Page::Traces => traces(app),
|
||||
Page::Text => source(app),
|
||||
@@ -361,49 +327,6 @@ fn modal<'a>(base: View<'a, Message>, content: View<'a, Message>) -> View<'a, Me
|
||||
]
|
||||
.into()
|
||||
}
|
||||
fn ancestors(p: &Project, id: Id) -> usize {
|
||||
let mut current = p.elements.get(&id).and_then(|e| e.parent);
|
||||
let mut depth = 0;
|
||||
while let Some(id) = current {
|
||||
depth += 1;
|
||||
if depth > 64 {
|
||||
break;
|
||||
}
|
||||
current = p.elements.get(&id).and_then(|e| e.parent);
|
||||
}
|
||||
depth
|
||||
}
|
||||
fn tree_hidden(app: &App, id: Id) -> bool {
|
||||
let mut current = app.project().elements.get(&id).and_then(|e| e.parent);
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
while let Some(id) = current {
|
||||
if !seen.insert(id) {
|
||||
return true;
|
||||
}
|
||||
if app.explorer_collapsed.contains(&id) {
|
||||
return true;
|
||||
}
|
||||
current = app.project().elements.get(&id).and_then(|e| e.parent);
|
||||
}
|
||||
false
|
||||
}
|
||||
fn tree_path(p: &Project, id: Id) -> String {
|
||||
let mut chain = vec![];
|
||||
let mut current = Some(id);
|
||||
while let Some(id) = current {
|
||||
if chain.len() > 64 {
|
||||
break;
|
||||
}
|
||||
let Some(e) = p.elements.get(&id) else {
|
||||
break;
|
||||
};
|
||||
chain.push(e.name.clone());
|
||||
current = e.parent;
|
||||
}
|
||||
chain.reverse();
|
||||
chain.join("/")
|
||||
}
|
||||
|
||||
fn diagram(app: &App) -> View<'_, Message> {
|
||||
let diagrams = app
|
||||
.project()
|
||||
@@ -566,6 +489,27 @@ fn diagram(app: &App) -> View<'_, Message> {
|
||||
|
||||
fn inspector(app: &App) -> View<'_, Message> {
|
||||
let p = app.project();
|
||||
if app.source_dirty() {
|
||||
let mut notice = column![
|
||||
text("Source drafts pending").size(17),
|
||||
label("Properties become editable after source drafts are applied or reverted."),
|
||||
action("Open source drafts", Message::Page(Page::Text))
|
||||
]
|
||||
.spacing(12);
|
||||
if app.draft_dirty() {
|
||||
notice = notice
|
||||
.push(label(
|
||||
"A property draft is also retained. Revert it to apply the source drafts.",
|
||||
))
|
||||
.push(action("Revert property draft", Message::RevertProperties));
|
||||
}
|
||||
return container(notice)
|
||||
.padding(14)
|
||||
.width(320)
|
||||
.height(Fill)
|
||||
.style(style::panel)
|
||||
.into();
|
||||
}
|
||||
let tabs = row![
|
||||
action(
|
||||
"Properties",
|
||||
@@ -709,17 +653,66 @@ fn inspector(app: &App) -> View<'_, Message> {
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
if p.elements
|
||||
.get(&app.selected)
|
||||
.is_some_and(|e| matches!(port_contract(e), Some(PortContract::Component { .. })))
|
||||
{
|
||||
body = body.push(
|
||||
pick_list(
|
||||
port_actions,
|
||||
None::<crate::port_workflow::Action>,
|
||||
Message::PortAction,
|
||||
)
|
||||
.placeholder("Port and exchange actions…")
|
||||
.text_size(12)
|
||||
.padding(9)
|
||||
.width(Fill),
|
||||
button(text("Connect boundary exchange…").size(12))
|
||||
.padding(10)
|
||||
.width(Fill)
|
||||
.on_press(Message::AddRelationKind(
|
||||
RelationKind::BoundaryExchange,
|
||||
p.elements.get(&app.selected).is_some_and(|e| {
|
||||
matches!(
|
||||
port_contract(e),
|
||||
Some(PortContract::Component {
|
||||
kind: ComponentPortKind::Flow,
|
||||
orientation: ComponentPortOrientation::In
|
||||
})
|
||||
)
|
||||
}),
|
||||
)),
|
||||
);
|
||||
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) => &[
|
||||
@@ -889,20 +882,11 @@ fn inspector(app: &App) -> View<'_, Message> {
|
||||
.into()
|
||||
}
|
||||
|
||||
fn catalog(app: &App) -> View<'_, Message> {
|
||||
let data = app.page == Page::Data;
|
||||
fn data_catalog(app: &App) -> View<'_, Message> {
|
||||
let p = app.project();
|
||||
let mut content = column![heading(
|
||||
if data {
|
||||
"Data & interfaces"
|
||||
} else {
|
||||
"Model explorer"
|
||||
},
|
||||
if data {
|
||||
"Define information, material and energy at the system boundary."
|
||||
} else {
|
||||
"A shared model across every diagram, editor and automation."
|
||||
}
|
||||
"Data & interfaces",
|
||||
"Define information, material and energy at the system boundary."
|
||||
)]
|
||||
.spacing(15);
|
||||
for kind in Kind::ALL {
|
||||
@@ -910,7 +894,7 @@ fn catalog(app: &App) -> View<'_, Message> {
|
||||
kind,
|
||||
Kind::ExchangeItem | Kind::DataType | Kind::DataField | Kind::Interface | Kind::Port
|
||||
);
|
||||
if data && !is_data {
|
||||
if !is_data {
|
||||
continue;
|
||||
}
|
||||
let elements = p
|
||||
@@ -960,33 +944,6 @@ fn catalog(app: &App) -> View<'_, Message> {
|
||||
);
|
||||
}
|
||||
}
|
||||
if !data {
|
||||
content = content.push(text("Relationships").size(18));
|
||||
for r in p
|
||||
.relations
|
||||
.values()
|
||||
.filter(|r| p.layer_of(r.id) == Some(app.layer))
|
||||
{
|
||||
content = content.push(
|
||||
button(
|
||||
column![
|
||||
text(format!(
|
||||
"{} → {}",
|
||||
short(p.named(r.source()), 33),
|
||||
short(p.named(r.target()), 33)
|
||||
))
|
||||
.size(12),
|
||||
label(format!("{} · {}", r.kind(), r.name))
|
||||
]
|
||||
.spacing(5),
|
||||
)
|
||||
.padding(12)
|
||||
.width(Fill)
|
||||
.style(style::nav)
|
||||
.on_press(Message::Select(r.id)),
|
||||
);
|
||||
}
|
||||
}
|
||||
scrollable(content.padding(24)).into()
|
||||
}
|
||||
|
||||
@@ -1140,11 +1097,7 @@ fn source_files(app: &App) -> View<'_, Message> {
|
||||
label("Includes define file membership. Closing a detached buffer never deletes its file."),
|
||||
]
|
||||
.spacing(9);
|
||||
if app
|
||||
.source_diagnostics
|
||||
.iter()
|
||||
.any(|issue| issue.diagnostic.code == "pending_save")
|
||||
{
|
||||
if app.pending_recovery.is_some() {
|
||||
panel = panel.push(action("Recover interrupted save", Message::SourceRecover));
|
||||
}
|
||||
panel.into()
|
||||
|
||||
Reference in New Issue
Block a user