diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-24 02:47:07 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-24 02:47:07 +0300 |
| commit | 520d0b8b9c1b9d621274411d5fc26e35df9d60d2 (patch) | |
| tree | f40e2a70743388073290a7c157f35bab4eb9bcde /src | |
| parent | 74fa90cecc6f6dfd93dc64893659771001ad2d82 (diff) | |
fix: prevent release key fallback from doubling input
refs #769
Diffstat (limited to 'src')
| -rw-r--r-- | src/input/encode.rs | 59 | ||||
| -rw-r--r-- | src/input/model.rs | 4 | ||||
| -rw-r--r-- | src/pane/terminal.rs | 77 |
3 files changed, 136 insertions, 4 deletions
diff --git a/src/input/encode.rs b/src/input/encode.rs index 2778a78b..b7259f38 100644 --- a/src/input/encode.rs +++ b/src/input/encode.rs @@ -12,6 +12,15 @@ pub fn encode_key(key: KeyEvent, protocol: KeyboardProtocol) -> Vec<u8> { } pub fn encode_terminal_key(key: TerminalKey, protocol: KeyboardProtocol) -> Vec<u8> { + // A release event only produces bytes when the pane protocol reports event + // types (Kitty REPORT_EVENT_TYPES). Otherwise the child expects a single + // legacy byte per keystroke, so re-emitting it on release would double keys + // like Enter/Backspace. The Ghostty wrapper can route release events through + // this fallback, so guard the fallback encoder too. + if key.kind == crossterm::event::KeyEventKind::Release && !protocol.reports_event_types() { + return Vec::new(); + } + if let Some(bytes) = encode_text_input(&key) { return bytes; } @@ -21,6 +30,9 @@ pub fn encode_terminal_key(key: TerminalKey, protocol: KeyboardProtocol) -> Vec< return bytes; } } + if key.kind == crossterm::event::KeyEventKind::Release && protocol.reports_event_types() { + return Vec::new(); + } encode_legacy(key.as_key_event()) } @@ -145,9 +157,10 @@ fn push_mouse_codepoint(bytes: &mut Vec<u8>, value: u32) -> Option<()> { /// Returns None if the key doesn't need CSI u (unmodified basic keys). fn try_encode_csi_u(key: &TerminalKey, flags: u16) -> Option<Vec<u8>> { let mods = key.modifiers; + let event_suffix = kitty_event_suffix(key, flags); // Unmodified keys use legacy encoding (more compatible) - if mods.is_empty() { + if mods.is_empty() && event_suffix.is_none() { return None; } @@ -166,7 +179,9 @@ fn try_encode_csi_u(key: &TerminalKey, flags: u16) -> Option<Vec<u8>> { | KeyCode::PageDown | KeyCode::Insert | KeyCode::Delete - | KeyCode::F(_) => { + | KeyCode::F(_) + if event_suffix.is_none() => + { return None; // let legacy handle these } _ => {} @@ -182,11 +197,20 @@ fn try_encode_csi_u(key: &TerminalKey, flags: u16) -> Option<Vec<u8>> { KeyCode::Tab => (9, None), KeyCode::Backspace => (127, None), KeyCode::Esc => (27, None), + KeyCode::Left => (57417, None), + KeyCode::Right => (57418, None), + KeyCode::Up => (57419, None), + KeyCode::Down => (57420, None), + KeyCode::PageUp => (57421, None), + KeyCode::PageDown => (57422, None), + KeyCode::Home => (57423, None), + KeyCode::End => (57424, None), + KeyCode::Insert => (57425, None), + KeyCode::Delete => (57426, None), _ => return None, // fall back to legacy for unhandled keys }; let modifier = kitty_modifier(mods); - let event_suffix = kitty_event_suffix(key, flags); let sequence = match (alternate_shifted, event_suffix) { (Some(shifted), Some(event)) => format!("\x1b[{codepoint}:{shifted};{modifier}:{event}u"), @@ -719,6 +743,35 @@ mod tests { } #[test] + fn release_bytes_gated_on_report_event_types() { + for code in [KeyCode::Enter, KeyCode::Backspace] { + let release = KeyEvent::new_with_kind( + code, + KeyModifiers::empty(), + crossterm::event::KeyEventKind::Release, + ); + + // Legacy and Kitty disambiguate-only (no REPORT_EVENT_TYPES) must not + // emit a byte on release, otherwise Enter/Backspace double (issue #769). + assert_eq!(encode_key(release, KeyboardProtocol::Legacy), b""); + assert_eq!( + encode_key(release, KeyboardProtocol::Kitty { flags: 1 }), + b"" + ); + } + + let modified_release = KeyEvent::new_with_kind( + KeyCode::Enter, + KeyModifiers::CONTROL, + crossterm::event::KeyEventKind::Release, + ); + assert_eq!( + encode_key(modified_release, KeyboardProtocol::Kitty { flags: 3 }), + b"\x1b[13;5:3u" + ); + } + + #[test] fn kitty_shifted_symbol_sends_text() { let key = TerminalKey::new(KeyCode::Char('1'), KeyModifiers::SHIFT) .with_shifted_codepoint('!' as u32); diff --git a/src/input/model.rs b/src/input/model.rs index a439217f..1ce107d4 100644 --- a/src/input/model.rs +++ b/src/input/model.rs @@ -95,6 +95,10 @@ impl KeyboardProtocol { Self::Kitty { flags } } } + + pub(crate) fn reports_event_types(self) -> bool { + matches!(self, Self::Kitty { flags } if flags & 0b0000_0010 != 0) + } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index ed5d1061..e573f958 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -1019,7 +1019,11 @@ impl GhosttyPaneTerminal { return crate::input::encode_terminal_key(key, protocol); }; match encoder.encode(&event) { - Ok(bytes) if !bytes.is_empty() => bytes, + Ok(bytes) + if !bytes.is_empty() && encoded_key_preserves_event_kind(&bytes, key, protocol) => + { + bytes + } Ok(_) | Err(_) => crate::input::encode_terminal_key(key, protocol), } } @@ -1291,6 +1295,23 @@ impl GhosttyPaneTerminal { } } +fn encoded_key_preserves_event_kind( + bytes: &[u8], + key: crate::input::TerminalKey, + protocol: crate::input::KeyboardProtocol, +) -> bool { + if !protocol.reports_event_types() || key.kind == crossterm::event::KeyEventKind::Press { + return true; + } + + std::str::from_utf8(bytes) + .ok() + .and_then(crate::input::parse_terminal_key_sequence) + .is_some_and(|parsed| { + parsed.code == key.code && parsed.modifiers == key.modifiers && parsed.kind == key.kind + }) +} + fn cursor_position_settle_pending(core: &GhosttyPaneCore) -> bool { core.cursor_settle_state.pending() } @@ -2433,6 +2454,60 @@ mod tests { } #[test] + fn ghostty_enter_backspace_release_in_legacy_pane_emits_nothing() { + let (tx, _rx) = mpsc::channel(4); + let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); + let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); + + for code in [ + crossterm::event::KeyCode::Enter, + crossterm::event::KeyCode::Backspace, + ] { + let press = pane.encode_terminal_key( + crate::input::TerminalKey::new(code, crossterm::event::KeyModifiers::empty()), + crate::input::KeyboardProtocol::Legacy, + ); + let release = pane.encode_terminal_key( + crate::input::TerminalKey::new(code, crossterm::event::KeyModifiers::empty()) + .with_kind(crossterm::event::KeyEventKind::Release), + crate::input::KeyboardProtocol::Legacy, + ); + assert!(!press.is_empty(), "{code:?} press should emit bytes"); + assert!( + release.is_empty(), + "{code:?} release should emit nothing in a legacy pane, got {release:?}" + ); + } + } + + #[test] + fn ghostty_release_still_encoded_for_report_event_pane() { + let (tx, _rx) = mpsc::channel(4); + // Push kitty flags including REPORT_EVENT_TYPES (0b10) + DISAMBIGUATE (0b1). + let mut terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); + terminal.write(b"\x1b[>3u"); + let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); + + let release = pane.encode_terminal_key( + crate::input::TerminalKey::new( + crossterm::event::KeyCode::Enter, + crossterm::event::KeyModifiers::empty(), + ) + .with_kind(crossterm::event::KeyEventKind::Release), + pane.keyboard_protocol().unwrap(), + ); + let parsed = + crate::input::parse_terminal_key_sequence(std::str::from_utf8(&release).unwrap()) + .unwrap(); + assert_eq!(parsed.code, crossterm::event::KeyCode::Enter); + assert_eq!( + parsed.kind, + crossterm::event::KeyEventKind::Release, + "report-event pane should encode a release, got {release:?}" + ); + } + + #[test] fn ghostty_char_keys_still_use_herdr_encoding() { let (tx, _rx) = mpsc::channel(4); let mut terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); |
