Skip to main content

leviath_cli/tui/
mod.rs

1//! Seams shared by every Leviath terminal UI.
2//!
3//! A ratatui app has exactly two pieces that cannot run under `cargo test`:
4//! taking over the real terminal (raw mode + alternate screen + a
5//! `CrosstermBackend` on real stdout) and blocking on real keyboard input.
6//! [`TerminalSetup`] and [`EventSource`] abstract those two, so a UI's whole
7//! loop is unit-testable against a [`ratatui::backend::TestBackend`] and a
8//! canned event list while the real crossterm bindings live in the
9//! coverage-excluded `lev` binary.
10//!
11//! This started as `commands/dashboard`-private code. It moved here when the
12//! `lev setup` wizard became a second ratatui surface: both drive the same
13//! `CrosstermSetup` from `main.rs`, share [`theme`], and share the test doubles
14//! below.
15//!
16//! ## Why the test doubles live here, not in each UI's test module
17//!
18//! `cargo-llvm-cov` reports generic functions per *instantiation*. A UI loop
19//! generic over `B: Backend` that monomorphizes over two backend types gets two
20//! region reports, and any arm exercised in only one of them shows as partially
21//! covered. Keeping exactly one `TestEventSource` and one
22//! `TestBackendHarness` for the whole crate means each loop monomorphizes
23//! once, and both the success and the error arms of its `?`s land inside that
24//! single instantiation. Both doubles therefore carry an injectable-failure
25//! switch rather than having an always-failing sibling type.
26
27pub(crate) mod keymap;
28pub mod theme;
29pub(crate) mod widgets;
30
31use crossterm::event::Event;
32use ratatui::Terminal;
33use std::time::Duration;
34
35/// Abstracts "give me the next input event, or `None` if the poll timeout
36/// elapses" (i.e. `crossterm::event::poll` + `event::read`), so a UI's main
37/// loop can be driven by canned events in tests instead of blocking on a real
38/// terminal.
39pub trait EventSource {
40    fn poll_event(&mut self, timeout: Duration) -> std::io::Result<Option<Event>>;
41}
42
43/// Production [`EventSource`]: reads real terminal input via crossterm.
44/// Uses injectable function pointers for `poll` and `read` so the two
45/// branches of `poll_event` can be exercised in unit tests without a real
46/// TTY.  In production, construct via [`CrosstermEventSource::new`]. Wired
47/// into the real UIs only by the binary.
48pub struct CrosstermEventSource {
49    poll_fn: fn(Duration) -> std::io::Result<bool>,
50    read_fn: fn() -> std::io::Result<Event>,
51}
52
53#[allow(clippy::new_without_default)] // constructed only by the binary's real UI entrypoints
54impl CrosstermEventSource {
55    pub fn new() -> Self {
56        Self {
57            poll_fn: crossterm::event::poll,
58            read_fn: crossterm::event::read,
59        }
60    }
61}
62
63impl EventSource for CrosstermEventSource {
64    fn poll_event(&mut self, timeout: Duration) -> std::io::Result<Option<Event>> {
65        if (self.poll_fn)(timeout)? {
66            Ok(Some((self.read_fn)()?))
67        } else {
68            Ok(None)
69        }
70    }
71}
72
73/// Abstracts terminal setup/teardown so a UI's generic core can be tested with
74/// a [`ratatui::backend::TestBackend`] and no-op TTY operations. The real
75/// crossterm implementation (`CrosstermSetup`) lives in the binary, since it
76/// can only be exercised against a real terminal.
77pub trait TerminalSetup {
78    type B: ratatui::backend::Backend;
79    fn enable(&mut self) -> anyhow::Result<()>;
80    fn create_terminal(&mut self) -> anyhow::Result<Terminal<Self::B>>;
81    fn disable(&mut self);
82    fn print_done(&self);
83}
84
85// ─── Test doubles (shared crate-wide; see the module docs for why) ───────────
86
87#[cfg(test)]
88pub(crate) use test_doubles::*;
89
90#[cfg(test)]
91mod test_doubles {
92    use super::*;
93    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
94
95    /// Build a plain unmodified key-press event, the overwhelmingly common
96    /// shape in UI tests.
97    pub(crate) fn key(code: KeyCode) -> Event {
98        Event::Key(KeyEvent::new(code, KeyModifiers::empty()))
99    }
100
101    /// Build a key-press event carrying modifiers (`Ctrl-S`, `Shift-Tab`, …).
102    pub(crate) fn key_with(code: KeyCode, modifiers: KeyModifiers) -> Event {
103        Event::Key(KeyEvent::new(code, modifiers))
104    }
105
106    /// The crate's single test [`EventSource`]. Two modes, both reachable from
107    /// one type:
108    /// - scripted: yields a fixed sequence (one `Option<Event>` per
109    ///   `poll_event` call - `Some(e)` -> `Ok(Some(e))`, `None` -> `Ok(None)`,
110    ///   i.e. a simulated poll-timeout tick), then `None` forever once
111    ///   exhausted.
112    /// - failing (`fail = true`): every `poll_event` returns `Err`, to drive a
113    ///   loop's `?`-propagation path.
114    pub(crate) struct TestEventSource {
115        events: std::collections::VecDeque<Option<Event>>,
116        fail: bool,
117    }
118
119    impl TestEventSource {
120        /// Construct from a list of concrete events (all wrapped in `Some`).
121        pub(crate) fn new(events: Vec<Event>) -> Self {
122            Self {
123                events: events.into_iter().map(Some).collect(),
124                fail: false,
125            }
126        }
127
128        /// Construct from a list of `Option<Event>`, allowing explicit `None`
129        /// ticks (simulated poll timeouts with no input) to be interleaved.
130        pub(crate) fn new_with_nones(events: Vec<Option<Event>>) -> Self {
131            Self {
132                events: events.into(),
133                fail: false,
134            }
135        }
136
137        /// Construct a source whose `poll_event` always errors.
138        pub(crate) fn failing() -> Self {
139            Self {
140                events: std::collections::VecDeque::new(),
141                fail: true,
142            }
143        }
144    }
145
146    impl EventSource for TestEventSource {
147        fn poll_event(&mut self, _timeout: Duration) -> std::io::Result<Option<Event>> {
148            if self.fail {
149                return Err(std::io::Error::other("simulated event source failure"));
150            }
151            Ok(self.events.pop_front().flatten())
152        }
153    }
154
155    /// The crate's single test [`ratatui::backend::Backend`]: a thin wrapper
156    /// around a real [`ratatui::backend::TestBackend`] that adds a `fail_draw`
157    /// switch, so both the success and the `?`-error arms of a loop's
158    /// `terminal.draw(...)?` are exercised within the *same* instantiation.
159    pub(crate) struct TestBackendHarness {
160        inner: ratatui::backend::TestBackend,
161        fail_draw: bool,
162    }
163
164    impl TestBackendHarness {
165        pub(crate) fn new(width: u16, height: u16) -> Self {
166            Self {
167                inner: ratatui::backend::TestBackend::new(width, height),
168                fail_draw: false,
169            }
170        }
171
172        pub(crate) fn failing(width: u16, height: u16) -> Self {
173            Self {
174                inner: ratatui::backend::TestBackend::new(width, height),
175                fail_draw: true,
176            }
177        }
178
179        /// The cells last drawn, so a test can assert on what a user would
180        /// actually read rather than only that drawing did not panic.
181        pub(crate) fn buffer(&self) -> &ratatui::buffer::Buffer {
182            self.inner.buffer()
183        }
184
185        /// The drawn frame as newline-separated rows of text.
186        pub(crate) fn text(&self) -> String {
187            let buffer = self.buffer();
188            let width = buffer.area.width as usize;
189            buffer
190                .content
191                .chunks(width)
192                .map(|row| row.iter().map(|cell| cell.symbol()).collect::<String>())
193                .collect::<Vec<_>>()
194                .join("\n")
195        }
196    }
197
198    /// ratatui 0.30's `TestBackend` is infallible (`Error = Infallible`);
199    /// the harness keeps `io::Error` so the fail-draw switch still exercises
200    /// the loops' error arms. `into_ok` converts the inner results: an
201    /// `Infallible` error is a proof no error exists, so the conversion has
202    /// no failure branch.
203    fn into_ok<T>(result: Result<T, std::convert::Infallible>) -> std::io::Result<T> {
204        match result {
205            Ok(value) => Ok(value),
206        }
207    }
208
209    impl ratatui::backend::Backend for TestBackendHarness {
210        type Error = std::io::Error;
211
212        fn draw<'a, I>(&mut self, content: I) -> std::io::Result<()>
213        where
214            I: Iterator<Item = (u16, u16, &'a ratatui::buffer::Cell)>,
215        {
216            if self.fail_draw {
217                return Err(std::io::Error::other("simulated draw failure"));
218            }
219            into_ok(self.inner.draw(content))
220        }
221
222        fn hide_cursor(&mut self) -> std::io::Result<()> {
223            into_ok(self.inner.hide_cursor())
224        }
225        fn show_cursor(&mut self) -> std::io::Result<()> {
226            into_ok(self.inner.show_cursor())
227        }
228        fn get_cursor_position(&mut self) -> std::io::Result<ratatui::layout::Position> {
229            into_ok(self.inner.get_cursor_position())
230        }
231        fn set_cursor_position<P: Into<ratatui::layout::Position>>(
232            &mut self,
233            position: P,
234        ) -> std::io::Result<()> {
235            into_ok(self.inner.set_cursor_position(position))
236        }
237        fn clear(&mut self) -> std::io::Result<()> {
238            into_ok(self.inner.clear())
239        }
240        fn clear_region(&mut self, region: ratatui::backend::ClearType) -> std::io::Result<()> {
241            into_ok(self.inner.clear_region(region))
242        }
243        fn size(&self) -> std::io::Result<ratatui::layout::Size> {
244            into_ok(self.inner.size())
245        }
246        fn window_size(&mut self) -> std::io::Result<ratatui::backend::WindowSize> {
247            into_ok(self.inner.window_size())
248        }
249        fn flush(&mut self) -> std::io::Result<()> {
250            into_ok(self.inner.flush())
251        }
252    }
253
254    /// A ready-to-draw terminal over the shared test backend.
255    pub(crate) fn test_terminal() -> Terminal<TestBackendHarness> {
256        Terminal::new(TestBackendHarness::new(120, 40)).unwrap()
257    }
258
259    /// Test [`TerminalSetup`]: a [`TestBackendHarness`] terminal and no-op TTY
260    /// operations, so a UI's generic core monomorphizes only over test doubles
261    /// in the measured test build - never over the real `CrosstermBackend`,
262    /// which can't be driven under `cargo test`. The two `_should_fail` flags
263    /// drive the `setup.enable()?` and `setup.create_terminal()?` failure arms
264    /// deterministically.
265    pub(crate) struct TestSetup {
266        pub(crate) enable_should_fail: bool,
267        pub(crate) create_should_fail: bool,
268    }
269
270    impl TestSetup {
271        pub(crate) fn new() -> Self {
272            Self {
273                enable_should_fail: false,
274                create_should_fail: false,
275            }
276        }
277    }
278
279    impl TerminalSetup for TestSetup {
280        type B = TestBackendHarness;
281
282        fn enable(&mut self) -> anyhow::Result<()> {
283            if self.enable_should_fail {
284                anyhow::bail!("simulated enable failure");
285            }
286            Ok(())
287        }
288
289        fn create_terminal(&mut self) -> anyhow::Result<Terminal<Self::B>> {
290            if self.create_should_fail {
291                anyhow::bail!("simulated create_terminal failure");
292            }
293            Terminal::new(TestBackendHarness::new(80, 24)).map_err(anyhow::Error::from)
294        }
295
296        fn disable(&mut self) {}
297
298        fn print_done(&self) {}
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305    use crossterm::event::KeyCode;
306
307    // ─── CrosstermEventSource ───────────────────────────────────────────────
308    //
309    // `poll_event` has four paths: poll-ready-then-read, poll-timeout, and the
310    // `?` error arm of each call. Injecting fn pointers exercises all four with
311    // no real TTY. The doubles are named fns reused across the tests rather
312    // than per-test closures, because a closure passed only to a test that
313    // never invokes it is itself an uncovered function.
314
315    fn poll_ready(_: Duration) -> std::io::Result<bool> {
316        Ok(true)
317    }
318    fn poll_timeout(_: Duration) -> std::io::Result<bool> {
319        Ok(false)
320    }
321    fn poll_fails(_: Duration) -> std::io::Result<bool> {
322        Err(std::io::Error::other("poll exploded"))
323    }
324    fn read_resize() -> std::io::Result<Event> {
325        Ok(Event::Resize(80, 24))
326    }
327    fn read_fails() -> std::io::Result<Event> {
328        Err(std::io::Error::other("read exploded"))
329    }
330
331    #[test]
332    fn crossterm_event_source_returns_the_read_event_when_poll_reports_ready() {
333        let mut source = CrosstermEventSource {
334            poll_fn: poll_ready,
335            read_fn: read_resize,
336        };
337
338        let event = source.poll_event(Duration::from_millis(1)).unwrap();
339
340        assert_eq!(event, Some(Event::Resize(80, 24)));
341    }
342
343    #[test]
344    fn crossterm_event_source_returns_none_when_poll_times_out() {
345        // `read_fn` is supplied but must never run: a timeout tick reports no
346        // event rather than reading one.
347        let mut source = CrosstermEventSource {
348            poll_fn: poll_timeout,
349            read_fn: read_resize,
350        };
351
352        let event = source.poll_event(Duration::from_millis(1)).unwrap();
353
354        assert!(event.is_none());
355    }
356
357    #[test]
358    fn crossterm_event_source_propagates_a_poll_error() {
359        let mut source = CrosstermEventSource {
360            poll_fn: poll_fails,
361            read_fn: read_resize,
362        };
363
364        let err = source.poll_event(Duration::from_millis(1)).unwrap_err();
365
366        assert!(err.to_string().contains("poll exploded"));
367    }
368
369    #[test]
370    fn crossterm_event_source_propagates_a_read_error() {
371        let mut source = CrosstermEventSource {
372            poll_fn: poll_ready,
373            read_fn: read_fails,
374        };
375
376        let err = source.poll_event(Duration::from_millis(1)).unwrap_err();
377
378        assert!(err.to_string().contains("read exploded"));
379    }
380
381    #[test]
382    fn crossterm_event_source_new_stores_the_real_crossterm_functions() {
383        // Taking a function's address never invokes it, so constructing the
384        // production source touches no real terminal state.
385        let _source = CrosstermEventSource::new();
386    }
387
388    #[test]
389    fn test_event_source_yields_scripted_events_then_none_forever() {
390        let mut source = TestEventSource::new(vec![key(KeyCode::Esc)]);
391
392        assert_eq!(
393            source.poll_event(Duration::from_millis(1)).unwrap(),
394            Some(key(KeyCode::Esc))
395        );
396        // Exhausted: every later poll is a timeout tick, not an error.
397        assert!(
398            source
399                .poll_event(Duration::from_millis(1))
400                .unwrap()
401                .is_none()
402        );
403        assert!(
404            source
405                .poll_event(Duration::from_millis(1))
406                .unwrap()
407                .is_none()
408        );
409    }
410
411    #[test]
412    fn test_event_source_interleaves_explicit_timeout_ticks() {
413        let mut source = TestEventSource::new_with_nones(vec![None, Some(key(KeyCode::Enter))]);
414
415        assert!(
416            source
417                .poll_event(Duration::from_millis(1))
418                .unwrap()
419                .is_none()
420        );
421        assert_eq!(
422            source.poll_event(Duration::from_millis(1)).unwrap(),
423            Some(key(KeyCode::Enter))
424        );
425    }
426
427    #[test]
428    fn test_event_source_failing_mode_errors_on_every_poll() {
429        let mut source = TestEventSource::failing();
430
431        assert!(source.poll_event(Duration::from_millis(1)).is_err());
432        assert!(source.poll_event(Duration::from_millis(1)).is_err());
433    }
434
435    #[test]
436    fn key_with_carries_its_modifiers() {
437        let event = key_with(KeyCode::Char('s'), crossterm::event::KeyModifiers::CONTROL);
438
439        assert_eq!(
440            event,
441            Event::Key(crossterm::event::KeyEvent::new(
442                KeyCode::Char('s'),
443                crossterm::event::KeyModifiers::CONTROL
444            ))
445        );
446        // …and the plain helper does not.
447        assert_ne!(event, key(KeyCode::Char('s')));
448    }
449
450    #[test]
451    fn test_backend_harness_draws_or_fails_on_demand() {
452        use ratatui::backend::Backend;
453
454        let mut ok = TestBackendHarness::new(10, 3);
455        assert!(ok.draw(std::iter::empty()).is_ok());
456        // Every non-draw method delegates to the inner TestBackend.
457        assert!(ok.hide_cursor().is_ok());
458        assert!(ok.show_cursor().is_ok());
459        assert!(ok.get_cursor_position().is_ok());
460        assert!(
461            ok.set_cursor_position(ratatui::layout::Position::new(0, 0))
462                .is_ok()
463        );
464        assert!(ok.clear().is_ok());
465        assert!(ok.clear_region(ratatui::backend::ClearType::All).is_ok());
466        assert!(ok.size().is_ok());
467        assert!(ok.window_size().is_ok());
468        assert!(ok.flush().is_ok());
469
470        let mut bad = TestBackendHarness::failing(10, 3);
471        assert!(bad.draw(std::iter::empty()).is_err());
472    }
473
474    #[test]
475    fn test_terminal_is_ready_to_draw() {
476        let mut terminal = test_terminal();
477        assert!(terminal.draw(|_| {}).is_ok());
478    }
479
480    #[test]
481    fn test_setup_succeeds_by_default_and_fails_when_switched() {
482        let mut setup = TestSetup::new();
483        assert!(setup.enable().is_ok());
484        assert!(setup.create_terminal().is_ok());
485        setup.disable();
486        setup.print_done();
487
488        let mut enable_fails = TestSetup {
489            enable_should_fail: true,
490            create_should_fail: false,
491        };
492        assert!(enable_fails.enable().is_err());
493
494        let mut create_fails = TestSetup {
495            enable_should_fail: false,
496            create_should_fail: true,
497        };
498        assert!(create_fails.create_terminal().is_err());
499    }
500}