Skip to main content

teksilo_widgets/
tree_view.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! TreeView — a virtualized, expandable/collapsible hierarchical list widget.
5//!
6//! Displays a [`TreeModel<T>`](teksilo_data::TreeModel) as an indented tree.
7//! Internally each view owns a [`TreeSlice`] for independent
8//! expand state, so two `TreeView`s on the same model can be open at different
9//! depths simultaneously. Only rows in the visible viewport + a small buffer have
10//! live widgets — rows outside the buffer are dormant, matching `ListView`'s
11//! virtualization model. An external [`TreeDataSource`]
12//! is also accepted via [`TreeView::from_source`] when the data lives outside a
13//! `TreeModel`.
14//!
15//! Row heights come in three modes: uniform (`item_height`, default fast path),
16//! exact per-flat-index callback (`item_height_fn`), and auto-measured
17//! (`auto_item_height` — height-for-width per row, scroll-anchored).
18//!
19//! ## Example
20//!
21//! ```rust
22//! # use teksilo_widgets::TreeView;
23//! # use teksilo_widgets::primitives::{HStack, Padding, TextWidget};
24//! # use teksilo_data::TreeModel;
25//! # use teksilo_i18n::lit;
26//! # struct Item { title: String }
27//! # let tree_model: TreeModel<Item> = TreeModel::new();
28//! let _w = TreeView::new(tree_model, |item, entry, _selected| {
29//!     let indent = entry.depth as f32 * 20.0;
30//!     Box::new(HStack::new()
31//!         .child(Padding::new(0.0, 0.0, 0.0, indent))
32//!         .child(TextWidget::new(lit!(&item.title))))
33//! })
34//! .item_height(28.0);
35//! ```
36
37use std::cell::{Cell, RefCell};
38use std::rc::Rc;
39use std::time::Duration;
40
41use teksilo_canvas::{Point, Rect, Size, SizeProposal};
42use teksilo_tokens::{BorderRole, Easing};
43
44use teksilo_core::DropFeedback;
45use teksilo_core::accessibility::AccessNodeBuilder;
46use teksilo_core::binding::BindingLevel;
47use teksilo_core::signal::{Prop, Signal};
48use teksilo_core::widget::{LayoutContext, Widget, WidgetPlacement};
49use teksilo_core::widget_builder::HandlerSet;
50use teksilo_core::widget_id::WidgetId;
51
52use teksilo_data::selection_model::SelectionModel;
53use teksilo_data::tree_slice::{TreeSlice, TreeSliceHandle};
54use teksilo_data::{
55    DropPosition, DropResponse, FlatEntry, ItemKey, KeyedSelectionModel, NodeId, TreeDataSource,
56    TreeModel,
57};
58
59use crate::common::row_metrics::{HeightSource, RowMetrics, SharedRowMetrics};
60use crate::common::scroll::OverscrollBehavior;
61use crate::data_views::{DragTransferMode, RowDragData, RowSelection, ViewId, ViewKind};
62use crate::scroll_area::ScrollBarMode;
63use crate::scroll_bar::{ScrollBar, ScrollBarOrientation, ScrollBarVisual};
64use crate::tree_source::{TreeRow, TreeRowMeta, TreeSource};
65
66const BUFFER_ITEMS: usize = 5;
67const DEFAULT_ITEM_HEIGHT: f32 = 28.0;
68const SCROLLBAR_THICKNESS: f32 = 12.0;
69
70/// Per-row context passed to a 4-arg TreeView delegate. Carries a
71/// reference to the slice handle and the row's `NodeId` so the
72/// delegate can wire chevron toggles and other tree-aware behavior
73/// without manually cloning state outside the closure.
74///
75/// Created internally by [`TreeView::new_with_context`]. Not
76/// constructed directly by user code.
77pub struct TreeRowContext<'a, T: 'static> {
78    slice: &'a TreeSliceHandle<T>,
79    node_id: teksilo_data::NodeId,
80}
81
82impl<'a, T: 'static> TreeRowContext<'a, T> {
83    /// Toggle callback for this row's chevron. Wires in one line:
84    /// `.on_toggle_rc(ctx.toggle_callback())`.
85    pub fn toggle_callback(&self) -> std::rc::Rc<dyn Fn(&mut teksilo_core::widget::EventContext)> {
86        let slice = self.slice.clone();
87        let node = self.node_id;
88        std::rc::Rc::new(move |_ctx| slice.toggle_expand(node))
89    }
90
91    /// Cloned handle to the slice — call `.toggle_expand(node)`,
92    /// `.expand(node)`, `.collapse(node)` directly.
93    pub fn slice_handle(&self) -> TreeSliceHandle<T> {
94        self.slice.clone()
95    }
96
97    /// The `NodeId` of this row in the backing `TreeModel`.
98    pub fn node_id(&self) -> teksilo_data::NodeId {
99        self.node_id
100    }
101}
102
103/// Delegate type for the built-in `TreeModel` path: takes the inputs the 3-arg
104/// form gets plus the optional `TreeRowContext`. Both the 3-arg `new` and the
105/// 4-arg `new_with_context` produce a closure of this shape.
106type TreeDelegate<T> = dyn Fn(&T, &FlatEntry, bool, &TreeRowContext<'_, T>) -> Box<dyn Widget>;
107
108/// Delegate type for the generic [`TreeView::from_source`] path: key-erased, so
109/// it receives a [`TreeRow`] (flat metadata + a chevron toggle) instead of the
110/// `NodeId`-typed `FlatEntry` / `TreeRowContext`.
111type SourceTreeDelegate<T> = dyn Fn(&T, &TreeRow, bool) -> Box<dyn Widget>;
112
113/// Internal, uniform per-row builder both constructors lower to:
114/// `(visible_index, &item, &meta, selected) -> row widget`. The built-in
115/// wrapper rebuilds the `NodeId` `TreeRowContext` from the index; the generic
116/// wrapper builds a key-erased `TreeRow`.
117type RowDelegate<T> = dyn Fn(usize, &T, &TreeRowMeta, bool) -> Box<dyn Widget>;
118
119/// A virtualized hierarchical tree widget backed by a `TreeModel<T>`.
120///
121/// ```rust
122/// # use teksilo_widgets::{TreeView};
123/// # use teksilo_widgets::primitives::{HStack, Padding, TextWidget};
124/// # use teksilo_data::TreeModel;
125/// # use teksilo_i18n::lit;
126/// # struct Item { title: String }
127/// # let tree_model: TreeModel<Item> = TreeModel::new();
128/// let _w = TreeView::new(tree_model, |item, entry, _selected| {
129///     let indent = entry.depth as f32 * 20.0;
130///     Box::new(HStack::new()
131///         .child(Padding::new(0.0, 0.0, 0.0, indent))
132///         .child(TextWidget::new(lit!(&item.title))))
133/// })
134/// .item_height(28.0);
135/// ```
136use crate::data_views::DropViz;
137
138pub struct TreeView<T: 'static> {
139    /// Index-keyed erased backing — the built-in `TreeSlice` or an external
140    /// `TreeDataSource`. All virtualization / DnD / keyboard work goes through
141    /// this in flat indices.
142    source: Rc<TreeSource<T>>,
143    /// Present only for the built-in `TreeModel` path; backs the `NodeId`-typed
144    /// public expand API + [`tree_slice`](Self::tree_slice). `None` for
145    /// [`from_source`](Self::from_source).
146    slice: Option<Rc<TreeSlice<T>>>,
147    /// Uniform per-row builder produced by whichever constructor was used.
148    row_delegate: Rc<RowDelegate<T>>,
149    item_height: f32,
150    /// Height-mode selection (uniform / exact callback / auto-measure).
151    height_source: HeightSource,
152    /// Row geometry — all virtualization consumers go through this.
153    metrics: SharedRowMetrics,
154    /// Row selection — index-based `SelectionModel` or keyed
155    /// `KeyedSelectionModel<NodeId>`, unified behind the index-facing facade.
156    row_selection: Option<RowSelection>,
157
158    /// Keyboard-focused flat index.
159    focused_index: Rc<Cell<Option<usize>>>,
160    /// The row identity `focused_index` currently points at, refreshed
161    /// alongside every write to `focused_index`. A tree's structural changes
162    /// (insert/remove/reorder, and — unlike a flat list — expand/collapse)
163    /// surface as a bare version bump with no `DataChange` delta to shift the
164    /// cursor by, so it is reconciled by identity instead: resolved against
165    /// the source on every version bump and used to rewrite `focused_index`
166    /// to wherever the row landed (or drop it if the row is gone). See
167    /// `crate::data_views::RowAnchor` and `reconcile_editing_row`, which
168    /// plays the same role for `TableView`'s `editing_cell`.
169    focused_anchor: Rc<RefCell<Option<crate::data_views::RowAnchor>>>,
170    /// The realized `(flat index -> row wrapper id)` map, filled by the body
171    /// pane each build. Lets this widget's `&self` methods resolve a row index
172    /// to a widget without reaching into the pane. Mirrors `ListView::row_map`.
173    row_map: Rc<RefCell<Vec<(usize, WidgetId)>>>,
174
175    /// Type-ahead ("type to jump") label extractor — opt-in via
176    /// [`type_ahead_label`](Self::type_ahead_label).
177    /// Per-row tooltip resolvers. The view attaches these itself, against the
178    /// row widget the delegate produced — an app cannot reach that widget to
179    /// hang a `.tooltip(...)` on it. Shared with `ListView`; see
180    /// [`RowTooltips`](crate::data_views::RowTooltips).
181    row_tooltips: crate::data_views::RowTooltips<T>,
182    type_ahead_label: Option<Rc<dyn Fn(&T) -> String>>,
183    /// Reset window for the type-ahead search term.
184    type_ahead_timeout: Duration,
185    /// Persistent type-ahead buffer (survives the per-keystroke rebuild).
186    type_ahead: Rc<crate::common::type_ahead::TypeAheadState>,
187
188    /// Enable intra-widget drag reordering.
189    reorderable: bool,
190
191    /// Cross-widget export / foreign-receive machinery — the builders
192    /// (`.exportable`, `.export_external`, `.accept_foreign_rows`,
193    /// `.on_rows_received`, `.on_rows_transferred_out`), the drag-start payload
194    /// build, and the move-out completion, shared by all five data views.
195    export: crate::data_views::RowExport<T>,
196
197    /// Whether a row-body PointerUp on a branch row auto-toggles its
198    /// expansion. Defaults to `true` (legacy behavior — convenient
199    /// for hand-built delegates without an explicit chevron). Set to
200    /// `false` when the delegate provides its own chevron tap target
201    /// (e.g. `StandardTreeItem`) to avoid the auto-toggle firing in
202    /// addition to the chevron's own click and cancelling out.
203    row_click_expands: bool,
204
205    /// Active drop feedback (set by on_drag_hover, cleared by on_drag_leave,
206    /// read by paint). Reactive Signal — bound at `RepaintOnly` so any
207    /// `set(...)` call dirties the TreeView for repaint automatically.
208    drop_feedback: Signal<Option<DropViz>>, // insertion line OR folder highlight
209
210    /// Optional row-activation callback (a click on the row body per
211    /// `activate_on`, or Enter/Space on the focused row) — distinct from
212    /// *selection*, which also moves on arrow navigation. Lets a view
213    /// open/commit a row without firing on every navigation step.
214    on_activate: Option<Rc<dyn Fn(usize, &mut teksilo_core::widget::EventContext)>>,
215    /// Whether activation is a single or double click (default `DoubleClick`).
216    activate_on: crate::data_views::ActivateOn,
217
218    /// `true` while this view (root or descendant) holds keyboard focus — the
219    /// root's inclusive [`BuildContext::view_focus_active`](teksilo_core::BuildContext::view_focus_active) signal, bound
220    /// `RepaintOnly`. With [`focus_visible`](Self::focus_visible) it drives the
221    /// **container focus ring**: when the view is Tab-focused but nothing is
222    /// selected, no row ring shows, so the whole view outlines itself instead —
223    /// the user can see where keyboard focus landed before they arrow.
224    view_focused: Signal<bool>,
225    /// Input-modality `:focus-visible`. Gates the container ring (and row rings)
226    /// to keyboard navigation, never a mouse click. Bound `RepaintOnly`.
227    focus_visible: Signal<bool>,
228
229    // Persistent scroll state
230    scroll_y: Signal<f32>,
231    max_scroll_y: Signal<f32>,
232    /// Scroll-chaining behavior at the boundary (default `Chain`).
233    overscroll_behavior: OverscrollBehavior,
234    viewport_ratio_y: Signal<f32>,
235
236    /// Animate wheel scrolling instead of snapping to the new offset.
237    /// Enabled by default — mirrors `ScrollArea`. Without it, each wheel
238    /// notch jumps by `item_height` per delivered line (typically 3),
239    /// which reads as a coarse multi-row jump rather than a smooth glide.
240    smooth_scrolling: bool,
241    /// Duration of the smooth scroll animation.
242    smooth_scroll_duration: Duration,
243
244    /// How the scroll bar is displayed. Defaults to `Permanent` — a
245    /// layout sibling that reserves its own width. `Overlay` / `Thin`
246    /// float over the content instead, like `ScrollArea`.
247    scroll_bar_style: ScrollBarMode,
248
249    /// Root-level **relayout** trigger. The root's `place_children` owns the
250    /// scrollbar totals (`max_scroll_y`, thumb ratio) and the content-width
251    /// decision, none of which its `build` output depends on — so a source
252    /// change, or a pane measurement that moves the content total, needs a
253    /// re-place here rather than a rebuild. Bumped by the source-version
254    /// observer and by [`body_pane::TreeViewBodyPane::total_refresh`].
255    layout_refresh: Signal<u64>,
256    /// Root-level **repaint** trigger for the container focus ring, which is
257    /// suppressed as soon as anything is selected. Selection changes rebuild
258    /// the pane (the delegate's `selected` argument) but must not rebuild the
259    /// root — they only change what the root paints.
260    paint_refresh: Signal<u64>,
261
262    /// Pane-local rebuild trigger, owned here so it survives pane rebuilds.
263    pane_version: Signal<u64>,
264    /// Buffered row range materialized by the pane's latest build.
265    pane_built_start: Rc<Cell<usize>>,
266    pane_built_end: Rc<Cell<usize>>,
267
268    // Set during build
269    body_pane_id: Option<WidgetId>,
270    scrollbar_id: Option<WidgetId>,
271    viewport_height: Rc<Cell<f32>>,
272    /// The TreeView's own absolute (window) bounds, cached from
273    /// `place_children` so the keyboard handler can chase the selected row
274    /// into enclosing scroll areas via
275    /// [`EventContext::ensure_visible`](teksilo_core::widget::EventContext::ensure_visible).
276    /// Rows are not distinct focusable nodes, so the focus-driven follow never
277    /// reveals the selected row in an outer scroller — this closes that gap.
278    viewport_bounds: Rc<Cell<Rect>>,
279    /// Content width (updated during `place_children`, used by drag
280    /// feedback so the insertion line / into-folder highlight spans the
281    /// row's actual width instead of a guess). Mirrors `ListView`.
282    placed_content_width: Rc<Cell<f32>>,
283    tree_id: ViewId,
284
285    /// Whole-view enabled state, statically or reactively. Forwarded to the
286    /// arena via `ctx.enabled_when(self_id, self.enabled.clone())` at build
287    /// time; a disabled view greys out and stops accepting focus /
288    /// selection / keyboard input (arena-gated).
289    enabled: Prop<bool>,
290}
291
292mod body_pane;
293mod builder;
294mod widget_impl;
295
296// std::fmt::Debug for the (non-Debug) generic fields.
297impl<T: 'static> std::fmt::Debug for TreeView<T> {
298    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
299        f.debug_struct("TreeView")
300            .field("visible_count", &self.source.visible_count())
301            .field("item_height", &self.item_height)
302            .field("scroll_bar_style", &self.scroll_bar_style)
303            .field("scroll_y", &self.scroll_y.get())
304            .finish()
305    }
306}
307
308#[cfg(test)]
309mod tests;