use std::ops::Range;
use crate::input::Key;
pub const KEYBOARD_COLS: usize = 10;
pub const KEYBOARD_KEYS: &[&str] = &[
"a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s",
"t", "u", "v", "w", "x", "y", "z", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "Space",
"-", "_", ".", "Del", "Clear", "Done",
];
const QUERY_LIMIT: usize = 128;
#[derive(Debug, PartialEq, Eq)]
pub enum Action {
None,
Open(usize),
Back,
Resume,
Submit(String), }
pub struct Browser {
names: Vec<String>,
matches: Vec<usize>,
selected: usize,
first: usize,
query: String,
typing: bool,
search_focused: bool,
keyboard: Option<usize>,
anchor: Option<(usize, usize)>,
submit: bool, cols: usize, }
impl Browser {
pub fn new<'a>(names: impl IntoIterator<Item = &'a str>) -> Self {
let names: Vec<_> = names.into_iter().map(str::to_lowercase).collect();
Self {
matches: (0..names.len()).collect(),
names,
selected: 0,
first: 0,
query: String::new(),
typing: false,
search_focused: false,
keyboard: None,
anchor: None,
submit: false,
cols: 1,
}
}
pub fn query_input() -> Self {
let mut browser = Browser::new(std::iter::empty());
browser.submit = true;
browser.keyboard = Some(0);
browser
}
pub fn set_columns(&mut self, cols: usize) {
self.cols = cols.max(1);
}
pub fn selected(&self) -> usize {
self.selected
}
pub fn query(&self) -> &str {
&self.query
}
pub fn searching(&self) -> bool {
self.typing || self.search_focused || self.keyboard.is_some()
}
pub fn search_focused(&self) -> bool {
self.search_focused
}
pub fn keyboard(&self) -> Option<usize> {
self.keyboard
}
pub fn total(&self) -> usize {
self.names.len()
}
pub fn count(&self) -> usize {
self.matches.len()
}
pub fn select_source(&mut self, source: usize) {
if let Some(filtered) = self.matches.iter().position(|&s| s == source) {
self.selected = filtered;
}
}
pub fn source_index(&self, filtered: usize) -> Option<usize> {
self.matches.get(filtered).copied()
}
pub fn window(&mut self, capacity: usize) -> Range<usize> {
let cols = self.cols;
let rows = (capacity.max(1) / cols).max(1);
self.selected = self.selected.min(self.count().saturating_sub(1));
let (row, total_rows) = (self.selected / cols, self.count().div_ceil(cols));
let mut first = (self.first / cols).min(total_rows.saturating_sub(rows));
if row < first {
first = row;
} else if row >= first + rows {
first = row + 1 - rows;
}
self.first = first * cols;
self.first..self.first.saturating_add(rows * cols).min(self.count())
}
pub fn handle(&mut self, key: Key, capacity: usize) -> Action {
let capacity = capacity.max(1);
if self.keyboard.is_some() {
let action = self.handle_keyboard(key);
self.window(capacity);
return action;
}
if self.submit {
match key {
Key::Select | Key::Search if !self.query.is_empty() => {
return Action::Submit(self.query.clone());
}
Key::Select | Key::Menu => {
self.keyboard = Some(0); return Action::None;
}
Key::Back if self.query.is_empty() => return Action::Back,
Key::Back | Key::Backspace => {
self.query.pop();
self.refilter();
return Action::None;
}
Key::Char(c) if !c.is_control() => {
self.append(c);
return Action::None;
}
_ => return Action::None,
}
}
let count = self.count();
if self.search_focused {
match key {
Key::Up | Key::Down if count == 0 => return Action::None,
Key::Up => {
self.finish_editing();
self.selected = count - 1;
self.window(capacity);
return Action::None;
}
Key::Down | Key::Back => {
self.finish_editing();
self.selected = 0;
self.window(capacity);
return Action::None;
}
Key::Select => {
self.search_focused = false;
self.keyboard = Some(0);
return Action::None;
}
Key::Play | Key::Pause => return Action::None,
Key::Char(_) | Key::Backspace | Key::Search => {
self.search_focused = false;
self.typing = true;
}
_ => self.finish_editing(),
}
}
let cols = self.cols;
let action = match key {
Key::Up if self.selected < cols => {
self.begin_search();
self.typing = false;
self.search_focused = true;
Action::None
}
Key::Up => {
self.selected -= cols;
Action::None
}
Key::Down if count > 0 && cols == 1 => {
self.selected = (self.selected + 1) % count;
Action::None
}
Key::Down if count > 0 => {
self.selected = if self.selected / cols < (count - 1) / cols {
(self.selected + cols).min(count - 1)
} else {
self.selected % cols
};
Action::None
}
Key::Left if cols > 1 => {
self.selected = self.selected.saturating_sub(1);
Action::None
}
Key::Right if cols > 1 => {
self.selected = (self.selected + 1).min(count.saturating_sub(1));
Action::None
}
Key::PageUp | Key::Prev | Key::SkipPrev | Key::Left => {
self.selected = self.selected.saturating_sub(capacity);
self.first = self.first.saturating_sub(capacity);
Action::None
}
Key::PageDown | Key::Next | Key::SkipNext | Key::Right => {
self.selected = self
.selected
.saturating_add(capacity)
.min(count.saturating_sub(1));
self.first = self
.first
.saturating_add(capacity)
.min(count.saturating_sub(capacity));
Action::None
}
Key::Home => {
self.selected = 0;
Action::None
}
Key::End => {
self.selected = count.saturating_sub(1);
Action::None
}
Key::Search => {
self.begin_search();
self.typing = true;
Action::None
}
Key::Menu => {
self.begin_search();
self.keyboard = Some(0);
self.typing = false;
Action::None
}
Key::Char(c) if !c.is_control() => {
if c == ' ' && !self.typing {
Action::Resume
} else if c == '/' && !self.typing {
self.begin_search();
self.typing = true;
Action::None
} else {
self.append(c);
Action::None
}
}
Key::Backspace if self.typing || !self.query.is_empty() => {
self.query.pop();
self.typing = true;
self.refilter();
Action::None
}
Key::Back if self.typing || !self.query.is_empty() => {
self.clear_query(true);
Action::None
}
Key::Back => Action::Back,
Key::Select => {
if let Some(index) = self.source_index(self.selected) {
self.finish_editing();
Action::Open(index)
} else {
Action::None
}
}
Key::Play | Key::Pause if !self.typing => Action::Resume,
_ => Action::None,
};
self.window(capacity);
action
}
fn begin_search(&mut self) {
if self.anchor.is_none() {
self.anchor = Some((self.source_index(self.selected).unwrap_or(0), self.first));
}
}
fn finish_editing(&mut self) {
self.typing = false;
self.search_focused = false;
self.keyboard = None;
if self.query.is_empty() {
self.anchor = None;
}
}
fn append(&mut self, c: char) {
self.begin_search();
self.typing = true;
if self.query.chars().count() < QUERY_LIMIT {
self.query.push(c);
self.refilter();
}
}
fn refilter(&mut self) {
let query = self.query.to_lowercase();
self.matches = self
.names
.iter()
.enumerate()
.filter_map(|(index, name)| name.contains(&query).then_some(index))
.collect();
self.selected = 0;
self.first = 0;
}
fn clear_query(&mut self, exit: bool) {
self.query.clear();
self.refilter();
if let Some((selected, first)) = self.anchor {
self.selected = selected;
self.first = first;
}
if exit {
self.anchor = None;
self.typing = false;
self.search_focused = false;
self.keyboard = None;
}
}
fn handle_keyboard(&mut self, key: Key) -> Action {
let selected = self.keyboard.unwrap();
let count = KEYBOARD_KEYS.len();
let rows = count.div_ceil(KEYBOARD_COLS);
match key {
Key::Left => self.keyboard = Some((selected + count - 1) % count),
Key::Right => self.keyboard = Some((selected + 1) % count),
Key::Up | Key::Down => {
let row = selected / KEYBOARD_COLS;
let row = if key == Key::Up {
(row + rows - 1) % rows
} else {
(row + 1) % rows
};
self.keyboard =
Some((row * KEYBOARD_COLS + selected % KEYBOARD_COLS).min(count - 1));
}
Key::Home => self.keyboard = Some(0),
Key::End => self.keyboard = Some(count - 1),
Key::Back | Key::Menu => {
if self.submit {
return Action::Back;
}
self.finish_editing();
}
Key::Search => {
self.keyboard = None;
self.typing = true;
}
Key::Char(c) if !c.is_control() => {
self.keyboard = None;
self.append(c);
}
Key::Backspace => {
self.query.pop();
self.refilter();
}
Key::Select => match KEYBOARD_KEYS[selected] {
"Del" => {
self.query.pop();
self.refilter();
}
"Clear" => self.clear_query(false),
"Done" => {
if self.submit && !self.query.is_empty() {
return Action::Submit(self.query.clone());
}
self.finish_editing();
}
label => {
self.append(if label == "Space" {
' '
} else {
label.chars().next().unwrap()
});
self.typing = false;
}
},
_ => {}
}
Action::None
}
}
#[cfg(test)]
mod tests {
use super::*;
fn browser() -> Browser {
Browser::new(["Zulu", "Shōgun.mkv", "Alpha", "Another"])
}
#[test]
fn selected_item_stays_visible_through_wrap_and_resize() {
let names: Vec<_> = (0..1000).map(|n| format!("file {n}")).collect();
let mut list = Browser::new(names.iter().map(String::as_str));
for _ in 0..11 {
list.handle(Key::Down, 10);
}
assert_eq!(list.window(10), 2..12);
assert_eq!(list.selected(), 11);
assert_eq!(list.window(3), 9..12);
list.handle(Key::Home, 10);
list.handle(Key::Up, 10);
assert!(list.search_focused());
assert_eq!(list.window(10), 0..10);
list.handle(Key::Up, 10);
assert_eq!(list.window(10), 990..1000);
list.handle(Key::Down, 10);
assert_eq!(list.window(10), 0..10);
}
#[test]
fn pages_preserve_position_and_clamp_at_ends() {
let names: Vec<_> = (0..25).map(|n| n.to_string()).collect();
let mut list = Browser::new(names.iter().map(String::as_str));
list.handle(Key::PageDown, 7);
assert_eq!((list.selected(), list.window(7)), (7, 7..14));
list.handle(Key::Next, 7);
assert_eq!((list.selected(), list.window(7)), (14, 14..21));
list.handle(Key::End, 7);
assert_eq!(list.window(7), 18..25);
list.handle(Key::PageDown, 7);
assert_eq!(list.selected(), 24);
list.handle(Key::Home, 7);
list.handle(Key::Prev, 7);
assert_eq!(list.selected(), 0);
}
#[test]
fn up_from_first_result_reaches_search_and_ok_opens_keyboard() {
let mut list = browser();
assert_eq!(list.handle(Key::Up, 2), Action::None);
assert!(list.search_focused());
assert!(list.searching());
assert_eq!(list.keyboard(), None);
assert_eq!(list.handle(Key::Pause, 2), Action::None);
assert_eq!(list.handle(Key::Select, 2), Action::None);
assert!(!list.search_focused());
assert_eq!(list.keyboard(), Some(0));
list.handle(Key::Select, 2);
assert_eq!(list.query(), "a");
list.handle(Key::End, 2);
list.handle(Key::Select, 2);
assert_eq!(list.keyboard(), None);
assert_eq!(list.handle(Key::Select, 2), Action::Open(2));
}
#[test]
fn down_and_back_leave_search_bar_without_clearing_filter() {
for key in [Key::Down, Key::Back] {
let mut list = browser();
list.handle(Key::Char('a'), 2);
list.handle(Key::Up, 2);
assert!(list.search_focused());
assert_eq!(list.handle(key, 2), Action::None);
assert!(!list.searching());
assert_eq!(list.query(), "a");
assert_eq!(list.handle(Key::Select, 2), Action::Open(2));
}
}
#[test]
fn search_bar_is_reachable_with_no_results() {
let mut list = Browser::new([]);
list.handle(Key::Up, 2);
assert!(list.search_focused());
list.handle(Key::Down, 2);
assert!(list.search_focused());
list.handle(Key::Select, 2);
assert_eq!(list.keyboard(), Some(0));
let mut list = browser();
list.handle(Key::Char('x'), 2);
list.handle(Key::Up, 2);
assert!(list.search_focused());
list.handle(Key::Backspace, 2);
assert_eq!(list.query(), "");
assert_eq!(list.count(), 4);
assert_eq!(list.handle(Key::Select, 2), Action::Open(0));
}
#[test]
fn native_typing_from_search_bar_still_opens_filtered_result() {
let mut list = browser();
list.handle(Key::Up, 2);
list.handle(Key::Char('S'), 2);
assert!(!list.search_focused());
assert_eq!(list.query(), "S");
assert_eq!(list.handle(Key::Select, 2), Action::Open(1));
}
#[test]
fn unicode_case_insensitive_filter_opens_original_item() {
let mut list = browser();
for c in "SHŌ".chars() {
list.handle(Key::Char(c), 2);
}
assert_eq!(list.count(), 1);
assert_eq!(list.query(), "SHŌ");
assert_eq!(list.handle(Key::Select, 2), Action::Open(1));
assert!(!list.searching());
assert_eq!(list.count(), 1);
}
#[test]
fn clearing_search_restores_previous_selection_and_window() {
let mut list = browser();
list.handle(Key::End, 2);
assert_eq!(list.window(2), 2..4);
list.handle(Key::Search, 2);
list.handle(Key::Char('z'), 2);
assert_eq!(list.handle(Key::Select, 2), Action::Open(0));
assert_eq!(list.handle(Key::Back, 2), Action::None);
assert_eq!((list.selected(), list.window(2)), (3, 2..4));
assert_eq!(list.handle(Key::Back, 2), Action::Back);
}
#[test]
fn ending_empty_search_does_not_reuse_an_old_selection_anchor() {
for done in [true, false] {
let mut list = browser();
list.handle(Key::End, 2);
list.handle(Key::Menu, 2);
if done {
list.handle(Key::End, 2);
list.handle(Key::Select, 2);
} else {
list.handle(Key::Back, 2);
}
list.handle(Key::Down, 2);
assert_eq!(list.selected(), 0);
list.handle(Key::Char('/'), 2);
list.handle(Key::Char('a'), 2);
list.handle(Key::Back, 2);
assert_eq!(list.selected(), 0);
}
let mut list = browser();
list.handle(Key::End, 2);
list.handle(Key::Search, 2);
assert_eq!(list.handle(Key::Select, 2), Action::Open(3));
list.handle(Key::Down, 2);
list.handle(Key::Char('a'), 2);
list.handle(Key::Back, 2);
assert_eq!(list.selected(), 0);
}
#[test]
fn empty_results_and_unicode_deletion_are_safe() {
let mut list = browser();
for c in "ōx".chars() {
list.handle(Key::Char(c), 0);
}
assert_eq!(list.count(), 0);
assert_eq!(list.window(0), 0..0);
assert_eq!(list.handle(Key::Select, 0), Action::None);
assert!(list.searching());
list.handle(Key::Backspace, 2);
assert_eq!(list.query(), "ō");
assert_eq!(list.count(), 1);
assert_eq!(list.handle(Key::Select, 2), Action::Open(1));
let mut empty = Browser::new([]);
for key in [Key::Up, Key::Down, Key::End, Key::PageDown] {
empty.handle(key, 10);
}
assert_eq!(empty.window(10), 0..0);
}
#[test]
fn remote_keyboard_filters_and_done_returns_to_results() {
let mut list = browser();
list.handle(Key::Menu, 2);
assert_eq!(list.keyboard(), Some(0));
list.handle(Key::Select, 2);
assert_eq!(list.query(), "a");
assert_eq!(list.count(), 2);
assert_eq!(list.handle(Key::Play, 2), Action::None);
list.handle(Key::End, 2);
list.handle(Key::Select, 2);
assert_eq!(list.keyboard(), None);
assert_eq!(list.handle(Key::Select, 2), Action::Open(2));
}
#[test]
fn typing_takes_over_remote_keyboard_without_resuming_playback() {
let mut list = browser();
list.handle(Key::Menu, 2);
list.handle(Key::Char('S'), 2);
assert_eq!(list.keyboard(), None);
assert_eq!(list.query(), "S");
assert_eq!(list.handle(Key::Play, 2), Action::None);
list.handle(Key::Char(' '), 2);
assert_eq!(list.query(), "S ");
list.handle(Key::Back, 2);
assert_eq!(list.handle(Key::Play, 2), Action::Resume);
}
#[test]
fn query_level_submits_typed_text_and_backs_out_when_empty() {
let mut q = Browser::query_input();
assert_eq!(q.keyboard(), Some(0)); q.handle(Key::Select, 2); q.handle(Key::Right, 2);
q.handle(Key::Select, 2); assert_eq!(q.query(), "ab");
q.handle(Key::End, 2);
assert_eq!(q.handle(Key::Select, 2), Action::Submit("ab".into()));
let mut q = Browser::query_input();
q.handle(Key::Char('h'), 2);
q.handle(Key::Char('i'), 2);
assert_eq!(q.keyboard(), None); assert_eq!(q.handle(Key::Select, 2), Action::Submit("hi".into()));
let mut q = Browser::query_input();
q.handle(Key::Back, 2); assert_eq!(q.handle(Key::Back, 2), Action::Back);
}
#[test]
fn query_length_and_keyboard_indices_are_bounded() {
let mut list = browser();
for _ in 0..200 {
list.handle(Key::Char('ō'), 10);
}
assert_eq!(list.query().chars().count(), QUERY_LIMIT);
list.handle(Key::Menu, 2);
for _ in 0..200 {
for key in [Key::Up, Key::Left, Key::Down, Key::Right] {
list.handle(key, 2);
assert!(list.keyboard().unwrap() < KEYBOARD_KEYS.len());
}
}
}
#[test]
fn grid_moves_by_rows_and_windows_whole_rows() {
let names: Vec<String> = (0..10).map(|i| format!("v{i}")).collect();
let mut grid = Browser::new(names.iter().map(String::as_str));
grid.set_columns(4);
assert_eq!(grid.window(8), 0..8);
grid.handle(Key::Right, 8);
grid.handle(Key::Down, 8);
assert_eq!(grid.selected(), 5);
grid.handle(Key::Down, 8); assert_eq!(grid.selected(), 9);
assert_eq!(grid.window(8), 4..10);
grid.handle(Key::Down, 8); assert_eq!(grid.selected(), 1);
assert_eq!(grid.window(8), 0..8);
grid.handle(Key::Left, 8);
grid.handle(Key::Left, 8);
assert_eq!(grid.selected(), 0);
grid.handle(Key::Up, 8); assert!(grid.search_focused());
grid.handle(Key::Down, 8);
grid.selected = 9;
assert_eq!(grid.window(9), 4..10);
}
}