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 *const () 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;