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