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}