Skip to main content

teksilo_widgets/tree_view/
builder.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Constructors and builder-pattern configuration for [`TreeView`].
5//!
6
7use super::*;
8
9impl<T: 'static> TreeView<T> {
10    /// Create a new TreeView backed by a `TreeModel<T>`.
11    ///
12    /// The delegate receives `(&item, &FlatEntry, selected)` and returns a
13    /// boxed widget. The `FlatEntry` provides `depth`, `has_children`, and
14    /// `is_expanded` for rendering indentation and expand/collapse toggles.
15    pub fn new(
16        model: TreeModel<T>,
17        delegate: impl Fn(&T, &FlatEntry, bool) -> Box<dyn Widget> + 'static,
18    ) -> Self {
19        // Adapt the 3-arg delegate to the internal 4-arg shape by
20        // discarding the context.
21        let adapted =
22            move |item: &T, entry: &FlatEntry, sel: bool, _ctx: &TreeRowContext<'_, T>| {
23                delegate(item, entry, sel)
24            };
25        Self::new_internal(model, Rc::new(adapted))
26    }
27
28    /// Like [`new`](Self::new), but the delegate also receives a
29    /// [`TreeRowContext`] from which `.toggle_callback()` can be
30    /// pulled in a single line — eliminating the need to manually
31    /// clone the slice handle outside the closure.
32    ///
33    /// ```rust
34    /// # use teksilo_widgets::{TreeView, StandardTreeItem};
35    /// # use teksilo_data::TreeModel;
36    /// # use teksilo_i18n::lit;
37    /// # struct Item { title: String }
38    /// # let model: TreeModel<Item> = TreeModel::new();
39    /// let _w = TreeView::new_with_context(model, |item, entry, selected, ctx| {
40    ///     Box::new(
41    ///         StandardTreeItem::new(lit!(&item.title))
42    ///             .from_entry(entry)
43    ///             .selected(selected)
44    ///             .on_toggle_rc(ctx.toggle_callback())
45    ///     )
46    /// });
47    /// ```
48    pub fn new_with_context(
49        model: TreeModel<T>,
50        delegate: impl Fn(&T, &FlatEntry, bool, &TreeRowContext<'_, T>) -> Box<dyn Widget> + 'static,
51    ) -> Self {
52        Self::new_internal(model, Rc::new(delegate))
53    }
54
55    fn new_internal(model: TreeModel<T>, delegate: Rc<TreeDelegate<T>>) -> Self {
56        let slice = Rc::new(TreeSlice::new(model));
57        let source = Rc::new(TreeSource::from_data_source(slice.clone()));
58        // Built-in wrapper: rebuild the `NodeId` `FlatEntry` + `TreeRowContext`
59        // from the visible index so the existing 3-/4-arg delegate keeps its
60        // exact API. `with_row` only invokes this for a present row, so
61        // `visible_node_id(i)` is `Some`; the `None` arm is an unreachable guard.
62        let slice_for_rows = slice.clone();
63        let row_delegate: Rc<RowDelegate<T>> = Rc::new(move |i, item, meta, selected| {
64            let handle = slice_for_rows.handle();
65            match handle.visible_node_id(i) {
66                Some(node_id) => {
67                    let entry = FlatEntry {
68                        node_id,
69                        depth: meta.depth,
70                        has_children: meta.has_children,
71                        is_expanded: meta.is_expanded,
72                    };
73                    let row_ctx = TreeRowContext {
74                        slice: &handle,
75                        node_id,
76                    };
77                    delegate(item, &entry, selected, &row_ctx)
78                }
79                None => crate::data_views::default_placeholder(),
80            }
81        });
82        Self::assemble(source, Some(slice), row_delegate)
83    }
84
85    /// Create a TreeView backed by any [`TreeDataSource`] — an external source of
86    /// truth (e.g. an entity store) carrying its own `Key`, so it needs no
87    /// `TreeModel` mirror. The delegate receives `(&item, &TreeRow, selected)`;
88    /// [`TreeRow`] exposes `depth` / `has_children` / `is_expanded` and a one-call
89    /// chevron `toggle_callback()`. Drop validation + lazy windowing route
90    /// through the source's `can_accept` / `accept_drop` / `row_state`.
91    pub fn from_source<S: TreeDataSource<Item = T>>(
92        source: S,
93        delegate: impl Fn(&T, &TreeRow, bool) -> Box<dyn Widget> + 'static,
94    ) -> Self {
95        Self::from_source_rc(Rc::new(source), Rc::new(delegate))
96    }
97
98    fn from_source_rc<S: TreeDataSource<Item = T>>(
99        s: Rc<S>,
100        delegate: Rc<SourceTreeDelegate<T>>,
101    ) -> Self {
102        let source = Rc::new(TreeSource::from_data_source(s));
103        let source_for_rows = source.clone();
104        let row_delegate: Rc<RowDelegate<T>> = Rc::new(move |i, item, _meta, selected| {
105            let row = TreeSource::row_context(&source_for_rows, i);
106            delegate(item, &row, selected)
107        });
108        Self::assemble(source, None, row_delegate)
109    }
110
111    /// Like [`from_source`](Self::from_source) but with **keyed** selection: the
112    /// `KeyedSelectionModel<S::Key>` tracks selection by source identity, so it
113    /// survives expand / collapse / filter / reorder and stays consistent across
114    /// two views of the same source. The view stays `TreeView<T>` — the `Key` is
115    /// captured here. Pruning consults the source's
116    /// [`contains_key`](teksilo_data::TreeDataSource::contains_key), so a
117    /// collapsed-but-present node keeps its selection.
118    pub fn from_source_keyed<S: TreeDataSource<Item = T>>(
119        source: S,
120        keyed: KeyedSelectionModel<S::Key>,
121        delegate: impl Fn(&T, &TreeRow, bool) -> Box<dyn Widget> + 'static,
122    ) -> Self
123    where
124        S::Key: ItemKey,
125    {
126        let s = Rc::new(source);
127        let key_at = {
128            let s = s.clone();
129            Rc::new(move |i| s.key_at(i)) as Rc<dyn Fn(usize) -> Option<S::Key>>
130        };
131        let len = {
132            let s = s.clone();
133            Rc::new(move || s.visible_count()) as Rc<dyn Fn() -> usize>
134        };
135        let contains = {
136            let s = s.clone();
137            Rc::new(move |k: &S::Key| s.contains_key(k)) as Rc<dyn Fn(&S::Key) -> bool>
138        };
139        let row_selection = RowSelection::from_keyed(keyed, key_at, len, contains);
140        let mut view = Self::from_source_rc(s, Rc::new(delegate));
141        view.row_selection = Some(row_selection);
142        view
143    }
144
145    fn assemble(
146        source: Rc<TreeSource<T>>,
147        slice: Option<Rc<TreeSlice<T>>>,
148        row_delegate: Rc<RowDelegate<T>>,
149    ) -> Self {
150        let view_id = ViewId::next(ViewKind::Tree);
151        Self {
152            source,
153            slice,
154            row_delegate,
155            item_height: DEFAULT_ITEM_HEIGHT,
156            height_source: HeightSource::Uniform,
157            metrics: Rc::new(RefCell::new(RowMetrics::uniform(DEFAULT_ITEM_HEIGHT, 0.0))),
158            row_selection: None,
159            focused_index: Rc::new(Cell::new(None)),
160            focused_anchor: Rc::new(RefCell::new(None)),
161            row_map: Rc::new(RefCell::new(Vec::new())),
162            row_tooltips: Default::default(),
163            type_ahead_label: None,
164            type_ahead_timeout: crate::common::type_ahead::DEFAULT_TYPE_AHEAD_TIMEOUT,
165            type_ahead: crate::common::type_ahead::TypeAheadState::new(),
166            reorderable: false,
167            export: crate::data_views::RowExport::default(),
168            row_click_expands: true,
169            drop_feedback: Signal::new(None),
170            // Replaced at build with the live tree signals.
171            view_focused: Signal::new(false),
172            focus_visible: Signal::new(false),
173            on_activate: None,
174            activate_on: crate::data_views::ActivateOn::default(),
175            overscroll_behavior: OverscrollBehavior::default(),
176            smooth_scrolling: true,
177            smooth_scroll_duration: Duration::from_millis(150),
178            scroll_bar_style: ScrollBarMode::Permanent,
179            scroll_y: Signal::new_animated(0.0),
180            max_scroll_y: Signal::new(0.0),
181            viewport_ratio_y: Signal::new(1.0),
182            layout_refresh: Signal::new(0_u64),
183            paint_refresh: Signal::new(0_u64),
184            pane_version: Signal::new(0_u64),
185            pane_built_start: Rc::new(Cell::new(0)),
186            pane_built_end: Rc::new(Cell::new(0)),
187            body_pane_id: None,
188            scrollbar_id: None,
189            viewport_height: Rc::new(Cell::new(600.0)),
190            viewport_bounds: Rc::new(Cell::new(Rect::ZERO)),
191            placed_content_width: Rc::new(Cell::new(0.0)),
192            tree_id: view_id,
193            enabled: Prop::Static(true),
194        }
195    }
196
197    /// Enable or disable the whole view. A disabled view greys out and stops
198    /// accepting focus / selection / keyboard input (arena-gated).
199    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
200        self.enabled = enabled.into();
201        self
202    }
203
204    /// Set the scroll-chaining behavior at the boundary (default
205    /// [`OverscrollBehavior::Chain`]; [`Contain`](OverscrollBehavior::Contain)
206    /// disables chaining to an ancestor scrollable).
207    pub fn overscroll_behavior(mut self, behavior: OverscrollBehavior) -> Self {
208        self.overscroll_behavior = behavior;
209        self
210    }
211
212    /// Re-materialize `self.metrics` after a height-mode / item-height
213    /// builder call.
214    fn remake_metrics(&self) {
215        *self.metrics.borrow_mut() = self.height_source.make_metrics(self.item_height, 0.0);
216    }
217
218    /// Set the fixed height per row (default 28.0) — the uniform fast
219    /// path. Mutually exclusive with [`item_height_fn`](Self::item_height_fn)
220    /// and [`auto_item_height`](Self::auto_item_height); the last mode
221    /// setter wins.
222    pub fn item_height(mut self, height: f32) -> Self {
223        self.item_height = height;
224        self.height_source = HeightSource::Uniform;
225        self.remake_metrics();
226        self
227    }
228
229    /// Enable or disable animated wheel scrolling (enabled by default).
230    /// When disabled, wheel events snap immediately to the new offset.
231    pub fn smooth_scrolling(mut self, enabled: bool) -> Self {
232        self.smooth_scrolling = enabled;
233        self
234    }
235
236    /// Duration of the smooth scroll animation (default 150 ms).
237    pub fn smooth_scroll_duration(mut self, duration: Duration) -> Self {
238        self.smooth_scroll_duration = duration;
239        self
240    }
241
242    /// How the scroll bar is displayed (default `Permanent`). `Overlay`
243    /// and `Thin` float the bar over the content instead of reserving a
244    /// layout column for it, mirroring `ScrollArea::scroll_bar_style`.
245    /// **Scroll from a signal the caller owns**, so the position survives the
246    /// view.
247    ///
248    /// A `TreeView` mints its own by default, which is right for a tree whose
249    /// lifetime is the writer's: it is created once and scrolls until they leave.
250    /// It is wrong for one inside a dock, whose content is torn down and rebuilt
251    /// whenever the layout changes -- opening a panel beside it, or the first
252    /// reveal of the band a result previews into. The tree comes back at the top,
253    /// and the row the writer was reading is somewhere above it.
254    ///
255    /// Hold the signal wherever the *model* lives and the position outlives the
256    /// widget, as the expand set already does.
257    ///
258    /// ⚠ Pass an **animated** signal (`Signal::new_animated`) unless smooth
259    /// scrolling is off: the view animates this one, and a plain signal makes
260    /// every wheel notch a jump.
261    pub fn scroll_signal(mut self, scroll: Signal<f32>) -> Self {
262        self.scroll_y = scroll;
263        self
264    }
265
266    pub fn scroll_bar_style(mut self, style: ScrollBarMode) -> Self {
267        self.scroll_bar_style = style;
268        self
269    }
270
271    /// Per-row heights from a callback over the *flat (visible) index*.
272    /// The callback must be pure (same index + same data → same height);
273    /// it is re-swept from the first changed flat index on every model
274    /// change or expand/collapse. No measurement pass runs.
275    pub fn item_height_fn(mut self, f: impl Fn(usize) -> f32 + 'static) -> Self {
276        self.height_source = HeightSource::Exact(Rc::new(f));
277        self.remake_metrics();
278        self
279    }
280
281    /// Auto-measured row heights: each realized row is measured at the
282    /// tree's content width (height-for-width), unrealized rows assume
283    /// `estimated`. Scroll anchoring keeps content above the viewport
284    /// stationary as estimates are corrected; measured heights above a
285    /// toggled row survive expand/collapse (divergence-driven
286    /// invalidation).
287    pub fn auto_item_height(mut self, estimated: f32) -> Self {
288        self.height_source = HeightSource::Auto { estimated };
289        self.remake_metrics();
290        self
291    }
292
293    /// Whether a row-body PointerUp on a branch row auto-toggles its
294    /// expansion (default `true`). Set to `false` when the delegate
295    /// provides its own chevron tap target (e.g. `StandardTreeItem`)
296    /// — without this, the auto-toggle fires in addition to the
297    /// chevron's own click and they cancel out, leaving the row
298    /// expanded only on body clicks.
299    pub fn row_click_expands(mut self, b: bool) -> Self {
300        self.row_click_expands = b;
301        self
302    }
303
304    /// Set the index-based selection model (visible positions). Unlike
305    /// `ListView` (where every structural change carries an insert/remove
306    /// `DataChange` the selection index-shifts against), a `TreeView`'s
307    /// structural changes — including expand/collapse — surface only as a
308    /// version bump, with no delta to shift a *selected index* by; a moved
309    /// row's old index is only clamped into range, not followed to its new
310    /// position (`focused_index`, the keyboard cursor, tracks by identity
311    /// via a `RowAnchor` and IS followed). For selection that survives
312    /// expand / collapse / filter and node moves, use
313    /// [`keyed_selection`](Self::keyed_selection) instead.
314    pub fn selection(mut self, sel: SelectionModel) -> Self {
315        self.row_selection = Some(RowSelection::from_index(sel));
316        self
317    }
318
319    /// Set a keyed selection model (by `NodeId`). Selection is tracked by node
320    /// identity, so it survives expand / collapse, filtering, and node moves —
321    /// and stays consistent if two views share the model. Pruned of deleted
322    /// nodes on each slice change. Mutually exclusive with
323    /// [`selection`](Self::selection) (last one set wins).
324    pub fn keyed_selection(mut self, keyed: KeyedSelectionModel<NodeId>) -> Self {
325        // Built-in `TreeModel` path only; on `from_source` use
326        // [`from_source_keyed`](Self::from_source_keyed) (the `Key` differs).
327        let Some(slice) = self.slice.clone() else {
328            return self;
329        };
330        let key_at = {
331            let tsh = slice.handle();
332            Rc::new(move |i| tsh.visible_node_id(i)) as Rc<dyn Fn(usize) -> Option<NodeId>>
333        };
334        let len = {
335            let tsh = slice.handle();
336            Rc::new(move || tsh.visible_count()) as Rc<dyn Fn() -> usize>
337        };
338        // A collapsed-but-present node must NOT be pruned, so existence is
339        // checked against the tree, not the visible projection.
340        let contains = {
341            let tsh = slice.handle();
342            Rc::new(move |n: &NodeId| tsh.tree().with_item(*n, |_| ()).is_some())
343                as Rc<dyn Fn(&NodeId) -> bool>
344        };
345        self.row_selection = Some(RowSelection::from_keyed(keyed, key_at, len, contains));
346        self
347    }
348
349    /// Enable intra-widget drag reordering.
350    ///
351    /// When enabled, tree rows can be dragged to reparent or reorder them.
352    /// Before/Into/After is chosen by where in the row the pointer drops; the
353    /// move is cycle-guarded — a drop onto the node itself or into its own
354    /// subtree is refused and shows no insertion line. Keyboard equivalent:
355    /// Alt+ArrowUp/Down.
356    pub fn reorderable(mut self, enabled: bool) -> Self {
357        self.reorderable = enabled;
358        self
359    }
360
361    /// Make rows **droppable outside this view** — on a
362    /// [`DropTarget`](crate::DropTarget), another data view, or the OS.
363    ///
364    /// A dragged row (or the whole selection, when the pressed row is part of a
365    /// multi-selection) carries clones of its items in a public
366    /// [`RowDragData<T>`](crate::RowDragData), so a foreign receiver can pull
367    /// them out with `payload.get_typed::<RowDragData<T>>()` /
368    /// `DropTarget::on_drop_typed::<RowDragData<T>>()` — no serialization. This
369    /// also makes rows a drag source even without [`reorderable`](Self::reorderable).
370    ///
371    /// `mode` chooses what happens to the origin rows once a *foreign* target
372    /// accepts them: [`DragTransferMode::Move`] removes them (via the source's
373    /// `on_drag_out`, or [`on_rows_transferred_out`](Self::on_rows_transferred_out)),
374    /// [`DragTransferMode::Copy`] leaves them. A same-view reorder is never a
375    /// transfer, so `mode` never affects it. Requires `T: Clone`.
376    pub fn exportable(mut self, mode: DragTransferMode) -> Self
377    where
378        T: Clone,
379    {
380        self.export.set_exportable(mode);
381        self
382    }
383
384    /// Additionally advertise the dragged rows as MIME data so they can be
385    /// dropped on a [`DropZone`](crate::DropZone) or exported to another
386    /// application / window via the OS. `f` maps the dragged items to
387    /// `(mime_type, bytes)` pairs (e.g. `text/plain`, `text/uri-list`, an
388    /// app-specific `application/x-…`). Implies [`exportable`](Self::exportable)
389    /// (defaulting to [`DragTransferMode::Move`] if not already set). Requires
390    /// `T: Clone`.
391    pub fn export_external(mut self, f: impl Fn(&[T]) -> Vec<(String, Vec<u8>)> + 'static) -> Self
392    where
393        T: Clone,
394    {
395        self.export.set_export_external(f);
396        self
397    }
398
399    /// Override how rows moved out to a foreign target are removed from this
400    /// view. Receives the dragged rows' indices (descending-safe) and the live
401    /// context. Without this, an [`exportable`](Self::exportable)
402    /// [`Move`](DragTransferMode::Move) drag removes them through the source's
403    /// `on_drag_out` (works out of the box for a `TreeSlice`/`TreeModel`).
404    pub fn on_rows_transferred_out(
405        mut self,
406        f: impl Fn(&[usize], &mut teksilo_core::widget::EventContext) + 'static,
407    ) -> Self {
408        self.export.set_on_rows_transferred_out(f);
409        self
410    }
411
412    /// Accept exported rows dropped from a **different** view or source without
413    /// writing a custom `TreeDataSource`. Pair with
414    /// [`on_rows_received`](Self::on_rows_received), which is handed the dropped
415    /// items and the insertion index. (Same-view reorder is
416    /// [`reorderable`](Self::reorderable); a custom `TreeDataSource` can still
417    /// accept foreign drops through its `can_accept`/`accept_drop` instead.)
418    pub fn accept_foreign_rows(mut self, accept: bool) -> Self {
419        self.export.accept_foreign_rows = accept;
420        self
421    }
422
423    /// Handler for rows accepted via [`accept_foreign_rows`](Self::accept_foreign_rows):
424    /// `(items, insertion_index, ctx)`. Insert them into your model at the
425    /// index.
426    pub fn on_rows_received(
427        mut self,
428        f: impl Fn(Vec<T>, usize, &mut teksilo_core::widget::EventContext) + 'static,
429    ) -> Self {
430        self.export.set_on_rows_received(f);
431        self
432    }
433
434    /// Set the row-**activation** handler — invoked with the flat row index on a
435    /// primary click on the row body, or **Enter** on the focused row.
436    /// Activation is distinct from *selection*: arrow-key navigation and
437    /// **Space** move / toggle the selection but do **not** activate, so a view
438    /// can open/commit a row on a deliberate click/Enter without firing on
439    /// every navigation step.
440    pub fn on_activate(
441        mut self,
442        f: impl Fn(usize, &mut teksilo_core::widget::EventContext) + 'static,
443    ) -> Self {
444        self.on_activate = Some(Rc::new(f));
445        self
446    }
447
448    /// Choose single- vs double-click activation (default
449    /// [`ActivateOn::DoubleClick`](crate::ActivateOn) — the cross-platform
450    /// convention; pass [`SingleClick`](crate::ActivateOn::SingleClick) for the
451    /// KDE/web/Scrivener feel). Enter activates in either mode.
452    pub fn activate_on(mut self, mode: crate::data_views::ActivateOn) -> Self {
453        self.activate_on = mode;
454        self
455    }
456
457    /// Enable **type-ahead** ("type to jump"): typing a printable character
458    /// while the tree has keyboard focus jumps the selection to the next
459    /// *visible* row whose label starts with the accumulated search term,
460    /// wrapping around (Qt `keyboardSearch` / macOS & Windows type-select).
461    /// `label(&item)` yields the searchable text; matching is
462    /// ASCII-case-insensitive. A pause longer than the
463    /// [`type_ahead_timeout`](Self::type_ahead_timeout) starts a fresh term.
464    /// Whether a composite row tooltip offers dwell-to-sticky promotion.
465    /// Default `true`.
466    ///
467    /// Turn it off for a read-only row card: with nothing to reach into there
468    /// is nothing to pin, so the countdown indicator would promise an
469    /// interaction that does not exist and the surface would outlive the
470    /// pointer for no reason.
471    pub fn row_tooltip_sticky(mut self, on: bool) -> Self {
472        self.row_tooltips.set_composite_sticky(on);
473        self
474    }
475
476    /// Per-row plain tooltip: one line of text for the row under the pointer.
477    ///
478    /// The resolver receives the row's flat index and its item; returning
479    /// `None` leaves that row without a tip. Mutually exclusive with
480    /// [`row_rich_tooltip`](Self::row_rich_tooltip) and
481    /// [`row_composite_tooltip`](Self::row_composite_tooltip) — last setter
482    /// wins, matching the per-widget tooltip matrix.
483    ///
484    /// Opens to the row's trailing side, never below it: rows stack
485    /// vertically, so a tip below would cover the next row.
486    pub fn row_tooltip(
487        mut self,
488        f: impl Fn(usize, &T) -> Option<teksilo_i18n::LocalizedString> + 'static,
489    ) -> Self {
490        self.row_tooltips.set_plain(f);
491        self
492    }
493
494    /// Per-row rich tooltip — a registry key or inline
495    /// [`TooltipContent`](crate::tooltip::TooltipContent), both of which
496    /// convert into [`RichTooltipSource`](crate::tooltip::RichTooltipSource).
497    /// See [`row_tooltip`](Self::row_tooltip) for the shared semantics.
498    pub fn row_rich_tooltip(
499        mut self,
500        f: impl Fn(usize, &T) -> Option<crate::tooltip::RichTooltipSource> + 'static,
501    ) -> Self {
502        self.row_tooltips.set_rich(f);
503        self
504    }
505
506    /// Per-row composite tooltip — an arbitrary widget tree describing the row.
507    ///
508    /// The body is built for every **realized** row (the virtualization window)
509    /// and rebuilt with it, so keep the resolver cheap and defer anything
510    /// costly to the body's own first paint, which only runs if the tip is
511    /// actually shown. See [`row_tooltip`](Self::row_tooltip) for the rest.
512    pub fn row_composite_tooltip(
513        mut self,
514        f: impl Fn(usize, &T) -> Option<Box<dyn teksilo_core::widget::Widget>> + 'static,
515    ) -> Self {
516        self.row_tooltips.set_composite(f);
517        self
518    }
519
520    pub fn type_ahead_label(mut self, label: impl Fn(&T) -> String + 'static) -> Self {
521        self.type_ahead_label = Some(Rc::new(label));
522        self
523    }
524
525    /// Reset window between keystrokes before the type-ahead search term
526    /// clears (default 500 ms). A zero duration disables type-ahead.
527    pub fn type_ahead_timeout(mut self, timeout: Duration) -> Self {
528        self.type_ahead_timeout = timeout;
529        self
530    }
531
532    /// Expand a node programmatically. No-op on the `from_source` path (which
533    /// owns its own expand state — use the source's `set_expanded`).
534    pub fn expand(&self, node: teksilo_data::NodeId) {
535        if let Some(slice) = &self.slice {
536            slice.expand(node);
537        }
538    }
539
540    /// Collapse a node programmatically. No-op on the `from_source` path.
541    pub fn collapse(&self, node: teksilo_data::NodeId) {
542        if let Some(slice) = &self.slice {
543            slice.collapse(node);
544        }
545    }
546
547    /// Toggle a node's expand/collapse state. No-op on the `from_source` path.
548    pub fn toggle(&self, node: teksilo_data::NodeId) {
549        if let Some(slice) = &self.slice {
550            slice.toggle(node);
551        }
552    }
553
554    /// Expand all nodes. No-op on the `from_source` path.
555    pub fn expand_all(&self) {
556        if let Some(slice) = &self.slice {
557            slice.expand_all();
558        }
559    }
560
561    /// Collapse all nodes. No-op on the `from_source` path.
562    pub fn collapse_all(&self) {
563        if let Some(slice) = &self.slice {
564            slice.collapse_all();
565        }
566    }
567
568    /// Access the internal `TreeSlice` (for persistence of expand state).
569    /// `None` on the [`from_source`](Self::from_source) path, which has no
570    /// `TreeSlice` (the external source owns expand state).
571    pub fn tree_slice(&self) -> Option<&TreeSlice<T>> {
572        self.slice.as_deref()
573    }
574
575    /// The root's children, in the one order `build`, `children` and
576    /// `place_children` all rely on: body pane first, scrollbar second. The
577    /// pane is always mounted (an empty tree realizes zero rows inside it).
578    pub(super) fn child_ids(&self) -> Vec<WidgetId> {
579        [self.body_pane_id, self.scrollbar_id]
580            .into_iter()
581            .flatten()
582            .collect()
583    }
584
585    pub(super) fn total_content_height(&self) -> f32 {
586        self.metrics
587            .borrow_mut()
588            .total_height(self.source.visible_count())
589    }
590
591    pub(super) fn visible_range(&self) -> (usize, usize) {
592        self.metrics.borrow_mut().visible_range(
593            self.scroll_y.get(),
594            self.viewport_height.get(),
595            self.source.visible_count(),
596            BUFFER_ITEMS,
597        )
598    }
599
600    pub(super) fn clamp_scroll(&self) {
601        let max = self.max_scroll_y.get();
602        let current = self.scroll_y.get();
603        let clamped = current.clamp(0.0, max);
604        if (clamped - current).abs() > 0.001 {
605            self.scroll_y.set(clamped);
606        }
607    }
608}