Skip to main content

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}