mod item;
mod render;
pub use item::{PaletteItem, fuzzy_score};
use super::{Component, EventContext, RenderContext, Toggleable};
use crate::input::{Event, Key};
use crate::scroll::ScrollState;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CommandPaletteMessage {
SetQuery(String),
TypeChar(char),
Backspace,
ClearQuery,
SelectNext,
SelectPrev,
Confirm,
Dismiss,
Show,
SetItems(Vec<PaletteItem>),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CommandPaletteOutput {
Selected(PaletteItem),
Dismissed,
QueryChanged(String),
}
#[derive(Clone, Debug)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub struct CommandPaletteState {
items: Vec<PaletteItem>,
query: String,
filtered_indices: Vec<usize>,
selected: Option<usize>,
visible: bool,
max_visible: usize,
placeholder: String,
title: Option<String>,
#[cfg_attr(feature = "serialization", serde(skip))]
scroll: ScrollState,
}
impl PartialEq for CommandPaletteState {
fn eq(&self, other: &Self) -> bool {
self.items == other.items
&& self.query == other.query
&& self.filtered_indices == other.filtered_indices
&& self.selected == other.selected
&& self.visible == other.visible
&& self.max_visible == other.max_visible
&& self.placeholder == other.placeholder
&& self.title == other.title
}
}
impl Default for CommandPaletteState {
fn default() -> Self {
Self {
items: Vec::new(),
query: String::new(),
filtered_indices: Vec::new(),
selected: None,
visible: false,
max_visible: 10,
placeholder: "Type to search...".to_string(),
title: Some("Command Palette".to_string()),
scroll: ScrollState::default(),
}
}
}
impl CommandPaletteState {
pub fn new(items: Vec<PaletteItem>) -> Self {
let filtered_indices: Vec<usize> = (0..items.len()).collect();
let selected = if items.is_empty() { None } else { Some(0) };
let scroll = ScrollState::new(filtered_indices.len());
Self {
items,
filtered_indices,
selected,
scroll,
..Default::default()
}
}
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
pub fn with_placeholder(mut self, placeholder: impl Into<String>) -> Self {
self.placeholder = placeholder.into();
self
}
pub fn with_max_visible(mut self, max_visible: usize) -> Self {
self.max_visible = max_visible;
self
}
pub fn with_visible(mut self, visible: bool) -> Self {
self.visible = visible;
self
}
pub fn items(&self) -> &[PaletteItem] {
&self.items
}
pub fn query(&self) -> &str {
&self.query
}
pub fn filtered_items(&self) -> Vec<&PaletteItem> {
self.filtered_indices
.iter()
.filter_map(|&i| self.items.get(i))
.collect()
}
pub fn selected_item(&self) -> Option<&PaletteItem> {
self.selected
.and_then(|si| self.filtered_indices.get(si))
.and_then(|&i| self.items.get(i))
}
pub fn set_items(&mut self, items: Vec<PaletteItem>) {
self.items = items;
self.refilter();
}
pub fn show(&mut self) {
self.visible = true;
}
pub fn dismiss(&mut self) {
self.visible = false;
self.query.clear();
self.refilter();
}
pub fn title(&self) -> Option<&str> {
self.title.as_deref()
}
pub fn set_title(&mut self, title: impl Into<String>) {
self.title = Some(title.into());
}
pub fn placeholder(&self) -> &str {
&self.placeholder
}
pub fn set_placeholder(&mut self, placeholder: impl Into<String>) {
self.placeholder = placeholder.into();
}
pub fn max_visible(&self) -> usize {
self.max_visible
}
pub fn set_max_visible(&mut self, max_visible: usize) {
self.max_visible = max_visible;
}
pub fn filtered_count(&self) -> usize {
self.filtered_indices.len()
}
pub fn selected_index(&self) -> Option<usize> {
self.selected
}
pub fn is_visible(&self) -> bool {
self.visible
}
pub fn set_visible(&mut self, visible: bool) {
self.visible = visible;
}
pub fn update(&mut self, msg: CommandPaletteMessage) -> Option<CommandPaletteOutput> {
CommandPalette::update(self, msg)
}
fn refilter(&mut self) {
if self.query.is_empty() {
self.filtered_indices = (0..self.items.len()).collect();
} else {
let mut scored: Vec<(usize, usize)> = self
.items
.iter()
.enumerate()
.filter_map(|(i, item)| {
fuzzy_score(&self.query, &item.label).map(|score| (i, score))
})
.collect();
scored.sort_by_key(|b| std::cmp::Reverse(b.1));
self.filtered_indices = scored.into_iter().map(|(i, _)| i).collect();
}
self.scroll.set_content_length(self.filtered_indices.len());
if self.filtered_indices.is_empty() {
self.selected = None;
} else {
self.selected = Some(0);
}
}
}
pub struct CommandPalette;
impl Component for CommandPalette {
type State = CommandPaletteState;
type Message = CommandPaletteMessage;
type Output = CommandPaletteOutput;
fn init() -> Self::State {
CommandPaletteState::default()
}
fn handle_event(
state: &Self::State,
event: &Event,
ctx: &EventContext,
) -> Option<Self::Message> {
if !ctx.focused || ctx.disabled || !state.visible {
return None;
}
if let Some(key) = event.as_key() {
match key.code {
Key::Esc => Some(CommandPaletteMessage::Dismiss),
Key::Enter => Some(CommandPaletteMessage::Confirm),
Key::Backspace => Some(CommandPaletteMessage::Backspace),
Key::Up => Some(CommandPaletteMessage::SelectPrev),
Key::Down => Some(CommandPaletteMessage::SelectNext),
Key::Char('p') if key.modifiers.ctrl() => Some(CommandPaletteMessage::SelectPrev),
Key::Char('n') if key.modifiers.ctrl() => Some(CommandPaletteMessage::SelectNext),
Key::Char('u') if key.modifiers.ctrl() => Some(CommandPaletteMessage::ClearQuery),
Key::Char(_) if !key.modifiers.ctrl() && !key.modifiers.alt() => {
key.raw_char.map(CommandPaletteMessage::TypeChar)
}
_ => None,
}
} else {
None
}
}
fn update(state: &mut Self::State, msg: Self::Message) -> Option<Self::Output> {
match msg {
CommandPaletteMessage::SetQuery(text) => {
state.query = text.clone();
state.refilter();
Some(CommandPaletteOutput::QueryChanged(text))
}
CommandPaletteMessage::TypeChar(c) => {
state.query.push(c);
let text = state.query.clone();
state.refilter();
Some(CommandPaletteOutput::QueryChanged(text))
}
CommandPaletteMessage::Backspace => {
if !state.query.is_empty() {
state.query.pop();
let text = state.query.clone();
state.refilter();
Some(CommandPaletteOutput::QueryChanged(text))
} else {
None
}
}
CommandPaletteMessage::ClearQuery => {
if !state.query.is_empty() {
state.query.clear();
state.refilter();
Some(CommandPaletteOutput::QueryChanged(String::new()))
} else {
None
}
}
CommandPaletteMessage::SelectNext => {
if let Some(current) = state.selected {
let len = state.filtered_indices.len();
if len > 0 {
let new_index = (current + 1) % len;
state.selected = Some(new_index);
state.scroll.ensure_visible(new_index);
}
}
None
}
CommandPaletteMessage::SelectPrev => {
if let Some(current) = state.selected {
let len = state.filtered_indices.len();
if len > 0 {
let new_index = if current == 0 { len - 1 } else { current - 1 };
state.selected = Some(new_index);
state.scroll.ensure_visible(new_index);
}
}
None
}
CommandPaletteMessage::Confirm => {
let item = state
.selected
.and_then(|si| state.filtered_indices.get(si).copied())
.and_then(|i| state.items.get(i).cloned());
if let Some(item) = item {
state.visible = false;
state.query.clear();
state.refilter();
Some(CommandPaletteOutput::Selected(item))
} else {
None
}
}
CommandPaletteMessage::Dismiss => {
state.visible = false;
state.query.clear();
state.refilter();
Some(CommandPaletteOutput::Dismissed)
}
CommandPaletteMessage::Show => {
state.visible = true;
None
}
CommandPaletteMessage::SetItems(items) => {
state.items = items;
state.refilter();
None
}
}
}
fn view(state: &Self::State, ctx: &mut RenderContext<'_, '_>) {
render::render_command_palette(
state,
ctx.frame,
ctx.area,
ctx.theme,
ctx.focused,
ctx.disabled,
);
}
}
impl Toggleable for CommandPalette {
fn is_visible(state: &Self::State) -> bool {
state.visible
}
fn set_visible(state: &mut Self::State, visible: bool) {
state.visible = visible;
}
}
#[cfg(test)]
mod event_tests;
#[cfg(test)]
mod snapshot_tests;
#[cfg(test)]
mod tests;