Skip to main content

purple_ssh/
tui.rs

1use std::io::{self, Stdout, stdout};
2use std::sync::Once;
3
4use anyhow::Result;
5use crossterm::{
6    ExecutableCommand, QueueableCommand,
7    cursor::MoveTo,
8    style::ResetColor,
9    terminal::{
10        Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode,
11        enable_raw_mode,
12    },
13};
14use ratatui::{Terminal, prelude::CrosstermBackend};
15
16use log::debug;
17
18use crate::app::App;
19use crate::ui;
20
21static PANIC_HOOK: Once = Once::new();
22
23pub struct Tui {
24    terminal: Terminal<CrosstermBackend<Stdout>>,
25}
26
27impl Tui {
28    pub fn new() -> Result<Self> {
29        let backend = CrosstermBackend::new(stdout());
30        let terminal = Terminal::new(backend)?;
31        Ok(Self { terminal })
32    }
33
34    /// Enter TUI mode: panic hook (installed once), raw mode, alternate screen.
35    pub fn enter(&mut self) -> Result<()> {
36        // Install panic hook BEFORE enabling raw mode to ensure cleanup on panic
37        PANIC_HOOK.call_once(|| {
38            let original_hook = std::panic::take_hook();
39            std::panic::set_hook(Box::new(move |panic_info| {
40                let _ = Self::reset();
41                original_hook(panic_info);
42            }));
43        });
44
45        enable_raw_mode()?;
46        if let Err(e) = io::stdout().execute(EnterAlternateScreen) {
47            disable_raw_mode()?;
48            return Err(e.into());
49        }
50
51        if let Err(e) = self.terminal.hide_cursor() {
52            let _ = Self::reset();
53            return Err(e.into());
54        }
55        if let Err(e) = self.terminal.clear() {
56            let _ = Self::reset();
57            return Err(e.into());
58        }
59        Ok(())
60    }
61
62    /// Exit TUI mode: restore terminal.
63    pub fn exit(&mut self) -> Result<()> {
64        Self::reset()?;
65        self.terminal.show_cursor()?;
66        Ok(())
67    }
68
69    /// Exit TUI mode and hand the next process a blank screen.
70    /// Terminals without alternate-screen support keep the TUI painted
71    /// on the main buffer, so without this clear a spawned ssh would
72    /// draw on top of the old frame.
73    pub fn suspend(&mut self) -> Result<()> {
74        self.exit()?;
75        queue_clear_home(&mut io::stdout())?;
76        Ok(())
77    }
78
79    /// Reset terminal to normal mode.
80    fn reset() -> Result<()> {
81        disable_raw_mode()?;
82        io::stdout().execute(LeaveAlternateScreen)?;
83        Ok(())
84    }
85
86    /// Draw the UI.
87    pub fn draw(
88        &mut self,
89        app: &mut App,
90        anim: &mut crate::animation::AnimationState,
91    ) -> Result<()> {
92        self.terminal.draw(|frame| ui::render(frame, app, anim))?;
93        Ok(())
94    }
95
96    /// Force a full redraw on the next draw() call.
97    /// Use after external processes may have written to the terminal.
98    pub fn force_redraw(&mut self) {
99        if let Err(e) = self.terminal.clear() {
100            debug!("[purple] Failed to clear terminal: {e}");
101        }
102    }
103}
104
105impl Drop for Tui {
106    fn drop(&mut self) {
107        let _ = Self::reset();
108        let _ = self.terminal.show_cursor();
109    }
110}
111
112/// Queue a color reset, a full-screen clear and a cursor home into `w`.
113/// The reset comes first because the clear fills the screen with the
114/// active background color. Split out from `suspend` so tests can
115/// assert the byte sequence.
116fn queue_clear_home<W: io::Write>(w: &mut W) -> io::Result<()> {
117    w.queue(ResetColor)?;
118    w.queue(Clear(ClearType::All))?;
119    w.queue(MoveTo(0, 0))?;
120    w.flush()
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn queue_clear_home_emits_reset_clear_then_home() {
129        let mut buf: Vec<u8> = Vec::new();
130        queue_clear_home(&mut buf).unwrap();
131        let s = String::from_utf8(buf).unwrap();
132        let reset_pos = s.find("\x1b[0m").expect("color-reset sequence present");
133        let clear_pos = s.find("\x1b[2J").expect("clear-all sequence present");
134        let home_pos = s.find("\x1b[1;1H").expect("cursor-home sequence present");
135        assert!(reset_pos < clear_pos, "color reset must run before clear");
136        assert!(clear_pos < home_pos, "clear must run before cursor home");
137    }
138}