My personal-knowledge-system, with deeply integrated task tracking and long term goal planning capabilities.
at main 279 lines 8.0 kB view raw
1use std::{ 2 io::{Stdout, stdout}, 3 ops::{Deref, DerefMut}, 4 time::Duration, 5}; 6 7use color_eyre::eyre::Result; 8use crossterm::{ 9 cursor, 10 event::{ 11 DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, 12 EventStream, KeyEvent, KeyEventKind, MouseEvent, 13 }, 14 terminal::{EnterAlternateScreen, LeaveAlternateScreen, enable_raw_mode}, 15}; 16use futures::{FutureExt as _, StreamExt as _}; 17use ratatui::{Terminal, prelude::CrosstermBackend}; 18use tokio::{ 19 sync::mpsc::{self, UnboundedReceiver, UnboundedSender}, 20 task::JoinHandle, 21 time::interval, 22}; 23use tokio_util::sync::CancellationToken; 24use tracing::error; 25 26/// Events processed by the whole application. 27#[expect(dead_code)] 28#[derive(Debug, Clone)] 29pub enum Event { 30 /// Application initialized 31 Init, 32 33 /// Application quit 34 Quit, 35 36 /// Application error 37 Error, 38 39 /// Application closed 40 Closed, 41 42 /// Tick = input refresh rate 43 Tick, 44 45 /// Render application 46 Render, 47 48 /// User enters application 49 FocusGained, 50 51 /// User leaves application 52 FocusLost, 53 54 /// Paste buffer 55 Paste(String), 56 57 /// any key event 58 Key(KeyEvent), 59 60 /// any mouse event 61 Mouse(MouseEvent), 62 63 /// Application resize 64 Resize(u16, u16), 65} 66 67/// A TUI which supports the general things you would want out of a TUI abstraction. 68pub struct Tui { 69 pub terminal: Terminal<CrosstermBackend<Stdout>>, 70 pub task: JoinHandle<()>, 71 pub cancellation_token: CancellationToken, 72 pub event_rx: UnboundedReceiver<Event>, 73 pub event_tx: UnboundedSender<Event>, 74 pub frame_rate: f64, 75 pub tick_rate: f64, 76 pub mouse_enabled: bool, 77 pub paste_enabled: bool, 78} 79 80#[expect(dead_code)] 81impl Tui { 82 /// Creates a new TUI. 83 pub fn new() -> Result<Self> { 84 let (event_tx, event_rx) = mpsc::unbounded_channel(); 85 Ok(Self { 86 terminal: Terminal::new(CrosstermBackend::new(stdout()))?, 87 task: tokio::spawn(async {}), 88 cancellation_token: CancellationToken::new(), 89 event_rx, 90 event_tx, 91 frame_rate: 60.0, 92 tick_rate: 4.0, 93 mouse_enabled: false, 94 paste_enabled: false, 95 }) 96 } 97 98 /// Set the tick rate, which is how often the TUI should 99 /// source events per second. 100 pub const fn with_tick_rate(mut self, tick_rate: f64) -> Self { 101 self.tick_rate = tick_rate; 102 self 103 } 104 105 /// Set the frame rate. 106 pub const fn with_frame_rate(mut self, frame_rate: f64) -> Self { 107 self.frame_rate = frame_rate; 108 self 109 } 110 111 /// Enable mouse interactions. 112 pub const fn set_mouse_enable(mut self, mouse_enabled: bool) -> Self { 113 self.mouse_enabled = mouse_enabled; 114 self 115 } 116 117 /// Enable pasting into the TUI. 118 pub const fn set_paste_enabled(mut self, paste_enabled: bool) -> Self { 119 self.paste_enabled = paste_enabled; 120 self 121 } 122 123 /// Begin the TUI event loop 124 pub fn start(&mut self) { 125 self.cancel(); 126 127 self.cancellation_token = CancellationToken::new(); 128 let event_loop = Self::event_loop( 129 self.event_tx.clone(), 130 self.cancellation_token.clone(), 131 self.tick_rate, 132 self.frame_rate, 133 ); 134 self.task = tokio::spawn(async { 135 event_loop.await; 136 }); 137 } 138 139 /// The event-loop for the TUI which sources events from crossterm. 140 async fn event_loop( 141 event_tx: UnboundedSender<Event>, 142 cancellation_token: CancellationToken, 143 tick_rate: f64, 144 frame_rate: f64, 145 ) { 146 use crossterm::event::Event as CrosstermEvent; 147 148 let mut event_stream = EventStream::new(); 149 let mut tick_interval = interval(Duration::from_secs_f64(1.0 / tick_rate)); 150 let mut render_interval = interval(Duration::from_secs_f64(1.0 / frame_rate)); 151 152 event_tx 153 .send(Event::Init) 154 .expect("Tui::event_loop: Failed to send init event."); 155 loop { 156 let event = tokio::select! { 157 () = cancellation_token.cancelled() => { 158 break; 159 } 160 _ = tick_interval.tick() => Event::Tick, 161 _ = render_interval.tick() => Event::Render, 162 crossterm_event = event_stream.next().fuse() => match crossterm_event { 163 Some(Ok(event)) => match event { 164 // we only care about press down events, 165 // not doing anything related to up / down keypresses 166 CrosstermEvent::Key(key) if key.kind == KeyEventKind::Press => Event::Key(key), 167 CrosstermEvent::Key(_) => continue, 168 CrosstermEvent::Mouse(mouse) => Event::Mouse(mouse), 169 CrosstermEvent::Resize(x, y) => Event::Resize(x, y), 170 CrosstermEvent::FocusLost => {Event::FocusLost }, 171 CrosstermEvent::FocusGained => {Event::FocusGained }, 172 CrosstermEvent::Paste(s)=> {Event::Paste(s)}, 173 174 } 175 Some(Err(_)) => Event::Error, 176 None => break, 177 } 178 }; 179 180 if event_tx.send(event).is_err() { 181 break; 182 } 183 } 184 185 cancellation_token.cancel(); 186 } 187 188 /// Stops the TUI by canceling the event-loop. 189 pub fn stop(&self) { 190 self.cancel(); 191 let mut counter = 0; 192 while !self.task.is_finished() { 193 std::thread::sleep(Duration::from_millis(1)); 194 counter += 1; 195 if counter > 50 { 196 self.task.abort(); 197 } 198 if counter > 100 { 199 error!("Failed to abort task in 100 milliseconds for some reason"); 200 break; 201 } 202 } 203 } 204 205 // Enters into the TUI by enabling alternate screen and starting event-loop. 206 pub fn enter(&mut self) -> Result<()> { 207 enable_raw_mode()?; 208 crossterm::execute!(stdout(), EnterAlternateScreen, cursor::Hide)?; 209 if self.mouse_enabled { 210 crossterm::execute!(stdout(), EnableMouseCapture)?; 211 } 212 if self.paste_enabled { 213 crossterm::execute!(stdout(), EnableBracketedPaste)?; 214 } 215 self.start(); 216 Ok(()) 217 } 218 219 /// Exits the tui, by leaving alternate screen. 220 pub fn exit(&mut self) -> color_eyre::Result<()> { 221 self.stop(); 222 if crossterm::terminal::is_raw_mode_enabled()? { 223 self.flush()?; 224 if self.paste_enabled { 225 crossterm::execute!(stdout(), DisableBracketedPaste)?; 226 } 227 if self.mouse_enabled { 228 crossterm::execute!(stdout(), DisableMouseCapture)?; 229 } 230 crossterm::execute!(stdout(), LeaveAlternateScreen, cursor::Show)?; 231 crossterm::terminal::disable_raw_mode()?; 232 } 233 Ok(()) 234 } 235 236 /// Cancel the internal event-loop. 237 pub fn cancel(&self) { 238 self.cancellation_token.cancel(); 239 } 240 241 /// Suspend the TUI. 242 pub fn suspend(&mut self) -> color_eyre::Result<()> { 243 self.exit()?; 244 #[cfg(not(windows))] 245 signal_hook::low_level::raise(signal_hook::consts::signal::SIGTSTP)?; 246 Ok(()) 247 } 248 249 /// Resume the TUI. 250 pub fn resume(&mut self) -> color_eyre::Result<()> { 251 self.enter()?; 252 Ok(()) 253 } 254 255 /// Get the next event. 256 pub async fn next_event(&mut self) -> Option<Event> { 257 self.event_rx.recv().await 258 } 259} 260 261impl Deref for Tui { 262 type Target = ratatui::Terminal<CrosstermBackend<Stdout>>; 263 264 fn deref(&self) -> &Self::Target { 265 &self.terminal 266 } 267} 268 269impl DerefMut for Tui { 270 fn deref_mut(&mut self) -> &mut Self::Target { 271 &mut self.terminal 272 } 273} 274 275impl Drop for Tui { 276 fn drop(&mut self) { 277 self.exit().unwrap(); 278 } 279}