Skip to main content

photon_ui/
terminal.rs

1use std::io;
2
3macro_rules! try_io {
4    ($expr:expr) => {
5        match $expr {
6            | Ok(v) => v,
7            | Err(e) => return Err(e),
8        }
9    };
10}
11
12/// Abstraction over a terminal device.
13///
14/// Both real terminals ([`ProcessTerminal`]) and test doubles
15/// ([`TestTerminal`]) implement this trait so the rest of the framework remains
16/// agnostic to the underlying I/O mechanism.
17pub trait Terminal {
18    /// Enter raw mode, alternate screen, and hide the cursor.
19    fn start(&mut self) -> io::Result<()>;
20    /// Leave raw mode, alternate screen, and show the cursor.
21    fn stop(&mut self) -> io::Result<()>;
22    /// Write raw data to the terminal.
23    fn write(&mut self, data: &str) -> io::Result<()>;
24    /// Return the current terminal size as `(cols, rows)`.
25    fn size(&self) -> io::Result<(u16, u16)>;
26    /// Move the hardware cursor to `(row, col)`.
27    fn move_cursor(&mut self, row: u16, col: u16) -> io::Result<()>;
28    /// Hide the hardware cursor.
29    fn hide_cursor(&mut self) -> io::Result<()>;
30    /// Show the hardware cursor.
31    fn show_cursor(&mut self) -> io::Result<()>;
32}
33
34/// In-memory terminal double for testing.
35///
36/// Records all writes, cursor moves, and cursor visibility changes so tests
37/// can assert on the exact ANSI sequences emitted by the renderer.
38pub struct TestTerminal {
39    cols: u16,
40    rows: u16,
41    buffer: Vec<String>,
42    cursor_moves: Vec<(u16, u16)>,
43    cursor_hidden: bool,
44}
45
46impl TestTerminal {
47    /// Create a new test terminal with the given dimensions.
48    pub fn new(cols: u16, rows: u16) -> Self {
49        Self {
50            cols,
51            rows,
52            buffer: Vec::new(),
53            cursor_moves: Vec::new(),
54            cursor_hidden: false,
55        }
56    }
57
58    /// All strings written via [`Terminal::write`] since creation.
59    pub fn written(&self) -> &Vec<String> {
60        &self.buffer
61    }
62
63    /// All cursor positions passed to [`Terminal::move_cursor`] since creation.
64    pub fn cursor_moves(&self) -> &Vec<(u16, u16)> {
65        &self.cursor_moves
66    }
67
68    /// Whether the cursor was most recently hidden.
69    pub fn is_cursor_hidden(&self) -> bool {
70        self.cursor_hidden
71    }
72}
73
74impl Terminal for TestTerminal {
75    fn start(&mut self) -> io::Result<()> {
76        Ok(())
77    }
78
79    fn stop(&mut self) -> io::Result<()> {
80        Ok(())
81    }
82
83    fn write(&mut self, data: &str) -> io::Result<()> {
84        self.buffer.push(data.to_string());
85        Ok(())
86    }
87
88    fn size(&self) -> io::Result<(u16, u16)> {
89        Ok((self.cols, self.rows))
90    }
91
92    fn move_cursor(&mut self, row: u16, col: u16) -> io::Result<()> {
93        self.cursor_moves.push((row, col));
94        Ok(())
95    }
96
97    fn hide_cursor(&mut self) -> io::Result<()> {
98        self.cursor_hidden = true;
99        Ok(())
100    }
101
102    fn show_cursor(&mut self) -> io::Result<()> {
103        self.cursor_hidden = false;
104        Ok(())
105    }
106}
107
108/// Real terminal backed by stdout.
109///
110/// Uses [`crossterm`] for raw-mode management, alternate screen, and cursor
111/// control. When constructed via `new_test` (available under `#[cfg(test)]`),
112/// it writes to an in-memory buffer and reports a fixed size of 80×24, which is
113/// useful for unit-testing code paths that require a [`Terminal`] but do not
114/// need a real TTY.
115pub struct ProcessTerminal {
116    stdout: Box<dyn std::io::Write>,
117    is_tty: bool,
118}
119
120impl Default for ProcessTerminal {
121    fn default() -> Self {
122        Self::new()
123    }
124}
125
126impl ProcessTerminal {
127    /// Create a terminal connected to the process stdout.
128    pub fn new() -> Self {
129        Self {
130            stdout: Box::new(std::io::stdout()),
131            is_tty: true,
132        }
133    }
134
135    /// Create a test terminal that writes to an in-memory buffer.
136    #[cfg(test)]
137    pub fn new_test() -> Self {
138        Self {
139            stdout: Box::new(Vec::new()),
140            is_tty: false,
141        }
142    }
143}
144
145impl Terminal for ProcessTerminal {
146    fn start(&mut self) -> io::Result<()> {
147        if self.is_tty {
148            try_io!(crossterm::terminal::enable_raw_mode());
149        }
150        try_io!(crossterm::execute!(
151            self.stdout,
152            crossterm::terminal::EnterAlternateScreen,
153            crossterm::cursor::Hide
154        ));
155        Ok(())
156    }
157
158    fn stop(&mut self) -> io::Result<()> {
159        try_io!(crossterm::execute!(
160            self.stdout,
161            crossterm::cursor::Show,
162            crossterm::terminal::LeaveAlternateScreen
163        ));
164        if self.is_tty {
165            try_io!(crossterm::terminal::disable_raw_mode());
166        }
167        Ok(())
168    }
169
170    fn write(&mut self, data: &str) -> io::Result<()> {
171        use std::io::Write;
172        try_io!(self.stdout.write_all(data.as_bytes()));
173        try_io!(self.stdout.flush());
174        Ok(())
175    }
176
177    fn size(&self) -> io::Result<(u16, u16)> {
178        if self.is_tty {
179            crossterm::terminal::size()
180        } else {
181            Ok((80, 24))
182        }
183    }
184
185    fn move_cursor(&mut self, row: u16, col: u16) -> io::Result<()> {
186        try_io!(crossterm::execute!(
187            self.stdout,
188            crossterm::cursor::MoveTo(col, row)
189        ));
190        Ok(())
191    }
192
193    fn hide_cursor(&mut self) -> io::Result<()> {
194        try_io!(crossterm::execute!(self.stdout, crossterm::cursor::Hide));
195        Ok(())
196    }
197
198    fn show_cursor(&mut self) -> io::Result<()> {
199        try_io!(crossterm::execute!(self.stdout, crossterm::cursor::Show));
200        Ok(())
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[test]
209    fn process_terminal_all_methods() {
210        let mut term = ProcessTerminal::new_test();
211        term.start().unwrap();
212        term.write("hello").unwrap();
213        assert_eq!(term.size().unwrap(), (80, 24));
214        term.move_cursor(5, 10).unwrap();
215        term.hide_cursor().unwrap();
216        term.show_cursor().unwrap();
217        term.stop().unwrap();
218    }
219
220    #[test]
221    fn process_terminal_new_does_not_panic() {
222        let _term = ProcessTerminal::new();
223    }
224}