use ratatui::layout::Rect;
use crate::commands::{Command, Ctx, GROUPS};
#[derive(Clone, Copy)]
pub struct Row {
pub command: Command,
pub enabled: bool,
}
pub struct Palette {
pub query: String,
pub cursor: usize,
pub selected: usize,
pub rows: Vec<Row>,
pub list_rect: Option<Rect>,
pub scrolled_by: usize,
}
impl Palette {
pub fn new(ctx: &Ctx) -> Self {
let mut p = Palette {
query: String::new(),
cursor: 0,
selected: 0,
rows: Vec::new(),
list_rect: None,
scrolled_by: 0,
};
p.refilter(ctx);
p
}
pub fn refilter(&mut self, ctx: &Ctx) {
let q = self.query.to_lowercase();
let mut scored: Vec<(i32, usize, Row)> = Vec::new();
let mut order = 0usize;
for (group, commands) in GROUPS {
for command in *commands {
order += 1;
let haystack = format!(
"{} {}",
group.to_lowercase(),
command.label().to_lowercase()
);
let Some(score) = score(&haystack, &q) else {
continue;
};
scored.push((
score,
order,
Row {
command: *command,
enabled: command.enabled(ctx),
},
));
}
}
scored.sort_by_key(|(score, order, row)| (!row.enabled, -score, *order));
self.rows = scored.into_iter().map(|(_, _, row)| row).collect();
self.selected = self.first_enabled().unwrap_or(0);
}
fn first_enabled(&self) -> Option<usize> {
self.rows.iter().position(|r| r.enabled)
}
pub fn step(&mut self, delta: isize) {
let n = self.rows.len() as isize;
if n == 0 {
return;
}
let mut i = self.selected as isize;
for _ in 0..n {
i = (i + delta).rem_euclid(n);
if self.rows[i as usize].enabled {
self.selected = i as usize;
return;
}
}
}
pub fn hit(&self, row: u16, col: u16) -> Option<&Row> {
let r = self.list_rect?;
if row < r.y || row >= r.y + r.height || col < r.x || col >= r.x + r.width {
return None;
}
self.rows.get(self.scrolled_by + (row - r.y) as usize)
}
pub fn covers(&self, row: u16, col: u16) -> bool {
self.list_rect.is_some_and(|r| {
row >= r.y && row < r.y + r.height && col >= r.x && col < r.x + r.width
})
}
pub fn chosen(&self) -> Option<Command> {
self.rows
.get(self.selected)
.filter(|r| r.enabled)
.map(|r| r.command)
}
}
fn score(haystack: &str, query: &str) -> Option<i32> {
if query.is_empty() {
return Some(0);
}
let hay: Vec<char> = haystack.chars().collect();
walk(&hay, query, true).or_else(|| walk(&hay, query, false))
}
fn walk(hay: &[char], query: &str, prefer_word_start: bool) -> Option<i32> {
let word_start = |i: usize| i == 0 || !hay[i - 1].is_alphanumeric();
let mut total = 0;
let mut at = 0usize;
let mut previous_match: Option<usize> = None;
for want in query.chars() {
if want == ' ' {
continue; }
let nearest = hay[at..].iter().position(|c| *c == want).map(|i| i + at);
let found = match prefer_word_start {
true => hay[at..]
.iter()
.enumerate()
.find(|(i, c)| **c == want && word_start(i + at))
.map(|(i, _)| i + at)
.or(nearest)?,
false => nearest?,
};
total += if word_start(found) { 8 } else { 1 };
if previous_match == Some(found.wrapping_sub(1)) {
total += 4;
}
previous_match = Some(found);
at = found + 1;
}
Some(total)
}
#[cfg(test)]
mod tests {
use super::*;
use leaf_core::Doc;
fn ctx(source: &str, format: leaf_core::Format) -> Ctx {
let mut doc = Doc::from_source(source.into(), format).unwrap();
Ctx::read(&mut doc)
}
#[test]
fn an_empty_query_lists_everything() {
let p = Palette::new(&ctx("hi\n", leaf_core::Format::Markdown));
let total: usize = GROUPS.iter().map(|(_, c)| c.len()).sum();
assert_eq!(p.rows.len(), total);
}
#[test]
fn a_non_subsequence_matches_nothing() {
assert_eq!(score("horizontal rule", "zzz"), None);
}
#[test]
fn word_initials_outrank_letters_buried_mid_word() {
let initials = score("insert row above", "ir").unwrap();
let buried = score("horizontal rule", "ir").unwrap();
assert!(
initials > buried,
"initials {initials} should outrank buried {buried}"
);
}
#[test]
fn typing_filters_and_the_highlight_lands_on_a_runnable_row() {
let ctx = ctx("hi\n", leaf_core::Format::Markdown);
let mut p = Palette::new(&ctx);
p.query = "footnote".into();
p.refilter(&ctx);
assert!(!p.rows.is_empty());
assert_eq!(p.chosen(), Some(Command::Footnote));
}
#[test]
fn an_unavailable_command_is_listed_but_not_runnable() {
let ctx = ctx("<p>hi</p>\n", leaf_core::Format::Html);
let mut p = Palette::new(&ctx);
p.query = "footnote".into();
p.refilter(&ctx);
assert!(
p.rows
.iter()
.any(|r| r.command == Command::Footnote && !r.enabled),
"the footnote command should be listed and dimmed"
);
assert_ne!(p.chosen(), Some(Command::Footnote));
}
#[test]
fn a_group_name_finds_its_whole_family() {
let ctx = ctx("| a |\n| - |\n| b |\n", leaf_core::Format::Markdown);
let mut p = Palette::new(&ctx);
p.query = "table".into();
p.refilter(&ctx);
assert!(p.rows.iter().filter(|r| r.enabled).count() >= 10);
}
#[test]
fn stepping_skips_unavailable_rows() {
let ctx = ctx("<p>hi</p>\n", leaf_core::Format::Html);
let mut p = Palette::new(&ctx);
for _ in 0..p.rows.len() + 2 {
p.step(1);
assert!(p.rows[p.selected].enabled);
}
}
}