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 ¤t,
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);