feat add command palette component

This commit is contained in:
2026-11-04 09:37:47 +07:00
parent fa73124ee4
commit 4bfc816cbe
7 changed files with 1040 additions and 11 deletions
+279
View File
@@ -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<Command> {
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<Command> {
move |query, commands| {
let query = query.to_lowercase();
let matched: Vec<Command> = 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<CommandPaletteState>,
}
impl Render for Host {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().size_full().child(self.palette.clone())
}
}
type Executed = Rc<RefCell<Vec<SharedString>>>;
struct PaletteHost {
cx: TestAppContext,
state: Entity<CommandPaletteState>,
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<RefCell<Option<Entity<CommandPaletteState>>>> = 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")]);
}