aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-19 22:23:49 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-19 22:24:20 +0300
commit052f20214b1be904924df8d50d01e31198098922 (patch)
tree209ec134a8dc7e90b0f141fa046b31cda835c85c /src
parent46a2b259a71828e7fcdeab1f67ef675304e2ca14 (diff)
fix: preserve windows terminal multiline paste
refs #670
Diffstat (limited to 'src')
-rw-r--r--src/client/input.rs22
-rw-r--r--src/client/input/windows_vti.rs1553
-rw-r--r--src/client/mod.rs162
-rw-r--r--src/protocol/wire.rs8
-rw-r--r--src/raw_input.rs11
5 files changed, 1750 insertions, 6 deletions
diff --git a/src/client/input.rs b/src/client/input.rs
index ba1e353f..7cf69533 100644
--- a/src/client/input.rs
+++ b/src/client/input.rs
@@ -23,6 +23,9 @@ use tokio::sync::mpsc;
use super::ClientLoopEvent;
+#[cfg(any(windows, test))]
+mod windows_vti;
+
// ---------------------------------------------------------------------------
// Stdin reader thread
// ---------------------------------------------------------------------------
@@ -114,6 +117,23 @@ fn windows_stdin_reader_loop(
event_tx: mpsc::Sender<ClientLoopEvent>,
should_quit: &Arc<AtomicBool>,
) {
+ if !super::windows_vti_input_backend_enabled() {
+ windows_crossterm_reader_loop(event_tx, should_quit);
+ } else {
+ match windows_vti::console_input_handle() {
+ Ok(handle) if windows_vti::virtual_terminal_input_enabled(handle) => {
+ windows_vti::raw_console_reader_loop(handle, event_tx, should_quit);
+ }
+ _ => windows_crossterm_reader_loop(event_tx, should_quit),
+ }
+ }
+}
+
+#[cfg(windows)]
+fn windows_crossterm_reader_loop(
+ event_tx: mpsc::Sender<ClientLoopEvent>,
+ should_quit: &Arc<AtomicBool>,
+) {
let mut framer = crate::raw_input::RawInputFramer::default();
let mut raw_sequence_pending = false;
@@ -254,7 +274,7 @@ fn send_windows_raw_events(
.is_ok()
}
-#[cfg(windows)]
+#[cfg(any(windows, test))]
fn windows_client_input_event_from_raw(
event: crate::raw_input::RawInputEvent,
) -> Option<crate::protocol::ClientInputEvent> {
diff --git a/src/client/input/windows_vti.rs b/src/client/input/windows_vti.rs
new file mode 100644
index 00000000..376efdb5
--- /dev/null
+++ b/src/client/input/windows_vti.rs
@@ -0,0 +1,1553 @@
+#[cfg(windows)]
+use std::sync::atomic::{AtomicBool, Ordering};
+#[cfg(windows)]
+use std::sync::Arc;
+
+#[cfg(windows)]
+use tokio::sync::mpsc;
+
+use super::windows_client_input_event_from_raw;
+#[cfg(windows)]
+use super::ClientLoopEvent;
+
+#[cfg(windows)]
+pub(super) fn raw_console_reader_loop(
+ handle: windows_sys::Win32::Foundation::HANDLE,
+ event_tx: mpsc::Sender<ClientLoopEvent>,
+ should_quit: &Arc<AtomicBool>,
+) {
+ let mut mapper = WindowsInputMapper::default();
+ let mut pump = WindowsInputPump::default();
+
+ while !should_quit.load(Ordering::Acquire) {
+ match windows_console_input_items(handle, &mut mapper) {
+ WindowsInputItems::Items(items) => {
+ if !process_platform_input_items(items, &mut pump, &event_tx) {
+ return;
+ }
+ }
+ WindowsInputItems::Idle => {
+ if !process_platform_input_items(mapper.idle(), &mut pump, &event_tx) {
+ return;
+ }
+ if !send_windows_client_input_events(pump.idle(), &event_tx) {
+ return;
+ }
+ }
+ WindowsInputItems::Closed => return,
+ }
+ }
+}
+
+#[cfg(windows)]
+fn process_platform_input_items(
+ items: Vec<PlatformInputItem>,
+ pump: &mut WindowsInputPump,
+ event_tx: &mpsc::Sender<ClientLoopEvent>,
+) -> bool {
+ for item in items {
+ if !send_windows_client_input_events(pump.process(item), event_tx) {
+ return false;
+ }
+ }
+ true
+}
+
+#[cfg(windows)]
+pub(super) fn console_input_handle() -> std::io::Result<windows_sys::Win32::Foundation::HANDLE> {
+ use windows_sys::Win32::Foundation::{HANDLE, INVALID_HANDLE_VALUE};
+ use windows_sys::Win32::System::Console::{GetStdHandle, STD_INPUT_HANDLE};
+
+ let handle: HANDLE = unsafe { GetStdHandle(STD_INPUT_HANDLE) };
+ if handle.is_null() || handle == INVALID_HANDLE_VALUE {
+ Err(std::io::Error::last_os_error())
+ } else {
+ Ok(handle)
+ }
+}
+
+#[cfg(windows)]
+pub(super) fn virtual_terminal_input_enabled(
+ handle: windows_sys::Win32::Foundation::HANDLE,
+) -> bool {
+ use windows_sys::Win32::System::Console::{GetConsoleMode, ENABLE_VIRTUAL_TERMINAL_INPUT};
+
+ let mut mode = 0;
+ (unsafe { GetConsoleMode(handle, &mut mode) } != 0) && mode & ENABLE_VIRTUAL_TERMINAL_INPUT != 0
+}
+
+#[cfg(windows)]
+enum WindowsInputItems {
+ Items(Vec<PlatformInputItem>),
+ Idle,
+ Closed,
+}
+
+#[cfg(windows)]
+fn windows_console_input_items(
+ handle: windows_sys::Win32::Foundation::HANDLE,
+ mapper: &mut WindowsInputMapper,
+) -> WindowsInputItems {
+ const WAIT_OBJECT_0: u32 = 0;
+ const WAIT_TIMEOUT: u32 = 258;
+
+ match unsafe { windows_sys::Win32::System::Threading::WaitForSingleObject(handle, 10) } {
+ WAIT_OBJECT_0 => {}
+ WAIT_TIMEOUT => return WindowsInputItems::Idle,
+ _ => return WindowsInputItems::Closed,
+ }
+
+ let mut records = [windows_sys::Win32::System::Console::INPUT_RECORD::default(); 64];
+ let mut read = 0;
+ let ok = unsafe {
+ windows_sys::Win32::System::Console::ReadConsoleInputW(
+ handle,
+ records.as_mut_ptr(),
+ records.len() as u32,
+ &mut read,
+ )
+ };
+ if ok == 0 {
+ return WindowsInputItems::Closed;
+ }
+
+ let mut items = Vec::new();
+ for record in records.iter().take(read as usize) {
+ if let Some(record) = windows_console_input_record_from_os(*record) {
+ items.extend(mapper.translate(record));
+ }
+ }
+ WindowsInputItems::Items(items)
+}
+
+#[cfg(windows)]
+fn windows_console_input_record_from_os(
+ record: windows_sys::Win32::System::Console::INPUT_RECORD,
+) -> Option<WindowsInputRecord> {
+ use windows_sys::Win32::System::Console::{FOCUS_EVENT, KEY_EVENT, MOUSE_EVENT};
+
+ match record.EventType as u32 {
+ KEY_EVENT => {
+ let key = unsafe { record.Event.KeyEvent };
+ let unicode = unsafe { key.uChar.UnicodeChar };
+ Some(WindowsInputRecord::Key(WindowsKeyRecord {
+ key_down: key.bKeyDown != 0,
+ repeat_count: key.wRepeatCount,
+ virtual_key_code: key.wVirtualKeyCode,
+ unicode,
+ control_key_state: key.dwControlKeyState,
+ }))
+ }
+ MOUSE_EVENT => {
+ let mouse = unsafe { record.Event.MouseEvent };
+ Some(WindowsInputRecord::Mouse(WindowsMouseRecord {
+ x: mouse.dwMousePosition.X.max(0) as u16,
+ y: mouse.dwMousePosition.Y.max(0) as u16,
+ button_state: mouse.dwButtonState,
+ control_key_state: mouse.dwControlKeyState,
+ event_flags: mouse.dwEventFlags,
+ }))
+ }
+ FOCUS_EVENT => {
+ let focus = unsafe { record.Event.FocusEvent };
+ Some(WindowsInputRecord::Focus(focus.bSetFocus != 0))
+ }
+ _ => None,
+ }
+}
+
+#[derive(Clone, Copy, Debug)]
+enum WindowsInputRecord {
+ Key(WindowsKeyRecord),
+ Mouse(WindowsMouseRecord),
+ Focus(bool),
+}
+
+#[derive(Clone, Copy, Debug)]
+struct WindowsKeyRecord {
+ key_down: bool,
+ repeat_count: u16,
+ virtual_key_code: u16,
+ unicode: u16,
+ control_key_state: u32,
+}
+
+#[derive(Clone, Copy, Debug)]
+struct WindowsMouseRecord {
+ x: u16,
+ y: u16,
+ button_state: u32,
+ control_key_state: u32,
+ event_flags: u32,
+}
+
+#[derive(Default)]
+struct WindowsInputMapper {
+ pending_high_surrogate: Option<u16>,
+ pending_paste_high_surrogate: Option<u16>,
+ mouse_buttons: WindowsMouseButtons,
+ win32_input: WindowsWin32InputModeFramer,
+}
+
+#[derive(Default)]
+struct WindowsInputPump {
+ framer: crate::raw_input::RawInputFramer,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+enum PlatformInputItem {
+ Bytes(Vec<u8>),
+ Semantic(crate::protocol::ClientInputEvent),
+ PasteAwareKey {
+ bytes: Vec<u8>,
+ events: Vec<crate::protocol::ClientInputEvent>,
+ },
+}
+
+#[derive(Default)]
+struct WindowsWin32InputModeFramer {
+ buffer: Vec<u8>,
+}
+
+enum WindowsWin32InputModeItem {
+ Bytes(Vec<u8>),
+ Key {
+ bytes: Vec<u8>,
+ record: WindowsKeyRecord,
+ },
+}
+
+impl WindowsInputPump {
+ fn process(&mut self, item: PlatformInputItem) -> Vec<crate::protocol::ClientInputEvent> {
+ match item {
+ PlatformInputItem::Bytes(bytes) => {
+ Self::raw_events_to_client_events(self.framer.push(&bytes))
+ }
+ PlatformInputItem::Semantic(event) => {
+ let mut events = Self::raw_events_to_client_events(self.framer.flush_timeout());
+ events.push(event);
+ events
+ }
+ PlatformInputItem::PasteAwareKey { bytes, events } => {
+ if self.framer.has_pending_bracketed_paste() {
+ Self::raw_events_to_client_events(self.framer.push(&bytes))
+ } else {
+ let mut output = Self::raw_events_to_client_events(self.framer.flush_timeout());
+ output.extend(events);
+ output
+ }
+ }
+ }
+ }
+
+ fn idle(&mut self) -> Vec<crate::protocol::ClientInputEvent> {
+ Self::raw_events_to_client_events(self.framer.flush_timeout())
+ }
+
+ fn raw_events_to_client_events(
+ events: Vec<crate::raw_input::RawInputEvent>,
+ ) -> Vec<crate::protocol::ClientInputEvent> {
+ events
+ .into_iter()
+ .filter_map(windows_client_input_event_from_raw)
+ .collect()
+ }
+}
+
+#[cfg(test)]
+#[derive(Default)]
+struct WindowsInputTranslator {
+ mapper: WindowsInputMapper,
+ pump: WindowsInputPump,
+}
+
+#[cfg(test)]
+impl WindowsInputTranslator {
+ fn translate(&mut self, record: WindowsInputRecord) -> Vec<crate::protocol::ClientInputEvent> {
+ let mut events = Vec::new();
+ for item in self.mapper.translate(record) {
+ events.extend(self.pump.process(item));
+ }
+ events
+ }
+
+ fn idle(&mut self) -> Vec<crate::protocol::ClientInputEvent> {
+ let mut events = Vec::new();
+ for item in self.mapper.idle() {
+ events.extend(self.pump.process(item));
+ }
+ events.extend(self.pump.idle());
+ events
+ }
+}
+
+#[derive(Clone, Copy, Debug, Default)]
+struct WindowsMouseButtons {
+ left: bool,
+ right: bool,
+ middle: bool,
+}
+
+impl WindowsInputMapper {
+ fn idle(&mut self) -> Vec<PlatformInputItem> {
+ self.win32_input
+ .flush_timeout()
+ .into_iter()
+ .map(PlatformInputItem::Bytes)
+ .collect()
+ }
+
+ fn translate(&mut self, record: WindowsInputRecord) -> Vec<PlatformInputItem> {
+ match record {
+ WindowsInputRecord::Key(key) => self.translate_key(key),
+ WindowsInputRecord::Mouse(mouse) => {
+ let items = self
+ .translate_mouse(mouse)
+ .map(PlatformInputItem::Semantic)
+ .into_iter()
+ .collect();
+ self.with_pending_win32_flush(items)
+ }
+ WindowsInputRecord::Focus(focused) => {
+ self.with_pending_win32_flush(vec![PlatformInputItem::Semantic(if focused {
+ crate::protocol::ClientInputEvent::FocusGained
+ } else {
+ crate::protocol::ClientInputEvent::FocusLost
+ })])
+ }
+ }
+ }
+
+ fn translate_key(&mut self, key: WindowsKeyRecord) -> Vec<PlatformInputItem> {
+ if windows_input_trace_enabled() {
+ tracing::info!(
+ key_down = key.key_down,
+ repeat_count = key.repeat_count,
+ virtual_key_code = key.virtual_key_code,
+ unicode = key.unicode,
+ control_key_state = key.control_key_state,
+ "windows input trace: console key record"
+ );
+ }
+
+ if !self.key_record_can_emit_event(key) {
+ return Vec::new();
+ }
+
+ let repeat_count = key.repeat_count.max(1);
+ if key.virtual_key_code == 0 {
+ let mut items = Vec::new();
+ let mut flush_pending_win32_before_items = false;
+ for repeat_idx in 0..repeat_count {
+ let kind = Self::semantic_key_kind(key, false, repeat_idx);
+ if let Some((bytes, event)) = self.synthetic_modified_key_event(key, kind) {
+ flush_pending_win32_before_items = true;
+ items.push(PlatformInputItem::PasteAwareKey {
+ bytes,
+ events: vec![event],
+ });
+ } else if let Some(bytes) = self.synthetic_utf16_unit_to_bytes(key.unicode) {
+ items.extend(self.translate_win32_input_mode_bytes(&bytes));
+ }
+ }
+ return if flush_pending_win32_before_items {
+ self.with_pending_win32_flush(items)
+ } else {
+ items
+ };
+ }
+
+ let events = self.translate_semantic_key_events(key);
+ let items = if let Some(bytes) = self.paste_payload_bytes_for_key(key) {
+ vec![PlatformInputItem::PasteAwareKey { bytes, events }]
+ } else {
+ events
+ .into_iter()
+ .map(PlatformInputItem::Semantic)
+ .collect()
+ };
+ self.with_pending_win32_flush(items)
+ }
+
+ fn translate_win32_input_mode_bytes(&mut self, bytes: &[u8]) -> Vec<PlatformInputItem> {
+ let mut items = Vec::new();
+ for item in self.win32_input.push(bytes) {
+ match item {
+ WindowsWin32InputModeItem::Bytes(bytes) => {
+ items.push(PlatformInputItem::Bytes(bytes))
+ }
+ WindowsWin32InputModeItem::Key { bytes, record } => {
+ items.push(PlatformInputItem::PasteAwareKey {
+ bytes,
+ events: self.translate_semantic_key_events(record),
+ })
+ }
+ }
+ }
+ items
+ }
+
+ fn with_pending_win32_flush(
+ &mut self,
+ mut items: Vec<PlatformInputItem>,
+ ) -> Vec<PlatformInputItem> {
+ let mut pending = self.idle();
+ pending.append(&mut items);
+ pending
+ }
+
+ fn synthetic_modified_key_event(
+ &mut self,
+ key: WindowsKeyRecord,
+ kind: crate::protocol::ClientKeyKind,
+ ) -> Option<(Vec<u8>, crate::protocol::ClientInputEvent)> {
+ use crate::protocol::ClientKeyCode;
+
+ let modifiers = windows_key_modifiers(key.control_key_state);
+ if !modifiers.contains(crossterm::event::KeyModifiers::SHIFT) {
+ return None;
+ }
+
+ let code = match key.unicode {
+ 0x0009 => ClientKeyCode::BackTab,
+ 0x000d => ClientKeyCode::Enter,
+ _ => return None,
+ };
+ self.pending_high_surrogate = None;
+ self.pending_paste_high_surrogate = None;
+ Some((
+ vec![key.unicode as u8],
+ crate::protocol::ClientInputEvent::Key {
+ code,
+ modifiers: modifiers.bits(),
+ kind,
+ },
+ ))
+ }
+
+ fn translate_semantic_key_events(
+ &mut self,
+ key: WindowsKeyRecord,
+ ) -> Vec<crate::protocol::ClientInputEvent> {
+ if !self.key_record_can_emit_event(key) {
+ return Vec::new();
+ }
+
+ if key.virtual_key_code == 0 {
+ if let Some((_bytes, event)) =
+ self.synthetic_modified_key_event(key, crate::protocol::ClientKeyKind::Press)
+ {
+ return vec![event];
+ }
+ }
+
+ let is_alt_code = Self::is_alt_code(key);
+ (0..key.repeat_count.max(1))
+ .filter_map(|repeat_idx| {
+ self.translate_semantic_key_event(
+ key,
+ Self::semantic_key_kind(key, is_alt_code, repeat_idx),
+ )
+ })
+ .collect()
+ }
+
+ fn key_record_can_emit_event(&self, key: WindowsKeyRecord) -> bool {
+ key.key_down || Self::is_alt_code(key) || key.virtual_key_code != 0
+ }
+
+ fn is_alt_code(key: WindowsKeyRecord) -> bool {
+ const VK_MENU: u16 = 0x12;
+ key.virtual_key_code == VK_MENU && !key.key_down && key.unicode != 0
+ }
+
+ fn semantic_key_kind(
+ key: WindowsKeyRecord,
+ is_alt_code: bool,
+ repeat_idx: u16,
+ ) -> crate::protocol::ClientKeyKind {
+ if is_alt_code {
+ crate::protocol::ClientKeyKind::Press
+ } else if !key.key_down {
+ crate::protocol::ClientKeyKind::Release
+ } else if repeat_idx > 0 {
+ crate::protocol::ClientKeyKind::Repeat
+ } else {
+ crate::protocol::ClientKeyKind::Press
+ }
+ }
+
+ fn translate_semantic_key_event(
+ &mut self,
+ key: WindowsKeyRecord,
+ kind: crate::protocol::ClientKeyKind,
+ ) -> Option<crate::protocol::ClientInputEvent> {
+ let modifiers = windows_key_modifiers(key.control_key_state);
+ let code = if let Some(code) =
+ windows_virtual_key_to_key_code(key.virtual_key_code, modifiers)
+ {
+ self.pending_high_surrogate = None;
+ Some(code)
+ } else {
+ if key.unicode == 0 {
+ self.pending_high_surrogate = None;
+ }
+ if modifiers.contains(crossterm::event::KeyModifiers::CONTROL) {
+ if let Some(code) = windows_unicode_control_to_key_code(key.unicode) {
+ self.pending_high_surrogate = None;
+ return Some(crate::protocol::ClientInputEvent::Key {
+ code,
+ modifiers: modifiers.bits(),
+ kind,
+ });
+ }
+ }
+ self.utf16_unit_to_char(key.unicode)
+ .filter(|ch| !ch.is_control())
+ .map(crate::protocol::ClientKeyCode::Char)
+ .or_else(|| {
+ windows_virtual_key_to_char_code(key.virtual_key_code, key.unicode, modifiers)
+ })
+ };
+
+ code.map(|code| crate::protocol::ClientInputEvent::Key {
+ code,
+ modifiers: modifiers.bits(),
+ kind,
+ })
+ }
+
+ fn utf16_unit_to_char(&mut self, unit: u16) -> Option<char> {
+ Self::utf16_unit_to_char_with_pending(&mut self.pending_high_surrogate, unit)
+ }
+
+ fn utf16_unit_to_char_with_pending(
+ pending_high_surrogate: &mut Option<u16>,
+ unit: u16,
+ ) -> Option<char> {
+ if unit == 0 {
+ return None;
+ }
+
+ if (0xd800..=0xdbff).contains(&unit) {
+ *pending_high_surrogate = Some(unit);
+ return None;
+ }
+
+ let ch = if (0xdc00..=0xdfff).contains(&unit) {
+ let high = pending_high_surrogate.take()?;
+ let codepoint = 0x10000 + (((high as u32 - 0xd800) << 10) | (unit as u32 - 0xdc00));
+ char::from_u32(codepoint)?
+ } else {
+ *pending_high_surrogate = None;
+ char::from_u32(unit as u32)?
+ };
+
+ Some(ch)
+ }
+
+ fn paste_payload_bytes_for_key(&mut self, key: WindowsKeyRecord) -> Option<Vec<u8>> {
+ if key.unicode == 0 {
+ return None;
+ }
+ if !key.key_down {
+ return Some(Vec::new());
+ }
+
+ let ch = Self::utf16_unit_to_char_with_pending(
+ &mut self.pending_paste_high_surrogate,
+ key.unicode,
+ )?;
+ let mut buf = [0; 4];
+ Some(
+ ch.encode_utf8(&mut buf)
+ .as_bytes()
+ .repeat(key.repeat_count.max(1) as usize),
+ )
+ }
+
+ fn synthetic_utf16_unit_to_bytes(&mut self, unit: u16) -> Option<Vec<u8>> {
+ let ch = self.utf16_unit_to_char(unit)?;
+ let mut buf = [0; 4];
+ Some(ch.encode_utf8(&mut buf).as_bytes().to_vec())
+ }
+
+ fn translate_mouse(
+ &mut self,
+ mouse: WindowsMouseRecord,
+ ) -> Option<crate::protocol::ClientInputEvent> {
+ use crossterm::event::{MouseButton, MouseEventKind};
+
+ const FROM_LEFT_1ST_BUTTON_PRESSED: u32 = 0x0001;
+ const RIGHTMOST_BUTTON_PRESSED: u32 = 0x0002;
+ const FROM_LEFT_2ND_BUTTON_PRESSED: u32 = 0x0004;
+ const MOUSE_MOVED: u32 = 0x0001;
+ const MOUSE_WHEELED: u32 = 0x0004;
+ const MOUSE_HWHEELED: u32 = 0x0008;
+
+ let buttons = WindowsMouseButtons {
+ left: mouse.button_state & FROM_LEFT_1ST_BUTTON_PRESSED != 0,
+ right: mouse.button_state & RIGHTMOST_BUTTON_PRESSED != 0,
+ middle: mouse.button_state & FROM_LEFT_2ND_BUTTON_PRESSED != 0,
+ };
+
+ let kind = if mouse.event_flags & MOUSE_WHEELED != 0 {
+ if (mouse.button_state as i32) < 0 {
+ MouseEventKind::ScrollDown
+ } else {
+ MouseEventKind::ScrollUp
+ }
+ } else if mouse.event_flags & MOUSE_HWHEELED != 0 {
+ if (mouse.button_state as i32) < 0 {
+ MouseEventKind::ScrollLeft
+ } else {
+ MouseEventKind::ScrollRight
+ }
+ } else if mouse.event_flags & MOUSE_MOVED != 0 {
+ if buttons.left {
+ MouseEventKind::Drag(MouseButton::Left)
+ } else if buttons.right {
+ MouseEventKind::Drag(MouseButton::Right)
+ } else if buttons.middle {
+ MouseEventKind::Drag(MouseButton::Middle)
+ } else {
+ MouseEventKind::Moved
+ }
+ } else if buttons.left && !self.mouse_buttons.left {
+ MouseEventKind::Down(MouseButton::Left)
+ } else if buttons.right && !self.mouse_buttons.right {
+ MouseEventKind::Down(MouseButton::Right)
+ } else if buttons.middle && !self.mouse_buttons.middle {
+ MouseEventKind::Down(MouseButton::Middle)
+ } else if !buttons.left && self.mouse_buttons.left {
+ MouseEventKind::Up(MouseButton::Left)
+ } else if !buttons.right && self.mouse_buttons.right {
+ MouseEventKind::Up(MouseButton::Right)
+ } else if !buttons.middle && self.mouse_buttons.middle {
+ MouseEventKind::Up(MouseButton::Middle)
+ } else {
+ self.mouse_buttons = buttons;
+ return None;
+ };
+ self.mouse_buttons = buttons;
+
+ Some(crate::protocol::ClientInputEvent::Mouse {
+ kind: crate::protocol::ClientMouseKind::from_crossterm(kind)?,
+ column: mouse.x,
+ row: mouse.y,
+ modifiers: windows_key_modifiers(mouse.control_key_state).bits(),
+ })
+ }
+}
+
+impl WindowsWin32InputModeFramer {
+ fn push(&mut self, bytes: &[u8]) -> Vec<WindowsWin32InputModeItem> {
+ self.buffer.extend_from_slice(bytes);
+
+ let mut items = Vec::new();
+ while let Some(item) = self.next_item() {
+ items.push(item);
+ }
+ items
+ }
+
+ fn next_item(&mut self) -> Option<WindowsWin32InputModeItem> {
+ if self.buffer.is_empty() {
+ return None;
+ }
+
+ if self.buffer.as_slice() == b"\x1b" || self.buffer.as_slice() == b"\x1b[" {
+ return None;
+ }
+
+ if self.buffer.starts_with(b"\x1b[") {
+ let mut cursor = 2;
+ while cursor < self.buffer.len()
+ && (self.buffer[cursor].is_ascii_digit() || self.buffer[cursor] == b';')
+ {
+ cursor += 1;
+ }
+
+ if cursor == self.buffer.len() {
+ return None;
+ }
+
+ if self.buffer[cursor] == b'_' {
+ let bytes = self.buffer[..=cursor].to_vec();
+ let body = String::from_utf8_lossy(&self.buffer[2..cursor]);
+ let item = parse_win32_input_mode_key_record(&body).map(|record| {
+ WindowsWin32InputModeItem::Key {
+ bytes: bytes.clone(),
+ record,
+ }
+ });
+ self.buffer.drain(..=cursor);
+ return Some(item.unwrap_or(WindowsWin32InputModeItem::Bytes(bytes)));
+ }
+ }
+
+ Some(WindowsWin32InputModeItem::Bytes(vec![self
+ .buffer
+ .remove(0)]))
+ }
+
+ fn flush_timeout(&mut self) -> Vec<Vec<u8>> {
+ if self.buffer.is_empty() {
+ Vec::new()
+ } else {
+ vec![std::mem::take(&mut self.buffer)]
+ }
+ }
+}
+
+fn parse_win32_input_mode_key_record(body: &str) -> Option<WindowsKeyRecord> {
+ let mut fields = body.split(';');
+ let virtual_key_code = fields.next()?.parse::<u16>().ok()?;
+ let _scan_code = fields.next()?.parse::<u16>().ok()?;
+ let unicode = fields.next()?.parse::<u16>().ok()?;
+ let key_down = fields.next()?.parse::<u16>().ok()? != 0;
+ let control_key_state = fields.next()?.parse::<u32>().ok()?;
+ let repeat_count = fields.next()?.parse::<u16>().ok()?;
+ if fields.next().is_some() {
+ return None;
+ }
+
+ Some(WindowsKeyRecord {
+ key_down,
+ repeat_count,
+ virtual_key_code,
+ unicode,
+ control_key_state,
+ })
+}
+
+fn windows_key_modifiers(control_key_state: u32) -> crossterm::event::KeyModifiers {
+ const RIGHT_ALT_PRESSED: u32 = 0x0001;
+ const LEFT_ALT_PRESSED: u32 = 0x0002;
+ const RIGHT_CTRL_PRESSED: u32 = 0x0004;
+ const LEFT_CTRL_PRESSED: u32 = 0x0008;
+ const SHIFT_PRESSED: u32 = 0x0010;
+
+ let mut modifiers = crossterm::event::KeyModifiers::empty();
+ let alt_gr = control_key_state & RIGHT_ALT_PRESSED != 0
+ && control_key_state & LEFT_CTRL_PRESSED != 0
+ && control_key_state & RIGHT_CTRL_PRESSED == 0;
+ if control_key_state & SHIFT_PRESSED != 0 {
+ modifiers |= crossterm::event::KeyModifiers::SHIFT;
+ }
+ if !alt_gr && control_key_state & (LEFT_CTRL_PRESSED | RIGHT_CTRL_PRESSED) != 0 {
+ modifiers |= crossterm::event::KeyModifiers::CONTROL;
+ }
+ // Treat right Alt as AltGr rather than a terminal Alt prefix.
+ if control_key_state & LEFT_ALT_PRESSED != 0
+ || (control_key_state & RIGHT_ALT_PRESSED != 0 && !alt_gr)
+ {
+ modifiers |= crossterm::event::KeyModifiers::ALT;
+ }
+ modifiers
+}
+
+fn windows_virtual_key_to_key_code(
+ vk: u16,
+ modifiers: crossterm::event::KeyModifiers,
+) -> Option<crate::protocol::ClientKeyCode> {
+ use crate::protocol::ClientKeyCode;
+ Some(match vk {
+ 0x08 => ClientKeyCode::Backspace,
+ 0x09 if modifiers.contains(crossterm::event::KeyModifiers::SHIFT) => ClientKeyCode::BackTab,
+ 0x09 => ClientKeyCode::Tab,
+ 0x0d => ClientKeyCode::Enter,
+ 0x1b => ClientKeyCode::Esc,
+ 0x21 => ClientKeyCode::PageUp,
+ 0x22 => ClientKeyCode::PageDown,
+ 0x23 => ClientKeyCode::End,
+ 0x24 => ClientKeyCode::Home,
+ 0x25 => ClientKeyCode::Left,
+ 0x26 => ClientKeyCode::Up,
+ 0x27 => ClientKeyCode::Right,
+ 0x28 => ClientKeyCode::Down,
+ 0x2d => ClientKeyCode::Insert,
+ 0x2e => ClientKeyCode::Delete,
+ 0x70..=0x87 => ClientKeyCode::F((vk - 0x6f) as u8),
+ _ => return None,
+ })
+}
+
+fn windows_virtual_key_to_char_code(
+ vk: u16,
+ unicode: u16,
+ modifiers: crossterm::event::KeyModifiers,
+) -> Option<crate::protocol::ClientKeyCode> {
+ use crate::protocol::ClientKeyCode;
+
+ if let Some(ch) = char::from_u32(unicode as u32).filter(|ch| !ch.is_control()) {
+ return Some(ClientKeyCode::Char(ch));
+ }
+
+ let ch = match vk {
+ 0x30..=0x39 => char::from_u32(vk as u32)?,
+ 0x41..=0x5a
+ if modifiers.contains(crossterm::event::KeyModifiers::SHIFT)
+ && !modifiers.contains(crossterm::event::KeyModifiers::CONTROL) =>
+ {
+ char::from_u32(vk as u32)?
+ }
+ 0x41..=0x5a => char::from_u32(vk as u32 + 32)?,
+ _ => return None,
+ };
+ Some(ClientKeyCode::Char(ch))
+}
+
+fn windows_unicode_control_to_key_code(unicode: u16) -> Option<crate::protocol::ClientKeyCode> {
+ use crate::protocol::ClientKeyCode;
+ Some(match unicode {
+ 0x00 => ClientKeyCode::Char(' '),
+ 0x1b => ClientKeyCode::Char('['),
+ 0x1c => ClientKeyCode::Char('\\'),
+ 0x1d => ClientKeyCode::Char(']'),
+ 0x1e => ClientKeyCode::Char('^'),
+ 0x1f => ClientKeyCode::Char('-'),
+ _ => return None,
+ })
+}
+
+#[cfg(windows)]
+fn send_windows_client_input_events(
+ events: Vec<crate::protocol::ClientInputEvent>,
+ event_tx: &mpsc::Sender<ClientLoopEvent>,
+) -> bool {
+ if events.is_empty() {
+ return true;
+ }
+ if windows_input_trace_enabled() {
+ tracing::info!(?events, "windows input trace: client input events");
+ }
+ event_tx
+ .blocking_send(ClientLoopEvent::StdinEvents(events))
+ .is_ok()
+}
+
+#[cfg(any(windows, test))]
+fn windows_input_trace_enabled() -> bool {
+ std::env::var_os("HERDR_WINDOWS_INPUT_TRACE").is_some()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn key_char(ch: char) -> WindowsInputRecord {
+ WindowsInputRecord::Key(WindowsKeyRecord {
+ key_down: true,
+ repeat_count: 1,
+ virtual_key_code: 0,
+ unicode: ch as u16,
+ control_key_state: 0,
+ })
+ }
+
+ fn key_vk(vk: u16, control_key_state: u32) -> WindowsInputRecord {
+ key_vk_with_unicode(vk, '\0', control_key_state)
+ }
+
+ fn key_vk_with_unicode(vk: u16, ch: char, control_key_state: u32) -> WindowsInputRecord {
+ WindowsInputRecord::Key(WindowsKeyRecord {
+ key_down: true,
+ repeat_count: 1,
+ virtual_key_code: vk,
+ unicode: ch as u16,
+ control_key_state,
+ })
+ }
+
+ fn key_vk_with_utf16(vk: u16, unit: u16) -> WindowsInputRecord {
+ WindowsInputRecord::Key(WindowsKeyRecord {
+ key_down: true,
+ repeat_count: 1,
+ virtual_key_code: vk,
+ unicode: unit,
+ control_key_state: 0,
+ })
+ }
+
+ fn key_vk_with_utf16_mods(vk: u16, unit: u16, control_key_state: u32) -> WindowsInputRecord {
+ WindowsInputRecord::Key(WindowsKeyRecord {
+ key_down: true,
+ repeat_count: 1,
+ virtual_key_code: vk,
+ unicode: unit,
+ control_key_state,
+ })
+ }
+
+ fn key_vk_with_repeat(vk: u16, repeat_count: u16) -> WindowsInputRecord {
+ WindowsInputRecord::Key(WindowsKeyRecord {
+ key_down: true,
+ repeat_count,
+ virtual_key_code: vk,
+ unicode: 0,
+ control_key_state: 0,
+ })
+ }
+
+ fn key_vk_with_unicode_repeat(
+ vk: u16,
+ ch: char,
+ control_key_state: u32,
+ repeat_count: u16,
+ ) -> WindowsInputRecord {
+ WindowsInputRecord::Key(WindowsKeyRecord {
+ key_down: true,
+ repeat_count,
+ virtual_key_code: vk,
+ unicode: ch as u16,
+ control_key_state,
+ })
+ }
+
+ fn key_vk_up_with_unicode(vk: u16, ch: char, control_key_state: u32) -> WindowsInputRecord {
+ WindowsInputRecord::Key(WindowsKeyRecord {
+ key_down: false,
+ repeat_count: 1,
+ virtual_key_code: vk,
+ unicode: ch as u16,
+ control_key_state,
+ })
+ }
+
+ fn translate(
+ records: impl IntoIterator<Item = WindowsInputRecord>,
+ ) -> Vec<crate::protocol::ClientInputEvent> {
+ let mut translator = WindowsInputTranslator::default();
+ records
+ .into_iter()
+ .flat_map(|record| translator.translate(record))
+ .collect()
+ }
+
+ #[test]
+ fn vti_bracketed_paste_records_emit_single_paste() {
+ let records = "\x1b[200~alpha\rbravo\rcharlie\x1b[201~"
+ .chars()
+ .map(key_char);
+
+ assert_eq!(
+ translate(records),
+ vec![crate::protocol::ClientInputEvent::Paste {
+ text: "alpha\rbravo\rcharlie".into(),
+ }]
+ );
+ }
+
+ #[test]
+ fn vti_incomplete_bracketed_paste_waits_for_terminator() {
+ let mut translator = WindowsInputTranslator::default();
+ for record in "\x1b[200~alpha\r".chars().map(key_char) {
+ assert!(translator.translate(record).is_empty());
+ }
+
+ let mut events = Vec::new();
+ for record in "bravo\x1b[201~".chars().map(key_char) {
+ events.extend(translator.translate(record));
+ }
+ assert_eq!(
+ events,
+ vec![crate::protocol::ClientInputEvent::Paste {
+ text: "alpha\rbravo".into(),
+ }]
+ );
+ }
+
+ #[test]
+ fn vti_ctrl_c_record_becomes_ctrl_c_key() {
+ assert_eq!(
+ translate([key_char('\u{3}')]),
+ vec![crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Char('c'),
+ modifiers: crossterm::event::KeyModifiers::CONTROL.bits(),
+ kind: crate::protocol::ClientKeyKind::Press,
+ }]
+ );
+ }
+
+ #[test]
+ fn vti_ctrl_j_record_preserves_lf_control_key() {
+ assert_eq!(
+ translate([key_vk_with_unicode(0x4a, '\n', 0x0008)]),
+ vec![crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Char('j'),
+ modifiers: crossterm::event::KeyModifiers::CONTROL.bits(),
+ kind: crate::protocol::ClientKeyKind::Press,
+ }]
+ );
+ }
+
+ #[test]
+ fn vti_real_non_letter_control_records_are_preserved() {
+ let cases = [
+ (0x20, 0x00, ' '),
+ (0xdb, 0x1b, '['),
+ (0xdc, 0x1c, '\\'),
+ (0xdd, 0x1d, ']'),
+ (0x36, 0x1e, '^'),
+ (0xbd, 0x1f, '-'),
+ ];
+
+ for (vk, unicode, expected) in cases {
+ assert_eq!(
+ translate([key_vk_with_utf16_mods(vk, unicode, 0x0008)]),
+ vec![crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Char(expected),
+ modifiers: crossterm::event::KeyModifiers::CONTROL.bits(),
+ kind: crate::protocol::ClientKeyKind::Press,
+ }],
+ "vk={vk:#x} unicode={unicode:#x}"
+ );
+ }
+ }
+
+ #[test]
+ fn vti_enter_outside_paste_becomes_enter_key() {
+ assert_eq!(
+ translate([key_char('\r')]),
+ vec![crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Enter,
+ modifiers: 0,
+ kind: crate::protocol::ClientKeyKind::Press,
+ }]
+ );
+ }
+
+ #[test]
+ 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,
+ },
+ ]
+ );
+ }
+
+ #[test]
+ fn vti_shift_enter_record_preserves_shift_modifier() {
+ assert_eq!(
+ translate([key_vk_with_unicode(0x0d, '\r', 0x0010)]),
+ vec![crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Enter,
+ modifiers: crossterm::event::KeyModifiers::SHIFT.bits(),
+ kind: crate::protocol::ClientKeyKind::Press,
+ }]
+ );
+ }
+
+ #[test]
+ fn vti_key_release_record_is_preserved() {
+ assert_eq!(
+ translate([key_vk_up_with_unicode(0x4a, 'j', 0x0008)]),
+ vec![crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Char('j'),
+ modifiers: crossterm::event::KeyModifiers::CONTROL.bits(),
+ kind: crate::protocol::ClientKeyKind::Release,
+ }]
+ );
+ }
+
+ #[test]
+ fn vti_synthetic_shift_enter_record_preserves_shift_modifier() {
+ assert_eq!(
+ translate([key_vk_with_unicode(0, '\r', 0x0010)]),
+ vec![crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Enter,
+ modifiers: crossterm::event::KeyModifiers::SHIFT.bits(),
+ kind: crate::protocol::ClientKeyKind::Press,
+ }]
+ );
+ }
+
+ #[test]
+ fn vti_repeated_synthetic_shift_enter_emits_repeats() {
+ assert_eq!(
+ translate([key_vk_with_unicode_repeat(0, '\r', 0x0010, 3)]),
+ vec![
+ crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Enter,
+ modifiers: crossterm::event::KeyModifiers::SHIFT.bits(),
+ kind: crate::protocol::ClientKeyKind::Press,
+ },
+ crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Enter,
+ modifiers: crossterm::event::KeyModifiers::SHIFT.bits(),
+ kind: crate::protocol::ClientKeyKind::Repeat,
+ },
+ crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Enter,
+ modifiers: crossterm::event::KeyModifiers::SHIFT.bits(),
+ kind: crate::protocol::ClientKeyKind::Repeat,
+ },
+ ]
+ );
+ }
+
+ #[test]
+ fn vti_synthetic_shift_enter_inside_paste_stays_in_paste_payload() {
+ let mut records: Vec<_> = "\x1b[200~alpha".chars().map(key_char).collect();
+ records.push(key_vk_with_unicode(0, '\r', 0x0010));
+ records.extend("bravo\x1b[201~".chars().map(key_char));
+
+ assert_eq!(
+ translate(records),
+ vec![crate::protocol::ClientInputEvent::Paste {
+ text: "alpha\rbravo".into(),
+ }]
+ );
+ }
+
+ #[test]
+ fn vti_vk_return_inside_paste_stays_in_paste_payload() {
+ let mut records: Vec<_> = "\x1b[200~alpha".chars().map(key_char).collect();
+ records.push(key_vk_with_unicode(0x0d, '\r', 0));
+ records.extend("bravo\x1b[201~".chars().map(key_char));
+
+ assert_eq!(
+ translate(records),
+ vec![crate::protocol::ClientInputEvent::Paste {
+ text: "alpha\rbravo".into(),
+ }]
+ );
+ }
+
+ #[test]
+ fn vti_vk_return_release_inside_paste_is_suppressed() {
+ let mut records: Vec<_> = "\x1b[200~alpha".chars().map(key_char).collect();
+ records.push(key_vk_up_with_unicode(0x0d, '\r', 0));
+ records.extend("bravo\x1b[201~".chars().map(key_char));
+
+ assert_eq!(
+ translate(records),
+ vec![crate::protocol::ClientInputEvent::Paste {
+ text: "alphabravo".into(),
+ }]
+ );
+ }
+
+ #[test]
+ fn vti_win32_input_mode_shift_enter_preserves_shift_modifier() {
+ let records =
+ "\x1b[16;42;0;1;16;1_\x1b[13;28;13;1;16;1_\x1b[13;28;13;0;16;1_\x1b[16;42;0;0;0;1_"
+ .chars()
+ .map(key_char);
+
+ assert_eq!(
+ translate(records),
+ vec![
+ crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Enter,
+ modifiers: crossterm::event::KeyModifiers::SHIFT.bits(),
+ kind: crate::protocol::ClientKeyKind::Press,
+ },
+ crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Enter,
+ modifiers: crossterm::event::KeyModifiers::SHIFT.bits(),
+ kind: crate::protocol::ClientKeyKind::Release,
+ },
+ ]
+ );
+ }
+
+ #[test]
+ fn vti_win32_input_mode_plain_enter_stays_plain_enter() {
+ let records = "\x1b[13;28;13;1;0;1_\x1b[13;28;13;0;0;1_"
+ .chars()
+ .map(key_char);
+
+ assert_eq!(
+ translate(records),
+ vec![
+ crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Enter,
+ modifiers: 0,
+ kind: crate::protocol::ClientKeyKind::Press,
+ },
+ crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Enter,
+ modifiers: 0,
+ kind: crate::protocol::ClientKeyKind::Release,
+ },
+ ]
+ );
+ }
+
+ #[test]
+ fn vti_win32_input_mode_ctrl_j_preserves_lf_control_key() {
+ let records = "\x1b[74;36;10;1;8;1_\x1b[74;36;10;0;8;1_"
+ .chars()
+ .map(key_char);
+
+ assert_eq!(
+ translate(records),
+ vec![
+ crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Char('j'),
+ modifiers: crossterm::event::KeyModifiers::CONTROL.bits(),
+ kind: crate::protocol::ClientKeyKind::Press,
+ },
+ crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Char('j'),
+ modifiers: crossterm::event::KeyModifiers::CONTROL.bits(),
+ kind: crate::protocol::ClientKeyKind::Release,
+ },
+ ]
+ );
+ }
+
+ #[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);
+
+ assert_eq!(
+ translate(records),
+ vec![
+ crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Char('a'),
+ modifiers: 0,
+ kind: crate::protocol::ClientKeyKind::Press,
+ },
+ crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Char('a'),
+ modifiers: 0,
+ kind: crate::protocol::ClientKeyKind::Release,
+ },
+ ]
+ );
+ }
+
+ #[test]
+ fn vti_win32_input_mode_sequence_inside_bracketed_paste_stays_payload() {
+ let records = "\x1b[200~alpha\x1b[13;28;13;1;16;1_bravo\x1b[201~"
+ .chars()
+ .map(key_char);
+
+ assert_eq!(
+ translate(records),
+ vec![crate::protocol::ClientInputEvent::Paste {
+ text: "alpha\x1b[13;28;13;1;16;1_bravo".into(),
+ }]
+ );
+ }
+
+ #[test]
+ fn vti_win32_input_mode_leaves_mouse_sequence_for_raw_parser() {
+ let records = "\x1b[<0;3;4M".chars().map(key_char);
+
+ assert_eq!(
+ translate(records),
+ vec![crate::protocol::ClientInputEvent::Mouse {
+ kind: crate::protocol::ClientMouseKind::Down(
+ crate::protocol::ClientMouseButton::Left,
+ ),
+ column: 2,
+ row: 3,
+ modifiers: 0,
+ }]
+ );
+ }
+
+ #[test]
+ fn vti_escape_arrow_sequence_becomes_arrow_key() {
+ let records = "\x1b[A".chars().map(key_char);
+ assert_eq!(
+ translate(records),
+ vec![crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Up,
+ modifiers: 0,
+ kind: crate::protocol::ClientKeyKind::Press,
+ }]
+ );
+ }
+
+ #[test]
+ fn vti_lone_escape_flushes_only_after_idle() {
+ let mut translator = WindowsInputTranslator::default();
+ assert!(translator.translate(key_char('\x1b')).is_empty());
+ assert_eq!(
+ translator.idle(),
+ vec![crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Esc,
+ modifiers: 0,
+ kind: crate::protocol::ClientKeyKind::Press,
+ }]
+ );
+ }
+
+ #[test]
+ fn vti_synthetic_shift_enter_after_lone_escape_stays_semantic() {
+ let mut translator = WindowsInputTranslator::default();
+ assert!(translator.translate(key_char('\x1b')).is_empty());
+ assert_eq!(
+ translator.translate(key_vk_with_unicode(0, '\r', 0x0010)),
+ 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::Enter,
+ modifiers: crossterm::event::KeyModifiers::SHIFT.bits(),
+ kind: crate::protocol::ClientKeyKind::Press,
+ },
+ ]
+ );
+ }
+
+ #[test]
+ fn vti_semantic_event_flushes_pending_raw_first() {
+ let mut translator = WindowsInputTranslator::default();
+ assert!(translator.translate(key_char('\x1b')).is_empty());
+ assert_eq!(
+ translator.translate(key_vk(0x26, 0)),
+ 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::Up,
+ modifiers: 0,
+ kind: crate::protocol::ClientKeyKind::Press,
+ },
+ ]
+ );
+ }
+
+ #[test]
+ fn vti_real_ctrl_c_record_stays_semantic() {
+ assert_eq!(
+ translate([key_vk_with_unicode(0x43, '\u{3}', 0x0008)]),
+ vec![crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Char('c'),
+ modifiers: crossterm::event::KeyModifiers::CONTROL.bits(),
+ kind: crate::protocol::ClientKeyKind::Press,
+ }]
+ );
+ }
+
+ #[test]
+ fn vti_right_alt_special_key_preserves_alt_modifier() {
+ assert_eq!(
+ translate([key_vk(0x26, 0x0001)]),
+ vec![crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Up,
+ modifiers: crossterm::event::KeyModifiers::ALT.bits(),
+ kind: crate::protocol::ClientKeyKind::Press,
+ }]
+ );
+ }
+
+ #[test]
+ fn vti_altgr_printable_key_is_not_terminal_alt_prefix() {
+ assert_eq!(
+ translate([key_vk_with_unicode(0x32, '@', 0x0001 | 0x0008)]),
+ vec![crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Char('@'),
+ modifiers: 0,
+ kind: crate::protocol::ClientKeyKind::Press,
+ }]
+ );
+ }
+
+ #[test]
+ fn vti_alt_code_unicode_on_alt_release_is_preserved() {
+ assert_eq!(
+ translate([key_vk_up_with_unicode(0x12, 'é', 0)]),
+ vec![crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Char('é'),
+ modifiers: 0,
+ kind: crate::protocol::ClientKeyKind::Press,
+ }]
+ );
+ }
+
+ #[test]
+ fn vti_semantic_surrogate_pair_preserves_emoji() {
+ let mut translator = WindowsInputTranslator::default();
+ let high = key_vk_with_utf16(0xe7, 0xd83d);
+ let low = key_vk_with_utf16(0xe7, 0xde42);
+
+ assert!(translator.translate(high).is_empty());
+ assert_eq!(
+ translator.translate(low),
+ vec![crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Char('🙂'),
+ modifiers: 0,
+ kind: crate::protocol::ClientKeyKind::Press,
+ }]
+ );
+ }
+
+ #[test]
+ fn vti_surrogate_pair_paste_preserves_emoji() {
+ let mut translator = WindowsInputTranslator::default();
+ let records = [
+ key_char('\x1b'),
+ key_char('['),
+ key_char('2'),
+ key_char('0'),
+ key_char('0'),
+ key_char('~'),
+ key_vk_with_utf16(0, 0xd83d),
+ key_vk_with_utf16(0, 0xde42),
+ key_char('\x1b'),
+ key_char('['),
+ key_char('2'),
+ key_char('0'),
+ key_char('1'),
+ key_char('~'),
+ ];
+
+ let events = records
+ .into_iter()
+ .flat_map(|record| translator.translate(record))
+ .collect::<Vec<_>>();
+ assert_eq!(
+ events,
+ vec![crate::protocol::ClientInputEvent::Paste {
+ text: "🙂".into()
+ }]
+ );
+ }
+
+ #[test]
+ fn vti_nonzero_vk_surrogate_pair_inside_paste_preserves_emoji() {
+ let mut translator = WindowsInputTranslator::default();
+ let high = key_vk_with_utf16(0xe7, 0xd83d);
+ let low = key_vk_with_utf16(0xe7, 0xde42);
+ let records = "\x1b[200~"
+ .chars()
+ .map(key_char)
+ .chain([high, low])
+ .chain("\x1b[201~".chars().map(key_char));
+
+ let events = records
+ .into_iter()
+ .flat_map(|record| translator.translate(record))
+ .collect::<Vec<_>>();
+ assert_eq!(
+ events,
+ vec![crate::protocol::ClientInputEvent::Paste {
+ text: "🙂".into()
+ }]
+ );
+ }
+
+ #[test]
+ fn vti_mouse_press_release_records_are_preserved() {
+ let events = translate([
+ WindowsInputRecord::Mouse(WindowsMouseRecord {
+ x: 3,
+ y: 4,
+ button_state: 0x0001,
+ control_key_state: 0,
+ event_flags: 0,
+ }),
+ WindowsInputRecord::Mouse(WindowsMouseRecord {
+ x: 3,
+ y: 4,
+ button_state: 0,
+ control_key_state: 0,
+ event_flags: 0,
+ }),
+ ]);
+
+ assert_eq!(
+ events,
+ vec![
+ crate::protocol::ClientInputEvent::Mouse {
+ kind: crate::protocol::ClientMouseKind::Down(
+ crate::protocol::ClientMouseButton::Left,
+ ),
+ column: 3,
+ row: 4,
+ modifiers: 0,
+ },
+ crate::protocol::ClientInputEvent::Mouse {
+ kind: crate::protocol::ClientMouseKind::Up(
+ crate::protocol::ClientMouseButton::Left,
+ ),
+ column: 3,
+ row: 4,
+ modifiers: 0,
+ },
+ ]
+ );
+ }
+
+ #[test]
+ fn vti_horizontal_wheel_records_match_crossterm_direction() {
+ let events = translate([
+ WindowsInputRecord::Mouse(WindowsMouseRecord {
+ x: 3,
+ y: 4,
+ button_state: 0xffff_0000,
+ control_key_state: 0,
+ event_flags: 0x0008,
+ }),
+ WindowsInputRecord::Mouse(WindowsMouseRecord {
+ x: 3,
+ y: 4,
+ button_state: 0x0001_0000,
+ control_key_state: 0,
+ event_flags: 0x0008,
+ }),
+ ]);
+
+ assert_eq!(
+ events,
+ vec![
+ crate::protocol::ClientInputEvent::Mouse {
+ kind: crate::protocol::ClientMouseKind::ScrollLeft,
+ column: 3,
+ row: 4,
+ modifiers: 0,
+ },
+ crate::protocol::ClientInputEvent::Mouse {
+ kind: crate::protocol::ClientMouseKind::ScrollRight,
+ column: 3,
+ row: 4,
+ modifiers: 0,
+ },
+ ]
+ );
+ }
+
+ #[test]
+ fn vti_focus_records_are_preserved() {
+ assert_eq!(
+ translate([
+ WindowsInputRecord::Focus(true),
+ WindowsInputRecord::Focus(false)
+ ]),
+ vec![
+ crate::protocol::ClientInputEvent::FocusGained,
+ crate::protocol::ClientInputEvent::FocusLost,
+ ]
+ );
+ }
+}
diff --git a/src/client/mod.rs b/src/client/mod.rs
index 834edd2b..8845e446 100644
--- a/src/client/mod.rs
+++ b/src/client/mod.rs
@@ -354,6 +354,28 @@ fn setup_terminal_with_capabilities(
}
}
+ #[cfg(windows)]
+ let windows_virtual_terminal_input =
+ if enable_client_protocols && windows_vti_input_backend_enabled() {
+ enable_windows_virtual_terminal_input()
+ } else {
+ WindowsVirtualTerminalInputSetup::default()
+ };
+
+ #[cfg(windows)]
+ if enable_client_protocols
+ && windows_vti_input_backend_enabled()
+ && windows_virtual_terminal_input.active
+ && windows_win32_input_mode_enabled()
+ {
+ if let Err(err) = enable_windows_win32_input_mode(&mut io::stdout()) {
+ if let Some(mode) = windows_virtual_terminal_input.restore_mode {
+ restore_windows_input_mode_value(mode);
+ }
+ return Err(err);
+ }
+ }
+
let modify_other_keys_mode = enable_client_protocols
.then(|| {
crate::input::host_modify_other_keys_mode(
@@ -371,6 +393,8 @@ fn setup_terminal_with_capabilities(
Ok(TerminalGuard {
reset_modify_other_keys: modify_other_keys_mode.is_some(),
reset_host_color_scheme_reports: host_color_scheme_reports,
+ #[cfg(windows)]
+ restore_windows_input_mode: windows_virtual_terminal_input.restore_mode,
})
}
@@ -382,6 +406,8 @@ fn should_enable_host_color_scheme_reports(enable_client_protocols: bool) -> boo
struct TerminalGuard {
reset_modify_other_keys: bool,
reset_host_color_scheme_reports: bool,
+ #[cfg(windows)]
+ restore_windows_input_mode: Option<u32>,
}
fn write_host_color_scheme_report_mode(
@@ -411,6 +437,90 @@ fn write_terminal_restore_postlude(
writer.flush()
}
+#[cfg(windows)]
+#[derive(Default)]
+struct WindowsVirtualTerminalInputSetup {
+ active: bool,
+ restore_mode: Option<u32>,
+}
+
+#[cfg(windows)]
+fn enable_windows_virtual_terminal_input() -> WindowsVirtualTerminalInputSetup {
+ use windows_sys::Win32::Foundation::{HANDLE, INVALID_HANDLE_VALUE};
+ use windows_sys::Win32::System::Console::{
+ GetConsoleMode, GetStdHandle, SetConsoleMode, ENABLE_VIRTUAL_TERMINAL_INPUT,
+ STD_INPUT_HANDLE,
+ };
+
+ let handle: HANDLE = unsafe { GetStdHandle(STD_INPUT_HANDLE) };
+ if handle.is_null() || handle == INVALID_HANDLE_VALUE {
+ tracing::warn!("failed to get Windows console input handle for VT input");
+ return WindowsVirtualTerminalInputSetup::default();
+ }
+
+ let mut mode = 0;
+ if unsafe { GetConsoleMode(handle, &mut mode) } == 0 {
+ tracing::warn!("failed to read Windows console input mode for VT input");
+ return WindowsVirtualTerminalInputSetup::default();
+ }
+
+ let desired = windows_virtual_terminal_input_mode(mode);
+ if desired == mode {
+ return WindowsVirtualTerminalInputSetup {
+ active: true,
+ restore_mode: None,
+ };
+ }
+
+ if unsafe { SetConsoleMode(handle, desired) } == 0 {
+ tracing::warn!("failed to enable Windows virtual terminal input");
+ return WindowsVirtualTerminalInputSetup::default();
+ }
+
+ let mut applied = 0;
+ if unsafe { GetConsoleMode(handle, &mut applied) } == 0 {
+ tracing::warn!("failed to verify Windows virtual terminal input mode");
+ let _ = unsafe { SetConsoleMode(handle, mode) };
+ return WindowsVirtualTerminalInputSetup::default();
+ }
+ if applied & ENABLE_VIRTUAL_TERMINAL_INPUT == 0 {
+ tracing::warn!("Windows virtual terminal input bit did not stick");
+ let _ = unsafe { SetConsoleMode(handle, mode) };
+ return WindowsVirtualTerminalInputSetup::default();
+ }
+
+ WindowsVirtualTerminalInputSetup {
+ active: true,
+ restore_mode: Some(mode),
+ }
+}
+
+#[cfg(windows)]
+fn windows_vti_input_backend_enabled() -> bool {
+ std::env::var("HERDR_WINDOWS_INPUT_BACKEND")
+ .map(|backend| !backend.eq_ignore_ascii_case("crossterm"))
+ .unwrap_or(true)
+}
+
+#[cfg(any(windows, test))]
+fn windows_virtual_terminal_input_mode(mode: u32) -> u32 {
+ mode | 0x0200
+}
+
+#[cfg(windows)]
+fn restore_windows_input_mode_value(mode: u32) {
+ use windows_sys::Win32::Foundation::{HANDLE, INVALID_HANDLE_VALUE};
+ use windows_sys::Win32::System::Console::{GetStdHandle, SetConsoleMode, STD_INPUT_HANDLE};
+
+ let handle: HANDLE = unsafe { GetStdHandle(STD_INPUT_HANDLE) };
+ if handle.is_null() || handle == INVALID_HANDLE_VALUE {
+ return;
+ }
+ if unsafe { SetConsoleMode(handle, mode) } == 0 {
+ tracing::warn!("failed to restore Windows console input mode");
+ }
+}
+
fn set_mouse_capture(enabled: bool) -> io::Result<()> {
if enabled {
execute!(io::stdout(), EnableMouseCapture)
@@ -424,7 +534,11 @@ fn set_mouse_capture(enabled: bool) -> io::Result<()> {
}
}
-fn restore_terminal_state(reset_modify_other_keys: bool, reset_host_color_scheme_reports: bool) {
+fn restore_terminal_state(
+ reset_modify_other_keys: bool,
+ reset_host_color_scheme_reports: bool,
+ #[cfg(windows)] restore_windows_input_mode: Option<u32>,
+) {
let _ = clear_received_kitty_graphics(&mut io::stdout());
// Reset modifyOtherKeys if we enabled it.
@@ -434,14 +548,25 @@ fn restore_terminal_state(reset_modify_other_keys: bool, reset_host_color_scheme
}
let _ = pop_keyboard_enhancement_flags();
+
let _ = execute!(
io::stdout(),
DisableFocusChange,
DisableBracketedPaste,
DisableMouseCapture
);
+ #[cfg(windows)]
+ if let Some(mode) = restore_windows_input_mode {
+ restore_windows_input_mode_value(mode);
+ }
+
ratatui::restore();
let _ = write_terminal_restore_postlude(&mut io::stdout(), reset_host_color_scheme_reports);
+
+ #[cfg(windows)]
+ if windows_vti_input_backend_enabled() && windows_win32_input_mode_enabled() {
+ let _ = disable_windows_win32_input_mode(&mut io::stdout());
+ }
}
#[cfg(not(windows))]
@@ -467,11 +592,32 @@ fn pop_keyboard_enhancement_flags() -> io::Result<()> {
Ok(())
}
+#[cfg(windows)]
+fn windows_win32_input_mode_enabled() -> bool {
+ std::env::var("HERDR_WINDOWS_INPUT_PROBE")
+ .map(|probe| probe.eq_ignore_ascii_case("win32"))
+ .unwrap_or(true)
+}
+
+#[cfg(windows)]
+fn enable_windows_win32_input_mode(writer: &mut impl std::io::Write) -> io::Result<()> {
+ writer.write_all(b"\x1b[?9001h")?;
+ writer.flush()
+}
+
+#[cfg(windows)]
+fn disable_windows_win32_input_mode(writer: &mut impl std::io::Write) -> io::Result<()> {
+ writer.write_all(b"\x1b[?9001l")?;
+ writer.flush()
+}
+
impl Drop for TerminalGuard {
fn drop(&mut self) {
restore_terminal_state(
self.reset_modify_other_keys,
self.reset_host_color_scheme_reports,
+ #[cfg(windows)]
+ self.restore_windows_input_mode,
);
}
}
@@ -745,11 +891,15 @@ fn run_client_with_mode(
// Install a panic hook to restore the terminal on panic (same as monolithic).
let panic_resets_modify_other_keys = terminal_guard.reset_modify_other_keys;
let panic_resets_host_color_scheme_reports = terminal_guard.reset_host_color_scheme_reports;
+ #[cfg(windows)]
+ let panic_restore_windows_input_mode = terminal_guard.restore_windows_input_mode;
let original_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
restore_terminal_state(
panic_resets_modify_other_keys,
panic_resets_host_color_scheme_reports,
+ #[cfg(windows)]
+ panic_restore_windows_input_mode,
);
original_hook(info);
}));
@@ -1072,6 +1222,10 @@ async fn run_client_loop(
let desired = enabled;
if desired != state.mouse_capture_active {
set_mouse_capture(desired).map_err(ClientError::ConnectionFailed)?;
+ #[cfg(windows)]
+ if windows_vti_input_backend_enabled() {
+ let _ = enable_windows_virtual_terminal_input();
+ }
state.mouse_capture_active = desired;
}
}
@@ -1543,6 +1697,12 @@ mod tests {
}
}
+ #[test]
+ fn windows_virtual_terminal_input_mode_sets_only_vti_bit() {
+ assert_eq!(windows_virtual_terminal_input_mode(0x01f0), 0x03f0);
+ assert_eq!(windows_virtual_terminal_input_mode(0x03f0), 0x03f0);
+ }
+
struct EnvVarsRemovedGuard {
previous: Vec<(&'static str, Option<OsString>)>,
}
diff --git a/src/protocol/wire.rs b/src/protocol/wire.rs
index 79c051b7..1bbfd9a8 100644
--- a/src/protocol/wire.rs
+++ b/src/protocol/wire.rs
@@ -130,7 +130,7 @@ pub enum ClientInputEvent {
}
impl ClientKeyKind {
- #[cfg(windows)]
+ #[cfg(any(windows, test))]
pub(crate) fn from_crossterm(kind: crossterm::event::KeyEventKind) -> Self {
match kind {
crossterm::event::KeyEventKind::Press => Self::Press,
@@ -149,7 +149,7 @@ impl ClientKeyKind {
}
impl ClientKeyCode {
- #[cfg(windows)]
+ #[cfg(any(windows, test))]
pub(crate) fn from_crossterm(code: crossterm::event::KeyCode) -> Option<Self> {
use crossterm::event::KeyCode;
Some(match code {
@@ -201,7 +201,7 @@ impl ClientKeyCode {
}
impl ClientMouseButton {
- #[cfg(windows)]
+ #[cfg(any(windows, test))]
pub(crate) fn from_crossterm(button: crossterm::event::MouseButton) -> Self {
match button {
crossterm::event::MouseButton::Left => Self::Left,
@@ -220,7 +220,7 @@ impl ClientMouseButton {
}
impl ClientMouseKind {
- #[cfg(windows)]
+ #[cfg(any(windows, test))]
pub(crate) fn from_crossterm(kind: crossterm::event::MouseEventKind) -> Option<Self> {
use crossterm::event::MouseEventKind;
Some(match kind {
diff --git a/src/raw_input.rs b/src/raw_input.rs
index 5638cd7a..b07641a5 100644
--- a/src/raw_input.rs
+++ b/src/raw_input.rs
@@ -142,6 +142,11 @@ impl RawInputFramer {
self.byte_framer.has_pending_input()
}
+ #[cfg(any(windows, test))]
+ pub(crate) fn has_pending_bracketed_paste(&self) -> bool {
+ self.byte_framer.has_pending_bracketed_paste()
+ }
+
pub(crate) fn flush_timeout(&mut self) -> Vec<RawInputEvent> {
Self::events_from_chunks(self.byte_framer.flush_timeout())
}
@@ -198,6 +203,12 @@ impl RawInputByteFramer {
!self.buffer.is_empty()
}
+ #[cfg(any(windows, test))]
+ pub(crate) fn has_pending_bracketed_paste(&self) -> bool {
+ self.buffer.starts_with(BRACKETED_PASTE_START)
+ && find_subsequence(&self.buffer, BRACKETED_PASTE_END).is_none()
+ }
+
pub(crate) fn flush_timeout(&mut self) -> Vec<Vec<u8>> {
let mut chunks = self.drain_available_chunks();