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