hojicha_runtime/program/
terminal_manager.rs

1//! Terminal management logic extracted from Program for testability
2
3use crate::program::MouseMode;
4use crossterm::{
5    execute,
6    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
7};
8use ratatui::{backend::CrosstermBackend, Terminal};
9use std::io::{self, Stdout};
10
11/// Configuration for terminal setup
12#[derive(Debug, Clone)]
13pub struct TerminalConfig {
14    /// Whether to use alternate screen buffer
15    pub alt_screen: bool,
16    /// Mouse input mode configuration
17    pub mouse_mode: MouseMode,
18    /// Whether to enable bracketed paste mode
19    pub bracketed_paste: bool,
20    /// Whether to enable terminal focus reporting
21    pub focus_reporting: bool,
22    /// Whether to run in headless mode (no terminal setup)
23    pub headless: bool,
24}
25
26impl Default for TerminalConfig {
27    fn default() -> Self {
28        Self {
29            alt_screen: true,
30            mouse_mode: MouseMode::None,
31            bracketed_paste: false,
32            focus_reporting: false,
33            headless: false,
34        }
35    }
36}
37
38/// Manages terminal setup, teardown, and state
39pub struct TerminalManager {
40    terminal: Option<Terminal<CrosstermBackend<Stdout>>>,
41    config: TerminalConfig,
42    alt_screen_was_active: bool,
43    is_released: bool,
44}
45
46impl TerminalManager {
47    /// Create a new terminal manager
48    pub fn new(config: TerminalConfig) -> io::Result<Self> {
49        let terminal = if !config.headless {
50            Some(Self::setup_terminal(&config)?)
51        } else {
52            None
53        };
54
55        Ok(Self {
56            terminal,
57            config,
58            alt_screen_was_active: false,
59            is_released: false,
60        })
61    }
62
63    /// Set up the terminal with the given configuration
64    fn setup_terminal(config: &TerminalConfig) -> io::Result<Terminal<CrosstermBackend<Stdout>>> {
65        let mut stdout = io::stdout();
66
67        // Enable raw mode
68        enable_raw_mode()?;
69
70        // Enter alternate screen if requested
71        if config.alt_screen {
72            execute!(stdout, EnterAlternateScreen)?;
73        }
74
75        // Set up mouse mode
76        match config.mouse_mode {
77            MouseMode::CellMotion => {
78                execute!(stdout, crossterm::event::EnableMouseCapture)?;
79            }
80            MouseMode::AllMotion => {
81                execute!(
82                    stdout,
83                    crossterm::event::EnableMouseCapture,
84                    crossterm::cursor::Show,
85                    crossterm::cursor::Hide,
86                )?;
87            }
88            MouseMode::None => {}
89        }
90
91        // Enable bracketed paste if requested
92        if config.bracketed_paste {
93            execute!(stdout, crossterm::event::EnableBracketedPaste)?;
94        }
95
96        // Enable focus reporting if requested
97        if config.focus_reporting {
98            execute!(stdout, crossterm::event::EnableFocusChange)?;
99        }
100
101        let backend = CrosstermBackend::new(stdout);
102        let mut terminal = Terminal::new(backend)?;
103        terminal.hide_cursor()?;
104
105        Ok(terminal)
106    }
107
108    /// Get a reference to the terminal
109    pub fn terminal(&self) -> Option<&Terminal<CrosstermBackend<Stdout>>> {
110        self.terminal.as_ref()
111    }
112
113    /// Get a mutable reference to the terminal
114    pub fn terminal_mut(&mut self) -> Option<&mut Terminal<CrosstermBackend<Stdout>>> {
115        self.terminal.as_mut()
116    }
117
118    /// Release the terminal (for running external commands)
119    pub fn release(&mut self) -> io::Result<()> {
120        if self.is_released || self.config.headless {
121            return Ok(());
122        }
123
124        // Store alt screen state
125        self.alt_screen_was_active = self.config.alt_screen;
126
127        // Show cursor before releasing
128        if let Some(ref mut terminal) = self.terminal {
129            terminal.show_cursor()?;
130        }
131
132        // Exit alt screen if active
133        if self.config.alt_screen {
134            execute!(io::stdout(), LeaveAlternateScreen)?;
135        }
136
137        // Disable raw mode
138        disable_raw_mode()?;
139
140        self.is_released = true;
141        Ok(())
142    }
143
144    /// Restore the terminal after releasing it
145    pub fn restore(&mut self) -> io::Result<()> {
146        if !self.is_released || self.config.headless {
147            return Ok(());
148        }
149
150        // Re-enable raw mode
151        enable_raw_mode()?;
152
153        // Restore alt screen if it was active
154        if self.alt_screen_was_active {
155            execute!(io::stdout(), EnterAlternateScreen)?;
156        }
157
158        // Hide cursor again
159        if let Some(ref mut terminal) = self.terminal {
160            terminal.hide_cursor()?;
161            // Force a redraw
162            terminal.clear()?;
163        }
164
165        self.is_released = false;
166        Ok(())
167    }
168
169    /// Check if the terminal is currently released
170    pub fn is_released(&self) -> bool {
171        self.is_released
172    }
173
174    /// Clean up terminal state
175    pub fn cleanup(&mut self) -> io::Result<()> {
176        if self.config.headless {
177            return Ok(());
178        }
179
180        // Show cursor
181        if let Some(ref mut terminal) = self.terminal {
182            let _ = terminal.show_cursor();
183        }
184
185        // Disable various terminal features
186        let mut stdout = io::stdout();
187
188        if self.config.focus_reporting {
189            let _ = execute!(stdout, crossterm::event::DisableFocusChange);
190        }
191
192        if self.config.bracketed_paste {
193            let _ = execute!(stdout, crossterm::event::DisableBracketedPaste);
194        }
195
196        if self.config.mouse_mode != MouseMode::None {
197            let _ = execute!(stdout, crossterm::event::DisableMouseCapture);
198        }
199
200        if self.config.alt_screen && !self.is_released {
201            let _ = execute!(stdout, LeaveAlternateScreen);
202        }
203
204        // Always try to disable raw mode
205        let _ = disable_raw_mode();
206
207        Ok(())
208    }
209
210    /// Draw a frame (wrapper for terminal.draw)
211    pub fn draw<F>(&mut self, f: F) -> io::Result<()>
212    where
213        F: FnOnce(&mut ratatui::Frame),
214    {
215        if let Some(ref mut terminal) = self.terminal {
216            terminal.draw(f)?;
217        }
218        Ok(())
219    }
220
221    /// Get the current terminal size
222    pub fn size(&self) -> io::Result<ratatui::layout::Rect> {
223        if let Some(ref terminal) = self.terminal {
224            let size = terminal.size()?;
225            Ok(ratatui::layout::Rect::new(0, 0, size.width, size.height))
226        } else {
227            // Return a default size for headless mode
228            Ok(ratatui::layout::Rect::new(0, 0, 80, 24))
229        }
230    }
231
232    /// Clear the terminal
233    pub fn clear(&mut self) -> io::Result<()> {
234        if let Some(ref mut terminal) = self.terminal {
235            terminal.clear()?;
236        }
237        Ok(())
238    }
239}
240
241impl Drop for TerminalManager {
242    fn drop(&mut self) {
243        let _ = self.cleanup();
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    #[test]
252    fn test_terminal_config_default() {
253        let config = TerminalConfig::default();
254        assert!(config.alt_screen);
255        assert_eq!(config.mouse_mode, MouseMode::None);
256        assert!(!config.bracketed_paste);
257        assert!(!config.focus_reporting);
258        assert!(!config.headless);
259    }
260
261    #[test]
262    fn test_terminal_manager_headless() {
263        let config = TerminalConfig {
264            headless: true,
265            ..Default::default()
266        };
267
268        let manager = TerminalManager::new(config).unwrap();
269        assert!(manager.terminal().is_none());
270        assert!(!manager.is_released());
271    }
272
273    #[test]
274    fn test_terminal_manager_release_restore() {
275        let config = TerminalConfig {
276            headless: true,
277            ..Default::default()
278        };
279
280        let mut manager = TerminalManager::new(config).unwrap();
281
282        // In headless mode, release should succeed but not change state
283        assert!(manager.release().is_ok());
284        assert!(!manager.is_released()); // Headless mode doesn't actually release
285
286        // Restore should also succeed without changing state
287        assert!(manager.restore().is_ok());
288        assert!(!manager.is_released());
289    }
290
291    #[test]
292    fn test_terminal_manager_size_headless() {
293        let config = TerminalConfig {
294            headless: true,
295            ..Default::default()
296        };
297
298        let manager = TerminalManager::new(config).unwrap();
299        let size = manager.size().unwrap();
300
301        // Should return default size in headless mode
302        assert_eq!(size.width, 80);
303        assert_eq!(size.height, 24);
304    }
305
306    #[test]
307    fn test_terminal_manager_clear_headless() {
308        let config = TerminalConfig {
309            headless: true,
310            ..Default::default()
311        };
312
313        let mut manager = TerminalManager::new(config).unwrap();
314
315        // Clear should not panic in headless mode
316        assert!(manager.clear().is_ok());
317    }
318
319    #[test]
320    fn test_terminal_manager_draw_headless() {
321        let config = TerminalConfig {
322            headless: true,
323            ..Default::default()
324        };
325
326        let mut manager = TerminalManager::new(config).unwrap();
327
328        // Draw should not panic in headless mode
329        assert!(manager
330            .draw(|_f| {
331                // Drawing logic would go here
332            })
333            .is_ok());
334    }
335
336    #[test]
337    fn test_terminal_manager_cleanup() {
338        let config = TerminalConfig {
339            headless: true,
340            ..Default::default()
341        };
342
343        let mut manager = TerminalManager::new(config).unwrap();
344
345        // Cleanup should not panic
346        assert!(manager.cleanup().is_ok());
347    }
348
349    #[test]
350    fn test_terminal_manager_drop() {
351        let config = TerminalConfig {
352            headless: true,
353            ..Default::default()
354        };
355
356        {
357            let _manager = TerminalManager::new(config).unwrap();
358            // Manager should clean up when dropped
359        }
360        // Should not panic
361    }
362
363    #[test]
364    fn test_terminal_config_variations() {
365        let configs = vec![
366            TerminalConfig {
367                alt_screen: false,
368                mouse_mode: MouseMode::CellMotion,
369                bracketed_paste: true,
370                focus_reporting: true,
371                headless: true,
372            },
373            TerminalConfig {
374                alt_screen: true,
375                mouse_mode: MouseMode::AllMotion,
376                bracketed_paste: false,
377                focus_reporting: false,
378                headless: true,
379            },
380        ];
381
382        for config in configs {
383            let manager = TerminalManager::new(config).unwrap();
384            assert!(manager.terminal().is_none()); // All are headless
385        }
386    }
387}