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