tsk/context/
terminal.rs

1use std::io::{self, Write};
2use std::sync::Mutex;
3
4/// Trait for terminal operations
5pub trait TerminalOperations: Send + Sync {
6    /// Set the terminal window title
7    fn set_title(&self, title: &str);
8
9    /// Restore the original terminal title
10    fn restore_title(&self);
11}
12
13/// Default terminal operations implementation
14pub struct DefaultTerminalOperations {
15    state: Mutex<TerminalState>,
16}
17
18struct TerminalState {
19    supported: bool,
20    original_title: Option<String>,
21}
22
23impl DefaultTerminalOperations {
24    /// Create a new terminal operations instance
25    pub fn new() -> Self {
26        let supported = Self::is_supported();
27
28        Self {
29            state: Mutex::new(TerminalState {
30                supported,
31                original_title: None,
32            }),
33        }
34    }
35
36    /// Check if terminal title updates are supported
37    fn is_supported() -> bool {
38        // Check if we're in a TTY
39        if !atty::is(atty::Stream::Stdout) {
40            return false;
41        }
42
43        // Check for common terminal environment variables
44        if let Ok(term) = std::env::var("TERM") {
45            // Most modern terminals support title changes
46            // Exclude known non-supporting terminals
47            !matches!(term.as_str(), "dumb" | "unknown")
48        } else {
49            false
50        }
51    }
52
53    /// Write the terminal title using OSC sequences
54    fn write_title(title: &str) {
55        // Use OSC (Operating System Command) sequences
56        // OSC 0 sets both window and icon title
57        // Format: ESC]0;title BEL
58        let _ = write!(io::stdout(), "\x1b]0;{title}\x07");
59        let _ = io::stdout().flush();
60    }
61}
62
63impl TerminalOperations for DefaultTerminalOperations {
64    fn set_title(&self, title: &str) {
65        let mut state = self.state.lock().unwrap();
66
67        if !state.supported {
68            return;
69        }
70
71        // Store the original title on first use
72        if state.original_title.is_none() {
73            // Note: There's no portable way to get the current title
74            // We'll just store a default to restore
75            state.original_title = Some("Terminal".to_string());
76        }
77
78        Self::write_title(title);
79    }
80
81    fn restore_title(&self) {
82        let state = self.state.lock().unwrap();
83
84        if !state.supported {
85            return;
86        }
87
88        if let Some(ref original) = state.original_title {
89            Self::write_title(original);
90        }
91    }
92}
93
94impl Drop for DefaultTerminalOperations {
95    fn drop(&mut self) {
96        // Restore title when the object is dropped
97        self.restore_title();
98    }
99}
100
101impl Default for DefaultTerminalOperations {
102    fn default() -> Self {
103        Self::new()
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    #[test]
112    fn test_terminal_operations_creation() {
113        let terminal_ops = DefaultTerminalOperations::new();
114        // In test environment, it might not be supported
115        // Just ensure it doesn't panic
116        let state = terminal_ops.state.lock().unwrap();
117        assert!(state.original_title.is_none());
118    }
119
120    #[test]
121    fn test_set_title_no_panic() {
122        let terminal_ops = DefaultTerminalOperations::new();
123        // Should not panic even if not supported
124        terminal_ops.set_title("Test Title");
125        terminal_ops.restore_title();
126    }
127
128    #[test]
129    fn test_drop_restores_title() {
130        {
131            let terminal_ops = DefaultTerminalOperations::new();
132            terminal_ops.set_title("Temporary Title");
133            // Terminal title should be restored when going out of scope
134        }
135        // Title should have been restored by Drop implementation
136    }
137
138    #[test]
139    fn test_terminal_support_detection() {
140        // Test with TERM environment variable
141        unsafe {
142            std::env::set_var("TERM", "xterm-256color");
143        }
144        assert!(DefaultTerminalOperations::is_supported() || !atty::is(atty::Stream::Stdout));
145
146        unsafe {
147            std::env::set_var("TERM", "dumb");
148        }
149        assert!(!DefaultTerminalOperations::is_supported());
150
151        unsafe {
152            std::env::remove_var("TERM");
153        }
154        assert!(!DefaultTerminalOperations::is_supported());
155    }
156
157    #[test]
158    fn test_concurrent_access() {
159        use std::sync::Arc;
160        use std::thread;
161
162        let terminal_ops = Arc::new(DefaultTerminalOperations::new());
163        let mut handles = vec![];
164
165        for i in 0..5 {
166            let ops = Arc::clone(&terminal_ops);
167            let handle = thread::spawn(move || {
168                ops.set_title(&format!("Thread {i}"));
169                ops.restore_title();
170            });
171            handles.push(handle);
172        }
173
174        for handle in handles {
175            handle.join().unwrap();
176        }
177    }
178}