#![allow(dead_code)] // TODO: Remove this once you start using the code use std::sync::Arc; use std::time::Duration; use backend::SshBackend; use color_eyre::Result; use crossterm::cursor; use crossterm::event::{ DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, KeyEvent, MouseEvent, }; use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen}; use serde::{Deserialize, Serialize}; use status::TuiStatus; use tokio::runtime::Handle; use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender}; use tokio::sync::{Mutex, RwLock}; use tokio::task::{block_in_place, JoinHandle}; use tokio::time::{interval, sleep, timeout}; use tokio_util::sync::CancellationToken; use tracing::error; pub(crate) mod backend; pub(crate) mod status; pub(crate) mod terminal; #[derive(Clone, Debug, Serialize, Deserialize)] pub enum Event { Init, Quit, Error, Closed, Tick, Render, FocusGained, FocusLost, Paste(String), Key(KeyEvent), Mouse(MouseEvent), Resize(u16, u16), } pub type Terminal = ratatui::Terminal; #[derive(Debug)] pub struct Tui { pub terminal: Arc>, pub task: JoinHandle<()>, pub cancellation_token: CancellationToken, pub event_rx: UnboundedReceiver, pub event_tx: UnboundedSender, pub resume_tx: Option>, pub status: Arc>, pub frame_rate: f64, pub tick_rate: f64, pub mouse: bool, pub paste: bool, } impl Tui { pub fn new(terminal: Arc>) -> Result { let (event_tx, event_rx) = mpsc::unbounded_channel(); Ok(Self { terminal, task: tokio::spawn(async {}), cancellation_token: CancellationToken::new(), event_rx, event_tx, resume_tx: Option::None, status: Arc::new(RwLock::new(TuiStatus::Active)), frame_rate: 60.0, tick_rate: 4.0, mouse: false, paste: false, }) } pub fn tick_rate(mut self, tick_rate: f64) -> Self { self.tick_rate = tick_rate; self } pub fn frame_rate(mut self, frame_rate: f64) -> Self { self.frame_rate = frame_rate; self } pub fn mouse(mut self, mouse: bool) -> Self { self.mouse = mouse; self } pub fn paste(mut self, paste: bool) -> Self { self.paste = paste; self } pub fn start(&mut self) { self.cancel(); // Cancel any existing task self.cancellation_token = CancellationToken::new(); let event_loop = Self::event_loop( self.status.clone(), self.event_tx.clone(), self.cancellation_token.clone(), self.tick_rate, self.frame_rate, ); self.task = tokio::spawn(async { event_loop.await; }); } async fn event_loop( status: Arc>, event_tx: UnboundedSender, cancellation_token: CancellationToken, tick_rate: f64, frame_rate: f64, ) { 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)); // if this fails, then it's likely a bug in the calling code event_tx.send(Event::Init).expect("failed to send init event"); let suspension_status = Arc::clone(&status); loop { let _guard = suspension_status.read().await; let event = tokio::select! { _ = cancellation_token.cancelled() => { break; } _ = tick_interval.tick() => Event::Tick, _ = render_interval.tick() => Event::Render, }; if event_tx.send(event).is_err() { // the receiver has been dropped, so there's no point in continuing the loop break; } } cancellation_token.cancel(); } async fn await_shutdown(&self) { while !self.task.is_finished() { sleep(Duration::from_millis(1)).await; } } pub async fn stop(&self) -> Result<()> { self.cancel(); let attempt_timeout = Duration::from_millis(50); let abort_shutdown = async { while !self.task.is_finished() { self.task.abort(); } }; if timeout(attempt_timeout, self.await_shutdown()).await.is_err() { timeout(attempt_timeout, abort_shutdown).await.inspect_err(|_| { error!("Failed to abort task in 100 milliseconds for unknown reason") })?; } Ok(()) } pub fn enter(&mut self) -> Result<()> { let mut term = self.terminal.try_lock()?; // crossterm::terminal::enable_raw_mode()?; // TODO: Enable raw mode for pty crossterm::execute!(term.backend_mut(), EnterAlternateScreen, cursor::Hide)?; if self.mouse { crossterm::execute!(term.backend_mut(), EnableMouseCapture)?; } if self.paste { crossterm::execute!(term.backend_mut(), EnableBracketedPaste)?; } drop(term); self.start(); Ok(()) } pub async fn exit(&mut self) -> Result<()> { self.stop().await?; // TODO: enable raw mode for pty let mut term = self.terminal.try_lock()?; term.flush()?; if self.paste { crossterm::execute!(term.backend_mut(), DisableBracketedPaste)?; } if self.mouse { crossterm::execute!(term.backend_mut(), DisableMouseCapture)?; } crossterm::execute!(term.backend_mut(), LeaveAlternateScreen, cursor::Show)?; Ok(()) } pub fn cancel(&self) { self.cancellation_token.cancel(); } pub async fn suspend(&mut self) -> Result> { // Exit the current Tui self.exit().await?; // Update the status and initialize a cancellation token let token = Arc::new(CancellationToken::new()); let suspension = Arc::new(Mutex::new(())); *self.status.write().await = TuiStatus::Suspended(Arc::clone(&suspension)); // Spawn a task holding on the lock until a notification interrupts it let status = Arc::clone(&self.status); let lock_release = Arc::clone(&token); tokio::task::spawn(async move { tokio::select! { _ = lock_release.cancelled() => { // Lock was released, update the status *status.write().await = TuiStatus::Active; } _ = tokio::time::sleep(tokio::time::Duration::from_secs(u64::MAX)) => { // Hold on to the lock until notified let _ = suspension.lock().await; } } }); Ok(token) } pub async fn next_event(&mut self) -> Option { self.event_rx.recv().await } } impl Drop for Tui { fn drop(&mut self) { block_in_place(|| { let handle = Handle::current(); let _ = handle.block_on(async { self.exit().await.inspect_err(|err| error!("Failed to exit Tui: {err}")) }); }) } }