use crate::core::{Color, HorizontalAlignment, Point, Rect};
use crate::event::{Event, EventHandler};
use crate::render::RenderContext;
use crate::signal::Signal1;
use crate::widget::capability::properties_trait::{base_property_get, base_property_set};
use crate::widget::capability::types::{CapabilityAccessError, CapabilityValue};
use crate::widget::capability::WidgetProperties;
use crate::widget::metrics::effective_font;
use crate::widget::{BaseWidget, Draw, Widget, WidgetKind};
use crate::{impl_widget_property_hooks, property_names_of};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommandEntry {
pub id: String,
pub title: String,
pub category: String,
pub keywords: Vec<String>,
pub shortcut: Option<String>,
}
impl CommandEntry {
pub fn new(id: impl Into<String>, title: impl Into<String>) -> Self {
Self {
id: id.into(),
title: title.into(),
category: String::new(),
keywords: Vec::new(),
shortcut: None,
}
}
pub fn with_category(mut self, category: impl Into<String>) -> Self {
self.category = category.into();
self
}
pub fn with_keywords(mut self, keywords: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.keywords = keywords.into_iter().map(Into::into).collect();
self
}
pub fn with_shortcut(mut self, shortcut: impl Into<String>) -> Self {
self.shortcut = Some(shortcut.into());
self
}
}
pub struct CommandPalette {
base: BaseWidget,
entries: Vec<CommandEntry>,
query: String,
filtered_indices: Vec<usize>,
highlighted_index: Option<usize>,
scroll_offset: usize,
mru: Vec<String>,
row_height: u32,
group_by_category: bool,
slots: Vec<PaletteSlot>,
rows: Vec<RowPresentation>,
empty_text: String,
show_hints: bool,
hint_text: String,
pub command_activated: Signal1<String>,
pub query_changed: Signal1<String>,
}
impl CommandPalette {
pub fn new(geometry: Rect) -> Self {
Self {
base: BaseWidget::new(WidgetKind::ListView, geometry, "CommandPalette"),
entries: Vec::new(),
query: String::new(),
filtered_indices: Vec::new(),
highlighted_index: None,
scroll_offset: 0,
mru: Vec::new(),
row_height: 24,
group_by_category: false,
slots: Vec::new(),
rows: Vec::new(),
empty_text: String::from("No matching commands"),
show_hints: true,
hint_text: String::new(),
command_activated: Signal1::new(),
query_changed: Signal1::new(),
}
}
pub fn set_entries(&mut self, entries: Vec<CommandEntry>) {
self.entries = entries;
self.refresh_filter();
self.base.request_layout();
self.base.request_redraw();
}
pub fn entries(&self) -> &[CommandEntry] {
&self.entries
}
pub fn set_query(&mut self, query: impl Into<String>) {
let next = query.into();
if self.query == next {
return;
}
self.query = next;
self.refresh_filter();
self.query_changed.emit(self.query.clone());
self.base.request_redraw();
}
pub fn clear_query(&mut self) {
self.set_query(String::new());
}
pub fn query(&self) -> &str {
&self.query
}
pub fn filtered_count(&self) -> usize {
self.filtered_indices.len()
}
pub fn highlighted_index(&self) -> Option<usize> {
self.highlighted_index.filter(|idx| *idx < self.filtered_indices.len())
}
pub fn set_row_height(&mut self, row_height: u32) {
let row_height = row_height.max(1);
if self.row_height == row_height {
return;
}
self.row_height = row_height;
self.base.request_layout();
self.base.request_redraw();
}
pub fn row_height(&self) -> u32 {
self.row_height
}
pub fn group_by_category(&self) -> bool {
self.group_by_category
}
pub fn set_group_by_category(&mut self, grouped: bool) {
if self.group_by_category == grouped {
return;
}
self.group_by_category = grouped;
self.refresh_filter();
self.base.request_layout();
self.base.request_redraw();
}
pub fn filtered_entry(&self, filtered_index: usize) -> Option<&CommandEntry> {
let entry_index = *self.filtered_indices.get(filtered_index)?;
self.entries.get(entry_index)
}
pub fn move_highlight(&mut self, delta: isize) {
if self.filtered_indices.is_empty() {
self.highlighted_index = None;
return;
}
let current = self.highlighted_index.unwrap_or(0) as isize;
let max = self.filtered_indices.len().saturating_sub(1) as isize;
let next = (current + delta).clamp(0, max) as usize;
self.highlighted_index = Some(next);
let visible = self.visible_rows().max(1);
self.scroll_offset = self.scroll_offset.min(next);
if next >= self.scroll_offset + visible {
self.scroll_offset = next + 1 - visible;
}
self.rebuild_slots();
self.base.request_redraw();
}
fn visible_rows(&self) -> usize {
let rect = self.geometry();
(rect.height.saturating_sub(self.header_height()).saturating_sub(self.footer_height())
/ self.row_height.max(1)) as usize
}
fn header_height(&self) -> u32 {
self.row_height
}
fn footer_height(&self) -> u32 {
if self.show_hints {
self.row_height.clamp(1, 18)
} else {
0
}
}
pub fn empty_text(&self) -> &str {
&self.empty_text
}
pub fn set_empty_text(&mut self, text: impl Into<String>) {
let text = text.into();
let text = if text.is_empty() { String::from("No matching commands") } else { text };
if self.empty_text == text {
return;
}
self.empty_text = text;
self.base.request_redraw();
}
pub fn show_hints(&self) -> bool {
self.show_hints
}
pub fn set_show_hints(&mut self, show: bool) {
if self.show_hints == show {
return;
}
self.show_hints = show;
self.move_highlight(0);
self.base.request_layout();
self.base.request_redraw();
}
pub fn hint_text(&self) -> &str {
&self.hint_text
}
pub fn set_hint_text(&mut self, text: impl Into<String>) {
let text = text.into();
if self.hint_text == text {
return;
}
self.hint_text = text;
self.base.request_redraw();
}
pub fn scroll_offset(&self) -> usize {
self.scroll_offset
}
pub fn record_use(&mut self, id: impl Into<String>) {
let id = id.into();
self.mru.retain(|existing| existing != &id);
self.mru.insert(0, id);
self.refresh_filter();
self.base.request_redraw();
}
pub fn set_mru(&mut self, ids: Vec<String>) {
if self.mru == ids {
return;
}
self.mru = ids;
self.refresh_filter();
self.base.request_redraw();
}
pub fn mru(&self) -> &[String] {
&self.mru
}
pub fn push_text(&mut self, text: &str) {
if text.is_empty() {
return;
}
self.query.push_str(text);
self.refresh_filter();
self.query_changed.emit(self.query.clone());
self.base.request_redraw();
}
pub fn activate_highlighted(&mut self) -> bool {
let Some(filtered_index) = self.highlighted_index() else {
return false;
};
let Some(entry) = self.filtered_entry(filtered_index) else {
return false;
};
let id = entry.id.clone();
self.record_use(id.clone());
self.command_activated.emit(id);
true
}
fn refresh_filter(&mut self) {
let query = self.query.to_lowercase();
let mut scored = self.score_all(&query, false);
if scored.is_empty() && !query.is_empty() {
scored = self.score_all(&query, true);
}
let rank = |index: usize| {
self.entries
.get(index)
.and_then(|entry| self.mru.iter().position(|id| id == &entry.id))
.unwrap_or(usize::MAX)
};
scored.sort_by(|a, b| {
b.1.cmp(&a.1).then_with(|| rank(a.0).cmp(&rank(b.0))).then_with(|| a.0.cmp(&b.0))
});
self.filtered_indices = scored.into_iter().map(|(index, _)| index).collect();
if self.group_by_category {
self.filtered_indices.sort_by(|a, b| {
let category = |index: usize| {
self.entries.get(index).map(|entry| entry.category.as_str()).unwrap_or("")
};
category(*a).cmp(category(*b))
});
}
self.scroll_offset = 0;
if self.filtered_indices.is_empty() {
self.highlighted_index = None;
} else {
self.highlighted_index = Some(0);
}
self.rebuild_rows();
self.rebuild_slots();
}
fn rebuild_rows(&mut self) {
let folded_query = self.query.to_lowercase();
self.rows.clear();
self.rows.reserve(self.filtered_indices.len());
for filtered_index in 0..self.filtered_indices.len() {
let Some(entry) = self.filtered_entry(filtered_index) else {
self.rows.push(RowPresentation::default());
continue;
};
let text = if entry.category.is_empty() {
entry.title.clone()
} else {
format!("{} · {}", entry.category, entry.title)
};
let highlight = if folded_query.is_empty() {
None
} else {
let folded = text.to_lowercase();
folded
.find(&folded_query)
.map(|offset| (text[..offset].chars().count(), self.query.chars().count()))
};
self.rows.push(RowPresentation { text, highlight });
}
}
fn rebuild_slots(&mut self) {
self.slots.clear();
let mut last_category: Option<&str> = None;
let mut pending: Vec<PaletteSlot> = Vec::with_capacity(self.filtered_indices.len());
for filtered_index in self.scroll_offset..self.filtered_indices.len() {
let category = self
.filtered_entry(filtered_index)
.map(|entry| entry.category.as_str())
.unwrap_or("");
if self.group_by_category && !category.is_empty() && last_category != Some(category) {
pending.push(PaletteSlot { header: true, filtered_index });
last_category = Some(category);
}
pending.push(PaletteSlot { header: false, filtered_index });
}
self.slots = pending;
}
fn score_all(&self, query: &str, allow_fuzzy: bool) -> Vec<(usize, i32)> {
self.entries
.iter()
.enumerate()
.filter_map(|(index, entry)| {
Self::score_entry_with(entry, query, allow_fuzzy).map(|score| (index, score))
})
.collect()
}
fn score_entry_with(entry: &CommandEntry, query: &str, allow_fuzzy: bool) -> Option<i32> {
if query.is_empty() {
return Some(1);
}
use crate::widget::text_utils::{
contains_ignore_ascii_case, starts_with_ignore_ascii_case,
};
if starts_with_ignore_ascii_case(&entry.title, query) {
return Some(120);
}
if contains_ignore_ascii_case(&entry.title, query) {
return Some(100);
}
if contains_ignore_ascii_case(&entry.id, query) {
return Some(80);
}
if contains_ignore_ascii_case(&entry.category, query) {
return Some(60);
}
if entry.keywords.iter().any(|keyword| contains_ignore_ascii_case(keyword, query)) {
return Some(50);
}
if !allow_fuzzy {
return None;
}
let title = subsequence_score(&entry.title, query);
let keywords =
entry.keywords.iter().filter_map(|keyword| subsequence_score(keyword, query)).max();
match (title, keywords) {
(None, None) => None,
(title, keywords) => Some(title.unwrap_or(0).max(keywords.unwrap_or(0)).min(40)),
}
}
fn append_query_char(&mut self, key: u32) {
if let Some(ch) = char::from_u32(key) {
if ch.is_ascii_graphic() || ch == ' ' {
self.query.push(ch);
self.refresh_filter();
self.query_changed.emit(self.query.clone());
self.base.request_redraw();
}
}
}
fn backspace_query(&mut self) {
if self.query.pop().is_some() {
self.refresh_filter();
self.query_changed.emit(self.query.clone());
self.base.request_redraw();
}
}
fn row_at(&self, pos: Point) -> Option<usize> {
let rect = self.geometry();
if pos.y < rect.y || pos.y >= rect.y + rect.height as i32 {
return None;
}
let body_top = rect.y + self.header_height() as i32;
if pos.y < body_top {
return None;
}
let row = ((pos.y - body_top) / self.row_height.max(1) as i32) as usize;
let slot = *self.display_slots().get(row)?;
if slot.header {
return None;
}
Some(slot.filtered_index)
}
fn pick_at_position(&mut self, pos: Point) {
if let Some(filtered_index) = self.row_at(pos) {
self.highlighted_index = Some(filtered_index);
self.base.request_redraw();
}
}
fn display_slots(&self) -> &[PaletteSlot] {
&self.slots
}
}
#[derive(Clone, Copy)]
struct PaletteSlot {
header: bool,
filtered_index: usize,
}
#[derive(Clone, Default)]
struct RowPresentation {
text: String,
highlight: Option<(usize, usize)>,
}
fn subsequence_score(text: &str, query: &str) -> Option<i32> {
if query.is_empty() {
return None;
}
let mut matched = 0usize;
let mut gaps = 0usize;
let mut last_hit: Option<usize> = None;
let mut first_hit: Option<usize> = None;
let mut query_chars = query.chars().map(|ch| ch.to_ascii_lowercase());
let mut wanted = query_chars.next()?;
for (index, ch) in text.chars().enumerate() {
if ch.to_ascii_lowercase() != wanted {
continue;
}
if let Some(previous) = last_hit {
gaps += index.saturating_sub(previous + 1);
} else {
first_hit = Some(index);
}
last_hit = Some(index);
matched += 1;
match query_chars.next() {
Some(next) => wanted = next,
None => break,
}
}
if matched != query.chars().count() {
return None;
}
let lead = first_hit.unwrap_or(0).min(20) as i32;
Some((40 - lead - gaps.min(20) as i32).max(1))
}
impl Widget for CommandPalette {
fn base(&self) -> &BaseWidget {
&self.base
}
fn set_state_theme_hook(&mut self) {
crate::style::reapply_active_theme_state(self);
}
fn base_mut(&mut self) -> &mut BaseWidget {
&mut self.base
}
fn size_hint(&self) -> crate::core::Size {
crate::core::Size::new(400, 300 + self.footer_height())
}
impl_draw_bridge!();
impl_widget_property_hooks!();
fn event_signal_dyn(&self, name: &str) -> Option<crate::signal::EventSignalRef> {
use crate::signal::EventSignalRef;
#[allow(unused_imports)]
use crate::widget::capability::CapabilityValue;
match name {
"command_activated" => {
Some(EventSignalRef::mapped("command_activated", &self.command_activated, |v| {
CapabilityValue::String(v.clone())
}))
}
"query_changed" => {
Some(EventSignalRef::mapped("query_changed", &self.query_changed, |v| {
CapabilityValue::String(v.clone())
}))
}
_ => None,
}
}
}
impl WidgetProperties for CommandPalette {
fn get(&self, name: &str) -> Result<CapabilityValue, CapabilityAccessError> {
match name {
"query" => Ok(CapabilityValue::String(self.query().to_string())),
"entry_count" => Ok(CapabilityValue::UInt(self.entries().len() as u64)),
"filtered_count" => Ok(CapabilityValue::UInt(self.filtered_count() as u64)),
"highlighted_index" => match self.highlighted_index() {
Some(index) => Ok(CapabilityValue::UInt(index as u64)),
None => Ok(CapabilityValue::Null),
},
"row_height" => Ok(CapabilityValue::UInt(self.row_height() as u64)),
"group_by_category" => Ok(CapabilityValue::Bool(self.group_by_category())),
_ => base_property_get(self, name),
}
}
fn set(&mut self, name: &str, value: CapabilityValue) -> Result<(), CapabilityAccessError> {
match name {
"query" => match value {
CapabilityValue::String(query) => {
self.set_query(query);
Ok(())
}
_ => Err(CapabilityAccessError::TypeMismatch),
},
"row_height" => match value {
CapabilityValue::UInt(height) => {
let height =
u32::try_from(height).map_err(|_| CapabilityAccessError::TypeMismatch)?;
self.set_row_height(height);
Ok(())
}
_ => Err(CapabilityAccessError::TypeMismatch),
},
"group_by_category" => match value {
CapabilityValue::Bool(grouped) => {
self.set_group_by_category(grouped);
Ok(())
}
_ => Err(CapabilityAccessError::TypeMismatch),
},
"entry_count" | "filtered_count" | "highlighted_index" => {
Err(CapabilityAccessError::ReadOnlyProperty)
}
_ => base_property_set(self, name, value),
}
}
fn property_names(&self) -> &'static [&'static str] {
property_names_of![
"query",
"entry_count",
"filtered_count",
"highlighted_index",
"row_height",
"group_by_category",
BASE_PROPERTY_NAMES
]
}
fn command(&mut self, name: &str) -> Result<(), CapabilityAccessError> {
match name {
"clear_query" => {
self.clear_query();
Ok(())
}
"activate_highlighted" => {
if self.activate_highlighted() {
Ok(())
} else {
Err(CapabilityAccessError::OutOfRange)
}
}
"move_highlight" => Err(CapabilityAccessError::OutOfRange),
_ if name.starts_with("set_") => Err(CapabilityAccessError::OutOfRange),
_ => Err(CapabilityAccessError::UnknownCommand),
}
}
}
impl EventHandler for CommandPalette {
fn handle_event(&mut self, event: &Event) {
self.base.handle_event(event);
if !self.base.is_enabled() {
return;
}
match event {
Event::MousePress { pos, button: 1, .. } => {
self.pick_at_position(*pos);
}
Event::MouseDoubleClick { pos, button: 1 } => {
self.pick_at_position(*pos);
let _ = self.activate_highlighted();
}
Event::KeyPress { key, modifiers } => {
let modifiers = crate::shortcut::Modifiers::from_event_bits(*modifiers);
let key_value = crate::shortcut::Key::from_key_code(*key);
match key_value {
Some(crate::shortcut::Key::Up) => self.move_highlight(-1),
Some(crate::shortcut::Key::Down) => self.move_highlight(1),
Some(crate::shortcut::Key::Enter) => {
let _ = self.activate_highlighted();
}
Some(crate::shortcut::Key::Escape) => self.clear_query(),
Some(crate::shortcut::Key::Backspace) => self.backspace_query(),
Some(crate::shortcut::Key::N)
if modifiers.contains(crate::shortcut::Modifiers::PRIMARY) =>
{
self.move_highlight(1);
}
Some(crate::shortcut::Key::P)
if modifiers.contains(crate::shortcut::Modifiers::PRIMARY) =>
{
self.move_highlight(-1);
}
_ => self.append_query_char(*key),
}
}
Event::TextInput { text } | Event::ImeCommit { text } => self.push_text(text),
Event::ImePreedit { .. } => {}
Event::Wheel { delta, .. } if delta.y != 0 => {
let max = self.filtered_indices.len().saturating_sub(1);
let next = if delta.y > 0 {
(self.scroll_offset + 1).min(max)
} else {
self.scroll_offset.saturating_sub(1)
};
if self.scroll_offset != next {
self.scroll_offset = next;
self.rebuild_slots();
self.base.request_redraw();
}
}
_ => { }
}
}
}
impl Draw for CommandPalette {
fn draw(&mut self, context: &mut RenderContext) {
let rect = self.geometry();
let style = self.base.style();
let theme = crate::style::resolved_theme_style_shared("command_palette");
let background = style
.background_color
.or_else(|| theme.as_ref().and_then(|t| t.background_color))
.unwrap_or(Color::WHITE);
let border = style
.border_color
.or_else(|| theme.as_ref().and_then(|t| t.border_color))
.unwrap_or_else(|| crate::core::Color::border_of(background));
let text_color = style
.text_color
.or_else(|| theme.as_ref().and_then(|t| t.text_color))
.unwrap_or(Color::BLACK);
let header_background = background.blend(&text_color, 0.06);
let highlight = background.blend(&text_color, 0.18);
context.face_with_gradient(
rect,
background,
self.style().background_gradient.as_ref(),
self.style().surface.unwrap_or_default(),
self.style().border_radius.unwrap_or(0),
Color::BLACK,
);
crate::widget::draw::stroke_surface_border(
context,
rect,
self.style().border_radius.unwrap_or(0),
border,
1,
);
let header = Rect::new(rect.x, rect.y, rect.width, self.row_height);
context.fill_rect(header, header_background);
let query_font = effective_font(style);
let query_h = context.measure_text("M", query_font).height;
context.draw_text_fitted_cow(
Rect::new(
header.x + 8,
header.y + (header.height as i32 - query_h as i32) / 2,
header.width.saturating_sub(16),
query_h,
),
&format!("> {}", self.query),
query_font,
text_color,
HorizontalAlignment::Left,
);
let mut y = rect.y + self.row_height as i32;
let footer_height = self.footer_height();
let limit = rect.y + rect.height as i32 - footer_height as i32;
if self.filtered_indices.is_empty() {
context.draw_text_fitted_cow(
Rect::new(rect.x + 8, y, rect.width.saturating_sub(16), self.row_height),
&self.empty_text,
effective_font(style),
text_color.blend(&background, 0.35),
HorizontalAlignment::Left,
);
}
let slots = self.display_slots();
for slot in slots.iter().copied() {
if y + self.row_height as i32 > limit {
break;
}
let filtered_index = slot.filtered_index;
if slot.header {
if let Some(entry) = self.filtered_entry(filtered_index) {
let band =
Rect::new(rect.x + 8, y, rect.width.saturating_sub(16), self.row_height);
context.draw_text_fitted_cow(
context.text_line(band, effective_font(style)),
&entry.category,
effective_font(style),
text_color.blend(&background, 0.35),
HorizontalAlignment::Left,
);
}
y += self.row_height as i32;
continue;
}
if self.highlighted_index == Some(filtered_index) {
context.fill_rect(
Rect::new(rect.x + 1, y, rect.width.saturating_sub(2), self.row_height),
highlight,
);
}
if let Some(entry) = self.filtered_entry(filtered_index) {
let trailing = if entry.shortcut.is_some() { 64 } else { 0 };
let band = Rect::new(
rect.x + 8,
y,
rect.width.saturating_sub(16 + trailing),
self.row_height,
);
let font = effective_font(style);
let line_box = context.text_line(band, font);
let presentation = self.rows.get(filtered_index).cloned().unwrap_or_default();
context.draw_text_fitted_cow(
line_box,
&presentation.text,
font,
text_color,
HorizontalAlignment::Left,
);
if let Some((offset, count)) = presentation.highlight {
let start_x = band.x
+ context
.measure_text(
&presentation.text.chars().take(offset).collect::<String>(),
font,
)
.width as i32;
let width = context
.measure_text(
&presentation.text.chars().skip(offset).take(count).collect::<String>(),
font,
)
.width;
context
.fill_rect(Rect::new(start_x, y, width.max(1), self.row_height), highlight);
}
if let Some(shortcut) = entry.shortcut.as_deref() {
let key_box = Rect::new(
rect.x + rect.width as i32 - 60,
y + 2,
52,
self.row_height.saturating_sub(4),
);
context.draw_text_line_cow(
key_box,
shortcut,
font,
text_color,
HorizontalAlignment::Right,
);
}
}
y += self.row_height as i32;
}
if footer_height > 0 {
let footer = Rect::new(
rect.x,
rect.y + rect.height as i32 - footer_height as i32,
rect.width,
footer_height,
);
context.fill_rect(footer, header_background);
context.draw_line(
Point::new(footer.x, footer.y),
Point::new(footer.x + footer.width as i32, footer.y),
border,
);
let hints = if self.hint_text.is_empty() {
"↑↓ select · ↵ run · esc clear"
} else {
self.hint_text.as_str()
};
let font = effective_font(style);
context.draw_text_fitted_cow(
context.text_line(
Rect::new(
footer.x + 8,
footer.y,
footer.width.saturating_sub(16),
footer.height,
),
font,
),
hints,
font,
text_color.blend(&background, 0.3),
HorizontalAlignment::Left,
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Arc, Mutex};
fn sample_entries() -> Vec<CommandEntry> {
vec![
CommandEntry::new("file.open", "Open File")
.with_category("File")
.with_keywords(["open", "load"]),
CommandEntry::new("file.save", "Save File")
.with_category("File")
.with_keywords(["save", "persist"]),
CommandEntry::new("view.toggle_sidebar", "Toggle Sidebar")
.with_category("View")
.with_keywords(["sidebar", "panel"]),
]
}
#[test]
fn query_filters_and_ranks_entries() {
let mut palette = CommandPalette::new(Rect::new(0, 0, 320, 160));
palette.set_entries(sample_entries());
assert_eq!(palette.filtered_count(), 3);
palette.set_query("save");
assert_eq!(palette.filtered_count(), 1);
assert_eq!(palette.filtered_entry(0).map(|entry| entry.id.as_str()), Some("file.save"));
palette.set_query("file");
assert_eq!(palette.filtered_count(), 2);
assert_eq!(palette.filtered_entry(0).map(|entry| entry.id.as_str()), Some("file.open"));
}
#[test]
fn keyboard_navigation_and_activation_work() {
let mut palette = CommandPalette::new(Rect::new(0, 0, 320, 160));
palette.set_entries(sample_entries());
let activated = Arc::new(Mutex::new(Vec::<String>::new()));
let sink = activated.clone();
palette.command_activated.connect(move |id| {
if let Ok(mut guard) = sink.lock() {
guard.push(id.as_ref().clone());
}
});
palette.handle_event(&Event::key_press(40, 0));
assert_eq!(palette.highlighted_index(), Some(1));
palette.handle_event(&Event::key_press(13, 0));
let got = activated.lock().ok().map(|guard| guard.clone()).unwrap_or_default();
assert_eq!(got, vec!["file.save".to_string()]);
}
#[test]
fn typing_and_backspace_drive_query() {
let mut palette = CommandPalette::new(Rect::new(0, 0, 320, 160));
palette.set_entries(sample_entries());
palette.handle_event(&Event::key_press('s' as u32, 0));
palette.handle_event(&Event::key_press('a' as u32, 0));
assert_eq!(palette.query(), "sa");
assert_eq!(palette.filtered_count(), 1);
palette.handle_event(&Event::key_press(8, 0));
assert_eq!(palette.query(), "s");
assert_eq!(palette.filtered_count(), 2);
palette.handle_event(&Event::key_press(27, 0));
assert_eq!(palette.query(), "");
assert_eq!(palette.filtered_count(), 3);
}
#[test]
fn default_state() {
let palette = CommandPalette::new(Rect::new(0, 0, 800, 600));
assert_eq!(palette.query(), "");
assert_eq!(palette.filtered_count(), 0);
assert_eq!(palette.highlighted_index(), None);
assert!(palette.entries().is_empty());
}
#[test]
fn empty_entries_handling() {
let mut palette = CommandPalette::new(Rect::new(0, 0, 800, 600));
palette.set_entries(Vec::new());
assert_eq!(palette.filtered_count(), 0);
assert_eq!(palette.highlighted_index(), None);
assert!(!palette.activate_highlighted());
palette.move_highlight(1);
assert_eq!(palette.highlighted_index(), None);
}
#[test]
fn set_query_programmatically() {
let mut palette = CommandPalette::new(Rect::new(0, 0, 800, 600));
palette.set_entries(vec![
CommandEntry::new("a", "Alpha"),
CommandEntry::new("b", "Beta"),
CommandEntry::new("g", "Gamma"),
]);
palette.set_query("beta");
assert_eq!(palette.query(), "beta");
assert_eq!(palette.filtered_count(), 1);
assert_eq!(palette.filtered_entry(0).map(|e| e.id.as_str()), Some("b"));
palette.clear_query();
assert_eq!(palette.query(), "");
assert_eq!(palette.filtered_count(), 3);
}
#[test]
fn case_insensitive_matching() {
let mut palette = CommandPalette::new(Rect::new(0, 0, 800, 600));
palette.set_entries(vec![
CommandEntry::new("openFile", "Open File"),
CommandEntry::new("openFolder", "Open Folder"),
]);
palette.set_query("open");
assert_eq!(palette.filtered_count(), 2);
assert_eq!(palette.filtered_entry(0).map(|e| e.id.as_str()), Some("openFile"));
palette.set_query("OPEN");
assert_eq!(palette.filtered_count(), 2);
palette.set_query("CLOSE");
assert_eq!(palette.filtered_count(), 0);
}
#[test]
fn a_click_lands_on_the_row_it_is_painted_on() {
let mut palette = CommandPalette::new(Rect::new(0, 0, 320, 120));
palette.set_entries(
(0..6)
.map(|index| CommandEntry::new(format!("cmd{index}"), format!("Cmd {index}")))
.collect(),
);
assert_eq!(palette.row_height(), 24);
palette.handle_event(&Event::mouse_press(10, 10, 1));
assert_eq!(palette.highlighted_index(), Some(0), "a header press leaves the highlight");
palette.handle_event(&Event::mouse_press(10, 30, 1));
assert_eq!(palette.highlighted_index(), Some(0));
palette.handle_event(&Event::wheel(0, 1, 0));
palette.handle_event(&Event::wheel(0, 1, 0));
assert_eq!(palette.scroll_offset(), 2);
palette.handle_event(&Event::mouse_press(10, 30, 1));
assert_eq!(palette.highlighted_index(), Some(2));
}
#[test]
fn activating_through_the_keyboard_records_the_command_as_used() {
let mut palette = CommandPalette::new(Rect::new(0, 0, 320, 160));
palette.set_entries(sample_entries());
assert!(palette.mru().is_empty());
palette.handle_event(&Event::key_press(40, 0));
palette.handle_event(&Event::key_press(13, 0));
assert_eq!(palette.mru().first().map(String::as_str), Some("file.save"));
}
#[test]
fn grouping_adds_a_heading_per_category() {
let mut palette = CommandPalette::new(Rect::new(0, 0, 320, 200));
palette.set_entries(sample_entries());
assert!(!palette.group_by_category());
palette.set_group_by_category(true);
let slots = palette.display_slots();
assert_eq!(slots.iter().filter(|slot| slot.header).count(), 2);
assert_eq!(slots.iter().filter(|slot| !slot.header).count(), 3);
assert_eq!(palette.filtered_count(), 3, "the entries are still addressable");
}
#[test]
fn category_and_keyword_based_matching() {
let mut palette = CommandPalette::new(Rect::new(0, 0, 800, 600));
palette.set_entries(vec![
CommandEntry::new("file.open", "Open")
.with_category("File")
.with_keywords(["load", "browse"]),
CommandEntry::new("edit.find", "Find")
.with_category("Edit")
.with_keywords(["search", "locate"]),
]);
palette.set_query("file");
assert_eq!(palette.filtered_count(), 1);
assert_eq!(palette.filtered_entry(0).map(|e| e.id.as_str()), Some("file.open"));
palette.set_query("load");
assert_eq!(palette.filtered_count(), 1);
assert_eq!(palette.filtered_entry(0).map(|e| e.id.as_str()), Some("file.open"));
palette.set_query("search");
assert_eq!(palette.filtered_count(), 1);
assert_eq!(palette.filtered_entry(0).map(|e| e.id.as_str()), Some("edit.find"));
palette.set_query("nonexistent");
assert_eq!(palette.filtered_count(), 0);
}
}