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}