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