Skip to main content

retroglyph_core/terminal/
present.rs

1//! Frame presentation: [`draw`](Terminal::draw), [`present`](Terminal::present), and
2//! [`present_count`](Terminal::present_count).
3//!
4//! `present` is the one piece of `Terminal`'s API that has to reconcile three different backend
5//! shapes (compositing vs. cell, single-layer vs. multi-layer) with the error-recovery contract
6//! documented on it; its own doc comment and the tests below cover that matrix directly.
7
8use alloc::vec::Vec;
9
10use super::{LayerOp, Terminal};
11use crate::backend::{Backend, Compositing, Output};
12use crate::grid::{Grid, HasSize};
13use crate::surface::Surface;
14
15/// Which of `present`'s three dispatch paths a call takes, decided once up front from the same
16/// `compositing()`/`max_layer()` inputs the branches below read, rather than discovered
17/// branch-by-branch. Only [`Flattened`](Self::Flattened) leaves the flatten buffers holding this
18/// frame's content; every other variant means the buffers were bypassed and are stale for next
19/// time, which `present` derives from `path` directly instead of each branch remembering to say
20/// so (see retroglyph#960, the shipped bug from that obligation being invisible).
21#[derive(Clone, Copy)]
22enum PresentPath {
23    /// Backend composites the raw layered stream itself; `present` never touches the flatten
24    /// buffers.
25    Composited { needs_full_frame: bool },
26    /// Only layer 0 is in play; diff the real grids directly rather than flatten an exact copy.
27    SingleLayer,
28    /// Multiple layers, non-compositing backend: flatten `current` into the scratch buffers and
29    /// diff those.
30    Flattened,
31}
32
33impl<B: Backend> Terminal<B> {
34    /// Draws one frame: `f` gets a [`Surface`] scoped to the whole terminal on layer 0, then the
35    /// frame is presented (see [`present`](Self::present)) once `f` returns.
36    ///
37    /// This is the common entry point for drawing: a caller that draws every frame regardless of
38    /// whether anything changed calls this once per frame. A caller that only wants to redraw
39    /// when its own state changed should gate the call to `draw` itself (e.g. `if
40    /// state.changed() { term.draw(|s| render(s, &state))?; }`) rather than rely on `draw`/
41    /// [`present`](Self::present) to no-op.
42    ///
43    /// # Errors
44    ///
45    /// Propagates errors from [`present`](Self::present).
46    pub fn draw(&mut self, f: impl FnOnce(&mut Surface<'_>)) -> Result<(), <B as Output>::Error> {
47        let area = self.area();
48        let mut surface = Surface::new(&mut self.current, area, 0);
49        f(&mut surface);
50        self.present()
51    }
52
53    /// Number of times [`present`](Self::present) has been called so far.
54    ///
55    /// Wraps on overflow; intended for detecting whether `present` was called *at all* between two
56    /// points in time (compare a saved count against the current one), not as a precise total.
57    /// Embedding drivers (e.g. `retroglyph-window`'s windowed drivers) use this to decide whether
58    /// application code already presented during a frame, so they can skip a redundant
59    /// driver-side present.
60    #[must_use]
61    pub const fn present_count(&self) -> u64 {
62        self.present_count
63    }
64
65    /// Layer ids with `op` currently pending, in ascending order.
66    ///
67    /// The single `u8::try_from` site for `pending_layer_ops`: see `present`'s own `# Panics`
68    /// note for why it never actually panics.
69    fn pending_layers(&self, op: LayerOp) -> impl Iterator<Item = u8> + '_ {
70        self.pending_layer_ops
71            .iter()
72            .enumerate()
73            .filter(move |&(_, &o)| o == op)
74            .map(|(id, _)| u8::try_from(id).expect("layer table is indexed by u8 layer ids"))
75    }
76
77    /// Present the current frame: computes the diff against the previous frame, sends changed
78    /// cells to the backend, flushes, then swaps buffers. Always presents unconditionally, even
79    /// if nothing was drawn since the last call; most callers want [`draw`](Self::draw) instead
80    /// of calling this directly.
81    ///
82    /// When the backend requires a full frame (see
83    /// [`crate::backend::Compositing::PixelLayered`]'s `needs_full_frame` field), all cells from
84    /// every allocated layer are sent rather than just the diff, so pixel-based backends can
85    /// clear and redraw to avoid orphaned pixels from sub-cell offsets.
86    ///
87    /// After a present, the new current buffer is cleared so the next frame starts empty.
88    /// Callers should not draw into a frame and skip presenting it: the next [`draw`](Self::draw)
89    /// call starts from an empty grid regardless.
90    ///
91    /// # Immediate mode
92    ///
93    /// This is an immediate-mode API (the same trade [ratatui] makes): the
94    /// current buffer is wiped after every present, so each frame must redraw
95    /// its entire scene from scratch by default. [`retain_layer`](Self::retain_layer) is the
96    /// escape hatch: it makes one specific layer's last-presented content stand in for a redraw,
97    /// so the app can skip regenerating it. The diff only bounds what is sent to the backend
98    /// (terminal or pixel I/O); it does not bound the CPU cost of your redraw, except for a
99    /// layer marked via `retain_layer`.
100    ///
101    /// [ratatui]: https://docs.rs/ratatui
102    ///
103    /// # Panics
104    ///
105    /// Never panics in practice: `pending_layer_ops` is indexed by u8 layer id and grown only up
106    /// to `idx + 1` for `idx = usize::from(layer_id)` in
107    /// [`retain_layer`](Self::retain_layer)/[`drop_layer`](Self::drop_layer), so its length is
108    /// always at most 256 and every index encountered here fits in u8.
109    ///
110    /// # Errors
111    ///
112    /// Propagates errors from the backend's [`draw_layers`](crate::backend::Output::draw_layers) or
113    /// [`flush`](crate::backend::Output::flush) operations. Either failure returns before the
114    /// current/previous buffers are swapped, so the cells from the failed frame stay marked
115    /// dirty in `previous` and are resent the next time `present` succeeds. `current` is still
116    /// cleared, same as on success, so the caller doesn't need to redraw anything to recover:
117    /// just call `draw`/`present` again, and the next frame starts from an empty grid like any
118    /// other.
119    #[doc(alias = "flush")]
120    #[doc(alias = "render")]
121    pub fn present(&mut self) -> Result<(), <B as Output>::Error> {
122        self.present_count = self.present_count.wrapping_add(1);
123        let retained: Vec<u8> = self.pending_layers(LayerOp::Retain).collect();
124        if !retained.is_empty() {
125            // Overwrite each retained layer's (empty, never-drawn-this-frame) content in
126            // `current` with `previous`'s, so the diff below finds no change on it: the backend
127            // gets nothing to redraw, and the copy (a flat per-layer clone) is far cheaper than
128            // whatever the app would have spent regenerating identical content. See
129            // `retain_layer`'s doc for why this has to run before the diff rather than skip the
130            // post-swap clear: `current` and `previous` alternate buffers every present, so
131            // anything short of re-syncing from the authoritative `previous` here would desync
132            // them again after a second consecutive retained frame.
133            //
134            // Uses `copy_layer_from` rather than `blit`: `blit` is a clipping/positioning copy
135            // that degrades multi-cell spans to their text fallback and treats empty tiles as
136            // transparent (an overlay, not a replacement), both wrong here, since a retained
137            // layer is copied whole, at the same geometry, and must be indistinguishable from
138            // what was presented last frame, whatever the app did or didn't draw into it this
139            // frame (retroglyph#955, retroglyph#956).
140            for id in retained {
141                self.current.copy_layer_from(id, &self.previous);
142                self.pending_layer_ops[usize::from(id)] = LayerOp::None;
143            }
144        }
145        // Decided once, up front, from the same `compositing()`/`max_layer()` inputs the three
146        // branches below read: see `PresentPath`'s docs for why this replaced each branch
147        // separately remembering to mark the flatten buffers stale.
148        let path = match self.backend.compositing() {
149            Compositing::PixelLayered { needs_full_frame } => {
150                PresentPath::Composited { needs_full_frame }
151            }
152            // Sticky-off, not sticky-on: layers are never deallocated on their own once written
153            // (see `Grid`'s layer storage), so `max_layer()` never drops back to 0 on its own. A
154            // terminal that ever draws to layer 1+, even for a single transient frame, stays on
155            // `Flattened` for the rest of the process, unless it explicitly calls `drop_layer` on
156            // every layer above 0 (retroglyph#1028).
157            Compositing::CellFlattened
158                if self.current.max_layer() == 0 && self.previous.max_layer() == 0 =>
159            {
160                PresentPath::SingleLayer
161            }
162            Compositing::CellFlattened => PresentPath::Flattened,
163        };
164        // The fallible part is scoped to this closure so both the success and error paths
165        // below can clear `current` before returning: `current` is presentation-buffer state
166        // for the *next* frame, not part of what makes the resend-on-retry behavior work (that
167        // lives entirely in `previous`/`flattened_previous`, left untouched here), so clearing
168        // it is safe unconditionally and keeps immediate mode's "next `draw` starts empty"
169        // contract true even after a failed present.
170        let result = (|| -> Result<(), <B as Output>::Error> {
171            match path {
172                PresentPath::Composited { needs_full_frame } => {
173                    // Pixel/GPU backends composite the raw layered stream themselves.
174                    if needs_full_frame {
175                        let all = self.current.layers();
176                        self.backend.draw_layers(all)?;
177                    } else {
178                        let diff = self.current.diff(&self.previous);
179                        self.backend.draw_layers(diff)?;
180                    }
181                }
182                PresentPath::SingleLayer => {
183                    // Fast path: only layer 0 is in play, so flattening would be an exact
184                    // copy of `current`. Diff the real grids directly and skip the
185                    // flatten buffers entirely.
186                    let diff = self.current.diff(&self.previous);
187                    self.backend.draw_layers(diff)?;
188                }
189                PresentPath::Flattened => {
190                    // Cell backends receive a pre-flattened, single-layer diff so layers
191                    // 1+ appear everywhere, not just on pixel backends.
192                    let size = self.current.size();
193                    let flattened_current = self
194                        .flattened_current
195                        .get_or_insert_with(|| Grid::new(size.width(), size.height()));
196                    let flattened_previous = self
197                        .flattened_previous
198                        .get_or_insert_with(|| Grid::new(size.width(), size.height()));
199                    if self.flattened_stale {
200                        // The previous frame took a different path, so `flattened_previous`
201                        // is stale. Clear it to force a full redraw this frame.
202                        flattened_previous.clear_all();
203                    }
204                    self.current.flatten_into(flattened_current);
205                    let diff = flattened_current.diff(flattened_previous);
206                    self.backend.draw_layers(diff)?;
207                }
208            }
209            self.backend.flush()
210        })();
211        // Written here, unconditionally, rather than only on success: this has to update even
212        // when draw/flush fails below, because a failed present still ran `path`'s branch (or
213        // didn't run the flatten branch at all), and the next present's flatten-branch decision
214        // must reflect that regardless of whether this frame's error propagates.
215        self.flattened_stale = !matches!(path, PresentPath::Flattened);
216        if let Err(err) = result {
217            // `current` is cleared even on failure so the next frame still starts from an
218            // empty grid; only the swap below is skipped. `previous`/`flattened_previous`
219            // still hold the last confirmed frame, so the next `present`'s diff against them
220            // resends the cells that never actually reached the backend instead of silently
221            // dropping them.
222            self.current.clear_all();
223            return Err(err);
224        }
225        // Deallocate any layer `drop_layer` marked, now that the diff above (computed while the
226        // layer was still allocated, if only as an already-cleared buffer) has told the backend
227        // to erase whatever it last showed there. Also gated on `flush` succeeding, for the same
228        // reason as the swaps below: on failure, `previous` must keep the layer allocated so a
229        // retried `present` can still resend the erase that never actually reached the backend.
230        let dropped: Vec<u8> = self.pending_layers(LayerOp::Drop).collect();
231        for id in dropped {
232            // If the app drew to `layer` again after calling `drop_layer` but before this
233            // present, that write is a live redraw the app clearly wants kept, not stale content:
234            // cancel the drop instead of discarding it.
235            if self.current.layer_is_empty(id) {
236                self.current.deallocate_layer(id);
237                self.previous.deallocate_layer(id);
238            }
239            self.pending_layer_ops[usize::from(id)] = LayerOp::None;
240        }
241        // Both swaps happen only after `flush` succeeds, for the same reason described above.
242        if matches!(path, PresentPath::Flattened) {
243            core::mem::swap(&mut self.flattened_current, &mut self.flattened_previous);
244        }
245        core::mem::swap(&mut self.current, &mut self.previous);
246        self.current.clear_all();
247        Ok(())
248    }
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254    use crate::backend::{Cursor, DrawCell, Headless, Input};
255    use crate::color::Color;
256    use crate::color::Style;
257    use crate::event::Event;
258    use crate::grid::{Pos, Size};
259    use alloc::vec::Vec;
260    use core::time::Duration;
261
262    /// Wraps [`Headless`] and fails the next [`flush`](Output::flush) or
263    /// [`draw_layers`](Output::draw_layers) call once, then forwards everything (including a
264    /// failed `draw_layers` call's content, which already reached the inner backend) as normal.
265    /// Used to exercise `present`'s documented error-recovery contract: either failure must
266    /// leave the frame's cells marked dirty so they are resent on the next successful `present`.
267    ///
268    /// Its `composites_layers` field is also configurable, so the same helper covers the
269    /// compositing, flatten, and single-layer fast-path branches of `present`.
270    ///
271    /// `std`-only: its `Output::Error` is `std::io::Error`, purely as a convenient stand-in
272    /// error type for this test.
273    #[cfg(feature = "std")]
274    struct FlushOnceFailing {
275        inner: Headless,
276        fail_next_flush: bool,
277        fail_next_draw_layers: bool,
278        composites_layers: bool,
279        /// Number of cells received by the most recent `draw_layers` call, so tests can
280        /// tell whether a frame's diff was actually sent, independent of `Headless`'s
281        /// applied grid (which a real backend might not update until well after `flush`).
282        last_draw_len: usize,
283    }
284
285    #[cfg(feature = "std")]
286    impl FlushOnceFailing {
287        fn new(width: u16, height: u16) -> Self {
288            Self {
289                inner: Headless::new(width, height),
290                fail_next_flush: false,
291                fail_next_draw_layers: false,
292                composites_layers: false,
293                last_draw_len: 0,
294            }
295        }
296    }
297
298    #[cfg(feature = "std")]
299    impl Output for FlushOnceFailing {
300        type Error = std::io::Error;
301
302        fn draw_layers<'a, I>(&mut self, content: I) -> Result<(), Self::Error>
303        where
304            I: Iterator<Item = DrawCell<'a>>,
305        {
306            if self.fail_next_draw_layers {
307                self.fail_next_draw_layers = false;
308                return Err(std::io::Error::other("simulated draw_layers failure"));
309            }
310            let content: Vec<_> = content.collect();
311            self.last_draw_len = content.len();
312            // Infallible in `Headless`; map its error type to ours to keep the wrapper's
313            // error type consistent across all `Output` methods.
314            self.inner
315                .draw_layers(content.into_iter())
316                .map_err(|e| match e {})
317        }
318
319        fn flush(&mut self) -> Result<(), Self::Error> {
320            if self.fail_next_flush {
321                self.fail_next_flush = false;
322                return Err(std::io::Error::other("simulated flush failure"));
323            }
324            self.inner.flush().map_err(|e| match e {})
325        }
326
327        fn size(&self) -> Size {
328            self.inner.size()
329        }
330
331        fn clear(&mut self) -> Result<(), Self::Error> {
332            self.inner.clear().map_err(|e| match e {})
333        }
334
335        fn compositing(&self) -> Compositing {
336            if self.composites_layers {
337                Compositing::PixelLayered {
338                    needs_full_frame: false,
339                }
340            } else {
341                Compositing::CellFlattened
342            }
343        }
344    }
345
346    #[cfg(feature = "std")]
347    impl Input for FlushOnceFailing {
348        fn poll_event(&mut self, timeout: Duration) -> Option<Event> {
349            self.inner.poll_event(timeout)
350        }
351    }
352
353    #[cfg(feature = "std")]
354    impl Cursor for FlushOnceFailing {}
355
356    #[test]
357    fn test_draw_composites_layers_for_cell_backend() {
358        // A cell backend (Headless) must see layers 1+ composited, not
359        // dropped. Terrain on layer 0, entity on layer 1.
360        let mut term = Terminal::new(Headless::new(3, 1));
361        term.draw(|s| {
362            s.put((0, 0), '.', Style::default());
363            s.put((1, 0), '.', Style::default());
364            s.on_layer(1).put((1, 0), '@', Style::default());
365        })
366        .expect("draw failed");
367        assert_eq!(term.backend().grid()[Pos::new(0, 0)].glyph(), '.');
368        // Layer 1's glyph wins at (1, 0).
369        assert_eq!(term.backend().grid()[Pos::new(1, 0)].glyph(), '@');
370    }
371
372    /// A `Compositing::PixelLayered` backend, the branch of `present` no real backend in this
373    /// workspace's core tests exercises (`retroglyph-gl`/`retroglyph-software` test their own
374    /// side of the [`Output`] contract, not `present`'s choice between it and a diff).
375    /// `needs_full_frame` is fixed at construction, so one struct covers both dispatch modes.
376    ///
377    /// Unlike [`Headless`], this records the raw `(layer, pos, glyph)` cells it receives instead
378    /// of writing them into a single flat grid: a real compositing backend interprets an
379    /// unwritten (default/blank) cell on a higher layer as transparent, but `Headless::draw_layers`
380    /// writes every cell it's handed literally to one shared grid regardless of layer, so replaying
381    /// a raw multi-layer stream through it (rather than the pre-flattened stream the non-
382    /// compositing path sends) does not reproduce correct compositing.
383    struct CompositingBackend {
384        size: Size,
385        full_frame: bool,
386        last_draw_cells: Vec<(u8, Pos, char)>,
387    }
388
389    impl CompositingBackend {
390        fn new(width: u16, height: u16, full_frame: bool) -> Self {
391            Self {
392                size: Size::new(width, height),
393                full_frame,
394                last_draw_cells: Vec::new(),
395            }
396        }
397    }
398
399    impl Output for CompositingBackend {
400        type Error = core::convert::Infallible;
401
402        fn draw_layers<'a, I>(&mut self, content: I) -> Result<(), Self::Error>
403        where
404            I: Iterator<Item = DrawCell<'a>>,
405        {
406            self.last_draw_cells = content
407                .map(|cell| (cell.layer, cell.pos, cell.tile.glyph()))
408                .collect();
409            Ok(())
410        }
411
412        fn flush(&mut self) -> Result<(), Self::Error> {
413            Ok(())
414        }
415
416        fn size(&self) -> Size {
417            self.size
418        }
419
420        fn clear(&mut self) -> Result<(), Self::Error> {
421            Ok(())
422        }
423
424        fn compositing(&self) -> Compositing {
425            Compositing::PixelLayered {
426                needs_full_frame: self.full_frame,
427            }
428        }
429    }
430
431    impl Input for CompositingBackend {
432        fn poll_event(&mut self, _timeout: Duration) -> Option<Event> {
433            None
434        }
435    }
436
437    impl Cursor for CompositingBackend {}
438
439    #[test]
440    fn test_composites_layers_diff_dispatches_only_changed_cells() {
441        let mut term = Terminal::new(CompositingBackend::new(3, 1, false));
442        term.draw(|s| {
443            s.put((0, 0), 'a', Style::default());
444            s.put((1, 0), 'b', Style::default());
445        })
446        .expect("draw failed");
447        assert_eq!(
448            term.backend().last_draw_cells.len(),
449            2,
450            "first frame: only the two written cells differ from the pre-allocated blank layer 0"
451        );
452
453        // Second, identical frame: nothing changed, so the compositing branch's diff half sends
454        // nothing, same as the non-compositing diff path.
455        term.draw(|s| {
456            s.put((0, 0), 'a', Style::default());
457            s.put((1, 0), 'b', Style::default());
458        })
459        .expect("draw failed");
460        assert!(
461            term.backend().last_draw_cells.is_empty(),
462            "PixelLayered {{ needs_full_frame: false }} still dispatches only the diff"
463        );
464    }
465
466    #[test]
467    fn test_composites_layers_full_frame_dispatches_every_allocated_cell() {
468        let mut term = Terminal::new(CompositingBackend::new(3, 1, true));
469        term.draw(|s| {
470            s.put((0, 0), 'a', Style::default());
471            s.put((1, 0), 'b', Style::default());
472        })
473        .expect("draw failed");
474        assert_eq!(
475            term.backend().last_draw_cells.len(),
476            3,
477            "first frame: every cell in the sole allocated layer (width 3), not just the two \
478             written ones"
479        );
480
481        // Second, identical frame: unlike the diff branch above, needs_full_frame: true actually
482        // takes effect here, so the whole layer is resent rather than an empty diff.
483        term.draw(|s| {
484            s.put((0, 0), 'a', Style::default());
485            s.put((1, 0), 'b', Style::default());
486        })
487        .expect("draw failed");
488        assert_eq!(
489            term.backend().last_draw_cells.len(),
490            3,
491            "PixelLayered {{ needs_full_frame: true }} resends every allocated cell on every present"
492        );
493    }
494
495    /// A cell backend whose `compositing()` can be toggled between presents, standing in
496    /// for a backend that degrades from pixel compositing to a cell path at runtime (retroglyph#960).
497    struct TogglingCompositor {
498        inner: Headless,
499        composites: bool,
500    }
501
502    impl Output for TogglingCompositor {
503        type Error = core::convert::Infallible;
504
505        fn draw_layers<'a, I>(&mut self, content: I) -> Result<(), Self::Error>
506        where
507            I: Iterator<Item = DrawCell<'a>>,
508        {
509            self.inner.draw_layers(content)
510        }
511
512        fn flush(&mut self) -> Result<(), Self::Error> {
513            self.inner.flush()
514        }
515
516        fn size(&self) -> Size {
517            self.inner.size()
518        }
519
520        fn clear(&mut self) -> Result<(), Self::Error> {
521            self.inner.clear()
522        }
523
524        fn compositing(&self) -> Compositing {
525            if self.composites {
526                Compositing::PixelLayered {
527                    needs_full_frame: false,
528                }
529            } else {
530                Compositing::CellFlattened
531            }
532        }
533    }
534
535    impl Input for TogglingCompositor {
536        fn poll_event(&mut self, timeout: Duration) -> Option<Event> {
537            self.inner.poll_event(timeout)
538        }
539    }
540
541    impl Cursor for TogglingCompositor {}
542
543    #[test]
544    fn present_marks_flatten_buffers_stale_after_a_composites_layers_present() {
545        // A backend that ever answers `PixelLayered` from `compositing()` and later
546        // `CellFlattened` must not leave `flattened_previous` holding a frame that was never
547        // actually the last one presented. Sequence: flatten branch (establishes stale-looking
548        // data) -> composites branch (bypasses the flatten buffers entirely) -> flatten branch
549        // again, where the bug would incorrectly diff against the first frame's flattened data
550        // instead of the second.
551        let mut term = Terminal::new(TogglingCompositor {
552            inner: Headless::new(3, 1),
553            composites: false,
554        });
555
556        // Frame 1: flatten branch. Layer 1 is touched so `max_layer() != 0`, and
557        // `compositing()` is `CellFlattened`, so this flattens and diffs normally.
558        term.draw(|s| {
559            s.put((0, 0), 'a', Style::default());
560            s.on_layer(1).put((1, 0), '#', Style::default());
561        })
562        .expect("draw failed");
563        assert_eq!(term.backend().inner.grid()[Pos::new(0, 0)].glyph(), 'a');
564
565        // Frame 2: composites branch. Bypasses the flatten buffers entirely, so
566        // `flattened_previous` still holds frame 1's flattened content. Layer 1 is redrawn
567        // identically to frame 1 so `Grid::diff` (now that it also reports a layer that stopped
568        // being written, retroglyph#1018) sees no change there and doesn't emit anything for it;
569        // `Headless::draw_layers` writes every cell to one shared grid regardless of layer (see
570        // `CompositingBackend`'s docs above), so a real layer-1 diff would corrupt this frame's
571        // single-grid glyph check, which is unrelated to what this test is verifying.
572        term.backend_mut().composites = true;
573        term.draw(|s| {
574            s.put((0, 0), 'b', Style::default());
575            s.on_layer(1).put((1, 0), '#', Style::default());
576        })
577        .expect("draw failed");
578        assert_eq!(term.backend().inner.grid()[Pos::new(0, 0)].glyph(), 'b');
579
580        // Frame 3: back to the flatten branch. Draws 'a' at (0, 0) again, on layer 0, but with
581        // layer 1 also touched so this lands in the flatten branch rather than the fast path.
582        // 'a' matches what frame 1 left in `flattened_previous`, even though the real last
583        // presented frame (frame 2) showed 'b' there. Without the fix, the stale match makes the
584        // diff skip (0, 0), and the backend keeps showing frame 2's 'b' forever.
585        term.backend_mut().composites = false;
586        term.draw(|s| {
587            s.put((0, 0), 'a', Style::default());
588            s.on_layer(1).put((2, 0), '@', Style::default());
589        })
590        .expect("draw failed");
591        assert_eq!(
592            term.backend().inner.grid()[Pos::new(0, 0)].glyph(),
593            'a',
594            "flattened_previous must be cleared after a PixelLayered present, not diffed \
595             against as if it were the last frame actually shown"
596        );
597
598        // `TogglingCompositor` forwards `clear` and `poll_event` unconditionally, same as every
599        // other method on it, so exercise both here rather than leaving them as dead delegation.
600        term.backend_mut().clear().expect("clear failed");
601        term.backend_mut().inner.push_event(Event::Close);
602        assert_eq!(term.poll(Duration::ZERO), Some(Event::Close));
603    }
604
605    #[test]
606    fn test_draw_explicit_space_on_higher_layer_erases_and_sets_bg() {
607        // An explicit space on a higher layer is opaque: it overwrites the
608        // glyph beneath (erase) and applies its background. This is the
609        // deliberate consequence of the explicit-EMPTY transparency model.
610        let mut term = Terminal::new(Headless::new(2, 1));
611        term.draw(|s| {
612            s.put((0, 0), 'x', Style::default());
613            s.on_layer(1).put((0, 0), ' ', Style::new().bg(Color::RED));
614        })
615        .expect("draw failed");
616        let cell = term.backend().grid()[Pos::new(0, 0)];
617        assert_eq!(cell.glyph(), ' ');
618        assert_eq!(cell.style().background(), Color::RED);
619    }
620
621    #[test]
622    fn test_draw_single_layer_fast_path_matches_backend() {
623        // Only layer 0 is ever touched: the fast path must still deliver the
624        // correct cells to a cell backend across multiple frames.
625        let mut term = Terminal::new(Headless::new(3, 1));
626        term.draw(|s| s.put((0, 0), 'a', Style::default()))
627            .expect("draw failed");
628        assert_eq!(term.backend().grid()[Pos::new(0, 0)].glyph(), 'a');
629
630        // Immediate mode: redraw 'a' and add 'c'.
631        term.draw(|s| {
632            s.put((0, 0), 'a', Style::default());
633            s.put((2, 0), 'c', Style::default());
634        })
635        .expect("draw failed");
636        assert_eq!(term.backend().grid()[Pos::new(0, 0)].glyph(), 'a');
637        assert_eq!(term.backend().grid()[Pos::new(2, 0)].glyph(), 'c');
638
639        // A cell that is not redrawn is erased (immediate mode).
640        term.draw(|s| s.put((0, 0), 'a', Style::default()))
641            .expect("draw failed");
642        assert_eq!(term.backend().grid()[Pos::new(0, 0)].glyph(), 'a');
643        assert_eq!(term.backend().grid()[Pos::new(2, 0)].glyph(), ' ');
644    }
645
646    #[test]
647    fn test_present_transition_single_to_multi_layer() {
648        // Start single-layer (fast path), then introduce layer 1. The frame
649        // that adds the layer must composite correctly despite the fast path
650        // having bypassed the flatten buffers.
651        let mut term = Terminal::new(Headless::new(2, 1));
652        term.draw(|s| {
653            s.put((0, 0), '.', Style::default());
654            s.put((1, 0), '.', Style::default());
655        })
656        .expect("draw failed");
657
658        term.draw(|s| {
659            s.put((0, 0), '.', Style::default());
660            s.put((1, 0), '.', Style::default());
661            s.on_layer(1).put((1, 0), '@', Style::default());
662        })
663        .expect("draw failed");
664        assert_eq!(term.backend().grid()[Pos::new(0, 0)].glyph(), '.');
665        assert_eq!(term.backend().grid()[Pos::new(1, 0)].glyph(), '@');
666    }
667
668    #[test]
669    fn test_present_transition_multi_to_single_to_multi_layer() {
670        // The reverse of the transition above: multi-layer (flatten path) drops back to
671        // single-layer (fast path, sets `flattened_stale`), then multi-layer again. The frame
672        // that returns to multi-layer must see `flattened_previous` cleared rather than diffed
673        // against the stale content the fast path bypassed, or the reintroduced layer's cells
674        // would wrongly look unchanged.
675        let mut term = Terminal::new(Headless::new(2, 1));
676        term.draw(|s| {
677            s.put((0, 0), '.', Style::default());
678            s.on_layer(1).put((1, 0), '@', Style::default());
679        })
680        .expect("draw failed");
681
682        // Single-layer frame: fast path, `flattened_stale` set.
683        term.draw(|s| s.put((0, 0), '.', Style::default()))
684            .expect("draw failed");
685        assert_eq!(term.backend().grid()[Pos::new(1, 0)].glyph(), ' ');
686
687        // Back to multi-layer: must composite correctly despite the intervening fast-path frame.
688        term.draw(|s| {
689            s.put((0, 0), '.', Style::default());
690            s.on_layer(1).put((1, 0), '@', Style::default());
691        })
692        .expect("draw failed");
693        assert_eq!(term.backend().grid()[Pos::new(0, 0)].glyph(), '.');
694        assert_eq!(term.backend().grid()[Pos::new(1, 0)].glyph(), '@');
695    }
696
697    #[cfg(feature = "std")]
698    #[test]
699    fn present_resends_cells_after_a_failed_flush_on_the_multi_layer_path() {
700        // Two-layer terminal so `present` takes the flatten-buffer path (not the
701        // single-layer fast path, which already handled this correctly).
702        let mut term = Terminal::new(FlushOnceFailing::new(2, 1));
703
704        term.backend_mut().fail_next_flush = true;
705        let result = term.draw(|s| {
706            s.put((0, 0), 'a', Style::default());
707            s.on_layer(1).put((1, 0), 'b', Style::default());
708        });
709        assert!(result.is_err(), "flush was expected to fail this frame");
710        assert_eq!(
711            term.backend().last_draw_len,
712            2,
713            "the failed frame's diff should still have been sent to draw_layers"
714        );
715
716        // Same content, flush succeeds this time. If the flatten buffers had already been
717        // swapped on the failed attempt, this diff would see "no change" and send nothing.
718        term.draw(|s| {
719            s.put((0, 0), 'a', Style::default());
720            s.on_layer(1).put((1, 0), 'b', Style::default());
721        })
722        .expect("draw failed");
723        assert_eq!(
724            term.backend().last_draw_len,
725            2,
726            "both cells must be resent since neither ever reached the screen"
727        );
728        assert_eq!(term.backend().inner.grid()[Pos::new(0, 0)].glyph(), 'a');
729        assert_eq!(term.backend().inner.grid()[Pos::new(1, 0)].glyph(), 'b');
730    }
731
732    #[cfg(feature = "std")]
733    #[test]
734    fn present_stale_flag_survives_a_failed_bypass_present() {
735        // Pins the ordering risk from retroglyph#1395: the `flattened_stale` assignment sits
736        // after the fallible closure but before the `if let Err` early return, so it updates even
737        // when the present that bypassed the flatten buffers fails. If it were only reachable on
738        // the success path instead, this test would fail: `flattened_stale` would stay `false`
739        // (left over from frame 1) into frame 3, which would then wrongly diff against
740        // `flattened_previous`'s frame-1 content instead of clearing it for a full redraw.
741        //
742        // Sequence: flatten (ok, establishes `flattened_previous`) -> compositing bypass (flush
743        // fails, but still must mark `flattened_stale`) -> flatten again, which must see the
744        // staleness and do a full redraw rather than diff against frame 1's stale data.
745        let mut term = Terminal::new(FlushOnceFailing::new(2, 1));
746
747        term.draw(|s| {
748            s.put((0, 0), 'a', Style::default());
749            s.on_layer(1).put((1, 0), '#', Style::default());
750        })
751        .expect("draw failed");
752        assert_eq!(term.backend().inner.grid()[Pos::new(0, 0)].glyph(), 'a');
753
754        // Compositing bypass path (`Compositing::PixelLayered`), and the flush fails.
755        term.backend_mut().composites_layers = true;
756        term.backend_mut().fail_next_flush = true;
757        let result = term.draw(|s| {
758            s.put((0, 0), 'a', Style::default());
759            s.on_layer(1).put((1, 0), '#', Style::default());
760        });
761        assert!(result.is_err(), "flush was expected to fail this frame");
762
763        // Back to the flatten path. Draws 'a' at (0, 0) again, matching what `flattened_previous`
764        // still holds from frame 1 (the compositing frame bypassed the flatten buffers entirely,
765        // whether or not it failed). Without `flattened_stale` surviving the failed present, this
766        // would wrongly look unchanged and skip sending (0, 0).
767        term.backend_mut().composites_layers = false;
768        term.draw(|s| {
769            s.put((0, 0), 'a', Style::default());
770            s.on_layer(1).put((1, 0), '@', Style::default());
771        })
772        .expect("draw failed");
773        assert_eq!(
774            term.backend().last_draw_len,
775            2,
776            "a full redraw is expected: (0, 0) resent despite matching flattened_previous's \
777             stale content, plus the changed (1, 0)"
778        );
779        assert_eq!(term.backend().inner.grid()[Pos::new(0, 0)].glyph(), 'a');
780        assert_eq!(term.backend().inner.grid()[Pos::new(1, 0)].glyph(), '@');
781    }
782
783    #[cfg(feature = "std")]
784    #[test]
785    fn present_resends_cells_after_a_failed_draw_layers_on_the_multi_layer_path() {
786        // `draw_layers` is the other documented early return in `present`: it must leave
787        // `previous`/`flattened_previous` untouched, same as a failed `flush`, so the next
788        // `present` resends everything rather than silently dropping it.
789        let mut term = Terminal::new(FlushOnceFailing::new(2, 1));
790
791        term.backend_mut().fail_next_draw_layers = true;
792        let result = term.draw(|s| {
793            s.put((0, 0), 'a', Style::default());
794            s.on_layer(1).put((1, 0), 'b', Style::default());
795        });
796        assert!(
797            result.is_err(),
798            "draw_layers was expected to fail this frame"
799        );
800        assert_eq!(
801            term.backend().last_draw_len,
802            0,
803            "a failed draw_layers call never recorded any content on the wrapper"
804        );
805
806        // Same content, draw_layers succeeds this time.
807        term.draw(|s| {
808            s.put((0, 0), 'a', Style::default());
809            s.on_layer(1).put((1, 0), 'b', Style::default());
810        })
811        .expect("draw failed");
812        assert_eq!(
813            term.backend().last_draw_len,
814            2,
815            "both cells must be resent since neither ever reached the screen"
816        );
817        assert_eq!(term.backend().inner.grid()[Pos::new(0, 0)].glyph(), 'a');
818        assert_eq!(term.backend().inner.grid()[Pos::new(1, 0)].glyph(), 'b');
819    }
820
821    #[cfg(feature = "std")]
822    #[test]
823    fn present_resends_cells_after_a_failed_flush_on_the_single_layer_fast_path() {
824        // Only layer 0 is ever touched, so `present` takes the fast path that diffs the raw
825        // grids directly, skipping the flatten buffers entirely.
826        let mut term = Terminal::new(FlushOnceFailing::new(2, 1));
827
828        term.backend_mut().fail_next_flush = true;
829        let result = term.draw(|s| s.put((0, 0), 'a', Style::default()));
830        assert!(result.is_err(), "flush was expected to fail this frame");
831        assert_eq!(
832            term.backend().last_draw_len,
833            1,
834            "the failed frame's diff should still have been sent to draw_layers"
835        );
836
837        // Nothing drawn this time: if the fast path's diff had already been swapped forward on
838        // the failed attempt, this frame would see no change and resend nothing.
839        term.draw(|s| s.put((0, 0), 'a', Style::default()))
840            .expect("draw failed");
841        assert_eq!(
842            term.backend().last_draw_len,
843            1,
844            "the cell must be resent since it never reached the screen"
845        );
846        assert_eq!(term.backend().inner.grid()[Pos::new(0, 0)].glyph(), 'a');
847    }
848
849    #[cfg(feature = "std")]
850    #[test]
851    fn present_failure_does_not_leak_the_failed_frames_content_into_the_next_frame() {
852        // Single-layer terminal so `present` takes the fast path, matching the issue's repro.
853        let mut term = Terminal::new(FlushOnceFailing::new(3, 1));
854
855        term.backend_mut().fail_next_flush = true;
856        let result = term.draw(|s| s.put((2, 0), 'X', Style::default()));
857        assert!(result.is_err(), "flush was expected to fail this frame");
858
859        // Next frame redraws different content and never touches (2, 0). `previous` is still
860        // empty (the swap was skipped), so if `current` had also been left holding the failed
861        // frame's 'X' (the bug), the diff below would see (2, 0) as newly changed from empty
862        // to 'X' and needlessly resend it, on top of the one cell this frame actually drew.
863        term.draw(|s| s.put((0, 0), 'A', Style::default()))
864            .expect("draw failed");
865        assert_eq!(
866            term.backend().last_draw_len,
867            1,
868            "only the redrawn cell should be sent; the failed frame's 'X' must not leak back in"
869        );
870        assert_eq!(term.backend().inner.grid()[Pos::new(0, 0)].glyph(), 'A');
871    }
872
873    #[cfg(feature = "std")]
874    #[test]
875    fn present_resends_cells_after_a_failed_flush_on_the_compositing_path() {
876        // `Compositing::PixelLayered` takes `present`'s first branch entirely, bypassing both
877        // the fast path and the flatten buffers; a failed flush there must still leave `previous`
878        // untouched so the raw per-layer diff is resent.
879        let mut term = Terminal::new(FlushOnceFailing::new(2, 1));
880        term.backend_mut().composites_layers = true;
881
882        // Layer 1 is newly allocated this frame, so its diff against an absent previous layer
883        // includes every cell in its width (see `Grid::diff`'s "newly allocated layer" case), not
884        // just the one actually written; layer 0 contributes only its one real change.
885        term.backend_mut().fail_next_flush = true;
886        let result = term.draw(|s| {
887            s.put((0, 0), 'a', Style::default());
888            s.on_layer(1).put((1, 0), 'b', Style::default());
889        });
890        assert!(result.is_err(), "flush was expected to fail this frame");
891        assert_eq!(
892            term.backend().last_draw_len,
893            3,
894            "the failed frame's diff should still have been sent to draw_layers"
895        );
896
897        // Same content, flush succeeds this time. If `previous` had already been swapped forward
898        // on the failed attempt, layer 1 would no longer be "newly allocated" and this diff would
899        // shrink to just the real changes instead of resending the same content.
900        term.draw(|s| {
901            s.put((0, 0), 'a', Style::default());
902            s.on_layer(1).put((1, 0), 'b', Style::default());
903        })
904        .expect("draw failed");
905        assert_eq!(
906            term.backend().last_draw_len,
907            3,
908            "the same diff must be resent since it never reached the screen"
909        );
910    }
911
912    #[test]
913    fn test_present_untouched_higher_layer_is_transparent() {
914        // A higher layer that was allocated but not written at this cell must
915        // not disturb the lower layer's glyph or background.
916        let mut term = Terminal::new(Headless::new(2, 1));
917        term.draw(|s| {
918            s.put((0, 0), 'x', Style::default());
919            // Allocate layer 1 by writing elsewhere, leaving (0, 0) empty.
920            s.on_layer(1).put((1, 0), 'y', Style::default());
921        })
922        .expect("draw failed");
923        assert_eq!(term.backend().grid()[Pos::new(0, 0)].glyph(), 'x');
924    }
925
926    #[test]
927    fn test_terminal_present_count_advances_once_per_present_call() {
928        let mut term = Terminal::new(Headless::new(2, 1));
929        assert_eq!(term.present_count(), 0);
930
931        term.draw(|_| {}).expect("draw failed"); // `draw` always presents.
932        assert_eq!(term.present_count(), 1);
933
934        term.present().expect("present failed");
935        assert_eq!(term.present_count(), 2);
936
937        term.present().expect("present failed");
938        assert_eq!(term.present_count(), 3);
939    }
940}