From 485870779ef6989338cfed70b29ba6cd30d5b252 Mon Sep 17 00:00:00 2001 From: zachvalenta Date: Wed, 20 May 2026 19:43:03 -0400 Subject: [PATCH] add: configurable keybindings --- README.md | 17 ++ docs/architecture.md | 2 +- src/app.rs | 78 +++------ src/cli.rs | 36 +++- src/config.rs | 391 +++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 5 +- src/terminal.rs | 13 +- 7 files changed, 478 insertions(+), 64 deletions(-) create mode 100644 src/config.rs diff --git a/README.md b/README.md index 8bed92d..06719a8 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ This opens the side-by-side review so you can check exactly what changed in your - Side-by-side panes with independent horizontal scroll offsets - Keyboard and mouse navigation (including wheel + shift-wheel) - Vim-like motion navigation (`h`/`j`/`k`/`l`, `g`/`G`, `Ctrl+u`/`Ctrl+d`) +- Configurable key bindings - In-diff search (`/` + Enter, then `n` / `N` to navigate matches) - Per-file reviewed toggles (`r`) with local persistence under `.git` - Language-aware syntax highlighting and line-level add/delete tinting @@ -65,6 +66,7 @@ deff --strategy range --base origin/main --head HEAD deff --strategy range --base origin/main --include-uncommitted deff --only-uncommitted deff --theme dark +deff --config ~/.config/deff/config.toml ``` Show help: @@ -121,6 +123,21 @@ Theme selection: - Use `--theme auto|dark|light` to control rendering for your terminal. - `--theme` takes precedence over `DEFF_THEME=dark|light`. +Key binding config: + +- Default filesystem locations: `~/.config/deff/config.toml` or `$XDG_CONFIG_HOME/deff/config.toml` +- `--config ` to load specific config file +- `--no-config` to ignore config files +- Supported actions: `quit`, `previous_file`, `next_file`, `scroll_up`, `scroll_down`, `page_up`, `page_down`, `top`, `bottom`, `search`, `next_match`, `previous_match`, `next_hunk`, `previous_hunk`, `toggle_reviewed` +- Key names: printable characters (`"j"`), arrows (`"left"`), paging keys (`"pageup"`), modifiers (`"ctrl-u"`, `"alt-j"`) +- Entries under `[keybindings]` replace that action's defaults; omitted actions keep default bindings + +```toml +[keybindings] +page_down = "J" +page_up = "K" +``` + Custom syntax grammars: - `deff` loads syntect defaults, bundled deff grammars, plus any extra `.sublime-syntax` files found in: diff --git a/docs/architecture.md b/docs/architecture.md index 2040fcb..0be1fa7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -7,6 +7,7 @@ - `src/lib.rs`: top-level orchestration (`run`) and dependency wiring. - `src/main.rs`: binary entrypoint and error exit handling. - `src/cli.rs`: clap definitions and argument validation into `CliOptions`. +- `src/config.rs`: user config loading and key binding parsing. - `src/model.rs`: shared enums/structs for comparison metadata and file views. - `src/git.rs`: git command execution plus comparison strategy resolution. - `src/diff.rs`: file descriptor discovery, hunk highlight parsing, and view construction. @@ -14,4 +15,3 @@ - `src/app.rs`: state transitions for keyboard/mouse navigation. - `src/terminal.rs`: TUI lifecycle and event loop plumbing. - `src/text.rs`: pure string-width and formatting helpers. - diff --git a/src/app.rs b/src/app.rs index 1c7bfe7..6d83c7f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3,6 +3,7 @@ use std::collections::HashSet; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind}; use crate::{ + config::{Action, KeyBindings}, model::{DiffFileView, PaneOffsets, PaneSide}, render::{create_frame_layout, get_body_line_count, get_max_pane_offsets, get_pane_for_column}, }; @@ -441,6 +442,7 @@ pub(crate) fn handle_keypress( files: &[DiffFileView], app: &mut AppState, rows: u16, + keybindings: &KeyBindings, ) -> KeypressOutcome { if key.modifiers.contains(KeyModifiers::CONTROL) && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C')) @@ -470,119 +472,77 @@ pub(crate) fn handle_keypress( return KeypressOutcome::default(); } - match key.code { - KeyCode::Char('q') | KeyCode::Char('Q') => KeypressOutcome { + match keybindings.action_for_key(key) { + Some(Action::Quit) => KeypressOutcome { should_quit: true, review_toggled: None, }, - KeyCode::Left => { + Some(Action::PreviousFile) => { if move_file(-1, files, app) { app.refresh_search_matches_for_current_file(files); } KeypressOutcome::default() } - KeyCode::Right => { + Some(Action::NextFile) => { if move_file(1, files, app) { app.refresh_search_matches_for_current_file(files); } KeypressOutcome::default() } - KeyCode::Up => { + Some(Action::ScrollUp) => { move_scroll(-1, files, app, rows); KeypressOutcome::default() } - KeyCode::Down => { + Some(Action::ScrollDown) => { move_scroll(1, files, app, rows); KeypressOutcome::default() } - KeyCode::Char('h') => { - if move_file(-1, files, app) { - app.refresh_search_matches_for_current_file(files); - } - KeypressOutcome::default() - } - KeyCode::Char('l') => { - if move_file(1, files, app) { - app.refresh_search_matches_for_current_file(files); - } - KeypressOutcome::default() - } - KeyCode::Char('k') => { - move_scroll(-1, files, app, rows); - KeypressOutcome::default() - } - KeyCode::Char('j') => { - move_scroll(1, files, app, rows); - KeypressOutcome::default() - } - KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { + Some(Action::PageUp) => { let page_size = get_body_line_count(rows as usize).max(1) as isize; move_scroll(-page_size, files, app, rows); KeypressOutcome::default() } - KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => { + Some(Action::PageDown) => { let page_size = get_body_line_count(rows as usize).max(1) as isize; move_scroll(page_size, files, app, rows); KeypressOutcome::default() } - KeyCode::PageUp => { - let page_size = get_body_line_count(rows as usize).max(1) as isize; - move_scroll(-page_size, files, app, rows); - KeypressOutcome::default() - } - KeyCode::PageDown => { - let page_size = get_body_line_count(rows as usize).max(1) as isize; - move_scroll(page_size, files, app, rows); - KeypressOutcome::default() - } - KeyCode::Home => { + Some(Action::Top) => { scroll_to_top(app); KeypressOutcome::default() } - KeyCode::End => { - scroll_to_bottom(files, app, rows); - KeypressOutcome::default() - } - KeyCode::Char('g') if key.modifiers.contains(KeyModifiers::SHIFT) => { - scroll_to_bottom(files, app, rows); - KeypressOutcome::default() - } - KeyCode::Char('G') => { + Some(Action::Bottom) => { scroll_to_bottom(files, app, rows); KeypressOutcome::default() } - KeyCode::Char('g') => { - scroll_to_top(app); - KeypressOutcome::default() - } - KeyCode::Char('/') => { + Some(Action::Search) => { app.enter_search_input_mode(); KeypressOutcome::default() } - KeyCode::Char('n') => { + Some(Action::NextMatch) => { app.jump_to_search_match(files, rows, true); KeypressOutcome::default() } - KeyCode::Char('N') => { + Some(Action::PreviousMatch) => { app.jump_to_search_match(files, rows, false); KeypressOutcome::default() } - KeyCode::Char('}') => { + Some(Action::NextHunk) => { app.jump_to_hunk(files, rows, true); KeypressOutcome::default() } - KeyCode::Char('{') => { + Some(Action::PreviousHunk) => { app.jump_to_hunk(files, rows, false); KeypressOutcome::default() } - KeyCode::Char('r') => { + Some(Action::ToggleReviewed) => { let reviewed = app.toggle_current_file_reviewed(); KeypressOutcome { should_quit: false, review_toggled: Some((app.file_index, reviewed)), } } - _ => KeypressOutcome::default(), + None => KeypressOutcome::default(), } } diff --git a/src/cli.rs b/src/cli.rs index 15bb501..767f5e8 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,5 +1,6 @@ use anyhow::{Result, bail}; use clap::Parser; +use std::path::PathBuf; use crate::model::{StrategyArg, StrategyId, ThemeMode}; @@ -17,6 +18,7 @@ const DEFAULT_HEAD_REF: &str = "HEAD"; deff --strategy range --base [--head ] deff --strategy range --base --include-uncommitted deff --theme dark + deff --config ~/.config/deff/config.toml Key bindings: h / left-arrow previous file @@ -33,7 +35,11 @@ Key bindings: / start in-diff search n / N next / previous search match r toggle reviewed for current file - q quit"# + q quit + +Config: + Reads ~/.config/deff/config.toml or $XDG_CONFIG_HOME/deff/config.toml by default. + Use [keybindings] entries such as page_down = "J"."# )] struct Cli { #[arg(long, value_enum)] @@ -48,6 +54,10 @@ struct Cli { only_uncommitted: bool, #[arg(long, value_enum, default_value_t = ThemeMode::Auto)] theme: ThemeMode, + #[arg(long)] + config: Option, + #[arg(long)] + no_config: bool, } #[derive(Clone, Debug)] @@ -58,6 +68,8 @@ pub(crate) struct CliOptions { pub(crate) include_uncommitted: bool, pub(crate) only_uncommitted: bool, pub(crate) theme_mode: ThemeMode, + pub(crate) config_path: Option, + pub(crate) no_config: bool, } impl TryFrom for CliOptions { @@ -106,6 +118,10 @@ impl TryFrom for CliOptions { bail!("--include-uncommitted currently requires --head HEAD"); } + if value.no_config && value.config.is_some() { + bail!("--config cannot be combined with --no-config"); + } + Ok(Self { strategy_id, base_ref: value.base, @@ -113,6 +129,8 @@ impl TryFrom for CliOptions { include_uncommitted: value.include_uncommitted, only_uncommitted: value.only_uncommitted, theme_mode: value.theme, + config_path: value.config, + no_config: value.no_config, }) } } @@ -134,6 +152,8 @@ mod tests { include_uncommitted: false, only_uncommitted: false, theme: ThemeMode::Auto, + config: None, + no_config: false, } } @@ -176,4 +196,18 @@ mod tests { .contains("--only-uncommitted cannot be combined with --head") ); } + + #[test] + fn rejects_config_path_with_no_config() { + let mut cli = base_cli(); + cli.config = Some(PathBuf::from("config.toml")); + cli.no_config = true; + + let error = CliOptions::try_from(cli).expect_err("config options should be rejected"); + assert!( + error + .to_string() + .contains("--config cannot be combined with --no-config") + ); + } } diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..40ade21 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,391 @@ +use std::{ + collections::HashMap, + env, fs, + path::{Path, PathBuf}, +}; + +use anyhow::{Context, Result, bail}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub(crate) enum Action { + Quit, + PreviousFile, + NextFile, + ScrollUp, + ScrollDown, + PageUp, + PageDown, + Top, + Bottom, + Search, + NextMatch, + PreviousMatch, + NextHunk, + PreviousHunk, + ToggleReviewed, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct KeyBinding { + code: KeyCode, + modifiers: KeyModifiers, +} + +impl KeyBinding { + fn matches(&self, key: KeyEvent) -> bool { + if self.code != key.code { + return false; + } + + let key_required = key + .modifiers + .intersection(KeyModifiers::CONTROL | KeyModifiers::ALT); + let binding_required = self + .modifiers + .intersection(KeyModifiers::CONTROL | KeyModifiers::ALT); + if key_required != binding_required { + return false; + } + + if matches!(self.code, KeyCode::Char(ch) if ch.is_ascii_uppercase()) { + return true; + } + + key.modifiers.contains(KeyModifiers::SHIFT) == self.modifiers.contains(KeyModifiers::SHIFT) + } +} + +#[derive(Clone, Debug)] +pub(crate) struct KeyBindings { + bindings_by_action: HashMap>, +} + +impl Default for KeyBindings { + fn default() -> Self { + let mut bindings_by_action = HashMap::new(); + + bindings_by_action.insert(Action::Quit, parse_default_bindings(&["q", "Q"])); + bindings_by_action.insert(Action::PreviousFile, parse_default_bindings(&["h", "left"])); + bindings_by_action.insert(Action::NextFile, parse_default_bindings(&["l", "right"])); + bindings_by_action.insert(Action::ScrollUp, parse_default_bindings(&["k", "up"])); + bindings_by_action.insert(Action::ScrollDown, parse_default_bindings(&["j", "down"])); + bindings_by_action.insert( + Action::PageUp, + parse_default_bindings(&["ctrl-u", "pageup"]), + ); + bindings_by_action.insert( + Action::PageDown, + parse_default_bindings(&["ctrl-d", "pagedown"]), + ); + bindings_by_action.insert(Action::Top, parse_default_bindings(&["g", "home"])); + bindings_by_action.insert(Action::Bottom, parse_default_bindings(&["G", "end"])); + bindings_by_action.insert(Action::Search, parse_default_bindings(&["/"])); + bindings_by_action.insert(Action::NextMatch, parse_default_bindings(&["n"])); + bindings_by_action.insert(Action::PreviousMatch, parse_default_bindings(&["N"])); + bindings_by_action.insert(Action::NextHunk, parse_default_bindings(&["}"])); + bindings_by_action.insert(Action::PreviousHunk, parse_default_bindings(&["{"])); + bindings_by_action.insert(Action::ToggleReviewed, parse_default_bindings(&["r"])); + + Self { bindings_by_action } + } +} + +impl KeyBindings { + pub(crate) fn load(config_path: Option<&Path>, skip_config: bool) -> Result { + let mut keybindings = Self::default(); + if skip_config { + return Ok(keybindings); + } + + let Some(path) = config_path + .map(Path::to_path_buf) + .or_else(default_config_path) + else { + return Ok(keybindings); + }; + + if !path.exists() { + if config_path.is_some() { + bail!("config file does not exist: {}", path.display()); + } + return Ok(keybindings); + } + + let contents = fs::read_to_string(&path) + .with_context(|| format!("failed to read config file {}", path.display()))?; + apply_config(&mut keybindings, &contents) + .with_context(|| format!("failed to parse config file {}", path.display()))?; + + Ok(keybindings) + } + + pub(crate) fn action_for_key(&self, key: KeyEvent) -> Option { + ACTION_ORDER.iter().copied().find(|action| { + self.bindings_by_action + .get(action) + .is_some_and(|bindings| bindings.iter().any(|binding| binding.matches(key))) + }) + } +} + +const ACTION_ORDER: &[Action] = &[ + Action::Quit, + Action::PreviousFile, + Action::NextFile, + Action::ScrollUp, + Action::ScrollDown, + Action::PageUp, + Action::PageDown, + Action::Top, + Action::Bottom, + Action::Search, + Action::NextMatch, + Action::PreviousMatch, + Action::NextHunk, + Action::PreviousHunk, + Action::ToggleReviewed, +]; + +fn default_config_path() -> Option { + if let Some(config_home) = env::var_os("XDG_CONFIG_HOME") { + return Some(PathBuf::from(config_home).join("deff/config.toml")); + } + + env::var_os("HOME").map(|home| PathBuf::from(home).join(".config/deff/config.toml")) +} + +fn apply_config(keybindings: &mut KeyBindings, contents: &str) -> Result<()> { + let mut in_keybindings_section = false; + + for (line_index, raw_line) in contents.lines().enumerate() { + let line_number = line_index + 1; + let line = strip_comment(raw_line).trim(); + if line.is_empty() { + continue; + } + + if line.starts_with('[') && line.ends_with(']') { + in_keybindings_section = line == "[keybindings]"; + continue; + } + + if !in_keybindings_section { + continue; + } + + let Some((raw_name, raw_value)) = line.split_once('=') else { + bail!("line {line_number}: expected key = value"); + }; + + let action = parse_action(raw_name.trim()) + .with_context(|| format!("line {line_number}: unknown keybinding action"))?; + let binding = parse_quoted_binding(raw_value.trim()) + .with_context(|| format!("line {line_number}: invalid binding value"))?; + keybindings.set_action_binding(action, binding); + } + + Ok(()) +} + +impl KeyBindings { + fn set_action_binding(&mut self, action: Action, binding: KeyBinding) { + for (existing_action, existing_bindings) in &mut self.bindings_by_action { + if *existing_action != action { + existing_bindings.retain(|existing_binding| existing_binding != &binding); + } + } + + self.bindings_by_action.insert(action, vec![binding]); + } +} + +fn strip_comment(line: &str) -> &str { + let mut in_string = false; + for (index, ch) in line.char_indices() { + match ch { + '"' => in_string = !in_string, + '#' if !in_string => return &line[..index], + _ => {} + } + } + line +} + +fn parse_action(name: &str) -> Result { + match name { + "quit" => Ok(Action::Quit), + "previous_file" => Ok(Action::PreviousFile), + "next_file" => Ok(Action::NextFile), + "scroll_up" => Ok(Action::ScrollUp), + "scroll_down" => Ok(Action::ScrollDown), + "page_up" => Ok(Action::PageUp), + "page_down" => Ok(Action::PageDown), + "top" => Ok(Action::Top), + "bottom" => Ok(Action::Bottom), + "search" => Ok(Action::Search), + "next_match" => Ok(Action::NextMatch), + "previous_match" => Ok(Action::PreviousMatch), + "next_hunk" => Ok(Action::NextHunk), + "previous_hunk" => Ok(Action::PreviousHunk), + "toggle_reviewed" => Ok(Action::ToggleReviewed), + _ => bail!("{name}"), + } +} + +fn parse_quoted_binding(value: &str) -> Result { + if value.len() < 2 || !value.starts_with('"') || !value.ends_with('"') { + bail!("binding must be a quoted string"); + } + + parse_key_binding(&value[1..value.len() - 1]) +} + +fn parse_default_bindings(values: &[&str]) -> Vec { + values + .iter() + .map(|value| parse_key_binding(value).expect("default keybinding should parse")) + .collect() +} + +fn parse_key_binding(value: &str) -> Result { + let mut modifiers = KeyModifiers::empty(); + let mut key_part = None; + + for part in value.split('-') { + let normalized = part.trim().to_ascii_lowercase(); + match normalized.as_str() { + "ctrl" | "control" => modifiers.insert(KeyModifiers::CONTROL), + "alt" | "option" => modifiers.insert(KeyModifiers::ALT), + "shift" => modifiers.insert(KeyModifiers::SHIFT), + "" => bail!("empty keybinding segment"), + _ if key_part.is_none() => key_part = Some(part.trim()), + _ => bail!("only one non-modifier key is supported"), + } + } + + let Some(key) = key_part else { + bail!("missing key"); + }; + + let code = match key.to_ascii_lowercase().as_str() { + "left" | "left-arrow" => KeyCode::Left, + "right" | "right-arrow" => KeyCode::Right, + "up" | "up-arrow" => KeyCode::Up, + "down" | "down-arrow" => KeyCode::Down, + "pageup" | "page-up" => KeyCode::PageUp, + "pagedown" | "page-down" => KeyCode::PageDown, + "home" => KeyCode::Home, + "end" => KeyCode::End, + "enter" | "return" => KeyCode::Enter, + "esc" | "escape" => KeyCode::Esc, + "backspace" => KeyCode::Backspace, + _ => { + let mut chars = key.chars(); + let Some(ch) = chars.next() else { + bail!("missing key"); + }; + if chars.next().is_some() { + bail!("unknown key {key}"); + } + let ch = if modifiers.contains(KeyModifiers::SHIFT) { + ch.to_ascii_uppercase() + } else { + ch + }; + KeyCode::Char(ch) + } + }; + + if matches!(code, KeyCode::Char(ch) if ch.is_ascii_uppercase()) { + modifiers.insert(KeyModifiers::SHIFT); + } + + Ok(KeyBinding { code, modifiers }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn key(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent { + KeyEvent::new(code, modifiers) + } + + #[test] + fn default_keybindings_include_ctrl_page_navigation() { + let bindings = KeyBindings::default(); + + assert_eq!( + bindings.action_for_key(key(KeyCode::Char('u'), KeyModifiers::CONTROL)), + Some(Action::PageUp) + ); + assert_eq!( + bindings.action_for_key(key(KeyCode::Char('d'), KeyModifiers::CONTROL)), + Some(Action::PageDown) + ); + } + + #[test] + fn config_can_replace_page_bindings_with_shifted_letters() { + let mut bindings = KeyBindings::default(); + apply_config( + &mut bindings, + r#" + [keybindings] + page_up = "K" + page_down = "J" + "#, + ) + .expect("config should parse"); + + assert_eq!( + bindings.action_for_key(key(KeyCode::Char('K'), KeyModifiers::SHIFT)), + Some(Action::PageUp) + ); + assert_eq!( + bindings.action_for_key(key(KeyCode::Char('J'), KeyModifiers::SHIFT)), + Some(Action::PageDown) + ); + assert_eq!( + bindings.action_for_key(key(KeyCode::Char('u'), KeyModifiers::CONTROL)), + None + ); + } + + #[test] + fn config_keeps_unspecified_defaults() { + let mut bindings = KeyBindings::default(); + apply_config( + &mut bindings, + r#" + [keybindings] + page_down = "J" + "#, + ) + .expect("config should parse"); + + assert_eq!( + bindings.action_for_key(key(KeyCode::Char('k'), KeyModifiers::empty())), + Some(Action::ScrollUp) + ); + } + + #[test] + fn configured_binding_takes_key_from_default_action() { + let mut bindings = KeyBindings::default(); + apply_config( + &mut bindings, + r#" + [keybindings] + page_down = "j" + "#, + ) + .expect("config should parse"); + + assert_eq!( + bindings.action_for_key(key(KeyCode::Char('j'), KeyModifiers::empty())), + Some(Action::PageDown) + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index b92232a..7861a3a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,6 @@ mod app; mod cli; +mod config; mod diff; mod git; mod model; @@ -13,6 +14,7 @@ use anyhow::{Context, Result}; use crate::{ cli::parse_cli_options, + config::KeyBindings, diff::{build_file_views, get_diff_file_descriptors}, git::{get_repository_root, resolve_comparison}, model::{ResolvedComparison, StrategyId}, @@ -24,6 +26,7 @@ use crate::{ pub fn run() -> Result<()> { let options = parse_cli_options()?; set_theme_mode_override(options.theme_mode); + let keybindings = KeyBindings::load(options.config_path.as_deref(), options.no_config)?; let current_directory = std::env::current_dir().context("failed to read current directory")?; let repository_root = get_repository_root(¤t_directory)?; @@ -58,5 +61,5 @@ pub fn run() -> Result<()> { let file_views = build_file_views(&repository_root, &comparison, &descriptors); let review_store = ReviewStore::load(&repository_root, &comparison)?; - start_interactive_review(&file_views, &comparison, review_store) + start_interactive_review(&file_views, &comparison, review_store, keybindings) } diff --git a/src/terminal.rs b/src/terminal.rs index 65d0869..08d510b 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -16,6 +16,7 @@ use ratatui::{ use crate::{ app::{AppState, handle_keypress, handle_mouse}, + config::KeyBindings, model::{DiffFileView, ResolvedComparison}, render::render_frame, review::ReviewStore, @@ -60,6 +61,7 @@ fn run_event_loop( files: &[DiffFileView], comparison: &ResolvedComparison, review_store: &mut ReviewStore, + keybindings: &KeyBindings, ) -> Result<()> { let initial_reviewed = review_store.reviewed_flags_for_files(files); let mut app = AppState::new(files.len(), initial_reviewed); @@ -74,7 +76,7 @@ fn run_event_loop( let (_, rows) = crossterm::terminal::size().context("failed to read terminal size")?; - let outcome = handle_keypress(key, files, &mut app, rows); + let outcome = handle_keypress(key, files, &mut app, rows, keybindings); if let Some((file_index, reviewed)) = outcome.review_toggled { review_store.set_reviewed(&files[file_index].review_key, reviewed); @@ -104,6 +106,7 @@ pub(crate) fn start_interactive_review( files: &[DiffFileView], comparison: &ResolvedComparison, mut review_store: ReviewStore, + keybindings: KeyBindings, ) -> Result<()> { if !io::stdin().is_terminal() || !io::stdout().is_terminal() { bail!("Interactive TTY is required to run deff"); @@ -133,7 +136,13 @@ pub(crate) fn start_interactive_review( } }; - let run_result = run_event_loop(&mut terminal, files, comparison, &mut review_store); + let run_result = run_event_loop( + &mut terminal, + files, + comparison, + &mut review_store, + &keybindings, + ); let mut restore_error: Option = None; if let Err(error) = disable_raw_mode() {