use crate::a11y::{A11y, Announce};
use crate::element_id::for_entity;
use crate::elements::listbox::wrapped_index;
use crate::elements::text_field::text_field;
use crate::input::{InputState, InputStateEvent};
use crate::theme::{ActiveTheme, ControlSize, Themeable};
use crate::traits::accessible::Accessible;
use crate::traits::control_sized::ControlSized;
use gpui::{
App, Context, ElementId, Entity, EventEmitter, Focusable, IntoElement, KeyBinding,
ParentElement, Rems, Render, Role, SharedString, Styled, Svg, Window, actions, deferred, div,
prelude::*, px,
};
use std::rc::Rc;
actions!(
command,
[
CommandSelectNext,
CommandSelectPrevious,
CommandRun,
CommandDismiss,
]
);
pub const COMMAND_CONTEXT: &str = "CommandPalette";
pub fn bind_command_keys(cx: &mut App) {
let under_input = Some("CommandPalette > Input");
cx.bind_keys([
KeyBinding::new("down", CommandSelectNext, under_input),
KeyBinding::new("up", CommandSelectPrevious, under_input),
KeyBinding::new("enter", CommandRun, under_input),
KeyBinding::new("escape", CommandDismiss, under_input),
KeyBinding::new("down", CommandSelectNext, Some(COMMAND_CONTEXT)),
KeyBinding::new("up", CommandSelectPrevious, Some(COMMAND_CONTEXT)),
KeyBinding::new("enter", CommandRun, Some(COMMAND_CONTEXT)),
KeyBinding::new("escape", CommandDismiss, Some(COMMAND_CONTEXT)),
]);
}
const PANEL_TOP: Rems = Rems(6.0);
const PANEL_WIDTH: f32 = 560.0;
const RESULTS_MAX_HEIGHT: f32 = 360.0;
pub enum CommandEvent {
QueryChanged(SharedString),
Run(usize),
Dismissed,
}
pub struct CommandItem {
pub label: SharedString,
pub subtitle: Option<SharedString>,
pub keywords: Vec<SharedString>,
pub shortcut: Option<SharedString>,
pub icon: Option<Rc<dyn Fn() -> Svg>>,
pub disabled: bool,
pub on_run: Option<Rc<dyn Fn(&mut Window, &mut App)>>,
}
impl CommandItem {
pub fn new(label: impl Into<SharedString>) -> Self {
Self {
label: label.into(),
subtitle: None,
keywords: Vec::new(),
shortcut: None,
icon: None,
disabled: false,
on_run: None,
}
}
pub fn subtitle(mut self, subtitle: impl Into<SharedString>) -> Self {
self.subtitle = Some(subtitle.into());
self
}
pub fn keywords(mut self, keywords: impl IntoIterator<Item = impl Into<SharedString>>) -> Self {
self.keywords.extend(keywords.into_iter().map(Into::into));
self
}
pub fn shortcut(mut self, shortcut: impl Into<SharedString>) -> Self {
self.shortcut = Some(shortcut.into());
self
}
pub fn icon(mut self, icon: impl Fn() -> Svg + 'static) -> Self {
self.icon = Some(Rc::new(icon));
self
}
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
pub fn on_run(mut self, on_run: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_run = Some(Rc::new(on_run));
self
}
pub fn haystack(&self) -> String {
let mut haystack = self.label.to_string();
if let Some(subtitle) = &self.subtitle {
haystack.push(' ');
haystack.push_str(subtitle);
}
for keyword in &self.keywords {
haystack.push(' ');
haystack.push_str(keyword);
}
haystack
}
}
type Matcher = Rc<dyn Fn(&str, &[CommandItem]) -> Vec<usize>>;
pub struct CommandState {
id: ElementId,
label: SharedString,
query: Entity<InputState>,
items: Vec<CommandItem>,
matches: Vec<usize>,
matcher: Option<Matcher>,
selected: Option<usize>,
open: bool,
size: ControlSize,
}
impl EventEmitter<CommandEvent> for CommandState {}
impl CommandState {
pub fn new(
id: impl Into<ElementId>,
name: impl Into<SharedString>,
items: Vec<CommandItem>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let query = cx.new(|cx| {
let mut state = InputState::new_singleline(cx);
state.set_placeholder(SharedString::from("Type a command…"), cx);
state
});
cx.subscribe_in(&query, window, |this, _query, event, _window, cx| {
if matches!(event, InputStateEvent::TextChanged) {
this.query_changed(cx);
}
})
.detach();
let matches = (0..items.len()).collect::<Vec<_>>();
let mut state = Self {
id: id.into(),
label: name.into(),
query,
items,
matches,
matcher: None,
selected: None,
open: false,
size: ControlSize::default(),
};
state.selected = state.first_runnable();
state
}
pub fn matcher(
mut self,
matcher: impl Fn(&str, &[CommandItem]) -> Vec<usize> + 'static,
) -> Self {
self.matcher = Some(Rc::new(matcher));
self
}
pub fn set_items(&mut self, items: Vec<CommandItem>, cx: &mut Context<Self>) {
self.items = items;
self.rematch(cx);
}
pub fn set_matches(&mut self, matches: Vec<usize>, cx: &mut Context<Self>) {
self.matches = matches
.into_iter()
.filter(|i| *i < self.items.len())
.collect();
self.selected = self.first_runnable();
cx.notify();
}
pub fn is_open(&self) -> bool {
self.open
}
pub fn open(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.open = true;
self.query
.update(cx, |state, cx| state.set_content_silent("", cx));
self.rematch(cx);
let handle = self.query.read(cx).focus_handle(cx);
window.focus(&handle, cx);
cx.notify();
}
pub fn dismiss(&mut self, cx: &mut Context<Self>) {
if !self.open {
return;
}
self.open = false;
cx.emit(CommandEvent::Dismissed);
cx.notify();
}
fn runnable_rows(&self) -> Vec<usize> {
self.matches
.iter()
.enumerate()
.filter(|(_, item)| self.items.get(**item).is_some_and(|item| !item.disabled))
.map(|(row, _)| row)
.collect()
}
fn first_runnable(&self) -> Option<usize> {
self.runnable_rows().first().copied()
}
fn next_selection(&self, delta: isize) -> Option<usize> {
let runnable = self.runnable_rows();
let position = self
.selected
.and_then(|selected| runnable.iter().position(|row| *row == selected));
let next = wrapped_index(position, delta, runnable.len())?;
runnable.get(next).copied()
}
pub fn selected_item(&self) -> Option<usize> {
self.matches.get(self.selected?).copied()
}
fn query_changed(&mut self, cx: &mut Context<Self>) {
let query = SharedString::from(self.query.read(cx).content().to_string());
self.rematch(cx);
cx.emit(CommandEvent::QueryChanged(query));
}
fn rematch(&mut self, cx: &mut Context<Self>) {
let query = self.query.read(cx).content().to_string();
self.matches = match &self.matcher {
Some(matcher) => matcher(&query, &self.items)
.into_iter()
.filter(|index| *index < self.items.len())
.collect(),
None => (0..self.items.len()).collect(),
};
self.selected = self.first_runnable();
cx.notify();
}
fn move_selection(&mut self, delta: isize, cx: &mut Context<Self>) {
if !self.open {
cx.propagate();
return;
}
self.selected = self.next_selection(delta);
cx.notify();
}
fn run_selected(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if !self.open {
cx.propagate();
return;
}
let Some(index) = self.selected_item() else {
return;
};
let on_run = self.items.get(index).and_then(|item| item.on_run.clone());
self.open = false;
cx.emit(CommandEvent::Run(index));
cx.notify();
if let Some(on_run) = on_run {
on_run(window, cx);
}
}
fn handle_dismiss(&mut self, cx: &mut Context<Self>) {
if !self.open {
cx.propagate();
return;
}
self.dismiss(cx);
}
fn row_a11y(&self, row: usize, item: &CommandItem) -> A11y {
A11y::new(Role::ListBoxOption)
.name(item.label.clone())
.selected(self.selected == Some(row))
.position_in_set(row + 1)
.size_of_set(self.matches.len())
}
}
impl Accessible for CommandState {
fn a11y(&self) -> A11y {
A11y::new(Role::ListBox).name(self.label.clone())
}
}
impl ControlSized for CommandState {
fn control_size(mut self, size: ControlSize) -> Self {
self.size = size;
self
}
}
impl Render for CommandState {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
if !self.open {
return div().into_any_element();
}
let theme = cx.theme();
let metrics = theme.control(self.size);
let list_a11y = self.a11y();
let top = PANEL_TOP.to_pixels(window.rem_size());
let scrim = theme.overlay();
let surface = theme.surface();
let border = theme.border();
let fg = theme.fg();
let fg_muted = theme.fg_muted();
let accent = theme.accent();
let rows: Vec<_> = self
.matches
.iter()
.enumerate()
.filter_map(|(row, index)| self.items.get(*index).map(|item| (row, *index, item)))
.map(|(row, index, item)| {
let is_selected = self.selected == Some(row);
let a11y = self.row_a11y(row, item);
let disabled = item.disabled;
let element = div()
.id(ElementId::NamedInteger("command-row".into(), row as u64))
.announce(a11y)
.flex()
.items_center()
.gap(metrics.gap)
.h(metrics.height)
.px(metrics.padding_x * 1.5)
.text_size(metrics.text_size)
.line_height(metrics.line_height)
.text_color(if disabled {
theme.fg_disabled()
} else if is_selected {
theme.bg()
} else {
fg
})
.when(is_selected && !disabled, |this| this.bg(accent))
.when(!disabled, |this| {
this.cursor_pointer()
.on_click(cx.listener(move |this, _, window, cx| {
this.selected = Some(row);
this.run_selected(window, cx);
}))
})
.when_some(item.icon.clone(), |this, icon| {
this.child(icon().size(metrics.text_size))
})
.child(div().flex_1().child(item.label.clone()))
.when_some(item.subtitle.clone(), |this, subtitle| {
this.child(div().text_color(fg_muted).child(subtitle))
})
.when_some(item.shortcut.clone(), |this, shortcut| {
this.child(div().text_color(fg_muted).child(shortcut))
});
#[cfg(test)]
let element =
element.debug_selector(move || format!("gpuikit-command-row-{index}"));
#[cfg(not(test))]
let _ = index;
element
})
.collect();
let panel = div()
.id(self.id.clone())
.occlude()
.w(px(PANEL_WIDTH))
.max_w_full()
.bg(surface)
.border_1()
.border_color(border)
.rounded(metrics.radius)
.shadow_lg()
.flex()
.flex_col()
.child(
div()
.p(metrics.padding_x)
.border_b_1()
.border_color(border)
.child(text_field(&self.query, cx).full_width(true)),
)
.child(
div()
.id(for_entity("gpuikit-command-results", cx.entity_id()))
.announce(list_a11y)
.max_h(px(RESULTS_MAX_HEIGHT))
.overflow_y_scroll()
.py(metrics.padding_y())
.flex()
.flex_col()
.children(rows),
);
#[cfg(test)]
let panel = panel.debug_selector(|| "gpuikit-command-panel".into());
let scrimmed = div()
.id(for_entity("gpuikit-command", cx.entity_id()))
.key_context(COMMAND_CONTEXT)
.on_action(cx.listener(|this, _: &CommandSelectNext, _window, cx| {
this.move_selection(1, cx);
}))
.on_action(cx.listener(|this, _: &CommandSelectPrevious, _window, cx| {
this.move_selection(-1, cx);
}))
.on_action(cx.listener(|this, _: &CommandRun, window, cx| {
this.run_selected(window, cx);
}))
.on_action(cx.listener(|this, _: &CommandDismiss, _window, cx| {
this.handle_dismiss(cx);
}))
.absolute()
.top_0()
.left_0()
.size_full()
.flex()
.flex_col()
.items_center()
.bg(scrim)
.pt(top)
.on_mouse_down(
gpui::MouseButton::Left,
cx.listener(|this, _, _window, cx| {
this.dismiss(cx);
}),
)
.child(panel);
deferred(scrimmed).with_priority(10).into_any_element()
}
}
#[cfg(test)]
mod tests {
use super::*;
use gpui::{
AppContext, Entity, IntoElement, Render, TestAppContext, VisualTestContext, div, px, size,
};
use std::cell::RefCell;
use std::ops::Deref;
use std::rc::Rc;
struct CommandTestView {
command: Entity<CommandState>,
}
impl Render for CommandTestView {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div()
}
}
fn open_command(
cx: &mut TestAppContext,
) -> (
Entity<CommandState>,
Rc<RefCell<Vec<String>>>,
gpui::Subscription,
&'static mut VisualTestContext,
) {
cx.update(crate::init);
let window = cx.open_window(size(px(400.), px(300.)), |window, cx| {
let command = cx.new(|cx| {
CommandState::new("cmd", "Commands", items(), window, cx).matcher(|query, items| {
items
.iter()
.enumerate()
.filter(|(_, item)| {
item.haystack()
.to_lowercase()
.contains(&query.to_lowercase())
})
.map(|(index, _)| index)
.collect()
})
});
CommandTestView { command }
});
let command = window
.read_with(cx, |view, _cx| view.command.clone())
.expect("the window's root view is the command test view");
let sink = Rc::new(RefCell::new(Vec::new()));
let sub = cx.update(|cx| {
let sink = sink.clone();
cx.subscribe(&command, move |_command, event: &CommandEvent, _cx| {
if let CommandEvent::QueryChanged(query) = event {
sink.borrow_mut().push(query.to_string());
}
})
});
let cx = VisualTestContext::from_window(*window.deref(), cx).into_mut();
cx.run_until_parked();
(command, sink, sub, cx)
}
#[gpui::test]
fn opening_does_not_emit_query_changed(cx: &mut TestAppContext) {
let (command, sink, _sub, cx) = open_command(cx);
cx.update(|_window, cx| {
command.update(cx, |this, cx| {
let query = this.query.clone();
query.update(cx, |query, cx| query.set_content("quit", cx));
});
});
cx.run_until_parked();
sink.borrow_mut().clear();
cx.update(|window, cx| {
command.update(cx, |this, cx| this.open(window, cx));
});
cx.run_until_parked();
assert!(
sink.borrow().is_empty(),
"opening the palette must not emit QueryChanged; it heard its own reset: {:?}",
sink.borrow(),
);
}
fn items() -> Vec<CommandItem> {
vec![
CommandItem::new("Open File").keywords(["edit"]),
CommandItem::new("Save").disabled(true),
CommandItem::new("Save As").subtitle("write a copy"),
CommandItem::new("Quit"),
]
}
fn model(matches: Vec<usize>) -> (Vec<usize>, Vec<CommandItem>) {
(matches, items())
}
fn runnable(matches: &[usize], items: &[CommandItem]) -> Vec<usize> {
matches
.iter()
.enumerate()
.filter(|(_, item)| items.get(**item).is_some_and(|item| !item.disabled))
.map(|(row, _)| row)
.collect()
}
#[test]
fn a_disabled_row_is_shown_and_skipped() {
let (matches, items) = model(vec![0, 1, 2, 3]);
assert_eq!(runnable(&matches, &items), vec![0, 2, 3]);
}
#[test]
fn the_selection_wraps_at_both_ends() {
let runnable = [0usize, 2, 3];
assert_eq!(wrapped_index(Some(2), 1, runnable.len()), Some(0));
assert_eq!(wrapped_index(Some(0), -1, runnable.len()), Some(2));
}
#[test]
fn an_empty_result_set_selects_nothing() {
assert_eq!(wrapped_index(None, 1, 0), None);
let (matches, items) = model(Vec::new());
assert!(runnable(&matches, &items).is_empty());
}
#[test]
fn entering_from_the_far_end_comes_in_at_the_bottom() {
assert_eq!(wrapped_index(None, -1, 3), Some(2));
assert_eq!(wrapped_index(None, 1, 3), Some(0));
}
#[test]
fn a_row_index_is_not_an_item_index() {
let matches = [2usize, 3];
assert_eq!(matches.first().copied(), Some(2));
assert_eq!(matches.get(1).copied(), Some(3));
}
#[test]
fn every_runnable_row_survives_a_filter() {
let (matches, items) = model(vec![1, 2]);
assert_eq!(runnable(&matches, &items), vec![1]);
}
#[test]
fn a_haystack_carries_the_label_the_subtitle_and_the_keywords() {
let item = CommandItem::new("Open File")
.subtitle("from disk")
.keywords(["edit", "load"]);
assert_eq!(item.haystack(), "Open File from disk edit load");
}
}