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