use gpui::{
App, AppContext as _, Context, Entity, EventEmitter, FocusHandle, Focusable,
InteractiveElement, IntoElement, KeyDownEvent, MouseButton, ParentElement, Render,
SharedString, StatefulInteractiveElement, Styled, Subscription, Window, div,
prelude::FluentBuilder, px,
};
use gpui_kit_semantics::{NodeSpec, Role, Semantic};
use gpui_kit_theme::{ActiveTheme, Elevation, Space, TypeScale};
use crate::controls::input::{TextInput, TextInputEvent};
use crate::display::empty::{EmptyKind, EmptyState};
use crate::foundation::{Ident, Pressable, StyledExt};
use crate::motion;
use crate::overlay::kbd::Kbd;
use crate::overlay::layer::surface;
use crate::overlay::popover::{self, MenuKey};
use crate::strings::{ActiveStrings, StringKey};
const PALETTE_WIDTH: f32 = 480.0;
const RESULTS_MAX_HEIGHT: f32 = 320.0;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Command {
id: SharedString,
label: SharedString,
section: Option<SharedString>,
shortcut: Option<SharedString>,
unavailable: Option<SharedString>,
}
impl Command {
pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
label: label.into(),
section: None,
shortcut: None,
unavailable: None,
}
}
pub fn section(mut self, section: impl Into<SharedString>) -> Self {
self.section = Some(section.into());
self
}
pub fn shortcut(mut self, keystroke: impl Into<SharedString>) -> Self {
self.shortcut = Some(keystroke.into());
self
}
pub fn unavailable(mut self, reason: impl Into<SharedString>) -> Self {
self.unavailable = Some(reason.into());
self
}
pub fn id(&self) -> &SharedString {
&self.id
}
pub fn label(&self) -> &SharedString {
&self.label
}
pub fn reason(&self) -> Option<&SharedString> {
self.unavailable.as_ref()
}
pub fn is_available(&self) -> bool {
self.unavailable.is_none()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CommandPaletteEvent {
QueryChanged(SharedString),
Invoked(SharedString),
Dismissed,
}
impl EventEmitter<CommandPaletteEvent> for CommandPalette {}
fn order_matches(commands: &[Command], query: &str) -> Vec<usize> {
let ranked: Vec<(usize, usize)> = commands
.iter()
.enumerate()
.filter_map(|(index, command)| {
popover::match_rank(query, command.label.as_ref()).map(|rank| (rank, index))
})
.collect();
let section_of = |index: usize| commands[index].section.clone().unwrap_or_default();
let mut sections: Vec<(SharedString, usize, usize)> = Vec::new();
for &(rank, index) in &ranked {
let name = section_of(index);
match sections.iter_mut().find(|(known, _, _)| *known == name) {
Some(section) => section.1 = section.1.min(rank),
None => sections.push((name, rank, index)),
}
}
sections.sort_by_key(|(_, rank, first)| (*rank, *first));
let mut ordered = Vec::with_capacity(ranked.len());
for (name, _, _) in §ions {
let mut group: Vec<(usize, usize)> = ranked
.iter()
.copied()
.filter(|(_, index)| section_of(*index) == *name)
.collect();
group.sort_by_key(|&(rank, index)| (rank, index));
ordered.extend(group.into_iter().map(|(_, index)| index));
}
ordered
}
pub struct CommandPalette {
ident: Ident,
focus_handle: FocusHandle,
query: Entity<TextInput>,
commands: Vec<Command>,
active: Option<SharedString>,
_subscriptions: Vec<Subscription>,
}
impl std::fmt::Debug for CommandPalette {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("CommandPalette")
.field("ident", &self.ident)
.field("commands", &self.commands.len())
.field("active", &self.active)
.finish()
}
}
impl CommandPalette {
pub fn new(ident: impl Into<Ident>, window: &mut Window, cx: &mut Context<Self>) -> Self {
let ident = ident.into();
let query = cx.new(|cx| {
TextInput::new(ident.child("query"), window, cx)
.placeholder(cx.strings().text(StringKey::PalettePlaceholder))
});
let subscription = cx.subscribe(&query, |palette, _query, event, cx| match event {
TextInputEvent::Change(text) => {
palette.active = None;
cx.emit(CommandPaletteEvent::QueryChanged(text.clone()));
cx.notify();
}
TextInputEvent::Submit => palette.invoke(cx),
TextInputEvent::Cancel => cx.emit(CommandPaletteEvent::Dismissed),
_ => {}
});
Self {
ident,
focus_handle: cx.focus_handle(),
query,
commands: Vec::new(),
active: None,
_subscriptions: vec![subscription],
}
}
pub fn commands(mut self, commands: impl IntoIterator<Item = Command>) -> Self {
self.commands = commands.into_iter().collect();
self
}
pub fn set_commands(&mut self, commands: Vec<Command>, cx: &mut Context<Self>) {
self.commands = commands;
self.active = None;
cx.notify();
}
pub fn query(&self, cx: &App) -> SharedString {
self.query.read(cx).value().clone()
}
pub fn set_query(&mut self, query: impl Into<SharedString>, cx: &mut Context<Self>) {
self.query
.update(cx, |input, cx| input.set_value(query, cx));
}
pub fn query_input(&self) -> &Entity<TextInput> {
&self.query
}
pub fn focus_query(&self, window: &mut Window, cx: &mut App) {
self.query.read(cx).focus_handle(cx).focus(window, cx);
}
pub fn active_id(&self, cx: &App) -> Option<SharedString> {
let ordered = self.ordered(cx);
self.resolved(&ordered)
.map(|index| self.commands[index].id.clone())
}
fn ordered(&self, cx: &App) -> Vec<usize> {
order_matches(&self.commands, self.query.read(cx).value().as_ref())
}
fn resolved(&self, ordered: &[usize]) -> Option<usize> {
if let Some(active) = &self.active
&& let Some(index) = ordered
.iter()
.copied()
.find(|index| &self.commands[*index].id == active)
&& self.commands[index].is_available()
{
return Some(index);
}
ordered
.iter()
.copied()
.find(|index| self.commands[*index].is_available())
}
fn step(&mut self, delta: isize, cx: &mut Context<Self>) {
let ordered = self.ordered(cx);
let choosable: Vec<usize> = ordered
.iter()
.copied()
.filter(|index| self.commands[*index].is_available())
.collect();
if choosable.is_empty() {
return;
}
let current = self
.resolved(&ordered)
.and_then(|index| choosable.iter().position(|choice| *choice == index));
let Some(next) = popover::step(current, choosable.len(), delta) else {
return;
};
self.active = Some(self.commands[choosable[next]].id.clone());
cx.notify();
}
fn invoke(&mut self, cx: &mut Context<Self>) {
let ordered = self.ordered(cx);
let Some(index) = self.resolved(&ordered) else {
return;
};
cx.emit(CommandPaletteEvent::Invoked(
self.commands[index].id.clone(),
));
}
fn choose(&mut self, id: SharedString, cx: &mut Context<Self>) {
self.active = Some(id.clone());
cx.emit(CommandPaletteEvent::Invoked(id));
cx.notify();
}
fn on_key_down(&mut self, event: &KeyDownEvent, _window: &mut Window, cx: &mut Context<Self>) {
let key = popover::classify_key(
event.keystroke.key.as_str(),
event.keystroke.modifiers.platform,
event.keystroke.modifiers.control,
);
match key {
MenuKey::Down => {
self.step(1, cx);
cx.stop_propagation();
}
MenuKey::Up => {
self.step(-1, cx);
cx.stop_propagation();
}
_ => {}
}
}
fn results(&self, cx: &mut Context<Self>) -> Vec<gpui::AnyElement> {
let theme = cx.theme().clone();
let ordered = self.ordered(cx);
let highlighted = self.resolved(&ordered);
let results_id = self.ident.child("results").semantic_id();
let mut section: Option<SharedString> = None;
let mut rows: Vec<gpui::AnyElement> = Vec::with_capacity(ordered.len());
let count = ordered.len();
for (position, index) in ordered.into_iter().enumerate() {
let command = &self.commands[index];
if command.section != section {
section = command.section.clone();
if let Some(name) = section.clone() {
rows.push(
popover::heading(&theme, name.as_ref())
.semantic_in(
cx,
NodeSpec::new(
self.ident
.child("section")
.child(name.as_ref())
.semantic_id(),
Role::Heading,
)
.parent(results_id.clone())
.level(2)
.text(name),
)
.into_any_element(),
);
}
}
let row_ident = self.ident.child(command.id.as_ref());
let active = highlighted == Some(index);
let available = command.is_available();
let id = command.id.clone();
let mut spec = NodeSpec::new(row_ident.semantic_id(), Role::MenuItem)
.parent(results_id.clone())
.text(command.label.clone())
.disabled(!available)
.hovered(active);
if let Some(reason) = command.reason() {
spec = spec.value(reason.clone());
}
let row = popover::menu_row(&theme, false, active)
.id(row_ident.element_id())
.when(available, |element| element.cursor_pointer().pressable(cx))
.when(!available, |element| {
element.opacity(theme.opacity.disabled)
})
.child(div().flex_1().child(command.label.clone()))
.children(command.reason().map(|reason| {
div()
.type_scale(&theme, TypeScale::Caption)
.text_color(theme.colors.warning)
.child(reason.clone())
}))
.children(
command
.shortcut
.clone()
.map(|keystroke| Kbd::new(keystroke).into_any_element()),
)
.when(available, |element| {
element.on_mouse_down(
MouseButton::Left,
cx.listener(move |palette, _, _, cx| {
palette.choose(id.clone(), cx);
}),
)
})
.semantic_in(cx, spec);
rows.push(
motion::row_in(
row_ident.child("in").element_id(),
&theme,
position,
count,
row,
)
.into_any_element(),
);
}
rows
}
}
impl Focusable for CommandPalette {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for CommandPalette {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = cx.theme().clone();
let query = self.query.read(cx).value().clone();
let rows = self.results(cx);
let results_id = self.ident.child("results").semantic_id();
let body = if rows.is_empty() {
EmptyState::new(
self.ident.child("empty"),
cx.strings().format(StringKey::PaletteNoMatch, &[&query]),
)
.kind(EmptyKind::Empty)
.detail(cx.strings().text(StringKey::PaletteEmptyDetail))
.into_any_element()
} else {
div()
.id(self.ident.child("results").element_id())
.flex()
.flex_col()
.max_h(px(RESULTS_MAX_HEIGHT))
.overflow_y_scroll()
.children(rows)
.semantic_in(cx, NodeSpec::new(results_id, Role::Menu))
.into_any_element()
};
surface(&theme, Elevation::Modal)
.w(px(PALETTE_WIDTH))
.p_token(&theme, Space::Xs)
.gap_token(&theme, Space::Xs)
.track_focus(&self.focus_handle)
.on_key_down(cx.listener(Self::on_key_down))
.child(div().p_token(&theme, Space::Xs).child(self.query.clone()))
.child(body)
.semantic_in(
cx,
NodeSpec::new(self.ident.semantic_id(), Role::Group).value(query),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn commands() -> Vec<Command> {
vec![
Command::new("editor.split", "Split editor").section("Editor"),
Command::new("workspace.save", "Save workspace").section("Workspace"),
Command::new("editor.save", "Save file")
.section("Editor")
.shortcut("cmd-s"),
Command::new("workspace.publish", "Publish workspace")
.section("Workspace")
.unavailable("Approval is required"),
]
}
#[test]
fn an_empty_query_lists_everything_grouped_by_section() {
assert_eq!(order_matches(&commands(), ""), vec![0, 2, 1, 3]);
}
#[test]
fn a_section_sits_where_its_best_match_does() {
assert_eq!(order_matches(&commands(), "save"), vec![1, 2]);
}
#[test]
fn a_command_the_host_refused_is_still_listed() {
let ordered = order_matches(&commands(), "publish");
assert_eq!(ordered, vec![3]);
assert!(!commands()[3].is_available());
}
#[test]
fn a_query_nothing_answers_orders_nothing() {
assert!(order_matches(&commands(), "zzz").is_empty());
}
}