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            cell_map: self.cell_map.clone(),
1707            selection_mode: self.selection_mode,
1708            selection: self.row_selection.clone(),
1709            cell_selection: self.cell_selection.clone(),
1710            scroll_y: self.scroll_y.clone(),
1711            max_scroll_y: self.max_scroll_y.clone(),
1712            viewport_height: self.viewport_height.clone(),
1713            body_bounds: self.body_bounds.clone(),
1714            row_metrics: self.row_metrics.clone(),
1715            tab_traversal: self.tab_traversal,
1716            editing_cell: self.editing_cell.clone(),
1717            display_col_to_id,
1718            display_col_triggers,
1719            on_cell_edit_request: self.on_cell_edit_request.clone(),
1720            on_row_activate: self.on_row_activate.clone(),
1721            type_ahead: self.type_ahead.clone(),
1722            type_ahead_label,
1723            type_ahead_timeout: self.type_ahead_timeout,
1724            column_widths: self.column_widths.clone(),
1725            pane_boundaries: *self.pane_boundaries.borrow(),
1726            scroll_x: self.scroll_x.clone(),
1727            max_scroll_x: self.max_scroll_x.clone(),
1728            middle_viewport_width: self.middle_viewport_width.clone(),
1729        };
1730
1731        // Alt+Arrow tree sibling reorder wraps the shared key handler: a move
1732        // among the node's siblings in the underlying `TreeModel` (cycle-free
1733        // by construction). Suppressed while sorted. Every other key falls
1734        // through to the navigator (cell/row movement, expand/collapse, edit).
1735        let mut shared_key = keyboard::build_key_handler(key_cfg);
1736        let reorderable_kbd = self.reorderable;
1737        let source_kbd = self.source.clone();
1738        let focused_kbd = self.focused_cell.clone();
1739        let sel_kbd = self.row_selection.clone();
1740        let sort_kbd = self.sort_signal.clone();
1741        let key_handler = move |event: &teksilo_core::event::WidgetEvent,
1742                                ctx: &mut EventContext|
1743              -> EventResponse {
1744            use teksilo_core::event::{Key, WidgetEvent};
1745            if reorderable_kbd
1746                && sort_kbd.get().is_none()
1747                && let WidgetEvent::KeyDown { key, modifiers, .. } = event
1748                && modifiers.alt()
1749                && matches!(key, Key::ArrowUp | Key::ArrowDown)
1750            {
1751                let row = focused_kbd.get().map(|(r, _)| r).or_else(|| {
1752                    sel_kbd
1753                        .as_ref()
1754                        .and_then(|s| s.selected_indices().first().copied())
1755                });
1756                // Sibling reorder + the "follow the moved row" bookkeeping live
1757                // in the source (key-typed there, so it works for an external
1758                // store too) and hand back the row's new flat index.
1759                if let Some(flat_idx) = row
1760                    && let Some(new_flat) =
1761                        source_kbd.keyboard_reorder(flat_idx, matches!(key, Key::ArrowDown))
1762                {
1763                    let col = focused_kbd.get().map(|(_, c)| c).unwrap_or(0);
1764                    focused_kbd.set(Some((new_flat, col)));
1765                    if let Some(ref s) = sel_kbd {
1766                        s.select(new_flat);
1767                    }
1768                    return EventResponse::Handled;
1769                }
1770            }
1771            shared_key(event, ctx)
1772        };
1773
1774        let mut handlers = HandlerSet::new()
1775            .on_scroll({
1776                let overscroll_behavior = self.overscroll_behavior;
1777                move |event, _ctx| match event {
1778                    teksilo_core::event::WidgetEvent::Scroll { delta, modifiers } => {
1779                        let (raw_dx, raw_dy) = match delta {
1780                            teksilo_core::event::ScrollDelta::Lines { x, y } => {
1781                                (x * line_height, y * line_height)
1782                            }
1783                            teksilo_core::event::ScrollDelta::Pixels { x, y } => (*x, *y),
1784                        };
1785                        // Shift+wheel remaps a vertical-only wheel to
1786                        // horizontal scroll (the `TabBar` precedent).
1787                        let (dx, dy) = if modifiers.shift() && raw_dx.abs() < f32::EPSILON {
1788                            (raw_dy, 0.0)
1789                        } else {
1790                            (raw_dx, raw_dy)
1791                        };
1792
1793                        let mut moved_any = false;
1794                        if dy.abs() > 0.0 {
1795                            let current = scroll_y_for_wheel.get();
1796                            let max = max_scroll_for_wheel.get();
1797                            // Base off the animation target (not the rendered
1798                            // offset) so a mid-fling boundary correctly chains
1799                            // and successive notches accumulate instead of
1800                            // restarting from the partway-animated position.
1801                            let base = scroll_y_for_wheel.animation_target().unwrap_or(current);
1802                            let (new_y, moved) =
1803                                crate::common::scroll::scroll_clamp_axis(base, dy, max);
1804                            if moved {
1805                                if smooth_scrolling {
1806                                    scroll_y_for_wheel.animate_to(
1807                                        new_y,
1808                                        smooth_scroll_duration,
1809                                        Easing::EaseOut,
1810                                    );
1811                                } else {
1812                                    scroll_y_for_wheel.set(new_y);
1813                                }
1814                            }
1815                            moved_any |= moved;
1816                        }
1817                        if dx.abs() > 0.0 {
1818                            let current = scroll_x_for_wheel.get();
1819                            let max = max_scroll_x_for_wheel.get();
1820                            let base = scroll_x_for_wheel.animation_target().unwrap_or(current);
1821                            let (new_x, moved) =
1822                                crate::common::scroll::scroll_clamp_axis(base, dx, max);
1823                            if moved {
1824                                if smooth_scrolling {
1825                                    scroll_x_for_wheel.animate_to(
1826                                        new_x,
1827                                        smooth_scroll_duration,
1828                                        Easing::EaseOut,
1829                                    );
1830                                } else {
1831                                    scroll_x_for_wheel.set(new_x);
1832                                }
1833                            }
1834                            moved_any |= moved;
1835                        }
1836                        // Chain to an ancestor scrollable when fully
1837                        // clamped (unless Contain), otherwise consume —
1838                        // same contract as ListView/TreeView/TableView.
1839                        crate::common::scroll::scroll_response(
1840                            moved_any,
1841                            overscroll_behavior == OverscrollBehavior::Contain,
1842                        )
1843                    }
1844                    _ => EventResponse::Ignored,
1845                }
1846            })
1847            .on_key(key_handler)
1848            .clips_children(true)
1849            .focusable(true);
1850
1851        // Row DnD: same-view reorder (reorderable) reparents/reorders the
1852        // dragged node(s) in the underlying `TreeModel`, cycle-guarded and
1853        // suppressed while sorted; plus optional foreign receive
1854        // (accept_foreign_rows / on_foreign_drop). Registered whenever ANY
1855        // of the three capabilities is enabled — a foreign-receive-only view
1856        // (reorderable == false) still needs to be a drop target.
1857        // NOTE: row DnD is still `NodeId`-typed, so it is registered only on the
1858        // projection path. A source-backed view (`from_source`) gets every other
1859        // capability but no built-in row drag yet — routing this through
1860        // `source.dnd.{can_accept,accept_drop}_fn` (as `TreeView` already does)
1861        // is a follow-up, because those closures also carry Into/Before/After
1862        // redirect semantics this widget does not model yet.
1863        // Row DnD: same-view reorder/reparent plus foreign receive, both routed
1864        // through the source's `can_accept` / `accept_drop` capability closures
1865        // — so this works over a `TreeModel`-backed projection AND an external
1866        // `TreeDataSource`, exactly like `TreeView`. Drop zones are the row's
1867        // thirds (Before / Into / After); the source's verdict decides the
1868        // effective position and may `Redirect` (e.g. Into-a-leaf becomes
1869        // After). Suppressed while sorted, where a manual order has no meaning.
1870        if self.export.is_drop_target(self.reorderable) || self.on_foreign_drop.is_some() {
1871            let my_model_id = self.model_id;
1872            let source_for_hover = self.source.clone();
1873            let metrics_for_hover = self.row_metrics.clone();
1874            let scroll_for_hover = self.scroll_y.clone();
1875            let header_h_for_hover = header_h;
1876            let feedback_for_hover = self.drop_feedback.clone();
1877            let sort_for_hover = self.sort_signal.clone();
1878            let reorderable_hover = self.reorderable;
1879            let export_for_hover = self.export.clone();
1880            let has_foreign_hook_hover = self.on_foreign_drop.is_some();
1881            let bounds_for_hover = self.body_bounds.clone();
1882            handlers = handlers.on_drag_hover(move |payload, position, _ctx| {
1883                // Column reorder is handled by the header strip
1884                // (`attach_header_reorder_handlers`); only row-level drops
1885                // get an insertion/into affordance here. Without this bail,
1886                // a `ColumnReorderDragData` dragged past the header into the
1887                // body would fall through to `on_foreign_drop` (which
1888                // accepts any payload type) and paint a row-drop visual for
1889                // a drag the header strip is already handling.
1890                if payload.has_typed::<ColumnReorderDragData>() {
1891                    feedback_for_hover.set(None);
1892                    return teksilo_core::DropFeedback::NoFeedback;
1893                }
1894                // Real body width, so the affordance spans the actual row area
1895                // rather than a placeholder.
1896                let viz_width = bounds_for_hover.get().width.max(1.0);
1897                let count = source_for_hover.visible_count();
1898                if count == 0 {
1899                    feedback_for_hover.set(None);
1900                    return teksilo_core::DropFeedback::NoFeedback;
1901                }
1902                let rd = payload.get_typed::<RowDragData<T>>();
1903                let is_same_view = rd.is_some_and(|r| r.source == my_model_id);
1904                let reorder_ok =
1905                    is_same_view && reorderable_hover && sort_for_hover.get().is_none();
1906                // The typed `accept_foreign_rows`/`on_rows_received` path can
1907                // only consume an EXPORT payload (items present); the raw
1908                // `on_foreign_drop` hook takes any foreign payload.
1909                let foreign_ok = !is_same_view
1910                    && (has_foreign_hook_hover
1911                        || export_for_hover.accepts_foreign_export(payload, my_model_id));
1912                if !reorder_ok && !foreign_ok {
1913                    feedback_for_hover.set(None);
1914                    return teksilo_core::DropFeedback::NoFeedback;
1915                }
1916                let scroll = scroll_for_hover.get().max(0.0);
1917                let content_y = position.y - header_h_for_hover + scroll;
1918                let (insertion_top, row_idx, row_top, row_h) = {
1919                    let mut m = metrics_for_hover.borrow_mut();
1920                    m.resize(count);
1921                    let ins = m.insertion_index(content_y);
1922                    let r = m.row_at(content_y);
1923                    (m.row_top(ins), r, m.row_top(r), m.row_height(r))
1924                };
1925                let y_in_row = content_y - row_top;
1926                let third = (row_h / 3.0).max(f32::EPSILON);
1927                let drop_pos = if y_in_row < third {
1928                    DropPosition::Before
1929                } else if y_in_row > 2.0 * third {
1930                    DropPosition::After
1931                } else {
1932                    DropPosition::Into
1933                };
1934                // The source owns the structural verdict — including the cycle
1935                // guard (a node may not land inside its own subtree), which used
1936                // to be re-derived here against the `TreeModel`.
1937                // `depth` rides along so `paint` can indent the affordance to
1938                // the level the dropped row lands at — see `TreeView`'s twin of
1939                // this block. A foreign drop lands at a flat index the view
1940                // cannot promise a nesting for, so it claims none: depth 0.
1941                let (effective, depth) = if reorder_ok {
1942                    match (source_for_hover.dnd.can_accept_fn)(
1943                        payload,
1944                        row_idx,
1945                        drop_pos,
1946                        my_model_id,
1947                    ) {
1948                        DropResponse::Reject => {
1949                            if !foreign_ok {
1950                                feedback_for_hover.set(None);
1951                                return teksilo_core::DropFeedback::NoFeedback;
1952                            }
1953                            (DropPosition::Before, 0)
1954                        }
1955                        DropResponse::Accept => (drop_pos, source_for_hover.depth(row_idx)),
1956                        DropResponse::Redirect(p) => (p, source_for_hover.depth(row_idx)),
1957                    }
1958                } else {
1959                    // A foreign source has no Into/reparent semantics to honor.
1960                    (DropPosition::Before, 0)
1961                };
1962                if effective == DropPosition::Into {
1963                    let top = row_top - scroll;
1964                    feedback_for_hover.set(Some(DropViz::Rect {
1965                        top,
1966                        height: row_h,
1967                        width: viz_width,
1968                        depth,
1969                    }));
1970                    teksilo_core::DropFeedback::HighlightRect {
1971                        rect: Rect::new(0.0, top, viz_width, row_h),
1972                        color: drop_into_tint(),
1973                    }
1974                } else {
1975                    let insertion_y = insertion_top - scroll;
1976                    feedback_for_hover.set(Some(DropViz::Line {
1977                        y: insertion_y,
1978                        width: viz_width,
1979                        depth,
1980                    }));
1981                    teksilo_core::DropFeedback::InsertionLine {
1982                        y: insertion_y,
1983                        width: viz_width,
1984                    }
1985                }
1986            });
1987
1988            let drop_model_id = self.model_id;
1989            let source_for_drop = self.source.clone();
1990            let metrics_for_drop = self.row_metrics.clone();
1991            let scroll_for_drop = self.scroll_y.clone();
1992            let header_h_for_drop = header_h;
1993            let feedback_for_drop = self.drop_feedback.clone();
1994            let sort_for_drop = self.sort_signal.clone();
1995            let reorderable_drop = self.reorderable;
1996            let on_foreign_for_drop = self.on_foreign_drop.clone();
1997            let proxy_for_foreign_hook = self.proxy.clone();
1998            let export_for_drop = self.export.clone();
1999            handlers = handlers.on_drop(move |mut payload, position, ctx| {
2000                feedback_for_drop.set(None);
2001                // See the matching bail in `on_drag_hover` above — a column
2002                // reorder drop is the header strip's, never the body's
2003                // (`on_foreign_drop` would otherwise swallow it).
2004                if payload.has_typed::<ColumnReorderDragData>() {
2005                    return false;
2006                }
2007                let count = source_for_drop.visible_count();
2008                if count == 0 {
2009                    return false;
2010                }
2011                let scroll = scroll_for_drop.get().max(0.0);
2012                let content_y = position.y - header_h_for_drop + scroll;
2013                let (flat_idx, row_top, row_h, ins) = {
2014                    let mut m = metrics_for_drop.borrow_mut();
2015                    m.resize(count);
2016                    let idx = m.row_at(content_y);
2017                    let ins = m.insertion_index(content_y);
2018                    (idx, m.row_top(idx), m.row_height(idx), ins)
2019                };
2020                let y_in_row = content_y - row_top;
2021                let third = (row_h / 3.0).max(f32::EPSILON);
2022                let drop_pos = if y_in_row < third {
2023                    DropPosition::Before
2024                } else if y_in_row > 2.0 * third {
2025                    DropPosition::After
2026                } else {
2027                    DropPosition::Into
2028                };
2029                let is_same_view = payload
2030                    .get_typed::<RowDragData<T>>()
2031                    .is_some_and(|rd| rd.source == drop_model_id);
2032                if is_same_view && (!reorderable_drop || sort_for_drop.get().is_some()) {
2033                    return false;
2034                }
2035                // The source applies the move (cycle-guarded, undo-aware for an
2036                // external store) and reports whether it took. Gated exactly as
2037                // `TreeView` does, so a foreign payload the source does NOT
2038                // recognise still reaches the `on_rows_received` sugar below.
2039                if (reorderable_drop || !is_same_view)
2040                    && (source_for_drop.dnd.accept_drop_fn)(
2041                        &payload,
2042                        flat_idx,
2043                        drop_pos,
2044                        drop_model_id,
2045                    )
2046                {
2047                    if is_same_view {
2048                        export_for_drop.note_self_reorder();
2049                    }
2050                    return true;
2051                }
2052                // Foreign payload: the typed receive sugar first, then the raw
2053                // escape hatch.
2054                if export_for_drop.foreign_receive(&mut payload, drop_model_id, ins, ctx) {
2055                    return true;
2056                }
2057                // `on_foreign_drop` predates the source path and is
2058                // `NodeId`-typed, so it only fires when there is a projection to
2059                // resolve the target node through.
2060                if let Some(ref hook) = on_foreign_for_drop
2061                    && let Some(ref p) = proxy_for_foreign_hook
2062                    && let Some(node) = p.visible_node_id(flat_idx)
2063                {
2064                    return hook(&payload, node, drop_pos, ctx);
2065                }
2066                false
2067            });
2068
2069            let feedback_for_leave = self.drop_feedback.clone();
2070            handlers = handlers.on_drag_leave(move |_ctx| {
2071                feedback_for_leave.set(None);
2072            });
2073
2074            let scroll_for_tick = self.scroll_y.clone();
2075            let max_scroll_for_tick = self.max_scroll_y.clone();
2076            let viewport_for_tick = self.viewport_height.clone();
2077            let header_h_for_tick = header_h;
2078            handlers = handlers.on_drag_tick(move |pos, _ctx| {
2079                // Auto-scroll near the body band's top/bottom edge during a
2080                // drag (body-relative so the header doesn't count as the top).
2081                const EDGE: f32 = 32.0;
2082                const MAX_VELOCITY: f32 = 12.0;
2083                let body_h = (viewport_for_tick.get() - header_h_for_tick).max(0.0);
2084                let y = pos.y - header_h_for_tick;
2085                let above = (EDGE - y).max(0.0);
2086                let below = (y - (body_h - EDGE)).max(0.0);
2087                let delta = if above > 0.0 {
2088                    -(above / EDGE) * MAX_VELOCITY
2089                } else if below > 0.0 {
2090                    (below / EDGE) * MAX_VELOCITY
2091                } else {
2092                    0.0
2093                };
2094                if delta.abs() > 0.01 {
2095                    let max = max_scroll_for_tick.get();
2096                    let new_y = (scroll_for_tick.get() + delta).clamp(0.0, max);
2097                    scroll_for_tick.set(new_y);
2098                }
2099            });
2100        }
2101
2102        // Export completion (move-out): fires on the drag source — this
2103        // view's root id, the stable id `start_drag` is given via the body
2104        // pane's `drag_anchor`. A same-view reorder called
2105        // `self.export.note_self_reorder()` in `on_drop` above, so it is
2106        // skipped here (already applied). Absent an
2107        // `on_rows_transferred_out` override, the default move-out runs the
2108        // stable-`NodeId` removal thunk `TreeBodyPane::build`'s `on_drag`
2109        // resolved at drag-start (ascending pre-order, so an already-removed
2110        // descendant of another dragged node is safely skipped).
2111        handlers = self.export.install_completion(handlers);
2112
2113        ctx.apply_self_handlers(handlers);
2114
2115        // ── Build children ────────────────────────────────────────────
2116
2117        self.header_row_id = None;
2118        self.body_pane_id = None;
2119        self.scrollbar_id = None;
2120        self.h_scrollbar_id = None;
2121        self.empty_id = None;
2122
2123        // Header strip.
2124        if self.show_header {
2125            // See `TableView::build`: a rebuild drops the pointer capture an
2126            // in-flight resize rides on, so the shared drag state must go with
2127            // it or a later bare PointerMove would resize with no button held.
2128            *self.resize_state.borrow_mut() = None;
2129            self.resize_target.set(None);
2130            self.resize_preview_x.set(None);
2131
2132            let boundaries = *self.pane_boundaries.borrow();
2133            let resize_columns: ColumnResizeTable = Rc::new(
2134                display_indices
2135                    .iter()
2136                    .map(|&i| {
2137                        let c = &self.columns[i];
2138                        ColumnResizeInfo {
2139                            id: c.id.clone(),
2140                            min_width: c.min_width.unwrap_or(cp::MIN_COLUMN_WIDTH_DEFAULT),
2141                            max_width: c.max_width,
2142                            resizable: c.resizable,
2143                        }
2144                    })
2145                    .collect(),
2146            );
2147            let mut cell_ids: Vec<WidgetId> = Vec::with_capacity(display_indices.len());
2148            let active_sort = self.sort_signal.get();
2149            for (display_pos, &col_idx) in display_indices.iter().enumerate() {
2150                let col = &self.columns[col_idx];
2151                let current_sort = active_sort
2152                    .as_ref()
2153                    .and_then(|(id, dir)| if id == &col.id { Some(*dir) } else { None });
2154                let filter_zone_width = cp::FILTER_INDICATOR_SIZE + cp::CELL_PADDING_HORIZONTAL;
2155                let cell = HeaderCell::new(HeaderCellSpec {
2156                    col_id: col.id.clone(),
2157                    label: col.header_label.resolve_now(),
2158                    col_index_1based: display_pos + 1,
2159                    sortable: col.sortable,
2160                    reorderable: col.reorderable,
2161                    filterable: col.filterable,
2162                    resize_grip: cp::RESIZE_HANDLE_WIDTH,
2163                    filter_zone_width,
2164                    current_sort,
2165                    width_index: display_pos,
2166                    pane_boundaries: boundaries,
2167                    resize_columns: resize_columns.clone(),
2168                    resize_policy: self.column_resize_policy,
2169                    resize_state: self.resize_state.clone(),
2170                    resize_target: self.resize_target.clone(),
2171                    resize_preview_x: self.resize_preview_x.clone(),
2172                    table_id: self.table_id,
2173                    sort_signal: self.sort_signal.clone(),
2174                    column_widths_signal: self.column_widths_signal.clone(),
2175                    column_widths: self.column_widths.clone(),
2176                    filters_signal: self.filters_signal.clone(),
2177                });
2178                cell_ids.push(ctx.add(cell));
2179            }
2180            let header_row = HeaderRow::new(
2181                cell_ids,
2182                self.column_widths.clone(),
2183                cp::GRID_LINE_THICKNESS,
2184                *self.pane_boundaries.borrow(),
2185                self.scroll_x.clone(),
2186            );
2187            // Wire reorder drag-target handlers on the header strip — the
2188            // shared drop-target half of the mechanism `HeaderCell` already
2189            // escalates a press into (see `table_view::header`). The tree
2190            // column reorders like any other column: it carries no special
2191            // case here, since `tree_display_pos` (re-resolved from
2192            // `display_indices` on every rebuild — see below) is what makes
2193            // the indent/twist gutter and Left/Right expand-collapse follow
2194            // it wherever the drop lands, including into the leading- or
2195            // trailing-pinned pane.
2196            let header_row_id = ctx.add(header_row);
2197            attach_header_reorder_handlers(
2198                ctx,
2199                header_row_id,
2200                self.table_id,
2201                self.column_widths.clone(),
2202                self.display_indices.clone(),
2203                self.pane_boundaries.clone(),
2204                self.column_order_signal.clone(),
2205                self.column_pinning_signal.clone(),
2206                self.columns.iter().map(|c| c.id.clone()).collect(),
2207                self.header_strip_width.clone(),
2208                self.scroll_x.clone(),
2209            );
2210            self.header_row_id = Some(header_row_id);
2211        }
2212
2213        // Body rows live in a TreeBodyPane — a sibling of the
2214        // scrollbar, so buffer-exit / selection / editing / expand
2215        // rebuilds target the pane and are never deferred by the
2216        // gesture-capture protection during a thumb drag.
2217        let row_count = self.source.visible_count();
2218
2219        // Lazy: nudge the source to load the realized window, and fetch
2220        // the next page as the viewport nears the end (append-only
2221        // sources). `TreeSource` already erases a `TreeDataSource`'s
2222        // `row_state`/`request_window`/`can_fetch_more`/`fetch_more`
2223        // into `self.source.dnd` (mirrors `list_source::DndLazy` — see
2224        // `TableView::build`); a fully-resident source's default (inert)
2225        // impls leave this a no-op.
2226        let (vis_start, vis_end) = self.visible_range();
2227        (self.source.dnd.request_window_fn)(vis_start..vis_end);
2228        if (self.source.dnd.can_fetch_more_fn)() && vis_end + BUFFER_ROWS >= row_count {
2229            (self.source.dnd.fetch_more_fn)();
2230        }
2231
2232        if row_count > 0 {
2233            let pane = body_pane::TreeBodyPane::<T> {
2234                source: self.source.clone(),
2235                editing_anchor: self.editing_anchor.clone(),
2236                columns: self.columns.clone(),
2237                display_indices: self.display_indices.clone(),
2238                column_widths: self.column_widths.clone(),
2239                pane_boundaries: *self.pane_boundaries.borrow(),
2240                scroll_x: self.scroll_x.clone(),
2241                tree_display_pos,
2242                indent_per_level,
2243                row_metrics: self.row_metrics.clone(),
2244                selection_mode: self.selection_mode,
2245                selection: self.row_selection.clone(),
2246                cell_selection: self.cell_selection.clone(),
2247                scroll_y: self.scroll_y.clone(),
2248                viewport_height: self.viewport_height.clone(),
2249                editing_cell: self.editing_cell.clone(),
2250                focused_cell: self.focused_cell.clone(),
2251                reorderable: self.reorderable,
2252                model_id: self.model_id,
2253                export: self.export.clone(),
2254                drag_anchor: ctx.self_id(),
2255                on_row_activate: self.on_row_activate.clone(),
2256                activate_on: self.activate_on,
2257                edit_triggers: self.edit_triggers,
2258                on_cell_edit_request: self.on_cell_edit_request.clone(),
2259                on_cell_edit_dismissed: self.on_cell_edit_dismissed.clone(),
2260                version: self.pane_version.clone(),
2261                prev_built_start: self.pane_built_start.clone(),
2262                prev_built_end: self.pane_built_end.clone(),
2263                total_refresh: self.pane_total_refresh.clone(),
2264                row_entries: Vec::new(),
2265                row_map: self.row_map.clone(),
2266                cell_map: self.cell_map.clone(),
2267            };
2268            self.body_pane_id = Some(ctx.add(pane));
2269            // An open cell editor also ends on a press that lands on no cell at
2270            // all — the empty band under the last row. Mounted here rather than
2271            // on the pane because the pane is not the hit target there.
2272            if let Some(handlers) = crate::table_view::body_pane::root_edit_dismiss_handler(
2273                &self.on_cell_edit_dismissed,
2274                &self.editing_cell,
2275                &Rc::new(
2276                    display_indices
2277                        .iter()
2278                        .map(|&i| self.columns[i].id.clone())
2279                        .collect::<Vec<_>>(),
2280                ),
2281            ) {
2282                ctx.apply_self_handlers(handlers);
2283            }
2284        } else if let Some(ref f) = self.empty_view {
2285            // Empty state — an empty tree, or a filter that matched nothing.
2286            self.empty_id = Some(ctx.add_boxed(f()));
2287        }
2288
2289        // Scrollbar.
2290        if self.show_internal_scrollbars {
2291            let sb = ScrollBar::new(
2292                ScrollBarOrientation::Vertical,
2293                self.scroll_y.clone(),
2294                self.max_scroll_y.clone(),
2295                self.viewport_ratio_y.clone(),
2296            )
2297            .visual(match self.scroll_bar_style {
2298                ScrollBarMode::Permanent => ScrollBarVisual::Permanent,
2299                ScrollBarMode::Overlay => ScrollBarVisual::Overlay,
2300                ScrollBarMode::Thin => ScrollBarVisual::Thin,
2301            });
2302            self.scrollbar_id = Some(ctx.add(sb));
2303
2304            // Horizontal bar — the Middle pane only, mirrors `TableView`.
2305            let hsb = ScrollBar::new(
2306                ScrollBarOrientation::Horizontal,
2307                self.scroll_x.clone(),
2308                self.max_scroll_x.clone(),
2309                self.viewport_ratio_x.clone(),
2310            )
2311            .visual(match self.scroll_bar_style {
2312                ScrollBarMode::Permanent => ScrollBarVisual::Permanent,
2313                ScrollBarMode::Overlay => ScrollBarVisual::Overlay,
2314                ScrollBarMode::Thin => ScrollBarVisual::Thin,
2315            });
2316            self.h_scrollbar_id = Some(ctx.add(hsb));
2317        }
2318
2319        // Z-order mirrors TableView: body pane first, header last so it
2320        // paints above any row that bleeds into the header band on
2321        // overscroll.
2322        let mut children: Vec<WidgetId> = Vec::new();
2323        if let Some(id) = self.body_pane_id {
2324            children.push(id);
2325        }
2326        if let Some(id) = self.empty_id {
2327            children.push(id);
2328        }
2329        if let Some(id) = self.scrollbar_id {
2330            children.push(id);
2331        }
2332        if let Some(id) = self.h_scrollbar_id {
2333            children.push(id);
2334        }
2335        if let Some(id) = self.header_row_id {
2336            children.push(id);
2337        }
2338        let _ = (header_h, row_h);
2339        children
2340    }
2341
2342    fn layout_response(
2343        &self,
2344        proposal: SizeProposal,
2345        _ctx: &LayoutContext,
2346    ) -> teksilo_core::widget::LayoutResponse {
2347        // Only an allocation may seed the cached viewport (`common::viewport`);
2348        // the body pane shares this very cell, so a measurement's fallback
2349        // would desync its realization window.
2350        let size = crate::common::viewport::viewport_size(
2351            proposal,
2352            &self.viewport_height,
2353            Size::new(400.0, 300.0),
2354        );
2355        if proposal.height.is_some() {
2356            // Viewport-relative imperatives are meaningful from here on — but
2357            // only once a real height has landed, for the reason `laid_out`
2358            // exists at all.
2359            self.laid_out.set(true);
2360        }
2361        size.into()
2362    }
2363
2364    fn place_children(
2365        &self,
2366        bounds: Rect,
2367        _proposal: SizeProposal,
2368        children: &mut [WidgetPlacement],
2369        ctx: &LayoutContext,
2370    ) {
2371        if children.is_empty() {
2372            return;
2373        }
2374        let rtl = ctx.is_rtl();
2375        let header_h = self.effective_header_height();
2376        let body_height_provisional = (bounds.height - header_h).max(0.0);
2377
2378        // Parent-before-child layout order means this runs before the
2379        // body pane's measure pass — in auto-measure mode the scrollbar
2380        // totals settle one frame after a measurement change.
2381        let total_height = self
2382            .row_metrics
2383            .borrow_mut()
2384            .total_height(self.source.visible_count());
2385        let needs_v_scrollbar =
2386            self.show_internal_scrollbars && total_height > body_height_provisional + 0.5;
2387        // Permanent reserves a layout column for the bar; Overlay / Thin
2388        // float over the content, so the body spans the full width.
2389        let reserves_v_bar = needs_v_scrollbar && self.scroll_bar_style == ScrollBarMode::Permanent;
2390        let body_width = if reserves_v_bar {
2391            (bounds.width - SCROLLBAR_THICKNESS).max(0.0)
2392        } else {
2393            bounds.width
2394        };
2395        // RTL mirror (see TableView::place_children): scrollbar to the
2396        // physical left, body/header band shifted right by its thickness.
2397        // Only shift when the bar actually reserves a column (Permanent).
2398        let band_left = if rtl && reserves_v_bar {
2399            bounds.x + SCROLLBAR_THICKNESS
2400        } else {
2401            bounds.x
2402        };
2403        let scrollbar_x = if rtl {
2404            bounds.x
2405        } else {
2406            bounds.x + bounds.width - SCROLLBAR_THICKNESS
2407        };
2408        // The header strip spans the band; snapshot its width for the
2409        // reorder-drop handler's RTL mirror (see `TableView::place_children`).
2410        self.header_strip_width.set(body_width);
2411
2412        let overrides = self.column_widths_signal.get();
2413        let display = self.display_indices.borrow().clone();
2414        let widths = layout::ColumnSolver::resolve_in_order(
2415            &self.columns,
2416            &display,
2417            body_width,
2418            cp::MIN_COLUMN_WIDTH_DEFAULT,
2419            &overrides,
2420        );
2421
2422        // Pane geometry (see `TableView::place_children`).
2423        let boundaries = *self.pane_boundaries.borrow();
2424        let (leading_w, middle_content_w, trailing_w) = layout::pane_widths(&widths, boundaries);
2425        let middle_viewport_w = (body_width - leading_w - trailing_w).max(0.0);
2426        let max_x = (middle_content_w - middle_viewport_w).max(0.0);
2427        self.max_scroll_x.set(max_x);
2428        self.middle_viewport_width.set(middle_viewport_w);
2429        let x_ratio = if middle_content_w > 0.0 {
2430            (middle_viewport_w / middle_content_w).clamp(0.0, 1.0)
2431        } else {
2432            1.0
2433        };
2434        self.viewport_ratio_x.set(x_ratio);
2435        {
2436            let current = self.scroll_x.get();
2437            let clamped = current.clamp(0.0, max_x);
2438            if (clamped - current).abs() > 0.001 {
2439                self.scroll_x.set(clamped);
2440            }
2441        }
2442
2443        *self.column_widths.borrow_mut() = widths;
2444
2445        let needs_h_scrollbar = self.show_internal_scrollbars && max_x > 0.5;
2446        let reserves_h_bar = needs_h_scrollbar && self.scroll_bar_style == ScrollBarMode::Permanent;
2447        let body_height = if reserves_h_bar {
2448            (body_height_provisional - SCROLLBAR_THICKNESS).max(0.0)
2449        } else {
2450            body_height_provisional
2451        };
2452
2453        let max_y = (total_height - body_height).max(0.0);
2454        self.max_scroll_y.set(max_y);
2455        let y_ratio = if total_height > 0.0 {
2456            (body_height / total_height).clamp(0.0, 1.0)
2457        } else {
2458            1.0
2459        };
2460        self.viewport_ratio_y.set(y_ratio);
2461        self.clamp_scroll();
2462
2463        let body_origin_y = bounds.y + header_h;
2464        // Cache the row-area rect for the keyboard handler's outer-scroll chase.
2465        self.body_bounds
2466            .set(Rect::new(band_left, body_origin_y, body_width, body_height));
2467
2468        let mut next = 0;
2469
2470        // Body pane fills the body region; it positions its rows
2471        // internally and clips them to its own bounds.
2472        if self.body_pane_id.is_some() {
2473            if let Some(child) = children.get_mut(next) {
2474                child.origin = Point::new(band_left, body_origin_y);
2475                child.size = Size::new(body_width, body_height);
2476            }
2477            next += 1;
2478        }
2479
2480        // Empty-state child fills the body region (below the header).
2481        if self.empty_id.is_some() {
2482            if let Some(child) = children.get_mut(next) {
2483                child.origin = Point::new(band_left, body_origin_y);
2484                child.size = Size::new(body_width, body_height);
2485            }
2486            next += 1;
2487        }
2488
2489        // Scrollbar — alongside the body, below the header.
2490        if self.scrollbar_id.is_some() {
2491            if let Some(child) = children.get_mut(next) {
2492                if needs_v_scrollbar {
2493                    child.origin = Point::new(scrollbar_x, body_origin_y);
2494                    child.size = Size::new(SCROLLBAR_THICKNESS, body_height);
2495                } else {
2496                    child.origin = bounds.origin();
2497                    child.size = Size::ZERO;
2498                }
2499            }
2500            next += 1;
2501        }
2502
2503        // Horizontal scrollbar — the Middle pane's own band, below the body.
2504        if self.h_scrollbar_id.is_some() {
2505            if let Some(child) = children.get_mut(next) {
2506                if needs_h_scrollbar {
2507                    let h_x = if rtl {
2508                        band_left + trailing_w
2509                    } else {
2510                        band_left + leading_w
2511                    };
2512                    child.origin = Point::new(h_x, body_origin_y + body_height);
2513                    child.size = Size::new(middle_viewport_w, SCROLLBAR_THICKNESS);
2514                } else {
2515                    child.origin = bounds.origin();
2516                    child.size = Size::ZERO;
2517                }
2518            }
2519            next += 1;
2520        }
2521
2522        // Header strip last — placed at top y but emitted last so paint
2523        // z-order draws it above any overscrolled body rows.
2524        if self.header_row_id.is_some()
2525            && let Some(child) = children.get_mut(next)
2526        {
2527            child.origin = Point::new(band_left, bounds.y);
2528            child.size = Size::new(body_width, header_h);
2529        }
2530    }
2531
2532    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
2533        let header_h = self.effective_header_height();
2534        let colors = &ctx.theme.colors;
2535        let scroll_y = self.scroll_y.get();
2536        let body_origin_y = bounds.y + header_h;
2537        let body_height = (bounds.height - header_h).max(0.0);
2538        let widths = self.column_widths.borrow();
2539        let body_width = widths.iter().sum::<f32>();
2540        let body_width_for_paint = if body_width > 0.0 {
2541            body_width.min(bounds.width)
2542        } else {
2543            bounds.width
2544        };
2545        // Physical left edge of the column content (see TableView::paint).
2546        let rtl = ctx.layout_direction == teksilo_core::environment::LayoutDirection::RightToLeft;
2547        let content_left = if rtl {
2548            bounds.x + bounds.width - body_width_for_paint
2549        } else {
2550            bounds.x
2551        };
2552
2553        // Visible row window for the paint passes — offset-table-driven
2554        // so variable heights paint correctly.
2555        let row_count = self.source.visible_count();
2556        let (first_visible, last_visible) =
2557            self.row_metrics
2558                .borrow_mut()
2559                .visible_range(scroll_y, body_height, row_count, 0);
2560
2561        // Clip the root-painted row decorations (alt-row stripes,
2562        // selection bands, grid lines, focus ring) to the body band —
2563        // `clips_children` only clips child widgets, not this widget's
2564        // own paint, which would otherwise bleed past the bottom edge
2565        // for the partially visible last row.
2566        canvas.set_clip(Rect::new(
2567            content_left,
2568            body_origin_y,
2569            body_width_for_paint,
2570            body_height,
2571        ));
2572
2573        if self.alternating_rows {
2574            let mut m = self.row_metrics.borrow_mut();
2575            for row_idx in first_visible..last_visible {
2576                if row_idx % 2 == 1 {
2577                    let y = body_origin_y + m.row_top(row_idx) - scroll_y;
2578                    let h = m.row_height(row_idx);
2579                    let rect = Rect::new(content_left, y, body_width_for_paint, h);
2580                    canvas.fill_rect(rect, SurfaceRole::AltRow.resolve(colors));
2581                }
2582            }
2583        }
2584
2585        if let Some(ref sel) = self.row_selection
2586            && matches!(
2587                self.selection_mode,
2588                TableSelectionMode::SingleRow | TableSelectionMode::MultiRow
2589            )
2590        {
2591            // Focus- and window-aware: vivid while the view holds keyboard
2592            // focus AND the host window is active, muted otherwise (the same
2593            // `SelectedInactive` serves view-unfocused and window-inactive).
2594            let bg = if self.view_focused.get() && ctx.window_active {
2595                SurfaceRole::Selected.resolve(colors)
2596            } else {
2597                SurfaceRole::SelectedInactive.resolve(colors)
2598            };
2599            let mut m = self.row_metrics.borrow_mut();
2600            for row_idx in sel.selected_indices() {
2601                let y = body_origin_y + m.row_top(row_idx) - scroll_y;
2602                let h = m.row_height(row_idx);
2603                if y + h < body_origin_y || y > body_origin_y + body_height {
2604                    continue;
2605                }
2606                let rect = Rect::new(content_left, y, body_width_for_paint, h);
2607                canvas.fill_rect(rect, bg);
2608            }
2609        }
2610
2611        let line_color = BorderRole::Divider.resolve(colors);
2612        let line_w = cp::GRID_LINE_THICKNESS.max(1.0);
2613        if matches!(self.grid_lines, GridLines::Horizontal | GridLines::Both) {
2614            let mut m = self.row_metrics.borrow_mut();
2615            for row_idx in first_visible..last_visible {
2616                let bottom = m.row_top(row_idx) + m.row_height(row_idx);
2617                let y = body_origin_y + bottom - scroll_y - line_w;
2618                let rect = Rect::new(content_left, y, body_width_for_paint, line_w);
2619                canvas.fill_rect(rect, line_color);
2620            }
2621        }
2622
2623        // Pane geometry for the two column-position-dependent decorations
2624        // below — see `TableView::paint`.
2625        let boundaries = *self.pane_boundaries.borrow();
2626        let scroll_x = self.scroll_x.get();
2627        let content_bounds = Rect::new(
2628            content_left,
2629            body_origin_y,
2630            body_width_for_paint,
2631            body_height,
2632        );
2633        let (leading_rect, middle_rect, trailing_rect) =
2634            layout::band_rects(content_bounds, &widths, boundaries, rtl);
2635
2636        if matches!(self.grid_lines, GridLines::Vertical | GridLines::Both) {
2637            let leading_end = boundaries.leading_count.min(widths.len());
2638            let middle_end = boundaries.middle_end.min(widths.len()).max(leading_end);
2639            crate::table_view::draw_pane_dividers(
2640                canvas,
2641                leading_rect,
2642                &widths[..leading_end],
2643                0.0,
2644                rtl,
2645                line_color,
2646                line_w,
2647            );
2648            crate::table_view::draw_pane_dividers(
2649                canvas,
2650                middle_rect,
2651                &widths[leading_end..middle_end],
2652                scroll_x,
2653                rtl,
2654                line_color,
2655                line_w,
2656            );
2657            crate::table_view::draw_pane_dividers(
2658                canvas,
2659                trailing_rect,
2660                &widths[middle_end..],
2661                0.0,
2662                rtl,
2663                line_color,
2664                line_w,
2665            );
2666        }
2667
2668        // Focus ring — keyboard-only (`:focus-visible`) and only while the
2669        // view holds focus, so a mouse click never leaves a ring.
2670        if self.view_focused.get()
2671            && self.focus_visible.get()
2672            && let Some((focus_row, focus_col)) = self.focused_cell.get()
2673            && focus_col < widths.len()
2674            && let Some(x_off) = layout::column_logical_x(
2675                &widths,
2676                boundaries,
2677                scroll_x,
2678                body_width_for_paint,
2679                focus_col,
2680            )
2681        {
2682            let cell_w = widths[focus_col];
2683            let (focus_top, focus_h) = {
2684                let mut m = self.row_metrics.borrow_mut();
2685                (m.row_top(focus_row), m.row_height(focus_row))
2686            };
2687            let y = body_origin_y + focus_top - scroll_y;
2688            if y + focus_h >= body_origin_y && y <= body_origin_y + body_height {
2689                let pane_rect = if focus_col < boundaries.leading_count {
2690                    leading_rect
2691                } else if focus_col >= boundaries.middle_end {
2692                    trailing_rect
2693                } else {
2694                    middle_rect
2695                };
2696                canvas.set_clip(pane_rect);
2697                let inset = cp::FOCUS_RING_INSET;
2698                let stroke = cp::GRID_LINE_THICKNESS.max(1.5);
2699                let ring_color = BorderRole::Focused.resolve(colors);
2700                let rx = if rtl {
2701                    content_left + body_width_for_paint - x_off - cell_w + inset
2702                } else {
2703                    content_left + x_off + inset
2704                };
2705                let ry = y + inset;
2706                let rw = (cell_w - inset * 2.0).max(0.0);
2707                let rh = (focus_h - inset * 2.0).max(0.0);
2708                canvas.fill_rect(Rect::new(rx, ry, rw, stroke), ring_color);
2709                canvas.fill_rect(Rect::new(rx, ry + rh - stroke, rw, stroke), ring_color);
2710                canvas.fill_rect(Rect::new(rx, ry, stroke, rh), ring_color);
2711                canvas.fill_rect(Rect::new(rx + rw - stroke, ry, stroke, rh), ring_color);
2712                canvas.clear_clip();
2713            }
2714        }
2715
2716        // Row-drop insertion indicator (source-accepted positions only — a
2717        // forbidden hover clears the signal). `y` is stored body-local.
2718        //
2719        // Both affordances are indented to the level the dropped row lands at,
2720        // measured from the **tree column's** own leading edge rather than the
2721        // body's: `.tree_column()` and a user column-reorder can move the
2722        // twist/indent gutter off the leading slot, and an indent measured from
2723        // the wrong origin points at nothing. The per-level step is this view's
2724        // `effective_indent()` — the very value its indent gutter renders with
2725        // — not the container recipe's, which describes `StandardTreeItem`.
2726        let drop_indent_origin = |depth: usize| -> f32 {
2727            let step = self.effective_indent();
2728            let tree_decl = self.tree_column_decl_index();
2729            let tree_slot = self
2730                .display_indices
2731                .borrow()
2732                .iter()
2733                .position(|&i| i == tree_decl)
2734                .unwrap_or(0);
2735            let col_x = layout::column_logical_x(
2736                &widths,
2737                boundaries,
2738                scroll_x,
2739                body_width_for_paint,
2740                tree_slot,
2741            )
2742            .unwrap_or(0.0);
2743            (col_x + depth as f32 * step).clamp(0.0, body_width_for_paint)
2744        };
2745        match self.drop_feedback.get() {
2746            Some(DropViz::Line { y, depth, .. }) => {
2747                let recipe = ctx
2748                    .theme
2749                    .style_slots
2750                    .list_container
2751                    .as_ref()
2752                    .map(|s| s.insertion())
2753                    .unwrap_or_default();
2754                let line_color = recipe.role.resolve(colors);
2755                let thickness = recipe.thickness;
2756                let line_y = body_origin_y + y - thickness * 0.5;
2757                let indent = drop_indent_origin(depth);
2758                // RTL mirrors the row, so the indent eats into the *right* edge
2759                // and the line still runs away from the row's leading side.
2760                let x = if rtl {
2761                    content_left
2762                } else {
2763                    content_left + indent
2764                };
2765                canvas.fill_rect(
2766                    Rect::new(x, line_y, body_width_for_paint - indent, thickness),
2767                    line_color,
2768                );
2769            }
2770            // "Drop into this container" — a box round the target row, inset on
2771            // every side so its horizontal edges can never be mistaken for the
2772            // Before / After line. Same affordance `TreeView` paints for an
2773            // `Into` verdict; see `ListDropIntoRecipe`.
2774            Some(DropViz::Rect {
2775                top, height, depth, ..
2776            }) => {
2777                let into = ctx
2778                    .theme
2779                    .style_slots
2780                    .list_container
2781                    .as_ref()
2782                    .map(|s| s.drop_into())
2783                    .unwrap_or_default();
2784                let color = into.role.resolve(colors);
2785                let indent = drop_indent_origin(depth);
2786                let x = if rtl {
2787                    content_left
2788                } else {
2789                    content_left + indent
2790                };
2791                let rect = Rect::new(
2792                    x + into.inset,
2793                    body_origin_y + top + into.inset,
2794                    (body_width_for_paint - indent - into.inset * 2.0).max(0.0),
2795                    (height - into.inset * 2.0).max(0.0),
2796                );
2797                let radius = teksilo_tokens::CornerRadius::uniform(into.corner_radius);
2798                canvas.fill_rounded_rect(rect, radius, color.with_alpha(into.fill_alpha));
2799                canvas.stroke_rounded_rect(rect, radius, color, into.thickness);
2800            }
2801            None => {}
2802        }
2803
2804        canvas.clear_clip();
2805
2806        // Container focus ring — keyboard focus on the view but no current cell
2807        // and no selection, so nothing else marks the focus. Outline the whole
2808        // view (see TableView / TreeView).
2809        let nothing_indicated = self.focused_cell.get().is_none()
2810            && self
2811                .row_selection
2812                .as_ref()
2813                .is_none_or(|s| s.selected_indices().is_empty())
2814            && self.cell_selection.as_ref().is_none_or(|s| s.count() == 0);
2815        if self.view_focused.get() && self.focus_visible.get() && nothing_indicated {
2816            let inset = 1.0_f32;
2817            let rect = Rect::new(
2818                bounds.x + inset,
2819                bounds.y + inset,
2820                (bounds.width - inset * 2.0).max(0.0),
2821                (bounds.height - inset * 2.0).max(0.0),
2822            );
2823            canvas.stroke_rect(rect, BorderRole::Focused.resolve(colors), 1.5);
2824        }
2825
2826        // `OnRelease` column-resize guide — see `TableView::paint`.
2827        if let Some(x) = self.resize_preview_x.get() {
2828            let thickness = cp::GRID_LINE_THICKNESS.max(1.5);
2829            canvas.fill_rect(
2830                Rect::new(x - thickness * 0.5, bounds.y, thickness, bounds.height),
2831                BorderRole::Focused.resolve(colors),
2832            );
2833        }
2834    }
2835
2836    /// The context-menu key opens the *current row's* menu, not the view's.
2837    ///
2838    /// A `TreeTableView` is focusable and its rows deliberately are not — the
2839    /// container owns focus and `set_selected` is what tells assistive
2840    /// technology which row is current. So the dispatcher's default of "the
2841    /// focused widget" would open the view's own menu, in the widget family
2842    /// where a per-row menu matters most.
2843    ///
2844    /// The row the user means is the focused cell's row if they have navigated,
2845    /// else the first selected row. Only realized rows have a widget, so a
2846    /// cursor scrolled outside the virtualization window resolves to nothing
2847    /// and the menu falls back to the view — right, because there is no row on
2848    /// screen for it to be about.
2849    fn context_menu_key_target(&self) -> Option<WidgetId> {
2850        let index = self.focused_cell.get().map(|(row, _col)| row).or_else(|| {
2851            self.row_selection
2852                .as_ref()
2853                .and_then(|s| s.selected_indices().first().copied())
2854        })?;
2855        let map = self.row_map.borrow();
2856        map.iter().find(|(i, _)| *i == index).map(|(_, id)| *id)
2857    }
2858
2859    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2860        builder.set_role(teksilo_core::accesskit::Role::TreeGrid);
2861        // Whether the selection takes more than one row. A real property on
2862        // both platforms that have one: UIA's `SelectionCanSelectMultiple`
2863        // and AT-SPI's multiselectable state. Left unset it reads false, so a
2864        // multi-select view was telling every screen reader that one row was
2865        // the most it would ever hold.
2866        //
2867        // Gated on the mode, and the gate matters beyond tidiness:
2868        // `accesskit_windows` picks the event it raises on a selection change
2869        // from this property (`adapter.rs:189-199`), firing
2870        // `ElementAddedToSelection` when it is true and `ElementSelected` when
2871        // it is false. A single-select view publishing `true` would trade the
2872        // right event for the wrong one.
2873        if self
2874            .row_selection
2875            .as_ref()
2876            .is_some_and(|selection| selection.mode() == teksilo_data::SelectionMode::Multi)
2877        {
2878            builder.set_multiselectable(true);
2879        }
2880
2881        if let Some(ref label) = self.a11y_label {
2882            builder.set_name(label.resolve_now());
2883        }
2884        let row_count = self.source.visible_count() + if self.show_header { 1 } else { 0 };
2885        let col_count = self.columns.len();
2886        let n = builder.inner_mut();
2887        n.set_row_count(row_count);
2888        n.set_column_count(col_count);
2889
2890        // Roving focus: point active_descendant at the focused cell's own
2891        // AT node so a screen reader follows arrow-key cell navigation
2892        // and ArrowLeft/Right expand/collapse. `cell_map` is a snapshot
2893        // of the body pane's last realized cells; a focused cell that
2894        // scrolled (or collapsed) out of the realized buffer simply
2895        // isn't in it, so no stale id is emitted.
2896        if let Some((row, col)) = self.focused_cell.get()
2897            && let Some(cell_id) = self.realized_cell(row, col)
2898        {
2899            builder.set_active_descendant(widget_id_to_node_id(cell_id));
2900        }
2901    }
2902
2903    fn as_any(&self) -> Option<&dyn std::any::Any> {
2904        Some(self)
2905    }
2906
2907    fn children(&self) -> Vec<WidgetId> {
2908        // Same order as `build()` — body pane first, header last so it
2909        // paints on top of any overscrolled rows.
2910        let mut out: Vec<WidgetId> = Vec::new();
2911        if let Some(id) = self.body_pane_id {
2912            out.push(id);
2913        }
2914        if let Some(id) = self.empty_id {
2915            out.push(id);
2916        }
2917        if let Some(id) = self.scrollbar_id {
2918            out.push(id);
2919        }
2920        if let Some(id) = self.h_scrollbar_id {
2921            out.push(id);
2922        }
2923        if let Some(id) = self.header_row_id {
2924            out.push(id);
2925        }
2926        out
2927    }
2928
2929    fn accessibility_children(&self) -> Option<Vec<WidgetId>> {
2930        // WCAG 1.3.2 (audit G17): read the column-header row FIRST, then the
2931        // body, even though `build()` / `children()` list the body first so it
2932        // paints beneath the header. Same id set as `children()`, reordered.
2933        let out: Vec<WidgetId> = [
2934            self.header_row_id,
2935            self.body_pane_id,
2936            self.empty_id,
2937            self.scrollbar_id,
2938            self.h_scrollbar_id,
2939        ]
2940        .into_iter()
2941        .flatten()
2942        .collect();
2943        if out.is_empty() { None } else { Some(out) }
2944    }
2945
2946    fn clips_children(&self) -> bool {
2947        true
2948    }
2949}
2950
2951#[cfg(test)]
2952mod tests {
2953    use super::*;
2954    use crate::table_view::column::{CellContext, ColumnWidth};
2955    use teksilo_canvas::SizeProposal;
2956    use teksilo_core::accesskit::Role;
2957    use teksilo_core::widget_tree::WidgetTree;
2958    use teksilo_data::{SortFilterTreeModel, TreeFilterMode, TreeModel};
2959    use teksilo_i18n::lit;
2960
2961    fn sample_tree() -> TreeModel<&'static str> {
2962        let t = TreeModel::new();
2963        let docs = t.insert_root(0, "docs");
2964        t.insert_child(docs, 0, "readme");
2965        t.insert_child(docs, 1, "guide");
2966        let src = t.insert_root(1, "src");
2967        t.insert_child(src, 0, "main.rs");
2968        t
2969    }
2970
2971    fn name_col() -> Column<&'static str> {
2972        Column::<&str>::new("name", lit!("Name"), |row, _: &CellContext| {
2973            Box::new(crate::primitives::TextWidget::new(lit!(*row)))
2974        })
2975        .width(ColumnWidth::Flex(1.0))
2976    }
2977
2978    fn size_col() -> Column<&'static str> {
2979        Column::<&str>::new("size", lit!("Size"), |_row, _: &CellContext| {
2980            Box::new(crate::primitives::TextWidget::new(lit!("0")))
2981        })
2982        .width(ColumnWidth::Fixed(60.0))
2983    }
2984
2985    #[test]
2986    fn row_selection_click_repaints_immediately_without_expand_collapse() {
2987        // Regression for "row selection in TreeTableView only fires on
2988        // expand/collapse": before the selection_signal was observed,
2989        // calling `sel.select(row)` mutated the model but the rendered
2990        // `BodyRow.selected` flag (computed at build time from
2991        // `sel.is_selected(...)`) was stale until something else
2992        // bumped the version signal — typically a twist toggle.
2993        use teksilo_canvas::Point;
2994        use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
2995        use teksilo_data::{SelectionMode, SelectionModel};
2996        let proxy = SortFilterTreeModel::new(sample_tree());
2997        let selection = SelectionModel::new(SelectionMode::Single);
2998        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
2999        tree.add(
3000            TreeTableView::from_projection(proxy.clone())
3001                .add_column(name_col())
3002                .selection_mode(TableSelectionMode::SingleRow)
3003                .selection(selection.clone())
3004                .row_height(20.0),
3005        );
3006        tree.layout(SizeProposal {
3007            width: Some(400.0),
3008            height: Some(200.0),
3009        });
3010        // Selection starts empty.
3011        assert_eq!(selection.selected_indices().len(), 0);
3012        // Click on the first body row — visible at flat_idx 0
3013        // ("docs"), which sits below the header at y ≈ header + 0.
3014        let header_h = cp::HEADER_HEIGHT;
3015        let click_y = header_h + 10.0;
3016        tree.dispatch_event(WidgetEvent::PointerDown {
3017            position: Point::new(40.0, click_y),
3018            button: PointerButton::Primary,
3019            modifiers: Modifiers::NONE,
3020        });
3021        tree.dispatch_event(WidgetEvent::PointerUp {
3022            position: Point::new(40.0, click_y),
3023            button: PointerButton::Primary,
3024            modifiers: Modifiers::NONE,
3025        });
3026        // Selection updated.
3027        assert_eq!(selection.selected_indices(), vec![0]);
3028        // And — the regression — the rendered tree must reflect the
3029        // new selection without us manually expanding/collapsing.
3030        // We trigger a layout (which renders the selection bg paint
3031        // path) and verify the selection IS still there: i.e., a
3032        // version-signal observer on `selection_signal` would have
3033        // fired and queued a rebuild.
3034        tree.layout(SizeProposal {
3035            width: Some(400.0),
3036            height: Some(200.0),
3037        });
3038        assert_eq!(selection.selected_indices(), vec![0]);
3039    }
3040
3041    #[test]
3042    fn first_arrow_lands_on_an_end_row_instead_of_skipping_it() {
3043        // `TreeTableView` plugs its own hierarchical `RowNavigator` into
3044        // `TableView`'s key handler, so it inherited the same bug: "no cursor
3045        // yet" was read as "cursor on (0, 0)", which made the first ArrowDown
3046        // step to flat row 1 (skipping row 0) and the first ArrowUp a DEAD KEY
3047        // (`prev_row(0)` is `None`). Entry now uses the navigator's own
3048        // first/last visible row, so it is hierarchy-aware.
3049        use teksilo_core::event::{Key, Modifiers};
3050        use teksilo_data::{SelectionMode, SelectionModel};
3051
3052        for (key, want, what) in [
3053            (
3054                Key::ArrowDown,
3055                0usize,
3056                "first ArrowDown enters at the first visible row",
3057            ),
3058            (
3059                Key::ArrowUp,
3060                3usize,
3061                "first ArrowUp enters at the last visible row",
3062            ),
3063        ] {
3064            let t = TreeModel::new();
3065            t.insert_root(0, "a");
3066            t.insert_root(1, "b");
3067            t.insert_root(2, "c");
3068            t.insert_root(3, "d");
3069            let proxy = SortFilterTreeModel::new(t);
3070            let selection = SelectionModel::new(SelectionMode::Single);
3071            let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3072            let id = tree.add(
3073                TreeTableView::from_projection(proxy.clone())
3074                    .add_column(name_col())
3075                    .selection_mode(TableSelectionMode::SingleRow)
3076                    .selection(selection.clone())
3077                    .row_height(20.0),
3078            );
3079            tree.layout(SizeProposal {
3080                width: Some(400.0),
3081                height: Some(200.0),
3082            });
3083            tree.focus(id);
3084            assert_eq!(proxy.visible_count(), 4, "four flat roots");
3085            assert!(
3086                selection.selected_indices().is_empty(),
3087                "precondition: no cursor, nothing selected"
3088            );
3089
3090            tree.press_key(key, Modifiers::NONE);
3091            assert_eq!(selection.selected_indices(), vec![want], "{what}");
3092        }
3093    }
3094
3095    #[test]
3096    fn expanded_children_are_reachable_by_the_first_arrow() {
3097        // Hierarchy-aware entry: with "docs" expanded, the last VISIBLE row is a
3098        // child, not a root — so the first ArrowUp must land on that child. A
3099        // raw `row_count - 1` would happen to agree here, but going through the
3100        // navigator is what keeps it correct for any projection (filtered,
3101        // sorted, partially collapsed).
3102        use teksilo_core::event::{Key, Modifiers};
3103        use teksilo_data::{SelectionMode, SelectionModel};
3104
3105        let proxy = SortFilterTreeModel::new(sample_tree()); // docs{readme,guide}, src{main.rs}
3106        let selection = SelectionModel::new(SelectionMode::Single);
3107        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3108        let id = tree.add(
3109            TreeTableView::from_projection(proxy.clone())
3110                .add_column(name_col())
3111                .selection_mode(TableSelectionMode::SingleRow)
3112                .selection(selection.clone())
3113                .row_height(20.0),
3114        );
3115        tree.layout(SizeProposal {
3116            width: Some(400.0),
3117            height: Some(200.0),
3118        });
3119        tree.focus(id);
3120
3121        let last = proxy.visible_count() - 1;
3122        tree.press_key(Key::ArrowUp, Modifiers::NONE);
3123        assert_eq!(
3124            selection.selected_indices(),
3125            vec![last],
3126            "first ArrowUp enters at the last VISIBLE row, whatever the hierarchy shows"
3127        );
3128    }
3129
3130    #[test]
3131    fn row_click_moves_focus_so_arrow_nav_resumes_there() {
3132        // Regression: in row-selection mode a row click set the selection but
3133        // NOT `focused_cell` (the arrow-nav origin, `unwrap_or((0,0))`), so the
3134        // next Arrow stepped from row 0 rather than the clicked row. Click flat
3135        // row 1 with ≥3 visible rows so the fall-back-to-0 bug is observable
3136        // (buggy: 0 → 1; fixed: 1 → 2).
3137        use teksilo_canvas::Point;
3138        use teksilo_core::event::{Key, Modifiers, PointerButton, WidgetEvent};
3139        use teksilo_data::{SelectionMode, SelectionModel};
3140        let t = TreeModel::new();
3141        t.insert_root(0, "a");
3142        t.insert_root(1, "b");
3143        t.insert_root(2, "c");
3144        t.insert_root(3, "d");
3145        let proxy = SortFilterTreeModel::new(t);
3146        let selection = SelectionModel::new(SelectionMode::Single);
3147        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3148        let id = tree.add(
3149            TreeTableView::from_projection(proxy.clone())
3150                .add_column(name_col())
3151                .selection_mode(TableSelectionMode::SingleRow)
3152                .selection(selection.clone())
3153                .row_height(20.0),
3154        );
3155        tree.layout(SizeProposal {
3156            width: Some(400.0),
3157            height: Some(200.0),
3158        });
3159        tree.focus(id);
3160        assert_eq!(proxy.visible_count(), 4, "four flat roots");
3161
3162        // Click flat row 1 ("b"): 20px rows starting below the header.
3163        let click_y = cp::HEADER_HEIGHT + 1.0 * 20.0 + 10.0;
3164        tree.dispatch_event(WidgetEvent::PointerDown {
3165            position: Point::new(40.0, click_y),
3166            button: PointerButton::Primary,
3167            modifiers: Modifiers::NONE,
3168        });
3169        tree.dispatch_event(WidgetEvent::PointerUp {
3170            position: Point::new(40.0, click_y),
3171            button: PointerButton::Primary,
3172            modifiers: Modifiers::NONE,
3173        });
3174        assert_eq!(
3175            selection.selected_indices(),
3176            vec![1],
3177            "click selects flat row 1"
3178        );
3179
3180        // ArrowDown must resume from the clicked row (1 → 2), not from row 0.
3181        tree.press_key(Key::ArrowDown, Modifiers::NONE);
3182        assert_eq!(
3183            selection.selected_indices(),
3184            vec![2],
3185            "ArrowDown after a click resumes from the clicked row (1 → 2)"
3186        );
3187    }
3188
3189    #[test]
3190    fn role_is_treegrid() {
3191        let proxy = SortFilterTreeModel::new(sample_tree());
3192        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3193        let id = tree.add(
3194            TreeTableView::from_projection(proxy)
3195                .add_column(name_col())
3196                .add_column(size_col())
3197                .row_height(20.0),
3198        );
3199        tree.layout(SizeProposal {
3200            width: Some(400.0),
3201            height: Some(200.0),
3202        });
3203        let info = tree.accessibility_node(id);
3204        assert_eq!(info.role(), Role::TreeGrid);
3205    }
3206
3207    #[test]
3208    fn initial_state_shows_only_roots() {
3209        let proxy = SortFilterTreeModel::new(sample_tree());
3210        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3211        let _id = tree.add(
3212            TreeTableView::from_projection(proxy.clone())
3213                .add_column(name_col())
3214                .row_height(20.0),
3215        );
3216        tree.layout(SizeProposal {
3217            width: Some(400.0),
3218            height: Some(200.0),
3219        });
3220        assert_eq!(proxy.visible_count(), 2); // docs, src
3221    }
3222
3223    #[test]
3224    fn expand_via_widget_reveals_children() {
3225        let proxy = SortFilterTreeModel::new(sample_tree());
3226        let docs = proxy.tree().root(0);
3227        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3228        let id = tree.add(
3229            TreeTableView::from_projection(proxy.clone())
3230                .add_column(name_col())
3231                .row_height(20.0),
3232        );
3233        tree.layout(SizeProposal {
3234            width: Some(400.0),
3235            height: Some(200.0),
3236        });
3237        {
3238            let any = tree.widget_as_any(id).unwrap();
3239            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3240            tt.expand(docs);
3241        }
3242        assert_eq!(proxy.visible_count(), 4); // docs, readme, guide, src
3243    }
3244
3245    #[test]
3246    fn ctrl_home_keeps_the_column_in_a_treegrid() {
3247        use teksilo_core::event::{Key, Modifiers};
3248        // The ARIA grid and treegrid patterns disagree here on purpose. A flat
3249        // cell grid escalates to the corner; a treegrid "moves focus to the
3250        // cell in the first row in the same column as the cell that had
3251        // focus". The shared keyboard module used to send both to the corner.
3252        let proxy = SortFilterTreeModel::new(sample_tree());
3253        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3254        let id = tree.add(
3255            TreeTableView::from_projection(proxy.clone())
3256                .add_column(name_col())
3257                .add_column(size_col())
3258                .row_height(20.0)
3259                .selection_mode(TableSelectionMode::MultiCell)
3260                .cell_selection(CellSelectionModel::new(TableSelectionMode::MultiCell)),
3261        );
3262        tree.layout(SizeProposal {
3263            width: Some(400.0),
3264            height: Some(200.0),
3265        });
3266        tree.focus(id);
3267        let read = |tree: &WidgetTree| {
3268            let any = tree.widget_as_any(id).unwrap();
3269            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3270            tt.focused_cell_signal().get()
3271        };
3272        {
3273            let any = tree.widget_as_any(id).unwrap();
3274            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3275            tt.set_focused_cell(1, 1);
3276        }
3277
3278        tree.press_key(Key::Home, Modifiers::COMMAND);
3279        assert_eq!(read(&tree), Some((0, 1)), "column 1 is kept");
3280        tree.press_key(Key::End, Modifiers::COMMAND);
3281        assert_eq!(read(&tree), Some((1, 1)), "and kept going the other way");
3282    }
3283
3284    #[test]
3285    fn a_row_answers_the_expand_action_it_advertises() {
3286        use teksilo_core::accesskit::Action;
3287        // A row that sets `expanded` already advertises UIA's ExpandCollapse
3288        // pattern — `accesskit_consumer` derives support from the property,
3289        // not from the action list — so Windows sends Expand and Collapse
3290        // whether or not anything answers. Nothing did.
3291        let proxy = SortFilterTreeModel::new(sample_tree());
3292        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3293        let id = tree.add(
3294            TreeTableView::from_projection(proxy.clone())
3295                .add_column(name_col())
3296                .row_height(20.0),
3297        );
3298        tree.layout(SizeProposal {
3299            width: Some(400.0),
3300            height: Some(200.0),
3301        });
3302        assert_eq!(proxy.visible_count(), 2, "roots only");
3303
3304        // Find the first body row (the header shares Role::Row, so pick the
3305        // one that declares an expanded state).
3306        let row = {
3307            let mut found = None;
3308            let mut walker = vec![id];
3309            while let Some(w) = walker.pop() {
3310                // Discriminate on the advertised action itself: the header
3311                // shares `Role::Row` but has no Expand to offer, and a body
3312                // row that failed to advertise one is exactly the bug.
3313                let node = tree.accessibility_node(w);
3314                if node.role() == teksilo_core::accesskit::Role::Row
3315                    && node.actions().contains(&Action::Expand)
3316                {
3317                    found = Some(w);
3318                    break;
3319                }
3320                for c in tree.children(w) {
3321                    walker.push(c);
3322                }
3323            }
3324            found.expect("a body row advertising Action::Expand")
3325        };
3326
3327        let mut ops = teksilo_core::window::NoopWindowOps;
3328        let handled = tree.dispatch_access_action(
3329            teksilo_core::accessibility::widget_id_to_node_id(row),
3330            Action::Expand,
3331            None,
3332            &mut ops,
3333        );
3334        assert!(handled, "the advertised Expand action must be serviced");
3335        // Whichever root the walk reached, it opened: the pattern is no longer
3336        // advertised-and-inert. (The walk is stack-ordered, so which of the
3337        // two roots it finds is not something to pin down here.)
3338        assert!(
3339            proxy.visible_count() > 2,
3340            "the row actually opened, rather than reporting success and doing nothing"
3341        );
3342    }
3343
3344    #[test]
3345    fn a_leaf_row_advertises_no_expand() {
3346        use teksilo_core::accesskit::Action;
3347        // `access_action` advertises as well as handles, so attaching the
3348        // ExpandCollapse pair to every row put the pattern on leaves — which
3349        // declare no `expanded` state and have nothing to open. That is the
3350        // advertised-and-inert bug this whole change set out to remove,
3351        // reappearing one level down.
3352        let proxy = SortFilterTreeModel::new(sample_tree());
3353        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3354        let id = tree.add(
3355            TreeTableView::from_projection(proxy.clone())
3356                .add_column(name_col())
3357                .row_height(20.0),
3358        );
3359        tree.layout(SizeProposal {
3360            width: Some(400.0),
3361            height: Some(200.0),
3362        });
3363        // Open "docs" so its two leaves are realized.
3364        tree.focus(id);
3365        {
3366            let any = tree.widget_as_any(id).unwrap();
3367            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3368            tt.set_focused_cell(0, 0);
3369        }
3370        tree.press_key(
3371            teksilo_core::event::Key::ArrowRight,
3372            teksilo_core::event::Modifiers::NONE,
3373        );
3374        tree.layout(SizeProposal {
3375            width: Some(400.0),
3376            height: Some(200.0),
3377        });
3378        assert_eq!(proxy.visible_count(), 4);
3379
3380        // Four visible rows — docs, readme, guide, src — of which exactly two
3381        // are branches. Only those two may offer the pattern.
3382        let mut rows_with_expand = 0;
3383        let mut walker = vec![id];
3384        while let Some(w) = walker.pop() {
3385            let node = tree.accessibility_node(w);
3386            if node.role() == teksilo_core::accesskit::Role::Row
3387                && node.actions().contains(&Action::Expand)
3388            {
3389                rows_with_expand += 1;
3390            }
3391            for c in tree.children(w) {
3392                walker.push(c);
3393            }
3394        }
3395        assert_eq!(
3396            rows_with_expand, 2,
3397            "only docs and src have children; readme and guide must not \
3398             advertise an Expand they cannot perform"
3399        );
3400    }
3401
3402    #[test]
3403    fn a_tree_table_is_one_tab_stop_and_space_checks_the_focused_cell() {
3404        use crate::table_view::CellContext;
3405        use teksilo_core::event::{Key, Modifiers};
3406
3407        let checked = teksilo_core::signal::Signal::new(false);
3408        let ck = checked.clone();
3409        let proxy = SortFilterTreeModel::new(sample_tree());
3410        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3411        let id = tree.add(
3412            TreeTableView::from_projection(proxy)
3413                .add_column(name_col())
3414                .add_column(Column::<&str>::new(
3415                    "done",
3416                    lit!("Done"),
3417                    move |_r, _: &CellContext| Box::new(crate::Checkbox::new(ck.clone())),
3418                ))
3419                .row_height(20.0),
3420        );
3421        let p = SizeProposal {
3422            width: Some(400.0),
3423            height: Some(200.0),
3424        };
3425        tree.layout(p);
3426        let stops = tree.tab_stops_within(id);
3427        assert_eq!(stops.len(), 1, "one Tab stop; got {}", stops.len());
3428
3429        tree.focus(id);
3430        {
3431            let any = tree.widget_as_any(id).unwrap();
3432            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3433            tt.set_focused_cell(0, 1); // the checkbox column
3434        }
3435        tree.press_key(Key::Space, Modifiers::NONE);
3436        tree.layout(p);
3437        assert!(checked.get(), "Space reaches the focused cell's checkbox");
3438    }
3439
3440    #[test]
3441    fn left_on_a_leaf_ascends_to_the_parent() {
3442        use teksilo_core::event::{Key, Modifiers};
3443        let proxy = SortFilterTreeModel::new(sample_tree());
3444        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3445        let id = tree.add(
3446            TreeTableView::from_projection(proxy.clone())
3447                .add_column(name_col())
3448                .row_height(20.0),
3449        );
3450        tree.layout(SizeProposal {
3451            width: Some(400.0),
3452            height: Some(200.0),
3453        });
3454        tree.focus(id);
3455        let focus_at = |tree: &WidgetTree, row: usize| {
3456            let any = tree.widget_as_any(id).unwrap();
3457            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3458            tt.set_focused_cell(row, 0);
3459        };
3460        let read = |tree: &WidgetTree| {
3461            let any = tree.widget_as_any(id).unwrap();
3462            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3463            tt.focused_cell_signal().get()
3464        };
3465
3466        focus_at(&tree, 0);
3467        tree.press_key(Key::ArrowRight, Modifiers::NONE); // open "docs"
3468        assert_eq!(proxy.visible_count(), 4);
3469
3470        // "readme" is a leaf, so Left ascends rather than collapsing. Left was
3471        // a dead key here before — `TreeView` has ascended since it shipped.
3472        focus_at(&tree, 1);
3473        tree.press_key(Key::ArrowLeft, Modifiers::NONE);
3474        assert_eq!(read(&tree), Some((0, 0)), "back on docs");
3475        assert_eq!(proxy.visible_count(), 4, "and nothing collapsed on the way");
3476
3477        // A second press does collapse, because docs is open.
3478        tree.press_key(Key::ArrowLeft, Modifiers::NONE);
3479        assert_eq!(proxy.visible_count(), 2);
3480    }
3481
3482    #[test]
3483    fn asterisk_expands_the_subtree_of_a_tree_table_row() {
3484        use teksilo_core::event::{Key, Modifiers};
3485        let proxy = SortFilterTreeModel::new(sample_tree());
3486        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3487        let id = tree.add(
3488            TreeTableView::from_projection(proxy.clone())
3489                .add_column(name_col())
3490                .row_height(20.0),
3491        );
3492        tree.layout(SizeProposal {
3493            width: Some(400.0),
3494            height: Some(200.0),
3495        });
3496        tree.focus(id);
3497        {
3498            let any = tree.widget_as_any(id).unwrap();
3499            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3500            tt.set_focused_cell(0, 0);
3501        }
3502        assert_eq!(proxy.visible_count(), 2, "roots only");
3503
3504        tree.press_key(Key::Character('*'), Modifiers::NONE);
3505        assert_eq!(proxy.visible_count(), 4, "docs and its two children");
3506
3507        // `-` folds it back one level.
3508        tree.press_key(Key::Character('-'), Modifiers::NONE);
3509        assert_eq!(proxy.visible_count(), 2);
3510    }
3511
3512    #[test]
3513    fn a_shifted_arrow_does_not_expand_a_tree_table_row() {
3514        use teksilo_core::event::{Key, Modifiers};
3515        let proxy = SortFilterTreeModel::new(sample_tree());
3516        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3517        let id = tree.add(
3518            TreeTableView::from_projection(proxy.clone())
3519                .add_column(name_col())
3520                .row_height(20.0),
3521        );
3522        tree.layout(SizeProposal {
3523            width: Some(400.0),
3524            height: Some(200.0),
3525        });
3526        tree.focus(id);
3527        {
3528            let any = tree.widget_as_any(id).unwrap();
3529            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3530            tt.set_focused_cell(0, 0);
3531        }
3532        tree.press_key(Key::ArrowRight, Modifiers::SHIFT);
3533        assert_eq!(proxy.visible_count(), 2, "Shift belongs to the selection");
3534    }
3535
3536    #[test]
3537    fn arrow_right_expands_and_left_collapses_on_tree_column() {
3538        let proxy = SortFilterTreeModel::new(sample_tree());
3539        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3540        let id = tree.add(
3541            TreeTableView::from_projection(proxy.clone())
3542                .add_column(name_col())
3543                .row_height(20.0),
3544        );
3545        tree.layout(SizeProposal {
3546            width: Some(400.0),
3547            height: Some(200.0),
3548        });
3549        tree.focus(id);
3550        {
3551            let any = tree.widget_as_any(id).unwrap();
3552            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3553            tt.set_focused_cell(0, 0);
3554        }
3555        // ArrowRight on first row (docs, has children, collapsed) →
3556        // expand.
3557        tree.press_key(
3558            teksilo_core::event::Key::ArrowRight,
3559            teksilo_core::event::Modifiers::NONE,
3560        );
3561        assert_eq!(proxy.visible_count(), 4);
3562        // ArrowLeft on first row (now expanded) → collapse.
3563        tree.press_key(
3564            teksilo_core::event::Key::ArrowLeft,
3565            teksilo_core::event::Modifiers::NONE,
3566        );
3567        assert_eq!(proxy.visible_count(), 2);
3568    }
3569
3570    /// Rows for the external-source tests: an indent-ordered stream keyed by a
3571    /// domain id, the shape `TreeDataSlice` derives a hierarchy from.
3572    fn slice_rows() -> Vec<teksilo_data::TreeRow<u64, &'static str>> {
3573        use teksilo_data::TreeRow;
3574        vec![
3575            TreeRow::new(1, "docs", 0),
3576            TreeRow::new(2, "readme", 1),
3577            TreeRow::new(3, "guide", 1),
3578            TreeRow::new(4, "src", 0),
3579        ]
3580    }
3581
3582    fn external_slice() -> teksilo_data::TreeDataSlice<u64, &'static str> {
3583        let slice = teksilo_data::TreeDataSlice::<u64, &'static str>::new();
3584        slice.set_source(slice_rows);
3585        slice.reload();
3586        slice
3587    }
3588
3589    #[test]
3590    fn from_source_renders_an_external_tree_without_a_tree_model() {
3591        // The point of `from_source`: no `TreeModel` mirror anywhere. The slice
3592        // owns identity (`u64`), derives the hierarchy from row depths, and the
3593        // table reads it through the erased `TreeDataSource`.
3594        let slice = external_slice();
3595        slice.expand(&1);
3596        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3597        let id = tree.add(
3598            TreeTableView::from_source(slice.clone())
3599                .add_column(name_col())
3600                .row_height(20.0),
3601        );
3602        tree.layout(SizeProposal {
3603            width: Some(400.0),
3604            height: Some(200.0),
3605        });
3606        assert_eq!(slice.visible_count(), 4, "docs + 2 children + src");
3607
3608        let any = tree.widget_as_any(id).unwrap();
3609        let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3610        assert!(
3611            tt.projection().is_none(),
3612            "a source-backed view has no TreeModel projection to expose"
3613        );
3614        assert!(tt.body_pane_id.is_some(), "rows rendered from the source");
3615    }
3616
3617    #[test]
3618    fn from_source_keyed_selection_survives_a_full_resource() {
3619        // The property a `TreeModel` mirror cannot offer: `NodeId`s are
3620        // reassigned on rebuild, but a domain key is not — so a keyed selection
3621        // still points at the same row after the source is re-materialised.
3622        let slice = external_slice();
3623        slice.expand(&1);
3624        let keyed = KeyedSelectionModel::<u64>::new(teksilo_data::SelectionMode::Multi);
3625        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3626        let _id = tree.add(
3627            TreeTableView::from_source_keyed(slice.clone(), keyed.clone())
3628                .add_column(name_col())
3629                .row_height(20.0),
3630        );
3631        tree.layout(SizeProposal {
3632            width: Some(400.0),
3633            height: Some(200.0),
3634        });
3635
3636        keyed.select(3); // "guide"
3637        assert!(keyed.is_selected(&3));
3638
3639        // Re-source from scratch — every row is rebuilt.
3640        slice.reload();
3641        assert!(
3642            keyed.is_selected(&3),
3643            "a domain-keyed selection must survive a re-source"
3644        );
3645    }
3646
3647    #[test]
3648    fn from_source_supports_drag_reorder_like_the_tree_view() {
3649        // Parity check: a source-backed table reorders through the source's own
3650        // `accept_drop`, the same path `TreeView` uses — no `TreeModel`, no
3651        // `NodeId` anywhere. Here the slice commits the move into its own store.
3652        use std::cell::RefCell;
3653        use std::rc::Rc;
3654        use teksilo_canvas::Point;
3655
3656        // The store the slice re-sources from; the reorder mutates it.
3657        let order: Rc<RefCell<Vec<u64>>> = Rc::new(RefCell::new(vec![1, 4]));
3658        let slice = teksilo_data::TreeDataSlice::<u64, &'static str>::new();
3659        {
3660            let order = order.clone();
3661            slice.set_source(move || {
3662                let names: std::collections::HashMap<u64, &'static str> =
3663                    [(1, "docs"), (4, "src")].into_iter().collect();
3664                order
3665                    .borrow()
3666                    .iter()
3667                    .map(|k| teksilo_data::TreeRow::new(*k, names[k], 0))
3668                    .collect()
3669            });
3670        }
3671        {
3672            let order = order.clone();
3673            // Domain policy: apply the move to the backing store.
3674            slice.set_reorder(move |dragged, target, _pos| {
3675                let mut o = order.borrow_mut();
3676                let Some(from) = o.iter().position(|k| *k == dragged) else {
3677                    return false;
3678                };
3679                let item = o.remove(from);
3680                let to = o
3681                    .iter()
3682                    .position(|k| *k == target)
3683                    .map_or(o.len(), |i| i + 1);
3684                o.insert(to, item);
3685                true
3686            });
3687        }
3688        // An external source must opt into dragging: `TreeDataSlice::drag`
3689        // defaults to `NoDrag` (pinned by its own `drag_default_is_nodrag`).
3690        slice.set_drag_policy(|_| teksilo_data::DragEligibility::CanDrag);
3691        slice.reload();
3692        assert_eq!(*order.borrow(), vec![1, 4], "docs, src");
3693
3694        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3695        tree.add(
3696            TreeTableView::from_source(slice.clone())
3697                .add_column(name_col())
3698                .reorderable(true)
3699                .row_height(20.0),
3700        );
3701        tree.layout(SizeProposal {
3702            width: Some(400.0),
3703            height: Some(300.0),
3704        });
3705
3706        // Drag docs (flat 0) onto the bottom third of src (flat 1) → After src.
3707        let h = cp::HEADER_HEIGHT;
3708        drag(
3709            &mut tree,
3710            Point::new(40.0, h + 10.0),
3711            Point::new(40.0, h + 38.0),
3712        );
3713        assert_eq!(
3714            *order.borrow(),
3715            vec![4, 1],
3716            "the source applied the reorder: src now precedes docs"
3717        );
3718    }
3719
3720    #[test]
3721    fn a_source_that_forbids_dragging_a_row_is_honored() {
3722        // The source owns drag eligibility. A view that ignored it would happily
3723        // move a row the store considers locked.
3724        use std::cell::RefCell;
3725        use std::rc::Rc;
3726        use teksilo_canvas::Point;
3727
3728        let order: Rc<RefCell<Vec<u64>>> = Rc::new(RefCell::new(vec![1, 4]));
3729        let slice = teksilo_data::TreeDataSlice::<u64, &'static str>::new();
3730        {
3731            let order = order.clone();
3732            slice.set_source(move || {
3733                let names: std::collections::HashMap<u64, &'static str> =
3734                    [(1, "docs"), (4, "src")].into_iter().collect();
3735                order
3736                    .borrow()
3737                    .iter()
3738                    .map(|k| teksilo_data::TreeRow::new(*k, names[k], 0))
3739                    .collect()
3740            });
3741        }
3742        {
3743            let order = order.clone();
3744            slice.set_reorder(move |dragged, target, _pos| {
3745                let mut o = order.borrow_mut();
3746                let Some(from) = o.iter().position(|k| *k == dragged) else {
3747                    return false;
3748                };
3749                let item = o.remove(from);
3750                let to = o
3751                    .iter()
3752                    .position(|k| *k == target)
3753                    .map_or(o.len(), |i| i + 1);
3754                o.insert(to, item);
3755                true
3756            });
3757        }
3758        // Row 1 ("docs") is pinned in place by the store.
3759        slice.set_drag_policy(|k| {
3760            if *k == 1 {
3761                teksilo_data::DragEligibility::NoDrag
3762            } else {
3763                teksilo_data::DragEligibility::CanDrag
3764            }
3765        });
3766        slice.reload();
3767
3768        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3769        tree.add(
3770            TreeTableView::from_source(slice.clone())
3771                .add_column(name_col())
3772                .reorderable(true)
3773                .row_height(20.0),
3774        );
3775        tree.layout(SizeProposal {
3776            width: Some(400.0),
3777            height: Some(300.0),
3778        });
3779
3780        let h = cp::HEADER_HEIGHT;
3781        drag(
3782            &mut tree,
3783            Point::new(40.0, h + 10.0),
3784            Point::new(40.0, h + 38.0),
3785        );
3786        assert_eq!(
3787            *order.borrow(),
3788            vec![1, 4],
3789            "a NoDrag row must not move, even onto a valid target"
3790        );
3791    }
3792
3793    #[test]
3794    fn drop_on_the_middle_third_reparents_into_the_target() {
3795        // The Into zone: dropping on a row's middle third makes the dragged node
3796        // that row's child, rather than a sibling before/after it.
3797        use teksilo_canvas::Point;
3798        let proxy = SortFilterTreeModel::new(sample_tree());
3799        proxy.collapse_all(); // roots only: docs@0, src@1
3800        let docs = proxy.tree().root(0);
3801        let src = proxy.tree().root(1);
3802        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3803        tree.add(
3804            TreeTableView::from_projection(proxy.clone())
3805                .add_column(name_col())
3806                .reorderable(true)
3807                .row_height(20.0),
3808        );
3809        tree.layout(SizeProposal {
3810            width: Some(400.0),
3811            height: Some(300.0),
3812        });
3813        let h = cp::HEADER_HEIGHT;
3814        // Drag docs (flat 0) onto the MIDDLE third of src (flat 1, [h+20, h+40])
3815        // → Into src.
3816        drag(
3817            &mut tree,
3818            Point::new(40.0, h + 10.0),
3819            Point::new(40.0, h + 30.0),
3820        );
3821        assert_eq!(proxy.tree().root_count(), 1, "docs is no longer a root");
3822        assert_eq!(
3823            proxy.tree().parent(docs),
3824            Some(src),
3825            "docs became a child of src"
3826        );
3827    }
3828
3829    #[test]
3830    fn the_into_box_is_inset_and_the_insertion_line_is_indented() {
3831        // The twin of `TreeView`'s pair: the two drop affordances must not read
3832        // alike. Flush to the row, the Into box's top edge is the very pixel a
3833        // Before line occupies — and the drag ghost hides the vertical sides
3834        // that would have told them apart.
3835        use teksilo_canvas::{DrawCommand, Point, ShapeKind};
3836        use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
3837
3838        let proxy = SortFilterTreeModel::new(sample_tree());
3839        proxy.expand_all(); // docs@0 readme@1 guide@2 src@3 main.rs@4
3840        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3841        tree.add(
3842            TreeTableView::from_projection(proxy.clone())
3843                .add_column(name_col())
3844                .reorderable(true)
3845                .row_height(20.0),
3846        );
3847        tree.layout(SizeProposal {
3848            width: Some(400.0),
3849            height: Some(300.0),
3850        });
3851        let h = cp::HEADER_HEIGHT;
3852
3853        // Hold a drag from "main.rs" (flat 4) — nothing is inside its subtree,
3854        // so every target below accepts.
3855        let start = Point::new(40.0, h + 90.0);
3856        tree.dispatch_event(WidgetEvent::PointerDown {
3857            position: start,
3858            button: PointerButton::Primary,
3859            modifiers: Modifiers::NONE,
3860        });
3861        tree.dispatch_event(WidgetEvent::PointerMove {
3862            position: Point::new(52.0, start.y),
3863        });
3864
3865        // Bottom third of "readme" (flat 1, depth 1) → After, at depth 1.
3866        tree.dispatch_event(WidgetEvent::PointerMove {
3867            position: Point::new(52.0, h + 38.0),
3868        });
3869        let frame = tree.render();
3870        let line_recipe = teksilo_core::styles::ListInsertionRecipe::default();
3871        // The insertion line is the only decoration exactly `thickness` tall
3872        // that spans the body — identify it by that, not by "something at x>0",
3873        // which any future row stripe would satisfy vacuously.
3874        let lines: Vec<_> = frame
3875            .decorations
3876            .iter()
3877            .filter(|d| (d.rect[3] - line_recipe.thickness).abs() < 0.01 && d.rect[2] > 100.0)
3878            .collect();
3879        assert_eq!(lines.len(), 1, "exactly one insertion line, got {lines:?}");
3880        assert!(
3881            lines[0].rect[0] >= line_recipe.indent_step,
3882            "the After line must start one indent step in for a depth-1 target, \
3883             got x = {} (step {})",
3884            lines[0].rect[0],
3885            line_recipe.indent_step
3886        );
3887
3888        // Middle third of "docs" (flat 0, depth 0) → Into, a box round the row.
3889        tree.dispatch_event(WidgetEvent::PointerMove {
3890            position: Point::new(52.0, h + 10.0),
3891        });
3892        let frame = tree.render();
3893        let recipe = teksilo_core::styles::ListDropIntoRecipe::default();
3894        let boxes: Vec<_> = frame
3895            .draw_order
3896            .iter()
3897            .filter_map(|c| match c {
3898                DrawCommand::Shape(i) => frame.shapes.get(*i),
3899                _ => None,
3900            })
3901            .filter(|s| s.shape == ShapeKind::RoundedRect && s.corner_radii[0] > 0.0)
3902            .filter(|s| (s.screen[3] - (20.0 - recipe.inset * 2.0)).abs() < 0.01)
3903            .collect();
3904        assert!(
3905            !boxes.is_empty(),
3906            "no inset rounded box for the Into hover; shapes = {:?}",
3907            frame.shapes.iter().map(|s| s.screen).collect::<Vec<_>>()
3908        );
3909        // Row 0 spans [h, h + 20]. The box's top edge must sit *inside* that
3910        // band — on the boundary it is pixel-identical to a Before line.
3911        assert!(
3912            boxes
3913                .iter()
3914                .all(|s| (s.screen[1] - (h + recipe.inset)).abs() < 0.01),
3915            "the Into box must be inset from the row's top edge ({}), got {:?}",
3916            h,
3917            boxes.iter().map(|s| s.screen).collect::<Vec<_>>()
3918        );
3919        assert!(
3920            boxes.iter().any(|s| s.stroke_width > 0.0)
3921                && boxes.iter().any(|s| s.stroke_width == 0.0),
3922            "the Into box needs both a wash and an outline"
3923        );
3924    }
3925
3926    #[test]
3927    fn an_active_sort_suppresses_drag_reorder() {
3928        // With the visible order driven by a sort, a manual reorder would have no
3929        // visible effect — so it must be refused outright rather than silently
3930        // mutating the tree behind the sort.
3931        use teksilo_canvas::Point;
3932        let proxy = SortFilterTreeModel::new(sample_tree())
3933            .with_comparator("name", |a: &&'static str, b: &&'static str| a.cmp(b));
3934        proxy.collapse_all();
3935        let docs = proxy.tree().root(0);
3936        let src = proxy.tree().root(1);
3937        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3938        let id = tree.add(
3939            TreeTableView::from_projection(proxy.clone())
3940                .add_column(name_col())
3941                .reorderable(true)
3942                .row_height(20.0),
3943        );
3944        tree.layout(SizeProposal {
3945            width: Some(400.0),
3946            height: Some(300.0),
3947        });
3948        {
3949            let any = tree.widget_as_any(id).unwrap();
3950            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
3951            tt.set_sort(Some("name"), SortDirection::Ascending);
3952        }
3953        tree.layout(SizeProposal {
3954            width: Some(400.0),
3955            height: Some(300.0),
3956        });
3957
3958        let h = cp::HEADER_HEIGHT;
3959        drag(
3960            &mut tree,
3961            Point::new(40.0, h + 10.0),
3962            Point::new(40.0, h + 38.0),
3963        );
3964        assert_eq!(
3965            proxy.tree().root(0),
3966            docs,
3967            "structure unchanged while sorted"
3968        );
3969        assert_eq!(
3970            proxy.tree().root(1),
3971            src,
3972            "structure unchanged while sorted"
3973        );
3974    }
3975
3976    #[test]
3977    fn an_open_cell_editor_follows_its_row_and_closes_if_the_row_vanishes() {
3978        // `editing_cell` is a (row, col) pair that outlives rebuilds. Without
3979        // reconciliation, filtering a row away above an open editor slides the
3980        // editor onto a different row and silently edits the wrong item.
3981        let slice = teksilo_data::TreeDataSlice::<u64, &'static str>::new();
3982        let all: Vec<u64> = vec![1, 2, 3];
3983        slice.set_source(move || {
3984            let names: std::collections::HashMap<u64, &'static str> =
3985                [(1, "one"), (2, "two"), (3, "three")].into_iter().collect();
3986            all.iter()
3987                .map(|k| teksilo_data::TreeRow::new(*k, names[k], 0))
3988                .collect()
3989        });
3990        slice.reload();
3991
3992        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
3993        let id = tree.add(
3994            TreeTableView::from_source(slice.clone())
3995                .add_column(name_col())
3996                .row_height(20.0),
3997        );
3998        let proposal = SizeProposal {
3999            width: Some(400.0),
4000            height: Some(200.0),
4001        };
4002        tree.layout(proposal);
4003
4004        // Edit row 2 ("three" sits at index 2).
4005        {
4006            let any = tree.widget_as_any(id).unwrap();
4007            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4008            tt.begin_edit(2, "name");
4009            assert_eq!(tt.editing_cell_signal().get(), Some((2, 0)));
4010        }
4011        tree.layout(proposal); // captures the anchor
4012
4013        // Drop the FIRST row: "three" is now at index 1.
4014        let fewer: Vec<u64> = vec![2, 3];
4015        slice.set_source(move || {
4016            let names: std::collections::HashMap<u64, &'static str> =
4017                [(2, "two"), (3, "three")].into_iter().collect();
4018            fewer
4019                .iter()
4020                .map(|k| teksilo_data::TreeRow::new(*k, names[k], 0))
4021                .collect()
4022        });
4023        slice.reload();
4024        tree.layout(proposal);
4025        {
4026            let any = tree.widget_as_any(id).unwrap();
4027            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4028            assert_eq!(
4029                tt.editing_cell_signal().get(),
4030                Some((1, 0)),
4031                "the editor must follow its row to index 1, not stay on index 2"
4032            );
4033        }
4034
4035        // Now delete the edited row itself: the editor must close, not move.
4036        let last: Vec<u64> = vec![2];
4037        slice.set_source(move || {
4038            last.iter()
4039                .map(|k| teksilo_data::TreeRow::new(*k, "two", 0))
4040                .collect()
4041        });
4042        slice.reload();
4043        tree.layout(proposal);
4044        let any = tree.widget_as_any(id).unwrap();
4045        let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4046        assert_eq!(
4047            tt.editing_cell_signal().get(),
4048            None,
4049            "the editor must close when its row is gone"
4050        );
4051    }
4052
4053    #[test]
4054    fn default_selection_mode_is_multi_row() {
4055        // The doc claimed `RowSingle` — a variant that does not exist. Pin the
4056        // real default behaviorally so prose can't drift from it again:
4057        // Shift+ArrowDown twice extends to 3 rows, which only MultiRow allows.
4058        use teksilo_core::event::{Key, Modifiers};
4059        let proxy = SortFilterTreeModel::new(wide_tree(10));
4060        let selection = teksilo_data::SelectionModel::new(teksilo_data::SelectionMode::Multi);
4061        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4062        let id = tree.add(
4063            TreeTableView::from_projection(proxy)
4064                .add_column(name_col())
4065                .selection(selection.clone())
4066                .row_height(20.0),
4067        );
4068        tree.layout(SizeProposal {
4069            width: Some(400.0),
4070            height: Some(200.0),
4071        });
4072        tree.focus(id);
4073        {
4074            let any = tree.widget_as_any(id).unwrap();
4075            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4076            tt.set_focused_cell(0, 0);
4077        }
4078        selection.select(0);
4079        tree.press_key(Key::ArrowDown, Modifiers::SHIFT);
4080        tree.press_key(Key::ArrowDown, Modifiers::SHIFT);
4081        assert_eq!(
4082            selection.selection_signal().get().len(),
4083            3,
4084            "default mode must extend a multi-row selection"
4085        );
4086    }
4087
4088    #[test]
4089    fn ctrl_arrow_moves_cursor_without_touching_selection() {
4090        // Explorer/Finder convention (shared with `TableView` via the
4091        // common `keyboard::build_key_handler`): Ctrl+Arrow repositions the
4092        // keyboard cursor without touching selection; plain Arrow keeps its
4093        // existing select-follow behavior.
4094        use teksilo_core::event::{Key, Modifiers};
4095        let proxy = SortFilterTreeModel::new(wide_tree(5));
4096        let selection = teksilo_data::SelectionModel::new(teksilo_data::SelectionMode::Multi);
4097        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4098        let id = tree.add(
4099            TreeTableView::from_projection(proxy)
4100                .add_column(name_col())
4101                .selection(selection.clone())
4102                .row_height(20.0),
4103        );
4104        tree.layout(SizeProposal {
4105            width: Some(400.0),
4106            height: Some(200.0),
4107        });
4108        tree.focus(id);
4109        {
4110            let any = tree.widget_as_any(id).unwrap();
4111            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4112            tt.set_focused_cell(0, 0);
4113        }
4114        selection.select(0);
4115
4116        tree.press_key(Key::ArrowDown, Modifiers::CTRL);
4117        {
4118            let any = tree.widget_as_any(id).unwrap();
4119            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4120            assert_eq!(
4121                tt.focused_cell_signal().get(),
4122                Some((1, 0)),
4123                "cursor advances"
4124            );
4125        }
4126        assert_eq!(
4127            selection.selected_indices(),
4128            vec![0],
4129            "Ctrl+Arrow must not touch selection"
4130        );
4131
4132        // Plain Arrow (no Ctrl) resumes select-follow from the cursor.
4133        tree.press_key(Key::ArrowDown, Modifiers::NONE);
4134        {
4135            let any = tree.widget_as_any(id).unwrap();
4136            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4137            assert_eq!(tt.focused_cell_signal().get(), Some((2, 0)));
4138        }
4139        assert_eq!(
4140            selection.selected_indices(),
4141            vec![2],
4142            "plain Arrow selects the row it lands on"
4143        );
4144    }
4145
4146    #[test]
4147    fn ctrl_space_toggles_the_cursor_row_after_a_ctrl_arrow_move() {
4148        use teksilo_core::event::{Key, Modifiers};
4149        let proxy = SortFilterTreeModel::new(wide_tree(5));
4150        let selection = teksilo_data::SelectionModel::new(teksilo_data::SelectionMode::Multi);
4151        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4152        let id = tree.add(
4153            TreeTableView::from_projection(proxy)
4154                .add_column(name_col())
4155                .selection(selection.clone())
4156                .row_height(20.0),
4157        );
4158        tree.layout(SizeProposal {
4159            width: Some(400.0),
4160            height: Some(200.0),
4161        });
4162        tree.focus(id);
4163        {
4164            let any = tree.widget_as_any(id).unwrap();
4165            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4166            tt.set_focused_cell(0, 0);
4167        }
4168        tree.press_key(Key::ArrowDown, Modifiers::CTRL);
4169        tree.press_key(Key::ArrowDown, Modifiers::CTRL);
4170        assert!(selection.selected_indices().is_empty());
4171
4172        tree.press_key(Key::Space, Modifiers::CTRL);
4173        assert_eq!(
4174            selection.selected_indices(),
4175            vec![2],
4176            "Ctrl+Space toggles the focused row on"
4177        );
4178
4179        tree.press_key(Key::Space, Modifiers::CTRL);
4180        assert!(
4181            selection.selected_indices().is_empty(),
4182            "Ctrl+Space toggles it back off"
4183        );
4184    }
4185
4186    #[test]
4187    fn empty_view_renders_when_the_tree_has_no_rows() {
4188        let proxy = SortFilterTreeModel::new(TreeModel::<&'static str>::new());
4189        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4190        let id = tree.add(
4191            TreeTableView::from_projection(proxy)
4192                .add_column(name_col())
4193                .empty_view(|| Box::new(crate::primitives::TextWidget::new(lit!("Nothing here"))))
4194                .row_height(20.0),
4195        );
4196        tree.layout(SizeProposal {
4197            width: Some(400.0),
4198            height: Some(200.0),
4199        });
4200        let any = tree.widget_as_any(id).unwrap();
4201        let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4202        assert!(tt.empty_id.is_some(), "placeholder should be built");
4203        assert!(tt.body_pane_id.is_none(), "no body pane for zero rows");
4204    }
4205
4206    #[test]
4207    fn empty_view_appears_when_live_rows_drop_to_zero() {
4208        // The transition case: rows exist, the widget is live, then a filter
4209        // removes them all. The body pane must be torn down and the
4210        // placeholder built — constructing already-empty (the two tests below)
4211        // never exercises that path.
4212        let proxy = SortFilterTreeModel::new(sample_tree()).with_predicate("name", |t| {
4213            let needle = t.to_string();
4214            Box::new(move |r: &&'static str| r.contains(&needle))
4215        });
4216        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4217        let id = tree.add(
4218            TreeTableView::from_projection(proxy.clone())
4219                .add_column(name_col())
4220                .empty_view(|| Box::new(crate::primitives::TextWidget::new(lit!("No matches"))))
4221                .row_height(20.0),
4222        );
4223        let proposal = SizeProposal {
4224            width: Some(400.0),
4225            height: Some(200.0),
4226        };
4227        tree.layout(proposal);
4228        {
4229            let any = tree.widget_as_any(id).unwrap();
4230            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4231            assert!(tt.body_pane_id.is_some(), "starts with a body pane");
4232            assert!(tt.empty_id.is_none(), "no placeholder while rows exist");
4233        }
4234
4235        proxy.set_filter("name", "zzz-no-such-row");
4236        tree.layout(proposal);
4237        assert_eq!(proxy.visible_count(), 0);
4238        let any = tree.widget_as_any(id).unwrap();
4239        let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4240        assert!(
4241            tt.empty_id.is_some(),
4242            "placeholder must appear once rows drop to zero"
4243        );
4244        assert!(tt.body_pane_id.is_none(), "stale body pane must be gone");
4245    }
4246
4247    #[test]
4248    fn empty_view_renders_when_a_filter_matches_nothing() {
4249        // The other half of the empty state: rows exist, but none survive the
4250        // filter. Without this the user sees a blank pane and no explanation.
4251        let proxy = SortFilterTreeModel::new(sample_tree()).with_predicate("name", |t| {
4252            let needle = t.to_string();
4253            Box::new(move |r: &&'static str| r.contains(&needle))
4254        });
4255        proxy.set_filter("name", "zzz-no-such-row");
4256        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4257        let id = tree.add(
4258            TreeTableView::from_projection(proxy.clone())
4259                .add_column(name_col())
4260                .empty_view(|| Box::new(crate::primitives::TextWidget::new(lit!("No matches"))))
4261                .row_height(20.0),
4262        );
4263        tree.layout(SizeProposal {
4264            width: Some(400.0),
4265            height: Some(200.0),
4266        });
4267        assert_eq!(proxy.visible_count(), 0);
4268        let any = tree.widget_as_any(id).unwrap();
4269        let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4270        assert!(tt.empty_id.is_some());
4271    }
4272
4273    #[test]
4274    fn scroll_to_row_and_ensure_row_visible_move_the_offset() {
4275        let proxy = SortFilterTreeModel::new(wide_tree(100));
4276        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4277        let id = tree.add(
4278            TreeTableView::from_projection(proxy)
4279                .add_column(name_col())
4280                .row_height(20.0),
4281        );
4282        tree.layout(SizeProposal {
4283            width: Some(400.0),
4284            height: Some(200.0),
4285        });
4286        let any = tree.widget_as_any(id).unwrap();
4287        let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4288
4289        // Aligns the row to the top: row 50 × 20 px.
4290        tt.scroll_to_row(50);
4291        assert!((tt.scroll_y_signal().get() - 1000.0).abs() < 1.0);
4292
4293        // Already-visible row: minimum scroll means no movement.
4294        let before = tt.scroll_y_signal().get();
4295        tt.ensure_row_visible(51);
4296        assert!((tt.scroll_y_signal().get() - before).abs() < f32::EPSILON);
4297
4298        // Off-screen upward: scrolls back just far enough.
4299        tt.ensure_row_visible(10);
4300        assert!((tt.scroll_y_signal().get() - 200.0).abs() < 1.0);
4301    }
4302
4303    #[test]
4304    fn begin_edit_resolves_a_column_id_and_end_edit_clears() {
4305        let proxy = SortFilterTreeModel::new(sample_tree());
4306        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4307        let id = tree.add(
4308            TreeTableView::from_projection(proxy)
4309                .add_column(name_col())
4310                .add_column(size_col())
4311                .row_height(20.0),
4312        );
4313        tree.layout(SizeProposal {
4314            width: Some(400.0),
4315            height: Some(200.0),
4316        });
4317        let any = tree.widget_as_any(id).unwrap();
4318        let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4319
4320        tt.begin_edit(1, "size");
4321        assert_eq!(tt.editing_cell_signal().get(), Some((1, 1)));
4322        tt.end_edit();
4323        assert_eq!(tt.editing_cell_signal().get(), None);
4324
4325        // Unknown id is a silent no-op, not a panic or a bogus position.
4326        tt.begin_edit(0, "no-such-column");
4327        assert_eq!(tt.editing_cell_signal().get(), None);
4328
4329        // An out-of-range row is refused too: without the bounds check this
4330        // stranded `editing_cell` on a row nothing could ever match, and only
4331        // an explicit `end_edit` would clear it.
4332        tt.begin_edit(9999, "name");
4333        assert_eq!(tt.editing_cell_signal().get(), None);
4334
4335        // ...and a refused call must not clobber a live editor.
4336        tt.begin_edit(1, "size");
4337        tt.begin_edit(9999, "size");
4338        assert_eq!(tt.editing_cell_signal().get(), Some((1, 1)));
4339    }
4340
4341    #[test]
4342    fn begin_edit_resolves_before_the_view_is_mounted() {
4343        // Seeding a freshly constructed view with an edit target it already
4344        // holds is only possible on the builder — a rebuild makes a brand-new
4345        // view whose `editing_cell` starts `None`, and there is no post-mount
4346        // handle (`as_any_mut` is not overridden). `display_indices` is filled
4347        // by `build()`, so before the fix this resolved against an empty cache
4348        // and silently did nothing: the caller's edit request vanished.
4349        //
4350        // `size` is pinned Leading, so display order is [size, name] and the
4351        // correct answer for "name" is 1, not its declaration index 0 — which
4352        // is what makes this a test of `display_order()` and not of a shortcut
4353        // that happens to agree when nothing is pinned.
4354        let proxy = SortFilterTreeModel::new(sample_tree());
4355        let view = TreeTableView::from_projection(proxy)
4356            .add_column(name_col())
4357            .add_column(size_col().pinned(PinnedSide::Leading))
4358            .row_height(20.0);
4359
4360        view.begin_edit(1, "name");
4361        assert_eq!(view.editing_cell_signal().get(), Some((1, 1)));
4362
4363        // The documented no-ops still hold with no cache to consult.
4364        view.end_edit();
4365        view.begin_edit(0, "no-such-column");
4366        assert_eq!(view.editing_cell_signal().get(), None);
4367        view.begin_edit(9999, "name");
4368        assert_eq!(view.editing_cell_signal().get(), None);
4369
4370        // And the seed survives mounting: the target it resolved is the one
4371        // the body pane reads back.
4372        view.begin_edit(1, "name");
4373        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4374        let id = tree.add(view);
4375        tree.layout(SizeProposal {
4376            width: Some(400.0),
4377            height: Some(200.0),
4378        });
4379        let tt = tree
4380            .widget_as_any(id)
4381            .unwrap()
4382            .downcast_ref::<TreeTableView<&'static str>>()
4383            .unwrap();
4384        assert_eq!(tt.editing_cell_signal().get(), Some((1, 1)));
4385    }
4386
4387    #[test]
4388    fn column_imperatives_write_their_signals() {
4389        let proxy = SortFilterTreeModel::new(sample_tree());
4390        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4391        let id = tree.add(
4392            TreeTableView::from_projection(proxy)
4393                .add_column(name_col())
4394                .add_column(size_col())
4395                .row_height(20.0),
4396        );
4397        tree.layout(SizeProposal {
4398            width: Some(400.0),
4399            height: Some(200.0),
4400        });
4401        let any = tree.widget_as_any(id).unwrap();
4402        let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4403
4404        tt.set_column_width("name", 123.0);
4405        assert_eq!(tt.column_widths_signal().get().get("name"), Some(&123.0));
4406        // A non-positive width removes the override rather than pinning 0 px.
4407        tt.set_column_width("name", 0.0);
4408        assert!(!tt.column_widths_signal().get().contains_key("name"));
4409
4410        // Order and pinning must actually reach `display_order()`, not just sit
4411        // in a signal nothing reads. Columns are declared name(0), size(1).
4412        assert_eq!(
4413            tt.display_order(),
4414            vec![0, 1],
4415            "declaration order initially"
4416        );
4417
4418        tt.set_column_order(vec!["size".into(), "name".into()]);
4419        assert_eq!(tt.column_order_signal().get(), vec!["size", "name"]);
4420        assert_eq!(
4421            tt.display_order(),
4422            vec![1, 0],
4423            "set_column_order must reorder the display, not only the signal"
4424        );
4425
4426        // Pinning outranks the order list: a Leading-pinned column sorts into
4427        // the leading band regardless of where the order puts it.
4428        tt.set_column_pinning("name", PinnedSide::Leading);
4429        assert_eq!(
4430            tt.column_pinning_signal().get().get("name"),
4431            Some(&PinnedSide::Leading)
4432        );
4433        assert_eq!(
4434            tt.display_order(),
4435            vec![0, 1],
4436            "set_column_pinning must pull the pinned column back to the front"
4437        );
4438        tt.set_column_pinning("name", PinnedSide::None);
4439        assert!(!tt.column_pinning_signal().get().contains_key("name"));
4440        assert_eq!(
4441            tt.display_order(),
4442            vec![1, 0],
4443            "clearing the pin restores the order list's arrangement"
4444        );
4445
4446        tt.set_sort(Some("name"), SortDirection::Ascending);
4447        assert!(tt.sort_signal().get().is_some());
4448        tt.clear_sort();
4449        assert_eq!(tt.sort_signal().get(), None);
4450    }
4451
4452    // ── Cell state survives a column reorder/pin ───────────────────────
4453    //
4454    // `focused_cell`, `editing_cell`, and `CellSelectionModel` all store
4455    // `(row, display_position)`. A drag-to-reorder or a pin toggle only
4456    // bumps the rebuild version — without a remap, the stored display
4457    // position would silently relabel onto whatever column now sits
4458    // there. Pinning makes display order diverge from declaration order
4459    // (columns are declared name(0), size(1)), so a shortcut that merely
4460    // keeps the same index would fail these.
4461
4462    #[test]
4463    fn column_pinning_remaps_focused_cell_to_follow_its_column() {
4464        let proxy = SortFilterTreeModel::new(sample_tree());
4465        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4466        let id = tree.add(
4467            TreeTableView::from_projection(proxy)
4468                .add_column(name_col())
4469                .add_column(size_col())
4470                .row_height(20.0),
4471        );
4472        tree.layout(SizeProposal {
4473            width: Some(400.0),
4474            height: Some(200.0),
4475        });
4476        tree.focus(id);
4477        {
4478            let any = tree.widget_as_any(id).unwrap();
4479            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4480            tt.set_focused_cell(0, 1); // focus `size`, at display position 1
4481            // Pinning `size` Leading swaps it ahead of `name` — display
4482            // order becomes [size, name]. A stale (0, 1) would now land
4483            // on `name`.
4484            tt.set_column_pinning("size", PinnedSide::Leading);
4485        }
4486        tree.layout(SizeProposal {
4487            width: Some(400.0),
4488            height: Some(200.0),
4489        });
4490        let any = tree.widget_as_any(id).unwrap();
4491        let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4492        assert_eq!(
4493            tt.focused_cell_signal().get(),
4494            Some((0, 0)),
4495            "focus must follow `size` to its new display position"
4496        );
4497    }
4498
4499    #[test]
4500    fn column_pinning_remaps_editing_cell_to_follow_its_column() {
4501        let proxy = SortFilterTreeModel::new(sample_tree());
4502        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4503        let id = tree.add(
4504            TreeTableView::from_projection(proxy)
4505                .add_column(name_col())
4506                .add_column(size_col())
4507                .row_height(20.0),
4508        );
4509        tree.layout(SizeProposal {
4510            width: Some(400.0),
4511            height: Some(200.0),
4512        });
4513        {
4514            let any = tree.widget_as_any(id).unwrap();
4515            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4516            tt.begin_edit(0, "size"); // size @ display position 1
4517            assert_eq!(tt.editing_cell_signal().get(), Some((0, 1)));
4518            tt.set_column_pinning("size", PinnedSide::Leading);
4519        }
4520        tree.layout(SizeProposal {
4521            width: Some(400.0),
4522            height: Some(200.0),
4523        });
4524        let any = tree.widget_as_any(id).unwrap();
4525        let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4526        assert_eq!(
4527            tt.editing_cell_signal().get(),
4528            Some((0, 0)),
4529            "the open editor must follow `size` to its new display \
4530             position, not relabel onto whatever column now sits at \
4531             position 1"
4532        );
4533    }
4534
4535    #[test]
4536    fn column_pinning_remaps_cell_selection_to_follow_its_column() {
4537        let proxy = SortFilterTreeModel::new(sample_tree());
4538        let cs = CellSelectionModel::new(TableSelectionMode::MultiCell);
4539        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4540        let id = tree.add(
4541            TreeTableView::from_projection(proxy)
4542                .add_column(name_col())
4543                .add_column(size_col())
4544                .row_height(20.0)
4545                .selection_mode(TableSelectionMode::MultiCell)
4546                .cell_selection(cs.clone()),
4547        );
4548        tree.layout(SizeProposal {
4549            width: Some(400.0),
4550            height: Some(200.0),
4551        });
4552        cs.select(0, 1); // select `size` at display position 1
4553        {
4554            let any = tree.widget_as_any(id).unwrap();
4555            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4556            tt.set_column_pinning("size", PinnedSide::Leading);
4557        }
4558        tree.layout(SizeProposal {
4559            width: Some(400.0),
4560            height: Some(200.0),
4561        });
4562        assert!(
4563            cs.is_selected(0, 0),
4564            "selection must follow `size` to its new display position"
4565        );
4566        assert!(!cs.is_selected(0, 1));
4567    }
4568
4569    #[test]
4570    fn collapsing_a_node_above_a_selected_cell_clears_stale_cell_selection() {
4571        // Cell selection is index-based; a `TreeDataSource`'s flattening
4572        // gives no per-row delta to reindex it by (unlike `TableView`'s
4573        // `ListModel` `DataChange`), so the honest fix on a structural
4574        // change is to drop the selection rather than let a stale flat
4575        // row index silently point at whatever node now occupies it.
4576        let proxy = SortFilterTreeModel::new(sample_tree());
4577        let docs = proxy.tree().root(0);
4578        let cs = CellSelectionModel::new(TableSelectionMode::MultiCell);
4579        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4580        let id = tree.add(
4581            TreeTableView::from_projection(proxy.clone())
4582                .add_column(name_col())
4583                .row_height(20.0)
4584                .selection_mode(TableSelectionMode::MultiCell)
4585                .cell_selection(cs.clone()),
4586        );
4587        tree.layout(SizeProposal {
4588            width: Some(400.0),
4589            height: Some(200.0),
4590        });
4591        {
4592            let any = tree.widget_as_any(id).unwrap();
4593            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4594            tt.expand(docs);
4595        }
4596        tree.layout(SizeProposal {
4597            width: Some(400.0),
4598            height: Some(200.0),
4599        });
4600        assert_eq!(proxy.visible_count(), 4); // docs, readme, guide, src
4601        cs.select(3, 0); // `src`, the last flat row
4602        {
4603            let any = tree.widget_as_any(id).unwrap();
4604            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4605            tt.collapse(docs);
4606        }
4607        tree.layout(SizeProposal {
4608            width: Some(400.0),
4609            height: Some(200.0),
4610        });
4611        assert_eq!(proxy.visible_count(), 2); // docs, src — `src` is now row 1
4612        assert_eq!(
4613            cs.count(),
4614            0,
4615            "a stale (row, col) surviving the collapse must be dropped, not \
4616             silently point at whatever node now sits at flat row 3"
4617        );
4618    }
4619
4620    #[test]
4621    fn content_only_update_leaves_cell_selection_untouched() {
4622        // A version bump that doesn't change the flat row count — an
4623        // in-place item edit, no expand/collapse/insert/remove — must not
4624        // disturb an existing cell selection.
4625        let model = sample_tree();
4626        let proxy = SortFilterTreeModel::new(model);
4627        let docs = proxy.tree().root(0);
4628        let cs = CellSelectionModel::new(TableSelectionMode::MultiCell);
4629        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4630        tree.add(
4631            TreeTableView::from_projection(proxy.clone())
4632                .add_column(name_col())
4633                .row_height(20.0)
4634                .selection_mode(TableSelectionMode::MultiCell)
4635                .cell_selection(cs.clone()),
4636        );
4637        tree.layout(SizeProposal {
4638            width: Some(400.0),
4639            height: Some(200.0),
4640        });
4641        cs.select(0, 0); // `docs`
4642        // In-place content update — same node, same position, new label.
4643        proxy.tree().update(docs, "docs-renamed");
4644        tree.layout(SizeProposal {
4645            width: Some(400.0),
4646            height: Some(200.0),
4647        });
4648        assert!(
4649            cs.is_selected(0, 0),
4650            "a content-only update must leave an unrelated selection alone"
4651        );
4652    }
4653
4654    // ── AT active_descendant follows cell focus ─────────────────────────
4655
4656    #[test]
4657    fn focused_cell_sets_active_descendant_to_the_cell_node() {
4658        let proxy = SortFilterTreeModel::new(sample_tree());
4659        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4660        let id = tree.add(
4661            TreeTableView::from_projection(proxy)
4662                .add_column(name_col())
4663                .add_column(size_col())
4664                .row_height(20.0),
4665        );
4666        tree.layout(SizeProposal {
4667            width: Some(400.0),
4668            height: Some(200.0),
4669        });
4670        tree.focus(id);
4671        {
4672            let any = tree.widget_as_any(id).unwrap();
4673            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4674            tt.set_focused_cell(0, 1);
4675        }
4676        let update = tree.sync_accessibility();
4677        let root_node_id = widget_id_to_node_id(id);
4678        let root_node = update
4679            .nodes
4680            .iter()
4681            .find(|(nid, _)| *nid == root_node_id)
4682            .map(|(_, n)| n)
4683            .expect("root node present in the AT tree");
4684        let active = root_node
4685            .active_descendant()
4686            .expect("a focused cell must set active_descendant");
4687        let cell_node = update
4688            .nodes
4689            .iter()
4690            .find(|(nid, _)| *nid == active)
4691            .map(|(_, n)| n)
4692            .expect("active_descendant must reference a node present in the TreeUpdate");
4693        assert_eq!(cell_node.role(), Role::Cell);
4694    }
4695
4696    #[test]
4697    fn active_descendant_clears_after_the_focused_cell_scrolls_out_of_realization() {
4698        let proxy = SortFilterTreeModel::new(wide_tree(1000));
4699        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4700        let id = tree.add(
4701            TreeTableView::from_projection(proxy)
4702                .add_column(name_col())
4703                .row_height(20.0),
4704        );
4705        tree.layout(SizeProposal {
4706            width: Some(400.0),
4707            height: Some(200.0),
4708        });
4709        tree.focus(id);
4710        {
4711            let any = tree.widget_as_any(id).unwrap();
4712            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4713            tt.set_focused_cell(1, 0);
4714        }
4715        let root_node_id = widget_id_to_node_id(id);
4716        let update = tree.sync_accessibility();
4717        let active_before = update
4718            .nodes
4719            .iter()
4720            .find(|(nid, _)| *nid == root_node_id)
4721            .and_then(|(_, n)| n.active_descendant());
4722        assert!(active_before.is_some(), "row 1 is realized initially");
4723
4724        // Scroll far enough that row 1 leaves the realized+buffer window.
4725        // Nothing clears `focused_cell` on scroll, so this exercises the
4726        // "stale id" hazard directly: the pre-scroll build's cell WidgetId
4727        // has no live AT node once the pane rebuilds without it.
4728        let signal = {
4729            let any = tree.widget_as_any(id).unwrap();
4730            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4731            tt.scroll_y_signal().clone()
4732        };
4733        signal.set(2000.0);
4734        tree.request_frame();
4735        tree.layout(SizeProposal {
4736            width: Some(400.0),
4737            height: Some(200.0),
4738        });
4739
4740        let update = tree.sync_accessibility();
4741        let active_after = update
4742            .nodes
4743            .iter()
4744            .find(|(nid, _)| *nid == root_node_id)
4745            .and_then(|(_, n)| n.active_descendant());
4746        assert_eq!(
4747            active_after, None,
4748            "a focused cell that scrolled out of realization must not leave \
4749             a stale active_descendant pointing at a destroyed node"
4750        );
4751    }
4752
4753    /// Taking focus reveals the row the keyboard cursor sits on.
4754    ///
4755    /// Only the rows near the viewport are realized, so a cursor placed before
4756    /// the view is ever looked at (a restored position, a preselected row)
4757    /// usually has no cell widget at all. Nothing then speaks for it:
4758    /// `accessibility()` finds no entry in `cell_map`, so it nominates no
4759    /// `active_descendant`, and a screen reader arriving here is told nothing.
4760    /// The first arrow press steps *past* that row as well, because the cursor
4761    /// was somewhere nobody was shown.
4762    ///
4763    /// Asserted on the accessibility tree, since that is what the failure was
4764    /// about: the cell has to be a node a platform can name.
4765    #[test]
4766    fn taking_focus_reveals_the_cursor_row() {
4767        let proxy = SortFilterTreeModel::new(wide_tree(1000));
4768        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4769        let id = tree.add(
4770            TreeTableView::from_projection(proxy)
4771                .add_column(name_col())
4772                .row_height(20.0),
4773        );
4774        let viewport = SizeProposal {
4775            width: Some(400.0),
4776            height: Some(200.0),
4777        };
4778        tree.layout(viewport);
4779
4780        // Place the cursor far below the viewport WITHOUT giving the view
4781        // focus. `set_focused_cell` never scrolls on its own: only the key
4782        // handler does (`table_view/keyboard.rs:445`), and no key was pressed.
4783        {
4784            let any = tree.widget_as_any(id).unwrap();
4785            any.downcast_ref::<TreeTableView<&'static str>>()
4786                .unwrap()
4787                .set_focused_cell(500, 0);
4788        }
4789        tree.request_frame();
4790        tree.layout(viewport);
4791
4792        let root_node_id = widget_id_to_node_id(id);
4793        // What a platform adapter is handed: the container's
4794        // `active_descendant`, resolved inside the same `TreeUpdate` that
4795        // published it.
4796        let nominated = |tree: &mut WidgetTree| -> Option<(Role, Option<usize>)> {
4797            let update = tree.sync_accessibility();
4798            let active = update
4799                .nodes
4800                .iter()
4801                .find(|(nid, _)| *nid == root_node_id)
4802                .and_then(|(_, n)| n.active_descendant())?;
4803            update
4804                .nodes
4805                .iter()
4806                .find(|(nid, _)| *nid == active)
4807                .map(|(_, n)| (n.role(), n.row_index()))
4808        };
4809
4810        assert_eq!(
4811            nominated(&mut tree),
4812            None,
4813            "row 500 starts far outside the realized window, which is the case \
4814             this is about"
4815        );
4816
4817        tree.focus(id);
4818        tree.layout(viewport);
4819
4820        assert_eq!(
4821            nominated(&mut tree),
4822            // `row_index` is stored zero-based and the adapters add the 1 back,
4823            // so the cursor's row 500 reads as 501 with the header counted.
4824            Some((Role::Cell, Some(501))),
4825            "taking focus has to bring the cursor's row into the realized \
4826             window, or nothing in the tree can be told about it"
4827        );
4828    }
4829
4830    /// With no cell navigated to yet, the selected row is the one revealed.
4831    ///
4832    /// A view restored into a selection has no `focused_cell`, so reading the
4833    /// cursor only from that signal would leave the selection off-screen and
4834    /// unrealized: no row node carrying `selected` for AT-SPI to announce
4835    /// either, and the next arrow press stepping from a row nobody saw. The
4836    /// fallback order is the keyboard handler's own
4837    /// (`table_view/keyboard.rs:134-139`).
4838    #[test]
4839    fn taking_focus_reveals_the_selected_row_when_no_cell_has_been_navigated_to() {
4840        use teksilo_data::{SelectionMode, SelectionModel};
4841
4842        let proxy = SortFilterTreeModel::new(wide_tree(1000));
4843        let sel = SelectionModel::new(SelectionMode::Single);
4844        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4845        let id = tree.add(
4846            TreeTableView::from_projection(proxy)
4847                .add_column(name_col())
4848                .row_height(20.0)
4849                .selection_mode(TableSelectionMode::SingleRow)
4850                .selection(sel.clone()),
4851        );
4852        let viewport = SizeProposal {
4853            width: Some(400.0),
4854            height: Some(200.0),
4855        };
4856        tree.layout(viewport);
4857
4858        sel.select(500);
4859        tree.request_frame();
4860        tree.layout(viewport);
4861
4862        let selected_rows = |tree: &mut WidgetTree| -> Vec<usize> {
4863            tree.sync_accessibility()
4864                .nodes
4865                .iter()
4866                .filter(|(_, n)| n.role() == Role::Row && n.is_selected() == Some(true))
4867                .filter_map(|(_, n)| n.row_index())
4868                .collect()
4869        };
4870
4871        assert!(
4872            selected_rows(&mut tree).is_empty(),
4873            "row 500 starts far outside the realized window, which is the case \
4874             this is about"
4875        );
4876
4877        tree.focus(id);
4878        tree.layout(viewport);
4879
4880        assert_eq!(
4881            selected_rows(&mut tree),
4882            vec![501],
4883            "taking focus has to realize the selected row, or there is no node \
4884             carrying `selected` for a screen reader to find"
4885        );
4886    }
4887
4888    /// The index revealed is a **visible** row, not a position in the
4889    /// unflattened tree.
4890    ///
4891    /// The two differ by every descendant hidden above the cursor, so a tree
4892    /// with collapsed branches is where a confusion between them shows. Here
4893    /// the first twenty roots keep their nine children each and show none of
4894    /// them, which slides every row below 180 places up the flat order while
4895    /// nothing about the tree itself moves. The gap is deliberately far wider
4896    /// than the realized window: a reveal aimed at the unflattened position
4897    /// would leave the cursor's row with no widget at all, which is the state
4898    /// this whole change is about.
4899    ///
4900    /// The reveal is fed `focused_cell`, whose row the keyboard handler clamps
4901    /// against `TreeNavigator::row_count()` = `TreeSource::visible_count()`
4902    /// (`tree_table_view.rs:129-131`), and it spends that index on
4903    /// `RowMetrics`, which `place_children` sizes from the same
4904    /// `visible_count()`. Both ends are therefore the flat order this test
4905    /// reads through `SortFilterTreeModel::visible_node_id`.
4906    ///
4907    /// Checked by identity rather than by arithmetic: the nominated cell has
4908    /// to be the one holding the node the cursor was put on.
4909    #[test]
4910    fn the_revealed_row_is_a_visible_index_not_a_position_in_the_unflattened_tree() {
4911        use teksilo_core::accessibility::node_id_to_widget_id;
4912
4913        let model = TreeModel::new();
4914        let mut to_collapse = Vec::new();
4915        let mut needle = None;
4916        for r in 0..40usize {
4917            let root = model.insert_root(r, "root");
4918            if r < 20 {
4919                to_collapse.push(root);
4920            }
4921            for c in 0..9usize {
4922                let label = if r == 30 && c == 4 { "needle" } else { "leaf" };
4923                let child = model.insert_child(root, c, label);
4924                if r == 30 && c == 4 {
4925                    needle = Some(child);
4926                }
4927            }
4928        }
4929        let needle = needle.expect("the needle inserted");
4930
4931        let proxy = SortFilterTreeModel::new(model);
4932        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
4933        let id = tree.add(
4934            TreeTableView::from_projection(proxy.clone())
4935                .add_column(name_col())
4936                .row_height(20.0),
4937        );
4938        let viewport = SizeProposal {
4939            width: Some(400.0),
4940            height: Some(200.0),
4941        };
4942        tree.layout(viewport);
4943        {
4944            let any = tree.widget_as_any(id).unwrap();
4945            any.downcast_ref::<TreeTableView<&'static str>>()
4946                .unwrap()
4947                .expand_all();
4948        }
4949        tree.layout(viewport);
4950
4951        let flat_of = |node| {
4952            (0..proxy.visible_count())
4953                .find(|&i| proxy.visible_node_id(i) == Some(node))
4954                .expect("the node is visible")
4955        };
4956        let flat_expanded = flat_of(needle);
4957
4958        {
4959            let any = tree.widget_as_any(id).unwrap();
4960            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
4961            for root in to_collapse {
4962                tt.collapse(root);
4963            }
4964        }
4965        tree.layout(viewport);
4966
4967        let flat = flat_of(needle);
4968        assert_eq!(
4969            flat,
4970            flat_expanded - 180,
4971            "collapsing the first twenty roots has to move the needle up the \
4972             flat order without moving it in the tree, or this test proves \
4973             nothing"
4974        );
4975
4976        {
4977            let any = tree.widget_as_any(id).unwrap();
4978            any.downcast_ref::<TreeTableView<&'static str>>()
4979                .unwrap()
4980                .set_focused_cell(flat, 0);
4981        }
4982        tree.request_frame();
4983        tree.layout(viewport);
4984        tree.focus(id);
4985        tree.layout(viewport);
4986
4987        let root_node_id = widget_id_to_node_id(id);
4988        let update = tree.sync_accessibility();
4989        let active = update
4990            .nodes
4991            .iter()
4992            .find(|(nid, _)| *nid == root_node_id)
4993            .and_then(|(_, n)| n.active_descendant())
4994            .expect(
4995                "taking focus has to bring the cursor's row into the realized \
4996                 window, or nothing in the tree can be told about it",
4997            );
4998        let cell = update
4999            .nodes
5000            .iter()
5001            .find(|(nid, _)| *nid == active)
5002            .map(|(_, n)| n)
5003            .expect("active_descendant must reference a node in the TreeUpdate");
5004        assert_eq!(cell.row_index(), Some(flat + 1));
5005
5006        // And it is the needle's own cell: walk the nominated cell's widget
5007        // subtree for the label the delegate rendered.
5008        let mut q = vec![node_id_to_widget_id(active)];
5009        let mut names = Vec::new();
5010        while let Some(w) = q.pop() {
5011            if let Some(name) = tree.accessibility_node(w).name() {
5012                names.push(name.to_string());
5013            }
5014            for c in tree.children(w) {
5015                q.push(c);
5016            }
5017        }
5018        assert!(
5019            names.iter().any(|n| n == "needle"),
5020            "the revealed row must be the node the cursor was put on, got {names:?}"
5021        );
5022    }
5023
5024    #[test]
5025    fn lazy_loading_rows_render_placeholder_cells_and_request_the_window() {
5026        // A windowed tree source with nothing resident: every visible row
5027        // is `Loading`, so the pane must render placeholder cells (not
5028        // skip the rows — `meta()` returning `None` used to mean "off the
5029        // end of `start..end`" unconditionally) and the view must nudge
5030        // the source to load the realized window. Mirrors TableView's
5031        // `lazy_loading_rows_render_placeholder_cells_and_request_the_window`.
5032        use std::cell::RefCell;
5033        use std::ops::Range;
5034        use teksilo_data::{FlatEntry, RowState};
5035
5036        struct Windowed {
5037            total: usize,
5038            requested: Rc<RefCell<Vec<Range<usize>>>>,
5039            version: Signal<u64>,
5040        }
5041        impl TreeDataSource for Windowed {
5042            type Item = &'static str;
5043            type Key = usize;
5044            fn visible_count(&self) -> usize {
5045                self.total
5046            }
5047            fn with_entry<R>(
5048                &self,
5049                _i: usize,
5050                _f: impl FnOnce(&&'static str, &FlatEntry<usize>) -> R,
5051            ) -> Option<R> {
5052                None // nothing resident yet
5053            }
5054            fn key_at(&self, i: usize) -> Option<usize> {
5055                (i < self.total).then_some(i)
5056            }
5057            fn flat_index_of(&self, key: &usize) -> Option<usize> {
5058                (*key < self.total).then_some(*key)
5059            }
5060            fn parent(&self, _key: &usize) -> Option<usize> {
5061                None
5062            }
5063            fn child_keys(&self, _key: &usize) -> Vec<usize> {
5064                vec![]
5065            }
5066            fn version_signal(&self) -> Signal<u64> {
5067                self.version.clone()
5068            }
5069            fn is_expanded(&self, _key: &usize) -> bool {
5070                false
5071            }
5072            fn set_expanded(&self, _key: &usize, _expanded: bool) {}
5073            fn row_state(&self, _flat_index: usize) -> RowState {
5074                RowState::Loading
5075            }
5076            fn request_window(&self, range: Range<usize>) {
5077                self.requested.borrow_mut().push(range);
5078            }
5079        }
5080
5081        let requested = Rc::new(RefCell::new(Vec::new()));
5082        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5083        let id = tree.add(
5084            TreeTableView::from_source(Windowed {
5085                total: 1000,
5086                requested: requested.clone(),
5087                version: Signal::new(0),
5088            })
5089            .add_column(name_col())
5090            .show_header(false)
5091            .row_height(30.0),
5092        );
5093        tree.layout(SizeProposal {
5094            width: Some(400.0),
5095            height: Some(300.0),
5096        });
5097
5098        // The body pane is the view's first child (header suppressed).
5099        // 300px / 30px = 10 visible + buffer → the loading rows realize
5100        // as placeholder row widgets, NOT skipped.
5101        let body_pane = tree.children(id)[0];
5102        let placeholder_rows = tree.children(body_pane).len();
5103        assert!(
5104            placeholder_rows >= 10,
5105            "loading rows must render as placeholders, got {placeholder_rows}"
5106        );
5107        // And the source was asked to load the realized window.
5108        assert!(
5109            !requested.borrow().is_empty(),
5110            "request_window must be called for the visible range"
5111        );
5112    }
5113
5114    #[test]
5115    fn arrow_expand_collapse_follows_a_non_leading_tree_column() {
5116        // Regression: the key handler hardcoded `col == 0` as "the tree
5117        // column", so designating any other column via `.tree_column()` moved
5118        // the twist visually but left ArrowLeft/ArrowRight expanding nothing.
5119        // Here the tree column is "size", at display position 1.
5120        use teksilo_core::event::{Key, Modifiers};
5121        let proxy = SortFilterTreeModel::new(sample_tree());
5122        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5123        let id = tree.add(
5124            TreeTableView::from_projection(proxy.clone())
5125                .add_column(name_col())
5126                .add_column(size_col())
5127                .tree_column("size")
5128                .row_height(20.0),
5129        );
5130        tree.layout(SizeProposal {
5131            width: Some(400.0),
5132            height: Some(200.0),
5133        });
5134        tree.focus(id);
5135
5136        // Off the tree column: the arrows are pure cursor movement, so the
5137        // visible set must not change.
5138        {
5139            let any = tree.widget_as_any(id).unwrap();
5140            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
5141            tt.set_focused_cell(0, 0);
5142        }
5143        tree.press_key(Key::ArrowRight, Modifiers::NONE);
5144        assert_eq!(
5145            proxy.visible_count(),
5146            2,
5147            "ArrowRight off the tree column must not expand"
5148        );
5149
5150        // On the tree column (display position 1): expand, then collapse.
5151        {
5152            let any = tree.widget_as_any(id).unwrap();
5153            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
5154            tt.set_focused_cell(0, 1);
5155        }
5156        tree.press_key(Key::ArrowRight, Modifiers::NONE);
5157        assert_eq!(
5158            proxy.visible_count(),
5159            4,
5160            "docs expands to reveal 2 children"
5161        );
5162        tree.press_key(Key::ArrowLeft, Modifiers::NONE);
5163        assert_eq!(proxy.visible_count(), 2, "docs collapses again");
5164    }
5165
5166    #[test]
5167    fn arrow_nav_scroll_follows_focused_row() {
5168        // 100 flat rows × 20 px in a 200 px viewport. Walking focus down
5169        // past the visible window must scroll to keep the focused row on
5170        // screen ("selection always visible"), matching TreeView / the
5171        // newly-fixed TableView. Regression for: TreeTableView keyboard
5172        // nav left scroll_y untouched.
5173        use teksilo_core::event::{Key, Modifiers};
5174        let proxy = SortFilterTreeModel::new(wide_tree(100));
5175        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5176        let id = tree.add(
5177            TreeTableView::from_projection(proxy)
5178                .add_column(name_col())
5179                .row_height(20.0),
5180        );
5181        let proposal = SizeProposal {
5182            width: Some(400.0),
5183            height: Some(200.0),
5184        };
5185        tree.layout(proposal);
5186        tree.focus(id);
5187        let read_scroll = |tree: &WidgetTree| {
5188            let any = tree.widget_as_any(id).unwrap();
5189            any.downcast_ref::<TreeTableView<&'static str>>()
5190                .unwrap()
5191                .scroll_y_signal()
5192                .get()
5193        };
5194        let read_focus = |tree: &WidgetTree| {
5195            let any = tree.widget_as_any(id).unwrap();
5196            any.downcast_ref::<TreeTableView<&'static str>>()
5197                .unwrap()
5198                .focused_cell_signal()
5199                .get()
5200        };
5201        {
5202            let any = tree.widget_as_any(id).unwrap();
5203            any.downcast_ref::<TreeTableView<&'static str>>()
5204                .unwrap()
5205                .set_focused_cell(0, 0);
5206        }
5207        assert_eq!(read_scroll(&tree), 0.0, "starts at top");
5208
5209        for _ in 0..20 {
5210            tree.press_key(Key::ArrowDown, Modifiers::NONE);
5211            tree.layout(proposal);
5212        }
5213        assert_eq!(read_focus(&tree), Some((20, 0)));
5214        assert!(
5215            read_scroll(&tree) > 200.0,
5216            "arrow-down nav must scroll to reveal row 20, got {}",
5217            read_scroll(&tree)
5218        );
5219
5220        // Ctrl+Home returns focus AND scroll to the top.
5221        tree.press_key(Key::Home, Modifiers::COMMAND);
5222        tree.layout(proposal);
5223        assert_eq!(read_focus(&tree), Some((0, 0)));
5224        assert_eq!(read_scroll(&tree), 0.0, "Ctrl+Home scrolls to top");
5225    }
5226
5227    #[test]
5228    fn type_ahead_jumps_to_matching_row() {
5229        use teksilo_core::event::{Key, Modifiers};
5230        let model = TreeModel::new();
5231        model.insert_root(0, "Apple");
5232        model.insert_root(1, "Banana");
5233        model.insert_root(2, "Cherry");
5234        model.insert_root(3, "Cranberry");
5235        let proxy = SortFilterTreeModel::new(model);
5236        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5237        let id = tree.add(
5238            TreeTableView::from_projection(proxy)
5239                .add_column(name_col())
5240                .row_height(20.0)
5241                .type_ahead_label(|s: &&'static str| s.to_string()),
5242        );
5243        tree.layout(SizeProposal {
5244            width: Some(400.0),
5245            height: Some(200.0),
5246        });
5247        tree.focus(id);
5248        let read_focus = |tree: &WidgetTree| {
5249            let any = tree.widget_as_any(id).unwrap();
5250            any.downcast_ref::<TreeTableView<&'static str>>()
5251                .unwrap()
5252                .focused_cell_signal()
5253                .get()
5254        };
5255        {
5256            let any = tree.widget_as_any(id).unwrap();
5257            any.downcast_ref::<TreeTableView<&'static str>>()
5258                .unwrap()
5259                .set_focused_cell(0, 0);
5260        }
5261        tree.press_key(Key::C, Modifiers::NONE);
5262        assert_eq!(read_focus(&tree), Some((2, 0)), "'c' → Cherry");
5263        tree.press_key(Key::R, Modifiers::NONE);
5264        assert_eq!(read_focus(&tree), Some((3, 0)), "'cr' → Cranberry");
5265    }
5266
5267    #[test]
5268    fn ctrl_tab_escapes_the_cell_grid() {
5269        use crate::primitives::{TextWidget, VStack};
5270        use teksilo_core::event::{Key, Modifiers};
5271        use teksilo_core::widget_builder::WidgetBuilder;
5272
5273        let proxy = SortFilterTreeModel::new(wide_tree(5));
5274        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5275        let id = tree.add(
5276            TreeTableView::from_projection(proxy)
5277                .add_column(name_col())
5278                .row_height(20.0),
5279        );
5280        let sink = tree.add(TextWidget::new(lit!("sink")).focusable(true));
5281        let _root = tree.add(VStack::new().add_child(id).add_child(sink));
5282        tree.layout(SizeProposal {
5283            width: Some(400.0),
5284            height: Some(200.0),
5285        });
5286        let read_focus = |tree: &WidgetTree| {
5287            let any = tree.widget_as_any(id).unwrap();
5288            any.downcast_ref::<TreeTableView<&'static str>>()
5289                .unwrap()
5290                .focused_cell_signal()
5291                .get()
5292        };
5293        tree.focus(id);
5294        {
5295            let any = tree.widget_as_any(id).unwrap();
5296            any.downcast_ref::<TreeTableView<&'static str>>()
5297                .unwrap()
5298                .set_focused_cell(0, 0);
5299        }
5300        let before = read_focus(&tree);
5301        tree.press_key(Key::Tab, Modifiers::CTRL);
5302        assert_eq!(
5303            read_focus(&tree),
5304            before,
5305            "Ctrl+Tab must not navigate cells"
5306        );
5307        assert_eq!(
5308            tree.focused(),
5309            Some(sink),
5310            "Ctrl+Tab moves focus out of the tree-table"
5311        );
5312    }
5313
5314    #[test]
5315    fn rows_carry_role_row_with_level_indicator() {
5316        let proxy = SortFilterTreeModel::new(sample_tree());
5317        let docs = proxy.tree().root(0);
5318        proxy.expand(docs);
5319        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5320        let id = tree.add(
5321            TreeTableView::from_projection(proxy)
5322                .add_column(name_col())
5323                .row_height(20.0),
5324        );
5325        tree.layout(SizeProposal {
5326            width: Some(400.0),
5327            height: Some(200.0),
5328        });
5329        // Walk the tree and count Role::Row entries.
5330        let mut q = vec![id];
5331        let mut row_count = 0;
5332        while let Some(n) = q.pop() {
5333            if tree.accessibility_node(n).role() == Role::Row {
5334                row_count += 1;
5335            }
5336            for c in tree.children(n) {
5337                q.push(c);
5338            }
5339        }
5340        // 1 header + 4 visible body rows (docs, readme, guide, src).
5341        assert!(
5342            row_count >= 5,
5343            "expected at least 5 Role::Row nodes, got {row_count}"
5344        );
5345    }
5346
5347    #[test]
5348    fn filter_mode_keep_ancestors_works_via_proxy() {
5349        let proxy = SortFilterTreeModel::new(sample_tree())
5350            .filter_mode(TreeFilterMode::KeepAncestors)
5351            .with_predicate("name", |t| {
5352                let needle = t.to_string();
5353                Box::new(move |row: &&str| row.contains(&needle))
5354            });
5355        proxy.expand_all();
5356        proxy.set_filter("name", "main");
5357        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5358        let _id = tree.add(
5359            TreeTableView::from_projection(proxy.clone())
5360                .add_column(name_col())
5361                .row_height(20.0),
5362        );
5363        tree.layout(SizeProposal {
5364            width: Some(400.0),
5365            height: Some(200.0),
5366        });
5367        // Visible: src (ancestor), main.rs (matches).
5368        assert_eq!(proxy.visible_count(), 2);
5369    }
5370
5371    #[test]
5372    fn collapse_all_then_expand_all_round_trips() {
5373        let proxy = SortFilterTreeModel::new(sample_tree());
5374        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5375        let id = tree.add(
5376            TreeTableView::from_projection(proxy.clone())
5377                .add_column(name_col())
5378                .row_height(20.0),
5379        );
5380        tree.layout(SizeProposal {
5381            width: Some(400.0),
5382            height: Some(200.0),
5383        });
5384        {
5385            let any = tree.widget_as_any(id).unwrap();
5386            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
5387            tt.expand_all();
5388        }
5389        assert_eq!(proxy.visible_count(), 5);
5390        {
5391            let any = tree.widget_as_any(id).unwrap();
5392            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
5393            tt.collapse_all();
5394        }
5395        assert_eq!(proxy.visible_count(), 2);
5396    }
5397
5398    #[test]
5399    fn rows_report_sibling_position_and_size_among_siblings() {
5400        // docs (root 1/2) -> readme (child 1/2), guide (child 2/2)
5401        // src  (root 2/2) -> main.rs (child 1/1)
5402        //
5403        // `TreeView`'s `TreeItemWrapper` already announces
5404        // position_in_set/size_of_set (`list_item_a11y.rs`);
5405        // `TreeTableView` never wired `TreeSource::sibling_pos` into its own
5406        // row wrapper (`TreeRowA11y`) despite the data being one call away.
5407        let proxy = SortFilterTreeModel::new(sample_tree());
5408        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5409        let id = tree.add(
5410            TreeTableView::from_projection(proxy.clone())
5411                .add_column(name_col())
5412                .row_height(20.0),
5413        );
5414        tree.layout(SizeProposal {
5415            width: Some(400.0),
5416            height: Some(400.0),
5417        });
5418        {
5419            let any = tree.widget_as_any(id).unwrap();
5420            any.downcast_ref::<TreeTableView<&'static str>>()
5421                .unwrap()
5422                .expand_all();
5423        }
5424        tree.layout(SizeProposal {
5425            width: Some(400.0),
5426            height: Some(400.0),
5427        });
5428        assert_eq!(proxy.visible_count(), 5);
5429
5430        // Collect all Role::Row body widgets (the header shares the role but
5431        // is excluded below by having no accesskit node y inside the body
5432        // band — simplest: sort every Role::Row by y and drop the topmost
5433        // one, which is always the header).
5434        let mut rows: Vec<WidgetId> = Vec::new();
5435        let mut q = vec![id];
5436        while let Some(n) = q.pop() {
5437            if tree.accessibility_node(n).role() == Role::Row {
5438                rows.push(n);
5439            }
5440            for c in tree.children(n) {
5441                q.push(c);
5442            }
5443        }
5444        rows.sort_by(|a, b| tree.bounds(*a).y.partial_cmp(&tree.bounds(*b).y).unwrap());
5445        assert_eq!(rows.len(), 6, "header + five body rows");
5446        let body_rows = &rows[1..];
5447
5448        // `position_in_set`/`size_of_set` aren't on the summarized
5449        // `AccessibilityInfo` — read them off the real accesskit node via a
5450        // fresh `TreeUpdate`, mirroring `docking::tests::find_a11y_node`.
5451        let update = tree.sync_accessibility();
5452        let find = |wid: WidgetId| -> &teksilo_core::accesskit::Node {
5453            let nid = widget_id_to_node_id(wid);
5454            update
5455                .nodes
5456                .iter()
5457                .find(|(n, _)| *n == nid)
5458                .map(|(_, n)| n)
5459                .expect("row must be in the a11y tree")
5460        };
5461        let positions: Vec<usize> = body_rows
5462            .iter()
5463            .map(|&r| find(r).position_in_set().expect("position_in_set"))
5464            .collect();
5465        // The row passes ARIA's 1-based sibling position; AccessKit stores it
5466        // zero-based, and the Windows and AT-SPI adapters add the 1 back — so
5467        // "the first of two siblings" is 0 on the node and "1" to the user.
5468        assert_eq!(
5469            positions,
5470            vec![0, 0, 1, 1, 0],
5471            "docs(1st) readme(1st) guide(2nd) src(2nd) main.rs(1st)"
5472        );
5473        // No sibling *count*, deliberately. AccessKit resolves a set size by
5474        // walking up from an item, so the only value a flattened tree could
5475        // publish is one shared by every row at every depth — which is not what
5476        // "of 2 siblings" means. See `TreeRowA11y::accessibility`.
5477        for &r in body_rows {
5478            assert_eq!(
5479                find(r).size_of_set(),
5480                None,
5481                "a per-sibling count is unrepresentable and must not be faked"
5482            );
5483        }
5484    }
5485
5486    #[test]
5487    fn row_count_in_a11y_includes_header() {
5488        let proxy = SortFilterTreeModel::new(sample_tree());
5489        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5490        let id = tree.add(
5491            TreeTableView::from_projection(proxy)
5492                .add_column(name_col())
5493                .add_column(size_col())
5494                .row_height(20.0),
5495        );
5496        tree.layout(SizeProposal {
5497            width: Some(400.0),
5498            height: Some(200.0),
5499        });
5500        let info = tree.accessibility_node(id);
5501        assert_eq!(info.role(), Role::TreeGrid);
5502        // We can't read row_count from AccessibilityInfo directly,
5503        // but we can verify Role::TreeGrid + Role::Row count matches
5504        // (header + 2 body rows = 3).
5505        let mut q = vec![id];
5506        let mut rows = 0;
5507        while let Some(n) = q.pop() {
5508            if tree.accessibility_node(n).role() == Role::Row {
5509                rows += 1;
5510            }
5511            for c in tree.children(n) {
5512                q.push(c);
5513            }
5514        }
5515        assert_eq!(rows, 3); // header + docs + src
5516    }
5517
5518    // ── RTL (right-to-left) ──────────────────────────────────────────────
5519
5520    /// A tree of `n` collapsed roots — enough to force a vertical scrollbar.
5521    fn wide_tree(n: u32) -> TreeModel<&'static str> {
5522        let t = TreeModel::new();
5523        for i in 0..n {
5524            t.insert_root(i as usize, "node");
5525        }
5526        t
5527    }
5528
5529    /// All `Role::Row` node bounds (header + body), for picking a body row.
5530    fn row_bounds(tree: &WidgetTree, root: WidgetId) -> Vec<teksilo_canvas::Rect> {
5531        let mut q = vec![root];
5532        let mut out = Vec::new();
5533        while let Some(n) = q.pop() {
5534            if tree.accessibility_node(n).role() == Role::Row {
5535                out.push(tree.bounds(n));
5536            }
5537            for c in tree.children(n) {
5538                q.push(c);
5539            }
5540        }
5541        out
5542    }
5543
5544    #[test]
5545    fn rtl_swaps_tree_expand_collapse_keys() {
5546        use teksilo_core::environment::LayoutDirection;
5547        use teksilo_core::event::{Key, Modifiers};
5548
5549        let proxy = SortFilterTreeModel::new(sample_tree());
5550        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5551        let table = tree.add(
5552            TreeTableView::from_projection(proxy.clone())
5553                .add_column(name_col())
5554                .row_height(20.0),
5555        );
5556        tree.layout(SizeProposal {
5557            width: Some(400.0),
5558            height: Some(200.0),
5559        });
5560        // Roots start collapsed: docs + src visible.
5561        assert_eq!(proxy.visible_count(), 2);
5562
5563        tree.set_layout_direction(LayoutDirection::RightToLeft);
5564        tree.focus(table);
5565        {
5566            let any = tree.widget_as_any(table).unwrap();
5567            any.downcast_ref::<TreeTableView<&'static str>>()
5568                .unwrap()
5569                .set_focused_cell(0, 0);
5570        }
5571
5572        // Under RTL the collapsed chevron points left, so ArrowLeft expands
5573        // (toward the children) and ArrowRight collapses.
5574        tree.press_key(Key::ArrowLeft, Modifiers::NONE);
5575        assert_eq!(
5576            proxy.visible_count(),
5577            4,
5578            "RTL ArrowLeft on the tree column should expand docs"
5579        );
5580        tree.press_key(Key::ArrowRight, Modifiers::NONE);
5581        assert_eq!(
5582            proxy.visible_count(),
5583            2,
5584            "RTL ArrowRight on the tree column should collapse docs"
5585        );
5586    }
5587
5588    #[test]
5589    fn rtl_tree_band_shifts_for_left_scrollbar() {
5590        use teksilo_core::environment::LayoutDirection;
5591        // 50 roots → vertical scrollbar present. Under RTL it sits on the
5592        // physical left, so the body band (and its rows) shift right by
5593        // SCROLLBAR_THICKNESS.
5594        let proxy = SortFilterTreeModel::new(wide_tree(50));
5595        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5596        let table = tree.add(
5597            TreeTableView::from_projection(proxy)
5598                .add_column(name_col())
5599                .row_height(20.0),
5600        );
5601        tree.layout(SizeProposal {
5602            width: Some(400.0),
5603            height: Some(200.0),
5604        });
5605        tree.set_layout_direction(LayoutDirection::RightToLeft);
5606        tree.layout(SizeProposal {
5607            width: Some(400.0),
5608            height: Some(200.0),
5609        });
5610
5611        let table_bounds = tree.bounds(table);
5612        // Pick a body row (below the header, which sits at the top).
5613        let body_row = row_bounds(&tree, table)
5614            .into_iter()
5615            .filter(|r| r.y > table_bounds.y + 5.0)
5616            .max_by(|a, b| a.y.partial_cmp(&b.y).unwrap())
5617            .expect("a body row");
5618        assert!(
5619            (body_row.x - SCROLLBAR_THICKNESS).abs() < 0.5,
5620            "RTL body row should start at SCROLLBAR_THICKNESS, got x={}",
5621            body_row.x
5622        );
5623        // LTR control: same table laid out left-to-right starts at 0.
5624        let mut tree2 = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5625        let proxy2 = SortFilterTreeModel::new(wide_tree(50));
5626        let table2 = tree2.add(
5627            TreeTableView::from_projection(proxy2)
5628                .add_column(name_col())
5629                .row_height(20.0),
5630        );
5631        tree2.layout(SizeProposal {
5632            width: Some(400.0),
5633            height: Some(200.0),
5634        });
5635        let tb2 = tree2.bounds(table2);
5636        let body_row2 = row_bounds(&tree2, table2)
5637            .into_iter()
5638            .filter(|r| r.y > tb2.y + 5.0)
5639            .max_by(|a, b| a.y.partial_cmp(&b.y).unwrap())
5640            .expect("a body row");
5641        assert!(body_row2.x.abs() < 0.5, "LTR body row x={}", body_row2.x);
5642    }
5643
5644    // ── Boundary scroll chaining ─────────────────────────────────────────
5645
5646    /// A TreeTableView (40 root rows × 20 px in a ~120 px viewport) above a
5647    /// filler inside an outer ScrollArea, so chaining from the inner
5648    /// tree-table to the outer area is observable.
5649    fn nested_tree_table_fixture(
5650        inner: OverscrollBehavior,
5651    ) -> (WidgetTree, Signal<f32>, Signal<f32>) {
5652        use crate::ScrollArea;
5653        use crate::primitives::{FixedSize, TextWidget, VStack};
5654        let model = TreeModel::new();
5655        for i in 0..40 {
5656            model.insert_root(i, "row");
5657        }
5658        let proxy = SortFilterTreeModel::new(model);
5659        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5660        let tt = TreeTableView::from_projection(proxy)
5661            .add_column(name_col())
5662            .show_header(false)
5663            .row_height(20.0)
5664            .overscroll_behavior(inner);
5665        let inner_y = tt.scroll_y_signal().clone();
5666        let tt_id = tree.add(tt);
5667        let viewport = tree.add(FixedSize::new().width(220.0).height(120.0).child_id(tt_id));
5668        let filler = tree.add(
5669            FixedSize::new()
5670                .width(220.0)
5671                .height(300.0)
5672                .child(TextWidget::new(lit!(""))),
5673        );
5674        let outer_content = tree.add(VStack::new().add_child(viewport).add_child(filler));
5675        let outer = ScrollArea::from_id(outer_content).smooth_scrolling(false);
5676        let outer_y = outer.scroll_y_signal().clone();
5677        let _outer = tree.add(outer);
5678        tree.layout(SizeProposal {
5679            width: Some(220.0),
5680            height: Some(150.0),
5681        });
5682        (tree, inner_y, outer_y)
5683    }
5684
5685    #[test]
5686    fn nested_tree_table_chains_to_outer_at_boundary() {
5687        use teksilo_canvas::Point;
5688        use teksilo_core::event::{Modifiers, ScrollDelta, WidgetEvent};
5689        let (mut tree, inner_y, outer_y) = nested_tree_table_fixture(OverscrollBehavior::Chain);
5690        tree.pointer_move(Point::new(50.0, 40.0));
5691        tree.dispatch_event(WidgetEvent::Scroll {
5692            delta: ScrollDelta::Pixels { x: 0.0, y: 9999.0 },
5693            modifiers: Modifiers::NONE,
5694        });
5695        tree.layout(SizeProposal {
5696            width: Some(220.0),
5697            height: Some(150.0),
5698        });
5699        let inner_bottom = inner_y.get();
5700        assert!(
5701            inner_bottom > 0.0,
5702            "inner tree-table should scroll down; got {inner_bottom}"
5703        );
5704        // A second wheel at the boundary must chain to the outer area.
5705        tree.pointer_move(Point::new(50.0, 40.0));
5706        tree.dispatch_event(WidgetEvent::Scroll {
5707            delta: ScrollDelta::Pixels { x: 0.0, y: 100.0 },
5708            modifiers: Modifiers::NONE,
5709        });
5710        tree.layout(SizeProposal {
5711            width: Some(220.0),
5712            height: Some(150.0),
5713        });
5714        assert!(
5715            (inner_y.get() - inner_bottom).abs() < 0.01,
5716            "inner stays clamped at bottom"
5717        );
5718        assert!(
5719            outer_y.get() > 0.01,
5720            "outer must scroll because the inner chained the boundary"
5721        );
5722    }
5723
5724    #[test]
5725    fn nested_tree_table_contain_blocks_chaining() {
5726        use teksilo_canvas::Point;
5727        use teksilo_core::event::{Modifiers, ScrollDelta, WidgetEvent};
5728        let (mut tree, _inner_y, outer_y) = nested_tree_table_fixture(OverscrollBehavior::Contain);
5729        tree.pointer_move(Point::new(50.0, 40.0));
5730        tree.dispatch_event(WidgetEvent::Scroll {
5731            delta: ScrollDelta::Pixels { x: 0.0, y: 9999.0 },
5732            modifiers: Modifiers::NONE,
5733        });
5734        tree.layout(SizeProposal {
5735            width: Some(220.0),
5736            height: Some(150.0),
5737        });
5738        tree.pointer_move(Point::new(50.0, 40.0));
5739        tree.dispatch_event(WidgetEvent::Scroll {
5740            delta: ScrollDelta::Pixels { x: 0.0, y: 100.0 },
5741            modifiers: Modifiers::NONE,
5742        });
5743        tree.layout(SizeProposal {
5744            width: Some(220.0),
5745            height: Some(150.0),
5746        });
5747        assert!(
5748            outer_y.get() < 0.01,
5749            "Contain must prevent chaining: outer stays put"
5750        );
5751    }
5752
5753    // ── TreeBodyPane split + variable row heights ───────────────────────
5754
5755    fn count_role(tree: &WidgetTree, root: WidgetId, role: Role) -> usize {
5756        let mut walker = vec![root];
5757        let mut n = 0;
5758        while let Some(id) = walker.pop() {
5759            if tree.accessibility_node(id).role() == role {
5760                n += 1;
5761            }
5762            for c in tree.children(id) {
5763                walker.push(c);
5764            }
5765        }
5766        n
5767    }
5768
5769    /// Collect the (y, height) bounds of the materialised `Role::Row`
5770    /// widgets, sorted by y.
5771    fn row_spans(tree: &WidgetTree, root: WidgetId) -> Vec<(f32, f32)> {
5772        let mut walker = vec![root];
5773        let mut spans = Vec::new();
5774        while let Some(id) = walker.pop() {
5775            if tree.accessibility_node(id).role() == Role::Row {
5776                let b = tree.bounds(id);
5777                spans.push((b.y, b.height));
5778            }
5779            for c in tree.children(id) {
5780                walker.push(c);
5781            }
5782        }
5783        spans.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
5784        spans
5785    }
5786
5787    #[test]
5788    fn rows_rebuild_during_scrollbar_thumb_drag() {
5789        // The reason `TreeBodyPane` exists — see `common::thumb_drag_test`'s
5790        // module docs for the invariant, and for why every virtualized view
5791        // asserts it through the same driver.
5792        let model = TreeModel::new();
5793        for i in 0..500 {
5794            model.insert_root(i, "root");
5795        }
5796        let proxy = SortFilterTreeModel::new(model);
5797        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5798        let table = tree.add(
5799            TreeTableView::from_projection(proxy.clone())
5800                .add_column(name_col())
5801                .row_height(20.0),
5802        );
5803        crate::common::thumb_drag_test::assert_body_survives_thumb_drag(
5804            &mut tree,
5805            table,
5806            400.0,
5807            200.0,
5808            cp::HEADER_HEIGHT,
5809            "TreeTableView",
5810            |t| {
5811                let mut n = 0;
5812                let mut walker = vec![table];
5813                while let Some(id) = walker.pop() {
5814                    if t.accessibility_node(id).role() == Role::Row {
5815                        let b = t.bounds(id);
5816                        if b.y >= 0.0 && b.y < 200.0 {
5817                            n += 1;
5818                        }
5819                    }
5820                    for c in t.children(id) {
5821                        walker.push(c);
5822                    }
5823                }
5824                n
5825            },
5826        );
5827    }
5828
5829    #[test]
5830    fn exact_row_height_fn_positions_tree_rows() {
5831        let heights = [60.0_f32, 20.0, 40.0];
5832        let proxy = SortFilterTreeModel::new(sample_tree());
5833        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5834        let table = tree.add(
5835            TreeTableView::from_projection(proxy)
5836                .add_column(name_col())
5837                .show_header(false)
5838                .row_height_fn(move |i| heights.get(i).copied().unwrap_or(28.0)),
5839        );
5840        tree.layout(SizeProposal {
5841            width: Some(400.0),
5842            height: Some(300.0),
5843        });
5844
5845        // Roots only: docs (60), src (20).
5846        let spans = row_spans(&tree, table);
5847        assert_eq!(spans.len(), 2);
5848        assert!((spans[0].0 - 0.0).abs() < 0.01 && (spans[0].1 - 60.0).abs() < 0.01);
5849        assert!((spans[1].0 - 60.0).abs() < 0.01 && (spans[1].1 - 20.0).abs() < 0.01);
5850    }
5851
5852    #[test]
5853    fn auto_row_height_measures_tree_cells() {
5854        #[derive(Debug)]
5855        struct FixedLeaf(f32, f32);
5856        impl Widget for FixedLeaf {
5857            fn layout_response(
5858                &self,
5859                _proposal: SizeProposal,
5860                _ctx: &LayoutContext,
5861            ) -> teksilo_core::widget::LayoutResponse {
5862                Size::new(self.0, self.1).into()
5863            }
5864        }
5865        let col = Column::<&str>::new("name", lit!("Name"), |_row, _: &CellContext| {
5866            Box::new(FixedLeaf(50.0, 30.0))
5867        })
5868        .width(ColumnWidth::Flex(1.0));
5869        let proxy = SortFilterTreeModel::new(sample_tree());
5870        let docs = proxy.tree().root(0);
5871        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5872        let table = tree.add(
5873            TreeTableView::from_projection(proxy.clone())
5874                .add_column(col)
5875                .show_header(false)
5876                .auto_row_height(50.0),
5877        );
5878        tree.layout(SizeProposal {
5879            width: Some(400.0),
5880            height: Some(300.0),
5881        });
5882        tree.layout(SizeProposal {
5883            width: Some(400.0),
5884            height: Some(300.0),
5885        });
5886
5887        // Rows measured to 30 from the 50 estimate.
5888        let spans = row_spans(&tree, table);
5889        assert!(
5890            (spans[1].0 - 30.0).abs() < 0.01,
5891            "row 1 should sit at measured 30, got {}",
5892            spans[1].0
5893        );
5894
5895        // Expanding docs (flat 0) keeps measured heights — the
5896        // divergence is the toggled row, not a full reset, so the
5897        // expanded children appear right below the measured row 0.
5898        proxy.expand(docs);
5899        tree.layout(SizeProposal {
5900            width: Some(400.0),
5901            height: Some(300.0),
5902        });
5903        tree.layout(SizeProposal {
5904            width: Some(400.0),
5905            height: Some(300.0),
5906        });
5907        let spans = row_spans(&tree, table);
5908        assert_eq!(spans.len(), 4); // docs, readme, guide, src
5909        assert!(
5910            (spans[1].0 - 30.0).abs() < 0.01,
5911            "measured row 0 must survive the expand, got {}",
5912            spans[1].0
5913        );
5914    }
5915
5916    // ── Row reorder (Stage 5) ──────────────────────────────────────────────
5917
5918    /// Full drag gesture: down on source, move to cross the threshold, move to
5919    /// target, up.
5920    fn drag(tree: &mut WidgetTree, from: teksilo_canvas::Point, to: teksilo_canvas::Point) {
5921        use teksilo_canvas::Point;
5922        use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
5923        tree.dispatch_event(WidgetEvent::PointerDown {
5924            position: from,
5925            button: PointerButton::Primary,
5926            modifiers: Modifiers::NONE,
5927        });
5928        tree.dispatch_event(WidgetEvent::PointerMove {
5929            position: Point::new(from.x + 10.0, from.y),
5930        });
5931        tree.dispatch_event(WidgetEvent::PointerMove { position: to });
5932        tree.dispatch_event(WidgetEvent::PointerUp {
5933            position: to,
5934            button: PointerButton::Primary,
5935            modifiers: Modifiers::NONE,
5936        });
5937    }
5938
5939    #[test]
5940    fn drag_reorders_roots_after() {
5941        use teksilo_canvas::Point;
5942        let proxy = SortFilterTreeModel::new(sample_tree());
5943        proxy.collapse_all(); // roots only: docs@0, src@1
5944        let docs = proxy.tree().root(0);
5945        let src = proxy.tree().root(1);
5946        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5947        tree.add(
5948            TreeTableView::from_projection(proxy.clone())
5949                .add_column(name_col())
5950                .reorderable(true)
5951                .row_height(20.0),
5952        );
5953        tree.layout(SizeProposal {
5954            width: Some(400.0),
5955            height: Some(300.0),
5956        });
5957        let h = cp::HEADER_HEIGHT;
5958        // Drag docs (flat 0, [h, h+20]) onto the bottom third of src (flat 1,
5959        // [h+20, h+40]) → After src.
5960        drag(
5961            &mut tree,
5962            Point::new(40.0, h + 10.0),
5963            Point::new(40.0, h + 38.0),
5964        );
5965        assert_eq!(proxy.tree().root_count(), 2);
5966        assert_eq!(proxy.tree().root(0), src, "src becomes the first root");
5967        assert_eq!(proxy.tree().root(1), docs, "docs moves after src");
5968    }
5969
5970    #[test]
5971    fn drag_into_own_descendant_is_refused() {
5972        use teksilo_canvas::Point;
5973        let proxy = SortFilterTreeModel::new(sample_tree());
5974        proxy.expand_all(); // docs@0, readme@1, guide@2, src@3, main.rs@4
5975        let docs = proxy.tree().root(0);
5976        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
5977        tree.add(
5978            TreeTableView::from_projection(proxy.clone())
5979                .add_column(name_col())
5980                .reorderable(true)
5981                .row_height(20.0),
5982        );
5983        tree.layout(SizeProposal {
5984            width: Some(400.0),
5985            height: Some(300.0),
5986        });
5987        let h = cp::HEADER_HEIGHT;
5988        // Drag docs (flat 0) into the middle third of readme (flat 1, a child
5989        // of docs) → cycle → refused; tree unchanged, no panic.
5990        drag(
5991            &mut tree,
5992            Point::new(40.0, h + 10.0),
5993            Point::new(40.0, h + 30.0),
5994        );
5995        assert_eq!(proxy.tree().parent(docs), None, "docs stays a root");
5996        assert_eq!(proxy.tree().root_count(), 2);
5997    }
5998
5999    #[test]
6000    fn reorder_is_suppressed_while_sorted() {
6001        use teksilo_canvas::Point;
6002        let proxy = SortFilterTreeModel::new(sample_tree());
6003        proxy.collapse_all();
6004        let docs = proxy.tree().root(0);
6005        let src = proxy.tree().root(1);
6006        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
6007        let id = tree.add(
6008            TreeTableView::from_projection(proxy.clone())
6009                .add_column(name_col())
6010                .reorderable(true)
6011                .row_height(20.0),
6012        );
6013        tree.layout(SizeProposal {
6014            width: Some(400.0),
6015            height: Some(300.0),
6016        });
6017        // Activate a sort: the drop gate must refuse the reorder (a manual
6018        // reorder is meaningless once the visible order is sort-driven).
6019        tree.widget_as_any(id)
6020            .and_then(|a| a.downcast_ref::<TreeTableView<&str>>())
6021            .expect("TreeTableView")
6022            .set_sort(Some("name"), teksilo_data::SortDirection::Ascending);
6023        let h = cp::HEADER_HEIGHT;
6024        drag(
6025            &mut tree,
6026            Point::new(40.0, h + 10.0),
6027            Point::new(40.0, h + 38.0),
6028        );
6029        assert_eq!(proxy.tree().root(0), docs, "docs unchanged while sorted");
6030        assert_eq!(proxy.tree().root(1), src, "src unchanged while sorted");
6031    }
6032
6033    #[test]
6034    fn keyed_selection_survives_collapse() {
6035        // Keyed (identity) selection: a node selected by NodeId stays selected
6036        // when its parent collapses (the row scrolls out of the projection).
6037        // The prune on every projection change must NOT drop a collapsed-but-
6038        // present node — existence is checked against the tree, not visibility.
6039        use teksilo_data::{KeyedSelectionModel, SelectionMode};
6040        let proxy = SortFilterTreeModel::new(sample_tree());
6041        proxy.expand_all();
6042        let docs = proxy.tree().root(0);
6043        let readme = proxy.tree().children(docs)[0];
6044        let keyed = KeyedSelectionModel::<NodeId>::new(SelectionMode::Multi);
6045        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
6046        tree.add(
6047            TreeTableView::from_projection(proxy.clone())
6048                .add_column(name_col())
6049                .selection_mode(TableSelectionMode::MultiRow)
6050                .keyed_selection(keyed.clone())
6051                .row_height(20.0),
6052        );
6053        tree.layout(SizeProposal {
6054            width: Some(400.0),
6055            height: Some(300.0),
6056        });
6057
6058        keyed.select(readme);
6059        assert!(keyed.is_selected(&readme));
6060
6061        // Collapse docs → readme leaves the visible projection, bumping the
6062        // version (which runs the prune). It must survive (still in the tree).
6063        proxy.collapse(docs);
6064        assert!(
6065            keyed.is_selected(&readme),
6066            "a collapsed-but-present node stays selected by identity"
6067        );
6068
6069        // Re-expand → still selected.
6070        proxy.expand(docs);
6071        assert!(keyed.is_selected(&readme));
6072    }
6073
6074    // ── Horizontal scroll ───────────────────────────────────────────────
6075    //
6076    // TreeTableView reuses TableView's `body::BodyRow` / `header::HeaderRow`
6077    // / `layout::` pane machinery wholesale, so these mirror the TableView
6078    // suite (`table_view::tests`) at reduced breadth: enough to confirm the
6079    // shared plumbing threads through this widget's own `build()` /
6080    // `place_children()` / `paint()` / `on_scroll` correctly, not to
6081    // re-verify the pane math itself (already unit-tested in `layout.rs`
6082    // and exercised end-to-end by TableView's suite).
6083
6084    /// Expand any AT-transparent id (the pane-band wrapper `RowBand`
6085    /// inserts under column pinning — see `table_view::body`'s module
6086    /// docs — never calls `set_role`, so it reads back as the
6087    /// `AccessNodeBuilder` default `Role::Unknown`) into its own children,
6088    /// recursively.
6089    fn tt_flatten_through_bands(tree: &WidgetTree, ids: Vec<WidgetId>) -> Vec<WidgetId> {
6090        let mut out = Vec::new();
6091        for id in ids {
6092            if matches!(
6093                tree.accessibility_node(id).role(),
6094                Role::GenericContainer | Role::Unknown
6095            ) {
6096                out.extend(tt_flatten_through_bands(tree, tree.children(id)));
6097            } else {
6098                out.push(id);
6099            }
6100        }
6101        out
6102    }
6103
6104    /// The first BODY `Role::Row` (band-flattened children include a
6105    /// `Role::Cell`) — distinguishes it from the header, which shares
6106    /// `Role::Row` but has only `Role::ColumnHeader` children.
6107    fn tt_first_body_row_id(tree: &WidgetTree, root: WidgetId) -> WidgetId {
6108        let mut walker = vec![root];
6109        while let Some(id) = walker.pop() {
6110            if tree.accessibility_node(id).role() == Role::Row {
6111                let flat = tt_flatten_through_bands(tree, tree.children(id));
6112                if flat
6113                    .iter()
6114                    .any(|&c| tree.accessibility_node(c).role() == Role::Cell)
6115                {
6116                    return id;
6117                }
6118            }
6119            for c in tree.children(id) {
6120                walker.push(c);
6121            }
6122        }
6123        panic!("no body Role::Row found");
6124    }
6125
6126    fn tt_header_row_id(tree: &WidgetTree, root: WidgetId) -> WidgetId {
6127        let mut walker = vec![root];
6128        while let Some(id) = walker.pop() {
6129            if tree.accessibility_node(id).role() == Role::Row {
6130                let flat = tt_flatten_through_bands(tree, tree.children(id));
6131                if !flat.is_empty()
6132                    && flat
6133                        .iter()
6134                        .all(|&c| tree.accessibility_node(c).role() == Role::ColumnHeader)
6135                {
6136                    return id;
6137                }
6138            }
6139            for c in tree.children(id) {
6140                walker.push(c);
6141            }
6142        }
6143        panic!("no header Role::Row found");
6144    }
6145
6146    fn tt_body_row_cells(tree: &WidgetTree, root: WidgetId) -> Vec<WidgetId> {
6147        tt_flatten_through_bands(tree, tree.children(tt_first_body_row_id(tree, root)))
6148    }
6149
6150    fn tt_header_row_cells(tree: &WidgetTree, root: WidgetId) -> Vec<WidgetId> {
6151        tt_flatten_through_bands(tree, tree.children(tt_header_row_id(tree, root)))
6152    }
6153
6154    /// Leading `lead` (60px, pinned) + unpinned `mid` (`middle_w` px) +
6155    /// Trailing `trail` (60px, pinned), over the default `sample_tree()`
6156    /// (roots collapsed — 2 visible rows).
6157    fn build_tt_pinned_scroll_table(middle_w: f32, table_w: f32) -> (WidgetTree, WidgetId) {
6158        let proxy = SortFilterTreeModel::new(sample_tree());
6159        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
6160        let id = tree.add(
6161            TreeTableView::from_projection(proxy)
6162                .add_column(
6163                    Column::<&'static str>::new("lead", lit!("Lead"), |row, _: &CellContext| {
6164                        Box::new(crate::primitives::TextWidget::new(lit!(*row)))
6165                    })
6166                    .width(ColumnWidth::Fixed(60.0))
6167                    .pinned(PinnedSide::Leading),
6168                )
6169                .add_column(
6170                    Column::<&'static str>::new("mid", lit!("Mid"), |row, _: &CellContext| {
6171                        Box::new(crate::primitives::TextWidget::new(lit!(*row)))
6172                    })
6173                    .width(ColumnWidth::Fixed(middle_w)),
6174                )
6175                .add_column(
6176                    Column::<&'static str>::new("trail", lit!("Trail"), |_row, _: &CellContext| {
6177                        Box::new(crate::primitives::TextWidget::new(lit!("x")))
6178                    })
6179                    .width(ColumnWidth::Fixed(60.0))
6180                    .pinned(PinnedSide::Trailing),
6181                )
6182                .row_height(20.0),
6183        );
6184        tree.layout(SizeProposal {
6185            width: Some(table_w),
6186            height: Some(200.0),
6187        });
6188        (tree, id)
6189    }
6190
6191    /// `n` unpinned Fixed columns of `col_w` px each.
6192    fn build_tt_wide_unpinned_table(col_w: f32, n: usize, table_w: f32) -> (WidgetTree, WidgetId) {
6193        let proxy = SortFilterTreeModel::new(sample_tree());
6194        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
6195        let mut tv = TreeTableView::from_projection(proxy);
6196        for i in 0..n {
6197            let col_id = format!("c{i}");
6198            tv = tv.add_column(
6199                Column::<&'static str>::new(
6200                    col_id.clone(),
6201                    lit!(col_id.clone()),
6202                    |row, _: &CellContext| Box::new(crate::primitives::TextWidget::new(lit!(*row))),
6203                )
6204                .width(ColumnWidth::Fixed(col_w)),
6205            );
6206        }
6207        // Cell selection: this fixture drives the *column* cursor.
6208        let id = tree.add(
6209            tv.row_height(20.0)
6210                .selection_mode(TableSelectionMode::MultiCell)
6211                .cell_selection(CellSelectionModel::new(TableSelectionMode::MultiCell)),
6212        );
6213        tree.layout(SizeProposal {
6214            width: Some(table_w),
6215            height: Some(200.0),
6216        });
6217        (tree, id)
6218    }
6219
6220    fn tt_scroll_x(tree: &WidgetTree, id: WidgetId) -> f32 {
6221        tree.widget_as_any(id)
6222            .unwrap()
6223            .downcast_ref::<TreeTableView<&'static str>>()
6224            .unwrap()
6225            .scroll_x_signal()
6226            .get()
6227    }
6228
6229    fn tt_max_scroll_x(tree: &WidgetTree, id: WidgetId) -> f32 {
6230        tree.widget_as_any(id)
6231            .unwrap()
6232            .downcast_ref::<TreeTableView<&'static str>>()
6233            .unwrap()
6234            .max_scroll_x_signal()
6235            .get()
6236    }
6237
6238    fn tt_set_scroll_x(tree: &WidgetTree, id: WidgetId, x: f32) {
6239        tree.widget_as_any(id)
6240            .unwrap()
6241            .downcast_ref::<TreeTableView<&'static str>>()
6242            .unwrap()
6243            .scroll_x_signal()
6244            .set(x);
6245    }
6246
6247    #[test]
6248    fn tt_scroll_x_clamps_after_the_pane_widens() {
6249        let (mut tree, id) = build_tt_wide_unpinned_table(200.0, 3, 300.0);
6250        let max = tt_max_scroll_x(&tree, id);
6251        assert!(max > 0.0, "columns must overflow the narrow table");
6252        tt_set_scroll_x(&tree, id, max);
6253        assert_eq!(tt_scroll_x(&tree, id), max);
6254
6255        tree.layout(SizeProposal {
6256            width: Some(700.0),
6257            height: Some(200.0),
6258        });
6259        assert_eq!(tt_max_scroll_x(&tree, id), 0.0, "content now fits");
6260        assert_eq!(
6261            tt_scroll_x(&tree, id),
6262            0.0,
6263            "scroll_x must clamp down with the new (smaller) max_scroll_x"
6264        );
6265    }
6266
6267    #[test]
6268    fn tt_pinned_columns_keep_their_bands_under_scroll() {
6269        let (mut tree, id) = build_tt_pinned_scroll_table(400.0, 200.0);
6270
6271        let cells0 = tt_body_row_cells(&tree, id);
6272        assert_eq!(cells0.len(), 3, "lead, mid, trail");
6273        let lead_x0 = tree.bounds(cells0[0]).x;
6274        let mid_x0 = tree.bounds(cells0[1]).x;
6275        let trail_x0 = tree.bounds(cells0[2]).x;
6276
6277        // `tt_first_body_row_id` returns the `TreeRowA11y` wrapper (the
6278        // `Role::Row` carrier); its sole child is the `.a11y_hidden()`
6279        // `BodyRow`, one level further in, whose own children are the
6280        // pane bands.
6281        let tree_row_a11y = tt_first_body_row_id(&tree, id);
6282        let body_row = tree.children(tree_row_a11y)[0];
6283        let raw_bands = tree.children(body_row);
6284        assert_eq!(raw_bands.len(), 3, "leading + middle + trailing bands");
6285        assert!(!tree.widget_clips_children(raw_bands[0]));
6286        assert!(
6287            tree.widget_clips_children(raw_bands[1]),
6288            "the Middle band must clip"
6289        );
6290        assert!(!tree.widget_clips_children(raw_bands[2]));
6291
6292        let max = tt_max_scroll_x(&tree, id);
6293        assert!(max > 0.0);
6294        tt_set_scroll_x(&tree, id, 50.0_f32.min(max));
6295        tree.layout(SizeProposal {
6296            width: Some(200.0),
6297            height: Some(200.0),
6298        });
6299
6300        let cells1 = tt_body_row_cells(&tree, id);
6301        assert_eq!(tree.bounds(cells1[0]).x, lead_x0, "Leading never moves");
6302        assert_eq!(tree.bounds(cells1[2]).x, trail_x0, "Trailing never moves");
6303        let mid_x1 = tree.bounds(cells1[1]).x;
6304        assert!(
6305            (mid_x1 - (mid_x0 - 50.0)).abs() < 0.5,
6306            "the Middle column shifts left by exactly scroll_x: got {mid_x1}, want ~{}",
6307            mid_x0 - 50.0
6308        );
6309    }
6310
6311    #[test]
6312    fn tt_header_and_body_x_offsets_agree_under_scroll() {
6313        let (mut tree, id) = build_tt_pinned_scroll_table(400.0, 200.0);
6314        tt_set_scroll_x(&tree, id, 37.0);
6315        tree.layout(SizeProposal {
6316            width: Some(200.0),
6317            height: Some(200.0),
6318        });
6319
6320        let header_cells = tt_header_row_cells(&tree, id);
6321        let body_cells = tt_body_row_cells(&tree, id);
6322        assert_eq!(header_cells.len(), body_cells.len());
6323        for (i, (&h, &b)) in header_cells.iter().zip(body_cells.iter()).enumerate() {
6324            let hx = tree.bounds(h).x;
6325            let bx = tree.bounds(b).x;
6326            assert!(
6327                (hx - bx).abs() < 0.01,
6328                "column {i}: header x {hx} must equal body x {bx}"
6329            );
6330        }
6331    }
6332
6333    #[test]
6334    fn tt_shift_wheel_scrolls_horizontally() {
6335        use teksilo_canvas::Point;
6336        use teksilo_core::event::{Modifiers, ScrollDelta, WidgetEvent};
6337        let (mut tree, id) = build_tt_wide_unpinned_table(200.0, 4, 300.0);
6338        tree.pointer_move(Point::new(50.0, 60.0));
6339        tree.dispatch_event(WidgetEvent::Scroll {
6340            delta: ScrollDelta::Lines { x: 0.0, y: 3.0 },
6341            modifiers: Modifiers::SHIFT,
6342        });
6343        tree.layout(SizeProposal {
6344            width: Some(300.0),
6345            height: Some(200.0),
6346        });
6347        assert!(
6348            tt_scroll_x(&tree, id) > 0.0,
6349            "Shift+wheel must remap a vertical-only wheel to horizontal scroll"
6350        );
6351        let any = tree.widget_as_any(id).unwrap();
6352        let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6353        assert_eq!(
6354            tt.scroll_y_signal().get(),
6355            0.0,
6356            "Shift+wheel must not also scroll vertically"
6357        );
6358    }
6359
6360    #[test]
6361    fn tt_ensure_col_visible_follows_focus_in_both_directions() {
6362        use teksilo_core::event::{Key, Modifiers};
6363        let (mut tree, id) = build_tt_wide_unpinned_table(150.0, 5, 300.0);
6364        tree.focus(id);
6365        {
6366            let any = tree.widget_as_any(id).unwrap();
6367            any.downcast_ref::<TreeTableView<&'static str>>()
6368                .unwrap()
6369                .set_focused_cell(0, 0);
6370        }
6371        assert_eq!(tt_scroll_x(&tree, id), 0.0);
6372
6373        tree.press_key(Key::End, Modifiers::NONE);
6374        {
6375            let any = tree.widget_as_any(id).unwrap();
6376            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6377            assert_eq!(tt.focused_cell_signal().get(), Some((0, 4)));
6378        }
6379        assert!(
6380            tt_scroll_x(&tree, id) > 0.0,
6381            "ensure-column-visible must scroll right to reveal column 4"
6382        );
6383
6384        tree.press_key(Key::Home, Modifiers::NONE);
6385        {
6386            let any = tree.widget_as_any(id).unwrap();
6387            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6388            assert_eq!(tt.focused_cell_signal().get(), Some((0, 0)));
6389        }
6390        assert_eq!(
6391            tt_scroll_x(&tree, id),
6392            0.0,
6393            "ensure-column-visible must scroll left back to 0 for column 0"
6394        );
6395    }
6396
6397    // ── Column header drag-to-reorder ───────────────────────────────────
6398    //
6399    // `HeaderCell` escalates a header press into a `ColumnReorderDragData`
6400    // drag past a 5px threshold (`table_view::header`); the drop-target
6401    // half — hover feedback, insertion-slot math, pane classification,
6402    // `column_order_signal`/`column_pinning_signal` writes — is
6403    // `header::attach_header_reorder_handlers`, shared verbatim with
6404    // `TableView` (moved there by this commit, not duplicated). These
6405    // tests drive the mechanism end-to-end through real pointer events
6406    // (`drag`, defined above for row reorder — the header strip is just
6407    // another drop target) rather than the imperative
6408    // `set_column_order`/`set_column_pinning` setters already covered
6409    // above, and additionally confirm the tree column carries no special
6410    // case through the shared path: its indent/twist gutter and the
6411    // ArrowLeft/Right expand-collapse binding both re-resolve from
6412    // `display_indices` on every rebuild, so they follow it to wherever a
6413    // drag lands it — including into a pinned pane, same as any other
6414    // column.
6415
6416    /// Column `id` at a distinct `width`, so a header/body cell's bounds
6417    /// alone identify which column it is after a reorder.
6418    fn reorder_col(id: &'static str, width: f32) -> Column<&'static str> {
6419        Column::<&'static str>::new(id, lit!(id), |row, _: &CellContext| {
6420            Box::new(crate::primitives::TextWidget::new(lit!(*row)))
6421        })
6422        .width(ColumnWidth::Fixed(width))
6423    }
6424
6425    /// Four unpinned columns "a" (60px, the default tree column since it's
6426    /// declared first), "b" (70px), "c" (80px), "d" (90px) — over
6427    /// `sample_tree()` (2 visible roots, "docs" has children).
6428    fn build_tt_reorder_table() -> (WidgetTree, WidgetId, SortFilterTreeModel<&'static str>) {
6429        let proxy = SortFilterTreeModel::new(sample_tree());
6430        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
6431        let id = tree.add(
6432            TreeTableView::from_projection(proxy.clone())
6433                .add_column(reorder_col("a", 60.0))
6434                .add_column(reorder_col("b", 70.0))
6435                .add_column(reorder_col("c", 80.0))
6436                .add_column(reorder_col("d", 90.0))
6437                .row_height(20.0),
6438        );
6439        tree.layout(SizeProposal {
6440            width: Some(400.0),
6441            height: Some(200.0),
6442        });
6443        (tree, id, proxy)
6444    }
6445
6446    /// Whether `id` or any descendant is a `TwistArrow` — the indent/twist
6447    /// gutter `TreeBodyPane` wraps around whichever cell is currently the
6448    /// tree column. Identified by `widget_type_name` (a plain `type_name`
6449    /// readout, no opt-in needed) rather than `widget_as_any` downcast,
6450    /// since `TwistArrow` — a layout-only primitive nobody has needed to
6451    /// downcast before — doesn't override `Widget::as_any`.
6452    fn tt_subtree_has_twist_arrow(tree: &WidgetTree, id: WidgetId) -> bool {
6453        if tree.widget_type_name(id) == Some("teksilo_widgets::primitives::twist_arrow::TwistArrow")
6454        {
6455            return true;
6456        }
6457        tree.children(id)
6458            .into_iter()
6459            .any(|c| tt_subtree_has_twist_arrow(tree, c))
6460    }
6461
6462    #[test]
6463    fn header_drag_reorders_column_before_an_earlier_sibling() {
6464        // Drag "d" (display 3) to a slot strictly inside the unpinned band
6465        // (before "b") — a plain reorder with no pane-boundary side effect.
6466        let (mut tree, id, _proxy) = build_tt_reorder_table();
6467        let header = tt_header_row_cells(&tree, id);
6468        assert_eq!(header.len(), 4);
6469        let from = tree.bounds(header[3]).center(); // "d"
6470        let to = teksilo_canvas::Point::new(65.0, from.y); // inside "b"'s leading half
6471        drag(&mut tree, from, to);
6472
6473        {
6474            let any = tree.widget_as_any(id).unwrap();
6475            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6476            assert_eq!(
6477                tt.column_order_signal().get(),
6478                vec![
6479                    "a".to_string(),
6480                    "d".to_string(),
6481                    "b".to_string(),
6482                    "c".to_string()
6483                ],
6484                "dropping \"d\" before \"b\" must write [a, d, b, c]"
6485            );
6486            assert_eq!(
6487                tt.column_pinning_signal().get().get("d"),
6488                None,
6489                "a mid-band drop must not pin the moved column"
6490            );
6491        }
6492
6493        // display_indices re-derive: a fresh layout must actually reflow
6494        // the header cells into the new order (Fixed widths, so an exact
6495        // width sequence identifies each column unambiguously).
6496        tree.layout(SizeProposal {
6497            width: Some(400.0),
6498            height: Some(200.0),
6499        });
6500        let after = tt_header_row_cells(&tree, id);
6501        let widths: Vec<f32> = after.iter().map(|&c| tree.bounds(c).width).collect();
6502        assert!(
6503            widths
6504                .iter()
6505                .zip([60.0, 90.0, 70.0, 80.0])
6506                .all(|(&w, want)| (w - want).abs() < 0.5),
6507            "header cells must reflow to widths [60, 90, 70, 80], got {widths:?}"
6508        );
6509    }
6510
6511    #[test]
6512    fn header_drag_to_the_leading_edge_pins_the_dropped_column() {
6513        // The pane-boundary classification in `attach_header_reorder_handlers`
6514        // (`insertion_display_idx <= panes.leading_count`) is the exact same
6515        // code TableView's header shares — dropping at the very leading
6516        // edge pins the dragged column Leading, growing the leading pane.
6517        let (mut tree, id, _proxy) = build_tt_reorder_table();
6518        let header = tt_header_row_cells(&tree, id);
6519        let from = tree.bounds(header[3]).center(); // "d"
6520        let to = teksilo_canvas::Point::new(5.0, from.y); // before "a"
6521        drag(&mut tree, from, to);
6522
6523        let any = tree.widget_as_any(id).unwrap();
6524        let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6525        assert_eq!(
6526            tt.column_order_signal().get(),
6527            vec![
6528                "d".to_string(),
6529                "a".to_string(),
6530                "b".to_string(),
6531                "c".to_string()
6532            ],
6533        );
6534        assert_eq!(
6535            tt.column_pinning_signal().get().get("d").copied(),
6536            Some(PinnedSide::Leading),
6537            "dropping at the leading edge must pin the column, same as TableView"
6538        );
6539    }
6540
6541    #[test]
6542    fn header_drag_reorder_remaps_focused_and_editing_cell_to_follow_their_columns() {
6543        // `focused_cell` / `editing_cell` store `(row, display_position)` —
6544        // `imperative::remap_cell_state` (already exercised by the
6545        // `column_pinning_remaps_*` tests above via the imperative setters)
6546        // must fire the same way when the reorder arrives through a real
6547        // header drag.
6548        let (mut tree, id, _proxy) = build_tt_reorder_table();
6549        {
6550            let any = tree.widget_as_any(id).unwrap();
6551            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6552            tt.set_focused_cell(0, 1); // "b"
6553            tt.begin_edit(0, "d"); // "d"
6554        }
6555
6556        let header = tt_header_row_cells(&tree, id);
6557        let from = tree.bounds(header[3]).center(); // "d"
6558        let to = teksilo_canvas::Point::new(65.0, from.y); // before "b" — see the plain-reorder test above
6559        drag(&mut tree, from, to);
6560        tree.layout(SizeProposal {
6561            width: Some(400.0),
6562            height: Some(200.0),
6563        });
6564
6565        let any = tree.widget_as_any(id).unwrap();
6566        let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6567        assert_eq!(
6568            tt.column_order_signal().get(),
6569            vec![
6570                "a".to_string(),
6571                "d".to_string(),
6572                "b".to_string(),
6573                "c".to_string()
6574            ],
6575        );
6576        assert_eq!(
6577            tt.focused_cell_signal().get(),
6578            Some((0, 2)),
6579            "focus must follow \"b\" to its new display position"
6580        );
6581        assert_eq!(
6582            tt.editing_cell_signal().get(),
6583            Some((0, 1)),
6584            "the open editor must follow \"d\" to its new display position"
6585        );
6586    }
6587
6588    #[test]
6589    fn header_drag_moves_the_tree_column_and_twist_follows() {
6590        // The tree column carries no special case anywhere in the reorder
6591        // path: `is_tree_column` in `TreeBodyPane::build` is a plain
6592        // `display_pos == tree_display_pos` comparison, and
6593        // `tree_display_pos` is re-resolved from `display_indices` on
6594        // every rebuild (see the comment on `TreeTableView::build`'s
6595        // `key_cfg.tree_column_display_pos`). So dragging "a" (the tree
6596        // column) to a later, unpinned slot must carry the indent/twist
6597        // gutter with it, and ArrowLeft/Right must stay bound to it there.
6598        let (mut tree, id, proxy) = build_tt_reorder_table();
6599        let header = tt_header_row_cells(&tree, id);
6600        let from = tree.bounds(header[0]).center(); // "a", the tree column
6601        let to = teksilo_canvas::Point::new(220.0, from.y); // lands "a" between "c" and "d"
6602        drag(&mut tree, from, to);
6603
6604        {
6605            let any = tree.widget_as_any(id).unwrap();
6606            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6607            assert_eq!(
6608                tt.column_order_signal().get(),
6609                vec![
6610                    "b".to_string(),
6611                    "c".to_string(),
6612                    "a".to_string(),
6613                    "d".to_string()
6614                ],
6615            );
6616            assert_eq!(
6617                tt.column_pinning_signal().get().get("a"),
6618                None,
6619                "a mid-band drop must not pin the tree column either"
6620            );
6621        }
6622        tree.layout(SizeProposal {
6623            width: Some(400.0),
6624            height: Some(200.0),
6625        });
6626
6627        let body = tt_body_row_cells(&tree, id);
6628        assert_eq!(body.len(), 4);
6629        assert!(
6630            !tt_subtree_has_twist_arrow(&tree, body[0]),
6631            "\"b\" is no longer the tree column"
6632        );
6633        assert!(
6634            !tt_subtree_has_twist_arrow(&tree, body[1]),
6635            "\"c\" is no longer the tree column"
6636        );
6637        assert!(
6638            tt_subtree_has_twist_arrow(&tree, body[2]),
6639            "the twist must follow \"a\" to its new display position"
6640        );
6641        assert!(
6642            !tt_subtree_has_twist_arrow(&tree, body[3]),
6643            "\"d\" is not the tree column"
6644        );
6645
6646        // ArrowLeft/Right stay bound to the tree column at its new slot.
6647        use teksilo_core::event::{Key, Modifiers};
6648        tree.focus(id);
6649        {
6650            let any = tree.widget_as_any(id).unwrap();
6651            any.downcast_ref::<TreeTableView<&'static str>>()
6652                .unwrap()
6653                .set_focused_cell(0, 2); // row 0 ("docs"), tree column's new slot
6654        }
6655        tree.press_key(Key::ArrowRight, Modifiers::NONE);
6656        assert_eq!(
6657            proxy.visible_count(),
6658            4,
6659            "ArrowRight on the relocated tree column must expand \"docs\""
6660        );
6661        tree.press_key(Key::ArrowLeft, Modifiers::NONE);
6662        assert_eq!(proxy.visible_count(), 2, "and ArrowLeft collapses it again");
6663    }
6664
6665    #[test]
6666    fn header_drag_from_a_different_table_is_rejected() {
6667        // Each TreeTableView mints its own `table_id`; a drop whose
6668        // `ColumnReorderDragData::source_table_id` doesn't match the
6669        // hovered header's own id must be a no-op — otherwise dragging a
6670        // column between two independent tree-tables on screen would
6671        // silently reorder the wrong one.
6672        use crate::primitives::{FixedSize, HStack};
6673        let proxy1 = SortFilterTreeModel::new(sample_tree());
6674        let proxy2 = SortFilterTreeModel::new(sample_tree());
6675        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
6676
6677        let tt1 = TreeTableView::from_projection(proxy1)
6678            .add_column(reorder_col("x", 100.0))
6679            .add_column(reorder_col("y", 100.0))
6680            .row_height(20.0);
6681        let order1 = tt1.column_order_signal().clone();
6682        let id1 = tree.add(tt1);
6683        let tt2 = TreeTableView::from_projection(proxy2)
6684            .add_column(reorder_col("x", 100.0))
6685            .add_column(reorder_col("y", 100.0))
6686            .row_height(20.0);
6687        let order2 = tt2.column_order_signal().clone();
6688        let id2 = tree.add(tt2);
6689
6690        let fixed1 = tree.add(FixedSize::new().width(200.0).height(150.0).child_id(id1));
6691        let fixed2 = tree.add(FixedSize::new().width(200.0).height(150.0).child_id(id2));
6692        tree.add(HStack::new().add_child(fixed1).add_child(fixed2));
6693        tree.layout(SizeProposal {
6694            width: Some(400.0),
6695            height: Some(150.0),
6696        });
6697
6698        // tt1 occupies window x[0, 200), tt2 x[200, 400) — drag tt1's
6699        // leading header cell into tt2's header strip.
6700        let from = tree.bounds(tt_header_row_cells(&tree, id1)[0]).center();
6701        let to = teksilo_canvas::Point::new(250.0, from.y); // inside tt2's "x" cell
6702        drag(&mut tree, from, to);
6703
6704        assert!(order1.get().is_empty(), "tt1's own order must be untouched");
6705        assert!(
6706            order2.get().is_empty(),
6707            "tt2 must reject a drop whose payload names a different table_id"
6708        );
6709    }
6710
6711    #[test]
6712    fn header_drag_released_over_the_body_does_not_trigger_foreign_row_drop() {
6713        // Regression: `on_foreign_drop` fires for "any payload NOT
6714        // recognized as this view's own row drag" — without the
6715        // `ColumnReorderDragData` bail at the top of the row-level
6716        // `on_drag_hover`/`on_drop` (added alongside wiring up header
6717        // reorder — TreeTableView never carried a `ColumnReorderDragData`
6718        // payload before), a header drag released past the header strip's
6719        // own y-range would fall through into this hatch, or into a
6720        // row-insertion-line hover affordance, for a drag the header is
6721        // already handling.
6722        use std::cell::Cell;
6723        let foreign_fired = Rc::new(Cell::new(false));
6724        let flag = foreign_fired.clone();
6725        let proxy = SortFilterTreeModel::new(sample_tree());
6726        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
6727        let id = tree.add(
6728            TreeTableView::from_projection(proxy)
6729                .add_column(name_col())
6730                .add_column(size_col())
6731                .on_foreign_drop(move |_payload, _node, _pos, _ctx| {
6732                    flag.set(true);
6733                    true
6734                })
6735                .row_height(20.0),
6736        );
6737        tree.layout(SizeProposal {
6738            width: Some(400.0),
6739            height: Some(200.0),
6740        });
6741
6742        let header = tt_header_row_cells(&tree, id);
6743        let from = tree.bounds(header[0]).center();
6744        let to = teksilo_canvas::Point::new(from.x, cp::HEADER_HEIGHT + 10.0); // below the header
6745        drag(&mut tree, from, to);
6746
6747        assert!(
6748            !foreign_fired.get(),
6749            "a column-reorder drag must never reach on_foreign_drop"
6750        );
6751        let any = tree.widget_as_any(id).unwrap();
6752        let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6753        assert!(
6754            tt.column_order_signal().get().is_empty(),
6755            "no header drop occurred either — the release point was outside the header strip"
6756        );
6757    }
6758
6759    #[test]
6760    fn header_drag_insertion_is_scroll_aware() {
6761        // The insertion-slot math (`layout::insertion_slot_at_x`) is unit
6762        // tested directly for scroll-awareness; this proves the SHARED
6763        // drop-target wiring actually reaches it under a nonzero
6764        // `scroll_x`, for TreeTableView same as TableView.
6765        let (mut tree, id) = build_tt_wide_unpinned_table(100.0, 4, 200.0);
6766        let max = tt_max_scroll_x(&tree, id);
6767        assert!(max > 0.0, "4×100px columns must overflow a 200px viewport");
6768        tt_set_scroll_x(&tree, id, max); // scrolled fully right
6769        tree.layout(SizeProposal {
6770            width: Some(200.0),
6771            height: Some(200.0),
6772        });
6773
6774        // At full scroll the 200px viewport shows logical [200, 400): "c2"
6775        // fills local [0, 100), "c3" fills local [100, 200). Dropping "c3"
6776        // at local x=10 (deep in "c2"'s own zone) must resolve against the
6777        // scrolled position and land before "c2" — an unscrolled read of
6778        // the same raw x=10 would instead land before "c0".
6779        let header = tt_header_row_cells(&tree, id);
6780        let from = tree.bounds(header[3]).center(); // "c3"
6781        let to = teksilo_canvas::Point::new(10.0, from.y);
6782        drag(&mut tree, from, to);
6783
6784        let any = tree.widget_as_any(id).unwrap();
6785        let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6786        assert_eq!(
6787            tt.column_order_signal().get(),
6788            vec![
6789                "c0".to_string(),
6790                "c1".to_string(),
6791                "c3".to_string(),
6792                "c2".to_string()
6793            ],
6794            "\"c3\" must land before \"c2\" (scroll-aware), not before \"c0\""
6795        );
6796    }
6797
6798    // ── Column resize grip (parity with TableView) ─────────────────────────
6799    //
6800    // The grip machinery lives in the shared `table_view::header::HeaderCell`,
6801    // but `TreeTableView` fills its own `HeaderCellSpec` and owns its own
6802    // `resize_state` / `resize_target` / `resize_preview_x` handles — so the
6803    // wiring is asserted here too rather than assumed from the TableView side.
6804
6805    fn tt_resize_table() -> (WidgetTree, WidgetId) {
6806        // `name` Flex(1) then `size` Fixed(60) at a 400 px viewport: `name`
6807        // spans [0, 340], `size` spans [340, 400], divider at x = 340.
6808        let proxy = SortFilterTreeModel::new(sample_tree());
6809        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
6810        let id = tree.add(
6811            TreeTableView::from_projection(proxy)
6812                .add_column(name_col())
6813                .add_column(size_col())
6814                .row_height(20.0)
6815                .show_internal_scrollbars(false),
6816        );
6817        tree.layout(SizeProposal {
6818            width: Some(400.0),
6819            height: Some(200.0),
6820        });
6821        (tree, id)
6822    }
6823
6824    fn tt_overrides(tree: &WidgetTree, id: WidgetId) -> std::collections::HashMap<String, f32> {
6825        let any = tree.widget_as_any(id).unwrap();
6826        any.downcast_ref::<TreeTableView<&'static str>>()
6827            .unwrap()
6828            .column_widths_signal()
6829            .get()
6830    }
6831
6832    #[test]
6833    fn tt_grip_reaches_into_the_next_column() {
6834        use teksilo_canvas::Point;
6835        use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
6836        let (mut tree, id) = tt_resize_table();
6837        let y = cp::HEADER_HEIGHT * 0.5;
6838        // One pixel PAST the name/size divider, i.e. inside `size`.
6839        tree.dispatch_event(WidgetEvent::PointerDown {
6840            position: Point::new(341.0, y),
6841            button: PointerButton::Primary,
6842            modifiers: Modifiers::NONE,
6843        });
6844        tree.dispatch_event(WidgetEvent::PointerMove {
6845            position: Point::new(311.0, y),
6846        });
6847        tree.dispatch_event(WidgetEvent::PointerUp {
6848            position: Point::new(311.0, y),
6849            button: PointerButton::Primary,
6850            modifiers: Modifiers::NONE,
6851        });
6852        let w = tt_overrides(&tree, id);
6853        assert!(
6854            (w.get("name").copied().unwrap_or(0.0) - 310.0).abs() < 0.5,
6855            "dragging the divider left from `size` must shrink `name` from 340 \
6856             to 310; got {w:?}"
6857        );
6858    }
6859
6860    #[test]
6861    fn tt_header_strip_paints_column_separators() {
6862        let (mut tree, _id) = tt_resize_table();
6863        let frame = tree.render();
6864        let found = frame.decorations.iter().any(|d| {
6865            let [x, y, w, h] = d.rect;
6866            (x - 339.0).abs() < 0.6
6867                && w <= 1.5
6868                && y.abs() < 0.6
6869                && (h - cp::HEADER_HEIGHT).abs() < 0.6
6870        });
6871        assert!(
6872            found,
6873            "expected a header separator at the name/size divider (x≈339); \
6874             decorations={:?}",
6875            frame.decorations.iter().map(|d| d.rect).collect::<Vec<_>>()
6876        );
6877    }
6878
6879    #[test]
6880    fn tree_column_chrome_is_clipped_to_its_column() {
6881        // The indent gutter and the twist chevron are rigid: a tree column
6882        // dragged narrower than `depth * indent + twist + gap` cannot shrink
6883        // to fit, and without a clip the chevron — and the whole label after
6884        // it — draws on top of the next column. Clipping the chrome wrapper
6885        // is what lets the grip shrink the tree column all the way to its
6886        // floor without the row bleeding sideways.
6887        let (tree, id) = tt_resize_table();
6888        // Find the first body cell of the tree column (column index 1 in the
6889        // 1-based AccessKit numbering) and check its chrome wrapper clips.
6890        let mut walker = vec![id];
6891        let mut checked = false;
6892        while let Some(node) = walker.pop() {
6893            if tree.accessibility_node(node).role() == Role::Cell {
6894                let kids = tree.children(node);
6895                if let Some(&wrapper) = kids.first()
6896                    && tree.widget_clips_children(wrapper)
6897                {
6898                    checked = true;
6899                    break;
6900                }
6901            }
6902            for c in tree.children(node) {
6903                walker.push(c);
6904            }
6905        }
6906        assert!(
6907            checked,
6908            "the tree column's indent + twist wrapper must clip its children"
6909        );
6910    }
6911
6912    /// An editable column whose delegate swaps in a real `TextInput`, so a test
6913    /// can ask where the keyboard actually went.
6914    fn editable_name_col() -> Column<&'static str> {
6915        Column::<&str>::new("name", lit!("Name"), |row, cx: &CellContext| {
6916            if cx.is_editing {
6917                Box::new(crate::text_input::TextInput::new(Signal::new(
6918                    (*row).to_string(),
6919                )))
6920            } else {
6921                Box::new(crate::primitives::TextWidget::new(lit!(*row)))
6922            }
6923        })
6924        .width(ColumnWidth::Flex(1.0))
6925        .editable(true)
6926    }
6927
6928    fn three_row_slice() -> teksilo_data::TreeDataSlice<u64, &'static str> {
6929        let slice = teksilo_data::TreeDataSlice::<u64, &'static str>::new();
6930        slice.set_source(move || {
6931            [(1_u64, "one"), (2, "two"), (3, "three")]
6932                .into_iter()
6933                .map(|(k, n)| teksilo_data::TreeRow::new(k, n, 0))
6934                .collect()
6935        });
6936        slice.reload();
6937        slice
6938    }
6939
6940    /// Two primary clicks at one point, close enough together to read as a
6941    /// double-click. `WidgetTree::click` twice would be two separate taps.
6942    fn double_click_at(tree: &mut WidgetTree, at: Point) {
6943        use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
6944        for _ in 0..2 {
6945            tree.dispatch_event(WidgetEvent::PointerDown {
6946                position: at,
6947                button: PointerButton::Primary,
6948                modifiers: Modifiers::NONE,
6949            });
6950            tree.dispatch_event(WidgetEvent::PointerUp {
6951                position: at,
6952                button: PointerButton::Primary,
6953                modifiers: Modifiers::NONE,
6954            });
6955        }
6956    }
6957
6958    /// **An open cell editor holds the keyboard.**
6959    ///
6960    /// `TableView`'s body pane has always focused into the editing cell; the
6961    /// line was left behind when the tree table was split out of it, so
6962    /// `TreeTableView`'s inline editing was reachable only with the mouse. With
6963    /// focus still on the table, every keystroke went to the table's own key
6964    /// handler instead: Escape cancelled nothing, Enter activated the row, and
6965    /// typing ran type-ahead over the value being edited.
6966    #[test]
6967    fn opening_a_cell_editor_moves_the_keyboard_into_it() {
6968        let slice = three_row_slice();
6969        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
6970        let id = tree.add(
6971            TreeTableView::from_source(slice)
6972                .add_column(editable_name_col())
6973                .row_height(20.0),
6974        );
6975        let proposal = SizeProposal {
6976            width: Some(400.0),
6977            height: Some(200.0),
6978        };
6979        tree.layout(proposal);
6980        tree.focus(id);
6981
6982        {
6983            let any = tree.widget_as_any(id).unwrap();
6984            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6985            tt.begin_edit(1, "name");
6986        }
6987        tree.layout(proposal);
6988
6989        let focused = tree.focused().expect("something must hold focus");
6990        assert_ne!(
6991            focused, id,
6992            "focus is still on the table, not in the editor"
6993        );
6994        let cell = {
6995            let any = tree.widget_as_any(id).unwrap();
6996            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
6997            tt.realized_cell(1, 0).expect("the edited cell is realized")
6998        };
6999        assert!(
7000            tree.is_descendant_of(focused, cell),
7001            "focus must land inside the edited cell, not on {:?}",
7002            tree.widget_type_name(focused)
7003        );
7004    }
7005
7006    /// ...and it still holds it after the pane rebuilds under it.
7007    ///
7008    /// A table rebuilds its rows constantly — selection, filtering, scroll, the
7009    /// edit signal itself — and each rebuild destroys and re-creates every cell
7010    /// widget, the open editor included. Restoring focus is therefore not a
7011    /// one-shot at edit-open: without it the first click on another row would
7012    /// silently deafen the editor the writer is still typing into. Driven
7013    /// through a selection change because that is the rebuild a click produces.
7014    #[test]
7015    fn an_open_editor_still_holds_the_keyboard_after_the_pane_rebuilds() {
7016        let slice = three_row_slice();
7017        let selection = teksilo_data::SelectionModel::new(teksilo_data::SelectionMode::Single);
7018        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
7019        let id = tree.add(
7020            TreeTableView::from_source(slice)
7021                .selection(selection.clone())
7022                .add_column(editable_name_col())
7023                .row_height(20.0),
7024        );
7025        let proposal = SizeProposal {
7026            width: Some(400.0),
7027            height: Some(200.0),
7028        };
7029        tree.layout(proposal);
7030        {
7031            let any = tree.widget_as_any(id).unwrap();
7032            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
7033            tt.begin_edit(1, "name");
7034        }
7035        tree.layout(proposal);
7036        tree.focused().expect("the editor took focus");
7037
7038        selection.select(2);
7039        tree.layout(proposal);
7040
7041        let focused = tree.focused().expect("focus survived the rebuild");
7042        assert_ne!(focused, id, "the rebuild dropped focus back onto the table");
7043        let cell = {
7044            let any = tree.widget_as_any(id).unwrap();
7045            let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
7046            tt.realized_cell(1, 0)
7047                .expect("the edited cell is still realized")
7048        };
7049        assert!(
7050            tree.is_descendant_of(focused, cell),
7051            "focus must still be inside the edited cell, not on {:?}",
7052            tree.widget_type_name(focused)
7053        );
7054    }
7055
7056    /// **Double-click opens the editor on an editable cell** — one arm of
7057    /// [`EditTriggers`], and one that had no implementation anywhere.
7058    /// `F2 | ANY_KEY | DOUBLE_CLICK` is the default set, so every table has
7059    /// been promising this; only `keyboard.rs`'s F2 and type-to-edit ever
7060    /// reached `on_cell_edit_request`.
7061    #[test]
7062    fn a_double_click_on_an_editable_cell_opens_its_editor() {
7063        let (mut tree, id, seen, _) = click_probe(EditTriggers::DOUBLE_CLICK);
7064        let cell = realized(&tree, id, 1, 0);
7065        let at = tree.bounds(cell).center();
7066        double_click_at(&mut tree, at);
7067
7068        assert_eq!(
7069            seen.borrow().as_slice(),
7070            &[(1, "name".to_string())],
7071            "a double-click on an editable cell must request its editor"
7072        );
7073        let any = tree.widget_as_any(id).unwrap();
7074        let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
7075        assert_eq!(tt.editing_cell_signal().get(), Some((1, 0)));
7076    }
7077
7078    /// **One click opens it** when the column asks for `SINGLE_CLICK` — the
7079    /// case the old closed enum could not express at all.
7080    #[test]
7081    fn a_single_click_opens_the_editor_when_the_column_asks_for_it() {
7082        let (mut tree, id, seen, _) = click_probe(EditTriggers::SINGLE_CLICK);
7083        let cell = realized(&tree, id, 1, 0);
7084        tree.click(cell);
7085
7086        assert_eq!(
7087            seen.borrow().as_slice(),
7088            &[(1, "name".to_string())],
7089            "one click on a SINGLE_CLICK column must request its editor"
7090        );
7091    }
7092
7093    /// ...and a column that asked for neither is not opened by any click.
7094    /// `NONE` has to mean none, or "read-only in practice" would be
7095    /// unexpressible for an otherwise editable column.
7096    #[test]
7097    fn a_click_opens_nothing_when_the_column_asks_for_no_click_trigger() {
7098        let (mut tree, id, seen, _) = click_probe(EditTriggers::F2);
7099        let cell = realized(&tree, id, 1, 0);
7100        tree.click(cell);
7101        let at = tree.bounds(cell).center();
7102        double_click_at(&mut tree, at);
7103
7104        assert!(
7105            seen.borrow().is_empty(),
7106            "an F2-only column opened an editor from a click: {:?}",
7107            seen.borrow()
7108        );
7109    }
7110
7111    /// A double-click that opens an editor does **not** also activate the row.
7112    ///
7113    /// The collision this rules out is opening the item *and* starting to edit
7114    /// it on one gesture, which is why the click arm could not simply be
7115    /// switched on. The framework settles it with no guard in the pane: the
7116    /// cell's gesture arena answers `Handled` to the press, so the bubble never
7117    /// reaches the row.
7118    ///
7119    /// One gesture per tree, and the read-only baseline is the **separate**
7120    /// test below: a second synthetic double-click in the same tree never
7121    /// reaches the row's `on_double_tap` at all (the recognizer reads clicks 3
7122    /// and 4 as a continuing run), so a single test doing both would pass with
7123    /// the behaviour removed — an earlier draft did, which is why this note
7124    /// exists.
7125    #[test]
7126    fn editing_a_cell_by_double_click_does_not_also_activate_the_row() {
7127        let (mut tree, id, _, activated) = click_probe(EditTriggers::DOUBLE_CLICK);
7128        let cell = realized(&tree, id, 1, 0);
7129        let at = tree.bounds(cell).center();
7130        double_click_at(&mut tree, at);
7131        assert_eq!(
7132            activated.get(),
7133            0,
7134            "double-clicking an editable cell opened the item as well as the editor"
7135        );
7136    }
7137
7138    /// The read-only column beside it still activates, which is what makes the
7139    /// guard a rule about *this gesture on an editable cell* rather than about
7140    /// the whole table.
7141    #[test]
7142    fn a_double_click_off_an_editable_cell_still_activates_the_row() {
7143        let (mut tree, id, _, activated) = click_probe(EditTriggers::DOUBLE_CLICK);
7144        let cell = realized(&tree, id, 1, 1);
7145        let at = tree.bounds(cell).center();
7146        double_click_at(&mut tree, at);
7147        assert_eq!(
7148            activated.get(),
7149            1,
7150            "a double-click away from an editable cell must still activate the row"
7151        );
7152    }
7153
7154    /// **A cell that edits on double-click still lets its row select on a
7155    /// plain click.**
7156    ///
7157    /// `press_claimed_by_interactive_child` counted `on_double_tap` as owning
7158    /// the press, so merely giving a cell double-click-to-edit silently stopped
7159    /// its row selecting — while every file manager selects a row on the first
7160    /// click of the double-click that opens it. The claim is now about
7161    /// handlers that act on a single press (`on_tap` / `on_long_press`).
7162    #[test]
7163    fn a_double_click_editable_cell_still_lets_its_row_select_on_one_click() {
7164        let selection = teksilo_data::SelectionModel::new(teksilo_data::SelectionMode::Single);
7165        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
7166        let id = tree.add(
7167            TreeTableView::from_source(three_row_slice())
7168                .selection(selection.clone())
7169                .add_column(editable_name_col().edit_triggers(EditTriggers::DOUBLE_CLICK))
7170                .row_height(20.0)
7171                .on_cell_edit_request(|_row, _col, _ctx| {}),
7172        );
7173        tree.layout(SizeProposal {
7174            width: Some(400.0),
7175            height: Some(200.0),
7176        });
7177
7178        let cell = realized(&tree, id, 1, 0);
7179        tree.click(cell);
7180        assert!(
7181            selection.is_selected(1),
7182            "one click on a double-click-editable cell must still select its row"
7183        );
7184    }
7185
7186    /// ...whereas `SINGLE_CLICK` deliberately does claim the press: that cell's
7187    /// click means "edit this value", not "select this row". Documented on
7188    /// [`EditTriggers::SINGLE_CLICK`] and the reason the set is per column.
7189    #[test]
7190    fn a_single_click_editable_cell_claims_the_press_from_row_selection() {
7191        let selection = teksilo_data::SelectionModel::new(teksilo_data::SelectionMode::Single);
7192        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
7193        let id = tree.add(
7194            TreeTableView::from_source(three_row_slice())
7195                .selection(selection.clone())
7196                .add_column(editable_name_col().edit_triggers(EditTriggers::SINGLE_CLICK))
7197                .add_column(size_col())
7198                .row_height(20.0)
7199                .on_cell_edit_request(|_row, _col, _ctx| {}),
7200        );
7201        tree.layout(SizeProposal {
7202            width: Some(400.0),
7203            height: Some(200.0),
7204        });
7205
7206        let editable = realized(&tree, id, 1, 0);
7207        tree.click(editable);
7208        assert!(
7209            !selection.is_selected(1),
7210            "a SINGLE_CLICK cell's click must go to the editor, not to selection"
7211        );
7212
7213        // The column beside it selects as always — which is what makes this a
7214        // property of the column rather than of the table.
7215        let plain = realized(&tree, id, 2, 1);
7216        tree.click(plain);
7217        assert!(
7218            selection.is_selected(2),
7219            "a click on a non-editing column must still select its row"
7220        );
7221    }
7222
7223    /// The cell realized at `(row, display column)`.
7224    fn realized(tree: &WidgetTree, id: WidgetId, row: usize, col: usize) -> WidgetId {
7225        let any = tree.widget_as_any(id).unwrap();
7226        let tt = any.downcast_ref::<TreeTableView<&'static str>>().unwrap();
7227        tt.realized_cell(row, col)
7228            .unwrap_or_else(|| panic!("cell ({row}, {col}) is not realized"))
7229    }
7230
7231    /// A laid-out table whose first column is editable under `triggers` and
7232    /// whose second is read-only, with the edit requests it receives and a
7233    /// count of row activations.
7234    #[allow(clippy::type_complexity)]
7235    fn click_probe(
7236        triggers: EditTriggers,
7237    ) -> (
7238        WidgetTree,
7239        WidgetId,
7240        Rc<RefCell<Vec<(usize, String)>>>,
7241        Rc<Cell<usize>>,
7242    ) {
7243        let seen: Rc<RefCell<Vec<(usize, String)>>> = Rc::new(RefCell::new(Vec::new()));
7244        let sink = seen.clone();
7245        let activated: Rc<Cell<usize>> = Rc::new(Cell::new(0));
7246        let counter = activated.clone();
7247        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
7248        let id = tree.add(
7249            TreeTableView::from_source(three_row_slice())
7250                .add_column(editable_name_col().edit_triggers(triggers))
7251                .add_column(size_col())
7252                .row_height(20.0)
7253                .on_cell_edit_request(move |row, col, _ctx| {
7254                    sink.borrow_mut().push((row, col.to_string()));
7255                })
7256                .on_row_activate(move |_row, _ctx| counter.set(counter.get() + 1)),
7257        );
7258        tree.layout(SizeProposal {
7259            width: Some(400.0),
7260            height: Some(200.0),
7261        });
7262        (tree, id, seen, activated)
7263    }
7264}