rust_expect/interact/
terminal.rs

1//! Terminal interaction modes.
2
3use std::io::{self, Read, Write};
4use std::sync::Arc;
5use std::sync::atomic::{AtomicBool, Ordering};
6
7/// Terminal mode for interactive sessions.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum TerminalMode {
10    /// Raw mode - no processing.
11    Raw,
12    /// Cooked mode - line buffering.
13    Cooked,
14    /// Cbreak mode - character at a time, no echo.
15    Cbreak,
16}
17
18/// Terminal size.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub struct TerminalSize {
21    /// Number of columns.
22    pub cols: u16,
23    /// Number of rows.
24    pub rows: u16,
25}
26
27impl Default for TerminalSize {
28    fn default() -> Self {
29        Self { cols: 80, rows: 24 }
30    }
31}
32
33impl TerminalSize {
34    /// Create a new terminal size.
35    #[must_use]
36    pub const fn new(cols: u16, rows: u16) -> Self {
37        Self { cols, rows }
38    }
39}
40
41/// Terminal state for saving/restoring.
42#[derive(Debug, Clone)]
43pub struct TerminalState {
44    /// Current mode.
45    pub mode: TerminalMode,
46    /// Echo enabled.
47    pub echo: bool,
48    /// Canonical mode.
49    pub canonical: bool,
50}
51
52impl Default for TerminalState {
53    fn default() -> Self {
54        Self {
55            mode: TerminalMode::Cooked,
56            echo: true,
57            canonical: true,
58        }
59    }
60}
61
62/// A terminal handle for interactive sessions.
63pub struct Terminal {
64    /// Running flag.
65    running: Arc<AtomicBool>,
66    /// Current mode.
67    mode: TerminalMode,
68    /// Saved state.
69    saved_state: Option<TerminalState>,
70}
71
72impl Terminal {
73    /// Create a new terminal.
74    #[must_use]
75    pub fn new() -> Self {
76        Self {
77            running: Arc::new(AtomicBool::new(false)),
78            mode: TerminalMode::Cooked,
79            saved_state: None,
80        }
81    }
82
83    /// Check if the terminal is running.
84    #[must_use]
85    pub fn is_running(&self) -> bool {
86        self.running.load(Ordering::SeqCst)
87    }
88
89    /// Set the running state.
90    pub fn set_running(&self, running: bool) {
91        self.running.store(running, Ordering::SeqCst);
92    }
93
94    /// Get the running flag for sharing.
95    #[must_use]
96    pub fn running_flag(&self) -> Arc<AtomicBool> {
97        Arc::clone(&self.running)
98    }
99
100    /// Get the current mode.
101    #[must_use]
102    pub const fn mode(&self) -> TerminalMode {
103        self.mode
104    }
105
106    /// Set terminal mode.
107    pub const fn set_mode(&mut self, mode: TerminalMode) {
108        self.mode = mode;
109    }
110
111    /// Save current state.
112    pub const fn save_state(&mut self) {
113        self.saved_state = Some(TerminalState {
114            mode: self.mode,
115            echo: true,
116            canonical: matches!(self.mode, TerminalMode::Cooked),
117        });
118    }
119
120    /// Restore saved state.
121    pub const fn restore_state(&mut self) {
122        if let Some(state) = self.saved_state.take() {
123            self.mode = state.mode;
124        }
125    }
126
127    /// Get terminal size.
128    pub fn size() -> io::Result<TerminalSize> {
129        // Use environment variables or defaults
130        let cols = std::env::var("COLUMNS")
131            .ok()
132            .and_then(|s| s.parse().ok())
133            .unwrap_or(80);
134        let rows = std::env::var("LINES")
135            .ok()
136            .and_then(|s| s.parse().ok())
137            .unwrap_or(24);
138        Ok(TerminalSize::new(cols, rows))
139    }
140
141    /// Check if stdin is a TTY.
142    #[must_use]
143    #[allow(unsafe_code)]
144    pub fn is_tty() -> bool {
145        #[cfg(unix)]
146        {
147            use std::os::unix::io::AsRawFd;
148            unsafe { libc::isatty(std::io::stdin().as_raw_fd()) != 0 }
149        }
150        #[cfg(not(unix))]
151        {
152            false
153        }
154    }
155}
156
157impl Default for Terminal {
158    fn default() -> Self {
159        Self::new()
160    }
161}
162
163/// Read input with timeout.
164pub fn read_with_timeout(timeout_ms: u64) -> io::Result<Option<u8>> {
165    use std::time::{Duration, Instant};
166
167    let deadline = Instant::now() + Duration::from_millis(timeout_ms);
168
169    loop {
170        // Non-blocking read attempt
171        let mut buf = [0u8; 1];
172        match io::stdin().read(&mut buf) {
173            Ok(0) => return Ok(None),
174            Ok(_) => return Ok(Some(buf[0])),
175            Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => {
176                if Instant::now() >= deadline {
177                    return Ok(None);
178                }
179                std::thread::sleep(Duration::from_millis(10));
180            }
181            Err(e) => return Err(e),
182        }
183    }
184}
185
186/// Write output immediately.
187pub fn write_immediate(data: &[u8]) -> io::Result<()> {
188    let mut stdout = io::stdout();
189    stdout.write_all(data)?;
190    stdout.flush()
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    #[test]
198    fn terminal_default() {
199        let term = Terminal::new();
200        assert!(!term.is_running());
201        assert_eq!(term.mode(), TerminalMode::Cooked);
202    }
203
204    #[test]
205    fn terminal_size_default() {
206        let size = TerminalSize::default();
207        assert_eq!(size.cols, 80);
208        assert_eq!(size.rows, 24);
209    }
210
211    #[test]
212    fn terminal_running_flag() {
213        let term = Terminal::new();
214        let flag = term.running_flag();
215
216        assert!(!term.is_running());
217        flag.store(true, Ordering::SeqCst);
218        assert!(term.is_running());
219    }
220}