fix(tui): filter KeyEventKind to prevent Release/Repeat from triggering commands (closes #23)

This commit is contained in:
dailz
2026-06-10 17:34:17 +08:00
parent e9f75ce3b1
commit 463c53148b

View File

@@ -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");