Skip to main content

teksilo_widgets/
tree_table_view.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `TreeTableView<T>` — hierarchical multi-column data table with expand/collapse.
5//!
6//! Sibling of [`TableView`](crate::TableView) for tree-shaped data. Each row carries
7//! a depth level; one designated column (the *tree column*, defaulting to the first)
8//! shows a twist (chevron) and an indent gutter that toggles the row's children.
9//! Backed by a [`SortFilterTreeModel<T>`] so sort, filter, and expand state compose
10//! without extra bookkeeping. Shares the header, column, keyboard, and selection
11//! modules with `TableView`.
12//!
13//! Rows live in a `TreeBodyPane` — a sibling of the scrollbar — so buffer-exit /
14//! selection / expand rebuilds are never deferred mid-thumb-drag. Three row-height
15//! modes: uniform (`row_height`, fast path), exact per-flat-index callback
16//! (`row_height_fn`), and auto-measured (`auto_row_height` — grows to tallest cell).
17//!
18//! ## Common patterns
19//!
20//! **A checkbox column.** Selection and "checked" are different things — a
21//! checkbox column wants its own state, with parent/child propagation. Build it
22//! from [`TreeCheckedModel`](teksilo_data::TreeCheckedModel) over the same tree
23//! the view projects.
24//!
25//! A cell delegate receives `(&T, &CellContext)` and **`CellContext` carries no
26//! node identity** — only [`row_index`](crate::CellContext::row_index). So
27//! capture the projection and resolve the row's `NodeId` through it:
28//!
29//! ```ignore
30//! let proxy = SortFilterTreeModel::new(tree);
31//! let checks = TreeCheckedModel::new(proxy.tree());
32//! let for_cells = proxy.clone();
33//! let col = Column::new("done", lit!("Done"), move |_item, cx: &CellContext| {
34//!     match for_cells.visible_node_id(cx.row_index) {
35//!         Some(node) => Box::new(Checkbox::new(checks.check_state(node))) as Box<dyn Widget>,
36//!         None => Box::new(Spacer::new()),
37//!     }
38//! });
39//! ```
40//!
41//! For a tree whose identity is a domain key rather than a `NodeId`, use
42//! [`KeyedTreeCheckedModel`](teksilo_data::KeyedTreeCheckedModel) instead — it
43//! survives a full re-source, which a `NodeId`-keyed set cannot.
44//!
45//! ## Accessibility
46//!
47//! Root emits `Role::TreeGrid`; rows carry `set_level` + `set_expanded`.
48//! ArrowLeft / ArrowRight on the tree column collapse / expand.
49//!
50//! ```ignore
51//! // Column delegates capture closures — use ignore.
52//! use teksilo_widgets::TreeTableView;
53//! use teksilo_data::TreeModel;
54//! # struct File { name: String }
55//! # let model: TreeModel<File> = TreeModel::new();
56//! let _view = TreeTableView::new(model).row_height(28.0);
57//! ```
58//!
59//! ## Pan to scroll
60//!
61//! The view installs [`common::scrollable::ScrollableBehavior`](crate::common::scrollable::ScrollableBehavior),
62//! which gives it the shared wheel arithmetic, a finger's pan and the
63//! `PanClaim` that puts it on a pan's claimant chain. A pan scrolls it, the
64//! release coasts, and a pan it cannot absorb hands the **whole** event to the
65//! container outside — never a residual. A pan that starts on a row scrolls
66//! rather than activating it or collapsing a multi-selection onto it. Both axes
67//! are claimed. Shift+wheel still
68//! scrolls the columns, and a finger's pan is never remapped by a held Shift:
69//! the remap is a wheel convention, and turning a drag sideways is not what
70//! the hand asked for.
71
72mod body_pane;
73mod widget_impl;
74
75use std::cell::{Cell, RefCell};
76use std::collections::HashMap;
77use std::rc::Rc;
78use std::time::Duration;
79
80use teksilo_canvas::{Canvas, Point, Rect, Size, SizeProposal};
81
82use teksilo_core::accessibility::{AccessNodeBuilder, widget_id_to_node_id};
83use teksilo_core::binding::BindingLevel;
84use teksilo_core::build_context::BuildContext;
85use teksilo_core::drag_payload::DragPayload;
86use teksilo_core::event::EventResponse;
87use teksilo_core::kinetic::KineticScroller;
88use teksilo_core::pointer::touch_action::PanAxes;
89use teksilo_core::signal::{Prop, Signal};
90use teksilo_core::widget::{EventContext, LayoutContext, PaintContext, Widget, WidgetPlacement};
91use teksilo_core::widget_builder::HandlerSet;
92use teksilo_core::widget_id::WidgetId;
93use teksilo_data::{
94    DropPosition, KeyedSelectionModel, NodeId, SelectionModel, SortDirection, SortFilterTreeModel,
95    TreeFilterMode, TreeModel,
96};
97use teksilo_i18n::LocalizedString;
98use teksilo_tokens::{BorderRole, OverscrollStyle, SurfaceRole};
99
100use crate::styles::recipe_table_style as cp;
101
102use crate::common::row_metrics::{HeightSource, RowMetrics, SharedRowMetrics};
103use crate::common::scroll::OverscrollBehavior;
104use crate::data_views::{DragTransferMode, RowDragData, RowSelection, ViewId, ViewKind};
105use crate::data_views::{DropViz, drop_into_tint};
106use crate::scroll_area::ScrollBarMode;
107use crate::scroll_bar::{ScrollBar, ScrollBarOrientation, ScrollBarVisual};
108use crate::table_view::ColumnReorderDragData;
109use crate::table_view::body::SharedColumnWidths;
110use crate::table_view::column::{
111    Column, ColumnResizePolicy, EditTriggers, GridLines, PinnedSide, TabTraversal,
112};
113use crate::table_view::header::{
114    ColumnResizeInfo, ColumnResizeTable, HeaderCell, HeaderCellSpec, HeaderRow, ResizeStateHandle,
115    attach_header_reorder_handlers,
116};
117use crate::table_view::imperative;
118use crate::table_view::keyboard;
119use crate::table_view::layout;
120use crate::table_view::row_navigator::RowNavigator;
121use crate::table_view::selection::{CellSelectionModel, TableSelectionMode};
122use crate::tree_source::TreeSource;
123use teksilo_data::{DropResponse, TreeDataSource};
124
125const BUFFER_ROWS: usize = 5;
126const SCROLLBAR_THICKNESS: f32 = 12.0;
127
128/// Hierarchical row navigator. Adapts a [`TreeSource`]'s flat-list view to the
129/// [`RowNavigator`] interface used by the shared keyboard handler.
130///
131/// Index-keyed throughout, so it works over any [`TreeDataSource`] — a
132/// `SortFilterTreeModel` over a `TreeModel`, or an external store carrying its
133/// own `Key`.
134pub(crate) struct TreeNavigator<T: 'static> {
135    source: Rc<TreeSource<T>>,
136}
137
138impl<T: 'static> TreeNavigator<T> {
139    pub(crate) fn new(source: Rc<TreeSource<T>>) -> Self {
140        Self { source }
141    }
142}
143
144impl<T: 'static> RowNavigator for TreeNavigator<T> {
145    fn row_count(&self) -> usize {
146        self.source.visible_count()
147    }
148
149    fn depth(&self, row: usize) -> Option<usize> {
150        self.source.meta(row).map(|m| m.depth)
151    }
152
153    fn has_children(&self, row: usize) -> bool {
154        self.source
155            .meta(row)
156            .map(|m| m.has_children)
157            .unwrap_or(false)
158    }
159
160    fn is_expanded(&self, row: usize) -> bool {
161        self.source
162            .meta(row)
163            .map(|m| m.is_expanded)
164            .unwrap_or(false)
165    }
166
167    fn toggle_expanded(&self, row: usize) {
168        self.source.toggle_at(row);
169    }
170}
171
172/// Hierarchical multi-column widget. See module documentation.
173pub struct TreeTableView<T: 'static> {
174    /// Erased row access — every read (counts, entries, expansion, DnD,
175    /// keyboard reorder) goes through here, so the widget works over any
176    /// [`TreeDataSource`] and never needs to know the source's `Key`.
177    source: Rc<TreeSource<T>>,
178    /// Present only on the [`from_projection`](Self::from_projection) /
179    /// [`new`](Self::new) paths. It backs the `NodeId`-typed public API
180    /// ([`expand`](Self::expand), [`projection`](Self::projection), …), which is
181    /// meaningless for an external source carrying its own key — those methods
182    /// no-op when this is `None`.
183    proxy: Option<SortFilterTreeModel<T>>,
184
185    columns: Vec<Column<T>>,
186    /// Column id hosting the twist + indent. `None` defaults to the
187    /// first column at build time.
188    tree_column_id: Option<String>,
189    indent_per_level: Option<f32>,
190    row_height: Option<f32>,
191    /// Height-mode selection (uniform / exact callback / auto-measure).
192    height_source: HeightSource,
193    /// Row geometry — shared with the keyboard handler and the body
194    /// pane.
195    row_metrics: SharedRowMetrics,
196    header_height: Option<f32>,
197    show_header: bool,
198    selection_mode: TableSelectionMode,
199    /// Row selection — index-based `SelectionModel` or keyed
200    /// `KeyedSelectionModel<NodeId>`, unified behind the index-facing facade.
201    row_selection: Option<RowSelection>,
202    cell_selection: Option<CellSelectionModel>,
203    alternating_rows: bool,
204    grid_lines: GridLines,
205    /// See [`Self::stretch_last_column`].
206    stretch_last_column: bool,
207    a11y_label: Option<LocalizedString>,
208    show_internal_scrollbars: bool,
209    column_resize_policy: ColumnResizePolicy,
210    tab_traversal: TabTraversal,
211    edit_triggers: EditTriggers,
212    #[allow(clippy::type_complexity)]
213    on_cell_edit_request: Option<Rc<dyn Fn(usize, &str, &mut EventContext)>>,
214    on_cell_edit_dismissed: Option<Rc<dyn Fn(usize, &str, &mut EventContext)>>,
215    #[allow(clippy::type_complexity)]
216    on_row_activate: Option<Rc<dyn Fn(usize, &mut EventContext)>>,
217
218    /// Animate wheel scrolling instead of snapping to the new offset.
219    /// Enabled by default — mirrors `ScrollArea`. Without it, each wheel
220    /// notch jumps by `row_height` per delivered line, which reads as a
221    /// coarse multi-row jump rather than a smooth glide.
222    smooth_scrolling: bool,
223    /// Duration of the smooth scroll animation.
224    smooth_scroll_duration: Duration,
225
226    /// How the scroll bar is displayed (default `Permanent`). `Overlay`
227    /// and `Thin` float the bar over the content instead of reserving a
228    /// layout column for it, mirroring `ScrollArea::scroll_bar_style`.
229    scroll_bar_style: ScrollBarMode,
230
231    // Public reactive signals
232    scroll_y: Signal<f32>,
233    max_scroll_y: Signal<f32>,
234    /// Scroll-chaining behavior at the boundary (default `Chain`).
235    overscroll_behavior: OverscrollBehavior,
236    viewport_ratio_y: Signal<f32>,
237    /// Horizontal scroll offset of the Middle (unpinned) pane — mirrors
238    /// `TableView::scroll_x`. See `table_view::PaneBoundaries`.
239    scroll_x: Signal<f32>,
240    max_scroll_x: Signal<f32>,
241    viewport_ratio_x: Signal<f32>,
242    /// This surface's pan physics: the range a finger's pan is clamped to and
243    /// the offset it is currently holding. Owned by the view rather than by
244    /// the [`ScrollableBehavior`](crate::common::scrollable::ScrollableBehavior)
245    /// so it survives a rebuild, and so `place_children` — the only pass that
246    /// knows the viewport extent — can publish into it.
247    scroller: Rc<RefCell<KineticScroller>>,
248    sort_signal: Signal<Option<(String, SortDirection)>>,
249    column_widths_signal: Signal<HashMap<String, f32>>,
250    column_order_signal: Signal<Vec<String>>,
251    column_pinning_signal: Signal<HashMap<String, PinnedSide>>,
252    filters_signal: Signal<HashMap<String, String>>,
253    focused_cell: Signal<Option<(usize, usize)>>,
254    /// The realized `(row index -> row wrapper id)` map, filled by the body
255    /// pane each build. Lets this widget's `&self` methods resolve a row index
256    /// to a widget without reaching into the pane. Mirrors `ListView::row_map`.
257    row_map: Rc<RefCell<Vec<(usize, WidgetId)>>>,
258    editing_cell: Signal<Option<(usize, usize)>>,
259    /// Type-ahead ("type to jump") label extractor — opt-in via
260    /// [`type_ahead_label`](Self::type_ahead_label).
261    #[allow(clippy::type_complexity)]
262    type_ahead_label: Option<Rc<dyn Fn(&T) -> String>>,
263    /// Reset window for the type-ahead search term.
264    type_ahead_timeout: Duration,
265    /// Persistent type-ahead buffer (survives the per-keystroke rebuild).
266    type_ahead: Rc<crate::common::type_ahead::TypeAheadState>,
267    /// Widget shown in place of the rows when nothing is visible — an empty
268    /// tree, or a filter that matched nothing.
269    #[allow(clippy::type_complexity)]
270    empty_view: Option<Rc<dyn Fn() -> Box<dyn Widget>>>,
271    /// Set on the first `place_children`. Until then `viewport_height` still
272    /// holds its construction placeholder, so viewport-relative imperatives
273    /// (`ensure_row_visible`) would scroll against a size that was never real.
274    laid_out: Rc<Cell<bool>>,
275    /// Anchor for the row with an open cell editor, so the editor follows its
276    /// row instead of its index. See `reconcile_editing_row`.
277    editing_anchor: Rc<RefCell<Option<crate::data_views::RowAnchor>>>,
278
279    // Build state
280    header_row_id: Option<WidgetId>,
281    body_pane_id: Option<WidgetId>,
282    scrollbar_id: Option<WidgetId>,
283    /// Horizontal scroll bar along the bottom of the Middle pane only —
284    /// mirrors `TableView::h_scrollbar_id`.
285    h_scrollbar_id: Option<WidgetId>,
286    empty_id: Option<WidgetId>,
287    /// Pane-local rebuild trigger + buffered range, owned here so they
288    /// survive `TreeTableView` rebuilds (each rebuild constructs a fresh
289    /// `TreeBodyPane` struct that inherits these handles).
290    pane_version: Signal<u64>,
291    pane_built_start: Rc<Cell<usize>>,
292    pane_built_end: Rc<Cell<usize>>,
293    /// Bumped by the pane when a measure pass changes the content
294    /// total; bound at `Relayout` on this root so `max_scroll_y` / the
295    /// thumb ratio are recomputed with the corrected total next frame.
296    pane_total_refresh: Signal<u64>,
297
298    /// Enable drag-to-reorder of rows (pointer drag + Alt+Arrow). The move
299    /// reparents/reorders nodes in the underlying `TreeModel`, cycle-guarded.
300    /// Suppressed while a sort is active (the visible order then differs from
301    /// the tree order, so a manual reorder would be meaningless).
302    reorderable: bool,
303    /// Active row-drop insertion indicator `(body_local_y, width)`. Set by
304    /// `on_drag_hover`, cleared on leave / drop, read by `paint`.
305    drop_feedback: Signal<Option<DropViz>>,
306
307    /// Whether activation is a single or double click (default `DoubleClick`).
308    activate_on: crate::data_views::ActivateOn,
309
310    /// `true` while this view — its root or any descendant — holds keyboard
311    /// focus. Captured at build from [`BuildContext::begin_view_focus`], bound
312    /// `RepaintOnly`. Drives focus-aware selection: the band paints `Selected`
313    /// while focused, muted `SelectedInactive` once focus leaves the view.
314    view_focused: Signal<bool>,
315    /// Input-modality `:focus-visible`. Gates the cell focus ring to keyboard
316    /// navigation (never a mouse click). Bound `RepaintOnly`.
317    focus_visible: Signal<bool>,
318
319    // Layout state
320    column_widths: SharedColumnWidths,
321    display_indices: Rc<RefCell<Vec<usize>>>,
322    /// Counts of (leading-pinned, middle, trailing-pinned) columns —
323    /// mirrors `TableView::pane_boundaries`. Populated by `display_order()`.
324    pane_boundaries: Rc<RefCell<crate::table_view::PaneBoundaries>>,
325    /// `(row, display_pos) -> WidgetId` for every cell realized by the
326    /// body pane's latest `build()`. Mirrors `TableView::cell_map` (the
327    /// GridView `tile_map` pattern — shared between the root and its
328    /// sibling-of-scrollbar pane); `accessibility()` reads it to point
329    /// `active_descendant` at the keyboard-focused cell's own AT node.
330    cell_map: Rc<RefCell<Vec<((usize, usize), WidgetId)>>>,
331    viewport_height: Rc<Cell<f32>>,
332    /// Middle-pane viewport width, snapshotted by `place_children` —
333    /// mirrors `TableView::middle_viewport_width`.
334    middle_viewport_width: Rc<Cell<f32>>,
335    /// The row-area's absolute (window) rect (below the header), cached by
336    /// `place_children`. Threaded into the keyboard handler so it can chase the
337    /// focused row into any *enclosing* scroll area via
338    /// [`EventContext::ensure_visible`](teksilo_core::widget::EventContext::ensure_visible).
339    body_bounds: Rc<Cell<Rect>>,
340    resize_state: ResizeStateHandle,
341    /// Display slot of the column under an active resize drag, or `None`.
342    /// Mirrors `TableView::resize_target` — shared with every `HeaderCell`
343    /// so the *target* column carries the "resizing" chrome even when the
344    /// gesture is anchored on its neighbour's half of the grip.
345    resize_target: Signal<Option<usize>>,
346    /// Window x of the prospective divider during a
347    /// [`ColumnResizePolicy::OnRelease`] drag. Mirrors
348    /// `TableView::resize_preview_x`.
349    resize_preview_x: Signal<Option<f32>>,
350    /// Width of the header strip (= the column band) snapshotted by
351    /// `place_children`. Mirrors `TableView::header_strip_width` — the
352    /// column-reorder drop handler needs it to mirror the drop x under RTL.
353    header_strip_width: Rc<Cell<f32>>,
354    /// Stable id grouping the column-header reorder/resize drag (an
355    /// unrelated mechanism to the row DnD below — see `table_view::header`).
356    table_id: usize,
357
358    /// Stable, kind-tagged identity for this view's **row** drag-and-drop —
359    /// distinct from `table_id` above. Minted via
360    /// `ViewId::next(ViewKind::TreeTable)`.
361    model_id: ViewId,
362
363    /// Cross-widget export / foreign-receive machinery — the builders
364    /// (`.exportable`, `.export_external`, `.accept_foreign_rows`,
365    /// `.on_rows_received`, `.on_rows_transferred_out`), the drag-start
366    /// payload build, and the move-out completion, shared by all five data
367    /// views. `TreeTableView` builds its reader + stable-key removal thunk
368    /// inline at drag-start (see `TreeBodyPane::build`'s `on_drag`) rather
369    /// than from source capability closures, so the key it removes by is
370    /// resolved once at drag-start and stays correct even if a mid-drag
371    /// spring-load reflattens the rows under the pointer.
372    export: crate::data_views::RowExport<T>,
373    /// Raw escape hatch for a payload this view cannot interpret itself.
374    ///
375    /// A source-backed view ([`from_source`](Self::from_source)) expresses
376    /// foreign-accept through its source's capability closures, like
377    /// `ListView` / `TableView`. This hook is what a **projection**-backed
378    /// view ([`from_projection`](Self::from_projection) / [`new`](Self::new))
379    /// has instead, since a `SortFilterTreeModel` carries no such closures.
380    /// Fires for any payload NOT recognized as this view's own row drag,
381    /// dropped on a node —
382    /// `(payload, target node, drop position, ctx) -> accepted`. Tried after
383    /// [`on_rows_received`](Self::on_rows_received).
384    #[allow(clippy::type_complexity)]
385    on_foreign_drop:
386        Option<Rc<dyn Fn(&DragPayload, NodeId, DropPosition, &mut EventContext) -> bool>>,
387
388    /// Whole-view enabled state, statically or reactively. Forwarded to the
389    /// arena via `ctx.enabled_when(self_id, self.enabled.clone())` at build
390    /// time; a disabled view greys out and stops accepting focus /
391    /// selection / keyboard input (arena-gated).
392    enabled: Prop<bool>,
393}
394
395impl<T: 'static> TreeTableView<T> {
396    /// Wrap a `SortFilterTreeModel<T>`.
397    /// Wrap a `SortFilterTreeModel<T>`.
398    pub fn from_projection(proxy: SortFilterTreeModel<T>) -> Self {
399        let source = Rc::new(TreeSource::from_data_source(Rc::new(proxy.clone())));
400        Self::assemble(source, Some(proxy))
401    }
402
403    /// Build a tree table over any [`TreeDataSource`] — an external source of
404    /// truth (a Qleany entity store, a database, a virtual filesystem) carrying
405    /// its own `Key`, so it needs no `TreeModel` mirror.
406    ///
407    /// This is the tree-table sibling of
408    /// [`TreeView::from_source`](crate::TreeView::from_source). Because the
409    /// source owns identity, its expand state (and a keyed selection) survive a
410    /// full re-source — which a `TreeModel` mirror cannot guarantee, since
411    /// `NodeId`s are reassigned on rebuild.
412    ///
413    /// The `NodeId`-typed methods ([`expand`](Self::expand),
414    /// [`projection`](Self::projection), [`keyed_selection`](Self::keyed_selection))
415    /// do not apply here and no-op; drive expansion through the source itself.
416    ///
417    /// Row drag-reorder **is** wired on this path: a drop routes through the source's
418    /// own `drag` / `can_accept` / `accept_drop`, exactly as
419    /// [`TreeView`](crate::TreeView) does — so the
420    /// source owns both the cycle guard and the commit. Note that
421    /// [`TreeDataSlice::drag`](teksilo_data::TreeDataSlice) defaults to `NoDrag`: an
422    /// external source must opt its rows in before anything can be dragged.
423    pub fn from_source<S: TreeDataSource<Item = T> + 'static>(source: S) -> Self {
424        Self::assemble(Rc::new(TreeSource::from_data_source(Rc::new(source))), None)
425    }
426
427    /// Like [`from_source`](Self::from_source) but with **keyed** selection:
428    /// the `KeyedSelectionModel<S::Key>` tracks rows by source identity, so it
429    /// survives expand / collapse, sort / filter and a full re-source. Pruning
430    /// consults the source's `contains_key`, so a collapsed-but-present row
431    /// keeps its selection. The view stays `TreeTableView<T>` — the `Key` is
432    /// captured here.
433    pub fn from_source_keyed<S: TreeDataSource<Item = T> + 'static>(
434        source: S,
435        keyed: KeyedSelectionModel<S::Key>,
436    ) -> Self
437    where
438        S::Key: teksilo_data::ItemKey,
439    {
440        let s = Rc::new(source);
441        let key_at = {
442            let s = s.clone();
443            Rc::new(move |i| s.key_at(i)) as Rc<dyn Fn(usize) -> Option<S::Key>>
444        };
445        let len = {
446            let s = s.clone();
447            Rc::new(move || s.visible_count()) as Rc<dyn Fn() -> usize>
448        };
449        let contains = {
450            let s = s.clone();
451            Rc::new(move |k: &S::Key| s.contains_key(k)) as Rc<dyn Fn(&S::Key) -> bool>
452        };
453        let mut view = Self::assemble(Rc::new(TreeSource::from_data_source(s)), None);
454        view.row_selection = Some(RowSelection::from_keyed(keyed, key_at, len, contains));
455        view
456    }
457
458    fn assemble(source: Rc<TreeSource<T>>, proxy: Option<SortFilterTreeModel<T>>) -> Self {
459        use std::sync::atomic::{AtomicUsize, Ordering};
460        static NEXT_ID: AtomicUsize = AtomicUsize::new(1);
461        let table_id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
462        Self {
463            source,
464            proxy,
465            columns: Vec::new(),
466            tree_column_id: None,
467            indent_per_level: None,
468            row_height: None,
469            height_source: HeightSource::Uniform,
470            row_metrics: Rc::new(RefCell::new(RowMetrics::uniform(cp::ROW_HEIGHT, 0.0))),
471            header_height: None,
472            show_header: true,
473            selection_mode: TableSelectionMode::default(),
474            row_selection: None,
475            cell_selection: None,
476            alternating_rows: false,
477            grid_lines: GridLines::None,
478            stretch_last_column: false,
479            a11y_label: None,
480            show_internal_scrollbars: true,
481            column_resize_policy: ColumnResizePolicy::default(),
482            tab_traversal: TabTraversal::default(),
483            edit_triggers: EditTriggers::default(),
484            on_cell_edit_request: None,
485            on_cell_edit_dismissed: None,
486            on_row_activate: None,
487            reorderable: false,
488            drop_feedback: Signal::new(None),
489            activate_on: crate::data_views::ActivateOn::default(),
490            smooth_scrolling: true,
491            smooth_scroll_duration: Duration::from_millis(150),
492            scroll_bar_style: ScrollBarMode::Permanent,
493            scroll_y: Signal::new_animated(0.0),
494            max_scroll_y: Signal::new(0.0),
495            overscroll_behavior: OverscrollBehavior::default(),
496            viewport_ratio_y: Signal::new(1.0),
497            scroll_x: Signal::new_animated(0.0),
498            max_scroll_x: Signal::new(0.0),
499            viewport_ratio_x: Signal::new(1.0),
500            scroller: Rc::new(RefCell::new(KineticScroller::new(OverscrollStyle::Clamp))),
501            sort_signal: Signal::new(None),
502            column_widths_signal: Signal::new(HashMap::new()),
503            column_order_signal: Signal::new(Vec::new()),
504            column_pinning_signal: Signal::new(HashMap::new()),
505            filters_signal: Signal::new(HashMap::new()),
506            focused_cell: Signal::new(None),
507            row_map: Rc::new(RefCell::new(Vec::new())),
508            type_ahead_label: None,
509            type_ahead_timeout: crate::common::type_ahead::DEFAULT_TYPE_AHEAD_TIMEOUT,
510            type_ahead: crate::common::type_ahead::TypeAheadState::new(),
511            // Replaced at build with the live tree signals.
512            view_focused: Signal::new(true),
513            focus_visible: Signal::new(false),
514            editing_cell: Signal::new(None),
515            empty_view: None,
516            laid_out: Rc::new(Cell::new(false)),
517            editing_anchor: Rc::new(RefCell::new(None)),
518            header_row_id: None,
519            body_pane_id: None,
520            scrollbar_id: None,
521            h_scrollbar_id: None,
522            empty_id: None,
523            pane_version: Signal::new(0_u64),
524            pane_built_start: Rc::new(Cell::new(0)),
525            pane_built_end: Rc::new(Cell::new(0)),
526            pane_total_refresh: Signal::new(0_u64),
527            column_widths: Rc::new(RefCell::new(Vec::new())),
528            display_indices: Rc::new(RefCell::new(Vec::new())),
529            pane_boundaries: Rc::new(RefCell::new(crate::table_view::PaneBoundaries::default())),
530            cell_map: Rc::new(RefCell::new(Vec::new())),
531            viewport_height: Rc::new(Cell::new(600.0)),
532            middle_viewport_width: Rc::new(Cell::new(600.0)),
533            body_bounds: Rc::new(Cell::new(Rect::ZERO)),
534            resize_state: Rc::new(RefCell::new(None)),
535            resize_target: Signal::new(None),
536            resize_preview_x: Signal::new(None),
537            header_strip_width: Rc::new(Cell::new(0.0)),
538            table_id,
539            model_id: ViewId::next(ViewKind::TreeTable),
540            export: crate::data_views::RowExport::default(),
541            on_foreign_drop: None,
542            enabled: Prop::Static(true),
543        }
544    }
545
546    /// Wrap a raw `TreeModel<T>` — convenience for callers that don't
547    /// need sort/filter. Internally builds an identity
548    /// `SortFilterTreeModel`.
549    pub fn new(model: TreeModel<T>) -> Self {
550        Self::from_projection(SortFilterTreeModel::new(model))
551    }
552
553    // ── Builder ────────────────────────────────────────────────────────
554
555    /// Enable or disable the whole view. A disabled view greys out and stops
556    /// accepting focus / selection / keyboard input (arena-gated).
557    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
558        self.enabled = enabled.into();
559        self
560    }
561
562    /// Set the scroll-chaining behavior at the boundary (default
563    /// [`OverscrollBehavior::Chain`]; [`Contain`](OverscrollBehavior::Contain)
564    /// disables chaining to an ancestor scrollable).
565    pub fn overscroll_behavior(mut self, behavior: OverscrollBehavior) -> Self {
566        self.overscroll_behavior = behavior;
567        self
568    }
569
570    /// Enable or disable animated wheel scrolling (enabled by default).
571    /// When disabled, wheel events snap immediately to the new offset.
572    pub fn smooth_scrolling(mut self, enabled: bool) -> Self {
573        self.smooth_scrolling = enabled;
574        self
575    }
576
577    /// Enable **type-ahead** ("type to jump"): typing a printable character
578    /// while the tree-table has keyboard focus jumps the focused row to the
579    /// next *visible* row whose label starts with the accumulated search term,
580    /// wrapping around (Qt `keyboardSearch` / macOS & Windows type-select).
581    /// `label(&item)` yields the searchable text; matching is
582    /// ASCII-case-insensitive. A pause longer than the
583    /// [`type_ahead_timeout`](Self::type_ahead_timeout) starts a fresh term.
584    pub fn type_ahead_label(mut self, label: impl Fn(&T) -> String + 'static) -> Self {
585        self.type_ahead_label = Some(Rc::new(label));
586        self
587    }
588
589    /// Reset window between keystrokes before the type-ahead search term
590    /// clears (default 500 ms). A zero duration disables type-ahead.
591    pub fn type_ahead_timeout(mut self, timeout: Duration) -> Self {
592        self.type_ahead_timeout = timeout;
593        self
594    }
595
596    /// Duration of the smooth scroll animation (default 150 ms).
597    pub fn smooth_scroll_duration(mut self, duration: Duration) -> Self {
598        self.smooth_scroll_duration = duration;
599        self
600    }
601
602    /// How the scroll bar is displayed (default `Permanent`). `Overlay`
603    /// and `Thin` float the bar over the content instead of reserving a
604    /// layout column for it, mirroring `ScrollArea::scroll_bar_style`.
605    pub fn scroll_bar_style(mut self, style: ScrollBarMode) -> Self {
606        self.scroll_bar_style = style;
607        self
608    }
609
610    /// Append a column definition. Columns are displayed in declaration order unless
611    /// reordered by the user.
612    pub fn add_column(mut self, col: Column<T>) -> Self {
613        self.columns.push(col);
614        self
615    }
616
617    /// Enable drag-to-reorder of **rows** (pointer drag + keyboard
618    /// Alt+ArrowUp/Down). Distinct from
619    /// [`Column::reorderable`](crate::Column::reorderable), which reorders
620    /// *columns* and defaults to `true`; this defaults to `false`.
621    ///
622    /// A drop reparents/reorders the dragged node in the underlying
623    /// `TreeModel` (top third of a row = Before, middle = Into / make-child,
624    /// bottom = After). The move is cycle-guarded — dropping a node onto
625    /// itself or into its own subtree is refused (no insertion line). Reorder
626    /// is **suppressed while a sort is active**: with the visible order driven
627    /// by the sort, a manual reorder would have no visible effect.
628    pub fn reorderable(mut self, enabled: bool) -> Self {
629        self.reorderable = enabled;
630        self
631    }
632
633    /// Make rows **droppable outside this view** — on a
634    /// [`DropTarget`](crate::DropTarget), another data view, or the OS.
635    ///
636    /// A dragged row (or the whole selection, when the pressed row is part of a
637    /// multi-selection) carries clones of its items in a public
638    /// [`RowDragData<T>`](crate::RowDragData), so a foreign receiver can pull
639    /// them out with `payload.get_typed::<RowDragData<T>>()` /
640    /// `DropTarget::on_drop_typed::<RowDragData<T>>()` — no serialization. This
641    /// also makes rows a drag source even without [`reorderable`](Self::reorderable).
642    ///
643    /// `mode` chooses what happens to the origin rows once a *foreign* target
644    /// accepts them: [`DragTransferMode::Move`] removes them — by default,
645    /// directly from the underlying `TreeModel` (any dragged node that is a
646    /// descendant of another dragged node is skipped, since removing the
647    /// ancestor already removes it); override via
648    /// [`on_rows_transferred_out`](Self::on_rows_transferred_out).
649    /// [`DragTransferMode::Copy`] leaves them. A same-view reorder is never a
650    /// transfer, so `mode` never affects it. Requires `T: Clone`.
651    pub fn exportable(mut self, mode: DragTransferMode) -> Self
652    where
653        T: Clone,
654    {
655        self.export.set_exportable(mode);
656        self
657    }
658
659    /// Additionally advertise the dragged rows as MIME data so they can be
660    /// dropped on a [`DropZone`](crate::DropZone) or exported to another
661    /// application / window via the OS. `f` maps the dragged items to
662    /// `(mime_type, bytes)` pairs (e.g. `text/plain`, `text/uri-list`, an
663    /// app-specific `application/x-…`). Implies [`exportable`](Self::exportable)
664    /// (defaulting to [`DragTransferMode::Move`] if not already set). Requires
665    /// `T: Clone`.
666    pub fn export_external(mut self, f: impl Fn(&[T]) -> Vec<(String, Vec<u8>)> + 'static) -> Self
667    where
668        T: Clone,
669    {
670        self.export.set_export_external(f);
671        self
672    }
673
674    /// Override how rows moved out to a foreign target are removed from this
675    /// view. Receives the dragged rows' flat visible indices (as captured at
676    /// drag-start) and the live context. Without this, an
677    /// [`exportable`](Self::exportable) [`Move`](DragTransferMode::Move) drag
678    /// removes the dragged nodes directly from the underlying `TreeModel`
679    /// (leaf-first / descending — a dragged node that is a descendant of
680    /// another dragged node is skipped, since removing the ancestor already
681    /// removes its whole subtree).
682    pub fn on_rows_transferred_out(
683        mut self,
684        f: impl Fn(&[usize], &mut EventContext) + 'static,
685    ) -> Self {
686        self.export.set_on_rows_transferred_out(f);
687        self
688    }
689
690    /// Accept exported rows dropped from a **different** view or source
691    /// without writing a custom source. Pair with
692    /// [`on_rows_received`](Self::on_rows_received), which is handed the
693    /// dropped items and the target flat row index. (Same-view reorder is
694    /// [`reorderable`](Self::reorderable).)
695    pub fn accept_foreign_rows(mut self, accept: bool) -> Self {
696        self.export.accept_foreign_rows = accept;
697        self
698    }
699
700    /// Handler for rows accepted via
701    /// [`accept_foreign_rows`](Self::accept_foreign_rows): `(items, target
702    /// flat row index, ctx)`. Insert them into your tree at/near the index.
703    pub fn on_rows_received(
704        mut self,
705        f: impl Fn(Vec<T>, usize, &mut EventContext) + 'static,
706    ) -> Self {
707        self.export.set_on_rows_received(f);
708        self
709    }
710
711    /// Raw escape hatch for a foreign drop.
712    ///
713    /// **Projection path only.** This hook is `NodeId`-typed and predates
714    /// [`from_source`](Self::from_source); over an external source there is no
715    /// `NodeId` to hand it, so it never fires. Prefer
716    /// [`accept_foreign_rows`](Self::accept_foreign_rows) +
717    /// [`on_rows_received`](Self::on_rows_received), which are source-agnostic.
718    /// A source-backed view expresses foreign-accept through its source's own
719    /// capability closures (`can_accept` / `accept_drop`), like `ListView` /
720    /// `TableView`; this hook is what a **projection**-backed view has instead,
721    /// since a `SortFilterTreeModel` carries no such closures.
722    /// This fires for **any** payload NOT recognized as this view's own row
723    /// drag — a different view's [`RowDragData<T>`](crate::RowDragData), or a
724    /// completely different payload type — dropped on a node: `(payload,
725    /// target node, drop position, ctx) -> accepted`. Tried after
726    /// [`on_rows_received`](Self::on_rows_received), so the typed sugar wins
727    /// when both are set and the payload happens to carry an exportable
728    /// `RowDragData<T>`.
729    pub fn on_foreign_drop(
730        mut self,
731        f: impl Fn(&DragPayload, NodeId, DropPosition, &mut EventContext) -> bool + 'static,
732    ) -> Self {
733        self.on_foreign_drop = Some(Rc::new(f));
734        self
735    }
736
737    /// Choose single- vs double-click activation for `on_row_activate` (default
738    /// [`ActivateOn::DoubleClick`](crate::ActivateOn)). Enter/Space activates in
739    /// either mode.
740    pub fn activate_on(mut self, mode: crate::data_views::ActivateOn) -> Self {
741        self.activate_on = mode;
742        self
743    }
744
745    /// Append multiple columns from an iterator.
746    pub fn columns(mut self, cols: impl IntoIterator<Item = Column<T>>) -> Self {
747        self.columns.extend(cols);
748        self
749    }
750
751    /// Designate which column hosts the twist + indent. Default: the
752    /// first column.
753    pub fn tree_column(mut self, col_id: impl Into<String>) -> Self {
754        self.tree_column_id = Some(col_id.into());
755        self
756    }
757
758    /// Override the per-depth indent in the tree column in logical pixels (default
759    /// comes from the active `TableStyle`).
760    pub fn indent_per_level(mut self, px: f32) -> Self {
761        self.indent_per_level = Some(px);
762        self
763    }
764
765    /// Re-materialize `self.row_metrics` after a height-mode /
766    /// row-height builder call.
767    fn remake_metrics(&self) {
768        *self.row_metrics.borrow_mut() = self
769            .height_source
770            .make_metrics(self.effective_row_height(), 0.0);
771    }
772
773    /// Fixed row height (default: the table style's 28 px) — the
774    /// uniform fast path. Mutually exclusive with
775    /// [`row_height_fn`](Self::row_height_fn) and
776    /// [`auto_row_height`](Self::auto_row_height); the last mode setter
777    /// wins.
778    pub fn row_height(mut self, height: f32) -> Self {
779        self.row_height = Some(height);
780        self.height_source = HeightSource::Uniform;
781        self.remake_metrics();
782        self
783    }
784
785    /// Per-row heights from a callback over the flat (visible) row
786    /// index. The callback must be pure (same index + same data → same
787    /// height); it is re-swept from the first changed flat index on
788    /// every projection rebuild (expand/collapse/sort/filter/mutation).
789    /// No measurement pass runs.
790    pub fn row_height_fn(mut self, f: impl Fn(usize) -> f32 + 'static) -> Self {
791        self.height_source = HeightSource::Exact(Rc::new(f));
792        self.remake_metrics();
793        self
794    }
795
796    /// Auto-measured row heights: each realized row reports the height
797    /// of its tallest cell measured at the cell's column width
798    /// (height-for-width), unrealized rows assume `estimated`. Scroll
799    /// anchoring keeps content above the viewport stationary; measured
800    /// heights above a toggled row survive expand/collapse
801    /// (divergence-driven invalidation). The scrollbar settles one
802    /// frame after a measurement change.
803    pub fn auto_row_height(mut self, estimated: f32) -> Self {
804        self.height_source = HeightSource::Auto { estimated };
805        self.remake_metrics();
806        self
807    }
808
809    /// Override the header row height in logical pixels.
810    pub fn header_height(mut self, height: f32) -> Self {
811        self.header_height = Some(height);
812        self
813    }
814
815    /// Show or hide the column header row (default `true`).
816    pub fn show_header(mut self, visible: bool) -> Self {
817        self.show_header = visible;
818        self
819    }
820
821    /// Set the row/cell selection mode (default
822    /// [`TableSelectionMode::MultiRow`]).
823    pub fn selection_mode(mut self, mode: TableSelectionMode) -> Self {
824        self.selection_mode = mode;
825        self
826    }
827
828    /// Set the index-based row selection model (visible positions). For
829    /// identity-based selection that survives expand / collapse / sort /
830    /// filter / structural edits, use [`keyed_selection`](Self::keyed_selection)
831    /// instead.
832    pub fn selection(mut self, sel: SelectionModel) -> Self {
833        self.row_selection = Some(RowSelection::from_index(sel));
834        self
835    }
836
837    /// Set a keyed row selection model (by `NodeId`). Selection is tracked by
838    /// node identity, so it survives expand / collapse, sort / filter, and node
839    /// moves — and stays consistent if two views share the projection. Pruned
840    /// of deleted nodes on each projection change. Mutually exclusive with
841    /// [`selection`](Self::selection) (last one set wins).
842    /// Only meaningful on the [`from_projection`](Self::from_projection) /
843    /// [`new`](Self::new) paths, whose identity *is* `NodeId`; a no-op over an
844    /// external source, which carries its own key — use
845    /// [`from_source_keyed`](Self::from_source_keyed) there.
846    pub fn keyed_selection(mut self, keyed: KeyedSelectionModel<NodeId>) -> Self {
847        let Some(proxy) = self.proxy.clone() else {
848            return self;
849        };
850        let key_at = {
851            let p = proxy.clone();
852            Rc::new(move |i| p.visible_node_id(i)) as Rc<dyn Fn(usize) -> Option<NodeId>>
853        };
854        let len = {
855            let p = proxy.clone();
856            Rc::new(move || p.visible_count()) as Rc<dyn Fn() -> usize>
857        };
858        // A collapsed-but-present node must NOT be pruned, so existence is
859        // checked against the tree, not the (visible) projection window.
860        let contains = {
861            let p = proxy;
862            Rc::new(move |n: &NodeId| p.tree().with_item(*n, |_| ()).is_some())
863                as Rc<dyn Fn(&NodeId) -> bool>
864        };
865        self.row_selection = Some(RowSelection::from_keyed(keyed, key_at, len, contains));
866        self
867    }
868
869    /// Attach a cell-level selection model (row and column axes tracked
870    /// independently).
871    pub fn cell_selection(mut self, sel: CellSelectionModel) -> Self {
872        self.cell_selection = Some(sel);
873        self
874    }
875
876    /// Paint odd-indexed rows with the `SurfaceRole::AlternatingRow` tint
877    /// (default `false`).
878    pub fn alternating_rows(mut self, enabled: bool) -> Self {
879        self.alternating_rows = enabled;
880        self
881    }
882
883    /// Paint horizontal and/or vertical dividers between cells.
884    pub fn grid_lines(mut self, kind: GridLines) -> Self {
885        self.grid_lines = kind;
886        self
887    }
888
889    /// Let the **last column in display order** take up whatever width the
890    /// other columns leave, so the table never ends in a bare strip at its
891    /// trailing edge — Qt's `stretchLastSection`, NSTableView's
892    /// `lastColumnOnlyAutoresizingStyle`. Default: off.
893    ///
894    /// Positional, not a property of a column: after a reorder it is the
895    /// *new* last column that stretches and the previous one goes back to
896    /// its own width. While a column stretches, its declared width is the
897    /// floor it grows from, its user-resize override is ignored, and its
898    /// trailing grip is disabled (no AccessKit Increment/Decrement either):
899    /// any size the user gave it, the stretch would take straight back.
900    /// Resizing any *other* column reflows it. Once the other columns
901    /// exceed the viewport there is nothing left to stretch into — the
902    /// last column sits at its own width and the pane scrolls, as in Qt.
903    ///
904    /// `Flex` columns already share every spare pixel among themselves, so
905    /// a table of `Flex` columns looks the same either way: this is for
906    /// pixel-sized tables (`Fixed` / `Auto`, or widths the user has set)
907    /// that would otherwise end in a gap.
908    pub fn stretch_last_column(mut self, on: bool) -> Self {
909        self.stretch_last_column = on;
910        self
911    }
912
913    /// Accessible label for the whole tree table, announced by AT as the
914    /// table's name.
915    pub fn a11y_label(mut self, label: impl Into<LocalizedString>) -> Self {
916        self.a11y_label = Some(label.into());
917        self
918    }
919
920    /// Show or hide the widget's internal vertical and horizontal scroll bars
921    /// (default `true`). Set to `false` when the table lives inside an external
922    /// `ScrollArea`.
923    pub fn show_internal_scrollbars(mut self, show: bool) -> Self {
924        self.show_internal_scrollbars = show;
925        self
926    }
927
928    /// Control how column widths are distributed when the table is resized
929    /// (default `Proportional`).
930    pub fn column_resize_policy(mut self, policy: ColumnResizePolicy) -> Self {
931        self.column_resize_policy = policy;
932        self
933    }
934
935    /// Set the keyboard Tab traversal direction inside the table (default `CellsThenRows`).
936    pub fn tab_traversal(mut self, mode: TabTraversal) -> Self {
937        self.tab_traversal = mode;
938        self
939    }
940
941    /// Set which user gestures open an in-place cell editor — a set, composed
942    /// with `|` (default `F2 | ANY_KEY | DOUBLE_CLICK`). See [`EditTriggers`].
943    pub fn edit_triggers(mut self, trigger: EditTriggers) -> Self {
944        self.edit_triggers = trigger;
945        self
946    }
947
948    /// Callback invoked when the user requests an in-place cell edit (e.g.
949    /// double-click when `edit_triggers` contains `DOUBLE_CLICK`). Receives the flat row
950    /// index, the column id, and a mutable `EventContext`.
951    pub fn on_cell_edit_request(
952        mut self,
953        f: impl Fn(usize, &str, &mut EventContext) + 'static,
954    ) -> Self {
955        self.on_cell_edit_request = Some(Rc::new(f));
956        self
957    }
958
959    /// Callback invoked when an **open** cell editor should end because the
960    /// pointer went somewhere else: a press that lands on any cell other than
961    /// the one being edited. Receives the editing cell's flat row index and
962    /// column id, so the owner can commit (or discard) whatever is in its
963    /// buffer, then clear its own editing state.
964    ///
965    /// The counterpart of [`on_cell_edit_request`](Self::on_cell_edit_request),
966    /// and the view cannot do it alone: the framework owns *which* cell is being
967    /// edited, but only the owner knows what an ended edit means — commit,
968    /// discard, or refuse a value that will not parse.
969    ///
970    /// **Why a press and not a focus change.** "The editor lost focus" is the
971    /// obvious signal and it cannot be used: a body pane rebuilds constantly —
972    /// selection, filtering, scroll, a reload from elsewhere — and every rebuild
973    /// destroys and re-creates the open editor, so focus leaves it many times
974    /// during an edit the writer never interrupted. A press on another cell is
975    /// unambiguous and happens exactly once.
976    pub fn on_cell_edit_dismissed(
977        mut self,
978        f: impl Fn(usize, &str, &mut EventContext) + 'static,
979    ) -> Self {
980        self.on_cell_edit_dismissed = Some(Rc::new(f));
981        self
982    }
983
984    /// Callback invoked when a row is activated (double-click or Enter, per
985    /// `activate_on`). Receives the flat row index.
986    pub fn on_row_activate(mut self, f: impl Fn(usize, &mut EventContext) + 'static) -> Self {
987        self.on_row_activate = Some(Rc::new(f));
988        self
989    }
990
991    /// Forward `mode` to the underlying projection. The proxy holds its
992    /// state behind `Rc<RefCell>`, so calling `.filter_mode()` on a
993    /// clone mutates the shared inner — effectively persisting the
994    /// choice on `self.proxy`.
995    pub fn filter_mode(self, mode: TreeFilterMode) -> Self {
996        if let Some(p) = &self.proxy {
997            let _ = p.clone().filter_mode(mode);
998        }
999        self
1000    }
1001
1002    // ── Reactive signals ──────────────────────────────────────────────
1003
1004    /// Current vertical scroll offset in logical pixels.
1005    pub fn scroll_y_signal(&self) -> &Signal<f32> {
1006        &self.scroll_y
1007    }
1008
1009    /// Maximum vertical scroll offset (content height − viewport height).
1010    pub fn max_scroll_y_signal(&self) -> &Signal<f32> {
1011        &self.max_scroll_y
1012    }
1013
1014    /// Viewport-to-content height ratio — drives the scrollbar thumb size.
1015    pub fn viewport_ratio_y_signal(&self) -> &Signal<f32> {
1016        &self.viewport_ratio_y
1017    }
1018
1019    /// Current horizontal scroll offset of the Middle (unpinned) pane, in
1020    /// logical pixels. Leading/Trailing-pinned columns are unaffected.
1021    pub fn scroll_x_signal(&self) -> &Signal<f32> {
1022        &self.scroll_x
1023    }
1024
1025    /// Maximum horizontal scroll offset — `middle_content_width −
1026    /// middle_viewport_width`.
1027    pub fn max_scroll_x_signal(&self) -> &Signal<f32> {
1028        &self.max_scroll_x
1029    }
1030
1031    /// Middle-pane viewport-to-content width ratio.
1032    pub fn viewport_ratio_x_signal(&self) -> &Signal<f32> {
1033        &self.viewport_ratio_x
1034    }
1035
1036    /// Active sort state: `Some((col_id, direction))` or `None` for unsorted.
1037    ///
1038    /// **This is the header's state, not the data's.** Clicking a sort header
1039    /// writes here; nothing reorders rows until you bind this onto the backing
1040    /// projection yourself:
1041    ///
1042    /// ```ignore
1043    /// let proxy = SortFilterTreeModel::new(tree)
1044    ///     .with_comparator("name", |a: &Row, b: &Row| a.name.cmp(&b.name));
1045    /// proxy.sort_signal(view.sort_signal().clone());
1046    /// ```
1047    ///
1048    /// The binding is deliberately not automatic: a projection may already
1049    /// carry preset comparators, predicates, and a filter mode, and adopting
1050    /// the view's empty signal at construction would clobber them.
1051    pub fn sort_signal(&self) -> &Signal<Option<(String, SortDirection)>> {
1052        &self.sort_signal
1053    }
1054
1055    /// Active per-column filters keyed by column id.
1056    ///
1057    /// Like [`sort_signal`](Self::sort_signal), this holds the header's state
1058    /// only — bind it onto the projection to actually filter rows:
1059    ///
1060    /// ```ignore
1061    /// let proxy = SortFilterTreeModel::new(tree)
1062    ///     .with_predicate("name", |t| {
1063    ///         let needle = t.to_string();
1064    ///         Box::new(move |r: &Row| r.name.contains(&needle))
1065    ///     });
1066    /// proxy.filters_signal(view.filters_signal().clone());
1067    /// ```
1068    pub fn filters_signal(&self) -> &Signal<HashMap<String, String>> {
1069        &self.filters_signal
1070    }
1071
1072    /// Current column widths in logical pixels, keyed by column id.
1073    pub fn column_widths_signal(&self) -> &Signal<HashMap<String, f32>> {
1074        &self.column_widths_signal
1075    }
1076
1077    /// Current column display order as a list of column ids.
1078    pub fn column_order_signal(&self) -> &Signal<Vec<String>> {
1079        &self.column_order_signal
1080    }
1081
1082    /// Keyboard-focused cell as `(row, display_column_index)`, or `None`.
1083    pub fn focused_cell_signal(&self) -> &Signal<Option<(usize, usize)>> {
1084        &self.focused_cell
1085    }
1086
1087    /// Cell currently being edited as `(row, display_column_index)`, or `None`.
1088    pub fn editing_cell_signal(&self) -> &Signal<Option<(usize, usize)>> {
1089        &self.editing_cell
1090    }
1091
1092    /// The widget realized for the cell at `(row, display column)` in the body
1093    /// pane's latest build, or `None` once it has scrolled (or collapsed) out
1094    /// of the realized buffer — `cell_map` is a snapshot, not an index of every
1095    /// row the source holds, so a miss here means "not on screen", never "no
1096    /// such cell".
1097    fn realized_cell(&self, row: usize, col: usize) -> Option<WidgetId> {
1098        self.cell_map
1099            .borrow()
1100            .iter()
1101            .find(|&&(pos, _)| pos == (row, col))
1102            .map(|&(_, id)| id)
1103    }
1104
1105    /// Access the underlying `SortFilterTreeModel` (for programmatic sort /
1106    /// filter / expand outside of the builder API).
1107    /// `None` when the view was built from an external
1108    /// [`teksilo_data::TreeDataSource`] via
1109    /// [`from_source`](Self::from_source) — there is no `TreeModel`-backed
1110    /// projection to hand back in that case.
1111    pub fn projection(&self) -> Option<&SortFilterTreeModel<T>> {
1112        self.proxy.as_ref()
1113    }
1114
1115    // ── Imperative API ─────────────────────────────────────────────────
1116
1117    /// Expand the subtree rooted at `node`.
1118    pub fn expand(&self, node: NodeId) {
1119        if let Some(p) = &self.proxy {
1120            p.expand(node);
1121        }
1122    }
1123
1124    /// Collapse the subtree rooted at `node`.
1125    pub fn collapse(&self, node: NodeId) {
1126        if let Some(p) = &self.proxy {
1127            p.collapse(node);
1128        }
1129    }
1130
1131    /// Toggle the expand/collapse state of `node`.
1132    pub fn toggle(&self, node: NodeId) {
1133        if let Some(p) = &self.proxy {
1134            p.toggle(node);
1135        }
1136    }
1137
1138    /// Expand all nodes in the tree.
1139    pub fn expand_all(&self) {
1140        if let Some(p) = &self.proxy {
1141            p.expand_all();
1142        }
1143    }
1144
1145    /// Collapse all nodes in the tree.
1146    pub fn collapse_all(&self) {
1147        if let Some(p) = &self.proxy {
1148            p.collapse_all();
1149        }
1150    }
1151
1152    /// Move keyboard focus to the cell at `(row, col)`.
1153    pub fn set_focused_cell(&self, row: usize, col: usize) {
1154        self.focused_cell.set(Some((row, col)));
1155    }
1156
1157    /// Clear the keyboard-focused cell.
1158    pub fn clear_focused_cell(&self) {
1159        self.focused_cell.set(None);
1160    }
1161
1162    /// Programmatically sort by `col_id` (pass `None` to clear the sort).
1163    ///
1164    /// Equality-guarded, like every persisted-layout setter here — see
1165    /// [`set_column_widths`](Self::set_column_widths).
1166    pub fn set_sort(&self, col_id: Option<&str>, dir: SortDirection) {
1167        imperative::set_if_changed(&self.sort_signal, col_id.map(|c| (c.to_string(), dir)));
1168    }
1169
1170    /// Set or clear the filter text for a single column.
1171    pub fn set_filter(&self, col_id: &str, text: &str) {
1172        imperative::set_filter(&self.filters_signal, col_id, text);
1173    }
1174
1175    pub fn clear_filters(&self) {
1176        imperative::set_if_changed(&self.filters_signal, HashMap::new());
1177    }
1178
1179    /// Widget shown when no rows are visible — an empty tree, or a filter
1180    /// that matched nothing. Without one, the body region is simply blank.
1181    pub fn empty_view(mut self, f: impl Fn() -> Box<dyn Widget> + 'static) -> Self {
1182        self.empty_view = Some(Rc::new(f));
1183        self
1184    }
1185
1186    /// Clear the active sort.
1187    pub fn clear_sort(&self) {
1188        imperative::set_if_changed(&self.sort_signal, None);
1189    }
1190
1191    /// Scroll so that `row` is aligned to the top of the viewport. A no-op
1192    /// before the first layout pass.
1193    pub fn scroll_to_row(&self, row: usize) {
1194        if !self.laid_out.get() {
1195            return;
1196        }
1197        imperative::scroll_to_row(row, &self.row_metrics, &self.scroll_y, &self.max_scroll_y);
1198    }
1199
1200    /// Scroll the minimum distance needed to make `row` visible. A no-op
1201    /// before the first layout pass, when the viewport height is not yet known.
1202    pub fn ensure_row_visible(&self, row: usize) {
1203        imperative::ensure_row_visible(
1204            row,
1205            &self.row_metrics,
1206            &self.scroll_y,
1207            &self.max_scroll_y,
1208            self.viewport_height.get(),
1209            self.laid_out.get(),
1210        );
1211    }
1212
1213    /// Scroll the row the keyboard cursor sits on into view when this view
1214    /// takes focus.
1215    ///
1216    /// Only the rows near the viewport are realized, so on a tree taller than
1217    /// the window the cursor row frequently has no widget. Everything that
1218    /// speaks for it then has nothing to speak about: no cell node exists, so
1219    /// `accessibility()` below finds nothing in `cell_map` and nominates no
1220    /// `active_descendant`, and a screen reader taking focus here is told
1221    /// nothing at all. The first arrow press steps *past* that row as well,
1222    /// because the cursor was somewhere the user was never shown.
1223    ///
1224    /// The cursor is read exactly as the shared keyboard handler reads it
1225    /// (`table_view::keyboard::build_key_handler`, `keyboard.rs:134-139`): the
1226    /// focused cell's row, else the first selected row. Anything else would
1227    /// reveal a row the next arrow press does not step from.
1228    ///
1229    /// That row index is a **flat visible** index, not a position in the
1230    /// unflattened tree: a collapsed node's descendants have no index at all
1231    /// here. Checked on both sides of the read. `focused_cell` is clamped to
1232    /// `TreeNavigator::row_count()`, which returns `TreeSource::visible_count()`
1233    /// (`tree_table_view.rs:145-147`), and the keyed selection facade builds
1234    /// its indices by scanning `0..visible_count()` through
1235    /// `SortFilterTreeModel::visible_node_id` (`data_views.rs:545-552`). On the
1236    /// spending side, `RowMetrics` is sized by `place_children` from that same
1237    /// `visible_count()`, so `row_top(i)` is the top of the *i*-th visible row.
1238    ///
1239    /// `ensure_row_visible`, the view's own imperative path, rather than
1240    /// `scroll_to_row`: a row already on screen must not jump under somebody
1241    /// who can see it. It carries the `laid_out` guard too, so a focus that
1242    /// arrives before the first real height is a no-op instead of scrolling
1243    /// against a viewport that was never measured. It is the same
1244    /// `RowMetrics::scroll_for_ensure_visible` arithmetic the keyboard runs on
1245    /// every arrow press; what the keyboard's own wrapper
1246    /// (`table_view/keyboard.rs:672`) adds on top is chasing the row into an
1247    /// *enclosing* scroll area, and that needs an `EventContext`, which an
1248    /// effect does not have. Nothing is lost: the same keyboard or programmatic
1249    /// focus change makes the framework reveal the newly focused widget in
1250    /// every ancestor scroll area itself (`focus_impl.rs:109`,
1251    /// `WidgetTree::scroll_focused_into_view`), so the enclosing viewport is
1252    /// somebody else's job here.
1253    ///
1254    /// The handles are cloned into the effect rather than reaching through
1255    /// `self`, which the closure cannot borrow.
1256    fn reveal_current_row_on_focus(&self, ctx: &mut BuildContext) {
1257        let focused_cell = self.focused_cell.clone();
1258        let selection = self.row_selection.clone();
1259        let row_metrics = self.row_metrics.clone();
1260        let scroll_y = self.scroll_y.clone();
1261        let max_scroll_y = self.max_scroll_y.clone();
1262        let viewport_height = self.viewport_height.clone();
1263        let laid_out = self.laid_out.clone();
1264
1265        ctx.effect(&self.view_focused, move |focused| {
1266            if !*focused {
1267                return;
1268            }
1269            let Some(row) = focused_cell.get().map(|(row, _col)| row).or_else(|| {
1270                selection
1271                    .as_ref()
1272                    .and_then(|s| s.selected_indices().first().copied())
1273            }) else {
1274                return;
1275            };
1276            imperative::ensure_row_visible(
1277                row,
1278                &row_metrics,
1279                &scroll_y,
1280                &max_scroll_y,
1281                viewport_height.get(),
1282                laid_out.get(),
1283            );
1284        });
1285    }
1286
1287    /// Set or remove a single column's user-resized width override.
1288    /// A non-positive `width` removes the entry (the column reverts to
1289    /// its declared width policy).
1290    pub fn set_column_width(&self, col_id: &str, width: f32) {
1291        imperative::set_column_width(&self.column_widths_signal, col_id, width);
1292    }
1293
1294    /// Replace the full width-override map (typically used to restore
1295    /// a persisted layout).
1296    ///
1297    /// Equality-guarded for the same reason as
1298    /// [`TableView::set_column_widths`](crate::TableView::set_column_widths):
1299    /// the documented settings round-trip would otherwise recurse without
1300    /// bound on the first tick of a live resize drag.
1301    pub fn set_column_widths(&self, widths: HashMap<String, f32>) {
1302        imperative::set_column_widths(&self.column_widths_signal, widths);
1303    }
1304
1305    /// Replace the column-order list. Ids not declared on this table
1306    /// are silently dropped on the next layout pass.
1307    pub fn set_column_order(&self, order: Vec<String>) {
1308        imperative::set_if_changed(&self.column_order_signal, order);
1309    }
1310
1311    /// Current column pinning overrides, keyed by column id. Wins over
1312    /// each column's declared [`Column::pinned`].
1313    pub fn column_pinning_signal(&self) -> &Signal<HashMap<String, PinnedSide>> {
1314        &self.column_pinning_signal
1315    }
1316
1317    /// Pin or unpin a single column. [`PinnedSide::None`] removes the
1318    /// override, reverting the column to its declared pinning.
1319    pub fn set_column_pinning(&self, col_id: &str, side: PinnedSide) {
1320        imperative::set_column_pinning(&self.column_pinning_signal, col_id, side);
1321    }
1322
1323    /// Begin editing the cell `(row, col_id)`. Silently no-ops if `col_id`
1324    /// isn't a currently-displayed column, or if `row` is outside the visible
1325    /// range — an out-of-range target would otherwise strand `editing_cell` on
1326    /// a row nothing can match.
1327    ///
1328    /// Callable **before the view is mounted**, which is the only point at
1329    /// which a consumer can seed a freshly constructed view with an edit
1330    /// target it already holds. `display_indices` is a cache `build()` fills,
1331    /// so a pre-mount call finds it empty; the order is recomputed on demand
1332    /// in that case rather than resolving against nothing and no-opping for a
1333    /// third, undocumented reason.
1334    pub fn begin_edit(&self, row: usize, col_id: &str) {
1335        let cached = self.display_indices.borrow();
1336        let recomputed;
1337        let display: &[usize] = if cached.is_empty() {
1338            recomputed = self.display_order();
1339            &recomputed
1340        } else {
1341            &cached
1342        };
1343        if let Some(target) = imperative::resolve_edit_target(
1344            row,
1345            col_id,
1346            &self.columns,
1347            display,
1348            self.source.visible_count(),
1349        ) {
1350            drop(cached);
1351            self.editing_cell.set(Some(target));
1352        }
1353    }
1354
1355    /// Close the active cell editor without committing (the field's `on_blur` still fires).
1356    pub fn end_edit(&self) {
1357        self.editing_cell.set(None);
1358    }
1359
1360    // ── Internals ──────────────────────────────────────────────────────
1361
1362    fn effective_row_height(&self) -> f32 {
1363        self.row_height.unwrap_or(cp::ROW_HEIGHT)
1364    }
1365
1366    fn effective_header_height(&self) -> f32 {
1367        if self.show_header {
1368            self.header_height.unwrap_or(cp::HEADER_HEIGHT)
1369        } else {
1370            0.0
1371        }
1372    }
1373
1374    fn effective_indent(&self) -> f32 {
1375        self.indent_per_level.unwrap_or(cp::TREE_INDENT_PER_LEVEL)
1376    }
1377
1378    /// Resolve the tree column id to a declaration index. Falls back
1379    /// to column 0 when the configured id isn't found or unset.
1380    fn tree_column_decl_index(&self) -> usize {
1381        if let Some(ref id) = self.tree_column_id {
1382            for (i, col) in self.columns.iter().enumerate() {
1383                if &col.id == id {
1384                    return i;
1385                }
1386            }
1387        }
1388        0
1389    }
1390
1391    fn display_order(&self) -> Vec<usize> {
1392        let order_signal = self.column_order_signal.get();
1393        let mut order_map: HashMap<&str, usize> = HashMap::new();
1394        for (i, id) in order_signal.iter().enumerate() {
1395            order_map.insert(id.as_str(), i);
1396        }
1397        let mut leading: Vec<usize> = Vec::new();
1398        let mut middle: Vec<usize> = Vec::new();
1399        let mut trailing: Vec<usize> = Vec::new();
1400        for (i, col) in self.columns.iter().enumerate() {
1401            let pinning = self
1402                .column_pinning_signal
1403                .get()
1404                .get(&col.id)
1405                .copied()
1406                .unwrap_or(col.pinned);
1407            match pinning {
1408                PinnedSide::Leading => leading.push(i),
1409                PinnedSide::None => middle.push(i),
1410                PinnedSide::Trailing => trailing.push(i),
1411            }
1412        }
1413        const FALLBACK_BASE: usize = usize::MAX / 2;
1414        let cols = &self.columns;
1415        let key_for = |i: usize| {
1416            order_map
1417                .get(cols[i].id.as_str())
1418                .copied()
1419                .unwrap_or(FALLBACK_BASE + i)
1420        };
1421        leading.sort_by_key(|&i| key_for(i));
1422        middle.sort_by_key(|&i| key_for(i));
1423        trailing.sort_by_key(|&i| key_for(i));
1424        let mut out = Vec::with_capacity(leading.len() + middle.len() + trailing.len());
1425        out.extend(leading);
1426        let leading_count = out.len();
1427        out.extend(middle);
1428        let middle_end = out.len();
1429        out.extend(trailing);
1430        // Stash the boundaries so paint / place_children / the keyboard
1431        // handler's ensure-column-visible can read them — mirrors
1432        // `TableView::display_order`.
1433        *self.pane_boundaries.borrow_mut() =
1434            crate::table_view::PaneBoundaries::new(leading_count, middle_end);
1435        out
1436    }
1437
1438    fn clamp_scroll(&self) {
1439        let max = self.max_scroll_y.get();
1440        let current = self.scroll_y.get();
1441        let clamped = current.clamp(0.0, max);
1442        if (clamped - current).abs() > 0.001 {
1443            self.scroll_y.set(clamped);
1444        }
1445    }
1446
1447    /// Buffered realized range — mirrors `TableView::visible_range`. Used
1448    /// only to nudge the lazy source (`request_window`/`fetch_more`); the
1449    /// pane recomputes its own copy independently for actual row
1450    /// realization.
1451    fn visible_range(&self) -> (usize, usize) {
1452        self.row_metrics.borrow_mut().visible_range(
1453            self.scroll_y.get(),
1454            self.viewport_height.get(),
1455            self.source.visible_count(),
1456            BUFFER_ROWS,
1457        )
1458    }
1459}
1460
1461impl<T: 'static> std::fmt::Debug for TreeTableView<T> {
1462    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1463        f.debug_struct("TreeTableView")
1464            .field("rows", &self.source.visible_count())
1465            .field("columns", &self.columns.len())
1466            .field("tree_column", &self.tree_column_id)
1467            .field("scroll_bar_style", &self.scroll_bar_style)
1468            .finish()
1469    }
1470}
1471
1472#[cfg(test)]
1473mod tests;