diff --git a/crates/ghostty-vt/src/lib.rs b/crates/ghostty-vt/src/lib.rs index 1270d9f03..14a3272e8 100644 --- a/crates/ghostty-vt/src/lib.rs +++ b/crates/ghostty-vt/src/lib.rs @@ -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( diff --git a/src/client/input.rs b/src/client/input.rs index 6a89b017e..992142065 100644 --- a/src/client/input.rs +++ b/src/client/input.rs @@ -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, @@ -601,7 +591,7 @@ mod tests { next: &[u8], ) -> Vec> { 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 ); } diff --git a/src/client/shell/input.rs b/src/client/shell/input.rs index 65d10305e..93a91cdfc 100644 --- a/src/client/shell/input.rs +++ b/src/client/shell/input.rs @@ -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, + key: &crate::input::TerminalKey, + ) -> Option> { + 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) -> Option { + 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 { self.popup_terminal_id .as_ref() diff --git a/src/client/shell/tests/input.rs b/src/client/shell/tests/input.rs index 145c7ae6b..446140112 100644 --- a/src/client/shell/tests/input.rs +++ b/src/client/shell/tests/input.rs @@ -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())); diff --git a/src/client/shell/tests/input_conformance.rs b/src/client/shell/tests/input_conformance.rs index f41312a16..ccbfa2665 100644 --- a/src/client/shell/tests/input_conformance.rs +++ b/src/client/shell/tests/input_conformance.rs @@ -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; diff --git a/src/input/lease.rs b/src/input/lease.rs index 0352df427..0934cb1ae 100644 --- a/src/input/lease.rs +++ b/src/input/lease.rs @@ -69,8 +69,11 @@ where lease_key: &InputLeaseKey, 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, - ) -> Option> { - 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) -> 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] diff --git a/src/input/mod.rs b/src/input/mod.rs index c2fa48ef7..5580da255 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -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; diff --git a/src/pane/input.rs b/src/pane/input.rs index c0ea79302..c4f7f1565 100644 --- a/src/pane/input.rs +++ b/src/pane/input.rs @@ -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 { - 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 _ => None, } } - -fn ghostty_unshifted_ascii_pair(c: char) -> Option { - Some(match c { - '!' => '1', - '@' => '2', - '#' => '3', - '$' => '4', - '%' => '5', - '^' => '6', - '&' => '7', - '*' => '8', - '(' => '9', - ')' => '0', - '_' => '-', - '+' => '=', - '{' => '[', - '}' => ']', - '|' => '\\', - ':' => ';', - '"' => '\'', - '<' => ',', - '>' => '.', - '?' => '/', - '~' => '`', - _ => return None, - }) -} diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index 5574bee41..4a4614721 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -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); diff --git a/src/raw_input.rs b/src/raw_input.rs index b7e0188c5..8daa561e5 100644 --- a/src/raw_input.rs +++ b/src/raw_input.rs @@ -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 { 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 { #[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());