My personal-knowledge-system, with deeply integrated task tracking and long term goal planning capabilities.
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}