Skip to main content

herogpui_components/
autocomplete.rs

1//! Autocomplete — port of `@heroui/autocomplete`.
2//!
3//! v3's Autocomplete is a **Select whose popover holds a search field**, not a
4//! text field with suggestions under it. `autocomplete.css` says so directly:
5//! `.autocomplete__trigger` is a field-shaped box (`min-h-9 rounded-field
6//! bg-field px-3 shadow-field`) holding `.autocomplete__value` and a chevron
7//! `.autocomplete__indicator`, and `.autocomplete__popover` stacks
8//! `[data-slot="search-field"]` above the list. The text field *with* a list is
9//! the [`crate::combo_box::ComboBox`], which is a separate component with its
10//! own stylesheet.
11//!
12//! This port had it the other way round -- an
13//! [`Input`](crate::input::Input) with a suggestion panel
14//! -- which drew none of that sheet and left the trigger nothing to show the
15//! selection in. The trigger draws the selection now, and the popover searches.
16//!
17//! [`InputState`] therefore backs the **search field inside the popover**, which
18//! is what `Autocomplete.Filter`'s `inputValue` and `onInputChange` address. The
19//! selection is a set of item keys, held by `value` / `defaultValue`.
20//!
21//! Pinned v3.2.4 / React Aria Components 1.20.0 keep a stable `Key` separate
22//! from each item's `textValue`: `value` / `defaultValue` / `disabledKeys`,
23//! the selection callbacks and the form value address items by key, while
24//! filtering and the visible text use the label. Items are therefore
25//! [`crate::PickerItem`]s; using a label as the key made duplicate labels alias
26//! each other's selection, disabled state and row identity.
27//!
28//! Pinned react-stately 3.49.0's `useSelectState` holds `selectedKeys` as a
29//! JavaScript `Set`, which iterates in insertion order: `selectedItems`,
30//! `selectedText`, the selection callbacks and the form value all follow the
31//! *selection's* order, not the collection's. The selection is therefore an
32//! ordered unique key list, and toggling removes in place or appends.
33
34use std::{
35    cell::{Cell, RefCell},
36    collections::HashMap,
37    rc::Rc,
38    time::Duration,
39};
40
41use gpui::{
42    prelude::*, px, AnimationExt, App, Entity, IntoElement, Pixels, RenderOnce, SharedString,
43    StatefulInteractiveElement, Styled, Window,
44};
45use herogpui_core::{element_id, FieldVariant, Placement, SelectionMode};
46use herogpui_theme::ActiveTheme;
47
48use crate::{
49    a11y::{self, A11y as _},
50    icons,
51    input::{InputState, SearchField},
52    matches::{empty_matches, MatchesCache},
53    picker_item::PickerItem,
54    selection::{normalize_selection, toggle_key},
55    util,
56};
57
58type OnSelectionChange = std::sync::Arc<dyn Fn(&SharedString, &mut Window, &mut App) + 'static>;
59type AutocompleteFormState = Rc<RefCell<crate::form::LiveFormFieldState>>;
60
61/// `.autocomplete__clear-button:not([data-empty="true"])` fades in over
62/// 150ms with `--ease-smooth`; clearing itself remains an immediate hide.
63const CLEAR_OPACITY_TRANSITION_MS: u64 = 150;
64
65thread_local! {
66    static AUTOCOMPLETE_FORM_STATES: RefCell<
67        HashMap<u64, std::rc::Weak<RefCell<crate::form::LiveFormFieldState>>>,
68    > = RefCell::new(HashMap::new());
69}
70
71fn autocomplete_form_state(entity_id: u64) -> AutocompleteFormState {
72    AUTOCOMPLETE_FORM_STATES.with(|states| {
73        let mut states = states.borrow_mut();
74        if let Some(state) = states.get(&entity_id).and_then(|state| state.upgrade()) {
75            return state;
76        }
77        let state = Rc::new(RefCell::new(crate::form::LiveFormFieldState {
78            value: crate::form::FormValue::Keys(Vec::new()),
79            is_invalid: false,
80            is_successful: true,
81            focus: None,
82            restore: None,
83        }));
84        states.insert(entity_id, Rc::downgrade(&state));
85        state
86    })
87}
88
89fn form_selection_value(selected: &[SharedString]) -> crate::form::FormValue {
90    crate::form::FormValue::Keys(selected.to_vec())
91}
92
93/// The rows the popover's list shows for `query`: the custom filter's own
94/// decision — including what an empty query means — or the default
95/// case-insensitive substring match. Filtering reads the items' labels, never
96/// their keys.
97fn compute_matches(
98    items: &[PickerItem],
99    query: &str,
100    max_items: usize,
101    filter: Option<&std::sync::Arc<dyn Fn(&str, &str) -> bool + 'static>>,
102) -> Vec<PickerItem> {
103    if let Some(filter) = filter {
104        return items
105            .iter()
106            .filter(|item| filter(item.label(), query))
107            .take(max_items)
108            .cloned()
109            .collect();
110    }
111    if query.is_empty() {
112        return items.iter().take(max_items).cloned().collect();
113    }
114    let lowered = query.to_lowercase();
115    items
116        .iter()
117        .filter(|it| it.label().to_lowercase().contains(&lowered))
118        .take(max_items)
119        .cloned()
120        .collect()
121}
122
123fn sync_form_state(
124    state: &AutocompleteFormState,
125    selected: &[SharedString],
126    is_disabled: bool,
127    is_invalid: bool,
128) {
129    let mut state = state.borrow_mut();
130    state.value = form_selection_value(selected);
131    state.is_successful = !is_disabled;
132    state.is_invalid = is_invalid;
133}
134
135/// HeroUI Autocomplete.
136#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
137#[derive(IntoElement)]
138pub struct Autocomplete {
139    /// `name` — the name this control submits under; read back by
140    /// [`Self::form_field`].
141    name: Option<SharedString>,
142    /// The search field's text state — `Autocomplete.Filter`'s `inputValue`.
143    state: Entity<InputState>,
144    items: Vec<PickerItem>,
145    max_items: usize,
146    /// `ListLayout`'s `rowHeight`, which virtualizes the popover list.
147    row_height: Option<Pixels>,
148    /// Replaces the list rows' `px-2.5` horizontal padding.
149    row_padding_x: Option<Pixels>,
150    /// Replaces the list rows' `py-1.5` vertical padding.
151    row_padding_y: Option<Pixels>,
152    /// The fill a hovered row takes, in place of `--default`.
153    row_hover_bg: Option<gpui::Hsla>,
154    /// The trigger's hover endpoint, in place of the variant's hover token.
155    trigger_hover_bg: Option<gpui::Hsla>,
156    /// The clear button's hover fill, in place of `--default-hover`.
157    clear_hover_bg: Option<gpui::Hsla>,
158    /// The family the option rows are drawn with; unset keeps the
159    /// inherited family. A detached popover does not inherit the trigger's
160    /// font.
161    row_font_family: Option<SharedString>,
162    /// The trigger's and filter field's family; unset keeps the inherited one.
163    font_family: Option<SharedString>,
164    /// The corner radius of the detached panel, in place of the owning
165    /// `container_radius` helper.
166    radius: Option<Pixels>,
167    label: Option<SharedString>,
168    placeholder: Option<SharedString>,
169    description: Option<SharedString>,
170    error_message: Option<SharedString>,
171    variant: FieldVariant,
172    full_width: bool,
173    /// Optional trigger geometry/chrome overrides; defaults are the stock box.
174    field: util::FieldBox,
175    is_disabled: bool,
176    is_read_only: bool,
177    is_invalid: bool,
178    is_required: bool,
179    /// `disabledKeys` — suggestions that render but cannot be chosen,
180    /// addressed by item key so a disabled item never disables its
181    /// same-label sibling.
182    disabled_keys: std::collections::HashSet<SharedString>,
183    /// `shouldFocusWrap` — whether the arrow keys wrap at the ends of the list.
184    should_focus_wrap: bool,
185    /// `ListBox.Section` — a heading above the item with this key.
186    sections: Vec<(SharedString, SharedString)>,
187    /// `Autocomplete.Indicator` — replaces the trigger chevron. The closure is
188    /// handed whether the popover is open.
189    indicator: Option<Box<dyn Fn(bool) -> gpui::AnyElement + 'static>>,
190    /// Composed `ListBox.ItemIndicator` — draws the selection tick. The closure
191    /// is handed whether the row is selected.
192    item_indicator: Option<Box<dyn Fn(bool) -> gpui::AnyElement + 'static>>,
193    /// `Autocomplete.Value` — draws the trigger's value.
194    value_content: Option<Box<dyn Fn(util::SelectionValue<'_>) -> gpui::AnyElement + 'static>>,
195    /// `allowsEmptyCollection` — whether the autocomplete may function when
196    /// the collection has no items at all. It is the `useSelectState` open
197    /// gate, not a close-on-filtered-empty flag: filtering an open popover
198    /// to zero keeps it mounted with the empty state either way.
199    allows_empty_collection: bool,
200    selection_mode: SelectionMode,
201    /// The selection as ordered unique item keys — react-stately 3.49.0's
202    /// `selectedKeys` is a JS `Set`, which iterates in insertion order, so
203    /// callbacks, the form value and the trigger all follow the order the
204    /// keys were picked (or the owner listed) in.
205    selected_keys: Vec<SharedString>,
206    /// Whether the caller drives the selection. An unset `value` is not an empty
207    /// controlled selection: without this flag every uncontrolled Autocomplete
208    /// would hand its own clicks back to a set nobody owns, and picking an item
209    /// would do nothing.
210    is_controlled: bool,
211    /// `defaultValue` — set it to hand this component its own selection.
212    default_value: Option<Vec<SharedString>>,
213    on_selection_change_all:
214        Option<std::sync::Arc<dyn Fn(&[SharedString], &mut Window, &mut App) + 'static>>,
215    /// `isOpen`. `None` lets the trigger own it, seeded from `defaultOpen`.
216    is_open: Option<bool>,
217    default_open: bool,
218    placement: Placement,
219    on_open_change: Option<std::sync::Arc<dyn Fn(&bool, &mut Window, &mut App) + 'static>>,
220    on_selection_change: Option<OnSelectionChange>,
221    /// `filter` — decides whether an item matches the query. Defaults to a
222    /// case-insensitive substring test.
223    filter: Option<std::sync::Arc<dyn Fn(&str, &str) -> bool + 'static>>,
224    input_value: Option<String>,
225    on_input_change: Option<std::sync::Arc<dyn Fn(&str, &mut Window, &mut App) + 'static>>,
226    on_clear: Option<std::sync::Arc<dyn Fn(&mut Window, &mut App) + 'static>>,
227    form_state: AutocompleteFormState,
228    /// The `sx` slot, refined over the root style at the end of render.
229    sx: Option<Box<gpui::StyleRefinement>>,
230}
231
232impl Autocomplete {
233    /// `selectionMode`
234    pub fn selection_mode(mut self, mode: SelectionMode) -> Self {
235        self.selection_mode = mode;
236        self
237    }
238
239    /// `defaultValue` — the uncontrolled initial selection.
240    ///
241    /// Supplying it hands the component its own selection set, seeded once;
242    /// [`Self::value`] is the controlled spelling. The listed order is the
243    /// selection's order, exactly as the owner listed it.
244    pub fn default_value(
245        mut self,
246        keys: impl IntoIterator<Item = impl Into<SharedString>>,
247    ) -> Self {
248        self.default_value = Some(keys.into_iter().map(Into::into).collect());
249        self
250    }
251
252    /// `value` — the controlled selection, as item keys. The listed order is
253    /// the owner's order and is preserved everywhere the selection is read.
254    pub fn value(mut self, keys: impl IntoIterator<Item = impl Into<SharedString>>) -> Self {
255        self.selected_keys = keys.into_iter().map(Into::into).collect();
256        self.is_controlled = true;
257        self
258    }
259
260    /// The `ListBox`'s spelling of [`Self::value`], for a caller that has a set.
261    pub fn selected_keys(mut self, keys: impl IntoIterator<Item = SharedString>) -> Self {
262        self.selected_keys = keys.into_iter().collect();
263        self.is_controlled = true;
264        self
265    }
266
267    /// `onChange`'s complete `Key | Key[] | null` domain as a selection slice.
268    /// Single selection reports zero or one key; multiple reports every key.
269    pub fn on_selection_change_all(
270        mut self,
271        handler: impl Fn(&[SharedString], &mut Window, &mut App) + 'static,
272    ) -> Self {
273        self.on_selection_change_all = Some(std::sync::Arc::new(handler));
274        self
275    }
276
277    /// `placement` on `Autocomplete.Popover`.
278    pub fn placement(mut self, placement: Placement) -> Self {
279        self.placement = placement;
280        self
281    }
282
283    /// `defaultOpen` — the popover starts open.
284    pub fn default_open(mut self, v: bool) -> Self {
285        self.default_open = v;
286        self
287    }
288
289    /// `isOpen` — the controlled popover state.
290    pub fn is_open(mut self, v: bool) -> Self {
291        self.is_open = Some(v);
292        self
293    }
294
295    /// `onOpenChange`
296    pub fn on_open_change(
297        mut self,
298        handler: impl Fn(&bool, &mut Window, &mut App) + 'static,
299    ) -> Self {
300        self.on_open_change = Some(std::sync::Arc::new(handler));
301        self
302    }
303
304    /// `filter` on `Autocomplete.Filter` — replaces the default
305    /// case-insensitive substring match.
306    ///
307    /// Called as `filter(item_label, input)`: v3 filters on the item's
308    /// `textValue`, not on its key.
309    pub fn filter(mut self, f: impl Fn(&str, &str) -> bool + 'static) -> Self {
310        self.filter = Some(std::sync::Arc::new(f));
311        self
312    }
313
314    /// `inputValue` on `Autocomplete.Filter` — the controlled search text.
315    ///
316    /// Unlike the bound [`InputState`] this does not write through, so a caller
317    /// can hold the query itself.
318    pub fn input_value(mut self, value: impl Into<String>) -> Self {
319        self.input_value = Some(value.into());
320        self
321    }
322
323    /// `onInputChange` on `Autocomplete.Filter` — every keystroke in the search
324    /// field.
325    pub fn on_input_change(mut self, f: impl Fn(&str, &mut Window, &mut App) + 'static) -> Self {
326        self.on_input_change = Some(std::sync::Arc::new(f));
327        self
328    }
329
330    /// `onClear` — called after `Autocomplete.ClearButton` clears selection.
331    /// The button's clearing behavior does not depend on this callback.
332    pub fn on_clear(mut self, f: impl Fn(&mut Window, &mut App) + 'static) -> Self {
333        self.on_clear = Some(std::sync::Arc::new(f));
334        self
335    }
336
337    /// Pick-only single-key convenience callback.
338    ///
339    /// Use [`Self::on_selection_change_all`] for v3's complete `onChange`
340    /// domain, including multiple selection and the empty value from clear.
341    pub fn on_change(
342        self,
343        handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
344    ) -> Self {
345        self.on_selection_change(handler)
346    }
347
348    /// Creates an autocomplete over `items`, with `state` holding the input text.
349    pub fn new(state: Entity<InputState>, items: Vec<PickerItem>) -> Self {
350        let form_state = autocomplete_form_state(state.entity_id().as_u64());
351        Self {
352            name: None,
353            state,
354            items,
355            max_items: 100,
356            row_height: None,
357            row_padding_x: None,
358            row_padding_y: None,
359            row_hover_bg: None,
360            trigger_hover_bg: None,
361            clear_hover_bg: None,
362            row_font_family: None,
363            font_family: None,
364            radius: None,
365            field: util::FieldBox::default(),
366            label: None,
367            placeholder: None,
368            description: None,
369            error_message: None,
370            variant: FieldVariant::Primary,
371            full_width: false,
372            is_disabled: false,
373            is_read_only: false,
374            is_invalid: false,
375            is_required: false,
376            disabled_keys: std::collections::HashSet::new(),
377            should_focus_wrap: false,
378            sections: Vec::new(),
379            indicator: None,
380            item_indicator: None,
381            value_content: None,
382            allows_empty_collection: false,
383            selection_mode: SelectionMode::Single,
384            selected_keys: Vec::new(),
385            is_controlled: false,
386            default_value: None,
387            on_selection_change_all: None,
388            filter: None,
389            input_value: None,
390            on_input_change: None,
391            on_clear: None,
392            is_open: None,
393            default_open: false,
394            placement: Placement::BottomStart,
395            on_open_change: None,
396            on_selection_change: None,
397            form_state,
398            sx: None,
399        }
400    }
401
402    /// `name` — the name this control submits under.
403    pub fn name(mut self, name: impl Into<SharedString>) -> Self {
404        self.name = Some(name.into());
405        self
406    }
407
408    /// The `Form` field this control submits, when it has a `name`.
409    ///
410    /// v3 discovers a field through the DOM; gpui gives a child no way to reach
411    /// its ancestor, so the control hands the pair over instead. The live
412    /// selection survives the next `Autocomplete::new` because it is keyed by
413    /// the search-field entity, the way DateField keys its form state. A
414    /// disabled control stays registered and is omitted from FormData.
415    ///
416    /// ```
417    /// # use gpui::{prelude::*, Window};
418    /// # use herogpui_components::{Autocomplete, Form, InputState, PickerItem};
419    /// # struct Demo;
420    /// # impl Render for Demo {
421    /// #     fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
422    /// #         let form = Form::new();
423    /// #         let state = cx.new(|cx| InputState::new(cx));
424    /// #         let control = Autocomplete::new(state, vec![PickerItem::new("rust", "Rust")])
425    /// #             .name("lang");
426    /// let field = control.form_field();
427    /// form.field(field.unwrap()).child(control)
428    /// #     }
429    /// # }
430    /// # let mut tcx = gpui::TestAppContext::single();
431    /// # tcx.update(herogpui_theme::ThemeProvider::init);
432    /// # let _ = tcx.add_window_view(|_, _| Demo);
433    /// ```
434    pub fn form_field(&self) -> Option<crate::form::FormField> {
435        let name = self.name.clone()?;
436        {
437            let mut state = self.form_state.borrow_mut();
438            state.is_successful = !self.is_disabled;
439            state.is_invalid = self.is_invalid || self.error_message.is_some();
440        }
441        Some(
442            crate::form::FormField::live(name, self.form_state.clone())
443                .is_required(self.is_required),
444        )
445    }
446
447    /// `ListLayout`'s `rowHeight` -- and what virtualizes the popover list.
448    ///
449    /// v3 wraps the list in `<Virtualizer layout={ListLayout}>` inside
450    /// `Autocomplete.Popover`; gpui's `uniform_list` builds only the rows in
451    /// view, and it can do that because every row is this tall.
452    pub fn row_height(mut self, h: impl Into<Pixels>) -> Self {
453        self.row_height = Some(h.into());
454        self
455    }
456
457    /// Sets the maximum number of suggestions shown; values below 1 are treated as 1.
458    pub fn max_items(mut self, n: usize) -> Self {
459        self.max_items = n.max(1);
460        self
461    }
462
463    /// Sets the label shown above the field.
464    pub fn label(mut self, l: impl Into<SharedString>) -> Self {
465        self.label = Some(l.into());
466        self
467    }
468
469    /// Sets the input placeholder.
470    pub fn placeholder(mut self, p: impl Into<SharedString>) -> Self {
471        self.placeholder = Some(p.into());
472        self
473    }
474
475    /// Sets the description shown below the field.
476    pub fn description(mut self, text: impl Into<SharedString>) -> Self {
477        self.description = Some(text.into());
478        self
479    }
480
481    /// Sets the error message shown when the field is invalid.
482    pub fn error_message(mut self, text: impl Into<SharedString>) -> Self {
483        self.error_message = Some(text.into());
484        self
485    }
486
487    /// Sets the field variant.
488    pub fn variant(mut self, variant: FieldVariant) -> Self {
489        self.variant = variant;
490        self
491    }
492
493    /// Sets whether the field fills the available width.
494    pub fn full_width(mut self, v: bool) -> Self {
495        self.full_width = v;
496        self
497    }
498
499    /// Fixes the trigger box at `h`. Unset keeps the 36px `min-h-9`; content
500    /// taller than an explicit height overflows the box.
501    pub fn height(mut self, h: impl Into<Pixels>) -> Self {
502        self.field.height = Some(h.into());
503        self
504    }
505
506    /// Replaces the trigger's `px-3` horizontal padding. The trailing 28px
507    /// keeps its room for the indicator.
508    pub fn padding_x(mut self, p: impl Into<Pixels>) -> Self {
509        self.field.padding_x = Some(p.into());
510        self
511    }
512
513    /// Renders the trigger with no background, border, field shadow, ring,
514    /// focus fill or hover fill, for a caller painting around it. The list
515    /// still opens and selects.
516    pub fn is_bare(mut self, v: bool) -> Self {
517        self.field.is_bare = v;
518        self.field.is_bare_is_set = true;
519        self
520    }
521
522    /// Shows or hides only the trigger's visual focus ring. The trigger stays
523    /// keyboard focusable and the list still opens when set to `false`.
524    pub fn focus_ring(mut self, v: bool) -> Self {
525        self.field.focus_ring = Some(v);
526        self
527    }
528
529    /// Replaces the list rows' `px-2.5` horizontal padding.
530    pub fn row_padding_x(mut self, p: impl Into<Pixels>) -> Self {
531        self.row_padding_x = Some(p.into());
532        self
533    }
534
535    /// Replaces the list rows' `py-1.5` vertical padding.
536    pub fn row_padding_y(mut self, p: impl Into<Pixels>) -> Self {
537        self.row_padding_y = Some(p.into());
538        self
539    }
540
541    /// The trigger's fill while hovered, in place of `--field-hover`
542    /// (`--default-hover` on the secondary variant). The 150ms ease-smooth
543    /// fade, the border hover and the clear button's suppression are
544    /// unchanged; a disabled or bare trigger does not hover. Not a v3 prop:
545    /// v3 tints the trigger with a class.
546    pub fn trigger_hover_bg(mut self, color: impl Into<gpui::Hsla>) -> Self {
547        self.trigger_hover_bg = Some(color.into());
548        self
549    }
550
551    /// The clear button's fill while hovered, in place of `--default-hover`.
552    /// Its press scale is unchanged. Not a v3 prop: v3 tints the button with
553    /// a class.
554    pub fn clear_hover_bg(mut self, color: impl Into<gpui::Hsla>) -> Self {
555        self.clear_hover_bg = Some(color.into());
556        self
557    }
558
559    /// The fill a hovered row takes, in place of `--default`.
560    pub fn row_hover_bg(mut self, color: impl Into<gpui::Hsla>) -> Self {
561        self.row_hover_bg = Some(color.into());
562        self
563    }
564
565    /// The family the trigger's value and placeholder, and the popover's
566    /// filter field (query, placeholder and caret measurement), are drawn
567    /// with; unset keeps the inherited family. The detached rows take
568    /// [`Autocomplete::row_font_family`] instead. Not a v3 prop; v3 sets it
569    /// with a class.
570    pub fn font_family(mut self, family: impl Into<SharedString>) -> Self {
571        self.font_family = Some(family.into());
572        self
573    }
574
575    /// The family the option rows are drawn with; unset keeps the inherited
576    /// family. A detached popover does not inherit the trigger's font.
577    pub fn row_font_family(mut self, family: impl Into<SharedString>) -> Self {
578        self.row_font_family = Some(family.into());
579        self
580    }
581
582    /// The corner radius of the detached panel, in place of the owning
583    /// `container_radius` helper. The panel's entry zoom interpolates the same
584    /// value, so both follow the override. Not a v3 prop; the removed v2
585    /// `radius` prop is prohibited and this is a per-component repository
586    /// extension.
587    ///
588    /// The trigger is a field box of its own, painted by the shared field
589    /// chrome — `--field-radius`, not this value.
590    pub fn radius(mut self, radius: impl Into<Pixels>) -> Self {
591        self.radius = Some(radius.into());
592        self
593    }
594
595    /// The one slot for caller-owned low-level styling: GPUI's styling methods
596    /// (`bg`, `text_color`, `w`, `h`, `p`, `rounded`, `border_color`, …)
597    /// applied to the autocomplete's root element after every value the
598    /// variant and the active theme chose, so they win. The trigger paints its
599    /// own chrome, so this reaches the box that chrome sits in, not the chrome.
600    pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
601        util::refine_sx(&mut self.sx, style);
602        self
603    }
604
605    /// Sets whether the field is disabled (`isDisabled`).
606    pub fn is_disabled(mut self, v: bool) -> Self {
607        self.is_disabled = v;
608        self
609    }
610
611    /// A read-only Autocomplete shows its selection and does not open. Pinned
612    /// v3 puts no read-only gate on the clear button part (only `disabled`),
613    /// so a read-only control keeps a working clear button.
614    pub fn is_read_only(mut self, v: bool) -> Self {
615        self.is_read_only = v;
616        self
617    }
618
619    /// Sets whether the field is invalid (`isInvalid`).
620    pub fn is_invalid(mut self, v: bool) -> Self {
621        self.is_invalid = v;
622        self
623    }
624
625    /// Sets whether the field is required (`isRequired`).
626    pub fn is_required(mut self, v: bool) -> Self {
627        self.is_required = v;
628        self
629    }
630
631    /// `disabledKeys` — keys of the suggestions that render but cannot be
632    /// chosen. Disabled state is per key, so one of two same-label items can
633    /// be disabled alone.
634    pub fn disabled_keys(mut self, keys: impl IntoIterator<Item = SharedString>) -> Self {
635        self.disabled_keys = keys.into_iter().collect();
636        self
637    }
638
639    /// `shouldFocusWrap` — whether the arrow keys wrap at the ends of the list.
640    pub fn should_focus_wrap(mut self, v: bool) -> Self {
641        self.should_focus_wrap = v;
642        self
643    }
644
645    /// `ListBox.Section` — a heading rendered above the item with this key.
646    pub fn section_before(
647        mut self,
648        item: impl Into<SharedString>,
649        label: impl Into<SharedString>,
650    ) -> Self {
651        self.sections.push((item.into(), label.into()));
652        self
653    }
654
655    /// `Autocomplete.Indicator` — draw the trigger indicator yourself.
656    ///
657    /// The closure receives the current open state, which is the GPUI analog of
658    /// v3's `data-open` attribute on this part.
659    pub fn indicator(mut self, render: impl Fn(bool) -> gpui::AnyElement + 'static) -> Self {
660        self.indicator = Some(Box::new(render));
661        self
662    }
663
664    /// Composed `ListBox.ItemIndicator` — draw the selected row tick yourself.
665    pub fn item_indicator(mut self, render: impl Fn(bool) -> gpui::AnyElement + 'static) -> Self {
666        self.item_indicator = Some(Box::new(render));
667        self
668    }
669
670    /// `Autocomplete.Value` — draw the trigger's value yourself.
671    ///
672    /// The closure is handed the render props v3 passes into
673    /// `<Autocomplete.Value>{({defaultChildren, isPlaceholder, selectedItems,
674    /// selectedText}) => …}`, so a multiple selection can be drawn as tags.
675    pub fn value_content(
676        mut self,
677        render: impl Fn(util::SelectionValue<'_>) -> gpui::AnyElement + 'static,
678    ) -> Self {
679        self.value_content = Some(Box::new(render));
680        self
681    }
682
683    /// `allowsEmptyCollection` — whether the autocomplete may function with a
684    /// collection that has no items at all (v3: *"When true, the autocomplete
685    /// can function even with no items."*).
686    ///
687    /// react-stately 3.49.0's `useSelectState` reads it as the `open`/`toggle`
688    /// gate: a truly empty collection refuses to open without it. Filtering an
689    /// open popover to zero is a different layer (the ListBox's empty-state
690    /// slot), so this is not a close-on-filtered-empty flag.
691    pub fn allows_empty_collection(mut self, v: bool) -> Self {
692        self.allows_empty_collection = v;
693        self
694    }
695
696    /// Sets the handler called with the chosen suggestion (`onSelectionChange`).
697    pub fn on_selection_change(
698        mut self,
699        f: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
700    ) -> Self {
701        self.on_selection_change = Some(std::sync::Arc::new(f));
702        self
703    }
704}
705
706type OnOpenChange = std::sync::Arc<dyn Fn(&bool, &mut Window, &mut App) + 'static>;
707type OnSelectionChangeAll =
708    std::sync::Arc<dyn Fn(&[SharedString], &mut Window, &mut App) + 'static>;
709
710/// The controlled halves `render` resolves first: the frame's ids and shared
711/// collection, the selection and the open flag. `controlled` takes `cx`
712/// mutably, so these precede every other read.
713struct AutoControlled {
714    base: String,
715    base_id: gpui::ElementId,
716    items: Rc<[PickerItem]>,
717    multiple: bool,
718    selection_own: Option<Entity<Vec<SharedString>>>,
719    open: bool,
720    open_own: Option<Entity<bool>>,
721    overlay_phase: util::OverlayPhase,
722    dismissal_token: util::OverlayToken,
723}
724
725/// The clear button's keyed interaction and derived flags.
726struct AutoClear {
727    clear_slot: util::Interaction,
728    clear_active: bool,
729    clear_hovered: bool,
730    clear_pressed: bool,
731    reduce_motion: bool,
732    clear_opacity: crate::anim::Tween<f32>,
733}
734
735/// Everything one Autocomplete frame shares between its painted parts: the
736/// controlled state, the keyed handles, the resolved matches and cursor, the
737/// clear button's state and the theme tokens. `render` resolves it once in
738/// [`Autocomplete::frame`]; the trigger, its clear button, the key handler
739/// and the popover all read this one copy.
740struct AutoFrame {
741    base: String,
742    base_id: gpui::ElementId,
743    items: Rc<[PickerItem]>,
744    multiple: bool,
745    selection_own: Option<Entity<Vec<SharedString>>>,
746    open: bool,
747    open_own: Option<Entity<bool>>,
748    overlay_phase: util::OverlayPhase,
749    dismissal_token: util::OverlayToken,
750    resolved_placement: Rc<Cell<Option<Placement>>>,
751    entry_placement: Placement,
752    blur_scope: gpui::FocusHandle,
753    focus_handle: Option<gpui::FocusHandle>,
754    cursor: Entity<Option<SharedString>>,
755    list_scroll_now: gpui::UniformListScrollHandle,
756    panel_scroll_now: gpui::ScrollHandle,
757    query_edit: Entity<Option<bool>>,
758    plain_edit_key: Entity<bool>,
759    clear: AutoClear,
760    raw_query: String,
761    matches: Rc<[PickerItem]>,
762    cursor_at: Option<usize>,
763    anchor_bounds: Rc<Cell<Option<gpui::Bounds<Pixels>>>>,
764    colors: herogpui_theme::ThemeColors,
765    layout: herogpui_theme::LayoutTheme,
766    is_invalid: bool,
767    can_open: bool,
768    toggle_allowed: bool,
769    trigger_pressed: Rc<Cell<bool>>,
770}
771
772impl RenderOnce for Autocomplete {
773    fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
774        // One shared collection for the frame: the `'static` panel and event
775        // closures clone the `Rc`, never the rows.
776        let items: Rc<[PickerItem]> = std::mem::take(&mut self.items).into();
777        let base = format!("autocomplete-{}", self.state.entity_id().as_u64());
778        // The same identity as `base`, kept as structure for the ids that key
779        // state rather than name a debug selector.
780        let base_id =
781            gpui::ElementId::named_usize("autocomplete", self.state.entity_id().as_u64() as usize);
782        // `Autocomplete.Filter.inputValue` is controlled state. Keep the bound
783        // search field on the owner's value while still reporting proposed
784        // edits through `onInputChange`.
785        if let Some(input_value) = self.input_value.clone() {
786            let current = self.state.read(cx).value().to_owned();
787            if current != input_value {
788                self.state.update(cx, |state, cx| {
789                    state.set_value(input_value);
790                    cx.notify();
791                });
792            }
793        }
794        // `defaultValue` opts into the component holding its own selection;
795        // `controlled` takes `cx` mutably, so it precedes the theme tokens.
796        // Both spellings normalize to the `Set` shape first: duplicates
797        // collapse to their first insertion, and single mode keeps one key.
798        // A controlled order is the owner's order — nothing is sorted.
799        let multiple = self.selection_mode == SelectionMode::Multiple;
800        self.selected_keys = normalize_selection(self.selected_keys.clone(), multiple);
801        let (selection, selection_own) = util::controlled(
802            window,
803            cx,
804            element_id::scoped(&base_id, "selection"),
805            self.is_controlled.then(|| self.selected_keys.clone()),
806            normalize_selection(self.default_value.clone().unwrap_or_default(), multiple),
807        );
808        self.selected_keys = selection;
809
810        // `isOpen` / `defaultOpen`: the trigger owns the popover, the way
811        // `.autocomplete__trigger` does in v3 -- the old port opened on focus,
812        // which is a ComboBox's behaviour, not this one's.
813        let (is_open, open_own) = util::controlled(
814            window,
815            cx,
816            element_id::scoped(&base_id, "open"),
817            self.is_open,
818            self.default_open,
819        );
820        let open = is_open && !self.is_disabled;
821        let (overlay_phase, dismissal_token) = util::overlay_scope(
822            window,
823            cx,
824            element_id::scoped(&base_id, "overlay"),
825            open,
826            true,
827        );
828        let frame = self.frame(
829            AutoControlled {
830                base,
831                base_id,
832                items,
833                multiple,
834                selection_own,
835                open,
836                open_own,
837                overlay_phase,
838                dismissal_token,
839            },
840            window,
841            cx,
842        );
843
844        // --- the trigger ----------------------------------------------------
845        let mut field = self.trigger_field(&frame, window, cx);
846        let (value_slot, selected_text) = self.trigger_value(&frame, cx);
847        // `@heroui/react/dist/components/autocomplete/autocomplete.js` builds
848        // this trigger from RAC `Select` + `Button` — v3's Autocomplete is a
849        // Select whose popover holds a search field, not a ComboBox — so
850        // `react-aria/.../select/useSelect.mjs` decides its contract through
851        // `useMenuTrigger({type: 'listbox'})`: a native `<button>` with
852        // `'aria-haspopup': 'listbox'`, `'aria-expanded': isOpen`,
853        // `'aria-controls'`, named by its label and the drawn value. Only the
854        // role, the name and `aria-expanded` have gpui builders.
855        //
856        // Stated here rather than at the head of the chain because
857        // `selected_text` is what names it, and that is not known until the
858        // value slot is resolved.
859        field = field
860            .a11y_named(
861                a11y::Role::Button,
862                &a11y::Name::maybe(self.label.clone())
863                    .described(Some(SharedString::from(selected_text))),
864            )
865            .a11y_expanded(frame.open);
866        field = field.child(value_slot);
867        field = field.child(self.clear_button(&frame, cx));
868        field = self.trigger_indicator_slot(field, &frame, window, cx);
869        field = self.trigger_toggle(field, &frame);
870        if let Some(handle) = &frame.focus_handle {
871            field = util::record_focus_bounds(field, handle, window, cx);
872        }
873
874        // The popup anchors to the trigger bounds -- not to the
875        // label-to-description wrapper root -- the way RAC's
876        // `useOverlayPosition` positions against the trigger rect.
877        // `scrollable_field_popover` below reads these bounds to flip and
878        // cap the panel; the measure element itself only records them.
879        let field = crate::popover::PopoverTriggerMeasure::new(field, frame.anchor_bounds.clone());
880
881        let mut root = self.field_root(field, &frame);
882        if frame.can_open {
883            root = self.root_keys(root, &frame);
884        }
885        root = self.root_escape(root, &frame);
886
887        // --- the popover ----------------------------------------------------
888        // The popover's presence is the Select's open state and nothing else.
889        // Filtering happens inside `Autocomplete.Filter`, which prunes only
890        // the ListBox's rows. At zero this port draws the "No results found"
891        // empty state used by v3's examples; `allowsEmptyCollection` is not a
892        // close-on-filtered-empty flag. The panel carries its own
893        // outside-press dismissal, so there is nothing to attach to the root
894        // when it is unmounted.
895        if frame.overlay_phase != util::OverlayPhase::Closed {
896            root = root.child(self.popover(frame, cx));
897        }
898
899        util::apply_sx(root, &self.sx)
900    }
901}
902
903impl Autocomplete {
904    /// Resolves the frame's keyed handles, matches and cursor on top of the
905    /// controlled state.
906    fn frame(&self, controlled: AutoControlled, window: &mut Window, cx: &mut App) -> AutoFrame {
907        let AutoControlled {
908            ref base_id,
909            ref items,
910            open,
911            ref open_own,
912            ..
913        } = controlled;
914        // Field popover placement can flip during prepaint. Feed the resolved
915        // physical side back into the next entry frame, just like Popover.
916        let (resolved_placement, entry_placement) =
917            crate::popover::field_placement_feedback(window, cx, base_id, self.placement);
918
919        // `usePopover` closes when focus leaves the trigger-plus-panel scope.
920        // Unlike Escape, blur leaves focus on its destination.
921        let blur_close_own = open_own.clone();
922        let blur_open_change = self.on_open_change.clone();
923        let blur_scope = util::close_on_blur(window, cx, base_id, open, move |window, cx| {
924            if let Some(held) = &blur_close_own {
925                held.update(cx, |v, cx| {
926                    *v = false;
927                    cx.notify();
928                });
929            }
930            if let Some(cb) = &blur_open_change {
931                cb(&false, window, cx);
932            }
933        });
934
935        // The trigger is what holds focus, so the open list can be walked with
936        // the arrows. A disabled control leaves the tab order.
937        let focus_handle = if self.is_disabled {
938            None
939        } else {
940            Some(util::tab_stop_handle(
941                element_id::scoped(base_id, "focus"),
942                window,
943                cx,
944            ))
945        };
946        // Which row the keyboard is on, held as the item's *key* so the cursor
947        // stays on the same item when the query filters or the caller reorders
948        // the collection.
949        let cursor = window.use_keyed_state(element_id::scoped(base_id, "cursor"), cx, |_, _| {
950            None::<SharedString>
951        });
952        // React Aria keeps the focused row in view, and v3's list is
953        // `overflow-y-auto`. The virtual list has its own handle kind, a
954        // scrolling div the other. `use_keyed_state` takes `cx` mutably, so both
955        // precede the theme tokens.
956        let list_scroll =
957            window.use_keyed_state(element_id::scoped(base_id, "list-scroll"), cx, |_, _| {
958                gpui::UniformListScrollHandle::new()
959            });
960        let panel_scroll =
961            window.use_keyed_state(element_id::scoped(base_id, "panel-scroll"), cx, |_, _| {
962                gpui::ScrollHandle::new()
963            });
964        let list_scroll_now = list_scroll.read(cx).clone();
965        let panel_scroll_now = panel_scroll.read(cx).clone();
966        // v3 writes `<SearchField autoFocus>` inside `Autocomplete.Filter`, so
967        // the query field takes the focus as the popover opens -- once per
968        // opening, or it would take the focus back on every frame.
969        let autofocused =
970            window.use_keyed_state(element_id::scoped(base_id, "autofocus"), cx, |_, _| false);
971        // SearchField's text callback and the bubbling key event cooperate to
972        // classify the pending edit; see the block after `matches` below.
973        let query_edit =
974            window.use_keyed_state(element_id::scoped(base_id, "query-edit"), cx, |_, _| {
975                None::<bool>
976            });
977        let plain_edit_key =
978            window.use_keyed_state(element_id::scoped(base_id, "plain-edit-key"), cx, |_, _| {
979                false
980            });
981        let clear = self.clear_state(base_id, window, cx);
982        let search_focus = self.state.read(cx).focus_handle.clone();
983        if open && !*autofocused.read(cx) {
984            window.focus(&search_focus, cx);
985            autofocused.update(cx, |v, _| *v = true);
986        } else if !open && *autofocused.read(cx) {
987            autofocused.update(cx, |v, _| *v = false);
988        }
989
990        // A controlled `inputValue` wins over whatever the search field holds.
991        let raw_query = match &self.input_value {
992            Some(v) => v.clone(),
993            None => self.state.read(cx).value().to_owned(),
994        };
995
996        let matches = self.matches(&controlled, &raw_query, &query_edit, window, cx);
997        // The stored cursor is the focused item's key; the row it lands on is
998        // wherever that key sits in the filtered collection now.
999        let cursor_at = cursor
1000            .read(cx)
1001            .as_ref()
1002            .and_then(|k| matches.iter().position(|it| it.key() == k));
1003
1004        // Forward typing while the popover is open puts the collection cursor
1005        // on its first enabled filtered row. react-aria 3.51.0 does this from
1006        // `useAutocomplete.onChange` only for forward input types, and clears
1007        // virtual focus for deletion, paste and history edits. GPUI exposes
1008        // neither a DOM input type nor one combined callback, so the actual
1009        // SearchField change and its bubbling unmodified character key mark
1010        // the edit together. A controlled prop update fires neither and cannot
1011        // masquerade as typing.
1012        if let Some(forward) = *query_edit.read(cx) {
1013            let next = if open && forward {
1014                matches
1015                    .iter()
1016                    .position(|item| !self.disabled_keys.contains(item.key()))
1017            } else {
1018                None
1019            };
1020            if cursor_at != next {
1021                let next_key = next.and_then(|i| matches.get(i)).map(|it| it.key().clone());
1022                cursor.update(cx, |v, cx| {
1023                    *v = next_key;
1024                    cx.notify();
1025                });
1026                if let Some(next) = next {
1027                    if self.row_height.is_some() {
1028                        list_scroll_now.scroll_to_item(next, gpui::ScrollStrategy::Center);
1029                    } else {
1030                        panel_scroll_now.scroll_to_item(next);
1031                    }
1032                }
1033            }
1034            query_edit.update(cx, |v, cx| {
1035                *v = None;
1036                cx.notify();
1037            });
1038        }
1039        if *plain_edit_key.read(cx) {
1040            plain_edit_key.update(cx, |v, _| *v = false);
1041        }
1042
1043        let anchor_bounds: Rc<Cell<Option<gpui::Bounds<Pixels>>>> = window
1044            .use_keyed_state(element_id::scoped(base_id, "anchor-bounds"), cx, |_, _| {
1045                Rc::new(Cell::new(None))
1046            })
1047            .read(cx)
1048            .clone();
1049
1050        // The theme tokens borrow `cx`, so they are copied out only after the
1051        // keyed-state updates above.
1052        let colors = cx.colors().clone();
1053        let layout = cx.layout().clone();
1054
1055        let is_invalid = self.is_invalid || self.error_message.is_some();
1056        self.sync_form(&controlled, is_invalid, &focus_handle);
1057        let can_open = !self.is_disabled;
1058        // Whether the trigger's open acts are allowed at all: react-stately
1059        // 3.49.0's `useSelectState` guards `open`/`toggle` — *"Don't open if
1060        // the collection is empty"* — and v3's Autocomplete root is a RAC
1061        // `Select`, whose trigger calls `state.toggle()`. A collection with no
1062        // items therefore refuses every trigger open/toggle unless
1063        // `allowsEmptyCollection` lets the autocomplete function with no
1064        // items. This is the *unfiltered* collection: a query that prunes an
1065        // open popover to zero never reaches this gate.
1066        let toggle_allowed = self.allows_empty_collection || !items.is_empty();
1067
1068        // Whether the pointer went down on the trigger (or on the clear
1069        // button inside it). The panel's outside-press dismissal treats the
1070        // trigger as outside its own bounds, so a press on an *open* popover's
1071        // trigger would dismiss it on the mouse-down *and* toggle it back open
1072        // through the trigger's own click on the mouse-up -- one press, two
1073        // contradictory reports. The trigger's capture-phase handler runs
1074        // before the panel's `on_mouse_down_out` in the same dispatch, so the
1075        // dismissal can see it and leave the close to the trigger's click.
1076        let trigger_pressed = Rc::new(Cell::new(false));
1077        let AutoControlled {
1078            base,
1079            base_id,
1080            items,
1081            multiple,
1082            selection_own,
1083            open,
1084            open_own,
1085            overlay_phase,
1086            dismissal_token,
1087        } = controlled;
1088        AutoFrame {
1089            base,
1090            base_id,
1091            items,
1092            multiple,
1093            selection_own,
1094            open,
1095            open_own,
1096            overlay_phase,
1097            dismissal_token,
1098            resolved_placement,
1099            entry_placement,
1100            blur_scope,
1101            focus_handle,
1102            cursor,
1103            list_scroll_now,
1104            panel_scroll_now,
1105            query_edit,
1106            plain_edit_key,
1107            clear,
1108            raw_query,
1109            matches,
1110            cursor_at,
1111            anchor_bounds,
1112            colors,
1113            layout,
1114            is_invalid,
1115            can_open,
1116            toggle_allowed,
1117            trigger_pressed,
1118        }
1119    }
1120
1121    /// The clear button's keyed (hovered, pressed) slot and its fade.
1122    fn clear_state(
1123        &self,
1124        base_id: &gpui::ElementId,
1125        window: &mut Window,
1126        cx: &mut App,
1127    ) -> AutoClear {
1128        // The pinned trigger hover carries
1129        // `:not(:has(.autocomplete__clear-button:hover))`: while the pointer is
1130        // on the clear button inside the trigger, the trigger's own hover fill
1131        // is suppressed so the affordance does not double-hover the whole
1132        // field. gpui 0.2.2 has no `:has` analog and a parent hitbox stays
1133        // hovered while a child's is, so the clear button feeds its own hover
1134        // into this keyed (hovered, pressed) slot (`on_hover` dispatches with
1135        // the moved position, before the next paint) and the trigger's
1136        // refinement reads it. The same slot carries the press, for the pinned
1137        // `:active, &[data-pressed] { transform: scale(0.93) }`.
1138        let clear_slot = util::interaction(element_id::scoped(base_id, "clear-ix"), window, cx);
1139        // The slot is read before the theme tokens for the same reason the
1140        // other keyed states are: the normalization below takes `cx` mutably.
1141        // `.autocomplete__clear-button` stays mounted for as long as it is
1142        // composed: pinned v3 gates the part only through `disabled={isDisabled}`
1143        // and its own `data-empty` (`pointer-events-none opacity-0`), and
1144        // neither the part nor pinned react-stately 3.49.0's
1145        // `selectionManager.setSelectedKeys` knows a read-only gate (RAC
1146        // 1.20.0's `Select` has no `isReadOnly` at all), so a read-only
1147        // control keeps a working clear button.
1148        let clear_empty = self.selected_keys.is_empty();
1149        let clear_active = !clear_empty && !self.is_disabled;
1150        // A hover or press recorded on the button outlives the listener that
1151        // would clear it when the button goes inert (cleared, disabled): the
1152        // pointer can leave and the selection can flip with no event reaching
1153        // the detached handler. The inert frames normalize the slot back to
1154        // rest, so a stale flag cannot survive a clear-and-reselect.
1155        if !clear_active && *clear_slot.read(cx) != (false, false) {
1156            clear_slot.update(cx, |state, _| *state = (false, false));
1157        }
1158        let (clear_hovered, clear_pressed) = if clear_active {
1159            *clear_slot.read(cx)
1160        } else {
1161            (false, false)
1162        };
1163        let reduce_motion = ActiveTheme::reduce_motion(cx);
1164        let mut clear_opacity = crate::anim::Tween::keyed(
1165            base_id,
1166            "clear-opacity",
1167            if clear_empty { 0.0 } else { 1.0 },
1168            window,
1169            cx,
1170        );
1171        // HeroUI hides the clear button immediately when the selection is
1172        // emptied, but lets a newly visible button fade in. Keeping the
1173        // transition on a listener-free child preserves the stable 20px hit
1174        // target and lets a clear/reselect reversal resume from its painted
1175        // opacity.
1176        if clear_empty {
1177            clear_opacity.settle();
1178        } else {
1179            clear_opacity.snap_if_reduced(reduce_motion);
1180        }
1181        AutoClear {
1182            clear_slot,
1183            clear_active,
1184            clear_hovered,
1185            clear_pressed,
1186            reduce_motion,
1187            clear_opacity,
1188        }
1189    }
1190
1191    /// The rows the list draws this frame, or none while nothing consumes
1192    /// them.
1193    fn matches(
1194        &self,
1195        controlled: &AutoControlled,
1196        raw_query: &str,
1197        query_edit: &Entity<Option<bool>>,
1198        window: &mut Window,
1199        cx: &mut App,
1200    ) -> Rc<[PickerItem]> {
1201        let AutoControlled {
1202            ref base_id,
1203            ref items,
1204            overlay_phase,
1205            ..
1206        } = *controlled;
1207        let overlay_active = overlay_phase != util::OverlayPhase::Closed;
1208        // The list starts unfiltered: v3's popover shows the whole collection
1209        // until something is typed into the search field. Closed and idle
1210        // frames draw no rows, so they skip the match work entirely; a
1211        // consuming frame with a typed query shares one cached list until the
1212        // query, the collection or the cap changes. An empty query copies the
1213        // capped prefix directly: no per-row matching runs for it, so the
1214        // cache's element-by-element key comparison would cost more than the
1215        // work it saves. A custom filter owns the whole decision, including
1216        // what an empty query means, and its configuration cannot join a
1217        // cache key — its results are never cached and it only runs while the
1218        // matches are consumed.
1219        let matches_cache =
1220            window.use_keyed_state(element_id::scoped(base_id, "matches"), cx, |_, _| {
1221                MatchesCache::default()
1222            });
1223        let consume_matches = overlay_active || query_edit.read(cx).is_some();
1224        let matches: Rc<[PickerItem]> = if !consume_matches {
1225            empty_matches()
1226        } else {
1227            let filter = self.filter.clone();
1228            match &filter {
1229                Some(f) => Rc::from(compute_matches(items, raw_query, self.max_items, Some(f))),
1230                None if raw_query.is_empty() => {
1231                    Rc::from(compute_matches(items, raw_query, self.max_items, None))
1232                }
1233                None => matches_cache.update(cx, |cache, _| {
1234                    cache.get(items.clone(), raw_query, self.max_items, |items| {
1235                        compute_matches(items, raw_query, self.max_items, None)
1236                    })
1237                }),
1238            }
1239        };
1240        matches
1241    }
1242
1243    /// Mirrors the selection into the live form state and installs the
1244    /// reset that restores the default selection.
1245    fn sync_form(
1246        &self,
1247        controlled: &AutoControlled,
1248        is_invalid: bool,
1249        focus_handle: &Option<gpui::FocusHandle>,
1250    ) {
1251        let AutoControlled {
1252            multiple,
1253            ref selection_own,
1254            ..
1255        } = *controlled;
1256        sync_form_state(
1257            &self.form_state,
1258            &self.selected_keys,
1259            self.is_disabled,
1260            is_invalid,
1261        );
1262        self.form_state.borrow_mut().focus = focus_handle.clone();
1263        let restore_own = selection_own.clone();
1264        let restore_state = Rc::downgrade(&self.form_state);
1265        let restore_default =
1266            normalize_selection(self.default_value.clone().unwrap_or_default(), multiple);
1267        let restore_all = self.on_selection_change_all.clone();
1268        self.form_state.borrow_mut().restore = (restore_own.is_some() || restore_all.is_some())
1269            .then(|| {
1270                util::shared(move |window: &mut Window, cx: &mut App| {
1271                    if let Some(state) = restore_state.upgrade() {
1272                        state.borrow_mut().value = form_selection_value(&restore_default);
1273                    }
1274                    if let Some(held) = &restore_own {
1275                        let set = restore_default.clone();
1276                        held.update(cx, |v, cx| {
1277                            *v = set;
1278                            cx.notify();
1279                        });
1280                    }
1281                    if let Some(cb) = &restore_all {
1282                        cb(&restore_default, window, cx);
1283                    }
1284                }) as std::sync::Arc<dyn Fn(&mut Window, &mut App)>
1285            });
1286    }
1287
1288    /// The `.autocomplete__trigger` box: geometry, field chrome, focus ring
1289    /// and hover fade.
1290    fn trigger_field(
1291        &self,
1292        frame: &AutoFrame,
1293        window: &mut Window,
1294        cx: &mut App,
1295    ) -> gpui::Stateful<gpui::Div> {
1296        let AutoFrame {
1297            ref base,
1298            ref base_id,
1299            ref focus_handle,
1300            clear: AutoClear { clear_hovered, .. },
1301            ref colors,
1302            ref layout,
1303            is_invalid,
1304            ..
1305        } = *frame;
1306        let field_box = self.field;
1307        // `radius` overrides the detached panel only; the trigger is a field
1308        // box painted by the shared field chrome.
1309        let trigger_radius = util::field_radius(cx);
1310        // `.autocomplete__trigger` is `relative isolate inline-flex min-h-9
1311        // rounded-field border bg-field px-3 py-2 text-sm shadow-field`, plus
1312        // `pe-7` because the indicator sits inside it.
1313        let mut field = gpui::div()
1314            .id(element_id::scoped(base_id, "trigger"))
1315            .when_some(self.font_family.clone(), |field, family| field.font_family(family))
1316            // Headless probe: the decision that gates the hover refinement
1317            // above, so a test can drive real hover coordinates and read the
1318            // rendered state without painted-color access.
1319            .debug_selector({
1320                let base = base.clone();
1321                move || format!("{base}-trigger-suppressed-{clear_hovered}")
1322            })
1323            .relative()
1324            .flex()
1325            .items_center()
1326            .gap(px(8.))
1327            .min_h(field_box.resolved_height())
1328            .when_some(field_box.height, |el, h| el.h(h))
1329            .px(field_box.resolved_padding_x())
1330            .pr(px(28.))
1331            .text_size(util::FIELD_TEXT)
1332            .line_height(px(20.));
1333        if !field_box.is_bare {
1334            field = util::apply_field_chrome(
1335                field,
1336                self.variant,
1337                is_invalid,
1338                false,
1339                Some(trigger_radius),
1340                cx,
1341            );
1342            // `.autocomplete__trigger:focus-visible` is `status-focused` -- the
1343            // offset ring, not a field's flush one, which is why the chrome
1344            // above is not told about the focus.
1345            if field_box.focus_ring.unwrap_or(true) {
1346                if let Some(handle) = &focus_handle {
1347                    // Overlay rather than spread shadow: the ring's corner is
1348                    // then concentric with `trigger_radius` instead of
1349                    // repeating the trigger's own radius four pixels out, and
1350                    // its edge is crisp instead of blurred.
1351                    field = util::ring_overlay_if_focused(
1352                        field,
1353                        handle,
1354                        true,
1355                        trigger_radius,
1356                        Vec::new(),
1357                        window,
1358                        cx,
1359                    );
1360                }
1361            }
1362        }
1363        if self.is_disabled {
1364            field = field.opacity(layout.disabled_opacity);
1365        } else {
1366            // The cursor is an affordance, not chrome: a bare trigger stays
1367            // clickable and keeps the themed pointer.
1368            field = field.cursor(util::interactive_cursor(cx));
1369            if !field_box.is_bare {
1370                let idle_bg = match self.variant {
1371                    FieldVariant::Primary => colors.field.background,
1372                    FieldVariant::Secondary => colors.default.color,
1373                };
1374                let hover_bg = self.trigger_hover_bg.unwrap_or(match self.variant {
1375                    FieldVariant::Primary => colors.field.hover(),
1376                    // `.autocomplete--secondary` hovers
1377                    // `--autocomplete-trigger-bg-hover: var(--default-hover)`.
1378                    FieldVariant::Secondary => colors.default.hover(),
1379                });
1380                let hover_border = colors.field.border_hover();
1381                // The trigger owns the stable focus/clear listeners. Animate
1382                // only its background fill with the pinned 150ms
1383                // `ease-smooth` curve; while the nested clear affordance is
1384                // hovered, suppress the parent endpoint just like v3's
1385                // `:not(:has(.autocomplete__clear-button:hover))` rule.
1386                field = crate::anim::hover_fade_with_duration_and_easing_suppressed(
1387                    field,
1388                    element_id::scoped(base_id, "trigger-hover-fade"),
1389                    (idle_bg, hover_bg),
1390                    None,
1391                    (!clear_hovered).then_some(hover_border),
1392                    clear_hovered,
1393                    |fill| fill.rounded(trigger_radius),
1394                    Some(150),
1395                    crate::anim::HoverFadeEasing::EaseSmooth,
1396                    window,
1397                    cx,
1398                );
1399            }
1400        }
1401        if self.full_width {
1402            field = field.w_full();
1403        } else {
1404            // v3's trigger is `inline-flex` and every documented example sizes
1405            // it from the outside (`<Autocomplete className="w-[256px]">`).
1406            // There is no `className` here, so the trigger keeps a floor of its
1407            // own rather than collapsing onto the placeholder -- the same choice
1408            // `ComboBox` makes.
1409            field = field.min_w(px(180.));
1410        }
1411        field
1412    }
1413
1414    /// `.autocomplete__value`: the selection's text, or the caller's slot.
1415    fn trigger_value(&mut self, frame: &AutoFrame, cx: &App) -> (gpui::AnyElement, String) {
1416        let AutoFrame {
1417            ref items,
1418            ref colors,
1419            ..
1420        } = *frame;
1421        // --- `.autocomplete__value` -----------------------------------------
1422        // The trigger renders the selection in the selection set's own order —
1423        // pinned react-stately 3.49.0's `selectedKeys` is a JS `Set`, whose
1424        // iteration order is insertion order, so `selectedItems`, the render
1425        // props' keys and `selectedText` follow the pick (or owner) order, not
1426        // the collection's. Each key resolves to its item wherever that item
1427        // now sits; a key whose item is not in the collection (still loading)
1428        // renders nothing, which is what v3's `selectedItems` does too.
1429        let mut selected_items: Vec<SharedString> = Vec::new();
1430        let mut selected_indices: Vec<usize> = Vec::new();
1431        for key in &self.selected_keys {
1432            if let Some((index, item)) = items.iter().enumerate().find(|(_, it)| it.key() == key) {
1433                selected_items.push(item.label().clone());
1434                selected_indices.push(index);
1435            }
1436        }
1437        let selected_key_order = self.selected_keys.clone();
1438        // `selectedText` — v3 joins with locale-aware separators; without CLDR
1439        // data this is a comma and a space.
1440        let selected_text = selected_items
1441            .iter()
1442            .map(ToString::to_string)
1443            .collect::<Vec<_>>()
1444            .join(", ");
1445        let is_placeholder = selected_items.is_empty();
1446        let placeholder = self
1447            .placeholder
1448            .clone()
1449            // v3's own default for this prop.
1450            .unwrap_or_else(|| crate::i18n::ui_string(crate::i18n::UiString::SelectPlaceholder, cx));
1451        // `.autocomplete__value` is `flex-1 text-start text-sm
1452        // wrap-break-word`, and `text-field-placeholder` while nothing is
1453        // chosen. Keep the value slot's min-content floor released so a
1454        // narrow trigger grows vertically for a long selected label.
1455        let default_children = gpui::div()
1456            .flex_1()
1457            .min_w_0()
1458            .whitespace_normal()
1459            .text_size(util::FIELD_TEXT)
1460            .line_height(px(20.))
1461            .text_color(if is_placeholder {
1462                colors.field.placeholder
1463            } else {
1464                colors.field.foreground
1465            })
1466            .child(if is_placeholder {
1467                placeholder.to_string()
1468            } else {
1469                selected_text.clone()
1470            })
1471            .into_any_element();
1472        let value_slot = match self.value_content.take() {
1473            Some(render) => gpui::div()
1474                .flex_1()
1475                .min_w_0()
1476                .child(render(util::SelectionValue {
1477                    selected_items: &selected_items,
1478                    selected_indices: &selected_indices,
1479                    selected_keys: Some(&selected_key_order),
1480                    selected_text: &selected_text,
1481                    is_placeholder,
1482                    default_children,
1483                }))
1484                .into_any_element(),
1485            None => default_children,
1486        };
1487        (value_slot, selected_text)
1488    }
1489
1490    /// `.autocomplete__clear-button`: a stable 20px hit box over a scaled,
1491    /// fading visual.
1492    fn clear_button(&self, frame: &AutoFrame, cx: &App) -> gpui::Stateful<gpui::Div> {
1493        let AutoFrame {
1494            ref base,
1495            ref base_id,
1496            ref selection_own,
1497            clear:
1498                AutoClear {
1499                    ref clear_slot,
1500                    clear_active,
1501                    clear_pressed,
1502                    reduce_motion,
1503                    ref clear_opacity,
1504                    ..
1505                },
1506            ref colors,
1507            ..
1508        } = *frame;
1509        // `.autocomplete__clear-button` — mounted whenever the trigger is:
1510        // `data-empty` only makes it invisible and pointer-inert, and
1511        // `disabled={isDisabled}` only disables it. The pinned part is a
1512        // 20px `rounded-xl p-1` box holding the `size-3.5` (14px) glyph, and
1513        // `:active, &[data-pressed]` scales the whole button to 0.93 about
1514        // its center. gpui 0.2.2 cannot scale a div, so the 20px hit box
1515        // stays put for the pointer and a centered *visual* box carries the
1516        // scale while pressed.
1517        let clear_scale = if clear_pressed { 0.93 } else { 1. };
1518        let clear_visual = px(20. * clear_scale);
1519        let clear_glyph = px(14. * clear_scale);
1520        let clear_radius = px(f32::from(util::small_radius(cx)) * clear_scale);
1521        // `.autocomplete__clear-button:hover` fills with `bg-default-hover`,
1522        // the role-hover mix -- not the lighter soft-hover wash.
1523        let hover_bg = self.clear_hover_bg.unwrap_or(colors.default.hover());
1524        let clear_visual = gpui::div()
1525            .debug_selector({
1526                let base = base.clone();
1527                move || format!("{base}-clear-visual")
1528            })
1529            .flex()
1530            .items_center()
1531            .justify_center()
1532            .flex_shrink_0()
1533            .size(clear_visual)
1534            .rounded(clear_radius)
1535            .when(clear_active, |el| el.hover(move |st| st.bg(hover_bg)))
1536            .child(
1537                gpui::svg()
1538                    .size(clear_glyph)
1539                    .path(icons::CLOSE)
1540                    .text_color(colors.muted),
1541            );
1542        let clear_visual = if clear_opacity.animates(reduce_motion) {
1543            let from = clear_opacity.from();
1544            let to = clear_opacity.target();
1545            let value = clear_opacity.value();
1546            clear_visual
1547                .with_animation(
1548                    element_id::indexed(base_id, "clear-opacity", clear_opacity.generation()),
1549                    gpui::Animation::new(Duration::from_millis(CLEAR_OPACITY_TRANSITION_MS))
1550                        .with_easing(crate::anim::ease_smooth()),
1551                    move |visual, delta| {
1552                        let next = from + (to - from) * delta;
1553                        value.set(next);
1554                        visual.opacity(next)
1555                    },
1556                )
1557                .into_any_element()
1558        } else {
1559            clear_opacity.settle();
1560            clear_visual
1561                .opacity(clear_opacity.target())
1562                .into_any_element()
1563        };
1564        let mut clear = gpui::div()
1565            .id(element_id::scoped(base_id, "clear"))
1566            // `autocomplete.js` hard-codes `"aria-label": "Clear selection"`
1567            // on this RAC `Button`, beside an `aria-hidden` that only hides
1568            // it while the selection is empty — and gpui has no
1569            // `aria-hidden`, so the port leaves the empty button in the tree
1570            // where upstream removes it from it.
1571            .a11y_named(
1572                a11y::Role::Button,
1573                &a11y::Name::labelled(crate::i18n::ui_string(
1574                    crate::i18n::UiString::ClearSelection,
1575                    cx,
1576                )),
1577            )
1578            // `.autocomplete__clear-button` is `h-6 w-6`
1579            // and then `size-5`, so 20px, `rounded-xl` and `p-1`
1580            // -- with the pinned `size-3.5` (14px) glyph inside.
1581            .size(px(20.))
1582            .p(px(4.))
1583            .rounded(util::small_radius(cx))
1584            .relative()
1585            .flex()
1586            .items_center()
1587            .justify_center()
1588            .flex_shrink_0()
1589            .when(clear_active, |el| el.cursor(util::interactive_cursor(cx)))
1590            .debug_selector({
1591                let base = base.clone();
1592                move || format!("{base}-clear")
1593            })
1594            .child(clear_visual);
1595        if clear_active {
1596            let own = selection_own.clone();
1597            let selection_cb = self.on_selection_change_all.clone();
1598            let clear_cb = self.on_clear.clone();
1599            let clear_form_state = self.form_state.clone();
1600            clear = util::track_interaction(clear, clear_slot).on_click(move |_, window, cx| {
1601                // The button sits *inside* the trigger, so gpui
1602                // dispatches its click up to the trigger's own
1603                // `on_click` too -- and clearing is not an open
1604                // gesture (React Aria's trigger press is
1605                // pointer-bound, so a bubbled DOM click is inert
1606                // there).
1607                cx.stop_propagation();
1608                // Uncontrolled: drop our own selection too, or the
1609                // button would clear nothing.
1610                if let Some(held) = &own {
1611                    held.update(cx, |v, cx| {
1612                        v.clear();
1613                        cx.notify();
1614                    });
1615                    clear_form_state.borrow_mut().value = crate::form::FormValue::Keys(Vec::new());
1616                }
1617                if let Some(cb) = &selection_cb {
1618                    cb(&[], window, cx);
1619                }
1620                if let Some(cb) = &clear_cb {
1621                    cb(window, cx);
1622                }
1623            });
1624        }
1625        clear
1626    }
1627
1628    /// The absolute `.autocomplete__indicator` end slot.
1629    fn trigger_indicator_slot(
1630        &mut self,
1631        mut field: gpui::Stateful<gpui::Div>,
1632        frame: &AutoFrame,
1633        window: &mut Window,
1634        cx: &mut App,
1635    ) -> gpui::Stateful<gpui::Div> {
1636        let AutoFrame {
1637            ref base_id,
1638            open,
1639            ref colors,
1640            ..
1641        } = *frame;
1642        // `.autocomplete__indicator` is `absolute inset-y-0 end-2 my-auto`, and
1643        // its glyph is `size-4`. HeroUI keeps one down-chevron in the tree and
1644        // rotates it over 150ms. Caller content still receives the live open
1645        // state and remains caller-owned.
1646        let trigger_indicator = match self.indicator.take() {
1647            Some(render) => render(open),
1648            None => crate::anim::rotating_indicator_with_duration(
1649                &element_id::scoped(base_id, "trigger-indicator"),
1650                open,
1651                gpui::svg()
1652                    .size(util::FIELD_ICON)
1653                    .path(icons::CHEVRON_DOWN)
1654                    .text_color(colors.field.placeholder),
1655                150,
1656                window,
1657                cx,
1658            ),
1659        };
1660        field = field.child(
1661            gpui::div()
1662                .absolute()
1663                .right(px(8.))
1664                .top_0()
1665                .bottom_0()
1666                .flex()
1667                .items_center()
1668                .justify_center()
1669                .text_color(colors.field.placeholder)
1670                .child(trigger_indicator),
1671        );
1672        field
1673    }
1674
1675    /// The trigger's press: toggles the popover and reports the change.
1676    fn trigger_toggle(
1677        &self,
1678        mut field: gpui::Stateful<gpui::Div>,
1679        frame: &AutoFrame,
1680    ) -> gpui::Stateful<gpui::Div> {
1681        let AutoFrame {
1682            open,
1683            ref open_own,
1684            ref focus_handle,
1685            can_open,
1686            toggle_allowed,
1687            ref trigger_pressed,
1688            ..
1689        } = *frame;
1690        // Clicking the trigger opens and closes the popover. The toggle is
1691        // the `useSelectState.toggle()` act: an empty collection without the
1692        // prop refuses it in *both* directions, and the refusal reports
1693        // nothing (the guard sits before `triggerState.toggle()`, so
1694        // `onOpenChange` never fires).
1695        if can_open {
1696            let own = open_own.clone();
1697            let cb = self.on_open_change.clone();
1698            let was_open = open;
1699            let pressed = trigger_pressed.clone();
1700            let may_toggle = toggle_allowed;
1701            field = field
1702                .capture_any_mouse_down(move |_, _, cx| {
1703                    pressed.set(true);
1704                    let pressed = pressed.clone();
1705                    cx.defer(move |_| pressed.set(false));
1706                })
1707                .when_some(focus_handle.as_ref(), |el, handle| el.track_focus(handle))
1708                .on_click(move |_, window, cx| {
1709                    if !may_toggle {
1710                        return;
1711                    }
1712                    if let Some(held) = &own {
1713                        held.update(cx, |v, cx| {
1714                            *v = !was_open;
1715                            cx.notify();
1716                        });
1717                    }
1718                    if let Some(cb) = &cb {
1719                        cb(&!was_open, window, cx);
1720                    }
1721                });
1722        }
1723        field
1724    }
1725
1726    /// The `.autocomplete` wrapper column and the root that scopes blur.
1727    fn field_root(&self, field: impl IntoElement, frame: &AutoFrame) -> gpui::Div {
1728        let AutoFrame {
1729            ref blur_scope,
1730            is_invalid,
1731            ..
1732        } = *frame;
1733        // --- the wrapper: `.autocomplete` is `flex flex-col gap-1` -----------
1734        let mut wrapper = gpui::div().flex().flex_col().gap(px(4.)).w_full();
1735        if let Some(label) = &self.label {
1736            wrapper = wrapper.child(
1737                crate::field::Label::new(label.clone())
1738                    .is_required(self.is_required)
1739                    .is_disabled(self.is_disabled)
1740                    .is_invalid(is_invalid),
1741            );
1742        }
1743        wrapper = wrapper.child(field);
1744        if is_invalid {
1745            if let Some(message) = &self.error_message {
1746                wrapper = wrapper.child(crate::field::ErrorMessage::new(message.clone()));
1747            }
1748        } else if let Some(desc) = &self.description {
1749            wrapper = wrapper.child(crate::field::Description::new(desc.clone()));
1750        }
1751
1752        let mut root = gpui::div().relative().child(wrapper);
1753        root = if self.full_width {
1754            root.w_full()
1755        } else {
1756            root.max_w(px(320.))
1757        };
1758        // The blur scope spans this one root, so a focus move between the
1759        // trigger and the search field inside the panel stays inside it.
1760        root = root.track_focus(blur_scope);
1761        root
1762    }
1763
1764    /// The root's key handler: the list walk while the search field holds
1765    /// the focus, and the closed trigger's Down/Up open.
1766    fn root_keys(&self, root: gpui::Div, frame: &AutoFrame) -> gpui::Div {
1767        let AutoFrame {
1768            multiple,
1769            ref selection_own,
1770            open,
1771            ref open_own,
1772            ref cursor,
1773            ref list_scroll_now,
1774            ref panel_scroll_now,
1775            ref query_edit,
1776            ref plain_edit_key,
1777            ref matches,
1778            toggle_allowed,
1779            ..
1780        } = *frame;
1781        let stops: Vec<usize> = (0..matches.len())
1782            .filter(|i| {
1783                matches
1784                    .get(*i)
1785                    .is_some_and(|item| !self.disabled_keys.contains(item.key()))
1786            })
1787            .collect();
1788        let held = cursor.clone();
1789        let key_query_edit = query_edit.clone();
1790        let key_plain_edit = plain_edit_key.clone();
1791        let wrap = self.should_focus_wrap;
1792        let virtual_rows = self.row_height.is_some();
1793        let page_row_height = self.row_height;
1794        let key_list_scroll = list_scroll_now.clone();
1795        let key_panel_scroll = panel_scroll_now.clone();
1796        let key_page_stops = stops.clone();
1797        let rows = matches.clone();
1798        let key_open_own = open_own.clone();
1799        let key_open_change = self.on_open_change.clone();
1800        let may_open = toggle_allowed;
1801        let on_change_all = self.on_selection_change_all.clone();
1802        let on_change_one = self.on_selection_change.clone();
1803        let key_selection_own = selection_own.clone();
1804        let key_form_state = self.form_state.clone();
1805        let selected_now = self.selected_keys.clone();
1806        let was_open = open;
1807        let handler = AutoKeys {
1808            stops,
1809            held,
1810            key_query_edit,
1811            key_plain_edit,
1812            wrap,
1813            virtual_rows,
1814            page_row_height,
1815            key_list_scroll,
1816            key_panel_scroll,
1817            key_page_stops,
1818            rows,
1819            key_open_own,
1820            key_open_change,
1821            may_open,
1822            on_change_all,
1823            on_change_one,
1824            key_selection_own,
1825            key_form_state,
1826            selected_now,
1827            was_open,
1828            multiple,
1829        };
1830        root.on_key_down(move |event, window, cx| handler.on_key_down(event, window, cx))
1831    }
1832
1833    /// Escape closes the popover and hands the focus back to the trigger.
1834    fn root_escape(&self, mut root: gpui::Div, frame: &AutoFrame) -> gpui::Div {
1835        let AutoFrame {
1836            ref open_own,
1837            ref dismissal_token,
1838            ref focus_handle,
1839            ..
1840        } = *frame;
1841        let escape_own = open_own.clone();
1842        let escape_cb = self.on_open_change.clone();
1843        let escape_focus = focus_handle.clone();
1844        root =
1845            util::dismiss_on_escape_with_token(root, dismissal_token.clone(), move |window, cx| {
1846                if let Some(held) = &escape_own {
1847                    held.update(cx, |v, cx| {
1848                        *v = false;
1849                        cx.notify();
1850                    });
1851                }
1852                if let Some(cb) = &escape_cb {
1853                    cb(&false, window, cx);
1854                }
1855                if let Some(handle) = &escape_focus {
1856                    window.focus(handle, cx);
1857                }
1858                util::DismissResult::Handled
1859            });
1860        root
1861    }
1862
1863    /// The popover's clipping surface: `bg-overlay`, the panel radius, the
1864    /// dark-mode hairline and the overlay shadow.
1865    fn panel_surface(&self, base: &str, radius: Pixels, frame: &AutoFrame) -> gpui::Div {
1866        let AutoFrame {
1867            ref colors,
1868            ref layout,
1869            ..
1870        } = *frame;
1871        let panel_selector = format!("{base}-panel");
1872        gpui::div()
1873                .w_full()
1874                .flex()
1875                .flex_col()
1876                // `.autocomplete__popover` is `p-0 pt-2`: the search field and
1877                // the list bring their own padding.
1878                .pt(px(8.))
1879                .bg(colors.overlay.background)
1880                .rounded(radius)
1881                // v3 gives a floating panel no border: `.popover` and friends are
1882                // `bg-overlay shadow-overlay` and a radius, and dark mode's
1883                // inset hairline is what separates the panel from the page.
1884                .when_some(layout.overlay_hairline, |el, hairline| {
1885                    el.border(layout.border_width).border_color(hairline)
1886                })
1887                .shadow(layout.overlay_shadow.clone())
1888                .debug_selector(move || panel_selector)
1889                // `.autocomplete__popover` is `overflow-hidden
1890                // overscroll-contain`: the panel clips while the inner list
1891                // owns the scrolling, and a wheel over it never reaches the
1892                // page behind. RAC caps the popover at the available viewport
1893                // height past a 12px inset; the positioner below re-lays the
1894                // panel out with that cap, so the panel carries a
1895                // viewport-relative bound rather than a fixed one.
1896                .max_h_full()
1897                .overflow_hidden()
1898                .occlude()
1899    }
1900
1901    /// The popover's search header: v3's `[data-slot="search-field"]`.
1902    fn search_row(&self, frame: &AutoFrame) -> gpui::Div {
1903        let AutoFrame {
1904            ref base,
1905            ref raw_query,
1906            ref query_edit,
1907            ref plain_edit_key,
1908            ..
1909        } = *frame;
1910        // The search field: v3's `[data-slot="search-field"]` inside the
1911        // popover is `shrink-0 px-3 py-1`, and `variant="secondary"` so it
1912        // reads as part of the panel rather than as a second field.
1913        let query_before_edit = raw_query.clone();
1914        let edit_query = query_edit.clone();
1915        let edit_key = plain_edit_key.clone();
1916        let input_change = self.on_input_change.clone();
1917        let search = SearchField::new(self.state.clone());
1918        let search = match self.font_family.clone() {
1919            Some(family) => search.font_family(family),
1920            None => search,
1921        };
1922        let search = search
1923            .variant(FieldVariant::Secondary)
1924            .placeholder("Search...")
1925            .is_read_only(self.is_read_only)
1926            .on_change(move |text, window, cx| {
1927                if text != query_before_edit {
1928                    let forward = *edit_key.read(cx);
1929                    edit_query.update(cx, |edit, cx| {
1930                        *edit = Some(forward);
1931                        cx.notify();
1932                    });
1933                }
1934                if let Some(cb) = &input_change {
1935                    cb(text, window, cx);
1936                }
1937            });
1938        gpui::div()
1939            .flex_shrink_0()
1940            .px(px(12.))
1941            .py(px(4.))
1942            .debug_selector({
1943                let base = base.clone();
1944                move || format!("{base}-search")
1945            })
1946            .child(search)
1947    }
1948
1949    /// The popover: surface, dismissal, search header, rows and motion.
1950    fn popover(&mut self, frame: AutoFrame, cx: &mut App) -> gpui::Deferred {
1951        // The entry zoom interpolates the panel's own radius, so one
1952        // binding feeds both the painted shape and the animation.
1953        let radius = self.radius.unwrap_or_else(|| util::container_radius(cx));
1954        let panel = self.panel_surface(&frame.base, radius, &frame);
1955        let search = self.search_row(&frame);
1956        let AutoFrame {
1957            base,
1958            base_id,
1959            multiple,
1960            selection_own,
1961            open_own,
1962            overlay_phase,
1963            dismissal_token,
1964            resolved_placement,
1965            entry_placement,
1966            focus_handle,
1967            list_scroll_now,
1968            panel_scroll_now,
1969            matches,
1970            cursor_at,
1971            anchor_bounds,
1972            colors,
1973            layout,
1974            trigger_pressed,
1975            ..
1976        } = frame;
1977        let overlay_exiting = overlay_phase == util::OverlayPhase::Exiting;
1978        // React Aria dismisses the popover on a press outside it; Escape is
1979        // read by the key handler above. A press that started on the
1980        // trigger (or its clear button) is not an outside press: the
1981        // trigger's own click owns the close, and the click only fires
1982        // because the down was not stolen as a dismissal.
1983        let dismiss_own = open_own.clone();
1984        let dismiss_cb = self.on_open_change.clone();
1985        let mut panel =
1986            util::dismiss_on_press_outside_with_token(panel, dismissal_token, move |window, cx| {
1987                if trigger_pressed.get() {
1988                    return util::DismissResult::Declined;
1989                }
1990                if let Some(held) = &dismiss_own {
1991                    held.update(cx, |v, cx| {
1992                        *v = false;
1993                        cx.notify();
1994                    });
1995                }
1996                if let Some(cb) = &dismiss_cb {
1997                    cb(&false, window, cx);
1998                }
1999                util::DismissResult::Handled
2000            });
2001
2002        panel = panel.child(search);
2003
2004        // Everything a row reads, owned: `uniform_list`'s callback is
2005        // `'static` and runs again on every scroll, so it cannot borrow
2006        // `self` or the theme -- and one row builder for both paths is what
2007        // keeps a virtual list drawing the same row as a short one.
2008        let matches_len = matches.len();
2009        // `useOption` adds `aria-posinset`/`aria-setsize` only
2010        // `if (isVirtualized)`; `row_height` is what windows this list.
2011        let row_virtualized = self.row_height.is_some();
2012        let rows = matches.clone();
2013        let sections = self.sections.clone();
2014        let row_disabled_keys = self.disabled_keys.clone();
2015        let row_selected_keys = self.selected_keys.clone();
2016        let indicator: Option<Rc<dyn Fn(bool) -> gpui::AnyElement>> =
2017            self.item_indicator.take().map(Rc::from);
2018        let on_change_all = self.on_selection_change_all.clone();
2019        let on_change_one = self.on_selection_change.clone();
2020        let row_selection_own = selection_own;
2021        let row_form_state = self.form_state.clone();
2022        let row_open_own = open_own;
2023        let row_open_change = self.on_open_change.clone();
2024        let row_trigger_focus = focus_handle;
2025        let base_row = format!("{base}-list");
2026        let base_row_id = element_id::scoped(&base_id, "list");
2027        let row_disabled_opacity = layout.disabled_opacity;
2028        // v3's `EmptyState` inside the popover is `text-center text-sm
2029        // text-overlay-foreground/60`. Copied out here because the row
2030        // builder below takes `cx` mutably, which ends the theme borrow.
2031        let mut empty_fg = colors.overlay.foreground;
2032        empty_fg.a *= 0.6;
2033        let row_padding_x = self.row_padding_x.unwrap_or(px(10.));
2034        let row_font_family = self.row_font_family.clone();
2035        let row_padding_y = self.row_padding_y.unwrap_or(px(6.));
2036        let rows = AutoRows {
2037            base_row,
2038            base_row_id,
2039            rows,
2040            sections,
2041            row_disabled_keys,
2042            row_selected_keys,
2043            indicator,
2044            on_change_all,
2045            on_change_one,
2046            row_selection_own,
2047            row_form_state,
2048            row_open_own,
2049            row_open_change,
2050            row_trigger_focus,
2051            colors,
2052            row_hover_bg: self.row_hover_bg,
2053            row_disabled_opacity,
2054            row_padding_x,
2055            row_font_family,
2056            row_padding_y,
2057            overlay_exiting,
2058            cursor_at,
2059            row_virtualized,
2060            matches_len,
2061            multiple,
2062        };
2063
2064        // The list: `[data-slot="list-box"]` inside the popover is
2065        // `max-h-[320px] min-h-0 p-1.5 overflow-y-auto`. The search header
2066        // above stays fixed (`shrink-0`) while this list shrinks with the
2067        // capped panel and owns the scrolling -- the outer panel only
2068        // clips (`overflow-hidden`).
2069        panel = panel.child(self.rows_list(
2070            rows,
2071            &base,
2072            &base_id,
2073            &list_scroll_now,
2074            &panel_scroll_now,
2075            cx,
2076        ));
2077
2078        if matches.is_empty() {
2079            panel = panel.child(
2080                gpui::div()
2081                        .w_full()
2082                        .px(px(12.))
2083                        .py(px(12.))
2084                        .text_center()
2085                        .text_size(util::FIELD_TEXT)
2086                        .line_height(px(20.))
2087                        .text_color(empty_fg)
2088                        // "No results found" in en-US.
2089                        .child(crate::i18n::ui_string(crate::i18n::UiString::NoResults, cx)),
2090            );
2091        }
2092
2093        let (slide_x, slide_y) = crate::popover::placement_entry_offset(entry_placement);
2094        let zoom = crate::anim::ZoomBox::panel(px(6.), radius);
2095        let zoom = crate::anim::ZoomBox {
2096            slide_x: (slide_x != 0.0).then(|| px(slide_x)),
2097            slide_y: (slide_y != 0.0).then(|| px(slide_y)),
2098            ..zoom
2099        };
2100        let panel = if overlay_phase == util::OverlayPhase::Exiting {
2101            crate::anim::exiting(
2102                panel,
2103                element_id::scoped(&base_id, "panel-out"),
2104                zoom,
2105                crate::anim::Motion::FLUID_OUT,
2106                cx,
2107            )
2108        } else {
2109            crate::anim::entering_zoom(
2110                panel,
2111                element_id::scoped(&base_id, "panel"),
2112                zoom,
2113                crate::anim::Motion::FLUID_IN,
2114                cx,
2115            )
2116        };
2117        // RAC positions the popover against the trigger with an 8px gap,
2118        // flips it when the other side has more room, and caps it at the
2119        // available viewport height past a 12px inset -- which
2120        // `scrollable_field_popover` reads from the measured trigger
2121        // bounds above.
2122        util::floating(
2123            crate::popover::scrollable_field_popover_with_resolved_placement(
2124                anchor_bounds,
2125                self.placement,
2126                Some(resolved_placement),
2127                panel,
2128            ),
2129        )
2130    }
2131
2132    /// The `[data-slot="list-box"]` rows: a windowed list under
2133    /// `row_height`, a scrolling column otherwise.
2134    fn rows_list(
2135        &self,
2136        rows: AutoRows,
2137        base: &str,
2138        base_id: &gpui::ElementId,
2139        list_scroll_now: &gpui::UniformListScrollHandle,
2140        panel_scroll_now: &gpui::ScrollHandle,
2141        cx: &mut App,
2142    ) -> gpui::AnyElement {
2143        let matches_len = rows.matches_len;
2144        match self.row_height {
2145            // Virtual: only the rows in view are built, which is what makes
2146            // a thousand options affordable. The list itself is the scroll
2147            // container: `Infer` sizes it from its rows -- the full
2148            // natural height on the positioner's measure pass (so the
2149            // flip sees the real extent, like upstream's `overlaySize`),
2150            // capped to the available height on the capped pass -- while
2151            // `max-h-[320px]` keeps the roomy-window height at the
2152            // upstream maximum and `min-h-0` lets it shrink with the
2153            // panel. A fixed inner height would strand rows outside a
2154            // capped panel.
2155            Some(row_height) => {
2156                let rows_selector = format!("{base}-rows");
2157                // The virtual half of the same `[data-slot="list-box"]`
2158                // the branch below draws. `gpui::uniform_list` returns a
2159                // `UniformList`, which is not a
2160                // `StatefulInteractiveElement` and so cannot carry a role
2161                // however many ids it has; the role goes on a wrapper that
2162                // adds no box of its own — `flex flex-col min-h-0` around
2163                // a `w-full` child lays out exactly as the child did.
2164                gpui::div()
2165                    .id(element_id::scoped(base_id, "list"))
2166                    .a11y(a11y::Role::ListBox)
2167                    .a11y_orientation(herogpui_core::Orientation::Vertical)
2168                    .flex()
2169                    .flex_col()
2170                    .w_full()
2171                    .min_h_0()
2172                    .child(
2173                        gpui::uniform_list(
2174                            element_id::scoped(base_id, "rows"),
2175                            matches_len,
2176                            move |range, _window, cx| {
2177                                range
2178                                    .map(|i| rows.row(i, Some(row_height), cx))
2179                                    .collect::<Vec<_>>()
2180                            },
2181                        )
2182                        .track_scroll(list_scroll_now)
2183                        .with_sizing_behavior(gpui::ListSizingBehavior::Infer)
2184                        .w_full()
2185                        .max_h(px(320.))
2186                        .min_h_0()
2187                        .p(px(6.))
2188                        .debug_selector(move || rows_selector),
2189                    )
2190                    .into_any_element()
2191            }
2192            None => {
2193                let list_selector = format!("{base}-list-scroll");
2194                let mut list = gpui::div()
2195                        .id(element_id::scoped(base_id, "list"))
2196                        // `[data-slot="list-box"]` is RAC's `ListBox`, i.e.
2197                        // `useListBox.mjs`'s literal `role: 'listbox'` with
2198                        // `'aria-orientation'` defaulting to vertical. The
2199                        // search field above it is outside the list upstream
2200                        // too, which is why the role is here and not on the
2201                        // popover panel.
2202                        .a11y(a11y::Role::ListBox)
2203                        .a11y_orientation(herogpui_core::Orientation::Vertical)
2204                        .debug_selector(move || list_selector)
2205                        .flex()
2206                        .flex_col()
2207                        .w_full()
2208                        .p(px(6.))
2209                        .max_h(px(320.))
2210                        .min_h_0()
2211                        .overflow_y_scroll()
2212                        .track_scroll(panel_scroll_now);
2213                for index in 0..matches_len {
2214                    list = list.child(rows.row(index, None, cx));
2215                }
2216                list.into_any_element()
2217            }
2218        }
2219    }
2220}
2221
2222/// The root's key handler, holding everything it reads. The handler is
2223/// `'static`, so it owns copies of the frame's state rather than borrowing
2224/// the Autocomplete.
2225struct AutoKeys {
2226    stops: Vec<usize>,
2227    held: Entity<Option<SharedString>>,
2228    key_query_edit: Entity<Option<bool>>,
2229    key_plain_edit: Entity<bool>,
2230    wrap: bool,
2231    virtual_rows: bool,
2232    page_row_height: Option<Pixels>,
2233    key_list_scroll: gpui::UniformListScrollHandle,
2234    key_panel_scroll: gpui::ScrollHandle,
2235    key_page_stops: Vec<usize>,
2236    rows: Rc<[PickerItem]>,
2237    key_open_own: Option<Entity<bool>>,
2238    key_open_change: Option<OnOpenChange>,
2239    may_open: bool,
2240    on_change_all: Option<OnSelectionChangeAll>,
2241    on_change_one: Option<OnSelectionChange>,
2242    key_selection_own: Option<Entity<Vec<SharedString>>>,
2243    key_form_state: AutocompleteFormState,
2244    selected_now: Vec<SharedString>,
2245    was_open: bool,
2246    multiple: bool,
2247}
2248
2249impl AutoKeys {
2250    fn on_key_down(&self, event: &gpui::KeyDownEvent, window: &mut Window, cx: &mut App) {
2251        let Self {
2252            ref stops,
2253            ref held,
2254            ref key_query_edit,
2255            ref key_plain_edit,
2256            wrap,
2257            page_row_height,
2258            ref key_list_scroll,
2259            ref key_panel_scroll,
2260            ref key_page_stops,
2261            ref rows,
2262            ref key_open_own,
2263            ref key_open_change,
2264            may_open,
2265            was_open,
2266            ..
2267        } = *self;
2268        let key = event.keystroke.key.as_str();
2269        let modifiers = event.keystroke.modifiers;
2270        let mut chars = key.chars();
2271        let plain_insert = was_open
2272            && (key == "space"
2273                || matches!(
2274                    (chars.next(), chars.next()),
2275                    (Some(ch), None) if !ch.is_control()
2276                ))
2277            && !modifiers.control
2278            && !modifiers.alt
2279            && !modifiers.platform
2280            && !modifiers.function;
2281        key_plain_edit.update(cx, |v, cx| {
2282            if *v != plain_insert {
2283                *v = plain_insert;
2284                cx.notify();
2285            }
2286        });
2287        if plain_insert {
2288            key_query_edit.update(cx, |edit, cx| {
2289                if edit.is_some() {
2290                    *edit = Some(true);
2291                    cx.notify();
2292                }
2293            });
2294        }
2295        if !was_open {
2296            // Closed: Down and Up open it. Enter and Space are *not*
2297            // handled here -- the trigger has a click listener and gpui
2298            // fires those for a focused element, so answering them again
2299            // would open and close the popover in one keystroke.
2300            if matches!(key, "down" | "up") {
2301                // The keyboard open is the same
2302                // `useSelectState.open()` act: an empty collection
2303                // without `allowsEmptyCollection` refuses it and
2304                // reports nothing.
2305                if !may_open {
2306                    return;
2307                }
2308                if let Some(held) = &key_open_own {
2309                    held.update(cx, |v, cx| {
2310                        *v = true;
2311                        cx.notify();
2312                    });
2313                }
2314                if let Some(cb) = &key_open_change {
2315                    cb(&true, window, cx);
2316                }
2317            }
2318            return;
2319        }
2320        // The focused search field owns inserted characters. In
2321        // particular, the shared list navigator treats Space as an
2322        // activation key, but Autocomplete must insert it into the
2323        // query rather than select the current virtual row.
2324        if plain_insert || key == "space" {
2325            return;
2326        }
2327        // The held cursor is the focused item's key; resolve it to the
2328        // row it occupies in the filtered collection now.
2329        let from = held
2330            .read(cx)
2331            .as_ref()
2332            .and_then(|k| rows.iter().position(|it| it.key() == k));
2333        // Pinned React Aria 3.51.0 binds PageUp/PageDown through the
2334        // listbox's `useSelectableCollection`, which the closed branch
2335        // above never reaches. Those handlers require
2336        // `manager.focusedKey != null` -- a mouse-opened, selection-less
2337        // Autocomplete has a null cursor and must answer nothing until
2338        // an arrow establishes one.
2339        //
2340        // With a cursor this popup pages by viewport, unlike the
2341        // Select/ComboBox/Dropdown popups: `autocomplete.css` styles
2342        // the composed `[data-slot="list-box"]` itself
2343        // `max-h-[320px] min-h-0 overflow-y-auto`, so the list element
2344        // is its own scroller and pinned `ListKeyboardDelegate` walks
2345        // enabled rows from the cursor until one crosses a
2346        // one-viewport boundary, taking the enabled end only when the
2347        // walk runs out. The default rows are laid out, so the
2348        // boundary reads real `ScrollHandle` rects (the plain ListBox
2349        // shape); a `row_height` list is uniform and pages by
2350        // whole-row steps across its *actual* laid-out viewport: the
2351        // panel caps together with the positioner, so the 320px
2352        // upstream maximum is only the roomy-window height, never the
2353        // paging ruler. The step reads the virtual list's own
2354        // `UniformListScrollHandle` viewport bounds -- the pinned
2355        // handle's `base_handle.bounds()` -- so a capped panel pages
2356        // by what it shows.
2357        let page_target = |from: usize| -> Option<usize> {
2358            if let Some(row_height) = page_row_height {
2359                let viewport_height =
2360                    f32::from(key_list_scroll.0.borrow().base_handle.bounds().size.height);
2361                if viewport_height <= 0. {
2362                    return None;
2363                }
2364                let step =
2365                    ((viewport_height / f32::from(row_height)).ceil() as usize).saturating_sub(1);
2366                let boundary = match key {
2367                    "pagedown" => (from + step).min(rows.len().saturating_sub(1)),
2368                    "pageup" => from.saturating_sub(step),
2369                    _ => return None,
2370                };
2371                return match key {
2372                    "pagedown" => key_page_stops
2373                        .iter()
2374                        .copied()
2375                        .find(|stop| *stop >= boundary)
2376                        .or_else(|| key_page_stops.last().copied()),
2377                    "pageup" => key_page_stops
2378                        .iter()
2379                        .rev()
2380                        .copied()
2381                        .find(|stop| *stop <= boundary)
2382                        .or_else(|| key_page_stops.first().copied()),
2383                    _ => None,
2384                };
2385            }
2386            let current = key_panel_scroll.bounds_for_item(from)?;
2387            let viewport_height = key_panel_scroll.bounds().size.height;
2388            let target = match key {
2389                "pagedown" => current.top() - current.size.height + viewport_height,
2390                "pageup" => current.top() + current.size.height - viewport_height,
2391                _ => return None,
2392            };
2393            match key {
2394                "pagedown" => key_page_stops
2395                    .iter()
2396                    .copied()
2397                    .filter(|stop| *stop >= from)
2398                    .find(|stop| {
2399                        key_panel_scroll
2400                            .bounds_for_item(*stop)
2401                            .is_some_and(|bounds| bounds.top() >= target)
2402                    })
2403                    .or_else(|| key_page_stops.last().copied()),
2404                "pageup" => key_page_stops
2405                    .iter()
2406                    .rev()
2407                    .copied()
2408                    .filter(|stop| *stop <= from)
2409                    .find(|stop| {
2410                        key_panel_scroll
2411                            .bounds_for_item(*stop)
2412                            .is_some_and(|bounds| bounds.top() <= target)
2413                    })
2414                    .or_else(|| key_page_stops.first().copied()),
2415                _ => None,
2416            }
2417        };
2418        let page_move = from.and_then(page_target);
2419        let page_move = page_move.filter(|next| Some(*next) != from);
2420        let next_move = page_move.map_or_else(
2421            || crate::list_nav::resolve(stops, from, key, wrap),
2422            crate::list_nav::Move::To,
2423        );
2424        self.apply_move(next_move, from, window, cx);
2425    }
2426
2427    /// The open list's answer to a resolved move: walk the cursor, or take
2428    /// the cursor row.
2429    fn apply_move(
2430        &self,
2431        next_move: crate::list_nav::Move,
2432        from: Option<usize>,
2433        window: &mut Window,
2434        cx: &mut App,
2435    ) {
2436        let Self {
2437            ref held,
2438            virtual_rows,
2439            ref key_list_scroll,
2440            ref key_panel_scroll,
2441            ref rows,
2442            ref key_open_own,
2443            ref key_open_change,
2444            ref on_change_all,
2445            ref on_change_one,
2446            ref key_selection_own,
2447            ref key_form_state,
2448            ref selected_now,
2449            multiple,
2450            ..
2451        } = *self;
2452        match next_move {
2453            crate::list_nav::Move::To(next) => {
2454                let next_key = rows.get(next).map(|item| item.key().clone());
2455                held.update(cx, |v, cx| {
2456                    *v = next_key;
2457                    cx.notify();
2458                });
2459                if virtual_rows {
2460                    key_list_scroll.scroll_to_item(next, gpui::ScrollStrategy::Center);
2461                } else {
2462                    key_panel_scroll.scroll_to_item(next);
2463                }
2464            }
2465            crate::list_nav::Move::Activate => {
2466                let Some(item) = from.and_then(|i| rows.get(i)) else {
2467                    return;
2468                };
2469                let item_key = item.key().clone();
2470                let mut next = selected_now.clone();
2471                if multiple {
2472                    toggle_key(&mut next, &item_key);
2473                } else {
2474                    next.clear();
2475                    next.push(item_key.clone());
2476                }
2477                if let Some(own) = &key_selection_own {
2478                    let set = next.clone();
2479                    own.update(cx, |v, cx| {
2480                        *v = set;
2481                        cx.notify();
2482                    });
2483                    key_form_state.borrow_mut().value = form_selection_value(&next);
2484                }
2485                if let Some(cb) = &on_change_one {
2486                    cb(&item_key, window, cx);
2487                }
2488                if let Some(cb) = &on_change_all {
2489                    cb(&next, window, cx);
2490                }
2491                // A single selection closes the popover, as v3's does;
2492                // a multiple one stays open for the next pick.
2493                if !multiple {
2494                    if let Some(own) = &key_open_own {
2495                        own.update(cx, |v, cx| {
2496                            *v = false;
2497                            cx.notify();
2498                        });
2499                    }
2500                    if let Some(cb) = &key_open_change {
2501                        cb(&false, window, cx);
2502                    }
2503                    // The focus is *not* moved back to the trigger here.
2504                    // gpui activates a focused element on Enter, so
2505                    // focusing the trigger inside this very keystroke
2506                    // fires its click listener and the popover reopens --
2507                    // observed, not theorised.
2508                }
2509            }
2510            crate::list_nav::Move::Ignore => {}
2511        }
2512    }
2513}
2514
2515/// Everything an option row reads, owned: `uniform_list`'s callback is
2516/// `'static` and runs again on every scroll, so it cannot borrow the
2517/// Autocomplete or the theme -- and one row builder for both paths is what
2518/// keeps a virtual list drawing the same row as a short one.
2519struct AutoRows {
2520    base_row: String,
2521    base_row_id: gpui::ElementId,
2522    rows: Rc<[PickerItem]>,
2523    sections: Vec<(SharedString, SharedString)>,
2524    row_disabled_keys: std::collections::HashSet<SharedString>,
2525    row_selected_keys: Vec<SharedString>,
2526    indicator: Option<Rc<dyn Fn(bool) -> gpui::AnyElement>>,
2527    on_change_all: Option<OnSelectionChangeAll>,
2528    on_change_one: Option<OnSelectionChange>,
2529    row_selection_own: Option<Entity<Vec<SharedString>>>,
2530    row_form_state: AutocompleteFormState,
2531    row_open_own: Option<Entity<bool>>,
2532    row_open_change: Option<OnOpenChange>,
2533    row_trigger_focus: Option<gpui::FocusHandle>,
2534    colors: herogpui_theme::ThemeColors,
2535    row_hover_bg: Option<gpui::Hsla>,
2536    row_disabled_opacity: f32,
2537    row_padding_x: Pixels,
2538    row_font_family: Option<SharedString>,
2539    row_padding_y: Pixels,
2540    overlay_exiting: bool,
2541    cursor_at: Option<usize>,
2542    row_virtualized: bool,
2543    matches_len: usize,
2544    multiple: bool,
2545}
2546
2547impl AutoRows {
2548    /// One option row, with its section header when one precedes it.
2549    fn row(&self, index: usize, fixed_h: Option<Pixels>, cx: &mut App) -> gpui::AnyElement {
2550        let Self {
2551            ref base_row,
2552            ref base_row_id,
2553            ref rows,
2554            ref sections,
2555            ref row_disabled_keys,
2556            ref row_selected_keys,
2557            ref indicator,
2558            ref colors,
2559            row_disabled_opacity,
2560            row_padding_x,
2561            ref row_font_family,
2562            row_padding_y,
2563            overlay_exiting,
2564            cursor_at,
2565            row_virtualized,
2566            matches_len,
2567            ..
2568        } = *self;
2569        let row_muted = colors.muted;
2570        let row_fg = colors.foreground;
2571        let row_hover_bg = self.row_hover_bg.unwrap_or(colors.default.color);
2572        let row_focus = colors.focus;
2573        let row_accent = colors.accent.color;
2574        let base = base_row.as_str();
2575        let base_id = &base_row_id;
2576        let item = &rows[index];
2577        // A section header rides above the row it introduces, so the two
2578        // are one element -- a virtual row is one slot tall.
2579        let mut head: Vec<gpui::AnyElement> = Vec::new();
2580        let done = |head: Vec<gpui::AnyElement>, row: gpui::AnyElement| {
2581            gpui::div()
2582                .flex()
2583                .flex_col()
2584                .when_some(fixed_h, |el, h| el.h(h).w_full())
2585                .children(head)
2586                .child(row)
2587                .into_any_element()
2588        };
2589        // `ListBox.Section`'s `Header`, above the item it introduces.
2590        if let Some((_, label)) = sections.iter().find(|(at, _)| at == item.key()) {
2591            head.push(
2592                gpui::div()
2593                    .px(px(8.))
2594                    .pt(px(6.))
2595                    .pb(px(4.))
2596                    .text_size(px(12.))
2597                    .line_height(px(16.))
2598                    .font_weight(gpui::FontWeight::MEDIUM)
2599                    .text_color(row_muted)
2600                    .child(label.to_string())
2601                    .into_any_element(),
2602            );
2603        }
2604        // The row's element id comes from the item's key, so two items
2605        // that share a label never share an interactive row.
2606        let item_disabled = row_disabled_keys.contains(item.key());
2607        let item_interactive = !item_disabled && !overlay_exiting;
2608        let row_selected = row_selected_keys.contains(item.key());
2609        let has_indicator_slot = indicator.is_some() || row_selected;
2610        let row_selector = format!("{base}-{}", item.key());
2611        let mut row = gpui::div()
2612                    .id(element_id::scoped(
2613                        &element_id::scoped(base_id, "opt"),
2614                        item.key().clone(),
2615                    ))
2616                    // `useOption.mjs`: `role: 'option'` plus `'aria-selected'`
2617                    // whenever the list selects at all. The search field keeps
2618                    // the real focus while a cursor walks the rows, which is
2619                    // upstream's virtual focus; gpui states that relation on
2620                    // the descendant, so the row can carry it even though this
2621                    // component does not own the field.
2622                    .a11y_named(
2623                        a11y::Role::ListBoxOption,
2624                        &a11y::Name::labelled(item.label().clone()),
2625                    )
2626                    .a11y_selected(row_selected)
2627                    .when(cursor_at == Some(index), |row| row.a11y_active_descendant())
2628                    .when(row_virtualized, |row| {
2629                        row.a11y_set_position(index, matches_len)
2630                    })
2631                    .debug_selector(move || row_selector)
2632                    .flex()
2633                    .items_center()
2634                    .justify_between()
2635                    .w_full()
2636                    // Every menu row in v3 is a `.list-box-item`: `min-h-9
2637                    // rounded-2xl py-1.5 gap-3` at `text-sm`, and the
2638                    // Autocomplete's popover restates the padding as `px-2.5`.
2639                    .min_h(util::FIELD_HEIGHT)
2640                    .rounded(util::soft_radius(cx))
2641                    .px(row_padding_x)
2642                    .py(row_padding_y)
2643                    .gap(px(12.))
2644                    .text_size(util::FIELD_TEXT)
2645                    .line_height(px(20.))
2646                    // HeroUI's list-box item reserves `pe-7` whenever its
2647                    // indicator slot is present; the indicator itself is
2648                    // absolute at the inline end. Keeping it out of flex
2649                    // flow prevents long labels from pushing the checkmark.
2650                    .relative()
2651                    .when(has_indicator_slot, |row| row.pr(px(28.)));
2652        if let Some(family) = row_font_family.clone() {
2653            row = row.font_family(family);
2654        }
2655
2656        if item_disabled {
2657            row = row.opacity(row_disabled_opacity);
2658        } else if item_interactive {
2659            row = row
2660                .cursor(util::interactive_cursor(cx))
2661                .hover(move |s| s.bg(row_hover_bg));
2662        }
2663        if row_selected {
2664            row = row.text_color(row_accent);
2665        } else {
2666            row = row.text_color(row_fg);
2667        }
2668        // `status-focused` on the row the keyboard is on.
2669        if util::shows_focus_ring(cursor_at == Some(index), cx) {
2670            row = row.border_2().border_color(row_focus);
2671        }
2672
2673        // HeroUI's ListBox.Item does not add an ellipsis rule. Keep
2674        // normal text flow in both natural and virtual rows; the
2675        // caller owns the fixed row geometry when `row_height` is
2676        // supplied, just as the upstream Virtualizer owns its
2677        // `rowHeight` layout.
2678        let label = gpui::div().flex_1().min_w_0().whitespace_normal();
2679        row = row.child(label.child(item.label().to_string()));
2680
2681        // The chosen rows are ticked, unless `ListBox.ItemIndicator` is
2682        // drawn by the caller.
2683        match &indicator {
2684            Some(render) => {
2685                row = row.child(
2686                    gpui::div()
2687                        .absolute()
2688                        .top_0()
2689                        .bottom_0()
2690                        .right(px(8.))
2691                        .w(px(16.))
2692                        .flex()
2693                        .items_center()
2694                        .justify_center()
2695                        .child(render(row_selected)),
2696                );
2697            }
2698            None if row_selected => {
2699                row = row.child(
2700                    gpui::div()
2701                        .absolute()
2702                        .top_0()
2703                        .bottom_0()
2704                        .right(px(8.))
2705                        .w(px(16.))
2706                        .flex()
2707                        .items_center()
2708                        .justify_center()
2709                        .child(
2710                            gpui::svg()
2711                                .size(px(13.))
2712                                .path(icons::CHECK)
2713                                .text_color(row_accent),
2714                        ),
2715                );
2716            }
2717            None => {}
2718        }
2719
2720        if item_interactive {
2721            row = self.attach_pick(row, item.key().clone());
2722        }
2723
2724        done(head, row.into_any_element())
2725    }
2726
2727    /// The row's pointer pick: a toggle in multiple mode, a select-and-close
2728    /// that hands the focus back to the trigger in single mode.
2729    fn attach_pick(
2730        &self,
2731        mut row: gpui::Stateful<gpui::Div>,
2732        value: SharedString,
2733    ) -> gpui::Stateful<gpui::Div> {
2734        let Self {
2735            ref row_selected_keys,
2736            ref row_selection_own,
2737            ref row_form_state,
2738            ref on_change_all,
2739            ref on_change_one,
2740            ref row_open_own,
2741            ref row_open_change,
2742            ref row_trigger_focus,
2743            multiple,
2744            ..
2745        } = *self;
2746        let current = row_selected_keys.clone();
2747        let own = row_selection_own.clone();
2748        let row_form_state = row_form_state.clone();
2749        let cb_all = on_change_all.clone();
2750        let cb_one = on_change_one.clone();
2751        let open_own = row_open_own.clone();
2752        let open_cb = row_open_change.clone();
2753        let trigger_focus = row_trigger_focus.clone();
2754        row = row.on_click(move |_, window, cx| {
2755            let mut next = current.clone();
2756            if multiple {
2757                toggle_key(&mut next, &value);
2758            } else {
2759                next.clear();
2760                next.push(value.clone());
2761            }
2762            // Uncontrolled: keep the new set, or picking an item
2763            // would do nothing.
2764            if let Some(held) = &own {
2765                let set = next.clone();
2766                held.update(cx, |v, cx| {
2767                    *v = set;
2768                    cx.notify();
2769                });
2770                row_form_state.borrow_mut().value = form_selection_value(&next);
2771            }
2772            if let Some(cb) = &cb_one {
2773                cb(&value, window, cx);
2774            }
2775            if let Some(cb) = &cb_all {
2776                cb(&next, window, cx);
2777            }
2778            // A single selection closes the popover; a multiple one
2779            // stays open for the next pick.
2780            if !multiple {
2781                if let Some(held) = &open_own {
2782                    held.update(cx, |v, cx| {
2783                        *v = false;
2784                        cx.notify();
2785                    });
2786                }
2787                if let Some(cb) = &open_cb {
2788                    cb(&false, window, cx);
2789                }
2790                if let Some(handle) = &trigger_focus {
2791                    window.focus(handle, cx);
2792                }
2793            }
2794        });
2795        row
2796    }
2797}
2798
2799// The pinned `.autocomplete--secondary` hover fill is
2800// `--autocomplete-trigger-bg-hover: var(--default-hover)` and the popup rows
2801// fill with the full `bg-default`. The two accessors differ by one word and
2802// the wrong one still looks plausible on screen, so the check is mechanical.
2803#[cfg(test)]
2804mod hover_tokens {
2805    #[test]
2806    fn secondary_trigger_and_menu_rows_use_the_pinned_hover_tokens() {
2807        // Scan the implementation only.
2808        let source = include_str!("autocomplete.rs")
2809            .split("#[cfg(test)]")
2810            .next()
2811            .expect("the implementation section is always present");
2812        assert!(
2813            source.contains("FieldVariant::Secondary => colors.default.hover()"),
2814            "the secondary trigger hover must read `colors.default.hover()` \
2815             (pinned `--autocomplete-trigger-bg-hover: var(--default-hover)`)"
2816        );
2817        assert!(
2818            source
2819                .contains("let row_hover_bg = self.row_hover_bg.unwrap_or(colors.default.color);"),
2820            "the popup rows must hover the full `bg-default` \
2821             (pinned `.list-box-item:hover`)"
2822        );
2823    }
2824
2825    #[test]
2826    fn the_clear_button_hovers_the_role_hover_token() {
2827        // Scan the implementation only; this test's own text names the
2828        // forbidden accessor.
2829        let source = include_str!("autocomplete.rs")
2830            .split("#[cfg(test)]")
2831            .next()
2832            .expect("the implementation section is always present");
2833        assert!(
2834            source
2835                .contains("let hover_bg = self.clear_hover_bg.unwrap_or(colors.default.hover());"),
2836            "the clear button must hover `bg-default-hover` \
2837             (pinned `.autocomplete__clear-button:hover`)"
2838        );
2839        assert!(
2840            !source.contains("colors.default.soft_hover()"),
2841            "the clear button must not hover the lighter soft-hover wash"
2842        );
2843    }
2844
2845    #[test]
2846    fn the_trigger_hover_defers_to_a_hovered_clear_button() {
2847        // Painted-only claim, so it is pinned by source shape: headless tests
2848        // prove the wiring (real coordinates flip the keyed flag) but cannot
2849        // read the painted quad. The refinement must leave the trigger's
2850        // resting style untouched while the clear button is hovered, which is
2851        // what `:not(:has(.autocomplete__clear-button:hover))` does upstream.
2852        let source = include_str!("autocomplete.rs")
2853            .split("#[cfg(test)]")
2854            .next()
2855            .expect("the implementation section is always present");
2856        assert!(
2857            source.contains("hover_fade_with_duration_and_easing_suppressed")
2858                && source.contains("clear_hovered,"),
2859            "the trigger hover must be suppressed while the clear button is hovered \
2860             (pinned `.autocomplete__trigger:hover:not(\
2861             :has(.autocomplete__clear-button:hover))`)"
2862        );
2863    }
2864
2865    #[test]
2866    fn trigger_hover_uses_the_pinned_smooth_transition() {
2867        let source = include_str!("autocomplete.rs")
2868            .split("#[cfg(test)]")
2869            .next()
2870            .expect("the implementation section is always present");
2871        assert!(
2872            source.contains("Some(150)")
2873                && source.contains("HoverFadeEasing::EaseSmooth")
2874                && source.contains("colors.field.border_hover()"),
2875            "the trigger hover must animate its background while retaining the border endpoint"
2876        );
2877    }
2878
2879    #[test]
2880    fn clear_button_visibility_fades_in_on_a_listener_free_visual_child() {
2881        let source = include_str!("autocomplete.rs")
2882            .split("#[cfg(test)]")
2883            .next()
2884            .expect("the implementation section is always present");
2885        assert!(
2886            source.contains("const CLEAR_OPACITY_TRANSITION_MS: u64 = 150;"),
2887            "the clear button must retain HeroUI's 150ms visibility duration"
2888        );
2889        assert!(
2890            source.contains("\"clear-opacity\"")
2891                && source.contains("clear_opacity.animates(reduce_motion)"),
2892            "clear visibility must use keyed reduced-motion-aware state"
2893        );
2894        assert!(
2895            source.contains("clear_opacity.settle();") && source.contains(".with_animation("),
2896            "clearing must hide immediately while appearance animates on the visual child"
2897        );
2898    }
2899}
2900
2901crate::util::impl_component_styled!(Autocomplete);