aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-08-23 00:01:57 +0300
committerGitHub <noreply@github.com>2026-08-23 00:01:57 +0300
commit1d604329b0ded93d18fd6b27f72c7473d1fee20f (patch)
tree2ac419b930b57c232b3e7bc8921beebfa1b997bb /src
parentc714e4c20e3272c21722a6f083fc2d592c244385 (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.rs94
-rw-r--r--src/client/input/windows_vti.rs201
-rw-r--r--src/input/encode.rs5
-rw-r--r--src/pane/terminal.rs24
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]