use std::collections::HashSet;
use uuid::Uuid;
use crate::store::NodeKind;
use crate::store::hierarchy::Hierarchy;
pub(super) struct FactsRow {
pub id: Uuid,
pub depth: usize,
pub has_children: bool,
pub expanded: bool,
}
pub(super) struct FactsTree {
pub root: Option<Uuid>,
expanded: HashSet<Uuid>,
pub cursor: usize,
pub scroll: usize,
visible: Vec<FactsRow>,
}
impl FactsTree {
pub(super) fn new(h: &Hierarchy) -> FactsTree {
let root = h
.iter()
.find(|n| {
n.kind == NodeKind::Book
&& n.system_tag.as_deref() == Some(crate::store::SYSTEM_TAG_FACTS)
})
.map(|n| n.id);
let mut expanded = HashSet::new();
if let Some(r) = root {
expanded.insert(r); }
let mut t = FactsTree { root, expanded, cursor: 0, scroll: 0, visible: Vec::new() };
t.rebuild(h);
t
}
pub(super) fn rebuild(&mut self, h: &Hierarchy) {
self.visible.clear();
if let Some(root) = self.root {
self.push_children(h, root, 0);
}
if self.cursor >= self.visible.len() {
self.cursor = self.visible.len().saturating_sub(1);
}
}
fn push_children(&mut self, h: &Hierarchy, parent: Uuid, depth: usize) {
for child in h.children_of(Some(parent)) {
if matches!(child.kind, NodeKind::Image | NodeKind::Script) {
continue;
}
let kids: Vec<Uuid> = h
.children_of(Some(child.id))
.iter()
.filter(|n| !matches!(n.kind, NodeKind::Image | NodeKind::Script))
.map(|n| n.id)
.collect();
let has_children = !kids.is_empty();
let expanded = self.expanded.contains(&child.id);
self.visible.push(FactsRow { id: child.id, depth, has_children, expanded });
if has_children && expanded {
self.push_children(h, child.id, depth + 1);
}
}
}
pub(super) fn rows(&self) -> &[FactsRow] {
&self.visible
}
pub(super) fn is_empty(&self) -> bool {
self.visible.is_empty()
}
pub(super) fn selected(&self) -> Option<Uuid> {
self.visible.get(self.cursor).map(|r| r.id)
}
pub(super) fn move_up(&mut self) {
self.cursor = self.cursor.saturating_sub(1);
}
pub(super) fn move_down(&mut self) {
if self.cursor + 1 < self.visible.len() {
self.cursor += 1;
}
}
pub(super) fn to_top(&mut self) {
self.cursor = 0;
}
pub(super) fn to_bottom(&mut self) {
self.cursor = self.visible.len().saturating_sub(1);
}
pub(super) fn step_in(&mut self, h: &Hierarchy) {
let Some(row) = self.visible.get(self.cursor) else { return };
if row.has_children {
if !row.expanded {
self.expanded.insert(row.id);
self.rebuild(h);
} else {
self.move_down();
}
}
}
pub(super) fn step_out(&mut self, h: &Hierarchy) {
let Some(row) = self.visible.get(self.cursor) else { return };
if row.has_children && row.expanded {
self.expanded.remove(&row.id);
self.rebuild(h);
return;
}
let depth = row.depth;
if depth == 0 {
return;
}
for i in (0..self.cursor).rev() {
if self.visible[i].depth < depth {
self.cursor = i;
break;
}
}
}
pub(super) fn toggle(&mut self, h: &Hierarchy) {
let Some(row) = self.visible.get(self.cursor) else { return };
if !row.has_children {
return;
}
if row.expanded {
self.expanded.remove(&row.id);
} else {
self.expanded.insert(row.id);
}
self.rebuild(h);
}
pub(super) fn reveal(&mut self, h: &Hierarchy, id: Uuid) -> bool {
let Some(root) = self.root else { return false };
let mut chain = Vec::new();
let mut cur = Some(id);
while let Some(c) = cur {
if c == root {
break;
}
chain.push(c);
cur = h.get(c).and_then(|n| n.parent_id);
}
if cur != Some(root) {
return false; }
for ancestor in chain.iter().skip(1) {
self.expanded.insert(*ancestor);
}
self.rebuild(h);
if let Some(pos) = self.visible.iter().position(|r| r.id == id) {
self.cursor = pos;
true
} else {
false
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::node::Node;
fn node(id: Uuid, kind: &str, parent: Option<Uuid>, order: u32, facts: bool) -> Node {
let mut raw = serde_json::json!({
"id": id,
"kind": kind,
"title": format!("{kind}-{order}"),
"slug": format!("{kind}-{order}"),
"path": [],
"parent_id": parent,
"order": order,
"file": null,
"modified_at": "2026-01-01T00:00:00Z",
});
if facts {
raw["system_tag"] = serde_json::json!("facts");
}
serde_json::from_value(raw).expect("test node deserialises")
}
fn sample() -> (Hierarchy, Uuid, Uuid, Uuid, Uuid) {
let book = Uuid::now_v7();
let ch1 = Uuid::now_v7();
let p1 = Uuid::now_v7();
let p2 = Uuid::now_v7();
let nodes = vec![
node(book, "book", None, 1, true),
node(ch1, "chapter", Some(book), 1, false),
node(p1, "paragraph", Some(ch1), 1, false),
node(p2, "paragraph", Some(ch1), 2, false),
];
(Hierarchy::from_nodes_for_test(nodes), book, ch1, p1, p2)
}
#[test]
fn finds_root_and_folds() {
let (h, book, ch1, _p1, _p2) = sample();
let mut t = FactsTree::new(&h);
assert_eq!(t.root, Some(book));
assert_eq!(t.rows().len(), 1);
assert_eq!(t.selected(), Some(ch1));
t.step_in(&h);
assert_eq!(t.rows().len(), 3);
t.step_out(&h);
assert_eq!(t.rows().len(), 1);
}
#[test]
fn reveal_expands_ancestors() {
let (h, _book, ch1, _p1, p2) = sample();
let mut t = FactsTree::new(&h);
assert_eq!(t.rows().len(), 1); assert!(t.reveal(&h, p2));
assert_eq!(t.selected(), Some(p2));
assert!(t.rows().iter().any(|r| r.id == ch1 && r.expanded));
}
}