Skip to main content

tui/transport/
mod.rs

1//! Transport abstraction.
2//!
3//! The TUI event loop sits on top of two I/O concerns:
4//!
5//! 1. A [`ratatui::backend::Backend`] that receives rendered frames.
6//! 2. A source of user input events (keystrokes, terminal resizes).
7//!
8//! Both are hidden behind [`Transport`]. A local invocation uses
9//! [`LocalTransport`], which owns a `CrosstermBackend<Stdout>` and
10//! polls crossterm for events. The SSH daemon will later provide a
11//! different impl that writes to a russh channel and reads keystrokes
12//! from that channel.
13//!
14//! Keeping this layer tiny is deliberate. Anything richer (history,
15//! mouse support, paste handling) grows inside `App` / `Intent`, not
16//! here.
17
18use std::io;
19use std::time::Duration;
20
21pub mod local;
22
23pub use local::LocalTransport;
24
25/// Input events the event loop cares about. Keystrokes and resizes
26/// are explicit; disconnect is signalled via [`Transport::poll`]
27/// returning an `io::Error`.
28#[derive(Debug, Clone, Copy)]
29pub enum RawEvent {
30    Key(crossterm::event::KeyEvent),
31    Resize(u16, u16),
32}
33
34/// Bridge between the event loop and whatever terminal it talks to.
35///
36/// Lifecycle: a `Transport` is constructed in whatever mode it needs
37/// (raw mode, alternate screen, SSH channel attached), then passed by
38/// mutable reference to [`crate::runtime::run_loop`]. When the loop
39/// exits, the owning code calls [`Transport::finish`] to tear down.
40///
41/// The transport owns the `Terminal` rather than just a raw backend
42/// because ratatui's `Terminal<B>` requires `B: Backend`, which does
43/// not hold for `&mut B`. Having the transport expose
44/// `terminal_mut()` keeps the runtime generic without hitting that
45/// trait-bound wall.
46pub trait Transport {
47    type Backend: ratatui::backend::Backend;
48
49    /// Mutable access to the owned ratatui `Terminal` so the runtime
50    /// can drive `draw`.
51    fn terminal_mut(&mut self) -> &mut ratatui::Terminal<Self::Backend>;
52
53    /// Block up to `timeout` waiting for an input event. Returns
54    /// `Ok(None)` on timeout, `Ok(Some(event))` on activity.
55    ///
56    /// # Errors
57    ///
58    /// Returns `Err` on disconnect (local stdin closed, SSH channel
59    /// closed) or any unrecoverable read failure.
60    fn poll(&mut self, timeout: Duration) -> io::Result<Option<RawEvent>>;
61
62    /// Restore the terminal to its pre-TUI state. The default impl is
63    /// a no-op so test transports do not have to implement it.
64    ///
65    /// # Errors
66    ///
67    /// Returns `Err` if teardown fails, e.g. writing the
68    /// "leave alternate screen" escape to a closed stdout.
69    fn finish(self) -> io::Result<()>
70    where
71        Self: Sized,
72    {
73        Ok(())
74    }
75
76    /// Forward a raw byte sequence to the same stream the backend
77    /// writes to, bypassing ratatui's cell grid. Used for terminal
78    /// escape sequences that ratatui can't model directly — kitty
79    /// graphics APC bytes, future sixel, etc. The default impl
80    /// silently drops the bytes so transports that don't speak that
81    /// dialect (or test doubles) don't need an override.
82    ///
83    /// # Errors
84    ///
85    /// Returns `Err` only if the underlying stream write fails.
86    fn write_passthrough(&mut self, _bytes: &[u8]) -> io::Result<()> {
87        Ok(())
88    }
89
90    /// Terminal type the *client* claims to be, in OpenSSH-style
91    /// `xterm-256color` form. The local transport reads `$TERM`; the
92    /// SSH transport returns whatever the peer sent in `pty-req`.
93    /// Returns `None` when the transport cannot tell.
94    ///
95    /// Capability detection (kitty graphics, true-colour, etc.)
96    /// should consult this rather than the daemon's own environment,
97    /// which is whatever the host running the daemon happened to
98    /// have.
99    fn client_term(&self) -> Option<&str> {
100        None
101    }
102}
103
104#[cfg(test)]
105pub mod tests_support {
106    //! Test doubles for [`Transport`]. The transports here back onto
107    //! [`ratatui::backend::TestBackend`] so tests can drive `run_loop`
108    //! without a real terminal.
109
110    use super::{RawEvent, Transport};
111    use ratatui::Terminal;
112    use ratatui::backend::TestBackend;
113    use std::collections::VecDeque;
114    use std::io;
115    use std::time::Duration;
116
117    /// A [`Transport`] that yields a pre-scripted sequence of
118    /// [`RawEvent`]s and then times out (poll returns `None`).
119    pub struct ScriptedTransport {
120        terminal: Terminal<TestBackend>,
121        queue: VecDeque<RawEvent>,
122    }
123
124    impl ScriptedTransport {
125        /// # Panics
126        ///
127        /// Panics only if `ratatui::Terminal::new` reports an error
128        /// for `TestBackend`, which is unreachable: `TestBackend`'s
129        /// associated `Error` type is `Infallible`.
130        #[must_use]
131        pub fn new(width: u16, height: u16, events: Vec<RawEvent>) -> Self {
132            Self {
133                terminal: Terminal::new(TestBackend::new(width, height))
134                    .expect("TestBackend never errors"),
135                queue: events.into(),
136            }
137        }
138    }
139
140    impl Transport for ScriptedTransport {
141        type Backend = TestBackend;
142
143        fn terminal_mut(&mut self) -> &mut Terminal<TestBackend> {
144            &mut self.terminal
145        }
146
147        fn poll(&mut self, _timeout: Duration) -> io::Result<Option<RawEvent>> {
148            Ok(self.queue.pop_front())
149        }
150
151        fn finish(self) -> io::Result<()> {
152            Ok(())
153        }
154    }
155
156    /// A [`Transport`] whose `poll` always returns `io::ErrorKind::
157    /// UnexpectedEof`, emulating an SSH channel closed by the remote.
158    pub struct DisconnectedTransport {
159        terminal: Terminal<TestBackend>,
160    }
161
162    impl DisconnectedTransport {
163        /// # Panics
164        ///
165        /// Panics only if `ratatui::Terminal::new` reports an error
166        /// for `TestBackend`, which is unreachable: `TestBackend`'s
167        /// associated `Error` type is `Infallible`.
168        #[must_use]
169        pub fn new(width: u16, height: u16) -> Self {
170            Self {
171                terminal: Terminal::new(TestBackend::new(width, height))
172                    .expect("TestBackend never errors"),
173            }
174        }
175    }
176
177    impl Transport for DisconnectedTransport {
178        type Backend = TestBackend;
179
180        fn terminal_mut(&mut self) -> &mut Terminal<TestBackend> {
181            &mut self.terminal
182        }
183
184        fn poll(&mut self, _timeout: Duration) -> io::Result<Option<RawEvent>> {
185            Err(io::Error::new(io::ErrorKind::UnexpectedEof, "peer hung up"))
186        }
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
194    use tests_support::{DisconnectedTransport, ScriptedTransport};
195
196    #[test]
197    fn scripted_transport_drains_events_in_order() {
198        let events = vec![
199            RawEvent::Key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)),
200            RawEvent::Resize(80, 24),
201        ];
202        let mut t = ScriptedTransport::new(40, 10, events);
203        let first = t.poll(Duration::from_millis(0)).unwrap();
204        assert!(matches!(first, Some(RawEvent::Key(_))));
205        let second = t.poll(Duration::from_millis(0)).unwrap();
206        assert!(matches!(second, Some(RawEvent::Resize(80, 24))));
207        let third = t.poll(Duration::from_millis(0)).unwrap();
208        assert!(third.is_none());
209    }
210
211    #[test]
212    fn disconnected_transport_reports_eof() {
213        let mut t = DisconnectedTransport::new(40, 10);
214        let err = t.poll(Duration::from_millis(0)).unwrap_err();
215        assert_eq!(err.kind(), io::ErrorKind::UnexpectedEof);
216    }
217
218    #[test]
219    fn transport_finish_default_is_ok() {
220        // A minimal impl that only overrides terminal_mut/poll uses
221        // the default `finish`, which must be Ok(()).
222        struct Minimal {
223            term: ratatui::Terminal<ratatui::backend::TestBackend>,
224        }
225        impl Transport for Minimal {
226            type Backend = ratatui::backend::TestBackend;
227            fn terminal_mut(&mut self) -> &mut ratatui::Terminal<Self::Backend> {
228                &mut self.term
229            }
230            fn poll(&mut self, _: Duration) -> io::Result<Option<RawEvent>> {
231                Ok(None)
232            }
233        }
234        let t = Minimal {
235            term: ratatui::Terminal::new(ratatui::backend::TestBackend::new(10, 5)).unwrap(),
236        };
237        assert!(t.finish().is_ok());
238    }
239}