use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
use crate::app::App;
use crate::types::{ClickTarget, InputMode, Overlay, Panel, ViewState};
pub enum Action {
Quit,
#[allow(dead_code)] NextPanel,
ToggleTerminal,
ToggleSidebar,
ToggleFilesPanel,
CloseFile,
SubmitInput,
InsertChar(char),
DeleteChar,
MoveCursorLeft,
MoveCursorRight,
HistoryUp,
HistoryDown,
TabComplete,
ScrollUp,
ScrollDown,
ScrollHalfPageUp,
ScrollHalfPageDown,
ScrollToTop,
ScrollToBottom,
EnterInsertMode,
EnterNormalMode,
EnterVisualMode,
EnterCommandMode,
EnterColonMode,
SelectionUp,
SelectionDown,
SendSelectionToAi,
AcceptDiff,
RejectDiff,
ToggleExpand,
OpenFile,
ShowCommandPalette,
ShowFilePicker,
ShowHelp,
FocusPanel(Panel),
#[allow(dead_code)] GotoLine,
SwitchView(ViewState),
ToggleMode,
StartScan,
WatchToggle,
#[allow(dead_code)] ShowThemePicker,
CodeSearch,
CodeSearchNext,
CodeSearchPrev,
Undo,
ShowUndoHistory,
ClickAt(ClickTarget),
ScrollLines(i32),
ViewKey(char),
ViewEnter,
ViewEscape,
None,
}
pub fn handle_key_event(key: KeyEvent, app: &App) -> Action {
if key.modifiers.contains(KeyModifiers::CONTROL) {
match key.code {
KeyCode::Char('c') => return Action::Quit,
KeyCode::Char('t') => return Action::ToggleTerminal,
KeyCode::Char('b') => return Action::ToggleSidebar,
KeyCode::Char('f') => return Action::ToggleFilesPanel,
KeyCode::Char('p') => return Action::ShowCommandPalette,
KeyCode::Char('s') => return Action::StartScan,
KeyCode::Char('k') if app.input_mode == InputMode::Visual => {
return Action::SendSelectionToAi;
}
KeyCode::Char('z') => return Action::Undo,
KeyCode::Char('d') => return Action::ScrollHalfPageDown,
KeyCode::Char('u') => return Action::ScrollHalfPageUp,
_ => {}
}
}
if key.modifiers.contains(KeyModifiers::ALT) {
match key.code {
KeyCode::Char('1') => return Action::FocusPanel(Panel::Chat),
KeyCode::Char('2') => return Action::FocusPanel(Panel::Score),
KeyCode::Char('3') => return Action::FocusPanel(Panel::FileBrowser),
KeyCode::Char('4') => return Action::FocusPanel(Panel::CodeViewer),
KeyCode::Char('5') => return Action::FocusPanel(Panel::Terminal),
_ => {}
}
}
if app.overlay != Overlay::None {
return handle_overlay_keys(key, app);
}
match app.input_mode {
InputMode::Insert => handle_insert_mode(key),
InputMode::Normal => handle_normal_mode(key, app),
InputMode::Command => handle_command_mode(key),
InputMode::Visual => handle_visual_mode(key),
}
}
pub fn handle_mouse_event(event: MouseEvent, app: &App) -> Action {
match event.kind {
MouseEventKind::ScrollUp => {
let lines = scroll_line_count(app);
Action::ScrollLines(-lines)
}
MouseEventKind::ScrollDown => {
let lines = scroll_line_count(app);
Action::ScrollLines(lines)
}
MouseEventKind::Down(MouseButton::Left) => {
let col = event.column;
let row = event.row;
for (rect, target) in &app.click_areas {
if col >= rect.x
&& col < rect.x + rect.width
&& row >= rect.y
&& row < rect.y + rect.height
{
return Action::ClickAt(target.clone());
}
}
Action::None
}
_ => Action::None,
}
}
fn scroll_line_count(app: &App) -> i32 {
let now = std::time::Instant::now();
let recent = app
.scroll_events
.iter()
.filter(|&&t| now.duration_since(t).as_millis() < 300)
.count();
if recent >= 3 {
let accel = app.config.scroll_acceleration;
(accel * 3.0) as i32
} else {
1
}
}
#[cfg(test)]
pub fn scroll_line_count_for_test(app: &App) -> i32 {
scroll_line_count(app)
}
fn handle_overlay_keys(key: KeyEvent, app: &App) -> Action {
let overlay = &app.overlay;
let onboarding_text_mode = app
.onboarding
.as_ref()
.is_some_and(|wiz| wiz.provider_substep == 1);
let navigable = !onboarding_text_mode
&& matches!(
overlay,
Overlay::ThemePicker
| Overlay::Onboarding
| Overlay::DismissModal
| Overlay::ConfirmDialog
| Overlay::UndoHistory
| Overlay::CommandPalette
| Overlay::LlmSettings
);
match key.code {
KeyCode::Esc => Action::EnterNormalMode,
KeyCode::Enter => Action::SubmitInput,
KeyCode::Char('j') | KeyCode::Down if navigable => Action::ScrollDown,
KeyCode::Char('k') | KeyCode::Up if navigable => Action::ScrollUp,
KeyCode::Char(c) => Action::InsertChar(c),
KeyCode::Backspace => Action::DeleteChar,
_ => Action::None,
}
}
const fn handle_insert_mode(key: KeyEvent) -> Action {
match key.code {
KeyCode::Enter if key.modifiers.contains(KeyModifiers::SHIFT) => Action::InsertChar('\n'),
KeyCode::Char('j') if key.modifiers.contains(KeyModifiers::CONTROL) => {
Action::InsertChar('\n')
}
KeyCode::Enter => Action::SubmitInput,
KeyCode::Char(c) => Action::InsertChar(c),
KeyCode::Backspace => Action::DeleteChar,
KeyCode::Left => Action::MoveCursorLeft,
KeyCode::Right => Action::MoveCursorRight,
KeyCode::Up => Action::HistoryUp,
KeyCode::Down => Action::HistoryDown,
KeyCode::Esc => Action::EnterNormalMode,
KeyCode::Tab => Action::TabComplete,
_ => Action::None,
}
}
fn handle_normal_mode(key: KeyEvent, app: &App) -> Action {
match key.code {
KeyCode::Char('q') => Action::Quit,
KeyCode::Tab => Action::ToggleMode,
KeyCode::Char('i') => Action::EnterInsertMode,
KeyCode::Char('/') if app.active_panel == Panel::CodeViewer => Action::CodeSearch,
KeyCode::Char('/') => Action::EnterCommandMode,
KeyCode::Char('n')
if app.active_panel == Panel::CodeViewer && app.code_search_query.is_some() =>
{
Action::CodeSearchNext
}
KeyCode::Char('N')
if app.active_panel == Panel::CodeViewer && app.code_search_query.is_some() =>
{
Action::CodeSearchPrev
}
KeyCode::Char('j') | KeyCode::Down => Action::ScrollDown,
KeyCode::Char('k') | KeyCode::Up => Action::ScrollUp,
KeyCode::Char('g') if app.view_state != ViewState::Passport => Action::ScrollToTop,
KeyCode::Char('G') => Action::ScrollToBottom,
KeyCode::Char('v' | 'V') => Action::EnterVisualMode,
KeyCode::Char(':') => Action::EnterColonMode,
KeyCode::Char('U') => Action::ShowUndoHistory,
KeyCode::Char('w') => Action::WatchToggle,
KeyCode::Char('?') => Action::ShowHelp,
KeyCode::Char('@') => Action::ShowFilePicker,
KeyCode::Char(c @ ('C' | 'D' | 'F' | 'L' | 'O' | 'P' | 'R' | 'S' | 'T')) => {
if let Some(view) = ViewState::from_letter(c) {
Action::SwitchView(view)
} else {
Action::None
}
}
KeyCode::Enter => match app.active_panel {
Panel::FileBrowser => Action::OpenFile,
_ if matches!(
app.view_state,
ViewState::Scan
| ViewState::Fix
| ViewState::Passport
| ViewState::Obligations
| ViewState::Report
) =>
{
Action::ViewEnter
}
_ => Action::SubmitInput,
},
KeyCode::Char(' ') if app.view_state == ViewState::Fix => Action::ViewKey(' '),
KeyCode::Char(' ') if app.active_panel == Panel::FileBrowser => Action::ToggleExpand,
KeyCode::Char('y') if app.active_panel == Panel::DiffPreview => Action::AcceptDiff,
KeyCode::Char('n') if app.active_panel == Panel::DiffPreview => Action::RejectDiff,
KeyCode::Backspace if app.active_panel == Panel::CodeViewer => Action::CloseFile,
KeyCode::Esc
if matches!(
app.view_state,
ViewState::Scan
| ViewState::Fix
| ViewState::Dashboard
| ViewState::Passport
| ViewState::Obligations
| ViewState::Report
| ViewState::Timeline
| ViewState::Log
| ViewState::Chat
) =>
{
Action::ViewEscape
}
KeyCode::Esc if app.active_panel == Panel::CodeViewer => Action::CloseFile,
KeyCode::Char(
c @ ('a' | 'c' | 'h' | 'm' | 'l' | 'f' | 'd' | 'e' | 'g' | 'n' | 'p' | 'x' | 'o' | '<'
| '>'),
) if matches!(
app.view_state,
ViewState::Scan
| ViewState::Fix
| ViewState::Report
| ViewState::Dashboard
| ViewState::Passport
| ViewState::Obligations
| ViewState::Timeline
| ViewState::Log
| ViewState::Chat
) =>
{
Action::ViewKey(c)
}
KeyCode::Char(c @ ('1'..='9')) if app.view_state == ViewState::Report => Action::ViewKey(c),
_ => Action::None,
}
}
const fn handle_command_mode(key: KeyEvent) -> Action {
match key.code {
KeyCode::Enter => Action::SubmitInput,
KeyCode::Char(c) => Action::InsertChar(c),
KeyCode::Backspace => Action::DeleteChar,
KeyCode::Esc => Action::EnterNormalMode,
KeyCode::Tab => Action::TabComplete,
_ => Action::None,
}
}
const fn handle_visual_mode(key: KeyEvent) -> Action {
match key.code {
KeyCode::Esc => Action::EnterNormalMode,
KeyCode::Char('j') | KeyCode::Down => Action::SelectionDown,
KeyCode::Char('k') | KeyCode::Up => Action::SelectionUp,
KeyCode::Char('y') => Action::AcceptDiff,
KeyCode::Char('n') => Action::RejectDiff,
_ => Action::None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
#[test]
fn test_watch_key_w_action() {
let app = App::new(crate::config::TuiConfig::default());
let mut test_app = app;
test_app.input_mode = InputMode::Normal;
let action = handle_key_event(key(KeyCode::Char('w')), &test_app);
assert!(matches!(action, Action::WatchToggle));
}
#[test]
fn test_theme_picker_overlay_jk_navigation() {
let mut app = App::new(crate::config::TuiConfig::default());
app.overlay = Overlay::ThemePicker;
let action_j = handle_key_event(key(KeyCode::Char('j')), &app);
assert!(matches!(action_j, Action::ScrollDown));
let action_k = handle_key_event(key(KeyCode::Char('k')), &app);
assert!(matches!(action_k, Action::ScrollUp));
let action_down = handle_key_event(key(KeyCode::Down), &app);
assert!(matches!(action_down, Action::ScrollDown));
let action_up = handle_key_event(key(KeyCode::Up), &app);
assert!(matches!(action_up, Action::ScrollUp));
}
#[test]
fn test_onboarding_overlay_jk_navigation() {
let mut app = App::new(crate::config::TuiConfig::default());
app.overlay = Overlay::Onboarding;
let action_j = handle_key_event(key(KeyCode::Char('j')), &app);
assert!(matches!(action_j, Action::ScrollDown));
let action_k = handle_key_event(key(KeyCode::Char('k')), &app);
assert!(matches!(action_k, Action::ScrollUp));
}
#[test]
fn test_non_navigable_overlay_jk_inserts() {
let mut app = App::new(crate::config::TuiConfig::default());
app.overlay = Overlay::FilePicker;
let action_j = handle_key_event(key(KeyCode::Char('j')), &app);
assert!(matches!(action_j, Action::InsertChar('j')));
}
#[test]
fn test_command_palette_jk_navigates() {
let mut app = App::new(crate::config::TuiConfig::default());
app.overlay = Overlay::CommandPalette;
let action_j = handle_key_event(key(KeyCode::Char('j')), &app);
assert!(matches!(action_j, Action::ScrollDown));
let action_k = handle_key_event(key(KeyCode::Char('k')), &app);
assert!(matches!(action_k, Action::ScrollUp));
}
#[test]
fn test_shift_m_no_op_in_normal_mode() {
let mut app = App::new(crate::config::TuiConfig::default());
app.input_mode = InputMode::Normal;
let action = handle_key_event(key(KeyCode::Char('M')), &app);
assert!(matches!(action, Action::None));
}
}