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    /// Row corner radius, concentric with the frame: the list's `p_1` (plus the
704    /// border when bordered) sits between a row and the frame's corner. A
705    /// borderless command without its own radius keeps the theme radius, since
706    /// its frame belongs to the host.
707    fn item_radius(&self, window: &Window, cx: &App) -> Pixels {
708        let theme = cx.theme();
709        let rem_size = window.rem_size();
710        let own_radius = self.options.style.corner_radii.top_left;
711        let (outer, border) = match (self.options.bordered, own_radius) {
712            (true, radius) => (
713                radius.map_or(theme.radius_lg, |r| r.to_pixels(rem_size)),
714                px(1.),
715            ),
716            (false, Some(radius)) => (radius.to_pixels(rem_size), px(0.)),
717            (false, None) => return theme.radius,
718        };
719        (outer - rem_size * 0.25 - border).max(px(0.))
720    }
721
722    /// The frame every item row shares, so that the measured height matches the
723    /// rendered one.
724    fn item_row(&self, selected: bool, window: &Window, cx: &App) -> gpui::Div {
725        div()
726            .flex()
727            .flex_row()
728            .items_center()
729            .w_full()
730            .gap_2()
731            .px_2()
732            .py_1p5()
733            .text_sm()
734            .rounded(self.item_radius(window, cx))
735            .when(selected, |this| {
736                this.bg(cx.theme().accent)
737                    .text_color(cx.theme().accent_foreground)
738            })
739    }
740
741    fn heading_row(&self, heading: SharedString, cx: &App) -> gpui::Div {
742        div()
743            .w_full()
744            .px_2()
745            .py_1p5()
746            .text_xs()
747            .font_medium()
748            .text_color(cx.theme().muted_foreground)
749            .child(heading)
750    }
751
752    fn render_row(&self, row_ix: usize, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
753        match self.rows.get(row_ix) {
754            None => div().into_any_element(),
755            Some(CommandRow::Separator) => div()
756                .w_full()
757                .py(px(4.))
758                .child(div().h(px(1.)).w_full().bg(cx.theme().border))
759                .into_any_element(),
760            Some(CommandRow::Heading(heading)) => {
761                self.heading_row(heading.clone(), cx).into_any_element()
762            }
763            Some(CommandRow::Item(matched_ix)) => self.render_item(*matched_ix, window, cx),
764        }
765    }
766
767    /// Render a row the virtual list shows. When an item's keybinding hint no
768    /// longer matches the one it was measured with, measure the rows again.
769    fn render_visible_row(
770        &mut self,
771        row_ix: usize,
772        window: &mut Window,
773        cx: &mut Context<Self>,
774    ) -> AnyElement {
775        let Some(&CommandRow::Item(matched_ix)) = self.rows.get(row_ix) else {
776            return self.render_row(row_ix, window, cx);
777        };
778
779        let binding = self.item_binding(matched_ix, window, cx);
780        let measured = self
781            .measured_bindings
782            .get(matched_ix)
783            .and_then(Option::as_ref);
784        if !self.needs_measure && measured != binding.as_ref().map(Kbd::keystroke) {
785            self.measure_on_next_frame(window, cx);
786        }
787
788        self.render_item_with_binding(matched_ix, binding, window, cx)
789    }
790
791    /// The keybinding hint an item shows in its trailing slot. A custom child
792    /// owns its complete presentation, so it has none.
793    fn item_binding(&self, matched_ix: usize, window: &Window, cx: &App) -> Option<Kbd> {
794        let item = self.item_at(matched_ix)?;
795        if item.content.is_some() {
796            return None;
797        }
798
799        let action = item.action.as_ref()?;
800        Kbd::binding_for_action_in(action.as_ref(), &self.focus_handle(cx), window)
801            .or_else(|| Kbd::binding_for_action(action.as_ref(), None, window))
802    }
803
804    fn render_item(
805        &self,
806        matched_ix: usize,
807        window: &mut Window,
808        cx: &mut Context<Self>,
809    ) -> AnyElement {
810        let binding = self.item_binding(matched_ix, window, cx);
811        self.render_item_with_binding(matched_ix, binding, window, cx)
812    }
813
814    fn render_item_with_binding(
815        &self,
816        matched_ix: usize,
817        binding: Option<Kbd>,
818        window: &mut Window,
819        cx: &mut Context<Self>,
820    ) -> AnyElement {
821        let Some(item) = self.item_at(matched_ix) else {
822            return div().into_any_element();
823        };
824
825        let disabled = item.is_disabled();
826        let selected = self.selected_index == Some(matched_ix) && !disabled;
827        let muted_foreground = cx.theme().muted_foreground;
828        let icon_color = if selected {
829            cx.theme().accent_foreground
830        } else {
831            muted_foreground
832        };
833        let content = match &item.content {
834            Some(render) => render(window, cx),
835            None => h_flex()
836                .flex_1()
837                .gap_2()
838                .items_center()
839                .when_some(item.icon.clone(), |this, icon| {
840                    this.child(icon.size_4().text_color(icon_color))
841                })
842                .when_some(item.label_text().cloned(), |this, label| this.child(label))
843                .into_any_element(),
844        };
845
846        self.item_row(selected, window, cx)
847            .id(self.matched[matched_ix].index_path)
848            .test_support()
849            .role(Role::ListBoxOption)
850            .aria_selected(selected)
851            .when(disabled, |this| this.text_color(muted_foreground))
852            .when(!disabled, |this| {
853                this.cursor_default()
854                    .on_hover(cx.listener(move |this, hovered: &bool, window, cx| {
855                        if *hovered {
856                            this.select(matched_ix, window, cx);
857                        }
858                    }))
859                    .on_click(cx.listener(move |this, _, window, cx| {
860                        this.confirm(matched_ix, window, cx);
861                    }))
862            })
863            .child(content)
864            .map(|this| match binding {
865                Some(binding) => this.child(binding.ml_auto()),
866                // The binding owns the trailing slot, so only an item without
867                // one can show its check there.
868                None => this.when(item.checked, |this| {
869                    this.child(crate::Sizable::xsmall(Icon::new(IconName::Check).ml_auto()))
870                }),
871            })
872            .into_any_element()
873    }
874
875    fn render_empty(&self, window: &mut Window, cx: &mut App) -> AnyElement {
876        if let Some(empty) = self.options.empty.as_ref() {
877            return empty(self, window, cx);
878        }
879
880        let message: SharedString = t!("Command.empty").to_string().into();
881
882        div()
883            .py_6()
884            .w_full()
885            .text_center()
886            .text_sm()
887            .text_color(cx.theme().muted_foreground)
888            .child(message)
889            .into_any_element()
890    }
891}
892
893impl Focusable for CommandState {
894    fn focus_handle(&self, cx: &App) -> FocusHandle {
895        if self.model.searchable {
896            self.query_input.focus_handle(cx)
897        } else {
898            self.focus_handle.clone()
899        }
900    }
901}
902
903impl Render for CommandState {
904    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
905        self.sync_placeholder(window, cx);
906
907        if self.needs_measure {
908            self.needs_measure = false;
909            self.measure_rows(window, cx);
910        }
911
912        if let Some(row_ix) = self.pending_scroll.take() {
913            self.scroll_handle
914                .scroll_to_item(row_ix, ScrollStrategy::Nearest);
915        }
916
917        let rows_count = self.rows.len();
918        let row_sizes = self.row_sizes.clone();
919        let command_state = cx.entity();
920
921        v_flex()
922            .id("command")
923            .test_support()
924            .key_context(CONTEXT)
925            .track_focus(&self.focus_handle)
926            .on_action(cx.listener(Self::on_action_select_up))
927            .on_action(cx.listener(Self::on_action_select_down))
928            .on_action(cx.listener(Self::on_action_confirm))
929            .on_action(cx.listener(Self::on_action_cancel))
930            .w_full()
931            .overflow_hidden()
932            .bg(cx.theme().popover)
933            .text_color(cx.theme().popover_foreground)
934            .when(self.options.bordered, |this| {
935                this.rounded(cx.theme().radius_lg)
936                    .border_1()
937                    .border_color(cx.theme().border)
938            })
939            .refine_style(&self.options.style)
940            .when_some(self.options.header.as_ref(), |this, header| {
941                this.child(header(self, window, cx))
942            })
943            .when(self.model.searchable, |this| {
944                this.child(
945                    div()
946                        .flex_none()
947                        .px_3()
948                        .border_b_1()
949                        .border_color(cx.theme().border)
950                        .child(
951                            Input::new(&self.query_input)
952                                .prefix(
953                                    Icon::new(IconName::Search)
954                                        .text_color(cx.theme().muted_foreground),
955                                )
956                                .appearance(false)
957                                .p_0(),
958                        ),
959                )
960            })
961            .child(
962                v_flex()
963                    .id("command-list-container")
964                    .role(Role::ListBox)
965                    .relative()
966                    .flex_1()
967                    // The rows carry their inset on the virtual list itself so
968                    // that a mid-scroll clip edge sits flush against the
969                    // surrounding dividers; only the empty slot needs the
970                    // container padding.
971                    .when(rows_count == 0, |this| this.p_1())
972                    .on_prepaint({
973                        let measure_state = command_state.clone();
974                        move |bounds, window, cx| {
975                            measure_state.update(cx, |state, cx| {
976                                // The list's `p_1` is one quarter rem on each
977                                // side. Its rem-dependent padding and inherited
978                                // layout-relevant text style participate in
979                                // the row-size cache key.
980                                let text_style = window.text_style();
981                                state.set_list_measurement_key(
982                                    ListMeasurementKey {
983                                        content_width: (bounds.size.width
984                                            - window.rem_size() * 0.5)
985                                            .max(px(0.)),
986                                        rem_size: window.rem_size(),
987                                        line_height: window.line_height(),
988                                        text_shape: TextShapeKey {
989                                            font_family: text_style.font_family,
990                                            font_features: text_style.font_features,
991                                            font_fallbacks: text_style.font_fallbacks,
992                                            font_size: text_style.font_size,
993                                            font_weight: text_style.font_weight,
994                                            font_style: text_style.font_style,
995                                            white_space: text_style.white_space,
996                                            text_overflow: text_style.text_overflow,
997                                            line_clamp: text_style.line_clamp,
998                                        },
999                                    },
1000                                    window,
1001                                    cx,
1002                                )
1003                            })
1004                        }
1005                    })
1006                    .max_h(self.options.max_h)
1007                    .overflow_hidden()
1008                    // While a search is in flight the list is empty because the
1009                    // answer has not arrived, which is not the same as no match.
1010                    .when(rows_count == 0 && !self.loading, |this| {
1011                        this.child(self.render_empty(window, cx))
1012                    })
1013                    .when(rows_count > 0, |this| {
1014                        this.child(
1015                            v_virtual_list(
1016                                command_state.clone(),
1017                                "command-list",
1018                                row_sizes,
1019                                move |this, visible_range, window, cx| {
1020                                    visible_range
1021                                        .map(|row_ix| this.render_visible_row(row_ix, window, cx))
1022                                        .collect::<Vec<_>>()
1023                                },
1024                            )
1025                            // Padding on the virtual list acts like CSS
1026                            // scroll-padding: the scroll ends keep their inset
1027                            // while scrolled-under rows paint and clip at the
1028                            // list edge.
1029                            .p_1()
1030                            .with_sizing_behavior(ListSizingBehavior::Infer)
1031                            .track_scroll(&self.scroll_handle)
1032                            .map(|list| {
1033                                gpui_base::ScrollBounce::new(
1034                                    "command-scroll-bounce",
1035                                    &self.scroll_handle,
1036                                    list,
1037                                )
1038                                .when_some(self.options.scroll_bounce, |bounce, on| {
1039                                    bounce.enabled(on)
1040                                })
1041                            }),
1042                        )
1043                        .child(Scrollbar::vertical(&self.scroll_handle))
1044                    }),
1045            )
1046            .when_some(self.options.footer.as_ref(), |this, footer| {
1047                this.child(footer(self, window, cx))
1048            })
1049    }
1050}
1051
1052// MARK: Tests
1053
1054#[cfg(test)]
1055mod tests {
1056    use std::{
1057        cell::{Cell, RefCell},
1058        rc::Rc,
1059    };
1060
1061    use gpui::{
1062        AppContext as _, AvailableSpace, Entity, InteractiveElement as _, IntoElement, KeyBinding,
1063        Modifiers, ParentElement as _, Pixels, Render, Styled as _, TestAppContext, Window,
1064        actions, div, point, prelude::FluentBuilder as _, px,
1065    };
1066
1067    use super::{CONTEXT, CommandModel, CommandRow, CommandState, SEPARATOR_ROW_HEIGHT};
1068    use crate::{
1069        Disableable as _, Icon, IconName, IndexPath,
1070        actions::{Cancel, Confirm, SelectDown},
1071        command::{Command, CommandEntry, CommandGroup, CommandItem},
1072    };
1073
1074    actions!(
1075        command_test,
1076        [GlobalTestItem, OpenTestItem, RemovePaletteTestItem]
1077    );
1078
1079    struct CommandActionsHarness {
1080        state: Entity<CommandState>,
1081        events: Rc<RefCell<Vec<String>>>,
1082    }
1083
1084    impl Render for CommandActionsHarness {
1085        fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
1086            let action_events = self.events.clone();
1087            let propagated_cancel_events = self.events.clone();
1088            let query_events = self.events.clone();
1089            let select_events = self.events.clone();
1090            let confirm_events = self.events.clone();
1091            let cancel_events = self.events.clone();
1092
1093            div()
1094                .size_full()
1095                .on_action(move |_: &OpenTestItem, _, _| {
1096                    action_events.borrow_mut().push("action".into());
1097                })
1098                .on_action(move |_: &Cancel, _, _| {
1099                    propagated_cancel_events
1100                        .borrow_mut()
1101                        .push("propagated_cancel".into());
1102                })
1103                .child(
1104                    Command::new(&self.state)
1105                        .item(
1106                            CommandItem::new()
1107                                .label("Item")
1108                                .keywords(["needle"])
1109                                .action(Box::new(OpenTestItem)),
1110                        )
1111                        .item(CommandItem::new().label("Item"))
1112                        .item(
1113                            CommandItem::new()
1114                                .label("Item")
1115                                .action(Box::new(GlobalTestItem)),
1116                        )
1117                        .on_query(move |query, _, _| {
1118                            query_events.borrow_mut().push(format!("query:{query}"));
1119                        })
1120                        .on_select(move |index, _, _| {
1121                            select_events
1122                                .borrow_mut()
1123                                .push(format!("select:{}:{}", index.section, index.row));
1124                        })
1125                        .on_confirm(move |index, _, _| {
1126                            confirm_events
1127                                .borrow_mut()
1128                                .push(format!("confirm:{}:{}", index.section, index.row));
1129                        })
1130                        .on_cancel(move |_, _| {
1131                            cancel_events.borrow_mut().push("cancel".into());
1132                        }),
1133                )
1134        }
1135    }
1136
1137    struct ReentrantCallbackHarness {
1138        state: Entity<CommandState>,
1139        events: Vec<String>,
1140    }
1141
1142    impl Render for ReentrantCallbackHarness {
1143        fn render(&mut self, _: &mut Window, cx: &mut gpui::Context<Self>) -> impl IntoElement {
1144            let select_owner = cx.weak_entity();
1145            let query_owner = cx.weak_entity();
1146            let confirm_owner = cx.weak_entity();
1147
1148            Command::new(&self.state)
1149                .item(CommandItem::new().label("alpha"))
1150                .item(CommandItem::new().label("beta"))
1151                .on_select(move |index, _, cx| {
1152                    _ = select_owner.update(cx, |harness, cx| {
1153                        assert_eq!(harness.state.read(cx).selected_index(), Some(index));
1154                        harness
1155                            .events
1156                            .push(format!("select:{}:{}", index.section, index.row));
1157                    });
1158                })
1159                .on_query(move |query, _, cx| {
1160                    _ = query_owner.update(cx, |harness, cx| {
1161                        assert_eq!(harness.state.read(cx).query(cx).as_ref(), query);
1162                        harness.events.push(format!("query:{query}"));
1163                    });
1164                })
1165                .on_confirm(move |index, _, cx| {
1166                    _ = confirm_owner.update(cx, |harness, cx| {
1167                        assert_eq!(harness.state.read(cx).selected_index(), Some(index));
1168                        harness
1169                            .events
1170                            .push(format!("confirm:{}:{}", index.section, index.row));
1171                    });
1172                })
1173        }
1174    }
1175
1176    #[gpui::test]
1177    fn query_and_selection_callbacks_run_after_the_state_lease_in_defined_order(
1178        cx: &mut TestAppContext,
1179    ) {
1180        cx.update(crate::init);
1181        let (harness, cx) = cx.add_window_view(|window, cx| ReentrantCallbackHarness {
1182            state: cx.new(|cx| CommandState::new(window, cx)),
1183            events: Vec::new(),
1184        });
1185        let state = cx.update(|_, cx| harness.read(cx).state.clone());
1186
1187        cx.run_until_parked();
1188        cx.update(|window, cx| {
1189            _ = window.draw(cx);
1190            state.update(cx, |state, cx| {
1191                state.selected_index = Some(1);
1192                state.set_query("alpha", window, cx);
1193            });
1194        });
1195
1196        assert_eq!(
1197            harness.read_with(cx, |harness, _| harness.events.clone()),
1198            ["select:0:0", "query:alpha"]
1199        );
1200    }
1201
1202    #[gpui::test]
1203    fn actionless_confirm_callback_runs_after_the_state_lease(cx: &mut TestAppContext) {
1204        cx.update(crate::init);
1205        let (harness, cx) = cx.add_window_view(|window, cx| ReentrantCallbackHarness {
1206            state: cx.new(|cx| CommandState::new(window, cx)),
1207            events: Vec::new(),
1208        });
1209        let state = cx.update(|_, cx| harness.read(cx).state.clone());
1210
1211        cx.run_until_parked();
1212        cx.update(|window, cx| {
1213            _ = window.draw(cx);
1214            state.update(cx, |state, cx| state.confirm(0, window, cx));
1215        });
1216
1217        assert_eq!(
1218            harness.read_with(cx, |harness, _| harness.events.clone()),
1219            ["confirm:0:0"]
1220        );
1221    }
1222
1223    struct CommandItemWidthHarness {
1224        state: Entity<CommandState>,
1225        matched_ix: usize,
1226        width: Rc<Cell<Option<Pixels>>>,
1227    }
1228
1229    impl Render for CommandItemWidthHarness {
1230        fn render(
1231            &mut self,
1232            window: &mut Window,
1233            cx: &mut gpui::Context<Self>,
1234        ) -> impl IntoElement {
1235            let width = self.width.clone();
1236            let item = self.state.update(cx, |state, cx| {
1237                state.render_item(self.matched_ix, window, cx)
1238            });
1239
1240            div()
1241                .on_children_prepainted(move |bounds, _, _| width.set(Some(bounds[0].size.width)))
1242                .child(item)
1243        }
1244    }
1245
1246    #[gpui::test]
1247    fn action_that_removes_command_state_still_confirms_after_dispatch(cx: &mut TestAppContext) {
1248        cx.update(crate::init);
1249        let events = Rc::new(RefCell::new(Vec::new()));
1250        let state_owner: Rc<RefCell<Option<Entity<CommandState>>>> = Rc::new(RefCell::new(None));
1251        let action_events = events.clone();
1252        let action_state_owner = state_owner.clone();
1253        cx.update(|cx| {
1254            cx.on_action(move |_: &RemovePaletteTestItem, _| {
1255                action_events.borrow_mut().push("action".into());
1256                action_state_owner.borrow_mut().take();
1257            });
1258        });
1259        let cx = cx.add_empty_window();
1260        cx.update(|window, cx| {
1261            let confirm_events = events.clone();
1262            let state = cx.new(|cx| {
1263                let mut state = CommandState::new(window, cx);
1264                state.install_model(
1265                    CommandModel {
1266                        entries: vec![CommandEntry::Item(
1267                            CommandItem::new()
1268                                .label("removed")
1269                                .action(Box::new(RemovePaletteTestItem)),
1270                        )],
1271                        searchable: false,
1272                        on_confirm: Some(Rc::new(move |index, _, _| {
1273                            confirm_events
1274                                .borrow_mut()
1275                                .push(format!("confirm:{}:{}", index.section, index.row));
1276                        })),
1277                        ..CommandModel::default()
1278                    },
1279                    cx,
1280                );
1281                state
1282            });
1283            *state_owner.borrow_mut() = Some(state.clone());
1284            state.update(cx, |state, cx| state.confirm(0, window, cx));
1285        });
1286        cx.run_until_parked();
1287
1288        assert!(state_owner.borrow().is_none());
1289        assert_eq!(events.borrow().as_slice(), ["action", "confirm:0:0"]);
1290    }
1291
1292    #[gpui::test]
1293    fn command_actions_and_callbacks_follow_defined_order(cx: &mut TestAppContext) {
1294        cx.update(|cx| {
1295            crate::init(cx);
1296            cx.bind_keys([
1297                KeyBinding::new("ctrl-o", OpenTestItem, Some(CONTEXT)),
1298                KeyBinding::new("ctrl-g", GlobalTestItem, None),
1299            ]);
1300        });
1301        let events = Rc::new(RefCell::new(Vec::new()));
1302        let (harness, cx) = cx.add_window_view(|window, cx| CommandActionsHarness {
1303            state: cx.new(|cx| CommandState::new(window, cx)),
1304            events: events.clone(),
1305        });
1306        let state = cx.update(|_, cx| harness.read(cx).state.clone());
1307
1308        cx.run_until_parked();
1309        cx.update(|window, cx| {
1310            _ = window.draw(cx);
1311            state.update(cx, |state, cx| state.focus(window, cx));
1312            _ = window.draw(cx);
1313        });
1314
1315        let action_width = Rc::new(Cell::new(None));
1316        let plain_width = Rc::new(Cell::new(None));
1317        let global_width = Rc::new(Cell::new(None));
1318        let (action_probe, plain_probe, global_probe) = cx.update(|_, cx| {
1319            (
1320                cx.new(|_| CommandItemWidthHarness {
1321                    state: state.clone(),
1322                    matched_ix: 0,
1323                    width: action_width.clone(),
1324                }),
1325                cx.new(|_| CommandItemWidthHarness {
1326                    state: state.clone(),
1327                    matched_ix: 1,
1328                    width: plain_width.clone(),
1329                }),
1330                cx.new(|_| CommandItemWidthHarness {
1331                    state: state.clone(),
1332                    matched_ix: 2,
1333                    width: global_width.clone(),
1334                }),
1335            )
1336        });
1337        cx.draw(
1338            point(px(0.), px(0.)),
1339            AvailableSpace::min_size(),
1340            move |_, _| action_probe.into_any_element(),
1341        );
1342        cx.draw(
1343            point(px(0.), px(0.)),
1344            AvailableSpace::min_size(),
1345            move |_, _| plain_probe.into_any_element(),
1346        );
1347        cx.draw(
1348            point(px(0.), px(0.)),
1349            AvailableSpace::min_size(),
1350            move |_, _| global_probe.into_any_element(),
1351        );
1352        let action_width = action_width.get().unwrap();
1353        let plain_width = plain_width.get().unwrap();
1354        let global_width = global_width.get().unwrap();
1355        assert!(
1356            action_width > plain_width,
1357            "the scoped Action binding should add a visible Kbd ({action_width:?} vs {plain_width:?})",
1358        );
1359        assert!(
1360            global_width > plain_width,
1361            "the app-level fallback binding should add a visible Kbd ({global_width:?} vs {plain_width:?})",
1362        );
1363
1364        cx.update(|window, cx| {
1365            state.update(cx, |state, cx| {
1366                state.set_query("needle", window, cx);
1367                state.set_query("needle", window, cx);
1368                state.set_query("", window, cx);
1369            });
1370            window.dispatch_action(Box::new(SelectDown), cx);
1371            window.dispatch_action(Box::new(crate::actions::SelectUp), cx);
1372            window.dispatch_action(Box::new(Confirm { secondary: false }), cx);
1373        });
1374        cx.run_until_parked();
1375
1376        assert_eq!(
1377            events.borrow().as_slice(),
1378            [
1379                "query:needle",
1380                "query:",
1381                "select:0:1",
1382                "select:0:0",
1383                "action",
1384                "confirm:0:0",
1385            ]
1386        );
1387
1388        cx.simulate_click(point(px(20.), px(52.)), Modifiers::default());
1389        cx.run_until_parked();
1390        cx.update(|window, cx| window.dispatch_action(Box::new(Cancel), cx));
1391        cx.run_until_parked();
1392
1393        assert_eq!(
1394            events.borrow().as_slice(),
1395            [
1396                "query:needle",
1397                "query:",
1398                "select:0:1",
1399                "select:0:0",
1400                "action",
1401                "confirm:0:0",
1402                "action",
1403                "confirm:0:0",
1404                "cancel",
1405                "propagated_cancel",
1406            ]
1407        );
1408    }
1409
1410    struct CommandOwnedEntriesHarness {
1411        state: Entity<CommandState>,
1412    }
1413
1414    impl Render for CommandOwnedEntriesHarness {
1415        fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
1416            Command::new(&self.state)
1417                .searchable(false)
1418                .item(CommandItem::new().label("alpha"))
1419                .group(
1420                    CommandGroup::new()
1421                        .label("Settings")
1422                        .item(CommandItem::new().label("beta")),
1423                )
1424                .separator()
1425                .item(
1426                    CommandItem::new()
1427                        .label("custom")
1428                        .child(|_, _| div().h(px(72.)).child("Custom")),
1429                )
1430        }
1431    }
1432
1433    #[gpui::test]
1434    fn command_owns_entries_and_lazy_item_content(cx: &mut TestAppContext) {
1435        cx.update(crate::init);
1436        let (harness, cx) = cx.add_window_view(|window, cx| CommandOwnedEntriesHarness {
1437            state: cx.new(|cx| CommandState::new(window, cx)),
1438        });
1439
1440        cx.run_until_parked();
1441        cx.update(|window, cx| _ = window.draw(cx));
1442
1443        let (labels, rows, row_sizes) = cx.update(|_, cx| {
1444            let state = harness.read(cx).state.read(cx);
1445            (
1446                (0..state.matched_count())
1447                    .map(|matched_ix| {
1448                        state
1449                            .item_at(matched_ix)
1450                            .unwrap()
1451                            .label_text()
1452                            .unwrap()
1453                            .clone()
1454                    })
1455                    .collect::<Vec<_>>(),
1456                state.rows.clone(),
1457                state.row_sizes.clone(),
1458            )
1459        });
1460
1461        assert_eq!(labels, ["alpha", "beta", "custom"]);
1462        assert!(matches!(
1463            rows.as_slice(),
1464            [
1465                CommandRow::Item(_),
1466                CommandRow::Heading(heading),
1467                CommandRow::Item(_),
1468                CommandRow::Separator,
1469                CommandRow::Item(_),
1470            ] if heading == "Settings"
1471        ));
1472        assert_eq!(row_sizes[4].height, px(84.));
1473    }
1474
1475    fn command_with_entries(
1476        state: &Entity<CommandState>,
1477        entries: impl IntoIterator<Item = CommandEntry>,
1478    ) -> Command {
1479        entries
1480            .into_iter()
1481            .fold(Command::new(state), |command, entry| match entry {
1482                CommandEntry::Item(item) => command.item(item),
1483                CommandEntry::Group(group) => command.group(group),
1484                CommandEntry::Separator => command.separator(),
1485            })
1486    }
1487
1488    fn command_state(
1489        window: &mut Window,
1490        cx: &mut gpui::Context<CommandState>,
1491        entries: impl IntoIterator<Item = CommandEntry>,
1492    ) -> CommandState {
1493        let mut state = CommandState::new(window, cx);
1494        state.install_model(
1495            CommandModel {
1496                entries: entries.into_iter().collect(),
1497                ..CommandModel::default()
1498            },
1499            cx,
1500        );
1501        state
1502    }
1503
1504    fn command_state_with_options(
1505        window: &mut Window,
1506        cx: &mut gpui::Context<CommandState>,
1507        entries: impl IntoIterator<Item = CommandEntry>,
1508        searchable: bool,
1509    ) -> CommandState {
1510        let mut state = CommandState::new(window, cx);
1511        state.install_model(
1512            CommandModel {
1513                entries: entries.into_iter().collect(),
1514                searchable,
1515                ..CommandModel::default()
1516            },
1517            cx,
1518        );
1519        state
1520    }
1521
1522    fn suggestion_entries() -> Vec<CommandEntry> {
1523        vec![
1524            CommandGroup::new()
1525                .label("Suggestions")
1526                .item(CommandItem::new().label("Calendar"))
1527                .item(CommandItem::new().label("Search Emoji"))
1528                .item(CommandItem::new().label("Calculator").disabled(true))
1529                .into(),
1530            CommandEntry::Separator,
1531            CommandGroup::new()
1532                .label("Settings")
1533                .item(CommandItem::new().label("Profile"))
1534                .item(CommandItem::new().label("Billing"))
1535                .into(),
1536        ]
1537    }
1538
1539    #[gpui::test]
1540    fn query_hides_the_groups_that_have_no_match(cx: &mut TestAppContext) {
1541        cx.update(crate::init);
1542        let cx = cx.add_empty_window();
1543
1544        cx.update(|window, cx| {
1545            let state = cx.new(|cx| command_state(window, cx, suggestion_entries()));
1546
1547            state.update(cx, |state, cx| {
1548                state.update_matches(cx);
1549                assert_eq!(state.matched_count(), 5);
1550                assert_eq!(
1551                    state
1552                        .rows
1553                        .iter()
1554                        .filter(|row| matches!(row, CommandRow::Heading(_)))
1555                        .count(),
1556                    2,
1557                );
1558                assert_eq!(
1559                    state
1560                        .rows
1561                        .iter()
1562                        .filter(|row| matches!(row, CommandRow::Separator))
1563                        .count(),
1564                    1,
1565                );
1566
1567                // "Bil" only matches an item of the second group, so the first
1568                // group's heading and the separator between them both go.
1569                state.set_query("Bil", window, cx);
1570                state.update_matches(cx);
1571
1572                assert_eq!(state.matched_count(), 1);
1573                assert_eq!(state.selected_index(), Some(IndexPath::new(1).section(1)));
1574                assert_eq!(
1575                    state
1576                        .rows
1577                        .iter()
1578                        .filter(|row| matches!(row, CommandRow::Separator))
1579                        .count(),
1580                    0,
1581                );
1582                assert!(matches!(state.rows.first(), Some(CommandRow::Heading(_))));
1583            });
1584        });
1585    }
1586
1587    fn custom_entries() -> Vec<CommandEntry> {
1588        vec![CommandEntry::Item(
1589            CommandItem::new().label("Custom").child(|_, _| div()),
1590        )]
1591    }
1592
1593    #[gpui::test]
1594    fn reinstalling_an_unchanged_model_keeps_the_measured_rows(cx: &mut TestAppContext) {
1595        cx.update(crate::init);
1596        let cx = cx.add_empty_window();
1597
1598        cx.update(|window, cx| {
1599            let state = cx.new(|cx| command_state(window, cx, suggestion_entries()));
1600
1601            state.update(cx, |state, cx| {
1602                // A host re-render rebuilds an equal model: nothing to lay out again.
1603                state.needs_measure = false;
1604                state.install_model(
1605                    CommandModel {
1606                        entries: suggestion_entries(),
1607                        ..CommandModel::default()
1608                    },
1609                    cx,
1610                );
1611                assert!(!state.needs_measure);
1612                assert_eq!(state.matched_count(), 5);
1613
1614                // A custom child can read state outside the item, so it is
1615                // always measured again.
1616                state.install_model(
1617                    CommandModel {
1618                        entries: custom_entries(),
1619                        ..CommandModel::default()
1620                    },
1621                    cx,
1622                );
1623                assert!(state.needs_measure);
1624                assert_eq!(state.matched_count(), 1);
1625
1626                // Even when the host clones entries it built once, which
1627                // keeps the same closure.
1628                state.needs_measure = false;
1629                let entries = state.model.entries.clone();
1630                state.install_model(
1631                    CommandModel {
1632                        entries,
1633                        ..CommandModel::default()
1634                    },
1635                    cx,
1636                );
1637                assert!(state.needs_measure);
1638
1639                // A changed label or icon style changes how the label wraps,
1640                // and a changed disabled flag changes what can be highlighted.
1641                let item = || CommandItem::new().label("Renamed");
1642                let icon = || Icon::new(IconName::Check);
1643                for entries in [
1644                    vec![CommandEntry::Item(item())],
1645                    vec![CommandEntry::Item(item().icon(icon()))],
1646                    vec![CommandEntry::Item(item().icon(icon().ml_2()))],
1647                    vec![CommandEntry::Item(
1648                        item().icon(icon().ml_2()).disabled(true),
1649                    )],
1650                ] {
1651                    state.needs_measure = false;
1652                    state.install_model(
1653                        CommandModel {
1654                            entries,
1655                            ..CommandModel::default()
1656                        },
1657                        cx,
1658                    );
1659                    assert!(state.needs_measure);
1660                }
1661                assert_eq!(state.selected_index(), None);
1662            });
1663        });
1664    }
1665
1666    #[gpui::test]
1667    fn a_query_that_matches_nothing_leaves_no_rows(cx: &mut TestAppContext) {
1668        cx.update(crate::init);
1669        let cx = cx.add_empty_window();
1670
1671        cx.update(|window, cx| {
1672            let state = cx.new(|cx| command_state(window, cx, suggestion_entries()));
1673
1674            state.update(cx, |state, cx| {
1675                state.set_query("zzz", window, cx);
1676                state.update_matches(cx);
1677
1678                assert_eq!(state.matched_count(), 0);
1679                assert!(state.rows.is_empty());
1680                assert_eq!(state.selected_index(), None);
1681            });
1682        });
1683    }
1684
1685    #[gpui::test]
1686    fn filterable_off_keeps_every_item_and_resets_the_highlight(cx: &mut TestAppContext) {
1687        cx.update(crate::init);
1688        let cx = cx.add_empty_window();
1689
1690        cx.update(|window, cx| {
1691            let state = cx.new(|cx| {
1692                let mut state = CommandState::new(window, cx);
1693                state.install_model(
1694                    CommandModel {
1695                        entries: suggestion_entries(),
1696                        filterable: false,
1697                        ..CommandModel::default()
1698                    },
1699                    cx,
1700                );
1701                state
1702            });
1703
1704            state.update(cx, |state, cx| {
1705                state.set_selected_index(Some(IndexPath::new(1).section(1)), window, cx);
1706
1707                // "Bil" would locally match only "Billing"; an unfiltered
1708                // palette keeps every row and hands the highlight back to the
1709                // first item instead of the textual match.
1710                state.set_query("Bil", window, cx);
1711
1712                assert_eq!(state.matched_count(), 5);
1713                assert_eq!(state.selected_index(), Some(IndexPath::new(0).section(0)));
1714            });
1715        });
1716    }
1717
1718    #[gpui::test]
1719    fn keywords_match_when_the_label_does_not(cx: &mut TestAppContext) {
1720        cx.update(crate::init);
1721        let cx = cx.add_empty_window();
1722
1723        cx.update(|window, cx| {
1724            let state = cx.new(|cx| {
1725                command_state(
1726                    window,
1727                    cx,
1728                    [CommandEntry::Item(
1729                        CommandItem::new().label("Profile").keywords(["account"]),
1730                    )],
1731                )
1732            });
1733
1734            state.update(cx, |state, cx| {
1735                state.set_query("account", window, cx);
1736                state.update_matches(cx);
1737
1738                assert_eq!(state.matched_count(), 1);
1739            });
1740        });
1741    }
1742
1743    #[gpui::test]
1744    fn non_searchable_command_keeps_every_item(cx: &mut TestAppContext) {
1745        cx.update(crate::init);
1746        let cx = cx.add_empty_window();
1747        cx.update(|window, cx| {
1748            let state = cx.new(|cx| {
1749                command_state_with_options(
1750                    window,
1751                    cx,
1752                    [
1753                        CommandEntry::Item(CommandItem::new().label("alpha")),
1754                        CommandEntry::Item(CommandItem::new().label("beta")),
1755                    ],
1756                    false,
1757                )
1758            });
1759            state.update(cx, |state, cx| {
1760                state.set_query("missing", window, cx);
1761                assert_eq!(state.matched_count(), 2);
1762            });
1763        });
1764    }
1765
1766    #[gpui::test]
1767    fn non_searchable_command_uses_frame_focus(cx: &mut TestAppContext) {
1768        cx.update(crate::init);
1769        let confirmed = Rc::new(RefCell::new(None));
1770        let confirmed_for_render = confirmed.clone();
1771        let (harness, cx) = cx.add_window_view(move |window, cx| Harness {
1772            state: cx.new(|cx| CommandState::new(window, cx)),
1773            command: Rc::new(move |state| {
1774                let confirmed = confirmed_for_render.clone();
1775                Command::new(state)
1776                    .searchable(false)
1777                    .item(CommandItem::new().label("alpha"))
1778                    .item(CommandItem::new().label("beta"))
1779                    .on_confirm(move |index_path, _, _| {
1780                        *confirmed.borrow_mut() = Some(index_path);
1781                    })
1782            }),
1783        });
1784        let state = cx.update(|_, cx| harness.read(cx).state.clone());
1785
1786        cx.run_until_parked();
1787        cx.update(|window, cx| _ = window.draw(cx));
1788        cx.update(|window, cx| {
1789            state.update(cx, |state, cx| state.focus(window, cx));
1790            assert!(state.read(cx).focus_handle.is_focused(window));
1791            window.dispatch_action(Box::new(SelectDown), cx);
1792            window.dispatch_action(Box::new(Confirm { secondary: false }), cx);
1793        });
1794
1795        assert_eq!(*confirmed.borrow(), Some(IndexPath::new(1).section(0)));
1796    }
1797
1798    #[gpui::test]
1799    fn filtered_ungrouped_item_keeps_its_input_row(cx: &mut TestAppContext) {
1800        cx.update(crate::init);
1801        let confirmed = Rc::new(RefCell::new(None));
1802        let confirmed_for_render = confirmed.clone();
1803        let (harness, cx) = cx.add_window_view(move |window, cx| Harness {
1804            state: cx.new(|cx| CommandState::new(window, cx)),
1805            command: Rc::new(move |state| {
1806                let confirmed = confirmed_for_render.clone();
1807                Command::new(state)
1808                    .items([
1809                        CommandItem::new().label("alpha"),
1810                        CommandItem::new().label("beta"),
1811                        CommandItem::new().label("gamma"),
1812                    ])
1813                    .on_confirm(move |index_path, _, _| {
1814                        *confirmed.borrow_mut() = Some(index_path);
1815                    })
1816            }),
1817        });
1818        let state = cx.update(|_, cx| harness.read(cx).state.clone());
1819
1820        cx.run_until_parked();
1821        cx.update(|window, cx| {
1822            state.update(cx, |state, cx| {
1823                state.set_query("gamma", window, cx);
1824                state.focus(window, cx);
1825            });
1826            window.dispatch_action(Box::new(Confirm { secondary: false }), cx);
1827        });
1828
1829        assert_eq!(*confirmed.borrow(), Some(IndexPath::new(2).section(0)));
1830    }
1831
1832    #[gpui::test]
1833    fn initially_rendered_disabled_first_item_selects_and_confirms_the_first_enabled_item(
1834        cx: &mut TestAppContext,
1835    ) {
1836        cx.update(crate::init);
1837        let confirmed = Rc::new(RefCell::new(None));
1838        let confirmed_for_render = confirmed.clone();
1839        let (harness, cx) = cx.add_window_view(move |window, cx| Harness {
1840            state: cx.new(|cx| CommandState::new(window, cx)),
1841            command: Rc::new(move |state| {
1842                let confirmed = confirmed_for_render.clone();
1843                Command::new(state)
1844                    .item(CommandItem::new().label("disabled").disabled(true))
1845                    .item(CommandItem::new().label("enabled"))
1846                    .on_confirm(move |index_path, _, _| {
1847                        *confirmed.borrow_mut() = Some(index_path);
1848                    })
1849            }),
1850        });
1851        let state = cx.update(|_, cx| harness.read(cx).state.clone());
1852
1853        cx.run_until_parked();
1854        cx.update(|window, cx| _ = window.draw(cx));
1855        cx.update(|window, cx| {
1856            state.update(cx, |state, cx| state.focus(window, cx));
1857            window.dispatch_action(Box::new(Confirm { secondary: false }), cx);
1858        });
1859
1860        assert_eq!(
1861            state.read_with(cx, |state, _| state.selected_index()),
1862            Some(IndexPath::new(1).section(0))
1863        );
1864        assert_eq!(*confirmed.borrow(), Some(IndexPath::new(1).section(0)));
1865    }
1866
1867    #[gpui::test]
1868    fn initially_rendered_all_disabled_items_have_no_selected_index_and_ignore_enter(
1869        cx: &mut TestAppContext,
1870    ) {
1871        cx.update(crate::init);
1872        let confirmed = Rc::new(RefCell::new(None));
1873        let confirmed_for_render = confirmed.clone();
1874        let (harness, cx) = cx.add_window_view(move |window, cx| Harness {
1875            state: cx.new(|cx| CommandState::new(window, cx)),
1876            command: Rc::new(move |state| {
1877                let confirmed = confirmed_for_render.clone();
1878                Command::new(state)
1879                    .item(CommandItem::new().label("one").disabled(true))
1880                    .item(CommandItem::new().label("two").disabled(true))
1881                    .on_confirm(move |index_path, _, _| {
1882                        *confirmed.borrow_mut() = Some(index_path);
1883                    })
1884            }),
1885        });
1886        let state = cx.update(|_, cx| harness.read(cx).state.clone());
1887
1888        cx.run_until_parked();
1889        cx.update(|window, cx| _ = window.draw(cx));
1890        cx.update(|window, cx| {
1891            state.update(cx, |state, cx| state.focus(window, cx));
1892            window.dispatch_action(Box::new(Confirm { secondary: false }), cx);
1893        });
1894
1895        assert_eq!(state.read_with(cx, |state, _| state.selected_index()), None);
1896        assert_eq!(*confirmed.borrow(), None);
1897    }
1898
1899    #[gpui::test]
1900    fn non_searchable_command_cancels_without_clearing_a_hidden_query(cx: &mut TestAppContext) {
1901        cx.update(crate::init);
1902        let cancelled = Rc::new(Cell::new(false));
1903        let cancelled_for_render = cancelled.clone();
1904        let query_calls = Rc::new(Cell::new(0));
1905        let query_calls_for_render = query_calls.clone();
1906        let (harness, cx) = cx.add_window_view(move |window, cx| Harness {
1907            state: cx.new(|cx| CommandState::new(window, cx)),
1908            command: Rc::new(move |state| {
1909                let cancelled = cancelled_for_render.clone();
1910                let query_calls = query_calls_for_render.clone();
1911                Command::new(state)
1912                    .searchable(false)
1913                    .item(CommandItem::new().label("alpha"))
1914                    .on_query(move |_, _, _| query_calls.set(query_calls.get() + 1))
1915                    .on_cancel(move |_, _| cancelled.set(true))
1916            }),
1917        });
1918        let state = cx.update(|_, cx| harness.read(cx).state.clone());
1919
1920        cx.run_until_parked();
1921        cx.update(|window, cx| _ = window.draw(cx));
1922        cx.update(|window, cx| {
1923            state.update(cx, |state, cx| {
1924                state.set_query("hidden query", window, cx);
1925                state.focus(window, cx);
1926            });
1927            window.dispatch_action(Box::new(Cancel), cx);
1928        });
1929
1930        assert!(cancelled.get());
1931        assert_eq!(query_calls.get(), 0);
1932        assert_eq!(
1933            state.read_with(cx, |state, cx| state.query(cx)),
1934            "hidden query"
1935        );
1936    }
1937
1938    #[gpui::test]
1939    fn moving_the_highlight_skips_disabled_items_and_wraps(cx: &mut TestAppContext) {
1940        cx.update(crate::init);
1941        let cx = cx.add_empty_window();
1942
1943        cx.update(|window, cx| {
1944            let state = cx.new(|cx| command_state(window, cx, suggestion_entries()));
1945
1946            state.update(cx, |state, cx| {
1947                state.update_matches(cx);
1948                state.reset_selection();
1949                assert_eq!(state.selected_index(), Some(IndexPath::new(0).section(0)));
1950
1951                state.select_by(1, window, cx);
1952                assert_eq!(state.selected_index(), Some(IndexPath::new(1).section(0)));
1953
1954                // "Calculator" is disabled, so it is stepped over.
1955                state.select_by(1, window, cx);
1956                assert_eq!(state.selected_index(), Some(IndexPath::new(0).section(1)));
1957
1958                state.select_by(-1, window, cx);
1959                assert_eq!(state.selected_index(), Some(IndexPath::new(1).section(0)));
1960
1961                // Wraps around the end, skipping the disabled item again.
1962                state.select_by(-1, window, cx);
1963                assert_eq!(state.selected_index(), Some(IndexPath::new(0).section(0)));
1964                state.select_by(-1, window, cx);
1965                assert_eq!(state.selected_index(), Some(IndexPath::new(1).section(1)));
1966            });
1967        });
1968    }
1969
1970    #[gpui::test]
1971    fn owner_can_set_and_clear_selection_by_original_index_path(cx: &mut TestAppContext) {
1972        cx.update(crate::init);
1973        let cx = cx.add_empty_window();
1974
1975        cx.update(|window, cx| {
1976            let initially_empty = cx.new(|cx| CommandState::new(window, cx));
1977            initially_empty.update(cx, |state, cx| {
1978                state.set_selected_index(None, window, cx);
1979                state.install_model(
1980                    CommandModel {
1981                        entries: suggestion_entries().into_iter().collect(),
1982                        ..CommandModel::default()
1983                    },
1984                    cx,
1985                );
1986                assert_eq!(state.selected_index(), None);
1987            });
1988
1989            let state = cx.new(|cx| command_state(window, cx, suggestion_entries()));
1990
1991            state.update(cx, |state, cx| {
1992                let target = IndexPath::new(1).section(1);
1993                state.set_selected_index(Some(target), window, cx);
1994                assert_eq!(state.selected_index(), Some(target));
1995
1996                state.set_selected_index(None, window, cx);
1997                assert_eq!(state.selected_index(), None);
1998
1999                state.install_model(
2000                    CommandModel {
2001                        entries: suggestion_entries().into_iter().collect(),
2002                        ..CommandModel::default()
2003                    },
2004                    cx,
2005                );
2006                assert_eq!(state.selected_index(), None);
2007
2008                state.set_query("calendar", window, cx);
2009                state.set_selected_index(Some(target), window, cx);
2010                assert_eq!(state.selected_index(), None);
2011            });
2012        });
2013    }
2014
2015    #[gpui::test]
2016    fn confirming_a_disabled_item_does_nothing(cx: &mut TestAppContext) {
2017        cx.update(crate::init);
2018        let cx = cx.add_empty_window();
2019
2020        cx.update(|window, cx| {
2021            let state = cx.new(|cx| {
2022                command_state(
2023                    window,
2024                    cx,
2025                    [
2026                        CommandEntry::Item(CommandItem::new().label("enabled")),
2027                        CommandEntry::Item(CommandItem::new().label("disabled").disabled(true)),
2028                    ],
2029                )
2030            });
2031
2032            state.update(cx, |state, cx| {
2033                state.update_matches(cx);
2034
2035                assert_eq!(state.matched_count(), 2);
2036                // Reaching the disabled row is only possible with the mouse or
2037                // an explicit index; confirming it must be a no-op.
2038                state.confirm(1, window, cx);
2039                assert_eq!(state.selected_index, Some(0));
2040            });
2041        });
2042    }
2043
2044    #[gpui::test]
2045    fn a_checked_item_uses_an_xsmall_trailing_check_icon(cx: &mut TestAppContext) {
2046        cx.update(crate::init);
2047        let cx = cx.add_empty_window();
2048        let unchecked_width = Rc::new(Cell::new(None));
2049        let checked_width = Rc::new(Cell::new(None));
2050        let (unchecked, checked) = cx.update(|window, cx| {
2051            let unchecked_state = cx.new(|cx| {
2052                command_state(
2053                    window,
2054                    cx,
2055                    [CommandEntry::Item(CommandItem::new().label("theme"))],
2056                )
2057            });
2058            let checked_state = cx.new(|cx| {
2059                command_state(
2060                    window,
2061                    cx,
2062                    [CommandEntry::Item(
2063                        CommandItem::new().label("theme").checked(true),
2064                    )],
2065                )
2066            });
2067            let unchecked_width = unchecked_width.clone();
2068            let checked_width = checked_width.clone();
2069            (
2070                cx.new(|_| CheckIconWidthHarness {
2071                    state: unchecked_state,
2072                    width: unchecked_width,
2073                }),
2074                cx.new(|_| CheckIconWidthHarness {
2075                    state: checked_state,
2076                    width: checked_width,
2077                }),
2078            )
2079        });
2080
2081        cx.draw(
2082            gpui::point(px(0.), px(0.)),
2083            gpui::AvailableSpace::min_size(),
2084            move |_, _| unchecked.into_any_element(),
2085        );
2086
2087        cx.draw(
2088            gpui::point(px(0.), px(0.)),
2089            gpui::AvailableSpace::min_size(),
2090            move |_, _| checked.into_any_element(),
2091        );
2092
2093        assert_eq!(
2094            checked_width.get().unwrap() - unchecked_width.get().unwrap(),
2095            px(20.)
2096        );
2097    }
2098
2099    struct CheckIconWidthHarness {
2100        state: Entity<CommandState>,
2101        width: Rc<Cell<Option<gpui::Pixels>>>,
2102    }
2103
2104    impl Render for CheckIconWidthHarness {
2105        fn render(
2106            &mut self,
2107            window: &mut Window,
2108            cx: &mut gpui::Context<Self>,
2109        ) -> impl IntoElement {
2110            let width = self.width.clone();
2111            let item = self.state.update(cx, |state, cx| {
2112                state.update_matches(cx);
2113                state.render_item(0, window, cx)
2114            });
2115
2116            div()
2117                .on_children_prepainted(move |bounds, _, _| width.set(Some(bounds[0].size.width)))
2118                .child(item)
2119        }
2120    }
2121
2122    struct Harness {
2123        state: Entity<CommandState>,
2124        command: Rc<dyn Fn(&Entity<CommandState>) -> Command>,
2125    }
2126
2127    impl Render for Harness {
2128        fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
2129            div()
2130                .size_full()
2131                .child((self.command)(&self.state).max_h(px(200.)))
2132        }
2133    }
2134
2135    #[gpui::test]
2136    fn header_and_footer_render_with_current_state(cx: &mut TestAppContext) {
2137        cx.update(crate::init);
2138        let header_calls = Rc::new(Cell::new(0));
2139        let footer_calls = Rc::new(Cell::new(0));
2140        let header_matched_count = Rc::new(Cell::new(None));
2141        let footer_matched_count = Rc::new(Cell::new(None));
2142
2143        let (harness, cx) = cx.add_window_view(|window, cx| HeaderFooterHarness {
2144            state: cx.new(|cx| CommandState::new(window, cx)),
2145            header_calls,
2146            footer_calls,
2147            header_matched_count,
2148            footer_matched_count,
2149        });
2150
2151        cx.run_until_parked();
2152        cx.update(|window, cx| _ = window.draw(cx));
2153
2154        let (header_calls, footer_calls, header_matched_count, footer_matched_count) =
2155            cx.update(|_, cx| {
2156                let harness = harness.read(cx);
2157                (
2158                    harness.header_calls.get(),
2159                    harness.footer_calls.get(),
2160                    harness.header_matched_count.get(),
2161                    harness.footer_matched_count.get(),
2162                )
2163            });
2164        assert!(header_calls > 0);
2165        assert!(footer_calls > 0);
2166        assert_eq!(header_matched_count, Some(2));
2167        assert_eq!(footer_matched_count, Some(2));
2168    }
2169
2170    #[gpui::test]
2171    fn custom_empty_slot_renders_with_current_state(cx: &mut TestAppContext) {
2172        cx.update(crate::init);
2173        let empty_calls = Rc::new(Cell::new(0));
2174        let empty_matched_count = Rc::new(Cell::new(None));
2175        let calls = empty_calls.clone();
2176        let matched_count = empty_matched_count.clone();
2177        let (_harness, cx) = cx.add_window_view(move |window, cx| Harness {
2178            state: cx.new(|cx| CommandState::new(window, cx)),
2179            command: Rc::new(move |state| {
2180                let calls = calls.clone();
2181                let matched_count = matched_count.clone();
2182                Command::new(state).empty(
2183                    move |state: &CommandState, _: &mut Window, _: &mut gpui::App| {
2184                        calls.set(calls.get() + 1);
2185                        matched_count.set(Some(state.matched_count()));
2186                        div().child("Custom empty")
2187                    },
2188                )
2189            }),
2190        });
2191
2192        cx.run_until_parked();
2193        cx.update(|window, cx| _ = window.draw(cx));
2194
2195        assert!(empty_calls.get() > 0);
2196        assert_eq!(empty_matched_count.get(), Some(0));
2197    }
2198
2199    fn entries_with_late_first_enabled_item() -> Vec<CommandEntry> {
2200        vec![
2201            CommandGroup::new()
2202                .label("Disabled")
2203                .items((0..30).map(|ix| {
2204                    CommandItem::new()
2205                        .label(format!("disabled-{ix}"))
2206                        .keywords(["match"])
2207                        .disabled(true)
2208                }))
2209                .into(),
2210            CommandEntry::Separator,
2211            CommandGroup::new()
2212                .label("Enabled")
2213                .item(CommandItem::new().label("enabled").keywords(["match"]))
2214                .into(),
2215        ]
2216    }
2217
2218    fn assert_first_enabled_row_is_scrolled_into_view(
2219        state: &Entity<CommandState>,
2220        cx: &mut TestAppContext,
2221    ) {
2222        let (selected_row, offset) = state.read_with(cx, |state, _| {
2223            (
2224                state.matched[state.selected_index.unwrap()].row_ix,
2225                state.scroll_handle.base_handle().offset().y,
2226            )
2227        });
2228
2229        assert!(selected_row > 30);
2230        assert!(
2231            offset < px(-900.),
2232            "the list should scroll to the selected row, not row zero ({offset:?})",
2233        );
2234    }
2235
2236    #[gpui::test]
2237    fn first_enabled_selection_resets_scroll_to_its_late_row(cx: &mut TestAppContext) {
2238        cx.update(crate::init);
2239        let (harness, cx) = cx.add_window_view(|window, cx| Harness {
2240            state: cx.new(|cx| CommandState::new(window, cx)),
2241            command: Rc::new(|state| {
2242                command_with_entries(state, entries_with_late_first_enabled_item())
2243            }),
2244        });
2245        let state = cx.update(|_, cx| harness.read(cx).state.clone());
2246
2247        cx.run_until_parked();
2248        cx.update(|window, cx| _ = window.draw(cx));
2249        assert_first_enabled_row_is_scrolled_into_view(&state, cx);
2250
2251        cx.update(|window, cx| {
2252            state.update(cx, |state, cx| state.set_query("match", window, cx));
2253            _ = window.draw(cx);
2254        });
2255        assert_first_enabled_row_is_scrolled_into_view(&state, cx);
2256
2257        cx.update(|window, cx| {
2258            harness.update(cx, |_, cx| {
2259                cx.notify();
2260            });
2261            _ = window.draw(cx);
2262        });
2263        assert_first_enabled_row_is_scrolled_into_view(&state, cx);
2264    }
2265
2266    struct HeaderFooterHarness {
2267        state: Entity<CommandState>,
2268        header_calls: Rc<Cell<usize>>,
2269        footer_calls: Rc<Cell<usize>>,
2270        header_matched_count: Rc<Cell<Option<usize>>>,
2271        footer_matched_count: Rc<Cell<Option<usize>>>,
2272    }
2273
2274    impl Render for HeaderFooterHarness {
2275        fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
2276            let header_calls = self.header_calls.clone();
2277            let header_matched_count = self.header_matched_count.clone();
2278            let footer_calls = self.footer_calls.clone();
2279            let footer_matched_count = self.footer_matched_count.clone();
2280
2281            div().size_full().child(
2282                Command::new(&self.state)
2283                    .items([
2284                        CommandItem::new().label("Calendar"),
2285                        CommandItem::new().label("Calculator"),
2286                    ])
2287                    .max_h(px(200.))
2288                    .header(move |state, _, _| {
2289                        header_calls.set(header_calls.get() + 1);
2290                        header_matched_count.set(Some(state.matched_count()));
2291                        div()
2292                    })
2293                    .footer(move |state, _, _| {
2294                        footer_calls.set(footer_calls.get() + 1);
2295                        footer_matched_count.set(Some(state.matched_count()));
2296                        div()
2297                    }),
2298            )
2299        }
2300    }
2301
2302    struct PaddedHarness {
2303        state: Entity<CommandState>,
2304    }
2305
2306    impl Render for PaddedHarness {
2307        fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
2308            div().size_full().child(
2309                Command::new(&self.state)
2310                    .item(
2311                        CommandItem::new()
2312                            .label("fixed")
2313                            .child(|_, _| div().h(px(32.))),
2314                    )
2315                    .max_h(px(200.))
2316                    .p_4(),
2317            )
2318        }
2319    }
2320
2321    struct WrappingHarness {
2322        state: Entity<CommandState>,
2323        width: Pixels,
2324        no_wrap: bool,
2325    }
2326
2327    impl Render for WrappingHarness {
2328        fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
2329            div().size_full().child(
2330                div().w(self.width).child(
2331                    Command::new(&self.state)
2332                        .item(CommandItem::new().label("wrapped").child(|_, _| {
2333                            div()
2334                                .w_full()
2335                                .child("A command row whose content wraps at narrow list widths")
2336                        }))
2337                        .max_h(px(200.))
2338                        .when(self.no_wrap, |this| this.whitespace_nowrap()),
2339                ),
2340            )
2341        }
2342    }
2343
2344    #[gpui::test]
2345    fn wrapping_rows_remeasure_for_the_list_content_width(cx: &mut TestAppContext) {
2346        cx.update(crate::init);
2347
2348        let (harness, cx) = cx.add_window_view(|window, cx| WrappingHarness {
2349            state: cx.new(|cx| CommandState::new(window, cx)),
2350            width: px(360.),
2351            no_wrap: false,
2352        });
2353
2354        cx.run_until_parked();
2355        cx.update(|window, cx| _ = window.draw(cx));
2356        cx.run_until_parked();
2357        cx.update(|window, cx| _ = window.draw(cx));
2358
2359        let wide = cx.update(|_, cx| harness.read(cx).state.read(cx).row_sizes[0].height);
2360
2361        cx.update(|_, cx| {
2362            harness.update(cx, |harness, cx| {
2363                harness.width = px(120.);
2364                cx.notify();
2365            })
2366        });
2367        cx.run_until_parked();
2368        cx.update(|window, cx| _ = window.draw(cx));
2369        cx.run_until_parked();
2370        cx.update(|window, cx| _ = window.draw(cx));
2371        let narrow = cx.update(|_, cx| harness.read(cx).state.read(cx).row_sizes[0].height);
2372
2373        assert!(
2374            narrow > wide,
2375            "the narrow list should cache a taller wrapped row ({narrow:?} vs {wide:?})",
2376        );
2377    }
2378
2379    #[gpui::test]
2380    fn wrapping_rows_remeasure_when_rem_size_changes(cx: &mut TestAppContext) {
2381        cx.update(crate::init);
2382
2383        let (harness, cx) = cx.add_window_view(|window, cx| {
2384            window.set_rem_size(px(20.));
2385            WrappingHarness {
2386                state: cx.new(|cx| CommandState::new(window, cx)),
2387                width: px(160.),
2388                no_wrap: false,
2389            }
2390        });
2391
2392        cx.run_until_parked();
2393        cx.update(|window, cx| _ = window.draw(cx));
2394        cx.run_until_parked();
2395        cx.update(|window, cx| _ = window.draw(cx));
2396        let smaller_rem = cx.update(|_, cx| harness.read(cx).state.read(cx).row_sizes[0].height);
2397
2398        cx.update(|window, cx| {
2399            window.set_rem_size(px(28.));
2400            _ = window.draw(cx);
2401        });
2402        cx.run_until_parked();
2403        cx.update(|window, cx| _ = window.draw(cx));
2404        let larger_rem = cx.update(|_, cx| harness.read(cx).state.read(cx).row_sizes[0].height);
2405
2406        assert!(
2407            larger_rem > smaller_rem,
2408            "a larger rem should remeasure the fixed-width wrapped row ({larger_rem:?} vs {smaller_rem:?})",
2409        );
2410    }
2411
2412    #[gpui::test]
2413    fn wrapping_rows_remeasure_when_inherited_typography_changes(cx: &mut TestAppContext) {
2414        cx.update(crate::init);
2415
2416        let (harness, cx) = cx.add_window_view(|window, cx| WrappingHarness {
2417            state: cx.new(|cx| CommandState::new(window, cx)),
2418            width: px(160.),
2419            no_wrap: false,
2420        });
2421
2422        cx.run_until_parked();
2423        cx.update(|window, cx| _ = window.draw(cx));
2424        cx.run_until_parked();
2425        cx.update(|window, cx| _ = window.draw(cx));
2426        let wrapped_height = cx.update(|_, cx| harness.read(cx).state.read(cx).row_sizes[0].height);
2427
2428        cx.update(|window, cx| {
2429            harness.update(cx, |harness, cx| {
2430                harness.no_wrap = true;
2431                cx.notify();
2432            });
2433            _ = window.draw(cx);
2434        });
2435        cx.run_until_parked();
2436        cx.update(|window, cx| _ = window.draw(cx));
2437        let no_wrap_height = cx.update(|_, cx| harness.read(cx).state.read(cx).row_sizes[0].height);
2438        assert!(
2439            no_wrap_height < wrapped_height,
2440            "a changed inherited typography should remeasure the fixed-width row ({no_wrap_height:?} vs {wrapped_height:?})",
2441        );
2442    }
2443
2444    #[gpui::test]
2445    fn outer_command_padding_does_not_inflate_measured_row_heights(cx: &mut TestAppContext) {
2446        cx.update(crate::init);
2447
2448        let (harness, cx) = cx.add_window_view(|window, cx| PaddedHarness {
2449            state: cx.new(|cx| CommandState::new(window, cx)),
2450        });
2451
2452        cx.run_until_parked();
2453        cx.update(|window, cx| _ = window.draw(cx));
2454        cx.run_until_parked();
2455        cx.update(|window, cx| _ = window.draw(cx));
2456        let height = cx.update(|_, cx| harness.read(cx).state.read(cx).row_sizes[0].height);
2457
2458        assert_eq!(height, px(44.));
2459    }
2460
2461    #[gpui::test]
2462    fn custom_rows_keep_independent_heights(cx: &mut TestAppContext) {
2463        cx.update(crate::init);
2464
2465        let (harness, cx) = cx.add_window_view(|window, cx| Harness {
2466            state: cx.new(|cx| CommandState::new(window, cx)),
2467            command: Rc::new(|state| {
2468                Command::new(state)
2469                    .group(
2470                        CommandGroup::new().label("Short").item(
2471                            CommandItem::new()
2472                                .label("short")
2473                                .child(|_, _| div().h(px(32.))),
2474                        ),
2475                    )
2476                    .separator()
2477                    .group(
2478                        CommandGroup::new().label("Tall").item(
2479                            CommandItem::new()
2480                                .label("tall")
2481                                .child(|_, _| div().h(px(72.))),
2482                        ),
2483                    )
2484            }),
2485        });
2486
2487        cx.run_until_parked();
2488        cx.update(|window, cx| _ = window.draw(cx));
2489        let row_sizes = cx.update(|_, cx| harness.read(cx).state.read(cx).row_sizes.clone());
2490
2491        assert_eq!(row_sizes.len(), 5);
2492        assert!(row_sizes[0].height > px(0.));
2493        assert_eq!(row_sizes[1].height, px(44.));
2494        assert_eq!(row_sizes[2].height, px(SEPARATOR_ROW_HEIGHT));
2495        assert!(row_sizes[3].height > px(0.));
2496        assert_eq!(row_sizes[4].height, px(84.));
2497    }
2498
2499    #[gpui::test]
2500    fn reinstalling_a_model_preserves_selection_by_index_path_and_remeasures_rows(
2501        cx: &mut TestAppContext,
2502    ) {
2503        cx.update(crate::init);
2504        let reversed = Rc::new(Cell::new(false));
2505        let reversed_for_render = reversed.clone();
2506        let (harness, cx) = cx.add_window_view(|window, cx| Harness {
2507            state: cx.new(|cx| CommandState::new(window, cx)),
2508            command: Rc::new(move |state| {
2509                if reversed_for_render.get() {
2510                    Command::new(state)
2511                        .item(
2512                            CommandItem::new()
2513                                .label("beta")
2514                                .child(|_, _| div().h(px(72.))),
2515                        )
2516                        .item(
2517                            CommandItem::new()
2518                                .label("alpha")
2519                                .child(|_, _| div().h(px(32.))),
2520                        )
2521                } else {
2522                    Command::new(state)
2523                        .item(
2524                            CommandItem::new()
2525                                .label("alpha")
2526                                .child(|_, _| div().h(px(32.))),
2527                        )
2528                        .item(
2529                            CommandItem::new()
2530                                .label("beta")
2531                                .child(|_, _| div().h(px(72.))),
2532                        )
2533                }
2534            }),
2535        });
2536        let state = cx.update(|_, cx| harness.read(cx).state.clone());
2537
2538        cx.run_until_parked();
2539        cx.update(|window, cx| _ = window.draw(cx));
2540        cx.update(|window, cx| {
2541            state.update(cx, |state, cx| state.select_by(1, window, cx));
2542        });
2543        assert_eq!(
2544            state.read_with(cx, |state, _| state.selected_index()),
2545            Some(IndexPath::new(1).section(0)),
2546        );
2547
2548        reversed.set(true);
2549        cx.update(|window, cx| {
2550            harness.update(cx, |_, cx| cx.notify());
2551            _ = window.draw(cx);
2552        });
2553
2554        let (selected_matched_index, selected_index, row_sizes) =
2555            state.read_with(cx, |state, _| {
2556                (
2557                    state.selected_index,
2558                    state.selected_index(),
2559                    state.row_sizes.clone(),
2560                )
2561            });
2562        assert_eq!(selected_matched_index, Some(1));
2563        assert_eq!(selected_index, Some(IndexPath::new(1).section(0)));
2564        assert_eq!(row_sizes[0].height, px(84.));
2565        assert_eq!(row_sizes[1].height, px(44.));
2566    }
2567
2568    #[gpui::test]
2569    fn a_state_redraw_reuses_the_installed_custom_row_measurement(cx: &mut TestAppContext) {
2570        cx.update(crate::init);
2571        let renders = Rc::new(Cell::new(0));
2572        let count = renders.clone();
2573        let cx = cx.add_empty_window();
2574        let state = cx.update(|window, cx| {
2575            cx.new(|cx| {
2576                command_state(
2577                    window,
2578                    cx,
2579                    [CommandEntry::Item(
2580                        CommandItem::new().label("custom").child(move |_, _| {
2581                            count.set(count.get() + 1);
2582                            div().child("Custom")
2583                        }),
2584                    )],
2585                )
2586            })
2587        });
2588
2589        let first_state = state.clone();
2590        cx.draw(
2591            gpui::point(px(0.), px(0.)),
2592            gpui::AvailableSpace::min_size(),
2593            move |_, _| first_state.into_any_element(),
2594        );
2595        let settled_state = state.clone();
2596        cx.draw(
2597            gpui::point(px(0.), px(0.)),
2598            gpui::AvailableSpace::min_size(),
2599            move |_, _| settled_state.into_any_element(),
2600        );
2601        let after_first_draw = renders.get();
2602        cx.draw(
2603            gpui::point(px(0.), px(0.)),
2604            gpui::AvailableSpace::min_size(),
2605            move |_, _| state.into_any_element(),
2606        );
2607
2608        assert_eq!(renders.get() - after_first_draw, 2);
2609    }
2610
2611    #[gpui::test]
2612    fn moving_past_the_visible_rows_scrolls_the_list(cx: &mut TestAppContext) {
2613        cx.update(crate::init);
2614
2615        let (harness, cx) = cx.add_window_view(|window, cx| Harness {
2616            state: cx.new(|cx| CommandState::new(window, cx)),
2617            command: Rc::new(|state| {
2618                Command::new(state)
2619                    .items((0..50).map(|ix| CommandItem::new().label(format!("Item {ix}"))))
2620            }),
2621        });
2622
2623        cx.run_until_parked();
2624        cx.update(|window, cx| _ = window.draw(cx));
2625
2626        let state = cx.update(|_, cx| harness.read(cx).state.clone());
2627        assert_eq!(
2628            state.read_with(cx, |state, _| state.scroll_handle.base_handle().offset().y),
2629            px(0.),
2630        );
2631
2632        // The list is capped well below 50 rows, so walking to the last one has
2633        // to bring the viewport with it.
2634        cx.update(|window, cx| {
2635            state.update(cx, |state, cx| {
2636                for _ in 0..49 {
2637                    state.select_by(1, window, cx);
2638                }
2639            })
2640        });
2641        cx.update(|window, cx| _ = window.draw(cx));
2642
2643        assert_eq!(
2644            state.read_with(cx, |state, _| state.selected_index()),
2645            Some(IndexPath::new(49).section(0))
2646        );
2647        assert!(
2648            state.read_with(cx, |state, _| state.scroll_handle.base_handle().offset().y) < px(0.),
2649            "selecting the last row should have scrolled the list",
2650        );
2651    }
2652
2653    #[gpui::test]
2654    fn a_reinstalled_model_does_not_scroll_a_preserved_selection(cx: &mut TestAppContext) {
2655        cx.update(crate::init);
2656
2657        let (harness, cx) = cx.add_window_view(|window, cx| Harness {
2658            state: cx.new(|cx| CommandState::new(window, cx)),
2659            command: Rc::new(|state| {
2660                Command::new(state)
2661                    .items((0..50).map(|ix| CommandItem::new().label(format!("Item {ix}"))))
2662            }),
2663        });
2664
2665        cx.run_until_parked();
2666        cx.update(|window, cx| _ = window.draw(cx));
2667
2668        let state = cx.update(|_, cx| harness.read(cx).state.clone());
2669
2670        // Hover selection does not scroll, and the host re-render it notifies
2671        // reinstalls the model with the selection preserved. That reinstall
2672        // must not scroll either, or the hover still moves the list one frame
2673        // later.
2674        cx.update(|window, cx| {
2675            state.update(cx, |state, cx| state.select(10, window, cx));
2676        });
2677        cx.update(|window, cx| _ = window.draw(cx));
2678
2679        assert_eq!(
2680            state.read_with(cx, |state, _| state.scroll_handle.base_handle().offset().y),
2681            px(0.),
2682            "reinstalling the model must keep the scroll position",
2683        );
2684    }
2685}