aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-08-01 04:27:50 +0300
committerGitHub <noreply@github.com>2026-08-01 04:27:50 +0300
commitb76adc155d979705a353fd6dc87c702f9dfc6c22 (patch)
treef613254e0cfbfd2b4450a6a5f3ee7c0ba8136976 /src
parent8d190eddda2c74afa3dc043f8ec8020ffd7b5a94 (diff)
fix(input): preserve native key lifecycle across routing (#2142)
refs #2077 Co-authored-by: Jonathan Liebig <jonathan.liebig@gmail.com>
Diffstat (limited to 'src')
-rw-r--r--src/app/api/panes.rs2
-rw-r--r--src/app/input/clipboard.rs45
-rw-r--r--src/app/input/copy_mode.rs4
-rw-r--r--src/app/input/lease.rs375
-rw-r--r--src/app/input/mod.rs62
-rw-r--r--src/app/input/modal.rs14
-rw-r--r--src/app/input/navigate.rs75
-rw-r--r--src/app/input/terminal.rs390
-rw-r--r--src/app/mod.rs447
-rw-r--r--src/app/popup.rs4
-rw-r--r--src/app/runtime.rs143
-rw-r--r--src/app/state.rs4
-rw-r--r--src/app/worktrees.rs14
-rw-r--r--src/client/input.rs54
-rw-r--r--src/client/input/windows_vti.rs503
-rw-r--r--src/client/mod.rs2
-rw-r--r--src/config/keybinds.rs52
-rw-r--r--src/input/encode.rs18
-rw-r--r--src/input/mod.rs4
-rw-r--r--src/input/model.rs248
-rw-r--r--src/input/parse.rs16
-rw-r--r--src/pane/input.rs8
-rw-r--r--src/pane/terminal.rs112
-rw-r--r--src/platform/windows.rs77
-rw-r--r--src/protocol/wire.rs129
-rw-r--r--src/raw_input.rs38
-rw-r--r--src/server/client_transport.rs108
-rw-r--r--src/server/clients.rs1
-rw-r--r--src/server/headless.rs27
29 files changed, 2469 insertions, 507 deletions
diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs
index 02c9c3d4..86e019ac 100644
--- a/src/app/api/panes.rs
+++ b/src/app/api/panes.rs
@@ -2678,7 +2678,7 @@ mod tests {
rx.try_recv().expect("forwarded release after pane move"),
bytes::Bytes::from_static(b"\x1b[106;1:3u")
);
- assert!(app.pressed_terminal_keys.is_empty());
+ assert!(app.input_leases.is_empty());
}
#[test]
diff --git a/src/app/input/clipboard.rs b/src/app/input/clipboard.rs
index db3fa9f2..62ac1ca9 100644
--- a/src/app/input/clipboard.rs
+++ b/src/app/input/clipboard.rs
@@ -5,7 +5,9 @@ use crate::{
input::TerminalKey,
};
-fn is_retained_selection_copy_key(key: TerminalKey) -> bool {
+use super::{ConsumedInputLease, InputLeaseKey};
+
+fn is_retained_selection_copy_key(key: &TerminalKey) -> bool {
matches!(key.code, KeyCode::Char('c' | 'C'))
&& matches!(key.modifiers, KeyModifiers::CONTROL | KeyModifiers::SUPER)
}
@@ -31,7 +33,7 @@ impl App {
key: TerminalKey,
) -> bool {
if self.state.copy_on_select
- || !is_retained_selection_copy_key(key)
+ || !is_retained_selection_copy_key(&key)
|| !self
.state
.selection
@@ -46,7 +48,10 @@ impl App {
return false;
}
- self.suppressed_repeat_keys.insert((source_id, key.code));
+ self.input_leases.insert_consumed(
+ InputLeaseKey::new(source_id, &key),
+ ConsumedInputLease::SuppressRepeats,
+ );
true
}
}
@@ -132,11 +137,19 @@ mod tests {
assert_visible_selection(&app);
assert!(app.event_rx.try_recv().is_err());
- let ctrl_c = TerminalKey::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
+ let ctrl_c = TerminalKey::new(KeyCode::Char('c'), KeyModifiers::CONTROL)
+ .with_windows_record(crate::input::WindowsKeyRecord {
+ key_down: true,
+ repeat_count: 1,
+ virtual_key_code: 0x43,
+ virtual_scan_code: 0x2e,
+ unicode: 'c' as u16,
+ control_key_state: 0x0008,
+ });
let source_id = 41;
app.route_client_events_from(
source_id,
- vec![crate::raw_input::RawInputEvent::Key(ctrl_c)],
+ vec![crate::raw_input::RawInputEvent::Key(ctrl_c.clone())],
false,
);
@@ -156,29 +169,39 @@ mod tests {
app.route_client_events_from(
source_id,
- vec![crate::raw_input::RawInputEvent::Key(
- ctrl_c.with_kind(KeyEventKind::Repeat),
- )],
+ vec![
+ crate::raw_input::RawInputEvent::Key(ctrl_c.clone()),
+ crate::raw_input::RawInputEvent::Key(
+ ctrl_c.clone().with_kind(KeyEventKind::Repeat),
+ ),
+ ],
false,
);
+ assert_eq!(app.input_leases.len(), 1);
assert!(app.event_rx.try_recv().is_err());
assert!(input_rx.try_recv().is_err());
app.route_client_events_from(
source_id,
vec![crate::raw_input::RawInputEvent::Key(
- ctrl_c.with_kind(KeyEventKind::Release),
+ ctrl_c.clone().with_kind(KeyEventKind::Release),
)],
false,
);
+ assert!(app.input_leases.is_empty());
app.route_client_events_from(
source_id,
- vec![crate::raw_input::RawInputEvent::Key(ctrl_c)],
+ vec![crate::raw_input::RawInputEvent::Key(ctrl_c.clone())],
false,
);
+ let expected = if cfg!(windows) {
+ b"\x1b[67;46;99;1;8;1_".as_slice()
+ } else {
+ b"\x03".as_slice()
+ };
assert_eq!(
input_rx.try_recv().expect("forwarded Ctrl-C").as_ref(),
- b"\x03"
+ expected
);
assert!(app.event_rx.try_recv().is_err());
}
diff --git a/src/app/input/copy_mode.rs b/src/app/input/copy_mode.rs
index 6c967133..b744d2ce 100644
--- a/src/app/input/copy_mode.rs
+++ b/src/app/input/copy_mode.rs
@@ -16,7 +16,7 @@ impl App {
return;
}
self.state.update_dismissed = true;
- if self.state.is_prefix_key(key) {
+ if self.state.is_prefix_key(&key) {
self.state.mode = Mode::Prefix;
return;
}
@@ -80,7 +80,7 @@ impl AppState {
terminal_runtimes: &TerminalRuntimeRegistry,
key: TerminalKey,
) {
- if self.handle_copy_mode_search_prompt_key(terminal_runtimes, key) {
+ if self.handle_copy_mode_search_prompt_key(terminal_runtimes, key.clone()) {
return;
}
match key.code {
diff --git a/src/app/input/lease.rs b/src/app/input/lease.rs
new file mode 100644
index 00000000..de118334
--- /dev/null
+++ b/src/app/input/lease.rs
@@ -0,0 +1,375 @@
+use std::collections::HashMap;
+
+use crate::app::{InputSourceId, TerminalInputContext, TerminalInputTarget};
+use crate::input::{KeyIdentity, TerminalKey};
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
+pub(crate) struct InputLeaseKey {
+ source_id: InputSourceId,
+ identity: KeyIdentity,
+}
+
+impl InputLeaseKey {
+ pub(crate) fn new(source_id: InputSourceId, key: &TerminalKey) -> Self {
+ Self {
+ source_id,
+ identity: key.identity(),
+ }
+ }
+}
+
+#[derive(Debug, PartialEq, Eq)]
+pub(crate) struct ForwardedInputLease {
+ pub(crate) target: TerminalInputTarget,
+ pub(crate) key: TerminalKey,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub(crate) enum ConsumedInputLease {
+ ReprocessRepeats(TerminalInputContext),
+ SuppressRepeats,
+}
+
+#[derive(Debug, PartialEq, Eq)]
+pub(crate) enum InputLease {
+ Forwarded(ForwardedInputLease),
+ Consumed(ConsumedInputLease),
+}
+
+pub(crate) enum RepeatPlan {
+ Forwarded(TerminalInputTarget),
+ Reprocess {
+ context: TerminalInputContext,
+ repetitions: u16,
+ tracked: bool,
+ },
+ Ignore,
+}
+
+#[derive(Default)]
+pub(crate) struct InputLeaseTable {
+ leases: HashMap<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)
+ );
+ }
+}
diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs
index 40bc82fb..f22c7994 100644
--- a/src/app/input/mod.rs
+++ b/src/app/input/mod.rs
@@ -38,6 +38,7 @@ fn modified_url_click_modifier_matches_terminal_mouse_reporting() {
mod clipboard;
mod copy_mode;
+mod lease;
mod modal;
mod mouse;
mod navigate;
@@ -48,6 +49,7 @@ mod sidebar;
mod terminal;
pub(crate) use self::{
+ lease::{ConsumedInputLease, ForwardedInputLease, InputLeaseKey, InputLeaseTable, RepeatPlan},
modal::{
handle_global_menu_key, handle_keybind_help_key, handle_navigator_key,
insert_keybind_help_query_text, insert_navigator_search_text, insert_rename_input_text,
@@ -121,6 +123,66 @@ impl App {
None
}
+ pub(crate) fn handle_text_commit_headless(&mut self, text: &str) {
+ if text.is_empty() {
+ return;
+ }
+ if self.state.popup_pane.is_some() {
+ if let Some(runtime) = self.popup_runtime() {
+ let _ = runtime.try_send_bytes(Bytes::copy_from_slice(text.as_bytes()));
+ } else {
+ self.close_popup_pane();
+ }
+ return;
+ }
+ if self.state.mode != Mode::Terminal {
+ self.paste_into_active_text_input(text);
+ return;
+ }
+
+ self.state.clear_selection();
+ self.selection_autoscroll_deadline = None;
+ self.state.update_dismissed = true;
+ if let Some(ws_idx) = self.state.active {
+ if let Some(runtime) = self
+ .state
+ .focused_runtime_in_workspace(&self.terminal_runtimes, ws_idx)
+ {
+ let _ = runtime.try_send_bytes(Bytes::copy_from_slice(text.as_bytes()));
+ }
+ }
+ }
+
+ pub(super) async fn handle_text_commit(&mut self, text: String) {
+ if text.is_empty() {
+ return;
+ }
+ if self.state.popup_pane.is_some() {
+ if let Some(runtime) = self.popup_runtime() {
+ let _ = runtime.send_bytes(Bytes::from(text)).await;
+ } else {
+ self.close_popup_pane();
+ }
+ return;
+ }
+ if self.state.mode != Mode::Terminal {
+ self.paste_into_active_text_input(&text);
+ return;
+ }
+
+ self.state.clear_selection();
+ self.selection_autoscroll_deadline = None;
+ self.state.update_dismissed = true;
+ if let Some(ws_idx) = self.state.active {
+ if let Some(runtime) = self
+ .state
+ .focused_runtime_in_workspace(&self.terminal_runtimes, ws_idx)
+ {
+ let _ = runtime.send_bytes(Bytes::from(text)).await;
+ }
+ }
+ }
+
pub(super) async fn handle_paste(&mut self, text: String) {
if self.state.popup_pane.is_some() {
if let Some(runtime) = self.popup_runtime() {
diff --git a/src/app/input/modal.rs b/src/app/input/modal.rs
index d8a14602..0a5c13e5 100644
--- a/src/app/input/modal.rs
+++ b/src/app/input/modal.rs
@@ -309,7 +309,7 @@ pub(super) fn keybind_help_back(state: &mut AppState) {
pub(crate) fn handle_keybind_help_key(state: &mut AppState, key: TerminalKey) {
if state.keybind_help.search_focused {
- let text_char = keybind_help_text_char(key);
+ let text_char = keybind_help_text_char(key.clone());
match key.code {
KeyCode::Up => state.scroll_keybind_help(-1),
KeyCode::Down => state.scroll_keybind_help(1),
@@ -343,13 +343,13 @@ pub(crate) fn handle_keybind_help_key(state: &mut AppState, key: TerminalKey) {
KeyCode::PageDown => state.scroll_keybind_help(8),
KeyCode::Home => state.keybind_help.scroll = 0,
KeyCode::End => state.keybind_help.scroll = state.keybind_help_max_scroll(),
- _ if keybind_help_text_char(key) == Some('/') => {
+ _ if keybind_help_text_char(key.clone()) == Some('/') => {
state.keybind_help.search_focused = true;
state.keybind_help.scroll = 0;
}
KeyCode::Esc => keybind_help_back(state),
KeyCode::Enter => leave_modal(state),
- _ if keybind_help_text_char(key) == Some('?') => leave_modal(state),
+ _ if keybind_help_text_char(key.clone()) == Some('?') => leave_modal(state),
_ => {}
}
}
@@ -712,8 +712,8 @@ pub(crate) fn handle_resize_key(state: &mut AppState, raw_key: TerminalKey) {
let key = raw_key.as_key_event();
if key.code == KeyCode::Esc
|| key.code == KeyCode::Enter
- || state.keybinds.resize_mode.matches_prefix_key(raw_key)
- || state.keybinds.resize_mode.matches_direct_key(raw_key)
+ || state.keybinds.resize_mode.matches_prefix_key(&raw_key)
+ || state.keybinds.resize_mode.matches_direct_key(&raw_key)
{
if state.active.is_some() {
state.mode = Mode::Terminal;
@@ -1128,8 +1128,8 @@ impl App {
let key = raw_key.as_key_event();
if key.code == KeyCode::Esc
|| key.code == KeyCode::Enter
- || self.state.keybinds.resize_mode.matches_prefix_key(raw_key)
- || self.state.keybinds.resize_mode.matches_direct_key(raw_key)
+ || self.state.keybinds.resize_mode.matches_prefix_key(&raw_key)
+ || self.state.keybinds.resize_mode.matches_direct_key(&raw_key)
{
self.state.mode = if self.state.active.is_some() {
Mode::Terminal
diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs
index 93dfe77d..6d0f7f22 100644
--- a/src/app/input/navigate.rs
+++ b/src/app/input/navigate.rs
@@ -31,14 +31,14 @@ pub(crate) fn terminal_direct_navigation_action(
pub(crate) fn terminal_direct_non_indexed_navigation_action(
state: &AppState,
- key: TerminalKey,
+ key: &TerminalKey,
) -> Option<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))
);
}
diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs
index 3e99d0fc..f0e10d9c 100644
--- a/src/app/input/terminal.rs
+++ b/src/app/input/terminal.rs
@@ -41,7 +41,7 @@ impl App {
source_id: InputSourceId,
key: TerminalKey,
) -> Option<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
- .state
- .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id)
- .and_then(crate::terminal::TerminalRuntime::scroll_metrics)
- .expect("scroll metrics after PageUp");
- assert!(after_up.offset_from_bottom > 0);
+ let page_up = physical_page_up(1);
+ app.route_client_events(
+ vec![crate::raw_input::RawInputEvent::Key(page_up.clone())],
+ false,
+ );
+ assert!(app.state.focus_pane_in_workspace(0, second_pane));
+ app.route_client_events(
+ vec![crate::raw_input::RawInputEvent::Key(
+ page_up.clone().with_kind(KeyEventKind::Repeat),
+ )],
+ false,
+ );
+ assert!(app.state.focus_pane_in_workspace(0, first_pane));
+ app.route_client_events(
+ vec![
+ crate::raw_input::RawInputEvent::Key(
+ page_up.clone().with_kind(KeyEventKind::Repeat),
+ ),
+ crate::raw_input::RawInputEvent::Key(page_up.with_kind(KeyEventKind::Release)),
+ ],
+ false,
+ );
- app.handle_terminal_key_headless(TerminalKey::new(
- KeyCode::PageDown,
- KeyModifiers::empty(),
- ));
- let after_down = app
- .state
- .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id)
- .and_then(crate::terminal::TerminalRuntime::scroll_metrics)
- .expect("scroll metrics after PageDown");
- assert_eq!(after_down.offset_from_bottom, 0);
+ let first_repeat = second_rx.try_recv().expect("first forwarded repeat");
+ let second_repeat = second_rx.try_recv().expect("owned repeat");
+ let release = second_rx.try_recv().expect("owned release");
+ assert_eq!(first_repeat, second_repeat);
+ assert_ne!(second_repeat, release);
+ assert!(second_rx.try_recv().is_err());
+ assert!(app.input_leases.is_empty());
}
#[tokio::test]
- async fn page_up_release_does_not_scroll_plain_shell_pane_again() {
+ async fn missing_popup_runtime_suppresses_grouped_and_later_repeats() {
let mut app = app_for_mouse_test();
let mut ws = Workspace::test_new("test");
let pane_id = ws.tabs[0].root_pane;
- let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18));
- let info = pane_infos[0].clone();
- ws.tabs[0].runtimes.insert(
- pane_id,
- crate::terminal::TerminalRuntime::test_with_scrollback_bytes(
- info.inner_rect.width,
- info.inner_rect.height,
- 16 * 1024,
- &numbered_lines_bytes(64),
- ),
- );
-
+ let (runtime, mut input_rx) = crate::terminal::TerminalRuntime::test_with_channel(80, 24);
+ ws.tabs[0].runtimes.insert(pane_id, runtime);
app.state.workspaces = vec![ws];
app.state.active = Some(0);
app.state.selected = 0;
app.state.mode = Mode::Terminal;
- app.state.view.pane_infos = pane_infos;
- app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty()));
- let after_press = app
+ let (popup_runtime, _popup_rx) =
+ crate::terminal::TerminalRuntime::test_with_channel(40, 12);
+ app.install_test_popup_runtime(popup_runtime);
+ let popup_terminal_id = app
.state
- .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id)
- .and_then(crate::terminal::TerminalRuntime::scroll_metrics)
- .expect("scroll metrics after PageUp press");
- assert_eq!(
- after_press.offset_from_bottom,
- info.inner_rect.height as usize
+ .popup_pane
+ .as_ref()
+ .expect("popup installed")
+ .terminal_id
+ .clone();
+ app.terminal_runtimes.remove(&popup_terminal_id);
+
+ let key = TerminalKey::new(KeyCode::Char('x'), KeyModifiers::empty()).with_repeat_count(3);
+ app.route_client_events(
+ vec![
+ crate::raw_input::RawInputEvent::Key(key.clone()),
+ crate::raw_input::RawInputEvent::Key(
+ key.clone()
+ .with_kind(KeyEventKind::Repeat)
+ .with_repeat_count(1),
+ ),
+ crate::raw_input::RawInputEvent::Key(key.with_kind(KeyEventKind::Release)),
+ ],
+ false,
);
+ assert!(app.state.popup_pane.is_none());
+ assert!(input_rx.try_recv().is_err());
+ assert!(app.input_leases.is_empty());
+ }
+
+ #[tokio::test]
+ async fn consumed_repeat_stays_suppressed_after_context_returns() {
+ let (mut app, pane_id, _pane_info) = app_with_plain_scrollback(128);
+ let page_up = physical_page_up(1);
+ app.route_client_events(
+ vec![crate::raw_input::RawInputEvent::Key(page_up.clone())],
+ false,
+ );
+ let after_press = pane_scroll_offset(&app, pane_id);
+
+ let (popup_runtime, mut popup_rx) =
+ crate::terminal::TerminalRuntime::test_with_channel(40, 12);
+ app.install_test_popup_runtime(popup_runtime);
+ app.route_client_events(
+ vec![crate::raw_input::RawInputEvent::Key(
+ page_up.clone().with_kind(KeyEventKind::Repeat),
+ )],
+ false,
+ );
+ app.close_popup_pane();
+ app.route_client_events(
+ vec![
+ crate::raw_input::RawInputEvent::Key(
+ page_up.clone().with_kind(KeyEventKind::Repeat),
+ ),
+ crate::raw_input::RawInputEvent::Key(page_up.with_kind(KeyEventKind::Release)),
+ ],
+ false,
+ );
+
+ assert_eq!(pane_scroll_offset(&app, pane_id), after_press);
+ assert!(popup_rx.try_recv().is_err());
+ assert!(app.input_leases.is_empty());
+ }
+
+ #[tokio::test]
+ async fn page_down_returns_to_bottom_after_page_up() {
+ let (mut app, pane_id, _pane_info) = app_with_plain_scrollback(64);
+
+ app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty()));
+ assert!(pane_scroll_offset(&app, pane_id) > 0);
+
+ app.handle_terminal_key_headless(TerminalKey::new(
+ KeyCode::PageDown,
+ KeyModifiers::empty(),
+ ));
+ assert_eq!(pane_scroll_offset(&app, pane_id), 0);
+ }
+
+ #[tokio::test]
+ async fn page_up_release_does_not_scroll_plain_shell_pane_again() {
+ let (mut app, pane_id, pane_info) = app_with_plain_scrollback(64);
+
+ app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty()));
+ let after_press = pane_scroll_offset(&app, pane_id);
+ assert_eq!(after_press, pane_info.inner_rect.height as usize);
+
app.handle_terminal_key_headless(
TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())
.with_kind(KeyEventKind::Release),
);
- let after_release = app
- .state
- .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id)
- .and_then(crate::terminal::TerminalRuntime::scroll_metrics)
- .expect("scroll metrics after PageUp release");
- assert_eq!(
- after_release.offset_from_bottom,
- after_press.offset_from_bottom
- );
+ assert_eq!(pane_scroll_offset(&app, pane_id), after_press);
}
#[tokio::test]
async fn modified_page_up_does_not_host_scroll_plain_shell_pane() {
- let mut app = app_for_mouse_test();
- let mut ws = Workspace::test_new("test");
- let pane_id = ws.tabs[0].root_pane;
- let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18));
- let info = pane_infos[0].clone();
- ws.tabs[0].runtimes.insert(
- pane_id,
- crate::terminal::TerminalRuntime::test_with_scrollback_bytes(
- info.inner_rect.width,
- info.inner_rect.height,
- 16 * 1024,
- &numbered_lines_bytes(64),
- ),
- );
-
- app.state.workspaces = vec![ws];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
- app.state.view.pane_infos = pane_infos;
+ let (mut app, pane_id, _pane_info) = app_with_plain_scrollback(64);
app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::CONTROL));
- let metrics = app
- .state
- .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id)
- .and_then(crate::terminal::TerminalRuntime::scroll_metrics)
- .expect("scroll metrics after modified PageUp");
- assert_eq!(metrics.offset_from_bottom, 0);
+ assert_eq!(pane_scroll_offset(&app, pane_id), 0);
}
#[tokio::test]
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 442df528..2a996685 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -136,9 +136,7 @@ pub struct App {
pub(crate) detached_custom_command_children: Vec<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(),
@@ -4877,6 +5012,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();
app.state.workspaces = vec![Workspace::test_new("test")];
@@ -5105,6 +5261,31 @@ last_pane = "prefix+tab"
}
#[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();
let mut workspace = Workspace::test_new("test");
@@ -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]
diff --git a/src/app/popup.rs b/src/app/popup.rs
index 3356ab14..f3d2e459 100644
--- a/src/app/popup.rs
+++ b/src/app/popup.rs
@@ -26,9 +26,7 @@ impl App {
.direct_attach_resize_locks
.remove(&popup.terminal_id);
self.state.terminals.remove(&popup.terminal_id);
- if let Some(runtime) = self.terminal_runtimes.remove(&popup.terminal_id) {
- runtime.shutdown();
- }
+ self.shutdown_terminal_runtime(popup.terminal_id);
self.state.mode = if self.state.active.is_some() {
Mode::Terminal
} else {
diff --git a/src/app/runtime.rs b/src/app/runtime.rs
index ef0a452d..ef0d1224 100644
--- a/src/app/runtime.rs
+++ b/src/app/runtime.rs
@@ -6,8 +6,8 @@ use std::time::Duration;
use crossterm::terminal;
use super::{
- background_update_check_enabled, pressed_key_identity, App, AUTO_UPDATE_CHECK_INTERVAL,
- MIN_RENDER_INTERVAL, RESIZE_POLL_INTERVAL, SELECTION_AUTOSCROLL_INTERVAL,
+ background_update_check_enabled, App, AUTO_UPDATE_CHECK_INTERVAL, MIN_RENDER_INTERVAL,
+ RESIZE_POLL_INTERVAL, SELECTION_AUTOSCROLL_INTERVAL,
};
fn retain_custom_command_after_wait(
pid: u32,
@@ -30,12 +30,20 @@ impl App {
.retain_mut(|child| retain_custom_command_after_wait(child.id(), child.try_wait()));
}
+ pub(crate) fn shutdown_terminal_runtime(&mut self, terminal_id: crate::terminal::TerminalId) {
+ let target = super::TerminalInputTarget {
+ terminal_id: terminal_id.clone(),
+ };
+ self.release_input_target_headless(&target);
+ if let Some(runtime) = self.terminal_runtimes.remove(&terminal_id) {
+ runtime.shutdown();
+ }
+ }
+
pub(crate) fn shutdown_detached_terminal_runtimes(&mut self) {
let terminal_ids = std::mem::take(&mut self.state.terminal_runtime_shutdowns);
for terminal_id in terminal_ids {
- if let Some(runtime) = self.terminal_runtimes.remove(&terminal_id) {
- runtime.shutdown();
- }
+ self.shutdown_terminal_runtime(terminal_id);
}
}
@@ -103,6 +111,65 @@ impl App {
changed
}
+ async fn execute_repeat_plan(
+ &mut self,
+ lease_key: super::input::InputLeaseKey,
+ key: crate::input::TerminalKey,
+ plan: super::input::RepeatPlan,
+ ) -> bool {
+ match plan {
+ super::input::RepeatPlan::Forwarded(target) => {
+ if !self.forward_terminal_key_to_target(&target, key).await {
+ self.input_leases.remove(&lease_key);
+ }
+ true
+ }
+ super::input::RepeatPlan::Reprocess {
+ context,
+ repetitions,
+ tracked,
+ } => {
+ let key = key
+ .with_kind(crossterm::event::KeyEventKind::Repeat)
+ .with_repeat_count(1);
+ let mut forwarded_target = None;
+ for _ in 0..repetitions {
+ if let Some(target) = &forwarded_target {
+ if !self
+ .forward_terminal_key_to_target(target, key.clone())
+ .await
+ {
+ self.input_leases.remove(&lease_key);
+ break;
+ }
+ continue;
+ }
+ let current_context = self.terminal_input_context();
+ if !self.input_leases.reprocess_allowed(
+ lease_key,
+ &context,
+ current_context.as_ref(),
+ tracked,
+ ) {
+ break;
+ }
+ if let Some(target) = self.handle_key(key.clone()).await {
+ if tracked {
+ self.input_leases.insert_forwarded(
+ lease_key,
+ target.clone(),
+ key.clone(),
+ );
+ forwarded_target = Some(target);
+ }
+ }
+ }
+ true
+ }
+ super::input::RepeatPlan::Ignore => false,
+ }
+ }
+
pub(super) async fn handle_raw_input_event(
&mut self,
event: crate::raw_input::RawInputEvent,
@@ -110,60 +177,46 @@ impl App {
let previous_mode = self.state.mode;
let changed = match event {
crate::raw_input::RawInputEvent::Key(key) => {
- let pressed_key_id = pressed_key_identity(super::LOCAL_INPUT_SOURCE, &key);
+ let lease_key = super::input::InputLeaseKey::new(super::LOCAL_INPUT_SOURCE, &key);
+ let key = self.input_leases.normalize_press(&lease_key, key);
match key.kind {
crossterm::event::KeyEventKind::Press => {
- if self.state.popup_pane.is_some()
- || self.state.mode == crate::app::Mode::Terminal
- {
- self.suppressed_repeat_keys.remove(&pressed_key_id);
- } else {
- self.suppressed_repeat_keys.insert(pressed_key_id);
- }
- if let Some(target) = self.handle_key(key).await {
- if !key.is_text_commit {
- self.pressed_terminal_keys.insert(
- pressed_key_id,
- super::PressedTerminalKey { target, key },
- );
- }
- } else {
- self.pressed_terminal_keys.remove(&pressed_key_id);
- }
+ let initial_context = self.terminal_input_context();
+ let target = self.handle_key(key.clone()).await;
+ let resulting_context = self.terminal_input_context();
+ let plan = self.input_leases.complete_press(
+ lease_key,
+ &key,
+ initial_context.as_ref(),
+ resulting_context.as_ref(),
+ target,
+ );
+ self.execute_repeat_plan(lease_key, key, plan).await;
true
}
crossterm::event::KeyEventKind::Repeat => {
- if let Some(pressed) =
- self.pressed_terminal_keys.get(&pressed_key_id).cloned()
- {
- if !self
- .forward_terminal_key_to_target(&pressed.target, key)
- .await
- {
- self.pressed_terminal_keys.remove(&pressed_key_id);
- }
- true
- } else if (self.state.popup_pane.is_some()
- || self.state.mode == crate::app::Mode::Terminal)
- && !self.suppressed_repeat_keys.contains(&pressed_key_id)
- {
- self.handle_key(key).await;
- true
- } else {
- false
- }
+ let current_context = self.terminal_input_context();
+ let plan = self.input_leases.plan_repeat(
+ lease_key,
+ &key,
+ current_context.as_ref(),
+ );
+ self.execute_repeat_plan(lease_key, key, plan).await
}
crossterm::event::KeyEventKind::Release => {
- self.suppressed_repeat_keys.remove(&pressed_key_id);
- if let Some(pressed) = self.pressed_terminal_keys.remove(&pressed_key_id) {
+ if let Some(lease) = self.input_leases.remove_forwarded(&lease_key) {
let _ = self
- .forward_terminal_key_to_target(&pressed.target, key)
+ .forward_terminal_key_to_target(&lease.target, key)
.await;
}
false
}
}
}
+ crate::raw_input::RawInputEvent::Text(text) => {
+ self.handle_text_commit(text.into_string()).await;
+ true
+ }
crate::raw_input::RawInputEvent::Paste(text) => {
self.handle_paste(text).await;
true
diff --git a/src/app/state.rs b/src/app/state.rs
index 602bd9a4..ef903f65 100644
--- a/src/app/state.rs
+++ b/src/app/state.rs
@@ -1645,7 +1645,7 @@ impl AppState {
|| self.focused_pane_requests_mouse_capture_from(terminal_runtimes)
}
- pub fn is_prefix_key(&self, key: crate::input::TerminalKey) -> bool {
+ pub fn is_prefix_key(&self, key: &crate::input::TerminalKey) -> bool {
crate::config::terminal_key_matches_combo(key, (self.prefix_code, self.prefix_mods))
}
@@ -1742,7 +1742,7 @@ pub fn key_matches(
expected_mods: KeyModifiers,
) -> bool {
crate::config::terminal_key_matches_combo(
- crate::input::TerminalKey::from(*key),
+ &crate::input::TerminalKey::from(*key),
(expected_code, expected_mods),
)
}
diff --git a/src/app/worktrees.rs b/src/app/worktrees.rs
index 23e17b4d..0087c8fe 100644
--- a/src/app/worktrees.rs
+++ b/src/app/worktrees.rs
@@ -997,14 +997,12 @@ impl App {
ws_idx: usize,
) {
for terminal_id in self.state.terminal_ids_for_workspace(ws_idx) {
- if let Some(runtime) = self.terminal_runtimes.remove(&terminal_id) {
- tracing::debug!(
- workspace_index = ws_idx,
- terminal_id = %terminal_id,
- "shutting down terminal runtime before worktree removal"
- );
- runtime.shutdown();
- }
+ tracing::debug!(
+ workspace_index = ws_idx,
+ terminal_id = %terminal_id,
+ "shutting down terminal runtime before worktree removal"
+ );
+ self.shutdown_terminal_runtime(terminal_id);
}
}
}
diff --git a/src/client/input.rs b/src/client/input.rs
index 28d38bda..09e0eb92 100644
--- a/src/client/input.rs
+++ b/src/client/input.rs
@@ -281,7 +281,16 @@ fn windows_crossterm_input_event(
code: crate::protocol::ClientKeyCode::Char(codepoint),
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Press,
- } => Some(crate::protocol::ClientInputEvent::Text { codepoint }),
+ source,
+ ..
+ } => Some(crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Char(codepoint),
+ modifiers: 0,
+ kind: crate::protocol::ClientKeyKind::Press,
+ repeat_count: 1,
+ generated_text: Some(codepoint.to_string()),
+ source,
+ }),
event => Some(event),
}
}
@@ -368,20 +377,29 @@ fn windows_client_input_event_from_raw(
event: crate::raw_input::RawInputEvent,
) -> Option<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] },
}
);
}
diff --git a/src/client/input/windows_vti.rs b/src/client/input/windows_vti.rs
index 8966112e..c95cbc43 100644
--- a/src/client/input/windows_vti.rs
+++ b/src/client/input/windows_vti.rs
@@ -9,6 +9,7 @@ use tokio::sync::mpsc;
use super::windows_client_input_event_from_raw;
#[cfg(windows)]
use super::ClientLoopEvent;
+use crate::input::WindowsKeyRecord;
#[cfg(windows)]
pub(super) fn raw_console_reader_loop(
@@ -165,16 +166,6 @@ enum WindowsInputRecord {
}
#[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,
y: 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,
})
@@ -1115,6 +1163,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
.into_iter()
@@ -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,48 +1484,87 @@ 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!(
translate([key_vk_with_utf16_mods(0x1b, 0, 0x0010)]),
@@ -1427,6 +1572,10 @@ mod tests {
code: crate::protocol::ClientKeyCode::Esc,
modifiers: crossterm::event::KeyModifiers::SHIFT.bits(),
kind: crate::protocol::ClientKeyKind::Press,
+
+ repeat_count: 1,
+ generated_text: None,
+ source: crate::protocol::ClientKeySource::Synthesized,
}]
);
}
@@ -1445,6 +1594,10 @@ mod tests {
code: crate::protocol::ClientKeyCode::Enter,
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Press,
+
+ repeat_count: 1,
+ generated_text: None,
+ source: crate::protocol::ClientKeySource::Synthesized,
}]
);
}
@@ -1453,23 +1606,14 @@ mod tests {
fn vti_repeated_virtual_key_records_emit_repeats() {
assert_eq!(
translate([key_vk_with_repeat(0x08, 3)]),
- vec![
- crate::protocol::ClientInputEvent::Key {
- code: crate::protocol::ClientKeyCode::Backspace,
- modifiers: 0,
- kind: crate::protocol::ClientKeyKind::Press,
- },
- crate::protocol::ClientInputEvent::Key {
- code: crate::protocol::ClientKeyCode::Backspace,
- modifiers: 0,
- kind: crate::protocol::ClientKeyKind::Repeat,
- },
- crate::protocol::ClientInputEvent::Key {
- code: crate::protocol::ClientKeyCode::Backspace,
- modifiers: 0,
- kind: crate::protocol::ClientKeyKind::Repeat,
- },
- ]
+ vec![crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Backspace,
+ modifiers: 0,
+ kind: crate::protocol::ClientKeyKind::Press,
+ repeat_count: 3,
+ generated_text: None,
+ source: crate::protocol::ClientKeySource::Synthesized,
+ }]
);
}
@@ -1481,6 +1625,10 @@ mod tests {
code: crate::protocol::ClientKeyCode::Enter,
modifiers: crossterm::event::KeyModifiers::SHIFT.bits(),
kind: crate::protocol::ClientKeyKind::Press,
+
+ repeat_count: 1,
+ generated_text: None,
+ source: crate::protocol::ClientKeySource::Synthesized,
}]
);
}
@@ -1493,6 +1641,10 @@ mod tests {
code: crate::protocol::ClientKeyCode::Char('j'),
modifiers: crossterm::event::KeyModifiers::CONTROL.bits(),
kind: crate::protocol::ClientKeyKind::Release,
+
+ repeat_count: 1,
+ generated_text: None,
+ source: crate::protocol::ClientKeySource::Synthesized,
}]
);
}
@@ -1505,6 +1657,10 @@ mod tests {
code: crate::protocol::ClientKeyCode::Enter,
modifiers: crossterm::event::KeyModifiers::SHIFT.bits(),
kind: crate::protocol::ClientKeyKind::Press,
+
+ repeat_count: 1,
+ generated_text: None,
+ source: crate::protocol::ClientKeySource::Synthesized,
}]
);
}
@@ -1518,16 +1674,28 @@ mod tests {
code: crate::protocol::ClientKeyCode::Enter,
modifiers: crossterm::event::KeyModifiers::SHIFT.bits(),
kind: crate::protocol::ClientKeyKind::Press,
+
+ repeat_count: 1,
+ generated_text: None,
+ source: crate::protocol::ClientKeySource::Synthesized,
},
crate::protocol::ClientInputEvent::Key {
code: crate::protocol::ClientKeyCode::Enter,
modifiers: crossterm::event::KeyModifiers::SHIFT.bits(),
kind: crate::protocol::ClientKeyKind::Repeat,
+
+ repeat_count: 1,
+ generated_text: None,
+ source: crate::protocol::ClientKeySource::Synthesized,
},
crate::protocol::ClientInputEvent::Key {
code: crate::protocol::ClientKeyCode::Enter,
modifiers: crossterm::event::KeyModifiers::SHIFT.bits(),
kind: crate::protocol::ClientKeyKind::Repeat,
+
+ repeat_count: 1,
+ generated_text: None,
+ source: crate::protocol::ClientKeySource::Synthesized,
},
]
);
@@ -1589,11 +1757,19 @@ mod tests {
code: crate::protocol::ClientKeyCode::Enter,
modifiers: crossterm::event::KeyModifiers::SHIFT.bits(),
kind: crate::protocol::ClientKeyKind::Press,
+
+ repeat_count: 1,
+ generated_text: None,
+ source: crate::protocol::ClientKeySource::Synthesized,
},
crate::protocol::ClientInputEvent::Key {
code: crate::protocol::ClientKeyCode::Enter,
modifiers: crossterm::event::KeyModifiers::SHIFT.bits(),
kind: crate::protocol::ClientKeyKind::Release,
+
+ repeat_count: 1,
+ generated_text: None,
+ source: crate::protocol::ClientKeySource::Synthesized,
},
]
);
@@ -1612,11 +1788,19 @@ mod tests {
code: crate::protocol::ClientKeyCode::Enter,
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Press,
+
+ repeat_count: 1,
+ generated_text: None,
+ source: crate::protocol::ClientKeySource::Synthesized,
},
crate::protocol::ClientInputEvent::Key {
code: crate::protocol::ClientKeyCode::Enter,
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Release,
+
+ repeat_count: 1,
+ generated_text: None,
+ source: crate::protocol::ClientKeySource::Synthesized,
},
]
);
@@ -1633,11 +1817,19 @@ mod tests {
code: crate::protocol::ClientKeyCode::Backspace,
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Press,
+
+ repeat_count: 1,
+ generated_text: None,
+ source: crate::protocol::ClientKeySource::Synthesized,
},
crate::protocol::ClientInputEvent::Key {
code: crate::protocol::ClientKeyCode::Backspace,
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Release,
+
+ repeat_count: 1,
+ generated_text: None,
+ source: crate::protocol::ClientKeySource::Synthesized,
},
]
);
@@ -1656,11 +1848,19 @@ mod tests {
code: crate::protocol::ClientKeyCode::Char('j'),
modifiers: crossterm::event::KeyModifiers::CONTROL.bits(),
kind: crate::protocol::ClientKeyKind::Press,
+
+ repeat_count: 1,
+ generated_text: None,
+ source: crate::protocol::ClientKeySource::Synthesized,
},
crate::protocol::ClientInputEvent::Key {
code: crate::protocol::ClientKeyCode::Char('j'),
modifiers: crossterm::event::KeyModifiers::CONTROL.bits(),
kind: crate::protocol::ClientKeyKind::Release,
+
+ repeat_count: 1,
+ generated_text: None,
+ source: crate::protocol::ClientKeySource::Synthesized,
},
]
);
@@ -1678,11 +1878,19 @@ mod tests {
code: crate::protocol::ClientKeyCode::Char('j'),
modifiers: crossterm::event::KeyModifiers::CONTROL.bits(),
kind: crate::protocol::ClientKeyKind::Press,
+
+ repeat_count: 1,
+ generated_text: None,
+ source: crate::protocol::ClientKeySource::Synthesized,
},
crate::protocol::ClientInputEvent::Key {
code: crate::protocol::ClientKeyCode::Char('j'),
modifiers: crossterm::event::KeyModifiers::CONTROL.bits(),
kind: crate::protocol::ClientKeyKind::Release,
+
+ repeat_count: 1,
+ generated_text: None,
+ source: crate::protocol::ClientKeySource::Synthesized,
},
]
);
@@ -1700,33 +1908,121 @@ mod tests {
code: crate::protocol::ClientKeyCode::Enter,
modifiers: crossterm::event::KeyModifiers::CONTROL.bits(),
kind: crate::protocol::ClientKeyKind::Press,
+
+ repeat_count: 1,
+ generated_text: None,
+ source: crate::protocol::ClientKeySource::Synthesized,
},
crate::protocol::ClientInputEvent::Key {
code: crate::protocol::ClientKeyCode::Enter,
modifiers: crossterm::event::KeyModifiers::CONTROL.bits(),
kind: crate::protocol::ClientKeyKind::Release,
+
+ repeat_count: 1,
+ generated_text: None,
+ source: crate::protocol::ClientKeySource::Synthesized,
},
]
);
}
#[test]
- fn vti_win32_input_mode_printable_key_becomes_char() {
- let records = "\x1b[65;30;97;1;0;1_\x1b[65;30;97;0;0;1_"
- .chars()
- .map(key_char);
+ fn vti_special_key_does_not_emit_incidental_printable_text() {
+ assert_eq!(
+ translate_with_provenance([WindowsInputRecord::Key(WindowsKeyRecord {
+ key_down: true,
+ repeat_count: 0,
+ virtual_key_code: 0x0d,
+ virtual_scan_code: 0,
+ unicode: b'a'.into(),
+ control_key_state: 0,
+ })]),
+ vec![crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Enter,
+ modifiers: 0,
+ kind: crate::protocol::ClientKeyKind::Press,
+ repeat_count: 1,
+ generated_text: None,
+ source: crate::protocol::ClientKeySource::Synthesized,
+ }]
+ );
+ }
+ #[test]
+ fn vti_win32_input_mode_printable_keys_preserve_physical_records() {
assert_eq!(
- translate(records),
- vec![
- crate::protocol::ClientInputEvent::Text { codepoint: 'a' },
- crate::protocol::ClientInputEvent::Key {
- code: crate::protocol::ClientKeyCode::Char('a'),
- modifiers: 0,
- kind: crate::protocol::ClientKeyKind::Release,
- },
- ]
+ translate(win32_input_mode_encoded_record(WindowsKeyRecord {
+ key_down: true,
+ repeat_count: 1,
+ virtual_key_code: 0,
+ virtual_scan_code: 0,
+ unicode: b'a'.into(),
+ control_key_state: 0,
+ })),
+ vec![crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Char('a'),
+ modifiers: 0,
+ kind: crate::protocol::ClientKeyKind::Press,
+ repeat_count: 1,
+ generated_text: Some("a".into()),
+ source: crate::protocol::ClientKeySource::Synthesized,
+ }]
);
+
+ use crate::protocol::ClientKeyKind::{Press, Release};
+ for repeat_count in [1, 3] {
+ let mut records = win32_input_mode_encoded_record(WindowsKeyRecord {
+ key_down: true,
+ repeat_count,
+ virtual_key_code: 0x41,
+ virtual_scan_code: 30,
+ unicode: b'a'.into(),
+ control_key_state: 0,
+ });
+ records.extend(win32_input_mode_encoded_record(WindowsKeyRecord {
+ key_down: false,
+ repeat_count: 1,
+ virtual_key_code: 0x41,
+ virtual_scan_code: 30,
+ unicode: b'a'.into(),
+ control_key_state: 0,
+ }));
+
+ assert_eq!(
+ translate_with_provenance(records)
+ .iter()
+ .map(|event| match event {
+ crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Char('a'),
+ modifiers: 0,
+ kind,
+ repeat_count: event_repeat_count,
+ generated_text: None,
+ source:
+ crate::protocol::ClientKeySource::WindowsConsole {
+ record:
+ WindowsKeyRecord {
+ repeat_count: record_repeat_count,
+ virtual_key_code: 0x41,
+ virtual_scan_code: 30,
+ unicode,
+ control_key_state: 0,
+ key_down,
+ },
+ },
+ } if *unicode == u16::from(b'a') => {
+ (*kind, *event_repeat_count, *key_down, *record_repeat_count)
+ }
+ event => panic!("unexpected printable key event: {event:?}"),
+ })
+ .collect::<Vec<_>>(),
+ [
+ (Press, repeat_count, true, repeat_count),
+ (Release, 1, false, 1)
+ ],
+ "repeat_count={repeat_count}"
+ );
+ }
}
#[test]
@@ -1817,20 +2113,33 @@ mod tests {
code: crate::protocol::ClientKeyCode::Esc,
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Press,
+ repeat_count: 1,
+ generated_text: None,
+ source: crate::protocol::ClientKeySource::WindowsConsole {
+ record: WindowsKeyRecord {
+ key_down: true,
+ repeat_count: 1,
+ virtual_key_code: 0x1b,
+ virtual_scan_code: 0x02,
+ unicode: 0,
+ control_key_state: 0,
+ }
+ },
}]
);
}
#[test]
fn vti_win32_input_mode_physical_escape_is_immediately_semantic() {
- let records = win32_input_mode_encoded_record(WindowsKeyRecord {
+ let record = WindowsKeyRecord {
key_down: true,
repeat_count: 1,
virtual_key_code: 0x1b,
virtual_scan_code: 0x01,
unicode: 0x1b,
control_key_state: 0,
- });
+ };
+ let records = win32_input_mode_encoded_record(record);
let mut translator = WindowsInputTranslator::default();
assert_eq!(
@@ -1842,6 +2151,9 @@ mod tests {
code: crate::protocol::ClientKeyCode::Esc,
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Press,
+ repeat_count: 1,
+ generated_text: None,
+ source: crate::protocol::ClientKeySource::WindowsConsole { record },
}]
);
}
@@ -1868,6 +2180,10 @@ mod tests {
code: crate::protocol::ClientKeyCode::Up,
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Press,
+
+ repeat_count: 1,
+ generated_text: None,
+ source: crate::protocol::ClientKeySource::Synthesized,
}]
);
}
@@ -1882,6 +2198,10 @@ mod tests {
code: crate::protocol::ClientKeyCode::Esc,
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Press,
+
+ repeat_count: 1,
+ generated_text: None,
+ source: crate::protocol::ClientKeySource::Vt { bytes: vec![0x1b] },
}]
);
}
@@ -1897,11 +2217,19 @@ mod tests {
code: crate::protocol::ClientKeyCode::Esc,
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Press,
+
+ repeat_count: 1,
+ generated_text: None,
+ source: crate::protocol::ClientKeySource::Vt { bytes: vec![0x1b] },
},
crate::protocol::ClientInputEvent::Key {
code: crate::protocol::ClientKeyCode::Enter,
modifiers: crossterm::event::KeyModifiers::SHIFT.bits(),
kind: crate::protocol::ClientKeyKind::Press,
+
+ repeat_count: 1,
+ generated_text: None,
+ source: crate::protocol::ClientKeySource::Synthesized,
},
]
);
@@ -1912,17 +2240,25 @@ mod tests {
let mut translator = WindowsInputTranslator::default();
assert!(translator.translate(key_char('\x1b')).is_empty());
assert_eq!(
- translator.translate(key_vk(0x26, 0)),
+ semantic_only(translator.translate(key_vk(0x26, 0))),
vec![
crate::protocol::ClientInputEvent::Key {
code: crate::protocol::ClientKeyCode::Esc,
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Press,
+
+ repeat_count: 1,
+ generated_text: None,
+ source: crate::protocol::ClientKeySource::Synthesized,
},
crate::protocol::ClientInputEvent::Key {
code: crate::protocol::ClientKeyCode::Up,
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Press,
+
+ repeat_count: 1,
+ generated_text: None,
+ source: crate::protocol::ClientKeySource::Synthesized,
},
]
);
@@ -1936,6 +2272,10 @@ mod tests {
code: crate::protocol::ClientKeyCode::Char('c'),
modifiers: crossterm::event::KeyModifiers::CONTROL.bits(),
kind: crate::protocol::ClientKeyKind::Press,
+
+ repeat_count: 1,
+ generated_text: None,
+ source: crate::protocol::ClientKeySource::Synthesized,
}]
);
}
@@ -1948,6 +2288,10 @@ mod tests {
code: crate::protocol::ClientKeyCode::Up,
modifiers: crossterm::event::KeyModifiers::ALT.bits(),
kind: crate::protocol::ClientKeyKind::Press,
+
+ repeat_count: 1,
+ generated_text: None,
+ source: crate::protocol::ClientKeySource::Synthesized,
}]
);
}
@@ -1960,6 +2304,10 @@ mod tests {
code: crate::protocol::ClientKeyCode::Char('@'),
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Press,
+
+ repeat_count: 1,
+ generated_text: None,
+ source: crate::protocol::ClientKeySource::Synthesized,
}]
);
}
@@ -1972,6 +2320,9 @@ mod tests {
code: crate::protocol::ClientKeyCode::Char('é'),
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Press,
+ repeat_count: 1,
+ generated_text: Some("é".into()),
+ source: crate::protocol::ClientKeySource::Synthesized,
}]
);
}
@@ -1989,6 +2340,10 @@ mod tests {
code: crate::protocol::ClientKeyCode::Char('🙂'),
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Press,
+
+ repeat_count: 1,
+ generated_text: None,
+ source: crate::protocol::ClientKeySource::Synthesized,
}]
);
}
diff --git a/src/client/mod.rs b/src/client/mod.rs
index 52fd6dd6..d7fcdf71 100644
--- a/src/client/mod.rs
+++ b/src/client/mod.rs
@@ -1841,7 +1841,7 @@ fn should_bridge_clipboard_image_paste(
events.as_slice(),
[crate::raw_input::RawInputEvent::Key(key)]
if key.kind == crossterm::event::KeyEventKind::Press
- && crate::config::terminal_key_matches_combo(*key, remote_image_paste_key)
+ && crate::config::terminal_key_matches_combo(key, remote_image_paste_key)
)
}
diff --git a/src/config/keybinds.rs b/src/config/keybinds.rs
index 02046ee3..199076f0 100644
--- a/src/config/keybinds.rs
+++ b/src/config/keybinds.rs
@@ -158,7 +158,7 @@ impl ResolvedBinding {
key_event_matches_combo(key, self.trigger.combo())
}
- fn matches_terminal_key(&self, key: TerminalKey) -> bool {
+ fn matches_terminal_key(&self, key: &TerminalKey) -> bool {
terminal_key_matches_combo(key, self.trigger.combo())
}
}
@@ -207,13 +207,13 @@ impl ActionKeybinds {
.any(|binding| binding.trigger.is_prefix() && binding.matches_key_event(key))
}
- pub fn matches_prefix_key(&self, key: TerminalKey) -> bool {
+ pub fn matches_prefix_key(&self, key: &TerminalKey) -> bool {
self.bindings
.iter()
.any(|binding| binding.trigger.is_prefix() && binding.matches_terminal_key(key))
}
- pub fn matches_direct_key(&self, key: TerminalKey) -> bool {
+ pub fn matches_direct_key(&self, key: &TerminalKey) -> bool {
self.bindings
.iter()
.any(|binding| binding.trigger.is_direct() && binding.matches_terminal_key(key))
@@ -263,7 +263,7 @@ pub struct IndexedKeybind {
}
impl IndexedKeybind {
- pub fn matched_index(&self, key: TerminalKey) -> Option<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]
diff --git a/src/input/encode.rs b/src/input/encode.rs
index cd59b0f4..c475e0da 100644
--- a/src/input/encode.rs
+++ b/src/input/encode.rs
@@ -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]
diff --git a/src/input/mod.rs b/src/input/mod.rs
index 3882b035..0a716f9c 100644
--- a/src/input/mod.rs
+++ b/src/input/mod.rs
@@ -9,7 +9,7 @@ pub use encode::{
#[cfg(not(windows))]
pub use model::ime_compatible_keyboard_enhancement_flags;
pub use model::{
- host_modify_other_keys_mode, KeyboardProtocol, MouseProtocolEncoding, MouseProtocolMode,
- TerminalKey,
+ host_modify_other_keys_mode, KeyIdentity, KeyboardProtocol, MouseProtocolEncoding,
+ MouseProtocolMode, TerminalKey, TextCommit, WindowsKeyRecord,
};
pub use parse::parse_terminal_key_sequence;
diff --git a/src/input/model.rs b/src/input/model.rs
index f6072bc6..9c3c14e7 100644
--- a/src/input/model.rs
+++ b/src/input/model.rs
@@ -3,13 +3,77 @@ use crossterm::event::KeyboardEnhancementFlags;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use serde::{Deserialize, Serialize};
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub struct WindowsKeyRecord {
+ pub key_down: bool,
+ pub repeat_count: u16,
+ pub virtual_key_code: u16,
+ pub virtual_scan_code: u16,
+ pub unicode: u16,
+ pub control_key_state: u32,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct TextCommit {
+ text: String,
+}
+
+impl TextCommit {
+ pub fn new(text: impl Into<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)
}
}
@@ -169,6 +324,83 @@ 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!(
KeyboardProtocol::from_kitty_flags(0),
diff --git a/src/input/parse.rs b/src/input/parse.rs
index e4c554f8..b0420816 100644
--- a/src/input/parse.rs
+++ b/src/input/parse.rs
@@ -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,
diff --git a/src/pane/input.rs b/src/pane/input.rs
index 6906e77d..38e8138b 100644
--- a/src/pane/input.rs
+++ b/src/pane/input.rs
@@ -1,5 +1,5 @@
pub(super) fn ghostty_key_event_from_terminal_key(
- key: crate::input::TerminalKey,
+ key: &crate::input::TerminalKey,
) -> Option<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,
diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs
index 5a2180bf..3f8d8aea 100644
--- a/src/pane/terminal.rs
+++ b/src/pane/terminal.rs
@@ -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,11 +3909,51 @@ 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);
let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap();
@@ -3933,9 +3992,9 @@ mod tests {
crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::SHIFT,
);
- let before = pane.encode_terminal_key(key, crate::input::KeyboardProtocol::Legacy);
+ let before = pane.encode_terminal_key(key.clone(), crate::input::KeyboardProtocol::Legacy);
pane.process_pty_bytes(pane_id, 0, b"\x1b[>1u", &tx);
- let after = pane.encode_terminal_key(key, crate::input::KeyboardProtocol::Legacy);
+ let after = pane.encode_terminal_key(key.clone(), crate::input::KeyboardProtocol::Legacy);
assert_ne!(before, after);
assert_eq!(after, b"\x1b[13;6u");
@@ -3950,7 +4009,7 @@ mod tests {
pane.process_pty_bytes(pane_id, 0, b"\x1b[>5u", &tx);
let key = crate::input::parse_terminal_key_sequence("\x1b[13;2u").unwrap();
- let encoded = pane.encode_terminal_key(key, crate::input::KeyboardProtocol::Legacy);
+ let encoded = pane.encode_terminal_key(key.clone(), crate::input::KeyboardProtocol::Legacy);
assert_eq!(
pane.keyboard_protocol(),
@@ -3968,7 +4027,7 @@ mod tests {
pane.seed_keyboard_protocol_flags(5);
let key = crate::input::parse_terminal_key_sequence("\x1b[13;2u").unwrap();
- let encoded = pane.encode_terminal_key(key, crate::input::KeyboardProtocol::Legacy);
+ let encoded = pane.encode_terminal_key(key.clone(), crate::input::KeyboardProtocol::Legacy);
assert_eq!(
pane.keyboard_protocol(),
@@ -4005,21 +4064,29 @@ mod tests {
);
}
+ #[cfg(windows)]
#[test]
- fn ghostty_modify_other_keys_mode_one_preserves_shift_enter() {
+ fn windows_ghostty_default_pane_preserves_synthesized_shift_enter_fallback() {
let (tx, _rx) = mpsc::channel(4);
let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap();
let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
let key = crate::input::parse_terminal_key_sequence("\x1b[13;2u").unwrap();
- #[cfg(windows)]
assert_eq!(
pane.encode_terminal_key(key, crate::input::KeyboardProtocol::Legacy),
b"\x1b[13;28;13;1;16;1_"
);
+ }
+
+ #[test]
+ fn ghostty_modify_other_keys_mode_one_preserves_shift_enter() {
+ let (tx, _rx) = mpsc::channel(4);
+ let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap();
+ let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
+ let key = crate::input::parse_terminal_key_sequence("\x1b[13;2u").unwrap();
pane.seed_history_ansi("\x1b[>4;1m");
- let encoded = pane.encode_terminal_key(key, crate::input::KeyboardProtocol::Legacy);
+ let encoded = pane.encode_terminal_key(key.clone(), crate::input::KeyboardProtocol::Legacy);
assert_eq!(encoded, b"\x1b[27;2;13~");
}
@@ -4033,7 +4100,7 @@ mod tests {
pane.process_pty_bytes(pane_id, 0, b"\x1b[>1u", &tx);
let key = crate::input::parse_terminal_key_sequence("\x1b\x7f").unwrap();
- let encoded = pane.encode_terminal_key(key, crate::input::KeyboardProtocol::Legacy);
+ let encoded = pane.encode_terminal_key(key.clone(), crate::input::KeyboardProtocol::Legacy);
assert_eq!(encoded, b"\x1b[127;3u");
}
@@ -4052,7 +4119,10 @@ mod tests {
crossterm::event::KeyModifiers::CONTROL,
);
assert_eq!(
- legacy.encode_terminal_key(ctrl_backspace, crate::input::KeyboardProtocol::Legacy),
+ legacy.encode_terminal_key(
+ ctrl_backspace.clone(),
+ crate::input::KeyboardProtocol::Legacy
+ ),
b"\x08"
);
diff --git a/src/platform/windows.rs b/src/platform/windows.rs
index 35ecfd82..6cc642eb 100644
--- a/src/platform/windows.rs
+++ b/src/platform/windows.rs
@@ -64,15 +64,33 @@ use super::{ClipboardImage, ForegroundJob, Signal};
const STILL_ACTIVE: u32 = 259;
const FOREGROUND_PROCESS_SNAPSHOT_CACHE_TTL: Duration = Duration::from_millis(250);
-pub(crate) fn encode_windows_conpty_shift_enter(key: crate::input::TerminalKey) -> Option<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)]
@@ -1390,6 +1408,53 @@ mod tests {
};
#[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];
super::copy_wide_truncated(&mut destination, "abc😀def");
diff --git a/src/protocol/wire.rs b/src/protocol/wire.rs
index 0d4b14bb..655f47cb 100644
--- a/src/protocol/wire.rs
+++ b/src/protocol/wire.rs
@@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize};
// ---------------------------------------------------------------------------
/// Current protocol version. Bumped when wire format changes incompatibly.
-pub const PROTOCOL_VERSION: u32 = 18;
+pub const PROTOCOL_VERSION: u32 = 19;
/// Maximum allowed frame payload size (2 MB). Frames larger than this are
/// rejected to prevent denial-of-service via oversized length prefixes.
@@ -115,10 +115,11 @@ pub enum ClientInputEvent {
code: ClientKeyCode,
modifiers: u8,
kind: ClientKeyKind,
+ repeat_count: u16,
+ generated_text: Option<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 {
diff --git a/src/raw_input.rs b/src/raw_input.rs
index 6b59b392..ce7aec19 100644
--- a/src/raw_input.rs
+++ b/src/raw_input.rs
@@ -53,10 +53,10 @@ pub fn parse_raw_input_bytes_with_ranges(data: &[u8]) -> Vec<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))
}
diff --git a/src/server/client_transport.rs b/src/server/client_transport.rs
index 743a108f..2675b4c3 100644
--- a/src/server/client_transport.rs
+++ b/src/server/client_transport.rs
@@ -383,24 +383,62 @@ enum InputEventLimit {
WithinLimits,
TooManyEvents,
PasteTooLarge { size: usize },
+ InputPayloadTooLarge { size: usize },
}
fn input_event_limit(events: &[ClientInputEvent]) -> InputEventLimit {
- if events.len() > MAX_INPUT_EVENT_BATCH {
- return InputEventLimit::TooManyEvents;
- }
-
+ let mut expanded_events = 0usize;
let mut paste_bytes = 0usize;
+ let mut input_bytes = 0usize;
for event in events {
- if let ClientInputEvent::Paste { text } = event {
- paste_bytes = paste_bytes.saturating_add(text.len());
+ expanded_events = expanded_events.saturating_add(match event {
+ ClientInputEvent::Key { repeat_count, .. } => usize::from((*repeat_count).max(1)),
+ _ => 1,
+ });
+ match event {
+ ClientInputEvent::Key {
+ repeat_count,
+ generated_text,
+ source,
+ ..
+ } => {
+ if let Some(text) = generated_text {
+ input_bytes = input_bytes.saturating_add(
+ text.len()
+ .saturating_mul(usize::from((*repeat_count).max(1))),
+ );
+ }
+ if let crate::protocol::ClientKeySource::Vt { bytes } = source {
+ input_bytes = input_bytes.saturating_add(bytes.len());
+ }
+ }
+ ClientInputEvent::TextCommit(text) => {
+ input_bytes = input_bytes.saturating_add(text.len());
+ }
+ ClientInputEvent::Paste { text } => {
+ paste_bytes = paste_bytes.saturating_add(text.len());
+ }
+ ClientInputEvent::Mouse { .. }
+ | ClientInputEvent::FocusGained
+ | ClientInputEvent::FocusLost => {}
}
}
- if paste_bytes > MAX_INPUT_PAYLOAD {
- InputEventLimit::PasteTooLarge { size: paste_bytes }
- } else {
+ if expanded_events > MAX_INPUT_EVENT_BATCH {
+ return InputEventLimit::TooManyEvents;
+ }
+
+ let payload_bytes = paste_bytes.saturating_add(input_bytes);
+ if payload_bytes <= MAX_INPUT_PAYLOAD {
InputEventLimit::WithinLimits
+ } else if input_bytes == 0 {
+ InputEventLimit::PasteTooLarge {
+ size: payload_bytes,
+ }
+ } else {
+ InputEventLimit::InputPayloadTooLarge {
+ size: payload_bytes,
+ }
}
}
@@ -717,6 +755,17 @@ fn client_read_loop(
max: MAX_INPUT_PAYLOAD,
}
}
+ InputEventLimit::InputPayloadTooLarge { size } => {
+ warn!(
+ client_id,
+ size,
+ max = MAX_INPUT_PAYLOAD,
+ "oversized structured input payload from client, closing"
+ );
+ let _ = server_event_tx
+ .blocking_send(ServerEvent::ClientDisconnected { client_id });
+ break;
+ }
},
ClientMessage::ObserveTerminal { target } => {
ServerEvent::ClientObserveTerminal { client_id, target }
@@ -1388,6 +1437,10 @@ new_tab = "ctrl+notakey"
code: crate::protocol::ClientKeyCode::Enter,
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Press,
+
+ repeat_count: 1,
+ generated_text: None,
+ source: crate::protocol::ClientKeySource::Synthesized,
},
ClientInputEvent::FocusGained,
];
@@ -1550,6 +1603,43 @@ new_tab = "ctrl+notakey"
}
#[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
// the connection is guaranteed to close within 5 seconds even with
diff --git a/src/server/clients.rs b/src/server/clients.rs
index c6c617ec..bb0f31c6 100644
--- a/src/server/clients.rs
+++ b/src/server/clients.rs
@@ -257,6 +257,7 @@ pub(crate) fn events_include_interaction(events: &[crate::raw_input::RawInputEve
matches!(
event,
crate::raw_input::RawInputEvent::Key(_)
+ | crate::raw_input::RawInputEvent::Text(_)
| crate::raw_input::RawInputEvent::Mouse(_)
| crate::raw_input::RawInputEvent::Paste(_)
| crate::raw_input::RawInputEvent::OuterFocusGained
diff --git a/src/server/headless.rs b/src/server/headless.rs
index 6fdb83ce..ff85f253 100644
--- a/src/server/headless.rs
+++ b/src/server/headless.rs
@@ -4544,6 +4544,7 @@ fn events_for_app_routing(
}
crate::raw_input::RawInputEvent::OuterFocusLost if !source_is_foreground => None,
crate::raw_input::RawInputEvent::Key(_)
+ | crate::raw_input::RawInputEvent::Text(_)
| crate::raw_input::RawInputEvent::Mouse(_)
| crate::raw_input::RawInputEvent::Paste(_) => {
source_is_foreground = true;
@@ -7539,6 +7540,10 @@ next_tab = ""
code: crate::protocol::ClientKeyCode::Char('j'),
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Press,
+
+ repeat_count: 1,
+ generated_text: None,
+ source: crate::protocol::ClientKeySource::Synthesized,
}],
}));
server.foreground_client_id = Some(2);
@@ -7558,7 +7563,7 @@ next_tab = ""
.expect("synthetic release from background client"),
Bytes::from_static(b"\x1b[106;1:3u")
);
- assert!(server.app.pressed_terminal_keys.is_empty());
+ assert!(server.app.input_leases.is_empty());
}
#[tokio::test]
@@ -7602,6 +7607,10 @@ next_tab = ""
code: crate::protocol::ClientKeyCode::Char('x'),
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Release,
+
+ repeat_count: 1,
+ generated_text: None,
+ source: crate::protocol::ClientKeySource::Synthesized,
},
crate::protocol::ClientInputEvent::FocusLost,
],
@@ -7652,6 +7661,10 @@ next_tab = ""
code: crate::protocol::ClientKeyCode::Char('x'),
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Release,
+
+ repeat_count: 1,
+ generated_text: None,
+ source: crate::protocol::ClientKeySource::Synthesized,
}],
}));
assert_eq!(server.foreground_client_id, Some(3));
@@ -7863,6 +7876,10 @@ next_tab = ""
code: crate::protocol::ClientKeyCode::Enter,
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Press,
+
+ repeat_count: 1,
+ generated_text: None,
+ source: crate::protocol::ClientKeySource::Synthesized,
}],
}));
@@ -7899,6 +7916,10 @@ next_tab = ""
code: crate::protocol::ClientKeyCode::Esc,
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Press,
+
+ repeat_count: 1,
+ generated_text: None,
+ source: crate::protocol::ClientKeySource::Synthesized,
}],
}));
@@ -7932,6 +7953,10 @@ next_tab = ""
code: crate::protocol::ClientKeyCode::Down,
modifiers: 0,
kind: crate::protocol::ClientKeyKind::Press,
+
+ repeat_count: 1,
+ generated_text: None,
+ source: crate::protocol::ClientKeySource::Synthesized,
}],
}));