seq_runtime/
terminal.rs

1//! Terminal Operations for Seq
2//!
3//! These functions provide low-level terminal control for building
4//! interactive applications (vim-style editors, menus, etc.).
5//!
6//! # Platform Support
7//!
8//! These functions are Unix-only (they use POSIX termios). On non-TTY
9//! file descriptors, operations gracefully degrade (raw mode is a no-op,
10//! size returns defaults).
11//!
12//! # Thread Safety
13//!
14//! Terminal operations are **not thread-safe** and should only be called
15//! from the main thread. This is standard for TUI applications - terminal
16//! state is global to the process.
17//!
18//! # Signal Safety
19//!
20//! When raw mode is enabled, signal handlers are installed for SIGINT and
21//! SIGTERM that restore terminal state before the process exits. This ensures
22//! the terminal isn't left in a broken state if the program is killed.
23//!
24//! # Safety Contract
25//!
26//! These functions are designed to be called ONLY by compiler-generated code.
27//! The compiler is responsible for ensuring correct stack types.
28
29use crate::stack::{Stack, pop, push};
30use crate::value::Value;
31use std::sync::atomic::{AtomicBool, Ordering};
32
33/// Track whether raw mode is currently enabled
34static RAW_MODE_ENABLED: AtomicBool = AtomicBool::new(false);
35
36/// Saved terminal settings (for restoration when exiting raw mode)
37static mut SAVED_TERMIOS: Option<libc::termios> = None;
38
39/// Saved signal handlers (for restoration when exiting raw mode)
40static mut SAVED_SIGINT_ACTION: Option<libc::sigaction> = None;
41static mut SAVED_SIGTERM_ACTION: Option<libc::sigaction> = None;
42
43/// Enable or disable raw terminal mode
44///
45/// Stack effect: ( Bool -- )
46///
47/// When enabled:
48/// - Input is not line-buffered (characters available immediately)
49/// - Echo is disabled
50/// - Ctrl+C doesn't generate SIGINT (read as byte 3)
51///
52/// # Safety
53/// Stack must have a Bool value on top
54#[unsafe(no_mangle)]
55pub unsafe extern "C" fn patch_seq_terminal_raw_mode(stack: Stack) -> Stack {
56    assert!(!stack.is_null(), "terminal_raw_mode: stack is empty");
57
58    let (rest, value) = unsafe { pop(stack) };
59
60    match value {
61        Value::Bool(enable) => {
62            if enable {
63                enable_raw_mode();
64            } else {
65                disable_raw_mode();
66            }
67            rest
68        }
69        _ => panic!("terminal_raw_mode: expected Bool on stack, got {:?}", value),
70    }
71}
72
73/// Read a single character from stdin (blocking)
74///
75/// Stack effect: ( -- Int )
76///
77/// Returns:
78/// - 0-255: The byte value read
79/// - -1: EOF or error
80///
81/// In raw mode, this returns immediately when a key is pressed.
82/// In cooked mode, this waits for Enter.
83///
84/// # Safety
85/// Always safe to call
86#[unsafe(no_mangle)]
87pub unsafe extern "C" fn patch_seq_terminal_read_char(stack: Stack) -> Stack {
88    let mut buf = [0u8; 1];
89    let result =
90        unsafe { libc::read(libc::STDIN_FILENO, buf.as_mut_ptr() as *mut libc::c_void, 1) };
91
92    let char_value = if result == 1 {
93        buf[0] as i64
94    } else {
95        -1 // EOF or error
96    };
97
98    unsafe { push(stack, Value::Int(char_value)) }
99}
100
101/// Read a single character from stdin (non-blocking)
102///
103/// Stack effect: ( -- Int )
104///
105/// Returns:
106/// - 0-255: The byte value read
107/// - -1: No input available, EOF, or error
108///
109/// This function returns immediately even if no input is available.
110///
111/// # Safety
112/// Always safe to call
113#[unsafe(no_mangle)]
114pub unsafe extern "C" fn patch_seq_terminal_read_char_nonblock(stack: Stack) -> Stack {
115    // Save current flags - if this fails, return -1
116    let flags = unsafe { libc::fcntl(libc::STDIN_FILENO, libc::F_GETFL) };
117    if flags < 0 {
118        return unsafe { push(stack, Value::Int(-1)) };
119    }
120
121    // Set non-blocking
122    unsafe { libc::fcntl(libc::STDIN_FILENO, libc::F_SETFL, flags | libc::O_NONBLOCK) };
123
124    let mut buf = [0u8; 1];
125    let result =
126        unsafe { libc::read(libc::STDIN_FILENO, buf.as_mut_ptr() as *mut libc::c_void, 1) };
127
128    // Always restore original flags
129    unsafe { libc::fcntl(libc::STDIN_FILENO, libc::F_SETFL, flags) };
130
131    let char_value = if result == 1 {
132        buf[0] as i64
133    } else {
134        -1 // No input, EOF, or error
135    };
136
137    unsafe { push(stack, Value::Int(char_value)) }
138}
139
140/// Get terminal width (columns)
141///
142/// Stack effect: ( -- Int )
143///
144/// Returns the number of columns in the terminal, or 80 if unknown.
145///
146/// # Safety
147/// Always safe to call
148#[unsafe(no_mangle)]
149pub unsafe extern "C" fn patch_seq_terminal_width(stack: Stack) -> Stack {
150    let width = get_terminal_size().0;
151    unsafe { push(stack, Value::Int(width)) }
152}
153
154/// Get terminal height (rows)
155///
156/// Stack effect: ( -- Int )
157///
158/// Returns the number of rows in the terminal, or 24 if unknown.
159///
160/// # Safety
161/// Always safe to call
162#[unsafe(no_mangle)]
163pub unsafe extern "C" fn patch_seq_terminal_height(stack: Stack) -> Stack {
164    let height = get_terminal_size().1;
165    unsafe { push(stack, Value::Int(height)) }
166}
167
168/// Flush stdout
169///
170/// Stack effect: ( -- )
171///
172/// Ensures all buffered output is written to the terminal.
173/// Useful after writing escape sequences or partial lines.
174///
175/// # Safety
176/// Always safe to call
177#[unsafe(no_mangle)]
178pub unsafe extern "C" fn patch_seq_terminal_flush(stack: Stack) -> Stack {
179    use std::io::Write;
180    let _ = std::io::stdout().flush();
181    stack
182}
183
184// ============================================================================
185// Internal helper functions
186// ============================================================================
187
188/// Signal handler that restores terminal state and re-raises the signal
189///
190/// This is called when SIGINT or SIGTERM is received while in raw mode.
191/// It restores the terminal to its original state, then re-raises the signal
192/// with the default handler so the process exits with the correct status.
193///
194/// Note: This handler uses minimal operations that are async-signal-safe.
195/// tcsetattr and signal/raise are all POSIX async-signal-safe functions.
196extern "C" fn signal_handler(sig: libc::c_int) {
197    // Restore terminal state (safe to call even if already restored)
198    // Note: tcsetattr is async-signal-safe per POSIX
199    unsafe {
200        if let Some(ref saved) = SAVED_TERMIOS {
201            libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, saved);
202        }
203    }
204
205    // Restore default signal handler and re-raise
206    unsafe {
207        libc::signal(sig, libc::SIG_DFL);
208        libc::raise(sig);
209    }
210}
211
212/// Install signal handlers for SIGINT and SIGTERM
213fn install_signal_handlers() {
214    unsafe {
215        let mut new_action: libc::sigaction = std::mem::zeroed();
216        new_action.sa_sigaction = signal_handler as usize;
217        libc::sigemptyset(&mut new_action.sa_mask);
218        new_action.sa_flags = 0;
219
220        // Save and replace SIGINT handler
221        let mut old_sigint: libc::sigaction = std::mem::zeroed();
222        if libc::sigaction(libc::SIGINT, &new_action, &mut old_sigint) == 0 {
223            SAVED_SIGINT_ACTION = Some(old_sigint);
224        }
225
226        // Save and replace SIGTERM handler
227        let mut old_sigterm: libc::sigaction = std::mem::zeroed();
228        if libc::sigaction(libc::SIGTERM, &new_action, &mut old_sigterm) == 0 {
229            SAVED_SIGTERM_ACTION = Some(old_sigterm);
230        }
231    }
232}
233
234/// Restore original signal handlers
235fn restore_signal_handlers() {
236    unsafe {
237        if let Some(ref action) = SAVED_SIGINT_ACTION {
238            libc::sigaction(libc::SIGINT, action, std::ptr::null_mut());
239        }
240        SAVED_SIGINT_ACTION = None;
241
242        if let Some(ref action) = SAVED_SIGTERM_ACTION {
243            libc::sigaction(libc::SIGTERM, action, std::ptr::null_mut());
244        }
245        SAVED_SIGTERM_ACTION = None;
246    }
247}
248
249fn enable_raw_mode() {
250    if RAW_MODE_ENABLED.load(Ordering::SeqCst) {
251        return; // Already in raw mode
252    }
253
254    unsafe {
255        // Check if stdin is a TTY - if not, raw mode is meaningless
256        if libc::isatty(libc::STDIN_FILENO) != 1 {
257            return; // Not a terminal, silently ignore
258        }
259
260        let mut termios: libc::termios = std::mem::zeroed();
261
262        // Get current terminal settings
263        if libc::tcgetattr(libc::STDIN_FILENO, &mut termios) != 0 {
264            return; // Failed to get settings
265        }
266
267        // Save for later restoration
268        SAVED_TERMIOS = Some(termios);
269
270        // Modify for raw mode:
271        // - Turn off ICANON (canonical mode) - no line buffering
272        // - Turn off ECHO - don't echo typed characters
273        // - Turn off ISIG - don't generate signals for Ctrl+C, Ctrl+Z
274        // - Turn off IEXTEN - disable implementation-defined input processing
275        termios.c_lflag &= !(libc::ICANON | libc::ECHO | libc::ISIG | libc::IEXTEN);
276
277        // Input flags:
278        // - Turn off IXON - disable Ctrl+S/Ctrl+Q flow control
279        // - Turn off ICRNL - don't translate CR to NL
280        termios.c_iflag &= !(libc::IXON | libc::ICRNL);
281
282        // Output flags:
283        // - Turn off OPOST - disable output processing
284        termios.c_oflag &= !libc::OPOST;
285
286        // Set VMIN and VTIME for blocking read of 1 character
287        termios.c_cc[libc::VMIN] = 1;
288        termios.c_cc[libc::VTIME] = 0;
289
290        // Apply settings
291        if libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, &termios) == 0 {
292            RAW_MODE_ENABLED.store(true, Ordering::SeqCst);
293            // Install signal handlers AFTER successfully entering raw mode
294            install_signal_handlers();
295        }
296    }
297}
298
299fn disable_raw_mode() {
300    if !RAW_MODE_ENABLED.load(Ordering::SeqCst) {
301        return; // Not in raw mode
302    }
303
304    // Restore signal handlers BEFORE restoring terminal
305    restore_signal_handlers();
306
307    unsafe {
308        if let Some(ref saved) = SAVED_TERMIOS {
309            libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, saved);
310        }
311        SAVED_TERMIOS = None;
312        RAW_MODE_ENABLED.store(false, Ordering::SeqCst);
313    }
314}
315
316fn get_terminal_size() -> (i64, i64) {
317    unsafe {
318        // Check if stdout is a TTY
319        if libc::isatty(libc::STDOUT_FILENO) != 1 {
320            return (80, 24); // Not a terminal, return defaults
321        }
322
323        let mut winsize: libc::winsize = std::mem::zeroed();
324        if libc::ioctl(libc::STDOUT_FILENO, libc::TIOCGWINSZ, &mut winsize) == 0 {
325            let cols = if winsize.ws_col > 0 {
326                winsize.ws_col as i64
327            } else {
328                80
329            };
330            let rows = if winsize.ws_row > 0 {
331                winsize.ws_row as i64
332            } else {
333                24
334            };
335            (cols, rows)
336        } else {
337            (80, 24) // Default fallback
338        }
339    }
340}
341
342// Public re-exports with short names for internal use
343pub use patch_seq_terminal_flush as terminal_flush;
344pub use patch_seq_terminal_height as terminal_height;
345pub use patch_seq_terminal_raw_mode as terminal_raw_mode;
346pub use patch_seq_terminal_read_char as terminal_read_char;
347pub use patch_seq_terminal_read_char_nonblock as terminal_read_char_nonblock;
348pub use patch_seq_terminal_width as terminal_width;
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353
354    #[test]
355    fn test_terminal_size() {
356        // Should return reasonable values (not panic)
357        let (width, height) = get_terminal_size();
358        assert!(width > 0);
359        assert!(height > 0);
360    }
361
362    #[test]
363    fn test_terminal_width_stack() {
364        unsafe {
365            let stack = crate::stack::alloc_test_stack();
366            let stack = terminal_width(stack);
367            let (_, value) = pop(stack);
368            match value {
369                Value::Int(w) => assert!(w > 0),
370                _ => panic!("expected Int"),
371            }
372        }
373    }
374
375    #[test]
376    fn test_terminal_height_stack() {
377        unsafe {
378            let stack = crate::stack::alloc_test_stack();
379            let stack = terminal_height(stack);
380            let (_, value) = pop(stack);
381            match value {
382                Value::Int(h) => assert!(h > 0),
383                _ => panic!("expected Int"),
384            }
385        }
386    }
387
388    #[test]
389    fn test_raw_mode_toggle() {
390        // Test that we can toggle raw mode without crashing
391        // Note: This may not work in all test environments
392        enable_raw_mode();
393        disable_raw_mode();
394        // Should be back to normal
395        assert!(!RAW_MODE_ENABLED.load(Ordering::SeqCst));
396    }
397}