#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct OptionList {
items: Vec<String>,
matches: Vec<usize>,
cursor: usize,
open: bool,
active: bool,
}
impl OptionList {
pub fn new(items: Vec<String>) -> OptionList {
let matches = (0..items.len()).collect();
OptionList {
items,
matches,
cursor: 0,
open: false,
active: false,
}
}
pub fn open(&mut self) {
self.open = true;
}
pub fn close(&mut self) {
self.open = false;
self.active = false;
}
pub fn is_open(&self) -> bool {
self.open && !self.matches.is_empty()
}
pub fn refilter(&mut self, query: &str) {
let q = query.to_lowercase();
self.matches = self
.items
.iter()
.enumerate()
.filter(|(_, item)| item.to_lowercase().contains(&q))
.map(|(i, _)| i)
.collect();
self.cursor = 0;
self.active = false;
}
pub fn up(&mut self) {
if self.is_open() {
self.active = true;
self.cursor = self.cursor.saturating_sub(1);
}
}
pub fn down(&mut self) {
if self.is_open() {
self.active = true;
let last = self.matches.len().saturating_sub(1);
self.cursor = (self.cursor + 1).min(last);
}
}
pub fn selected(&self) -> Option<&str> {
if self.is_open() && self.active {
self.matches
.get(self.cursor)
.map(|&i| self.items[i].as_str())
} else {
None
}
}
pub fn set_cursor(&mut self, index: usize) {
if !self.matches.is_empty() {
self.cursor = index.min(self.matches.len() - 1);
}
self.active = true;
}
pub fn cursor(&self) -> usize {
self.cursor
}
pub fn match_count(&self) -> usize {
self.matches.len()
}
pub fn match_labels(&self) -> impl Iterator<Item = &str> {
self.matches.iter().map(move |&i| self.items[i].as_str())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn list() -> OptionList {
OptionList::new(vec![
"main".into(),
"origin/main".into(),
"origin/dev".into(),
"feature/login".into(),
])
}
#[test]
fn new_matches_everything_and_starts_closed() {
let ol = list();
assert_eq!(ol.match_count(), 4);
assert!(!ol.is_open());
assert_eq!(ol.selected(), None);
}
#[test]
fn refilter_substring_case_insensitive() {
let mut ol = list();
ol.open();
ol.refilter("MAIN");
let m: Vec<&str> = ol.match_labels().collect();
assert_eq!(m, vec!["main", "origin/main"]);
ol.refilter("zzz");
assert_eq!(ol.match_count(), 0);
assert!(!ol.is_open());
}
#[test]
fn navigation_clamps_within_matches() {
let mut ol = list();
ol.open();
assert_eq!(ol.cursor(), 0);
ol.up(); assert_eq!(ol.cursor(), 0);
ol.down();
ol.down();
assert_eq!(ol.cursor(), 2);
for _ in 0..10 {
ol.down();
}
assert_eq!(ol.cursor(), 3); }
#[test]
fn selected_only_after_engaging_the_list() {
let mut ol = list();
ol.open();
assert_eq!(ol.selected(), None);
ol.down(); assert_eq!(ol.selected(), Some("origin/main"));
ol.refilter("feat");
assert_eq!(ol.selected(), None);
assert_eq!(ol.match_labels().collect::<Vec<_>>(), vec!["feature/login"]);
}
#[test]
fn close_clears_open_and_active() {
let mut ol = list();
ol.open();
ol.down();
assert!(ol.selected().is_some());
ol.close();
assert!(!ol.is_open());
assert_eq!(ol.selected(), None);
}
#[test]
fn set_cursor_seeds_a_fixed_choice() {
let mut ol = OptionList::new(vec!["low".into(), "medium".into(), "high".into()]);
ol.open();
ol.set_cursor(2);
assert_eq!(ol.cursor(), 2);
assert_eq!(ol.selected(), Some("high"));
ol.set_cursor(99);
assert_eq!(ol.cursor(), 2);
}
#[test]
fn navigation_is_a_noop_while_closed() {
let mut ol = list();
ol.down();
assert_eq!(ol.cursor(), 0);
assert_eq!(ol.selected(), None);
}
}