par_term_input/lib.rs
1//! Keyboard input handling and VT byte sequence generation for par-term.
2//!
3//! This crate converts `winit` keyboard events into the terminal input byte
4//! sequences expected by shell applications. It handles character input,
5//! named keys, function keys, modifier combinations, Option/Alt key modes,
6//! clipboard operations, and the modifyOtherKeys protocol extension.
7//!
8//! The primary entry point is [`InputHandler`], which tracks modifier state
9//! and translates each [`winit::event::KeyEvent`] into a `Vec<u8>` suitable
10//! for writing directly to the PTY.
11
12use arboard::Clipboard;
13use winit::event::{ElementState, KeyEvent, Modifiers};
14use winit::keyboard::{Key, KeyCode, ModifiersState, NamedKey, PhysicalKey};
15
16use par_term_config::OptionKeyMode;
17
18/// Input handler for converting winit events to terminal input
19pub struct InputHandler {
20 pub modifiers: Modifiers,
21 clipboard: Option<Clipboard>,
22 /// Option key mode for left Option/Alt key
23 pub left_option_key_mode: OptionKeyMode,
24 /// Option key mode for right Option/Alt key
25 pub right_option_key_mode: OptionKeyMode,
26 /// Track which Alt key is currently pressed (for determining mode on character input)
27 /// True = left Alt is pressed, False = right Alt or no Alt
28 left_alt_pressed: bool,
29 /// True = right Alt is pressed
30 right_alt_pressed: bool,
31}
32
33impl InputHandler {
34 /// Create a new input handler
35 pub fn new() -> Self {
36 let clipboard = Clipboard::new().ok();
37 if clipboard.is_none() {
38 log::warn!("Failed to initialize clipboard support");
39 }
40
41 Self {
42 modifiers: Modifiers::default(),
43 clipboard,
44 left_option_key_mode: OptionKeyMode::default(),
45 right_option_key_mode: OptionKeyMode::default(),
46 left_alt_pressed: false,
47 right_alt_pressed: false,
48 }
49 }
50
51 /// Update the current modifier state
52 pub fn update_modifiers(&mut self, modifiers: Modifiers) {
53 self.modifiers = modifiers;
54 }
55
56 /// Update Option/Alt key modes from config
57 pub fn update_option_key_modes(&mut self, left: OptionKeyMode, right: OptionKeyMode) {
58 self.left_option_key_mode = left;
59 self.right_option_key_mode = right;
60 }
61
62 /// Track Alt key press/release to know which Alt is active
63 pub fn track_alt_key(&mut self, event: &KeyEvent) {
64 // Check if this is an Alt key event by physical key
65 let is_left_alt = matches!(event.physical_key, PhysicalKey::Code(KeyCode::AltLeft));
66 let is_right_alt = matches!(event.physical_key, PhysicalKey::Code(KeyCode::AltRight));
67
68 if is_left_alt {
69 self.left_alt_pressed = event.state == ElementState::Pressed;
70 } else if is_right_alt {
71 self.right_alt_pressed = event.state == ElementState::Pressed;
72 }
73 }
74
75 /// Defensive modifier-state sync from physical key events.
76 ///
77 /// On Windows, `WM_NCACTIVATE(false)` fires when a notification, popup, or system
78 /// dialog briefly steals visual focus. Winit responds by emitting `ModifiersChanged(empty)`,
79 /// which clears our modifier state. Because keyboard focus is never actually lost,
80 /// no `WM_SETFOCUS` fires to restore the state. Subsequent `WM_KEYDOWN` messages should
81 /// re-trigger `update_modifiers` inside winit, but in practice there is a window where
82 /// the state stays zeroed, causing Shift/Ctrl/Alt to stop working until the key is
83 /// physically released and re-pressed.
84 ///
85 /// To guard against this, we synthesize modifier updates directly from `KeyboardInput`
86 /// events for physical modifier keys. This runs after `ModifiersChanged` has already been
87 /// applied (winit guarantees `ModifiersChanged` fires before `KeyboardInput` for the same
88 /// key), so it is a no-op in the normal path and only corrects state when winit's
89 /// `ModifiersChanged` is stale or missing.
90 pub fn sync_modifier_from_key_event(&mut self, event: &KeyEvent) {
91 let pressed = event.state == ElementState::Pressed;
92 let mut state = self.modifiers.state();
93
94 match event.physical_key {
95 PhysicalKey::Code(KeyCode::ShiftLeft | KeyCode::ShiftRight) => {
96 state.set(ModifiersState::SHIFT, pressed);
97 }
98 PhysicalKey::Code(KeyCode::ControlLeft | KeyCode::ControlRight) => {
99 state.set(ModifiersState::CONTROL, pressed);
100 }
101 PhysicalKey::Code(KeyCode::AltLeft | KeyCode::AltRight) => {
102 state.set(ModifiersState::ALT, pressed);
103 }
104 PhysicalKey::Code(KeyCode::SuperLeft | KeyCode::SuperRight) => {
105 state.set(ModifiersState::SUPER, pressed);
106 }
107 _ => return, // Not a modifier key — nothing to do
108 }
109
110 self.modifiers = Modifiers::from(state);
111 }
112
113 /// Get the active Option key mode based on which Alt key is pressed
114 fn get_active_option_mode(&self) -> OptionKeyMode {
115 // If both are pressed, prefer left (arbitrary but consistent)
116 // If only one is pressed, use that one's mode
117 // If neither is pressed (shouldn't happen when alt modifier is set), default to left
118 if self.left_alt_pressed {
119 self.left_option_key_mode
120 } else if self.right_alt_pressed {
121 self.right_option_key_mode
122 } else {
123 // Fallback: both modes are the same in most configs, so use left
124 self.left_option_key_mode
125 }
126 }
127
128 /// Apply Option/Alt key transformation based on the configured mode
129 fn apply_option_key_mode(&self, bytes: &mut Vec<u8>, original_char: char) {
130 let mode = self.get_active_option_mode();
131
132 match mode {
133 OptionKeyMode::Normal => {
134 // Normal mode: the character is already the special character from the OS
135 // (e.g., Option+f = ƒ on macOS). Don't modify it.
136 // The bytes already contain the correct character from winit.
137 }
138 OptionKeyMode::Meta => {
139 // Meta mode: set the high bit (8th bit) on the character
140 // This only works for ASCII characters (0-127)
141 if original_char.is_ascii() {
142 let meta_byte = (original_char as u8) | 0x80;
143 bytes.clear();
144 bytes.push(meta_byte);
145 }
146 // For non-ASCII, fall through to ESC mode behavior
147 else {
148 bytes.insert(0, 0x1b);
149 }
150 }
151 OptionKeyMode::Esc => {
152 // Esc mode: send ESC prefix before the character
153 // First, we need to use the base character, not the special character
154 // This requires getting the unmodified key
155 if original_char.is_ascii() {
156 bytes.clear();
157 bytes.push(0x1b); // ESC
158 bytes.push(original_char as u8);
159 } else {
160 // For non-ASCII original characters, just prepend ESC to what we have
161 bytes.insert(0, 0x1b);
162 }
163 }
164 }
165 }
166
167 /// Convert a keyboard event to terminal input bytes
168 ///
169 /// If `modify_other_keys_mode` is > 0, keys with modifiers will be reported
170 /// using the XTerm modifyOtherKeys format: CSI 27 ; modifier ; keycode ~
171 pub fn handle_key_event(&mut self, event: KeyEvent) -> Option<Vec<u8>> {
172 self.handle_key_event_with_mode(event, 0, false)
173 }
174
175 /// Convert a keyboard event to terminal input bytes with modifyOtherKeys support
176 ///
177 /// `modify_other_keys_mode`:
178 /// - 0: Disabled (normal key handling)
179 /// - 1: Report modifiers for special keys only
180 /// - 2: Report modifiers for all keys
181 ///
182 /// `application_cursor`: When true (DECCKM mode enabled), arrow keys send
183 /// SS3 sequences (ESC O A) instead of CSI sequences (ESC [ A).
184 pub fn handle_key_event_with_mode(
185 &mut self,
186 event: KeyEvent,
187 modify_other_keys_mode: u8,
188 application_cursor: bool,
189 ) -> Option<Vec<u8>> {
190 if event.state != ElementState::Pressed {
191 return None;
192 }
193
194 let ctrl = self.modifiers.state().control_key();
195 let alt = self.modifiers.state().alt_key();
196
197 // Check if we should use modifyOtherKeys encoding.
198 //
199 // Both mode 1 and mode 2 use the same encoding path here — the per-mode routing
200 // decisions are made inside `try_modify_other_keys_encoding` (e.g. the Shift-only
201 // exemption that matches iTerm2's reference implementation).
202 if modify_other_keys_mode > 0
203 && let Some(bytes) = self.try_modify_other_keys_encoding(&event)
204 {
205 return Some(bytes);
206 }
207
208 match event.logical_key {
209 // Character keys
210 Key::Character(ref s) => {
211 if ctrl {
212 // Handle Ctrl+key combinations
213 let ch = s.chars().next()?;
214
215 // Note: Ctrl+V paste is handled at higher level for bracketed paste support
216
217 if ch.is_ascii_alphabetic() {
218 // Ctrl+A through Ctrl+Z map to ASCII 1-26
219 let byte = (ch.to_ascii_lowercase() as u8) - b'a' + 1;
220 return Some(vec![byte]);
221 }
222 }
223
224 // Get the base character (without Alt modification) for Option key modes
225 // We need to look at the physical key to get the unmodified character
226 let base_char = self.get_base_character(&event);
227
228 // Regular character input
229 let mut bytes = s.as_bytes().to_vec();
230
231 // Handle Alt/Option key based on configured mode
232 if alt {
233 if let Some(base) = base_char {
234 self.apply_option_key_mode(&mut bytes, base);
235 } else {
236 // Fallback: if we can't determine base character, use the first char
237 let ch = s.chars().next().unwrap_or('\0');
238 self.apply_option_key_mode(&mut bytes, ch);
239 }
240 }
241
242 Some(bytes)
243 }
244
245 // Special keys
246 Key::Named(named_key) => {
247 // Handle Ctrl+Space specially - sends NUL (0x00)
248 if ctrl && matches!(named_key, NamedKey::Space) {
249 return Some(vec![0x00]);
250 }
251
252 // Note: Shift+Insert paste is handled at higher level for bracketed paste support
253
254 let shift = self.modifiers.state().shift_key();
255
256 // Compute xterm modifier parameter for named keys.
257 // Standard: bit0=Shift, bit1=Alt, bit2=Ctrl; value = bits + 1.
258 // Only applied when at least one modifier is held.
259 let has_modifier = shift || alt || ctrl;
260 let modifier_param = if has_modifier {
261 let mut bits = 0u8;
262 if shift {
263 bits |= 1;
264 }
265 if alt {
266 bits |= 2;
267 }
268 if ctrl {
269 bits |= 4;
270 }
271 Some(bits + 1)
272 } else {
273 None
274 };
275
276 // Keys that use the "letter" form: CSI 1;modifier letter (with modifier)
277 // or CSI letter / SS3 letter (without modifier).
278 // Note: SS3 (application cursor mode) is only used when no modifier is
279 // present — with a modifier the sequence switches to CSI form per xterm.
280 if let Some(suffix) = match named_key {
281 NamedKey::ArrowUp => Some('A'),
282 NamedKey::ArrowDown => Some('B'),
283 NamedKey::ArrowRight => Some('C'),
284 NamedKey::ArrowLeft => Some('D'),
285 NamedKey::Home => Some('H'),
286 NamedKey::End => Some('F'),
287 _ => None,
288 } {
289 return if let Some(m) = modifier_param {
290 // CSI 1 ; modifier letter
291 Some(format!("\x1b[1;{m}{suffix}").into_bytes())
292 } else if application_cursor
293 && matches!(
294 named_key,
295 NamedKey::ArrowUp
296 | NamedKey::ArrowDown
297 | NamedKey::ArrowRight
298 | NamedKey::ArrowLeft
299 )
300 {
301 // SS3 letter (application cursor, no modifier)
302 Some(format!("\x1bO{suffix}").into_bytes())
303 } else {
304 // CSI letter (normal mode, no modifier)
305 Some(format!("\x1b[{suffix}").into_bytes())
306 };
307 }
308
309 // Keys that use the "tilde" form: CSI keycode ; modifier ~ (with modifier)
310 // or CSI keycode ~ (without modifier).
311 if let Some(keycode) = match named_key {
312 NamedKey::Insert => Some(2),
313 NamedKey::Delete => Some(3),
314 NamedKey::PageUp => Some(5),
315 NamedKey::PageDown => Some(6),
316 NamedKey::F5 => Some(15),
317 NamedKey::F6 => Some(17),
318 NamedKey::F7 => Some(18),
319 NamedKey::F8 => Some(19),
320 NamedKey::F9 => Some(20),
321 NamedKey::F10 => Some(21),
322 NamedKey::F11 => Some(23),
323 NamedKey::F12 => Some(24),
324 _ => None,
325 } {
326 return if let Some(m) = modifier_param {
327 Some(format!("\x1b[{keycode};{m}~").into_bytes())
328 } else {
329 Some(format!("\x1b[{keycode}~").into_bytes())
330 };
331 }
332
333 // F1-F4 use SS3 form without modifier, CSI form with modifier.
334 // SS3 P/Q/R/S → CSI 1;modifier P/Q/R/S
335 if let Some(suffix) = match named_key {
336 NamedKey::F1 => Some('P'),
337 NamedKey::F2 => Some('Q'),
338 NamedKey::F3 => Some('R'),
339 NamedKey::F4 => Some('S'),
340 _ => None,
341 } {
342 return if let Some(m) = modifier_param {
343 Some(format!("\x1b[1;{m}{suffix}").into_bytes())
344 } else {
345 Some(format!("\x1bO{suffix}").into_bytes())
346 };
347 }
348
349 // Remaining keys with special handling (no modifier encoding)
350 let seq = match named_key {
351 // Shift+Enter sends LF (newline) for soft line breaks (like iTerm2)
352 // Regular Enter sends CR (carriage return) for command execution
353 NamedKey::Enter => {
354 if shift {
355 "\n"
356 } else {
357 "\r"
358 }
359 }
360 // Shift+Tab sends reverse-tab escape sequence (CSI Z)
361 // Regular Tab sends HT (horizontal tab)
362 NamedKey::Tab => {
363 if shift {
364 "\x1b[Z"
365 } else {
366 "\t"
367 }
368 }
369 NamedKey::Space => " ",
370 NamedKey::Backspace => "\x7f",
371 NamedKey::Escape => "\x1b",
372
373 _ => return None,
374 };
375
376 Some(seq.as_bytes().to_vec())
377 }
378
379 _ => None,
380 }
381 }
382
383 /// Try to encode a key event using modifyOtherKeys format
384 ///
385 /// Returns Some(bytes) if the key should be encoded with modifyOtherKeys,
386 /// None if normal handling should be used.
387 ///
388 /// modifyOtherKeys format: CSI 27 ; modifier ; keycode ~
389 /// Where modifier is:
390 /// - 2 = Shift
391 /// - 3 = Alt
392 /// - 4 = Shift+Alt
393 /// - 5 = Ctrl
394 /// - 6 = Shift+Ctrl
395 /// - 7 = Alt+Ctrl
396 /// - 8 = Shift+Alt+Ctrl
397 fn try_modify_other_keys_encoding(&self, event: &KeyEvent) -> Option<Vec<u8>> {
398 let ctrl = self.modifiers.state().control_key();
399 let alt = self.modifiers.state().alt_key();
400 let shift = self.modifiers.state().shift_key();
401
402 // No modifiers means no special encoding needed
403 if !ctrl && !alt && !shift {
404 return None;
405 }
406
407 // Get the base character for the key
408 let base_char = self.get_base_character(event)?;
409
410 // Skip modifyOtherKeys encoding for any Shift-only combination on printable
411 // characters, regardless of mode or character class.
412 //
413 // This matches iTerm2's reference implementation (sources/iTermModifyOtherKeysMapper.m
414 // and iTermModifyOtherKeysMapper1.m), which is confirmed by its `iTermModifyOtherKeys1Test`
415 // suite: for Shift+letter, Shift+digit, and Shift+symbol iTerm2 returns `nil` from
416 // `keyMapperStringForPreCocoaEvent` and lets Cocoa's text-input system emit the OS-
417 // resolved shifted character (`A`, `!`, `@`, `{`, etc.) directly to the PTY. The same
418 // rule applies in both mode 1 (via `shouldModifyOtherKeysForNumberEvent` / ...Symbol /
419 // ...RegularEvent returning NO) and mode 2 (via the base mapper returning nil unless
420 // Control is held).
421 //
422 // Why this is necessary: winit's `logical_key` already contains the layout-correct
423 // shifted character. TUI applications built on crossterm (Claude Code, etc.) that see
424 // a `CSI 27;2;49~` sequence cannot reverse-map the base codepoint `49` ('1') to the
425 // shifted codepoint `33` ('!') because they have no access to the OS keyboard layout
426 // tables — they just render the base character. Falling through to the normal
427 // `Key::Character` path below sends the winit-provided shifted character as raw bytes,
428 // which every application handles correctly.
429 //
430 // We intentionally keep Shift+Alt/Shift+Ctrl etc. encoded here because those carry
431 // modifier information that cannot be recovered from the character alone.
432 if shift && !ctrl && !alt {
433 return None;
434 }
435
436 // Calculate the modifier value
437 // bit 0 (1) = Shift
438 // bit 1 (2) = Alt
439 // bit 2 (4) = Ctrl
440 // The final value is bits + 1
441 let mut modifier_bits = 0u8;
442 if shift {
443 modifier_bits |= 1;
444 }
445 if alt {
446 modifier_bits |= 2;
447 }
448 if ctrl {
449 modifier_bits |= 4;
450 }
451
452 // Add 1 to get the XTerm modifier value (so no modifiers would be 1, but we already checked for that)
453 let modifier_value = modifier_bits + 1;
454
455 // Get the Unicode codepoint of the base character
456 let keycode = base_char as u32;
457
458 // Format: CSI 27 ; modifier ; keycode ~
459 // CSI = ESC [
460 Some(format!("\x1b[27;{};{}~", modifier_value, keycode).into_bytes())
461 }
462
463 /// Get the base character from a key event (the character without Alt modification)
464 /// This maps physical key codes to their unmodified ASCII characters
465 fn get_base_character(&self, event: &KeyEvent) -> Option<char> {
466 // Map physical key codes to their base characters
467 // This is needed because on macOS, Option+key produces a different logical character
468 match event.physical_key {
469 PhysicalKey::Code(code) => match code {
470 KeyCode::KeyA => Some('a'),
471 KeyCode::KeyB => Some('b'),
472 KeyCode::KeyC => Some('c'),
473 KeyCode::KeyD => Some('d'),
474 KeyCode::KeyE => Some('e'),
475 KeyCode::KeyF => Some('f'),
476 KeyCode::KeyG => Some('g'),
477 KeyCode::KeyH => Some('h'),
478 KeyCode::KeyI => Some('i'),
479 KeyCode::KeyJ => Some('j'),
480 KeyCode::KeyK => Some('k'),
481 KeyCode::KeyL => Some('l'),
482 KeyCode::KeyM => Some('m'),
483 KeyCode::KeyN => Some('n'),
484 KeyCode::KeyO => Some('o'),
485 KeyCode::KeyP => Some('p'),
486 KeyCode::KeyQ => Some('q'),
487 KeyCode::KeyR => Some('r'),
488 KeyCode::KeyS => Some('s'),
489 KeyCode::KeyT => Some('t'),
490 KeyCode::KeyU => Some('u'),
491 KeyCode::KeyV => Some('v'),
492 KeyCode::KeyW => Some('w'),
493 KeyCode::KeyX => Some('x'),
494 KeyCode::KeyY => Some('y'),
495 KeyCode::KeyZ => Some('z'),
496 KeyCode::Digit0 => Some('0'),
497 KeyCode::Digit1 => Some('1'),
498 KeyCode::Digit2 => Some('2'),
499 KeyCode::Digit3 => Some('3'),
500 KeyCode::Digit4 => Some('4'),
501 KeyCode::Digit5 => Some('5'),
502 KeyCode::Digit6 => Some('6'),
503 KeyCode::Digit7 => Some('7'),
504 KeyCode::Digit8 => Some('8'),
505 KeyCode::Digit9 => Some('9'),
506 KeyCode::Minus => Some('-'),
507 KeyCode::Equal => Some('='),
508 KeyCode::BracketLeft => Some('['),
509 KeyCode::BracketRight => Some(']'),
510 KeyCode::Backslash => Some('\\'),
511 KeyCode::Semicolon => Some(';'),
512 KeyCode::Quote => Some('\''),
513 KeyCode::Backquote => Some('`'),
514 KeyCode::Comma => Some(','),
515 KeyCode::Period => Some('.'),
516 KeyCode::Slash => Some('/'),
517 KeyCode::Space => Some(' '),
518 _ => None,
519 },
520 _ => None,
521 }
522 }
523
524 /// Paste text from clipboard (returns raw text, caller handles terminal conversion)
525 pub fn paste_from_clipboard(&mut self) -> Option<String> {
526 if let Some(ref mut clipboard) = self.clipboard {
527 match clipboard.get_text() {
528 Ok(text) => {
529 log::debug!("Pasting from clipboard: {} chars", text.len());
530 Some(text)
531 }
532 Err(e) => {
533 log::error!("Failed to get clipboard text: {}", e);
534 None
535 }
536 }
537 } else {
538 log::warn!("Clipboard not available");
539 None
540 }
541 }
542
543 /// Check if clipboard contains an image (used when text paste returns None
544 /// to determine if we should forward the paste event to the terminal for
545 /// image-aware applications like Claude Code)
546 pub fn clipboard_has_image(&mut self) -> bool {
547 if let Some(ref mut clipboard) = self.clipboard {
548 let has_image = clipboard.get_image().is_ok();
549 log::debug!("Clipboard image check: {}", has_image);
550 has_image
551 } else {
552 false
553 }
554 }
555
556 /// Copy text to clipboard
557 pub fn copy_to_clipboard(&mut self, text: &str) -> Result<(), String> {
558 if let Some(ref mut clipboard) = self.clipboard {
559 clipboard
560 .set_text(text.to_string())
561 .map_err(|e| format!("Failed to set clipboard text: {}", e))
562 } else {
563 Err("Clipboard not available".to_string())
564 }
565 }
566
567 /// Copy text to primary selection (Linux X11 only)
568 #[cfg(target_os = "linux")]
569 pub fn copy_to_primary_selection(&mut self, text: &str) -> Result<(), String> {
570 use arboard::SetExtLinux;
571
572 if let Some(ref mut clipboard) = self.clipboard {
573 clipboard
574 .set()
575 .clipboard(arboard::LinuxClipboardKind::Primary)
576 .text(text.to_string())
577 .map_err(|e| format!("Failed to set primary selection: {}", e))?;
578 Ok(())
579 } else {
580 Err("Clipboard not available".to_string())
581 }
582 }
583
584 /// Paste text from primary selection (Linux X11 only, returns raw text)
585 #[cfg(target_os = "linux")]
586 pub fn paste_from_primary_selection(&mut self) -> Option<String> {
587 use arboard::GetExtLinux;
588
589 if let Some(ref mut clipboard) = self.clipboard {
590 match clipboard
591 .get()
592 .clipboard(arboard::LinuxClipboardKind::Primary)
593 .text()
594 {
595 Ok(text) => {
596 log::debug!("Pasting from primary selection: {} chars", text.len());
597 Some(text)
598 }
599 Err(e) => {
600 log::error!("Failed to get primary selection text: {}", e);
601 None
602 }
603 }
604 } else {
605 log::warn!("Clipboard not available");
606 None
607 }
608 }
609
610 /// Fallback for non-Linux platforms - copy to primary selection not supported
611 #[cfg(not(target_os = "linux"))]
612 pub fn copy_to_primary_selection(&mut self, _text: &str) -> Result<(), String> {
613 Ok(()) // No-op on non-Linux platforms
614 }
615
616 /// Fallback for non-Linux platforms - paste from primary selection uses regular clipboard
617 #[cfg(not(target_os = "linux"))]
618 pub fn paste_from_primary_selection(&mut self) -> Option<String> {
619 self.paste_from_clipboard()
620 }
621}
622
623impl Default for InputHandler {
624 fn default() -> Self {
625 Self::new()
626 }
627}