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}