use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::tui::app::{App, Focus, Mode};
use crate::tui::guards::{evaluate, Guard};
const PAGE_SIZE: usize = 10;
pub fn handle_key(app: &mut App, event: KeyEvent, list_len: usize) -> bool {
if app.mode == Mode::About {
match event.code {
KeyCode::Char('?' | 'q') | KeyCode::F(1) | KeyCode::Esc => {
app.close_about();
}
KeyCode::Char('j') | KeyCode::Down => app.scroll_detail_down(),
KeyCode::Char('k') | KeyCode::Up => app.scroll_detail_up(),
_ => {}
}
return false;
}
if app.mode == Mode::Search {
match event.code {
KeyCode::Esc => app.exit_search_clear(),
KeyCode::Char(c) => app.search_push(c),
KeyCode::Backspace => app.search_pop(),
KeyCode::Enter => app.exit_search_keep(),
KeyCode::Up => app.move_up(),
KeyCode::Down => app.move_down(list_len),
_ => {}
}
return false;
}
match (event.code, event.modifiers) {
(KeyCode::Char('q' | 'Q'), KeyModifiers::NONE) => return true,
(KeyCode::Char('?') | KeyCode::F(1), KeyModifiers::NONE) => {
app.open_about();
}
(KeyCode::Char('/'), KeyModifiers::NONE) => app.enter_search_mode(),
(KeyCode::Char('j') | KeyCode::Down, KeyModifiers::NONE) => {
app.move_down(list_len);
}
(KeyCode::Char('k') | KeyCode::Up, KeyModifiers::NONE) => {
app.move_up();
}
(KeyCode::Char('g') | KeyCode::Home, KeyModifiers::NONE) => {
app.move_to_top();
}
(KeyCode::Char('G') | KeyCode::End, KeyModifiers::NONE) => {
app.move_to_bottom(list_len);
}
(KeyCode::PageDown, KeyModifiers::NONE)
| (KeyCode::Char('d' | 'f'), KeyModifiers::CONTROL) => app.page_down(list_len, PAGE_SIZE),
(KeyCode::PageUp, KeyModifiers::NONE)
| (KeyCode::Char('u' | 'b'), KeyModifiers::CONTROL) => app.page_up(PAGE_SIZE),
(KeyCode::Char('J'), KeyModifiers::NONE) => {
if let Some(r) = evaluate(&[Guard::DetailFocused], app, list_len) {
app.flash(r);
} else {
app.scroll_detail_down();
}
}
(KeyCode::Char('K'), KeyModifiers::NONE) => {
if let Some(r) = evaluate(&[Guard::DetailFocused], app, list_len) {
app.flash(r);
} else {
app.scroll_detail_up();
}
}
(KeyCode::Tab, KeyModifiers::NONE) => {
if app.focus == Focus::List {
app.focus_detail();
} else {
app.focus_list();
}
}
(KeyCode::Char('l') | KeyCode::Right, KeyModifiers::NONE) => {
app.focus_detail();
}
(KeyCode::Char('h') | KeyCode::Left, KeyModifiers::NONE) => {
app.focus_list();
}
(KeyCode::Char('f'), KeyModifiers::NONE) => app.toggle_detail_fullscreen(),
(KeyCode::Char('p'), KeyModifiers::NONE) => app.cycle_platform_filter(),
(KeyCode::Char('t'), KeyModifiers::NONE) => {
if let Some(r) = evaluate(&[Guard::NotInSearchMode], app, list_len) {
app.flash(r);
} else {
let next = (app.dataset_idx + 1) % App::DATASET_COUNT;
app.switch_dataset(next);
}
}
(KeyCode::Char('s'), KeyModifiers::NONE) => {
if let Some(r) = evaluate(&[Guard::NotInSearchMode], app, list_len) {
app.flash(r);
} else {
app.cycle_crit_filter();
}
}
_ => {}
}
false
}
pub fn handle_mouse(app: &mut App, mouse: crossterm::event::MouseEvent, list_len: usize) {
use crossterm::event::{MouseButton, MouseEventKind};
match mouse.kind {
MouseEventKind::ScrollDown => app.move_down(list_len),
MouseEventKind::ScrollUp => app.move_up(),
MouseEventKind::Down(MouseButton::Left) => {
if mouse.row == 0 {
if mouse.column < 19 {
} else if mouse.column < 35 {
app.cycle_dataset();
} else if mouse.column < 47 {
app.cycle_platform_filter();
} else {
app.cycle_crit_filter();
}
} else if mouse.row >= 2 {
let idx = mouse.row as usize - 2;
if idx < list_len {
app.selected = idx;
}
}
}
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tui::app::App;
fn app() -> App {
App::new()
}
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn ctrl_key(c: char) -> KeyEvent {
KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)
}
#[test]
fn q_returns_true() {
let mut a = app();
assert!(handle_key(&mut a, key(KeyCode::Char('q')), 10));
}
#[test]
fn shift_q_returns_true() {
let mut a = app();
assert!(handle_key(&mut a, key(KeyCode::Char('Q')), 10));
}
#[test]
fn other_key_returns_false() {
let mut a = app();
assert!(!handle_key(&mut a, key(KeyCode::Char('j')), 10));
}
#[test]
fn question_mark_opens_about() {
let mut a = app();
handle_key(&mut a, key(KeyCode::Char('?')), 10);
assert_eq!(a.mode, Mode::About);
}
#[test]
fn f1_opens_about() {
let mut a = app();
handle_key(&mut a, key(KeyCode::F(1)), 10);
assert_eq!(a.mode, Mode::About);
}
#[test]
fn esc_closes_about() {
let mut a = app();
a.open_about();
handle_key(&mut a, key(KeyCode::Esc), 10);
assert_eq!(a.mode, Mode::Normal);
}
#[test]
fn q_closes_about_without_quitting() {
let mut a = app();
a.open_about();
let quit = handle_key(&mut a, key(KeyCode::Char('q')), 10);
assert!(!quit, "q in about modal should close, not quit");
assert_eq!(a.mode, Mode::Normal);
}
#[test]
fn slash_enters_search_mode() {
let mut a = app();
handle_key(&mut a, key(KeyCode::Char('/')), 10);
assert_eq!(a.mode, Mode::Search);
}
#[test]
fn esc_exits_search_mode_and_clears_query() {
let mut a = app();
a.enter_search_mode();
a.search_push('p');
handle_key(&mut a, key(KeyCode::Esc), 10);
assert_eq!(a.mode, Mode::Normal);
assert_eq!(a.search_query, "");
}
#[test]
fn enter_exits_search_mode_keeping_query() {
let mut a = app();
a.enter_search_mode();
a.search_push('p');
handle_key(&mut a, key(KeyCode::Enter), 10);
assert_eq!(a.mode, Mode::Normal);
assert_eq!(a.search_query, "p");
}
#[test]
fn char_in_search_mode_appends_to_query() {
let mut a = app();
a.enter_search_mode();
handle_key(&mut a, key(KeyCode::Char('p')), 10);
handle_key(&mut a, key(KeyCode::Char('f')), 10);
assert_eq!(a.search_query, "pf");
}
#[test]
fn backspace_in_search_pops_char() {
let mut a = app();
a.enter_search_mode();
a.search_push('p');
a.search_push('f');
handle_key(&mut a, key(KeyCode::Backspace), 10);
assert_eq!(a.search_query, "p");
}
#[test]
fn j_moves_down() {
let mut a = app();
handle_key(&mut a, key(KeyCode::Char('j')), 10);
assert_eq!(a.selected, 1);
}
#[test]
fn k_at_top_stays_at_zero() {
let mut a = app();
a.selected = 0;
handle_key(&mut a, key(KeyCode::Char('k')), 10);
assert_eq!(a.selected, 0);
}
#[test]
fn g_jumps_to_top() {
let mut a = app();
a.selected = 5;
handle_key(&mut a, key(KeyCode::Char('g')), 10);
assert_eq!(a.selected, 0);
}
#[test]
fn capital_g_jumps_to_bottom() {
let mut a = app();
handle_key(&mut a, key(KeyCode::Char('G')), 20);
assert_eq!(a.selected, 19);
}
#[test]
fn dataset_switch_blocked_in_search_mode() {
let mut a = app();
a.enter_search_mode();
let before_ds = a.dataset_idx;
handle_key(&mut a, key(KeyCode::Char('t')), 10);
assert_eq!(
a.dataset_idx, before_ds,
"dataset should not change in search mode"
);
}
#[test]
fn tab_toggles_focus() {
let mut a = app();
handle_key(&mut a, key(KeyCode::Tab), 10);
assert_eq!(a.focus, Focus::Detail);
}
#[test]
fn tab_toggles_focus_back() {
let mut a = app();
handle_key(&mut a, key(KeyCode::Tab), 10);
handle_key(&mut a, key(KeyCode::Tab), 10);
assert_eq!(a.focus, Focus::List);
}
#[test]
fn l_moves_focus_to_detail() {
let mut a = app();
handle_key(&mut a, key(KeyCode::Char('l')), 10);
assert_eq!(a.focus, Focus::Detail);
}
#[test]
fn h_moves_focus_to_list() {
let mut a = app();
a.focus_detail();
handle_key(&mut a, key(KeyCode::Char('h')), 10);
assert_eq!(a.focus, Focus::List);
}
#[test]
fn ctrl_f_pages_down() {
let mut a = app();
handle_key(&mut a, ctrl_key('f'), 100);
assert_eq!(a.selected, PAGE_SIZE);
}
#[test]
fn ctrl_b_pages_up() {
let mut a = app();
a.selected = 20;
handle_key(&mut a, ctrl_key('b'), 100);
assert_eq!(a.selected, 10);
}
#[test]
fn ctrl_b_clamps_at_zero() {
let mut a = app();
a.selected = 3;
handle_key(&mut a, ctrl_key('b'), 100);
assert_eq!(a.selected, 0);
}
#[test]
fn p_once_activates_windows_all_filter() {
use crate::tui::app::WinVersionFilter;
use forensicnomicon::catalog::Platform;
let mut a = app();
handle_key(&mut a, key(KeyCode::Char('p')), 10);
assert!(!a.platform_mask.is_empty());
assert!(a.platform_mask.contains(Platform::Windows));
assert_eq!(a.win_version, WinVersionFilter::All);
}
#[test]
fn p_twice_cycles_to_win10plus() {
use crate::tui::app::WinVersionFilter;
let mut a = app();
handle_key(&mut a, key(KeyCode::Char('p')), 10);
handle_key(&mut a, key(KeyCode::Char('p')), 10);
assert_eq!(a.win_version, WinVersionFilter::Win10Plus);
}
#[test]
fn p_three_times_cycles_to_win11plus() {
use crate::tui::app::WinVersionFilter;
let mut a = app();
for _ in 0..3 {
handle_key(&mut a, key(KeyCode::Char('p')), 10);
}
assert_eq!(a.win_version, WinVersionFilter::Win11Plus);
}
#[test]
fn p_four_times_cycles_to_macos() {
use forensicnomicon::catalog::Platform;
let mut a = app();
for _ in 0..4 {
handle_key(&mut a, key(KeyCode::Char('p')), 10);
}
assert!(a.platform_mask.contains(Platform::MacOS));
assert!(!a.platform_mask.contains(Platform::Windows));
}
#[test]
fn p_five_times_cycles_to_linux() {
use forensicnomicon::catalog::Platform;
let mut a = app();
for _ in 0..5 {
handle_key(&mut a, key(KeyCode::Char('p')), 10);
}
assert!(a.platform_mask.contains(Platform::Linux));
}
#[test]
fn p_six_times_cycles_back_to_off() {
let mut a = app();
for _ in 0..6 {
handle_key(&mut a, key(KeyCode::Char('p')), 10);
}
assert!(a.platform_mask.is_empty(), "sixth press must clear filter");
}
#[test]
fn s_key_cycles_severity_filter_from_all_to_critical() {
use crate::tui::app::CritFilter;
let mut a = app();
assert_eq!(a.crit_filter, CritFilter::All);
handle_key(&mut a, key(KeyCode::Char('s')), 10);
assert_eq!(a.crit_filter, CritFilter::Critical);
}
#[test]
fn s_key_cycles_severity_filter_three_times() {
use crate::tui::app::CritFilter;
let mut a = app();
handle_key(&mut a, key(KeyCode::Char('s')), 10);
handle_key(&mut a, key(KeyCode::Char('s')), 10);
handle_key(&mut a, key(KeyCode::Char('s')), 10);
assert_eq!(a.crit_filter, CritFilter::Medium);
}
#[test]
fn s_key_not_dispatched_in_search_mode() {
use crate::tui::app::CritFilter;
let mut a = app();
a.enter_search_mode();
handle_key(&mut a, key(KeyCode::Char('s')), 10);
assert_eq!(
a.crit_filter,
CritFilter::All,
"severity filter must not change in search mode"
);
assert!(
a.search_query.contains('s'),
"s must be added to search query"
);
}
#[test]
fn mouse_scroll_down_moves_down() {
use crossterm::event::{MouseEvent, MouseEventKind};
let mut a = app();
handle_mouse(
&mut a,
MouseEvent {
kind: MouseEventKind::ScrollDown,
column: 0,
row: 5,
modifiers: KeyModifiers::NONE,
},
10,
);
assert_eq!(a.selected, 1);
}
#[test]
fn mouse_scroll_up_moves_up() {
use crossterm::event::{MouseEvent, MouseEventKind};
let mut a = app();
a.selected = 5;
handle_mouse(
&mut a,
MouseEvent {
kind: MouseEventKind::ScrollUp,
column: 0,
row: 5,
modifiers: KeyModifiers::NONE,
},
10,
);
assert_eq!(a.selected, 4);
}
#[test]
fn mouse_left_click_list_row_selects_item() {
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
let mut a = app();
handle_mouse(
&mut a,
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 10,
row: 4,
modifiers: KeyModifiers::NONE,
},
10,
);
assert_eq!(a.selected, 2);
}
#[test]
fn mouse_left_click_dataset_area_cycles_dataset() {
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
let mut a = app();
assert_eq!(a.dataset_idx, 0);
handle_mouse(
&mut a,
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 25,
row: 0,
modifiers: KeyModifiers::NONE,
},
10,
);
assert_eq!(a.dataset_idx, 1, "click on dataset area must cycle dataset");
}
#[test]
fn mouse_left_click_title_area_is_ignored() {
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
let mut a = app();
handle_mouse(
&mut a,
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 5,
row: 0,
modifiers: KeyModifiers::NONE,
},
10,
);
assert_eq!(a.dataset_idx, 0, "click on title must not change dataset");
assert!(
a.platform_mask.is_empty(),
"click on title must not change platform"
);
assert_eq!(
a.crit_filter,
crate::tui::app::CritFilter::All,
"click on title must not change crit"
);
}
#[test]
fn mouse_left_click_out_of_bounds_does_not_panic() {
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
let mut a = app();
handle_mouse(
&mut a,
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 10,
row: 100,
modifiers: KeyModifiers::NONE,
},
5, );
assert_eq!(a.selected, 0);
}
}