Skip to main content

photon_ui/
terminal.rs

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