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