Skip to main content

gpui_cn/command/
mod.rs

1//! A command palette: a search field over a filtered list of commands,
2//! with groups, the commands' shortcuts, and keyboard navigation, as
3//! shadcn's `Command`.
4//!
5//! [`CommandState`] holds the commands, the query, and the highlight;
6//! [`Command`] draws them.
7
8mod item;
9
10use std::rc::Rc;
11
12use gpui_kit::{
13    AnyElement, App, AppContext as _, Context, ElementId, Entity, EventEmitter, FocusHandle,
14    Focusable, InteractiveElement as _, IntoElement, KeyBinding, ListAlignment, ListState,
15    ParentElement as _, RenderOnce, Role, SharedString, StatefulInteractiveElement as _,
16    StyleRefinement, Styled, Subscription, Task, Window,
17    base::{
18        StyledExt as _, TestSupportExt as _,
19        actions::{Cancel, Confirm, SelectDown, SelectUp},
20        input::{InputEvent, InputState},
21        v_flex,
22    },
23    div,
24    prelude::FluentBuilder as _,
25};
26
27use item::Row;
28pub use item::{CommandEntry, CommandGroup, CommandItem, CommandRow};
29
30use crate::{
31    ActiveTheme as _, ScrollArea, Theme,
32    menu::{
33        MenuLook, label_block, line_slot, row_frame, row_line, search_row, search_style, separator,
34        shortcut,
35    },
36};
37
38/// What a palette shows while no command matches, as shadcn's demo.
39const DEFAULT_EMPTY: &str = "No results.";
40
41/// The key context of a palette, which takes Up, Down, Enter, and Escape
42/// the search field passes on.
43const CONTEXT: &str = "GpuiCnCommand";
44
45pub(crate) fn init(cx: &mut App) {
46    cx.bind_keys([
47        KeyBinding::new("up", SelectUp, Some(CONTEXT)),
48        KeyBinding::new("down", SelectDown, Some(CONTEXT)),
49        KeyBinding::new("enter", Confirm { secondary: false }, Some(CONTEXT)),
50        KeyBinding::new("escape", Cancel, Some(CONTEXT)),
51    ]);
52}
53
54/// A search the application runs itself: given the query, it returns the
55/// commands to show, when it has them. See
56/// [`CommandState::with_search_handler`].
57pub type CommandSearchHandler =
58    Rc<dyn Fn(SharedString, &mut Context<CommandState>) -> Task<Vec<CommandEntry>>>;
59
60/// A chosen command's action and the element it goes to.
61type Pending = (Box<dyn gpui_kit::Action>, FocusHandle);
62
63/// What a [`CommandState`] reports.
64#[derive(Clone, Debug, PartialEq, Eq)]
65pub enum CommandEvent {
66    /// The query changed. The payload is the new query.
67    QueryChanged(SharedString),
68    /// A command was chosen, after its action was dispatched. The payload
69    /// is its key.
70    Confirmed(SharedString),
71    /// Enter was pressed while no command matched the query. The payload
72    /// is the query, such as a path or a URL to open.
73    Submitted(SharedString),
74}
75
76/// The commands of a [`Command`] palette, its query, and its highlight.
77///
78/// Owned by the view that shows the palette, which observes it so a
79/// change renders. The query filters the commands by label and keywords
80/// as a fuzzy subsequence, ignoring case, and puts the highlight on the first match; Up and Down
81/// move it, and Enter chooses it. Choosing a command, or submitting a
82/// query, clears the query for the next time.
83pub struct CommandState {
84    input: Entity<InputState>,
85    entries: Vec<CommandEntry>,
86    rows: Vec<Row>,
87    highlighted: Option<usize>,
88    list: ListState,
89    handler: Option<CommandSearchHandler>,
90    /// The running search. Dropping it cancels it, so a newer query
91    /// cancels the search before it and no stale rows arrive.
92    search: Option<Task<()>>,
93    _subscriptions: Vec<Subscription>,
94}
95
96impl EventEmitter<CommandEvent> for CommandState {}
97
98/// The search field's focus, which holds the keyboard for the palette.
99impl Focusable for CommandState {
100    fn focus_handle(&self, cx: &App) -> FocusHandle {
101        self.input.read(cx).focus_handle(cx)
102    }
103}
104
105impl CommandState {
106    /// A palette with no commands, whose search field shows `placeholder`.
107    pub fn new(
108        placeholder: impl Into<SharedString>,
109        window: &mut Window,
110        cx: &mut Context<Self>,
111    ) -> Self {
112        let style = search_style(cx.theme());
113        let input = cx.new(|cx| {
114            let mut input = InputState::new(window, cx).placeholder(placeholder);
115            input.set_editor_style(style);
116            input
117        });
118        let subscription = cx.subscribe(&input, |this, input, event: &InputEvent, cx| {
119            if matches!(event, InputEvent::Change) {
120                let query = input.read(cx).value();
121                this.query_changed(query, cx);
122            }
123        });
124        let overdraw = cx.theme().metrics.menu_max_height;
125        Self {
126            input,
127            entries: Vec::new(),
128            rows: Vec::new(),
129            highlighted: None,
130            list: ListState::new(0, ListAlignment::Top, overdraw),
131            handler: None,
132            search: None,
133            _subscriptions: vec![subscription],
134        }
135    }
136
137    /// Seeds the commands.
138    pub fn with_entries(
139        mut self,
140        entries: impl IntoIterator<Item = impl Into<CommandEntry>>,
141    ) -> Self {
142        self.entries = entries.into_iter().map(Into::into).collect();
143        self.refilter("");
144        self
145    }
146
147    /// Answers the query with `handler` in place of the local filter:
148    /// for each change, and for the empty query when set, it gets the
149    /// query and returns a task with the commands to show. The task can
150    /// match on a background thread, fuzzily, or ask a server; a newer
151    /// query cancels the task before it. The commands shown stay until
152    /// the answer arrives.
153    ///
154    /// ```
155    /// use gpui_cn::{CommandEntry, CommandItem, CommandState};
156    /// use gpui_kit::{AppContext as _, Context, Window};
157    ///
158    /// fn files(window: &mut Window, cx: &mut Context<CommandState>) -> CommandState {
159    ///     CommandState::new("Open any file", window, cx).with_search_handler(|query, cx| {
160    ///         let paths = cx.background_spawn(async move {
161    ///             // Any search: fuzzy, indexed, or a request.
162    ///             vec![format!("src/{query}.rs")]
163    ///         });
164    ///         cx.spawn(async move |_, _| {
165    ///             paths
166    ///                 .await
167    ///                 .into_iter()
168    ///                 .map(|path| CommandEntry::from(CommandItem::new(path.clone(), path)))
169    ///                 .collect()
170    ///         })
171    ///     }, cx)
172    /// }
173    /// ```
174    pub fn with_search_handler(
175        mut self,
176        handler: impl Fn(SharedString, &mut Context<Self>) -> Task<Vec<CommandEntry>> + 'static,
177        cx: &mut Context<Self>,
178    ) -> Self {
179        self.handler = Some(Rc::new(handler));
180        self.run_search(SharedString::default(), cx);
181        self
182    }
183
184    /// Whether a search handler is still answering the query.
185    pub fn is_searching(&self) -> bool {
186        self.search.is_some()
187    }
188
189    /// Replaces the commands, as an answer to the query. The highlight
190    /// stays on the command with the same key when there is one, and a
191    /// search handler's answer still on its way is dropped, so it cannot
192    /// replace these commands.
193    pub fn set_entries(
194        &mut self,
195        entries: impl IntoIterator<Item = impl Into<CommandEntry>>,
196        cx: &mut Context<Self>,
197    ) {
198        self.search = None;
199        let highlighted = self.highlighted();
200        self.entries = entries.into_iter().map(Into::into).collect();
201        let query = self.query(cx);
202        self.refilter(&query);
203        if let Some(key) = highlighted
204            && let Some(row) = self.row_of(&key)
205        {
206            self.highlight(row);
207        }
208        cx.notify();
209    }
210
211    /// The search query.
212    pub fn query(&self, cx: &App) -> SharedString {
213        self.input.read(cx).value()
214    }
215
216    /// Replaces the search query.
217    pub fn set_query(
218        &mut self,
219        query: impl Into<SharedString>,
220        window: &mut Window,
221        cx: &mut Context<Self>,
222    ) {
223        let query = query.into();
224        if self.query(cx) == query {
225            return;
226        }
227        self.input
228            .update(cx, |input, cx| input.set_value(query.clone(), window, cx));
229        // A value set from code does not report a change, so the rows
230        // follow it here.
231        self.query_changed(query, cx);
232    }
233
234    /// The query changed: the handler answers it, else the local filter.
235    fn query_changed(&mut self, query: SharedString, cx: &mut Context<Self>) {
236        if self.handler.is_some() {
237            self.run_search(query.clone(), cx);
238        } else {
239            self.refilter(&query);
240        }
241        cx.emit(CommandEvent::QueryChanged(query));
242        cx.notify();
243    }
244
245    /// Asks the handler for the commands of `query`, and shows them when
246    /// they arrive unless a newer query has replaced the search.
247    fn run_search(&mut self, query: SharedString, cx: &mut Context<Self>) {
248        let Some(handler) = self.handler.clone() else {
249            return;
250        };
251        let answer = handler(query, cx);
252        // The rows answer the query before this one until the new answer
253        // arrives, so Enter submits what was typed rather than choose one.
254        self.highlighted = None;
255        self.search = Some(cx.spawn(async move |this, cx| {
256            let entries = answer.await;
257            this.update(cx, |this, cx| {
258                this.search = None;
259                this.entries = entries;
260                this.refilter("");
261                cx.notify();
262            })
263            .ok();
264        }));
265    }
266
267    /// The key of the command the keyboard or the pointer is on.
268    pub fn highlighted(&self) -> Option<SharedString> {
269        match self.rows.get(self.highlighted?)? {
270            Row::Item(item) => Some(item.key().clone()),
271            Row::Heading(_) | Row::Separator => None,
272        }
273    }
274
275    /// How many commands match the query.
276    pub fn matched_count(&self) -> usize {
277        self.rows
278            .iter()
279            .filter(|row| matches!(row, Row::Item(_)))
280            .count()
281    }
282
283    /// Filters the rows for `query` and puts the highlight on the first
284    /// command that can be chosen.
285    fn refilter(&mut self, query: &str) {
286        // A handler's commands are the answer to the query already.
287        let query = if self.handler.is_none() {
288            query.trim().to_string()
289        } else {
290            String::new()
291        };
292        self.rows = item::rows(&self.entries, &query);
293        self.list.reset(self.rows.len());
294        self.highlighted = None;
295        if let Some(first) = (0..self.rows.len()).find(|row| self.is_selectable(*row)) {
296            self.highlight(first);
297        }
298    }
299
300    fn row_of(&self, key: &SharedString) -> Option<usize> {
301        self.rows.iter().position(|row| match row {
302            Row::Item(item) => item.key() == key && !item.is_disabled(),
303            Row::Heading(_) | Row::Separator => false,
304        })
305    }
306
307    fn is_selectable(&self, row: usize) -> bool {
308        matches!(self.rows.get(row), Some(Row::Item(item)) if !item.is_disabled())
309    }
310
311    fn highlight(&mut self, row: usize) {
312        self.highlighted = Some(row);
313        self.list.scroll_to_reveal_item(row);
314    }
315
316    /// Moves the highlight one command up or down, wrapping at the ends.
317    fn move_highlight(&mut self, delta: isize, cx: &mut Context<Self>) {
318        let count = self.rows.len() as isize;
319        if count == 0 {
320            return;
321        }
322        let mut row = self
323            .highlighted
324            .map(|row| row as isize)
325            .unwrap_or(if delta > 0 { -1 } else { count });
326        for _ in 0..count {
327            row = (row + delta).rem_euclid(count);
328            if self.is_selectable(row as usize) {
329                self.highlight(row as usize);
330                cx.notify();
331                return;
332            }
333        }
334    }
335
336    /// The pointer moved onto `row`.
337    fn hover_row(&mut self, row: usize, cx: &mut Context<Self>) {
338        if self.is_selectable(row) && self.highlighted != Some(row) {
339            self.highlighted = Some(row);
340            cx.notify();
341        }
342    }
343
344    /// Chooses `row`: clears the query for the next time, reports the
345    /// command, and hands back its action and where it goes, to dispatch
346    /// once the state is released.
347    fn choose(
348        &mut self,
349        row: usize,
350        window: &mut Window,
351        cx: &mut Context<Self>,
352    ) -> Option<Pending> {
353        let Some(Row::Item(item)) = self.rows.get(row).cloned() else {
354            return None;
355        };
356        if item.is_disabled() {
357            return None;
358        }
359        let pending = item.dispatched_action().map(|action| {
360            let context = item
361                .own_action_context()
362                .cloned()
363                .unwrap_or_else(|| self.input.read(cx).focus_handle(cx));
364            (action.boxed_clone(), context)
365        });
366        self.set_query("", window, cx);
367        cx.emit(CommandEvent::Confirmed(item.key().clone()));
368        pending
369    }
370
371    /// Enter: chooses the highlighted command, or submits the query when
372    /// no command matches it.
373    fn confirm(&mut self, window: &mut Window, cx: &mut Context<Self>) -> Option<Pending> {
374        match self.highlighted {
375            Some(row) => self.choose(row, window, cx),
376            None => {
377                let query = self.query(cx);
378                if !query.trim().is_empty() {
379                    self.set_query("", window, cx);
380                    cx.emit(CommandEvent::Submitted(query));
381                }
382                None
383            }
384        }
385    }
386
387    /// Chooses as the pointer or the keyboard does, then dispatches the
388    /// command's action once the state is released, so a handler that
389    /// reads or updates the palette can.
390    fn activate(
391        this: &Entity<Self>,
392        choose: impl FnOnce(&mut Self, &mut Window, &mut Context<Self>) -> Option<Pending>,
393        window: &mut Window,
394        cx: &mut App,
395    ) {
396        if let Some((action, context)) = this.update(cx, |state, cx| choose(state, window, cx)) {
397            context.dispatch_action(action.as_ref(), window, cx);
398        }
399    }
400}
401
402/// A command palette on a [`CommandState`], as shadcn's `Command`: the
403/// search field, a hairline, and the matching commands in a scroll
404/// region, drawn with a menu's rows.
405///
406/// ```
407/// use gpui_cn::{Command, CommandItem, CommandState};
408/// use gpui_kit::{AppContext as _, Context, Entity, IntoElement, Window};
409///
410/// fn palette(window: &mut Window, cx: &mut Context<()>) -> Entity<CommandState> {
411///     cx.new(|cx| {
412///         CommandState::new("Type a command or search...", window, cx)
413///             .with_entries([CommandItem::new("calendar", "Calendar")])
414///     })
415/// }
416///
417/// fn view(state: &Entity<CommandState>) -> impl IntoElement {
418///     Command::new("palette", state)
419/// }
420/// ```
421///
422/// The palette draws its own frame; inside a [`Popover`](crate::Popover)
423/// or another frame, turn it off with [`bordered`](Self::bordered). The
424/// id keys the rows, so it must be stable across frames.
425#[derive(IntoElement)]
426pub struct Command {
427    id: ElementId,
428    state: Entity<CommandState>,
429    bordered: bool,
430    empty: SharedString,
431    style: StyleRefinement,
432}
433
434impl Command {
435    /// A palette on `state`, with a stable id.
436    pub fn new(id: impl Into<ElementId>, state: &Entity<CommandState>) -> Self {
437        Self {
438            id: id.into(),
439            state: state.clone(),
440            bordered: true,
441            empty: DEFAULT_EMPTY.into(),
442            style: StyleRefinement::default(),
443        }
444    }
445
446    /// Whether the palette draws its own frame. On by default.
447    pub fn bordered(mut self, bordered: bool) -> Self {
448        self.bordered = bordered;
449        self
450    }
451
452    /// The text shown while no command matches. The default is shadcn's
453    /// "No results."
454    pub fn empty(mut self, text: impl Into<SharedString>) -> Self {
455        self.empty = text.into();
456        self
457    }
458}
459
460impl Styled for Command {
461    fn style(&mut self) -> &mut StyleRefinement {
462        &mut self.style
463    }
464}
465
466impl RenderOnce for Command {
467    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
468        let look = MenuLook::of(cx.theme(), window.rem_size());
469        let pointer_cursors = Theme::global(cx).pointer_cursors;
470        let state = self.state;
471        let (input, list, empty) = {
472            let state = state.read(cx);
473            (
474                state.input.clone(),
475                state.list.clone(),
476                state.rows.is_empty(),
477            )
478        };
479        let child = |name: &'static str| ElementId::NamedChild(self.id.clone().into(), name.into());
480        let body = if empty {
481            div()
482                .py_6()
483                .text_center()
484                .text_color(look.muted_foreground)
485                .child(self.empty)
486                .into_any_element()
487        } else {
488            let rows = Rows {
489                id: self.id.clone(),
490                state: state.clone(),
491                look: look.clone(),
492                pointer_cursors,
493                action_context: input.read(cx).focus_handle(cx),
494            };
495            ScrollArea::list(
496                child("rows"),
497                &list,
498                look.row_height,
499                move |row, window, cx| rows.render(row, window, cx),
500            )
501            .max_h(look.rows_max_height(true))
502            .into_any_element()
503        };
504        let update = |f: fn(&mut CommandState, &mut Context<CommandState>)| {
505            let state = state.clone();
506            move |cx: &mut App| state.update(cx, f)
507        };
508        let (up, down) = (
509            update(|state, cx| state.move_highlight(-1, cx)),
510            update(|state, cx| state.move_highlight(1, cx)),
511        );
512        v_flex()
513            .id(self.id.clone())
514            .test_support()
515            .key_context(CONTEXT)
516            .min_w(look.search_min_width)
517            .max_w(look.max_width)
518            .text_size(look.text_size)
519            .line_height(look.line_height)
520            .text_color(look.foreground)
521            .when(self.bordered, |this| {
522                this.p(look.padding)
523                    .rounded(look.radius)
524                    .bg(look.surface)
525                    .border_1()
526                    .border_color(look.border)
527            })
528            .refine_style(&self.style)
529            .on_action(move |_: &SelectUp, _, cx| up(cx))
530            .on_action(move |_: &SelectDown, _, cx| down(cx))
531            .on_action({
532                let state = state.clone();
533                move |_: &Confirm, window, cx| {
534                    CommandState::activate(
535                        &state,
536                        |state, window, cx| state.confirm(window, cx),
537                        window,
538                        cx,
539                    );
540                }
541            })
542            .on_action({
543                let state = state.clone();
544                // Escape clears a query first; with none it goes on, to
545                // the popover or dialog around the palette.
546                move |_: &Cancel, window, cx| {
547                    if state.read(cx).query(cx).is_empty() {
548                        cx.propagate();
549                    } else {
550                        state.update(cx, |state, cx| state.set_query("", window, cx));
551                    }
552                }
553            })
554            .child(
555                search_row(&input, &look, window, cx)
556                    .id(child("search"))
557                    .test_support(),
558            )
559            .child(separator(&look))
560            .child(body)
561    }
562}
563
564/// What the rows are drawn from, owned by the list's row builder, which
565/// outlives the render that made it.
566struct Rows {
567    id: ElementId,
568    state: Entity<CommandState>,
569    look: MenuLook,
570    pointer_cursors: bool,
571    /// Where the rows' actions go when an item names no context of its
572    /// own, whose bindings their shortcuts show: the search field.
573    action_context: FocusHandle,
574}
575
576impl Rows {
577    fn render(&self, row: usize, window: &mut Window, cx: &mut App) -> AnyElement {
578        let look = &self.look;
579        let (entry, highlighted, query) = {
580            let state = self.state.read(cx);
581            let Some(entry) = state.rows.get(row).cloned() else {
582                return div().into_any_element();
583            };
584            (entry, state.highlighted == Some(row), state.query(cx))
585        };
586        let context = &self.action_context;
587        let item = match entry {
588            Row::Separator => return separator(look),
589            Row::Heading(text) => {
590                return div()
591                    .w_full()
592                    .h(look.row_height)
593                    .px(look.row_padding)
594                    .flex()
595                    .items_center()
596                    .text_color(look.muted_foreground)
597                    .child(text)
598                    .into_any_element();
599            }
600            Row::Item(item) => item,
601        };
602        let disabled = item.is_disabled();
603        let foreground = if disabled {
604            look.muted_foreground
605        } else {
606            look.foreground
607        };
608        let shortcut = item.dispatched_action().and_then(|action| {
609            shortcut(action, item.own_action_context().unwrap_or(context), window)
610        });
611        let row_id = ElementId::NamedChild(self.id.clone().into(), item.key().clone());
612        let hover = self.state.clone();
613        let choose = self.state.clone();
614        let touch = look.touch;
615        row_frame(look, highlighted && !disabled)
616            .id(row_id)
617            .test_support()
618            .role(Role::ListBoxOption)
619            .aria_label(item.label().clone())
620            .when_some(shortcut.clone(), |this, shortcut| {
621                this.aria_keyshortcuts(shortcut)
622            })
623            .when(highlighted && !disabled, |this| {
624                this.aria_active_descendant()
625            })
626            .text_color(foreground)
627            .map(|this| {
628                if !disabled && self.pointer_cursors {
629                    this.cursor_pointer()
630                } else {
631                    this.cursor_default()
632                }
633            })
634            .map(|this| match item.renderer() {
635                Some(render) => this.child(render(
636                    CommandRow {
637                        highlighted,
638                        disabled,
639                    },
640                    window,
641                    cx,
642                )),
643                None => this.child(
644                    row_line()
645                        .children(item.leading_icon().cloned().map(|icon| {
646                            line_slot(look)
647                                .w_4()
648                                .justify_center()
649                                .child(icon.size_4().text_color(foreground))
650                        }))
651                        .child(div().flex_1().min_w_0().child(label_block(
652                            item.label().clone(),
653                            &crate::fuzzy::matched(&query, item.label()).unwrap_or_default(),
654                            None,
655                            look,
656                        )))
657                        .children(shortcut.map(|shortcut| {
658                            line_slot(look)
659                                .ml_4()
660                                .text_color(look.description)
661                                .child(shortcut)
662                        })),
663                ),
664            })
665            // A row that scrolls under a resting pointer does not take the
666            // keyboard's highlight; a finger has no hover.
667            .when(!touch, |this| {
668                this.on_mouse_move(move |_, _, cx| {
669                    hover.update(cx, |state, cx| state.hover_row(row, cx));
670                })
671            })
672            .on_click(move |_, window, cx| {
673                CommandState::activate(
674                    &choose,
675                    |state, window, cx| state.choose(row, window, cx),
676                    window,
677                    cx,
678                );
679            })
680            .into_any_element()
681    }
682}