Skip to main content

teksilo_widgets/
table_view.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `TableView<T>` — generic, virtualized, accessible tabular widget.
5//!
6//! Built atop the [`ListModel<T>`](teksilo_data::ListModel) /
7//! [`ListDataSource`] data layer in
8//! `teksilo-data` and the `teksilo-tokens` `TableStyle`. Mirrors Qt's
9//! `QTableView`, SwiftUI's `Table`, and JavaFX's `TableView`.
10//! The core skeleton: single body pane, row-virtualized with alternating
11//! backgrounds, grid lines, `Role::Table > Role::Row > Role::Cell`
12//! accessibility, multi-row selection, and an empty-state slot. Headers,
13//! sort, filter, resize, reorder, pinning, cell selection, and editing are
14//! also included. Row heights come in three modes: uniform (`row_height`,
15//! the default fast path), exact per-row callback (`row_height_fn`), and
16//! auto-measured (`auto_row_height` — rows grow to their tallest cell,
17//! height-for-width). See docs/table-view.md "Row heights".
18//!
19//! ```ignore
20//! use teksilo_data::ListModel;
21//! use teksilo_widgets::table_view::{Column, ColumnWidth, TableView};
22//! use teksilo_i18n::lit;
23//!
24//! struct Person { name: String, age: u32 }
25//!
26//! let model: ListModel<Person> = ListModel::new();
27//! let _table = TableView::new(model)
28//!     .add_column(Column::new("name", ColumnWidth::Flex(1.0))
29//!         .label(lit!("Name"))
30//!         .cell(|p: &Person, _cx| Box::new(
31//!             teksilo_widgets::primitives::TextWidget::new(
32//!                 teksilo_i18n::lit!(p.name.clone())
33//!             )
34//!         )))
35//!     .add_column(Column::new("age", ColumnWidth::Fixed(60.0))
36//!         .label(lit!("Age"))
37//!         .cell(|p: &Person, _cx| Box::new(
38//!             teksilo_widgets::primitives::TextWidget::new(
39//!                 teksilo_i18n::lit!(p.age.to_string())
40//!             )
41//!         )))
42//!     .alternating_rows(true)
43//!     .row_height(32.0);
44//! ```
45
46pub mod a11y;
47pub mod body;
48pub mod body_pane;
49pub mod column;
50pub mod filter;
51pub mod header;
52pub mod imperative;
53pub mod keyboard;
54pub mod layout;
55pub mod row_navigator;
56pub mod selection;
57#[cfg(test)]
58mod tests;
59
60use std::cell::{Cell, RefCell};
61use std::collections::HashMap;
62use std::rc::Rc;
63use std::time::Duration;
64
65use teksilo_canvas::{Canvas, Point, Rect, Size, SizeProposal};
66
67use teksilo_core::ObserverHandle;
68use teksilo_core::accessibility::{AccessNodeBuilder, widget_id_to_node_id};
69use teksilo_core::binding::BindingLevel;
70use teksilo_core::build_context::BuildContext;
71use teksilo_core::signal::{Prop, Signal};
72use teksilo_core::widget::{LayoutContext, PaintContext, Widget, WidgetPlacement};
73use teksilo_core::widget_builder::HandlerSet;
74use teksilo_core::widget_id::WidgetId;
75use teksilo_data::{
76    DataChange, DropPosition, DropResponse, ItemKey, KeyedSelectionModel, ListDataSource,
77    ListModel, SelectionModel,
78};
79use teksilo_i18n::LocalizedString;
80use teksilo_tokens::{BorderRole, Easing, SurfaceRole};
81
82use crate::styles::recipe_table_style as cp;
83
84use crate::common::row_metrics::{HeightSource, RowMetrics, SharedRowMetrics};
85use crate::common::scroll::OverscrollBehavior;
86use crate::data_views::{
87    DragTransferMode, RowDragData, RowSelection, ViewId, ViewKind, flat_insertion_target,
88};
89use crate::list_source::DndLazy;
90use crate::scroll_area::ScrollBarMode;
91use crate::scroll_bar::{ScrollBar, ScrollBarOrientation, ScrollBarVisual};
92
93pub use self::column::{
94    Alignment, CellContext, Column, ColumnContext, ColumnResizePolicy, ColumnWidth, EditTriggers,
95    GridLines, PinnedSide, TabTraversal, TruncationPolicy,
96};
97pub use self::selection::{CellSelectionModel, TableSelectionMode};
98pub use teksilo_data::SortDirection;
99
100const BUFFER_ROWS: usize = 5;
101const SCROLLBAR_THICKNESS: f32 = 12.0;
102
103/// Pane partition produced by [`TableView::display_order`].
104///
105/// `leading_count` columns sit in the leading-pinned region, the next
106/// `middle_end - leading_count` columns sit in the middle (scrollable
107/// in future phases) region, and the remainder are trailing-pinned.
108/// All counts are positions inside the display-order vector.
109#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
110pub(crate) struct PaneBoundaries {
111    pub leading_count: usize,
112    pub middle_end: usize,
113}
114
115impl PaneBoundaries {
116    pub(crate) fn new(leading_count: usize, middle_end: usize) -> Self {
117        Self {
118            leading_count,
119            middle_end,
120        }
121    }
122}
123
124/// Drag payload for column reorder. Carried via `DragPayload::typed`.
125#[derive(Debug, Clone)]
126pub(crate) struct ColumnReorderDragData {
127    pub col_id: String,
128    /// Stable id of the source TableView, so dropping into a sibling
129    /// table is rejected by the on_drop matcher.
130    pub source_table_id: usize,
131}
132
133// ── Source erasure ─────────────────────────────────────────────────────────
134
135type LenFn = Rc<dyn Fn() -> usize>;
136type WithItemFn<T> = Rc<dyn Fn(usize, &dyn Fn(&T))>;
137type ObserveFn = Rc<dyn Fn(Box<dyn Fn(&DataChange)>) -> ObserverHandle>;
138/// Divergence side-channel for `DataChange::Reset`-emitting proxies
139/// (`ListDataSource::first_changed_index`). Raw `ListModel`s report
140/// `None` — their observers already get fine-grained variants.
141type FirstChangedFn = Rc<dyn Fn() -> Option<usize>>;
142
143/// The multi-cell read erasure. `TableView` reads each row's item once
144/// per cell (each column's `cell` delegate), so it keeps the side-effect
145/// `with_item_fn` form rather than `ListSource`'s single-widget reader.
146/// The DnD + lazy protocol is shared from `DndLazy` (built separately in
147/// the constructors). Returned alongside the `Rc<S>` source so the caller
148/// can build a `DndLazy` from the same handle without re-wrapping.
149fn erase_list_model<T: 'static>(
150    model: ListModel<T>,
151) -> (LenFn, WithItemFn<T>, ObserveFn, FirstChangedFn) {
152    let m_len = model.clone();
153    let m_read = model.clone();
154    let m_obs = model;
155    let len_fn: LenFn = Rc::new(move || m_len.len());
156    let with_item_fn: WithItemFn<T> = Rc::new(move |idx, f| {
157        m_read.with_item(idx, |item| f(item));
158    });
159    let observe_fn: ObserveFn =
160        Rc::new(move |callback| m_obs.observe_changes(move |change| callback(change)));
161    (len_fn, with_item_fn, observe_fn, Rc::new(|| None))
162}
163
164fn erase_data_source<S: ListDataSource<Item = T>, T: 'static>(
165    s: Rc<S>,
166) -> (LenFn, WithItemFn<T>, ObserveFn, FirstChangedFn) {
167    let s_len = s.clone();
168    let s_read = s.clone();
169    let s_obs = s.clone();
170    let s_changed = s;
171    let len_fn: LenFn = Rc::new(move || s_len.len());
172    let with_item_fn: WithItemFn<T> = Rc::new(move |idx, f| {
173        s_read.with_item(idx, |item| f(item));
174    });
175    let observe_fn: ObserveFn =
176        Rc::new(move |callback| s_obs.observe_changes(move |change| callback(change)));
177    let first_changed_fn: FirstChangedFn = Rc::new(move || s_changed.first_changed_index());
178    (len_fn, with_item_fn, observe_fn, first_changed_fn)
179}
180
181// `read_item` lived here for the inline body-row build; that loop now
182// lives in `BodyPane` which has its own copy. Keeping it removed
183// avoids dead-code drift between the two paths.
184
185// ── Public widget ──────────────────────────────────────────────────────────
186
187/// Generic, virtualized, accessible table with sortable / filterable / resizable columns.
188///
189/// Construct with [`TableView::new`] (from a [`ListModel<T>`](teksilo_data::ListModel))
190/// or [`TableView::from_source`] (any [`ListDataSource`]), then chain builder methods
191/// to configure columns, row heights, selection, and so on. See module docs for the full
192/// feature list and row-height modes.
193pub struct TableView<T: 'static> {
194    // Source erasure (multi-cell read path; DnD + lazy live in `dnd`).
195    len_fn: LenFn,
196    with_item_fn: WithItemFn<T>,
197    observe_fn: ObserveFn,
198    first_changed_fn: FirstChangedFn,
199    /// Source-owned DnD validation + lazy windowing, erased from the
200    /// backing `ListDataSource`. A `ListModel` reorders in place via its
201    /// `accept_drop`; an external source routes the move to its store and
202    /// can forbid a drop by returning `DropResponse::Reject` (the view
203    /// then paints no insertion line).
204    dnd: DndLazy,
205    /// Resolve a row index to a movement-proof handle (see `RowAnchor`).
206    anchor_fn: Rc<dyn Fn(usize) -> crate::data_views::RowAnchor>,
207    /// Anchor for the row with an open cell editor, so the editor follows its
208    /// row instead of its index. See `reconcile_editing_row`.
209    editing_anchor: Rc<RefCell<Option<crate::data_views::RowAnchor>>>,
210
211    // Configuration
212    columns: Vec<Column<T>>,
213    row_height: Option<f32>,
214    /// Height-mode selection (uniform / exact callback / auto-measure).
215    height_source: HeightSource,
216    /// Row geometry — shared with `BodyPane` and the keyboard handler.
217    row_metrics: SharedRowMetrics,
218    header_height: Option<f32>,
219    show_header: bool,
220    selection_mode: TableSelectionMode,
221    /// Row selection — index-based `SelectionModel` or keyed
222    /// `KeyedSelectionModel<K>`, unified behind the index-facing facade.
223    row_selection: Option<RowSelection>,
224    cell_selection: Option<CellSelectionModel>,
225    alternating_rows: bool,
226    grid_lines: GridLines,
227    a11y_label: Option<LocalizedString>,
228    show_internal_scrollbars: bool,
229    empty_view: Option<Rc<dyn Fn() -> Box<dyn Widget>>>,
230    column_resize_policy: ColumnResizePolicy,
231
232    /// Animate wheel scrolling instead of snapping to the new offset.
233    /// Enabled by default — mirrors `ScrollArea`. Without it, each wheel
234    /// notch jumps by `row_height` per delivered line (typically 3),
235    /// which reads as a coarse multi-row jump rather than a smooth glide.
236    smooth_scrolling: bool,
237    /// Duration of the smooth scroll animation.
238    smooth_scroll_duration: Duration,
239
240    /// How the scroll bar is displayed. Defaults to `Permanent` — a
241    /// layout sibling that reserves its own width. `Overlay` / `Thin`
242    /// float over the content instead, like `ScrollArea`.
243    scroll_bar_style: ScrollBarMode,
244
245    // Public reactive signals
246    scroll_y: Signal<f32>,
247    max_scroll_y: Signal<f32>,
248    /// Scroll-chaining behavior at the boundary (default `Chain`).
249    overscroll_behavior: OverscrollBehavior,
250    viewport_ratio_y: Signal<f32>,
251    /// Horizontal scroll offset of the Middle (unpinned) pane — see
252    /// `PaneBoundaries`. Leading/Trailing-pinned columns never move; the
253    /// Middle pane's content shifts by `-scroll_x`.
254    scroll_x: Signal<f32>,
255    /// Maximum `scroll_x` — `middle_content_width − middle_viewport_width`.
256    max_scroll_x: Signal<f32>,
257    /// Middle-pane viewport-to-content width ratio, for the horizontal
258    /// scroll bar's thumb.
259    viewport_ratio_x: Signal<f32>,
260    sort_signal: Signal<Option<(String, SortDirection)>>,
261    column_widths_signal: Signal<HashMap<String, f32>>,
262    /// Column ids in display order. Empty means "use declaration order".
263    column_order_signal: Signal<Vec<String>>,
264    /// Per-id override for `Column::pinned`. Missing keys mean "use the
265    /// declared pinning". The drag-to-reorder UI updates this when a
266    /// column crosses a pane boundary.
267    column_pinning_signal: Signal<HashMap<String, PinnedSide>>,
268    /// Currently keyboard-focused cell `(row_index, display_col)`, or
269    /// `None` when no cell is focused.
270    focused_cell: Signal<Option<(usize, usize)>>,
271    /// The realized `(row index -> row wrapper id)` map, filled by the body
272    /// pane each build. Lets this widget's `&self` methods resolve a row index
273    /// to a widget without reaching into the pane. Mirrors `ListView::row_map`.
274    row_map: Rc<RefCell<Vec<(usize, WidgetId)>>>,
275    /// Type-ahead ("type to jump") label extractor — opt-in via
276    /// [`type_ahead_label`](Self::type_ahead_label).
277    #[allow(clippy::type_complexity)]
278    type_ahead_label: Option<Rc<dyn Fn(&T) -> String>>,
279    /// Reset window for the type-ahead search term.
280    type_ahead_timeout: Duration,
281    /// Persistent type-ahead buffer (survives the per-keystroke rebuild).
282    type_ahead: Rc<crate::common::type_ahead::TypeAheadState>,
283    tab_traversal: TabTraversal,
284    /// Cell currently in edit mode, or `None` when no editor is open.
285    /// Cell delegates inspect this through `CellContext::is_editing` to
286    /// swap in an editor widget.
287    editing_cell: Signal<Option<(usize, usize)>>,
288    edit_triggers: EditTriggers,
289    /// User callback invoked when an edit trigger fires on the focused
290    /// cell.
291    #[allow(clippy::type_complexity)]
292    on_cell_edit_request: Option<Rc<dyn Fn(usize, &str, &mut teksilo_core::widget::EventContext)>>,
293    #[allow(clippy::type_complexity)]
294    on_cell_edit_dismissed:
295        Option<Rc<dyn Fn(usize, &str, &mut teksilo_core::widget::EventContext)>>,
296    /// Per-column filter text. Updated by filter affordances in the
297    /// header, by `set_filter` / `clear_filters`, and by
298    /// downstream consumers binding it (e.g., `SortFilterListModel`).
299    filters_signal: Signal<HashMap<String, String>>,
300    /// User callback invoked on every row activation (Enter on the
301    /// focused row).
302    #[allow(clippy::type_complexity)]
303    on_row_activate: Option<Rc<dyn Fn(usize, &mut teksilo_core::widget::EventContext)>>,
304    reorderable: bool,
305    /// Active row-drop insertion indicator `(body_local_y, width)` —
306    /// `body_local_y` is measured from the body band top (below the
307    /// header). Set by `on_drag_hover` when the source accepts the
308    /// hovered position, cleared on leave / drop, read by `paint`.
309    /// Reactive (`RepaintOnly`) so a `set(...)` dirties the table.
310    drop_feedback: Signal<Option<(f32, f32)>>,
311
312    /// Whether activation is a single or double click (default `DoubleClick`).
313    activate_on: crate::data_views::ActivateOn,
314
315    /// `true` while this view — its root or any descendant (e.g. a cell
316    /// editor) — holds keyboard focus. Captured at build from
317    /// [`BuildContext::view_focus_active`] and bound `RepaintOnly`. Drives
318    /// **focus-aware selection**: the selection band paints with the active
319    /// `Selected` chrome while focused and the muted `SelectedInactive` chrome
320    /// once focus leaves the table — the standard desktop affordance.
321    view_focused: Signal<bool>,
322    /// Input-modality `:focus-visible` — `true` after keyboard input, `false`
323    /// after a pointer press. Gates the cell focus ring so it shows only
324    /// during keyboard navigation, never on a mouse click. Bound `RepaintOnly`.
325    focus_visible: Signal<bool>,
326
327    // Build state
328    header_row_id: Option<WidgetId>,
329    body_pane_id: Option<WidgetId>,
330    scrollbar_id: Option<WidgetId>,
331    /// Horizontal scroll bar along the bottom of the Middle pane only —
332    /// built whenever `show_internal_scrollbars` is set, placed/sized (and
333    /// hidden at zero size, mirroring the vertical bar) in `place_children`.
334    h_scrollbar_id: Option<WidgetId>,
335    empty_id: Option<WidgetId>,
336    /// Pane-local rebuild trigger + buffered range, owned here so they
337    /// survive `TableView` rebuilds (each rebuild constructs a fresh
338    /// `BodyPane` struct that inherits these handles).
339    pane_version: Signal<u64>,
340    pane_built_start: Rc<Cell<usize>>,
341    pane_built_end: Rc<Cell<usize>>,
342    /// Bumped by the pane when a measure pass changes the content
343    /// total; bound at `Relayout` on this root so `max_scroll_y` / the
344    /// thumb ratio are recomputed with the corrected total next frame.
345    pane_total_refresh: Signal<u64>,
346
347    // Layout state
348    /// Resolved widths in **display order** (parallel to
349    /// `display_indices`).
350    column_widths: Rc<RefCell<Vec<f32>>>,
351    /// Display-order indices into `self.columns`. Recomputed each
352    /// `build()`; read by `place_children` and `paint`.
353    display_indices: Rc<RefCell<Vec<usize>>>,
354    /// `(row, display_pos) -> WidgetId` for every cell realized by the
355    /// body pane's latest `build()`. Shared with `BodyPane` (the GridView
356    /// `tile_map` pattern — two holders across the sibling-of-scrollbar
357    /// split): the pane overwrites it wholesale each time it rebuilds, so
358    /// a cell that scrolled out of the realized buffer simply isn't in
359    /// the map. `accessibility()` reads it to point `active_descendant`
360    /// at the keyboard-focused cell's own AT node.
361    cell_map: Rc<RefCell<Vec<((usize, usize), WidgetId)>>>,
362    /// Counts of (leading-pinned, middle, trailing-pinned) columns —
363    /// used by paint to draw pane dividers and by the drop-zone math
364    /// to classify a drop position.
365    pane_boundaries: Rc<RefCell<PaneBoundaries>>,
366    viewport_height: Rc<Cell<f32>>,
367    /// Middle-pane viewport width, snapshotted by `place_children` — the
368    /// horizontal analogue of `viewport_height`. Read by the keyboard
369    /// handler's ensure-column-visible follow.
370    middle_viewport_width: Rc<Cell<f32>>,
371    /// Set on the first `place_children`. Until then `viewport_height` still
372    /// holds its construction placeholder, so viewport-relative imperatives
373    /// (`ensure_row_visible`) would scroll against a size that was never real.
374    laid_out: Rc<Cell<bool>>,
375    /// The row-area's absolute (window) rect (below the header), cached by
376    /// `place_children`. Threaded into the keyboard handler so it can chase the
377    /// focused row into any *enclosing* scroll area via
378    /// [`EventContext::ensure_visible`](teksilo_core::widget::EventContext::ensure_visible).
379    body_bounds: Rc<Cell<Rect>>,
380    /// Width of the header strip (= the column band) snapshotted by
381    /// `place_children`. The reorder-drop handler needs it to mirror the
382    /// drop x under RTL, where the column content is right-anchored in
383    /// the band (`local.x` is measured from the strip's physical left).
384    header_strip_width: Rc<Cell<f32>>,
385
386    // Header-cell shared state — tracked across the table so the
387    // pointer-capture'd resize delivers PointerMove events back to the
388    // active HeaderCell.
389    resize_state: header::ResizeStateHandle,
390    /// Display slot of the column under an active resize drag, or `None`.
391    /// Shared with every `HeaderCell` so the *target* column shows the
392    /// "resizing" chrome — which is not always the cell holding the pointer
393    /// capture, since a grip straddles the divider between two cells.
394    resize_target: Signal<Option<usize>>,
395    /// Window x of the prospective divider while a
396    /// [`ColumnResizePolicy::OnRelease`] drag is in flight. Painted as a
397    /// guide line by `paint`; `None` at rest. Under `Live` the columns
398    /// themselves move, so nothing is published here.
399    resize_preview_x: Signal<Option<f32>>,
400
401    /// Stable id used by the column-reorder drag payload to disambiguate
402    /// inter-table drops. Unrelated to row DnD — a wholly separate
403    /// mechanism (`ColumnReorderDragData` + header handlers).
404    table_id: usize,
405
406    /// Stable, kind-tagged ID for this TableView instance's **row** DnD
407    /// (identifies its own row reorder vs. a foreign row drop, even across
408    /// widget kinds / windows). Distinct from `table_id` above, which only
409    /// disambiguates the separate column-reorder mechanism.
410    model_id: ViewId,
411
412    /// Cross-widget export / foreign-receive machinery — the builders
413    /// (`.exportable`, `.export_external`, `.accept_foreign_rows`,
414    /// `.on_rows_received`, `.on_rows_transferred_out`), the drag-start payload
415    /// build, and the move-out completion, shared by all four data views.
416    export: crate::data_views::RowExport<T>,
417
418    /// Whole-view enabled state, statically or reactively. Forwarded to the
419    /// arena via `ctx.enabled_when(self_id, self.enabled.clone())` at build
420    /// time; a disabled view greys out and stops accepting focus /
421    /// selection / keyboard input (arena-gated).
422    enabled: Prop<bool>,
423}
424
425/// Build the anchor factory for a keyed source: capture the row's key now,
426/// resolve its current index later. Keyless sources fall back to a fixed anchor.
427fn anchor_factory<S: ListDataSource<Item = T> + 'static, T: 'static>(
428    s: Rc<S>,
429) -> Rc<dyn Fn(usize) -> crate::data_views::RowAnchor> {
430    Rc::new(move |index| match s.key_at(index) {
431        Some(key) => {
432            let src = s.clone();
433            crate::data_views::RowAnchor::new(Rc::new(move || {
434                if src.key_at(index).as_ref() == Some(&key) {
435                    return Some(index);
436                }
437                src.index_of(&key)
438            }))
439        }
440        None => crate::data_views::RowAnchor::fixed(index),
441    })
442}
443
444impl<T: 'static> TableView<T> {
445    /// Wrap a `ListModel<T>`.
446    pub fn new(model: ListModel<T>) -> Self {
447        let dnd = DndLazy::from_source(Rc::new(model.clone()));
448        let (len_fn, with_item_fn, observe_fn, first_changed_fn) = erase_list_model(model);
449        // A bare `ListModel` exposes no row identity.
450        let anchor_fn = Rc::new(crate::data_views::RowAnchor::fixed) as Rc<dyn Fn(usize) -> _>;
451        Self::create(
452            len_fn,
453            with_item_fn,
454            observe_fn,
455            first_changed_fn,
456            dnd,
457            anchor_fn,
458        )
459    }
460
461    /// Wrap any `ListDataSource<Item = T>` (e.g. a
462    /// [`SortFilterListModel<T>`](teksilo_data::SortFilterListModel)).
463    ///
464    /// The source owns DnD validation (`can_accept` / `accept_drop`) and
465    /// lazy windowing (`row_state` / `request_window` / `fetch_more`); a
466    /// read-only source leaves the defaults inert.
467    pub fn from_source<S: ListDataSource<Item = T>>(source: S) -> Self {
468        let s = Rc::new(source);
469        let dnd = DndLazy::from_source(s.clone());
470        let anchor_fn = anchor_factory::<S, T>(s.clone());
471        let (len_fn, with_item_fn, observe_fn, first_changed_fn) = erase_data_source::<S, T>(s);
472        Self::create(
473            len_fn,
474            with_item_fn,
475            observe_fn,
476            first_changed_fn,
477            dnd,
478            anchor_fn,
479        )
480    }
481
482    /// Wrap any `ListDataSource<Item = T>` with **keyed** row selection. The
483    /// `KeyedSelectionModel<S::Key>` tracks selection by source identity, so it
484    /// survives reorders / filters / lazy window-slides and stays consistent
485    /// across two views of the same source. The view stays `TableView<T>` — the
486    /// index↔key mapping is captured from the concrete source here. Equivalent
487    /// to `from_source(..)` plus an identity-based replacement for
488    /// [`selection`](Self::selection).
489    pub fn from_source_keyed<S: ListDataSource<Item = T>>(
490        source: S,
491        keyed: KeyedSelectionModel<S::Key>,
492    ) -> Self
493    where
494        S::Key: ItemKey,
495    {
496        let s = Rc::new(source);
497        let dnd = DndLazy::from_source(s.clone());
498        let key_at = {
499            let s = s.clone();
500            Rc::new(move |i| s.key_at(i)) as Rc<dyn Fn(usize) -> Option<S::Key>>
501        };
502        let len = {
503            let s = s.clone();
504            Rc::new(move || s.len()) as Rc<dyn Fn() -> usize>
505        };
506        let contains = {
507            let s = s.clone();
508            Rc::new(move |k: &S::Key| (0..s.len()).any(|i| s.key_at(i).as_ref() == Some(k)))
509                as Rc<dyn Fn(&S::Key) -> bool>
510        };
511        let row_selection = RowSelection::from_keyed(keyed, key_at, len, contains);
512        let anchor_fn = anchor_factory::<S, T>(s.clone());
513        let (len_fn, with_item_fn, observe_fn, first_changed_fn) = erase_data_source::<S, T>(s);
514        let mut view = Self::create(
515            len_fn,
516            with_item_fn,
517            observe_fn,
518            first_changed_fn,
519            dnd,
520            anchor_fn,
521        );
522        view.row_selection = Some(row_selection);
523        view
524    }
525
526    fn create(
527        len_fn: LenFn,
528        with_item_fn: WithItemFn<T>,
529        observe_fn: ObserveFn,
530        first_changed_fn: FirstChangedFn,
531        dnd: DndLazy,
532        anchor_fn: Rc<dyn Fn(usize) -> crate::data_views::RowAnchor>,
533    ) -> Self {
534        use std::sync::atomic::{AtomicUsize, Ordering};
535        static NEXT_ID: AtomicUsize = AtomicUsize::new(1);
536        let table_id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
537        Self {
538            len_fn,
539            with_item_fn,
540            observe_fn,
541            first_changed_fn,
542            dnd,
543            anchor_fn,
544            editing_anchor: Rc::new(RefCell::new(None)),
545            columns: Vec::new(),
546            row_height: None,
547            height_source: HeightSource::Uniform,
548            row_metrics: Rc::new(RefCell::new(RowMetrics::uniform(cp::ROW_HEIGHT, 0.0))),
549            header_height: None,
550            show_header: true,
551            selection_mode: TableSelectionMode::default(),
552            row_selection: None,
553            cell_selection: None,
554            alternating_rows: false,
555            grid_lines: GridLines::None,
556            a11y_label: None,
557            show_internal_scrollbars: true,
558            empty_view: None,
559            column_resize_policy: ColumnResizePolicy::default(),
560            smooth_scrolling: true,
561            smooth_scroll_duration: Duration::from_millis(150),
562            scroll_bar_style: ScrollBarMode::Permanent,
563            overscroll_behavior: OverscrollBehavior::default(),
564            scroll_y: Signal::new_animated(0.0),
565            max_scroll_y: Signal::new(0.0),
566            viewport_ratio_y: Signal::new(1.0),
567            scroll_x: Signal::new_animated(0.0),
568            max_scroll_x: Signal::new(0.0),
569            viewport_ratio_x: Signal::new(1.0),
570            sort_signal: Signal::new(None),
571            column_widths_signal: Signal::new(HashMap::new()),
572            column_order_signal: Signal::new(Vec::new()),
573            column_pinning_signal: Signal::new(HashMap::new()),
574            focused_cell: Signal::new(None),
575            row_map: Rc::new(RefCell::new(Vec::new())),
576            type_ahead_label: None,
577            type_ahead_timeout: crate::common::type_ahead::DEFAULT_TYPE_AHEAD_TIMEOUT,
578            type_ahead: crate::common::type_ahead::TypeAheadState::new(),
579            // Replaced at build with the live tree signals; the defaults are
580            // only the pre-build values (treat as focused, pointer modality).
581            view_focused: Signal::new(true),
582            focus_visible: Signal::new(false),
583            tab_traversal: TabTraversal::default(),
584            editing_cell: Signal::new(None),
585            edit_triggers: EditTriggers::default(),
586            on_cell_edit_request: None,
587            on_cell_edit_dismissed: None,
588            filters_signal: Signal::new(HashMap::new()),
589            on_row_activate: None,
590            reorderable: false,
591            drop_feedback: Signal::new(None),
592            activate_on: crate::data_views::ActivateOn::default(),
593            header_row_id: None,
594            body_pane_id: None,
595            scrollbar_id: None,
596            h_scrollbar_id: None,
597            empty_id: None,
598            pane_version: Signal::new(0_u64),
599            pane_built_start: Rc::new(Cell::new(0)),
600            pane_built_end: Rc::new(Cell::new(0)),
601            pane_total_refresh: Signal::new(0_u64),
602            column_widths: Rc::new(RefCell::new(Vec::new())),
603            display_indices: Rc::new(RefCell::new(Vec::new())),
604            cell_map: Rc::new(RefCell::new(Vec::new())),
605            pane_boundaries: Rc::new(RefCell::new(PaneBoundaries::default())),
606            viewport_height: Rc::new(Cell::new(600.0)),
607            middle_viewport_width: Rc::new(Cell::new(600.0)),
608            laid_out: Rc::new(Cell::new(false)),
609            body_bounds: Rc::new(Cell::new(Rect::ZERO)),
610            header_strip_width: Rc::new(Cell::new(0.0)),
611            resize_state: Rc::new(std::cell::RefCell::new(None)),
612            resize_target: Signal::new(None),
613            resize_preview_x: Signal::new(None),
614            table_id,
615            model_id: ViewId::next(ViewKind::Table),
616            export: crate::data_views::RowExport::default(),
617            enabled: Prop::Static(true),
618        }
619    }
620
621    // ── Builder ────────────────────────────────────────────────────────
622
623    /// Enable or disable the whole view. A disabled view greys out and stops
624    /// accepting focus / selection / keyboard input (arena-gated).
625    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
626        self.enabled = enabled.into();
627        self
628    }
629
630    /// Set the scroll-chaining behavior at the boundary (default
631    /// [`OverscrollBehavior::Chain`]; [`Contain`](OverscrollBehavior::Contain)
632    /// disables chaining to an ancestor scrollable).
633    pub fn overscroll_behavior(mut self, behavior: OverscrollBehavior) -> Self {
634        self.overscroll_behavior = behavior;
635        self
636    }
637
638    /// Enable or disable animated wheel scrolling (enabled by default).
639    /// When disabled, wheel events snap immediately to the new offset.
640    pub fn smooth_scrolling(mut self, enabled: bool) -> Self {
641        self.smooth_scrolling = enabled;
642        self
643    }
644
645    /// Enable **type-ahead** ("type to jump"): typing a printable character
646    /// while the table has keyboard focus jumps the focused row to the next
647    /// row whose label starts with the accumulated search term, wrapping
648    /// around (Qt `keyboardSearch` / macOS & Windows type-select).
649    /// `label(&item)` yields the searchable text for a row; matching is
650    /// ASCII-case-insensitive. A pause longer than the
651    /// [`type_ahead_timeout`](Self::type_ahead_timeout) starts a fresh term.
652    ///
653    /// On an editable column whose [`EditTriggers`] is type-to-edit, typing
654    /// starts an edit instead — type-ahead applies on non-editable columns
655    /// (or when no type-to-edit trigger is configured).
656    pub fn type_ahead_label(mut self, label: impl Fn(&T) -> String + 'static) -> Self {
657        self.type_ahead_label = Some(Rc::new(label));
658        self
659    }
660
661    /// Reset window between keystrokes before the type-ahead search term
662    /// clears (default 500 ms). A zero duration disables type-ahead.
663    pub fn type_ahead_timeout(mut self, timeout: Duration) -> Self {
664        self.type_ahead_timeout = timeout;
665        self
666    }
667
668    /// Duration of the smooth scroll animation (default 150 ms).
669    pub fn smooth_scroll_duration(mut self, duration: Duration) -> Self {
670        self.smooth_scroll_duration = duration;
671        self
672    }
673
674    /// How the scroll bar is displayed (default `Permanent`). `Overlay`
675    /// and `Thin` float the bar over the content instead of reserving a
676    /// layout column for it, mirroring `ScrollArea::scroll_bar_style`.
677    pub fn scroll_bar_style(mut self, style: ScrollBarMode) -> Self {
678        self.scroll_bar_style = style;
679        self
680    }
681
682    /// Append a single [`Column<T>`] definition to the table.
683    pub fn add_column(mut self, col: Column<T>) -> Self {
684        self.columns.push(col);
685        self
686    }
687
688    /// Append multiple [`Column<T>`] definitions from an iterator.
689    pub fn columns(mut self, cols: impl IntoIterator<Item = Column<T>>) -> Self {
690        self.columns.extend(cols);
691        self
692    }
693
694    /// Re-materialize `self.row_metrics` after a height-mode /
695    /// row-height builder call.
696    fn remake_metrics(&self) {
697        *self.row_metrics.borrow_mut() = self
698            .height_source
699            .make_metrics(self.effective_row_height(), 0.0);
700    }
701
702    /// Fixed row height (default: the table style's 28 px) — the
703    /// uniform fast path. Mutually exclusive with
704    /// [`row_height_fn`](Self::row_height_fn) and
705    /// [`auto_row_height`](Self::auto_row_height); the last mode setter
706    /// wins.
707    pub fn row_height(mut self, height: f32) -> Self {
708        self.row_height = Some(height);
709        self.height_source = HeightSource::Uniform;
710        self.remake_metrics();
711        self
712    }
713
714    /// Per-row heights from a callback over the visible row index. The
715    /// callback must be pure (same index + same data → same height); it
716    /// is re-swept from the first changed index on every model change
717    /// (a `SortFilterListModel` source reports that index through
718    /// `first_changed_index`, so sort/filter/append keep the valid
719    /// prefix). No measurement pass runs.
720    pub fn row_height_fn(mut self, f: impl Fn(usize) -> f32 + 'static) -> Self {
721        self.height_source = HeightSource::Exact(Rc::new(f));
722        self.remake_metrics();
723        self
724    }
725
726    /// Auto-measured row heights: each realized row reports the height
727    /// of its tallest cell measured at the cell's column width
728    /// (height-for-width), unrealized rows assume `estimated`. Scroll
729    /// anchoring keeps content above the viewport stationary as
730    /// estimates are corrected; the scrollbar settles one frame after a
731    /// measurement change.
732    pub fn auto_row_height(mut self, estimated: f32) -> Self {
733        self.height_source = HeightSource::Auto { estimated };
734        self.remake_metrics();
735        self
736    }
737
738    /// Override the column header row height in logical pixels. Default: the table style's `HEADER_HEIGHT`.
739    pub fn header_height(mut self, height: f32) -> Self {
740        self.header_height = Some(height);
741        self
742    }
743
744    /// Show or hide the column header row. Default: visible.
745    pub fn show_header(mut self, visible: bool) -> Self {
746        self.show_header = visible;
747        self
748    }
749
750    /// Set how column widths are redistributed when columns are
751    /// added, resized, or the table's own width changes. See
752    /// [`ColumnResizePolicy`].
753    pub fn column_resize_policy(mut self, policy: ColumnResizePolicy) -> Self {
754        self.column_resize_policy = policy;
755        self
756    }
757
758    /// Control how Tab / Shift+Tab navigate between cells. See
759    /// [`TabTraversal`].
760    pub fn tab_traversal(mut self, mode: TabTraversal) -> Self {
761        self.tab_traversal = mode;
762        self
763    }
764
765    /// Set which user action opens a cell editor. See [`EditTriggers`].
766    pub fn edit_triggers(mut self, trigger: EditTriggers) -> Self {
767        self.edit_triggers = trigger;
768        self
769    }
770
771    /// Hook fired by the keyboard handler when an edit trigger fires
772    /// on the focused cell. Receives `(row_index, col_id, ctx)`.
773    pub fn on_cell_edit_request(
774        mut self,
775        f: impl Fn(usize, &str, &mut teksilo_core::widget::EventContext) + 'static,
776    ) -> Self {
777        self.on_cell_edit_request = Some(Rc::new(f));
778        self
779    }
780
781    /// Callback invoked when an **open** cell editor should end because the
782    /// pointer went somewhere else: a press that lands on any cell other than
783    /// the one being edited. Receives the editing cell's flat row index and
784    /// column id, so the owner can commit (or discard) whatever is in its
785    /// buffer, then clear its own editing state.
786    ///
787    /// The counterpart of [`on_cell_edit_request`](Self::on_cell_edit_request),
788    /// and the view cannot do it alone: the framework owns *which* cell is being
789    /// edited, but only the owner knows what an ended edit means — commit,
790    /// discard, or refuse a value that will not parse.
791    ///
792    /// **Why a press and not a focus change.** "The editor lost focus" is the
793    /// obvious signal and it cannot be used: a body pane rebuilds constantly —
794    /// selection, filtering, scroll, a reload from elsewhere — and every rebuild
795    /// destroys and re-creates the open editor, so focus leaves it many times
796    /// during an edit the writer never interrupted. A press on another cell is
797    /// unambiguous and happens exactly once.
798    pub fn on_cell_edit_dismissed(
799        mut self,
800        f: impl Fn(usize, &str, &mut teksilo_core::widget::EventContext) + 'static,
801    ) -> Self {
802        self.on_cell_edit_dismissed = Some(Rc::new(f));
803        self
804    }
805
806    /// Hook fired when the user presses Enter on the focused row.
807    pub fn on_row_activate(
808        mut self,
809        f: impl Fn(usize, &mut teksilo_core::widget::EventContext) + 'static,
810    ) -> Self {
811        self.on_row_activate = Some(Rc::new(f));
812        self
813    }
814
815    /// Enable drag-to-reorder of **rows** (pointer drag + keyboard
816    /// Alt+ArrowUp/Down). Distinct from
817    /// [`Column::reorderable`](crate::Column::reorderable), which reorders
818    /// *columns* and defaults to `true`; this defaults to `false`.
819    ///
820    /// The move is routed through the backing source's `accept_drop`: a
821    /// `ListModel` reorders in place, an external source routes the move to
822    /// its store. Per-hover the source's `can_accept` decides whether the
823    /// drop is allowed — a forbidden position shows no insertion line and
824    /// the drop is refused. A row may also be forbidden from dragging at
825    /// all (the source's `drag` gate). Cross-table / external drops arrive
826    /// at `accept_drop` as `DragSource::Foreign`; a bare `ListModel`
827    /// rejects them, an external source decides.
828    pub fn reorderable(mut self, enabled: bool) -> Self {
829        self.reorderable = enabled;
830        self
831    }
832
833    /// Renamed to [`reorderable`](Self::reorderable), matching `ListView`,
834    /// `GridView`, `TreeView` and `TreeTableView` — this was the only view in
835    /// the family spelling it differently.
836    #[deprecated(since = "0.6.3", note = "renamed to `reorderable`")]
837    pub fn reorderable_rows(self, enabled: bool) -> Self {
838        self.reorderable(enabled)
839    }
840
841    /// Make rows **droppable outside this view** — on a
842    /// [`DropTarget`](crate::DropTarget), another data view, or the OS.
843    ///
844    /// A dragged row (or the whole selection, when the pressed row is part of a
845    /// multi-selection) carries clones of its items in a public
846    /// [`RowDragData<T>`](crate::RowDragData), so a foreign receiver can pull
847    /// them out with `payload.get_typed::<RowDragData<T>>()` /
848    /// `DropTarget::on_drop_typed::<RowDragData<T>>()` — no serialization. This
849    /// also makes rows a drag source even without [`reorderable`](Self::reorderable).
850    ///
851    /// `mode` chooses what happens to the origin rows once a *foreign* target
852    /// accepts them: [`DragTransferMode::Move`] removes them (via the source's
853    /// `on_drag_out`, or [`on_rows_transferred_out`](Self::on_rows_transferred_out)),
854    /// [`DragTransferMode::Copy`] leaves them. A same-view reorder is never a
855    /// transfer, so `mode` never affects it. Requires `T: Clone`.
856    pub fn exportable(mut self, mode: DragTransferMode) -> Self
857    where
858        T: Clone,
859    {
860        self.export.set_exportable(mode);
861        self
862    }
863
864    /// Additionally advertise the dragged rows as MIME data so they can be
865    /// dropped on a [`DropZone`](crate::DropZone) or exported to another
866    /// application / window via the OS. `f` maps the dragged items to
867    /// `(mime_type, bytes)` pairs (e.g. `text/plain`, `text/uri-list`, an
868    /// app-specific `application/x-…`). Implies [`exportable`](Self::exportable)
869    /// (defaulting to [`DragTransferMode::Move`] if not already set). Requires
870    /// `T: Clone`.
871    pub fn export_external(mut self, f: impl Fn(&[T]) -> Vec<(String, Vec<u8>)> + 'static) -> Self
872    where
873        T: Clone,
874    {
875        self.export.set_export_external(f);
876        self
877    }
878
879    /// Override how rows moved out to a foreign target are removed from this
880    /// view. Receives the dragged rows' indices (descending-safe) and the live
881    /// context. Without this, an [`exportable`](Self::exportable)
882    /// [`Move`](DragTransferMode::Move) drag removes them through the source's
883    /// `on_drag_out` (works out of the box for a `ListModel`).
884    pub fn on_rows_transferred_out(
885        mut self,
886        f: impl Fn(&[usize], &mut teksilo_core::widget::EventContext) + 'static,
887    ) -> Self {
888        self.export.set_on_rows_transferred_out(f);
889        self
890    }
891
892    /// Accept exported rows dropped from a **different** view or source without
893    /// writing a custom `ListDataSource`. Pair with
894    /// [`on_rows_received`](Self::on_rows_received), which is handed the dropped
895    /// items and the insertion index. (Same-view reorder is
896    /// [`reorderable`](Self::reorderable); a custom `ListDataSource` can still
897    /// accept foreign drops through its `can_accept`/`accept_drop` instead.)
898    pub fn accept_foreign_rows(mut self, accept: bool) -> Self {
899        self.export.accept_foreign_rows = accept;
900        self
901    }
902
903    /// Handler for rows accepted via [`accept_foreign_rows`](Self::accept_foreign_rows):
904    /// `(items, insertion_index, ctx)`. Insert them into your model at the
905    /// index.
906    pub fn on_rows_received(
907        mut self,
908        f: impl Fn(Vec<T>, usize, &mut teksilo_core::widget::EventContext) + 'static,
909    ) -> Self {
910        self.export.set_on_rows_received(f);
911        self
912    }
913
914    /// Choose single- vs double-click activation for `on_row_activate` (default
915    /// [`ActivateOn::DoubleClick`](crate::ActivateOn)). Enter/Space activates in
916    /// either mode.
917    pub fn activate_on(mut self, mode: crate::data_views::ActivateOn) -> Self {
918        self.activate_on = mode;
919        self
920    }
921
922    /// Choose the row-selection granularity (None / Single / Multi).
923    /// See [`TableSelectionMode`].
924    pub fn selection_mode(mut self, mode: TableSelectionMode) -> Self {
925        self.selection_mode = mode;
926        self
927    }
928
929    /// Set the index-based row selection model (positions). For identity-based
930    /// selection that survives reorder / filter / window-slide, build the view
931    /// with [`from_source_keyed`](Self::from_source_keyed) instead.
932    pub fn selection(mut self, sel: SelectionModel) -> Self {
933        self.row_selection = Some(RowSelection::from_index(sel));
934        self
935    }
936
937    /// Install an independent cell-selection model on top of row selection.
938    /// See [`CellSelectionModel`].
939    pub fn cell_selection(mut self, sel: CellSelectionModel) -> Self {
940        self.cell_selection = Some(sel);
941        self
942    }
943
944    /// Paint every other row with a tinted background. Default: off.
945    pub fn alternating_rows(mut self, enabled: bool) -> Self {
946        self.alternating_rows = enabled;
947        self
948    }
949
950    /// Draw horizontal and/or vertical grid lines between cells.
951    /// See [`GridLines`].
952    pub fn grid_lines(mut self, kind: GridLines) -> Self {
953        self.grid_lines = kind;
954        self
955    }
956
957    /// Provide an accessible label for the table (`aria-label`). Required
958    /// when the page hosts more than one table so screen readers can
959    /// distinguish them.
960    pub fn a11y_label(mut self, label: impl Into<LocalizedString>) -> Self {
961        self.a11y_label = Some(label.into());
962        self
963    }
964
965    /// Show or hide the built-in vertical scroll bar. Default: visible. Set to
966    /// `false` when an external scroll bar is wired to [`scroll_y_signal`](Self::scroll_y_signal).
967    pub fn show_internal_scrollbars(mut self, show: bool) -> Self {
968        self.show_internal_scrollbars = show;
969        self
970    }
971
972    /// Widget shown when the source is empty.
973    pub fn empty_view(mut self, f: impl Fn() -> Box<dyn Widget> + 'static) -> Self {
974        self.empty_view = Some(Rc::new(f));
975        self
976    }
977
978    // ── Public reactive signals ────────────────────────────────────────
979
980    /// Current vertical scroll offset in logical pixels.
981    pub fn scroll_y_signal(&self) -> &Signal<f32> {
982        &self.scroll_y
983    }
984
985    /// Maximum vertical scroll offset — `total_content_height − viewport_height`.
986    pub fn max_scroll_y_signal(&self) -> &Signal<f32> {
987        &self.max_scroll_y
988    }
989
990    /// Viewport-to-content height ratio, used by external scroll bar thumbs.
991    pub fn viewport_ratio_y_signal(&self) -> &Signal<f32> {
992        &self.viewport_ratio_y
993    }
994
995    /// Current horizontal scroll offset of the Middle (unpinned) pane, in
996    /// logical pixels. Leading/Trailing-pinned columns are unaffected —
997    /// see [`Column::pinned`].
998    pub fn scroll_x_signal(&self) -> &Signal<f32> {
999        &self.scroll_x
1000    }
1001
1002    /// Maximum horizontal scroll offset — `middle_content_width −
1003    /// middle_viewport_width`.
1004    pub fn max_scroll_x_signal(&self) -> &Signal<f32> {
1005        &self.max_scroll_x
1006    }
1007
1008    /// Middle-pane viewport-to-content width ratio, used by external
1009    /// horizontal scroll bar thumbs.
1010    pub fn viewport_ratio_x_signal(&self) -> &Signal<f32> {
1011        &self.viewport_ratio_x
1012    }
1013
1014    /// Active sort: `Some((col_id, dir))` or `None` when unsorted.
1015    /// Mutated by header clicks (cycle: None → Asc → Desc → None) and by
1016    /// [`set_sort`](Self::set_sort) / [`clear_sort`](Self::clear_sort).
1017    /// Bind a [`SortFilterListModel`](teksilo_data::SortFilterListModel) to
1018    /// drive a re-sort of the underlying data:
1019    ///
1020    /// ```ignore
1021    /// let proxy = SortFilterListModel::new(model)
1022    ///     .with_comparator("name", |a, b| a.name.cmp(&b.name));
1023    /// proxy.sort_signal(table.sort_signal().clone());
1024    /// ```
1025    pub fn sort_signal(&self) -> &Signal<Option<(String, SortDirection)>> {
1026        &self.sort_signal
1027    }
1028
1029    /// Map of column id → user-overridden width. A column id appears in
1030    /// this map only after the user resizes that column; missing keys
1031    /// mean "use the declared width policy".
1032    pub fn column_widths_signal(&self) -> &Signal<HashMap<String, f32>> {
1033        &self.column_widths_signal
1034    }
1035
1036    /// Column ids in display order. Updated when the user drags a
1037    /// header to reorder, or imperatively via
1038    /// [`set_column_order`](Self::set_column_order). When empty, the
1039    /// declared order applies. Pinned-side groups (Leading / None /
1040    /// Trailing) are *always* honored — the entries inside this signal
1041    /// only re-sort within each group.
1042    pub fn column_order_signal(&self) -> &Signal<Vec<String>> {
1043        &self.column_order_signal
1044    }
1045
1046    /// Per-id pinning override map. A key here pins the column to that
1047    /// side; missing keys fall back to the declared `Column::pinned`.
1048    /// Updated when the user drags a column across a pane boundary.
1049    pub fn column_pinning_signal(&self) -> &Signal<HashMap<String, PinnedSide>> {
1050        &self.column_pinning_signal
1051    }
1052
1053    /// Currently keyboard-focused cell, as `(row_index, display_col)`,
1054    /// or `None` when no cell is focused. Mutated by the keyboard
1055    /// handler (Arrow keys / Tab / Home / End / PgUp / PgDn /
1056    /// Ctrl-Home / Ctrl-End / Escape) and by direct
1057    /// [`set_focused_cell`](Self::set_focused_cell) /
1058    /// [`clear_focused_cell`](Self::clear_focused_cell) calls.
1059    pub fn focused_cell_signal(&self) -> &Signal<Option<(usize, usize)>> {
1060        &self.focused_cell
1061    }
1062
1063    /// Move the focused cell. Out-of-range values are silently clamped
1064    /// when the next layout runs.
1065    pub fn set_focused_cell(&self, row: usize, col: usize) {
1066        self.focused_cell.set(Some((row, col)));
1067    }
1068
1069    /// Remove keyboard focus from any cell (equivalent to pressing Escape).
1070    pub fn clear_focused_cell(&self) {
1071        self.focused_cell.set(None);
1072    }
1073
1074    /// Cell currently in edit mode, or `None` when no editor is open.
1075    /// Cell delegates inspect this via `CellContext::is_editing` and
1076    /// swap in an editor widget when matched.
1077    pub fn editing_cell_signal(&self) -> &Signal<Option<(usize, usize)>> {
1078        &self.editing_cell
1079    }
1080
1081    /// Begin editing the cell `(row, col_id)`. Silently no-ops if `col_id`
1082    /// isn't a currently-displayed column, or if `row` is outside the visible
1083    /// range — an out-of-range target would otherwise strand `editing_cell` on
1084    /// a row nothing can match.
1085    ///
1086    /// Callable **before the view is mounted**, which is the only point at
1087    /// which a consumer can seed a freshly constructed view with an edit
1088    /// target it already holds. `display_indices` is a cache `build()` fills,
1089    /// so a pre-mount call finds it empty; the order is recomputed on demand
1090    /// in that case rather than resolving against nothing and no-opping for a
1091    /// third, undocumented reason.
1092    pub fn begin_edit(&self, row: usize, col_id: &str) {
1093        let cached = self.display_indices.borrow();
1094        let recomputed;
1095        let display: &[usize] = if cached.is_empty() {
1096            recomputed = self.display_order();
1097            &recomputed
1098        } else {
1099            &cached
1100        };
1101        if let Some(target) =
1102            imperative::resolve_edit_target(row, col_id, &self.columns, display, (self.len_fn)())
1103        {
1104            drop(cached);
1105            self.editing_cell.set(Some(target));
1106        }
1107    }
1108
1109    /// Close the active cell editor without committing (the field's `on_blur` still fires).
1110    pub fn end_edit(&self) {
1111        self.editing_cell.set(None);
1112    }
1113
1114    /// Per-column filter text. Updated by filter affordances in
1115    /// header cells and by
1116    /// [`set_filter`](Self::set_filter) / [`clear_filters`](Self::clear_filters).
1117    /// Bind a `SortFilterListModel<T>` to drive the upstream data:
1118    ///
1119    /// ```ignore
1120    /// let proxy = SortFilterListModel::new(model)
1121    ///     .with_predicate("name", |t| {
1122    ///         let needle = t.to_string();
1123    ///         Box::new(move |r: &Row| r.name.contains(&needle))
1124    ///     });
1125    /// proxy.filters_signal(table.filters_signal().clone());
1126    /// ```
1127    pub fn filters_signal(&self) -> &Signal<HashMap<String, String>> {
1128        &self.filters_signal
1129    }
1130
1131    /// Set or clear the filter text for a single column. An empty `text` removes
1132    /// the entry for `col_id` (same as clearing the filter for that column).
1133    pub fn set_filter(&self, col_id: &str, text: &str) {
1134        imperative::set_filter(&self.filters_signal, col_id, text);
1135    }
1136
1137    /// Remove all active column filters.
1138    pub fn clear_filters(&self) {
1139        imperative::set_if_changed(&self.filters_signal, HashMap::new());
1140    }
1141
1142    // ── Imperative API ─────────────────────────────────────────────────
1143
1144    /// Scroll so that `row` is aligned to the top of the viewport. A no-op
1145    /// before the first layout pass.
1146    pub fn scroll_to_row(&self, row: usize) {
1147        imperative::scroll_to_row(row, &self.row_metrics, &self.scroll_y, &self.max_scroll_y);
1148    }
1149
1150    /// Set the active sort imperatively. Equivalent to writing to
1151    /// [`sort_signal`](Self::sort_signal) directly, except that an unchanged
1152    /// value neither writes nor notifies — see
1153    /// [`set_column_widths`](Self::set_column_widths).
1154    pub fn set_sort(&self, col_id: Option<&str>, dir: SortDirection) {
1155        let next = col_id.map(|c| (c.to_string(), dir));
1156        imperative::set_if_changed(&self.sort_signal, next);
1157    }
1158
1159    /// Clear the active sort.
1160    pub fn clear_sort(&self) {
1161        imperative::set_if_changed(&self.sort_signal, None);
1162    }
1163
1164    /// Set or remove a single column's user-resized width override.
1165    /// A non-positive `width` removes the entry (the column reverts to
1166    /// its declared width policy).
1167    pub fn set_column_width(&self, col_id: &str, width: f32) {
1168        imperative::set_column_width(&self.column_widths_signal, col_id, width);
1169    }
1170
1171    /// Replace the full width-override map (typically used to restore
1172    /// a persisted layout).
1173    ///
1174    /// A no-op when the map is unchanged, so the documented
1175    /// settings-round-trip wiring (see docs/table-view.md, "Persistence")
1176    /// terminates instead of recursing: `Signal::set` has no equality check of
1177    /// its own, and a live resize writes a width on every pointer move.
1178    pub fn set_column_widths(&self, widths: HashMap<String, f32>) {
1179        imperative::set_column_widths(&self.column_widths_signal, widths);
1180    }
1181
1182    /// Replace the column-order list. Ids not declared on this table
1183    /// are silently dropped on the next layout pass.
1184    pub fn set_column_order(&self, order: Vec<String>) {
1185        imperative::set_if_changed(&self.column_order_signal, order);
1186    }
1187
1188    /// Pin or unpin a single column.
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    /// Effective pinning for a column — `column_pinning_signal` wins
1194    /// over the declared `Column::pinned`.
1195    fn effective_pinning(&self, col: &Column<T>) -> PinnedSide {
1196        self.column_pinning_signal
1197            .get()
1198            .get(&col.id)
1199            .copied()
1200            .unwrap_or(col.pinned)
1201    }
1202
1203    /// Compute the visible column display order: a flat list of indices
1204    /// into `self.columns`. Columns are partitioned by effective
1205    /// pinning (Leading first, then None, then Trailing); within each
1206    /// pane they appear in `column_order_signal` order, with any
1207    /// columns missing from the signal appended in declaration order.
1208    fn display_order(&self) -> Vec<usize> {
1209        let order_signal = self.column_order_signal.get();
1210        let mut order_map: HashMap<&str, usize> = HashMap::new();
1211        for (i, id) in order_signal.iter().enumerate() {
1212            order_map.insert(id.as_str(), i);
1213        }
1214        let mut leading: Vec<usize> = Vec::new();
1215        let mut middle: Vec<usize> = Vec::new();
1216        let mut trailing: Vec<usize> = Vec::new();
1217        for (i, col) in self.columns.iter().enumerate() {
1218            match self.effective_pinning(col) {
1219                PinnedSide::Leading => leading.push(i),
1220                PinnedSide::None => middle.push(i),
1221                PinnedSide::Trailing => trailing.push(i),
1222            }
1223        }
1224        // Sort key: explicit `column_order_signal` positions win (low
1225        // values); columns missing from the signal fall back to their
1226        // declaration index, offset by a huge constant so they always
1227        // sort after any explicitly-ordered column.
1228        const FALLBACK_BASE: usize = usize::MAX / 2;
1229        let sort_pane = |bucket: &mut Vec<usize>, cols: &[Column<T>]| {
1230            bucket.sort_by_key(|&i| {
1231                order_map
1232                    .get(cols[i].id.as_str())
1233                    .copied()
1234                    .unwrap_or(FALLBACK_BASE + i)
1235            });
1236        };
1237        sort_pane(&mut leading, &self.columns);
1238        sort_pane(&mut middle, &self.columns);
1239        sort_pane(&mut trailing, &self.columns);
1240        let mut out = Vec::with_capacity(leading.len() + middle.len() + trailing.len());
1241        out.extend(leading);
1242        let leading_count = out.len();
1243        out.extend(middle);
1244        let middle_end = out.len();
1245        out.extend(trailing);
1246        // Stash the boundaries so paint / drop-zone math can read them.
1247        *self.pane_boundaries.borrow_mut() = PaneBoundaries::new(leading_count, middle_end);
1248        out
1249    }
1250
1251    /// Scroll the minimum distance needed to make `row` visible. A no-op
1252    /// before the first layout pass, when the viewport height is not yet known.
1253    pub fn ensure_row_visible(&self, row: usize) {
1254        imperative::ensure_row_visible(
1255            row,
1256            &self.row_metrics,
1257            &self.scroll_y,
1258            &self.max_scroll_y,
1259            self.viewport_height.get(),
1260            self.laid_out.get(),
1261        );
1262    }
1263
1264    // ── Internals ──────────────────────────────────────────────────────
1265
1266    /// Scroll the row the keyboard cursor is on into view when this table
1267    /// takes focus.
1268    ///
1269    /// Only the rows near the viewport are realized, so on a table taller than
1270    /// the window the cursor's row frequently has no widget. Everything that
1271    /// speaks for it then has nothing to speak about: no row node carries
1272    /// `selected`, the `cell_map` lookup in `accessibility()` finds nothing so
1273    /// no `active_descendant` is nominated, and a screen reader taking focus
1274    /// here is told nothing at all. Worse, the first arrow press steps *past*
1275    /// that row, because the cursor was somewhere the user was never shown.
1276    ///
1277    /// The row resolves the way `context_menu_key_target` below resolves it:
1278    /// the focused cell's row if the user has navigated, else the first
1279    /// selected row. Both are "the row this table is currently about", and a
1280    /// session restored into a selection has no focused cell yet.
1281    ///
1282    /// **Vertical only, and that is the whole of it here.** The body pane
1283    /// realizes every display column of a realized row, iterating the full
1284    /// `display_indices` with no `scroll_x` culling
1285    /// (`table_view/body_pane.rs:313-443`), so the cell is in `cell_map`
1286    /// whatever the horizontal offset is. The row is the only axis that can
1287    /// hide it from the AT tree. A sighted keyboard user can still land with
1288    /// the cursor's column scrolled off to the side, which would want
1289    /// `ensure_col_visible` (`table_view/keyboard.rs:483`); that is a private
1290    /// helper of the key-handler module and out of this change's reach.
1291    ///
1292    /// Ensure-visible rather than scroll-to: a row already on screen must not
1293    /// jump under somebody who can see it.
1294    ///
1295    /// The handles are cloned into the effect rather than reaching through
1296    /// `self`, which the closure cannot borrow.
1297    fn reveal_current_row_on_focus(&self, ctx: &mut teksilo_core::build_context::BuildContext) {
1298        let row_metrics = self.row_metrics.clone();
1299        let scroll_y = self.scroll_y.clone();
1300        let max_scroll_y = self.max_scroll_y.clone();
1301        let viewport_height = self.viewport_height.clone();
1302        let laid_out = self.laid_out.clone();
1303        let focused_cell = self.focused_cell.clone();
1304        let selection = self.row_selection.clone();
1305
1306        ctx.effect(&self.view_focused, move |focused| {
1307            if !*focused {
1308                return;
1309            }
1310            let Some(row) = focused_cell.get().map(|(row, _col)| row).or_else(|| {
1311                selection
1312                    .as_ref()
1313                    .and_then(|s| s.selected_indices().first().copied())
1314            }) else {
1315                return;
1316            };
1317            imperative::ensure_row_visible(
1318                row,
1319                &row_metrics,
1320                &scroll_y,
1321                &max_scroll_y,
1322                viewport_height.get(),
1323                laid_out.get(),
1324            );
1325        });
1326    }
1327
1328    /// The configured row height (override) or the table style's 28 px
1329    /// fallback. In the non-uniform modes this is the seed estimate;
1330    /// real geometry lives in `row_metrics`.
1331    fn effective_row_height(&self) -> f32 {
1332        self.row_height.unwrap_or(cp::ROW_HEIGHT)
1333    }
1334
1335    fn effective_header_height(&self) -> f32 {
1336        if !self.show_header {
1337            0.0
1338        } else {
1339            self.header_height.unwrap_or(cp::HEADER_HEIGHT)
1340        }
1341    }
1342
1343    fn total_content_height(&self) -> f32 {
1344        self.row_metrics.borrow_mut().total_height((self.len_fn)())
1345    }
1346
1347    fn visible_range(&self) -> (usize, usize) {
1348        self.row_metrics.borrow_mut().visible_range(
1349            self.scroll_y.get(),
1350            self.viewport_height.get(),
1351            (self.len_fn)(),
1352            BUFFER_ROWS,
1353        )
1354    }
1355
1356    fn clamp_scroll(&self) {
1357        let max = self.max_scroll_y.get();
1358        let current = self.scroll_y.get();
1359        let clamped = current.clamp(0.0, max);
1360        if (clamped - current).abs() > 0.001 {
1361            self.scroll_y.set(clamped);
1362        }
1363    }
1364}
1365
1366impl<T: 'static> std::fmt::Debug for TableView<T> {
1367    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1368        f.debug_struct("TableView")
1369            .field("rows", &(self.len_fn)())
1370            .field("columns", &self.columns.len())
1371            .field("scroll_y", &self.scroll_y.get())
1372            .field("selection_mode", &self.selection_mode)
1373            .field("scroll_bar_style", &self.scroll_bar_style)
1374            .finish()
1375    }
1376}
1377
1378impl<T: 'static> Widget for TableView<T> {
1379    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1380        let self_id = ctx.self_id();
1381        ctx.enabled_when(self_id, self.enabled.clone());
1382
1383        let row_h = self.effective_row_height();
1384        let header_h = self.effective_header_height();
1385
1386        // Version signal — bumps drive a rebuild.
1387        let version = ctx.signal(0_u64);
1388        version.bind_to(ctx.self_id(), ctx.binding_registry(), BindingLevel::Rebuild);
1389
1390        // Scroll-y at Relayout: place_children re-runs without rebuild.
1391        self.scroll_y.bind_to(
1392            ctx.self_id(),
1393            ctx.binding_registry(),
1394            BindingLevel::Relayout,
1395        );
1396        ctx.register_animated_signal(&self.scroll_y);
1397
1398        // Scroll-x mirrors scroll-y: Relayout re-places the header + body
1399        // bands (and any pane-aware root decorations) without a rebuild.
1400        self.scroll_x.bind_to(
1401            ctx.self_id(),
1402            ctx.binding_registry(),
1403            BindingLevel::Relayout,
1404        );
1405        ctx.register_animated_signal(&self.scroll_x);
1406
1407        // Row-drop insertion indicator at RepaintOnly so on_drag_hover /
1408        // on_drag_leave `set(...)` calls dirty paint without a rebuild.
1409        self.drop_feedback.bind_to(
1410            ctx.self_id(),
1411            ctx.binding_registry(),
1412            BindingLevel::RepaintOnly,
1413        );
1414
1415        // Pane → root total refresh (auto-measure mode): re-place this
1416        // root when the body pane's measurements changed the content
1417        // total, so `max_scroll_y` / the thumb ratio pick up the
1418        // corrected value.
1419        self.pane_total_refresh.bind_to(
1420            ctx.self_id(),
1421            ctx.binding_registry(),
1422            BindingLevel::Relayout,
1423        );
1424
1425        // Column width overrides: any change re-runs place_children
1426        // (which calls ColumnSolver with the latest map). No rebuild
1427        // needed — widths flow through `column_widths` Rc into rows.
1428        self.column_widths_signal.bind_to(
1429            ctx.self_id(),
1430            ctx.binding_registry(),
1431            BindingLevel::Relayout,
1432        );
1433
1434        // `OnRelease` resize guide line — paint-only, nothing moves until the
1435        // button comes up.
1436        self.resize_preview_x.bind_to(
1437            ctx.self_id(),
1438            ctx.binding_registry(),
1439            BindingLevel::RepaintOnly,
1440        );
1441
1442        // A resize drag that loses the window never gets its PointerUp: the
1443        // user Alt-Tabs (or a native dialog steals focus) with the button
1444        // down, releases it over another window, and the OS delivers the Up
1445        // nowhere. Abandon the gesture on deactivation, or the state outlives
1446        // it and the next bare PointerMove drags the column with no button
1447        // held. Nothing is committed — an interrupted drag leaves the column
1448        // wherever the last delivered move put it, which is what the user last
1449        // saw.
1450        {
1451            let resize_state = self.resize_state.clone();
1452            let resize_target = self.resize_target.clone();
1453            let resize_preview_x = self.resize_preview_x.clone();
1454            ctx.effect(&ctx.window_active_signal(), move |active| {
1455                if !*active && resize_state.borrow().is_some() {
1456                    *resize_state.borrow_mut() = None;
1457                    resize_target.set(None);
1458                    resize_preview_x.set(None);
1459                }
1460            });
1461        }
1462
1463        // Column order + pinning: changes require a rebuild because the
1464        // header cells and row cells must be re-emitted in the new order
1465        // (each cell captures its display-position-based 1-based index).
1466        let v_for_order = version.clone();
1467        let order_ver = Rc::new(Cell::new(0_u64));
1468        ctx.effect(&self.column_order_signal, move |_| {
1469            let next = order_ver.get() + 1;
1470            order_ver.set(next);
1471            v_for_order.set(next);
1472        });
1473        let v_for_pin = version.clone();
1474        let pin_ver = Rc::new(Cell::new(0_u64));
1475        ctx.effect(&self.column_pinning_signal, move |_| {
1476            let next = pin_ver.get() + 1;
1477            pin_ver.set(next);
1478            v_for_pin.set(next);
1479        });
1480        let v_for_edit = version.clone();
1481        let edit_ver = Rc::new(Cell::new(0_u64));
1482        ctx.effect(&self.editing_cell, move |_| {
1483            let next = edit_ver.get() + 1;
1484            edit_ver.set(next);
1485            v_for_edit.set(next);
1486        });
1487        let v_for_filter = version.clone();
1488        let filter_ver = Rc::new(Cell::new(0_u64));
1489        ctx.effect(&self.filters_signal, move |_| {
1490            let next = filter_ver.get() + 1;
1491            filter_ver.set(next);
1492            v_for_filter.set(next);
1493        });
1494
1495        // Sort signal: a change requires a rebuild because each header
1496        // cell's chevron child is added/removed conditionally and the
1497        // AccessKit `set_sort_direction` is captured at build time.
1498        let v_for_sort = version.clone();
1499        let sort_ver = Rc::new(Cell::new(0_u64));
1500        ctx.effect(&self.sort_signal, move |_| {
1501            let next = sort_ver.get() + 1;
1502            sort_ver.set(next);
1503            v_for_sort.set(next);
1504        });
1505
1506        // Observe model changes -> bump version.
1507        let v_for_data = version.clone();
1508        let data_ver = Rc::new(Cell::new(0_u64));
1509        let upstream = (self.observe_fn)(Box::new({
1510            let dv = data_ver.clone();
1511            let sel_for_adjust = self.row_selection.clone();
1512            let cell_sel_for_adjust = self.cell_selection.clone();
1513            let metrics_for_data = self.row_metrics.clone();
1514            let len_for_data = self.len_fn.clone();
1515            let first_changed = self.first_changed_fn.clone();
1516            move |change| {
1517                // Keep row metrics in step with the data: rows before
1518                // the first changed index keep their heights, the rest
1519                // re-derive. A `SortFilterListModel` source collapses
1520                // everything to `Reset` — its real divergence comes
1521                // through the side-channel, which is what lets an
1522                // append keep the measured prefix.
1523                let divergence = match change {
1524                    DataChange::ItemsInserted { range } | DataChange::ItemsRemoved { range } => {
1525                        Some(range.start)
1526                    }
1527                    DataChange::ItemUpdated { index } => Some(*index),
1528                    DataChange::ItemsMoved { from, to, .. } => Some((*from).min(*to)),
1529                    DataChange::WindowLoaded { range } => Some(range.start),
1530                    DataChange::Reset => (first_changed)(),
1531                };
1532                metrics_for_data
1533                    .borrow_mut()
1534                    .apply_divergence(divergence, (len_for_data)());
1535                // Keep row selection in step: index-shift (index model) or
1536                // prune orphaned keys (keyed model). Cell selection (always
1537                // index-based) is adjusted separately below.
1538                if let Some(ref rs) = sel_for_adjust {
1539                    rs.on_data_change(change);
1540                }
1541                if let Some(ref s) = cell_sel_for_adjust {
1542                    match change {
1543                        DataChange::ItemsInserted { range } => {
1544                            s.adjust_for_row_insert(range.start, range.end - range.start);
1545                        }
1546                        DataChange::ItemsRemoved { range } => {
1547                            s.adjust_for_row_remove(range.start, range.end - range.start);
1548                        }
1549                        DataChange::ItemsMoved { from, to, count } => {
1550                            s.adjust_for_row_move(*from, *to, *count);
1551                        }
1552                        DataChange::Reset => s.clear(),
1553                        _ => {}
1554                    }
1555                }
1556                let next = dv.get() + 1;
1557                dv.set(next);
1558                v_for_data.set(next);
1559            }
1560        }));
1561        ctx.own_handle(upstream);
1562
1563        // Observe selection changes -> bump version (rebuild updates the
1564        // `is_selected` arg passed to cell delegates).
1565        if let Some(ref rs) = self.row_selection {
1566            let v_for_sel = version.clone();
1567            let sel_ver = Rc::new(Cell::new(0_u64));
1568            let handle = rs.observe_for_rebuild(move || {
1569                let next = sel_ver.get() + 1;
1570                sel_ver.set(next);
1571                v_for_sel.set(next);
1572            });
1573            ctx.own_handle(handle);
1574        }
1575        if let Some(ref cs) = self.cell_selection {
1576            let v_for_csel = version.clone();
1577            let csel_ver = Rc::new(Cell::new(0_u64));
1578            ctx.effect(&cs.selection_signal(), move |_| {
1579                let next = csel_ver.get() + 1;
1580                csel_ver.set(next);
1581                v_for_csel.set(next);
1582            });
1583        }
1584
1585        // Observe scroll position — only rebuild when visible range exits
1586        // the buffered window. The Relayout binding above handles
1587        // intra-buffer scrolls without a rebuild.
1588        let vp_h = self.viewport_height.clone();
1589        let len_for_scroll = self.len_fn.clone();
1590        let (built_start, built_end) = self.visible_range();
1591        let prev_built_start = Rc::new(Cell::new(built_start));
1592        let prev_built_end = Rc::new(Cell::new(built_end));
1593        let v_for_scroll = version.clone();
1594        let scroll_ver = Rc::new(Cell::new(0_u64));
1595        let scroll_handle = self.scroll_y.observe({
1596            let pbs = prev_built_start.clone();
1597            let pbe = prev_built_end.clone();
1598            let sv = scroll_ver.clone();
1599            let metrics = self.row_metrics.clone();
1600            move |y| {
1601                let count = (len_for_scroll)();
1602                let (visible_start, visible_end) =
1603                    metrics.borrow_mut().visible_range(*y, vp_h.get(), count, 0);
1604                if visible_start < pbs.get() || visible_end > pbe.get() {
1605                    let new_start = visible_start.saturating_sub(BUFFER_ROWS);
1606                    let new_end = (visible_end + BUFFER_ROWS).min(count);
1607                    pbs.set(new_start);
1608                    pbe.set(new_end);
1609                    let next = sv.get() + 1;
1610                    sv.set(next);
1611                    v_for_scroll.set(next);
1612                }
1613            }
1614        });
1615        ctx.own_handle(scroll_handle);
1616
1617        // Compute display order eagerly — the keyboard handler needs
1618        // the column count, and the header / body builds below also
1619        // need it. We re-write `self.display_indices` here; later
1620        // build steps read it.
1621        let display_indices_now = self.display_order();
1622
1623        // Remap any `(row, display_pos)` pairs the *previous* order left in
1624        // `focused_cell` / `editing_cell` / `cell_selection` onto their
1625        // column's position under the order just computed, before it
1626        // overwrites `self.display_indices` below. A column reorder drag or
1627        // a pin toggle only bumps `version` (see the `column_order_signal` /
1628        // `column_pinning_signal` effects above) — display position is
1629        // recomputed here on every rebuild regardless of cause, so this map
1630        // is the identity (a no-op) unless THIS rebuild's cause was an
1631        // order/pinning change.
1632        {
1633            let old_display = self.display_indices.borrow();
1634            if !old_display.is_empty() {
1635                let old_to_new: Vec<Option<usize>> = old_display
1636                    .iter()
1637                    .map(|&decl_idx| {
1638                        let id = &self.columns[decl_idx].id;
1639                        display_indices_now
1640                            .iter()
1641                            .position(|&new_decl_idx| self.columns[new_decl_idx].id == *id)
1642                    })
1643                    .collect();
1644                drop(old_display);
1645                imperative::remap_cell_state(
1646                    &self.focused_cell,
1647                    &self.editing_cell,
1648                    self.cell_selection.as_ref(),
1649                    &old_to_new,
1650                );
1651            }
1652        }
1653        *self.display_indices.borrow_mut() = display_indices_now.clone();
1654
1655        // Self handlers: scroll wheel + keyboard + clip + focusable.
1656        let scroll_y_for_wheel = self.scroll_y.clone();
1657        let max_scroll_for_wheel = self.max_scroll_y.clone();
1658        let scroll_x_for_wheel = self.scroll_x.clone();
1659        let max_scroll_x_for_wheel = self.max_scroll_x.clone();
1660        let line_height = row_h;
1661        let overscroll_behavior = self.overscroll_behavior;
1662        let smooth_scrolling = self.smooth_scrolling;
1663        let smooth_scroll_duration = self.smooth_scroll_duration;
1664
1665        // Bind focused_cell at RepaintOnly — its update redraws the
1666        // focus ring without rebuilding the row tree. Also at
1667        // AccessibilityOnly (orthogonal — see `BindingLevel`) so a
1668        // keyboard focus move re-walks the AT tree and re-resolves
1669        // `active_descendant` in `accessibility()` below, even though
1670        // nothing about the cell's own node changed.
1671        self.focused_cell.bind_to(
1672            ctx.self_id(),
1673            ctx.binding_registry(),
1674            BindingLevel::RepaintOnly,
1675        );
1676        self.focused_cell.bind_to(
1677            ctx.self_id(),
1678            ctx.binding_registry(),
1679            BindingLevel::AccessibilityOnly,
1680        );
1681
1682        // Focus-aware selection + modality-gated focus ring. `begin_view_focus`
1683        // keys the scope signal on this root id directly — the same id the body
1684        // pane uses for its row scope (`drag_anchor = ctx.self_id()`), and
1685        // independent of the arena focusable flag (not yet wired here). A plain
1686        // `view_focus_active()` here would find no focusable ancestor and fall
1687        // back to the constant-`true` "outside any scope" signal — `true`
1688        // whenever ANY widget holds focus, lighting every table's ring at once.
1689        // The signal is `true` whenever the table or any descendant holds focus,
1690        // so the selection band dims to `SelectedInactive` on focus-out. Pop
1691        // straight back; the body pane re-pushes the same cached signal.
1692        // `focus_visible` gates the cell ring to keyboard navigation. Both bound
1693        // `RepaintOnly`: a focus/modality change redraws without a rebuild.
1694        self.view_focused = ctx.begin_view_focus();
1695        ctx.end_view_focus();
1696        self.focus_visible = ctx.focus_visible();
1697        self.reveal_current_row_on_focus(ctx);
1698        self.view_focused.bind_to(
1699            ctx.self_id(),
1700            ctx.binding_registry(),
1701            BindingLevel::RepaintOnly,
1702        );
1703        self.focus_visible.bind_to(
1704            ctx.self_id(),
1705            ctx.binding_registry(),
1706            BindingLevel::RepaintOnly,
1707        );
1708
1709        // Build the navigator + key handler. The keyboard module is
1710        // generic over RowNavigator so TreeTableView can plug in its own
1711        // tree-aware navigator.
1712        let navigator: Rc<dyn row_navigator::RowNavigator> =
1713            Rc::new(row_navigator::FlatNavigator::new(self.len_fn.clone()));
1714        // display_col_to_id resolves a display position back to its
1715        // column id, so the keyboard module doesn't need a `Column<T>`
1716        // reference. Snapshotted at build; rebuilds re-issue this.
1717        let column_ids_in_display_order: Vec<String> = display_indices_now
1718            .iter()
1719            .map(|&i| self.columns[i].id.clone())
1720            .collect();
1721        let display_col_to_id: Rc<dyn Fn(usize) -> Option<String>> = {
1722            let ids = column_ids_in_display_order;
1723            Rc::new(move |pos| ids.get(pos).cloned())
1724        };
1725        // The effective trigger set per display column: the view's, overridden
1726        // by the column's own, and `NONE` for a non-editable one. Resolved here
1727        // so the keyboard handler never has to reach a `Column<T>`.
1728        let display_col_triggers: Rc<dyn Fn(usize) -> EditTriggers> = {
1729            let view_triggers = self.edit_triggers;
1730            let per_display_column: Vec<EditTriggers> = display_indices_now
1731                .iter()
1732                .map(|&i| self.columns[i].effective_edit_triggers(view_triggers))
1733                .collect();
1734            Rc::new(move |pos| {
1735                per_display_column
1736                    .get(pos)
1737                    .copied()
1738                    .unwrap_or(EditTriggers::NONE)
1739            })
1740        };
1741
1742        // Type-ahead label resolver (row -> Some(text)) built from the user's
1743        // `Fn(&T) -> String` + the side-effect source read: the closure only
1744        // fires for a resident row, so unloaded (lazy) rows resolve to `None`
1745        // and the search skips them.
1746        let type_ahead_label: Option<Rc<dyn Fn(usize) -> Option<String>>> =
1747            self.type_ahead_label.clone().map(|user| {
1748                let with_item = self.with_item_fn.clone();
1749                Rc::new(move |i: usize| {
1750                    let out = std::cell::RefCell::new(None);
1751                    (with_item)(i, &|item| {
1752                        *out.borrow_mut() = Some(user(item));
1753                    });
1754                    out.into_inner()
1755                }) as Rc<dyn Fn(usize) -> Option<String>>
1756            });
1757
1758        let key_cfg = keyboard::KeyHandlerConfig {
1759            navigator,
1760            col_count: display_indices_now.len().max(1),
1761            // Flat table: no tree column exists. `FlatNavigator` reports no
1762            // children and never expands, so this value is inert — it only has
1763            // to be a position the cursor can actually occupy.
1764            tree_column_display_pos: 0,
1765            focused_cell: self.focused_cell.clone(),
1766            selection_mode: self.selection_mode,
1767            selection: self.row_selection.clone(),
1768            cell_selection: self.cell_selection.clone(),
1769            scroll_y: self.scroll_y.clone(),
1770            max_scroll_y: self.max_scroll_y.clone(),
1771            viewport_height: self.viewport_height.clone(),
1772            body_bounds: self.body_bounds.clone(),
1773            row_metrics: self.row_metrics.clone(),
1774            tab_traversal: self.tab_traversal,
1775            editing_cell: self.editing_cell.clone(),
1776            display_col_to_id,
1777            display_col_triggers,
1778            on_cell_edit_request: self.on_cell_edit_request.clone(),
1779            on_row_activate: self.on_row_activate.clone(),
1780            type_ahead: self.type_ahead.clone(),
1781            type_ahead_label,
1782            type_ahead_timeout: self.type_ahead_timeout,
1783            column_widths: self.column_widths.clone(),
1784            pane_boundaries: *self.pane_boundaries.borrow(),
1785            scroll_x: self.scroll_x.clone(),
1786            max_scroll_x: self.max_scroll_x.clone(),
1787            middle_viewport_width: self.middle_viewport_width.clone(),
1788        };
1789
1790        // Row DnD is owned by the backing source. The view computes the
1791        // geometric (target_row, position) and asks the source: `can_accept`
1792        // on hover gates the insertion line (forbidden → no affordance),
1793        // `accept_drop` on release commits the move (in-place for a
1794        // `ListModel`, routed for an external source). Same-view reorders and
1795        // foreign / cross-table drops both flow through `accept_drop` — the
1796        // erased closures recover SameView-vs-Foreign from the payload.
1797        let view_id = self.model_id;
1798        let can_accept_hover = self.dnd.can_accept_fn.clone();
1799        let scroll_for_hover = self.scroll_y.clone();
1800        let metrics_for_hover = self.row_metrics.clone();
1801        let len_for_hover = self.len_fn.clone();
1802        let header_h_for_hover = header_h;
1803        let band_width_for_hover = self.header_strip_width.clone();
1804        let feedback_for_hover = self.drop_feedback.clone();
1805        let export_for_hover = self.export.clone();
1806
1807        let accept_drop_for_drop = self.dnd.accept_drop_fn.clone();
1808        let scroll_y_for_drop = self.scroll_y.clone();
1809        let header_h_for_drop = header_h;
1810        let metrics_for_drop = self.row_metrics.clone();
1811        let len_fn_for_drop = self.len_fn.clone();
1812        let feedback_for_drop = self.drop_feedback.clone();
1813        let export_for_drop = self.export.clone();
1814        let reorderable_for_drop = self.reorderable;
1815
1816        let feedback_for_leave = self.drop_feedback.clone();
1817        let scroll_for_tick = self.scroll_y.clone();
1818        let max_scroll_for_tick = self.max_scroll_y.clone();
1819        let viewport_for_tick = self.viewport_height.clone();
1820        let header_h_for_tick = header_h;
1821
1822        // Alt+Arrow reorder wraps the shared key handler: the move is a
1823        // synthetic same-view `RowDragData` through the source's
1824        // `accept_drop`, so it travels exactly the pointer-drop path. Every
1825        // other key falls through to the shared navigator (cell/row
1826        // movement, edit, etc.).
1827        let mut shared_key = keyboard::build_key_handler(key_cfg);
1828        let reorderable_kbd = self.reorderable;
1829        let accept_drop_kbd = self.dnd.accept_drop_fn.clone();
1830        let stash_kbd = self.dnd.stash_drag_keys_fn.clone();
1831        let focused_kbd = self.focused_cell.clone();
1832        let sel_kbd = self.row_selection.clone();
1833        let len_kbd = self.len_fn.clone();
1834        let key_handler = move |event: &teksilo_core::event::WidgetEvent,
1835                                ctx: &mut teksilo_core::widget::EventContext|
1836              -> teksilo_core::event::EventResponse {
1837            use teksilo_core::event::{EventResponse, Key, WidgetEvent};
1838            if reorderable_kbd
1839                && let WidgetEvent::KeyDown { key, modifiers, .. } = event
1840                && modifiers.alt()
1841            {
1842                let count = (len_kbd)();
1843                if count > 0 {
1844                    let cur = focused_kbd.get().map(|(r, _)| r).or_else(|| {
1845                        sel_kbd
1846                            .as_ref()
1847                            .and_then(|s| s.selected_indices().first().copied())
1848                    });
1849                    if let Some(idx) = cur {
1850                        let mv = match key {
1851                            Key::ArrowUp if idx > 0 => {
1852                                Some((idx - 1, DropPosition::Before, idx - 1))
1853                            }
1854                            Key::ArrowDown if idx + 1 < count => {
1855                                Some((idx + 1, DropPosition::After, idx + 1))
1856                            }
1857                            _ => None,
1858                        };
1859                        if let Some((target, position, dest)) = mv {
1860                            // Synthetic same-view payloads must stash the
1861                            // dragged row's key at construction — the accept
1862                            // path resolves identity from the stash, never
1863                            // from `rows`.
1864                            (stash_kbd)(&[idx]);
1865                            let payload =
1866                                teksilo_core::drag_payload::DragPayload::typed(RowDragData::<T> {
1867                                    source: view_id,
1868                                    rows: vec![idx],
1869                                    items: None,
1870                                });
1871                            if (accept_drop_kbd)(&payload, target, position, view_id) {
1872                                if let Some(ref s) = sel_kbd {
1873                                    s.select(dest);
1874                                }
1875                                let col = focused_kbd.get().map(|(_, c)| c).unwrap_or(0);
1876                                focused_kbd.set(Some((dest, col)));
1877                            }
1878                            return EventResponse::Handled;
1879                        }
1880                    }
1881                }
1882            }
1883            shared_key(event, ctx)
1884        };
1885
1886        let mut handlers = HandlerSet::new()
1887            .on_scroll(move |event, _ctx| match event {
1888                teksilo_core::event::WidgetEvent::Scroll { delta, modifiers } => {
1889                    let (raw_dx, raw_dy) = match delta {
1890                        teksilo_core::event::ScrollDelta::Lines { x, y } => {
1891                            (x * line_height, y * line_height)
1892                        }
1893                        teksilo_core::event::ScrollDelta::Pixels { x, y } => (*x, *y),
1894                    };
1895                    // Shift+wheel remaps a vertical-only wheel to horizontal
1896                    // scroll (the `TabBar` precedent) — a genuine two-axis
1897                    // trackpad delta (both native `dx` and `dy` nonzero)
1898                    // passes through unremapped either way.
1899                    let (dx, dy) = if modifiers.shift() && raw_dx.abs() < f32::EPSILON {
1900                        (raw_dy, 0.0)
1901                    } else {
1902                        (raw_dx, raw_dy)
1903                    };
1904
1905                    let mut moved_any = false;
1906                    if dy.abs() > 0.0 {
1907                        let current = scroll_y_for_wheel.get();
1908                        let max = max_scroll_for_wheel.get();
1909                        // Base off the animation target (not the rendered
1910                        // offset) so a mid-fling boundary correctly chains
1911                        // and successive notches accumulate instead of
1912                        // restarting from the partway-animated position.
1913                        let base = scroll_y_for_wheel.animation_target().unwrap_or(current);
1914                        let (new_y, moved) =
1915                            crate::common::scroll::scroll_clamp_axis(base, dy, max);
1916                        if moved {
1917                            if smooth_scrolling {
1918                                scroll_y_for_wheel.animate_to(
1919                                    new_y,
1920                                    smooth_scroll_duration,
1921                                    Easing::EaseOut,
1922                                );
1923                            } else {
1924                                scroll_y_for_wheel.set(new_y);
1925                            }
1926                        }
1927                        moved_any |= moved;
1928                    }
1929                    if dx.abs() > 0.0 {
1930                        let current = scroll_x_for_wheel.get();
1931                        let max = max_scroll_x_for_wheel.get();
1932                        let base = scroll_x_for_wheel.animation_target().unwrap_or(current);
1933                        let (new_x, moved) =
1934                            crate::common::scroll::scroll_clamp_axis(base, dx, max);
1935                        if moved {
1936                            if smooth_scrolling {
1937                                scroll_x_for_wheel.animate_to(
1938                                    new_x,
1939                                    smooth_scroll_duration,
1940                                    Easing::EaseOut,
1941                                );
1942                            } else {
1943                                scroll_x_for_wheel.set(new_x);
1944                            }
1945                        }
1946                        moved_any |= moved;
1947                    }
1948                    // Chain to an ancestor scrollable when fully clamped on
1949                    // every axis touched (unless Contain), otherwise consume.
1950                    crate::common::scroll::scroll_response(
1951                        moved_any,
1952                        overscroll_behavior == OverscrollBehavior::Contain,
1953                    )
1954                }
1955                _ => teksilo_core::event::EventResponse::Ignored,
1956            })
1957            .clips_children(true)
1958            .focusable(true);
1959
1960        handlers = handlers.on_key(key_handler);
1961
1962        // Row-level drop target: registered only when this table can
1963        // reorder its own rows or accept foreign ones (mirrors ListView).
1964        // Column reorder lives entirely on the header strip
1965        // (`attach_header_reorder_handlers`) and is untouched by this gate.
1966        if self.export.is_drop_target(self.reorderable) {
1967            handlers = handlers
1968                .on_drag_hover(move |payload, position, _ctx| {
1969                    // Column reorder is handled by the header strip; only
1970                    // row-level drops (same-view `RowDragData` or a foreign
1971                    // payload the source accepts) get an insertion line here.
1972                    if payload.has_typed::<ColumnReorderDragData>() {
1973                        feedback_for_hover.set(None);
1974                        return teksilo_core::DropFeedback::NoFeedback;
1975                    }
1976                    let body_y = position.y - header_h_for_hover;
1977                    let scroll = scroll_for_hover.get();
1978                    let content_y = body_y + scroll;
1979                    let len = (len_for_hover)();
1980                    let (ins, line_y) = {
1981                        let mut m = metrics_for_hover.borrow_mut();
1982                        m.resize(len);
1983                        let ins = m.insertion_index(content_y);
1984                        (ins, m.row_top(ins) - scroll)
1985                    };
1986                    let width = band_width_for_hover.get();
1987                    // Source-owned validation: paint the line only when the
1988                    // source does not reject the hovered position. A foreign
1989                    // exported row is allowed when `accept_foreign_rows` is on
1990                    // even though a bare `ListModel`'s `can_accept` rejects
1991                    // the `Foreign` branch.
1992                    let allowed = flat_insertion_target(ins, len).is_some_and(|(target, pos)| {
1993                        !matches!(
1994                            (can_accept_hover)(payload, target, pos, view_id),
1995                            DropResponse::Reject
1996                        ) || export_for_hover.accepts_foreign_export(payload, view_id)
1997                    });
1998                    if allowed {
1999                        feedback_for_hover.set(Some((line_y, width)));
2000                        teksilo_core::DropFeedback::InsertionLine { y: line_y, width }
2001                    } else {
2002                        feedback_for_hover.set(None);
2003                        teksilo_core::DropFeedback::NoFeedback
2004                    }
2005                })
2006                .on_drop(move |mut payload, position, ctx| {
2007                    feedback_for_drop.set(None);
2008                    if payload.has_typed::<ColumnReorderDragData>() {
2009                        return false;
2010                    }
2011                    let body_y = position.y - header_h_for_drop;
2012                    let scroll = scroll_y_for_drop.get();
2013                    let content_y = body_y + scroll;
2014                    let len = (len_fn_for_drop)();
2015                    let ins = {
2016                        let mut m = metrics_for_drop.borrow_mut();
2017                        m.resize(len);
2018                        m.insertion_index(content_y)
2019                    };
2020                    let is_same_view = payload
2021                        .get_typed::<RowDragData<T>>()
2022                        .is_some_and(|rd| rd.source == view_id);
2023                    // Route the drop to the source's accept_drop first. A
2024                    // same-view reorder only happens when the table is
2025                    // `reorderable`; a foreign payload is the source's
2026                    // call (a bare ListModel rejects it).
2027                    if (reorderable_for_drop || !is_same_view)
2028                        && let Some((target, position_kind)) = flat_insertion_target(ins, len)
2029                        && (accept_drop_for_drop)(&payload, target, position_kind, view_id)
2030                    {
2031                        // Only suppress our OWN move-out for a genuine
2032                        // same-view drop.
2033                        if is_same_view {
2034                            export_for_drop.note_self_reorder();
2035                        }
2036                        return true;
2037                    }
2038                    // Otherwise, the shared foreign-receive sugar
2039                    // (peek-before-take).
2040                    export_for_drop.foreign_receive(&mut payload, view_id, ins, ctx)
2041                })
2042                .on_drag_leave(move |_ctx| {
2043                    feedback_for_leave.set(None);
2044                })
2045                .on_drag_tick(move |pos, _ctx| {
2046                    // Auto-scroll when the pointer lingers within 32 px of the
2047                    // body band's top/bottom edge during a drag (body-relative
2048                    // so the header doesn't count as the top edge).
2049                    const EDGE: f32 = 32.0;
2050                    const MAX_VELOCITY: f32 = 12.0;
2051                    let body_h = (viewport_for_tick.get() - header_h_for_tick).max(0.0);
2052                    let y = pos.y - header_h_for_tick;
2053                    let above = (EDGE - y).max(0.0);
2054                    let below = (y - (body_h - EDGE)).max(0.0);
2055                    let delta = if above > 0.0 {
2056                        -(above / EDGE) * MAX_VELOCITY
2057                    } else if below > 0.0 {
2058                        (below / EDGE) * MAX_VELOCITY
2059                    } else {
2060                        0.0
2061                    };
2062                    if delta.abs() > 0.01 {
2063                        let max = max_scroll_for_tick.get();
2064                        let new_y = (scroll_for_tick.get() + delta).clamp(0.0, max);
2065                        scroll_for_tick.set(new_y);
2066                    }
2067                });
2068        }
2069
2070        // Export completion (move-out): fires on the drag source — this
2071        // table's root id, the stable id `start_drag` was given.
2072        handlers = self.export.install_completion(handlers);
2073
2074        ctx.apply_self_handlers(handlers);
2075
2076        // ── Build children ────────────────────────────────────────────
2077        self.header_row_id = None;
2078        self.body_pane_id = None;
2079        self.scrollbar_id = None;
2080        self.h_scrollbar_id = None;
2081        self.empty_id = None;
2082
2083        // Display order was already computed above (before the
2084        // keyboard handler was wired); pull it back into a local for
2085        // the header / body loops.
2086        let display_indices = display_indices_now;
2087
2088        // Header strip: build first so it sits above the body in the
2089        // child order (place_children iterates in this order).
2090        if self.show_header {
2091            // A rebuild destroys (and re-creates) every header cell, which
2092            // drops the pointer capture an in-flight resize depends on. Clear
2093            // the shared drag state with it: a `ResizeState` that outlived its
2094            // anchor would otherwise let the next bare PointerMove over the
2095            // same column resize it with no button held.
2096            *self.resize_state.borrow_mut() = None;
2097            self.resize_target.set(None);
2098            self.resize_preview_x.set(None);
2099
2100            let boundaries = *self.pane_boundaries.borrow();
2101            let resize_columns: header::ColumnResizeTable = Rc::new(
2102                display_indices
2103                    .iter()
2104                    .map(|&i| {
2105                        let c = &self.columns[i];
2106                        header::ColumnResizeInfo {
2107                            id: c.id.clone(),
2108                            min_width: c.min_width.unwrap_or(cp::MIN_COLUMN_WIDTH_DEFAULT),
2109                            max_width: c.max_width,
2110                            resizable: c.resizable,
2111                        }
2112                    })
2113                    .collect(),
2114            );
2115            let mut cell_ids: Vec<WidgetId> = Vec::with_capacity(display_indices.len());
2116            let active_sort = self.sort_signal.get();
2117            for (display_pos, &col_idx) in display_indices.iter().enumerate() {
2118                let col = &self.columns[col_idx];
2119                let current_sort = active_sort
2120                    .as_ref()
2121                    .and_then(|(id, dir)| if id == &col.id { Some(*dir) } else { None });
2122                // Filter zone width: indicator glyph + a small horizontal
2123                // padding for tap tolerance. Mirrors the layout of the
2124                // HStack inside HeaderCell::build.
2125                let filter_zone_width = cp::FILTER_INDICATOR_SIZE + cp::CELL_PADDING_HORIZONTAL;
2126                let cell = header::HeaderCell::new(header::HeaderCellSpec {
2127                    col_id: col.id.clone(),
2128                    label: col.header_label.resolve_now(),
2129                    col_index_1based: display_pos + 1,
2130                    sortable: col.sortable,
2131                    reorderable: col.reorderable,
2132                    filterable: col.filterable,
2133                    resize_grip: cp::RESIZE_HANDLE_WIDTH,
2134                    filter_zone_width,
2135                    current_sort,
2136                    width_index: display_pos,
2137                    pane_boundaries: boundaries,
2138                    resize_columns: resize_columns.clone(),
2139                    resize_policy: self.column_resize_policy,
2140                    resize_state: self.resize_state.clone(),
2141                    resize_target: self.resize_target.clone(),
2142                    resize_preview_x: self.resize_preview_x.clone(),
2143                    table_id: self.table_id,
2144                    sort_signal: self.sort_signal.clone(),
2145                    column_widths_signal: self.column_widths_signal.clone(),
2146                    column_widths: self.column_widths.clone(),
2147                    filters_signal: self.filters_signal.clone(),
2148                });
2149                cell_ids.push(ctx.add(cell));
2150            }
2151            let header_row = header::HeaderRow::new(
2152                cell_ids,
2153                self.column_widths.clone(),
2154                cp::GRID_LINE_THICKNESS,
2155                *self.pane_boundaries.borrow(),
2156                self.scroll_x.clone(),
2157            );
2158            // Wire reorder drag-target handlers on the header strip.
2159            let header_row_id = ctx.add(header_row);
2160            header::attach_header_reorder_handlers(
2161                ctx,
2162                header_row_id,
2163                self.table_id,
2164                self.column_widths.clone(),
2165                self.display_indices.clone(),
2166                self.pane_boundaries.clone(),
2167                self.column_order_signal.clone(),
2168                self.column_pinning_signal.clone(),
2169                self.columns.iter().map(|c| c.id.clone()).collect(),
2170                self.header_strip_width.clone(),
2171                self.scroll_x.clone(),
2172            );
2173            self.header_row_id = Some(header_row_id);
2174        }
2175
2176        let row_count = (self.len_fn)();
2177
2178        // Lazy: nudge the source to load the realized window, and fetch the
2179        // next page as the viewport nears the end (append-only sources). A
2180        // fully-resident source leaves these inert.
2181        let (vis_start, vis_end) = self.visible_range();
2182        (self.dnd.request_window_fn)(vis_start..vis_end);
2183        if (self.dnd.can_fetch_more_fn)() && vis_end + BUFFER_ROWS >= row_count {
2184            (self.dnd.fetch_more_fn)();
2185        }
2186
2187        if row_count == 0 {
2188            // Empty state.
2189            if let Some(ref f) = self.empty_view {
2190                let id = ctx.add_boxed(f());
2191                self.empty_id = Some(id);
2192            }
2193        } else {
2194            // Hoist the row pane into its own widget so that
2195            // scroll-buffer-exit rebuilds (which happen mid-thumb-drag
2196            // when the user scrolls past the buffered range) target a
2197            // sibling of the scrollbar rather than the scrollbar's
2198            // ancestor. Rebuilding the ancestor would be deferred by
2199            // the framework (to preserve the captured drag), leaving
2200            // the body empty until the user released the thumb.
2201            let pane = body_pane::BodyPane::<T> {
2202                len_fn: self.len_fn.clone(),
2203                with_item_fn: self.with_item_fn.clone(),
2204                drag_fn: self.dnd.drag_fn.clone(),
2205                row_state_fn: self.dnd.row_state_fn.clone(),
2206                columns: self.columns.clone(),
2207                display_indices: self.display_indices.clone(),
2208                column_widths: self.column_widths.clone(),
2209                pane_boundaries: *self.pane_boundaries.borrow(),
2210                scroll_x: self.scroll_x.clone(),
2211                row_metrics: self.row_metrics.clone(),
2212                selection_mode: self.selection_mode,
2213                selection: self.row_selection.clone(),
2214                cell_selection: self.cell_selection.clone(),
2215                scroll_y: self.scroll_y.clone(),
2216                viewport_height: self.viewport_height.clone(),
2217                editing_cell: self.editing_cell.clone(),
2218                focused_cell: self.focused_cell.clone(),
2219                reorderable: self.reorderable,
2220                export: self.export.clone(),
2221                snapshot_out_fn: self.dnd.snapshot_out_fn.clone(),
2222                anchor_fn: self.anchor_fn.clone(),
2223                editing_anchor: self.editing_anchor.clone(),
2224                view_id: self.model_id,
2225                drag_anchor: ctx.self_id(),
2226                on_row_activate: self.on_row_activate.clone(),
2227                activate_on: self.activate_on,
2228                edit_triggers: self.edit_triggers,
2229                on_cell_edit_request: self.on_cell_edit_request.clone(),
2230                on_cell_edit_dismissed: self.on_cell_edit_dismissed.clone(),
2231                version: self.pane_version.clone(),
2232                prev_built_start: self.pane_built_start.clone(),
2233                prev_built_end: self.pane_built_end.clone(),
2234                total_refresh: self.pane_total_refresh.clone(),
2235                row_entries: Vec::new(),
2236                row_map: self.row_map.clone(),
2237                cell_map: self.cell_map.clone(),
2238            };
2239            self.body_pane_id = Some(ctx.add(pane));
2240            // An open cell editor also ends on a press that lands on no cell at
2241            // all — the empty band under the last row. Mounted here rather than
2242            // on the pane because the pane is not the hit target there.
2243            if let Some(handlers) = body_pane::root_edit_dismiss_handler(
2244                &self.on_cell_edit_dismissed,
2245                &self.editing_cell,
2246                &Rc::new(
2247                    display_indices
2248                        .iter()
2249                        .map(|&i| self.columns[i].id.clone())
2250                        .collect::<Vec<_>>(),
2251                ),
2252            ) {
2253                ctx.apply_self_handlers(handlers);
2254            }
2255        }
2256
2257        // Scrollbar (single internal vertical bar).
2258        if self.show_internal_scrollbars {
2259            let sb = ScrollBar::new(
2260                ScrollBarOrientation::Vertical,
2261                self.scroll_y.clone(),
2262                self.max_scroll_y.clone(),
2263                self.viewport_ratio_y.clone(),
2264            )
2265            .visual(match self.scroll_bar_style {
2266                ScrollBarMode::Permanent => ScrollBarVisual::Permanent,
2267                ScrollBarMode::Overlay => ScrollBarVisual::Overlay,
2268                ScrollBarMode::Thin => ScrollBarVisual::Thin,
2269            });
2270            self.scrollbar_id = Some(ctx.add(sb));
2271
2272            // Horizontal bar — the Middle pane only. Visibility (max_scroll_x
2273            // > 0) and geometry (band_left + pinned-pane offsets) are decided
2274            // in `place_children`, same as the vertical bar's `needs_scrollbar`
2275            // gate; here we just build it unconditionally so it exists to be
2276            // placed (zero-sized and skipped when not needed).
2277            let hsb = ScrollBar::new(
2278                ScrollBarOrientation::Horizontal,
2279                self.scroll_x.clone(),
2280                self.max_scroll_x.clone(),
2281                self.viewport_ratio_x.clone(),
2282            )
2283            .visual(match self.scroll_bar_style {
2284                ScrollBarMode::Permanent => ScrollBarVisual::Permanent,
2285                ScrollBarMode::Overlay => ScrollBarVisual::Overlay,
2286                ScrollBarMode::Thin => ScrollBarVisual::Thin,
2287            });
2288            self.h_scrollbar_id = Some(ctx.add(hsb));
2289        }
2290
2291        // Z-order: body rows first, then empty/scrollbar, then header
2292        // last. The header band overlaps the top of the body region
2293        // when `scroll_y > 0` (rows positioned at `body_origin_y +
2294        // row_idx * row_h - scroll_y` can extend above
2295        // `body_origin_y` on overscroll). Painting the header last
2296        // means it sits on top of any row that bleeds into the
2297        // header band — without this fix, scrolled-out rows would
2298        // visibly draw over the header label.
2299        let mut children: Vec<WidgetId> = Vec::new();
2300        if let Some(id) = self.body_pane_id {
2301            children.push(id);
2302        }
2303        if let Some(id) = self.empty_id {
2304            children.push(id);
2305        }
2306        if let Some(id) = self.scrollbar_id {
2307            children.push(id);
2308        }
2309        if let Some(id) = self.h_scrollbar_id {
2310            children.push(id);
2311        }
2312        if let Some(id) = self.header_row_id {
2313            children.push(id);
2314        }
2315        // Suppress the unused-binding warning on header_h while the
2316        // value is consumed by `place_children` via the same helper.
2317        let _ = header_h;
2318        children
2319    }
2320
2321    fn layout_response(
2322        &self,
2323        proposal: SizeProposal,
2324        _ctx: &LayoutContext,
2325    ) -> teksilo_core::widget::LayoutResponse {
2326        // Only an allocation may seed the cached viewport (`common::viewport`);
2327        // the body pane shares this very cell, so a measurement's fallback
2328        // would desync its realization window.
2329        let size = crate::common::viewport::viewport_size(
2330            proposal,
2331            &self.viewport_height,
2332            Size::new(400.0, 300.0),
2333        );
2334        if proposal.height.is_some() {
2335            // Viewport-relative imperatives are meaningful from here on — but
2336            // only once a real height has landed, for the reason `laid_out`
2337            // exists at all.
2338            self.laid_out.set(true);
2339        }
2340        size.into()
2341    }
2342
2343    fn place_children(
2344        &self,
2345        bounds: Rect,
2346        _proposal: SizeProposal,
2347        children: &mut [WidgetPlacement],
2348        ctx: &LayoutContext,
2349    ) {
2350        if children.is_empty() {
2351            return;
2352        }
2353        let rtl = ctx.is_rtl();
2354        let header_h = self.effective_header_height();
2355        // Provisional — the vertical scrollbar's own need is decided
2356        // against this (a possible tiny inaccuracy if reserving room for
2357        // the horizontal bar below would itself flip that decision; not
2358        // worth a fixed-point iteration for a dual-scrollbar corner case).
2359        let body_height_provisional = (bounds.height - header_h).max(0.0);
2360
2361        // Parent-before-child layout order means this runs before the
2362        // body pane's measure pass — in auto-measure mode the scrollbar
2363        // totals settle one frame after a measurement change.
2364        let total_height = self.total_content_height();
2365        let needs_v_scrollbar =
2366            self.show_internal_scrollbars && total_height > body_height_provisional + 0.5;
2367        // Permanent reserves a column for the bar; Overlay / Thin float
2368        // over the content, so rows span the full width.
2369        let reserves_v_bar = needs_v_scrollbar && self.scroll_bar_style == ScrollBarMode::Permanent;
2370        let body_width = if reserves_v_bar {
2371            (bounds.width - SCROLLBAR_THICKNESS).max(0.0)
2372        } else {
2373            bounds.width
2374        };
2375        // Under RTL the vertical scrollbar moves to the physical left
2376        // (matching `ScrollArea`), so the body/header band shifts right
2377        // by its thickness. `band_left` is the shared origin for the
2378        // body pane, empty state, and header; `scrollbar_x` is the
2379        // scrollbar's own physical x. The paint pass derives the same
2380        // content region from these conventions so the two never drift.
2381        let band_left = if rtl && reserves_v_bar {
2382            bounds.x + SCROLLBAR_THICKNESS
2383        } else {
2384            bounds.x
2385        };
2386        let scrollbar_x = if rtl {
2387            bounds.x
2388        } else {
2389            bounds.x + bounds.width - SCROLLBAR_THICKNESS
2390        };
2391        // The header strip spans the band; snapshot its width for the
2392        // reorder-drop handler's RTL mirror.
2393        self.header_strip_width.set(body_width);
2394
2395        // Resolve column widths in display order, honoring any
2396        // user-resize overrides from `column_widths_signal`.
2397        let overrides = self.column_widths_signal.get();
2398        let display = self.display_indices.borrow().clone();
2399        let widths = layout::ColumnSolver::resolve_in_order(
2400            &self.columns,
2401            &display,
2402            body_width,
2403            cp::MIN_COLUMN_WIDTH_DEFAULT,
2404            &overrides,
2405        );
2406
2407        // Pane geometry: the Middle pane's viewport (`body_width` minus the
2408        // pinned panes) and the horizontal scroll headroom it implies.
2409        let boundaries = *self.pane_boundaries.borrow();
2410        let (leading_w, middle_content_w, trailing_w) = layout::pane_widths(&widths, boundaries);
2411        let middle_viewport_w = (body_width - leading_w - trailing_w).max(0.0);
2412        let max_x = (middle_content_w - middle_viewport_w).max(0.0);
2413        self.max_scroll_x.set(max_x);
2414        self.middle_viewport_width.set(middle_viewport_w);
2415        let x_ratio = if middle_content_w > 0.0 {
2416            (middle_viewport_w / middle_content_w).clamp(0.0, 1.0)
2417        } else {
2418            1.0
2419        };
2420        self.viewport_ratio_x.set(x_ratio);
2421        // Clamp scroll_x — a pane shrink (window narrowed, a column grew)
2422        // must not leave scroll_x stranded past the new max (mirrors
2423        // `clamp_scroll` for scroll_y).
2424        {
2425            let current = self.scroll_x.get();
2426            let clamped = current.clamp(0.0, max_x);
2427            if (clamped - current).abs() > 0.001 {
2428                self.scroll_x.set(clamped);
2429            }
2430        }
2431
2432        *self.column_widths.borrow_mut() = widths;
2433
2434        let needs_h_scrollbar = self.show_internal_scrollbars && max_x > 0.5;
2435        let reserves_h_bar = needs_h_scrollbar && self.scroll_bar_style == ScrollBarMode::Permanent;
2436        let body_height = if reserves_h_bar {
2437            (body_height_provisional - SCROLLBAR_THICKNESS).max(0.0)
2438        } else {
2439            body_height_provisional
2440        };
2441
2442        // Vertical scrollbar totals, against the FINAL body_height (after
2443        // any horizontal-bar reservation) so the range stays accurate when
2444        // both bars show at once.
2445        let max_y = (total_height - body_height).max(0.0);
2446        self.max_scroll_y.set(max_y);
2447        let y_ratio = if total_height > 0.0 {
2448            (body_height / total_height).clamp(0.0, 1.0)
2449        } else {
2450            1.0
2451        };
2452        self.viewport_ratio_y.set(y_ratio);
2453        self.clamp_scroll();
2454
2455        let body_origin_y = bounds.y + header_h;
2456        // Cache the row-area rect for the keyboard handler's outer-scroll chase.
2457        self.body_bounds
2458            .set(Rect::new(band_left, body_origin_y, body_width, body_height));
2459
2460        let mut next = 0;
2461
2462        // BodyPane fills the body region. It positions its rows
2463        // internally using its own scroll signal and clips them to
2464        // its own bounds.
2465        if self.body_pane_id.is_some() {
2466            if let Some(child) = children.get_mut(next) {
2467                child.origin = Point::new(band_left, body_origin_y);
2468                child.size = Size::new(body_width, body_height);
2469            }
2470            next += 1;
2471        }
2472
2473        // Empty-state child fills the body region (below the header).
2474        if self.empty_id.is_some() {
2475            if let Some(child) = children.get_mut(next) {
2476                child.origin = Point::new(band_left, body_origin_y);
2477                child.size = Size::new(body_width, body_height);
2478            }
2479            next += 1;
2480        }
2481
2482        // Scrollbar — alongside the body, below the header. Physical
2483        // left under RTL, physical right under LTR.
2484        if self.scrollbar_id.is_some() {
2485            if let Some(child) = children.get_mut(next) {
2486                if needs_v_scrollbar {
2487                    child.origin = Point::new(scrollbar_x, body_origin_y);
2488                    child.size = Size::new(SCROLLBAR_THICKNESS, body_height);
2489                } else {
2490                    child.origin = bounds.origin();
2491                    child.size = Size::ZERO;
2492                }
2493            }
2494            next += 1;
2495        }
2496
2497        // Horizontal scrollbar — the Middle pane's own band, below the
2498        // body, never overlapping a pinned pane.
2499        if self.h_scrollbar_id.is_some() {
2500            if let Some(child) = children.get_mut(next) {
2501                if needs_h_scrollbar {
2502                    let h_x = if rtl {
2503                        band_left + trailing_w
2504                    } else {
2505                        band_left + leading_w
2506                    };
2507                    child.origin = Point::new(h_x, body_origin_y + body_height);
2508                    child.size = Size::new(middle_viewport_w, SCROLLBAR_THICKNESS);
2509                } else {
2510                    child.origin = bounds.origin();
2511                    child.size = Size::ZERO;
2512                }
2513            }
2514            next += 1;
2515        }
2516
2517        // Header strip last — placed at top y but emitted last so paint
2518        // z-order draws it above any overscrolled body rows.
2519        if self.header_row_id.is_some()
2520            && let Some(child) = children.get_mut(next)
2521        {
2522            child.origin = Point::new(band_left, bounds.y);
2523            child.size = Size::new(body_width, header_h);
2524        }
2525    }
2526
2527    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
2528        let header_h = self.effective_header_height();
2529        let colors = &ctx.theme.colors;
2530
2531        let scroll_y = self.scroll_y.get();
2532        let body_origin_y = bounds.y + header_h;
2533        let body_height = (bounds.height - header_h).max(0.0);
2534        let widths = self.column_widths.borrow();
2535        let body_width = widths.iter().sum::<f32>();
2536        let body_width_for_paint = if body_width > 0.0 {
2537            body_width.min(bounds.width)
2538        } else {
2539            bounds.width
2540        };
2541        // Physical left edge of the column content. Under RTL the band is
2542        // right-aligned within `bounds` (the scrollbar took the left), so
2543        // content runs from `bounds.right() - body_width` leftward —
2544        // exactly where `place_children` reverse-placed the cells.
2545        let rtl = ctx.layout_direction == teksilo_core::environment::LayoutDirection::RightToLeft;
2546        let content_left = if rtl {
2547            bounds.x + bounds.width - body_width_for_paint
2548        } else {
2549            bounds.x
2550        };
2551
2552        // Visible row window for the paint passes — offset-table-driven
2553        // so variable heights paint correctly. One metrics borrow per
2554        // pass; nothing inside re-enters the metrics.
2555        let row_count = (self.len_fn)();
2556        let (first_visible, last_visible) =
2557            self.row_metrics
2558                .borrow_mut()
2559                .visible_range(scroll_y, body_height, row_count, 0);
2560
2561        // Clip the root-painted row decorations (alt-row stripes,
2562        // selection bands, grid lines, focus ring) to the body band.
2563        // `clips_children` only clips child WIDGETS — this widget's own
2564        // paint would otherwise bleed past the table's bottom edge for
2565        // the partially visible last row (its stripe/grid-line rect
2566        // spans the full row height).
2567        canvas.set_clip(Rect::new(
2568            content_left,
2569            body_origin_y,
2570            body_width_for_paint,
2571            body_height,
2572        ));
2573
2574        // Alt-row backgrounds — paint odd visible rows. Parity keys on
2575        // the row index, not on y, so stripes stay stable under
2576        // variable heights.
2577        if self.alternating_rows {
2578            let mut m = self.row_metrics.borrow_mut();
2579            for row_idx in first_visible..last_visible {
2580                if row_idx % 2 == 1 {
2581                    let y = body_origin_y + m.row_top(row_idx) - scroll_y;
2582                    let h = m.row_height(row_idx);
2583                    let rect = Rect::new(content_left, y, body_width_for_paint, h);
2584                    canvas.fill_rect(rect, SurfaceRole::AltRow.resolve(colors));
2585                }
2586            }
2587        }
2588
2589        // Selection highlights — row selection modes only.
2590        if let Some(ref sel) = self.row_selection
2591            && matches!(
2592                self.selection_mode,
2593                TableSelectionMode::SingleRow | TableSelectionMode::MultiRow
2594            )
2595        {
2596            // Focus- and window-aware: vivid `Selected` while the table holds
2597            // keyboard focus AND the host window is active; muted
2598            // `SelectedInactive` once focus moves elsewhere or the window goes
2599            // inactive (the same desaturation serves both states).
2600            let bg = if self.view_focused.get() && ctx.window_active {
2601                SurfaceRole::Selected.resolve(colors)
2602            } else {
2603                SurfaceRole::SelectedInactive.resolve(colors)
2604            };
2605            let mut m = self.row_metrics.borrow_mut();
2606            for row_idx in sel.selected_indices() {
2607                let y = body_origin_y + m.row_top(row_idx) - scroll_y;
2608                let h = m.row_height(row_idx);
2609                if y + h < body_origin_y || y > body_origin_y + body_height {
2610                    continue;
2611                }
2612                let rect = Rect::new(content_left, y, body_width_for_paint, h);
2613                canvas.fill_rect(rect, bg);
2614            }
2615        }
2616
2617        // Grid lines.
2618        let line_color = BorderRole::Divider.resolve(colors);
2619        let line_w = cp::GRID_LINE_THICKNESS.max(1.0);
2620
2621        if matches!(self.grid_lines, GridLines::Horizontal | GridLines::Both) {
2622            let mut m = self.row_metrics.borrow_mut();
2623            for row_idx in first_visible..last_visible {
2624                let bottom = m.row_top(row_idx) + m.row_height(row_idx);
2625                let y = body_origin_y + bottom - scroll_y - line_w;
2626                let rect = Rect::new(content_left, y, body_width_for_paint, line_w);
2627                canvas.fill_rect(rect, line_color);
2628            }
2629        }
2630
2631        // Pane geometry for the two column-position-dependent decorations
2632        // below (vertical grid lines, the cell focus ring): both must clip
2633        // to the target column's OWN pane, or a scrolled Middle-pane
2634        // decoration could paint over a pinned Leading/Trailing column
2635        // within the same row band (the outer body clip above only bounds
2636        // the row's outer edges, not the seam between panes).
2637        let boundaries = *self.pane_boundaries.borrow();
2638        let scroll_x = self.scroll_x.get();
2639        let content_bounds = Rect::new(
2640            content_left,
2641            body_origin_y,
2642            body_width_for_paint,
2643            body_height,
2644        );
2645        let (leading_rect, middle_rect, trailing_rect) =
2646            layout::band_rects(content_bounds, &widths, boundaries, rtl);
2647
2648        if matches!(self.grid_lines, GridLines::Vertical | GridLines::Both) {
2649            let leading_end = boundaries.leading_count.min(widths.len());
2650            let middle_end = boundaries.middle_end.min(widths.len()).max(leading_end);
2651            draw_pane_dividers(
2652                canvas,
2653                leading_rect,
2654                &widths[..leading_end],
2655                0.0,
2656                rtl,
2657                line_color,
2658                line_w,
2659            );
2660            draw_pane_dividers(
2661                canvas,
2662                middle_rect,
2663                &widths[leading_end..middle_end],
2664                scroll_x,
2665                rtl,
2666                line_color,
2667                line_w,
2668            );
2669            draw_pane_dividers(
2670                canvas,
2671                trailing_rect,
2672                &widths[middle_end..],
2673                0.0,
2674                rtl,
2675                line_color,
2676                line_w,
2677            );
2678        }
2679
2680        // Focus ring on the currently-focused cell — keyboard-only
2681        // (`:focus-visible`) and only while the table itself holds focus, so a
2682        // mouse click never leaves a ring and an unfocused table shows none.
2683        if self.view_focused.get()
2684            && self.focus_visible.get()
2685            && let Some((focus_row, focus_col)) = self.focused_cell.get()
2686            && focus_col < widths.len()
2687            && let Some(x_off) = layout::column_logical_x(
2688                &widths,
2689                boundaries,
2690                scroll_x,
2691                body_width_for_paint,
2692                focus_col,
2693            )
2694        {
2695            let cell_w = widths[focus_col];
2696            let (focus_top, focus_h) = {
2697                let mut m = self.row_metrics.borrow_mut();
2698                (m.row_top(focus_row), m.row_height(focus_row))
2699            };
2700            let y = body_origin_y + focus_top - scroll_y;
2701            if y + focus_h >= body_origin_y && y <= body_origin_y + body_height {
2702                let pane_rect = if focus_col < boundaries.leading_count {
2703                    leading_rect
2704                } else if focus_col >= boundaries.middle_end {
2705                    trailing_rect
2706                } else {
2707                    middle_rect
2708                };
2709                canvas.set_clip(pane_rect);
2710                let inset = cp::FOCUS_RING_INSET;
2711                let stroke = cp::GRID_LINE_THICKNESS.max(1.5);
2712                let ring_color = BorderRole::Focused.resolve(colors);
2713                // `x_off` is the leading-side offset (sum of widths before
2714                // the focused column). Under RTL that offset is measured
2715                // from the right edge of the content band.
2716                let rx = if rtl {
2717                    content_left + body_width_for_paint - x_off - cell_w + inset
2718                } else {
2719                    content_left + x_off + inset
2720                };
2721                let ry = y + inset;
2722                let rw = (cell_w - inset * 2.0).max(0.0);
2723                let rh = (focus_h - inset * 2.0).max(0.0);
2724                // Top
2725                canvas.fill_rect(Rect::new(rx, ry, rw, stroke), ring_color);
2726                // Bottom
2727                canvas.fill_rect(Rect::new(rx, ry + rh - stroke, rw, stroke), ring_color);
2728                // Left
2729                canvas.fill_rect(Rect::new(rx, ry, stroke, rh), ring_color);
2730                // Right
2731                canvas.fill_rect(Rect::new(rx + rw - stroke, ry, stroke, rh), ring_color);
2732                canvas.clear_clip();
2733            }
2734        }
2735
2736        // Row-drop insertion indicator (source-accepted positions only —
2737        // a forbidden hover clears the signal, so no line shows). `y` is
2738        // stored body-local; the band clip is already active.
2739        if let Some((y, _width)) = self.drop_feedback.get() {
2740            let line_color = BorderRole::Focused.resolve(colors);
2741            let thickness = 2.0_f32;
2742            let line_y = body_origin_y + y - thickness * 0.5;
2743            canvas.fill_rect(
2744                Rect::new(content_left, line_y, body_width_for_paint, thickness),
2745                line_color,
2746            );
2747        }
2748
2749        canvas.clear_clip();
2750
2751        // Container focus ring — the table holds keyboard focus but nothing
2752        // indicates where: no current cell (no cell ring) and no selection (no
2753        // band). Outline the whole view so Tab has a visible landing point
2754        // before the user navigates (mirrors TreeView / ListView).
2755        let nothing_indicated = self.focused_cell.get().is_none()
2756            && self
2757                .row_selection
2758                .as_ref()
2759                .is_none_or(|s| s.selected_indices().is_empty())
2760            && self.cell_selection.as_ref().is_none_or(|s| s.count() == 0);
2761        if self.view_focused.get() && self.focus_visible.get() && nothing_indicated {
2762            let inset = 1.0_f32;
2763            let rect = Rect::new(
2764                bounds.x + inset,
2765                bounds.y + inset,
2766                (bounds.width - inset * 2.0).max(0.0),
2767                (bounds.height - inset * 2.0).max(0.0),
2768            );
2769            canvas.stroke_rect(rect, BorderRole::Focused.resolve(colors), 1.5);
2770        }
2771
2772        // `OnRelease` column-resize guide. Under that policy no column moves
2773        // until the button comes up, so this line is the *only* feedback the
2774        // gesture has — the same full-height rubber band Qt / Excel draw.
2775        if let Some(x) = self.resize_preview_x.get() {
2776            let thickness = cp::GRID_LINE_THICKNESS.max(1.5);
2777            canvas.fill_rect(
2778                Rect::new(x - thickness * 0.5, bounds.y, thickness, bounds.height),
2779                BorderRole::Focused.resolve(colors),
2780            );
2781        }
2782    }
2783
2784    /// The context-menu key opens the *current row's* menu, not the view's.
2785    ///
2786    /// A `TableView` is focusable and its rows deliberately are not — the
2787    /// container owns focus and `set_selected` is what tells assistive
2788    /// technology which row is current. So the dispatcher's default of "the
2789    /// focused widget" would open the view's own menu, in the widget family
2790    /// where a per-row menu matters most.
2791    ///
2792    /// The row the user means is the focused cell's row if they have navigated,
2793    /// else the first selected row. Only realized rows have a widget, so a
2794    /// cursor scrolled outside the virtualization window resolves to nothing
2795    /// and the menu falls back to the view — right, because there is no row on
2796    /// screen for it to be about.
2797    fn context_menu_key_target(&self) -> Option<WidgetId> {
2798        let index = self.focused_cell.get().map(|(row, _col)| row).or_else(|| {
2799            self.row_selection
2800                .as_ref()
2801                .and_then(|s| s.selected_indices().first().copied())
2802        })?;
2803        let map = self.row_map.borrow();
2804        map.iter().find(|(i, _)| *i == index).map(|(_, id)| *id)
2805    }
2806
2807    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2808        builder.set_role(teksilo_core::accesskit::Role::Table);
2809        if let Some(ref label) = self.a11y_label {
2810            builder.set_name(label.resolve_now());
2811        }
2812        // AccessKit's `row_count` includes the header row when present —
2813        // matches ARIA `aria-rowcount` semantics.
2814        let row_count = (self.len_fn)() + if self.show_header { 1 } else { 0 };
2815        let col_count = self.columns.len();
2816        let n = builder.inner_mut();
2817        n.set_row_count(row_count);
2818        n.set_column_count(col_count);
2819
2820        // Roving focus: point active_descendant at the focused cell's own
2821        // AT node so a screen reader follows arrow-key cell navigation
2822        // (only the table root is otherwise focusable — the ring is
2823        // visual-only). `cell_map` is a snapshot of the body pane's last
2824        // realized cells; a focused cell that scrolled out of the
2825        // realized buffer simply isn't in it, so no stale id is emitted.
2826        if let Some(target) = self.focused_cell.get() {
2827            let map = self.cell_map.borrow();
2828            if let Some(&(_, cell_id)) = map.iter().find(|&&(pos, _)| pos == target) {
2829                builder.set_active_descendant(widget_id_to_node_id(cell_id));
2830            }
2831        }
2832    }
2833
2834    fn as_any(&self) -> Option<&dyn std::any::Any> {
2835        Some(self)
2836    }
2837
2838    fn children(&self) -> Vec<WidgetId> {
2839        // Same order as `build()` — body pane first, header last so
2840        // it paints on top of any overscrolled rows.
2841        let mut out: Vec<WidgetId> = Vec::new();
2842        if let Some(id) = self.body_pane_id {
2843            out.push(id);
2844        }
2845        if let Some(id) = self.empty_id {
2846            out.push(id);
2847        }
2848        if let Some(id) = self.scrollbar_id {
2849            out.push(id);
2850        }
2851        if let Some(id) = self.h_scrollbar_id {
2852            out.push(id);
2853        }
2854        if let Some(id) = self.header_row_id {
2855            out.push(id);
2856        }
2857        out
2858    }
2859
2860    fn accessibility_children(&self) -> Option<Vec<WidgetId>> {
2861        // WCAG 1.3.2 (audit G17): read the column-header row FIRST, then the
2862        // body, even though `build()` / `children()` list the body first so it
2863        // paints beneath the header. Same id set as `children()`, reordered.
2864        let out: Vec<WidgetId> = [
2865            self.header_row_id,
2866            self.body_pane_id,
2867            self.empty_id,
2868            self.scrollbar_id,
2869            self.h_scrollbar_id,
2870        ]
2871        .into_iter()
2872        .flatten()
2873        .collect();
2874        if out.is_empty() { None } else { Some(out) }
2875    }
2876
2877    fn clips_children(&self) -> bool {
2878        true
2879    }
2880}
2881
2882/// Draw the internal vertical grid-line dividers for one pane band —
2883/// `slice.len() - 1` lines between adjacent columns, clipped to `rect` so a
2884/// scrolled Middle-pane line can't bleed past its own viewport into a
2885/// pinned neighbour. `scroll` is nonzero only for the Middle pane.
2886///
2887/// Shared by `TableView`/`TreeTableView`'s `paint()`, which are otherwise
2888/// near-identical for this decoration.
2889#[allow(clippy::too_many_arguments)]
2890pub(crate) fn draw_pane_dividers(
2891    canvas: &mut Canvas,
2892    rect: Rect,
2893    slice: &[f32],
2894    scroll: f32,
2895    rtl: bool,
2896    color: teksilo_tokens::Color,
2897    line_w: f32,
2898) {
2899    if slice.len() < 2 || rect.width <= 0.0 {
2900        return;
2901    }
2902    canvas.set_clip(rect);
2903    if rtl {
2904        let mut x = rect.right() + scroll;
2905        for &w in &slice[..slice.len() - 1] {
2906            x -= w;
2907            canvas.fill_rect(Rect::new(x, rect.y, line_w, rect.height), color);
2908        }
2909    } else {
2910        let mut x = rect.x - scroll;
2911        for &w in &slice[..slice.len() - 1] {
2912            x += w;
2913            canvas.fill_rect(Rect::new(x - line_w, rect.y, line_w, rect.height), color);
2914        }
2915    }
2916    canvas.clear_clip();
2917}
2918
2919// Reorder drag-target plumbing (hover + drop on the header strip) lives in
2920// `header::attach_header_reorder_handlers` — shared with `TreeTableView`,
2921// which builds its header out of the same `HeaderCell`/`HeaderRow` pair.