use std::io::{self, stdout, Stdout, Write};
use std::sync::Once;
use crossterm::{
cursor::{Hide, Show},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::backend::CrosstermBackend;
use ratatui::Frame;
use ratatui::Terminal as RatatuiTerminal;
#[allow(dead_code)]
static HOOK_INSTALLED: Once = Once::new();
pub struct Terminal {
inner: RatatuiTerminal<CrosstermBackend<Stdout>>,
}
impl Terminal {
pub fn init() -> io::Result<Self> {
enable_raw_mode()?;
let mut stdout = stdout();
execute!(stdout, EnterAlternateScreen, Hide)?;
HOOK_INSTALLED.call_once(|| {
let original_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
restore_terminal();
original_hook(info);
}));
});
let backend = CrosstermBackend::new(stdout);
let inner = RatatuiTerminal::new(backend)?;
Ok(Self { inner })
}
pub fn restore(&mut self) {
let _ = self;
restore_terminal();
}
pub fn draw<F>(&mut self, f: F) -> io::Result<()>
where
F: FnOnce(&mut Frame),
{
self.inner.draw(f)?;
Ok(())
}
pub fn size() -> io::Result<(u16, u16)> {
crossterm::terminal::size()
}
#[allow(dead_code, clippy::unused_self)]
pub fn into_inner(self) -> RatatuiTerminal<CrosstermBackend<Stdout>> {
let backend = CrosstermBackend::new(stdout());
RatatuiTerminal::new(backend).expect("failed to create terminal for into_inner")
}
}
#[allow(dead_code)]
fn restore_terminal() {
let mut stdout = stdout();
let _ = execute!(stdout, Show);
let _ = execute!(stdout, LeaveAlternateScreen);
let _ = disable_raw_mode();
let _ = stdout.flush();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn restore_terminal_is_idempotent() {
restore_terminal();
restore_terminal();
restore_terminal();
}
#[test]
fn install_panic_hook_does_not_panic() {
HOOK_INSTALLED.call_once(|| {
let original_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
restore_terminal();
original_hook(info);
}));
});
}
#[test]
fn terminal_size_returns_valid_dimensions() {
use std::io::IsTerminal;
if !std::io::stdout().is_terminal() {
eprintln!("skipping: no TTY attached");
return;
}
let (cols, rows) = Terminal::size().expect("terminal size should succeed");
assert!(cols > 0, "columns should be > 0, got {cols}");
assert!(rows > 0, "rows should be > 0, got {rows}");
}
#[test]
fn restore_terminal_safe_without_init() {
for _ in 0..10 {
restore_terminal();
}
}
#[test]
fn restore_terminal_preserves_stdout() {
restore_terminal();
let result = std::io::stdout().write_all(b"test");
assert!(
result.is_ok(),
"stdout should remain writable after restore"
);
}
#[test]
fn panic_hook_does_not_interfere_with_normal_operation() {
use std::io::IsTerminal;
HOOK_INSTALLED.call_once(|| {
let original_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
restore_terminal();
original_hook(info);
}));
});
restore_terminal();
if !std::io::stdout().is_terminal() {
eprintln!("skipping terminal size check: no TTY attached");
return;
}
let (cols, rows) = Terminal::size().expect("size should work");
assert!(cols > 0);
assert!(rows > 0);
}
#[test]
fn multiple_hooks_do_not_stack() {
let hook1 = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
restore_terminal();
hook1(info);
}));
HOOK_INSTALLED.call_once(|| {
let original_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
restore_terminal();
original_hook(info);
}));
});
let _ = std::panic::take_hook();
}
}