use std::{
io::{self, Stdout},
time::Duration,
};
use crossterm::{
event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers},
execute,
terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode,
enable_raw_mode,
},
};
#[cfg(not(windows))]
use crossterm::event::{DisableBracketedPaste, EnableBracketedPaste};
use ratatui::{Frame, Terminal, backend::CrosstermBackend};
type Backend = CrosstermBackend<Stdout>;
pub enum TuiEvent {
Key(KeyEvent),
Paste(String),
Resize,
Quit,
}
pub struct Tui {
terminal: Terminal<Backend>,
on_enter: Box<dyn Fn()>,
on_leave: Box<dyn Fn()>,
}
impl Tui {
pub fn new() -> io::Result<Self> {
Self::with_hooks(|| {}, || {})
}
pub fn with_hooks(
on_enter: impl Fn() + 'static,
on_leave: impl Fn() + 'static,
) -> io::Result<Self> {
enable_raw_mode()?;
let mut out = io::stdout();
enter_screen(&mut out)?;
let on_enter: Box<dyn Fn()> = Box::new(on_enter);
let on_leave: Box<dyn Fn()> = Box::new(on_leave);
on_enter();
let terminal = Terminal::new(CrosstermBackend::new(out))?;
Ok(Self {
terminal,
on_enter,
on_leave,
})
}
pub fn draw<F: FnOnce(&mut Frame)>(&mut self, render: F) -> io::Result<()> {
self.terminal.draw(render)?;
Ok(())
}
pub fn read_event(&self) -> io::Result<TuiEvent> {
loop {
let event = event::read()?;
if let Some(classified) = classify(&event) {
return Ok(classified);
}
}
}
pub fn poll_event(
&self,
timeout: Duration,
) -> io::Result<Option<TuiEvent>> {
if event::poll(timeout)? {
let event = event::read()?;
return Ok(classify(&event));
}
Ok(None)
}
pub fn suspend<T>(&mut self, action: impl FnOnce() -> T) -> io::Result<T> {
restore()?;
(self.on_leave)();
let result = action();
enable_raw_mode()?;
enter_screen(&mut io::stdout())?;
(self.on_enter)();
self.terminal.clear()?;
Ok(result)
}
}
impl Drop for Tui {
fn drop(&mut self) {
if let Err(error) = restore() {
log::error!("failed to restore the terminal on exit: {error}");
}
(self.on_leave)();
}
}
fn classify(event: &Event) -> Option<TuiEvent> {
match event {
Event::Resize(_, _) => Some(TuiEvent::Resize),
Event::Paste(text) => Some(TuiEvent::Paste(normalize_newlines(text))),
Event::Key(key) if key.kind != KeyEventKind::Release => {
if is_global_quit(key) {
Some(TuiEvent::Quit)
} else {
Some(TuiEvent::Key(*key))
}
}
_ => None,
}
}
fn normalize_newlines(text: &str) -> String {
text.replace("\r\n", "\n").replace('\r', "\n")
}
fn is_global_quit(key: &KeyEvent) -> bool {
key.code == KeyCode::Char('q')
&& key.modifiers.contains(KeyModifiers::CONTROL)
}
fn enter_screen(out: &mut Stdout) -> io::Result<()> {
execute!(out, EnterAlternateScreen)?;
#[cfg(not(windows))]
execute!(out, EnableBracketedPaste)?;
Ok(())
}
fn leave_screen(out: &mut Stdout) -> io::Result<()> {
#[cfg(not(windows))]
execute!(out, DisableBracketedPaste)?;
execute!(out, LeaveAlternateScreen)?;
Ok(())
}
fn restore() -> io::Result<()> {
leave_screen(&mut io::stdout())?;
disable_raw_mode()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_newlines_collapses_crlf_and_lone_cr() {
assert_eq!(normalize_newlines("a\r\nb\rc\nd"), "a\nb\nc\nd");
}
#[test]
fn classify_paste_normalizes_newlines() {
let event = Event::Paste("a\r\nb".to_string());
match classify(&event) {
Some(TuiEvent::Paste(text)) => assert_eq!(text, "a\nb"),
_ => panic!("expected a normalized paste event"),
}
}
}