Skip to main content

gpui_component/command/
state.rs

1use gpui_base::TestSupportExt as _;
2use std::rc::Rc;
3
4use gpui::{
5    AbsoluteLength, AnyElement, App, AppContext as _, AvailableSpace, Context, Entity, FocusHandle,
6    Focusable, FontFallbacks, FontFeatures, FontStyle, FontWeight, InteractiveElement, IntoElement,
7    KeyBinding, Keystroke, ListSizingBehavior, ParentElement, Pixels, Render, Role, ScrollStrategy,
8    SharedString, Size, StatefulInteractiveElement as _, StyleRefinement, Styled, Subscription,
9    TextOverflow, WhiteSpace, Window, div, prelude::FluentBuilder as _, px, size,
10};
11use rust_i18n::t;
12
13use crate::{
14    ActiveTheme as _, ElementExt as _, Icon, IconName, IndexPath, StyledExt as _,
15    VirtualListScrollHandle,
16    actions::{Cancel, Confirm, SelectDown, SelectUp},
17    command::{
18        command::CommandOptions,
19        item::{CommandEntry, CommandItem},
20    },
21    h_flex,
22    input::{Input, InputEvent, InputState},
23    kbd::Kbd,
24    scroll::Scrollbar,
25    v_flex, v_virtual_list,
26};
27
28pub(crate) const CONTEXT: &str = "Command";
29
30/// The row a separator occupies: a one-pixel rule with a little air on
31/// either side. Fixed, so that only the item and heading rows need measuring.
32const SEPARATOR_ROW_HEIGHT: f32 = 9.;
33
34pub(crate) type OnQuery = dyn Fn(&str, &mut Window, &mut App);
35pub(crate) type OnIndex = dyn Fn(IndexPath, &mut Window, &mut App);
36pub(crate) type OnCancel = dyn Fn(&mut Window, &mut App);
37
38pub(crate) struct CommandModel {
39    pub(crate) entries: Vec<CommandEntry>,
40    pub(crate) searchable: bool,
41    pub(crate) filterable: bool,
42    pub(crate) on_query: Option<Rc<OnQuery>>,
43    pub(crate) on_select: Option<Rc<OnIndex>>,
44    pub(crate) on_confirm: Option<Rc<OnIndex>>,
45    pub(crate) on_cancel: Option<Rc<OnCancel>>,
46}
47
48impl Default for CommandModel {
49    fn default() -> Self {
50        Self {
51            entries: Vec::new(),
52            searchable: true,
53            filterable: true,
54            on_query: None,
55            on_select: None,
56            on_confirm: None,
57            on_cancel: None,
58        }
59    }
60}
61
62pub(crate) fn init(cx: &mut App) {
63    let context: Option<&str> = Some(CONTEXT);
64    cx.bind_keys([
65        KeyBinding::new("escape", Cancel, context),
66        KeyBinding::new("enter", Confirm { secondary: false }, context),
67        KeyBinding::new("up", SelectUp, context),
68        KeyBinding::new("down", SelectDown, context),
69    ]);
70}
71
72/// One rendered line of the list.
73///
74/// Groups are flattened into headings and items so the list is a single
75/// sequence of rows, which is what the virtual list scrolls over.
76#[derive(Clone, PartialEq)]
77enum CommandRow {
78    Heading(SharedString),
79    /// Holds the index into [`CommandState::matched`].
80    Item(usize),
81    Separator,
82}
83
84#[derive(Clone, PartialEq)]
85struct TextShapeKey {
86    font_family: SharedString,
87    font_features: FontFeatures,
88    font_fallbacks: Option<FontFallbacks>,
89    font_size: AbsoluteLength,
90    font_weight: FontWeight,
91    font_style: FontStyle,
92    white_space: WhiteSpace,
93    text_overflow: Option<TextOverflow>,
94    line_clamp: Option<usize>,
95}
96
97#[derive(Clone, PartialEq)]
98struct ListMeasurementKey {
99    content_width: Pixels,
100    rem_size: Pixels,
101    line_height: Pixels,
102    text_shape: TextShapeKey,
103}
104
105/// An item that survived the current query, and where it landed.
106#[derive(Clone)]
107struct MatchedItem {
108    entry_ix: usize,
109    item_ix: usize,
110    index_path: IndexPath,
111    row_ix: usize,
112    disabled: bool,
113}
114
115/// The interaction state of a [`crate::command::Command`] palette: its query,
116/// focus, scrolling, and highlighted command.
117pub struct CommandState {
118    focus_handle: FocusHandle,
119    query_input: Entity<InputState>,
120    scroll_handle: VirtualListScrollHandle,
121    model: CommandModel,
122    rows: Vec<CommandRow>,
123    row_sizes: Rc<Vec<Size<Pixels>>>,
124    list_measurement_key: Option<ListMeasurementKey>,
125    needs_measure: bool,
126    /// The keybinding hint each matched item showed when the rows were last
127    /// measured. Hints are looked up on every render, so a keymap change can
128    /// resize a row while the model stays the same.
129    measured_bindings: Vec<Option<Keystroke>>,
130    matched: Vec<MatchedItem>,
131    selected_index: Option<usize>,
132    preserve_no_selection: bool,
133    loading: bool,
134    pending_scroll: Option<usize>,
135    /// The placeholder last written to the query input, so that `render` only
136    /// writes when it changed — `set_placeholder` notifies, and an
137    /// unconditional notify from `render` would redraw every frame.
138    applied_placeholder: SharedString,
139    applied_query: SharedString,
140    pub(crate) options: CommandOptions,
141    _subscriptions: Vec<Subscription>,
142}
143
144impl CommandState {
145    /// Create an empty palette.
146    pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
147        let query_input = cx.new(|cx| InputState::new(window, cx));
148
149        let _subscriptions =
150            vec![cx.subscribe_in(&query_input, window, Self::on_query_input_event)];
151
152        Self {
153            focus_handle: cx.focus_handle(),
154            query_input,
155            scroll_handle: VirtualListScrollHandle::new(),
156            model: CommandModel::default(),
157            rows: Vec::new(),
158            row_sizes: Rc::new(Vec::new()),
159            list_measurement_key: None,
160            needs_measure: true,
161            measured_bindings: Vec::new(),
162            matched: Vec::new(),
163            selected_index: None,
164            preserve_no_selection: false,
165            loading: false,
166            pending_scroll: None,
167            applied_placeholder: SharedString::default(),
168            applied_query: SharedString::default(),
169            options: CommandOptions::default(),
170            _subscriptions,
171        }
172    }
173
174    /// Apply the presentation of the latest [`crate::command::Command`] render.
175    pub(crate) fn set_options(&mut self, options: CommandOptions) {
176        if self.options.style.text != options.style.text {
177            self.needs_measure = true;
178        }
179        self.options = options;
180    }
181
182    pub(crate) fn install_model(&mut self, model: CommandModel, cx: &mut Context<Self>) {
183        let selected_index_path = self.selected_index();
184        // The model reinstalls on every host re-render, usually unchanged.
185        // Keep the rows and their measured sizes then: measuring lays out
186        // every row, not only the visible ones.
187        let same_layout = self.model.searchable == model.searchable
188            && self.model.filterable == model.filterable
189            && self.model.entries.len() == model.entries.len()
190            && self
191                .model
192                .entries
193                .iter()
194                .zip(&model.entries)
195                .all(|(entry, other)| entry.same_layout(other));
196        self.model = model;
197        if !same_layout {
198            self.update_matches(cx);
199        }
200
201        let preserved_selection = selected_index_path.and_then(|selected_index_path| {
202            self.matched
203                .iter()
204                .enumerate()
205                .find_map(|(matched_ix, matched)| {
206                    (!matched.disabled && matched.index_path == selected_index_path)
207                        .then_some(matched_ix)
208                })
209        });
210
211        if let Some(matched_ix) = preserved_selection {
212            // Preserving the selection is not a navigation: the model
213            // reinstalls on every host re-render, so scrolling here would
214            // move the list one frame after a hover selection.
215            self.selected_index = Some(matched_ix);
216            self.preserve_no_selection = false;
217        } else if self.preserve_no_selection {
218            self.selected_index = None;
219            self.pending_scroll = None;
220        } else {
221            self.reset_selection();
222        }
223    }
224
225    /// The current search query.
226    pub fn query(&self, cx: &App) -> SharedString {
227        self.query_input.read(cx).value()
228    }
229
230    /// Replace the search query, as if it had been typed.
231    ///
232    /// The input suppresses its own change event for a programmatic write, so
233    /// the re-filter and query callback happen here instead.
234    pub fn set_query(
235        &mut self,
236        query: impl Into<SharedString>,
237        window: &mut Window,
238        cx: &mut Context<Self>,
239    ) {
240        let query = query.into();
241        if self.query(cx) == query {
242            return;
243        }
244
245        self.query_input
246            .update(cx, |input, cx| input.set_value(query, window, cx));
247        self.on_query_changed(window, cx);
248    }
249
250    /// The highlighted item's path in the model installed by the latest
251    /// [`crate::command::Command`] render, before local filtering.
252    ///
253    /// Ungrouped items occupy section 0 and use their input position as the
254    /// row. Explicit groups use their group and item positions; when a model
255    /// mixes both forms, the implicit ungrouped section comes first.
256    pub fn selected_index(&self) -> Option<IndexPath> {
257        self.selected_index
258            .and_then(|selected_index| self.matched.get(selected_index))
259            .filter(|matched| !matched.disabled)
260            .map(|matched| matched.index_path)
261    }
262
263    /// Highlight an item by its original, unfiltered model path, or clear the
264    /// highlight with `None`.
265    ///
266    /// A path that is currently filtered out or disabled clears the
267    /// highlight. A visible selection is scrolled into view.
268    pub fn set_selected_index(
269        &mut self,
270        index: Option<IndexPath>,
271        window: &mut Window,
272        cx: &mut Context<Self>,
273    ) {
274        let matched_ix = index.and_then(|index| {
275            self.matched
276                .iter()
277                .position(|matched| matched.index_path == index && !matched.disabled)
278        });
279
280        let preserve_no_selection = matched_ix.is_none();
281        if self.selected_index == matched_ix {
282            self.preserve_no_selection = preserve_no_selection;
283            return;
284        }
285
286        let previous_index = self.selected_index();
287        self.selected_index = matched_ix;
288        self.preserve_no_selection = preserve_no_selection;
289        self.pending_scroll = matched_ix
290            .and_then(|matched_ix| self.matched.get(matched_ix))
291            .map(|matched| matched.row_ix);
292
293        if let Some((on_select, index)) = self.on_select_if_changed(previous_index) {
294            window.defer(cx, move |window, cx| on_select(index, window, cx));
295        }
296
297        cx.notify();
298    }
299
300    /// The number of items matching the current query.
301    pub fn matched_count(&self) -> usize {
302        self.matched.len()
303    }
304
305    /// Move focus to the palette's active control.
306    pub fn focus(&self, window: &mut Window, cx: &mut App) {
307        if self.model.searchable {
308            self.query_input.focus_handle(cx).focus(window, cx);
309        } else {
310            self.focus_handle.focus(window, cx);
311        }
312    }
313
314    /// Show or hide the search field's spinner, and suppress the empty message
315    /// while it spins.
316    ///
317    /// Turn it on while an `on_query` callback is being answered.
318    pub fn set_loading(&mut self, loading: bool, window: &mut Window, cx: &mut Context<Self>) {
319        self.loading = loading;
320        self.query_input
321            .update(cx, |input, cx| input.set_loading(loading, window, cx));
322        cx.notify();
323    }
324
325    /// Whether the search field is showing its spinner.
326    pub fn is_loading(&self) -> bool {
327        self.loading
328    }
329
330    // MARK: Matching
331
332    fn item_matches(&self, item: &CommandItem, query: &str) -> bool {
333        if !self.model.searchable || !self.model.filterable || query.is_empty() {
334            true
335        } else {
336            item.matches(query)
337        }
338    }
339
340    fn item_at(&self, matched_ix: usize) -> Option<&CommandItem> {
341        let matched = self.matched.get(matched_ix)?;
342
343        match self.model.entries.get(matched.entry_ix)? {
344            CommandEntry::Item(item) => Some(item),
345            CommandEntry::Group(group) => group.items.get(matched.item_ix),
346            CommandEntry::Separator => None,
347        }
348    }
349
350    /// Recompute the visible rows and the matching items for the current query.
351    ///
352    fn update_matches(&mut self, cx: &App) {
353        let query = self.query(cx);
354        let query = query.trim();
355
356        let mut rows: Vec<CommandRow> = Vec::new();
357        let mut matched: Vec<MatchedItem> = Vec::new();
358        let has_ungrouped_items = self
359            .model
360            .entries
361            .iter()
362            .any(|entry| matches!(entry, CommandEntry::Item(_)));
363        let mut ungrouped_item_ix = 0;
364        let mut group_ix = 0;
365        // A separator is only drawn once something follows it, which drops the
366        // leading, trailing and doubled separators a filtered list leaves behind.
367        let mut pending_separator = false;
368
369        for (entry_ix, entry) in self.model.entries.iter().enumerate() {
370            match entry {
371                CommandEntry::Separator => pending_separator = !rows.is_empty(),
372                CommandEntry::Item(item) => {
373                    let item_ix = ungrouped_item_ix;
374                    ungrouped_item_ix += 1;
375                    if !self.item_matches(item, query) {
376                        continue;
377                    }
378
379                    if pending_separator {
380                        rows.push(CommandRow::Separator);
381                        pending_separator = false;
382                    }
383
384                    let index_path = IndexPath::new(item_ix).section(0);
385                    matched.push(MatchedItem {
386                        entry_ix,
387                        item_ix: 0,
388                        index_path,
389                        row_ix: rows.len(),
390                        disabled: item.is_disabled(),
391                    });
392                    rows.push(CommandRow::Item(matched.len() - 1));
393                }
394                CommandEntry::Group(group) => {
395                    let section_ix = group_ix + usize::from(has_ungrouped_items);
396                    group_ix += 1;
397                    let visible = group
398                        .items
399                        .iter()
400                        .enumerate()
401                        .filter(|(_, item)| self.item_matches(item, query))
402                        .map(|(item_ix, item)| (item_ix, item.is_disabled()))
403                        .collect::<Vec<_>>();
404
405                    if visible.is_empty() {
406                        continue;
407                    }
408
409                    if pending_separator {
410                        rows.push(CommandRow::Separator);
411                        pending_separator = false;
412                    }
413
414                    if let Some(heading) = group.heading() {
415                        rows.push(CommandRow::Heading(heading.clone()));
416                    }
417
418                    for (item_ix, disabled) in visible {
419                        let index_path = IndexPath::new(item_ix).section(section_ix);
420                        matched.push(MatchedItem {
421                            entry_ix,
422                            item_ix,
423                            index_path,
424                            row_ix: rows.len(),
425                            disabled,
426                        });
427                        rows.push(CommandRow::Item(matched.len() - 1));
428                    }
429                }
430            }
431        }
432
433        self.rows = rows;
434        self.matched = matched;
435        self.needs_measure = true;
436        self.selected_index = self.selected_index.and_then(|selected_index| {
437            (selected_index < self.matched.len()).then_some(selected_index)
438        });
439    }
440
441    /// Move the highlight to the first item that can be confirmed.
442    fn reset_selection(&mut self) {
443        self.selected_index = self.matched.iter().position(|matched| !matched.disabled);
444        self.preserve_no_selection = false;
445        self.pending_scroll = self
446            .selected_index
447            .and_then(|selected_index| self.matched.get(selected_index))
448            .map(|matched| matched.row_ix)
449            .or(Some(0));
450    }
451
452    fn on_query_input_event(
453        &mut self,
454        _: &Entity<InputState>,
455        event: &InputEvent,
456        window: &mut Window,
457        cx: &mut Context<Self>,
458    ) {
459        if !matches!(event, InputEvent::Change) {
460            return;
461        }
462
463        self.on_query_changed(window, cx);
464    }
465
466    /// Re-filter for the query that is now in the field, and report it.
467    fn on_query_changed(&mut self, window: &mut Window, cx: &mut Context<Self>) {
468        let query = self.query(cx);
469        if query == self.applied_query {
470            return;
471        }
472
473        let previous_selection = self.selected_index();
474        self.applied_query = query.clone();
475        self.update_matches(cx);
476        self.reset_selection();
477        let selection_callback = self.on_select_if_changed(previous_selection);
478        let query_callback = self
479            .model
480            .searchable
481            .then(|| self.model.on_query.clone())
482            .flatten();
483
484        if selection_callback.is_some() || query_callback.is_some() {
485            window.defer(cx, move |window, cx| {
486                if let Some((on_select, index)) = selection_callback {
487                    on_select(index, window, cx);
488                }
489                if let Some(on_query) = query_callback {
490                    on_query(query.as_ref(), window, cx);
491                }
492            });
493        }
494
495        cx.notify();
496    }
497
498    fn set_list_measurement_key(
499        &mut self,
500        measurement_key: ListMeasurementKey,
501        window: &mut Window,
502        cx: &mut Context<Self>,
503    ) {
504        if self.list_measurement_key.as_ref() == Some(&measurement_key) {
505            return;
506        }
507
508        self.list_measurement_key = Some(measurement_key);
509        self.measure_on_next_frame(window, cx);
510    }
511
512    /// Measure the rows again on the next frame. Called while the list is
513    /// drawn, where a plain notify schedules no new frame.
514    fn measure_on_next_frame(&mut self, window: &mut Window, cx: &mut Context<Self>) {
515        self.needs_measure = true;
516        let entity_id = cx.entity_id();
517        window.on_next_frame(move |_, cx| cx.notify(entity_id));
518    }
519
520    // MARK: Actions
521
522    fn on_select_if_changed(
523        &self,
524        previous_index: Option<IndexPath>,
525    ) -> Option<(Rc<OnIndex>, IndexPath)> {
526        let index = self.selected_index();
527        if index == previous_index {
528            return None;
529        }
530
531        self.model.on_select.clone().zip(index)
532    }
533
534    /// Highlight an item without scrolling it into view. Hover goes through
535    /// here, and revealing a half-clipped edge row would slide the next row
536    /// under the resting cursor, hover-selecting and scrolling in a loop.
537    fn select(&mut self, matched_ix: usize, window: &mut Window, cx: &mut Context<Self>) {
538        if self.selected_index == Some(matched_ix) {
539            return;
540        }
541
542        let previous_index = self.selected_index();
543        self.selected_index = Some(matched_ix);
544        self.preserve_no_selection = false;
545
546        if let Some((on_select, index)) = self.on_select_if_changed(previous_index) {
547            window.defer(cx, move |window, cx| on_select(index, window, cx));
548        }
549
550        cx.notify();
551    }
552
553    /// Move the highlight by `step` items, wrapping around and skipping the
554    /// disabled ones.
555    fn select_by(&mut self, step: isize, window: &mut Window, cx: &mut Context<Self>) {
556        let len = self.matched.len();
557        if len == 0 {
558            return;
559        }
560
561        let mut next = self
562            .selected_index
563            .unwrap_or_else(|| if step >= 0 { len.saturating_sub(1) } else { 0 });
564        let mut enabled = None;
565        for _ in 0..len {
566            next = (next as isize + step).rem_euclid(len as isize) as usize;
567            if !self.matched[next].disabled {
568                enabled = Some(next);
569                break;
570            }
571        }
572
573        if let Some(next) = enabled
574            && self.selected_index != Some(next)
575        {
576            self.pending_scroll = self.matched.get(next).map(|matched| matched.row_ix);
577            self.select(next, window, cx);
578        }
579    }
580
581    fn on_action_select_up(&mut self, _: &SelectUp, window: &mut Window, cx: &mut Context<Self>) {
582        self.select_by(-1, window, cx);
583    }
584
585    fn on_action_select_down(
586        &mut self,
587        _: &SelectDown,
588        window: &mut Window,
589        cx: &mut Context<Self>,
590    ) {
591        self.select_by(1, window, cx);
592    }
593
594    fn on_action_confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
595        if let Some(selected_index) = self.selected_index {
596            self.confirm(selected_index, window, cx);
597        }
598    }
599
600    /// Escape clears a non-empty query first, and only then leaves the palette
601    /// — the dialog that hosts it closes on the second press.
602    fn on_action_cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
603        if self.model.searchable && !self.query(cx).is_empty() {
604            self.set_query("", window, cx);
605            return;
606        }
607
608        // Cancel is the one synchronous callback: propagation must continue in
609        // this dispatch so a hosting Dialog observes it once and owns the pop.
610        if let Some(on_cancel) = self.model.on_cancel.clone() {
611            on_cancel(window, cx);
612        }
613
614        cx.propagate();
615    }
616
617    fn confirm(&mut self, matched_ix: usize, window: &mut Window, cx: &mut Context<Self>) {
618        let Some(item) = self.item_at(matched_ix) else {
619            return;
620        };
621        if item.is_disabled() {
622            return;
623        }
624
625        let index_path = self.matched[matched_ix].index_path;
626        let action = item.action.as_ref().map(|action| action.boxed_clone());
627        let on_confirm = self.model.on_confirm.clone();
628
629        if let Some(action) = action {
630            window.dispatch_action(action, cx);
631        }
632        if let Some(on_confirm) = on_confirm {
633            window.defer(cx, move |window, cx| {
634                on_confirm(index_path, window, cx);
635            });
636        }
637    }
638
639    // MARK: Row sizing
640
641    /// Measure each row before passing the sizes to the virtual list. Custom
642    /// item elements can have independent intrinsic heights.
643    fn measure_rows(&mut self, window: &mut Window, cx: &mut Context<Self>) {
644        let available = size(
645            self.list_measurement_key
646                .as_ref()
647                .map_or(AvailableSpace::MinContent, |key| {
648                    AvailableSpace::Definite(key.content_width)
649                }),
650            AvailableSpace::MinContent,
651        );
652        let mut text_style = StyleRefinement::default();
653        text_style.text = self.options.style.text.clone();
654        let mut bindings = vec![None; self.matched.len()];
655
656        let row_sizes = self
657            .rows
658            .iter()
659            .enumerate()
660            .map(|(row_ix, row)| {
661                let row = match row {
662                    CommandRow::Separator => return size(px(0.), px(SEPARATOR_ROW_HEIGHT)),
663                    CommandRow::Heading(_) => self.render_row(row_ix, window, cx),
664                    CommandRow::Item(matched_ix) => {
665                        let binding = self.item_binding(*matched_ix, window, cx);
666                        bindings[*matched_ix] = binding.as_ref().map(|kbd| kbd.keystroke().clone());
667                        self.render_item_with_binding(*matched_ix, binding, window, cx)
668                    }
669                };
670                let row_size = div()
671                    .refine_style(&text_style)
672                    .child(row)
673                    .into_any_element()
674                    .layout_as_root(available, window, cx);
675                size(px(0.), row_size.height)
676            })
677            .collect::<Vec<_>>();
678
679        self.row_sizes = Rc::new(row_sizes);
680        self.measured_bindings = bindings;
681    }
682
683    // MARK: Rendering
684
685    fn sync_placeholder(&mut self, window: &mut Window, cx: &mut Context<Self>) {
686        let placeholder = self
687            .options
688            .placeholder
689            .as_ref()
690            .cloned()
691            .unwrap_or_else(|| t!("Command.placeholder").to_string().into());
692
693        if self.applied_placeholder == placeholder {
694            return;
695        }
696
697        self.applied_placeholder = placeholder.clone();
698        self.query_input.update(cx, |input, cx| {
699            input.set_placeholder(placeholder, window, cx)
700        });
701    }
702
703    /// The frame every item row shares, so that the measured height matches the
704    /// rendered one.
705    fn item_row(&self, selected: bool, cx: &App) -> gpui::Div {
706        div()
707            .flex()
708            .flex_row()
709            .items_center()
710            .w_full()
711            .gap_2()
712            .px_2()
713            .py_1p5()
714            .text_sm()
715            .rounded(cx.theme().radius)
716            .when(selected, |this| {
717                this.bg(cx.theme().accent)
718                    .text_color(cx.theme().accent_foreground)
719            })
720    }
721
722    fn heading_row(&self, heading: SharedString, cx: &App) -> gpui::Div {
723        div()
724            .w_full()
725            .px_2()
726            .py_1p5()
727            .text_xs()
728            .font_medium()
729            .text_color(cx.theme().muted_foreground)
730            .child(heading)
731    }
732
733    fn render_row(&self, row_ix: usize, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
734        match self.rows.get(row_ix) {
735            None => div().into_any_element(),
736            Some(CommandRow::Separator) => div()
737                .w_full()
738                .py(px(4.))
739                .child(div().h(px(1.)).w_full().bg(cx.theme().border))
740                .into_any_element(),
741            Some(CommandRow::Heading(heading)) => {
742                self.heading_row(heading.clone(), cx).into_any_element()
743            }
744            Some(CommandRow::Item(matched_ix)) => self.render_item(*matched_ix, window, cx),
745        }
746    }
747
748    /// Render a row the virtual list shows. When an item's keybinding hint no
749    /// longer matches the one it was measured with, measure the rows again.
750    fn render_visible_row(
751        &mut self,
752        row_ix: usize,
753        window: &mut Window,
754        cx: &mut Context<Self>,
755    ) -> AnyElement {
756        let Some(&CommandRow::Item(matched_ix)) = self.rows.get(row_ix) else {
757            return self.render_row(row_ix, window, cx);
758        };
759
760        let binding = self.item_binding(matched_ix, window, cx);
761        let measured = self
762            .measured_bindings
763            .get(matched_ix)
764            .and_then(Option::as_ref);
765        if !self.needs_measure && measured != binding.as_ref().map(Kbd::keystroke) {
766            self.measure_on_next_frame(window, cx);
767        }
768
769        self.render_item_with_binding(matched_ix, binding, window, cx)
770    }
771
772    /// The keybinding hint an item shows in its trailing slot. A custom child
773    /// owns its complete presentation, so it has none.
774    fn item_binding(&self, matched_ix: usize, window: &Window, cx: &App) -> Option<Kbd> {
775        let item = self.item_at(matched_ix)?;
776        if item.content.is_some() {
777            return None;
778        }
779
780        let action = item.action.as_ref()?;
781        Kbd::binding_for_action_in(action.as_ref(), &self.focus_handle(cx), window)
782            .or_else(|| Kbd::binding_for_action(action.as_ref(), None, window))
783    }
784
785    fn render_item(
786        &self,
787        matched_ix: usize,
788        window: &mut Window,
789        cx: &mut Context<Self>,
790    ) -> AnyElement {
791        let binding = self.item_binding(matched_ix, window, cx);
792        self.render_item_with_binding(matched_ix, binding, window, cx)
793    }
794
795    fn render_item_with_binding(
796        &self,
797        matched_ix: usize,
798        binding: Option<Kbd>,
799        window: &mut Window,
800        cx: &mut Context<Self>,
801    ) -> AnyElement {
802        let Some(item) = self.item_at(matched_ix) else {
803            return div().into_any_element();
804        };
805
806        let disabled = item.is_disabled();
807        let selected = self.selected_index == Some(matched_ix) && !disabled;
808        let muted_foreground = cx.theme().muted_foreground;
809        let icon_color = if selected {
810            cx.theme().accent_foreground
811        } else {
812            muted_foreground
813        };
814        let content = match &item.content {
815            Some(render) => render(window, cx),
816            None => h_flex()
817                .flex_1()
818                .gap_2()
819                .items_center()
820                .when_some(item.icon.clone(), |this, icon| {
821                    this.child(icon.size_4().text_color(icon_color))
822                })
823                .when_some(item.label_text().cloned(), |this, label| this.child(label))
824                .into_any_element(),
825        };
826
827        self.item_row(selected, cx)
828            .id(self.matched[matched_ix].index_path)
829            .test_support()
830            .role(Role::ListBoxOption)
831            .aria_selected(selected)
832            .when(disabled, |this| this.text_color(muted_foreground))
833            .when(!disabled, |this| {
834                this.cursor_default()
835                    .on_hover(cx.listener(move |this, hovered: &bool, window, cx| {
836                        if *hovered {
837                            this.select(matched_ix, window, cx);
838                        }
839                    }))
840                    .on_click(cx.listener(move |this, _, window, cx| {
841                        this.confirm(matched_ix, window, cx);
842                    }))
843            })
844            .child(content)
845            .map(|this| match binding {
846                Some(binding) => this.child(binding.ml_auto()),
847                // The binding owns the trailing slot, so only an item without
848                // one can show its check there.
849                None => this.when(item.checked, |this| {
850                    this.child(crate::Sizable::xsmall(Icon::new(IconName::Check).ml_auto()))
851                }),
852            })
853            .into_any_element()
854    }
855
856    fn render_empty(&self, window: &mut Window, cx: &mut App) -> AnyElement {
857        if let Some(empty) = self.options.empty.as_ref() {
858            return empty(self, window, cx);
859        }
860
861        let message: SharedString = t!("Command.empty").to_string().into();
862
863        div()
864            .py_6()
865            .w_full()
866            .text_center()
867            .text_sm()
868            .text_color(cx.theme().muted_foreground)
869            .child(message)
870            .into_any_element()
871    }
872}
873
874impl Focusable for CommandState {
875    fn focus_handle(&self, cx: &App) -> FocusHandle {
876        if self.model.searchable {
877            self.query_input.focus_handle(cx)
878        } else {
879            self.focus_handle.clone()
880        }
881    }
882}
883
884impl Render for CommandState {
885    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
886        self.sync_placeholder(window, cx);
887
888        if self.needs_measure {
889            self.needs_measure = false;
890            self.measure_rows(window, cx);
891        }
892
893        if let Some(row_ix) = self.pending_scroll.take() {
894            self.scroll_handle
895                .scroll_to_item(row_ix, ScrollStrategy::Nearest);
896        }
897
898        let rows_count = self.rows.len();
899        let row_sizes = self.row_sizes.clone();
900        let command_state = cx.entity();
901
902        v_flex()
903            .id("command")
904            .test_support()
905            .key_context(CONTEXT)
906            .track_focus(&self.focus_handle)
907            .on_action(cx.listener(Self::on_action_select_up))
908            .on_action(cx.listener(Self::on_action_select_down))
909            .on_action(cx.listener(Self::on_action_confirm))
910            .on_action(cx.listener(Self::on_action_cancel))
911            .w_full()
912            .overflow_hidden()
913            .bg(cx.theme().popover)
914            .text_color(cx.theme().popover_foreground)
915            .when(self.options.bordered, |this| {
916                this.rounded(cx.theme().radius_lg)
917                    .border_1()
918                    .border_color(cx.theme().border)
919            })
920            .refine_style(&self.options.style)
921            .when_some(self.options.header.as_ref(), |this, header| {
922                this.child(header(self, window, cx))
923            })
924            .when(self.model.searchable, |this| {
925                this.child(
926                    div()
927                        .flex_none()
928                        .px_3()
929                        .border_b_1()
930                        .border_color(cx.theme().border)
931                        .child(
932                            Input::new(&self.query_input)
933                                .prefix(
934                                    Icon::new(IconName::Search)
935                                        .text_color(cx.theme().muted_foreground),
936                                )
937                                .appearance(false)
938                                .p_0(),
939                        ),
940                )
941            })
942            .child(
943                v_flex()
944                    .id("command-list-container")
945                    .role(Role::ListBox)
946                    .relative()
947                    .flex_1()
948                    // The rows carry their inset on the virtual list itself so
949                    // that a mid-scroll clip edge sits flush against the
950                    // surrounding dividers; only the empty slot needs the
951                    // container padding.
952                    .when(rows_count == 0, |this| this.p_1())
953                    .on_prepaint({
954                        let measure_state = command_state.clone();
955                        move |bounds, window, cx| {
956                            measure_state.update(cx, |state, cx| {
957                                // The list's `p_1` is one quarter rem on each
958                                // side. Its rem-dependent padding and inherited
959                                // layout-relevant text style participate in
960                                // the row-size cache key.
961                                let text_style = window.text_style();
962                                state.set_list_measurement_key(
963                                    ListMeasurementKey {
964                                        content_width: (bounds.size.width
965                                            - window.rem_size() * 0.5)
966                                            .max(px(0.)),
967                                        rem_size: window.rem_size(),
968                                        line_height: window.line_height(),
969                                        text_shape: TextShapeKey {
970                                            font_family: text_style.font_family,
971                                            font_features: text_style.font_features,
972                                            font_fallbacks: text_style.font_fallbacks,
973                                            font_size: text_style.font_size,
974                                            font_weight: text_style.font_weight,
975                                            font_style: text_style.font_style,
976                                            white_space: text_style.white_space,
977                                            text_overflow: text_style.text_overflow,
978                                            line_clamp: text_style.line_clamp,
979                                        },
980                                    },
981                                    window,
982                                    cx,
983                                )
984                            })
985                        }
986                    })
987                    .max_h(self.options.max_h)
988                    .overflow_hidden()
989                    // While a search is in flight the list is empty because the
990                    // answer has not arrived, which is not the same as no match.
991                    .when(rows_count == 0 && !self.loading, |this| {
992                        this.child(self.render_empty(window, cx))
993                    })
994                    .when(rows_count > 0, |this| {
995                        this.child(
996                            v_virtual_list(
997                                command_state.clone(),
998                                "command-list",
999                                row_sizes,
1000                                move |this, visible_range, window, cx| {
1001                                    visible_range
1002                                        .map(|row_ix| this.render_visible_row(row_ix, window, cx))
1003                                        .collect::<Vec<_>>()
1004                                },
1005                            )
1006                            // Padding on the virtual list acts like CSS
1007                            // scroll-padding: the scroll ends keep their inset
1008                            // while scrolled-under rows paint and clip at the
1009                            // list edge.
1010                            .p_1()
1011                            .with_sizing_behavior(ListSizingBehavior::Infer)
1012                            .track_scroll(&self.scroll_handle),
1013                        )
1014                        .child(Scrollbar::vertical(&self.scroll_handle))
1015                    }),
1016            )
1017            .when_some(self.options.footer.as_ref(), |this, footer| {
1018                this.child(footer(self, window, cx))
1019            })
1020    }
1021}
1022
1023// MARK: Tests
1024
1025#[cfg(test)]
1026mod tests {
1027    use std::{
1028        cell::{Cell, RefCell},
1029        rc::Rc,
1030    };
1031
1032    use gpui::{
1033        AppContext as _, AvailableSpace, Entity, InteractiveElement as _, IntoElement, KeyBinding,
1034        Modifiers, ParentElement as _, Pixels, Render, Styled as _, TestAppContext, Window,
1035        actions, div, point, prelude::FluentBuilder as _, px,
1036    };
1037
1038    use super::{CONTEXT, CommandModel, CommandRow, CommandState, SEPARATOR_ROW_HEIGHT};
1039    use crate::{
1040        Disableable as _, Icon, IconName, IndexPath,
1041        actions::{Cancel, Confirm, SelectDown},
1042        command::{Command, CommandEntry, CommandGroup, CommandItem},
1043    };
1044
1045    actions!(
1046        command_test,
1047        [GlobalTestItem, OpenTestItem, RemovePaletteTestItem]
1048    );
1049
1050    struct CommandActionsHarness {
1051        state: Entity<CommandState>,
1052        events: Rc<RefCell<Vec<String>>>,
1053    }
1054
1055    impl Render for CommandActionsHarness {
1056        fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
1057            let action_events = self.events.clone();
1058            let propagated_cancel_events = self.events.clone();
1059            let query_events = self.events.clone();
1060            let select_events = self.events.clone();
1061            let confirm_events = self.events.clone();
1062            let cancel_events = self.events.clone();
1063
1064            div()
1065                .size_full()
1066                .on_action(move |_: &OpenTestItem, _, _| {
1067                    action_events.borrow_mut().push("action".into());
1068                })
1069                .on_action(move |_: &Cancel, _, _| {
1070                    propagated_cancel_events
1071                        .borrow_mut()
1072                        .push("propagated_cancel".into());
1073                })
1074                .child(
1075                    Command::new(&self.state)
1076                        .item(
1077                            CommandItem::new()
1078                                .label("Item")
1079                                .keywords(["needle"])
1080                                .action(Box::new(OpenTestItem)),
1081                        )
1082                        .item(CommandItem::new().label("Item"))
1083                        .item(
1084                            CommandItem::new()
1085                                .label("Item")
1086                                .action(Box::new(GlobalTestItem)),
1087                        )
1088                        .on_query(move |query, _, _| {
1089                            query_events.borrow_mut().push(format!("query:{query}"));
1090                        })
1091                        .on_select(move |index, _, _| {
1092                            select_events
1093                                .borrow_mut()
1094                                .push(format!("select:{}:{}", index.section, index.row));
1095                        })
1096                        .on_confirm(move |index, _, _| {
1097                            confirm_events
1098                                .borrow_mut()
1099                                .push(format!("confirm:{}:{}", index.section, index.row));
1100                        })
1101                        .on_cancel(move |_, _| {
1102                            cancel_events.borrow_mut().push("cancel".into());
1103                        }),
1104                )
1105        }
1106    }
1107
1108    struct ReentrantCallbackHarness {
1109        state: Entity<CommandState>,
1110        events: Vec<String>,
1111    }
1112
1113    impl Render for ReentrantCallbackHarness {
1114        fn render(&mut self, _: &mut Window, cx: &mut gpui::Context<Self>) -> impl IntoElement {
1115            let select_owner = cx.weak_entity();
1116            let query_owner = cx.weak_entity();
1117            let confirm_owner = cx.weak_entity();
1118
1119            Command::new(&self.state)
1120                .item(CommandItem::new().label("alpha"))
1121                .item(CommandItem::new().label("beta"))
1122                .on_select(move |index, _, cx| {
1123                    _ = select_owner.update(cx, |harness, cx| {
1124                        assert_eq!(harness.state.read(cx).selected_index(), Some(index));
1125                        harness
1126                            .events
1127                            .push(format!("select:{}:{}", index.section, index.row));
1128                    });
1129                })
1130                .on_query(move |query, _, cx| {
1131                    _ = query_owner.update(cx, |harness, cx| {
1132                        assert_eq!(harness.state.read(cx).query(cx).as_ref(), query);
1133                        harness.events.push(format!("query:{query}"));
1134                    });
1135                })
1136                .on_confirm(move |index, _, cx| {
1137                    _ = confirm_owner.update(cx, |harness, cx| {
1138                        assert_eq!(harness.state.read(cx).selected_index(), Some(index));
1139                        harness
1140                            .events
1141                            .push(format!("confirm:{}:{}", index.section, index.row));
1142                    });
1143                })
1144        }
1145    }
1146
1147    #[gpui::test]
1148    fn query_and_selection_callbacks_run_after_the_state_lease_in_defined_order(
1149        cx: &mut TestAppContext,
1150    ) {
1151        cx.update(crate::init);
1152        let (harness, cx) = cx.add_window_view(|window, cx| ReentrantCallbackHarness {
1153            state: cx.new(|cx| CommandState::new(window, cx)),
1154            events: Vec::new(),
1155        });
1156        let state = cx.update(|_, cx| harness.read(cx).state.clone());
1157
1158        cx.run_until_parked();
1159        cx.update(|window, cx| {
1160            _ = window.draw(cx);
1161            state.update(cx, |state, cx| {
1162                state.selected_index = Some(1);
1163                state.set_query("alpha", window, cx);
1164            });
1165        });
1166
1167        assert_eq!(
1168            harness.read_with(cx, |harness, _| harness.events.clone()),
1169            ["select:0:0", "query:alpha"]
1170        );
1171    }
1172
1173    #[gpui::test]
1174    fn actionless_confirm_callback_runs_after_the_state_lease(cx: &mut TestAppContext) {
1175        cx.update(crate::init);
1176        let (harness, cx) = cx.add_window_view(|window, cx| ReentrantCallbackHarness {
1177            state: cx.new(|cx| CommandState::new(window, cx)),
1178            events: Vec::new(),
1179        });
1180        let state = cx.update(|_, cx| harness.read(cx).state.clone());
1181
1182        cx.run_until_parked();
1183        cx.update(|window, cx| {
1184            _ = window.draw(cx);
1185            state.update(cx, |state, cx| state.confirm(0, window, cx));
1186        });
1187
1188        assert_eq!(
1189            harness.read_with(cx, |harness, _| harness.events.clone()),
1190            ["confirm:0:0"]
1191        );
1192    }
1193
1194    struct CommandItemWidthHarness {
1195        state: Entity<CommandState>,
1196        matched_ix: usize,
1197        width: Rc<Cell<Option<Pixels>>>,
1198    }
1199
1200    impl Render for CommandItemWidthHarness {
1201        fn render(
1202            &mut self,
1203            window: &mut Window,
1204            cx: &mut gpui::Context<Self>,
1205        ) -> impl IntoElement {
1206            let width = self.width.clone();
1207            let item = self.state.update(cx, |state, cx| {
1208                state.render_item(self.matched_ix, window, cx)
1209            });
1210
1211            div()
1212                .on_children_prepainted(move |bounds, _, _| width.set(Some(bounds[0].size.width)))
1213                .child(item)
1214        }
1215    }
1216
1217    #[gpui::test]
1218    fn action_that_removes_command_state_still_confirms_after_dispatch(cx: &mut TestAppContext) {
1219        cx.update(crate::init);
1220        let events = Rc::new(RefCell::new(Vec::new()));
1221        let state_owner: Rc<RefCell<Option<Entity<CommandState>>>> = Rc::new(RefCell::new(None));
1222        let action_events = events.clone();
1223        let action_state_owner = state_owner.clone();
1224        cx.update(|cx| {
1225            cx.on_action(move |_: &RemovePaletteTestItem, _| {
1226                action_events.borrow_mut().push("action".into());
1227                action_state_owner.borrow_mut().take();
1228            });
1229        });
1230        let cx = cx.add_empty_window();
1231        cx.update(|window, cx| {
1232            let confirm_events = events.clone();
1233            let state = cx.new(|cx| {
1234                let mut state = CommandState::new(window, cx);
1235                state.install_model(
1236                    CommandModel {
1237                        entries: vec![CommandEntry::Item(
1238                            CommandItem::new()
1239                                .label("removed")
1240                                .action(Box::new(RemovePaletteTestItem)),
1241                        )],
1242                        searchable: false,
1243                        on_confirm: Some(Rc::new(move |index, _, _| {
1244                            confirm_events
1245                                .borrow_mut()
1246                                .push(format!("confirm:{}:{}", index.section, index.row));
1247                        })),
1248                        ..CommandModel::default()
1249                    },
1250                    cx,
1251                );
1252                state
1253            });
1254            *state_owner.borrow_mut() = Some(state.clone());
1255            state.update(cx, |state, cx| state.confirm(0, window, cx));
1256        });
1257        cx.run_until_parked();
1258
1259        assert!(state_owner.borrow().is_none());
1260        assert_eq!(events.borrow().as_slice(), ["action", "confirm:0:0"]);
1261    }
1262
1263    #[gpui::test]
1264    fn command_actions_and_callbacks_follow_defined_order(cx: &mut TestAppContext) {
1265        cx.update(|cx| {
1266            crate::init(cx);
1267            cx.bind_keys([
1268                KeyBinding::new("ctrl-o", OpenTestItem, Some(CONTEXT)),
1269                KeyBinding::new("ctrl-g", GlobalTestItem, None),
1270            ]);
1271        });
1272        let events = Rc::new(RefCell::new(Vec::new()));
1273        let (harness, cx) = cx.add_window_view(|window, cx| CommandActionsHarness {
1274            state: cx.new(|cx| CommandState::new(window, cx)),
1275            events: events.clone(),
1276        });
1277        let state = cx.update(|_, cx| harness.read(cx).state.clone());
1278
1279        cx.run_until_parked();
1280        cx.update(|window, cx| {
1281            _ = window.draw(cx);
1282            state.update(cx, |state, cx| state.focus(window, cx));
1283            _ = window.draw(cx);
1284        });
1285
1286        let action_width = Rc::new(Cell::new(None));
1287        let plain_width = Rc::new(Cell::new(None));
1288        let global_width = Rc::new(Cell::new(None));
1289        let (action_probe, plain_probe, global_probe) = cx.update(|_, cx| {
1290            (
1291                cx.new(|_| CommandItemWidthHarness {
1292                    state: state.clone(),
1293                    matched_ix: 0,
1294                    width: action_width.clone(),
1295                }),
1296                cx.new(|_| CommandItemWidthHarness {
1297                    state: state.clone(),
1298                    matched_ix: 1,
1299                    width: plain_width.clone(),
1300                }),
1301                cx.new(|_| CommandItemWidthHarness {
1302                    state: state.clone(),
1303                    matched_ix: 2,
1304                    width: global_width.clone(),
1305                }),
1306            )
1307        });
1308        cx.draw(
1309            point(px(0.), px(0.)),
1310            AvailableSpace::min_size(),
1311            move |_, _| action_probe.into_any_element(),
1312        );
1313        cx.draw(
1314            point(px(0.), px(0.)),
1315            AvailableSpace::min_size(),
1316            move |_, _| plain_probe.into_any_element(),
1317        );
1318        cx.draw(
1319            point(px(0.), px(0.)),
1320            AvailableSpace::min_size(),
1321            move |_, _| global_probe.into_any_element(),
1322        );
1323        let action_width = action_width.get().unwrap();
1324        let plain_width = plain_width.get().unwrap();
1325        let global_width = global_width.get().unwrap();
1326        assert!(
1327            action_width > plain_width,
1328            "the scoped Action binding should add a visible Kbd ({action_width:?} vs {plain_width:?})",
1329        );
1330        assert!(
1331            global_width > plain_width,
1332            "the app-level fallback binding should add a visible Kbd ({global_width:?} vs {plain_width:?})",
1333        );
1334
1335        cx.update(|window, cx| {
1336            state.update(cx, |state, cx| {
1337                state.set_query("needle", window, cx);
1338                state.set_query("needle", window, cx);
1339                state.set_query("", window, cx);
1340            });
1341            window.dispatch_action(Box::new(SelectDown), cx);
1342            window.dispatch_action(Box::new(crate::actions::SelectUp), cx);
1343            window.dispatch_action(Box::new(Confirm { secondary: false }), cx);
1344        });
1345        cx.run_until_parked();
1346
1347        assert_eq!(
1348            events.borrow().as_slice(),
1349            [
1350                "query:needle",
1351                "query:",
1352                "select:0:1",
1353                "select:0:0",
1354                "action",
1355                "confirm:0:0",
1356            ]
1357        );
1358
1359        cx.simulate_click(point(px(20.), px(52.)), Modifiers::default());
1360        cx.run_until_parked();
1361        cx.update(|window, cx| window.dispatch_action(Box::new(Cancel), cx));
1362        cx.run_until_parked();
1363
1364        assert_eq!(
1365            events.borrow().as_slice(),
1366            [
1367                "query:needle",
1368                "query:",
1369                "select:0:1",
1370                "select:0:0",
1371                "action",
1372                "confirm:0:0",
1373                "action",
1374                "confirm:0:0",
1375                "cancel",
1376                "propagated_cancel",
1377            ]
1378        );
1379    }
1380
1381    struct CommandOwnedEntriesHarness {
1382        state: Entity<CommandState>,
1383    }
1384
1385    impl Render for CommandOwnedEntriesHarness {
1386        fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
1387            Command::new(&self.state)
1388                .searchable(false)
1389                .item(CommandItem::new().label("alpha"))
1390                .group(
1391                    CommandGroup::new()
1392                        .label("Settings")
1393                        .item(CommandItem::new().label("beta")),
1394                )
1395                .separator()
1396                .item(
1397                    CommandItem::new()
1398                        .label("custom")
1399                        .child(|_, _| div().h(px(72.)).child("Custom")),
1400                )
1401        }
1402    }
1403
1404    #[gpui::test]
1405    fn command_owns_entries_and_lazy_item_content(cx: &mut TestAppContext) {
1406        cx.update(crate::init);
1407        let (harness, cx) = cx.add_window_view(|window, cx| CommandOwnedEntriesHarness {
1408            state: cx.new(|cx| CommandState::new(window, cx)),
1409        });
1410
1411        cx.run_until_parked();
1412        cx.update(|window, cx| _ = window.draw(cx));
1413
1414        let (labels, rows, row_sizes) = cx.update(|_, cx| {
1415            let state = harness.read(cx).state.read(cx);
1416            (
1417                (0..state.matched_count())
1418                    .map(|matched_ix| {
1419                        state
1420                            .item_at(matched_ix)
1421                            .unwrap()
1422                            .label_text()
1423                            .unwrap()
1424                            .clone()
1425                    })
1426                    .collect::<Vec<_>>(),
1427                state.rows.clone(),
1428                state.row_sizes.clone(),
1429            )
1430        });
1431
1432        assert_eq!(labels, ["alpha", "beta", "custom"]);
1433        assert!(matches!(
1434            rows.as_slice(),
1435            [
1436                CommandRow::Item(_),
1437                CommandRow::Heading(heading),
1438                CommandRow::Item(_),
1439                CommandRow::Separator,
1440                CommandRow::Item(_),
1441            ] if heading == "Settings"
1442        ));
1443        assert_eq!(row_sizes[4].height, px(84.));
1444    }
1445
1446    fn command_with_entries(
1447        state: &Entity<CommandState>,
1448        entries: impl IntoIterator<Item = CommandEntry>,
1449    ) -> Command {
1450        entries
1451            .into_iter()
1452            .fold(Command::new(state), |command, entry| match entry {
1453                CommandEntry::Item(item) => command.item(item),
1454                CommandEntry::Group(group) => command.group(group),
1455                CommandEntry::Separator => command.separator(),
1456            })
1457    }
1458
1459    fn command_state(
1460        window: &mut Window,
1461        cx: &mut gpui::Context<CommandState>,
1462        entries: impl IntoIterator<Item = CommandEntry>,
1463    ) -> CommandState {
1464        let mut state = CommandState::new(window, cx);
1465        state.install_model(
1466            CommandModel {
1467                entries: entries.into_iter().collect(),
1468                ..CommandModel::default()
1469            },
1470            cx,
1471        );
1472        state
1473    }
1474
1475    fn command_state_with_options(
1476        window: &mut Window,
1477        cx: &mut gpui::Context<CommandState>,
1478        entries: impl IntoIterator<Item = CommandEntry>,
1479        searchable: bool,
1480    ) -> CommandState {
1481        let mut state = CommandState::new(window, cx);
1482        state.install_model(
1483            CommandModel {
1484                entries: entries.into_iter().collect(),
1485                searchable,
1486                ..CommandModel::default()
1487            },
1488            cx,
1489        );
1490        state
1491    }
1492
1493    fn suggestion_entries() -> Vec<CommandEntry> {
1494        vec![
1495            CommandGroup::new()
1496                .label("Suggestions")
1497                .item(CommandItem::new().label("Calendar"))
1498                .item(CommandItem::new().label("Search Emoji"))
1499                .item(CommandItem::new().label("Calculator").disabled(true))
1500                .into(),
1501            CommandEntry::Separator,
1502            CommandGroup::new()
1503                .label("Settings")
1504                .item(CommandItem::new().label("Profile"))
1505                .item(CommandItem::new().label("Billing"))
1506                .into(),
1507        ]
1508    }
1509
1510    #[gpui::test]
1511    fn query_hides_the_groups_that_have_no_match(cx: &mut TestAppContext) {
1512        cx.update(crate::init);
1513        let cx = cx.add_empty_window();
1514
1515        cx.update(|window, cx| {
1516            let state = cx.new(|cx| command_state(window, cx, suggestion_entries()));
1517
1518            state.update(cx, |state, cx| {
1519                state.update_matches(cx);
1520                assert_eq!(state.matched_count(), 5);
1521                assert_eq!(
1522                    state
1523                        .rows
1524                        .iter()
1525                        .filter(|row| matches!(row, CommandRow::Heading(_)))
1526                        .count(),
1527                    2,
1528                );
1529                assert_eq!(
1530                    state
1531                        .rows
1532                        .iter()
1533                        .filter(|row| matches!(row, CommandRow::Separator))
1534                        .count(),
1535                    1,
1536                );
1537
1538                // "Bil" only matches an item of the second group, so the first
1539                // group's heading and the separator between them both go.
1540                state.set_query("Bil", window, cx);
1541                state.update_matches(cx);
1542
1543                assert_eq!(state.matched_count(), 1);
1544                assert_eq!(state.selected_index(), Some(IndexPath::new(1).section(1)));
1545                assert_eq!(
1546                    state
1547                        .rows
1548                        .iter()
1549                        .filter(|row| matches!(row, CommandRow::Separator))
1550                        .count(),
1551                    0,
1552                );
1553                assert!(matches!(state.rows.first(), Some(CommandRow::Heading(_))));
1554            });
1555        });
1556    }
1557
1558    fn custom_entries() -> Vec<CommandEntry> {
1559        vec![CommandEntry::Item(
1560            CommandItem::new().label("Custom").child(|_, _| div()),
1561        )]
1562    }
1563
1564    #[gpui::test]
1565    fn reinstalling_an_unchanged_model_keeps_the_measured_rows(cx: &mut TestAppContext) {
1566        cx.update(crate::init);
1567        let cx = cx.add_empty_window();
1568
1569        cx.update(|window, cx| {
1570            let state = cx.new(|cx| command_state(window, cx, suggestion_entries()));
1571
1572            state.update(cx, |state, cx| {
1573                // A host re-render rebuilds an equal model: nothing to lay out again.
1574                state.needs_measure = false;
1575                state.install_model(
1576                    CommandModel {
1577                        entries: suggestion_entries(),
1578                        ..CommandModel::default()
1579                    },
1580                    cx,
1581                );
1582                assert!(!state.needs_measure);
1583                assert_eq!(state.matched_count(), 5);
1584
1585                // A custom child can read state outside the item, so it is
1586                // always measured again.
1587                state.install_model(
1588                    CommandModel {
1589                        entries: custom_entries(),
1590                        ..CommandModel::default()
1591                    },
1592                    cx,
1593                );
1594                assert!(state.needs_measure);
1595                assert_eq!(state.matched_count(), 1);
1596
1597                // Even when the host clones entries it built once, which
1598                // keeps the same closure.
1599                state.needs_measure = false;
1600                let entries = state.model.entries.clone();
1601                state.install_model(
1602                    CommandModel {
1603                        entries,
1604                        ..CommandModel::default()
1605                    },
1606                    cx,
1607                );
1608                assert!(state.needs_measure);
1609
1610                // A changed label or icon style changes how the label wraps,
1611                // and a changed disabled flag changes what can be highlighted.
1612                let item = || CommandItem::new().label("Renamed");
1613                let icon = || Icon::new(IconName::Check);
1614                for entries in [
1615                    vec![CommandEntry::Item(item())],
1616                    vec![CommandEntry::Item(item().icon(icon()))],
1617                    vec![CommandEntry::Item(item().icon(icon().ml_2()))],
1618                    vec![CommandEntry::Item(
1619                        item().icon(icon().ml_2()).disabled(true),
1620                    )],
1621                ] {
1622                    state.needs_measure = false;
1623                    state.install_model(
1624                        CommandModel {
1625                            entries,
1626                            ..CommandModel::default()
1627                        },
1628                        cx,
1629                    );
1630                    assert!(state.needs_measure);
1631                }
1632                assert_eq!(state.selected_index(), None);
1633            });
1634        });
1635    }
1636
1637    #[gpui::test]
1638    fn a_query_that_matches_nothing_leaves_no_rows(cx: &mut TestAppContext) {
1639        cx.update(crate::init);
1640        let cx = cx.add_empty_window();
1641
1642        cx.update(|window, cx| {
1643            let state = cx.new(|cx| command_state(window, cx, suggestion_entries()));
1644
1645            state.update(cx, |state, cx| {
1646                state.set_query("zzz", window, cx);
1647                state.update_matches(cx);
1648
1649                assert_eq!(state.matched_count(), 0);
1650                assert!(state.rows.is_empty());
1651                assert_eq!(state.selected_index(), None);
1652            });
1653        });
1654    }
1655
1656    #[gpui::test]
1657    fn filterable_off_keeps_every_item_and_resets_the_highlight(cx: &mut TestAppContext) {
1658        cx.update(crate::init);
1659        let cx = cx.add_empty_window();
1660
1661        cx.update(|window, cx| {
1662            let state = cx.new(|cx| {
1663                let mut state = CommandState::new(window, cx);
1664                state.install_model(
1665                    CommandModel {
1666                        entries: suggestion_entries(),
1667                        filterable: false,
1668                        ..CommandModel::default()
1669                    },
1670                    cx,
1671                );
1672                state
1673            });
1674
1675            state.update(cx, |state, cx| {
1676                state.set_selected_index(Some(IndexPath::new(1).section(1)), window, cx);
1677
1678                // "Bil" would locally match only "Billing"; an unfiltered
1679                // palette keeps every row and hands the highlight back to the
1680                // first item instead of the textual match.
1681                state.set_query("Bil", window, cx);
1682
1683                assert_eq!(state.matched_count(), 5);
1684                assert_eq!(state.selected_index(), Some(IndexPath::new(0).section(0)));
1685            });
1686        });
1687    }
1688
1689    #[gpui::test]
1690    fn keywords_match_when_the_label_does_not(cx: &mut TestAppContext) {
1691        cx.update(crate::init);
1692        let cx = cx.add_empty_window();
1693
1694        cx.update(|window, cx| {
1695            let state = cx.new(|cx| {
1696                command_state(
1697                    window,
1698                    cx,
1699                    [CommandEntry::Item(
1700                        CommandItem::new().label("Profile").keywords(["account"]),
1701                    )],
1702                )
1703            });
1704
1705            state.update(cx, |state, cx| {
1706                state.set_query("account", window, cx);
1707                state.update_matches(cx);
1708
1709                assert_eq!(state.matched_count(), 1);
1710            });
1711        });
1712    }
1713
1714    #[gpui::test]
1715    fn non_searchable_command_keeps_every_item(cx: &mut TestAppContext) {
1716        cx.update(crate::init);
1717        let cx = cx.add_empty_window();
1718        cx.update(|window, cx| {
1719            let state = cx.new(|cx| {
1720                command_state_with_options(
1721                    window,
1722                    cx,
1723                    [
1724                        CommandEntry::Item(CommandItem::new().label("alpha")),
1725                        CommandEntry::Item(CommandItem::new().label("beta")),
1726                    ],
1727                    false,
1728                )
1729            });
1730            state.update(cx, |state, cx| {
1731                state.set_query("missing", window, cx);
1732                assert_eq!(state.matched_count(), 2);
1733            });
1734        });
1735    }
1736
1737    #[gpui::test]
1738    fn non_searchable_command_uses_frame_focus(cx: &mut TestAppContext) {
1739        cx.update(crate::init);
1740        let confirmed = Rc::new(RefCell::new(None));
1741        let confirmed_for_render = confirmed.clone();
1742        let (harness, cx) = cx.add_window_view(move |window, cx| Harness {
1743            state: cx.new(|cx| CommandState::new(window, cx)),
1744            command: Rc::new(move |state| {
1745                let confirmed = confirmed_for_render.clone();
1746                Command::new(state)
1747                    .searchable(false)
1748                    .item(CommandItem::new().label("alpha"))
1749                    .item(CommandItem::new().label("beta"))
1750                    .on_confirm(move |index_path, _, _| {
1751                        *confirmed.borrow_mut() = Some(index_path);
1752                    })
1753            }),
1754        });
1755        let state = cx.update(|_, cx| harness.read(cx).state.clone());
1756
1757        cx.run_until_parked();
1758        cx.update(|window, cx| _ = window.draw(cx));
1759        cx.update(|window, cx| {
1760            state.update(cx, |state, cx| state.focus(window, cx));
1761            assert!(state.read(cx).focus_handle.is_focused(window));
1762            window.dispatch_action(Box::new(SelectDown), cx);
1763            window.dispatch_action(Box::new(Confirm { secondary: false }), cx);
1764        });
1765
1766        assert_eq!(*confirmed.borrow(), Some(IndexPath::new(1).section(0)));
1767    }
1768
1769    #[gpui::test]
1770    fn filtered_ungrouped_item_keeps_its_input_row(cx: &mut TestAppContext) {
1771        cx.update(crate::init);
1772        let confirmed = Rc::new(RefCell::new(None));
1773        let confirmed_for_render = confirmed.clone();
1774        let (harness, cx) = cx.add_window_view(move |window, cx| Harness {
1775            state: cx.new(|cx| CommandState::new(window, cx)),
1776            command: Rc::new(move |state| {
1777                let confirmed = confirmed_for_render.clone();
1778                Command::new(state)
1779                    .items([
1780                        CommandItem::new().label("alpha"),
1781                        CommandItem::new().label("beta"),
1782                        CommandItem::new().label("gamma"),
1783                    ])
1784                    .on_confirm(move |index_path, _, _| {
1785                        *confirmed.borrow_mut() = Some(index_path);
1786                    })
1787            }),
1788        });
1789        let state = cx.update(|_, cx| harness.read(cx).state.clone());
1790
1791        cx.run_until_parked();
1792        cx.update(|window, cx| {
1793            state.update(cx, |state, cx| {
1794                state.set_query("gamma", window, cx);
1795                state.focus(window, cx);
1796            });
1797            window.dispatch_action(Box::new(Confirm { secondary: false }), cx);
1798        });
1799
1800        assert_eq!(*confirmed.borrow(), Some(IndexPath::new(2).section(0)));
1801    }
1802
1803    #[gpui::test]
1804    fn initially_rendered_disabled_first_item_selects_and_confirms_the_first_enabled_item(
1805        cx: &mut TestAppContext,
1806    ) {
1807        cx.update(crate::init);
1808        let confirmed = Rc::new(RefCell::new(None));
1809        let confirmed_for_render = confirmed.clone();
1810        let (harness, cx) = cx.add_window_view(move |window, cx| Harness {
1811            state: cx.new(|cx| CommandState::new(window, cx)),
1812            command: Rc::new(move |state| {
1813                let confirmed = confirmed_for_render.clone();
1814                Command::new(state)
1815                    .item(CommandItem::new().label("disabled").disabled(true))
1816                    .item(CommandItem::new().label("enabled"))
1817                    .on_confirm(move |index_path, _, _| {
1818                        *confirmed.borrow_mut() = Some(index_path);
1819                    })
1820            }),
1821        });
1822        let state = cx.update(|_, cx| harness.read(cx).state.clone());
1823
1824        cx.run_until_parked();
1825        cx.update(|window, cx| _ = window.draw(cx));
1826        cx.update(|window, cx| {
1827            state.update(cx, |state, cx| state.focus(window, cx));
1828            window.dispatch_action(Box::new(Confirm { secondary: false }), cx);
1829        });
1830
1831        assert_eq!(
1832            state.read_with(cx, |state, _| state.selected_index()),
1833            Some(IndexPath::new(1).section(0))
1834        );
1835        assert_eq!(*confirmed.borrow(), Some(IndexPath::new(1).section(0)));
1836    }
1837
1838    #[gpui::test]
1839    fn initially_rendered_all_disabled_items_have_no_selected_index_and_ignore_enter(
1840        cx: &mut TestAppContext,
1841    ) {
1842        cx.update(crate::init);
1843        let confirmed = Rc::new(RefCell::new(None));
1844        let confirmed_for_render = confirmed.clone();
1845        let (harness, cx) = cx.add_window_view(move |window, cx| Harness {
1846            state: cx.new(|cx| CommandState::new(window, cx)),
1847            command: Rc::new(move |state| {
1848                let confirmed = confirmed_for_render.clone();
1849                Command::new(state)
1850                    .item(CommandItem::new().label("one").disabled(true))
1851                    .item(CommandItem::new().label("two").disabled(true))
1852                    .on_confirm(move |index_path, _, _| {
1853                        *confirmed.borrow_mut() = Some(index_path);
1854                    })
1855            }),
1856        });
1857        let state = cx.update(|_, cx| harness.read(cx).state.clone());
1858
1859        cx.run_until_parked();
1860        cx.update(|window, cx| _ = window.draw(cx));
1861        cx.update(|window, cx| {
1862            state.update(cx, |state, cx| state.focus(window, cx));
1863            window.dispatch_action(Box::new(Confirm { secondary: false }), cx);
1864        });
1865
1866        assert_eq!(state.read_with(cx, |state, _| state.selected_index()), None);
1867        assert_eq!(*confirmed.borrow(), None);
1868    }
1869
1870    #[gpui::test]
1871    fn non_searchable_command_cancels_without_clearing_a_hidden_query(cx: &mut TestAppContext) {
1872        cx.update(crate::init);
1873        let cancelled = Rc::new(Cell::new(false));
1874        let cancelled_for_render = cancelled.clone();
1875        let query_calls = Rc::new(Cell::new(0));
1876        let query_calls_for_render = query_calls.clone();
1877        let (harness, cx) = cx.add_window_view(move |window, cx| Harness {
1878            state: cx.new(|cx| CommandState::new(window, cx)),
1879            command: Rc::new(move |state| {
1880                let cancelled = cancelled_for_render.clone();
1881                let query_calls = query_calls_for_render.clone();
1882                Command::new(state)
1883                    .searchable(false)
1884                    .item(CommandItem::new().label("alpha"))
1885                    .on_query(move |_, _, _| query_calls.set(query_calls.get() + 1))
1886                    .on_cancel(move |_, _| cancelled.set(true))
1887            }),
1888        });
1889        let state = cx.update(|_, cx| harness.read(cx).state.clone());
1890
1891        cx.run_until_parked();
1892        cx.update(|window, cx| _ = window.draw(cx));
1893        cx.update(|window, cx| {
1894            state.update(cx, |state, cx| {
1895                state.set_query("hidden query", window, cx);
1896                state.focus(window, cx);
1897            });
1898            window.dispatch_action(Box::new(Cancel), cx);
1899        });
1900
1901        assert!(cancelled.get());
1902        assert_eq!(query_calls.get(), 0);
1903        assert_eq!(
1904            state.read_with(cx, |state, cx| state.query(cx)),
1905            "hidden query"
1906        );
1907    }
1908
1909    #[gpui::test]
1910    fn moving_the_highlight_skips_disabled_items_and_wraps(cx: &mut TestAppContext) {
1911        cx.update(crate::init);
1912        let cx = cx.add_empty_window();
1913
1914        cx.update(|window, cx| {
1915            let state = cx.new(|cx| command_state(window, cx, suggestion_entries()));
1916
1917            state.update(cx, |state, cx| {
1918                state.update_matches(cx);
1919                state.reset_selection();
1920                assert_eq!(state.selected_index(), Some(IndexPath::new(0).section(0)));
1921
1922                state.select_by(1, window, cx);
1923                assert_eq!(state.selected_index(), Some(IndexPath::new(1).section(0)));
1924
1925                // "Calculator" is disabled, so it is stepped over.
1926                state.select_by(1, window, cx);
1927                assert_eq!(state.selected_index(), Some(IndexPath::new(0).section(1)));
1928
1929                state.select_by(-1, window, cx);
1930                assert_eq!(state.selected_index(), Some(IndexPath::new(1).section(0)));
1931
1932                // Wraps around the end, skipping the disabled item again.
1933                state.select_by(-1, window, cx);
1934                assert_eq!(state.selected_index(), Some(IndexPath::new(0).section(0)));
1935                state.select_by(-1, window, cx);
1936                assert_eq!(state.selected_index(), Some(IndexPath::new(1).section(1)));
1937            });
1938        });
1939    }
1940
1941    #[gpui::test]
1942    fn owner_can_set_and_clear_selection_by_original_index_path(cx: &mut TestAppContext) {
1943        cx.update(crate::init);
1944        let cx = cx.add_empty_window();
1945
1946        cx.update(|window, cx| {
1947            let initially_empty = cx.new(|cx| CommandState::new(window, cx));
1948            initially_empty.update(cx, |state, cx| {
1949                state.set_selected_index(None, window, cx);
1950                state.install_model(
1951                    CommandModel {
1952                        entries: suggestion_entries().into_iter().collect(),
1953                        ..CommandModel::default()
1954                    },
1955                    cx,
1956                );
1957                assert_eq!(state.selected_index(), None);
1958            });
1959
1960            let state = cx.new(|cx| command_state(window, cx, suggestion_entries()));
1961
1962            state.update(cx, |state, cx| {
1963                let target = IndexPath::new(1).section(1);
1964                state.set_selected_index(Some(target), window, cx);
1965                assert_eq!(state.selected_index(), Some(target));
1966
1967                state.set_selected_index(None, window, cx);
1968                assert_eq!(state.selected_index(), None);
1969
1970                state.install_model(
1971                    CommandModel {
1972                        entries: suggestion_entries().into_iter().collect(),
1973                        ..CommandModel::default()
1974                    },
1975                    cx,
1976                );
1977                assert_eq!(state.selected_index(), None);
1978
1979                state.set_query("calendar", window, cx);
1980                state.set_selected_index(Some(target), window, cx);
1981                assert_eq!(state.selected_index(), None);
1982            });
1983        });
1984    }
1985
1986    #[gpui::test]
1987    fn confirming_a_disabled_item_does_nothing(cx: &mut TestAppContext) {
1988        cx.update(crate::init);
1989        let cx = cx.add_empty_window();
1990
1991        cx.update(|window, cx| {
1992            let state = cx.new(|cx| {
1993                command_state(
1994                    window,
1995                    cx,
1996                    [
1997                        CommandEntry::Item(CommandItem::new().label("enabled")),
1998                        CommandEntry::Item(CommandItem::new().label("disabled").disabled(true)),
1999                    ],
2000                )
2001            });
2002
2003            state.update(cx, |state, cx| {
2004                state.update_matches(cx);
2005
2006                assert_eq!(state.matched_count(), 2);
2007                // Reaching the disabled row is only possible with the mouse or
2008                // an explicit index; confirming it must be a no-op.
2009                state.confirm(1, window, cx);
2010                assert_eq!(state.selected_index, Some(0));
2011            });
2012        });
2013    }
2014
2015    #[gpui::test]
2016    fn a_checked_item_uses_an_xsmall_trailing_check_icon(cx: &mut TestAppContext) {
2017        cx.update(crate::init);
2018        let cx = cx.add_empty_window();
2019        let unchecked_width = Rc::new(Cell::new(None));
2020        let checked_width = Rc::new(Cell::new(None));
2021        let (unchecked, checked) = cx.update(|window, cx| {
2022            let unchecked_state = cx.new(|cx| {
2023                command_state(
2024                    window,
2025                    cx,
2026                    [CommandEntry::Item(CommandItem::new().label("theme"))],
2027                )
2028            });
2029            let checked_state = cx.new(|cx| {
2030                command_state(
2031                    window,
2032                    cx,
2033                    [CommandEntry::Item(
2034                        CommandItem::new().label("theme").checked(true),
2035                    )],
2036                )
2037            });
2038            let unchecked_width = unchecked_width.clone();
2039            let checked_width = checked_width.clone();
2040            (
2041                cx.new(|_| CheckIconWidthHarness {
2042                    state: unchecked_state,
2043                    width: unchecked_width,
2044                }),
2045                cx.new(|_| CheckIconWidthHarness {
2046                    state: checked_state,
2047                    width: checked_width,
2048                }),
2049            )
2050        });
2051
2052        cx.draw(
2053            gpui::point(px(0.), px(0.)),
2054            gpui::AvailableSpace::min_size(),
2055            move |_, _| unchecked.into_any_element(),
2056        );
2057
2058        cx.draw(
2059            gpui::point(px(0.), px(0.)),
2060            gpui::AvailableSpace::min_size(),
2061            move |_, _| checked.into_any_element(),
2062        );
2063
2064        assert_eq!(
2065            checked_width.get().unwrap() - unchecked_width.get().unwrap(),
2066            px(20.)
2067        );
2068    }
2069
2070    struct CheckIconWidthHarness {
2071        state: Entity<CommandState>,
2072        width: Rc<Cell<Option<gpui::Pixels>>>,
2073    }
2074
2075    impl Render for CheckIconWidthHarness {
2076        fn render(
2077            &mut self,
2078            window: &mut Window,
2079            cx: &mut gpui::Context<Self>,
2080        ) -> impl IntoElement {
2081            let width = self.width.clone();
2082            let item = self.state.update(cx, |state, cx| {
2083                state.update_matches(cx);
2084                state.render_item(0, window, cx)
2085            });
2086
2087            div()
2088                .on_children_prepainted(move |bounds, _, _| width.set(Some(bounds[0].size.width)))
2089                .child(item)
2090        }
2091    }
2092
2093    struct Harness {
2094        state: Entity<CommandState>,
2095        command: Rc<dyn Fn(&Entity<CommandState>) -> Command>,
2096    }
2097
2098    impl Render for Harness {
2099        fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
2100            div()
2101                .size_full()
2102                .child((self.command)(&self.state).max_h(px(200.)))
2103        }
2104    }
2105
2106    #[gpui::test]
2107    fn header_and_footer_render_with_current_state(cx: &mut TestAppContext) {
2108        cx.update(crate::init);
2109        let header_calls = Rc::new(Cell::new(0));
2110        let footer_calls = Rc::new(Cell::new(0));
2111        let header_matched_count = Rc::new(Cell::new(None));
2112        let footer_matched_count = Rc::new(Cell::new(None));
2113
2114        let (harness, cx) = cx.add_window_view(|window, cx| HeaderFooterHarness {
2115            state: cx.new(|cx| CommandState::new(window, cx)),
2116            header_calls,
2117            footer_calls,
2118            header_matched_count,
2119            footer_matched_count,
2120        });
2121
2122        cx.run_until_parked();
2123        cx.update(|window, cx| _ = window.draw(cx));
2124
2125        let (header_calls, footer_calls, header_matched_count, footer_matched_count) =
2126            cx.update(|_, cx| {
2127                let harness = harness.read(cx);
2128                (
2129                    harness.header_calls.get(),
2130                    harness.footer_calls.get(),
2131                    harness.header_matched_count.get(),
2132                    harness.footer_matched_count.get(),
2133                )
2134            });
2135        assert!(header_calls > 0);
2136        assert!(footer_calls > 0);
2137        assert_eq!(header_matched_count, Some(2));
2138        assert_eq!(footer_matched_count, Some(2));
2139    }
2140
2141    #[gpui::test]
2142    fn custom_empty_slot_renders_with_current_state(cx: &mut TestAppContext) {
2143        cx.update(crate::init);
2144        let empty_calls = Rc::new(Cell::new(0));
2145        let empty_matched_count = Rc::new(Cell::new(None));
2146        let calls = empty_calls.clone();
2147        let matched_count = empty_matched_count.clone();
2148        let (_harness, cx) = cx.add_window_view(move |window, cx| Harness {
2149            state: cx.new(|cx| CommandState::new(window, cx)),
2150            command: Rc::new(move |state| {
2151                let calls = calls.clone();
2152                let matched_count = matched_count.clone();
2153                Command::new(state).empty(
2154                    move |state: &CommandState, _: &mut Window, _: &mut gpui::App| {
2155                        calls.set(calls.get() + 1);
2156                        matched_count.set(Some(state.matched_count()));
2157                        div().child("Custom empty")
2158                    },
2159                )
2160            }),
2161        });
2162
2163        cx.run_until_parked();
2164        cx.update(|window, cx| _ = window.draw(cx));
2165
2166        assert!(empty_calls.get() > 0);
2167        assert_eq!(empty_matched_count.get(), Some(0));
2168    }
2169
2170    fn entries_with_late_first_enabled_item() -> Vec<CommandEntry> {
2171        vec![
2172            CommandGroup::new()
2173                .label("Disabled")
2174                .items((0..30).map(|ix| {
2175                    CommandItem::new()
2176                        .label(format!("disabled-{ix}"))
2177                        .keywords(["match"])
2178                        .disabled(true)
2179                }))
2180                .into(),
2181            CommandEntry::Separator,
2182            CommandGroup::new()
2183                .label("Enabled")
2184                .item(CommandItem::new().label("enabled").keywords(["match"]))
2185                .into(),
2186        ]
2187    }
2188
2189    fn assert_first_enabled_row_is_scrolled_into_view(
2190        state: &Entity<CommandState>,
2191        cx: &mut TestAppContext,
2192    ) {
2193        let (selected_row, offset) = state.read_with(cx, |state, _| {
2194            (
2195                state.matched[state.selected_index.unwrap()].row_ix,
2196                state.scroll_handle.base_handle().offset().y,
2197            )
2198        });
2199
2200        assert!(selected_row > 30);
2201        assert!(
2202            offset < px(-900.),
2203            "the list should scroll to the selected row, not row zero ({offset:?})",
2204        );
2205    }
2206
2207    #[gpui::test]
2208    fn first_enabled_selection_resets_scroll_to_its_late_row(cx: &mut TestAppContext) {
2209        cx.update(crate::init);
2210        let (harness, cx) = cx.add_window_view(|window, cx| Harness {
2211            state: cx.new(|cx| CommandState::new(window, cx)),
2212            command: Rc::new(|state| {
2213                command_with_entries(state, entries_with_late_first_enabled_item())
2214            }),
2215        });
2216        let state = cx.update(|_, cx| harness.read(cx).state.clone());
2217
2218        cx.run_until_parked();
2219        cx.update(|window, cx| _ = window.draw(cx));
2220        assert_first_enabled_row_is_scrolled_into_view(&state, cx);
2221
2222        cx.update(|window, cx| {
2223            state.update(cx, |state, cx| state.set_query("match", window, cx));
2224            _ = window.draw(cx);
2225        });
2226        assert_first_enabled_row_is_scrolled_into_view(&state, cx);
2227
2228        cx.update(|window, cx| {
2229            harness.update(cx, |_, cx| {
2230                cx.notify();
2231            });
2232            _ = window.draw(cx);
2233        });
2234        assert_first_enabled_row_is_scrolled_into_view(&state, cx);
2235    }
2236
2237    struct HeaderFooterHarness {
2238        state: Entity<CommandState>,
2239        header_calls: Rc<Cell<usize>>,
2240        footer_calls: Rc<Cell<usize>>,
2241        header_matched_count: Rc<Cell<Option<usize>>>,
2242        footer_matched_count: Rc<Cell<Option<usize>>>,
2243    }
2244
2245    impl Render for HeaderFooterHarness {
2246        fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
2247            let header_calls = self.header_calls.clone();
2248            let header_matched_count = self.header_matched_count.clone();
2249            let footer_calls = self.footer_calls.clone();
2250            let footer_matched_count = self.footer_matched_count.clone();
2251
2252            div().size_full().child(
2253                Command::new(&self.state)
2254                    .items([
2255                        CommandItem::new().label("Calendar"),
2256                        CommandItem::new().label("Calculator"),
2257                    ])
2258                    .max_h(px(200.))
2259                    .header(move |state, _, _| {
2260                        header_calls.set(header_calls.get() + 1);
2261                        header_matched_count.set(Some(state.matched_count()));
2262                        div()
2263                    })
2264                    .footer(move |state, _, _| {
2265                        footer_calls.set(footer_calls.get() + 1);
2266                        footer_matched_count.set(Some(state.matched_count()));
2267                        div()
2268                    }),
2269            )
2270        }
2271    }
2272
2273    struct PaddedHarness {
2274        state: Entity<CommandState>,
2275    }
2276
2277    impl Render for PaddedHarness {
2278        fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
2279            div().size_full().child(
2280                Command::new(&self.state)
2281                    .item(
2282                        CommandItem::new()
2283                            .label("fixed")
2284                            .child(|_, _| div().h(px(32.))),
2285                    )
2286                    .max_h(px(200.))
2287                    .p_4(),
2288            )
2289        }
2290    }
2291
2292    struct WrappingHarness {
2293        state: Entity<CommandState>,
2294        width: Pixels,
2295        no_wrap: bool,
2296    }
2297
2298    impl Render for WrappingHarness {
2299        fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
2300            div().size_full().child(
2301                div().w(self.width).child(
2302                    Command::new(&self.state)
2303                        .item(CommandItem::new().label("wrapped").child(|_, _| {
2304                            div()
2305                                .w_full()
2306                                .child("A command row whose content wraps at narrow list widths")
2307                        }))
2308                        .max_h(px(200.))
2309                        .when(self.no_wrap, |this| this.whitespace_nowrap()),
2310                ),
2311            )
2312        }
2313    }
2314
2315    #[gpui::test]
2316    fn wrapping_rows_remeasure_for_the_list_content_width(cx: &mut TestAppContext) {
2317        cx.update(crate::init);
2318
2319        let (harness, cx) = cx.add_window_view(|window, cx| WrappingHarness {
2320            state: cx.new(|cx| CommandState::new(window, cx)),
2321            width: px(360.),
2322            no_wrap: false,
2323        });
2324
2325        cx.run_until_parked();
2326        cx.update(|window, cx| _ = window.draw(cx));
2327        cx.run_until_parked();
2328        cx.update(|window, cx| _ = window.draw(cx));
2329
2330        let wide = cx.update(|_, cx| harness.read(cx).state.read(cx).row_sizes[0].height);
2331
2332        cx.update(|_, cx| {
2333            harness.update(cx, |harness, cx| {
2334                harness.width = px(120.);
2335                cx.notify();
2336            })
2337        });
2338        cx.run_until_parked();
2339        cx.update(|window, cx| _ = window.draw(cx));
2340        cx.run_until_parked();
2341        cx.update(|window, cx| _ = window.draw(cx));
2342        let narrow = cx.update(|_, cx| harness.read(cx).state.read(cx).row_sizes[0].height);
2343
2344        assert!(
2345            narrow > wide,
2346            "the narrow list should cache a taller wrapped row ({narrow:?} vs {wide:?})",
2347        );
2348    }
2349
2350    #[gpui::test]
2351    fn wrapping_rows_remeasure_when_rem_size_changes(cx: &mut TestAppContext) {
2352        cx.update(crate::init);
2353
2354        let (harness, cx) = cx.add_window_view(|window, cx| {
2355            window.set_rem_size(px(20.));
2356            WrappingHarness {
2357                state: cx.new(|cx| CommandState::new(window, cx)),
2358                width: px(160.),
2359                no_wrap: false,
2360            }
2361        });
2362
2363        cx.run_until_parked();
2364        cx.update(|window, cx| _ = window.draw(cx));
2365        cx.run_until_parked();
2366        cx.update(|window, cx| _ = window.draw(cx));
2367        let smaller_rem = cx.update(|_, cx| harness.read(cx).state.read(cx).row_sizes[0].height);
2368
2369        cx.update(|window, cx| {
2370            window.set_rem_size(px(28.));
2371            _ = window.draw(cx);
2372        });
2373        cx.run_until_parked();
2374        cx.update(|window, cx| _ = window.draw(cx));
2375        let larger_rem = cx.update(|_, cx| harness.read(cx).state.read(cx).row_sizes[0].height);
2376
2377        assert!(
2378            larger_rem > smaller_rem,
2379            "a larger rem should remeasure the fixed-width wrapped row ({larger_rem:?} vs {smaller_rem:?})",
2380        );
2381    }
2382
2383    #[gpui::test]
2384    fn wrapping_rows_remeasure_when_inherited_typography_changes(cx: &mut TestAppContext) {
2385        cx.update(crate::init);
2386
2387        let (harness, cx) = cx.add_window_view(|window, cx| WrappingHarness {
2388            state: cx.new(|cx| CommandState::new(window, cx)),
2389            width: px(160.),
2390            no_wrap: false,
2391        });
2392
2393        cx.run_until_parked();
2394        cx.update(|window, cx| _ = window.draw(cx));
2395        cx.run_until_parked();
2396        cx.update(|window, cx| _ = window.draw(cx));
2397        let wrapped_height = cx.update(|_, cx| harness.read(cx).state.read(cx).row_sizes[0].height);
2398
2399        cx.update(|window, cx| {
2400            harness.update(cx, |harness, cx| {
2401                harness.no_wrap = true;
2402                cx.notify();
2403            });
2404            _ = window.draw(cx);
2405        });
2406        cx.run_until_parked();
2407        cx.update(|window, cx| _ = window.draw(cx));
2408        let no_wrap_height = cx.update(|_, cx| harness.read(cx).state.read(cx).row_sizes[0].height);
2409        assert!(
2410            no_wrap_height < wrapped_height,
2411            "a changed inherited typography should remeasure the fixed-width row ({no_wrap_height:?} vs {wrapped_height:?})",
2412        );
2413    }
2414
2415    #[gpui::test]
2416    fn outer_command_padding_does_not_inflate_measured_row_heights(cx: &mut TestAppContext) {
2417        cx.update(crate::init);
2418
2419        let (harness, cx) = cx.add_window_view(|window, cx| PaddedHarness {
2420            state: cx.new(|cx| CommandState::new(window, cx)),
2421        });
2422
2423        cx.run_until_parked();
2424        cx.update(|window, cx| _ = window.draw(cx));
2425        cx.run_until_parked();
2426        cx.update(|window, cx| _ = window.draw(cx));
2427        let height = cx.update(|_, cx| harness.read(cx).state.read(cx).row_sizes[0].height);
2428
2429        assert_eq!(height, px(44.));
2430    }
2431
2432    #[gpui::test]
2433    fn custom_rows_keep_independent_heights(cx: &mut TestAppContext) {
2434        cx.update(crate::init);
2435
2436        let (harness, cx) = cx.add_window_view(|window, cx| Harness {
2437            state: cx.new(|cx| CommandState::new(window, cx)),
2438            command: Rc::new(|state| {
2439                Command::new(state)
2440                    .group(
2441                        CommandGroup::new().label("Short").item(
2442                            CommandItem::new()
2443                                .label("short")
2444                                .child(|_, _| div().h(px(32.))),
2445                        ),
2446                    )
2447                    .separator()
2448                    .group(
2449                        CommandGroup::new().label("Tall").item(
2450                            CommandItem::new()
2451                                .label("tall")
2452                                .child(|_, _| div().h(px(72.))),
2453                        ),
2454                    )
2455            }),
2456        });
2457
2458        cx.run_until_parked();
2459        cx.update(|window, cx| _ = window.draw(cx));
2460        let row_sizes = cx.update(|_, cx| harness.read(cx).state.read(cx).row_sizes.clone());
2461
2462        assert_eq!(row_sizes.len(), 5);
2463        assert!(row_sizes[0].height > px(0.));
2464        assert_eq!(row_sizes[1].height, px(44.));
2465        assert_eq!(row_sizes[2].height, px(SEPARATOR_ROW_HEIGHT));
2466        assert!(row_sizes[3].height > px(0.));
2467        assert_eq!(row_sizes[4].height, px(84.));
2468    }
2469
2470    #[gpui::test]
2471    fn reinstalling_a_model_preserves_selection_by_index_path_and_remeasures_rows(
2472        cx: &mut TestAppContext,
2473    ) {
2474        cx.update(crate::init);
2475        let reversed = Rc::new(Cell::new(false));
2476        let reversed_for_render = reversed.clone();
2477        let (harness, cx) = cx.add_window_view(|window, cx| Harness {
2478            state: cx.new(|cx| CommandState::new(window, cx)),
2479            command: Rc::new(move |state| {
2480                if reversed_for_render.get() {
2481                    Command::new(state)
2482                        .item(
2483                            CommandItem::new()
2484                                .label("beta")
2485                                .child(|_, _| div().h(px(72.))),
2486                        )
2487                        .item(
2488                            CommandItem::new()
2489                                .label("alpha")
2490                                .child(|_, _| div().h(px(32.))),
2491                        )
2492                } else {
2493                    Command::new(state)
2494                        .item(
2495                            CommandItem::new()
2496                                .label("alpha")
2497                                .child(|_, _| div().h(px(32.))),
2498                        )
2499                        .item(
2500                            CommandItem::new()
2501                                .label("beta")
2502                                .child(|_, _| div().h(px(72.))),
2503                        )
2504                }
2505            }),
2506        });
2507        let state = cx.update(|_, cx| harness.read(cx).state.clone());
2508
2509        cx.run_until_parked();
2510        cx.update(|window, cx| _ = window.draw(cx));
2511        cx.update(|window, cx| {
2512            state.update(cx, |state, cx| state.select_by(1, window, cx));
2513        });
2514        assert_eq!(
2515            state.read_with(cx, |state, _| state.selected_index()),
2516            Some(IndexPath::new(1).section(0)),
2517        );
2518
2519        reversed.set(true);
2520        cx.update(|window, cx| {
2521            harness.update(cx, |_, cx| cx.notify());
2522            _ = window.draw(cx);
2523        });
2524
2525        let (selected_matched_index, selected_index, row_sizes) =
2526            state.read_with(cx, |state, _| {
2527                (
2528                    state.selected_index,
2529                    state.selected_index(),
2530                    state.row_sizes.clone(),
2531                )
2532            });
2533        assert_eq!(selected_matched_index, Some(1));
2534        assert_eq!(selected_index, Some(IndexPath::new(1).section(0)));
2535        assert_eq!(row_sizes[0].height, px(84.));
2536        assert_eq!(row_sizes[1].height, px(44.));
2537    }
2538
2539    #[gpui::test]
2540    fn a_state_redraw_reuses_the_installed_custom_row_measurement(cx: &mut TestAppContext) {
2541        cx.update(crate::init);
2542        let renders = Rc::new(Cell::new(0));
2543        let count = renders.clone();
2544        let cx = cx.add_empty_window();
2545        let state = cx.update(|window, cx| {
2546            cx.new(|cx| {
2547                command_state(
2548                    window,
2549                    cx,
2550                    [CommandEntry::Item(
2551                        CommandItem::new().label("custom").child(move |_, _| {
2552                            count.set(count.get() + 1);
2553                            div().child("Custom")
2554                        }),
2555                    )],
2556                )
2557            })
2558        });
2559
2560        let first_state = state.clone();
2561        cx.draw(
2562            gpui::point(px(0.), px(0.)),
2563            gpui::AvailableSpace::min_size(),
2564            move |_, _| first_state.into_any_element(),
2565        );
2566        let settled_state = state.clone();
2567        cx.draw(
2568            gpui::point(px(0.), px(0.)),
2569            gpui::AvailableSpace::min_size(),
2570            move |_, _| settled_state.into_any_element(),
2571        );
2572        let after_first_draw = renders.get();
2573        cx.draw(
2574            gpui::point(px(0.), px(0.)),
2575            gpui::AvailableSpace::min_size(),
2576            move |_, _| state.into_any_element(),
2577        );
2578
2579        assert_eq!(renders.get() - after_first_draw, 2);
2580    }
2581
2582    #[gpui::test]
2583    fn moving_past_the_visible_rows_scrolls_the_list(cx: &mut TestAppContext) {
2584        cx.update(crate::init);
2585
2586        let (harness, cx) = cx.add_window_view(|window, cx| Harness {
2587            state: cx.new(|cx| CommandState::new(window, cx)),
2588            command: Rc::new(|state| {
2589                Command::new(state)
2590                    .items((0..50).map(|ix| CommandItem::new().label(format!("Item {ix}"))))
2591            }),
2592        });
2593
2594        cx.run_until_parked();
2595        cx.update(|window, cx| _ = window.draw(cx));
2596
2597        let state = cx.update(|_, cx| harness.read(cx).state.clone());
2598        assert_eq!(
2599            state.read_with(cx, |state, _| state.scroll_handle.base_handle().offset().y),
2600            px(0.),
2601        );
2602
2603        // The list is capped well below 50 rows, so walking to the last one has
2604        // to bring the viewport with it.
2605        cx.update(|window, cx| {
2606            state.update(cx, |state, cx| {
2607                for _ in 0..49 {
2608                    state.select_by(1, window, cx);
2609                }
2610            })
2611        });
2612        cx.update(|window, cx| _ = window.draw(cx));
2613
2614        assert_eq!(
2615            state.read_with(cx, |state, _| state.selected_index()),
2616            Some(IndexPath::new(49).section(0))
2617        );
2618        assert!(
2619            state.read_with(cx, |state, _| state.scroll_handle.base_handle().offset().y) < px(0.),
2620            "selecting the last row should have scrolled the list",
2621        );
2622    }
2623
2624    #[gpui::test]
2625    fn a_reinstalled_model_does_not_scroll_a_preserved_selection(cx: &mut TestAppContext) {
2626        cx.update(crate::init);
2627
2628        let (harness, cx) = cx.add_window_view(|window, cx| Harness {
2629            state: cx.new(|cx| CommandState::new(window, cx)),
2630            command: Rc::new(|state| {
2631                Command::new(state)
2632                    .items((0..50).map(|ix| CommandItem::new().label(format!("Item {ix}"))))
2633            }),
2634        });
2635
2636        cx.run_until_parked();
2637        cx.update(|window, cx| _ = window.draw(cx));
2638
2639        let state = cx.update(|_, cx| harness.read(cx).state.clone());
2640
2641        // Hover selection does not scroll, and the host re-render it notifies
2642        // reinstalls the model with the selection preserved. That reinstall
2643        // must not scroll either, or the hover still moves the list one frame
2644        // later.
2645        cx.update(|window, cx| {
2646            state.update(cx, |state, cx| state.select(10, window, cx));
2647        });
2648        cx.update(|window, cx| _ = window.draw(cx));
2649
2650        assert_eq!(
2651            state.read_with(cx, |state, _| state.scroll_handle.base_handle().offset().y),
2652            px(0.),
2653            "reinstalling the model must keep the scroll position",
2654        );
2655    }
2656}