1use std::io::IsTerminal;
2use std::io::Write;
3
4use crossterm::Command;
5use crossterm::QueueableCommand;
6use crossterm::cursor::Hide;
7use crossterm::cursor::Show;
8use crossterm::style::Attribute;
9use crossterm::style::ResetColor;
10use crossterm::style::SetAttribute;
11use crossterm::terminal::Clear;
12use crossterm::terminal::ClearType;
13use crossterm::terminal::EnterAlternateScreen;
14use crossterm::terminal::LeaveAlternateScreen;
15use muxr_core::TerminalSize;
16use rootcause::prelude::ResultExt;
17
18const BRACKETED_PASTE_DISABLE: &[u8] = b"\x1b[?2004l";
19const BRACKETED_PASTE_ENABLE: &[u8] = b"\x1b[?2004h";
20const CURSOR_SHAPE_DEFAULT: &[u8] = b"\x1b[0 q";
21const KITTY_KEYBOARD_PROTOCOL_DISABLE: &[u8] = b"\x1b[<1u";
22const KITTY_KEYBOARD_PROTOCOL_ENABLE: &[u8] = b"\x1b[>1u";
23const MOUSE_BUTTON_CAPTURE_DISABLE: &[u8] = b"\x1b[?1000l";
24const MOUSE_BUTTON_CAPTURE_ENABLE: &[u8] = b"\x1b[?1000h";
25const MOUSE_BUTTON_EVENT_CAPTURE_DISABLE: &[u8] = b"\x1b[?1002l";
26const MOUSE_BUTTON_EVENT_CAPTURE_ENABLE: &[u8] = b"\x1b[?1002h";
27const MOUSE_ANY_EVENT_CAPTURE_DISABLE: &[u8] = b"\x1b[?1003l";
28const MOUSE_ANY_EVENT_CAPTURE_ENABLE: &[u8] = b"\x1b[?1003h";
29const MOUSE_SGR_DISABLE: &[u8] = b"\x1b[?1006l";
30const MOUSE_SGR_ENABLE: &[u8] = b"\x1b[?1006h";
31const OSC8_CLOSE: &[u8] = b"\x1b]8;;\x1b\\";
32
33#[derive(Clone, Copy, Debug, Eq, PartialEq)]
34pub enum SynchronizedOutput {
35 Csi,
36 Dcs,
37}
38
39impl SynchronizedOutput {
40 #[must_use]
41 pub fn for_term(term: Option<&str>) -> Self {
42 match term {
43 Some("alacritty") => Self::Dcs,
44 Some(_) | None => Self::Csi,
45 }
46 }
47
48 #[must_use]
49 const fn start_sequence(self) -> &'static [u8] {
50 match self {
51 Self::Csi => b"\x1b[?2026h",
52 Self::Dcs => b"\x1bP=1s\x1b\\",
53 }
54 }
55
56 #[must_use]
57 const fn end_sequence(self) -> &'static [u8] {
58 match self {
59 Self::Csi => b"\x1b[?2026l",
60 Self::Dcs => b"\x1bP=2s\x1b\\",
61 }
62 }
63}
64
65pub struct TerminalGuard {
66 render_screen: TerminalGuardMode,
67 raw_mode: TerminalGuardMode,
68}
69
70#[derive(Clone, Copy, Debug, Eq, PartialEq)]
71enum TerminalGuardMode {
72 Disabled,
73 Enabled,
74}
75
76impl TerminalGuard {
77 pub fn enable_if_terminal() -> rootcause::Result<Self> {
78 let raw_mode = if std::io::stdin().is_terminal() {
79 crossterm::terminal::enable_raw_mode().context("failed to enable muxr client raw mode")?;
80 TerminalGuardMode::Enabled
81 } else {
82 TerminalGuardMode::Disabled
83 };
84 let render_screen = if std::io::stdout().is_terminal() {
85 let mut stdout = std::io::stdout();
86 if let Err(error) = enter_terminal(&mut stdout) {
87 drop(restore_terminal(&mut stdout));
89 if raw_mode == TerminalGuardMode::Enabled {
90 drop(crossterm::terminal::disable_raw_mode());
91 }
92 return Err(error).context("failed to enter muxr client terminal screen")?;
93 }
94 TerminalGuardMode::Enabled
95 } else {
96 TerminalGuardMode::Disabled
97 };
98
99 Ok(Self {
100 render_screen,
101 raw_mode,
102 })
103 }
104}
105
106impl Drop for TerminalGuard {
107 fn drop(&mut self) {
108 if self.render_screen == TerminalGuardMode::Enabled {
109 let mut stdout = std::io::stdout();
110 drop(restore_terminal(&mut stdout));
111 }
112 if self.raw_mode == TerminalGuardMode::Enabled {
113 drop(crossterm::terminal::disable_raw_mode());
114 }
115 }
116}
117
118pub fn current_terminal_size() -> rootcause::Result<TerminalSize> {
119 match crossterm::terminal::size() {
120 Ok((cols, rows)) => TerminalSize::new(cols, rows),
121 Err(error) => {
122 if let Some(size) = self::terminal_size_from_env()? {
125 return Ok(size);
126 }
127 Err(error).context("failed to read muxr terminal size")?
128 }
129 }
130}
131
132pub fn pane_size_for_terminal(tab_bar_width: u16, size: &TerminalSize) -> rootcause::Result<TerminalSize> {
133 let cols = size.cols().saturating_sub(tab_bar_width).max(1);
134 TerminalSize::new(cols, size.rows())
135}
136
137pub fn queue_synchronized_update_start(stdout: &mut impl Write, mode: SynchronizedOutput) -> rootcause::Result<()> {
138 stdout
139 .write_all(mode.start_sequence())
140 .context("failed to write muxr synchronized render start")?;
141 Ok(())
142}
143
144pub fn queue_synchronized_update_end(stdout: &mut impl Write, mode: SynchronizedOutput) -> rootcause::Result<()> {
145 stdout
146 .write_all(mode.end_sequence())
147 .context("failed to write muxr synchronized render end")?;
148 Ok(())
149}
150
151#[derive(Clone, Copy, Debug, Eq, PartialEq)]
152pub enum MouseAnyMotionCapture {
153 Disabled,
154 Enabled,
155}
156
157pub fn queue_mouse_any_motion_capture(
165 stdout: &mut impl Write,
166 capture: MouseAnyMotionCapture,
167) -> rootcause::Result<()> {
168 match capture {
169 MouseAnyMotionCapture::Enabled => {
170 queue_bytes(stdout, MOUSE_ANY_EVENT_CAPTURE_ENABLE)?;
171 }
172 MouseAnyMotionCapture::Disabled => {
173 queue_bytes(stdout, MOUSE_ANY_EVENT_CAPTURE_DISABLE)?;
174 queue_bytes(stdout, MOUSE_BUTTON_CAPTURE_ENABLE)?;
175 queue_bytes(stdout, MOUSE_BUTTON_EVENT_CAPTURE_ENABLE)?;
176 queue_bytes(stdout, MOUSE_SGR_ENABLE)?;
177 }
178 }
179 Ok(())
180}
181
182fn terminal_size_from_env() -> rootcause::Result<Option<TerminalSize>> {
183 let (Some(cols), Some(rows)) = (std::env::var("COLUMNS").ok(), std::env::var("LINES").ok()) else {
184 return Ok(None);
185 };
186 TerminalSize::new(
187 cols.parse::<u16>()
188 .context("failed to parse COLUMNS terminal size fallback")?,
189 rows.parse::<u16>()
190 .context("failed to parse LINES terminal size fallback")?,
191 )
192 .map(Some)
193}
194
195fn enter_terminal(stdout: &mut impl Write) -> rootcause::Result<()> {
196 queue_cmd(stdout, EnterAlternateScreen)?;
197 queue_bytes(stdout, BRACKETED_PASTE_ENABLE)?;
198 queue_bytes(stdout, KITTY_KEYBOARD_PROTOCOL_ENABLE)?;
199 queue_bytes(stdout, MOUSE_ANY_EVENT_CAPTURE_DISABLE)?;
200 queue_bytes(stdout, MOUSE_BUTTON_CAPTURE_ENABLE)?;
201 queue_bytes(stdout, MOUSE_BUTTON_EVENT_CAPTURE_ENABLE)?;
202 queue_bytes(stdout, MOUSE_SGR_ENABLE)?;
203 queue_cmd(stdout, Clear(ClearType::All))?;
204 queue_cmd(stdout, Hide)?;
205 stdout.flush().context("failed to flush muxr terminal enter")?;
206 Ok(())
207}
208
209fn restore_terminal(stdout: &mut impl Write) -> rootcause::Result<()> {
210 queue_bytes(stdout, OSC8_CLOSE)?;
211 queue_bytes(stdout, KITTY_KEYBOARD_PROTOCOL_DISABLE)?;
212 queue_bytes(stdout, MOUSE_SGR_DISABLE)?;
213 queue_bytes(stdout, MOUSE_ANY_EVENT_CAPTURE_DISABLE)?;
214 queue_bytes(stdout, MOUSE_BUTTON_EVENT_CAPTURE_DISABLE)?;
215 queue_bytes(stdout, MOUSE_BUTTON_CAPTURE_DISABLE)?;
216 queue_bytes(stdout, BRACKETED_PASTE_DISABLE)?;
217 queue_cmd(stdout, LeaveAlternateScreen)?;
218 reset_style(stdout)?;
219 queue_bytes(stdout, CURSOR_SHAPE_DEFAULT)?;
220 queue_cmd(stdout, Show)?;
221 stdout.flush().context("failed to flush muxr terminal restore")?;
222 Ok(())
223}
224
225fn reset_style(stdout: &mut impl Write) -> rootcause::Result<()> {
226 queue_cmd(stdout, ResetColor)?;
227 queue_cmd(stdout, SetAttribute(Attribute::Reset))
228}
229
230fn queue_cmd<W, C>(stdout: &mut W, cmd: C) -> rootcause::Result<()>
231where
232 W: Write,
233 C: Command,
234{
235 Ok(stdout
236 .queue(cmd)
237 .map(|_| ())
238 .context("failed to write muxr terminal mode command")?)
239}
240
241fn queue_bytes(stdout: &mut impl Write, bytes: &[u8]) -> rootcause::Result<()> {
242 stdout
243 .write_all(bytes)
244 .context("failed to write muxr terminal mode sequence")?;
245 Ok(())
246}
247
248#[cfg(test)]
249mod tests {
250 use muxr_config::MuxrConfig;
251 use rootcause::prelude::ResultExt;
252 use test_that::prelude::*;
253
254 use super::*;
255
256 #[test]
257 fn test_pane_size_for_terminal_when_tab_bar_has_room_reserves_sidebar_columns() -> rootcause::Result<()> {
258 let tab_bar_width = MuxrConfig::default().tab_bar.width;
259
260 assert_that!(
261 pane_size_for_terminal(tab_bar_width, &TerminalSize::new(80, 24)?)?,
262 eq(TerminalSize::new(80_u16.saturating_sub(tab_bar_width), 24)?)
263 );
264 assert_that!(
265 pane_size_for_terminal(tab_bar_width, &TerminalSize::new(80, 1)?)?,
266 eq(TerminalSize::new(80_u16.saturating_sub(tab_bar_width), 1)?)
267 );
268 Ok(())
269 }
270
271 #[test]
272 fn test_enter_terminal_writes_alternate_screen_and_clear() -> rootcause::Result<()> {
273 let mut output = Vec::new();
274
275 enter_terminal(&mut output)?;
276
277 let rendered = String::from_utf8(output).context("muxr terminal test output was not utf8")?;
278 assert_that!(rendered, contains_substring("\x1b[?1049h"));
279 assert_that!(rendered, contains_substring("\x1b[?2004h"));
280 assert_that!(rendered, contains_substring("\x1b[>1u"));
281 assert_that!(rendered, contains_substring("\x1b[?1003l"));
282 assert_that!(rendered, contains_substring("\x1b[?1000h"));
283 assert_that!(rendered, contains_substring("\x1b[?1002h"));
284 assert_that!(rendered, not(contains_substring("\x1b[?1003h")));
285 assert_that!(rendered, contains_substring("\x1b[?1006h"));
286 assert_that!(rendered, contains_substring("\x1b[2J"));
287 assert_that!(rendered, contains_substring("\x1b[?25l"));
288 Ok(())
289 }
290
291 #[test]
292 fn test_queue_mouse_any_motion_capture_when_enabled_writes_any_motion_sequence() -> rootcause::Result<()> {
293 let mut output = Vec::new();
294
295 queue_mouse_any_motion_capture(&mut output, MouseAnyMotionCapture::Enabled)?;
296
297 assert_that!(String::from_utf8(output)?, eq("\x1b[?1003h"));
298 Ok(())
299 }
300
301 #[test]
302 fn test_queue_mouse_any_motion_capture_when_disabled_reasserts_button_capture() -> rootcause::Result<()> {
303 let mut output = Vec::new();
304
305 queue_mouse_any_motion_capture(&mut output, MouseAnyMotionCapture::Disabled)?;
306
307 assert_that!(
308 String::from_utf8(output)?,
309 eq("\x1b[?1003l\x1b[?1000h\x1b[?1002h\x1b[?1006h")
310 );
311 Ok(())
312 }
313
314 #[rstest::rstest]
315 #[case::alacritty(Some("alacritty"), SynchronizedOutput::Dcs)]
316 #[case::xterm(Some("xterm-256color"), SynchronizedOutput::Csi)]
317 #[case::unknown(None, SynchronizedOutput::Csi)]
318 fn test_synchronized_output_for_term_when_term_is_known_returns_expected_mode(
319 #[case] term: Option<&str>,
320 #[case] expected: SynchronizedOutput,
321 ) {
322 assert_that!(SynchronizedOutput::for_term(term), eq(expected));
323 }
324
325 #[rstest::rstest]
326 #[case::csi(SynchronizedOutput::Csi, "\x1b[?2026h", "\x1b[?2026l")]
327 #[case::dcs(SynchronizedOutput::Dcs, "\x1bP=1s\x1b\\", "\x1bP=2s\x1b\\")]
328 fn test_synchronized_update_queue_when_mode_is_selected_writes_expected_sequences(
329 #[case] mode: SynchronizedOutput,
330 #[case] start: &str,
331 #[case] end: &str,
332 ) -> rootcause::Result<()> {
333 let mut output = Vec::new();
334
335 queue_synchronized_update_start(&mut output, mode)?;
336 queue_synchronized_update_end(&mut output, mode)?;
337
338 let rendered = String::from_utf8(output).context("muxr terminal test output was not utf8")?;
339 assert_that!(rendered, eq(format!("{start}{end}")));
340 Ok(())
341 }
342
343 #[test]
344 fn test_restore_terminal_writes_alternate_screen_exit_cursor_and_style_reset() -> rootcause::Result<()> {
345 let mut output = Vec::new();
346
347 restore_terminal(&mut output)?;
348
349 let rendered = String::from_utf8(output).context("muxr terminal test output was not utf8")?;
350 assert_that!(rendered, contains_substring("\x1b[<1u"));
351 assert_that!(rendered, contains_substring("\x1b[?1006l"));
352 assert_that!(rendered, contains_substring("\x1b[?1003l"));
353 assert_that!(rendered, contains_substring("\x1b[?1002l"));
354 assert_that!(rendered, contains_substring("\x1b[?1000l"));
355 assert_that!(rendered, contains_substring("\x1b[?2004l"));
356 assert_that!(rendered, contains_substring("\x1b[?1049l"));
357 assert_that!(rendered, contains_substring("\x1b[0 q"));
358 assert_that!(rendered, contains_substring("\x1b[?25h"));
359 assert_that!(rendered, contains_substring("\x1b[0m"));
360 assert_that!(rendered, starts_with("\x1b]8;;\x1b\\"));
361 Ok(())
362 }
363}