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, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste,
18    EnableFocusChange, EnableMouseCapture, KeyboardEnhancementFlags, PopKeyboardEnhancementFlags,
19    PushKeyboardEnhancementFlags,
20};
21use crossterm::execute;
22use crossterm::terminal::{
23    EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
24    supports_keyboard_enhancement,
25};
26use ratatui::Terminal;
27use ratatui::backend::CrosstermBackend;
28
29static TERMINAL_NEEDS_RESTORE: AtomicBool = AtomicBool::new(false);
30
31/// Whether the kitty keyboard-enhancement flags were pushed at setup — they
32/// stack per screen buffer, so `restore_terminal` must pop them (before
33/// leaving the alternate screen) exactly when they were pushed.
34static KEYBOARD_ENHANCED: AtomicBool = AtomicBool::new(false);
35
36/// Owned terminal that restores the shell on drop.
37///
38/// Construct once at the top of `app::run`; keep it alive for the
39/// duration of the main loop; let it drop. Do not construct twice
40/// (the second `enable_raw_mode()` is idempotent but the second
41/// `EnterAlternateScreen` stacks).
42pub struct TerminalGuard {
43    inner: Terminal<CrosstermBackend<Stdout>>,
44    restored: bool,
45}
46
47impl TerminalGuard {
48    pub fn setup() -> Result<Self> {
49        enable_raw_mode().context("failed to enable raw mode")?;
50        TERMINAL_NEEDS_RESTORE.store(true, Ordering::SeqCst);
51        let mut stdout = io::stdout();
52        if let Err(error) = execute!(
53            stdout,
54            EnterAlternateScreen,
55            EnableMouseCapture,
56            EnableBracketedPaste,
57            EnableFocusChange,
58        ) {
59            restore_terminal_once();
60            return Err(error).context(
61                "failed to enter alternate screen / enable mouse / enable bracketed paste",
62            );
63        }
64
65        // Kitty keyboard protocol, minimal tier: with only DISAMBIGUATE, keys
66        // that are ambiguous in the legacy encoding arrive as distinct events
67        // — Ctrl+Shift+C stops being byte-identical to Ctrl+C (so the copy
68        // chord can't hit the quit path) and Esc stops being an Alt- prefix
69        // guess. The probe needs raw mode (already on) and must run before
70        // the main loop takes over the event reader. Terminals without the
71        // protocol answer the probe negatively and keep legacy behavior.
72        if matches!(supports_keyboard_enhancement(), Ok(true))
73            && execute!(
74                io::stdout(),
75                PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES)
76            )
77            .is_ok()
78        {
79            KEYBOARD_ENHANCED.store(true, Ordering::SeqCst);
80        }
81
82        let backend = CrosstermBackend::new(stdout);
83        let terminal = match Terminal::new(backend).context("failed to create terminal") {
84            Ok(terminal) => terminal,
85            Err(error) => {
86                restore_terminal_once();
87                return Err(error);
88            },
89        };
90
91        install_panic_hook();
92
93        Ok(Self {
94            inner: terminal,
95            restored: false,
96        })
97    }
98
99    /// Mutable access for the render pass.
100    pub fn inner_mut(&mut self) -> &mut Terminal<CrosstermBackend<Stdout>> {
101        &mut self.inner
102    }
103
104    /// Restore terminal state now. Idempotent so normal exit, signal
105    /// exit, and Drop can all share the same call site safely.
106    pub fn restore_now(&mut self) {
107        if self.restored {
108            return;
109        }
110        restore_terminal_once();
111        let _ = self.inner.show_cursor();
112        self.restored = true;
113    }
114}
115
116impl Drop for TerminalGuard {
117    fn drop(&mut self) {
118        self.restore_now();
119    }
120}
121
122/// Best-effort terminal restore used by both normal Drop and panic
123/// handling. The explicit escape fallback turns off every mouse mode
124/// commonly emitted by terminals that support SGR mouse reporting;
125/// this protects users when crossterm's higher-level cleanup does not
126/// fully unwind a prior partial setup or a killed process left modes
127/// dirty.
128fn restore_terminal() {
129    let mut stdout = io::stdout();
130    // Pop the keyboard flags BEFORE leaving the alternate screen: kitty keeps
131    // a separate flag stack per screen buffer, so popping after the switch
132    // would pop the main screen's (empty) stack and leave the alt screen's
133    // flags armed for the next full-screen app.
134    if KEYBOARD_ENHANCED.swap(false, Ordering::SeqCst) {
135        let _ = execute!(stdout, PopKeyboardEnhancementFlags);
136    }
137    let _ = execute!(
138        stdout,
139        DisableMouseCapture,
140        DisableBracketedPaste,
141        DisableFocusChange,
142        LeaveAlternateScreen,
143        Show,
144    );
145    // `\x1b[<u` (pop keyboard flags) rides in the raw fallback unconditionally:
146    // terminals without the kitty protocol ignore the unknown CSI, same as the
147    // mouse-mode resets below.
148    let _ = stdout.write_all(
149        b"\x1b[<u\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1004l\x1b[?1005l\x1b[?1006l\x1b[?1015l\x1b[?2004l\x1b[?1049l\x1b[?25h\x1b[0m",
150    );
151    let _ = stdout.flush();
152    let _ = disable_raw_mode();
153}
154
155fn restore_terminal_once() {
156    if !TERMINAL_NEEDS_RESTORE.swap(false, Ordering::SeqCst) {
157        return;
158    }
159    restore_terminal();
160}
161
162/// Install a panic hook that restores the terminal before propagating
163/// the panic. Without this, a panic mid-render leaves the user in raw
164/// mode with the alternate screen still active — a shell unusable
165/// until they type `reset` blind.
166fn install_panic_hook() {
167    static HOOK_INSTALLED: AtomicBool = AtomicBool::new(false);
168    if HOOK_INSTALLED
169        .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
170        .is_err()
171    {
172        return;
173    }
174
175    let original = std::panic::take_hook();
176    std::panic::set_hook(Box::new(move |info| {
177        restore_terminal_once();
178        original(info);
179    }));
180}