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