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-core` `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//! The header strip is a [`TableHeader`] over the columns'
20//! [`ColumnSpec`]s, which this view and [`TreeTableView`](crate::TreeTableView)
21//! both compose and which works on its own over rows an application lays out
22//! itself.
23//!
24//! ## Column state an application owns
25//!
26//! The sort, the column widths, the column order and the filters live in four
27//! signals the table publishes (`sort_signal()`, `column_widths_signal()`,
28//! `column_order_signal()`, `filters_signal()`). By default the table creates
29//! them. [`bind_sort`](TableView::bind_sort),
30//! [`bind_column_widths`](TableView::bind_column_widths),
31//! [`bind_column_order`](TableView::bind_column_order) and
32//! [`bind_filters`](TableView::bind_filters) make the table adopt signals the
33//! application already holds instead, so several views, or a view-model, share
34//! one state without mirroring it.
35//!
36//! - **Adopting writes nothing.** The table never seeds an adopted signal: not
37//!   when it is built, mounted, laid out or rebuilt. What the signal holds is
38//!   what the table shows; an empty map, an empty list or `None` means the
39//!   declared defaults. The table writes only for a user gesture (a header
40//!   click, a resize drag, a reorder drop, the filter popover, an AccessKit
41//!   increment) or an imperative setter (`set_sort`, `set_column_widths`, …).
42//! - **Ids the table does not declare are ignored when read and kept when
43//!   written.** A width, a filter or an order entry for a column this table
44//!   lacks changes nothing here, and a reorder in this table moves the
45//!   dragged column and leaves every other entry where it was. A column with
46//!   no width entry takes its declared [`ColumnWidth`].
47//! - **A resize writes one width into an adopted map.** The `Flex` columns
48//!   before the grip keep their widths, so the divider follows the pointer,
49//!   but in this table only: the widths they had here are not the widths
50//!   they have in a view of another size sharing the map. A table on its own
51//!   map writes them into it, so the map it persists matches what it shows.
52//! - **No feedback loop.** A view reacts to a change by relaying out or
53//!   rebuilding, never by writing back, so two views bound to the same signals
54//!   follow each other's edits and settle after one pass.
55//! - **Bind before any imperative setter.** A setter called earlier in the
56//!   builder chain wrote the table's own signal, which the bind replaces.
57//!
58//! ```ignore
59//! use teksilo_data::ListModel;
60//! use teksilo_widgets::table_view::{Column, ColumnWidth, TableView};
61//! use teksilo_i18n::lit;
62//!
63//! struct Person { name: String, age: u32 }
64//!
65//! let model: ListModel<Person> = ListModel::new();
66//! let _table = TableView::new(model)
67//!     .add_column(Column::new("name", ColumnWidth::Flex(1.0))
68//!         .label(lit!("Name"))
69//!         .cell(|p: &Person, _cx| Box::new(
70//!             teksilo_widgets::primitives::TextWidget::new(
71//!                 teksilo_i18n::lit!(p.name.clone())
72//!             )
73//!         )))
74//!     .add_column(Column::new("age", ColumnWidth::Fixed(60.0))
75//!         .label(lit!("Age"))
76//!         .cell(|p: &Person, _cx| Box::new(
77//!             teksilo_widgets::primitives::TextWidget::new(
78//!                 teksilo_i18n::lit!(p.age.to_string())
79//!             )
80//!         )))
81//!     .alternating_rows(true)
82//!     .row_height(32.0);
83//! ```
84//!
85//! ## Scrolling to a row
86//!
87//! [`handle`](TableView::handle) returns a [`TableViewHandle`], a cheap `Clone`
88//! taken before the table is added and usable from any handler afterwards. Its
89//! [`scroll_to_row`](TableViewHandle::scroll_to_row) and
90//! [`ensure_row_visible`](TableViewHandle::ensure_row_visible) are applied at
91//! the next layout pass, after row measurement and scroll anchoring, so they
92//! may follow a model change in the same turn, or be called before mount:
93//!
94//! ```rust
95//! # use teksilo_data::ListModel;
96//! # use teksilo_widgets::TableView;
97//! # let model: ListModel<String> = ListModel::new();
98//! let table = TableView::new(model.clone());
99//! let handle = table.handle();
100//! // … add the columns, `ctx.add(table)`; then, from a handler:
101//! model.insert(0, "Fresh".into());
102//! handle.ensure_row_visible(0);
103//! ```
104//!
105//! The builder-value methods of the same names compute the offset when they
106//! are called, against the last layout pass. See docs/table-view.md,
107//! "Scrolling to a row".
108//!
109//! ## Pan to scroll
110//!
111//! The view installs [`common::scrollable::ScrollableBehavior`](crate::common::scrollable::ScrollableBehavior),
112//! which gives it the shared wheel arithmetic, a finger's pan and the
113//! `PanClaim` that puts it on a pan's claimant chain. A pan scrolls it, the
114//! release coasts, and a pan it cannot absorb hands the **whole** event to the
115//! container outside — never a residual. A pan that starts on a row scrolls
116//! rather than activating it or collapsing a multi-selection onto it. Both axes
117//! are claimed. Shift+wheel still
118//! scrolls the columns, and a finger's pan is never remapped by a held Shift:
119//! the remap is a wheel convention, and turning a drag sideways is not what
120//! the hand asked for.
121
122pub mod a11y;
123pub mod body;
124pub mod body_pane;
125pub mod column;
126pub mod filter;
127pub mod header;
128pub mod imperative;
129pub mod keyboard;
130pub mod layout;
131pub mod row_navigator;
132pub mod selection;
133pub mod table_header;
134mod widget_impl;
135
136#[cfg(test)]
137mod tests;
138
139#[cfg(test)]
140mod handle_tests;
141
142use std::cell::{Cell, RefCell};
143use std::collections::HashMap;
144use std::rc::Rc;
145use std::time::Duration;
146
147use teksilo_canvas::{Canvas, Point, Rect, Size, SizeProposal};
148
149use teksilo_core::ObserverHandle;
150use teksilo_core::accessibility::{AccessNodeBuilder, widget_id_to_node_id};
151use teksilo_core::binding::BindingLevel;
152use teksilo_core::build_context::BuildContext;
153use teksilo_core::kinetic::KineticScroller;
154use teksilo_core::pointer::touch_action::PanAxes;
155use teksilo_core::signal::{Prop, Signal};
156use teksilo_core::widget::{LayoutContext, PaintContext, Widget, WidgetPlacement};
157use teksilo_core::widget_builder::HandlerSet;
158use teksilo_core::widget_id::WidgetId;
159use teksilo_data::{
160    DataChange, DropResponse, ItemKey, KeyedSelectionModel, ListDataSource, ListModel,
161    SelectionModel,
162};
163use teksilo_i18n::LocalizedString;
164use teksilo_tokens::{BorderRole, OverscrollStyle, SurfaceRole};
165
166use crate::styles::recipe_table_style as cp;
167
168use crate::common::row_metrics::{HeightSource, RowMetrics, SharedRowMetrics};
169use crate::common::row_reveal::{RevealAlign, RowReveal, SharedRowReveal};
170use crate::common::scroll::OverscrollBehavior;
171use crate::data_views::{
172    DragTransferMode, RowDragData, RowSelection, ViewId, ViewKind, flat_insertion_target,
173};
174use crate::list_source::DndLazy;
175use crate::scroll_area::ScrollBarMode;
176use crate::scroll_bar::{ScrollBar, ScrollBarOrientation, ScrollBarVisual};
177
178pub use self::column::{
179    Alignment, CellContext, Column, ColumnContext, ColumnResizePolicy, ColumnSpec, ColumnWidth,
180    EditTriggers, GridLines, PinnedSide, TabTraversal, TruncationPolicy,
181};
182pub use self::selection::{CellSelectionModel, TableSelectionMode};
183pub use self::table_header::TableHeader;
184pub(crate) use self::table_header::{HeaderLink, next_header_id};
185pub use teksilo_data::SortDirection;
186
187const BUFFER_ROWS: usize = 5;
188const SCROLLBAR_THICKNESS: f32 = 12.0;
189
190/// Pane partition produced by [`TableView::display_order`].
191///
192/// `leading_count` columns sit in the leading-pinned region, the next
193/// `middle_end - leading_count` columns sit in the middle (horizontally
194/// scrollable) region, and the remainder are trailing-pinned.
195/// All counts are positions inside the display-order vector.
196#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
197pub(crate) struct PaneBoundaries {
198    pub leading_count: usize,
199    pub middle_end: usize,
200}
201
202impl PaneBoundaries {
203    pub(crate) fn new(leading_count: usize, middle_end: usize) -> Self {
204        Self {
205            leading_count,
206            middle_end,
207        }
208    }
209}
210
211/// Drag payload for column reorder. Carried via `DragPayload::typed`.
212#[derive(Debug, Clone)]
213pub(crate) struct ColumnReorderDragData {
214    pub col_id: String,
215    /// Stable id of the source header (`next_header_id`), so dropping into
216    /// a sibling table's header is rejected by the on_drop matcher.
217    pub source_table_id: usize,
218}
219
220// ── Source erasure ─────────────────────────────────────────────────────────
221
222type LenFn = Rc<dyn Fn() -> usize>;
223type WithItemFn<T> = Rc<dyn Fn(usize, &dyn Fn(&T))>;
224type ObserveFn = Rc<dyn Fn(Box<dyn Fn(&DataChange)>) -> ObserverHandle>;
225/// Divergence side-channel for `DataChange::Reset`-emitting proxies
226/// (`ListDataSource::first_changed_index`). Raw `ListModel`s report
227/// `None` — their observers already get fine-grained variants.
228type FirstChangedFn = Rc<dyn Fn() -> Option<usize>>;
229
230/// The multi-cell read erasure. `TableView` reads each row's item once
231/// per cell (each column's `cell` delegate), so it keeps the side-effect
232/// `with_item_fn` form rather than `ListSource`'s single-widget reader.
233/// The DnD + lazy protocol is shared from `DndLazy` (built separately in
234/// the constructors). Returned alongside the `Rc<S>` source so the caller
235/// can build a `DndLazy` from the same handle without re-wrapping.
236fn erase_list_model<T: 'static>(
237    model: ListModel<T>,
238) -> (LenFn, WithItemFn<T>, ObserveFn, FirstChangedFn) {
239    let m_len = model.clone();
240    let m_read = model.clone();
241    let m_obs = model;
242    let len_fn: LenFn = Rc::new(move || m_len.len());
243    let with_item_fn: WithItemFn<T> = Rc::new(move |idx, f| {
244        m_read.with_item(idx, |item| f(item));
245    });
246    let observe_fn: ObserveFn =
247        Rc::new(move |callback| m_obs.observe_changes(move |change| callback(change)));
248    (len_fn, with_item_fn, observe_fn, Rc::new(|| None))
249}
250
251fn erase_data_source<S: ListDataSource<Item = T>, T: 'static>(
252    s: Rc<S>,
253) -> (LenFn, WithItemFn<T>, ObserveFn, FirstChangedFn) {
254    let s_len = s.clone();
255    let s_read = s.clone();
256    let s_obs = s.clone();
257    let s_changed = s;
258    let len_fn: LenFn = Rc::new(move || s_len.len());
259    let with_item_fn: WithItemFn<T> = Rc::new(move |idx, f| {
260        s_read.with_item(idx, |item| f(item));
261    });
262    let observe_fn: ObserveFn =
263        Rc::new(move |callback| s_obs.observe_changes(move |change| callback(change)));
264    let first_changed_fn: FirstChangedFn = Rc::new(move || s_changed.first_changed_index());
265    (len_fn, with_item_fn, observe_fn, first_changed_fn)
266}
267
268// `read_item` lived here for the inline body-row build; that loop now
269// lives in `BodyPane` which has its own copy. Keeping it removed
270// avoids dead-code drift between the two paths.
271
272// ── Public widget ──────────────────────────────────────────────────────────
273
274/// Generic, virtualized, accessible table with sortable / filterable / resizable columns.
275///
276/// Construct with [`TableView::new`] (from a [`ListModel<T>`](teksilo_data::ListModel))
277/// or [`TableView::from_source`] (any [`ListDataSource`]), then chain builder methods
278/// to configure columns, row heights, selection, and so on. See module docs for the full
279/// feature list and row-height modes.
280pub struct TableView<T: 'static> {
281    // Source erasure (multi-cell read path; DnD + lazy live in `dnd`).
282    len_fn: LenFn,
283    with_item_fn: WithItemFn<T>,
284    observe_fn: ObserveFn,
285    first_changed_fn: FirstChangedFn,
286    /// Source-owned DnD validation + lazy windowing, erased from the
287    /// backing `ListDataSource`. A `ListModel` reorders in place via its
288    /// `accept_drop`; an external source routes the move to its store and
289    /// can forbid a drop by returning `DropResponse::Reject` (the view
290    /// then paints no insertion line).
291    dnd: DndLazy,
292    /// Resolve a row index to a movement-proof handle (see `RowAnchor`).
293    anchor_fn: Rc<dyn Fn(usize) -> crate::data_views::RowAnchor>,
294    /// Anchor for the row with an open cell editor, so the editor follows its
295    /// row instead of its index. See `reconcile_editing_row`.
296    editing_anchor: Rc<RefCell<Option<crate::data_views::RowAnchor>>>,
297
298    // Configuration
299    columns: Vec<Column<T>>,
300    row_height: Option<f32>,
301    /// Height-mode selection (uniform / exact callback / auto-measure).
302    height_source: HeightSource,
303    /// Row geometry — shared with `BodyPane` and the keyboard handler.
304    row_metrics: SharedRowMetrics,
305    header_height: Option<f32>,
306    /// The active `TableStyle`'s row and header heights at the density
307    /// in force, read at build by `adopt_style_heights`. Until then they
308    /// hold IntUI's Compact values, the module constants.
309    style_row_height: f32,
310    style_header_height: f32,
311    show_header: bool,
312    selection_mode: TableSelectionMode,
313    /// Row selection — index-based `SelectionModel` or keyed
314    /// `KeyedSelectionModel<K>`, unified behind the index-facing facade.
315    row_selection: Option<RowSelection>,
316    cell_selection: Option<CellSelectionModel>,
317    alternating_rows: bool,
318    grid_lines: GridLines,
319    /// See [`Self::stretch_last_column`].
320    stretch_last_column: bool,
321    a11y_label: Option<LocalizedString>,
322    show_internal_scrollbars: bool,
323    empty_view: Option<Rc<dyn Fn() -> Box<dyn Widget>>>,
324    column_resize_policy: ColumnResizePolicy,
325
326    /// Animate wheel scrolling instead of snapping to the new offset.
327    /// Enabled by default — mirrors `ScrollArea`. Without it, each wheel
328    /// notch jumps by `row_height` per delivered line (typically 3),
329    /// which reads as a coarse multi-row jump rather than a smooth glide.
330    smooth_scrolling: bool,
331    /// Duration of the smooth scroll animation.
332    smooth_scroll_duration: Duration,
333
334    /// How the scroll bar is displayed. Defaults to `Permanent` — a
335    /// layout sibling that reserves its own width. `Overlay` / `Thin`
336    /// float over the content instead, like `ScrollArea`.
337    scroll_bar_style: ScrollBarMode,
338
339    // Public reactive signals
340    scroll_y: Signal<f32>,
341    max_scroll_y: Signal<f32>,
342    /// Scroll-chaining behavior at the boundary (default `Chain`).
343    overscroll_behavior: OverscrollBehavior,
344    viewport_ratio_y: Signal<f32>,
345    /// Horizontal scroll offset of the Middle (unpinned) pane — see
346    /// `PaneBoundaries`. Leading/Trailing-pinned columns never move; the
347    /// Middle pane's content shifts by `-scroll_x`.
348    scroll_x: Signal<f32>,
349    /// Maximum `scroll_x` — `middle_content_width − middle_viewport_width`.
350    max_scroll_x: Signal<f32>,
351    /// Middle-pane viewport-to-content width ratio, for the horizontal
352    /// scroll bar's thumb.
353    viewport_ratio_x: Signal<f32>,
354    /// This surface's pan physics: the range a finger's pan is clamped to and
355    /// the offset it is currently holding. Owned by the view rather than by
356    /// the [`ScrollableBehavior`](crate::common::scrollable::ScrollableBehavior)
357    /// so it survives a rebuild, and so `place_children` — the only pass that
358    /// knows the viewport extent — can publish into it.
359    scroller: Rc<RefCell<KineticScroller>>,
360    sort_signal: Signal<Option<(String, SortDirection)>>,
361    column_widths_signal: Signal<HashMap<String, f32>>,
362    /// `Some` once `bind_column_widths` adopted the width map: the widths a
363    /// resize freezes stay here, in this view, rather than in a map other
364    /// views may share (see `header::commit_resize`).
365    column_widths_frozen: Option<header::SharedFrozenWidths>,
366    /// Column ids in display order. Empty means "use declaration order".
367    column_order_signal: Signal<Vec<String>>,
368    /// Per-id override for `Column::pinned`. Missing keys mean "use the
369    /// declared pinning". The drag-to-reorder UI updates this when a
370    /// column crosses a pane boundary.
371    column_pinning_signal: Signal<HashMap<String, PinnedSide>>,
372    /// Currently keyboard-focused cell `(row_index, display_col)`, or
373    /// `None` when no cell is focused.
374    focused_cell: Signal<Option<(usize, usize)>>,
375    /// The realized `(row index -> row wrapper id)` map, filled by the body
376    /// pane each build. Lets this widget's `&self` methods resolve a row index
377    /// to a widget without reaching into the pane. Mirrors `ListView::row_map`.
378    row_map: Rc<RefCell<Vec<(usize, WidgetId)>>>,
379    /// Type-ahead ("type to jump") label extractor — opt-in via
380    /// [`type_ahead_label`](Self::type_ahead_label).
381    #[allow(clippy::type_complexity)]
382    type_ahead_label: Option<Rc<dyn Fn(&T) -> String>>,
383    /// Reset window for the type-ahead search term.
384    type_ahead_timeout: Duration,
385    /// Persistent type-ahead buffer (survives the per-keystroke rebuild).
386    type_ahead: Rc<crate::common::type_ahead::TypeAheadState>,
387    tab_traversal: TabTraversal,
388    /// Cell currently in edit mode, or `None` when no editor is open.
389    /// Cell delegates inspect this through `CellContext::is_editing` to
390    /// swap in an editor widget.
391    editing_cell: Signal<Option<(usize, usize)>>,
392    edit_triggers: EditTriggers,
393    /// User callback invoked when an edit trigger fires on the focused
394    /// cell.
395    #[allow(clippy::type_complexity)]
396    on_cell_edit_request: Option<Rc<dyn Fn(usize, &str, &mut teksilo_core::widget::EventContext)>>,
397    #[allow(clippy::type_complexity)]
398    on_cell_edit_dismissed:
399        Option<Rc<dyn Fn(usize, &str, &mut teksilo_core::widget::EventContext)>>,
400    /// Per-column filter text. Updated by filter affordances in the
401    /// header, by `set_filter` / `clear_filters`, and by
402    /// downstream consumers binding it (e.g., `SortFilterListModel`).
403    filters_signal: Signal<HashMap<String, String>>,
404    /// User callback invoked on every row activation (Enter on the
405    /// focused row).
406    #[allow(clippy::type_complexity)]
407    on_row_activate: Option<Rc<dyn Fn(usize, &mut teksilo_core::widget::EventContext)>>,
408    reorderable: bool,
409    /// Active row-drop insertion indicator `(body_local_y, width)` —
410    /// `body_local_y` is measured from the body band top (below the
411    /// header). Set by `on_drag_hover` when the source accepts the
412    /// hovered position, cleared on leave / drop, read by `paint`.
413    /// Reactive (`RepaintOnly`) so a `set(...)` dirties the table.
414    drop_feedback: Signal<Option<(f32, f32)>>,
415
416    /// Whether activation is a single or double click (default `DoubleClick`).
417    activate_on: crate::data_views::ActivateOn,
418
419    /// `true` while this view — its root or any descendant (e.g. a cell
420    /// editor) — holds keyboard focus. Captured at build from
421    /// [`BuildContext::begin_view_focus`] and bound `RepaintOnly`. Drives
422    /// **focus-aware selection**: the selection band paints with the active
423    /// `Selected` chrome while focused and the muted `SelectedInactive` chrome
424    /// once focus leaves the table — the standard desktop affordance.
425    view_focused: Signal<bool>,
426    /// Input-modality `:focus-visible` — `true` after keyboard input, `false`
427    /// after a pointer press. Gates the cell focus ring so it shows only
428    /// during keyboard navigation, never on a mouse click. Bound `RepaintOnly`.
429    focus_visible: Signal<bool>,
430
431    // Build state
432    header_row_id: Option<WidgetId>,
433    body_pane_id: Option<WidgetId>,
434    scrollbar_id: Option<WidgetId>,
435    /// Horizontal scroll bar along the bottom of the Middle pane only —
436    /// built whenever `show_internal_scrollbars` is set, placed/sized (and
437    /// hidden at zero size, mirroring the vertical bar) in `place_children`.
438    h_scrollbar_id: Option<WidgetId>,
439    empty_id: Option<WidgetId>,
440    /// Pane-local rebuild trigger + buffered range, owned here so they
441    /// survive `TableView` rebuilds (each rebuild constructs a fresh
442    /// `BodyPane` struct that inherits these handles).
443    pane_version: Signal<u64>,
444    pane_built_start: Rc<Cell<usize>>,
445    pane_built_end: Rc<Cell<usize>>,
446    /// Bumped by the pane when a measure pass changes the content
447    /// total; bound at `Relayout` on this root so `max_scroll_y` / the
448    /// thumb ratio are recomputed with the corrected total next frame.
449    pane_total_refresh: Signal<u64>,
450    /// The reveal a [`TableViewHandle`] asked for, applied by the body pane
451    /// after its measure pass. Created with the view so a handle taken before
452    /// mount reaches every pane the view builds.
453    reveal: SharedRowReveal,
454
455    // Layout state
456    /// Resolved widths in **display order** (parallel to
457    /// `display_indices`).
458    column_widths: Rc<RefCell<Vec<f32>>>,
459    /// Display-order indices into `self.columns`. Recomputed each
460    /// `build()`; read by `place_children` and `paint`.
461    display_indices: Rc<RefCell<Vec<usize>>>,
462    /// `(row, display_pos) -> WidgetId` for every cell realized by the
463    /// body pane's latest `build()`. Shared with `BodyPane` (the GridView
464    /// `tile_map` pattern — two holders across the sibling-of-scrollbar
465    /// split): the pane overwrites it wholesale each time it rebuilds, so
466    /// a cell that scrolled out of the realized buffer simply isn't in
467    /// the map. `accessibility()` reads it to point `active_descendant`
468    /// at the keyboard-focused cell's own AT node.
469    cell_map: Rc<RefCell<Vec<((usize, usize), WidgetId)>>>,
470    /// Counts of (leading-pinned, middle, trailing-pinned) columns —
471    /// used by paint to draw pane dividers and by the drop-zone math
472    /// to classify a drop position.
473    pane_boundaries: Rc<RefCell<PaneBoundaries>>,
474    viewport_height: Rc<Cell<f32>>,
475    /// Middle-pane viewport width, snapshotted by `place_children` — the
476    /// horizontal analogue of `viewport_height`. Read by the keyboard
477    /// handler's ensure-column-visible follow.
478    middle_viewport_width: Rc<Cell<f32>>,
479    /// Set on the first `place_children`. Until then `viewport_height` still
480    /// holds its construction placeholder, so viewport-relative imperatives
481    /// (`ensure_row_visible`) would scroll against a size that was never real.
482    laid_out: Rc<Cell<bool>>,
483    /// The row-area's absolute (window) rect (below the header), cached by
484    /// `place_children`. Threaded into the keyboard handler so it can chase the
485    /// focused row into any *enclosing* scroll area via
486    /// [`EventContext::ensure_visible`](teksilo_core::widget::EventContext::ensure_visible).
487    body_bounds: Rc<Cell<Rect>>,
488    /// Width of the header strip (= the column band) snapshotted by
489    /// `place_children`. The reorder-drop handler needs it to mirror the
490    /// drop x under RTL, where the column content is right-anchored in
491    /// the band (`local.x` is measured from the strip's physical left).
492    header_strip_width: Rc<Cell<f32>>,
493
494    // Header-cell shared state — tracked across the table so the
495    // pointer-capture'd resize delivers PointerMove events back to the
496    // active HeaderCell.
497    resize_state: header::ResizeStateHandle,
498    /// Display slot of the column under an active resize drag, or `None`.
499    /// Shared with every `HeaderCell` so the *target* column shows the
500    /// "resizing" chrome — which is not always the cell holding the pointer
501    /// capture, since a grip straddles the divider between two cells.
502    resize_target: Signal<Option<usize>>,
503    /// Window x of the prospective divider while a
504    /// [`ColumnResizePolicy::OnRelease`] drag is in flight. Painted as a
505    /// guide line by `paint`; `None` at rest. Under `Live` the columns
506    /// themselves move, so nothing is published here.
507    resize_preview_x: Signal<Option<f32>>,
508
509    /// Stable id used by the column-reorder drag payload to disambiguate
510    /// inter-table drops — drawn from the counter every header shares
511    /// (`next_header_id`). Unrelated to row DnD — a wholly separate
512    /// mechanism (`ColumnReorderDragData` + the header's drop target).
513    table_id: usize,
514
515    /// Stable, kind-tagged ID for this TableView instance's **row** DnD
516    /// (identifies its own row reorder vs. a foreign row drop, even across
517    /// widget kinds / windows). Distinct from `table_id` above, which only
518    /// disambiguates the separate column-reorder mechanism.
519    model_id: ViewId,
520
521    /// Cross-widget export / foreign-receive machinery — the builders
522    /// (`.exportable`, `.export_external`, `.accept_foreign_rows`,
523    /// `.on_rows_received`, `.on_rows_transferred_out`), the drag-start payload
524    /// build, and the move-out completion, shared by all five data views.
525    export: crate::data_views::RowExport<T>,
526
527    /// Whole-view enabled state, statically or reactively. Forwarded to the
528    /// arena via `ctx.enabled_when(self_id, self.enabled.clone())` at build
529    /// time; a disabled view greys out and stops accepting focus /
530    /// selection / keyboard input (arena-gated).
531    enabled: Prop<bool>,
532}
533
534/// Build the anchor factory for a keyed source: capture the row's key now,
535/// resolve its current index later. Keyless sources fall back to a fixed anchor.
536fn anchor_factory<S: ListDataSource<Item = T> + 'static, T: 'static>(
537    s: Rc<S>,
538) -> Rc<dyn Fn(usize) -> crate::data_views::RowAnchor> {
539    Rc::new(move |index| match s.key_at(index) {
540        Some(key) => {
541            let src = s.clone();
542            crate::data_views::RowAnchor::new(Rc::new(move || {
543                if src.key_at(index).as_ref() == Some(&key) {
544                    return Some(index);
545                }
546                src.index_of(&key)
547            }))
548        }
549        None => crate::data_views::RowAnchor::fixed(index),
550    })
551}
552
553impl<T: 'static> TableView<T> {
554    /// Wrap a `ListModel<T>`.
555    pub fn new(model: ListModel<T>) -> Self {
556        let dnd = DndLazy::from_source(Rc::new(model.clone()));
557        let (len_fn, with_item_fn, observe_fn, first_changed_fn) = erase_list_model(model);
558        // A bare `ListModel` exposes no row identity.
559        let anchor_fn = Rc::new(crate::data_views::RowAnchor::fixed) as Rc<dyn Fn(usize) -> _>;
560        Self::create(
561            len_fn,
562            with_item_fn,
563            observe_fn,
564            first_changed_fn,
565            dnd,
566            anchor_fn,
567        )
568    }
569
570    /// Wrap any `ListDataSource<Item = T>` (e.g. a
571    /// [`SortFilterListModel<T>`](teksilo_data::SortFilterListModel)).
572    ///
573    /// The source owns DnD validation (`can_accept` / `accept_drop`) and
574    /// lazy windowing (`row_state` / `request_window` / `fetch_more`); a
575    /// read-only source leaves the defaults inert.
576    pub fn from_source<S: ListDataSource<Item = T>>(source: S) -> Self {
577        let s = Rc::new(source);
578        let dnd = DndLazy::from_source(s.clone());
579        let anchor_fn = anchor_factory::<S, T>(s.clone());
580        let (len_fn, with_item_fn, observe_fn, first_changed_fn) = erase_data_source::<S, T>(s);
581        Self::create(
582            len_fn,
583            with_item_fn,
584            observe_fn,
585            first_changed_fn,
586            dnd,
587            anchor_fn,
588        )
589    }
590
591    /// Wrap any `ListDataSource<Item = T>` with **keyed** row selection. The
592    /// `KeyedSelectionModel<S::Key>` tracks selection by source identity, so it
593    /// survives reorders / filters / lazy window-slides and stays consistent
594    /// across two views of the same source. The view stays `TableView<T>` — the
595    /// index↔key mapping is captured from the concrete source here. Equivalent
596    /// to `from_source(..)` plus an identity-based replacement for
597    /// [`selection`](Self::selection).
598    pub fn from_source_keyed<S: ListDataSource<Item = T>>(
599        source: S,
600        keyed: KeyedSelectionModel<S::Key>,
601    ) -> Self
602    where
603        S::Key: ItemKey,
604    {
605        let s = Rc::new(source);
606        let dnd = DndLazy::from_source(s.clone());
607        let key_at = {
608            let s = s.clone();
609            Rc::new(move |i| s.key_at(i)) as Rc<dyn Fn(usize) -> Option<S::Key>>
610        };
611        let len = {
612            let s = s.clone();
613            Rc::new(move || s.len()) as Rc<dyn Fn() -> usize>
614        };
615        // Existence for the prune is the keys over the rows, gathered once per
616        // prune; see `ListView::from_source_keyed`.
617        let row_selection = RowSelection::from_keyed_flat(keyed, key_at, len);
618        let anchor_fn = anchor_factory::<S, T>(s.clone());
619        let (len_fn, with_item_fn, observe_fn, first_changed_fn) = erase_data_source::<S, T>(s);
620        let mut view = Self::create(
621            len_fn,
622            with_item_fn,
623            observe_fn,
624            first_changed_fn,
625            dnd,
626            anchor_fn,
627        );
628        view.row_selection = Some(row_selection);
629        view
630    }
631
632    fn create(
633        len_fn: LenFn,
634        with_item_fn: WithItemFn<T>,
635        observe_fn: ObserveFn,
636        first_changed_fn: FirstChangedFn,
637        dnd: DndLazy,
638        anchor_fn: Rc<dyn Fn(usize) -> crate::data_views::RowAnchor>,
639    ) -> Self {
640        let table_id = next_header_id();
641        Self {
642            len_fn,
643            with_item_fn,
644            observe_fn,
645            first_changed_fn,
646            dnd,
647            anchor_fn,
648            editing_anchor: Rc::new(RefCell::new(None)),
649            columns: Vec::new(),
650            row_height: None,
651            height_source: HeightSource::Uniform,
652            row_metrics: Rc::new(RefCell::new(RowMetrics::uniform(cp::ROW_HEIGHT, 0.0))),
653            header_height: None,
654            style_row_height: cp::ROW_HEIGHT,
655            style_header_height: cp::HEADER_HEIGHT,
656            show_header: true,
657            selection_mode: TableSelectionMode::default(),
658            row_selection: None,
659            cell_selection: None,
660            alternating_rows: false,
661            grid_lines: GridLines::None,
662            stretch_last_column: false,
663            a11y_label: None,
664            show_internal_scrollbars: true,
665            empty_view: None,
666            column_resize_policy: ColumnResizePolicy::default(),
667            smooth_scrolling: true,
668            smooth_scroll_duration: Duration::from_millis(150),
669            scroll_bar_style: ScrollBarMode::Permanent,
670            overscroll_behavior: OverscrollBehavior::default(),
671            scroll_y: Signal::new_animated(0.0),
672            max_scroll_y: Signal::new(0.0),
673            viewport_ratio_y: Signal::new(1.0),
674            scroll_x: Signal::new_animated(0.0),
675            max_scroll_x: Signal::new(0.0),
676            viewport_ratio_x: Signal::new(1.0),
677            scroller: Rc::new(RefCell::new(KineticScroller::new(OverscrollStyle::Clamp))),
678            sort_signal: Signal::new(None),
679            column_widths_signal: Signal::new(HashMap::new()),
680            column_widths_frozen: None,
681            column_order_signal: Signal::new(Vec::new()),
682            column_pinning_signal: Signal::new(HashMap::new()),
683            focused_cell: Signal::new(None),
684            row_map: Rc::new(RefCell::new(Vec::new())),
685            type_ahead_label: None,
686            type_ahead_timeout: crate::common::type_ahead::DEFAULT_TYPE_AHEAD_TIMEOUT,
687            type_ahead: crate::common::type_ahead::TypeAheadState::new(),
688            // Replaced at build with the live tree signals; the defaults are
689            // only the pre-build values (treat as focused, pointer modality).
690            view_focused: Signal::new(true),
691            focus_visible: Signal::new(false),
692            tab_traversal: TabTraversal::default(),
693            editing_cell: Signal::new(None),
694            edit_triggers: EditTriggers::default(),
695            on_cell_edit_request: None,
696            on_cell_edit_dismissed: None,
697            filters_signal: Signal::new(HashMap::new()),
698            on_row_activate: None,
699            reorderable: false,
700            drop_feedback: Signal::new(None),
701            activate_on: crate::data_views::ActivateOn::default(),
702            header_row_id: None,
703            body_pane_id: None,
704            scrollbar_id: None,
705            h_scrollbar_id: None,
706            empty_id: None,
707            pane_version: Signal::new(0_u64),
708            pane_built_start: Rc::new(Cell::new(0)),
709            pane_built_end: Rc::new(Cell::new(0)),
710            pane_total_refresh: Signal::new(0_u64),
711            reveal: RowReveal::new(),
712            column_widths: Rc::new(RefCell::new(Vec::new())),
713            display_indices: Rc::new(RefCell::new(Vec::new())),
714            cell_map: Rc::new(RefCell::new(Vec::new())),
715            pane_boundaries: Rc::new(RefCell::new(PaneBoundaries::default())),
716            viewport_height: Rc::new(Cell::new(600.0)),
717            middle_viewport_width: Rc::new(Cell::new(600.0)),
718            laid_out: Rc::new(Cell::new(false)),
719            body_bounds: Rc::new(Cell::new(Rect::ZERO)),
720            header_strip_width: Rc::new(Cell::new(0.0)),
721            resize_state: Rc::new(std::cell::RefCell::new(None)),
722            resize_target: Signal::new(None),
723            resize_preview_x: Signal::new(None),
724            table_id,
725            model_id: ViewId::next(ViewKind::Table),
726            export: crate::data_views::RowExport::default(),
727            enabled: Prop::Static(true),
728        }
729    }
730
731    // ── Builder ────────────────────────────────────────────────────────
732
733    /// Enable or disable the whole view. A disabled view greys out and stops
734    /// accepting focus / selection / keyboard input (arena-gated).
735    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
736        self.enabled = enabled.into();
737        self
738    }
739
740    /// Set the scroll-chaining behavior at the boundary (default
741    /// [`OverscrollBehavior::Chain`]; [`Contain`](OverscrollBehavior::Contain)
742    /// disables chaining to an ancestor scrollable).
743    pub fn overscroll_behavior(mut self, behavior: OverscrollBehavior) -> Self {
744        self.overscroll_behavior = behavior;
745        self
746    }
747
748    /// Enable or disable animated wheel scrolling (enabled by default).
749    /// When disabled, wheel events snap immediately to the new offset.
750    pub fn smooth_scrolling(mut self, enabled: bool) -> Self {
751        self.smooth_scrolling = enabled;
752        self
753    }
754
755    /// Enable **type-ahead** ("type to jump"): typing a printable character
756    /// while the table has keyboard focus jumps the focused row to the next
757    /// row whose label starts with the accumulated search term, wrapping
758    /// around (Qt `keyboardSearch` / macOS & Windows type-select).
759    /// `label(&item)` yields the searchable text for a row; matching is
760    /// ASCII-case-insensitive. A pause longer than the
761    /// [`type_ahead_timeout`](Self::type_ahead_timeout) starts a fresh term.
762    ///
763    /// On an editable column whose [`EditTriggers`] is type-to-edit, typing
764    /// starts an edit instead — type-ahead applies on non-editable columns
765    /// (or when no type-to-edit trigger is configured).
766    pub fn type_ahead_label(mut self, label: impl Fn(&T) -> String + 'static) -> Self {
767        self.type_ahead_label = Some(Rc::new(label));
768        self
769    }
770
771    /// Reset window between keystrokes before the type-ahead search term
772    /// clears (default 500 ms). A zero duration disables type-ahead.
773    pub fn type_ahead_timeout(mut self, timeout: Duration) -> Self {
774        self.type_ahead_timeout = timeout;
775        self
776    }
777
778    /// Duration of the smooth scroll animation (default 150 ms).
779    pub fn smooth_scroll_duration(mut self, duration: Duration) -> Self {
780        self.smooth_scroll_duration = duration;
781        self
782    }
783
784    /// How the scroll bar is displayed (default `Permanent`). `Overlay`
785    /// and `Thin` float the bar over the content instead of reserving a
786    /// layout column for it, mirroring `ScrollArea::scroll_bar_style`.
787    pub fn scroll_bar_style(mut self, style: ScrollBarMode) -> Self {
788        self.scroll_bar_style = style;
789        self
790    }
791
792    /// Append a single [`Column<T>`] definition to the table.
793    pub fn add_column(mut self, col: Column<T>) -> Self {
794        self.columns.push(col);
795        self
796    }
797
798    /// Append multiple [`Column<T>`] definitions from an iterator.
799    pub fn columns(mut self, cols: impl IntoIterator<Item = Column<T>>) -> Self {
800        self.columns.extend(cols);
801        self
802    }
803
804    /// Re-materialize `self.row_metrics` after a height-mode /
805    /// row-height builder call.
806    fn remake_metrics(&self) {
807        *self.row_metrics.borrow_mut() = self
808            .height_source
809            .make_metrics(self.effective_row_height(), 0.0);
810    }
811
812    /// Read the active `TableStyle`'s row and header heights at the density
813    /// in force. Called first thing in `build`, so every height the build and
814    /// the later layout passes read is the style's, unless the app set its
815    /// own through [`row_height`](Self::row_height) /
816    /// [`header_height`](Self::header_height), which win. A uniform-height
817    /// view with no row height of its own re-materializes its metrics when
818    /// the style's row height differs from the one they were made with.
819    fn adopt_style_heights(&mut self, ctx: &BuildContext) {
820        let style = crate::styles::recipe_table_style::resolve_table_style(ctx);
821        let tokens = &ctx.theme().input;
822        self.style_header_height = style.header_height(tokens);
823        let row = style.row_height(tokens);
824        if row != self.style_row_height {
825            self.style_row_height = row;
826            if self.row_height.is_none() && matches!(self.height_source, HeightSource::Uniform) {
827                self.remake_metrics();
828            }
829        }
830    }
831
832    /// Fixed row height, the uniform fast path. Default: the active
833    /// `TableStyle`'s
834    /// [`row_height`](teksilo_core::styles::TableStyle::row_height), 28 px
835    /// under IntUI at the Compact density. Mutually exclusive with
836    /// [`row_height_fn`](Self::row_height_fn) and
837    /// [`auto_row_height`](Self::auto_row_height); the last mode setter
838    /// wins.
839    pub fn row_height(mut self, height: f32) -> Self {
840        self.row_height = Some(height);
841        self.height_source = HeightSource::Uniform;
842        self.remake_metrics();
843        self
844    }
845
846    /// Per-row heights from a callback over the visible row index. The
847    /// callback must be pure (same index + same data → same height); it
848    /// is re-swept from the first changed index on every model change
849    /// (a `SortFilterListModel` source reports that index through
850    /// `first_changed_index`, so sort/filter/append keep the valid
851    /// prefix). No measurement pass runs.
852    pub fn row_height_fn(mut self, f: impl Fn(usize) -> f32 + 'static) -> Self {
853        self.height_source = HeightSource::Exact(Rc::new(f));
854        self.remake_metrics();
855        self
856    }
857
858    /// Auto-measured row heights: each realized row reports the height
859    /// of its tallest cell measured at the cell's column width
860    /// (height-for-width), unrealized rows assume `estimated`. Scroll
861    /// anchoring keeps content above the viewport stationary as
862    /// estimates are corrected; the scrollbar settles one frame after a
863    /// measurement change.
864    pub fn auto_row_height(mut self, estimated: f32) -> Self {
865        self.height_source = HeightSource::Auto { estimated };
866        self.remake_metrics();
867        self
868    }
869
870    /// Override the column header row height in logical pixels. Default: the
871    /// active `TableStyle`'s
872    /// [`header_height`](teksilo_core::styles::TableStyle::header_height),
873    /// 32 px under IntUI at the Compact density.
874    pub fn header_height(mut self, height: f32) -> Self {
875        self.header_height = Some(height);
876        self
877    }
878
879    /// Show or hide the column header row. Default: visible.
880    pub fn show_header(mut self, visible: bool) -> Self {
881        self.show_header = visible;
882        self
883    }
884
885    /// Set how column widths are redistributed when columns are
886    /// added, resized, or the table's own width changes. See
887    /// [`ColumnResizePolicy`].
888    pub fn column_resize_policy(mut self, policy: ColumnResizePolicy) -> Self {
889        self.column_resize_policy = policy;
890        self
891    }
892
893    /// Control how Tab / Shift+Tab navigate between cells. See
894    /// [`TabTraversal`].
895    pub fn tab_traversal(mut self, mode: TabTraversal) -> Self {
896        self.tab_traversal = mode;
897        self
898    }
899
900    /// Set which user action opens a cell editor. See [`EditTriggers`].
901    pub fn edit_triggers(mut self, trigger: EditTriggers) -> Self {
902        self.edit_triggers = trigger;
903        self
904    }
905
906    /// Hook fired by the keyboard handler when an edit trigger fires
907    /// on the focused cell. Receives `(row_index, col_id, ctx)`.
908    pub fn on_cell_edit_request(
909        mut self,
910        f: impl Fn(usize, &str, &mut teksilo_core::widget::EventContext) + 'static,
911    ) -> Self {
912        self.on_cell_edit_request = Some(Rc::new(f));
913        self
914    }
915
916    /// Callback invoked when an **open** cell editor should end because the
917    /// pointer went somewhere else: a press that lands on any cell other than
918    /// the one being edited. Receives the editing cell's flat row index and
919    /// column id, so the owner can commit (or discard) whatever is in its
920    /// buffer, then clear its own editing state.
921    ///
922    /// The counterpart of [`on_cell_edit_request`](Self::on_cell_edit_request),
923    /// and the view cannot do it alone: the framework owns *which* cell is being
924    /// edited, but only the owner knows what an ended edit means — commit,
925    /// discard, or refuse a value that will not parse.
926    ///
927    /// **Why a press and not a focus change.** "The editor lost focus" is the
928    /// obvious signal and it cannot be used: a body pane rebuilds constantly —
929    /// selection, filtering, scroll, a reload from elsewhere — and every rebuild
930    /// destroys and re-creates the open editor, so focus leaves it many times
931    /// during an edit the writer never interrupted. A press on another cell is
932    /// unambiguous and happens exactly once.
933    pub fn on_cell_edit_dismissed(
934        mut self,
935        f: impl Fn(usize, &str, &mut teksilo_core::widget::EventContext) + 'static,
936    ) -> Self {
937        self.on_cell_edit_dismissed = Some(Rc::new(f));
938        self
939    }
940
941    /// Callback invoked when a row is activated: a click or a double click per
942    /// [`activate_on`](Self::activate_on), or Enter on the focused row.
943    /// Receives the flat row index.
944    ///
945    /// The pointer half is a **gesture**, so it arbitrates against a pan and
946    /// against the reorder drag through the gesture arena: a click activates,
947    /// a drag does not.
948    pub fn on_row_activate(
949        mut self,
950        f: impl Fn(usize, &mut teksilo_core::widget::EventContext) + 'static,
951    ) -> Self {
952        self.on_row_activate = Some(Rc::new(f));
953        self
954    }
955
956    /// Enable drag-to-reorder of **rows** (pointer drag + keyboard
957    /// Alt+ArrowUp/Down). Distinct from
958    /// [`Column::reorderable`](crate::Column::reorderable), which reorders
959    /// *columns* and defaults to `true`; this defaults to `false`.
960    ///
961    /// The move is routed through the backing source's `accept_drop`: a
962    /// `ListModel` reorders in place, an external source routes the move to
963    /// its store. Per-hover the source's `can_accept` decides whether the
964    /// drop is allowed — a forbidden position shows no insertion line and
965    /// the drop is refused. A row may also be forbidden from dragging at
966    /// all (the source's `drag` gate). Cross-table / external drops arrive
967    /// at `accept_drop` as `DragSource::Foreign`; a bare `ListModel`
968    /// rejects them, an external source decides.
969    pub fn reorderable(mut self, enabled: bool) -> Self {
970        self.reorderable = enabled;
971        self
972    }
973
974    /// Renamed to [`reorderable`](Self::reorderable), matching `ListView`,
975    /// `GridView`, `TreeView` and `TreeTableView` — this was the only view in
976    /// the family spelling it differently.
977    #[deprecated(since = "0.6.3", note = "renamed to `reorderable`")]
978    pub fn reorderable_rows(self, enabled: bool) -> Self {
979        self.reorderable(enabled)
980    }
981
982    /// Make rows **droppable outside this view** — on a
983    /// [`DropTarget`](crate::DropTarget), another data view, or the OS.
984    ///
985    /// A dragged row (or the whole selection, when the pressed row is part of a
986    /// multi-selection) carries clones of its items in a public
987    /// [`RowDragData<T>`](crate::RowDragData), so a foreign receiver can pull
988    /// them out with `payload.get_typed::<RowDragData<T>>()` /
989    /// `DropTarget::on_drop_typed::<RowDragData<T>>()` — no serialization. This
990    /// also makes rows a drag source even without [`reorderable`](Self::reorderable).
991    ///
992    /// `mode` chooses what happens to the origin rows once a *foreign* target
993    /// accepts them: [`DragTransferMode::Move`] removes them (via the source's
994    /// `on_drag_out`, or [`on_rows_transferred_out`](Self::on_rows_transferred_out)),
995    /// [`DragTransferMode::Copy`] leaves them. A same-view reorder is never a
996    /// transfer, so `mode` never affects it. Requires `T: Clone`.
997    pub fn exportable(mut self, mode: DragTransferMode) -> Self
998    where
999        T: Clone,
1000    {
1001        self.export.set_exportable(mode);
1002        self
1003    }
1004
1005    /// Additionally advertise the dragged rows as MIME data so they can be
1006    /// dropped on a [`DropZone`](crate::DropZone) or exported to another
1007    /// application / window via the OS. `f` maps the dragged items to
1008    /// `(mime_type, bytes)` pairs (e.g. `text/plain`, `text/uri-list`, an
1009    /// app-specific `application/x-…`). Implies [`exportable`](Self::exportable)
1010    /// (defaulting to [`DragTransferMode::Move`] if not already set). Requires
1011    /// `T: Clone`.
1012    pub fn export_external(mut self, f: impl Fn(&[T]) -> Vec<(String, Vec<u8>)> + 'static) -> Self
1013    where
1014        T: Clone,
1015    {
1016        self.export.set_export_external(f);
1017        self
1018    }
1019
1020    /// Override how rows moved out to a foreign target are removed from this
1021    /// view. Receives the dragged rows' indices (descending-safe) and the live
1022    /// context. Without this, an [`exportable`](Self::exportable)
1023    /// [`Move`](DragTransferMode::Move) drag removes them through the source's
1024    /// `on_drag_out` (works out of the box for a `ListModel`).
1025    pub fn on_rows_transferred_out(
1026        mut self,
1027        f: impl Fn(&[usize], &mut teksilo_core::widget::EventContext) + 'static,
1028    ) -> Self {
1029        self.export.set_on_rows_transferred_out(f);
1030        self
1031    }
1032
1033    /// Accept exported rows dropped from a **different** view or source without
1034    /// writing a custom `ListDataSource`. Pair with
1035    /// [`on_rows_received`](Self::on_rows_received), which is handed the dropped
1036    /// items and the insertion index. (Same-view reorder is
1037    /// [`reorderable`](Self::reorderable); a custom `ListDataSource` can still
1038    /// accept foreign drops through its `can_accept`/`accept_drop` instead.)
1039    pub fn accept_foreign_rows(mut self, accept: bool) -> Self {
1040        self.export.accept_foreign_rows = accept;
1041        self
1042    }
1043
1044    /// Handler for rows accepted via [`accept_foreign_rows`](Self::accept_foreign_rows):
1045    /// `(items, insertion_index, ctx)`. Insert them into your model at the
1046    /// index.
1047    pub fn on_rows_received(
1048        mut self,
1049        f: impl Fn(Vec<T>, usize, &mut teksilo_core::widget::EventContext) + 'static,
1050    ) -> Self {
1051        self.export.set_on_rows_received(f);
1052        self
1053    }
1054
1055    /// Choose single- vs double-click activation for `on_row_activate` (default
1056    /// [`ActivateOn::DoubleClick`](crate::ActivateOn)). Enter/Space activates in
1057    /// either mode.
1058    pub fn activate_on(mut self, mode: crate::data_views::ActivateOn) -> Self {
1059        self.activate_on = mode;
1060        self
1061    }
1062
1063    /// Choose the row-selection granularity (None / Single / Multi).
1064    /// See [`TableSelectionMode`].
1065    pub fn selection_mode(mut self, mode: TableSelectionMode) -> Self {
1066        self.selection_mode = mode;
1067        self
1068    }
1069
1070    /// Set the index-based row selection model (positions). For identity-based
1071    /// selection that survives reorder / filter / window-slide, build the view
1072    /// with [`from_source_keyed`](Self::from_source_keyed) instead.
1073    pub fn selection(mut self, sel: SelectionModel) -> Self {
1074        self.row_selection = Some(RowSelection::from_index(sel));
1075        self
1076    }
1077
1078    /// Install an independent cell-selection model on top of row selection.
1079    /// See [`CellSelectionModel`].
1080    pub fn cell_selection(mut self, sel: CellSelectionModel) -> Self {
1081        self.cell_selection = Some(sel);
1082        self
1083    }
1084
1085    /// Paint every other row with a tinted background. Default: off.
1086    pub fn alternating_rows(mut self, enabled: bool) -> Self {
1087        self.alternating_rows = enabled;
1088        self
1089    }
1090
1091    /// Draw horizontal and/or vertical grid lines between cells.
1092    /// See [`GridLines`].
1093    pub fn grid_lines(mut self, kind: GridLines) -> Self {
1094        self.grid_lines = kind;
1095        self
1096    }
1097
1098    /// Let the **last column in display order** take up whatever width the
1099    /// other columns leave, so the table never ends in a bare strip at its
1100    /// trailing edge — Qt's `stretchLastSection`, NSTableView's
1101    /// `lastColumnOnlyAutoresizingStyle`. Default: off.
1102    ///
1103    /// Positional, not a property of a column: after a reorder it is the
1104    /// *new* last column that stretches and the previous one goes back to
1105    /// its own width. While a column stretches, its declared width is the
1106    /// floor it grows from, its user-resize override is ignored, and its
1107    /// trailing grip is disabled (no AccessKit Increment/Decrement either):
1108    /// any size the user gave it, the stretch would take straight back.
1109    /// Resizing any *other* column reflows it. Once the other columns
1110    /// exceed the viewport there is nothing left to stretch into — the
1111    /// last column sits at its own width and the pane scrolls, as in Qt.
1112    ///
1113    /// `Flex` columns already share every spare pixel among themselves, so
1114    /// a table of `Flex` columns looks the same either way: this is for
1115    /// pixel-sized tables (`Fixed` / `Auto`, or widths the user has set)
1116    /// that would otherwise end in a gap.
1117    pub fn stretch_last_column(mut self, on: bool) -> Self {
1118        self.stretch_last_column = on;
1119        self
1120    }
1121
1122    /// Provide an accessible label for the table (`aria-label`). Required
1123    /// when the page hosts more than one table so screen readers can
1124    /// distinguish them.
1125    pub fn a11y_label(mut self, label: impl Into<LocalizedString>) -> Self {
1126        self.a11y_label = Some(label.into());
1127        self
1128    }
1129
1130    /// Show or hide the built-in vertical and horizontal scroll bars. Default:
1131    /// visible. Set to `false` when an external scroll bar is wired to
1132    /// [`scroll_y_signal`](Self::scroll_y_signal).
1133    pub fn show_internal_scrollbars(mut self, show: bool) -> Self {
1134        self.show_internal_scrollbars = show;
1135        self
1136    }
1137
1138    /// Widget shown when the source is empty.
1139    pub fn empty_view(mut self, f: impl Fn() -> Box<dyn Widget> + 'static) -> Self {
1140        self.empty_view = Some(Rc::new(f));
1141        self
1142    }
1143
1144    // ── Adopted column state ───────────────────────────────────────────
1145
1146    /// Use `sort` as this table's sort state instead of a signal of its own:
1147    /// header clicks and [`set_sort`](Self::set_sort) write it, and a write
1148    /// from anywhere else updates the header. [`sort_signal`](Self::sort_signal)
1149    /// returns it. Bind the same signal to a
1150    /// [`SortFilterListModel`](teksilo_data::SortFilterListModel) to re-sort
1151    /// the rows.
1152    ///
1153    /// The table writes nothing into it until the user sorts; see the module
1154    /// docs, "Column state an application owns", for the full contract.
1155    pub fn bind_sort(mut self, sort: Signal<Option<(String, SortDirection)>>) -> Self {
1156        self.sort_signal = sort;
1157        self
1158    }
1159
1160    /// Use `widths` as this table's map of column id → width instead of a
1161    /// signal of its own: a resize drag and
1162    /// [`set_column_width`](Self::set_column_width) write it, and a write from
1163    /// anywhere else resizes the columns. A column with no entry takes its
1164    /// declared [`ColumnWidth`]; an entry for a column the table lacks is
1165    /// ignored. A resize writes the resized column's entry alone; the `Flex`
1166    /// columns before it keep their widths in this table, not in the map.
1167    /// [`column_widths_signal`](Self::column_widths_signal) returns it.
1168    ///
1169    /// See the module docs, "Column state an application owns".
1170    pub fn bind_column_widths(mut self, widths: Signal<HashMap<String, f32>>) -> Self {
1171        self.column_widths_signal = widths;
1172        self.column_widths_frozen = Some(Rc::default());
1173        self
1174    }
1175
1176    /// Use `order` as this table's column order instead of a signal of its
1177    /// own: a reorder drop and [`set_column_order`](Self::set_column_order)
1178    /// write it, and a write from anywhere else reorders the columns. Ids the
1179    /// table lacks are skipped when it lays out and kept in place when it
1180    /// writes. [`column_order_signal`](Self::column_order_signal) returns it.
1181    ///
1182    /// See the module docs, "Column state an application owns".
1183    pub fn bind_column_order(mut self, order: Signal<Vec<String>>) -> Self {
1184        self.column_order_signal = order;
1185        self
1186    }
1187
1188    /// Use `filters` as this table's per-column filter text instead of a
1189    /// signal of its own: the filter popover and
1190    /// [`set_filter`](Self::set_filter) write it.
1191    /// [`filters_signal`](Self::filters_signal) returns it. Bind the same
1192    /// signal to a [`SortFilterListModel`](teksilo_data::SortFilterListModel)
1193    /// to filter the rows.
1194    ///
1195    /// See the module docs, "Column state an application owns".
1196    pub fn bind_filters(mut self, filters: Signal<HashMap<String, String>>) -> Self {
1197        self.filters_signal = filters;
1198        self
1199    }
1200
1201    // ── Public reactive signals ────────────────────────────────────────
1202
1203    /// Current vertical scroll offset in logical pixels.
1204    pub fn scroll_y_signal(&self) -> &Signal<f32> {
1205        &self.scroll_y
1206    }
1207
1208    /// Maximum vertical scroll offset — `total_content_height − viewport_height`.
1209    pub fn max_scroll_y_signal(&self) -> &Signal<f32> {
1210        &self.max_scroll_y
1211    }
1212
1213    /// Viewport-to-content height ratio, used by external scroll bar thumbs.
1214    pub fn viewport_ratio_y_signal(&self) -> &Signal<f32> {
1215        &self.viewport_ratio_y
1216    }
1217
1218    /// Current horizontal scroll offset of the Middle (unpinned) pane, in
1219    /// logical pixels. Leading/Trailing-pinned columns are unaffected —
1220    /// see [`Column::pinned`].
1221    pub fn scroll_x_signal(&self) -> &Signal<f32> {
1222        &self.scroll_x
1223    }
1224
1225    /// Maximum horizontal scroll offset — `middle_content_width −
1226    /// middle_viewport_width`.
1227    pub fn max_scroll_x_signal(&self) -> &Signal<f32> {
1228        &self.max_scroll_x
1229    }
1230
1231    /// Middle-pane viewport-to-content width ratio, used by external
1232    /// horizontal scroll bar thumbs.
1233    pub fn viewport_ratio_x_signal(&self) -> &Signal<f32> {
1234        &self.viewport_ratio_x
1235    }
1236
1237    /// Active sort: `Some((col_id, dir))` or `None` when unsorted.
1238    /// Mutated by header clicks (cycle: None → Asc → Desc → None) and by
1239    /// [`set_sort`](Self::set_sort) / [`clear_sort`](Self::clear_sort). The
1240    /// signal adopted by [`bind_sort`](Self::bind_sort), if any.
1241    /// Bind a [`SortFilterListModel`](teksilo_data::SortFilterListModel) to
1242    /// drive a re-sort of the underlying data:
1243    ///
1244    /// ```ignore
1245    /// let proxy = SortFilterListModel::new(model)
1246    ///     .with_comparator("name", |a, b| a.name.cmp(&b.name));
1247    /// proxy.sort_signal(table.sort_signal().clone());
1248    /// ```
1249    pub fn sort_signal(&self) -> &Signal<Option<(String, SortDirection)>> {
1250        &self.sort_signal
1251    }
1252
1253    /// Map of column id → user-overridden width. The table writes entries
1254    /// only for a resize: the resized column's, and — when the map is its
1255    /// own — one for each `Flex` column before it, frozen at the width it
1256    /// had. Missing keys mean "use the declared width policy". The signal
1257    /// adopted by [`bind_column_widths`](Self::bind_column_widths), if any.
1258    pub fn column_widths_signal(&self) -> &Signal<HashMap<String, f32>> {
1259        &self.column_widths_signal
1260    }
1261
1262    /// Column ids in display order. Updated when the user drags a
1263    /// header to reorder, or imperatively via
1264    /// [`set_column_order`](Self::set_column_order). When empty, the
1265    /// declared order applies. Pinned-side groups (Leading / None /
1266    /// Trailing) are *always* honored — the entries inside this signal
1267    /// only re-sort within each group. The signal adopted by
1268    /// [`bind_column_order`](Self::bind_column_order), if any.
1269    pub fn column_order_signal(&self) -> &Signal<Vec<String>> {
1270        &self.column_order_signal
1271    }
1272
1273    /// Per-id pinning override map. A key here puts the column on that side,
1274    /// [`PinnedSide::None`] unpinning a column declared pinned; missing keys
1275    /// fall back to the declared `Column::pinned`. Updated when the user
1276    /// drags a column across a pane boundary.
1277    pub fn column_pinning_signal(&self) -> &Signal<HashMap<String, PinnedSide>> {
1278        &self.column_pinning_signal
1279    }
1280
1281    /// Currently keyboard-focused cell, as `(row_index, display_col)`,
1282    /// or `None` when no cell is focused. Mutated by the keyboard
1283    /// handler (Arrow keys / Tab / Home / End / PgUp / PgDn /
1284    /// Ctrl-Home / Ctrl-End / Escape), by direct
1285    /// [`set_focused_cell`](Self::set_focused_cell) /
1286    /// [`clear_focused_cell`](Self::clear_focused_cell) calls, and — in a
1287    /// table whose selection holds one row or cell — by any selection change
1288    /// that leaves an existing cursor off the selection, which moves the
1289    /// cursor onto it.
1290    pub fn focused_cell_signal(&self) -> &Signal<Option<(usize, usize)>> {
1291        &self.focused_cell
1292    }
1293
1294    /// Move the focused cell. Out-of-range values are silently clamped
1295    /// when the next layout runs.
1296    pub fn set_focused_cell(&self, row: usize, col: usize) {
1297        self.focused_cell.set(Some((row, col)));
1298    }
1299
1300    /// Remove keyboard focus from any cell (equivalent to pressing Escape).
1301    pub fn clear_focused_cell(&self) {
1302        self.focused_cell.set(None);
1303    }
1304
1305    /// Cell currently in edit mode, or `None` when no editor is open.
1306    /// Cell delegates inspect this via `CellContext::is_editing` and
1307    /// swap in an editor widget when matched.
1308    pub fn editing_cell_signal(&self) -> &Signal<Option<(usize, usize)>> {
1309        &self.editing_cell
1310    }
1311
1312    /// Begin editing the cell `(row, col_id)`. Silently no-ops if `col_id`
1313    /// isn't a currently-displayed column, or if `row` is outside the visible
1314    /// range — an out-of-range target would otherwise strand `editing_cell` on
1315    /// a row nothing can match.
1316    ///
1317    /// Callable **before the view is mounted**, which is the only point at
1318    /// which a consumer can seed a freshly constructed view with an edit
1319    /// target it already holds. `display_indices` is a cache `build()` fills,
1320    /// so a pre-mount call finds it empty; the order is recomputed on demand
1321    /// in that case rather than resolving against nothing and no-opping for a
1322    /// third, undocumented reason.
1323    pub fn begin_edit(&self, row: usize, col_id: &str) {
1324        let cached = self.display_indices.borrow();
1325        let recomputed;
1326        let display: &[usize] = if cached.is_empty() {
1327            recomputed = self.display_order();
1328            &recomputed
1329        } else {
1330            &cached
1331        };
1332        if let Some(target) =
1333            imperative::resolve_edit_target(row, col_id, &self.columns, display, (self.len_fn)())
1334        {
1335            drop(cached);
1336            self.editing_cell.set(Some(target));
1337        }
1338    }
1339
1340    /// Close the active cell editor without committing (the field's `on_blur` still fires).
1341    pub fn end_edit(&self) {
1342        self.editing_cell.set(None);
1343    }
1344
1345    /// Per-column filter text. Updated by filter affordances in
1346    /// header cells and by
1347    /// [`set_filter`](Self::set_filter) / [`clear_filters`](Self::clear_filters).
1348    /// The signal adopted by [`bind_filters`](Self::bind_filters), if any.
1349    /// Bind a `SortFilterListModel<T>` to drive the upstream data:
1350    ///
1351    /// ```ignore
1352    /// let proxy = SortFilterListModel::new(model)
1353    ///     .with_predicate("name", |t| {
1354    ///         let needle = t.to_string();
1355    ///         Box::new(move |r: &Row| r.name.contains(&needle))
1356    ///     });
1357    /// proxy.filters_signal(table.filters_signal().clone());
1358    /// ```
1359    pub fn filters_signal(&self) -> &Signal<HashMap<String, String>> {
1360        &self.filters_signal
1361    }
1362
1363    /// Set or clear the filter text for a single column. An empty `text` removes
1364    /// the entry for `col_id` (same as clearing the filter for that column).
1365    pub fn set_filter(&self, col_id: &str, text: &str) {
1366        imperative::set_filter(&self.filters_signal, col_id, text);
1367    }
1368
1369    /// Remove all active column filters.
1370    pub fn clear_filters(&self) {
1371        imperative::set_if_changed(&self.filters_signal, HashMap::new());
1372    }
1373
1374    // ── Imperative API ─────────────────────────────────────────────────
1375
1376    /// Scroll so that `row` is aligned to the top of the viewport, now,
1377    /// against the row offsets and scroll range of the last layout pass.
1378    ///
1379    /// The offset is clamped when this is called, to the range that pass
1380    /// wrote. Before the table has been laid out that range is `0`, so the
1381    /// call scrolls to the top and nothing more; after a model change in the
1382    /// same turn the range still describes the old rows. On a mounted table,
1383    /// or to scroll before the first layout, use a [`handle`](Self::handle),
1384    /// which defers the computation to the next layout pass.
1385    pub fn scroll_to_row(&self, row: usize) {
1386        imperative::scroll_to_row(row, &self.row_metrics, &self.scroll_y, &self.max_scroll_y);
1387    }
1388
1389    /// Set the active sort imperatively. Equivalent to writing to
1390    /// [`sort_signal`](Self::sort_signal) directly, except that an unchanged
1391    /// value neither writes nor notifies — see
1392    /// [`set_column_widths`](Self::set_column_widths).
1393    pub fn set_sort(&self, col_id: Option<&str>, dir: SortDirection) {
1394        let next = col_id.map(|c| (c.to_string(), dir));
1395        imperative::set_if_changed(&self.sort_signal, next);
1396    }
1397
1398    /// Clear the active sort.
1399    pub fn clear_sort(&self) {
1400        imperative::set_if_changed(&self.sort_signal, None);
1401    }
1402
1403    /// Set or remove a single column's user-resized width override.
1404    /// A non-positive `width` removes the entry (the column reverts to
1405    /// its declared width policy).
1406    pub fn set_column_width(&self, col_id: &str, width: f32) {
1407        imperative::set_column_width(&self.column_widths_signal, col_id, width);
1408    }
1409
1410    /// Replace the full width-override map (typically used to restore
1411    /// a persisted layout).
1412    ///
1413    /// A no-op when the map is unchanged, so the documented
1414    /// signal round trip (see docs/table-view.md, "Persistence", which shows
1415    /// it for the column order) terminates instead of recursing: `Signal::set` has no equality check of
1416    /// its own, and a live resize writes a width on every pointer move.
1417    pub fn set_column_widths(&self, widths: HashMap<String, f32>) {
1418        imperative::set_column_widths(&self.column_widths_signal, widths);
1419    }
1420
1421    /// Replace the column-order list. Ids not declared on this table
1422    /// are silently dropped on the next layout pass.
1423    pub fn set_column_order(&self, order: Vec<String>) {
1424        imperative::set_if_changed(&self.column_order_signal, order);
1425    }
1426
1427    /// Put a single column on `side`: [`PinnedSide::Leading`] or
1428    /// [`PinnedSide::Trailing`] pins it, [`PinnedSide::None`] unpins it, a
1429    /// column declared pinned ([`Column::pinned`]) included.
1430    ///
1431    /// Writes [`column_pinning_signal`](Self::column_pinning_signal) the way a
1432    /// header drag does: an entry for a column moved off its declared side,
1433    /// and none for a column put back on it.
1434    pub fn set_column_pinning(&self, col_id: &str, side: PinnedSide) {
1435        let declared = self
1436            .columns
1437            .iter()
1438            .find(|c| c.spec.id == col_id)
1439            .map_or(PinnedSide::None, |c| c.spec.pinned);
1440        imperative::set_column_pinning(&self.column_pinning_signal, col_id, side, declared);
1441    }
1442
1443    /// Compute the visible column display order: a flat list of indices
1444    /// into `self.columns`. Columns are partitioned by effective
1445    /// pinning (Leading first, then None, then Trailing); within each
1446    /// pane they appear in `column_order_signal` order, with any
1447    /// columns missing from the signal appended in declaration order.
1448    /// See [`layout::display_order`].
1449    fn display_order(&self) -> Vec<usize> {
1450        let (out, boundaries) = layout::display_order(
1451            &self.columns,
1452            &self.column_order_signal.get(),
1453            &self.column_pinning_signal.get(),
1454        );
1455        // Stash the boundaries so paint / drop-zone math can read them.
1456        *self.pane_boundaries.borrow_mut() = boundaries;
1457        out
1458    }
1459
1460    /// Scroll the minimum distance needed to make `row` visible, now, against
1461    /// the viewport, row offsets and scroll range of the last layout pass. A
1462    /// no-op before the first layout pass, when the viewport height is not yet
1463    /// known. A [`handle`](Self::handle) defers the computation to the next
1464    /// layout pass instead.
1465    pub fn ensure_row_visible(&self, row: usize) {
1466        imperative::ensure_row_visible(
1467            row,
1468            &self.row_metrics,
1469            &self.scroll_y,
1470            &self.max_scroll_y,
1471            self.viewport_height.get(),
1472            self.laid_out.get(),
1473        );
1474    }
1475
1476    // ── Internals ──────────────────────────────────────────────────────
1477
1478    /// Scroll the row the keyboard cursor is on into view when this table
1479    /// takes focus.
1480    ///
1481    /// Only the rows near the viewport are realized, so on a table taller than
1482    /// the window the cursor's row frequently has no widget. Everything that
1483    /// speaks for it then has nothing to speak about: no row node carries
1484    /// `selected`, the `cell_map` lookup in `accessibility()` finds nothing so
1485    /// no `active_descendant` is nominated, and a screen reader taking focus
1486    /// here is told nothing at all. Worse, the first arrow press steps *past*
1487    /// that row, because the cursor was somewhere the user was never shown.
1488    ///
1489    /// The row resolves the way `widget_impl`'s `context_menu_key_target` resolves it:
1490    /// the focused cell's row if the user has navigated, else the first
1491    /// selected row. Both are "the row this table is currently about", and a
1492    /// session restored into a selection has no focused cell yet.
1493    ///
1494    /// **Vertical only, and that is the whole of it here.** The body pane
1495    /// realizes every display column of a realized row, iterating the full
1496    /// `display_indices` with no `scroll_x` culling
1497    /// (`table_view/body_pane.rs:313-443`), so the cell is in `cell_map`
1498    /// whatever the horizontal offset is. The row is the only axis that can
1499    /// hide it from the AT tree. A sighted keyboard user can still land with
1500    /// the cursor's column scrolled off to the side, which would want
1501    /// `ensure_col_visible` (`table_view/keyboard.rs:710`); that is a private
1502    /// helper of the key-handler module and out of this change's reach.
1503    ///
1504    /// Ensure-visible rather than scroll-to: a row already on screen must not
1505    /// jump under somebody who can see it.
1506    ///
1507    /// The handles are cloned into the effect rather than reaching through
1508    /// `self`, which the closure cannot borrow.
1509    fn reveal_current_row_on_focus(&self, ctx: &mut teksilo_core::build_context::BuildContext) {
1510        let row_metrics = self.row_metrics.clone();
1511        let scroll_y = self.scroll_y.clone();
1512        let max_scroll_y = self.max_scroll_y.clone();
1513        let viewport_height = self.viewport_height.clone();
1514        let laid_out = self.laid_out.clone();
1515        let focused_cell = self.focused_cell.clone();
1516        let selection = self.row_selection.clone();
1517
1518        ctx.effect(&self.view_focused, move |focused| {
1519            if !*focused {
1520                return;
1521            }
1522            let Some(row) = focused_cell.get().map(|(row, _col)| row).or_else(|| {
1523                selection
1524                    .as_ref()
1525                    .and_then(|s| s.selected_indices().first().copied())
1526            }) else {
1527                return;
1528            };
1529            imperative::ensure_row_visible(
1530                row,
1531                &row_metrics,
1532                &scroll_y,
1533                &max_scroll_y,
1534                viewport_height.get(),
1535                laid_out.get(),
1536            );
1537        });
1538    }
1539
1540    /// The configured row height (override) or the active table style's
1541    /// (see `adopt_style_heights`). In the non-uniform modes this is only the
1542    /// scroll step; real geometry lives in `row_metrics`.
1543    fn effective_row_height(&self) -> f32 {
1544        self.row_height.unwrap_or(self.style_row_height)
1545    }
1546
1547    fn effective_header_height(&self) -> f32 {
1548        if !self.show_header {
1549            0.0
1550        } else {
1551            self.header_height.unwrap_or(self.style_header_height)
1552        }
1553    }
1554
1555    fn total_content_height(&self) -> f32 {
1556        self.row_metrics.borrow_mut().total_height((self.len_fn)())
1557    }
1558
1559    fn visible_range(&self) -> (usize, usize) {
1560        self.row_metrics.borrow_mut().visible_range(
1561            self.scroll_y.get(),
1562            self.viewport_height.get(),
1563            (self.len_fn)(),
1564            BUFFER_ROWS,
1565        )
1566    }
1567
1568    fn clamp_scroll(&self) {
1569        let max = self.max_scroll_y.get();
1570        let current = self.scroll_y.get();
1571        let clamped = current.clamp(0.0, max);
1572        if (clamped - current).abs() > 0.001 {
1573            self.scroll_y.set(clamped);
1574        }
1575    }
1576}
1577
1578impl<T: 'static> TableView<T> {
1579    /// A handle that scrolls this table once it is mounted.
1580    ///
1581    /// Take it while describing the tree, before the table is handed to
1582    /// `ctx.add(...)`; it stays attached to the table across rebuilds. See
1583    /// [`TableViewHandle`].
1584    pub fn handle(&self) -> TableViewHandle {
1585        TableViewHandle {
1586            reveal: self.reveal.clone(),
1587        }
1588    }
1589}
1590
1591/// A handle on a mounted [`TableView`], to scroll it to a row from outside
1592/// the widget — a view-model reacting to a search hit, a "go to row" command.
1593///
1594/// Cheap to clone, valid before the table is mounted, and it survives the
1595/// table's rebuilds. Its methods never compute an offset when they are called:
1596/// they record the request and ask for a layout pass, and the table applies it
1597/// there, after row measurement and scroll anchoring have settled. So a model
1598/// change made in the same turn (rows inserted above, say) is accounted for, a
1599/// request made before mount is applied at the first layout, and a later
1600/// request replaces an earlier pending one. A request on a table with no rows
1601/// is dropped; a row past the end reveals the last row.
1602///
1603/// ```rust
1604/// # use teksilo_data::ListModel;
1605/// # use teksilo_widgets::TableView;
1606/// # let model: ListModel<String> = ListModel::new();
1607/// let table = TableView::new(model);
1608/// let handle = table.handle();
1609/// // … add the columns, `ctx.add(table)`, then later, from any handler:
1610/// handle.ensure_row_visible(1_250);
1611/// ```
1612#[derive(Clone)]
1613pub struct TableViewHandle {
1614    reveal: SharedRowReveal,
1615}
1616
1617impl std::fmt::Debug for TableViewHandle {
1618    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1619        f.debug_struct("TableViewHandle")
1620            .field("pending", &self.reveal.pending())
1621            .finish()
1622    }
1623}
1624
1625impl TableViewHandle {
1626    /// Scroll so that `row` is aligned to the top of the viewport, as far as
1627    /// the scroll range allows, at the next layout pass.
1628    pub fn scroll_to_row(&self, row: usize) {
1629        self.reveal.request(row, RevealAlign::Top);
1630    }
1631
1632    /// Scroll the minimum distance that makes `row` fully visible, at the
1633    /// next layout pass; no scroll when it already is.
1634    pub fn ensure_row_visible(&self, row: usize) {
1635        self.reveal.request(row, RevealAlign::Nearest);
1636    }
1637}
1638
1639impl<T: 'static> std::fmt::Debug for TableView<T> {
1640    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1641        f.debug_struct("TableView")
1642            .field("rows", &(self.len_fn)())
1643            .field("columns", &self.columns.len())
1644            .field("scroll_y", &self.scroll_y.get())
1645            .field("selection_mode", &self.selection_mode)
1646            .field("scroll_bar_style", &self.scroll_bar_style)
1647            .finish()
1648    }
1649}
1650
1651/// Draw the internal vertical grid-line dividers for one pane band —
1652/// `slice.len() - 1` lines between adjacent columns, clipped to `rect` so a
1653/// scrolled Middle-pane line can't bleed past its own viewport into a
1654/// pinned neighbour. `scroll` is nonzero only for the Middle pane.
1655///
1656/// Shared by `TableView`/`TreeTableView`'s `paint()`, which are otherwise
1657/// near-identical for this decoration.
1658#[allow(clippy::too_many_arguments)]
1659pub(crate) fn draw_pane_dividers(
1660    canvas: &mut Canvas,
1661    rect: Rect,
1662    slice: &[f32],
1663    scroll: f32,
1664    rtl: bool,
1665    color: teksilo_tokens::Color,
1666    line_w: f32,
1667) {
1668    if slice.len() < 2 || rect.width <= 0.0 {
1669        return;
1670    }
1671    canvas.set_clip(rect);
1672    if rtl {
1673        let mut x = rect.right() + scroll;
1674        for &w in &slice[..slice.len() - 1] {
1675            x -= w;
1676            canvas.fill_rect(Rect::new(x, rect.y, line_w, rect.height), color);
1677        }
1678    } else {
1679        let mut x = rect.x - scroll;
1680        for &w in &slice[..slice.len() - 1] {
1681            x += w;
1682            canvas.fill_rect(Rect::new(x - line_w, rect.y, line_w, rect.height), color);
1683        }
1684    }
1685    canvas.clear_clip();
1686}
1687
1688// The header strip — its cells, their resize / sort / reorder gestures and the
1689// reorder drop target — is `TableHeader` (`table_header.rs`), which this view
1690// and `TreeTableView` both compose.