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/// Rendering quality level, derived from terminal capabilities.
122///
123/// Widgets can inspect this to choose the best rendering strategy available.
124/// Ordered from lowest to highest fidelity — comparison operators work as expected.
125#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
126pub enum RenderingQuality {
127    /// No unicode — ASCII borders (+--+), no block characters.
128    Ascii,
129    /// Unicode box-drawing (─│┌┐) but no sub-cell tricks. Limited color.
130    Basic,
131    /// Half-blocks, eighth-blocks, braille — the standard TUI experience.
132    Standard,
133    /// Full sub-cell rendering + true color. Best visual fidelity.
134    High,
135}
136
137/// Detected terminal capabilities.
138///
139/// Use [`TerminalCaps::detect()`] to probe the current environment. Widgets and
140/// the rendering layer can inspect these fields to degrade gracefully on limited
141/// terminals (e.g., fall back to 256 colors or ASCII-only borders).
142#[derive(Debug, Clone)]
143pub struct TerminalCaps {
144    /// Color depth the terminal advertises.
145    pub color_depth: ColorDepth,
146    /// Whether the terminal supports Unicode (UTF-8 locale or Windows Terminal).
147    pub unicode: bool,
148    /// Whether mouse events are available (crossterm always enables this).
149    pub mouse: bool,
150    /// Whether the terminal supports setting the window title.
151    pub title: bool,
152    /// Whether the Kitty graphics protocol is available (image rendering).
153    pub kitty_graphics: bool,
154    /// Whether Sixel graphics are available (image rendering).
155    pub sixel: bool,
156    /// Whether iTerm2 inline image protocol is available.
157    pub iterm_images: bool,
158    /// Overall rendering quality level, derived from other capabilities.
159    pub rendering_quality: RenderingQuality,
160}
161
162impl TerminalCaps {
163    /// Detect terminal capabilities from environment variables and platform heuristics.
164    ///
165    /// **Color depth detection (in priority order):**
166    /// 1. `COLORTERM` env var contains "truecolor" or "24bit" -> TrueColor
167    /// 2. `TERM` env var contains "256color" -> EightBit
168    /// 3. Windows: `WT_SESSION` present (Windows Terminal) -> TrueColor, else EightBit
169    /// 4. `TERM` is "dumb" -> NoColor
170    /// 5. Fallback -> Standard (16 colors)
171    ///
172    /// **Unicode detection:**
173    /// 1. `LC_ALL` or `LANG` contains "UTF-8" or "utf8" (case-insensitive) -> true
174    /// 2. Windows: assume true (modern conhost + Windows Terminal handle Unicode)
175    /// 3. `TERM` is in xterm family -> true
176    /// 4. Fallback -> false
177    ///
178    /// **Mouse:** Always true (crossterm enables mouse capture).
179    ///
180    /// **Title:** true unless `TERM` is "dumb" or "linux" (Linux virtual console).
181    pub fn detect() -> Self {
182        let color_depth = detect_color_depth();
183        let unicode = detect_unicode();
184        let title = detect_title_support();
185        let kitty_graphics = detect_kitty_graphics();
186        let sixel = detect_sixel();
187        let iterm_images = detect_iterm_images();
188        let rendering_quality = derive_rendering_quality(color_depth, unicode);
189
190        Self {
191            color_depth,
192            unicode,
193            mouse: true, // crossterm always enables mouse capture
194            title,
195            kitty_graphics,
196            sixel,
197            iterm_images,
198            rendering_quality,
199        }
200    }
201}
202
203/// Module-level convenience function equivalent to [`TerminalCaps::detect()`].
204pub fn detect_capabilities() -> TerminalCaps {
205    TerminalCaps::detect()
206}
207
208fn detect_color_depth() -> ColorDepth {
209    // 1. COLORTERM is the strongest signal
210    if let Ok(ct) = std::env::var("COLORTERM") {
211        let ct_lower = ct.to_lowercase();
212        if ct_lower.contains("truecolor") || ct_lower.contains("24bit") {
213            return ColorDepth::TrueColor;
214        }
215    }
216
217    // 2. TERM containing 256color
218    if let Ok(term) = std::env::var("TERM") {
219        if term.contains("256color") {
220            return ColorDepth::EightBit;
221        }
222        if term == "dumb" {
223            return ColorDepth::NoColor;
224        }
225    }
226
227    // 3. Windows-specific heuristics
228    #[cfg(target_os = "windows")]
229    {
230        // Windows Terminal sets WT_SESSION
231        if std::env::var("WT_SESSION").is_ok() {
232            return ColorDepth::TrueColor;
233        }
234        // Modern Windows 10+ conhost supports 256 colors
235        ColorDepth::EightBit
236    }
237
238    // 4. Fallback: 16 standard colors
239    #[cfg(not(target_os = "windows"))]
240    ColorDepth::Standard
241}
242
243fn detect_unicode() -> bool {
244    // 1. Check locale env vars
245    for var_name in &["LC_ALL", "LANG", "LC_CTYPE"] {
246        if let Ok(val) = std::env::var(var_name) {
247            let val_upper = val.to_uppercase();
248            if val_upper.contains("UTF-8") || val_upper.contains("UTF8") {
249                return true;
250            }
251        }
252    }
253
254    // 2. Windows: modern terminals handle Unicode
255    #[cfg(target_os = "windows")]
256    {
257        true
258    }
259
260    // 3. xterm family usually supports Unicode
261    #[cfg(not(target_os = "windows"))]
262    {
263        if let Ok(term) = std::env::var("TERM") {
264            if term.starts_with("xterm") || term.starts_with("rxvt") || term.contains("256color") {
265                return true;
266            }
267        }
268        false
269    }
270}
271
272fn detect_title_support() -> bool {
273    if let Ok(term) = std::env::var("TERM") {
274        // Linux virtual console and dumb terminals don't support titles
275        if term == "dumb" || term == "linux" {
276            return false;
277        }
278    }
279    // On Windows and most other terminals, title is supported
280    true
281}
282
283/// Detect Kitty graphics protocol support.
284/// Checks `TERM_PROGRAM=kitty` or `KITTY_WINDOW_ID` env var.
285fn detect_kitty_graphics() -> bool {
286    if let Ok(prog) = std::env::var("TERM_PROGRAM") {
287        if prog.eq_ignore_ascii_case("kitty") {
288            return true;
289        }
290    }
291    std::env::var("KITTY_WINDOW_ID").is_ok()
292}
293
294/// Detect Sixel graphics support via heuristics.
295/// True DA1 query requires async terminal I/O; we use env-based heuristics.
296fn detect_sixel() -> bool {
297    // Explicit opt-in via env var
298    if std::env::var("SIXEL_SUPPORT").is_ok() {
299        return true;
300    }
301    // Some terminals advertise via TERM_PROGRAM
302    if let Ok(prog) = std::env::var("TERM_PROGRAM") {
303        let prog_lower = prog.to_lowercase();
304        // Known sixel-capable terminals
305        if prog_lower == "mlterm"
306            || prog_lower == "contour"
307            || prog_lower == "foot"
308            || prog_lower == "wezterm"
309        {
310            return true;
311        }
312    }
313    false
314}
315
316/// Detect iTerm2 inline image protocol support.
317/// Checks `TERM_PROGRAM=iTerm.app` or `LC_TERMINAL=iTerm2`.
318fn detect_iterm_images() -> bool {
319    if let Ok(prog) = std::env::var("TERM_PROGRAM") {
320        if prog == "iTerm.app" {
321            return true;
322        }
323    }
324    if let Ok(lc) = std::env::var("LC_TERMINAL") {
325        if lc == "iTerm2" {
326            return true;
327        }
328    }
329    // WezTerm also supports the iTerm2 image protocol
330    if let Ok(prog) = std::env::var("TERM_PROGRAM") {
331        if prog.to_lowercase() == "wezterm" {
332            return true;
333        }
334    }
335    false
336}
337
338/// Derive the overall rendering quality from color depth and unicode support.
339fn derive_rendering_quality(color_depth: ColorDepth, unicode: bool) -> RenderingQuality {
340    if !unicode {
341        return RenderingQuality::Ascii;
342    }
343    match color_depth {
344        ColorDepth::NoColor | ColorDepth::Standard => RenderingQuality::Basic,
345        ColorDepth::EightBit => RenderingQuality::Standard,
346        ColorDepth::TrueColor => RenderingQuality::High,
347    }
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353
354    #[test]
355    fn terminal_caps_detect_returns_valid_struct() {
356        let caps = TerminalCaps::detect();
357        // Mouse is always true
358        assert!(caps.mouse, "mouse should always be true");
359        // color_depth should be one of the valid variants
360        match caps.color_depth {
361            ColorDepth::NoColor
362            | ColorDepth::Standard
363            | ColorDepth::EightBit
364            | ColorDepth::TrueColor => {}
365        }
366    }
367
368    #[test]
369    fn terminal_caps_color_depth_equality() {
370        assert_eq!(ColorDepth::TrueColor, ColorDepth::TrueColor);
371        assert_ne!(ColorDepth::TrueColor, ColorDepth::EightBit);
372        assert_ne!(ColorDepth::Standard, ColorDepth::NoColor);
373    }
374
375    #[test]
376    fn terminal_detect_capabilities_convenience() {
377        let caps = detect_capabilities();
378        assert!(caps.mouse);
379        // Just ensure it doesn't panic and returns a valid struct
380    }
381
382    #[test]
383    fn terminal_caps_clone_and_debug() {
384        let caps = TerminalCaps::detect();
385        let cloned = caps.clone();
386        assert_eq!(caps.color_depth, cloned.color_depth);
387        assert_eq!(caps.unicode, cloned.unicode);
388        assert_eq!(caps.mouse, cloned.mouse);
389        assert_eq!(caps.title, cloned.title);
390        assert_eq!(caps.kitty_graphics, cloned.kitty_graphics);
391        assert_eq!(caps.sixel, cloned.sixel);
392        assert_eq!(caps.iterm_images, cloned.iterm_images);
393        assert_eq!(caps.rendering_quality, cloned.rendering_quality);
394        // Debug formatting should not panic
395        let _debug = format!("{:?}", caps);
396    }
397
398    #[test]
399    fn rendering_quality_ordering() {
400        assert!(RenderingQuality::Ascii < RenderingQuality::Basic);
401        assert!(RenderingQuality::Basic < RenderingQuality::Standard);
402        assert!(RenderingQuality::Standard < RenderingQuality::High);
403    }
404
405    #[test]
406    fn derive_quality_from_caps() {
407        assert_eq!(
408            derive_rendering_quality(ColorDepth::NoColor, false),
409            RenderingQuality::Ascii
410        );
411        assert_eq!(
412            derive_rendering_quality(ColorDepth::TrueColor, false),
413            RenderingQuality::Ascii
414        );
415        assert_eq!(
416            derive_rendering_quality(ColorDepth::NoColor, true),
417            RenderingQuality::Basic
418        );
419        assert_eq!(
420            derive_rendering_quality(ColorDepth::Standard, true),
421            RenderingQuality::Basic
422        );
423        assert_eq!(
424            derive_rendering_quality(ColorDepth::EightBit, true),
425            RenderingQuality::Standard
426        );
427        assert_eq!(
428            derive_rendering_quality(ColorDepth::TrueColor, true),
429            RenderingQuality::High
430        );
431    }
432
433    #[test]
434    fn terminal_color_depth_detection_windows() {
435        // On Windows (our CI/dev platform), detect should return at least EightBit
436        #[cfg(target_os = "windows")]
437        {
438            let caps = TerminalCaps::detect();
439            assert!(
440                caps.color_depth == ColorDepth::EightBit
441                    || caps.color_depth == ColorDepth::TrueColor,
442                "Windows should detect at least 256 colors, got {:?}",
443                caps.color_depth
444            );
445        }
446    }
447
448    #[test]
449    fn terminal_unicode_detection_windows() {
450        #[cfg(target_os = "windows")]
451        {
452            let caps = TerminalCaps::detect();
453            assert!(caps.unicode, "Windows should detect Unicode support");
454        }
455    }
456}