teksilo_widgets/table_view/widget_impl.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! The [`Widget`] trait implementation for [`TableView`]: build (row
5//! realization, pane assembly, pointer and drag wiring), layout, placement,
6//! paint and accessibility.
7
8use super::*;
9impl<T: 'static> Widget for TableView<T> {
10 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
11 let self_id = ctx.self_id();
12 ctx.enabled_when(self_id, self.enabled.clone());
13
14 self.adopt_style_heights(ctx);
15 let row_h = self.effective_row_height();
16 let header_h = self.effective_header_height();
17
18 // Version signal — bumps drive a rebuild.
19 let version = ctx.signal(0_u64);
20 version.bind_to(ctx.self_id(), ctx.binding_registry(), BindingLevel::Rebuild);
21
22 // Scroll-y at Relayout: place_children re-runs without rebuild.
23 self.scroll_y.bind_to(
24 ctx.self_id(),
25 ctx.binding_registry(),
26 BindingLevel::Relayout,
27 );
28 ctx.register_animated_signal(&self.scroll_y);
29
30 // Scroll-x mirrors scroll-y: Relayout re-places the header + body
31 // bands (and any pane-aware root decorations) without a rebuild.
32 self.scroll_x.bind_to(
33 ctx.self_id(),
34 ctx.binding_registry(),
35 BindingLevel::Relayout,
36 );
37 ctx.register_animated_signal(&self.scroll_x);
38
39 // Row-drop insertion indicator at RepaintOnly so on_drag_hover /
40 // on_drag_leave `set(...)` calls dirty paint without a rebuild.
41 self.drop_feedback.bind_to(
42 ctx.self_id(),
43 ctx.binding_registry(),
44 BindingLevel::RepaintOnly,
45 );
46
47 // Pane → root total refresh (auto-measure mode): re-place this
48 // root when the body pane's measurements changed the content
49 // total, so `max_scroll_y` / the thumb ratio pick up the
50 // corrected value.
51 self.pane_total_refresh.bind_to(
52 ctx.self_id(),
53 ctx.binding_registry(),
54 BindingLevel::Relayout,
55 );
56 // A handle's reveal is applied by the body pane, which binds the same
57 // request; the root binds it too, so a table with no pane (no rows)
58 // still gets the pass that drops it.
59 self.reveal.bind(ctx);
60
61 // Column width overrides: any change re-runs place_children
62 // (which calls ColumnSolver with the latest map). No rebuild
63 // needed — widths flow through `column_widths` Rc into rows.
64 self.column_widths_signal.bind_to(
65 ctx.self_id(),
66 ctx.binding_registry(),
67 BindingLevel::Relayout,
68 );
69
70 // `OnRelease` resize guide line — paint-only, nothing moves until the
71 // button comes up. (The header abandons a drag whose window goes
72 // inactive; see `TableHeader::build`.)
73 self.resize_preview_x.bind_to(
74 ctx.self_id(),
75 ctx.binding_registry(),
76 BindingLevel::RepaintOnly,
77 );
78
79 // Column order + pinning: changes require a rebuild because the
80 // header cells and row cells must be re-emitted in the new order
81 // (each cell captures its display-position-based 1-based index).
82 let v_for_order = version.clone();
83 let order_ver = Rc::new(Cell::new(0_u64));
84 ctx.effect(&self.column_order_signal, move |_| {
85 let next = order_ver.get() + 1;
86 order_ver.set(next);
87 v_for_order.set(next);
88 });
89 let v_for_pin = version.clone();
90 let pin_ver = Rc::new(Cell::new(0_u64));
91 ctx.effect(&self.column_pinning_signal, move |_| {
92 let next = pin_ver.get() + 1;
93 pin_ver.set(next);
94 v_for_pin.set(next);
95 });
96 let v_for_edit = version.clone();
97 let edit_ver = Rc::new(Cell::new(0_u64));
98 ctx.effect(&self.editing_cell, move |_| {
99 let next = edit_ver.get() + 1;
100 edit_ver.set(next);
101 v_for_edit.set(next);
102 });
103 let v_for_filter = version.clone();
104 let filter_ver = Rc::new(Cell::new(0_u64));
105 ctx.effect(&self.filters_signal, move |_| {
106 let next = filter_ver.get() + 1;
107 filter_ver.set(next);
108 v_for_filter.set(next);
109 });
110
111 // Sort signal: a change requires a rebuild because each header
112 // cell's chevron child is added/removed conditionally and the
113 // AccessKit `set_sort_direction` is captured at build time.
114 let v_for_sort = version.clone();
115 let sort_ver = Rc::new(Cell::new(0_u64));
116 ctx.effect(&self.sort_signal, move |_| {
117 let next = sort_ver.get() + 1;
118 sort_ver.set(next);
119 v_for_sort.set(next);
120 });
121
122 // Observe model changes -> bump version.
123 let v_for_data = version.clone();
124 let data_ver = Rc::new(Cell::new(0_u64));
125 let upstream = (self.observe_fn)(Box::new({
126 let dv = data_ver.clone();
127 let sel_for_adjust = self.row_selection.clone();
128 let cell_sel_for_adjust = self.cell_selection.clone();
129 let selection_mode = self.selection_mode;
130 let focused_for_data = self.focused_cell.clone();
131 let metrics_for_data = self.row_metrics.clone();
132 let len_for_data = self.len_fn.clone();
133 let first_changed = self.first_changed_fn.clone();
134 move |change| {
135 // Keep row metrics in step with the data: rows before
136 // the first changed index keep their heights, the rest
137 // re-derive. A `SortFilterListModel` source collapses
138 // everything to `Reset` — its real divergence comes
139 // through the side-channel, which is what lets an
140 // append keep the measured prefix.
141 let divergence = match change {
142 DataChange::ItemsInserted { range } | DataChange::ItemsRemoved { range } => {
143 Some(range.start)
144 }
145 DataChange::ItemUpdated { index } => Some(*index),
146 DataChange::ItemsMoved { from, to, .. } => Some((*from).min(*to)),
147 DataChange::WindowLoaded { range } => Some(range.start),
148 DataChange::Reset => (first_changed)(),
149 };
150 metrics_for_data
151 .borrow_mut()
152 .apply_divergence(divergence, (len_for_data)());
153 // Keep row selection in step: index-shift (index model) or
154 // prune orphaned keys (keyed model). Cell selection (always
155 // index-based) is adjusted separately below.
156 if let Some(ref rs) = sel_for_adjust {
157 rs.on_data_change(change);
158 }
159 if let Some(ref s) = cell_sel_for_adjust {
160 match change {
161 DataChange::ItemsInserted { range } => {
162 s.adjust_for_row_insert(range.start, range.end - range.start);
163 }
164 DataChange::ItemsRemoved { range } => {
165 s.adjust_for_row_remove(range.start, range.end - range.start);
166 }
167 DataChange::ItemsMoved { from, to, count } => {
168 s.adjust_for_row_move(*from, *to, *count);
169 }
170 DataChange::Reset => s.clear(),
171 _ => {}
172 }
173 }
174 // A keyed selection follows its row through a sort or an
175 // insert without being written, so no selection observer
176 // hears it: put the cursor back on it here.
177 crate::table_view::selection::put_the_cursor_on_the_selection(
178 selection_mode,
179 &focused_for_data,
180 sel_for_adjust.as_ref(),
181 cell_sel_for_adjust.as_ref(),
182 );
183 let next = dv.get() + 1;
184 dv.set(next);
185 v_for_data.set(next);
186 }
187 }));
188 ctx.own_handle(upstream);
189
190 // Observe selection changes -> bump version (rebuild updates the
191 // `is_selected` arg passed to cell delegates).
192 if let Some(ref rs) = self.row_selection {
193 let v_for_sel = version.clone();
194 let sel_ver = Rc::new(Cell::new(0_u64));
195 let handle = rs.observe_for_rebuild(move || {
196 let next = sel_ver.get() + 1;
197 sel_ver.set(next);
198 v_for_sel.set(next);
199 });
200 ctx.own_handle(handle);
201 }
202 if let Some(ref cs) = self.cell_selection {
203 let v_for_csel = version.clone();
204 let csel_ver = Rc::new(Cell::new(0_u64));
205 ctx.effect(&cs.selection_signal(), move |_| {
206 let next = csel_ver.get() + 1;
207 csel_ver.set(next);
208 v_for_csel.set(next);
209 });
210 }
211
212 // Observe scroll position — only rebuild when visible range exits
213 // the buffered window. The Relayout binding above handles
214 // intra-buffer scrolls without a rebuild.
215 let vp_h = self.viewport_height.clone();
216 let len_for_scroll = self.len_fn.clone();
217 let (built_start, built_end) = self.visible_range();
218 let prev_built_start = Rc::new(Cell::new(built_start));
219 let prev_built_end = Rc::new(Cell::new(built_end));
220 let v_for_scroll = version.clone();
221 let scroll_ver = Rc::new(Cell::new(0_u64));
222 let scroll_handle = self.scroll_y.observe({
223 let pbs = prev_built_start.clone();
224 let pbe = prev_built_end.clone();
225 let sv = scroll_ver.clone();
226 let metrics = self.row_metrics.clone();
227 move |y| {
228 let count = (len_for_scroll)();
229 let (visible_start, visible_end) =
230 metrics.borrow_mut().visible_range(*y, vp_h.get(), count, 0);
231 if visible_start < pbs.get() || visible_end > pbe.get() {
232 let new_start = visible_start.saturating_sub(BUFFER_ROWS);
233 let new_end = (visible_end + BUFFER_ROWS).min(count);
234 pbs.set(new_start);
235 pbe.set(new_end);
236 let next = sv.get() + 1;
237 sv.set(next);
238 v_for_scroll.set(next);
239 }
240 }
241 });
242 ctx.own_handle(scroll_handle);
243
244 // Compute display order eagerly — the keyboard handler needs
245 // the column count, and the header / body builds below also
246 // need it. We re-write `self.display_indices` here; later
247 // build steps read it.
248 let display_indices_now = self.display_order();
249
250 // Remap any `(row, display_pos)` pairs the *previous* order left in
251 // `focused_cell` / `editing_cell` / `cell_selection` onto their
252 // column's position under the order just computed, before it
253 // overwrites `self.display_indices` below. A column reorder drag or
254 // a pin toggle only bumps `version` (see the `column_order_signal` /
255 // `column_pinning_signal` effects above) — display position is
256 // recomputed here on every rebuild regardless of cause, so this map
257 // is the identity (a no-op) unless THIS rebuild's cause was an
258 // order/pinning change.
259 {
260 let old_display = self.display_indices.borrow();
261 if !old_display.is_empty() {
262 let old_to_new: Vec<Option<usize>> = old_display
263 .iter()
264 .map(|&decl_idx| {
265 let id = &self.columns[decl_idx].spec.id;
266 display_indices_now
267 .iter()
268 .position(|&new_decl_idx| self.columns[new_decl_idx].spec.id == *id)
269 })
270 .collect();
271 drop(old_display);
272 imperative::remap_cell_state(
273 &self.focused_cell,
274 &self.editing_cell,
275 self.cell_selection.as_ref(),
276 &old_to_new,
277 );
278 }
279 }
280 *self.display_indices.borrow_mut() = display_indices_now.clone();
281
282 // Self handlers: scroll wheel + keyboard + clip + focusable.
283 let line_height = row_h;
284
285 // Bind focused_cell at RepaintOnly — its update redraws the
286 // focus ring without rebuilding the row tree. Also at
287 // AccessibilityOnly (orthogonal — see `BindingLevel`) so a
288 // keyboard focus move re-walks the AT tree and re-resolves
289 // `active_descendant` in `accessibility()` below, even though
290 // nothing about the cell's own node changed.
291 self.focused_cell.bind_to(
292 ctx.self_id(),
293 ctx.binding_registry(),
294 BindingLevel::RepaintOnly,
295 );
296 self.focused_cell.bind_to(
297 ctx.self_id(),
298 ctx.binding_registry(),
299 BindingLevel::AccessibilityOnly,
300 );
301
302 // Focus-aware selection + modality-gated focus ring. `begin_view_focus`
303 // keys the scope signal on this root id directly — the same id the body
304 // pane uses for its row scope (`drag_anchor = ctx.self_id()`), and
305 // independent of the arena focusable flag (not yet wired here). A plain
306 // `view_focus_active()` here would find no focusable ancestor and fall
307 // back to the constant-`true` "outside any scope" signal — `true`
308 // whenever ANY widget holds focus, lighting every table's ring at once.
309 // The signal is `true` whenever the table or any descendant holds focus,
310 // so the selection band dims to `SelectedInactive` on focus-out. Pop
311 // straight back; the body pane re-pushes the same cached signal.
312 // `focus_visible` gates the cell ring to keyboard navigation. Both bound
313 // `RepaintOnly`: a focus/modality change redraws without a rebuild.
314 self.view_focused = ctx.begin_view_focus();
315 ctx.end_view_focus();
316 self.focus_visible = ctx.focus_visible();
317 crate::table_view::selection::follow_the_selection_in_single_mode(
318 ctx,
319 self.selection_mode,
320 &self.focused_cell,
321 self.row_selection.as_ref(),
322 self.cell_selection.as_ref(),
323 );
324 self.reveal_current_row_on_focus(ctx);
325 self.view_focused.bind_to(
326 ctx.self_id(),
327 ctx.binding_registry(),
328 BindingLevel::RepaintOnly,
329 );
330 self.focus_visible.bind_to(
331 ctx.self_id(),
332 ctx.binding_registry(),
333 BindingLevel::RepaintOnly,
334 );
335
336 // Build the navigator + key handler. The keyboard module is
337 // generic over RowNavigator so TreeTableView can plug in its own
338 // tree-aware navigator.
339 let navigator: Rc<dyn row_navigator::RowNavigator> =
340 Rc::new(row_navigator::FlatNavigator::new(self.len_fn.clone()));
341 // display_col_to_id resolves a display position back to its
342 // column id, so the keyboard module doesn't need a `Column<T>`
343 // reference. Snapshotted at build; rebuilds re-issue this.
344 let column_ids_in_display_order: Vec<String> = display_indices_now
345 .iter()
346 .map(|&i| self.columns[i].spec.id.clone())
347 .collect();
348 let display_col_to_id: Rc<dyn Fn(usize) -> Option<String>> = {
349 let ids = column_ids_in_display_order;
350 Rc::new(move |pos| ids.get(pos).cloned())
351 };
352 // The effective trigger set per display column: the view's, overridden
353 // by the column's own, and `NONE` for a non-editable one. Resolved here
354 // so the keyboard handler never has to reach a `Column<T>`.
355 let display_col_triggers: Rc<dyn Fn(usize) -> EditTriggers> = {
356 let view_triggers = self.edit_triggers;
357 let per_display_column: Vec<EditTriggers> = display_indices_now
358 .iter()
359 .map(|&i| self.columns[i].effective_edit_triggers(view_triggers))
360 .collect();
361 Rc::new(move |pos| {
362 per_display_column
363 .get(pos)
364 .copied()
365 .unwrap_or(EditTriggers::NONE)
366 })
367 };
368
369 // Type-ahead label resolver (row -> Some(text)) built from the user's
370 // `Fn(&T) -> String` + the side-effect source read: the closure only
371 // fires for a resident row, so unloaded (lazy) rows resolve to `None`
372 // and the search skips them.
373 let type_ahead_label: Option<Rc<dyn Fn(usize) -> Option<String>>> =
374 self.type_ahead_label.clone().map(|user| {
375 let with_item = self.with_item_fn.clone();
376 Rc::new(move |i: usize| {
377 let out = std::cell::RefCell::new(None);
378 (with_item)(i, &|item| {
379 *out.borrow_mut() = Some(user(item));
380 });
381 out.into_inner()
382 }) as Rc<dyn Fn(usize) -> Option<String>>
383 });
384
385 let key_cfg = keyboard::KeyHandlerConfig {
386 navigator,
387 col_count: display_indices_now.len().max(1),
388 // Flat table: no tree column exists. `FlatNavigator` reports no
389 // children and never expands, so this value is inert — it only has
390 // to be a position the cursor can actually occupy.
391 tree_column_display_pos: 0,
392 focused_cell: self.focused_cell.clone(),
393 cell_map: self.cell_map.clone(),
394 selection_mode: self.selection_mode,
395 selection: self.row_selection.clone(),
396 cell_selection: self.cell_selection.clone(),
397 scroll_y: self.scroll_y.clone(),
398 max_scroll_y: self.max_scroll_y.clone(),
399 viewport_height: self.viewport_height.clone(),
400 body_bounds: self.body_bounds.clone(),
401 row_metrics: self.row_metrics.clone(),
402 tab_traversal: self.tab_traversal,
403 editing_cell: self.editing_cell.clone(),
404 display_col_to_id,
405 display_col_triggers,
406 on_cell_edit_request: self.on_cell_edit_request.clone(),
407 on_row_activate: self.on_row_activate.clone(),
408 type_ahead: self.type_ahead.clone(),
409 type_ahead_label,
410 type_ahead_timeout: self.type_ahead_timeout,
411 column_widths: self.column_widths.clone(),
412 pane_boundaries: *self.pane_boundaries.borrow(),
413 scroll_x: self.scroll_x.clone(),
414 max_scroll_x: self.max_scroll_x.clone(),
415 middle_viewport_width: self.middle_viewport_width.clone(),
416 };
417
418 // Row DnD is owned by the backing source. The view computes the
419 // geometric (target_row, position) and asks the source: `can_accept`
420 // on hover gates the insertion line (forbidden → no affordance),
421 // `accept_drop` on release commits the move (in-place for a
422 // `ListModel`, routed for an external source). Same-view reorders and
423 // foreign / cross-table drops both flow through `accept_drop` — the
424 // erased closures recover SameView-vs-Foreign from the payload.
425 let view_id = self.model_id;
426 let can_accept_hover = self.dnd.can_accept_fn.clone();
427 let scroll_for_hover = self.scroll_y.clone();
428 let metrics_for_hover = self.row_metrics.clone();
429 let len_for_hover = self.len_fn.clone();
430 let header_h_for_hover = header_h;
431 let band_width_for_hover = self.header_strip_width.clone();
432 let feedback_for_hover = self.drop_feedback.clone();
433 let export_for_hover = self.export.clone();
434
435 let accept_drop_for_drop = self.dnd.accept_drop_fn.clone();
436 let scroll_y_for_drop = self.scroll_y.clone();
437 let header_h_for_drop = header_h;
438 let metrics_for_drop = self.row_metrics.clone();
439 let len_fn_for_drop = self.len_fn.clone();
440 let feedback_for_drop = self.drop_feedback.clone();
441 let export_for_drop = self.export.clone();
442 let reorderable_for_drop = self.reorderable;
443
444 let feedback_for_leave = self.drop_feedback.clone();
445 let scroll_for_tick = self.scroll_y.clone();
446 let max_scroll_for_tick = self.max_scroll_y.clone();
447 let viewport_for_tick = self.viewport_height.clone();
448 let header_h_for_tick = header_h;
449
450 // --- The non-drag reorder, all four routes at once ---
451 //
452 // SC 2.5.7 wants the row drag reachable without a drag. One closure
453 // performs the move; the chord below, the row's context menu and the
454 // row's AccessKit custom actions all call it, so the four routes cannot
455 // reach different end states. The move travels the source's own
456 // drop-accept path — see `common::ordered_move`.
457 let reorder_perform: Option<crate::common::ordered_move::MoveRow> =
458 self.reorderable.then(|| {
459 let mover = Rc::new(crate::common::ordered_move::RowMover {
460 len: self.len_fn.clone(),
461 stash: self.dnd.stash_drag_keys_fn.clone(),
462 payload: Rc::new(move |from: usize| {
463 teksilo_core::drag_payload::DragPayload::typed(RowDragData::<T> {
464 source: view_id,
465 rows: vec![from],
466 items: None,
467 })
468 }),
469 accept: self.dnd.accept_drop_fn.clone(),
470 view: view_id,
471 name: {
472 // The type-ahead label resolver, where the application
473 // gave one: the same string that names a row for
474 // find-as-you-type names it in the utterance.
475 let with_item = self.with_item_fn.clone();
476 let label = self.type_ahead_label.clone();
477 Rc::new(move |index: usize| {
478 let label = label.as_ref()?;
479 let out = std::cell::RefCell::new(None);
480 (with_item)(index, &|item| *out.borrow_mut() = Some(label(item)));
481 out.into_inner()
482 })
483 },
484 });
485 let sel = self.row_selection.clone();
486 let focused = self.focused_cell.clone();
487 Rc::new(
488 move |mv: crate::common::ordered_move::OrderedMove,
489 from: usize,
490 ctx: &mut teksilo_core::widget::EventContext| {
491 let Some((dest, utterance)) = mover.commit(mv, from) else {
492 return;
493 };
494 if let Some(ref s) = sel {
495 s.select(dest);
496 }
497 let col = focused.get().map(|(_, c)| c).unwrap_or(0);
498 focused.set(Some((dest, col)));
499 ctx.announce(utterance);
500 },
501 ) as crate::common::ordered_move::MoveRow
502 });
503
504 // The reorder chord wraps the shared key handler; every other key falls
505 // through to the shared navigator (cell/row movement, edit, etc.).
506 // Select all comes through the rebindable `edit.select_all`.
507 {
508 let cfg = key_cfg.clone();
509 crate::common::standard_edit::register_standard_edit_action(
510 ctx,
511 teksilo_core::shortcut::StandardEdit::SelectAll,
512 move |_ctx| keyboard::run_select_all(&cfg),
513 );
514 }
515 let mut shared_key = keyboard::build_key_handler(key_cfg);
516 let reorder_key = reorder_perform.clone();
517 let focused_kbd = self.focused_cell.clone();
518 let sel_kbd = self.row_selection.clone();
519 let len_kbd = self.len_fn.clone();
520 let key_handler = move |event: &teksilo_core::event::WidgetEvent,
521 ctx: &mut teksilo_core::widget::EventContext|
522 -> teksilo_core::event::EventResponse {
523 use teksilo_core::event::{EventResponse, WidgetEvent};
524 if let Some(ref perform) = reorder_key
525 && let WidgetEvent::KeyDown { key, modifiers, .. } = event
526 && let Some(mv) = crate::common::ordered_move::OrderedMove::from_key(
527 *key,
528 *modifiers,
529 crate::common::ordered_move::MoveAxis::Vertical,
530 ctx.is_rtl(),
531 )
532 {
533 let count = (len_kbd)();
534 let cur = focused_kbd.get().map(|(r, _)| r).or_else(|| {
535 sel_kbd
536 .as_ref()
537 .and_then(|s| s.selected_indices().first().copied())
538 });
539 if let Some(from) = cur
540 && mv.destination(from, count).is_some()
541 {
542 perform(mv, from, ctx);
543 return EventResponse::Handled;
544 }
545 }
546 shared_key(event, ctx)
547 };
548
549 // The wheel arithmetic, the pan and the claim that puts this node on a
550 // finger's claimant chain all come from `common::scrollable`. A wheel
551 // still takes the path it always did — `handle_scroll_event` branches
552 // on the scroll *source*, not the phase.
553 let mut handlers = HandlerSet::new().clips_children(true).focusable(true);
554 {
555 use crate::common::scrollable::{
556 ScrollableAxes, ScrollableBehavior, handle_scroll_event, shift_wheel_remap,
557 };
558 let axes = ScrollableAxes::new(
559 self.scroll_x.clone(),
560 self.scroll_y.clone(),
561 self.max_scroll_x.clone(),
562 self.max_scroll_y.clone(),
563 );
564 let behavior = ScrollableBehavior::new(axes.clone())
565 .with_scroller(self.scroller.clone())
566 .axes(PanAxes::BOTH)
567 .overscroll(self.overscroll_behavior)
568 .smooth(self.smooth_scrolling)
569 .smooth_duration(self.smooth_scroll_duration)
570 .line_height(line_height)
571 .reduced_motion(ctx.prefers_reduced_motion())
572 .physics(ctx.theme().input.scroll_physics);
573 // Shift+wheel scrolls the columns. The remap is a delta rewrite,
574 // which the shared handler cannot express; the `before` arm builds
575 // the rewritten event and hands it to that same handler, so the
576 // arithmetic is still written once.
577 let scroller = behavior.scroller();
578 let options = behavior.options();
579 // `Some(..)` on both arms of the remap: a remapped event is one
580 // this arm has consumed, so the shared handler must not then run
581 // on the ORIGINAL — a Shift+wheel notch whose horizontal delta the
582 // table cannot absorb (no overflow, or already at the end) would
583 // otherwise fall through and scroll the rows vertically instead.
584 // The `Ignored` inside the `Some` is still the boundary answer, so
585 // the whole original event chains outward as it should.
586 let behavior = behavior.before(move |event, ctx| {
587 shift_wheel_remap(event, ctx)
588 .map(|remapped| handle_scroll_event(&remapped, &axes, &scroller, &options, ctx))
589 });
590 handlers = behavior.install(handlers);
591 }
592
593 handlers = handlers.on_key(key_handler);
594
595 // Row-level drop target: registered only when this table can
596 // reorder its own rows or accept foreign ones (mirrors ListView).
597 // Column reorder lives entirely on the header strip
598 // (`TableHeader`'s own drop target) and is untouched by this gate.
599 if self.export.is_drop_target(self.reorderable) {
600 handlers = handlers
601 .on_drag_hover(move |payload, position, _ctx| {
602 // Column reorder is handled by the header strip; only
603 // row-level drops (same-view `RowDragData` or a foreign
604 // payload the source accepts) get an insertion line here.
605 if payload.has_typed::<ColumnReorderDragData>() {
606 feedback_for_hover.set(None);
607 return teksilo_core::DropFeedback::NoFeedback;
608 }
609 let body_y = position.y - header_h_for_hover;
610 let scroll = scroll_for_hover.get();
611 let content_y = body_y + scroll;
612 let len = (len_for_hover)();
613 let (ins, line_y) = {
614 let mut m = metrics_for_hover.borrow_mut();
615 m.resize(len);
616 let ins = m.insertion_index(content_y);
617 (ins, m.row_top(ins) - scroll)
618 };
619 let width = band_width_for_hover.get();
620 // Source-owned validation: paint the line only when the
621 // source does not reject the hovered position. A foreign
622 // exported row is allowed when `accept_foreign_rows` is on
623 // even though a bare `ListModel`'s `can_accept` rejects
624 // the `Foreign` branch.
625 let allowed = flat_insertion_target(ins, len).is_some_and(|(target, pos)| {
626 !matches!(
627 (can_accept_hover)(payload, target, pos, view_id),
628 DropResponse::Reject
629 ) || export_for_hover.accepts_foreign_export(payload, view_id)
630 });
631 if allowed {
632 feedback_for_hover.set(Some((line_y, width)));
633 teksilo_core::DropFeedback::InsertionLine { y: line_y, width }
634 } else {
635 feedback_for_hover.set(None);
636 teksilo_core::DropFeedback::NoFeedback
637 }
638 })
639 .on_drop(move |mut payload, position, ctx| {
640 feedback_for_drop.set(None);
641 if payload.has_typed::<ColumnReorderDragData>() {
642 return false;
643 }
644 let body_y = position.y - header_h_for_drop;
645 let scroll = scroll_y_for_drop.get();
646 let content_y = body_y + scroll;
647 let len = (len_fn_for_drop)();
648 let ins = {
649 let mut m = metrics_for_drop.borrow_mut();
650 m.resize(len);
651 m.insertion_index(content_y)
652 };
653 let is_same_view = payload
654 .get_typed::<RowDragData<T>>()
655 .is_some_and(|rd| rd.source == view_id);
656 // Route the drop to the source's accept_drop first. A
657 // same-view reorder only happens when the table is
658 // `reorderable`; a foreign payload is the source's
659 // call (a bare ListModel rejects it).
660 if (reorderable_for_drop || !is_same_view)
661 && let Some((target, position_kind)) = flat_insertion_target(ins, len)
662 && (accept_drop_for_drop)(&payload, target, position_kind, view_id)
663 {
664 // Only suppress our OWN move-out for a genuine
665 // same-view drop.
666 if is_same_view {
667 export_for_drop.note_self_reorder();
668 }
669 return true;
670 }
671 // Otherwise, the shared foreign-receive sugar
672 // (peek-before-take).
673 export_for_drop.foreign_receive(&mut payload, view_id, ins, ctx)
674 })
675 .on_drag_leave(move |_ctx| {
676 feedback_for_leave.set(None);
677 })
678 .on_drag_tick(move |pos, ctx| {
679 // Auto-scroll when the pointer lingers near the
680 // body band's top/bottom edge during a drag (body-relative
681 // so the header doesn't count as the top edge).
682 let body_h = (viewport_for_tick.get() - header_h_for_tick).max(0.0);
683 let y = pos.y - header_h_for_tick;
684 let band = crate::common::drag_autoscroll::band_for(ctx.pointer_kind());
685 let delta = crate::common::drag_autoscroll::step(y, body_h, band);
686 if delta.abs() > 0.01 {
687 let max = max_scroll_for_tick.get();
688 let new_y = (scroll_for_tick.get() + delta).clamp(0.0, max);
689 scroll_for_tick.set(new_y);
690 }
691 });
692 }
693
694 // Export completion (move-out): fires on the drag source — this
695 // table's root id, the stable id `start_drag` was given.
696 handlers = self.export.install_completion(handlers);
697
698 ctx.apply_self_handlers(handlers);
699
700 // ── Build children ────────────────────────────────────────────
701 self.header_row_id = None;
702 self.body_pane_id = None;
703 self.scrollbar_id = None;
704 self.h_scrollbar_id = None;
705 self.empty_id = None;
706
707 // Display order was already computed above (before the
708 // keyboard handler was wired); pull it back into a local for
709 // the header / body loops.
710 let display_indices = display_indices_now;
711
712 // Header strip: a hosted `TableHeader`. The table resolves the widths
713 // and the display order the header reads — the body needs both before
714 // the header is placed — paints the `OnRelease` resize guide across
715 // the rows, and rebuilds the header with itself.
716 if self.show_header {
717 let header = TableHeader::new(self.columns.iter().map(|c| c.spec.clone()).collect())
718 .widths(self.column_widths_signal.clone())
719 .sort(self.sort_signal.clone())
720 .order(self.column_order_signal.clone())
721 .pinning(self.column_pinning_signal.clone())
722 .filters(self.filters_signal.clone())
723 .scroll_x(self.scroll_x.clone())
724 .resize_policy(self.column_resize_policy)
725 .stretch_last_column(self.stretch_last_column)
726 .hosted(HeaderLink {
727 table_id: self.table_id,
728 widths: self.column_widths.clone(),
729 display_indices: self.display_indices.clone(),
730 pane_boundaries: self.pane_boundaries.clone(),
731 strip_width: self.header_strip_width.clone(),
732 resize_state: self.resize_state.clone(),
733 resize_target: self.resize_target.clone(),
734 resize_preview_x: self.resize_preview_x.clone(),
735 frozen_widths: self.column_widths_frozen.clone(),
736 });
737 self.header_row_id = Some(ctx.add(header));
738 }
739
740 let row_count = (self.len_fn)();
741
742 // Lazy: nudge the source to load the realized window, and fetch the
743 // next page as the viewport nears the end (append-only sources). A
744 // fully-resident source leaves these inert.
745 let (vis_start, vis_end) = self.visible_range();
746 (self.dnd.request_window_fn)(vis_start..vis_end);
747 if (self.dnd.can_fetch_more_fn)() && vis_end + BUFFER_ROWS >= row_count {
748 (self.dnd.fetch_more_fn)();
749 }
750
751 if row_count == 0 {
752 // Empty state.
753 if let Some(ref f) = self.empty_view {
754 let id = ctx.add_boxed(f());
755 self.empty_id = Some(id);
756 }
757 } else {
758 // Hoist the row pane into its own widget so that
759 // scroll-buffer-exit rebuilds (which happen mid-thumb-drag
760 // when the user scrolls past the buffered range) target a
761 // sibling of the scrollbar rather than the scrollbar's
762 // ancestor. Rebuilding the ancestor would be deferred by
763 // the framework (to preserve the captured drag), leaving
764 // the body empty until the user released the thumb.
765 let pane = body_pane::BodyPane::<T> {
766 len_fn: self.len_fn.clone(),
767 with_item_fn: self.with_item_fn.clone(),
768 drag_fn: self.dnd.drag_fn.clone(),
769 row_state_fn: self.dnd.row_state_fn.clone(),
770 columns: self.columns.clone(),
771 display_indices: self.display_indices.clone(),
772 column_widths: self.column_widths.clone(),
773 pane_boundaries: *self.pane_boundaries.borrow(),
774 scroll_x: self.scroll_x.clone(),
775 row_metrics: self.row_metrics.clone(),
776 selection_mode: self.selection_mode,
777 selection: self.row_selection.clone(),
778 cell_selection: self.cell_selection.clone(),
779 scroll_y: self.scroll_y.clone(),
780 viewport_height: self.viewport_height.clone(),
781 editing_cell: self.editing_cell.clone(),
782 focused_cell: self.focused_cell.clone(),
783 reorderable: self.reorderable,
784 reorder_perform: reorder_perform.clone(),
785 export: self.export.clone(),
786 snapshot_out_fn: self.dnd.snapshot_out_fn.clone(),
787 anchor_fn: self.anchor_fn.clone(),
788 editing_anchor: self.editing_anchor.clone(),
789 view_id: self.model_id,
790 drag_anchor: ctx.self_id(),
791 on_row_activate: self.on_row_activate.clone(),
792 activate_on: self.activate_on,
793 edit_triggers: self.edit_triggers,
794 on_cell_edit_request: self.on_cell_edit_request.clone(),
795 on_cell_edit_dismissed: self.on_cell_edit_dismissed.clone(),
796 version: self.pane_version.clone(),
797 prev_built_start: self.pane_built_start.clone(),
798 prev_built_end: self.pane_built_end.clone(),
799 total_refresh: self.pane_total_refresh.clone(),
800 row_entries: Vec::new(),
801 row_roots: Vec::new(),
802 row_map: self.row_map.clone(),
803 cell_map: self.cell_map.clone(),
804 reveal: self.reveal.clone(),
805 };
806 self.body_pane_id = Some(ctx.add(pane));
807 // An open cell editor also ends on a press that lands on no cell at
808 // all — the empty band under the last row. Mounted here rather than
809 // on the pane because the pane is not the hit target there.
810 if let Some(handlers) = body_pane::root_edit_dismiss_handler(
811 &self.on_cell_edit_dismissed,
812 &self.editing_cell,
813 &Rc::new(
814 display_indices
815 .iter()
816 .map(|&i| self.columns[i].spec.id.clone())
817 .collect::<Vec<_>>(),
818 ),
819 ) {
820 ctx.apply_self_handlers(handlers);
821 }
822 }
823
824 // Scrollbar (single internal vertical bar).
825 if self.show_internal_scrollbars {
826 let sb = ScrollBar::new(
827 ScrollBarOrientation::Vertical,
828 self.scroll_y.clone(),
829 self.max_scroll_y.clone(),
830 self.viewport_ratio_y.clone(),
831 )
832 .visual(match self.scroll_bar_style {
833 ScrollBarMode::Permanent => ScrollBarVisual::Permanent,
834 ScrollBarMode::Overlay => ScrollBarVisual::Overlay,
835 ScrollBarMode::Thin => ScrollBarVisual::Thin,
836 });
837 self.scrollbar_id = Some(ctx.add(sb));
838
839 // Horizontal bar — the Middle pane only. Visibility (max_scroll_x
840 // > 0) and geometry (band_left + pinned-pane offsets) are decided
841 // in `place_children`, same as the vertical bar's `needs_scrollbar`
842 // gate; here we just build it unconditionally so it exists to be
843 // placed (zero-sized and skipped when not needed).
844 let hsb = ScrollBar::new(
845 ScrollBarOrientation::Horizontal,
846 self.scroll_x.clone(),
847 self.max_scroll_x.clone(),
848 self.viewport_ratio_x.clone(),
849 )
850 .visual(match self.scroll_bar_style {
851 ScrollBarMode::Permanent => ScrollBarVisual::Permanent,
852 ScrollBarMode::Overlay => ScrollBarVisual::Overlay,
853 ScrollBarMode::Thin => ScrollBarVisual::Thin,
854 });
855 self.h_scrollbar_id = Some(ctx.add(hsb));
856 }
857
858 // Z-order: body rows first, then empty/scrollbar, then header
859 // last. The header band overlaps the top of the body region
860 // when `scroll_y > 0` (rows positioned at `body_origin_y +
861 // row_idx * row_h - scroll_y` can extend above
862 // `body_origin_y` on overscroll). Painting the header last
863 // means it sits on top of any row that bleeds into the
864 // header band — without this fix, scrolled-out rows would
865 // visibly draw over the header label.
866 let mut children: Vec<WidgetId> = Vec::new();
867 if let Some(id) = self.body_pane_id {
868 children.push(id);
869 }
870 if let Some(id) = self.empty_id {
871 children.push(id);
872 }
873 if let Some(id) = self.scrollbar_id {
874 children.push(id);
875 }
876 if let Some(id) = self.h_scrollbar_id {
877 children.push(id);
878 }
879 if let Some(id) = self.header_row_id {
880 children.push(id);
881 }
882 children
883 }
884
885 fn layout_response(
886 &self,
887 proposal: SizeProposal,
888 _ctx: &LayoutContext,
889 ) -> teksilo_core::widget::LayoutResponse {
890 // Only an allocation may seed the cached viewport (`common::viewport`);
891 // the body pane shares this very cell, so a measurement's fallback
892 // would desync its realization window.
893 let size = crate::common::viewport::viewport_size(
894 proposal,
895 &self.viewport_height,
896 Size::new(400.0, 300.0),
897 );
898 if proposal.height.is_some() {
899 // Viewport-relative imperatives are meaningful from here on — but
900 // only once a real height has landed, for the reason `laid_out`
901 // exists at all.
902 self.laid_out.set(true);
903 }
904 size.into()
905 }
906
907 fn place_children(
908 &self,
909 bounds: Rect,
910 _proposal: SizeProposal,
911 children: &mut [WidgetPlacement],
912 ctx: &LayoutContext,
913 ) {
914 // The rubber band's resistance is a fraction of the viewport. This
915 // view does not band, but the scroller reads the extent either way and
916 // this is the only pass that knows it.
917 self.scroller
918 .borrow_mut()
919 .set_viewport(teksilo_canvas::Vec2::new(bounds.width, bounds.height));
920 if children.is_empty() {
921 return;
922 }
923 let rtl = ctx.is_rtl();
924 let header_h = self.effective_header_height();
925 // Provisional — the vertical scrollbar's own need is decided
926 // against this (a possible tiny inaccuracy if reserving room for
927 // the horizontal bar below would itself flip that decision; not
928 // worth a fixed-point iteration for a dual-scrollbar corner case).
929 let body_height_provisional = (bounds.height - header_h).max(0.0);
930
931 // Parent-before-child layout order means this runs before the
932 // body pane's measure pass — in auto-measure mode the scrollbar
933 // totals settle one frame after a measurement change.
934 let total_height = self.total_content_height();
935 let needs_v_scrollbar =
936 self.show_internal_scrollbars && total_height > body_height_provisional + 0.5;
937 // Permanent reserves a column for the bar; Overlay / Thin float
938 // over the content, so rows span the full width.
939 let reserves_v_bar = needs_v_scrollbar && self.scroll_bar_style == ScrollBarMode::Permanent;
940 let body_width = if reserves_v_bar {
941 (bounds.width - SCROLLBAR_THICKNESS).max(0.0)
942 } else {
943 bounds.width
944 };
945 // Under RTL the vertical scrollbar moves to the physical left
946 // (matching `ScrollArea`), so the body/header band shifts right
947 // by its thickness. `band_left` is the shared origin for the
948 // body pane, empty state, and header; `scrollbar_x` is the
949 // scrollbar's own physical x. The paint pass derives the same
950 // content region from these conventions so the two never drift.
951 let band_left = if rtl && reserves_v_bar {
952 bounds.x + SCROLLBAR_THICKNESS
953 } else {
954 bounds.x
955 };
956 let scrollbar_x = if rtl {
957 bounds.x
958 } else {
959 bounds.x + bounds.width - SCROLLBAR_THICKNESS
960 };
961 // The header strip spans the band; snapshot its width for the
962 // reorder-drop handler's RTL mirror.
963 self.header_strip_width.set(body_width);
964
965 // Resolve column widths in display order, honoring any
966 // user-resize overrides from `column_widths_signal`.
967 let overrides = header::width_overrides(
968 self.column_widths_signal.get(),
969 self.column_widths_frozen.as_ref(),
970 self.columns.iter().map(|c| c.spec.id.as_str()),
971 );
972 let display = self.display_indices.borrow().clone();
973 let widths = layout::ColumnSolver::resolve_in_order(
974 &self.columns,
975 &display,
976 body_width,
977 cp::MIN_COLUMN_WIDTH_DEFAULT,
978 &overrides,
979 self.stretch_last_column,
980 );
981
982 // Pane geometry: the Middle pane's viewport (`body_width` minus the
983 // pinned panes) and the horizontal scroll headroom it implies.
984 let boundaries = *self.pane_boundaries.borrow();
985 let (leading_w, middle_content_w, trailing_w) = layout::pane_widths(&widths, boundaries);
986 let middle_viewport_w = (body_width - leading_w - trailing_w).max(0.0);
987 let max_x = (middle_content_w - middle_viewport_w).max(0.0);
988 self.max_scroll_x.set(max_x);
989 self.middle_viewport_width.set(middle_viewport_w);
990 let x_ratio = if middle_content_w > 0.0 {
991 (middle_viewport_w / middle_content_w).clamp(0.0, 1.0)
992 } else {
993 1.0
994 };
995 self.viewport_ratio_x.set(x_ratio);
996 // Clamp scroll_x — a pane shrink (window narrowed, a column grew)
997 // must not leave scroll_x stranded past the new max (mirrors
998 // `clamp_scroll` for scroll_y).
999 {
1000 let current = self.scroll_x.get();
1001 let clamped = current.clamp(0.0, max_x);
1002 if (clamped - current).abs() > 0.001 {
1003 self.scroll_x.set(clamped);
1004 }
1005 }
1006
1007 *self.column_widths.borrow_mut() = widths;
1008
1009 let needs_h_scrollbar = self.show_internal_scrollbars && max_x > 0.5;
1010 let reserves_h_bar = needs_h_scrollbar && self.scroll_bar_style == ScrollBarMode::Permanent;
1011 let body_height = if reserves_h_bar {
1012 (body_height_provisional - SCROLLBAR_THICKNESS).max(0.0)
1013 } else {
1014 body_height_provisional
1015 };
1016
1017 // Vertical scrollbar totals, against the FINAL body_height (after
1018 // any horizontal-bar reservation) so the range stays accurate when
1019 // both bars show at once.
1020 let max_y = (total_height - body_height).max(0.0);
1021 self.max_scroll_y.set(max_y);
1022 let y_ratio = if total_height > 0.0 {
1023 (body_height / total_height).clamp(0.0, 1.0)
1024 } else {
1025 1.0
1026 };
1027 self.viewport_ratio_y.set(y_ratio);
1028 self.clamp_scroll();
1029
1030 let body_origin_y = bounds.y + header_h;
1031 // Cache the row-area rect for the keyboard handler's outer-scroll chase.
1032 self.body_bounds
1033 .set(Rect::new(band_left, body_origin_y, body_width, body_height));
1034
1035 let mut next = 0;
1036
1037 // No body pane means no rows: there is nothing a handle's reveal could
1038 // bring into view, so it is dropped rather than replayed onto rows
1039 // that arrive later.
1040 if self.body_pane_id.is_none() {
1041 self.reveal.clear();
1042 }
1043
1044 // BodyPane fills the body region. It positions its rows
1045 // internally using its own scroll signal and clips them to
1046 // its own bounds.
1047 if self.body_pane_id.is_some() {
1048 if let Some(child) = children.get_mut(next) {
1049 child.origin = Point::new(band_left, body_origin_y);
1050 child.size = Size::new(body_width, body_height);
1051 }
1052 next += 1;
1053 }
1054
1055 // Empty-state child fills the body region (below the header).
1056 if self.empty_id.is_some() {
1057 if let Some(child) = children.get_mut(next) {
1058 child.origin = Point::new(band_left, body_origin_y);
1059 child.size = Size::new(body_width, body_height);
1060 }
1061 next += 1;
1062 }
1063
1064 // Scrollbar — alongside the body, below the header. Physical
1065 // left under RTL, physical right under LTR.
1066 if self.scrollbar_id.is_some() {
1067 if let Some(child) = children.get_mut(next) {
1068 if needs_v_scrollbar {
1069 child.origin = Point::new(scrollbar_x, body_origin_y);
1070 child.size = Size::new(SCROLLBAR_THICKNESS, body_height);
1071 } else {
1072 child.origin = bounds.origin();
1073 child.size = Size::ZERO;
1074 }
1075 }
1076 next += 1;
1077 }
1078
1079 // Horizontal scrollbar — the Middle pane's own band, below the
1080 // body, never overlapping a pinned pane.
1081 if self.h_scrollbar_id.is_some() {
1082 if let Some(child) = children.get_mut(next) {
1083 if needs_h_scrollbar {
1084 let h_x = if rtl {
1085 band_left + trailing_w
1086 } else {
1087 band_left + leading_w
1088 };
1089 child.origin = Point::new(h_x, body_origin_y + body_height);
1090 child.size = Size::new(middle_viewport_w, SCROLLBAR_THICKNESS);
1091 } else {
1092 child.origin = bounds.origin();
1093 child.size = Size::ZERO;
1094 }
1095 }
1096 next += 1;
1097 }
1098
1099 // Header strip last — placed at top y but emitted last so paint
1100 // z-order draws it above any overscrolled body rows.
1101 if self.header_row_id.is_some()
1102 && let Some(child) = children.get_mut(next)
1103 {
1104 child.origin = Point::new(band_left, bounds.y);
1105 child.size = Size::new(body_width, header_h);
1106 }
1107 }
1108
1109 fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
1110 let header_h = self.effective_header_height();
1111 let colors = &ctx.theme.colors;
1112
1113 let scroll_y = self.scroll_y.get();
1114 let body_origin_y = bounds.y + header_h;
1115 let body_height = (bounds.height - header_h).max(0.0);
1116 let widths = self.column_widths.borrow();
1117 let body_width = widths.iter().sum::<f32>();
1118 let body_width_for_paint = if body_width > 0.0 {
1119 body_width.min(bounds.width)
1120 } else {
1121 bounds.width
1122 };
1123 // Physical left edge of the column content. Under RTL the band is
1124 // right-aligned within `bounds` (the scrollbar took the left), so
1125 // content runs from `bounds.right() - body_width` leftward —
1126 // exactly where `place_children` reverse-placed the cells.
1127 let rtl = ctx.layout_direction == teksilo_core::environment::LayoutDirection::RightToLeft;
1128 let content_left = if rtl {
1129 bounds.x + bounds.width - body_width_for_paint
1130 } else {
1131 bounds.x
1132 };
1133
1134 // Visible row window for the paint passes — offset-table-driven
1135 // so variable heights paint correctly. One metrics borrow per
1136 // pass; nothing inside re-enters the metrics.
1137 let row_count = (self.len_fn)();
1138 let (first_visible, last_visible) =
1139 self.row_metrics
1140 .borrow_mut()
1141 .visible_range(scroll_y, body_height, row_count, 0);
1142
1143 // Clip the root-painted row decorations (alt-row stripes,
1144 // selection bands, grid lines, focus ring) to the body band.
1145 // `clips_children` only clips child WIDGETS — this widget's own
1146 // paint would otherwise bleed past the table's bottom edge for
1147 // the partially visible last row (its stripe/grid-line rect
1148 // spans the full row height).
1149 canvas.set_clip(Rect::new(
1150 content_left,
1151 body_origin_y,
1152 body_width_for_paint,
1153 body_height,
1154 ));
1155
1156 // Alt-row backgrounds — paint odd visible rows. Parity keys on
1157 // the row index, not on y, so stripes stay stable under
1158 // variable heights.
1159 if self.alternating_rows {
1160 let mut m = self.row_metrics.borrow_mut();
1161 for row_idx in first_visible..last_visible {
1162 if row_idx % 2 == 1 {
1163 let y = body_origin_y + m.row_top(row_idx) - scroll_y;
1164 let h = m.row_height(row_idx);
1165 let rect = Rect::new(content_left, y, body_width_for_paint, h);
1166 canvas.fill_rect(rect, SurfaceRole::AltRow.resolve(colors));
1167 }
1168 }
1169 }
1170
1171 // Selection highlights — row selection modes only.
1172 if let Some(ref sel) = self.row_selection
1173 && matches!(
1174 self.selection_mode,
1175 TableSelectionMode::SingleRow | TableSelectionMode::MultiRow
1176 )
1177 {
1178 // Focus- and window-aware: vivid `Selected` while the table holds
1179 // keyboard focus AND the host window is active; muted
1180 // `SelectedInactive` once focus moves elsewhere or the window goes
1181 // inactive (the same desaturation serves both states).
1182 let bg = if self.view_focused.get() && ctx.window_active {
1183 SurfaceRole::Selected.resolve(colors)
1184 } else {
1185 SurfaceRole::SelectedInactive.resolve(colors)
1186 };
1187 let mut m = self.row_metrics.borrow_mut();
1188 for row_idx in sel.selected_indices() {
1189 let y = body_origin_y + m.row_top(row_idx) - scroll_y;
1190 let h = m.row_height(row_idx);
1191 if y + h < body_origin_y || y > body_origin_y + body_height {
1192 continue;
1193 }
1194 let rect = Rect::new(content_left, y, body_width_for_paint, h);
1195 canvas.fill_rect(rect, bg);
1196 }
1197 }
1198
1199 // Grid lines.
1200 let line_color = BorderRole::Divider.resolve(colors);
1201 let line_w = cp::GRID_LINE_THICKNESS.max(1.0);
1202
1203 if matches!(self.grid_lines, GridLines::Horizontal | GridLines::Both) {
1204 let mut m = self.row_metrics.borrow_mut();
1205 for row_idx in first_visible..last_visible {
1206 let bottom = m.row_top(row_idx) + m.row_height(row_idx);
1207 let y = body_origin_y + bottom - scroll_y - line_w;
1208 let rect = Rect::new(content_left, y, body_width_for_paint, line_w);
1209 canvas.fill_rect(rect, line_color);
1210 }
1211 }
1212
1213 // Pane geometry for the two column-position-dependent decorations
1214 // below (vertical grid lines, the cell focus ring): both must clip
1215 // to the target column's OWN pane, or a scrolled Middle-pane
1216 // decoration could paint over a pinned Leading/Trailing column
1217 // within the same row band (the outer body clip above only bounds
1218 // the row's outer edges, not the seam between panes).
1219 let boundaries = *self.pane_boundaries.borrow();
1220 let scroll_x = self.scroll_x.get();
1221 let content_bounds = Rect::new(
1222 content_left,
1223 body_origin_y,
1224 body_width_for_paint,
1225 body_height,
1226 );
1227 let (leading_rect, middle_rect, trailing_rect) =
1228 layout::band_rects(content_bounds, &widths, boundaries, rtl);
1229
1230 if matches!(self.grid_lines, GridLines::Vertical | GridLines::Both) {
1231 let leading_end = boundaries.leading_count.min(widths.len());
1232 let middle_end = boundaries.middle_end.min(widths.len()).max(leading_end);
1233 draw_pane_dividers(
1234 canvas,
1235 leading_rect,
1236 &widths[..leading_end],
1237 0.0,
1238 rtl,
1239 line_color,
1240 line_w,
1241 );
1242 draw_pane_dividers(
1243 canvas,
1244 middle_rect,
1245 &widths[leading_end..middle_end],
1246 scroll_x,
1247 rtl,
1248 line_color,
1249 line_w,
1250 );
1251 draw_pane_dividers(
1252 canvas,
1253 trailing_rect,
1254 &widths[middle_end..],
1255 0.0,
1256 rtl,
1257 line_color,
1258 line_w,
1259 );
1260 }
1261
1262 // Focus ring on the currently-focused cell — keyboard-only
1263 // (`:focus-visible`) and only while the table itself holds focus, so a
1264 // mouse click never leaves a ring and an unfocused table shows none.
1265 if self.view_focused.get()
1266 && self.focus_visible.get()
1267 && let Some((focus_row, focus_col)) = self.focused_cell.get()
1268 && focus_col < widths.len()
1269 && let Some(x_off) = layout::column_logical_x(
1270 &widths,
1271 boundaries,
1272 scroll_x,
1273 body_width_for_paint,
1274 focus_col,
1275 )
1276 {
1277 let cell_w = widths[focus_col];
1278 let (focus_top, focus_h) = {
1279 let mut m = self.row_metrics.borrow_mut();
1280 (m.row_top(focus_row), m.row_height(focus_row))
1281 };
1282 let y = body_origin_y + focus_top - scroll_y;
1283 if y + focus_h >= body_origin_y && y <= body_origin_y + body_height {
1284 let pane_rect = if focus_col < boundaries.leading_count {
1285 leading_rect
1286 } else if focus_col >= boundaries.middle_end {
1287 trailing_rect
1288 } else {
1289 middle_rect
1290 };
1291 canvas.set_clip(pane_rect);
1292 let inset = cp::FOCUS_RING_INSET;
1293 let stroke = cp::GRID_LINE_THICKNESS.max(1.5);
1294 let ring_color = BorderRole::Focused.resolve(colors);
1295 // `x_off` is the leading-side offset `column_logical_x`
1296 // resolved for the column's own pane (Leading and Trailing
1297 // anchored to the band's ends, Middle shifted by `-scroll_x`).
1298 // Under RTL that offset is measured from the right edge of the
1299 // content band.
1300 let rx = if rtl {
1301 content_left + body_width_for_paint - x_off - cell_w + inset
1302 } else {
1303 content_left + x_off + inset
1304 };
1305 let ry = y + inset;
1306 let rw = (cell_w - inset * 2.0).max(0.0);
1307 let rh = (focus_h - inset * 2.0).max(0.0);
1308 // Top
1309 canvas.fill_rect(Rect::new(rx, ry, rw, stroke), ring_color);
1310 // Bottom
1311 canvas.fill_rect(Rect::new(rx, ry + rh - stroke, rw, stroke), ring_color);
1312 // Left
1313 canvas.fill_rect(Rect::new(rx, ry, stroke, rh), ring_color);
1314 // Right
1315 canvas.fill_rect(Rect::new(rx + rw - stroke, ry, stroke, rh), ring_color);
1316 canvas.clear_clip();
1317 }
1318 }
1319
1320 // Row-drop insertion indicator (source-accepted positions only —
1321 // a forbidden hover clears the signal, so no line shows). `y` is
1322 // stored body-local; the band clip is already active.
1323 if let Some((y, _width)) = self.drop_feedback.get() {
1324 let line_color = BorderRole::Focused.resolve(colors);
1325 let thickness = 2.0_f32;
1326 let line_y = body_origin_y + y - thickness * 0.5;
1327 canvas.fill_rect(
1328 Rect::new(content_left, line_y, body_width_for_paint, thickness),
1329 line_color,
1330 );
1331 }
1332
1333 canvas.clear_clip();
1334
1335 // Container focus ring — the table holds keyboard focus but nothing
1336 // indicates where: no current cell (no cell ring) and no selection (no
1337 // band). Outline the whole view so Tab has a visible landing point
1338 // before the user navigates (mirrors TreeView / ListView).
1339 let nothing_indicated = self.focused_cell.get().is_none()
1340 && self
1341 .row_selection
1342 .as_ref()
1343 .is_none_or(|s| s.selected_indices().is_empty())
1344 && self.cell_selection.as_ref().is_none_or(|s| s.count() == 0);
1345 if self.view_focused.get() && self.focus_visible.get() && nothing_indicated {
1346 let inset = 1.0_f32;
1347 let rect = Rect::new(
1348 bounds.x + inset,
1349 bounds.y + inset,
1350 (bounds.width - inset * 2.0).max(0.0),
1351 (bounds.height - inset * 2.0).max(0.0),
1352 );
1353 canvas.stroke_rect(rect, BorderRole::Focused.resolve(colors), 1.5);
1354 }
1355
1356 // `OnRelease` column-resize guide. Under that policy no column moves
1357 // until the button comes up, so this line is the *only* feedback the
1358 // gesture has — the same full-height rubber band Qt / Excel draw.
1359 if let Some(x) = self.resize_preview_x.get() {
1360 let thickness = cp::GRID_LINE_THICKNESS.max(1.5);
1361 canvas.fill_rect(
1362 Rect::new(x - thickness * 0.5, bounds.y, thickness, bounds.height),
1363 BorderRole::Focused.resolve(colors),
1364 );
1365 }
1366 }
1367
1368 /// The context-menu key opens the *current row's* menu, not the view's.
1369 ///
1370 /// A `TableView` is focusable and its rows deliberately are not — the
1371 /// container owns focus and `set_selected` is what tells assistive
1372 /// technology which row is current. So the dispatcher's default of "the
1373 /// focused widget" would open the view's own menu, in the widget family
1374 /// where a per-row menu matters most.
1375 ///
1376 /// The row the user means is the focused cell's row if they have navigated,
1377 /// else the first selected row. Only realized rows have a widget, so a
1378 /// cursor scrolled outside the virtualization window resolves to nothing
1379 /// and the menu falls back to the view — right, because there is no row on
1380 /// screen for it to be about.
1381 fn context_menu_key_target(&self) -> Option<WidgetId> {
1382 let index = self.focused_cell.get().map(|(row, _col)| row).or_else(|| {
1383 self.row_selection
1384 .as_ref()
1385 .and_then(|s| s.selected_indices().first().copied())
1386 })?;
1387 let map = self.row_map.borrow();
1388 map.iter().find(|(i, _)| *i == index).map(|(_, id)| *id)
1389 }
1390
1391 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1392 // `Role::Grid` for a table the keyboard can drive, `Role::Table` for
1393 // one that is pure structure.
1394 //
1395 // Not a semantic nicety: `accesskit_consumer`'s
1396 // `is_container_with_selectable_children` — the sole gate on UIA's
1397 // `ISelectionProvider` — lists `Grid`, `ListBox`, `ListGrid`, `Tree`
1398 // and `TreeGrid`, and **not** `Table`. Announced as a table, a
1399 // multi-select `TableView` exposed no `CanSelectMultiple` and no
1400 // `GetSelection` to Narrator, NVDA or JAWS at all. The two roles map
1401 // identically on macOS (`NSAccessibilityTableRole`) and AT-SPI
1402 // (`AtspiRole::Table`), so this is a Windows fix that costs nothing
1403 // elsewhere.
1404 //
1405 // `Role::ListGrid` looks like the closer match — its own doc says it
1406 // exists for Chromium's `TableView` — but `accesskit_macos` maps it to
1407 // `NSAccessibilityUnknownRole`, which is worse than either.
1408 builder.set_role(if self.selection_mode == TableSelectionMode::None {
1409 teksilo_core::accesskit::Role::Table
1410 } else {
1411 teksilo_core::accesskit::Role::Grid
1412 });
1413 if let Some(ref label) = self.a11y_label {
1414 builder.set_name(label.resolve_now());
1415 }
1416 // AccessKit's `row_count` includes the header row when present —
1417 // matches ARIA `aria-rowcount` semantics.
1418 let row_count = (self.len_fn)() + if self.show_header { 1 } else { 0 };
1419 let col_count = self.columns.len();
1420 let n = builder.inner_mut();
1421 n.set_row_count(row_count);
1422 n.set_column_count(col_count);
1423
1424 // Roving focus: point active_descendant at the focused cell's own
1425 // AT node so a screen reader follows arrow-key cell navigation
1426 // (only the table root is otherwise focusable — the ring is
1427 // visual-only). `cell_map` is a snapshot of the body pane's last
1428 // realized cells; a focused cell that scrolled out of the
1429 // realized buffer simply isn't in it, so no stale id is emitted.
1430 if let Some(target) = self.focused_cell.get() {
1431 let map = self.cell_map.borrow();
1432 if let Some(&(_, cell_id)) = map.iter().find(|&&(pos, _)| pos == target) {
1433 builder.set_active_descendant(widget_id_to_node_id(cell_id));
1434 }
1435 }
1436 }
1437
1438 fn as_any(&self) -> Option<&dyn std::any::Any> {
1439 Some(self)
1440 }
1441
1442 fn children(&self) -> Vec<WidgetId> {
1443 // Same order as `build()` — body pane first, header last so
1444 // it paints on top of any overscrolled rows.
1445 let mut out: Vec<WidgetId> = Vec::new();
1446 if let Some(id) = self.body_pane_id {
1447 out.push(id);
1448 }
1449 if let Some(id) = self.empty_id {
1450 out.push(id);
1451 }
1452 if let Some(id) = self.scrollbar_id {
1453 out.push(id);
1454 }
1455 if let Some(id) = self.h_scrollbar_id {
1456 out.push(id);
1457 }
1458 if let Some(id) = self.header_row_id {
1459 out.push(id);
1460 }
1461 out
1462 }
1463
1464 fn accessibility_children(&self) -> Option<Vec<WidgetId>> {
1465 // WCAG 1.3.2 (audit G17): read the column-header row FIRST, then the
1466 // body, even though `build()` / `children()` list the body first so it
1467 // paints beneath the header. Same id set as `children()`, reordered.
1468 let out: Vec<WidgetId> = [
1469 self.header_row_id,
1470 self.body_pane_id,
1471 self.empty_id,
1472 self.scrollbar_id,
1473 self.h_scrollbar_id,
1474 ]
1475 .into_iter()
1476 .flatten()
1477 .collect();
1478 if out.is_empty() { None } else { Some(out) }
1479 }
1480
1481 fn clips_children(&self) -> bool {
1482 true
1483 }
1484}