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