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