Skip to main content

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}