use crate::{sanitize_str, sanitize_title};
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum KeyModifier {
Ctrl,
Shift,
Alt,
Meta,
}
impl KeyModifier {
pub const fn label(self) -> &'static str {
match self {
Self::Ctrl => "Ctrl",
Self::Shift => "Shift",
Self::Alt => "Alt",
Self::Meta => "Meta",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct Shortcut {
pub modifiers: Vec<KeyModifier>,
pub key: String,
}
impl Shortcut {
pub fn new(key: impl Into<String>) -> Self {
Self {
modifiers: Vec::new(),
key: sanitize_title(&key.into(), 24),
}
}
pub fn with_modifiers(mut self, modifiers: impl IntoIterator<Item = KeyModifier>) -> Self {
self.modifiers = modifiers.into_iter().collect();
self
}
pub fn label(&self) -> String {
self.modifiers
.iter()
.map(|modifier| modifier.label())
.chain(std::iter::once(self.key.as_str()))
.collect::<Vec<_>>()
.join("+")
}
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct CommandItem {
pub id: String,
pub title: String,
pub subtitle: Option<String>,
pub keywords: Vec<String>,
pub shortcut: Option<Shortcut>,
pub disabled: bool,
pub score_bias: f32,
}
impl CommandItem {
pub fn new(id: impl Into<String>, title: impl Into<String>) -> Self {
Self {
id: sanitize_key(&id.into()),
title: sanitize_title(&title.into(), 80),
subtitle: None,
keywords: Vec::new(),
shortcut: None,
disabled: false,
score_bias: 0.0,
}
}
pub fn with_subtitle(mut self, subtitle: impl Into<String>) -> Self {
self.subtitle = Some(sanitize_str(&subtitle.into(), 120));
self
}
pub fn with_keywords(mut self, keywords: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.keywords = keywords
.into_iter()
.map(|keyword| sanitize_key(&keyword.into()))
.filter(|keyword| !keyword.is_empty())
.collect();
self
}
pub fn with_shortcut(mut self, shortcut: Shortcut) -> Self {
self.shortcut = Some(shortcut);
self
}
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
pub fn with_score_bias(mut self, score_bias: f32) -> Self {
self.score_bias = score_bias;
self
}
pub fn score(&self, query: &str) -> Option<f32> {
if self.disabled {
return None;
}
let query = normalize_query(query);
if query.is_empty() {
return Some(1.0 + self.score_bias);
}
let id = self.id.to_lowercase();
let title = self.title.to_lowercase();
let subtitle = self.subtitle.as_deref().unwrap_or_default().to_lowercase();
let mut score = None;
score = max_score(score, match_rank(&id, &query, 100.0));
score = max_score(score, match_rank(&title, &query, 90.0));
score = max_score(score, match_rank(&subtitle, &query, 40.0));
for keyword in &self.keywords {
score = max_score(score, match_rank(&keyword.to_lowercase(), &query, 65.0));
}
score.map(|value| value + self.score_bias)
}
}
#[derive(Clone, Debug, Default, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct CommandPalette {
pub query: String,
pub selected_index: usize,
pub items: Vec<CommandItem>,
}
impl CommandPalette {
pub fn new(items: impl IntoIterator<Item = CommandItem>) -> Self {
Self {
query: String::new(),
selected_index: 0,
items: items.into_iter().collect(),
}
}
pub fn set_query(&mut self, query: impl Into<String>) {
self.query = sanitize_str(&query.into(), 120);
self.selected_index = 0;
}
pub fn move_selection(&mut self, delta: isize) {
let count = self.filtered_items().len();
if count == 0 {
self.selected_index = 0;
return;
}
let current = self.selected_index.min(count - 1);
self.selected_index = if delta.is_negative() {
current.saturating_sub(delta.unsigned_abs())
} else {
current.saturating_add(delta as usize).min(count - 1)
};
}
pub fn filtered_items(&self) -> Vec<&CommandItem> {
let mut matches = self
.items
.iter()
.filter_map(|item| item.score(&self.query).map(|score| (score, item)))
.collect::<Vec<_>>();
matches.sort_by(|(left_score, left), (right_score, right)| {
right_score
.total_cmp(left_score)
.then_with(|| left.title.cmp(&right.title))
});
matches.into_iter().map(|(_, item)| item).collect()
}
pub fn selected_item(&self) -> Option<&CommandItem> {
self.filtered_items().get(self.selected_index).copied()
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct TextSnippet {
pub trigger: String,
pub body: String,
pub description: Option<String>,
pub keywords: Vec<String>,
}
impl TextSnippet {
pub fn new(trigger: impl Into<String>, body: impl Into<String>) -> Self {
Self {
trigger: sanitize_key(&trigger.into()),
body: sanitize_str(&body.into(), 4_000),
description: None,
keywords: Vec::new(),
}
}
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(sanitize_str(&description.into(), 120));
self
}
pub fn with_keywords(mut self, keywords: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.keywords = keywords
.into_iter()
.map(|keyword| sanitize_key(&keyword.into()))
.filter(|keyword| !keyword.is_empty())
.collect();
self
}
pub fn score(&self, query: &str) -> Option<f32> {
let query = normalize_query(query);
if query.is_empty() {
return Some(1.0);
}
let trigger = self.trigger.to_lowercase();
let description = self
.description
.as_deref()
.unwrap_or_default()
.to_lowercase();
let mut score = None;
score = max_score(score, match_rank(&trigger, &query, 100.0));
score = max_score(score, match_rank(&description, &query, 45.0));
for keyword in &self.keywords {
score = max_score(score, match_rank(&keyword.to_lowercase(), &query, 65.0));
}
score
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct SnippetSet {
pub snippets: Vec<TextSnippet>,
}
impl SnippetSet {
pub fn new(snippets: impl IntoIterator<Item = TextSnippet>) -> Self {
Self {
snippets: snippets.into_iter().collect(),
}
}
pub fn expand(&self, trigger: &str) -> Option<&str> {
let trigger = sanitize_key(trigger);
self.snippets
.iter()
.find(|snippet| snippet.trigger == trigger)
.map(|snippet| snippet.body.as_str())
}
pub fn matches(&self, query: &str) -> Vec<&TextSnippet> {
let mut matches = self
.snippets
.iter()
.filter_map(|snippet| snippet.score(query).map(|score| (score, snippet)))
.collect::<Vec<_>>();
matches.sort_by(|(left_score, left), (right_score, right)| {
right_score
.total_cmp(left_score)
.then_with(|| left.trigger.cmp(&right.trigger))
});
matches.into_iter().map(|(_, snippet)| snippet).collect()
}
}
fn sanitize_key(input: &str) -> String {
sanitize_str(input, 80)
.trim()
.to_lowercase()
.chars()
.filter(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | ':' | '.'))
.collect()
}
fn normalize_query(input: &str) -> String {
sanitize_str(input, 120).trim().to_lowercase()
}
fn match_rank(candidate: &str, query: &str, base_score: f32) -> Option<f32> {
if candidate == query {
Some(base_score + 30.0)
} else if candidate.starts_with(query) {
Some(base_score + 15.0)
} else if candidate.contains(query) {
Some(base_score)
} else {
None
}
}
fn max_score(left: Option<f32>, right: Option<f32>) -> Option<f32> {
match (left, right) {
(Some(left), Some(right)) => Some(left.max(right)),
(Some(value), None) | (None, Some(value)) => Some(value),
(None, None) => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shortcut_label_joins_modifiers() {
let shortcut = Shortcut::new("P").with_modifiers([KeyModifier::Ctrl, KeyModifier::Shift]);
assert_eq!(shortcut.label(), "Ctrl+Shift+P");
}
#[test]
fn command_palette_filters_and_skips_disabled_items() {
let mut palette = CommandPalette::new([
CommandItem::new("open", "Open File"),
CommandItem::new("close", "Close File").disabled(true),
CommandItem::new("theme", "Switch Theme").with_keywords(["palette"]),
]);
palette.set_query("pal");
let matches = palette.filtered_items();
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].id, "theme");
assert_eq!(palette.selected_item().unwrap().title, "Switch Theme");
}
#[test]
fn snippets_expand_and_rank_matches() {
let snippets = SnippetSet::new([
TextSnippet::new("hdr", "# Title").with_keywords(["markdown"]),
TextSnippet::new("todo", "- [ ] ").with_description("Task checkbox"),
]);
assert_eq!(snippets.expand("hdr"), Some("# Title"));
assert_eq!(snippets.matches("task")[0].trigger, "todo");
}
}