facett-app/src/sidebar.rs
//! **`Sidebar` — a persistent nav rail rendered from a [`NavModel`](crate::NavModel).**
//! Sections fold/unfold, leaves highlight the active route, and a collapse toggle
//! narrows it to an icon-only rail. Theme-aware (egui Style installed by
//! `look::Theme`). Returns the clicked route so the host drives its [`Router`].
use crate::nav::{NavItem, NavModel};
use egui::Ui;
use std::collections::BTreeSet;
/// Stateful rail: which sections are expanded + collapsed (icon-only) mode.
#[derive(Default)]
pub struct Sidebar {
pub collapsed: bool,
expanded: BTreeSet<String>,
seeded: bool,
}
impl Sidebar {
pub fn new() -> Self {
Self::default()
}
/// Builder: start collapsed (icon-only).
pub fn collapsed(mut self, on: bool) -> Self {
self.collapsed = on;
self
}
fn seed(&mut self, model: &NavModel) {
if !self.seeded {
for it in model.flat() {
if it.is_section() {
self.expanded.insert(it.id.clone());
}
}
self.seeded = true;
}
}
/// Draw the rail. Returns `Some(route)` if a leaf was clicked this frame.
pub fn show(&mut self, ui: &mut Ui, model: &NavModel, active_route: &str) -> Option<String> {
self.seed(model);
let mut clicked = None;
// Collapse toggle.
let toggle = if self.collapsed { "»" } else { "« collapse" };
if ui.add(egui::Button::new(toggle).frame(false)).clicked() {
self.collapsed = !self.collapsed;
}
ui.separator();
let items = model.items.clone(); // small; avoids borrow tangle with &mut self recursion
for it in &items {
self.row(ui, it, active_route, 0, &mut clicked);
}
clicked
}
fn row(&mut self, ui: &mut Ui, it: &NavItem, active: &str, depth: usize, clicked: &mut Option<String>) {
let indent = depth as f32 * 14.0;
let glyph = it.icon.clone().unwrap_or_default();
if it.is_section() {
if self.collapsed {
// Collapsed: skip the header, surface the children's icons directly.
for c in &it.children {
self.row(ui, c, active, depth, clicked);
}
return;
}
let exp = self.expanded.contains(&it.id);
let arrow = if exp { "▾" } else { "▸" };
ui.horizontal(|ui| {
ui.add_space(indent);
if ui.add(egui::Button::new(format!("{arrow} {glyph} {}", it.label)).frame(false)).clicked() {
if exp {
self.expanded.remove(&it.id);
} else {
self.expanded.insert(it.id.clone());
}
}
});
if self.expanded.contains(&it.id) {
for c in &it.children {
self.row(ui, c, active, depth + 1, clicked);
}
}
return;
}
// Leaf.
let route = it.route_or_id().to_string();
let selected = route == active;
if self.collapsed {
let face = if glyph.is_empty() { it.label.chars().next().map(|c| c.to_string()).unwrap_or_default() } else { glyph };
let resp = ui.add_enabled(it.enabled, egui::Button::selectable(selected, face)).on_hover_text(&it.label);
if resp.clicked() {
*clicked = Some(route);
}
return;
}
ui.horizontal(|ui| {
ui.add_space(indent);
let badge = it.badge.as_deref().map(|b| format!(" ({b})")).unwrap_or_default();
let text = if glyph.is_empty() { format!("{}{}", it.label, badge) } else { format!("{glyph} {}{}", it.label, badge) };
let resp = ui.add_enabled(it.enabled, egui::Button::selectable(selected, text));
if resp.clicked() {
*clicked = Some(route.clone());
}
});
}
}