Skip to main content

muxr_client/
terminal.rs

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                // Enter can fail after partial mode writes, so restore before returning without a guard.
88                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            // Headless callers cannot be queried by crossterm; explicit COLUMNS/LINES is the only fallback, so
123            // missing terminal size still fails instead of silently guessing.
124            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
157/// Queue the outer-terminal any-motion mouse capture mode.
158///
159/// Pane applications request this mode dynamically. Button-event capture remains enabled, so disabling any-motion
160/// returns the client to the lower-volume mouse mode.
161///
162/// # Errors
163/// - The terminal mode sequence cannot be queued.
164pub 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}