use std::collections::HashMap;
use std::rc::Rc;
use std::sync::Arc;
use gpui::{
div, prelude::FluentBuilder as _, px, App, ElementId, Entity, Focusable as _,
InteractiveElement, IntoElement, ParentElement, Pixels, RenderOnce, ScrollHandle, SharedString,
StatefulInteractiveElement, Styled, Window,
};
use herogpui_core::element_id;
use herogpui_theme::ActiveTheme;
use crate::a11y::{self, A11y as _};
use crate::input::{Input, InputState};
use crate::matches::MatchesCache;
use crate::picker_item::PickerItem;
use crate::{anim, util, Filter, Kbd, Sensitivity};
type SelectCallback = Arc<dyn Fn(&SharedString, &mut Window, &mut App) + 'static>;
type OpenChange = Arc<dyn Fn(&bool, &mut Window, &mut App) + 'static>;
type CommandFilter = Rc<dyn Fn(&CommandItem, &str) -> bool + 'static>;
const ROW_HEIGHT: Pixels = px(40.);
const HEADING_HEIGHT: Pixels = px(28.);
pub fn is_command_palette_shortcut(keystroke: &gpui::Keystroke) -> bool {
let m = &keystroke.modifiers;
keystroke.key == "k" && m.secondary() && !m.alt && !m.shift && !(m.control && m.platform)
}
#[must_use]
#[derive(Clone)]
pub struct CommandItem {
key: SharedString,
label: SharedString,
group: Option<SharedString>,
description: Option<SharedString>,
icon: Option<SharedString>,
shortcut: Vec<SharedString>,
keywords: Vec<SharedString>,
is_disabled: bool,
}
impl CommandItem {
pub fn new(key: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
Self {
key: key.into(),
label: label.into(),
group: None,
description: None,
icon: None,
shortcut: Vec::new(),
keywords: Vec::new(),
is_disabled: false,
}
}
pub fn group(mut self, group: impl Into<SharedString>) -> Self {
self.group = Some(group.into());
self
}
pub fn description(mut self, description: impl Into<SharedString>) -> Self {
self.description = Some(description.into());
self
}
pub fn icon(mut self, icon: impl Into<SharedString>) -> Self {
self.icon = Some(icon.into());
self
}
pub fn shortcut(mut self, keys: impl IntoIterator<Item = impl Into<SharedString>>) -> Self {
self.shortcut = keys.into_iter().map(Into::into).collect();
self
}
pub fn keywords(mut self, words: impl IntoIterator<Item = impl Into<SharedString>>) -> Self {
self.keywords = words.into_iter().map(Into::into).collect();
self
}
pub fn is_disabled(mut self, disabled: bool) -> Self {
self.is_disabled = disabled;
self
}
pub fn key(&self) -> &SharedString {
&self.key
}
pub fn label(&self) -> &SharedString {
&self.label
}
fn search_text(&self) -> String {
let mut text = self.label.to_string();
for part in self.keywords.iter().chain(self.group.iter()) {
text.push('\n');
text.push_str(part);
}
text
}
}
fn default_matches(filter: &Filter, text: &str, query: &str) -> bool {
query
.split_whitespace()
.all(|word| text.split('\n').any(|field| filter.contains(field, word)))
}
#[must_use]
#[derive(IntoElement)]
pub struct CommandPalette {
id: ElementId,
items: Vec<CommandItem>,
is_open: Option<bool>,
default_open: bool,
placeholder: Option<SharedString>,
empty_text: Option<SharedString>,
label: SharedString,
width: Pixels,
max_list_height: Pixels,
close_on_select: bool,
filter: Option<CommandFilter>,
on_select: Option<SelectCallback>,
on_open_change: Option<OpenChange>,
sx: Option<Box<gpui::StyleRefinement>>,
}
impl CommandPalette {
pub fn new(id: impl Into<ElementId>, items: Vec<CommandItem>) -> Self {
Self {
id: id.into(),
items,
is_open: None,
default_open: false,
placeholder: None,
empty_text: None,
label: SharedString::new_static("Command palette"),
width: px(560.),
max_list_height: px(320.),
close_on_select: true,
filter: None,
on_select: None,
on_open_change: None,
sx: None,
}
}
pub fn is_open(mut self, open: bool) -> Self {
self.is_open = Some(open);
self
}
pub fn default_open(mut self, open: bool) -> Self {
self.default_open = open;
self
}
pub fn placeholder(mut self, text: impl Into<SharedString>) -> Self {
self.placeholder = Some(text.into());
self
}
pub fn empty_text(mut self, text: impl Into<SharedString>) -> Self {
self.empty_text = Some(text.into());
self
}
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
self.label = label.into();
self
}
pub fn width(mut self, width: impl Into<Pixels>) -> Self {
self.width = width.into();
self
}
pub fn max_list_height(mut self, height: impl Into<Pixels>) -> Self {
self.max_list_height = height.into();
self
}
pub fn close_on_select(mut self, close: bool) -> Self {
self.close_on_select = close;
self
}
pub fn filter(mut self, filter: impl Fn(&CommandItem, &str) -> bool + 'static) -> Self {
self.filter = Some(Rc::new(filter));
self
}
pub fn on_select(mut self, f: impl Fn(&SharedString, &mut Window, &mut App) + 'static) -> Self {
self.on_select = Some(Arc::new(f));
self
}
pub fn on_open_change(mut self, f: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
self.on_open_change = Some(Arc::new(f));
self
}
pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
util::refine_sx(&mut self.sx, style);
self
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Line {
Heading(usize),
Command(usize),
}
fn group_lines(items: &[CommandItem], matched: &[usize]) -> (Vec<Line>, Vec<SharedString>) {
let mut groups: Vec<Option<SharedString>> = Vec::new();
let mut members: Vec<Vec<usize>> = Vec::new();
for &ix in matched {
let group = items[ix].group.clone();
let at = match groups.iter().position(|g| *g == group) {
Some(at) => at,
None => {
groups.push(group);
members.push(Vec::new());
groups.len() - 1
}
};
members[at].push(ix);
}
let mut lines = Vec::new();
let mut headings = Vec::new();
for (group, rows) in groups.into_iter().zip(members) {
if let Some(name) = group {
lines.push(Line::Heading(headings.len()));
headings.push(name);
}
lines.extend(rows.into_iter().map(Line::Command));
}
(lines, headings)
}
#[derive(Clone)]
struct Opener {
own: Option<Entity<bool>>,
on_open_change: Option<OpenChange>,
}
impl Opener {
fn set(&self, open: bool, window: &mut Window, cx: &mut App) {
if let Some(own) = &self.own {
own.update(cx, |value, cx| {
*value = open;
cx.notify();
});
}
if let Some(cb) = &self.on_open_change {
cb(&open, window, cx);
}
}
}
impl RenderOnce for CommandPalette {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let base = self.id.clone();
let (open, own) = util::controlled(
window,
cx,
element_id::scoped(&base, "open"),
self.is_open,
self.default_open,
);
let (phase, token) =
util::overlay_scope(window, cx, element_id::scoped(&base, "phase"), open, true);
if phase == util::OverlayPhase::Closed {
crate::modal::release_dialog_focus(&base, window, cx);
return div().into_any_element();
}
let exiting = phase == util::OverlayPhase::Exiting;
let opener = Opener {
own,
on_open_change: self.on_open_change.clone(),
};
let query_state =
window.use_keyed_state(element_id::scoped(&base, "query"), cx, |_, cx| {
InputState::new(cx)
});
let cursor = window.use_keyed_state(element_id::scoped(&base, "cursor"), cx, |_, _| {
None::<SharedString>
});
let last_query =
window.use_keyed_state(element_id::scoped(&base, "last-query"), cx, |_, _| {
None::<String>
});
let scroll = window
.use_keyed_state(element_id::scoped(&base, "scroll"), cx, |_, _| {
ScrollHandle::new()
})
.read(cx)
.clone();
let cache = window.use_keyed_state(element_id::scoped(&base, "matches"), cx, |_, _| {
MatchesCache::default()
});
let input_focus = query_state.read(cx).focus_handle(cx);
crate::modal::claim_dialog_focus(&base, &input_focus, window, cx);
let query = query_state.read(cx).value().to_owned();
let items = Rc::new(self.items);
let matched: Vec<usize> = if query.trim().is_empty() {
(0..items.len()).collect()
} else if let Some(filter) = &self.filter {
(0..items.len())
.filter(|&ix| filter(&items[ix], &query))
.collect()
} else {
let index: HashMap<SharedString, usize> = items
.iter()
.enumerate()
.map(|(ix, item)| (item.key.clone(), ix))
.collect();
let pickers: Rc<[PickerItem]> = items
.iter()
.map(|item| PickerItem::new(item.key.clone(), item.search_text()))
.collect::<Vec<_>>()
.into();
let hits = cache.update(cx, |cache, _| {
cache.get(pickers, &query, usize::MAX, |all| {
let filter = Filter::new(Sensitivity::Base);
all.iter()
.filter(|p| default_matches(&filter, p.label(), &query))
.cloned()
.collect()
})
});
hits.iter()
.filter_map(|p| index.get(p.key()).copied())
.collect()
};
let (lines, headings) = group_lines(&items, &matched);
let stops: Vec<usize> = lines
.iter()
.enumerate()
.filter_map(|(at, line)| match line {
Line::Command(ix) if !items[*ix].is_disabled => Some(at),
_ => None,
})
.collect();
let query_changed = last_query.read(cx).as_deref() != Some(query.as_str());
if query_changed {
last_query.update(cx, |q, _| *q = Some(query.clone()));
}
let held_line = cursor.read(cx).as_ref().and_then(|key| {
lines.iter().position(|line| match line {
Line::Command(ix) => &items[*ix].key == key,
Line::Heading(_) => false,
})
});
let cursor_line = if query_changed {
None
} else {
held_line.filter(|at| stops.contains(at))
}
.or_else(|| stops.first().copied());
let cursor_key = cursor_line.and_then(|at| match lines[at] {
Line::Command(ix) => Some(items[ix].key.clone()),
Line::Heading(_) => None,
});
if *cursor.read(cx) != cursor_key {
cursor.update(cx, |c, _| *c = cursor_key.clone());
}
if query_changed {
if let Some(at) = cursor_line {
scroll.scroll_to_item(at);
}
}
let run = util::shared({
let opener = opener.clone();
let on_select = self.on_select.clone();
let items = items.clone();
let close_on_select = self.close_on_select;
move |ix: usize, window: &mut Window, cx: &mut App| {
let item = &items[ix];
if item.is_disabled {
return;
}
if close_on_select {
opener.set(false, window, cx);
}
if let Some(cb) = &on_select {
cb(&item.key, window, cx);
}
}
});
let dismiss = util::shared({
let opener = opener;
move |window: &mut Window, cx: &mut App| {
opener.set(false, window, cx);
util::DismissResult::Handled
}
});
let colors = cx.colors().clone();
let layout = cx.layout().clone();
let radius = util::container_radius(cx);
let row_radius = util::soft_radius(cx);
let pointer = util::interactive_cursor(cx);
let placeholder = self
.placeholder
.clone()
.unwrap_or_else(|| crate::i18n::ui_string(crate::i18n::UiString::Search, cx));
let empty_text = self
.empty_text
.clone()
.unwrap_or_else(|| crate::i18n::ui_string(crate::i18n::UiString::NoResults, cx));
let search = div()
.flex()
.items_center()
.gap(px(8.))
.px(px(12.))
.h(px(48.))
.border_b(layout.border_width)
.border_color(colors.separator)
.child(
gpui::svg()
.size(util::FIELD_ICON)
.flex_shrink_0()
.path(crate::icons::SEARCH)
.text_color(colors.muted),
)
.child(
div().flex_1().min_w_0().child(
Input::new(&query_state)
.placeholder(placeholder)
.is_bare(true)
.focus_ring(false)
.full_width(),
),
);
let mut list = div()
.id(element_id::scoped(&base, "list"))
.a11y_named(
a11y::Role::ListBox,
&a11y::Name::labelled(self.label.clone()),
)
.track_scroll(&scroll)
.overflow_y_scroll()
.max_h(self.max_list_height)
.flex()
.flex_col()
.p(px(6.))
.debug_selector({
let name = format!("{}-list", selector_base(&base));
move || name
});
if lines.is_empty() {
list = list.child(
div()
.flex()
.items_center()
.justify_center()
.h(px(64.))
.text_color(colors.muted)
.debug_selector({
let name = format!("{}-empty", selector_base(&base));
move || name
})
.child(empty_text),
);
}
for (at, line) in lines.iter().enumerate() {
match *line {
Line::Heading(h) => {
list = list.child(
div()
.flex()
.items_end()
.h(HEADING_HEIGHT)
.flex_shrink_0()
.px(px(8.))
.pb(px(4.))
.text_size(px(12.))
.line_height(px(16.))
.font_weight(gpui::FontWeight::MEDIUM)
.text_color(colors.muted)
.child(headings[h].clone()),
);
}
Line::Command(ix) => {
let item = &items[ix];
let highlighted = cursor_line == Some(at);
let mut row = div()
.id(element_id::scoped(
&element_id::scoped(&base, "item"),
item.key.clone(),
))
.a11y_named(
a11y::Role::ListBoxOption,
&a11y::Name::labelled(item.label.clone())
.described(item.description.clone()),
)
.a11y_selected(highlighted)
.when(highlighted, |el| el.a11y_active_descendant())
.flex()
.items_center()
.gap(px(10.))
.h(ROW_HEIGHT)
.flex_shrink_0()
.px(px(8.))
.rounded(row_radius)
.text_color(colors.foreground)
.when(highlighted, |el| el.bg(colors.default.color))
.debug_selector({
let name = format!("{}-item-{}", selector_base(&base), item.key);
move || name
});
if let Some(icon) = &item.icon {
row = row.child(
gpui::svg()
.size(util::FIELD_ICON)
.flex_shrink_0()
.path(icon.clone())
.text_color(colors.muted),
);
}
row = row.child(
div()
.flex()
.items_baseline()
.gap(px(8.))
.flex_1()
.min_w_0()
.child(div().truncate().child(item.label.clone()))
.when_some(item.description.clone(), |el, description| {
el.child(
div()
.flex_1()
.min_w_0()
.truncate()
.text_size(px(12.))
.text_color(colors.muted)
.child(description),
)
}),
);
if !item.shortcut.is_empty() {
row = row.child(
div().flex().gap(px(4.)).flex_shrink_0().children(
item.shortcut
.iter()
.map(|key| Kbd::new().child(key.clone())),
),
);
}
if item.is_disabled {
row = row.opacity(layout.disabled_opacity);
} else {
let hover_cursor = cursor.clone();
let key = item.key.clone();
let press = run.clone();
row = row
.cursor(pointer)
.on_hover(move |over, _, cx| {
if *over {
hover_cursor.update(cx, |c, cx| {
if c.as_ref() != Some(&key) {
*c = Some(key.clone());
cx.notify();
}
});
}
})
.on_click(move |_, window, cx| press(ix, window, cx));
}
list = list.child(row);
}
}
}
let keys_lines = Rc::new(lines);
let keys_cursor = cursor.clone();
let keys_scroll = scroll;
let keys_items = items;
let keys_run = run.clone();
let panel = div()
.id(element_id::scoped(&base, "dialog"))
.a11y_named(
a11y::Role::Dialog,
&a11y::Name::labelled(self.label.clone()),
)
.relative()
.flex()
.flex_col()
.w(self.width)
.max_w_full()
.bg(colors.overlay.background)
.text_color(colors.foreground)
.text_size(px(14.))
.line_height(px(20.))
.rounded(radius)
.overflow_hidden()
.when_some(layout.overlay_hairline, |el, hairline| {
el.border(layout.border_width).border_color(hairline)
})
.shadow(layout.overlay_shadow)
.debug_selector({
let name = format!("{}-panel", selector_base(&base));
move || name
})
.on_key_down(move |event, window, cx| {
let m = &event.keystroke.modifiers;
if m.control || m.alt || m.platform || m.shift || m.function {
return;
}
let key = event.keystroke.key.as_str();
let from = keys_cursor.read(cx).as_ref().and_then(|held| {
keys_lines.iter().position(|line| match line {
Line::Command(ix) => &keys_items[*ix].key == held,
Line::Heading(_) => false,
})
});
match key {
"up" | "down" => {
let target = match crate::list_nav::resolve(&stops, from, key, true) {
crate::list_nav::Move::To(to) => Some(to),
_ => None,
};
if let Some(to) = target {
if let Line::Command(ix) = keys_lines[to] {
let key = keys_items[ix].key.clone();
keys_cursor.update(cx, |c, cx| {
*c = Some(key);
cx.notify();
});
keys_scroll.scroll_to_item(to);
}
}
cx.stop_propagation();
}
"enter" => {
if let Some(Line::Command(ix)) = from.map(|at| keys_lines[at]) {
keys_run(ix, window, cx);
}
cx.stop_propagation();
}
_ => {}
}
})
.child(search)
.child(list);
let panel = util::apply_sx(panel, &self.sx);
let panel = if exiting {
panel
} else {
util::dismiss_on_press_outside_with_token(panel, token.clone(), {
let dismiss = dismiss.clone();
move |window, cx| dismiss(window, cx)
})
};
let focus_scope = window
.use_keyed_state(element_id::scoped(&base, "scope"), cx, |_, cx| {
cx.focus_handle()
})
.read(cx)
.clone();
let mut overlay = util::trap_tab(
div()
.id(element_id::scoped(&base, "overlay"))
.track_focus(&focus_scope),
&focus_scope,
)
.absolute()
.inset_0()
.flex()
.flex_col()
.items_center()
.pt(window.viewport_size().height * 0.15)
.px(px(16.));
overlay = util::dismiss_on_escape_with_token(overlay, token, move |window, cx| {
dismiss(window, cx)
});
let scrim = div()
.id(element_id::scoped(&base, "backdrop"))
.absolute()
.inset_0()
.bg(colors.backdrop);
overlay = overlay.child(if exiting {
anim::exiting(
scrim,
element_id::scoped(&base, "backdrop-out"),
anim::ZoomBox::default(),
anim::Motion::BACKDROP_OUT,
cx,
)
} else {
anim::entering(
scrim,
element_id::scoped(&base, "backdrop-in"),
anim::Motion::BACKDROP_IN,
cx,
)
});
let zoom = anim::ZoomBox {
width: Some(self.width),
radius: Some(radius),
..Default::default()
};
overlay = overlay.child(if exiting {
anim::exiting(
panel,
element_id::scoped(&base, "panel-out"),
zoom,
anim::Motion::PANEL_OUT,
cx,
)
} else {
anim::entering_zoom(
panel,
element_id::scoped(&base, "panel-in"),
zoom,
anim::Motion::PANEL_IN,
cx,
)
});
util::window_overlay(overlay, window).into_any_element()
}
}
fn selector_base(id: &ElementId) -> String {
match id {
ElementId::Name(name) => name.to_string(),
other => format!("{other:?}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn items() -> Vec<CommandItem> {
vec![
CommandItem::new("new", "New file").group("File"),
CommandItem::new("theme", "Toggle theme").keywords(["dark", "light"]),
CommandItem::new("open", "Open file").group("File"),
CommandItem::new("close", "Close window").group("Window"),
]
}
#[test]
fn groups_keep_first_appearance_order_and_ungrouped_rows_have_no_heading() {
let items = items();
let (lines, headings) = group_lines(&items, &[0, 1, 2, 3]);
assert_eq!(headings, ["File", "Window"]);
assert_eq!(
lines,
[
Line::Heading(0),
Line::Command(0),
Line::Command(2),
Line::Command(1),
Line::Heading(1),
Line::Command(3),
]
);
}
#[test]
fn every_query_word_must_match_a_field() {
let filter = Filter::new(Sensitivity::Base);
let theme = items()[1].search_text();
assert!(default_matches(&filter, &theme, "toggle DARK"));
assert!(!default_matches(&filter, &theme, "toggle window"));
let new = items()[0].search_text();
assert!(default_matches(&filter, &new, "file new"));
assert!(!default_matches(&filter, &theme, "themedark"));
}
#[test]
fn the_shortcut_is_secondary_k_alone() {
let parse = |s: &str| gpui::Keystroke::parse(s).unwrap();
assert!(is_command_palette_shortcut(&parse("secondary-k")));
assert!(!is_command_palette_shortcut(&parse("k")));
assert!(!is_command_palette_shortcut(&parse("secondary-shift-k")));
assert!(!is_command_palette_shortcut(&parse("secondary-j")));
}
}
crate::util::impl_component_styled!(CommandPalette);