//! 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::>() .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; 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() }