Skip to main content

vtcode_tui/core_tui/
alternate_screen.rs

1use std::io::{self, Write};
2
3use crate::utils::tty::TtyExt;
4use anyhow::{Context, Result};
5use ratatui::crossterm::{
6    cursor::MoveToColumn,
7    event::{DisableBracketedPaste, DisableFocusChange, EnableBracketedPaste, EnableFocusChange},
8    execute,
9    terminal::{
10        self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode,
11        enable_raw_mode,
12    },
13};
14
15/// Terminal state that needs to be preserved when entering alternate screen
16#[derive(Debug)]
17struct TerminalState {
18    raw_mode_enabled: bool,
19    bracketed_paste_enabled: bool,
20    focus_change_enabled: bool,
21}
22
23/// Manages entering and exiting alternate screen with proper state preservation
24///
25/// This struct ensures that terminal state is properly saved before entering
26/// alternate screen and restored when exiting, even in the presence of errors.
27///
28/// # Example
29///
30/// ```no_run
31/// # fn main() -> anyhow::Result<()> {
32/// use vtcode_tui::core_tui::alternate_screen::AlternateScreenSession;
33///
34/// // Run a closure in alternate screen with automatic cleanup
35/// let result = AlternateScreenSession::run(|| {
36///     // Your code that runs in alternate screen
37///     println!("Running in alternate screen!");
38///     Ok(())
39/// })?;
40/// # Ok(())
41/// # }
42/// ```
43pub struct AlternateScreenSession {
44    /// Terminal state before entering alternate screen
45    original_state: TerminalState,
46    /// Whether we successfully entered alternate screen
47    entered: bool,
48}
49
50impl AlternateScreenSession {
51    /// Enter alternate screen, saving current terminal state
52    ///
53    /// This will:
54    /// 1. Save the current terminal state
55    /// 2. Enter alternate screen
56    /// 3. Enable raw mode
57    /// 4. Enable bracketed paste
58    /// 5. Enable focus change events (if supported)
59    /// 6. Push keyboard enhancement flags (if supported)
60    ///
61    /// # Errors
62    ///
63    /// Returns an error if any terminal operation fails.
64    pub fn enter() -> Result<Self> {
65        let mut stdout = io::stdout();
66
67        // Check if stdout is a TTY before proceeding
68        let is_tty = stdout.is_tty_ext();
69        if !is_tty {
70            tracing::warn!("stdout is not a TTY, alternate screen features may not work");
71        }
72
73        // Save current state
74        let original_state = TerminalState {
75            raw_mode_enabled: false, // We'll enable it fresh
76            bracketed_paste_enabled: false,
77            focus_change_enabled: false,
78        };
79
80        // Enter alternate screen first
81        execute!(stdout, EnterAlternateScreen)
82            .context("failed to enter alternate screen for terminal app")?;
83
84        let mut session = Self {
85            original_state,
86            entered: true,
87        };
88
89        // Enable raw mode
90        enable_raw_mode().context("failed to enable raw mode for terminal app")?;
91        session.original_state.raw_mode_enabled = true;
92
93        // Enable bracketed paste (only if TTY)
94        if is_tty && execute!(stdout, EnableBracketedPaste).is_ok() {
95            session.original_state.bracketed_paste_enabled = true;
96        }
97
98        // Enable focus change events (only if TTY)
99        if is_tty && execute!(stdout, EnableFocusChange).is_ok() {
100            session.original_state.focus_change_enabled = true;
101        }
102
103        Ok(session)
104    }
105
106    /// Exit alternate screen, restoring original terminal state
107    ///
108    /// This will:
109    /// 1. Pop keyboard enhancement flags (if they were pushed)
110    /// 2. Disable focus change events (if they were enabled)
111    /// 3. Disable bracketed paste (if it was enabled)
112    /// 4. Disable raw mode (if it was enabled)
113    /// 5. Leave alternate screen
114    ///
115    /// # Errors
116    ///
117    /// Returns an error if any terminal operation fails. However, this method
118    /// will attempt to restore as much state as possible even if some operations fail.
119    pub fn exit(mut self) -> Result<()> {
120        self.restore_state()?;
121        self.entered = false; // Prevent Drop from trying again
122        Ok(())
123    }
124
125    /// Run a closure in alternate screen with automatic cleanup
126    ///
127    /// This is a convenience method that handles entering and exiting alternate
128    /// screen automatically, ensuring cleanup happens even if the closure panics.
129    ///
130    /// # Errors
131    ///
132    /// Returns an error if entering/exiting alternate screen fails, or if the
133    /// closure returns an error.
134    pub fn run<F, T>(f: F) -> Result<T>
135    where
136        F: FnOnce() -> Result<T>,
137    {
138        let session = Self::enter()?;
139        let result = f();
140        session.exit()?;
141        result
142    }
143
144    /// Internal method to restore terminal state
145    fn restore_state(&mut self) -> Result<()> {
146        if !self.entered {
147            return Ok(());
148        }
149
150        // Drain any pending crossterm events BEFORE leaving alternate screen and disabling raw mode
151        // to prevent them from leaking to the shell.
152        while let Ok(true) = crossterm::event::poll(std::time::Duration::from_millis(0)) {
153            let _ = crossterm::event::read();
154        }
155
156        let mut stdout = io::stdout();
157
158        // Clear current line to remove artifacts like ^C from rapid presses
159        let _ = execute!(stdout, MoveToColumn(0), Clear(ClearType::CurrentLine));
160
161        let mut errors = Vec::new();
162
163        // Restore in proper order to prevent leakage
164
165        // 1. Leave alternate screen FIRST
166        if let Err(e) = execute!(stdout, LeaveAlternateScreen) {
167            tracing::warn!(%e, "failed to leave alternate screen");
168            errors.push(format!("leave alternate screen: {}", e));
169        }
170
171        // 2. Disable focus change (if enabled and TTY)
172        if self.original_state.focus_change_enabled
173            && let Err(e) = execute!(stdout, DisableFocusChange)
174        {
175            tracing::warn!(%e, "failed to disable focus change");
176            errors.push(format!("disable focus change: {}", e));
177        }
178
179        // 3. Disable bracketed paste (if enabled and TTY)
180        if self.original_state.bracketed_paste_enabled
181            && let Err(e) = execute!(stdout, DisableBracketedPaste)
182        {
183            tracing::warn!(%e, "failed to disable bracketed paste");
184            errors.push(format!("disable bracketed paste: {}", e));
185        }
186
187        // 4. Disable raw mode LAST
188        if self.original_state.raw_mode_enabled
189            && let Err(e) = disable_raw_mode()
190        {
191            tracing::warn!(%e, "failed to disable raw mode");
192            errors.push(format!("disable raw mode: {}", e));
193        }
194
195        // Flush to ensure all changes are applied
196        if let Err(e) = stdout.flush() {
197            tracing::warn!(%e, "failed to flush stdout");
198            errors.push(format!("flush stdout: {}", e));
199        }
200
201        if errors.is_empty() {
202            Ok(())
203        } else {
204            tracing::warn!(
205                errors = ?errors,
206                "some terminal operations failed during restore"
207            );
208            // Don't fail the operation, just warn - terminal is likely already in a bad state
209            Ok(())
210        }
211    }
212}
213
214impl Drop for AlternateScreenSession {
215    fn drop(&mut self) {
216        if self.entered {
217            // Best effort cleanup - ignore errors in Drop
218            let _ = self.restore_state();
219        }
220    }
221}
222
223/// Clear the alternate screen
224///
225/// This is useful when you want to clear the screen before running a terminal app.
226pub fn clear_screen() -> Result<()> {
227    execute!(io::stdout(), Clear(ClearType::All)).context("failed to clear alternate screen")
228}
229
230/// Get current terminal size
231pub fn terminal_size() -> Result<(u16, u16)> {
232    terminal::size().context("failed to get terminal size")
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    #[test]
240    fn test_enter_exit_cycle() {
241        if !io::stdout().is_tty_ext() {
242            return;
243        }
244
245        // This test verifies that we can enter and exit alternate screen
246        // without panicking. We can't easily verify the actual terminal state
247        // in a unit test, but we can at least ensure the code doesn't crash.
248        let session = AlternateScreenSession::enter();
249        assert!(session.is_ok());
250
251        if let Ok(session) = session {
252            let result = session.exit();
253            assert!(result.is_ok());
254        }
255    }
256
257    #[test]
258    fn test_run_with_closure() {
259        if !io::stdout().is_tty_ext() {
260            return;
261        }
262
263        let result = AlternateScreenSession::run(|| {
264            // Simulate some work in alternate screen
265            Ok(42)
266        });
267
268        assert!(result.is_ok());
269        assert_eq!(result.unwrap(), 42);
270    }
271
272    #[test]
273    fn test_run_with_error() {
274        let result: Result<()> = AlternateScreenSession::run(|| Err(anyhow::anyhow!("test error")));
275
276        assert!(result.is_err());
277    }
278
279    #[test]
280    fn test_drop_cleanup() {
281        // Verify that Drop properly cleans up
282        {
283            let _session = AlternateScreenSession::enter();
284            // Session dropped here
285        }
286        // If we get here without hanging, Drop worked
287    }
288}