Skip to main content

teksilo_widgets/tree_table_view/
widget_impl.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! The [`Widget`] trait implementation for [`TreeTableView`]: build (row
5//! realization, pane assembly, pointer and drag wiring), layout, placement,
6//! paint and accessibility.
7
8use super::*;
9impl<T: 'static> Widget for TreeTableView<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        let row_h = self.effective_row_height();
15        let header_h = self.effective_header_height();
16        let indent_per_level = self.effective_indent();
17
18        let version = ctx.signal(0_u64);
19        version.bind_to(ctx.self_id(), ctx.binding_registry(), BindingLevel::Rebuild);
20
21        self.scroll_y.bind_to(
22            ctx.self_id(),
23            ctx.binding_registry(),
24            BindingLevel::Relayout,
25        );
26        ctx.register_animated_signal(&self.scroll_y);
27
28        self.scroll_x.bind_to(
29            ctx.self_id(),
30            ctx.binding_registry(),
31            BindingLevel::Relayout,
32        );
33        ctx.register_animated_signal(&self.scroll_x);
34
35        // Pane → root total refresh (auto-measure mode): re-place this
36        // root when the body pane's measurements changed the content
37        // total, so `max_scroll_y` / the thumb ratio pick up the
38        // corrected value.
39        self.pane_total_refresh.bind_to(
40            ctx.self_id(),
41            ctx.binding_registry(),
42            BindingLevel::Relayout,
43        );
44
45        self.column_widths_signal.bind_to(
46            ctx.self_id(),
47            ctx.binding_registry(),
48            BindingLevel::Relayout,
49        );
50        // `OnRelease` resize guide line — paint-only, nothing moves until the
51        // button comes up.
52        self.resize_preview_x.bind_to(
53            ctx.self_id(),
54            ctx.binding_registry(),
55            BindingLevel::RepaintOnly,
56        );
57
58        // Abandon an in-flight resize when the window goes inactive — see
59        // `TableView::build` for why the missing PointerUp would otherwise
60        // leave the column dragging with no button held.
61        {
62            let resize_state = self.resize_state.clone();
63            let resize_target = self.resize_target.clone();
64            let resize_preview_x = self.resize_preview_x.clone();
65            ctx.effect(&ctx.window_active_signal(), move |active| {
66                if !*active && resize_state.borrow().is_some() {
67                    *resize_state.borrow_mut() = None;
68                    resize_target.set(None);
69                    resize_preview_x.set(None);
70                }
71            });
72        }
73        self.focused_cell.bind_to(
74            ctx.self_id(),
75            ctx.binding_registry(),
76            BindingLevel::RepaintOnly,
77        );
78        // Also at AccessibilityOnly (orthogonal — see `BindingLevel`) so a
79        // keyboard focus move re-walks the AT tree and re-resolves
80        // `active_descendant` in `accessibility()` below, even though
81        // nothing about the cell's own node changed.
82        self.focused_cell.bind_to(
83            ctx.self_id(),
84            ctx.binding_registry(),
85            BindingLevel::AccessibilityOnly,
86        );
87
88        // Focus-aware selection + modality-gated focus ring (mirrors TableView).
89        // `begin_view_focus` keys the scope signal on this root id directly —
90        // the same id the body pane uses for its row scope, and independent of
91        // the arena focusable flag (not yet wired here). A plain
92        // `view_focus_active()` would find no focusable ancestor and fall back
93        // to the constant-`true` "outside any scope" signal, lighting the ring
94        // whenever ANY widget takes focus. Pop straight back; the body pane
95        // re-pushes the same cached signal. `focus_visible` is the
96        // keyboard/pointer modality. Both `RepaintOnly`.
97        self.view_focused = ctx.begin_view_focus();
98        ctx.end_view_focus();
99        self.focus_visible = ctx.focus_visible();
100        self.reveal_current_row_on_focus(ctx);
101        self.view_focused.bind_to(
102            ctx.self_id(),
103            ctx.binding_registry(),
104            BindingLevel::RepaintOnly,
105        );
106        self.focus_visible.bind_to(
107            ctx.self_id(),
108            ctx.binding_registry(),
109            BindingLevel::RepaintOnly,
110        );
111        // Row-drop insertion indicator at RepaintOnly so on_drag_hover /
112        // on_drag_leave `set(...)` calls dirty paint without a rebuild.
113        self.drop_feedback.bind_to(
114            ctx.self_id(),
115            ctx.binding_registry(),
116            BindingLevel::RepaintOnly,
117        );
118
119        // Bump version on projection version (data + sort/filter +
120        // expand/collapse all in one signal). Proxy observers fire
121        // synchronously per rebuild, so `first_changed_index()`
122        // describes exactly this change — heights of flat rows before
123        // it (e.g. above an expand/collapse point) stay valid.
124        let v_for_proj = version.clone();
125        let proj_ver = Rc::new(Cell::new(0_u64));
126        let prev_visible_count = Rc::new(Cell::new(self.source.visible_count()));
127        ctx.effect(&self.source.version_signal(), {
128            let metrics = self.row_metrics.clone();
129            let src = self.source.clone();
130            let row_sel = self.row_selection.clone();
131            let cell_sel = self.cell_selection.clone();
132            let prev_visible_count = prev_visible_count.clone();
133            move |_| {
134                metrics
135                    .borrow_mut()
136                    .apply_divergence(src.first_changed_index(), src.visible_count());
137                // Drop any keyed selection whose node was deleted (no-op for
138                // the index model). Cheap; runs on every projection change.
139                if let Some(ref rs) = row_sel {
140                    rs.prune();
141                }
142                // Cell selection is index-based (unlike the keyed row
143                // selection above), and a `TreeDataSource`'s flattening
144                // collapses every structural change — expand/collapse,
145                // insert/remove, a re-sort — into one version bump with no
146                // per-change delta to follow, unlike `TableView`'s
147                // `ListModel` `DataChange` granularity. A changed visible
148                // row count is a structural signal we CAN act on
149                // honestly: clear the selection rather than let it point
150                // at whatever node now occupies that flat index. Leave it
151                // alone when the count is unchanged — a content-only
152                // update (e.g. an in-place item edit) never moves a row,
153                // and clearing on every projection bump would drop the
154                // selection on a plain data refresh.
155                let new_visible_count = src.visible_count();
156                if let Some(ref cs) = cell_sel
157                    && new_visible_count != prev_visible_count.get()
158                {
159                    cs.clear();
160                }
161                prev_visible_count.set(new_visible_count);
162                let next = proj_ver.get() + 1;
163                proj_ver.set(next);
164                v_for_proj.set(next);
165            }
166        });
167
168        // Sort + filter signals are NOT auto-bound onto the proxy.
169        // The proxy may already carry preset comparators/predicates
170        // and a custom filter mode; auto-binding would clobber them.
171        // Callers wire the proxy explicitly:
172        //
173        //   proxy.sort_signal(tree_table.sort_signal().clone());
174        //   proxy.filters_signal(tree_table.filters_signal().clone());
175        //
176        // Documented in the module-level comment.
177
178        let v_for_sort = version.clone();
179        let sv = Rc::new(Cell::new(0_u64));
180        ctx.effect(&self.sort_signal, move |_| {
181            let next = sv.get() + 1;
182            sv.set(next);
183            v_for_sort.set(next);
184        });
185        let v_for_order = version.clone();
186        let ov = Rc::new(Cell::new(0_u64));
187        ctx.effect(&self.column_order_signal, move |_| {
188            let next = ov.get() + 1;
189            ov.set(next);
190            v_for_order.set(next);
191        });
192        let v_for_pin = version.clone();
193        let pv = Rc::new(Cell::new(0_u64));
194        ctx.effect(&self.column_pinning_signal, move |_| {
195            let next = pv.get() + 1;
196            pv.set(next);
197            v_for_pin.set(next);
198        });
199        // Selection / focus / editing effects live on the TreeBodyPane
200        // (they only affect row content) — rebuilding the pane instead
201        // of the root keeps those rebuilds out of the scrollbar's
202        // ancestor chain during a thumb drag.
203
204        // Display order.
205        let display_indices = self.display_order();
206
207        // Remap any `(row, display_pos)` pairs the *previous* order left in
208        // `focused_cell` / `editing_cell` / `cell_selection` onto their
209        // column's position under the order just computed, before it
210        // overwrites `self.display_indices` below. See the identical block
211        // in `TableView::build` for why this is a no-op unless THIS
212        // rebuild's cause was a column reorder/pinning change.
213        {
214            let old_display = self.display_indices.borrow();
215            if !old_display.is_empty() {
216                let old_to_new: Vec<Option<usize>> = old_display
217                    .iter()
218                    .map(|&decl_idx| {
219                        let id = &self.columns[decl_idx].id;
220                        display_indices
221                            .iter()
222                            .position(|&new_decl_idx| self.columns[new_decl_idx].id == *id)
223                    })
224                    .collect();
225                drop(old_display);
226                imperative::remap_cell_state(
227                    &self.focused_cell,
228                    &self.editing_cell,
229                    self.cell_selection.as_ref(),
230                    &old_to_new,
231                );
232            }
233        }
234        *self.display_indices.borrow_mut() = display_indices.clone();
235        let tree_decl = self.tree_column_decl_index();
236        let tree_display_pos = display_indices
237            .iter()
238            .position(|&i| i == tree_decl)
239            .unwrap_or(0);
240
241        // Self handlers: scroll wheel + keyboard.
242        let line_height = row_h;
243
244        let column_ids_in_display_order: Vec<String> = display_indices
245            .iter()
246            .map(|&i| self.columns[i].id.clone())
247            .collect();
248        let display_col_to_id: Rc<dyn Fn(usize) -> Option<String>> = {
249            let ids = column_ids_in_display_order;
250            Rc::new(move |pos| ids.get(pos).cloned())
251        };
252        // The effective trigger set per display column: the view's, overridden
253        // by the column's own, and `NONE` for a non-editable one. Resolved here
254        // so the keyboard handler never has to reach a `Column<T>`.
255        let display_col_triggers: Rc<dyn Fn(usize) -> EditTriggers> = {
256            let view_triggers = self.edit_triggers;
257            let per_display_column: Vec<EditTriggers> = display_indices
258                .iter()
259                .map(|&i| self.columns[i].effective_edit_triggers(view_triggers))
260                .collect();
261            Rc::new(move |pos| {
262                per_display_column
263                    .get(pos)
264                    .copied()
265                    .unwrap_or(EditTriggers::NONE)
266            })
267        };
268
269        let navigator: Rc<dyn RowNavigator> = Rc::new(TreeNavigator::new(self.source.clone()));
270        // Type-ahead resolver: read the visible row's item text through the
271        // projection (`None` if the flat index isn't currently visible).
272        let type_ahead_label: Option<Rc<dyn Fn(usize) -> Option<String>>> =
273            self.type_ahead_label.clone().map(|user| {
274                let src = self.source.clone();
275                Rc::new(move |i: usize| src.with_row_str(i, &|item| user(item)))
276                    as Rc<dyn Fn(usize) -> Option<String>>
277            });
278
279        let key_cfg = keyboard::KeyHandlerConfig {
280            navigator,
281            col_count: display_indices.len().max(1),
282            // The same resolved position the twist and indent gutter render at
283            // (see `tree_display_pos` above), so the arrow keys keep following
284            // the chevron when `.tree_column()` or a user column-reorder moves
285            // it off the leading position.
286            tree_column_display_pos: tree_display_pos,
287            focused_cell: self.focused_cell.clone(),
288            cell_map: self.cell_map.clone(),
289            selection_mode: self.selection_mode,
290            selection: self.row_selection.clone(),
291            cell_selection: self.cell_selection.clone(),
292            scroll_y: self.scroll_y.clone(),
293            max_scroll_y: self.max_scroll_y.clone(),
294            viewport_height: self.viewport_height.clone(),
295            body_bounds: self.body_bounds.clone(),
296            row_metrics: self.row_metrics.clone(),
297            tab_traversal: self.tab_traversal,
298            editing_cell: self.editing_cell.clone(),
299            display_col_to_id,
300            display_col_triggers,
301            on_cell_edit_request: self.on_cell_edit_request.clone(),
302            on_row_activate: self.on_row_activate.clone(),
303            type_ahead: self.type_ahead.clone(),
304            type_ahead_label,
305            type_ahead_timeout: self.type_ahead_timeout,
306            column_widths: self.column_widths.clone(),
307            pane_boundaries: *self.pane_boundaries.borrow(),
308            scroll_x: self.scroll_x.clone(),
309            max_scroll_x: self.max_scroll_x.clone(),
310            middle_viewport_width: self.middle_viewport_width.clone(),
311        };
312
313        // --- The non-drag reorder, all four routes at once ---
314        //
315        // The row drag moves a node among its siblings and reparents it; both
316        // are committed through the source's own `accept_drop` (cycle-free by
317        // construction) and both closures are shared by the chord below, the
318        // row's context menu and the row's AccessKit custom actions — see
319        // `common::ordered_move`. Suppressed while sorted, because a sorted view
320        // is not showing the model's order and moving a row in it would say
321        // nothing about where the row went.
322        let (reorder_perform, reparent_perform) = if self.reorderable {
323            let follow: Rc<dyn Fn(usize)> = {
324                let focused = self.focused_cell.clone();
325                let sel = self.row_selection.clone();
326                Rc::new(move |new_flat: usize| {
327                    let col = focused.get().map(|(_, c)| c).unwrap_or(0);
328                    focused.set(Some((new_flat, col)));
329                    if let Some(ref s) = sel {
330                        s.select(new_flat);
331                    }
332                })
333            };
334            let name_of: Rc<dyn Fn(usize) -> Option<String>> = {
335                let source = self.source.clone();
336                let label = self.type_ahead_label.clone();
337                Rc::new(move |index: usize| {
338                    let label = label.as_ref()?;
339                    source.with_row_str(index, &|item| label(item))
340                })
341            };
342            let sibling = {
343                let source = self.source.clone();
344                let follow = follow.clone();
345                let name_of = name_of.clone();
346                let sort = self.sort_signal.clone();
347                Rc::new(
348                    move |mv: crate::common::ordered_move::OrderedMove,
349                          flat: usize,
350                          ctx: &mut EventContext| {
351                        if sort.get().is_some() {
352                            return;
353                        }
354                        let name = (name_of)(flat);
355                        let Some(new_flat) = source.sibling_move(flat, mv) else {
356                            return;
357                        };
358                        follow(new_flat);
359                        let (pos, size) = source.sibling_position(new_flat);
360                        ctx.announce(crate::common::ordered_move::move_announcement(
361                            name.as_deref(),
362                            pos.saturating_sub(1),
363                            size,
364                        ));
365                    },
366                ) as crate::common::ordered_move::MoveRow
367            };
368            let reparent = {
369                let source = self.source.clone();
370                let follow = follow.clone();
371                let sort = self.sort_signal.clone();
372                Rc::new(
373                    move |mv: crate::common::ordered_move::TreeMove,
374                          flat: usize,
375                          ctx: &mut EventContext| {
376                        if sort.get().is_some() {
377                            return;
378                        }
379                        let name = (name_of)(flat);
380                        let Some(new_flat) = source.reparent(flat, mv) else {
381                            return;
382                        };
383                        follow(new_flat);
384                        let level = source.meta(new_flat).map_or(1, |m| m.depth + 1);
385                        ctx.announce(crate::common::ordered_move::reparent_announcement(
386                            name.as_deref(),
387                            level,
388                        ));
389                    },
390                ) as crate::common::ordered_move::TreeReparentRow
391            };
392            (Some(sibling), Some(reparent))
393        } else {
394            (None, None)
395        };
396
397        // The reorder chords wrap the shared key handler; every other key falls
398        // through to the navigator (cell/row movement, expand/collapse, edit) —
399        // including `⌥→` / `⌥←` on macOS, which the reparent decoder declines
400        // there because they already expand a whole subtree.
401        let mut shared_key = keyboard::build_key_handler(key_cfg);
402        let reorder_key = reorder_perform.clone();
403        let reparent_key = reparent_perform.clone();
404        let focused_kbd = self.focused_cell.clone();
405        let sel_kbd = self.row_selection.clone();
406        let key_handler = move |event: &teksilo_core::event::WidgetEvent,
407                                ctx: &mut EventContext|
408              -> EventResponse {
409            use teksilo_core::event::WidgetEvent;
410            if let Some(ref sibling) = reorder_key
411                && let Some(ref reparent) = reparent_key
412                && let WidgetEvent::KeyDown { key, modifiers, .. } = event
413                // `command()` as well as `alt()`: the reparent's portable
414                // spelling is the accelerator plus `]` / `[`.
415                && (modifiers.alt() || modifiers.command())
416            {
417                let row = focused_kbd.get().map(|(r, _)| r).or_else(|| {
418                    sel_kbd
419                        .as_ref()
420                        .and_then(|s| s.selected_indices().first().copied())
421                });
422                if let Some(flat_idx) = row {
423                    if let Some(mv) = crate::common::ordered_move::OrderedMove::from_key(
424                        *key,
425                        *modifiers,
426                        crate::common::ordered_move::MoveAxis::Vertical,
427                        ctx.is_rtl(),
428                    ) {
429                        sibling(mv, flat_idx, ctx);
430                        return EventResponse::Handled;
431                    }
432                    if let Some(mv) = crate::common::ordered_move::TreeMove::from_key(
433                        *key,
434                        *modifiers,
435                        ctx.is_rtl(),
436                    ) {
437                        reparent(mv, flat_idx, ctx);
438                        return EventResponse::Handled;
439                    }
440                }
441            }
442            shared_key(event, ctx)
443        };
444
445        // The wheel arithmetic, the pan and the claim that puts this node on a
446        // finger's claimant chain all come from `common::scrollable`. A wheel
447        // still takes the path it always did — `handle_scroll_event` branches
448        // on the scroll *source*, not the phase.
449        let mut handlers = HandlerSet::new()
450            .on_key(key_handler)
451            .clips_children(true)
452            .focusable(true);
453        {
454            use crate::common::scrollable::{
455                ScrollableAxes, ScrollableBehavior, handle_scroll_event, shift_wheel_remap,
456            };
457            let axes = ScrollableAxes::new(
458                self.scroll_x.clone(),
459                self.scroll_y.clone(),
460                self.max_scroll_x.clone(),
461                self.max_scroll_y.clone(),
462            );
463            let behavior = ScrollableBehavior::new(axes.clone())
464                .with_scroller(self.scroller.clone())
465                .axes(PanAxes::BOTH)
466                .overscroll(self.overscroll_behavior)
467                .smooth(self.smooth_scrolling)
468                .smooth_duration(self.smooth_scroll_duration)
469                .line_height(line_height)
470                .reduced_motion(ctx.prefers_reduced_motion())
471                .physics(ctx.theme().input.scroll_physics);
472            // Shift+wheel scrolls the columns. The remap is a delta rewrite,
473            // which the shared handler cannot express; the `before` arm builds
474            // the rewritten event and hands it to that same handler, so the
475            // arithmetic is still written once.
476            let scroller = behavior.scroller();
477            let options = behavior.options();
478            // `Some(..)` on both arms of the remap: a remapped event is one
479            // this arm has consumed, so the shared handler must not then run
480            // on the ORIGINAL — a Shift+wheel notch whose horizontal delta the
481            // table cannot absorb (no overflow, or already at the end) would
482            // otherwise fall through and scroll the rows vertically instead.
483            // The `Ignored` inside the `Some` is still the boundary answer, so
484            // the whole original event chains outward as it should.
485            let behavior = behavior.before(move |event, ctx| {
486                shift_wheel_remap(event, ctx)
487                    .map(|remapped| handle_scroll_event(&remapped, &axes, &scroller, &options, ctx))
488            });
489            handlers = behavior.install(handlers);
490        }
491
492        // Row DnD: same-view reorder (reorderable) reparents/reorders the
493        // dragged node(s) in the underlying `TreeModel`, cycle-guarded and
494        // suppressed while sorted; plus optional foreign receive
495        // (accept_foreign_rows / on_foreign_drop). Registered whenever ANY
496        // of the three capabilities is enabled — a foreign-receive-only view
497        // (reorderable == false) still needs to be a drop target.
498        // NOTE: row DnD is still `NodeId`-typed, so it is registered only on the
499        // projection path. A source-backed view (`from_source`) gets every other
500        // capability but no built-in row drag yet — routing this through
501        // `source.dnd.{can_accept,accept_drop}_fn` (as `TreeView` already does)
502        // is a follow-up, because those closures also carry Into/Before/After
503        // redirect semantics this widget does not model yet.
504        // Row DnD: same-view reorder/reparent plus foreign receive, both routed
505        // through the source's `can_accept` / `accept_drop` capability closures
506        // — so this works over a `TreeModel`-backed projection AND an external
507        // `TreeDataSource`, exactly like `TreeView`. Drop zones are the row's
508        // thirds (Before / Into / After); the source's verdict decides the
509        // effective position and may `Redirect` (e.g. Into-a-leaf becomes
510        // After). Suppressed while sorted, where a manual order has no meaning.
511        if self.export.is_drop_target(self.reorderable) || self.on_foreign_drop.is_some() {
512            let my_model_id = self.model_id;
513            let source_for_hover = self.source.clone();
514            let metrics_for_hover = self.row_metrics.clone();
515            let scroll_for_hover = self.scroll_y.clone();
516            let header_h_for_hover = header_h;
517            let feedback_for_hover = self.drop_feedback.clone();
518            let sort_for_hover = self.sort_signal.clone();
519            let reorderable_hover = self.reorderable;
520            let export_for_hover = self.export.clone();
521            let has_foreign_hook_hover = self.on_foreign_drop.is_some();
522            let bounds_for_hover = self.body_bounds.clone();
523            handlers = handlers.on_drag_hover(move |payload, position, ctx| {
524                // Column reorder is handled by the header strip
525                // (`attach_header_reorder_handlers`); only row-level drops
526                // get an insertion/into affordance here. Without this bail,
527                // a `ColumnReorderDragData` dragged past the header into the
528                // body would fall through to `on_foreign_drop` (which
529                // accepts any payload type) and paint a row-drop visual for
530                // a drag the header strip is already handling.
531                if payload.has_typed::<ColumnReorderDragData>() {
532                    feedback_for_hover.set(None);
533                    return teksilo_core::DropFeedback::NoFeedback;
534                }
535                // Real body width, so the affordance spans the actual row area
536                // rather than a placeholder.
537                let viz_width = bounds_for_hover.get().width.max(1.0);
538                let count = source_for_hover.visible_count();
539                if count == 0 {
540                    feedback_for_hover.set(None);
541                    return teksilo_core::DropFeedback::NoFeedback;
542                }
543                let rd = payload.get_typed::<RowDragData<T>>();
544                let is_same_view = rd.is_some_and(|r| r.source == my_model_id);
545                let reorder_ok =
546                    is_same_view && reorderable_hover && sort_for_hover.get().is_none();
547                // The typed `accept_foreign_rows`/`on_rows_received` path can
548                // only consume an EXPORT payload (items present); the raw
549                // `on_foreign_drop` hook takes any foreign payload.
550                let foreign_ok = !is_same_view
551                    && (has_foreign_hook_hover
552                        || export_for_hover.accepts_foreign_export(payload, my_model_id));
553                if !reorder_ok && !foreign_ok {
554                    feedback_for_hover.set(None);
555                    return teksilo_core::DropFeedback::NoFeedback;
556                }
557                let scroll = scroll_for_hover.get().max(0.0);
558                let content_y = position.y - header_h_for_hover + scroll;
559                let (insertion_top, row_idx, row_top, row_h) = {
560                    let mut m = metrics_for_hover.borrow_mut();
561                    m.resize(count);
562                    let ins = m.insertion_index(content_y);
563                    let r = m.row_at(content_y);
564                    (m.row_top(ins), r, m.row_top(r), m.row_height(r))
565                };
566                // Before / Into / After from the y within the row. The bands
567                // are plain thirds for a cursor and widen at the edges for a
568                // finger — `common::drop_bands` owns the rule, and the hover
569                // affordance and the drop itself both read it, so the line the
570                // user sees cannot promise a position the drop does not take.
571                let y_in_row = content_y - row_top;
572                let drop_pos = crate::common::drop_bands::drop_position_in_row(
573                    y_in_row,
574                    row_h,
575                    ctx.pointer_kind(),
576                );
577                // The source owns the structural verdict — including the cycle
578                // guard (a node may not land inside its own subtree), which used
579                // to be re-derived here against the `TreeModel`.
580                // `depth` rides along so `paint` can indent the affordance to
581                // the level the dropped row lands at — see `TreeView`'s twin of
582                // this block. A foreign drop lands at a flat index the view
583                // cannot promise a nesting for, so it claims none: depth 0.
584                let (effective, depth) = if reorder_ok {
585                    match (source_for_hover.dnd.can_accept_fn)(
586                        payload,
587                        row_idx,
588                        drop_pos,
589                        my_model_id,
590                    ) {
591                        DropResponse::Reject => {
592                            if !foreign_ok {
593                                feedback_for_hover.set(None);
594                                return teksilo_core::DropFeedback::NoFeedback;
595                            }
596                            (DropPosition::Before, 0)
597                        }
598                        DropResponse::Accept => (drop_pos, source_for_hover.depth(row_idx)),
599                        DropResponse::Redirect(p) => (p, source_for_hover.depth(row_idx)),
600                    }
601                } else {
602                    // A foreign source has no Into/reparent semantics to honor.
603                    (DropPosition::Before, 0)
604                };
605                if effective == DropPosition::Into {
606                    let top = row_top - scroll;
607                    feedback_for_hover.set(Some(DropViz::Rect {
608                        top,
609                        height: row_h,
610                        width: viz_width,
611                        depth,
612                    }));
613                    teksilo_core::DropFeedback::HighlightRect {
614                        rect: Rect::new(0.0, top, viz_width, row_h),
615                        color: drop_into_tint(),
616                    }
617                } else {
618                    let insertion_y = insertion_top - scroll;
619                    feedback_for_hover.set(Some(DropViz::Line {
620                        y: insertion_y,
621                        width: viz_width,
622                        depth,
623                    }));
624                    teksilo_core::DropFeedback::InsertionLine {
625                        y: insertion_y,
626                        width: viz_width,
627                    }
628                }
629            });
630
631            let drop_model_id = self.model_id;
632            let source_for_drop = self.source.clone();
633            let metrics_for_drop = self.row_metrics.clone();
634            let scroll_for_drop = self.scroll_y.clone();
635            let header_h_for_drop = header_h;
636            let feedback_for_drop = self.drop_feedback.clone();
637            let sort_for_drop = self.sort_signal.clone();
638            let reorderable_drop = self.reorderable;
639            let on_foreign_for_drop = self.on_foreign_drop.clone();
640            let proxy_for_foreign_hook = self.proxy.clone();
641            let export_for_drop = self.export.clone();
642            handlers = handlers.on_drop(move |mut payload, position, ctx| {
643                feedback_for_drop.set(None);
644                // See the matching bail in `on_drag_hover` above — a column
645                // reorder drop is the header strip's, never the body's
646                // (`on_foreign_drop` would otherwise swallow it).
647                if payload.has_typed::<ColumnReorderDragData>() {
648                    return false;
649                }
650                let count = source_for_drop.visible_count();
651                if count == 0 {
652                    return false;
653                }
654                let scroll = scroll_for_drop.get().max(0.0);
655                let content_y = position.y - header_h_for_drop + scroll;
656                let (flat_idx, row_top, row_h, ins) = {
657                    let mut m = metrics_for_drop.borrow_mut();
658                    m.resize(count);
659                    let idx = m.row_at(content_y);
660                    let ins = m.insertion_index(content_y);
661                    (idx, m.row_top(idx), m.row_height(idx), ins)
662                };
663                // Before / Into / After from the y within the row. The bands
664                // are plain thirds for a cursor and widen at the edges for a
665                // finger — `common::drop_bands` owns the rule, and the hover
666                // affordance and the drop itself both read it, so the line the
667                // user sees cannot promise a position the drop does not take.
668                let y_in_row = content_y - row_top;
669                let drop_pos = crate::common::drop_bands::drop_position_in_row(
670                    y_in_row,
671                    row_h,
672                    ctx.pointer_kind(),
673                );
674                let is_same_view = payload
675                    .get_typed::<RowDragData<T>>()
676                    .is_some_and(|rd| rd.source == drop_model_id);
677                if is_same_view && (!reorderable_drop || sort_for_drop.get().is_some()) {
678                    return false;
679                }
680                // The source applies the move (cycle-guarded, undo-aware for an
681                // external store) and reports whether it took. Gated exactly as
682                // `TreeView` does, so a foreign payload the source does NOT
683                // recognise still reaches the `on_rows_received` sugar below.
684                if (reorderable_drop || !is_same_view)
685                    && (source_for_drop.dnd.accept_drop_fn)(
686                        &payload,
687                        flat_idx,
688                        drop_pos,
689                        drop_model_id,
690                    )
691                {
692                    if is_same_view {
693                        export_for_drop.note_self_reorder();
694                    }
695                    return true;
696                }
697                // Foreign payload: the typed receive sugar first, then the raw
698                // escape hatch.
699                if export_for_drop.foreign_receive(&mut payload, drop_model_id, ins, ctx) {
700                    return true;
701                }
702                // `on_foreign_drop` predates the source path and is
703                // `NodeId`-typed, so it only fires when there is a projection to
704                // resolve the target node through.
705                if let Some(ref hook) = on_foreign_for_drop
706                    && let Some(ref p) = proxy_for_foreign_hook
707                    && let Some(node) = p.visible_node_id(flat_idx)
708                {
709                    return hook(&payload, node, drop_pos, ctx);
710                }
711                false
712            });
713
714            let feedback_for_leave = self.drop_feedback.clone();
715            handlers = handlers.on_drag_leave(move |_ctx| {
716                feedback_for_leave.set(None);
717            });
718
719            let scroll_for_tick = self.scroll_y.clone();
720            let max_scroll_for_tick = self.max_scroll_y.clone();
721            let viewport_for_tick = self.viewport_height.clone();
722            let header_h_for_tick = header_h;
723            handlers = handlers.on_drag_tick(move |pos, ctx| {
724                // Auto-scroll near the body band's top/bottom edge during a
725                // drag (body-relative so the header doesn't count as the top).
726                let body_h = (viewport_for_tick.get() - header_h_for_tick).max(0.0);
727                let y = pos.y - header_h_for_tick;
728                let band = crate::common::drag_autoscroll::band_for(ctx.pointer_kind());
729                let delta = crate::common::drag_autoscroll::step(y, body_h, band);
730                if delta.abs() > 0.01 {
731                    let max = max_scroll_for_tick.get();
732                    let new_y = (scroll_for_tick.get() + delta).clamp(0.0, max);
733                    scroll_for_tick.set(new_y);
734                }
735            });
736        }
737
738        // Export completion (move-out): fires on the drag source — this
739        // view's root id, the stable id `start_drag` is given via the body
740        // pane's `drag_anchor`. A same-view reorder called
741        // `self.export.note_self_reorder()` in `on_drop` above, so it is
742        // skipped here (already applied). Absent an
743        // `on_rows_transferred_out` override, the default move-out runs the
744        // stable-`NodeId` removal thunk `TreeBodyPane::build`'s `on_drag`
745        // resolved at drag-start (ascending pre-order, so an already-removed
746        // descendant of another dragged node is safely skipped).
747        handlers = self.export.install_completion(handlers);
748
749        ctx.apply_self_handlers(handlers);
750
751        // ── Build children ────────────────────────────────────────────
752
753        self.header_row_id = None;
754        self.body_pane_id = None;
755        self.scrollbar_id = None;
756        self.h_scrollbar_id = None;
757        self.empty_id = None;
758
759        // Header strip.
760        if self.show_header {
761            // See `TableView::build`: a rebuild drops the pointer capture an
762            // in-flight resize rides on, so the shared drag state must go with
763            // it or a later bare PointerMove would resize with no button held.
764            *self.resize_state.borrow_mut() = None;
765            self.resize_target.set(None);
766            self.resize_preview_x.set(None);
767
768            let boundaries = *self.pane_boundaries.borrow();
769            // A stretched last column has no size of its own to drag — see
770            // `TableView::build`.
771            let stretched_slot = self
772                .stretch_last_column
773                .then(|| display_indices.len().saturating_sub(1));
774            let resize_columns: ColumnResizeTable = Rc::new(
775                display_indices
776                    .iter()
777                    .enumerate()
778                    .map(|(slot, &i)| {
779                        let c = &self.columns[i];
780                        ColumnResizeInfo {
781                            id: c.id.clone(),
782                            min_width: c.min_width.unwrap_or(cp::MIN_COLUMN_WIDTH_DEFAULT),
783                            max_width: c.max_width,
784                            resizable: c.resizable && stretched_slot != Some(slot),
785                            flex: matches!(c.width, crate::ColumnWidth::Flex(_)),
786                        }
787                    })
788                    .collect(),
789            );
790            let mut cell_ids: Vec<WidgetId> = Vec::with_capacity(display_indices.len());
791            let active_sort = self.sort_signal.get();
792            let cell_padding_horizontal =
793                crate::styles::recipe_table_style::resolve_table_style(ctx)
794                    .cell_padding_horizontal(&ctx.theme().input);
795            for (display_pos, &col_idx) in display_indices.iter().enumerate() {
796                let col = &self.columns[col_idx];
797                let current_sort = active_sort
798                    .as_ref()
799                    .and_then(|(id, dir)| if id == &col.id { Some(*dir) } else { None });
800                // The gutter half comes from the active `TableStyle`, matching
801                // what `HeaderCell::build` actually pads by — see the twin in
802                // `table_view/widget_impl.rs`.
803                let filter_zone_width = cp::FILTER_INDICATOR_SIZE + cell_padding_horizontal;
804                let cell = HeaderCell::new(HeaderCellSpec {
805                    col_id: col.id.clone(),
806                    label: col.header_label.resolve_now(),
807                    col_index_1based: display_pos + 1,
808                    sortable: col.sortable,
809                    reorderable: col.reorderable,
810                    filterable: col.filterable,
811                    resize_grip: cp::RESIZE_HANDLE_WIDTH,
812                    filter_zone_width,
813                    current_sort,
814                    width_index: display_pos,
815                    pane_boundaries: boundaries,
816                    resize_columns: resize_columns.clone(),
817                    resize_policy: self.column_resize_policy,
818                    resize_state: self.resize_state.clone(),
819                    resize_target: self.resize_target.clone(),
820                    resize_preview_x: self.resize_preview_x.clone(),
821                    table_id: self.table_id,
822                    sort_signal: self.sort_signal.clone(),
823                    column_widths_signal: self.column_widths_signal.clone(),
824                    column_widths: self.column_widths.clone(),
825                    filters_signal: self.filters_signal.clone(),
826                });
827                cell_ids.push(ctx.add(cell));
828            }
829            let header_row = HeaderRow::new(
830                cell_ids,
831                self.column_widths.clone(),
832                cp::GRID_LINE_THICKNESS,
833                *self.pane_boundaries.borrow(),
834                self.scroll_x.clone(),
835                self.column_widths_signal.clone(),
836            );
837            // Wire reorder drag-target handlers on the header strip — the
838            // shared drop-target half of the mechanism `HeaderCell` already
839            // escalates a press into (see `table_view::header`). The tree
840            // column reorders like any other column: it carries no special
841            // case here, since `tree_display_pos` (re-resolved from
842            // `display_indices` on every rebuild — see below) is what makes
843            // the indent/twist gutter and Left/Right expand-collapse follow
844            // it wherever the drop lands, including into the leading- or
845            // trailing-pinned pane.
846            let header_row_id = ctx.add(header_row);
847            attach_header_reorder_handlers(
848                ctx,
849                header_row_id,
850                self.table_id,
851                self.column_widths.clone(),
852                self.display_indices.clone(),
853                self.pane_boundaries.clone(),
854                self.column_order_signal.clone(),
855                self.column_pinning_signal.clone(),
856                self.columns.iter().map(|c| c.id.clone()).collect(),
857                self.header_strip_width.clone(),
858                self.scroll_x.clone(),
859            );
860            self.header_row_id = Some(header_row_id);
861        }
862
863        // Body rows live in a TreeBodyPane — a sibling of the
864        // scrollbar, so buffer-exit / selection / editing / expand
865        // rebuilds target the pane and are never deferred by the
866        // gesture-capture protection during a thumb drag.
867        let row_count = self.source.visible_count();
868
869        // Lazy: nudge the source to load the realized window, and fetch
870        // the next page as the viewport nears the end (append-only
871        // sources). `TreeSource` already erases a `TreeDataSource`'s
872        // `row_state`/`request_window`/`can_fetch_more`/`fetch_more`
873        // into `self.source.dnd` (mirrors `list_source::DndLazy` — see
874        // `TableView::build`); a fully-resident source's default (inert)
875        // impls leave this a no-op.
876        let (vis_start, vis_end) = self.visible_range();
877        (self.source.dnd.request_window_fn)(vis_start..vis_end);
878        if (self.source.dnd.can_fetch_more_fn)() && vis_end + BUFFER_ROWS >= row_count {
879            (self.source.dnd.fetch_more_fn)();
880        }
881
882        if row_count > 0 {
883            let pane = body_pane::TreeBodyPane::<T> {
884                source: self.source.clone(),
885                editing_anchor: self.editing_anchor.clone(),
886                columns: self.columns.clone(),
887                display_indices: self.display_indices.clone(),
888                column_widths: self.column_widths.clone(),
889                pane_boundaries: *self.pane_boundaries.borrow(),
890                scroll_x: self.scroll_x.clone(),
891                tree_display_pos,
892                indent_per_level,
893                row_metrics: self.row_metrics.clone(),
894                selection_mode: self.selection_mode,
895                selection: self.row_selection.clone(),
896                cell_selection: self.cell_selection.clone(),
897                scroll_y: self.scroll_y.clone(),
898                viewport_height: self.viewport_height.clone(),
899                editing_cell: self.editing_cell.clone(),
900                focused_cell: self.focused_cell.clone(),
901                reorderable: self.reorderable,
902                reorder_perform: reorder_perform.clone(),
903                reparent_perform: reparent_perform.clone(),
904                model_id: self.model_id,
905                export: self.export.clone(),
906                drag_anchor: ctx.self_id(),
907                on_row_activate: self.on_row_activate.clone(),
908                activate_on: self.activate_on,
909                edit_triggers: self.edit_triggers,
910                on_cell_edit_request: self.on_cell_edit_request.clone(),
911                on_cell_edit_dismissed: self.on_cell_edit_dismissed.clone(),
912                version: self.pane_version.clone(),
913                prev_built_start: self.pane_built_start.clone(),
914                prev_built_end: self.pane_built_end.clone(),
915                total_refresh: self.pane_total_refresh.clone(),
916                row_entries: Vec::new(),
917                row_roots: Vec::new(),
918                row_map: self.row_map.clone(),
919                cell_map: self.cell_map.clone(),
920            };
921            self.body_pane_id = Some(ctx.add(pane));
922            // An open cell editor also ends on a press that lands on no cell at
923            // all — the empty band under the last row. Mounted here rather than
924            // on the pane because the pane is not the hit target there.
925            if let Some(handlers) = crate::table_view::body_pane::root_edit_dismiss_handler(
926                &self.on_cell_edit_dismissed,
927                &self.editing_cell,
928                &Rc::new(
929                    display_indices
930                        .iter()
931                        .map(|&i| self.columns[i].id.clone())
932                        .collect::<Vec<_>>(),
933                ),
934            ) {
935                ctx.apply_self_handlers(handlers);
936            }
937        } else if let Some(ref f) = self.empty_view {
938            // Empty state — an empty tree, or a filter that matched nothing.
939            self.empty_id = Some(ctx.add_boxed(f()));
940        }
941
942        // Scrollbar.
943        if self.show_internal_scrollbars {
944            let sb = ScrollBar::new(
945                ScrollBarOrientation::Vertical,
946                self.scroll_y.clone(),
947                self.max_scroll_y.clone(),
948                self.viewport_ratio_y.clone(),
949            )
950            .visual(match self.scroll_bar_style {
951                ScrollBarMode::Permanent => ScrollBarVisual::Permanent,
952                ScrollBarMode::Overlay => ScrollBarVisual::Overlay,
953                ScrollBarMode::Thin => ScrollBarVisual::Thin,
954            });
955            self.scrollbar_id = Some(ctx.add(sb));
956
957            // Horizontal bar — the Middle pane only, mirrors `TableView`.
958            let hsb = ScrollBar::new(
959                ScrollBarOrientation::Horizontal,
960                self.scroll_x.clone(),
961                self.max_scroll_x.clone(),
962                self.viewport_ratio_x.clone(),
963            )
964            .visual(match self.scroll_bar_style {
965                ScrollBarMode::Permanent => ScrollBarVisual::Permanent,
966                ScrollBarMode::Overlay => ScrollBarVisual::Overlay,
967                ScrollBarMode::Thin => ScrollBarVisual::Thin,
968            });
969            self.h_scrollbar_id = Some(ctx.add(hsb));
970        }
971
972        // Z-order mirrors TableView: body pane first, header last so it
973        // paints above any row that bleeds into the header band on
974        // overscroll.
975        let mut children: Vec<WidgetId> = Vec::new();
976        if let Some(id) = self.body_pane_id {
977            children.push(id);
978        }
979        if let Some(id) = self.empty_id {
980            children.push(id);
981        }
982        if let Some(id) = self.scrollbar_id {
983            children.push(id);
984        }
985        if let Some(id) = self.h_scrollbar_id {
986            children.push(id);
987        }
988        if let Some(id) = self.header_row_id {
989            children.push(id);
990        }
991        let _ = (header_h, row_h);
992        children
993    }
994
995    fn layout_response(
996        &self,
997        proposal: SizeProposal,
998        _ctx: &LayoutContext,
999    ) -> teksilo_core::widget::LayoutResponse {
1000        // Only an allocation may seed the cached viewport (`common::viewport`);
1001        // the body pane shares this very cell, so a measurement's fallback
1002        // would desync its realization window.
1003        let size = crate::common::viewport::viewport_size(
1004            proposal,
1005            &self.viewport_height,
1006            Size::new(400.0, 300.0),
1007        );
1008        if proposal.height.is_some() {
1009            // Viewport-relative imperatives are meaningful from here on — but
1010            // only once a real height has landed, for the reason `laid_out`
1011            // exists at all.
1012            self.laid_out.set(true);
1013        }
1014        size.into()
1015    }
1016
1017    fn place_children(
1018        &self,
1019        bounds: Rect,
1020        _proposal: SizeProposal,
1021        children: &mut [WidgetPlacement],
1022        ctx: &LayoutContext,
1023    ) {
1024        // The rubber band's resistance is a fraction of the viewport. This
1025        // view does not band, but the scroller reads the extent either way and
1026        // this is the only pass that knows it.
1027        self.scroller
1028            .borrow_mut()
1029            .set_viewport(teksilo_canvas::Vec2::new(bounds.width, bounds.height));
1030        if children.is_empty() {
1031            return;
1032        }
1033        let rtl = ctx.is_rtl();
1034        let header_h = self.effective_header_height();
1035        let body_height_provisional = (bounds.height - header_h).max(0.0);
1036
1037        // Parent-before-child layout order means this runs before the
1038        // body pane's measure pass — in auto-measure mode the scrollbar
1039        // totals settle one frame after a measurement change.
1040        let total_height = self
1041            .row_metrics
1042            .borrow_mut()
1043            .total_height(self.source.visible_count());
1044        let needs_v_scrollbar =
1045            self.show_internal_scrollbars && total_height > body_height_provisional + 0.5;
1046        // Permanent reserves a layout column for the bar; Overlay / Thin
1047        // float over the content, so the body spans the full width.
1048        let reserves_v_bar = needs_v_scrollbar && self.scroll_bar_style == ScrollBarMode::Permanent;
1049        let body_width = if reserves_v_bar {
1050            (bounds.width - SCROLLBAR_THICKNESS).max(0.0)
1051        } else {
1052            bounds.width
1053        };
1054        // RTL mirror (see TableView::place_children): scrollbar to the
1055        // physical left, body/header band shifted right by its thickness.
1056        // Only shift when the bar actually reserves a column (Permanent).
1057        let band_left = if rtl && reserves_v_bar {
1058            bounds.x + SCROLLBAR_THICKNESS
1059        } else {
1060            bounds.x
1061        };
1062        let scrollbar_x = if rtl {
1063            bounds.x
1064        } else {
1065            bounds.x + bounds.width - SCROLLBAR_THICKNESS
1066        };
1067        // The header strip spans the band; snapshot its width for the
1068        // reorder-drop handler's RTL mirror (see `TableView::place_children`).
1069        self.header_strip_width.set(body_width);
1070
1071        let overrides = self.column_widths_signal.get();
1072        let display = self.display_indices.borrow().clone();
1073        let widths = layout::ColumnSolver::resolve_in_order(
1074            &self.columns,
1075            &display,
1076            body_width,
1077            cp::MIN_COLUMN_WIDTH_DEFAULT,
1078            &overrides,
1079            self.stretch_last_column,
1080        );
1081
1082        // Pane geometry (see `TableView::place_children`).
1083        let boundaries = *self.pane_boundaries.borrow();
1084        let (leading_w, middle_content_w, trailing_w) = layout::pane_widths(&widths, boundaries);
1085        let middle_viewport_w = (body_width - leading_w - trailing_w).max(0.0);
1086        let max_x = (middle_content_w - middle_viewport_w).max(0.0);
1087        self.max_scroll_x.set(max_x);
1088        self.middle_viewport_width.set(middle_viewport_w);
1089        let x_ratio = if middle_content_w > 0.0 {
1090            (middle_viewport_w / middle_content_w).clamp(0.0, 1.0)
1091        } else {
1092            1.0
1093        };
1094        self.viewport_ratio_x.set(x_ratio);
1095        {
1096            let current = self.scroll_x.get();
1097            let clamped = current.clamp(0.0, max_x);
1098            if (clamped - current).abs() > 0.001 {
1099                self.scroll_x.set(clamped);
1100            }
1101        }
1102
1103        *self.column_widths.borrow_mut() = widths;
1104
1105        let needs_h_scrollbar = self.show_internal_scrollbars && max_x > 0.5;
1106        let reserves_h_bar = needs_h_scrollbar && self.scroll_bar_style == ScrollBarMode::Permanent;
1107        let body_height = if reserves_h_bar {
1108            (body_height_provisional - SCROLLBAR_THICKNESS).max(0.0)
1109        } else {
1110            body_height_provisional
1111        };
1112
1113        let max_y = (total_height - body_height).max(0.0);
1114        self.max_scroll_y.set(max_y);
1115        let y_ratio = if total_height > 0.0 {
1116            (body_height / total_height).clamp(0.0, 1.0)
1117        } else {
1118            1.0
1119        };
1120        self.viewport_ratio_y.set(y_ratio);
1121        self.clamp_scroll();
1122
1123        let body_origin_y = bounds.y + header_h;
1124        // Cache the row-area rect for the keyboard handler's outer-scroll chase.
1125        self.body_bounds
1126            .set(Rect::new(band_left, body_origin_y, body_width, body_height));
1127
1128        let mut next = 0;
1129
1130        // Body pane fills the body region; it positions its rows
1131        // internally and clips them to its own bounds.
1132        if self.body_pane_id.is_some() {
1133            if let Some(child) = children.get_mut(next) {
1134                child.origin = Point::new(band_left, body_origin_y);
1135                child.size = Size::new(body_width, body_height);
1136            }
1137            next += 1;
1138        }
1139
1140        // Empty-state child fills the body region (below the header).
1141        if self.empty_id.is_some() {
1142            if let Some(child) = children.get_mut(next) {
1143                child.origin = Point::new(band_left, body_origin_y);
1144                child.size = Size::new(body_width, body_height);
1145            }
1146            next += 1;
1147        }
1148
1149        // Scrollbar — alongside the body, below the header.
1150        if self.scrollbar_id.is_some() {
1151            if let Some(child) = children.get_mut(next) {
1152                if needs_v_scrollbar {
1153                    child.origin = Point::new(scrollbar_x, body_origin_y);
1154                    child.size = Size::new(SCROLLBAR_THICKNESS, body_height);
1155                } else {
1156                    child.origin = bounds.origin();
1157                    child.size = Size::ZERO;
1158                }
1159            }
1160            next += 1;
1161        }
1162
1163        // Horizontal scrollbar — the Middle pane's own band, below the body.
1164        if self.h_scrollbar_id.is_some() {
1165            if let Some(child) = children.get_mut(next) {
1166                if needs_h_scrollbar {
1167                    let h_x = if rtl {
1168                        band_left + trailing_w
1169                    } else {
1170                        band_left + leading_w
1171                    };
1172                    child.origin = Point::new(h_x, body_origin_y + body_height);
1173                    child.size = Size::new(middle_viewport_w, SCROLLBAR_THICKNESS);
1174                } else {
1175                    child.origin = bounds.origin();
1176                    child.size = Size::ZERO;
1177                }
1178            }
1179            next += 1;
1180        }
1181
1182        // Header strip last — placed at top y but emitted last so paint
1183        // z-order draws it above any overscrolled body rows.
1184        if self.header_row_id.is_some()
1185            && let Some(child) = children.get_mut(next)
1186        {
1187            child.origin = Point::new(band_left, bounds.y);
1188            child.size = Size::new(body_width, header_h);
1189        }
1190    }
1191
1192    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
1193        let header_h = self.effective_header_height();
1194        let colors = &ctx.theme.colors;
1195        let scroll_y = self.scroll_y.get();
1196        let body_origin_y = bounds.y + header_h;
1197        let body_height = (bounds.height - header_h).max(0.0);
1198        let widths = self.column_widths.borrow();
1199        let body_width = widths.iter().sum::<f32>();
1200        let body_width_for_paint = if body_width > 0.0 {
1201            body_width.min(bounds.width)
1202        } else {
1203            bounds.width
1204        };
1205        // Physical left edge of the column content (see TableView::paint).
1206        let rtl = ctx.layout_direction == teksilo_core::environment::LayoutDirection::RightToLeft;
1207        let content_left = if rtl {
1208            bounds.x + bounds.width - body_width_for_paint
1209        } else {
1210            bounds.x
1211        };
1212
1213        // Visible row window for the paint passes — offset-table-driven
1214        // so variable heights paint correctly.
1215        let row_count = self.source.visible_count();
1216        let (first_visible, last_visible) =
1217            self.row_metrics
1218                .borrow_mut()
1219                .visible_range(scroll_y, body_height, row_count, 0);
1220
1221        // Clip the root-painted row decorations (alt-row stripes,
1222        // selection bands, grid lines, focus ring) to the body band —
1223        // `clips_children` only clips child widgets, not this widget's
1224        // own paint, which would otherwise bleed past the bottom edge
1225        // for the partially visible last row.
1226        canvas.set_clip(Rect::new(
1227            content_left,
1228            body_origin_y,
1229            body_width_for_paint,
1230            body_height,
1231        ));
1232
1233        if self.alternating_rows {
1234            let mut m = self.row_metrics.borrow_mut();
1235            for row_idx in first_visible..last_visible {
1236                if row_idx % 2 == 1 {
1237                    let y = body_origin_y + m.row_top(row_idx) - scroll_y;
1238                    let h = m.row_height(row_idx);
1239                    let rect = Rect::new(content_left, y, body_width_for_paint, h);
1240                    canvas.fill_rect(rect, SurfaceRole::AltRow.resolve(colors));
1241                }
1242            }
1243        }
1244
1245        if let Some(ref sel) = self.row_selection
1246            && matches!(
1247                self.selection_mode,
1248                TableSelectionMode::SingleRow | TableSelectionMode::MultiRow
1249            )
1250        {
1251            // Focus- and window-aware: vivid while the view holds keyboard
1252            // focus AND the host window is active, muted otherwise (the same
1253            // `SelectedInactive` serves view-unfocused and window-inactive).
1254            let bg = if self.view_focused.get() && ctx.window_active {
1255                SurfaceRole::Selected.resolve(colors)
1256            } else {
1257                SurfaceRole::SelectedInactive.resolve(colors)
1258            };
1259            let mut m = self.row_metrics.borrow_mut();
1260            for row_idx in sel.selected_indices() {
1261                let y = body_origin_y + m.row_top(row_idx) - scroll_y;
1262                let h = m.row_height(row_idx);
1263                if y + h < body_origin_y || y > body_origin_y + body_height {
1264                    continue;
1265                }
1266                let rect = Rect::new(content_left, y, body_width_for_paint, h);
1267                canvas.fill_rect(rect, bg);
1268            }
1269        }
1270
1271        let line_color = BorderRole::Divider.resolve(colors);
1272        let line_w = cp::GRID_LINE_THICKNESS.max(1.0);
1273        if matches!(self.grid_lines, GridLines::Horizontal | GridLines::Both) {
1274            let mut m = self.row_metrics.borrow_mut();
1275            for row_idx in first_visible..last_visible {
1276                let bottom = m.row_top(row_idx) + m.row_height(row_idx);
1277                let y = body_origin_y + bottom - scroll_y - line_w;
1278                let rect = Rect::new(content_left, y, body_width_for_paint, line_w);
1279                canvas.fill_rect(rect, line_color);
1280            }
1281        }
1282
1283        // Pane geometry for the two column-position-dependent decorations
1284        // below — see `TableView::paint`.
1285        let boundaries = *self.pane_boundaries.borrow();
1286        let scroll_x = self.scroll_x.get();
1287        let content_bounds = Rect::new(
1288            content_left,
1289            body_origin_y,
1290            body_width_for_paint,
1291            body_height,
1292        );
1293        let (leading_rect, middle_rect, trailing_rect) =
1294            layout::band_rects(content_bounds, &widths, boundaries, rtl);
1295
1296        if matches!(self.grid_lines, GridLines::Vertical | GridLines::Both) {
1297            let leading_end = boundaries.leading_count.min(widths.len());
1298            let middle_end = boundaries.middle_end.min(widths.len()).max(leading_end);
1299            crate::table_view::draw_pane_dividers(
1300                canvas,
1301                leading_rect,
1302                &widths[..leading_end],
1303                0.0,
1304                rtl,
1305                line_color,
1306                line_w,
1307            );
1308            crate::table_view::draw_pane_dividers(
1309                canvas,
1310                middle_rect,
1311                &widths[leading_end..middle_end],
1312                scroll_x,
1313                rtl,
1314                line_color,
1315                line_w,
1316            );
1317            crate::table_view::draw_pane_dividers(
1318                canvas,
1319                trailing_rect,
1320                &widths[middle_end..],
1321                0.0,
1322                rtl,
1323                line_color,
1324                line_w,
1325            );
1326        }
1327
1328        // Focus ring — keyboard-only (`:focus-visible`) and only while the
1329        // view holds focus, so a mouse click never leaves a ring.
1330        if self.view_focused.get()
1331            && self.focus_visible.get()
1332            && let Some((focus_row, focus_col)) = self.focused_cell.get()
1333            && focus_col < widths.len()
1334            && let Some(x_off) = layout::column_logical_x(
1335                &widths,
1336                boundaries,
1337                scroll_x,
1338                body_width_for_paint,
1339                focus_col,
1340            )
1341        {
1342            let cell_w = widths[focus_col];
1343            let (focus_top, focus_h) = {
1344                let mut m = self.row_metrics.borrow_mut();
1345                (m.row_top(focus_row), m.row_height(focus_row))
1346            };
1347            let y = body_origin_y + focus_top - scroll_y;
1348            if y + focus_h >= body_origin_y && y <= body_origin_y + body_height {
1349                let pane_rect = if focus_col < boundaries.leading_count {
1350                    leading_rect
1351                } else if focus_col >= boundaries.middle_end {
1352                    trailing_rect
1353                } else {
1354                    middle_rect
1355                };
1356                canvas.set_clip(pane_rect);
1357                let inset = cp::FOCUS_RING_INSET;
1358                let stroke = cp::GRID_LINE_THICKNESS.max(1.5);
1359                let ring_color = BorderRole::Focused.resolve(colors);
1360                let rx = if rtl {
1361                    content_left + body_width_for_paint - x_off - cell_w + inset
1362                } else {
1363                    content_left + x_off + inset
1364                };
1365                let ry = y + inset;
1366                let rw = (cell_w - inset * 2.0).max(0.0);
1367                let rh = (focus_h - inset * 2.0).max(0.0);
1368                canvas.fill_rect(Rect::new(rx, ry, rw, stroke), ring_color);
1369                canvas.fill_rect(Rect::new(rx, ry + rh - stroke, rw, stroke), ring_color);
1370                canvas.fill_rect(Rect::new(rx, ry, stroke, rh), ring_color);
1371                canvas.fill_rect(Rect::new(rx + rw - stroke, ry, stroke, rh), ring_color);
1372                canvas.clear_clip();
1373            }
1374        }
1375
1376        // Row-drop insertion indicator (source-accepted positions only — a
1377        // forbidden hover clears the signal). `y` is stored body-local.
1378        //
1379        // Both affordances are indented to the level the dropped row lands at,
1380        // measured from the **tree column's** own leading edge rather than the
1381        // body's: `.tree_column()` and a user column-reorder can move the
1382        // twist/indent gutter off the leading slot, and an indent measured from
1383        // the wrong origin points at nothing. The per-level step is this view's
1384        // `effective_indent()` — the very value its indent gutter renders with
1385        // — not the container recipe's, which describes `StandardTreeItem`.
1386        let drop_indent_origin = |depth: usize| -> f32 {
1387            let step = self.effective_indent();
1388            let tree_decl = self.tree_column_decl_index();
1389            let tree_slot = self
1390                .display_indices
1391                .borrow()
1392                .iter()
1393                .position(|&i| i == tree_decl)
1394                .unwrap_or(0);
1395            let col_x = layout::column_logical_x(
1396                &widths,
1397                boundaries,
1398                scroll_x,
1399                body_width_for_paint,
1400                tree_slot,
1401            )
1402            .unwrap_or(0.0);
1403            (col_x + depth as f32 * step).clamp(0.0, body_width_for_paint)
1404        };
1405        match self.drop_feedback.get() {
1406            Some(DropViz::Line { y, depth, .. }) => {
1407                let recipe = ctx
1408                    .theme
1409                    .style_slots
1410                    .list_container
1411                    .as_ref()
1412                    .map(|s| s.insertion())
1413                    .unwrap_or_default();
1414                let line_color = recipe.role.resolve(colors);
1415                let thickness = recipe.thickness;
1416                let line_y = body_origin_y + y - thickness * 0.5;
1417                let indent = drop_indent_origin(depth);
1418                // RTL mirrors the row, so the indent eats into the *right* edge
1419                // and the line still runs away from the row's leading side.
1420                let x = if rtl {
1421                    content_left
1422                } else {
1423                    content_left + indent
1424                };
1425                canvas.fill_rect(
1426                    Rect::new(x, line_y, body_width_for_paint - indent, thickness),
1427                    line_color,
1428                );
1429            }
1430            // "Drop into this container" — a box round the target row, inset on
1431            // every side so its horizontal edges can never be mistaken for the
1432            // Before / After line. Same affordance `TreeView` paints for an
1433            // `Into` verdict; see `ListDropIntoRecipe`.
1434            Some(DropViz::Rect {
1435                top, height, depth, ..
1436            }) => {
1437                let into = ctx
1438                    .theme
1439                    .style_slots
1440                    .list_container
1441                    .as_ref()
1442                    .map(|s| s.drop_into())
1443                    .unwrap_or_default();
1444                let color = into.role.resolve(colors);
1445                let indent = drop_indent_origin(depth);
1446                let x = if rtl {
1447                    content_left
1448                } else {
1449                    content_left + indent
1450                };
1451                let rect = Rect::new(
1452                    x + into.inset,
1453                    body_origin_y + top + into.inset,
1454                    (body_width_for_paint - indent - into.inset * 2.0).max(0.0),
1455                    (height - into.inset * 2.0).max(0.0),
1456                );
1457                let radius = teksilo_tokens::CornerRadius::uniform(into.corner_radius);
1458                canvas.fill_rounded_rect(rect, radius, color.with_alpha(into.fill_alpha));
1459                canvas.stroke_rounded_rect(rect, radius, color, into.thickness);
1460            }
1461            None => {}
1462        }
1463
1464        canvas.clear_clip();
1465
1466        // Container focus ring — keyboard focus on the view but no current cell
1467        // and no selection, so nothing else marks the focus. Outline the whole
1468        // view (see TableView / TreeView).
1469        let nothing_indicated = self.focused_cell.get().is_none()
1470            && self
1471                .row_selection
1472                .as_ref()
1473                .is_none_or(|s| s.selected_indices().is_empty())
1474            && self.cell_selection.as_ref().is_none_or(|s| s.count() == 0);
1475        if self.view_focused.get() && self.focus_visible.get() && nothing_indicated {
1476            let inset = 1.0_f32;
1477            let rect = Rect::new(
1478                bounds.x + inset,
1479                bounds.y + inset,
1480                (bounds.width - inset * 2.0).max(0.0),
1481                (bounds.height - inset * 2.0).max(0.0),
1482            );
1483            canvas.stroke_rect(rect, BorderRole::Focused.resolve(colors), 1.5);
1484        }
1485
1486        // `OnRelease` column-resize guide — see `TableView::paint`.
1487        if let Some(x) = self.resize_preview_x.get() {
1488            let thickness = cp::GRID_LINE_THICKNESS.max(1.5);
1489            canvas.fill_rect(
1490                Rect::new(x - thickness * 0.5, bounds.y, thickness, bounds.height),
1491                BorderRole::Focused.resolve(colors),
1492            );
1493        }
1494    }
1495
1496    /// The context-menu key opens the *current row's* menu, not the view's.
1497    ///
1498    /// A `TreeTableView` is focusable and its rows deliberately are not — the
1499    /// container owns focus and `set_selected` is what tells assistive
1500    /// technology which row is current. So the dispatcher's default of "the
1501    /// focused widget" would open the view's own menu, in the widget family
1502    /// where a per-row menu matters most.
1503    ///
1504    /// The row the user means is the focused cell's row if they have navigated,
1505    /// else the first selected row. Only realized rows have a widget, so a
1506    /// cursor scrolled outside the virtualization window resolves to nothing
1507    /// and the menu falls back to the view — right, because there is no row on
1508    /// screen for it to be about.
1509    fn context_menu_key_target(&self) -> Option<WidgetId> {
1510        let index = self.focused_cell.get().map(|(row, _col)| row).or_else(|| {
1511            self.row_selection
1512                .as_ref()
1513                .and_then(|s| s.selected_indices().first().copied())
1514        })?;
1515        let map = self.row_map.borrow();
1516        map.iter().find(|(i, _)| *i == index).map(|(_, id)| *id)
1517    }
1518
1519    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1520        builder.set_role(teksilo_core::accesskit::Role::TreeGrid);
1521        // Whether the selection takes more than one row. A real property on
1522        // both platforms that have one: UIA's `SelectionCanSelectMultiple`
1523        // and AT-SPI's multiselectable state. Left unset it reads false, so a
1524        // multi-select view was telling every screen reader that one row was
1525        // the most it would ever hold.
1526        //
1527        // Gated on the mode, and the gate matters beyond tidiness:
1528        // `accesskit_windows` picks the event it raises on a selection change
1529        // from this property (`adapter.rs:189-199`), firing
1530        // `ElementAddedToSelection` when it is true and `ElementSelected` when
1531        // it is false. A single-select view publishing `true` would trade the
1532        // right event for the wrong one.
1533        if self
1534            .row_selection
1535            .as_ref()
1536            .is_some_and(|selection| selection.mode() == teksilo_data::SelectionMode::Multi)
1537        {
1538            builder.set_multiselectable(true);
1539        }
1540
1541        if let Some(ref label) = self.a11y_label {
1542            builder.set_name(label.resolve_now());
1543        }
1544        let row_count = self.source.visible_count() + if self.show_header { 1 } else { 0 };
1545        let col_count = self.columns.len();
1546        let n = builder.inner_mut();
1547        n.set_row_count(row_count);
1548        n.set_column_count(col_count);
1549
1550        // Roving focus: point active_descendant at the focused cell's own
1551        // AT node so a screen reader follows arrow-key cell navigation
1552        // and ArrowLeft/Right expand/collapse. `cell_map` is a snapshot
1553        // of the body pane's last realized cells; a focused cell that
1554        // scrolled (or collapsed) out of the realized buffer simply
1555        // isn't in it, so no stale id is emitted.
1556        if let Some((row, col)) = self.focused_cell.get()
1557            && let Some(cell_id) = self.realized_cell(row, col)
1558        {
1559            builder.set_active_descendant(widget_id_to_node_id(cell_id));
1560        }
1561    }
1562
1563    fn as_any(&self) -> Option<&dyn std::any::Any> {
1564        Some(self)
1565    }
1566
1567    fn children(&self) -> Vec<WidgetId> {
1568        // Same order as `build()` — body pane first, header last so it
1569        // paints on top of any overscrolled rows.
1570        let mut out: Vec<WidgetId> = Vec::new();
1571        if let Some(id) = self.body_pane_id {
1572            out.push(id);
1573        }
1574        if let Some(id) = self.empty_id {
1575            out.push(id);
1576        }
1577        if let Some(id) = self.scrollbar_id {
1578            out.push(id);
1579        }
1580        if let Some(id) = self.h_scrollbar_id {
1581            out.push(id);
1582        }
1583        if let Some(id) = self.header_row_id {
1584            out.push(id);
1585        }
1586        out
1587    }
1588
1589    fn accessibility_children(&self) -> Option<Vec<WidgetId>> {
1590        // WCAG 1.3.2 (audit G17): read the column-header row FIRST, then the
1591        // body, even though `build()` / `children()` list the body first so it
1592        // paints beneath the header. Same id set as `children()`, reordered.
1593        let out: Vec<WidgetId> = [
1594            self.header_row_id,
1595            self.body_pane_id,
1596            self.empty_id,
1597            self.scrollbar_id,
1598            self.h_scrollbar_id,
1599        ]
1600        .into_iter()
1601        .flatten()
1602        .collect();
1603        if out.is_empty() { None } else { Some(out) }
1604    }
1605
1606    fn clips_children(&self) -> bool {
1607        true
1608    }
1609}