Skip to main content

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}