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
        app.drain_console();
43
16
        if let Some(event) = transport.poll(POLL_INTERVAL)? {
44
15
            handle_event(app, event);
45
15
        }
46
    }
47
5
    Ok(())
48
6
}
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.
55
16
fn emit_pending_chart<T>(transport: &mut T, app: &mut App) -> io::Result<()>
56
16
where
57
16
    T: Transport,
58
{
59
16
    let Some(spec) = app.take_pending_chart() else {
60
16
        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
16
}
68

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

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

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

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

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

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

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

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

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

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