From 4bfc816cbe9976bae25bf9e8e313792e9bef22e1 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 4 Nov 2026 09:37:47 +0700 Subject: [PATCH] feat add command palette component --- crates/core/Cargo.toml | 4 +- crates/core/src/command_palette.rs | 493 +++++++++++++++++++++ crates/core/src/lib.rs | 7 +- crates/core/tests/command_palette.rs | 279 ++++++++++++ crates/core/tests/command_palette_bench.rs | 96 ++++ docs/CHECKLIST.md | 9 +- examples/gallery/src/main.rs | 163 ++++++- 7 files changed, 1040 insertions(+), 11 deletions(-) create mode 100644 crates/core/src/command_palette.rs create mode 100644 crates/core/tests/command_palette.rs create mode 100644 crates/core/tests/command_palette_bench.rs diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 737d29d..495bfe0 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -36,6 +36,7 @@ full = [ "field", "status_dot", "combobox", + "command_palette", "tooltip", "menu", "select", @@ -62,7 +63,8 @@ segmented = [] spinner = [] field = [] status_dot = [] -combobox = ["input"] +combobox = ["input", "scroll_area"] +command_palette = ["input", "scroll_area"] tooltip = [] menu = ["scroll_area"] select = ["menu"] diff --git a/crates/core/src/command_palette.rs b/crates/core/src/command_palette.rs new file mode 100644 index 0000000..2f6ea24 --- /dev/null +++ b/crates/core/src/command_palette.rs @@ -0,0 +1,493 @@ +//! 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() + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index a6ad497..4db9e0b 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -78,6 +78,9 @@ pub mod status_dot; #[cfg(feature = "combobox")] pub mod combobox; +#[cfg(feature = "command_palette")] +pub mod command_palette; + #[cfg(feature = "toast")] pub mod toast; @@ -89,8 +92,8 @@ pub fn init(cx: &mut App) { gpui_native_theme::Theme::init(cx); #[cfg(feature = "input")] input::bind_input_keys(cx, None); - // Load-bearing order: the combobox's bindings must win the depth tie - // with the input's, and gpui breaks ties by later registration. #[cfg(feature = "combobox")] combobox::bind_combobox_keys(cx); + #[cfg(feature = "command_palette")] + command_palette::bind_command_palette_keys(cx); } diff --git a/crates/core/tests/command_palette.rs b/crates/core/tests/command_palette.rs new file mode 100644 index 0000000..f83254c --- /dev/null +++ b/crates/core/tests/command_palette.rs @@ -0,0 +1,279 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use gpui::{ + AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, SharedString, + Styled as _, TestAppContext, VisualTestContext, Window, div, +}; +use gpui_native_core::command_palette::{Command, CommandPalette, CommandPaletteState}; +use gpui_native_core::init; + +fn commands() -> Vec { + vec![ + Command::new("open-file", "Open File").hint("Cmd+O"), + Command::new("close-tab", "Close Tab"), + Command::new("save-all", "Save All").hint("Cmd+Alt+S"), + Command::new("toggle-theme", "Toggle Theme"), + ] +} + +/// The caller-owned matcher: case-insensitive substring. `reverse` flips the +/// match order, exercising that ranking belongs to the caller. +fn substring_matcher(reverse: bool) -> impl Fn(&str, &[Command]) -> Vec { + move |query, commands| { + let query = query.to_lowercase(); + let matched: Vec = commands + .iter() + .filter(|command| command.label.to_lowercase().contains(&query)) + .cloned() + .collect(); + if reverse { + matched.into_iter().rev().collect() + } else { + matched + } + } +} + +struct Host { + palette: Entity, +} + +impl Render for Host { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div().size_full().child(self.palette.clone()) + } +} + +type Executed = Rc>>; + +struct PaletteHost { + cx: TestAppContext, + state: Entity, + executed: Executed, + visual: VisualTestContext, +} + +fn palette_host(reverse: bool) -> PaletteHost { + let cx = TestAppContext::single(); + // `init` registers the input bindings and then the palette's, in the + // load-bearing order. + cx.update(init); + let executed: Executed = Rc::new(RefCell::new(Vec::new())); + let state_cell: Rc>>> = Rc::new(RefCell::new(None)); + let window = cx.update(|cx| { + let executed_for_builder = executed.clone(); + let cell = state_cell.clone(); + cx.open_window(gpui::WindowOptions::default(), |window, cx| { + let palette = + CommandPalette::new("test-palette", commands(), substring_matcher(reverse)) + .on_execute(move |command: &Command, _, _| { + executed_for_builder.borrow_mut().push(command.id.clone()) + }); + let state = cx.new(|cx| CommandPaletteState::new(palette, window, cx)); + cell.borrow_mut().replace(state.clone()); + cx.new(|_| Host { palette: state }) + }) + .unwrap() + }); + let state = state_cell.borrow().clone().expect("state captured"); + let mut visual = VisualTestContext::from_window(window.into(), &cx); + draw(&mut visual); + PaletteHost { + cx, + state, + executed, + visual, + } +} + +fn draw(visual: &mut VisualTestContext) { + visual.update(|window, cx| { + window.draw(cx).clear(cx); + }); +} + +fn open(host: &mut PaletteHost) { + let state = host.state.clone(); + host.visual.update(|window, cx| { + state.update(cx, |state, cx| state.open(window, cx)); + }); +} + +fn type_text(host: &mut PaletteHost, text: &str) { + for letter in text.chars() { + host.visual.simulate_keystrokes(&letter.to_string()); + } + draw(&mut host.visual); +} + +#[test] +fn open_lists_the_callers_commands() { + let mut host = palette_host(false); + open(&mut host); + draw(&mut host.visual); + + assert!(host.visual.debug_bounds("test-palette-command-0").is_some()); + assert!(host.visual.debug_bounds("test-palette-command-3").is_some()); + assert!(host.visual.debug_bounds("test-palette-panel").is_some()); + assert!(host.cx.update(|cx| host.state.read(cx).is_open())); +} + +#[test] +fn enter_runs_the_highlighted_command_and_closes() { + let mut host = palette_host(false); + open(&mut host); + host.visual.simulate_keystrokes("enter"); + draw(&mut host.visual); + + assert_eq!( + *host.executed.borrow(), + vec![SharedString::from("open-file")], + "the first row starts highlighted" + ); + assert!(host.visual.debug_bounds("test-palette-panel").is_none()); +} + +#[test] +fn rows_follow_the_callers_match_order() { + let mut host = palette_host(true); + open(&mut host); + host.visual.simulate_keystrokes("enter"); + draw(&mut host.visual); + + assert_eq!( + *host.executed.borrow(), + vec![SharedString::from("toggle-theme")], + "enter runs the first row, and the caller's matcher owns the row order" + ); +} + +#[test] +fn typing_filters_through_the_caller_matcher() { + let mut host = palette_host(false); + open(&mut host); + type_text(&mut host, "save"); + + assert!(host.visual.debug_bounds("test-palette-command-0").is_some()); + assert!( + host.visual.debug_bounds("test-palette-command-1").is_none(), + "only the matching command stays listed" + ); + + host.visual.simulate_keystrokes("enter"); + draw(&mut host.visual); + assert_eq!( + *host.executed.borrow(), + vec![SharedString::from("save-all")] + ); +} + +#[test] +fn escape_closes_without_running() { + let mut host = palette_host(false); + open(&mut host); + host.visual.simulate_keystrokes("escape"); + draw(&mut host.visual); + + assert!(host.visual.debug_bounds("test-palette-panel").is_none()); + assert!(host.executed.borrow().is_empty()); + assert!(!host.cx.update(|cx| host.state.read(cx).is_open())); +} + +#[test] +fn arrow_keys_move_the_highlight() { + let mut host = palette_host(false); + open(&mut host); + host.visual.simulate_keystrokes("down"); + host.visual.simulate_keystrokes("enter"); + draw(&mut host.visual); + assert_eq!( + *host.executed.borrow(), + vec![SharedString::from("close-tab")] + ); + + // Up from the top wraps to the last row. + open(&mut host); + host.visual.simulate_keystrokes("up"); + host.visual.simulate_keystrokes("enter"); + draw(&mut host.visual); + assert_eq!( + *host.executed.borrow(), + vec![ + SharedString::from("close-tab"), + SharedString::from("toggle-theme") + ] + ); +} + +#[test] +fn clicking_a_row_runs_it() { + let mut host = palette_host(false); + open(&mut host); + let row = host + .visual + .debug_bounds("test-palette-command-2") + .expect("save-all row missing"); + host.visual + .simulate_click(row.center(), gpui::Modifiers::default()); + draw(&mut host.visual); + + assert_eq!( + *host.executed.borrow(), + vec![SharedString::from("save-all")] + ); + assert!(host.visual.debug_bounds("test-palette-panel").is_none()); +} + +#[test] +fn clicking_the_scrim_closes() { + let mut host = palette_host(false); + open(&mut host); + draw(&mut host.visual); + host.visual.simulate_click( + gpui::point(gpui::px(10.), gpui::px(10.)), + gpui::Modifiers::default(), + ); + draw(&mut host.visual); + + assert!(host.visual.debug_bounds("test-palette-panel").is_none()); + assert!(host.executed.borrow().is_empty()); +} + +#[test] +fn reopen_starts_from_a_fresh_query() { + let mut host = palette_host(false); + open(&mut host); + type_text(&mut host, "zzzz"); + assert!( + host.visual.debug_bounds("test-palette-command-0").is_none(), + "nothing matches nonsense" + ); + + host.visual.simulate_keystrokes("escape"); + open(&mut host); + draw(&mut host.visual); + assert!( + host.visual.debug_bounds("test-palette-command-0").is_some(), + "a fresh open clears the query" + ); +} + +#[test] +fn set_commands_replaces_the_registry() { + let mut host = palette_host(false); + host.cx.update(|cx| { + host.state.update(cx, |state, cx| { + state.set_commands(vec![Command::new("only", "Only Command")], cx) + }); + }); + open(&mut host); + draw(&mut host.visual); + + assert!(host.visual.debug_bounds("test-palette-command-0").is_some()); + assert!(host.visual.debug_bounds("test-palette-command-1").is_none()); + + host.visual.simulate_keystrokes("enter"); + draw(&mut host.visual); + assert_eq!(*host.executed.borrow(), vec![SharedString::from("only")]); +} diff --git a/crates/core/tests/command_palette_bench.rs b/crates/core/tests/command_palette_bench.rs new file mode 100644 index 0000000..c534b91 --- /dev/null +++ b/crates/core/tests/command_palette_bench.rs @@ -0,0 +1,96 @@ +use std::cell::RefCell; +use std::rc::Rc; +use std::time::Instant; + +use gpui::{ + AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, SharedString, + Styled as _, TestAppContext, Window, div, +}; +use gpui_native_core::command_palette::{Command, CommandPalette, CommandPaletteState}; +use gpui_native_core::init; + +fn command(label: &'static str) -> Command { + Command::new(SharedString::from(label), SharedString::from(label)) +} + +struct Host { + palette: Entity, + renders: Rc>, +} + +impl Render for Host { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + *self.renders.borrow_mut() += 1; + div().size_full().child(self.palette.clone()) + } +} + +#[test] +fn typing_with_the_palette_open_is_not_pathologically_slow() { + let cx = TestAppContext::single(); + cx.update(init); + let (state, renders, window) = cx.update(|cx| { + let renders = Rc::new(RefCell::new(0)); + let state_cell: Rc>>> = + Rc::new(RefCell::new(None)); + let renders_for_host = renders.clone(); + let cell = state_cell.clone(); + let window = cx + .open_window(gpui::WindowOptions::default(), |window, cx| { + let matcher = |query: &str, commands: &[Command]| { + let query = query.to_lowercase(); + commands + .iter() + .filter(|command| command.label.to_lowercase().contains(&query)) + .cloned() + .collect() + }; + let state = cx.new(|cx| { + CommandPaletteState::new( + CommandPalette::new( + "bench-palette", + vec![ + command("Open File"), + command("Close Tab"), + command("Save All"), + command("Toggle Theme"), + ], + matcher, + ), + window, + cx, + ) + }); + let state_for_host = state.clone(); + cell.borrow_mut().replace(state.clone()); + cx.new(|_| Host { + palette: state_for_host, + renders: renders_for_host, + }) + }) + .unwrap(); + let state = state_cell.borrow().clone().expect("state captured"); + (state, renders, window) + }); + let mut visual = gpui::VisualTestContext::from_window(window.into(), &cx); + visual.update(|window, cx| { + state.update(cx, |state, cx| state.open(window, cx)); + window.draw(cx).clear(cx); + }); + + let started = Instant::now(); + for letter in "abcdefghijklmnopqrstuvwxyz".chars() { + visual.simulate_keystrokes(&letter.to_string()); + visual.update(|window, cx| { + window.draw(cx).clear(cx); + }); + } + let elapsed = started.elapsed(); + + let total_renders = *renders.borrow(); + println!( + "26 real keystrokes with the palette open: {elapsed:?}, host renders: {total_renders} ({} per keystroke)", + total_renders as f32 / 26. + ); + assert!(elapsed.as_millis() < 2000, "typing took {elapsed:?}"); +} diff --git a/docs/CHECKLIST.md b/docs/CHECKLIST.md index 7c9ca27..0c591df 100644 --- a/docs/CHECKLIST.md +++ b/docs/CHECKLIST.md @@ -9,18 +9,16 @@ is the first downstream consumer; its screens drive the order. ## Next -- [ ] **Combobox** (M11) — type-to-filter pickers; adopt gpuikit's - `on_create` / `keep_unmatched` blur modes. +- [ ] **Command palette** (M12) — filtered actions; the matcher stays + caller-owned. - [x] API sketch - - [x] Implementation (theme-baked, motion inline per AGENTS.md) + - [x] Implementation (theme-baked, no entry motion per AGENTS.md) - [x] Tests - [x] Gallery panel - [ ] Review ## Queue — to follow -- [ ] **Command palette** (M12) — filtered actions; the matcher stays - caller-owned. - [ ] **VirtualList** (M13, `data`) — thin `uniform_list` binding for commit histories and the inbox. - [ ] **Code view** (M14, opens `crates/editor`) — tree-sitter highlighting. @@ -63,6 +61,7 @@ is the first downstream consumer; its screens drive the order. settings-row variants - [x] Status dot — colored state bead, Badge's variant vocabulary, optional screen-reader label +- [x] Combobox — type-to-filter picker, create / keep / revert blur modes ### data (`crates/data`) diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index 17d2292..01f6947 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -2,9 +2,9 @@ use std::time::Duration; use gpui::prelude::FluentBuilder as _; use gpui::{ - App, AppContext as _, Bounds, Context, Entity, FontWeight, IntoElement, ParentElement as _, - Pixels, Render, SharedString, Styled as _, WeakEntity, Window, WindowBounds, WindowOptions, - div, px, size, + App, AppContext as _, Bounds, Context, Entity, FontWeight, InteractiveElement as _, + IntoElement, KeyBinding, ParentElement as _, Pixels, Render, SharedString, Styled as _, + WeakEntity, Window, WindowBounds, WindowOptions, actions, div, px, size, }; use gpui_native_core::alert::{Alert, AlertSeverity, Alerts, AlertsView}; use gpui_native_core::avatar::Avatar; @@ -12,6 +12,7 @@ use gpui_native_core::badge::{Badge, BadgeVariant}; use gpui_native_core::button::{Button, ButtonVariant}; use gpui_native_core::checkbox::Checkbox; use gpui_native_core::combobox::{Combobox, ComboboxOption, ComboboxState}; +use gpui_native_core::command_palette::{Command, CommandPalette, CommandPaletteState}; use gpui_native_core::common::{Sizable as _, Size, h_flex, v_flex}; use gpui_native_core::context_menu::ContextMenu; use gpui_native_core::dialog::Dialog; @@ -85,6 +86,7 @@ const CATALOG: &[(&str, &[(&str, &str)])] = &[ ("alert", "Alert"), ("toast", "Toast"), ("dialog", "Dialog"), + ("command-palette", "Command palette"), ("menu", "Menu"), ("context-menu", "Context menu"), ("popover", "Popover"), @@ -174,11 +176,26 @@ impl PresetChoice { /// The context menu demo's actions. const CONTEXT_ACTIONS: [&str; 3] = ["Rename", "Duplicate", "Archive"]; +actions!(gallery, [OpenCommandPalette]); + +/// The command palette demo's actions. +fn gallery_commands() -> Vec { + vec![ + Command::new("open-settings", "Open Settings").hint("Cmd+,"), + Command::new("toggle-theme", "Toggle Theme").hint("Shift+Cmd+T"), + Command::new("save-all", "Save All").hint("Cmd+Alt+S"), + Command::new("close-tab", "Close Tab").hint("Cmd+W"), + Command::new("reload-window", "Reload Window"), + ] +} + struct Gallery { layout_state: Entity, dock_state: Entity, alerts: Entity, toasts: Entity, + palette: Option>, + palette_runs: Vec, dialog_open: bool, alert_open: bool, } @@ -352,6 +369,7 @@ impl Gallery { layout_state: Entity, alerts: Entity, toasts: Entity, + palette: Entity, gallery: WeakEntity, cx: &mut Context, ) { @@ -539,6 +557,7 @@ impl Gallery { ); dock.register_panel( PanelDescriptor::new("dialog", "Dialog", { + let gallery = gallery.clone(); move |_, cx| { cx.new(|_| DialogDemo { gallery: gallery.clone(), @@ -548,6 +567,16 @@ impl Gallery { }), cx, ); + dock.register_panel( + PanelDescriptor::new("command-palette", "Command palette", { + let palette = palette.clone(); + move |_, cx| { + cx.new(|cx| CommandPaletteDemo::new(gallery.clone(), palette.clone(), cx)) + .into() + } + }), + cx, + ); dock.register_panel( PanelDescriptor::new("menu", "Menu", |_, cx| { cx.new(|_| MenuDemo { open: false }).into() @@ -608,6 +637,7 @@ impl Render for Gallery { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let theme = cx.theme(); let gallery = cx.entity().downgrade(); + let palette_for_action = self.palette.clone(); // Dialogs are window-modal, so they mount at the window root; the // Dialog demo panel only flips these flags. @@ -617,11 +647,17 @@ impl Render for Gallery { div() .size_full() .font_family(".SystemUIFont") + .on_action(move |_: &OpenCommandPalette, window, cx| { + if let Some(palette) = &palette_for_action { + palette.update(cx, |palette, cx| palette.toggle(window, cx)); + } + }) .bg(theme.colors.background) .text_color(theme.colors.foreground) .child(self.render_shell(cx)) .child(dialog) .child(alert_dialog) + .when_some(self.palette.clone(), |root, palette| root.child(palette)) .child(AlertsView::new("gallery-alerts", self.alerts.clone())) .child(ToastsView::new("gallery-toasts", self.toasts.clone())) } @@ -2194,6 +2230,92 @@ impl Render for DialogDemo { } } +struct CommandPaletteDemo { + gallery: WeakEntity, + palette: Entity, + _subscription: gpui::Subscription, +} + +impl CommandPaletteDemo { + fn new( + gallery: WeakEntity, + palette: Entity, + cx: &mut Context, + ) -> Self { + // The panel is a cached view in the dock: the readouts re-render only + // when the palette state they read is notified. + let subscription = cx.observe(&palette, |_, _, cx| cx.notify()); + Self { + gallery, + palette, + _subscription: subscription, + } + } +} + +impl Render for CommandPaletteDemo { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let colors = cx.theme().colors; + let open = self.palette.read(cx).is_open(); + let runs = self + .gallery + .upgrade() + .map(|gallery| gallery.read(cx).palette_runs.clone()) + .unwrap_or_default(); + let runs = if runs.is_empty() { + "none yet".to_string() + } else { + runs.iter() + .map(|id| id.to_string()) + .collect::>() + .join(", ") + }; + let palette_toggle = self.palette.clone(); + + ScrollArea::new("command-palette-demo") + .size_full() + .p_4() + .child(demo_section( + "window-modal, mounted at the window root", + colors, + v_flex() + .gap_2() + .child( + Button::new("command-palette-demo-open") + .label("Open command palette") + .variant(ButtonVariant::Secondary) + .on_click(move |_, window, cx| { + palette_toggle.update(cx, |palette, cx| palette.toggle(window, cx)); + }), + ) + .child( + div() + .text_xs() + .text_color(colors.muted_foreground) + .child("Cmd+K (Ctrl+K elsewhere) opens it anywhere in the window."), + ), + )) + .child(demo_section( + "runs", + colors, + v_flex() + .gap_1() + .child( + div() + .text_xs() + .text_color(colors.muted_foreground) + .child(if open { "open" } else { "closed" }), + ) + .child( + div() + .text_xs() + .text_color(colors.muted_foreground) + .child(format!("executed: {runs}")), + ), + )) + } +} + struct MenuDemo { open: bool, } @@ -2791,6 +2913,10 @@ fn main() { .with_http_client(std::sync::Arc::new(reqwest_client::ReqwestClient::new())) .run(|cx: &mut App| { gpui_native_core::init(cx); + cx.bind_keys([ + KeyBinding::new("cmd-k", OpenCommandPalette, None), + KeyBinding::new("ctrl-k", OpenCommandPalette, None), + ]); let layout_state = LayoutState::new(cx); let dock_state = DockState::new(cx); // A panel that declares a right sidebar view asks for it to open on @@ -2842,16 +2968,47 @@ fn main() { dock_state: dock_state.clone(), alerts: alerts.clone(), toasts: toasts.clone(), + palette: None, + palette_runs: Vec::new(), dialog_open: false, alert_open: false, } }); + let palette = cx.new(|cx| { + let matcher = |query: &str, commands: &[Command]| { + let needle = query.to_lowercase(); + commands + .iter() + .filter(|command| command.label.to_lowercase().contains(&needle)) + .cloned() + .collect() + }; + let gallery_for_execute = gallery.downgrade(); + CommandPaletteState::new( + CommandPalette::new("gallery-palette", gallery_commands(), matcher) + .on_execute(move |command: &Command, _, cx| { + gallery_for_execute + .update(cx, |gallery, cx| { + gallery.palette_runs.push(command.id.clone()); + cx.notify(); + }) + .ok(); + }), + window, + cx, + ) + }); + gallery.update(cx, |gallery, cx| { + gallery.palette = Some(palette.clone()); + cx.notify(); + }); dock_state.update(cx, |dock, cx| { Gallery::register_panels( dock, layout_state.clone(), alerts.clone(), toasts.clone(), + palette.clone(), gallery.downgrade(), cx, );