Files
ARCADIA-Studio/crates/oa-desktop/src/explorer_view.rs
T

394 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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()
}