diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-08-23 00:01:57 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-08-23 00:01:57 +0300 |
| commit | 1d604329b0ded93d18fd6b27f72c7473d1fee20f (patch) | |
| tree | 2ac419b930b57c232b3e7bc8921beebfa1b997bb /src | |
| parent | c714e4c20e3272c21722a6f083fc2d592c244385 (diff) | |
fix(windows): preserve non-us shifted text (#3055)
* fix(windows): preserve non-us shifted text
refs #3045
* fix(windows): retain shifted key repeat lifecycle
refs #3045
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/input/lease.rs | 94 | ||||
| -rw-r--r-- | src/client/input/windows_vti.rs | 201 | ||||
| -rw-r--r-- | src/input/encode.rs | 5 | ||||
| -rw-r--r-- | src/pane/terminal.rs | 24 |
4 files changed, 312 insertions, 12 deletions
diff --git a/src/app/input/lease.rs b/src/app/input/lease.rs index de118334..6724b804 100644 --- a/src/app/input/lease.rs +++ b/src/app/input/lease.rs @@ -57,7 +57,11 @@ impl InputLeaseTable { lease_key: &InputLeaseKey, key: TerminalKey, ) -> TerminalKey { - if key.kind != crossterm::event::KeyEventKind::Press || key.generated_text.is_some() { + // Generated text is normally stateless, but a native physical key still owns + // press/repeat/release lifecycle even when it carries a layout result. + if key.kind != crossterm::event::KeyEventKind::Press + || (key.generated_text.is_some() && !key.has_physical_identity()) + { return key; } if key.has_physical_identity() && self.leases.contains_key(lease_key) { @@ -76,7 +80,7 @@ impl InputLeaseTable { resulting_context: Option<&TerminalInputContext>, target: Option<TerminalInputTarget>, ) -> RepeatPlan { - if key.generated_text.is_some() { + if key.generated_text.is_some() && !key.has_physical_identity() { return RepeatPlan::Ignore; } if let Some(target) = target { @@ -253,6 +257,19 @@ mod tests { } } + fn physical_generated_slash(repeat_count: u16) -> TerminalKey { + TerminalKey::new(KeyCode::Char('/'), KeyModifiers::SHIFT) + .with_generated_text(Some("/".to_owned())) + .with_windows_record(crate::input::WindowsKeyRecord { + key_down: true, + repeat_count, + virtual_key_code: 0x37, + virtual_scan_code: 0x08, + unicode: u16::from(b'/'), + control_key_state: 0x0010, + }) + } + #[test] fn remove_source_returns_forwarded_and_discards_consumed_leases() { let key = TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()); @@ -333,6 +350,79 @@ mod tests { } #[test] + fn physical_generated_text_keeps_native_repeat_lifecycle() { + let key = physical_generated_slash(3); + let lease_key = InputLeaseKey::new(7, &key); + let context = TerminalInputContext::Pane; + let forwarded_target = target(); + let mut leases = InputLeaseTable::default(); + + assert_eq!( + leases.normalize_press(&lease_key, key.clone()).kind, + crossterm::event::KeyEventKind::Press + ); + assert!(matches!( + leases.complete_press( + lease_key, + &key, + Some(&context), + Some(&context), + Some(forwarded_target.clone()), + ), + RepeatPlan::Ignore + )); + assert!(leases.contains(&lease_key)); + + let repeated = key.with_repeat_count(1); + let repeated = leases.normalize_press(&lease_key, repeated); + assert_eq!(repeated.kind, crossterm::event::KeyEventKind::Repeat); + assert!(matches!( + leases.plan_repeat(lease_key, &repeated, Some(&context)), + RepeatPlan::Forwarded(target) if target == forwarded_target + )); + assert!(leases.remove_forwarded(&lease_key).is_some()); + } + + #[test] + fn consumed_grouped_physical_generated_text_reprocesses_repeats() { + let key = physical_generated_slash(3); + let lease_key = InputLeaseKey::new(7, &key); + let context = TerminalInputContext::Pane; + let mut leases = InputLeaseTable::default(); + + assert!(matches!( + leases.complete_press(lease_key, &key, Some(&context), Some(&context), None), + RepeatPlan::Reprocess { + context: TerminalInputContext::Pane, + repetitions: 2, + tracked: true, + } + )); + } + + #[test] + fn semantic_generated_text_remains_untracked() { + let key = TerminalKey::new(KeyCode::Char('/'), KeyModifiers::SHIFT) + .with_generated_text(Some("/".to_owned())) + .with_repeat_count(3); + let lease_key = InputLeaseKey::new(7, &key); + let context = TerminalInputContext::Pane; + let mut leases = InputLeaseTable::default(); + + assert!(matches!( + leases.complete_press( + lease_key, + &key, + Some(&context), + Some(&context), + Some(target()) + ), + RepeatPlan::Ignore + )); + assert!(leases.is_empty()); + } + + #[test] fn new_semantic_press_recomputes_consumed_repeat_disposition() { let key = TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()).with_repeat_count(3); let lease_key = InputLeaseKey::new(7, &key); diff --git a/src/client/input/windows_vti.rs b/src/client/input/windows_vti.rs index dcf804c1..adfc9196 100644 --- a/src/client/input/windows_vti.rs +++ b/src/client/input/windows_vti.rs @@ -587,15 +587,25 @@ impl WindowsInputMapper { code, modifiers, kind, + generated_text, .. - } => crate::protocol::ClientInputEvent::Key { - code, - modifiers, - kind, - repeat_count: key.repeat_count.max(1), - generated_text: None, - source: crate::protocol::ClientKeySource::WindowsConsole { record: key }, - }, + } => { + // Windows Unicode is the authoritative host-layout result. Plain and + // AltGr characters already encode from `code`; Shift must remain on the + // physical event, so carry its produced text separately. + let shifted_text = key.key_down + && modifiers == crossterm::event::KeyModifiers::SHIFT.bits(); + crate::protocol::ClientInputEvent::Key { + code, + modifiers, + kind, + repeat_count: key.repeat_count.max(1), + generated_text: if shifted_text { generated_text } else { None }, + source: crate::protocol::ClientKeySource::WindowsConsole { + record: key, + }, + } + } event => event, }) .into_iter() @@ -2029,6 +2039,181 @@ mod tests { } #[test] + fn vti_win32_input_mode_non_us_shifted_text_preserves_generated_text() { + let pressed = WindowsKeyRecord { + key_down: true, + repeat_count: 1, + virtual_key_code: 0x37, + virtual_scan_code: 0x08, + unicode: b'/'.into(), + control_key_state: 0x0010, + }; + let released = WindowsKeyRecord { + key_down: false, + ..pressed + }; + let mut records = win32_input_mode_encoded_record(pressed); + records.extend(win32_input_mode_encoded_record(released)); + + let events = translate_with_provenance(records); + assert_eq!( + events, + vec![ + crate::protocol::ClientInputEvent::Key { + code: crate::protocol::ClientKeyCode::Char('/'), + modifiers: crossterm::event::KeyModifiers::SHIFT.bits(), + kind: crate::protocol::ClientKeyKind::Press, + repeat_count: 1, + generated_text: Some("/".into()), + source: crate::protocol::ClientKeySource::WindowsConsole { record: pressed }, + }, + crate::protocol::ClientInputEvent::Key { + code: crate::protocol::ClientKeyCode::Char('/'), + modifiers: crossterm::event::KeyModifiers::SHIFT.bits(), + kind: crate::protocol::ClientKeyKind::Release, + repeat_count: 1, + generated_text: None, + source: crate::protocol::ClientKeySource::WindowsConsole { record: released }, + }, + ] + ); + + let crate::raw_input::RawInputEvent::Key(key) = events[0].to_raw_input_event() else { + panic!("expected translated key"); + }; + assert_eq!( + crate::input::encode_terminal_key(key.clone(), crate::input::KeyboardProtocol::Legacy), + b"/" + ); + assert_eq!( + crate::input::encode_terminal_key( + key.clone(), + crate::input::KeyboardProtocol::Kitty { flags: 7 }, + ), + b"/" + ); + assert_eq!( + crate::input::encode_terminal_key( + key, + crate::input::KeyboardProtocol::Kitty { flags: 15 }, + ), + b"\x1b[47;2:1u" + ); + } + + #[test] + fn vti_native_layout_text_and_command_modifiers_encode_by_role() { + let cases = [ + ( + "plain unicode", + 0x4c, + 0x26, + 'λ' as u16, + 0, + 0, + None, + "λ".as_bytes(), + ), + ( + "shifted latin", + 0x41, + 0x1e, + 'A' as u16, + 0x0010, + crossterm::event::KeyModifiers::SHIFT.bits(), + Some("A"), + b"A".as_slice(), + ), + ( + "shifted non-ascii", + 0x4c, + 0x26, + 'Λ' as u16, + 0x0010, + crossterm::event::KeyModifiers::SHIFT.bits(), + Some("Λ"), + "Λ".as_bytes(), + ), + ( + "altgr", + 0x45, + 0x12, + '€' as u16, + 0x0009, + 0, + None, + "€".as_bytes(), + ), + ( + "shift altgr", + 0x37, + 0x08, + '{' as u16, + 0x0019, + crossterm::event::KeyModifiers::SHIFT.bits(), + Some("{"), + b"{".as_slice(), + ), + ( + "left alt command", + 0x41, + 0x1e, + 'a' as u16, + 0x0002, + crossterm::event::KeyModifiers::ALT.bits(), + None, + b"\x1ba".as_slice(), + ), + ( + "control command", + 0x41, + 0x1e, + 0x01, + 0x0008, + crossterm::event::KeyModifiers::CONTROL.bits(), + None, + b"\x01".as_slice(), + ), + ]; + + for ( + name, + virtual_key_code, + virtual_scan_code, + unicode, + state, + modifiers, + text, + expected, + ) in cases + { + let events = + translate_with_provenance(win32_input_mode_encoded_record(WindowsKeyRecord { + key_down: true, + repeat_count: 1, + virtual_key_code, + virtual_scan_code, + unicode, + control_key_state: state, + })); + let [event] = events.as_slice() else { + panic!("{name}: expected one translated event, got {events:?}"); + }; + let crate::raw_input::RawInputEvent::Key(key) = event.to_raw_input_event() else { + panic!("{name}: expected translated key"); + }; + + assert_eq!(key.modifiers.bits(), modifiers, "{name}: modifiers"); + assert_eq!(key.generated_text.as_deref(), text, "{name}: text"); + assert_eq!( + crate::input::encode_terminal_key(key, crate::input::KeyboardProtocol::Legacy), + expected, + "{name}: encoding" + ); + } + } + + #[test] fn vti_win32_input_mode_sequence_inside_bracketed_paste_stays_payload() { let records = "\x1b[200~alpha\x1b[13;28;13;1;16;1_bravo\x1b[201~" .chars() diff --git a/src/input/encode.rs b/src/input/encode.rs index 8ec8a1c5..774ab1a6 100644 --- a/src/input/encode.rs +++ b/src/input/encode.rs @@ -16,7 +16,10 @@ pub fn encode_key(key: KeyEvent, protocol: KeyboardProtocol) -> Vec<u8> { } pub fn encode_terminal_key(key: TerminalKey, protocol: KeyboardProtocol) -> Vec<u8> { - if key.kind != crossterm::event::KeyEventKind::Release { + // REPORT_ALL_KEYS must retain physical press/repeat/release semantics instead of + // reducing a native key to its layout-generated text. + let preserve_physical_key = key.has_physical_identity() && protocol.reports_all_keys(); + if !preserve_physical_key && key.kind != crossterm::event::KeyEventKind::Release { if let Some(text) = &key.generated_text { return text.as_bytes().to_vec(); } diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index f1c6e2e5..37de333b 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -4499,7 +4499,7 @@ mod tests { } #[test] - fn grouped_semantic_key_repeats_expand_at_the_destination() { + fn grouped_key_repeats_expand_at_the_destination() { let (tx, _rx) = mpsc::channel(4); let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); @@ -4513,6 +4513,28 @@ mod tests { pane.encode_terminal_key(key, crate::input::KeyboardProtocol::Legacy), b"xxx" ); + + let shifted = crate::input::TerminalKey::new( + crossterm::event::KeyCode::Char('/'), + crossterm::event::KeyModifiers::SHIFT, + ) + .with_generated_text(Some("/".to_owned())) + .with_windows_record(crate::input::WindowsKeyRecord { + key_down: true, + repeat_count: 3, + virtual_key_code: 0x37, + virtual_scan_code: 0x08, + unicode: u16::from(b'/'), + control_key_state: 0x0010, + }); + assert_eq!( + pane.encode_terminal_key(shifted.clone(), crate::input::KeyboardProtocol::Legacy,), + b"///" + ); + 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" + ); } #[test] |
