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