394 lines
14 KiB
Rust
394 lines
14 KiB
Rust
//! Containment navigation: a compact outline and a focused, paged model browser.
|
||
use crate::{
|
||
app::{App, Message, Page},
|
||
explorer::Group,
|
||
style,
|
||
views::{action, heading, label, short},
|
||
};
|
||
use iced::{
|
||
Element as View, Fill,
|
||
widget::{self, Space, button, column, container, row, scrollable, text, tooltip},
|
||
};
|
||
use oa_core::{Id, Kind, RelationKind};
|
||
use std::collections::BTreeSet;
|
||
|
||
const ROW_HEIGHT: f32 = 32.;
|
||
const GROUP_HEIGHT: f32 = 36.;
|
||
const PAGE_SIZE: usize = 100;
|
||
|
||
pub fn sidebar(app: &App) -> View<'_, Message> {
|
||
let mut content = column![].spacing(0);
|
||
let mut found = false;
|
||
let query = app.search.trim();
|
||
for group in Group::ALL {
|
||
let rows = app
|
||
.explorer_index
|
||
.rows(group, query, &app.explorer_collapsed);
|
||
if rows.is_empty() {
|
||
continue;
|
||
}
|
||
found = true;
|
||
let expanded = !query.is_empty() || !app.explorer_groups_collapsed.contains(&group);
|
||
content = content.push(
|
||
button(
|
||
row![
|
||
text(if expanded { "⌄" } else { "›" }).size(16).width(18),
|
||
text(group.label(app.layer)).size(12).width(Fill),
|
||
label(app.explorer_index.roots[&group].len().to_string()),
|
||
]
|
||
.align_y(iced::Center),
|
||
)
|
||
.height(GROUP_HEIGHT)
|
||
.padding([7, 4])
|
||
// The horizontal scrollable has unbounded width: a finite header
|
||
// keeps an outline containing only collapsed groups visible.
|
||
.width(226)
|
||
.style(style::nav)
|
||
.on_press_maybe(query.is_empty().then_some(Message::ExplorerGroup(group))),
|
||
);
|
||
if !expanded {
|
||
continue;
|
||
}
|
||
for entry in rows {
|
||
let element = &app.project().elements[&entry.id];
|
||
let mut outline = row![].height(ROW_HEIGHT).align_y(iced::Center);
|
||
// Keep a constant number of widgets per row, including deep search
|
||
// paths. The full indentation remains horizontally scrollable.
|
||
outline = outline.push(Space::new().width(entry.depth as f32 * 16.));
|
||
outline = outline.push(row![
|
||
Space::new().width(8),
|
||
container(Space::new())
|
||
.width(1)
|
||
.height(ROW_HEIGHT)
|
||
.style(|_| container::Style {
|
||
background: Some(style::LINE.into()),
|
||
..Default::default()
|
||
}),
|
||
Space::new().width(7)
|
||
]);
|
||
outline = outline.push(
|
||
button(
|
||
text(if entry.has_children {
|
||
if query.is_empty() && app.explorer_collapsed.contains(&entry.id) {
|
||
"›"
|
||
} else {
|
||
"⌄"
|
||
}
|
||
} else {
|
||
"·"
|
||
})
|
||
.size(15),
|
||
)
|
||
.style(style::nav)
|
||
.width(24)
|
||
.height(ROW_HEIGHT)
|
||
.padding(4)
|
||
.on_press_maybe(
|
||
(entry.has_children && query.is_empty())
|
||
.then_some(Message::CollapseExplorer(entry.id)),
|
||
),
|
||
);
|
||
let caption = row![
|
||
container(Space::new())
|
||
.width(3)
|
||
.height(15)
|
||
.style(move |_| container::Style {
|
||
background: Some(style::kind_color(element.kind()).into()),
|
||
..Default::default()
|
||
}),
|
||
text(short(&element.name, 25 - entry.depth.min(2) * 3))
|
||
.size(12)
|
||
.wrapping(text::Wrapping::None)
|
||
.color(if entry.matched {
|
||
style::INK
|
||
} else {
|
||
style::MUTED
|
||
})
|
||
]
|
||
.spacing(7)
|
||
.align_y(iced::Center);
|
||
let select = if app.page == Page::Catalog {
|
||
Message::Explore(Some(entry.id))
|
||
} else {
|
||
Message::Select(entry.id)
|
||
};
|
||
let node = button(caption)
|
||
.width(186. - entry.depth.min(2) as f32 * 16.)
|
||
.height(ROW_HEIGHT)
|
||
.padding([7, 6])
|
||
.clip(true)
|
||
.on_press(select)
|
||
.style(if app.selected == entry.id {
|
||
style::selected
|
||
} else {
|
||
style::nav
|
||
});
|
||
outline = outline.push(tooltip(
|
||
node,
|
||
container(
|
||
column![
|
||
text(&element.name).size(13),
|
||
label(format!("{} · @{}", element.kind(), element.alias))
|
||
]
|
||
.spacing(4),
|
||
)
|
||
.padding(8)
|
||
.style(style::card),
|
||
tooltip::Position::Right,
|
||
));
|
||
let id = entry.id;
|
||
content = content.push(crate::context_menu::Area::new(outline, move |anchor| {
|
||
crate::model_menu::open(id, anchor)
|
||
}));
|
||
}
|
||
}
|
||
if !found {
|
||
content = content.push(label(if query.is_empty() {
|
||
"No elements yet. Create one below."
|
||
} else {
|
||
"No matches in this perspective."
|
||
}));
|
||
}
|
||
scrollable(content)
|
||
.id("model-explorer")
|
||
.height(Fill)
|
||
.width(Fill)
|
||
.direction(scrollable::Direction::Both {
|
||
vertical: scrollable::Scrollbar::default(),
|
||
horizontal: scrollable::Scrollbar::default(),
|
||
})
|
||
.into()
|
||
}
|
||
|
||
pub fn selection_offset(app: &App) -> widget::operation::AbsoluteOffset {
|
||
let mut y = 0.;
|
||
for group in Group::ALL {
|
||
let rows = app.explorer_index.rows(group, "", &app.explorer_collapsed);
|
||
if rows.is_empty() {
|
||
continue;
|
||
}
|
||
y += GROUP_HEIGHT;
|
||
if app.explorer_groups_collapsed.contains(&group) {
|
||
continue;
|
||
}
|
||
for entry in rows {
|
||
if entry.id == app.selected {
|
||
return widget::operation::AbsoluteOffset {
|
||
x: entry.depth.saturating_sub(1) as f32 * 16.,
|
||
y: (y - ROW_HEIGHT).max(0.),
|
||
};
|
||
}
|
||
y += ROW_HEIGHT;
|
||
}
|
||
}
|
||
widget::operation::AbsoluteOffset::default()
|
||
}
|
||
|
||
fn item<'a>(app: &'a App, id: Id, context: bool) -> View<'a, Message> {
|
||
let p = app.project();
|
||
let e = &p.elements[&id];
|
||
let count = app.explorer_index.children(id).len();
|
||
let mut info = column![
|
||
text(&e.name).size(14),
|
||
label(format!("{} · @{}", e.kind(), e.alias))
|
||
]
|
||
.spacing(4);
|
||
if context {
|
||
let ancestors = app.explorer_index.ancestry(id);
|
||
let path = ancestors
|
||
.iter()
|
||
.take(ancestors.len().saturating_sub(1))
|
||
.map(|id| p.named(*id))
|
||
.collect::<Vec<_>>()
|
||
.join(" / ");
|
||
if !path.is_empty() {
|
||
info = info.push(label(path));
|
||
}
|
||
}
|
||
crate::context_menu::Area::new(
|
||
button(
|
||
row![
|
||
info.width(Fill),
|
||
label(if count == 0 {
|
||
"Details".into()
|
||
} else {
|
||
format!("{count} children")
|
||
}),
|
||
text("›").size(20)
|
||
]
|
||
.spacing(8)
|
||
.align_y(iced::Center),
|
||
)
|
||
.width(Fill)
|
||
.padding(12)
|
||
.on_press(Message::Explore(Some(id)))
|
||
.style(if app.selected == id {
|
||
style::selected
|
||
} else {
|
||
style::nav
|
||
}),
|
||
move |anchor| crate::model_menu::open(id, anchor),
|
||
)
|
||
.into()
|
||
}
|
||
|
||
pub fn catalog(app: &App) -> View<'_, Message> {
|
||
let p = app.project();
|
||
let index = &app.explorer_index;
|
||
let focus = app
|
||
.explorer_focus
|
||
.filter(|id| p.elements.get(id).is_some_and(|e| e.layer() == app.layer));
|
||
let query = app.search.trim();
|
||
let mut out = column![heading(
|
||
"Model explorer",
|
||
"Browse containment · search names, kinds or @aliases"
|
||
)]
|
||
.spacing(14);
|
||
let mut crumbs = row![action("Project", Message::Explore(None))]
|
||
.spacing(3)
|
||
.align_y(iced::Center);
|
||
if let Some(group) = app.explorer_group {
|
||
crumbs = crumbs
|
||
.push(text("/ ").size(12))
|
||
.push(action(group.label(app.layer), Message::BrowseGroup(group)));
|
||
}
|
||
if let Some(id) = focus {
|
||
for ancestor in index.ancestry(id) {
|
||
crumbs = crumbs.push(text("/ ").size(12)).push(action(
|
||
&short(p.named(ancestor), 24),
|
||
Message::Explore(Some(ancestor)),
|
||
));
|
||
}
|
||
}
|
||
out = out.push(
|
||
scrollable(crumbs).direction(scrollable::Direction::Horizontal(
|
||
scrollable::Scrollbar::default(),
|
||
)),
|
||
);
|
||
let ids: Vec<Id>;
|
||
if !query.is_empty() {
|
||
ids = Group::ALL
|
||
.into_iter()
|
||
.flat_map(|g| index.rows(g, query, &BTreeSet::new()))
|
||
.filter(|r| r.matched)
|
||
.map(|r| r.id)
|
||
.collect();
|
||
out = out.push(label(format!(
|
||
"{} results across this perspective",
|
||
ids.len()
|
||
)));
|
||
} else if let Some(id) = focus {
|
||
let e = &p.elements[&id];
|
||
out = out
|
||
.push(text(&e.name).size(21))
|
||
.push(label(format!("{} · @{}", e.kind(), e.alias)));
|
||
if !e.description.is_empty() {
|
||
out = out.push(text(short(&e.description, 400)).size(13));
|
||
}
|
||
if matches!(e.kind(), Kind::System | Kind::SystemActor) {
|
||
out = out.push(
|
||
button(text("+ Add port…").size(13))
|
||
.padding(10)
|
||
.on_press(Message::PortAction(crate::port_workflow::Action::Component)),
|
||
);
|
||
out = out.push(label(
|
||
"Owned ports appear below. Select a port to connect a boundary exchange.",
|
||
));
|
||
}
|
||
ids = index.children(id).to_vec();
|
||
out = out.push(text(format!("Contained elements · {}", ids.len())).size(15));
|
||
} else if let Some(group) = app.explorer_group {
|
||
ids = index.roots[&group].clone();
|
||
out = out.push(text(group.label(app.layer)).size(19));
|
||
} else {
|
||
for group in Group::ALL {
|
||
let roots = index.roots[&group].len();
|
||
if roots == 0 {
|
||
continue;
|
||
}
|
||
let total = index.rows(group, "", &BTreeSet::new()).len();
|
||
out = out.push(
|
||
button(
|
||
row![
|
||
column![
|
||
text(group.label(app.layer)).size(17),
|
||
label(format!("{roots} roots · {total} elements"))
|
||
]
|
||
.spacing(5)
|
||
.width(Fill),
|
||
text("›").size(24)
|
||
]
|
||
.align_y(iced::Center),
|
||
)
|
||
.padding(18)
|
||
.width(Fill)
|
||
.style(style::nav)
|
||
.on_press(Message::BrowseGroup(group)),
|
||
);
|
||
}
|
||
if p.elements.values().all(|e| e.layer() != app.layer) {
|
||
out = out.push(label(
|
||
"This perspective is empty. Create the first model element in the sidebar.",
|
||
));
|
||
}
|
||
ids = vec![];
|
||
}
|
||
let page = app
|
||
.explorer_page
|
||
.min(ids.len().saturating_sub(1) / PAGE_SIZE);
|
||
if ids.len() > PAGE_SIZE {
|
||
out = out.push(page_controls(page, ids.len()));
|
||
}
|
||
for id in ids.iter().skip(page * PAGE_SIZE).take(PAGE_SIZE) {
|
||
out = out.push(item(app, *id, !query.is_empty()));
|
||
}
|
||
if ids.len() > PAGE_SIZE {
|
||
out = out.push(page_controls(page, ids.len()));
|
||
}
|
||
if query.is_empty()
|
||
&& let Some(id) = focus
|
||
{
|
||
if ids.is_empty() {
|
||
out = out.push(label("No contained elements. Properties are on the right."));
|
||
}
|
||
// Allocation is deliberately separate from containment.
|
||
let allocations: Vec<_> = p
|
||
.relations
|
||
.values()
|
||
.filter(|r| r.kind() == RelationKind::Allocation && r.source() == id)
|
||
.collect();
|
||
if !allocations.is_empty() {
|
||
out = out.push(text(format!("Allocated work · {}", allocations.len())).size(15));
|
||
for r in allocations {
|
||
out = out.push(
|
||
action(p.named(r.target()), Message::Explore(Some(r.target()))).width(Fill),
|
||
);
|
||
}
|
||
}
|
||
out = out.push(action(
|
||
"Connections and references",
|
||
Message::InspectorTab(crate::navigation::InspectorTab::Connections),
|
||
));
|
||
}
|
||
scrollable(out.padding(22)).id("model-browser").into()
|
||
}
|
||
|
||
fn page_controls(page: usize, total: usize) -> View<'static, Message> {
|
||
row![
|
||
button(text("Previous").size(12))
|
||
.padding(8)
|
||
.on_press_maybe((page > 0).then_some(Message::ExplorerPage(page.saturating_sub(1)))),
|
||
label(format!(
|
||
"{}–{} of {total}",
|
||
page * PAGE_SIZE + 1,
|
||
((page + 1) * PAGE_SIZE).min(total)
|
||
)),
|
||
button(text("Next").size(12)).padding(8).on_press_maybe(
|
||
((page + 1) * PAGE_SIZE < total).then_some(Message::ExplorerPage(page + 1))
|
||
)
|
||
]
|
||
.spacing(10)
|
||
.align_y(iced::Center)
|
||
.into()
|
||
}
|