intelli_shell/
tui.rs

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/// Events that can occur within the TUI application
28#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
29pub enum Event {
30    /// A periodic tick event, useful for time-based updates or animations
31    Tick,
32    /// A periodic render event, suggesting the UI should be redrawn
33    Render,
34    /// The terminal window gained focus
35    FocusGained,
36    /// The terminal window lost focus
37    FocusLost,
38    /// Text was pasted into the terminal (requires paste mode)
39    Paste(String),
40    /// A key was pressed
41    Key(KeyEvent),
42    /// A mouse event occurred (requires mouse capture)
43    Mouse(MouseEvent),
44    /// The terminal window was resized (columns and rows)
45    Resize(u16, u16),
46}
47
48/// Manages the terminal User Interface (TUI) lifecycle, event handling, and rendering
49pub 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    /// Constructs a new terminal ui with default settings
81    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    /// Sets the tick rate for the TUI.
99    ///
100    /// The tick rate determines how frequently `Event::Tick` is generated.
101    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    /// Sets the frame rate for the TUI.
108    ///
109    /// The frame rate determines how often `Event::Render` is emitted.
110    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    /// Enables or disables mouse event capture.
117    ///
118    /// If true, `Event::Mouse` events will be emitted.
119    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    /// Enables or disables bracketed paste mode.
126    ///
127    /// If true, `Event::Paste` events will be emitted.
128    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    /// Asynchronously retrieves the next event from the event queue.
135    ///
136    /// Returns `None` if the event channel has been closed (e.g., the event loop has stopped).
137    pub async fn next_event(&mut self) -> Option<Event> {
138        self.event_rx.recv().await
139    }
140
141    /// Prepares the terminal for full-screen TUI interaction and starts the event loop
142    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        // Enter raw mode and set up the terminal
148        let keyboard_enhancement_supported = self.enter_raw_mode(true)?;
149
150        // Store the state and start the event loop
151        self.state = Some(State::FullScreen(keyboard_enhancement_supported));
152        self.start();
153
154        Ok(())
155    }
156
157    /// Prepares the terminal for inline TUI interaction and starts the event loop
158    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        // Save the original cursor position
171        let (orig_cursor_x, orig_cursor_y) = cursor::position()?;
172        tracing::trace!("Initial cursor position: ({orig_cursor_x},{orig_cursor_y})");
173        // Prepare the area for the inline content
174        crossterm::execute!(
175            self.stdout,
176            // Fill in the minimum height (plus the extra line), the cursor will end up at the end
177            style::Print("\n".repeat((min_height + extra_line) as usize)),
178            // Move the cursor back the min height (without the extra lines)
179            cursor::MoveToPreviousLine(min_height),
180            // And clear the lines below
181            terminal::Clear(ClearType::FromCursorDown)
182        )?;
183        // Retrieve the new cursor position, which defines the starting coords for the area to render in
184        let (cursor_x, cursor_y) = cursor::position()?;
185        // Calculate where the cursor should be restored to
186        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        // Enter raw mode and set up the terminal
191        let keyboard_enhancement_supported = self.enter_raw_mode(false)?;
192
193        // Store the state and start the event loop
194        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    /// Renders the TUI using the provided callback function.
210    ///
211    /// The callback receives a mutable reference to the `Frame` and the area to render in, which might not be the same
212    /// as the frame area for inline TUIs.
213    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    /// Restores the terminal to its original state and stops the event loop
239    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                // Ensure signals are checked in order (cancellation first)
355                biased;
356
357                // Exit the loop if cancellation is requested
358                _ = cancellation_token.cancelled() => {
359                    break;
360                }
361
362                // Crossterm events
363                crossterm_event = event_stream.next().fuse() => match crossterm_event {
364                    Some(Ok(event)) => match event {
365                        // Process only key press events to avoid duplicate events for release/repeat
366                        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, // Ignore other crossterm event types
373                    }
374                    Some(Err(err)) =>  {
375                        tracing::error!("Error retrieving next crossterm event: {err}");
376                        break;
377                    },
378                    None => break, // Event stream ended, exit the loop
379                },
380
381                // Intervals
382                _ = tick_interval.tick() => Event::Tick,
383                _ = render_interval.tick() => Event::Render,
384            };
385
386            // Try to send the processed event
387            if event_tx.send(event).is_err() {
388                // If sending fails, the receiver is likely dropped. Exit the loop
389                break;
390            }
391        }
392
393        // Ensure the token is cancelled if the loop exits for reasons other than direct cancellation
394        // (e.g. event_stream ending or send error).
395        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                // Attempt to abort the task if it hasn't finished in a short period
407                if counter > 50 {
408                    tracing::debug!("Task hasn't finished in 50 milliseconds, attempting to abort");
409                    self.task.abort();
410                }
411                // Log an error and give up waiting if the task hasn't finished after the abort
412                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}