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 /// The configured row height (override) or the table style's 28 px
1267 /// fallback. In the non-uniform modes this is the seed estimate;
1268 /// real geometry lives in `row_metrics`.
1269 fn effective_row_height(&self) -> f32 {
1270 self.row_height.unwrap_or(cp::ROW_HEIGHT)
1271 }
1272
1273 fn effective_header_height(&self) -> f32 {
1274 if !self.show_header {
1275 0.0
1276 } else {
1277 self.header_height.unwrap_or(cp::HEADER_HEIGHT)
1278 }
1279 }
1280
1281 fn total_content_height(&self) -> f32 {
1282 self.row_metrics.borrow_mut().total_height((self.len_fn)())
1283 }
1284
1285 fn visible_range(&self) -> (usize, usize) {
1286 self.row_metrics.borrow_mut().visible_range(
1287 self.scroll_y.get(),
1288 self.viewport_height.get(),
1289 (self.len_fn)(),
1290 BUFFER_ROWS,
1291 )
1292 }
1293
1294 fn clamp_scroll(&self) {
1295 let max = self.max_scroll_y.get();
1296 let current = self.scroll_y.get();
1297 let clamped = current.clamp(0.0, max);
1298 if (clamped - current).abs() > 0.001 {
1299 self.scroll_y.set(clamped);
1300 }
1301 }
1302}
1303
1304impl<T: 'static> std::fmt::Debug for TableView<T> {
1305 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1306 f.debug_struct("TableView")
1307 .field("rows", &(self.len_fn)())
1308 .field("columns", &self.columns.len())
1309 .field("scroll_y", &self.scroll_y.get())
1310 .field("selection_mode", &self.selection_mode)
1311 .field("scroll_bar_style", &self.scroll_bar_style)
1312 .finish()
1313 }
1314}
1315
1316impl<T: 'static> Widget for TableView<T> {
1317 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1318 let self_id = ctx.self_id();
1319 ctx.enabled_when(self_id, self.enabled.clone());
1320
1321 let row_h = self.effective_row_height();
1322 let header_h = self.effective_header_height();
1323
1324 // Version signal — bumps drive a rebuild.
1325 let version = ctx.signal(0_u64);
1326 version.bind_to(ctx.self_id(), ctx.binding_registry(), BindingLevel::Rebuild);
1327
1328 // Scroll-y at Relayout: place_children re-runs without rebuild.
1329 self.scroll_y.bind_to(
1330 ctx.self_id(),
1331 ctx.binding_registry(),
1332 BindingLevel::Relayout,
1333 );
1334 ctx.register_animated_signal(&self.scroll_y);
1335
1336 // Scroll-x mirrors scroll-y: Relayout re-places the header + body
1337 // bands (and any pane-aware root decorations) without a rebuild.
1338 self.scroll_x.bind_to(
1339 ctx.self_id(),
1340 ctx.binding_registry(),
1341 BindingLevel::Relayout,
1342 );
1343 ctx.register_animated_signal(&self.scroll_x);
1344
1345 // Row-drop insertion indicator at RepaintOnly so on_drag_hover /
1346 // on_drag_leave `set(...)` calls dirty paint without a rebuild.
1347 self.drop_feedback.bind_to(
1348 ctx.self_id(),
1349 ctx.binding_registry(),
1350 BindingLevel::RepaintOnly,
1351 );
1352
1353 // Pane → root total refresh (auto-measure mode): re-place this
1354 // root when the body pane's measurements changed the content
1355 // total, so `max_scroll_y` / the thumb ratio pick up the
1356 // corrected value.
1357 self.pane_total_refresh.bind_to(
1358 ctx.self_id(),
1359 ctx.binding_registry(),
1360 BindingLevel::Relayout,
1361 );
1362
1363 // Column width overrides: any change re-runs place_children
1364 // (which calls ColumnSolver with the latest map). No rebuild
1365 // needed — widths flow through `column_widths` Rc into rows.
1366 self.column_widths_signal.bind_to(
1367 ctx.self_id(),
1368 ctx.binding_registry(),
1369 BindingLevel::Relayout,
1370 );
1371
1372 // `OnRelease` resize guide line — paint-only, nothing moves until the
1373 // button comes up.
1374 self.resize_preview_x.bind_to(
1375 ctx.self_id(),
1376 ctx.binding_registry(),
1377 BindingLevel::RepaintOnly,
1378 );
1379
1380 // A resize drag that loses the window never gets its PointerUp: the
1381 // user Alt-Tabs (or a native dialog steals focus) with the button
1382 // down, releases it over another window, and the OS delivers the Up
1383 // nowhere. Abandon the gesture on deactivation, or the state outlives
1384 // it and the next bare PointerMove drags the column with no button
1385 // held. Nothing is committed — an interrupted drag leaves the column
1386 // wherever the last delivered move put it, which is what the user last
1387 // saw.
1388 {
1389 let resize_state = self.resize_state.clone();
1390 let resize_target = self.resize_target.clone();
1391 let resize_preview_x = self.resize_preview_x.clone();
1392 ctx.effect(&ctx.window_active_signal(), move |active| {
1393 if !*active && resize_state.borrow().is_some() {
1394 *resize_state.borrow_mut() = None;
1395 resize_target.set(None);
1396 resize_preview_x.set(None);
1397 }
1398 });
1399 }
1400
1401 // Column order + pinning: changes require a rebuild because the
1402 // header cells and row cells must be re-emitted in the new order
1403 // (each cell captures its display-position-based 1-based index).
1404 let v_for_order = version.clone();
1405 let order_ver = Rc::new(Cell::new(0_u64));
1406 ctx.effect(&self.column_order_signal, move |_| {
1407 let next = order_ver.get() + 1;
1408 order_ver.set(next);
1409 v_for_order.set(next);
1410 });
1411 let v_for_pin = version.clone();
1412 let pin_ver = Rc::new(Cell::new(0_u64));
1413 ctx.effect(&self.column_pinning_signal, move |_| {
1414 let next = pin_ver.get() + 1;
1415 pin_ver.set(next);
1416 v_for_pin.set(next);
1417 });
1418 let v_for_edit = version.clone();
1419 let edit_ver = Rc::new(Cell::new(0_u64));
1420 ctx.effect(&self.editing_cell, move |_| {
1421 let next = edit_ver.get() + 1;
1422 edit_ver.set(next);
1423 v_for_edit.set(next);
1424 });
1425 let v_for_filter = version.clone();
1426 let filter_ver = Rc::new(Cell::new(0_u64));
1427 ctx.effect(&self.filters_signal, move |_| {
1428 let next = filter_ver.get() + 1;
1429 filter_ver.set(next);
1430 v_for_filter.set(next);
1431 });
1432
1433 // Sort signal: a change requires a rebuild because each header
1434 // cell's chevron child is added/removed conditionally and the
1435 // AccessKit `set_sort_direction` is captured at build time.
1436 let v_for_sort = version.clone();
1437 let sort_ver = Rc::new(Cell::new(0_u64));
1438 ctx.effect(&self.sort_signal, move |_| {
1439 let next = sort_ver.get() + 1;
1440 sort_ver.set(next);
1441 v_for_sort.set(next);
1442 });
1443
1444 // Observe model changes -> bump version.
1445 let v_for_data = version.clone();
1446 let data_ver = Rc::new(Cell::new(0_u64));
1447 let upstream = (self.observe_fn)(Box::new({
1448 let dv = data_ver.clone();
1449 let sel_for_adjust = self.row_selection.clone();
1450 let cell_sel_for_adjust = self.cell_selection.clone();
1451 let metrics_for_data = self.row_metrics.clone();
1452 let len_for_data = self.len_fn.clone();
1453 let first_changed = self.first_changed_fn.clone();
1454 move |change| {
1455 // Keep row metrics in step with the data: rows before
1456 // the first changed index keep their heights, the rest
1457 // re-derive. A `SortFilterListModel` source collapses
1458 // everything to `Reset` — its real divergence comes
1459 // through the side-channel, which is what lets an
1460 // append keep the measured prefix.
1461 let divergence = match change {
1462 DataChange::ItemsInserted { range } | DataChange::ItemsRemoved { range } => {
1463 Some(range.start)
1464 }
1465 DataChange::ItemUpdated { index } => Some(*index),
1466 DataChange::ItemsMoved { from, to, .. } => Some((*from).min(*to)),
1467 DataChange::WindowLoaded { range } => Some(range.start),
1468 DataChange::Reset => (first_changed)(),
1469 };
1470 metrics_for_data
1471 .borrow_mut()
1472 .apply_divergence(divergence, (len_for_data)());
1473 // Keep row selection in step: index-shift (index model) or
1474 // prune orphaned keys (keyed model). Cell selection (always
1475 // index-based) is adjusted separately below.
1476 if let Some(ref rs) = sel_for_adjust {
1477 rs.on_data_change(change);
1478 }
1479 if let Some(ref s) = cell_sel_for_adjust {
1480 match change {
1481 DataChange::ItemsInserted { range } => {
1482 s.adjust_for_row_insert(range.start, range.end - range.start);
1483 }
1484 DataChange::ItemsRemoved { range } => {
1485 s.adjust_for_row_remove(range.start, range.end - range.start);
1486 }
1487 DataChange::ItemsMoved { from, to, count } => {
1488 s.adjust_for_row_move(*from, *to, *count);
1489 }
1490 DataChange::Reset => s.clear(),
1491 _ => {}
1492 }
1493 }
1494 let next = dv.get() + 1;
1495 dv.set(next);
1496 v_for_data.set(next);
1497 }
1498 }));
1499 ctx.own_handle(upstream);
1500
1501 // Observe selection changes -> bump version (rebuild updates the
1502 // `is_selected` arg passed to cell delegates).
1503 if let Some(ref rs) = self.row_selection {
1504 let v_for_sel = version.clone();
1505 let sel_ver = Rc::new(Cell::new(0_u64));
1506 let handle = rs.observe_for_rebuild(move || {
1507 let next = sel_ver.get() + 1;
1508 sel_ver.set(next);
1509 v_for_sel.set(next);
1510 });
1511 ctx.own_handle(handle);
1512 }
1513 if let Some(ref cs) = self.cell_selection {
1514 let v_for_csel = version.clone();
1515 let csel_ver = Rc::new(Cell::new(0_u64));
1516 ctx.effect(&cs.selection_signal(), move |_| {
1517 let next = csel_ver.get() + 1;
1518 csel_ver.set(next);
1519 v_for_csel.set(next);
1520 });
1521 }
1522
1523 // Observe scroll position — only rebuild when visible range exits
1524 // the buffered window. The Relayout binding above handles
1525 // intra-buffer scrolls without a rebuild.
1526 let vp_h = self.viewport_height.clone();
1527 let len_for_scroll = self.len_fn.clone();
1528 let (built_start, built_end) = self.visible_range();
1529 let prev_built_start = Rc::new(Cell::new(built_start));
1530 let prev_built_end = Rc::new(Cell::new(built_end));
1531 let v_for_scroll = version.clone();
1532 let scroll_ver = Rc::new(Cell::new(0_u64));
1533 let scroll_handle = self.scroll_y.observe({
1534 let pbs = prev_built_start.clone();
1535 let pbe = prev_built_end.clone();
1536 let sv = scroll_ver.clone();
1537 let metrics = self.row_metrics.clone();
1538 move |y| {
1539 let count = (len_for_scroll)();
1540 let (visible_start, visible_end) =
1541 metrics.borrow_mut().visible_range(*y, vp_h.get(), count, 0);
1542 if visible_start < pbs.get() || visible_end > pbe.get() {
1543 let new_start = visible_start.saturating_sub(BUFFER_ROWS);
1544 let new_end = (visible_end + BUFFER_ROWS).min(count);
1545 pbs.set(new_start);
1546 pbe.set(new_end);
1547 let next = sv.get() + 1;
1548 sv.set(next);
1549 v_for_scroll.set(next);
1550 }
1551 }
1552 });
1553 ctx.own_handle(scroll_handle);
1554
1555 // Compute display order eagerly — the keyboard handler needs
1556 // the column count, and the header / body builds below also
1557 // need it. We re-write `self.display_indices` here; later
1558 // build steps read it.
1559 let display_indices_now = self.display_order();
1560
1561 // Remap any `(row, display_pos)` pairs the *previous* order left in
1562 // `focused_cell` / `editing_cell` / `cell_selection` onto their
1563 // column's position under the order just computed, before it
1564 // overwrites `self.display_indices` below. A column reorder drag or
1565 // a pin toggle only bumps `version` (see the `column_order_signal` /
1566 // `column_pinning_signal` effects above) — display position is
1567 // recomputed here on every rebuild regardless of cause, so this map
1568 // is the identity (a no-op) unless THIS rebuild's cause was an
1569 // order/pinning change.
1570 {
1571 let old_display = self.display_indices.borrow();
1572 if !old_display.is_empty() {
1573 let old_to_new: Vec<Option<usize>> = old_display
1574 .iter()
1575 .map(|&decl_idx| {
1576 let id = &self.columns[decl_idx].id;
1577 display_indices_now
1578 .iter()
1579 .position(|&new_decl_idx| self.columns[new_decl_idx].id == *id)
1580 })
1581 .collect();
1582 drop(old_display);
1583 imperative::remap_cell_state(
1584 &self.focused_cell,
1585 &self.editing_cell,
1586 self.cell_selection.as_ref(),
1587 &old_to_new,
1588 );
1589 }
1590 }
1591 *self.display_indices.borrow_mut() = display_indices_now.clone();
1592
1593 // Self handlers: scroll wheel + keyboard + clip + focusable.
1594 let scroll_y_for_wheel = self.scroll_y.clone();
1595 let max_scroll_for_wheel = self.max_scroll_y.clone();
1596 let scroll_x_for_wheel = self.scroll_x.clone();
1597 let max_scroll_x_for_wheel = self.max_scroll_x.clone();
1598 let line_height = row_h;
1599 let overscroll_behavior = self.overscroll_behavior;
1600 let smooth_scrolling = self.smooth_scrolling;
1601 let smooth_scroll_duration = self.smooth_scroll_duration;
1602
1603 // Bind focused_cell at RepaintOnly — its update redraws the
1604 // focus ring without rebuilding the row tree. Also at
1605 // AccessibilityOnly (orthogonal — see `BindingLevel`) so a
1606 // keyboard focus move re-walks the AT tree and re-resolves
1607 // `active_descendant` in `accessibility()` below, even though
1608 // nothing about the cell's own node changed.
1609 self.focused_cell.bind_to(
1610 ctx.self_id(),
1611 ctx.binding_registry(),
1612 BindingLevel::RepaintOnly,
1613 );
1614 self.focused_cell.bind_to(
1615 ctx.self_id(),
1616 ctx.binding_registry(),
1617 BindingLevel::AccessibilityOnly,
1618 );
1619
1620 // Focus-aware selection + modality-gated focus ring. `begin_view_focus`
1621 // keys the scope signal on this root id directly — the same id the body
1622 // pane uses for its row scope (`drag_anchor = ctx.self_id()`), and
1623 // independent of the arena focusable flag (not yet wired here). A plain
1624 // `view_focus_active()` here would find no focusable ancestor and fall
1625 // back to the constant-`true` "outside any scope" signal — `true`
1626 // whenever ANY widget holds focus, lighting every table's ring at once.
1627 // The signal is `true` whenever the table or any descendant holds focus,
1628 // so the selection band dims to `SelectedInactive` on focus-out. Pop
1629 // straight back; the body pane re-pushes the same cached signal.
1630 // `focus_visible` gates the cell ring to keyboard navigation. Both bound
1631 // `RepaintOnly`: a focus/modality change redraws without a rebuild.
1632 self.view_focused = ctx.begin_view_focus();
1633 ctx.end_view_focus();
1634 self.focus_visible = ctx.focus_visible();
1635 self.view_focused.bind_to(
1636 ctx.self_id(),
1637 ctx.binding_registry(),
1638 BindingLevel::RepaintOnly,
1639 );
1640 self.focus_visible.bind_to(
1641 ctx.self_id(),
1642 ctx.binding_registry(),
1643 BindingLevel::RepaintOnly,
1644 );
1645
1646 // Build the navigator + key handler. The keyboard module is
1647 // generic over RowNavigator so TreeTableView can plug in its own
1648 // tree-aware navigator.
1649 let navigator: Rc<dyn row_navigator::RowNavigator> =
1650 Rc::new(row_navigator::FlatNavigator::new(self.len_fn.clone()));
1651 // display_col_to_id resolves a display position back to its
1652 // column id, so the keyboard module doesn't need a `Column<T>`
1653 // reference. Snapshotted at build; rebuilds re-issue this.
1654 let column_ids_in_display_order: Vec<String> = display_indices_now
1655 .iter()
1656 .map(|&i| self.columns[i].id.clone())
1657 .collect();
1658 let display_col_to_id: Rc<dyn Fn(usize) -> Option<String>> = {
1659 let ids = column_ids_in_display_order;
1660 Rc::new(move |pos| ids.get(pos).cloned())
1661 };
1662 // The effective trigger set per display column: the view's, overridden
1663 // by the column's own, and `NONE` for a non-editable one. Resolved here
1664 // so the keyboard handler never has to reach a `Column<T>`.
1665 let display_col_triggers: Rc<dyn Fn(usize) -> EditTriggers> = {
1666 let view_triggers = self.edit_triggers;
1667 let per_display_column: Vec<EditTriggers> = display_indices_now
1668 .iter()
1669 .map(|&i| self.columns[i].effective_edit_triggers(view_triggers))
1670 .collect();
1671 Rc::new(move |pos| {
1672 per_display_column
1673 .get(pos)
1674 .copied()
1675 .unwrap_or(EditTriggers::NONE)
1676 })
1677 };
1678
1679 // Type-ahead label resolver (row -> Some(text)) built from the user's
1680 // `Fn(&T) -> String` + the side-effect source read: the closure only
1681 // fires for a resident row, so unloaded (lazy) rows resolve to `None`
1682 // and the search skips them.
1683 let type_ahead_label: Option<Rc<dyn Fn(usize) -> Option<String>>> =
1684 self.type_ahead_label.clone().map(|user| {
1685 let with_item = self.with_item_fn.clone();
1686 Rc::new(move |i: usize| {
1687 let out = std::cell::RefCell::new(None);
1688 (with_item)(i, &|item| {
1689 *out.borrow_mut() = Some(user(item));
1690 });
1691 out.into_inner()
1692 }) as Rc<dyn Fn(usize) -> Option<String>>
1693 });
1694
1695 let key_cfg = keyboard::KeyHandlerConfig {
1696 navigator,
1697 col_count: display_indices_now.len().max(1),
1698 // Flat table: no tree column exists. `FlatNavigator` reports no
1699 // children and never expands, so this value is inert — it only has
1700 // to be a position the cursor can actually occupy.
1701 tree_column_display_pos: 0,
1702 focused_cell: self.focused_cell.clone(),
1703 selection_mode: self.selection_mode,
1704 selection: self.row_selection.clone(),
1705 cell_selection: self.cell_selection.clone(),
1706 scroll_y: self.scroll_y.clone(),
1707 max_scroll_y: self.max_scroll_y.clone(),
1708 viewport_height: self.viewport_height.clone(),
1709 body_bounds: self.body_bounds.clone(),
1710 row_metrics: self.row_metrics.clone(),
1711 tab_traversal: self.tab_traversal,
1712 editing_cell: self.editing_cell.clone(),
1713 display_col_to_id,
1714 display_col_triggers,
1715 on_cell_edit_request: self.on_cell_edit_request.clone(),
1716 on_row_activate: self.on_row_activate.clone(),
1717 type_ahead: self.type_ahead.clone(),
1718 type_ahead_label,
1719 type_ahead_timeout: self.type_ahead_timeout,
1720 column_widths: self.column_widths.clone(),
1721 pane_boundaries: *self.pane_boundaries.borrow(),
1722 scroll_x: self.scroll_x.clone(),
1723 max_scroll_x: self.max_scroll_x.clone(),
1724 middle_viewport_width: self.middle_viewport_width.clone(),
1725 };
1726
1727 // Row DnD is owned by the backing source. The view computes the
1728 // geometric (target_row, position) and asks the source: `can_accept`
1729 // on hover gates the insertion line (forbidden → no affordance),
1730 // `accept_drop` on release commits the move (in-place for a
1731 // `ListModel`, routed for an external source). Same-view reorders and
1732 // foreign / cross-table drops both flow through `accept_drop` — the
1733 // erased closures recover SameView-vs-Foreign from the payload.
1734 let view_id = self.model_id;
1735 let can_accept_hover = self.dnd.can_accept_fn.clone();
1736 let scroll_for_hover = self.scroll_y.clone();
1737 let metrics_for_hover = self.row_metrics.clone();
1738 let len_for_hover = self.len_fn.clone();
1739 let header_h_for_hover = header_h;
1740 let band_width_for_hover = self.header_strip_width.clone();
1741 let feedback_for_hover = self.drop_feedback.clone();
1742 let export_for_hover = self.export.clone();
1743
1744 let accept_drop_for_drop = self.dnd.accept_drop_fn.clone();
1745 let scroll_y_for_drop = self.scroll_y.clone();
1746 let header_h_for_drop = header_h;
1747 let metrics_for_drop = self.row_metrics.clone();
1748 let len_fn_for_drop = self.len_fn.clone();
1749 let feedback_for_drop = self.drop_feedback.clone();
1750 let export_for_drop = self.export.clone();
1751 let reorderable_for_drop = self.reorderable;
1752
1753 let feedback_for_leave = self.drop_feedback.clone();
1754 let scroll_for_tick = self.scroll_y.clone();
1755 let max_scroll_for_tick = self.max_scroll_y.clone();
1756 let viewport_for_tick = self.viewport_height.clone();
1757 let header_h_for_tick = header_h;
1758
1759 // Alt+Arrow reorder wraps the shared key handler: the move is a
1760 // synthetic same-view `RowDragData` through the source's
1761 // `accept_drop`, so it travels exactly the pointer-drop path. Every
1762 // other key falls through to the shared navigator (cell/row
1763 // movement, edit, etc.).
1764 let mut shared_key = keyboard::build_key_handler(key_cfg);
1765 let reorderable_kbd = self.reorderable;
1766 let accept_drop_kbd = self.dnd.accept_drop_fn.clone();
1767 let stash_kbd = self.dnd.stash_drag_keys_fn.clone();
1768 let focused_kbd = self.focused_cell.clone();
1769 let sel_kbd = self.row_selection.clone();
1770 let len_kbd = self.len_fn.clone();
1771 let key_handler = move |event: &teksilo_core::event::WidgetEvent,
1772 ctx: &mut teksilo_core::widget::EventContext|
1773 -> teksilo_core::event::EventResponse {
1774 use teksilo_core::event::{EventResponse, Key, WidgetEvent};
1775 if reorderable_kbd
1776 && let WidgetEvent::KeyDown { key, modifiers, .. } = event
1777 && modifiers.alt()
1778 {
1779 let count = (len_kbd)();
1780 if count > 0 {
1781 let cur = focused_kbd.get().map(|(r, _)| r).or_else(|| {
1782 sel_kbd
1783 .as_ref()
1784 .and_then(|s| s.selected_indices().first().copied())
1785 });
1786 if let Some(idx) = cur {
1787 let mv = match key {
1788 Key::ArrowUp if idx > 0 => {
1789 Some((idx - 1, DropPosition::Before, idx - 1))
1790 }
1791 Key::ArrowDown if idx + 1 < count => {
1792 Some((idx + 1, DropPosition::After, idx + 1))
1793 }
1794 _ => None,
1795 };
1796 if let Some((target, position, dest)) = mv {
1797 // Synthetic same-view payloads must stash the
1798 // dragged row's key at construction — the accept
1799 // path resolves identity from the stash, never
1800 // from `rows`.
1801 (stash_kbd)(&[idx]);
1802 let payload =
1803 teksilo_core::drag_payload::DragPayload::typed(RowDragData::<T> {
1804 source: view_id,
1805 rows: vec![idx],
1806 items: None,
1807 });
1808 if (accept_drop_kbd)(&payload, target, position, view_id) {
1809 if let Some(ref s) = sel_kbd {
1810 s.select(dest);
1811 }
1812 let col = focused_kbd.get().map(|(_, c)| c).unwrap_or(0);
1813 focused_kbd.set(Some((dest, col)));
1814 }
1815 return EventResponse::Handled;
1816 }
1817 }
1818 }
1819 }
1820 shared_key(event, ctx)
1821 };
1822
1823 let mut handlers = HandlerSet::new()
1824 .on_scroll(move |event, _ctx| match event {
1825 teksilo_core::event::WidgetEvent::Scroll { delta, modifiers } => {
1826 let (raw_dx, raw_dy) = match delta {
1827 teksilo_core::event::ScrollDelta::Lines { x, y } => {
1828 (x * line_height, y * line_height)
1829 }
1830 teksilo_core::event::ScrollDelta::Pixels { x, y } => (*x, *y),
1831 };
1832 // Shift+wheel remaps a vertical-only wheel to horizontal
1833 // scroll (the `TabBar` precedent) — a genuine two-axis
1834 // trackpad delta (both native `dx` and `dy` nonzero)
1835 // passes through unremapped either way.
1836 let (dx, dy) = if modifiers.shift() && raw_dx.abs() < f32::EPSILON {
1837 (raw_dy, 0.0)
1838 } else {
1839 (raw_dx, raw_dy)
1840 };
1841
1842 let mut moved_any = false;
1843 if dy.abs() > 0.0 {
1844 let current = scroll_y_for_wheel.get();
1845 let max = max_scroll_for_wheel.get();
1846 // Base off the animation target (not the rendered
1847 // offset) so a mid-fling boundary correctly chains
1848 // and successive notches accumulate instead of
1849 // restarting from the partway-animated position.
1850 let base = scroll_y_for_wheel.animation_target().unwrap_or(current);
1851 let (new_y, moved) =
1852 crate::common::scroll::scroll_clamp_axis(base, dy, max);
1853 if moved {
1854 if smooth_scrolling {
1855 scroll_y_for_wheel.animate_to(
1856 new_y,
1857 smooth_scroll_duration,
1858 Easing::EaseOut,
1859 );
1860 } else {
1861 scroll_y_for_wheel.set(new_y);
1862 }
1863 }
1864 moved_any |= moved;
1865 }
1866 if dx.abs() > 0.0 {
1867 let current = scroll_x_for_wheel.get();
1868 let max = max_scroll_x_for_wheel.get();
1869 let base = scroll_x_for_wheel.animation_target().unwrap_or(current);
1870 let (new_x, moved) =
1871 crate::common::scroll::scroll_clamp_axis(base, dx, max);
1872 if moved {
1873 if smooth_scrolling {
1874 scroll_x_for_wheel.animate_to(
1875 new_x,
1876 smooth_scroll_duration,
1877 Easing::EaseOut,
1878 );
1879 } else {
1880 scroll_x_for_wheel.set(new_x);
1881 }
1882 }
1883 moved_any |= moved;
1884 }
1885 // Chain to an ancestor scrollable when fully clamped on
1886 // every axis touched (unless Contain), otherwise consume.
1887 crate::common::scroll::scroll_response(
1888 moved_any,
1889 overscroll_behavior == OverscrollBehavior::Contain,
1890 )
1891 }
1892 _ => teksilo_core::event::EventResponse::Ignored,
1893 })
1894 .clips_children(true)
1895 .focusable(true);
1896
1897 handlers = handlers.on_key(key_handler);
1898
1899 // Row-level drop target: registered only when this table can
1900 // reorder its own rows or accept foreign ones (mirrors ListView).
1901 // Column reorder lives entirely on the header strip
1902 // (`attach_header_reorder_handlers`) and is untouched by this gate.
1903 if self.export.is_drop_target(self.reorderable) {
1904 handlers = handlers
1905 .on_drag_hover(move |payload, position, _ctx| {
1906 // Column reorder is handled by the header strip; only
1907 // row-level drops (same-view `RowDragData` or a foreign
1908 // payload the source accepts) get an insertion line here.
1909 if payload.has_typed::<ColumnReorderDragData>() {
1910 feedback_for_hover.set(None);
1911 return teksilo_core::DropFeedback::NoFeedback;
1912 }
1913 let body_y = position.y - header_h_for_hover;
1914 let scroll = scroll_for_hover.get();
1915 let content_y = body_y + scroll;
1916 let len = (len_for_hover)();
1917 let (ins, line_y) = {
1918 let mut m = metrics_for_hover.borrow_mut();
1919 m.resize(len);
1920 let ins = m.insertion_index(content_y);
1921 (ins, m.row_top(ins) - scroll)
1922 };
1923 let width = band_width_for_hover.get();
1924 // Source-owned validation: paint the line only when the
1925 // source does not reject the hovered position. A foreign
1926 // exported row is allowed when `accept_foreign_rows` is on
1927 // even though a bare `ListModel`'s `can_accept` rejects
1928 // the `Foreign` branch.
1929 let allowed = flat_insertion_target(ins, len).is_some_and(|(target, pos)| {
1930 !matches!(
1931 (can_accept_hover)(payload, target, pos, view_id),
1932 DropResponse::Reject
1933 ) || export_for_hover.accepts_foreign_export(payload, view_id)
1934 });
1935 if allowed {
1936 feedback_for_hover.set(Some((line_y, width)));
1937 teksilo_core::DropFeedback::InsertionLine { y: line_y, width }
1938 } else {
1939 feedback_for_hover.set(None);
1940 teksilo_core::DropFeedback::NoFeedback
1941 }
1942 })
1943 .on_drop(move |mut payload, position, ctx| {
1944 feedback_for_drop.set(None);
1945 if payload.has_typed::<ColumnReorderDragData>() {
1946 return false;
1947 }
1948 let body_y = position.y - header_h_for_drop;
1949 let scroll = scroll_y_for_drop.get();
1950 let content_y = body_y + scroll;
1951 let len = (len_fn_for_drop)();
1952 let ins = {
1953 let mut m = metrics_for_drop.borrow_mut();
1954 m.resize(len);
1955 m.insertion_index(content_y)
1956 };
1957 let is_same_view = payload
1958 .get_typed::<RowDragData<T>>()
1959 .is_some_and(|rd| rd.source == view_id);
1960 // Route the drop to the source's accept_drop first. A
1961 // same-view reorder only happens when the table is
1962 // `reorderable`; a foreign payload is the source's
1963 // call (a bare ListModel rejects it).
1964 if (reorderable_for_drop || !is_same_view)
1965 && let Some((target, position_kind)) = flat_insertion_target(ins, len)
1966 && (accept_drop_for_drop)(&payload, target, position_kind, view_id)
1967 {
1968 // Only suppress our OWN move-out for a genuine
1969 // same-view drop.
1970 if is_same_view {
1971 export_for_drop.note_self_reorder();
1972 }
1973 return true;
1974 }
1975 // Otherwise, the shared foreign-receive sugar
1976 // (peek-before-take).
1977 export_for_drop.foreign_receive(&mut payload, view_id, ins, ctx)
1978 })
1979 .on_drag_leave(move |_ctx| {
1980 feedback_for_leave.set(None);
1981 })
1982 .on_drag_tick(move |pos, _ctx| {
1983 // Auto-scroll when the pointer lingers within 32 px of the
1984 // body band's top/bottom edge during a drag (body-relative
1985 // so the header doesn't count as the top edge).
1986 const EDGE: f32 = 32.0;
1987 const MAX_VELOCITY: f32 = 12.0;
1988 let body_h = (viewport_for_tick.get() - header_h_for_tick).max(0.0);
1989 let y = pos.y - header_h_for_tick;
1990 let above = (EDGE - y).max(0.0);
1991 let below = (y - (body_h - EDGE)).max(0.0);
1992 let delta = if above > 0.0 {
1993 -(above / EDGE) * MAX_VELOCITY
1994 } else if below > 0.0 {
1995 (below / EDGE) * MAX_VELOCITY
1996 } else {
1997 0.0
1998 };
1999 if delta.abs() > 0.01 {
2000 let max = max_scroll_for_tick.get();
2001 let new_y = (scroll_for_tick.get() + delta).clamp(0.0, max);
2002 scroll_for_tick.set(new_y);
2003 }
2004 });
2005 }
2006
2007 // Export completion (move-out): fires on the drag source — this
2008 // table's root id, the stable id `start_drag` was given.
2009 handlers = self.export.install_completion(handlers);
2010
2011 ctx.apply_self_handlers(handlers);
2012
2013 // ── Build children ────────────────────────────────────────────
2014 self.header_row_id = None;
2015 self.body_pane_id = None;
2016 self.scrollbar_id = None;
2017 self.h_scrollbar_id = None;
2018 self.empty_id = None;
2019
2020 // Display order was already computed above (before the
2021 // keyboard handler was wired); pull it back into a local for
2022 // the header / body loops.
2023 let display_indices = display_indices_now;
2024
2025 // Header strip: build first so it sits above the body in the
2026 // child order (place_children iterates in this order).
2027 if self.show_header {
2028 // A rebuild destroys (and re-creates) every header cell, which
2029 // drops the pointer capture an in-flight resize depends on. Clear
2030 // the shared drag state with it: a `ResizeState` that outlived its
2031 // anchor would otherwise let the next bare PointerMove over the
2032 // same column resize it with no button held.
2033 *self.resize_state.borrow_mut() = None;
2034 self.resize_target.set(None);
2035 self.resize_preview_x.set(None);
2036
2037 let boundaries = *self.pane_boundaries.borrow();
2038 let resize_columns: header::ColumnResizeTable = Rc::new(
2039 display_indices
2040 .iter()
2041 .map(|&i| {
2042 let c = &self.columns[i];
2043 header::ColumnResizeInfo {
2044 id: c.id.clone(),
2045 min_width: c.min_width.unwrap_or(cp::MIN_COLUMN_WIDTH_DEFAULT),
2046 max_width: c.max_width,
2047 resizable: c.resizable,
2048 }
2049 })
2050 .collect(),
2051 );
2052 let mut cell_ids: Vec<WidgetId> = Vec::with_capacity(display_indices.len());
2053 let active_sort = self.sort_signal.get();
2054 for (display_pos, &col_idx) in display_indices.iter().enumerate() {
2055 let col = &self.columns[col_idx];
2056 let current_sort = active_sort
2057 .as_ref()
2058 .and_then(|(id, dir)| if id == &col.id { Some(*dir) } else { None });
2059 // Filter zone width: indicator glyph + a small horizontal
2060 // padding for tap tolerance. Mirrors the layout of the
2061 // HStack inside HeaderCell::build.
2062 let filter_zone_width = cp::FILTER_INDICATOR_SIZE + cp::CELL_PADDING_HORIZONTAL;
2063 let cell = header::HeaderCell::new(header::HeaderCellSpec {
2064 col_id: col.id.clone(),
2065 label: col.header_label.resolve_now(),
2066 col_index_1based: display_pos + 1,
2067 sortable: col.sortable,
2068 reorderable: col.reorderable,
2069 filterable: col.filterable,
2070 resize_grip: cp::RESIZE_HANDLE_WIDTH,
2071 filter_zone_width,
2072 current_sort,
2073 width_index: display_pos,
2074 pane_boundaries: boundaries,
2075 resize_columns: resize_columns.clone(),
2076 resize_policy: self.column_resize_policy,
2077 resize_state: self.resize_state.clone(),
2078 resize_target: self.resize_target.clone(),
2079 resize_preview_x: self.resize_preview_x.clone(),
2080 table_id: self.table_id,
2081 sort_signal: self.sort_signal.clone(),
2082 column_widths_signal: self.column_widths_signal.clone(),
2083 column_widths: self.column_widths.clone(),
2084 filters_signal: self.filters_signal.clone(),
2085 });
2086 cell_ids.push(ctx.add(cell));
2087 }
2088 let header_row = header::HeaderRow::new(
2089 cell_ids,
2090 self.column_widths.clone(),
2091 cp::GRID_LINE_THICKNESS,
2092 *self.pane_boundaries.borrow(),
2093 self.scroll_x.clone(),
2094 );
2095 // Wire reorder drag-target handlers on the header strip.
2096 let header_row_id = ctx.add(header_row);
2097 header::attach_header_reorder_handlers(
2098 ctx,
2099 header_row_id,
2100 self.table_id,
2101 self.column_widths.clone(),
2102 self.display_indices.clone(),
2103 self.pane_boundaries.clone(),
2104 self.column_order_signal.clone(),
2105 self.column_pinning_signal.clone(),
2106 self.columns.iter().map(|c| c.id.clone()).collect(),
2107 self.header_strip_width.clone(),
2108 self.scroll_x.clone(),
2109 );
2110 self.header_row_id = Some(header_row_id);
2111 }
2112
2113 let row_count = (self.len_fn)();
2114
2115 // Lazy: nudge the source to load the realized window, and fetch the
2116 // next page as the viewport nears the end (append-only sources). A
2117 // fully-resident source leaves these inert.
2118 let (vis_start, vis_end) = self.visible_range();
2119 (self.dnd.request_window_fn)(vis_start..vis_end);
2120 if (self.dnd.can_fetch_more_fn)() && vis_end + BUFFER_ROWS >= row_count {
2121 (self.dnd.fetch_more_fn)();
2122 }
2123
2124 if row_count == 0 {
2125 // Empty state.
2126 if let Some(ref f) = self.empty_view {
2127 let id = ctx.add_boxed(f());
2128 self.empty_id = Some(id);
2129 }
2130 } else {
2131 // Hoist the row pane into its own widget so that
2132 // scroll-buffer-exit rebuilds (which happen mid-thumb-drag
2133 // when the user scrolls past the buffered range) target a
2134 // sibling of the scrollbar rather than the scrollbar's
2135 // ancestor. Rebuilding the ancestor would be deferred by
2136 // the framework (to preserve the captured drag), leaving
2137 // the body empty until the user released the thumb.
2138 let pane = body_pane::BodyPane::<T> {
2139 len_fn: self.len_fn.clone(),
2140 with_item_fn: self.with_item_fn.clone(),
2141 drag_fn: self.dnd.drag_fn.clone(),
2142 row_state_fn: self.dnd.row_state_fn.clone(),
2143 columns: self.columns.clone(),
2144 display_indices: self.display_indices.clone(),
2145 column_widths: self.column_widths.clone(),
2146 pane_boundaries: *self.pane_boundaries.borrow(),
2147 scroll_x: self.scroll_x.clone(),
2148 row_metrics: self.row_metrics.clone(),
2149 selection_mode: self.selection_mode,
2150 selection: self.row_selection.clone(),
2151 cell_selection: self.cell_selection.clone(),
2152 scroll_y: self.scroll_y.clone(),
2153 viewport_height: self.viewport_height.clone(),
2154 editing_cell: self.editing_cell.clone(),
2155 focused_cell: self.focused_cell.clone(),
2156 reorderable: self.reorderable,
2157 export: self.export.clone(),
2158 snapshot_out_fn: self.dnd.snapshot_out_fn.clone(),
2159 anchor_fn: self.anchor_fn.clone(),
2160 editing_anchor: self.editing_anchor.clone(),
2161 view_id: self.model_id,
2162 drag_anchor: ctx.self_id(),
2163 on_row_activate: self.on_row_activate.clone(),
2164 activate_on: self.activate_on,
2165 edit_triggers: self.edit_triggers,
2166 on_cell_edit_request: self.on_cell_edit_request.clone(),
2167 on_cell_edit_dismissed: self.on_cell_edit_dismissed.clone(),
2168 version: self.pane_version.clone(),
2169 prev_built_start: self.pane_built_start.clone(),
2170 prev_built_end: self.pane_built_end.clone(),
2171 total_refresh: self.pane_total_refresh.clone(),
2172 row_entries: Vec::new(),
2173 row_map: self.row_map.clone(),
2174 cell_map: self.cell_map.clone(),
2175 };
2176 self.body_pane_id = Some(ctx.add(pane));
2177 // An open cell editor also ends on a press that lands on no cell at
2178 // all — the empty band under the last row. Mounted here rather than
2179 // on the pane because the pane is not the hit target there.
2180 if let Some(handlers) = body_pane::root_edit_dismiss_handler(
2181 &self.on_cell_edit_dismissed,
2182 &self.editing_cell,
2183 &Rc::new(
2184 display_indices
2185 .iter()
2186 .map(|&i| self.columns[i].id.clone())
2187 .collect::<Vec<_>>(),
2188 ),
2189 ) {
2190 ctx.apply_self_handlers(handlers);
2191 }
2192 }
2193
2194 // Scrollbar (single internal vertical bar).
2195 if self.show_internal_scrollbars {
2196 let sb = ScrollBar::new(
2197 ScrollBarOrientation::Vertical,
2198 self.scroll_y.clone(),
2199 self.max_scroll_y.clone(),
2200 self.viewport_ratio_y.clone(),
2201 )
2202 .visual(match self.scroll_bar_style {
2203 ScrollBarMode::Permanent => ScrollBarVisual::Permanent,
2204 ScrollBarMode::Overlay => ScrollBarVisual::Overlay,
2205 ScrollBarMode::Thin => ScrollBarVisual::Thin,
2206 });
2207 self.scrollbar_id = Some(ctx.add(sb));
2208
2209 // Horizontal bar — the Middle pane only. Visibility (max_scroll_x
2210 // > 0) and geometry (band_left + pinned-pane offsets) are decided
2211 // in `place_children`, same as the vertical bar's `needs_scrollbar`
2212 // gate; here we just build it unconditionally so it exists to be
2213 // placed (zero-sized and skipped when not needed).
2214 let hsb = ScrollBar::new(
2215 ScrollBarOrientation::Horizontal,
2216 self.scroll_x.clone(),
2217 self.max_scroll_x.clone(),
2218 self.viewport_ratio_x.clone(),
2219 )
2220 .visual(match self.scroll_bar_style {
2221 ScrollBarMode::Permanent => ScrollBarVisual::Permanent,
2222 ScrollBarMode::Overlay => ScrollBarVisual::Overlay,
2223 ScrollBarMode::Thin => ScrollBarVisual::Thin,
2224 });
2225 self.h_scrollbar_id = Some(ctx.add(hsb));
2226 }
2227
2228 // Z-order: body rows first, then empty/scrollbar, then header
2229 // last. The header band overlaps the top of the body region
2230 // when `scroll_y > 0` (rows positioned at `body_origin_y +
2231 // row_idx * row_h - scroll_y` can extend above
2232 // `body_origin_y` on overscroll). Painting the header last
2233 // means it sits on top of any row that bleeds into the
2234 // header band — without this fix, scrolled-out rows would
2235 // visibly draw over the header label.
2236 let mut children: Vec<WidgetId> = Vec::new();
2237 if let Some(id) = self.body_pane_id {
2238 children.push(id);
2239 }
2240 if let Some(id) = self.empty_id {
2241 children.push(id);
2242 }
2243 if let Some(id) = self.scrollbar_id {
2244 children.push(id);
2245 }
2246 if let Some(id) = self.h_scrollbar_id {
2247 children.push(id);
2248 }
2249 if let Some(id) = self.header_row_id {
2250 children.push(id);
2251 }
2252 // Suppress the unused-binding warning on header_h while the
2253 // value is consumed by `place_children` via the same helper.
2254 let _ = header_h;
2255 children
2256 }
2257
2258 fn layout_response(
2259 &self,
2260 proposal: SizeProposal,
2261 _ctx: &LayoutContext,
2262 ) -> teksilo_core::widget::LayoutResponse {
2263 // Only an allocation may seed the cached viewport (`common::viewport`);
2264 // the body pane shares this very cell, so a measurement's fallback
2265 // would desync its realization window.
2266 let size = crate::common::viewport::viewport_size(
2267 proposal,
2268 &self.viewport_height,
2269 Size::new(400.0, 300.0),
2270 );
2271 if proposal.height.is_some() {
2272 // Viewport-relative imperatives are meaningful from here on — but
2273 // only once a real height has landed, for the reason `laid_out`
2274 // exists at all.
2275 self.laid_out.set(true);
2276 }
2277 size.into()
2278 }
2279
2280 fn place_children(
2281 &self,
2282 bounds: Rect,
2283 _proposal: SizeProposal,
2284 children: &mut [WidgetPlacement],
2285 ctx: &LayoutContext,
2286 ) {
2287 if children.is_empty() {
2288 return;
2289 }
2290 let rtl = ctx.is_rtl();
2291 let header_h = self.effective_header_height();
2292 // Provisional — the vertical scrollbar's own need is decided
2293 // against this (a possible tiny inaccuracy if reserving room for
2294 // the horizontal bar below would itself flip that decision; not
2295 // worth a fixed-point iteration for a dual-scrollbar corner case).
2296 let body_height_provisional = (bounds.height - header_h).max(0.0);
2297
2298 // Parent-before-child layout order means this runs before the
2299 // body pane's measure pass — in auto-measure mode the scrollbar
2300 // totals settle one frame after a measurement change.
2301 let total_height = self.total_content_height();
2302 let needs_v_scrollbar =
2303 self.show_internal_scrollbars && total_height > body_height_provisional + 0.5;
2304 // Permanent reserves a column for the bar; Overlay / Thin float
2305 // over the content, so rows span the full width.
2306 let reserves_v_bar = needs_v_scrollbar && self.scroll_bar_style == ScrollBarMode::Permanent;
2307 let body_width = if reserves_v_bar {
2308 (bounds.width - SCROLLBAR_THICKNESS).max(0.0)
2309 } else {
2310 bounds.width
2311 };
2312 // Under RTL the vertical scrollbar moves to the physical left
2313 // (matching `ScrollArea`), so the body/header band shifts right
2314 // by its thickness. `band_left` is the shared origin for the
2315 // body pane, empty state, and header; `scrollbar_x` is the
2316 // scrollbar's own physical x. The paint pass derives the same
2317 // content region from these conventions so the two never drift.
2318 let band_left = if rtl && reserves_v_bar {
2319 bounds.x + SCROLLBAR_THICKNESS
2320 } else {
2321 bounds.x
2322 };
2323 let scrollbar_x = if rtl {
2324 bounds.x
2325 } else {
2326 bounds.x + bounds.width - SCROLLBAR_THICKNESS
2327 };
2328 // The header strip spans the band; snapshot its width for the
2329 // reorder-drop handler's RTL mirror.
2330 self.header_strip_width.set(body_width);
2331
2332 // Resolve column widths in display order, honoring any
2333 // user-resize overrides from `column_widths_signal`.
2334 let overrides = self.column_widths_signal.get();
2335 let display = self.display_indices.borrow().clone();
2336 let widths = layout::ColumnSolver::resolve_in_order(
2337 &self.columns,
2338 &display,
2339 body_width,
2340 cp::MIN_COLUMN_WIDTH_DEFAULT,
2341 &overrides,
2342 );
2343
2344 // Pane geometry: the Middle pane's viewport (`body_width` minus the
2345 // pinned panes) and the horizontal scroll headroom it implies.
2346 let boundaries = *self.pane_boundaries.borrow();
2347 let (leading_w, middle_content_w, trailing_w) = layout::pane_widths(&widths, boundaries);
2348 let middle_viewport_w = (body_width - leading_w - trailing_w).max(0.0);
2349 let max_x = (middle_content_w - middle_viewport_w).max(0.0);
2350 self.max_scroll_x.set(max_x);
2351 self.middle_viewport_width.set(middle_viewport_w);
2352 let x_ratio = if middle_content_w > 0.0 {
2353 (middle_viewport_w / middle_content_w).clamp(0.0, 1.0)
2354 } else {
2355 1.0
2356 };
2357 self.viewport_ratio_x.set(x_ratio);
2358 // Clamp scroll_x — a pane shrink (window narrowed, a column grew)
2359 // must not leave scroll_x stranded past the new max (mirrors
2360 // `clamp_scroll` for scroll_y).
2361 {
2362 let current = self.scroll_x.get();
2363 let clamped = current.clamp(0.0, max_x);
2364 if (clamped - current).abs() > 0.001 {
2365 self.scroll_x.set(clamped);
2366 }
2367 }
2368
2369 *self.column_widths.borrow_mut() = widths;
2370
2371 let needs_h_scrollbar = self.show_internal_scrollbars && max_x > 0.5;
2372 let reserves_h_bar = needs_h_scrollbar && self.scroll_bar_style == ScrollBarMode::Permanent;
2373 let body_height = if reserves_h_bar {
2374 (body_height_provisional - SCROLLBAR_THICKNESS).max(0.0)
2375 } else {
2376 body_height_provisional
2377 };
2378
2379 // Vertical scrollbar totals, against the FINAL body_height (after
2380 // any horizontal-bar reservation) so the range stays accurate when
2381 // both bars show at once.
2382 let max_y = (total_height - body_height).max(0.0);
2383 self.max_scroll_y.set(max_y);
2384 let y_ratio = if total_height > 0.0 {
2385 (body_height / total_height).clamp(0.0, 1.0)
2386 } else {
2387 1.0
2388 };
2389 self.viewport_ratio_y.set(y_ratio);
2390 self.clamp_scroll();
2391
2392 let body_origin_y = bounds.y + header_h;
2393 // Cache the row-area rect for the keyboard handler's outer-scroll chase.
2394 self.body_bounds
2395 .set(Rect::new(band_left, body_origin_y, body_width, body_height));
2396
2397 let mut next = 0;
2398
2399 // BodyPane fills the body region. It positions its rows
2400 // internally using its own scroll signal and clips them to
2401 // its own bounds.
2402 if self.body_pane_id.is_some() {
2403 if let Some(child) = children.get_mut(next) {
2404 child.origin = Point::new(band_left, body_origin_y);
2405 child.size = Size::new(body_width, body_height);
2406 }
2407 next += 1;
2408 }
2409
2410 // Empty-state child fills the body region (below the header).
2411 if self.empty_id.is_some() {
2412 if let Some(child) = children.get_mut(next) {
2413 child.origin = Point::new(band_left, body_origin_y);
2414 child.size = Size::new(body_width, body_height);
2415 }
2416 next += 1;
2417 }
2418
2419 // Scrollbar — alongside the body, below the header. Physical
2420 // left under RTL, physical right under LTR.
2421 if self.scrollbar_id.is_some() {
2422 if let Some(child) = children.get_mut(next) {
2423 if needs_v_scrollbar {
2424 child.origin = Point::new(scrollbar_x, body_origin_y);
2425 child.size = Size::new(SCROLLBAR_THICKNESS, body_height);
2426 } else {
2427 child.origin = bounds.origin();
2428 child.size = Size::ZERO;
2429 }
2430 }
2431 next += 1;
2432 }
2433
2434 // Horizontal scrollbar — the Middle pane's own band, below the
2435 // body, never overlapping a pinned pane.
2436 if self.h_scrollbar_id.is_some() {
2437 if let Some(child) = children.get_mut(next) {
2438 if needs_h_scrollbar {
2439 let h_x = if rtl {
2440 band_left + trailing_w
2441 } else {
2442 band_left + leading_w
2443 };
2444 child.origin = Point::new(h_x, body_origin_y + body_height);
2445 child.size = Size::new(middle_viewport_w, SCROLLBAR_THICKNESS);
2446 } else {
2447 child.origin = bounds.origin();
2448 child.size = Size::ZERO;
2449 }
2450 }
2451 next += 1;
2452 }
2453
2454 // Header strip last — placed at top y but emitted last so paint
2455 // z-order draws it above any overscrolled body rows.
2456 if self.header_row_id.is_some()
2457 && let Some(child) = children.get_mut(next)
2458 {
2459 child.origin = Point::new(band_left, bounds.y);
2460 child.size = Size::new(body_width, header_h);
2461 }
2462 }
2463
2464 fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
2465 let header_h = self.effective_header_height();
2466 let colors = &ctx.theme.colors;
2467
2468 let scroll_y = self.scroll_y.get();
2469 let body_origin_y = bounds.y + header_h;
2470 let body_height = (bounds.height - header_h).max(0.0);
2471 let widths = self.column_widths.borrow();
2472 let body_width = widths.iter().sum::<f32>();
2473 let body_width_for_paint = if body_width > 0.0 {
2474 body_width.min(bounds.width)
2475 } else {
2476 bounds.width
2477 };
2478 // Physical left edge of the column content. Under RTL the band is
2479 // right-aligned within `bounds` (the scrollbar took the left), so
2480 // content runs from `bounds.right() - body_width` leftward —
2481 // exactly where `place_children` reverse-placed the cells.
2482 let rtl = ctx.layout_direction == teksilo_core::environment::LayoutDirection::RightToLeft;
2483 let content_left = if rtl {
2484 bounds.x + bounds.width - body_width_for_paint
2485 } else {
2486 bounds.x
2487 };
2488
2489 // Visible row window for the paint passes — offset-table-driven
2490 // so variable heights paint correctly. One metrics borrow per
2491 // pass; nothing inside re-enters the metrics.
2492 let row_count = (self.len_fn)();
2493 let (first_visible, last_visible) =
2494 self.row_metrics
2495 .borrow_mut()
2496 .visible_range(scroll_y, body_height, row_count, 0);
2497
2498 // Clip the root-painted row decorations (alt-row stripes,
2499 // selection bands, grid lines, focus ring) to the body band.
2500 // `clips_children` only clips child WIDGETS — this widget's own
2501 // paint would otherwise bleed past the table's bottom edge for
2502 // the partially visible last row (its stripe/grid-line rect
2503 // spans the full row height).
2504 canvas.set_clip(Rect::new(
2505 content_left,
2506 body_origin_y,
2507 body_width_for_paint,
2508 body_height,
2509 ));
2510
2511 // Alt-row backgrounds — paint odd visible rows. Parity keys on
2512 // the row index, not on y, so stripes stay stable under
2513 // variable heights.
2514 if self.alternating_rows {
2515 let mut m = self.row_metrics.borrow_mut();
2516 for row_idx in first_visible..last_visible {
2517 if row_idx % 2 == 1 {
2518 let y = body_origin_y + m.row_top(row_idx) - scroll_y;
2519 let h = m.row_height(row_idx);
2520 let rect = Rect::new(content_left, y, body_width_for_paint, h);
2521 canvas.fill_rect(rect, SurfaceRole::AltRow.resolve(colors));
2522 }
2523 }
2524 }
2525
2526 // Selection highlights — row selection modes only.
2527 if let Some(ref sel) = self.row_selection
2528 && matches!(
2529 self.selection_mode,
2530 TableSelectionMode::SingleRow | TableSelectionMode::MultiRow
2531 )
2532 {
2533 // Focus- and window-aware: vivid `Selected` while the table holds
2534 // keyboard focus AND the host window is active; muted
2535 // `SelectedInactive` once focus moves elsewhere or the window goes
2536 // inactive (the same desaturation serves both states).
2537 let bg = if self.view_focused.get() && ctx.window_active {
2538 SurfaceRole::Selected.resolve(colors)
2539 } else {
2540 SurfaceRole::SelectedInactive.resolve(colors)
2541 };
2542 let mut m = self.row_metrics.borrow_mut();
2543 for row_idx in sel.selected_indices() {
2544 let y = body_origin_y + m.row_top(row_idx) - scroll_y;
2545 let h = m.row_height(row_idx);
2546 if y + h < body_origin_y || y > body_origin_y + body_height {
2547 continue;
2548 }
2549 let rect = Rect::new(content_left, y, body_width_for_paint, h);
2550 canvas.fill_rect(rect, bg);
2551 }
2552 }
2553
2554 // Grid lines.
2555 let line_color = BorderRole::Divider.resolve(colors);
2556 let line_w = cp::GRID_LINE_THICKNESS.max(1.0);
2557
2558 if matches!(self.grid_lines, GridLines::Horizontal | GridLines::Both) {
2559 let mut m = self.row_metrics.borrow_mut();
2560 for row_idx in first_visible..last_visible {
2561 let bottom = m.row_top(row_idx) + m.row_height(row_idx);
2562 let y = body_origin_y + bottom - scroll_y - line_w;
2563 let rect = Rect::new(content_left, y, body_width_for_paint, line_w);
2564 canvas.fill_rect(rect, line_color);
2565 }
2566 }
2567
2568 // Pane geometry for the two column-position-dependent decorations
2569 // below (vertical grid lines, the cell focus ring): both must clip
2570 // to the target column's OWN pane, or a scrolled Middle-pane
2571 // decoration could paint over a pinned Leading/Trailing column
2572 // within the same row band (the outer body clip above only bounds
2573 // the row's outer edges, not the seam between panes).
2574 let boundaries = *self.pane_boundaries.borrow();
2575 let scroll_x = self.scroll_x.get();
2576 let content_bounds = Rect::new(
2577 content_left,
2578 body_origin_y,
2579 body_width_for_paint,
2580 body_height,
2581 );
2582 let (leading_rect, middle_rect, trailing_rect) =
2583 layout::band_rects(content_bounds, &widths, boundaries, rtl);
2584
2585 if matches!(self.grid_lines, GridLines::Vertical | GridLines::Both) {
2586 let leading_end = boundaries.leading_count.min(widths.len());
2587 let middle_end = boundaries.middle_end.min(widths.len()).max(leading_end);
2588 draw_pane_dividers(
2589 canvas,
2590 leading_rect,
2591 &widths[..leading_end],
2592 0.0,
2593 rtl,
2594 line_color,
2595 line_w,
2596 );
2597 draw_pane_dividers(
2598 canvas,
2599 middle_rect,
2600 &widths[leading_end..middle_end],
2601 scroll_x,
2602 rtl,
2603 line_color,
2604 line_w,
2605 );
2606 draw_pane_dividers(
2607 canvas,
2608 trailing_rect,
2609 &widths[middle_end..],
2610 0.0,
2611 rtl,
2612 line_color,
2613 line_w,
2614 );
2615 }
2616
2617 // Focus ring on the currently-focused cell — keyboard-only
2618 // (`:focus-visible`) and only while the table itself holds focus, so a
2619 // mouse click never leaves a ring and an unfocused table shows none.
2620 if self.view_focused.get()
2621 && self.focus_visible.get()
2622 && let Some((focus_row, focus_col)) = self.focused_cell.get()
2623 && focus_col < widths.len()
2624 && let Some(x_off) = layout::column_logical_x(
2625 &widths,
2626 boundaries,
2627 scroll_x,
2628 body_width_for_paint,
2629 focus_col,
2630 )
2631 {
2632 let cell_w = widths[focus_col];
2633 let (focus_top, focus_h) = {
2634 let mut m = self.row_metrics.borrow_mut();
2635 (m.row_top(focus_row), m.row_height(focus_row))
2636 };
2637 let y = body_origin_y + focus_top - scroll_y;
2638 if y + focus_h >= body_origin_y && y <= body_origin_y + body_height {
2639 let pane_rect = if focus_col < boundaries.leading_count {
2640 leading_rect
2641 } else if focus_col >= boundaries.middle_end {
2642 trailing_rect
2643 } else {
2644 middle_rect
2645 };
2646 canvas.set_clip(pane_rect);
2647 let inset = cp::FOCUS_RING_INSET;
2648 let stroke = cp::GRID_LINE_THICKNESS.max(1.5);
2649 let ring_color = BorderRole::Focused.resolve(colors);
2650 // `x_off` is the leading-side offset (sum of widths before
2651 // the focused column). Under RTL that offset is measured
2652 // from the right edge of the content band.
2653 let rx = if rtl {
2654 content_left + body_width_for_paint - x_off - cell_w + inset
2655 } else {
2656 content_left + x_off + inset
2657 };
2658 let ry = y + inset;
2659 let rw = (cell_w - inset * 2.0).max(0.0);
2660 let rh = (focus_h - inset * 2.0).max(0.0);
2661 // Top
2662 canvas.fill_rect(Rect::new(rx, ry, rw, stroke), ring_color);
2663 // Bottom
2664 canvas.fill_rect(Rect::new(rx, ry + rh - stroke, rw, stroke), ring_color);
2665 // Left
2666 canvas.fill_rect(Rect::new(rx, ry, stroke, rh), ring_color);
2667 // Right
2668 canvas.fill_rect(Rect::new(rx + rw - stroke, ry, stroke, rh), ring_color);
2669 canvas.clear_clip();
2670 }
2671 }
2672
2673 // Row-drop insertion indicator (source-accepted positions only —
2674 // a forbidden hover clears the signal, so no line shows). `y` is
2675 // stored body-local; the band clip is already active.
2676 if let Some((y, _width)) = self.drop_feedback.get() {
2677 let line_color = BorderRole::Focused.resolve(colors);
2678 let thickness = 2.0_f32;
2679 let line_y = body_origin_y + y - thickness * 0.5;
2680 canvas.fill_rect(
2681 Rect::new(content_left, line_y, body_width_for_paint, thickness),
2682 line_color,
2683 );
2684 }
2685
2686 canvas.clear_clip();
2687
2688 // Container focus ring — the table holds keyboard focus but nothing
2689 // indicates where: no current cell (no cell ring) and no selection (no
2690 // band). Outline the whole view so Tab has a visible landing point
2691 // before the user navigates (mirrors TreeView / ListView).
2692 let nothing_indicated = self.focused_cell.get().is_none()
2693 && self
2694 .row_selection
2695 .as_ref()
2696 .is_none_or(|s| s.selected_indices().is_empty())
2697 && self.cell_selection.as_ref().is_none_or(|s| s.count() == 0);
2698 if self.view_focused.get() && self.focus_visible.get() && nothing_indicated {
2699 let inset = 1.0_f32;
2700 let rect = Rect::new(
2701 bounds.x + inset,
2702 bounds.y + inset,
2703 (bounds.width - inset * 2.0).max(0.0),
2704 (bounds.height - inset * 2.0).max(0.0),
2705 );
2706 canvas.stroke_rect(rect, BorderRole::Focused.resolve(colors), 1.5);
2707 }
2708
2709 // `OnRelease` column-resize guide. Under that policy no column moves
2710 // until the button comes up, so this line is the *only* feedback the
2711 // gesture has — the same full-height rubber band Qt / Excel draw.
2712 if let Some(x) = self.resize_preview_x.get() {
2713 let thickness = cp::GRID_LINE_THICKNESS.max(1.5);
2714 canvas.fill_rect(
2715 Rect::new(x - thickness * 0.5, bounds.y, thickness, bounds.height),
2716 BorderRole::Focused.resolve(colors),
2717 );
2718 }
2719 }
2720
2721 /// The context-menu key opens the *current row's* menu, not the view's.
2722 ///
2723 /// A `TableView` is focusable and its rows deliberately are not — the
2724 /// container owns focus and `set_selected` is what tells assistive
2725 /// technology which row is current. So the dispatcher's default of "the
2726 /// focused widget" would open the view's own menu, in the widget family
2727 /// where a per-row menu matters most.
2728 ///
2729 /// The row the user means is the focused cell's row if they have navigated,
2730 /// else the first selected row. Only realized rows have a widget, so a
2731 /// cursor scrolled outside the virtualization window resolves to nothing
2732 /// and the menu falls back to the view — right, because there is no row on
2733 /// screen for it to be about.
2734 fn context_menu_key_target(&self) -> Option<WidgetId> {
2735 let index = self.focused_cell.get().map(|(row, _col)| row).or_else(|| {
2736 self.row_selection
2737 .as_ref()
2738 .and_then(|s| s.selected_indices().first().copied())
2739 })?;
2740 let map = self.row_map.borrow();
2741 map.iter().find(|(i, _)| *i == index).map(|(_, id)| *id)
2742 }
2743
2744 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2745 builder.set_role(teksilo_core::accesskit::Role::Table);
2746 if let Some(ref label) = self.a11y_label {
2747 builder.set_name(label.resolve_now());
2748 }
2749 // AccessKit's `row_count` includes the header row when present —
2750 // matches ARIA `aria-rowcount` semantics.
2751 let row_count = (self.len_fn)() + if self.show_header { 1 } else { 0 };
2752 let col_count = self.columns.len();
2753 let n = builder.inner_mut();
2754 n.set_row_count(row_count);
2755 n.set_column_count(col_count);
2756
2757 // Roving focus: point active_descendant at the focused cell's own
2758 // AT node so a screen reader follows arrow-key cell navigation
2759 // (only the table root is otherwise focusable — the ring is
2760 // visual-only). `cell_map` is a snapshot of the body pane's last
2761 // realized cells; a focused cell that scrolled out of the
2762 // realized buffer simply isn't in it, so no stale id is emitted.
2763 if let Some(target) = self.focused_cell.get() {
2764 let map = self.cell_map.borrow();
2765 if let Some(&(_, cell_id)) = map.iter().find(|&&(pos, _)| pos == target) {
2766 builder.set_active_descendant(widget_id_to_node_id(cell_id));
2767 }
2768 }
2769 }
2770
2771 fn as_any(&self) -> Option<&dyn std::any::Any> {
2772 Some(self)
2773 }
2774
2775 fn children(&self) -> Vec<WidgetId> {
2776 // Same order as `build()` — body pane first, header last so
2777 // it paints on top of any overscrolled rows.
2778 let mut out: Vec<WidgetId> = Vec::new();
2779 if let Some(id) = self.body_pane_id {
2780 out.push(id);
2781 }
2782 if let Some(id) = self.empty_id {
2783 out.push(id);
2784 }
2785 if let Some(id) = self.scrollbar_id {
2786 out.push(id);
2787 }
2788 if let Some(id) = self.h_scrollbar_id {
2789 out.push(id);
2790 }
2791 if let Some(id) = self.header_row_id {
2792 out.push(id);
2793 }
2794 out
2795 }
2796
2797 fn accessibility_children(&self) -> Option<Vec<WidgetId>> {
2798 // WCAG 1.3.2 (audit G17): read the column-header row FIRST, then the
2799 // body, even though `build()` / `children()` list the body first so it
2800 // paints beneath the header. Same id set as `children()`, reordered.
2801 let out: Vec<WidgetId> = [
2802 self.header_row_id,
2803 self.body_pane_id,
2804 self.empty_id,
2805 self.scrollbar_id,
2806 self.h_scrollbar_id,
2807 ]
2808 .into_iter()
2809 .flatten()
2810 .collect();
2811 if out.is_empty() { None } else { Some(out) }
2812 }
2813
2814 fn clips_children(&self) -> bool {
2815 true
2816 }
2817}
2818
2819/// Draw the internal vertical grid-line dividers for one pane band —
2820/// `slice.len() - 1` lines between adjacent columns, clipped to `rect` so a
2821/// scrolled Middle-pane line can't bleed past its own viewport into a
2822/// pinned neighbour. `scroll` is nonzero only for the Middle pane.
2823///
2824/// Shared by `TableView`/`TreeTableView`'s `paint()`, which are otherwise
2825/// near-identical for this decoration.
2826#[allow(clippy::too_many_arguments)]
2827pub(crate) fn draw_pane_dividers(
2828 canvas: &mut Canvas,
2829 rect: Rect,
2830 slice: &[f32],
2831 scroll: f32,
2832 rtl: bool,
2833 color: teksilo_tokens::Color,
2834 line_w: f32,
2835) {
2836 if slice.len() < 2 || rect.width <= 0.0 {
2837 return;
2838 }
2839 canvas.set_clip(rect);
2840 if rtl {
2841 let mut x = rect.right() + scroll;
2842 for &w in &slice[..slice.len() - 1] {
2843 x -= w;
2844 canvas.fill_rect(Rect::new(x, rect.y, line_w, rect.height), color);
2845 }
2846 } else {
2847 let mut x = rect.x - scroll;
2848 for &w in &slice[..slice.len() - 1] {
2849 x += w;
2850 canvas.fill_rect(Rect::new(x - line_w, rect.y, line_w, rect.height), color);
2851 }
2852 }
2853 canvas.clear_clip();
2854}
2855
2856// Reorder drag-target plumbing (hover + drop on the header strip) lives in
2857// `header::attach_header_reorder_handlers` — shared with `TreeTableView`,
2858// which builds its header out of the same `HeaderCell`/`HeaderRow` pair.