feat add command palette component
This commit is contained in:
@@ -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<SharedString>,
|
||||
}
|
||||
|
||||
impl Command {
|
||||
/// Creates a command without a hint.
|
||||
pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> 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<SharedString>) -> Self {
|
||||
self.hint = Some(hint.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Filters and ranks the commands for a query.
|
||||
pub type CommandMatcher = Rc<dyn Fn(&str, &[Command]) -> Vec<Command>>;
|
||||
/// Runs the chosen command.
|
||||
pub type CommandHandler = Rc<dyn Fn(&Command, &mut Window, &mut App)>;
|
||||
|
||||
/// Builder for a command palette. Pass it to [`CommandPaletteState::new`].
|
||||
pub struct CommandPalette {
|
||||
id: ElementId,
|
||||
name: SharedString,
|
||||
placeholder: SharedString,
|
||||
commands: Vec<Command>,
|
||||
matcher: CommandMatcher,
|
||||
on_execute: Option<CommandHandler>,
|
||||
}
|
||||
|
||||
impl CommandPalette {
|
||||
/// Creates the builder, the caller-owned matcher decides what matches.
|
||||
pub fn new<I, M>(id: I, commands: Vec<Command>, matcher: M) -> Self
|
||||
where
|
||||
I: Into<ElementId>,
|
||||
M: Fn(&str, &[Command]) -> Vec<Command> + '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<L>(mut self, placeholder: L) -> Self
|
||||
where
|
||||
L: Into<SharedString>,
|
||||
{
|
||||
self.placeholder = placeholder.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Runs `handler` with the chosen command, then closes the palette.
|
||||
pub fn on_execute<F>(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<L>(mut self, label: L) -> Self
|
||||
where
|
||||
L: Into<SharedString>,
|
||||
{
|
||||
self.name = label.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// The stateful palette.
|
||||
pub struct CommandPaletteState {
|
||||
id: ElementId,
|
||||
name: SharedString,
|
||||
placeholder: SharedString,
|
||||
commands: Vec<Command>,
|
||||
matcher: CommandMatcher,
|
||||
on_execute: Option<CommandHandler>,
|
||||
query: Entity<InputState>,
|
||||
/// The commands the matcher returned for the query, in display order.
|
||||
matches: Vec<Command>,
|
||||
/// The highlighted row, when the palette is open.
|
||||
highlight: Option<usize>,
|
||||
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>) -> 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<Self>) {
|
||||
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<Self>) {
|
||||
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<Self>) {
|
||||
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<Command>, cx: &mut Context<Self>) {
|
||||
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<Self>) {
|
||||
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<Self>) {
|
||||
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<Self>) {
|
||||
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<Self>) {
|
||||
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<Self>) {
|
||||
if !self.open {
|
||||
cx.propagate();
|
||||
return;
|
||||
}
|
||||
self.close(cx);
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for CommandPaletteState {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> 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<AnyElement> = 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<RefCell<Option<Bounds<Pixels>>>> = 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()
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user