Skip to main content

urushi_tui/terminal/
screen.rs

1//! Frame state, diffing, and transactional presentation.
2
3use std::io;
4
5use urushi::StyledGrapheme;
6use urushi_terminal::{CommandWriter, Position, TerminalSize};
7
8use super::Rect;
9use super::output::CellWriter;
10use crate::cell::{Buffer, BufferSizeError, CellWriteError};
11
12/// A synchronous full-screen presentation engine.
13///
14/// `Screen` owns the working and committed cell buffers, but it does not own a
15/// terminal session or any input path. A caller may use it directly in its own
16/// loop with any [`CommandWriter`], independently of the optional Urushi TEA
17/// runtime and Tokio.
18///
19/// A draw is transactional with respect to the committed baseline: changed
20/// cells, the cursor request, and the writer flush must all succeed before the
21/// working frame becomes committed. If output fails after an arbitrary prefix,
22/// the next draw clears the physical surface and reconstructs it from a blank
23/// baseline.
24pub struct Screen<W> {
25    writer: W,
26    committed: Buffer,
27    working: Buffer,
28    needs_clear: bool,
29}
30
31impl<W: CommandWriter> Screen<W> {
32    /// Creates a screen for a terminal surface of `size`.
33    pub fn new(writer: W, size: TerminalSize) -> io::Result<Self> {
34        let committed = Buffer::new(size).map_err(buffer_size_error)?;
35        let working = Buffer::new(size).map_err(buffer_size_error)?;
36        Ok(Self {
37            writer,
38            committed,
39            working,
40            needs_clear: true,
41        })
42    }
43
44    /// Returns the current frame size.
45    pub const fn size(&self) -> TerminalSize {
46        self.working.size()
47    }
48
49    /// Returns the command writer used for physical output.
50    pub const fn writer(&self) -> &W {
51        &self.writer
52    }
53
54    /// Consumes the screen and returns its command writer.
55    pub fn into_inner(self) -> W {
56        self.writer
57    }
58
59    /// Replaces both frame buffers and invalidates the physical baseline.
60    pub fn resize(&mut self, size: TerminalSize) -> io::Result<()> {
61        let working = Buffer::new(size).map_err(buffer_size_error)?;
62        self.committed.resize(size).map_err(buffer_size_error)?;
63        self.working = working;
64        self.needs_clear = true;
65        Ok(())
66    }
67
68    /// Invalidates the physical cell baseline without changing frame size.
69    ///
70    /// The next draw clears the surface and emits the complete working frame.
71    /// Hosts use this when another presentation layer, such as immediate-mode
72    /// terminal graphics, can leave pixels that a cell diff cannot remove.
73    pub fn invalidate(&mut self) {
74        self.needs_clear = true;
75    }
76
77    /// Runs direct physical output and invalidates the cell baseline.
78    ///
79    /// This is for output such as presentation-layer cleanup that can change
80    /// what is physically visible independently of the committed cell buffer.
81    /// The next draw clears the surface and reconstructs every cell, whether
82    /// `output` succeeds or fails.
83    pub fn modify_surface(
84        &mut self,
85        output: impl FnOnce(&mut W) -> io::Result<()>,
86    ) -> io::Result<()> {
87        self.needs_clear = true;
88        output(&mut self.writer)
89    }
90
91    /// Builds and presents one frame synchronously.
92    ///
93    /// The closure may only change the working cells and cursor request through
94    /// its borrowed [`Frame`]. Presentation history and commit remain owned by
95    /// the screen.
96    pub fn draw(&mut self, draw: impl FnOnce(&mut Frame<'_>)) -> io::Result<()> {
97        self.draw_with(draw, |_| Ok(()))
98    }
99
100    /// Builds one cell frame and presents additional terminal output before commit.
101    ///
102    /// `present` runs after changed cells and the cursor request have been
103    /// written, but before the final flush and cell-baseline commit. If either
104    /// closure, output, or flushing fails, the next draw reconstructs the full
105    /// cell frame from a cleared physical surface. `present` must not clear or
106    /// otherwise replace the cell layer; use [`Screen::modify_surface`] for
107    /// direct output that does.
108    pub fn draw_with(
109        &mut self,
110        draw: impl FnOnce(&mut Frame<'_>),
111        present: impl FnOnce(&mut W) -> io::Result<()>,
112    ) -> io::Result<()> {
113        self.working.reset();
114        let mut frame = Frame {
115            buffer: &mut self.working,
116            area: Rect::from_size(self.committed.size()),
117            cursor: None,
118        };
119        draw(&mut frame);
120        let cursor = frame.cursor;
121
122        let blank;
123        let baseline = if self.needs_clear {
124            blank = Buffer::new(self.working.size()).map_err(buffer_size_error)?;
125            &blank
126        } else {
127            &self.committed
128        };
129        let changes = baseline.diff(&self.working).map_err(io::Error::other)?;
130        let result = (|| {
131            if self.needs_clear {
132                self.writer.clear()?;
133            }
134            self.writer.draw(changes)?;
135            self.writer.set_cursor(cursor)?;
136            present(&mut self.writer)?;
137            self.writer.flush()
138        })();
139
140        if let Err(error) = result {
141            self.needs_clear = true;
142            return Err(error);
143        }
144
145        std::mem::swap(&mut self.committed, &mut self.working);
146        self.needs_clear = false;
147        Ok(())
148    }
149}
150
151fn buffer_size_error(error: BufferSizeError) -> io::Error {
152    io::Error::new(io::ErrorKind::InvalidInput, error)
153}
154
155/// Draw-scoped access to a screen's working presentation state.
156///
157/// A frame cannot write commands, flush, or commit. Cells that have zero width
158/// or do not fit completely inside the frame are ignored.
159pub struct Frame<'a> {
160    buffer: &'a mut Buffer,
161    area: Rect,
162    cursor: Option<Position>,
163}
164
165impl Frame<'_> {
166    /// Returns the frame's complete drawable area.
167    pub const fn area(&self) -> Rect {
168        self.area
169    }
170
171    /// Places one styled grapheme at a cell position.
172    pub fn put(&mut self, column: usize, row: usize, cell: &StyledGrapheme) {
173        match self.buffer.write(column, row, cell) {
174            Ok(()) | Err(CellWriteError::ZeroWidth) | Err(CellWriteError::OutOfBounds { .. }) => {}
175        }
176    }
177
178    /// Requests a visible cursor position, or hides the cursor with `None`.
179    pub fn set_cursor(&mut self, at: Option<Position>) {
180        self.cursor = at;
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use urushi::{Available, Color, TextStyle, View, resolve};
188    use urushi_terminal::{ClearRegion, Command, CursorMove, TerminalOutput, TerminalStyle};
189
190    #[derive(Clone, Copy, Debug, PartialEq, Eq)]
191    enum Failure {
192        Clear,
193        Print,
194        Cursor,
195        Flush,
196    }
197
198    #[derive(Debug, PartialEq, Eq)]
199    enum Recorded {
200        Clear,
201        Move(Position),
202        CursorVisible(bool),
203        Style(TerminalStyle),
204        ResetStyle,
205        Print(String),
206        Flush,
207    }
208
209    #[derive(Default)]
210    struct RecordingCommands {
211        commands: Vec<Recorded>,
212        failure: Option<Failure>,
213        reset_failure_countdown: Option<usize>,
214    }
215
216    impl RecordingCommands {
217        fn fail_once(&mut self, failure: Failure) {
218            self.failure = Some(failure);
219        }
220
221        fn take_failure(&mut self, failure: Failure) -> io::Result<()> {
222            if self.failure == Some(failure) {
223                self.failure = None;
224                Err(io::Error::other("planned terminal failure"))
225            } else {
226                Ok(())
227            }
228        }
229
230        fn fail_reset_after(&mut self, successful_resets: usize) {
231            self.reset_failure_countdown = Some(successful_resets);
232        }
233
234        fn clear_count(&self) -> usize {
235            self.commands
236                .iter()
237                .filter(|command| matches!(command, Recorded::Clear))
238                .count()
239        }
240    }
241
242    impl TerminalOutput for RecordingCommands {
243        fn flush(&mut self) -> io::Result<()> {
244            self.commands.push(Recorded::Flush);
245            self.take_failure(Failure::Flush)
246        }
247    }
248
249    impl CommandWriter for RecordingCommands {
250        fn write_command(&mut self, command: Command<'_>) -> io::Result<()> {
251            match command {
252                Command::MoveCursor(CursorMove::To(position)) => {
253                    self.commands.push(Recorded::Move(position));
254                }
255                Command::SetCursorVisible(visible) => {
256                    self.commands.push(Recorded::CursorVisible(visible));
257                    self.take_failure(Failure::Cursor)?;
258                }
259                Command::SetStyle(style) => self.commands.push(Recorded::Style(style)),
260                Command::ResetStyle => {
261                    self.commands.push(Recorded::ResetStyle);
262                    if let Some(countdown) = &mut self.reset_failure_countdown {
263                        if *countdown == 0 {
264                            self.reset_failure_countdown = None;
265                            return Err(io::Error::other("planned terminal failure"));
266                        }
267                        *countdown -= 1;
268                    }
269                }
270                Command::Print(text) => {
271                    self.commands
272                        .push(Recorded::Print(text.as_str().to_owned()));
273                    self.take_failure(Failure::Print)?;
274                }
275                Command::Clear(ClearRegion::Screen) => {
276                    self.commands.push(Recorded::Clear);
277                    self.take_failure(Failure::Clear)?;
278                }
279                _ => panic!("unexpected cell command: {command:?}"),
280            }
281            Ok(())
282        }
283    }
284
285    fn grapheme(symbol: &str) -> StyledGrapheme {
286        resolve(&View::text(symbol, TextStyle::new()), Available::size(2, 1))
287            .expect("test view resolves")
288            .rows()[0][0]
289            .clone()
290    }
291
292    fn draw_pair(
293        screen: &mut Screen<RecordingCommands>,
294        left: &StyledGrapheme,
295        right: &StyledGrapheme,
296    ) -> io::Result<()> {
297        screen.draw(|frame| {
298            frame.put(0, 0, left);
299            frame.put(1, 0, right);
300        })
301    }
302
303    #[test]
304    fn successful_draw_commits_the_incremental_baseline() {
305        let mut screen = Screen::new(RecordingCommands::default(), TerminalSize::new(2, 1))
306            .expect("screen size is valid");
307        let a = grapheme("a");
308        let b = grapheme("b");
309        let c = grapheme("c");
310        draw_pair(&mut screen, &a, &b).expect("initial frame succeeds");
311        let next = screen.writer().commands.len();
312
313        draw_pair(&mut screen, &a, &c).expect("incremental frame succeeds");
314
315        let prints = screen.writer().commands[next..]
316            .iter()
317            .filter_map(|command| match command {
318                Recorded::Print(symbol) => Some(symbol.as_str()),
319                _ => None,
320            })
321            .collect::<Vec<_>>();
322        assert_eq!(prints, ["c"]);
323        assert_eq!(screen.writer().clear_count(), 1);
324    }
325
326    #[test]
327    fn any_partial_output_failure_forces_a_complete_repair() {
328        for failure in [Failure::Print, Failure::Cursor, Failure::Flush] {
329            let mut screen = Screen::new(RecordingCommands::default(), TerminalSize::new(2, 1))
330                .expect("screen size is valid");
331            let a = grapheme("a");
332            let b = grapheme("b");
333            let c = grapheme("c");
334            let d = grapheme("d");
335            draw_pair(&mut screen, &a, &b).expect("baseline frame succeeds");
336
337            screen.writer.fail_once(failure);
338            assert!(draw_pair(&mut screen, &c, &d).is_err());
339            let repair = screen.writer().commands.len();
340            draw_pair(&mut screen, &a, &c).expect("repair frame succeeds");
341
342            let repaired = &screen.writer().commands[repair..];
343            assert!(repaired.starts_with(&[Recorded::ResetStyle, Recorded::Clear]));
344            let prints = repaired
345                .iter()
346                .filter_map(|command| match command {
347                    Recorded::Print(symbol) => Some(symbol.as_str()),
348                    _ => None,
349                })
350                .collect::<Vec<_>>();
351            assert_eq!(prints, ["a", "c"], "{failure:?}");
352            assert_eq!(screen.writer().clear_count(), 2, "{failure:?}");
353        }
354    }
355
356    #[test]
357    fn extension_failure_prevents_cell_commit_and_forces_complete_repair() {
358        let mut screen = Screen::new(RecordingCommands::default(), TerminalSize::new(2, 1))
359            .expect("screen size is valid");
360        let a = grapheme("a");
361        let b = grapheme("b");
362
363        let error = screen
364            .draw_with(
365                |frame| {
366                    frame.put(0, 0, &a);
367                    frame.put(1, 0, &b);
368                },
369                |_| Err(io::Error::other("planned extension failure")),
370            )
371            .expect_err("extension failure aborts the transaction");
372        assert_eq!(error.to_string(), "planned extension failure");
373
374        let repair = screen.writer().commands.len();
375        screen
376            .draw(|frame| {
377                frame.put(0, 0, &a);
378                frame.put(1, 0, &b);
379            })
380            .expect("the complete cell frame is retried");
381
382        assert!(
383            screen.writer().commands[repair..]
384                .starts_with(&[Recorded::ResetStyle, Recorded::Clear])
385        );
386        let prints = screen.writer().commands[repair..]
387            .iter()
388            .filter_map(|command| match command {
389                Recorded::Print(symbol) => Some(symbol.as_str()),
390                _ => None,
391            })
392            .collect::<Vec<_>>();
393        assert_eq!(prints, ["a", "b"]);
394    }
395
396    #[test]
397    fn direct_surface_output_forces_the_next_draw_to_reconstruct_cells() {
398        let mut screen = Screen::new(RecordingCommands::default(), TerminalSize::new(2, 1))
399            .expect("screen size is valid");
400        let a = grapheme("a");
401        let b = grapheme("b");
402        draw_pair(&mut screen, &a, &b).expect("baseline frame succeeds");
403
404        screen
405            .modify_surface(|writer| writer.clear())
406            .expect("direct output succeeds");
407        let repair = screen.writer().commands.len();
408        draw_pair(&mut screen, &a, &b).expect("unchanged cells are reconstructed");
409
410        let repaired = &screen.writer().commands[repair..];
411        assert!(repaired.starts_with(&[Recorded::ResetStyle, Recorded::Clear]));
412        let prints = repaired
413            .iter()
414            .filter_map(|command| match command {
415                Recorded::Print(symbol) => Some(symbol.as_str()),
416                _ => None,
417            })
418            .collect::<Vec<_>>();
419        assert_eq!(prints, ["a", "b"]);
420    }
421
422    #[test]
423    fn failed_clear_is_retried_before_cells_are_sent() {
424        let mut writer = RecordingCommands::default();
425        writer.fail_once(Failure::Clear);
426        let mut screen =
427            Screen::new(writer, TerminalSize::new(1, 1)).expect("screen size is valid");
428        let a = grapheme("a");
429
430        assert!(screen.draw(|frame| frame.put(0, 0, &a)).is_err());
431        assert!(
432            !screen
433                .writer()
434                .commands
435                .iter()
436                .any(|command| matches!(command, Recorded::Print(_)))
437        );
438        screen
439            .draw(|frame| frame.put(0, 0, &a))
440            .expect("clear and frame are retried");
441
442        assert_eq!(screen.writer().clear_count(), 2);
443    }
444
445    #[test]
446    fn empty_repair_resets_style_before_clearing() {
447        let mut screen = Screen::new(RecordingCommands::default(), TerminalSize::new(1, 1))
448            .expect("screen size is valid");
449        let styled = resolve(
450            &View::text("x", TextStyle::new().background(Color::BLUE)),
451            Available::size(1, 1),
452        )
453        .expect("test view resolves")
454        .rows()[0][0]
455            .clone();
456        screen.draw(|_| {}).expect("baseline frame succeeds");
457
458        screen.writer.fail_reset_after(1);
459        assert!(screen.draw(|frame| frame.put(0, 0, &styled)).is_err());
460        let repair = screen.writer().commands.len();
461        screen.draw(|_| {}).expect("empty repair frame succeeds");
462
463        assert!(
464            screen.writer().commands[repair..]
465                .starts_with(&[Recorded::ResetStyle, Recorded::Clear])
466        );
467    }
468
469    #[test]
470    fn resize_discards_the_old_diff_baseline() {
471        let mut screen = Screen::new(RecordingCommands::default(), TerminalSize::new(2, 1))
472            .expect("screen size is valid");
473        let a = grapheme("a");
474        screen
475            .draw(|frame| frame.put(0, 0, &a))
476            .expect("initial frame succeeds");
477
478        screen
479            .resize(TerminalSize::new(3, 1))
480            .expect("new size is valid");
481        screen
482            .draw(|frame| frame.put(0, 0, &a))
483            .expect("resized frame succeeds");
484
485        assert_eq!(screen.size(), TerminalSize::new(3, 1));
486        assert_eq!(screen.writer().clear_count(), 2);
487    }
488
489    #[test]
490    fn frame_exposes_area_and_cursor_without_owning_output() {
491        let mut screen = Screen::new(RecordingCommands::default(), TerminalSize::new(2, 1))
492            .expect("screen size is valid");
493        let wide = grapheme("界");
494
495        screen
496            .draw(|frame| {
497                assert_eq!(frame.area(), Rect::from_size(TerminalSize::new(2, 1)));
498                frame.put(1, 0, &wide);
499                frame.set_cursor(Some(Position::new(1, 0)));
500            })
501            .expect("out-of-bounds cells are ignored");
502
503        assert!(
504            screen
505                .writer()
506                .commands
507                .contains(&Recorded::CursorVisible(true))
508        );
509        assert!(
510            screen
511                .writer()
512                .commands
513                .contains(&Recorded::Move(Position::new(1, 0)))
514        );
515    }
516}