use crate::actions::{
dispatch_key_outcome, execute_confirm_action, execute_palette_action, kick_scan, open_repo_url,
};
use crate::app::{self, App};
use crate::event::AppEvent;
use crate::key_outcome::{diff_launch_outcome, editor_launch_outcome};
use crossterm::event::{KeyCode, KeyEvent, MouseEvent, MouseEventKind};
use ratatui::Terminal;
pub async fn handle_key<B: ratatui::backend::Backend>(
key: KeyEvent,
app: &mut App,
terminal: &mut Terminal<B>,
tx: tokio::sync::mpsc::Sender<AppEvent>,
) -> Result<bool, Box<dyn std::error::Error>>
where
B::Error: 'static,
{
if app.palette.visible {
match key.code {
KeyCode::Esc => {
app.palette.visible = false;
app.palette.query.clear();
}
KeyCode::Char('q') if app.palette.mode == Some(app::PaletteMode::Menu) => {
app.palette.visible = false;
}
KeyCode::Char('j') | KeyCode::Down => {
if !app.palette.items.is_empty() {
app.palette.selected_idx =
(app.palette.selected_idx + 1) % app.palette.items.len();
}
}
KeyCode::Char('k') | KeyCode::Up => {
if !app.palette.items.is_empty() {
app.palette.selected_idx = app
.palette
.selected_idx
.checked_sub(1)
.unwrap_or(app.palette.items.len() - 1);
}
}
KeyCode::Enter => {
if app.palette.selected_idx < app.palette.items.len() {
let action = app.palette.items[app.palette.selected_idx].clone();
if action.enabled {
app.palette.visible = false;
app.palette.query.clear();
execute_palette_action(&action, app, terminal, tx.clone()).await?;
}
}
}
KeyCode::Backspace => {
if app.palette.mode == Some(app::PaletteMode::Command) {
app.palette.query.pop();
app.palette.selected_idx = 0;
}
}
KeyCode::Char(c) => {
if app.palette.mode == Some(app::PaletteMode::Command) {
app.palette.query.push(c);
app.palette.selected_idx = 0;
} else {
if let Some(pos) = app
.palette
.items
.iter()
.position(|a| a.key.to_lowercase() == c.to_string().to_lowercase())
{
let action = app.palette.items[pos].clone();
if action.enabled {
app.palette.visible = false;
execute_palette_action(&action, app, terminal, tx.clone()).await?;
}
}
}
}
_ => {}
}
return Ok(false);
}
if key.code == KeyCode::Char('T') && !app.filter_active {
app.toggle_theme();
return Ok(false);
}
if key.code == KeyCode::Char(';') {
app.palette.visible = true;
app.palette.mode = Some(app::PaletteMode::Menu);
app.palette.query.clear();
app.palette.selected_idx = 0;
return Ok(false);
}
if key.code == KeyCode::Char('p')
&& key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL)
{
app.palette.visible = true;
app.palette.mode = Some(app::PaletteMode::Command);
app.palette.query.clear();
app.palette.selected_idx = 0;
return Ok(false);
}
match app.view_mode {
app::ViewMode::DirectoryTree => {
if app.show_confirm_modal {
match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
execute_confirm_action(app, tx.clone()).await?;
}
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
app.show_confirm_modal = false;
app.confirm_modal_action = None;
}
_ => {}
}
} else if app.filter_active {
match key.code {
KeyCode::Esc => {
app.cancel_filter();
}
KeyCode::Enter => {
app.commit_filter();
}
KeyCode::Char('f') => {
app.filter_diffs_only = !app.filter_diffs_only;
}
_ => {
app.filter_input.apply_edit(key.code);
}
}
} else {
match key.code {
KeyCode::Char('q') | KeyCode::Esc => return Ok(true),
KeyCode::Char('j') | KeyCode::Down => app.select_next(),
KeyCode::Char('k') | KeyCode::Up => app.select_prev(),
KeyCode::Char('f')
if key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL) =>
{
app.page_down();
}
KeyCode::Char('b')
if key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL) =>
{
app.page_up();
}
KeyCode::Char(' ') => app.toggle_expand(),
KeyCode::Char('h') | KeyCode::Left => app.collapse_selected(),
KeyCode::Char('l') | KeyCode::Right => app.expand_selected(),
KeyCode::Tab => app.active_side_left = !app.active_side_left,
KeyCode::Char('1') => {
app.focus_left_pane();
}
KeyCode::Char('2') => {
app.focus_right_pane();
}
KeyCode::Char('c') => {
app.precise_mode = !app.precise_mode;
kick_scan(app, tx.clone());
}
KeyCode::Char('r') => {
kick_scan(app, tx.clone());
}
KeyCode::Char('s') => {
app.swap_paths();
app.set_status("Swapped left ↔ right", false);
kick_scan(app, tx.clone());
}
KeyCode::Char('C') => {
app.open_config();
}
KeyCode::Char('/') => {
app.open_filter();
}
KeyCode::Char('?') => {
app.open_help();
}
KeyCode::Backspace
if !app.filter_pattern.is_empty() || app.filter_diffs_only =>
{
app.clear_filter();
}
KeyCode::Char('L') if app.selected_idx < app.filtered_rows.len() => {
let row = &app.filtered_rows[app.selected_idx];
if row.right.is_some() {
app.show_confirm_modal = true;
app.confirm_modal_message =
format!("Copy '{}' to left side?", row.name);
app.confirm_modal_action = Some(app::ConfirmAction::CopyRightToLeft);
}
}
KeyCode::Char('R') if app.selected_idx < app.filtered_rows.len() => {
let row = &app.filtered_rows[app.selected_idx];
if row.left.is_some() {
app.show_confirm_modal = true;
app.confirm_modal_message =
format!("Copy '{}' to right side?", row.name);
app.confirm_modal_action = Some(app::ConfirmAction::CopyLeftToRight);
}
}
KeyCode::Char('D') if app.selected_idx < app.filtered_rows.len() => {
dispatch_key_outcome(
diff_launch_outcome(app),
terminal,
app.mouse_enabled,
)?;
}
KeyCode::Char('E') if app.selected_idx < app.filtered_rows.len() => {
dispatch_key_outcome(
editor_launch_outcome(app),
terminal,
app.mouse_enabled,
)?;
}
KeyCode::Enter if app.selected_idx < app.filtered_rows.len() => {
let row = &app.filtered_rows[app.selected_idx];
let is_dir = row.left.as_ref().map(|f| f.is_dir).unwrap_or(false)
|| row.right.as_ref().map(|f| f.is_dir).unwrap_or(false);
if is_dir {
app.toggle_expand();
} else {
app.enter_file_diff();
}
}
_ => {}
}
}
}
app::ViewMode::FileDiff => {
if app.show_confirm_modal {
match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
execute_confirm_action(app, tx.clone()).await?;
}
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
app.show_confirm_modal = false;
app.confirm_modal_action = None;
}
_ => {}
}
} else {
match key.code {
KeyCode::Esc | KeyCode::Char('q') => {
app.view_mode = app::ViewMode::DirectoryTree;
}
KeyCode::Down
if key.modifiers.contains(crossterm::event::KeyModifiers::ALT) =>
{
app.jump_to_next_change();
}
KeyCode::Up if key.modifiers.contains(crossterm::event::KeyModifiers::ALT) => {
app.jump_to_prev_change();
}
KeyCode::Char('N') => {
app.jump_to_next_change();
}
KeyCode::Char('P') => {
app.jump_to_prev_change();
}
KeyCode::Char('j') | KeyCode::Down => {
let max_scroll = app.diff_physical_rows.saturating_sub(app.visible_height);
if app.diff_scroll < max_scroll {
app.diff_scroll += 1;
}
}
KeyCode::Char('k') | KeyCode::Up if app.diff_scroll > 0 => {
app.diff_scroll -= 1;
}
KeyCode::Char('f')
if key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL) =>
{
app.diff_page_down();
}
KeyCode::Char('b')
if key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL) =>
{
app.diff_page_up();
}
KeyCode::Left => {
if !app.diff_wrap && app.diff_h_scroll > 0 {
app.diff_h_scroll -= 1;
}
}
KeyCode::Right => {
if !app.diff_wrap {
let content_width =
(terminal.size().map(|s| s.width as usize).unwrap_or(80) / 2)
.saturating_sub(2);
let max_h_scroll =
app.diff_max_line_width.saturating_sub(content_width);
if app.diff_h_scroll < max_h_scroll {
app.diff_h_scroll += 1;
}
}
}
KeyCode::Char('L') | KeyCode::Char('l')
if app.selected_idx < app.filtered_rows.len() =>
{
let row = &app.filtered_rows[app.selected_idx];
if row.right.is_some() {
app.show_confirm_modal = true;
app.confirm_modal_message =
format!("Copy '{}' to left side?", row.name);
app.confirm_modal_action = Some(app::ConfirmAction::CopyRightToLeft);
}
}
KeyCode::Char('R') | KeyCode::Char('r')
if app.selected_idx < app.filtered_rows.len() =>
{
let row = &app.filtered_rows[app.selected_idx];
if row.left.is_some() {
app.show_confirm_modal = true;
app.confirm_modal_message =
format!("Copy '{}' to right side?", row.name);
app.confirm_modal_action = Some(app::ConfirmAction::CopyLeftToRight);
}
}
KeyCode::Char('[') => {
match app
.copy_hunk_at_cursor(crate::diff_view::HunkCopyDirection::RightToLeft)
{
Ok(()) => {
app.set_status("Copied change block to left".to_string(), false)
}
Err(e) => app.set_status(format!("Hunk copy failed: {}", e), true),
}
}
KeyCode::Char(']') => {
match app
.copy_hunk_at_cursor(crate::diff_view::HunkCopyDirection::LeftToRight)
{
Ok(()) => {
app.set_status("Copied change block to right".to_string(), false)
}
Err(e) => app.set_status(format!("Hunk copy failed: {}", e), true),
}
}
KeyCode::Char('w') => {
app.diff_wrap = !app.diff_wrap;
app.diff_scroll = 0;
app.diff_h_scroll = 0;
}
KeyCode::Char('?') => {
app.open_help();
}
KeyCode::Char('C') => {
app.open_config();
}
KeyCode::Char('f') => {
app.diff_show_full = !app.diff_show_full;
if let Err(e) = app.refresh_file_diff() {
app.diff_show_full = !app.diff_show_full;
app.set_status(format!("Cannot refresh diff: {e}"), true);
} else {
app.diff_scroll = 0;
app.diff_h_scroll = 0;
}
}
_ => {}
}
}
}
app::ViewMode::ConfigMenu => match key.code {
KeyCode::Esc | KeyCode::Char('q') => app.view_mode = app.config_return_view,
KeyCode::Char('j') | KeyCode::Down => {
app.config_select_next();
}
KeyCode::Char('k') | KeyCode::Up => {
app.config_select_prev();
}
KeyCode::Char(' ') | KeyCode::Enter => {
app.apply_config_selection();
}
KeyCode::Char('h') | KeyCode::Left => {
app.adjust_config_selection(false);
}
KeyCode::Char('l') | KeyCode::Right => {
app.adjust_config_selection(true);
}
KeyCode::Char('?') => {
app.open_help();
}
_ => {}
},
app::ViewMode::Help => {
if app.help_index_open {
match key.code {
KeyCode::Char('j') | KeyCode::Down => {
app.help_index_sel = (app.help_index_sel + 1) % app::HelpTopic::all().len();
}
KeyCode::Char('k') | KeyCode::Up => {
app.help_index_sel = app
.help_index_sel
.checked_sub(1)
.unwrap_or(app::HelpTopic::all().len() - 1);
}
KeyCode::Enter => {
app.help_topic = app::HelpTopic::all()[app.help_index_sel];
app.help_index_open = false;
app.help_scroll = 0;
}
KeyCode::Char(c @ '1'..='6') => {
app.help_topic = app::HelpTopic::all()[(c as u8 - b'1') as usize];
app.help_index_open = false;
app.help_scroll = 0;
}
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('?') => {
app.view_mode = app.help_return_view;
app.help_index_open = false;
}
KeyCode::Char('C') => {
app.open_config();
}
_ => {}
}
} else {
match key.code {
KeyCode::Char(c @ '1'..='6') => {
app.help_topic = app::HelpTopic::all()[(c as u8 - b'1') as usize];
app.help_scroll = 0;
}
KeyCode::Tab => {
app.help_index_sel = app::HelpTopic::all()
.iter()
.position(|&t| t == app.help_topic)
.unwrap_or(0);
app.help_index_open = true;
}
KeyCode::Char('j') | KeyCode::Down => {
app.help_scroll = app.help_scroll.saturating_add(1);
}
KeyCode::Char('k') | KeyCode::Up => {
app.help_scroll = app.help_scroll.saturating_sub(1);
}
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('?') => {
app.view_mode = app.help_return_view;
}
KeyCode::Char('C') => {
app.open_config();
}
_ => {}
}
}
}
}
Ok(false)
}
pub async fn handle_mouse<B: ratatui::backend::Backend>(
mouse: MouseEvent,
app: &mut App,
terminal: &mut Terminal<B>,
tx: tokio::sync::mpsc::Sender<AppEvent>,
) -> Result<(), Box<dyn std::error::Error>>
where
B::Error: 'static,
{
if let MouseEventKind::Down(crossterm::event::MouseButton::Left) = mouse.kind {
if app.show_confirm_modal {
if let Ok(size) = terminal.size() {
let size_rect = ratatui::prelude::Rect::new(0, 0, size.width, size.height);
let modal_area = crate::ui::centered_rect(60, 7, size_rect);
if mouse.row == modal_area.y
&& mouse.column >= modal_area.x + modal_area.width.saturating_sub(5)
&& mouse.column < modal_area.x + modal_area.width.saturating_sub(2)
{
app.show_confirm_modal = false;
app.confirm_modal_action = None;
return Ok(());
}
}
}
if mouse.row == 0 {
if let Ok(size) = terminal.size() {
let w = size.width;
if mouse.column >= w.saturating_sub(17) && mouse.column < w.saturating_sub(9) {
app.palette.visible = false;
app.palette.query.clear();
app.open_config();
return Ok(());
} else if mouse.column >= w.saturating_sub(7) {
app.palette.visible = false;
app.palette.query.clear();
app.open_help();
return Ok(());
}
}
} else if app.palette.visible {
if let Ok(size) = terminal.size() {
let mode = app.palette.mode.unwrap_or(app::PaletteMode::Menu);
let count = app.palette.items.len();
let (pop_w, pop_h) = match mode {
app::PaletteMode::Menu => (50, (count + 2).max(4) as u16),
app::PaletteMode::Command => (55, 12),
};
let menu_x = size.width.saturating_sub(pop_w) / 2;
let menu_y = size.height.saturating_sub(pop_h) / 2;
if mouse.column >= menu_x
&& mouse.column < menu_x + pop_w
&& mouse.row >= menu_y
&& mouse.row < menu_y + pop_h
{
if mouse.row == menu_y
&& mouse.column >= menu_x + pop_w.saturating_sub(5)
&& mouse.column < menu_x + pop_w.saturating_sub(2)
{
app.palette.visible = false;
app.palette.query.clear();
return Ok(());
}
let list_start_y = match mode {
app::PaletteMode::Menu => menu_y + 1,
app::PaletteMode::Command => menu_y + 3,
};
if mouse.row >= list_start_y && mouse.row < menu_y + pop_h - 1 {
let click_idx = (mouse.row - list_start_y) as usize;
if click_idx < app.palette.items.len() {
let action = app.palette.items[click_idx].clone();
if action.enabled {
app.palette.visible = false;
app.palette.query.clear();
execute_palette_action(&action, app, terminal, tx.clone()).await?;
}
}
}
} else {
app.palette.visible = false;
app.palette.query.clear();
}
}
return Ok(());
} else {
if let Ok(size) = terminal.size() {
if app.view_mode == app::ViewMode::Help {
if mouse.row == 1
&& mouse.column >= size.width.saturating_sub(5)
&& mouse.column < size.width.saturating_sub(2)
{
app.view_mode = app.help_return_view;
return Ok(());
}
} else if app.view_mode == app::ViewMode::ConfigMenu {
if mouse.row == 1
&& mouse.column >= size.width.saturating_sub(5)
&& mouse.column < size.width.saturating_sub(2)
{
app.view_mode = app.config_return_view;
return Ok(());
}
} else if app.view_mode == app::ViewMode::FileDiff {
let row = app.filtered_rows.get(app.selected_idx);
let has_changes = app.diff_rows.iter().any(|(l, r)| {
l.as_ref().map(|d| d.tag) == Some(similar::ChangeTag::Delete)
|| r.as_ref().map(|d| d.tag) == Some(similar::ChangeTag::Insert)
});
let show_identical =
!has_changes && row.is_some_and(|r| r.left.is_some() || r.right.is_some());
let header_height = if show_identical { 2 } else { 1 };
let body_y = header_height + 1;
if mouse.row == body_y as u16
&& mouse.column >= size.width.saturating_sub(5)
&& mouse.column < size.width.saturating_sub(2)
{
app.view_mode = app::ViewMode::DirectoryTree;
return Ok(());
}
}
}
}
}
if app.palette.visible {
match mouse.kind {
MouseEventKind::ScrollDown => {
if !app.palette.items.is_empty() {
app.palette.selected_idx =
(app.palette.selected_idx + 1) % app.palette.items.len();
}
return Ok(());
}
MouseEventKind::ScrollUp => {
if !app.palette.items.is_empty() {
app.palette.selected_idx = app
.palette
.selected_idx
.checked_sub(1)
.unwrap_or(app.palette.items.len() - 1);
}
return Ok(());
}
_ => {}
}
}
match app.view_mode {
app::ViewMode::DirectoryTree => match mouse.kind {
MouseEventKind::ScrollDown => app.select_next(),
MouseEventKind::ScrollUp => app.select_prev(),
MouseEventKind::Down(crossterm::event::MouseButton::Left) => {
let click_y = mouse.row as usize;
if click_y >= 2 {
let offset_y = click_y - 2;
if offset_y < app.visible_height {
let idx = app.scroll_offset + offset_y;
if idx < app.filtered_rows.len() {
let now = std::time::Instant::now();
let is_double_click = Some(idx) == app.last_click_idx
&& app.last_click_time.is_some_and(|t| {
now.duration_since(t) < std::time::Duration::from_millis(400)
});
app.selected_idx = idx;
if is_double_click {
let row = &app.filtered_rows[app.selected_idx];
let is_dir = row.left.as_ref().map(|f| f.is_dir).unwrap_or(false)
|| row.right.as_ref().map(|f| f.is_dir).unwrap_or(false);
if is_dir {
app.toggle_expand();
} else {
app.enter_file_diff();
}
app.last_click_idx = None;
app.last_click_time = None;
} else {
app.last_click_idx = Some(idx);
app.last_click_time = Some(now);
}
}
}
}
}
MouseEventKind::Down(crossterm::event::MouseButton::Right) => {
let click_y = mouse.row as usize;
if click_y >= 2 {
let offset_y = click_y - 2;
if offset_y < app.visible_height {
let idx = app.scroll_offset + offset_y;
if idx < app.filtered_rows.len() {
app.selected_idx = idx;
app.palette.visible = true;
app.palette.mode = Some(app::PaletteMode::Menu);
app.palette.query.clear();
app.palette.selected_idx = 0;
app.palette.x = mouse.column;
app.palette.y = mouse.row;
}
}
}
}
_ => {}
},
app::ViewMode::FileDiff => match mouse.kind {
MouseEventKind::ScrollDown => {
let max_scroll = app.diff_physical_rows.saturating_sub(app.visible_height);
if app.diff_scroll < max_scroll {
app.diff_scroll += 1;
}
}
MouseEventKind::ScrollUp if app.diff_scroll > 0 => {
app.diff_scroll -= 1;
}
MouseEventKind::Down(crossterm::event::MouseButton::Right) => {
app.palette.visible = true;
app.palette.mode = Some(app::PaletteMode::Menu);
app.palette.query.clear();
app.palette.selected_idx = 0;
app.palette.x = mouse.column;
app.palette.y = mouse.row;
}
_ => {}
},
app::ViewMode::ConfigMenu => match mouse.kind {
MouseEventKind::ScrollDown => {
let rows = app.config_rows();
if matches!(
rows.get(app.config_selected_idx),
Some(app::ConfigRowKind::DiffContext)
) {
app.adjust_config_selection(false);
} else {
app.config_select_next();
}
}
MouseEventKind::ScrollUp => {
let rows = app.config_rows();
if matches!(
rows.get(app.config_selected_idx),
Some(app::ConfigRowKind::DiffContext)
) {
app.adjust_config_selection(true);
} else {
app.config_select_prev();
}
}
MouseEventKind::Down(crossterm::event::MouseButton::Left) => {
let click_y = mouse.row as usize;
if click_y >= 2 {
let row_idx = click_y - 2;
let rows = app.config_rows();
if row_idx < rows.len() && rows[row_idx].is_selectable() {
app.config_selected_idx = row_idx;
app.apply_config_selection();
}
}
}
MouseEventKind::Down(crossterm::event::MouseButton::Right) => {
app.palette.visible = true;
app.palette.mode = Some(app::PaletteMode::Menu);
app.palette.query.clear();
app.palette.selected_idx = 0;
app.palette.x = mouse.column;
app.palette.y = mouse.row;
}
_ => {}
},
app::ViewMode::Help => match mouse.kind {
MouseEventKind::ScrollDown => {
if app.help_index_open {
app.help_index_sel = (app.help_index_sel + 1) % app::HelpTopic::all().len();
} else {
app.help_scroll = app.help_scroll.saturating_add(1);
}
}
MouseEventKind::ScrollUp => {
if app.help_index_open {
app.help_index_sel = app
.help_index_sel
.checked_sub(1)
.unwrap_or(app::HelpTopic::all().len() - 1);
} else {
app.help_scroll = app.help_scroll.saturating_sub(1);
}
}
MouseEventKind::Down(crossterm::event::MouseButton::Left) => {
if app.help_index_open {
let click_y = mouse.row as usize;
if click_y >= 2 && click_y < 2 + crate::app::HelpTopic::all().len() {
let idx = click_y - 2;
app.help_topic = crate::app::HelpTopic::all()[idx];
app.help_index_open = false;
app.help_scroll = 0;
}
} else if app.help_topic == app::HelpTopic::About {
if let Some(visible_row) =
crate::ui::ABOUT_REPO_LINE.checked_sub(app.help_scroll)
{
if mouse.row == 2 + visible_row && mouse.column >= 3 {
open_repo_url(app);
}
}
}
}
MouseEventKind::Down(crossterm::event::MouseButton::Right) => {
app.palette.visible = true;
app.palette.mode = Some(app::PaletteMode::Menu);
app.palette.query.clear();
app.palette.selected_idx = 0;
app.palette.x = mouse.column;
app.palette.y = mouse.row;
}
_ => {}
},
}
Ok(())
}