Skip to main content

urushi_terminal/
session.rs

1//! Acquisition and restoration of interactive terminal state.
2
3use std::{error::Error, fmt, io};
4
5use crate::{Command, CommandWriter, KeyboardEnhancementFlags, KeyboardEnhancementQuery};
6
7/// Process-side terminal modes that cannot be expressed as terminal commands.
8///
9/// Output modes such as the alternate screen and input reporting are expressed
10/// by [`Command`]. Raw mode changes the process terminal driver and therefore
11/// remains a separate backend capability.
12pub trait RawModeControl {
13    fn is_interactive(&self) -> bool;
14
15    fn enable_raw_mode(&mut self) -> io::Result<()>;
16    fn disable_raw_mode(&mut self) -> io::Result<()>;
17}
18
19impl<T: RawModeControl + ?Sized> RawModeControl for &mut T {
20    fn is_interactive(&self) -> bool {
21        T::is_interactive(self)
22    }
23
24    fn enable_raw_mode(&mut self) -> io::Result<()> {
25        T::enable_raw_mode(self)
26    }
27
28    fn disable_raw_mode(&mut self) -> io::Result<()> {
29        T::disable_raw_mode(self)
30    }
31}
32
33/// Terminal modes acquired by a session.
34///
35/// The default acquires no modes. Prompt and TUI entry points choose their own
36/// explicit profiles rather than placing application policy in this crate.
37#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
38pub struct SessionOptions {
39    /// Enables raw input mode.
40    pub raw_mode: bool,
41    /// Uses the alternate screen buffer.
42    pub alternate_screen: bool,
43    /// Reports a paste as one paste event.
44    pub bracketed_paste: bool,
45    /// Reports terminal focus changes.
46    pub focus_change: bool,
47    /// Requests the selected enhanced-keyboard information when supported.
48    pub keyboard_enhancement: Option<KeyboardEnhancementFlags>,
49    /// Captures mouse input instead of leaving terminal text selection active.
50    pub mouse_capture: bool,
51    /// Hides the cursor until a frame or restoration shows it.
52    pub hide_cursor: bool,
53}
54
55/// A session-entry failure and the first restoration failure, if any.
56#[derive(Debug)]
57pub struct SessionError {
58    source: io::Error,
59    cleanup: Option<io::Error>,
60}
61
62impl SessionError {
63    /// Returns the operation failure that prevented entry.
64    pub fn source_error(&self) -> &io::Error {
65        &self.source
66    }
67
68    /// Returns the first additional failure encountered during restoration.
69    pub fn cleanup_error(&self) -> Option<&io::Error> {
70        self.cleanup.as_ref()
71    }
72
73    /// Splits the entry failure from its optional restoration failure.
74    pub fn into_parts(self) -> (io::Error, Option<io::Error>) {
75        (self.source, self.cleanup)
76    }
77}
78
79impl fmt::Display for SessionError {
80    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
81        write!(
82            formatter,
83            "failed to enter terminal session: {}",
84            self.source
85        )?;
86        if let Some(cleanup) = &self.cleanup {
87            write!(formatter, "; terminal restoration also failed: {cleanup}")?;
88        }
89        Ok(())
90    }
91}
92
93impl Error for SessionError {
94    fn source(&self) -> Option<&(dyn Error + 'static)> {
95        Some(&self.source)
96    }
97}
98
99/// A guard for every terminal state successfully acquired during entry.
100///
101/// Call [`restore`](Self::restore) on normal and error returns so restoration
102/// errors can be reported. `Drop` is the final guard during panic unwinding.
103/// It cannot report restoration failures. The session cannot restore the
104/// cursor's pre-entry position, process aborts, `kill -9`, or signals whose
105/// handling never unwinds through this value.
106pub struct TerminalSession<
107    'a,
108    C: RawModeControl + CommandWriter + KeyboardEnhancementQuery + ?Sized,
109> {
110    control: &'a mut C,
111    entered: Entered,
112    restored: bool,
113}
114
115#[derive(Default)]
116struct Entered {
117    raw_mode: bool,
118    alternate_screen: bool,
119    bracketed_paste: bool,
120    focus_change: bool,
121    keyboard_enhancement: bool,
122    mouse_capture: bool,
123    cursor_hidden: bool,
124}
125
126impl<'a, C> TerminalSession<'a, C>
127where
128    C: RawModeControl + CommandWriter + KeyboardEnhancementQuery + ?Sized,
129{
130    /// Acquires the requested modes, restoring partial state if entry fails.
131    pub fn enter(control: &'a mut C, options: SessionOptions) -> Result<Self, SessionError> {
132        let mut session = Self {
133            control,
134            entered: Entered::default(),
135            restored: false,
136        };
137        if let Err(source) = session.acquire(options) {
138            let cleanup = session.restore().err();
139            return Err(SessionError { source, cleanup });
140        }
141        Ok(session)
142    }
143
144    /// Borrows the underlying control while the session remains active.
145    pub fn control_mut(&mut self) -> &mut C {
146        self.control
147    }
148
149    /// Attempts every outstanding restoration in reverse acquisition order.
150    ///
151    /// The first error is returned after all remaining steps and the final
152    /// flush have been attempted. Repeated calls are no-ops.
153    pub fn restore(&mut self) -> io::Result<()> {
154        if self.restored {
155            return Ok(());
156        }
157        self.restored = true;
158        let mut first = None;
159        macro_rules! restore_command {
160            ($entered:ident, $command:expr) => {
161                if self.entered.$entered {
162                    self.entered.$entered = false;
163                    if let Err(error) = self.control.write_command($command) {
164                        record_first(&mut first, error);
165                    }
166                }
167            };
168        }
169        restore_command!(cursor_hidden, Command::SetCursorVisible(true));
170        restore_command!(mouse_capture, Command::SetMouseCapture(false));
171        restore_command!(keyboard_enhancement, Command::PopKeyboardEnhancement);
172        restore_command!(focus_change, Command::SetFocusReporting(false));
173        restore_command!(bracketed_paste, Command::SetBracketedPaste(false));
174        restore_command!(alternate_screen, Command::SetAlternateScreen(false));
175        if let Err(error) = self.control.flush() {
176            record_first(&mut first, error);
177        }
178        if self.entered.raw_mode {
179            self.entered.raw_mode = false;
180            if let Err(error) = self.control.disable_raw_mode() {
181                record_first(&mut first, error);
182            }
183        }
184        first.map_or(Ok(()), Err)
185    }
186
187    fn acquire(&mut self, options: SessionOptions) -> io::Result<()> {
188        if options.raw_mode {
189            self.entered.raw_mode = true;
190            self.control.enable_raw_mode()?;
191        }
192        if options.alternate_screen {
193            self.entered.alternate_screen = true;
194            self.control
195                .write_command(Command::SetAlternateScreen(true))?;
196        }
197        if options.bracketed_paste {
198            self.entered.bracketed_paste = true;
199            self.control
200                .write_command(Command::SetBracketedPaste(true))?;
201        }
202        if options.focus_change {
203            self.entered.focus_change = true;
204            self.control
205                .write_command(Command::SetFocusReporting(true))?;
206        }
207        if let Some(flags) = options.keyboard_enhancement
208            && self.control.supports_keyboard_enhancement()?
209        {
210            self.entered.keyboard_enhancement = true;
211            self.control
212                .write_command(Command::PushKeyboardEnhancement(flags))?;
213        }
214        if options.mouse_capture {
215            self.entered.mouse_capture = true;
216            self.control.write_command(Command::SetMouseCapture(true))?;
217        }
218        if options.hide_cursor {
219            self.entered.cursor_hidden = true;
220            self.control
221                .write_command(Command::SetCursorVisible(false))?;
222        }
223        self.control.flush()
224    }
225}
226
227impl<C> Drop for TerminalSession<'_, C>
228where
229    C: RawModeControl + CommandWriter + KeyboardEnhancementQuery + ?Sized,
230{
231    fn drop(&mut self) {
232        if !self.restored {
233            let _cleanup_error = self.restore();
234        }
235    }
236}
237
238fn record_first(first: &mut Option<io::Error>, error: io::Error) {
239    if first.is_none() {
240        *first = Some(error);
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247    use crate::{Position, TerminalOutput, TerminalQuery, TerminalSize, WindowSize};
248    use std::panic::{AssertUnwindSafe, catch_unwind};
249
250    const ACQUIRE: [&str; 8] = [
251        "enable_raw",
252        "enter_alternate",
253        "enable_paste",
254        "enable_focus",
255        "enable_keyboard",
256        "enable_mouse",
257        "hide_cursor",
258        "flush",
259    ];
260    const RESTORE: [&str; 8] = [
261        "show_cursor",
262        "disable_mouse",
263        "disable_keyboard",
264        "disable_focus",
265        "disable_paste",
266        "leave_alternate",
267        "flush",
268        "disable_raw",
269    ];
270
271    #[derive(Default)]
272    struct RecordingControl {
273        calls: Vec<&'static str>,
274        fail_acquire: Option<&'static str>,
275        fail_restore: Vec<&'static str>,
276    }
277
278    impl RecordingControl {
279        fn call(&mut self, name: &'static str) -> io::Result<()> {
280            self.calls.push(name);
281            if self.fail_acquire == Some(name) || self.fail_restore.contains(&name) {
282                Err(io::Error::other(name))
283            } else {
284                Ok(())
285            }
286        }
287    }
288
289    impl RawModeControl for RecordingControl {
290        fn is_interactive(&self) -> bool {
291            true
292        }
293        fn enable_raw_mode(&mut self) -> io::Result<()> {
294            self.call("enable_raw")
295        }
296        fn disable_raw_mode(&mut self) -> io::Result<()> {
297            self.call("disable_raw")
298        }
299    }
300
301    impl CommandWriter for RecordingControl {
302        fn write_command(&mut self, command: Command<'_>) -> io::Result<()> {
303            let name = match command {
304                Command::SetAlternateScreen(true) => "enter_alternate",
305                Command::SetAlternateScreen(false) => "leave_alternate",
306                Command::SetBracketedPaste(true) => "enable_paste",
307                Command::SetBracketedPaste(false) => "disable_paste",
308                Command::SetFocusReporting(true) => "enable_focus",
309                Command::SetFocusReporting(false) => "disable_focus",
310                Command::PushKeyboardEnhancement(_) => "enable_keyboard",
311                Command::PopKeyboardEnhancement => "disable_keyboard",
312                Command::SetMouseCapture(true) => "enable_mouse",
313                Command::SetMouseCapture(false) => "disable_mouse",
314                Command::SetCursorVisible(false) => "hide_cursor",
315                Command::SetCursorVisible(true) => "show_cursor",
316                _ => panic!("unexpected session command: {command:?}"),
317            };
318            self.call(name)
319        }
320    }
321
322    impl TerminalQuery for RecordingControl {
323        fn terminal_size(&mut self) -> io::Result<TerminalSize> {
324            Ok(TerminalSize::new(80, 24))
325        }
326
327        fn cursor_position(&mut self) -> io::Result<Position> {
328            Ok(Position::new(0, 0))
329        }
330
331        fn window_size(&mut self) -> io::Result<WindowSize> {
332            Ok(WindowSize::new(TerminalSize::new(80, 24), None))
333        }
334
335        fn raw_mode_enabled(&mut self) -> io::Result<bool> {
336            Ok(false)
337        }
338    }
339
340    impl KeyboardEnhancementQuery for RecordingControl {
341        fn supports_keyboard_enhancement(&mut self) -> io::Result<bool> {
342            Ok(true)
343        }
344    }
345
346    impl TerminalOutput for RecordingControl {
347        fn flush(&mut self) -> io::Result<()> {
348            self.call("flush")
349        }
350    }
351
352    fn all_modes() -> SessionOptions {
353        SessionOptions {
354            raw_mode: true,
355            alternate_screen: true,
356            bracketed_paste: true,
357            focus_change: true,
358            keyboard_enhancement: Some(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES),
359            mouse_capture: true,
360            hide_cursor: true,
361        }
362    }
363
364    #[test]
365    fn explicit_restore_reverses_every_acquired_mode() {
366        let mut control = RecordingControl::default();
367        let mut session =
368            TerminalSession::enter(&mut control, all_modes()).expect("entry succeeds");
369        session.restore().expect("restoration succeeds");
370        drop(session);
371
372        assert_eq!(
373            control.calls,
374            [ACQUIRE.as_slice(), RESTORE.as_slice()].concat()
375        );
376    }
377
378    #[test]
379    fn every_partial_entry_failure_restores_the_mode_that_may_have_been_written() {
380        for (failed_index, failed) in ACQUIRE.into_iter().enumerate() {
381            let mut control = RecordingControl {
382                fail_acquire: Some(failed),
383                ..RecordingControl::default()
384            };
385            let error = match TerminalSession::enter(&mut control, all_modes()) {
386                Ok(_) => panic!("planned acquisition should fail"),
387                Err(error) => error,
388            };
389            assert_eq!(error.source_error().to_string(), failed);
390
391            let mut expected = ACQUIRE[..=failed_index].to_vec();
392            if failed == "flush" {
393                expected.extend_from_slice(&RESTORE);
394            } else {
395                let inverse_start = RESTORE.len() - failed_index - 2;
396                expected.extend_from_slice(&RESTORE[inverse_start..]);
397            }
398            assert_eq!(control.calls, expected, "failed at {failed}");
399        }
400    }
401
402    #[test]
403    fn restoration_reports_the_first_error_and_attempts_every_step() {
404        let mut control = RecordingControl::default();
405        let mut session =
406            TerminalSession::enter(&mut control, all_modes()).expect("entry succeeds");
407        session.control_mut().fail_restore = vec!["show_cursor", "disable_focus", "flush"];
408        let error = session
409            .restore()
410            .expect_err("restoration failure is reported");
411        assert_eq!(error.to_string(), "show_cursor");
412        drop(session);
413
414        assert_eq!(
415            control.calls,
416            [ACQUIRE.as_slice(), RESTORE.as_slice()].concat()
417        );
418    }
419
420    #[test]
421    fn panic_unwinding_runs_the_same_reverse_restoration() {
422        let mut control = RecordingControl::default();
423        let panic = catch_unwind(AssertUnwindSafe(|| {
424            let _session =
425                TerminalSession::enter(&mut control, all_modes()).expect("entry succeeds");
426            panic!("planned panic");
427        }));
428
429        assert!(panic.is_err());
430        assert_eq!(
431            control.calls,
432            [ACQUIRE.as_slice(), RESTORE.as_slice()].concat()
433        );
434    }
435}