//! The command palette: a modal query field over the application's actions. use std::cell::RefCell; use std::rc::Rc; use gpui::prelude::FluentBuilder as _; use gpui::{ AnyElement, App, Bounds, Context, ElementId, Entity, InteractiveElement as _, IntoElement, KeyBinding, MouseButton, MouseDownEvent, ParentElement as _, Pixels, Render, Role, ScrollHandle, SharedString, StatefulInteractiveElement as _, Styled as _, Subscription, Window, actions, canvas, div, px, relative, }; use gpui_native_theme::{ActiveTheme as _, Size}; use crate::common::{h_flex, v_flex}; use crate::input::{Input, InputState, InputStateEvent}; use crate::scroll_area::{ScrollArea, ScrollAxes}; actions!( command_palette, [HighlightNext, HighlightPrevious, Choose, Dismiss] ); /// The key context the palette's panel declares. pub const COMMAND_PALETTE_CONTEXT: &str = "CommandPalette"; /// Binds the palette's four keys: down, up, enter, and escape. pub fn bind_command_palette_keys(cx: &mut App) { let under_input = Some("CommandPalette > Input"); cx.bind_keys([ KeyBinding::new("down", HighlightNext, under_input), KeyBinding::new("up", HighlightPrevious, under_input), KeyBinding::new("enter", Choose, under_input), KeyBinding::new("escape", Dismiss, under_input), KeyBinding::new("down", HighlightNext, Some(COMMAND_PALETTE_CONTEXT)), KeyBinding::new("up", HighlightPrevious, Some(COMMAND_PALETTE_CONTEXT)), KeyBinding::new("enter", Choose, Some(COMMAND_PALETTE_CONTEXT)), KeyBinding::new("escape", Dismiss, Some(COMMAND_PALETTE_CONTEXT)), ]); } /// One action the palette can run: a stable id and the label the matcher sees. #[derive(Clone)] pub struct Command { /// The stable domain id handed to `on_execute`. pub id: SharedString, /// What the matcher matches and the row shows. pub label: SharedString, /// Gray text on the row's right edge, e.g. the keybinding. pub hint: Option, } impl Command { /// Creates a command without a hint. pub fn new(id: impl Into, label: impl Into) -> Self { Self { id: id.into(), label: label.into(), hint: None, } } /// Sets the row's right-edge hint. pub fn hint(mut self, hint: impl Into) -> Self { self.hint = Some(hint.into()); self } } /// Filters and ranks the commands for a query. pub type CommandMatcher = Rc Vec>; /// Runs the chosen command. pub type CommandHandler = Rc; /// Builder for a command palette. Pass it to [`CommandPaletteState::new`]. pub struct CommandPalette { id: ElementId, name: SharedString, placeholder: SharedString, commands: Vec, matcher: CommandMatcher, on_execute: Option, } impl CommandPalette { /// Creates the builder, the caller-owned matcher decides what matches. pub fn new(id: I, commands: Vec, matcher: M) -> Self where I: Into, M: Fn(&str, &[Command]) -> Vec + 'static, { Self { id: id.into(), name: SharedString::from("Command palette"), placeholder: SharedString::from("Type a command..."), commands, matcher: Rc::new(matcher), on_execute: None, } } /// Sets the text shown while the query is empty. pub fn placeholder(mut self, placeholder: L) -> Self where L: Into, { self.placeholder = placeholder.into(); self } /// Runs `handler` with the chosen command, then closes the palette. pub fn on_execute(mut self, handler: F) -> Self where F: Fn(&Command, &mut Window, &mut App) + 'static, { self.on_execute = Some(Rc::new(handler)); self } /// Overrides the accessibility label of the panel. pub fn accessibility_label(mut self, label: L) -> Self where L: Into, { self.name = label.into(); self } } /// The stateful palette. pub struct CommandPaletteState { id: ElementId, name: SharedString, placeholder: SharedString, commands: Vec, matcher: CommandMatcher, on_execute: Option, query: Entity, /// The commands the matcher returned for the query, in display order. matches: Vec, /// The highlighted row, when the palette is open. highlight: Option, open: bool, /// Keeps the query-event subscription tied to the state's lifetime. _subscription: Subscription, } impl CommandPaletteState { /// Builds the state, its query field, and the subscription tying them. pub fn new(palette: CommandPalette, window: &mut Window, cx: &mut Context) -> Self { let query = InputState::new(cx); let subscription = cx.subscribe_in(&query, window, |state, _, event, window, cx| { if event == &InputStateEvent::TextChanged { state.text_changed(window, cx); } }); let matches = (palette.matcher)("", &palette.commands); Self { id: palette.id, name: palette.name, placeholder: palette.placeholder, commands: palette.commands, matcher: palette.matcher, on_execute: palette.on_execute, query, matches, highlight: None, open: false, _subscription: subscription, } } /// Opens the palette: fresh query, fresh matches, focus in the field. pub fn open(&mut self, window: &mut Window, cx: &mut Context) { if self.open { self.focus(window, cx); return; } self.open = true; self.query.update(cx, |query, cx| { query.set_value_silent("", window, cx); }); self.refilter(""); self.highlight = (!self.matches.is_empty()).then_some(0); self.focus(window, cx); cx.notify(); } /// Closes the palette. pub fn close(&mut self, cx: &mut Context) { if !self.open { return; } self.open = false; self.highlight = None; cx.notify(); } /// Opens the palette when closed, closes it when open. pub fn toggle(&mut self, window: &mut Window, cx: &mut Context) { if self.open { self.close(cx); } else { self.open(window, cx); } } /// Whether the palette is showing. pub fn is_open(&self) -> bool { self.open } /// Replaces the command list and re-runs the current query's matches. pub fn set_commands(&mut self, commands: Vec, cx: &mut Context) { self.commands = commands; let query = self.query.read(cx).value().to_string(); self.refilter(&query); cx.notify(); } /// Focuses the query field. pub fn focus(&self, window: &mut Window, cx: &mut App) { window.focus(&self.query.read(cx).focus_handle(), cx); } /// Asks the caller's matcher who matches, and clamps the highlight. fn refilter(&mut self, query: &str) { self.matches = (self.matcher)(query, &self.commands); if let Some(highlight) = self.highlight { self.highlight = (highlight < self.matches.len()).then_some(highlight); } } /// Typing: fresh matches, highlight the first row. fn text_changed(&mut self, _window: &mut Window, cx: &mut Context) { let query = self.query.read(cx).value().to_string(); self.refilter(&query); self.highlight = (!self.matches.is_empty()).then_some(0); cx.notify(); } /// Down and Up over the matched rows, wrapping at the ends. fn move_highlight(&mut self, delta: i32, cx: &mut Context) { if self.matches.is_empty() { return; } let next = match self.highlight { Some(row) => (row as i32 + delta).rem_euclid(self.matches.len() as i32) as usize, None if delta > 0 => 0, None => self.matches.len() - 1, }; self.highlight = Some(next); cx.notify(); } /// Enter. Runs the highlighted command and closes. fn choose(&mut self, window: &mut Window, cx: &mut Context) { let Some(row) = self.highlight else { cx.propagate(); return; }; self.choose_row(row, window, cx); } /// Runs the command at a row and closes. fn choose_row(&mut self, row: usize, window: &mut Window, cx: &mut Context) { let Some(command) = self.matches.get(row) else { return; }; let command = command.clone(); if let Some(on_execute) = self.on_execute.clone() { on_execute(&command, window, cx); } self.close(cx); } /// Escape. Closes the palette. fn dismiss(&mut self, _window: &mut Window, cx: &mut Context) { if !self.open { cx.propagate(); return; } self.close(cx); } } impl Render for CommandPaletteState { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { if !self.open { return div().into_any_element(); } let (colors, radius, metrics, shadows) = { let theme = cx.theme(); ( theme.colors, theme.radius, theme.sizes.resolve(Size::default()), theme.elevations.dialog.shadows(), ) }; let id = self.id.clone(); let state = cx.entity(); let rows: Vec = self .matches .iter() .enumerate() .map(|(row, command)| { let highlighted = self.highlight == Some(row); let state = state.clone(); let row_ix = row; h_flex() .id(("command", row)) .debug_selector({ let name = format!("{id}-command-{row}"); move || name }) .gap_2() .px_2() .py_1() .rounded(radius.sm) .when(highlighted, |row| row.bg(colors.muted)) .when(!highlighted, |row| row.hover(|row| row.bg(colors.muted))) .on_mouse_move({ let state = state.clone(); move |_, _window, cx| { if state.read(cx).highlight != Some(row_ix) { state.update(cx, |state, cx| { state.highlight = Some(row_ix); cx.notify(); }); } } }) .on_click({ let state = state.clone(); move |_, window, cx| { state.update(cx, |state, cx| state.choose_row(row_ix, window, cx)); } }) .child( div() .flex_1() .min_w_0() .overflow_hidden() .whitespace_nowrap() .text_ellipsis() .child(command.label.clone()), ) .when_some(command.hint.clone(), |row, hint| { row.child( div() .flex_shrink_0() .text_size(metrics.caption_size) .text_color(colors.muted_foreground) .child(hint), ) }) .into_any_element() }) .collect(); let rows: AnyElement = if rows.is_empty() { div() .px_2() .py_1() .text_size(metrics.caption_size) .text_color(colors.muted_foreground) .child("No matches") .into_any_element() } else { let scroll = window.use_keyed_state((id.clone(), "rows-scroll"), cx, |_, _| ScrollHandle::new()); ScrollArea::new((id.clone(), "rows")) .handle(scroll.read(cx).clone()) .axes(ScrollAxes::Vertical) .max_h(px(320.)) .children(rows) .into_any_element() }; let panel_bounds: Rc>>> = Rc::new(RefCell::new(None)); let query_focus = self.query.read(cx).focus_handle(); let panel = v_flex() .id((id.clone(), "panel")) .debug_selector({ let name = format!("{id}-panel"); move || name }) .key_context(COMMAND_PALETTE_CONTEXT) .role(Role::Dialog) .aria_label(self.name.clone()) .w_full() .max_w(relative(0.6)) .p_2() .gap_2() .bg(colors.surface) .text_color(colors.foreground) .rounded(radius.overlay) .border_1() .border_color(colors.border) .shadow(shadows) .overflow_hidden() .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) .on_action({ let state = state.clone(); move |_: &HighlightNext, _window, cx| { state.update(cx, |state, cx| state.move_highlight(1, cx)); } }) .on_action({ let state = state.clone(); move |_: &HighlightPrevious, _window, cx| { state.update(cx, |state, cx| state.move_highlight(-1, cx)); } }) .on_action({ let state = state.clone(); move |_: &Choose, window, cx| { state.update(cx, |state, cx| state.choose(window, cx)); } }) .on_action({ let state = state.clone(); move |_: &Dismiss, window, cx| { state.update(cx, |state, cx| state.dismiss(window, cx)); } }) .child( Input::new((id.clone(), "query"), self.query.clone()) .placeholder(self.placeholder.clone()), ) .child(rows) .child( canvas( { let cell = panel_bounds.clone(); move |bounds, _window, _cx| *cell.borrow_mut() = Some(bounds) }, { let focus = query_focus.clone(); move |_, _, window, cx| { if !focus.contains_focused(window, cx) { window.focus(&focus, cx); } } }, ) .absolute() .inset_0(), ); let scrim = div() .absolute() .inset_0() .bg(colors.overlay) .occlude() .on_mouse_down(MouseButton::Left, { let state = state.clone(); let panel_bounds = panel_bounds.clone(); move |event: &MouseDownEvent, _window, cx| { let on_panel = panel_bounds .borrow() .is_some_and(|bounds| bounds.contains(&event.position)); if on_panel { return; } state.update(cx, |state, cx| state.close(cx)); cx.stop_propagation(); } }); div() .absolute() .inset_0() .flex() .justify_center() .items_start() .pt_10() .child(scrim) .child(panel) .into_any_element() } }