use std::collections::HashSet;
use super::keybind::{Action, BindingEntry, KeyBindings};
#[derive(Debug, Clone)]
pub struct PaletteEntry {
pub action: Action,
pub label: String,
pub description: String,
pub chord: String,
}
fn push_table(
out: &mut Vec<PaletteEntry>,
seen: &mut HashSet<Action>,
prefix: Option<&str>,
table: &[BindingEntry],
) {
for e in table {
if matches!(e.action, Action::None) {
continue;
}
let label = e.action.label();
if label.is_empty() {
continue;
}
if !seen.insert(e.action.clone()) {
continue;
}
let chord = match prefix {
Some(p) => format!("{p} {}", e.chord.to_display_string()),
None => e.chord.to_display_string(),
};
out.push(PaletteEntry {
action: e.action.clone(),
label,
description: e.action.description(),
chord,
});
}
}
pub fn collect(b: &KeyBindings) -> Vec<PaletteEntry> {
let meta = b.meta_prefix.to_display_string();
let view = b.view_prefix.as_ref().map(|c| c.to_display_string());
let bund = b.bund_prefix.as_ref().map(|c| c.to_display_string());
let mut out = Vec::new();
let mut seen: HashSet<Action> = HashSet::new();
push_table(&mut out, &mut seen, None, &b.top_level);
push_table(&mut out, &mut seen, Some(&meta), &b.meta_sub);
if let Some(v) = &view {
push_table(&mut out, &mut seen, Some(v), &b.view_sub);
}
if let Some(z) = &bund {
push_table(&mut out, &mut seen, Some(z), &b.bund_sub);
}
out.sort_by(|a, b| a.label.cmp(&b.label).then(a.chord.cmp(&b.chord)));
out
}
fn score(e: &PaletteEntry, q: &str) -> i32 {
let label = e.label.to_lowercase();
if label.starts_with(q) {
4
} else if label.contains(q) {
3
} else if e.chord.to_lowercase().contains(q) {
2
} else if e.description.to_lowercase().contains(q) {
1
} else {
0
}
}
pub fn fuzzy_filter(entries: &[PaletteEntry], query: &str) -> Vec<usize> {
let q = query.trim().to_lowercase();
if q.is_empty() {
return (0..entries.len()).collect();
}
let mut scored: Vec<(i32, usize)> = entries
.iter()
.enumerate()
.filter_map(|(i, e)| {
let s = score(e, &q);
(s > 0).then_some((s, i))
})
.collect();
scored.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
scored.into_iter().map(|(_, i)| i).collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn defaults() -> KeyBindings {
KeyBindings::defaults()
}
#[test]
fn collect_is_nonempty_and_well_formed() {
let entries = collect(&defaults());
assert!(entries.len() > 20, "the default registry yields many commands");
for e in &entries {
assert!(!e.label.is_empty(), "every entry has a label");
assert!(!e.chord.is_empty(), "every entry has a rendered chord");
assert!(!matches!(e.action, Action::None));
}
}
#[test]
fn collect_dedupes_by_action() {
let entries = collect(&defaults());
let mut seen = HashSet::new();
for e in &entries {
assert!(seen.insert(e.action.clone()), "no action appears twice");
}
}
#[test]
fn sub_chords_carry_their_prefix() {
let b = defaults();
let meta = b.meta_prefix.to_display_string();
let entries = collect(&b);
assert!(
entries.iter().any(|e| e.chord.starts_with(&meta)),
"meta sub-chords are prefixed with {meta}"
);
}
#[test]
fn empty_query_returns_all_in_order() {
let entries = collect(&defaults());
let idx = fuzzy_filter(&entries, " ");
assert_eq!(idx, (0..entries.len()).collect::<Vec<_>>());
}
#[test]
fn query_filters_and_ranks() {
let entries = collect(&defaults());
let idx = fuzzy_filter(&entries, "save");
assert!(!idx.is_empty(), "`save` matches something");
let top = &entries[idx[0]];
assert!(
top.label.to_lowercase().contains("save")
|| top.label.to_lowercase().starts_with("save"),
"top hit for `save` is a label match, got {:?}",
top.label
);
assert!(idx.iter().all(|&i| i < entries.len()));
}
#[test]
fn nonsense_query_matches_nothing() {
let entries = collect(&defaults());
assert!(fuzzy_filter(&entries, "zzqxnonsense").is_empty());
}
mod prop {
use super::{collect, fuzzy_filter};
use crate::tui::keybind::KeyBindings;
use proptest::prelude::*;
proptest! {
#[test]
fn fuzzy_filter_returns_valid_unique_indices(q in ".{0,32}") {
let entries = collect(&KeyBindings::defaults());
let idx = fuzzy_filter(&entries, &q);
let mut seen = std::collections::HashSet::new();
for &i in &idx {
prop_assert!(i < entries.len(), "index in range");
prop_assert!(seen.insert(i), "no duplicate index");
}
}
#[test]
fn whitespace_query_returns_all(q in "[ \\t]{0,8}") {
let entries = collect(&KeyBindings::defaults());
prop_assert_eq!(fuzzy_filter(&entries, &q).len(), entries.len());
}
}
}
}