use crate::fuzzy::{self, Match};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Target {
Workflow { namespace: String, run_id: String },
Row(usize),
Command(String),
Pane(u64),
Query(String),
Namespace(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Item {
pub label: String,
pub note: String,
pub preview: String,
pub target: Target,
}
impl Item {
pub fn new(label: impl Into<String>, target: Target) -> Self {
Self {
label: label.into(),
note: String::new(),
preview: String::new(),
target,
}
}
pub fn with_note(mut self, note: impl Into<String>) -> Self {
self.note = note.into();
self
}
pub fn with_preview(mut self, preview: impl Into<String>) -> Self {
self.preview = preview.into();
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
Workflows,
HistoryRows,
Panes,
Commands,
Filters,
Namespaces,
}
impl Kind {
pub fn title(self) -> &'static str {
match self {
Kind::Workflows => "workflows",
Kind::HistoryRows => "events",
Kind::Panes => "panes",
Kind::Commands => "commands",
Kind::Filters => "filters",
Kind::Namespaces => "namespaces",
}
}
}
#[derive(Debug, Clone)]
pub struct Picker {
pub kind: Kind,
pub prompt: String,
items: Vec<Item>,
hits: Vec<(usize, Match)>,
pub cursor: usize,
}
impl Picker {
pub fn new(kind: Kind, items: Vec<Item>) -> Self {
let mut p = Self {
kind,
prompt: String::new(),
items,
hits: Vec::new(),
cursor: 0,
};
p.refilter();
p
}
pub fn is_empty(&self) -> bool {
self.hits.is_empty()
}
pub fn total(&self) -> usize {
self.items.len()
}
pub fn shown(&self) -> usize {
self.hits.len()
}
pub fn rows(&self) -> impl Iterator<Item = (&Item, &Match)> {
self.hits.iter().map(|(i, m)| (&self.items[*i], m))
}
pub fn selected(&self) -> Option<&Item> {
self.hits.get(self.cursor).map(|(i, _)| &self.items[*i])
}
pub fn accept(&self) -> Option<&Target> {
self.selected().map(|i| &i.target)
}
pub fn push(&mut self, c: char) {
self.prompt.push(c);
self.refilter();
}
pub fn backspace(&mut self) -> bool {
let had = self.prompt.pop().is_some();
if had {
self.refilter();
}
had
}
pub fn move_cursor(&mut self, delta: isize) {
if self.hits.is_empty() {
self.cursor = 0;
return;
}
let last = self.hits.len() as isize - 1;
self.cursor = (self.cursor as isize + delta).clamp(0, last) as usize;
}
fn refilter(&mut self) {
self.hits = fuzzy::rank(&self.prompt, &self.items, |i| i.label.clone());
self.cursor = 0;
}
}
#[cfg(test)]
mod tests {
use super::*;
fn items(labels: &[&str]) -> Vec<Item> {
labels
.iter()
.enumerate()
.map(|(i, l)| Item::new(*l, Target::Row(i)))
.collect()
}
fn picker(labels: &[&str]) -> Picker {
Picker::new(Kind::Workflows, items(labels))
}
fn labels(p: &Picker) -> Vec<String> {
p.rows().map(|(i, _)| i.label.clone()).collect()
}
#[test]
fn a_new_picker_shows_everything_in_the_order_given() {
let p = picker(&["c", "a", "b"]);
assert_eq!(labels(&p), vec!["c", "a", "b"]);
assert_eq!(p.shown(), 3);
assert_eq!(p.total(), 3);
}
#[test]
fn typing_narrows_the_list() {
let mut p = picker(&["order-checkout", "order-refund", "shipping"]);
p.push('o');
p.push('r');
assert_eq!(p.shown(), 2, "shipping has no 'or'");
}
#[test]
fn the_best_match_is_selected_as_you_type() {
let mut p = picker(&["processor", "order-checkout"]);
for c in "oc".chars() {
p.push(c);
}
assert_eq!(
p.selected().map(|i| i.label.as_str()),
Some("order-checkout"),
"a word-start match should outrank one buried mid-word"
);
}
#[test]
fn the_cursor_returns_to_the_top_on_every_keystroke() {
let mut p = picker(&["alpha", "beta", "gamma"]);
p.move_cursor(2);
assert_eq!(p.cursor, 2);
p.push('a');
assert_eq!(p.cursor, 0);
}
#[test]
fn the_cursor_clamps_rather_than_wrapping() {
let mut p = picker(&["a", "b"]);
p.move_cursor(10);
assert_eq!(p.cursor, 1, "clamped to the last row");
p.move_cursor(-10);
assert_eq!(p.cursor, 0, "clamped to the first");
}
#[test]
fn backspace_reports_when_there_is_nothing_left_to_delete() {
let mut p = picker(&["a"]);
p.push('a');
assert!(p.backspace(), "deleted the 'a'");
assert!(
!p.backspace(),
"empty, so the caller should close the picker"
);
}
#[test]
fn backspace_widens_the_list_again() {
let mut p = picker(&["order", "shipping"]);
p.push('o');
p.push('r');
assert_eq!(p.shown(), 1);
p.backspace();
p.backspace();
assert_eq!(p.shown(), 2, "back to everything");
}
#[test]
fn a_prompt_matching_nothing_leaves_no_selection() {
let mut p = picker(&["order"]);
for c in "zzz".chars() {
p.push(c);
}
assert!(p.is_empty());
assert_eq!(p.selected(), None);
assert_eq!(p.accept(), None);
}
#[test]
fn accept_returns_the_target_of_the_row_under_the_cursor() {
let mut p = picker(&["alpha", "beta"]);
p.move_cursor(1);
assert_eq!(p.accept(), Some(&Target::Row(1)));
}
#[test]
fn match_positions_come_back_for_highlighting() {
let mut p = picker(&["order-checkout"]);
p.push('o');
let (item, m) = p.rows().next().unwrap();
assert_eq!(m.positions.len(), 1);
assert!(item.label.is_char_boundary(m.positions[0]));
}
#[test]
fn notes_are_not_matched_against() {
let items = vec![Item::new("order-1", Target::Row(0)).with_note("Running")];
let mut p = Picker::new(Kind::Workflows, items);
for c in "running".chars() {
p.push(c);
}
assert!(p.is_empty(), "the note is shown, not searched");
}
}