fix(tui): filter KeyEventKind to prevent Release/Repeat from triggering commands (closes #23)
This commit is contained in:
@@ -539,12 +539,65 @@ impl App {
|
||||
// ── Key handling ────────────────────────────────────────────────
|
||||
|
||||
pub fn handle_key(&mut self, key: crossterm::event::KeyEvent) {
|
||||
use crossterm::event::KeyEventKind;
|
||||
|
||||
let should_handle = match key.kind {
|
||||
KeyEventKind::Press => true,
|
||||
KeyEventKind::Repeat => self.is_repeatable_key(&key),
|
||||
KeyEventKind::Release => false,
|
||||
};
|
||||
|
||||
if !should_handle {
|
||||
return;
|
||||
}
|
||||
|
||||
match self.mode {
|
||||
AppMode::Normal => self.handle_normal_key(key),
|
||||
AppMode::Settings => self.handle_settings_key(key),
|
||||
}
|
||||
}
|
||||
|
||||
/// Keys that should auto-repeat when held (scroll/navigation only).
|
||||
fn is_repeatable_key(&self, key: &crossterm::event::KeyEvent) -> bool {
|
||||
use crossterm::event::{KeyCode, KeyModifiers};
|
||||
|
||||
let plain = key.modifiers.is_empty();
|
||||
let ctrl = key.modifiers == KeyModifiers::CONTROL;
|
||||
|
||||
match self.mode {
|
||||
AppMode::Normal => {
|
||||
(plain
|
||||
&& matches!(
|
||||
key.code,
|
||||
KeyCode::Char('j')
|
||||
| KeyCode::Down
|
||||
| KeyCode::Char('k')
|
||||
| KeyCode::Up
|
||||
| KeyCode::PageDown
|
||||
| KeyCode::PageUp
|
||||
))
|
||||
|| (ctrl
|
||||
&& matches!(
|
||||
key.code,
|
||||
KeyCode::Char('d')
|
||||
| KeyCode::Char('u')
|
||||
| KeyCode::Char('f')
|
||||
| KeyCode::Char('b')
|
||||
))
|
||||
}
|
||||
AppMode::Settings => plain
|
||||
&& matches!(
|
||||
key.code,
|
||||
KeyCode::Char('j')
|
||||
| KeyCode::Down
|
||||
| KeyCode::Char('k')
|
||||
| KeyCode::Up
|
||||
| KeyCode::Left
|
||||
| KeyCode::Right
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_normal_key(&mut self, key: crossterm::event::KeyEvent) {
|
||||
use crossterm::event::{KeyCode, KeyModifiers};
|
||||
|
||||
@@ -2863,6 +2916,145 @@ plain text line
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
// ── Issue #23: KeyEventKind filtering ───────────────────────────
|
||||
|
||||
fn make_key_with_kind(
|
||||
code: crossterm::event::KeyCode,
|
||||
modifiers: crossterm::event::KeyModifiers,
|
||||
kind: crossterm::event::KeyEventKind,
|
||||
) -> crossterm::event::KeyEvent {
|
||||
crossterm::event::KeyEvent::new_with_kind(code, modifiers, kind)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issue23_release_quit_ignored() {
|
||||
use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers};
|
||||
let mut app = App::new();
|
||||
let release_q = make_key_with_kind(KeyCode::Char('q'), KeyModifiers::NONE, KeyEventKind::Release);
|
||||
app.handle_key(release_q);
|
||||
assert!(!app.should_quit, "Release+q must NOT quit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issue23_release_scroll_ignored() {
|
||||
use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers};
|
||||
let path = make_temp_file("a\nb\nc\n");
|
||||
let result = std::panic::catch_unwind(|| {
|
||||
let mut app = App::new();
|
||||
load_file_ready(&mut app, &path);
|
||||
|
||||
let release_j = make_key_with_kind(KeyCode::Char('j'), KeyModifiers::NONE, KeyEventKind::Release);
|
||||
app.handle_key(release_j);
|
||||
assert_eq!(app.cursor_line, 0, "Release+j must NOT scroll");
|
||||
cleanup(&path);
|
||||
});
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issue23_repeat_quit_ignored() {
|
||||
use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers};
|
||||
let mut app = App::new();
|
||||
let repeat_q = make_key_with_kind(KeyCode::Char('q'), KeyModifiers::NONE, KeyEventKind::Repeat);
|
||||
app.handle_key(repeat_q);
|
||||
assert!(!app.should_quit, "Repeat+q must NOT quit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issue23_repeat_j_passes() {
|
||||
use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers};
|
||||
let path = make_temp_file("a\nb\nc\n");
|
||||
let result = std::panic::catch_unwind(|| {
|
||||
let mut app = App::new();
|
||||
load_file_ready(&mut app, &path);
|
||||
|
||||
let repeat_j = make_key_with_kind(KeyCode::Char('j'), KeyModifiers::NONE, KeyEventKind::Repeat);
|
||||
app.handle_key(repeat_j);
|
||||
assert_eq!(app.cursor_line, 1, "Repeat+j must scroll");
|
||||
cleanup(&path);
|
||||
});
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issue23_repeat_ctrl_d_passes() {
|
||||
use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers};
|
||||
let path = make_temp_file("a\nb\nc\nd\ne\nf\ng\nh\ni\nj\n");
|
||||
let result = std::panic::catch_unwind(|| {
|
||||
let mut app = App::new();
|
||||
app.content_height = 5;
|
||||
load_file_ready(&mut app, &path);
|
||||
install_vhi(&mut app, &[1usize; 10]);
|
||||
|
||||
let repeat_ctrl_d = make_key_with_kind(
|
||||
KeyCode::Char('d'), KeyModifiers::CONTROL, KeyEventKind::Repeat,
|
||||
);
|
||||
app.handle_key(repeat_ctrl_d);
|
||||
assert!(app.cursor_line > 0, "Repeat+Ctrl+d must scroll half page");
|
||||
cleanup(&path);
|
||||
});
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issue23_repeat_ctrl_d_without_ctrl_ignored() {
|
||||
use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers};
|
||||
let path = make_temp_file("a\nb\nc\n");
|
||||
let result = std::panic::catch_unwind(|| {
|
||||
let mut app = App::new();
|
||||
load_file_ready(&mut app, &path);
|
||||
|
||||
let repeat_plain_d = make_key_with_kind(
|
||||
KeyCode::Char('d'), KeyModifiers::NONE, KeyEventKind::Repeat,
|
||||
);
|
||||
app.handle_key(repeat_plain_d);
|
||||
assert_eq!(app.cursor_line, 0, "Repeat+plain d must NOT scroll");
|
||||
cleanup(&path);
|
||||
});
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issue23_repeat_g_ignored() {
|
||||
use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers};
|
||||
let path = make_temp_file("a\nb\nc\n");
|
||||
let result = std::panic::catch_unwind(|| {
|
||||
let mut app = App::new();
|
||||
load_file_ready(&mut app, &path);
|
||||
|
||||
let repeat_g = make_key_with_kind(KeyCode::Char('g'), KeyModifiers::NONE, KeyEventKind::Repeat);
|
||||
app.handle_key(repeat_g);
|
||||
assert_eq!(app.cursor_line, 0, "Repeat+g must NOT jump");
|
||||
assert!(app.last_g_press.is_none(), "Repeat+g must not set last_g_press");
|
||||
cleanup(&path);
|
||||
});
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issue23_settings_repeat_left_right_passes() {
|
||||
use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers};
|
||||
let mut app = App::new();
|
||||
enter_settings(&mut app);
|
||||
assert_eq!(app.mode, AppMode::Settings);
|
||||
|
||||
let repeat_right = make_key_with_kind(KeyCode::Right, KeyModifiers::NONE, KeyEventKind::Repeat);
|
||||
app.handle_key(repeat_right);
|
||||
app.handle_key(make_key(KeyCode::Enter));
|
||||
assert_eq!(app.mode, AppMode::Normal, "Repeat Right in Settings should work then Enter closes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issue23_settings_repeat_enter_ignored() {
|
||||
use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers};
|
||||
let mut app = App::new();
|
||||
enter_settings(&mut app);
|
||||
|
||||
let repeat_enter = make_key_with_kind(KeyCode::Enter, KeyModifiers::NONE, KeyEventKind::Repeat);
|
||||
app.handle_key(repeat_enter);
|
||||
assert_eq!(app.mode, AppMode::Settings, "Repeat+Enter must NOT close settings");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_append_no_trailing_newline_no_new_lines_only_height_change() {
|
||||
let path = make_temp_file("abc");
|
||||
|
||||
Reference in New Issue
Block a user