Skip to main content

entrenar/train/tui/capability/
capabilities.rs

1//! Terminal capability detection.
2
3use super::TerminalMode;
4
5/// Detected terminal capabilities.
6#[allow(clippy::struct_excessive_bools)]
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub struct TerminalCapabilities {
9    /// Terminal width in columns
10    pub width: u16,
11    /// Terminal height in rows
12    pub height: u16,
13    /// Supports Unicode characters
14    pub unicode: bool,
15    /// Supports ANSI color codes
16    pub ansi_color: bool,
17    /// Supports 24-bit true color
18    pub true_color: bool,
19    /// Is interactive TTY
20    pub is_tty: bool,
21}
22
23impl Default for TerminalCapabilities {
24    fn default() -> Self {
25        Self {
26            width: 80,
27            height: 24,
28            unicode: true,
29            ansi_color: true,
30            true_color: false,
31            is_tty: true,
32        }
33    }
34}
35
36impl TerminalCapabilities {
37    /// Detect terminal capabilities from environment.
38    pub fn detect() -> Self {
39        use std::env;
40        use std::io::{stdout, IsTerminal};
41
42        let is_tty = stdout().is_terminal();
43
44        // Get size from environment or default
45        let (width, height) = Self::get_size();
46
47        // Check for Unicode support (most modern terminals)
48        let lang = env::var("LANG").unwrap_or_default();
49        let unicode = lang.contains("UTF") || lang.contains("utf");
50
51        // Check for ANSI color support
52        let term = env::var("TERM").unwrap_or_default();
53        let ansi_color = !term.is_empty() && term != "dumb";
54
55        // Check for true color support
56        let colorterm = env::var("COLORTERM").unwrap_or_default();
57        let true_color = colorterm == "truecolor" || colorterm == "24bit";
58
59        Self { width, height, unicode, ansi_color, true_color, is_tty }
60    }
61
62    /// Get terminal size.
63    pub(crate) fn get_size() -> (u16, u16) {
64        use std::env;
65
66        // 1. Check environment variables (CI/headless)
67        if let (Ok(cols), Ok(rows)) = (env::var("COLUMNS"), env::var("LINES")) {
68            if let (Ok(c), Ok(r)) = (cols.parse(), rows.parse()) {
69                return (c, r);
70            }
71        }
72
73        // 2. Try ioctl on Unix
74        #[cfg(unix)]
75        {
76            use std::io::{stdout, IsTerminal};
77            if stdout().is_terminal() {
78                // Use libc directly for TIOCGWINSZ
79                #[repr(C)]
80                #[allow(clippy::struct_field_names)]
81                struct WinSize {
82                    ws_row: u16,
83                    ws_col: u16,
84                    ws_xpixel: u16,
85                    ws_ypixel: u16,
86                }
87                extern "C" {
88                    fn ioctl(fd: i32, request: u64, ...) -> i32;
89                }
90                const TIOCGWINSZ: u64 = 0x5413; // Linux
91                let mut ws = WinSize { ws_row: 0, ws_col: 0, ws_xpixel: 0, ws_ypixel: 0 };
92                // SAFETY: ioctl with TIOCGWINSZ is safe for reading terminal size
93                #[allow(unsafe_code)]
94                if unsafe { ioctl(1, TIOCGWINSZ, &mut ws) } == 0 && ws.ws_col > 0 {
95                    return (ws.ws_col, ws.ws_row);
96                }
97            }
98        }
99
100        // 3. Fallback
101        (80, 24)
102    }
103
104    /// Get recommended terminal mode based on capabilities.
105    pub fn recommended_mode(&self) -> TerminalMode {
106        if !self.is_tty {
107            TerminalMode::Ascii
108        } else if self.true_color {
109            TerminalMode::Ansi
110        } else if self.unicode {
111            TerminalMode::Unicode
112        } else {
113            TerminalMode::Ascii
114        }
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    #[test]
123    fn test_terminal_capabilities_default() {
124        let caps = TerminalCapabilities::default();
125        assert_eq!(caps.width, 80);
126        assert_eq!(caps.height, 24);
127        assert!(caps.unicode);
128        assert!(caps.ansi_color);
129        assert!(!caps.true_color);
130        assert!(caps.is_tty);
131    }
132
133    #[test]
134    fn test_terminal_capabilities_clone() {
135        let caps = TerminalCapabilities::default();
136        let cloned = caps;
137        assert_eq!(caps.width, cloned.width);
138        assert_eq!(caps.unicode, cloned.unicode);
139    }
140
141    #[test]
142    fn test_terminal_capabilities_eq() {
143        let caps1 = TerminalCapabilities::default();
144        let caps2 = TerminalCapabilities::default();
145        assert_eq!(caps1, caps2);
146
147        let caps3 = TerminalCapabilities { width: 120, ..Default::default() };
148        assert_ne!(caps1, caps3);
149    }
150
151    #[test]
152    fn test_terminal_capabilities_debug() {
153        let caps = TerminalCapabilities::default();
154        let debug = format!("{caps:?}");
155        assert!(debug.contains("TerminalCapabilities"));
156        assert!(debug.contains("width: 80"));
157    }
158
159    #[test]
160    fn test_recommended_mode_not_tty() {
161        let caps = TerminalCapabilities { is_tty: false, ..Default::default() };
162        assert_eq!(caps.recommended_mode(), TerminalMode::Ascii);
163    }
164
165    #[test]
166    fn test_recommended_mode_true_color() {
167        let caps = TerminalCapabilities {
168            is_tty: true,
169            true_color: true,
170            unicode: true,
171            ..Default::default()
172        };
173        assert_eq!(caps.recommended_mode(), TerminalMode::Ansi);
174    }
175
176    #[test]
177    fn test_recommended_mode_unicode() {
178        let caps = TerminalCapabilities {
179            is_tty: true,
180            true_color: false,
181            unicode: true,
182            ..Default::default()
183        };
184        assert_eq!(caps.recommended_mode(), TerminalMode::Unicode);
185    }
186
187    #[test]
188    fn test_recommended_mode_ascii_fallback() {
189        let caps = TerminalCapabilities {
190            is_tty: true,
191            true_color: false,
192            unicode: false,
193            ansi_color: false,
194            ..Default::default()
195        };
196        assert_eq!(caps.recommended_mode(), TerminalMode::Ascii);
197    }
198
199    #[test]
200    fn test_detect_returns_valid_capabilities() {
201        // Note: actual values depend on environment
202        let caps = TerminalCapabilities::detect();
203        assert!(caps.width > 0);
204        assert!(caps.height > 0);
205    }
206
207    #[test]
208    fn test_get_size_returns_valid_size() {
209        let (width, height) = TerminalCapabilities::get_size();
210        assert!(width > 0);
211        assert!(height > 0);
212    }
213}