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