Skip to main content

herogpui_components/
list_box.rs

1//! ListBox — port of `@heroui/list-box` (v3).
2//!
3//! A list of options, nonselecting by default: the pinned `useListState`
4//! defaults `selectionMode` to `none`. Mirrors the React API: `selectionMode`,
5//! `selectedKeys`, `disabledKeys`, `onSelectionChange`, `onAction`, and the
6//! `default | danger` item variant. Sections are expressed with
7//! [`ListBoxItem::section`] headers and [`ListBoxItem::separator`].
8
9use std::collections::HashSet;
10use std::sync::Arc;
11
12use gpui::{
13    div, prelude::*, px, App, ElementId, InteractiveElement, IntoElement, RenderOnce, SharedString,
14    Styled, Window,
15};
16use herogpui_core::{element_id, SelectionMode};
17use herogpui_theme::ActiveTheme;
18
19use crate::{
20    a11y::{self, A11y as _},
21    icons, util, EscapeKeyBehavior,
22};
23
24/// Visual variant of a list item.
25#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
26pub enum ListBoxItemVariant {
27    #[default]
28    /// The standard appearance.
29    Default,
30    /// Destructive action — danger text, danger-soft hover.
31    Danger,
32}
33
34/// One row of a [`ListBox`].
35#[must_use = "builder methods return a new value; pass the item to its component"]
36#[derive(Clone)]
37pub enum ListBoxItem {
38    /// A selectable option.
39    Option {
40        /// Unique key identifying the option.
41        key: SharedString,
42        /// Text shown for the option.
43        label: SharedString,
44        /// Optional secondary line beneath the label.
45        description: Option<SharedString>,
46        /// Asset path of a leading icon.
47        icon: Option<SharedString>,
48        /// Trailing shortcut hint.
49        shortcut: Option<SharedString>,
50        /// Visual variant of the option.
51        variant: ListBoxItemVariant,
52        /// Whether the option is disabled.
53        is_disabled: bool,
54    },
55    /// A non-interactive section header.
56    Section(SharedString),
57    /// A horizontal rule between groups.
58    Separator,
59}
60
61impl ListBoxItem {
62    /// Creates an option with the given key and label.
63    pub fn new(key: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
64        Self::Option {
65            key: key.into(),
66            label: label.into(),
67            description: None,
68            icon: None,
69            shortcut: None,
70            variant: ListBoxItemVariant::Default,
71            is_disabled: false,
72        }
73    }
74
75    /// Creates a non-interactive section header.
76    pub fn section(label: impl Into<SharedString>) -> Self {
77        Self::Section(label.into())
78    }
79
80    /// Creates a horizontal separator between groups.
81    pub fn separator() -> Self {
82        Self::Separator
83    }
84
85    /// Secondary line beneath the label.
86    pub fn description(mut self, text: impl Into<SharedString>) -> Self {
87        if let Self::Option { description, .. } = &mut self {
88            *description = Some(text.into());
89        }
90        self
91    }
92
93    /// Sets the leading icon asset path. Ignored for headers and separators.
94    pub fn icon(mut self, path: impl Into<SharedString>) -> Self {
95        if let Self::Option { icon, .. } = &mut self {
96            *icon = Some(path.into());
97        }
98        self
99    }
100
101    /// Sets the trailing shortcut hint. Ignored for headers and separators.
102    pub fn shortcut(mut self, text: impl Into<SharedString>) -> Self {
103        if let Self::Option { shortcut, .. } = &mut self {
104            *shortcut = Some(text.into());
105        }
106        self
107    }
108
109    /// Sets the option's visual variant. Ignored for headers and separators.
110    pub fn variant(mut self, v: ListBoxItemVariant) -> Self {
111        if let Self::Option { variant, .. } = &mut self {
112            *variant = v;
113        }
114        self
115    }
116
117    /// Shorthand for [`ListBoxItemVariant::Danger`].
118    pub fn danger(self) -> Self {
119        self.variant(ListBoxItemVariant::Danger)
120    }
121
122    /// Disables the option (v3 `isDisabled`). Ignored for headers and separators.
123    pub fn is_disabled(mut self, v: bool) -> Self {
124        if let Self::Option { is_disabled, .. } = &mut self {
125            *is_disabled = v;
126        }
127        self
128    }
129
130    /// The item's key, or `None` for headers and separators.
131    pub fn key(&self) -> Option<&SharedString> {
132        match self {
133            Self::Option { key, .. } => Some(key),
134            _ => None,
135        }
136    }
137}
138
139type OnSelectionChange = Arc<dyn Fn(&HashSet<SharedString>, &mut Window, &mut App) + 'static>;
140type OnAction = Arc<dyn Fn(&SharedString, &mut Window, &mut App) + 'static>;
141/// `ListBox.ItemIndicator`'s render function, handed `isSelected`.
142type Indicator = Arc<dyn Fn(bool) -> gpui::AnyElement + 'static>;
143
144/// The anchor and the moving end of a Shift range in a multiple-selection
145/// collection (React Stately's `anchorKey` / `currentKey`), and whether the
146/// selection is a raw select-all. `TreeView` shares it.
147#[derive(Clone, Debug, Default)]
148pub(crate) struct ListBoxSelectionRange {
149    pub(crate) anchor: Option<SharedString>,
150    pub(crate) current: Option<SharedString>,
151    pub(crate) is_all: bool,
152}
153
154/// React Stately's `extendSelection`: drop the keys between the anchor and
155/// the previous range end, then add the selectable keys between the anchor
156/// and `target`, in `collection` order. A raw select-all collapses to
157/// `target`, and with no anchor the range starts at `target`. `TreeView`
158/// shares it.
159pub(crate) fn extend_selection_range(
160    current: &HashSet<SharedString>,
161    collection: &[SharedString],
162    selectable: &HashSet<SharedString>,
163    range: &ListBoxSelectionRange,
164    target: &SharedString,
165) -> HashSet<SharedString> {
166    if range.is_all {
167        return HashSet::from([target.clone()]);
168    }
169    let anchor = range.anchor.as_ref().unwrap_or(target);
170    let previous = range.current.as_ref().unwrap_or(target);
171    let anchor_at = collection.iter().position(|key| key == anchor);
172    let previous_at = collection.iter().position(|key| key == previous);
173    let target_at = collection.iter().position(|key| key == target);
174    let between = |from: Option<usize>, to: Option<usize>| {
175        from.zip(to)
176            .map(|(from, to)| if from <= to { from..=to } else { to..=from })
177    };
178    let mut next = current.clone();
179    if let Some(previous_range) = between(anchor_at, previous_at) {
180        for index in previous_range {
181            next.remove(&collection[index]);
182        }
183    }
184    if let Some(target_range) = between(anchor_at, target_at) {
185        for index in target_range {
186            let key = &collection[index];
187            if selectable.contains(key) {
188                next.insert(key.clone());
189            }
190        }
191    }
192    next
193}
194
195/// Pinned React Aria 3.51.0 `useSelectableCollection` registers Home and End
196/// only for the chords each platform's handler admits: none, Shift, Alt, and
197/// Alt+Shift on macOS -- no Meta or Control handler exists -- and none,
198/// Shift, Control, and Control+Shift on Windows and Linux. The upstream
199/// matcher reads exactly the browser's canonical modifier flags -- Alt,
200/// Control, Meta, Shift -- so GPUI's `function` flag is ignored here: a
201/// browser exposes no Fn state for it to read, so vetoing on the flag would
202/// claim a pinned guard that does not exist, and the framework delivers an
203/// Fn-bearing press with every matched modifier flag still false. A chord
204/// outside the registration is entirely inert: no focus move, no selection,
205/// no preventDefault. `macos` is simulated explicitly so every platform's
206/// unit tests can prove both maps.
207fn home_end_registered(modifiers: gpui::Modifiers, macos: bool) -> bool {
208    if macos {
209        !modifiers.control && !modifiers.platform
210    } else {
211        !modifiers.alt && !modifiers.platform
212    }
213}
214
215/// Pinned `useSelectableCollection` (`isCtrlKeyPressed`): a Shift move
216/// extends the range on the collection's navigation keys, while Home and
217/// End extend only from Control+Shift on Windows and Linux. macOS registers
218/// no Home/End extension at all -- its Shift and Alt+Shift chords move the
219/// focus alone -- so the platform is an explicit bool rather than a `cfg!`.
220fn shift_home_end_extends(key_name: &str, control: bool, macos: bool) -> bool {
221    !matches!(key_name, "home" | "end") || (!macos && control)
222}
223
224/// HeroUI ListBox.
225#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
226#[derive(IntoElement)]
227pub struct ListBox {
228    id: ElementId,
229    items: Vec<ListBoxItem>,
230    selection_mode: SelectionMode,
231    selected_keys: HashSet<SharedString>,
232    default_selected_keys: HashSet<SharedString>,
233    is_controlled: bool,
234    disallow_empty_selection: bool,
235    escape_key_behavior: EscapeKeyBehavior,
236    disabled_keys: HashSet<SharedString>,
237    /// Applies to every item unless the item overrides it.
238    variant: ListBoxItemVariant,
239    max_h: Option<gpui::Pixels>,
240    /// `shouldFocusWrap` — whether arrow keys wrap at the ends.
241    should_focus_wrap: bool,
242    /// `ListLayout`'s `rowHeight`. Setting it virtualizes the list: a fixed row
243    /// height is what lets the geometry be computed instead of laid out.
244    row_height: Option<gpui::Pixels>,
245    /// Replaces the option rows' `px-2` horizontal padding.
246    row_padding_x: Option<gpui::Pixels>,
247    /// Replaces the option rows' `py-1.5` vertical padding.
248    row_padding_y: Option<gpui::Pixels>,
249    /// The fill a hovered option row takes, in place of `--default`.
250    row_hover_bg: Option<gpui::Hsla>,
251    /// `ListLayout`'s `estimatedRowHeight` — the estimate that virtualizes a
252    /// list whose rows are *not* all one height.
253    estimated_row_height: Option<gpui::Pixels>,
254    /// `ListLayout`'s `headingHeight` — a section row's height when the list is
255    /// virtual.
256    heading_height: Option<gpui::Pixels>,
257    /// `ListLayout`'s `gap` and `padding`, which override the stylesheet's.
258    gap: gpui::Pixels,
259    padding: gpui::Pixels,
260    /// `ListBox.ItemIndicator` — draw the tick yourself. v3 hands its render
261    /// function `isSelected`, so this closure receives it.
262    indicator: Option<Indicator>,
263    /// `children` on `ListBox.Item` — a render function handed the row's key and
264    /// its state.
265    item_content:
266        Option<Arc<dyn Fn(&SharedString, util::InteractiveState) -> gpui::AnyElement + 'static>>,
267    on_selection_change: Option<OnSelectionChange>,
268    on_action: Option<OnAction>,
269    /// The `sx` slot, refined over the root style at the end of render.
270    sx: Option<Box<gpui::StyleRefinement>>,
271}
272
273impl ListBox {
274    /// Creates a non-selecting list with the given id and items.
275    pub fn new(id: impl Into<ElementId>, items: Vec<ListBoxItem>) -> Self {
276        Self {
277            id: id.into(),
278            items,
279            // Pinned `useListState` (`useMultipleSelectionState`) defaults
280            // `selectionMode` to `none`; HeroUI's v3 ListBox wrapper forwards
281            // props to React Aria untouched, so a plain list is nonselecting.
282            // Enable single or multiple selection with `selection_mode`.
283            selection_mode: SelectionMode::None,
284            selected_keys: HashSet::new(),
285            default_selected_keys: HashSet::new(),
286            is_controlled: false,
287            disallow_empty_selection: false,
288            escape_key_behavior: EscapeKeyBehavior::ClearSelection,
289            disabled_keys: HashSet::new(),
290            variant: ListBoxItemVariant::Default,
291            should_focus_wrap: false,
292            row_height: None,
293            row_padding_x: None,
294            row_padding_y: None,
295            row_hover_bg: None,
296            estimated_row_height: None,
297            heading_height: None,
298            // `.list-box` is `p-1` with `mt-1` between children.
299            gap: px(4.),
300            padding: px(4.),
301            max_h: None,
302            indicator: None,
303            item_content: None,
304            on_selection_change: None,
305            on_action: None,
306            sx: None,
307        }
308    }
309
310    /// Sets the selection mode (v3 `selectionMode`).
311    pub fn selection_mode(mut self, mode: SelectionMode) -> Self {
312        self.selection_mode = mode;
313        self
314    }
315
316    /// Sets the controlled selection (v3 `selectedKeys`) from item keys.
317    pub fn selected_keys(mut self, keys: impl IntoIterator<Item = SharedString>) -> Self {
318        self.selected_keys = keys.into_iter().collect();
319        self.is_controlled = true;
320        self
321    }
322
323    /// Controlled single-key convenience. Does not change the selection mode:
324    /// pair with `selection_mode(SelectionMode::Single)` for interactive picks.
325    pub fn selected_key(mut self, key: impl Into<SharedString>) -> Self {
326        self.selected_keys = HashSet::from([key.into()]);
327        self.is_controlled = true;
328        self
329    }
330
331    /// `defaultSelectedKeys` — seeds the list's own selection state.
332    pub fn default_selected_keys(mut self, keys: impl IntoIterator<Item = SharedString>) -> Self {
333        self.default_selected_keys = keys.into_iter().collect();
334        self
335    }
336
337    /// `disallowEmptySelection` — keeps the final selected item selected.
338    pub fn disallow_empty_selection(mut self, v: bool) -> Self {
339        self.disallow_empty_selection = v;
340        self
341    }
342
343    /// `escapeKeyBehavior` — whether unmodified Escape clears selection.
344    pub fn escape_key_behavior(mut self, behavior: EscapeKeyBehavior) -> Self {
345        self.escape_key_behavior = behavior;
346        self
347    }
348
349    /// Disables the given item keys (v3 `disabledKeys`).
350    pub fn disabled_keys(mut self, keys: impl IntoIterator<Item = SharedString>) -> Self {
351        self.disabled_keys = keys.into_iter().collect();
352        self
353    }
354
355    /// Sets the visual variant applied to the list's options.
356    pub fn variant(mut self, variant: ListBoxItemVariant) -> Self {
357        self.variant = variant;
358        self
359    }
360
361    /// `shouldFocusWrap` — whether the arrow keys wrap at the ends of the list.
362    pub fn should_focus_wrap(mut self, v: bool) -> Self {
363        self.should_focus_wrap = v;
364        self
365    }
366
367    /// Caps the list height and scrolls beyond it.
368    pub fn max_h(mut self, h: impl Into<gpui::Pixels>) -> Self {
369        self.max_h = Some(h.into());
370        self
371    }
372
373    /// `ListLayout`'s `rowHeight` — **and** what virtualizes the list.
374    ///
375    /// v3 wraps the list in `<Virtualizer layout={ListLayout}
376    /// layoutOptions={{rowHeight: 50}}>`; the wrapper has no separate identity
377    /// here, so the option that defines the layout carries it. a uniform
378    /// [`VirtualList`](crate::VirtualList) builds only the rows the viewport shows, and it can do
379    /// that because every row is this tall.
380    pub fn row_height(mut self, h: impl Into<gpui::Pixels>) -> Self {
381        self.row_height = Some(h.into());
382        self
383    }
384
385    /// `ListLayout`'s `estimatedRowHeight` — virtualize rows that are *not* all
386    /// the same height.
387    ///
388    /// `rowHeight` maps to a uniform `VirtualList`, which measures one row and multiplies;
389    /// this maps to gpui's `list`, which measures each row it builds and keeps a
390    /// running total, so a described row and a plain one can differ. The estimate
391    /// is what it renders beyond the viewport (`overdraw`) while it learns the
392    /// real heights.
393    pub fn estimated_row_height(mut self, h: impl Into<gpui::Pixels>) -> Self {
394        self.estimated_row_height = Some(h.into());
395        self
396    }
397
398    /// `ListLayout`'s `headingHeight` — how tall a section row is in a virtual
399    /// list, where a row cannot size itself.
400    pub fn heading_height(mut self, h: impl Into<gpui::Pixels>) -> Self {
401        self.heading_height = Some(h.into());
402        self
403    }
404
405    /// `ListLayout`'s `gap`, overriding the stylesheet's `mt-1`.
406    pub fn gap(mut self, gap: impl Into<gpui::Pixels>) -> Self {
407        self.gap = gap.into();
408        self
409    }
410
411    /// `ListLayout`'s `padding`, overriding the stylesheet's `p-1`.
412    pub fn padding(mut self, padding: impl Into<gpui::Pixels>) -> Self {
413        self.padding = padding.into();
414        self
415    }
416
417    /// Replaces the option rows' `px-2` horizontal padding. Section headings
418    /// keep their own inset.
419    pub fn row_padding_x(mut self, p: impl Into<gpui::Pixels>) -> Self {
420        self.row_padding_x = Some(p.into());
421        self
422    }
423
424    /// Replaces the option rows' `py-1.5` vertical padding.
425    pub fn row_padding_y(mut self, p: impl Into<gpui::Pixels>) -> Self {
426        self.row_padding_y = Some(p.into());
427        self
428    }
429
430    /// The fill a hovered option row takes, in place of `--default`.
431    pub fn row_hover_bg(mut self, color: impl Into<gpui::Hsla>) -> Self {
432        self.row_hover_bg = Some(color.into());
433        self
434    }
435
436    /// `ListBox.ItemIndicator` — draw the selected tick yourself.
437    ///
438    /// The closure is handed `isSelected`, the value v3 passes into the same
439    /// render function, so a caller can return its own glyph, or nothing.
440    /// `children` on `ListBox.Item` — replaces a row's label.
441    ///
442    /// The closure is handed the row's key and the state v3 passes into the same
443    /// render prop: `isSelected`, `isFocused`, `isPressed` and `isDisabled`. The
444    /// press is a frame behind the pointer or activation key, because gpui
445    /// reports it to a handler.
446    pub fn item_content(
447        mut self,
448        render: impl Fn(&SharedString, util::InteractiveState) -> gpui::AnyElement + 'static,
449    ) -> Self {
450        self.item_content = Some(Arc::new(render));
451        self
452    }
453
454    /// Renders a custom indicator for each option; the closure receives whether the option is selected.
455    pub fn indicator(mut self, render: impl Fn(bool) -> gpui::AnyElement + 'static) -> Self {
456        self.indicator = Some(Arc::new(render));
457        self
458    }
459
460    /// Called with the full selection after a toggle.
461    pub fn on_selection_change(
462        mut self,
463        handler: impl Fn(&HashSet<SharedString>, &mut Window, &mut App) + 'static,
464    ) -> Self {
465        self.on_selection_change = Some(Arc::new(handler));
466        self
467    }
468
469    /// Called when an item is activated, regardless of selection mode.
470    pub fn on_action(
471        mut self,
472        handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
473    ) -> Self {
474        self.on_action = Some(Arc::new(handler));
475        self
476    }
477
478    /// The one slot for caller-owned low-level styling: GPUI's styling methods
479    /// (`bg`, `text_color`, `w`, `h`, `p`, `rounded`, `border_color`, …)
480    /// applied to the list box's root element after every value the layout
481    /// and the active theme chose, so they win.
482    pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
483        util::refine_sx(&mut self.sx, style);
484        self
485    }
486}
487
488impl RenderOnce for ListBox {
489    fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
490        // Which row the keyboard is on, and the handle that receives the keys.
491        // `use_keyed_state` takes `cx` mutably, so both precede the tokens.
492        let base_id = self.id.clone();
493        // The `debug_selector` spelling `collections.rs` queries on; a label,
494        // not an id.
495        let base = format!("{base_id:?}");
496        let focus_handle =
497            window.use_keyed_state(element_id::scoped(&base_id, "focus"), cx, |_, cx| {
498                cx.focus_handle().tab_stop(true)
499            });
500        let focus_handle = focus_handle.read(cx).clone();
501        let cursor = window.use_keyed_state(element_id::scoped(&base_id, "cursor"), cx, |_, _| {
502            None::<usize>
503        });
504        let selection_range = window.use_keyed_state(
505            element_id::scoped(&base_id, "selection-range"),
506            cx,
507            |_, _| ListBoxSelectionRange::default(),
508        );
509        let mut cursor_at = *cursor.read(cx);
510        let (selected_keys, selection_own) = util::controlled(
511            window,
512            cx,
513            element_id::scoped(&base_id, "selected"),
514            self.is_controlled.then(|| self.selected_keys.clone()),
515            self.default_selected_keys.clone(),
516        );
517        self.selected_keys = selected_keys;
518        // React Aria keeps the focused row in view. Two handles, because the
519        // virtual list owns its own scrolling and a plain one does not.
520        let list_scroll = {
521            let count = self.items.len();
522            window.use_keyed_state(
523                element_id::scoped(&base_id, "list-scroll"),
524                cx,
525                move |_, _| crate::VirtualListHandle::uniform(count),
526            )
527        };
528        let box_scroll =
529            window.use_keyed_state(element_id::scoped(&base_id, "box-scroll"), cx, |_, _| {
530                gpui::ScrollHandle::new()
531            });
532        // `gpui::list`'s state is intrusive -- the caller holds it -- so a
533        // variable-height list keeps one here, seeded with the item count and
534        // the estimate it overdraws by.
535        let list_state = {
536            let count = self.items.len();
537            let overdraw = self.estimated_row_height.unwrap_or(px(36.)) * 3.;
538            window.use_keyed_state(
539                element_id::scoped(&base_id, "list-state"),
540                cx,
541                move |_, _| crate::VirtualListHandle::with_overdraw(count, overdraw),
542            )
543        };
544        let variable_row_heights = if self.row_height.is_none()
545            && self.estimated_row_height.is_some()
546        {
547            let identities: Vec<String> = self
548                .items
549                .iter()
550                .enumerate()
551                .map(|(index, item)| match item {
552                    ListBoxItem::Option { key, .. } => format!("option:{key}"),
553                    ListBoxItem::Section(label) => format!("section:{label}"),
554                    ListBoxItem::Separator => format!("separator:{index}"),
555                })
556                .collect();
557            let count = identities.len();
558            let heights =
559                window.use_keyed_state(element_id::scoped(&base_id, "row-heights"), cx, |_, _| {
560                    (Vec::<String>::new(), Vec::<Option<gpui::Pixels>>::new())
561                });
562            if heights.read(cx).0 != identities {
563                heights.update(cx, |stored, _| {
564                    *stored = (identities, vec![None; count]);
565                });
566            }
567            Some(heights)
568        } else {
569            None
570        };
571        let list_scroll_now = list_scroll.read(cx).clone();
572        let box_scroll_now = box_scroll.read(cx).clone();
573        let list_handle_now = list_state.read(cx).clone();
574        let list_state_now = list_handle_now.list_state().clone();
575        // The letters typed so far. A search that reset every frame could only
576        // ever match one letter.
577        let typed = window.use_keyed_state(element_id::scoped(&base_id, "typed"), cx, |_, _| {
578            crate::list_nav::Typeahead::default()
579        });
580        // One hover/press slot per row. Custom `item_content` render props
581        // read the slot's state, and ordinary rows use the same slot for the
582        // default HeroUI `scale(0.98)` press skin.
583        let interaction: std::rc::Rc<Vec<util::Interaction>> = std::rc::Rc::new(
584            (0..self.items.len())
585                .map(|index| {
586                    util::interaction(
587                        element_id::scoped(
588                            &element_id::indexed(&self.id, "item", index),
589                            "interaction",
590                        ),
591                        window,
592                        cx,
593                    )
594                })
595                .collect(),
596        );
597
598        let colors = cx.colors();
599
600        // `.list-box` is `relative w-full overflow-clip p-1` with `mt-1` between
601        // children, and nothing else: the popover around it paints the panel.
602        // This used to draw its own surface, border and radius, which put a
603        // second panel inside every picker.
604        let mut list = div()
605            .id(self.id.clone())
606            // `react-aria/dist/private/listbox/useListBox.mjs` is one literal
607            // `role: 'listbox'` with `'aria-orientation': orientation`, which
608            // defaults to vertical and is the only axis this port's list
609            // lays out on. `aria-multiselectable` is the recorded omission in
610            // `crate::a11y`: gpui has no builder for it.
611            .a11y(a11y::Role::ListBox)
612            .a11y_orientation(herogpui_core::Orientation::Vertical)
613            .relative()
614            .w_full()
615            .flex()
616            .flex_col()
617            .gap(self.gap)
618            .p(self.padding)
619            .overflow_hidden()
620            .text_color(colors.foreground)
621            .track_focus(&focus_handle)
622            .key_context("ListBox")
623            // A click has to move the keyboard's focus onto the list, or the
624            // arrow keys would go nowhere after a pointer selection.
625            .on_mouse_down(gpui::MouseButton::Left, {
626                let fh = focus_handle.clone();
627                move |_, window, cx| window.focus(&fh, cx)
628            });
629
630        // A virtualized list scrolls inside its uniform `VirtualList`, which owns the
631        // scroll offset it computes the visible range from; a second scroller
632        // around it would move the rows without telling it.
633        if let (Some(max_h), None) = (self.max_h, self.row_height) {
634            list = list
635                .max_h(max_h)
636                .overflow_y_scroll()
637                .track_scroll(&box_scroll_now);
638        }
639
640        // The rows a keyboard can land on: an item that is not disabled.
641        // Sections and separators are skipped, so the cursor never stops on
642        // something that cannot be chosen.
643        let stops: Vec<usize> = self
644            .items
645            .iter()
646            .enumerate()
647            .filter(|(_, item)| match item {
648                ListBoxItem::Option {
649                    key, is_disabled, ..
650                } => !is_disabled && !self.disabled_keys.contains(key),
651                _ => false,
652            })
653            .map(|(i, _)| i)
654            .collect();
655
656        if let Some(stale) = cursor_at.filter(|index| !stops.contains(index)) {
657            cursor_at = stops
658                .iter()
659                .copied()
660                .find(|index| *index > stale)
661                .or_else(|| stops.iter().rev().copied().find(|index| *index < stale))
662                .or_else(|| stops.first().copied());
663            cursor.update(cx, |value, cx| {
664                *value = cursor_at;
665                cx.notify();
666            });
667        }
668
669        // React Aria moves collection focus on entry to the first selected
670        // option, falling back to the first enabled option. Keep the keyed
671        // cursor untouched until the user actually navigates, but use that
672        // entry stop for focus styling and immediate Enter/Space activation.
673        let has_focus = focus_handle.is_focused(window);
674        if cursor_at.is_none() && has_focus {
675            cursor_at = stops
676                .iter()
677                .copied()
678                .find(|index| match self.items.get(*index) {
679                    Some(ListBoxItem::Option { key, .. }) => self.selected_keys.contains(key),
680                    _ => false,
681                })
682                .or_else(|| stops.first().copied());
683        }
684        let focused_at = (window.is_window_active() && has_focus)
685            .then_some(cursor_at)
686            .flatten();
687        // The headless window may be inactive while its list still holds
688        // keyboard focus. Placement follows the active row regardless of
689        // focus-ring modality or window activation.
690        let anchor_at = has_focus.then_some(cursor_at).flatten();
691
692        if !stops.is_empty() || !self.selected_keys.is_empty() {
693            let held = cursor.clone();
694            let stops_for_keys = stops;
695            let wrap = self.should_focus_wrap;
696            let fixed_virtual = self.row_height.is_some();
697            // Pinned `ListKeyboardDelegate` pages by one visible rectangle, so
698            // the step reads the virtual list's own laid-out viewport -- the
699            // uniform handle's `viewport_bounds()` -- and not the configured
700            // `max_h` cap: a bounded parent (or a resized window) shows fewer
701            // rows than the cap allows. A zero viewport answers nothing, which
702            // the shared resolver turns into no movement.
703            let fixed_row_height = self.row_height;
704            let variable_scroll = self
705                .row_height
706                .is_none()
707                .then_some(self.estimated_row_height)
708                .flatten()
709                .map(|_| list_state_now.clone());
710            let variable_heights = variable_row_heights.clone();
711            let variable_estimate = self.estimated_row_height;
712            let plain_rows = self.row_height.is_none() && self.estimated_row_height.is_none();
713            let plain_scrollable = plain_rows && self.max_h.is_some();
714            let key_list_scroll = list_scroll_now.clone();
715            let key_box_scroll = box_scroll_now;
716            let keys: Vec<SharedString> = self
717                .items
718                .iter()
719                .map(|item| item.key().cloned().unwrap_or_default())
720                .collect();
721            // Every row's text, so typeahead can search it. A row that cannot be
722            // landed on has no label here, so it is never a match.
723            let labels: Vec<String> = self
724                .items
725                .iter()
726                .map(|item| match item {
727                    ListBoxItem::Option { label, .. } => label.to_string(),
728                    _ => String::new(),
729                })
730                .collect();
731            let typed_keys = typed;
732            let mode = self.selection_mode;
733            let disallow_empty = self.disallow_empty_selection;
734            let escape_key_behavior = self.escape_key_behavior;
735            let selected_now = self.selected_keys.clone();
736            let on_selection_change = self.on_selection_change.clone();
737            let on_action = self.on_action.clone();
738            let selection_own_for_keys = selection_own.clone();
739            let selection_range_for_keys = selection_range.clone();
740            let interaction_for_keys = interaction.clone();
741            let entry_at = cursor_at;
742            let selectable_keys: HashSet<SharedString> = stops_for_keys
743                .iter()
744                .filter_map(|index| keys.get(*index).cloned())
745                .collect();
746            list = list.on_key_down(move |event, window, cx| {
747                let from = (*held.read(cx))
748                    .filter(|index| stops_for_keys.contains(index))
749                    .or(entry_at.filter(|index| stops_for_keys.contains(index)));
750                let key_name = event.keystroke.key.as_str();
751                if key_name == "a"
752                    && event.keystroke.modifiers.secondary()
753                    && !event.keystroke.modifiers.shift
754                    && !event.keystroke.modifiers.alt
755                    && !event.keystroke.modifiers.function
756                    && if cfg!(target_os = "macos") {
757                        !event.keystroke.modifiers.control
758                    } else {
759                        !event.keystroke.modifiers.platform
760                    }
761                    && mode == SelectionMode::Multiple
762                {
763                    let next: HashSet<SharedString> = stops_for_keys
764                        .iter()
765                        .filter_map(|index| keys.get(*index).cloned())
766                        .collect();
767                    let all_selected = next.iter().all(|key| selected_now.contains(key));
768                    if !all_selected {
769                        if let Some(held) = &selection_own_for_keys {
770                            held.update(cx, |value, cx| {
771                                *value = next.clone();
772                                cx.notify();
773                            });
774                        }
775                        if let Some(cb) = &on_selection_change {
776                            cb(&next, window, cx);
777                        }
778                        selection_range_for_keys.update(cx, |range, _| {
779                            range.anchor = None;
780                            range.current = None;
781                            range.is_all = true;
782                        });
783                    }
784                    cx.stop_propagation();
785                    return;
786                }
787                // Pinned `useSelectableCollection` clears a nonempty selection
788                // on Escape by default and leaves an empty collection alone.
789                if key_name == "escape"
790                    && !event.keystroke.modifiers.modified()
791                    && escape_key_behavior == EscapeKeyBehavior::ClearSelection
792                    && crate::selection::reports_changes(mode)
793                    && !disallow_empty
794                    && !selected_now.is_empty()
795                {
796                    let next = HashSet::new();
797                    if let Some(held) = &selection_own_for_keys {
798                        held.update(cx, |value, cx| {
799                            *value = next.clone();
800                            cx.notify();
801                        });
802                    }
803                    if let Some(cb) = &on_selection_change {
804                        cb(&next, window, cx);
805                    }
806                    selection_range_for_keys.update(cx, |range, _| {
807                        *range = ListBoxSelectionRange::default();
808                    });
809                    cx.stop_propagation();
810                    return;
811                }
812                let page_by_step = |from: usize, step: usize| match key_name {
813                    "pagedown" => {
814                        let boundary = from.saturating_add(step).min(keys.len() - 1);
815                        stops_for_keys
816                            .iter()
817                            .copied()
818                            .find(|stop| *stop >= boundary)
819                            .or_else(|| stops_for_keys.last().copied())
820                    }
821                    "pageup" => {
822                        let boundary = from.saturating_sub(step);
823                        stops_for_keys
824                            .iter()
825                            .rev()
826                            .copied()
827                            .find(|stop| *stop <= boundary)
828                            .or_else(|| stops_for_keys.first().copied())
829                    }
830                    _ => None,
831                };
832                let fixed_page_move = from.and_then(|from| {
833                    let row_height = fixed_row_height?;
834                    let viewport_height = f32::from(key_list_scroll.viewport_bounds().size.height);
835                    if viewport_height <= 0. {
836                        return None;
837                    }
838                    let step = ((viewport_height / f32::from(row_height)).ceil() as usize)
839                        .saturating_sub(1);
840                    page_by_step(from, step)
841                });
842                let variable_page_move = from.and_then(|from| {
843                    let viewport_height = variable_scroll.as_ref()?.viewport_bounds().size.height;
844                    let heights = variable_heights.as_ref()?.read(cx);
845                    let estimate = variable_estimate?;
846                    let height_at =
847                        |index: usize| heights.1.get(index).copied().flatten().unwrap_or(estimate);
848                    let mut distance = height_at(from);
849                    let mut target = from;
850                    match key_name {
851                        "pagedown" => {
852                            if distance >= viewport_height {
853                                return Some(target);
854                            }
855                            let boundary = viewport_height - distance;
856                            distance = px(0.);
857                            for next in stops_for_keys.iter().copied().filter(|next| *next > from) {
858                                for index in target..next {
859                                    distance += height_at(index);
860                                }
861                                target = next;
862                                if distance >= boundary {
863                                    break;
864                                }
865                            }
866                            Some(target)
867                        }
868                        "pageup" => {
869                            if distance >= viewport_height {
870                                return Some(target);
871                            }
872                            for previous in stops_for_keys
873                                .iter()
874                                .rev()
875                                .copied()
876                                .filter(|previous| *previous < from)
877                            {
878                                for index in previous..target {
879                                    distance += height_at(index);
880                                }
881                                target = previous;
882                                if distance >= viewport_height {
883                                    break;
884                                }
885                            }
886                            Some(target)
887                        }
888                        _ => None,
889                    }
890                });
891                let is_variable_page = fixed_page_move.is_none() && variable_page_move.is_some();
892                let plain_page_move = from.filter(|_| plain_rows).and_then(|from| {
893                    if !plain_scrollable {
894                        return match key_name {
895                            "pagedown" => stops_for_keys.last().copied(),
896                            "pageup" => stops_for_keys.first().copied(),
897                            _ => None,
898                        };
899                    }
900                    let current = key_box_scroll.bounds_for_item(from)?;
901                    let viewport_height = key_box_scroll.bounds().size.height;
902                    let target = match key_name {
903                        "pagedown" => current.top() - current.size.height + viewport_height,
904                        "pageup" => current.top() + current.size.height - viewport_height,
905                        _ => return None,
906                    };
907                    match key_name {
908                        "pagedown" => stops_for_keys
909                            .iter()
910                            .copied()
911                            .filter(|stop| *stop >= from)
912                            .find(|stop| {
913                                key_box_scroll
914                                    .bounds_for_item(*stop)
915                                    .is_some_and(|bounds| bounds.top() >= target)
916                            })
917                            .or_else(|| stops_for_keys.last().copied()),
918                        "pageup" => stops_for_keys
919                            .iter()
920                            .rev()
921                            .copied()
922                            .filter(|stop| *stop <= from)
923                            .find(|stop| {
924                                key_box_scroll
925                                    .bounds_for_item(*stop)
926                                    .is_some_and(|bounds| bounds.top() <= target)
927                            })
928                            .or_else(|| stops_for_keys.first().copied()),
929                        _ => None,
930                    }
931                });
932                let page_move = fixed_page_move
933                    .or(variable_page_move)
934                    .or(plain_page_move)
935                    .filter(|next| Some(*next) != from);
936                let navigation = page_move.map_or_else(
937                    || crate::list_nav::resolve(&stops_for_keys, from, key_name, wrap),
938                    crate::list_nav::Move::To,
939                );
940                match navigation {
941                    crate::list_nav::Move::To(next) => {
942                        let modifiers = event.keystroke.modifiers;
943                        // Pinned `useSelectableCollection`: Shift extends a
944                        // multiple selection from the anchor with no other
945                        // chord, so plain Shift navigation is exact.
946                        //
947                        // The pinned registrations install no Home/End
948                        // handler for an unregistered chord -- Cmd- or
949                        // Ctrl-bearing on macOS, Alt- or platform-bearing
950                        // elsewhere -- so the whole event stays inert: no
951                        // focus move, no selection, no preventDefault.
952                        if matches!(key_name, "home" | "end")
953                            && !home_end_registered(modifiers, cfg!(target_os = "macos"))
954                        {
955                            return;
956                        }
957                        let exact_shift_navigation = if cfg!(target_os = "macos") {
958                            !modifiers.control && !modifiers.platform && !modifiers.function
959                        } else {
960                            !modifiers.alt && !modifiers.platform && !modifiers.function
961                        };
962                        let extends_selection = modifiers.shift
963                            && mode == SelectionMode::Multiple
964                            && exact_shift_navigation
965                            && shift_home_end_extends(
966                                key_name,
967                                modifiers.control,
968                                cfg!(target_os = "macos"),
969                            )
970                            && Some(next) != from;
971                        if extends_selection {
972                            if let Some(target) = keys.get(next) {
973                                let range = selection_range_for_keys.read(cx).clone();
974                                let next_selection = extend_selection_range(
975                                    &selected_now,
976                                    &keys,
977                                    &selectable_keys,
978                                    &range,
979                                    target,
980                                );
981                                selection_range_for_keys.update(cx, |range, _| {
982                                    if range.anchor.is_none() {
983                                        range.anchor = Some(target.clone());
984                                    }
985                                    range.current = Some(target.clone());
986                                    range.is_all = false;
987                                });
988                                if next_selection != selected_now {
989                                    if let Some(held) = &selection_own_for_keys {
990                                        held.update(cx, |value, cx| {
991                                            *value = next_selection.clone();
992                                            cx.notify();
993                                        });
994                                    }
995                                    if let Some(cb) = &on_selection_change {
996                                        cb(&next_selection, window, cx);
997                                    }
998                                }
999                            }
1000                        }
1001                        held.update(cx, |v, cx| {
1002                            *v = Some(next);
1003                            cx.notify();
1004                        });
1005                        if fixed_virtual {
1006                            key_list_scroll.scroll_to_item(next, crate::VirtualListScroll::Center);
1007                        } else if let Some(state) = &variable_scroll {
1008                            if is_variable_page || matches!(key_name, "up" | "down") {
1009                                state.scroll_to_reveal_item(next);
1010                            } else {
1011                                state.scroll_to(gpui::ListOffset {
1012                                    item_ix: next,
1013                                    offset_in_item: px(0.),
1014                                });
1015                            }
1016                        } else {
1017                            key_box_scroll.scroll_to_item(next);
1018                        }
1019                    }
1020                    crate::list_nav::Move::Activate => {
1021                        let Some(index) = from else {
1022                            return;
1023                        };
1024                        let Some(item_key) = keys.get(index).cloned() else {
1025                            return;
1026                        };
1027                        if crate::selection::reports_changes(mode) || on_action.is_some() {
1028                            if let Some(slot) = interaction_for_keys.get(index) {
1029                                util::begin_keyboard_press(slot, event, window, cx);
1030                            }
1031                        }
1032                        let action_key = event.keystroke.key == "enter";
1033                        let has_primary_action = on_action.is_some()
1034                            && (mode == SelectionMode::None || selected_now.is_empty());
1035                        if action_key && has_primary_action {
1036                            if let Some(cb) = &on_action {
1037                                cb(&item_key, window, cx);
1038                                return;
1039                            }
1040                        }
1041                        if action_key && on_action.is_some() {
1042                            return;
1043                        }
1044                        if crate::selection::reports_changes(mode) {
1045                            let was_selected = selected_now.contains(&item_key);
1046                            let next = match mode {
1047                                SelectionMode::None => selected_now.clone(),
1048                                SelectionMode::Single => {
1049                                    if selected_now.contains(&item_key) && !disallow_empty {
1050                                        HashSet::new()
1051                                    } else {
1052                                        HashSet::from([item_key.clone()])
1053                                    }
1054                                }
1055                                SelectionMode::Multiple => {
1056                                    let mut set = selected_now.clone();
1057                                    if set.remove(&item_key) {
1058                                        if disallow_empty && set.is_empty() {
1059                                            set.insert(item_key.clone());
1060                                        }
1061                                    } else {
1062                                        set.insert(item_key.clone());
1063                                    }
1064                                    set
1065                                }
1066                            };
1067                            if next != selected_now {
1068                                if let Some(held) = &selection_own_for_keys {
1069                                    held.update(cx, |value, cx| {
1070                                        *value = next.clone();
1071                                        cx.notify();
1072                                    });
1073                                }
1074                                if let Some(cb) = &on_selection_change {
1075                                    cb(&next, window, cx);
1076                                }
1077                            }
1078                            if mode == SelectionMode::Multiple && !was_selected {
1079                                selection_range_for_keys.update(cx, |range, _| {
1080                                    range.anchor = Some(item_key.clone());
1081                                    range.current = Some(item_key.clone());
1082                                    range.is_all = false;
1083                                });
1084                            } else if mode == SelectionMode::Multiple {
1085                                selection_range_for_keys.update(cx, |range, _| {
1086                                    if range.is_all {
1087                                        *range = ListBoxSelectionRange::default();
1088                                    }
1089                                });
1090                            }
1091                        }
1092                    }
1093                    crate::list_nav::Move::Ignore => {
1094                        // Typeahead: letters jump to the row that starts with
1095                        // them, which is the other half of v3's keyboard.
1096                        if event.keystroke.modifiers.control
1097                            || event.keystroke.modifiers.platform
1098                            || event.keystroke.modifiers.alt
1099                        {
1100                            return;
1101                        }
1102                        let key = key_name;
1103                        if !crate::list_nav::is_typeahead_key(key) {
1104                            return;
1105                        }
1106                        let now = web_time::Instant::now();
1107                        let (query, repeat) = typed_keys.update(cx, |t, _| {
1108                            let query = t.push(key, now);
1109                            (query, t.is_repeat())
1110                        });
1111                        if let Some(found) = crate::list_nav::typeahead(
1112                            &labels,
1113                            &stops_for_keys,
1114                            from,
1115                            &query,
1116                            repeat,
1117                        ) {
1118                            held.update(cx, |v, cx| {
1119                                *v = Some(found);
1120                                cx.notify();
1121                            });
1122                        }
1123                    }
1124                }
1125            });
1126        }
1127
1128        // The virtual paths below move `self` into their row builders, so the
1129        // slot comes out first: it refines whichever path returns.
1130        let sx = self.sx.take();
1131
1132        // With `rowHeight` set the list is virtual: only the rows the viewport
1133        // shows are built, which is what makes a thousand of them affordable.
1134        // A uniform `VirtualList` measures row 0 and multiplies, so the row builder is
1135        // told the height rather than left to size itself.
1136        // `estimatedRowHeight` virtualizes a list whose rows differ: gpui's
1137        // `list` measures each row it builds, where the uniform mode measures one
1138        // and multiplies. Its state is intrusive -- the caller has to hold it --
1139        // so it lives in the window's keyed store, and a change in the item
1140        // count resets it.
1141        if self.row_height.is_none() && self.estimated_row_height.is_some() {
1142            let height = self.max_h.unwrap_or(px(400.));
1143            let count = self.items.len();
1144            let rows = std::rc::Rc::new(self);
1145            let handle = list_handle_now;
1146            if handle.item_count() != count {
1147                handle.set_item_count(count);
1148            }
1149            let interaction = interaction.clone();
1150            let row_range = selection_range.clone();
1151            let measured_heights =
1152                variable_row_heights.expect("estimated row height creates a measurement store");
1153            return util::apply_sx(
1154                list.child(
1155                    crate::VirtualList::new(
1156                        element_id::scoped(&base_id, "variable-rows"),
1157                        &handle,
1158                        move |index, _window, cx| {
1159                            let row = rows.row(
1160                                index,
1161                                focused_at,
1162                                anchor_at,
1163                                None,
1164                                interaction.get(index),
1165                                &cursor,
1166                                &row_range,
1167                                selection_own.as_ref(),
1168                                _window,
1169                                cx,
1170                            );
1171                            let measured = measured_heights.clone();
1172                            div()
1173                                .relative()
1174                                .w_full()
1175                                .child(row)
1176                                .child(
1177                                    gpui::canvas(
1178                                        move |bounds: gpui::Bounds<gpui::Pixels>, _, cx| {
1179                                            measured.update(cx, |(_, heights), cx| {
1180                                                if heights.get(index).copied().flatten()
1181                                                    != Some(bounds.size.height)
1182                                                {
1183                                                    heights[index] = Some(bounds.size.height);
1184                                                    cx.notify();
1185                                                }
1186                                            });
1187                                            bounds
1188                                        },
1189                                        |_, _, _, _| {},
1190                                    )
1191                                    .absolute()
1192                                    .inset_0(),
1193                                )
1194                                .into_any_element()
1195                        },
1196                    )
1197                    .height(height),
1198                ),
1199                &sx,
1200            )
1201            .into_any_element();
1202        }
1203
1204        if let Some(row_height) = self.row_height {
1205            let height = self.max_h.unwrap_or(px(400.));
1206            let list_id = self.id.clone();
1207            let count = self.items.len();
1208            let rows = std::rc::Rc::new(self);
1209            let interaction = interaction.clone();
1210            let row_range = selection_range.clone();
1211            // The headless probe name for the virtual viewport's bounds.
1212            let rows_selector = format!("{base}-rows");
1213            // The viewport scrolls inside the uniform `VirtualList`, which
1214            // builds only the shown rows. A fixed height caps the roomy-window viewport
1215            // at the configured value so an unbounded parent sizes to cap +
1216            // padding instead of the rows' full natural height; `min_h_0`
1217            // lets that fixed height shrink as a flex item with a bounded
1218            // parent, handing the viewport its real height for paging.
1219            let handle = list_scroll_now;
1220            if handle.item_count() != count {
1221                handle.splice(0..handle.item_count(), count);
1222            }
1223            return util::apply_sx(
1224                list.child(
1225                    crate::VirtualList::new(list_id, &handle, move |i, window, cx| {
1226                        rows.row(
1227                            i,
1228                            focused_at,
1229                            anchor_at,
1230                            Some(row_height),
1231                            interaction.get(i),
1232                            &cursor,
1233                            &row_range,
1234                            selection_own.as_ref(),
1235                            window,
1236                            cx,
1237                        )
1238                    })
1239                    .height(height)
1240                    .debug_selector(rows_selector),
1241                ),
1242                &sx,
1243            )
1244            .into_any_element();
1245        }
1246
1247        let mut items = Vec::with_capacity(self.items.len());
1248        for index in 0..self.items.len() {
1249            items.push(self.row(
1250                index,
1251                focused_at,
1252                anchor_at,
1253                None,
1254                interaction.get(index),
1255                &cursor,
1256                &selection_range,
1257                selection_own.as_ref(),
1258                window,
1259                cx,
1260            ));
1261        }
1262        util::apply_sx(list.children(items), &sx).into_any_element()
1263    }
1264}
1265
1266impl ListBox {
1267    /// One row, by index.
1268    ///
1269    /// Shared by the plain and the virtualized paths so the two cannot drift:
1270    /// `fixed_h` is `Some` only for the virtual one, where every row -- a
1271    /// heading and a separator included -- is one `rowHeight` tall because that
1272    /// is the number the scroll geometry is computed from.
1273    #[allow(clippy::too_many_arguments)]
1274    fn row(
1275        &self,
1276        index: usize,
1277        cursor_at: Option<usize>,
1278        anchor_at: Option<usize>,
1279        fixed_h: Option<gpui::Pixels>,
1280        interaction: Option<&util::Interaction>,
1281        cursor: &gpui::Entity<Option<usize>>,
1282        selection_range: &gpui::Entity<ListBoxSelectionRange>,
1283        selection_own: Option<&gpui::Entity<HashSet<SharedString>>>,
1284        window: &mut Window,
1285        cx: &mut App,
1286    ) -> gpui::AnyElement {
1287        let colors = cx.colors();
1288        // `.list-box-item` is `min-h-9`.
1289        let row_h = fixed_h.unwrap_or(px(36.));
1290        let text_size = util::FIELD_TEXT;
1291        let sized = |el: gpui::Div| match fixed_h {
1292            Some(h) => el.h(h),
1293            None => el,
1294        };
1295        match &self.items[index] {
1296            ListBoxItem::Separator => sized(
1297                div()
1298                    .my(px(4.))
1299                    .mx(gpui::relative(0.03))
1300                    .w(gpui::relative(0.94))
1301                    .h(cx.layout().border_width)
1302                    .bg(colors.separator),
1303            )
1304            .into_any_element(),
1305            ListBoxItem::Section(label) => sized(
1306                div()
1307                    .when_some(self.heading_height, |el, h| el.h(h))
1308                    .px(px(8.))
1309                    .pt(px(6.))
1310                    .pb(px(4.))
1311                    .text_size(px(12.))
1312                    .line_height(px(16.))
1313                    .font_weight(gpui::FontWeight::MEDIUM)
1314                    .text_color(colors.muted)
1315                    .child(label.to_string()),
1316            )
1317            .into_any_element(),
1318            ListBoxItem::Option {
1319                key,
1320                label,
1321                description,
1322                icon,
1323                shortcut,
1324                variant,
1325                is_disabled,
1326            } => {
1327                let variant = if *variant == ListBoxItemVariant::Default {
1328                    self.variant
1329                } else {
1330                    *variant
1331                };
1332                let disabled = *is_disabled || self.disabled_keys.contains(key);
1333                let selected = self.selected_keys.contains(key);
1334                let pressable = !disabled
1335                    && (crate::selection::reports_changes(self.selection_mode)
1336                        || self.on_action.is_some());
1337
1338                let fg = match variant {
1339                    ListBoxItemVariant::Default => colors.foreground,
1340                    ListBoxItemVariant::Danger => colors.danger.color,
1341                };
1342                let hover_bg = self.row_hover_bg.unwrap_or(colors.default.color);
1343
1344                // `useOption.mjs` is `role: 'option'` with
1345                // `'aria-selected': selectionMode !== 'none' ? isSelected :
1346                // undefined`, and it adds `aria-posinset`/`aria-setsize`
1347                // only `if (isVirtualized)` — counted over the *options*
1348                // (`getItemCount`), which skips the sections and separators
1349                // that carry no node. The port's virtual paths are the same
1350                // situation: only a window of rows is built, so the position
1351                // cannot be counted from the tree.
1352                let virtualized = self.row_height.is_some() || self.estimated_row_height.is_some();
1353                let option_count = self
1354                    .items
1355                    .iter()
1356                    .filter(|item| matches!(item, ListBoxItem::Option { .. }))
1357                    .count();
1358                let option_index = self.items[..index]
1359                    .iter()
1360                    .filter(|item| matches!(item, ListBoxItem::Option { .. }))
1361                    .count();
1362                let row_name = a11y::Name::labelled(label.clone()).described(description.clone());
1363                let mut row = div()
1364                    .id(element_id::indexed(&self.id, "item", index))
1365                    // Headless probe: the option row itself, so tests can
1366                    // measure the row padding on the plain and virtual paths.
1367                    .debug_selector({
1368                        let owner = format!("{:?}", self.id);
1369                        move || format!("{owner}-item-{index}")
1370                    })
1371                    .a11y_named(a11y::Role::ListBoxOption, &row_name)
1372                    .when(self.selection_mode != SelectionMode::None, |row| {
1373                        row.a11y_selected(selected)
1374                    })
1375                    .when(virtualized, |row| {
1376                        row.a11y_set_position(option_index, option_count)
1377                    })
1378                    // The list holds one focus handle and moves a cursor
1379                    // through the rows, which is `shouldUseVirtualFocus` in
1380                    // upstream's terms. gpui states that relation on the
1381                    // descendant rather than on the container, so the row the
1382                    // cursor is on says so itself.
1383                    .when(cursor_at == Some(index), |row| row.a11y_active_descendant())
1384                    .flex()
1385                    .flex_row()
1386                    .items_center()
1387                    .gap(px(12.))
1388                    .px(self.row_padding_x.unwrap_or(px(8.)))
1389                    .relative()
1390                    // A virtual row is laid out on its own, so it takes the width
1391                    // it is given rather than inheriting a stretch.
1392                    .map(|el| match fixed_h {
1393                        Some(h) => el.h(h).w_full(),
1394                        None => el.min_h(row_h),
1395                    })
1396                    .py(self.row_padding_y.unwrap_or(px(6.)))
1397                    .rounded(util::soft_radius(cx))
1398                    .text_size(text_size)
1399                    .line_height(px(20.))
1400                    .text_color(fg);
1401
1402                if disabled {
1403                    row = row.opacity(cx.layout().disabled_opacity);
1404                } else {
1405                    row = row
1406                        .cursor(util::interactive_cursor(cx))
1407                        .hover(move |s| s.bg(hover_bg));
1408                }
1409
1410                // `.list-box-item` takes `status-focused` on the row the keyboard
1411                // is on. A ring rather than a border: a border would move the
1412                // row's content by two pixels as the cursor arrived. Pointer
1413                // hover seats the cursor without painting the ring. The ring is
1414                // an overlay child so its corner stays concentric with the
1415                // row's `soft_radius`; the shadow variant would keep the row's
1416                // own radius two pixels further out and blur the edge. The
1417                // overlay reaches four pixels past the row, which is exactly
1418                // the list's `p-1` plus its border band, so the enclosing
1419                // `overflow_hidden` list does not cut it -- and clips the
1420                // shadow spread to the same box anyway.
1421                let row = util::with_focus_ring_overlay(
1422                    row,
1423                    util::shows_focus_ring(cursor_at == Some(index), cx),
1424                    true,
1425                    util::soft_radius(cx),
1426                    Vec::new(),
1427                    cx,
1428                );
1429                let mut row = row;
1430
1431                if let Some(path) = icon {
1432                    row = row.child(
1433                        gpui::svg()
1434                            .size(util::FIELD_ICON)
1435                            .path(path.clone())
1436                            .flex_shrink_0()
1437                            .text_color(fg),
1438                    );
1439                }
1440
1441                // Label plus optional description stack -- or the render
1442                // function, which v3 hands the row's state. The content branch
1443                // falls through to the click handler below: `ListBox.Item`
1444                // stays a row whether its children are a node or a function.
1445                if let Some(render) = &self.item_content {
1446                    let focused = cursor_at == Some(index);
1447                    // The slot's press is a frame behind the pointer, because
1448                    // gpui reports it to a handler rather than to the render
1449                    // that draws it. v3's `ListBox.Item` render-props table
1450                    // lists no `isHovered`, so the hover the slot also tracks
1451                    // is not handed over.
1452                    let (_, recorded_press) =
1453                        interaction.map(|slot| *slot.read(cx)).unwrap_or_default();
1454                    row = row.child(render(
1455                        key,
1456                        util::InteractiveState {
1457                            is_hovered: false,
1458                            is_pressed: pressable && recorded_press,
1459                            is_focused: focused,
1460                            is_focus_visible: focused && util::focus_visible(cx),
1461                            is_selected: selected,
1462                            is_disabled: disabled,
1463                            is_pending: false,
1464                            is_indeterminate: false,
1465                        },
1466                    ));
1467                } else {
1468                    // Headless probe: the label column starts at the row's
1469                    // content edge, so tests can measure row padding.
1470                    let owner = format!("{:?}", self.id);
1471                    row = row.child(
1472                        div()
1473                            .debug_selector(move || format!("{owner}-item-{index}-label"))
1474                            .flex()
1475                            .flex_col()
1476                            .flex_1()
1477                            // `[data-slot="description"]` is `text-wrap`, which
1478                            // needs a column that can shrink below its content.
1479                            .min_w_0()
1480                            .child(div().font_weight(gpui::FontWeight::MEDIUM).child(label.to_string()))
1481                            .when_some(description.clone(), |el, d| {
1482                                el.child(
1483                                    div()
1484                                        .text_size(px(12.)).line_height(px(16.))
1485                                        .text_color(colors.muted)
1486                                        .child(d.to_string()),
1487                                )
1488                            }),
1489                    );
1490                }
1491
1492                if let Some(render) = &self.indicator {
1493                    // HeroUI's `.list-box-item__indicator` is absolute at the
1494                    // inline end, with `pe-7` reserved on the row whenever an
1495                    // indicator exists. Keeping the slot out of flex flow
1496                    // prevents a long label from changing its width or
1497                    // pushing the checkmark away from the row edge.
1498                    row = row.pr(px(28.)).child(
1499                        div()
1500                            .absolute()
1501                            .top_0()
1502                            .bottom_0()
1503                            .right(px(8.))
1504                            .w(px(16.))
1505                            .flex()
1506                            .items_center()
1507                            .justify_center()
1508                            .child(render(selected)),
1509                    );
1510                } else if selected && self.selection_mode != SelectionMode::None {
1511                    row = row.pr(px(28.)).child(
1512                        div()
1513                            .absolute()
1514                            .top_0()
1515                            .bottom_0()
1516                            .right(px(8.))
1517                            .w(px(16.))
1518                            .flex()
1519                            .items_center()
1520                            .justify_center()
1521                            .child(gpui::svg()
1522                                    // `.list-box-item__indicator` is `size-4`.
1523                                    .size(px(16.))
1524                                    .path(icons::CHECK)
1525                                    .text_color(match variant {
1526                                        ListBoxItemVariant::Default => colors.default.foreground,
1527                                        ListBoxItemVariant::Danger => colors.danger.color,
1528                                    })),
1529                    );
1530                } else if let Some(sc) = shortcut {
1531                    row = row.child(
1532                        crate::kbd::Kbd::new()
1533                            .variant(crate::kbd::KbdVariant::Light)
1534                            .child(sc.to_string()),
1535                    );
1536                }
1537
1538                if pressable {
1539                    // Wrap first so the stable press slot owns the hitbox;
1540                    // then record pointer/keyboard state on that slot for
1541                    // both built-in rows and caller render props.
1542                    row = crate::anim::pressed_with_background_ramp(
1543                        row,
1544                        crate::anim::PressBox {
1545                            height: row_h,
1546                            padding_x: Some(self.row_padding_x.unwrap_or(px(8.))),
1547                            width: None,
1548                            min_width: None,
1549                            text_size,
1550                            line_height: px(20.),
1551                            gap: px(12.),
1552                            radius: util::soft_radius(cx),
1553                            shrink_x: false,
1554                            scale: crate::anim::PRESSED_SCALE_SUBTLE,
1555                        },
1556                        None,
1557                        crate::anim::LIST_ITEM_PRESS,
1558                        interaction,
1559                        window,
1560                        cx,
1561                    );
1562                    if let Some(slot) = interaction {
1563                        row = util::track_interaction(row, slot);
1564                    }
1565                }
1566
1567                if !disabled {
1568                    let key = key.clone();
1569                    let mode = self.selection_mode;
1570                    let disallow_empty = self.disallow_empty_selection;
1571                    let current = self.selected_keys.clone();
1572                    let on_selection_change = self.on_selection_change.clone();
1573                    let on_action = self.on_action.clone();
1574                    let moved = cursor.clone();
1575                    let selection_own = selection_own.cloned();
1576                    let range_for_click = selection_range.clone();
1577                    // The collection order the range resolves against -- every
1578                    // option's key, disabled ones included: they keep their
1579                    // collection positions so the span traversal preserves
1580                    // indexes, while the `selectable` filter keeps their
1581                    // insertions out of the range.
1582                    let collection: Vec<SharedString> = self
1583                        .items
1584                        .iter()
1585                        .filter_map(|item| item.key().cloned())
1586                        .collect();
1587                    let selectable: HashSet<SharedString> = self
1588                        .items
1589                        .iter()
1590                        .filter_map(|item| match item {
1591                            ListBoxItem::Option {
1592                                key, is_disabled, ..
1593                            } => (!is_disabled && !self.disabled_keys.contains(key))
1594                                .then(|| key.clone()),
1595                            _ => None,
1596                        })
1597                        .collect();
1598                    row = row
1599                        .on_mouse_down(gpui::MouseButton::Left, move |_, _, cx| {
1600                            moved.update(cx, |value, cx| {
1601                                *value = Some(index);
1602                                cx.notify();
1603                            });
1604                        })
1605                        .on_click(move |ev, window, cx| {
1606                            let has_primary_action = on_action.is_some()
1607                                && (mode == SelectionMode::None || current.is_empty());
1608                            if has_primary_action {
1609                                if let Some(action) = &on_action {
1610                                    action(&key, window, cx);
1611                                }
1612                                return;
1613                            }
1614                            if crate::selection::reports_changes(mode) {
1615                                let was_selected = current.contains(&key);
1616                                // A Shift click extends from the anchor in
1617                                // multiple mode; an ordinary click toggles and
1618                                // seats the range on itself. A controlled
1619                                // selection only reports; the owner's prop
1620                                // stays in charge until it feeds the value
1621                                // back, while the range keeps advancing.
1622                                let extends_selection =
1623                                    ev.modifiers().shift && mode == SelectionMode::Multiple;
1624                                let next = if extends_selection {
1625                                    let range = range_for_click.read(cx).clone();
1626                                    extend_selection_range(
1627                                        &current,
1628                                        &collection,
1629                                        &selectable,
1630                                        &range,
1631                                        &key,
1632                                    )
1633                                } else {
1634                                    match mode {
1635                                        SelectionMode::None => current.clone(),
1636                                        SelectionMode::Single => {
1637                                            if current.contains(&key) && !disallow_empty {
1638                                                HashSet::new()
1639                                            } else {
1640                                                HashSet::from([key.clone()])
1641                                            }
1642                                        }
1643                                        SelectionMode::Multiple => {
1644                                            let mut set = current.clone();
1645                                            if set.remove(&key) {
1646                                                if disallow_empty && set.is_empty() {
1647                                                    set.insert(key.clone());
1648                                                }
1649                                            } else {
1650                                                set.insert(key.clone());
1651                                            }
1652                                            set
1653                                        }
1654                                    }
1655                                };
1656                                if next != current {
1657                                    if let Some(held) = &selection_own {
1658                                        held.update(cx, |value, cx| {
1659                                            *value = next.clone();
1660                                            cx.notify();
1661                                        });
1662                                    }
1663                                    if let Some(change) = &on_selection_change {
1664                                        change(&next, window, cx);
1665                                    }
1666                                }
1667                                if extends_selection {
1668                                    range_for_click.update(cx, |range, _| {
1669                                        if range.anchor.is_none() {
1670                                            range.anchor = Some(key.clone());
1671                                        }
1672                                        range.current = Some(key.clone());
1673                                        range.is_all = false;
1674                                    });
1675                                } else if mode == SelectionMode::Multiple {
1676                                    if was_selected {
1677                                        // A deselect ends a raw `all`, so the
1678                                        // next Shift click extends instead of
1679                                        // collapsing to its target.
1680                                        range_for_click.update(cx, |range, _| {
1681                                            if range.is_all {
1682                                                *range = ListBoxSelectionRange::default();
1683                                            }
1684                                        });
1685                                    } else {
1686                                        range_for_click.update(cx, |range, _| {
1687                                            range.anchor = Some(key.clone());
1688                                            range.current = Some(key.clone());
1689                                            range.is_all = false;
1690                                        });
1691                                    }
1692                                }
1693                            }
1694                        });
1695                }
1696
1697                if anchor_at == Some(index)
1698                    && let Some(focus) = window.focused(cx)
1699                {
1700                    row = util::record_focus_bounds(row, &focus, window, cx);
1701                }
1702                row.into_any_element()
1703            }
1704        }
1705    }
1706}
1707
1708#[cfg(test)]
1709mod tests {
1710    use super::*;
1711
1712    fn implementation_source() -> &'static str {
1713        include_str!("list_box.rs")
1714            .split("#[cfg(test)]")
1715            .next()
1716            .expect("the implementation section is always present")
1717    }
1718
1719    #[test]
1720    fn selected_indicators_are_absolute_and_reserve_end_padding() {
1721        let source = implementation_source();
1722        assert!(source.contains(".relative()"));
1723        assert!(source.contains(".pr(px(28.))"));
1724        assert!(source.contains(".absolute()"));
1725        assert!(source.contains(".right(px(8.))"));
1726        assert!(source.contains(".top_0()"));
1727        assert!(source.contains(".bottom_0()"));
1728    }
1729
1730    #[test]
1731    fn ordinary_rows_use_the_pinned_subtle_press_skin() {
1732        let source = implementation_source();
1733        assert!(source.contains("PRESSED_SCALE_SUBTLE"));
1734        assert!(source.contains("util::track_interaction(row, slot)"));
1735        assert!(source.contains("crate::anim::pressed_with_background_ramp("));
1736        assert!(source.contains("crate::anim::LIST_ITEM_PRESS"));
1737        assert!(source.contains("shrink_x: false"));
1738    }
1739
1740    /// The Home/End gate takes the platform as an explicit bool, so this
1741    /// truth table is free of `cfg!` and mechanically proves both maps from
1742    /// any host: no macOS chord ever extends -- Shift and Alt+Shift move the
1743    /// focus alone -- while Windows and Linux extend exactly from
1744    /// Control+Shift.
1745    #[test]
1746    fn shift_home_end_extends_only_from_control_outside_macos() {
1747        for key in ["home", "end"] {
1748            assert!(
1749                !shift_home_end_extends(key, true, true),
1750                "macOS registers no Home/End extension"
1751            );
1752            assert!(!shift_home_end_extends(key, false, true));
1753            assert!(
1754                shift_home_end_extends(key, true, false),
1755                "Control+Shift+{key} must extend on Windows and Linux"
1756            );
1757            assert!(
1758                !shift_home_end_extends(key, false, false),
1759                "plain Shift+{key} must only move the focus"
1760            );
1761        }
1762    }
1763
1764    /// Arrows and page keys never consult the Home/End gate: their forbidden
1765    /// extra chords are rejected earlier, by `exact_shift_navigation`.
1766    #[test]
1767    fn shift_navigation_keys_do_not_consult_the_home_end_gate() {
1768        for key in ["up", "down", "left", "right", "pageup", "pagedown"] {
1769            assert!(shift_home_end_extends(key, false, true));
1770            assert!(shift_home_end_extends(key, true, false));
1771        }
1772    }
1773
1774    /// The registration gate takes `Modifiers`, so the pinned chord map can
1775    /// be spelled out: macOS registers none, Shift, Alt, and Alt+Shift and
1776    /// every Control- or Meta-bearing chord is entirely inert, while
1777    /// Windows and Linux register none, Shift, Control, and Control+Shift
1778    /// and reject every Alt- or Meta-bearing chord. The upstream matcher
1779    /// sees only the browser's Alt/Control/Meta/Shift flags, so GPUI's
1780    /// `function` flag is ignored: `fn` stays registered on both maps, and
1781    /// it never rescues a chord the platform itself rejects.
1782    #[test]
1783    fn home_end_registration_matches_the_pinned_chord_map() {
1784        let none = gpui::Modifiers::none();
1785        let shift = gpui::Modifiers {
1786            shift: true,
1787            ..none
1788        };
1789        let alt = gpui::Modifiers { alt: true, ..none };
1790        let alt_shift = gpui::Modifiers { shift: true, ..alt };
1791        let function = gpui::Modifiers {
1792            function: true,
1793            ..none
1794        };
1795        let function_alt = gpui::Modifiers {
1796            alt: true,
1797            ..function
1798        };
1799        for modifiers in [none, shift, alt, alt_shift, function, function_alt] {
1800            assert!(
1801                home_end_registered(modifiers, true),
1802                "macOS must register {modifiers:?}"
1803            );
1804        }
1805        let control = gpui::Modifiers {
1806            control: true,
1807            ..none
1808        };
1809        let control_shift = gpui::Modifiers {
1810            shift: true,
1811            ..control
1812        };
1813        let platform = gpui::Modifiers {
1814            platform: true,
1815            ..none
1816        };
1817        let platform_shift = gpui::Modifiers {
1818            shift: true,
1819            ..platform
1820        };
1821        for modifiers in [control, control_shift, platform, platform_shift] {
1822            assert!(
1823                !home_end_registered(modifiers, true),
1824                "macOS must not register {modifiers:?}"
1825            );
1826        }
1827        for modifiers in [none, shift, control, control_shift, function] {
1828            assert!(
1829                home_end_registered(modifiers, false),
1830                "Windows and Linux must register {modifiers:?}"
1831            );
1832        }
1833        for modifiers in [alt, alt_shift, function_alt, platform, platform_shift] {
1834            assert!(
1835                !home_end_registered(modifiers, false),
1836                "Windows and Linux must not register {modifiers:?}"
1837            );
1838        }
1839    }
1840
1841    /// The keystroke spellings real events hand the gate: `ctrl` parses to
1842    /// the Control field the Windows/Linux registration admits and macOS
1843    /// vetoes, `cmd` to the platform field macOS vetoes, `alt-shift` to
1844    /// the chord that stays registered (focus-only) on macOS alone, and
1845    /// `fn` to the flag the browser matcher never sees, so it registers
1846    /// exactly like the bare key on both maps.
1847    #[test]
1848    fn keystroke_spellings_reach_the_registration_gate() {
1849        let ctrl_shift_home = gpui::Keystroke::parse("ctrl-shift-home").unwrap();
1850        assert!(home_end_registered(ctrl_shift_home.modifiers, false));
1851        assert!(!home_end_registered(ctrl_shift_home.modifiers, true));
1852        let cmd_shift_home = gpui::Keystroke::parse("cmd-shift-home").unwrap();
1853        assert!(!home_end_registered(cmd_shift_home.modifiers, true));
1854        let alt_shift_end = gpui::Keystroke::parse("alt-shift-end").unwrap();
1855        assert!(home_end_registered(alt_shift_end.modifiers, true));
1856        assert!(!home_end_registered(alt_shift_end.modifiers, false));
1857        let fn_home = gpui::Keystroke::parse("fn-home").unwrap();
1858        assert!(fn_home.modifiers.function);
1859        assert!(home_end_registered(fn_home.modifiers, true));
1860        assert!(home_end_registered(fn_home.modifiers, false));
1861    }
1862}
1863
1864crate::util::impl_component_styled!(ListBox);