mirror of
https://github.com/herdrdev/herdr.git
synced 2026-11-06 08:06:30 +00:00
fix: let libghostty encode every key sent to panes
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user