Skip to main content

rab/tui/
terminal.rs

1use std::io::{self, Write};
2use std::time::Duration;
3
4use crossterm::event::{self, Event, KeyEvent, KeyboardEnhancementFlags};
5
6// =============================================================================
7// TerminalEvent — events that can come from the terminal beyond just keys
8// =============================================================================
9
10/// Events from the terminal that the app loop should handle.
11/// Matches pi's StdinBuffer which emits both `data` (key sequences) and `paste` events.
12#[derive(Debug, Clone)]
13pub enum TerminalEvent {
14    Key(KeyEvent),
15    Paste(String),
16    Resize(u16, u16),
17}
18
19// =============================================================================
20// Terminal trait — pi-compatible terminal interface
21// =============================================================================
22
23/// Terminal interface matching pi's `packages/tui/src/terminal.ts`.
24/// All methods work with a `&mut dyn Write` for flexibility.
25pub trait TerminalTrait {
26    fn start(&mut self, writer: &mut dyn Write) -> io::Result<()>;
27    fn stop(&mut self, writer: &mut dyn Write) -> io::Result<()>;
28    fn drain_input(&mut self, max_ms: u64) -> io::Result<()>;
29    fn write(&self, writer: &mut dyn Write, data: &str) -> io::Result<()>;
30    fn size(&self) -> io::Result<(u16, u16)>;
31    fn kitty_protocol_active(&self) -> bool;
32    fn move_by(&self, writer: &mut dyn Write, lines: i32) -> io::Result<()>;
33    fn hide_cursor(&self, writer: &mut dyn Write) -> io::Result<()>;
34    fn show_cursor(&self, writer: &mut dyn Write) -> io::Result<()>;
35    fn clear_line(&self, writer: &mut dyn Write) -> io::Result<()>;
36    fn clear_from_cursor(&self, writer: &mut dyn Write) -> io::Result<()>;
37    fn clear_screen(&self, writer: &mut dyn Write) -> io::Result<()>;
38    fn set_title(&self, writer: &mut dyn Write, title: &str) -> io::Result<()>;
39    fn set_progress(&self, writer: &mut dyn Write, active: bool) -> io::Result<()>;
40    /// Enable/disable terminal color scheme change notifications (OSC 2031).
41    /// When enabled, the terminal reports color scheme changes via
42    /// `\x1b]10;rgb:RRRR/GGGG/BBBB\x07` sequences.
43    fn set_color_scheme_notifications(
44        &self,
45        writer: &mut dyn Write,
46        enabled: bool,
47    ) -> io::Result<()>;
48}
49
50// =============================================================================
51// Poll functions (kept as free functions for the event loop)
52// =============================================================================
53
54/// Poll for any terminal event (key, paste, resize).
55/// Returns None on timeout or for unhandled event types.
56pub fn poll_terminal_event(timeout: Option<Duration>) -> io::Result<Option<TerminalEvent>> {
57    if event::poll(timeout.unwrap_or(Duration::ZERO))? {
58        match event::read()? {
59            Event::Key(key) => Ok(Some(TerminalEvent::Key(key))),
60            Event::Paste(content) => Ok(Some(TerminalEvent::Paste(content))),
61            Event::Resize(w, h) => Ok(Some(TerminalEvent::Resize(w, h))),
62            _ => Ok(None),
63        }
64    } else {
65        Ok(None)
66    }
67}
68
69pub fn poll_key_event(timeout: Option<Duration>) -> io::Result<Option<KeyEvent>> {
70    match poll_terminal_event(timeout)? {
71        Some(TerminalEvent::Key(key)) => Ok(Some(key)),
72        _ => Ok(None),
73    }
74}
75
76pub fn read_key_event() -> io::Result<KeyEvent> {
77    loop {
78        match event::read()? {
79            Event::Key(key) => return Ok(key),
80            Event::Paste(_) => continue,
81            Event::Resize(_, _) => continue,
82            _ => continue,
83        }
84    }
85}
86
87// =============================================================================
88// ProcessTerminal — crossterm-backed implementation
89// =============================================================================
90
91pub struct ProcessTerminal {
92    was_raw: bool,
93    kitty_active: bool,
94}
95
96impl ProcessTerminal {
97    pub fn new() -> Self {
98        Self {
99            was_raw: false,
100            kitty_active: false,
101        }
102    }
103
104    fn enable_bracketed_paste(&self, writer: &mut dyn Write) -> io::Result<()> {
105        write!(writer, "\x1b[?2004h")?;
106        writer.flush()
107    }
108
109    fn disable_bracketed_paste(&self, writer: &mut dyn Write) -> io::Result<()> {
110        write!(writer, "\x1b[?2004l")?;
111        writer.flush()
112    }
113
114    fn enable_kitty_protocol(&mut self, writer: &mut dyn Write) -> io::Result<()> {
115        let flags = KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
116            | KeyboardEnhancementFlags::REPORT_EVENT_TYPES
117            | KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS;
118        write!(writer, "\x1b[>{}u", flags.bits())?;
119        writer.flush()?;
120        self.kitty_active = true;
121        Ok(())
122    }
123
124    fn disable_kitty_protocol(&mut self, writer: &mut dyn Write) -> io::Result<()> {
125        if self.kitty_active {
126            write!(writer, "\x1b[<u")?;
127            writer.flush()?;
128            self.kitty_active = false;
129        }
130        Ok(())
131    }
132}
133
134impl Default for ProcessTerminal {
135    fn default() -> Self {
136        Self::new()
137    }
138}
139
140impl Drop for ProcessTerminal {
141    fn drop(&mut self) {
142        if self.was_raw {
143            let _ = crossterm::terminal::disable_raw_mode();
144        }
145    }
146}
147
148impl TerminalTrait for ProcessTerminal {
149    fn start(&mut self, writer: &mut dyn Write) -> io::Result<()> {
150        crossterm::terminal::enable_raw_mode()?;
151        self.was_raw = true;
152        self.enable_bracketed_paste(writer)?;
153        self.enable_kitty_protocol(writer)?;
154        // Refresh terminal dimensions
155        let _ = crossterm::terminal::size();
156        Ok(())
157    }
158
159    fn stop(&mut self, writer: &mut dyn Write) -> io::Result<()> {
160        self.disable_kitty_protocol(writer)?;
161        self.disable_bracketed_paste(writer)?;
162        if self.was_raw {
163            crossterm::terminal::disable_raw_mode()?;
164            self.was_raw = false;
165        }
166        Ok(())
167    }
168
169    fn drain_input(&mut self, max_ms: u64) -> io::Result<()> {
170        // Disable Kitty protocol so trailing release events don't leak
171        let mut buf = Vec::new();
172        self.disable_kitty_protocol(&mut buf)?;
173        if !buf.is_empty() {
174            let stdout = std::io::stdout();
175            let mut handle = stdout.lock();
176            handle.write_all(&buf)?;
177            handle.flush()?;
178        }
179
180        let start = std::time::Instant::now();
181        let mut last_data = start;
182        loop {
183            if start.elapsed().as_millis() as u64 >= max_ms {
184                break;
185            }
186            if event::poll(Duration::from_millis(10))? {
187                let _ = event::read()?;
188                last_data = std::time::Instant::now();
189            } else if last_data.elapsed().as_millis() > 50 {
190                break;
191            }
192        }
193        Ok(())
194    }
195
196    fn write(&self, writer: &mut dyn Write, data: &str) -> io::Result<()> {
197        write!(writer, "{}", data)?;
198        writer.flush()
199    }
200
201    fn size(&self) -> io::Result<(u16, u16)> {
202        crossterm::terminal::size()
203    }
204
205    fn kitty_protocol_active(&self) -> bool {
206        self.kitty_active
207    }
208
209    fn move_by(&self, writer: &mut dyn Write, lines: i32) -> io::Result<()> {
210        if lines > 0 {
211            write!(writer, "\x1b[{}B", lines)?;
212        } else if lines < 0 {
213            write!(writer, "\x1b[{}A", -lines)?;
214        }
215        writer.flush()
216    }
217
218    fn hide_cursor(&self, writer: &mut dyn Write) -> io::Result<()> {
219        write!(writer, "\x1b[?25l")?;
220        writer.flush()
221    }
222
223    fn show_cursor(&self, writer: &mut dyn Write) -> io::Result<()> {
224        write!(writer, "\x1b[?25h")?;
225        writer.flush()
226    }
227
228    fn clear_line(&self, writer: &mut dyn Write) -> io::Result<()> {
229        write!(writer, "\x1b[2K")?;
230        writer.flush()
231    }
232
233    fn clear_from_cursor(&self, writer: &mut dyn Write) -> io::Result<()> {
234        write!(writer, "\x1b[J")?;
235        writer.flush()
236    }
237
238    fn clear_screen(&self, writer: &mut dyn Write) -> io::Result<()> {
239        write!(writer, "\x1b[2J\x1b[H")?;
240        writer.flush()
241    }
242
243    fn set_title(&self, writer: &mut dyn Write, title: &str) -> io::Result<()> {
244        write!(writer, "\x1b]0;{}\x07", title)?;
245        writer.flush()
246    }
247
248    fn set_progress(&self, writer: &mut dyn Write, active: bool) -> io::Result<()> {
249        if active {
250            write!(writer, "\x1b]9;4;3\x07")?;
251        } else {
252            write!(writer, "\x1b]9;4;0;\x07")?;
253        }
254        writer.flush()
255    }
256
257    fn set_color_scheme_notifications(
258        &self,
259        writer: &mut dyn Write,
260        enabled: bool,
261    ) -> io::Result<()> {
262        if enabled {
263            write!(writer, "\x1b[?2031h")?;
264        } else {
265            write!(writer, "\x1b[?2031l")?;
266        }
267        writer.flush()
268    }
269}
270
271// =============================================================================
272// Legacy Terminal struct (backward compat) — uses crossterm execute! for Sized writers
273// =============================================================================
274
275use crossterm::{cursor, execute, terminal::ClearType};
276
277pub struct Terminal {
278    inner: ProcessTerminal,
279}
280
281impl Terminal {
282    pub fn new() -> Self {
283        Self {
284            inner: ProcessTerminal::new(),
285        }
286    }
287
288    pub fn enter_raw_mode(&mut self) -> io::Result<()> {
289        let mut buf = Vec::new();
290        self.inner.start(&mut buf)?;
291        if !buf.is_empty() {
292            let stdout = std::io::stdout();
293            let mut handle = stdout.lock();
294            handle.write_all(&buf)?;
295            handle.flush()?;
296        }
297        Ok(())
298    }
299
300    pub fn leave_raw_mode(&mut self) -> io::Result<()> {
301        let mut buf = Vec::new();
302        self.inner.stop(&mut buf)?;
303        if !buf.is_empty() {
304            let stdout = std::io::stdout();
305            let mut handle = stdout.lock();
306            handle.write_all(&buf)?;
307            handle.flush()?;
308        }
309        Ok(())
310    }
311
312    pub fn show_cursor(writer: &mut impl Write) -> io::Result<()> {
313        execute!(writer, cursor::Show)
314    }
315
316    pub fn hide_cursor(writer: &mut impl Write) -> io::Result<()> {
317        execute!(writer, cursor::Hide)
318    }
319
320    pub fn move_cursor_to(writer: &mut impl Write, row: u16, col: u16) -> io::Result<()> {
321        execute!(writer, cursor::MoveTo(col, row))
322    }
323
324    pub fn clear_line(writer: &mut impl Write) -> io::Result<()> {
325        execute!(writer, crossterm::terminal::Clear(ClearType::CurrentLine))
326    }
327
328    pub fn clear_screen(writer: &mut impl Write) -> io::Result<()> {
329        execute!(writer, crossterm::terminal::Clear(ClearType::All))
330    }
331
332    pub fn size() -> io::Result<(u16, u16)> {
333        crossterm::terminal::size()
334    }
335
336    pub fn write(writer: &mut impl Write, data: &str) -> io::Result<()> {
337        write!(writer, "{}", data)?;
338        writer.flush()
339    }
340
341    pub fn begin_sync(writer: &mut impl Write) -> io::Result<()> {
342        write!(writer, "\x1b[?2026h")?;
343        writer.flush()
344    }
345
346    pub fn end_sync(writer: &mut impl Write) -> io::Result<()> {
347        write!(writer, "\x1b[?2026l")?;
348        writer.flush()
349    }
350
351    pub fn set_color_scheme_notifications(
352        writer: &mut impl Write,
353        enabled: bool,
354    ) -> io::Result<()> {
355        if enabled {
356            write!(writer, "\x1b[?2031h")?;
357        } else {
358            write!(writer, "\x1b[?2031l")?;
359        }
360        writer.flush()
361    }
362}
363
364impl Default for Terminal {
365    fn default() -> Self {
366        Self::new()
367    }
368}
369
370impl Drop for Terminal {
371    fn drop(&mut self) {
372        let _ = self.inner.stop(&mut std::io::sink());
373    }
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379
380    #[test]
381    fn test_new_terminal() {
382        let term = ProcessTerminal::new();
383        assert!(!term.kitty_protocol_active());
384    }
385
386    #[test]
387    fn test_drain_input_timeout() {
388        let mut term = ProcessTerminal::new();
389        // drain_input may fail if no TTY is available in test env — that's ok
390        let _ = term.drain_input(10);
391    }
392}