teksilo_data/tree_row_filter.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `TreeRowFilter` — sort + tree-aware filter over a [`TreeRow`] stream.
5//!
6//! The composable sort/filter stage for the [`TreeDataSlice`](crate::TreeDataSlice)
7//! pipeline. Where [`SortFilterTreeModel`](crate::SortFilterTreeModel) is a full
8//! projection *over an in-memory `TreeModel`* (it owns its own expand state), an
9//! external tree already has its expand/flatten projection — the
10//! `TreeDataSlice`. Stacking a second projection on top would mean two expand
11//! states. So for external trees, sort/filter belongs **below** the slice, as a
12//! transform of its raw indent-ordered input:
13//!
14//! ```text
15//! rows::load() → TreeRowFilter::apply → TreeDataSlice::set_source → TreeView
16//! \___ Vec<TreeRow> → Vec<TreeRow> ___/ \___ the one projection ___/
17//! ```
18//!
19//! It uses the same three [`TreeFilterMode`] strategies and sorts siblings per
20//! parent, then re-emits a valid indent-ordered stream (surviving nodes' depths
21//! are compacted onto their nearest surviving ancestor, which `TreeDataSlice`
22//! re-derives into a clean tree):
23//!
24//! - **`KeepAncestors`** — a node stays if it matches or any descendant matches
25//! (the outline-search behaviour; equivalent to `SortFilterTreeModel`).
26//! - **`HideNonMatching`** — a node stays only if it *and every ancestor* match
27//! (children of a hidden parent stay hidden; equivalent to `SortFilterTreeModel`).
28//! - **`KeepDescendants`** — a match keeps its whole subtree, surfaced even when
29//! the match's own ancestors don't match (the subtree compacts onto a root).
30//! This deliberately differs from `SortFilterTreeModel`, whose flatten drops a
31//! match unless its full ancestor path is visible — which defeats the mode's
32//! "keep the match and its subtree" intent.
33//!
34//! ## Revealing the matches
35//!
36//! `TreeRowFilter` reshapes the *rows*; it does not touch the slice's per-view
37//! **expand state**. So `KeepAncestors` keeps the ancestor rows, but a
38//! freshly-collapsed `TreeDataSlice` still hides the matches under them. While a
39//! filter is active, flip the slice's reveal override so the whole narrowed
40//! result shows; turn it off when the filter clears (the user's real collapse
41//! state is preserved underneath):
42//!
43//! ```ignore
44//! let filtered = !query.is_empty();
45//! slice.set_source(move || if filtered { sieve.apply(load()) } else { load() });
46//! slice.reload();
47//! slice.set_all_expanded(filtered); // reveal while searching, restore after
48//! ```
49//!
50//! ## Example
51//!
52//! ```
53//! use teksilo_data::{TreeRowFilter, TreeRow, TreeFilterMode};
54//!
55//! let rows = vec![
56//! TreeRow::new(1u64, "Book One", 0),
57//! TreeRow::new(2, "Opening", 1),
58//! TreeRow::new(3, "The Dawn Raid", 1),
59//! TreeRow::new(4, "Notes", 0),
60//! ];
61//!
62//! // Outline search: keep matches and the folders that lead to them.
63//! let sieve = TreeRowFilter::new()
64//! .filter_mode(TreeFilterMode::KeepAncestors)
65//! .filter(|title: &&str| title.contains("Dawn"));
66//! let out = sieve.apply(rows);
67//! // "Book One" (ancestor of the match) + "The Dawn Raid".
68//! assert_eq!(out.iter().map(|r| r.item).collect::<Vec<_>>(), vec!["Book One", "The Dawn Raid"]);
69//! ```
70
71use std::cmp::Ordering;
72use std::marker::PhantomData;
73
74use crate::dnd_types::ItemKey;
75use crate::sort_filter_tree_model::TreeFilterMode;
76use crate::tree_data_slice::TreeRow;
77
78type Predicate<T> = Box<dyn Fn(&T) -> bool>;
79type Comparator<T> = Box<dyn Fn(&T, &T) -> Ordering>;
80
81/// A reusable sort + tree-aware filter over a `Vec<`[`TreeRow`]`<K, T>>`. Build
82/// it once, [`apply`](Self::apply) it to each freshly-sourced row stream (e.g.
83/// inside a `TreeDataSlice::set_source` closure). See the [module docs](self).
84pub struct TreeRowFilter<K: ItemKey, T> {
85 predicate: Option<Predicate<T>>,
86 mode: TreeFilterMode,
87 comparator: Option<Comparator<T>>,
88 _k: PhantomData<fn() -> K>,
89}
90
91impl<K: ItemKey, T: 'static> Default for TreeRowFilter<K, T> {
92 fn default() -> Self {
93 Self::new()
94 }
95}
96
97impl<K: ItemKey, T: 'static> TreeRowFilter<K, T> {
98 /// An identity transform (no filter, no sort). Chain [`filter`](Self::filter)
99 /// / [`sort`](Self::sort) to configure it.
100 pub fn new() -> Self {
101 Self {
102 predicate: None,
103 mode: TreeFilterMode::default(),
104 comparator: None,
105 _k: PhantomData,
106 }
107 }
108
109 /// Set the filter strategy (how ancestors/descendants of a match are kept).
110 /// Defaults to `TreeFilterMode::default()`.
111 pub fn filter_mode(mut self, mode: TreeFilterMode) -> Self {
112 self.mode = mode;
113 self
114 }
115
116 /// Set the match predicate over the row item. A row "matches" when `pred`
117 /// returns `true`; the [`filter_mode`](Self::filter_mode) decides what else
118 /// stays visible. With no predicate every row is kept.
119 pub fn filter(mut self, pred: impl Fn(&T) -> bool + 'static) -> Self {
120 self.predicate = Some(Box::new(pred));
121 self
122 }
123
124 /// Sort siblings (ascending) by a comparator on the row item. Parent/child
125 /// structure is preserved — only the order within each parent changes.
126 pub fn sort(mut self, cmp: impl Fn(&T, &T) -> Ordering + 'static) -> Self {
127 self.comparator = Some(Box::new(cmp));
128 self
129 }
130
131 /// Sort siblings (descending) by a comparator on the row item.
132 pub fn sort_desc(mut self, cmp: impl Fn(&T, &T) -> Ordering + 'static) -> Self {
133 self.comparator = Some(Box::new(move |a, b| cmp(a, b).reverse()));
134 self
135 }
136
137 /// Apply the filter + sort to an indent-ordered row stream, returning a new
138 /// indent-ordered stream. `O(n log n)` for the sort, `O(n)` otherwise.
139 pub fn apply(&self, rows: Vec<TreeRow<K, T>>) -> Vec<TreeRow<K, T>> {
140 // Fast path: nothing to do.
141 if self.predicate.is_none() && self.comparator.is_none() {
142 return rows;
143 }
144 let n = rows.len();
145
146 // 1. Derive parent/children/roots from the indent depths (the same
147 // nearest-preceding-smaller-depth rule `TreeDataSlice` uses).
148 let mut children: Vec<Vec<usize>> = vec![Vec::new(); n];
149 let mut parent_of: Vec<Option<usize>> = vec![None; n];
150 let mut roots: Vec<usize> = Vec::new();
151 let mut stack: Vec<(usize, usize)> = Vec::new(); // (depth, index)
152 for (i, row) in rows.iter().enumerate() {
153 while let Some(&(d, _)) = stack.last() {
154 if d >= row.depth {
155 stack.pop();
156 } else {
157 break;
158 }
159 }
160 match stack.last() {
161 Some(&(_, parent)) => {
162 children[parent].push(i);
163 parent_of[i] = Some(parent);
164 }
165 None => roots.push(i),
166 }
167 stack.push((row.depth, i));
168 }
169
170 // 2. Visibility per filter mode.
171 let visible = self.compute_visible(&rows, &children, &roots, &parent_of);
172
173 // 3. Sort siblings (and roots) by the comparator.
174 if let Some(cmp) = &self.comparator {
175 roots.sort_by(|&a, &b| cmp(&rows[a].item, &rows[b].item));
176 for list in children.iter_mut() {
177 list.sort_by(|&a, &b| cmp(&rows[a].item, &rows[b].item));
178 }
179 }
180
181 // 4. Pre-order DFS: emit visible nodes; a hidden node contributes no
182 // depth, so a visible child of a hidden parent compacts onto the
183 // nearest surviving ancestor.
184 let mut emit: Vec<(usize, usize)> = Vec::with_capacity(n);
185 for &root in &roots {
186 emit_dfs(root, 0, &children, &visible, &mut emit);
187 }
188
189 // 5. Move each surviving row out (once) at its compacted depth.
190 let mut slots: Vec<Option<TreeRow<K, T>>> = rows.into_iter().map(Some).collect();
191 emit.into_iter()
192 .map(|(i, depth)| {
193 let mut row = slots[i].take().expect("each node emitted at most once");
194 row.depth = depth;
195 row
196 })
197 .collect()
198 }
199
200 fn compute_visible(
201 &self,
202 rows: &[TreeRow<K, T>],
203 children: &[Vec<usize>],
204 roots: &[usize],
205 parent_of: &[Option<usize>],
206 ) -> Vec<bool> {
207 let Some(pred) = &self.predicate else {
208 return vec![true; rows.len()];
209 };
210 let matches: Vec<bool> = rows.iter().map(|r| pred(&r.item)).collect();
211 let mut visible = vec![false; rows.len()];
212 match self.mode {
213 TreeFilterMode::HideNonMatching => {
214 // Whole-path match: a node is visible only if it matches AND its
215 // parent is visible ("children of hidden parents stay hidden").
216 // Rows are pre-order, so a parent's visibility is decided first.
217 for i in 0..rows.len() {
218 visible[i] = matches[i] && parent_of[i].is_none_or(|p| visible[p]);
219 }
220 }
221 TreeFilterMode::KeepAncestors => {
222 for &r in roots {
223 keep_ancestors(r, children, &matches, &mut visible);
224 }
225 }
226 TreeFilterMode::KeepDescendants => {
227 for &r in roots {
228 keep_descendants(r, children, &matches, &mut visible);
229 }
230 }
231 }
232 visible
233 }
234}
235
236/// Post-order: a node is visible if it matches or any descendant is visible.
237/// Explicit-stack: collect the subtree in pre-order first, then walk it
238/// **reversed** — every node is processed only after all of its
239/// descendants, so `visible[c]` already holds each child's final verdict.
240/// Depth-bounded by the subtree's node count, not the call stack.
241fn keep_ancestors(root: usize, children: &[Vec<usize>], matches: &[bool], visible: &mut [bool]) {
242 let mut pre_order = Vec::with_capacity(children.len());
243 let mut stack = vec![root];
244 while let Some(i) = stack.pop() {
245 pre_order.push(i);
246 for &c in children[i].iter().rev() {
247 stack.push(c);
248 }
249 }
250 for &i in pre_order.iter().rev() {
251 let any_descendant = children[i].iter().any(|&c| visible[c]);
252 if matches[i] || any_descendant {
253 visible[i] = true;
254 }
255 }
256}
257
258/// Pre-order: once a node matches, its whole subtree stays visible.
259/// Explicit-stack, threading `ancestor_matched` through the stack instead of
260/// a recursive call argument.
261fn keep_descendants(root: usize, children: &[Vec<usize>], matches: &[bool], visible: &mut [bool]) {
262 let mut stack = vec![(root, false)];
263 while let Some((i, ancestor_matched)) = stack.pop() {
264 let here = matches[i] || ancestor_matched;
265 if here {
266 visible[i] = true;
267 }
268 for &c in children[i].iter().rev() {
269 stack.push((c, here));
270 }
271 }
272}
273
274/// Emit visible nodes in pre-order; hidden nodes add no depth (their visible
275/// descendants compact onto the nearest surviving ancestor). Explicit-stack
276/// pre-order walk, children pushed in reverse so `pop()` yields them in
277/// source order — output order is bit-for-bit identical to the recursive form.
278fn emit_dfs(
279 root: usize,
280 out_depth: usize,
281 children: &[Vec<usize>],
282 visible: &[bool],
283 emit: &mut Vec<(usize, usize)>,
284) {
285 let mut stack = vec![(root, out_depth)];
286 while let Some((i, out_depth)) = stack.pop() {
287 let child_depth = if visible[i] {
288 emit.push((i, out_depth));
289 out_depth + 1
290 } else {
291 out_depth
292 };
293 for &c in children[i].iter().rev() {
294 stack.push((c, child_depth));
295 }
296 }
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302
303 // Manuscript(0)
304 // Book One(1)
305 // Opening(2)
306 // Dawn(2)
307 // Chapter Two(1)
308 // Fight(2)
309 // Notes(0)
310 // Sketch(1)
311 fn sample() -> Vec<TreeRow<u64, &'static str>> {
312 vec![
313 TreeRow::new(1, "Manuscript", 0),
314 TreeRow::new(2, "Book One", 1),
315 TreeRow::new(3, "Opening", 2),
316 TreeRow::new(4, "Dawn", 2),
317 TreeRow::new(5, "Chapter Two", 1),
318 TreeRow::new(6, "Fight", 2),
319 TreeRow::new(7, "Notes", 0),
320 TreeRow::new(8, "Sketch", 1),
321 ]
322 }
323
324 fn titles(rows: &[TreeRow<u64, &'static str>]) -> Vec<&'static str> {
325 rows.iter().map(|r| r.item).collect()
326 }
327
328 #[test]
329 fn identity_passes_through() {
330 let out = TreeRowFilter::new().apply(sample());
331 assert_eq!(out.len(), 8);
332 assert_eq!(titles(&out), titles(&sample()));
333 }
334
335 #[test]
336 fn keep_ancestors_shows_path_to_match() {
337 let out = TreeRowFilter::new()
338 .filter_mode(TreeFilterMode::KeepAncestors)
339 .filter(|t: &&str| *t == "Dawn")
340 .apply(sample());
341 // Dawn + its ancestors (Book One, Manuscript). Depths compacted 0,1,2.
342 assert_eq!(titles(&out), vec!["Manuscript", "Book One", "Dawn"]);
343 assert_eq!(
344 out.iter().map(|r| r.depth).collect::<Vec<_>>(),
345 vec![0, 1, 2]
346 );
347 }
348
349 #[test]
350 fn keep_descendants_surfaces_subtree_even_under_nonmatching_ancestor() {
351 let out = TreeRowFilter::new()
352 .filter_mode(TreeFilterMode::KeepDescendants)
353 .filter(|t: &&str| *t == "Book One")
354 .apply(sample());
355 // Book One matches; its ancestor "Manuscript" does NOT. KeepDescendants
356 // keeps the match AND its subtree, so Book One + children are surfaced
357 // and compacted (Book One becomes a root). This deliberately differs from
358 // SortFilterTreeModel's flatten, which drops a match whose ancestor path
359 // isn't visible.
360 assert_eq!(titles(&out), vec!["Book One", "Opening", "Dawn"]);
361 assert_eq!(
362 out.iter().map(|r| r.depth).collect::<Vec<_>>(),
363 vec![0, 1, 1]
364 );
365 }
366
367 #[test]
368 fn hide_non_matching_requires_whole_path() {
369 // "Manuscript" and "Book One" form a connected path from a root, so both
370 // survive (self + every ancestor matches).
371 let out = TreeRowFilter::new()
372 .filter_mode(TreeFilterMode::HideNonMatching)
373 .filter(|t: &&str| *t == "Manuscript" || *t == "Book One")
374 .apply(sample());
375 assert_eq!(titles(&out), vec!["Manuscript", "Book One"]);
376 assert_eq!(out.iter().map(|r| r.depth).collect::<Vec<_>>(), vec![0, 1]);
377 }
378
379 #[test]
380 fn hide_non_matching_hides_match_under_hidden_parent() {
381 // "Opening" matches but its parent "Book One" does not → hidden
382 // (children of hidden parents stay hidden).
383 let out = TreeRowFilter::new()
384 .filter_mode(TreeFilterMode::HideNonMatching)
385 .filter(|t: &&str| *t == "Opening")
386 .apply(sample());
387 assert!(out.is_empty());
388 }
389
390 #[test]
391 fn empty_match_yields_empty() {
392 let out = TreeRowFilter::new()
393 .filter_mode(TreeFilterMode::KeepAncestors)
394 .filter(|_: &&str| false)
395 .apply(sample());
396 assert!(out.is_empty());
397 }
398
399 #[test]
400 fn sort_reorders_siblings_per_parent() {
401 let out = TreeRowFilter::new()
402 .sort(|a: &&str, b: &&str| a.cmp(b))
403 .apply(sample());
404 // Roots sorted: Manuscript, Notes. Under Manuscript: Book One, Chapter Two
405 // (already ordered); under Book One: Dawn, Opening (was Opening, Dawn).
406 assert_eq!(
407 titles(&out),
408 vec![
409 "Manuscript",
410 "Book One",
411 "Dawn",
412 "Opening",
413 "Chapter Two",
414 "Fight",
415 "Notes",
416 "Sketch"
417 ]
418 );
419 }
420
421 #[test]
422 fn sort_desc_reverses() {
423 let out = TreeRowFilter::new()
424 .sort_desc(|a: &&str, b: &&str| a.cmp(b))
425 .apply(sample());
426 // Roots descending: Notes, Manuscript.
427 assert_eq!(out[0].item, "Notes");
428 assert_eq!(out[1].item, "Sketch");
429 assert_eq!(out[2].item, "Manuscript");
430 }
431
432 #[test]
433 fn filter_then_sort_compose() {
434 // Keep the path to Dawn + Opening (both under Book One), then sort
435 // siblings ascending — Dawn should precede Opening even though the
436 // source order is Opening, Dawn.
437 let out = TreeRowFilter::new()
438 .filter_mode(TreeFilterMode::KeepAncestors)
439 .filter(|t: &&str| *t == "Dawn" || *t == "Opening")
440 .sort(|a: &&str, b: &&str| a.cmp(b))
441 .apply(sample());
442 assert_eq!(
443 titles(&out),
444 vec!["Manuscript", "Book One", "Dawn", "Opening"]
445 );
446 }
447
448 #[test]
449 fn structure_preserved_when_all_match() {
450 let out = TreeRowFilter::new()
451 .filter_mode(TreeFilterMode::KeepAncestors)
452 .filter(|_: &&str| true)
453 .apply(sample());
454 assert_eq!(titles(&out), titles(&sample()));
455 assert_eq!(
456 out.iter().map(|r| r.depth).collect::<Vec<_>>(),
457 vec![0, 1, 2, 2, 1, 2, 0, 1]
458 );
459 }
460
461 /// `keep_ancestors`, `keep_descendants`, and `emit_dfs` are
462 /// explicit-stack walks; a 50,000-deep single-child chain must apply
463 /// every filter mode without overflowing the call stack.
464 #[test]
465 fn deep_chain_applies_each_mode_without_overflow() {
466 const DEPTH: usize = 50_000;
467 let rows = |depth: usize| -> Vec<TreeRow<u64, usize>> {
468 (0..depth).map(|i| TreeRow::new(i as u64, i, i)).collect()
469 };
470
471 // KeepAncestors: matching the deepest node keeps its entire
472 // ancestor chain — every row in this linear tree.
473 let out = TreeRowFilter::new()
474 .filter_mode(TreeFilterMode::KeepAncestors)
475 .filter(move |item: &usize| *item == DEPTH - 1)
476 .apply(rows(DEPTH));
477 assert_eq!(out.len(), DEPTH);
478 assert_eq!(out.last().unwrap().depth, DEPTH - 1);
479
480 // KeepDescendants: matching the root keeps its entire subtree —
481 // every row, compacted onto the root.
482 let out = TreeRowFilter::new()
483 .filter_mode(TreeFilterMode::KeepDescendants)
484 .filter(|item: &usize| *item == 0)
485 .apply(rows(DEPTH));
486 assert_eq!(out.len(), DEPTH);
487
488 // HideNonMatching: only the root matches, and its child
489 // immediately breaks the whole-path rule — one surviving row.
490 let out = TreeRowFilter::new()
491 .filter_mode(TreeFilterMode::HideNonMatching)
492 .filter(|item: &usize| *item == 0)
493 .apply(rows(DEPTH));
494 assert_eq!(out.len(), 1);
495 }
496}