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