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