mirror of
https://github.com/herdrdev/herdr.git
synced 2026-11-06 00:06:18 +00:00
fix: keep macos alt, non-us kitty keys, text releases and reply-tail recovery correct after the libghostty switch
This commit is contained in:
@@ -3093,6 +3093,23 @@ impl KeyEncoder {
|
||||
unsafe { ffi::ghostty_key_encoder_setopt_from_terminal(self.raw, terminal.raw()) }
|
||||
}
|
||||
|
||||
/// Whether macOS Option acts as Alt (ESC prefix) rather than a text
|
||||
/// modifier. `set_from_terminal` resets this to false.
|
||||
pub fn set_macos_option_as_alt(&mut self, enabled: bool) {
|
||||
let value = if enabled {
|
||||
ffi::GhosttyOptionAsAlt_GHOSTTY_OPTION_AS_ALT_TRUE
|
||||
} else {
|
||||
ffi::GhosttyOptionAsAlt_GHOSTTY_OPTION_AS_ALT_FALSE
|
||||
};
|
||||
unsafe {
|
||||
ffi::ghostty_key_encoder_setopt(
|
||||
self.raw,
|
||||
ffi::GhosttyKeyEncoderOption_GHOSTTY_KEY_ENCODER_OPT_MACOS_OPTION_AS_ALT,
|
||||
(&value as *const ffi::GhosttyOptionAsAlt).cast(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_kitty_flags(&mut self, flags: u8) {
|
||||
unsafe {
|
||||
ffi::ghostty_key_encoder_setopt(
|
||||
|
||||
+8
-18
@@ -126,7 +126,7 @@ fn unix_stdin_reader_loop(
|
||||
if (framer.has_pending_input() || !pending_palette.is_empty())
|
||||
&& stdin_read_ready(
|
||||
&reader,
|
||||
idle_flush_timeout_ms(&framer, host_mouse_capture_active.load(Ordering::Acquire)),
|
||||
framer.idle_flush_timeout_ms(host_mouse_capture_active.load(Ordering::Acquire)),
|
||||
) == Some(false)
|
||||
{
|
||||
let had_pending = framer.has_pending_input();
|
||||
@@ -228,10 +228,8 @@ fn unix_stdin_reader_loop(
|
||||
return;
|
||||
}
|
||||
|
||||
let timeout_ms = idle_flush_timeout_ms(
|
||||
&framer,
|
||||
host_mouse_capture_active.load(Ordering::Acquire),
|
||||
);
|
||||
let timeout_ms =
|
||||
framer.idle_flush_timeout_ms(host_mouse_capture_active.load(Ordering::Acquire));
|
||||
if stdin_read_ready(&reader, timeout_ms) == Some(false) {
|
||||
let had_pending = framer.has_pending_input();
|
||||
let chunks = framer.flush_timeout();
|
||||
@@ -371,14 +369,6 @@ fn flush_unix_palette_input(
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn idle_flush_timeout_ms(
|
||||
framer: &crate::raw_input::RawInputByteFramer,
|
||||
host_mouse_capture_active: bool,
|
||||
) -> i32 {
|
||||
framer.idle_flush_timeout_ms(host_mouse_capture_active)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn windows_stdin_reader_loop(
|
||||
event_tx: mpsc::Sender<ClientLoopEvent>,
|
||||
@@ -601,7 +591,7 @@ mod tests {
|
||||
next: &[u8],
|
||||
) -> Vec<Vec<u8>> {
|
||||
let first_wait =
|
||||
std::time::Duration::from_millis(idle_flush_timeout_ms(framer, mouse_capture) as u64);
|
||||
std::time::Duration::from_millis(framer.idle_flush_timeout_ms(mouse_capture) as u64);
|
||||
let mut chunks = Vec::new();
|
||||
if gap >= first_wait {
|
||||
chunks.extend(framer.flush_timeout());
|
||||
@@ -869,23 +859,23 @@ mod tests {
|
||||
// A lone ESC or ESC[ may still be a key (Escape, Alt+[).
|
||||
for framer in [&escape, &csi] {
|
||||
assert_eq!(
|
||||
idle_flush_timeout_ms(framer, false),
|
||||
framer.idle_flush_timeout_ms(false),
|
||||
crate::raw_input::RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
idle_flush_timeout_ms(&escape, true),
|
||||
escape.idle_flush_timeout_ms(true),
|
||||
crate::raw_input::MOUSE_ACTIVE_ESCAPE_SEQUENCE_FLUSH_TIMEOUT_MS
|
||||
);
|
||||
assert_eq!(
|
||||
idle_flush_timeout_ms(&csi, true),
|
||||
csi.idle_flush_timeout_ms(true),
|
||||
crate::raw_input::MOUSE_ACTIVE_CSI_INTRODUCER_FLUSH_TIMEOUT_MS
|
||||
);
|
||||
// Anything further inside a sequence cannot be a key: wait for the rest.
|
||||
for framer in [&sgr_mouse, &default_mouse, &unrelated] {
|
||||
for mouse_capture in [false, true] {
|
||||
assert_eq!(
|
||||
idle_flush_timeout_ms(framer, mouse_capture),
|
||||
framer.idle_flush_timeout_ms(mouse_capture),
|
||||
crate::raw_input::INCOMPLETE_SEQUENCE_FLUSH_TIMEOUT_MS
|
||||
);
|
||||
}
|
||||
|
||||
+54
-28
@@ -358,37 +358,24 @@ impl ClientShellState {
|
||||
self.execute_repeat_plan(lease_key, key, plan, outcome);
|
||||
}
|
||||
KeyEventKind::Release => {
|
||||
// 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
|
||||
let Some(lease) = self.take_release_lease(&lease_key, &key) else {
|
||||
return;
|
||||
};
|
||||
let crate::input::InputLease::Forwarded(lease) = lease else {
|
||||
// Herdr consumed the press; its release stays with Herdr.
|
||||
return;
|
||||
};
|
||||
// A native record is released as the recorded key. A VT release
|
||||
// report already names its key and shifted character exactly.
|
||||
let release = if key.physical_key_id().is_some() {
|
||||
lease
|
||||
.key
|
||||
.with_modifiers(key.modifiers)
|
||||
.with_kind(KeyEventKind::Release);
|
||||
self.push_pane_key(lease.target, release, outcome);
|
||||
.with_kind(KeyEventKind::Release)
|
||||
} else {
|
||||
let _ = self.input_leases.remove(&lease_key);
|
||||
if let Some(shifted) = shifted_lease_key {
|
||||
let _ = self.input_leases.remove(&shifted);
|
||||
}
|
||||
}
|
||||
key
|
||||
};
|
||||
self.push_pane_key(lease.target, release, outcome);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1058,6 +1045,45 @@ impl ClientShellState {
|
||||
.map(crate::protocol::ClientClipboardImageTarget::Pane)
|
||||
}
|
||||
|
||||
/// The tracked press for a release. A press that arrived as text ("A") is
|
||||
/// reported released as its key ("a", shifted alternate "A"), possibly after
|
||||
/// Shift was let go, so the shifted alternate and the letter's other case
|
||||
/// also match.
|
||||
fn take_release_lease(
|
||||
&mut self,
|
||||
lease_key: &crate::input::InputLeaseKey<u8>,
|
||||
key: &crate::input::TerminalKey,
|
||||
) -> Option<crate::input::InputLease<ClientInputContext, ClientInputTarget>> {
|
||||
if let Some(lease) = self.input_leases.remove(lease_key) {
|
||||
return Some(lease);
|
||||
}
|
||||
let KeyCode::Char(c) = key.code else {
|
||||
return None;
|
||||
};
|
||||
fn single(mut chars: impl Iterator<Item = char>) -> Option<char> {
|
||||
match (chars.next(), chars.next()) {
|
||||
(Some(only), None) => Some(only),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
let other_case = if c.is_lowercase() {
|
||||
single(c.to_uppercase())
|
||||
} else {
|
||||
single(c.to_lowercase())
|
||||
}
|
||||
.filter(|other| *other != c);
|
||||
key.shifted_codepoint
|
||||
.and_then(char::from_u32)
|
||||
.into_iter()
|
||||
.chain(other_case)
|
||||
.find_map(|candidate| {
|
||||
self.input_leases.remove(&crate::input::InputLeaseKey::new(
|
||||
LOCAL_INPUT_SOURCE,
|
||||
&crate::input::TerminalKey::new(KeyCode::Char(candidate), key.modifiers),
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn popup_input_target(&self) -> Option<ClientInputTarget> {
|
||||
self.popup_terminal_id
|
||||
.as_ref()
|
||||
|
||||
@@ -282,6 +282,59 @@ fn invalid_experimental_reload_keeps_input_source_preference() {
|
||||
assert!(!shell.switch_ascii_input_source_in_prefix);
|
||||
}
|
||||
|
||||
fn pane_key_events(
|
||||
outcome: &ClientShellInput,
|
||||
) -> Vec<(
|
||||
crate::protocol::ClientKeyCode,
|
||||
crate::protocol::ClientKeyKind,
|
||||
)> {
|
||||
outcome
|
||||
.requests
|
||||
.iter()
|
||||
.filter_map(|request| match request {
|
||||
ClientMessage::ClientShellPaneInput { events, .. } => Some(events),
|
||||
_ => None,
|
||||
})
|
||||
.flatten()
|
||||
.filter_map(|event| match event {
|
||||
ClientPaneInputEvent::Key { code, kind, .. } => Some((code.clone(), *kind)),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_press_release_survives_shift_released_first_and_never_becomes_a_repeat() {
|
||||
use crate::protocol::{ClientKeyCode, ClientKeyKind};
|
||||
|
||||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||||
state.set_host_reports_key_releases(true);
|
||||
state.set_snapshot(Box::new(snapshot()));
|
||||
state.set_pane_surface(surface());
|
||||
|
||||
// Shift+A arrives as text; Shift is let go before A, so the release names
|
||||
// plain `a` with no shifted alternate.
|
||||
let press = state.handle_input_bytes(b"A");
|
||||
assert_eq!(
|
||||
pane_key_events(&press),
|
||||
[(ClientKeyCode::Char('A'), ClientKeyKind::Press)]
|
||||
);
|
||||
let release = state.handle_input_bytes(b"\x1b[97;1:3u");
|
||||
assert_eq!(
|
||||
pane_key_events(&release),
|
||||
[(ClientKeyCode::Char('a'), ClientKeyKind::Release)]
|
||||
);
|
||||
|
||||
// A text press whose release never arrived must not turn the next press
|
||||
// into a repeat routed to the old target.
|
||||
let _ = state.handle_input_bytes(b"%");
|
||||
let again = state.handle_input_bytes(b"%");
|
||||
assert_eq!(
|
||||
pane_key_events(&again),
|
||||
[(ClientKeyCode::Char('%'), ClientKeyKind::Press)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn physical_release_uses_the_leased_press_code_with_current_modifiers() {
|
||||
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
||||
|
||||
@@ -195,6 +195,9 @@ impl Oracle {
|
||||
terminal.write(app_output);
|
||||
let mut encoder = ghostty::KeyEncoder::new().expect("encoder");
|
||||
encoder.set_from_terminal(&terminal);
|
||||
// Keystrokes here carry an interpreted Alt, as on Linux; keep the oracle
|
||||
// identical on macOS, where libghostty would otherwise treat Option as text.
|
||||
encoder.set_macos_option_as_alt(true);
|
||||
Self {
|
||||
_terminal: terminal,
|
||||
encoder,
|
||||
@@ -511,10 +514,21 @@ fn run_keyboard_conformance() -> Report {
|
||||
// the chord without it), so nothing downstream can recover it.
|
||||
let super_unreportable =
|
||||
host == HostProfile::Legacy && mods & ghostty::MOD_SUPER != 0;
|
||||
// Plain text names a character, not a key: which key and Shift state
|
||||
// produced punctuation depends on the layout. Only a letter's Shift
|
||||
// is layout independent (its case). Legacy hosts never report
|
||||
// Shift for non-letters; Kitty hosts send unmodified punctuation
|
||||
// presses as plain text.
|
||||
let non_letter_text = def.text.is_some_and(|(base, _)| !base.is_alphabetic());
|
||||
let plain_text_press =
|
||||
host_bytes.0.len() == 1 && host_bytes.0[0].is_ascii_graphic();
|
||||
let layout_unreportable = non_letter_text
|
||||
&& mods & ghostty::MOD_SHIFT != 0
|
||||
&& (host == HostProfile::Legacy || plain_text_press);
|
||||
let ambiguous = expected_by_host_bytes
|
||||
.get(host_bytes)
|
||||
.is_some_and(|distinct| distinct.iter().any(|other| *other != expected));
|
||||
if super_unreportable || ambiguous {
|
||||
if super_unreportable || layout_unreportable || ambiguous {
|
||||
tally.host_lossy += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -610,8 +624,9 @@ fn print_report(report: &Report) {
|
||||
// Ratchet: failure counts may only go down. Lower them when a change fixes
|
||||
// cases; a rise means a regression in Herdr's input transparency.
|
||||
// Remaining: Alt+], Alt+Shift+P/X, Alt+^, Alt+_ behind legacy hosts. Their bytes
|
||||
// also start host replies, so they are never forwarded (#344).
|
||||
const KEYBOARD_FAILURES_BASELINE: usize = 50;
|
||||
// also start host replies, so they are never forwarded and the reply-tail
|
||||
// discard can swallow the next keystroke (#344).
|
||||
const KEYBOARD_FAILURES_BASELINE: usize = 60;
|
||||
const MOUSE_FAILURES_BASELINE: usize = 96;
|
||||
// Remaining: legacy hosts split right after ESC[, which is also Alt+[.
|
||||
const SPLIT_IDLE_MISMATCH_BASELINE: usize = 207;
|
||||
|
||||
+9
-13
@@ -69,8 +69,11 @@ where
|
||||
lease_key: &InputLeaseKey<Source>,
|
||||
key: TerminalKey,
|
||||
) -> TerminalKey {
|
||||
// A VT text press is always a new press: hosts send text repeats as
|
||||
// text, and a stale lease (release reported after Shift was let go)
|
||||
// must not turn the next press into a repeat routed to an old target.
|
||||
if key.kind != crossterm::event::KeyEventKind::Press
|
||||
|| (key.generated_text.is_some() && !key.has_physical_identity())
|
||||
|| (key.generated_text.is_some() && key.physical_key_id().is_none())
|
||||
{
|
||||
return key;
|
||||
}
|
||||
@@ -172,16 +175,6 @@ where
|
||||
allowed
|
||||
}
|
||||
|
||||
pub(crate) fn remove_forwarded(
|
||||
&mut self,
|
||||
key: &InputLeaseKey<Source>,
|
||||
) -> Option<ForwardedInputLease<Target>> {
|
||||
match self.leases.remove(key) {
|
||||
Some(InputLease::Forwarded(lease)) => Some(lease),
|
||||
Some(InputLease::Consumed(_)) | None => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn contains(&self, key: &InputLeaseKey<Source>) -> bool {
|
||||
self.leases.contains_key(key)
|
||||
@@ -375,7 +368,10 @@ mod tests {
|
||||
leases.plan_repeat(lease_key, &repeated, Some(&context)),
|
||||
RepeatPlan::Forwarded(10)
|
||||
));
|
||||
assert!(leases.remove_forwarded(&lease_key).is_some());
|
||||
assert!(matches!(
|
||||
leases.remove(&lease_key),
|
||||
Some(InputLease::Forwarded(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -408,7 +404,7 @@ mod tests {
|
||||
leases.complete_press(lease_key, &key, Some(&context), Some(&context), Some(10)),
|
||||
RepeatPlan::Ignore
|
||||
));
|
||||
assert_eq!(leases.remove_forwarded(&lease_key), None);
|
||||
assert!(leases.remove(&lease_key).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ pub(crate) use keybindings::{
|
||||
resolve_non_indexed_action, resolve_prefix_binding, KeybindAction, KeybindDispatch,
|
||||
KeybindMatch,
|
||||
};
|
||||
pub(crate) use lease::{InputLeaseKey, InputLeaseTable, RepeatPlan};
|
||||
pub(crate) use lease::{InputLease, InputLeaseKey, InputLeaseTable, RepeatPlan};
|
||||
#[cfg(not(windows))]
|
||||
pub use model::ime_compatible_keyboard_enhancement_flags;
|
||||
pub use model::WindowsKeyRecord;
|
||||
|
||||
+40
-75
@@ -22,9 +22,11 @@ pub(super) fn ghostty_key_event_from_terminal_key(
|
||||
let mut mods = ghostty_mods_from_key_modifiers(key.modifiers);
|
||||
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);
|
||||
// An uppercase letter's key is its lowercase letter on every layout.
|
||||
// Nothing else is inferred: Kitty reports already name the unshifted
|
||||
// key (`+` on a German layout stays `+`), and legacy input does not
|
||||
// say which key produced punctuation.
|
||||
let base = unshifted_letter(c);
|
||||
if base != c {
|
||||
mods |= crate::ghostty::MOD_SHIFT;
|
||||
}
|
||||
@@ -39,7 +41,15 @@ pub(super) fn ghostty_key_event_from_terminal_key(
|
||||
// (e.g. Shift+7 = "/" on a German layout, where `base` cannot
|
||||
// be recovered from the character alone).
|
||||
let reported_shifted_text = key.generated_text.is_some() && text != " ";
|
||||
if shifted && (!text.starts_with(base) || reported_shifted_text) {
|
||||
// A non-letter given with Shift and no reported shifted character
|
||||
// is the produced character itself ("shift+?" is "?"). Kitty
|
||||
// hosts report shifted alternates, so a changed character shows
|
||||
// up there.
|
||||
let given_shifted_char =
|
||||
key.shifted_codepoint.is_none() && !base.is_alphabetic() && text != " ";
|
||||
if shifted
|
||||
&& (!text.starts_with(base) || reported_shifted_text || given_shifted_char)
|
||||
{
|
||||
event.set_consumed_mods(crate::ghostty::MOD_SHIFT);
|
||||
}
|
||||
event.set_utf8(&text);
|
||||
@@ -58,6 +68,17 @@ pub(super) fn ghostty_key_event_from_terminal_key(
|
||||
Some(event)
|
||||
}
|
||||
|
||||
/// Configure `encoder` for a pane's live terminal modes. Herdr has already
|
||||
/// decoded Alt from the host, so macOS Option must keep its Alt meaning (ESC
|
||||
/// prefix) instead of being treated as a text modifier.
|
||||
pub(super) fn configure_key_encoder(
|
||||
encoder: &mut crate::ghostty::KeyEncoder,
|
||||
terminal: &crate::ghostty::Terminal,
|
||||
) {
|
||||
encoder.set_from_terminal(terminal);
|
||||
encoder.set_macos_option_as_alt(true);
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -98,58 +119,29 @@ fn key_text(key: &crate::input::TerminalKey, base: char, shifted: bool) -> Optio
|
||||
{
|
||||
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
|
||||
// Without a reported shifted character, only a letter's Shift is known.
|
||||
let produced = match key.shifted_codepoint.and_then(char::from_u32) {
|
||||
Some(shifted_char) if shifted => shifted_char,
|
||||
_ if shifted => shifted_letter(base),
|
||||
_ => 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;
|
||||
fn shifted_letter(c: char) -> char {
|
||||
let mut upper = c.to_uppercase();
|
||||
match (upper.next(), upper.next()) {
|
||||
(Some(upper), None) if c.is_lowercase() => upper,
|
||||
_ => 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());
|
||||
fn unshifted_letter(c: char) -> char {
|
||||
let mut lower = c.to_lowercase();
|
||||
match (lower.next(), lower.next()) {
|
||||
(Some(lower), None) if c.is_uppercase() => lower,
|
||||
_ => c,
|
||||
}
|
||||
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 {
|
||||
@@ -464,30 +456,3 @@ fn ghostty_key_from_char(base: char) -> Option<crate::ghostty::ffi::GhosttyKey>
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn ghostty_unshifted_ascii_pair(c: char) -> Option<char> {
|
||||
Some(match c {
|
||||
'!' => '1',
|
||||
'@' => '2',
|
||||
'#' => '3',
|
||||
'$' => '4',
|
||||
'%' => '5',
|
||||
'^' => '6',
|
||||
'&' => '7',
|
||||
'*' => '8',
|
||||
'(' => '9',
|
||||
')' => '0',
|
||||
'_' => '-',
|
||||
'+' => '=',
|
||||
'{' => '[',
|
||||
'}' => ']',
|
||||
'|' => '\\',
|
||||
':' => ';',
|
||||
'"' => '\'',
|
||||
'<' => ',',
|
||||
'>' => '.',
|
||||
'?' => '/',
|
||||
'~' => '`',
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
+37
-10
@@ -25,10 +25,10 @@ use super::cursor::CURSOR_POSITION_SETTLE;
|
||||
use super::cursor::{CursorPositionSettleState, DecscusrTracker};
|
||||
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, legacy_shell_key,
|
||||
legacy_super_chord,
|
||||
configure_key_encoder, 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, legacy_shell_key, legacy_super_chord,
|
||||
},
|
||||
kitty_keyboard::KittyKeyboardTracker,
|
||||
osc::{
|
||||
@@ -1169,7 +1169,7 @@ impl GhosttyPaneTerminal {
|
||||
let initial_default_background = initial_colors.map(|colors| colors.background);
|
||||
let mut key_encoder =
|
||||
crate::ghostty::KeyEncoder::new().map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||
key_encoder.set_from_terminal(&terminal);
|
||||
configure_key_encoder(&mut key_encoder, &terminal);
|
||||
Ok(Self {
|
||||
#[cfg(test)]
|
||||
scroll_metrics_reads: std::sync::atomic::AtomicUsize::new(0),
|
||||
@@ -1429,7 +1429,7 @@ impl GhosttyPaneTerminal {
|
||||
debug!(pane = pane_id.raw(), "processed kitty graphics sequence");
|
||||
}
|
||||
if let Ok(mut key_encoder) = self.key_encoder.lock() {
|
||||
key_encoder.set_from_terminal(&core.terminal);
|
||||
configure_key_encoder(&mut key_encoder, &core.terminal);
|
||||
}
|
||||
let synchronized_output = core
|
||||
.terminal
|
||||
@@ -1579,7 +1579,7 @@ impl GhosttyPaneTerminal {
|
||||
#[cfg(windows)]
|
||||
windows_recent_fallback::update(&mut core);
|
||||
if let Ok(mut key_encoder) = self.key_encoder.lock() {
|
||||
key_encoder.set_from_terminal(&core.terminal);
|
||||
configure_key_encoder(&mut key_encoder, &core.terminal);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1643,7 +1643,7 @@ impl GhosttyPaneTerminal {
|
||||
}
|
||||
|
||||
if let Ok(mut key_encoder) = self.key_encoder.lock() {
|
||||
key_encoder.set_from_terminal(&core.terminal);
|
||||
configure_key_encoder(&mut key_encoder, &core.terminal);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1666,7 +1666,7 @@ impl GhosttyPaneTerminal {
|
||||
core.kitty_keyboard.observe(ansi.as_bytes());
|
||||
core.terminal.write(ansi.as_bytes());
|
||||
if let Ok(mut key_encoder) = self.key_encoder.lock() {
|
||||
key_encoder.set_from_terminal(&core.terminal);
|
||||
configure_key_encoder(&mut key_encoder, &core.terminal);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2067,7 +2067,7 @@ impl GhosttyPaneTerminal {
|
||||
};
|
||||
let encoded = if negotiated_nothing && legacy_super_chord(&key) {
|
||||
crate::ghostty::KeyEncoder::new().and_then(|mut encoder| {
|
||||
encoder.set_from_terminal(&core.terminal);
|
||||
configure_key_encoder(&mut encoder, &core.terminal);
|
||||
encoder.set_kitty_flags(KITTY_DISAMBIGUATE);
|
||||
encoder.encode(&event)
|
||||
})
|
||||
@@ -4946,6 +4946,33 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ghostty_alt_letters_keep_esc_prefix_on_every_platform() {
|
||||
// libghostty treats macOS Option as a text modifier unless told otherwise;
|
||||
// Herdr has already decoded Alt from these host bytes.
|
||||
for (host_bytes, expected) in [
|
||||
("\x1bf", &b"\x1bf"[..]),
|
||||
("\x1bb", b"\x1bb"),
|
||||
("\x1bA", b"\x1bA"),
|
||||
("\x1b.", b"\x1b."),
|
||||
] {
|
||||
let key = crate::input::parse_terminal_key_sequence(host_bytes).expect("alt key");
|
||||
assert_eq!(
|
||||
test_encode_key_for_app(b"", key),
|
||||
expected,
|
||||
"{host_bytes:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ghostty_kitty_reports_keep_non_us_key_identity() {
|
||||
// Ctrl++ on a German layout, where + is an unshifted key. The report
|
||||
// already names the key; nothing may reinterpret it as US Shift+=.
|
||||
let key = crate::input::parse_terminal_key_sequence("\x1b[43;5u").expect("kitty key");
|
||||
assert_eq!(test_encode_key_for_app(b"\x1b[>1u", key), b"\x1b[43;5u");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ghostty_disambiguate_reports_ctrl_letters_as_kitty_keys() {
|
||||
let (tx, _rx) = mpsc::channel(4);
|
||||
|
||||
+1
-19
@@ -110,7 +110,6 @@ pub(crate) struct RawInputFramer {
|
||||
|
||||
#[cfg(any(windows, test))]
|
||||
impl RawInputFramer {
|
||||
#[cfg(any(windows, test))]
|
||||
pub(crate) fn for_host_input() -> Self {
|
||||
Self {
|
||||
byte_framer: RawInputByteFramer::for_host_input(),
|
||||
@@ -146,12 +145,10 @@ impl RawInputFramer {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(any(windows, test))]
|
||||
pub(crate) fn has_pending_bracketed_paste(&self) -> bool {
|
||||
self.byte_framer.has_pending_bracketed_paste()
|
||||
}
|
||||
|
||||
#[cfg(any(windows, test))]
|
||||
pub(crate) fn has_pending_default_mouse_sequence(&self) -> bool {
|
||||
starts_with_incomplete_default_mouse_sequence(&self.byte_framer.buffer)
|
||||
}
|
||||
@@ -160,7 +157,6 @@ impl RawInputFramer {
|
||||
events_from_framed_chunks(self.byte_framer.flush_timeout())
|
||||
}
|
||||
|
||||
#[cfg(any(windows, test))]
|
||||
pub(crate) fn flush_keyboard_escape(&mut self) -> Vec<RawInputEvent> {
|
||||
if self.byte_framer.buffer.as_slice() == [ESC] {
|
||||
self.byte_framer.lone_escape_recently_flushed = true;
|
||||
@@ -171,7 +167,6 @@ impl RawInputFramer {
|
||||
}
|
||||
|
||||
/// Semantic input ends mouse recovery, unlike another idle interval.
|
||||
#[cfg(any(windows, test))]
|
||||
pub(crate) fn flush_interrupted(&mut self) -> Vec<RawInputEvent> {
|
||||
#[cfg(windows)]
|
||||
self.byte_framer.flush_lone_escape_on_interruption();
|
||||
@@ -581,17 +576,6 @@ impl RawInputByteFramer {
|
||||
chunks.push(std::mem::take(&mut self.buffer));
|
||||
return chunks;
|
||||
}
|
||||
// A lone string introducer with no reply expected is a legacy Alt chord
|
||||
// that cannot be forwarded safely. Drop just these bytes; entering
|
||||
// control-string discard would swallow the user's next keystrokes.
|
||||
if is_escape_key_prefix(&self.buffer)
|
||||
&& self.buffer.len() == 2
|
||||
&& !self.awaiting_host_reply()
|
||||
{
|
||||
tracing::debug!(bytes = ?self.buffer, "dropping lone string introducer");
|
||||
self.buffer.clear();
|
||||
return chunks;
|
||||
}
|
||||
|
||||
if let Some(ControlString::Incomplete { family }) = control_string(&self.buffer) {
|
||||
tracing::debug!(
|
||||
@@ -1073,6 +1057,7 @@ fn is_alt_key_introducer(buffer: &[u8]) -> bool {
|
||||
matches!(buffer, [ESC, b'[' | b'O'])
|
||||
}
|
||||
|
||||
#[cfg(any(unix, test))]
|
||||
fn is_escape_key_prefix(buffer: &[u8]) -> bool {
|
||||
match buffer {
|
||||
[ESC] => true,
|
||||
@@ -2771,7 +2756,6 @@ mod tests {
|
||||
#[test]
|
||||
fn raw_input_byte_framer_discards_split_control_string_after_timeout() {
|
||||
let mut framer = RawInputByteFramer::default();
|
||||
framer.host_color_query_sent();
|
||||
|
||||
assert!(framer.push(b"\x1b]").is_empty());
|
||||
assert!(framer.flush_timeout().is_empty());
|
||||
@@ -2782,7 +2766,6 @@ mod tests {
|
||||
#[test]
|
||||
fn raw_input_byte_framer_keeps_discarding_tail_across_timeout() {
|
||||
let mut framer = RawInputByteFramer::default();
|
||||
framer.host_color_query_sent();
|
||||
|
||||
assert!(framer.push(b"\x1b]").is_empty());
|
||||
assert!(framer.flush_timeout().is_empty());
|
||||
@@ -2795,7 +2778,6 @@ mod tests {
|
||||
#[test]
|
||||
fn raw_input_byte_framer_releases_discard_on_implausible_tail() {
|
||||
let mut framer = RawInputByteFramer::default();
|
||||
framer.host_color_query_sent();
|
||||
|
||||
assert!(framer.push(b"\x1b]").is_empty());
|
||||
assert!(framer.flush_timeout().is_empty());
|
||||
|
||||
Reference in New Issue
Block a user