teksilo_data/tree_data_source.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `TreeDataSource` — read-and-command interface for hierarchical data behind a
5//! `TreeView` / `TreeTableView`.
6//!
7//! `TreeDataSource` is to trees what [`ListDataSource`](crate::ListDataSource)
8//! is to flat lists: a projected, per-view, flattened read API plus the
9//! capability protocol for identity, DnD validation, and lazy loading.
10//! The built-in [`TreeSlice`](crate::TreeSlice) and
11//! [`SortFilterTreeModel`](crate::SortFilterTreeModel) implement it over an
12//! in-memory [`TreeModel`]; an external source of truth
13//! (e.g. a Qleany entity store) implements it directly with its own `Key` type
14//! and so never needs to mirror itself into a `TreeModel`.
15//!
16//! ## When to use
17//!
18//! Implement `TreeDataSource` directly when your data already lives outside an
19//! in-memory tree (a database, a virtual filesystem, a remote store) and you
20//! do not want to mirror it into a `TreeModel`. Use [`TreeSlice`](crate::TreeSlice)
21//! when you have a `TreeModel<T>` and want per-view expand state.
22//!
23//! ## Example
24//!
25//! ```ignore
26//! use teksilo_data::{TreeDataSource, FlatEntry, NodeId};
27//! use teksilo_data::dnd_types::{DragEligibility, DropQuery, DropResponse, DropCommit, RowState};
28//! use teksilo_core::signal::Signal;
29//!
30//! struct MySource { version: Signal<u64> }
31//!
32//! impl TreeDataSource for MySource {
33//! type Item = String;
34//! type Key = NodeId;
35//!
36//! fn visible_count(&self) -> usize { 0 }
37//! fn with_entry<R>(&self, _i: usize, _f: impl FnOnce(&String, &FlatEntry<NodeId>) -> R) -> Option<R> { None }
38//! fn key_at(&self, _i: usize) -> Option<NodeId> { None }
39//! fn flat_index_of(&self, _k: &NodeId) -> Option<usize> { None }
40//! fn parent(&self, _k: &NodeId) -> Option<NodeId> { None }
41//! fn child_keys(&self, _k: &NodeId) -> Vec<NodeId> { vec![] }
42//! fn version_signal(&self) -> Signal<u64> { self.version.clone() }
43//! fn is_expanded(&self, _k: &NodeId) -> bool { false }
44//! fn set_expanded(&self, _k: &NodeId, _expanded: bool) {}
45//! }
46//! ```
47
48use teksilo_core::signal::Signal;
49
50use crate::dnd_types::ItemKey;
51use crate::dnd_types::{
52 DragEligibility, DragSource, DropCommit, DropPosition, DropQuery, DropResponse, RowState,
53};
54use crate::tree_change::NodeId;
55use crate::tree_model::TreeModel;
56
57/// A single entry in a tree's flattened, currently-visible row list.
58///
59/// Generic over the key type so external sources carry their own identity
60/// (`K = NodeId` for `TreeModel`-backed sources, `K = i64` for an entity-id
61/// store, …). The default `K = NodeId` keeps every in-tree `FlatEntry` mention
62/// and `entry.node_id` read compiling unchanged.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct FlatEntry<K: ItemKey = NodeId> {
65 /// The row's stable key in its source.
66 pub node_id: K,
67 /// Depth in the tree (0 for roots).
68 pub depth: usize,
69 /// Whether this row has children in the source.
70 pub has_children: bool,
71 /// Whether this row is currently expanded (children visible).
72 pub is_expanded: bool,
73}
74
75/// A per-view flattened, projectable view over hierarchical data.
76///
77/// Not object-safe (associated types + `impl FnOnce`); views consume it
78/// generically and erase it into a closure bundle, exactly as `ListView` does
79/// with `ListDataSource`. The DnD (`drag`/`can_accept`/`accept_drop`/
80/// `on_drag_out`) and lazy (`row_state`/`request_window`/`can_fetch_more`/
81/// `fetch_more`) methods default to inert/fully-resident, so a read-only source
82/// implements only the core read + nav surface.
83pub trait TreeDataSource: 'static {
84 /// The item type stored at each node.
85 type Item: 'static;
86 /// The stable per-node identity (`NodeId` for in-memory trees, an entity id
87 /// for an external store).
88 type Key: ItemKey;
89
90 // ── Core read ─────────────────────────────────────────────────────────
91 /// Number of currently-visible (flattened) rows.
92 fn visible_count(&self) -> usize;
93 /// Access the item + flat metadata at a visible index via callback.
94 fn with_entry<R>(
95 &self,
96 flat_index: usize,
97 f: impl FnOnce(&Self::Item, &FlatEntry<Self::Key>) -> R,
98 ) -> Option<R>;
99 /// The key of the row at a visible index.
100 fn key_at(&self, flat_index: usize) -> Option<Self::Key>;
101 /// The visible index of a key, if currently visible.
102 fn flat_index_of(&self, key: &Self::Key) -> Option<usize>;
103 /// The parent of a node (`None` for a root) — drives sibling nav + the
104 /// drop cycle-guard.
105 fn parent(&self, key: &Self::Key) -> Option<Self::Key>;
106 /// The children of a node, in order.
107 fn child_keys(&self, key: &Self::Key) -> Vec<Self::Key>;
108 /// A version signal that bumps on every structural/projection change — the
109 /// view binds it at `BindingLevel::Rebuild`.
110 fn version_signal(&self) -> Signal<u64>;
111
112 // ── Expand / collapse (per-view) ──────────────────────────────────────
113 /// Whether the node is expanded.
114 fn is_expanded(&self, key: &Self::Key) -> bool;
115 /// Expand (`true`) or collapse (`false`) the node.
116 fn set_expanded(&self, key: &Self::Key, expanded: bool);
117
118 /// First visible index whose content may differ after the latest change —
119 /// rows `0..index` are unchanged, so per-row derived state (e.g. a measured
120 /// height) remains valid. `None` means unknown (treat as a full change).
121 fn first_changed_index(&self) -> Option<usize> {
122 None
123 }
124
125 /// Whether `key` still exists in the source, **independent of visibility** —
126 /// a node hidden under a collapsed ancestor (or scrolled out of a lazy
127 /// window) still exists. Drives keyed-selection pruning, so that a
128 /// collapsed-but-present node keeps its selection and only a *deleted* node
129 /// is dropped. Default: visible-only (`flat_index_of(key).is_some()`);
130 /// sources whose nodes persist while collapsed/scrolled out should override
131 /// this to consult their full store.
132 fn contains_key(&self, key: &Self::Key) -> bool {
133 self.flat_index_of(key).is_some()
134 }
135
136 // ── DnD (default: inert) ──────────────────────────────────────────────
137 /// Whether the node may begin a drag (the transferable gate).
138 fn drag(&self, _key: &Self::Key) -> DragEligibility {
139 DragEligibility::NoDrag
140 }
141 /// Whether a hovered drop is permitted (and where) — the pre-commit verdict.
142 fn can_accept(&self, _query: &DropQuery<'_, Self::Key>) -> DropResponse {
143 DropResponse::Reject
144 }
145 /// Apply a committed drop. Returns whether it was applied.
146 fn accept_drop(&self, _commit: DropCommit<'_, Self::Key>) -> bool {
147 false
148 }
149 /// Reorder a whole set of this source's OWN nodes so they land contiguously
150 /// at a drop gap — the multi-row same-view reorder commit. `sources` are the
151 /// dragged nodes' keys in visible order; `target` / `position` name the drop
152 /// gap. Returns whether anything moved.
153 ///
154 /// The default first drops any `sources` node that is a **descendant of
155 /// another** `sources` node (moving an ancestor already carries its
156 /// subtree), then moves the remaining top-level nodes one at a time,
157 /// re-anchoring each after the previous. Tree keys are stable, so the
158 /// re-anchoring is correct without index bookkeeping.
159 fn reorder_within(
160 &self,
161 sources: &[Self::Key],
162 target: &Self::Key,
163 position: DropPosition,
164 ) -> bool {
165 // Dropping INTO one of the dragged subtrees (target is a dragged node
166 // or a descendant of one) is invalid for the whole gesture — reject
167 // rather than partially apply, matching what the hover verdict shows.
168 let mut t = Some(target.clone());
169 while let Some(node) = t {
170 if sources.iter().any(|s| s == &node) {
171 return false;
172 }
173 t = self.parent(&node);
174 }
175 // Keep only nodes that are not a descendant of another selected node.
176 let top: Vec<Self::Key> = sources
177 .iter()
178 .filter(|k| {
179 let mut p = self.parent(k);
180 while let Some(ancestor) = p {
181 if sources.iter().any(|s| s == &ancestor) {
182 return false;
183 }
184 p = self.parent(&ancestor);
185 }
186 true
187 })
188 .cloned()
189 .collect();
190 let mut anchor = target.clone();
191 let mut pos = position;
192 let mut moved = false;
193 for key in &top {
194 if key == &anchor {
195 continue;
196 }
197 if self.accept_drop(DropCommit {
198 source: DragSource::SameView { key: key.clone() },
199 target: anchor.clone(),
200 position: pos,
201 }) {
202 moved = true;
203 anchor = key.clone();
204 pos = DropPosition::After;
205 }
206 }
207 moved
208 }
209 /// Called on the *origin* source after one of its rows was accepted by a
210 /// different view (source-side completion). Sources backed by a shared /
211 /// command model no-op this; independent models use it to drop the moved
212 /// row.
213 fn on_drag_out(&self, _key: &Self::Key) {}
214
215 // ── Lazy (default: fully resident) ────────────────────────────────────
216 /// Whether the row at a visible index is loaded.
217 fn row_state(&self, _flat_index: usize) -> RowState {
218 RowState::Ready
219 }
220 /// Nudge the source to load the given visible range (the view calls this
221 /// each build with its visible + buffer window).
222 fn request_window(&self, _range: std::ops::Range<usize>) {}
223 /// Whether more rows can be appended (infinite scroll).
224 fn can_fetch_more(&self) -> bool {
225 false
226 }
227 /// Fetch the next page (append-only growth).
228 fn fetch_more(&self) {}
229}
230
231/// Whether `node` is `ancestor` or one of its descendants — the move cycle
232/// guard (you cannot drop a node into its own subtree).
233pub fn tree_is_desc_or_self<T: 'static>(
234 tree: &TreeModel<T>,
235 node: NodeId,
236 ancestor: NodeId,
237) -> bool {
238 let mut cur = Some(node);
239 while let Some(n) = cur {
240 if n == ancestor {
241 return true;
242 }
243 cur = tree.parent(n);
244 }
245 false
246}
247
248/// Apply a tree reorder by `NodeId`, with the cycle guard and the
249/// remove-then-insert index adjustment `TreeModel::move_node` requires. Shared
250/// by the `TreeSlice` / `SortFilterTreeModel` `accept_drop` impls. Returns
251/// whether the move was applied (false = rejected, e.g. cycle or self-drop).
252pub fn tree_apply_reorder<T: 'static>(
253 tree: &TreeModel<T>,
254 source: NodeId,
255 target: NodeId,
256 position: DropPosition,
257) -> bool {
258 if source == target {
259 return false;
260 }
261 // Reject dropping a node anywhere inside its own subtree (covers Into a
262 // descendant and reorder relative to a descendant).
263 if tree_is_desc_or_self(tree, target, source) {
264 return false;
265 }
266 match position {
267 DropPosition::Into => {
268 // Append as last child. move_node removes `source` first, so if it
269 // was already a child of `target` the post-removal length is one
270 // smaller.
271 let mut idx = tree.child_count(target);
272 if tree.parent(source) == Some(target) {
273 idx -= 1;
274 }
275 tree.move_node(source, target, idx);
276 true
277 }
278 DropPosition::Before | DropPosition::After => {
279 let new_parent = tree.parent(target);
280 let siblings: Vec<NodeId> = match new_parent {
281 Some(p) => tree.children(p),
282 None => (0..tree.root_count()).map(|i| tree.root(i)).collect(),
283 };
284 // `target` must be among its own parent's children. If it isn't,
285 // the tree is inconsistent — reject the drop rather than silently
286 // falling back to index 0, which would reorder the node to the
287 // start and corrupt the sibling order.
288 let Some(pos) = siblings.iter().position(|&s| s == target) else {
289 return false;
290 };
291 let mut idx = if position == DropPosition::After {
292 pos + 1
293 } else {
294 pos
295 };
296 // Same-parent removal shift: move_node removes `source` before
297 // inserting, so an insertion point above the source's old slot
298 // shifts down by one.
299 if tree.parent(source) == new_parent
300 && let Some(sp) = siblings.iter().position(|&s| s == source)
301 && sp < idx
302 {
303 idx -= 1;
304 }
305 match new_parent {
306 Some(p) => tree.move_node(source, p, idx),
307 None => tree.move_to_root(source, idx),
308 }
309 true
310 }
311 }
312}