use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use super::config::run_palette_config;
use super::palette::{
apply_palette_completion, is_palette_open_shortcut, is_palette_toggle,
run_palette_local_command, run_palette_sync,
};
use super::*;
pub(super) enum LoopAction {
Continue,
Exit,
Restart,
}
pub(super) fn pending_main_page_move_count(state: &mut AppState) -> u16 {
state.take_pending_main_page_count().unwrap_or(1)
}
fn handle_main_page_count_prefix(state: &mut AppState, key: KeyEvent) -> bool {
if key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER)
{
state.clear_pending_main_page_count();
return false;
}
let KeyCode::Char(character) = key.code else {
return false;
};
if !character.is_ascii_digit() {
return false;
}
if character == '0' && !state.has_pending_main_page_count() {
return false;
}
let digit = character
.to_digit(10)
.expect("ascii digit should convert to decimal") as u16;
state.push_pending_main_page_count_digit(digit);
true
}
pub(super) fn handle_key_event(state: &mut AppState, key: KeyEvent) -> LoopAction {
tracing::debug!(
key = ?key,
ui_page = ?state.ui_page,
focus = ?state.focus,
code_focus = ?state.code_focus,
code_edit_mode = ?state.code_edit_mode,
config_editor_open = state.config_editor.open,
keymap_editor_open = state.keymap_editor.open,
palette_open = state.palette.open,
search_active = state.search.active,
"user key event"
);
if state.config_editor.open {
state.clear_pending_main_page_inputs();
return handle_config_editor_key_event(state, key);
}
if state.keymap_editor.open {
state.clear_pending_main_page_inputs();
return handle_keymap_editor_key_event(state, key);
}
if state.palette.open {
state.clear_pending_main_page_inputs();
if is_palette_toggle(key) {
state.close_palette();
return LoopAction::Continue;
}
return handle_palette_key_event(state, key);
}
if state.search.active {
state.clear_pending_main_page_inputs();
return handle_search_key_event(state, key);
}
if state.reply_panel.is_some() {
state.clear_pending_main_page_inputs();
return handle_reply_key_event(state, key);
}
if state.is_code_edit_active() {
state.clear_pending_main_page_inputs();
return handle_code_edit_key_event(state, key);
}
if is_palette_open_shortcut(key) {
state.clear_pending_main_page_inputs();
state.toggle_palette();
return LoopAction::Continue;
}
if let Some(action) = handle_main_page_key_event(state, key) {
return action;
}
if handle_main_page_count_prefix(state, key) {
return LoopAction::Continue;
}
state.clear_pending_main_page_count();
match key.code {
KeyCode::Char('/') => {
if matches!(state.ui_page, UiPage::Mail) {
state.open_search();
} else {
state.status = "search is only available on mail page".to_string();
}
}
KeyCode::Char(character)
if matches!(state.ui_page, UiPage::Mail)
&& matches!(state.focus, Pane::Subscriptions)
&& character.eq_ignore_ascii_case(&'y') =>
{
state.set_current_subscription_enabled(true);
}
KeyCode::Char(character)
if matches!(state.ui_page, UiPage::Mail)
&& matches!(state.focus, Pane::Subscriptions)
&& character.eq_ignore_ascii_case(&'n') =>
{
state.set_current_subscription_enabled(false);
}
KeyCode::Char('-')
if matches!(state.ui_page, UiPage::Mail) && matches!(state.focus, Pane::Preview) =>
{
state.select_previous_thread();
}
KeyCode::Char(character)
if matches!(state.ui_page, UiPage::Mail)
&& matches!(state.focus, Pane::Preview)
&& matches!(character, '=' | '+') =>
{
state.select_next_thread();
}
KeyCode::Char('e')
if matches!(state.ui_page, UiPage::Mail) && matches!(state.focus, Pane::Preview) =>
{
state.open_reply_panel(true);
}
KeyCode::Char(character)
if matches!(state.ui_page, UiPage::Mail) && character.eq_ignore_ascii_case(&'r') =>
{
state.open_reply_panel(false);
}
KeyCode::Char('[') if matches!(state.ui_page, UiPage::Mail) => {
state.resize_mail_panes(HorizontalResizeDirection::Left, MailPaneResizeMode::Expand);
}
KeyCode::Char(']') if matches!(state.ui_page, UiPage::Mail) => {
state.resize_mail_panes(HorizontalResizeDirection::Right, MailPaneResizeMode::Expand);
}
KeyCode::Char('{') if matches!(state.ui_page, UiPage::Mail) => {
state.resize_mail_panes(HorizontalResizeDirection::Left, MailPaneResizeMode::Shrink);
}
KeyCode::Char('}') if matches!(state.ui_page, UiPage::Mail) => {
state.resize_mail_panes(HorizontalResizeDirection::Right, MailPaneResizeMode::Shrink);
}
KeyCode::Char('e') if matches!(state.ui_page, UiPage::CodeBrowser) => {
state.enter_code_edit_mode();
}
KeyCode::Char('E') if matches!(state.ui_page, UiPage::CodeBrowser) => {
state.open_external_editor();
}
KeyCode::Tab => state.toggle_ui_page(),
KeyCode::Char(character)
if matches!(state.ui_page, UiPage::Mail)
&& matches!(state.focus, Pane::Threads)
&& character.eq_ignore_ascii_case(&'a') =>
{
state.run_patch_action(patch_worker::PatchAction::Apply);
}
KeyCode::Char(character)
if matches!(state.ui_page, UiPage::Mail)
&& matches!(state.focus, Pane::Threads)
&& character.eq_ignore_ascii_case(&'d') =>
{
state.run_patch_action(patch_worker::PatchAction::Download);
}
KeyCode::Char(character)
if matches!(state.ui_page, UiPage::Mail)
&& matches!(state.focus, Pane::Threads)
&& character.eq_ignore_ascii_case(&'u') =>
{
state.run_patch_undo_action();
}
KeyCode::Enter => match state.ui_page {
UiPage::Mail => match state.focus {
Pane::Subscriptions => state.handle_subscription_enter(),
Pane::Threads => {
if let Some(thread) = state.selected_thread() {
state.status = format!("selected {}", thread.message_id);
state.focus = Pane::Preview;
}
}
Pane::Preview => {}
},
UiPage::CodeBrowser => {
if matches!(state.code_focus, CodePaneFocus::Tree) {
state.handle_kernel_tree_enter();
}
}
},
KeyCode::Esc => {
state.status = "open command palette with : (preferred) or Ctrl+`".to_string();
}
KeyCode::Char('q') => {
state.status = "q emergency exit disabled; use command palette quit/exit".to_string();
}
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
state.status = "Ctrl+C is disabled, use command palette quit/exit".to_string();
}
_ => {}
}
LoopAction::Continue
}
fn handle_config_editor_key_event(state: &mut AppState, key: KeyEvent) -> LoopAction {
match state.config_editor.mode {
ConfigEditorMode::Browse => match key.code {
KeyCode::Esc => state.close_config_editor(),
KeyCode::Up | KeyCode::Char('i') => state.move_config_editor_up(),
KeyCode::Down | KeyCode::Char('k') => state.move_config_editor_down(),
KeyCode::Enter | KeyCode::Char('e') => state.start_config_editor_edit(),
KeyCode::Tab => state.cycle_config_editor_value(),
KeyCode::Char('x') => state.unset_selected_config_key(),
_ => {}
},
ConfigEditorMode::Edit => match key.code {
KeyCode::Esc => {
state.config_editor.mode = ConfigEditorMode::Browse;
state.config_editor.input.clear();
state.status = "config edit cancelled".to_string();
}
KeyCode::Enter => state.save_config_editor_value(),
KeyCode::Tab => state.cycle_config_editor_value(),
KeyCode::Backspace => {
state.config_editor.input.pop();
}
KeyCode::Char(character)
if !key.modifiers.intersects(
KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER,
) =>
{
state.config_editor.input.push(character);
}
_ => {}
},
}
LoopAction::Continue
}
fn handle_code_edit_key_event(state: &mut AppState, key: KeyEvent) -> LoopAction {
match state.code_edit_mode {
CodeEditMode::Browse => {}
CodeEditMode::VimNormal => match key.code {
KeyCode::Char('h') => state.move_code_edit_cursor_left(),
KeyCode::Char('j') => state.move_code_edit_cursor_down(),
KeyCode::Char('k') => state.move_code_edit_cursor_up(),
KeyCode::Char('l') => state.move_code_edit_cursor_right(),
KeyCode::Char('i') => {
state.code_edit_mode = CodeEditMode::VimInsert;
state.status = "insert mode".to_string();
}
KeyCode::Char('x') => {
if !state.delete_code_edit_character() {
state.status = "nothing to delete".to_string();
}
}
KeyCode::Char('s') => {
let _ = state.save_code_edit_buffer();
}
KeyCode::Char('E') => {
state.open_external_editor();
}
KeyCode::Char(':')
if !key.modifiers.intersects(
KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER,
) =>
{
state.enter_code_edit_command_mode();
}
KeyCode::Esc => {
if state.code_edit_dirty {
state.status = "unsaved changes, run :w, :wq, or :q!".to_string();
} else {
let target = state
.code_edit_target
.as_ref()
.map(|path| path.display().to_string())
.unwrap_or_else(|| "<file>".to_string());
state.exit_code_edit_mode(format!("exit edit mode for {target}"));
}
}
_ => {}
},
CodeEditMode::VimInsert => match key.code {
KeyCode::Esc => {
state.code_edit_mode = CodeEditMode::VimNormal;
state.status = "normal mode".to_string();
}
KeyCode::Backspace => {
if !state.backspace_code_edit_character() {
state.status = "nothing to delete".to_string();
}
}
KeyCode::Enter => {
let _ = state.insert_code_edit_newline();
}
KeyCode::Tab => {
for character in PREVIEW_TAB_SPACES.chars() {
let _ = state.insert_code_edit_character(character);
}
}
KeyCode::Char(character)
if !key.modifiers.intersects(
KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER,
) =>
{
let _ = state.insert_code_edit_character(character);
}
_ => {}
},
CodeEditMode::VimCommand => match key.code {
KeyCode::Esc => {
state.code_edit_command_input.clear();
state.code_edit_mode = CodeEditMode::VimNormal;
state.status = "command cancelled".to_string();
}
KeyCode::Backspace => {
state.code_edit_command_input.pop();
}
KeyCode::Enter => {
state.execute_code_edit_command();
}
KeyCode::Char(character)
if !key.modifiers.intersects(
KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER,
) =>
{
state.code_edit_command_input.push(character);
}
_ => {}
},
}
LoopAction::Continue
}
fn handle_reply_key_event(state: &mut AppState, key: KeyEvent) -> LoopAction {
if state
.reply_panel
.as_ref()
.is_some_and(|panel| panel.preview_open)
{
match key.code {
KeyCode::Esc => state.close_send_preview("send preview closed"),
KeyCode::Enter => state.confirm_send_preview(),
KeyCode::Char(character) if character.eq_ignore_ascii_case(&'c') => {
state.confirm_send_preview()
}
KeyCode::Char(character) if character.eq_ignore_ascii_case(&'s') => {
state.attempt_reply_send()
}
KeyCode::Char('j') | KeyCode::Down => {
if let Some(panel) = state.reply_panel.as_mut() {
panel.preview_scroll = panel.preview_scroll.saturating_add(1);
}
}
KeyCode::Char('k') | KeyCode::Up => {
if let Some(panel) = state.reply_panel.as_mut() {
panel.preview_scroll = panel.preview_scroll.saturating_sub(1);
}
}
_ => {}
}
return LoopAction::Continue;
}
if let Some(notice_action) = state
.reply_panel
.as_ref()
.and_then(|panel| panel.reply_notice.as_ref().and_then(|notice| notice.action))
{
match key.code {
KeyCode::Esc | KeyCode::Enter => {
state.close_reply_notice("reply notice closed");
}
KeyCode::Char(character)
if character.eq_ignore_ascii_case(&'p')
&& matches!(notice_action, super::ReplyNoticeAction::OpenPreview) =>
{
state.close_reply_notice("opening send preview");
state.open_send_preview();
}
KeyCode::Char(character)
if character.eq_ignore_ascii_case(&'s')
&& matches!(notice_action, super::ReplyNoticeAction::Send) =>
{
state.close_reply_notice("sending reply");
state.attempt_reply_send();
}
_ => {
state.close_reply_notice("reply notice closed");
}
}
return LoopAction::Continue;
}
let Some(mode) = state.reply_panel.as_ref().map(|panel| panel.mode) else {
return LoopAction::Continue;
};
match mode {
ReplyEditMode::Normal => match key.code {
KeyCode::Char('h') => {
if let Some(panel) = state.reply_panel.as_mut() {
panel.move_left();
}
}
KeyCode::Enter | KeyCode::Char('o') => {
if let Some(panel) = state.reply_panel.as_mut() {
panel.open_line_below();
panel.mode = ReplyEditMode::Insert;
}
state.status = "reply insert mode".to_string();
}
KeyCode::Char('j') | KeyCode::Down => {
if let Some(panel) = state.reply_panel.as_mut() {
panel.move_down();
}
}
KeyCode::Char('k') | KeyCode::Up => {
if let Some(panel) = state.reply_panel.as_mut() {
panel.move_up();
}
}
KeyCode::Char('l') => {
if let Some(panel) = state.reply_panel.as_mut() {
panel.move_right();
}
}
KeyCode::Char('i') => {
if let Some(panel) = state.reply_panel.as_mut() {
panel.mode = ReplyEditMode::Insert;
}
state.status = "reply insert mode".to_string();
}
KeyCode::Char('x') => {
let deleted = state
.reply_panel
.as_mut()
.map(|panel| panel.delete_char())
.unwrap_or(false);
if !deleted {
state.status = "nothing to delete".to_string();
}
}
KeyCode::Char('p') => state.open_send_preview(),
KeyCode::Char(character) if character.eq_ignore_ascii_case(&'s') => {
state.attempt_reply_send()
}
KeyCode::Char(':')
if !key.modifiers.intersects(
KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER,
) =>
{
if let Some(panel) = state.reply_panel.as_mut() {
panel.mode = ReplyEditMode::Command;
panel.command_input.clear();
panel.adjust_scroll();
}
state.status = "reply command mode".to_string();
}
KeyCode::Esc => {
if state.reply_panel.as_ref().is_some_and(|panel| panel.dirty) {
state.status = "unsaved reply draft, run :q! to discard".to_string();
} else {
state.close_reply_panel("closed reply panel");
}
}
_ => {}
},
ReplyEditMode::Insert => match key.code {
KeyCode::Esc => {
if let Some(panel) = state.reply_panel.as_mut() {
panel.mode = ReplyEditMode::Normal;
panel.adjust_scroll();
}
state.status = "reply normal mode".to_string();
}
KeyCode::Backspace => {
let deleted = state
.reply_panel
.as_mut()
.map(|panel| panel.backspace())
.unwrap_or(false);
if !deleted {
state.status = "nothing to delete".to_string();
}
}
KeyCode::Enter => {
if let Some(panel) = state.reply_panel.as_mut() {
panel.insert_newline();
}
}
KeyCode::Tab => {
if let Some(panel) = state.reply_panel.as_mut() {
for character in PREVIEW_TAB_SPACES.chars() {
panel.insert_char(character);
}
}
}
KeyCode::Char(character)
if !key.modifiers.intersects(
KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER,
) =>
{
if let Some(panel) = state.reply_panel.as_mut() {
panel.insert_char(character);
}
}
_ => {}
},
ReplyEditMode::Command => match key.code {
KeyCode::Esc => {
if let Some(panel) = state.reply_panel.as_mut() {
panel.command_input.clear();
panel.mode = ReplyEditMode::Normal;
panel.adjust_scroll();
}
state.status = "reply command cancelled".to_string();
}
KeyCode::Backspace => {
if let Some(panel) = state.reply_panel.as_mut() {
panel.command_input.pop();
}
}
KeyCode::Enter => state.execute_reply_command(),
KeyCode::Char(character)
if !key.modifiers.intersects(
KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER,
) =>
{
if let Some(panel) = state.reply_panel.as_mut() {
panel.command_input.push(character);
}
}
_ => {}
},
}
LoopAction::Continue
}
fn handle_search_key_event(state: &mut AppState, key: KeyEvent) -> LoopAction {
match key.code {
KeyCode::Esc => state.close_search(),
KeyCode::Enter => state.apply_search(),
KeyCode::Backspace => {
state.search.input.pop();
}
KeyCode::Char(character)
if !key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER) =>
{
state.search.input.push(character);
}
_ => {}
}
LoopAction::Continue
}
fn handle_palette_key_event(state: &mut AppState, key: KeyEvent) -> LoopAction {
match key.code {
KeyCode::Esc => {
state.close_palette();
}
KeyCode::Enter => {
let raw_command = state.palette.input.trim().to_string();
tracing::debug!(command = %raw_command, "user submitted command palette input");
state.palette.input.clear();
state.palette.clear_completion();
if raw_command.is_empty() {
state.status = "empty command".to_string();
return LoopAction::Continue;
}
if let Some(local_command) = raw_command.strip_prefix('!') {
run_palette_local_command(state, local_command);
return LoopAction::Continue;
}
state.palette.clear_local_result();
let command = raw_command.to_ascii_lowercase();
match command.as_str() {
"quit" | "exit" => return LoopAction::Exit,
"restart" => return LoopAction::Restart,
"help" => {
state.status = format!(
"commands: quit, exit, restart, help, sync [mailbox], config ..., keymap, vim, !<local shell command> | keys: {} focus, {} move, [ ] expand pane, {{ }} shrink pane, -/= preview switch, y/n enable, a apply, d download, u undo apply, e reply/inline edit, r reply, E external vim",
main_page_focus_shortcuts(&state.main_page_keymap),
main_page_move_shortcuts(&state.main_page_keymap)
);
}
value if value.split_whitespace().next() == Some("sync") => {
run_palette_sync(state, value);
state.dismiss_palette();
}
value if value.split_whitespace().next() == Some("config") => {
run_palette_config(state, value);
}
"keymap" => {
state.open_keymap_editor();
}
"vim" => {
state.open_external_editor();
}
_ => {
state.status = format!("unknown command: {command}");
}
}
}
KeyCode::Backspace => {
state.palette.input.pop();
state.palette.clear_completion();
if !state.palette.input.trim_start().starts_with('!') {
state.palette.clear_local_result();
}
}
KeyCode::Tab => {
apply_palette_completion(state);
}
KeyCode::Char(character)
if !key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER) =>
{
state.palette.input.push(character);
state.palette.clear_completion();
if !state.palette.input.trim_start().starts_with('!') {
state.palette.clear_local_result();
}
}
_ => {}
}
LoopAction::Continue
}