use crossterm::event::{KeyCode, KeyEvent};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ListAction {
Quit,
MoveUp,
MoveDown,
JumpToTop,
JumpToBottom,
PageUp,
PageDown,
ToggleGrouping,
EnterDetail,
EnterSearch,
OpenSortMenu,
OpenFilterMenu,
ShowHelp,
CopyPath,
CopyItemAsLlm,
OpenInEditor,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ListActionContext {
pub has_items: bool,
pub has_selection: bool,
}
impl ListActionContext {
#[cfg(test)]
pub fn empty() -> Self {
Self {
has_items: false,
has_selection: false,
}
}
#[cfg(test)]
pub fn with_selection() -> Self {
Self {
has_items: true,
has_selection: true,
}
}
}
pub fn determine_list_action(key: KeyEvent, ctx: ListActionContext) -> Option<ListAction> {
match key.code {
KeyCode::Char('q') => Some(ListAction::Quit),
KeyCode::Up | KeyCode::Char('k') => Some(ListAction::MoveUp),
KeyCode::Down | KeyCode::Char('j') => Some(ListAction::MoveDown),
KeyCode::Char('g') | KeyCode::Home => Some(ListAction::JumpToTop),
KeyCode::Char('G') | KeyCode::End => Some(ListAction::JumpToBottom),
KeyCode::PageUp => Some(ListAction::PageUp),
KeyCode::PageDown => Some(ListAction::PageDown),
KeyCode::Char('u') => Some(ListAction::ToggleGrouping),
KeyCode::Enter | KeyCode::Right | KeyCode::Char('l') => {
if ctx.has_items && ctx.has_selection {
Some(ListAction::EnterDetail)
} else {
None
}
}
KeyCode::Char('/') => Some(ListAction::EnterSearch),
KeyCode::Char('s') => Some(ListAction::OpenSortMenu),
KeyCode::Char('f') => Some(ListAction::OpenFilterMenu),
KeyCode::Char('?') => Some(ListAction::ShowHelp),
KeyCode::Char('c') => {
if ctx.has_selection {
Some(ListAction::CopyPath)
} else {
None
}
}
KeyCode::Char('C') => {
if ctx.has_selection {
Some(ListAction::CopyItemAsLlm)
} else {
None
}
}
KeyCode::Char('e') | KeyCode::Char('o') => {
if ctx.has_selection {
Some(ListAction::OpenInEditor)
} else {
None
}
}
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::KeyModifiers;
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
#[test]
fn quit_with_q() {
let ctx = ListActionContext::empty();
assert_eq!(
determine_list_action(key(KeyCode::Char('q')), ctx),
Some(ListAction::Quit)
);
}
#[test]
fn quit_works_with_any_context() {
for ctx in [
ListActionContext::empty(),
ListActionContext::with_selection(),
] {
assert_eq!(
determine_list_action(key(KeyCode::Char('q')), ctx),
Some(ListAction::Quit)
);
}
}
#[test]
fn move_up_with_up_arrow() {
let ctx = ListActionContext::with_selection();
assert_eq!(
determine_list_action(key(KeyCode::Up), ctx),
Some(ListAction::MoveUp)
);
}
#[test]
fn move_up_with_k() {
let ctx = ListActionContext::with_selection();
assert_eq!(
determine_list_action(key(KeyCode::Char('k')), ctx),
Some(ListAction::MoveUp)
);
}
#[test]
fn move_down_with_down_arrow() {
let ctx = ListActionContext::with_selection();
assert_eq!(
determine_list_action(key(KeyCode::Down), ctx),
Some(ListAction::MoveDown)
);
}
#[test]
fn move_down_with_j() {
let ctx = ListActionContext::with_selection();
assert_eq!(
determine_list_action(key(KeyCode::Char('j')), ctx),
Some(ListAction::MoveDown)
);
}
#[test]
fn jump_to_top_with_g() {
let ctx = ListActionContext::with_selection();
assert_eq!(
determine_list_action(key(KeyCode::Char('g')), ctx),
Some(ListAction::JumpToTop)
);
}
#[test]
fn toggle_grouping_with_u() {
let ctx = ListActionContext::with_selection();
assert_eq!(
determine_list_action(key(KeyCode::Char('u')), ctx),
Some(ListAction::ToggleGrouping)
);
}
#[test]
fn jump_to_top_with_home() {
let ctx = ListActionContext::with_selection();
assert_eq!(
determine_list_action(key(KeyCode::Home), ctx),
Some(ListAction::JumpToTop)
);
}
#[test]
fn jump_to_bottom_with_end() {
let ctx = ListActionContext::with_selection();
assert_eq!(
determine_list_action(key(KeyCode::End), ctx),
Some(ListAction::JumpToBottom)
);
}
#[test]
fn jump_to_bottom_with_uppercase_g() {
let ctx = ListActionContext::with_selection();
assert_eq!(
determine_list_action(key(KeyCode::Char('G')), ctx),
Some(ListAction::JumpToBottom)
);
}
#[test]
fn page_up_key() {
let ctx = ListActionContext::with_selection();
assert_eq!(
determine_list_action(key(KeyCode::PageUp), ctx),
Some(ListAction::PageUp)
);
}
#[test]
fn page_down_key() {
let ctx = ListActionContext::with_selection();
assert_eq!(
determine_list_action(key(KeyCode::PageDown), ctx),
Some(ListAction::PageDown)
);
}
#[test]
fn enter_detail_requires_selection() {
let empty = ListActionContext::empty();
assert_eq!(determine_list_action(key(KeyCode::Enter), empty), None);
let items_only = ListActionContext {
has_items: true,
has_selection: false,
};
assert_eq!(determine_list_action(key(KeyCode::Enter), items_only), None);
let with_sel = ListActionContext::with_selection();
assert_eq!(
determine_list_action(key(KeyCode::Enter), with_sel),
Some(ListAction::EnterDetail)
);
}
#[test]
fn enter_detail_with_right_arrow() {
let with_sel = ListActionContext::with_selection();
assert_eq!(
determine_list_action(key(KeyCode::Right), with_sel),
Some(ListAction::EnterDetail)
);
let empty = ListActionContext::empty();
assert_eq!(determine_list_action(key(KeyCode::Right), empty), None);
}
#[test]
fn enter_detail_with_l_key() {
let with_sel = ListActionContext::with_selection();
assert_eq!(
determine_list_action(key(KeyCode::Char('l')), with_sel),
Some(ListAction::EnterDetail)
);
let empty = ListActionContext::empty();
assert_eq!(determine_list_action(key(KeyCode::Char('l')), empty), None);
}
#[test]
fn enter_search_with_slash() {
let ctx = ListActionContext::with_selection();
assert_eq!(
determine_list_action(key(KeyCode::Char('/')), ctx),
Some(ListAction::EnterSearch)
);
}
#[test]
fn open_sort_menu_with_s() {
let ctx = ListActionContext::with_selection();
assert_eq!(
determine_list_action(key(KeyCode::Char('s')), ctx),
Some(ListAction::OpenSortMenu)
);
}
#[test]
fn open_filter_menu_with_f() {
let ctx = ListActionContext::with_selection();
assert_eq!(
determine_list_action(key(KeyCode::Char('f')), ctx),
Some(ListAction::OpenFilterMenu)
);
}
#[test]
fn show_help_with_question_mark() {
let ctx = ListActionContext::with_selection();
assert_eq!(
determine_list_action(key(KeyCode::Char('?')), ctx),
Some(ListAction::ShowHelp)
);
}
#[test]
fn copy_path_requires_selection() {
let empty = ListActionContext::empty();
assert_eq!(determine_list_action(key(KeyCode::Char('c')), empty), None);
let with_sel = ListActionContext::with_selection();
assert_eq!(
determine_list_action(key(KeyCode::Char('c')), with_sel),
Some(ListAction::CopyPath)
);
}
#[test]
fn copy_item_as_llm_requires_selection() {
let empty = ListActionContext::empty();
assert_eq!(determine_list_action(key(KeyCode::Char('C')), empty), None);
let with_sel = ListActionContext::with_selection();
assert_eq!(
determine_list_action(key(KeyCode::Char('C')), with_sel),
Some(ListAction::CopyItemAsLlm)
);
}
#[test]
fn open_in_editor_with_e_requires_selection() {
let empty = ListActionContext::empty();
assert_eq!(determine_list_action(key(KeyCode::Char('e')), empty), None);
let with_sel = ListActionContext::with_selection();
assert_eq!(
determine_list_action(key(KeyCode::Char('e')), with_sel),
Some(ListAction::OpenInEditor)
);
}
#[test]
fn open_in_editor_with_o_requires_selection() {
let empty = ListActionContext::empty();
assert_eq!(determine_list_action(key(KeyCode::Char('o')), empty), None);
let with_sel = ListActionContext::with_selection();
assert_eq!(
determine_list_action(key(KeyCode::Char('o')), with_sel),
Some(ListAction::OpenInEditor)
);
}
#[test]
fn unknown_key_returns_none() {
let ctx = ListActionContext::with_selection();
assert_eq!(determine_list_action(key(KeyCode::Char('x')), ctx), None);
assert_eq!(determine_list_action(key(KeyCode::Char('z')), ctx), None);
assert_eq!(determine_list_action(key(KeyCode::F(1)), ctx), None);
}
#[test]
fn determine_action_is_deterministic() {
let ctx = ListActionContext::with_selection();
let k = key(KeyCode::Enter);
let r1 = determine_list_action(k, ctx);
let r2 = determine_list_action(k, ctx);
assert_eq!(r1, r2);
}
#[test]
fn context_affects_guarded_actions() {
let k = key(KeyCode::Enter);
let empty = ListActionContext::empty();
let with_sel = ListActionContext::with_selection();
assert_ne!(
determine_list_action(k, empty),
determine_list_action(k, with_sel)
);
}
#[test]
fn navigation_keys_work_on_empty_list() {
let empty = ListActionContext::empty();
assert_eq!(
determine_list_action(key(KeyCode::Up), empty),
Some(ListAction::MoveUp)
);
assert_eq!(
determine_list_action(key(KeyCode::Down), empty),
Some(ListAction::MoveDown)
);
}
}
#[cfg(test)]
mod property_tests {
use super::*;
use crossterm::event::KeyModifiers;
use proptest::prelude::*;
fn key_code_strategy() -> impl Strategy<Value = KeyCode> {
prop_oneof![
Just(KeyCode::Char('q')),
Just(KeyCode::Char('k')),
Just(KeyCode::Char('j')),
Just(KeyCode::Char('g')),
Just(KeyCode::Char('G')),
Just(KeyCode::Char('u')),
Just(KeyCode::Char('l')),
Just(KeyCode::Char('/')),
Just(KeyCode::Char('s')),
Just(KeyCode::Char('f')),
Just(KeyCode::Char('?')),
Just(KeyCode::Char('c')),
Just(KeyCode::Char('C')),
Just(KeyCode::Char('e')),
Just(KeyCode::Char('o')),
Just(KeyCode::Up),
Just(KeyCode::Down),
Just(KeyCode::Left),
Just(KeyCode::Right),
Just(KeyCode::Home),
Just(KeyCode::End),
Just(KeyCode::PageUp),
Just(KeyCode::PageDown),
Just(KeyCode::Enter),
Just(KeyCode::Esc),
Just(KeyCode::Tab),
]
}
fn context_strategy() -> impl Strategy<Value = ListActionContext> {
(any::<bool>(), any::<bool>()).prop_map(|(has_items, has_selection)| ListActionContext {
has_items,
has_selection: has_items && has_selection, })
}
proptest! {
#[test]
fn quit_always_available(ctx in context_strategy()) {
let key = KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE);
prop_assert_eq!(determine_list_action(key, ctx), Some(ListAction::Quit));
}
#[test]
fn navigation_always_available(ctx in context_strategy()) {
let up_key = KeyEvent::new(KeyCode::Up, KeyModifiers::NONE);
let down_key = KeyEvent::new(KeyCode::Down, KeyModifiers::NONE);
prop_assert_eq!(determine_list_action(up_key, ctx), Some(ListAction::MoveUp));
prop_assert_eq!(determine_list_action(down_key, ctx), Some(ListAction::MoveDown));
}
#[test]
fn detail_keys_require_items_and_selection(ctx in context_strategy()) {
for code in [KeyCode::Enter, KeyCode::Right, KeyCode::Char('l')] {
let key = KeyEvent::new(code, KeyModifiers::NONE);
let result = determine_list_action(key, ctx);
if ctx.has_items && ctx.has_selection {
prop_assert_eq!(result, Some(ListAction::EnterDetail));
} else {
prop_assert_eq!(result, None);
}
}
}
#[test]
fn deterministic(
code in key_code_strategy(),
ctx in context_strategy()
) {
let key = KeyEvent::new(code, KeyModifiers::NONE);
let r1 = determine_list_action(key, ctx);
let r2 = determine_list_action(key, ctx);
prop_assert_eq!(r1, r2);
}
}
}