Skip to main content

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}