1use std::{
2 cmp,
3 io::{self, Stdout, stdout},
4 ops::{Deref, DerefMut},
5 thread,
6 time::Duration,
7};
8
9use color_eyre::Result;
10use crossterm::{
11 cursor,
12 event::{
13 self, Event as CrosstermEvent, EventStream, KeyCode, KeyEvent, KeyEventKind, KeyModifiers,
14 KeyboardEnhancementFlags, MouseEvent,
15 },
16 style,
17 terminal::{self, ClearType, supports_keyboard_enhancement},
18};
19use futures_util::{FutureExt, StreamExt};
20use ratatui::{CompletedFrame, Frame, Terminal, backend::CrosstermBackend as Backend, layout::Rect};
21use serde::{Deserialize, Serialize};
22use tokio::{
23 sync::mpsc::{self, UnboundedReceiver, UnboundedSender},
24 task::JoinHandle,
25 time::interval,
26};
27use tokio_util::sync::CancellationToken;
28use tracing::instrument;
29
30#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
32pub enum Event {
33 Tick,
35 Render,
37 FocusGained,
39 FocusLost,
41 Paste(String),
43 Key(KeyEvent),
45 Mouse(MouseEvent),
47 Resize(u16, u16),
49}
50
51pub struct Tui {
53 stdout: Stdout,
54 terminal: Terminal<Backend<Stdout>>,
55 task: JoinHandle<()>,
56 loop_cancellation_token: CancellationToken,
57 global_cancellation_token: CancellationToken,
58 event_rx: UnboundedReceiver<Event>,
59 event_tx: UnboundedSender<Event>,
60 frame_rate: f64,
61 tick_rate: f64,
62 mouse: bool,
63 paste: bool,
64 state: Option<State>,
65}
66
67#[derive(Clone, Copy)]
68enum State {
69 FullScreen(bool),
70 Inline(bool, InlineTuiContext),
71}
72
73#[derive(Clone, Copy)]
74struct InlineTuiContext {
75 min_height: u16,
76 x: u16,
77 y: u16,
78 restore_cursor_x: u16,
79 restore_cursor_y: u16,
80}
81
82#[allow(dead_code, reason = "provide a useful interface, even if not required yet")]
83impl Tui {
84 pub fn new(cancellation_token: CancellationToken) -> Result<Self> {
86 let (event_tx, event_rx) = mpsc::unbounded_channel();
87 Ok(Self {
88 stdout: stdout(),
89 terminal: Terminal::new(Backend::new(stdout()))?,
90 task: tokio::spawn(async {}),
91 loop_cancellation_token: CancellationToken::new(),
92 global_cancellation_token: cancellation_token,
93 event_rx,
94 event_tx,
95 frame_rate: 60.0,
96 tick_rate: 10.0,
97 mouse: false,
98 paste: false,
99 state: None,
100 })
101 }
102
103 pub fn tick_rate(mut self, tick_rate: f64) -> Self {
107 self.state.is_some().then(|| panic!("Can't updated an entered TUI"));
108 self.tick_rate = tick_rate;
109 self
110 }
111
112 pub fn frame_rate(mut self, frame_rate: f64) -> Self {
116 self.state.is_some().then(|| panic!("Can't updated an entered TUI"));
117 self.frame_rate = frame_rate;
118 self
119 }
120
121 pub fn mouse(mut self, mouse: bool) -> Self {
125 self.state.is_some().then(|| panic!("Can't updated an entered TUI"));
126 self.mouse = mouse;
127 self
128 }
129
130 pub fn paste(mut self, paste: bool) -> Self {
134 self.state.is_some().then(|| panic!("Can't updated an entered TUI"));
135 self.paste = paste;
136 self
137 }
138
139 pub async fn next_event(&mut self) -> Option<Event> {
143 self.event_rx.recv().await
144 }
145
146 pub fn enter(&mut self) -> Result<()> {
148 self.state.is_some().then(|| panic!("Can't re-enter on a TUI"));
149
150 tracing::trace!(mouse = self.mouse, paste = self.paste, "Entering a full-screen TUI");
151
152 let keyboard_enhancement_supported = self.enter_raw_mode(true)?;
154
155 self.state = Some(State::FullScreen(keyboard_enhancement_supported));
157 self.start();
158
159 Ok(())
160 }
161
162 pub fn enter_inline(&mut self, extra_line: bool, min_height: u16) -> Result<()> {
164 self.state.is_some().then(|| panic!("Can't re-enter on a TUI"));
165 let extra_line = extra_line as u16;
166
167 tracing::trace!(
168 mouse = self.mouse,
169 paste = self.paste,
170 extra_line,
171 min_height,
172 "Entering an inline TUI"
173 );
174
175 let (orig_cursor_x, orig_cursor_y) = cursor::position()?;
177 tracing::trace!("Initial cursor position: ({orig_cursor_x},{orig_cursor_y})");
178 crossterm::execute!(
180 self.stdout,
181 style::Print("\n".repeat((min_height + extra_line) as usize)),
183 cursor::MoveToPreviousLine(min_height),
185 terminal::Clear(ClearType::FromCursorDown)
187 )?;
188 let (cursor_x, cursor_y) = cursor::position()?;
190 let restore_cursor_x = orig_cursor_x;
192 let restore_cursor_y = cmp::min(orig_cursor_y, cmp::max(cursor_y, extra_line) - extra_line);
193 tracing::trace!("Cursor shall be restored at: ({restore_cursor_x},{restore_cursor_y})");
194
195 let keyboard_enhancement_supported = self.enter_raw_mode(false)?;
197
198 self.state = Some(State::Inline(
200 keyboard_enhancement_supported,
201 InlineTuiContext {
202 min_height,
203 x: cursor_x,
204 y: cursor_y,
205 restore_cursor_x,
206 restore_cursor_y,
207 },
208 ));
209 self.start();
210
211 Ok(())
212 }
213
214 pub fn render<F>(&mut self, render_callback: F) -> io::Result<CompletedFrame<'_>>
219 where
220 F: FnOnce(&mut Frame, Rect),
221 {
222 let Some(state) = self.state else {
223 return Err(io::Error::other("Cannot render on a non-entered TUI"));
224 };
225
226 self.terminal.draw(|frame| {
227 let area = match state {
228 State::FullScreen(_) => frame.area(),
229 State::Inline(_, inline) => {
230 let frame = frame.area();
231 let min_height = cmp::min(frame.height, inline.min_height);
232 let available_height = frame.height - inline.y;
233 let height = cmp::max(min_height, available_height);
234 let width = frame.width - inline.x;
235 Rect::new(inline.x, inline.y, width, height)
236 }
237 };
238
239 render_callback(frame, area);
240 })
241 }
242
243 pub fn exit(mut self) -> Result<()> {
245 self.state.is_none().then(|| panic!("Cannot exit a non-entered TUI"));
246 self.stop();
247 self.restore_terminal()
248 }
249
250 fn restore_terminal(&mut self) -> Result<()> {
251 match self.state.take() {
252 None => (),
253 Some(State::FullScreen(keyboard_enhancement_supported)) => {
254 tracing::trace!("Leaving the full-screen TUI");
255 self.flush()?;
256 self.exit_raw_mode(true, keyboard_enhancement_supported)?;
257 }
258 Some(State::Inline(keyboard_enhancement_supported, ctx)) => {
259 tracing::trace!("Leaving the inline TUI");
260 self.flush()?;
261 self.exit_raw_mode(false, keyboard_enhancement_supported)?;
262 crossterm::execute!(
263 self.stdout,
264 cursor::MoveTo(ctx.restore_cursor_x, ctx.restore_cursor_y),
265 terminal::Clear(ClearType::FromCursorDown)
266 )?;
267 }
268 }
269
270 Ok(())
271 }
272
273 fn enter_raw_mode(&mut self, alt_screen: bool) -> Result<bool> {
274 terminal::enable_raw_mode()?;
275 crossterm::execute!(self.stdout, cursor::Hide)?;
276 if alt_screen {
277 crossterm::execute!(self.stdout, terminal::EnterAlternateScreen)?;
278 }
279 if self.mouse {
280 crossterm::execute!(self.stdout, event::EnableMouseCapture)?;
281 }
282 if self.paste {
283 crossterm::execute!(self.stdout, event::EnableBracketedPaste)?;
284 }
285
286 tracing::trace!("Checking keyboard enhancement support");
287 let keyboard_enhancement_supported = supports_keyboard_enhancement()
288 .inspect_err(|err| tracing::error!("{err}"))
289 .unwrap_or(false);
290
291 if keyboard_enhancement_supported {
292 tracing::trace!("Keyboard enhancement flags enabled");
293 crossterm::execute!(
294 self.stdout,
295 event::PushKeyboardEnhancementFlags(
296 KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
297 | KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES
298 | KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS
299 ),
300 )?;
301 } else {
302 tracing::trace!("Keyboard enhancement flags not enabled");
303 }
304
305 Ok(keyboard_enhancement_supported)
306 }
307
308 fn exit_raw_mode(&mut self, alt_screen: bool, keyboard_enhancement_supported: bool) -> Result<()> {
309 if keyboard_enhancement_supported {
310 crossterm::execute!(self.stdout, event::PopKeyboardEnhancementFlags)?;
311 }
312
313 if self.paste {
314 crossterm::execute!(self.stdout, event::DisableBracketedPaste)?;
315 }
316 if self.mouse {
317 crossterm::execute!(self.stdout, event::DisableMouseCapture)?;
318 }
319 if alt_screen {
320 crossterm::execute!(self.stdout, terminal::LeaveAlternateScreen)?;
321 }
322 crossterm::execute!(self.stdout, cursor::Show)?;
323 terminal::disable_raw_mode()?;
324
325 Ok(())
326 }
327
328 fn start(&mut self) {
329 self.cancel();
330 self.loop_cancellation_token = CancellationToken::new();
331
332 tracing::trace!(
333 tick_rate = self.tick_rate,
334 frame_rate = self.frame_rate,
335 "Starting the event loop"
336 );
337
338 self.task = tokio::spawn(Self::event_loop(
339 self.event_tx.clone(),
340 self.loop_cancellation_token.clone(),
341 self.global_cancellation_token.clone(),
342 self.tick_rate,
343 self.frame_rate,
344 ));
345 }
346
347 #[instrument(skip_all)]
348 async fn event_loop(
349 event_tx: UnboundedSender<Event>,
350 loop_cancellation_token: CancellationToken,
351 global_cancellation_token: CancellationToken,
352 tick_rate: f64,
353 frame_rate: f64,
354 ) {
355 let mut event_stream = EventStream::new();
356 let mut tick_interval = interval(Duration::from_secs_f64(1.0 / tick_rate));
357 let mut render_interval = interval(Duration::from_secs_f64(1.0 / frame_rate));
358
359 loop {
360 let event = tokio::select! {
361 biased;
363
364 _ = loop_cancellation_token.cancelled() => {
366 break;
367 }
368 _ = global_cancellation_token.cancelled() => {
369 break;
370 }
371
372 crossterm_event = event_stream.next().fuse() => match crossterm_event {
374 Some(Ok(event)) => match event {
375 CrosstermEvent::Key(KeyEvent {
377 code: KeyCode::Char('c'),
378 modifiers: KeyModifiers::CONTROL,
379 ..
380 }) => {
381 tracing::debug!("Ctrl+C key event received in TUI, cancelling token");
382 global_cancellation_token.cancel();
383 continue;
384 }
385 CrosstermEvent::Key(key) if key.kind == KeyEventKind::Press => Event::Key(key),
387 CrosstermEvent::Mouse(mouse) => Event::Mouse(mouse),
388 CrosstermEvent::Resize(cols, rows) => Event::Resize(cols, rows),
389 CrosstermEvent::FocusLost => Event::FocusLost,
390 CrosstermEvent::FocusGained => Event::FocusGained,
391 CrosstermEvent::Paste(s) => Event::Paste(s),
392 _ => continue, }
394 Some(Err(err)) => {
395 tracing::error!("Error retrieving next crossterm event: {err}");
396 break;
397 },
398 None => break, },
400
401 _ = tick_interval.tick() => Event::Tick,
403 _ = render_interval.tick() => Event::Render,
404 };
405
406 if event_tx.send(event).is_err() {
408 break;
410 }
411 }
412
413 loop_cancellation_token.cancel();
416 }
417
418 fn stop(&self) {
419 if !self.task.is_finished() {
420 tracing::trace!("Stopping the event loop");
421 self.cancel();
422 let mut counter = 0;
423 while !self.task.is_finished() {
424 thread::sleep(Duration::from_millis(1));
425 counter += 1;
426 if counter > 50 {
428 tracing::debug!("Task hasn't finished in 50 milliseconds, attempting to abort");
429 self.task.abort();
430 }
431 if counter > 100 {
433 tracing::error!("Failed to abort task in 100 milliseconds for unknown reason");
434 break;
435 }
436 }
437 }
438 }
439
440 fn cancel(&self) {
441 self.loop_cancellation_token.cancel();
442 }
443}
444
445impl Deref for Tui {
446 type Target = Terminal<Backend<Stdout>>;
447
448 fn deref(&self) -> &Self::Target {
449 &self.terminal
450 }
451}
452
453impl DerefMut for Tui {
454 fn deref_mut(&mut self) -> &mut Self::Target {
455 &mut self.terminal
456 }
457}
458
459impl Drop for Tui {
460 fn drop(&mut self) {
461 self.stop();
462 if let Err(err) = self.restore_terminal() {
463 tracing::error!("Failed to restore terminal state: {err:?}");
464 }
465 }
466}