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}