Skip to main content

teksilo_widgets/table_view/
body_pane.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `BodyPane<T>` — the virtualized row pane underneath the header.
5//!
6//! Splitting this out of `TableView`'s root widget is a deliberate
7//! architectural choice: `TableView` owns the header row, the body
8//! pane, the vertical and horizontal scrollbars and the empty view as
9//! direct children. Rebuilds triggered by
10//! scroll-buffer exits, selection changes, or row-edit toggles target
11//! the body pane only, *not* the table root.
12//!
13//! Why it matters: when the user drags the scrollbar thumb, the
14//! framework holds an implicit pointer capture on the scrollbar widget
15//! for the entire Down→Up sequence. The rebuild deferral in
16//! `process_pending_rebuilds` skips rebuilds that target any *ancestor*
17//! of the captured widget — otherwise the rebuild would destroy the
18//! scrollbar mid-drag and the recogniser would lose the press state.
19//! With the row-rebuild target moved off `TableView` (an ancestor of
20//! the scrollbar) and onto `BodyPane` (a sibling of the scrollbar),
21//! mid-drag rebuilds become safe and the body keeps materializing
22//! visible rows as `scroll_y` advances.
23//!
24//! `BodyPane` is `pub(crate)` — applications still talk to `TableView`.
25
26use std::cell::{Cell, RefCell};
27use std::rc::Rc;
28
29use teksilo_canvas::{Point, Rect, Size, SizeProposal};
30use teksilo_core::accessibility::AccessNodeBuilder;
31use teksilo_core::binding::BindingLevel;
32use teksilo_core::build_context::BuildContext;
33use teksilo_core::signal::Signal;
34use teksilo_core::widget::{LayoutContext, Widget, WidgetPlacement};
35use teksilo_core::widget_builder::HandlerSet;
36use teksilo_core::widget_id::WidgetId;
37use teksilo_data::{DragEligibility, RowState};
38
39use super::a11y::CellA11y;
40use super::body::{BodyRow, SharedColumnWidths};
41use super::column::{CellContext, Column};
42use super::selection::{CellSelectionModel, TableSelectionMode};
43use crate::common::row_metrics::SharedRowMetrics;
44use crate::common::row_reveal::{RevealAlign, SharedRowReveal};
45use crate::data_views::deferred_select::PendingSelect;
46use crate::data_views::{RowSelection, ViewId, default_placeholder};
47use crate::table_view::column::EditTriggers;
48
49const BUFFER_ROWS: usize = 5;
50
51pub(crate) type LenFn = Rc<dyn Fn() -> usize>;
52pub(crate) type WithItemFn<T> = Rc<dyn Fn(usize, &dyn Fn(&T))>;
53
54/// The row-virtualization pane. Owns the visible row widgets and
55/// handles their per-row click + drag handlers. Sized to fill the
56/// caller's proposal; lays each row at `RowMetrics::row_top(flat_index)
57/// - scroll_y` in pane-local coordinates.
58pub(crate) struct BodyPane<T: 'static> {
59    pub(crate) len_fn: LenFn,
60    pub(crate) with_item_fn: WithItemFn<T>,
61    /// Source per-row drag gate — `NoDrag` suppresses the drag gesture.
62    pub(crate) drag_fn: Rc<dyn Fn(usize) -> DragEligibility>,
63    /// Source per-row load state — a `Loading` row renders a placeholder
64    /// skeleton instead of its cells.
65    pub(crate) row_state_fn: Rc<dyn Fn(usize) -> RowState>,
66
67    pub(crate) columns: Vec<Column<T>>,
68    pub(crate) display_indices: Rc<RefCell<Vec<usize>>>,
69    pub(crate) column_widths: SharedColumnWidths,
70    /// Pane partition (Leading/Middle/Trailing), snapshotted at build —
71    /// forwarded to each `BodyRow` for the pane-band split. See
72    /// `body::BodyRow`'s module docs.
73    pub(crate) pane_boundaries: super::PaneBoundaries,
74    /// Middle-pane horizontal scroll offset, forwarded to each `BodyRow`.
75    pub(crate) scroll_x: Signal<f32>,
76
77    /// Row geometry shared with the `TableView` root (one handle, two
78    /// holders — the root drives scrollbar totals / paint / keyboard,
79    /// the pane drives realization, placement, and measurement).
80    pub(crate) row_metrics: SharedRowMetrics,
81    pub(crate) selection_mode: TableSelectionMode,
82    pub(crate) selection: Option<RowSelection>,
83    pub(crate) cell_selection: Option<CellSelectionModel>,
84
85    pub(crate) scroll_y: Signal<f32>,
86    pub(crate) viewport_height: Rc<Cell<f32>>,
87    pub(crate) editing_cell: Signal<Option<(usize, usize)>>,
88    pub(crate) focused_cell: Signal<Option<(usize, usize)>>,
89
90    pub(crate) reorderable: bool,
91    /// The non-drag reorder, bound once by the root. `Some` exactly when the
92    /// view is reorderable; each realized row binds its own index into it and
93    /// gets the menu rows and custom actions SC 2.5.7 asks for.
94    pub(crate) reorder_perform: Option<crate::common::ordered_move::MoveRow>,
95    /// Cross-widget export / foreign-receive machinery, cloned in from the
96    /// owning `TableView` — builds the drag-start payload here; the
97    /// self-reorder flag and removal-thunk stash are Rc-backed, so mutations
98    /// made through this clone are visible to the root's `on_drag_ended`
99    /// completion (installed on the owning `TableView`'s own clone).
100    pub(crate) export: crate::data_views::RowExport<T>,
101    /// Source-side move-out completion: resolves stable keys at drag-start
102    /// and returns a removal thunk. Threaded straight from the owning
103    /// `TableView`'s `dnd` bundle.
104    pub(crate) snapshot_out_fn: crate::data_views::SnapshotOutFn,
105    /// Resolve a row index to a movement-proof handle. Threaded from the
106    /// owning `TableView`'s source, like `snapshot_out_fn`, because the pane
107    /// gets erased closures rather than the source itself.
108    pub(crate) anchor_fn: Rc<dyn Fn(usize) -> crate::data_views::RowAnchor>,
109    /// Anchor slot for the row with an open cell editor (Rc-shared with the
110    /// owning `TableView`, so it survives this pane being rebuilt).
111    pub(crate) editing_anchor: Rc<RefCell<Option<crate::data_views::RowAnchor>>>,
112    /// Stable, kind-tagged id of the owning `TableView` instance — stamped
113    /// into the `RowDragData` payload so the source can tell a same-view
114    /// reorder from a foreign drop.
115    pub(crate) view_id: ViewId,
116
117    /// Optional row-activation callback (a click per `activate_on`, or
118    /// Enter/Space on the focused row) — distinct from *selection*, which also
119    /// moves on arrow navigation.
120    pub(crate) on_row_activate: Option<Rc<dyn Fn(usize, &mut teksilo_core::widget::EventContext)>>,
121    /// Whether activation is a single or double click (default `DoubleClick`).
122    pub(crate) activate_on: crate::data_views::ActivateOn,
123    /// Which gestures open a cell editor. The pane implements the
124    /// **double-click** arm; F2 and type-to-edit live in the shared key
125    /// handler (`table_view::keyboard`), which is the root's business.
126    pub(crate) edit_triggers: crate::table_view::EditTriggers,
127    /// Fired with `(flat row, column id)` when a double-click opens an editor,
128    /// so the owner can seed its buffer — the same callback the keyboard
129    /// routes use.
130    pub(crate) on_cell_edit_request:
131        Option<Rc<dyn Fn(usize, &str, &mut teksilo_core::widget::EventContext)>>,
132    /// Fired when a press lands outside the cell currently being edited, so the
133    /// owner can end that edit. See `TableView::on_cell_edit_dismissed`.
134    pub(crate) on_cell_edit_dismissed:
135        Option<Rc<dyn Fn(usize, &str, &mut teksilo_core::widget::EventContext)>>,
136    /// Anchor used by row drag-start to identify the source. Captured
137    /// at construction so the closure stays `'static`.
138    pub(crate) drag_anchor: WidgetId,
139
140    /// Pane-local rebuild trigger. A persistent field (re-bound each
141    /// build) so `place_children`'s post-measure realization re-check
142    /// can request a rebuild of this pane.
143    pub(crate) version: Signal<u64>,
144    /// Bound at `Relayout` on the `TableView` ROOT. The root computes
145    /// scrollbar totals (`max_scroll_y`, thumb ratio) before this pane
146    /// measures (parent-before-child layout order); when a measure pass
147    /// changes the content total, the pane bumps this so the root
148    /// re-places next frame with the corrected total — otherwise the
149    /// stale totals would persist forever (content beyond the estimated
150    /// total would be unreachable). A dedicated signal rather than a
151    /// `scroll_y` self-set so an in-flight scroll animation is never
152    /// cancelled.
153    pub(crate) total_refresh: Signal<u64>,
154    /// Buffered row range materialized by the latest build.
155    pub(crate) prev_built_start: Rc<Cell<usize>>,
156    pub(crate) prev_built_end: Rc<Cell<usize>>,
157
158    // Build state
159    pub(crate) row_entries: Vec<(usize, WidgetId)>,
160    /// The ids this pane hands the arena as its children, positionally aligned
161    /// with [`Self::row_entries`].
162    ///
163    /// A row that is a drag source is wrapped in a
164    /// [`DragSurface`](crate::data_views::DragSurface) — the drag has to
165    /// strictly enclose whatever captures the press — so its layout child is
166    /// the wrapper. `row_entries` (and so `row_map`) stays on the `Role::Row`
167    /// node.
168    pub(crate) row_roots: Vec<WidgetId>,
169    /// The realized `(row index -> row wrapper id)` map, shared with the owning
170    /// view so its `&self` methods can resolve a row index to a widget without
171    /// reaching into this pane.
172    pub(crate) row_map: Rc<RefCell<Vec<(usize, WidgetId)>>>,
173    /// `(row, display_pos) -> WidgetId` for every realized cell, shared
174    /// with the `TableView` root (the GridView `tile_map` pattern).
175    /// Overwritten wholesale at the end of every `build()`; the root's
176    /// `accessibility()` reads it to resolve `active_descendant` for the
177    /// keyboard-focused cell.
178    pub(crate) cell_map: Rc<RefCell<Vec<((usize, usize), WidgetId)>>>,
179    /// The pending [`TableViewHandle`](crate::TableViewHandle) reveal,
180    /// applied in `place_children` after the measure pass — this pane is
181    /// where the row offsets settle, and the scroll anchoring with them.
182    pub(crate) reveal: SharedRowReveal,
183}
184
185impl<T: 'static> BodyPane<T> {
186    /// Apply the pending handle reveal against the settled row offsets of
187    /// this pass. `viewport` is the pane's own height (the body, below the
188    /// header and above any horizontal scroll bar).
189    fn apply_reveal(&self, viewport: f32) {
190        let count = (self.len_fn)();
191        let measured = |row: usize| {
192            !self.row_metrics.borrow().needs_measure()
193                || self.row_entries.iter().any(|&(i, _)| i == row)
194        };
195        self.reveal
196            .apply(&self.scroll_y, count, measured, |row, align, scroll| {
197                let mut m = self.row_metrics.borrow_mut();
198                let max = (m.total_height(count) - viewport).max(0.0);
199                match align {
200                    RevealAlign::Top => m.row_top(row).clamp(0.0, max),
201                    RevealAlign::Nearest => m.scroll_for_ensure_visible(row, scroll, viewport, max),
202                }
203            });
204    }
205
206    fn visible_range(&self) -> (usize, usize) {
207        self.row_metrics.borrow_mut().visible_range(
208            self.scroll_y.get(),
209            self.viewport_height.get(),
210            (self.len_fn)(),
211            BUFFER_ROWS,
212        )
213    }
214}
215
216impl<T: 'static> std::fmt::Debug for BodyPane<T> {
217    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
218        f.debug_struct("BodyPane")
219            .field("rows", &(self.len_fn)())
220            .field("columns", &self.columns.len())
221            .finish()
222    }
223}
224
225impl<T: 'static> Widget for BodyPane<T> {
226    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
227        // The pane rebuilds on BOTH an editing change and a data change, so
228        // this is the one place that sees every transition an open editor has
229        // to survive.
230        let anchor_fn = self.anchor_fn.clone();
231        crate::data_views::reconcile_editing_row(&self.editing_cell, &self.editing_anchor, &|i| {
232            anchor_fn(i)
233        });
234        // Self-rebuild trigger. A persistent field (not `ctx.signal`)
235        // so the realization re-check in `place_children` can bump it
236        // after measurement.
237        let version = self.version.clone();
238        version.bind_to(ctx.self_id(), ctx.binding_registry(), BindingLevel::Rebuild);
239        // A handle's reveal request re-places this pane, which applies it.
240        self.reveal.bind(ctx);
241
242        // Scroll position re-places rows without rebuilding (within buffer).
243        self.scroll_y.bind_to(
244            ctx.self_id(),
245            ctx.binding_registry(),
246            BindingLevel::Relayout,
247        );
248        ctx.register_animated_signal(&self.scroll_y);
249
250        // Buffer-exit detection. Bumps version → rebuild THIS pane.
251        // Critical: because BodyPane is a sibling of the scrollbar
252        // (not its ancestor), the rebuild deferral logic doesn't
253        // skip this rebuild during a thumb drag. Without this split
254        // (when the rebuild was rooted on TableView, an ancestor of
255        // the scrollbar), dragging the thumb past the buffer left
256        // the body empty until the user released the thumb.
257        let len = self.len_fn.clone();
258        let vp_h = self.viewport_height.clone();
259        let (initial_start, initial_end) = self.visible_range();
260        self.prev_built_start.set(initial_start);
261        self.prev_built_end.set(initial_end);
262        let v_for_scroll = version.clone();
263        let scroll_handle = self.scroll_y.observe({
264            let pbs = self.prev_built_start.clone();
265            let pbe = self.prev_built_end.clone();
266            let metrics = self.row_metrics.clone();
267            move |y| {
268                let count = (len)();
269                let (visible_start, visible_end) =
270                    metrics.borrow_mut().visible_range(*y, vp_h.get(), count, 0);
271                if visible_start < pbs.get() || visible_end > pbe.get() {
272                    let new_start = visible_start.saturating_sub(BUFFER_ROWS);
273                    let new_end = (visible_end + BUFFER_ROWS).min(count);
274                    pbs.set(new_start);
275                    pbe.set(new_end);
276                    v_for_scroll.set(v_for_scroll.get() + 1);
277                }
278            }
279        });
280        ctx.own_handle(scroll_handle);
281
282        // Selection / editing changes — refresh `is_selected` /
283        // `is_editing` flags fed into the cell delegate.
284        if let Some(ref sel) = self.selection {
285            let v = version.clone();
286            let counter = Rc::new(Cell::new(0_u64));
287            let handle = sel.observe_for_rebuild(move || {
288                counter.set(counter.get() + 1);
289                v.set(counter.get());
290            });
291            ctx.own_handle(handle);
292        }
293        if let Some(ref cs) = self.cell_selection {
294            let v = version.clone();
295            let counter = Rc::new(Cell::new(0_u64));
296            ctx.effect(&cs.selection_signal(), move |_| {
297                counter.set(counter.get() + 1);
298                v.set(counter.get());
299            });
300        }
301        let v_for_edit = version.clone();
302        let edit_counter = Rc::new(Cell::new(0_u64));
303        ctx.effect(&self.editing_cell, move |_| {
304            edit_counter.set(edit_counter.get() + 1);
305            v_for_edit.set(edit_counter.get());
306        });
307
308        // Build the visible row range.
309        self.row_entries.clear();
310        self.row_roots.clear();
311        let mut cell_entries: Vec<((usize, usize), WidgetId)> = Vec::new();
312        let (start, end) = self.visible_range();
313        let columns = self.columns.clone();
314        let with_item_fn = self.with_item_fn.clone();
315        let display_indices = self.display_indices.borrow().clone();
316        // Column ids in display order, so a dismissal can name the column whose
317        // editor it is ending rather than the one that was pressed.
318        let display_col_ids: Rc<Vec<String>> = Rc::new(
319            display_indices
320                .iter()
321                .map(|&i| columns[i].spec.id.clone())
322                .collect(),
323        );
324        let editing_state = self.editing_cell.get();
325        let row_widths_handle = self.column_widths.clone();
326        let selection_mode = self.selection_mode;
327        // Rows become a drag source when reorderable OR exportable — the
328        // export path makes a row draggable-out even when same-view
329        // reordering is disabled.
330        let is_drag_source = self.export.is_drag_source(self.reorderable);
331
332        // Key the row focus scope on the table's focusable root (`drag_anchor`),
333        // not this pane — keyboard focus lands on the root, so a `StandardItem`
334        // cell's focus-aware selection must track the root's focus.
335        ctx.begin_view_focus_for(self.drag_anchor);
336        for row_idx in start..end {
337            // One anchor per row, cloned into every handler that addresses the
338            // row — the cells' double-click-to-edit as well as the row's own
339            // selection / activation / drag. Resolved once here, above the cell
340            // loop, so all of them agree on which row this is.
341            let row_anchor = (self.anchor_fn)(row_idx);
342            let row_selected_for_a11y = match (selection_mode, &self.selection) {
343                (TableSelectionMode::SingleRow | TableSelectionMode::MultiRow, Some(s)) => {
344                    s.is_selected(row_idx)
345                }
346                _ => false,
347            };
348
349            // A non-resident row (data not yet loaded) whose source reports
350            // `Loading` renders placeholder cells instead of being skipped,
351            // so the scrollbar and layout stay stable while the window loads.
352            let resident = read_item_local(&with_item_fn, row_idx, |_| ()).is_some();
353            let loading = !resident && (self.row_state_fn)(row_idx) == RowState::Loading;
354
355            let mut cell_ids: Vec<WidgetId> = Vec::with_capacity(display_indices.len());
356            for (display_pos, &col_idx) in display_indices.iter().enumerate() {
357                let col = &columns[col_idx];
358                let is_editing = editing_state == Some((row_idx, display_pos));
359                let is_selected = match (selection_mode, &self.selection, &self.cell_selection) {
360                    (TableSelectionMode::SingleRow | TableSelectionMode::MultiRow, Some(s), _) => {
361                        s.is_selected(row_idx)
362                    }
363                    (
364                        TableSelectionMode::SingleCell | TableSelectionMode::MultiCell,
365                        _,
366                        Some(cs),
367                    ) => cs.is_selected(row_idx, display_pos),
368                    _ => false,
369                };
370                let is_focused = self.focused_cell.get() == Some((row_idx, display_pos));
371                let cell_ctx = CellContext {
372                    row_index: row_idx,
373                    col_id: col.spec.id.clone(),
374                    col_index: display_pos,
375                    is_selected,
376                    is_focused,
377                    is_hovered: false,
378                    is_editing,
379                    depth: None,
380                    is_tree_column: false,
381                };
382                let cell_widget = if loading {
383                    Some(default_placeholder())
384                } else {
385                    read_item_local(&with_item_fn, row_idx, |item| (col.cell)(item, &cell_ctx))
386                };
387                if let Some(widget) = cell_widget {
388                    let inner_id = ctx.add_boxed(widget);
389                    // When the cell delegate just swapped in an editor
390                    // (because `is_editing` flipped to true), the
391                    // delegate's child subtree is built fresh — focus
392                    // is still on whatever it was before the rebuild,
393                    // which is now stale. Walk the editing cell's
394                    // subtree for the first focusable descendant and
395                    // hand keyboard focus to it. Without this, F2 puts
396                    // a `TextInput` on screen but the user has to
397                    // click it before they can type.
398                    //
399                    // `focus_into` rather than a bare `focus` on the
400                    // first focusable descendant: it is a no-op while
401                    // focus is already inside the cell, so the rebuild
402                    // storm a table lives in (selection, filtering,
403                    // scroll, the edit signal itself) cannot yank the
404                    // caret back to the field's start mid-edit.
405                    if is_editing {
406                        ctx.focus_into(inner_id);
407                    }
408                    let cell_a11y = CellA11y::new(
409                        inner_id,
410                        row_idx + 2, // header is row 1
411                        display_pos + 1,
412                        is_selected,
413                    )
414                    // `Role::GridCell` only where the cell really is the
415                    // selectable unit; see `CellA11y::is_grid_cell`.
416                    .with_grid_cell_role(selection_mode.is_cell_mode());
417                    let cell_id = ctx.add(cell_a11y);
418
419                    // Per-cell pointer handler: a click on the cell
420                    // sets `focused_cell` to (row, col) so the focus
421                    // ring follows the mouse. Also mirrors the click
422                    // into `cell_selection` when the table is in a
423                    // cell-selection mode (Ctrl/Shift modifiers extend
424                    // the rectangular selection just like the keyboard
425                    // handler). Skipped while editing — the click
426                    // belongs to the inner editor.
427                    let focused_for_cell = self.focused_cell.clone();
428                    let editing_for_cell = self.editing_cell.clone();
429                    let cell_sel_for_click = self.cell_selection.clone();
430                    let row_for_cell = row_idx;
431                    let col_for_cell = display_pos;
432                    let mode_for_cell = selection_mode;
433                    // Cell selection is deferred to the release for a direct
434                    // pointer, for the same reason the row's is: a finger's
435                    // press is the opening sample of a scroll, and no
436                    // release-time predicate can unwrite a selection already
437                    // made on `PointerDown`. A mouse still commits on press.
438                    // The row body uses `data_views::deferred_select`; a cell
439                    // cannot (its coordinate is a pair and its model is
440                    // `CellSelectionModel`), so it reuses only the decision
441                    // enum.
442                    let pending_cell_select: Rc<Cell<Option<PendingSelect>>> =
443                        Rc::new(Cell::new(None));
444                    let apply_cell = move |cs: &CellSelectionModel, what: PendingSelect| match what
445                    {
446                        PendingSelect::Collapse => cs.select(row_for_cell, col_for_cell),
447                        PendingSelect::Toggle => cs.toggle(row_for_cell, col_for_cell),
448                        PendingSelect::Extend => cs.extend_to(row_for_cell, col_for_cell),
449                    };
450                    let cell_handlers =
451                        HandlerSet::new().on_pointer_event(move |event, ctx| match event {
452                            teksilo_core::event::WidgetEvent::PointerDown {
453                                button: teksilo_core::event::PointerButton::Primary,
454                                modifiers,
455                                ..
456                            } => {
457                                if editing_for_cell.get().is_some() {
458                                    return teksilo_core::event::EventResponse::Ignored;
459                                }
460                                let what = match mode_for_cell {
461                                    TableSelectionMode::SingleCell => Some(PendingSelect::Collapse),
462                                    TableSelectionMode::MultiCell => Some(if modifiers.shift() {
463                                        PendingSelect::Extend
464                                    } else if modifiers.command() {
465                                        PendingSelect::Toggle
466                                    } else {
467                                        PendingSelect::Collapse
468                                    }),
469                                    _ => None,
470                                };
471                                if ctx.pointer_kind().is_direct() {
472                                    // The focus ring travels with the applied
473                                    // selection, not with the press.
474                                    pending_cell_select.set(what);
475                                    return teksilo_core::event::EventResponse::Ignored;
476                                }
477                                pending_cell_select.set(None);
478                                focused_for_cell.set(Some((row_for_cell, col_for_cell)));
479                                if let (Some(cs), Some(what)) = (cell_sel_for_click.as_ref(), what)
480                                {
481                                    apply_cell(cs, what);
482                                }
483                                teksilo_core::event::EventResponse::Ignored
484                            }
485                            teksilo_core::event::WidgetEvent::PointerUp {
486                                button: teksilo_core::event::PointerButton::Primary,
487                                ..
488                            } => {
489                                if !crate::data_views::release_completes_the_press(ctx) {
490                                    // Abandoned, not postponed — a pan that
491                                    // started on this cell selects nothing.
492                                    pending_cell_select.set(None);
493                                    return teksilo_core::event::EventResponse::Ignored;
494                                }
495                                if let Some(what) = pending_cell_select.replace(None) {
496                                    focused_for_cell.set(Some((row_for_cell, col_for_cell)));
497                                    if let Some(cs) = cell_sel_for_click.as_ref() {
498                                        apply_cell(cs, what);
499                                    }
500                                }
501                                teksilo_core::event::EventResponse::Ignored
502                            }
503                            _ => teksilo_core::event::EventResponse::Ignored,
504                        });
505                    ctx.apply_handlers(cell_id, cell_handlers);
506                    // Click-to-edit, from this column's `EditTriggers`. A second
507                    // `apply_handlers` on the same node *merges* into its
508                    // external bucket, so the focus-ring / cell-selection
509                    // handler above survives.
510                    if let Some(edit_handlers) = cell_edit_handlers(
511                        col.effective_edit_triggers(self.edit_triggers),
512                        &self.on_cell_edit_request,
513                        &self.editing_cell,
514                        &row_anchor,
515                        display_pos,
516                        &col.spec.id,
517                    ) {
518                        ctx.apply_handlers(cell_id, edit_handlers);
519                    }
520                    if let Some(dismiss) = cell_edit_dismiss_handler(
521                        &self.on_cell_edit_dismissed,
522                        &self.editing_cell,
523                        &display_col_ids,
524                        &row_anchor,
525                        display_pos,
526                    ) {
527                        ctx.apply_handlers(cell_id, dismiss);
528                    }
529
530                    cell_entries.push(((row_idx, display_pos), cell_id));
531                    cell_ids.push(cell_id);
532                }
533            }
534
535            // Auto-measure mode hands the row a `None` height so its
536            // `layout_response` measures the tallest cell; fixed modes
537            // pass the per-row height from the metrics. (Two separate
538            // borrows — a borrow inside an `if` condition would live to
539            // the end of the statement and collide with `borrow_mut`.)
540            let needs_measure = self.row_metrics.borrow().needs_measure();
541            let row_height = if needs_measure {
542                None
543            } else {
544                Some(self.row_metrics.borrow_mut().row_height(row_idx))
545            };
546            let row_widget = BodyRow::new(
547                cell_ids,
548                row_idx + 2,
549                row_selected_for_a11y,
550                row_height,
551                row_widths_handle.clone(),
552                self.pane_boundaries,
553                self.scroll_x.clone(),
554            );
555            let row_id = ctx.add(row_widget);
556
557            // A grid is *one* Tab stop — the cell cursor is the navigation, and
558            // a control the delegate put in a cell (a checkbox column, most
559            // often) must not become a Tab stop of its own. Only realized rows
560            // exist, so a per-cell stop would make the Tab order follow the
561            // scroll position: 36 stops in a 200-row table showing 35, and a
562            // different 36 after scrolling. Honoured up the ancestor chain by
563            // `tab_stop_effective`, so one call covers every cell in the row.
564            // `Space` reaches the control through the focused cell's published
565            // keyboard toggle instead.
566            ctx.set_tab_stop(row_id, false);
567
568            // Selection click on the row. Skipped while a cell is in
569            // edit mode so clicks landing inside the editor (e.g. on
570            // the cell's `TextInput`) don't change the selection — a
571            // selection change would re-emit the row, destroying the
572            // editor and dropping focus mid-click.
573            let mut row_handlers = HandlerSet::new();
574            // ScrollIntoView is the one scroll action all three AccessKit
575            // adapters actually consume (UIA `IScrollItemProvider`, AppKit
576            // `accessibilityScrollToVisible`, AT-SPI `ScrollTo`). Rows are not
577            // focusable nodes, so without it a screen reader has no way to
578            // bring one into view. `ListView` and `TreeView` have answered it
579            // since they shipped; the tables did not.
580            {
581                let metrics = self.row_metrics.clone();
582                let scroll = self.scroll_y.clone();
583                let viewport = self.viewport_height.clone();
584                let len = self.len_fn.clone();
585                row_handlers = row_handlers.on_access_action(move |action, _ctx| {
586                    if action != teksilo_core::accesskit::Action::ScrollIntoView {
587                        return teksilo_core::event::EventResponse::Ignored;
588                    }
589                    let vh = viewport.get();
590                    let cur = scroll.get();
591                    let new = {
592                        let mut m = metrics.borrow_mut();
593                        let total = m.total_height(len());
594                        let max = (total - vh).max(0.0);
595                        m.scroll_for_ensure_visible(row_idx, cur, vh, max)
596                    };
597                    if (new - cur).abs() > f32::EPSILON {
598                        scroll.set(new);
599                    }
600                    teksilo_core::event::EventResponse::Handled
601                });
602            }
603            if let Some(ref sel) = self.selection {
604                let click_anchor = row_anchor.clone();
605                let sel_for_click = sel.clone();
606                let editing_for_click = self.editing_cell.clone();
607                if matches!(
608                    selection_mode,
609                    TableSelectionMode::SingleRow | TableSelectionMode::MultiRow
610                ) {
611                    // Deferred collapse: pressing an already-selected row
612                    // keeps the whole (multi-)selection so it can be
613                    // dragged; the collapse-to-single happens on release
614                    // WITHOUT a drag, and only on a release the row still
615                    // owns (see `release_completes_the_press`).
616                    let pending_collapse = crate::data_views::deferred_select::pending_cell();
617                    row_handlers = row_handlers.on_pointer_event(move |event, ctx| match event {
618                        teksilo_core::event::WidgetEvent::PointerDown {
619                            button: teksilo_core::event::PointerButton::Primary,
620                            modifiers,
621                            ..
622                        } => {
623                            if editing_for_click.get().is_some() {
624                                return teksilo_core::event::EventResponse::Ignored;
625                            }
626                            // Resolve the row's CURRENT position. The
627                            // interactive-child guard lives in the shared
628                            // helper, which also clears any stale deferred
629                            // decision — so a vanished row must still reach it
630                            // rather than returning early and stranding the
631                            // flag.
632                            let Some(row_index_for_click) = click_anchor.index() else {
633                                pending_collapse.set(None);
634                                return teksilo_core::event::EventResponse::Ignored;
635                            };
636                            // Nav-cursor sync (`focused_cell`) is handled by the
637                            // per-cell pointer handler above, which fires on any
638                            // cell click in every mode — so a row click here already
639                            // moves the arrow-nav origin. (TreeTableView has no such
640                            // per-cell handler, so it syncs in its row handler.)
641                            //
642                            // The shared helper owns the whole decision: the
643                            // interactive-child guard, the accelerator and
644                            // shift clicks, the deferred collapse, and — for a
645                            // direct pointer — deferring the lot to the
646                            // release.
647                            crate::data_views::deferred_select::on_down(
648                                &sel_for_click,
649                                row_index_for_click,
650                                *modifiers,
651                                &pending_collapse,
652                                ctx,
653                            );
654                            // Ignored so the gesture arena on this widget
655                            // still sees the PointerDown and can arm the
656                            // DragRecognizer for drag-to-reorder/export
657                            // alongside selection.
658                            teksilo_core::event::EventResponse::Ignored
659                        }
660                        teksilo_core::event::WidgetEvent::PointerUp {
661                            button: teksilo_core::event::PointerButton::Primary,
662                            ..
663                        } => {
664                            // Apply whatever the press deferred — the
665                            // collapse of a multi-selection a mouse kept alive
666                            // for a drag, or a direct pointer's whole
667                            // decision. The helper owns the
668                            // interactive-child guard and asks
669                            // `release_completes_the_press` for itself, which
670                            // is how a finger's pan claim (which wins the
671                            // arbitration without raising a drag, so the
672                            // `PointerUp` is not routed to `handle_drag_drop`
673                            // and does arrive here) commits nothing.
674                            match click_anchor.index() {
675                                Some(row) => {
676                                    crate::data_views::deferred_select::on_up(
677                                        &sel_for_click,
678                                        row,
679                                        &pending_collapse,
680                                        ctx,
681                                    );
682                                }
683                                None => pending_collapse.set(None),
684                            }
685                            teksilo_core::event::EventResponse::Ignored
686                        }
687                        _ => teksilo_core::event::EventResponse::Ignored,
688                    });
689                }
690            }
691            let mut row_drag: Option<HandlerSet> = None;
692            if is_drag_source {
693                let drag_row = row_idx;
694                let view_id = self.view_id;
695                let anchor = self.drag_anchor;
696                let drag_gate = self.drag_fn.clone();
697                let with_item_for_preview = self.with_item_fn.clone();
698                let columns_for_preview = self.columns.clone();
699                let display_for_preview = self.display_indices.clone();
700                let widths_for_preview = self.column_widths.clone();
701                let metrics_for_preview = self.row_metrics.clone();
702                // Export capture: the dragged set is selection-aware; the
703                // shared `RowExport` builds the payload (clones / MIME /
704                // Loading-filter / stash) when the view opted in.
705                let sel_for_drag = self.selection.clone();
706                let export_for_drag = self.export.clone();
707                let with_item_for_drag = self.with_item_fn.clone();
708                let snapshot_for_drag = self.snapshot_out_fn.clone();
709                row_drag = Some(crate::data_views::row_grab_surface().on_drag(
710                    move |phase, ctx| {
711                        if let teksilo_core::gesture::DragPhase::Started { .. } = phase {
712                            // The source's per-row transferable gate.
713                            if (drag_gate)(drag_row) == DragEligibility::NoDrag {
714                                return;
715                            }
716                            // Selection-aware dragged set: the whole selection
717                            // when the pressed row is part of a multi-selection,
718                            // else just the pressed row.
719                            let rows: Vec<usize> = match sel_for_drag.as_ref() {
720                                Some(s) if s.is_selected(drag_row) => {
721                                    let mut v = s.selected_indices();
722                                    v.sort_unstable();
723                                    if v.len() <= 1 { vec![drag_row] } else { v }
724                                }
725                                _ => vec![drag_row],
726                            };
727                            // Adapt the side-effect `with_item_fn` reader to the
728                            // `RowExport::build_payload` signature (which needs a
729                            // bool-returning "did it resolve" reader).
730                            let read = |i: usize, f: &mut dyn FnMut(&T)| -> bool {
731                                read_item_local(&with_item_for_drag, i, |t| f(t)).is_some()
732                            };
733                            let Some(payload) = export_for_drag.build_payload(
734                                view_id,
735                                rows,
736                                &read,
737                                &snapshot_for_drag,
738                            ) else {
739                                return;
740                            };
741                            // Build a full-width preview from the PRESSED row's
742                            // cells so the floating widget reads as the picked-up
743                            // row. Cells are built eagerly here (no arena), then a
744                            // self-contained `CellRowPreview` lays them out.
745                            let display = display_for_preview.borrow().clone();
746                            let cells: Vec<Box<dyn Widget>> =
747                                read_item_local(&with_item_for_preview, drag_row, |item| {
748                                    display
749                                        .iter()
750                                        .enumerate()
751                                        .map(|(display_pos, &col_idx)| {
752                                            let col = &columns_for_preview[col_idx];
753                                            let cell_ctx = CellContext {
754                                                row_index: drag_row,
755                                                col_id: col.spec.id.clone(),
756                                                col_index: display_pos,
757                                                is_selected: false,
758                                                is_focused: false,
759                                                is_hovered: false,
760                                                is_editing: false,
761                                                depth: None,
762                                                is_tree_column: false,
763                                            };
764                                            (col.cell)(item, &cell_ctx)
765                                        })
766                                        .collect::<Vec<_>>()
767                                })
768                                .unwrap_or_default();
769                            if cells.is_empty() {
770                                ctx.start_drag(anchor, payload);
771                                return;
772                            }
773                            let widths = widths_for_preview.borrow().clone();
774                            let h = metrics_for_preview.borrow_mut().row_height(drag_row);
775                            let total_w = widths.iter().sum::<f32>().max(120.0);
776                            let preview = Box::new(crate::drag_preview::DragPreview::new(
777                                total_w,
778                                h,
779                                Box::new(CellRowPreview::new(cells, widths, h)),
780                            )) as Box<dyn Widget>;
781                            ctx.start_drag_with_preview(anchor, payload, preview);
782                        }
783                    },
784                ));
785            }
786            // Row activation (open/commit) — a gesture, so it arbitrates
787            // against the reorder drag via the gesture arena (a click
788            // activates, a drag does not). `SingleClick` → `on_tap`,
789            // `DoubleClick` → `on_double_tap`; Enter/Space activates too.
790            if let Some(ref cb) = self.on_row_activate {
791                let cb = cb.clone();
792                // Anchored: a row that moved (or vanished) between build and
793                // click must not activate whoever took its slot.
794                let a = row_anchor.clone();
795                let handlers = match self.activate_on {
796                    crate::data_views::ActivateOn::SingleClick => {
797                        let a = a.clone();
798                        HandlerSet::new().on_tap(move |_tap, ctx| {
799                            if let Some(cur) = a.index() {
800                                cb(cur, ctx)
801                            }
802                        })
803                    }
804                    crate::data_views::ActivateOn::DoubleClick => {
805                        // **Editing wins over activation, and the framework
806                        // arbitrates it — there is nothing to guard here.** A
807                        // node carrying a gesture arena answers `Handled` to
808                        // the press and the bubble stops there, so once a cell
809                        // takes a click trigger its row's activation no longer
810                        // sees clicks on *that column*. Which is the wanted
811                        // reading: a column that edits on double-click must not
812                        // also open the row on the same gesture. Every other
813                        // column still activates.
814                        HandlerSet::new().on_double_tap(move |_tap, ctx| {
815                            if let Some(cur) = a.index() {
816                                cb(cur, ctx)
817                            }
818                        })
819                    }
820                };
821                ctx.apply_handlers(row_id, handlers);
822            }
823            ctx.apply_handlers(row_id, row_handlers);
824
825            // The non-drag alternative to that drag: the four Move commands as
826            // AccessKit custom actions plus a context menu carrying the same
827            // rows, both calling the root's own commit closure. See
828            // `common::ordered_move`.
829            if let Some(ref perform) = self.reorder_perform {
830                let anchor = (self.anchor_fn)(row_idx);
831                crate::common::ordered_move::RowCommands {
832                    perform: crate::common::ordered_move::bind_row(
833                        perform,
834                        Rc::new(move || anchor.index()),
835                    ),
836                    from: row_idx,
837                    count: (self.len_fn)(),
838                    axis: crate::common::ordered_move::MoveAxis::Vertical,
839                    extra: Vec::new(),
840                }
841                .install(ctx, row_id);
842            }
843
844            // The reorder drag goes on a wrapper that STRICTLY encloses this
845            // row, never on the row itself: a drag on the node that captures
846            // the press is driven by the capture dispatch, which runs before
847            // the arbitration advances, so it latches at `drag_slop` and
848            // decides the sequence before the table's own `PanClaim` can win at
849            // `pan_slop`. Enclosing it is also the only shape the tree arms
850            // `DragActivation` for — `Immediate` for a mouse, `AfterLongPress`
851            // for a finger. The absorber guarantees the row a gesture arena so
852            // something *inside* the wrapper takes the press.
853            let mut row_root = row_id;
854            if let Some(drag) = row_drag {
855                ctx.apply_handlers(row_id, crate::data_views::press_absorber());
856                row_root = ctx.add(crate::data_views::DragSurface::new(row_id));
857                ctx.apply_handlers(row_root, drag);
858            }
859
860            self.row_entries.push((row_idx, row_id));
861            self.row_roots.push(row_root);
862        }
863        ctx.end_view_focus();
864
865        *self.cell_map.borrow_mut() = cell_entries;
866
867        // Publish the realized (index -> row wrapper id) map for the view.
868        *self.row_map.borrow_mut() = self.row_entries.clone();
869
870        self.row_roots.clone()
871    }
872
873    fn layout_response(
874        &self,
875        proposal: SizeProposal,
876        _ctx: &LayoutContext,
877    ) -> teksilo_core::widget::LayoutResponse {
878        // Only an allocation may seed the cached viewport — a measurement's
879        // fallback would desync `build`'s realization window (`common::viewport`).
880        crate::common::viewport::viewport_size(
881            proposal,
882            &self.viewport_height,
883            Size::new(400.0, 300.0),
884        )
885        .into()
886    }
887
888    fn place_children(
889        &self,
890        bounds: Rect,
891        _proposal: SizeProposal,
892        children: &mut [WidgetPlacement],
893        ctx: &LayoutContext,
894    ) {
895        // The allocated height is the authoritative viewport: `build` sizes its
896        // realization window from this, and a stale value there costs a
897        // permanent rebuild loop (`common::viewport`).
898        crate::common::viewport::record_viewport_height(&self.viewport_height, bounds.height);
899
900        // Auto-measure pass: measure every realized row at the pane
901        // width (BodyRow reports its tallest cell, height-for-width),
902        // feed the heights back, and apply the scroll-anchor delta so
903        // content above the viewport stays put. Measurements are
904        // collected with NO metrics borrow held.
905        if self.row_metrics.borrow().needs_measure() {
906            let count = (self.len_fn)();
907            let pre_total = self.row_metrics.borrow_mut().total_height(count);
908            let mut measured = Vec::with_capacity(children.len());
909            for (i, child) in children.iter().enumerate() {
910                if let Some(size) = ctx.child_size(child.id, SizeProposal::with_width(bounds.width))
911                {
912                    let (model_index, _) = self.row_entries[i];
913                    measured.push((model_index, size.height));
914                }
915            }
916            let anchor = self
917                .row_metrics
918                .borrow_mut()
919                .observe_measured(&measured, self.scroll_y.get());
920            if anchor.abs() > 0.01 {
921                // Safe from place_children: the dirty flag is set but the
922                // binding flush already ran this pass — lands next frame.
923                self.scroll_y.set((self.scroll_y.get() + anchor).max(0.0));
924            }
925
926            // Realization re-check: corrected offsets may reveal viewport
927            // rows the estimated offsets never realized. Request a pane
928            // rebuild for next frame; the 0.01 measurement epsilon
929            // guarantees convergence.
930            let (vs, ve) = self.row_metrics.borrow_mut().visible_range(
931                self.scroll_y.get(),
932                self.viewport_height.get(),
933                count,
934                0,
935            );
936            if vs < self.prev_built_start.get() || ve > self.prev_built_end.get() {
937                self.prev_built_start.set(vs.saturating_sub(BUFFER_ROWS));
938                self.prev_built_end.set((ve + BUFFER_ROWS).min(count));
939                self.version.set(self.version.get() + 1);
940            }
941
942            // Total-refresh poke: the root computed `max_scroll_y` /
943            // thumb ratio BEFORE this measure pass (parent-first
944            // ordering). If the content total changed, re-place the
945            // root next frame so the corrected total lands — without
946            // this, content past the estimated total stays unreachable
947            // forever. Terminates: a re-measure of settled rows yields
948            // zero deltas (sub-pixel epsilon), leaving the total fixed.
949            let post_total = self.row_metrics.borrow_mut().total_height(count);
950            if (post_total - pre_total).abs() > 0.01 {
951                self.total_refresh.set(self.total_refresh.get() + 1);
952            }
953        }
954
955        // A handle's reveal, now that the measure pass and the anchoring have
956        // settled the offsets it is computed from.
957        self.apply_reveal(bounds.height);
958
959        let scroll_y = self.scroll_y.get();
960        for (i, child) in children.iter_mut().enumerate() {
961            let (model_index, _) = self.row_entries[i];
962            let (top, height) = {
963                let mut m = self.row_metrics.borrow_mut();
964                (m.row_top(model_index), m.row_height(model_index))
965            };
966            let y = bounds.y + top - scroll_y;
967            child.origin = Point::new(bounds.x, y);
968            child.size = Size::new(bounds.width, height);
969        }
970    }
971
972    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
973        // Body pane stands in as the table's `Role::RowGroup` — the
974        // ARIA-blessed intermediate between `Role::Table` and
975        // `Role::Row`. Without a non-hidden role here, AT clients
976        // that walk `Table > Row` directly would balk at a hidden
977        // generic container in the path.
978        builder.set_role(teksilo_core::accesskit::Role::RowGroup);
979    }
980
981    fn children(&self) -> Vec<WidgetId> {
982        self.row_roots.clone()
983    }
984
985    fn clips_children(&self) -> bool {
986        true
987    }
988}
989
990fn read_item_local<T, R>(
991    with_item_fn: &WithItemFn<T>,
992    idx: usize,
993    f: impl FnOnce(&T) -> R,
994) -> Option<R> {
995    let f_cell: Cell<Option<_>> = Cell::new(Some(f));
996    let slot: Cell<Option<R>> = Cell::new(None);
997    (with_item_fn)(idx, &|item: &T| {
998        if let Some(f) = f_cell.take() {
999            slot.set(Some(f(item)));
1000        }
1001    });
1002    slot.into_inner()
1003}
1004
1005/// Self-contained, arena-free row preview for the drag floating widget: it
1006/// owns its (already-built) boxed cells and lays them out horizontally at the
1007/// dragged row's column widths. Built once at drag-start and mounted by the
1008/// framework's drag-overlay build pass, so it can't reuse `BodyRow` (which
1009/// addresses cells by arena id). Shared with `TreeTableView`'s body pane.
1010pub(crate) struct CellRowPreview {
1011    /// Cells to mount, drained in `build`.
1012    cells: Vec<Box<dyn Widget>>,
1013    /// Display-order column widths, parallel to the mounted children.
1014    widths: Vec<f32>,
1015    height: f32,
1016    ids: Vec<WidgetId>,
1017}
1018
1019impl CellRowPreview {
1020    pub(crate) fn new(cells: Vec<Box<dyn Widget>>, widths: Vec<f32>, height: f32) -> Self {
1021        Self {
1022            cells,
1023            widths,
1024            height,
1025            ids: Vec::new(),
1026        }
1027    }
1028}
1029
1030impl std::fmt::Debug for CellRowPreview {
1031    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1032        f.debug_struct("CellRowPreview")
1033            .field("cells", &self.ids.len())
1034            .finish()
1035    }
1036}
1037
1038impl Widget for CellRowPreview {
1039    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1040        self.ids = std::mem::take(&mut self.cells)
1041            .into_iter()
1042            .map(|w| ctx.add_boxed(w))
1043            .collect();
1044        self.ids.clone()
1045    }
1046
1047    fn layout_response(
1048        &self,
1049        _proposal: SizeProposal,
1050        _ctx: &LayoutContext,
1051    ) -> teksilo_core::widget::LayoutResponse {
1052        Size::new(self.widths.iter().sum::<f32>().max(1.0), self.height).into()
1053    }
1054
1055    fn place_children(
1056        &self,
1057        bounds: Rect,
1058        _proposal: SizeProposal,
1059        children: &mut [WidgetPlacement],
1060        _ctx: &LayoutContext,
1061    ) {
1062        let mut x = bounds.x;
1063        for (i, child) in children.iter_mut().enumerate() {
1064            let w = self.widths.get(i).copied().unwrap_or(0.0);
1065            child.origin = Point::new(x, bounds.y);
1066            child.size = Size::new(w, bounds.height);
1067            x += w;
1068        }
1069    }
1070
1071    fn children(&self) -> Vec<WidgetId> {
1072        self.ids.clone()
1073    }
1074}
1075
1076/// The click half of [`EditTriggers`], as handlers for one cell.
1077///
1078/// Shared by both body panes so `TableView` and `TreeTableView` cannot drift
1079/// about what a click on an editable cell means — the drift that left the tree
1080/// table with no keyboard focus in its editors for as long as it has existed.
1081///
1082/// Returns `None` when this column opens no editor by click, so a cell that
1083/// wants neither trigger gets no handler at all — and stays out of the
1084/// gesture-arena bookkeeping entirely.
1085///
1086/// Nothing here has to suppress row activation: a node carrying a gesture arena
1087/// answers `Handled` to the press, so the bubble stops at the cell and the row's
1088/// activation never sees a click on a column that took a click trigger.
1089#[allow(clippy::too_many_arguments)]
1090pub(crate) fn cell_edit_handlers(
1091    triggers: EditTriggers,
1092    request: &Option<Rc<dyn Fn(usize, &str, &mut teksilo_core::widget::EventContext)>>,
1093    editing_cell: &Signal<Option<(usize, usize)>>,
1094    row_anchor: &crate::data_views::RowAnchor,
1095    display_pos: usize,
1096    col_id: &str,
1097) -> Option<HandlerSet> {
1098    let single = triggers.contains(EditTriggers::SINGLE_CLICK);
1099    let double = triggers.contains(EditTriggers::DOUBLE_CLICK);
1100    if !(single || double) {
1101        return None;
1102    }
1103    let request = request.clone()?;
1104
1105    // Anchored like every other row-addressed handler in a body pane: a row
1106    // that moved between build and click must not hand its edit to whoever
1107    // took its slot.
1108    let open = {
1109        let editing_cell = editing_cell.clone();
1110        let anchor = row_anchor.clone();
1111        let col_id = col_id.to_string();
1112        move |ctx: &mut teksilo_core::widget::EventContext| {
1113            if let Some(row) = anchor.index() {
1114                editing_cell.set(Some((row, display_pos)));
1115                request(row, &col_id, ctx);
1116            }
1117        }
1118    };
1119    let open = Rc::new(open);
1120
1121    let mut handlers = HandlerSet::new();
1122    if single {
1123        let open = open.clone();
1124        handlers = handlers.on_tap(move |_tap, ctx| open(ctx));
1125    }
1126    if double {
1127        // Only when `SINGLE_CLICK` is off: with both set the first click has
1128        // already opened the editor, and a second one arriving as a `DoubleTap`
1129        // would re-seed the buffer from the model — silently discarding what
1130        // the writer typed between the two clicks. (The gesture arena would not
1131        // deliver it anyway: `TapRecognizer` is skipped whenever a multi-tap
1132        // recognizer is present, so wiring both would cost the single click
1133        // instead.)
1134        if !single {
1135            handlers = handlers.on_double_tap(move |_tap, ctx| open(ctx));
1136        }
1137    }
1138    Some(handlers)
1139}
1140
1141/// Ends an open edit when a press lands on **some other cell**.
1142///
1143/// Shared by both body panes, next to [`cell_edit_handlers`], so the two tables
1144/// cannot disagree about when an edit stops.
1145///
1146/// `on_pointer_event` rather than a tap: it is not a gesture, so this adds no
1147/// recognizer to the cell and does not make it a tap owner — a cell carrying
1148/// this still selects its row on a press exactly as before. It also fires on
1149/// the press, not the release, which is what makes "click away and the value is
1150/// kept" feel immediate.
1151///
1152/// **A press inside the edited cell is not a dismissal.** Clicking into the open
1153/// field to move the caret, select a word or drag over text all land there, and
1154/// every one of them would otherwise close the editor under the pointer.
1155///
1156/// Deliberately reports the **editing** cell, not the pressed one: the owner is
1157/// being told which edit ended, and it has to be able to write that row's value
1158/// back. `EventResponse::Ignored` throughout — ending an edit is a side effect
1159/// of the press, never a reason to swallow it, so the press goes on to select
1160/// its row (or open its own editor) in the same gesture.
1161pub(crate) fn cell_edit_dismiss_handler(
1162    dismissed: &Option<Rc<dyn Fn(usize, &str, &mut teksilo_core::widget::EventContext)>>,
1163    editing_cell: &Signal<Option<(usize, usize)>>,
1164    display_col_ids: &Rc<Vec<String>>,
1165    row_anchor: &crate::data_views::RowAnchor,
1166    display_pos: usize,
1167) -> Option<HandlerSet> {
1168    let dismissed = dismissed.clone()?;
1169    let editing_cell = editing_cell.clone();
1170    let col_ids = display_col_ids.clone();
1171    let anchor = row_anchor.clone();
1172    Some(HandlerSet::new().on_pointer_event(move |event, ctx| {
1173        if let teksilo_core::event::WidgetEvent::PointerDown {
1174            button: teksilo_core::event::PointerButton::Primary,
1175            ..
1176        } = event
1177            && let Some((edit_row, edit_col)) = editing_cell.get()
1178            && (edit_col != display_pos || anchor.index() != Some(edit_row))
1179            && let Some(col_id) = col_ids.get(edit_col)
1180        {
1181            dismissed(edit_row, col_id, ctx);
1182        }
1183        teksilo_core::event::EventResponse::Ignored
1184    }))
1185}
1186
1187/// The same dismissal, mounted on the **table root** so it also catches a press
1188/// that lands on no cell at all — the empty band below the last row, the gutter
1189/// beside a short column set, the header strip.
1190///
1191/// Guarded on `press_claimed_by_interactive_child`, which here is exactly the
1192/// question "did this press belong to a control inside the table": the open
1193/// editor is a `TextInput` and owns its taps, so clicking into the field to move
1194/// the caret or drag over a word is claimed, and is not a dismissal. A press on
1195/// a plain cell is not claimed and so dismisses here as well as through its own
1196/// handler — the second call is a no-op, there being no open edit left to end.
1197pub(crate) fn root_edit_dismiss_handler(
1198    dismissed: &Option<Rc<dyn Fn(usize, &str, &mut teksilo_core::widget::EventContext)>>,
1199    editing_cell: &Signal<Option<(usize, usize)>>,
1200    display_col_ids: &Rc<Vec<String>>,
1201) -> Option<HandlerSet> {
1202    let dismissed = dismissed.clone()?;
1203    let editing_cell = editing_cell.clone();
1204    let col_ids = display_col_ids.clone();
1205    Some(HandlerSet::new().on_pointer_event(move |event, ctx| {
1206        if let teksilo_core::event::WidgetEvent::PointerDown {
1207            button: teksilo_core::event::PointerButton::Primary,
1208            ..
1209        } = event
1210            && !ctx.press_claimed_by_interactive_child()
1211            && let Some((edit_row, edit_col)) = editing_cell.get()
1212            && let Some(col_id) = col_ids.get(edit_col)
1213        {
1214            dismissed(edit_row, col_id, ctx);
1215        }
1216        teksilo_core::event::EventResponse::Ignored
1217    }))
1218}