Skip to main content

textual_rs/
terminal.rs

1//! Terminal setup and teardown: raw mode, alternate screen, and mouse capture.
2
3use crossterm::cursor::{Hide, Show};
4use crossterm::event::{DisableMouseCapture, EnableMouseCapture};
5use crossterm::execute;
6use crossterm::terminal::{
7    disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
8};
9use std::io;
10use std::panic;
11
12/// Install a panic hook that restores the terminal before printing the panic message.
13/// MUST be called before TerminalGuard::new() (before entering raw mode).
14pub fn init_panic_hook() {
15    let original_hook = panic::take_hook();
16    panic::set_hook(Box::new(move |panic_info| {
17        let _ = disable_raw_mode();
18        let _ = execute!(
19            io::stdout(),
20            LeaveAlternateScreen,
21            DisableMouseCapture,
22            Show
23        );
24        original_hook(panic_info);
25    }));
26}
27
28/// RAII guard that enters raw mode + alt screen + mouse capture on creation and restores on drop.
29pub struct TerminalGuard;
30
31impl TerminalGuard {
32    /// Enter raw mode, alternate screen, and mouse capture. Returns error if terminal setup fails.
33    pub fn new() -> io::Result<Self> {
34        enable_raw_mode()?;
35        execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture, Hide)?;
36        Ok(TerminalGuard)
37    }
38}
39
40impl Drop for TerminalGuard {
41    fn drop(&mut self) {
42        let _ = disable_raw_mode();
43        let _ = execute!(
44            io::stdout(),
45            LeaveAlternateScreen,
46            DisableMouseCapture,
47            Show
48        );
49    }
50}
51
52// ---------------------------------------------------------------------------
53// Mouse capture stack
54// ---------------------------------------------------------------------------
55
56/// Stack-based mouse capture state. The effective state is the top of the stack,
57/// defaulting to `true` (captured) when empty. Screens/widgets push to temporarily
58/// override; pop to restore. This prevents competing enable/disable calls from
59/// clobbering each other.
60#[derive(Debug, Clone)]
61pub struct MouseCaptureStack {
62    stack: Vec<bool>,
63}
64
65impl MouseCaptureStack {
66    /// Create a new empty stack. The effective state defaults to captured (true).
67    pub fn new() -> Self {
68        Self { stack: Vec::new() }
69    }
70
71    /// Current effective mouse-capture state. True = terminal captures mouse events;
72    /// false = pass-through to terminal emulator for native selection.
73    pub fn is_enabled(&self) -> bool {
74        self.stack.last().copied().unwrap_or(true)
75    }
76
77    /// Push a new capture state. Returns the previous is_enabled() value
78    /// so the caller can detect transitions.
79    pub fn push(&mut self, enabled: bool) -> bool {
80        let prev = self.is_enabled();
81        self.stack.push(enabled);
82        prev
83    }
84
85    /// Pop the top capture state. Returns the new is_enabled() value.
86    /// No-op if stack is empty (default state cannot be popped).
87    pub fn pop(&mut self) -> bool {
88        self.stack.pop();
89        self.is_enabled()
90    }
91
92    /// Reset to default state (empty stack = captured). Used by resize guard.
93    pub fn reset(&mut self) {
94        self.stack.clear();
95    }
96}
97
98impl Default for MouseCaptureStack {
99    fn default() -> Self {
100        Self::new()
101    }
102}
103
104// ---------------------------------------------------------------------------
105// Terminal capability detection
106// ---------------------------------------------------------------------------
107
108/// Color depth level supported by the terminal.
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub enum ColorDepth {
111    /// No color (dumb terminal)
112    NoColor,
113    /// 16 standard ANSI colors
114    Standard,
115    /// 256 color palette (xterm-256color)
116    EightBit,
117    /// 24-bit true color (16M colors)
118    TrueColor,
119}
120
121/// Detected terminal capabilities.
122///
123/// Use [`TerminalCaps::detect()`] to probe the current environment. Widgets and
124/// the rendering layer can inspect these fields to degrade gracefully on limited
125/// terminals (e.g., fall back to 256 colors or ASCII-only borders).
126#[derive(Debug, Clone)]
127pub struct TerminalCaps {
128    /// Color depth the terminal advertises.
129    pub color_depth: ColorDepth,
130    /// Whether the terminal supports Unicode (UTF-8 locale or Windows Terminal).
131    pub unicode: bool,
132    /// Whether mouse events are available (crossterm always enables this).
133    pub mouse: bool,
134    /// Whether the terminal supports setting the window title.
135    pub title: bool,
136}
137
138impl TerminalCaps {
139    /// Detect terminal capabilities from environment variables and platform heuristics.
140    ///
141    /// **Color depth detection (in priority order):**
142    /// 1. `COLORTERM` env var contains "truecolor" or "24bit" -> TrueColor
143    /// 2. `TERM` env var contains "256color" -> EightBit
144    /// 3. Windows: `WT_SESSION` present (Windows Terminal) -> TrueColor, else EightBit
145    /// 4. `TERM` is "dumb" -> NoColor
146    /// 5. Fallback -> Standard (16 colors)
147    ///
148    /// **Unicode detection:**
149    /// 1. `LC_ALL` or `LANG` contains "UTF-8" or "utf8" (case-insensitive) -> true
150    /// 2. Windows: assume true (modern conhost + Windows Terminal handle Unicode)
151    /// 3. `TERM` is in xterm family -> true
152    /// 4. Fallback -> false
153    ///
154    /// **Mouse:** Always true (crossterm enables mouse capture).
155    ///
156    /// **Title:** true unless `TERM` is "dumb" or "linux" (Linux virtual console).
157    pub fn detect() -> Self {
158        let color_depth = detect_color_depth();
159        let unicode = detect_unicode();
160        let title = detect_title_support();
161
162        Self {
163            color_depth,
164            unicode,
165            mouse: true, // crossterm always enables mouse capture
166            title,
167        }
168    }
169}
170
171/// Module-level convenience function equivalent to [`TerminalCaps::detect()`].
172pub fn detect_capabilities() -> TerminalCaps {
173    TerminalCaps::detect()
174}
175
176fn detect_color_depth() -> ColorDepth {
177    // 1. COLORTERM is the strongest signal
178    if let Ok(ct) = std::env::var("COLORTERM") {
179        let ct_lower = ct.to_lowercase();
180        if ct_lower.contains("truecolor") || ct_lower.contains("24bit") {
181            return ColorDepth::TrueColor;
182        }
183    }
184
185    // 2. TERM containing 256color
186    if let Ok(term) = std::env::var("TERM") {
187        if term.contains("256color") {
188            return ColorDepth::EightBit;
189        }
190        if term == "dumb" {
191            return ColorDepth::NoColor;
192        }
193    }
194
195    // 3. Windows-specific heuristics
196    #[cfg(target_os = "windows")]
197    {
198        // Windows Terminal sets WT_SESSION
199        if std::env::var("WT_SESSION").is_ok() {
200            return ColorDepth::TrueColor;
201        }
202        // Modern Windows 10+ conhost supports 256 colors
203        ColorDepth::EightBit
204    }
205
206    // 4. Fallback: 16 standard colors
207    #[cfg(not(target_os = "windows"))]
208    ColorDepth::Standard
209}
210
211fn detect_unicode() -> bool {
212    // 1. Check locale env vars
213    for var_name in &["LC_ALL", "LANG", "LC_CTYPE"] {
214        if let Ok(val) = std::env::var(var_name) {
215            let val_upper = val.to_uppercase();
216            if val_upper.contains("UTF-8") || val_upper.contains("UTF8") {
217                return true;
218            }
219        }
220    }
221
222    // 2. Windows: modern terminals handle Unicode
223    #[cfg(target_os = "windows")]
224    {
225        true
226    }
227
228    // 3. xterm family usually supports Unicode
229    #[cfg(not(target_os = "windows"))]
230    {
231        if let Ok(term) = std::env::var("TERM") {
232            if term.starts_with("xterm") || term.starts_with("rxvt") || term.contains("256color") {
233                return true;
234            }
235        }
236        false
237    }
238}
239
240fn detect_title_support() -> bool {
241    if let Ok(term) = std::env::var("TERM") {
242        // Linux virtual console and dumb terminals don't support titles
243        if term == "dumb" || term == "linux" {
244            return false;
245        }
246    }
247    // On Windows and most other terminals, title is supported
248    true
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    #[test]
256    fn terminal_caps_detect_returns_valid_struct() {
257        let caps = TerminalCaps::detect();
258        // Mouse is always true
259        assert!(caps.mouse, "mouse should always be true");
260        // color_depth should be one of the valid variants
261        match caps.color_depth {
262            ColorDepth::NoColor
263            | ColorDepth::Standard
264            | ColorDepth::EightBit
265            | ColorDepth::TrueColor => {}
266        }
267    }
268
269    #[test]
270    fn terminal_caps_color_depth_equality() {
271        assert_eq!(ColorDepth::TrueColor, ColorDepth::TrueColor);
272        assert_ne!(ColorDepth::TrueColor, ColorDepth::EightBit);
273        assert_ne!(ColorDepth::Standard, ColorDepth::NoColor);
274    }
275
276    #[test]
277    fn terminal_detect_capabilities_convenience() {
278        let caps = detect_capabilities();
279        assert!(caps.mouse);
280        // Just ensure it doesn't panic and returns a valid struct
281    }
282
283    #[test]
284    fn terminal_caps_clone_and_debug() {
285        let caps = TerminalCaps::detect();
286        let cloned = caps.clone();
287        assert_eq!(caps.color_depth, cloned.color_depth);
288        assert_eq!(caps.unicode, cloned.unicode);
289        assert_eq!(caps.mouse, cloned.mouse);
290        assert_eq!(caps.title, cloned.title);
291        // Debug formatting should not panic
292        let _debug = format!("{:?}", caps);
293    }
294
295    #[test]
296    fn terminal_color_depth_detection_windows() {
297        // On Windows (our CI/dev platform), detect should return at least EightBit
298        #[cfg(target_os = "windows")]
299        {
300            let caps = TerminalCaps::detect();
301            assert!(
302                caps.color_depth == ColorDepth::EightBit
303                    || caps.color_depth == ColorDepth::TrueColor,
304                "Windows should detect at least 256 colors, got {:?}",
305                caps.color_depth
306            );
307        }
308    }
309
310    #[test]
311    fn terminal_unicode_detection_windows() {
312        #[cfg(target_os = "windows")]
313        {
314            let caps = TerminalCaps::detect();
315            assert!(caps.unicode, "Windows should detect Unicode support");
316        }
317    }
318}