diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index 8296adf929..5063273858 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -27,6 +27,7 @@ - Known-agent integrations now leave pane ownership to confirmed process exit, so restarting Pi with the same saved session restores lifecycle state even with custom working UI. (#1792) - OMP integration install, status, and uninstall now respect `PI_CONFIG_DIR` when `PI_CODING_AGENT_DIR` is not set, and installation refuses extension-directory collisions with Pi. (#1696) - Physical Escape key records on native Windows now bypass raw VT report framing, so pane applications receive Escape immediately and reliably. (#1736) +- Native Windows key presses, grouped repeats, and releases now preserve their physical lifecycle and stay with the pane that received the initial press. (#2077) - Windows now shows `system` notifications and completes MP3 notification sounds without leaving PowerShell players waiting for a timeout. (#1330) ## [0.7.5] - 2026-07-21 diff --git a/docs/next/api/herdr-api.schema.json b/docs/next/api/herdr-api.schema.json index 59a0c6a331..b4704cd15c 100644 --- a/docs/next/api/herdr-api.schema.json +++ b/docs/next/api/herdr-api.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "protocol": 18, + "protocol": 19, "schema_version": 1, "schemas": { "error_response": { diff --git a/scripts/windows_check.ps1 b/scripts/windows_check.ps1 index 7c225301a6..1907d66be5 100644 --- a/scripts/windows_check.ps1 +++ b/scripts/windows_check.ps1 @@ -29,6 +29,53 @@ function Invoke-CargoWithZigCacheRecovery { Invoke-Checked cargo $Arguments } +function Invoke-CargoTestFilter { + param( + [Parameter(Mandatory)] + [string]$Filter, + [switch]$Exact + ) + + $commonArguments = @( + "test", + "--locked", + "--target", + "x86_64-pc-windows-msvc", + "--bin", + "herdr", + $Filter + ) + $harnessArguments = @("--list") + if ($Exact) { + $harnessArguments += "--exact" + } + + $listArguments = $commonArguments + @("--") + $harnessArguments + $listOutput = @(& cargo @listArguments) + if ($LASTEXITCODE -ne 0) { + throw "could not enumerate tests for filter '$Filter': $($listOutput -join [Environment]::NewLine)" + } + + $testNames = @( + foreach ($line in $listOutput) { + $match = [regex]::Match([string]$line, '^\s*(\S+): test\s*$') + if ($match.Success) { + $match.Groups[1].Value + } + } + ) + if ($testNames.Count -eq 0) { + throw "test filter '$Filter' selected zero tests" + } + + Write-Host "Running $($testNames.Count) test(s) for '$Filter'" + $runArguments = $commonArguments + if ($Exact) { + $runArguments += @("--", "--exact") + } + Invoke-Checked cargo $runArguments +} + Invoke-Checked rustup @("target", "add", "x86_64-pc-windows-msvc") Invoke-Checked cargo @("fmt", "--check") Invoke-CargoWithZigCacheRecovery @( @@ -47,22 +94,7 @@ if ($Mode -eq "lint") { return } -Invoke-Checked cargo @( - "test", - "--locked", - "--target", - "x86_64-pc-windows-msvc", - "--bin", - "herdr", - "windows_" -) -Invoke-Checked cargo @( - "test", - "--locked", - "--target", - "x86_64-pc-windows-msvc", - "--bin", - "herdr", - "server::client_transport::tests" -) +Invoke-CargoTestFilter "windows_" +Invoke-CargoTestFilter "server::client_transport::tests" +Invoke-CargoTestFilter "app::tests::native_repeats_and_releases_follow_the_pressed_pane" -Exact Invoke-Checked cargo @("build", "--locked", "--target", "x86_64-pc-windows-msvc") diff --git a/scripts/windows_conpty_enhanced_input_probe.ps1 b/scripts/windows_conpty_enhanced_input_probe.ps1 index bbd2b40b33..1e45252103 100644 --- a/scripts/windows_conpty_enhanced_input_probe.ps1 +++ b/scripts/windows_conpty_enhanced_input_probe.ps1 @@ -132,6 +132,34 @@ function Send-RawAndObserve { } } +function Send-NativeRecordAndObserve { + param( + [string] $PaneId, + [string] $Text, + [string] $ExpectedRecord + ) + $needle = "PROBE_RECORD:$ExpectedRecord" + $before = ([regex]::Matches((Read-Pane -PaneId $PaneId), [regex]::Escape($needle))).Count + & $script:Exe pane send-text $PaneId $Text | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "pane send-text failed with exit code $LASTEXITCODE" + } + $deadline = (Get-Date).AddSeconds(10) + do { + $paneText = Read-Pane -PaneId $PaneId + $after = ([regex]::Matches($paneText, [regex]::Escape($needle))).Count + if ($after -gt $before) { + break + } + Start-Sleep -Milliseconds 200 + } while ((Get-Date) -lt $deadline) + return [ordered]@{ + expected_record = $needle + delivered = $after -gt $before + pane = $paneText + } +} + $script:Exe = (Resolve-Path $ExePath).Path $workDir = Join-Path ([System.IO.Path]::GetTempPath()) "herdr-conpty-input-$([guid]::NewGuid().ToString('N'))" $probeSource = Join-Path $workDir "probe.rs" @@ -175,6 +203,12 @@ extern "system" { bytes_read: *mut u32, overlapped: *mut c_void, ) -> i32; + fn ReadConsoleInputW( + handle: Handle, + records: *mut InputRecord, + length: u32, + records_read: *mut u32, + ) -> i32; fn WriteFile( handle: Handle, buffer: *const c_void, @@ -184,6 +218,31 @@ extern "system" { ) -> i32; } +#[repr(C)] +#[derive(Clone, Copy)] +struct KeyEventRecord { + key_down: i32, + repeat_count: u16, + virtual_key_code: u16, + virtual_scan_code: u16, + unicode_char: u16, + control_key_state: u32, +} + +#[repr(C)] +#[derive(Clone, Copy)] +union InputRecordEvent { + key: KeyEventRecord, +} + +#[repr(C)] +struct InputRecord { + event_type: u16, + event: InputRecordEvent, +} + +const KEY_EVENT: u16 = 0x0001; + fn write_all(handle: Handle, mut bytes: &[u8]) { while !bytes.is_empty() { let mut written = 0; @@ -212,43 +271,88 @@ fn main() { write_all(output, b"PROBE_ERROR_GET_MODE\r\n"); std::process::exit(1); } - let raw_vt_mode = (console_mode | ENABLE_VIRTUAL_TERMINAL_INPUT) - & !(ENABLE_PROCESSED_INPUT | ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT); - if unsafe { SetConsoleMode(input, raw_vt_mode) } == 0 { - write_all(output, b"PROBE_ERROR_SET_MODE\r\n"); - std::process::exit(2); + if mode != "native" { + let raw_vt_mode = (console_mode | ENABLE_VIRTUAL_TERMINAL_INPUT) + & !(ENABLE_PROCESSED_INPUT | ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT); + if unsafe { SetConsoleMode(input, raw_vt_mode) } == 0 { + write_all(output, b"PROBE_ERROR_SET_MODE\r\n"); + std::process::exit(2); + } } - if mode == "kitty" { + if mode == "native" { + write_all(output, b"PROBE_READY_NATIVE\r\n"); + } else if mode == "kitty" { write_all(output, b"\x1b[>7u\x1b[?u\x1b[cPROBE_READY_KITTY\r\n"); } else { write_all(output, b"PROBE_READY_LEGACY\r\n"); } - let mut all = Vec::new(); - let mut buffer = [0u8; 256]; - loop { - let mut read = 0; - let ok = unsafe { - ReadFile( - input, - buffer.as_mut_ptr().cast(), - buffer.len() as u32, - &mut read, - std::ptr::null_mut(), - ) - }; - if ok == 0 || read == 0 { - break; + if mode == "native" { + let mut records: [InputRecord; 16] = std::array::from_fn(|_| InputRecord { + event_type: 0, + event: InputRecordEvent { + key: KeyEventRecord { + key_down: 0, + repeat_count: 0, + virtual_key_code: 0, + virtual_scan_code: 0, + unicode_char: 0, + control_key_state: 0, + }, + }, + }); + loop { + let mut read = 0; + let ok = unsafe { + ReadConsoleInputW(input, records.as_mut_ptr(), records.len() as u32, &mut read) + }; + if ok == 0 || read == 0 { + break; + } + for record in &records[..read as usize] { + if record.event_type != KEY_EVENT { + continue; + } + let key = unsafe { &record.event.key }; + let line = format!( + "PROBE_RECORD:{};{};{};{};{};{}\r\n", + if key.key_down != 0 { 1 } else { 0 }, + key.repeat_count, + key.virtual_key_code, + key.virtual_scan_code, + key.unicode_char, + key.control_key_state, + ); + write_all(output, line.as_bytes()); + } } - all.extend_from_slice(&buffer[..read as usize]); - let mut line = String::from("PROBE_ALL:"); - for byte in &all { - use std::fmt::Write as _; - let _ = write!(&mut line, "{byte:02x}"); + } else { + let mut all = Vec::new(); + let mut buffer = [0u8; 256]; + loop { + let mut read = 0; + let ok = unsafe { + ReadFile( + input, + buffer.as_mut_ptr().cast(), + buffer.len() as u32, + &mut read, + std::ptr::null_mut(), + ) + }; + if ok == 0 || read == 0 { + break; + } + all.extend_from_slice(&buffer[..read as usize]); + let mut line = String::from("PROBE_ALL:"); + for byte in &all { + use std::fmt::Write as _; + let _ = write!(&mut line, "{byte:02x}"); + } + line.push_str("\r\n"); + write_all(output, line.as_bytes()); } - line.push_str("\r\n"); - write_all(output, line.as_bytes()); } } '@ | Set-Content -NoNewline -Encoding utf8 $probeSource @@ -311,6 +415,13 @@ fn main() { $report.raw_alt_v = Send-RawAndObserve -PaneId $kittyPane -Text ([char]27 + "v") -ExpectedHex "1b76" $report.raw_ctrl_u = Send-RawAndObserve -PaneId $kittyPane -Text ([string][char]0x15) -ExpectedHex "15" + $nativePane = New-ProbePane -Mode "native" + # Win32 input mode records use CSI Vk;Scan;Unicode;Down;Control;Repeat _. + $nativeEscapeDown = [char]27 + "[27;1;27;1;0;3_" + $nativeEscapeRelease = [char]27 + "[27;1;27;0;0;1_" + $report.native_escape_down = Send-NativeRecordAndObserve -PaneId $nativePane -Text $nativeEscapeDown -ExpectedRecord "1;3;27;1;27;0" + $report.native_escape_release = Send-NativeRecordAndObserve -PaneId $nativePane -Text $nativeEscapeRelease -ExpectedRecord "0;1;27;1;27;0" + $consoleHosts = @(Get-Process -Name conhost, OpenConsole -ErrorAction SilentlyContinue | Where-Object { $initialConsoleHostIds -notcontains $_.Id } | ForEach-Object { @@ -354,6 +465,8 @@ fn main() { -or -not $report.raw_kitty_ctrl_delete.delivered ` -or -not $report.raw_alt_v.delivered ` -or -not $report.raw_ctrl_u.delivered ` + -or -not $report.native_escape_down.delivered ` + -or -not $report.native_escape_release.delivered ` -or ($appLocalHostRequired -and -not $report.app_local_console_host) } finally { if ($null -ne $server) { diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs index 02c9c3d443..86e019ac93 100644 --- a/src/app/api/panes.rs +++ b/src/app/api/panes.rs @@ -2678,7 +2678,7 @@ mod tests { rx.try_recv().expect("forwarded release after pane move"), bytes::Bytes::from_static(b"\x1b[106;1:3u") ); - assert!(app.pressed_terminal_keys.is_empty()); + assert!(app.input_leases.is_empty()); } #[test] diff --git a/src/app/input/clipboard.rs b/src/app/input/clipboard.rs index db3fa9f2b6..62ac1ca906 100644 --- a/src/app/input/clipboard.rs +++ b/src/app/input/clipboard.rs @@ -5,7 +5,9 @@ use crate::{ input::TerminalKey, }; -fn is_retained_selection_copy_key(key: TerminalKey) -> bool { +use super::{ConsumedInputLease, InputLeaseKey}; + +fn is_retained_selection_copy_key(key: &TerminalKey) -> bool { matches!(key.code, KeyCode::Char('c' | 'C')) && matches!(key.modifiers, KeyModifiers::CONTROL | KeyModifiers::SUPER) } @@ -31,7 +33,7 @@ impl App { key: TerminalKey, ) -> bool { if self.state.copy_on_select - || !is_retained_selection_copy_key(key) + || !is_retained_selection_copy_key(&key) || !self .state .selection @@ -46,7 +48,10 @@ impl App { return false; } - self.suppressed_repeat_keys.insert((source_id, key.code)); + self.input_leases.insert_consumed( + InputLeaseKey::new(source_id, &key), + ConsumedInputLease::SuppressRepeats, + ); true } } @@ -132,11 +137,19 @@ mod tests { assert_visible_selection(&app); assert!(app.event_rx.try_recv().is_err()); - let ctrl_c = TerminalKey::new(KeyCode::Char('c'), KeyModifiers::CONTROL); + let ctrl_c = TerminalKey::new(KeyCode::Char('c'), KeyModifiers::CONTROL) + .with_windows_record(crate::input::WindowsKeyRecord { + key_down: true, + repeat_count: 1, + virtual_key_code: 0x43, + virtual_scan_code: 0x2e, + unicode: 'c' as u16, + control_key_state: 0x0008, + }); let source_id = 41; app.route_client_events_from( source_id, - vec![crate::raw_input::RawInputEvent::Key(ctrl_c)], + vec![crate::raw_input::RawInputEvent::Key(ctrl_c.clone())], false, ); @@ -156,29 +169,39 @@ mod tests { app.route_client_events_from( source_id, - vec![crate::raw_input::RawInputEvent::Key( - ctrl_c.with_kind(KeyEventKind::Repeat), - )], + vec![ + crate::raw_input::RawInputEvent::Key(ctrl_c.clone()), + crate::raw_input::RawInputEvent::Key( + ctrl_c.clone().with_kind(KeyEventKind::Repeat), + ), + ], false, ); + assert_eq!(app.input_leases.len(), 1); assert!(app.event_rx.try_recv().is_err()); assert!(input_rx.try_recv().is_err()); app.route_client_events_from( source_id, vec![crate::raw_input::RawInputEvent::Key( - ctrl_c.with_kind(KeyEventKind::Release), + ctrl_c.clone().with_kind(KeyEventKind::Release), )], false, ); + assert!(app.input_leases.is_empty()); app.route_client_events_from( source_id, - vec![crate::raw_input::RawInputEvent::Key(ctrl_c)], + vec![crate::raw_input::RawInputEvent::Key(ctrl_c.clone())], false, ); + let expected = if cfg!(windows) { + b"\x1b[67;46;99;1;8;1_".as_slice() + } else { + b"\x03".as_slice() + }; assert_eq!( input_rx.try_recv().expect("forwarded Ctrl-C").as_ref(), - b"\x03" + expected ); assert!(app.event_rx.try_recv().is_err()); } diff --git a/src/app/input/copy_mode.rs b/src/app/input/copy_mode.rs index 6c96713336..b744d2ce6e 100644 --- a/src/app/input/copy_mode.rs +++ b/src/app/input/copy_mode.rs @@ -16,7 +16,7 @@ impl App { return; } self.state.update_dismissed = true; - if self.state.is_prefix_key(key) { + if self.state.is_prefix_key(&key) { self.state.mode = Mode::Prefix; return; } @@ -80,7 +80,7 @@ impl AppState { terminal_runtimes: &TerminalRuntimeRegistry, key: TerminalKey, ) { - if self.handle_copy_mode_search_prompt_key(terminal_runtimes, key) { + if self.handle_copy_mode_search_prompt_key(terminal_runtimes, key.clone()) { return; } match key.code { diff --git a/src/app/input/lease.rs b/src/app/input/lease.rs new file mode 100644 index 0000000000..de118334d4 --- /dev/null +++ b/src/app/input/lease.rs @@ -0,0 +1,375 @@ +use std::collections::HashMap; + +use crate::app::{InputSourceId, TerminalInputContext, TerminalInputTarget}; +use crate::input::{KeyIdentity, TerminalKey}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(crate) struct InputLeaseKey { + source_id: InputSourceId, + identity: KeyIdentity, +} + +impl InputLeaseKey { + pub(crate) fn new(source_id: InputSourceId, key: &TerminalKey) -> Self { + Self { + source_id, + identity: key.identity(), + } + } +} + +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct ForwardedInputLease { + pub(crate) target: TerminalInputTarget, + pub(crate) key: TerminalKey, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum ConsumedInputLease { + ReprocessRepeats(TerminalInputContext), + SuppressRepeats, +} + +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum InputLease { + Forwarded(ForwardedInputLease), + Consumed(ConsumedInputLease), +} + +pub(crate) enum RepeatPlan { + Forwarded(TerminalInputTarget), + Reprocess { + context: TerminalInputContext, + repetitions: u16, + tracked: bool, + }, + Ignore, +} + +#[derive(Default)] +pub(crate) struct InputLeaseTable { + leases: HashMap, +} + +impl InputLeaseTable { + pub(crate) fn normalize_press( + &mut self, + lease_key: &InputLeaseKey, + key: TerminalKey, + ) -> TerminalKey { + if key.kind != crossterm::event::KeyEventKind::Press || key.generated_text.is_some() { + return key; + } + if key.has_physical_identity() && self.leases.contains_key(lease_key) { + key.with_kind(crossterm::event::KeyEventKind::Repeat) + } else { + self.leases.remove(lease_key); + key + } + } + + pub(crate) fn complete_press( + &mut self, + lease_key: InputLeaseKey, + key: &TerminalKey, + initial_context: Option<&TerminalInputContext>, + resulting_context: Option<&TerminalInputContext>, + target: Option, + ) -> RepeatPlan { + if key.generated_text.is_some() { + return RepeatPlan::Ignore; + } + if let Some(target) = target { + self.insert_forwarded(lease_key, target, key.clone()); + return RepeatPlan::Ignore; + } + if !self.leases.contains_key(&lease_key) { + let disposition = match (initial_context, resulting_context) { + (Some(initial), Some(resulting)) if initial == resulting => { + ConsumedInputLease::ReprocessRepeats(initial.clone()) + } + _ => ConsumedInputLease::SuppressRepeats, + }; + self.insert_consumed(lease_key, disposition); + } + match self.leases.get(&lease_key) { + Some(InputLease::Consumed(ConsumedInputLease::ReprocessRepeats(context))) + if key.repeat_count > 1 => + { + RepeatPlan::Reprocess { + context: context.clone(), + repetitions: key.repeat_count - 1, + tracked: true, + } + } + _ => RepeatPlan::Ignore, + } + } + + pub(crate) fn plan_repeat( + &mut self, + lease_key: InputLeaseKey, + key: &TerminalKey, + current_context: Option<&TerminalInputContext>, + ) -> RepeatPlan { + match self.leases.get(&lease_key) { + Some(InputLease::Forwarded(lease)) => { + return RepeatPlan::Forwarded(lease.target.clone()); + } + Some(InputLease::Consumed(ConsumedInputLease::ReprocessRepeats(context))) + if current_context == Some(context) => + { + return RepeatPlan::Reprocess { + context: context.clone(), + repetitions: key.repeat_count, + tracked: true, + }; + } + Some(InputLease::Consumed(ConsumedInputLease::ReprocessRepeats(_))) => { + self.insert_consumed(lease_key, ConsumedInputLease::SuppressRepeats); + return RepeatPlan::Ignore; + } + Some(InputLease::Consumed(ConsumedInputLease::SuppressRepeats)) => { + return RepeatPlan::Ignore; + } + None => {} + } + match current_context { + Some(context) => RepeatPlan::Reprocess { + context: context.clone(), + repetitions: key.repeat_count, + tracked: false, + }, + None => RepeatPlan::Ignore, + } + } + + pub(crate) fn reprocess_allowed( + &mut self, + lease_key: InputLeaseKey, + expected_context: &TerminalInputContext, + current_context: Option<&TerminalInputContext>, + tracked: bool, + ) -> bool { + let allowed = current_context == Some(expected_context); + if tracked && !allowed { + self.insert_consumed(lease_key, ConsumedInputLease::SuppressRepeats); + } + 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) + } + + pub(crate) fn insert_forwarded( + &mut self, + key: InputLeaseKey, + target: TerminalInputTarget, + original: TerminalKey, + ) { + self.leases.insert( + key, + InputLease::Forwarded(ForwardedInputLease { + target, + key: original, + }), + ); + } + + pub(crate) fn insert_consumed(&mut self, key: InputLeaseKey, disposition: ConsumedInputLease) { + self.leases.insert(key, InputLease::Consumed(disposition)); + } + + pub(crate) fn remove(&mut self, key: &InputLeaseKey) -> Option { + self.leases.remove(key) + } + + pub(crate) fn remove_source(&mut self, source_id: InputSourceId) -> Vec { + let keys = self + .leases + .keys() + .filter(|key| key.source_id == source_id) + .copied() + .collect::>(); + self.remove_keys(keys) + } + + pub(crate) fn remove_target( + &mut self, + target: &TerminalInputTarget, + ) -> Vec { + let keys = self + .leases + .iter() + .filter_map(|(key, lease)| match lease { + InputLease::Forwarded(lease) if &lease.target == target => Some(*key), + InputLease::Forwarded(_) | InputLease::Consumed(_) => None, + }) + .collect::>(); + self.remove_keys(keys) + } + + fn remove_keys( + &mut self, + keys: impl IntoIterator, + ) -> Vec { + keys.into_iter() + .filter_map(|key| match self.leases.remove(&key) { + Some(InputLease::Forwarded(lease)) => Some(lease), + Some(InputLease::Consumed(_)) | None => None, + }) + .collect() + } + + #[cfg(test)] + pub(crate) fn is_empty(&self) -> bool { + self.leases.is_empty() + } + + #[cfg(test)] + pub(crate) fn len(&self) -> usize { + self.leases.len() + } +} + +#[cfg(test)] +mod tests { + use crossterm::event::{KeyCode, KeyModifiers}; + + use super::*; + + fn target() -> TerminalInputTarget { + TerminalInputTarget { + terminal_id: crate::terminal::TerminalId::alloc(), + } + } + + #[test] + fn remove_source_returns_forwarded_and_discards_consumed_leases() { + let key = TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()); + let forwarded = InputLeaseKey::new(7, &key); + let consumed = InputLeaseKey::new( + 7, + &TerminalKey::new(KeyCode::Char('c'), KeyModifiers::CONTROL), + ); + let other_source = InputLeaseKey::new(8, &key); + let first_target = target(); + let mut leases = InputLeaseTable::default(); + leases.insert_forwarded(forwarded, first_target.clone(), key.clone()); + leases.insert_consumed(consumed, ConsumedInputLease::SuppressRepeats); + leases.insert_forwarded(other_source, target(), key.clone()); + + assert_eq!( + leases.remove_source(7), + vec![ForwardedInputLease { + target: first_target, + key, + }] + ); + assert_eq!(leases.len(), 1); + assert!(leases.contains(&other_source)); + } + + #[test] + fn remove_target_closes_only_forwarded_leases_for_that_terminal() { + let key = TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()); + let removed_key = InputLeaseKey::new(7, &key); + let retained_key = InputLeaseKey::new(8, &key); + let removed_target = target(); + let retained_target = target(); + let mut leases = InputLeaseTable::default(); + leases.insert_forwarded(removed_key, removed_target.clone(), key.clone()); + leases.insert_forwarded(retained_key, retained_target, key.clone()); + + assert_eq!( + leases.remove_target(&removed_target), + vec![ForwardedInputLease { + target: removed_target, + key, + }] + ); + assert_eq!(leases.len(), 1); + assert!(leases.contains(&retained_key)); + } + + #[test] + fn duplicate_physical_press_normalizes_for_forwarded_and_consumed_leases() { + let record = crate::input::WindowsKeyRecord { + key_down: true, + repeat_count: 1, + virtual_key_code: 65, + virtual_scan_code: 30, + unicode: 97, + control_key_state: 0, + }; + let physical = + TerminalKey::new(KeyCode::Char('a'), KeyModifiers::empty()).with_windows_record(record); + let lease_key = InputLeaseKey::new(7, &physical); + let mut leases = InputLeaseTable::default(); + + assert_eq!( + leases.normalize_press(&lease_key, physical.clone()).kind, + crossterm::event::KeyEventKind::Press + ); + leases.insert_consumed(lease_key, ConsumedInputLease::SuppressRepeats); + assert_eq!( + leases.normalize_press(&lease_key, physical.clone()).kind, + crossterm::event::KeyEventKind::Repeat + ); + leases.insert_forwarded(lease_key, target(), physical.clone()); + assert_eq!( + leases.normalize_press(&lease_key, physical.clone()).kind, + crossterm::event::KeyEventKind::Repeat + ); + } + + #[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); + let context = TerminalInputContext::Pane; + let mut leases = InputLeaseTable::default(); + leases.insert_consumed(lease_key, ConsumedInputLease::SuppressRepeats); + + let key = leases.normalize_press(&lease_key, key); + let plan = leases.complete_press(lease_key, &key, Some(&context), Some(&context), None); + + assert!(matches!( + plan, + RepeatPlan::Reprocess { + context: TerminalInputContext::Pane, + repetitions: 2, + tracked: true, + } + )); + } + + #[test] + fn physical_and_semantic_identities_do_not_collide() { + let record = crate::input::WindowsKeyRecord { + key_down: true, + repeat_count: 1, + virtual_key_code: 65, + virtual_scan_code: 30, + unicode: 97, + control_key_state: 0, + }; + let physical = + TerminalKey::new(KeyCode::Char('a'), KeyModifiers::empty()).with_windows_record(record); + let semantic = TerminalKey::new(KeyCode::Char('a'), KeyModifiers::empty()); + + assert_ne!( + InputLeaseKey::new(7, &physical), + InputLeaseKey::new(7, &semantic) + ); + } +} diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs index 40bc82fb27..f22c799473 100644 --- a/src/app/input/mod.rs +++ b/src/app/input/mod.rs @@ -38,6 +38,7 @@ fn modified_url_click_modifier_matches_terminal_mouse_reporting() { mod clipboard; mod copy_mode; +mod lease; mod modal; mod mouse; mod navigate; @@ -48,6 +49,7 @@ mod sidebar; mod terminal; pub(crate) use self::{ + lease::{ConsumedInputLease, ForwardedInputLease, InputLeaseKey, InputLeaseTable, RepeatPlan}, modal::{ handle_global_menu_key, handle_keybind_help_key, handle_navigator_key, insert_keybind_help_query_text, insert_navigator_search_text, insert_rename_input_text, @@ -121,6 +123,66 @@ impl App { None } + pub(crate) fn handle_text_commit_headless(&mut self, text: &str) { + if text.is_empty() { + return; + } + if self.state.popup_pane.is_some() { + if let Some(runtime) = self.popup_runtime() { + let _ = runtime.try_send_bytes(Bytes::copy_from_slice(text.as_bytes())); + } else { + self.close_popup_pane(); + } + return; + } + if self.state.mode != Mode::Terminal { + self.paste_into_active_text_input(text); + return; + } + + self.state.clear_selection(); + self.selection_autoscroll_deadline = None; + self.state.update_dismissed = true; + if let Some(ws_idx) = self.state.active { + if let Some(runtime) = self + .state + .focused_runtime_in_workspace(&self.terminal_runtimes, ws_idx) + { + let _ = runtime.try_send_bytes(Bytes::copy_from_slice(text.as_bytes())); + } + } + } + + pub(super) async fn handle_text_commit(&mut self, text: String) { + if text.is_empty() { + return; + } + if self.state.popup_pane.is_some() { + if let Some(runtime) = self.popup_runtime() { + let _ = runtime.send_bytes(Bytes::from(text)).await; + } else { + self.close_popup_pane(); + } + return; + } + if self.state.mode != Mode::Terminal { + self.paste_into_active_text_input(&text); + return; + } + + self.state.clear_selection(); + self.selection_autoscroll_deadline = None; + self.state.update_dismissed = true; + if let Some(ws_idx) = self.state.active { + if let Some(runtime) = self + .state + .focused_runtime_in_workspace(&self.terminal_runtimes, ws_idx) + { + let _ = runtime.send_bytes(Bytes::from(text)).await; + } + } + } + pub(super) async fn handle_paste(&mut self, text: String) { if self.state.popup_pane.is_some() { if let Some(runtime) = self.popup_runtime() { diff --git a/src/app/input/modal.rs b/src/app/input/modal.rs index d8a146024c..0a5c13e569 100644 --- a/src/app/input/modal.rs +++ b/src/app/input/modal.rs @@ -309,7 +309,7 @@ pub(super) fn keybind_help_back(state: &mut AppState) { pub(crate) fn handle_keybind_help_key(state: &mut AppState, key: TerminalKey) { if state.keybind_help.search_focused { - let text_char = keybind_help_text_char(key); + let text_char = keybind_help_text_char(key.clone()); match key.code { KeyCode::Up => state.scroll_keybind_help(-1), KeyCode::Down => state.scroll_keybind_help(1), @@ -343,13 +343,13 @@ pub(crate) fn handle_keybind_help_key(state: &mut AppState, key: TerminalKey) { KeyCode::PageDown => state.scroll_keybind_help(8), KeyCode::Home => state.keybind_help.scroll = 0, KeyCode::End => state.keybind_help.scroll = state.keybind_help_max_scroll(), - _ if keybind_help_text_char(key) == Some('/') => { + _ if keybind_help_text_char(key.clone()) == Some('/') => { state.keybind_help.search_focused = true; state.keybind_help.scroll = 0; } KeyCode::Esc => keybind_help_back(state), KeyCode::Enter => leave_modal(state), - _ if keybind_help_text_char(key) == Some('?') => leave_modal(state), + _ if keybind_help_text_char(key.clone()) == Some('?') => leave_modal(state), _ => {} } } @@ -712,8 +712,8 @@ pub(crate) fn handle_resize_key(state: &mut AppState, raw_key: TerminalKey) { let key = raw_key.as_key_event(); if key.code == KeyCode::Esc || key.code == KeyCode::Enter - || state.keybinds.resize_mode.matches_prefix_key(raw_key) - || state.keybinds.resize_mode.matches_direct_key(raw_key) + || state.keybinds.resize_mode.matches_prefix_key(&raw_key) + || state.keybinds.resize_mode.matches_direct_key(&raw_key) { if state.active.is_some() { state.mode = Mode::Terminal; @@ -1128,8 +1128,8 @@ impl App { let key = raw_key.as_key_event(); if key.code == KeyCode::Esc || key.code == KeyCode::Enter - || self.state.keybinds.resize_mode.matches_prefix_key(raw_key) - || self.state.keybinds.resize_mode.matches_direct_key(raw_key) + || self.state.keybinds.resize_mode.matches_prefix_key(&raw_key) + || self.state.keybinds.resize_mode.matches_direct_key(&raw_key) { self.state.mode = if self.state.active.is_some() { Mode::Terminal diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs index 93dfe77ddf..6d0f7f22e7 100644 --- a/src/app/input/navigate.rs +++ b/src/app/input/navigate.rs @@ -31,14 +31,14 @@ pub(crate) fn terminal_direct_navigation_action( pub(crate) fn terminal_direct_non_indexed_navigation_action( state: &AppState, - key: TerminalKey, + key: &TerminalKey, ) -> Option { non_indexed_action_for_key(state, key, BindingDispatch::Direct) } pub(crate) fn terminal_direct_indexed_navigation_action( state: &AppState, - key: TerminalKey, + key: &TerminalKey, ) -> Option { indexed_navigation_action(state, key, BindingDispatch::Direct) } @@ -65,7 +65,7 @@ impl App { return; } - if self.state.is_prefix_key(raw_key) { + if self.state.is_prefix_key(&raw_key) { if self.state.copy_mode_pane_is_focused() { self.state.cancel_copy_mode(&self.terminal_runtimes); } @@ -81,20 +81,20 @@ impl App { } if let Some(action) = - non_indexed_action_for_key(&self.state, raw_key, BindingDispatch::Prefix) + non_indexed_action_for_key(&self.state, &raw_key, BindingDispatch::Prefix) { self.execute_prefix_key_action(action); return; } - if let Some(binding) = command_for_key(&self.state, raw_key, BindingDispatch::Prefix) { + if let Some(binding) = command_for_key(&self.state, &raw_key, BindingDispatch::Prefix) { self.cancel_copy_mode_if_active(); self.launch_custom_command(binding, ActionContext::Prefix); return; } if let Some(action) = - indexed_navigation_action(&self.state, raw_key, BindingDispatch::Prefix) + indexed_navigation_action(&self.state, &raw_key, BindingDispatch::Prefix) { self.execute_prefix_key_action(action); return; @@ -128,7 +128,7 @@ impl App { let key = raw_key.as_key_event(); self.state.update_dismissed = true; - if key.code == KeyCode::Esc || self.state.is_prefix_key(raw_key) { + if key.code == KeyCode::Esc || self.state.is_prefix_key(&raw_key) { leave_navigate_mode(&mut self.state); return; } @@ -138,7 +138,7 @@ impl App { .keybinds .navigate .workspace_up - .matches_direct_key(raw_key) + .matches_direct_key(&raw_key) { self.state.move_selected_workspace_by_visible_delta(-1); return; @@ -148,18 +148,18 @@ impl App { .keybinds .navigate .workspace_down - .matches_direct_key(raw_key) + .matches_direct_key(&raw_key) { self.state.move_selected_workspace_by_visible_delta(1); return; } - if let Some(action) = navigate_reserved_action_for_key(&self.state, raw_key) { + if let Some(action) = navigate_reserved_action_for_key(&self.state, &raw_key) { self.execute_tui_navigate_action(action, ActionContext::Navigate); return; } - if let Some(action) = navigate_mode_non_indexed_action_for_key(&self.state, raw_key) { + if let Some(action) = navigate_mode_non_indexed_action_for_key(&self.state, &raw_key) { if action == NavigateAction::EditScrollback { self.launch_focused_scrollback_editor(); } else { @@ -169,12 +169,12 @@ impl App { return; } - if let Some(binding) = command_for_key(&self.state, raw_key, BindingDispatch::Prefix) { + if let Some(binding) = command_for_key(&self.state, &raw_key, BindingDispatch::Prefix) { self.launch_custom_command(binding, ActionContext::Navigate); return; } - if let Some(action) = navigate_mode_indexed_action_for_key(&self.state, raw_key) { + if let Some(action) = navigate_mode_indexed_action_for_key(&self.state, &raw_key) { self.execute_tui_navigate_action(action, ActionContext::Navigate); self.selection_autoscroll_deadline = None; } @@ -773,7 +773,7 @@ impl App { return false; }; - let bytes = rt.encode_terminal_key(key); + let bytes = rt.encode_terminal_key(key.clone()); if bytes.is_empty() || rt.try_send_bytes(Bytes::from(bytes)).is_err() { return false; } @@ -1136,7 +1136,7 @@ pub(crate) enum BindingDispatch { pub(crate) fn command_for_key( state: &AppState, - key: TerminalKey, + key: &TerminalKey, dispatch: BindingDispatch, ) -> Option { state @@ -1150,7 +1150,7 @@ pub(crate) fn command_for_key( .cloned() } -fn unmodified_digit_for_key(key: TerminalKey) -> Option { +fn unmodified_digit_for_key(key: &TerminalKey) -> Option { ('1'..='9').find(|digit| { crate::config::terminal_key_matches_combo( key, @@ -1164,7 +1164,7 @@ fn unmodified_digit_for_key(key: TerminalKey) -> Option { #[cfg(test)] pub(super) fn handle_navigate_reserved_key(state: &mut AppState, key: TerminalKey) -> bool { - if let Some(c) = unmodified_digit_for_key(key) { + if let Some(c) = unmodified_digit_for_key(&key) { let idx = (c as usize) - ('1' as usize); if let Some(ws_idx) = state.workspace_at_visible_position(idx) { state.switch_workspace(ws_idx); @@ -1203,7 +1203,12 @@ pub(super) fn handle_navigate_reserved_key(state: &mut AppState, key: TerminalKe } } - if state.keybinds.navigate.workspace_up.matches_direct_key(key) { + if state + .keybinds + .navigate + .workspace_up + .matches_direct_key(&key) + { state.move_selected_workspace_by_visible_delta(-1); return true; } @@ -1211,24 +1216,24 @@ pub(super) fn handle_navigate_reserved_key(state: &mut AppState, key: TerminalKe .keybinds .navigate .workspace_down - .matches_direct_key(key) + .matches_direct_key(&key) { state.move_selected_workspace_by_visible_delta(1); return true; } - if state.keybinds.navigate.pane_left.matches_direct_key(key) { + if state.keybinds.navigate.pane_left.matches_direct_key(&key) { state.navigate_pane(NavDirection::Left); return true; } - if state.keybinds.navigate.pane_down.matches_direct_key(key) { + if state.keybinds.navigate.pane_down.matches_direct_key(&key) { state.navigate_pane(NavDirection::Down); return true; } - if state.keybinds.navigate.pane_up.matches_direct_key(key) { + if state.keybinds.navigate.pane_up.matches_direct_key(&key) { state.navigate_pane(NavDirection::Up); return true; } - if state.keybinds.navigate.pane_right.matches_direct_key(key) { + if state.keybinds.navigate.pane_right.matches_direct_key(&key) { state.navigate_pane(NavDirection::Right); return true; } @@ -1236,7 +1241,7 @@ pub(super) fn handle_navigate_reserved_key(state: &mut AppState, key: TerminalKe false } -fn navigate_reserved_action_for_key(state: &AppState, key: TerminalKey) -> Option { +fn navigate_reserved_action_for_key(state: &AppState, key: &TerminalKey) -> Option { if let Some(c) = unmodified_digit_for_key(key) { return Some(NavigateAction::SwitchWorkspace( (c as usize) - ('1' as usize), @@ -1303,12 +1308,12 @@ pub(crate) fn handle_navigate_key(state: &mut AppState, key: KeyEvent) { state.update_dismissed = true; let terminal_key = TerminalKey::from(key); - if state.is_prefix_key(terminal_key) || key.code == KeyCode::Esc { + if state.is_prefix_key(&terminal_key) || key.code == KeyCode::Esc { leave_navigate_mode(state); return; } - if handle_navigate_reserved_key(state, terminal_key) { + if handle_navigate_reserved_key(state, terminal_key.clone()) { return; } @@ -1396,7 +1401,7 @@ fn copy_mode_survives_prefix_action(action: NavigateAction) -> bool { fn indexed_navigation_action( state: &AppState, - key: TerminalKey, + key: &TerminalKey, dispatch: BindingDispatch, ) -> Option { let kb = &state.keybinds; @@ -1440,7 +1445,7 @@ fn indexed_navigation_action( fn action_matches( bindings: &crate::config::ActionKeybinds, - key: TerminalKey, + key: &TerminalKey, dispatch: BindingDispatch, ) -> bool { match dispatch { @@ -1455,13 +1460,13 @@ fn action_for_key( key: TerminalKey, dispatch: BindingDispatch, ) -> Option { - non_indexed_action_for_key(state, key, dispatch) - .or_else(|| indexed_navigation_action(state, key, dispatch)) + non_indexed_action_for_key(state, &key, dispatch) + .or_else(|| indexed_navigation_action(state, &key, dispatch)) } fn non_indexed_action_for_key( state: &AppState, - key: TerminalKey, + key: &TerminalKey, dispatch: BindingDispatch, ) -> Option { let kb = &state.keybinds; @@ -1536,7 +1541,7 @@ fn navigate_mode_action_for_key(state: &AppState, key: TerminalKey) -> Option Option { let action = non_indexed_action_for_key(state, key, BindingDispatch::Prefix)?; if matches!( @@ -1553,7 +1558,7 @@ fn navigate_mode_non_indexed_action_for_key( fn navigate_mode_indexed_action_for_key( state: &AppState, - key: TerminalKey, + key: &TerminalKey, ) -> Option { indexed_navigation_action(state, key, BindingDispatch::Prefix) } @@ -2719,9 +2724,9 @@ command = "echo literal" state.keybinds = config.keybinds(); let key = TerminalKey::new(KeyCode::Char('!'), KeyModifiers::empty()); - assert!(command_for_key(&state, key, BindingDispatch::Prefix).is_some()); + assert!(command_for_key(&state, &key, BindingDispatch::Prefix).is_some()); assert_eq!( - indexed_navigation_action(&state, key, BindingDispatch::Prefix), + indexed_navigation_action(&state, &key, BindingDispatch::Prefix), Some(NavigateAction::SwitchWorkspace(0)) ); } diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs index 3e99d0fc5f..f0e10d9c9a 100644 --- a/src/app/input/terminal.rs +++ b/src/app/input/terminal.rs @@ -41,7 +41,7 @@ impl App { source_id: InputSourceId, key: TerminalKey, ) -> Option { - match self.prepare_popup_key_forward(key) { + match self.prepare_popup_key_forward(key.clone()) { PreparedPopupInput::NotOpen => {} PreparedPopupInput::Consumed => return None, PreparedPopupInput::Bytes { target, bytes } => { @@ -66,7 +66,7 @@ impl App { key: TerminalKey, ) -> Option { let key_event = key.as_key_event(); - if self.try_copy_retained_selection(source_id, key) { + if self.try_copy_retained_selection(source_id, key.clone()) { return None; } @@ -74,7 +74,8 @@ impl App { self.selection_autoscroll_deadline = None; self.state.update_dismissed = true; - if let Some(action) = super::terminal_direct_non_indexed_navigation_action(&self.state, key) + if let Some(action) = + super::terminal_direct_non_indexed_navigation_action(&self.state, &key) { debug!( code = ?key_event.code, @@ -93,7 +94,7 @@ impl App { if let Some(binding) = super::navigate::command_for_key( &self.state, - key, + &key, super::navigate::BindingDispatch::Direct, ) { debug!( @@ -107,7 +108,7 @@ impl App { return None; } - if let Some(action) = super::terminal_direct_indexed_navigation_action(&self.state, key) { + if let Some(action) = super::terminal_direct_indexed_navigation_action(&self.state, &key) { debug!( code = ?key_event.code, modifiers = ?key_event.modifiers, @@ -119,7 +120,7 @@ impl App { return None; } - if self.state.is_prefix_key(key) { + if self.state.is_prefix_key(&key) { self.state.mode = Mode::Prefix; return None; } @@ -189,7 +190,7 @@ impl App { rt.scroll_reset(); let protocol = rt.keyboard_protocol(); - let bytes = rt.encode_terminal_key(key); + let bytes = rt.encode_terminal_key(key.clone()); if matches!(key_event.code, KeyCode::Esc) || key_event @@ -251,7 +252,7 @@ impl App { return PreparedPopupInput::Consumed; }; rt.scroll_reset(); - let bytes = rt.encode_terminal_key(key); + let bytes = rt.encode_terminal_key(key.clone()); self.state.mode = Mode::Terminal; if bytes.is_empty() { PreparedPopupInput::Consumed @@ -316,7 +317,7 @@ impl App { let Some(runtime) = self.terminal_input_runtime(target) else { return false; }; - let bytes = runtime.encode_terminal_key(key); + let bytes = runtime.encode_terminal_key(key.clone()); bytes.is_empty() || runtime.try_send_bytes(Bytes::from(bytes)).is_ok() } @@ -328,25 +329,24 @@ impl App { let Some(runtime) = self.terminal_input_runtime(target) else { return false; }; - let bytes = runtime.encode_terminal_key(key); + let bytes = runtime.encode_terminal_key(key.clone()); bytes.is_empty() || runtime.send_bytes(Bytes::from(bytes)).await.is_ok() } fn take_pressed_keys_for_source( &mut self, source_id: crate::app::InputSourceId, - ) -> Vec { - let pressed = self - .pressed_terminal_keys - .iter() - .filter(|((id, _), _)| *id == source_id) - .map(|(_, pressed)| pressed.clone()) - .collect(); - self.pressed_terminal_keys - .retain(|(id, _), _| *id != source_id); - self.suppressed_repeat_keys - .retain(|(id, _)| *id != source_id); - pressed + ) -> Vec { + self.input_leases.remove_source(source_id) + } + + pub(crate) fn release_input_target_headless(&mut self, target: &TerminalInputTarget) { + for pressed in self.input_leases.remove_target(target) { + let release = pressed + .key + .with_kind(crossterm::event::KeyEventKind::Release); + let _ = self.forward_terminal_key_to_target_headless(&pressed.target, release); + } } pub(crate) fn release_input_source_headless(&mut self, source_id: crate::app::InputSourceId) { @@ -375,7 +375,7 @@ impl App { &mut self, key: TerminalKey, ) -> Option { - match self.prepare_popup_key_forward(key) { + match self.prepare_popup_key_forward(key.clone()) { PreparedPopupInput::NotOpen => {} PreparedPopupInput::Consumed => return None, PreparedPopupInput::Bytes { target, bytes } => { @@ -1579,65 +1579,155 @@ mod tests { assert!(rx.try_recv().is_err()); } - #[tokio::test] - async fn page_up_scrolls_plain_shell_pane() { + fn app_with_plain_scrollback( + line_count: usize, + ) -> (App, crate::layout::PaneId, crate::layout::PaneInfo) { let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); - let info = pane_infos[0].clone(); - ws.tabs[0].runtimes.insert( + let mut workspace = Workspace::test_new("test"); + let pane_id = workspace.tabs[0].root_pane; + let pane_infos = workspace.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); + let pane_info = pane_infos[0].clone(); + workspace.tabs[0].runtimes.insert( pane_id, crate::terminal::TerminalRuntime::test_with_scrollback_bytes( - info.inner_rect.width, - info.inner_rect.height, + pane_info.inner_rect.width, + pane_info.inner_rect.height, 16 * 1024, - &numbered_lines_bytes(64), + &numbered_lines_bytes(line_count), ), ); - - app.state.workspaces = vec![ws]; + app.state.workspaces = vec![workspace]; app.state.active = Some(0); app.state.selected = 0; app.state.mode = Mode::Terminal; app.state.view.pane_infos = pane_infos; + (app, pane_id, pane_info) + } - let start_metrics = app - .state + fn pane_scroll_offset(app: &App, pane_id: crate::layout::PaneId) -> usize { + app.state .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) .and_then(crate::terminal::TerminalRuntime::scroll_metrics) - .expect("initial scroll metrics"); - assert_eq!(start_metrics.offset_from_bottom, 0); + .expect("pane scroll metrics") + .offset_from_bottom + } + + fn physical_page_up(repeat_count: u16) -> TerminalKey { + TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty()).with_windows_record( + crate::input::WindowsKeyRecord { + key_down: true, + repeat_count, + virtual_key_code: 0x21, + virtual_scan_code: 0x49, + unicode: 0, + control_key_state: 0x0100, + }, + ) + } + + #[tokio::test] + async fn page_up_scrolls_plain_shell_pane() { + let (mut app, pane_id, pane_info) = app_with_plain_scrollback(64); + assert_eq!(pane_scroll_offset(&app, pane_id), 0); app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())); - let end_metrics = app - .state - .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) - .and_then(crate::terminal::TerminalRuntime::scroll_metrics) - .expect("scroll metrics after PageUp"); assert_eq!( - end_metrics.offset_from_bottom, - info.inner_rect.height as usize + pane_scroll_offset(&app, pane_id), + pane_info.inner_rect.height as usize ); } #[tokio::test] - async fn page_down_returns_to_bottom_after_page_up() { + async fn consumed_page_up_preserves_separate_and_grouped_repeats() { + let (mut app, pane_id, pane_info) = app_with_plain_scrollback(256); + let page_up = physical_page_up(1); + app.route_client_events( + vec![ + crate::raw_input::RawInputEvent::Key(page_up.clone()), + crate::raw_input::RawInputEvent::Key( + page_up.clone().with_kind(KeyEventKind::Repeat), + ), + crate::raw_input::RawInputEvent::Key( + page_up.clone().with_kind(KeyEventKind::Release), + ), + ], + false, + ); + + assert_eq!( + pane_scroll_offset(&app, pane_id), + pane_info.inner_rect.height as usize * 2 + ); + + app.route_client_events( + vec![crate::raw_input::RawInputEvent::Key(physical_page_up(3))], + false, + ); + + assert_eq!( + pane_scroll_offset(&app, pane_id), + pane_info.inner_rect.height as usize * 5 + ); + } + + #[tokio::test] + async fn runtime_consumed_page_up_preserves_separate_and_grouped_repeats() { + let (mut app, pane_id, pane_info) = app_with_plain_scrollback(256); + let page_up = physical_page_up(1); + for key in [ + page_up.clone(), + page_up.clone().with_kind(KeyEventKind::Repeat), + page_up.clone().with_kind(KeyEventKind::Release), + ] { + app.handle_raw_input_event(crate::raw_input::RawInputEvent::Key(key)) + .await; + } + + assert_eq!( + pane_scroll_offset(&app, pane_id), + pane_info.inner_rect.height as usize * 2 + ); + + app.handle_raw_input_event(crate::raw_input::RawInputEvent::Key(physical_page_up(3))) + .await; + + assert_eq!( + pane_scroll_offset(&app, pane_id), + pane_info.inner_rect.height as usize * 5 + ); + } + + #[tokio::test] + async fn consumed_repeat_that_becomes_forwarded_acquires_pane_ownership() { let mut app = app_for_mouse_test(); let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; + let first_pane = ws.tabs[0].root_pane; + let second_pane = ws.test_split(ratatui::layout::Direction::Horizontal); let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); - let info = pane_infos[0].clone(); + let first_info = pane_infos + .iter() + .find(|info| info.id == first_pane) + .expect("first pane info"); ws.tabs[0].runtimes.insert( - pane_id, + first_pane, crate::terminal::TerminalRuntime::test_with_scrollback_bytes( - info.inner_rect.width, - info.inner_rect.height, + first_info.inner_rect.width, + first_info.inner_rect.height, 16 * 1024, - &numbered_lines_bytes(64), + &numbered_lines_bytes(128), ), ); + let (second_runtime, mut second_rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 80, + 24, + 0, + b"\x1b[?1h\x1b[>15u", + 3, + ); + ws.tabs[0].runtimes.insert(second_pane, second_runtime); + ws.tabs[0].layout.focus_pane(first_pane); app.state.workspaces = vec![ws]; app.state.active = Some(0); @@ -1645,107 +1735,153 @@ mod tests { app.state.mode = Mode::Terminal; app.state.view.pane_infos = pane_infos; - app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())); - let after_up = app - .state - .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) - .and_then(crate::terminal::TerminalRuntime::scroll_metrics) - .expect("scroll metrics after PageUp"); - assert!(after_up.offset_from_bottom > 0); + let page_up = physical_page_up(1); + app.route_client_events( + vec![crate::raw_input::RawInputEvent::Key(page_up.clone())], + false, + ); + assert!(app.state.focus_pane_in_workspace(0, second_pane)); + app.route_client_events( + vec![crate::raw_input::RawInputEvent::Key( + page_up.clone().with_kind(KeyEventKind::Repeat), + )], + false, + ); + assert!(app.state.focus_pane_in_workspace(0, first_pane)); + app.route_client_events( + vec![ + crate::raw_input::RawInputEvent::Key( + page_up.clone().with_kind(KeyEventKind::Repeat), + ), + crate::raw_input::RawInputEvent::Key(page_up.with_kind(KeyEventKind::Release)), + ], + false, + ); - app.handle_terminal_key_headless(TerminalKey::new( - KeyCode::PageDown, - KeyModifiers::empty(), - )); - let after_down = app - .state - .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) - .and_then(crate::terminal::TerminalRuntime::scroll_metrics) - .expect("scroll metrics after PageDown"); - assert_eq!(after_down.offset_from_bottom, 0); + let first_repeat = second_rx.try_recv().expect("first forwarded repeat"); + let second_repeat = second_rx.try_recv().expect("owned repeat"); + let release = second_rx.try_recv().expect("owned release"); + assert_eq!(first_repeat, second_repeat); + assert_ne!(second_repeat, release); + assert!(second_rx.try_recv().is_err()); + assert!(app.input_leases.is_empty()); } #[tokio::test] - async fn page_up_release_does_not_scroll_plain_shell_pane_again() { + async fn missing_popup_runtime_suppresses_grouped_and_later_repeats() { let mut app = app_for_mouse_test(); let mut ws = Workspace::test_new("test"); let pane_id = ws.tabs[0].root_pane; - let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); - let info = pane_infos[0].clone(); - ws.tabs[0].runtimes.insert( - pane_id, - crate::terminal::TerminalRuntime::test_with_scrollback_bytes( - info.inner_rect.width, - info.inner_rect.height, - 16 * 1024, - &numbered_lines_bytes(64), - ), - ); - + let (runtime, mut input_rx) = crate::terminal::TerminalRuntime::test_with_channel(80, 24); + ws.tabs[0].runtimes.insert(pane_id, runtime); app.state.workspaces = vec![ws]; app.state.active = Some(0); app.state.selected = 0; app.state.mode = Mode::Terminal; - app.state.view.pane_infos = pane_infos; - app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())); - let after_press = app + let (popup_runtime, _popup_rx) = + crate::terminal::TerminalRuntime::test_with_channel(40, 12); + app.install_test_popup_runtime(popup_runtime); + let popup_terminal_id = app .state - .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) - .and_then(crate::terminal::TerminalRuntime::scroll_metrics) - .expect("scroll metrics after PageUp press"); - assert_eq!( - after_press.offset_from_bottom, - info.inner_rect.height as usize + .popup_pane + .as_ref() + .expect("popup installed") + .terminal_id + .clone(); + app.terminal_runtimes.remove(&popup_terminal_id); + + let key = TerminalKey::new(KeyCode::Char('x'), KeyModifiers::empty()).with_repeat_count(3); + app.route_client_events( + vec![ + crate::raw_input::RawInputEvent::Key(key.clone()), + crate::raw_input::RawInputEvent::Key( + key.clone() + .with_kind(KeyEventKind::Repeat) + .with_repeat_count(1), + ), + crate::raw_input::RawInputEvent::Key(key.with_kind(KeyEventKind::Release)), + ], + false, ); + assert!(app.state.popup_pane.is_none()); + assert!(input_rx.try_recv().is_err()); + assert!(app.input_leases.is_empty()); + } + + #[tokio::test] + async fn consumed_repeat_stays_suppressed_after_context_returns() { + let (mut app, pane_id, _pane_info) = app_with_plain_scrollback(128); + let page_up = physical_page_up(1); + app.route_client_events( + vec![crate::raw_input::RawInputEvent::Key(page_up.clone())], + false, + ); + let after_press = pane_scroll_offset(&app, pane_id); + + let (popup_runtime, mut popup_rx) = + crate::terminal::TerminalRuntime::test_with_channel(40, 12); + app.install_test_popup_runtime(popup_runtime); + app.route_client_events( + vec![crate::raw_input::RawInputEvent::Key( + page_up.clone().with_kind(KeyEventKind::Repeat), + )], + false, + ); + app.close_popup_pane(); + app.route_client_events( + vec![ + crate::raw_input::RawInputEvent::Key( + page_up.clone().with_kind(KeyEventKind::Repeat), + ), + crate::raw_input::RawInputEvent::Key(page_up.with_kind(KeyEventKind::Release)), + ], + false, + ); + + assert_eq!(pane_scroll_offset(&app, pane_id), after_press); + assert!(popup_rx.try_recv().is_err()); + assert!(app.input_leases.is_empty()); + } + + #[tokio::test] + async fn page_down_returns_to_bottom_after_page_up() { + let (mut app, pane_id, _pane_info) = app_with_plain_scrollback(64); + + app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())); + assert!(pane_scroll_offset(&app, pane_id) > 0); + + app.handle_terminal_key_headless(TerminalKey::new( + KeyCode::PageDown, + KeyModifiers::empty(), + )); + assert_eq!(pane_scroll_offset(&app, pane_id), 0); + } + + #[tokio::test] + async fn page_up_release_does_not_scroll_plain_shell_pane_again() { + let (mut app, pane_id, pane_info) = app_with_plain_scrollback(64); + + app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())); + let after_press = pane_scroll_offset(&app, pane_id); + assert_eq!(after_press, pane_info.inner_rect.height as usize); + app.handle_terminal_key_headless( TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty()) .with_kind(KeyEventKind::Release), ); - let after_release = app - .state - .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) - .and_then(crate::terminal::TerminalRuntime::scroll_metrics) - .expect("scroll metrics after PageUp release"); - assert_eq!( - after_release.offset_from_bottom, - after_press.offset_from_bottom - ); + assert_eq!(pane_scroll_offset(&app, pane_id), after_press); } #[tokio::test] async fn modified_page_up_does_not_host_scroll_plain_shell_pane() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); - let info = pane_infos[0].clone(); - ws.tabs[0].runtimes.insert( - pane_id, - crate::terminal::TerminalRuntime::test_with_scrollback_bytes( - info.inner_rect.width, - info.inner_rect.height, - 16 * 1024, - &numbered_lines_bytes(64), - ), - ); - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.view.pane_infos = pane_infos; + let (mut app, pane_id, _pane_info) = app_with_plain_scrollback(64); app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::CONTROL)); - let metrics = app - .state - .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) - .and_then(crate::terminal::TerminalRuntime::scroll_metrics) - .expect("scroll metrics after modified PageUp"); - assert_eq!(metrics.offset_from_bottom, 0); + assert_eq!(pane_scroll_offset(&app, pane_id), 0); } #[tokio::test] diff --git a/src/app/mod.rs b/src/app/mod.rs index 442df5286c..2a996685ca 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -136,9 +136,7 @@ pub struct App { pub(crate) detached_custom_command_children: Vec, pub(crate) persist_pane_history: bool, pub(crate) last_render_at: Option, - pub(crate) pressed_terminal_keys: - HashMap<(InputSourceId, crossterm::event::KeyCode), PressedTerminalKey>, - pub(crate) suppressed_repeat_keys: HashSet<(InputSourceId, crossterm::event::KeyCode)>, + pub(crate) input_leases: input::InputLeaseTable, pub render_notify: Arc, pub(crate) render_dirty: Arc, pub(crate) full_redraw_pending: bool, @@ -205,21 +203,14 @@ pub(crate) struct TerminalInputTarget { } #[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct PressedTerminalKey { - target: TerminalInputTarget, - key: crate::input::TerminalKey, +pub(crate) enum TerminalInputContext { + Pane, + Popup(crate::terminal::TerminalId), } pub(crate) type InputSourceId = u64; const LOCAL_INPUT_SOURCE: InputSourceId = 0; -fn pressed_key_identity( - source_id: InputSourceId, - key: &crate::input::TerminalKey, -) -> (InputSourceId, crossterm::event::KeyCode) { - (source_id, key.code) -} - fn auto_updates_enabled(no_session: bool) -> bool { !no_session && !cfg!(debug_assertions) } @@ -765,8 +756,7 @@ impl App { selection_highlight_clear_deadline: None, persist_pane_history: config.experimental.pane_history, last_render_at: None, - pressed_terminal_keys: HashMap::new(), - suppressed_repeat_keys: HashSet::new(), + input_leases: input::InputLeaseTable::default(), api_rx, event_hub, last_focus, @@ -1587,6 +1577,73 @@ impl App { // --------------------------------------------------------------------------- impl App { + pub(crate) fn terminal_input_context(&self) -> Option { + if let Some(popup) = &self.state.popup_pane { + Some(TerminalInputContext::Popup(popup.terminal_id.clone())) + } else if self.state.mode == Mode::Terminal { + Some(TerminalInputContext::Pane) + } else { + None + } + } + + fn execute_repeat_plan_headless( + &mut self, + source_id: InputSourceId, + lease_key: input::InputLeaseKey, + key: crate::input::TerminalKey, + plan: input::RepeatPlan, + ) { + match plan { + input::RepeatPlan::Forwarded(target) => { + if !self.forward_terminal_key_to_target_headless(&target, key) { + self.input_leases.remove(&lease_key); + } + } + input::RepeatPlan::Reprocess { + context, + repetitions, + tracked, + } => { + let key = key + .with_kind(crossterm::event::KeyEventKind::Repeat) + .with_repeat_count(1); + let mut forwarded_target = None; + for _ in 0..repetitions { + if let Some(target) = &forwarded_target { + if !self.forward_terminal_key_to_target_headless(target, key.clone()) { + self.input_leases.remove(&lease_key); + break; + } + continue; + } + let current_context = self.terminal_input_context(); + if !self.input_leases.reprocess_allowed( + lease_key, + &context, + current_context.as_ref(), + tracked, + ) { + break; + } + if let Some(target) = + self.handle_terminal_key_headless_from(source_id, key.clone()) + { + if tracked { + self.input_leases.insert_forwarded( + lease_key, + target.clone(), + key.clone(), + ); + forwarded_target = Some(target); + } + } + } + } + input::RepeatPlan::Ignore => {} + } + } + /// Routes raw input bytes from a client through the existing input pipeline. /// /// The input bytes are parsed into `RawInputEvent`s and then processed. @@ -1618,57 +1675,47 @@ impl App { let previous_mode = self.state.mode; match event { crate::raw_input::RawInputEvent::Key(key) => { - let pressed_key_id = pressed_key_identity(source_id, &key); + let lease_key = input::InputLeaseKey::new(source_id, &key); + let key = self.input_leases.normalize_press(&lease_key, key); match key.kind { crossterm::event::KeyEventKind::Press => { - if self.state.popup_pane.is_some() || self.state.mode == Mode::Terminal - { - self.suppressed_repeat_keys.remove(&pressed_key_id); - if let Some(target) = - self.handle_terminal_key_headless_from(source_id, key) - { - if !key.is_text_commit { - self.pressed_terminal_keys.insert( - pressed_key_id, - PressedTerminalKey { target, key }, - ); - } - } else { - self.pressed_terminal_keys.remove(&pressed_key_id); - } + let initial_context = self.terminal_input_context(); + let target = if initial_context.is_some() { + self.handle_terminal_key_headless_from(source_id, key.clone()) } else { - self.pressed_terminal_keys.remove(&pressed_key_id); - self.suppressed_repeat_keys.insert(pressed_key_id); - self.handle_non_terminal_key_headless(key); - } + self.handle_non_terminal_key_headless(key.clone()); + None + }; + let resulting_context = self.terminal_input_context(); + let plan = self.input_leases.complete_press( + lease_key, + &key, + initial_context.as_ref(), + resulting_context.as_ref(), + target, + ); + self.execute_repeat_plan_headless(source_id, lease_key, key, plan); } crossterm::event::KeyEventKind::Repeat => { - if let Some(pressed) = - self.pressed_terminal_keys.get(&pressed_key_id).cloned() - { - if !self - .forward_terminal_key_to_target_headless(&pressed.target, key) - { - self.pressed_terminal_keys.remove(&pressed_key_id); - } - } else if (self.state.popup_pane.is_some() - || self.state.mode == Mode::Terminal) - && !self.suppressed_repeat_keys.contains(&pressed_key_id) - { - let _ = self.handle_terminal_key_headless_from(source_id, key); - } + let current_context = self.terminal_input_context(); + let plan = self.input_leases.plan_repeat( + lease_key, + &key, + current_context.as_ref(), + ); + self.execute_repeat_plan_headless(source_id, lease_key, key, plan); } crossterm::event::KeyEventKind::Release => { - self.suppressed_repeat_keys.remove(&pressed_key_id); - if let Some(pressed) = - self.pressed_terminal_keys.remove(&pressed_key_id) - { + if let Some(lease) = self.input_leases.remove_forwarded(&lease_key) { let _ = self - .forward_terminal_key_to_target_headless(&pressed.target, key); + .forward_terminal_key_to_target_headless(&lease.target, key); } } } } + crate::raw_input::RawInputEvent::Text(text) => { + self.handle_text_commit_headless(text.as_str()); + } crate::raw_input::RawInputEvent::Mouse(mouse) => { if self.state.popup_pane.is_some() || self.state.mouse_capture { self.handle_mouse_event_headless(source_id, mouse); @@ -1838,6 +1885,94 @@ mod tests { ) } + #[cfg(windows)] + #[tokio::test] + async fn native_repeats_and_releases_follow_the_pressed_pane() { + let record = crate::input::WindowsKeyRecord { + key_down: true, + repeat_count: 1, + virtual_key_code: 27, + virtual_scan_code: 1, + unicode: 27, + control_key_state: 0, + }; + let key = crate::input::TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()) + .with_windows_record(record); + let enhanced = crate::input::TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()) + .with_windows_record(crate::input::WindowsKeyRecord { + control_key_state: 0x0100, + ..record + }); + assert_ne!( + input::InputLeaseKey::new(7, &key), + input::InputLeaseKey::new(7, &enhanced) + ); + + let mut app = test_app(); + let mut workspace = Workspace::test_new("test"); + let pressed_pane = workspace.focused_pane_id().unwrap(); + let other_pane = workspace.test_split(ratatui::layout::Direction::Horizontal); + workspace.tabs[0].layout.focus_pane(pressed_pane); + let (pressed_runtime, mut pressed_rx) = + TerminalRuntime::test_with_channel_capacity(80, 24, 6); + let (other_runtime, mut other_rx) = TerminalRuntime::test_with_channel_capacity(80, 24, 6); + workspace.tabs[0] + .runtimes + .insert(pressed_pane, pressed_runtime); + workspace.tabs[0].runtimes.insert(other_pane, other_runtime); + app.state.workspaces = vec![workspace]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + + let release = crate::input::TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()) + .with_windows_record(crate::input::WindowsKeyRecord { + key_down: false, + repeat_count: 1, + unicode: 0, + ..record + }) + .with_kind(KeyEventKind::Release); + app.route_client_events( + vec![crate::raw_input::RawInputEvent::Key(key.clone())], + false, + ); + assert!(app.state.focus_pane_in_workspace(0, other_pane)); + app.route_client_events( + vec![ + crate::raw_input::RawInputEvent::Key(key.clone()), + crate::raw_input::RawInputEvent::Key(key.clone().with_kind(KeyEventKind::Repeat)), + crate::raw_input::RawInputEvent::Key(release), + crate::raw_input::RawInputEvent::Key(key.clone()), + crate::raw_input::RawInputEvent::OuterFocusLost, + ], + false, + ); + + for _ in 0..3 { + assert_eq!( + pressed_rx.try_recv().unwrap(), + bytes::Bytes::from_static(b"\x1b[27;1;27;1;0;1_") + ); + } + assert_eq!( + pressed_rx.try_recv().unwrap(), + bytes::Bytes::from_static(b"\x1b[27;1;0;0;0;1_") + ); + assert!(pressed_rx.try_recv().is_err()); + + assert_eq!( + other_rx.try_recv().unwrap(), + bytes::Bytes::from_static(b"\x1b[27;1;27;1;0;1_") + ); + assert_eq!( + other_rx.try_recv().unwrap(), + bytes::Bytes::from_static(b"\x1b[27;1;27;0;0;1_") + ); + assert!(other_rx.try_recv().is_err()); + assert!(app.input_leases.is_empty()); + } + fn release_notes_state() -> state::ReleaseNotesState { state::ReleaseNotesState { version: "0.1.0".into(), @@ -4876,6 +5011,27 @@ mod tests { ); } + #[test] + fn explicit_text_commit_does_not_trigger_navigate_binding() { + let mut app = test_app(); + app.state.workspaces = vec![Workspace::test_new("test")]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.detach_exits = false; + app.state.mode = Mode::Navigate; + + app.route_client_events( + vec![crate::raw_input::RawInputEvent::Text( + crate::input::TextCommit::new("q"), + )], + false, + ); + + assert!(!app.state.detach_requested); + assert_eq!(app.state.mode, Mode::Navigate); + assert!(app.input_leases.is_empty()); + } + #[test] fn route_client_input_q_detaches_in_persistence_mode() { let mut app = test_app(); @@ -5104,6 +5260,31 @@ last_pane = "prefix+tab" assert!(rx.try_recv().is_err()); } + #[tokio::test] + async fn explicit_text_commit_bypasses_bindings_leases_and_key_encoding() { + let mut app = test_app(); + let mut workspace = Workspace::test_new("test"); + let focused = workspace.focused_pane_id().unwrap(); + let (runtime, mut rx) = + TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 2); + workspace.tabs[0].runtimes.insert(focused, runtime); + app.state.workspaces = vec![workspace]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + + app.route_client_events( + vec![crate::raw_input::RawInputEvent::Text( + crate::input::TextCommit::new("你🙂"), + )], + false, + ); + + assert_eq!(rx.recv().await.unwrap().as_ref(), "你🙂".as_bytes()); + assert!(rx.try_recv().is_err()); + assert!(app.input_leases.is_empty()); + } + #[tokio::test] async fn committed_ime_text_bypasses_report_all_key_encoding() { let mut app = test_app(); @@ -5123,7 +5304,7 @@ last_pane = "prefix+tab" rx.recv().await.unwrap(), bytes::Bytes::from_static("你".as_bytes()) ); - assert!(app.pressed_terminal_keys.is_empty()); + assert!(app.input_leases.is_empty()); } #[tokio::test] @@ -5142,7 +5323,7 @@ last_pane = "prefix+tab" app.route_client_input(b"A".to_vec()); assert_eq!(rx.recv().await.unwrap(), bytes::Bytes::from_static(b"A")); - assert!(app.pressed_terminal_keys.is_empty()); + assert!(app.input_leases.is_empty()); } #[tokio::test] @@ -5174,7 +5355,7 @@ last_pane = "prefix+tab" rx.try_recv().expect("physical release"), bytes::Bytes::from_static(b"\x1b[106;1:3u") ); - assert!(app.pressed_terminal_keys.is_empty()); + assert!(app.input_leases.is_empty()); } #[tokio::test] @@ -5210,7 +5391,7 @@ last_pane = "prefix+tab" rx.try_recv().expect("synthetic release on focus loss"), bytes::Bytes::from_static(b"\x1b[106;1:3u") ); - assert!(app.pressed_terminal_keys.is_empty()); + assert!(app.input_leases.is_empty()); } #[tokio::test] @@ -5245,7 +5426,155 @@ last_pane = "prefix+tab" rx.try_recv().expect("synthetic release on disconnect"), bytes::Bytes::from_static(b"\x1b[106;1:3u") ); - assert!(app.pressed_terminal_keys.is_empty()); + assert!(app.input_leases.is_empty()); + } + + #[tokio::test] + async fn physical_count_one_repeats_and_release_keep_the_pressed_pane() { + let mut app = test_app(); + let mut workspace = Workspace::test_new("test"); + let pressed_pane = workspace.focused_pane_id().unwrap(); + let other_pane = workspace.test_split(ratatui::layout::Direction::Horizontal); + workspace.tabs[0].layout.focus_pane(pressed_pane); + let (pressed_runtime, mut pressed_rx) = + TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 5); + let (other_runtime, mut other_rx) = + TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 2); + workspace.tabs[0] + .runtimes + .insert(pressed_pane, pressed_runtime); + workspace.tabs[0].runtimes.insert(other_pane, other_runtime); + app.state.workspaces = vec![workspace]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + + let record = crate::input::WindowsKeyRecord { + key_down: true, + repeat_count: 1, + virtual_key_code: 65, + virtual_scan_code: 30, + unicode: 97, + control_key_state: 0, + }; + let roundtrip = |events: Vec| { + let message = crate::protocol::ClientMessage::InputEvents { events }; + let encoded = + bincode::serde::encode_to_vec(&message, bincode::config::standard()).unwrap(); + let (decoded, _): (crate::protocol::ClientMessage, _) = + bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); + let crate::protocol::ClientMessage::InputEvents { events } = decoded else { + panic!("expected structured input events"); + }; + events + .into_iter() + .map(|event| event.to_raw_input_event()) + .collect() + }; + let event = |kind, record| crate::protocol::ClientInputEvent::Key { + code: crate::protocol::ClientKeyCode::Char('a'), + modifiers: 0, + kind, + repeat_count: 1, + generated_text: None, + source: crate::protocol::ClientKeySource::WindowsConsole { record }, + }; + + app.route_client_events( + roundtrip(vec![event(crate::protocol::ClientKeyKind::Press, record)]), + false, + ); + assert!(app.state.focus_pane_in_workspace(0, other_pane)); + app.route_client_events( + roundtrip(vec![ + event(crate::protocol::ClientKeyKind::Press, record), + event(crate::protocol::ClientKeyKind::Repeat, record), + event( + crate::protocol::ClientKeyKind::Release, + crate::input::WindowsKeyRecord { + key_down: false, + unicode: 0, + ..record + }, + ), + ]), + false, + ); + + for expected in [ + b"\x1b[97;1:1u".as_slice(), + b"\x1b[97;1:2u".as_slice(), + b"\x1b[97;1:2u".as_slice(), + b"\x1b[97;1:3u".as_slice(), + ] { + assert_eq!(pressed_rx.try_recv().unwrap().as_ref(), expected); + } + assert!(pressed_rx.try_recv().is_err()); + assert!(other_rx.try_recv().is_err()); + assert!(app.input_leases.is_empty()); + } + + #[tokio::test] + async fn grouped_physical_press_and_runtime_loss_preserve_count_then_close_the_lease() { + let mut app = test_app(); + let workspace = Workspace::test_new("test"); + let pane_id = workspace.focused_pane_id().unwrap(); + let terminal_id = workspace.tabs[0].terminal_id(pane_id).unwrap().clone(); + app.state.workspaces = vec![workspace]; + app.state.ensure_test_terminals(); + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + let (runtime, mut rx) = + TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 2); + app.terminal_runtimes.insert(terminal_id.clone(), runtime); + + let record = crate::input::WindowsKeyRecord { + key_down: true, + repeat_count: 3, + virtual_key_code: 65, + virtual_scan_code: 30, + unicode: 97, + control_key_state: 0, + }; + let message = crate::protocol::ClientMessage::InputEvents { + events: vec![crate::protocol::ClientInputEvent::Key { + code: crate::protocol::ClientKeyCode::Char('a'), + modifiers: 0, + kind: crate::protocol::ClientKeyKind::Press, + repeat_count: 3, + generated_text: None, + source: crate::protocol::ClientKeySource::WindowsConsole { record }, + }], + }; + let encoded = bincode::serde::encode_to_vec(&message, bincode::config::standard()).unwrap(); + let (decoded, _): (crate::protocol::ClientMessage, _) = + bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); + let crate::protocol::ClientMessage::InputEvents { events } = decoded else { + panic!("expected structured input events"); + }; + + app.route_client_events( + events + .into_iter() + .map(|event| event.to_raw_input_event()) + .collect(), + false, + ); + app.shutdown_terminal_runtime(terminal_id.clone()); + + assert_eq!( + rx.try_recv().expect("grouped press"), + bytes::Bytes::from_static(b"\x1b[97;1:1u\x1b[97;1:2u\x1b[97;1:2u") + ); + assert_eq!( + rx.try_recv() + .expect("synthetic release before runtime shutdown"), + bytes::Bytes::from_static(b"\x1b[97;1:3u") + ); + assert!(rx.try_recv().is_err()); + assert!(app.input_leases.is_empty()); + assert!(app.terminal_runtimes.get(&terminal_id).is_none()); } #[tokio::test] diff --git a/src/app/popup.rs b/src/app/popup.rs index 3356ab1469..f3d2e459a6 100644 --- a/src/app/popup.rs +++ b/src/app/popup.rs @@ -26,9 +26,7 @@ impl App { .direct_attach_resize_locks .remove(&popup.terminal_id); self.state.terminals.remove(&popup.terminal_id); - if let Some(runtime) = self.terminal_runtimes.remove(&popup.terminal_id) { - runtime.shutdown(); - } + self.shutdown_terminal_runtime(popup.terminal_id); self.state.mode = if self.state.active.is_some() { Mode::Terminal } else { diff --git a/src/app/runtime.rs b/src/app/runtime.rs index ef0a452d2f..ef0d122485 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -6,8 +6,8 @@ use std::time::Duration; use crossterm::terminal; use super::{ - background_update_check_enabled, pressed_key_identity, App, AUTO_UPDATE_CHECK_INTERVAL, - MIN_RENDER_INTERVAL, RESIZE_POLL_INTERVAL, SELECTION_AUTOSCROLL_INTERVAL, + background_update_check_enabled, App, AUTO_UPDATE_CHECK_INTERVAL, MIN_RENDER_INTERVAL, + RESIZE_POLL_INTERVAL, SELECTION_AUTOSCROLL_INTERVAL, }; fn retain_custom_command_after_wait( pid: u32, @@ -30,12 +30,20 @@ impl App { .retain_mut(|child| retain_custom_command_after_wait(child.id(), child.try_wait())); } + pub(crate) fn shutdown_terminal_runtime(&mut self, terminal_id: crate::terminal::TerminalId) { + let target = super::TerminalInputTarget { + terminal_id: terminal_id.clone(), + }; + self.release_input_target_headless(&target); + if let Some(runtime) = self.terminal_runtimes.remove(&terminal_id) { + runtime.shutdown(); + } + } + pub(crate) fn shutdown_detached_terminal_runtimes(&mut self) { let terminal_ids = std::mem::take(&mut self.state.terminal_runtime_shutdowns); for terminal_id in terminal_ids { - if let Some(runtime) = self.terminal_runtimes.remove(&terminal_id) { - runtime.shutdown(); - } + self.shutdown_terminal_runtime(terminal_id); } } @@ -103,6 +111,65 @@ impl App { changed } + async fn execute_repeat_plan( + &mut self, + lease_key: super::input::InputLeaseKey, + key: crate::input::TerminalKey, + plan: super::input::RepeatPlan, + ) -> bool { + match plan { + super::input::RepeatPlan::Forwarded(target) => { + if !self.forward_terminal_key_to_target(&target, key).await { + self.input_leases.remove(&lease_key); + } + true + } + super::input::RepeatPlan::Reprocess { + context, + repetitions, + tracked, + } => { + let key = key + .with_kind(crossterm::event::KeyEventKind::Repeat) + .with_repeat_count(1); + let mut forwarded_target = None; + for _ in 0..repetitions { + if let Some(target) = &forwarded_target { + if !self + .forward_terminal_key_to_target(target, key.clone()) + .await + { + self.input_leases.remove(&lease_key); + break; + } + continue; + } + let current_context = self.terminal_input_context(); + if !self.input_leases.reprocess_allowed( + lease_key, + &context, + current_context.as_ref(), + tracked, + ) { + break; + } + if let Some(target) = self.handle_key(key.clone()).await { + if tracked { + self.input_leases.insert_forwarded( + lease_key, + target.clone(), + key.clone(), + ); + forwarded_target = Some(target); + } + } + } + true + } + super::input::RepeatPlan::Ignore => false, + } + } + pub(super) async fn handle_raw_input_event( &mut self, event: crate::raw_input::RawInputEvent, @@ -110,60 +177,46 @@ impl App { let previous_mode = self.state.mode; let changed = match event { crate::raw_input::RawInputEvent::Key(key) => { - let pressed_key_id = pressed_key_identity(super::LOCAL_INPUT_SOURCE, &key); + let lease_key = super::input::InputLeaseKey::new(super::LOCAL_INPUT_SOURCE, &key); + let key = self.input_leases.normalize_press(&lease_key, key); match key.kind { crossterm::event::KeyEventKind::Press => { - if self.state.popup_pane.is_some() - || self.state.mode == crate::app::Mode::Terminal - { - self.suppressed_repeat_keys.remove(&pressed_key_id); - } else { - self.suppressed_repeat_keys.insert(pressed_key_id); - } - if let Some(target) = self.handle_key(key).await { - if !key.is_text_commit { - self.pressed_terminal_keys.insert( - pressed_key_id, - super::PressedTerminalKey { target, key }, - ); - } - } else { - self.pressed_terminal_keys.remove(&pressed_key_id); - } + let initial_context = self.terminal_input_context(); + let target = self.handle_key(key.clone()).await; + let resulting_context = self.terminal_input_context(); + let plan = self.input_leases.complete_press( + lease_key, + &key, + initial_context.as_ref(), + resulting_context.as_ref(), + target, + ); + self.execute_repeat_plan(lease_key, key, plan).await; true } crossterm::event::KeyEventKind::Repeat => { - if let Some(pressed) = - self.pressed_terminal_keys.get(&pressed_key_id).cloned() - { - if !self - .forward_terminal_key_to_target(&pressed.target, key) - .await - { - self.pressed_terminal_keys.remove(&pressed_key_id); - } - true - } else if (self.state.popup_pane.is_some() - || self.state.mode == crate::app::Mode::Terminal) - && !self.suppressed_repeat_keys.contains(&pressed_key_id) - { - self.handle_key(key).await; - true - } else { - false - } + let current_context = self.terminal_input_context(); + let plan = self.input_leases.plan_repeat( + lease_key, + &key, + current_context.as_ref(), + ); + self.execute_repeat_plan(lease_key, key, plan).await } crossterm::event::KeyEventKind::Release => { - self.suppressed_repeat_keys.remove(&pressed_key_id); - if let Some(pressed) = self.pressed_terminal_keys.remove(&pressed_key_id) { + if let Some(lease) = self.input_leases.remove_forwarded(&lease_key) { let _ = self - .forward_terminal_key_to_target(&pressed.target, key) + .forward_terminal_key_to_target(&lease.target, key) .await; } false } } } + crate::raw_input::RawInputEvent::Text(text) => { + self.handle_text_commit(text.into_string()).await; + true + } crate::raw_input::RawInputEvent::Paste(text) => { self.handle_paste(text).await; true diff --git a/src/app/state.rs b/src/app/state.rs index 602bd9a4f1..ef903f65ce 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -1645,7 +1645,7 @@ impl AppState { || self.focused_pane_requests_mouse_capture_from(terminal_runtimes) } - pub fn is_prefix_key(&self, key: crate::input::TerminalKey) -> bool { + pub fn is_prefix_key(&self, key: &crate::input::TerminalKey) -> bool { crate::config::terminal_key_matches_combo(key, (self.prefix_code, self.prefix_mods)) } @@ -1742,7 +1742,7 @@ pub fn key_matches( expected_mods: KeyModifiers, ) -> bool { crate::config::terminal_key_matches_combo( - crate::input::TerminalKey::from(*key), + &crate::input::TerminalKey::from(*key), (expected_code, expected_mods), ) } diff --git a/src/app/worktrees.rs b/src/app/worktrees.rs index 23e17b4d58..0087c8fe94 100644 --- a/src/app/worktrees.rs +++ b/src/app/worktrees.rs @@ -997,14 +997,12 @@ impl App { ws_idx: usize, ) { for terminal_id in self.state.terminal_ids_for_workspace(ws_idx) { - if let Some(runtime) = self.terminal_runtimes.remove(&terminal_id) { - tracing::debug!( - workspace_index = ws_idx, - terminal_id = %terminal_id, - "shutting down terminal runtime before worktree removal" - ); - runtime.shutdown(); - } + tracing::debug!( + workspace_index = ws_idx, + terminal_id = %terminal_id, + "shutting down terminal runtime before worktree removal" + ); + self.shutdown_terminal_runtime(terminal_id); } } } diff --git a/src/client/input.rs b/src/client/input.rs index 28d38bda17..09e0eb92ea 100644 --- a/src/client/input.rs +++ b/src/client/input.rs @@ -281,7 +281,16 @@ fn windows_crossterm_input_event( code: crate::protocol::ClientKeyCode::Char(codepoint), modifiers: 0, kind: crate::protocol::ClientKeyKind::Press, - } => Some(crate::protocol::ClientInputEvent::Text { codepoint }), + source, + .. + } => Some(crate::protocol::ClientInputEvent::Key { + code: crate::protocol::ClientKeyCode::Char(codepoint), + modifiers: 0, + kind: crate::protocol::ClientKeyKind::Press, + repeat_count: 1, + generated_text: Some(codepoint.to_string()), + source, + }), event => Some(event), } } @@ -368,20 +377,29 @@ fn windows_client_input_event_from_raw( event: crate::raw_input::RawInputEvent, ) -> Option { match event { - crate::raw_input::RawInputEvent::Key(key) if key.is_text_commit => { - let crossterm::event::KeyCode::Char(codepoint) = key.code else { - return None; - }; - Some(crate::protocol::ClientInputEvent::Text { codepoint }) - } + crate::raw_input::RawInputEvent::Text(text) => Some( + crate::protocol::ClientInputEvent::TextCommit(text.into_string()), + ), crate::raw_input::RawInputEvent::Key(key) => { let code = crate::protocol::ClientKeyCode::from_crossterm(key.code)?; let modifiers = key.modifiers.bits(); let kind = crate::protocol::ClientKeyKind::from_crossterm(key.kind); + let source = if let Some(bytes) = key.vt_bytes() { + crate::protocol::ClientKeySource::Vt { + bytes: bytes.to_vec(), + } + } else if let Some(record) = key.windows_record() { + crate::protocol::ClientKeySource::WindowsConsole { record } + } else { + crate::protocol::ClientKeySource::Synthesized + }; Some(crate::protocol::ClientInputEvent::Key { code, modifiers, kind, + repeat_count: key.repeat_count, + generated_text: key.generated_text.clone(), + source, }) } crate::raw_input::RawInputEvent::Mouse(mouse) => { @@ -573,12 +591,19 @@ mod windows_tests { } #[test] - fn windows_crossterm_printable_press_is_text() { + fn windows_crossterm_printable_press_keeps_key_semantics_and_text() { let event = Event::Key(KeyEvent::new(KeyCode::Char('你'), KeyModifiers::empty())); assert_eq!( windows_crossterm_input_event(event), - Some(crate::protocol::ClientInputEvent::Text { codepoint: '你' }) + Some(crate::protocol::ClientInputEvent::Key { + code: crate::protocol::ClientKeyCode::Char('你'), + modifiers: 0, + kind: crate::protocol::ClientKeyKind::Press, + repeat_count: 1, + generated_text: Some("你".to_string()), + source: crate::protocol::ClientKeySource::Synthesized, + }) ); } @@ -673,6 +698,9 @@ mod windows_tests { code: crate::protocol::ClientKeyCode::Char('d'), modifiers: KeyModifiers::CONTROL.bits(), kind: crate::protocol::ClientKeyKind::Press, + repeat_count: 1, + generated_text: None, + source: crate::protocol::ClientKeySource::Vt { bytes: vec![4] }, } ); } @@ -693,6 +721,11 @@ mod windows_tests { code: crate::protocol::ClientKeyCode::Up, modifiers: 0, kind: crate::protocol::ClientKeyKind::Press, + repeat_count: 1, + generated_text: None, + source: crate::protocol::ClientKeySource::Vt { + bytes: b"\x1b[A".to_vec() + }, } ); } @@ -712,6 +745,9 @@ mod windows_tests { code: crate::protocol::ClientKeyCode::Esc, modifiers: 0, kind: crate::protocol::ClientKeyKind::Press, + repeat_count: 1, + generated_text: None, + source: crate::protocol::ClientKeySource::Vt { bytes: vec![0x1b] }, } ); } diff --git a/src/client/input/windows_vti.rs b/src/client/input/windows_vti.rs index 8966112e0d..c95cbc43f7 100644 --- a/src/client/input/windows_vti.rs +++ b/src/client/input/windows_vti.rs @@ -9,6 +9,7 @@ use tokio::sync::mpsc; use super::windows_client_input_event_from_raw; #[cfg(windows)] use super::ClientLoopEvent; +use crate::input::WindowsKeyRecord; #[cfg(windows)] pub(super) fn raw_console_reader_loop( @@ -164,16 +165,6 @@ enum WindowsInputRecord { Focus(bool), } -#[derive(Clone, Copy, Debug)] -struct WindowsKeyRecord { - key_down: bool, - repeat_count: u16, - virtual_key_code: u16, - virtual_scan_code: u16, - unicode: u16, - control_key_state: u32, -} - #[derive(Clone, Copy, Debug)] struct WindowsMouseRecord { x: u16, @@ -502,6 +493,10 @@ impl WindowsInputMapper { return Some(vec![0x1b]); } + if record.virtual_scan_code != 0 { + return None; + } + if !record.key_down || record.repeat_count.max(1) != 1 || windows_key_modifiers(record.control_key_state).bits() != 0 @@ -550,6 +545,10 @@ impl WindowsInputMapper { code, modifiers: modifiers.bits(), kind, + repeat_count: 1, + + generated_text: None, + source: crate::protocol::ClientKeySource::Synthesized, }, )) } @@ -574,6 +573,35 @@ impl WindowsInputMapper { } let is_alt_code = Self::is_alt_code(key); + let carries_native_record = key.repeat_count != 0 + && key.virtual_key_code != 0 + && key.virtual_key_code != 0x03 + && !matches!(key.virtual_key_code, 0xe5 | 0xe7) + && !is_alt_code + && !(0xd800..=0xdfff).contains(&key.unicode); + if carries_native_record { + return self + .translate_semantic_key_event(key, Self::semantic_key_kind(key, is_alt_code, 0)) + .map(|event| match event { + crate::protocol::ClientInputEvent::Key { + code, + modifiers, + kind, + .. + } => crate::protocol::ClientInputEvent::Key { + code, + modifiers, + kind, + repeat_count: key.repeat_count.max(1), + generated_text: None, + source: crate::protocol::ClientKeySource::WindowsConsole { record: key }, + }, + event => event, + }) + .into_iter() + .collect(); + } + (0..key.repeat_count.max(1)) .filter_map(|repeat_idx| { self.translate_semantic_key_event( @@ -625,7 +653,9 @@ impl WindowsInputMapper { if key.virtual_key_code == 0 { let codepoint = self.utf16_unit_to_char(key.unicode)?; if !codepoint.is_control() { - return Some(crate::protocol::ClientInputEvent::Text { codepoint }); + return Some(crate::protocol::ClientInputEvent::TextCommit( + codepoint.to_string(), + )); } } if modifiers.contains(crossterm::event::KeyModifiers::CONTROL) @@ -637,6 +667,10 @@ impl WindowsInputMapper { code: crate::protocol::ClientKeyCode::Char('j'), modifiers: modifiers.bits(), kind, + repeat_count: 1, + + generated_text: None, + source: crate::protocol::ClientKeySource::Synthesized, }); } @@ -656,6 +690,10 @@ impl WindowsInputMapper { code, modifiers: modifiers.bits(), kind, + repeat_count: 1, + + generated_text: None, + source: crate::protocol::ClientKeySource::Synthesized, }); } } @@ -667,10 +705,20 @@ impl WindowsInputMapper { }) }; - code.map(|code| crate::protocol::ClientInputEvent::Key { - code, - modifiers: modifiers.bits(), - kind, + code.map(|code| { + let generated_text = matches!(code, crate::protocol::ClientKeyCode::Char(_)) + .then(|| char::from_u32(key.unicode as u32)) + .flatten() + .filter(|ch| !ch.is_control()) + .map(|ch| ch.to_string()); + crate::protocol::ClientInputEvent::Key { + code, + modifiers: modifiers.bits(), + kind, + repeat_count: 1, + generated_text, + source: crate::protocol::ClientKeySource::Synthesized, + } }) } @@ -1063,7 +1111,7 @@ mod tests { key_down: true, repeat_count, virtual_key_code: vk, - virtual_scan_code: 0, + virtual_scan_code: 1, unicode: 0, control_key_state: 0, }) @@ -1114,6 +1162,38 @@ mod tests { fn translate( records: impl IntoIterator, + ) -> Vec { + semantic_only(translate_with_provenance(records)) + } + + fn semantic_only( + events: impl IntoIterator, + ) -> Vec { + events + .into_iter() + .map(|event| match event { + crate::protocol::ClientInputEvent::Key { + code, + modifiers, + kind, + repeat_count, + generated_text, + .. + } => crate::protocol::ClientInputEvent::Key { + code, + modifiers, + kind, + repeat_count, + generated_text, + source: crate::protocol::ClientKeySource::Synthesized, + }, + event => event, + }) + .collect() + } + + fn translate_with_provenance( + records: impl IntoIterator, ) -> Vec { let mut translator = WindowsInputTranslator::default(); records @@ -1237,10 +1317,20 @@ mod tests { control_key_state, }); - assert_eq!( - translate(records), - vec![crate::protocol::ClientInputEvent::Text { codepoint: '你' }] - ); + let events = translate(records); + match events.as_slice() { + [crate::protocol::ClientInputEvent::TextCommit(text)] => { + assert_eq!(text, "你"); + } + [crate::protocol::ClientInputEvent::Key { + code: crate::protocol::ClientKeyCode::Char('你'), + repeat_count: 1, + generated_text: Some(text), + source: crate::protocol::ClientKeySource::Synthesized, + .. + }] => assert_eq!(text, "你"), + other => panic!("unexpected VK=0 result: {other:?}"), + } } } @@ -1292,6 +1382,10 @@ mod tests { code: crate::protocol::ClientKeyCode::Char('c'), modifiers: crossterm::event::KeyModifiers::CONTROL.bits(), kind: crate::protocol::ClientKeyKind::Press, + + repeat_count: 1, + generated_text: None, + source: crate::protocol::ClientKeySource::Synthesized, }] ); } @@ -1304,6 +1398,10 @@ mod tests { code: crate::protocol::ClientKeyCode::Char('j'), modifiers: crossterm::event::KeyModifiers::CONTROL.bits(), kind: crate::protocol::ClientKeyKind::Press, + + repeat_count: 1, + generated_text: None, + source: crate::protocol::ClientKeySource::Synthesized, }] ); } @@ -1344,6 +1442,10 @@ mod tests { code: crate::protocol::ClientKeyCode::Char(expected), modifiers: crossterm::event::KeyModifiers::CONTROL.bits(), kind: crate::protocol::ClientKeyKind::Press, + + repeat_count: 1, + generated_text: None, + source: crate::protocol::ClientKeySource::Synthesized, }], "vk={vk:#x} unicode={unicode:#x}" ); @@ -1362,6 +1464,10 @@ mod tests { code: crate::protocol::ClientKeyCode::Esc, modifiers: 0, kind: crate::protocol::ClientKeyKind::Press, + + repeat_count: 1, + generated_text: None, + source: crate::protocol::ClientKeySource::Vt { bytes: vec![0x1b] }, }] ); } @@ -1378,47 +1484,86 @@ mod tests { code: crate::protocol::ClientKeyCode::Esc, modifiers: 0, kind: crate::protocol::ClientKeyKind::Press, + + repeat_count: 1, + generated_text: None, + source: crate::protocol::ClientKeySource::Vt { bytes: vec![0x1b] }, }] ); } #[test] fn vti_physical_escape_key_record_is_immediately_semantic() { - let mut translator = WindowsInputTranslator::default(); + let record = WindowsKeyRecord { + key_down: true, + repeat_count: 1, + virtual_key_code: 0x1b, + virtual_scan_code: 0x01, + unicode: 0x1b, + control_key_state: 0, + }; assert_eq!( - translator.translate(key_vk_with_scan_unicode(0x1b, 0x01, '\x1b', 0)), + translate_with_provenance([WindowsInputRecord::Key(record)]), vec![crate::protocol::ClientInputEvent::Key { code: crate::protocol::ClientKeyCode::Esc, modifiers: 0, kind: crate::protocol::ClientKeyKind::Press, + repeat_count: 1, + generated_text: None, + source: crate::protocol::ClientKeySource::WindowsConsole { record }, }] ); } #[test] - fn vti_repeated_escape_record_stays_semantic() { + fn vti_grouped_escape_down_and_up_keep_native_ownership_record() { + use crate::protocol::ClientKeyKind::{Press, Release}; + + let events = translate_with_provenance([ + key_vk_with_repeat(0x1b, 3), + key_vk_up_with_scan_unicode(0x1b, 1, '\0', 0), + ]); + assert_eq!( - translate([key_vk_with_repeat(0x1b, 3)]), - vec![ - crate::protocol::ClientInputEvent::Key { - code: crate::protocol::ClientKeyCode::Esc, - modifiers: 0, - kind: crate::protocol::ClientKeyKind::Press, - }, - crate::protocol::ClientInputEvent::Key { - code: crate::protocol::ClientKeyCode::Esc, - modifiers: 0, - kind: crate::protocol::ClientKeyKind::Repeat, - }, - crate::protocol::ClientInputEvent::Key { - code: crate::protocol::ClientKeyCode::Esc, - modifiers: 0, - kind: crate::protocol::ClientKeyKind::Repeat, - }, - ] + events + .iter() + .map(|event| match event { + crate::protocol::ClientInputEvent::Key { + code: crate::protocol::ClientKeyCode::Esc, + kind, + repeat_count, + source: crate::protocol::ClientKeySource::WindowsConsole { record }, + .. + } => (*kind, *repeat_count, record.key_down, record.repeat_count), + event => panic!("unexpected grouped event: {event:?}"), + }) + .collect::>(), + [(Press, 3, true, 3), (Release, 1, false, 1)] ); } + #[test] + fn vti_ctrl_break_record_keeps_semantic_path() { + assert!(matches!( + translate_with_provenance([key_vk(0x03, 0x0008)]).as_slice(), + [crate::protocol::ClientInputEvent::Key { .. }] + )); + } + + #[test] + fn vti_ime_virtual_keys_keep_semantic_path() { + for vk in [0xe5, 0xe7] { + assert!(matches!( + translate_with_provenance([key_vk_with_unicode(vk, 'é', 0)]).as_slice(), + [crate::protocol::ClientInputEvent::Key { + generated_text: Some(text), + source: crate::protocol::ClientKeySource::Synthesized, + .. + }] if text == "é" + )); + } + } + #[test] fn vti_modified_escape_remains_semantic() { assert_eq!( @@ -1427,6 +1572,10 @@ mod tests { code: crate::protocol::ClientKeyCode::Esc, modifiers: crossterm::event::KeyModifiers::SHIFT.bits(), kind: crate::protocol::ClientKeyKind::Press, + + repeat_count: 1, + generated_text: None, + source: crate::protocol::ClientKeySource::Synthesized, }] ); } @@ -1445,6 +1594,10 @@ mod tests { code: crate::protocol::ClientKeyCode::Enter, modifiers: 0, kind: crate::protocol::ClientKeyKind::Press, + + repeat_count: 1, + generated_text: None, + source: crate::protocol::ClientKeySource::Synthesized, }] ); } @@ -1453,23 +1606,14 @@ mod tests { fn vti_repeated_virtual_key_records_emit_repeats() { assert_eq!( translate([key_vk_with_repeat(0x08, 3)]), - vec![ - crate::protocol::ClientInputEvent::Key { - code: crate::protocol::ClientKeyCode::Backspace, - modifiers: 0, - kind: crate::protocol::ClientKeyKind::Press, - }, - crate::protocol::ClientInputEvent::Key { - code: crate::protocol::ClientKeyCode::Backspace, - modifiers: 0, - kind: crate::protocol::ClientKeyKind::Repeat, - }, - crate::protocol::ClientInputEvent::Key { - code: crate::protocol::ClientKeyCode::Backspace, - modifiers: 0, - kind: crate::protocol::ClientKeyKind::Repeat, - }, - ] + vec![crate::protocol::ClientInputEvent::Key { + code: crate::protocol::ClientKeyCode::Backspace, + modifiers: 0, + kind: crate::protocol::ClientKeyKind::Press, + repeat_count: 3, + generated_text: None, + source: crate::protocol::ClientKeySource::Synthesized, + }] ); } @@ -1481,6 +1625,10 @@ mod tests { code: crate::protocol::ClientKeyCode::Enter, modifiers: crossterm::event::KeyModifiers::SHIFT.bits(), kind: crate::protocol::ClientKeyKind::Press, + + repeat_count: 1, + generated_text: None, + source: crate::protocol::ClientKeySource::Synthesized, }] ); } @@ -1493,6 +1641,10 @@ mod tests { code: crate::protocol::ClientKeyCode::Char('j'), modifiers: crossterm::event::KeyModifiers::CONTROL.bits(), kind: crate::protocol::ClientKeyKind::Release, + + repeat_count: 1, + generated_text: None, + source: crate::protocol::ClientKeySource::Synthesized, }] ); } @@ -1505,6 +1657,10 @@ mod tests { code: crate::protocol::ClientKeyCode::Enter, modifiers: crossterm::event::KeyModifiers::SHIFT.bits(), kind: crate::protocol::ClientKeyKind::Press, + + repeat_count: 1, + generated_text: None, + source: crate::protocol::ClientKeySource::Synthesized, }] ); } @@ -1518,16 +1674,28 @@ mod tests { code: crate::protocol::ClientKeyCode::Enter, modifiers: crossterm::event::KeyModifiers::SHIFT.bits(), kind: crate::protocol::ClientKeyKind::Press, + + repeat_count: 1, + generated_text: None, + source: crate::protocol::ClientKeySource::Synthesized, }, crate::protocol::ClientInputEvent::Key { code: crate::protocol::ClientKeyCode::Enter, modifiers: crossterm::event::KeyModifiers::SHIFT.bits(), kind: crate::protocol::ClientKeyKind::Repeat, + + repeat_count: 1, + generated_text: None, + source: crate::protocol::ClientKeySource::Synthesized, }, crate::protocol::ClientInputEvent::Key { code: crate::protocol::ClientKeyCode::Enter, modifiers: crossterm::event::KeyModifiers::SHIFT.bits(), kind: crate::protocol::ClientKeyKind::Repeat, + + repeat_count: 1, + generated_text: None, + source: crate::protocol::ClientKeySource::Synthesized, }, ] ); @@ -1589,11 +1757,19 @@ mod tests { code: crate::protocol::ClientKeyCode::Enter, modifiers: crossterm::event::KeyModifiers::SHIFT.bits(), kind: crate::protocol::ClientKeyKind::Press, + + repeat_count: 1, + generated_text: None, + source: crate::protocol::ClientKeySource::Synthesized, }, crate::protocol::ClientInputEvent::Key { code: crate::protocol::ClientKeyCode::Enter, modifiers: crossterm::event::KeyModifiers::SHIFT.bits(), kind: crate::protocol::ClientKeyKind::Release, + + repeat_count: 1, + generated_text: None, + source: crate::protocol::ClientKeySource::Synthesized, }, ] ); @@ -1612,11 +1788,19 @@ mod tests { code: crate::protocol::ClientKeyCode::Enter, modifiers: 0, kind: crate::protocol::ClientKeyKind::Press, + + repeat_count: 1, + generated_text: None, + source: crate::protocol::ClientKeySource::Synthesized, }, crate::protocol::ClientInputEvent::Key { code: crate::protocol::ClientKeyCode::Enter, modifiers: 0, kind: crate::protocol::ClientKeyKind::Release, + + repeat_count: 1, + generated_text: None, + source: crate::protocol::ClientKeySource::Synthesized, }, ] ); @@ -1633,11 +1817,19 @@ mod tests { code: crate::protocol::ClientKeyCode::Backspace, modifiers: 0, kind: crate::protocol::ClientKeyKind::Press, + + repeat_count: 1, + generated_text: None, + source: crate::protocol::ClientKeySource::Synthesized, }, crate::protocol::ClientInputEvent::Key { code: crate::protocol::ClientKeyCode::Backspace, modifiers: 0, kind: crate::protocol::ClientKeyKind::Release, + + repeat_count: 1, + generated_text: None, + source: crate::protocol::ClientKeySource::Synthesized, }, ] ); @@ -1656,11 +1848,19 @@ mod tests { code: crate::protocol::ClientKeyCode::Char('j'), modifiers: crossterm::event::KeyModifiers::CONTROL.bits(), kind: crate::protocol::ClientKeyKind::Press, + + repeat_count: 1, + generated_text: None, + source: crate::protocol::ClientKeySource::Synthesized, }, crate::protocol::ClientInputEvent::Key { code: crate::protocol::ClientKeyCode::Char('j'), modifiers: crossterm::event::KeyModifiers::CONTROL.bits(), kind: crate::protocol::ClientKeyKind::Release, + + repeat_count: 1, + generated_text: None, + source: crate::protocol::ClientKeySource::Synthesized, }, ] ); @@ -1678,11 +1878,19 @@ mod tests { code: crate::protocol::ClientKeyCode::Char('j'), modifiers: crossterm::event::KeyModifiers::CONTROL.bits(), kind: crate::protocol::ClientKeyKind::Press, + + repeat_count: 1, + generated_text: None, + source: crate::protocol::ClientKeySource::Synthesized, }, crate::protocol::ClientInputEvent::Key { code: crate::protocol::ClientKeyCode::Char('j'), modifiers: crossterm::event::KeyModifiers::CONTROL.bits(), kind: crate::protocol::ClientKeyKind::Release, + + repeat_count: 1, + generated_text: None, + source: crate::protocol::ClientKeySource::Synthesized, }, ] ); @@ -1700,33 +1908,121 @@ mod tests { code: crate::protocol::ClientKeyCode::Enter, modifiers: crossterm::event::KeyModifiers::CONTROL.bits(), kind: crate::protocol::ClientKeyKind::Press, + + repeat_count: 1, + generated_text: None, + source: crate::protocol::ClientKeySource::Synthesized, }, crate::protocol::ClientInputEvent::Key { code: crate::protocol::ClientKeyCode::Enter, modifiers: crossterm::event::KeyModifiers::CONTROL.bits(), kind: crate::protocol::ClientKeyKind::Release, + + repeat_count: 1, + generated_text: None, + source: crate::protocol::ClientKeySource::Synthesized, }, ] ); } #[test] - fn vti_win32_input_mode_printable_key_becomes_char() { - let records = "\x1b[65;30;97;1;0;1_\x1b[65;30;97;0;0;1_" - .chars() - .map(key_char); + fn vti_special_key_does_not_emit_incidental_printable_text() { + assert_eq!( + translate_with_provenance([WindowsInputRecord::Key(WindowsKeyRecord { + key_down: true, + repeat_count: 0, + virtual_key_code: 0x0d, + virtual_scan_code: 0, + unicode: b'a'.into(), + control_key_state: 0, + })]), + vec![crate::protocol::ClientInputEvent::Key { + code: crate::protocol::ClientKeyCode::Enter, + modifiers: 0, + kind: crate::protocol::ClientKeyKind::Press, + repeat_count: 1, + generated_text: None, + source: crate::protocol::ClientKeySource::Synthesized, + }] + ); + } + #[test] + fn vti_win32_input_mode_printable_keys_preserve_physical_records() { assert_eq!( - translate(records), - vec![ - crate::protocol::ClientInputEvent::Text { codepoint: 'a' }, - crate::protocol::ClientInputEvent::Key { - code: crate::protocol::ClientKeyCode::Char('a'), - modifiers: 0, - kind: crate::protocol::ClientKeyKind::Release, - }, - ] + translate(win32_input_mode_encoded_record(WindowsKeyRecord { + key_down: true, + repeat_count: 1, + virtual_key_code: 0, + virtual_scan_code: 0, + unicode: b'a'.into(), + control_key_state: 0, + })), + vec![crate::protocol::ClientInputEvent::Key { + code: crate::protocol::ClientKeyCode::Char('a'), + modifiers: 0, + kind: crate::protocol::ClientKeyKind::Press, + repeat_count: 1, + generated_text: Some("a".into()), + source: crate::protocol::ClientKeySource::Synthesized, + }] ); + + use crate::protocol::ClientKeyKind::{Press, Release}; + for repeat_count in [1, 3] { + let mut records = win32_input_mode_encoded_record(WindowsKeyRecord { + key_down: true, + repeat_count, + virtual_key_code: 0x41, + virtual_scan_code: 30, + unicode: b'a'.into(), + control_key_state: 0, + }); + records.extend(win32_input_mode_encoded_record(WindowsKeyRecord { + key_down: false, + repeat_count: 1, + virtual_key_code: 0x41, + virtual_scan_code: 30, + unicode: b'a'.into(), + control_key_state: 0, + })); + + assert_eq!( + translate_with_provenance(records) + .iter() + .map(|event| match event { + crate::protocol::ClientInputEvent::Key { + code: crate::protocol::ClientKeyCode::Char('a'), + modifiers: 0, + kind, + repeat_count: event_repeat_count, + generated_text: None, + source: + crate::protocol::ClientKeySource::WindowsConsole { + record: + WindowsKeyRecord { + repeat_count: record_repeat_count, + virtual_key_code: 0x41, + virtual_scan_code: 30, + unicode, + control_key_state: 0, + key_down, + }, + }, + } if *unicode == u16::from(b'a') => { + (*kind, *event_repeat_count, *key_down, *record_repeat_count) + } + event => panic!("unexpected printable key event: {event:?}"), + }) + .collect::>(), + [ + (Press, repeat_count, true, repeat_count), + (Release, 1, false, 1) + ], + "repeat_count={repeat_count}" + ); + } } #[test] @@ -1817,20 +2113,33 @@ mod tests { code: crate::protocol::ClientKeyCode::Esc, modifiers: 0, kind: crate::protocol::ClientKeyKind::Press, + repeat_count: 1, + generated_text: None, + source: crate::protocol::ClientKeySource::WindowsConsole { + record: WindowsKeyRecord { + key_down: true, + repeat_count: 1, + virtual_key_code: 0x1b, + virtual_scan_code: 0x02, + unicode: 0, + control_key_state: 0, + } + }, }] ); } #[test] fn vti_win32_input_mode_physical_escape_is_immediately_semantic() { - let records = win32_input_mode_encoded_record(WindowsKeyRecord { + let record = WindowsKeyRecord { key_down: true, repeat_count: 1, virtual_key_code: 0x1b, virtual_scan_code: 0x01, unicode: 0x1b, control_key_state: 0, - }); + }; + let records = win32_input_mode_encoded_record(record); let mut translator = WindowsInputTranslator::default(); assert_eq!( @@ -1842,6 +2151,9 @@ mod tests { code: crate::protocol::ClientKeyCode::Esc, modifiers: 0, kind: crate::protocol::ClientKeyKind::Press, + repeat_count: 1, + generated_text: None, + source: crate::protocol::ClientKeySource::WindowsConsole { record }, }] ); } @@ -1868,6 +2180,10 @@ mod tests { code: crate::protocol::ClientKeyCode::Up, modifiers: 0, kind: crate::protocol::ClientKeyKind::Press, + + repeat_count: 1, + generated_text: None, + source: crate::protocol::ClientKeySource::Synthesized, }] ); } @@ -1882,6 +2198,10 @@ mod tests { code: crate::protocol::ClientKeyCode::Esc, modifiers: 0, kind: crate::protocol::ClientKeyKind::Press, + + repeat_count: 1, + generated_text: None, + source: crate::protocol::ClientKeySource::Vt { bytes: vec![0x1b] }, }] ); } @@ -1897,11 +2217,19 @@ mod tests { code: crate::protocol::ClientKeyCode::Esc, modifiers: 0, kind: crate::protocol::ClientKeyKind::Press, + + repeat_count: 1, + generated_text: None, + source: crate::protocol::ClientKeySource::Vt { bytes: vec![0x1b] }, }, crate::protocol::ClientInputEvent::Key { code: crate::protocol::ClientKeyCode::Enter, modifiers: crossterm::event::KeyModifiers::SHIFT.bits(), kind: crate::protocol::ClientKeyKind::Press, + + repeat_count: 1, + generated_text: None, + source: crate::protocol::ClientKeySource::Synthesized, }, ] ); @@ -1912,17 +2240,25 @@ mod tests { let mut translator = WindowsInputTranslator::default(); assert!(translator.translate(key_char('\x1b')).is_empty()); assert_eq!( - translator.translate(key_vk(0x26, 0)), + semantic_only(translator.translate(key_vk(0x26, 0))), vec![ crate::protocol::ClientInputEvent::Key { code: crate::protocol::ClientKeyCode::Esc, modifiers: 0, kind: crate::protocol::ClientKeyKind::Press, + + repeat_count: 1, + generated_text: None, + source: crate::protocol::ClientKeySource::Synthesized, }, crate::protocol::ClientInputEvent::Key { code: crate::protocol::ClientKeyCode::Up, modifiers: 0, kind: crate::protocol::ClientKeyKind::Press, + + repeat_count: 1, + generated_text: None, + source: crate::protocol::ClientKeySource::Synthesized, }, ] ); @@ -1936,6 +2272,10 @@ mod tests { code: crate::protocol::ClientKeyCode::Char('c'), modifiers: crossterm::event::KeyModifiers::CONTROL.bits(), kind: crate::protocol::ClientKeyKind::Press, + + repeat_count: 1, + generated_text: None, + source: crate::protocol::ClientKeySource::Synthesized, }] ); } @@ -1948,6 +2288,10 @@ mod tests { code: crate::protocol::ClientKeyCode::Up, modifiers: crossterm::event::KeyModifiers::ALT.bits(), kind: crate::protocol::ClientKeyKind::Press, + + repeat_count: 1, + generated_text: None, + source: crate::protocol::ClientKeySource::Synthesized, }] ); } @@ -1960,6 +2304,10 @@ mod tests { code: crate::protocol::ClientKeyCode::Char('@'), modifiers: 0, kind: crate::protocol::ClientKeyKind::Press, + + repeat_count: 1, + generated_text: None, + source: crate::protocol::ClientKeySource::Synthesized, }] ); } @@ -1972,6 +2320,9 @@ mod tests { code: crate::protocol::ClientKeyCode::Char('é'), modifiers: 0, kind: crate::protocol::ClientKeyKind::Press, + repeat_count: 1, + generated_text: Some("é".into()), + source: crate::protocol::ClientKeySource::Synthesized, }] ); } @@ -1989,6 +2340,10 @@ mod tests { code: crate::protocol::ClientKeyCode::Char('🙂'), modifiers: 0, kind: crate::protocol::ClientKeyKind::Press, + + repeat_count: 1, + generated_text: None, + source: crate::protocol::ClientKeySource::Synthesized, }] ); } diff --git a/src/client/mod.rs b/src/client/mod.rs index 52fd6dd698..d7fcdf710e 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -1841,7 +1841,7 @@ fn should_bridge_clipboard_image_paste( events.as_slice(), [crate::raw_input::RawInputEvent::Key(key)] if key.kind == crossterm::event::KeyEventKind::Press - && crate::config::terminal_key_matches_combo(*key, remote_image_paste_key) + && crate::config::terminal_key_matches_combo(key, remote_image_paste_key) ) } diff --git a/src/config/keybinds.rs b/src/config/keybinds.rs index 02046ee312..199076f03c 100644 --- a/src/config/keybinds.rs +++ b/src/config/keybinds.rs @@ -158,7 +158,7 @@ impl ResolvedBinding { key_event_matches_combo(key, self.trigger.combo()) } - fn matches_terminal_key(&self, key: TerminalKey) -> bool { + fn matches_terminal_key(&self, key: &TerminalKey) -> bool { terminal_key_matches_combo(key, self.trigger.combo()) } } @@ -207,13 +207,13 @@ impl ActionKeybinds { .any(|binding| binding.trigger.is_prefix() && binding.matches_key_event(key)) } - pub fn matches_prefix_key(&self, key: TerminalKey) -> bool { + pub fn matches_prefix_key(&self, key: &TerminalKey) -> bool { self.bindings .iter() .any(|binding| binding.trigger.is_prefix() && binding.matches_terminal_key(key)) } - pub fn matches_direct_key(&self, key: TerminalKey) -> bool { + pub fn matches_direct_key(&self, key: &TerminalKey) -> bool { self.bindings .iter() .any(|binding| binding.trigger.is_direct() && binding.matches_terminal_key(key)) @@ -263,7 +263,7 @@ pub struct IndexedKeybind { } impl IndexedKeybind { - pub fn matched_index(&self, key: TerminalKey) -> Option { + pub fn matched_index(&self, key: &TerminalKey) -> Option { let combo = self.trigger.combo(); let (expected_code, _) = normalize_key_combo(combo); let KeyCode::Char(key_number @ '1'..='9') = expected_code else { @@ -1303,7 +1303,7 @@ pub fn key_event_matches_combo(key: &KeyEvent, combo: KeyCombo) -> bool { key_parts_match_combo(key.code, key.modifiers, None, combo) } -pub fn terminal_key_matches_combo(key: TerminalKey, combo: KeyCombo) -> bool { +pub fn terminal_key_matches_combo(key: &TerminalKey, combo: KeyCombo) -> bool { key_parts_match_combo(key.code, key.modifiers, key.shifted_codepoint, combo) } @@ -1404,7 +1404,7 @@ fn shifted_number_symbol(ch: char) -> Option { .find_map(|(number, symbol)| (*symbol == ch).then_some(*number)) } -fn indexed_shifted_number_matches(key: TerminalKey, combo: KeyCombo, number: char) -> bool { +fn indexed_shifted_number_matches(key: &TerminalKey, combo: KeyCombo, number: char) -> bool { let (expected_code, expected_modifiers) = normalize_key_combo(combo); matches!(expected_code, KeyCode::Char(expected) if expected == number) && expected_modifiers.contains(KeyModifiers::SHIFT) @@ -1650,7 +1650,7 @@ close_tab = "X" for ch in ['ğ', 'ç', 'ş', 'ı', 'é', 'ø'] { let bindings = ActionKeybinds::prefix(&ch.to_string()); assert!(bindings - .matches_prefix_key(TerminalKey::new(KeyCode::Char(ch), KeyModifiers::empty(),))); + .matches_prefix_key(&TerminalKey::new(KeyCode::Char(ch), KeyModifiers::empty(),))); } } @@ -1660,7 +1660,7 @@ close_tab = "X" { let bindings = ActionKeybinds::prefix(&format!("shift+{base}")); assert!(bindings.matches_prefix_key( - TerminalKey::new(KeyCode::Char(base), KeyModifiers::SHIFT) + &TerminalKey::new(KeyCode::Char(base), KeyModifiers::SHIFT) .with_shifted_codepoint(shifted as u32) )); } @@ -1676,20 +1676,20 @@ close_tab = "X" fn shifted_letter_binding_matches_legacy_uppercase_key_event() { let bindings = ActionKeybinds::prefix("shift+n"); assert!(bindings - .matches_prefix_key(TerminalKey::new(KeyCode::Char('N'), KeyModifiers::empty(),))); + .matches_prefix_key(&TerminalKey::new(KeyCode::Char('N'), KeyModifiers::empty(),))); } #[test] fn shifted_letter_direct_binding_matches_legacy_uppercase_key_event() { let bindings = ActionKeybinds::direct("shift+n"); assert!(bindings - .matches_direct_key(TerminalKey::new(KeyCode::Char('N'), KeyModifiers::empty(),))); + .matches_direct_key(&TerminalKey::new(KeyCode::Char('N'), KeyModifiers::empty(),))); } #[test] fn shifted_letter_binding_matches_modern_modified_key_event() { let bindings = ActionKeybinds::direct("cmd+shift+j"); - assert!(bindings.matches_direct_key(TerminalKey::new( + assert!(bindings.matches_direct_key(&TerminalKey::new( KeyCode::Char('J'), KeyModifiers::SUPER | KeyModifiers::SHIFT, ))); @@ -1699,32 +1699,32 @@ close_tab = "X" fn legacy_uppercase_key_event_does_not_match_unshifted_letter_binding() { let bindings = ActionKeybinds::prefix("n"); assert!(!bindings - .matches_prefix_key(TerminalKey::new(KeyCode::Char('N'), KeyModifiers::empty(),))); + .matches_prefix_key(&TerminalKey::new(KeyCode::Char('N'), KeyModifiers::empty(),))); } #[test] fn legacy_uppercase_shift_fallback_is_limited_to_ascii_letters() { let shifted_number = ActionKeybinds::prefix("shift+1"); assert!(!shifted_number - .matches_prefix_key(TerminalKey::new(KeyCode::Char('!'), KeyModifiers::empty(),))); + .matches_prefix_key(&TerminalKey::new(KeyCode::Char('!'), KeyModifiers::empty(),))); let shifted_non_ascii = ActionKeybinds::prefix("shift+ö"); assert!(!shifted_non_ascii - .matches_prefix_key(TerminalKey::new(KeyCode::Char('Ö'), KeyModifiers::empty(),))); + .matches_prefix_key(&TerminalKey::new(KeyCode::Char('Ö'), KeyModifiers::empty(),))); } #[test] fn shifted_tab_inputs_match_backtab_canonical_binding() { let bindings = ActionKeybinds::prefix("shift+tab"); assert!( - bindings.matches_prefix_key(TerminalKey::new(KeyCode::BackTab, KeyModifiers::empty())) + bindings.matches_prefix_key(&TerminalKey::new(KeyCode::BackTab, KeyModifiers::empty())) ); assert!( - bindings.matches_prefix_key(TerminalKey::new(KeyCode::BackTab, KeyModifiers::SHIFT)) + bindings.matches_prefix_key(&TerminalKey::new(KeyCode::BackTab, KeyModifiers::SHIFT)) ); - assert!(bindings.matches_prefix_key(TerminalKey::new(KeyCode::Tab, KeyModifiers::SHIFT))); + assert!(bindings.matches_prefix_key(&TerminalKey::new(KeyCode::Tab, KeyModifiers::SHIFT))); assert!(!ActionKeybinds::prefix("tab") - .matches_prefix_key(TerminalKey::new(KeyCode::Tab, KeyModifiers::SHIFT))); + .matches_prefix_key(&TerminalKey::new(KeyCode::Tab, KeyModifiers::SHIFT))); assert_eq!( normalize_key_combo((KeyCode::Tab, KeyModifiers::CONTROL | KeyModifiers::SHIFT)), (KeyCode::BackTab, KeyModifiers::CONTROL) @@ -1746,15 +1746,15 @@ close_tab = "X" #[test] fn shifted_punctuation_matches_enhanced_input() { let help = ActionKeybinds::prefix("?"); - assert!(help.matches_prefix_key(TerminalKey::new(KeyCode::Char('?'), KeyModifiers::SHIFT))); + assert!(help.matches_prefix_key(&TerminalKey::new(KeyCode::Char('?'), KeyModifiers::SHIFT))); assert!(help.matches_prefix_key( - TerminalKey::new(KeyCode::Char('/'), KeyModifiers::SHIFT) + &TerminalKey::new(KeyCode::Char('/'), KeyModifiers::SHIFT) .with_shifted_codepoint('?' as u32) )); let bang = ActionKeybinds::prefix("!"); assert!(bang.matches_prefix_key( - TerminalKey::new(KeyCode::Char('1'), KeyModifiers::SHIFT) + &TerminalKey::new(KeyCode::Char('1'), KeyModifiers::SHIFT) .with_shifted_codepoint('!' as u32) )); } @@ -1805,12 +1805,12 @@ navigate_pane_down = "ctrl+j" assert!(keybinds .navigate .workspace_up - .matches_direct_key(TerminalKey::new(KeyCode::Char('j'), KeyModifiers::empty()))); + .matches_direct_key(&TerminalKey::new(KeyCode::Char('j'), KeyModifiers::empty()))); assert!(keybinds.navigate.workspace_down.bindings.is_empty()); assert!(keybinds .navigate .pane_down - .matches_direct_key(TerminalKey::new(KeyCode::Char('j'), KeyModifiers::CONTROL))); + .matches_direct_key(&TerminalKey::new(KeyCode::Char('j'), KeyModifiers::CONTROL))); assert!(diagnostics.iter().any(|diag| { diag.contains("kept keys.navigate_workspace_up") && diag.contains("disabled keys.navigate_workspace_down") @@ -1862,11 +1862,11 @@ command = "echo hi" assert!(keybinds .navigate .workspace_down - .matches_direct_key(TerminalKey::new(KeyCode::Char('n'), KeyModifiers::empty()))); + .matches_direct_key(&TerminalKey::new(KeyCode::Char('n'), KeyModifiers::empty()))); assert!(keybinds .navigate .workspace_down - .matches_direct_key(TerminalKey::new(KeyCode::Char('f'), KeyModifiers::empty()))); + .matches_direct_key(&TerminalKey::new(KeyCode::Char('f'), KeyModifiers::empty()))); assert!(!keybinds.custom_commands.is_empty()); assert!(!diagnostics.iter().any(|diag| { diag.contains("disabled keys.navigate_workspace_down") @@ -1888,7 +1888,7 @@ navigate_pane_down = "j" assert!(keybinds .navigate .pane_down - .matches_direct_key(TerminalKey::new(KeyCode::Char('j'), KeyModifiers::empty()))); + .matches_direct_key(&TerminalKey::new(KeyCode::Char('j'), KeyModifiers::empty()))); } #[test] diff --git a/src/input/encode.rs b/src/input/encode.rs index cd59b0f4bb..c475e0da5f 100644 --- a/src/input/encode.rs +++ b/src/input/encode.rs @@ -16,8 +16,10 @@ pub fn encode_key(key: KeyEvent, protocol: KeyboardProtocol) -> Vec { } pub fn encode_terminal_key(key: TerminalKey, protocol: KeyboardProtocol) -> Vec { - if key.is_text_commit { - return encode_text_input(&key).unwrap_or_default(); + if key.kind != crossterm::event::KeyEventKind::Release { + if let Some(text) = &key.generated_text { + return text.as_bytes().to_vec(); + } } // A release event only produces bytes when the pane protocol reports event @@ -282,10 +284,7 @@ fn encode_legacy(key: TerminalKey) -> Vec { // Alt modifier on character keys: prefix with ESC if mods.contains(KeyModifiers::ALT) { - let inner = TerminalKey { - modifiers: mods.difference(KeyModifiers::ALT), - ..key - }; + let inner = key.with_modifiers(mods.difference(KeyModifiers::ALT)); let mut bytes = vec![0x1b]; bytes.extend(encode_legacy_inner(inner)); return bytes; @@ -950,6 +949,13 @@ mod tests { encode_key(release, KeyboardProtocol::Kitty { flags: 3 }), b"\x1b[106;1:3u" ); + + let mut malformed_release = TerminalKey::from(release); + malformed_release.generated_text = Some("j".to_owned()); + assert_eq!( + encode_terminal_key(malformed_release, KeyboardProtocol::Kitty { flags: 3 }), + b"\x1b[106;1:3u" + ); } #[test] diff --git a/src/input/mod.rs b/src/input/mod.rs index 3882b03507..0a716f9ccd 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -9,7 +9,7 @@ pub use encode::{ #[cfg(not(windows))] pub use model::ime_compatible_keyboard_enhancement_flags; pub use model::{ - host_modify_other_keys_mode, KeyboardProtocol, MouseProtocolEncoding, MouseProtocolMode, - TerminalKey, + host_modify_other_keys_mode, KeyIdentity, KeyboardProtocol, MouseProtocolEncoding, + MouseProtocolMode, TerminalKey, TextCommit, WindowsKeyRecord, }; pub use parse::parse_terminal_key_sequence; diff --git a/src/input/model.rs b/src/input/model.rs index f6072bc680..9c3c14e7ec 100644 --- a/src/input/model.rs +++ b/src/input/model.rs @@ -3,13 +3,77 @@ use crossterm::event::KeyboardEnhancementFlags; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct WindowsKeyRecord { + pub key_down: bool, + pub repeat_count: u16, + pub virtual_key_code: u16, + pub virtual_scan_code: u16, + pub unicode: u16, + pub control_key_state: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TextCommit { + text: String, +} + +impl TextCommit { + pub fn new(text: impl Into) -> Self { + Self { text: text.into() } + } + + pub fn as_str(&self) -> &str { + &self.text + } + + pub(crate) fn into_string(self) -> String { + self.text + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct PhysicalKeyId(u32); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum KeyIdentity { + Physical(PhysicalKeyId), + Semantic(KeyCode), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum KeySource { + Synthesized, + Vt { + bytes: Vec, + }, + WindowsConsole { + record: WindowsKeyRecord, + physical_key: Option, + }, +} + +impl WindowsKeyRecord { + fn physical_key_id(self) -> Option { + const ENHANCED_KEY: u32 = 0x0100; + (self.virtual_scan_code != 0).then(|| { + PhysicalKeyId( + u32::from(self.virtual_scan_code) + | (u32::from(self.control_key_state & ENHANCED_KEY != 0) << 16), + ) + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] pub struct TerminalKey { pub code: KeyCode, pub modifiers: KeyModifiers, pub kind: crossterm::event::KeyEventKind, + pub repeat_count: u16, pub shifted_codepoint: Option, - pub is_text_commit: bool, + pub generated_text: Option, + source: KeySource, } impl TerminalKey { @@ -18,36 +82,127 @@ impl TerminalKey { code, modifiers, kind: crossterm::event::KeyEventKind::Press, + repeat_count: 1, shifted_codepoint: None, - is_text_commit: false, + generated_text: None, + source: KeySource::Synthesized, } } pub fn with_kind(mut self, kind: crossterm::event::KeyEventKind) -> Self { + if kind == crossterm::event::KeyEventKind::Release { + self.repeat_count = 1; + self.generated_text = None; + } self.kind = kind; self } + pub fn with_repeat_count(mut self, repeat_count: u16) -> Self { + self.repeat_count = if self.kind == crossterm::event::KeyEventKind::Release { + 1 + } else { + repeat_count.max(1) + }; + self + } + + pub(crate) fn with_modifiers(mut self, modifiers: KeyModifiers) -> Self { + self.modifiers = modifiers; + self + } + #[allow(dead_code)] // Reserved for the upcoming raw input parser to preserve shifted/base key pairs. pub fn with_shifted_codepoint(mut self, shifted_codepoint: u32) -> Self { self.shifted_codepoint = Some(shifted_codepoint); self } - pub fn as_text_commit(mut self) -> Self { + pub(crate) fn with_generated_text(mut self, text: Option) -> Self { + self.generated_text = if self.kind == crossterm::event::KeyEventKind::Release { + None + } else { + text + }; + self + } + + pub(crate) fn with_vt_bytes(mut self, bytes: Vec) -> Self { + self.source = KeySource::Vt { bytes }; + self + } + + pub fn with_windows_record(mut self, record: WindowsKeyRecord) -> Self { + self.repeat_count = if self.kind == crossterm::event::KeyEventKind::Release { + 1 + } else { + record.repeat_count.max(1) + }; + self.source = KeySource::WindowsConsole { + physical_key: record.physical_key_id(), + record, + }; + self + } + + #[cfg(any(windows, test))] + pub(crate) fn vt_bytes(&self) -> Option<&[u8]> { + match &self.source { + KeySource::Vt { bytes } => Some(bytes), + KeySource::Synthesized | KeySource::WindowsConsole { .. } => None, + } + } + + #[cfg(any(windows, test))] + pub(crate) fn windows_record(&self) -> Option { + match self.source { + KeySource::WindowsConsole { record, .. } => Some(record), + KeySource::Synthesized | KeySource::Vt { .. } => None, + } + } + + pub(crate) fn identity(&self) -> KeyIdentity { + match self.source { + KeySource::WindowsConsole { + physical_key: Some(physical_key), + .. + } => KeyIdentity::Physical(physical_key), + KeySource::WindowsConsole { + physical_key: None, .. + } + | KeySource::Synthesized + | KeySource::Vt { .. } => KeyIdentity::Semantic(self.code), + } + } + + pub(crate) fn has_physical_identity(&self) -> bool { + matches!( + self.source, + KeySource::WindowsConsole { + physical_key: Some(_), + .. + } + ) + } + + pub fn with_text_commit(mut self) -> Self { let has_text_only_modifiers = match self.code { - KeyCode::Char(ch) if ch.is_ascii_uppercase() => { + KeyCode::Char(ch) if ch.is_uppercase() => { self.modifiers == KeyModifiers::SHIFT || self.modifiers.is_empty() } KeyCode::Char(_) => self.modifiers.is_empty(), _ => false, }; - self.is_text_commit = - has_text_only_modifiers && self.kind == crossterm::event::KeyEventKind::Press; + if has_text_only_modifiers && self.kind == crossterm::event::KeyEventKind::Press { + self.generated_text = match self.code { + KeyCode::Char(ch) => Some(ch.to_string()), + _ => None, + }; + } self } - pub fn as_key_event(self) -> KeyEvent { + pub fn as_key_event(&self) -> KeyEvent { KeyEvent::new_with_kind(self.code, self.modifiers, self.kind) } } @@ -168,6 +323,83 @@ pub enum MouseProtocolEncoding { mod tests { use super::*; + #[test] + fn native_source_exposes_typed_identity_without_changing_semantics() { + let record = WindowsKeyRecord { + key_down: true, + repeat_count: 1, + virtual_key_code: 27, + virtual_scan_code: 1, + unicode: 27, + control_key_state: 0, + }; + let key = TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()).with_windows_record(record); + let enhanced = TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()).with_windows_record( + WindowsKeyRecord { + control_key_state: 0x0100, + ..record + }, + ); + + assert!(matches!(key.identity(), KeyIdentity::Physical(_))); + assert_ne!(key.identity(), enhanced.identity()); + assert_eq!(key.code, KeyCode::Esc); + } + + #[test] + fn semantic_source_uses_semantic_identity() { + let key = TerminalKey::new(KeyCode::Char('x'), KeyModifiers::CONTROL); + + assert_eq!(key.identity(), KeyIdentity::Semantic(KeyCode::Char('x'))); + assert!(!key.has_physical_identity()); + assert_eq!(key.windows_record(), None); + } + + #[test] + fn native_source_remains_immutable_when_canonical_phase_changes() { + let key = TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()) + .with_windows_record(WindowsKeyRecord { + key_down: true, + repeat_count: 1, + virtual_key_code: 27, + virtual_scan_code: 1, + unicode: 27, + control_key_state: 0, + }) + .with_kind(crossterm::event::KeyEventKind::Release); + + assert_eq!(key.kind, crossterm::event::KeyEventKind::Release); + assert_eq!( + key.windows_record().map(|record| record.key_down), + Some(true) + ); + assert_eq!(key.repeat_count, 1); + } + + #[test] + fn release_clears_generated_text_and_grouped_repeat_count() { + let release = TerminalKey::new(KeyCode::Char('a'), KeyModifiers::empty()) + .with_generated_text(Some("a".to_owned())) + .with_repeat_count(4) + .with_kind(crossterm::event::KeyEventKind::Release); + let regrouped_release = release + .clone() + .with_repeat_count(4) + .with_generated_text(Some("ignored".to_owned())); + + assert_eq!(release.generated_text, None); + assert_eq!(release.repeat_count, 1); + assert_eq!(regrouped_release.generated_text, None); + assert_eq!(regrouped_release.repeat_count, 1); + } + + #[test] + fn non_ascii_uppercase_with_shift_is_committed_text() { + let key = TerminalKey::new(KeyCode::Char('É'), KeyModifiers::SHIFT).with_text_commit(); + + assert_eq!(key.generated_text.as_deref(), Some("É")); + } + #[test] fn protocol_from_zero_flags_is_legacy() { assert_eq!( diff --git a/src/input/parse.rs b/src/input/parse.rs index e4c554f83f..b042081630 100644 --- a/src/input/parse.rs +++ b/src/input/parse.rs @@ -40,13 +40,11 @@ fn parse_kitty_key_sequence(data: &str) -> Option { let code = kitty_codepoint_to_keycode(codepoint)?; let kind = parse_kitty_event_type(event_type)?; - Some(TerminalKey { - code, - modifiers: key_modifiers_from_u8(modifier), - kind, - shifted_codepoint, - is_text_commit: false, - }) + let mut key = TerminalKey::new(code, key_modifiers_from_u8(modifier)).with_kind(kind); + if let Some(shifted_codepoint) = shifted_codepoint { + key = key.with_shifted_codepoint(shifted_codepoint); + } + Some(key) } #[allow(dead_code)] // Reserved for the upcoming raw stdin parser. @@ -533,7 +531,7 @@ mod tests { fn parse_legacy_alt_shift_letter_preserves_shift() { let key = parse_terminal_key_sequence("\x1bA").expect("alt-shift letter should parse"); assert_terminal_key_eq( - key, + key.clone(), KeyCode::Char('A'), KeyModifiers::ALT | KeyModifiers::SHIFT, crossterm::event::KeyEventKind::Press, @@ -620,7 +618,7 @@ mod tests { fn parse_kitty_sequence_with_associated_emoji_text() { let key = parse_terminal_key_sequence("\x1b[128512;1;128512u").unwrap(); assert_terminal_key_eq( - key, + key.clone(), KeyCode::Char('😀'), KeyModifiers::empty(), crossterm::event::KeyEventKind::Press, diff --git a/src/pane/input.rs b/src/pane/input.rs index 6906e77d31..38e8138b56 100644 --- a/src/pane/input.rs +++ b/src/pane/input.rs @@ -1,5 +1,5 @@ pub(super) fn ghostty_key_event_from_terminal_key( - key: crate::input::TerminalKey, + key: &crate::input::TerminalKey, ) -> Option { let mut event = crate::ghostty::KeyEvent::new().ok()?; event.set_action(match key.kind { @@ -32,7 +32,7 @@ pub(super) fn ghostty_key_event_from_terminal_key( Some(event) } -pub(super) fn ghostty_prefers_herdr_text_encoding(key: crate::input::TerminalKey) -> bool { +pub(super) fn ghostty_prefers_herdr_text_encoding(key: &crate::input::TerminalKey) -> bool { matches!(key.code, crossterm::event::KeyCode::Char(_)) } @@ -168,7 +168,7 @@ pub(super) fn ghostty_mouse_event_from_wheel_kind( Some(event) } -fn ghostty_key_text(key: crate::input::TerminalKey) -> Option { +fn ghostty_key_text(key: &crate::input::TerminalKey) -> Option { match key.code { crossterm::event::KeyCode::Char(c) => Some( key.shifted_codepoint @@ -180,7 +180,7 @@ fn ghostty_key_text(key: crate::input::TerminalKey) -> Option { } } -fn ghostty_unshifted_codepoint(key: crate::input::TerminalKey) -> Option { +fn ghostty_unshifted_codepoint(key: &crate::input::TerminalKey) -> Option { match key.code { crossterm::event::KeyCode::Char(c) => Some(c as u32), _ => None, diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index 5a2180bf83..3f8d8aeae9 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -1635,22 +1635,40 @@ impl GhosttyPaneTerminal { protocol: crate::input::KeyboardProtocol, ) -> Vec { #[cfg(windows)] - if let Some(bytes) = crate::platform::encode_windows_conpty_shift_enter(key) { - if self.core.lock().is_ok_and(|core| { - core.terminal - .kitty_keyboard_flags() - .is_ok_and(|flags| flags == 0) - && !core.kitty_keyboard.modify_other_keys_enabled() - }) { + if self.core.lock().is_ok_and(|core| { + core.terminal + .kitty_keyboard_flags() + .is_ok_and(|flags| flags == 0) + && !core.kitty_keyboard.modify_other_keys_enabled() + }) { + if let Some(bytes) = crate::platform::encode_windows_conpty_fallback(&key) { return bytes; } } - if ghostty_prefers_herdr_text_encoding(key) { + let repeat_count = key.repeat_count; + let first = key.with_repeat_count(1); + let mut bytes = self.encode_terminal_key_once(first.clone(), protocol); + 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); + for _ in 1..repeat_count { + bytes.extend_from_slice(&repeated_bytes); + } + } + bytes + } + + fn encode_terminal_key_once( + &self, + key: crate::input::TerminalKey, + protocol: crate::input::KeyboardProtocol, + ) -> Vec { + if ghostty_prefers_herdr_text_encoding(&key) { return crate::input::encode_terminal_key(key, protocol); } - let Some(event) = ghostty_key_event_from_terminal_key(key) else { + let Some(event) = ghostty_key_event_from_terminal_key(&key) else { return crate::input::encode_terminal_key(key, protocol); }; @@ -1659,7 +1677,8 @@ impl GhosttyPaneTerminal { }; match encoder.encode(&event) { Ok(bytes) - if !bytes.is_empty() && encoded_key_preserves_event_kind(&bytes, key, protocol) => + if !bytes.is_empty() + && encoded_key_preserves_event_kind(&bytes, &key, protocol) => { bytes } @@ -1969,7 +1988,7 @@ impl GhosttyPaneTerminal { fn encoded_key_preserves_event_kind( bytes: &[u8], - key: crate::input::TerminalKey, + key: &crate::input::TerminalKey, protocol: crate::input::KeyboardProtocol, ) -> bool { if !protocol.reports_event_types() || key.kind == crossterm::event::KeyEventKind::Press { @@ -3890,10 +3909,50 @@ mod tests { assert_eq!(encoded, b"\x1bOA"); let key = crate::input::parse_terminal_key_sequence("\x1b[13;2u").unwrap(); - let encoded = pane.encode_terminal_key(key, crate::input::KeyboardProtocol::Legacy); + let encoded = pane.encode_terminal_key(key.clone(), crate::input::KeyboardProtocol::Legacy); assert_eq!(encoded, b"\x1b[27;2;13~"); } + #[test] + fn grouped_semantic_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(); + let key = crate::input::TerminalKey::new( + crossterm::event::KeyCode::Char('x'), + crossterm::event::KeyModifiers::empty(), + ) + .with_repeat_count(3); + + assert_eq!( + pane.encode_terminal_key(key, crate::input::KeyboardProtocol::Legacy), + b"xxx" + ); + } + + #[test] + fn grouped_release_is_encoded_once() { + let (tx, _rx) = mpsc::channel(4); + let mut terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); + terminal.write(b"\x1b[>11u"); + let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); + let protocol = pane.keyboard_protocol().unwrap(); + let release = crate::input::TerminalKey::new( + crossterm::event::KeyCode::Up, + crossterm::event::KeyModifiers::empty(), + ) + .with_kind(crossterm::event::KeyEventKind::Release); + let expected = pane.encode_terminal_key(release.clone(), protocol); + + assert!(!expected.is_empty()); + let mut malformed_release = release; + malformed_release.repeat_count = 3; + assert_eq!( + pane.encode_terminal_key(malformed_release, protocol), + expected + ); + } + #[test] fn ghostty_key_encoder_updates_after_terminal_mode_changes() { let (tx, _rx) = mpsc::channel(4); @@ -3933,9 +3992,9 @@ mod tests { crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::SHIFT, ); - let before = pane.encode_terminal_key(key, crate::input::KeyboardProtocol::Legacy); + let before = pane.encode_terminal_key(key.clone(), crate::input::KeyboardProtocol::Legacy); pane.process_pty_bytes(pane_id, 0, b"\x1b[>1u", &tx); - let after = pane.encode_terminal_key(key, crate::input::KeyboardProtocol::Legacy); + let after = pane.encode_terminal_key(key.clone(), crate::input::KeyboardProtocol::Legacy); assert_ne!(before, after); assert_eq!(after, b"\x1b[13;6u"); @@ -3950,7 +4009,7 @@ mod tests { pane.process_pty_bytes(pane_id, 0, b"\x1b[>5u", &tx); let key = crate::input::parse_terminal_key_sequence("\x1b[13;2u").unwrap(); - let encoded = pane.encode_terminal_key(key, crate::input::KeyboardProtocol::Legacy); + let encoded = pane.encode_terminal_key(key.clone(), crate::input::KeyboardProtocol::Legacy); assert_eq!( pane.keyboard_protocol(), @@ -3968,7 +4027,7 @@ mod tests { pane.seed_keyboard_protocol_flags(5); let key = crate::input::parse_terminal_key_sequence("\x1b[13;2u").unwrap(); - let encoded = pane.encode_terminal_key(key, crate::input::KeyboardProtocol::Legacy); + let encoded = pane.encode_terminal_key(key.clone(), crate::input::KeyboardProtocol::Legacy); assert_eq!( pane.keyboard_protocol(), @@ -4005,21 +4064,29 @@ mod tests { ); } + #[cfg(windows)] #[test] - fn ghostty_modify_other_keys_mode_one_preserves_shift_enter() { + fn windows_ghostty_default_pane_preserves_synthesized_shift_enter_fallback() { let (tx, _rx) = mpsc::channel(4); let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); let key = crate::input::parse_terminal_key_sequence("\x1b[13;2u").unwrap(); - #[cfg(windows)] assert_eq!( pane.encode_terminal_key(key, crate::input::KeyboardProtocol::Legacy), b"\x1b[13;28;13;1;16;1_" ); + } + + #[test] + fn ghostty_modify_other_keys_mode_one_preserves_shift_enter() { + let (tx, _rx) = mpsc::channel(4); + let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); + let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); + let key = crate::input::parse_terminal_key_sequence("\x1b[13;2u").unwrap(); pane.seed_history_ansi("\x1b[>4;1m"); - let encoded = pane.encode_terminal_key(key, crate::input::KeyboardProtocol::Legacy); + let encoded = pane.encode_terminal_key(key.clone(), crate::input::KeyboardProtocol::Legacy); assert_eq!(encoded, b"\x1b[27;2;13~"); } @@ -4033,7 +4100,7 @@ mod tests { pane.process_pty_bytes(pane_id, 0, b"\x1b[>1u", &tx); let key = crate::input::parse_terminal_key_sequence("\x1b\x7f").unwrap(); - let encoded = pane.encode_terminal_key(key, crate::input::KeyboardProtocol::Legacy); + let encoded = pane.encode_terminal_key(key.clone(), crate::input::KeyboardProtocol::Legacy); assert_eq!(encoded, b"\x1b[127;3u"); } @@ -4052,7 +4119,10 @@ mod tests { crossterm::event::KeyModifiers::CONTROL, ); assert_eq!( - legacy.encode_terminal_key(ctrl_backspace, crate::input::KeyboardProtocol::Legacy), + legacy.encode_terminal_key( + ctrl_backspace.clone(), + crate::input::KeyboardProtocol::Legacy + ), b"\x08" ); diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 35ecfd827f..6cc642eb70 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -64,15 +64,33 @@ use super::{ClipboardImage, ForegroundJob, Signal}; const STILL_ACTIVE: u32 = 259; const FOREGROUND_PROCESS_SNAPSHOT_CACHE_TTL: Duration = Duration::from_millis(250); -pub(crate) fn encode_windows_conpty_shift_enter(key: crate::input::TerminalKey) -> Option> { +/// Encode native or targeted semantic Win32 input for a compatible ConPTY destination. +pub(crate) fn encode_windows_conpty_fallback(key: &crate::input::TerminalKey) -> Option> { use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers}; - if key.code != KeyCode::Enter || key.modifiers != KeyModifiers::SHIFT { - return None; - } + let (virtual_key_code, virtual_scan_code, unicode, control_key_state) = + if let Some(record) = key.windows_record() { + ( + record.virtual_key_code, + record.virtual_scan_code, + record.unicode, + record.control_key_state, + ) + } else if key.code == KeyCode::Enter && key.modifiers == KeyModifiers::SHIFT { + (13, 28, 13, 16) + } else { + return None; + }; + let key_down = key.kind != KeyEventKind::Release; + let repeat_count = if key_down { key.repeat_count.max(1) } else { 1 }; - let key_down = !matches!(key.kind, KeyEventKind::Release); - Some(format!("\x1b[13;28;13;{};16;1_", u8::from(key_down)).into_bytes()) + Some( + format!( + "\x1b[{virtual_key_code};{virtual_scan_code};{unicode};{};{control_key_state};{repeat_count}_", + u8::from(key_down), + ) + .into_bytes(), + ) } #[derive(Debug)] @@ -1389,6 +1407,53 @@ mod tests { AllocConsole, FreeConsole, GetConsoleProcessList, GetConsoleWindow, }; + #[test] + fn windows_conpty_native_encoder_uses_canonical_phase_and_repeat_count() { + let key = crate::input::TerminalKey::new( + crossterm::event::KeyCode::Esc, + crossterm::event::KeyModifiers::empty(), + ) + .with_windows_record(crate::input::WindowsKeyRecord { + key_down: true, + repeat_count: 3, + virtual_key_code: 27, + virtual_scan_code: 1, + unicode: 27, + control_key_state: 0, + }); + + assert_eq!( + super::encode_windows_conpty_fallback(&key), + Some(b"\x1b[27;1;27;1;0;3_".to_vec()) + ); + let mut release = key.with_kind(crossterm::event::KeyEventKind::Release); + release.repeat_count = 3; + assert_eq!( + super::encode_windows_conpty_fallback(&release), + Some(b"\x1b[27;1;27;0;0;1_".to_vec()) + ); + } + + #[test] + fn windows_conpty_native_encoder_preserves_semantic_shift_enter_fallback() { + let shift_enter = crate::input::TerminalKey::new( + crossterm::event::KeyCode::Enter, + crossterm::event::KeyModifiers::SHIFT, + ); + + assert_eq!( + super::encode_windows_conpty_fallback(&shift_enter), + Some(b"\x1b[13;28;13;1;16;1_".to_vec()) + ); + assert_eq!( + super::encode_windows_conpty_fallback(&crate::input::TerminalKey::new( + crossterm::event::KeyCode::Enter, + crossterm::event::KeyModifiers::empty(), + )), + None + ); + } + #[test] fn windows_notification_text_is_null_terminated_and_unicode_safe() { let mut destination = [u16::MAX; 6]; diff --git a/src/protocol/wire.rs b/src/protocol/wire.rs index 0d4b14bba7..655f47cb44 100644 --- a/src/protocol/wire.rs +++ b/src/protocol/wire.rs @@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize}; // --------------------------------------------------------------------------- /// Current protocol version. Bumped when wire format changes incompatibly. -pub const PROTOCOL_VERSION: u32 = 18; +pub const PROTOCOL_VERSION: u32 = 19; /// Maximum allowed frame payload size (2 MB). Frames larger than this are /// rejected to prevent denial-of-service via oversized length prefixes. @@ -115,10 +115,11 @@ pub enum ClientInputEvent { code: ClientKeyCode, modifiers: u8, kind: ClientKeyKind, + repeat_count: u16, + generated_text: Option, + source: ClientKeySource, }, - Text { - codepoint: char, - }, + TextCommit(String), Mouse { kind: ClientMouseKind, column: u16, @@ -132,6 +133,17 @@ pub enum ClientInputEvent { FocusLost, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum ClientKeySource { + Synthesized, + Vt { + bytes: Vec, + }, + WindowsConsole { + record: crate::input::WindowsKeyRecord, + }, +} + impl ClientKeyKind { #[cfg(any(windows, test))] pub(crate) fn from_crossterm(kind: crossterm::event::KeyEventKind) -> Self { @@ -261,6 +273,9 @@ impl ClientInputEvent { code: ClientKeyCode::from_crossterm(key.code)?, modifiers: key.modifiers.bits(), kind: ClientKeyKind::from_crossterm(key.kind), + repeat_count: 1, + generated_text: None, + source: ClientKeySource::Synthesized, }), crossterm::event::Event::Mouse(mouse) => Some(Self::Mouse { kind: ClientMouseKind::from_crossterm(mouse.kind)?, @@ -281,20 +296,28 @@ impl ClientInputEvent { code, modifiers, kind, - } => crate::raw_input::RawInputEvent::Key( - crate::input::TerminalKey::new( + repeat_count, + generated_text, + source, + } => { + let mut key = crate::input::TerminalKey::new( code.to_crossterm(), crossterm::event::KeyModifiers::from_bits_truncate(*modifiers), ) - .with_kind(kind.to_crossterm()), - ), - Self::Text { codepoint } => crate::raw_input::RawInputEvent::Key( - crate::input::TerminalKey::new( - crossterm::event::KeyCode::Char(*codepoint), - crossterm::event::KeyModifiers::empty(), - ) - .as_text_commit(), - ), + .with_generated_text(generated_text.clone()); + key = match source { + ClientKeySource::Synthesized => key, + ClientKeySource::Vt { bytes } => key.with_vt_bytes(bytes.clone()), + ClientKeySource::WindowsConsole { record } => key.with_windows_record(*record), + }; + key = key + .with_repeat_count(*repeat_count) + .with_kind(kind.to_crossterm()); + crate::raw_input::RawInputEvent::Key(key) + } + Self::TextCommit(text) => { + crate::raw_input::RawInputEvent::Text(crate::input::TextCommit::new(text.clone())) + } Self::Mouse { kind, column, @@ -1077,12 +1100,39 @@ mod tests { code: ClientKeyCode::Char('N'), modifiers: crossterm::event::KeyModifiers::SHIFT.bits(), kind: ClientKeyKind::Press, + + repeat_count: 1, + generated_text: None, + source: crate::protocol::ClientKeySource::Synthesized, }, ClientInputEvent::Key { code: ClientKeyCode::Backspace, modifiers: 0, kind: ClientKeyKind::Press, + repeat_count: 3, + generated_text: None, + source: crate::protocol::ClientKeySource::Vt { + bytes: b"\x1b[127;1u".to_vec(), + }, + }, + ClientInputEvent::Key { + code: ClientKeyCode::Esc, + modifiers: 0, + kind: ClientKeyKind::Release, + repeat_count: 1, + generated_text: None, + source: crate::protocol::ClientKeySource::WindowsConsole { + record: crate::input::WindowsKeyRecord { + key_down: false, + repeat_count: 1, + virtual_key_code: 27, + virtual_scan_code: 1, + unicode: 27, + control_key_state: 0, + }, + }, }, + ClientInputEvent::TextCommit("你🙂".to_owned()), ClientInputEvent::Mouse { kind: ClientMouseKind::Down(ClientMouseButton::Left), column: 3, @@ -1092,17 +1142,58 @@ mod tests { ], }; let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); + // Freeze the protocol 19 input envelope before it is published. + assert_eq!( + encoded, + vec![ + 7, 5, 0, 15, 78, 1, 0, 1, 0, 0, 0, 0, 0, 0, 3, 0, 1, 8, 27, 91, 49, 50, 55, 59, 49, + 117, 0, 14, 0, 2, 1, 0, 2, 0, 1, 27, 1, 27, 0, 1, 7, 228, 189, 160, 240, 159, 153, + 130, 2, 0, 0, 3, 4, 0, + ] + ); let (decoded, _): (ClientMessage, _) = bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); assert_eq!(msg, decoded); } + #[test] + fn wire_release_cannot_restore_a_grouped_repeat_count() { + let event = ClientInputEvent::Key { + code: ClientKeyCode::Esc, + modifiers: 0, + kind: ClientKeyKind::Release, + repeat_count: 3, + generated_text: Some("ignored".to_owned()), + source: ClientKeySource::Synthesized, + }; + + match event.to_raw_input_event() { + crate::raw_input::RawInputEvent::Key(key) => { + assert_eq!(key.kind, crossterm::event::KeyEventKind::Release); + assert_eq!(key.repeat_count, 1); + assert_eq!(key.generated_text, None); + } + other => panic!("expected key event, got {other:?}"), + } + } + #[test] fn client_input_events_convert_to_raw_keys() { + let record = crate::input::WindowsKeyRecord { + key_down: false, + repeat_count: 1, + virtual_key_code: 78, + virtual_scan_code: 49, + unicode: 78, + control_key_state: 16, + }; let shifted = ClientInputEvent::Key { code: ClientKeyCode::Char('N'), modifiers: crossterm::event::KeyModifiers::SHIFT.bits(), kind: ClientKeyKind::Press, + repeat_count: 1, + generated_text: None, + source: ClientKeySource::WindowsConsole { record }, } .to_raw_input_event(); match shifted { @@ -1110,6 +1201,10 @@ mod tests { assert_eq!(key.code, crossterm::event::KeyCode::Char('N')); assert_eq!(key.modifiers, crossterm::event::KeyModifiers::SHIFT); assert_eq!(key.kind, crossterm::event::KeyEventKind::Press); + assert_eq!( + key.windows_record().map(|record| record.key_down), + Some(false) + ); } other => panic!("expected shifted key event, got {other:?}"), } @@ -1118,6 +1213,10 @@ mod tests { code: ClientKeyCode::Backspace, modifiers: 0, kind: ClientKeyKind::Press, + + repeat_count: 1, + generated_text: None, + source: crate::protocol::ClientKeySource::Synthesized, } .to_raw_input_event(); match backspace { diff --git a/src/raw_input.rs b/src/raw_input.rs index 6b59b3927e..ce7aec1943 100644 --- a/src/raw_input.rs +++ b/src/raw_input.rs @@ -53,10 +53,10 @@ pub fn parse_raw_input_bytes_with_ranges(data: &[u8]) -> Vec Vec Vec { use std::os::fd::AsRawFd; use tokio::sync::mpsc; -use crate::input::{parse_terminal_key_sequence, TerminalKey}; +use crate::input::{parse_terminal_key_sequence, TerminalKey, TextCommit}; use crate::terminal_theme::{ parse_default_color_response, parse_palette_color_response, DefaultColorKind, HostAppearance, RgbColor, @@ -126,6 +126,7 @@ pub(crate) fn is_complete_text_bracketed_paste(data: &[u8]) -> bool { #[derive(Debug)] pub enum RawInputEvent { Key(TerminalKey), + Text(TextCommit), Paste(String), Mouse(MouseEvent), OuterFocusGained, @@ -187,10 +188,10 @@ impl RawInputFramer { .into_iter() .filter_map(|chunk| { if chunk.as_slice() == [ESC] { - return Some(RawInputEvent::Key(TerminalKey::new( - crossterm::event::KeyCode::Esc, - KeyModifiers::empty(), - ))); + return Some(RawInputEvent::Key( + TerminalKey::new(crossterm::event::KeyCode::Esc, KeyModifiers::empty()) + .with_vt_bytes(chunk), + )); } extract_one_event(&chunk).map(|(event, _consumed)| { tracing::debug!(raw_bytes = ?chunk, event = ?event, "raw input event parsed"); @@ -639,10 +640,10 @@ pub(crate) fn drain_complete_input_bytes(buffer: &mut Vec) -> Vec> { fn flush_incomplete_buffer(buffer: &mut Vec, tx: &mpsc::Sender) { if let Some(bytes) = flush_incomplete_input_bytes(buffer) { if bytes.as_slice() == [ESC] { - let _ = tx.blocking_send(RawInputEvent::Key(TerminalKey::new( - crossterm::event::KeyCode::Esc, - KeyModifiers::empty(), - ))); + let _ = tx.blocking_send(RawInputEvent::Key( + TerminalKey::new(crossterm::event::KeyCode::Esc, KeyModifiers::empty()) + .with_vt_bytes(bytes), + )); return; } @@ -752,7 +753,10 @@ fn extract_one_event(buffer: &[u8]) -> Option<(RawInputEvent, usize)> { } if let Some(key) = parse_terminal_key_sequence(seq) { - return Some((RawInputEvent::Key(key), seq_len)); + return Some(( + RawInputEvent::Key(key.with_vt_bytes(buffer[..seq_len].to_vec())), + seq_len, + )); } tracing::debug!(sequence = ?seq, "dropping unsupported escape sequence"); @@ -761,7 +765,9 @@ fn extract_one_event(buffer: &[u8]) -> Option<(RawInputEvent, usize)> { let consumed = first_complete_utf8_char_len(buffer)?; let text = std::str::from_utf8(&buffer[..consumed]).ok()?; - let key = parse_terminal_key_sequence(text)?.as_text_commit(); + let key = parse_terminal_key_sequence(text)? + .with_text_commit() + .with_vt_bytes(buffer[..consumed].to_vec()); Some((RawInputEvent::Key(key), consumed)) } diff --git a/src/server/client_transport.rs b/src/server/client_transport.rs index 743a108f89..2675b4c303 100644 --- a/src/server/client_transport.rs +++ b/src/server/client_transport.rs @@ -383,24 +383,62 @@ enum InputEventLimit { WithinLimits, TooManyEvents, PasteTooLarge { size: usize }, + InputPayloadTooLarge { size: usize }, } fn input_event_limit(events: &[ClientInputEvent]) -> InputEventLimit { - if events.len() > MAX_INPUT_EVENT_BATCH { - return InputEventLimit::TooManyEvents; - } - + let mut expanded_events = 0usize; let mut paste_bytes = 0usize; + let mut input_bytes = 0usize; for event in events { - if let ClientInputEvent::Paste { text } = event { - paste_bytes = paste_bytes.saturating_add(text.len()); + expanded_events = expanded_events.saturating_add(match event { + ClientInputEvent::Key { repeat_count, .. } => usize::from((*repeat_count).max(1)), + _ => 1, + }); + match event { + ClientInputEvent::Key { + repeat_count, + generated_text, + source, + .. + } => { + if let Some(text) = generated_text { + input_bytes = input_bytes.saturating_add( + text.len() + .saturating_mul(usize::from((*repeat_count).max(1))), + ); + } + if let crate::protocol::ClientKeySource::Vt { bytes } = source { + input_bytes = input_bytes.saturating_add(bytes.len()); + } + } + ClientInputEvent::TextCommit(text) => { + input_bytes = input_bytes.saturating_add(text.len()); + } + ClientInputEvent::Paste { text } => { + paste_bytes = paste_bytes.saturating_add(text.len()); + } + ClientInputEvent::Mouse { .. } + | ClientInputEvent::FocusGained + | ClientInputEvent::FocusLost => {} } } - if paste_bytes > MAX_INPUT_PAYLOAD { - InputEventLimit::PasteTooLarge { size: paste_bytes } - } else { + if expanded_events > MAX_INPUT_EVENT_BATCH { + return InputEventLimit::TooManyEvents; + } + + let payload_bytes = paste_bytes.saturating_add(input_bytes); + if payload_bytes <= MAX_INPUT_PAYLOAD { InputEventLimit::WithinLimits + } else if input_bytes == 0 { + InputEventLimit::PasteTooLarge { + size: payload_bytes, + } + } else { + InputEventLimit::InputPayloadTooLarge { + size: payload_bytes, + } } } @@ -717,6 +755,17 @@ fn client_read_loop( max: MAX_INPUT_PAYLOAD, } } + InputEventLimit::InputPayloadTooLarge { size } => { + warn!( + client_id, + size, + max = MAX_INPUT_PAYLOAD, + "oversized structured input payload from client, closing" + ); + let _ = server_event_tx + .blocking_send(ServerEvent::ClientDisconnected { client_id }); + break; + } }, ClientMessage::ObserveTerminal { target } => { ServerEvent::ClientObserveTerminal { client_id, target } @@ -1388,6 +1437,10 @@ new_tab = "ctrl+notakey" code: crate::protocol::ClientKeyCode::Enter, modifiers: 0, kind: crate::protocol::ClientKeyKind::Press, + + repeat_count: 1, + generated_text: None, + source: crate::protocol::ClientKeySource::Synthesized, }, ClientInputEvent::FocusGained, ]; @@ -1549,6 +1602,43 @@ new_tab = "ctrl+notakey" .expect("read thread result"); } + #[test] + fn structured_input_limits_charge_grouped_repeats_and_text_payloads() { + let grouped = ClientInputEvent::Key { + code: crate::protocol::ClientKeyCode::Char('x'), + modifiers: 0, + kind: crate::protocol::ClientKeyKind::Press, + repeat_count: (MAX_INPUT_EVENT_BATCH + 1) as u16, + generated_text: None, + source: crate::protocol::ClientKeySource::Synthesized, + }; + assert_eq!( + input_event_limit(&[grouped]), + InputEventLimit::TooManyEvents + ); + + let repeated_text = ClientInputEvent::Key { + code: crate::protocol::ClientKeyCode::Char('x'), + modifiers: 0, + kind: crate::protocol::ClientKeyKind::Press, + repeat_count: MAX_INPUT_EVENT_BATCH as u16, + generated_text: Some("x".repeat((MAX_INPUT_PAYLOAD / MAX_INPUT_EVENT_BATCH) + 1)), + source: crate::protocol::ClientKeySource::Synthesized, + }; + assert!(matches!( + input_event_limit(&[repeated_text]), + InputEventLimit::InputPayloadTooLarge { size } if size > MAX_INPUT_PAYLOAD + )); + + let text = ClientInputEvent::TextCommit("x".repeat(MAX_INPUT_PAYLOAD + 1)); + assert_eq!( + input_event_limit(&[text]), + InputEventLimit::InputPayloadTooLarge { + size: MAX_INPUT_PAYLOAD + 1 + } + ); + } + #[test] fn handshake_timeout_is_within_five_second_deadline() { // The handshake timeout must be short enough that diff --git a/src/server/clients.rs b/src/server/clients.rs index c6c617ecfe..bb0f31c68d 100644 --- a/src/server/clients.rs +++ b/src/server/clients.rs @@ -257,6 +257,7 @@ pub(crate) fn events_include_interaction(events: &[crate::raw_input::RawInputEve matches!( event, crate::raw_input::RawInputEvent::Key(_) + | crate::raw_input::RawInputEvent::Text(_) | crate::raw_input::RawInputEvent::Mouse(_) | crate::raw_input::RawInputEvent::Paste(_) | crate::raw_input::RawInputEvent::OuterFocusGained diff --git a/src/server/headless.rs b/src/server/headless.rs index 6fdb83ce0e..ff85f253b9 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -4544,6 +4544,7 @@ fn events_for_app_routing( } crate::raw_input::RawInputEvent::OuterFocusLost if !source_is_foreground => None, crate::raw_input::RawInputEvent::Key(_) + | crate::raw_input::RawInputEvent::Text(_) | crate::raw_input::RawInputEvent::Mouse(_) | crate::raw_input::RawInputEvent::Paste(_) => { source_is_foreground = true; @@ -7539,6 +7540,10 @@ next_tab = "" code: crate::protocol::ClientKeyCode::Char('j'), modifiers: 0, kind: crate::protocol::ClientKeyKind::Press, + + repeat_count: 1, + generated_text: None, + source: crate::protocol::ClientKeySource::Synthesized, }], })); server.foreground_client_id = Some(2); @@ -7558,7 +7563,7 @@ next_tab = "" .expect("synthetic release from background client"), Bytes::from_static(b"\x1b[106;1:3u") ); - assert!(server.app.pressed_terminal_keys.is_empty()); + assert!(server.app.input_leases.is_empty()); } #[tokio::test] @@ -7602,6 +7607,10 @@ next_tab = "" code: crate::protocol::ClientKeyCode::Char('x'), modifiers: 0, kind: crate::protocol::ClientKeyKind::Release, + + repeat_count: 1, + generated_text: None, + source: crate::protocol::ClientKeySource::Synthesized, }, crate::protocol::ClientInputEvent::FocusLost, ], @@ -7652,6 +7661,10 @@ next_tab = "" code: crate::protocol::ClientKeyCode::Char('x'), modifiers: 0, kind: crate::protocol::ClientKeyKind::Release, + + repeat_count: 1, + generated_text: None, + source: crate::protocol::ClientKeySource::Synthesized, }], })); assert_eq!(server.foreground_client_id, Some(3)); @@ -7863,6 +7876,10 @@ next_tab = "" code: crate::protocol::ClientKeyCode::Enter, modifiers: 0, kind: crate::protocol::ClientKeyKind::Press, + + repeat_count: 1, + generated_text: None, + source: crate::protocol::ClientKeySource::Synthesized, }], })); @@ -7899,6 +7916,10 @@ next_tab = "" code: crate::protocol::ClientKeyCode::Esc, modifiers: 0, kind: crate::protocol::ClientKeyKind::Press, + + repeat_count: 1, + generated_text: None, + source: crate::protocol::ClientKeySource::Synthesized, }], })); @@ -7932,6 +7953,10 @@ next_tab = "" code: crate::protocol::ClientKeyCode::Down, modifiers: 0, kind: crate::protocol::ClientKeyKind::Press, + + repeat_count: 1, + generated_text: None, + source: crate::protocol::ClientKeySource::Synthesized, }], })); diff --git a/tests/api_ping.rs b/tests/api_ping.rs index 344fe6ecd0..1033628a92 100644 --- a/tests/api_ping.rs +++ b/tests/api_ping.rs @@ -304,7 +304,7 @@ fn ping_over_socket_returns_version() { assert_eq!(value["result"]["version"], env!("CARGO_PKG_VERSION")); // Intentionally hardcoded so wire protocol bumps require updating this test. // Changing this value means old clients/servers are no longer compatible. - assert_eq!(value["result"]["protocol"], 18); + assert_eq!(value["result"]["protocol"], 19); cleanup_spawned_herdr(child, base); } diff --git a/tests/cli/sessions.rs b/tests/cli/sessions.rs index b7655542ec..8cb0f4b799 100644 --- a/tests/cli/sessions.rs +++ b/tests/cli/sessions.rs @@ -326,7 +326,7 @@ fn status_commands_report_client_and_server_versions() { "stdout: {full_stdout}" ); assert!( - full_stdout.contains(" protocol: 18"), + full_stdout.contains(" protocol: 19"), "stdout: {full_stdout}" ); assert!(full_stdout.contains("server:\n"), "stdout: {full_stdout}"); @@ -359,7 +359,7 @@ fn status_commands_report_client_and_server_versions() { "stdout: {server_stdout}" ); assert!( - server_stdout.contains("protocol: 18"), + server_stdout.contains("protocol: 19"), "stdout: {server_stdout}" ); @@ -371,7 +371,7 @@ fn status_commands_report_client_and_server_versions() { "stdout: {client_stdout}" ); assert!( - client_stdout.contains("protocol: 18"), + client_stdout.contains("protocol: 19"), "stdout: {client_stdout}" ); assert!( @@ -381,7 +381,7 @@ fn status_commands_report_client_and_server_versions() { let full_json = run_cli_json(&socket_path, &["status", "--json"]); assert_eq!(full_json["client"]["version"], env!("CARGO_PKG_VERSION")); - assert_eq!(full_json["client"]["protocol"], 18); + assert_eq!(full_json["client"]["protocol"], 19); assert_eq!(full_json["server"]["status"], "running"); assert_eq!(full_json["server"]["running"], true); assert_eq!(full_json["server"]["compatible"], true); @@ -395,12 +395,12 @@ fn status_commands_report_client_and_server_versions() { let server_json = run_cli_json(&socket_path, &["status", "server", "--json"]); assert_eq!(server_json["status"], "running"); assert_eq!(server_json["version"], env!("CARGO_PKG_VERSION")); - assert_eq!(server_json["protocol"], 18); + assert_eq!(server_json["protocol"], 19); assert_eq!(server_json["compatible"], true); let client_json = run_cli_json(&socket_path, &["status", "client", "--json"]); assert_eq!(client_json["version"], env!("CARGO_PKG_VERSION")); - assert_eq!(client_json["protocol"], 18); + assert_eq!(client_json["protocol"], 19); assert!(client_json["binary"] .as_str() .is_some_and(|path| !path.is_empty())); diff --git a/tests/support/mod.rs b/tests/support/mod.rs index c964512991..b10e2a93ae 100644 --- a/tests/support/mod.rs +++ b/tests/support/mod.rs @@ -15,7 +15,7 @@ static INIT: Once = Once::new(); static CLEANUP_GUARD: OnceLock = OnceLock::new(); const WATCHDOG_SCAN_INTERVAL: Duration = Duration::from_secs(1); const RUNTIME_OWNER_MARKER: &str = ".herdr-test-owner-pid"; -pub const CURRENT_PROTOCOL: u32 = 18; +pub const CURRENT_PROTOCOL: u32 = 19; pub fn register_spawned_herdr_pid(pid: Option) { let Some(pid) = pid else {