Lines
77.94 %
Functions
50 %
Branches
100 %
//! Transport abstraction.
//!
//! The TUI event loop sits on top of two I/O concerns:
//! 1. A [`ratatui::backend::Backend`] that receives rendered frames.
//! 2. A source of user input events (keystrokes, terminal resizes).
//! Both are hidden behind [`Transport`]. A local invocation uses
//! [`LocalTransport`], which owns a `CrosstermBackend<Stdout>` and
//! polls crossterm for events. The SSH daemon will later provide a
//! different impl that writes to a russh channel and reads keystrokes
//! from that channel.
//! Keeping this layer tiny is deliberate. Anything richer (history,
//! mouse support, paste handling) grows inside `App` / `Intent`, not
//! here.
use std::io;
use std::time::Duration;
pub mod local;
pub use local::LocalTransport;
/// Input events the event loop cares about. Keystrokes and resizes
/// are explicit; disconnect is signalled via [`Transport::poll`]
/// returning an `io::Error`.
#[derive(Debug, Clone, Copy)]
pub enum RawEvent {
Key(crossterm::event::KeyEvent),
Resize(u16, u16),
}
/// Bridge between the event loop and whatever terminal it talks to.
///
/// Lifecycle: a `Transport` is constructed in whatever mode it needs
/// (raw mode, alternate screen, SSH channel attached), then passed by
/// mutable reference to [`crate::runtime::run_loop`]. When the loop
/// exits, the owning code calls [`Transport::finish`] to tear down.
/// The transport owns the `Terminal` rather than just a raw backend
/// because ratatui's `Terminal<B>` requires `B: Backend`, which does
/// not hold for `&mut B`. Having the transport expose
/// `terminal_mut()` keeps the runtime generic without hitting that
/// trait-bound wall.
pub trait Transport {
type Backend: ratatui::backend::Backend;
/// Mutable access to the owned ratatui `Terminal` so the runtime
/// can drive `draw`.
fn terminal_mut(&mut self) -> &mut ratatui::Terminal<Self::Backend>;
/// Block up to `timeout` waiting for an input event. Returns
/// `Ok(None)` on timeout, `Ok(Some(event))` on activity.
/// # Errors
/// Returns `Err` on disconnect (local stdin closed, SSH channel
/// closed) or any unrecoverable read failure.
fn poll(&mut self, timeout: Duration) -> io::Result<Option<RawEvent>>;
/// Restore the terminal to its pre-TUI state. The default impl is
/// a no-op so test transports do not have to implement it.
/// Returns `Err` if teardown fails, e.g. writing the
/// "leave alternate screen" escape to a closed stdout.
fn finish(self) -> io::Result<()>
where
Self: Sized,
{
Ok(())
/// Forward a raw byte sequence to the same stream the backend
/// writes to, bypassing ratatui's cell grid. Used for terminal
/// escape sequences that ratatui can't model directly — kitty
/// graphics APC bytes, future sixel, etc. The default impl
/// silently drops the bytes so transports that don't speak that
/// dialect (or test doubles) don't need an override.
/// Returns `Err` only if the underlying stream write fails.
fn write_passthrough(&mut self, _bytes: &[u8]) -> io::Result<()> {
/// Terminal type the *client* claims to be, in OpenSSH-style
/// `xterm-256color` form. The local transport reads `$TERM`; the
/// SSH transport returns whatever the peer sent in `pty-req`.
/// Returns `None` when the transport cannot tell.
/// Capability detection (kitty graphics, true-colour, etc.)
/// should consult this rather than the daemon's own environment,
/// which is whatever the host running the daemon happened to
/// have.
fn client_term(&self) -> Option<&str> {
None
#[cfg(test)]
pub mod tests_support {
//! Test doubles for [`Transport`]. The transports here back onto
//! [`ratatui::backend::TestBackend`] so tests can drive `run_loop`
//! without a real terminal.
use super::{RawEvent, Transport};
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use std::collections::VecDeque;
/// A [`Transport`] that yields a pre-scripted sequence of
/// [`RawEvent`]s and then times out (poll returns `None`).
pub struct ScriptedTransport {
terminal: Terminal<TestBackend>,
queue: VecDeque<RawEvent>,
impl ScriptedTransport {
/// # Panics
/// Panics only if `ratatui::Terminal::new` reports an error
/// for `TestBackend`, which is unreachable: `TestBackend`'s
/// associated `Error` type is `Infallible`.
#[must_use]
pub fn new(width: u16, height: u16, events: Vec<RawEvent>) -> Self {
Self {
terminal: Terminal::new(TestBackend::new(width, height))
.expect("TestBackend never errors"),
queue: events.into(),
impl Transport for ScriptedTransport {
type Backend = TestBackend;
fn terminal_mut(&mut self) -> &mut Terminal<TestBackend> {
&mut self.terminal
fn poll(&mut self, _timeout: Duration) -> io::Result<Option<RawEvent>> {
Ok(self.queue.pop_front())
fn finish(self) -> io::Result<()> {
/// A [`Transport`] whose `poll` always returns `io::ErrorKind::
/// UnexpectedEof`, emulating an SSH channel closed by the remote.
pub struct DisconnectedTransport {
impl DisconnectedTransport {
pub fn new(width: u16, height: u16) -> Self {
impl Transport for DisconnectedTransport {
Err(io::Error::new(io::ErrorKind::UnexpectedEof, "peer hung up"))
mod tests {
use super::*;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use tests_support::{DisconnectedTransport, ScriptedTransport};
#[test]
fn scripted_transport_drains_events_in_order() {
let events = vec![
RawEvent::Key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)),
RawEvent::Resize(80, 24),
];
let mut t = ScriptedTransport::new(40, 10, events);
let first = t.poll(Duration::from_millis(0)).unwrap();
assert!(matches!(first, Some(RawEvent::Key(_))));
let second = t.poll(Duration::from_millis(0)).unwrap();
assert!(matches!(second, Some(RawEvent::Resize(80, 24))));
let third = t.poll(Duration::from_millis(0)).unwrap();
assert!(third.is_none());
fn disconnected_transport_reports_eof() {
let mut t = DisconnectedTransport::new(40, 10);
let err = t.poll(Duration::from_millis(0)).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::UnexpectedEof);
fn transport_finish_default_is_ok() {
// A minimal impl that only overrides terminal_mut/poll uses
// the default `finish`, which must be Ok(()).
struct Minimal {
term: ratatui::Terminal<ratatui::backend::TestBackend>,
impl Transport for Minimal {
type Backend = ratatui::backend::TestBackend;
fn terminal_mut(&mut self) -> &mut ratatui::Terminal<Self::Backend> {
&mut self.term
fn poll(&mut self, _: Duration) -> io::Result<Option<RawEvent>> {
Ok(None)
let t = Minimal {
term: ratatui::Terminal::new(ratatui::backend::TestBackend::new(10, 5)).unwrap(),
};
assert!(t.finish().is_ok());