Skip to main content

remendo/
tui.rs

1//! Terminal user interface lifecycle management.
2//!
3//! The `Tui` struct wraps the ratatui terminal, event channels,
4//! and async event handler task. It manages raw mode, alternate
5//! screen, panic hooks, and clean shutdown.
6
7use crate::event::Event;
8use color_eyre::Result;
9use crossterm::event::{
10    DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste,
11    EnableFocusChange, EnableMouseCapture, EventStream,
12};
13use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen};
14use futures::StreamExt;
15use ratatui::DefaultTerminal;
16use std::io::{self, stdout};
17use std::panic;
18use std::time::Duration;
19use tokio::sync::mpsc;
20use tokio::task::JoinHandle;
21use tokio_util::sync::CancellationToken;
22
23/// Terminal UI wrapper managing lifecycle and event delivery.
24///
25/// The lifecycle is split into two phases:
26/// 1. `Tui::new()` — creates channels and config; no terminal side effects
27/// 2. `Tui::enter()` — initializes the terminal, enters raw mode, spawns event task
28///
29/// This split ensures config loading and stderr output happen before
30/// the terminal is taken over.
31pub struct Tui {
32    /// The ratatui terminal instance (initialized in `enter()`).
33    terminal: Option<DefaultTerminal>,
34    /// Receiver for events from the event handler task.
35    event_rx: mpsc::UnboundedReceiver<Event>,
36    /// Sender for events (cloned into the event task).
37    event_tx: mpsc::UnboundedSender<Event>,
38    /// Handle to the spawned event handler task.
39    task: Option<JoinHandle<()>>,
40    /// Cancellation token to stop the event handler task.
41    cancellation_token: CancellationToken,
42    /// Target tick rate (ticks per second).
43    tick_rate: f64,
44    /// Target frame rate (frames per second).
45    frame_rate: f64,
46}
47
48impl Tui {
49    /// Create a new TUI instance **without** initializing the terminal.
50    ///
51    /// No side effects — the terminal remains in normal mode.
52    /// Call [`Tui::enter`] to activate raw mode and start the event
53    /// handler task.
54    #[must_use]
55    pub fn new(tick_rate: f64, frame_rate: f64) -> Self {
56        let (event_tx, event_rx) = mpsc::unbounded_channel();
57        Self {
58            terminal: None,
59            event_rx,
60            event_tx,
61            task: None,
62            cancellation_token: CancellationToken::new(),
63            tick_rate,
64            frame_rate,
65        }
66    }
67
68    /// Initialize the terminal and enter raw mode.
69    ///
70    /// This enables raw mode, alternate screen, mouse capture,
71    /// focus change events, and bracketed paste. It also installs
72    /// a panic hook that restores the terminal before displaying
73    /// the error report, and spawns the async event handler task.
74    ///
75    /// # Errors
76    ///
77    /// Returns an error if terminal setup fails (instead of panicking).
78    pub fn enter(&mut self) -> Result<()> {
79        terminal::enable_raw_mode()?;
80        crossterm::execute!(
81            stdout(),
82            EnterAlternateScreen,
83            EnableMouseCapture,
84            EnableFocusChange,
85            EnableBracketedPaste,
86        )?;
87
88        // Initialize the terminal after entering raw mode
89        let backend = ratatui::prelude::CrosstermBackend::new(stdout());
90        let terminal = ratatui::Terminal::new(backend)?;
91        self.terminal = Some(terminal);
92
93        // Install panic hook that restores the terminal
94        let original_hook = panic::take_hook();
95        panic::set_hook(Box::new(move |panic_info| {
96            let _ = Self::reset();
97            original_hook(panic_info);
98        }));
99
100        // Spawn the event handler task
101        let event_tx = self.event_tx.clone();
102        let cancellation_token = self.cancellation_token.clone();
103        let tick_delay = Duration::from_secs_f64(1.0 / self.tick_rate);
104        let render_delay = Duration::from_secs_f64(1.0 / self.frame_rate);
105
106        self.task = Some(tokio::spawn(async move {
107            let mut reader = EventStream::new();
108            let mut tick_interval = tokio::time::interval(tick_delay);
109            let mut render_interval = tokio::time::interval(render_delay);
110
111            // Send Init event
112            let _ = event_tx.send(Event::Init);
113
114            loop {
115                #[allow(clippy::ignored_unit_patterns)]
116                let event = tokio::select! {
117                    () = cancellation_token.cancelled() => break,
118                    _ = tick_interval.tick() => Event::Tick,
119                    _ = render_interval.tick() => Event::Render,
120                    crossterm_event = reader.next() => {
121                        match crossterm_event {
122                            Some(Ok(evt)) => translate_crossterm_event(evt),
123                            Some(Err(_)) => Event::Error,
124                            None => break,
125                        }
126                    }
127                };
128
129                if event_tx.send(event).is_err() {
130                    // Receiver dropped — app is shutting down
131                    break;
132                }
133            }
134        }));
135
136        Ok(())
137    }
138
139    /// Wait for the next event from the event handler task.
140    ///
141    /// # Errors
142    ///
143    /// Returns an error if the event channel is closed unexpectedly.
144    pub async fn next(&mut self) -> Result<Event> {
145        self.event_rx
146            .recv()
147            .await
148            .ok_or_else(|| color_eyre::eyre::eyre!("event channel closed"))
149    }
150
151    /// Exit raw mode and restore the terminal.
152    ///
153    /// Cancels the event handler task and restores the terminal state.
154    ///
155    /// # Errors
156    ///
157    /// Returns an error if terminal restoration fails.
158    pub fn exit(&mut self) -> Result<()> {
159        self.cancellation_token.cancel();
160        self.task = None;
161        self.terminal = None;
162        Self::reset()?;
163        Ok(())
164    }
165
166    /// Draw a frame using the provided rendering closure.
167    ///
168    /// # Errors
169    ///
170    /// Returns an error if the terminal is not initialized or drawing fails.
171    pub fn draw(&mut self, f: impl FnOnce(&mut ratatui::Frame)) -> Result<()> {
172        let terminal = self.terminal.as_mut().ok_or_else(|| {
173            color_eyre::eyre::eyre!("terminal not initialized — call enter() first")
174        })?;
175        terminal.draw(f)?;
176        Ok(())
177    }
178
179    /// Temporarily suspend the TUI for an external process (e.g., editor).
180    ///
181    /// Leaves alternate screen and disables raw mode so the subprocess
182    /// can interact with the terminal normally. Call `resume()` after
183    /// the subprocess exits.
184    ///
185    /// # Errors
186    ///
187    /// Returns an error if terminal state cannot be changed.
188    pub fn suspend(&mut self) -> Result<()> {
189        Self::reset()?;
190        Ok(())
191    }
192
193    /// Resume the TUI after a `suspend()` call.
194    ///
195    /// Re-enters raw mode and alternate screen. The event handler
196    /// task is still running — it will resume delivering events.
197    ///
198    /// # Errors
199    ///
200    /// Returns an error if terminal state cannot be restored.
201    pub fn resume(&mut self) -> Result<()> {
202        self.drain_events();
203        terminal::enable_raw_mode()?;
204        crossterm::execute!(
205            stdout(),
206            EnterAlternateScreen,
207            EnableMouseCapture,
208            EnableFocusChange,
209            EnableBracketedPaste,
210        )?;
211        // Re-create the terminal backend for the new stdout
212        let backend = ratatui::prelude::CrosstermBackend::new(stdout());
213        self.terminal = Some(ratatui::Terminal::new(backend)?);
214        Ok(())
215    }
216
217    /// Discard all queued events from the event channel.
218    ///
219    /// Called by [`resume`](Self::resume) to prevent stale input that
220    /// accumulated during a [`suspend`](Self::suspend) from being
221    /// interpreted as TUI commands. The background event handler task
222    /// keeps running during suspend, so its `EventStream` can race
223    /// with the child process for stdin and capture keypresses (e.g.,
224    /// `q` to quit an editor) that would otherwise cause unintended
225    /// actions like quitting the app.
226    fn drain_events(&mut self) {
227        while self.event_rx.try_recv().is_ok() {}
228    }
229
230    /// Reset terminal state (used by both exit and panic hook).
231    fn reset() -> io::Result<()> {
232        terminal::disable_raw_mode()?;
233        crossterm::execute!(
234            stdout(),
235            DisableMouseCapture,
236            DisableFocusChange,
237            DisableBracketedPaste,
238            LeaveAlternateScreen,
239        )?;
240        Ok(())
241    }
242}
243
244impl Drop for Tui {
245    fn drop(&mut self) {
246        self.cancellation_token.cancel();
247        let _ = Self::reset();
248    }
249}
250
251/// Translate a crossterm event into our application `Event` enum.
252fn translate_crossterm_event(event: crossterm::event::Event) -> Event {
253    match event {
254        crossterm::event::Event::Key(key) => Event::Key(key),
255        crossterm::event::Event::Mouse(mouse) => Event::Mouse(mouse),
256        crossterm::event::Event::Resize(w, h) => Event::Resize(w, h),
257        crossterm::event::Event::FocusGained => Event::FocusGained,
258        crossterm::event::Event::FocusLost => Event::FocusLost,
259        crossterm::event::Event::Paste(text) => Event::Paste(text),
260    }
261}
262
263#[cfg(test)]
264#[allow(clippy::unwrap_used)]
265mod tests {
266    use super::*;
267
268    #[test]
269    fn drain_events_empties_channel() {
270        let mut tui = Tui::new(1.0, 1.0);
271        // Inject several events via the sender (simulating the background task)
272        tui.event_tx.send(Event::Tick).unwrap();
273        tui.event_tx.send(Event::Render).unwrap();
274        tui.event_tx
275            .send(Event::Key(crossterm::event::KeyEvent::new(
276                crossterm::event::KeyCode::Char('q'),
277                crossterm::event::KeyModifiers::NONE,
278            )))
279            .unwrap();
280
281        tui.drain_events();
282
283        // Channel should be empty
284        assert!(tui.event_rx.try_recv().is_err());
285    }
286
287    #[test]
288    fn drain_events_on_empty_channel_is_noop() {
289        let mut tui = Tui::new(1.0, 1.0);
290        tui.drain_events();
291        assert!(tui.event_rx.try_recv().is_err());
292    }
293}