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