Skip to main content

teksilo_widgets/
tree_table_view.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `TreeTableView<T>` — hierarchical multi-column data table with expand/collapse.
5//!
6//! Sibling of [`TableView`](crate::TableView) for tree-shaped data. Each row carries
7//! a depth level; one designated column (the *tree column*, defaulting to the first)
8//! shows a twist (chevron) and an indent gutter that toggles the row's children.
9//! Backed by a [`SortFilterTreeModel<T>`] so sort, filter, and expand state compose
10//! without extra bookkeeping. Shares the header, column, keyboard, and selection
11//! modules with `TableView`.
12//!
13//! Rows live in a `TreeBodyPane` — a sibling of the scrollbar — so buffer-exit /
14//! selection / expand rebuilds are never deferred mid-thumb-drag. Three row-height
15//! modes: uniform (`row_height`, fast path), exact per-flat-index callback
16//! (`row_height_fn`), and auto-measured (`auto_row_height` — grows to tallest cell).
17//!
18//! ## Common patterns
19//!
20//! **A checkbox column.** Selection and "checked" are different things — a
21//! checkbox column wants its own state, with parent/child propagation. Build it
22//! from [`TreeCheckedModel`](teksilo_data::TreeCheckedModel) over the same tree
23//! the view projects.
24//!
25//! A cell delegate receives `(&T, &CellContext)` and **`CellContext` carries no
26//! node identity** — only [`row_index`](crate::CellContext::row_index). So
27//! capture the projection and resolve the row's `NodeId` through it:
28//!
29//! ```ignore
30//! let proxy = SortFilterTreeModel::new(tree);
31//! let checks = TreeCheckedModel::new(proxy.tree());
32//! let for_cells = proxy.clone();
33//! let col = Column::new("done", lit!("Done"), move |_item, cx: &CellContext| {
34//!     match for_cells.visible_node_id(cx.row_index) {
35//!         Some(node) => Box::new(Checkbox::new(checks.check_state(node))) as Box<dyn Widget>,
36//!         None => Box::new(Spacer::new()),
37//!     }
38//! });
39//! ```
40//!
41//! For a tree whose identity is a domain key rather than a `NodeId`, use
42//! [`KeyedTreeCheckedModel`](teksilo_data::KeyedTreeCheckedModel) instead — it
43//! survives a full re-source, which a `NodeId`-keyed set cannot.
44//!
45//! ## Accessibility
46//!
47//! Root emits `Role::TreeGrid`; rows carry `set_level` + `set_expanded`.
48//! ArrowLeft / ArrowRight on the tree column collapse / expand.
49//!
50//! ```ignore
51//! // Column delegates capture closures — use ignore.
52//! use teksilo_widgets::TreeTableView;
53//! use teksilo_data::TreeModel;
54//! # struct File { name: String }
55//! # let model: TreeModel<File> = TreeModel::new();
56//! let _view = TreeTableView::new(model).row_height(28.0);
57//! ```
58
59mod body_pane;
60
61use std::cell::{Cell, RefCell};
62use std::collections::HashMap;
63use std::rc::Rc;
64use std::time::Duration;
65
66use teksilo_canvas::{Canvas, Point, Rect, Size, SizeProposal};
67
68use teksilo_core::accessibility::{AccessNodeBuilder, widget_id_to_node_id};
69use teksilo_core::binding::BindingLevel;
70use teksilo_core::build_context::BuildContext;
71use teksilo_core::drag_payload::DragPayload;
72use teksilo_core::event::EventResponse;
73use teksilo_core::signal::{Prop, Signal};
74use teksilo_core::widget::{EventContext, LayoutContext, PaintContext, Widget, WidgetPlacement};
75use teksilo_core::widget_builder::HandlerSet;
76use teksilo_core::widget_id::WidgetId;
77use teksilo_data::{
78    DropPosition, KeyedSelectionModel, NodeId, SelectionModel, SortDirection, SortFilterTreeModel,
79    TreeFilterMode, TreeModel,
80};
81use teksilo_i18n::LocalizedString;
82use teksilo_tokens::{BorderRole, Easing, SurfaceRole};
83
84use crate::styles::recipe_table_style as cp;
85
86use crate::common::row_metrics::{HeightSource, RowMetrics, SharedRowMetrics};
87use crate::common::scroll::OverscrollBehavior;
88use crate::data_views::{DragTransferMode, RowDragData, RowSelection, ViewId, ViewKind};
89use crate::data_views::{DropViz, drop_into_tint};
90use crate::scroll_area::ScrollBarMode;
91use crate::scroll_bar::{ScrollBar, ScrollBarOrientation, ScrollBarVisual};
92use crate::table_view::ColumnReorderDragData;
93use crate::table_view::body::SharedColumnWidths;
94use crate::table_view::column::{
95    Column, ColumnResizePolicy, EditTriggers, GridLines, PinnedSide, TabTraversal,
96};
97use crate::table_view::header::{
98    ColumnResizeInfo, ColumnResizeTable, HeaderCell, HeaderCellSpec, HeaderRow, ResizeStateHandle,
99    attach_header_reorder_handlers,
100};
101use crate::table_view::imperative;
102use crate::table_view::keyboard;
103use crate::table_view::layout;
104use crate::table_view::row_navigator::RowNavigator;
105use crate::table_view::selection::{CellSelectionModel, TableSelectionMode};
106use crate::tree_source::TreeSource;
107use teksilo_data::{DropResponse, TreeDataSource};
108
109const BUFFER_ROWS: usize = 5;
110const SCROLLBAR_THICKNESS: f32 = 12.0;
111
112/// Hierarchical row navigator. Adapts a [`TreeSource`]'s flat-list view to the
113/// [`RowNavigator`] interface used by the shared keyboard handler.
114///
115/// Index-keyed throughout, so it works over any [`TreeDataSource`] — a
116/// `SortFilterTreeModel` over a `TreeModel`, or an external store carrying its
117/// own `Key`.
118pub(crate) struct TreeNavigator<T: 'static> {
119    source: Rc<TreeSource<T>>,
120}
121
122impl<T: 'static> TreeNavigator<T> {
123    pub(crate) fn new(source: Rc<TreeSource<T>>) -> Self {
124        Self { source }
125    }
126}
127
128impl<T: 'static> RowNavigator for TreeNavigator<T> {
129    fn row_count(&self) -> usize {
130        self.source.visible_count()
131    }
132
133    fn depth(&self, row: usize) -> Option<usize> {
134        self.source.meta(row).map(|m| m.depth)
135    }
136
137    fn has_children(&self, row: usize) -> bool {
138        self.source
139            .meta(row)
140            .map(|m| m.has_children)
141            .unwrap_or(false)
142    }
143
144    fn is_expanded(&self, row: usize) -> bool {
145        self.source
146            .meta(row)
147            .map(|m| m.is_expanded)
148            .unwrap_or(false)
149    }
150
151    fn toggle_expanded(&self, row: usize) {
152        self.source.toggle_at(row);
153    }
154}
155
156/// Hierarchical multi-column widget. See module documentation.
157pub struct TreeTableView<T: 'static> {
158    /// Erased row access — every read (counts, entries, expansion, DnD,
159    /// keyboard reorder) goes through here, so the widget works over any
160    /// [`TreeDataSource`] and never needs to know the source's `Key`.
161    source: Rc<TreeSource<T>>,
162    /// Present only on the [`from_projection`](Self::from_projection) /
163    /// [`new`](Self::new) paths. It backs the `NodeId`-typed public API
164    /// ([`expand`](Self::expand), [`projection`](Self::projection), …), which is
165    /// meaningless for an external source carrying its own key — those methods
166    /// no-op when this is `None`.
167    proxy: Option<SortFilterTreeModel<T>>,
168
169    columns: Vec<Column<T>>,
170    /// Column id hosting the twist + indent. `None` defaults to the
171    /// first column at build time.
172    tree_column_id: Option<String>,
173    indent_per_level: Option<f32>,
174    row_height: Option<f32>,
175    /// Height-mode selection (uniform / exact callback / auto-measure).
176    height_source: HeightSource,
177    /// Row geometry — shared with the keyboard handler and the body
178    /// pane.
179    row_metrics: SharedRowMetrics,
180    header_height: Option<f32>,
181    show_header: bool,
182    selection_mode: TableSelectionMode,
183    /// Row selection — index-based `SelectionModel` or keyed
184    /// `KeyedSelectionModel<NodeId>`, unified behind the index-facing facade.
185    row_selection: Option<RowSelection>,
186    cell_selection: Option<CellSelectionModel>,
187    alternating_rows: bool,
188    grid_lines: GridLines,
189    a11y_label: Option<LocalizedString>,
190    show_internal_scrollbars: bool,
191    column_resize_policy: ColumnResizePolicy,
192    tab_traversal: TabTraversal,
193    edit_triggers: EditTriggers,
194    #[allow(clippy::type_complexity)]
195    on_cell_edit_request: Option<Rc<dyn Fn(usize, &str, &mut EventContext)>>,
196    on_cell_edit_dismissed: Option<Rc<dyn Fn(usize, &str, &mut EventContext)>>,
197    #[allow(clippy::type_complexity)]
198    on_row_activate: Option<Rc<dyn Fn(usize, &mut EventContext)>>,
199
200    /// Animate wheel scrolling instead of snapping to the new offset.
201    /// Enabled by default — mirrors `ScrollArea`. Without it, each wheel
202    /// notch jumps by `row_height` per delivered line, which reads as a
203    /// coarse multi-row jump rather than a smooth glide.
204    smooth_scrolling: bool,
205    /// Duration of the smooth scroll animation.
206    smooth_scroll_duration: Duration,
207
208    /// How the scroll bar is displayed (default `Permanent`). `Overlay`
209    /// and `Thin` float the bar over the content instead of reserving a
210    /// layout column for it, mirroring `ScrollArea::scroll_bar_style`.
211    scroll_bar_style: ScrollBarMode,
212
213    // Public reactive signals
214    scroll_y: Signal<f32>,
215    max_scroll_y: Signal<f32>,
216    /// Scroll-chaining behavior at the boundary (default `Chain`).
217    overscroll_behavior: OverscrollBehavior,
218    viewport_ratio_y: Signal<f32>,
219    /// Horizontal scroll offset of the Middle (unpinned) pane — mirrors
220    /// `TableView::scroll_x`. See `table_view::PaneBoundaries`.
221    scroll_x: Signal<f32>,
222    max_scroll_x: Signal<f32>,
223    viewport_ratio_x: Signal<f32>,
224    sort_signal: Signal<Option<(String, SortDirection)>>,
225    column_widths_signal: Signal<HashMap<String, f32>>,
226    column_order_signal: Signal<Vec<String>>,
227    column_pinning_signal: Signal<HashMap<String, PinnedSide>>,
228    filters_signal: Signal<HashMap<String, String>>,
229    focused_cell: Signal<Option<(usize, usize)>>,
230    /// The realized `(row index -> row wrapper id)` map, filled by the body
231    /// pane each build. Lets this widget's `&self` methods resolve a row index
232    /// to a widget without reaching into the pane. Mirrors `ListView::row_map`.
233    row_map: Rc<RefCell<Vec<(usize, WidgetId)>>>,
234    editing_cell: Signal<Option<(usize, usize)>>,
235    /// Type-ahead ("type to jump") label extractor — opt-in via
236    /// [`type_ahead_label`](Self::type_ahead_label).
237    #[allow(clippy::type_complexity)]
238    type_ahead_label: Option<Rc<dyn Fn(&T) -> String>>,
239    /// Reset window for the type-ahead search term.
240    type_ahead_timeout: Duration,
241    /// Persistent type-ahead buffer (survives the per-keystroke rebuild).
242    type_ahead: Rc<crate::common::type_ahead::TypeAheadState>,
243    /// Widget shown in place of the rows when nothing is visible — an empty
244    /// tree, or a filter that matched nothing.
245    #[allow(clippy::type_complexity)]
246    empty_view: Option<Rc<dyn Fn() -> Box<dyn Widget>>>,
247    /// Set on the first `place_children`. Until then `viewport_height` still
248    /// holds its construction placeholder, so viewport-relative imperatives
249    /// (`ensure_row_visible`) would scroll against a size that was never real.
250    laid_out: Rc<Cell<bool>>,
251    /// Anchor for the row with an open cell editor, so the editor follows its
252    /// row instead of its index. See `reconcile_editing_row`.
253    editing_anchor: Rc<RefCell<Option<crate::data_views::RowAnchor>>>,
254
255    // Build state
256    header_row_id: Option<WidgetId>,
257    body_pane_id: Option<WidgetId>,
258    scrollbar_id: Option<WidgetId>,
259    /// Horizontal scroll bar along the bottom of the Middle pane only —
260    /// mirrors `TableView::h_scrollbar_id`.
261    h_scrollbar_id: Option<WidgetId>,
262    empty_id: Option<WidgetId>,
263    /// Pane-local rebuild trigger + buffered range, owned here so they
264    /// survive `TreeTableView` rebuilds (each rebuild constructs a fresh
265    /// `TreeBodyPane` struct that inherits these handles).
266    pane_version: Signal<u64>,
267    pane_built_start: Rc<Cell<usize>>,
268    pane_built_end: Rc<Cell<usize>>,
269    /// Bumped by the pane when a measure pass changes the content
270    /// total; bound at `Relayout` on this root so `max_scroll_y` / the
271    /// thumb ratio are recomputed with the corrected total next frame.
272    pane_total_refresh: Signal<u64>,
273
274    /// Enable drag-to-reorder of rows (pointer drag + Alt+Arrow). The move
275    /// reparents/reorders nodes in the underlying `TreeModel`, cycle-guarded.
276    /// Suppressed while a sort is active (the visible order then differs from
277    /// the tree order, so a manual reorder would be meaningless).
278    reorderable: bool,
279    /// Active row-drop insertion indicator `(body_local_y, width)`. Set by
280    /// `on_drag_hover`, cleared on leave / drop, read by `paint`.
281    drop_feedback: Signal<Option<DropViz>>,
282
283    /// Whether activation is a single or double click (default `DoubleClick`).
284    activate_on: crate::data_views::ActivateOn,
285
286    /// `true` while this view — its root or any descendant — holds keyboard
287    /// focus. Captured at build from [`BuildContext::view_focus_active`], bound
288    /// `RepaintOnly`. Drives focus-aware selection: the band paints `Selected`
289    /// while focused, muted `SelectedInactive` once focus leaves the view.
290    view_focused: Signal<bool>,
291    /// Input-modality `:focus-visible`. Gates the cell focus ring to keyboard
292    /// navigation (never a mouse click). Bound `RepaintOnly`.
293    focus_visible: Signal<bool>,
294
295    // Layout state
296    column_widths: SharedColumnWidths,
297    display_indices: Rc<RefCell<Vec<usize>>>,
298    /// Counts of (leading-pinned, middle, trailing-pinned) columns —
299    /// mirrors `TableView::pane_boundaries`. Populated by `display_order()`.
300    pane_boundaries: Rc<RefCell<crate::table_view::PaneBoundaries>>,
301    /// `(row, display_pos) -> WidgetId` for every cell realized by the
302    /// body pane's latest `build()`. Mirrors `TableView::cell_map` (the
303    /// GridView `tile_map` pattern — shared between the root and its
304    /// sibling-of-scrollbar pane); `accessibility()` reads it to point
305    /// `active_descendant` at the keyboard-focused cell's own AT node.
306    cell_map: Rc<RefCell<Vec<((usize, usize), WidgetId)>>>,
307    viewport_height: Rc<Cell<f32>>,
308    /// Middle-pane viewport width, snapshotted by `place_children` —
309    /// mirrors `TableView::middle_viewport_width`.
310    middle_viewport_width: Rc<Cell<f32>>,
311    /// The row-area's absolute (window) rect (below the header), cached by
312    /// `place_children`. Threaded into the keyboard handler so it can chase the
313    /// focused row into any *enclosing* scroll area via
314    /// [`EventContext::ensure_visible`](teksilo_core::widget::EventContext::ensure_visible).
315    body_bounds: Rc<Cell<Rect>>,
316    resize_state: ResizeStateHandle,
317    /// Display slot of the column under an active resize drag, or `None`.
318    /// Mirrors `TableView::resize_target` — shared with every `HeaderCell`
319    /// so the *target* column carries the "resizing" chrome even when the
320    /// gesture is anchored on its neighbour's half of the grip.
321    resize_target: Signal<Option<usize>>,
322    /// Window x of the prospective divider during a
323    /// [`ColumnResizePolicy::OnRelease`] drag. Mirrors
324    /// `TableView::resize_preview_x`.
325    resize_preview_x: Signal<Option<f32>>,
326    /// Width of the header strip (= the column band) snapshotted by
327    /// `place_children`. Mirrors `TableView::header_strip_width` — the
328    /// column-reorder drop handler needs it to mirror the drop x under RTL.
329    header_strip_width: Rc<Cell<f32>>,
330    /// Stable id grouping the column-header reorder/resize drag (an
331    /// unrelated mechanism to the row DnD below — see `table_view::header`).
332    table_id: usize,
333
334    /// Stable, kind-tagged identity for this view's **row** drag-and-drop —
335    /// distinct from `table_id` above. Minted via
336    /// `ViewId::next(ViewKind::TreeTable)`.
337    model_id: ViewId,
338
339    /// Cross-widget export / foreign-receive machinery — the builders
340    /// (`.exportable`, `.export_external`, `.accept_foreign_rows`,
341    /// `.on_rows_received`, `.on_rows_transferred_out`), the drag-start
342    /// payload build, and the move-out completion, shared by all five data
343    /// views. `TreeTableView` builds its reader + stable-key removal thunk
344    /// inline at drag-start (see `TreeBodyPane::build`'s `on_drag`) rather
345    /// than from source capability closures, so the key it removes by is
346    /// resolved once at drag-start and stays correct even if a mid-drag
347    /// spring-load reflattens the rows under the pointer.
348    export: crate::data_views::RowExport<T>,
349    /// Raw escape hatch for a payload this view cannot interpret itself.
350    ///
351    /// A source-backed view ([`from_source`](Self::from_source)) expresses
352    /// foreign-accept through its source's capability closures, like
353    /// `ListView` / `TableView`. This hook is what a **projection**-backed
354    /// view ([`from_projection`](Self::from_projection) / [`new`](Self::new))
355    /// has instead, since a `SortFilterTreeModel` carries no such closures.
356    /// Fires for any payload NOT recognized as this view's own row drag,
357    /// dropped on a node —
358    /// `(payload, target node, drop position, ctx) -> accepted`. Tried after
359    /// [`on_rows_received`](Self::on_rows_received).
360    #[allow(clippy::type_complexity)]
361    on_foreign_drop:
362        Option<Rc<dyn Fn(&DragPayload, NodeId, DropPosition, &mut EventContext) -> bool>>,
363
364    /// Whole-view enabled state, statically or reactively. Forwarded to the
365    /// arena via `ctx.enabled_when(self_id, self.enabled.clone())` at build
366    /// time; a disabled view greys out and stops accepting focus /
367    /// selection / keyboard input (arena-gated).
368    enabled: Prop<bool>,
369}
370
371impl<T: 'static> TreeTableView<T> {
372    /// Wrap a `SortFilterTreeModel<T>`.
373    /// Wrap a `SortFilterTreeModel<T>`.
374    pub fn from_projection(proxy: SortFilterTreeModel<T>) -> Self {
375        let source = Rc::new(TreeSource::from_data_source(Rc::new(proxy.clone())));
376        Self::assemble(source, Some(proxy))
377    }
378
379    /// Build a tree table over any [`TreeDataSource`] — an external source of
380    /// truth (a Qleany entity store, a database, a virtual filesystem) carrying
381    /// its own `Key`, so it needs no `TreeModel` mirror.
382    ///
383    /// This is the tree-table sibling of
384    /// [`TreeView::from_source`](crate::TreeView::from_source). Because the
385    /// source owns identity, its expand state (and a keyed selection) survive a
386    /// full re-source — which a `TreeModel` mirror cannot guarantee, since
387    /// `NodeId`s are reassigned on rebuild.
388    ///
389    /// The `NodeId`-typed methods ([`expand`](Self::expand),
390    /// [`projection`](Self::projection), [`keyed_selection`](Self::keyed_selection))
391    /// do not apply here and no-op; drive expansion through the source itself.
392    ///
393    /// Row drag-reorder **is** wired on this path: a drop routes through the source's
394    /// own `drag` / `can_accept` / `accept_drop`, exactly as
395    /// [`TreeView`](crate::TreeView) does — so the
396    /// source owns both the cycle guard and the commit. Note that
397    /// [`TreeDataSlice::drag`](teksilo_data::TreeDataSlice) defaults to `NoDrag`: an
398    /// external source must opt its rows in before anything can be dragged.
399    pub fn from_source<S: TreeDataSource<Item = T> + 'static>(source: S) -> Self {
400        Self::assemble(Rc::new(TreeSource::from_data_source(Rc::new(source))), None)
401    }
402
403    /// Like [`from_source`](Self::from_source) but with **keyed** selection:
404    /// the `KeyedSelectionModel<S::Key>` tracks rows by source identity, so it
405    /// survives expand / collapse, sort / filter and a full re-source. Pruning
406    /// consults the source's `contains_key`, so a collapsed-but-present row
407    /// keeps its selection. The view stays `TreeTableView<T>` — the `Key` is
408    /// captured here.
409    pub fn from_source_keyed<S: TreeDataSource<Item = T> + 'static>(
410        source: S,
411        keyed: KeyedSelectionModel<S::Key>,
412    ) -> Self
413    where
414        S::Key: teksilo_data::ItemKey,
415    {
416        let s = Rc::new(source);
417        let key_at = {
418            let s = s.clone();
419            Rc::new(move |i| s.key_at(i)) as Rc<dyn Fn(usize) -> Option<S::Key>>
420        };
421        let len = {
422            let s = s.clone();
423            Rc::new(move || s.visible_count()) as Rc<dyn Fn() -> usize>
424        };
425        let contains = {
426            let s = s.clone();
427            Rc::new(move |k: &S::Key| s.contains_key(k)) as Rc<dyn Fn(&S::Key) -> bool>
428        };
429        let mut view = Self::assemble(Rc::new(TreeSource::from_data_source(s)), None);
430        view.row_selection = Some(RowSelection::from_keyed(keyed, key_at, len, contains));
431        view
432    }
433
434    fn assemble(source: Rc<TreeSource<T>>, proxy: Option<SortFilterTreeModel<T>>) -> Self {
435        use std::sync::atomic::{AtomicUsize, Ordering};
436        static NEXT_ID: AtomicUsize = AtomicUsize::new(1);
437        let table_id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
438        Self {
439            source,
440            proxy,
441            columns: Vec::new(),
442            tree_column_id: None,
443            indent_per_level: None,
444            row_height: None,
445            height_source: HeightSource::Uniform,
446            row_metrics: Rc::new(RefCell::new(RowMetrics::uniform(cp::ROW_HEIGHT, 0.0))),
447            header_height: None,
448            show_header: true,
449            selection_mode: TableSelectionMode::default(),
450            row_selection: None,
451            cell_selection: None,
452            alternating_rows: false,
453            grid_lines: GridLines::None,
454            a11y_label: None,
455            show_internal_scrollbars: true,
456            column_resize_policy: ColumnResizePolicy::default(),
457            tab_traversal: TabTraversal::default(),
458            edit_triggers: EditTriggers::default(),
459            on_cell_edit_request: None,
460            on_cell_edit_dismissed: None,
461            on_row_activate: None,
462            reorderable: false,
463            drop_feedback: Signal::new(None),
464            activate_on: crate::data_views::ActivateOn::default(),
465            smooth_scrolling: true,
466            smooth_scroll_duration: Duration::from_millis(150),
467            scroll_bar_style: ScrollBarMode::Permanent,
468            scroll_y: Signal::new_animated(0.0),
469            max_scroll_y: Signal::new(0.0),
470            overscroll_behavior: OverscrollBehavior::default(),
471            viewport_ratio_y: Signal::new(1.0),
472            scroll_x: Signal::new_animated(0.0),
473            max_scroll_x: Signal::new(0.0),
474            viewport_ratio_x: Signal::new(1.0),
475            sort_signal: Signal::new(None),
476            column_widths_signal: Signal::new(HashMap::new()),
477            column_order_signal: Signal::new(Vec::new()),
478            column_pinning_signal: Signal::new(HashMap::new()),
479            filters_signal: Signal::new(HashMap::new()),
480            focused_cell: Signal::new(None),
481            row_map: Rc::new(RefCell::new(Vec::new())),
482            type_ahead_label: None,
483            type_ahead_timeout: crate::common::type_ahead::DEFAULT_TYPE_AHEAD_TIMEOUT,
484            type_ahead: crate::common::type_ahead::TypeAheadState::new(),
485            // Replaced at build with the live tree signals.
486            view_focused: Signal::new(true),
487            focus_visible: Signal::new(false),
488            editing_cell: Signal::new(None),
489            empty_view: None,
490            laid_out: Rc::new(Cell::new(false)),
491            editing_anchor: Rc::new(RefCell::new(None)),
492            header_row_id: None,
493            body_pane_id: None,
494            scrollbar_id: None,
495            h_scrollbar_id: None,
496            empty_id: None,
497            pane_version: Signal::new(0_u64),
498            pane_built_start: Rc::new(Cell::new(0)),
499            pane_built_end: Rc::new(Cell::new(0)),
500            pane_total_refresh: Signal::new(0_u64),
501            column_widths: Rc::new(RefCell::new(Vec::new())),
502            display_indices: Rc::new(RefCell::new(Vec::new())),
503            pane_boundaries: Rc::new(RefCell::new(crate::table_view::PaneBoundaries::default())),
504            cell_map: Rc::new(RefCell::new(Vec::new())),
505            viewport_height: Rc::new(Cell::new(600.0)),
506            middle_viewport_width: Rc::new(Cell::new(600.0)),
507            body_bounds: Rc::new(Cell::new(Rect::ZERO)),
508            resize_state: Rc::new(RefCell::new(None)),
509            resize_target: Signal::new(None),
510            resize_preview_x: Signal::new(None),
511            header_strip_width: Rc::new(Cell::new(0.0)),
512            table_id,
513            model_id: ViewId::next(ViewKind::TreeTable),
514            export: crate::data_views::RowExport::default(),
515            on_foreign_drop: None,
516            enabled: Prop::Static(true),
517        }
518    }
519
520    /// Wrap a raw `TreeModel<T>` — convenience for callers that don't
521    /// need sort/filter. Internally builds an identity
522    /// `SortFilterTreeModel`.
523    pub fn new(model: TreeModel<T>) -> Self {
524        Self::from_projection(SortFilterTreeModel::new(model))
525    }
526
527    // ── Builder ────────────────────────────────────────────────────────
528
529    /// Enable or disable the whole view. A disabled view greys out and stops
530    /// accepting focus / selection / keyboard input (arena-gated).
531    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
532        self.enabled = enabled.into();
533        self
534    }
535
536    /// Set the scroll-chaining behavior at the boundary (default
537    /// [`OverscrollBehavior::Chain`]; [`Contain`](OverscrollBehavior::Contain)
538    /// disables chaining to an ancestor scrollable).
539    pub fn overscroll_behavior(mut self, behavior: OverscrollBehavior) -> Self {
540        self.overscroll_behavior = behavior;
541        self
542    }
543
544    /// Enable or disable animated wheel scrolling (enabled by default).
545    /// When disabled, wheel events snap immediately to the new offset.
546    pub fn smooth_scrolling(mut self, enabled: bool) -> Self {
547        self.smooth_scrolling = enabled;
548        self
549    }
550
551    /// Enable **type-ahead** ("type to jump"): typing a printable character
552    /// while the tree-table has keyboard focus jumps the focused row to the
553    /// next *visible* row whose label starts with the accumulated search term,
554    /// wrapping around (Qt `keyboardSearch` / macOS & Windows type-select).
555    /// `label(&item)` yields the searchable text; matching is
556    /// ASCII-case-insensitive. A pause longer than the
557    /// [`type_ahead_timeout`](Self::type_ahead_timeout) starts a fresh term.
558    pub fn type_ahead_label(mut self, label: impl Fn(&T) -> String + 'static) -> Self {
559        self.type_ahead_label = Some(Rc::new(label));
560        self
561    }
562
563    /// Reset window between keystrokes before the type-ahead search term
564    /// clears (default 500 ms). A zero duration disables type-ahead.
565    pub fn type_ahead_timeout(mut self, timeout: Duration) -> Self {
566        self.type_ahead_timeout = timeout;
567        self
568    }
569
570    /// Duration of the smooth scroll animation (default 150 ms).
571    pub fn smooth_scroll_duration(mut self, duration: Duration) -> Self {
572        self.smooth_scroll_duration = duration;
573        self
574    }
575
576    /// How the scroll bar is displayed (default `Permanent`). `Overlay`
577    /// and `Thin` float the bar over the content instead of reserving a
578    /// layout column for it, mirroring `ScrollArea::scroll_bar_style`.
579    pub fn scroll_bar_style(mut self, style: ScrollBarMode) -> Self {
580        self.scroll_bar_style = style;
581        self
582    }
583
584    /// Append a column definition. Columns are displayed in declaration order unless
585    /// reordered by the user.
586    pub fn add_column(mut self, col: Column<T>) -> Self {
587        self.columns.push(col);
588        self
589    }
590
591    /// Enable drag-to-reorder of **rows** (pointer drag + keyboard
592    /// Alt+ArrowUp/Down). Distinct from
593    /// [`Column::reorderable`](crate::Column::reorderable), which reorders
594    /// *columns* and defaults to `true`; this defaults to `false`.
595    ///
596    /// A drop reparents/reorders the dragged node in the underlying
597    /// `TreeModel` (top third of a row = Before, middle = Into / make-child,
598    /// bottom = After). The move is cycle-guarded — dropping a node onto
599    /// itself or into its own subtree is refused (no insertion line). Reorder
600    /// is **suppressed while a sort is active**: with the visible order driven
601    /// by the sort, a manual reorder would have no visible effect.
602    pub fn reorderable(mut self, enabled: bool) -> Self {
603        self.reorderable = enabled;
604        self
605    }
606
607    /// Make rows **droppable outside this view** — on a
608    /// [`DropTarget`](crate::DropTarget), another data view, or the OS.
609    ///
610    /// A dragged row (or the whole selection, when the pressed row is part of a
611    /// multi-selection) carries clones of its items in a public
612    /// [`RowDragData<T>`](crate::RowDragData), so a foreign receiver can pull
613    /// them out with `payload.get_typed::<RowDragData<T>>()` /
614    /// `DropTarget::on_drop_typed::<RowDragData<T>>()` — no serialization. This
615    /// also makes rows a drag source even without [`reorderable`](Self::reorderable).
616    ///
617    /// `mode` chooses what happens to the origin rows once a *foreign* target
618    /// accepts them: [`DragTransferMode::Move`] removes them — by default,
619    /// directly from the underlying `TreeModel` (any dragged node that is a
620    /// descendant of another dragged node is skipped, since removing the
621    /// ancestor already removes it); override via
622    /// [`on_rows_transferred_out`](Self::on_rows_transferred_out).
623    /// [`DragTransferMode::Copy`] leaves them. A same-view reorder is never a
624    /// transfer, so `mode` never affects it. Requires `T: Clone`.
625    pub fn exportable(mut self, mode: DragTransferMode) -> Self
626    where
627        T: Clone,
628    {
629        self.export.set_exportable(mode);
630        self
631    }
632
633    /// Additionally advertise the dragged rows as MIME data so they can be
634    /// dropped on a [`DropZone`](crate::DropZone) or exported to another
635    /// application / window via the OS. `f` maps the dragged items to
636    /// `(mime_type, bytes)` pairs (e.g. `text/plain`, `text/uri-list`, an
637    /// app-specific `application/x-…`). Implies [`exportable`](Self::exportable)
638    /// (defaulting to [`DragTransferMode::Move`] if not already set). Requires
639    /// `T: Clone`.
640    pub fn export_external(mut self, f: impl Fn(&[T]) -> Vec<(String, Vec<u8>)> + 'static) -> Self
641    where
642        T: Clone,
643    {
644        self.export.set_export_external(f);
645        self
646    }
647
648    /// Override how rows moved out to a foreign target are removed from this
649    /// view. Receives the dragged rows' flat visible indices (as captured at
650    /// drag-start) and the live context. Without this, an
651    /// [`exportable`](Self::exportable) [`Move`](DragTransferMode::Move) drag
652    /// removes the dragged nodes directly from the underlying `TreeModel`
653    /// (leaf-first / descending — a dragged node that is a descendant of
654    /// another dragged node is skipped, since removing the ancestor already
655    /// removes its whole subtree).
656    pub fn on_rows_transferred_out(
657        mut self,
658        f: impl Fn(&[usize], &mut EventContext) + 'static,
659    ) -> Self {
660        self.export.set_on_rows_transferred_out(f);
661        self
662    }
663
664    /// Accept exported rows dropped from a **different** view or source
665    /// without writing a custom source. Pair with
666    /// [`on_rows_received`](Self::on_rows_received), which is handed the
667    /// dropped items and the target flat row index. (Same-view reorder is
668    /// [`reorderable`](Self::reorderable).)
669    pub fn accept_foreign_rows(mut self, accept: bool) -> Self {
670        self.export.accept_foreign_rows = accept;
671        self
672    }
673
674    /// Handler for rows accepted via
675    /// [`accept_foreign_rows`](Self::accept_foreign_rows): `(items, target
676    /// flat row index, ctx)`. Insert them into your tree at/near the index.
677    pub fn on_rows_received(
678        mut self,
679        f: impl Fn(Vec<T>, usize, &mut EventContext) + 'static,
680    ) -> Self {
681        self.export.set_on_rows_received(f);
682        self
683    }
684
685    /// Raw escape hatch for a foreign drop.
686    ///
687    /// **Projection path only.** This hook is `NodeId`-typed and predates
688    /// [`from_source`](Self::from_source); over an external source there is no
689    /// `NodeId` to hand it, so it never fires. Prefer
690    /// [`accept_foreign_rows`](Self::accept_foreign_rows) +
691    /// [`on_rows_received`](Self::on_rows_received), which are source-agnostic. Unlike `ListView` / `TableView`,
692    /// `TreeTableView` is backed by a concrete `SortFilterTreeModel<T>` rather
693    /// than a pluggable source, so it cannot express foreign-accept purely
694    /// through source capability closures (`can_accept` / `accept_drop`).
695    /// This fires for **any** payload NOT recognized as this view's own row
696    /// drag — a different view's [`RowDragData<T>`](crate::RowDragData), or a
697    /// completely different payload type — dropped on a node: `(payload,
698    /// target node, drop position, ctx) -> accepted`. Tried after
699    /// [`on_rows_received`](Self::on_rows_received), so the typed sugar wins
700    /// when both are set and the payload happens to carry an exportable
701    /// `RowDragData<T>`.
702    pub fn on_foreign_drop(
703        mut self,
704        f: impl Fn(&DragPayload, NodeId, DropPosition, &mut EventContext) -> bool + 'static,
705    ) -> Self {
706        self.on_foreign_drop = Some(Rc::new(f));
707        self
708    }
709
710    /// Choose single- vs double-click activation for `on_row_activate` (default
711    /// [`ActivateOn::DoubleClick`](crate::ActivateOn)). Enter/Space activates in
712    /// either mode.
713    pub fn activate_on(mut self, mode: crate::data_views::ActivateOn) -> Self {
714        self.activate_on = mode;
715        self
716    }
717
718    /// Append multiple columns from an iterator.
719    pub fn columns(mut self, cols: impl IntoIterator<Item = Column<T>>) -> Self {
720        self.columns.extend(cols);
721        self
722    }
723
724    /// Designate which column hosts the twist + indent. Default: the
725    /// first column.
726    pub fn tree_column(mut self, col_id: impl Into<String>) -> Self {
727        self.tree_column_id = Some(col_id.into());
728        self
729    }
730
731    /// Override the per-depth indent in the tree column in logical pixels (default
732    /// comes from the active `TableStyle`).
733    pub fn indent_per_level(mut self, px: f32) -> Self {
734        self.indent_per_level = Some(px);
735        self
736    }
737
738    /// Re-materialize `self.row_metrics` after a height-mode /
739    /// row-height builder call.
740    fn remake_metrics(&self) {
741        *self.row_metrics.borrow_mut() = self
742            .height_source
743            .make_metrics(self.effective_row_height(), 0.0);
744    }
745
746    /// Fixed row height (default: the table style's 28 px) — the
747    /// uniform fast path. Mutually exclusive with
748    /// [`row_height_fn`](Self::row_height_fn) and
749    /// [`auto_row_height`](Self::auto_row_height); the last mode setter
750    /// wins.
751    pub fn row_height(mut self, height: f32) -> Self {
752        self.row_height = Some(height);
753        self.height_source = HeightSource::Uniform;
754        self.remake_metrics();
755        self
756    }
757
758    /// Per-row heights from a callback over the flat (visible) row
759    /// index. The callback must be pure (same index + same data → same
760    /// height); it is re-swept from the first changed flat index on
761    /// every projection rebuild (expand/collapse/sort/filter/mutation).
762    /// No measurement pass runs.
763    pub fn row_height_fn(mut self, f: impl Fn(usize) -> f32 + 'static) -> Self {
764        self.height_source = HeightSource::Exact(Rc::new(f));
765        self.remake_metrics();
766        self
767    }
768
769    /// Auto-measured row heights: each realized row reports the height
770    /// of its tallest cell measured at the cell's column width
771    /// (height-for-width), unrealized rows assume `estimated`. Scroll
772    /// anchoring keeps content above the viewport stationary; measured
773    /// heights above a toggled row survive expand/collapse
774    /// (divergence-driven invalidation). The scrollbar settles one
775    /// frame after a measurement change.
776    pub fn auto_row_height(mut self, estimated: f32) -> Self {
777        self.height_source = HeightSource::Auto { estimated };
778        self.remake_metrics();
779        self
780    }
781
782    /// Override the header row height in logical pixels.
783    pub fn header_height(mut self, height: f32) -> Self {
784        self.header_height = Some(height);
785        self
786    }
787
788    /// Show or hide the column header row (default `true`).
789    pub fn show_header(mut self, visible: bool) -> Self {
790        self.show_header = visible;
791        self
792    }
793
794    /// Set the row/cell selection mode (default
795    /// [`TableSelectionMode::MultiRow`]).
796    pub fn selection_mode(mut self, mode: TableSelectionMode) -> Self {
797        self.selection_mode = mode;
798        self
799    }
800
801    /// Set the index-based row selection model (visible positions). For
802    /// identity-based selection that survives expand / collapse / sort /
803    /// filter / structural edits, use [`keyed_selection`](Self::keyed_selection)
804    /// instead.
805    pub fn selection(mut self, sel: SelectionModel) -> Self {
806        self.row_selection = Some(RowSelection::from_index(sel));
807        self
808    }
809
810    /// Set a keyed row selection model (by `NodeId`). Selection is tracked by
811    /// node identity, so it survives expand / collapse, sort / filter, and node
812    /// moves — and stays consistent if two views share the projection. Pruned
813    /// of deleted nodes on each projection change. Mutually exclusive with
814    /// [`selection`](Self::selection) (last one set wins).
815    /// Only meaningful on the [`from_projection`](Self::from_projection) /
816    /// [`new`](Self::new) paths, whose identity *is* `NodeId`; a no-op over an
817    /// external source, which carries its own key — use
818    /// [`from_source_keyed`](Self::from_source_keyed) there.
819    pub fn keyed_selection(mut self, keyed: KeyedSelectionModel<NodeId>) -> Self {
820        let Some(proxy) = self.proxy.clone() else {
821            return self;
822        };
823        let key_at = {
824            let p = proxy.clone();
825            Rc::new(move |i| p.visible_node_id(i)) as Rc<dyn Fn(usize) -> Option<NodeId>>
826        };
827        let len = {
828            let p = proxy.clone();
829            Rc::new(move || p.visible_count()) as Rc<dyn Fn() -> usize>
830        };
831        // A collapsed-but-present node must NOT be pruned, so existence is
832        // checked against the tree, not the (visible) projection window.
833        let contains = {
834            let p = proxy;
835            Rc::new(move |n: &NodeId| p.tree().with_item(*n, |_| ()).is_some())
836                as Rc<dyn Fn(&NodeId) -> bool>
837        };
838        self.row_selection = Some(RowSelection::from_keyed(keyed, key_at, len, contains));
839        self
840    }
841
842    /// Attach a cell-level selection model (row and column axes tracked
843    /// independently).
844    pub fn cell_selection(mut self, sel: CellSelectionModel) -> Self {
845        self.cell_selection = Some(sel);
846        self
847    }
848
849    /// Paint odd-indexed rows with the `SurfaceRole::AlternatingRow` tint
850    /// (default `false`).
851    pub fn alternating_rows(mut self, enabled: bool) -> Self {
852        self.alternating_rows = enabled;
853        self
854    }
855
856    /// Paint horizontal and/or vertical dividers between cells.
857    pub fn grid_lines(mut self, kind: GridLines) -> Self {
858        self.grid_lines = kind;
859        self
860    }
861
862    /// Accessible label for the whole tree table, announced by AT as the
863    /// table's name.
864    pub fn a11y_label(mut self, label: impl Into<LocalizedString>) -> Self {
865        self.a11y_label = Some(label.into());
866        self
867    }
868
869    /// Show or hide the widget's internal vertical and horizontal scroll bars
870    /// (default `true`). Set to `false` when the table lives inside an external
871    /// `ScrollArea`.
872    pub fn show_internal_scrollbars(mut self, show: bool) -> Self {
873        self.show_internal_scrollbars = show;
874        self
875    }
876
877    /// Control how column widths are distributed when the table is resized
878    /// (default `Proportional`).
879    pub fn column_resize_policy(mut self, policy: ColumnResizePolicy) -> Self {
880        self.column_resize_policy = policy;
881        self
882    }
883
884    /// Set the keyboard Tab traversal direction inside the table (default `Cells`).
885    pub fn tab_traversal(mut self, mode: TabTraversal) -> Self {
886        self.tab_traversal = mode;
887        self
888    }
889
890    /// Set which user gesture starts an in-place cell edit (default
891    /// `DoubleClick`).
892    pub fn edit_triggers(mut self, trigger: EditTriggers) -> Self {
893        self.edit_triggers = trigger;
894        self
895    }
896
897    /// Callback invoked when the user requests an in-place cell edit (e.g.
898    /// double-click when `edit_triggers` is `DoubleClick`). Receives the flat row
899    /// index, the column id, and a mutable `EventContext`.
900    pub fn on_cell_edit_request(
901        mut self,
902        f: impl Fn(usize, &str, &mut EventContext) + 'static,
903    ) -> Self {
904        self.on_cell_edit_request = Some(Rc::new(f));
905        self
906    }
907
908    /// Callback invoked when an **open** cell editor should end because the
909    /// pointer went somewhere else: a press that lands on any cell other than
910    /// the one being edited. Receives the editing cell's flat row index and
911    /// column id, so the owner can commit (or discard) whatever is in its
912    /// buffer, then clear its own editing state.
913    ///
914    /// The counterpart of [`on_cell_edit_request`](Self::on_cell_edit_request),
915    /// and the view cannot do it alone: the framework owns *which* cell is being
916    /// edited, but only the owner knows what an ended edit means — commit,
917    /// discard, or refuse a value that will not parse.
918    ///
919    /// **Why a press and not a focus change.** "The editor lost focus" is the
920    /// obvious signal and it cannot be used: a body pane rebuilds constantly —
921    /// selection, filtering, scroll, a reload from elsewhere — and every rebuild
922    /// destroys and re-creates the open editor, so focus leaves it many times
923    /// during an edit the writer never interrupted. A press on another cell is
924    /// unambiguous and happens exactly once.
925    pub fn on_cell_edit_dismissed(
926        mut self,
927        f: impl Fn(usize, &str, &mut EventContext) + 'static,
928    ) -> Self {
929        self.on_cell_edit_dismissed = Some(Rc::new(f));
930        self
931    }
932
933    /// Callback invoked when a row is activated (double-click or Enter, per
934    /// `activate_on`). Receives the flat row index.
935    pub fn on_row_activate(mut self, f: impl Fn(usize, &mut EventContext) + 'static) -> Self {
936        self.on_row_activate = Some(Rc::new(f));
937        self
938    }
939
940    /// Forward `mode` to the underlying projection. The proxy holds its
941    /// state behind `Rc<RefCell>`, so calling `.filter_mode()` on a
942    /// clone mutates the shared inner — effectively persisting the
943    /// choice on `self.proxy`.
944    pub fn filter_mode(self, mode: TreeFilterMode) -> Self {
945        if let Some(p) = &self.proxy {
946            let _ = p.clone().filter_mode(mode);
947        }
948        self
949    }
950
951    // ── Reactive signals ──────────────────────────────────────────────
952
953    /// Current vertical scroll offset in logical pixels.
954    pub fn scroll_y_signal(&self) -> &Signal<f32> {
955        &self.scroll_y
956    }
957
958    /// Maximum vertical scroll offset (content height − viewport height).
959    pub fn max_scroll_y_signal(&self) -> &Signal<f32> {
960        &self.max_scroll_y
961    }
962
963    /// Viewport-to-content height ratio — drives the scrollbar thumb size.
964    pub fn viewport_ratio_y_signal(&self) -> &Signal<f32> {
965        &self.viewport_ratio_y
966    }
967
968    /// Current horizontal scroll offset of the Middle (unpinned) pane, in
969    /// logical pixels. Leading/Trailing-pinned columns are unaffected.
970    pub fn scroll_x_signal(&self) -> &Signal<f32> {
971        &self.scroll_x
972    }
973
974    /// Maximum horizontal scroll offset — `middle_content_width −
975    /// middle_viewport_width`.
976    pub fn max_scroll_x_signal(&self) -> &Signal<f32> {
977        &self.max_scroll_x
978    }
979
980    /// Middle-pane viewport-to-content width ratio.
981    pub fn viewport_ratio_x_signal(&self) -> &Signal<f32> {
982        &self.viewport_ratio_x
983    }
984
985    /// Active sort state: `Some((col_id, direction))` or `None` for unsorted.
986    ///
987    /// **This is the header's state, not the data's.** Clicking a sort header
988    /// writes here; nothing reorders rows until you bind this onto the backing
989    /// projection yourself:
990    ///
991    /// ```ignore
992    /// let proxy = SortFilterTreeModel::new(tree)
993    ///     .with_comparator("name", |a: &Row, b: &Row| a.name.cmp(&b.name));
994    /// proxy.sort_signal(view.sort_signal().clone());
995    /// ```
996    ///
997    /// The binding is deliberately not automatic: a projection may already
998    /// carry preset comparators, predicates, and a filter mode, and adopting
999    /// the view's empty signal at construction would clobber them.
1000    pub fn sort_signal(&self) -> &Signal<Option<(String, SortDirection)>> {
1001        &self.sort_signal
1002    }
1003
1004    /// Active per-column filters keyed by column id.
1005    ///
1006    /// Like [`sort_signal`](Self::sort_signal), this holds the header's state
1007    /// only — bind it onto the projection to actually filter rows:
1008    ///
1009    /// ```ignore
1010    /// let proxy = SortFilterTreeModel::new(tree)
1011    ///     .with_predicate("name", |t| {
1012    ///         let needle = t.to_string();
1013    ///         Box::new(move |r: &Row| r.name.contains(&needle))
1014    ///     });
1015    /// proxy.filters_signal(view.filters_signal().clone());
1016    /// ```
1017    pub fn filters_signal(&self) -> &Signal<HashMap<String, String>> {
1018        &self.filters_signal
1019    }
1020
1021    /// Current column widths in logical pixels, keyed by column id.
1022    pub fn column_widths_signal(&self) -> &Signal<HashMap<String, f32>> {
1023        &self.column_widths_signal
1024    }
1025
1026    /// Current column display order as a list of column ids.
1027    pub fn column_order_signal(&self) -> &Signal<Vec<String>> {
1028        &self.column_order_signal
1029    }
1030
1031    /// Keyboard-focused cell as `(row, display_column_index)`, or `None`.
1032    pub fn focused_cell_signal(&self) -> &Signal<Option<(usize, usize)>> {
1033        &self.focused_cell
1034    }
1035
1036    /// Cell currently being edited as `(row, display_column_index)`, or `None`.
1037    pub fn editing_cell_signal(&self) -> &Signal<Option<(usize, usize)>> {
1038        &self.editing_cell
1039    }
1040
1041    /// The widget realized for the cell at `(row, display column)` in the body
1042    /// pane's latest build, or `None` once it has scrolled (or collapsed) out
1043    /// of the realized buffer — `cell_map` is a snapshot, not an index of every
1044    /// row the source holds, so a miss here means "not on screen", never "no
1045    /// such cell".
1046    fn realized_cell(&self, row: usize, col: usize) -> Option<WidgetId> {
1047        self.cell_map
1048            .borrow()
1049            .iter()
1050            .find(|&&(pos, _)| pos == (row, col))
1051            .map(|&(_, id)| id)
1052    }
1053
1054    /// Access the underlying `SortFilterTreeModel` (for programmatic sort /
1055    /// filter / expand outside of the builder API).
1056    /// `None` when the view was built from an external
1057    /// [`teksilo_data::TreeDataSource`] via
1058    /// [`from_source`](Self::from_source) — there is no `TreeModel`-backed
1059    /// projection to hand back in that case.
1060    pub fn projection(&self) -> Option<&SortFilterTreeModel<T>> {
1061        self.proxy.as_ref()
1062    }
1063
1064    // ── Imperative API ─────────────────────────────────────────────────
1065
1066    /// Expand the subtree rooted at `node`.
1067    pub fn expand(&self, node: NodeId) {
1068        if let Some(p) = &self.proxy {
1069            p.expand(node);
1070        }
1071    }
1072
1073    /// Collapse the subtree rooted at `node`.
1074    pub fn collapse(&self, node: NodeId) {
1075        if let Some(p) = &self.proxy {
1076            p.collapse(node);
1077        }
1078    }
1079
1080    /// Toggle the expand/collapse state of `node`.
1081    pub fn toggle(&self, node: NodeId) {
1082        if let Some(p) = &self.proxy {
1083            p.toggle(node);
1084        }
1085    }
1086
1087    /// Expand all nodes in the tree.
1088    pub fn expand_all(&self) {
1089        if let Some(p) = &self.proxy {
1090            p.expand_all();
1091        }
1092    }
1093
1094    /// Collapse all nodes in the tree.
1095    pub fn collapse_all(&self) {
1096        if let Some(p) = &self.proxy {
1097            p.collapse_all();
1098        }
1099    }
1100
1101    /// Move keyboard focus to the cell at `(row, col)`.
1102    pub fn set_focused_cell(&self, row: usize, col: usize) {
1103        self.focused_cell.set(Some((row, col)));
1104    }
1105
1106    /// Clear the keyboard-focused cell.
1107    pub fn clear_focused_cell(&self) {
1108        self.focused_cell.set(None);
1109    }
1110
1111    /// Programmatically sort by `col_id` (pass `None` to clear the sort).
1112    ///
1113    /// Equality-guarded, like every persisted-layout setter here — see
1114    /// [`set_column_widths`](Self::set_column_widths).
1115    pub fn set_sort(&self, col_id: Option<&str>, dir: SortDirection) {
1116        imperative::set_if_changed(&self.sort_signal, col_id.map(|c| (c.to_string(), dir)));
1117    }
1118
1119    /// Set or clear the filter text for a single column.
1120    pub fn set_filter(&self, col_id: &str, text: &str) {
1121        imperative::set_filter(&self.filters_signal, col_id, text);
1122    }
1123
1124    pub fn clear_filters(&self) {
1125        imperative::set_if_changed(&self.filters_signal, HashMap::new());
1126    }
1127
1128    /// Widget shown when no rows are visible — an empty tree, or a filter
1129    /// that matched nothing. Without one, the body region is simply blank.
1130    pub fn empty_view(mut self, f: impl Fn() -> Box<dyn Widget> + 'static) -> Self {
1131        self.empty_view = Some(Rc::new(f));
1132        self
1133    }
1134
1135    /// Clear the active sort.
1136    pub fn clear_sort(&self) {
1137        imperative::set_if_changed(&self.sort_signal, None);
1138    }
1139
1140    /// Scroll so that `row` is aligned to the top of the viewport. A no-op
1141    /// before the first layout pass.
1142    pub fn scroll_to_row(&self, row: usize) {
1143        if !self.laid_out.get() {
1144            return;
1145        }
1146        imperative::scroll_to_row(row, &self.row_metrics, &self.scroll_y, &self.max_scroll_y);
1147    }
1148
1149    /// Scroll the minimum distance needed to make `row` visible. A no-op
1150    /// before the first layout pass, when the viewport height is not yet known.
1151    pub fn ensure_row_visible(&self, row: usize) {
1152        imperative::ensure_row_visible(
1153            row,
1154            &self.row_metrics,
1155            &self.scroll_y,
1156            &self.max_scroll_y,
1157            self.viewport_height.get(),
1158            self.laid_out.get(),
1159        );
1160    }
1161
1162    /// Scroll the row the keyboard cursor sits on into view when this view
1163    /// takes focus.
1164    ///
1165    /// Only the rows near the viewport are realized, so on a tree taller than
1166    /// the window the cursor row frequently has no widget. Everything that
1167    /// speaks for it then has nothing to speak about: no cell node exists, so
1168    /// `accessibility()` below finds nothing in `cell_map` and nominates no
1169    /// `active_descendant`, and a screen reader taking focus here is told
1170    /// nothing at all. The first arrow press steps *past* that row as well,
1171    /// because the cursor was somewhere the user was never shown.
1172    ///
1173    /// The cursor is read exactly as the shared keyboard handler reads it
1174    /// (`table_view::keyboard::build_key_handler`, `keyboard.rs:134-139`): the
1175    /// focused cell's row, else the first selected row. Anything else would
1176    /// reveal a row the next arrow press does not step from.
1177    ///
1178    /// That row index is a **flat visible** index, not a position in the
1179    /// unflattened tree: a collapsed node's descendants have no index at all
1180    /// here. Checked on both sides of the read. `focused_cell` is clamped to
1181    /// `TreeNavigator::row_count()`, which returns `TreeSource::visible_count()`
1182    /// (`tree_table_view.rs:129-131`), and the keyed selection facade builds
1183    /// its indices by scanning `0..visible_count()` through
1184    /// `SortFilterTreeModel::visible_node_id` (`data_views.rs:545-552`). On the
1185    /// spending side, `RowMetrics` is sized by `place_children` from that same
1186    /// `visible_count()`, so `row_top(i)` is the top of the *i*-th visible row.
1187    ///
1188    /// `ensure_row_visible`, the view's own imperative path, rather than
1189    /// `scroll_to_row`: a row already on screen must not jump under somebody
1190    /// who can see it. It carries the `laid_out` guard too, so a focus that
1191    /// arrives before the first real height is a no-op instead of scrolling
1192    /// against a viewport that was never measured. It is the same
1193    /// `RowMetrics::scroll_for_ensure_visible` arithmetic the keyboard runs on
1194    /// every arrow press; what the keyboard's own wrapper
1195    /// (`table_view/keyboard.rs:445`) adds on top is chasing the row into an
1196    /// *enclosing* scroll area, and that needs an `EventContext`, which an
1197    /// effect does not have. Nothing is lost: the same keyboard or programmatic
1198    /// focus change makes the framework reveal the newly focused widget in
1199    /// every ancestor scroll area itself (`focus_impl.rs:95-96`,
1200    /// `WidgetTree::scroll_focused_into_view`), so the enclosing viewport is
1201    /// somebody else's job here.
1202    ///
1203    /// The handles are cloned into the effect rather than reaching through
1204    /// `self`, which the closure cannot borrow.
1205    fn reveal_current_row_on_focus(&self, ctx: &mut BuildContext) {
1206        let focused_cell = self.focused_cell.clone();
1207        let selection = self.row_selection.clone();
1208        let row_metrics = self.row_metrics.clone();
1209        let scroll_y = self.scroll_y.clone();
1210        let max_scroll_y = self.max_scroll_y.clone();
1211        let viewport_height = self.viewport_height.clone();
1212        let laid_out = self.laid_out.clone();
1213
1214        ctx.effect(&self.view_focused, move |focused| {
1215            if !*focused {
1216                return;
1217            }
1218            let Some(row) = focused_cell.get().map(|(row, _col)| row).or_else(|| {
1219                selection
1220                    .as_ref()
1221                    .and_then(|s| s.selected_indices().first().copied())
1222            }) else {
1223                return;
1224            };
1225            imperative::ensure_row_visible(
1226                row,
1227                &row_metrics,
1228                &scroll_y,
1229                &max_scroll_y,
1230                viewport_height.get(),
1231                laid_out.get(),
1232            );
1233        });
1234    }
1235
1236    /// Set or remove a single column's user-resized width override.
1237    /// A non-positive `width` removes the entry (the column reverts to
1238    /// its declared width policy).
1239    pub fn set_column_width(&self, col_id: &str, width: f32) {
1240        imperative::set_column_width(&self.column_widths_signal, col_id, width);
1241    }
1242
1243    /// Replace the full width-override map (typically used to restore
1244    /// a persisted layout).
1245    ///
1246    /// Equality-guarded for the same reason as
1247    /// [`TableView::set_column_widths`](crate::TableView::set_column_widths):
1248    /// the documented settings round-trip would otherwise recurse without
1249    /// bound on the first tick of a live resize drag.
1250    pub fn set_column_widths(&self, widths: HashMap<String, f32>) {
1251        imperative::set_column_widths(&self.column_widths_signal, widths);
1252    }
1253
1254    /// Replace the column-order list. Ids not declared on this table
1255    /// are silently dropped on the next layout pass.
1256    pub fn set_column_order(&self, order: Vec<String>) {
1257        imperative::set_if_changed(&self.column_order_signal, order);
1258    }
1259
1260    /// Current column pinning overrides, keyed by column id. Wins over
1261    /// each column's declared [`Column::pinned`].
1262    pub fn column_pinning_signal(&self) -> &Signal<HashMap<String, PinnedSide>> {
1263        &self.column_pinning_signal
1264    }
1265
1266    /// Pin or unpin a single column. [`PinnedSide::None`] removes the
1267    /// override, reverting the column to its declared pinning.
1268    pub fn set_column_pinning(&self, col_id: &str, side: PinnedSide) {
1269        imperative::set_column_pinning(&self.column_pinning_signal, col_id, side);
1270    }
1271
1272    /// Begin editing the cell `(row, col_id)`. Silently no-ops if `col_id`
1273    /// isn't a currently-displayed column, or if `row` is outside the visible
1274    /// range — an out-of-range target would otherwise strand `editing_cell` on
1275    /// a row nothing can match.
1276    ///
1277    /// Callable **before the view is mounted**, which is the only point at
1278    /// which a consumer can seed a freshly constructed view with an edit
1279    /// target it already holds. `display_indices` is a cache `build()` fills,
1280    /// so a pre-mount call finds it empty; the order is recomputed on demand
1281    /// in that case rather than resolving against nothing and no-opping for a
1282    /// third, undocumented reason.
1283    pub fn begin_edit(&self, row: usize, col_id: &str) {
1284        let cached = self.display_indices.borrow();
1285        let recomputed;
1286        let display: &[usize] = if cached.is_empty() {
1287            recomputed = self.display_order();
1288            &recomputed
1289        } else {
1290            &cached
1291        };
1292        if let Some(target) = imperative::resolve_edit_target(
1293            row,
1294            col_id,
1295            &self.columns,
1296            display,
1297            self.source.visible_count(),
1298        ) {
1299            drop(cached);
1300            self.editing_cell.set(Some(target));
1301        }
1302    }
1303
1304    /// Close the active cell editor without committing (the field's `on_blur` still fires).
1305    pub fn end_edit(&self) {
1306        self.editing_cell.set(None);
1307    }
1308
1309    // ── Internals ──────────────────────────────────────────────────────
1310
1311    fn effective_row_height(&self) -> f32 {
1312        self.row_height.unwrap_or(cp::ROW_HEIGHT)
1313    }
1314
1315    fn effective_header_height(&self) -> f32 {
1316        if self.show_header {
1317            self.header_height.unwrap_or(cp::HEADER_HEIGHT)
1318        } else {
1319            0.0
1320        }
1321    }
1322
1323    fn effective_indent(&self) -> f32 {
1324        self.indent_per_level.unwrap_or(cp::TREE_INDENT_PER_LEVEL)
1325    }
1326
1327    /// Resolve the tree column id to a declaration index. Falls back
1328    /// to column 0 when the configured id isn't found or unset.
1329    fn tree_column_decl_index(&self) -> usize {
1330        if let Some(ref id) = self.tree_column_id {
1331            for (i, col) in self.columns.iter().enumerate() {
1332                if &col.id == id {
1333                    return i;
1334                }
1335            }
1336        }
1337        0
1338    }
1339
1340    fn display_order(&self) -> Vec<usize> {
1341        let order_signal = self.column_order_signal.get();
1342        let mut order_map: HashMap<&str, usize> = HashMap::new();
1343        for (i, id) in order_signal.iter().enumerate() {
1344            order_map.insert(id.as_str(), i);
1345        }
1346        let mut leading: Vec<usize> = Vec::new();
1347        let mut middle: Vec<usize> = Vec::new();
1348        let mut trailing: Vec<usize> = Vec::new();
1349        for (i, col) in self.columns.iter().enumerate() {
1350            let pinning = self
1351                .column_pinning_signal
1352                .get()
1353                .get(&col.id)
1354                .copied()
1355                .unwrap_or(col.pinned);
1356            match pinning {
1357                PinnedSide::Leading => leading.push(i),
1358                PinnedSide::None => middle.push(i),
1359                PinnedSide::Trailing => trailing.push(i),
1360            }
1361        }
1362        const FALLBACK_BASE: usize = usize::MAX / 2;
1363        let cols = &self.columns;
1364        let key_for = |i: usize| {
1365            order_map
1366                .get(cols[i].id.as_str())
1367                .copied()
1368                .unwrap_or(FALLBACK_BASE + i)
1369        };
1370        leading.sort_by_key(|&i| key_for(i));
1371        middle.sort_by_key(|&i| key_for(i));
1372        trailing.sort_by_key(|&i| key_for(i));
1373        let mut out = Vec::with_capacity(leading.len() + middle.len() + trailing.len());
1374        out.extend(leading);
1375        let leading_count = out.len();
1376        out.extend(middle);
1377        let middle_end = out.len();
1378        out.extend(trailing);
1379        // Stash the boundaries so paint / place_children / the keyboard
1380        // handler's ensure-column-visible can read them — mirrors
1381        // `TableView::display_order`.
1382        *self.pane_boundaries.borrow_mut() =
1383            crate::table_view::PaneBoundaries::new(leading_count, middle_end);
1384        out
1385    }
1386
1387    fn clamp_scroll(&self) {
1388        let max = self.max_scroll_y.get();
1389        let current = self.scroll_y.get();
1390        let clamped = current.clamp(0.0, max);
1391        if (clamped - current).abs() > 0.001 {
1392            self.scroll_y.set(clamped);
1393        }
1394    }
1395
1396    /// Buffered realized range — mirrors `TableView::visible_range`. Used
1397    /// only to nudge the lazy source (`request_window`/`fetch_more`); the
1398    /// pane recomputes its own copy independently for actual row
1399    /// realization.
1400    fn visible_range(&self) -> (usize, usize) {
1401        self.row_metrics.borrow_mut().visible_range(
1402            self.scroll_y.get(),
1403            self.viewport_height.get(),
1404            self.source.visible_count(),
1405            BUFFER_ROWS,
1406        )
1407    }
1408}
1409
1410impl<T: 'static> std::fmt::Debug for TreeTableView<T> {
1411    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1412        f.debug_struct("TreeTableView")
1413            .field("rows", &self.source.visible_count())
1414            .field("columns", &self.columns.len())
1415            .field("tree_column", &self.tree_column_id)
1416            .field("scroll_bar_style", &self.scroll_bar_style)
1417            .finish()
1418    }
1419}
1420
1421impl<T: 'static> Widget for TreeTableView<T> {
1422    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1423        let self_id = ctx.self_id();
1424        ctx.enabled_when(self_id, self.enabled.clone());
1425
1426        let row_h = self.effective_row_height();
1427        let header_h = self.effective_header_height();
1428        let indent_per_level = self.effective_indent();
1429
1430        let version = ctx.signal(0_u64);
1431        version.bind_to(ctx.self_id(), ctx.binding_registry(), BindingLevel::Rebuild);
1432
1433        self.scroll_y.bind_to(
1434            ctx.self_id(),
1435            ctx.binding_registry(),
1436            BindingLevel::Relayout,
1437        );
1438        ctx.register_animated_signal(&self.scroll_y);
1439
1440        self.scroll_x.bind_to(
1441            ctx.self_id(),
1442            ctx.binding_registry(),
1443            BindingLevel::Relayout,
1444        );
1445        ctx.register_animated_signal(&self.scroll_x);
1446
1447        // Pane → root total refresh (auto-measure mode): re-place this
1448        // root when the body pane's measurements changed the content
1449        // total, so `max_scroll_y` / the thumb ratio pick up the
1450        // corrected value.
1451        self.pane_total_refresh.bind_to(
1452            ctx.self_id(),
1453            ctx.binding_registry(),
1454            BindingLevel::Relayout,
1455        );
1456
1457        self.column_widths_signal.bind_to(
1458            ctx.self_id(),
1459            ctx.binding_registry(),
1460            BindingLevel::Relayout,
1461        );
1462        // `OnRelease` resize guide line — paint-only, nothing moves until the
1463        // button comes up.
1464        self.resize_preview_x.bind_to(
1465            ctx.self_id(),
1466            ctx.binding_registry(),
1467            BindingLevel::RepaintOnly,
1468        );
1469
1470        // Abandon an in-flight resize when the window goes inactive — see
1471        // `TableView::build` for why the missing PointerUp would otherwise
1472        // leave the column dragging with no button held.
1473        {
1474            let resize_state = self.resize_state.clone();
1475            let resize_target = self.resize_target.clone();
1476            let resize_preview_x = self.resize_preview_x.clone();
1477            ctx.effect(&ctx.window_active_signal(), move |active| {
1478                if !*active && resize_state.borrow().is_some() {
1479                    *resize_state.borrow_mut() = None;
1480                    resize_target.set(None);
1481                    resize_preview_x.set(None);
1482                }
1483            });
1484        }
1485        self.focused_cell.bind_to(
1486            ctx.self_id(),
1487            ctx.binding_registry(),
1488            BindingLevel::RepaintOnly,
1489        );
1490        // Also at AccessibilityOnly (orthogonal — see `BindingLevel`) so a
1491        // keyboard focus move re-walks the AT tree and re-resolves
1492        // `active_descendant` in `accessibility()` below, even though
1493        // nothing about the cell's own node changed.
1494        self.focused_cell.bind_to(
1495            ctx.self_id(),
1496            ctx.binding_registry(),
1497            BindingLevel::AccessibilityOnly,
1498        );
1499
1500        // Focus-aware selection + modality-gated focus ring (mirrors TableView).
1501        // `begin_view_focus` keys the scope signal on this root id directly —
1502        // the same id the body pane uses for its row scope, and independent of
1503        // the arena focusable flag (not yet wired here). A plain
1504        // `view_focus_active()` would find no focusable ancestor and fall back
1505        // to the constant-`true` "outside any scope" signal, lighting the ring
1506        // whenever ANY widget takes focus. Pop straight back; the body pane
1507        // re-pushes the same cached signal. `focus_visible` is the
1508        // keyboard/pointer modality. Both `RepaintOnly`.
1509        self.view_focused = ctx.begin_view_focus();
1510        ctx.end_view_focus();
1511        self.focus_visible = ctx.focus_visible();
1512        self.reveal_current_row_on_focus(ctx);
1513        self.view_focused.bind_to(
1514            ctx.self_id(),
1515            ctx.binding_registry(),
1516            BindingLevel::RepaintOnly,
1517        );
1518        self.focus_visible.bind_to(
1519            ctx.self_id(),
1520            ctx.binding_registry(),
1521            BindingLevel::RepaintOnly,
1522        );
1523        // Row-drop insertion indicator at RepaintOnly so on_drag_hover /
1524        // on_drag_leave `set(...)` calls dirty paint without a rebuild.
1525        self.drop_feedback.bind_to(
1526            ctx.self_id(),
1527            ctx.binding_registry(),
1528            BindingLevel::RepaintOnly,
1529        );
1530
1531        // Bump version on projection version (data + sort/filter +
1532        // expand/collapse all in one signal). Proxy observers fire
1533        // synchronously per rebuild, so `first_changed_index()`
1534        // describes exactly this change — heights of flat rows before
1535        // it (e.g. above an expand/collapse point) stay valid.
1536        let v_for_proj = version.clone();
1537        let proj_ver = Rc::new(Cell::new(0_u64));
1538        let prev_visible_count = Rc::new(Cell::new(self.source.visible_count()));
1539        ctx.effect(&self.source.version_signal(), {
1540            let metrics = self.row_metrics.clone();
1541            let src = self.source.clone();
1542            let row_sel = self.row_selection.clone();
1543            let cell_sel = self.cell_selection.clone();
1544            let prev_visible_count = prev_visible_count.clone();
1545            move |_| {
1546                metrics
1547                    .borrow_mut()
1548                    .apply_divergence(src.first_changed_index(), src.visible_count());
1549                // Drop any keyed selection whose node was deleted (no-op for
1550                // the index model). Cheap; runs on every projection change.
1551                if let Some(ref rs) = row_sel {
1552                    rs.prune();
1553                }
1554                // Cell selection is index-based (unlike the keyed row
1555                // selection above), and a `TreeDataSource`'s flattening
1556                // collapses every structural change — expand/collapse,
1557                // insert/remove, a re-sort — into one version bump with no
1558                // per-change delta to follow, unlike `TableView`'s
1559                // `ListModel` `DataChange` granularity. A changed visible
1560                // row count is a structural signal we CAN act on
1561                // honestly: clear the selection rather than let it point
1562                // at whatever node now occupies that flat index. Leave it
1563                // alone when the count is unchanged — a content-only
1564                // update (e.g. an in-place item edit) never moves a row,
1565                // and clearing on every projection bump would drop the
1566                // selection on a plain data refresh.
1567                let new_visible_count = src.visible_count();
1568                if let Some(ref cs) = cell_sel
1569                    && new_visible_count != prev_visible_count.get()
1570                {
1571                    cs.clear();
1572                }
1573                prev_visible_count.set(new_visible_count);
1574                let next = proj_ver.get() + 1;
1575                proj_ver.set(next);
1576                v_for_proj.set(next);
1577            }
1578        });
1579
1580        // Sort + filter signals are NOT auto-bound onto the proxy.
1581        // The proxy may already carry preset comparators/predicates
1582        // and a custom filter mode; auto-binding would clobber them.
1583        // Callers wire the proxy explicitly:
1584        //
1585        //   proxy.sort_signal(tree_table.sort_signal().clone());
1586        //   proxy.filters_signal(tree_table.filters_signal().clone());
1587        //
1588        // Documented in the module-level comment.
1589
1590        let v_for_sort = version.clone();
1591        let sv = Rc::new(Cell::new(0_u64));
1592        ctx.effect(&self.sort_signal, move |_| {
1593            let next = sv.get() + 1;
1594            sv.set(next);
1595            v_for_sort.set(next);
1596        });
1597        let v_for_order = version.clone();
1598        let ov = Rc::new(Cell::new(0_u64));
1599        ctx.effect(&self.column_order_signal, move |_| {
1600            let next = ov.get() + 1;
1601            ov.set(next);
1602            v_for_order.set(next);
1603        });
1604        let v_for_pin = version.clone();
1605        let pv = Rc::new(Cell::new(0_u64));
1606        ctx.effect(&self.column_pinning_signal, move |_| {
1607            let next = pv.get() + 1;
1608            pv.set(next);
1609            v_for_pin.set(next);
1610        });
1611        // Selection / focus / editing effects live on the TreeBodyPane
1612        // (they only affect row content) — rebuilding the pane instead
1613        // of the root keeps those rebuilds out of the scrollbar's
1614        // ancestor chain during a thumb drag.
1615
1616        // Display order.
1617        let display_indices = self.display_order();
1618
1619        // Remap any `(row, display_pos)` pairs the *previous* order left in
1620        // `focused_cell` / `editing_cell` / `cell_selection` onto their
1621        // column's position under the order just computed, before it
1622        // overwrites `self.display_indices` below. See the identical block
1623        // in `TableView::build` for why this is a no-op unless THIS
1624        // rebuild's cause was a column reorder/pinning change.
1625        {
1626            let old_display = self.display_indices.borrow();
1627            if !old_display.is_empty() {
1628                let old_to_new: Vec<Option<usize>> = old_display
1629                    .iter()
1630                    .map(|&decl_idx| {
1631                        let id = &self.columns[decl_idx].id;
1632                        display_indices
1633                            .iter()
1634                            .position(|&new_decl_idx| self.columns[new_decl_idx].id == *id)
1635                    })
1636                    .collect();
1637                drop(old_display);
1638                imperative::remap_cell_state(
1639                    &self.focused_cell,
1640                    &self.editing_cell,
1641                    self.cell_selection.as_ref(),
1642                    &old_to_new,
1643                );
1644            }
1645        }
1646        *self.display_indices.borrow_mut() = display_indices.clone();
1647        let tree_decl = self.tree_column_decl_index();
1648        let tree_display_pos = display_indices
1649            .iter()
1650            .position(|&i| i == tree_decl)
1651            .unwrap_or(0);
1652
1653        // Self handlers: scroll wheel + keyboard.
1654        let scroll_y_for_wheel = self.scroll_y.clone();
1655        let max_scroll_for_wheel = self.max_scroll_y.clone();
1656        let scroll_x_for_wheel = self.scroll_x.clone();
1657        let max_scroll_x_for_wheel = self.max_scroll_x.clone();
1658        let line_height = row_h;
1659        let smooth_scrolling = self.smooth_scrolling;
1660        let smooth_scroll_duration = self.smooth_scroll_duration;
1661
1662        let column_ids_in_display_order: Vec<String> = display_indices
1663            .iter()
1664            .map(|&i| self.columns[i].id.clone())
1665            .collect();
1666        let display_col_to_id: Rc<dyn Fn(usize) -> Option<String>> = {
1667            let ids = column_ids_in_display_order;
1668            Rc::new(move |pos| ids.get(pos).cloned())
1669        };
1670        // The effective trigger set per display column: the view's, overridden
1671        // by the column's own, and `NONE` for a non-editable one. Resolved here
1672        // so the keyboard handler never has to reach a `Column<T>`.
1673        let display_col_triggers: Rc<dyn Fn(usize) -> EditTriggers> = {
1674            let view_triggers = self.edit_triggers;
1675            let per_display_column: Vec<EditTriggers> = display_indices
1676                .iter()
1677                .map(|&i| self.columns[i].effective_edit_triggers(view_triggers))
1678                .collect();
1679            Rc::new(move |pos| {
1680                per_display_column
1681                    .get(pos)
1682                    .copied()
1683                    .unwrap_or(EditTriggers::NONE)
1684            })
1685        };
1686
1687        let navigator: Rc<dyn RowNavigator> = Rc::new(TreeNavigator::new(self.source.clone()));
1688        // Type-ahead resolver: read the visible row's item text through the
1689        // projection (`None` if the flat index isn't currently visible).
1690        let type_ahead_label: Option<Rc<dyn Fn(usize) -> Option<String>>> =
1691            self.type_ahead_label.clone().map(|user| {
1692                let src = self.source.clone();
1693                Rc::new(move |i: usize| src.with_row_str(i, &|item| user(item)))
1694                    as Rc<dyn Fn(usize) -> Option<String>>
1695            });
1696
1697        let key_cfg = keyboard::KeyHandlerConfig {
1698            navigator,
1699            col_count: display_indices.len().max(1),
1700            // The same resolved position the twist and indent gutter render at
1701            // (see `tree_display_pos` above), so the arrow keys keep following
1702            // the chevron when `.tree_column()` or a user column-reorder moves
1703            // it off the leading position.
1704            tree_column_display_pos: tree_display_pos,
1705            focused_cell: self.focused_cell.clone(),
1706            selection_mode: self.selection_mode,
1707            selection: self.row_selection.clone(),
1708            cell_selection: self.cell_selection.clone(),
1709            scroll_y: self.scroll_y.clone(),
1710            max_scroll_y: self.max_scroll_y.clone(),
1711            viewport_height: self.viewport_height.clone(),
1712            body_bounds: self.body_bounds.clone(),
1713            row_metrics: self.row_metrics.clone(),
1714            tab_traversal: self.tab_traversal,
1715            editing_cell: self.editing_cell.clone(),
1716            display_col_to_id,
1717            display_col_triggers,
1718            on_cell_edit_request: self.on_cell_edit_request.clone(),
1719            on_row_activate: self.on_row_activate.clone(),
1720            type_ahead: self.type_ahead.clone(),
1721            type_ahead_label,
1722            type_ahead_timeout: self.type_ahead_timeout,
1723            column_widths: self.column_widths.clone(),
1724            pane_boundaries: *self.pane_boundaries.borrow(),
1725            scroll_x: self.scroll_x.clone(),
1726            max_scroll_x: self.max_scroll_x.clone(),
1727            middle_viewport_width: self.middle_viewport_width.clone(),
1728        };
1729
1730        // Alt+Arrow tree sibling reorder wraps the shared key handler: a move
1731        // among the node's siblings in the underlying `TreeModel` (cycle-free
1732        // by construction). Suppressed while sorted. Every other key falls
1733        // through to the navigator (cell/row movement, expand/collapse, edit).
1734        let mut shared_key = keyboard::build_key_handler(key_cfg);
1735        let reorderable_kbd = self.reorderable;
1736        let source_kbd = self.source.clone();
1737        let focused_kbd = self.focused_cell.clone();
1738        let sel_kbd = self.row_selection.clone();
1739        let sort_kbd = self.sort_signal.clone();
1740        let key_handler = move |event: &teksilo_core::event::WidgetEvent,
1741                                ctx: &mut EventContext|
1742              -> EventResponse {
1743            use teksilo_core::event::{Key, WidgetEvent};
1744            if reorderable_kbd
1745                && sort_kbd.get().is_none()
1746                && let WidgetEvent::KeyDown { key, modifiers, .. } = event
1747                && modifiers.alt()
1748                && matches!(key, Key::ArrowUp | Key::ArrowDown)
1749            {
1750                let row = focused_kbd.get().map(|(r, _)| r).or_else(|| {
1751                    sel_kbd
1752                        .as_ref()
1753                        .and_then(|s| s.selected_indices().first().copied())
1754                });
1755                // Sibling reorder + the "follow the moved row" bookkeeping live
1756                // in the source (key-typed there, so it works for an external
1757                // store too) and hand back the row's new flat index.
1758                if let Some(flat_idx) = row
1759                    && let Some(new_flat) =
1760                        source_kbd.keyboard_reorder(flat_idx, matches!(key, Key::ArrowDown))
1761                {
1762                    let col = focused_kbd.get().map(|(_, c)| c).unwrap_or(0);
1763                    focused_kbd.set(Some((new_flat, col)));
1764                    if let Some(ref s) = sel_kbd {
1765                        s.select(new_flat);
1766                    }
1767                    return EventResponse::Handled;
1768                }
1769            }
1770            shared_key(event, ctx)
1771        };
1772
1773        let mut handlers = HandlerSet::new()
1774            .on_scroll({
1775                let overscroll_behavior = self.overscroll_behavior;
1776                move |event, _ctx| match event {
1777                    teksilo_core::event::WidgetEvent::Scroll { delta, modifiers } => {
1778                        let (raw_dx, raw_dy) = match delta {
1779                            teksilo_core::event::ScrollDelta::Lines { x, y } => {
1780                                (x * line_height, y * line_height)
1781                            }
1782                            teksilo_core::event::ScrollDelta::Pixels { x, y } => (*x, *y),
1783                        };
1784                        // Shift+wheel remaps a vertical-only wheel to
1785                        // horizontal scroll (the `TabBar` precedent).
1786                        let (dx, dy) = if modifiers.shift() && raw_dx.abs() < f32::EPSILON {
1787                            (raw_dy, 0.0)
1788                        } else {
1789                            (raw_dx, raw_dy)
1790                        };
1791
1792                        let mut moved_any = false;
1793                        if dy.abs() > 0.0 {
1794                            let current = scroll_y_for_wheel.get();
1795                            let max = max_scroll_for_wheel.get();
1796                            // Base off the animation target (not the rendered
1797                            // offset) so a mid-fling boundary correctly chains
1798                            // and successive notches accumulate instead of
1799                            // restarting from the partway-animated position.
1800                            let base = scroll_y_for_wheel.animation_target().unwrap_or(current);
1801                            let (new_y, moved) =
1802                                crate::common::scroll::scroll_clamp_axis(base, dy, max);
1803                            if moved {
1804                                if smooth_scrolling {
1805                                    scroll_y_for_wheel.animate_to(
1806                                        new_y,
1807                                        smooth_scroll_duration,
1808                                        Easing::EaseOut,
1809                                    );
1810                                } else {
1811                                    scroll_y_for_wheel.set(new_y);
1812                                }
1813                            }
1814                            moved_any |= moved;
1815                        }
1816                        if dx.abs() > 0.0 {
1817                            let current = scroll_x_for_wheel.get();
1818                            let max = max_scroll_x_for_wheel.get();
1819                            let base = scroll_x_for_wheel.animation_target().unwrap_or(current);
1820                            let (new_x, moved) =
1821                                crate::common::scroll::scroll_clamp_axis(base, dx, max);
1822                            if moved {
1823                                if smooth_scrolling {
1824                                    scroll_x_for_wheel.animate_to(
1825                                        new_x,
1826                                        smooth_scroll_duration,
1827                                        Easing::EaseOut,
1828                                    );
1829                                } else {
1830                                    scroll_x_for_wheel.set(new_x);
1831                                }
1832                            }
1833                            moved_any |= moved;
1834                        }
1835                        // Chain to an ancestor scrollable when fully
1836                        // clamped (unless Contain), otherwise consume —
1837                        // same contract as ListView/TreeView/TableView.
1838                        crate::common::scroll::scroll_response(
1839                            moved_any,
1840                            overscroll_behavior == OverscrollBehavior::Contain,
1841                        )
1842                    }
1843                    _ => EventResponse::Ignored,
1844                }
1845            })
1846            .on_key(key_handler)
1847            .clips_children(true)
1848            .focusable(true);
1849
1850        // Row DnD: same-view reorder (reorderable) reparents/reorders the
1851        // dragged node(s) in the underlying `TreeModel`, cycle-guarded and
1852        // suppressed while sorted; plus optional foreign receive
1853        // (accept_foreign_rows / on_foreign_drop). Registered whenever ANY
1854        // of the three capabilities is enabled — a foreign-receive-only view
1855        // (reorderable == false) still needs to be a drop target.
1856        // NOTE: row DnD is still `NodeId`-typed, so it is registered only on the
1857        // projection path. A source-backed view (`from_source`) gets every other
1858        // capability but no built-in row drag yet — routing this through
1859        // `source.dnd.{can_accept,accept_drop}_fn` (as `TreeView` already does)
1860        // is a follow-up, because those closures also carry Into/Before/After
1861        // redirect semantics this widget does not model yet.
1862        // Row DnD: same-view reorder/reparent plus foreign receive, both routed
1863        // through the source's `can_accept` / `accept_drop` capability closures
1864        // — so this works over a `TreeModel`-backed projection AND an external
1865        // `TreeDataSource`, exactly like `TreeView`. Drop zones are the row's
1866        // thirds (Before / Into / After); the source's verdict decides the
1867        // effective position and may `Redirect` (e.g. Into-a-leaf becomes
1868        // After). Suppressed while sorted, where a manual order has no meaning.
1869        if self.export.is_drop_target(self.reorderable) || self.on_foreign_drop.is_some() {
1870            let my_model_id = self.model_id;
1871            let source_for_hover = self.source.clone();
1872            let metrics_for_hover = self.row_metrics.clone();
1873            let scroll_for_hover = self.scroll_y.clone();
1874            let header_h_for_hover = header_h;
1875            let feedback_for_hover = self.drop_feedback.clone();
1876            let sort_for_hover = self.sort_signal.clone();
1877            let reorderable_hover = self.reorderable;
1878            let export_for_hover = self.export.clone();
1879            let has_foreign_hook_hover = self.on_foreign_drop.is_some();
1880            let bounds_for_hover = self.body_bounds.clone();
1881            handlers = handlers.on_drag_hover(move |payload, position, _ctx| {
1882                // Column reorder is handled by the header strip
1883                // (`attach_header_reorder_handlers`); only row-level drops
1884                // get an insertion/into affordance here. Without this bail,
1885                // a `ColumnReorderDragData` dragged past the header into the
1886                // body would fall through to `on_foreign_drop` (which
1887                // accepts any payload type) and paint a row-drop visual for
1888                // a drag the header strip is already handling.
1889                if payload.has_typed::<ColumnReorderDragData>() {
1890                    feedback_for_hover.set(None);
1891                    return teksilo_core::DropFeedback::NoFeedback;
1892                }
1893                // Real body width, so the affordance spans the actual row area
1894                // rather than a placeholder.
1895                let viz_width = bounds_for_hover.get().width.max(1.0);
1896                let count = source_for_hover.visible_count();
1897                if count == 0 {
1898                    feedback_for_hover.set(None);
1899                    return teksilo_core::DropFeedback::NoFeedback;
1900                }
1901                let rd = payload.get_typed::<RowDragData<T>>();
1902                let is_same_view = rd.is_some_and(|r| r.source == my_model_id);
1903                let reorder_ok =
1904                    is_same_view && reorderable_hover && sort_for_hover.get().is_none();
1905                // The typed `accept_foreign_rows`/`on_rows_received` path can
1906                // only consume an EXPORT payload (items present); the raw
1907                // `on_foreign_drop` hook takes any foreign payload.
1908                let foreign_ok = !is_same_view
1909                    && (has_foreign_hook_hover
1910                        || export_for_hover.accepts_foreign_export(payload, my_model_id));
1911                if !reorder_ok && !foreign_ok {
1912                    feedback_for_hover.set(None);
1913                    return teksilo_core::DropFeedback::NoFeedback;
1914                }
1915                let scroll = scroll_for_hover.get().max(0.0);
1916                let content_y = position.y - header_h_for_hover + scroll;
1917                let (insertion_top, row_idx, row_top, row_h) = {
1918                    let mut m = metrics_for_hover.borrow_mut();
1919                    m.resize(count);
1920                    let ins = m.insertion_index(content_y);
1921                    let r = m.row_at(content_y);
1922                    (m.row_top(ins), r, m.row_top(r), m.row_height(r))
1923                };
1924                let y_in_row = content_y - row_top;
1925                let third = (row_h / 3.0).max(f32::EPSILON);
1926                let drop_pos = if y_in_row < third {
1927                    DropPosition::Before
1928                } else if y_in_row > 2.0 * third {
1929                    DropPosition::After
1930                } else {
1931                    DropPosition::Into
1932                };
1933                // The source owns the structural verdict — including the cycle
1934                // guard (a node may not land inside its own subtree), which used
1935                // to be re-derived here against the `TreeModel`.
1936                // `depth` rides along so `paint` can indent the affordance to
1937                // the level the dropped row lands at — see `TreeView`'s twin of
1938                // this block. A foreign drop lands at a flat index the view
1939                // cannot promise a nesting for, so it claims none: depth 0.
1940                let (effective, depth) = if reorder_ok {
1941                    match (source_for_hover.dnd.can_accept_fn)(
1942                        payload,
1943                        row_idx,
1944                        drop_pos,
1945                        my_model_id,
1946                    ) {
1947                        DropResponse::Reject => {
1948                            if !foreign_ok {
1949                                feedback_for_hover.set(None);
1950                                return teksilo_core::DropFeedback::NoFeedback;
1951                            }
1952                            (DropPosition::Before, 0)
1953                        }
1954                        DropResponse::Accept => (drop_pos, source_for_hover.depth(row_idx)),
1955                        DropResponse::Redirect(p) => (p, source_for_hover.depth(row_idx)),
1956                    }
1957                } else {
1958                    // A foreign source has no Into/reparent semantics to honor.
1959                    (DropPosition::Before, 0)
1960                };
1961                if effective == DropPosition::Into {
1962                    let top = row_top - scroll;
1963                    feedback_for_hover.set(Some(DropViz::Rect {
1964                        top,
1965                        height: row_h,
1966                        width: viz_width,
1967                        depth,
1968                    }));
1969                    teksilo_core::DropFeedback::HighlightRect {
1970                        rect: Rect::new(0.0, top, viz_width, row_h),
1971                        color: drop_into_tint(),
1972                    }
1973                } else {
1974                    let insertion_y = insertion_top - scroll;
1975                    feedback_for_hover.set(Some(DropViz::Line {
1976                        y: insertion_y,
1977                        width: viz_width,
1978                        depth,
1979                    }));
1980                    teksilo_core::DropFeedback::InsertionLine {
1981                        y: insertion_y,
1982                        width: viz_width,
1983                    }
1984                }
1985            });
1986
1987            let drop_model_id = self.model_id;
1988            let source_for_drop = self.source.clone();
1989            let metrics_for_drop = self.row_metrics.clone();
1990            let scroll_for_drop = self.scroll_y.clone();
1991            let header_h_for_drop = header_h;
1992            let feedback_for_drop = self.drop_feedback.clone();
1993            let sort_for_drop = self.sort_signal.clone();
1994            let reorderable_drop = self.reorderable;
1995            let on_foreign_for_drop = self.on_foreign_drop.clone();
1996            let proxy_for_foreign_hook = self.proxy.clone();
1997            let export_for_drop = self.export.clone();
1998            handlers = handlers.on_drop(move |mut payload, position, ctx| {
1999                feedback_for_drop.set(None);
2000                // See the matching bail in `on_drag_hover` above — a column
2001                // reorder drop is the header strip's, never the body's
2002                // (`on_foreign_drop` would otherwise swallow it).
2003                if payload.has_typed::<ColumnReorderDragData>() {
2004                    return false;
2005                }
2006                let count = source_for_drop.visible_count();
2007                if count == 0 {
2008                    return false;
2009                }
2010                let scroll = scroll_for_drop.get().max(0.0);
2011                let content_y = position.y - header_h_for_drop + scroll;
2012                let (flat_idx, row_top, row_h, ins) = {
2013                    let mut m = metrics_for_drop.borrow_mut();
2014                    m.resize(count);
2015                    let idx = m.row_at(content_y);
2016                    let ins = m.insertion_index(content_y);
2017                    (idx, m.row_top(idx), m.row_height(idx), ins)
2018                };
2019                let y_in_row = content_y - row_top;
2020                let third = (row_h / 3.0).max(f32::EPSILON);
2021                let drop_pos = if y_in_row < third {
2022                    DropPosition::Before
2023                } else if y_in_row > 2.0 * third {
2024                    DropPosition::After
2025                } else {
2026                    DropPosition::Into
2027                };
2028                let is_same_view = payload
2029                    .get_typed::<RowDragData<T>>()
2030                    .is_some_and(|rd| rd.source == drop_model_id);
2031                if is_same_view && (!reorderable_drop || sort_for_drop.get().is_some()) {
2032                    return false;
2033                }
2034                // The source applies the move (cycle-guarded, undo-aware for an
2035                // external store) and reports whether it took. Gated exactly as
2036                // `TreeView` does, so a foreign payload the source does NOT
2037                // recognise still reaches the `on_rows_received` sugar below.
2038                if (reorderable_drop || !is_same_view)
2039                    && (source_for_drop.dnd.accept_drop_fn)(
2040                        &payload,
2041                        flat_idx,
2042                        drop_pos,
2043                        drop_model_id,
2044                    )
2045                {
2046                    if is_same_view {
2047                        export_for_drop.note_self_reorder();
2048                    }
2049                    return true;
2050                }
2051                // Foreign payload: the typed receive sugar first, then the raw
2052                // escape hatch.
2053                if export_for_drop.foreign_receive(&mut payload, drop_model_id, ins, ctx) {
2054                    return true;
2055                }
2056                // `on_foreign_drop` predates the source path and is
2057                // `NodeId`-typed, so it only fires when there is a projection to
2058                // resolve the target node through.
2059                if let Some(ref hook) = on_foreign_for_drop
2060                    && let Some(ref p) = proxy_for_foreign_hook
2061                    && let Some(node) = p.visible_node_id(flat_idx)
2062                {
2063                    return hook(&payload, node, drop_pos, ctx);
2064                }
2065                false
2066            });
2067
2068            let feedback_for_leave = self.drop_feedback.clone();
2069            handlers = handlers.on_drag_leave(move |_ctx| {
2070                feedback_for_leave.set(None);
2071            });
2072
2073            let scroll_for_tick = self.scroll_y.clone();
2074            let max_scroll_for_tick = self.max_scroll_y.clone();
2075            let viewport_for_tick = self.viewport_height.clone();
2076            let header_h_for_tick = header_h;
2077            handlers = handlers.on_drag_tick(move |pos, _ctx| {
2078                // Auto-scroll near the body band's top/bottom edge during a
2079                // drag (body-relative so the header doesn't count as the top).
2080                const EDGE: f32 = 32.0;
2081                const MAX_VELOCITY: f32 = 12.0;
2082                let body_h = (viewport_for_tick.get() - header_h_for_tick).max(0.0);
2083                let y = pos.y - header_h_for_tick;
2084                let above = (EDGE - y).max(0.0);
2085                let below = (y - (body_h - EDGE)).max(0.0);
2086                let delta = if above > 0.0 {
2087                    -(above / EDGE) * MAX_VELOCITY
2088                } else if below > 0.0 {
2089                    (below / EDGE) * MAX_VELOCITY
2090                } else {
2091                    0.0
2092                };
2093                if delta.abs() > 0.01 {
2094                    let max = max_scroll_for_tick.get();
2095                    let new_y = (scroll_for_tick.get() + delta).clamp(0.0, max);
2096                    scroll_for_tick.set(new_y);
2097                }
2098            });
2099        }
2100
2101        // Export completion (move-out): fires on the drag source — this
2102        // view's root id, the stable id `start_drag` is given via the body
2103        // pane's `drag_anchor`. A same-view reorder called
2104        // `self.export.note_self_reorder()` in `on_drop` above, so it is
2105        // skipped here (already applied). Absent an
2106        // `on_rows_transferred_out` override, the default move-out runs the
2107        // stable-`NodeId` removal thunk `TreeBodyPane::build`'s `on_drag`
2108        // resolved at drag-start (ascending pre-order, so an already-removed
2109        // descendant of another dragged node is safely skipped).
2110        handlers = self.export.install_completion(handlers);
2111
2112        ctx.apply_self_handlers(handlers);
2113
2114        // ── Build children ────────────────────────────────────────────
2115
2116        self.header_row_id = None;
2117        self.body_pane_id = None;
2118        self.scrollbar_id = None;
2119        self.h_scrollbar_id = None;
2120        self.empty_id = None;
2121
2122        // Header strip.
2123        if self.show_header {
2124            // See `TableView::build`: a rebuild drops the pointer capture an
2125            // in-flight resize rides on, so the shared drag state must go with
2126            // it or a later bare PointerMove would resize with no button held.
2127            *self.resize_state.borrow_mut() = None;
2128            self.resize_target.set(None);
2129            self.resize_preview_x.set(None);
2130
2131            let boundaries = *self.pane_boundaries.borrow();
2132            let resize_columns: ColumnResizeTable = Rc::new(
2133                display_indices
2134                    .iter()
2135                    .map(|&i| {
2136                        let c = &self.columns[i];
2137                        ColumnResizeInfo {
2138                            id: c.id.clone(),
2139                            min_width: c.min_width.unwrap_or(cp::MIN_COLUMN_WIDTH_DEFAULT),
2140                            max_width: c.max_width,
2141                            resizable: c.resizable,
2142                        }
2143                    })
2144                    .collect(),
2145            );
2146            let mut cell_ids: Vec<WidgetId> = Vec::with_capacity(display_indices.len());
2147            let active_sort = self.sort_signal.get();
2148            for (display_pos, &col_idx) in display_indices.iter().enumerate() {
2149                let col = &self.columns[col_idx];
2150                let current_sort = active_sort
2151                    .as_ref()
2152                    .and_then(|(id, dir)| if id == &col.id { Some(*dir) } else { None });
2153                let filter_zone_width = cp::FILTER_INDICATOR_SIZE + cp::CELL_PADDING_HORIZONTAL;
2154                let cell = HeaderCell::new(HeaderCellSpec {
2155                    col_id: col.id.clone(),
2156                    label: col.header_label.resolve_now(),
2157                    col_index_1based: display_pos + 1,
2158                    sortable: col.sortable,
2159                    reorderable: col.reorderable,
2160                    filterable: col.filterable,
2161                    resize_grip: cp::RESIZE_HANDLE_WIDTH,
2162                    filter_zone_width,
2163                    current_sort,
2164                    width_index: display_pos,
2165                    pane_boundaries: boundaries,
2166                    resize_columns: resize_columns.clone(),
2167                    resize_policy: self.column_resize_policy,
2168                    resize_state: self.resize_state.clone(),
2169                    resize_target: self.resize_target.clone(),
2170                    resize_preview_x: self.resize_preview_x.clone(),
2171                    table_id: self.table_id,
2172                    sort_signal: self.sort_signal.clone(),
2173                    column_widths_signal: self.column_widths_signal.clone(),
2174                    column_widths: self.column_widths.clone(),
2175                    filters_signal: self.filters_signal.clone(),
2176                });
2177                cell_ids.push(ctx.add(cell));
2178            }
2179            let header_row = HeaderRow::new(
2180                cell_ids,
2181                self.column_widths.clone(),
2182                cp::GRID_LINE_THICKNESS,
2183                *self.pane_boundaries.borrow(),
2184                self.scroll_x.clone(),
2185            );
2186            // Wire reorder drag-target handlers on the header strip — the
2187            // shared drop-target half of the mechanism `HeaderCell` already
2188            // escalates a press into (see `table_view::header`). The tree
2189            // column reorders like any other column: it carries no special
2190            // case here, since `tree_display_pos` (re-resolved from
2191            // `display_indices` on every rebuild — see below) is what makes
2192            // the indent/twist gutter and Left/Right expand-collapse follow
2193            // it wherever the drop lands, including into the leading- or
2194            // trailing-pinned pane.
2195            let header_row_id = ctx.add(header_row);
2196            attach_header_reorder_handlers(
2197                ctx,
2198                header_row_id,
2199                self.table_id,
2200                self.column_widths.clone(),
2201                self.display_indices.clone(),
2202                self.pane_boundaries.clone(),
2203                self.column_order_signal.clone(),
2204                self.column_pinning_signal.clone(),
2205                self.columns.iter().map(|c| c.id.clone()).collect(),
2206                self.header_strip_width.clone(),
2207                self.scroll_x.clone(),
2208            );
2209            self.header_row_id = Some(header_row_id);
2210        }
2211
2212        // Body rows live in a TreeBodyPane — a sibling of the
2213        // scrollbar, so buffer-exit / selection / editing / expand
2214        // rebuilds target the pane and are never deferred by the
2215        // gesture-capture protection during a thumb drag.
2216        let row_count = self.source.visible_count();
2217
2218        // Lazy: nudge the source to load the realized window, and fetch
2219        // the next page as the viewport nears the end (append-only
2220        // sources). `TreeSource` already erases a `TreeDataSource`'s
2221        // `row_state`/`request_window`/`can_fetch_more`/`fetch_more`
2222        // into `self.source.dnd` (mirrors `list_source::DndLazy` — see
2223        // `TableView::build`); a fully-resident source's default (inert)
2224        // impls leave this a no-op.
2225        let (vis_start, vis_end) = self.visible_range();
2226        (self.source.dnd.request_window_fn)(vis_start..vis_end);
2227        if (self.source.dnd.can_fetch_more_fn)() && vis_end + BUFFER_ROWS >= row_count {
2228            (self.source.dnd.fetch_more_fn)();
2229        }
2230
2231        if row_count > 0 {
2232            let pane = body_pane::TreeBodyPane::<T> {
2233                source: self.source.clone(),
2234                editing_anchor: self.editing_anchor.clone(),
2235                columns: self.columns.clone(),
2236                display_indices: self.display_indices.clone(),
2237                column_widths: self.column_widths.clone(),
2238                pane_boundaries: *self.pane_boundaries.borrow(),
2239                scroll_x: self.scroll_x.clone(),
2240                tree_display_pos,
2241                indent_per_level,
2242                row_metrics: self.row_metrics.clone(),
2243                selection_mode: self.selection_mode,
2244                selection: self.row_selection.clone(),
2245                cell_selection: self.cell_selection.clone(),
2246                scroll_y: self.scroll_y.clone(),
2247                viewport_height: self.viewport_height.clone(),
2248                editing_cell: self.editing_cell.clone(),
2249                focused_cell: self.focused_cell.clone(),
2250                reorderable: self.reorderable,
2251                model_id: self.model_id,
2252                export: self.export.clone(),
2253                drag_anchor: ctx.self_id(),
2254                on_row_activate: self.on_row_activate.clone(),
2255                activate_on: self.activate_on,
2256                edit_triggers: self.edit_triggers,
2257                on_cell_edit_request: self.on_cell_edit_request.clone(),
2258                on_cell_edit_dismissed: self.on_cell_edit_dismissed.clone(),
2259                version: self.pane_version.clone(),
2260                prev_built_start: self.pane_built_start.clone(),
2261                prev_built_end: self.pane_built_end.clone(),
2262                total_refresh: self.pane_total_refresh.clone(),
2263                row_entries: Vec::new(),
2264                row_map: self.row_map.clone(),
2265                cell_map: self.cell_map.clone(),
2266            };
2267            self.body_pane_id = Some(ctx.add(pane));
2268            // An open cell editor also ends on a press that lands on no cell at
2269            // all — the empty band under the last row. Mounted here rather than
2270            // on the pane because the pane is not the hit target there.
2271            if let Some(handlers) = crate::table_view::body_pane::root_edit_dismiss_handler(
2272                &self.on_cell_edit_dismissed,
2273                &self.editing_cell,
2274                &Rc::new(
2275                    display_indices
2276                        .iter()
2277                        .map(|&i| self.columns[i].id.clone())
2278                        .collect::<Vec<_>>(),
2279                ),
2280            ) {
2281                ctx.apply_self_handlers(handlers);
2282            }
2283        } else if let Some(ref f) = self.empty_view {
2284            // Empty state — an empty tree, or a filter that matched nothing.
2285            self.empty_id = Some(ctx.add_boxed(f()));
2286        }
2287
2288        // Scrollbar.
2289        if self.show_internal_scrollbars {
2290            let sb = ScrollBar::new(
2291                ScrollBarOrientation::Vertical,
2292                self.scroll_y.clone(),
2293                self.max_scroll_y.clone(),
2294                self.viewport_ratio_y.clone(),
2295            )
2296            .visual(match self.scroll_bar_style {
2297                ScrollBarMode::Permanent => ScrollBarVisual::Permanent,
2298                ScrollBarMode::Overlay => ScrollBarVisual::Overlay,
2299                ScrollBarMode::Thin => ScrollBarVisual::Thin,
2300            });
2301            self.scrollbar_id = Some(ctx.add(sb));
2302
2303            // Horizontal bar — the Middle pane only, mirrors `TableView`.
2304            let hsb = ScrollBar::new(
2305                ScrollBarOrientation::Horizontal,
2306                self.scroll_x.clone(),
2307                self.max_scroll_x.clone(),
2308                self.viewport_ratio_x.clone(),
2309            )
2310            .visual(match self.scroll_bar_style {
2311                ScrollBarMode::Permanent => ScrollBarVisual::Permanent,
2312                ScrollBarMode::Overlay => ScrollBarVisual::Overlay,
2313                ScrollBarMode::Thin => ScrollBarVisual::Thin,
2314            });
2315            self.h_scrollbar_id = Some(ctx.add(hsb));
2316        }
2317
2318        // Z-order mirrors TableView: body pane first, header last so it
2319        // paints above any row that bleeds into the header band on
2320        // overscroll.
2321        let mut children: Vec<WidgetId> = Vec::new();
2322        if let Some(id) = self.body_pane_id {
2323            children.push(id);
2324        }
2325        if let Some(id) = self.empty_id {
2326            children.push(id);
2327        }
2328        if let Some(id) = self.scrollbar_id {
2329            children.push(id);
2330        }
2331        if let Some(id) = self.h_scrollbar_id {
2332            children.push(id);
2333        }
2334        if let Some(id) = self.header_row_id {
2335            children.push(id);
2336        }
2337        let _ = (header_h, row_h);
2338        children
2339    }
2340
2341    fn layout_response(
2342        &self,
2343        proposal: SizeProposal,
2344        _ctx: &LayoutContext,
2345    ) -> teksilo_core::widget::LayoutResponse {
2346        // Only an allocation may seed the cached viewport (`common::viewport`);
2347        // the body pane shares this very cell, so a measurement's fallback
2348        // would desync its realization window.
2349        let size = crate::common::viewport::viewport_size(
2350            proposal,
2351            &self.viewport_height,
2352            Size::new(400.0, 300.0),
2353        );
2354        if proposal.height.is_some() {
2355            // Viewport-relative imperatives are meaningful from here on — but
2356            // only once a real height has landed, for the reason `laid_out`
2357            // exists at all.
2358            self.laid_out.set(true);
2359        }
2360        size.into()
2361    }
2362
2363    fn place_children(
2364        &self,
2365        bounds: Rect,
2366        _proposal: SizeProposal,
2367        children: &mut [WidgetPlacement],
2368        ctx: &LayoutContext,
2369    ) {
2370        if children.is_empty() {
2371            return;
2372        }
2373        let rtl = ctx.is_rtl();
2374        let header_h = self.effective_header_height();
2375        let body_height_provisional = (bounds.height - header_h).max(0.0);
2376
2377        // Parent-before-child layout order means this runs before the
2378        // body pane's measure pass — in auto-measure mode the scrollbar
2379        // totals settle one frame after a measurement change.
2380        let total_height = self
2381            .row_metrics
2382            .borrow_mut()
2383            .total_height(self.source.visible_count());
2384        let needs_v_scrollbar =
2385            self.show_internal_scrollbars && total_height > body_height_provisional + 0.5;
2386        // Permanent reserves a layout column for the bar; Overlay / Thin
2387        // float over the content, so the body spans the full width.
2388        let reserves_v_bar = needs_v_scrollbar && self.scroll_bar_style == ScrollBarMode::Permanent;
2389        let body_width = if reserves_v_bar {
2390            (bounds.width - SCROLLBAR_THICKNESS).max(0.0)
2391        } else {
2392            bounds.width
2393        };
2394        // RTL mirror (see TableView::place_children): scrollbar to the
2395        // physical left, body/header band shifted right by its thickness.
2396        // Only shift when the bar actually reserves a column (Permanent).
2397        let band_left = if rtl && reserves_v_bar {
2398            bounds.x + SCROLLBAR_THICKNESS
2399        } else {
2400            bounds.x
2401        };
2402        let scrollbar_x = if rtl {
2403            bounds.x
2404        } else {
2405            bounds.x + bounds.width - SCROLLBAR_THICKNESS
2406        };
2407        // The header strip spans the band; snapshot its width for the
2408        // reorder-drop handler's RTL mirror (see `TableView::place_children`).
2409        self.header_strip_width.set(body_width);
2410
2411        let overrides = self.column_widths_signal.get();
2412        let display = self.display_indices.borrow().clone();
2413        let widths = layout::ColumnSolver::resolve_in_order(
2414            &self.columns,
2415            &display,
2416            body_width,
2417            cp::MIN_COLUMN_WIDTH_DEFAULT,
2418            &overrides,
2419        );
2420
2421        // Pane geometry (see `TableView::place_children`).
2422        let boundaries = *self.pane_boundaries.borrow();
2423        let (leading_w, middle_content_w, trailing_w) = layout::pane_widths(&widths, boundaries);
2424        let middle_viewport_w = (body_width - leading_w - trailing_w).max(0.0);
2425        let max_x = (middle_content_w - middle_viewport_w).max(0.0);
2426        self.max_scroll_x.set(max_x);
2427        self.middle_viewport_width.set(middle_viewport_w);
2428        let x_ratio = if middle_content_w > 0.0 {
2429            (middle_viewport_w / middle_content_w).clamp(0.0, 1.0)
2430        } else {
2431            1.0
2432        };
2433        self.viewport_ratio_x.set(x_ratio);
2434        {
2435            let current = self.scroll_x.get();
2436            let clamped = current.clamp(0.0, max_x);
2437            if (clamped - current).abs() > 0.001 {
2438                self.scroll_x.set(clamped);
2439            }
2440        }
2441
2442        *self.column_widths.borrow_mut() = widths;
2443
2444        let needs_h_scrollbar = self.show_internal_scrollbars && max_x > 0.5;
2445        let reserves_h_bar = needs_h_scrollbar && self.scroll_bar_style == ScrollBarMode::Permanent;
2446        let body_height = if reserves_h_bar {
2447            (body_height_provisional - SCROLLBAR_THICKNESS).max(0.0)
2448        } else {
2449            body_height_provisional
2450        };
2451
2452        let max_y = (total_height - body_height).max(0.0);
2453        self.max_scroll_y.set(max_y);
2454        let y_ratio = if total_height > 0.0 {
2455            (body_height / total_height).clamp(0.0, 1.0)
2456        } else {
2457            1.0
2458        };
2459        self.viewport_ratio_y.set(y_ratio);
2460        self.clamp_scroll();
2461
2462        let body_origin_y = bounds.y + header_h;
2463        // Cache the row-area rect for the keyboard handler's outer-scroll chase.
2464        self.body_bounds
2465            .set(Rect::new(band_left, body_origin_y, body_width, body_height));
2466
2467        let mut next = 0;
2468
2469        // Body pane fills the body region; it positions its rows
2470        // internally and clips them to its own bounds.
2471        if self.body_pane_id.is_some() {
2472            if let Some(child) = children.get_mut(next) {
2473                child.origin = Point::new(band_left, body_origin_y);
2474                child.size = Size::new(body_width, body_height);
2475            }
2476            next += 1;
2477        }
2478
2479        // Empty-state child fills the body region (below the header).
2480        if self.empty_id.is_some() {
2481            if let Some(child) = children.get_mut(next) {
2482                child.origin = Point::new(band_left, body_origin_y);
2483                child.size = Size::new(body_width, body_height);
2484            }
2485            next += 1;
2486        }
2487
2488        // Scrollbar — alongside the body, below the header.
2489        if self.scrollbar_id.is_some() {
2490            if let Some(child) = children.get_mut(next) {
2491                if needs_v_scrollbar {
2492                    child.origin = Point::new(scrollbar_x, body_origin_y);
2493                    child.size = Size::new(SCROLLBAR_THICKNESS, body_height);
2494                } else {
2495                    child.origin = bounds.origin();
2496                    child.size = Size::ZERO;
2497                }
2498            }
2499            next += 1;
2500        }
2501
2502        // Horizontal scrollbar — the Middle pane's own band, below the body.
2503        if self.h_scrollbar_id.is_some() {
2504            if let Some(child) = children.get_mut(next) {
2505                if needs_h_scrollbar {
2506                    let h_x = if rtl {
2507                        band_left + trailing_w
2508                    } else {
2509                        band_left + leading_w
2510                    };
2511                    child.origin = Point::new(h_x, body_origin_y + body_height);
2512                    child.size = Size::new(middle_viewport_w, SCROLLBAR_THICKNESS);
2513                } else {
2514                    child.origin = bounds.origin();
2515                    child.size = Size::ZERO;
2516                }
2517            }
2518            next += 1;
2519        }
2520
2521        // Header strip last — placed at top y but emitted last so paint
2522        // z-order draws it above any overscrolled body rows.
2523        if self.header_row_id.is_some()
2524            && let Some(child) = children.get_mut(next)
2525        {
2526            child.origin = Point::new(band_left, bounds.y);
2527            child.size = Size::new(body_width, header_h);
2528        }
2529    }
2530
2531    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
2532        let header_h = self.effective_header_height();
2533        let colors = &ctx.theme.colors;
2534        let scroll_y = self.scroll_y.get();
2535        let body_origin_y = bounds.y + header_h;
2536        let body_height = (bounds.height - header_h).max(0.0);
2537        let widths = self.column_widths.borrow();
2538        let body_width = widths.iter().sum::<f32>();
2539        let body_width_for_paint = if body_width > 0.0 {
2540            body_width.min(bounds.width)
2541        } else {
2542            bounds.width
2543        };
2544        // Physical left edge of the column content (see TableView::paint).
2545        let rtl = ctx.layout_direction == teksilo_core::environment::LayoutDirection::RightToLeft;
2546        let content_left = if rtl {
2547            bounds.x + bounds.width - body_width_for_paint
2548        } else {
2549            bounds.x
2550        };
2551
2552        // Visible row window for the paint passes — offset-table-driven
2553        // so variable heights paint correctly.
2554        let row_count = self.source.visible_count();
2555        let (first_visible, last_visible) =
2556            self.row_metrics
2557                .borrow_mut()
2558                .visible_range(scroll_y, body_height, row_count, 0);
2559
2560        // Clip the root-painted row decorations (alt-row stripes,
2561        // selection bands, grid lines, focus ring) to the body band —
2562        // `clips_children` only clips child widgets, not this widget's
2563        // own paint, which would otherwise bleed past the bottom edge
2564        // for the partially visible last row.
2565        canvas.set_clip(Rect::new(
2566            content_left,
2567            body_origin_y,
2568            body_width_for_paint,
2569            body_height,
2570        ));
2571
2572        if self.alternating_rows {
2573            let mut m = self.row_metrics.borrow_mut();
2574            for row_idx in first_visible..last_visible {
2575                if row_idx % 2 == 1 {
2576                    let y = body_origin_y + m.row_top(row_idx) - scroll_y;
2577                    let h = m.row_height(row_idx);
2578                    let rect = Rect::new(content_left, y, body_width_for_paint, h);
2579                    canvas.fill_rect(rect, SurfaceRole::AltRow.resolve(colors));
2580                }
2581            }
2582        }
2583
2584        if let Some(ref sel) = self.row_selection
2585            && matches!(
2586                self.selection_mode,
2587                TableSelectionMode::SingleRow | TableSelectionMode::MultiRow
2588            )
2589        {
2590            // Focus- and window-aware: vivid while the view holds keyboard
2591            // focus AND the host window is active, muted otherwise (the same
2592            // `SelectedInactive` serves view-unfocused and window-inactive).
2593            let bg = if self.view_focused.get() && ctx.window_active {
2594                SurfaceRole::Selected.resolve(colors)
2595            } else {
2596                SurfaceRole::SelectedInactive.resolve(colors)
2597            };
2598            let mut m = self.row_metrics.borrow_mut();
2599            for row_idx in sel.selected_indices() {
2600                let y = body_origin_y + m.row_top(row_idx) - scroll_y;
2601                let h = m.row_height(row_idx);
2602                if y + h < body_origin_y || y > body_origin_y + body_height {
2603                    continue;
2604                }
2605                let rect = Rect::new(content_left, y, body_width_for_paint, h);
2606                canvas.fill_rect(rect, bg);
2607            }
2608        }
2609
2610        let line_color = BorderRole::Divider.resolve(colors);
2611        let line_w = cp::GRID_LINE_THICKNESS.max(1.0);
2612        if matches!(self.grid_lines, GridLines::Horizontal | GridLines::Both) {
2613            let mut m = self.row_metrics.borrow_mut();
2614            for row_idx in first_visible..last_visible {
2615                let bottom = m.row_top(row_idx) + m.row_height(row_idx);
2616                let y = body_origin_y + bottom - scroll_y - line_w;
2617                let rect = Rect::new(content_left, y, body_width_for_paint, line_w);
2618                canvas.fill_rect(rect, line_color);
2619            }
2620        }
2621
2622        // Pane geometry for the two column-position-dependent decorations
2623        // below — see `TableView::paint`.
2624        let boundaries = *self.pane_boundaries.borrow();
2625        let scroll_x = self.scroll_x.get();
2626        let content_bounds = Rect::new(
2627            content_left,
2628            body_origin_y,
2629            body_width_for_paint,
2630            body_height,
2631        );
2632        let (leading_rect, middle_rect, trailing_rect) =
2633            layout::band_rects(content_bounds, &widths, boundaries, rtl);
2634
2635        if matches!(self.grid_lines, GridLines::Vertical | GridLines::Both) {
2636            let leading_end = boundaries.leading_count.min(widths.len());
2637            let middle_end = boundaries.middle_end.min(widths.len()).max(leading_end);
2638            crate::table_view::draw_pane_dividers(
2639                canvas,
2640                leading_rect,
2641                &widths[..leading_end],
2642                0.0,
2643                rtl,
2644                line_color,
2645                line_w,
2646            );
2647            crate::table_view::draw_pane_dividers(
2648                canvas,
2649                middle_rect,
2650                &widths[leading_end..middle_end],
2651                scroll_x,
2652                rtl,
2653                line_color,
2654                line_w,
2655            );
2656            crate::table_view::draw_pane_dividers(
2657                canvas,
2658                trailing_rect,
2659                &widths[middle_end..],
2660                0.0,
2661                rtl,
2662                line_color,
2663                line_w,
2664            );
2665        }
2666
2667        // Focus ring — keyboard-only (`:focus-visible`) and only while the
2668        // view holds focus, so a mouse click never leaves a ring.
2669        if self.view_focused.get()
2670            && self.focus_visible.get()
2671            && let Some((focus_row, focus_col)) = self.focused_cell.get()
2672            && focus_col < widths.len()
2673            && let Some(x_off) = layout::column_logical_x(
2674                &widths,
2675                boundaries,
2676                scroll_x,
2677                body_width_for_paint,
2678                focus_col,
2679            )
2680        {
2681            let cell_w = widths[focus_col];
2682            let (focus_top, focus_h) = {
2683                let mut m = self.row_metrics.borrow_mut();
2684                (m.row_top(focus_row), m.row_height(focus_row))
2685            };
2686            let y = body_origin_y + focus_top - scroll_y;
2687            if y + focus_h >= body_origin_y && y <= body_origin_y + body_height {
2688                let pane_rect = if focus_col < boundaries.leading_count {
2689                    leading_rect
2690                } else if focus_col >= boundaries.middle_end {
2691                    trailing_rect
2692                } else {
2693                    middle_rect
2694                };
2695                canvas.set_clip(pane_rect);
2696                let inset = cp::FOCUS_RING_INSET;
2697                let stroke = cp::GRID_LINE_THICKNESS.max(1.5);
2698                let ring_color = BorderRole::Focused.resolve(colors);
2699                let rx = if rtl {
2700                    content_left + body_width_for_paint - x_off - cell_w + inset
2701                } else {
2702                    content_left + x_off + inset
2703                };
2704                let ry = y + inset;
2705                let rw = (cell_w - inset * 2.0).max(0.0);
2706                let rh = (focus_h - inset * 2.0).max(0.0);
2707                canvas.fill_rect(Rect::new(rx, ry, rw, stroke), ring_color);
2708                canvas.fill_rect(Rect::new(rx, ry + rh - stroke, rw, stroke), ring_color);
2709                canvas.fill_rect(Rect::new(rx, ry, stroke, rh), ring_color);
2710                canvas.fill_rect(Rect::new(rx + rw - stroke, ry, stroke, rh), ring_color);
2711                canvas.clear_clip();
2712            }
2713        }
2714
2715        // Row-drop insertion indicator (source-accepted positions only — a
2716        // forbidden hover clears the signal). `y` is stored body-local.
2717        //
2718        // Both affordances are indented to the level the dropped row lands at,
2719        // measured from the **tree column's** own leading edge rather than the
2720        // body's: `.tree_column()` and a user column-reorder can move the
2721        // twist/indent gutter off the leading slot, and an indent measured from
2722        // the wrong origin points at nothing. The per-level step is this view's
2723        // `effective_indent()` — the very value its indent gutter renders with
2724        // — not the container recipe's, which describes `StandardTreeItem`.
2725        let drop_indent_origin = |depth: usize| -> f32 {
2726            let step = self.effective_indent();
2727            let tree_decl = self.tree_column_decl_index();
2728            let tree_slot = self
2729                .display_indices
2730                .borrow()
2731                .iter()
2732                .position(|&i| i == tree_decl)
2733                .unwrap_or(0);
2734            let col_x = layout::column_logical_x(
2735                &widths,
2736                boundaries,
2737                scroll_x,
2738                body_width_for_paint,
2739                tree_slot,
2740            )
2741            .unwrap_or(0.0);
2742            (col_x + depth as f32 * step).clamp(0.0, body_width_for_paint)
2743        };
2744        match self.drop_feedback.get() {
2745            Some(DropViz::Line { y, depth, .. }) => {
2746                let recipe = ctx
2747                    .theme
2748                    .style_slots
2749                    .list_container
2750                    .as_ref()
2751                    .map(|s| s.insertion())
2752                    .unwrap_or_default();
2753                let line_color = recipe.role.resolve(colors);
2754                let thickness = recipe.thickness;
2755                let line_y = body_origin_y + y - thickness * 0.5;
2756                let indent = drop_indent_origin(depth);
2757                // RTL mirrors the row, so the indent eats into the *right* edge
2758                // and the line still runs away from the row's leading side.
2759                let x = if rtl {
2760                    content_left
2761                } else {
2762                    content_left + indent
2763                };
2764                canvas.fill_rect(
2765                    Rect::new(x, line_y, body_width_for_paint - indent, thickness),
2766                    line_color,
2767                );
2768            }
2769            // "Drop into this container" — a box round the target row, inset on
2770            // every side so its horizontal edges can never be mistaken for the
2771            // Before / After line. Same affordance `TreeView` paints for an
2772            // `Into` verdict; see `ListDropIntoRecipe`.
2773            Some(DropViz::Rect {
2774                top, height, depth, ..
2775            }) => {
2776                let into = ctx
2777                    .theme
2778                    .style_slots
2779                    .list_container
2780                    .as_ref()
2781                    .map(|s| s.drop_into())
2782                    .unwrap_or_default();
2783                let color = into.role.resolve(colors);
2784                let indent = drop_indent_origin(depth);
2785                let x = if rtl {
2786                    content_left
2787                } else {
2788                    content_left + indent
2789                };
2790                let rect = Rect::new(
2791                    x + into.inset,
2792                    body_origin_y + top + into.inset,
2793                    (body_width_for_paint - indent - into.inset * 2.0).max(0.0),
2794                    (height - into.inset * 2.0).max(0.0),
2795                );
2796                let radius = teksilo_tokens::CornerRadius::uniform(into.corner_radius);
2797                canvas.fill_rounded_rect(rect, radius, color.with_alpha(into.fill_alpha));
2798                canvas.stroke_rounded_rect(rect, radius, color, into.thickness);
2799            }
2800            None => {}
2801        }
2802
2803        canvas.clear_clip();
2804
2805        // Container focus ring — keyboard focus on the view but no current cell
2806        // and no selection, so nothing else marks the focus. Outline the whole
2807        // view (see TableView / TreeView).
2808        let nothing_indicated = self.focused_cell.get().is_none()
2809            && self
2810                .row_selection
2811                .as_ref()
2812                .is_none_or(|s| s.selected_indices().is_empty())
2813            && self.cell_selection.as_ref().is_none_or(|s| s.count() == 0);
2814        if self.view_focused.get() && self.focus_visible.get() && nothing_indicated {
2815            let inset = 1.0_f32;
2816            let rect = Rect::new(
2817                bounds.x + inset,
2818                bounds.y + inset,
2819                (bounds.width - inset * 2.0).max(0.0),
2820                (bounds.height - inset * 2.0).max(0.0),
2821            );
2822            canvas.stroke_rect(rect, BorderRole::Focused.resolve(colors), 1.5);
2823        }
2824
2825        // `OnRelease` column-resize guide — see `TableView::paint`.
2826        if let Some(x) = self.resize_preview_x.get() {
2827            let thickness = cp::GRID_LINE_THICKNESS.max(1.5);
2828            canvas.fill_rect(
2829                Rect::new(x - thickness * 0.5, bounds.y, thickness, bounds.height),
2830                BorderRole::Focused.resolve(colors),
2831            );
2832        }
2833    }
2834
2835    /// The context-menu key opens the *current row's* menu, not the view's.
2836    ///
2837    /// A `TreeTableView` is focusable and its rows deliberately are not — the
2838    /// container owns focus and `set_selected` is what tells assistive
2839    /// technology which row is current. So the dispatcher's default of "the
2840    /// focused widget" would open the view's own menu, in the widget family
2841    /// where a per-row menu matters most.
2842    ///
2843    /// The row the user means is the focused cell's row if they have navigated,
2844    /// else the first selected row. Only realized rows have a widget, so a
2845    /// cursor scrolled outside the virtualization window resolves to nothing
2846    /// and the menu falls back to the view — right, because there is no row on
2847    /// screen for it to be about.
2848    fn context_menu_key_target(&self) -> Option<WidgetId> {
2849        let index = self.focused_cell.get().map(|(row, _col)| row).or_else(|| {
2850            self.row_selection
2851                .as_ref()
2852                .and_then(|s| s.selected_indices().first().copied())
2853        })?;
2854        let map = self.row_map.borrow();
2855        map.iter().find(|(i, _)| *i == index).map(|(_, id)| *id)
2856    }
2857
2858    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2859        builder.set_role(teksilo_core::accesskit::Role::TreeGrid);
2860        // Whether the selection takes more than one row. A real property on
2861        // both platforms that have one: UIA's `SelectionCanSelectMultiple`
2862        // and AT-SPI's multiselectable state. Left unset it reads false, so a
2863        // multi-select view was telling every screen reader that one row was
2864        // the most it would ever hold.
2865        //
2866        // Gated on the mode, and the gate matters beyond tidiness:
2867        // `accesskit_windows` picks the event it raises on a selection change
2868        // from this property (`adapter.rs:189-199`), firing
2869        // `ElementAddedToSelection` when it is true and `ElementSelected` when
2870        // it is false. A single-select view publishing `true` would trade the
2871        // right event for the wrong one.
2872        if self
2873            .row_selection
2874            .as_ref()
2875            .is_some_and(|selection| selection.mode() == teksilo_data::SelectionMode::Multi)
2876        {
2877            builder.set_multiselectable(true);
2878        }
2879
2880        if let Some(ref label) = self.a11y_label {
2881            builder.set_name(label.resolve_now());
2882        }
2883        let row_count = self.source.visible_count() + if self.show_header { 1 } else { 0 };
2884        let col_count = self.columns.len();
2885        let n = builder.inner_mut();
2886        n.set_row_count(row_count);
2887        n.set_column_count(col_count);
2888
2889        // Roving focus: point active_descendant at the focused cell's own
2890        // AT node so a screen reader follows arrow-key cell navigation
2891        // and ArrowLeft/Right expand/collapse. `cell_map` is a snapshot
2892        // of the body pane's last realized cells; a focused cell that
2893        // scrolled (or collapsed) out of the realized buffer simply
2894        // isn't in it, so no stale id is emitted.
2895        if let Some((row, col)) = self.focused_cell.get()
2896            && let Some(cell_id) = self.realized_cell(row, col)
2897        {
2898            builder.set_active_descendant(widget_id_to_node_id(cell_id));
2899        }
2900    }
2901
2902    fn as_any(&self) -> Option<&dyn std::any::Any> {
2903        Some(self)
2904    }
2905
2906    fn children(&self) -> Vec<WidgetId> {
2907        // Same order as `build()` — body pane first, header last so it
2908        // paints on top of any overscrolled rows.
2909        let mut out: Vec<WidgetId> = Vec::new();
2910        if let Some(id) = self.body_pane_id {
2911            out.push(id);
2912        }
2913        if let Some(id) = self.empty_id {
2914            out.push(id);
2915        }
2916        if let Some(id) = self.scrollbar_id {
2917            out.push(id);
2918        }
2919        if let Some(id) = self.h_scrollbar_id {
2920            out.push(id);
2921        }
2922        if let Some(id) = self.header_row_id {
2923            out.push(id);
2924        }
2925        out
2926    }
2927
2928    fn accessibility_children(&self) -> Option<Vec<WidgetId>> {
2929        // WCAG 1.3.2 (audit G17): read the column-header row FIRST, then the
2930        // body, even though `build()` / `children()` list the body first so it
2931        // paints beneath the header. Same id set as `children()`, reordered.
2932        let out: Vec<WidgetId> = [
2933            self.header_row_id,
2934            self.body_pane_id,
2935            self.empty_id,
2936            self.scrollbar_id,
2937            self.h_scrollbar_id,
2938        ]
2939        .into_iter()
2940        .flatten()
2941        .collect();
2942        if out.is_empty() { None } else { Some(out) }
2943    }
2944
2945    fn clips_children(&self) -> bool {
2946        true
2947    }
2948}
2949
2950#[cfg(test)]
2951mod tests {
2952    use super::*;
2953    use crate::table_view::column::{CellContext, ColumnWidth};
2954    use teksilo_canvas::SizeProposal;
2955    use teksilo_core::accesskit::Role;
2956    use teksilo_core::widget_tree::WidgetTree;
2957    use teksilo_data::{SortFilterTreeModel, TreeFilterMode, TreeModel};
2958    use teksilo_i18n::lit;
2959
2960    fn sample_tree() -> TreeModel<&'static str> {
2961        let t = TreeModel::new();
2962        let docs = t.insert_root(0, "docs");
2963        t.insert_child(docs, 0, "readme");
2964        t.insert_child(docs, 1, "guide");
2965        let src = t.insert_root(1, "src");
2966        t.insert_child(src, 0, "main.rs");
2967        t
2968    }
2969
2970    fn name_col() -> Column<&'static str> {
2971        Column::<&str>::new("name", lit!("Name"), |row, _: &CellContext| {
2972            Box::new(crate::primitives::TextWidget::new(lit!(*row)))
2973        })
2974        .width(ColumnWidth::Flex(1.0))
2975    }
2976
2977    fn size_col() -> Column<&'static str> {
2978        Column::<&str>::new("size", lit!("Size"), |_row, _: &CellContext| {
2979            Box::new(crate::primitives::TextWidget::new(lit!("0")))
2980        })
2981        .width(ColumnWidth::Fixed(60.0))
2982    }
2983
2984    #[test]
2985    fn row_selection_click_repaints_immediately_without_expand_collapse() {
2986        // Regression for "row selection in TreeTableView only fires on
2987        // expand/collapse": before the selection_signal was observed,
2988        // calling `sel.select(row)` mutated the model but the rendered
2989        // `BodyRow.selected` flag (computed at build time from
2990        // `sel.is_selected(...)`) was stale until something else
2991        // bumped the version signal — typically a twist toggle.
2992        use teksilo_canvas::Point;
2993        use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
2994        use teksilo_data::{SelectionMode, SelectionModel};
2995        let proxy = SortFilterTreeModel::new(sample_tree());
2996        let selection = SelectionModel::new(SelectionMode::Single);
2997        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
2998        tree.add(
2999            TreeTableView::from_projection(proxy.clone())
3000                .add_column(name_col())
3001                .selection_mode(TableSelectionMode::SingleRow)
3002                .selection(selection.clone())
3003                .row_height(20.0),
3004        );
3005        tree.layout(SizeProposal {
3006            width: Some(400.0),
3007            height: Some(200.0),
3008        });
3009        // Selection starts empty.
3010        assert_eq!(selection.selected_indices().len(), 0);
3011        // Click on the first body row — visible at flat_idx 0
3012        // ("docs"), which sits below the header at y ≈ header + 0.
3013        let header_h = cp::HEADER_HEIGHT;
3014        let click_y = header_h + 10.0;
3015        tree.dispatch_event(WidgetEvent::PointerDown {
3016            position: Point::new(40.0, click_y),
3017            button: PointerButton::Primary,
3018            modifiers: Modifiers::NONE,
3019        });
3020        tree.dispatch_event(WidgetEvent::PointerUp {
3021            position: Point::new(40.0, click_y),
3022            button: PointerButton::Primary,
3023            modifiers: Modifiers::NONE,
3024        });
3025        // Selection updated.
3026        assert_eq!(selection.selected_indices(), vec![0]);
3027        // And — the regression — the rendered tree must reflect the
3028        // new selection without us manually expanding/collapsing.
3029        // We trigger a layout (which renders the selection bg paint
3030        // path) and verify the selection IS still there: i.e., a
3031        // version-signal observer on `selection_signal` would have
3032        // fired and queued a rebuild.
3033        tree.layout(SizeProposal {
3034            width: Some(400.0),
3035            height: Some(200.0),
3036        });
3037        assert_eq!(selection.selected_indices(), vec![0]);
3038    }
3039
3040    #[test]
3041    fn first_arrow_lands_on_an_end_row_instead_of_skipping_it() {
3042        // `TreeTableView` plugs its own hierarchical `RowNavigator` into
3043        // `TableView`'s key handler, so it inherited the same bug: "no cursor
3044        // yet" was read as "cursor on (0, 0)", which made the first ArrowDown
3045        // step to flat row 1 (skipping row 0) and the first ArrowUp a DEAD KEY
3046        // (`prev_row(0)` is `None`). Entry now uses the navigator's own
3047        // first/last visible row, so it is hierarchy-aware.
3048        use teksilo_core::event::{Key, Modifiers};
3049        use teksilo_data::{SelectionMode, SelectionModel};
3050
3051        for (key, want, what) in [
3052            (
3053                Key::ArrowDown,
3054                0usize,
3055                "first ArrowDown enters at the first visible row",
3056            ),
3057            (
3058                Key::ArrowUp,
3059                3usize,
3060                "first ArrowUp enters at the last visible row",
3061            ),
3062        ] {
3063            let t = TreeModel::new();
3064            t.insert_root(0, "a");
3065            t.insert_root(1, "b");
3066            t.insert_root(2, "c");
3067            t.insert_root(3, "d");
3068            let proxy = SortFilterTreeModel::new(t);
3069            let selection = SelectionModel::new(SelectionMode::Single);
3070            let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3071            let id = tree.add(
3072                TreeTableView::from_projection(proxy.clone())
3073                    .add_column(name_col())
3074                    .selection_mode(TableSelectionMode::SingleRow)
3075                    .selection(selection.clone())
3076                    .row_height(20.0),
3077            );
3078            tree.layout(SizeProposal {
3079                width: Some(400.0),
3080                height: Some(200.0),
3081            });
3082            tree.focus(id);
3083            assert_eq!(proxy.visible_count(), 4, "four flat roots");
3084            assert!(
3085                selection.selected_indices().is_empty(),
3086                "precondition: no cursor, nothing selected"
3087            );
3088
3089            tree.press_key(key, Modifiers::NONE);
3090            assert_eq!(selection.selected_indices(), vec![want], "{what}");
3091        }
3092    }
3093
3094    #[test]
3095    fn expanded_children_are_reachable_by_the_first_arrow() {
3096        // Hierarchy-aware entry: with "docs" expanded, the last VISIBLE row is a
3097        // child, not a root — so the first ArrowUp must land on that child. A
3098        // raw `row_count - 1` would happen to agree here, but going through the
3099        // navigator is what keeps it correct for any projection (filtered,
3100        // sorted, partially collapsed).
3101        use teksilo_core::event::{Key, Modifiers};
3102        use teksilo_data::{SelectionMode, SelectionModel};
3103
3104        let proxy = SortFilterTreeModel::new(sample_tree()); // docs{readme,guide}, src{main.rs}
3105        let selection = SelectionModel::new(SelectionMode::Single);
3106        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3107        let id = tree.add(
3108            TreeTableView::from_projection(proxy.clone())
3109                .add_column(name_col())
3110                .selection_mode(TableSelectionMode::SingleRow)
3111                .selection(selection.clone())
3112                .row_height(20.0),
3113        );
3114        tree.layout(SizeProposal {
3115            width: Some(400.0),
3116            height: Some(200.0),
3117        });
3118        tree.focus(id);
3119
3120        let last = proxy.visible_count() - 1;
3121        tree.press_key(Key::ArrowUp, Modifiers::NONE);
3122        assert_eq!(
3123            selection.selected_indices(),
3124            vec![last],
3125            "first ArrowUp enters at the last VISIBLE row, whatever the hierarchy shows"
3126        );
3127    }
3128
3129    #[test]
3130    fn row_click_moves_focus_so_arrow_nav_resumes_there() {
3131        // Regression: in row-selection mode a row click set the selection but
3132        // NOT `focused_cell` (the arrow-nav origin, `unwrap_or((0,0))`), so the
3133        // next Arrow stepped from row 0 rather than the clicked row. Click flat
3134        // row 1 with ≥3 visible rows so the fall-back-to-0 bug is observable
3135        // (buggy: 0 → 1; fixed: 1 → 2).
3136        use teksilo_canvas::Point;
3137        use teksilo_core::event::{Key, Modifiers, PointerButton, WidgetEvent};
3138        use teksilo_data::{SelectionMode, SelectionModel};
3139        let t = TreeModel::new();
3140        t.insert_root(0, "a");
3141        t.insert_root(1, "b");
3142        t.insert_root(2, "c");
3143        t.insert_root(3, "d");
3144        let proxy = SortFilterTreeModel::new(t);
3145        let selection = SelectionModel::new(SelectionMode::Single);
3146        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3147        let id = tree.add(
3148            TreeTableView::from_projection(proxy.clone())
3149                .add_column(name_col())
3150                .selection_mode(TableSelectionMode::SingleRow)
3151                .selection(selection.clone())
3152                .row_height(20.0),
3153        );
3154        tree.layout(SizeProposal {
3155            width: Some(400.0),
3156            height: Some(200.0),
3157        });
3158        tree.focus(id);
3159        assert_eq!(proxy.visible_count(), 4, "four flat roots");
3160
3161        // Click flat row 1 ("b"): 20px rows starting below the header.
3162        let click_y = cp::HEADER_HEIGHT + 1.0 * 20.0 + 10.0;
3163        tree.dispatch_event(WidgetEvent::PointerDown {
3164            position: Point::new(40.0, click_y),
3165            button: PointerButton::Primary,
3166            modifiers: Modifiers::NONE,
3167        });
3168        tree.dispatch_event(WidgetEvent::PointerUp {
3169            position: Point::new(40.0, click_y),
3170            button: PointerButton::Primary,
3171            modifiers: Modifiers::NONE,
3172        });
3173        assert_eq!(
3174            selection.selected_indices(),
3175            vec![1],
3176            "click selects flat row 1"
3177        );
3178
3179        // ArrowDown must resume from the clicked row (1 → 2), not from row 0.
3180        tree.press_key(Key::ArrowDown, Modifiers::NONE);
3181        assert_eq!(
3182            selection.selected_indices(),
3183            vec![2],
3184            "ArrowDown after a click resumes from the clicked row (1 → 2)"
3185        );
3186    }
3187
3188    #[test]
3189    fn role_is_treegrid() {
3190        let proxy = SortFilterTreeModel::new(sample_tree());
3191        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3192        let id = tree.add(
3193            TreeTableView::from_projection(proxy)
3194                .add_column(name_col())
3195                .add_column(size_col())
3196                .row_height(20.0),
3197        );
3198        tree.layout(SizeProposal {
3199            width: Some(400.0),
3200            height: Some(200.0),
3201        });
3202        let info = tree.accessibility_node(id);
3203        assert_eq!(info.role(), Role::TreeGrid);
3204    }
3205
3206    #[test]
3207    fn initial_state_shows_only_roots() {
3208        let proxy = SortFilterTreeModel::new(sample_tree());
3209        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3210        let _id = tree.add(
3211            TreeTableView::from_projection(proxy.clone())
3212                .add_column(name_col())
3213                .row_height(20.0),
3214        );
3215        tree.layout(SizeProposal {
3216            width: Some(400.0),
3217            height: Some(200.0),
3218        });
3219        assert_eq!(proxy.visible_count(), 2); // docs, src
3220    }
3221
3222    #[test]
3223    fn expand_via_widget_reveals_children() {
3224        let proxy = SortFilterTreeModel::new(sample_tree());
3225        let docs = proxy.tree().root(0);
3226        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3227        let id = tree.add(
3228            TreeTableView::from_projection(proxy.clone())
3229                .add_column(name_col())
3230                .row_height(20.0),
3231        );
3232        tree.layout(SizeProposal {
3233            width: Some(400.0),
3234            height: Some(200.0),
3235        });
3236        {
3237            let any = tree.widget_as_any(id).unwrap();
3238            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3239            tt.expand(docs);
3240        }
3241        assert_eq!(proxy.visible_count(), 4); // docs, readme, guide, src
3242    }
3243
3244    #[test]
3245    fn arrow_right_expands_and_left_collapses_on_tree_column() {
3246        let proxy = SortFilterTreeModel::new(sample_tree());
3247        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3248        let id = tree.add(
3249            TreeTableView::from_projection(proxy.clone())
3250                .add_column(name_col())
3251                .row_height(20.0),
3252        );
3253        tree.layout(SizeProposal {
3254            width: Some(400.0),
3255            height: Some(200.0),
3256        });
3257        tree.focus(id);
3258        {
3259            let any = tree.widget_as_any(id).unwrap();
3260            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3261            tt.set_focused_cell(0, 0);
3262        }
3263        // ArrowRight on first row (docs, has children, collapsed) →
3264        // expand.
3265        tree.press_key(
3266            teksilo_core::event::Key::ArrowRight,
3267            teksilo_core::event::Modifiers::NONE,
3268        );
3269        assert_eq!(proxy.visible_count(), 4);
3270        // ArrowLeft on first row (now expanded) → collapse.
3271        tree.press_key(
3272            teksilo_core::event::Key::ArrowLeft,
3273            teksilo_core::event::Modifiers::NONE,
3274        );
3275        assert_eq!(proxy.visible_count(), 2);
3276    }
3277
3278    /// Rows for the external-source tests: an indent-ordered stream keyed by a
3279    /// domain id, the shape `TreeDataSlice` derives a hierarchy from.
3280    fn slice_rows() -> Vec<teksilo_data::TreeRow<u64, &'static str>> {
3281        use teksilo_data::TreeRow;
3282        vec![
3283            TreeRow::new(1, "docs", 0),
3284            TreeRow::new(2, "readme", 1),
3285            TreeRow::new(3, "guide", 1),
3286            TreeRow::new(4, "src", 0),
3287        ]
3288    }
3289
3290    fn external_slice() -> teksilo_data::TreeDataSlice<u64, &'static str> {
3291        let slice = teksilo_data::TreeDataSlice::<u64, &'static str>::new();
3292        slice.set_source(slice_rows);
3293        slice.reload();
3294        slice
3295    }
3296
3297    #[test]
3298    fn from_source_renders_an_external_tree_without_a_tree_model() {
3299        // The point of `from_source`: no `TreeModel` mirror anywhere. The slice
3300        // owns identity (`u64`), derives the hierarchy from row depths, and the
3301        // table reads it through the erased `TreeDataSource`.
3302        let slice = external_slice();
3303        slice.expand(&1);
3304        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3305        let id = tree.add(
3306            TreeTableView::from_source(slice.clone())
3307                .add_column(name_col())
3308                .row_height(20.0),
3309        );
3310        tree.layout(SizeProposal {
3311            width: Some(400.0),
3312            height: Some(200.0),
3313        });
3314        assert_eq!(slice.visible_count(), 4, "docs + 2 children + src");
3315
3316        let any = tree.widget_as_any(id).unwrap();
3317        let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3318        assert!(
3319            tt.projection().is_none(),
3320            "a source-backed view has no TreeModel projection to expose"
3321        );
3322        assert!(tt.body_pane_id.is_some(), "rows rendered from the source");
3323    }
3324
3325    #[test]
3326    fn from_source_keyed_selection_survives_a_full_resource() {
3327        // The property a `TreeModel` mirror cannot offer: `NodeId`s are
3328        // reassigned on rebuild, but a domain key is not — so a keyed selection
3329        // still points at the same row after the source is re-materialised.
3330        let slice = external_slice();
3331        slice.expand(&1);
3332        let keyed = KeyedSelectionModel::<u64>::new(teksilo_data::SelectionMode::Multi);
3333        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3334        let _id = tree.add(
3335            TreeTableView::from_source_keyed(slice.clone(), keyed.clone())
3336                .add_column(name_col())
3337                .row_height(20.0),
3338        );
3339        tree.layout(SizeProposal {
3340            width: Some(400.0),
3341            height: Some(200.0),
3342        });
3343
3344        keyed.select(3); // "guide"
3345        assert!(keyed.is_selected(&3));
3346
3347        // Re-source from scratch — every row is rebuilt.
3348        slice.reload();
3349        assert!(
3350            keyed.is_selected(&3),
3351            "a domain-keyed selection must survive a re-source"
3352        );
3353    }
3354
3355    #[test]
3356    fn from_source_supports_drag_reorder_like_the_tree_view() {
3357        // Parity check: a source-backed table reorders through the source's own
3358        // `accept_drop`, the same path `TreeView` uses — no `TreeModel`, no
3359        // `NodeId` anywhere. Here the slice commits the move into its own store.
3360        use std::cell::RefCell;
3361        use std::rc::Rc;
3362        use teksilo_canvas::Point;
3363
3364        // The store the slice re-sources from; the reorder mutates it.
3365        let order: Rc<RefCell<Vec<u64>>> = Rc::new(RefCell::new(vec![1, 4]));
3366        let slice = teksilo_data::TreeDataSlice::<u64, &'static str>::new();
3367        {
3368            let order = order.clone();
3369            slice.set_source(move || {
3370                let names: std::collections::HashMap<u64, &'static str> =
3371                    [(1, "docs"), (4, "src")].into_iter().collect();
3372                order
3373                    .borrow()
3374                    .iter()
3375                    .map(|k| teksilo_data::TreeRow::new(*k, names[k], 0))
3376                    .collect()
3377            });
3378        }
3379        {
3380            let order = order.clone();
3381            // Domain policy: apply the move to the backing store.
3382            slice.set_reorder(move |dragged, target, _pos| {
3383                let mut o = order.borrow_mut();
3384                let Some(from) = o.iter().position(|k| *k == dragged) else {
3385                    return false;
3386                };
3387                let item = o.remove(from);
3388                let to = o
3389                    .iter()
3390                    .position(|k| *k == target)
3391                    .map_or(o.len(), |i| i + 1);
3392                o.insert(to, item);
3393                true
3394            });
3395        }
3396        // An external source must opt into dragging: `TreeDataSlice::drag`
3397        // defaults to `NoDrag` (pinned by its own `drag_default_is_nodrag`).
3398        slice.set_drag_policy(|_| teksilo_data::DragEligibility::CanDrag);
3399        slice.reload();
3400        assert_eq!(*order.borrow(), vec![1, 4], "docs, src");
3401
3402        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3403        tree.add(
3404            TreeTableView::from_source(slice.clone())
3405                .add_column(name_col())
3406                .reorderable(true)
3407                .row_height(20.0),
3408        );
3409        tree.layout(SizeProposal {
3410            width: Some(400.0),
3411            height: Some(300.0),
3412        });
3413
3414        // Drag docs (flat 0) onto the bottom third of src (flat 1) → After src.
3415        let h = cp::HEADER_HEIGHT;
3416        drag(
3417            &mut tree,
3418            Point::new(40.0, h + 10.0),
3419            Point::new(40.0, h + 38.0),
3420        );
3421        assert_eq!(
3422            *order.borrow(),
3423            vec![4, 1],
3424            "the source applied the reorder: src now precedes docs"
3425        );
3426    }
3427
3428    #[test]
3429    fn a_source_that_forbids_dragging_a_row_is_honored() {
3430        // The source owns drag eligibility. A view that ignored it would happily
3431        // move a row the store considers locked.
3432        use std::cell::RefCell;
3433        use std::rc::Rc;
3434        use teksilo_canvas::Point;
3435
3436        let order: Rc<RefCell<Vec<u64>>> = Rc::new(RefCell::new(vec![1, 4]));
3437        let slice = teksilo_data::TreeDataSlice::<u64, &'static str>::new();
3438        {
3439            let order = order.clone();
3440            slice.set_source(move || {
3441                let names: std::collections::HashMap<u64, &'static str> =
3442                    [(1, "docs"), (4, "src")].into_iter().collect();
3443                order
3444                    .borrow()
3445                    .iter()
3446                    .map(|k| teksilo_data::TreeRow::new(*k, names[k], 0))
3447                    .collect()
3448            });
3449        }
3450        {
3451            let order = order.clone();
3452            slice.set_reorder(move |dragged, target, _pos| {
3453                let mut o = order.borrow_mut();
3454                let Some(from) = o.iter().position(|k| *k == dragged) else {
3455                    return false;
3456                };
3457                let item = o.remove(from);
3458                let to = o
3459                    .iter()
3460                    .position(|k| *k == target)
3461                    .map_or(o.len(), |i| i + 1);
3462                o.insert(to, item);
3463                true
3464            });
3465        }
3466        // Row 1 ("docs") is pinned in place by the store.
3467        slice.set_drag_policy(|k| {
3468            if *k == 1 {
3469                teksilo_data::DragEligibility::NoDrag
3470            } else {
3471                teksilo_data::DragEligibility::CanDrag
3472            }
3473        });
3474        slice.reload();
3475
3476        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3477        tree.add(
3478            TreeTableView::from_source(slice.clone())
3479                .add_column(name_col())
3480                .reorderable(true)
3481                .row_height(20.0),
3482        );
3483        tree.layout(SizeProposal {
3484            width: Some(400.0),
3485            height: Some(300.0),
3486        });
3487
3488        let h = cp::HEADER_HEIGHT;
3489        drag(
3490            &mut tree,
3491            Point::new(40.0, h + 10.0),
3492            Point::new(40.0, h + 38.0),
3493        );
3494        assert_eq!(
3495            *order.borrow(),
3496            vec![1, 4],
3497            "a NoDrag row must not move, even onto a valid target"
3498        );
3499    }
3500
3501    #[test]
3502    fn drop_on_the_middle_third_reparents_into_the_target() {
3503        // The Into zone: dropping on a row's middle third makes the dragged node
3504        // that row's child, rather than a sibling before/after it.
3505        use teksilo_canvas::Point;
3506        let proxy = SortFilterTreeModel::new(sample_tree());
3507        proxy.collapse_all(); // roots only: docs@0, src@1
3508        let docs = proxy.tree().root(0);
3509        let src = proxy.tree().root(1);
3510        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3511        tree.add(
3512            TreeTableView::from_projection(proxy.clone())
3513                .add_column(name_col())
3514                .reorderable(true)
3515                .row_height(20.0),
3516        );
3517        tree.layout(SizeProposal {
3518            width: Some(400.0),
3519            height: Some(300.0),
3520        });
3521        let h = cp::HEADER_HEIGHT;
3522        // Drag docs (flat 0) onto the MIDDLE third of src (flat 1, [h+20, h+40])
3523        // → Into src.
3524        drag(
3525            &mut tree,
3526            Point::new(40.0, h + 10.0),
3527            Point::new(40.0, h + 30.0),
3528        );
3529        assert_eq!(proxy.tree().root_count(), 1, "docs is no longer a root");
3530        assert_eq!(
3531            proxy.tree().parent(docs),
3532            Some(src),
3533            "docs became a child of src"
3534        );
3535    }
3536
3537    #[test]
3538    fn the_into_box_is_inset_and_the_insertion_line_is_indented() {
3539        // The twin of `TreeView`'s pair: the two drop affordances must not read
3540        // alike. Flush to the row, the Into box's top edge is the very pixel a
3541        // Before line occupies — and the drag ghost hides the vertical sides
3542        // that would have told them apart.
3543        use teksilo_canvas::{DrawCommand, Point, ShapeKind};
3544        use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
3545
3546        let proxy = SortFilterTreeModel::new(sample_tree());
3547        proxy.expand_all(); // docs@0 readme@1 guide@2 src@3 main.rs@4
3548        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3549        tree.add(
3550            TreeTableView::from_projection(proxy.clone())
3551                .add_column(name_col())
3552                .reorderable(true)
3553                .row_height(20.0),
3554        );
3555        tree.layout(SizeProposal {
3556            width: Some(400.0),
3557            height: Some(300.0),
3558        });
3559        let h = cp::HEADER_HEIGHT;
3560
3561        // Hold a drag from "main.rs" (flat 4) — nothing is inside its subtree,
3562        // so every target below accepts.
3563        let start = Point::new(40.0, h + 90.0);
3564        tree.dispatch_event(WidgetEvent::PointerDown {
3565            position: start,
3566            button: PointerButton::Primary,
3567            modifiers: Modifiers::NONE,
3568        });
3569        tree.dispatch_event(WidgetEvent::PointerMove {
3570            position: Point::new(52.0, start.y),
3571        });
3572
3573        // Bottom third of "readme" (flat 1, depth 1) → After, at depth 1.
3574        tree.dispatch_event(WidgetEvent::PointerMove {
3575            position: Point::new(52.0, h + 38.0),
3576        });
3577        let frame = tree.render();
3578        let line_recipe = teksilo_core::styles::ListInsertionRecipe::default();
3579        // The insertion line is the only decoration exactly `thickness` tall
3580        // that spans the body — identify it by that, not by "something at x>0",
3581        // which any future row stripe would satisfy vacuously.
3582        let lines: Vec<_> = frame
3583            .decorations
3584            .iter()
3585            .filter(|d| (d.rect[3] - line_recipe.thickness).abs() < 0.01 && d.rect[2] > 100.0)
3586            .collect();
3587        assert_eq!(lines.len(), 1, "exactly one insertion line, got {lines:?}");
3588        assert!(
3589            lines[0].rect[0] >= line_recipe.indent_step,
3590            "the After line must start one indent step in for a depth-1 target, \
3591             got x = {} (step {})",
3592            lines[0].rect[0],
3593            line_recipe.indent_step
3594        );
3595
3596        // Middle third of "docs" (flat 0, depth 0) → Into, a box round the row.
3597        tree.dispatch_event(WidgetEvent::PointerMove {
3598            position: Point::new(52.0, h + 10.0),
3599        });
3600        let frame = tree.render();
3601        let recipe = teksilo_core::styles::ListDropIntoRecipe::default();
3602        let boxes: Vec<_> = frame
3603            .draw_order
3604            .iter()
3605            .filter_map(|c| match c {
3606                DrawCommand::Shape(i) => frame.shapes.get(*i),
3607                _ => None,
3608            })
3609            .filter(|s| s.shape == ShapeKind::RoundedRect && s.corner_radii[0] > 0.0)
3610            .filter(|s| (s.screen[3] - (20.0 - recipe.inset * 2.0)).abs() < 0.01)
3611            .collect();
3612        assert!(
3613            !boxes.is_empty(),
3614            "no inset rounded box for the Into hover; shapes = {:?}",
3615            frame.shapes.iter().map(|s| s.screen).collect::<Vec<_>>()
3616        );
3617        // Row 0 spans [h, h + 20]. The box's top edge must sit *inside* that
3618        // band — on the boundary it is pixel-identical to a Before line.
3619        assert!(
3620            boxes
3621                .iter()
3622                .all(|s| (s.screen[1] - (h + recipe.inset)).abs() < 0.01),
3623            "the Into box must be inset from the row's top edge ({}), got {:?}",
3624            h,
3625            boxes.iter().map(|s| s.screen).collect::<Vec<_>>()
3626        );
3627        assert!(
3628            boxes.iter().any(|s| s.stroke_width > 0.0)
3629                && boxes.iter().any(|s| s.stroke_width == 0.0),
3630            "the Into box needs both a wash and an outline"
3631        );
3632    }
3633
3634    #[test]
3635    fn an_active_sort_suppresses_drag_reorder() {
3636        // With the visible order driven by a sort, a manual reorder would have no
3637        // visible effect — so it must be refused outright rather than silently
3638        // mutating the tree behind the sort.
3639        use teksilo_canvas::Point;
3640        let proxy = SortFilterTreeModel::new(sample_tree())
3641            .with_comparator("name", |a: &&'static str, b: &&'static str| a.cmp(b));
3642        proxy.collapse_all();
3643        let docs = proxy.tree().root(0);
3644        let src = proxy.tree().root(1);
3645        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3646        let id = tree.add(
3647            TreeTableView::from_projection(proxy.clone())
3648                .add_column(name_col())
3649                .reorderable(true)
3650                .row_height(20.0),
3651        );
3652        tree.layout(SizeProposal {
3653            width: Some(400.0),
3654            height: Some(300.0),
3655        });
3656        {
3657            let any = tree.widget_as_any(id).unwrap();
3658            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3659            tt.set_sort(Some("name"), SortDirection::Ascending);
3660        }
3661        tree.layout(SizeProposal {
3662            width: Some(400.0),
3663            height: Some(300.0),
3664        });
3665
3666        let h = cp::HEADER_HEIGHT;
3667        drag(
3668            &mut tree,
3669            Point::new(40.0, h + 10.0),
3670            Point::new(40.0, h + 38.0),
3671        );
3672        assert_eq!(
3673            proxy.tree().root(0),
3674            docs,
3675            "structure unchanged while sorted"
3676        );
3677        assert_eq!(
3678            proxy.tree().root(1),
3679            src,
3680            "structure unchanged while sorted"
3681        );
3682    }
3683
3684    #[test]
3685    fn an_open_cell_editor_follows_its_row_and_closes_if_the_row_vanishes() {
3686        // `editing_cell` is a (row, col) pair that outlives rebuilds. Without
3687        // reconciliation, filtering a row away above an open editor slides the
3688        // editor onto a different row and silently edits the wrong item.
3689        let slice = teksilo_data::TreeDataSlice::<u64, &'static str>::new();
3690        let all: Vec<u64> = vec![1, 2, 3];
3691        slice.set_source(move || {
3692            let names: std::collections::HashMap<u64, &'static str> =
3693                [(1, "one"), (2, "two"), (3, "three")].into_iter().collect();
3694            all.iter()
3695                .map(|k| teksilo_data::TreeRow::new(*k, names[k], 0))
3696                .collect()
3697        });
3698        slice.reload();
3699
3700        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3701        let id = tree.add(
3702            TreeTableView::from_source(slice.clone())
3703                .add_column(name_col())
3704                .row_height(20.0),
3705        );
3706        let proposal = SizeProposal {
3707            width: Some(400.0),
3708            height: Some(200.0),
3709        };
3710        tree.layout(proposal);
3711
3712        // Edit row 2 ("three" sits at index 2).
3713        {
3714            let any = tree.widget_as_any(id).unwrap();
3715            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3716            tt.begin_edit(2, "name");
3717            assert_eq!(tt.editing_cell_signal().get(), Some((2, 0)));
3718        }
3719        tree.layout(proposal); // captures the anchor
3720
3721        // Drop the FIRST row: "three" is now at index 1.
3722        let fewer: Vec<u64> = vec![2, 3];
3723        slice.set_source(move || {
3724            let names: std::collections::HashMap<u64, &'static str> =
3725                [(2, "two"), (3, "three")].into_iter().collect();
3726            fewer
3727                .iter()
3728                .map(|k| teksilo_data::TreeRow::new(*k, names[k], 0))
3729                .collect()
3730        });
3731        slice.reload();
3732        tree.layout(proposal);
3733        {
3734            let any = tree.widget_as_any(id).unwrap();
3735            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3736            assert_eq!(
3737                tt.editing_cell_signal().get(),
3738                Some((1, 0)),
3739                "the editor must follow its row to index 1, not stay on index 2"
3740            );
3741        }
3742
3743        // Now delete the edited row itself: the editor must close, not move.
3744        let last: Vec<u64> = vec![2];
3745        slice.set_source(move || {
3746            last.iter()
3747                .map(|k| teksilo_data::TreeRow::new(*k, "two", 0))
3748                .collect()
3749        });
3750        slice.reload();
3751        tree.layout(proposal);
3752        let any = tree.widget_as_any(id).unwrap();
3753        let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3754        assert_eq!(
3755            tt.editing_cell_signal().get(),
3756            None,
3757            "the editor must close when its row is gone"
3758        );
3759    }
3760
3761    #[test]
3762    fn default_selection_mode_is_multi_row() {
3763        // The doc claimed `RowSingle` — a variant that does not exist. Pin the
3764        // real default behaviorally so prose can't drift from it again:
3765        // Shift+ArrowDown twice extends to 3 rows, which only MultiRow allows.
3766        use teksilo_core::event::{Key, Modifiers};
3767        let proxy = SortFilterTreeModel::new(wide_tree(10));
3768        let selection = teksilo_data::SelectionModel::new(teksilo_data::SelectionMode::Multi);
3769        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3770        let id = tree.add(
3771            TreeTableView::from_projection(proxy)
3772                .add_column(name_col())
3773                .selection(selection.clone())
3774                .row_height(20.0),
3775        );
3776        tree.layout(SizeProposal {
3777            width: Some(400.0),
3778            height: Some(200.0),
3779        });
3780        tree.focus(id);
3781        {
3782            let any = tree.widget_as_any(id).unwrap();
3783            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3784            tt.set_focused_cell(0, 0);
3785        }
3786        selection.select(0);
3787        tree.press_key(Key::ArrowDown, Modifiers::SHIFT);
3788        tree.press_key(Key::ArrowDown, Modifiers::SHIFT);
3789        assert_eq!(
3790            selection.selection_signal().get().len(),
3791            3,
3792            "default mode must extend a multi-row selection"
3793        );
3794    }
3795
3796    #[test]
3797    fn ctrl_arrow_moves_cursor_without_touching_selection() {
3798        // Explorer/Finder convention (shared with `TableView` via the
3799        // common `keyboard::build_key_handler`): Ctrl+Arrow repositions the
3800        // keyboard cursor without touching selection; plain Arrow keeps its
3801        // existing select-follow behavior.
3802        use teksilo_core::event::{Key, Modifiers};
3803        let proxy = SortFilterTreeModel::new(wide_tree(5));
3804        let selection = teksilo_data::SelectionModel::new(teksilo_data::SelectionMode::Multi);
3805        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3806        let id = tree.add(
3807            TreeTableView::from_projection(proxy)
3808                .add_column(name_col())
3809                .selection(selection.clone())
3810                .row_height(20.0),
3811        );
3812        tree.layout(SizeProposal {
3813            width: Some(400.0),
3814            height: Some(200.0),
3815        });
3816        tree.focus(id);
3817        {
3818            let any = tree.widget_as_any(id).unwrap();
3819            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3820            tt.set_focused_cell(0, 0);
3821        }
3822        selection.select(0);
3823
3824        tree.press_key(Key::ArrowDown, Modifiers::CTRL);
3825        {
3826            let any = tree.widget_as_any(id).unwrap();
3827            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3828            assert_eq!(
3829                tt.focused_cell_signal().get(),
3830                Some((1, 0)),
3831                "cursor advances"
3832            );
3833        }
3834        assert_eq!(
3835            selection.selected_indices(),
3836            vec![0],
3837            "Ctrl+Arrow must not touch selection"
3838        );
3839
3840        // Plain Arrow (no Ctrl) resumes select-follow from the cursor.
3841        tree.press_key(Key::ArrowDown, Modifiers::NONE);
3842        {
3843            let any = tree.widget_as_any(id).unwrap();
3844            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3845            assert_eq!(tt.focused_cell_signal().get(), Some((2, 0)));
3846        }
3847        assert_eq!(
3848            selection.selected_indices(),
3849            vec![2],
3850            "plain Arrow selects the row it lands on"
3851        );
3852    }
3853
3854    #[test]
3855    fn ctrl_space_toggles_the_cursor_row_after_a_ctrl_arrow_move() {
3856        use teksilo_core::event::{Key, Modifiers};
3857        let proxy = SortFilterTreeModel::new(wide_tree(5));
3858        let selection = teksilo_data::SelectionModel::new(teksilo_data::SelectionMode::Multi);
3859        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3860        let id = tree.add(
3861            TreeTableView::from_projection(proxy)
3862                .add_column(name_col())
3863                .selection(selection.clone())
3864                .row_height(20.0),
3865        );
3866        tree.layout(SizeProposal {
3867            width: Some(400.0),
3868            height: Some(200.0),
3869        });
3870        tree.focus(id);
3871        {
3872            let any = tree.widget_as_any(id).unwrap();
3873            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3874            tt.set_focused_cell(0, 0);
3875        }
3876        tree.press_key(Key::ArrowDown, Modifiers::CTRL);
3877        tree.press_key(Key::ArrowDown, Modifiers::CTRL);
3878        assert!(selection.selected_indices().is_empty());
3879
3880        tree.press_key(Key::Space, Modifiers::CTRL);
3881        assert_eq!(
3882            selection.selected_indices(),
3883            vec![2],
3884            "Ctrl+Space toggles the focused row on"
3885        );
3886
3887        tree.press_key(Key::Space, Modifiers::CTRL);
3888        assert!(
3889            selection.selected_indices().is_empty(),
3890            "Ctrl+Space toggles it back off"
3891        );
3892    }
3893
3894    #[test]
3895    fn empty_view_renders_when_the_tree_has_no_rows() {
3896        let proxy = SortFilterTreeModel::new(TreeModel::<&'static str>::new());
3897        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3898        let id = tree.add(
3899            TreeTableView::from_projection(proxy)
3900                .add_column(name_col())
3901                .empty_view(|| Box::new(crate::primitives::TextWidget::new(lit!("Nothing here"))))
3902                .row_height(20.0),
3903        );
3904        tree.layout(SizeProposal {
3905            width: Some(400.0),
3906            height: Some(200.0),
3907        });
3908        let any = tree.widget_as_any(id).unwrap();
3909        let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3910        assert!(tt.empty_id.is_some(), "placeholder should be built");
3911        assert!(tt.body_pane_id.is_none(), "no body pane for zero rows");
3912    }
3913
3914    #[test]
3915    fn empty_view_appears_when_live_rows_drop_to_zero() {
3916        // The transition case: rows exist, the widget is live, then a filter
3917        // removes them all. The body pane must be torn down and the
3918        // placeholder built — constructing already-empty (the two tests below)
3919        // never exercises that path.
3920        let proxy = SortFilterTreeModel::new(sample_tree()).with_predicate("name", |t| {
3921            let needle = t.to_string();
3922            Box::new(move |r: &&'static str| r.contains(&needle))
3923        });
3924        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3925        let id = tree.add(
3926            TreeTableView::from_projection(proxy.clone())
3927                .add_column(name_col())
3928                .empty_view(|| Box::new(crate::primitives::TextWidget::new(lit!("No matches"))))
3929                .row_height(20.0),
3930        );
3931        let proposal = SizeProposal {
3932            width: Some(400.0),
3933            height: Some(200.0),
3934        };
3935        tree.layout(proposal);
3936        {
3937            let any = tree.widget_as_any(id).unwrap();
3938            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3939            assert!(tt.body_pane_id.is_some(), "starts with a body pane");
3940            assert!(tt.empty_id.is_none(), "no placeholder while rows exist");
3941        }
3942
3943        proxy.set_filter("name", "zzz-no-such-row");
3944        tree.layout(proposal);
3945        assert_eq!(proxy.visible_count(), 0);
3946        let any = tree.widget_as_any(id).unwrap();
3947        let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3948        assert!(
3949            tt.empty_id.is_some(),
3950            "placeholder must appear once rows drop to zero"
3951        );
3952        assert!(tt.body_pane_id.is_none(), "stale body pane must be gone");
3953    }
3954
3955    #[test]
3956    fn empty_view_renders_when_a_filter_matches_nothing() {
3957        // The other half of the empty state: rows exist, but none survive the
3958        // filter. Without this the user sees a blank pane and no explanation.
3959        let proxy = SortFilterTreeModel::new(sample_tree()).with_predicate("name", |t| {
3960            let needle = t.to_string();
3961            Box::new(move |r: &&'static str| r.contains(&needle))
3962        });
3963        proxy.set_filter("name", "zzz-no-such-row");
3964        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3965        let id = tree.add(
3966            TreeTableView::from_projection(proxy.clone())
3967                .add_column(name_col())
3968                .empty_view(|| Box::new(crate::primitives::TextWidget::new(lit!("No matches"))))
3969                .row_height(20.0),
3970        );
3971        tree.layout(SizeProposal {
3972            width: Some(400.0),
3973            height: Some(200.0),
3974        });
3975        assert_eq!(proxy.visible_count(), 0);
3976        let any = tree.widget_as_any(id).unwrap();
3977        let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3978        assert!(tt.empty_id.is_some());
3979    }
3980
3981    #[test]
3982    fn scroll_to_row_and_ensure_row_visible_move_the_offset() {
3983        let proxy = SortFilterTreeModel::new(wide_tree(100));
3984        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3985        let id = tree.add(
3986            TreeTableView::from_projection(proxy)
3987                .add_column(name_col())
3988                .row_height(20.0),
3989        );
3990        tree.layout(SizeProposal {
3991            width: Some(400.0),
3992            height: Some(200.0),
3993        });
3994        let any = tree.widget_as_any(id).unwrap();
3995        let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3996
3997        // Aligns the row to the top: row 50 × 20 px.
3998        tt.scroll_to_row(50);
3999        assert!((tt.scroll_y_signal().get() - 1000.0).abs() < 1.0);
4000
4001        // Already-visible row: minimum scroll means no movement.
4002        let before = tt.scroll_y_signal().get();
4003        tt.ensure_row_visible(51);
4004        assert!((tt.scroll_y_signal().get() - before).abs() < f32::EPSILON);
4005
4006        // Off-screen upward: scrolls back just far enough.
4007        tt.ensure_row_visible(10);
4008        assert!((tt.scroll_y_signal().get() - 200.0).abs() < 1.0);
4009    }
4010
4011    #[test]
4012    fn begin_edit_resolves_a_column_id_and_end_edit_clears() {
4013        let proxy = SortFilterTreeModel::new(sample_tree());
4014        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4015        let id = tree.add(
4016            TreeTableView::from_projection(proxy)
4017                .add_column(name_col())
4018                .add_column(size_col())
4019                .row_height(20.0),
4020        );
4021        tree.layout(SizeProposal {
4022            width: Some(400.0),
4023            height: Some(200.0),
4024        });
4025        let any = tree.widget_as_any(id).unwrap();
4026        let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4027
4028        tt.begin_edit(1, "size");
4029        assert_eq!(tt.editing_cell_signal().get(), Some((1, 1)));
4030        tt.end_edit();
4031        assert_eq!(tt.editing_cell_signal().get(), None);
4032
4033        // Unknown id is a silent no-op, not a panic or a bogus position.
4034        tt.begin_edit(0, "no-such-column");
4035        assert_eq!(tt.editing_cell_signal().get(), None);
4036
4037        // An out-of-range row is refused too: without the bounds check this
4038        // stranded `editing_cell` on a row nothing could ever match, and only
4039        // an explicit `end_edit` would clear it.
4040        tt.begin_edit(9999, "name");
4041        assert_eq!(tt.editing_cell_signal().get(), None);
4042
4043        // ...and a refused call must not clobber a live editor.
4044        tt.begin_edit(1, "size");
4045        tt.begin_edit(9999, "size");
4046        assert_eq!(tt.editing_cell_signal().get(), Some((1, 1)));
4047    }
4048
4049    #[test]
4050    fn begin_edit_resolves_before_the_view_is_mounted() {
4051        // Seeding a freshly constructed view with an edit target it already
4052        // holds is only possible on the builder — a rebuild makes a brand-new
4053        // view whose `editing_cell` starts `None`, and there is no post-mount
4054        // handle (`as_any_mut` is not overridden). `display_indices` is filled
4055        // by `build()`, so before the fix this resolved against an empty cache
4056        // and silently did nothing: the caller's edit request vanished.
4057        //
4058        // `size` is pinned Leading, so display order is [size, name] and the
4059        // correct answer for "name" is 1, not its declaration index 0 — which
4060        // is what makes this a test of `display_order()` and not of a shortcut
4061        // that happens to agree when nothing is pinned.
4062        let proxy = SortFilterTreeModel::new(sample_tree());
4063        let view = TreeTableView::from_projection(proxy)
4064            .add_column(name_col())
4065            .add_column(size_col().pinned(PinnedSide::Leading))
4066            .row_height(20.0);
4067
4068        view.begin_edit(1, "name");
4069        assert_eq!(view.editing_cell_signal().get(), Some((1, 1)));
4070
4071        // The documented no-ops still hold with no cache to consult.
4072        view.end_edit();
4073        view.begin_edit(0, "no-such-column");
4074        assert_eq!(view.editing_cell_signal().get(), None);
4075        view.begin_edit(9999, "name");
4076        assert_eq!(view.editing_cell_signal().get(), None);
4077
4078        // And the seed survives mounting: the target it resolved is the one
4079        // the body pane reads back.
4080        view.begin_edit(1, "name");
4081        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4082        let id = tree.add(view);
4083        tree.layout(SizeProposal {
4084            width: Some(400.0),
4085            height: Some(200.0),
4086        });
4087        let tt = tree
4088            .widget_as_any(id)
4089            .unwrap()
4090            .downcast_ref::<TreeTableView<&'static str>>()
4091            .unwrap();
4092        assert_eq!(tt.editing_cell_signal().get(), Some((1, 1)));
4093    }
4094
4095    #[test]
4096    fn column_imperatives_write_their_signals() {
4097        let proxy = SortFilterTreeModel::new(sample_tree());
4098        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4099        let id = tree.add(
4100            TreeTableView::from_projection(proxy)
4101                .add_column(name_col())
4102                .add_column(size_col())
4103                .row_height(20.0),
4104        );
4105        tree.layout(SizeProposal {
4106            width: Some(400.0),
4107            height: Some(200.0),
4108        });
4109        let any = tree.widget_as_any(id).unwrap();
4110        let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4111
4112        tt.set_column_width("name", 123.0);
4113        assert_eq!(tt.column_widths_signal().get().get("name"), Some(&123.0));
4114        // A non-positive width removes the override rather than pinning 0 px.
4115        tt.set_column_width("name", 0.0);
4116        assert!(!tt.column_widths_signal().get().contains_key("name"));
4117
4118        // Order and pinning must actually reach `display_order()`, not just sit
4119        // in a signal nothing reads. Columns are declared name(0), size(1).
4120        assert_eq!(
4121            tt.display_order(),
4122            vec![0, 1],
4123            "declaration order initially"
4124        );
4125
4126        tt.set_column_order(vec!["size".into(), "name".into()]);
4127        assert_eq!(tt.column_order_signal().get(), vec!["size", "name"]);
4128        assert_eq!(
4129            tt.display_order(),
4130            vec![1, 0],
4131            "set_column_order must reorder the display, not only the signal"
4132        );
4133
4134        // Pinning outranks the order list: a Leading-pinned column sorts into
4135        // the leading band regardless of where the order puts it.
4136        tt.set_column_pinning("name", PinnedSide::Leading);
4137        assert_eq!(
4138            tt.column_pinning_signal().get().get("name"),
4139            Some(&PinnedSide::Leading)
4140        );
4141        assert_eq!(
4142            tt.display_order(),
4143            vec![0, 1],
4144            "set_column_pinning must pull the pinned column back to the front"
4145        );
4146        tt.set_column_pinning("name", PinnedSide::None);
4147        assert!(!tt.column_pinning_signal().get().contains_key("name"));
4148        assert_eq!(
4149            tt.display_order(),
4150            vec![1, 0],
4151            "clearing the pin restores the order list's arrangement"
4152        );
4153
4154        tt.set_sort(Some("name"), SortDirection::Ascending);
4155        assert!(tt.sort_signal().get().is_some());
4156        tt.clear_sort();
4157        assert_eq!(tt.sort_signal().get(), None);
4158    }
4159
4160    // ── Cell state survives a column reorder/pin ───────────────────────
4161    //
4162    // `focused_cell`, `editing_cell`, and `CellSelectionModel` all store
4163    // `(row, display_position)`. A drag-to-reorder or a pin toggle only
4164    // bumps the rebuild version — without a remap, the stored display
4165    // position would silently relabel onto whatever column now sits
4166    // there. Pinning makes display order diverge from declaration order
4167    // (columns are declared name(0), size(1)), so a shortcut that merely
4168    // keeps the same index would fail these.
4169
4170    #[test]
4171    fn column_pinning_remaps_focused_cell_to_follow_its_column() {
4172        let proxy = SortFilterTreeModel::new(sample_tree());
4173        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4174        let id = tree.add(
4175            TreeTableView::from_projection(proxy)
4176                .add_column(name_col())
4177                .add_column(size_col())
4178                .row_height(20.0),
4179        );
4180        tree.layout(SizeProposal {
4181            width: Some(400.0),
4182            height: Some(200.0),
4183        });
4184        tree.focus(id);
4185        {
4186            let any = tree.widget_as_any(id).unwrap();
4187            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4188            tt.set_focused_cell(0, 1); // focus `size`, at display position 1
4189            // Pinning `size` Leading swaps it ahead of `name` — display
4190            // order becomes [size, name]. A stale (0, 1) would now land
4191            // on `name`.
4192            tt.set_column_pinning("size", PinnedSide::Leading);
4193        }
4194        tree.layout(SizeProposal {
4195            width: Some(400.0),
4196            height: Some(200.0),
4197        });
4198        let any = tree.widget_as_any(id).unwrap();
4199        let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4200        assert_eq!(
4201            tt.focused_cell_signal().get(),
4202            Some((0, 0)),
4203            "focus must follow `size` to its new display position"
4204        );
4205    }
4206
4207    #[test]
4208    fn column_pinning_remaps_editing_cell_to_follow_its_column() {
4209        let proxy = SortFilterTreeModel::new(sample_tree());
4210        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4211        let id = tree.add(
4212            TreeTableView::from_projection(proxy)
4213                .add_column(name_col())
4214                .add_column(size_col())
4215                .row_height(20.0),
4216        );
4217        tree.layout(SizeProposal {
4218            width: Some(400.0),
4219            height: Some(200.0),
4220        });
4221        {
4222            let any = tree.widget_as_any(id).unwrap();
4223            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4224            tt.begin_edit(0, "size"); // size @ display position 1
4225            assert_eq!(tt.editing_cell_signal().get(), Some((0, 1)));
4226            tt.set_column_pinning("size", PinnedSide::Leading);
4227        }
4228        tree.layout(SizeProposal {
4229            width: Some(400.0),
4230            height: Some(200.0),
4231        });
4232        let any = tree.widget_as_any(id).unwrap();
4233        let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4234        assert_eq!(
4235            tt.editing_cell_signal().get(),
4236            Some((0, 0)),
4237            "the open editor must follow `size` to its new display \
4238             position, not relabel onto whatever column now sits at \
4239             position 1"
4240        );
4241    }
4242
4243    #[test]
4244    fn column_pinning_remaps_cell_selection_to_follow_its_column() {
4245        let proxy = SortFilterTreeModel::new(sample_tree());
4246        let cs = CellSelectionModel::new(TableSelectionMode::MultiCell);
4247        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4248        let id = tree.add(
4249            TreeTableView::from_projection(proxy)
4250                .add_column(name_col())
4251                .add_column(size_col())
4252                .row_height(20.0)
4253                .selection_mode(TableSelectionMode::MultiCell)
4254                .cell_selection(cs.clone()),
4255        );
4256        tree.layout(SizeProposal {
4257            width: Some(400.0),
4258            height: Some(200.0),
4259        });
4260        cs.select(0, 1); // select `size` at display position 1
4261        {
4262            let any = tree.widget_as_any(id).unwrap();
4263            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4264            tt.set_column_pinning("size", PinnedSide::Leading);
4265        }
4266        tree.layout(SizeProposal {
4267            width: Some(400.0),
4268            height: Some(200.0),
4269        });
4270        assert!(
4271            cs.is_selected(0, 0),
4272            "selection must follow `size` to its new display position"
4273        );
4274        assert!(!cs.is_selected(0, 1));
4275    }
4276
4277    #[test]
4278    fn collapsing_a_node_above_a_selected_cell_clears_stale_cell_selection() {
4279        // Cell selection is index-based; a `TreeDataSource`'s flattening
4280        // gives no per-row delta to reindex it by (unlike `TableView`'s
4281        // `ListModel` `DataChange`), so the honest fix on a structural
4282        // change is to drop the selection rather than let a stale flat
4283        // row index silently point at whatever node now occupies it.
4284        let proxy = SortFilterTreeModel::new(sample_tree());
4285        let docs = proxy.tree().root(0);
4286        let cs = CellSelectionModel::new(TableSelectionMode::MultiCell);
4287        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4288        let id = tree.add(
4289            TreeTableView::from_projection(proxy.clone())
4290                .add_column(name_col())
4291                .row_height(20.0)
4292                .selection_mode(TableSelectionMode::MultiCell)
4293                .cell_selection(cs.clone()),
4294        );
4295        tree.layout(SizeProposal {
4296            width: Some(400.0),
4297            height: Some(200.0),
4298        });
4299        {
4300            let any = tree.widget_as_any(id).unwrap();
4301            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4302            tt.expand(docs);
4303        }
4304        tree.layout(SizeProposal {
4305            width: Some(400.0),
4306            height: Some(200.0),
4307        });
4308        assert_eq!(proxy.visible_count(), 4); // docs, readme, guide, src
4309        cs.select(3, 0); // `src`, the last flat row
4310        {
4311            let any = tree.widget_as_any(id).unwrap();
4312            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4313            tt.collapse(docs);
4314        }
4315        tree.layout(SizeProposal {
4316            width: Some(400.0),
4317            height: Some(200.0),
4318        });
4319        assert_eq!(proxy.visible_count(), 2); // docs, src — `src` is now row 1
4320        assert_eq!(
4321            cs.count(),
4322            0,
4323            "a stale (row, col) surviving the collapse must be dropped, not \
4324             silently point at whatever node now sits at flat row 3"
4325        );
4326    }
4327
4328    #[test]
4329    fn content_only_update_leaves_cell_selection_untouched() {
4330        // A version bump that doesn't change the flat row count — an
4331        // in-place item edit, no expand/collapse/insert/remove — must not
4332        // disturb an existing cell selection.
4333        let model = sample_tree();
4334        let proxy = SortFilterTreeModel::new(model);
4335        let docs = proxy.tree().root(0);
4336        let cs = CellSelectionModel::new(TableSelectionMode::MultiCell);
4337        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4338        tree.add(
4339            TreeTableView::from_projection(proxy.clone())
4340                .add_column(name_col())
4341                .row_height(20.0)
4342                .selection_mode(TableSelectionMode::MultiCell)
4343                .cell_selection(cs.clone()),
4344        );
4345        tree.layout(SizeProposal {
4346            width: Some(400.0),
4347            height: Some(200.0),
4348        });
4349        cs.select(0, 0); // `docs`
4350        // In-place content update — same node, same position, new label.
4351        proxy.tree().update(docs, "docs-renamed");
4352        tree.layout(SizeProposal {
4353            width: Some(400.0),
4354            height: Some(200.0),
4355        });
4356        assert!(
4357            cs.is_selected(0, 0),
4358            "a content-only update must leave an unrelated selection alone"
4359        );
4360    }
4361
4362    // ── AT active_descendant follows cell focus ─────────────────────────
4363
4364    #[test]
4365    fn focused_cell_sets_active_descendant_to_the_cell_node() {
4366        let proxy = SortFilterTreeModel::new(sample_tree());
4367        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4368        let id = tree.add(
4369            TreeTableView::from_projection(proxy)
4370                .add_column(name_col())
4371                .add_column(size_col())
4372                .row_height(20.0),
4373        );
4374        tree.layout(SizeProposal {
4375            width: Some(400.0),
4376            height: Some(200.0),
4377        });
4378        tree.focus(id);
4379        {
4380            let any = tree.widget_as_any(id).unwrap();
4381            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4382            tt.set_focused_cell(0, 1);
4383        }
4384        let update = tree.sync_accessibility();
4385        let root_node_id = widget_id_to_node_id(id);
4386        let root_node = update
4387            .nodes
4388            .iter()
4389            .find(|(nid, _)| *nid == root_node_id)
4390            .map(|(_, n)| n)
4391            .expect("root node present in the AT tree");
4392        let active = root_node
4393            .active_descendant()
4394            .expect("a focused cell must set active_descendant");
4395        let cell_node = update
4396            .nodes
4397            .iter()
4398            .find(|(nid, _)| *nid == active)
4399            .map(|(_, n)| n)
4400            .expect("active_descendant must reference a node present in the TreeUpdate");
4401        assert_eq!(cell_node.role(), Role::Cell);
4402    }
4403
4404    #[test]
4405    fn active_descendant_clears_after_the_focused_cell_scrolls_out_of_realization() {
4406        let proxy = SortFilterTreeModel::new(wide_tree(1000));
4407        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4408        let id = tree.add(
4409            TreeTableView::from_projection(proxy)
4410                .add_column(name_col())
4411                .row_height(20.0),
4412        );
4413        tree.layout(SizeProposal {
4414            width: Some(400.0),
4415            height: Some(200.0),
4416        });
4417        tree.focus(id);
4418        {
4419            let any = tree.widget_as_any(id).unwrap();
4420            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4421            tt.set_focused_cell(1, 0);
4422        }
4423        let root_node_id = widget_id_to_node_id(id);
4424        let update = tree.sync_accessibility();
4425        let active_before = update
4426            .nodes
4427            .iter()
4428            .find(|(nid, _)| *nid == root_node_id)
4429            .and_then(|(_, n)| n.active_descendant());
4430        assert!(active_before.is_some(), "row 1 is realized initially");
4431
4432        // Scroll far enough that row 1 leaves the realized+buffer window.
4433        // Nothing clears `focused_cell` on scroll, so this exercises the
4434        // "stale id" hazard directly: the pre-scroll build's cell WidgetId
4435        // has no live AT node once the pane rebuilds without it.
4436        let signal = {
4437            let any = tree.widget_as_any(id).unwrap();
4438            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4439            tt.scroll_y_signal().clone()
4440        };
4441        signal.set(2000.0);
4442        tree.request_frame();
4443        tree.layout(SizeProposal {
4444            width: Some(400.0),
4445            height: Some(200.0),
4446        });
4447
4448        let update = tree.sync_accessibility();
4449        let active_after = update
4450            .nodes
4451            .iter()
4452            .find(|(nid, _)| *nid == root_node_id)
4453            .and_then(|(_, n)| n.active_descendant());
4454        assert_eq!(
4455            active_after, None,
4456            "a focused cell that scrolled out of realization must not leave \
4457             a stale active_descendant pointing at a destroyed node"
4458        );
4459    }
4460
4461    /// Taking focus reveals the row the keyboard cursor sits on.
4462    ///
4463    /// Only the rows near the viewport are realized, so a cursor placed before
4464    /// the view is ever looked at (a restored position, a preselected row)
4465    /// usually has no cell widget at all. Nothing then speaks for it:
4466    /// `accessibility()` finds no entry in `cell_map`, so it nominates no
4467    /// `active_descendant`, and a screen reader arriving here is told nothing.
4468    /// The first arrow press steps *past* that row as well, because the cursor
4469    /// was somewhere nobody was shown.
4470    ///
4471    /// Asserted on the accessibility tree, since that is what the failure was
4472    /// about: the cell has to be a node a platform can name.
4473    #[test]
4474    fn taking_focus_reveals_the_cursor_row() {
4475        let proxy = SortFilterTreeModel::new(wide_tree(1000));
4476        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4477        let id = tree.add(
4478            TreeTableView::from_projection(proxy)
4479                .add_column(name_col())
4480                .row_height(20.0),
4481        );
4482        let viewport = SizeProposal {
4483            width: Some(400.0),
4484            height: Some(200.0),
4485        };
4486        tree.layout(viewport);
4487
4488        // Place the cursor far below the viewport WITHOUT giving the view
4489        // focus. `set_focused_cell` never scrolls on its own: only the key
4490        // handler does (`table_view/keyboard.rs:445`), and no key was pressed.
4491        {
4492            let any = tree.widget_as_any(id).unwrap();
4493            any.downcast_ref::<TreeTableView<&'static str>>()
4494                .unwrap()
4495                .set_focused_cell(500, 0);
4496        }
4497        tree.request_frame();
4498        tree.layout(viewport);
4499
4500        let root_node_id = widget_id_to_node_id(id);
4501        // What a platform adapter is handed: the container's
4502        // `active_descendant`, resolved inside the same `TreeUpdate` that
4503        // published it.
4504        let nominated = |tree: &mut WidgetTree| -> Option<(Role, Option<usize>)> {
4505            let update = tree.sync_accessibility();
4506            let active = update
4507                .nodes
4508                .iter()
4509                .find(|(nid, _)| *nid == root_node_id)
4510                .and_then(|(_, n)| n.active_descendant())?;
4511            update
4512                .nodes
4513                .iter()
4514                .find(|(nid, _)| *nid == active)
4515                .map(|(_, n)| (n.role(), n.row_index()))
4516        };
4517
4518        assert_eq!(
4519            nominated(&mut tree),
4520            None,
4521            "row 500 starts far outside the realized window, which is the case \
4522             this is about"
4523        );
4524
4525        tree.focus(id);
4526        tree.layout(viewport);
4527
4528        assert_eq!(
4529            nominated(&mut tree),
4530            // `row_index` is stored zero-based and the adapters add the 1 back,
4531            // so the cursor's row 500 reads as 501 with the header counted.
4532            Some((Role::Cell, Some(501))),
4533            "taking focus has to bring the cursor's row into the realized \
4534             window, or nothing in the tree can be told about it"
4535        );
4536    }
4537
4538    /// With no cell navigated to yet, the selected row is the one revealed.
4539    ///
4540    /// A view restored into a selection has no `focused_cell`, so reading the
4541    /// cursor only from that signal would leave the selection off-screen and
4542    /// unrealized: no row node carrying `selected` for AT-SPI to announce
4543    /// either, and the next arrow press stepping from a row nobody saw. The
4544    /// fallback order is the keyboard handler's own
4545    /// (`table_view/keyboard.rs:134-139`).
4546    #[test]
4547    fn taking_focus_reveals_the_selected_row_when_no_cell_has_been_navigated_to() {
4548        use teksilo_data::{SelectionMode, SelectionModel};
4549
4550        let proxy = SortFilterTreeModel::new(wide_tree(1000));
4551        let sel = SelectionModel::new(SelectionMode::Single);
4552        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4553        let id = tree.add(
4554            TreeTableView::from_projection(proxy)
4555                .add_column(name_col())
4556                .row_height(20.0)
4557                .selection_mode(TableSelectionMode::SingleRow)
4558                .selection(sel.clone()),
4559        );
4560        let viewport = SizeProposal {
4561            width: Some(400.0),
4562            height: Some(200.0),
4563        };
4564        tree.layout(viewport);
4565
4566        sel.select(500);
4567        tree.request_frame();
4568        tree.layout(viewport);
4569
4570        let selected_rows = |tree: &mut WidgetTree| -> Vec<usize> {
4571            tree.sync_accessibility()
4572                .nodes
4573                .iter()
4574                .filter(|(_, n)| n.role() == Role::Row && n.is_selected() == Some(true))
4575                .filter_map(|(_, n)| n.row_index())
4576                .collect()
4577        };
4578
4579        assert!(
4580            selected_rows(&mut tree).is_empty(),
4581            "row 500 starts far outside the realized window, which is the case \
4582             this is about"
4583        );
4584
4585        tree.focus(id);
4586        tree.layout(viewport);
4587
4588        assert_eq!(
4589            selected_rows(&mut tree),
4590            vec![501],
4591            "taking focus has to realize the selected row, or there is no node \
4592             carrying `selected` for a screen reader to find"
4593        );
4594    }
4595
4596    /// The index revealed is a **visible** row, not a position in the
4597    /// unflattened tree.
4598    ///
4599    /// The two differ by every descendant hidden above the cursor, so a tree
4600    /// with collapsed branches is where a confusion between them shows. Here
4601    /// the first twenty roots keep their nine children each and show none of
4602    /// them, which slides every row below 180 places up the flat order while
4603    /// nothing about the tree itself moves. The gap is deliberately far wider
4604    /// than the realized window: a reveal aimed at the unflattened position
4605    /// would leave the cursor's row with no widget at all, which is the state
4606    /// this whole change is about.
4607    ///
4608    /// The reveal is fed `focused_cell`, whose row the keyboard handler clamps
4609    /// against `TreeNavigator::row_count()` = `TreeSource::visible_count()`
4610    /// (`tree_table_view.rs:129-131`), and it spends that index on
4611    /// `RowMetrics`, which `place_children` sizes from the same
4612    /// `visible_count()`. Both ends are therefore the flat order this test
4613    /// reads through `SortFilterTreeModel::visible_node_id`.
4614    ///
4615    /// Checked by identity rather than by arithmetic: the nominated cell has
4616    /// to be the one holding the node the cursor was put on.
4617    #[test]
4618    fn the_revealed_row_is_a_visible_index_not_a_position_in_the_unflattened_tree() {
4619        use teksilo_core::accessibility::node_id_to_widget_id;
4620
4621        let model = TreeModel::new();
4622        let mut to_collapse = Vec::new();
4623        let mut needle = None;
4624        for r in 0..40usize {
4625            let root = model.insert_root(r, "root");
4626            if r < 20 {
4627                to_collapse.push(root);
4628            }
4629            for c in 0..9usize {
4630                let label = if r == 30 && c == 4 { "needle" } else { "leaf" };
4631                let child = model.insert_child(root, c, label);
4632                if r == 30 && c == 4 {
4633                    needle = Some(child);
4634                }
4635            }
4636        }
4637        let needle = needle.expect("the needle inserted");
4638
4639        let proxy = SortFilterTreeModel::new(model);
4640        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4641        let id = tree.add(
4642            TreeTableView::from_projection(proxy.clone())
4643                .add_column(name_col())
4644                .row_height(20.0),
4645        );
4646        let viewport = SizeProposal {
4647            width: Some(400.0),
4648            height: Some(200.0),
4649        };
4650        tree.layout(viewport);
4651        {
4652            let any = tree.widget_as_any(id).unwrap();
4653            any.downcast_ref::<TreeTableView<&'static str>>()
4654                .unwrap()
4655                .expand_all();
4656        }
4657        tree.layout(viewport);
4658
4659        let flat_of = |node| {
4660            (0..proxy.visible_count())
4661                .find(|&i| proxy.visible_node_id(i) == Some(node))
4662                .expect("the node is visible")
4663        };
4664        let flat_expanded = flat_of(needle);
4665
4666        {
4667            let any = tree.widget_as_any(id).unwrap();
4668            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4669            for root in to_collapse {
4670                tt.collapse(root);
4671            }
4672        }
4673        tree.layout(viewport);
4674
4675        let flat = flat_of(needle);
4676        assert_eq!(
4677            flat,
4678            flat_expanded - 180,
4679            "collapsing the first twenty roots has to move the needle up the \
4680             flat order without moving it in the tree, or this test proves \
4681             nothing"
4682        );
4683
4684        {
4685            let any = tree.widget_as_any(id).unwrap();
4686            any.downcast_ref::<TreeTableView<&'static str>>()
4687                .unwrap()
4688                .set_focused_cell(flat, 0);
4689        }
4690        tree.request_frame();
4691        tree.layout(viewport);
4692        tree.focus(id);
4693        tree.layout(viewport);
4694
4695        let root_node_id = widget_id_to_node_id(id);
4696        let update = tree.sync_accessibility();
4697        let active = update
4698            .nodes
4699            .iter()
4700            .find(|(nid, _)| *nid == root_node_id)
4701            .and_then(|(_, n)| n.active_descendant())
4702            .expect(
4703                "taking focus has to bring the cursor's row into the realized \
4704                 window, or nothing in the tree can be told about it",
4705            );
4706        let cell = update
4707            .nodes
4708            .iter()
4709            .find(|(nid, _)| *nid == active)
4710            .map(|(_, n)| n)
4711            .expect("active_descendant must reference a node in the TreeUpdate");
4712        assert_eq!(cell.row_index(), Some(flat + 1));
4713
4714        // And it is the needle's own cell: walk the nominated cell's widget
4715        // subtree for the label the delegate rendered.
4716        let mut q = vec![node_id_to_widget_id(active)];
4717        let mut names = Vec::new();
4718        while let Some(w) = q.pop() {
4719            if let Some(name) = tree.accessibility_node(w).name() {
4720                names.push(name.to_string());
4721            }
4722            for c in tree.children(w) {
4723                q.push(c);
4724            }
4725        }
4726        assert!(
4727            names.iter().any(|n| n == "needle"),
4728            "the revealed row must be the node the cursor was put on, got {names:?}"
4729        );
4730    }
4731
4732    #[test]
4733    fn lazy_loading_rows_render_placeholder_cells_and_request_the_window() {
4734        // A windowed tree source with nothing resident: every visible row
4735        // is `Loading`, so the pane must render placeholder cells (not
4736        // skip the rows — `meta()` returning `None` used to mean "off the
4737        // end of `start..end`" unconditionally) and the view must nudge
4738        // the source to load the realized window. Mirrors TableView's
4739        // `lazy_loading_rows_render_placeholder_cells_and_request_the_window`.
4740        use std::cell::RefCell;
4741        use std::ops::Range;
4742        use teksilo_data::{FlatEntry, RowState};
4743
4744        struct Windowed {
4745            total: usize,
4746            requested: Rc<RefCell<Vec<Range<usize>>>>,
4747            version: Signal<u64>,
4748        }
4749        impl TreeDataSource for Windowed {
4750            type Item = &'static str;
4751            type Key = usize;
4752            fn visible_count(&self) -> usize {
4753                self.total
4754            }
4755            fn with_entry<R>(
4756                &self,
4757                _i: usize,
4758                _f: impl FnOnce(&&'static str, &FlatEntry<usize>) -> R,
4759            ) -> Option<R> {
4760                None // nothing resident yet
4761            }
4762            fn key_at(&self, i: usize) -> Option<usize> {
4763                (i < self.total).then_some(i)
4764            }
4765            fn flat_index_of(&self, key: &usize) -> Option<usize> {
4766                (*key < self.total).then_some(*key)
4767            }
4768            fn parent(&self, _key: &usize) -> Option<usize> {
4769                None
4770            }
4771            fn child_keys(&self, _key: &usize) -> Vec<usize> {
4772                vec![]
4773            }
4774            fn version_signal(&self) -> Signal<u64> {
4775                self.version.clone()
4776            }
4777            fn is_expanded(&self, _key: &usize) -> bool {
4778                false
4779            }
4780            fn set_expanded(&self, _key: &usize, _expanded: bool) {}
4781            fn row_state(&self, _flat_index: usize) -> RowState {
4782                RowState::Loading
4783            }
4784            fn request_window(&self, range: Range<usize>) {
4785                self.requested.borrow_mut().push(range);
4786            }
4787        }
4788
4789        let requested = Rc::new(RefCell::new(Vec::new()));
4790        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4791        let id = tree.add(
4792            TreeTableView::from_source(Windowed {
4793                total: 1000,
4794                requested: requested.clone(),
4795                version: Signal::new(0),
4796            })
4797            .add_column(name_col())
4798            .show_header(false)
4799            .row_height(30.0),
4800        );
4801        tree.layout(SizeProposal {
4802            width: Some(400.0),
4803            height: Some(300.0),
4804        });
4805
4806        // The body pane is the view's first child (header suppressed).
4807        // 300px / 30px = 10 visible + buffer → the loading rows realize
4808        // as placeholder row widgets, NOT skipped.
4809        let body_pane = tree.children(id)[0];
4810        let placeholder_rows = tree.children(body_pane).len();
4811        assert!(
4812            placeholder_rows >= 10,
4813            "loading rows must render as placeholders, got {placeholder_rows}"
4814        );
4815        // And the source was asked to load the realized window.
4816        assert!(
4817            !requested.borrow().is_empty(),
4818            "request_window must be called for the visible range"
4819        );
4820    }
4821
4822    #[test]
4823    fn arrow_expand_collapse_follows_a_non_leading_tree_column() {
4824        // Regression: the key handler hardcoded `col == 0` as "the tree
4825        // column", so designating any other column via `.tree_column()` moved
4826        // the twist visually but left ArrowLeft/ArrowRight expanding nothing.
4827        // Here the tree column is "size", at display position 1.
4828        use teksilo_core::event::{Key, Modifiers};
4829        let proxy = SortFilterTreeModel::new(sample_tree());
4830        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4831        let id = tree.add(
4832            TreeTableView::from_projection(proxy.clone())
4833                .add_column(name_col())
4834                .add_column(size_col())
4835                .tree_column("size")
4836                .row_height(20.0),
4837        );
4838        tree.layout(SizeProposal {
4839            width: Some(400.0),
4840            height: Some(200.0),
4841        });
4842        tree.focus(id);
4843
4844        // Off the tree column: the arrows are pure cursor movement, so the
4845        // visible set must not change.
4846        {
4847            let any = tree.widget_as_any(id).unwrap();
4848            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4849            tt.set_focused_cell(0, 0);
4850        }
4851        tree.press_key(Key::ArrowRight, Modifiers::NONE);
4852        assert_eq!(
4853            proxy.visible_count(),
4854            2,
4855            "ArrowRight off the tree column must not expand"
4856        );
4857
4858        // On the tree column (display position 1): expand, then collapse.
4859        {
4860            let any = tree.widget_as_any(id).unwrap();
4861            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4862            tt.set_focused_cell(0, 1);
4863        }
4864        tree.press_key(Key::ArrowRight, Modifiers::NONE);
4865        assert_eq!(
4866            proxy.visible_count(),
4867            4,
4868            "docs expands to reveal 2 children"
4869        );
4870        tree.press_key(Key::ArrowLeft, Modifiers::NONE);
4871        assert_eq!(proxy.visible_count(), 2, "docs collapses again");
4872    }
4873
4874    #[test]
4875    fn arrow_nav_scroll_follows_focused_row() {
4876        // 100 flat rows × 20 px in a 200 px viewport. Walking focus down
4877        // past the visible window must scroll to keep the focused row on
4878        // screen ("selection always visible"), matching TreeView / the
4879        // newly-fixed TableView. Regression for: TreeTableView keyboard
4880        // nav left scroll_y untouched.
4881        use teksilo_core::event::{Key, Modifiers};
4882        let proxy = SortFilterTreeModel::new(wide_tree(100));
4883        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4884        let id = tree.add(
4885            TreeTableView::from_projection(proxy)
4886                .add_column(name_col())
4887                .row_height(20.0),
4888        );
4889        let proposal = SizeProposal {
4890            width: Some(400.0),
4891            height: Some(200.0),
4892        };
4893        tree.layout(proposal);
4894        tree.focus(id);
4895        let read_scroll = |tree: &WidgetTree| {
4896            let any = tree.widget_as_any(id).unwrap();
4897            any.downcast_ref::<TreeTableView<&'static str>>()
4898                .unwrap()
4899                .scroll_y_signal()
4900                .get()
4901        };
4902        let read_focus = |tree: &WidgetTree| {
4903            let any = tree.widget_as_any(id).unwrap();
4904            any.downcast_ref::<TreeTableView<&'static str>>()
4905                .unwrap()
4906                .focused_cell_signal()
4907                .get()
4908        };
4909        {
4910            let any = tree.widget_as_any(id).unwrap();
4911            any.downcast_ref::<TreeTableView<&'static str>>()
4912                .unwrap()
4913                .set_focused_cell(0, 0);
4914        }
4915        assert_eq!(read_scroll(&tree), 0.0, "starts at top");
4916
4917        for _ in 0..20 {
4918            tree.press_key(Key::ArrowDown, Modifiers::NONE);
4919            tree.layout(proposal);
4920        }
4921        assert_eq!(read_focus(&tree), Some((20, 0)));
4922        assert!(
4923            read_scroll(&tree) > 200.0,
4924            "arrow-down nav must scroll to reveal row 20, got {}",
4925            read_scroll(&tree)
4926        );
4927
4928        // Ctrl+Home returns focus AND scroll to the top.
4929        tree.press_key(Key::Home, Modifiers::COMMAND);
4930        tree.layout(proposal);
4931        assert_eq!(read_focus(&tree), Some((0, 0)));
4932        assert_eq!(read_scroll(&tree), 0.0, "Ctrl+Home scrolls to top");
4933    }
4934
4935    #[test]
4936    fn type_ahead_jumps_to_matching_row() {
4937        use teksilo_core::event::{Key, Modifiers};
4938        let model = TreeModel::new();
4939        model.insert_root(0, "Apple");
4940        model.insert_root(1, "Banana");
4941        model.insert_root(2, "Cherry");
4942        model.insert_root(3, "Cranberry");
4943        let proxy = SortFilterTreeModel::new(model);
4944        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4945        let id = tree.add(
4946            TreeTableView::from_projection(proxy)
4947                .add_column(name_col())
4948                .row_height(20.0)
4949                .type_ahead_label(|s: &&'static str| s.to_string()),
4950        );
4951        tree.layout(SizeProposal {
4952            width: Some(400.0),
4953            height: Some(200.0),
4954        });
4955        tree.focus(id);
4956        let read_focus = |tree: &WidgetTree| {
4957            let any = tree.widget_as_any(id).unwrap();
4958            any.downcast_ref::<TreeTableView<&'static str>>()
4959                .unwrap()
4960                .focused_cell_signal()
4961                .get()
4962        };
4963        {
4964            let any = tree.widget_as_any(id).unwrap();
4965            any.downcast_ref::<TreeTableView<&'static str>>()
4966                .unwrap()
4967                .set_focused_cell(0, 0);
4968        }
4969        tree.press_key(Key::C, Modifiers::NONE);
4970        assert_eq!(read_focus(&tree), Some((2, 0)), "'c' → Cherry");
4971        tree.press_key(Key::R, Modifiers::NONE);
4972        assert_eq!(read_focus(&tree), Some((3, 0)), "'cr' → Cranberry");
4973    }
4974
4975    #[test]
4976    fn ctrl_tab_escapes_the_cell_grid() {
4977        use crate::primitives::{TextWidget, VStack};
4978        use teksilo_core::event::{Key, Modifiers};
4979        use teksilo_core::widget_builder::WidgetBuilder;
4980
4981        let proxy = SortFilterTreeModel::new(wide_tree(5));
4982        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4983        let id = tree.add(
4984            TreeTableView::from_projection(proxy)
4985                .add_column(name_col())
4986                .row_height(20.0),
4987        );
4988        let sink = tree.add(TextWidget::new(lit!("sink")).focusable(true));
4989        let _root = tree.add(VStack::new().add_child(id).add_child(sink));
4990        tree.layout(SizeProposal {
4991            width: Some(400.0),
4992            height: Some(200.0),
4993        });
4994        let read_focus = |tree: &WidgetTree| {
4995            let any = tree.widget_as_any(id).unwrap();
4996            any.downcast_ref::<TreeTableView<&'static str>>()
4997                .unwrap()
4998                .focused_cell_signal()
4999                .get()
5000        };
5001        tree.focus(id);
5002        {
5003            let any = tree.widget_as_any(id).unwrap();
5004            any.downcast_ref::<TreeTableView<&'static str>>()
5005                .unwrap()
5006                .set_focused_cell(0, 0);
5007        }
5008        let before = read_focus(&tree);
5009        tree.press_key(Key::Tab, Modifiers::CTRL);
5010        assert_eq!(
5011            read_focus(&tree),
5012            before,
5013            "Ctrl+Tab must not navigate cells"
5014        );
5015        assert_eq!(
5016            tree.focused(),
5017            Some(sink),
5018            "Ctrl+Tab moves focus out of the tree-table"
5019        );
5020    }
5021
5022    #[test]
5023    fn rows_carry_role_row_with_level_indicator() {
5024        let proxy = SortFilterTreeModel::new(sample_tree());
5025        let docs = proxy.tree().root(0);
5026        proxy.expand(docs);
5027        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5028        let id = tree.add(
5029            TreeTableView::from_projection(proxy)
5030                .add_column(name_col())
5031                .row_height(20.0),
5032        );
5033        tree.layout(SizeProposal {
5034            width: Some(400.0),
5035            height: Some(200.0),
5036        });
5037        // Walk the tree and count Role::Row entries.
5038        let mut q = vec![id];
5039        let mut row_count = 0;
5040        while let Some(n) = q.pop() {
5041            if tree.accessibility_node(n).role() == Role::Row {
5042                row_count += 1;
5043            }
5044            for c in tree.children(n) {
5045                q.push(c);
5046            }
5047        }
5048        // 1 header + 4 visible body rows (docs, readme, guide, src).
5049        assert!(
5050            row_count >= 5,
5051            "expected at least 5 Role::Row nodes, got {row_count}"
5052        );
5053    }
5054
5055    #[test]
5056    fn filter_mode_keep_ancestors_works_via_proxy() {
5057        let proxy = SortFilterTreeModel::new(sample_tree())
5058            .filter_mode(TreeFilterMode::KeepAncestors)
5059            .with_predicate("name", |t| {
5060                let needle = t.to_string();
5061                Box::new(move |row: &&str| row.contains(&needle))
5062            });
5063        proxy.expand_all();
5064        proxy.set_filter("name", "main");
5065        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5066        let _id = tree.add(
5067            TreeTableView::from_projection(proxy.clone())
5068                .add_column(name_col())
5069                .row_height(20.0),
5070        );
5071        tree.layout(SizeProposal {
5072            width: Some(400.0),
5073            height: Some(200.0),
5074        });
5075        // Visible: src (ancestor), main.rs (matches).
5076        assert_eq!(proxy.visible_count(), 2);
5077    }
5078
5079    #[test]
5080    fn collapse_all_then_expand_all_round_trips() {
5081        let proxy = SortFilterTreeModel::new(sample_tree());
5082        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5083        let id = tree.add(
5084            TreeTableView::from_projection(proxy.clone())
5085                .add_column(name_col())
5086                .row_height(20.0),
5087        );
5088        tree.layout(SizeProposal {
5089            width: Some(400.0),
5090            height: Some(200.0),
5091        });
5092        {
5093            let any = tree.widget_as_any(id).unwrap();
5094            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
5095            tt.expand_all();
5096        }
5097        assert_eq!(proxy.visible_count(), 5);
5098        {
5099            let any = tree.widget_as_any(id).unwrap();
5100            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
5101            tt.collapse_all();
5102        }
5103        assert_eq!(proxy.visible_count(), 2);
5104    }
5105
5106    #[test]
5107    fn rows_report_sibling_position_and_size_among_siblings() {
5108        // docs (root 1/2) -> readme (child 1/2), guide (child 2/2)
5109        // src  (root 2/2) -> main.rs (child 1/1)
5110        //
5111        // `TreeView`'s `TreeItemWrapper` already announces
5112        // position_in_set/size_of_set (`list_item_a11y.rs`);
5113        // `TreeTableView` never wired `TreeSource::sibling_pos` into its own
5114        // row wrapper (`TreeRowA11y`) despite the data being one call away.
5115        let proxy = SortFilterTreeModel::new(sample_tree());
5116        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5117        let id = tree.add(
5118            TreeTableView::from_projection(proxy.clone())
5119                .add_column(name_col())
5120                .row_height(20.0),
5121        );
5122        tree.layout(SizeProposal {
5123            width: Some(400.0),
5124            height: Some(400.0),
5125        });
5126        {
5127            let any = tree.widget_as_any(id).unwrap();
5128            any.downcast_ref::<TreeTableView<&'static str>>()
5129                .unwrap()
5130                .expand_all();
5131        }
5132        tree.layout(SizeProposal {
5133            width: Some(400.0),
5134            height: Some(400.0),
5135        });
5136        assert_eq!(proxy.visible_count(), 5);
5137
5138        // Collect all Role::Row body widgets (the header shares the role but
5139        // is excluded below by having no accesskit node y inside the body
5140        // band — simplest: sort every Role::Row by y and drop the topmost
5141        // one, which is always the header).
5142        let mut rows: Vec<WidgetId> = Vec::new();
5143        let mut q = vec![id];
5144        while let Some(n) = q.pop() {
5145            if tree.accessibility_node(n).role() == Role::Row {
5146                rows.push(n);
5147            }
5148            for c in tree.children(n) {
5149                q.push(c);
5150            }
5151        }
5152        rows.sort_by(|a, b| tree.bounds(*a).y.partial_cmp(&tree.bounds(*b).y).unwrap());
5153        assert_eq!(rows.len(), 6, "header + five body rows");
5154        let body_rows = &rows[1..];
5155
5156        // `position_in_set`/`size_of_set` aren't on the summarized
5157        // `AccessibilityInfo` — read them off the real accesskit node via a
5158        // fresh `TreeUpdate`, mirroring `docking::tests::find_a11y_node`.
5159        let update = tree.sync_accessibility();
5160        let find = |wid: WidgetId| -> &teksilo_core::accesskit::Node {
5161            let nid = widget_id_to_node_id(wid);
5162            update
5163                .nodes
5164                .iter()
5165                .find(|(n, _)| *n == nid)
5166                .map(|(_, n)| n)
5167                .expect("row must be in the a11y tree")
5168        };
5169        let positions: Vec<usize> = body_rows
5170            .iter()
5171            .map(|&r| find(r).position_in_set().expect("position_in_set"))
5172            .collect();
5173        // The row passes ARIA's 1-based sibling position; AccessKit stores it
5174        // zero-based, and the Windows and AT-SPI adapters add the 1 back — so
5175        // "the first of two siblings" is 0 on the node and "1" to the user.
5176        assert_eq!(
5177            positions,
5178            vec![0, 0, 1, 1, 0],
5179            "docs(1st) readme(1st) guide(2nd) src(2nd) main.rs(1st)"
5180        );
5181        // No sibling *count*, deliberately. AccessKit resolves a set size by
5182        // walking up from an item, so the only value a flattened tree could
5183        // publish is one shared by every row at every depth — which is not what
5184        // "of 2 siblings" means. See `TreeRowA11y::accessibility`.
5185        for &r in body_rows {
5186            assert_eq!(
5187                find(r).size_of_set(),
5188                None,
5189                "a per-sibling count is unrepresentable and must not be faked"
5190            );
5191        }
5192    }
5193
5194    #[test]
5195    fn row_count_in_a11y_includes_header() {
5196        let proxy = SortFilterTreeModel::new(sample_tree());
5197        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5198        let id = tree.add(
5199            TreeTableView::from_projection(proxy)
5200                .add_column(name_col())
5201                .add_column(size_col())
5202                .row_height(20.0),
5203        );
5204        tree.layout(SizeProposal {
5205            width: Some(400.0),
5206            height: Some(200.0),
5207        });
5208        let info = tree.accessibility_node(id);
5209        assert_eq!(info.role(), Role::TreeGrid);
5210        // We can't read row_count from AccessibilityInfo directly,
5211        // but we can verify Role::TreeGrid + Role::Row count matches
5212        // (header + 2 body rows = 3).
5213        let mut q = vec![id];
5214        let mut rows = 0;
5215        while let Some(n) = q.pop() {
5216            if tree.accessibility_node(n).role() == Role::Row {
5217                rows += 1;
5218            }
5219            for c in tree.children(n) {
5220                q.push(c);
5221            }
5222        }
5223        assert_eq!(rows, 3); // header + docs + src
5224    }
5225
5226    // ── RTL (right-to-left) ──────────────────────────────────────────────
5227
5228    /// A tree of `n` collapsed roots — enough to force a vertical scrollbar.
5229    fn wide_tree(n: u32) -> TreeModel<&'static str> {
5230        let t = TreeModel::new();
5231        for i in 0..n {
5232            t.insert_root(i as usize, "node");
5233        }
5234        t
5235    }
5236
5237    /// All `Role::Row` node bounds (header + body), for picking a body row.
5238    fn row_bounds(tree: &WidgetTree, root: WidgetId) -> Vec<teksilo_canvas::Rect> {
5239        let mut q = vec![root];
5240        let mut out = Vec::new();
5241        while let Some(n) = q.pop() {
5242            if tree.accessibility_node(n).role() == Role::Row {
5243                out.push(tree.bounds(n));
5244            }
5245            for c in tree.children(n) {
5246                q.push(c);
5247            }
5248        }
5249        out
5250    }
5251
5252    #[test]
5253    fn rtl_swaps_tree_expand_collapse_keys() {
5254        use teksilo_core::environment::LayoutDirection;
5255        use teksilo_core::event::{Key, Modifiers};
5256
5257        let proxy = SortFilterTreeModel::new(sample_tree());
5258        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5259        let table = tree.add(
5260            TreeTableView::from_projection(proxy.clone())
5261                .add_column(name_col())
5262                .row_height(20.0),
5263        );
5264        tree.layout(SizeProposal {
5265            width: Some(400.0),
5266            height: Some(200.0),
5267        });
5268        // Roots start collapsed: docs + src visible.
5269        assert_eq!(proxy.visible_count(), 2);
5270
5271        tree.set_layout_direction(LayoutDirection::RightToLeft);
5272        tree.focus(table);
5273        {
5274            let any = tree.widget_as_any(table).unwrap();
5275            any.downcast_ref::<TreeTableView<&'static str>>()
5276                .unwrap()
5277                .set_focused_cell(0, 0);
5278        }
5279
5280        // Under RTL the collapsed chevron points left, so ArrowLeft expands
5281        // (toward the children) and ArrowRight collapses.
5282        tree.press_key(Key::ArrowLeft, Modifiers::NONE);
5283        assert_eq!(
5284            proxy.visible_count(),
5285            4,
5286            "RTL ArrowLeft on the tree column should expand docs"
5287        );
5288        tree.press_key(Key::ArrowRight, Modifiers::NONE);
5289        assert_eq!(
5290            proxy.visible_count(),
5291            2,
5292            "RTL ArrowRight on the tree column should collapse docs"
5293        );
5294    }
5295
5296    #[test]
5297    fn rtl_tree_band_shifts_for_left_scrollbar() {
5298        use teksilo_core::environment::LayoutDirection;
5299        // 50 roots → vertical scrollbar present. Under RTL it sits on the
5300        // physical left, so the body band (and its rows) shift right by
5301        // SCROLLBAR_THICKNESS.
5302        let proxy = SortFilterTreeModel::new(wide_tree(50));
5303        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5304        let table = tree.add(
5305            TreeTableView::from_projection(proxy)
5306                .add_column(name_col())
5307                .row_height(20.0),
5308        );
5309        tree.layout(SizeProposal {
5310            width: Some(400.0),
5311            height: Some(200.0),
5312        });
5313        tree.set_layout_direction(LayoutDirection::RightToLeft);
5314        tree.layout(SizeProposal {
5315            width: Some(400.0),
5316            height: Some(200.0),
5317        });
5318
5319        let table_bounds = tree.bounds(table);
5320        // Pick a body row (below the header, which sits at the top).
5321        let body_row = row_bounds(&tree, table)
5322            .into_iter()
5323            .filter(|r| r.y > table_bounds.y + 5.0)
5324            .max_by(|a, b| a.y.partial_cmp(&b.y).unwrap())
5325            .expect("a body row");
5326        assert!(
5327            (body_row.x - SCROLLBAR_THICKNESS).abs() < 0.5,
5328            "RTL body row should start at SCROLLBAR_THICKNESS, got x={}",
5329            body_row.x
5330        );
5331        // LTR control: same table laid out left-to-right starts at 0.
5332        let mut tree2 = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5333        let proxy2 = SortFilterTreeModel::new(wide_tree(50));
5334        let table2 = tree2.add(
5335            TreeTableView::from_projection(proxy2)
5336                .add_column(name_col())
5337                .row_height(20.0),
5338        );
5339        tree2.layout(SizeProposal {
5340            width: Some(400.0),
5341            height: Some(200.0),
5342        });
5343        let tb2 = tree2.bounds(table2);
5344        let body_row2 = row_bounds(&tree2, table2)
5345            .into_iter()
5346            .filter(|r| r.y > tb2.y + 5.0)
5347            .max_by(|a, b| a.y.partial_cmp(&b.y).unwrap())
5348            .expect("a body row");
5349        assert!(body_row2.x.abs() < 0.5, "LTR body row x={}", body_row2.x);
5350    }
5351
5352    // ── Boundary scroll chaining ─────────────────────────────────────────
5353
5354    /// A TreeTableView (40 root rows × 20 px in a ~120 px viewport) above a
5355    /// filler inside an outer ScrollArea, so chaining from the inner
5356    /// tree-table to the outer area is observable.
5357    fn nested_tree_table_fixture(
5358        inner: OverscrollBehavior,
5359    ) -> (WidgetTree, Signal<f32>, Signal<f32>) {
5360        use crate::ScrollArea;
5361        use crate::primitives::{FixedSize, TextWidget, VStack};
5362        let model = TreeModel::new();
5363        for i in 0..40 {
5364            model.insert_root(i, "row");
5365        }
5366        let proxy = SortFilterTreeModel::new(model);
5367        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5368        let tt = TreeTableView::from_projection(proxy)
5369            .add_column(name_col())
5370            .show_header(false)
5371            .row_height(20.0)
5372            .overscroll_behavior(inner);
5373        let inner_y = tt.scroll_y_signal().clone();
5374        let tt_id = tree.add(tt);
5375        let viewport = tree.add(FixedSize::new().width(220.0).height(120.0).child_id(tt_id));
5376        let filler = tree.add(
5377            FixedSize::new()
5378                .width(220.0)
5379                .height(300.0)
5380                .child(TextWidget::new(lit!(""))),
5381        );
5382        let outer_content = tree.add(VStack::new().add_child(viewport).add_child(filler));
5383        let outer = ScrollArea::from_id(outer_content).smooth_scrolling(false);
5384        let outer_y = outer.scroll_y_signal().clone();
5385        let _outer = tree.add(outer);
5386        tree.layout(SizeProposal {
5387            width: Some(220.0),
5388            height: Some(150.0),
5389        });
5390        (tree, inner_y, outer_y)
5391    }
5392
5393    #[test]
5394    fn nested_tree_table_chains_to_outer_at_boundary() {
5395        use teksilo_canvas::Point;
5396        use teksilo_core::event::{Modifiers, ScrollDelta, WidgetEvent};
5397        let (mut tree, inner_y, outer_y) = nested_tree_table_fixture(OverscrollBehavior::Chain);
5398        tree.pointer_move(Point::new(50.0, 40.0));
5399        tree.dispatch_event(WidgetEvent::Scroll {
5400            delta: ScrollDelta::Pixels { x: 0.0, y: 9999.0 },
5401            modifiers: Modifiers::NONE,
5402        });
5403        tree.layout(SizeProposal {
5404            width: Some(220.0),
5405            height: Some(150.0),
5406        });
5407        let inner_bottom = inner_y.get();
5408        assert!(
5409            inner_bottom > 0.0,
5410            "inner tree-table should scroll down; got {inner_bottom}"
5411        );
5412        // A second wheel at the boundary must chain to the outer area.
5413        tree.pointer_move(Point::new(50.0, 40.0));
5414        tree.dispatch_event(WidgetEvent::Scroll {
5415            delta: ScrollDelta::Pixels { x: 0.0, y: 100.0 },
5416            modifiers: Modifiers::NONE,
5417        });
5418        tree.layout(SizeProposal {
5419            width: Some(220.0),
5420            height: Some(150.0),
5421        });
5422        assert!(
5423            (inner_y.get() - inner_bottom).abs() < 0.01,
5424            "inner stays clamped at bottom"
5425        );
5426        assert!(
5427            outer_y.get() > 0.01,
5428            "outer must scroll because the inner chained the boundary"
5429        );
5430    }
5431
5432    #[test]
5433    fn nested_tree_table_contain_blocks_chaining() {
5434        use teksilo_canvas::Point;
5435        use teksilo_core::event::{Modifiers, ScrollDelta, WidgetEvent};
5436        let (mut tree, _inner_y, outer_y) = nested_tree_table_fixture(OverscrollBehavior::Contain);
5437        tree.pointer_move(Point::new(50.0, 40.0));
5438        tree.dispatch_event(WidgetEvent::Scroll {
5439            delta: ScrollDelta::Pixels { x: 0.0, y: 9999.0 },
5440            modifiers: Modifiers::NONE,
5441        });
5442        tree.layout(SizeProposal {
5443            width: Some(220.0),
5444            height: Some(150.0),
5445        });
5446        tree.pointer_move(Point::new(50.0, 40.0));
5447        tree.dispatch_event(WidgetEvent::Scroll {
5448            delta: ScrollDelta::Pixels { x: 0.0, y: 100.0 },
5449            modifiers: Modifiers::NONE,
5450        });
5451        tree.layout(SizeProposal {
5452            width: Some(220.0),
5453            height: Some(150.0),
5454        });
5455        assert!(
5456            outer_y.get() < 0.01,
5457            "Contain must prevent chaining: outer stays put"
5458        );
5459    }
5460
5461    // ── TreeBodyPane split + variable row heights ───────────────────────
5462
5463    fn count_role(tree: &WidgetTree, root: WidgetId, role: Role) -> usize {
5464        let mut walker = vec![root];
5465        let mut n = 0;
5466        while let Some(id) = walker.pop() {
5467            if tree.accessibility_node(id).role() == role {
5468                n += 1;
5469            }
5470            for c in tree.children(id) {
5471                walker.push(c);
5472            }
5473        }
5474        n
5475    }
5476
5477    /// Collect the (y, height) bounds of the materialised `Role::Row`
5478    /// widgets, sorted by y.
5479    fn row_spans(tree: &WidgetTree, root: WidgetId) -> Vec<(f32, f32)> {
5480        let mut walker = vec![root];
5481        let mut spans = Vec::new();
5482        while let Some(id) = walker.pop() {
5483            if tree.accessibility_node(id).role() == Role::Row {
5484                let b = tree.bounds(id);
5485                spans.push((b.y, b.height));
5486            }
5487            for c in tree.children(id) {
5488                walker.push(c);
5489            }
5490        }
5491        spans.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
5492        spans
5493    }
5494
5495    #[test]
5496    fn rows_rebuild_during_scrollbar_thumb_drag() {
5497        // The reason `TreeBodyPane` exists — see `common::thumb_drag_test`'s
5498        // module docs for the invariant, and for why every virtualized view
5499        // asserts it through the same driver.
5500        let model = TreeModel::new();
5501        for i in 0..500 {
5502            model.insert_root(i, "root");
5503        }
5504        let proxy = SortFilterTreeModel::new(model);
5505        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5506        let table = tree.add(
5507            TreeTableView::from_projection(proxy.clone())
5508                .add_column(name_col())
5509                .row_height(20.0),
5510        );
5511        crate::common::thumb_drag_test::assert_body_survives_thumb_drag(
5512            &mut tree,
5513            table,
5514            400.0,
5515            200.0,
5516            cp::HEADER_HEIGHT,
5517            "TreeTableView",
5518            |t| {
5519                let mut n = 0;
5520                let mut walker = vec![table];
5521                while let Some(id) = walker.pop() {
5522                    if t.accessibility_node(id).role() == Role::Row {
5523                        let b = t.bounds(id);
5524                        if b.y >= 0.0 && b.y < 200.0 {
5525                            n += 1;
5526                        }
5527                    }
5528                    for c in t.children(id) {
5529                        walker.push(c);
5530                    }
5531                }
5532                n
5533            },
5534        );
5535    }
5536
5537    #[test]
5538    fn exact_row_height_fn_positions_tree_rows() {
5539        let heights = [60.0_f32, 20.0, 40.0];
5540        let proxy = SortFilterTreeModel::new(sample_tree());
5541        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5542        let table = tree.add(
5543            TreeTableView::from_projection(proxy)
5544                .add_column(name_col())
5545                .show_header(false)
5546                .row_height_fn(move |i| heights.get(i).copied().unwrap_or(28.0)),
5547        );
5548        tree.layout(SizeProposal {
5549            width: Some(400.0),
5550            height: Some(300.0),
5551        });
5552
5553        // Roots only: docs (60), src (20).
5554        let spans = row_spans(&tree, table);
5555        assert_eq!(spans.len(), 2);
5556        assert!((spans[0].0 - 0.0).abs() < 0.01 && (spans[0].1 - 60.0).abs() < 0.01);
5557        assert!((spans[1].0 - 60.0).abs() < 0.01 && (spans[1].1 - 20.0).abs() < 0.01);
5558    }
5559
5560    #[test]
5561    fn auto_row_height_measures_tree_cells() {
5562        #[derive(Debug)]
5563        struct FixedLeaf(f32, f32);
5564        impl Widget for FixedLeaf {
5565            fn layout_response(
5566                &self,
5567                _proposal: SizeProposal,
5568                _ctx: &LayoutContext,
5569            ) -> teksilo_core::widget::LayoutResponse {
5570                Size::new(self.0, self.1).into()
5571            }
5572        }
5573        let col = Column::<&str>::new("name", lit!("Name"), |_row, _: &CellContext| {
5574            Box::new(FixedLeaf(50.0, 30.0))
5575        })
5576        .width(ColumnWidth::Flex(1.0));
5577        let proxy = SortFilterTreeModel::new(sample_tree());
5578        let docs = proxy.tree().root(0);
5579        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5580        let table = tree.add(
5581            TreeTableView::from_projection(proxy.clone())
5582                .add_column(col)
5583                .show_header(false)
5584                .auto_row_height(50.0),
5585        );
5586        tree.layout(SizeProposal {
5587            width: Some(400.0),
5588            height: Some(300.0),
5589        });
5590        tree.layout(SizeProposal {
5591            width: Some(400.0),
5592            height: Some(300.0),
5593        });
5594
5595        // Rows measured to 30 from the 50 estimate.
5596        let spans = row_spans(&tree, table);
5597        assert!(
5598            (spans[1].0 - 30.0).abs() < 0.01,
5599            "row 1 should sit at measured 30, got {}",
5600            spans[1].0
5601        );
5602
5603        // Expanding docs (flat 0) keeps measured heights — the
5604        // divergence is the toggled row, not a full reset, so the
5605        // expanded children appear right below the measured row 0.
5606        proxy.expand(docs);
5607        tree.layout(SizeProposal {
5608            width: Some(400.0),
5609            height: Some(300.0),
5610        });
5611        tree.layout(SizeProposal {
5612            width: Some(400.0),
5613            height: Some(300.0),
5614        });
5615        let spans = row_spans(&tree, table);
5616        assert_eq!(spans.len(), 4); // docs, readme, guide, src
5617        assert!(
5618            (spans[1].0 - 30.0).abs() < 0.01,
5619            "measured row 0 must survive the expand, got {}",
5620            spans[1].0
5621        );
5622    }
5623
5624    // ── Row reorder (Stage 5) ──────────────────────────────────────────────
5625
5626    /// Full drag gesture: down on source, move to cross the threshold, move to
5627    /// target, up.
5628    fn drag(tree: &mut WidgetTree, from: teksilo_canvas::Point, to: teksilo_canvas::Point) {
5629        use teksilo_canvas::Point;
5630        use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
5631        tree.dispatch_event(WidgetEvent::PointerDown {
5632            position: from,
5633            button: PointerButton::Primary,
5634            modifiers: Modifiers::NONE,
5635        });
5636        tree.dispatch_event(WidgetEvent::PointerMove {
5637            position: Point::new(from.x + 10.0, from.y),
5638        });
5639        tree.dispatch_event(WidgetEvent::PointerMove { position: to });
5640        tree.dispatch_event(WidgetEvent::PointerUp {
5641            position: to,
5642            button: PointerButton::Primary,
5643            modifiers: Modifiers::NONE,
5644        });
5645    }
5646
5647    #[test]
5648    fn drag_reorders_roots_after() {
5649        use teksilo_canvas::Point;
5650        let proxy = SortFilterTreeModel::new(sample_tree());
5651        proxy.collapse_all(); // roots only: docs@0, src@1
5652        let docs = proxy.tree().root(0);
5653        let src = proxy.tree().root(1);
5654        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5655        tree.add(
5656            TreeTableView::from_projection(proxy.clone())
5657                .add_column(name_col())
5658                .reorderable(true)
5659                .row_height(20.0),
5660        );
5661        tree.layout(SizeProposal {
5662            width: Some(400.0),
5663            height: Some(300.0),
5664        });
5665        let h = cp::HEADER_HEIGHT;
5666        // Drag docs (flat 0, [h, h+20]) onto the bottom third of src (flat 1,
5667        // [h+20, h+40]) → After src.
5668        drag(
5669            &mut tree,
5670            Point::new(40.0, h + 10.0),
5671            Point::new(40.0, h + 38.0),
5672        );
5673        assert_eq!(proxy.tree().root_count(), 2);
5674        assert_eq!(proxy.tree().root(0), src, "src becomes the first root");
5675        assert_eq!(proxy.tree().root(1), docs, "docs moves after src");
5676    }
5677
5678    #[test]
5679    fn drag_into_own_descendant_is_refused() {
5680        use teksilo_canvas::Point;
5681        let proxy = SortFilterTreeModel::new(sample_tree());
5682        proxy.expand_all(); // docs@0, readme@1, guide@2, src@3, main.rs@4
5683        let docs = proxy.tree().root(0);
5684        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5685        tree.add(
5686            TreeTableView::from_projection(proxy.clone())
5687                .add_column(name_col())
5688                .reorderable(true)
5689                .row_height(20.0),
5690        );
5691        tree.layout(SizeProposal {
5692            width: Some(400.0),
5693            height: Some(300.0),
5694        });
5695        let h = cp::HEADER_HEIGHT;
5696        // Drag docs (flat 0) into the middle third of readme (flat 1, a child
5697        // of docs) → cycle → refused; tree unchanged, no panic.
5698        drag(
5699            &mut tree,
5700            Point::new(40.0, h + 10.0),
5701            Point::new(40.0, h + 30.0),
5702        );
5703        assert_eq!(proxy.tree().parent(docs), None, "docs stays a root");
5704        assert_eq!(proxy.tree().root_count(), 2);
5705    }
5706
5707    #[test]
5708    fn reorder_is_suppressed_while_sorted() {
5709        use teksilo_canvas::Point;
5710        let proxy = SortFilterTreeModel::new(sample_tree());
5711        proxy.collapse_all();
5712        let docs = proxy.tree().root(0);
5713        let src = proxy.tree().root(1);
5714        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5715        let id = tree.add(
5716            TreeTableView::from_projection(proxy.clone())
5717                .add_column(name_col())
5718                .reorderable(true)
5719                .row_height(20.0),
5720        );
5721        tree.layout(SizeProposal {
5722            width: Some(400.0),
5723            height: Some(300.0),
5724        });
5725        // Activate a sort: the drop gate must refuse the reorder (a manual
5726        // reorder is meaningless once the visible order is sort-driven).
5727        tree.widget_as_any(id)
5728            .and_then(|a| a.downcast_ref::<TreeTableView<&str>>())
5729            .expect("TreeTableView")
5730            .set_sort(Some("name"), teksilo_data::SortDirection::Ascending);
5731        let h = cp::HEADER_HEIGHT;
5732        drag(
5733            &mut tree,
5734            Point::new(40.0, h + 10.0),
5735            Point::new(40.0, h + 38.0),
5736        );
5737        assert_eq!(proxy.tree().root(0), docs, "docs unchanged while sorted");
5738        assert_eq!(proxy.tree().root(1), src, "src unchanged while sorted");
5739    }
5740
5741    #[test]
5742    fn keyed_selection_survives_collapse() {
5743        // Keyed (identity) selection: a node selected by NodeId stays selected
5744        // when its parent collapses (the row scrolls out of the projection).
5745        // The prune on every projection change must NOT drop a collapsed-but-
5746        // present node — existence is checked against the tree, not visibility.
5747        use teksilo_data::{KeyedSelectionModel, SelectionMode};
5748        let proxy = SortFilterTreeModel::new(sample_tree());
5749        proxy.expand_all();
5750        let docs = proxy.tree().root(0);
5751        let readme = proxy.tree().children(docs)[0];
5752        let keyed = KeyedSelectionModel::<NodeId>::new(SelectionMode::Multi);
5753        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5754        tree.add(
5755            TreeTableView::from_projection(proxy.clone())
5756                .add_column(name_col())
5757                .selection_mode(TableSelectionMode::MultiRow)
5758                .keyed_selection(keyed.clone())
5759                .row_height(20.0),
5760        );
5761        tree.layout(SizeProposal {
5762            width: Some(400.0),
5763            height: Some(300.0),
5764        });
5765
5766        keyed.select(readme);
5767        assert!(keyed.is_selected(&readme));
5768
5769        // Collapse docs → readme leaves the visible projection, bumping the
5770        // version (which runs the prune). It must survive (still in the tree).
5771        proxy.collapse(docs);
5772        assert!(
5773            keyed.is_selected(&readme),
5774            "a collapsed-but-present node stays selected by identity"
5775        );
5776
5777        // Re-expand → still selected.
5778        proxy.expand(docs);
5779        assert!(keyed.is_selected(&readme));
5780    }
5781
5782    // ── Horizontal scroll ───────────────────────────────────────────────
5783    //
5784    // TreeTableView reuses TableView's `body::BodyRow` / `header::HeaderRow`
5785    // / `layout::` pane machinery wholesale, so these mirror the TableView
5786    // suite (`table_view::tests`) at reduced breadth: enough to confirm the
5787    // shared plumbing threads through this widget's own `build()` /
5788    // `place_children()` / `paint()` / `on_scroll` correctly, not to
5789    // re-verify the pane math itself (already unit-tested in `layout.rs`
5790    // and exercised end-to-end by TableView's suite).
5791
5792    /// Expand any AT-transparent id (the pane-band wrapper `RowBand`
5793    /// inserts under column pinning — see `table_view::body`'s module
5794    /// docs — never calls `set_role`, so it reads back as the
5795    /// `AccessNodeBuilder` default `Role::Unknown`) into its own children,
5796    /// recursively.
5797    fn tt_flatten_through_bands(tree: &WidgetTree, ids: Vec<WidgetId>) -> Vec<WidgetId> {
5798        let mut out = Vec::new();
5799        for id in ids {
5800            if matches!(
5801                tree.accessibility_node(id).role(),
5802                Role::GenericContainer | Role::Unknown
5803            ) {
5804                out.extend(tt_flatten_through_bands(tree, tree.children(id)));
5805            } else {
5806                out.push(id);
5807            }
5808        }
5809        out
5810    }
5811
5812    /// The first BODY `Role::Row` (band-flattened children include a
5813    /// `Role::Cell`) — distinguishes it from the header, which shares
5814    /// `Role::Row` but has only `Role::ColumnHeader` children.
5815    fn tt_first_body_row_id(tree: &WidgetTree, root: WidgetId) -> WidgetId {
5816        let mut walker = vec![root];
5817        while let Some(id) = walker.pop() {
5818            if tree.accessibility_node(id).role() == Role::Row {
5819                let flat = tt_flatten_through_bands(tree, tree.children(id));
5820                if flat
5821                    .iter()
5822                    .any(|&c| tree.accessibility_node(c).role() == Role::Cell)
5823                {
5824                    return id;
5825                }
5826            }
5827            for c in tree.children(id) {
5828                walker.push(c);
5829            }
5830        }
5831        panic!("no body Role::Row found");
5832    }
5833
5834    fn tt_header_row_id(tree: &WidgetTree, root: WidgetId) -> WidgetId {
5835        let mut walker = vec![root];
5836        while let Some(id) = walker.pop() {
5837            if tree.accessibility_node(id).role() == Role::Row {
5838                let flat = tt_flatten_through_bands(tree, tree.children(id));
5839                if !flat.is_empty()
5840                    && flat
5841                        .iter()
5842                        .all(|&c| tree.accessibility_node(c).role() == Role::ColumnHeader)
5843                {
5844                    return id;
5845                }
5846            }
5847            for c in tree.children(id) {
5848                walker.push(c);
5849            }
5850        }
5851        panic!("no header Role::Row found");
5852    }
5853
5854    fn tt_body_row_cells(tree: &WidgetTree, root: WidgetId) -> Vec<WidgetId> {
5855        tt_flatten_through_bands(tree, tree.children(tt_first_body_row_id(tree, root)))
5856    }
5857
5858    fn tt_header_row_cells(tree: &WidgetTree, root: WidgetId) -> Vec<WidgetId> {
5859        tt_flatten_through_bands(tree, tree.children(tt_header_row_id(tree, root)))
5860    }
5861
5862    /// Leading `lead` (60px, pinned) + unpinned `mid` (`middle_w` px) +
5863    /// Trailing `trail` (60px, pinned), over the default `sample_tree()`
5864    /// (roots collapsed — 2 visible rows).
5865    fn build_tt_pinned_scroll_table(middle_w: f32, table_w: f32) -> (WidgetTree, WidgetId) {
5866        let proxy = SortFilterTreeModel::new(sample_tree());
5867        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5868        let id = tree.add(
5869            TreeTableView::from_projection(proxy)
5870                .add_column(
5871                    Column::<&'static str>::new("lead", lit!("Lead"), |row, _: &CellContext| {
5872                        Box::new(crate::primitives::TextWidget::new(lit!(*row)))
5873                    })
5874                    .width(ColumnWidth::Fixed(60.0))
5875                    .pinned(PinnedSide::Leading),
5876                )
5877                .add_column(
5878                    Column::<&'static str>::new("mid", lit!("Mid"), |row, _: &CellContext| {
5879                        Box::new(crate::primitives::TextWidget::new(lit!(*row)))
5880                    })
5881                    .width(ColumnWidth::Fixed(middle_w)),
5882                )
5883                .add_column(
5884                    Column::<&'static str>::new("trail", lit!("Trail"), |_row, _: &CellContext| {
5885                        Box::new(crate::primitives::TextWidget::new(lit!("x")))
5886                    })
5887                    .width(ColumnWidth::Fixed(60.0))
5888                    .pinned(PinnedSide::Trailing),
5889                )
5890                .row_height(20.0),
5891        );
5892        tree.layout(SizeProposal {
5893            width: Some(table_w),
5894            height: Some(200.0),
5895        });
5896        (tree, id)
5897    }
5898
5899    /// `n` unpinned Fixed columns of `col_w` px each.
5900    fn build_tt_wide_unpinned_table(col_w: f32, n: usize, table_w: f32) -> (WidgetTree, WidgetId) {
5901        let proxy = SortFilterTreeModel::new(sample_tree());
5902        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5903        let mut tv = TreeTableView::from_projection(proxy);
5904        for i in 0..n {
5905            let col_id = format!("c{i}");
5906            tv = tv.add_column(
5907                Column::<&'static str>::new(
5908                    col_id.clone(),
5909                    lit!(col_id.clone()),
5910                    |row, _: &CellContext| Box::new(crate::primitives::TextWidget::new(lit!(*row))),
5911                )
5912                .width(ColumnWidth::Fixed(col_w)),
5913            );
5914        }
5915        let id = tree.add(tv.row_height(20.0));
5916        tree.layout(SizeProposal {
5917            width: Some(table_w),
5918            height: Some(200.0),
5919        });
5920        (tree, id)
5921    }
5922
5923    fn tt_scroll_x(tree: &WidgetTree, id: WidgetId) -> f32 {
5924        tree.widget_as_any(id)
5925            .unwrap()
5926            .downcast_ref::<TreeTableView<&'static str>>()
5927            .unwrap()
5928            .scroll_x_signal()
5929            .get()
5930    }
5931
5932    fn tt_max_scroll_x(tree: &WidgetTree, id: WidgetId) -> f32 {
5933        tree.widget_as_any(id)
5934            .unwrap()
5935            .downcast_ref::<TreeTableView<&'static str>>()
5936            .unwrap()
5937            .max_scroll_x_signal()
5938            .get()
5939    }
5940
5941    fn tt_set_scroll_x(tree: &WidgetTree, id: WidgetId, x: f32) {
5942        tree.widget_as_any(id)
5943            .unwrap()
5944            .downcast_ref::<TreeTableView<&'static str>>()
5945            .unwrap()
5946            .scroll_x_signal()
5947            .set(x);
5948    }
5949
5950    #[test]
5951    fn tt_scroll_x_clamps_after_the_pane_widens() {
5952        let (mut tree, id) = build_tt_wide_unpinned_table(200.0, 3, 300.0);
5953        let max = tt_max_scroll_x(&tree, id);
5954        assert!(max > 0.0, "columns must overflow the narrow table");
5955        tt_set_scroll_x(&tree, id, max);
5956        assert_eq!(tt_scroll_x(&tree, id), max);
5957
5958        tree.layout(SizeProposal {
5959            width: Some(700.0),
5960            height: Some(200.0),
5961        });
5962        assert_eq!(tt_max_scroll_x(&tree, id), 0.0, "content now fits");
5963        assert_eq!(
5964            tt_scroll_x(&tree, id),
5965            0.0,
5966            "scroll_x must clamp down with the new (smaller) max_scroll_x"
5967        );
5968    }
5969
5970    #[test]
5971    fn tt_pinned_columns_keep_their_bands_under_scroll() {
5972        let (mut tree, id) = build_tt_pinned_scroll_table(400.0, 200.0);
5973
5974        let cells0 = tt_body_row_cells(&tree, id);
5975        assert_eq!(cells0.len(), 3, "lead, mid, trail");
5976        let lead_x0 = tree.bounds(cells0[0]).x;
5977        let mid_x0 = tree.bounds(cells0[1]).x;
5978        let trail_x0 = tree.bounds(cells0[2]).x;
5979
5980        // `tt_first_body_row_id` returns the `TreeRowA11y` wrapper (the
5981        // `Role::Row` carrier); its sole child is the `.a11y_hidden()`
5982        // `BodyRow`, one level further in, whose own children are the
5983        // pane bands.
5984        let tree_row_a11y = tt_first_body_row_id(&tree, id);
5985        let body_row = tree.children(tree_row_a11y)[0];
5986        let raw_bands = tree.children(body_row);
5987        assert_eq!(raw_bands.len(), 3, "leading + middle + trailing bands");
5988        assert!(!tree.widget_clips_children(raw_bands[0]));
5989        assert!(
5990            tree.widget_clips_children(raw_bands[1]),
5991            "the Middle band must clip"
5992        );
5993        assert!(!tree.widget_clips_children(raw_bands[2]));
5994
5995        let max = tt_max_scroll_x(&tree, id);
5996        assert!(max > 0.0);
5997        tt_set_scroll_x(&tree, id, 50.0_f32.min(max));
5998        tree.layout(SizeProposal {
5999            width: Some(200.0),
6000            height: Some(200.0),
6001        });
6002
6003        let cells1 = tt_body_row_cells(&tree, id);
6004        assert_eq!(tree.bounds(cells1[0]).x, lead_x0, "Leading never moves");
6005        assert_eq!(tree.bounds(cells1[2]).x, trail_x0, "Trailing never moves");
6006        let mid_x1 = tree.bounds(cells1[1]).x;
6007        assert!(
6008            (mid_x1 - (mid_x0 - 50.0)).abs() < 0.5,
6009            "the Middle column shifts left by exactly scroll_x: got {mid_x1}, want ~{}",
6010            mid_x0 - 50.0
6011        );
6012    }
6013
6014    #[test]
6015    fn tt_header_and_body_x_offsets_agree_under_scroll() {
6016        let (mut tree, id) = build_tt_pinned_scroll_table(400.0, 200.0);
6017        tt_set_scroll_x(&tree, id, 37.0);
6018        tree.layout(SizeProposal {
6019            width: Some(200.0),
6020            height: Some(200.0),
6021        });
6022
6023        let header_cells = tt_header_row_cells(&tree, id);
6024        let body_cells = tt_body_row_cells(&tree, id);
6025        assert_eq!(header_cells.len(), body_cells.len());
6026        for (i, (&h, &b)) in header_cells.iter().zip(body_cells.iter()).enumerate() {
6027            let hx = tree.bounds(h).x;
6028            let bx = tree.bounds(b).x;
6029            assert!(
6030                (hx - bx).abs() < 0.01,
6031                "column {i}: header x {hx} must equal body x {bx}"
6032            );
6033        }
6034    }
6035
6036    #[test]
6037    fn tt_shift_wheel_scrolls_horizontally() {
6038        use teksilo_canvas::Point;
6039        use teksilo_core::event::{Modifiers, ScrollDelta, WidgetEvent};
6040        let (mut tree, id) = build_tt_wide_unpinned_table(200.0, 4, 300.0);
6041        tree.pointer_move(Point::new(50.0, 60.0));
6042        tree.dispatch_event(WidgetEvent::Scroll {
6043            delta: ScrollDelta::Lines { x: 0.0, y: 3.0 },
6044            modifiers: Modifiers::SHIFT,
6045        });
6046        tree.layout(SizeProposal {
6047            width: Some(300.0),
6048            height: Some(200.0),
6049        });
6050        assert!(
6051            tt_scroll_x(&tree, id) > 0.0,
6052            "Shift+wheel must remap a vertical-only wheel to horizontal scroll"
6053        );
6054        let any = tree.widget_as_any(id).unwrap();
6055        let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6056        assert_eq!(
6057            tt.scroll_y_signal().get(),
6058            0.0,
6059            "Shift+wheel must not also scroll vertically"
6060        );
6061    }
6062
6063    #[test]
6064    fn tt_ensure_col_visible_follows_focus_in_both_directions() {
6065        use teksilo_core::event::{Key, Modifiers};
6066        let (mut tree, id) = build_tt_wide_unpinned_table(150.0, 5, 300.0);
6067        tree.focus(id);
6068        {
6069            let any = tree.widget_as_any(id).unwrap();
6070            any.downcast_ref::<TreeTableView<&'static str>>()
6071                .unwrap()
6072                .set_focused_cell(0, 0);
6073        }
6074        assert_eq!(tt_scroll_x(&tree, id), 0.0);
6075
6076        tree.press_key(Key::End, Modifiers::NONE);
6077        {
6078            let any = tree.widget_as_any(id).unwrap();
6079            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6080            assert_eq!(tt.focused_cell_signal().get(), Some((0, 4)));
6081        }
6082        assert!(
6083            tt_scroll_x(&tree, id) > 0.0,
6084            "ensure-column-visible must scroll right to reveal column 4"
6085        );
6086
6087        tree.press_key(Key::Home, Modifiers::NONE);
6088        {
6089            let any = tree.widget_as_any(id).unwrap();
6090            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6091            assert_eq!(tt.focused_cell_signal().get(), Some((0, 0)));
6092        }
6093        assert_eq!(
6094            tt_scroll_x(&tree, id),
6095            0.0,
6096            "ensure-column-visible must scroll left back to 0 for column 0"
6097        );
6098    }
6099
6100    // ── Column header drag-to-reorder ───────────────────────────────────
6101    //
6102    // `HeaderCell` escalates a header press into a `ColumnReorderDragData`
6103    // drag past a 5px threshold (`table_view::header`); the drop-target
6104    // half — hover feedback, insertion-slot math, pane classification,
6105    // `column_order_signal`/`column_pinning_signal` writes — is
6106    // `header::attach_header_reorder_handlers`, shared verbatim with
6107    // `TableView` (moved there by this commit, not duplicated). These
6108    // tests drive the mechanism end-to-end through real pointer events
6109    // (`drag`, defined above for row reorder — the header strip is just
6110    // another drop target) rather than the imperative
6111    // `set_column_order`/`set_column_pinning` setters already covered
6112    // above, and additionally confirm the tree column carries no special
6113    // case through the shared path: its indent/twist gutter and the
6114    // ArrowLeft/Right expand-collapse binding both re-resolve from
6115    // `display_indices` on every rebuild, so they follow it to wherever a
6116    // drag lands it — including into a pinned pane, same as any other
6117    // column.
6118
6119    /// Column `id` at a distinct `width`, so a header/body cell's bounds
6120    /// alone identify which column it is after a reorder.
6121    fn reorder_col(id: &'static str, width: f32) -> Column<&'static str> {
6122        Column::<&'static str>::new(id, lit!(id), |row, _: &CellContext| {
6123            Box::new(crate::primitives::TextWidget::new(lit!(*row)))
6124        })
6125        .width(ColumnWidth::Fixed(width))
6126    }
6127
6128    /// Four unpinned columns "a" (60px, the default tree column since it's
6129    /// declared first), "b" (70px), "c" (80px), "d" (90px) — over
6130    /// `sample_tree()` (2 visible roots, "docs" has children).
6131    fn build_tt_reorder_table() -> (WidgetTree, WidgetId, SortFilterTreeModel<&'static str>) {
6132        let proxy = SortFilterTreeModel::new(sample_tree());
6133        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
6134        let id = tree.add(
6135            TreeTableView::from_projection(proxy.clone())
6136                .add_column(reorder_col("a", 60.0))
6137                .add_column(reorder_col("b", 70.0))
6138                .add_column(reorder_col("c", 80.0))
6139                .add_column(reorder_col("d", 90.0))
6140                .row_height(20.0),
6141        );
6142        tree.layout(SizeProposal {
6143            width: Some(400.0),
6144            height: Some(200.0),
6145        });
6146        (tree, id, proxy)
6147    }
6148
6149    /// Whether `id` or any descendant is a `TwistArrow` — the indent/twist
6150    /// gutter `TreeBodyPane` wraps around whichever cell is currently the
6151    /// tree column. Identified by `widget_type_name` (a plain `type_name`
6152    /// readout, no opt-in needed) rather than `widget_as_any` downcast,
6153    /// since `TwistArrow` — a layout-only primitive nobody has needed to
6154    /// downcast before — doesn't override `Widget::as_any`.
6155    fn tt_subtree_has_twist_arrow(tree: &WidgetTree, id: WidgetId) -> bool {
6156        if tree.widget_type_name(id) == Some("teksilo_widgets::primitives::twist_arrow::TwistArrow")
6157        {
6158            return true;
6159        }
6160        tree.children(id)
6161            .into_iter()
6162            .any(|c| tt_subtree_has_twist_arrow(tree, c))
6163    }
6164
6165    #[test]
6166    fn header_drag_reorders_column_before_an_earlier_sibling() {
6167        // Drag "d" (display 3) to a slot strictly inside the unpinned band
6168        // (before "b") — a plain reorder with no pane-boundary side effect.
6169        let (mut tree, id, _proxy) = build_tt_reorder_table();
6170        let header = tt_header_row_cells(&tree, id);
6171        assert_eq!(header.len(), 4);
6172        let from = tree.bounds(header[3]).center(); // "d"
6173        let to = teksilo_canvas::Point::new(65.0, from.y); // inside "b"'s leading half
6174        drag(&mut tree, from, to);
6175
6176        {
6177            let any = tree.widget_as_any(id).unwrap();
6178            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6179            assert_eq!(
6180                tt.column_order_signal().get(),
6181                vec![
6182                    "a".to_string(),
6183                    "d".to_string(),
6184                    "b".to_string(),
6185                    "c".to_string()
6186                ],
6187                "dropping \"d\" before \"b\" must write [a, d, b, c]"
6188            );
6189            assert_eq!(
6190                tt.column_pinning_signal().get().get("d"),
6191                None,
6192                "a mid-band drop must not pin the moved column"
6193            );
6194        }
6195
6196        // display_indices re-derive: a fresh layout must actually reflow
6197        // the header cells into the new order (Fixed widths, so an exact
6198        // width sequence identifies each column unambiguously).
6199        tree.layout(SizeProposal {
6200            width: Some(400.0),
6201            height: Some(200.0),
6202        });
6203        let after = tt_header_row_cells(&tree, id);
6204        let widths: Vec<f32> = after.iter().map(|&c| tree.bounds(c).width).collect();
6205        assert!(
6206            widths
6207                .iter()
6208                .zip([60.0, 90.0, 70.0, 80.0])
6209                .all(|(&w, want)| (w - want).abs() < 0.5),
6210            "header cells must reflow to widths [60, 90, 70, 80], got {widths:?}"
6211        );
6212    }
6213
6214    #[test]
6215    fn header_drag_to_the_leading_edge_pins_the_dropped_column() {
6216        // The pane-boundary classification in `attach_header_reorder_handlers`
6217        // (`insertion_display_idx <= panes.leading_count`) is the exact same
6218        // code TableView's header shares — dropping at the very leading
6219        // edge pins the dragged column Leading, growing the leading pane.
6220        let (mut tree, id, _proxy) = build_tt_reorder_table();
6221        let header = tt_header_row_cells(&tree, id);
6222        let from = tree.bounds(header[3]).center(); // "d"
6223        let to = teksilo_canvas::Point::new(5.0, from.y); // before "a"
6224        drag(&mut tree, from, to);
6225
6226        let any = tree.widget_as_any(id).unwrap();
6227        let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6228        assert_eq!(
6229            tt.column_order_signal().get(),
6230            vec![
6231                "d".to_string(),
6232                "a".to_string(),
6233                "b".to_string(),
6234                "c".to_string()
6235            ],
6236        );
6237        assert_eq!(
6238            tt.column_pinning_signal().get().get("d").copied(),
6239            Some(PinnedSide::Leading),
6240            "dropping at the leading edge must pin the column, same as TableView"
6241        );
6242    }
6243
6244    #[test]
6245    fn header_drag_reorder_remaps_focused_and_editing_cell_to_follow_their_columns() {
6246        // `focused_cell` / `editing_cell` store `(row, display_position)` —
6247        // `imperative::remap_cell_state` (already exercised by the
6248        // `column_pinning_remaps_*` tests above via the imperative setters)
6249        // must fire the same way when the reorder arrives through a real
6250        // header drag.
6251        let (mut tree, id, _proxy) = build_tt_reorder_table();
6252        {
6253            let any = tree.widget_as_any(id).unwrap();
6254            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6255            tt.set_focused_cell(0, 1); // "b"
6256            tt.begin_edit(0, "d"); // "d"
6257        }
6258
6259        let header = tt_header_row_cells(&tree, id);
6260        let from = tree.bounds(header[3]).center(); // "d"
6261        let to = teksilo_canvas::Point::new(65.0, from.y); // before "b" — see the plain-reorder test above
6262        drag(&mut tree, from, to);
6263        tree.layout(SizeProposal {
6264            width: Some(400.0),
6265            height: Some(200.0),
6266        });
6267
6268        let any = tree.widget_as_any(id).unwrap();
6269        let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6270        assert_eq!(
6271            tt.column_order_signal().get(),
6272            vec![
6273                "a".to_string(),
6274                "d".to_string(),
6275                "b".to_string(),
6276                "c".to_string()
6277            ],
6278        );
6279        assert_eq!(
6280            tt.focused_cell_signal().get(),
6281            Some((0, 2)),
6282            "focus must follow \"b\" to its new display position"
6283        );
6284        assert_eq!(
6285            tt.editing_cell_signal().get(),
6286            Some((0, 1)),
6287            "the open editor must follow \"d\" to its new display position"
6288        );
6289    }
6290
6291    #[test]
6292    fn header_drag_moves_the_tree_column_and_twist_follows() {
6293        // The tree column carries no special case anywhere in the reorder
6294        // path: `is_tree_column` in `TreeBodyPane::build` is a plain
6295        // `display_pos == tree_display_pos` comparison, and
6296        // `tree_display_pos` is re-resolved from `display_indices` on
6297        // every rebuild (see the comment on `TreeTableView::build`'s
6298        // `key_cfg.tree_column_display_pos`). So dragging "a" (the tree
6299        // column) to a later, unpinned slot must carry the indent/twist
6300        // gutter with it, and ArrowLeft/Right must stay bound to it there.
6301        let (mut tree, id, proxy) = build_tt_reorder_table();
6302        let header = tt_header_row_cells(&tree, id);
6303        let from = tree.bounds(header[0]).center(); // "a", the tree column
6304        let to = teksilo_canvas::Point::new(220.0, from.y); // lands "a" between "c" and "d"
6305        drag(&mut tree, from, to);
6306
6307        {
6308            let any = tree.widget_as_any(id).unwrap();
6309            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6310            assert_eq!(
6311                tt.column_order_signal().get(),
6312                vec![
6313                    "b".to_string(),
6314                    "c".to_string(),
6315                    "a".to_string(),
6316                    "d".to_string()
6317                ],
6318            );
6319            assert_eq!(
6320                tt.column_pinning_signal().get().get("a"),
6321                None,
6322                "a mid-band drop must not pin the tree column either"
6323            );
6324        }
6325        tree.layout(SizeProposal {
6326            width: Some(400.0),
6327            height: Some(200.0),
6328        });
6329
6330        let body = tt_body_row_cells(&tree, id);
6331        assert_eq!(body.len(), 4);
6332        assert!(
6333            !tt_subtree_has_twist_arrow(&tree, body[0]),
6334            "\"b\" is no longer the tree column"
6335        );
6336        assert!(
6337            !tt_subtree_has_twist_arrow(&tree, body[1]),
6338            "\"c\" is no longer the tree column"
6339        );
6340        assert!(
6341            tt_subtree_has_twist_arrow(&tree, body[2]),
6342            "the twist must follow \"a\" to its new display position"
6343        );
6344        assert!(
6345            !tt_subtree_has_twist_arrow(&tree, body[3]),
6346            "\"d\" is not the tree column"
6347        );
6348
6349        // ArrowLeft/Right stay bound to the tree column at its new slot.
6350        use teksilo_core::event::{Key, Modifiers};
6351        tree.focus(id);
6352        {
6353            let any = tree.widget_as_any(id).unwrap();
6354            any.downcast_ref::<TreeTableView<&'static str>>()
6355                .unwrap()
6356                .set_focused_cell(0, 2); // row 0 ("docs"), tree column's new slot
6357        }
6358        tree.press_key(Key::ArrowRight, Modifiers::NONE);
6359        assert_eq!(
6360            proxy.visible_count(),
6361            4,
6362            "ArrowRight on the relocated tree column must expand \"docs\""
6363        );
6364        tree.press_key(Key::ArrowLeft, Modifiers::NONE);
6365        assert_eq!(proxy.visible_count(), 2, "and ArrowLeft collapses it again");
6366    }
6367
6368    #[test]
6369    fn header_drag_from_a_different_table_is_rejected() {
6370        // Each TreeTableView mints its own `table_id`; a drop whose
6371        // `ColumnReorderDragData::source_table_id` doesn't match the
6372        // hovered header's own id must be a no-op — otherwise dragging a
6373        // column between two independent tree-tables on screen would
6374        // silently reorder the wrong one.
6375        use crate::primitives::{FixedSize, HStack};
6376        let proxy1 = SortFilterTreeModel::new(sample_tree());
6377        let proxy2 = SortFilterTreeModel::new(sample_tree());
6378        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
6379
6380        let tt1 = TreeTableView::from_projection(proxy1)
6381            .add_column(reorder_col("x", 100.0))
6382            .add_column(reorder_col("y", 100.0))
6383            .row_height(20.0);
6384        let order1 = tt1.column_order_signal().clone();
6385        let id1 = tree.add(tt1);
6386        let tt2 = TreeTableView::from_projection(proxy2)
6387            .add_column(reorder_col("x", 100.0))
6388            .add_column(reorder_col("y", 100.0))
6389            .row_height(20.0);
6390        let order2 = tt2.column_order_signal().clone();
6391        let id2 = tree.add(tt2);
6392
6393        let fixed1 = tree.add(FixedSize::new().width(200.0).height(150.0).child_id(id1));
6394        let fixed2 = tree.add(FixedSize::new().width(200.0).height(150.0).child_id(id2));
6395        tree.add(HStack::new().add_child(fixed1).add_child(fixed2));
6396        tree.layout(SizeProposal {
6397            width: Some(400.0),
6398            height: Some(150.0),
6399        });
6400
6401        // tt1 occupies window x[0, 200), tt2 x[200, 400) — drag tt1's
6402        // leading header cell into tt2's header strip.
6403        let from = tree.bounds(tt_header_row_cells(&tree, id1)[0]).center();
6404        let to = teksilo_canvas::Point::new(250.0, from.y); // inside tt2's "x" cell
6405        drag(&mut tree, from, to);
6406
6407        assert!(order1.get().is_empty(), "tt1's own order must be untouched");
6408        assert!(
6409            order2.get().is_empty(),
6410            "tt2 must reject a drop whose payload names a different table_id"
6411        );
6412    }
6413
6414    #[test]
6415    fn header_drag_released_over_the_body_does_not_trigger_foreign_row_drop() {
6416        // Regression: `on_foreign_drop` fires for "any payload NOT
6417        // recognized as this view's own row drag" — without the
6418        // `ColumnReorderDragData` bail at the top of the row-level
6419        // `on_drag_hover`/`on_drop` (added alongside wiring up header
6420        // reorder — TreeTableView never carried a `ColumnReorderDragData`
6421        // payload before), a header drag released past the header strip's
6422        // own y-range would fall through into this hatch, or into a
6423        // row-insertion-line hover affordance, for a drag the header is
6424        // already handling.
6425        use std::cell::Cell;
6426        let foreign_fired = Rc::new(Cell::new(false));
6427        let flag = foreign_fired.clone();
6428        let proxy = SortFilterTreeModel::new(sample_tree());
6429        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
6430        let id = tree.add(
6431            TreeTableView::from_projection(proxy)
6432                .add_column(name_col())
6433                .add_column(size_col())
6434                .on_foreign_drop(move |_payload, _node, _pos, _ctx| {
6435                    flag.set(true);
6436                    true
6437                })
6438                .row_height(20.0),
6439        );
6440        tree.layout(SizeProposal {
6441            width: Some(400.0),
6442            height: Some(200.0),
6443        });
6444
6445        let header = tt_header_row_cells(&tree, id);
6446        let from = tree.bounds(header[0]).center();
6447        let to = teksilo_canvas::Point::new(from.x, cp::HEADER_HEIGHT + 10.0); // below the header
6448        drag(&mut tree, from, to);
6449
6450        assert!(
6451            !foreign_fired.get(),
6452            "a column-reorder drag must never reach on_foreign_drop"
6453        );
6454        let any = tree.widget_as_any(id).unwrap();
6455        let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6456        assert!(
6457            tt.column_order_signal().get().is_empty(),
6458            "no header drop occurred either — the release point was outside the header strip"
6459        );
6460    }
6461
6462    #[test]
6463    fn header_drag_insertion_is_scroll_aware() {
6464        // The insertion-slot math (`layout::insertion_slot_at_x`) is unit
6465        // tested directly for scroll-awareness; this proves the SHARED
6466        // drop-target wiring actually reaches it under a nonzero
6467        // `scroll_x`, for TreeTableView same as TableView.
6468        let (mut tree, id) = build_tt_wide_unpinned_table(100.0, 4, 200.0);
6469        let max = tt_max_scroll_x(&tree, id);
6470        assert!(max > 0.0, "4×100px columns must overflow a 200px viewport");
6471        tt_set_scroll_x(&tree, id, max); // scrolled fully right
6472        tree.layout(SizeProposal {
6473            width: Some(200.0),
6474            height: Some(200.0),
6475        });
6476
6477        // At full scroll the 200px viewport shows logical [200, 400): "c2"
6478        // fills local [0, 100), "c3" fills local [100, 200). Dropping "c3"
6479        // at local x=10 (deep in "c2"'s own zone) must resolve against the
6480        // scrolled position and land before "c2" — an unscrolled read of
6481        // the same raw x=10 would instead land before "c0".
6482        let header = tt_header_row_cells(&tree, id);
6483        let from = tree.bounds(header[3]).center(); // "c3"
6484        let to = teksilo_canvas::Point::new(10.0, from.y);
6485        drag(&mut tree, from, to);
6486
6487        let any = tree.widget_as_any(id).unwrap();
6488        let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6489        assert_eq!(
6490            tt.column_order_signal().get(),
6491            vec![
6492                "c0".to_string(),
6493                "c1".to_string(),
6494                "c3".to_string(),
6495                "c2".to_string()
6496            ],
6497            "\"c3\" must land before \"c2\" (scroll-aware), not before \"c0\""
6498        );
6499    }
6500
6501    // ── Column resize grip (parity with TableView) ─────────────────────────
6502    //
6503    // The grip machinery lives in the shared `table_view::header::HeaderCell`,
6504    // but `TreeTableView` fills its own `HeaderCellSpec` and owns its own
6505    // `resize_state` / `resize_target` / `resize_preview_x` handles — so the
6506    // wiring is asserted here too rather than assumed from the TableView side.
6507
6508    fn tt_resize_table() -> (WidgetTree, WidgetId) {
6509        // `name` Flex(1) then `size` Fixed(60) at a 400 px viewport: `name`
6510        // spans [0, 340], `size` spans [340, 400], divider at x = 340.
6511        let proxy = SortFilterTreeModel::new(sample_tree());
6512        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
6513        let id = tree.add(
6514            TreeTableView::from_projection(proxy)
6515                .add_column(name_col())
6516                .add_column(size_col())
6517                .row_height(20.0)
6518                .show_internal_scrollbars(false),
6519        );
6520        tree.layout(SizeProposal {
6521            width: Some(400.0),
6522            height: Some(200.0),
6523        });
6524        (tree, id)
6525    }
6526
6527    fn tt_overrides(tree: &WidgetTree, id: WidgetId) -> std::collections::HashMap<String, f32> {
6528        let any = tree.widget_as_any(id).unwrap();
6529        any.downcast_ref::<TreeTableView<&'static str>>()
6530            .unwrap()
6531            .column_widths_signal()
6532            .get()
6533    }
6534
6535    #[test]
6536    fn tt_grip_reaches_into_the_next_column() {
6537        use teksilo_canvas::Point;
6538        use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
6539        let (mut tree, id) = tt_resize_table();
6540        let y = cp::HEADER_HEIGHT * 0.5;
6541        // One pixel PAST the name/size divider, i.e. inside `size`.
6542        tree.dispatch_event(WidgetEvent::PointerDown {
6543            position: Point::new(341.0, y),
6544            button: PointerButton::Primary,
6545            modifiers: Modifiers::NONE,
6546        });
6547        tree.dispatch_event(WidgetEvent::PointerMove {
6548            position: Point::new(311.0, y),
6549        });
6550        tree.dispatch_event(WidgetEvent::PointerUp {
6551            position: Point::new(311.0, y),
6552            button: PointerButton::Primary,
6553            modifiers: Modifiers::NONE,
6554        });
6555        let w = tt_overrides(&tree, id);
6556        assert!(
6557            (w.get("name").copied().unwrap_or(0.0) - 310.0).abs() < 0.5,
6558            "dragging the divider left from `size` must shrink `name` from 340 \
6559             to 310; got {w:?}"
6560        );
6561    }
6562
6563    #[test]
6564    fn tt_header_strip_paints_column_separators() {
6565        let (mut tree, _id) = tt_resize_table();
6566        let frame = tree.render();
6567        let found = frame.decorations.iter().any(|d| {
6568            let [x, y, w, h] = d.rect;
6569            (x - 339.0).abs() < 0.6
6570                && w <= 1.5
6571                && y.abs() < 0.6
6572                && (h - cp::HEADER_HEIGHT).abs() < 0.6
6573        });
6574        assert!(
6575            found,
6576            "expected a header separator at the name/size divider (x≈339); \
6577             decorations={:?}",
6578            frame.decorations.iter().map(|d| d.rect).collect::<Vec<_>>()
6579        );
6580    }
6581
6582    #[test]
6583    fn tree_column_chrome_is_clipped_to_its_column() {
6584        // The indent gutter and the twist chevron are rigid: a tree column
6585        // dragged narrower than `depth * indent + twist + gap` cannot shrink
6586        // to fit, and without a clip the chevron — and the whole label after
6587        // it — draws on top of the next column. Clipping the chrome wrapper
6588        // is what lets the grip shrink the tree column all the way to its
6589        // floor without the row bleeding sideways.
6590        let (tree, id) = tt_resize_table();
6591        // Find the first body cell of the tree column (column index 1 in the
6592        // 1-based AccessKit numbering) and check its chrome wrapper clips.
6593        let mut walker = vec![id];
6594        let mut checked = false;
6595        while let Some(node) = walker.pop() {
6596            if tree.accessibility_node(node).role() == Role::Cell {
6597                let kids = tree.children(node);
6598                if let Some(&wrapper) = kids.first()
6599                    && tree.widget_clips_children(wrapper)
6600                {
6601                    checked = true;
6602                    break;
6603                }
6604            }
6605            for c in tree.children(node) {
6606                walker.push(c);
6607            }
6608        }
6609        assert!(
6610            checked,
6611            "the tree column's indent + twist wrapper must clip its children"
6612        );
6613    }
6614
6615    /// An editable column whose delegate swaps in a real `TextInput`, so a test
6616    /// can ask where the keyboard actually went.
6617    fn editable_name_col() -> Column<&'static str> {
6618        Column::<&str>::new("name", lit!("Name"), |row, cx: &CellContext| {
6619            if cx.is_editing {
6620                Box::new(crate::text_input::TextInput::new(Signal::new(
6621                    (*row).to_string(),
6622                )))
6623            } else {
6624                Box::new(crate::primitives::TextWidget::new(lit!(*row)))
6625            }
6626        })
6627        .width(ColumnWidth::Flex(1.0))
6628        .editable(true)
6629    }
6630
6631    fn three_row_slice() -> teksilo_data::TreeDataSlice<u64, &'static str> {
6632        let slice = teksilo_data::TreeDataSlice::<u64, &'static str>::new();
6633        slice.set_source(move || {
6634            [(1_u64, "one"), (2, "two"), (3, "three")]
6635                .into_iter()
6636                .map(|(k, n)| teksilo_data::TreeRow::new(k, n, 0))
6637                .collect()
6638        });
6639        slice.reload();
6640        slice
6641    }
6642
6643    /// Two primary clicks at one point, close enough together to read as a
6644    /// double-click. `WidgetTree::click` twice would be two separate taps.
6645    fn double_click_at(tree: &mut WidgetTree, at: Point) {
6646        use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
6647        for _ in 0..2 {
6648            tree.dispatch_event(WidgetEvent::PointerDown {
6649                position: at,
6650                button: PointerButton::Primary,
6651                modifiers: Modifiers::NONE,
6652            });
6653            tree.dispatch_event(WidgetEvent::PointerUp {
6654                position: at,
6655                button: PointerButton::Primary,
6656                modifiers: Modifiers::NONE,
6657            });
6658        }
6659    }
6660
6661    /// **An open cell editor holds the keyboard.**
6662    ///
6663    /// `TableView`'s body pane has always focused into the editing cell; the
6664    /// line was left behind when the tree table was split out of it, so
6665    /// `TreeTableView`'s inline editing was reachable only with the mouse. With
6666    /// focus still on the table, every keystroke went to the table's own key
6667    /// handler instead: Escape cancelled nothing, Enter activated the row, and
6668    /// typing ran type-ahead over the value being edited.
6669    #[test]
6670    fn opening_a_cell_editor_moves_the_keyboard_into_it() {
6671        let slice = three_row_slice();
6672        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
6673        let id = tree.add(
6674            TreeTableView::from_source(slice)
6675                .add_column(editable_name_col())
6676                .row_height(20.0),
6677        );
6678        let proposal = SizeProposal {
6679            width: Some(400.0),
6680            height: Some(200.0),
6681        };
6682        tree.layout(proposal);
6683        tree.focus(id);
6684
6685        {
6686            let any = tree.widget_as_any(id).unwrap();
6687            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6688            tt.begin_edit(1, "name");
6689        }
6690        tree.layout(proposal);
6691
6692        let focused = tree.focused().expect("something must hold focus");
6693        assert_ne!(
6694            focused, id,
6695            "focus is still on the table, not in the editor"
6696        );
6697        let cell = {
6698            let any = tree.widget_as_any(id).unwrap();
6699            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6700            tt.realized_cell(1, 0).expect("the edited cell is realized")
6701        };
6702        assert!(
6703            tree.is_descendant_of(focused, cell),
6704            "focus must land inside the edited cell, not on {:?}",
6705            tree.widget_type_name(focused)
6706        );
6707    }
6708
6709    /// ...and it still holds it after the pane rebuilds under it.
6710    ///
6711    /// A table rebuilds its rows constantly — selection, filtering, scroll, the
6712    /// edit signal itself — and each rebuild destroys and re-creates every cell
6713    /// widget, the open editor included. Restoring focus is therefore not a
6714    /// one-shot at edit-open: without it the first click on another row would
6715    /// silently deafen the editor the writer is still typing into. Driven
6716    /// through a selection change because that is the rebuild a click produces.
6717    #[test]
6718    fn an_open_editor_still_holds_the_keyboard_after_the_pane_rebuilds() {
6719        let slice = three_row_slice();
6720        let selection = teksilo_data::SelectionModel::new(teksilo_data::SelectionMode::Single);
6721        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
6722        let id = tree.add(
6723            TreeTableView::from_source(slice)
6724                .selection(selection.clone())
6725                .add_column(editable_name_col())
6726                .row_height(20.0),
6727        );
6728        let proposal = SizeProposal {
6729            width: Some(400.0),
6730            height: Some(200.0),
6731        };
6732        tree.layout(proposal);
6733        {
6734            let any = tree.widget_as_any(id).unwrap();
6735            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6736            tt.begin_edit(1, "name");
6737        }
6738        tree.layout(proposal);
6739        tree.focused().expect("the editor took focus");
6740
6741        selection.select(2);
6742        tree.layout(proposal);
6743
6744        let focused = tree.focused().expect("focus survived the rebuild");
6745        assert_ne!(focused, id, "the rebuild dropped focus back onto the table");
6746        let cell = {
6747            let any = tree.widget_as_any(id).unwrap();
6748            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6749            tt.realized_cell(1, 0)
6750                .expect("the edited cell is still realized")
6751        };
6752        assert!(
6753            tree.is_descendant_of(focused, cell),
6754            "focus must still be inside the edited cell, not on {:?}",
6755            tree.widget_type_name(focused)
6756        );
6757    }
6758
6759    /// **Double-click opens the editor on an editable cell** — one arm of
6760    /// [`EditTriggers`], and one that had no implementation anywhere.
6761    /// `F2 | ANY_KEY | DOUBLE_CLICK` is the default set, so every table has
6762    /// been promising this; only `keyboard.rs`'s F2 and type-to-edit ever
6763    /// reached `on_cell_edit_request`.
6764    #[test]
6765    fn a_double_click_on_an_editable_cell_opens_its_editor() {
6766        let (mut tree, id, seen, _) = click_probe(EditTriggers::DOUBLE_CLICK);
6767        let cell = realized(&tree, id, 1, 0);
6768        let at = tree.bounds(cell).center();
6769        double_click_at(&mut tree, at);
6770
6771        assert_eq!(
6772            seen.borrow().as_slice(),
6773            &[(1, "name".to_string())],
6774            "a double-click on an editable cell must request its editor"
6775        );
6776        let any = tree.widget_as_any(id).unwrap();
6777        let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6778        assert_eq!(tt.editing_cell_signal().get(), Some((1, 0)));
6779    }
6780
6781    /// **One click opens it** when the column asks for `SINGLE_CLICK` — the
6782    /// case the old closed enum could not express at all.
6783    #[test]
6784    fn a_single_click_opens_the_editor_when_the_column_asks_for_it() {
6785        let (mut tree, id, seen, _) = click_probe(EditTriggers::SINGLE_CLICK);
6786        let cell = realized(&tree, id, 1, 0);
6787        tree.click(cell);
6788
6789        assert_eq!(
6790            seen.borrow().as_slice(),
6791            &[(1, "name".to_string())],
6792            "one click on a SINGLE_CLICK column must request its editor"
6793        );
6794    }
6795
6796    /// ...and a column that asked for neither is not opened by any click.
6797    /// `NONE` has to mean none, or "read-only in practice" would be
6798    /// unexpressible for an otherwise editable column.
6799    #[test]
6800    fn a_click_opens_nothing_when_the_column_asks_for_no_click_trigger() {
6801        let (mut tree, id, seen, _) = click_probe(EditTriggers::F2);
6802        let cell = realized(&tree, id, 1, 0);
6803        tree.click(cell);
6804        let at = tree.bounds(cell).center();
6805        double_click_at(&mut tree, at);
6806
6807        assert!(
6808            seen.borrow().is_empty(),
6809            "an F2-only column opened an editor from a click: {:?}",
6810            seen.borrow()
6811        );
6812    }
6813
6814    /// A double-click that opens an editor does **not** also activate the row.
6815    ///
6816    /// The collision this rules out is opening the item *and* starting to edit
6817    /// it on one gesture, which is why the click arm could not simply be
6818    /// switched on. The framework settles it with no guard in the pane: the
6819    /// cell's gesture arena answers `Handled` to the press, so the bubble never
6820    /// reaches the row.
6821    ///
6822    /// One gesture per tree, and the read-only baseline is the **separate**
6823    /// test below: a second synthetic double-click in the same tree never
6824    /// reaches the row's `on_double_tap` at all (the recognizer reads clicks 3
6825    /// and 4 as a continuing run), so a single test doing both would pass with
6826    /// the behaviour removed — an earlier draft did, which is why this note
6827    /// exists.
6828    #[test]
6829    fn editing_a_cell_by_double_click_does_not_also_activate_the_row() {
6830        let (mut tree, id, _, activated) = click_probe(EditTriggers::DOUBLE_CLICK);
6831        let cell = realized(&tree, id, 1, 0);
6832        let at = tree.bounds(cell).center();
6833        double_click_at(&mut tree, at);
6834        assert_eq!(
6835            activated.get(),
6836            0,
6837            "double-clicking an editable cell opened the item as well as the editor"
6838        );
6839    }
6840
6841    /// The read-only column beside it still activates, which is what makes the
6842    /// guard a rule about *this gesture on an editable cell* rather than about
6843    /// the whole table.
6844    #[test]
6845    fn a_double_click_off_an_editable_cell_still_activates_the_row() {
6846        let (mut tree, id, _, activated) = click_probe(EditTriggers::DOUBLE_CLICK);
6847        let cell = realized(&tree, id, 1, 1);
6848        let at = tree.bounds(cell).center();
6849        double_click_at(&mut tree, at);
6850        assert_eq!(
6851            activated.get(),
6852            1,
6853            "a double-click away from an editable cell must still activate the row"
6854        );
6855    }
6856
6857    /// **A cell that edits on double-click still lets its row select on a
6858    /// plain click.**
6859    ///
6860    /// `press_claimed_by_interactive_child` counted `on_double_tap` as owning
6861    /// the press, so merely giving a cell double-click-to-edit silently stopped
6862    /// its row selecting — while every file manager selects a row on the first
6863    /// click of the double-click that opens it. The claim is now about
6864    /// handlers that act on a single press (`on_tap` / `on_long_press`).
6865    #[test]
6866    fn a_double_click_editable_cell_still_lets_its_row_select_on_one_click() {
6867        let selection = teksilo_data::SelectionModel::new(teksilo_data::SelectionMode::Single);
6868        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
6869        let id = tree.add(
6870            TreeTableView::from_source(three_row_slice())
6871                .selection(selection.clone())
6872                .add_column(editable_name_col().edit_triggers(EditTriggers::DOUBLE_CLICK))
6873                .row_height(20.0)
6874                .on_cell_edit_request(|_row, _col, _ctx| {}),
6875        );
6876        tree.layout(SizeProposal {
6877            width: Some(400.0),
6878            height: Some(200.0),
6879        });
6880
6881        let cell = realized(&tree, id, 1, 0);
6882        tree.click(cell);
6883        assert!(
6884            selection.is_selected(1),
6885            "one click on a double-click-editable cell must still select its row"
6886        );
6887    }
6888
6889    /// ...whereas `SINGLE_CLICK` deliberately does claim the press: that cell's
6890    /// click means "edit this value", not "select this row". Documented on
6891    /// [`EditTriggers::SINGLE_CLICK`] and the reason the set is per column.
6892    #[test]
6893    fn a_single_click_editable_cell_claims_the_press_from_row_selection() {
6894        let selection = teksilo_data::SelectionModel::new(teksilo_data::SelectionMode::Single);
6895        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
6896        let id = tree.add(
6897            TreeTableView::from_source(three_row_slice())
6898                .selection(selection.clone())
6899                .add_column(editable_name_col().edit_triggers(EditTriggers::SINGLE_CLICK))
6900                .add_column(size_col())
6901                .row_height(20.0)
6902                .on_cell_edit_request(|_row, _col, _ctx| {}),
6903        );
6904        tree.layout(SizeProposal {
6905            width: Some(400.0),
6906            height: Some(200.0),
6907        });
6908
6909        let editable = realized(&tree, id, 1, 0);
6910        tree.click(editable);
6911        assert!(
6912            !selection.is_selected(1),
6913            "a SINGLE_CLICK cell's click must go to the editor, not to selection"
6914        );
6915
6916        // The column beside it selects as always — which is what makes this a
6917        // property of the column rather than of the table.
6918        let plain = realized(&tree, id, 2, 1);
6919        tree.click(plain);
6920        assert!(
6921            selection.is_selected(2),
6922            "a click on a non-editing column must still select its row"
6923        );
6924    }
6925
6926    /// The cell realized at `(row, display column)`.
6927    fn realized(tree: &WidgetTree, id: WidgetId, row: usize, col: usize) -> WidgetId {
6928        let any = tree.widget_as_any(id).unwrap();
6929        let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6930        tt.realized_cell(row, col)
6931            .unwrap_or_else(|| panic!("cell ({row}, {col}) is not realized"))
6932    }
6933
6934    /// A laid-out table whose first column is editable under `triggers` and
6935    /// whose second is read-only, with the edit requests it receives and a
6936    /// count of row activations.
6937    #[allow(clippy::type_complexity)]
6938    fn click_probe(
6939        triggers: EditTriggers,
6940    ) -> (
6941        WidgetTree,
6942        WidgetId,
6943        Rc<RefCell<Vec<(usize, String)>>>,
6944        Rc<Cell<usize>>,
6945    ) {
6946        let seen: Rc<RefCell<Vec<(usize, String)>>> = Rc::new(RefCell::new(Vec::new()));
6947        let sink = seen.clone();
6948        let activated: Rc<Cell<usize>> = Rc::new(Cell::new(0));
6949        let counter = activated.clone();
6950        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
6951        let id = tree.add(
6952            TreeTableView::from_source(three_row_slice())
6953                .add_column(editable_name_col().edit_triggers(triggers))
6954                .add_column(size_col())
6955                .row_height(20.0)
6956                .on_cell_edit_request(move |row, col, _ctx| {
6957                    sink.borrow_mut().push((row, col.to_string()));
6958                })
6959                .on_row_activate(move |_row, _ctx| counter.set(counter.get() + 1)),
6960        );
6961        tree.layout(SizeProposal {
6962            width: Some(400.0),
6963            height: Some(200.0),
6964        });
6965        (tree, id, seen, activated)
6966    }
6967}