Skip to main content

wisp/session/
terminal.rs

1//! The terminal session the UI runs in: ratatui's raw mode / inline viewport
2//! plus the crossterm mode escapes layered on top, owned by one RAII guard.
3
4use crossterm::cursor::MoveTo;
5use crossterm::event::{
6    DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, KeyboardEnhancementFlags,
7    PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
8};
9use crossterm::execute;
10use crossterm::terminal::{Clear, ClearType};
11use ratatui::backend::CrosstermBackend;
12use ratatui::{DefaultTerminal, Terminal, TerminalOptions, Viewport};
13use std::io;
14use std::thread;
15
16/// Rows left above the inline viewport for the scrollback the UI commits into
17/// the terminal's own history.
18pub const INLINE_SCROLLBACK_RESERVE: u16 = 2;
19
20pub fn inline_viewport_height(terminal_height: u16) -> u16 {
21    if terminal_height == 0 { 0 } else { terminal_height.saturating_sub(INLINE_SCROLLBACK_RESERVE).max(1) }
22}
23
24/// Whether the inline viewport no longer matches the window it was created for.
25///
26/// Ratatui keeps the height an inline viewport was created with: it clamps that
27/// height to a window that shrank but never grows it back, so a stale viewport
28/// both wastes rows after a shrink and swallows the rows this UI commits its
29/// scrollback through after a regrow. Only a rebuilt terminal puts the two back
30/// in step.
31pub fn inline_viewport_needs_resync(terminal_height: u16, current_viewport_height: u16) -> bool {
32    let height = inline_viewport_height(terminal_height);
33    height != 0 && height != current_viewport_height
34}
35
36/// Owns the terminal for the whole UI run: ratatui's raw mode / inline viewport
37/// and the crossterm mode escapes on top, undone when this is dropped.
38/// Construct only through [`TerminalSession::enter`].
39pub(crate) struct TerminalSession {
40    terminal: DefaultTerminal,
41    keyboard_enhancement: bool,
42    bracketed_paste: bool,
43    mouse_capture: bool,
44}
45
46impl TerminalSession {
47    /// Enters ratatui (raw mode, inline viewport, global panic hook) and
48    /// switches on the modes the UI needs for its whole run. Keyboard
49    /// enhancement is best-effort; bracketed paste is required. On any failure
50    /// the terminal is restored best-effort before the original error is
51    /// returned.
52    pub(crate) fn enter(viewport: Viewport) -> io::Result<Self> {
53        let terminal = match ratatui::try_init_with_options(TerminalOptions { viewport }) {
54            Ok(terminal) => terminal,
55            Err(error) => {
56                // Raw mode may already be active; restore best-effort and keep
57                // the original init error authoritative.
58                ratatui::restore();
59                return Err(error);
60            }
61        };
62        let mut session = Self { terminal, keyboard_enhancement: false, bracketed_paste: false, mouse_capture: false };
63        session.try_keyboard_enhancement();
64        // A failure here drops the session, which undoes the modes that landed
65        // and restores ratatui before the error propagates.
66        session.apply_bracketed_paste()?;
67        Ok(session)
68    }
69
70    pub(crate) fn terminal_mut(&mut self) -> &mut DefaultTerminal {
71        &mut self.terminal
72    }
73
74    /// Turns mouse reporting on only while something on screen wants it, so an
75    /// ordinary composer leaves the terminal's own selection and scrollback alone.
76    pub(crate) fn set_mouse_capture(&mut self, enabled: bool) {
77        if enabled == self.mouse_capture {
78            return;
79        }
80        if enabled {
81            // Enable is best-effort; if it fails, roll back best-effort and keep
82            // the flag on only when that rollback also failed (so teardown retries).
83            let enabled_ok = execute!(io::stdout(), EnableMouseCapture).is_ok();
84            self.mouse_capture = enabled_ok || execute!(io::stdout(), DisableMouseCapture).is_err();
85        } else if execute!(io::stdout(), DisableMouseCapture).is_ok() {
86            self.mouse_capture = false;
87        }
88    }
89
90    /// Rebuilds the terminal when the window no longer matches the inline
91    /// viewport it was created with: after a shrink (ratatui clamps the viewport
92    /// height) or a regrow (it never grows the height back).
93    pub(crate) fn resync_inline_viewport(&mut self) -> io::Result<()> {
94        let terminal_height = self.terminal.size()?.height;
95        let height = inline_viewport_height(terminal_height);
96        if !inline_viewport_needs_resync(terminal_height, self.terminal.get_frame().area().height) {
97            return Ok(());
98        }
99
100        execute!(io::stdout(), MoveTo(0, terminal_height - height), Clear(ClearType::FromCursorDown))?;
101        self.terminal = Terminal::with_options(
102            CrosstermBackend::new(io::stdout()),
103            TerminalOptions { viewport: Viewport::Inline(height) },
104        )?;
105        Ok(())
106    }
107
108    fn try_keyboard_enhancement(&mut self) {
109        let flags = KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
110            | KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS
111            | KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES;
112        if execute!(io::stdout(), PushKeyboardEnhancementFlags(flags)).is_ok() {
113            self.keyboard_enhancement = true;
114        } else if execute!(io::stdout(), PopKeyboardEnhancementFlags).is_err() {
115            // A failed push can still have reached the terminal, and the pop that
116            // should have undone it failed too, so teardown retries.
117            self.keyboard_enhancement = true;
118        }
119    }
120
121    fn apply_bracketed_paste(&mut self) -> io::Result<()> {
122        match execute!(io::stdout(), EnableBracketedPaste) {
123            Ok(()) => {
124                self.bracketed_paste = true;
125                Ok(())
126            }
127            Err(error) => {
128                // A failed enable can still have reached the terminal.
129                if execute!(io::stdout(), DisableBracketedPaste).is_err() {
130                    self.bracketed_paste = true;
131                }
132                Err(error)
133            }
134        }
135    }
136
137    fn undo_modes(&mut self) {
138        if self.mouse_capture {
139            let _ = execute!(io::stdout(), DisableMouseCapture);
140            self.mouse_capture = false;
141        }
142        if self.bracketed_paste {
143            let _ = execute!(io::stdout(), DisableBracketedPaste);
144            self.bracketed_paste = false;
145        }
146        if self.keyboard_enhancement {
147            let _ = execute!(io::stdout(), PopKeyboardEnhancementFlags);
148            self.keyboard_enhancement = false;
149        }
150    }
151}
152
153impl Drop for TerminalSession {
154    fn drop(&mut self) {
155        // Reverse order: the UI's escapes are torn down while raw mode is still
156        // on, then ratatui (raw mode off) is restored.
157        self.undo_modes();
158        // On a panic, ratatui's global panic hook already restored before
159        // unwinding reached this `Drop`; skip our own restore so ratatui is
160        // restored exactly once instead of twice.
161        if !thread::panicking() {
162            ratatui::restore();
163        }
164    }
165}