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}