use std::{io, panic, sync::Once};
use color_eyre::Result;
use crossterm::{
event::{DisableMouseCapture, EnableMouseCapture},
terminal::{self, EnterAlternateScreen, LeaveAlternateScreen},
};
pub type CrosstermTerminal = ratatui::Terminal<ratatui::backend::CrosstermBackend<std::io::Stderr>>;
static PANIC_HOOK: Once = Once::new();
static SIGNAL_HOOKS: Once = Once::new();
const TERMINAL_RESTORE_SEQUENCES: &[u8] =
b"\x1b[?1049l\x1b[?25h\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1015l\x1b[?1006l";
fn restore_terminal_signal_safe() {
use std::io::Write;
use std::mem::ManuallyDrop;
use std::os::fd::FromRawFd;
let mut stderr = ManuallyDrop::new(unsafe { std::fs::File::from_raw_fd(2) });
let _ = stderr.write_all(TERMINAL_RESTORE_SEQUENCES);
}
fn register_signal_hooks() {
SIGNAL_HOOKS.call_once(|| {
use signal_hook::consts::{SIGHUP, SIGINT, SIGQUIT, SIGTERM};
for signal in [SIGTERM, SIGINT, SIGHUP, SIGQUIT] {
let _ = unsafe {
signal_hook::low_level::register(signal, move || {
restore_terminal_signal_safe();
let _ = signal_hook::low_level::emulate_default_handler(signal);
})
};
}
});
}
use crate::{app::App, event::EventHandler};
pub struct Tui {
terminal: CrosstermTerminal,
pub events: EventHandler,
}
impl Tui {
pub fn new(terminal: CrosstermTerminal, events: EventHandler) -> Self {
Self { terminal, events }
}
pub fn enter_alternate_screen() -> Result<()> {
terminal::enable_raw_mode()?;
crossterm::execute!(io::stderr(), EnterAlternateScreen, EnableMouseCapture)?;
Ok(())
}
pub fn enter(&mut self) -> Result<()> {
Self::enter_alternate_screen()?;
self.events.start();
PANIC_HOOK.call_once(|| {
let panic_hook = panic::take_hook();
panic::set_hook(Box::new(move |panic| {
let _ = Self::reset();
panic_hook(panic);
}));
});
register_signal_hooks();
self.terminal.hide_cursor()?;
self.terminal.clear()?;
Ok(())
}
pub fn draw(&mut self, app: &mut App) -> Result<()> {
if app.should_redraw {
self.terminal.clear()?;
app.should_redraw = false;
}
self.terminal.draw(|frame| app.render(frame))?;
Ok(())
}
pub fn reset() -> Result<()> {
terminal::disable_raw_mode()?;
crossterm::execute!(io::stderr(), LeaveAlternateScreen, DisableMouseCapture)?;
Ok(())
}
pub fn exit(&mut self) -> Result<()> {
Self::reset()?;
self.terminal.show_cursor()?;
self.events.stop();
Ok(())
}
}
impl Drop for Tui {
fn drop(&mut self) {
let _ = Self::reset();
let _ = self.terminal.show_cursor();
self.events.stop();
}
}
#[cfg(test)]
mod tests {
use super::TERMINAL_RESTORE_SEQUENCES;
fn contains(haystack: &[u8], needle: &[u8]) -> bool {
haystack
.windows(needle.len())
.any(|window| window == needle)
}
#[test]
fn restore_sequences_leave_alternate_screen() {
assert!(contains(TERMINAL_RESTORE_SEQUENCES, b"\x1b[?1049l"));
}
#[test]
fn restore_sequences_show_cursor() {
assert!(contains(TERMINAL_RESTORE_SEQUENCES, b"\x1b[?25h"));
}
#[test]
fn restore_sequences_disable_all_mouse_capture_modes() {
for mode in ["1000", "1002", "1003", "1015", "1006"] {
let sequence = format!("\x1b[?{mode}l");
assert!(
contains(TERMINAL_RESTORE_SEQUENCES, sequence.as_bytes()),
"missing disable sequence for mouse mode {mode}"
);
}
}
#[test]
fn restore_sequences_contain_no_enabling_private_modes() {
let text = String::from_utf8_lossy(TERMINAL_RESTORE_SEQUENCES);
for part in text.split('\x1b').filter(|part| !part.is_empty()) {
assert!(
part.ends_with('l') || part == "[?25h",
"unexpected sequence: ESC{part}"
);
}
}
}