Skip to main content

gpui_kit/overlay/
palette.rs

1//! A filterable list of commands: the keyboard surface of an application.
2//!
3//! The palette is the one place a typist expects to find everything the
4//! application can do, so it never hides a command it was given. A command the
5//! host has marked unavailable is shown as unavailable, with the host's own
6//! reason, and a query that matches nothing says so about that query instead
7//! of drawing an empty list that looks like an application with no commands.
8
9use gpui::{
10    App, AppContext as _, Context, Entity, EventEmitter, FocusHandle, Focusable,
11    InteractiveElement, IntoElement, KeyDownEvent, MouseButton, ParentElement, Render,
12    SharedString, StatefulInteractiveElement, Styled, Subscription, Window, div,
13    prelude::FluentBuilder, px,
14};
15use gpui_kit_semantics::{NodeSpec, Role, Semantic};
16use gpui_kit_theme::{ActiveTheme, Elevation, Space, TypeScale};
17
18use crate::controls::input::{TextInput, TextInputEvent};
19use crate::display::empty::{EmptyKind, EmptyState};
20use crate::foundation::{Ident, Pressable, StyledExt};
21use crate::motion;
22use crate::overlay::kbd::Kbd;
23use crate::overlay::layer::surface;
24use crate::overlay::popover::{self, MenuKey};
25use crate::strings::{ActiveStrings, StringKey};
26
27/// How wide the palette is. The value occurs once, so it stays here rather
28/// than in the token document.
29const PALETTE_WIDTH: f32 = 480.0;
30/// How tall the result list grows before it scrolls.
31const RESULTS_MAX_HEIGHT: f32 = 320.0;
32
33/// One thing the application can do.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct Command {
36    id: SharedString,
37    label: SharedString,
38    section: Option<SharedString>,
39    shortcut: Option<SharedString>,
40    /// Why the host will not run this now. `None` means it will.
41    unavailable: Option<SharedString>,
42}
43
44impl Command {
45    pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
46        Self {
47            id: id.into(),
48            label: label.into(),
49            section: None,
50            shortcut: None,
51            unavailable: None,
52        }
53    }
54
55    /// The group this command belongs to. Sections stay contiguous, ordered by
56    /// the best match inside them.
57    pub fn section(mut self, section: impl Into<SharedString>) -> Self {
58        self.section = Some(section.into());
59        self
60    }
61
62    /// The keystroke that runs it without the palette.
63    pub fn shortcut(mut self, keystroke: impl Into<SharedString>) -> Self {
64        self.shortcut = Some(keystroke.into());
65        self
66    }
67
68    /// Marks the command as one the host will not run now, in the host's own
69    /// words. It is still listed: hiding a command a typist knows exists is a
70    /// lie about the application.
71    pub fn unavailable(mut self, reason: impl Into<SharedString>) -> Self {
72        self.unavailable = Some(reason.into());
73        self
74    }
75
76    pub fn id(&self) -> &SharedString {
77        &self.id
78    }
79
80    pub fn label(&self) -> &SharedString {
81        &self.label
82    }
83
84    pub fn reason(&self) -> Option<&SharedString> {
85        self.unavailable.as_ref()
86    }
87
88    pub fn is_available(&self) -> bool {
89        self.unavailable.is_none()
90    }
91}
92
93/// What the palette reports. The owner decides what any of it means.
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub enum CommandPaletteEvent {
96    QueryChanged(SharedString),
97    /// The highlighted command was taken.
98    Invoked(SharedString),
99    /// The palette was waved away with escape. The host owns whether it stays
100    /// on screen, so the palette only reports the intent.
101    Dismissed,
102}
103
104impl EventEmitter<CommandPaletteEvent> for CommandPalette {}
105
106/// Orders the commands that answer `query`, keeping each section contiguous.
107///
108/// A section sits where its best match does, so the closest answer is still
109/// the first row while the grouping stays readable.
110fn order_matches(commands: &[Command], query: &str) -> Vec<usize> {
111    let ranked: Vec<(usize, usize)> = commands
112        .iter()
113        .enumerate()
114        .filter_map(|(index, command)| {
115            popover::match_rank(query, command.label.as_ref()).map(|rank| (rank, index))
116        })
117        .collect();
118
119    let section_of = |index: usize| commands[index].section.clone().unwrap_or_default();
120    let mut sections: Vec<(SharedString, usize, usize)> = Vec::new();
121    for &(rank, index) in &ranked {
122        let name = section_of(index);
123        match sections.iter_mut().find(|(known, _, _)| *known == name) {
124            Some(section) => section.1 = section.1.min(rank),
125            None => sections.push((name, rank, index)),
126        }
127    }
128    sections.sort_by_key(|(_, rank, first)| (*rank, *first));
129
130    let mut ordered = Vec::with_capacity(ranked.len());
131    for (name, _, _) in &sections {
132        let mut group: Vec<(usize, usize)> = ranked
133            .iter()
134            .copied()
135            .filter(|(_, index)| section_of(*index) == *name)
136            .collect();
137        group.sort_by_key(|&(rank, index)| (rank, index));
138        ordered.extend(group.into_iter().map(|(_, index)| index));
139    }
140    ordered
141}
142
143/// A query field over a list of commands.
144///
145/// The palette owns the query and where the keyboard is; the command list and
146/// whether the palette is on screen at all belong to the host.
147pub struct CommandPalette {
148    ident: Ident,
149    focus_handle: FocusHandle,
150    query: Entity<TextInput>,
151    commands: Vec<Command>,
152    /// The highlighted command, by identity, so filtering does not move the
153    /// highlight onto whatever happens to sit at the same position.
154    active: Option<SharedString>,
155    /// Held so the query subscription lives as long as the palette does.
156    _subscriptions: Vec<Subscription>,
157}
158
159impl std::fmt::Debug for CommandPalette {
160    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
161        formatter
162            .debug_struct("CommandPalette")
163            .field("ident", &self.ident)
164            .field("commands", &self.commands.len())
165            .field("active", &self.active)
166            .finish()
167    }
168}
169
170impl CommandPalette {
171    pub fn new(ident: impl Into<Ident>, window: &mut Window, cx: &mut Context<Self>) -> Self {
172        let ident = ident.into();
173        let query = cx.new(|cx| {
174            TextInput::new(ident.child("query"), window, cx)
175                .placeholder(cx.strings().text(StringKey::PalettePlaceholder))
176        });
177        let subscription = cx.subscribe(&query, |palette, _query, event, cx| match event {
178            TextInputEvent::Change(text) => {
179                // A new query is a new list, so the highlight goes back to the
180                // best answer rather than staying on a row that may be gone.
181                palette.active = None;
182                cx.emit(CommandPaletteEvent::QueryChanged(text.clone()));
183                cx.notify();
184            }
185            TextInputEvent::Submit => palette.invoke(cx),
186            TextInputEvent::Cancel => cx.emit(CommandPaletteEvent::Dismissed),
187            _ => {}
188        });
189
190        Self {
191            ident,
192            focus_handle: cx.focus_handle(),
193            query,
194            commands: Vec::new(),
195            active: None,
196            _subscriptions: vec![subscription],
197        }
198    }
199
200    pub fn commands(mut self, commands: impl IntoIterator<Item = Command>) -> Self {
201        self.commands = commands.into_iter().collect();
202        self
203    }
204
205    pub fn set_commands(&mut self, commands: Vec<Command>, cx: &mut Context<Self>) {
206        self.commands = commands;
207        self.active = None;
208        cx.notify();
209    }
210
211    pub fn query(&self, cx: &App) -> SharedString {
212        self.query.read(cx).value().clone()
213    }
214
215    /// Replaces the query from the host side, for example when the palette is
216    /// opened with something already typed.
217    pub fn set_query(&mut self, query: impl Into<SharedString>, cx: &mut Context<Self>) {
218        self.query
219            .update(cx, |input, cx| input.set_value(query, cx));
220    }
221
222    pub fn query_input(&self) -> &Entity<TextInput> {
223        &self.query
224    }
225
226    /// Moves the keyboard into the query field.
227    pub fn focus_query(&self, window: &mut Window, cx: &mut App) {
228        self.query.read(cx).focus_handle(cx).focus(window, cx);
229    }
230
231    /// The command the keyboard is on, or `None` when nothing can be taken.
232    pub fn active_id(&self, cx: &App) -> Option<SharedString> {
233        let ordered = self.ordered(cx);
234        self.resolved(&ordered)
235            .map(|index| self.commands[index].id.clone())
236    }
237
238    fn ordered(&self, cx: &App) -> Vec<usize> {
239        order_matches(&self.commands, self.query.read(cx).value().as_ref())
240    }
241
242    /// The command the highlight sits on: the one the typist put it on while
243    /// it still answers the query, or the best answer that can be taken.
244    fn resolved(&self, ordered: &[usize]) -> Option<usize> {
245        if let Some(active) = &self.active
246            && let Some(index) = ordered
247                .iter()
248                .copied()
249                .find(|index| &self.commands[*index].id == active)
250            && self.commands[index].is_available()
251        {
252            return Some(index);
253        }
254        ordered
255            .iter()
256            .copied()
257            .find(|index| self.commands[*index].is_available())
258    }
259
260    fn step(&mut self, delta: isize, cx: &mut Context<Self>) {
261        let ordered = self.ordered(cx);
262        let choosable: Vec<usize> = ordered
263            .iter()
264            .copied()
265            .filter(|index| self.commands[*index].is_available())
266            .collect();
267        if choosable.is_empty() {
268            return;
269        }
270        let current = self
271            .resolved(&ordered)
272            .and_then(|index| choosable.iter().position(|choice| *choice == index));
273        let Some(next) = popover::step(current, choosable.len(), delta) else {
274            return;
275        };
276        self.active = Some(self.commands[choosable[next]].id.clone());
277        cx.notify();
278    }
279
280    /// Reports the highlighted command. An unavailable command is never
281    /// invoked, and no reachable row installs a handler for one.
282    fn invoke(&mut self, cx: &mut Context<Self>) {
283        let ordered = self.ordered(cx);
284        let Some(index) = self.resolved(&ordered) else {
285            return;
286        };
287        cx.emit(CommandPaletteEvent::Invoked(
288            self.commands[index].id.clone(),
289        ));
290    }
291
292    fn choose(&mut self, id: SharedString, cx: &mut Context<Self>) {
293        self.active = Some(id.clone());
294        cx.emit(CommandPaletteEvent::Invoked(id));
295        cx.notify();
296    }
297
298    fn on_key_down(&mut self, event: &KeyDownEvent, _window: &mut Window, cx: &mut Context<Self>) {
299        let key = popover::classify_key(
300            event.keystroke.key.as_str(),
301            event.keystroke.modifiers.platform,
302            event.keystroke.modifiers.control,
303        );
304        match key {
305            MenuKey::Down => {
306                self.step(1, cx);
307                cx.stop_propagation();
308            }
309            MenuKey::Up => {
310                self.step(-1, cx);
311                cx.stop_propagation();
312            }
313            _ => {}
314        }
315    }
316
317    fn results(&self, cx: &mut Context<Self>) -> Vec<gpui::AnyElement> {
318        let theme = cx.theme().clone();
319        let ordered = self.ordered(cx);
320        let highlighted = self.resolved(&ordered);
321        let results_id = self.ident.child("results").semantic_id();
322        let mut section: Option<SharedString> = None;
323        let mut rows: Vec<gpui::AnyElement> = Vec::with_capacity(ordered.len());
324        let count = ordered.len();
325
326        for (position, index) in ordered.into_iter().enumerate() {
327            let command = &self.commands[index];
328            if command.section != section {
329                section = command.section.clone();
330                if let Some(name) = section.clone() {
331                    rows.push(
332                        popover::heading(&theme, name.as_ref())
333                            .semantic_in(
334                                cx,
335                                NodeSpec::new(
336                                    self.ident
337                                        .child("section")
338                                        .child(name.as_ref())
339                                        .semantic_id(),
340                                    Role::Heading,
341                                )
342                                .parent(results_id.clone())
343                                .level(2)
344                                .text(name),
345                            )
346                            .into_any_element(),
347                    );
348                }
349            }
350
351            let row_ident = self.ident.child(command.id.as_ref());
352            let active = highlighted == Some(index);
353            let available = command.is_available();
354            let id = command.id.clone();
355            let mut spec = NodeSpec::new(row_ident.semantic_id(), Role::MenuItem)
356                .parent(results_id.clone())
357                .text(command.label.clone())
358                .disabled(!available)
359                .hovered(active);
360            if let Some(reason) = command.reason() {
361                spec = spec.value(reason.clone());
362            }
363
364            let row = popover::menu_row(&theme, false, active)
365                .id(row_ident.element_id())
366                .when(available, |element| element.cursor_pointer().pressable(cx))
367                .when(!available, |element| {
368                    element.opacity(theme.opacity.disabled)
369                })
370                .child(div().flex_1().child(command.label.clone()))
371                .children(command.reason().map(|reason| {
372                    div()
373                        .type_scale(&theme, TypeScale::Caption)
374                        .text_color(theme.colors.warning)
375                        .child(reason.clone())
376                }))
377                .children(
378                    command
379                        .shortcut
380                        .clone()
381                        .map(|keystroke| Kbd::new(keystroke).into_any_element()),
382                )
383                .when(available, |element| {
384                    element.on_mouse_down(
385                        MouseButton::Left,
386                        cx.listener(move |palette, _, _, cx| {
387                            palette.choose(id.clone(), cx);
388                        }),
389                    )
390                })
391                .semantic_in(cx, spec);
392
393            rows.push(
394                motion::row_in(
395                    row_ident.child("in").element_id(),
396                    &theme,
397                    position,
398                    count,
399                    row,
400                )
401                .into_any_element(),
402            );
403        }
404
405        rows
406    }
407}
408
409impl Focusable for CommandPalette {
410    fn focus_handle(&self, _cx: &App) -> FocusHandle {
411        self.focus_handle.clone()
412    }
413}
414
415impl Render for CommandPalette {
416    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
417        let theme = cx.theme().clone();
418        let query = self.query.read(cx).value().clone();
419        let rows = self.results(cx);
420        let results_id = self.ident.child("results").semantic_id();
421
422        let body = if rows.is_empty() {
423            // A query that answered nothing is a fact about the query, not an
424            // application without commands, so it says which query it was.
425            EmptyState::new(
426                self.ident.child("empty"),
427                cx.strings().format(StringKey::PaletteNoMatch, &[&query]),
428            )
429            .kind(EmptyKind::Empty)
430            .detail(cx.strings().text(StringKey::PaletteEmptyDetail))
431            .into_any_element()
432        } else {
433            div()
434                .id(self.ident.child("results").element_id())
435                .flex()
436                .flex_col()
437                .max_h(px(RESULTS_MAX_HEIGHT))
438                .overflow_y_scroll()
439                .children(rows)
440                .semantic_in(cx, NodeSpec::new(results_id, Role::Menu))
441                .into_any_element()
442        };
443
444        surface(&theme, Elevation::Modal)
445            .w(px(PALETTE_WIDTH))
446            .p_token(&theme, Space::Xs)
447            .gap_token(&theme, Space::Xs)
448            .track_focus(&self.focus_handle)
449            .on_key_down(cx.listener(Self::on_key_down))
450            .child(div().p_token(&theme, Space::Xs).child(self.query.clone()))
451            .child(body)
452            .semantic_in(
453                cx,
454                NodeSpec::new(self.ident.semantic_id(), Role::Group).value(query),
455            )
456    }
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462
463    fn commands() -> Vec<Command> {
464        vec![
465            Command::new("editor.split", "Split editor").section("Editor"),
466            Command::new("workspace.save", "Save workspace").section("Workspace"),
467            Command::new("editor.save", "Save file")
468                .section("Editor")
469                .shortcut("cmd-s"),
470            Command::new("workspace.publish", "Publish workspace")
471                .section("Workspace")
472                .unavailable("Approval is required"),
473        ]
474    }
475
476    #[test]
477    fn an_empty_query_lists_everything_grouped_by_section() {
478        assert_eq!(order_matches(&commands(), ""), vec![0, 2, 1, 3]);
479    }
480
481    #[test]
482    fn a_section_sits_where_its_best_match_does() {
483        // "Save workspace" is a prefix match, so its section leads even though
484        // the editor section was declared first.
485        assert_eq!(order_matches(&commands(), "save"), vec![1, 2]);
486    }
487
488    #[test]
489    fn a_command_the_host_refused_is_still_listed() {
490        let ordered = order_matches(&commands(), "publish");
491        assert_eq!(ordered, vec![3]);
492        assert!(!commands()[3].is_available());
493    }
494
495    #[test]
496    fn a_query_nothing_answers_orders_nothing() {
497        assert!(order_matches(&commands(), "zzz").is_empty());
498    }
499}