retroglyph_core/terminal/retain.rs
1//! Per-layer redraw control: [`retain_layer`](Terminal::retain_layer) and
2//! [`drop_layer`](Terminal::drop_layer).
3//!
4//! Both are one-shot opt-ins that defer part of [`present`](Terminal::present)'s work by a
5//! frame: `retain_layer` re-syncs a layer from `previous` instead of requiring a redraw, and
6//! `drop_layer` defers deallocating a layer until its erase has actually reached the backend.
7//! See each method's own doc for the full contract.
8
9use super::Terminal;
10use crate::backend::Backend;
11
12impl<B: Backend> Terminal<B> {
13 /// Marks `layer` so the next [`present`](Self::present) treats it as unchanged instead of
14 /// requiring the app to have redrawn it: `present` copies `layer`'s last-presented content
15 /// back into `current` before diffing, so the diff (and thus the backend) sees no change on
16 /// it, whatever the app did or didn't draw into it this frame.
17 ///
18 /// Call this before [`draw`](Self::draw)/[`present`](Self::present) on a frame where a
19 /// layer's content is known not to have changed (e.g. the camera didn't move since the last
20 /// frame, so a cached map layer is still correct) and skip drawing it that frame. This is
21 /// the actual point of the method: [`present`](Self::present)'s diff already keeps the
22 /// *backend* from re-receiving unchanged cells, but the *app* still has to regenerate them
23 /// every frame to produce a buffer worth diffing. Marking a layer retained lets the app skip
24 /// that regeneration too, at the cost of a per-cell copy handled internally (a flat, verbatim
25 /// replace, far cheaper than most real content generation).
26 ///
27 /// This is a one-shot opt-in, not a sticky mode: it only affects the very next `present`, so
28 /// a caller that wants a layer retained for several frames in a row must call this again
29 /// before each of them. [`resize`](Self::resize) also clears any pending retention.
30 ///
31 /// # Examples
32 ///
33 /// ```
34 /// use retroglyph_core::backend::Headless;
35 /// use retroglyph_core::surface::Layer;
36 /// use retroglyph_core::terminal::Terminal;
37 ///
38 /// let mut term = Terminal::new(Headless::new(10, 5));
39 /// let camera_moved = false;
40 ///
41 /// if camera_moved {
42 /// term.draw(|s| s.on_tier(Layer::World).print((0, 0), "map", Default::default()))
43 /// .unwrap();
44 /// } else {
45 /// // The camera didn't move this frame: skip regenerating the map layer.
46 /// term.retain_layer(Layer::World);
47 /// term.draw(|s| s.on_tier(Layer::Hud).print((0, 1), "HP: 10", Default::default()))
48 /// .unwrap();
49 /// }
50 /// ```
51 pub fn retain_layer(&mut self, layer: impl Into<u8>) {
52 let idx = usize::from(layer.into());
53 if self.retained_layers.len() <= idx {
54 self.retained_layers.resize(idx + 1, false);
55 }
56 self.retained_layers[idx] = true;
57 }
58
59 /// Marks `layer` to be deallocated, forgetting it was ever drawn to.
60 ///
61 /// [`Grid::max_layer`](crate::grid::Grid::max_layer) only grows on write, so a terminal that ever draws to a layer above 0,
62 /// even for a single frame, stays on [`present`](Self::present)'s flatten path for the rest of
63 /// the process, whether or not that layer is still in use (retroglyph#1028). This is the
64 /// explicit escape hatch: call it once a layer's content is truly done (a one-off overlay
65 /// dismissed, a transient effect finished), and once every layer above 0 has been dropped,
66 /// `present` falls back onto its single-layer fast path.
67 ///
68 /// `layer`'s content is cleared immediately (so this frame's diff still tells the backend to
69 /// erase whatever it last showed there, exactly as if the app had simply stopped drawing to
70 /// it), but the underlying buffer is only freed, and `max_layer` only allowed to fall, once
71 /// the next [`present`](Self::present) has sent that erase and no longer needs the layer for
72 /// its diff. Deallocating any earlier would make the layer invisible to `present`'s diff
73 /// (which only walks `current`'s own allocated layers), silently dropping the erase instead of
74 /// sending it.
75 ///
76 /// This is a one-shot request, not a sticky mode: unlike [`retain_layer`](Self::retain_layer),
77 /// which defers one frame's redraw, this defers the actual deallocation, so drawing to `layer`
78 /// again before the next `present` (undoing the drop) cancels it instead of losing that draw:
79 /// the layer stays allocated and behaves like any other write. Any pending
80 /// [`retain_layer`](Self::retain_layer) call for `layer` is cleared immediately, though:
81 /// retaining content that no longer exists would resurrect it on the next present regardless
82 /// of whether the drop itself goes through.
83 ///
84 /// # Panics
85 ///
86 /// Panics if `layer` is 0: layer 0 is always allocated and can never be dropped.
87 ///
88 /// # Examples
89 ///
90 /// ```
91 /// use retroglyph_core::backend::Headless;
92 /// use retroglyph_core::surface::Layer;
93 /// use retroglyph_core::terminal::Terminal;
94 ///
95 /// let mut term = Terminal::new(Headless::new(10, 5));
96 /// term.draw(|s| s.on_tier(Layer::Hud).print((0, 0), "Paused", Default::default()))
97 /// .unwrap();
98 ///
99 /// term.drop_layer(Layer::Hud);
100 /// term.present().unwrap(); // Sends the erase, then frees the layer.
101 /// assert_eq!(term.grid().max_layer(), 0);
102 /// ```
103 pub fn drop_layer(&mut self, layer: impl Into<u8>) {
104 let id = layer.into();
105 assert_ne!(id, 0, "layer 0 is always allocated and cannot be dropped");
106 self.current.clear(id);
107 let idx = usize::from(id);
108 if idx < self.retained_layers.len() {
109 self.retained_layers[idx] = false;
110 }
111 if self.dropped_layers.len() <= idx {
112 self.dropped_layers.resize(idx + 1, false);
113 }
114 self.dropped_layers[idx] = true;
115 }
116}
117
118#[cfg(test)]
119mod tests {
120 use super::*;
121 use crate::backend::{Cursor, DrawCell, Headless, Input, Output};
122 use crate::color::Style;
123 use crate::event::Event;
124 use crate::grid::{Pos, Size};
125 use alloc::vec::Vec;
126 use core::time::Duration;
127
128 /// A `composites_layers() == true` cell-recording backend, used to prove `retain_layer`'s
129 /// pre-diff copy from `previous` still applies on the branch of `present` that bypasses the
130 /// fast path and flatten buffers entirely. See `present`'s own test module for the fuller
131 /// version of this fixture (dispatch-mode coverage); this one only needs the diff branch.
132 struct CompositingBackend {
133 size: Size,
134 last_draw_cells: Vec<(u8, Pos, char)>,
135 }
136
137 impl CompositingBackend {
138 fn new(width: u16, height: u16) -> Self {
139 Self {
140 size: Size::new(width, height),
141 last_draw_cells: Vec::new(),
142 }
143 }
144 }
145
146 impl Output for CompositingBackend {
147 type Error = core::convert::Infallible;
148
149 fn draw_layers<'a, I>(&mut self, content: I) -> Result<(), Self::Error>
150 where
151 I: Iterator<Item = DrawCell<'a>>,
152 {
153 self.last_draw_cells = content
154 .map(|cell| (cell.layer, cell.pos, cell.tile.glyph()))
155 .collect();
156 Ok(())
157 }
158
159 fn flush(&mut self) -> Result<(), Self::Error> {
160 Ok(())
161 }
162
163 fn size(&self) -> Size {
164 self.size
165 }
166
167 fn clear(&mut self) -> Result<(), Self::Error> {
168 Ok(())
169 }
170
171 fn composites_layers(&self) -> bool {
172 true
173 }
174 }
175
176 impl Input for CompositingBackend {
177 fn poll_event(&mut self, _timeout: Duration) -> Option<Event> {
178 None
179 }
180 }
181
182 impl Cursor for CompositingBackend {}
183
184 // --- retain_layer ---
185
186 #[test]
187 fn test_retain_layer_skips_redraw_and_keeps_backend_content() {
188 use crate::surface::Layer;
189
190 let mut term = Terminal::new(Headless::new(3, 1));
191 term.draw(|s| s.on_tier(Layer::World).put((0, 0), 'W', Style::default()))
192 .expect("draw failed");
193
194 // Retain `World`, then draw a frame that only touches `Hud`.
195 term.retain_layer(Layer::World);
196 term.draw(|s| s.on_tier(Layer::Hud).put((1, 0), 'H', Style::default()))
197 .expect("draw failed");
198
199 // `World` was never redrawn this frame, but the backend still shows it composited under
200 // the new `Hud` cell: `present` re-synced it from `previous` before diffing.
201 assert_eq!(term.backend().grid()[Pos::new(0, 0)].glyph(), 'W');
202 assert_eq!(term.backend().grid()[Pos::new(1, 0)].glyph(), 'H');
203 }
204
205 #[test]
206 fn test_retain_layer_survives_repeated_retention_without_desync() {
207 use crate::surface::Layer;
208
209 // Retaining a layer for several frames in a row (never redrawing it) must not desync
210 // `current`/`previous`: each present re-syncs the retained layer from `previous`, so the
211 // backend keeps showing it correctly across any number of consecutive retains.
212 let mut term = Terminal::new(Headless::new(3, 1));
213 term.draw(|s| s.on_tier(Layer::World).put((0, 0), 'W', Style::default()))
214 .expect("draw failed");
215
216 for _ in 0..3 {
217 term.retain_layer(Layer::World);
218 term.draw(|_| {}).expect("draw failed");
219 assert_eq!(term.backend().grid()[Pos::new(0, 0)].glyph(), 'W');
220 }
221 }
222
223 #[test]
224 fn test_retain_layer_is_one_shot() {
225 use crate::surface::Layer;
226
227 let mut term = Terminal::new(Headless::new(3, 1));
228 term.draw(|s| s.on_tier(Layer::World).put((0, 0), 'W', Style::default()))
229 .expect("draw failed");
230
231 term.retain_layer(Layer::World);
232 term.draw(|_| {}).expect("draw failed"); // Retained: the backend keeps showing 'W'.
233 assert_eq!(term.backend().grid()[Pos::new(0, 0)].glyph(), 'W');
234
235 // Retention was one-shot: the next present, with `World` still undrawn, clears it from
236 // the backend like any ordinary immediate-mode frame.
237 term.draw(|_| {}).expect("draw failed");
238 assert_eq!(term.backend().grid()[Pos::new(0, 0)].glyph(), ' ');
239 }
240
241 #[test]
242 fn test_retain_layer_replaces_a_cell_the_app_draws_at_an_empty_previous_cell() {
243 use crate::surface::Layer;
244
245 // retroglyph#956: `present` must discard whatever the app drew into a retained layer
246 // this frame, even at a cell where `previous` had nothing (so a naive transparent-skip
247 // copy would let the app's write leak through).
248 let mut term = Terminal::new(Headless::new(4, 1));
249 term.draw(|s| s.on_tier(Layer::World).put((0, 0), 'W', Style::default()))
250 .expect("draw failed");
251
252 term.retain_layer(Layer::World);
253 term.draw(|s| s.on_tier(Layer::World).put((2, 0), 'X', Style::default()))
254 .expect("draw failed");
255
256 assert_eq!(term.backend().grid()[Pos::new(0, 0)].glyph(), 'W');
257 assert_eq!(term.backend().grid()[Pos::new(2, 0)].glyph(), ' ');
258 }
259
260 #[test]
261 fn test_retain_layer_restores_a_cell_the_app_erases_with_an_explicit_space() {
262 use crate::surface::Layer;
263
264 // retroglyph#956: an explicit-space write on a retained layer is still a draw the app
265 // made this frame, so it must be discarded like any other write on that layer, not
266 // treated as an opaque erase that survives the retention.
267 let mut term = Terminal::new(Headless::new(4, 1));
268 term.draw(|s| s.on_tier(Layer::World).put((0, 0), 'W', Style::default()))
269 .expect("draw failed");
270
271 term.retain_layer(Layer::World);
272 term.draw(|s| s.on_tier(Layer::World).put((0, 0), ' ', Style::default()))
273 .expect("draw failed");
274
275 assert_eq!(term.backend().grid()[Pos::new(0, 0)].glyph(), 'W');
276 }
277
278 #[test]
279 fn test_retain_layer_preserves_multi_cell_span_flags() {
280 use crate::surface::Layer;
281 use crate::tile::TileFlags;
282
283 // retroglyph#955: `retain_layer` used to re-sync via `Grid::blit`, whose clipping-copy
284 // contract intentionally strips `SPAN_ANCHOR`/`SPAN_COVERED` and degrades a span to its
285 // text fallback. That's wrong for a retained layer, which is copied whole at the same
286 // geometry and must be indistinguishable from what was presented last frame.
287 let mut term = Terminal::new(Headless::new(4, 2));
288 term.draw(|s| {
289 s.on_tier(Layer::World)
290 .put_span((0, 0), &["Tr", "__"], Style::default())
291 .unwrap();
292 })
293 .expect("draw failed");
294
295 let anchor_flags = term.backend().grid()[Pos::new(0, 0)].flags();
296 let covered_flags = term.backend().grid()[Pos::new(1, 0)].flags();
297 assert!(anchor_flags.contains(TileFlags::SPAN_ANCHOR));
298 assert!(covered_flags.contains(TileFlags::SPAN_COVERED));
299
300 term.retain_layer(Layer::World);
301 term.draw(|_| {}).expect("draw failed");
302
303 // The span survived the retained present untouched: same glyphs, same span flags.
304 assert_eq!(term.backend().grid()[Pos::new(0, 0)].glyph(), 'T');
305 assert_eq!(term.backend().grid()[Pos::new(1, 0)].glyph(), 'r');
306 assert_eq!(term.backend().grid()[Pos::new(0, 0)].flags(), anchor_flags);
307 assert_eq!(term.backend().grid()[Pos::new(1, 0)].flags(), covered_flags);
308 }
309
310 #[test]
311 fn test_retain_layer_accepts_raw_u8_and_layer() {
312 use crate::surface::Layer;
313
314 // `retain_layer` takes `impl Into<u8>`, so a raw layer id and the `Layer` enum both work.
315 let mut term = Terminal::new(Headless::new(1, 1));
316 term.draw(|s| s.put((0, 0), 'A', Style::default()))
317 .expect("draw failed");
318
319 term.retain_layer(0u8);
320 term.draw(|_| {}).expect("draw failed");
321 assert_eq!(term.backend().grid()[Pos::new(0, 0)].glyph(), 'A');
322
323 term.retain_layer(Layer::World);
324 term.draw(|_| {}).expect("draw failed");
325 assert_eq!(term.backend().grid()[Pos::new(0, 0)].glyph(), 'A');
326 }
327
328 #[test]
329 fn test_retain_layer_skips_redraw_on_a_compositing_backend() {
330 use crate::surface::Layer;
331
332 // `composites_layers() == true` takes `present`'s first branch entirely; `retain_layer`'s
333 // pre-diff copy from `previous` (`Grid::copy_layer_from`) runs before that branch, so it
334 // must still apply here: the retained layer's cell should be re-synced (and so absent
335 // from the diff, since it now matches `previous`) rather than diffed as a real change.
336 let mut term = Terminal::new(CompositingBackend::new(3, 1));
337 term.draw(|s| s.on_tier(Layer::World).put((0, 0), 'W', Style::default()))
338 .expect("draw failed");
339
340 term.retain_layer(Layer::World);
341 term.draw(|s| s.on_tier(Layer::Hud).put((1, 0), 'H', Style::default()))
342 .expect("draw failed");
343
344 let cells = &term.backend().last_draw_cells;
345 assert!(
346 !cells
347 .iter()
348 .any(|&(layer, pos, _)| layer == 0 && pos == Pos::new(0, 0)),
349 "retained layer's unchanged cell must not be re-sent as a diff: {cells:?}"
350 );
351 assert!(
352 cells.contains(&(1, Pos::new(1, 0), 'H')),
353 "the newly drawn Hud cell must still be sent: {cells:?}"
354 );
355 }
356
357 #[test]
358 fn test_retain_layer_never_drawn_is_a_no_op() {
359 // `Grid::copy_layer_from`'s `None` arm, no-op branch: retaining a non-zero layer id
360 // that has never been drawn to on either buffer leaves it unallocated on both sides.
361 // Must not panic or grow the layer table for a layer id nobody ever wrote to.
362 let mut term = Terminal::new(Headless::new(3, 1));
363 term.retain_layer(5u8);
364 term.draw(|_| {}).expect("draw failed");
365 assert_eq!(term.backend().grid()[Pos::new(0, 0)].glyph(), ' ');
366 }
367
368 #[test]
369 fn test_retain_layer_deallocates_when_previous_lacks_it() {
370 // `Grid::copy_layer_from`'s `None` arm, deallocating branch: `current` can carry a
371 // non-zero layer allocated (but emptied by immediate mode) from an older frame while
372 // `previous` never allocated it at all, if that layer went undrawn (and unretained) for
373 // a frame in between. Retaining it then must clear it from `current`, not leave stale
374 // allocation state behind. Layer 0 can't exercise this (always allocated on both
375 // sides), so this writes directly to a non-zero raw layer id via `on_layer`.
376 let mut term = Terminal::new(Headless::new(3, 1));
377 term.draw(|s| s.on_layer(5).put((0, 0), 'W', Style::default()))
378 .expect("draw failed");
379 assert_eq!(term.backend().grid()[Pos::new(0, 0)].glyph(), 'W');
380
381 // Layer 5 goes undrawn and unretained: ordinary immediate-mode clearing puts `current`'s
382 // (still allocated) layer 5 buffer back to empty, and this frame's diff sends that.
383 term.draw(|s| s.on_layer(1).put((1, 0), 'H', Style::default()))
384 .expect("draw failed");
385 assert_eq!(term.backend().grid()[Pos::new(0, 0)].glyph(), ' ');
386
387 // Retaining layer 5 now must not resurrect stale content or panic, even though
388 // `previous` (this frame's source) never allocated layer 5 at all.
389 term.retain_layer(5u8);
390 term.draw(|_| {}).expect("draw failed");
391 assert_eq!(term.backend().grid()[Pos::new(0, 0)].glyph(), ' ');
392 }
393
394 // --- drop_layer ---
395
396 #[test]
397 #[should_panic(expected = "layer 0 is always allocated")]
398 fn test_drop_layer_zero_panics() {
399 let mut term = Terminal::new(Headless::new(3, 1));
400 term.drop_layer(0u8);
401 }
402
403 #[test]
404 fn test_drop_layer_restores_the_single_layer_fast_path() {
405 use crate::surface::Layer;
406
407 // retroglyph#1028: a layer stays allocated once written, permanently moving `present`
408 // onto the flatten path. `drop_layer` is the explicit escape hatch back to the
409 // single-layer fast path once every layer above 0 is gone.
410 let mut term = Terminal::new(Headless::new(3, 1));
411 term.draw(|s| s.on_tier(Layer::Hud).put((0, 0), 'H', Style::default()))
412 .expect("draw failed");
413 // `current` was just swapped to the other (never-written) buffer, so `previous` is the
414 // one still carrying the allocation right after this draw.
415 assert_eq!(term.current.max_layer(), 0);
416 assert_ne!(term.previous.max_layer(), 0);
417
418 // The deallocation is deferred to the next `present`: calling `drop_layer` alone must
419 // not yet change either buffer's `max_layer`.
420 term.drop_layer(Layer::Hud);
421 assert_ne!(term.previous.max_layer(), 0);
422
423 term.draw(|_| {}).expect("draw failed");
424 assert_eq!(term.current.max_layer(), 0);
425 assert_eq!(term.previous.max_layer(), 0);
426 }
427
428 #[test]
429 fn test_drop_layer_erases_content_from_the_backend_on_the_next_present() {
430 use crate::surface::Layer;
431
432 let mut term = Terminal::new(Headless::new(3, 1));
433 term.draw(|s| s.on_tier(Layer::Hud).put((0, 0), 'H', Style::default()))
434 .expect("draw failed");
435 assert_eq!(term.backend().grid()[Pos::new(0, 0)].glyph(), 'H');
436
437 term.drop_layer(Layer::Hud);
438 term.draw(|_| {}).expect("draw failed");
439
440 // The layer's content is gone once the drop has gone through a present, same as if the
441 // app had simply stopped drawing to it.
442 assert_eq!(term.backend().grid()[Pos::new(0, 0)].glyph(), ' ');
443 }
444
445 #[test]
446 fn test_drop_layer_is_cancelled_by_a_redraw_before_the_next_present() {
447 use crate::surface::Layer;
448
449 // A write to `layer` after `drop_layer` but before the deferred deallocation runs is a
450 // live redraw the app wants kept, not stale content left over from before the drop, so
451 // it must not be silently discarded.
452 let mut term = Terminal::new(Headless::new(3, 1));
453 term.draw(|s| s.on_tier(Layer::Hud).put((0, 0), 'H', Style::default()))
454 .expect("draw failed");
455
456 term.drop_layer(Layer::Hud);
457 term.draw(|s| s.on_tier(Layer::Hud).put((0, 0), 'J', Style::default()))
458 .expect("draw failed");
459
460 assert_eq!(term.backend().grid()[Pos::new(0, 0)].glyph(), 'J');
461 assert_ne!(term.current.max_layer(), 0);
462 }
463
464 #[test]
465 fn test_drop_layer_clears_pending_retention_for_that_layer() {
466 use crate::surface::Layer;
467
468 // Retaining content that no longer exists would resurrect it on the next present, so
469 // `drop_layer` must clear any pending `retain_layer` call for the same id.
470 let mut term = Terminal::new(Headless::new(3, 1));
471 term.draw(|s| s.on_tier(Layer::Hud).put((0, 0), 'H', Style::default()))
472 .expect("draw failed");
473
474 term.retain_layer(Layer::Hud);
475 term.drop_layer(Layer::Hud);
476 term.draw(|_| {}).expect("draw failed");
477
478 assert_eq!(term.backend().grid()[Pos::new(0, 0)].glyph(), ' ');
479 }
480
481 #[test]
482 fn test_drop_layer_never_drawn_is_a_no_op() {
483 let mut term = Terminal::new(Headless::new(3, 1));
484 term.drop_layer(5u8);
485 term.draw(|_| {}).expect("draw failed");
486 assert_eq!(term.backend().grid()[Pos::new(0, 0)].glyph(), ' ');
487 }
488
489 #[test]
490 fn test_drop_layer_reallocates_on_the_next_write_after_it_takes_effect() {
491 use crate::surface::Layer;
492
493 // The only way back once a drop has actually gone through is drawing to the layer
494 // again, which reallocates it from scratch.
495 let mut term = Terminal::new(Headless::new(3, 1));
496 term.draw(|s| s.on_tier(Layer::Hud).put((0, 0), 'H', Style::default()))
497 .expect("draw failed");
498 term.drop_layer(Layer::Hud);
499 term.draw(|_| {}).expect("draw failed"); // Lets the drop take effect.
500 assert_eq!(term.current.max_layer(), 0);
501 assert_eq!(term.previous.max_layer(), 0);
502
503 term.draw(|s| s.on_tier(Layer::Hud).put((0, 0), 'J', Style::default()))
504 .expect("draw failed");
505 assert_eq!(term.backend().grid()[Pos::new(0, 0)].glyph(), 'J');
506 assert_ne!(term.previous.max_layer(), 0);
507 }
508}