From 53cfd0683113a90baf725cd21a9612f39f0a2425 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Mon, 5 Oct 2026 23:07:59 +0300 Subject: [PATCH] fix: let libghostty encode every key sent to panes --- crates/ghostty-vt/src/lib.rs | 29 ++- src/client/mod.rs | 1 + src/client/shell/input.rs | 33 ++- src/client/shell/state.rs | 4 + src/client/shell/tests/input_conformance.rs | 64 +++-- src/pane/input.rs | 245 +++++++++++++++----- src/pane/terminal.rs | 166 +++++++------ 7 files changed, 391 insertions(+), 151 deletions(-) diff --git a/crates/ghostty-vt/src/lib.rs b/crates/ghostty-vt/src/lib.rs index 65b4d1a5a..1270d9f03 100644 --- a/crates/ghostty-vt/src/lib.rs +++ b/crates/ghostty-vt/src/lib.rs @@ -3021,13 +3021,19 @@ impl Drop for RenderState { pub struct KeyEvent { raw: ffi::GhosttyKeyEvent, + // libghostty borrows the UTF-8 pointer instead of copying it, so the event + // owns the text for as long as the handle can be encoded. + utf8: Box, } impl KeyEvent { pub fn new() -> Result { let mut raw = ptr::null_mut(); unsafe { ffi::ghostty_key_event_new(ptr::null(), &mut raw).into_result()? }; - Ok(Self { raw }) + Ok(Self { + raw, + utf8: Box::from(""), + }) } pub fn set_action(&mut self, action: ffi::GhosttyKeyAction) { @@ -3043,8 +3049,13 @@ impl KeyEvent { } pub fn set_utf8(&mut self, text: &str) { + self.utf8 = Box::from(text); unsafe { - ffi::ghostty_key_event_set_utf8(self.raw, text.as_ptr().cast::(), text.len()) + ffi::ghostty_key_event_set_utf8( + self.raw, + self.utf8.as_ptr().cast::(), + self.utf8.len(), + ) } } @@ -3055,6 +3066,10 @@ impl KeyEvent { pub fn set_consumed_mods(&mut self, mods: u16) { unsafe { ffi::ghostty_key_event_set_consumed_mods(self.raw, mods) } } + + pub fn set_composing(&mut self, composing: bool) { + unsafe { ffi::ghostty_key_event_set_composing(self.raw, composing) } + } } impl Drop for KeyEvent { @@ -3078,6 +3093,16 @@ impl KeyEncoder { unsafe { ffi::ghostty_key_encoder_setopt_from_terminal(self.raw, terminal.raw()) } } + pub fn set_kitty_flags(&mut self, flags: u8) { + unsafe { + ffi::ghostty_key_encoder_setopt( + self.raw, + ffi::GhosttyKeyEncoderOption_GHOSTTY_KEY_ENCODER_OPT_KITTY_FLAGS, + (&flags as *const u8).cast(), + ) + } + } + pub fn encode(&mut self, event: &KeyEvent) -> Result, Error> { encode_with_retry(|buf, len, out_len| unsafe { ffi::ghostty_key_encoder_encode(self.raw, event.raw, buf, len, out_len) diff --git a/src/client/mod.rs b/src/client/mod.rs index 6a754e1fb..7e9a87e78 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -472,6 +472,7 @@ async fn run_client_loop( }; let mut federated = endpoint_catalog.has_enabled_ssh(); if let Some(shell) = state.shell.as_mut() { + shell.set_host_reports_key_releases(config.host_escape_disambiguation_active); shell.set_graphics_cell_size(initial_cell_width_px, initial_cell_height_px); shell.set_endpoint_catalog(&endpoint_catalog.ssh); shell.set_endpoint_methods_for( diff --git a/src/client/shell/input.rs b/src/client/shell/input.rs index c7664f290..c7a03b17f 100644 --- a/src/client/shell/input.rs +++ b/src/client/shell/input.rs @@ -98,6 +98,10 @@ impl ClientShellState { ) } + pub(crate) fn set_host_reports_key_releases(&mut self, reports: bool) { + self.host_reports_key_releases = reports; + } + #[cfg(test)] pub(crate) fn handle_input_bytes(&mut self, data: &[u8]) -> ClientShellInput { self.handle_raw_events(crate::raw_input::parse_raw_input_bytes_sync(data)) @@ -324,6 +328,11 @@ impl ClientShellState { self.copy_input_queue.push_back(key); return; } + let key = if self.host_reports_key_releases && key.generated_text.is_some() { + key.with_physical_identity_hint(true) + } else { + key + }; let lease_key = crate::input::InputLeaseKey::new(LOCAL_INPUT_SOURCE, &key); let key = self.input_leases.normalize_press(&lease_key, key); match key.kind { @@ -351,7 +360,26 @@ impl ClientShellState { self.execute_repeat_plan(lease_key, key, plan, outcome); } KeyEventKind::Release => { - if let Some(lease) = self.input_leases.remove_forwarded(&lease_key) { + // A press that arrived as text ("X") is released by its key + // report ("x" with shifted alternate "X"); match either. + let shifted_lease_key = + key.shifted_codepoint + .and_then(char::from_u32) + .map(|shifted| { + crate::input::InputLeaseKey::new( + LOCAL_INPUT_SOURCE, + &crate::input::TerminalKey::new( + KeyCode::Char(shifted), + key.modifiers, + ), + ) + }); + let forwarded = self.input_leases.remove_forwarded(&lease_key).or_else(|| { + shifted_lease_key + .as_ref() + .and_then(|shifted| self.input_leases.remove_forwarded(shifted)) + }); + if let Some(lease) = forwarded { let release = lease .key .with_modifiers(key.modifiers) @@ -359,6 +387,9 @@ impl ClientShellState { self.push_pane_key(lease.target, release, outcome); } else { let _ = self.input_leases.remove(&lease_key); + if let Some(shifted) = shifted_lease_key { + let _ = self.input_leases.remove(&shifted); + } } } } diff --git a/src/client/shell/state.rs b/src/client/shell/state.rs index 9c69fbe56..8e1bd3103 100644 --- a/src/client/shell/state.rs +++ b/src/client/shell/state.rs @@ -907,6 +907,9 @@ pub(crate) struct ClientShellState { pub(super) pane_mouse_gesture: Option, pub(super) link_hover: Option, pub(super) url_click_consumes_until_up: bool, + /// The host terminal reports key releases (Kitty event types), so text + /// presses can be tracked until their release arrives. + pub(super) host_reports_key_releases: bool, pub(super) replaying_url_click: bool, pub(super) selection: Option>, pub(super) last_pane_click: Option, @@ -1072,6 +1075,7 @@ impl ClientShellState { pane_mouse_gesture: None, link_hover: None, url_click_consumes_until_up: false, + host_reports_key_releases: false, replaying_url_click: false, selection: None, last_pane_click: None, diff --git a/src/client/shell/tests/input_conformance.rs b/src/client/shell/tests/input_conformance.rs index a81a9637d..0c02f9f92 100644 --- a/src/client/shell/tests/input_conformance.rs +++ b/src/client/shell/tests/input_conformance.rs @@ -225,16 +225,21 @@ impl Oracle { } } +fn is_legacy_pane(pane_mode: &[u8]) -> bool { + matches!(pane_mode, b"" | b"\x1b[?1h") +} + /// Agreed exception table: panes that negotiated no keyboard protocol keep the -/// classic bytes for keys where Ghostty's escape sequences break shells. +/// classic bytes for keys where Ghostty's escape sequences break shells, and +/// get Super chords as basic Kitty reports (see `is_legacy_pane` use above). fn legacy_shell_exception(pane_mode: &[u8], key: &str, mods: u16) -> Option> { - if !matches!(pane_mode, b"" | b"\x1b[?1h") { + if !is_legacy_pane(pane_mode) { return None; } let alt = mods & ghostty::MOD_ALT != 0; let base = mods & !ghostty::MOD_ALT; let classic: &[u8] = match (key, base) { - ("enter", m) if m == ghostty::MOD_SHIFT || m == ghostty::MOD_CTRL => b"\r", + ("enter", m) if m != 0 => b"\r", ("m", ghostty::MOD_CTRL) => b"\r", ("i", ghostty::MOD_CTRL) | ("tab", ghostty::MOD_CTRL) => b"\t", ("[", ghostty::MOD_CTRL) => b"\x1b", @@ -264,7 +269,7 @@ impl HerdrPath { runtime.test_process_pty_bytes(app_output); while rx.try_recv().is_ok() {} Self { - state: Self::fresh_state(), + state: Self::fresh_state(host), framer: Self::fresh_framer(host), runtime, rx, @@ -272,8 +277,10 @@ impl HerdrPath { } } - fn fresh_state() -> ClientShellState { + fn fresh_state(host: HostProfile) -> ClientShellState { let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + // Mirrors the client: Herdr pushes Kitty event types on Kitty hosts. + state.set_host_reports_key_releases(host == HostProfile::Kitty); state.set_snapshot(Box::new(snapshot())); state.set_pane_surface(surface()); state @@ -342,7 +349,7 @@ impl HerdrPath { } fn reset_client(&mut self) { - self.state = Self::fresh_state(); + self.state = Self::fresh_state(self.host); self.framer = Self::fresh_framer(self.host); } @@ -436,6 +443,7 @@ fn run_keyboard_conformance() -> Report { for &(pane_name, pane_mode) in PANE_MODES { let mut host_oracle = Oracle::new(host.setup(pane_mode)); let mut direct_oracle = Oracle::new(pane_mode); + let mut disambiguate_oracle = Oracle::new(b"\x1b[>1u"); let mut cases = Vec::new(); for def in KEYS { @@ -449,6 +457,8 @@ fn run_keyboard_conformance() -> Report { let (mut press, release) = direct_oracle.keystroke(*def, mods); if let Some(classic) = legacy_shell_exception(pane_mode, def.name, mods) { press = classic; + } else if is_legacy_pane(pane_mode) && mods & ghostty::MOD_SUPER != 0 { + press = disambiguate_oracle.keystroke(*def, mods).0; } // A host that never reports releases gives nobody a release to forward. let expected = if host_bytes.1.is_empty() { @@ -461,7 +471,10 @@ fn run_keyboard_conformance() -> Report { } let mut expected_by_host_bytes: HashMap<&HostKeystroke, Vec<&Vec>> = HashMap::new(); - for (_, _, host_bytes, expected) in &cases { + for (_, mods, host_bytes, expected) in &cases { + if host == HostProfile::Legacy && mods & ghostty::MOD_SUPER != 0 { + continue; + } expected_by_host_bytes .entry(host_bytes) .or_default() @@ -479,8 +492,14 @@ fn run_keyboard_conformance() -> Report { .by_cell .entry((host.name(), pane_name, group)) .or_default(); - let distinct = &expected_by_host_bytes[host_bytes]; - if distinct.iter().any(|other| *other != expected) { + // Legacy hosts cannot report Super at all (they drop it or send + // the chord without it), so nothing downstream can recover it. + let super_unreportable = + host == HostProfile::Legacy && mods & ghostty::MOD_SUPER != 0; + let ambiguous = expected_by_host_bytes + .get(host_bytes) + .is_some_and(|distinct| distinct.iter().any(|other| *other != expected)); + if super_unreportable || ambiguous { tally.host_lossy += 1; continue; } @@ -517,7 +536,7 @@ fn run_keyboard_conformance() -> Report { report } -fn print_report(report: &Report) -> Tally { +fn print_report(report: &Report) { let mut total = Tally::default(); println!( "\n{:<12} {:<17} {:<6} {:>7} {:>7} {:>7} {:>7} {:>7} {:>7}", @@ -571,13 +590,12 @@ fn print_report(report: &Report) -> Tally { .collect(); std::fs::write(path, lines.join("\n")).expect("write failures"); } - total } -// Ratchet: these baselines may only go up. Raise them when a change improves a -// score; a drop means a regression in Herdr's input transparency. -const KEYBOARD_PASSED_BASELINE: usize = 15_203; -const MOUSE_PASSED_BASELINE: usize = 1_824; +// Ratchet: failure counts may only go down. Lower them when a change fixes +// cases; a rise means a regression in Herdr's input transparency. +const KEYBOARD_FAILURES_BASELINE: usize = 120; +const MOUSE_FAILURES_BASELINE: usize = 96; const SPLIT_IDLE_MISMATCH_BASELINE: usize = 6_730; const REPLY_IDLE_MISMATCH_BASELINE: usize = 77; @@ -1076,21 +1094,21 @@ fn split_read_robustness() { #[tokio::test(flavor = "multi_thread")] async fn mouse_transparency_conformance() { let report = run_mouse_conformance(); - let total = print_report(&report); + print_report(&report); assert!( - total.passed >= MOUSE_PASSED_BASELINE, - "mouse transparency regressed: {} < {MOUSE_PASSED_BASELINE}", - total.passed + report.failures.len() <= MOUSE_FAILURES_BASELINE, + "mouse transparency regressed: {} failures > {MOUSE_FAILURES_BASELINE}", + report.failures.len() ); } #[tokio::test(flavor = "multi_thread")] async fn keyboard_transparency_conformance() { let report = run_keyboard_conformance(); - let total = print_report(&report); + print_report(&report); assert!( - total.passed >= KEYBOARD_PASSED_BASELINE, - "keyboard transparency regressed: {} < {KEYBOARD_PASSED_BASELINE}", - total.passed + report.failures.len() <= KEYBOARD_FAILURES_BASELINE, + "keyboard transparency regressed: {} failures > {KEYBOARD_FAILURES_BASELINE}", + report.failures.len() ); } diff --git a/src/pane/input.rs b/src/pane/input.rs index 10900c948..70560ef69 100644 --- a/src/pane/input.rs +++ b/src/pane/input.rs @@ -1,6 +1,12 @@ +/// Build the key event the Ghostty app would build from an OS key event, so +/// libghostty can encode it for the pane's current keyboard modes: physical +/// key, modifiers, the layout text, the unshifted codepoint, and Shift marked +/// consumed when it produced the text. pub(super) fn ghostty_key_event_from_terminal_key( key: &crate::input::TerminalKey, ) -> Option { + use crossterm::event::KeyCode; + let mut event = crate::ghostty::KeyEvent::new().ok()?; event.set_action(match key.kind { crossterm::event::KeyEventKind::Press => { @@ -14,31 +20,132 @@ pub(super) fn ghostty_key_event_from_terminal_key( } }); let mut mods = ghostty_mods_from_key_modifiers(key.modifiers); - if matches!(key.code, crossterm::event::KeyCode::BackTab) { - // Ghostty represents backtab as Tab with Shift rather than a distinct key. - mods |= crate::ghostty::MOD_SHIFT; + match key.code { + KeyCode::Char(c) => { + // Legacy input reports the produced character ("%", "D"); Kitty + // reports the unshifted key. Both map to the same physical key. + let base = unshifted_char(c); + if base != c { + mods |= crate::ghostty::MOD_SHIFT; + } + event.set_key( + ghostty_key_from_char(base) + .unwrap_or(crate::ghostty::ffi::GhosttyKey_GHOSTTY_KEY_UNIDENTIFIED), + ); + event.set_unshifted_codepoint(base as u32); + let shifted = mods & crate::ghostty::MOD_SHIFT != 0; + if let Some(text) = key_text(key, base, shifted) { + if shifted && !text.starts_with(base) { + event.set_consumed_mods(crate::ghostty::MOD_SHIFT); + } + event.set_utf8(&text); + } + } + KeyCode::BackTab => { + // Ghostty represents backtab as Tab with Shift rather than a distinct key. + mods |= crate::ghostty::MOD_SHIFT; + event.set_key(crate::ghostty::ffi::GhosttyKey_GHOSTTY_KEY_TAB); + } + code => event.set_key(ghostty_key_from_key_code(code)?), } event.set_mods(mods); - event.set_key(ghostty_key_from_crossterm_key_code( - key.code, - key.shifted_codepoint, - )?); - - if let Some(text) = ghostty_key_text(key) { - event.set_utf8(&text); - } else { - event.set_utf8(""); - } - - if let Some(codepoint) = ghostty_unshifted_codepoint(key) { - event.set_unshifted_codepoint(codepoint); - } + event.set_composing(key.is_windows_dead_key()); Some(event) } -pub(super) fn ghostty_prefers_herdr_text_encoding(key: &crate::input::TerminalKey) -> bool { - matches!(key.code, crossterm::event::KeyCode::Char(_)) +/// Exception table for panes that negotiated no keyboard protocol. Ghostty sends +/// these chords as escape sequences that plain shells do not understand; keep +/// the classic key instead. libghostty still encodes the result (so Alt keeps +/// its ESC prefix). +pub(super) fn legacy_shell_key(key: crate::input::TerminalKey) -> crate::input::TerminalKey { + use crossterm::event::{KeyCode, KeyModifiers}; + + let alt = key.modifiers & KeyModifiers::ALT; + let chord = key.modifiers - KeyModifiers::ALT; + let classic = match (key.code, chord) { + (KeyCode::Enter, chord) if !chord.is_empty() => KeyCode::Enter, + (KeyCode::Char('m'), KeyModifiers::CONTROL) => KeyCode::Enter, + (KeyCode::Char('i'), KeyModifiers::CONTROL) | (KeyCode::Tab, KeyModifiers::CONTROL) => { + KeyCode::Tab + } + (KeyCode::Char('['), KeyModifiers::CONTROL) => KeyCode::Esc, + _ => return key, + }; + crate::input::TerminalKey::new(classic, alt) + .with_kind(key.kind) + .with_repeat_count(key.repeat_count) +} + +/// Second exception for panes that negotiated nothing: legacy encoding has no +/// Super, so Ghostty would deliver Cmd+C as a bare "c" (#3710). Encode Super +/// chords as basic Kitty (disambiguate) reports instead. +pub(super) fn legacy_super_chord(key: &crate::input::TerminalKey) -> bool { + key.modifiers + .contains(crossterm::event::KeyModifiers::SUPER) +} + +/// Text the key produced on the user's layout, before Ctrl/Alt transformations. +fn key_text(key: &crate::input::TerminalKey, base: char, shifted: bool) -> Option { + if let Some(text) = key + .generated_text + .as_ref() + .filter(|text| !text.is_empty() && !text.chars().any(char::is_control)) + { + return Some(text.clone()); + } + let produced = if shifted { + key.shifted_codepoint + .and_then(char::from_u32) + .or_else(|| us_shifted_char(base)) + .unwrap_or(base) + } else { + base + }; + (!produced.is_control()).then(|| produced.to_string()) +} + +fn unshifted_char(c: char) -> char { + if c.is_uppercase() { + let mut lower = c.to_lowercase(); + if let (Some(lower), None) = (lower.next(), lower.next()) { + return lower; + } + return c; + } + ghostty_unshifted_ascii_pair(c).unwrap_or(c) +} + +/// US-layout shifted character, used only when the host reported Shift without +/// the shifted alternate. +fn us_shifted_char(base: char) -> Option { + if base.is_ascii_lowercase() { + return Some(base.to_ascii_uppercase()); + } + Some(match base { + '1' => '!', + '2' => '@', + '3' => '#', + '4' => '$', + '5' => '%', + '6' => '^', + '7' => '&', + '8' => '*', + '9' => '(', + '0' => ')', + '-' => '_', + '=' => '+', + '[' => '{', + ']' => '}', + '\\' => '|', + ';' => ':', + '\'' => '"', + ',' => '<', + '.' => '>', + '/' => '?', + '`' => '~', + _ => return None, + }) } pub(super) fn ghostty_mods_from_key_modifiers(modifiers: crossterm::event::KeyModifiers) -> u16 { @@ -196,31 +303,14 @@ pub(super) fn ghostty_mouse_event_from_wheel_kind( Some(event) } -fn ghostty_key_text(key: &crate::input::TerminalKey) -> Option { - match key.code { - crossterm::event::KeyCode::Char(c) => Some( - key.shifted_codepoint - .and_then(char::from_u32) - .unwrap_or(c) - .to_string(), - ), - _ => None, - } -} - -fn ghostty_unshifted_codepoint(key: &crate::input::TerminalKey) -> Option { - match key.code { - crossterm::event::KeyCode::Char(c) => Some(c as u32), - _ => None, - } -} - -fn ghostty_key_from_crossterm_key_code( +/// Physical key for a non-character key code. Keys libghostty has no name for +/// (some media keys, Hyper/Meta, ISO level shifts) return None; the Ghostty app +/// cannot send them either. +fn ghostty_key_from_key_code( code: crossterm::event::KeyCode, - shifted_codepoint: Option, ) -> Option { use crate::ghostty::ffi; - use crossterm::event::KeyCode; + use crossterm::event::{KeyCode, MediaKeyCode, ModifierKeyCode}; match code { KeyCode::Backspace => Some(ffi::GhosttyKey_GHOSTTY_KEY_BACKSPACE), @@ -250,26 +340,75 @@ fn ghostty_key_from_crossterm_key_code( 10 => ffi::GhosttyKey_GHOSTTY_KEY_F10, 11 => ffi::GhosttyKey_GHOSTTY_KEY_F11, 12 => ffi::GhosttyKey_GHOSTTY_KEY_F12, + 13 => ffi::GhosttyKey_GHOSTTY_KEY_F13, + 14 => ffi::GhosttyKey_GHOSTTY_KEY_F14, + 15 => ffi::GhosttyKey_GHOSTTY_KEY_F15, + 16 => ffi::GhosttyKey_GHOSTTY_KEY_F16, + 17 => ffi::GhosttyKey_GHOSTTY_KEY_F17, + 18 => ffi::GhosttyKey_GHOSTTY_KEY_F18, + 19 => ffi::GhosttyKey_GHOSTTY_KEY_F19, + 20 => ffi::GhosttyKey_GHOSTTY_KEY_F20, + 21 => ffi::GhosttyKey_GHOSTTY_KEY_F21, + 22 => ffi::GhosttyKey_GHOSTTY_KEY_F22, + 23 => ffi::GhosttyKey_GHOSTTY_KEY_F23, + 24 => ffi::GhosttyKey_GHOSTTY_KEY_F24, + 25 => ffi::GhosttyKey_GHOSTTY_KEY_F25, _ => return None, }), - KeyCode::Char(c) => ghostty_key_from_char(c, shifted_codepoint), + KeyCode::CapsLock => Some(ffi::GhosttyKey_GHOSTTY_KEY_CAPS_LOCK), + KeyCode::ScrollLock => Some(ffi::GhosttyKey_GHOSTTY_KEY_SCROLL_LOCK), + KeyCode::NumLock => Some(ffi::GhosttyKey_GHOSTTY_KEY_NUM_LOCK), + KeyCode::PrintScreen => Some(ffi::GhosttyKey_GHOSTTY_KEY_PRINT_SCREEN), + KeyCode::Pause => Some(ffi::GhosttyKey_GHOSTTY_KEY_PAUSE), + KeyCode::Menu => Some(ffi::GhosttyKey_GHOSTTY_KEY_CONTEXT_MENU), + KeyCode::KeypadBegin => Some(ffi::GhosttyKey_GHOSTTY_KEY_NUMPAD_BEGIN), + KeyCode::Media(MediaKeyCode::PlayPause) => { + Some(ffi::GhosttyKey_GHOSTTY_KEY_MEDIA_PLAY_PAUSE) + } + KeyCode::Media(MediaKeyCode::Stop) => Some(ffi::GhosttyKey_GHOSTTY_KEY_MEDIA_STOP), + KeyCode::Media(MediaKeyCode::TrackNext) => { + Some(ffi::GhosttyKey_GHOSTTY_KEY_MEDIA_TRACK_NEXT) + } + KeyCode::Media(MediaKeyCode::TrackPrevious) => { + Some(ffi::GhosttyKey_GHOSTTY_KEY_MEDIA_TRACK_PREVIOUS) + } + KeyCode::Media(MediaKeyCode::LowerVolume) => { + Some(ffi::GhosttyKey_GHOSTTY_KEY_AUDIO_VOLUME_DOWN) + } + KeyCode::Media(MediaKeyCode::RaiseVolume) => { + Some(ffi::GhosttyKey_GHOSTTY_KEY_AUDIO_VOLUME_UP) + } + KeyCode::Media(MediaKeyCode::MuteVolume) => { + Some(ffi::GhosttyKey_GHOSTTY_KEY_AUDIO_VOLUME_MUTE) + } + KeyCode::Modifier(ModifierKeyCode::LeftShift) => { + Some(ffi::GhosttyKey_GHOSTTY_KEY_SHIFT_LEFT) + } + KeyCode::Modifier(ModifierKeyCode::RightShift) => { + Some(ffi::GhosttyKey_GHOSTTY_KEY_SHIFT_RIGHT) + } + KeyCode::Modifier(ModifierKeyCode::LeftControl) => { + Some(ffi::GhosttyKey_GHOSTTY_KEY_CONTROL_LEFT) + } + KeyCode::Modifier(ModifierKeyCode::RightControl) => { + Some(ffi::GhosttyKey_GHOSTTY_KEY_CONTROL_RIGHT) + } + KeyCode::Modifier(ModifierKeyCode::LeftAlt) => Some(ffi::GhosttyKey_GHOSTTY_KEY_ALT_LEFT), + KeyCode::Modifier(ModifierKeyCode::RightAlt) => Some(ffi::GhosttyKey_GHOSTTY_KEY_ALT_RIGHT), + KeyCode::Modifier(ModifierKeyCode::LeftSuper) => { + Some(ffi::GhosttyKey_GHOSTTY_KEY_META_LEFT) + } + KeyCode::Modifier(ModifierKeyCode::RightSuper) => { + Some(ffi::GhosttyKey_GHOSTTY_KEY_META_RIGHT) + } _ => None, } } -fn ghostty_key_from_char( - c: char, - shifted_codepoint: Option, -) -> Option { +fn ghostty_key_from_char(base: char) -> Option { use crate::ghostty::ffi; - let base = if let Some(shifted) = shifted_codepoint.and_then(char::from_u32) { - ghostty_unshifted_ascii_pair(shifted).unwrap_or(c) - } else { - c - }; - - match base.to_ascii_lowercase() { + match base { 'a' => Some(ffi::GhosttyKey_GHOSTTY_KEY_A), 'b' => Some(ffi::GhosttyKey_GHOSTTY_KEY_B), 'c' => Some(ffi::GhosttyKey_GHOSTTY_KEY_C), diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index d43aca1a8..41b956909 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -9,7 +9,7 @@ use ratatui::{layout::Rect, Frame}; #[cfg(any(unix, test))] use serde::{Deserialize, Serialize}; use tokio::sync::mpsc; -use tracing::{debug, error}; +use tracing::{debug, error, warn}; use unicode_width::UnicodeWidthStr; use crate::layout::PaneId; @@ -27,8 +27,8 @@ use super::{ input::{ ghostty_key_event_from_terminal_key, ghostty_mouse_encoder_for_terminal, ghostty_mouse_event_from_button_kind, ghostty_mouse_event_from_motion_kind, - ghostty_mouse_event_from_wheel_kind, ghostty_mouse_position_for_terminal, - ghostty_prefers_herdr_text_encoding, + ghostty_mouse_event_from_wheel_kind, ghostty_mouse_position_for_terminal, legacy_shell_key, + legacy_super_chord, }, kitty_keyboard::KittyKeyboardTracker, osc::{ @@ -44,6 +44,7 @@ const DEFAULT_DETECTION_ROWS: usize = 24; const KITTY_GRAPHICS_REDRAW_SETTLE: Duration = Duration::from_millis(20); const CURSOR_POSITION_SETTLE_ENABLED: bool = cfg!(windows); const MODE_MOUSE_X10: u16 = 9; +const KITTY_DISAMBIGUATE: u8 = 1; const MODE_MOUSE_PRESS_RELEASE: u16 = 1000; const MODE_MOUSE_BUTTON_MOTION: u16 = 1002; const MODE_MOUSE_ANY_MOTION: u16 = 1003; @@ -2023,7 +2024,8 @@ impl GhosttyPaneTerminal { pub fn encode_terminal_key( &self, key: crate::input::TerminalKey, - protocol: crate::input::KeyboardProtocol, + // Unused: libghostty reads the live modes. Removed with the shadow tracker. + _protocol: crate::input::KeyboardProtocol, ) -> Vec { #[cfg(windows)] if self.core.lock().is_ok_and(|core| { @@ -2043,10 +2045,10 @@ impl GhosttyPaneTerminal { let repeat_count = key.repeat_count; let first = key.with_repeat_count(1); - let mut bytes = self.encode_terminal_key_once(first.clone(), protocol); + let mut bytes = self.encode_terminal_key_once(first.clone()); if repeat_count > 1 && first.kind != crossterm::event::KeyEventKind::Release { let repeated = first.with_kind(crossterm::event::KeyEventKind::Repeat); - let repeated_bytes = self.encode_terminal_key_once(repeated, protocol); + let repeated_bytes = self.encode_terminal_key_once(repeated); for _ in 1..repeat_count { bytes.extend_from_slice(&repeated_bytes); } @@ -2054,56 +2056,48 @@ impl GhosttyPaneTerminal { bytes } - fn encode_terminal_key_once( - &self, - key: crate::input::TerminalKey, - protocol: crate::input::KeyboardProtocol, - ) -> Vec { - // Ghostty emits extended Enter sequences even without negotiation. - // Ordinary shells need legacy input unless the child enabled an extension. - if key.code == crossterm::event::KeyCode::Enter - && !key.modifiers.is_empty() - && self.core.lock().is_ok_and(|core| { - core.terminal - .kitty_keyboard_flags() - .is_ok_and(|flags| flags == 0) - && core.kitty_keyboard.modify_other_keys_level() == 0 - && core - .terminal - .modify_other_keys_enabled() - .is_ok_and(|enabled| !enabled) - }) - { - return crate::input::encode_terminal_key(key, crate::input::KeyboardProtocol::Legacy); - } - - if matches!(protocol, crate::input::KeyboardProtocol::Legacy) - && key.code == crossterm::event::KeyCode::Tab - && key.modifiers == crossterm::event::KeyModifiers::CONTROL - { - return crate::input::encode_terminal_key(key, protocol); - } - - if ghostty_prefers_herdr_text_encoding(&key) { - return crate::input::encode_terminal_key(key, protocol); - } + /// libghostty encodes every key for the pane's live keyboard modes. The only + /// Herdr policy is the legacy shell table for panes that negotiated nothing. + fn encode_terminal_key_once(&self, key: crate::input::TerminalKey) -> Vec { + let Ok(core) = self.core.lock() else { + return Vec::new(); + }; + let negotiated_nothing = core + .terminal + .kitty_keyboard_flags() + .is_ok_and(|flags| flags == 0) + && core.kitty_keyboard.modify_other_keys_level() == 0 + && core + .terminal + .modify_other_keys_enabled() + .is_ok_and(|enabled| !enabled); + let key = if negotiated_nothing { + legacy_shell_key(key) + } else { + key + }; let Some(event) = ghostty_key_event_from_terminal_key(&key) else { - return crate::input::encode_terminal_key(key, protocol); + debug!(code = ?key.code, "key has no libghostty equivalent; not forwarded"); + return Vec::new(); }; - - let Ok(mut encoder) = self.key_encoder.lock() else { - return crate::input::encode_terminal_key(key, protocol); + let encoded = if negotiated_nothing && legacy_super_chord(&key) { + crate::ghostty::KeyEncoder::new().and_then(|mut encoder| { + encoder.set_from_terminal(&core.terminal); + encoder.set_kitty_flags(KITTY_DISAMBIGUATE); + encoder.encode(&event) + }) + } else { + drop(core); + let Ok(mut encoder) = self.key_encoder.lock() else { + return Vec::new(); + }; + encoder.encode(&event) }; - match encoder.encode(&event) { - Ok(bytes) - if !bytes.is_empty() - && encoded_key_preserves_event_kind(&bytes, &key, protocol) => - { - bytes - } - Ok(_) | Err(_) => crate::input::encode_terminal_key(key, protocol), - } + encoded.unwrap_or_else(|err| { + warn!(?err, code = ?key.code, "libghostty key encoding failed"); + Vec::new() + }) } pub(crate) fn encode_mouse_button( @@ -2513,23 +2507,6 @@ 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 effective_cursor_state( core: &mut GhosttyPaneCore, current: Option, @@ -4979,7 +4956,7 @@ mod tests { } #[test] - fn ghostty_char_keys_still_use_herdr_encoding() { + fn ghostty_disambiguate_reports_ctrl_letters_as_kitty_keys() { let (tx, _rx) = mpsc::channel(4); let mut terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); terminal.write(b"\x1b[>1u"); @@ -4993,7 +4970,52 @@ mod tests { crate::input::KeyboardProtocol::Legacy, ); - assert_eq!(encoded, vec![1]); + assert_eq!(encoded, b"\x1b[97;5u"); + } + + #[test] + fn ghostty_encodes_text_keys_with_layout_text() { + let (tx, _rx) = mpsc::channel(4); + let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); + let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); + + for (key, expected) in [ + ( + crate::input::TerminalKey::new( + crossterm::event::KeyCode::Char('D'), + crossterm::event::KeyModifiers::SHIFT, + ), + "D", + ), + ( + crate::input::TerminalKey::new( + crossterm::event::KeyCode::Char('%'), + crossterm::event::KeyModifiers::empty(), + ), + "%", + ), + ( + crate::input::TerminalKey::new( + crossterm::event::KeyCode::Char('ö'), + crossterm::event::KeyModifiers::empty(), + ) + .with_generated_text(Some("ö".to_owned())), + "ö", + ), + ( + crate::input::TerminalKey::new( + crossterm::event::KeyCode::Char('8'), + crossterm::event::KeyModifiers::CONTROL, + ), + "\x7f", + ), + ] { + assert_eq!( + pane.encode_terminal_key(key.clone(), crate::input::KeyboardProtocol::Legacy), + expected.as_bytes(), + "{key:?}" + ); + } } #[test] @@ -5115,7 +5137,7 @@ mod tests { let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); assert_eq!( pane.encode_terminal_key(shifted, crate::input::KeyboardProtocol::Kitty { flags: 15 },), - b"\x1b[47;2:1u\x1b[47;2:2u\x1b[47;2:2u" + b"\x1b[47;2u\x1b[47;2:2u\x1b[47;2:2u" ); }