fix(input): preserve native key lifecycle across routing (#2142)
refs #2077 Co-authored-by: Jonathan Liebig <jonathan.liebig@gmail.com>
This commit is contained in:
parent
8d190eddda
commit
b76adc155d
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"protocol": 18,
|
||||
"protocol": 19,
|
||||
"schema_version": 1,
|
||||
"schemas": {
|
||||
"error_response": {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<InputLeaseKey, InputLease>,
|
||||
}
|
||||
|
||||
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<TerminalInputTarget>,
|
||||
) -> 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<ForwardedInputLease> {
|
||||
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<InputLease> {
|
||||
self.leases.remove(key)
|
||||
}
|
||||
|
||||
pub(crate) fn remove_source(&mut self, source_id: InputSourceId) -> Vec<ForwardedInputLease> {
|
||||
let keys = self
|
||||
.leases
|
||||
.keys()
|
||||
.filter(|key| key.source_id == source_id)
|
||||
.copied()
|
||||
.collect::<Vec<_>>();
|
||||
self.remove_keys(keys)
|
||||
}
|
||||
|
||||
pub(crate) fn remove_target(
|
||||
&mut self,
|
||||
target: &TerminalInputTarget,
|
||||
) -> Vec<ForwardedInputLease> {
|
||||
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::<Vec<_>>();
|
||||
self.remove_keys(keys)
|
||||
}
|
||||
|
||||
fn remove_keys(
|
||||
&mut self,
|
||||
keys: impl IntoIterator<Item = InputLeaseKey>,
|
||||
) -> Vec<ForwardedInputLease> {
|
||||
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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<NavigateAction> {
|
||||
non_indexed_action_for_key(state, key, BindingDispatch::Direct)
|
||||
}
|
||||
|
||||
pub(crate) fn terminal_direct_indexed_navigation_action(
|
||||
state: &AppState,
|
||||
key: TerminalKey,
|
||||
key: &TerminalKey,
|
||||
) -> Option<NavigateAction> {
|
||||
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<crate::config::CustomCommandKeybind> {
|
||||
state
|
||||
|
|
@ -1150,7 +1150,7 @@ pub(crate) fn command_for_key(
|
|||
.cloned()
|
||||
}
|
||||
|
||||
fn unmodified_digit_for_key(key: TerminalKey) -> Option<char> {
|
||||
fn unmodified_digit_for_key(key: &TerminalKey) -> Option<char> {
|
||||
('1'..='9').find(|digit| {
|
||||
crate::config::terminal_key_matches_combo(
|
||||
key,
|
||||
|
|
@ -1164,7 +1164,7 @@ fn unmodified_digit_for_key(key: TerminalKey) -> Option<char> {
|
|||
|
||||
#[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<NavigateAction> {
|
||||
fn navigate_reserved_action_for_key(state: &AppState, key: &TerminalKey) -> Option<NavigateAction> {
|
||||
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<NavigateAction> {
|
||||
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<NavigateAction> {
|
||||
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<NavigateAction> {
|
||||
let kb = &state.keybinds;
|
||||
|
|
@ -1536,7 +1541,7 @@ fn navigate_mode_action_for_key(state: &AppState, key: TerminalKey) -> Option<Na
|
|||
|
||||
fn navigate_mode_non_indexed_action_for_key(
|
||||
state: &AppState,
|
||||
key: TerminalKey,
|
||||
key: &TerminalKey,
|
||||
) -> Option<NavigateAction> {
|
||||
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<NavigateAction> {
|
||||
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))
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ impl App {
|
|||
source_id: InputSourceId,
|
||||
key: TerminalKey,
|
||||
) -> Option<TerminalInputTarget> {
|
||||
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<PreparedPaneInput> {
|
||||
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<crate::app::PressedTerminalKey> {
|
||||
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<super::ForwardedInputLease> {
|
||||
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<TerminalInputTarget> {
|
||||
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
|
||||
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,
|
||||
);
|
||||
|
||||
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 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 (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;
|
||||
|
||||
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");
|
||||
assert!(after_up.offset_from_bottom > 0);
|
||||
.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(),
|
||||
));
|
||||
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);
|
||||
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 = 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::empty()));
|
||||
let after_press = 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
|
||||
);
|
||||
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]
|
||||
|
|
|
|||
447
src/app/mod.rs
447
src/app/mod.rs
|
|
@ -136,9 +136,7 @@ pub struct App {
|
|||
pub(crate) detached_custom_command_children: Vec<std::process::Child>,
|
||||
pub(crate) persist_pane_history: bool,
|
||||
pub(crate) last_render_at: Option<Instant>,
|
||||
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<Notify>,
|
||||
pub(crate) render_dirty: Arc<crate::render_signal::RenderSignal>,
|
||||
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<TerminalInputContext> {
|
||||
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<crate::protocol::ClientInputEvent>| {
|
||||
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]
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<crate::protocol::ClientInputEvent> {
|
||||
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] },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Item = WindowsInputRecord>,
|
||||
) -> Vec<crate::protocol::ClientInputEvent> {
|
||||
semantic_only(translate_with_provenance(records))
|
||||
}
|
||||
|
||||
fn semantic_only(
|
||||
events: impl IntoIterator<Item = crate::protocol::ClientInputEvent>,
|
||||
) -> Vec<crate::protocol::ClientInputEvent> {
|
||||
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<Item = WindowsInputRecord>,
|
||||
) -> Vec<crate::protocol::ClientInputEvent> {
|
||||
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::<Vec<_>>(),
|
||||
[(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,35 +1908,123 @@ 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(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_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(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::<Vec<_>>(),
|
||||
[
|
||||
(Press, repeat_count, true, repeat_count),
|
||||
(Release, 1, false, 1)
|
||||
],
|
||||
"repeat_count={repeat_count}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vti_win32_input_mode_sequence_inside_bracketed_paste_stays_payload() {
|
||||
let records = "\x1b[200~alpha\x1b[13;28;13;1;16;1_bravo\x1b[201~"
|
||||
|
|
@ -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,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<usize> {
|
||||
pub fn matched_index(&self, key: &TerminalKey) -> Option<usize> {
|
||||
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<char> {
|
|||
.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]
|
||||
|
|
|
|||
|
|
@ -16,8 +16,10 @@ pub fn encode_key(key: KeyEvent, protocol: KeyboardProtocol) -> Vec<u8> {
|
|||
}
|
||||
|
||||
pub fn encode_terminal_key(key: TerminalKey, protocol: KeyboardProtocol) -> Vec<u8> {
|
||||
if key.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<u8> {
|
|||
|
||||
// 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]
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<String>) -> 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<u8>,
|
||||
},
|
||||
WindowsConsole {
|
||||
record: WindowsKeyRecord,
|
||||
physical_key: Option<PhysicalKeyId>,
|
||||
},
|
||||
}
|
||||
|
||||
impl WindowsKeyRecord {
|
||||
fn physical_key_id(self) -> Option<PhysicalKeyId> {
|
||||
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<u32>,
|
||||
pub is_text_commit: bool,
|
||||
pub generated_text: Option<String>,
|
||||
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<String>) -> 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<u8>) -> 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<WindowsKeyRecord> {
|
||||
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!(
|
||||
|
|
|
|||
|
|
@ -40,13 +40,11 @@ fn parse_kitty_key_sequence(data: &str) -> Option<TerminalKey> {
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
pub(super) fn ghostty_key_event_from_terminal_key(
|
||||
key: crate::input::TerminalKey,
|
||||
key: &crate::input::TerminalKey,
|
||||
) -> Option<crate::ghostty::KeyEvent> {
|
||||
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<String> {
|
||||
fn ghostty_key_text(key: &crate::input::TerminalKey) -> Option<String> {
|
||||
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<String> {
|
|||
}
|
||||
}
|
||||
|
||||
fn ghostty_unshifted_codepoint(key: crate::input::TerminalKey) -> Option<u32> {
|
||||
fn ghostty_unshifted_codepoint(key: &crate::input::TerminalKey) -> Option<u32> {
|
||||
match key.code {
|
||||
crossterm::event::KeyCode::Char(c) => Some(c as u32),
|
||||
_ => None,
|
||||
|
|
|
|||
|
|
@ -1635,22 +1635,40 @@ impl GhosttyPaneTerminal {
|
|||
protocol: crate::input::KeyboardProtocol,
|
||||
) -> Vec<u8> {
|
||||
#[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<u8> {
|
||||
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,6 +4064,20 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
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();
|
||||
|
||||
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);
|
||||
|
|
@ -4012,14 +4085,8 @@ mod tests {
|
|||
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_"
|
||||
);
|
||||
|
||||
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"
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Vec<u8>> {
|
||||
/// Encode native or targeted semantic Win32 input for a compatible ConPTY destination.
|
||||
pub(crate) fn encode_windows_conpty_fallback(key: &crate::input::TerminalKey) -> Option<Vec<u8>> {
|
||||
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];
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
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<u8>,
|
||||
},
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -53,10 +53,10 @@ pub fn parse_raw_input_bytes_with_ranges(data: &[u8]) -> Vec<RawInputEventWithRa
|
|||
if !buffer.is_empty() {
|
||||
if buffer.as_slice() == [ESC] {
|
||||
events.push(RawInputEventWithRange {
|
||||
event: RawInputEvent::Key(TerminalKey::new(
|
||||
crossterm::event::KeyCode::Esc,
|
||||
KeyModifiers::empty(),
|
||||
)),
|
||||
event: RawInputEvent::Key(
|
||||
TerminalKey::new(crossterm::event::KeyCode::Esc, KeyModifiers::empty())
|
||||
.with_vt_bytes(vec![ESC]),
|
||||
),
|
||||
start: offset,
|
||||
len: 1,
|
||||
});
|
||||
|
|
@ -68,7 +68,7 @@ pub fn parse_raw_input_bytes_with_ranges(data: &[u8]) -> Vec<RawInputEventWithRa
|
|||
} else if let Ok(text) = std::str::from_utf8(&buffer) {
|
||||
if let Some(key) = parse_terminal_key_sequence(text) {
|
||||
events.push(RawInputEventWithRange {
|
||||
event: RawInputEvent::Key(key.as_text_commit()),
|
||||
event: RawInputEvent::Key(key.with_text_commit().with_vt_bytes(buffer.clone())),
|
||||
start: offset,
|
||||
len: buffer.len(),
|
||||
});
|
||||
|
|
@ -94,7 +94,7 @@ pub fn parse_raw_input_bytes_sync(data: &[u8]) -> Vec<RawInputEvent> {
|
|||
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<u8>) -> Vec<Vec<u8>> {
|
|||
fn flush_incomplete_buffer(buffer: &mut Vec<u8>, tx: &mpsc::Sender<RawInputEvent>) {
|
||||
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))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}],
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ static INIT: Once = Once::new();
|
|||
static CLEANUP_GUARD: OnceLock<CleanupGuard> = 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<u32>) {
|
||||
let Some(pid) = pid else {
|
||||
|
|
|
|||
Loading…
Reference in New Issue