use super::detail_page::DetailPage;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DetailAction {
NavigateBack,
NextPage,
PrevPage,
JumpToPage(DetailPage),
MoveSelection(i32),
ScrollUp,
ScrollDown,
ScrollHalfPageUp,
ScrollHalfPageDown,
ScrollPageUp,
ScrollPageDown,
ScrollToTop,
ScrollToBottom,
CopyPage,
CopyItemAsLlm,
OpenInEditor,
ShowHelp,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DetailActionContext {
pub current_page: DetailPage,
}
impl DetailActionContext {
#[must_use]
pub fn new(current_page: DetailPage) -> Self {
Self { current_page }
}
}
#[must_use]
pub fn page_from_digit(c: char) -> Option<DetailPage> {
match c {
'1' => Some(DetailPage::Overview),
'2' => Some(DetailPage::ScoreBreakdown),
'3' => Some(DetailPage::Context),
'4' => Some(DetailPage::Dependencies),
'5' => Some(DetailPage::GitContext),
'6' => Some(DetailPage::Patterns),
'7' => Some(DetailPage::DataFlow),
'8' => Some(DetailPage::Responsibilities),
_ => None,
}
}
pub fn classify_detail_key(key: KeyEvent, _ctx: DetailActionContext) -> Option<DetailAction> {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('h') | KeyCode::Backspace => {
Some(DetailAction::NavigateBack)
}
KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => Some(DetailAction::NextPage),
KeyCode::BackTab | KeyCode::Left => Some(DetailAction::PrevPage),
KeyCode::Char(c @ '1'..='8') => page_from_digit(c).map(DetailAction::JumpToPage),
KeyCode::Down | KeyCode::Char('j') if !ctrl => Some(DetailAction::MoveSelection(1)),
KeyCode::Up | KeyCode::Char('k') if !ctrl => Some(DetailAction::MoveSelection(-1)),
KeyCode::Char('d') if ctrl => Some(DetailAction::ScrollHalfPageDown),
KeyCode::Char('u') if ctrl => Some(DetailAction::ScrollHalfPageUp),
KeyCode::Char('f') if ctrl => Some(DetailAction::ScrollPageDown),
KeyCode::Char('b') if ctrl => Some(DetailAction::ScrollPageUp),
KeyCode::PageDown => Some(DetailAction::ScrollPageDown),
KeyCode::PageUp => Some(DetailAction::ScrollPageUp),
KeyCode::Char('g') => Some(DetailAction::ScrollToTop),
KeyCode::Char('G') => Some(DetailAction::ScrollToBottom),
KeyCode::Char('c') if !ctrl => Some(DetailAction::CopyPage),
KeyCode::Char('C') => Some(DetailAction::CopyItemAsLlm),
KeyCode::Char('e') | KeyCode::Char('o') => Some(DetailAction::OpenInEditor),
KeyCode::Char('?') => Some(DetailAction::ShowHelp),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::KeyModifiers;
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
#[test]
fn escape_navigates_back() {
let ctx = DetailActionContext::new(DetailPage::Overview);
assert_eq!(
classify_detail_key(key(KeyCode::Esc), ctx),
Some(DetailAction::NavigateBack)
);
}
#[test]
fn q_navigates_back() {
let ctx = DetailActionContext::new(DetailPage::Overview);
assert_eq!(
classify_detail_key(key(KeyCode::Char('q')), ctx),
Some(DetailAction::NavigateBack)
);
}
#[test]
fn tab_goes_to_next_page() {
let ctx = DetailActionContext::new(DetailPage::Overview);
assert_eq!(
classify_detail_key(key(KeyCode::Tab), ctx),
Some(DetailAction::NextPage)
);
}
#[test]
fn right_arrow_goes_to_next_page() {
let ctx = DetailActionContext::new(DetailPage::Overview);
assert_eq!(
classify_detail_key(key(KeyCode::Right), ctx),
Some(DetailAction::NextPage)
);
}
#[test]
fn l_goes_to_next_page() {
let ctx = DetailActionContext::new(DetailPage::Overview);
assert_eq!(
classify_detail_key(key(KeyCode::Char('l')), ctx),
Some(DetailAction::NextPage)
);
}
#[test]
fn backtab_goes_to_prev_page() {
let ctx = DetailActionContext::new(DetailPage::Context);
assert_eq!(
classify_detail_key(key(KeyCode::BackTab), ctx),
Some(DetailAction::PrevPage)
);
}
#[test]
fn left_arrow_goes_to_prev_page() {
let ctx = DetailActionContext::new(DetailPage::Context);
assert_eq!(
classify_detail_key(key(KeyCode::Left), ctx),
Some(DetailAction::PrevPage)
);
}
#[test]
fn backspace_navigates_back() {
let ctx = DetailActionContext::new(DetailPage::Context);
assert_eq!(
classify_detail_key(key(KeyCode::Backspace), ctx),
Some(DetailAction::NavigateBack)
);
}
#[test]
fn h_navigates_back() {
let ctx = DetailActionContext::new(DetailPage::Context);
assert_eq!(
classify_detail_key(key(KeyCode::Char('h')), ctx),
Some(DetailAction::NavigateBack)
);
}
#[test]
fn number_keys_jump_to_pages() {
let ctx = DetailActionContext::new(DetailPage::Overview);
let expected_pages = [
('1', DetailPage::Overview),
('2', DetailPage::ScoreBreakdown),
('3', DetailPage::Context),
('4', DetailPage::Dependencies),
('5', DetailPage::GitContext),
('6', DetailPage::Patterns),
('7', DetailPage::DataFlow),
('8', DetailPage::Responsibilities),
];
for (digit, expected_page) in expected_pages {
assert_eq!(
classify_detail_key(key(KeyCode::Char(digit)), ctx),
Some(DetailAction::JumpToPage(expected_page)),
"digit '{}' should jump to {:?}",
digit,
expected_page
);
}
}
#[test]
fn page_from_digit_returns_none_for_invalid() {
assert_eq!(page_from_digit('0'), None);
assert_eq!(page_from_digit('9'), None);
assert_eq!(page_from_digit('a'), None);
}
#[test]
fn down_moves_selection_positive() {
let ctx = DetailActionContext::new(DetailPage::Overview);
assert_eq!(
classify_detail_key(key(KeyCode::Down), ctx),
Some(DetailAction::MoveSelection(1))
);
}
#[test]
fn j_moves_selection_positive() {
let ctx = DetailActionContext::new(DetailPage::Overview);
assert_eq!(
classify_detail_key(key(KeyCode::Char('j')), ctx),
Some(DetailAction::MoveSelection(1))
);
}
#[test]
fn up_moves_selection_negative() {
let ctx = DetailActionContext::new(DetailPage::Overview);
assert_eq!(
classify_detail_key(key(KeyCode::Up), ctx),
Some(DetailAction::MoveSelection(-1))
);
}
#[test]
fn k_moves_selection_negative() {
let ctx = DetailActionContext::new(DetailPage::Overview);
assert_eq!(
classify_detail_key(key(KeyCode::Char('k')), ctx),
Some(DetailAction::MoveSelection(-1))
);
}
#[test]
fn c_copies_page_on_all_pages() {
for page in [
DetailPage::Overview,
DetailPage::ScoreBreakdown,
DetailPage::Context,
DetailPage::Dependencies,
DetailPage::GitContext,
DetailPage::Patterns,
DetailPage::DataFlow,
DetailPage::Responsibilities,
] {
let ctx = DetailActionContext::new(page);
assert_eq!(
classify_detail_key(key(KeyCode::Char('c')), ctx),
Some(DetailAction::CopyPage),
"'c' on {:?} should copy page",
page
);
}
}
#[test]
fn uppercase_c_copies_item_as_llm() {
for page in [
DetailPage::Overview,
DetailPage::ScoreBreakdown,
DetailPage::Context,
DetailPage::Dependencies,
DetailPage::GitContext,
DetailPage::Patterns,
DetailPage::DataFlow,
DetailPage::Responsibilities,
] {
let ctx = DetailActionContext::new(page);
assert_eq!(
classify_detail_key(key(KeyCode::Char('C')), ctx),
Some(DetailAction::CopyItemAsLlm),
"'C' on {:?} should copy item as LLM markdown",
page
);
}
}
#[test]
fn e_opens_in_editor() {
let ctx = DetailActionContext::new(DetailPage::Overview);
assert_eq!(
classify_detail_key(key(KeyCode::Char('e')), ctx),
Some(DetailAction::OpenInEditor)
);
}
#[test]
fn o_opens_in_editor() {
let ctx = DetailActionContext::new(DetailPage::Overview);
assert_eq!(
classify_detail_key(key(KeyCode::Char('o')), ctx),
Some(DetailAction::OpenInEditor)
);
}
#[test]
fn question_mark_shows_help() {
let ctx = DetailActionContext::new(DetailPage::Overview);
assert_eq!(
classify_detail_key(key(KeyCode::Char('?')), ctx),
Some(DetailAction::ShowHelp)
);
}
#[test]
fn unknown_keys_return_none() {
let ctx = DetailActionContext::new(DetailPage::Overview);
assert_eq!(classify_detail_key(key(KeyCode::Char('x')), ctx), None);
assert_eq!(classify_detail_key(key(KeyCode::Char('z')), ctx), None);
assert_eq!(classify_detail_key(key(KeyCode::Char('p')), ctx), None);
assert_eq!(classify_detail_key(key(KeyCode::F(1)), ctx), None);
assert_eq!(classify_detail_key(key(KeyCode::Char('0')), ctx), None);
assert_eq!(classify_detail_key(key(KeyCode::Char('9')), ctx), None);
}
#[test]
fn classification_is_deterministic() {
let ctx = DetailActionContext::new(DetailPage::Context);
let k = key(KeyCode::Char('c'));
let r1 = classify_detail_key(k, ctx);
let r2 = classify_detail_key(k, ctx);
assert_eq!(r1, r2);
}
#[test]
fn c_key_consistent_across_pages() {
let k = key(KeyCode::Char('c'));
let context_page = DetailActionContext::new(DetailPage::Context);
let overview_page = DetailActionContext::new(DetailPage::Overview);
assert_eq!(
classify_detail_key(k, context_page),
classify_detail_key(k, overview_page)
);
}
#[test]
fn navigation_keys_work_on_all_pages() {
for page in [
DetailPage::Overview,
DetailPage::ScoreBreakdown,
DetailPage::Context,
DetailPage::Dependencies,
DetailPage::GitContext,
DetailPage::Patterns,
DetailPage::DataFlow,
DetailPage::Responsibilities,
] {
let ctx = DetailActionContext::new(page);
assert_eq!(
classify_detail_key(key(KeyCode::Esc), ctx),
Some(DetailAction::NavigateBack),
"Esc should work on {:?}",
page
);
assert_eq!(
classify_detail_key(key(KeyCode::Backspace), ctx),
Some(DetailAction::NavigateBack),
"Backspace should navigate back on {:?}",
page
);
assert_eq!(
classify_detail_key(key(KeyCode::Char('h')), ctx),
Some(DetailAction::NavigateBack),
"'h' should navigate back on {:?}",
page
);
assert_eq!(
classify_detail_key(key(KeyCode::Tab), ctx),
Some(DetailAction::NextPage),
"Tab should work on {:?}",
page
);
assert_eq!(
classify_detail_key(key(KeyCode::Right), ctx),
Some(DetailAction::NextPage),
"Right should go to next page on {:?}",
page
);
assert_eq!(
classify_detail_key(key(KeyCode::BackTab), ctx),
Some(DetailAction::PrevPage),
"BackTab should work on {:?}",
page
);
assert_eq!(
classify_detail_key(key(KeyCode::Left), ctx),
Some(DetailAction::PrevPage),
"Left should go to prev page on {:?}",
page
);
}
}
}
#[cfg(test)]
mod property_tests {
use super::*;
use crossterm::event::KeyModifiers;
use proptest::prelude::*;
fn detail_page_strategy() -> impl Strategy<Value = DetailPage> {
prop_oneof![
Just(DetailPage::Overview),
Just(DetailPage::ScoreBreakdown),
Just(DetailPage::Context),
Just(DetailPage::Dependencies),
Just(DetailPage::GitContext),
Just(DetailPage::Patterns),
Just(DetailPage::DataFlow),
Just(DetailPage::Responsibilities),
]
}
fn key_code_strategy() -> impl Strategy<Value = KeyCode> {
prop_oneof![
Just(KeyCode::Esc),
Just(KeyCode::Char('q')),
Just(KeyCode::Backspace),
Just(KeyCode::Tab),
Just(KeyCode::BackTab),
Just(KeyCode::Right),
Just(KeyCode::Left),
Just(KeyCode::Char('l')),
Just(KeyCode::Char('h')),
Just(KeyCode::Char('j')),
Just(KeyCode::Char('k')),
Just(KeyCode::Up),
Just(KeyCode::Down),
Just(KeyCode::Char('c')),
Just(KeyCode::Char('C')), Just(KeyCode::Char('e')),
Just(KeyCode::Char('o')),
Just(KeyCode::Char('?')),
Just(KeyCode::Char('1')),
Just(KeyCode::Char('2')),
Just(KeyCode::Char('3')),
Just(KeyCode::Char('4')),
Just(KeyCode::Char('5')),
Just(KeyCode::Char('6')),
Just(KeyCode::Char('7')),
Just(KeyCode::Char('8')),
]
}
proptest! {
#[test]
fn navigation_always_available(page in detail_page_strategy()) {
let ctx = DetailActionContext::new(page);
let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
let backspace = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE);
let h = KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE);
let tab = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE);
let right = KeyEvent::new(KeyCode::Right, KeyModifiers::NONE);
let backtab = KeyEvent::new(KeyCode::BackTab, KeyModifiers::NONE);
let left = KeyEvent::new(KeyCode::Left, KeyModifiers::NONE);
prop_assert_eq!(classify_detail_key(esc, ctx), Some(DetailAction::NavigateBack));
prop_assert_eq!(classify_detail_key(backspace, ctx), Some(DetailAction::NavigateBack));
prop_assert_eq!(classify_detail_key(h, ctx), Some(DetailAction::NavigateBack));
prop_assert_eq!(classify_detail_key(tab, ctx), Some(DetailAction::NextPage));
prop_assert_eq!(classify_detail_key(right, ctx), Some(DetailAction::NextPage));
prop_assert_eq!(classify_detail_key(backtab, ctx), Some(DetailAction::PrevPage));
prop_assert_eq!(classify_detail_key(left, ctx), Some(DetailAction::PrevPage));
}
#[test]
fn movement_always_available(page in detail_page_strategy()) {
let ctx = DetailActionContext::new(page);
let up = KeyEvent::new(KeyCode::Up, KeyModifiers::NONE);
let down = KeyEvent::new(KeyCode::Down, KeyModifiers::NONE);
prop_assert_eq!(classify_detail_key(up, ctx), Some(DetailAction::MoveSelection(-1)));
prop_assert_eq!(classify_detail_key(down, ctx), Some(DetailAction::MoveSelection(1)));
}
#[test]
fn c_always_copies_page(page in detail_page_strategy()) {
let ctx = DetailActionContext::new(page);
let c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE);
let action = classify_detail_key(c, ctx);
prop_assert_eq!(action, Some(DetailAction::CopyPage));
}
#[test]
fn uppercase_c_always_copies_llm(page in detail_page_strategy()) {
let ctx = DetailActionContext::new(page);
let big_c = KeyEvent::new(KeyCode::Char('C'), KeyModifiers::NONE);
let action = classify_detail_key(big_c, ctx);
prop_assert_eq!(action, Some(DetailAction::CopyItemAsLlm));
}
#[test]
fn deterministic(
code in key_code_strategy(),
page in detail_page_strategy()
) {
let ctx = DetailActionContext::new(page);
let key = KeyEvent::new(code, KeyModifiers::NONE);
let r1 = classify_detail_key(key, ctx);
let r2 = classify_detail_key(key, ctx);
prop_assert_eq!(r1, r2);
}
#[test]
fn number_keys_jump_to_page(
digit in prop_oneof![
Just('1'), Just('2'), Just('3'), Just('4'),
Just('5'), Just('6'), Just('7'), Just('8')
],
page in detail_page_strategy()
) {
let ctx = DetailActionContext::new(page);
let key = KeyEvent::new(KeyCode::Char(digit), KeyModifiers::NONE);
let action = classify_detail_key(key, ctx);
prop_assert!(
matches!(action, Some(DetailAction::JumpToPage(_))),
"digit {} should produce JumpToPage action",
digit
);
}
}
}