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::{
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/// Events that can occur within the TUI application
31#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
32pub enum Event {
33    /// A periodic tick event, useful for time-based updates or animations
34    Tick,
35    /// A periodic render event, suggesting the UI should be redrawn
36    Render,
37    /// The terminal window gained focus
38    FocusGained,
39    /// The terminal window lost focus
40    FocusLost,
41    /// Text was pasted into the terminal (requires paste mode)
42    Paste(String),
43    /// A key was pressed
44    Key(KeyEvent),
45    /// A mouse event occurred (requires mouse capture)
46    Mouse(MouseEvent),
47    /// The terminal window was resized (columns and rows)
48    Resize(u16, u16),
49}
50
51/// Manages the terminal User Interface (TUI) lifecycle, event handling, and rendering
52pub 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    /// Constructs a new terminal ui with default settings
85    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    /// Sets the tick rate for the TUI.
104    ///
105    /// The tick rate determines how frequently `Event::Tick` is generated.
106    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    /// Sets the frame rate for the TUI.
113    ///
114    /// The frame rate determines how often `Event::Render` is emitted.
115    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    /// Enables or disables mouse event capture.
122    ///
123    /// If true, `Event::Mouse` events will be emitted.
124    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    /// Enables or disables bracketed paste mode.
131    ///
132    /// If true, `Event::Paste` events will be emitted.
133    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    /// Asynchronously retrieves the next event from the event queue.
140    ///
141    /// Returns `None` if the event channel has been closed (e.g., the event loop has stopped).
142    pub async fn next_event(&mut self) -> Option<Event> {
143        self.event_rx.recv().await
144    }
145
146    /// Prepares the terminal for full-screen TUI interaction and starts the event loop
147    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        // Enter raw mode and set up the terminal
153        let keyboard_enhancement_supported = self.enter_raw_mode(true)?;
154
155        // Store the state and start the event loop
156        self.state = Some(State::FullScreen(keyboard_enhancement_supported));
157        self.start();
158
159        Ok(())
160    }
161
162    /// Prepares the terminal for inline TUI interaction and starts the event loop
163    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        // Save the original cursor position
176        let (orig_cursor_x, orig_cursor_y) = cursor::position()?;
177        tracing::trace!("Initial cursor position: ({orig_cursor_x},{orig_cursor_y})");
178        // Prepare the area for the inline content
179        crossterm::execute!(
180            self.stdout,
181            // Fill in the minimum height (plus the extra line), the cursor will end up at the end
182            style::Print("\n".repeat((min_height + extra_line) as usize)),
183            // Move the cursor back the min height (without the extra lines)
184            cursor::MoveToPreviousLine(min_height),
185            // And clear the lines below
186            terminal::Clear(ClearType::FromCursorDown)
187        )?;
188        // Retrieve the new cursor position, which defines the starting coords for the area to render in
189        let (cursor_x, cursor_y) = cursor::position()?;
190        // Calculate where the cursor should be restored to
191        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        // Enter raw mode and set up the terminal
196        let keyboard_enhancement_supported = self.enter_raw_mode(false)?;
197
198        // Store the state and start the event loop
199        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    /// Renders the TUI using the provided callback function.
215    ///
216    /// The callback receives a mutable reference to the `Frame` and the area to render in, which might not be the same
217    /// as the frame area for inline TUIs.
218    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    /// Restores the terminal to its original state and stops the event loop
244    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                // Ensure signals are checked in order (cancellation first)
362                biased;
363
364                // Exit the loop if any cancellation is requested
365                _ = loop_cancellation_token.cancelled() => {
366                    break;
367                }
368                _ = global_cancellation_token.cancelled() => {
369                    break;
370                }
371
372                // Crossterm events
373                crossterm_event = event_stream.next().fuse() => match crossterm_event {
374                    Some(Ok(event)) => match event {
375                        // On raw mode, SIGINT is no longer received and we should handle it manually
376                        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                        // Process only key press events to avoid duplicate events for release/repeat
386                        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, // Ignore other crossterm event types
393                    }
394                    Some(Err(err)) =>  {
395                        tracing::error!("Error retrieving next crossterm event: {err}");
396                        break;
397                    },
398                    None => break, // Event stream ended, exit the loop
399                },
400
401                // Intervals
402                _ = tick_interval.tick() => Event::Tick,
403                _ = render_interval.tick() => Event::Render,
404            };
405
406            // Try to send the processed event
407            if event_tx.send(event).is_err() {
408                // If sending fails, the receiver is likely dropped. Exit the loop
409                break;
410            }
411        }
412
413        // Ensure the token is cancelled if the loop exits for reasons other than direct cancellation
414        // (e.g. event_stream ending or send error).
415        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                // Attempt to abort the task if it hasn't finished in a short period
427                if counter > 50 {
428                    tracing::debug!("Task hasn't finished in 50 milliseconds, attempting to abort");
429                    self.task.abort();
430                }
431                // Log an error and give up waiting if the task hasn't finished after the abort
432                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}