Skip to main content

mermaid_cli/app/
terminal.rs

1//! Terminal setup and teardown.
2//!
3//! Raw mode, alternate screen, bracketed paste, mouse capture, and
4//! panic-hook restoration — all entered and exited through
5//! `TerminalGuard`.
6//!
7//! The `TerminalGuard` type is the important piece: putting teardown
8//! inside a `Drop` impl means a panic in the render loop still
9//! restores the user's shell, no matter where it happens.
10
11use std::io::{self, Stdout, Write};
12use std::sync::atomic::{AtomicBool, Ordering};
13
14use anyhow::{Context, Result};
15use crossterm::cursor::Show;
16use crossterm::event::{
17    DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
18};
19use crossterm::execute;
20use crossterm::terminal::{
21    EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
22};
23use ratatui::Terminal;
24use ratatui::backend::CrosstermBackend;
25
26static TERMINAL_NEEDS_RESTORE: AtomicBool = AtomicBool::new(false);
27
28/// Owned terminal that restores the shell on drop.
29///
30/// Construct once at the top of `app::run`; keep it alive for the
31/// duration of the main loop; let it drop. Do not construct twice
32/// (the second `enable_raw_mode()` is idempotent but the second
33/// `EnterAlternateScreen` stacks).
34pub struct TerminalGuard {
35    inner: Terminal<CrosstermBackend<Stdout>>,
36    restored: bool,
37}
38
39impl TerminalGuard {
40    pub fn setup() -> Result<Self> {
41        enable_raw_mode().context("failed to enable raw mode")?;
42        TERMINAL_NEEDS_RESTORE.store(true, Ordering::SeqCst);
43        let mut stdout = io::stdout();
44        if let Err(error) = execute!(
45            stdout,
46            EnterAlternateScreen,
47            EnableMouseCapture,
48            EnableBracketedPaste,
49        ) {
50            restore_terminal_once();
51            return Err(error).context(
52                "failed to enter alternate screen / enable mouse / enable bracketed paste",
53            );
54        }
55
56        let backend = CrosstermBackend::new(stdout);
57        let terminal = match Terminal::new(backend).context("failed to create terminal") {
58            Ok(terminal) => terminal,
59            Err(error) => {
60                restore_terminal_once();
61                return Err(error);
62            },
63        };
64
65        install_panic_hook();
66
67        Ok(Self {
68            inner: terminal,
69            restored: false,
70        })
71    }
72
73    /// Mutable access for the render pass.
74    pub fn inner_mut(&mut self) -> &mut Terminal<CrosstermBackend<Stdout>> {
75        &mut self.inner
76    }
77
78    /// Restore terminal state now. Idempotent so normal exit, signal
79    /// exit, and Drop can all share the same call site safely.
80    pub fn restore_now(&mut self) {
81        if self.restored {
82            return;
83        }
84        restore_terminal_once();
85        let _ = self.inner.show_cursor();
86        self.restored = true;
87    }
88}
89
90impl Drop for TerminalGuard {
91    fn drop(&mut self) {
92        self.restore_now();
93    }
94}
95
96/// Best-effort terminal restore used by both normal Drop and panic
97/// handling. The explicit escape fallback turns off every mouse mode
98/// commonly emitted by terminals that support SGR mouse reporting;
99/// this protects users when crossterm's higher-level cleanup does not
100/// fully unwind a prior partial setup or a killed process left modes
101/// dirty.
102fn restore_terminal() {
103    let mut stdout = io::stdout();
104    let _ = execute!(
105        stdout,
106        DisableMouseCapture,
107        DisableBracketedPaste,
108        LeaveAlternateScreen,
109        Show,
110    );
111    let _ = stdout.write_all(
112        b"\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1005l\x1b[?1006l\x1b[?1015l\x1b[?2004l\x1b[?1049l\x1b[?25h\x1b[0m",
113    );
114    let _ = stdout.flush();
115    let _ = disable_raw_mode();
116}
117
118fn restore_terminal_once() {
119    if !TERMINAL_NEEDS_RESTORE.swap(false, Ordering::SeqCst) {
120        return;
121    }
122    restore_terminal();
123}
124
125/// Install a panic hook that restores the terminal before propagating
126/// the panic. Without this, a panic mid-render leaves the user in raw
127/// mode with the alternate screen still active — a shell unusable
128/// until they type `reset` blind.
129fn install_panic_hook() {
130    static HOOK_INSTALLED: AtomicBool = AtomicBool::new(false);
131    if HOOK_INSTALLED
132        .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
133        .is_err()
134    {
135        return;
136    }
137
138    let original = std::panic::take_hook();
139    std::panic::set_hook(Box::new(move |info| {
140        restore_terminal_once();
141        original(info);
142    }));
143}
144
145/// Public best-effort restore helper for tests and external cleanup
146/// paths that do not own a `TerminalGuard`.
147pub fn force_restore_terminal() {
148    restore_terminal();
149}