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