use std::collections::HashMap;
use crate::core::buffer::Buffer;
use crate::core::color::Color;
use crate::core::rect::Rect;
use crate::sanitize;
use crate::widgets::Widget;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum ExpandTrigger {
At,
Slash,
Exclamation,
Hash,
Colon,
Custom(char),
}
impl ExpandTrigger {
pub fn prefix(&self) -> char {
match self {
ExpandTrigger::At => '@',
ExpandTrigger::Slash => '/',
ExpandTrigger::Exclamation => '!',
ExpandTrigger::Hash => '#',
ExpandTrigger::Colon => ':',
ExpandTrigger::Custom(c) => *c,
}
}
}
#[derive(Debug, Clone)]
pub struct Snippet {
pub alias: String,
pub expansion: String,
pub description: String,
pub trigger: ExpandTrigger,
}
impl Snippet {
pub fn new(alias: &str, expansion: &str) -> Self {
Self {
alias: alias.to_string(),
expansion: expansion.to_string(),
description: String::new(),
trigger: ExpandTrigger::At,
}
}
pub fn with_trigger(mut self, trigger: ExpandTrigger) -> Self {
self.trigger = trigger;
self
}
pub fn with_description(mut self, desc: &str) -> Self {
self.description = desc.to_string();
self
}
}
#[derive(Debug, Clone)]
pub struct TextExpander {
snippets: Vec<Snippet>,
alias_index: HashMap<String, usize>,
triggers: Vec<ExpandTrigger>,
max_suggestions: usize,
}
impl TextExpander {
pub fn new() -> Self {
Self {
snippets: Vec::new(),
alias_index: HashMap::new(),
triggers: vec![
ExpandTrigger::At,
ExpandTrigger::Slash,
ExpandTrigger::Exclamation,
],
max_suggestions: 8,
}
}
pub fn with_trigger(mut self, trigger: ExpandTrigger) -> Self {
if !self.triggers.contains(&trigger) {
self.triggers.push(trigger);
}
self
}
pub fn with_max_suggestions(mut self, n: usize) -> Self {
self.max_suggestions = n;
self
}
pub fn add_snippet(&mut self, snippet: Snippet) {
let idx = self.snippets.len();
self.alias_index.insert(snippet.alias.clone(), idx);
self.snippets.push(snippet);
}
pub fn remove_snippet(&mut self, alias: &str) -> bool {
if let Some(&idx) = self.alias_index.get(alias) {
self.snippets.remove(idx);
self.alias_index.clear();
for (i, s) in self.snippets.iter().enumerate() {
self.alias_index.insert(s.alias.clone(), i);
}
true
} else {
false
}
}
pub fn get_expansion(&self, alias: &str) -> Option<&Snippet> {
self.alias_index
.get(alias)
.and_then(|&i| self.snippets.get(i))
}
pub fn expand(&self, input: &str) -> Option<String> {
if input.is_empty() {
return None;
}
let first_char = input.chars().next()?;
if !self.triggers.iter().any(|t| t.prefix() == first_char) {
return None;
}
let alias = &input[1..];
if alias.is_empty() {
return None;
}
self.get_expansion(alias)
.map(|s| s.expansion.clone())
.or_else(|| {
self.snippets
.iter()
.find(|s| s.alias.starts_with(alias) || alias.starts_with(&s.alias))
.map(|s| s.expansion.clone())
})
}
pub fn parse_input(&self, input: &str) -> InputParseResult {
if let Some(last_space) = input.rfind(' ') {
let after_space = &input[last_space + 1..];
if let Some(first_char) = after_space.chars().next() {
if self.triggers.iter().any(|t| t.prefix() == first_char) && after_space.len() > 1 {
let query = &after_space[1..];
let matches = self.fuzzy_matches(query);
return InputParseResult {
completed_prefix: input[..last_space].to_string(),
query: query.to_string(),
trigger_prefix: first_char,
suggestions: matches,
cursor_in_snippet: true,
};
}
}
} else {
if let Some(first_char) = input.chars().next() {
if self.triggers.iter().any(|t| t.prefix() == first_char) && input.len() > 1 {
let query = &input[1..];
let matches = self.fuzzy_matches(query);
return InputParseResult {
completed_prefix: String::new(),
query: query.to_string(),
trigger_prefix: first_char,
suggestions: matches,
cursor_in_snippet: true,
};
}
}
}
InputParseResult {
completed_prefix: input.to_string(),
query: String::new(),
trigger_prefix: ' ',
suggestions: Vec::new(),
cursor_in_snippet: false,
}
}
pub fn fuzzy_matches(&self, query: &str) -> Vec<(String, String, String)> {
if query.is_empty() {
return self
.snippets
.iter()
.take(self.max_suggestions)
.map(|s| (s.alias.clone(), s.expansion.clone(), s.description.clone()))
.collect();
}
let lq = query.to_lowercase();
let mut scored: Vec<(i32, usize, &Snippet)> = self
.snippets
.iter()
.enumerate()
.filter_map(|(i, s)| {
let la = s.alias.to_lowercase();
let score = if la == lq {
100
} else if la.starts_with(&lq) {
80
} else if la.contains(&lq) {
50
} else {
let mut matches = 0;
let mut qi = 0;
for ch in la.chars() {
if qi < lq.len() && ch == lq.chars().nth(qi).unwrap_or(' ') {
matches += 1;
qi += 1;
}
}
if qi == lq.len() {
matches * 10
} else {
0
}
};
if score > 0 {
Some((score, i, s))
} else {
None
}
})
.collect();
scored.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
scored
.into_iter()
.take(self.max_suggestions)
.map(|(_, _, s)| (s.alias.clone(), s.expansion.clone(), s.description.clone()))
.collect()
}
pub fn complete(&self, result: &InputParseResult, selected_idx: usize) -> Option<String> {
if !result.cursor_in_snippet || selected_idx >= result.suggestions.len() {
return None;
}
let (alias, _, _) = &result.suggestions[selected_idx];
let full_snippet = format!("{}{}", result.trigger_prefix, alias);
if result.completed_prefix.is_empty() {
Some(full_snippet)
} else {
Some(format!("{} {}", result.completed_prefix, full_snippet))
}
}
pub fn snippets(&self) -> &[Snippet] {
&self.snippets
}
}
impl Default for TextExpander {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
pub struct InputParseResult {
pub completed_prefix: String,
pub query: String,
pub trigger_prefix: char,
pub suggestions: Vec<(String, String, String)>,
pub cursor_in_snippet: bool,
}
#[derive(Debug, Clone)]
pub struct SuggestionPopup {
pub selected: usize,
pub visible: bool,
pub result: InputParseResult,
}
impl SuggestionPopup {
pub fn new() -> Self {
Self {
selected: 0,
visible: false,
result: InputParseResult {
completed_prefix: String::new(),
query: String::new(),
trigger_prefix: ' ',
suggestions: Vec::new(),
cursor_in_snippet: false,
},
}
}
pub fn update(&mut self, result: InputParseResult) {
self.result = result;
self.visible = self.result.cursor_in_snippet && !self.result.suggestions.is_empty();
self.selected = 0;
}
pub fn move_up(&mut self) {
if self.selected > 0 {
self.selected -= 1;
} else if !self.result.suggestions.is_empty() {
self.selected = self.result.suggestions.len() - 1;
}
}
pub fn move_down(&mut self) {
if self.selected + 1 < self.result.suggestions.len() {
self.selected += 1;
} else {
self.selected = 0;
}
}
pub fn hide(&mut self) {
self.visible = false;
}
}
impl Default for SuggestionPopup {
fn default() -> Self {
Self::new()
}
}
impl Widget for SuggestionPopup {
fn render(&self, buffer: &mut Buffer, area: Rect) {
if !self.visible || self.result.suggestions.is_empty() || area.width < 10 {
return;
}
let max_width = area.width as usize;
let max_rows = area.height as usize;
let items = self.result.suggestions.len().min(max_rows);
let height = items as u16;
if height == 0 {
return;
}
let x = area.x as usize;
let y = area.y as usize;
let bg = Color::rgb(30, 30, 46);
let fg = Color::rgb(205, 214, 244);
let selected_bg = Color::rgb(49, 50, 68);
let accent = Color::rgb(137, 180, 250);
let dim = Color::rgb(108, 112, 134);
for row in 0..height {
for col in 0..max_width {
buffer.set(
x + col as usize,
y + row as usize,
crate::core::buffer::Cell::new(' ', fg, Some(bg)),
);
}
}
for i in 0..items {
let (alias, expansion, _description) = &self.result.suggestions[i];
let row_y = y + i;
let item_bg = if i == self.selected {
Some(selected_bg)
} else {
Some(bg)
};
if i == self.selected {
for col in 0..max_width {
buffer.set(
x + col,
row_y,
crate::core::buffer::Cell::new(' ', fg, item_bg),
);
}
}
let prefix_str = format!("{} ", self.result.trigger_prefix);
buffer.set_str(x + 1, row_y, &prefix_str, accent, item_bg);
let alias_col = x + 1 + prefix_str.len();
let truncated_alias =
sanitize::truncate_str(alias, max_width.saturating_sub(alias_col - x + 2));
buffer.set_str(alias_col, row_y, &truncated_alias, fg, item_bg);
let exp_col = alias_col + alias.len() + 2;
if exp_col < x + max_width {
let available = x + max_width - exp_col;
let truncated_exp = sanitize::truncate_str(expansion, available);
buffer.set_str(exp_col, row_y, &truncated_exp, dim, item_bg);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_expander() -> TextExpander {
let mut exp = TextExpander::new();
exp.add_snippet(Snippet::new("hello", "Hello, World!").with_description("Greeting"));
exp.add_snippet(Snippet::new("help", "Show help message").with_description("Help text"));
exp.add_snippet(Snippet::new("quit", "Exit the application").with_description("Quit"));
exp.add_snippet(Snippet::new("foo", "bar").with_trigger(ExpandTrigger::Slash));
exp
}
#[test]
fn test_expand_exact() {
let exp = test_expander();
assert_eq!(exp.expand("@hello"), Some("Hello, World!".into()));
assert_eq!(exp.expand("@help"), Some("Show help message".into()));
assert_eq!(exp.expand("@quit"), Some("Exit the application".into()));
}
#[test]
fn test_expand_custom_trigger() {
let exp = test_expander();
assert_eq!(exp.expand("/foo"), Some("bar".into()));
}
#[test]
fn test_expand_no_match() {
let exp = test_expander();
assert_eq!(exp.expand("@nonexistent"), None);
}
#[test]
fn test_expand_empty_alias() {
let exp = test_expander();
assert_eq!(exp.expand("@"), None);
}
#[test]
fn test_expand_no_trigger() {
let exp = test_expander();
assert_eq!(exp.expand("hello"), None);
}
#[test]
fn test_expand_fuzzy_prefix() {
let exp = test_expander();
assert_eq!(exp.expand("@hel"), Some("Hello, World!".into()));
}
#[test]
fn test_fuzzy_matches_empty_query() {
let exp = test_expander();
let matches = exp.fuzzy_matches("");
assert_eq!(matches.len(), 4);
}
#[test]
fn test_fuzzy_matches_specific() {
let exp = test_expander();
let matches = exp.fuzzy_matches("hel");
assert!(!matches.is_empty());
assert_eq!(matches[0].0, "hello");
}
#[test]
fn test_parse_input_no_trigger() {
let exp = test_expander();
let result = exp.parse_input("just text");
assert!(!result.cursor_in_snippet);
assert_eq!(result.completed_prefix, "just text");
}
#[test]
fn test_parse_input_with_trigger() {
let exp = test_expander();
let result = exp.parse_input("say @hel");
assert!(result.cursor_in_snippet);
assert_eq!(result.completed_prefix, "say");
assert_eq!(result.query, "hel");
assert!(!result.suggestions.is_empty());
}
#[test]
fn test_parse_input_trigger_at_start() {
let exp = test_expander();
let result = exp.parse_input("@hel");
assert!(result.cursor_in_snippet);
assert_eq!(result.completed_prefix, "");
assert_eq!(result.query, "hel");
}
#[test]
fn test_complete() {
let exp = test_expander();
let result = exp.parse_input("@hel");
let completed = exp.complete(&result, 0);
assert_eq!(completed, Some("@hello".into()));
}
#[test]
fn test_complete_with_prefix() {
let exp = test_expander();
let result = exp.parse_input("say @hel");
let completed = exp.complete(&result, 0);
assert_eq!(completed, Some("say @hello".into()));
}
#[test]
fn test_remove_snippet() {
let mut exp = test_expander();
assert!(exp.remove_snippet("hello"));
assert_eq!(exp.expand("@hello"), None);
assert!(!exp.remove_snippet("nonexistent"));
}
#[test]
fn test_suggestion_popup() {
let mut popup = SuggestionPopup::new();
assert!(!popup.visible);
let exp = test_expander();
let result = exp.parse_input("@hel");
popup.update(result);
assert!(popup.visible);
assert_eq!(popup.selected, 0);
popup.move_down();
popup.move_up();
popup.hide();
assert!(!popup.visible);
}
#[test]
fn test_max_suggestions() {
let mut exp = TextExpander::new().with_max_suggestions(2);
for i in 0..10 {
exp.add_snippet(Snippet::new(&format!("s{}", i), "expansion"));
}
let matches = exp.fuzzy_matches("s");
assert!(matches.len() <= 2);
}
}