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 ────────────────────────────────────────────────
|
// ── Key handling ────────────────────────────────────────────────
|
||||||
|
|
||||||
pub fn handle_key(&mut self, key: crossterm::event::KeyEvent) {
|
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 {
|
match self.mode {
|
||||||
AppMode::Normal => self.handle_normal_key(key),
|
AppMode::Normal => self.handle_normal_key(key),
|
||||||
AppMode::Settings => self.handle_settings_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) {
|
fn handle_normal_key(&mut self, key: crossterm::event::KeyEvent) {
|
||||||
use crossterm::event::{KeyCode, KeyModifiers};
|
use crossterm::event::{KeyCode, KeyModifiers};
|
||||||
|
|
||||||
@@ -2863,6 +2916,145 @@ plain text line
|
|||||||
assert!(result.is_ok());
|
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]
|
#[test]
|
||||||
fn test_append_no_trailing_newline_no_new_lines_only_height_change() {
|
fn test_append_no_trailing_newline_no_new_lines_only_height_change() {
|
||||||
let path = make_temp_file("abc");
|
let path = make_temp_file("abc");
|
||||||
|
|||||||
Reference in New Issue
Block a user