fix: let libghostty encode every key sent to panes

This commit is contained in:
Ogulcan Celik
2026-10-06 17:21:09 +03:00
parent eaeb69b3f1
commit 53cfd06831
7 changed files with 391 additions and 151 deletions
+27 -2
View File
@@ -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<str>,
}
impl KeyEvent {
pub fn new() -> Result<Self, Error> {
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::<c_char>(), text.len())
ffi::ghostty_key_event_set_utf8(
self.raw,
self.utf8.as_ptr().cast::<c_char>(),
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<Vec<u8>, Error> {
encode_with_retry(|buf, len, out_len| unsafe {
ffi::ghostty_key_encoder_encode(self.raw, event.raw, buf, len, out_len)
+1
View File
@@ -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(
+32 -1
View File
@@ -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);
}
}
}
}
+4
View File
@@ -907,6 +907,9 @@ pub(crate) struct ClientShellState {
pub(super) pane_mouse_gesture: Option<ClientPaneMouseGesture>,
pub(super) link_hover: Option<super::link_hover::LinkHover>,
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<crate::selection::Selection<String>>,
pub(super) last_pane_click: Option<ClientPaneClick>,
@@ -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,
+41 -23
View File
@@ -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<Vec<u8>> {
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<u8>>> = 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()
);
}
+192 -53
View File
@@ -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<crate::ghostty::KeyEvent> {
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<String> {
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<char> {
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<String> {
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<u32> {
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<u32>,
) -> Option<crate::ghostty::ffi::GhosttyKey> {
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<u32>,
) -> Option<crate::ghostty::ffi::GhosttyKey> {
fn ghostty_key_from_char(base: char) -> Option<crate::ghostty::ffi::GhosttyKey> {
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),
+94 -72
View File
@@ -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<u8> {
#[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<u8> {
// 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<u8> {
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<TerminalCursorState>,
@@ -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"
);
}