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        app.drain_console();
43        if let Some(event) = transport.poll(POLL_INTERVAL)? {
44            handle_event(app, event);
45        }
46    }
47    Ok(())
48}
49
50/// Drain `app.pending_chart` and, if the active client speaks the
51/// kitty graphics protocol, push a kitty APC payload through the
52/// transport's passthrough channel. Other backends are silent
53/// no-ops here — chart-as-text rendering is up to the per-tab draw
54/// code, not this overlay path.
55fn emit_pending_chart<T>(transport: &mut T, app: &mut App) -> io::Result<()>
56where
57    T: Transport,
58{
59    let Some(spec) = app.take_pending_chart() else {
60        return Ok(());
61    };
62    if !matches!(detect_for(transport), ChartBackend::Kitty) {
63        return Ok(());
64    }
65    let bytes = plotting::kitty::render_kitty(&spec, plotting::kitty::KittyOpts::default());
66    transport.write_passthrough(bytes.as_bytes())
67}
68
69fn handle_event(app: &mut App, event: RawEvent) {
70    match event {
71        RawEvent::Key(key) => {
72            if let Some(intent) = keymap::translate(app, key) {
73                apply(app, intent);
74            }
75        }
76        RawEvent::Resize(_, _) => {
77            // ratatui's `Terminal` re-queries the backend size on the
78            // next `draw`, so there is nothing explicit to do here.
79            // Left as an enum variant so consumers that want to react
80            // (e.g. re-rasterise a kitty chart at the new pixel size)
81            // have a hook.
82        }
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89    use crate::transport::RawEvent;
90    use crate::transport::tests_support::{DisconnectedTransport, ScriptedTransport};
91    use crate::widgets::EditMode;
92    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
93    use sqlx::types::Uuid;
94
95    fn key(code: KeyCode) -> RawEvent {
96        RawEvent::Key(KeyEvent::new(code, KeyModifiers::NONE))
97    }
98
99    fn fresh_app() -> App {
100        App::new(Uuid::new_v4(), EditMode::Emacs)
101    }
102
103    #[test]
104    fn run_loop_exits_on_quit_intent() {
105        let mut app = fresh_app();
106        let mut transport = ScriptedTransport::new(40, 10, vec![key(KeyCode::Char('q'))]);
107        run_loop(&mut transport, &mut app).unwrap();
108        assert!(app.should_quit);
109    }
110
111    #[test]
112    fn run_loop_applies_tab_selection() {
113        let mut app = fresh_app();
114        let mut transport = ScriptedTransport::new(
115            40,
116            10,
117            vec![key(KeyCode::Char('1')), key(KeyCode::Char('q'))],
118        );
119        run_loop(&mut transport, &mut app).unwrap();
120        assert_eq!(app.active_tab, crate::app::Tab::Accounts);
121    }
122
123    #[test]
124    fn run_loop_routes_cmdline_submit() {
125        let mut app = fresh_app();
126        let events = vec![
127            key(KeyCode::Char(':')),
128            key(KeyCode::Char('v')),
129            key(KeyCode::Char('e')),
130            key(KeyCode::Char('r')),
131            key(KeyCode::Char('s')),
132            key(KeyCode::Char('i')),
133            key(KeyCode::Char('o')),
134            key(KeyCode::Char('n')),
135            key(KeyCode::Enter),
136            key(KeyCode::Char('q')),
137        ];
138        let mut transport = ScriptedTransport::new(60, 10, events);
139        run_loop(&mut transport, &mut app).unwrap();
140        assert!(!app.command_line_active);
141        assert!(app.status.contains("version"));
142    }
143
144    #[test]
145    fn handle_event_tolerates_resize() {
146        let mut app = fresh_app();
147        handle_event(&mut app, RawEvent::Resize(80, 24));
148        assert!(!app.should_quit);
149        assert_eq!(app.active_tab, crate::app::Tab::Reports);
150    }
151
152    #[test]
153    fn run_loop_exits_without_polling_when_app_already_quitting() {
154        let mut app = fresh_app();
155        app.request_quit();
156        // An empty queue would normally let the loop continue past the
157        // first iteration; the should_quit guard must short-circuit
158        // before poll ever runs, even against an always-erroring poll.
159        let mut transport = DisconnectedTransport::new(40, 10);
160        run_loop(&mut transport, &mut app).expect("should return Ok");
161    }
162
163    #[test]
164    fn run_loop_propagates_poll_errors() {
165        let mut app = fresh_app();
166        let mut transport = DisconnectedTransport::new(40, 10);
167        let err = run_loop(&mut transport, &mut app).expect_err("poll err bubbles");
168        assert_eq!(err.kind(), std::io::ErrorKind::UnexpectedEof);
169        // The loop should not have flipped the quit flag; the caller
170        // decides whether an I/O error means "quit" or "retry".
171        assert!(!app.should_quit);
172    }
173
174    #[test]
175    fn run_loop_ignores_unbound_keys() {
176        // Keys with no binding must not panic or change state.
177        let mut app = fresh_app();
178        let mut transport =
179            ScriptedTransport::new(40, 10, vec![key(KeyCode::F(5)), key(KeyCode::Char('q'))]);
180        run_loop(&mut transport, &mut app).unwrap();
181        assert!(app.should_quit);
182    }
183}