Skip to main content

herogpui_components/
command_palette.rs

1//! `CommandPalette` — a modal command search (HeroGPUI extension; HeroUI v3
2//! has no command palette).
3//!
4//! The palette is a dialog over a scrim, on the same overlay stack as
5//! `Modal`: it takes the focus as it opens (into its search field), keeps Tab
6//! inside, closes on Escape or a press outside while it is the topmost
7//! overlay, and hands the focus back to whatever held it when it closes.
8//! Inside:
9//!
10//! - **Search.** Typing filters the commands. Every whitespace-separated word
11//!   of the query must occur in a command's label, one of its `keywords` or
12//!   its group name, compared by `useFilter`'s default `base` sensitivity
13//!   (case and accents ignored). The filtered list is cached across frames
14//!   (`matches.rs`) while the query and the commands stay unchanged.
15//! - **Groups.** Commands keep their order and are grouped under their
16//!   `group` heading, groups in the order they first appear.
17//! - **Keyboard.** The search field keeps the focus; Up and Down move a
18//!   highlighted command (wrapping, skipping disabled ones), Enter runs it,
19//!   Escape closes. A new query highlights the first match. The pointer
20//!   highlights what it hovers and a press runs it.
21//! - **Shortcuts** are displayed as `Kbd` keys beside a command; the palette
22//!   does not bind them.
23//! - **Empty state.** With no match, `empty_text` (the `NoResults` UI string by
24//!   default) takes the list's place.
25//!
26//! Open state is controlled (`is_open` + `on_open_change`) or uncontrolled
27//! (`default_open`). To open it from anywhere with Cmd-K (Ctrl-K off macOS),
28//! check [`is_command_palette_shortcut`] in a root `on_key_down`, or bind a
29//! GPUI action to the `"secondary-k"` keystroke:
30//!
31//! ```
32//! use gpui::{div, prelude::*, Context, Window};
33//! use herogpui_components::{is_command_palette_shortcut, CommandItem, CommandPalette};
34//!
35//! struct App {
36//!     palette_open: bool,
37//! }
38//!
39//! impl gpui::Render for App {
40//!     fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
41//!         div()
42//!             .size_full()
43//!             .on_key_down(cx.listener(|this, event: &gpui::KeyDownEvent, _, cx| {
44//!                 if is_command_palette_shortcut(&event.keystroke) {
45//!                     this.palette_open = !this.palette_open;
46//!                     cx.notify();
47//!                 }
48//!             }))
49//!             .child(
50//!                 CommandPalette::new(
51//!                     "palette",
52//!                     vec![
53//!                         CommandItem::new("new", "New file").group("File").shortcut(["⌘", "N"]),
54//!                         CommandItem::new("theme", "Toggle theme").keywords(["dark", "light"]),
55//!                     ],
56//!                 )
57//!                 .is_open(self.palette_open)
58//!                 .on_open_change(cx.listener(|this, open: &bool, _, cx| {
59//!                     this.palette_open = *open;
60//!                     cx.notify();
61//!                 }))
62//!                 .on_select(|key, _, _| println!("run {key}")),
63//!             )
64//!     }
65//! }
66//! ```
67//!
68//! **Accessibility.** The panel is `Role::Dialog` named by `label`, the
69//! results are `Role::ListBox` and each command `Role::ListBoxOption` named by
70//! its label, with the highlighted one `selected` and marked as the active
71//! descendant of the focused search field — the shape cmdk and React Aria's
72//! Autocomplete give a search-driven list with virtual focus.
73
74use std::collections::HashMap;
75use std::rc::Rc;
76use std::sync::Arc;
77
78use gpui::{
79    div, prelude::FluentBuilder as _, px, App, ElementId, Entity, Focusable as _,
80    InteractiveElement, IntoElement, ParentElement, Pixels, RenderOnce, ScrollHandle, SharedString,
81    StatefulInteractiveElement, Styled, Window,
82};
83use herogpui_core::element_id;
84use herogpui_theme::ActiveTheme;
85
86use crate::a11y::{self, A11y as _};
87use crate::input::{Input, InputState};
88use crate::matches::MatchesCache;
89use crate::picker_item::PickerItem;
90use crate::{anim, util, Filter, Kbd, Sensitivity};
91
92type SelectCallback = Arc<dyn Fn(&SharedString, &mut Window, &mut App) + 'static>;
93type OpenChange = Arc<dyn Fn(&bool, &mut Window, &mut App) + 'static>;
94type CommandFilter = Rc<dyn Fn(&CommandItem, &str) -> bool + 'static>;
95
96/// The height of one command row.
97const ROW_HEIGHT: Pixels = px(40.);
98/// The height of a group heading.
99const HEADING_HEIGHT: Pixels = px(28.);
100
101/// Whether `keystroke` is the conventional command-palette shortcut: Cmd-K on
102/// macOS, Ctrl-K elsewhere (GPUI's `secondary` modifier), with no other
103/// modifier held.
104pub fn is_command_palette_shortcut(keystroke: &gpui::Keystroke) -> bool {
105    let m = &keystroke.modifiers;
106    keystroke.key == "k" && m.secondary() && !m.alt && !m.shift && !(m.control && m.platform)
107}
108
109/// One command of a [`CommandPalette`].
110#[must_use]
111#[derive(Clone)]
112pub struct CommandItem {
113    key: SharedString,
114    label: SharedString,
115    group: Option<SharedString>,
116    description: Option<SharedString>,
117    icon: Option<SharedString>,
118    shortcut: Vec<SharedString>,
119    keywords: Vec<SharedString>,
120    is_disabled: bool,
121}
122
123impl CommandItem {
124    /// A command `label` identified by `key`, which [`CommandPalette::on_select`]
125    /// reports. Keys must be unique in the palette.
126    pub fn new(key: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
127        Self {
128            key: key.into(),
129            label: label.into(),
130            group: None,
131            description: None,
132            icon: None,
133            shortcut: Vec::new(),
134            keywords: Vec::new(),
135            is_disabled: false,
136        }
137    }
138
139    /// The heading this command is listed under.
140    pub fn group(mut self, group: impl Into<SharedString>) -> Self {
141        self.group = Some(group.into());
142        self
143    }
144
145    /// A secondary line drawn after the label, in the muted colour.
146    pub fn description(mut self, description: impl Into<SharedString>) -> Self {
147        self.description = Some(description.into());
148        self
149    }
150
151    /// An icon before the label: an [`crate::IconName`] or any served SVG path.
152    pub fn icon(mut self, icon: impl Into<SharedString>) -> Self {
153        self.icon = Some(icon.into());
154        self
155    }
156
157    /// The keys of the command's shortcut, each drawn as one `Kbd`
158    /// (`["⌘", "N"]`). Display only.
159    pub fn shortcut(mut self, keys: impl IntoIterator<Item = impl Into<SharedString>>) -> Self {
160        self.shortcut = keys.into_iter().map(Into::into).collect();
161        self
162    }
163
164    /// Extra words the search matches besides the label and the group.
165    pub fn keywords(mut self, words: impl IntoIterator<Item = impl Into<SharedString>>) -> Self {
166        self.keywords = words.into_iter().map(Into::into).collect();
167        self
168    }
169
170    /// A disabled command is listed dimmed and cannot be highlighted or run.
171    pub fn is_disabled(mut self, disabled: bool) -> Self {
172        self.is_disabled = disabled;
173        self
174    }
175
176    /// The command's key.
177    pub fn key(&self) -> &SharedString {
178        &self.key
179    }
180
181    /// The command's label.
182    pub fn label(&self) -> &SharedString {
183        &self.label
184    }
185
186    /// Everything the default search reads, joined by newlines so a query
187    /// word cannot match across two fields.
188    fn search_text(&self) -> String {
189        let mut text = self.label.to_string();
190        for part in self.keywords.iter().chain(self.group.iter()) {
191            text.push('\n');
192            text.push_str(part);
193        }
194        text
195    }
196}
197
198/// Whether `item` matches `query` under the default search.
199fn default_matches(filter: &Filter, text: &str, query: &str) -> bool {
200    query
201        .split_whitespace()
202        .all(|word| text.split('\n').any(|field| filter.contains(field, word)))
203}
204
205/// A modal command search. See the [module docs](self).
206#[must_use]
207#[derive(IntoElement)]
208pub struct CommandPalette {
209    id: ElementId,
210    items: Vec<CommandItem>,
211    is_open: Option<bool>,
212    default_open: bool,
213    placeholder: Option<SharedString>,
214    empty_text: Option<SharedString>,
215    label: SharedString,
216    width: Pixels,
217    max_list_height: Pixels,
218    close_on_select: bool,
219    filter: Option<CommandFilter>,
220    on_select: Option<SelectCallback>,
221    on_open_change: Option<OpenChange>,
222    sx: Option<Box<gpui::StyleRefinement>>,
223}
224
225impl CommandPalette {
226    /// A palette over `items`. `id` keys its state; give every instance its
227    /// own.
228    pub fn new(id: impl Into<ElementId>, items: Vec<CommandItem>) -> Self {
229        Self {
230            id: id.into(),
231            items,
232            is_open: None,
233            default_open: false,
234            placeholder: None,
235            empty_text: None,
236            label: SharedString::new_static("Command palette"),
237            width: px(560.),
238            max_list_height: px(320.),
239            close_on_select: true,
240            filter: None,
241            on_select: None,
242            on_open_change: None,
243            sx: None,
244        }
245    }
246
247    /// The controlled open state.
248    pub fn is_open(mut self, open: bool) -> Self {
249        self.is_open = Some(open);
250        self
251    }
252
253    /// The initial open state of an uncontrolled palette.
254    pub fn default_open(mut self, open: bool) -> Self {
255        self.default_open = open;
256        self
257    }
258
259    /// The search field's placeholder; the `Search` UI string by default.
260    pub fn placeholder(mut self, text: impl Into<SharedString>) -> Self {
261        self.placeholder = Some(text.into());
262        self
263    }
264
265    /// What the list shows when nothing matches; the `NoResults` UI string by
266    /// default.
267    pub fn empty_text(mut self, text: impl Into<SharedString>) -> Self {
268        self.empty_text = Some(text.into());
269        self
270    }
271
272    /// The dialog's accessible name; "Command palette" by default.
273    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
274        self.label = label.into();
275        self
276    }
277
278    /// The panel's width; 560px by default.
279    pub fn width(mut self, width: impl Into<Pixels>) -> Self {
280        self.width = width.into();
281        self
282    }
283
284    /// The results list's maximum height before it scrolls; 320px by default.
285    pub fn max_list_height(mut self, height: impl Into<Pixels>) -> Self {
286        self.max_list_height = height.into();
287        self
288    }
289
290    /// Whether running a command closes the palette; `true` by default.
291    pub fn close_on_select(mut self, close: bool) -> Self {
292        self.close_on_select = close;
293        self
294    }
295
296    /// Replaces the default search: `filter(item, query)` decides whether a
297    /// command is listed. An empty query lists everything either way.
298    pub fn filter(mut self, filter: impl Fn(&CommandItem, &str) -> bool + 'static) -> Self {
299        self.filter = Some(Rc::new(filter));
300        self
301    }
302
303    /// Runs with the chosen command's key.
304    pub fn on_select(mut self, f: impl Fn(&SharedString, &mut Window, &mut App) + 'static) -> Self {
305        self.on_select = Some(Arc::new(f));
306        self
307    }
308
309    /// Reports every open or close the palette asks for.
310    pub fn on_open_change(mut self, f: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
311        self.on_open_change = Some(Arc::new(f));
312        self
313    }
314
315    /// Caller-owned styling refined over the panel after the theme's values.
316    pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
317        util::refine_sx(&mut self.sx, style);
318        self
319    }
320}
321
322/// One line of the rendered list: a heading or a command (by index into the
323/// palette's items).
324#[derive(Clone, Copy, PartialEq, Eq, Debug)]
325enum Line {
326    Heading(usize),
327    Command(usize),
328}
329
330/// The matched commands grouped under their headings, in first-appearance
331/// order, as the flat line list the scroller renders.
332fn group_lines(items: &[CommandItem], matched: &[usize]) -> (Vec<Line>, Vec<SharedString>) {
333    let mut groups: Vec<Option<SharedString>> = Vec::new();
334    let mut members: Vec<Vec<usize>> = Vec::new();
335    for &ix in matched {
336        let group = items[ix].group.clone();
337        let at = match groups.iter().position(|g| *g == group) {
338            Some(at) => at,
339            None => {
340                groups.push(group);
341                members.push(Vec::new());
342                groups.len() - 1
343            }
344        };
345        members[at].push(ix);
346    }
347    let mut lines = Vec::new();
348    let mut headings = Vec::new();
349    for (group, rows) in groups.into_iter().zip(members) {
350        if let Some(name) = group {
351            lines.push(Line::Heading(headings.len()));
352            headings.push(name);
353        }
354        lines.extend(rows.into_iter().map(Line::Command));
355    }
356    (lines, headings)
357}
358
359/// The open-state writer every close path shares.
360#[derive(Clone)]
361struct Opener {
362    own: Option<Entity<bool>>,
363    on_open_change: Option<OpenChange>,
364}
365
366impl Opener {
367    fn set(&self, open: bool, window: &mut Window, cx: &mut App) {
368        if let Some(own) = &self.own {
369            own.update(cx, |value, cx| {
370                *value = open;
371                cx.notify();
372            });
373        }
374        if let Some(cb) = &self.on_open_change {
375            cb(&open, window, cx);
376        }
377    }
378}
379
380impl RenderOnce for CommandPalette {
381    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
382        let base = self.id.clone();
383        let (open, own) = util::controlled(
384            window,
385            cx,
386            element_id::scoped(&base, "open"),
387            self.is_open,
388            self.default_open,
389        );
390        let (phase, token) =
391            util::overlay_scope(window, cx, element_id::scoped(&base, "phase"), open, true);
392        if phase == util::OverlayPhase::Closed {
393            crate::modal::release_dialog_focus(&base, window, cx);
394            return div().into_any_element();
395        }
396        let exiting = phase == util::OverlayPhase::Exiting;
397        let opener = Opener {
398            own,
399            on_open_change: self.on_open_change.clone(),
400        };
401
402        // The query lives only while the palette is on screen, so every
403        // opening starts from an empty search.
404        let query_state =
405            window.use_keyed_state(element_id::scoped(&base, "query"), cx, |_, cx| {
406                InputState::new(cx)
407            });
408        let cursor = window.use_keyed_state(element_id::scoped(&base, "cursor"), cx, |_, _| {
409            None::<SharedString>
410        });
411        let last_query =
412            window.use_keyed_state(element_id::scoped(&base, "last-query"), cx, |_, _| {
413                None::<String>
414            });
415        let scroll = window
416            .use_keyed_state(element_id::scoped(&base, "scroll"), cx, |_, _| {
417                ScrollHandle::new()
418            })
419            .read(cx)
420            .clone();
421        let cache = window.use_keyed_state(element_id::scoped(&base, "matches"), cx, |_, _| {
422            MatchesCache::default()
423        });
424        let input_focus = query_state.read(cx).focus_handle(cx);
425        // Claimed on every frame the palette is on screen, the exit included,
426        // so the remembered return target survives until the close.
427        crate::modal::claim_dialog_focus(&base, &input_focus, window, cx);
428
429        // Match. The default search goes through the shared cache, keyed by
430        // every command's key and search text; a custom filter cannot join a
431        // cache key and runs every frame.
432        let query = query_state.read(cx).value().to_owned();
433        let items = Rc::new(self.items);
434        let matched: Vec<usize> = if query.trim().is_empty() {
435            (0..items.len()).collect()
436        } else if let Some(filter) = &self.filter {
437            (0..items.len())
438                .filter(|&ix| filter(&items[ix], &query))
439                .collect()
440        } else {
441            let index: HashMap<SharedString, usize> = items
442                .iter()
443                .enumerate()
444                .map(|(ix, item)| (item.key.clone(), ix))
445                .collect();
446            let pickers: Rc<[PickerItem]> = items
447                .iter()
448                .map(|item| PickerItem::new(item.key.clone(), item.search_text()))
449                .collect::<Vec<_>>()
450                .into();
451            let hits = cache.update(cx, |cache, _| {
452                cache.get(pickers, &query, usize::MAX, |all| {
453                    let filter = Filter::new(Sensitivity::Base);
454                    all.iter()
455                        .filter(|p| default_matches(&filter, p.label(), &query))
456                        .cloned()
457                        .collect()
458                })
459            });
460            hits.iter()
461                .filter_map(|p| index.get(p.key()).copied())
462                .collect()
463        };
464        let (lines, headings) = group_lines(&items, &matched);
465        let stops: Vec<usize> = lines
466            .iter()
467            .enumerate()
468            .filter_map(|(at, line)| match line {
469                Line::Command(ix) if !items[*ix].is_disabled => Some(at),
470                _ => None,
471            })
472            .collect();
473
474        // A new query highlights the first match, as cmdk does; otherwise the
475        // highlight is a key and follows its command through re-filtering.
476        let query_changed = last_query.read(cx).as_deref() != Some(query.as_str());
477        if query_changed {
478            last_query.update(cx, |q, _| *q = Some(query.clone()));
479        }
480        let held_line = cursor.read(cx).as_ref().and_then(|key| {
481            lines.iter().position(|line| match line {
482                Line::Command(ix) => &items[*ix].key == key,
483                Line::Heading(_) => false,
484            })
485        });
486        let cursor_line = if query_changed {
487            None
488        } else {
489            held_line.filter(|at| stops.contains(at))
490        }
491        .or_else(|| stops.first().copied());
492        let cursor_key = cursor_line.and_then(|at| match lines[at] {
493            Line::Command(ix) => Some(items[ix].key.clone()),
494            Line::Heading(_) => None,
495        });
496        if *cursor.read(cx) != cursor_key {
497            cursor.update(cx, |c, _| *c = cursor_key.clone());
498        }
499        if query_changed {
500            if let Some(at) = cursor_line {
501                scroll.scroll_to_item(at);
502            }
503        }
504
505        let run = util::shared({
506            let opener = opener.clone();
507            let on_select = self.on_select.clone();
508            let items = items.clone();
509            let close_on_select = self.close_on_select;
510            move |ix: usize, window: &mut Window, cx: &mut App| {
511                let item = &items[ix];
512                if item.is_disabled {
513                    return;
514                }
515                if close_on_select {
516                    opener.set(false, window, cx);
517                }
518                if let Some(cb) = &on_select {
519                    cb(&item.key, window, cx);
520                }
521            }
522        });
523        let dismiss = util::shared({
524            let opener = opener;
525            move |window: &mut Window, cx: &mut App| {
526                opener.set(false, window, cx);
527                util::DismissResult::Handled
528            }
529        });
530
531        let colors = cx.colors().clone();
532        let layout = cx.layout().clone();
533        let radius = util::container_radius(cx);
534        let row_radius = util::soft_radius(cx);
535        let pointer = util::interactive_cursor(cx);
536        let placeholder = self
537            .placeholder
538            .clone()
539            .unwrap_or_else(|| crate::i18n::ui_string(crate::i18n::UiString::Search, cx));
540        let empty_text = self
541            .empty_text
542            .clone()
543            .unwrap_or_else(|| crate::i18n::ui_string(crate::i18n::UiString::NoResults, cx));
544
545        let search = div()
546            .flex()
547            .items_center()
548            .gap(px(8.))
549            .px(px(12.))
550            .h(px(48.))
551            .border_b(layout.border_width)
552            .border_color(colors.separator)
553            .child(
554                gpui::svg()
555                    .size(util::FIELD_ICON)
556                    .flex_shrink_0()
557                    .path(crate::icons::SEARCH)
558                    .text_color(colors.muted),
559            )
560            .child(
561                div().flex_1().min_w_0().child(
562                    Input::new(&query_state)
563                        .placeholder(placeholder)
564                        .is_bare(true)
565                        .focus_ring(false)
566                        .full_width(),
567                ),
568            );
569
570        let mut list = div()
571            .id(element_id::scoped(&base, "list"))
572            .a11y_named(
573                a11y::Role::ListBox,
574                &a11y::Name::labelled(self.label.clone()),
575            )
576            .track_scroll(&scroll)
577            .overflow_y_scroll()
578            .max_h(self.max_list_height)
579            .flex()
580            .flex_col()
581            .p(px(6.))
582            .debug_selector({
583                let name = format!("{}-list", selector_base(&base));
584                move || name
585            });
586        if lines.is_empty() {
587            list = list.child(
588                div()
589                    .flex()
590                    .items_center()
591                    .justify_center()
592                    .h(px(64.))
593                    .text_color(colors.muted)
594                    .debug_selector({
595                        let name = format!("{}-empty", selector_base(&base));
596                        move || name
597                    })
598                    .child(empty_text),
599            );
600        }
601        for (at, line) in lines.iter().enumerate() {
602            match *line {
603                Line::Heading(h) => {
604                    list = list.child(
605                        div()
606                            .flex()
607                            .items_end()
608                            .h(HEADING_HEIGHT)
609                            .flex_shrink_0()
610                            .px(px(8.))
611                            .pb(px(4.))
612                            .text_size(px(12.))
613                            .line_height(px(16.))
614                            .font_weight(gpui::FontWeight::MEDIUM)
615                            .text_color(colors.muted)
616                            .child(headings[h].clone()),
617                    );
618                }
619                Line::Command(ix) => {
620                    let item = &items[ix];
621                    let highlighted = cursor_line == Some(at);
622                    let mut row = div()
623                        .id(element_id::scoped(
624                            &element_id::scoped(&base, "item"),
625                            item.key.clone(),
626                        ))
627                        .a11y_named(
628                            a11y::Role::ListBoxOption,
629                            &a11y::Name::labelled(item.label.clone())
630                                .described(item.description.clone()),
631                        )
632                        .a11y_selected(highlighted)
633                        .when(highlighted, |el| el.a11y_active_descendant())
634                        .flex()
635                        .items_center()
636                        .gap(px(10.))
637                        .h(ROW_HEIGHT)
638                        .flex_shrink_0()
639                        .px(px(8.))
640                        .rounded(row_radius)
641                        .text_color(colors.foreground)
642                        .when(highlighted, |el| el.bg(colors.default.color))
643                        .debug_selector({
644                            let name = format!("{}-item-{}", selector_base(&base), item.key);
645                            move || name
646                        });
647                    if let Some(icon) = &item.icon {
648                        row = row.child(
649                            gpui::svg()
650                                .size(util::FIELD_ICON)
651                                .flex_shrink_0()
652                                .path(icon.clone())
653                                .text_color(colors.muted),
654                        );
655                    }
656                    row = row.child(
657                        div()
658                            .flex()
659                            .items_baseline()
660                            .gap(px(8.))
661                            .flex_1()
662                            .min_w_0()
663                            .child(div().truncate().child(item.label.clone()))
664                            .when_some(item.description.clone(), |el, description| {
665                                el.child(
666                                    div()
667                                        .flex_1()
668                                        .min_w_0()
669                                        .truncate()
670                                        .text_size(px(12.))
671                                        .text_color(colors.muted)
672                                        .child(description),
673                                )
674                            }),
675                    );
676                    if !item.shortcut.is_empty() {
677                        row = row.child(
678                            div().flex().gap(px(4.)).flex_shrink_0().children(
679                                item.shortcut
680                                    .iter()
681                                    .map(|key| Kbd::new().child(key.clone())),
682                            ),
683                        );
684                    }
685                    if item.is_disabled {
686                        row = row.opacity(layout.disabled_opacity);
687                    } else {
688                        let hover_cursor = cursor.clone();
689                        let key = item.key.clone();
690                        let press = run.clone();
691                        row = row
692                            .cursor(pointer)
693                            .on_hover(move |over, _, cx| {
694                                if *over {
695                                    hover_cursor.update(cx, |c, cx| {
696                                        if c.as_ref() != Some(&key) {
697                                            *c = Some(key.clone());
698                                            cx.notify();
699                                        }
700                                    });
701                                }
702                            })
703                            .on_click(move |_, window, cx| press(ix, window, cx));
704                    }
705                    list = list.child(row);
706                }
707            }
708        }
709
710        let keys_lines = Rc::new(lines);
711        let keys_cursor = cursor.clone();
712        let keys_scroll = scroll;
713        let keys_items = items;
714        let keys_run = run.clone();
715        let panel = div()
716            .id(element_id::scoped(&base, "dialog"))
717            .a11y_named(
718                a11y::Role::Dialog,
719                &a11y::Name::labelled(self.label.clone()),
720            )
721            .relative()
722            .flex()
723            .flex_col()
724            .w(self.width)
725            .max_w_full()
726            .bg(colors.overlay.background)
727            .text_color(colors.foreground)
728            .text_size(px(14.))
729            .line_height(px(20.))
730            .rounded(radius)
731            .overflow_hidden()
732            .when_some(layout.overlay_hairline, |el, hairline| {
733                el.border(layout.border_width).border_color(hairline)
734            })
735            .shadow(layout.overlay_shadow)
736            .debug_selector({
737                let name = format!("{}-panel", selector_base(&base));
738                move || name
739            })
740            .on_key_down(move |event, window, cx| {
741                let m = &event.keystroke.modifiers;
742                if m.control || m.alt || m.platform || m.shift || m.function {
743                    return;
744                }
745                let key = event.keystroke.key.as_str();
746                let from = keys_cursor.read(cx).as_ref().and_then(|held| {
747                    keys_lines.iter().position(|line| match line {
748                        Line::Command(ix) => &keys_items[*ix].key == held,
749                        Line::Heading(_) => false,
750                    })
751                });
752                match key {
753                    "up" | "down" => {
754                        let target = match crate::list_nav::resolve(&stops, from, key, true) {
755                            crate::list_nav::Move::To(to) => Some(to),
756                            _ => None,
757                        };
758                        if let Some(to) = target {
759                            if let Line::Command(ix) = keys_lines[to] {
760                                let key = keys_items[ix].key.clone();
761                                keys_cursor.update(cx, |c, cx| {
762                                    *c = Some(key);
763                                    cx.notify();
764                                });
765                                keys_scroll.scroll_to_item(to);
766                            }
767                        }
768                        cx.stop_propagation();
769                    }
770                    "enter" => {
771                        if let Some(Line::Command(ix)) = from.map(|at| keys_lines[at]) {
772                            keys_run(ix, window, cx);
773                        }
774                        cx.stop_propagation();
775                    }
776                    _ => {}
777                }
778            })
779            .child(search)
780            .child(list);
781        let panel = util::apply_sx(panel, &self.sx);
782        let panel = if exiting {
783            panel
784        } else {
785            util::dismiss_on_press_outside_with_token(panel, token.clone(), {
786                let dismiss = dismiss.clone();
787                move |window, cx| dismiss(window, cx)
788            })
789        };
790
791        let focus_scope = window
792            .use_keyed_state(element_id::scoped(&base, "scope"), cx, |_, cx| {
793                cx.focus_handle()
794            })
795            .read(cx)
796            .clone();
797        let mut overlay = util::trap_tab(
798            div()
799                .id(element_id::scoped(&base, "overlay"))
800                .track_focus(&focus_scope),
801            &focus_scope,
802        )
803        .absolute()
804        .inset_0()
805        .flex()
806        .flex_col()
807        .items_center()
808        .pt(window.viewport_size().height * 0.15)
809        .px(px(16.));
810        overlay = util::dismiss_on_escape_with_token(overlay, token, move |window, cx| {
811            dismiss(window, cx)
812        });
813        let scrim = div()
814            .id(element_id::scoped(&base, "backdrop"))
815            .absolute()
816            .inset_0()
817            .bg(colors.backdrop);
818        overlay = overlay.child(if exiting {
819            anim::exiting(
820                scrim,
821                element_id::scoped(&base, "backdrop-out"),
822                anim::ZoomBox::default(),
823                anim::Motion::BACKDROP_OUT,
824                cx,
825            )
826        } else {
827            anim::entering(
828                scrim,
829                element_id::scoped(&base, "backdrop-in"),
830                anim::Motion::BACKDROP_IN,
831                cx,
832            )
833        });
834        let zoom = anim::ZoomBox {
835            width: Some(self.width),
836            radius: Some(radius),
837            ..Default::default()
838        };
839        overlay = overlay.child(if exiting {
840            anim::exiting(
841                panel,
842                element_id::scoped(&base, "panel-out"),
843                zoom,
844                anim::Motion::PANEL_OUT,
845                cx,
846            )
847        } else {
848            anim::entering_zoom(
849                panel,
850                element_id::scoped(&base, "panel-in"),
851                zoom,
852                anim::Motion::PANEL_IN,
853                cx,
854            )
855        });
856        util::window_overlay(overlay, window).into_any_element()
857    }
858}
859
860/// The debug-selector spelling of an id: its name when it has one.
861fn selector_base(id: &ElementId) -> String {
862    match id {
863        ElementId::Name(name) => name.to_string(),
864        other => format!("{other:?}"),
865    }
866}
867
868#[cfg(test)]
869mod tests {
870    use super::*;
871
872    fn items() -> Vec<CommandItem> {
873        vec![
874            CommandItem::new("new", "New file").group("File"),
875            CommandItem::new("theme", "Toggle theme").keywords(["dark", "light"]),
876            CommandItem::new("open", "Open file").group("File"),
877            CommandItem::new("close", "Close window").group("Window"),
878        ]
879    }
880
881    #[test]
882    fn groups_keep_first_appearance_order_and_ungrouped_rows_have_no_heading() {
883        let items = items();
884        let (lines, headings) = group_lines(&items, &[0, 1, 2, 3]);
885        assert_eq!(headings, ["File", "Window"]);
886        assert_eq!(
887            lines,
888            [
889                Line::Heading(0),
890                Line::Command(0),
891                Line::Command(2),
892                Line::Command(1),
893                Line::Heading(1),
894                Line::Command(3),
895            ]
896        );
897    }
898
899    #[test]
900    fn every_query_word_must_match_a_field() {
901        let filter = Filter::new(Sensitivity::Base);
902        let theme = items()[1].search_text();
903        assert!(default_matches(&filter, &theme, "toggle DARK"));
904        assert!(!default_matches(&filter, &theme, "toggle window"));
905        let new = items()[0].search_text();
906        assert!(default_matches(&filter, &new, "file new"));
907        // Words do not match across the label/keyword boundary.
908        assert!(!default_matches(&filter, &theme, "themedark"));
909    }
910
911    #[test]
912    fn the_shortcut_is_secondary_k_alone() {
913        let parse = |s: &str| gpui::Keystroke::parse(s).unwrap();
914        assert!(is_command_palette_shortcut(&parse("secondary-k")));
915        assert!(!is_command_palette_shortcut(&parse("k")));
916        assert!(!is_command_palette_shortcut(&parse("secondary-shift-k")));
917        assert!(!is_command_palette_shortcut(&parse("secondary-j")));
918    }
919}
920
921crate::util::impl_component_styled!(CommandPalette);