Add desktop navigation tree
This commit is contained in:
390
apps/desktop/src/navigation.rs
Normal file
390
apps/desktop/src/navigation.rs
Normal file
@@ -0,0 +1,390 @@
|
||||
//! 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<NavigationNode>,
|
||||
}
|
||||
|
||||
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<TreeNodeId>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub struct NavigationTree {
|
||||
nodes: Vec<NavigationNode>,
|
||||
expanded: BTreeSet<TreeNodeId>,
|
||||
selected: Option<TreeNodeId>,
|
||||
}
|
||||
|
||||
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<NavigationRow> {
|
||||
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<TestNode>) {
|
||||
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<TreeNodeId>,
|
||||
rows: &mut Vec<NavigationRow>,
|
||||
) {
|
||||
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<usize> {
|
||||
let selected = selected?;
|
||||
rows.iter().position(|row| &row.id == selected)
|
||||
}
|
||||
|
||||
fn collect_ids(nodes: &[NavigationNode]) -> BTreeSet<TreeNodeId> {
|
||||
fn visit(nodes: &[NavigationNode], ids: &mut BTreeSet<TreeNodeId>) {
|
||||
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<TreeNodeId>) -> 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<TestNode>,
|
||||
}
|
||||
|
||||
#[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 {
|
||||
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<TestNode> {
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user