//! Desktop navigation over storage-owned tree models and identities. use std::{collections::BTreeSet, path::Path}; use ironstorage::read::{TreeModel, TreeNode, TreeNodeId, TreeNodeIndicators, TreeNodeKind}; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum NavigationKey { Previous, Next, Collapse, Expand, Activate, First, Last, } #[derive(Clone, Debug, Eq, PartialEq)] pub enum NavigationIntent { None, OpenEntry(String), } #[derive(Clone, Debug, Eq, PartialEq)] struct NavigationNode { id: TreeNodeId, name: String, indicators: TreeNodeIndicators, children: Vec, } impl NavigationNode { fn from_storage(node: &TreeNode) -> Self { Self { id: node.id().clone(), name: node.name().to_owned(), indicators: node.indicators(), children: node.children().iter().map(Self::from_storage).collect(), } } } #[derive(Clone, Debug, Eq, PartialEq)] pub struct NavigationRow { pub id: TreeNodeId, pub name: String, pub indicators: TreeNodeIndicators, pub depth: usize, pub expanded: bool, pub has_children: bool, parent: Option, } #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct NavigationTree { nodes: Vec, expanded: BTreeSet, selected: Option, } impl NavigationTree { pub fn replace(&mut self, model: &TreeModel) { self.nodes = model .children() .iter() .map(NavigationNode::from_storage) .collect(); let valid = collect_ids(&self.nodes); self.expanded.retain(|id| valid.contains(id)); if !self.selected.as_ref().is_some_and(|id| valid.contains(id)) { self.selected = None; } } pub fn is_empty(&self) -> bool { self.nodes.is_empty() } pub fn selected(&self) -> Option<&TreeNodeId> { self.selected.as_ref() } pub fn rows(&self) -> Vec { let mut rows = Vec::new(); flatten(&self.nodes, 0, None, &self.expanded, &mut rows); rows } pub fn selected_ratio(&self) -> f32 { let rows = self.rows(); let Some(index) = selected_index(&rows, self.selected.as_ref()) else { return 0.0; }; index as f32 / rows.len().saturating_sub(1).max(1) as f32 } pub fn activate(&mut self, id: TreeNodeId) -> NavigationIntent { if !self.rows().iter().any(|row| row.id == id) { return NavigationIntent::None; } self.selected = Some(id); self.activate_selected() } pub fn navigate(&mut self, key: NavigationKey) -> NavigationIntent { match key { NavigationKey::Previous => self.move_by(-1), NavigationKey::Next => self.move_by(1), NavigationKey::Collapse => self.collapse_or_parent(), NavigationKey::Expand => self.expand_or_child(), NavigationKey::Activate => return self.activate_selected(), NavigationKey::First => self.select_edge(false), NavigationKey::Last => self.select_edge(true), } NavigationIntent::None } pub fn select_entry_path(&mut self, path: &str) -> bool { let mut lineage = Vec::new(); if !find_lineage(&self.nodes, path, &mut lineage) { return false; } let Some(selected) = lineage.pop() else { return false; }; if selected.kind() != TreeNodeKind::Entry { return false; } self.expanded.extend(lineage); self.selected = Some(selected); true } fn activate_selected(&mut self) -> NavigationIntent { let Some(selected) = self.selected.clone() else { return NavigationIntent::None; }; if selected.is_directory() { if !self.expanded.remove(&selected) { self.expanded.insert(selected); } NavigationIntent::None } else { NavigationIntent::OpenEntry( selected .entry() .expect("non-directory tree identity is an entry") .to_string(), ) } } fn move_by(&mut self, amount: isize) { let rows = self.rows(); if rows.is_empty() { self.selected = None; return; } let destination = selected_index(&rows, self.selected.as_ref()).map_or_else( || usize::from(amount < 0) * (rows.len() - 1), |index| index.saturating_add_signed(amount).min(rows.len() - 1), ); self.selected = Some(rows[destination].id.clone()); } fn select_edge(&mut self, last: bool) { let rows = self.rows(); self.selected = rows .get(if last { rows.len().saturating_sub(1) } else { 0 }) .map(|row| row.id.clone()); } fn collapse_or_parent(&mut self) { let Some(selected) = self.selected.clone() else { return; }; if selected.is_directory() && self.expanded.remove(&selected) { return; } let rows = self.rows(); self.selected = selected_index(&rows, Some(&selected)) .and_then(|index| rows[index].parent.clone()) .or(Some(selected)); } fn expand_or_child(&mut self) { let rows = self.rows(); let Some(index) = selected_index(&rows, self.selected.as_ref()) else { self.select_edge(false); return; }; let row = &rows[index]; if !row.id.is_directory() || !row.has_children { return; } if self.expanded.insert(row.id.clone()) { return; } let rows = self.rows(); if let Some(child) = rows.get(index + 1).filter(|child| child.depth > row.depth) { self.selected = Some(child.id.clone()); } } #[cfg(test)] pub(crate) fn replace_test_nodes(&mut self, nodes: Vec) { self.nodes = nodes.into_iter().map(TestNode::into_navigation).collect(); let valid = collect_ids(&self.nodes); self.expanded.retain(|id| valid.contains(id)); if !self.selected.as_ref().is_some_and(|id| valid.contains(id)) { self.selected = None; } } } fn flatten( nodes: &[NavigationNode], depth: usize, parent: Option<&TreeNodeId>, expanded: &BTreeSet, rows: &mut Vec, ) { for node in nodes { let is_expanded = expanded.contains(&node.id); rows.push(NavigationRow { id: node.id.clone(), name: node.name.clone(), indicators: node.indicators, depth, expanded: is_expanded, has_children: !node.children.is_empty(), parent: parent.cloned(), }); if node.id.is_directory() && is_expanded { flatten(&node.children, depth + 1, Some(&node.id), expanded, rows); } } } fn selected_index(rows: &[NavigationRow], selected: Option<&TreeNodeId>) -> Option { let selected = selected?; rows.iter().position(|row| &row.id == selected) } fn collect_ids(nodes: &[NavigationNode]) -> BTreeSet { fn visit(nodes: &[NavigationNode], ids: &mut BTreeSet) { for node in nodes { ids.insert(node.id.clone()); visit(&node.children, ids); } } let mut ids = BTreeSet::new(); visit(nodes, &mut ids); ids } fn find_lineage(nodes: &[NavigationNode], path: &str, lineage: &mut Vec) -> bool { for node in nodes { lineage.push(node.id.clone()); if node .id .entry() .is_some_and(|entry| entry.as_path() == Path::new(path)) || find_lineage(&node.children, path, lineage) { return true; } let _ = lineage.pop(); } false } #[cfg(test)] pub(crate) struct TestNode { pub id: TreeNodeId, pub name: String, pub children: Vec, } #[cfg(test)] impl TestNode { fn into_navigation(self) -> NavigationNode { NavigationNode { id: self.id, name: self.name, indicators: TreeNodeIndicators::default(), children: self .children .into_iter() .map(Self::into_navigation) .collect(), } } } #[cfg(test)] mod tests { use ironstorage::repository::{DirectoryPath, EntryPath}; use super::*; fn directory(path: &str, children: Vec) -> TestNode { TestNode { id: TreeNodeId::Directory(DirectoryPath::parse(path).expect("directory path")), name: path.rsplit('/').next().unwrap_or(path).to_owned(), children, } } fn entry(path: &str) -> TestNode { TestNode { id: TreeNodeId::Entry(EntryPath::parse(path).expect("entry path")), name: path.rsplit('/').next().unwrap_or(path).to_owned(), children: Vec::new(), } } fn populated() -> Vec { vec![ directory( "personal", vec![ entry("personal/email"), entry("personal/a very long entry name that remains complete"), ], ), directory("work", vec![entry("work/server")]), ] } #[test] fn keyboard_and_mouse_navigation_expand_folders_and_open_only_entries() { let mut tree = NavigationTree::default(); tree.replace_test_nodes(populated()); assert_eq!(tree.navigate(NavigationKey::Next), NavigationIntent::None); assert_eq!( tree.selected().map(TreeNodeId::path), Some(Path::new("personal")) ); assert_eq!(tree.navigate(NavigationKey::Expand), NavigationIntent::None); assert_eq!(tree.rows().len(), 4); assert_eq!(tree.navigate(NavigationKey::Expand), NavigationIntent::None); assert_eq!( tree.navigate(NavigationKey::Activate), NavigationIntent::OpenEntry("personal/email".to_owned()) ); assert_eq!( tree.navigate(NavigationKey::Collapse), NavigationIntent::None ); assert_eq!( tree.selected().map(TreeNodeId::path), Some(Path::new("personal")) ); assert_eq!( tree.activate(TreeNodeId::Directory( DirectoryPath::parse("personal").expect("path") )), NavigationIntent::None ); assert_eq!(tree.rows().len(), 2); } #[test] fn refresh_preserves_valid_typed_identity_and_clears_removed_state() { let mut tree = NavigationTree::default(); tree.replace_test_nodes(populated()); assert!(tree.select_entry_path("personal/email")); let selected = tree.selected().cloned(); tree.replace_test_nodes(populated()); assert_eq!(tree.selected(), selected.as_ref()); assert!(tree.rows().iter().any(|row| row.name.contains("very long"))); tree.replace_test_nodes(vec![directory("personal", Vec::new())]); assert!(tree.selected().is_none()); assert_eq!(tree.expanded.len(), 1); tree.replace_test_nodes(Vec::new()); assert!(tree.selected().is_none()); assert!(tree.expanded.is_empty()); assert!(tree.rows().is_empty()); assert_eq!( tree.navigate(NavigationKey::Activate), NavigationIntent::None ); } }