diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index 5ba9c2f..05548a0 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -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");