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