use std::io::BufWriter;
use std::ops::{Deref, DerefMut};
use std::sync::Once;
use color_eyre::eyre::Result;
use crossterm::event::KeyEventKind;
use crossterm::event::{DisableBracketedPaste, EnableBracketedPaste};
use crossterm::event::{DisableMouseCapture, EnableMouseCapture};
use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen};
use crossterm::{self, cursor};
use futures::{FutureExt as _, StreamExt as _};
use ratatui::layout::Rect;
use ratatui::prelude::{Backend, CrosstermBackend};
use ratatui::{TerminalOptions, Viewport};
use tokio::sync::mpsc::{Receiver, Sender, channel};
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
use super::util::cursor_pos_from_tty;
use super::{Event, Size};
const TICK_RATE: f64 = 12.;
static PANIC_HOOK_SET: Once = Once::new();
pub struct Tui<B: Backend = ratatui::backend::CrosstermBackend<BufWriter<std::io::Stderr>>>
where
B::Error: Send + Sync + 'static,
{
pub terminal: ratatui::Terminal<B>,
pub task: Option<JoinHandle<()>>,
pub event_rx: Receiver<Event>,
pub event_tx: Sender<Event>,
pub tick_rate: f64,
pub cancellation_token: CancellationToken,
pub is_fullscreen: bool,
}
impl Tui {
pub fn new_with_height(height: Size) -> Result<Self> {
let backend = CrosstermBackend::new(std::io::BufWriter::new(std::io::stderr()));
Self::new_with_height_and_backend(backend, height)
}
}
impl<B: Backend> Tui<B>
where
B::Error: Send + Sync + 'static,
{
pub fn new_with_height_and_backend(backend: B, height: Size) -> Result<Self> {
let event_channel = channel(1024 * 1024);
let term_height = backend.size().expect("Failed to get terminal height").height;
let lines = match height {
Size::Percent(100) => None,
Size::Fixed(lines) => Some(lines),
Size::Percent(p) => Some(term_height * p / 100),
};
let viewport = if let Some(mut height) = lines {
let cursor_pos = cursor_pos_from_tty()?;
let mut y = cursor_pos.1 - 1;
height = height.min(term_height);
if term_height - cursor_pos.1 < height {
let to_scroll = height - (term_height - cursor_pos.1) - 1;
crossterm::execute!(std::io::stderr(), crossterm::terminal::ScrollUp(to_scroll))?;
y = y.saturating_sub(to_scroll);
}
Viewport::Fixed(Rect::new(
0,
y,
backend.size().expect("Failed to get terminal width").width - 1,
height,
))
} else {
Viewport::Fullscreen
};
set_panic_hook();
Ok(Self {
terminal: ratatui::Terminal::with_options(backend, TerminalOptions { viewport })?,
task: None,
event_rx: event_channel.1,
event_tx: event_channel.0,
tick_rate: TICK_RATE,
cancellation_token: CancellationToken::default(),
is_fullscreen: lines.is_none(),
})
}
#[cfg(any(test, feature = "test-utils"))]
pub fn new_for_test(backend: B) -> Result<Self> {
let event_channel = channel(1024 * 1024);
Ok(Self {
terminal: ratatui::Terminal::new(backend)?,
task: None,
event_rx: event_channel.1,
event_tx: event_channel.0,
tick_rate: TICK_RATE,
cancellation_token: CancellationToken::default(),
is_fullscreen: true,
})
}
pub fn enter(&mut self) -> Result<()> {
crossterm::terminal::enable_raw_mode()?;
crossterm::execute!(std::io::stderr(), EnableMouseCapture, EnableBracketedPaste)?;
if self.is_fullscreen {
crossterm::execute!(std::io::stderr(), EnterAlternateScreen, cursor::Hide)?;
}
self.start();
Ok(())
}
pub fn exit(&mut self) -> Result<()> {
self.stop();
if crossterm::terminal::is_raw_mode_enabled()? {
crossterm::execute!(
std::io::stderr(),
DisableMouseCapture,
DisableBracketedPaste,
LeaveAlternateScreen,
cursor::Show
)?;
crossterm::terminal::disable_raw_mode()?;
}
if !self.is_fullscreen {
self.clear()?;
let area = self.get_frame().area();
let orig = ratatui::layout::Position { x: area.x, y: area.y };
self.set_cursor_position(orig)?;
};
Ok(())
}
pub fn stop(&self) {
self.cancel();
}
pub fn cancel(&self) {
self.cancellation_token.cancel();
}
pub fn start(&mut self) {
let tick_delay = std::time::Duration::from_secs_f64(1.0 / self.tick_rate);
let event_tx_clone = self.event_tx.clone();
let cancellation_token_clone = self.cancellation_token.clone();
if self.task.is_some() {
self.cancel();
}
self.task = Some(tokio::spawn(async move {
let mut reader = crossterm::event::EventStream::new();
let mut tick_interval = tokio::time::interval(tick_delay);
loop {
let tick_delay = tick_interval.tick();
let crossterm_event = reader.next().fuse();
tokio::select! {
_ = cancellation_token_clone.cancelled() => {
break;
}
maybe_event = crossterm_event => {
match maybe_event {
Some(Ok(crossterm::event::Event::Key(key))) => {
if key.kind == KeyEventKind::Press {
_ = event_tx_clone.try_send(Event::Key(key));
}
}
Some(Ok(crossterm::event::Event::Paste(text))) => {
_ = event_tx_clone.try_send(Event::Paste(text));
}
Some(Ok(crossterm::event::Event::Mouse(mouse))) => {
_ = event_tx_clone.try_send(Event::Mouse(mouse));
}
Some(Ok(crossterm::event::Event::Resize(cols, rows))) => {
_ = event_tx_clone.try_send(Event::Resize(cols, rows));
_ = event_tx_clone.try_send(Event::Render);
}
Some(Err(e)) => {
_ = event_tx_clone.try_send(Event::Error(e.to_string()));
}
None | Some(Ok(_)) => {},
}
},
_ = tick_delay => {
_ = event_tx_clone.try_send(Event::Heartbeat);
},
}
}
}));
}
pub async fn next(&mut self) -> Option<Event> {
self.event_rx.recv().await
}
}
impl<B: Backend> Deref for Tui<B>
where
B::Error: Send + Sync + 'static,
{
type Target = ratatui::Terminal<B>;
fn deref(&self) -> &Self::Target {
&self.terminal
}
}
impl<B: Backend> DerefMut for Tui<B>
where
B::Error: Send + Sync + 'static,
{
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.terminal
}
}
impl<B: Backend> Drop for Tui<B>
where
B::Error: Send + Sync + 'static,
{
fn drop(&mut self) {
if let Some(t) = self.task.take() {
t.abort();
}
let _ = self.exit();
}
}
fn set_panic_hook() {
PANIC_HOOK_SET.call_once(|| {
let hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic_info| {
ratatui::restore(); hook(panic_info);
}));
});
}