Skip to main content

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            // SAFETY: `libc::isatty` is FFI-safe; it accepts any file descriptor value
149            // and returns 0/1 without reading or writing memory. The fd from stdin is
150            // valid for the duration of the process.
151            unsafe { libc::isatty(std::io::stdin().as_raw_fd()) != 0 }
152        }
153        #[cfg(not(unix))]
154        {
155            false
156        }
157    }
158}
159
160impl Default for Terminal {
161    fn default() -> Self {
162        Self::new()
163    }
164}
165
166/// Read input with timeout.
167pub fn read_with_timeout(timeout_ms: u64) -> io::Result<Option<u8>> {
168    use std::time::{Duration, Instant};
169
170    let deadline = Instant::now() + Duration::from_millis(timeout_ms);
171
172    loop {
173        // Non-blocking read attempt
174        let mut buf = [0u8; 1];
175        match io::stdin().read(&mut buf) {
176            Ok(0) => return Ok(None),
177            Ok(_) => return Ok(Some(buf[0])),
178            Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => {
179                if Instant::now() >= deadline {
180                    return Ok(None);
181                }
182                std::thread::sleep(Duration::from_millis(10));
183            }
184            Err(e) => return Err(e),
185        }
186    }
187}
188
189/// Write output immediately.
190pub fn write_immediate(data: &[u8]) -> io::Result<()> {
191    let mut stdout = io::stdout();
192    stdout.write_all(data)?;
193    stdout.flush()
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    #[test]
201    fn terminal_default() {
202        let term = Terminal::new();
203        assert!(!term.is_running());
204        assert_eq!(term.mode(), TerminalMode::Cooked);
205    }
206
207    #[test]
208    fn terminal_size_default() {
209        let size = TerminalSize::default();
210        assert_eq!(size.cols, 80);
211        assert_eq!(size.rows, 24);
212    }
213
214    #[test]
215    fn terminal_running_flag() {
216        let term = Terminal::new();
217        let flag = term.running_flag();
218
219        assert!(!term.is_running());
220        flag.store(true, Ordering::SeqCst);
221        assert!(term.is_running());
222    }
223}