Skip to main content

tui/
runtime.rs

1//! Transport-agnostic event loop.
2//!
3//! Receives a [`Transport`] and an [`App`], draws the UI, polls for
4//! input, translates key events to [`Intent`]s, and applies them.
5//! Quits when `App::should_quit` is set. The loop is generic so both
6//! the local binary and the SSH daemon share identical behaviour.
7
8use crate::app::App;
9use crate::chart::{Backend as ChartBackend, detect_for};
10use crate::draw::draw;
11use crate::event::apply;
12use crate::keymap;
13use crate::transport::{RawEvent, Transport};
14use std::io;
15use std::time::Duration;
16
17/// Poll interval. Short enough to feel responsive to
18/// `App::should_quit` flips, long enough to not burn CPU.
19pub const POLL_INTERVAL: Duration = Duration::from_millis(200);
20
21/// Run the TUI until the app requests shutdown or the transport
22/// disconnects. Does **not** call `Transport::finish`; the caller
23/// owns the transport's lifecycle.
24///
25/// # Errors
26///
27/// Propagates `io::Error`s from:
28/// - `Terminal::draw`, which fails if the backend's writer is broken
29///   (stdout closed, SSH channel closed while writing).
30/// - `Transport::poll`, which fails on disconnect.
31pub fn run_loop<T>(transport: &mut T, app: &mut App) -> io::Result<()>
32where
33    T: Transport,
34    <T::Backend as ratatui::backend::Backend>::Error: std::error::Error + Send + Sync + 'static,
35{
36    while !app.should_quit {
37        transport
38            .terminal_mut()
39            .draw(|frame| draw(frame, app))
40            .map_err(io::Error::other)?;
41        emit_pending_chart(transport, app)?;
42        if let Some(event) = transport.poll(POLL_INTERVAL)? {
43            handle_event(app, event);
44        }
45    }
46    Ok(())
47}
48
49/// Drain `app.pending_chart` and, if the active client speaks the
50/// kitty graphics protocol, push a kitty APC payload through the
51/// transport's passthrough channel. Other backends are silent
52/// no-ops here — chart-as-text rendering is up to the per-tab draw
53/// code, not this overlay path.
54fn emit_pending_chart<T>(transport: &mut T, app: &mut App) -> io::Result<()>
55where
56    T: Transport,
57{
58    let Some(spec) = app.take_pending_chart() else {
59        return Ok(());
60    };
61    if !matches!(detect_for(transport), ChartBackend::Kitty) {
62        return Ok(());
63    }
64    let bytes = plotting::kitty::render_kitty(&spec, plotting::kitty::KittyOpts::default());
65    transport.write_passthrough(bytes.as_bytes())
66}
67
68fn handle_event(app: &mut App, event: RawEvent) {
69    match event {
70        RawEvent::Key(key) => {
71            if let Some(intent) = keymap::translate(app, key) {
72                apply(app, intent);
73            }
74        }
75        RawEvent::Resize(_, _) => {
76            // ratatui's `Terminal` re-queries the backend size on the
77            // next `draw`, so there is nothing explicit to do here.
78            // Left as an enum variant so consumers that want to react
79            // (e.g. re-rasterise a kitty chart at the new pixel size)
80            // have a hook.
81        }
82    }
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88    use crate::transport::RawEvent;
89    use crate::transport::tests_support::{DisconnectedTransport, ScriptedTransport};
90    use crate::widgets::EditMode;
91    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
92    use sqlx::types::Uuid;
93
94    fn key(code: KeyCode) -> RawEvent {
95        RawEvent::Key(KeyEvent::new(code, KeyModifiers::NONE))
96    }
97
98    fn fresh_app() -> App {
99        App::new(Uuid::new_v4(), EditMode::Emacs)
100    }
101
102    #[test]
103    fn run_loop_exits_on_quit_intent() {
104        let mut app = fresh_app();
105        let mut transport = ScriptedTransport::new(40, 10, vec![key(KeyCode::Char('q'))]);
106        run_loop(&mut transport, &mut app).unwrap();
107        assert!(app.should_quit);
108    }
109
110    #[test]
111    fn run_loop_applies_tab_selection() {
112        let mut app = fresh_app();
113        let mut transport = ScriptedTransport::new(
114            40,
115            10,
116            vec![key(KeyCode::Char('1')), key(KeyCode::Char('q'))],
117        );
118        run_loop(&mut transport, &mut app).unwrap();
119        assert_eq!(app.active_tab, crate::app::Tab::Accounts);
120    }
121
122    #[test]
123    fn run_loop_routes_cmdline_submit() {
124        let mut app = fresh_app();
125        let events = vec![
126            key(KeyCode::Char(':')),
127            key(KeyCode::Char('v')),
128            key(KeyCode::Char('e')),
129            key(KeyCode::Char('r')),
130            key(KeyCode::Char('s')),
131            key(KeyCode::Char('i')),
132            key(KeyCode::Char('o')),
133            key(KeyCode::Char('n')),
134            key(KeyCode::Enter),
135            key(KeyCode::Char('q')),
136        ];
137        let mut transport = ScriptedTransport::new(60, 10, events);
138        run_loop(&mut transport, &mut app).unwrap();
139        assert!(!app.command_line_active);
140        assert!(app.status.contains("version"));
141    }
142
143    #[test]
144    fn handle_event_tolerates_resize() {
145        let mut app = fresh_app();
146        handle_event(&mut app, RawEvent::Resize(80, 24));
147        assert!(!app.should_quit);
148        assert_eq!(app.active_tab, crate::app::Tab::Reports);
149    }
150
151    #[test]
152    fn run_loop_exits_without_polling_when_app_already_quitting() {
153        let mut app = fresh_app();
154        app.request_quit();
155        // An empty queue would normally let the loop continue past the
156        // first iteration; the should_quit guard must short-circuit
157        // before poll ever runs, even against an always-erroring poll.
158        let mut transport = DisconnectedTransport::new(40, 10);
159        run_loop(&mut transport, &mut app).expect("should return Ok");
160    }
161
162    #[test]
163    fn run_loop_propagates_poll_errors() {
164        let mut app = fresh_app();
165        let mut transport = DisconnectedTransport::new(40, 10);
166        let err = run_loop(&mut transport, &mut app).expect_err("poll err bubbles");
167        assert_eq!(err.kind(), std::io::ErrorKind::UnexpectedEof);
168        // The loop should not have flipped the quit flag; the caller
169        // decides whether an I/O error means "quit" or "retry".
170        assert!(!app.should_quit);
171    }
172
173    #[test]
174    fn run_loop_ignores_unbound_keys() {
175        // Keys with no binding must not panic or change state.
176        let mut app = fresh_app();
177        let mut transport =
178            ScriptedTransport::new(40, 10, vec![key(KeyCode::F(5)), key(KeyCode::Char('q'))]);
179        run_loop(&mut transport, &mut app).unwrap();
180        assert!(app.should_quit);
181    }
182}