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 (a hosted
11//! [`TableHeader`]), column, keyboard, and selection
12//! modules with `TableView`.
13//!
14//! Rows live in a `TreeBodyPane` — a sibling of the scrollbar — so buffer-exit /
15//! selection / expand rebuilds are never deferred mid-thumb-drag. Three row-height
16//! modes: uniform (`row_height`, fast path), exact per-flat-index callback
17//! (`row_height_fn`), and auto-measured (`auto_row_height` — grows to tallest cell).
18//!
19//! ## Common patterns
20//!
21//! **A checkbox column.** Selection and "checked" are different things — a
22//! checkbox column wants its own state, with parent/child propagation. Build it
23//! from [`TreeCheckedModel`](teksilo_data::TreeCheckedModel) over the same tree
24//! the view projects.
25//!
26//! A cell delegate receives `(&T, &CellContext)` and **`CellContext` carries no
27//! node identity** — only [`row_index`](crate::CellContext::row_index). So
28//! capture the projection and resolve the row's `NodeId` through it:
29//!
30//! ```ignore
31//! let proxy = SortFilterTreeModel::new(tree);
32//! let checks = TreeCheckedModel::new(proxy.tree());
33//! let for_cells = proxy.clone();
34//! let col = Column::new("done", lit!("Done"), move |_item, cx: &CellContext| {
35//!     match for_cells.visible_node_id(cx.row_index) {
36//!         Some(node) => Box::new(Checkbox::new(checks.check_state(node))) as Box<dyn Widget>,
37//!         None => Box::new(Spacer::new()),
38//!     }
39//! });
40//! ```
41//!
42//! For a tree whose identity is a domain key rather than a `NodeId`, use
43//! [`KeyedTreeCheckedModel`](teksilo_data::KeyedTreeCheckedModel) instead — it
44//! survives a full re-source, which a `NodeId`-keyed set cannot.
45//!
46//! **Group rows that span the table, and the group kept in sight.** A tree
47//! whose upper levels are groups — songs under albums under artists — draws
48//! those rows as one band across every column with
49//! [`full_width_row`](TreeTableView::full_width_row) and
50//! [`full_width_row_delegate`](TreeTableView::full_width_row_delegate), and
51//! keeps the groups being scrolled through pinned under the header with
52//! [`pinned_ancestors`](TreeTableView::pinned_ancestors):
53//!
54//! ```ignore
55//! let view = TreeTableView::from_projection(proxy)
56//!     .columns(columns)
57//!     .full_width_row(|row: &Row| !row.is_song())
58//!     .full_width_row_delegate(|row, _cx: &CellContext| {
59//!         Box::new(TextWidget::new(lit!(row.summary()))) as Box<dyn Widget>
60//!     })
61//!     .pinned_ancestors(2); // the artist, then the album
62//! ```
63//!
64//! A band is still a row of the tree — it selects, drags, expands from its
65//! chevron and answers type-ahead — and one cell to the keyboard and to
66//! assistive technology. The pinned rows are copies, pushed up as the next
67//! group arrives, hidden from assistive technology, and every reveal stops
68//! below them. The two builders say the rest.
69//!
70//! ## Accessibility
71//!
72//! Root emits `Role::TreeGrid`; rows carry `set_level` + `set_expanded`.
73//! ArrowLeft / ArrowRight on the tree column collapse / expand. A full-width
74//! row holds one cell, spanning every column.
75//!
76//! ```ignore
77//! // Column delegates capture closures — use ignore.
78//! use teksilo_widgets::TreeTableView;
79//! use teksilo_data::TreeModel;
80//! # struct File { name: String }
81//! # let model: TreeModel<File> = TreeModel::new();
82//! let _view = TreeTableView::new(model).row_height(28.0);
83//! ```
84//!
85//! ## Scrolling to a row
86//!
87//! [`handle`](TreeTableView::handle) returns a [`TreeTableViewHandle`], a
88//! cheap `Clone` taken before the view is added and usable from any handler
89//! afterwards. Its [`scroll_to_row`](TreeTableViewHandle::scroll_to_row) and
90//! [`ensure_row_visible`](TreeTableViewHandle::ensure_row_visible) take a flat
91//! visible row index and are applied at the next layout pass, after row
92//! measurement and scroll anchoring, so they may follow an expansion in the
93//! same turn, or be called before mount:
94//!
95//! ```rust
96//! # use teksilo_data::TreeModel;
97//! # use teksilo_widgets::TreeTableView;
98//! # let model: TreeModel<String> = TreeModel::new();
99//! let view = TreeTableView::new(model);
100//! let handle = view.handle();
101//! // … add the columns, `ctx.add(view)`; then, from a handler, after
102//! // expanding the row's ancestors in the same turn:
103//! handle.ensure_row_visible(42);
104//! ```
105//!
106//! The builder-value methods of the same names compute the offset when they
107//! are called, against the last layout pass.
108//!
109//! ## Pan to scroll
110//!
111//! The view installs [`common::scrollable::ScrollableBehavior`](crate::common::scrollable::ScrollableBehavior),
112//! which gives it the shared wheel arithmetic, a finger's pan and the
113//! `PanClaim` that puts it on a pan's claimant chain. A pan scrolls it, the
114//! release coasts, and a pan it cannot absorb hands the **whole** event to the
115//! container outside — never a residual. A pan that starts on a row scrolls
116//! rather than activating it or collapsing a multi-selection onto it. Both axes
117//! are claimed. Shift+wheel still
118//! scrolls the columns, and a finger's pan is never remapped by a held Shift:
119//! the remap is a wheel convention, and turning a drag sideways is not what
120//! the hand asked for.
121
122mod body_pane;
123mod full_width;
124mod pinned;
125mod widget_impl;
126
127use std::cell::{Cell, RefCell};
128use std::collections::HashMap;
129use std::rc::Rc;
130use std::time::Duration;
131
132use teksilo_canvas::{Canvas, Point, Rect, Size, SizeProposal};
133
134use teksilo_core::accessibility::{AccessNodeBuilder, widget_id_to_node_id};
135use teksilo_core::binding::BindingLevel;
136use teksilo_core::build_context::BuildContext;
137use teksilo_core::drag_payload::DragPayload;
138use teksilo_core::event::EventResponse;
139use teksilo_core::kinetic::KineticScroller;
140use teksilo_core::pointer::touch_action::PanAxes;
141use teksilo_core::signal::{Prop, Signal};
142use teksilo_core::widget::{EventContext, LayoutContext, PaintContext, Widget, WidgetPlacement};
143use teksilo_core::widget_builder::HandlerSet;
144use teksilo_core::widget_id::WidgetId;
145use teksilo_data::{
146    DropPosition, KeyedSelectionModel, NodeId, SelectionModel, SortDirection, SortFilterTreeModel,
147    TreeFilterMode, TreeModel,
148};
149use teksilo_i18n::LocalizedString;
150use teksilo_tokens::{BorderRole, OverscrollStyle, SurfaceRole};
151
152use crate::styles::recipe_table_style as cp;
153
154use crate::common::row_metrics::{HeightSource, RowMetrics, SharedRowMetrics};
155use crate::common::row_reveal::{RevealAlign, RowReveal, SharedRowReveal};
156use crate::common::scroll::OverscrollBehavior;
157use crate::data_views::{DragTransferMode, RowDragData, RowSelection, ViewId, ViewKind};
158use crate::data_views::{DropViz, drop_into_tint};
159use crate::scroll_area::ScrollBarMode;
160use crate::scroll_bar::{ScrollBar, ScrollBarOrientation, ScrollBarVisual};
161use crate::table_view::ColumnReorderDragData;
162use crate::table_view::body::SharedColumnWidths;
163use crate::table_view::column::{
164    CellContext, Column, ColumnResizePolicy, EditTriggers, GridLines, PinnedSide, TabTraversal,
165};
166use crate::table_view::header::{ResizeStateHandle, SharedFrozenWidths, width_overrides};
167use crate::table_view::imperative;
168use crate::table_view::keyboard;
169use crate::table_view::layout;
170use crate::table_view::row_navigator::RowNavigator;
171use crate::table_view::selection::{CellSelectionModel, TableSelectionMode};
172use crate::table_view::{HeaderLink, TableHeader};
173use crate::tree_source::TreeSource;
174use teksilo_data::{DropResponse, TreeDataSource};
175
176const BUFFER_ROWS: usize = 5;
177const SCROLLBAR_THICKNESS: f32 = 12.0;
178
179/// Hierarchical row navigator. Adapts a [`TreeSource`]'s flat-list view to the
180/// [`RowNavigator`] interface used by the shared keyboard handler.
181///
182/// Index-keyed throughout, so it works over any [`TreeDataSource`] — a
183/// `SortFilterTreeModel` over a `TreeModel`, or an external store carrying its
184/// own `Key`.
185pub(crate) struct TreeNavigator<T: 'static> {
186    source: Rc<TreeSource<T>>,
187    /// Which rows are full-width ones, when the view has any.
188    full_width: Option<Rc<dyn Fn(usize) -> bool>>,
189    /// The reveal below the pinned ancestors, while the view pins any.
190    pinned: Option<pinned::PinnedReveal>,
191}
192
193impl<T: 'static> TreeNavigator<T> {
194    pub(crate) fn new(source: Rc<TreeSource<T>>) -> Self {
195        Self {
196            source,
197            full_width: None,
198            pinned: None,
199        }
200    }
201
202    pub(crate) fn with_full_width(mut self, at: Option<Rc<dyn Fn(usize) -> bool>>) -> Self {
203        self.full_width = at;
204        self
205    }
206
207    pub(crate) fn with_pinned(mut self, reveal: Option<pinned::PinnedReveal>) -> Self {
208        self.pinned = reveal;
209        self
210    }
211}
212
213impl<T: 'static> RowNavigator for TreeNavigator<T> {
214    fn row_count(&self) -> usize {
215        self.source.visible_count()
216    }
217
218    fn depth(&self, row: usize) -> Option<usize> {
219        self.source.meta(row).map(|m| m.depth)
220    }
221
222    fn has_children(&self, row: usize) -> bool {
223        self.source
224            .meta(row)
225            .map(|m| m.has_children)
226            .unwrap_or(false)
227    }
228
229    fn is_expanded(&self, row: usize) -> bool {
230        self.source
231            .meta(row)
232            .map(|m| m.is_expanded)
233            .unwrap_or(false)
234    }
235
236    fn toggle_expanded(&self, row: usize) {
237        self.source.toggle_at(row);
238    }
239
240    fn spans_all_columns(&self, row: usize) -> bool {
241        self.full_width.as_ref().is_some_and(|at| at(row))
242    }
243
244    fn reveal_scroll(
245        &self,
246        row: usize,
247        scroll: f32,
248        viewport: f32,
249        max_scroll: f32,
250    ) -> Option<f32> {
251        self.pinned
252            .as_ref()
253            .map(|pinned| pinned.scroll_for(row, scroll, viewport, max_scroll))
254    }
255}
256
257/// Hierarchical multi-column widget. See module documentation.
258pub struct TreeTableView<T: 'static> {
259    /// Erased row access — every read (counts, entries, expansion, DnD,
260    /// keyboard reorder) goes through here, so the widget works over any
261    /// [`TreeDataSource`] and never needs to know the source's `Key`.
262    source: Rc<TreeSource<T>>,
263    /// Present only on the [`from_projection`](Self::from_projection) /
264    /// [`new`](Self::new) paths. It backs the `NodeId`-typed public API
265    /// ([`expand`](Self::expand), [`projection`](Self::projection), …), which is
266    /// meaningless for an external source carrying its own key — those methods
267    /// no-op when this is `None`.
268    proxy: Option<SortFilterTreeModel<T>>,
269
270    columns: Vec<Column<T>>,
271    /// Column id hosting the twist + indent. `None` defaults to the
272    /// first column at build time.
273    tree_column_id: Option<String>,
274    indent_per_level: Option<f32>,
275    row_height: Option<f32>,
276    /// Height-mode selection (uniform / exact callback / auto-measure).
277    height_source: HeightSource,
278    /// Row geometry — shared with the keyboard handler and the body
279    /// pane.
280    row_metrics: SharedRowMetrics,
281    header_height: Option<f32>,
282    /// The active `TableStyle`'s row and header heights at the density
283    /// in force, read at build by `adopt_style_heights`. Until then they
284    /// hold IntUI's Compact values, the module constants.
285    style_row_height: f32,
286    style_header_height: f32,
287    show_header: bool,
288    selection_mode: TableSelectionMode,
289    /// Row selection — index-based `SelectionModel` or keyed
290    /// `KeyedSelectionModel<NodeId>`, unified behind the index-facing facade.
291    row_selection: Option<RowSelection>,
292    cell_selection: Option<CellSelectionModel>,
293    alternating_rows: bool,
294    grid_lines: GridLines,
295    /// See [`Self::stretch_last_column`].
296    stretch_last_column: bool,
297    a11y_label: Option<LocalizedString>,
298    show_internal_scrollbars: bool,
299    column_resize_policy: ColumnResizePolicy,
300    tab_traversal: TabTraversal,
301    edit_triggers: EditTriggers,
302    #[allow(clippy::type_complexity)]
303    on_cell_edit_request: Option<Rc<dyn Fn(usize, &str, &mut EventContext)>>,
304    on_cell_edit_dismissed: Option<Rc<dyn Fn(usize, &str, &mut EventContext)>>,
305    #[allow(clippy::type_complexity)]
306    on_row_activate: Option<Rc<dyn Fn(usize, &mut EventContext)>>,
307
308    /// Animate wheel scrolling instead of snapping to the new offset.
309    /// Enabled by default — mirrors `ScrollArea`. Without it, each wheel
310    /// notch jumps by `row_height` per delivered line, which reads as a
311    /// coarse multi-row jump rather than a smooth glide.
312    smooth_scrolling: bool,
313    /// Duration of the smooth scroll animation.
314    smooth_scroll_duration: Duration,
315
316    /// How the scroll bar is displayed (default `Permanent`). `Overlay`
317    /// and `Thin` float the bar over the content instead of reserving a
318    /// layout column for it, mirroring `ScrollArea::scroll_bar_style`.
319    scroll_bar_style: ScrollBarMode,
320
321    // Public reactive signals
322    scroll_y: Signal<f32>,
323    max_scroll_y: Signal<f32>,
324    /// Scroll-chaining behavior at the boundary (default `Chain`).
325    overscroll_behavior: OverscrollBehavior,
326    viewport_ratio_y: Signal<f32>,
327    /// Horizontal scroll offset of the Middle (unpinned) pane — mirrors
328    /// `TableView::scroll_x`. See `table_view::PaneBoundaries`.
329    scroll_x: Signal<f32>,
330    max_scroll_x: Signal<f32>,
331    viewport_ratio_x: Signal<f32>,
332    /// This surface's pan physics: the range a finger's pan is clamped to and
333    /// the offset it is currently holding. Owned by the view rather than by
334    /// the [`ScrollableBehavior`](crate::common::scrollable::ScrollableBehavior)
335    /// so it survives a rebuild, and so `place_children` — the only pass that
336    /// knows the viewport extent — can publish into it.
337    scroller: Rc<RefCell<KineticScroller>>,
338    sort_signal: Signal<Option<(String, SortDirection)>>,
339    column_widths_signal: Signal<HashMap<String, f32>>,
340    /// `Some` once `bind_column_widths` adopted the width map — see the
341    /// field of the same name on `TableView`.
342    column_widths_frozen: Option<SharedFrozenWidths>,
343    column_order_signal: Signal<Vec<String>>,
344    column_pinning_signal: Signal<HashMap<String, PinnedSide>>,
345    filters_signal: Signal<HashMap<String, String>>,
346    focused_cell: Signal<Option<(usize, usize)>>,
347    /// The realized `(row index -> row wrapper id)` map, filled by the body
348    /// pane each build. Lets this widget's `&self` methods resolve a row index
349    /// to a widget without reaching into the pane. Mirrors `ListView::row_map`.
350    row_map: Rc<RefCell<Vec<(usize, WidgetId)>>>,
351    editing_cell: Signal<Option<(usize, usize)>>,
352    /// Type-ahead ("type to jump") label extractor — opt-in via
353    /// [`type_ahead_label`](Self::type_ahead_label).
354    #[allow(clippy::type_complexity)]
355    type_ahead_label: Option<Rc<dyn Fn(&T) -> String>>,
356    /// Reset window for the type-ahead search term.
357    type_ahead_timeout: Duration,
358    /// Persistent type-ahead buffer (survives the per-keystroke rebuild).
359    type_ahead: Rc<crate::common::type_ahead::TypeAheadState>,
360    /// Widget shown in place of the rows when nothing is visible — an empty
361    /// tree, or a filter that matched nothing.
362    #[allow(clippy::type_complexity)]
363    empty_view: Option<Rc<dyn Fn() -> Box<dyn Widget>>>,
364    /// Set on the first `place_children`. Until then `viewport_height` still
365    /// holds its construction placeholder, so viewport-relative imperatives
366    /// (`ensure_row_visible`) would scroll against a size that was never real.
367    laid_out: Rc<Cell<bool>>,
368    /// Anchor for the row with an open cell editor, so the editor follows its
369    /// row instead of its index. See `reconcile_editing_row`.
370    editing_anchor: Rc<RefCell<Option<crate::data_views::RowAnchor>>>,
371
372    // Build state
373    header_row_id: Option<WidgetId>,
374    body_pane_id: Option<WidgetId>,
375    scrollbar_id: Option<WidgetId>,
376    /// Horizontal scroll bar along the bottom of the Middle pane only —
377    /// mirrors `TableView::h_scrollbar_id`.
378    h_scrollbar_id: Option<WidgetId>,
379    empty_id: Option<WidgetId>,
380    /// Pane-local rebuild trigger + buffered range, owned here so they
381    /// survive `TreeTableView` rebuilds (each rebuild constructs a fresh
382    /// `TreeBodyPane` struct that inherits these handles).
383    pane_version: Signal<u64>,
384    pane_built_start: Rc<Cell<usize>>,
385    pane_built_end: Rc<Cell<usize>>,
386    /// Bumped by the pane when a measure pass changes the content
387    /// total; bound at `Relayout` on this root so `max_scroll_y` / the
388    /// thumb ratio are recomputed with the corrected total next frame.
389    pane_total_refresh: Signal<u64>,
390    /// The reveal a [`TreeTableViewHandle`] asked for, applied by the body
391    /// pane after its measure pass. Created with the view so a handle taken
392    /// before mount reaches every pane the view builds.
393    reveal: SharedRowReveal,
394
395    /// Enable drag-to-reorder of rows (pointer drag + Alt+Arrow). The move
396    /// reparents/reorders nodes in the underlying `TreeModel`, cycle-guarded.
397    /// Suppressed while the view is sorted by one of its own columns (the
398    /// visible order then differs from the tree order, so a manual reorder
399    /// would be meaningless). A shared sort on a column it lacks leaves it on.
400    reorderable: bool,
401    /// Active row-drop insertion indicator `(body_local_y, width)`. Set by
402    /// `on_drag_hover`, cleared on leave / drop, read by `paint`.
403    drop_feedback: Signal<Option<DropViz>>,
404
405    /// Whether activation is a single or double click (default `DoubleClick`).
406    activate_on: crate::data_views::ActivateOn,
407
408    /// `true` while this view — its root or any descendant — holds keyboard
409    /// focus. Captured at build from [`BuildContext::begin_view_focus`], bound
410    /// `RepaintOnly`. Drives focus-aware selection: the band paints `Selected`
411    /// while focused, muted `SelectedInactive` once focus leaves the view.
412    view_focused: Signal<bool>,
413    /// Input-modality `:focus-visible`. Gates the cell focus ring to keyboard
414    /// navigation (never a mouse click). Bound `RepaintOnly`.
415    focus_visible: Signal<bool>,
416
417    // Layout state
418    column_widths: SharedColumnWidths,
419    display_indices: Rc<RefCell<Vec<usize>>>,
420    /// Counts of (leading-pinned, middle, trailing-pinned) columns —
421    /// mirrors `TableView::pane_boundaries`. Populated by `display_order()`.
422    pane_boundaries: Rc<RefCell<crate::table_view::PaneBoundaries>>,
423    /// `(row, display_pos) -> WidgetId` for every cell realized by the
424    /// body pane's latest `build()`. Mirrors `TableView::cell_map` (the
425    /// GridView `tile_map` pattern — shared between the root and its
426    /// sibling-of-scrollbar pane); `accessibility()` reads it to point
427    /// `active_descendant` at the keyboard-focused cell's own AT node.
428    cell_map: Rc<RefCell<Vec<((usize, usize), WidgetId)>>>,
429    viewport_height: Rc<Cell<f32>>,
430    /// Middle-pane viewport width, snapshotted by `place_children` —
431    /// mirrors `TableView::middle_viewport_width`.
432    middle_viewport_width: Rc<Cell<f32>>,
433    /// The row-area's absolute (window) rect (below the header), cached by
434    /// `place_children`. Threaded into the keyboard handler so it can chase the
435    /// focused row into any *enclosing* scroll area via
436    /// [`EventContext::ensure_visible`](teksilo_core::widget::EventContext::ensure_visible).
437    body_bounds: Rc<Cell<Rect>>,
438    resize_state: ResizeStateHandle,
439    /// Display slot of the column under an active resize drag, or `None`.
440    /// Mirrors `TableView::resize_target` — shared with every `HeaderCell`
441    /// so the *target* column carries the "resizing" chrome even when the
442    /// gesture is anchored on its neighbour's half of the grip.
443    resize_target: Signal<Option<usize>>,
444    /// Window x of the prospective divider during a
445    /// [`ColumnResizePolicy::OnRelease`] drag. Mirrors
446    /// `TableView::resize_preview_x`.
447    resize_preview_x: Signal<Option<f32>>,
448    /// Width of the header strip (= the column band) snapshotted by
449    /// `place_children`. Mirrors `TableView::header_strip_width` — the
450    /// column-reorder drop handler needs it to mirror the drop x under RTL.
451    header_strip_width: Rc<Cell<f32>>,
452    /// Stable id grouping the column-header reorder/resize drag (an
453    /// unrelated mechanism to the row DnD below — see `table_view::header`).
454    table_id: usize,
455
456    /// Stable, kind-tagged identity for this view's **row** drag-and-drop —
457    /// distinct from `table_id` above. Minted via
458    /// `ViewId::next(ViewKind::TreeTable)`.
459    model_id: ViewId,
460
461    /// Cross-widget export / foreign-receive machinery — the builders
462    /// (`.exportable`, `.export_external`, `.accept_foreign_rows`,
463    /// `.on_rows_received`, `.on_rows_transferred_out`), the drag-start
464    /// payload build, and the move-out completion, shared by all five data
465    /// views. `TreeTableView` builds its reader + stable-key removal thunk
466    /// inline at drag-start (see `TreeBodyPane::build`'s `on_drag`) rather
467    /// than from source capability closures, so the key it removes by is
468    /// resolved once at drag-start and stays correct even if a mid-drag
469    /// spring-load reflattens the rows under the pointer.
470    export: crate::data_views::RowExport<T>,
471    /// Raw escape hatch for a payload this view cannot interpret itself.
472    ///
473    /// A source-backed view ([`from_source`](Self::from_source)) expresses
474    /// foreign-accept through its source's capability closures, like
475    /// `ListView` / `TableView`. This hook is what a **projection**-backed
476    /// view ([`from_projection`](Self::from_projection) / [`new`](Self::new))
477    /// has instead, since a `SortFilterTreeModel` carries no such closures.
478    /// Fires for any payload NOT recognized as this view's own row drag,
479    /// dropped on a node —
480    /// `(payload, target node, drop position, ctx) -> accepted`. Tried after
481    /// [`on_rows_received`](Self::on_rows_received).
482    #[allow(clippy::type_complexity)]
483    on_foreign_drop:
484        Option<Rc<dyn Fn(&DragPayload, NodeId, DropPosition, &mut EventContext) -> bool>>,
485
486    /// Whole-view enabled state, statically or reactively. Forwarded to the
487    /// arena via `ctx.enabled_when(self_id, self.enabled.clone())` at build
488    /// time; a disabled view greys out and stops accepting focus /
489    /// selection / keyboard input (arena-gated).
490    enabled: Prop<bool>,
491
492    /// Which items are drawn as one band across every column — see
493    /// [`full_width_row`](Self::full_width_row).
494    #[allow(clippy::type_complexity)]
495    full_width_row: Option<Rc<dyn Fn(&T) -> bool>>,
496    /// What draws that band — see
497    /// [`full_width_row_delegate`](Self::full_width_row_delegate).
498    full_width_delegate: Option<full_width::BandDelegate<T>>,
499    /// How many levels of ancestors stay pinned under the header; `0` pins
500    /// none. See [`pinned_ancestors`](Self::pinned_ancestors).
501    pinned_ancestors: usize,
502    /// The rows the pinned stack copies, outermost first, written by
503    /// `place_children` and bound `Rebuild` on the stack. Owned here so it
504    /// survives the root's rebuilds, as `ListView::pinned_section` does.
505    pinned_rows: Signal<Vec<usize>>,
506    /// The rows the stack last built. See `pinned::PinnedStack::built`.
507    pinned_built: Rc<RefCell<Vec<usize>>>,
508    /// Where each line of the stack goes this frame, from `place_children`.
509    pinned_layout: Rc<RefCell<Vec<pinned::StackLine>>>,
510    pinned_stack_id: Option<WidgetId>,
511}
512
513impl<T: 'static> TreeTableView<T> {
514    /// Wrap a `SortFilterTreeModel<T>`.
515    pub fn from_projection(proxy: SortFilterTreeModel<T>) -> Self {
516        let source = Rc::new(TreeSource::from_data_source(Rc::new(proxy.clone())));
517        Self::assemble(source, Some(proxy))
518    }
519
520    /// Build a tree table over any [`TreeDataSource`] — an external source of
521    /// truth (an entity store, a database, a virtual filesystem) carrying
522    /// its own `Key`, so it needs no `TreeModel` mirror.
523    ///
524    /// This is the tree-table sibling of
525    /// [`TreeView::from_source`](crate::TreeView::from_source). Because the
526    /// source owns identity, its expand state (and a keyed selection) survive a
527    /// full re-source — which a `TreeModel` mirror cannot guarantee, since
528    /// `NodeId`s are reassigned on rebuild.
529    ///
530    /// The `NodeId`-typed methods ([`expand`](Self::expand),
531    /// [`projection`](Self::projection), [`keyed_selection`](Self::keyed_selection))
532    /// do not apply here and no-op; drive expansion through the source itself.
533    ///
534    /// Row drag-reorder **is** wired on this path: a drop routes through the source's
535    /// own `drag` / `can_accept` / `accept_drop`, exactly as
536    /// [`TreeView`](crate::TreeView) does — so the
537    /// source owns both the cycle guard and the commit. Note that
538    /// [`TreeDataSlice::drag`](teksilo_data::TreeDataSlice) defaults to `NoDrag`: an
539    /// external source must opt its rows in before anything can be dragged.
540    pub fn from_source<S: TreeDataSource<Item = T> + 'static>(source: S) -> Self {
541        Self::assemble(Rc::new(TreeSource::from_data_source(Rc::new(source))), None)
542    }
543
544    /// Like [`from_source`](Self::from_source) but with **keyed** selection:
545    /// the `KeyedSelectionModel<S::Key>` tracks rows by source identity, so it
546    /// survives expand / collapse, sort / filter and a full re-source. Pruning
547    /// consults the source's `contains_key`, so a collapsed-but-present row
548    /// keeps its selection. The view stays `TreeTableView<T>` — the `Key` is
549    /// captured here.
550    pub fn from_source_keyed<S: TreeDataSource<Item = T> + 'static>(
551        source: S,
552        keyed: KeyedSelectionModel<S::Key>,
553    ) -> Self
554    where
555        S::Key: teksilo_data::ItemKey,
556    {
557        let s = Rc::new(source);
558        let key_at = {
559            let s = s.clone();
560            Rc::new(move |i| s.key_at(i)) as Rc<dyn Fn(usize) -> Option<S::Key>>
561        };
562        let len = {
563            let s = s.clone();
564            Rc::new(move || s.visible_count()) as Rc<dyn Fn() -> usize>
565        };
566        let contains = {
567            let s = s.clone();
568            Rc::new(move |k: &S::Key| s.contains_key(k)) as Rc<dyn Fn(&S::Key) -> bool>
569        };
570        let mut view = Self::assemble(Rc::new(TreeSource::from_data_source(s)), None);
571        view.row_selection = Some(RowSelection::from_keyed(keyed, key_at, len, contains));
572        view
573    }
574
575    fn assemble(source: Rc<TreeSource<T>>, proxy: Option<SortFilterTreeModel<T>>) -> Self {
576        let table_id = crate::table_view::next_header_id();
577        Self {
578            source,
579            proxy,
580            columns: Vec::new(),
581            tree_column_id: None,
582            indent_per_level: None,
583            row_height: None,
584            height_source: HeightSource::Uniform,
585            row_metrics: Rc::new(RefCell::new(RowMetrics::uniform(cp::ROW_HEIGHT, 0.0))),
586            header_height: None,
587            style_row_height: cp::ROW_HEIGHT,
588            style_header_height: cp::HEADER_HEIGHT,
589            show_header: true,
590            selection_mode: TableSelectionMode::default(),
591            row_selection: None,
592            cell_selection: None,
593            alternating_rows: false,
594            grid_lines: GridLines::None,
595            stretch_last_column: false,
596            a11y_label: None,
597            show_internal_scrollbars: true,
598            column_resize_policy: ColumnResizePolicy::default(),
599            tab_traversal: TabTraversal::default(),
600            edit_triggers: EditTriggers::default(),
601            on_cell_edit_request: None,
602            on_cell_edit_dismissed: None,
603            on_row_activate: None,
604            reorderable: false,
605            drop_feedback: Signal::new(None),
606            activate_on: crate::data_views::ActivateOn::default(),
607            smooth_scrolling: true,
608            smooth_scroll_duration: Duration::from_millis(150),
609            scroll_bar_style: ScrollBarMode::Permanent,
610            scroll_y: Signal::new_animated(0.0),
611            max_scroll_y: Signal::new(0.0),
612            overscroll_behavior: OverscrollBehavior::default(),
613            viewport_ratio_y: Signal::new(1.0),
614            scroll_x: Signal::new_animated(0.0),
615            max_scroll_x: Signal::new(0.0),
616            viewport_ratio_x: Signal::new(1.0),
617            scroller: Rc::new(RefCell::new(KineticScroller::new(OverscrollStyle::Clamp))),
618            sort_signal: Signal::new(None),
619            column_widths_signal: Signal::new(HashMap::new()),
620            column_widths_frozen: None,
621            column_order_signal: Signal::new(Vec::new()),
622            column_pinning_signal: Signal::new(HashMap::new()),
623            filters_signal: Signal::new(HashMap::new()),
624            focused_cell: Signal::new(None),
625            row_map: Rc::new(RefCell::new(Vec::new())),
626            type_ahead_label: None,
627            type_ahead_timeout: crate::common::type_ahead::DEFAULT_TYPE_AHEAD_TIMEOUT,
628            type_ahead: crate::common::type_ahead::TypeAheadState::new(),
629            // Replaced at build with the live tree signals.
630            view_focused: Signal::new(true),
631            focus_visible: Signal::new(false),
632            editing_cell: Signal::new(None),
633            empty_view: None,
634            laid_out: Rc::new(Cell::new(false)),
635            editing_anchor: Rc::new(RefCell::new(None)),
636            header_row_id: None,
637            body_pane_id: None,
638            scrollbar_id: None,
639            h_scrollbar_id: None,
640            empty_id: None,
641            pane_version: Signal::new(0_u64),
642            pane_built_start: Rc::new(Cell::new(0)),
643            pane_built_end: Rc::new(Cell::new(0)),
644            pane_total_refresh: Signal::new(0_u64),
645            reveal: RowReveal::new(),
646            column_widths: Rc::new(RefCell::new(Vec::new())),
647            display_indices: Rc::new(RefCell::new(Vec::new())),
648            pane_boundaries: Rc::new(RefCell::new(crate::table_view::PaneBoundaries::default())),
649            cell_map: Rc::new(RefCell::new(Vec::new())),
650            viewport_height: Rc::new(Cell::new(600.0)),
651            middle_viewport_width: Rc::new(Cell::new(600.0)),
652            body_bounds: Rc::new(Cell::new(Rect::ZERO)),
653            resize_state: Rc::new(RefCell::new(None)),
654            resize_target: Signal::new(None),
655            resize_preview_x: Signal::new(None),
656            header_strip_width: Rc::new(Cell::new(0.0)),
657            table_id,
658            model_id: ViewId::next(ViewKind::TreeTable),
659            export: crate::data_views::RowExport::default(),
660            on_foreign_drop: None,
661            enabled: Prop::Static(true),
662            full_width_row: None,
663            full_width_delegate: None,
664            pinned_ancestors: 0,
665            pinned_rows: Signal::new(Vec::new()),
666            pinned_built: Rc::new(RefCell::new(Vec::new())),
667            pinned_layout: Rc::new(RefCell::new(Vec::new())),
668            pinned_stack_id: None,
669        }
670    }
671
672    /// Wrap a raw `TreeModel<T>` — convenience for callers that don't
673    /// need sort/filter. Internally builds an identity
674    /// `SortFilterTreeModel`.
675    pub fn new(model: TreeModel<T>) -> Self {
676        Self::from_projection(SortFilterTreeModel::new(model))
677    }
678
679    // ── Builder ────────────────────────────────────────────────────────
680
681    /// Enable or disable the whole view. A disabled view greys out and stops
682    /// accepting focus / selection / keyboard input (arena-gated).
683    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
684        self.enabled = enabled.into();
685        self
686    }
687
688    /// Set the scroll-chaining behavior at the boundary (default
689    /// [`OverscrollBehavior::Chain`]; [`Contain`](OverscrollBehavior::Contain)
690    /// disables chaining to an ancestor scrollable).
691    pub fn overscroll_behavior(mut self, behavior: OverscrollBehavior) -> Self {
692        self.overscroll_behavior = behavior;
693        self
694    }
695
696    /// Enable or disable animated wheel scrolling (enabled by default).
697    /// When disabled, wheel events snap immediately to the new offset.
698    pub fn smooth_scrolling(mut self, enabled: bool) -> Self {
699        self.smooth_scrolling = enabled;
700        self
701    }
702
703    /// Enable **type-ahead** ("type to jump"): typing a printable character
704    /// while the tree-table has keyboard focus jumps the focused row to the
705    /// next *visible* row whose label starts with the accumulated search term,
706    /// wrapping around (Qt `keyboardSearch` / macOS & Windows type-select).
707    /// `label(&item)` yields the searchable text; matching is
708    /// ASCII-case-insensitive. A pause longer than the
709    /// [`type_ahead_timeout`](Self::type_ahead_timeout) starts a fresh term.
710    pub fn type_ahead_label(mut self, label: impl Fn(&T) -> String + 'static) -> Self {
711        self.type_ahead_label = Some(Rc::new(label));
712        self
713    }
714
715    /// Reset window between keystrokes before the type-ahead search term
716    /// clears (default 500 ms). A zero duration disables type-ahead.
717    pub fn type_ahead_timeout(mut self, timeout: Duration) -> Self {
718        self.type_ahead_timeout = timeout;
719        self
720    }
721
722    /// Duration of the smooth scroll animation (default 150 ms).
723    pub fn smooth_scroll_duration(mut self, duration: Duration) -> Self {
724        self.smooth_scroll_duration = duration;
725        self
726    }
727
728    /// How the scroll bar is displayed (default `Permanent`). `Overlay`
729    /// and `Thin` float the bar over the content instead of reserving a
730    /// layout column for it, mirroring `ScrollArea::scroll_bar_style`.
731    pub fn scroll_bar_style(mut self, style: ScrollBarMode) -> Self {
732        self.scroll_bar_style = style;
733        self
734    }
735
736    /// Append a column definition. Columns are displayed in declaration order unless
737    /// reordered by the user.
738    pub fn add_column(mut self, col: Column<T>) -> Self {
739        self.columns.push(col);
740        self
741    }
742
743    /// Enable drag-to-reorder of **rows** (pointer drag + keyboard
744    /// Alt+ArrowUp/Down). Distinct from
745    /// [`Column::reorderable`](crate::Column::reorderable), which reorders
746    /// *columns* and defaults to `true`; this defaults to `false`.
747    ///
748    /// A drop reparents/reorders the dragged node in the underlying
749    /// `TreeModel` (top third of a row = Before, middle = Into / make-child,
750    /// bottom = After). The move is cycle-guarded — dropping a node onto
751    /// itself or into its own subtree is refused (no insertion line). Reorder
752    /// is **suppressed while the view is sorted** by one of its own columns:
753    /// with the visible order driven by the sort, a manual reorder would have
754    /// no visible effect. A sort a shared [`bind_sort`](Self::bind_sort)
755    /// holds for a column this view lacks does not count.
756    pub fn reorderable(mut self, enabled: bool) -> Self {
757        self.reorderable = enabled;
758        self
759    }
760
761    /// Make rows **droppable outside this view** — on a
762    /// [`DropTarget`](crate::DropTarget), another data view, or the OS.
763    ///
764    /// A dragged row (or the whole selection, when the pressed row is part of a
765    /// multi-selection) carries clones of its items in a public
766    /// [`RowDragData<T>`](crate::RowDragData), so a foreign receiver can pull
767    /// them out with `payload.get_typed::<RowDragData<T>>()` /
768    /// `DropTarget::on_drop_typed::<RowDragData<T>>()` — no serialization. This
769    /// also makes rows a drag source even without [`reorderable`](Self::reorderable).
770    ///
771    /// `mode` chooses what happens to the origin rows once a *foreign* target
772    /// accepts them: [`DragTransferMode::Move`] removes them — by default,
773    /// directly from the underlying `TreeModel` (any dragged node that is a
774    /// descendant of another dragged node is skipped, since removing the
775    /// ancestor already removes it); override via
776    /// [`on_rows_transferred_out`](Self::on_rows_transferred_out).
777    /// [`DragTransferMode::Copy`] leaves them. A same-view reorder is never a
778    /// transfer, so `mode` never affects it. Requires `T: Clone`.
779    pub fn exportable(mut self, mode: DragTransferMode) -> Self
780    where
781        T: Clone,
782    {
783        self.export.set_exportable(mode);
784        self
785    }
786
787    /// Additionally advertise the dragged rows as MIME data so they can be
788    /// dropped on a [`DropZone`](crate::DropZone) or exported to another
789    /// application / window via the OS. `f` maps the dragged items to
790    /// `(mime_type, bytes)` pairs (e.g. `text/plain`, `text/uri-list`, an
791    /// app-specific `application/x-…`). Implies [`exportable`](Self::exportable)
792    /// (defaulting to [`DragTransferMode::Move`] if not already set). Requires
793    /// `T: Clone`.
794    pub fn export_external(mut self, f: impl Fn(&[T]) -> Vec<(String, Vec<u8>)> + 'static) -> Self
795    where
796        T: Clone,
797    {
798        self.export.set_export_external(f);
799        self
800    }
801
802    /// Override how rows moved out to a foreign target are removed from this
803    /// view. Receives the dragged rows' flat visible indices (as captured at
804    /// drag-start) and the live context. Without this, an
805    /// [`exportable`](Self::exportable) [`Move`](DragTransferMode::Move) drag
806    /// removes the dragged nodes directly from the underlying `TreeModel`
807    /// (leaf-first / descending — a dragged node that is a descendant of
808    /// another dragged node is skipped, since removing the ancestor already
809    /// removes its whole subtree).
810    pub fn on_rows_transferred_out(
811        mut self,
812        f: impl Fn(&[usize], &mut EventContext) + 'static,
813    ) -> Self {
814        self.export.set_on_rows_transferred_out(f);
815        self
816    }
817
818    /// Accept exported rows dropped from a **different** view or source
819    /// without writing a custom source. Pair with
820    /// [`on_rows_received`](Self::on_rows_received), which is handed the
821    /// dropped items and the target flat row index. (Same-view reorder is
822    /// [`reorderable`](Self::reorderable).)
823    pub fn accept_foreign_rows(mut self, accept: bool) -> Self {
824        self.export.accept_foreign_rows = accept;
825        self
826    }
827
828    /// Handler for rows accepted via
829    /// [`accept_foreign_rows`](Self::accept_foreign_rows): `(items, target
830    /// flat row index, ctx)`. Insert them into your tree at/near the index.
831    pub fn on_rows_received(
832        mut self,
833        f: impl Fn(Vec<T>, usize, &mut EventContext) + 'static,
834    ) -> Self {
835        self.export.set_on_rows_received(f);
836        self
837    }
838
839    /// Raw escape hatch for a foreign drop.
840    ///
841    /// **Projection path only.** This hook is `NodeId`-typed and predates
842    /// [`from_source`](Self::from_source); over an external source there is no
843    /// `NodeId` to hand it, so it never fires. Prefer
844    /// [`accept_foreign_rows`](Self::accept_foreign_rows) +
845    /// [`on_rows_received`](Self::on_rows_received), which are source-agnostic.
846    /// A source-backed view expresses foreign-accept through its source's own
847    /// capability closures (`can_accept` / `accept_drop`), like `ListView` /
848    /// `TableView`; this hook is what a **projection**-backed view has instead,
849    /// since a `SortFilterTreeModel` carries no such closures.
850    /// This fires for **any** payload NOT recognized as this view's own row
851    /// drag — a different view's [`RowDragData<T>`](crate::RowDragData), or a
852    /// completely different payload type — dropped on a node: `(payload,
853    /// target node, drop position, ctx) -> accepted`. Tried after
854    /// [`on_rows_received`](Self::on_rows_received), so the typed sugar wins
855    /// when both are set and the payload happens to carry an exportable
856    /// `RowDragData<T>`.
857    pub fn on_foreign_drop(
858        mut self,
859        f: impl Fn(&DragPayload, NodeId, DropPosition, &mut EventContext) -> bool + 'static,
860    ) -> Self {
861        self.on_foreign_drop = Some(Rc::new(f));
862        self
863    }
864
865    /// Choose single- vs double-click activation for `on_row_activate` (default
866    /// [`ActivateOn::DoubleClick`](crate::ActivateOn)). Enter/Space activates in
867    /// either mode.
868    pub fn activate_on(mut self, mode: crate::data_views::ActivateOn) -> Self {
869        self.activate_on = mode;
870        self
871    }
872
873    /// Append multiple columns from an iterator.
874    pub fn columns(mut self, cols: impl IntoIterator<Item = Column<T>>) -> Self {
875        self.columns.extend(cols);
876        self
877    }
878
879    /// Designate which column hosts the twist + indent. Default: the
880    /// first column.
881    pub fn tree_column(mut self, col_id: impl Into<String>) -> Self {
882        self.tree_column_id = Some(col_id.into());
883        self
884    }
885
886    /// Override the per-depth indent in the tree column in logical pixels (default
887    /// comes from the active `TableStyle`).
888    pub fn indent_per_level(mut self, px: f32) -> Self {
889        self.indent_per_level = Some(px);
890        self
891    }
892
893    /// Draw the rows whose item satisfies `is_full_width` as **one cell across
894    /// every column** instead of a cell per column: a group row ("an artist ·
895    /// 12 songs | 3 albums | 52:10") over rows that fill the columns. What the
896    /// band shows comes from
897    /// [`full_width_row_delegate`](Self::full_width_row_delegate); without one
898    /// it shows the tree column's own cell, laid across the row.
899    ///
900    /// Asked of the item, like the cell delegates; a row whose item is still
901    /// loading is never full-width. The predicate is read on every build and
902    /// on every key press, so keep it to a field test.
903    ///
904    /// The row stays a row of the tree in every other respect: it selects,
905    /// activates, drags, takes drops and answers type-ahead as any row does,
906    /// its chevron (indent and twist) sits in the band, and ←/→ act on it as
907    /// on the tree column from any column: they collapse and expand it, and ←
908    /// on a collapsed band moves up to its parent. In cell navigation the band
909    /// is one cell: Home and End stay on it, Tab passes it as one stop, and no
910    /// column of it opens an editor. The cursor keeps the column it arrived
911    /// with, so stepping on to an ordinary row lands back in that column.
912    ///
913    /// The band is as wide as the columns together, or the row when they are
914    /// wider, so beside columns narrower than the view it ends where a row's
915    /// selection band, alternating tint and focus ring do. It stays in the
916    /// viewport while the columns
917    /// scroll sideways, so a group row stays readable; its chevron sits where
918    /// the tree column's does with the columns unscrolled. Its height comes
919    /// from the view's height mode like any row's: the uniform height, the
920    /// [`row_height_fn`](Self::row_height_fn) callback, or, under
921    /// [`auto_row_height`](Self::auto_row_height), the band measured at its
922    /// width.
923    ///
924    /// To assistive technology the row is unchanged (`Role::Row` with its
925    /// level, expanded state and position) and holds one `Role::Cell`
926    /// (`Role::GridCell` in the cell modes) at column 1, with a column span of
927    /// every column and the band as its content.
928    pub fn full_width_row(mut self, is_full_width: impl Fn(&T) -> bool + 'static) -> Self {
929        self.full_width_row = Some(Rc::new(is_full_width));
930        self
931    }
932
933    /// Build the band of a [`full_width_row`](Self::full_width_row) from its
934    /// item, as a [`Column`]'s delegate builds a cell. The context describes
935    /// the band as the tree column, whose chevron it carries: `col_id` and
936    /// `col_index` are the tree column's and `is_tree_column` is `true`;
937    /// `is_focused` holds while the cursor is on the row, in any column, and
938    /// `is_selected` while the row is selected (in a cell mode, while any of
939    /// its cells is). The indent and the chevron are drawn before the
940    /// delegate's widget, as in the tree column.
941    ///
942    /// Does nothing without [`full_width_row`](Self::full_width_row), which
943    /// says which rows get it.
944    pub fn full_width_row_delegate(
945        mut self,
946        delegate: impl Fn(&T, &CellContext) -> Box<dyn Widget> + 'static,
947    ) -> Self {
948        self.full_width_delegate = Some(Rc::new(delegate));
949        self
950    }
951
952    /// Keep the ancestor rows of the first visible row pinned under the
953    /// header while the view scrolls, outermost first and at most `depth`
954    /// levels of them: the "sticky scroll" of code editors, the artist and
955    /// then the album over the songs. `0` (the default) pins nothing.
956    ///
957    /// A pinned row is pushed up by the next row at its level as that row
958    /// arrives, as a pinned section header is, so it never covers a row it is
959    /// not an ancestor of. It is a copy, drawn with the real row's cells (its
960    /// band, for a [`full_width_row`](Self::full_width_row)) at the real row's
961    /// height, on the header's surface. The copy shows the row's content, not
962    /// its selection or its focus, and its chevron is a picture. Under
963    /// [`auto_row_height`](Self::auto_row_height) the copy measures the row,
964    /// as the row does once laid out: an ancestor the view scrolled past
965    /// without laying it out takes its height a frame after it is pinned, and
966    /// a reveal made before then counts it at the estimate.
967    ///
968    /// A press on a pinned row does what a plain click on the real row would:
969    /// it ends an open cell edit, selects the row and puts the cursor on it,
970    /// activates it under [`ActivateOn::SingleClick`](crate::data_views::ActivateOn::SingleClick),
971    /// and scrolls it back into view, right under its own pinned ancestors. A
972    /// double click on a copy does not activate it, and a press never reaches
973    /// a control drawn inside the copy. A drop on a pinned row lands on the
974    /// row it shows, and its indicator is drawn over the copy.
975    ///
976    /// Every reveal the view makes (the keyboard moving the cursor,
977    /// [`ensure_row_visible`](Self::ensure_row_visible),
978    /// [`scroll_to_row`](Self::scroll_to_row), taking focus, the
979    /// ScrollIntoView action) stops below the ancestors that would be pinned
980    /// over the row, so a copy never covers the row the cursor is on.
981    ///
982    /// The copies are hidden from assistive technology and are not focusable;
983    /// the real rows stay where they are in the tree, scrolled under the
984    /// copies like any row off the top. The stack is re-derived on every
985    /// layout, so an expand, a collapse, an insert or a re-source above or
986    /// inside it shows on the next frame.
987    pub fn pinned_ancestors(mut self, depth: usize) -> Self {
988        self.pinned_ancestors = depth;
989        self
990    }
991
992    /// Re-materialize `self.row_metrics` after a height-mode /
993    /// row-height builder call.
994    fn remake_metrics(&self) {
995        *self.row_metrics.borrow_mut() = self
996            .height_source
997            .make_metrics(self.effective_row_height(), 0.0);
998    }
999
1000    /// Read the active `TableStyle`'s row and header heights at the density
1001    /// in force. Called first thing in `build`, so every height the build and
1002    /// the later layout passes read is the style's, unless the app set its
1003    /// own through [`row_height`](Self::row_height) /
1004    /// [`header_height`](Self::header_height), which win. A uniform-height
1005    /// view with no row height of its own re-materializes its metrics when
1006    /// the style's row height differs from the one they were made with.
1007    fn adopt_style_heights(&mut self, ctx: &BuildContext) {
1008        let style = crate::styles::recipe_table_style::resolve_table_style(ctx);
1009        let tokens = &ctx.theme().input;
1010        self.style_header_height = style.header_height(tokens);
1011        let row = style.row_height(tokens);
1012        if row != self.style_row_height {
1013            self.style_row_height = row;
1014            if self.row_height.is_none() && matches!(self.height_source, HeightSource::Uniform) {
1015                self.remake_metrics();
1016            }
1017        }
1018    }
1019
1020    /// Fixed row height, the uniform fast path. Default: the active
1021    /// `TableStyle`'s
1022    /// [`row_height`](teksilo_core::styles::TableStyle::row_height), 28 px
1023    /// under IntUI at the Compact density. Mutually exclusive with
1024    /// [`row_height_fn`](Self::row_height_fn) and
1025    /// [`auto_row_height`](Self::auto_row_height); the last mode setter
1026    /// wins.
1027    pub fn row_height(mut self, height: f32) -> Self {
1028        self.row_height = Some(height);
1029        self.height_source = HeightSource::Uniform;
1030        self.remake_metrics();
1031        self
1032    }
1033
1034    /// Per-row heights from a callback over the flat (visible) row
1035    /// index. The callback must be pure (same index + same data → same
1036    /// height); it is re-swept from the first changed flat index on
1037    /// every projection rebuild (expand/collapse/sort/filter/mutation).
1038    /// No measurement pass runs.
1039    pub fn row_height_fn(mut self, f: impl Fn(usize) -> f32 + 'static) -> Self {
1040        self.height_source = HeightSource::Exact(Rc::new(f));
1041        self.remake_metrics();
1042        self
1043    }
1044
1045    /// Auto-measured row heights: each realized row reports the height
1046    /// of its tallest cell measured at the cell's column width
1047    /// (height-for-width), unrealized rows assume `estimated`. Scroll
1048    /// anchoring keeps content above the viewport stationary; measured
1049    /// heights above a toggled row survive expand/collapse
1050    /// (divergence-driven invalidation). The scrollbar settles one
1051    /// frame after a measurement change.
1052    pub fn auto_row_height(mut self, estimated: f32) -> Self {
1053        self.height_source = HeightSource::Auto { estimated };
1054        self.remake_metrics();
1055        self
1056    }
1057
1058    /// Override the header row height in logical pixels. Default: the active
1059    /// `TableStyle`'s
1060    /// [`header_height`](teksilo_core::styles::TableStyle::header_height),
1061    /// 32 px under IntUI at the Compact density.
1062    pub fn header_height(mut self, height: f32) -> Self {
1063        self.header_height = Some(height);
1064        self
1065    }
1066
1067    /// Show or hide the column header row (default `true`).
1068    pub fn show_header(mut self, visible: bool) -> Self {
1069        self.show_header = visible;
1070        self
1071    }
1072
1073    /// Set the row/cell selection mode (default
1074    /// [`TableSelectionMode::MultiRow`]).
1075    pub fn selection_mode(mut self, mode: TableSelectionMode) -> Self {
1076        self.selection_mode = mode;
1077        self
1078    }
1079
1080    /// Set the index-based row selection model (visible positions). For
1081    /// identity-based selection that survives expand / collapse / sort /
1082    /// filter / structural edits, use [`keyed_selection`](Self::keyed_selection)
1083    /// instead.
1084    pub fn selection(mut self, sel: SelectionModel) -> Self {
1085        self.row_selection = Some(RowSelection::from_index(sel));
1086        self
1087    }
1088
1089    /// Set a keyed row selection model (by `NodeId`). Selection is tracked by
1090    /// node identity, so it survives expand / collapse, sort / filter, and node
1091    /// moves — and stays consistent if two views share the projection. Pruned
1092    /// of deleted nodes on each projection change. Mutually exclusive with
1093    /// [`selection`](Self::selection) (last one set wins).
1094    /// Only meaningful on the [`from_projection`](Self::from_projection) /
1095    /// [`new`](Self::new) paths, whose identity *is* `NodeId`; a no-op over an
1096    /// external source, which carries its own key — use
1097    /// [`from_source_keyed`](Self::from_source_keyed) there.
1098    pub fn keyed_selection(mut self, keyed: KeyedSelectionModel<NodeId>) -> Self {
1099        let Some(proxy) = self.proxy.clone() else {
1100            return self;
1101        };
1102        let key_at = {
1103            let p = proxy.clone();
1104            Rc::new(move |i| p.visible_node_id(i)) as Rc<dyn Fn(usize) -> Option<NodeId>>
1105        };
1106        let len = {
1107            let p = proxy.clone();
1108            Rc::new(move || p.visible_count()) as Rc<dyn Fn() -> usize>
1109        };
1110        // A collapsed-but-present node must NOT be pruned, so existence is
1111        // checked against the tree, not the (visible) projection window.
1112        let contains = {
1113            let p = proxy;
1114            Rc::new(move |n: &NodeId| p.tree().with_item(*n, |_| ()).is_some())
1115                as Rc<dyn Fn(&NodeId) -> bool>
1116        };
1117        self.row_selection = Some(RowSelection::from_keyed(keyed, key_at, len, contains));
1118        self
1119    }
1120
1121    /// Attach a cell-level selection model (row and column axes tracked
1122    /// independently).
1123    pub fn cell_selection(mut self, sel: CellSelectionModel) -> Self {
1124        self.cell_selection = Some(sel);
1125        self
1126    }
1127
1128    /// Paint odd-indexed rows with the `SurfaceRole::AlternatingRow` tint
1129    /// (default `false`).
1130    pub fn alternating_rows(mut self, enabled: bool) -> Self {
1131        self.alternating_rows = enabled;
1132        self
1133    }
1134
1135    /// Paint horizontal and/or vertical dividers between cells.
1136    pub fn grid_lines(mut self, kind: GridLines) -> Self {
1137        self.grid_lines = kind;
1138        self
1139    }
1140
1141    /// Let the **last column in display order** take up whatever width the
1142    /// other columns leave, so the table never ends in a bare strip at its
1143    /// trailing edge — Qt's `stretchLastSection`, NSTableView's
1144    /// `lastColumnOnlyAutoresizingStyle`. Default: off.
1145    ///
1146    /// Positional, not a property of a column: after a reorder it is the
1147    /// *new* last column that stretches and the previous one goes back to
1148    /// its own width. While a column stretches, its declared width is the
1149    /// floor it grows from, its user-resize override is ignored, and its
1150    /// trailing grip is disabled (no AccessKit Increment/Decrement either):
1151    /// any size the user gave it, the stretch would take straight back.
1152    /// Resizing any *other* column reflows it. Once the other columns
1153    /// exceed the viewport there is nothing left to stretch into — the
1154    /// last column sits at its own width and the pane scrolls, as in Qt.
1155    ///
1156    /// `Flex` columns already share every spare pixel among themselves, so
1157    /// a table of `Flex` columns looks the same either way: this is for
1158    /// pixel-sized tables (`Fixed` / `Auto`, or widths the user has set)
1159    /// that would otherwise end in a gap.
1160    pub fn stretch_last_column(mut self, on: bool) -> Self {
1161        self.stretch_last_column = on;
1162        self
1163    }
1164
1165    /// Accessible label for the whole tree table, announced by AT as the
1166    /// table's name.
1167    pub fn a11y_label(mut self, label: impl Into<LocalizedString>) -> Self {
1168        self.a11y_label = Some(label.into());
1169        self
1170    }
1171
1172    /// Show or hide the widget's internal vertical and horizontal scroll bars
1173    /// (default `true`). Set to `false` when the table lives inside an external
1174    /// `ScrollArea`.
1175    pub fn show_internal_scrollbars(mut self, show: bool) -> Self {
1176        self.show_internal_scrollbars = show;
1177        self
1178    }
1179
1180    /// Control how column widths are distributed when the table is resized
1181    /// (default `Proportional`).
1182    pub fn column_resize_policy(mut self, policy: ColumnResizePolicy) -> Self {
1183        self.column_resize_policy = policy;
1184        self
1185    }
1186
1187    /// Set the keyboard Tab traversal direction inside the table (default `CellsThenRows`).
1188    pub fn tab_traversal(mut self, mode: TabTraversal) -> Self {
1189        self.tab_traversal = mode;
1190        self
1191    }
1192
1193    /// Set which user gestures open an in-place cell editor — a set, composed
1194    /// with `|` (default `F2 | ANY_KEY | DOUBLE_CLICK`). See [`EditTriggers`].
1195    pub fn edit_triggers(mut self, trigger: EditTriggers) -> Self {
1196        self.edit_triggers = trigger;
1197        self
1198    }
1199
1200    /// Callback invoked when the user requests an in-place cell edit (e.g.
1201    /// double-click when `edit_triggers` contains `DOUBLE_CLICK`). Receives the flat row
1202    /// index, the column id, and a mutable `EventContext`.
1203    pub fn on_cell_edit_request(
1204        mut self,
1205        f: impl Fn(usize, &str, &mut EventContext) + 'static,
1206    ) -> Self {
1207        self.on_cell_edit_request = Some(Rc::new(f));
1208        self
1209    }
1210
1211    /// Callback invoked when an **open** cell editor should end because the
1212    /// pointer went somewhere else: a press that lands on any cell other than
1213    /// the one being edited. Receives the editing cell's flat row index and
1214    /// column id, so the owner can commit (or discard) whatever is in its
1215    /// buffer, then clear its own editing state.
1216    ///
1217    /// The counterpart of [`on_cell_edit_request`](Self::on_cell_edit_request),
1218    /// and the view cannot do it alone: the framework owns *which* cell is being
1219    /// edited, but only the owner knows what an ended edit means — commit,
1220    /// discard, or refuse a value that will not parse.
1221    ///
1222    /// **Why a press and not a focus change.** "The editor lost focus" is the
1223    /// obvious signal and it cannot be used: a body pane rebuilds constantly —
1224    /// selection, filtering, scroll, a reload from elsewhere — and every rebuild
1225    /// destroys and re-creates the open editor, so focus leaves it many times
1226    /// during an edit the writer never interrupted. A press on another cell is
1227    /// unambiguous and happens exactly once.
1228    pub fn on_cell_edit_dismissed(
1229        mut self,
1230        f: impl Fn(usize, &str, &mut EventContext) + 'static,
1231    ) -> Self {
1232        self.on_cell_edit_dismissed = Some(Rc::new(f));
1233        self
1234    }
1235
1236    /// Callback invoked when a row is activated (double-click or Enter, per
1237    /// `activate_on`). Receives the flat row index.
1238    pub fn on_row_activate(mut self, f: impl Fn(usize, &mut EventContext) + 'static) -> Self {
1239        self.on_row_activate = Some(Rc::new(f));
1240        self
1241    }
1242
1243    /// Forward `mode` to the underlying projection. The proxy holds its
1244    /// state behind `Rc<RefCell>`, so calling `.filter_mode()` on a
1245    /// clone mutates the shared inner — effectively persisting the
1246    /// choice on `self.proxy`.
1247    pub fn filter_mode(self, mode: TreeFilterMode) -> Self {
1248        if let Some(p) = &self.proxy {
1249            let _ = p.clone().filter_mode(mode);
1250        }
1251        self
1252    }
1253
1254    // ── Adopted column state ──────────────────────────────────────────
1255    //
1256    // The same contract as `TableView`'s `bind_*` builders: see
1257    // "Column state an application owns" in `table_view`'s module docs.
1258
1259    /// Use `sort` as this view's sort state instead of a signal of its own:
1260    /// header clicks and [`set_sort`](Self::set_sort) write it, a write from
1261    /// anywhere else updates the header, and
1262    /// [`sort_signal`](Self::sort_signal) returns it. Bind the same signal to
1263    /// the [`SortFilterTreeModel`] to re-sort the rows; one signal shared by
1264    /// both is what lets a projection keep its preset comparators while the
1265    /// header drives it.
1266    ///
1267    /// The view writes nothing into it until the user sorts; see
1268    /// [`TableView`](crate::TableView)'s module docs, "Column state an
1269    /// application owns".
1270    pub fn bind_sort(mut self, sort: Signal<Option<(String, SortDirection)>>) -> Self {
1271        self.sort_signal = sort;
1272        self
1273    }
1274
1275    /// Use `widths` as this view's map of column id → width instead of a
1276    /// signal of its own: a resize drag and
1277    /// [`set_column_width`](Self::set_column_width) write it, and a write from
1278    /// anywhere else resizes the columns. A column with no entry takes its
1279    /// declared width; an entry for a column the view lacks is ignored. A
1280    /// resize writes the resized column's entry alone; the `Flex` columns
1281    /// before it keep their widths in this view, not in the map.
1282    /// [`column_widths_signal`](Self::column_widths_signal) returns it.
1283    pub fn bind_column_widths(mut self, widths: Signal<HashMap<String, f32>>) -> Self {
1284        self.column_widths_signal = widths;
1285        self.column_widths_frozen = Some(Rc::default());
1286        self
1287    }
1288
1289    /// Use `order` as this view's column order instead of a signal of its
1290    /// own: a reorder drop and [`set_column_order`](Self::set_column_order)
1291    /// write it, and a write from anywhere else reorders the columns. Ids the
1292    /// view lacks are skipped when it lays out and kept in place when it
1293    /// writes. [`column_order_signal`](Self::column_order_signal) returns it.
1294    pub fn bind_column_order(mut self, order: Signal<Vec<String>>) -> Self {
1295        self.column_order_signal = order;
1296        self
1297    }
1298
1299    /// Use `filters` as this view's per-column filter text instead of a
1300    /// signal of its own: the filter popover and
1301    /// [`set_filter`](Self::set_filter) write it, and
1302    /// [`filters_signal`](Self::filters_signal) returns it. Bind the same
1303    /// signal to the [`SortFilterTreeModel`] to filter the rows.
1304    pub fn bind_filters(mut self, filters: Signal<HashMap<String, String>>) -> Self {
1305        self.filters_signal = filters;
1306        self
1307    }
1308
1309    // ── Reactive signals ──────────────────────────────────────────────
1310
1311    /// Current vertical scroll offset in logical pixels.
1312    pub fn scroll_y_signal(&self) -> &Signal<f32> {
1313        &self.scroll_y
1314    }
1315
1316    /// Maximum vertical scroll offset (content height − viewport height).
1317    pub fn max_scroll_y_signal(&self) -> &Signal<f32> {
1318        &self.max_scroll_y
1319    }
1320
1321    /// Viewport-to-content height ratio — drives the scrollbar thumb size.
1322    pub fn viewport_ratio_y_signal(&self) -> &Signal<f32> {
1323        &self.viewport_ratio_y
1324    }
1325
1326    /// Current horizontal scroll offset of the Middle (unpinned) pane, in
1327    /// logical pixels. Leading/Trailing-pinned columns are unaffected.
1328    pub fn scroll_x_signal(&self) -> &Signal<f32> {
1329        &self.scroll_x
1330    }
1331
1332    /// Maximum horizontal scroll offset — `middle_content_width −
1333    /// middle_viewport_width`.
1334    pub fn max_scroll_x_signal(&self) -> &Signal<f32> {
1335        &self.max_scroll_x
1336    }
1337
1338    /// Middle-pane viewport-to-content width ratio.
1339    pub fn viewport_ratio_x_signal(&self) -> &Signal<f32> {
1340        &self.viewport_ratio_x
1341    }
1342
1343    /// Active sort state: `Some((col_id, direction))` or `None` for unsorted.
1344    ///
1345    /// **This is the header's state, not the data's.** Clicking a sort header
1346    /// writes here; nothing reorders rows until you bind this onto the backing
1347    /// projection yourself:
1348    ///
1349    /// ```ignore
1350    /// let proxy = SortFilterTreeModel::new(tree)
1351    ///     .with_comparator("name", |a: &Row, b: &Row| a.name.cmp(&b.name));
1352    /// proxy.sort_signal(view.sort_signal().clone());
1353    /// ```
1354    ///
1355    /// The binding is deliberately not automatic: a projection may already
1356    /// carry preset comparators, predicates, and a filter mode, and adopting
1357    /// the view's empty signal at construction would clobber them. To share
1358    /// one signal between the two from the start, hand it to
1359    /// [`bind_sort`](Self::bind_sort); this returns the adopted signal then.
1360    pub fn sort_signal(&self) -> &Signal<Option<(String, SortDirection)>> {
1361        &self.sort_signal
1362    }
1363
1364    /// Active per-column filters keyed by column id.
1365    ///
1366    /// Like [`sort_signal`](Self::sort_signal), this holds the header's state
1367    /// only — bind it onto the projection to actually filter rows:
1368    ///
1369    /// ```ignore
1370    /// let proxy = SortFilterTreeModel::new(tree)
1371    ///     .with_predicate("name", |t| {
1372    ///         let needle = t.to_string();
1373    ///         Box::new(move |r: &Row| r.name.contains(&needle))
1374    ///     });
1375    /// proxy.filters_signal(view.filters_signal().clone());
1376    /// ```
1377    ///
1378    /// The signal adopted by [`bind_filters`](Self::bind_filters), if any.
1379    pub fn filters_signal(&self) -> &Signal<HashMap<String, String>> {
1380        &self.filters_signal
1381    }
1382
1383    /// User-resized column widths in logical pixels, keyed by column id. The
1384    /// view writes entries only for a resize: the resized column's, and —
1385    /// when the map is its own — one for each `Flex` column before it, frozen
1386    /// at the width it had. A missing key means the declared width. The
1387    /// signal adopted by [`bind_column_widths`](Self::bind_column_widths), if
1388    /// any.
1389    pub fn column_widths_signal(&self) -> &Signal<HashMap<String, f32>> {
1390        &self.column_widths_signal
1391    }
1392
1393    /// Current column display order as a list of column ids. The signal
1394    /// adopted by [`bind_column_order`](Self::bind_column_order), if any.
1395    pub fn column_order_signal(&self) -> &Signal<Vec<String>> {
1396        &self.column_order_signal
1397    }
1398
1399    /// Keyboard-focused cell as `(row, display_column_index)`, or `None`.
1400    ///
1401    /// In a tree table whose selection holds one row or cell, a selection
1402    /// change that leaves an existing cursor off the selection moves the
1403    /// cursor onto it.
1404    pub fn focused_cell_signal(&self) -> &Signal<Option<(usize, usize)>> {
1405        &self.focused_cell
1406    }
1407
1408    /// Cell currently being edited as `(row, display_column_index)`, or `None`.
1409    pub fn editing_cell_signal(&self) -> &Signal<Option<(usize, usize)>> {
1410        &self.editing_cell
1411    }
1412
1413    /// The widget realized for the cell at `(row, display column)` in the body
1414    /// pane's latest build, or `None` once it has scrolled (or collapsed) out
1415    /// of the realized buffer — `cell_map` is a snapshot, not an index of every
1416    /// row the source holds, so a miss here means "not on screen", never "no
1417    /// such cell".
1418    fn realized_cell(&self, row: usize, col: usize) -> Option<WidgetId> {
1419        self.cell_map
1420            .borrow()
1421            .iter()
1422            .find(|&&(pos, _)| pos == (row, col))
1423            .map(|&(_, id)| id)
1424    }
1425
1426    /// Access the underlying `SortFilterTreeModel` (for programmatic sort /
1427    /// filter / expand outside of the builder API).
1428    /// `None` when the view was built from an external
1429    /// [`teksilo_data::TreeDataSource`] via
1430    /// [`from_source`](Self::from_source) — there is no `TreeModel`-backed
1431    /// projection to hand back in that case.
1432    pub fn projection(&self) -> Option<&SortFilterTreeModel<T>> {
1433        self.proxy.as_ref()
1434    }
1435
1436    // ── Imperative API ─────────────────────────────────────────────────
1437
1438    /// Expand the subtree rooted at `node`.
1439    pub fn expand(&self, node: NodeId) {
1440        if let Some(p) = &self.proxy {
1441            p.expand(node);
1442        }
1443    }
1444
1445    /// Collapse the subtree rooted at `node`.
1446    pub fn collapse(&self, node: NodeId) {
1447        if let Some(p) = &self.proxy {
1448            p.collapse(node);
1449        }
1450    }
1451
1452    /// Toggle the expand/collapse state of `node`.
1453    pub fn toggle(&self, node: NodeId) {
1454        if let Some(p) = &self.proxy {
1455            p.toggle(node);
1456        }
1457    }
1458
1459    /// Expand all nodes in the tree.
1460    pub fn expand_all(&self) {
1461        if let Some(p) = &self.proxy {
1462            p.expand_all();
1463        }
1464    }
1465
1466    /// Collapse all nodes in the tree.
1467    pub fn collapse_all(&self) {
1468        if let Some(p) = &self.proxy {
1469            p.collapse_all();
1470        }
1471    }
1472
1473    /// Move keyboard focus to the cell at `(row, col)`.
1474    pub fn set_focused_cell(&self, row: usize, col: usize) {
1475        self.focused_cell.set(Some((row, col)));
1476    }
1477
1478    /// Clear the keyboard-focused cell.
1479    pub fn clear_focused_cell(&self) {
1480        self.focused_cell.set(None);
1481    }
1482
1483    /// Programmatically sort by `col_id` (pass `None` to clear the sort).
1484    ///
1485    /// Equality-guarded, like every persisted-layout setter here — see
1486    /// [`set_column_widths`](Self::set_column_widths).
1487    pub fn set_sort(&self, col_id: Option<&str>, dir: SortDirection) {
1488        imperative::set_if_changed(&self.sort_signal, col_id.map(|c| (c.to_string(), dir)));
1489    }
1490
1491    /// Set or clear the filter text for a single column.
1492    pub fn set_filter(&self, col_id: &str, text: &str) {
1493        imperative::set_filter(&self.filters_signal, col_id, text);
1494    }
1495
1496    pub fn clear_filters(&self) {
1497        imperative::set_if_changed(&self.filters_signal, HashMap::new());
1498    }
1499
1500    /// Widget shown when no rows are visible — an empty tree, or a filter
1501    /// that matched nothing. Without one, the body region is simply blank.
1502    pub fn empty_view(mut self, f: impl Fn() -> Box<dyn Widget> + 'static) -> Self {
1503        self.empty_view = Some(Rc::new(f));
1504        self
1505    }
1506
1507    /// Clear the active sort.
1508    pub fn clear_sort(&self) {
1509        imperative::set_if_changed(&self.sort_signal, None);
1510    }
1511
1512    /// Scroll so that `row` (a flat visible index) is aligned to the top of
1513    /// the viewport, now, against the row offsets and scroll range of the last
1514    /// layout pass. A no-op before the first layout pass.
1515    ///
1516    /// The offset is clamped when this is called, to the range that pass
1517    /// wrote, so after a structural change in the same turn (a node expanded
1518    /// above the row) it still describes the old rows. On a mounted view, or
1519    /// to scroll before the first layout, use a [`handle`](Self::handle),
1520    /// which defers the computation to the next layout pass.
1521    ///
1522    /// With [`pinned_ancestors`](Self::pinned_ancestors), the top it is
1523    /// aligned to is the bottom of its own pinned ancestors.
1524    pub fn scroll_to_row(&self, row: usize) {
1525        if !self.laid_out.get() {
1526            return;
1527        }
1528        match self.pinned_reveal() {
1529            Some(pinned) => {
1530                if let Some(target) = pinned.top_for(row) {
1531                    self.scroll_y
1532                        .set(target.clamp(0.0, self.max_scroll_y.get()));
1533                }
1534            }
1535            None => imperative::scroll_to_row(
1536                row,
1537                &self.row_metrics,
1538                &self.scroll_y,
1539                &self.max_scroll_y,
1540            ),
1541        }
1542    }
1543
1544    /// Scroll the minimum distance needed to make `row` (a flat visible
1545    /// index) visible, now, against the viewport, row offsets and scroll range
1546    /// of the last layout pass. A no-op before the first layout pass, when the
1547    /// viewport height is not yet known. A [`handle`](Self::handle) defers the
1548    /// computation to the next layout pass instead.
1549    ///
1550    /// With [`pinned_ancestors`](Self::pinned_ancestors), "visible" means
1551    /// below the ancestors that would be pinned over it.
1552    pub fn ensure_row_visible(&self, row: usize) {
1553        Self::reveal_row(
1554            row,
1555            self.pinned_reveal().as_ref(),
1556            &self.row_metrics,
1557            &self.scroll_y,
1558            &self.max_scroll_y,
1559            self.viewport_height.get(),
1560            self.laid_out.get(),
1561        );
1562    }
1563
1564    /// The one reveal every path shares: below the pinned ancestors when
1565    /// there are any, the plain row arithmetic otherwise.
1566    fn reveal_row(
1567        row: usize,
1568        pinned: Option<&pinned::PinnedReveal>,
1569        row_metrics: &SharedRowMetrics,
1570        scroll_y: &Signal<f32>,
1571        max_scroll_y: &Signal<f32>,
1572        viewport_height: f32,
1573        laid_out: bool,
1574    ) {
1575        let Some(pinned) = pinned else {
1576            imperative::ensure_row_visible(
1577                row,
1578                row_metrics,
1579                scroll_y,
1580                max_scroll_y,
1581                viewport_height,
1582                laid_out,
1583            );
1584            return;
1585        };
1586        if !laid_out {
1587            return;
1588        }
1589        let scroll = scroll_y.get();
1590        let new_scroll = pinned.scroll_for(row, scroll, viewport_height, max_scroll_y.get());
1591        if (new_scroll - scroll).abs() > f32::EPSILON {
1592            scroll_y.set(new_scroll);
1593        }
1594    }
1595
1596    /// Scroll the row the keyboard cursor sits on into view when this view
1597    /// takes focus.
1598    ///
1599    /// Only the rows near the viewport are realized, so on a tree taller than
1600    /// the window the cursor row frequently has no widget. Everything that
1601    /// speaks for it then has nothing to speak about: no cell node exists, so
1602    /// `accessibility()` below finds nothing in `cell_map` and nominates no
1603    /// `active_descendant`, and a screen reader taking focus here is told
1604    /// nothing at all. The first arrow press steps *past* that row as well,
1605    /// because the cursor was somewhere the user was never shown.
1606    ///
1607    /// The cursor is read exactly as the shared keyboard handler reads it
1608    /// (`table_view::keyboard::build_key_handler`, `keyboard.rs:134-139`): the
1609    /// focused cell's row, else the first selected row. Anything else would
1610    /// reveal a row the next arrow press does not step from.
1611    ///
1612    /// That row index is a **flat visible** index, not a position in the
1613    /// unflattened tree: a collapsed node's descendants have no index at all
1614    /// here. Checked on both sides of the read. `focused_cell` is clamped to
1615    /// `TreeNavigator::row_count()`, which returns `TreeSource::visible_count()`
1616    /// (`tree_table_view.rs:145-147`), and the keyed selection facade builds
1617    /// its indices by scanning `0..visible_count()` through
1618    /// `SortFilterTreeModel::visible_node_id` (`data_views.rs:545-552`). On the
1619    /// spending side, `RowMetrics` is sized by `place_children` from that same
1620    /// `visible_count()`, so `row_top(i)` is the top of the *i*-th visible row.
1621    ///
1622    /// `ensure_row_visible`, the view's own imperative path, rather than
1623    /// `scroll_to_row`: a row already on screen must not jump under somebody
1624    /// who can see it. It carries the `laid_out` guard too, so a focus that
1625    /// arrives before the first real height is a no-op instead of scrolling
1626    /// against a viewport that was never measured. It is the same
1627    /// `RowMetrics::scroll_for_ensure_visible` arithmetic the keyboard runs on
1628    /// every arrow press; what the keyboard's own wrapper
1629    /// (`table_view/keyboard.rs:672`) adds on top is chasing the row into an
1630    /// *enclosing* scroll area, and that needs an `EventContext`, which an
1631    /// effect does not have. Nothing is lost: the same keyboard or programmatic
1632    /// focus change makes the framework reveal the newly focused widget in
1633    /// every ancestor scroll area itself (`focus_impl.rs:109`,
1634    /// `WidgetTree::scroll_focused_into_view`), so the enclosing viewport is
1635    /// somebody else's job here.
1636    ///
1637    /// The handles are cloned into the effect rather than reaching through
1638    /// `self`, which the closure cannot borrow.
1639    fn reveal_current_row_on_focus(&self, ctx: &mut BuildContext) {
1640        let focused_cell = self.focused_cell.clone();
1641        let selection = self.row_selection.clone();
1642        let row_metrics = self.row_metrics.clone();
1643        let scroll_y = self.scroll_y.clone();
1644        let max_scroll_y = self.max_scroll_y.clone();
1645        let viewport_height = self.viewport_height.clone();
1646        let laid_out = self.laid_out.clone();
1647        let pinned = self.pinned_reveal();
1648
1649        ctx.effect(&self.view_focused, move |focused| {
1650            if !*focused {
1651                return;
1652            }
1653            let Some(row) = focused_cell.get().map(|(row, _col)| row).or_else(|| {
1654                selection
1655                    .as_ref()
1656                    .and_then(|s| s.selected_indices().first().copied())
1657            }) else {
1658                return;
1659            };
1660            Self::reveal_row(
1661                row,
1662                pinned.as_ref(),
1663                &row_metrics,
1664                &scroll_y,
1665                &max_scroll_y,
1666                viewport_height.get(),
1667                laid_out.get(),
1668            );
1669        });
1670    }
1671
1672    /// Set or remove a single column's user-resized width override.
1673    /// A non-positive `width` removes the entry (the column reverts to
1674    /// its declared width policy).
1675    pub fn set_column_width(&self, col_id: &str, width: f32) {
1676        imperative::set_column_width(&self.column_widths_signal, col_id, width);
1677    }
1678
1679    /// Replace the full width-override map (typically used to restore
1680    /// a persisted layout).
1681    ///
1682    /// Equality-guarded for the same reason as
1683    /// [`TableView::set_column_widths`](crate::TableView::set_column_widths):
1684    /// the documented settings round-trip would otherwise recurse without
1685    /// bound on the first tick of a live resize drag.
1686    pub fn set_column_widths(&self, widths: HashMap<String, f32>) {
1687        imperative::set_column_widths(&self.column_widths_signal, widths);
1688    }
1689
1690    /// Replace the column-order list. Ids not declared on this table
1691    /// are silently dropped on the next layout pass.
1692    pub fn set_column_order(&self, order: Vec<String>) {
1693        imperative::set_if_changed(&self.column_order_signal, order);
1694    }
1695
1696    /// Current column pinning overrides, keyed by column id. Wins over
1697    /// each column's declared [`Column::pinned`].
1698    pub fn column_pinning_signal(&self) -> &Signal<HashMap<String, PinnedSide>> {
1699        &self.column_pinning_signal
1700    }
1701
1702    /// Put a single column on `side`: [`PinnedSide::Leading`] or
1703    /// [`PinnedSide::Trailing`] pins it, [`PinnedSide::None`] unpins it, a
1704    /// column declared pinned ([`Column::pinned`]) included.
1705    ///
1706    /// Writes [`column_pinning_signal`](Self::column_pinning_signal) the way a
1707    /// header drag does: an entry for a column moved off its declared side,
1708    /// and none for a column put back on it.
1709    pub fn set_column_pinning(&self, col_id: &str, side: PinnedSide) {
1710        let declared = self
1711            .columns
1712            .iter()
1713            .find(|c| c.spec.id == col_id)
1714            .map_or(PinnedSide::None, |c| c.spec.pinned);
1715        imperative::set_column_pinning(&self.column_pinning_signal, col_id, side, declared);
1716    }
1717
1718    /// Begin editing the cell `(row, col_id)`. Silently no-ops if `col_id`
1719    /// isn't a currently-displayed column, or if `row` is outside the visible
1720    /// range — an out-of-range target would otherwise strand `editing_cell` on
1721    /// a row nothing can match.
1722    ///
1723    /// Callable **before the view is mounted**, which is the only point at
1724    /// which a consumer can seed a freshly constructed view with an edit
1725    /// target it already holds. `display_indices` is a cache `build()` fills,
1726    /// so a pre-mount call finds it empty; the order is recomputed on demand
1727    /// in that case rather than resolving against nothing and no-opping for a
1728    /// third, undocumented reason.
1729    ///
1730    /// A [`full_width_row`](Self::full_width_row) has no cell of any column
1731    /// to edit, so a row that is one is a no-op too.
1732    pub fn begin_edit(&self, row: usize, col_id: &str) {
1733        if self.full_width_rows().is_some_and(|fw| (fw.at)(row)) {
1734            return;
1735        }
1736        let cached = self.display_indices.borrow();
1737        let recomputed;
1738        let display: &[usize] = if cached.is_empty() {
1739            recomputed = self.display_order();
1740            &recomputed
1741        } else {
1742            &cached
1743        };
1744        if let Some(target) = imperative::resolve_edit_target(
1745            row,
1746            col_id,
1747            &self.columns,
1748            display,
1749            self.source.visible_count(),
1750        ) {
1751            drop(cached);
1752            self.editing_cell.set(Some(target));
1753        }
1754    }
1755
1756    /// Close the active cell editor without committing (the field's `on_blur` still fires).
1757    pub fn end_edit(&self) {
1758        self.editing_cell.set(None);
1759    }
1760
1761    // ── Internals ──────────────────────────────────────────────────────
1762
1763    fn effective_row_height(&self) -> f32 {
1764        self.row_height.unwrap_or(self.style_row_height)
1765    }
1766
1767    /// The full-width rows and the delegate that draws them, or `None` when
1768    /// the view has none. The delegate falls back to the tree column's cell.
1769    fn full_width_rows(&self) -> Option<full_width::FullWidthRows<T>> {
1770        let is_full_width = self.full_width_row.clone()?;
1771        let delegate = match &self.full_width_delegate {
1772            Some(delegate) => delegate.clone(),
1773            None => self
1774                .columns
1775                .get(self.tree_column_decl_index())?
1776                .cell
1777                .clone(),
1778        };
1779        let source = self.source.clone();
1780        Some(full_width::FullWidthRows {
1781            at: Rc::new(move |row| {
1782                let mut hit = false;
1783                (source.read_item_fn)(row, &mut |item| hit = is_full_width(item));
1784                hit
1785            }),
1786            delegate,
1787        })
1788    }
1789
1790    /// The reveal below the pinned ancestors, while the view pins any.
1791    fn pinned_reveal(&self) -> Option<pinned::PinnedReveal> {
1792        pinned::PinnedReveal::new(
1793            self.source.clone(),
1794            self.row_metrics.clone(),
1795            self.pinned_ancestors,
1796        )
1797    }
1798
1799    fn effective_header_height(&self) -> f32 {
1800        if self.show_header {
1801            self.header_height.unwrap_or(self.style_header_height)
1802        } else {
1803            0.0
1804        }
1805    }
1806
1807    fn effective_indent(&self) -> f32 {
1808        self.indent_per_level.unwrap_or(cp::TREE_INDENT_PER_LEVEL)
1809    }
1810
1811    /// Resolve the tree column id to a declaration index. Falls back
1812    /// to column 0 when the configured id isn't found or unset.
1813    fn tree_column_decl_index(&self) -> usize {
1814        if let Some(ref id) = self.tree_column_id {
1815            for (i, col) in self.columns.iter().enumerate() {
1816                if &col.spec.id == id {
1817                    return i;
1818                }
1819            }
1820        }
1821        0
1822    }
1823
1824    /// Display order as indices into `self.columns` — see
1825    /// [`layout::display_order`], shared with `TableView`.
1826    fn display_order(&self) -> Vec<usize> {
1827        let (out, boundaries) = layout::display_order(
1828            &self.columns,
1829            &self.column_order_signal.get(),
1830            &self.column_pinning_signal.get(),
1831        );
1832        // Stash the boundaries so paint / place_children / the keyboard
1833        // handler's ensure-column-visible can read them.
1834        *self.pane_boundaries.borrow_mut() = boundaries;
1835        out
1836    }
1837
1838    fn clamp_scroll(&self) {
1839        let max = self.max_scroll_y.get();
1840        let current = self.scroll_y.get();
1841        let clamped = current.clamp(0.0, max);
1842        if (clamped - current).abs() > 0.001 {
1843            self.scroll_y.set(clamped);
1844        }
1845    }
1846
1847    /// Buffered realized range — mirrors `TableView::visible_range`. Used
1848    /// only to nudge the lazy source (`request_window`/`fetch_more`); the
1849    /// pane recomputes its own copy independently for actual row
1850    /// realization.
1851    fn visible_range(&self) -> (usize, usize) {
1852        self.row_metrics.borrow_mut().visible_range(
1853            self.scroll_y.get(),
1854            self.viewport_height.get(),
1855            self.source.visible_count(),
1856            BUFFER_ROWS,
1857        )
1858    }
1859}
1860
1861impl<T: 'static> TreeTableView<T> {
1862    /// A handle that scrolls this view once it is mounted.
1863    ///
1864    /// Take it while describing the tree, before the view is handed to
1865    /// `ctx.add(...)`; it stays attached to the view across rebuilds. See
1866    /// [`TreeTableViewHandle`].
1867    pub fn handle(&self) -> TreeTableViewHandle {
1868        TreeTableViewHandle {
1869            reveal: self.reveal.clone(),
1870        }
1871    }
1872}
1873
1874/// A handle on a mounted [`TreeTableView`], to scroll it to a row from
1875/// outside the widget — a view-model revealing the node a search found, say.
1876///
1877/// Rows are addressed by their **flat visible index**, as by
1878/// [`TreeTableView::scroll_to_row`]: the position of the row among the rows
1879/// currently shown, counting the children of expanded nodes and skipping those
1880/// of collapsed ones. With [`pinned_ancestors`](TreeTableView::pinned_ancestors)
1881/// the row is revealed below the ancestors pinned over it.
1882///
1883/// Cheap to clone, valid before the view is mounted, and it survives the
1884/// view's rebuilds. Its methods never compute an offset when they are called:
1885/// they record the request and ask for a layout pass, and the view applies it
1886/// there, after row measurement and scroll anchoring have settled. So a
1887/// structural change made in the same turn (a node expanded above the target,
1888/// say) is accounted for, a request made before mount is applied at the first
1889/// layout, and a later request replaces an earlier pending one. A request on a
1890/// view with no rows is dropped; a row past the end reveals the last row.
1891///
1892/// ```rust
1893/// # use teksilo_data::TreeModel;
1894/// # use teksilo_widgets::TreeTableView;
1895/// # let model: TreeModel<String> = TreeModel::new();
1896/// let view = TreeTableView::new(model);
1897/// let handle = view.handle();
1898/// // … add the columns, `ctx.add(view)`; later, after expanding the row's
1899/// // ancestors:
1900/// handle.ensure_row_visible(42);
1901/// ```
1902#[derive(Clone)]
1903pub struct TreeTableViewHandle {
1904    reveal: SharedRowReveal,
1905}
1906
1907impl std::fmt::Debug for TreeTableViewHandle {
1908    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1909        f.debug_struct("TreeTableViewHandle")
1910            .field("pending", &self.reveal.pending())
1911            .finish()
1912    }
1913}
1914
1915impl TreeTableViewHandle {
1916    /// Scroll so that `row` (a flat visible index) is aligned to the top of
1917    /// the viewport, as far as the scroll range allows, at the next layout
1918    /// pass.
1919    pub fn scroll_to_row(&self, row: usize) {
1920        self.reveal.request(row, RevealAlign::Top);
1921    }
1922
1923    /// Scroll the minimum distance that makes `row` (a flat visible index)
1924    /// fully visible, at the next layout pass; no scroll when it already is.
1925    pub fn ensure_row_visible(&self, row: usize) {
1926        self.reveal.request(row, RevealAlign::Nearest);
1927    }
1928}
1929
1930impl<T: 'static> std::fmt::Debug for TreeTableView<T> {
1931    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1932        f.debug_struct("TreeTableView")
1933            .field("rows", &self.source.visible_count())
1934            .field("columns", &self.columns.len())
1935            .field("tree_column", &self.tree_column_id)
1936            .field("scroll_bar_style", &self.scroll_bar_style)
1937            .finish()
1938    }
1939}
1940
1941#[cfg(test)]
1942mod tests;
1943
1944#[cfg(test)]
1945mod handle_tests;