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

            
8
use crate::app::App;
9
use crate::chart::{Backend as ChartBackend, detect_for};
10
use crate::draw::draw;
11
use crate::event::apply;
12
use crate::keymap;
13
use crate::transport::{RawEvent, Transport};
14
use std::io;
15
use std::time::Duration;
16

            
17
/// Poll interval. Short enough to feel responsive to
18
/// `App::should_quit` flips, long enough to not burn CPU.
19
pub 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.
31
6
pub fn run_loop<T>(transport: &mut T, app: &mut App) -> io::Result<()>
32
6
where
33
6
    T: Transport,
34
6
    <T::Backend as ratatui::backend::Backend>::Error: std::error::Error + Send + Sync + 'static,
35
{
36
21
    while !app.should_quit {
37
16
        transport
38
16
            .terminal_mut()
39
16
            .draw(|frame| draw(frame, app))
40
16
            .map_err(io::Error::other)?;
41
16
        emit_pending_chart(transport, app)?;
42
16
        if let Some(event) = transport.poll(POLL_INTERVAL)? {
43
15
            handle_event(app, event);
44
15
        }
45
    }
46
5
    Ok(())
47
6
}
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.
54
16
fn emit_pending_chart<T>(transport: &mut T, app: &mut App) -> io::Result<()>
55
16
where
56
16
    T: Transport,
57
{
58
16
    let Some(spec) = app.take_pending_chart() else {
59
16
        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
16
}
67

            
68
16
fn handle_event(app: &mut App, event: RawEvent) {
69
16
    match event {
70
15
        RawEvent::Key(key) => {
71
15
            if let Some(intent) = keymap::translate(app, key) {
72
14
                apply(app, intent);
73
14
            }
74
        }
75
1
        RawEvent::Resize(_, _) => {
76
1
            // ratatui's `Terminal` re-queries the backend size on the
77
1
            // next `draw`, so there is nothing explicit to do here.
78
1
            // Left as an enum variant so consumers that want to react
79
1
            // (e.g. re-rasterise a kitty chart at the new pixel size)
80
1
            // have a hook.
81
1
        }
82
    }
83
16
}
84

            
85
#[cfg(test)]
86
mod 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
15
    fn key(code: KeyCode) -> RawEvent {
95
15
        RawEvent::Key(KeyEvent::new(code, KeyModifiers::NONE))
96
15
    }
97

            
98
7
    fn fresh_app() -> App {
99
7
        App::new(Uuid::new_v4(), EditMode::Emacs)
100
7
    }
101

            
102
    #[test]
103
1
    fn run_loop_exits_on_quit_intent() {
104
1
        let mut app = fresh_app();
105
1
        let mut transport = ScriptedTransport::new(40, 10, vec![key(KeyCode::Char('q'))]);
106
1
        run_loop(&mut transport, &mut app).unwrap();
107
1
        assert!(app.should_quit);
108
1
    }
109

            
110
    #[test]
111
1
    fn run_loop_applies_tab_selection() {
112
1
        let mut app = fresh_app();
113
1
        let mut transport = ScriptedTransport::new(
114
            40,
115
            10,
116
1
            vec![key(KeyCode::Char('1')), key(KeyCode::Char('q'))],
117
        );
118
1
        run_loop(&mut transport, &mut app).unwrap();
119
1
        assert_eq!(app.active_tab, crate::app::Tab::Accounts);
120
1
    }
121

            
122
    #[test]
123
1
    fn run_loop_routes_cmdline_submit() {
124
1
        let mut app = fresh_app();
125
1
        let events = vec![
126
1
            key(KeyCode::Char(':')),
127
1
            key(KeyCode::Char('v')),
128
1
            key(KeyCode::Char('e')),
129
1
            key(KeyCode::Char('r')),
130
1
            key(KeyCode::Char('s')),
131
1
            key(KeyCode::Char('i')),
132
1
            key(KeyCode::Char('o')),
133
1
            key(KeyCode::Char('n')),
134
1
            key(KeyCode::Enter),
135
1
            key(KeyCode::Char('q')),
136
        ];
137
1
        let mut transport = ScriptedTransport::new(60, 10, events);
138
1
        run_loop(&mut transport, &mut app).unwrap();
139
1
        assert!(!app.command_line_active);
140
1
        assert!(app.status.contains("version"));
141
1
    }
142

            
143
    #[test]
144
1
    fn handle_event_tolerates_resize() {
145
1
        let mut app = fresh_app();
146
1
        handle_event(&mut app, RawEvent::Resize(80, 24));
147
1
        assert!(!app.should_quit);
148
1
        assert_eq!(app.active_tab, crate::app::Tab::Reports);
149
1
    }
150

            
151
    #[test]
152
1
    fn run_loop_exits_without_polling_when_app_already_quitting() {
153
1
        let mut app = fresh_app();
154
1
        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
1
        let mut transport = DisconnectedTransport::new(40, 10);
159
1
        run_loop(&mut transport, &mut app).expect("should return Ok");
160
1
    }
161

            
162
    #[test]
163
1
    fn run_loop_propagates_poll_errors() {
164
1
        let mut app = fresh_app();
165
1
        let mut transport = DisconnectedTransport::new(40, 10);
166
1
        let err = run_loop(&mut transport, &mut app).expect_err("poll err bubbles");
167
1
        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
1
        assert!(!app.should_quit);
171
1
    }
172

            
173
    #[test]
174
1
    fn run_loop_ignores_unbound_keys() {
175
        // Keys with no binding must not panic or change state.
176
1
        let mut app = fresh_app();
177
1
        let mut transport =
178
1
            ScriptedTransport::new(40, 10, vec![key(KeyCode::F(5)), key(KeyCode::Char('q'))]);
179
1
        run_loop(&mut transport, &mut app).unwrap();
180
1
        assert!(app.should_quit);
181
1
    }
182
}