use std::io;
use bitflags::bitflags;
use crossterm::{
cursor,
event::{DisableMouseCapture, EnableMouseCapture},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
bitflags! {
#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
pub struct TerminalModes: u8 {
const RAW_MODE = 1 << 0;
const ALTERNATE_SCREEN = 1 << 1;
const HIDDEN_CURSOR = 1 << 2;
const MOUSE_CAPTURE = 1 << 3;
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum Operation {
EnableRawMode,
EnterAlternateScreen,
HideCursor,
EnableMouseCapture,
DisableMouseCapture,
ShowCursor,
LeaveAlternateScreen,
DisableRawMode,
}
type Backend = fn(Operation) -> io::Result<()>;
fn crossterm_backend(operation: Operation) -> io::Result<()> {
match operation {
Operation::EnableRawMode => enable_raw_mode(),
Operation::EnterAlternateScreen => execute!(io::stdout(), EnterAlternateScreen),
Operation::HideCursor => execute!(io::stdout(), cursor::Hide),
Operation::EnableMouseCapture => execute!(io::stdout(), EnableMouseCapture),
Operation::DisableMouseCapture => execute!(io::stdout(), DisableMouseCapture),
Operation::ShowCursor => execute!(io::stdout(), cursor::Show),
Operation::LeaveAlternateScreen => execute!(io::stdout(), LeaveAlternateScreen),
Operation::DisableRawMode => disable_raw_mode(),
}
}
#[must_use = "dropping the session immediately restores the terminal"]
pub struct TerminalSession {
backend: Backend,
pending_restore: TerminalModes,
}
impl TerminalSession {
pub fn try_new(modes: TerminalModes) -> io::Result<Self> {
Self::try_new_with(crossterm_backend, modes)
}
fn try_new_with(backend: Backend, modes: TerminalModes) -> io::Result<Self> {
let mut session = Self {
backend,
pending_restore: TerminalModes::empty(),
};
for (mode, operation) in [
(TerminalModes::RAW_MODE, Operation::EnableRawMode),
(
TerminalModes::ALTERNATE_SCREEN,
Operation::EnterAlternateScreen,
),
(TerminalModes::HIDDEN_CURSOR, Operation::HideCursor),
(TerminalModes::MOUSE_CAPTURE, Operation::EnableMouseCapture),
] {
if !modes.contains(mode) {
continue;
}
session.pending_restore.insert(mode);
(session.backend)(operation)?;
}
Ok(session)
}
pub fn restore(&mut self) -> io::Result<()> {
let mut first_error = None;
for (mode, operation) in [
(TerminalModes::MOUSE_CAPTURE, Operation::DisableMouseCapture),
(TerminalModes::HIDDEN_CURSOR, Operation::ShowCursor),
(
TerminalModes::ALTERNATE_SCREEN,
Operation::LeaveAlternateScreen,
),
(TerminalModes::RAW_MODE, Operation::DisableRawMode),
] {
if !self.pending_restore.contains(mode) {
continue;
}
match (self.backend)(operation) {
Ok(()) => self.pending_restore.remove(mode),
Err(error) if first_error.is_none() => first_error = Some(error),
Err(_) => {}
}
}
match first_error {
Some(error) => Err(error),
None => Ok(()),
}
}
}
impl Drop for TerminalSession {
fn drop(&mut self) {
self.restore().ok();
}
}
#[cfg(test)]
mod tests {
use std::{
cell::RefCell,
collections::VecDeque,
io::{self, ErrorKind},
};
use super::{Operation, TerminalModes, TerminalSession};
#[derive(Default)]
struct MockState {
operations: Vec<Operation>,
failures: VecDeque<Operation>,
}
thread_local! {
static MOCK_STATE: RefCell<MockState> = RefCell::new(MockState::default());
}
fn mock_backend(operation: Operation) -> io::Result<()> {
MOCK_STATE.with_borrow_mut(|state| {
state.operations.push(operation);
if state.failures.front() == Some(&operation) {
state.failures.pop_front();
Err(io::Error::other("terminal operation failed"))
} else {
Ok(())
}
})
}
fn panicking_backend(operation: Operation) -> io::Result<()> {
if operation == Operation::HideCursor {
MOCK_STATE.with_borrow_mut(|state| state.operations.push(operation));
panic!("terminal operation panicked");
}
mock_backend(operation)
}
fn reset(failures: impl IntoIterator<Item = Operation>) {
MOCK_STATE.with_borrow_mut(|state| {
*state = MockState {
failures: failures.into_iter().collect(),
..MockState::default()
};
});
}
fn operations() -> Vec<Operation> {
MOCK_STATE.with_borrow(|state| state.operations.clone())
}
fn try_new(modes: TerminalModes) -> io::Result<TerminalSession> {
TerminalSession::try_new_with(mock_backend, modes)
}
mod terminal_session {
use super::*;
mod restore {
use super::*;
#[test]
fn restores_fullscreen_modes_in_reverse_order() {
reset([]);
let modes = TerminalModes::RAW_MODE
| TerminalModes::ALTERNATE_SCREEN
| TerminalModes::HIDDEN_CURSOR
| TerminalModes::MOUSE_CAPTURE;
let mut session = try_new(modes).unwrap();
session.restore().unwrap();
session.restore().unwrap();
assert_eq!(
operations(),
[
Operation::EnableRawMode,
Operation::EnterAlternateScreen,
Operation::HideCursor,
Operation::EnableMouseCapture,
Operation::DisableMouseCapture,
Operation::ShowCursor,
Operation::LeaveAlternateScreen,
Operation::DisableRawMode,
]
);
}
#[test]
fn continues_after_an_error_and_retries_failed_steps() {
reset([Operation::DisableMouseCapture]);
let modes = TerminalModes::RAW_MODE
| TerminalModes::ALTERNATE_SCREEN
| TerminalModes::HIDDEN_CURSOR
| TerminalModes::MOUSE_CAPTURE;
let mut session = try_new(modes).unwrap();
assert_eq!(session.restore().unwrap_err().kind(), ErrorKind::Other);
session.restore().unwrap();
assert_eq!(
operations(),
[
Operation::EnableRawMode,
Operation::EnterAlternateScreen,
Operation::HideCursor,
Operation::EnableMouseCapture,
Operation::DisableMouseCapture,
Operation::ShowCursor,
Operation::LeaveAlternateScreen,
Operation::DisableRawMode,
Operation::DisableMouseCapture,
]
);
}
#[test]
fn applies_only_requested_modes() {
reset([]);
let modes = TerminalModes::ALTERNATE_SCREEN | TerminalModes::MOUSE_CAPTURE;
let mut session = try_new(modes).unwrap();
session.restore().unwrap();
assert_eq!(
operations(),
[
Operation::EnterAlternateScreen,
Operation::EnableMouseCapture,
Operation::DisableMouseCapture,
Operation::LeaveAlternateScreen,
]
);
}
}
mod drop {
use super::*;
#[test]
fn restores_terminal_when_dropped() {
reset([]);
{
let modes = TerminalModes::RAW_MODE
| TerminalModes::HIDDEN_CURSOR
| TerminalModes::MOUSE_CAPTURE;
let _session = try_new(modes).unwrap();
}
assert_eq!(
operations(),
[
Operation::EnableRawMode,
Operation::HideCursor,
Operation::EnableMouseCapture,
Operation::DisableMouseCapture,
Operation::ShowCursor,
Operation::DisableRawMode,
]
);
}
}
mod try_new_with {
use super::*;
#[test]
fn rolls_back_attempted_setup_when_setup_fails() {
reset([Operation::HideCursor]);
let modes = TerminalModes::RAW_MODE
| TerminalModes::ALTERNATE_SCREEN
| TerminalModes::HIDDEN_CURSOR
| TerminalModes::MOUSE_CAPTURE;
let error = try_new(modes).err().expect("setup must fail");
assert_eq!(error.kind(), ErrorKind::Other);
assert_eq!(
operations(),
[
Operation::EnableRawMode,
Operation::EnterAlternateScreen,
Operation::HideCursor,
Operation::ShowCursor,
Operation::LeaveAlternateScreen,
Operation::DisableRawMode,
]
);
}
#[test]
fn restores_attempted_setup_when_setup_panics() {
reset([]);
let modes = TerminalModes::RAW_MODE
| TerminalModes::ALTERNATE_SCREEN
| TerminalModes::HIDDEN_CURSOR
| TerminalModes::MOUSE_CAPTURE;
let panic = std::panic::catch_unwind(|| {
TerminalSession::try_new_with(panicking_backend, modes).ok();
});
assert!(panic.is_err());
assert_eq!(
operations(),
[
Operation::EnableRawMode,
Operation::EnterAlternateScreen,
Operation::HideCursor,
Operation::ShowCursor,
Operation::LeaveAlternateScreen,
Operation::DisableRawMode,
]
);
}
}
}
}