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//! ## Scrolling to a row
20//!
21//! [`handle`](TreeView::handle) returns a [`TreeViewHandle`], a cheap `Clone`
22//! taken before the view is added and usable from any handler afterwards. Its
23//! [`scroll_to_index`](TreeViewHandle::scroll_to_index) and
24//! [`ensure_index_visible`](TreeViewHandle::ensure_index_visible) take a flat
25//! visible row index and are applied at the next layout pass, after row
26//! measurement and scroll anchoring, so they may follow an expansion in the
27//! same turn, or be called before mount:
28//!
29//! ```rust
30//! # use teksilo_widgets::TreeView;
31//! # use teksilo_widgets::primitives::TextWidget;
32//! # use teksilo_data::TreeModel;
33//! # use teksilo_i18n::lit;
34//! # let model: TreeModel<String> = TreeModel::new();
35//! let tree = TreeView::new(model, |item, _entry, _selected| {
36//!     Box::new(TextWidget::new(lit!(item)))
37//! });
38//! let handle = tree.handle();
39//! // … `ctx.add(tree)`; then, from a handler, after expanding the row's
40//! // ancestors in the same turn:
41//! handle.ensure_index_visible(42);
42//! ```
43//!
44//! ## The keyboard cursor
45//!
46//! The tree has one current row, its keyboard cursor: the row the arrow keys
47//! step from and the one it names to assistive technology as its active
48//! descendant while it holds keyboard focus. The same [`TreeViewHandle`]
49//! reads and moves it, in flat visible indices:
50//! [`cursor`](TreeViewHandle::cursor),
51//! [`set_cursor`](TreeViewHandle::set_cursor) (clamped to the last visible
52//! row; keyboard focus, the selection and the scroll offset are left alone)
53//! and [`cursor_signal`](TreeViewHandle::cursor_signal), for an application
54//! to observe. The cursor follows its row through an expansion or a collapse
55//! above it, whoever set it. A cursor set before mount is there at the first
56//! build.
57//!
58//! In a single selection the cursor follows the selection. In a multiple
59//! selection the two are **separate**: `Ctrl+↓` moves the cursor and leaves
60//! the selection, and a selection written from outside leaves the cursor
61//! where it was. Read the cursor, not the first selected row, for "the row
62//! the user is on".
63//!
64//! ## Pan to scroll
65//!
66//! The view installs [`common::scrollable::ScrollableBehavior`](crate::common::scrollable::ScrollableBehavior),
67//! which gives it the shared wheel arithmetic, a finger's pan and the
68//! `PanClaim` that puts it on a pan's claimant chain. A pan scrolls it, the
69//! release coasts, and a pan it cannot absorb hands the **whole** event to the
70//! container outside. Vertical only: this view owns no horizontal offset, so a
71//! horizontal pan is declined and chains outward. A pan that starts on a row
72//! scrolls rather than activating it, toggling its chevron, or collapsing a
73//! multi-selection onto it — the three things a *release* on that row commits.
74//! Activation is a gesture, so the arbitration cancels it; the chevron and the
75//! deferred collapse are raw `PointerUp` arms the arbitration cannot reach, so
76//! each asks `data_views::release_completes_the_press` whether the release still
77//! belongs to the row.
78//!
79//! ## Keyboard
80//!
81//! Arrows move the cursor; `Home` / `End` reach the first and last **visible**
82//! row and `PageUp` / `PageDown` a viewport of them, each moving the selection
83//! unless the accelerator is held, which moves the cursor alone — in a
84//! multiple selection only: a single selection always moves with the cursor,
85//! a selection the application sets moves the cursor onto it, and a
86//! structural change carries the selection along with its row. `Shift`
87//! extends a range from the anchor and `Ctrl+Shift` extends it additively.
88//!
89//! `→` opens a closed node and, on one already open, moves into its first
90//! child; `←` closes an open node and, on a leaf or a closed one, ascends to
91//! the parent. Both mirror under RTL. `*` expands the whole subtree, `+` and
92//! `-` one level. `Space` selects or toggles, `Enter` activates, `Ctrl+A` and
93//! `Ctrl+Shift+A` select and deselect everything, and `Ctrl+Arrow` with
94//! `Ctrl+Space` build a disjoint selection. On macOS `⌘↓` opens a row, `⌘↑`
95//! ascends, and `⌥→`/`⌥←` expand or collapse a subtree.
96//!
97//! The full table, and why some of it is platform-specific, is in
98//! [docs/data-view-keyboard.md](https://github.com/ferntech-eu/teksilo/blob/main/docs/data-view-keyboard.md).
99//!
100//! ## Example
101//!
102//! ```rust
103//! # use teksilo_widgets::TreeView;
104//! # use teksilo_widgets::primitives::{HStack, Padding, TextWidget};
105//! # use teksilo_data::TreeModel;
106//! # use teksilo_i18n::lit;
107//! # struct Item { title: String }
108//! # let tree_model: TreeModel<Item> = TreeModel::new();
109//! let _w = TreeView::new(tree_model, |item, entry, _selected| {
110//!     let indent = entry.depth as f32 * 20.0;
111//!     Box::new(HStack::new()
112//!         .child(Padding::new(0.0, 0.0, 0.0, indent))
113//!         .child(TextWidget::new(lit!(&item.title))))
114//! })
115//! .item_height(28.0);
116//! ```
117
118use std::cell::{Cell, RefCell};
119use std::rc::Rc;
120use std::time::Duration;
121
122use teksilo_canvas::{Point, Rect, Size, SizeProposal};
123use teksilo_tokens::{BorderRole, InputTokens, OverscrollStyle, TargetRole};
124
125use teksilo_core::DropFeedback;
126use teksilo_core::accessibility::AccessNodeBuilder;
127use teksilo_core::binding::BindingLevel;
128use teksilo_core::kinetic::KineticScroller;
129use teksilo_core::pointer::touch_action::PanAxes;
130use teksilo_core::signal::{Prop, Signal};
131use teksilo_core::widget::{LayoutContext, Widget, WidgetPlacement};
132use teksilo_core::widget_builder::HandlerSet;
133use teksilo_core::widget_id::WidgetId;
134
135use teksilo_data::selection_model::SelectionModel;
136use teksilo_data::tree_slice::{TreeSlice, TreeSliceHandle};
137use teksilo_data::{
138    DropPosition, DropResponse, FlatEntry, ItemKey, KeyedSelectionModel, NodeId, TreeDataSource,
139    TreeModel,
140};
141
142use crate::common::row_metrics::{HeightSource, RowMetrics, SharedRowMetrics};
143use crate::common::row_reveal::{RevealAlign, RowReveal, SharedRowReveal};
144use crate::common::scroll::OverscrollBehavior;
145use crate::data_views::{DragTransferMode, RowDragData, RowSelection, ViewId, ViewKind};
146use crate::scroll_area::ScrollBarMode;
147use crate::scroll_bar::{ScrollBar, ScrollBarOrientation, ScrollBarVisual};
148use crate::tree_source::{TreeRow, TreeRowMeta, TreeSource};
149
150const BUFFER_ITEMS: usize = 5;
151const DEFAULT_ITEM_HEIGHT: f32 = 28.0;
152
153/// [`DEFAULT_ITEM_HEIGHT`] raised to the density's `target_size`
154/// (24 / 32 / 44 dp). The identity at Compact.
155fn default_item_height(tokens: &InputTokens) -> f32 {
156    dp(DEFAULT_ITEM_HEIGHT, TargetRole::Target, tokens)
157}
158const SCROLLBAR_THICKNESS: f32 = 12.0;
159
160/// Per-row context passed to a 4-arg TreeView delegate. Carries a
161/// reference to the slice handle and the row's `NodeId` so the
162/// delegate can wire chevron toggles and other tree-aware behavior
163/// without manually cloning state outside the closure.
164///
165/// Created internally by [`TreeView::new_with_context`]. Not
166/// constructed directly by user code.
167pub struct TreeRowContext<'a, T: 'static> {
168    slice: &'a TreeSliceHandle<T>,
169    node_id: teksilo_data::NodeId,
170}
171
172impl<'a, T: 'static> TreeRowContext<'a, T> {
173    /// Toggle callback for this row's chevron. Wires in one line:
174    /// `.on_chevron_toggle_rc(ctx.toggle_callback())`.
175    pub fn toggle_callback(&self) -> std::rc::Rc<dyn Fn(&mut teksilo_core::widget::EventContext)> {
176        let slice = self.slice.clone();
177        let node = self.node_id;
178        std::rc::Rc::new(move |_ctx| slice.toggle_expand(node))
179    }
180
181    /// Cloned handle to the slice — call `.toggle_expand(node)`,
182    /// `.expand(node)`, `.collapse(node)` directly.
183    pub fn slice_handle(&self) -> TreeSliceHandle<T> {
184        self.slice.clone()
185    }
186
187    /// The `NodeId` of this row in the backing `TreeModel`.
188    pub fn node_id(&self) -> teksilo_data::NodeId {
189        self.node_id
190    }
191}
192
193/// Delegate type for the built-in `TreeModel` path: takes the inputs the 3-arg
194/// form gets plus the optional `TreeRowContext`. Both the 3-arg `new` and the
195/// 4-arg `new_with_context` produce a closure of this shape.
196type TreeDelegate<T> = dyn Fn(&T, &FlatEntry, bool, &TreeRowContext<'_, T>) -> Box<dyn Widget>;
197
198/// Delegate type for the generic [`TreeView::from_source`] path: key-erased, so
199/// it receives a [`TreeRow`] (flat metadata + a chevron toggle) instead of the
200/// `NodeId`-typed `FlatEntry` / `TreeRowContext`.
201type SourceTreeDelegate<T> = dyn Fn(&T, &TreeRow, bool) -> Box<dyn Widget>;
202
203/// Internal, uniform per-row builder both constructors lower to:
204/// `(visible_index, &item, &meta, selected) -> row widget`. The built-in
205/// wrapper rebuilds the `NodeId` `TreeRowContext` from the index; the generic
206/// wrapper builds a key-erased `TreeRow`.
207type RowDelegate<T> = dyn Fn(usize, &T, &TreeRowMeta, bool) -> Box<dyn Widget>;
208
209/// A virtualized hierarchical tree widget backed by a `TreeModel<T>`.
210///
211/// ```rust
212/// # use teksilo_widgets::{TreeView};
213/// # use teksilo_widgets::primitives::{HStack, Padding, TextWidget};
214/// # use teksilo_data::TreeModel;
215/// # use teksilo_i18n::lit;
216/// # struct Item { title: String }
217/// # let tree_model: TreeModel<Item> = TreeModel::new();
218/// let _w = TreeView::new(tree_model, |item, entry, _selected| {
219///     let indent = entry.depth as f32 * 20.0;
220///     Box::new(HStack::new()
221///         .child(Padding::new(0.0, 0.0, 0.0, indent))
222///         .child(TextWidget::new(lit!(&item.title))))
223/// })
224/// .item_height(28.0);
225/// ```
226use crate::data_views::DropViz;
227use teksilo_core::styles::density::dp;
228
229pub struct TreeView<T: 'static> {
230    /// Index-keyed erased backing — the built-in `TreeSlice` or an external
231    /// `TreeDataSource`. All virtualization / DnD / keyboard work goes through
232    /// this in flat indices.
233    source: Rc<TreeSource<T>>,
234    /// Present only for the built-in `TreeModel` path; backs the `NodeId`-typed
235    /// public expand API + [`tree_slice`](Self::tree_slice). `None` for
236    /// [`from_source`](Self::from_source).
237    slice: Option<Rc<TreeSlice<T>>>,
238    /// Uniform per-row builder produced by whichever constructor was used.
239    row_delegate: Rc<RowDelegate<T>>,
240    item_height: f32,
241    /// Height-mode selection (uniform / exact callback / auto-measure).
242    height_source: HeightSource,
243    /// Row geometry — all virtualization consumers go through this.
244    metrics: SharedRowMetrics,
245    /// Row selection — index-based `SelectionModel` or keyed
246    /// `KeyedSelectionModel<NodeId>`, unified behind the index-facing facade.
247    row_selection: Option<RowSelection>,
248
249    /// The keyboard cursor, a flat visible index: the row the arrow keys
250    /// step from and the view names as its active descendant. A signal,
251    /// bound `AccessibilityOnly` by the root, so a write from anywhere (the
252    /// keys, a click, a [`TreeViewHandle`]) re-walks the AT tree; shared with
253    /// every handle.
254    focused_index: Signal<Option<usize>>,
255    /// The row identity `focused_index` currently points at, refreshed
256    /// alongside every write to `focused_index`. A tree's structural changes
257    /// (insert/remove/reorder, and — unlike a flat list — expand/collapse)
258    /// surface as a bare version bump with no `DataChange` delta to shift the
259    /// cursor by, so it is reconciled by identity instead: resolved against
260    /// the source on every version bump and used to rewrite `focused_index`
261    /// to wherever the row landed (or drop it if the row is gone). See
262    /// `crate::data_views::RowAnchor` and `reconcile_editing_row`, which
263    /// plays the same role for `TableView`'s `editing_cell`.
264    focused_anchor: Rc<RefCell<Option<crate::data_views::RowAnchor>>>,
265    /// The realized `(flat index -> row wrapper id)` map, filled by the body
266    /// pane each build. Lets this widget's `&self` methods resolve a row index
267    /// to a widget without reaching into the pane. Mirrors `ListView::row_map`.
268    row_map: Rc<RefCell<Vec<(usize, WidgetId)>>>,
269
270    /// Type-ahead ("type to jump") label extractor — opt-in via
271    /// [`type_ahead_label`](Self::type_ahead_label).
272    /// Per-row tooltip resolvers. The view attaches these itself, against the
273    /// row widget the delegate produced — an app cannot reach that widget to
274    /// hang a `.tooltip(...)` on it. Shared with `ListView`; see
275    /// [`RowTooltips`](crate::data_views::RowTooltips).
276    row_tooltips: crate::data_views::RowTooltips<T>,
277    type_ahead_label: Option<Rc<dyn Fn(&T) -> String>>,
278    /// Reset window for the type-ahead search term.
279    type_ahead_timeout: Duration,
280    /// Persistent type-ahead buffer (survives the per-keystroke rebuild).
281    type_ahead: Rc<crate::common::type_ahead::TypeAheadState>,
282
283    /// Enable intra-widget drag reordering.
284    reorderable: bool,
285
286    /// Cross-widget export / foreign-receive machinery — the builders
287    /// (`.exportable`, `.export_external`, `.accept_foreign_rows`,
288    /// `.on_rows_received`, `.on_rows_transferred_out`), the drag-start payload
289    /// build, and the move-out completion, shared by all five data views.
290    export: crate::data_views::RowExport<T>,
291
292    /// Whether a row-body PointerUp on a branch row auto-toggles its
293    /// expansion. Defaults to `true` (legacy behavior — convenient
294    /// for hand-built delegates without an explicit chevron). Set to
295    /// `false` when the delegate provides its own chevron tap target
296    /// (e.g. `StandardTreeItem`) to avoid the auto-toggle firing in
297    /// addition to the chevron's own click and cancelling out.
298    row_click_expands: bool,
299
300    /// Active drop feedback (set by on_drag_hover, cleared by on_drag_leave,
301    /// read by paint). Reactive Signal — bound at `RepaintOnly` so any
302    /// `set(...)` call dirties the TreeView for repaint automatically.
303    drop_feedback: Signal<Option<DropViz>>, // insertion line OR folder highlight
304
305    /// Optional row-activation callback (a click on the row body per
306    /// `activate_on`, or Enter/Space on the focused row) — distinct from
307    /// *selection*, which also moves on arrow navigation. Lets a view
308    /// open/commit a row without firing on every navigation step.
309    on_activate: Option<Rc<dyn Fn(usize, &mut teksilo_core::widget::EventContext)>>,
310    /// Whether activation is a single or double click (default `DoubleClick`).
311    activate_on: crate::data_views::ActivateOn,
312
313    /// `true` while this view (root or descendant) holds keyboard focus — the
314    /// root's inclusive [`BuildContext::view_focus_active`](teksilo_core::BuildContext::view_focus_active) signal, bound
315    /// `RepaintOnly`. With [`focus_visible`](Self::focus_visible) it drives the
316    /// **container focus ring**: when the view is Tab-focused but nothing is
317    /// selected, no row ring shows, so the whole view outlines itself instead —
318    /// the user can see where keyboard focus landed before they arrow.
319    view_focused: Signal<bool>,
320    /// Input-modality `:focus-visible`. Gates the container ring (and row rings)
321    /// to keyboard navigation, never a mouse click. Bound `RepaintOnly`.
322    focus_visible: Signal<bool>,
323
324    // Persistent scroll state
325    scroll_y: Signal<f32>,
326    max_scroll_y: Signal<f32>,
327    /// Scroll-chaining behavior at the boundary (default `Chain`).
328    overscroll_behavior: OverscrollBehavior,
329    viewport_ratio_y: Signal<f32>,
330
331    /// Animate wheel scrolling instead of snapping to the new offset.
332    /// Enabled by default — mirrors `ScrollArea`. Without it, each wheel
333    /// notch jumps by `item_height` per delivered line (typically 3),
334    /// which reads as a coarse multi-row jump rather than a smooth glide.
335    smooth_scrolling: bool,
336    /// Duration of the smooth scroll animation.
337    smooth_scroll_duration: Duration,
338
339    /// How the scroll bar is displayed. Defaults to `Permanent` — a
340    /// layout sibling that reserves its own width. `Overlay` / `Thin`
341    /// float over the content instead, like `ScrollArea`.
342    scroll_bar_style: ScrollBarMode,
343
344    /// Root-level **relayout** trigger. The root's `place_children` owns the
345    /// scrollbar totals (`max_scroll_y`, thumb ratio) and the content-width
346    /// decision, none of which its `build` output depends on — so a source
347    /// change, or a pane measurement that moves the content total, needs a
348    /// re-place here rather than a rebuild. Bumped by the source-version
349    /// observer and by [`body_pane::TreeViewBodyPane::total_refresh`].
350    layout_refresh: Signal<u64>,
351    /// Root-level **repaint** trigger for the container focus ring, which is
352    /// suppressed as soon as anything is selected. Selection changes rebuild
353    /// the pane (the delegate's `selected` argument) but must not rebuild the
354    /// root — they only change what the root paints.
355    paint_refresh: Signal<u64>,
356
357    /// Pane-local rebuild trigger, owned here so it survives pane rebuilds.
358    pane_version: Signal<u64>,
359    /// Buffered row range materialized by the pane's latest build.
360    pane_built_start: Rc<Cell<usize>>,
361    pane_built_end: Rc<Cell<usize>>,
362
363    // Set during build
364    body_pane_id: Option<WidgetId>,
365    scrollbar_id: Option<WidgetId>,
366    viewport_height: Rc<Cell<f32>>,
367    /// The TreeView's own absolute (window) bounds, cached from
368    /// `place_children` so the keyboard handler can chase the selected row
369    /// into enclosing scroll areas via
370    /// [`EventContext::ensure_visible`](teksilo_core::widget::EventContext::ensure_visible).
371    /// Rows are not distinct focusable nodes, so the focus-driven follow never
372    /// reveals the selected row in an outer scroller — this closes that gap.
373    viewport_bounds: Rc<Cell<Rect>>,
374    /// This surface's pan physics: the range a finger's pan is clamped to and
375    /// the offset it is currently holding. Owned by the view rather than by
376    /// the [`ScrollableBehavior`](crate::common::scrollable::ScrollableBehavior)
377    /// so it survives a rebuild, and so `place_children` — the only pass that
378    /// knows the viewport extent — can publish into it.
379    scroller: Rc<RefCell<KineticScroller>>,
380    /// Content width (updated during `place_children`, used by drag
381    /// feedback so the insertion line / into-folder highlight spans the
382    /// row's actual width instead of a guess). Mirrors `ListView`.
383    placed_content_width: Rc<Cell<f32>>,
384    tree_id: ViewId,
385
386    /// Whole-view enabled state, statically or reactively. Forwarded to the
387    /// arena via `ctx.enabled_when(self_id, self.enabled.clone())` at build
388    /// time; a disabled view greys out and stops accepting focus /
389    /// selection / keyboard input (arena-gated).
390    enabled: Prop<bool>,
391    /// The reveal a [`TreeViewHandle`] asked for, applied by the body pane
392    /// after its measure pass. Created with the view so a handle taken before
393    /// mount reaches every pane the view builds.
394    reveal: SharedRowReveal,
395}
396
397mod body_pane;
398mod builder;
399mod widget_impl;
400
401// std::fmt::Debug for the (non-Debug) generic fields.
402impl<T: 'static> std::fmt::Debug for TreeView<T> {
403    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
404        f.debug_struct("TreeView")
405            .field("visible_count", &self.source.visible_count())
406            .field("item_height", &self.item_height)
407            .field("scroll_bar_style", &self.scroll_bar_style)
408            .field("scroll_y", &self.scroll_y.get())
409            .finish()
410    }
411}
412
413impl<T: 'static> TreeView<T> {
414    /// A handle that scrolls this tree once it is mounted.
415    ///
416    /// Take it while describing the tree, before the view is handed to
417    /// `ctx.add(...)`; it stays attached to the view across rebuilds. See
418    /// [`TreeViewHandle`].
419    pub fn handle(&self) -> TreeViewHandle {
420        let count = self.source.clone();
421        let anchor = self.source.clone();
422        TreeViewHandle {
423            reveal: self.reveal.clone(),
424            cursor: self.focused_index.clone(),
425            cursor_anchor: self.focused_anchor.clone(),
426            len: Rc::new(move || count.visible_count()),
427            anchor_at: Rc::new(move |index| anchor.anchor(index)),
428        }
429    }
430}
431
432/// A handle on a mounted [`TreeView`], to scroll it to a row from outside the
433/// widget — a view-model revealing the node a search found, say.
434///
435/// Rows are addressed by their **flat visible index**: the position of the row
436/// among the rows currently shown, counting the children of expanded nodes and
437/// skipping those of collapsed ones (the index the delegate's selection and the
438/// keyboard use).
439///
440/// Cheap to clone, valid before the view is mounted, and it survives the
441/// view's rebuilds. Its methods never compute an offset when they are called:
442/// they record the request and ask for a layout pass, and the view applies it
443/// there, after row measurement and scroll anchoring have settled. So a
444/// structural change made in the same turn (a node expanded above the target,
445/// say) is accounted for, a request made before mount is applied at the first
446/// layout, and a later request replaces an earlier pending one. A request on
447/// an empty tree is dropped; an index past the end reveals the last row.
448///
449/// The handle also reads and moves the tree's **keyboard cursor**, the row the
450/// arrow keys step from and assistive technology is told is current:
451/// [`cursor`](Self::cursor), [`set_cursor`](Self::set_cursor) and
452/// [`cursor_signal`](Self::cursor_signal), in the same flat visible indices.
453/// In a multiple selection the cursor and the selection are separate: the
454/// accelerator with an arrow moves the cursor alone, and a selection written
455/// from outside leaves the cursor where it was.
456///
457/// ```rust
458/// # use teksilo_widgets::TreeView;
459/// # use teksilo_widgets::primitives::TextWidget;
460/// # use teksilo_data::TreeModel;
461/// # use teksilo_i18n::lit;
462/// # let model: TreeModel<String> = TreeModel::new();
463/// let tree = TreeView::new(model, |item, _entry, _selected| {
464///     Box::new(TextWidget::new(lit!(item)))
465/// });
466/// let handle = tree.handle();
467/// // … `ctx.add(tree)`; later, after expanding the node's ancestors:
468/// handle.ensure_index_visible(42);
469/// ```
470#[derive(Clone)]
471pub struct TreeViewHandle {
472    reveal: SharedRowReveal,
473    /// The tree's `focused_index`, the same signal the keyboard writes.
474    cursor: Signal<Option<usize>>,
475    /// The tree's `focused_anchor`: the row identity the cursor follows
476    /// through a structural change, written with every write of the cursor.
477    cursor_anchor: Rc<RefCell<Option<crate::data_views::RowAnchor>>>,
478    /// The tree's visible row count, to clamp a cursor written from here.
479    len: Rc<dyn Fn() -> usize>,
480    /// The identity of the row at a flat index, for `cursor_anchor`.
481    anchor_at: Rc<dyn Fn(usize) -> crate::data_views::RowAnchor>,
482}
483
484impl std::fmt::Debug for TreeViewHandle {
485    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
486        f.debug_struct("TreeViewHandle")
487            .field("pending", &self.reveal.pending())
488            .field("cursor", &self.cursor.get())
489            .finish()
490    }
491}
492
493impl TreeViewHandle {
494    /// Scroll so the row at the given flat visible index is aligned to the
495    /// top of the viewport, as far as the scroll range allows, at the next
496    /// layout pass.
497    pub fn scroll_to_index(&self, index: usize) {
498        self.reveal.request(index, RevealAlign::Top);
499    }
500
501    /// Scroll the minimum distance that brings the row at the given flat
502    /// visible index fully into view, at the next layout pass; no scroll
503    /// when it already is.
504    pub fn ensure_index_visible(&self, index: usize) {
505        self.reveal.request(index, RevealAlign::Nearest);
506    }
507
508    /// The flat visible index the keyboard cursor is on, or `None` while the
509    /// tree has no cursor: it has not been navigated, clicked or given one by
510    /// [`set_cursor`](Self::set_cursor). With no cursor the keys start from
511    /// the first selected row, and from the first or last row when nothing is
512    /// selected.
513    ///
514    /// The cursor follows its row: an expansion or a collapse above it, or an
515    /// insertion, moves the index with the row, and a row that leaves the
516    /// visible rows takes the cursor with it. In a single selection
517    /// the cursor follows the selection: the keys move them together, and a
518    /// selection written from outside moves the cursor onto it. In a multiple
519    /// selection they are separate: the accelerator with an arrow
520    /// (`Ctrl+Arrow`) moves the cursor alone, and a selection written from
521    /// outside leaves the cursor where it was.
522    pub fn cursor(&self) -> Option<usize> {
523        self.cursor.get()
524    }
525
526    /// Move the keyboard cursor to the row at flat visible index `index`, or
527    /// remove it with `None`.
528    ///
529    /// An index past the last visible row is clamped to that row; on an empty
530    /// tree the cursor is removed. While the tree holds keyboard focus it
531    /// names the row as its active descendant at the next accessibility
532    /// update, when the row is realized; the next arrow key steps from it,
533    /// and the cursor follows that row through later structural changes.
534    ///
535    /// Keyboard focus and the selection are left alone, and the tree does not
536    /// scroll: pair it with
537    /// [`ensure_index_visible`](Self::ensure_index_visible) to bring the row
538    /// into view. Usable before the tree is mounted and across its rebuilds;
539    /// in a single selection the tree seats the cursor on the selected row
540    /// when it is built and whenever the selection changes.
541    pub fn set_cursor(&self, index: Option<usize>) {
542        let len = (self.len)();
543        let index = index.and_then(|i| len.checked_sub(1).map(|last| i.min(last)));
544        // The anchor first: an observer of the cursor reads a consistent pair.
545        *self.cursor_anchor.borrow_mut() = index.map(|i| (self.anchor_at)(i));
546        self.cursor.set(index);
547    }
548
549    /// A read-only signal of the cursor, as [`cursor`](Self::cursor) reports
550    /// it, for an application to observe or bind: a status line naming the
551    /// current row, actions enabled for it. It changes with every write of
552    /// the cursor, by the keys, a click, a structural change moving it or
553    /// [`set_cursor`](Self::set_cursor).
554    pub fn cursor_signal(&self) -> Signal<Option<usize>> {
555        self.cursor.map(|index| *index)
556    }
557}
558
559#[cfg(test)]
560mod tests;
561
562#[cfg(test)]
563mod handle_tests;
564
565#[cfg(test)]
566mod cursor_tests;