Skip to main content

imp_tui/
terminal.rs

1use std::io::{self, Write};
2
3use crossterm::cursor::Show;
4use crossterm::event::{
5    DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
6};
7use crossterm::terminal::{
8    disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
9};
10use ratatui::backend::CrosstermBackend;
11use ratatui::Terminal;
12
13pub type InteractiveTerminal = Terminal<CrosstermBackend<io::Stdout>>;
14
15pub fn set_window_title(title: &str) -> io::Result<()> {
16    let mut stdout = io::stdout();
17    write!(stdout, "\x1b]0;{title}\x07")?;
18    stdout.flush()?;
19    Ok(())
20}
21
22pub fn ring_terminal_bell() -> io::Result<()> {
23    #[cfg(test)]
24    {
25        Ok(())
26    }
27
28    #[cfg(not(test))]
29    {
30        let mut stdout = io::stdout();
31        write!(stdout, "\x07")?;
32        stdout.flush()?;
33        Ok(())
34    }
35}
36
37fn restore_terminal<W: Write>(writer: &mut W) -> io::Result<()> {
38    let _ = disable_raw_mode();
39    write!(writer, "\x1b[0m")?;
40    #[cfg(unix)]
41    crossterm::execute!(
42        writer,
43        Show,
44        LeaveAlternateScreen,
45        DisableMouseCapture,
46        DisableBracketedPaste
47    )?;
48    #[cfg(not(unix))]
49    crossterm::execute!(writer, Show, LeaveAlternateScreen, DisableMouseCapture)?;
50    writer.flush()?;
51    Ok(())
52}
53
54fn restore_terminal_if_needed<W: Write>(writer: &mut W, restored: &mut bool) -> io::Result<()> {
55    if *restored {
56        Ok(())
57    } else {
58        *restored = true;
59        restore_terminal(writer)
60    }
61}
62
63pub struct TerminalSession {
64    terminal: InteractiveTerminal,
65    last_title: Option<String>,
66    restored: bool,
67}
68
69impl TerminalSession {
70    pub fn enter() -> io::Result<Self> {
71        enable_raw_mode()?;
72        let mut stdout = io::stdout();
73        #[cfg(unix)]
74        crossterm::execute!(
75            stdout,
76            EnterAlternateScreen,
77            EnableMouseCapture,
78            EnableBracketedPaste
79        )?;
80        #[cfg(not(unix))]
81        crossterm::execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
82        let backend = CrosstermBackend::new(stdout);
83        let terminal = Terminal::new(backend)?;
84        Ok(Self {
85            terminal,
86            last_title: None,
87            restored: false,
88        })
89    }
90
91    pub fn terminal_mut(&mut self) -> &mut InteractiveTerminal {
92        &mut self.terminal
93    }
94
95    pub fn restore(&mut self) -> io::Result<()> {
96        restore_terminal_if_needed(self.terminal.backend_mut(), &mut self.restored)
97    }
98
99    pub fn set_window_title(&mut self, title: &str) -> io::Result<()> {
100        if self.last_title.as_deref() == Some(title) {
101            return Ok(());
102        }
103
104        let mut stdout = io::stdout();
105        write!(stdout, "\x1b]0;{title}\x07")?;
106        stdout.flush()?;
107        self.last_title = Some(title.to_string());
108        Ok(())
109    }
110}
111
112impl Drop for TerminalSession {
113    fn drop(&mut self) {
114        let _ = self.restore();
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    #[test]
123    fn restore_terminal_writes_reset_and_exit_sequences() {
124        let mut output = Vec::new();
125        restore_terminal(&mut output).unwrap();
126        let text = String::from_utf8(output).unwrap();
127
128        assert!(text.contains("\u{1b}[0m"));
129        assert!(text.contains("\u{1b}[?25h"));
130        assert!(text.contains("\u{1b}[?1049l"));
131    }
132
133    #[test]
134    fn restore_terminal_if_needed_is_idempotent() {
135        let mut output = Vec::new();
136        let mut restored = false;
137
138        restore_terminal_if_needed(&mut output, &mut restored).unwrap();
139        let first = output.clone();
140        restore_terminal_if_needed(&mut output, &mut restored).unwrap();
141
142        assert!(restored);
143        assert_eq!(output, first);
144    }
145}