use std::{
cmp,
io::{self, Stdout, stdout},
ops::{Deref, DerefMut},
thread,
time::Duration,
};
use color_eyre::Result;
use crossterm::{
cursor,
event::{
self, Event as CrosstermEvent, EventStream, KeyCode, KeyEvent, KeyEventKind, KeyModifiers,
KeyboardEnhancementFlags, MouseEvent,
},
style,
terminal::{self, ClearType, supports_keyboard_enhancement},
};
use futures_util::{FutureExt, StreamExt};
use ratatui::{CompletedFrame, Frame, Terminal, backend::CrosstermBackend as Backend, layout::Rect};
use serde::{Deserialize, Serialize};
use tokio::{
sync::mpsc::{self, UnboundedReceiver, UnboundedSender},
task::JoinHandle,
time::interval,
};
use tokio_util::sync::CancellationToken;
use tracing::instrument;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum Event {
Tick,
Render,
FocusGained,
FocusLost,
Paste(String),
Key(KeyEvent),
Mouse(MouseEvent),
Resize(u16, u16),
}
pub struct Tui {
stdout: Stdout,
terminal: Terminal<Backend<Stdout>>,
task: JoinHandle<()>,
loop_cancellation_token: CancellationToken,
global_cancellation_token: CancellationToken,
event_rx: UnboundedReceiver<Event>,
event_tx: UnboundedSender<Event>,
frame_rate: f64,
tick_rate: f64,
mouse: bool,
paste: bool,
state: Option<State>,
}
#[derive(Clone, Copy)]
enum State {
FullScreen(bool),
Inline(bool, InlineTuiContext),
}
#[derive(Clone, Copy)]
struct InlineTuiContext {
min_height: u16,
x: u16,
y: u16,
restore_cursor_x: u16,
restore_cursor_y: u16,
}
#[allow(dead_code, reason = "provide a useful interface, even if not required yet")]
impl Tui {
pub fn new(cancellation_token: CancellationToken) -> Result<Self> {
let (event_tx, event_rx) = mpsc::unbounded_channel();
Ok(Self {
stdout: stdout(),
terminal: Terminal::new(Backend::new(stdout()))?,
task: tokio::spawn(async {}),
loop_cancellation_token: CancellationToken::new(),
global_cancellation_token: cancellation_token,
event_rx,
event_tx,
frame_rate: 60.0,
tick_rate: 10.0,
mouse: false,
paste: false,
state: None,
})
}
pub fn tick_rate(mut self, tick_rate: f64) -> Self {
self.state.is_some().then(|| panic!("Can't updated an entered TUI"));
self.tick_rate = tick_rate;
self
}
pub fn frame_rate(mut self, frame_rate: f64) -> Self {
self.state.is_some().then(|| panic!("Can't updated an entered TUI"));
self.frame_rate = frame_rate;
self
}
pub fn mouse(mut self, mouse: bool) -> Self {
self.state.is_some().then(|| panic!("Can't updated an entered TUI"));
self.mouse = mouse;
self
}
pub fn paste(mut self, paste: bool) -> Self {
self.state.is_some().then(|| panic!("Can't updated an entered TUI"));
self.paste = paste;
self
}
pub async fn next_event(&mut self) -> Option<Event> {
self.event_rx.recv().await
}
pub fn enter(&mut self) -> Result<()> {
self.state.is_some().then(|| panic!("Can't re-enter on a TUI"));
tracing::trace!(mouse = self.mouse, paste = self.paste, "Entering a full-screen TUI");
let keyboard_enhancement_supported = self.enter_raw_mode(true)?;
self.state = Some(State::FullScreen(keyboard_enhancement_supported));
self.start();
Ok(())
}
pub fn enter_inline(&mut self, extra_line: bool, min_height: u16) -> Result<()> {
self.state.is_some().then(|| panic!("Can't re-enter on a TUI"));
let extra_line = extra_line as u16;
tracing::trace!(
mouse = self.mouse,
paste = self.paste,
extra_line,
min_height,
"Entering an inline TUI"
);
let (orig_cursor_x, orig_cursor_y) = cursor::position()?;
tracing::trace!("Initial cursor position: ({orig_cursor_x},{orig_cursor_y})");
crossterm::execute!(
self.stdout,
style::Print("\n".repeat((min_height + extra_line) as usize)),
cursor::MoveToPreviousLine(min_height),
terminal::Clear(ClearType::FromCursorDown)
)?;
let (cursor_x, cursor_y) = cursor::position()?;
let restore_cursor_x = orig_cursor_x;
let restore_cursor_y = cmp::min(orig_cursor_y, cmp::max(cursor_y, extra_line) - extra_line);
tracing::trace!("Cursor shall be restored at: ({restore_cursor_x},{restore_cursor_y})");
let keyboard_enhancement_supported = self.enter_raw_mode(false)?;
self.state = Some(State::Inline(
keyboard_enhancement_supported,
InlineTuiContext {
min_height,
x: cursor_x,
y: cursor_y,
restore_cursor_x,
restore_cursor_y,
},
));
self.start();
Ok(())
}
pub fn render<F>(&mut self, render_callback: F) -> io::Result<CompletedFrame<'_>>
where
F: FnOnce(&mut Frame, Rect),
{
let Some(state) = self.state else {
return Err(io::Error::other("Cannot render on a non-entered TUI"));
};
self.terminal.draw(|frame| {
let area = match state {
State::FullScreen(_) => frame.area(),
State::Inline(_, inline) => {
let frame = frame.area();
let min_height = cmp::min(frame.height, inline.min_height);
let available_height = frame.height - inline.y;
let height = cmp::max(min_height, available_height);
let width = frame.width - inline.x;
Rect::new(inline.x, inline.y, width, height)
}
};
render_callback(frame, area);
})
}
pub fn exit(mut self) -> Result<()> {
self.state.is_none().then(|| panic!("Cannot exit a non-entered TUI"));
self.stop();
self.restore_terminal()
}
fn restore_terminal(&mut self) -> Result<()> {
match self.state.take() {
None => (),
Some(State::FullScreen(keyboard_enhancement_supported)) => {
tracing::trace!("Leaving the full-screen TUI");
self.flush()?;
self.exit_raw_mode(true, keyboard_enhancement_supported)?;
}
Some(State::Inline(keyboard_enhancement_supported, ctx)) => {
tracing::trace!("Leaving the inline TUI");
self.flush()?;
self.exit_raw_mode(false, keyboard_enhancement_supported)?;
crossterm::execute!(
self.stdout,
cursor::MoveTo(ctx.restore_cursor_x, ctx.restore_cursor_y),
terminal::Clear(ClearType::FromCursorDown)
)?;
}
}
Ok(())
}
fn enter_raw_mode(&mut self, alt_screen: bool) -> Result<bool> {
terminal::enable_raw_mode()?;
crossterm::execute!(self.stdout, cursor::Hide)?;
if alt_screen {
crossterm::execute!(self.stdout, terminal::EnterAlternateScreen)?;
}
if self.mouse {
crossterm::execute!(self.stdout, event::EnableMouseCapture)?;
}
if self.paste {
crossterm::execute!(self.stdout, event::EnableBracketedPaste)?;
}
tracing::trace!("Checking keyboard enhancement support");
let keyboard_enhancement_supported = supports_keyboard_enhancement()
.inspect_err(|err| tracing::error!("{err}"))
.unwrap_or(false);
if keyboard_enhancement_supported {
tracing::trace!("Keyboard enhancement flags enabled");
crossterm::execute!(
self.stdout,
event::PushKeyboardEnhancementFlags(
KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
| KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES
| KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS
),
)?;
} else {
tracing::trace!("Keyboard enhancement flags not enabled");
}
Ok(keyboard_enhancement_supported)
}
fn exit_raw_mode(&mut self, alt_screen: bool, keyboard_enhancement_supported: bool) -> Result<()> {
if keyboard_enhancement_supported {
crossterm::execute!(self.stdout, event::PopKeyboardEnhancementFlags)?;
}
if self.paste {
crossterm::execute!(self.stdout, event::DisableBracketedPaste)?;
}
if self.mouse {
crossterm::execute!(self.stdout, event::DisableMouseCapture)?;
}
if alt_screen {
crossterm::execute!(self.stdout, terminal::LeaveAlternateScreen)?;
}
crossterm::execute!(self.stdout, cursor::Show)?;
terminal::disable_raw_mode()?;
Ok(())
}
fn start(&mut self) {
self.cancel();
self.loop_cancellation_token = CancellationToken::new();
tracing::trace!(
tick_rate = self.tick_rate,
frame_rate = self.frame_rate,
"Starting the event loop"
);
self.task = tokio::spawn(Self::event_loop(
self.event_tx.clone(),
self.loop_cancellation_token.clone(),
self.global_cancellation_token.clone(),
self.tick_rate,
self.frame_rate,
));
}
#[instrument(skip_all)]
async fn event_loop(
event_tx: UnboundedSender<Event>,
loop_cancellation_token: CancellationToken,
global_cancellation_token: CancellationToken,
tick_rate: f64,
frame_rate: f64,
) {
let mut event_stream = EventStream::new();
let mut tick_interval = interval(Duration::from_secs_f64(1.0 / tick_rate));
let mut render_interval = interval(Duration::from_secs_f64(1.0 / frame_rate));
loop {
let event = tokio::select! {
biased;
_ = loop_cancellation_token.cancelled() => {
break;
}
_ = global_cancellation_token.cancelled() => {
break;
}
crossterm_event = event_stream.next().fuse() => match crossterm_event {
Some(Ok(event)) => match event {
CrosstermEvent::Key(KeyEvent {
code: KeyCode::Char('c'),
modifiers: KeyModifiers::CONTROL,
..
}) => {
tracing::debug!("Ctrl+C key event received in TUI, cancelling token");
global_cancellation_token.cancel();
continue;
}
CrosstermEvent::Key(key) if key.kind == KeyEventKind::Press => Event::Key(key),
CrosstermEvent::Mouse(mouse) => Event::Mouse(mouse),
CrosstermEvent::Resize(cols, rows) => Event::Resize(cols, rows),
CrosstermEvent::FocusLost => Event::FocusLost,
CrosstermEvent::FocusGained => Event::FocusGained,
CrosstermEvent::Paste(s) => Event::Paste(s),
_ => continue, }
Some(Err(err)) => {
tracing::error!("Error retrieving next crossterm event: {err}");
break;
},
None => break, },
_ = tick_interval.tick() => Event::Tick,
_ = render_interval.tick() => Event::Render,
};
if event_tx.send(event).is_err() {
break;
}
}
loop_cancellation_token.cancel();
}
fn stop(&self) {
if !self.task.is_finished() {
tracing::trace!("Stopping the event loop");
self.cancel();
let mut counter = 0;
while !self.task.is_finished() {
thread::sleep(Duration::from_millis(1));
counter += 1;
if counter > 50 {
tracing::debug!("Task hasn't finished in 50 milliseconds, attempting to abort");
self.task.abort();
}
if counter > 100 {
tracing::error!("Failed to abort task in 100 milliseconds for unknown reason");
break;
}
}
}
}
fn cancel(&self) {
self.loop_cancellation_token.cancel();
}
}
impl Deref for Tui {
type Target = Terminal<Backend<Stdout>>;
fn deref(&self) -> &Self::Target {
&self.terminal
}
}
impl DerefMut for Tui {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.terminal
}
}
impl Drop for Tui {
fn drop(&mut self) {
self.stop();
if let Err(err) = self.restore_terminal() {
tracing::error!("Failed to restore terminal state: {err:?}");
}
}
}