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}