Skip to main content

rab/tui/
terminal.rs

1use std::io::{self, Write};
2use std::sync::LazyLock;
3use std::sync::Mutex;
4use std::time::Duration;
5
6use crossterm::event::{self, Event, KeyEvent, KeyboardEnhancementFlags};
7
8// =============================================================================
9// TerminalEvent — events that can come from the terminal beyond just keys
10// =============================================================================
11
12/// Events from the terminal that the app loop should handle.
13/// Matches pi's StdinBuffer which emits both `data` (key sequences) and `paste` events.
14#[derive(Debug, Clone)]
15pub enum TerminalEvent {
16    Key(KeyEvent),
17    Paste(String),
18    Resize(u16, u16),
19}
20
21// =============================================================================
22// Terminal trait — pi-compatible terminal interface
23// =============================================================================
24
25/// Terminal interface matching pi's `packages/tui/src/terminal.ts`.
26/// All methods work with a `&mut dyn Write` for flexibility.
27pub trait TerminalTrait {
28    fn start(&mut self, writer: &mut dyn Write) -> io::Result<()>;
29    fn stop(&mut self, writer: &mut dyn Write) -> io::Result<()>;
30    fn drain_input(&mut self, max_ms: u64) -> io::Result<()>;
31    fn write(&self, writer: &mut dyn Write, data: &str) -> io::Result<()>;
32    fn size(&self) -> io::Result<(u16, u16)>;
33    fn kitty_protocol_active(&self) -> bool;
34    fn move_by(&self, writer: &mut dyn Write, lines: i32) -> io::Result<()>;
35    fn hide_cursor(&self, writer: &mut dyn Write) -> io::Result<()>;
36    fn show_cursor(&self, writer: &mut dyn Write) -> io::Result<()>;
37    fn clear_line(&self, writer: &mut dyn Write) -> io::Result<()>;
38    fn clear_from_cursor(&self, writer: &mut dyn Write) -> io::Result<()>;
39    fn clear_screen(&self, writer: &mut dyn Write) -> io::Result<()>;
40    fn set_title(&self, writer: &mut dyn Write, title: &str) -> io::Result<()>;
41    fn set_progress(&self, writer: &mut dyn Write, active: bool) -> io::Result<()>;
42    /// Enable/disable terminal color scheme change notifications (OSC 2031).
43    /// When enabled, the terminal reports color scheme changes via
44    /// `\x1b]10;rgb:RRRR/GGGG/BBBB\x07` sequences.
45    fn set_color_scheme_notifications(
46        &self,
47        writer: &mut dyn Write,
48        enabled: bool,
49    ) -> io::Result<()>;
50}
51
52// =============================================================================
53// Background stdin reader — reads crossterm events on a dedicated thread
54// and forwards them through a channel so crossterm parser bugs (e.g. hanging
55// on partial escape sequences) cannot freeze the main event loop.
56// =============================================================================
57
58use std::sync::atomic::{AtomicBool, Ordering};
59use std::sync::mpsc;
60
61static EVENT_TX: LazyLock<Mutex<Option<mpsc::Sender<TerminalEvent>>>> =
62    LazyLock::new(|| Mutex::new(None));
63
64static EVENT_RX: LazyLock<Mutex<Option<mpsc::Receiver<TerminalEvent>>>> =
65    LazyLock::new(|| Mutex::new(None));
66
67static STDIN_THREAD_HANDLE: LazyLock<Mutex<Option<std::thread::JoinHandle<()>>>> =
68    LazyLock::new(|| Mutex::new(None));
69
70static STDIN_RUNNING: AtomicBool = AtomicBool::new(false);
71
72/// Start the background stdin reader thread.
73/// Must be called once after raw mode is enabled.
74pub fn start_stdin_reader() {
75    let (tx, rx) = mpsc::channel();
76    *EVENT_TX.lock().unwrap() = Some(tx.clone());
77    *EVENT_RX.lock().unwrap() = Some(rx);
78    STDIN_RUNNING.store(true, Ordering::SeqCst);
79
80    let handle = std::thread::spawn(move || {
81        // Use poll() with a timeout so we can check the stop flag regularly.
82        // 100ms interval is short enough for responsive shutdown but long
83        // enough to not waste CPU.
84        while STDIN_RUNNING.load(Ordering::SeqCst) {
85            match event::poll(std::time::Duration::from_millis(100)) {
86                Ok(true) => {
87                    // Data available — read it
88                    match event::read() {
89                        Ok(Event::Key(key)) => {
90                            let _ = tx.send(TerminalEvent::Key(key));
91                        }
92                        Ok(Event::Paste(content)) => {
93                            let _ = tx.send(TerminalEvent::Paste(content));
94                        }
95                        Ok(Event::Resize(w, h)) => {
96                            let _ = tx.send(TerminalEvent::Resize(w, h));
97                        }
98                        Ok(_) => {}
99                        Err(_) => {
100                            // Stdin error — terminal likely closed. Exit thread.
101                            break;
102                        }
103                    }
104                }
105                Ok(false) => {
106                    // Timeout — loop back and check stop flag
107                }
108                Err(_) => {
109                    // poll error — exit
110                    break;
111                }
112            }
113        }
114    });
115
116    *STDIN_THREAD_HANDLE.lock().unwrap() = Some(handle);
117}
118
119/// Initiate stopping the background stdin reader thread.
120/// Sets the running flag and drops sender/receiver.
121/// The thread will exit on the next event::read() that completes after
122/// the flag is cleared. Call `join_stdin_reader()` to wait for exit.
123pub fn stop_stdin_reader() {
124    STDIN_RUNNING.store(false, Ordering::SeqCst);
125    let mut guard = EVENT_TX.lock().unwrap();
126    *guard = None;
127    drop(guard);
128    let mut rx_guard = EVENT_RX.lock().unwrap();
129    *rx_guard = None;
130}
131
132/// Wait for the stdin reader thread to exit.
133pub fn join_stdin_reader() {
134    let mut guard = STDIN_THREAD_HANDLE.lock().unwrap();
135    if let Some(handle) = guard.take() {
136        let _ = handle.join();
137    }
138}
139
140/// Drain all pending events from the stdin reader channel.
141/// Non-blocking read from the background stdin reader channel.
142///
143/// The receiver is taken out of the mutex before the call so `EVENT_RX`
144/// is never held during the operation — other code (e.g. `stop_stdin_reader`)
145/// can always lock it without contention.
146pub fn try_recv_terminal_event() -> Option<TerminalEvent> {
147    use std::sync::mpsc::TryRecvError;
148    let rx_opt = EVENT_RX.lock().unwrap().take();
149    let rx = rx_opt.as_ref()?;
150    let (event, keep) = match rx.try_recv() {
151        Ok(event) => (Some(event), true),
152        Err(TryRecvError::Empty) => (None, true),
153        Err(TryRecvError::Disconnected) => (None, false),
154    };
155    let _ = rx;
156    if keep {
157        *EVENT_RX.lock().unwrap() = rx_opt;
158    }
159    event
160}
161
162// =============================================================================
163// ProcessTerminal — crossterm-backed implementation
164// =============================================================================
165
166pub struct ProcessTerminal {
167    was_raw: bool,
168    kitty_active: bool,
169}
170
171impl ProcessTerminal {
172    pub fn new() -> Self {
173        Self {
174            was_raw: false,
175            kitty_active: false,
176        }
177    }
178
179    fn enable_bracketed_paste(&self, writer: &mut dyn Write) -> io::Result<()> {
180        write!(writer, "\x1b[?2004h")?;
181        writer.flush()
182    }
183
184    fn disable_bracketed_paste(&self, writer: &mut dyn Write) -> io::Result<()> {
185        write!(writer, "\x1b[?2004l")?;
186        writer.flush()
187    }
188
189    fn enable_kitty_protocol(&mut self, writer: &mut dyn Write) -> io::Result<()> {
190        if self.kitty_active {
191            return Ok(());
192        }
193        // Push flags: DISAMBIGUATE_ESCAPE_CODES (1) | REPORT_EVENT_TYPES (2) | REPORT_ALTERNATE_KEYS (4)
194        // This enables:
195        //   - Disambiguate escape codes (all keys use CSI-u format)
196        //   - Report event types (press/repeat/release)
197        //   - Report alternate keys (shifted key according to keyboard layout)
198        let flags = KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
199            | KeyboardEnhancementFlags::REPORT_EVENT_TYPES
200            | KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS;
201        write!(writer, "\x1b[>{}u", flags.bits())?;
202        writer.flush()?;
203        self.kitty_active = true;
204        Ok(())
205    }
206
207    fn disable_kitty_protocol(&mut self, writer: &mut dyn Write) -> io::Result<()> {
208        if self.kitty_active {
209            // Pop one level of keyboard enhancement flags
210            write!(writer, "\x1b[<u")?;
211            writer.flush()?;
212            self.kitty_active = false;
213        }
214        Ok(())
215    }
216}
217
218impl Default for ProcessTerminal {
219    fn default() -> Self {
220        Self::new()
221    }
222}
223
224impl Drop for ProcessTerminal {
225    fn drop(&mut self) {
226        if self.was_raw {
227            let _ = crossterm::terminal::disable_raw_mode();
228        }
229    }
230}
231
232impl TerminalTrait for ProcessTerminal {
233    fn start(&mut self, writer: &mut dyn Write) -> io::Result<()> {
234        crossterm::terminal::enable_raw_mode()?;
235        self.was_raw = true;
236        self.enable_bracketed_paste(writer)?;
237        // Enable Kitty keyboard protocol for:
238        //   - Disambiguated escape codes (no more ambiguity between Esc and Alt+key)
239        //   - Key event types (press/repeat/release)
240        //   - Alternate key reporting (shifted key according to keyboard layout)
241        // Crossterm 0.29+ parses Kitty CSI-u sequences natively via
242        // parse_csi_u_encoded_key_code. The enhancement flags response is
243        // consumed internally by crossterm and doesn't interfere with event reading.
244        self.enable_kitty_protocol(writer)?;
245        // Refresh terminal dimensions
246        let _ = crossterm::terminal::size();
247        Ok(())
248    }
249
250    fn stop(&mut self, writer: &mut dyn Write) -> io::Result<()> {
251        self.disable_kitty_protocol(writer)?;
252        self.disable_bracketed_paste(writer)?;
253        if self.was_raw {
254            crossterm::terminal::disable_raw_mode()?;
255            self.was_raw = false;
256        }
257        Ok(())
258    }
259
260    fn drain_input(&mut self, max_ms: u64) -> io::Result<()> {
261        // Pop Kitty keyboard protocol so trailing release events don't leak
262        let mut buf = Vec::new();
263        self.disable_kitty_protocol(&mut buf)?;
264        if !buf.is_empty() {
265            let stdout = std::io::stdout();
266            let mut handle = stdout.lock();
267            handle.write_all(&buf)?;
268            handle.flush()?;
269        }
270
271        let start = std::time::Instant::now();
272        let mut last_data = start;
273        loop {
274            if start.elapsed().as_millis() as u64 >= max_ms {
275                break;
276            }
277            if event::poll(Duration::from_millis(10))? {
278                let _ = event::read()?;
279                last_data = std::time::Instant::now();
280            } else if last_data.elapsed().as_millis() > 50 {
281                break;
282            }
283        }
284        Ok(())
285    }
286
287    fn write(&self, writer: &mut dyn Write, data: &str) -> io::Result<()> {
288        write!(writer, "{}", data)?;
289        writer.flush()
290    }
291
292    fn size(&self) -> io::Result<(u16, u16)> {
293        crossterm::terminal::size()
294    }
295
296    fn kitty_protocol_active(&self) -> bool {
297        self.kitty_active
298    }
299
300    fn move_by(&self, writer: &mut dyn Write, lines: i32) -> io::Result<()> {
301        if lines > 0 {
302            write!(writer, "\x1b[{}B", lines)?;
303        } else if lines < 0 {
304            write!(writer, "\x1b[{}A", -lines)?;
305        }
306        writer.flush()
307    }
308
309    fn hide_cursor(&self, writer: &mut dyn Write) -> io::Result<()> {
310        write!(writer, "\x1b[?25l")?;
311        writer.flush()
312    }
313
314    fn show_cursor(&self, writer: &mut dyn Write) -> io::Result<()> {
315        write!(writer, "\x1b[?25h")?;
316        writer.flush()
317    }
318
319    fn clear_line(&self, writer: &mut dyn Write) -> io::Result<()> {
320        write!(writer, "\x1b[2K")?;
321        writer.flush()
322    }
323
324    fn clear_from_cursor(&self, writer: &mut dyn Write) -> io::Result<()> {
325        write!(writer, "\x1b[J")?;
326        writer.flush()
327    }
328
329    fn clear_screen(&self, writer: &mut dyn Write) -> io::Result<()> {
330        write!(writer, "\x1b[2J\x1b[H")?;
331        writer.flush()
332    }
333
334    fn set_title(&self, writer: &mut dyn Write, title: &str) -> io::Result<()> {
335        write!(writer, "\x1b]0;{}\x07", title)?;
336        writer.flush()
337    }
338
339    fn set_progress(&self, writer: &mut dyn Write, active: bool) -> io::Result<()> {
340        if active {
341            write!(writer, "\x1b]9;4;3\x07")?;
342        } else {
343            write!(writer, "\x1b]9;4;0;\x07")?;
344        }
345        writer.flush()
346    }
347
348    fn set_color_scheme_notifications(
349        &self,
350        writer: &mut dyn Write,
351        enabled: bool,
352    ) -> io::Result<()> {
353        if enabled {
354            write!(writer, "\x1b[?2031h")?;
355        } else {
356            write!(writer, "\x1b[?2031l")?;
357        }
358        writer.flush()
359    }
360}