Lines
95.15 %
Functions
61.54 %
Branches
100 %
//! Transport-agnostic event loop.
//!
//! Receives a [`Transport`] and an [`App`], draws the UI, polls for
//! input, translates key events to [`Intent`]s, and applies them.
//! Quits when `App::should_quit` is set. The loop is generic so both
//! the local binary and the SSH daemon share identical behaviour.
use crate::app::App;
use crate::chart::{Backend as ChartBackend, detect_for};
use crate::draw::draw;
use crate::event::apply;
use crate::keymap;
use crate::transport::{RawEvent, Transport};
use std::io;
use std::time::Duration;
/// Poll interval. Short enough to feel responsive to
/// `App::should_quit` flips, long enough to not burn CPU.
pub const POLL_INTERVAL: Duration = Duration::from_millis(200);
/// Run the TUI until the app requests shutdown or the transport
/// disconnects. Does **not** call `Transport::finish`; the caller
/// owns the transport's lifecycle.
///
/// # Errors
/// Propagates `io::Error`s from:
/// - `Terminal::draw`, which fails if the backend's writer is broken
/// (stdout closed, SSH channel closed while writing).
/// - `Transport::poll`, which fails on disconnect.
pub fn run_loop<T>(transport: &mut T, app: &mut App) -> io::Result<()>
where
T: Transport,
<T::Backend as ratatui::backend::Backend>::Error: std::error::Error + Send + Sync + 'static,
{
while !app.should_quit {
transport
.terminal_mut()
.draw(|frame| draw(frame, app))
.map_err(io::Error::other)?;
emit_pending_chart(transport, app)?;
if let Some(event) = transport.poll(POLL_INTERVAL)? {
handle_event(app, event);
}
Ok(())
/// Drain `app.pending_chart` and, if the active client speaks the
/// kitty graphics protocol, push a kitty APC payload through the
/// transport's passthrough channel. Other backends are silent
/// no-ops here — chart-as-text rendering is up to the per-tab draw
/// code, not this overlay path.
fn emit_pending_chart<T>(transport: &mut T, app: &mut App) -> io::Result<()>
let Some(spec) = app.take_pending_chart() else {
return Ok(());
};
if !matches!(detect_for(transport), ChartBackend::Kitty) {
let bytes = plotting::kitty::render_kitty(&spec, plotting::kitty::KittyOpts::default());
transport.write_passthrough(bytes.as_bytes())
fn handle_event(app: &mut App, event: RawEvent) {
match event {
RawEvent::Key(key) => {
if let Some(intent) = keymap::translate(app, key) {
apply(app, intent);
RawEvent::Resize(_, _) => {
// ratatui's `Terminal` re-queries the backend size on the
// next `draw`, so there is nothing explicit to do here.
// Left as an enum variant so consumers that want to react
// (e.g. re-rasterise a kitty chart at the new pixel size)
// have a hook.
#[cfg(test)]
mod tests {
use super::*;
use crate::transport::RawEvent;
use crate::transport::tests_support::{DisconnectedTransport, ScriptedTransport};
use crate::widgets::EditMode;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use sqlx::types::Uuid;
fn key(code: KeyCode) -> RawEvent {
RawEvent::Key(KeyEvent::new(code, KeyModifiers::NONE))
fn fresh_app() -> App {
App::new(Uuid::new_v4(), EditMode::Emacs)
#[test]
fn run_loop_exits_on_quit_intent() {
let mut app = fresh_app();
let mut transport = ScriptedTransport::new(40, 10, vec![key(KeyCode::Char('q'))]);
run_loop(&mut transport, &mut app).unwrap();
assert!(app.should_quit);
fn run_loop_applies_tab_selection() {
let mut transport = ScriptedTransport::new(
40,
10,
vec![key(KeyCode::Char('1')), key(KeyCode::Char('q'))],
);
assert_eq!(app.active_tab, crate::app::Tab::Accounts);
fn run_loop_routes_cmdline_submit() {
let events = vec![
key(KeyCode::Char(':')),
key(KeyCode::Char('v')),
key(KeyCode::Char('e')),
key(KeyCode::Char('r')),
key(KeyCode::Char('s')),
key(KeyCode::Char('i')),
key(KeyCode::Char('o')),
key(KeyCode::Char('n')),
key(KeyCode::Enter),
key(KeyCode::Char('q')),
];
let mut transport = ScriptedTransport::new(60, 10, events);
assert!(!app.command_line_active);
assert!(app.status.contains("version"));
fn handle_event_tolerates_resize() {
handle_event(&mut app, RawEvent::Resize(80, 24));
assert!(!app.should_quit);
assert_eq!(app.active_tab, crate::app::Tab::Reports);
fn run_loop_exits_without_polling_when_app_already_quitting() {
app.request_quit();
// An empty queue would normally let the loop continue past the
// first iteration; the should_quit guard must short-circuit
// before poll ever runs, even against an always-erroring poll.
let mut transport = DisconnectedTransport::new(40, 10);
run_loop(&mut transport, &mut app).expect("should return Ok");
fn run_loop_propagates_poll_errors() {
let err = run_loop(&mut transport, &mut app).expect_err("poll err bubbles");
assert_eq!(err.kind(), std::io::ErrorKind::UnexpectedEof);
// The loop should not have flipped the quit flag; the caller
// decides whether an I/O error means "quit" or "retry".
fn run_loop_ignores_unbound_keys() {
// Keys with no binding must not panic or change state.
let mut transport =
ScriptedTransport::new(40, 10, vec![key(KeyCode::F(5)), key(KeyCode::Char('q'))]);