Skip to main content

teksilo_widgets/
tree_source.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Type-erased data source adapter for [`TreeView`](crate::TreeView).
5//!
6//! Wraps any [`TreeDataSource`] behind a uniform set of `Rc<dyn Fn(..)>` closures
7//! keyed on the **visible flat index**, so `TreeView<T>` requires no extra type
8//! parameter for the source's `Key`. Each closure resolves index → `Key` (via
9//! `key_at`) before forwarding to the source's `parent`, `set_expanded`,
10//! `can_accept`, etc. The `Key` type is fully captured here and never surfaces
11//! in the view.
12//!
13//! Both built-in and external backings flow through
14//! [`TreeSource::from_data_source`]: the `TreeView::new(TreeModel)` path wraps a
15//! `Rc<TreeSlice<T>>` (which implements `TreeDataSource<Key = NodeId>`), while
16//! `TreeView::from_source` wraps an external `TreeDataSource` with its own `Key`.
17//! The only built-in-vs-external difference — the `NodeId`-typed `TreeRowContext`
18//! handed to the legacy delegate — lives in `tree_view.rs`, not here.
19
20use std::cell::RefCell;
21use std::rc::Rc;
22
23use teksilo_core::drag_payload::DragPayload;
24use teksilo_core::signal::Signal;
25use teksilo_core::widget::{EventContext, Widget};
26use teksilo_data::{
27    DragEligibility, DragSource, DropCommit, DropPosition, DropQuery, DropResponse, RowState,
28    TreeDataSource,
29};
30
31use crate::data_views::{RowDragData, ViewId};
32
33/// Key-erased per-row flat metadata, derived from the source's `FlatEntry`.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub struct TreeRowMeta {
36    /// Depth in the tree (0 for roots).
37    pub depth: usize,
38    /// Whether this row has children in the source.
39    pub has_children: bool,
40    /// Whether this row is currently expanded.
41    pub is_expanded: bool,
42}
43
44/// Per-row context handed to a [`TreeView::from_source`](crate::TreeView::from_source)
45/// delegate — the key-erased counterpart of the built-in
46/// [`TreeRowContext`](crate::TreeRowContext). Carries the row's flat metadata
47/// plus a one-call chevron toggle that flips the row's expansion through the
48/// source (by index → key → `set_expanded`).
49pub struct TreeRow {
50    /// Depth in the tree (0 for roots).
51    pub depth: usize,
52    /// Whether this row has children in the source.
53    pub has_children: bool,
54    /// Whether this row is currently expanded.
55    pub is_expanded: bool,
56    toggle: Rc<dyn Fn(&mut EventContext)>,
57}
58
59impl TreeRow {
60    /// Toggle callback for this row's chevron. Wires in one line:
61    /// `.on_toggle_rc(row.toggle_callback())`.
62    pub fn toggle_callback(&self) -> Rc<dyn Fn(&mut EventContext)> {
63        self.toggle.clone()
64    }
65}
66
67/// Cache of the ascending flat indices of every visible depth-0 (root) row,
68/// valid for one source version. Roots have no `parent` to enumerate
69/// siblings through, so both `sibling_pos`'s root branch and Alt+Arrow's
70/// root-sibling reorder fall back to scanning the WHOLE visible range for
71/// `depth == 0` rows — O(realized root rows × visible count) per rebuild for
72/// a flat-ish tree with many roots, since every realized root row repeats
73/// the full scan. Rebuilt once per version bump (shared by both call sites)
74/// instead, then answered by a binary search (`sibling_pos`) or a direct
75/// re-map (`keyboard_reorder`).
76type RootIndexCache = RefCell<Option<(u64, Rc<Vec<usize>>)>>;
77
78/// The cached root indices for `source`'s current version, rescanning only
79/// when the version has moved on since the last call.
80fn root_indices<S: TreeDataSource>(source: &S, cache: &RootIndexCache) -> Rc<Vec<usize>> {
81    let version = source.version_signal().get();
82    {
83        let cached = cache.borrow();
84        if let Some((v, flat)) = cached.as_ref()
85            && *v == version
86        {
87            return flat.clone();
88        }
89    }
90    let n = source.visible_count();
91    let flat = Rc::new(
92        (0..n)
93            .filter(|&j| source.with_entry(j, |_it, e| e.depth == 0).unwrap_or(false))
94            .collect::<Vec<usize>>(),
95    );
96    *cache.borrow_mut() = Some((version, flat.clone()));
97    flat
98}
99
100/// Erased DnD + lazy capability closures for a tree source. View-facing
101/// arguments are visible flat indices + the view's id; the closures resolve keys
102/// internally. Mirrors [`DndLazy`](crate::list_source::DndLazy) for trees, so the
103/// `Key` never escapes into `TreeView<T>`.
104pub(crate) struct TreeDndLazy {
105    /// Whether the row at `index` may begin a drag.
106    pub(crate) drag_fn: Rc<dyn Fn(usize) -> DragEligibility>,
107    /// `(payload, target_index, position, this_view_id) -> verdict`.
108    pub(crate) can_accept_fn: Rc<dyn Fn(&DragPayload, usize, DropPosition, ViewId) -> DropResponse>,
109    /// `(payload, target_index, position, this_view_id) -> applied`.
110    pub(crate) accept_drop_fn: Rc<dyn Fn(&DragPayload, usize, DropPosition, ViewId) -> bool>,
111    /// Source-side completion: resolve stable node keys for these rows NOW
112    /// (drag-start) and return a thunk that removes them (a foreign move-out).
113    /// Resolving eagerly keeps a Move correct even when the tree's flat indices
114    /// reshuffle mid-drag (spring-load auto-expand), since the stable `NodeId`s
115    /// were already captured.
116    pub(crate) snapshot_out_fn: crate::data_views::SnapshotOutFn,
117    /// Resolve + stash the dragged rows' stable node keys for a **synthetic**
118    /// same-view payload built outside `RowExport::build_payload`. Pointer
119    /// drags stash through [`snapshot_out_fn`](Self::snapshot_out_fn) at
120    /// drag-start; the same-view accept path reads identity exclusively from
121    /// this stash — see [`can_accept_fn`](Self::can_accept_fn).
122    pub(crate) stash_drag_keys_fn: Rc<dyn Fn(&[usize])>,
123    /// Whether the row at `index` is loaded.
124    pub(crate) row_state_fn: Rc<dyn Fn(usize) -> RowState>,
125    /// Nudge the source to load a visible range.
126    pub(crate) request_window_fn: Rc<dyn Fn(std::ops::Range<usize>)>,
127    /// Whether more rows can be appended.
128    pub(crate) can_fetch_more_fn: Rc<dyn Fn() -> bool>,
129    /// Fetch the next page.
130    pub(crate) fetch_more_fn: Rc<dyn Fn()>,
131}
132
133impl TreeDndLazy {
134    fn from_source<T: 'static, S: TreeDataSource<Item = T> + 'static>(s: Rc<S>) -> Self {
135        // Stable node keys of the in-flight same-view drag, resolved from flat
136        // indices ONCE at payload construction (`snapshot_out_fn` for pointer
137        // drags, `stash_drag_keys_fn` for synthetic keyboard payloads). The
138        // accept path reads identity from here rather than re-resolving
139        // `RowDragData::rows` at hover/drop time: a tree's flat indices
140        // reshuffle mid-drag (the spring-load auto-expand is triggered by the
141        // very hover that precedes the drop), and whichever nodes slid into
142        // the stale slots must not stand in for the dragged ones.
143        let drag_keys: Rc<RefCell<Option<Vec<S::Key>>>> = Rc::new(RefCell::new(None));
144        let (keys_ca, keys_ad, keys_snap, keys_stash) = (
145            drag_keys.clone(),
146            drag_keys.clone(),
147            drag_keys.clone(),
148            drag_keys,
149        );
150        let (s1, s2, s3, s4, s5, s6, s7, s8, s9) = (
151            s.clone(),
152            s.clone(),
153            s.clone(),
154            s.clone(),
155            s.clone(),
156            s.clone(),
157            s.clone(),
158            s.clone(),
159            s,
160        );
161        Self {
162            drag_fn: Rc::new(move |index| match s1.key_at(index) {
163                Some(k) => s1.drag(&k),
164                None => DragEligibility::NoDrag,
165            }),
166            can_accept_fn: Rc::new(move |payload, target_index, position, view_id| {
167                let Some(target_key) = s2.key_at(target_index) else {
168                    return DropResponse::Reject;
169                };
170                if let Some(rd) = payload.get_typed::<RowDragData<T>>()
171                    && rd.source == view_id
172                {
173                    let source_key = {
174                        let stash = keys_ca.borrow();
175                        let Some(keys) = stash.as_ref().filter(|k| !k.is_empty()) else {
176                            debug_assert!(false, "same-view drag without a drag-start key stash");
177                            return DropResponse::Reject;
178                        };
179                        // Own-row rejection by key, so it survives a mid-drag
180                        // reflow.
181                        if keys.contains(&target_key) {
182                            return DropResponse::Reject;
183                        }
184                        keys[0].clone()
185                    };
186                    return s2.can_accept(&DropQuery {
187                        source: DragSource::SameView { key: source_key },
188                        target: target_key,
189                        position,
190                    });
191                }
192                s2.can_accept(&DropQuery {
193                    source: DragSource::Foreign { payload },
194                    target: target_key,
195                    position,
196                })
197            }),
198            accept_drop_fn: Rc::new(move |payload, target_index, position, view_id| {
199                let Some(target_key) = s3.key_at(target_index) else {
200                    return false;
201                };
202                if let Some(rd) = payload.get_typed::<RowDragData<T>>()
203                    && rd.source == view_id
204                {
205                    // Consume the drag-start stash (a construction path that
206                    // forgot to stash then fails loudly on its next drop
207                    // instead of silently reusing a previous drag's keys).
208                    let taken = keys_ad.borrow_mut().take();
209                    let Some(keys) = taken.filter(|k| !k.is_empty()) else {
210                        debug_assert!(false, "same-view drop without a drag-start key stash");
211                        return false;
212                    };
213                    if keys.contains(&target_key) {
214                        return false;
215                    }
216                    // `reorder_within` drops descendants-of-selected and keeps
217                    // the remaining nodes contiguous (single- or multi-row).
218                    return s3.reorder_within(&keys, &target_key, position);
219                }
220                s3.accept_drop(DropCommit {
221                    source: DragSource::Foreign { payload },
222                    target: target_key,
223                    position,
224                })
225            }),
226            snapshot_out_fn: Rc::new(move |indices: &[usize]| {
227                // Resolves stable keys NOW: they feed both the same-view accept
228                // path (via the drag-key stash) and the returned removal thunk.
229                let mut pairs: Vec<(usize, S::Key)> = indices
230                    .iter()
231                    .filter_map(|&i| s4.key_at(i).map(|k| (i, k)))
232                    .collect();
233                *keys_snap.borrow_mut() = Some(pairs.iter().map(|(_, k)| k.clone()).collect());
234                pairs.sort_by_key(|&(i, _)| std::cmp::Reverse(i));
235                let s = s4.clone();
236                Box::new(move || {
237                    for (_, k) in &pairs {
238                        s.on_drag_out(k);
239                    }
240                }) as Box<dyn Fn()>
241            }),
242            stash_drag_keys_fn: Rc::new(move |indices: &[usize]| {
243                *keys_stash.borrow_mut() =
244                    Some(indices.iter().filter_map(|&i| s9.key_at(i)).collect());
245            }),
246            row_state_fn: Rc::new(move |index| s5.row_state(index)),
247            request_window_fn: Rc::new(move |range| s6.request_window(range)),
248            can_fetch_more_fn: Rc::new(move || s7.can_fetch_more()),
249            fetch_more_fn: Rc::new(move || s8.fetch_more()),
250        }
251    }
252}
253
254/// Erased tree backing consumed by `TreeView`. All accessors are keyed on the
255/// visible flat index; the `Key` type is captured at construction and never
256/// surfaces in `TreeView<T>`.
257pub(crate) struct TreeSource<T: 'static> {
258    visible_count_fn: Rc<dyn Fn() -> usize>,
259    /// Build a widget for the row at `index`: hands the builder `(&T, &TreeRowMeta)`.
260    /// `None` when the index is out of range OR its data is still `Loading`.
261    with_row_fn:
262        Rc<dyn Fn(usize, &dyn Fn(&T, &TreeRowMeta) -> Box<dyn Widget>) -> Option<Box<dyn Widget>>>,
263    /// String-returning sibling of [`with_row_fn`](Self::with_row_fn) — reads
264    /// an arbitrary `String` from a resident row's item, for type-ahead label
265    /// extraction. `None` when out of range or still loading.
266    with_row_str_fn: Rc<dyn Fn(usize, &dyn Fn(&T) -> String) -> Option<String>>,
267    /// Read `&T` from the resident row at `index` via a side-effecting
268    /// callback, returning whether it ran. Powers export item-cloning
269    /// (`.exportable(..)`) without the delegate's widget-building path.
270    pub(crate) read_item_fn: Rc<dyn Fn(usize, &mut dyn FnMut(&T)) -> bool>,
271    /// Flat metadata for `index` without building a widget (a11y, keyboard).
272    meta_fn: Rc<dyn Fn(usize) -> Option<TreeRowMeta>>,
273    /// Expand (`true`) / collapse (`false`) the row at `index` (index → key).
274    set_expanded_at_fn: Rc<dyn Fn(usize, bool)>,
275    /// Whether the row at `index` is expanded.
276    is_expanded_at_fn: Rc<dyn Fn(usize) -> bool>,
277    /// The visible flat index of the row's parent, if visible (ArrowLeft-to-parent).
278    parent_index_fn: Rc<dyn Fn(usize) -> Option<usize>>,
279    /// `(pos_in_set_1based, set_size)` among the row's siblings (a11y).
280    sibling_pos_fn: Rc<dyn Fn(usize) -> (usize, usize)>,
281    /// Alt+Arrow sibling reorder: `(index, down) -> new flat index` (or `None`
282    /// if at an edge / rejected). The key-typed sibling logic stays internal.
283    keyboard_reorder_fn: Rc<dyn Fn(usize, bool) -> Option<usize>>,
284    /// Resolve `index` to a [`RowAnchor`](crate::data_views::RowAnchor) that
285    /// survives row movement. Captures the source's key at build time; the key
286    /// stays inside the closure, so `TreeSource<T>` remains key-agnostic.
287    anchor_fn: Rc<dyn Fn(usize) -> crate::data_views::RowAnchor>,
288    version_fn: Rc<dyn Fn() -> Signal<u64>>,
289    first_changed_fn: Rc<dyn Fn() -> Option<usize>>,
290    pub(crate) dnd: TreeDndLazy,
291}
292
293impl<T: 'static> TreeSource<T> {
294    /// Erase any concrete [`TreeDataSource`]. The built-in path passes a
295    /// `Rc<TreeSlice<T>>`; an external source passes its own `Rc<S>`.
296    pub(crate) fn from_data_source<S: TreeDataSource<Item = T> + 'static>(s: Rc<S>) -> Self {
297        let dnd = TreeDndLazy::from_source(s.clone());
298        // Shared by `sibling_pos_fn` and `keyboard_reorder_fn` below — both
299        // need "all visible roots, in order" and a version bump invalidates
300        // both alike, so one scan per version serves either caller.
301        let root_cache: Rc<RootIndexCache> = Rc::new(RefCell::new(None));
302        let (root_cache_sib, root_cache_kbd) = (root_cache.clone(), root_cache);
303        let (s1, s2, s3, s4, s5, s6, s7, s8, s9, s10, s11, s12, s13) = (
304            s.clone(),
305            s.clone(),
306            s.clone(),
307            s.clone(),
308            s.clone(),
309            s.clone(),
310            s.clone(),
311            s.clone(),
312            s.clone(),
313            s.clone(),
314            s.clone(),
315            s.clone(),
316            s,
317        );
318        Self {
319            visible_count_fn: Rc::new(move || s1.visible_count()),
320            anchor_fn: Rc::new(move |index| match s13.key_at(index) {
321                Some(key) => {
322                    let src = s13.clone();
323                    crate::data_views::RowAnchor::new(Rc::new(move || {
324                        // Fast path: the captured slot still holds this row.
325                        if src.key_at(index).as_ref() == Some(&key) {
326                            return Some(index);
327                        }
328                        src.flat_index_of(&key)
329                    }))
330                }
331                None => crate::data_views::RowAnchor::fixed(index),
332            }),
333            with_row_fn: Rc::new(move |index, build| {
334                s2.with_entry(index, |item, entry| {
335                    let meta = TreeRowMeta {
336                        depth: entry.depth,
337                        has_children: entry.has_children,
338                        is_expanded: entry.is_expanded,
339                    };
340                    build(item, &meta)
341                })
342            }),
343            with_row_str_fn: Rc::new(move |index, f| s11.with_entry(index, |item, _entry| f(item))),
344            read_item_fn: Rc::new(move |index, f| {
345                s12.with_entry(index, |item, _entry| f(item)).is_some()
346            }),
347            meta_fn: Rc::new(move |index| {
348                s3.with_entry(index, |_item, entry| TreeRowMeta {
349                    depth: entry.depth,
350                    has_children: entry.has_children,
351                    is_expanded: entry.is_expanded,
352                })
353            }),
354            set_expanded_at_fn: Rc::new(move |index, expanded| {
355                if let Some(k) = s4.key_at(index) {
356                    s4.set_expanded(&k, expanded);
357                }
358            }),
359            is_expanded_at_fn: Rc::new(move |index| {
360                s5.key_at(index)
361                    .map(|k| s5.is_expanded(&k))
362                    .unwrap_or(false)
363            }),
364            parent_index_fn: Rc::new(move |index| {
365                let k = s6.key_at(index)?;
366                let p = s6.parent(&k)?;
367                s6.flat_index_of(&p)
368            }),
369            sibling_pos_fn: Rc::new(move |index| {
370                let Some(k) = s7.key_at(index) else {
371                    return (1, 1);
372                };
373                match s7.parent(&k) {
374                    Some(p) => {
375                        let sibs = s7.child_keys(&p);
376                        let pos = sibs.iter().position(|x| *x == k).unwrap_or(0) + 1;
377                        (pos, sibs.len().max(1))
378                    }
379                    None => {
380                        // Roots are always visible (depth 0). The cached scan
381                        // (one per source version) avoids re-deriving "all
382                        // visible roots" for every realized root row.
383                        let roots = root_indices(&*s7, &root_cache_sib);
384                        let pos = roots.binary_search(&index).map(|p| p + 1).unwrap_or(1);
385                        (pos, roots.len().max(1))
386                    }
387                }
388            }),
389            keyboard_reorder_fn: Rc::new(move |index, down| {
390                let k = s10.key_at(index)?;
391                // Ordered sibling keys at `k`'s level. Roots are always visible
392                // (depth 0 never collapses out), so the root list is the visible
393                // depth-0 scan — no root-enumeration method needed on the trait.
394                let siblings: Vec<S::Key> = match s10.parent(&k) {
395                    Some(p) => s10.child_keys(&p),
396                    None => root_indices(&*s10, &root_cache_kbd)
397                        .iter()
398                        .filter_map(|&j| s10.key_at(j))
399                        .collect(),
400                };
401                let pos = siblings.iter().position(|x| *x == k)?;
402                let (target, position) = if down {
403                    if pos + 1 >= siblings.len() {
404                        return None;
405                    }
406                    (siblings[pos + 1].clone(), DropPosition::After)
407                } else {
408                    if pos == 0 {
409                        return None;
410                    }
411                    (siblings[pos - 1].clone(), DropPosition::Before)
412                };
413                let applied = s10.accept_drop(DropCommit {
414                    source: DragSource::SameView { key: k.clone() },
415                    target,
416                    position,
417                });
418                if applied { s10.flat_index_of(&k) } else { None }
419            }),
420            version_fn: Rc::new(move || s8.version_signal()),
421            first_changed_fn: Rc::new(move || s9.first_changed_index()),
422            dnd,
423        }
424    }
425
426    /// A movement-proof handle to the row at `index`.
427    pub(crate) fn anchor(&self, index: usize) -> crate::data_views::RowAnchor {
428        (self.anchor_fn)(index)
429    }
430
431    pub(crate) fn visible_count(&self) -> usize {
432        (self.visible_count_fn)()
433    }
434
435    pub(crate) fn with_row(
436        &self,
437        index: usize,
438        build: &dyn Fn(&T, &TreeRowMeta) -> Box<dyn Widget>,
439    ) -> Option<Box<dyn Widget>> {
440        (self.with_row_fn)(index, build)
441    }
442
443    pub(crate) fn meta(&self, index: usize) -> Option<TreeRowMeta> {
444        (self.meta_fn)(index)
445    }
446
447    /// Read a `String` from the resident row at `index` (type-ahead label).
448    pub(crate) fn with_row_str(&self, index: usize, f: &dyn Fn(&T) -> String) -> Option<String> {
449        (self.with_row_str_fn)(index, f)
450    }
451
452    /// The tree depth of the visible row at `index`, or `0` when the row is
453    /// out of range or its data is still `Loading`. Drives the drop
454    /// affordance's indent — a missing row reads as root level rather than
455    /// shifting the indicator somewhere arbitrary.
456    pub(crate) fn depth(&self, index: usize) -> usize {
457        self.meta(index).map(|m| m.depth).unwrap_or(0)
458    }
459
460    pub(crate) fn set_expanded_at(&self, index: usize, expanded: bool) {
461        (self.set_expanded_at_fn)(index, expanded)
462    }
463
464    pub(crate) fn is_expanded_at(&self, index: usize) -> bool {
465        (self.is_expanded_at_fn)(index)
466    }
467
468    pub(crate) fn toggle_at(&self, index: usize) {
469        let expanded = (self.is_expanded_at_fn)(index);
470        (self.set_expanded_at_fn)(index, !expanded);
471    }
472
473    pub(crate) fn parent_index(&self, index: usize) -> Option<usize> {
474        (self.parent_index_fn)(index)
475    }
476
477    pub(crate) fn sibling_pos(&self, index: usize) -> (usize, usize) {
478        (self.sibling_pos_fn)(index)
479    }
480
481    /// Move the row at `index` up (`down=false`) or down among its siblings,
482    /// routed through the source's own `accept_drop`. Returns the moved row's
483    /// new flat index, or `None` at an edge / if rejected.
484    pub(crate) fn keyboard_reorder(&self, index: usize, down: bool) -> Option<usize> {
485        (self.keyboard_reorder_fn)(index, down)
486    }
487
488    pub(crate) fn version_signal(&self) -> Signal<u64> {
489        (self.version_fn)()
490    }
491
492    pub(crate) fn first_changed_index(&self) -> Option<usize> {
493        (self.first_changed_fn)()
494    }
495
496    /// Build a per-row [`TreeRow`] context (key-erased toggle) for the
497    /// `from_source` delegate.
498    pub(crate) fn row_context(self_rc: &Rc<TreeSource<T>>, index: usize) -> TreeRow {
499        let meta = self_rc.meta(index).unwrap_or(TreeRowMeta {
500            depth: 0,
501            has_children: false,
502            is_expanded: false,
503        });
504        let src = self_rc.clone();
505        // Anchored, not index-captured: the chevron keeps toggling ITS row even
506        // if rows above it appear or vanish before the click lands, and no-ops
507        // if the row is gone rather than toggling whoever took its place.
508        let anchor = self_rc.anchor(index);
509        TreeRow {
510            depth: meta.depth,
511            has_children: meta.has_children,
512            is_expanded: meta.is_expanded,
513            toggle: Rc::new(move |_ctx| {
514                if let Some(i) = anchor.index() {
515                    src.toggle_at(i);
516                }
517            }),
518        }
519    }
520}
521
522#[cfg(test)]
523mod drag_identity_tests {
524    use super::*;
525    use std::cell::RefCell;
526    use teksilo_data::{TreeDataSlice, TreeRow};
527
528    use crate::data_views::{RowDragData, ViewId, ViewKind};
529
530    fn slice_of(keys: &[u64]) -> TreeDataSlice<u64, u64> {
531        let slice = TreeDataSlice::<u64, u64>::new();
532        let owned: Vec<u64> = keys.to_vec();
533        slice.set_source(move || owned.iter().map(|k| TreeRow::new(*k, *k, 0)).collect());
534        slice.reload();
535        slice
536    }
537
538    fn reshape(slice: &TreeDataSlice<u64, u64>, keys: &[u64]) {
539        let owned: Vec<u64> = keys.to_vec();
540        slice.set_source(move || owned.iter().map(|k| TreeRow::new(*k, *k, 0)).collect());
541        slice.reload();
542    }
543
544    fn same_view_payload(view_id: ViewId, rows: Vec<usize>) -> DragPayload {
545        DragPayload::typed(RowDragData::<u64> {
546            source: view_id,
547            rows,
548            items: None,
549        })
550    }
551
552    #[test]
553    fn a_reorder_moves_the_node_dragged_not_the_slot_it_left() {
554        // Node 30 is grabbed at flat index 2, then the tree reflows mid-drag —
555        // the exact shape a spring-load auto-expand produces, since the dwell
556        // that expands a collapsed branch happens during the very drag. The
557        // drop must move node 30, not whichever node now sits at index 2.
558        let slice = slice_of(&[10, 20, 30]);
559        let recorded: Rc<RefCell<Vec<(u64, u64, DropPosition)>>> =
560            Rc::new(RefCell::new(Vec::new()));
561        let rec = recorded.clone();
562        slice.set_reorder(move |dragged, target, pos| {
563            rec.borrow_mut().push((dragged, target, pos));
564            true
565        });
566        let src = Rc::new(TreeSource::from_data_source(Rc::new(slice.clone())));
567        let vid = ViewId::next(ViewKind::Tree);
568
569        let _thunk = (src.dnd.snapshot_out_fn)(&[2]); // drag-start on node 30
570        let payload = same_view_payload(vid, vec![2]);
571
572        reshape(&slice, &[1, 2, 10, 20, 30]); // rows appear above mid-drag
573
574        assert_eq!(
575            (src.dnd.can_accept_fn)(&payload, 0, DropPosition::Before, vid),
576            DropResponse::Accept
577        );
578        assert!((src.dnd.accept_drop_fn)(
579            &payload,
580            0,
581            DropPosition::Before,
582            vid
583        ));
584        assert_eq!(
585            recorded.borrow().as_slice(),
586            &[(30, 1, DropPosition::Before)],
587            "the dragged node's key must move, not whichever node slid into its old index"
588        );
589    }
590
591    #[test]
592    fn a_reflowed_own_node_still_rejects_a_drop_onto_itself() {
593        // After the mid-drag reflow the dragged node sits at a NEW flat index;
594        // the own-row rejection must follow it there.
595        let slice = slice_of(&[10, 20, 30]);
596        slice.set_reorder(|_, _, _| true);
597        let src = Rc::new(TreeSource::from_data_source(Rc::new(slice.clone())));
598        let vid = ViewId::next(ViewKind::Tree);
599
600        let _thunk = (src.dnd.snapshot_out_fn)(&[2]); // node 30
601        let payload = same_view_payload(vid, vec![2]);
602
603        reshape(&slice, &[1, 2, 10, 20, 30]); // node 30 now at index 4
604
605        assert_eq!(
606            (src.dnd.can_accept_fn)(&payload, 4, DropPosition::Before, vid),
607            DropResponse::Reject
608        );
609        assert!(!(src.dnd.accept_drop_fn)(
610            &payload,
611            4,
612            DropPosition::Before,
613            vid
614        ));
615    }
616}
617
618#[cfg(test)]
619mod anchor_tests {
620    use super::*;
621    use teksilo_data::{TreeDataSlice, TreeRow};
622
623    fn slice_of(keys: &[u64]) -> TreeDataSlice<u64, u64> {
624        let slice = TreeDataSlice::<u64, u64>::new();
625        let owned: Vec<u64> = keys.to_vec();
626        slice.set_source(move || owned.iter().map(|k| TreeRow::new(*k, *k, 0)).collect());
627        slice.reload();
628        slice
629    }
630
631    #[test]
632    fn an_anchor_follows_its_row_when_rows_shift_above_it() {
633        // Row 30 starts at index 2. After two rows are inserted above it, a
634        // captured index would point at a different row entirely; the anchor
635        // resolves to 30's new position.
636        let slice = slice_of(&[10, 20, 30]);
637        let src = Rc::new(TreeSource::from_data_source(Rc::new(slice.clone())));
638        let anchor = src.anchor(2);
639        assert_eq!(anchor.index(), Some(2));
640
641        let shifted: Vec<u64> = vec![1, 2, 10, 20, 30];
642        slice.set_source(move || shifted.iter().map(|k| TreeRow::new(*k, *k, 0)).collect());
643        slice.reload();
644
645        assert_eq!(
646            anchor.index(),
647            Some(4),
648            "the anchor must track row 30 to its new index, not stay at 2"
649        );
650    }
651
652    #[test]
653    fn an_anchor_reports_none_once_its_row_is_gone() {
654        // Deleting the row must make the handler a no-op, not redirect it onto
655        // whichever row slid into the vacated slot.
656        let slice = slice_of(&[10, 20, 30]);
657        let src = Rc::new(TreeSource::from_data_source(Rc::new(slice.clone())));
658        let anchor = src.anchor(1); // row 20
659
660        let remaining: Vec<u64> = vec![10, 30];
661        slice.set_source(move || remaining.iter().map(|k| TreeRow::new(*k, *k, 0)).collect());
662        slice.reload();
663
664        assert_eq!(anchor.index(), None, "row 20 is gone");
665        assert!(!anchor.is_live());
666    }
667
668    #[test]
669    fn a_keyless_source_degrades_to_a_fixed_anchor() {
670        // No identity available: the anchor is no worse than capturing the
671        // index, and must not pretend the row vanished.
672        let anchor = crate::data_views::RowAnchor::fixed(7);
673        assert_eq!(anchor.index(), Some(7));
674        assert!(anchor.is_live());
675    }
676
677    #[test]
678    fn an_editing_reconcile_converges_in_one_pass() {
679        // `reconcile_editing_row` writes `editing_cell` from inside a pane's
680        // build. That is safe only because it settles: once the row index has
681        // been corrected, a second pass must write nothing. Pin that, so the
682        // write-during-build never becomes a rebuild loop.
683        use std::cell::RefCell;
684        use teksilo_core::signal::Signal;
685
686        let slice = slice_of(&[10, 20, 30]);
687        let src = Rc::new(TreeSource::from_data_source(Rc::new(slice.clone())));
688        let editing: Signal<Option<(usize, usize)>> = Signal::new(Some((2, 0)));
689        let slot = Rc::new(RefCell::new(None));
690        let anchor_of = |i: usize| src.anchor(i);
691
692        // Pass 1 captures the anchor for row 30.
693        crate::data_views::reconcile_editing_row(&editing, &slot, &anchor_of);
694        assert_eq!(editing.get(), Some((2, 0)));
695
696        // Row 30 moves to index 4.
697        let shifted: Vec<u64> = vec![1, 2, 10, 20, 30];
698        slice.set_source(move || shifted.iter().map(|k| TreeRow::new(*k, *k, 0)).collect());
699        slice.reload();
700
701        crate::data_views::reconcile_editing_row(&editing, &slot, &anchor_of);
702        assert_eq!(editing.get(), Some((4, 0)), "corrected once");
703
704        // The settling pass must be a no-op.
705        let before = editing.get();
706        crate::data_views::reconcile_editing_row(&editing, &slot, &anchor_of);
707        assert_eq!(editing.get(), before, "second pass must write nothing");
708    }
709}