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

            
18
use std::io;
19
use std::time::Duration;
20

            
21
pub mod local;
22

            
23
pub 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)]
29
pub 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.
46
pub 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
1
    fn finish(self) -> io::Result<()>
70
1
    where
71
1
        Self: Sized,
72
    {
73
1
        Ok(())
74
1
    }
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)]
105
pub 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
5
        pub fn new(width: u16, height: u16, events: Vec<RawEvent>) -> Self {
132
5
            Self {
133
5
                terminal: Terminal::new(TestBackend::new(width, height))
134
5
                    .expect("TestBackend never errors"),
135
5
                queue: events.into(),
136
5
            }
137
5
        }
138
    }
139

            
140
    impl Transport for ScriptedTransport {
141
        type Backend = TestBackend;
142

            
143
15
        fn terminal_mut(&mut self) -> &mut Terminal<TestBackend> {
144
15
            &mut self.terminal
145
15
        }
146

            
147
18
        fn poll(&mut self, _timeout: Duration) -> io::Result<Option<RawEvent>> {
148
18
            Ok(self.queue.pop_front())
149
18
        }
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
3
        pub fn new(width: u16, height: u16) -> Self {
170
3
            Self {
171
3
                terminal: Terminal::new(TestBackend::new(width, height))
172
3
                    .expect("TestBackend never errors"),
173
3
            }
174
3
        }
175
    }
176

            
177
    impl Transport for DisconnectedTransport {
178
        type Backend = TestBackend;
179

            
180
1
        fn terminal_mut(&mut self) -> &mut Terminal<TestBackend> {
181
1
            &mut self.terminal
182
1
        }
183

            
184
2
        fn poll(&mut self, _timeout: Duration) -> io::Result<Option<RawEvent>> {
185
2
            Err(io::Error::new(io::ErrorKind::UnexpectedEof, "peer hung up"))
186
2
        }
187
    }
188
}
189

            
190
#[cfg(test)]
191
mod tests {
192
    use super::*;
193
    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
194
    use tests_support::{DisconnectedTransport, ScriptedTransport};
195

            
196
    #[test]
197
1
    fn scripted_transport_drains_events_in_order() {
198
1
        let events = vec![
199
1
            RawEvent::Key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)),
200
1
            RawEvent::Resize(80, 24),
201
        ];
202
1
        let mut t = ScriptedTransport::new(40, 10, events);
203
1
        let first = t.poll(Duration::from_millis(0)).unwrap();
204
1
        assert!(matches!(first, Some(RawEvent::Key(_))));
205
1
        let second = t.poll(Duration::from_millis(0)).unwrap();
206
1
        assert!(matches!(second, Some(RawEvent::Resize(80, 24))));
207
1
        let third = t.poll(Duration::from_millis(0)).unwrap();
208
1
        assert!(third.is_none());
209
1
    }
210

            
211
    #[test]
212
1
    fn disconnected_transport_reports_eof() {
213
1
        let mut t = DisconnectedTransport::new(40, 10);
214
1
        let err = t.poll(Duration::from_millis(0)).unwrap_err();
215
1
        assert_eq!(err.kind(), io::ErrorKind::UnexpectedEof);
216
1
    }
217

            
218
    #[test]
219
1
    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
1
        let t = Minimal {
235
1
            term: ratatui::Terminal::new(ratatui::backend::TestBackend::new(10, 5)).unwrap(),
236
1
        };
237
1
        assert!(t.finish().is_ok());
238
1
    }
239
}