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}