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                        )
1033                        .child(Scrollbar::vertical(&self.scroll_handle))
1034                    }),
1035            )
1036            .when_some(self.options.footer.as_ref(), |this, footer| {
1037                this.child(footer(self, window, cx))
1038            })
1039    }
1040}
1041
1042// MARK: Tests
1043
1044#[cfg(test)]
1045mod tests {
1046    use std::{
1047        cell::{Cell, RefCell},
1048        rc::Rc,
1049    };
1050
1051    use gpui::{
1052        AppContext as _, AvailableSpace, Entity, InteractiveElement as _, IntoElement, KeyBinding,
1053        Modifiers, ParentElement as _, Pixels, Render, Styled as _, TestAppContext, Window,
1054        actions, div, point, prelude::FluentBuilder as _, px,
1055    };
1056
1057    use super::{CONTEXT, CommandModel, CommandRow, CommandState, SEPARATOR_ROW_HEIGHT};
1058    use crate::{
1059        Disableable as _, Icon, IconName, IndexPath,
1060        actions::{Cancel, Confirm, SelectDown},
1061        command::{Command, CommandEntry, CommandGroup, CommandItem},
1062    };
1063
1064    actions!(
1065        command_test,
1066        [GlobalTestItem, OpenTestItem, RemovePaletteTestItem]
1067    );
1068
1069    struct CommandActionsHarness {
1070        state: Entity<CommandState>,
1071        events: Rc<RefCell<Vec<String>>>,
1072    }
1073
1074    impl Render for CommandActionsHarness {
1075        fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
1076            let action_events = self.events.clone();
1077            let propagated_cancel_events = self.events.clone();
1078            let query_events = self.events.clone();
1079            let select_events = self.events.clone();
1080            let confirm_events = self.events.clone();
1081            let cancel_events = self.events.clone();
1082
1083            div()
1084                .size_full()
1085                .on_action(move |_: &OpenTestItem, _, _| {
1086                    action_events.borrow_mut().push("action".into());
1087                })
1088                .on_action(move |_: &Cancel, _, _| {
1089                    propagated_cancel_events
1090                        .borrow_mut()
1091                        .push("propagated_cancel".into());
1092                })
1093                .child(
1094                    Command::new(&self.state)
1095                        .item(
1096                            CommandItem::new()
1097                                .label("Item")
1098                                .keywords(["needle"])
1099                                .action(Box::new(OpenTestItem)),
1100                        )
1101                        .item(CommandItem::new().label("Item"))
1102                        .item(
1103                            CommandItem::new()
1104                                .label("Item")
1105                                .action(Box::new(GlobalTestItem)),
1106                        )
1107                        .on_query(move |query, _, _| {
1108                            query_events.borrow_mut().push(format!("query:{query}"));
1109                        })
1110                        .on_select(move |index, _, _| {
1111                            select_events
1112                                .borrow_mut()
1113                                .push(format!("select:{}:{}", index.section, index.row));
1114                        })
1115                        .on_confirm(move |index, _, _| {
1116                            confirm_events
1117                                .borrow_mut()
1118                                .push(format!("confirm:{}:{}", index.section, index.row));
1119                        })
1120                        .on_cancel(move |_, _| {
1121                            cancel_events.borrow_mut().push("cancel".into());
1122                        }),
1123                )
1124        }
1125    }
1126
1127    struct ReentrantCallbackHarness {
1128        state: Entity<CommandState>,
1129        events: Vec<String>,
1130    }
1131
1132    impl Render for ReentrantCallbackHarness {
1133        fn render(&mut self, _: &mut Window, cx: &mut gpui::Context<Self>) -> impl IntoElement {
1134            let select_owner = cx.weak_entity();
1135            let query_owner = cx.weak_entity();
1136            let confirm_owner = cx.weak_entity();
1137
1138            Command::new(&self.state)
1139                .item(CommandItem::new().label("alpha"))
1140                .item(CommandItem::new().label("beta"))
1141                .on_select(move |index, _, cx| {
1142                    _ = select_owner.update(cx, |harness, cx| {
1143                        assert_eq!(harness.state.read(cx).selected_index(), Some(index));
1144                        harness
1145                            .events
1146                            .push(format!("select:{}:{}", index.section, index.row));
1147                    });
1148                })
1149                .on_query(move |query, _, cx| {
1150                    _ = query_owner.update(cx, |harness, cx| {
1151                        assert_eq!(harness.state.read(cx).query(cx).as_ref(), query);
1152                        harness.events.push(format!("query:{query}"));
1153                    });
1154                })
1155                .on_confirm(move |index, _, cx| {
1156                    _ = confirm_owner.update(cx, |harness, cx| {
1157                        assert_eq!(harness.state.read(cx).selected_index(), Some(index));
1158                        harness
1159                            .events
1160                            .push(format!("confirm:{}:{}", index.section, index.row));
1161                    });
1162                })
1163        }
1164    }
1165
1166    #[gpui::test]
1167    fn query_and_selection_callbacks_run_after_the_state_lease_in_defined_order(
1168        cx: &mut TestAppContext,
1169    ) {
1170        cx.update(crate::init);
1171        let (harness, cx) = cx.add_window_view(|window, cx| ReentrantCallbackHarness {
1172            state: cx.new(|cx| CommandState::new(window, cx)),
1173            events: Vec::new(),
1174        });
1175        let state = cx.update(|_, cx| harness.read(cx).state.clone());
1176
1177        cx.run_until_parked();
1178        cx.update(|window, cx| {
1179            _ = window.draw(cx);
1180            state.update(cx, |state, cx| {
1181                state.selected_index = Some(1);
1182                state.set_query("alpha", window, cx);
1183            });
1184        });
1185
1186        assert_eq!(
1187            harness.read_with(cx, |harness, _| harness.events.clone()),
1188            ["select:0:0", "query:alpha"]
1189        );
1190    }
1191
1192    #[gpui::test]
1193    fn actionless_confirm_callback_runs_after_the_state_lease(cx: &mut TestAppContext) {
1194        cx.update(crate::init);
1195        let (harness, cx) = cx.add_window_view(|window, cx| ReentrantCallbackHarness {
1196            state: cx.new(|cx| CommandState::new(window, cx)),
1197            events: Vec::new(),
1198        });
1199        let state = cx.update(|_, cx| harness.read(cx).state.clone());
1200
1201        cx.run_until_parked();
1202        cx.update(|window, cx| {
1203            _ = window.draw(cx);
1204            state.update(cx, |state, cx| state.confirm(0, window, cx));
1205        });
1206
1207        assert_eq!(
1208            harness.read_with(cx, |harness, _| harness.events.clone()),
1209            ["confirm:0:0"]
1210        );
1211    }
1212
1213    struct CommandItemWidthHarness {
1214        state: Entity<CommandState>,
1215        matched_ix: usize,
1216        width: Rc<Cell<Option<Pixels>>>,
1217    }
1218
1219    impl Render for CommandItemWidthHarness {
1220        fn render(
1221            &mut self,
1222            window: &mut Window,
1223            cx: &mut gpui::Context<Self>,
1224        ) -> impl IntoElement {
1225            let width = self.width.clone();
1226            let item = self.state.update(cx, |state, cx| {
1227                state.render_item(self.matched_ix, window, cx)
1228            });
1229
1230            div()
1231                .on_children_prepainted(move |bounds, _, _| width.set(Some(bounds[0].size.width)))
1232                .child(item)
1233        }
1234    }
1235
1236    #[gpui::test]
1237    fn action_that_removes_command_state_still_confirms_after_dispatch(cx: &mut TestAppContext) {
1238        cx.update(crate::init);
1239        let events = Rc::new(RefCell::new(Vec::new()));
1240        let state_owner: Rc<RefCell<Option<Entity<CommandState>>>> = Rc::new(RefCell::new(None));
1241        let action_events = events.clone();
1242        let action_state_owner = state_owner.clone();
1243        cx.update(|cx| {
1244            cx.on_action(move |_: &RemovePaletteTestItem, _| {
1245                action_events.borrow_mut().push("action".into());
1246                action_state_owner.borrow_mut().take();
1247            });
1248        });
1249        let cx = cx.add_empty_window();
1250        cx.update(|window, cx| {
1251            let confirm_events = events.clone();
1252            let state = cx.new(|cx| {
1253                let mut state = CommandState::new(window, cx);
1254                state.install_model(
1255                    CommandModel {
1256                        entries: vec![CommandEntry::Item(
1257                            CommandItem::new()
1258                                .label("removed")
1259                                .action(Box::new(RemovePaletteTestItem)),
1260                        )],
1261                        searchable: false,
1262                        on_confirm: Some(Rc::new(move |index, _, _| {
1263                            confirm_events
1264                                .borrow_mut()
1265                                .push(format!("confirm:{}:{}", index.section, index.row));
1266                        })),
1267                        ..CommandModel::default()
1268                    },
1269                    cx,
1270                );
1271                state
1272            });
1273            *state_owner.borrow_mut() = Some(state.clone());
1274            state.update(cx, |state, cx| state.confirm(0, window, cx));
1275        });
1276        cx.run_until_parked();
1277
1278        assert!(state_owner.borrow().is_none());
1279        assert_eq!(events.borrow().as_slice(), ["action", "confirm:0:0"]);
1280    }
1281
1282    #[gpui::test]
1283    fn command_actions_and_callbacks_follow_defined_order(cx: &mut TestAppContext) {
1284        cx.update(|cx| {
1285            crate::init(cx);
1286            cx.bind_keys([
1287                KeyBinding::new("ctrl-o", OpenTestItem, Some(CONTEXT)),
1288                KeyBinding::new("ctrl-g", GlobalTestItem, None),
1289            ]);
1290        });
1291        let events = Rc::new(RefCell::new(Vec::new()));
1292        let (harness, cx) = cx.add_window_view(|window, cx| CommandActionsHarness {
1293            state: cx.new(|cx| CommandState::new(window, cx)),
1294            events: events.clone(),
1295        });
1296        let state = cx.update(|_, cx| harness.read(cx).state.clone());
1297
1298        cx.run_until_parked();
1299        cx.update(|window, cx| {
1300            _ = window.draw(cx);
1301            state.update(cx, |state, cx| state.focus(window, cx));
1302            _ = window.draw(cx);
1303        });
1304
1305        let action_width = Rc::new(Cell::new(None));
1306        let plain_width = Rc::new(Cell::new(None));
1307        let global_width = Rc::new(Cell::new(None));
1308        let (action_probe, plain_probe, global_probe) = cx.update(|_, cx| {
1309            (
1310                cx.new(|_| CommandItemWidthHarness {
1311                    state: state.clone(),
1312                    matched_ix: 0,
1313                    width: action_width.clone(),
1314                }),
1315                cx.new(|_| CommandItemWidthHarness {
1316                    state: state.clone(),
1317                    matched_ix: 1,
1318                    width: plain_width.clone(),
1319                }),
1320                cx.new(|_| CommandItemWidthHarness {
1321                    state: state.clone(),
1322                    matched_ix: 2,
1323                    width: global_width.clone(),
1324                }),
1325            )
1326        });
1327        cx.draw(
1328            point(px(0.), px(0.)),
1329            AvailableSpace::min_size(),
1330            move |_, _| action_probe.into_any_element(),
1331        );
1332        cx.draw(
1333            point(px(0.), px(0.)),
1334            AvailableSpace::min_size(),
1335            move |_, _| plain_probe.into_any_element(),
1336        );
1337        cx.draw(
1338            point(px(0.), px(0.)),
1339            AvailableSpace::min_size(),
1340            move |_, _| global_probe.into_any_element(),
1341        );
1342        let action_width = action_width.get().unwrap();
1343        let plain_width = plain_width.get().unwrap();
1344        let global_width = global_width.get().unwrap();
1345        assert!(
1346            action_width > plain_width,
1347            "the scoped Action binding should add a visible Kbd ({action_width:?} vs {plain_width:?})",
1348        );
1349        assert!(
1350            global_width > plain_width,
1351            "the app-level fallback binding should add a visible Kbd ({global_width:?} vs {plain_width:?})",
1352        );
1353
1354        cx.update(|window, cx| {
1355            state.update(cx, |state, cx| {
1356                state.set_query("needle", window, cx);
1357                state.set_query("needle", window, cx);
1358                state.set_query("", window, cx);
1359            });
1360            window.dispatch_action(Box::new(SelectDown), cx);
1361            window.dispatch_action(Box::new(crate::actions::SelectUp), cx);
1362            window.dispatch_action(Box::new(Confirm { secondary: false }), cx);
1363        });
1364        cx.run_until_parked();
1365
1366        assert_eq!(
1367            events.borrow().as_slice(),
1368            [
1369                "query:needle",
1370                "query:",
1371                "select:0:1",
1372                "select:0:0",
1373                "action",
1374                "confirm:0:0",
1375            ]
1376        );
1377
1378        cx.simulate_click(point(px(20.), px(52.)), Modifiers::default());
1379        cx.run_until_parked();
1380        cx.update(|window, cx| window.dispatch_action(Box::new(Cancel), cx));
1381        cx.run_until_parked();
1382
1383        assert_eq!(
1384            events.borrow().as_slice(),
1385            [
1386                "query:needle",
1387                "query:",
1388                "select:0:1",
1389                "select:0:0",
1390                "action",
1391                "confirm:0:0",
1392                "action",
1393                "confirm:0:0",
1394                "cancel",
1395                "propagated_cancel",
1396            ]
1397        );
1398    }
1399
1400    struct CommandOwnedEntriesHarness {
1401        state: Entity<CommandState>,
1402    }
1403
1404    impl Render for CommandOwnedEntriesHarness {
1405        fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
1406            Command::new(&self.state)
1407                .searchable(false)
1408                .item(CommandItem::new().label("alpha"))
1409                .group(
1410                    CommandGroup::new()
1411                        .label("Settings")
1412                        .item(CommandItem::new().label("beta")),
1413                )
1414                .separator()
1415                .item(
1416                    CommandItem::new()
1417                        .label("custom")
1418                        .child(|_, _| div().h(px(72.)).child("Custom")),
1419                )
1420        }
1421    }
1422
1423    #[gpui::test]
1424    fn command_owns_entries_and_lazy_item_content(cx: &mut TestAppContext) {
1425        cx.update(crate::init);
1426        let (harness, cx) = cx.add_window_view(|window, cx| CommandOwnedEntriesHarness {
1427            state: cx.new(|cx| CommandState::new(window, cx)),
1428        });
1429
1430        cx.run_until_parked();
1431        cx.update(|window, cx| _ = window.draw(cx));
1432
1433        let (labels, rows, row_sizes) = cx.update(|_, cx| {
1434            let state = harness.read(cx).state.read(cx);
1435            (
1436                (0..state.matched_count())
1437                    .map(|matched_ix| {
1438                        state
1439                            .item_at(matched_ix)
1440                            .unwrap()
1441                            .label_text()
1442                            .unwrap()
1443                            .clone()
1444                    })
1445                    .collect::<Vec<_>>(),
1446                state.rows.clone(),
1447                state.row_sizes.clone(),
1448            )
1449        });
1450
1451        assert_eq!(labels, ["alpha", "beta", "custom"]);
1452        assert!(matches!(
1453            rows.as_slice(),
1454            [
1455                CommandRow::Item(_),
1456                CommandRow::Heading(heading),
1457                CommandRow::Item(_),
1458                CommandRow::Separator,
1459                CommandRow::Item(_),
1460            ] if heading == "Settings"
1461        ));
1462        assert_eq!(row_sizes[4].height, px(84.));
1463    }
1464
1465    fn command_with_entries(
1466        state: &Entity<CommandState>,
1467        entries: impl IntoIterator<Item = CommandEntry>,
1468    ) -> Command {
1469        entries
1470            .into_iter()
1471            .fold(Command::new(state), |command, entry| match entry {
1472                CommandEntry::Item(item) => command.item(item),
1473                CommandEntry::Group(group) => command.group(group),
1474                CommandEntry::Separator => command.separator(),
1475            })
1476    }
1477
1478    fn command_state(
1479        window: &mut Window,
1480        cx: &mut gpui::Context<CommandState>,
1481        entries: impl IntoIterator<Item = CommandEntry>,
1482    ) -> CommandState {
1483        let mut state = CommandState::new(window, cx);
1484        state.install_model(
1485            CommandModel {
1486                entries: entries.into_iter().collect(),
1487                ..CommandModel::default()
1488            },
1489            cx,
1490        );
1491        state
1492    }
1493
1494    fn command_state_with_options(
1495        window: &mut Window,
1496        cx: &mut gpui::Context<CommandState>,
1497        entries: impl IntoIterator<Item = CommandEntry>,
1498        searchable: bool,
1499    ) -> CommandState {
1500        let mut state = CommandState::new(window, cx);
1501        state.install_model(
1502            CommandModel {
1503                entries: entries.into_iter().collect(),
1504                searchable,
1505                ..CommandModel::default()
1506            },
1507            cx,
1508        );
1509        state
1510    }
1511
1512    fn suggestion_entries() -> Vec<CommandEntry> {
1513        vec![
1514            CommandGroup::new()
1515                .label("Suggestions")
1516                .item(CommandItem::new().label("Calendar"))
1517                .item(CommandItem::new().label("Search Emoji"))
1518                .item(CommandItem::new().label("Calculator").disabled(true))
1519                .into(),
1520            CommandEntry::Separator,
1521            CommandGroup::new()
1522                .label("Settings")
1523                .item(CommandItem::new().label("Profile"))
1524                .item(CommandItem::new().label("Billing"))
1525                .into(),
1526        ]
1527    }
1528
1529    #[gpui::test]
1530    fn query_hides_the_groups_that_have_no_match(cx: &mut TestAppContext) {
1531        cx.update(crate::init);
1532        let cx = cx.add_empty_window();
1533
1534        cx.update(|window, cx| {
1535            let state = cx.new(|cx| command_state(window, cx, suggestion_entries()));
1536
1537            state.update(cx, |state, cx| {
1538                state.update_matches(cx);
1539                assert_eq!(state.matched_count(), 5);
1540                assert_eq!(
1541                    state
1542                        .rows
1543                        .iter()
1544                        .filter(|row| matches!(row, CommandRow::Heading(_)))
1545                        .count(),
1546                    2,
1547                );
1548                assert_eq!(
1549                    state
1550                        .rows
1551                        .iter()
1552                        .filter(|row| matches!(row, CommandRow::Separator))
1553                        .count(),
1554                    1,
1555                );
1556
1557                // "Bil" only matches an item of the second group, so the first
1558                // group's heading and the separator between them both go.
1559                state.set_query("Bil", window, cx);
1560                state.update_matches(cx);
1561
1562                assert_eq!(state.matched_count(), 1);
1563                assert_eq!(state.selected_index(), Some(IndexPath::new(1).section(1)));
1564                assert_eq!(
1565                    state
1566                        .rows
1567                        .iter()
1568                        .filter(|row| matches!(row, CommandRow::Separator))
1569                        .count(),
1570                    0,
1571                );
1572                assert!(matches!(state.rows.first(), Some(CommandRow::Heading(_))));
1573            });
1574        });
1575    }
1576
1577    fn custom_entries() -> Vec<CommandEntry> {
1578        vec![CommandEntry::Item(
1579            CommandItem::new().label("Custom").child(|_, _| div()),
1580        )]
1581    }
1582
1583    #[gpui::test]
1584    fn reinstalling_an_unchanged_model_keeps_the_measured_rows(cx: &mut TestAppContext) {
1585        cx.update(crate::init);
1586        let cx = cx.add_empty_window();
1587
1588        cx.update(|window, cx| {
1589            let state = cx.new(|cx| command_state(window, cx, suggestion_entries()));
1590
1591            state.update(cx, |state, cx| {
1592                // A host re-render rebuilds an equal model: nothing to lay out again.
1593                state.needs_measure = false;
1594                state.install_model(
1595                    CommandModel {
1596                        entries: suggestion_entries(),
1597                        ..CommandModel::default()
1598                    },
1599                    cx,
1600                );
1601                assert!(!state.needs_measure);
1602                assert_eq!(state.matched_count(), 5);
1603
1604                // A custom child can read state outside the item, so it is
1605                // always measured again.
1606                state.install_model(
1607                    CommandModel {
1608                        entries: custom_entries(),
1609                        ..CommandModel::default()
1610                    },
1611                    cx,
1612                );
1613                assert!(state.needs_measure);
1614                assert_eq!(state.matched_count(), 1);
1615
1616                // Even when the host clones entries it built once, which
1617                // keeps the same closure.
1618                state.needs_measure = false;
1619                let entries = state.model.entries.clone();
1620                state.install_model(
1621                    CommandModel {
1622                        entries,
1623                        ..CommandModel::default()
1624                    },
1625                    cx,
1626                );
1627                assert!(state.needs_measure);
1628
1629                // A changed label or icon style changes how the label wraps,
1630                // and a changed disabled flag changes what can be highlighted.
1631                let item = || CommandItem::new().label("Renamed");
1632                let icon = || Icon::new(IconName::Check);
1633                for entries in [
1634                    vec![CommandEntry::Item(item())],
1635                    vec![CommandEntry::Item(item().icon(icon()))],
1636                    vec![CommandEntry::Item(item().icon(icon().ml_2()))],
1637                    vec![CommandEntry::Item(
1638                        item().icon(icon().ml_2()).disabled(true),
1639                    )],
1640                ] {
1641                    state.needs_measure = false;
1642                    state.install_model(
1643                        CommandModel {
1644                            entries,
1645                            ..CommandModel::default()
1646                        },
1647                        cx,
1648                    );
1649                    assert!(state.needs_measure);
1650                }
1651                assert_eq!(state.selected_index(), None);
1652            });
1653        });
1654    }
1655
1656    #[gpui::test]
1657    fn a_query_that_matches_nothing_leaves_no_rows(cx: &mut TestAppContext) {
1658        cx.update(crate::init);
1659        let cx = cx.add_empty_window();
1660
1661        cx.update(|window, cx| {
1662            let state = cx.new(|cx| command_state(window, cx, suggestion_entries()));
1663
1664            state.update(cx, |state, cx| {
1665                state.set_query("zzz", window, cx);
1666                state.update_matches(cx);
1667
1668                assert_eq!(state.matched_count(), 0);
1669                assert!(state.rows.is_empty());
1670                assert_eq!(state.selected_index(), None);
1671            });
1672        });
1673    }
1674
1675    #[gpui::test]
1676    fn filterable_off_keeps_every_item_and_resets_the_highlight(cx: &mut TestAppContext) {
1677        cx.update(crate::init);
1678        let cx = cx.add_empty_window();
1679
1680        cx.update(|window, cx| {
1681            let state = cx.new(|cx| {
1682                let mut state = CommandState::new(window, cx);
1683                state.install_model(
1684                    CommandModel {
1685                        entries: suggestion_entries(),
1686                        filterable: false,
1687                        ..CommandModel::default()
1688                    },
1689                    cx,
1690                );
1691                state
1692            });
1693
1694            state.update(cx, |state, cx| {
1695                state.set_selected_index(Some(IndexPath::new(1).section(1)), window, cx);
1696
1697                // "Bil" would locally match only "Billing"; an unfiltered
1698                // palette keeps every row and hands the highlight back to the
1699                // first item instead of the textual match.
1700                state.set_query("Bil", window, cx);
1701
1702                assert_eq!(state.matched_count(), 5);
1703                assert_eq!(state.selected_index(), Some(IndexPath::new(0).section(0)));
1704            });
1705        });
1706    }
1707
1708    #[gpui::test]
1709    fn keywords_match_when_the_label_does_not(cx: &mut TestAppContext) {
1710        cx.update(crate::init);
1711        let cx = cx.add_empty_window();
1712
1713        cx.update(|window, cx| {
1714            let state = cx.new(|cx| {
1715                command_state(
1716                    window,
1717                    cx,
1718                    [CommandEntry::Item(
1719                        CommandItem::new().label("Profile").keywords(["account"]),
1720                    )],
1721                )
1722            });
1723
1724            state.update(cx, |state, cx| {
1725                state.set_query("account", window, cx);
1726                state.update_matches(cx);
1727
1728                assert_eq!(state.matched_count(), 1);
1729            });
1730        });
1731    }
1732
1733    #[gpui::test]
1734    fn non_searchable_command_keeps_every_item(cx: &mut TestAppContext) {
1735        cx.update(crate::init);
1736        let cx = cx.add_empty_window();
1737        cx.update(|window, cx| {
1738            let state = cx.new(|cx| {
1739                command_state_with_options(
1740                    window,
1741                    cx,
1742                    [
1743                        CommandEntry::Item(CommandItem::new().label("alpha")),
1744                        CommandEntry::Item(CommandItem::new().label("beta")),
1745                    ],
1746                    false,
1747                )
1748            });
1749            state.update(cx, |state, cx| {
1750                state.set_query("missing", window, cx);
1751                assert_eq!(state.matched_count(), 2);
1752            });
1753        });
1754    }
1755
1756    #[gpui::test]
1757    fn non_searchable_command_uses_frame_focus(cx: &mut TestAppContext) {
1758        cx.update(crate::init);
1759        let confirmed = Rc::new(RefCell::new(None));
1760        let confirmed_for_render = confirmed.clone();
1761        let (harness, cx) = cx.add_window_view(move |window, cx| Harness {
1762            state: cx.new(|cx| CommandState::new(window, cx)),
1763            command: Rc::new(move |state| {
1764                let confirmed = confirmed_for_render.clone();
1765                Command::new(state)
1766                    .searchable(false)
1767                    .item(CommandItem::new().label("alpha"))
1768                    .item(CommandItem::new().label("beta"))
1769                    .on_confirm(move |index_path, _, _| {
1770                        *confirmed.borrow_mut() = Some(index_path);
1771                    })
1772            }),
1773        });
1774        let state = cx.update(|_, cx| harness.read(cx).state.clone());
1775
1776        cx.run_until_parked();
1777        cx.update(|window, cx| _ = window.draw(cx));
1778        cx.update(|window, cx| {
1779            state.update(cx, |state, cx| state.focus(window, cx));
1780            assert!(state.read(cx).focus_handle.is_focused(window));
1781            window.dispatch_action(Box::new(SelectDown), cx);
1782            window.dispatch_action(Box::new(Confirm { secondary: false }), cx);
1783        });
1784
1785        assert_eq!(*confirmed.borrow(), Some(IndexPath::new(1).section(0)));
1786    }
1787
1788    #[gpui::test]
1789    fn filtered_ungrouped_item_keeps_its_input_row(cx: &mut TestAppContext) {
1790        cx.update(crate::init);
1791        let confirmed = Rc::new(RefCell::new(None));
1792        let confirmed_for_render = confirmed.clone();
1793        let (harness, cx) = cx.add_window_view(move |window, cx| Harness {
1794            state: cx.new(|cx| CommandState::new(window, cx)),
1795            command: Rc::new(move |state| {
1796                let confirmed = confirmed_for_render.clone();
1797                Command::new(state)
1798                    .items([
1799                        CommandItem::new().label("alpha"),
1800                        CommandItem::new().label("beta"),
1801                        CommandItem::new().label("gamma"),
1802                    ])
1803                    .on_confirm(move |index_path, _, _| {
1804                        *confirmed.borrow_mut() = Some(index_path);
1805                    })
1806            }),
1807        });
1808        let state = cx.update(|_, cx| harness.read(cx).state.clone());
1809
1810        cx.run_until_parked();
1811        cx.update(|window, cx| {
1812            state.update(cx, |state, cx| {
1813                state.set_query("gamma", window, cx);
1814                state.focus(window, cx);
1815            });
1816            window.dispatch_action(Box::new(Confirm { secondary: false }), cx);
1817        });
1818
1819        assert_eq!(*confirmed.borrow(), Some(IndexPath::new(2).section(0)));
1820    }
1821
1822    #[gpui::test]
1823    fn initially_rendered_disabled_first_item_selects_and_confirms_the_first_enabled_item(
1824        cx: &mut TestAppContext,
1825    ) {
1826        cx.update(crate::init);
1827        let confirmed = Rc::new(RefCell::new(None));
1828        let confirmed_for_render = confirmed.clone();
1829        let (harness, cx) = cx.add_window_view(move |window, cx| Harness {
1830            state: cx.new(|cx| CommandState::new(window, cx)),
1831            command: Rc::new(move |state| {
1832                let confirmed = confirmed_for_render.clone();
1833                Command::new(state)
1834                    .item(CommandItem::new().label("disabled").disabled(true))
1835                    .item(CommandItem::new().label("enabled"))
1836                    .on_confirm(move |index_path, _, _| {
1837                        *confirmed.borrow_mut() = Some(index_path);
1838                    })
1839            }),
1840        });
1841        let state = cx.update(|_, cx| harness.read(cx).state.clone());
1842
1843        cx.run_until_parked();
1844        cx.update(|window, cx| _ = window.draw(cx));
1845        cx.update(|window, cx| {
1846            state.update(cx, |state, cx| state.focus(window, cx));
1847            window.dispatch_action(Box::new(Confirm { secondary: false }), cx);
1848        });
1849
1850        assert_eq!(
1851            state.read_with(cx, |state, _| state.selected_index()),
1852            Some(IndexPath::new(1).section(0))
1853        );
1854        assert_eq!(*confirmed.borrow(), Some(IndexPath::new(1).section(0)));
1855    }
1856
1857    #[gpui::test]
1858    fn initially_rendered_all_disabled_items_have_no_selected_index_and_ignore_enter(
1859        cx: &mut TestAppContext,
1860    ) {
1861        cx.update(crate::init);
1862        let confirmed = Rc::new(RefCell::new(None));
1863        let confirmed_for_render = confirmed.clone();
1864        let (harness, cx) = cx.add_window_view(move |window, cx| Harness {
1865            state: cx.new(|cx| CommandState::new(window, cx)),
1866            command: Rc::new(move |state| {
1867                let confirmed = confirmed_for_render.clone();
1868                Command::new(state)
1869                    .item(CommandItem::new().label("one").disabled(true))
1870                    .item(CommandItem::new().label("two").disabled(true))
1871                    .on_confirm(move |index_path, _, _| {
1872                        *confirmed.borrow_mut() = Some(index_path);
1873                    })
1874            }),
1875        });
1876        let state = cx.update(|_, cx| harness.read(cx).state.clone());
1877
1878        cx.run_until_parked();
1879        cx.update(|window, cx| _ = window.draw(cx));
1880        cx.update(|window, cx| {
1881            state.update(cx, |state, cx| state.focus(window, cx));
1882            window.dispatch_action(Box::new(Confirm { secondary: false }), cx);
1883        });
1884
1885        assert_eq!(state.read_with(cx, |state, _| state.selected_index()), None);
1886        assert_eq!(*confirmed.borrow(), None);
1887    }
1888
1889    #[gpui::test]
1890    fn non_searchable_command_cancels_without_clearing_a_hidden_query(cx: &mut TestAppContext) {
1891        cx.update(crate::init);
1892        let cancelled = Rc::new(Cell::new(false));
1893        let cancelled_for_render = cancelled.clone();
1894        let query_calls = Rc::new(Cell::new(0));
1895        let query_calls_for_render = query_calls.clone();
1896        let (harness, cx) = cx.add_window_view(move |window, cx| Harness {
1897            state: cx.new(|cx| CommandState::new(window, cx)),
1898            command: Rc::new(move |state| {
1899                let cancelled = cancelled_for_render.clone();
1900                let query_calls = query_calls_for_render.clone();
1901                Command::new(state)
1902                    .searchable(false)
1903                    .item(CommandItem::new().label("alpha"))
1904                    .on_query(move |_, _, _| query_calls.set(query_calls.get() + 1))
1905                    .on_cancel(move |_, _| cancelled.set(true))
1906            }),
1907        });
1908        let state = cx.update(|_, cx| harness.read(cx).state.clone());
1909
1910        cx.run_until_parked();
1911        cx.update(|window, cx| _ = window.draw(cx));
1912        cx.update(|window, cx| {
1913            state.update(cx, |state, cx| {
1914                state.set_query("hidden query", window, cx);
1915                state.focus(window, cx);
1916            });
1917            window.dispatch_action(Box::new(Cancel), cx);
1918        });
1919
1920        assert!(cancelled.get());
1921        assert_eq!(query_calls.get(), 0);
1922        assert_eq!(
1923            state.read_with(cx, |state, cx| state.query(cx)),
1924            "hidden query"
1925        );
1926    }
1927
1928    #[gpui::test]
1929    fn moving_the_highlight_skips_disabled_items_and_wraps(cx: &mut TestAppContext) {
1930        cx.update(crate::init);
1931        let cx = cx.add_empty_window();
1932
1933        cx.update(|window, cx| {
1934            let state = cx.new(|cx| command_state(window, cx, suggestion_entries()));
1935
1936            state.update(cx, |state, cx| {
1937                state.update_matches(cx);
1938                state.reset_selection();
1939                assert_eq!(state.selected_index(), Some(IndexPath::new(0).section(0)));
1940
1941                state.select_by(1, window, cx);
1942                assert_eq!(state.selected_index(), Some(IndexPath::new(1).section(0)));
1943
1944                // "Calculator" is disabled, so it is stepped over.
1945                state.select_by(1, window, cx);
1946                assert_eq!(state.selected_index(), Some(IndexPath::new(0).section(1)));
1947
1948                state.select_by(-1, window, cx);
1949                assert_eq!(state.selected_index(), Some(IndexPath::new(1).section(0)));
1950
1951                // Wraps around the end, skipping the disabled item again.
1952                state.select_by(-1, window, cx);
1953                assert_eq!(state.selected_index(), Some(IndexPath::new(0).section(0)));
1954                state.select_by(-1, window, cx);
1955                assert_eq!(state.selected_index(), Some(IndexPath::new(1).section(1)));
1956            });
1957        });
1958    }
1959
1960    #[gpui::test]
1961    fn owner_can_set_and_clear_selection_by_original_index_path(cx: &mut TestAppContext) {
1962        cx.update(crate::init);
1963        let cx = cx.add_empty_window();
1964
1965        cx.update(|window, cx| {
1966            let initially_empty = cx.new(|cx| CommandState::new(window, cx));
1967            initially_empty.update(cx, |state, cx| {
1968                state.set_selected_index(None, window, cx);
1969                state.install_model(
1970                    CommandModel {
1971                        entries: suggestion_entries().into_iter().collect(),
1972                        ..CommandModel::default()
1973                    },
1974                    cx,
1975                );
1976                assert_eq!(state.selected_index(), None);
1977            });
1978
1979            let state = cx.new(|cx| command_state(window, cx, suggestion_entries()));
1980
1981            state.update(cx, |state, cx| {
1982                let target = IndexPath::new(1).section(1);
1983                state.set_selected_index(Some(target), window, cx);
1984                assert_eq!(state.selected_index(), Some(target));
1985
1986                state.set_selected_index(None, window, cx);
1987                assert_eq!(state.selected_index(), None);
1988
1989                state.install_model(
1990                    CommandModel {
1991                        entries: suggestion_entries().into_iter().collect(),
1992                        ..CommandModel::default()
1993                    },
1994                    cx,
1995                );
1996                assert_eq!(state.selected_index(), None);
1997
1998                state.set_query("calendar", window, cx);
1999                state.set_selected_index(Some(target), window, cx);
2000                assert_eq!(state.selected_index(), None);
2001            });
2002        });
2003    }
2004
2005    #[gpui::test]
2006    fn confirming_a_disabled_item_does_nothing(cx: &mut TestAppContext) {
2007        cx.update(crate::init);
2008        let cx = cx.add_empty_window();
2009
2010        cx.update(|window, cx| {
2011            let state = cx.new(|cx| {
2012                command_state(
2013                    window,
2014                    cx,
2015                    [
2016                        CommandEntry::Item(CommandItem::new().label("enabled")),
2017                        CommandEntry::Item(CommandItem::new().label("disabled").disabled(true)),
2018                    ],
2019                )
2020            });
2021
2022            state.update(cx, |state, cx| {
2023                state.update_matches(cx);
2024
2025                assert_eq!(state.matched_count(), 2);
2026                // Reaching the disabled row is only possible with the mouse or
2027                // an explicit index; confirming it must be a no-op.
2028                state.confirm(1, window, cx);
2029                assert_eq!(state.selected_index, Some(0));
2030            });
2031        });
2032    }
2033
2034    #[gpui::test]
2035    fn a_checked_item_uses_an_xsmall_trailing_check_icon(cx: &mut TestAppContext) {
2036        cx.update(crate::init);
2037        let cx = cx.add_empty_window();
2038        let unchecked_width = Rc::new(Cell::new(None));
2039        let checked_width = Rc::new(Cell::new(None));
2040        let (unchecked, checked) = cx.update(|window, cx| {
2041            let unchecked_state = cx.new(|cx| {
2042                command_state(
2043                    window,
2044                    cx,
2045                    [CommandEntry::Item(CommandItem::new().label("theme"))],
2046                )
2047            });
2048            let checked_state = cx.new(|cx| {
2049                command_state(
2050                    window,
2051                    cx,
2052                    [CommandEntry::Item(
2053                        CommandItem::new().label("theme").checked(true),
2054                    )],
2055                )
2056            });
2057            let unchecked_width = unchecked_width.clone();
2058            let checked_width = checked_width.clone();
2059            (
2060                cx.new(|_| CheckIconWidthHarness {
2061                    state: unchecked_state,
2062                    width: unchecked_width,
2063                }),
2064                cx.new(|_| CheckIconWidthHarness {
2065                    state: checked_state,
2066                    width: checked_width,
2067                }),
2068            )
2069        });
2070
2071        cx.draw(
2072            gpui::point(px(0.), px(0.)),
2073            gpui::AvailableSpace::min_size(),
2074            move |_, _| unchecked.into_any_element(),
2075        );
2076
2077        cx.draw(
2078            gpui::point(px(0.), px(0.)),
2079            gpui::AvailableSpace::min_size(),
2080            move |_, _| checked.into_any_element(),
2081        );
2082
2083        assert_eq!(
2084            checked_width.get().unwrap() - unchecked_width.get().unwrap(),
2085            px(20.)
2086        );
2087    }
2088
2089    struct CheckIconWidthHarness {
2090        state: Entity<CommandState>,
2091        width: Rc<Cell<Option<gpui::Pixels>>>,
2092    }
2093
2094    impl Render for CheckIconWidthHarness {
2095        fn render(
2096            &mut self,
2097            window: &mut Window,
2098            cx: &mut gpui::Context<Self>,
2099        ) -> impl IntoElement {
2100            let width = self.width.clone();
2101            let item = self.state.update(cx, |state, cx| {
2102                state.update_matches(cx);
2103                state.render_item(0, window, cx)
2104            });
2105
2106            div()
2107                .on_children_prepainted(move |bounds, _, _| width.set(Some(bounds[0].size.width)))
2108                .child(item)
2109        }
2110    }
2111
2112    struct Harness {
2113        state: Entity<CommandState>,
2114        command: Rc<dyn Fn(&Entity<CommandState>) -> Command>,
2115    }
2116
2117    impl Render for Harness {
2118        fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
2119            div()
2120                .size_full()
2121                .child((self.command)(&self.state).max_h(px(200.)))
2122        }
2123    }
2124
2125    #[gpui::test]
2126    fn header_and_footer_render_with_current_state(cx: &mut TestAppContext) {
2127        cx.update(crate::init);
2128        let header_calls = Rc::new(Cell::new(0));
2129        let footer_calls = Rc::new(Cell::new(0));
2130        let header_matched_count = Rc::new(Cell::new(None));
2131        let footer_matched_count = Rc::new(Cell::new(None));
2132
2133        let (harness, cx) = cx.add_window_view(|window, cx| HeaderFooterHarness {
2134            state: cx.new(|cx| CommandState::new(window, cx)),
2135            header_calls,
2136            footer_calls,
2137            header_matched_count,
2138            footer_matched_count,
2139        });
2140
2141        cx.run_until_parked();
2142        cx.update(|window, cx| _ = window.draw(cx));
2143
2144        let (header_calls, footer_calls, header_matched_count, footer_matched_count) =
2145            cx.update(|_, cx| {
2146                let harness = harness.read(cx);
2147                (
2148                    harness.header_calls.get(),
2149                    harness.footer_calls.get(),
2150                    harness.header_matched_count.get(),
2151                    harness.footer_matched_count.get(),
2152                )
2153            });
2154        assert!(header_calls > 0);
2155        assert!(footer_calls > 0);
2156        assert_eq!(header_matched_count, Some(2));
2157        assert_eq!(footer_matched_count, Some(2));
2158    }
2159
2160    #[gpui::test]
2161    fn custom_empty_slot_renders_with_current_state(cx: &mut TestAppContext) {
2162        cx.update(crate::init);
2163        let empty_calls = Rc::new(Cell::new(0));
2164        let empty_matched_count = Rc::new(Cell::new(None));
2165        let calls = empty_calls.clone();
2166        let matched_count = empty_matched_count.clone();
2167        let (_harness, cx) = cx.add_window_view(move |window, cx| Harness {
2168            state: cx.new(|cx| CommandState::new(window, cx)),
2169            command: Rc::new(move |state| {
2170                let calls = calls.clone();
2171                let matched_count = matched_count.clone();
2172                Command::new(state).empty(
2173                    move |state: &CommandState, _: &mut Window, _: &mut gpui::App| {
2174                        calls.set(calls.get() + 1);
2175                        matched_count.set(Some(state.matched_count()));
2176                        div().child("Custom empty")
2177                    },
2178                )
2179            }),
2180        });
2181
2182        cx.run_until_parked();
2183        cx.update(|window, cx| _ = window.draw(cx));
2184
2185        assert!(empty_calls.get() > 0);
2186        assert_eq!(empty_matched_count.get(), Some(0));
2187    }
2188
2189    fn entries_with_late_first_enabled_item() -> Vec<CommandEntry> {
2190        vec![
2191            CommandGroup::new()
2192                .label("Disabled")
2193                .items((0..30).map(|ix| {
2194                    CommandItem::new()
2195                        .label(format!("disabled-{ix}"))
2196                        .keywords(["match"])
2197                        .disabled(true)
2198                }))
2199                .into(),
2200            CommandEntry::Separator,
2201            CommandGroup::new()
2202                .label("Enabled")
2203                .item(CommandItem::new().label("enabled").keywords(["match"]))
2204                .into(),
2205        ]
2206    }
2207
2208    fn assert_first_enabled_row_is_scrolled_into_view(
2209        state: &Entity<CommandState>,
2210        cx: &mut TestAppContext,
2211    ) {
2212        let (selected_row, offset) = state.read_with(cx, |state, _| {
2213            (
2214                state.matched[state.selected_index.unwrap()].row_ix,
2215                state.scroll_handle.base_handle().offset().y,
2216            )
2217        });
2218
2219        assert!(selected_row > 30);
2220        assert!(
2221            offset < px(-900.),
2222            "the list should scroll to the selected row, not row zero ({offset:?})",
2223        );
2224    }
2225
2226    #[gpui::test]
2227    fn first_enabled_selection_resets_scroll_to_its_late_row(cx: &mut TestAppContext) {
2228        cx.update(crate::init);
2229        let (harness, cx) = cx.add_window_view(|window, cx| Harness {
2230            state: cx.new(|cx| CommandState::new(window, cx)),
2231            command: Rc::new(|state| {
2232                command_with_entries(state, entries_with_late_first_enabled_item())
2233            }),
2234        });
2235        let state = cx.update(|_, cx| harness.read(cx).state.clone());
2236
2237        cx.run_until_parked();
2238        cx.update(|window, cx| _ = window.draw(cx));
2239        assert_first_enabled_row_is_scrolled_into_view(&state, cx);
2240
2241        cx.update(|window, cx| {
2242            state.update(cx, |state, cx| state.set_query("match", window, cx));
2243            _ = window.draw(cx);
2244        });
2245        assert_first_enabled_row_is_scrolled_into_view(&state, cx);
2246
2247        cx.update(|window, cx| {
2248            harness.update(cx, |_, cx| {
2249                cx.notify();
2250            });
2251            _ = window.draw(cx);
2252        });
2253        assert_first_enabled_row_is_scrolled_into_view(&state, cx);
2254    }
2255
2256    struct HeaderFooterHarness {
2257        state: Entity<CommandState>,
2258        header_calls: Rc<Cell<usize>>,
2259        footer_calls: Rc<Cell<usize>>,
2260        header_matched_count: Rc<Cell<Option<usize>>>,
2261        footer_matched_count: Rc<Cell<Option<usize>>>,
2262    }
2263
2264    impl Render for HeaderFooterHarness {
2265        fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
2266            let header_calls = self.header_calls.clone();
2267            let header_matched_count = self.header_matched_count.clone();
2268            let footer_calls = self.footer_calls.clone();
2269            let footer_matched_count = self.footer_matched_count.clone();
2270
2271            div().size_full().child(
2272                Command::new(&self.state)
2273                    .items([
2274                        CommandItem::new().label("Calendar"),
2275                        CommandItem::new().label("Calculator"),
2276                    ])
2277                    .max_h(px(200.))
2278                    .header(move |state, _, _| {
2279                        header_calls.set(header_calls.get() + 1);
2280                        header_matched_count.set(Some(state.matched_count()));
2281                        div()
2282                    })
2283                    .footer(move |state, _, _| {
2284                        footer_calls.set(footer_calls.get() + 1);
2285                        footer_matched_count.set(Some(state.matched_count()));
2286                        div()
2287                    }),
2288            )
2289        }
2290    }
2291
2292    struct PaddedHarness {
2293        state: Entity<CommandState>,
2294    }
2295
2296    impl Render for PaddedHarness {
2297        fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
2298            div().size_full().child(
2299                Command::new(&self.state)
2300                    .item(
2301                        CommandItem::new()
2302                            .label("fixed")
2303                            .child(|_, _| div().h(px(32.))),
2304                    )
2305                    .max_h(px(200.))
2306                    .p_4(),
2307            )
2308        }
2309    }
2310
2311    struct WrappingHarness {
2312        state: Entity<CommandState>,
2313        width: Pixels,
2314        no_wrap: bool,
2315    }
2316
2317    impl Render for WrappingHarness {
2318        fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
2319            div().size_full().child(
2320                div().w(self.width).child(
2321                    Command::new(&self.state)
2322                        .item(CommandItem::new().label("wrapped").child(|_, _| {
2323                            div()
2324                                .w_full()
2325                                .child("A command row whose content wraps at narrow list widths")
2326                        }))
2327                        .max_h(px(200.))
2328                        .when(self.no_wrap, |this| this.whitespace_nowrap()),
2329                ),
2330            )
2331        }
2332    }
2333
2334    #[gpui::test]
2335    fn wrapping_rows_remeasure_for_the_list_content_width(cx: &mut TestAppContext) {
2336        cx.update(crate::init);
2337
2338        let (harness, cx) = cx.add_window_view(|window, cx| WrappingHarness {
2339            state: cx.new(|cx| CommandState::new(window, cx)),
2340            width: px(360.),
2341            no_wrap: false,
2342        });
2343
2344        cx.run_until_parked();
2345        cx.update(|window, cx| _ = window.draw(cx));
2346        cx.run_until_parked();
2347        cx.update(|window, cx| _ = window.draw(cx));
2348
2349        let wide = cx.update(|_, cx| harness.read(cx).state.read(cx).row_sizes[0].height);
2350
2351        cx.update(|_, cx| {
2352            harness.update(cx, |harness, cx| {
2353                harness.width = px(120.);
2354                cx.notify();
2355            })
2356        });
2357        cx.run_until_parked();
2358        cx.update(|window, cx| _ = window.draw(cx));
2359        cx.run_until_parked();
2360        cx.update(|window, cx| _ = window.draw(cx));
2361        let narrow = cx.update(|_, cx| harness.read(cx).state.read(cx).row_sizes[0].height);
2362
2363        assert!(
2364            narrow > wide,
2365            "the narrow list should cache a taller wrapped row ({narrow:?} vs {wide:?})",
2366        );
2367    }
2368
2369    #[gpui::test]
2370    fn wrapping_rows_remeasure_when_rem_size_changes(cx: &mut TestAppContext) {
2371        cx.update(crate::init);
2372
2373        let (harness, cx) = cx.add_window_view(|window, cx| {
2374            window.set_rem_size(px(20.));
2375            WrappingHarness {
2376                state: cx.new(|cx| CommandState::new(window, cx)),
2377                width: px(160.),
2378                no_wrap: false,
2379            }
2380        });
2381
2382        cx.run_until_parked();
2383        cx.update(|window, cx| _ = window.draw(cx));
2384        cx.run_until_parked();
2385        cx.update(|window, cx| _ = window.draw(cx));
2386        let smaller_rem = cx.update(|_, cx| harness.read(cx).state.read(cx).row_sizes[0].height);
2387
2388        cx.update(|window, cx| {
2389            window.set_rem_size(px(28.));
2390            _ = window.draw(cx);
2391        });
2392        cx.run_until_parked();
2393        cx.update(|window, cx| _ = window.draw(cx));
2394        let larger_rem = cx.update(|_, cx| harness.read(cx).state.read(cx).row_sizes[0].height);
2395
2396        assert!(
2397            larger_rem > smaller_rem,
2398            "a larger rem should remeasure the fixed-width wrapped row ({larger_rem:?} vs {smaller_rem:?})",
2399        );
2400    }
2401
2402    #[gpui::test]
2403    fn wrapping_rows_remeasure_when_inherited_typography_changes(cx: &mut TestAppContext) {
2404        cx.update(crate::init);
2405
2406        let (harness, cx) = cx.add_window_view(|window, cx| WrappingHarness {
2407            state: cx.new(|cx| CommandState::new(window, cx)),
2408            width: px(160.),
2409            no_wrap: false,
2410        });
2411
2412        cx.run_until_parked();
2413        cx.update(|window, cx| _ = window.draw(cx));
2414        cx.run_until_parked();
2415        cx.update(|window, cx| _ = window.draw(cx));
2416        let wrapped_height = cx.update(|_, cx| harness.read(cx).state.read(cx).row_sizes[0].height);
2417
2418        cx.update(|window, cx| {
2419            harness.update(cx, |harness, cx| {
2420                harness.no_wrap = true;
2421                cx.notify();
2422            });
2423            _ = window.draw(cx);
2424        });
2425        cx.run_until_parked();
2426        cx.update(|window, cx| _ = window.draw(cx));
2427        let no_wrap_height = cx.update(|_, cx| harness.read(cx).state.read(cx).row_sizes[0].height);
2428        assert!(
2429            no_wrap_height < wrapped_height,
2430            "a changed inherited typography should remeasure the fixed-width row ({no_wrap_height:?} vs {wrapped_height:?})",
2431        );
2432    }
2433
2434    #[gpui::test]
2435    fn outer_command_padding_does_not_inflate_measured_row_heights(cx: &mut TestAppContext) {
2436        cx.update(crate::init);
2437
2438        let (harness, cx) = cx.add_window_view(|window, cx| PaddedHarness {
2439            state: cx.new(|cx| CommandState::new(window, cx)),
2440        });
2441
2442        cx.run_until_parked();
2443        cx.update(|window, cx| _ = window.draw(cx));
2444        cx.run_until_parked();
2445        cx.update(|window, cx| _ = window.draw(cx));
2446        let height = cx.update(|_, cx| harness.read(cx).state.read(cx).row_sizes[0].height);
2447
2448        assert_eq!(height, px(44.));
2449    }
2450
2451    #[gpui::test]
2452    fn custom_rows_keep_independent_heights(cx: &mut TestAppContext) {
2453        cx.update(crate::init);
2454
2455        let (harness, cx) = cx.add_window_view(|window, cx| Harness {
2456            state: cx.new(|cx| CommandState::new(window, cx)),
2457            command: Rc::new(|state| {
2458                Command::new(state)
2459                    .group(
2460                        CommandGroup::new().label("Short").item(
2461                            CommandItem::new()
2462                                .label("short")
2463                                .child(|_, _| div().h(px(32.))),
2464                        ),
2465                    )
2466                    .separator()
2467                    .group(
2468                        CommandGroup::new().label("Tall").item(
2469                            CommandItem::new()
2470                                .label("tall")
2471                                .child(|_, _| div().h(px(72.))),
2472                        ),
2473                    )
2474            }),
2475        });
2476
2477        cx.run_until_parked();
2478        cx.update(|window, cx| _ = window.draw(cx));
2479        let row_sizes = cx.update(|_, cx| harness.read(cx).state.read(cx).row_sizes.clone());
2480
2481        assert_eq!(row_sizes.len(), 5);
2482        assert!(row_sizes[0].height > px(0.));
2483        assert_eq!(row_sizes[1].height, px(44.));
2484        assert_eq!(row_sizes[2].height, px(SEPARATOR_ROW_HEIGHT));
2485        assert!(row_sizes[3].height > px(0.));
2486        assert_eq!(row_sizes[4].height, px(84.));
2487    }
2488
2489    #[gpui::test]
2490    fn reinstalling_a_model_preserves_selection_by_index_path_and_remeasures_rows(
2491        cx: &mut TestAppContext,
2492    ) {
2493        cx.update(crate::init);
2494        let reversed = Rc::new(Cell::new(false));
2495        let reversed_for_render = reversed.clone();
2496        let (harness, cx) = cx.add_window_view(|window, cx| Harness {
2497            state: cx.new(|cx| CommandState::new(window, cx)),
2498            command: Rc::new(move |state| {
2499                if reversed_for_render.get() {
2500                    Command::new(state)
2501                        .item(
2502                            CommandItem::new()
2503                                .label("beta")
2504                                .child(|_, _| div().h(px(72.))),
2505                        )
2506                        .item(
2507                            CommandItem::new()
2508                                .label("alpha")
2509                                .child(|_, _| div().h(px(32.))),
2510                        )
2511                } else {
2512                    Command::new(state)
2513                        .item(
2514                            CommandItem::new()
2515                                .label("alpha")
2516                                .child(|_, _| div().h(px(32.))),
2517                        )
2518                        .item(
2519                            CommandItem::new()
2520                                .label("beta")
2521                                .child(|_, _| div().h(px(72.))),
2522                        )
2523                }
2524            }),
2525        });
2526        let state = cx.update(|_, cx| harness.read(cx).state.clone());
2527
2528        cx.run_until_parked();
2529        cx.update(|window, cx| _ = window.draw(cx));
2530        cx.update(|window, cx| {
2531            state.update(cx, |state, cx| state.select_by(1, window, cx));
2532        });
2533        assert_eq!(
2534            state.read_with(cx, |state, _| state.selected_index()),
2535            Some(IndexPath::new(1).section(0)),
2536        );
2537
2538        reversed.set(true);
2539        cx.update(|window, cx| {
2540            harness.update(cx, |_, cx| cx.notify());
2541            _ = window.draw(cx);
2542        });
2543
2544        let (selected_matched_index, selected_index, row_sizes) =
2545            state.read_with(cx, |state, _| {
2546                (
2547                    state.selected_index,
2548                    state.selected_index(),
2549                    state.row_sizes.clone(),
2550                )
2551            });
2552        assert_eq!(selected_matched_index, Some(1));
2553        assert_eq!(selected_index, Some(IndexPath::new(1).section(0)));
2554        assert_eq!(row_sizes[0].height, px(84.));
2555        assert_eq!(row_sizes[1].height, px(44.));
2556    }
2557
2558    #[gpui::test]
2559    fn a_state_redraw_reuses_the_installed_custom_row_measurement(cx: &mut TestAppContext) {
2560        cx.update(crate::init);
2561        let renders = Rc::new(Cell::new(0));
2562        let count = renders.clone();
2563        let cx = cx.add_empty_window();
2564        let state = cx.update(|window, cx| {
2565            cx.new(|cx| {
2566                command_state(
2567                    window,
2568                    cx,
2569                    [CommandEntry::Item(
2570                        CommandItem::new().label("custom").child(move |_, _| {
2571                            count.set(count.get() + 1);
2572                            div().child("Custom")
2573                        }),
2574                    )],
2575                )
2576            })
2577        });
2578
2579        let first_state = state.clone();
2580        cx.draw(
2581            gpui::point(px(0.), px(0.)),
2582            gpui::AvailableSpace::min_size(),
2583            move |_, _| first_state.into_any_element(),
2584        );
2585        let settled_state = state.clone();
2586        cx.draw(
2587            gpui::point(px(0.), px(0.)),
2588            gpui::AvailableSpace::min_size(),
2589            move |_, _| settled_state.into_any_element(),
2590        );
2591        let after_first_draw = renders.get();
2592        cx.draw(
2593            gpui::point(px(0.), px(0.)),
2594            gpui::AvailableSpace::min_size(),
2595            move |_, _| state.into_any_element(),
2596        );
2597
2598        assert_eq!(renders.get() - after_first_draw, 2);
2599    }
2600
2601    #[gpui::test]
2602    fn moving_past_the_visible_rows_scrolls_the_list(cx: &mut TestAppContext) {
2603        cx.update(crate::init);
2604
2605        let (harness, cx) = cx.add_window_view(|window, cx| Harness {
2606            state: cx.new(|cx| CommandState::new(window, cx)),
2607            command: Rc::new(|state| {
2608                Command::new(state)
2609                    .items((0..50).map(|ix| CommandItem::new().label(format!("Item {ix}"))))
2610            }),
2611        });
2612
2613        cx.run_until_parked();
2614        cx.update(|window, cx| _ = window.draw(cx));
2615
2616        let state = cx.update(|_, cx| harness.read(cx).state.clone());
2617        assert_eq!(
2618            state.read_with(cx, |state, _| state.scroll_handle.base_handle().offset().y),
2619            px(0.),
2620        );
2621
2622        // The list is capped well below 50 rows, so walking to the last one has
2623        // to bring the viewport with it.
2624        cx.update(|window, cx| {
2625            state.update(cx, |state, cx| {
2626                for _ in 0..49 {
2627                    state.select_by(1, window, cx);
2628                }
2629            })
2630        });
2631        cx.update(|window, cx| _ = window.draw(cx));
2632
2633        assert_eq!(
2634            state.read_with(cx, |state, _| state.selected_index()),
2635            Some(IndexPath::new(49).section(0))
2636        );
2637        assert!(
2638            state.read_with(cx, |state, _| state.scroll_handle.base_handle().offset().y) < px(0.),
2639            "selecting the last row should have scrolled the list",
2640        );
2641    }
2642
2643    #[gpui::test]
2644    fn a_reinstalled_model_does_not_scroll_a_preserved_selection(cx: &mut TestAppContext) {
2645        cx.update(crate::init);
2646
2647        let (harness, cx) = cx.add_window_view(|window, cx| Harness {
2648            state: cx.new(|cx| CommandState::new(window, cx)),
2649            command: Rc::new(|state| {
2650                Command::new(state)
2651                    .items((0..50).map(|ix| CommandItem::new().label(format!("Item {ix}"))))
2652            }),
2653        });
2654
2655        cx.run_until_parked();
2656        cx.update(|window, cx| _ = window.draw(cx));
2657
2658        let state = cx.update(|_, cx| harness.read(cx).state.clone());
2659
2660        // Hover selection does not scroll, and the host re-render it notifies
2661        // reinstalls the model with the selection preserved. That reinstall
2662        // must not scroll either, or the hover still moves the list one frame
2663        // later.
2664        cx.update(|window, cx| {
2665            state.update(cx, |state, cx| state.select(10, window, cx));
2666        });
2667        cx.update(|window, cx| _ = window.draw(cx));
2668
2669        assert_eq!(
2670            state.read_with(cx, |state, _| state.scroll_handle.base_handle().offset().y),
2671            px(0.),
2672            "reinstalling the model must keep the scroll position",
2673        );
2674    }
2675}