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}