Skip to main content

teksilo_widgets/list_view/
widget_impl.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! The [`Widget`] trait implementation for [`ListView`]: build (body pane
5//! and scrollbar assembly, row realization, pointer and drag wiring), layout,
6//! placement, paint and accessibility.
7
8use super::*;
9impl<T: 'static> Widget for ListView<T> {
10    fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
11        // The root builds two children — the body pane and the scrollbar —
12        // plus the pinned section header when one is asked for, and none of
13        // them depends on the data, the selection or the scroll offset. So
14        // it declares no `Rebuild`-level binding at all: row realization is
15        // the pane's job (see `body_pane`'s module docs for why that
16        // separation is load-bearing and not just tidy), and what the root
17        // still owns resolves at `Relayout` / `RepaintOnly`.
18        let self_id = ctx.self_id();
19        ctx.enabled_when(self_id, self.enabled.clone());
20
21        // Scrollbar totals + the content-width decision live in the root's
22        // `place_children`; a data change or a pane measurement that moves
23        // the content total re-places the root through this.
24        self.layout_refresh.bind_to(
25            ctx.self_id(),
26            ctx.binding_registry(),
27            BindingLevel::Relayout,
28        );
29        // A handle's reveal is applied by the body pane, which binds the same
30        // request; the root binds it too so the pass also refreshes the
31        // scroll range the pane clamps against.
32        self.reveal.bind(ctx);
33        // Container focus ring: painted only while nothing is selected, so a
34        // selection change has to reach the root's paint — without rebuilding
35        // it and taking the scrollbar down with it.
36        self.paint_refresh.bind_to(
37            ctx.self_id(),
38            ctx.binding_registry(),
39            BindingLevel::RepaintOnly,
40        );
41
42        // The keyboard cursor names the active descendant (`accessibility`),
43        // so any write of it — the keys, a click, a handle's `set_cursor` —
44        // re-walks the AT tree. A cursor-only move (the accelerator with an
45        // arrow in a multiple selection) changes nothing else the walk would
46        // notice. Neither a rebuild nor a relayout: no row is drawn from it.
47        self.focused_index.bind_to(
48            ctx.self_id(),
49            ctx.binding_registry(),
50            BindingLevel::AccessibilityOnly,
51        );
52
53        // Bind scroll_y at Relayout so place_children runs on every scroll
54        // position change (re-clamps and refreshes the thumb) without a
55        // rebuild. The pane holds the matching binding for its rows.
56        self.scroll_y.bind_to(
57            ctx.self_id(),
58            ctx.binding_registry(),
59            BindingLevel::Relayout,
60        );
61
62        // Register animated signal for smooth scrolling. Deliberately the
63        // ROOT and only the root: the scheduler keys an animation to the
64        // widget that registered its signal last and cancels it when that
65        // widget rebuilds, so registering from the pane too would make every
66        // buffer-exit rebuild abort an in-flight fling.
67        ctx.register_animated_signal(&self.scroll_y);
68
69        // Bind drop_feedback at RepaintOnly so `set(...)` calls from
70        // on_drag_hover / on_drag_leave dirty the ListView's paint cache
71        // without triggering a rebuild.
72        self.drop_feedback.bind_to(
73            ctx.self_id(),
74            ctx.binding_registry(),
75            BindingLevel::RepaintOnly,
76        );
77
78        // Focus signals for the container ring (see TreeView). `RepaintOnly` so
79        // focus-in/out redraws; selection-emptiness changes arrive on
80        // `paint_refresh`. `begin_view_focus` keys the scope signal on this root id directly,
81        // independent of the arena focusable flag (not yet wired at this point):
82        // a plain `view_focus_active()` would `find_focusable_at_or_above`
83        // nothing and fall back to the constant-`true` "outside any scope"
84        // signal — lighting the ring whenever ANY other widget takes keyboard
85        // focus. Pop straight back; the real row scope below resolves the same
86        // cached signal.
87        self.view_focused = ctx.begin_view_focus();
88        ctx.end_view_focus();
89        self.focus_visible = ctx.focus_visible();
90        self.follow_the_selection_in_single_mode(ctx);
91        self.reveal_current_row_on_focus(ctx);
92        self.view_focused.bind_to(
93            ctx.self_id(),
94            ctx.binding_registry(),
95            BindingLevel::RepaintOnly,
96        );
97        self.focus_visible.bind_to(
98            ctx.self_id(),
99            ctx.binding_registry(),
100            BindingLevel::RepaintOnly,
101        );
102
103        // --- Observe model changes ---
104        // One observer, root-owned, doing the bookkeeping the pane can't
105        // (metrics divergence, selection shift, keyboard cursor) and then
106        // fanning out: rebuild the pane (row content changed) and re-place
107        // the root (the content total, hence the thumb, changed).
108        let pane_version_for_data = self.pane_version.clone();
109        let layout_refresh_for_data = self.layout_refresh.clone();
110        let pinned_refresh_for_data = self.pinned_refresh.clone();
111        let data_ver = Rc::new(Cell::new(0_u64));
112        let data_handle = (self.source.observe_fn)(Box::new({
113            let dv = data_ver.clone();
114            let metrics = self.metrics.clone();
115            let sections = self.sections.clone();
116            let len_fn = self.source.len_fn.clone();
117            let first_changed = self.source.first_changed_fn.clone();
118            let row_sel = self.row_selection.clone();
119            let focused = self.focused_index.clone();
120            move |change| {
121                // Keep row metrics in step with the data: rows before
122                // the first changed index keep their (seeded or
123                // measured) heights, the rest re-derive.
124                let divergence = match change {
125                    DataChange::ItemsInserted { range } | DataChange::ItemsRemoved { range } => {
126                        Some(range.start)
127                    }
128                    DataChange::ItemUpdated { index } => Some(*index),
129                    DataChange::ItemsMoved { from, to, .. } => Some((*from).min(*to)),
130                    // A lazy window load makes rows from range.start onward differ.
131                    DataChange::WindowLoaded { range } => Some(range.start),
132                    // Reset-emitting proxies (SortFilterListModel) expose
133                    // their real divergence through the side-channel.
134                    DataChange::Reset => (first_changed)(),
135                };
136                metrics
137                    .borrow_mut()
138                    .apply_divergence(divergence, (len_fn)());
139                // The provider's counts follow the model, so they are read
140                // again — lazily, on the next geometric query, by which time
141                // a provider observing the same model has caught up too.
142                // The metrics stay item-indexed, so the measured prefix
143                // above survives a regrouping untouched.
144                if let Some(ref sections) = sections {
145                    sections.invalidate();
146                }
147                // Keep selection in step: index-shift (index model) or prune
148                // orphaned keys (keyed model).
149                if let Some(ref rs) = row_sel {
150                    rs.on_data_change(change);
151                }
152                // Keep the keyboard-navigation anchor in step too — otherwise
153                // it silently points at the wrong row after any insert /
154                // remove / move (reachable not just from local edits but
155                // from a live watcher pushing in a peer process's write).
156                if let Some(current) = focused.get() {
157                    focused.set(teksilo_data::data_change::adjust_single_index_for_change(
158                        current, change,
159                    ));
160                }
161                let next = dv.get() + 1;
162                dv.set(next);
163                pane_version_for_data.set(next);
164                layout_refresh_for_data.set(next);
165                if sections.is_some() {
166                    pinned_refresh_for_data.set(next);
167                }
168            }
169        }));
170        ctx.own_handle(data_handle);
171
172        // --- Observe selection changes ---
173        // The pane runs its own selection observer for the delegate's
174        // `selected` argument; the root only needs its container focus ring
175        // repainted, since that ring is suppressed once anything is selected.
176        if let Some(ref rs) = self.row_selection {
177            let paint_refresh_for_sel = self.paint_refresh.clone();
178            let sel_ver = Rc::new(Cell::new(0_u64));
179            let handle = rs.observe_for_rebuild(move || {
180                let next = sel_ver.get() + 1;
181                sel_ver.set(next);
182                paint_refresh_for_sel.set(next);
183            });
184            ctx.own_handle(handle);
185        }
186
187        // Scroll-buffer exit is deliberately NOT observed here. It rebuilds
188        // the body pane and nothing else — the root's own children are
189        // unaffected by which rows are realized, and a root rebuild during a
190        // scrollbar thumb drag is exactly the one the framework defers.
191
192        // --- Set up scroll event handler + DnD handlers on self ---
193        // The wheel arithmetic, the pan and the claim that puts this node on a
194        // finger's claimant chain all come from `common::scrollable`. A wheel
195        // still takes the path it always did — `handle_scroll_event` branches
196        // on the scroll *source*, not the phase.
197        let mut handlers = HandlerSet::new()
198            .clips_children(true)
199            .focusable(!self.presentational);
200        {
201            let behavior = crate::common::scrollable::ScrollableBehavior::new(
202                crate::common::scrollable::ScrollableAxes::vertical(
203                    self.scroll_y.clone(),
204                    self.max_scroll_y.clone(),
205                ),
206            )
207            .with_scroller(self.scroller.clone())
208            // Vertical only: this view owns no horizontal offset, so a
209            // horizontal pan is declined and chains outward.
210            .axes(PanAxes::Y)
211            .overscroll(self.overscroll_behavior)
212            .smooth(self.smooth_scrolling)
213            .smooth_duration(self.smooth_scroll_duration)
214            .line_height(self.item_height)
215            .reduced_motion(ctx.prefers_reduced_motion())
216            .physics(ctx.theme().input.scroll_physics);
217            handlers = behavior.install(handlers);
218        }
219
220        // --- The non-drag reorder, all four routes at once ---
221        //
222        // SC 2.5.7 wants the row drag reachable without a drag. One closure
223        // performs the move; the chord below, the row's context menu and the
224        // row's AccessKit custom actions all call it, so the four routes cannot
225        // reach different end states. The move itself travels the bound
226        // source's own drop-accept path — see `common::ordered_move`.
227        let reorder_perform: Option<crate::common::ordered_move::MoveRow> =
228            self.reorderable.then(|| {
229                let mover = std::rc::Rc::new(crate::common::ordered_move::RowMover {
230                    len: self.source.len_fn.clone(),
231                    stash: self.source.dnd.stash_drag_keys_fn.clone(),
232                    payload: {
233                        let view = self.model_id;
234                        Rc::new(move |from: usize| {
235                            DragPayload::typed(RowDragData::<T> {
236                                source: view,
237                                rows: vec![from],
238                                items: None,
239                            })
240                        })
241                    },
242                    accept: self.source.dnd.accept_drop_fn.clone(),
243                    view: self.model_id,
244                    name: {
245                        // The type-ahead label resolver, where the application
246                        // gave one: the same string that names a row for
247                        // find-as-you-type names it in the utterance.
248                        let with_item_str = self.source.with_item_str_fn.clone();
249                        let label = self.type_ahead_label.clone();
250                        Rc::new(move |index: usize| {
251                            let label = label.as_ref()?;
252                            (with_item_str)(index, &|item| label(item))
253                        })
254                    },
255                });
256                let sel = self.row_selection.clone();
257                let fi = self.focused_index.clone();
258                let geometry = self.geometry();
259                let len = self.source.len_fn.clone();
260                let scroll = self.scroll_y.clone();
261                let vh = self.viewport_height.clone();
262                let vb = self.viewport_bounds.clone();
263                let max = self.max_scroll_y.clone();
264                std::rc::Rc::new(
265                    move |mv: crate::common::ordered_move::OrderedMove,
266                          from: usize,
267                          ctx: &mut teksilo_core::widget::EventContext| {
268                        let Some((dest, utterance)) = mover.commit(mv, from) else {
269                            return;
270                        };
271                        if let Some(ref sel) = sel {
272                            sel.select(dest);
273                        }
274                        fi.set(Some(dest));
275                        // Reveal the moved row (own viewport first, then chain
276                        // to any enclosing scroll area).
277                        let current = scroll.get();
278                        let count = len();
279                        let new_scroll = geometry.scroll_for_ensure_visible(
280                            dest,
281                            current,
282                            vh.get(),
283                            max.get(),
284                            count,
285                        );
286                        if (new_scroll - current).abs() > f32::EPSILON {
287                            scroll.set(new_scroll);
288                        }
289                        geometry.chase_into_outer_view(ctx, vb.get(), dest, new_scroll, count);
290                        ctx.announce(utterance);
291                    },
292                ) as crate::common::ordered_move::MoveRow
293            });
294
295        // --- Select all, through the rebindable `edit.select_all` ---
296        // Multi selection only; a no-op for Single / None, matching every
297        // list control.
298        {
299            let len = self.source.len_fn.clone();
300            let sel = self.row_selection.clone();
301            crate::common::standard_edit::register_standard_edit_action(
302                ctx,
303                teksilo_core::shortcut::StandardEdit::SelectAll,
304                move |_ctx| {
305                    let count = (len)();
306                    match sel {
307                        Some(ref sel)
308                            if count > 0 && sel.mode() == teksilo_data::SelectionMode::Multi =>
309                        {
310                            sel.select_all(count);
311                            true
312                        }
313                        _ => false,
314                    }
315                },
316            );
317        }
318
319        // --- Keyboard navigation + Alt+Arrow reorder ---
320        {
321            let len_for_key = self.source.len_fn.clone();
322            let sel_for_key = self.row_selection.clone();
323            let activate_key = self.on_activate.clone();
324            let fi = self.focused_index.clone();
325            let reorder_key = reorder_perform.clone();
326            let scroll_for_nav = self.scroll_y.clone();
327            let geometry_for_nav = self.geometry();
328            let max_for_nav = self.max_scroll_y.clone();
329            let vh_for_nav = self.viewport_height.clone();
330            let vb_for_nav = self.viewport_bounds.clone();
331            // Type-ahead state + label resolver (reads row text via the
332            // source's string accessor, so lazy/unloaded rows are skipped).
333            let ta_state = self.type_ahead.clone();
334            // Index → realized row id, so `Space` can ask the row whether it
335            // publishes a keyboard toggle (a checkbox) before falling back to
336            // the selection.
337            let row_map_for_key = self.row_map.clone();
338            let ta_label = self.type_ahead_label.clone();
339            let ta_timeout = self.type_ahead_timeout;
340            let with_item_str = self.source.with_item_str_fn.clone();
341
342            handlers = handlers.on_key(move |event, ctx| {
343                if let teksilo_core::event::WidgetEvent::KeyDown { key, modifiers, .. } = event {
344                    use teksilo_core::event::Key;
345                    let count = (len_for_key)();
346                    if count == 0 {
347                        return teksilo_core::event::EventResponse::Ignored;
348                    }
349
350                    // Deselect all — Ctrl+Shift+A, ⇧⌘A on macOS (Multi
351                    // selection only). GTK is the only toolkit that
352                    // *mandates* it, but the ARIA listbox and tree patterns
353                    // both sanction an unselect-all, Windows and Qt simply
354                    // have none, and adding it takes nothing away. Select all
355                    // is the `edit.select_all` action registered below, so a
356                    // plain Ctrl+A only gets here when it is not bound to it.
357                    if modifiers.command() && matches!(key, Key::A) {
358                        if modifiers.shift()
359                            && let Some(ref sel) = sel_for_key
360                            && sel.mode() == teksilo_data::SelectionMode::Multi
361                        {
362                            sel.clear();
363                            return teksilo_core::event::EventResponse::Handled;
364                        }
365                        return teksilo_core::event::EventResponse::Ignored;
366                    }
367
368                    // macOS reads a couple of chords in a list that the other
369                    // desktops spend elsewhere, and both are dead here
370                    // otherwise. ⌘↓ opens the row — Finder's "Command–Down
371                    // Arrow: Open the selected item", and what VS Code binds as
372                    // `list.select`'s macOS secondary. (⌘↑ ascends to the
373                    // parent, which a flat list has none of; `TreeView` claims
374                    // it.) Off macOS this resolves to `None` and costs nothing.
375                    if let Some(alias) = list_nav::mac_alias(*key, *modifiers, ctx.is_rtl()) {
376                        if alias == list_nav::MacAlias::Activate {
377                            let row = fi
378                                .get()
379                                .or_else(|| {
380                                    sel_for_key
381                                        .as_ref()
382                                        .and_then(|s| s.selected_indices().first().copied())
383                                })
384                                .unwrap_or(0)
385                                .min(count - 1);
386                            if let Some(ref sel) = sel_for_key {
387                                sel.select(row);
388                            }
389                            if let Some(ref cb) = activate_key {
390                                cb(row, ctx);
391                            }
392                            return teksilo_core::event::EventResponse::Handled;
393                        }
394                        return teksilo_core::event::EventResponse::Ignored;
395                    }
396
397                    // Type-ahead: a printable char (no Ctrl/Alt/Super) jumps the
398                    // selection to the next row whose label starts with the
399                    // accumulated term. Opt-in via `type_ahead_label`.
400                    if ta_label.is_some()
401                        && !modifiers.ctrl()
402                        && !modifiers.alt()
403                        && !modifiers.super_key()
404                        && let Some(c) = key.to_char()
405                    {
406                        // From the cursor, which is the selection when no key
407                        // has moved it — as every other reader of `fi` does.
408                        let current = fi
409                            .get()
410                            .or_else(|| {
411                                sel_for_key
412                                    .as_ref()
413                                    .and_then(|s| s.selected_indices().first().copied())
414                            })
415                            .unwrap_or(0)
416                            .min(count - 1);
417                        let label = ta_label.as_ref().unwrap();
418                        if let Some(idx) = ta_state.search(c, current, count, ta_timeout, |i| {
419                            (with_item_str)(i, &|item| label(item))
420                        }) {
421                            fi.set(Some(idx));
422                            if let Some(ref sel) = sel_for_key {
423                                sel.select(idx);
424                            }
425                            let scroll = scroll_for_nav.get();
426                            let new_scroll = geometry_for_nav.scroll_for_ensure_visible(
427                                idx,
428                                scroll,
429                                vh_for_nav.get(),
430                                max_for_nav.get(),
431                                count,
432                            );
433                            if (new_scroll - scroll).abs() > f32::EPSILON {
434                                scroll_for_nav.set(new_scroll);
435                            }
436                            geometry_for_nav.chase_into_outer_view(
437                                ctx,
438                                vb_for_nav.get(),
439                                idx,
440                                new_scroll,
441                                count,
442                            );
443                            return teksilo_core::event::EventResponse::Handled;
444                        }
445                        return teksilo_core::event::EventResponse::Ignored;
446                    }
447
448                    // Alt+Arrow / Alt+Home / Alt+End: the keyboard route to
449                    // the row drag. Decoding and commit are both
450                    // `common::ordered_move`, shared with the row's context
451                    // menu and its AccessKit custom actions.
452                    if let Some(ref perform) = reorder_key
453                        && let Some(mv) = crate::common::ordered_move::OrderedMove::from_key(
454                            *key,
455                            *modifiers,
456                            crate::common::ordered_move::MoveAxis::Vertical,
457                            ctx.is_rtl(),
458                        )
459                    {
460                        let cursor = sel_for_key
461                            .as_ref()
462                            .and_then(|s| s.selected_indices().first().copied())
463                            .or_else(|| fi.get());
464                        if let Some(from) = cursor
465                            && mv.destination(from, count).is_some()
466                        {
467                            perform(mv, from, ctx);
468                            return teksilo_core::event::EventResponse::Handled;
469                        }
470                    }
471
472                    // Navigation keys (no modifiers or with Shift for extend)
473                    //
474                    // The cursor is `focused_index` once the user has navigated
475                    // or clicked; failing that it is the current selection — a
476                    // view can be handed a selected row before it is ever
477                    // focused (a launcher preselecting the top entry, a dialog
478                    // restoring the last choice), and the keyboard must continue
479                    // from what the user can see, not from an invisible zero.
480                    //
481                    // `None` ("no cursor yet") is deliberately NOT the same as
482                    // `Some(0)`: from nothing, Down must land ON the first row
483                    // and Up on the last one. Stepping to row 1 instead would
484                    // silently skip row 0 — the row the user was looking at —
485                    // which is what every toolkit (GTK, Qt, macOS, the ARIA
486                    // listbox pattern) explicitly avoids.
487                    let cursor = fi
488                        .get()
489                        .or_else(|| {
490                            sel_for_key
491                                .as_ref()
492                                .and_then(|s| s.selected_indices().first().copied())
493                        })
494                        .map(|i| i.min(count - 1));
495                    // Anchor for the keys that need a row to compute *from*
496                    // (paging, activation) rather than a direction to step in.
497                    let current = cursor.unwrap_or(0);
498                    // The edge-and-page family is resolved once, in
499                    // `common::list_nav`, so the five data views cannot drift
500                    // apart on it again. A flat list has no row to be scoped
501                    // to, so `RowFirst` / `RowLast` never arrive here — but a
502                    // list's row *is* the collection, so they read the same way
503                    // if the view kind is ever widened.
504                    let nav = list_nav::nav_chord(*key, *modifiers, list_nav::ViewKind::Linear);
505                    let new_idx = if let Some(chord) = nav {
506                        Some(match chord.movement {
507                            list_nav::NavMove::First | list_nav::NavMove::RowFirst => 0,
508                            list_nav::NavMove::Last | list_nav::NavMove::RowLast => count - 1,
509                            // Geometry-driven, so variable and auto-measured
510                            // heights page by visual distance rather than by a
511                            // fixed row count; the ensure-visible below then
512                            // scrolls to follow.
513                            list_nav::NavMove::Page { down } => {
514                                let r = geometry_for_nav.page_target(
515                                    current,
516                                    down,
517                                    vh_for_nav.get(),
518                                    count,
519                                );
520                                // Guarantee progress even when one row is
521                                // taller than the whole viewport.
522                                if r == current && down {
523                                    (current + 1).min(count - 1)
524                                } else if r == current {
525                                    current.saturating_sub(1)
526                                } else {
527                                    r.min(count - 1)
528                                }
529                            }
530                        })
531                    } else {
532                        match key {
533                            Key::ArrowDown => Some(match cursor {
534                                None => 0,
535                                Some(c) => (c + 1).min(count - 1),
536                            }),
537                            Key::ArrowUp => Some(match cursor {
538                                None => count - 1,
539                                Some(c) => c.saturating_sub(1),
540                            }),
541                            Key::Enter => {
542                                // Enter activates the focused row (open / commit).
543                                if let Some(ref sel) = sel_for_key {
544                                    sel.select(current);
545                                }
546                                if let Some(ref cb) = activate_key {
547                                    cb(current, ctx);
548                                }
549                                return teksilo_core::event::EventResponse::Handled;
550                            }
551                            Key::Space if modifiers.ctrl() => {
552                                // Ctrl+Space toggles the focused row's selection —
553                                // the keyboard equivalent of Ctrl+click. Distinct
554                                // from plain Space below: it always toggles (even
555                                // in Single mode, via `SelectionModel::toggle`'s
556                                // own Single-mode fallback to `select`). In a
557                                // multiple selection it pairs with Ctrl+Arrow's
558                                // cursor-only move so a user can walk the cursor
559                                // without disturbing the existing selection, then
560                                // Ctrl+Space to add rows one at a time.
561                                //
562                                // Both halves stay on literal `ctrl()`, macOS
563                                // included: ⌘Space is Spotlight and never reaches
564                                // an app, and ⌘↑/⌘↓ already mean something else in
565                                // a Finder list. This Explorer-style cursor pair
566                                // has no ⌘ counterpart, so Control keeps it
567                                // reachable and out of the platform's way.
568                                if let Some(ref sel) = sel_for_key {
569                                    sel.toggle(current);
570                                }
571                                fi.set(Some(current));
572                                return teksilo_core::event::EventResponse::Handled;
573                            }
574                            Key::Space => {
575                                // A row carrying a checkbox reads Space as
576                                // "check this" — what Windows does for a
577                                // checkbox list view, and what a visible
578                                // checkbox looks like it should answer to. The
579                                // row's control is out of the Tab order, so
580                                // this is its only keyboard route; Ctrl+Space
581                                // above keeps toggling the *selection*.
582                                //
583                                // Rows without a checkbox are unaffected:
584                                // there is no published toggle, so Space falls
585                                // through to the selection as before.
586                                if let Some(row_id) = row_map_for_key
587                                    .borrow()
588                                    .iter()
589                                    .find(|(i, _)| *i == current)
590                                    .map(|(_, id)| *id)
591                                {
592                                    let sel_fallback = sel_for_key.clone();
593                                    ctx.row_space_activate(
594                                        row_id,
595                                        std::rc::Rc::new(
596                                            move |_ctx: &mut teksilo_core::widget::EventContext| {
597                                                if let Some(ref sel) = sel_fallback {
598                                                    if sel.mode()
599                                                        == teksilo_data::SelectionMode::Multi
600                                                    {
601                                                        sel.toggle(current);
602                                                    } else {
603                                                        sel.select(current);
604                                                    }
605                                                }
606                                            },
607                                        ),
608                                    );
609                                    fi.set(Some(current));
610                                    return teksilo_core::event::EventResponse::Handled;
611                                }
612                                // Otherwise Space moves/toggles the selection but
613                                // does NOT activate — the platform convention
614                                // (Enter is the activator). Multi: toggle the
615                                // focused row; Single: select it.
616                                if let Some(ref sel) = sel_for_key {
617                                    if sel.mode() == teksilo_data::SelectionMode::Multi {
618                                        sel.toggle(current);
619                                    } else {
620                                        sel.select(current);
621                                    }
622                                }
623                                fi.set(Some(current));
624                                return teksilo_core::event::EventResponse::Handled;
625                            }
626                            _ => None,
627                        }
628                    };
629
630                    if let Some(idx) = new_idx {
631                        fi.set(Some(idx));
632                        // What the chord does to the selection. The edge-and-page
633                        // keys carry their own answer from `list_nav`, where the
634                        // accelerator means "move the cursor, leave the selection
635                        // alone" — the rule GTK4 applies to every navigation key,
636                        // and Qt in its multi-selection modes.
637                        //
638                        // The arrows keep reading literal `ctrl()` instead: ⌘↑/⌘↓
639                        // already mean something else in a Finder list (see the
640                        // Ctrl+Space arm above), so this pair has no ⌘ counterpart
641                        // to move to. That asymmetry is deliberate — it is also
642                        // what leaves ⌘↑/⌘↓ free for the macOS aliases.
643                        //
644                        // Either way a single selection moves with the cursor:
645                        // `for_cardinality` takes the cursor-only move away there,
646                        // so the row announced is always the row selected.
647                        let op = match nav {
648                            Some(chord) => chord.selection,
649                            None if modifiers.ctrl()
650                                && !modifiers.shift()
651                                && matches!(key, Key::ArrowUp | Key::ArrowDown) =>
652                            {
653                                list_nav::SelectionOp::Suppress
654                            }
655                            None if modifiers.shift() => list_nav::SelectionOp::Extend,
656                            None => list_nav::SelectionOp::Replace,
657                        };
658                        if let Some(ref sel) = sel_for_key {
659                            match op.for_cardinality(sel.mode().into()) {
660                                list_nav::SelectionOp::Replace => sel.select(idx),
661                                list_nav::SelectionOp::Suppress => {}
662                                list_nav::SelectionOp::Extend => sel.extend_to(idx),
663                                list_nav::SelectionOp::ExtendAdditive => {
664                                    sel.extend_to_additive(idx)
665                                }
666                            }
667                        }
668                        // Scroll into view — the ListView's own viewport first,
669                        // then chain to any enclosing scroll area.
670                        let scroll = scroll_for_nav.get();
671                        let new_scroll = geometry_for_nav.scroll_for_ensure_visible(
672                            idx,
673                            scroll,
674                            vh_for_nav.get(),
675                            max_for_nav.get(),
676                            count,
677                        );
678                        if (new_scroll - scroll).abs() > f32::EPSILON {
679                            scroll_for_nav.set(new_scroll);
680                        }
681                        geometry_for_nav.chase_into_outer_view(
682                            ctx,
683                            vb_for_nav.get(),
684                            idx,
685                            new_scroll,
686                            count,
687                        );
688                        return teksilo_core::event::EventResponse::Handled;
689                    }
690                }
691                teksilo_core::event::EventResponse::Ignored
692            });
693        }
694
695        // --- DnD: register self as a drop target when it can reorder OR accept
696        // foreign rows. The source's `can_accept` decides per-hover whether the
697        // drop is allowed (and a forbidden verdict shows no insertion line). ---
698        if self.export.is_drop_target(self.reorderable) {
699            let geometry_for_hover = self.geometry();
700            let scroll_for_hover = self.scroll_y.clone();
701            let len_for_hover = self.source.len_fn.clone();
702            let can_accept_for_hover = self.source.dnd.can_accept_fn.clone();
703            let my_view_id = self.model_id;
704
705            let feedback_for_hover = self.drop_feedback.clone();
706            let width_for_hover = self.placed_content_width.clone();
707            let export_for_hover = self.export.clone();
708            let pinned_for_hover = self.pinned_shown.clone();
709            handlers = handlers.on_drag_hover(move |payload, position, _ctx| {
710                let scroll = scroll_for_hover.get().max(0.0);
711                let len = (len_for_hover)();
712                // This list's own rows, being moved: with sections they decide
713                // which side of a header they land on.
714                let dragged: &[usize] = payload
715                    .get_typed::<RowDragData<T>>()
716                    .filter(|rd| rd.source == my_view_id)
717                    .map_or(&[], |rd| rd.rows.as_slice());
718                let (ins, insertion_y) = geometry_for_hover.drop_at(
719                    position.y,
720                    scroll,
721                    len,
722                    dragged,
723                    pinned_for_hover.get(),
724                );
725                let line_width = width_for_hover.get();
726                // Ask the source whether a drop here is allowed; paint the
727                // insertion line only when it is. A foreign exported row is
728                // allowed when `accept_foreign_rows` is on even though a bare
729                // `ListModel`'s `can_accept` rejects the `Foreign` branch.
730                let allowed = flat_insertion_target(ins, len).is_some_and(|(target, pos)| {
731                    !matches!(
732                        (can_accept_for_hover)(payload, target, pos, my_view_id),
733                        DropResponse::Reject
734                    ) || export_for_hover.accepts_foreign_export(payload, my_view_id)
735                });
736                if allowed {
737                    feedback_for_hover.set(Some((insertion_y, line_width)));
738                    DropFeedback::InsertionLine {
739                        y: insertion_y,
740                        width: line_width,
741                    }
742                } else {
743                    feedback_for_hover.set(None);
744                    DropFeedback::NoFeedback
745                }
746            });
747
748            let len_for_drop = self.source.len_fn.clone();
749            let accept_drop_for_drop = self.source.dnd.accept_drop_fn.clone();
750            let drop_view_id = self.model_id;
751            let scroll_for_drop = self.scroll_y.clone();
752            let geometry_for_drop = self.geometry();
753            let export_for_drop = self.export.clone();
754            let reorderable_for_drop = self.reorderable;
755            let pinned_for_drop = self.pinned_shown.clone();
756
757            handlers = handlers.on_drop(move |mut payload, position, ctx| {
758                let scroll = scroll_for_drop.get().max(0.0);
759                let len = (len_for_drop)();
760                // The index does not depend on the dragged rows, only the line.
761                let (ins, _) =
762                    geometry_for_drop.drop_at(position.y, scroll, len, &[], pinned_for_drop.get());
763                let is_same_view = payload
764                    .get_typed::<RowDragData<T>>()
765                    .is_some_and(|rd| rd.source == drop_view_id);
766                // A same-view reorder only happens when the view is
767                // `reorderable`; a foreign payload is the source's call (a bare
768                // ListModel rejects it).
769                if (reorderable_for_drop || !is_same_view)
770                    && let Some((target, position_kind)) = flat_insertion_target(ins, len)
771                    && (accept_drop_for_drop)(&payload, target, position_kind, drop_view_id)
772                {
773                    if is_same_view {
774                        export_for_drop.note_self_reorder();
775                    }
776                    return true;
777                }
778                // Otherwise, the shared foreign-receive sugar (peek-before-take).
779                export_for_drop.foreign_receive(&mut payload, drop_view_id, ins, ctx)
780            });
781
782            // Clear the insertion line whenever the drag leaves this
783            // widget — pointer moves to another target, drop completes,
784            // Escape cancels, or the source is destroyed.
785            let feedback_for_leave = self.drop_feedback.clone();
786            handlers = handlers.on_drag_leave(move |_ctx| {
787                feedback_for_leave.set(None);
788            });
789
790            // Per-frame auto-scroll when the pointer lingers near the
791            // viewport top or bottom edge during a drag — the band is the
792            // pointer kind's, 32 dp for a mouse or pen and 64 dp for a finger
793            // (`common::drag_autoscroll`).
794            // Linear ramp inside the edge zone, capped at ~12 px/frame
795            // so fast-moving fingers still feel responsive but don't
796            // rocket past the content.
797            let scroll_for_tick = self.scroll_y.clone();
798            let max_scroll_for_tick = self.max_scroll_y.clone();
799            let viewport_for_tick = self.viewport_height.clone();
800            handlers = handlers.on_drag_tick(move |pos, ctx| {
801                let h = viewport_for_tick.get();
802                let band = crate::common::drag_autoscroll::band_for(ctx.pointer_kind());
803                let delta = crate::common::drag_autoscroll::step(pos.y, h, band);
804                if delta.abs() > 0.01 {
805                    let max = max_scroll_for_tick.get();
806                    let new_y = (scroll_for_tick.get() + delta).clamp(0.0, max);
807                    scroll_for_tick.set(new_y);
808                }
809            });
810        }
811
812        // Export completion (move-out): fires on the drag source — this view's
813        // root id, the stable id start_drag was given.
814        handlers = self.export.install_completion(handlers);
815
816        ctx.apply_self_handlers(handlers);
817
818        // --- Body pane ---
819        // Hoisted into its own widget so that scroll-buffer-exit rebuilds
820        // (which happen mid-thumb-drag once the user scrolls past the
821        // buffered range) target a SIBLING of the scrollbar rather than the
822        // scrollbar's ancestor. Rebuilding the ancestor would be deferred by
823        // the framework to preserve the captured drag, leaving the list blank
824        // until the user released the thumb. See `body_pane`'s module docs.
825        let pane = body_pane::ListBodyPane::<T> {
826            source: self.source.clone(),
827            delegate: self.delegate.clone(),
828            row_tooltips: self.row_tooltips.clone(),
829            geometry: self.geometry(),
830            header_factory: self.header_factory(),
831            row_selection: self.row_selection.clone(),
832            focused_index: self.focused_index.clone(),
833            row_map: self.row_map.clone(),
834            reorderable: self.reorderable,
835            reorder_perform: reorder_perform.clone(),
836            export: self.export.clone(),
837            on_activate: self.on_activate.clone(),
838            activate_on: self.activate_on,
839            model_id: self.model_id,
840            root_id: self_id,
841            scroll_y: self.scroll_y.clone(),
842            viewport_height: self.viewport_height.clone(),
843            placed_content_width: self.placed_content_width.clone(),
844            version: self.pane_version.clone(),
845            total_refresh: self.layout_refresh.clone(),
846            prev_built_start: self.pane_built_start.clone(),
847            prev_built_end: self.pane_built_end.clone(),
848            prev_built_headers: self.pane_built_headers.clone(),
849            item_entries: Vec::new(),
850            child_roots: Vec::new(),
851            child_kinds: Vec::new(),
852            header_tab_stops: Vec::new(),
853            presentational: self.presentational,
854            reveal: self.reveal.clone(),
855        };
856        self.body_pane_id = Some(ctx.add(pane));
857
858        // --- Pinned section header ---
859        // Built whenever pinning is asked for, even while the provider has no
860        // section: the slot then holds nothing, and fills in once a model
861        // change gives it one. The root never rebuilds on a data change, so a
862        // slot skipped here could never appear later.
863        self.pinned_header_id = None;
864        if self.pinned_section_headers
865            && let (Some(table), Some(factory)) = (self.sections.clone(), self.header_factory())
866        {
867            self.pinned_header_id = Some(ctx.add(sections::PinnedSectionHeader {
868                section: self.pinned_section.clone(),
869                refresh: self.pinned_refresh.clone(),
870                built: self.pinned_built.clone(),
871                factory,
872                table,
873                len_fn: self.source.len_fn.clone(),
874                child: None,
875            }));
876            // The section is decided as the scroll moves, not only when the
877            // root is next placed: a scroll set by input, an animation or
878            // `scroll_to_index` lands before the frame's rebuilds, so the slot
879            // is rebuilt for the new section in the frame that shows it, and
880            // never spends a frame hidden while it catches up.
881            // `place_children` decides it again, for the offsets written
882            // during layout (the clamp, a measurement's anchor).
883            let geometry = self.geometry();
884            let len = self.source.len_fn.clone();
885            let pinned_section = self.pinned_section.clone();
886            let handle = self.scroll_y.observe(move |y| {
887                let section = geometry.pinned_header(*y, len()).map(|p| p.section);
888                if pinned_section.get() != section {
889                    pinned_section.set(section);
890                }
891            });
892            ctx.own_handle(handle);
893        }
894
895        // --- Create scrollbar ---
896        // Skipped when the caller opted out via `show_scrollbar(false)`
897        // — they're expected to mount their own, wired through the
898        // exposed signal accessors.
899        if self.show_scrollbar {
900            let scrollbar = ScrollBar::new(
901                ScrollBarOrientation::Vertical,
902                self.scroll_y.clone(),
903                self.max_scroll_y.clone(),
904                self.viewport_ratio_y.clone(),
905            )
906            .visual(match self.scroll_bar_style {
907                ScrollBarMode::Permanent => ScrollBarVisual::Permanent,
908                ScrollBarMode::Overlay => ScrollBarVisual::Overlay,
909                ScrollBarMode::Thin => ScrollBarVisual::Thin,
910            });
911            let sb_id = ctx.add(scrollbar);
912            self.scrollbar_id = Some(sb_id);
913        } else {
914            self.scrollbar_id = None;
915        }
916
917        self.child_ids()
918    }
919
920    fn layout_response(
921        &self,
922        proposal: SizeProposal,
923        _ctx: &LayoutContext,
924    ) -> teksilo_core::widget::LayoutResponse {
925        // The viewport takes whatever the parent offers — but only an
926        // allocation is cached for the visible-range computation; a
927        // measurement's fallback is not a viewport (`common::viewport`).
928        crate::common::viewport::viewport_size(
929            proposal,
930            &self.viewport_height,
931            Size::new(300.0, 200.0),
932        )
933        .into()
934    }
935
936    fn place_children(
937        &self,
938        bounds: Rect,
939        _proposal: SizeProposal,
940        children: &mut [WidgetPlacement],
941        _ctx: &LayoutContext,
942    ) {
943        // Cache our own absolute bounds for the keyboard handler's
944        // outer-scroll chase (`ensure_visible`). Done before the empty-children
945        // bail so the rect stays fresh even for an empty list that later fills.
946        self.viewport_bounds.set(bounds);
947        // The allocated height is the authoritative viewport: `build` sizes its
948        // realization window from this, and a stale value there costs a
949        // permanent rebuild loop (`common::viewport`).
950        crate::common::viewport::record_viewport_height(&self.viewport_height, bounds.height);
951        // The rubber band's resistance is a fraction of the viewport. This
952        // view does not band, but the scroller reads the extent either way and
953        // this is the only pass that knows it.
954        self.scroller
955            .borrow_mut()
956            .set_viewport(teksilo_canvas::Vec2::new(bounds.width, bounds.height));
957
958        if children.is_empty() {
959            return;
960        }
961
962        let viewport_height = bounds.height;
963
964        // The scrollbar decision uses the pre-measure total: the content
965        // width must be known before rows can be measured at it. If a
966        // measurement flips the decision, the next frame corrects it.
967        let provisional_total = self.total_content_height();
968        let needs_internal_scrollbar =
969            self.show_scrollbar && provisional_total > viewport_height + 0.5;
970        let reserves_bar = self.scroll_bar_style == ScrollBarMode::Permanent;
971        let content_width = if needs_internal_scrollbar && reserves_bar {
972            (bounds.width - SCROLLBAR_THICKNESS).max(0.0)
973        } else {
974            bounds.width
975        };
976        self.placed_content_width.set(content_width);
977
978        // Totals for the scrollbar. In auto-measure mode these are computed
979        // BEFORE the pane measures its rows (parent-before-child ordering), so
980        // the pane pokes `layout_refresh` when a measurement moves the total
981        // and we re-place next frame with the corrected value.
982        let total_height = self.total_content_height();
983        let max_y = (total_height - viewport_height).max(0.0);
984        self.max_scroll_y.set(max_y);
985        let ratio = if total_height > 0.0 {
986            (viewport_height / total_height).clamp(0.0, 1.0)
987        } else {
988            1.0
989        };
990        self.viewport_ratio_y.set(ratio);
991        self.clamp_scroll();
992
993        // The pinned header: the current section is decided from the clamped
994        // offset, and handed to the slot when it changed. The scroll observer
995        // in `build` has normally done so already. When it has not (an offset
996        // written during layout) the slot rebuilds next frame, and until it
997        // has, it is not shown, rather than show the previous section's title
998        // over this one's rows.
999        let pinned_shown = self.pinned_header_id.and_then(|_| {
1000            let placement = self
1001                .geometry()
1002                .pinned_header(self.scroll_y.get(), self.source.len());
1003            let section = placement.map(|p| p.section);
1004            if self.pinned_section.get() != section {
1005                self.pinned_section.set(section);
1006            }
1007            let placement = placement?;
1008            (placement.visible && self.pinned_built.get() == Some(placement.section)).then_some(
1009                sections::PinnedShown {
1010                    section: placement.section,
1011                    top: placement.offset,
1012                    bottom: placement.offset + self.section_header_height,
1013                },
1014            )
1015        });
1016        self.pinned_shown.set(pinned_shown);
1017
1018        // The body pane fills the content column and positions its own rows;
1019        // the pinned header lies over its top; the scrollbar sits alongside.
1020        for child in children.iter_mut() {
1021            if Some(child.id) == self.body_pane_id {
1022                child.origin = bounds.origin();
1023                child.size = Size::new(content_width, bounds.height);
1024            } else if Some(child.id) == self.pinned_header_id {
1025                match pinned_shown {
1026                    Some(shown) => {
1027                        child.origin = Point::new(bounds.x, bounds.y + shown.top);
1028                        child.size = Size::new(content_width, self.section_header_height);
1029                    }
1030                    None => {
1031                        child.origin = bounds.origin();
1032                        child.size = Size::ZERO;
1033                    }
1034                }
1035            } else if Some(child.id) == self.scrollbar_id {
1036                if needs_internal_scrollbar {
1037                    child.origin =
1038                        Point::new(bounds.x + bounds.width - SCROLLBAR_THICKNESS, bounds.y);
1039                    child.size = Size::new(SCROLLBAR_THICKNESS, bounds.height);
1040                } else {
1041                    child.origin = bounds.origin();
1042                    child.size = Size::ZERO;
1043                }
1044            }
1045        }
1046    }
1047
1048    fn wants_post_paint(&self) -> bool {
1049        true
1050    }
1051
1052    /// The insertion line and the container focus ring, drawn over the
1053    /// children.
1054    ///
1055    /// Not in `paint`, which runs before them: the pinned section header is
1056    /// an opaque child over the top of the viewport, and would cover the top
1057    /// edge of the ring and a line drawn along or under it. A row that paints
1058    /// its own background covers a line the same way.
1059    fn post_paint(
1060        &self,
1061        bounds: Rect,
1062        canvas: &mut teksilo_canvas::Canvas,
1063        ctx: &teksilo_core::widget::PaintContext,
1064    ) {
1065        // Draw insertion line during drag hover. Recipe-driven role +
1066        // thickness — defaults to BorderRole::Accent / 2 dp; a custom
1067        // `ListContainerStyle` installed via the theme slot overrides.
1068        if let Some((y, width)) = self.drop_feedback.get() {
1069            let recipe = ctx
1070                .theme
1071                .style_slots
1072                .list_container
1073                .as_ref()
1074                .map(|s| s.insertion())
1075                .unwrap_or_default();
1076            let color = recipe.role.resolve(&ctx.theme.colors);
1077            let half = recipe.thickness * 0.5;
1078            // Kept whole inside the viewport rather than clipped at its
1079            // edge: a line at the first or the after-last boundary, or along
1080            // the top edge of the pinned header, would lose half its
1081            // thickness.
1082            let line_y = (bounds.y + y)
1083                .min(bounds.y + bounds.height - half)
1084                .max(bounds.y + half);
1085            canvas.fill_rect(
1086                Rect::new(bounds.x, line_y - half, width, recipe.thickness),
1087                color,
1088            );
1089        }
1090
1091        // Container focus ring — keyboard focus landed but nothing is selected,
1092        // so no row ring shows; outline the whole view (see TreeView).
1093        let has_selection = self
1094            .row_selection
1095            .as_ref()
1096            .is_some_and(|s| s.has_selection());
1097        if self.view_focused.get() && self.focus_visible.get() && !has_selection {
1098            let color = BorderRole::Focused.resolve(&ctx.theme.colors);
1099            let inset = 1.0_f32;
1100            let rect = Rect::new(
1101                bounds.x + inset,
1102                bounds.y + inset,
1103                (bounds.width - inset * 2.0).max(0.0),
1104                (bounds.height - inset * 2.0).max(0.0),
1105            );
1106            canvas.stroke_rect(rect, color, 1.5);
1107        }
1108    }
1109
1110    /// The context-menu key opens the *current row's* menu, not the list's.
1111    ///
1112    /// A `ListView` is focusable and its rows deliberately are not — the
1113    /// container owns focus and `set_selected` is what tells assistive
1114    /// technology which row is current (see `list_item_a11y`). So the
1115    /// dispatcher's default of "the focused widget" would open the list's own
1116    /// menu, in the widget family where a per-row menu matters most.
1117    ///
1118    /// The row the user means is the keyboard cursor if they have navigated
1119    /// (`focused_index`), else the first selected row. Both are indices into
1120    /// the model, and only realized rows have a widget, so a cursor scrolled
1121    /// outside the virtualization window resolves to nothing and the menu falls
1122    /// back to the list — which is the right answer, since there is no row on
1123    /// screen for it to be about.
1124    fn context_menu_key_target(&self) -> Option<WidgetId> {
1125        self.current_row_widget()
1126    }
1127
1128    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1129        if self.presentational {
1130            builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
1131            return;
1132        }
1133        builder.set_role(teksilo_core::accesskit::Role::ListBox);
1134        // Whether the selection takes more than one row. A real property on
1135        // both platforms that have one: UIA's `SelectionCanSelectMultiple`
1136        // and AT-SPI's multiselectable state. Left unset it reads false, so a
1137        // multi-select view was telling every screen reader that one row was
1138        // the most it would ever hold.
1139        //
1140        // Gated on the mode, and the gate matters beyond tidiness:
1141        // `accesskit_windows` picks the event it raises on a selection change
1142        // from this property (`adapter.rs:189-199`), firing
1143        // `ElementAddedToSelection` when it is true and `ElementSelected` when
1144        // it is false. A single-select view publishing `true` would trade the
1145        // right event for the wrong one.
1146        if self
1147            .row_selection
1148            .as_ref()
1149            .is_some_and(|selection| selection.mode() == teksilo_data::SelectionMode::Multi)
1150        {
1151            builder.set_multiselectable(true);
1152        }
1153
1154        // The logical row count, not the realized virtualization window: a
1155        // 200-row list announces "of 200" even while twenty rows exist as
1156        // widgets. It belongs here rather than on each row, because
1157        // `size_of_set_from_container` resolves an item's set size by walking
1158        // *up* from it — a size written on a row is read by no adapter.
1159        builder.set_size_of_set(self.source.len());
1160
1161        // The current row, as the container's active descendant.
1162        //
1163        // Keyboard focus stays here, on the list, and the row is marked
1164        // `selected`. On AT-SPI that is the whole story: Orca announces the
1165        // selection change. On Windows it is not, because UIA has no
1166        // active-descendant property at all — what it has is a focused
1167        // element, and for a list box that element is the item.
1168        //
1169        // AccessKit bridges the two in the consumer rather than in each
1170        // adapter: `accesskit_consumer` resolves the focused node as
1171        // `focused.active_descendant().unwrap_or(focused)`
1172        // (`tree.rs:541`) and `accesskit_windows::focus_moved`
1173        // (`adapter.rs:341-345`) raises `UIA_AutomationFocusChangedEventId` on
1174        // whatever comes out. So this one property turns every arrow press
1175        // into the focus change a screen reader announces, and `is_focused`
1176        // (`consumer node.rs:89-105`) moves from this container to the row,
1177        // which is what the ARIA listbox pattern says should happen.
1178        //
1179        // Without it, arrowing through any Teksilo list is silent to NVDA:
1180        // there is no focus change to announce, and the selection event that
1181        // is raised names a row node the pane rebuilt a moment earlier. The
1182        // mouse still reads rows correctly, because hit-testing does not go
1183        // through events at all, which is exactly how this hid for so long.
1184        // Only while this view actually holds focus. A container that does not
1185        // have focus has no active descendant to speak of, and publishing one
1186        // anyway puts a second relation in the tree for a client to follow: the
1187        // combobox pattern (`CommandPalette`) keeps focus on a text field that
1188        // points at a row in *this* list, and two publishers of the same row is
1189        // an ambiguity nobody needs to resolve.
1190        if self.view_focused.get()
1191            && let Some(row) = self.current_row_widget()
1192        {
1193            builder.set_active_descendant(teksilo_core::accessibility::widget_id_to_node_id(row));
1194        }
1195    }
1196
1197    fn as_any(&self) -> Option<&dyn std::any::Any> {
1198        Some(self)
1199    }
1200
1201    fn children(&self) -> Vec<WidgetId> {
1202        self.child_ids()
1203    }
1204
1205    fn clips_children(&self) -> bool {
1206        true
1207    }
1208}