Skip to main content

rpi_tui/
terminal.rs

1//! Terminal abstraction for TUI.
2//!
3//! Provides a trait for terminal operations and a `ProcessTerminal` implementation
4//! using crossterm.
5
6use std::io::{self, IsTerminal, Write};
7use std::sync::{Arc, Mutex};
8use std::time::Duration;
9
10use crossterm::{
11    event::{self, Event, KeyEvent, MouseEvent},
12    terminal as cterm,
13};
14use thiserror::Error;
15
16/// Mouse tracking prevents native text selection in macOS terminal emulators.
17/// RPI only consumes wheel events, so keep the platform default selectable.
18const fn mouse_tracking_enabled_for(is_macos: bool) -> bool {
19    !is_macos
20}
21
22const fn mouse_tracking_enabled_by_default() -> bool {
23    mouse_tracking_enabled_for(cfg!(target_os = "macos"))
24}
25
26/// The kitty keyboard protocol (`\x1b[>u`) makes macOS IME composition stop
27/// working (no candidate window → CJK input breaks). Disable it on macOS by
28/// default; `RPI_KITTY_KEYBOARD=1` re-enables it for push-to-talk-style
29/// key-release handling, `RPI_KITTY_KEYBOARD=0` forces it off elsewhere.
30fn keyboard_protocol_enabled() -> bool {
31    if let Some(value) = std::env::var_os("RPI_KITTY_KEYBOARD") {
32        return value != "0";
33    }
34    !cfg!(target_os = "macos")
35}
36
37/// Errors that can occur during terminal operations.
38#[derive(Debug, Error)]
39pub enum TerminalError {
40    #[error("IO error: {0}")]
41    Io(#[from] io::Error),
42    #[error("Terminal error: {0}")]
43    Terminal(String),
44}
45
46/// Terminal capabilities and state.
47#[derive(Debug, Clone, Default)]
48pub struct TerminalInfo {
49    pub columns: usize,
50    pub rows: usize,
51    pub supports_true_color: bool,
52    pub supports_mouse: bool,
53    pub supports_kitty_keyboard: bool,
54}
55
56/// Input event types.
57#[derive(Debug, Clone)]
58pub enum InputEvent {
59    Key(KeyEvent),
60    Mouse(MouseEvent),
61    Resize(usize, usize),
62    FocusGained,
63    FocusLost,
64}
65
66/// Terminal trait for abstracting terminal operations.
67///
68/// This allows for different terminal implementations (real terminal, virtual terminal for testing).
69pub trait Terminal: Send + Sync {
70    /// Get terminal information (columns, rows, capabilities).
71    fn info(&self) -> TerminalInfo;
72
73    /// Get current terminal width in columns.
74    fn columns(&self) -> usize {
75        self.info().columns
76    }
77
78    /// Get current terminal height in rows.
79    fn rows(&self) -> usize {
80        self.info().rows
81    }
82
83    /// Write raw bytes to the terminal.
84    fn write(&self, data: &str);
85
86    /// Hide the cursor.
87    fn hide_cursor(&self);
88
89    /// Show the cursor.
90    fn show_cursor(&self);
91
92    /// Move cursor to position (0-indexed).
93    fn move_cursor(&self, row: usize, col: usize);
94
95    /// Clear the screen.
96    fn clear_screen(&self);
97
98    /// Set terminal title.
99    fn set_title(&self, title: &str);
100
101    /// Enable mouse events.
102    fn enable_mouse(&self);
103
104    /// Disable mouse events.
105    fn disable_mouse(&self);
106
107    /// Enter terminal raw mode (required for `event::read()` to deliver
108    /// individual key events without line buffering). Pairs with
109    /// [`Terminal::stop`], which exits raw mode.
110    ///
111    /// Callers that drive their own input loop (instead of the reader thread
112    /// spawned by [`Terminal::start`]) use this to set up the tty without
113    /// spawning a competing reader — see `TuiAltScreen::start_readerless`.
114    fn enter_raw_mode(&self);
115
116    /// Leave raw mode *without* stopping the terminal, so the shell gets a
117    /// working line discipline back. Pairs with [`Terminal::enter_raw_mode`];
118    /// a `Ctrl+Z` suspend uses it, then re-enters raw mode on resume.
119    fn exit_raw_mode(&self);
120
121    /// Re-read the current terminal size into the cached [`TerminalInfo`].
122    /// Called on `Event::Resize` so subsequent renders use the new dimensions.
123    fn refresh_size(&self);
124
125    /// Start terminal raw mode.
126    fn start(
127        &self,
128        on_input: Box<dyn Fn(InputEvent) + Send + Sync>,
129        on_resize: Box<dyn Fn() + Send + Sync>,
130    );
131
132    /// Stop terminal and restore original state.
133    fn stop(&self);
134
135    /// Check if terminal is a TTY.
136    fn is_tty(&self) -> bool;
137
138    /// Set progress indicator (terminal progress bar support).
139    fn set_progress(&self, active: bool);
140
141    /// Flush output buffer.
142    fn flush(&self);
143}
144
145/// Process-based terminal using crossterm.
146pub struct ProcessTerminal {
147    info: Mutex<TerminalInfo>,
148    running: Mutex<bool>,
149    writer: Mutex<Box<dyn Write + Send + Sync>>,
150    #[cfg(any(unix, test))]
151    keyboard: Mutex<KeyboardProtocolState>,
152}
153
154#[cfg(any(unix, test))]
155#[derive(Clone, Copy)]
156enum KeyboardProtocol {
157    Kitty,
158    Legacy,
159}
160
161#[cfg(any(unix, test))]
162#[derive(Default)]
163struct KeyboardProtocolState {
164    detected: Option<bool>,
165    active: Option<KeyboardProtocol>,
166}
167
168impl ProcessTerminal {
169    /// Create a new process terminal.
170    pub fn new() -> Self {
171        let (cols, rows) = cterm::size().unwrap_or((80, 24));
172        let info = TerminalInfo {
173            columns: cols as usize,
174            rows: rows as usize,
175            supports_true_color: true, // Assume true color support
176            supports_mouse: true,
177            supports_kitty_keyboard: false, // Will be detected
178        };
179
180        Self {
181            info: Mutex::new(info),
182            running: Mutex::new(false),
183            writer: Mutex::new(Box::new(io::stdout())),
184            #[cfg(any(unix, test))]
185            keyboard: Mutex::new(KeyboardProtocolState::default()),
186        }
187    }
188
189    /// Update terminal size.
190    fn update_size(&self) {
191        if let Ok(size) = cterm::size() {
192            if let Ok(mut info) = self.info.lock() {
193                info.columns = size.0 as usize;
194                info.rows = size.1 as usize;
195            }
196        }
197    }
198
199    fn configure_mouse_tracking(&self) {
200        if mouse_tracking_enabled_by_default() {
201            self.enable_mouse();
202        } else {
203            self.disable_mouse();
204        }
205    }
206
207    /// Negotiate before starting the input reader. Cache support so resuming
208    /// never queries stdin while a reader is already running.
209    #[cfg(any(unix, test))]
210    fn enable_keyboard_protocol(&self, detect: impl FnOnce() -> bool) {
211        let mut keyboard = self.keyboard.lock().unwrap_or_else(|p| p.into_inner());
212        if keyboard.active.is_some() {
213            return;
214        }
215        let supported = match keyboard.detected {
216            Some(supported) => supported,
217            None => {
218                // Match Pi: 1: disambiguate, 2: repeat/release, 4: alternate keys.
219                // Leave text as UTF-8 for IME commits. Flag 8 replaces text
220                // with key reports; crossterm cannot consume associated text.
221                self.write("\x1b[>7u");
222                self.flush();
223                let supported = detect();
224                keyboard.detected = Some(supported);
225                if !supported {
226                    self.write("\x1b[<u");
227                }
228                if let Ok(mut info) = self.info.lock() {
229                    info.supports_kitty_keyboard = supported;
230                }
231                if supported {
232                    keyboard.active = Some(KeyboardProtocol::Kitty);
233                    return;
234                }
235                false
236            }
237        };
238        let protocol = if supported {
239            self.write("\x1b[>7u");
240            KeyboardProtocol::Kitty
241        } else {
242            // Crossterm cannot parse xterm's CSI 27;modifier;key~ encoding.
243            // Keep legacy input rather than enabling modifyOtherKeys and
244            // losing keys. Apple Terminal's Shift+Enter is normalized below.
245            KeyboardProtocol::Legacy
246        };
247        keyboard.active = Some(protocol);
248    }
249
250    #[cfg(any(unix, test))]
251    fn disable_keyboard_protocol(&self) {
252        let mut keyboard = self.keyboard.lock().unwrap_or_else(|p| p.into_inner());
253        match keyboard.active.take() {
254            Some(KeyboardProtocol::Kitty) => self.write("\x1b[<u"),
255            Some(KeyboardProtocol::Legacy) | None => {}
256        }
257    }
258}
259
260impl Default for ProcessTerminal {
261    fn default() -> Self {
262        Self::new()
263    }
264}
265
266impl Terminal for ProcessTerminal {
267    fn info(&self) -> TerminalInfo {
268        self.info.lock().map(|i| i.clone()).unwrap_or_default()
269    }
270
271    fn write(&self, data: &str) {
272        if let Ok(mut writer) = self.writer.lock() {
273            let _ = writer.write_all(data.as_bytes());
274        }
275    }
276
277    fn hide_cursor(&self) {
278        self.write("\x1b[?25l");
279    }
280
281    fn show_cursor(&self) {
282        self.write("\x1b[?25h");
283    }
284
285    fn move_cursor(&self, row: usize, col: usize) {
286        // ANSI escape: ESC [ row ; col H (1-indexed)
287        self.write(&format!("\x1b[{};{}H", row + 1, col + 1));
288    }
289
290    fn clear_screen(&self) {
291        self.write("\x1b[2J\x1b[H");
292    }
293
294    fn set_title(&self, title: &str) {
295        self.write(&format!("\x1b]2;{}\x07", title));
296    }
297
298    fn enable_mouse(&self) {
299        // Enable mouse tracking (SGR mode)
300        self.write("\x1b[?1000h\x1b[?1002h\x1b[?1006h");
301    }
302
303    fn disable_mouse(&self) {
304        self.write("\x1b[?1006l\x1b[?1002l\x1b[?1000l");
305    }
306
307    fn enter_raw_mode(&self) {
308        let _ = cterm::enable_raw_mode();
309        if let Ok(mut running) = self.running.lock() {
310            *running = true;
311        }
312        self.configure_mouse_tracking();
313        self.hide_cursor();
314        // Enable bracketed paste mode so pasted text arrives as a single
315        // Event::Paste instead of individual key events (which would trigger
316        // submit per line for multi-line pastes).
317        self.write("\x1b[?2004h");
318        #[cfg(unix)]
319        if keyboard_protocol_enabled() && self.is_tty() && io::stdin().is_terminal() {
320            self.enable_keyboard_protocol(|| {
321                cterm::supports_keyboard_enhancement().unwrap_or(false)
322            });
323        }
324        self.update_size();
325        self.flush();
326    }
327
328    fn exit_raw_mode(&self) {
329        // Mirror `enter_raw_mode` without tearing the terminal down: drop the
330        // bracketed-paste mode it turned on and restore the line discipline.
331        self.write("\x1b[?2004l");
332        #[cfg(unix)]
333        self.disable_keyboard_protocol();
334        self.flush();
335        let _ = cterm::disable_raw_mode();
336    }
337
338    fn refresh_size(&self) {
339        self.update_size();
340    }
341
342    fn start(
343        &self,
344        on_input: Box<dyn Fn(InputEvent) + Send + Sync>,
345        on_resize: Box<dyn Fn() + Send + Sync>,
346    ) {
347        self.enter_raw_mode();
348
349        // Update size initially
350        self.update_size();
351
352        // Spawn input handling thread
353        let running = Arc::new(Mutex::new(true));
354        let running_clone = running.clone();
355        let info_clone = Arc::new(Mutex::new(self.info()));
356
357        std::thread::spawn(move || {
358            loop {
359                // Check if we're still running
360                if !*running_clone.lock().unwrap() {
361                    break;
362                }
363
364                // Poll for events with timeout
365                match event::poll(Duration::from_millis(100)) {
366                    Ok(true) => {
367                        if let Ok(event) = event::read() {
368                            match event {
369                                Event::Key(key) => {
370                                    on_input(InputEvent::Key(normalize_key_event(key)))
371                                }
372                                Event::Mouse(mouse) => on_input(InputEvent::Mouse(mouse)),
373                                Event::Resize(cols, rows) => {
374                                    if let Ok(mut info) = info_clone.lock() {
375                                        info.columns = cols as usize;
376                                        info.rows = rows as usize;
377                                    }
378                                    on_resize();
379                                }
380                                Event::FocusGained => on_input(InputEvent::FocusGained),
381                                Event::FocusLost => on_input(InputEvent::FocusLost),
382                                Event::Paste(_) => { /* Ignore paste events for now */ }
383                            }
384                        }
385                    }
386                    Ok(false) => {}  // Timeout, continue polling
387                    Err(_) => break, // Error, stop the thread
388                }
389            }
390        });
391
392        // Store running flag
393        if let Ok(mut r) = self.running.lock() {
394            *r = true;
395        }
396    }
397
398    fn stop(&self) {
399        if let Ok(mut running) = self.running.lock() {
400            *running = false;
401        }
402
403        self.disable_mouse();
404        self.show_cursor();
405        // Disable bracketed paste mode before exiting raw mode.
406        self.write("\x1b[?2004l");
407        #[cfg(unix)]
408        self.disable_keyboard_protocol();
409        self.flush();
410
411        // Exit raw mode
412        let _ = cterm::disable_raw_mode();
413    }
414
415    fn is_tty(&self) -> bool {
416        std::io::stdout().is_terminal()
417    }
418
419    fn set_progress(&self, active: bool) {
420        // OSC 9;4 is supported by modern terminal emulators (including
421        // Windows Terminal, iTerm2-compatible terminals, and many Linux
422        // terminals). State 1 starts an indeterminate progress indicator and
423        // state 0 clears it.
424        let state = if active { 1 } else { 0 };
425        self.write(&format!("\x1b]9;4;{state};0\x07"));
426        self.flush();
427    }
428
429    fn flush(&self) {
430        if let Ok(mut writer) = self.writer.lock() {
431            let _ = writer.flush();
432        }
433    }
434}
435
436/// Apple Terminal sends the same CR for Enter and Shift+Enter. Like Pi's
437/// native helper, consult the local modifier state only for that legacy key.
438/// SSH input belongs to the remote user, not the Mac's physical keyboard.
439pub fn normalize_key_event(key: KeyEvent) -> KeyEvent {
440    #[cfg(target_os = "macos")]
441    if key.code == event::KeyCode::Enter
442        && key.modifiers.is_empty()
443        && local_apple_terminal(
444            std::env::var("TERM_PROGRAM").ok().as_deref(),
445            ["SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"]
446                .iter()
447                .any(|name| std::env::var_os(name).is_some()),
448        )
449    {
450        #[link(name = "CoreGraphics", kind = "framework")]
451        extern "C" {
452            fn CGEventSourceFlagsState(state_id: i32) -> u64;
453        }
454        // kCGEventSourceStateCombinedSessionState = 0; maskShift = 1 << 17.
455        // This accessor reads modifier flags without creating an event tap.
456        let shift_pressed = unsafe { CGEventSourceFlagsState(0) } & (1 << 17) != 0;
457        return normalize_native_shift_enter(key, shift_pressed);
458    }
459    key
460}
461
462#[cfg(any(target_os = "macos", test))]
463fn local_apple_terminal(term_program: Option<&str>, ssh: bool) -> bool {
464    term_program == Some("Apple_Terminal") && !ssh
465}
466
467#[cfg(any(target_os = "macos", test))]
468fn normalize_native_shift_enter(mut key: KeyEvent, shift_pressed: bool) -> KeyEvent {
469    if key.code == event::KeyCode::Enter && key.modifiers.is_empty() && shift_pressed {
470        key.modifiers.insert(event::KeyModifiers::SHIFT);
471    }
472    key
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478
479    #[derive(Clone)]
480    struct RecordedOutput(Arc<Mutex<Vec<u8>>>);
481    impl Write for RecordedOutput {
482        fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
483            self.0.lock().unwrap().extend_from_slice(bytes);
484            Ok(bytes.len())
485        }
486        fn flush(&mut self) -> io::Result<()> {
487            Ok(())
488        }
489    }
490
491    #[test]
492    fn kitty_protocol_preserves_ime_text_and_restores_once_per_activation() {
493        let terminal = ProcessTerminal::new();
494        let output = RecordedOutput(Arc::new(Mutex::new(Vec::new())));
495        *terminal.writer.lock().unwrap() = Box::new(output.clone());
496        terminal.enable_keyboard_protocol(|| true);
497        assert!(terminal.info().supports_kitty_keyboard);
498        terminal.enable_keyboard_protocol(|| panic!("active protocol must not be pushed twice"));
499        terminal.disable_keyboard_protocol();
500        terminal.disable_keyboard_protocol();
501        terminal.enable_keyboard_protocol(|| panic!("resume must not query stdin"));
502        terminal.disable_keyboard_protocol();
503        let bytes = output.0.lock().unwrap().clone();
504        assert_eq!(bytes, b"\x1b[>7u\x1b[<u\x1b[>7u\x1b[<u");
505        // Check the actual emitted flags to prevent reintroducing all-key mode.
506        let sequence = std::str::from_utf8(&bytes)
507            .unwrap()
508            .split('u')
509            .next()
510            .unwrap();
511        let mask: u8 = sequence.strip_prefix("\x1b[>").unwrap().parse().unwrap();
512        let flags = event::KeyboardEnhancementFlags::from_bits(mask).unwrap();
513        assert!(flags.contains(event::KeyboardEnhancementFlags::REPORT_EVENT_TYPES));
514        assert!(flags.contains(event::KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES));
515        assert!(flags.contains(event::KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS));
516        assert!(!flags.contains(event::KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES));
517        // Bit 16 is associated-text reporting, unsupported by crossterm 0.27.
518        assert_eq!(mask & 16, 0);
519    }
520
521    #[test]
522    fn legacy_terminal_does_not_enable_an_encoding_the_reader_cannot_parse() {
523        let terminal = ProcessTerminal::new();
524        let output = RecordedOutput(Arc::new(Mutex::new(Vec::new())));
525        *terminal.writer.lock().unwrap() = Box::new(output.clone());
526        terminal.enable_keyboard_protocol(|| false);
527        assert!(!terminal.info().supports_kitty_keyboard);
528        terminal.disable_keyboard_protocol();
529        terminal.enable_keyboard_protocol(|| panic!("resume must reuse detection"));
530        terminal.disable_keyboard_protocol();
531        assert_eq!(output.0.lock().unwrap().as_slice(), b"\x1b[>7u\x1b[<u");
532    }
533
534    #[test]
535    fn native_shift_enter_only_changes_unmodified_enter_in_local_apple_terminal() {
536        use event::{KeyCode, KeyModifiers};
537        assert!(local_apple_terminal(Some("Apple_Terminal"), false));
538        assert!(!local_apple_terminal(Some("Apple_Terminal"), true));
539        assert!(!local_apple_terminal(Some("iTerm.app"), false));
540        let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
541        assert_eq!(
542            normalize_native_shift_enter(enter, true).modifiers,
543            KeyModifiers::SHIFT
544        );
545        assert_eq!(normalize_native_shift_enter(enter, false), enter);
546        for key in [
547            KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE),
548            KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL),
549            KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT),
550        ] {
551            assert_eq!(normalize_native_shift_enter(key, true), key);
552        }
553    }
554
555    #[test]
556    fn native_shift_enter_inserts_a_line_before_plain_enter_submits() {
557        use event::{KeyCode, KeyModifiers};
558        let editor = crate::Editor::simple();
559        let submitted = Arc::new(Mutex::new(Vec::new()));
560        let capture = submitted.clone();
561        editor.on_submit(Arc::new(move |text| {
562            capture.lock().unwrap().push(text.to_string())
563        }));
564        editor.insert("first");
565        let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
566        editor.handle_key(normalize_native_shift_enter(enter, true));
567        editor.insert("second");
568        assert_eq!(editor.get_text(), "first\nsecond");
569        assert!(submitted.lock().unwrap().is_empty());
570        editor.handle_key(normalize_native_shift_enter(enter, false));
571        assert_eq!(submitted.lock().unwrap().as_slice(), &["first\nsecond"]);
572    }
573
574    #[test]
575    fn mouse_tracking_default_preserves_native_selection_on_macos() {
576        assert!(!mouse_tracking_enabled_for(true));
577        assert!(mouse_tracking_enabled_for(false));
578    }
579
580    #[test]
581    fn ime_committed_utf8_text_reaches_editor_without_submitting() {
582        use event::{KeyCode, KeyModifiers};
583        let editor = crate::Editor::simple();
584        let submitted = Arc::new(Mutex::new(Vec::new()));
585        let capture = submitted.clone();
586        editor.on_submit(Arc::new(move |text| {
587            capture.lock().unwrap().push(text.to_string())
588        }));
589        let committed = "你好,世界𠮷 abc";
590        for ch in committed.chars() {
591            editor.handle_key(normalize_key_event(KeyEvent::new(
592                KeyCode::Char(ch),
593                KeyModifiers::NONE,
594            )));
595        }
596        assert_eq!(editor.get_text(), committed);
597        assert!(submitted.lock().unwrap().is_empty());
598        editor.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
599        assert_eq!(submitted.lock().unwrap().as_slice(), &[committed]);
600    }
601}