Skip to main content

retroglyph_core/backend/
headless.rs

1//! In-memory backend for testing. Stores presented content and allows injecting synthetic events.
2//!
3//! [`Headless::format_view`](crate::backend::Headless::format_view) renders the current frame for snapshot testing (e.g. with `insta`)
4//! and [`Headless::push_event`](crate::backend::Headless::push_event) queues synthetic input for a
5//! test to drive directly, or to feed through [`TestHarness`](crate::testing::TestHarness) when a
6//! full `App` loop is involved.
7//!
8//! [`Headless::format_styled`] emits Select Graphic Rendition (SGR) parameters via
9//! [`crate::color::sgr`], shared with `retroglyph-recorder`'s asciicast export so the two
10//! encoders can't drift apart.
11
12use crate::backend::{Cursor, CursorStyle, DrawCell, Input, Output};
13use crate::color::Style;
14use crate::event::{Event, push_coalesced};
15use crate::grid::{Grid, HasSize, Pos, Size};
16use crate::tile::Tile;
17use alloc::collections::VecDeque;
18use alloc::string::String;
19use core::time::Duration;
20
21/// In-memory backend for testing.
22///
23/// Stores presented content and allows injecting synthetic events.
24pub struct Headless {
25    layers: Grid,
26    composited: Option<Grid>,
27    cursor_visible: bool,
28    cursor_pos: Pos,
29    cursor_style: CursorStyle,
30    event_queue: VecDeque<Event>,
31}
32
33impl Headless {
34    /// The glyph [`format_view`](Self::format_view)/[`format_styled`](Self::format_styled) render in place of a literal space, so
35    /// layout is visible in text diffs.
36    ///
37    /// Public so a caller converting a rendered view back to plain text (or a search needle's
38    /// spaces into the view's own convention, as [`TestHarness::find_text`](crate::testing::TestHarness::find_text) does) has
39    /// something to match against other than a hardcoded `'·'` that could silently drift out of
40    /// sync with this backend's actual choice. Prefer [`TestHarness::readable_view`](crate::testing::TestHarness::readable_view) for the
41    /// common case of getting a plain string back for `.contains`/`assert_eq!` checks.
42    pub const SPACE_GLYPH: char = '·';
43
44    /// Creates a new headless backend of the given dimensions.
45    #[must_use]
46    pub fn new(width: u16, height: u16) -> Self {
47        Self {
48            layers: Grid::new(width, height),
49            composited: None,
50            cursor_visible: false,
51            cursor_pos: Pos::default(),
52            cursor_style: CursorStyle::default(),
53            event_queue: VecDeque::new(),
54        }
55    }
56
57    /// Returns the composited frame: every layer this backend has been sent, flattened into a
58    /// single layer under the [`TileFlags::EMPTY`](crate::tile::TileFlags) transparency rule
59    /// documented on [`crate::grid`].
60    ///
61    /// For the usual case, a backend left at the default
62    /// [`compositing`](Output::compositing) of [`Compositing::CellFlattened`](crate::backend::Compositing::CellFlattened),
63    /// [`crate::terminal::Terminal::present`] has already flattened the frame and only layer 0 is
64    /// ever written, so this is simply the received content. Use
65    /// [`layer_grid`](Self::layer_grid) to inspect the raw per-layer state instead.
66    #[must_use]
67    pub fn grid(&self) -> &Grid {
68        self.composited.as_ref().unwrap_or(&self.layers)
69    }
70
71    /// Returns the raw, un-composited per-layer state, as received.
72    ///
73    /// Only interesting for a backend wrapping this one that returns
74    /// [`Compositing::PixelLayered`](crate::backend::Compositing::PixelLayered) from
75    /// [`compositing`](Output::compositing) and so receives the multi-layer stream:
76    /// this is what lets a test assert which layer a cell arrived on, rather than only what the
77    /// composited frame looks like. Otherwise identical to [`grid`](Self::grid).
78    #[must_use]
79    pub const fn layer_grid(&self) -> &Grid {
80        &self.layers
81    }
82
83    /// Recomputes [`grid`](Self::grid) from [`layer_grid`](Self::layer_grid).
84    ///
85    /// A no-op while only layer 0 has ever been written, which keeps the single-layer path (every
86    /// cell backend) free of both the flatten and the second grid's allocation.
87    fn recomposite(&mut self) {
88        if self.layers.max_layer() == 0 {
89            return;
90        }
91        let size = self.layers.size();
92        let dst = self
93            .composited
94            .get_or_insert_with(|| Grid::new(size.width(), size.height()));
95        self.layers.flatten_into(dst);
96    }
97
98    /// Returns the cursor visibility.
99    #[must_use]
100    pub const fn cursor_visible(&self) -> bool {
101        self.cursor_visible
102    }
103
104    /// Returns the cursor position.
105    #[must_use]
106    pub const fn cursor_position(&self) -> Pos {
107        self.cursor_pos
108    }
109
110    /// Returns the cursor's shape and blink behavior, as last set by
111    /// [`Cursor::set_cursor_style`](crate::backend::Cursor::set_cursor_style).
112    #[must_use]
113    pub const fn cursor_style(&self) -> CursorStyle {
114        self.cursor_style
115    }
116
117    /// Injects a synthetic event into the queue.
118    ///
119    /// Coalesces consecutive `Mouse(Moved)` or same-button `Mouse(Drag)` events with the queue's
120    /// current tail (see [`push_coalesced`]), matching the `retroglyph-window` and
121    /// `retroglyph-terminal-wasm` backends this stands in for during tests (retroglyph#768): a
122    /// caller pushing a burst of pointer positions before draining the queue sees only the latest
123    /// one, the same as it would against a real backend.
124    pub fn push_event(&mut self, event: Event) {
125        push_coalesced(&mut self.event_queue, event);
126    }
127
128    /// Converts the current grid state into a readable string for snapshot testing.
129    ///
130    /// Space cells are rendered as `·` so layout is visible in text diffs.
131    #[must_use]
132    pub fn format_view(&self) -> String {
133        let grid = self.grid();
134        let mut out = String::new();
135        for row in grid.size().to_rect().rows() {
136            for pos in row {
137                let cell = &grid[pos];
138                let (glyph, is_spacer) = Self::display_glyph(cell);
139                out.push(if is_spacer { ' ' } else { glyph });
140            }
141            out.push('\n');
142        }
143        out
144    }
145
146    /// `format_view`, with each cell's colors emitted as SGR (ANSI) escape sequences.
147    ///
148    /// Suitable for `insta::assert_snapshot!`, which renders ANSI in its terminal diff output:
149    /// a color regression that `format_view` can't see (two styles that share a glyph) shows up
150    /// as a snapshot diff here. Spacer cells (the trailing half of a wide glyph) are blanked the
151    /// same way `format_view` blanks them, with no style of their own.
152    ///
153    /// Each run of cells that share a [`Style`](crate::color::Style) is wrapped in a `\x1b[0m` reset followed by the
154    /// SGR codes for that style's non-default foreground/background; a bare `Style::default()`
155    /// run only gets the reset. This keeps every row self-contained (no state leaks across rows
156    /// or into terminals that render the snapshot directly).
157    ///
158    /// [`Color::Ansi`](crate::color::Color::Ansi) and [`Color::Indexed`](crate::color::Color::Indexed) map to their standard SGR codes (30-37/90-97 and
159    /// `38;5;n`/`48;5;n`); [`Color::Rgb`](crate::color::Color::Rgb) maps to 24-bit SGR (`38;2;r;g;b`/`48;2;r;g;b`) rather
160    /// than being downgraded, so this reflects the style as authored, not as a particular
161    /// terminal would render it.
162    #[must_use]
163    pub fn format_styled(&self) -> String {
164        let grid = self.grid();
165        let mut out = String::new();
166        for row in grid.size().to_rect().rows() {
167            let mut current: Option<Style> = None;
168            for pos in row {
169                let cell = &grid[pos];
170                let (glyph, is_spacer) = Self::display_glyph(cell);
171                let style = if is_spacer {
172                    Style::default()
173                } else {
174                    cell.style()
175                };
176                if current != Some(style) {
177                    out.push_str("\x1b[0m");
178                    crate::color::sgr::push_sgr(&mut out, style);
179                    current = Some(style);
180                }
181                out.push(if is_spacer { ' ' } else { glyph });
182            }
183            if current.is_some_and(|s| s != Style::default()) {
184                out.push_str("\x1b[0m");
185            }
186            out.push('\n');
187        }
188        out
189    }
190
191    /// The glyph `format_view`/`format_styled` render for `cell`, and whether it's a wide-glyph
192    /// spacer (rendered blank in both, with no style in `format_styled`).
193    const fn display_glyph(cell: &Tile) -> (char, bool) {
194        let is_spacer = cell
195            .flags()
196            .contains(crate::tile::TileFlags::WIDE_CHAR_SPACER);
197        let glyph = if cell.glyph() == ' ' {
198            Self::SPACE_GLYPH
199        } else {
200            cell.glyph()
201        };
202        (glyph, is_spacer)
203    }
204}
205
206impl Output for Headless {
207    type Error = core::convert::Infallible;
208
209    fn draw_layers<'a, I>(&mut self, content: I) -> Result<(), Self::Error>
210    where
211        I: Iterator<Item = DrawCell<'a>>,
212    {
213        for cell in content {
214            let pos = cell.pos;
215            // A `DrawCell` is already-resolved content from some source grid, replayed here
216            // cell-by-cell at that same source position and on that same source layer, not a
217            // caller placing a new tile at an arbitrary destination. `put_tile`'s sanitizing
218            // (span role, `WIDE_CHAR`/spacer synthesis, overlap clearing) exists for the latter;
219            // using it here would strip `SPAN_ANCHOR`/`SPAN_COVERED` from a faithfully-positioned
220            // replay (retroglyph#984) the same way it should from a moved one, so this writes
221            // straight through `tile_mut_or_alloc` instead. That never fails on an in-bounds `pos`
222            // (sourced from this same grid's own geometry).
223            //
224            // Keeping each layer separate rather than folding everything onto layer 0 is what
225            // makes a raw multi-layer stream replay correctly (retroglyph#1084): the stream is a
226            // per-layer diff, so a cell occluded on a higher layer this frame and revealed the
227            // next arrives only as the higher layer's erase, with nothing resent for the layer
228            // below. Only retained per-layer state can composite that back.
229            if let Some(t) = self.layers.tile_mut_or_alloc(cell.layer, pos) {
230                *t = *cell.tile;
231            }
232            // Rebuild the side-table entry from the parts that arrived, so a headless capture
233            // round-trips both members rather than only the grapheme.
234            let extra = crate::grid::TileExtra {
235                grapheme: cell.grapheme.map(alloc::sync::Arc::from),
236                tint: cell.tint,
237            };
238            self.layers.set_extra(cell.layer, pos.x, pos.y, extra);
239        }
240        self.recomposite();
241        Ok(())
242    }
243
244    fn resize(&mut self, size: Size) {
245        self.layers.resize(size.width(), size.height());
246        // Resized in lockstep rather than dropped: `flatten_into` requires matching dimensions,
247        // and `Grid::resize` preserves the overlapping region, so the composited view survives a
248        // resize the same way a real backend's surface would.
249        if let Some(composited) = self.composited.as_mut() {
250            composited.resize(size.width(), size.height());
251        }
252    }
253
254    fn flush(&mut self) -> Result<(), Self::Error> {
255        Ok(())
256    }
257
258    fn size(&self) -> Size {
259        Size::new(self.layers.width(), self.layers.height())
260    }
261
262    fn clear(&mut self) -> Result<(), Self::Error> {
263        self.layers.clear_all();
264        if let Some(composited) = self.composited.as_mut() {
265            composited.clear_all();
266        }
267        Ok(())
268    }
269}
270
271impl Input for Headless {
272    fn poll_event(&mut self, _timeout: Duration) -> Option<Event> {
273        self.event_queue.pop_front()
274    }
275
276    fn push_event(&mut self, event: Event) {
277        Self::push_event(self, event);
278    }
279}
280
281impl Cursor for Headless {
282    fn set_cursor_visible(&mut self, visible: bool) {
283        self.cursor_visible = visible;
284    }
285
286    fn set_cursor_position(&mut self, position: Pos) {
287        self.cursor_pos = position;
288    }
289
290    fn set_cursor_style(&mut self, style: CursorStyle) {
291        self.cursor_style = style;
292    }
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298    use crate::color::Color;
299
300    #[test]
301    fn test_headless_new() {
302        let backend = Headless::new(80, 25);
303        assert_eq!(backend.grid().width(), 80);
304        assert_eq!(backend.grid().height(), 25);
305    }
306
307    #[test]
308    fn test_headless_cursor_style_defaults_and_records_set_cursor_style() {
309        use crate::backend::CursorStyle;
310
311        let mut backend = Headless::new(10, 10);
312        assert_eq!(backend.cursor_style(), CursorStyle::BlinkingBlock);
313        Cursor::set_cursor_style(&mut backend, CursorStyle::SteadyBar);
314        assert_eq!(backend.cursor_style(), CursorStyle::SteadyBar);
315    }
316
317    #[test]
318    fn test_headless_events() {
319        let mut backend = Headless::new(10, 10);
320        let event = Event::Close;
321        backend.push_event(event);
322        assert_eq!(backend.poll_event(Duration::ZERO), Some(Event::Close));
323        assert_eq!(backend.poll_event(Duration::ZERO), None);
324    }
325
326    fn moved(x: u16) -> Event {
327        use crate::event::{KeyModifiers, MouseEvent, MouseEventKind};
328        Event::Mouse(MouseEvent {
329            kind: MouseEventKind::Moved,
330            position: Pos { x, y: 0 },
331            pixel_position: None,
332            modifiers: KeyModifiers::NONE,
333        })
334    }
335
336    /// Regression test for retroglyph#768: `Headless` must coalesce a burst of consecutive
337    /// `Moved` events the same way `retroglyph-window` and `retroglyph-terminal-wasm` do, so
338    /// `TestHarness`-driven tests stay faithful to the real backends.
339    #[test]
340    fn consecutive_moved_events_coalesce_to_one() {
341        let mut backend = Headless::new(10, 10);
342        for x in 0..1_000u16 {
343            backend.push_event(moved(x));
344        }
345        assert_eq!(backend.event_queue.len(), 1);
346        assert_eq!(backend.poll_event(Duration::ZERO), Some(moved(999)));
347        assert_eq!(backend.poll_event(Duration::ZERO), None);
348    }
349
350    /// A non-`Moved` event between two `Moved` bursts must not be swallowed: only *consecutive*
351    /// `Moved` events collapse.
352    #[test]
353    fn non_moved_event_breaks_coalescing() {
354        use crate::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
355        let mut backend = Headless::new(10, 10);
356        backend.push_event(moved(1));
357        backend.push_event(moved(2));
358        backend.push_event(Event::Mouse(MouseEvent {
359            kind: MouseEventKind::Down(MouseButton::Left),
360            position: Pos { x: 2, y: 0 },
361            pixel_position: None,
362            modifiers: KeyModifiers::NONE,
363        }));
364        backend.push_event(moved(3));
365        assert_eq!(backend.event_queue.len(), 3);
366        assert_eq!(backend.poll_event(Duration::ZERO), Some(moved(2)));
367        assert!(matches!(
368            backend.poll_event(Duration::ZERO),
369            Some(Event::Mouse(MouseEvent {
370                kind: MouseEventKind::Down(MouseButton::Left),
371                ..
372            }))
373        ));
374        assert_eq!(backend.poll_event(Duration::ZERO), Some(moved(3)));
375    }
376
377    /// A transparent (untouched, `EMPTY`) cell on a higher layer must not erase opaque content
378    /// below it when the raw multi-layer stream is replayed (retroglyph#1084). `Grid::diff`
379    /// yields every cell of a newly allocated layer, so the blank parts of layer 1 arrive right
380    /// after layer 0's real content.
381    #[test]
382    fn draw_layers_treats_empty_higher_layer_cells_as_transparent() {
383        let mut backend = Headless::new(3, 1);
384        let a = Tile::new('a', Style::default());
385        let hash = Tile::new('#', Style::default());
386        let blank = Tile::default();
387        backend
388            .draw_layers(
389                [
390                    DrawCell::on_layer(0, Pos::new(0, 0), &a),
391                    // Layer 1 arrives in full, blanks included.
392                    DrawCell::on_layer(1, Pos::new(0, 0), &blank),
393                    DrawCell::on_layer(1, Pos::new(1, 0), &blank),
394                    DrawCell::on_layer(1, Pos::new(2, 0), &hash),
395                ]
396                .into_iter(),
397            )
398            .expect("draw_layers failed");
399        assert_eq!(backend.format_view(), "a·#\n");
400    }
401
402    /// The stream is a per-layer *diff*, so revealing a cell sends only the erase on the layer
403    /// that used to occlude it. Nothing is resent for the layer below, which is why `Headless`
404    /// has to retain each layer rather than composite eagerly into one grid.
405    #[test]
406    fn draw_layers_reveals_the_layer_below_when_an_occluder_is_erased() {
407        let mut backend = Headless::new(3, 1);
408        let a = Tile::new('a', Style::default());
409        let hash = Tile::new('#', Style::default());
410        let blank = Tile::default();
411        backend
412            .draw_layers(
413                [
414                    DrawCell::on_layer(0, Pos::new(0, 0), &a),
415                    DrawCell::on_layer(1, Pos::new(0, 0), &hash),
416                ]
417                .into_iter(),
418            )
419            .expect("draw_layers failed");
420        assert_eq!(backend.format_view(), "#··\n", "layer 1 occludes layer 0");
421
422        // Only layer 1's erase is sent: layer 0 is unchanged, so it contributes nothing.
423        backend
424            .draw_layers(core::iter::once(DrawCell::on_layer(
425                1,
426                Pos::new(0, 0),
427                &blank,
428            )))
429            .expect("draw_layers failed");
430        assert_eq!(
431            backend.format_view(),
432            "a··\n",
433            "erasing the occluder must restore layer 0's tile, which was never resent"
434        );
435    }
436
437    /// An explicit space is `EMPTY`-clear and so stays opaque, matching `flatten_into` and the
438    /// transparency model documented on [`crate::grid`].
439    #[test]
440    fn draw_layers_keeps_an_explicit_space_on_a_higher_layer_opaque() {
441        let mut backend = Headless::new(3, 1);
442        let a = Tile::new('a', Style::default());
443        let space = Tile::new(' ', Style::default());
444        backend
445            .draw_layers(
446                [
447                    DrawCell::on_layer(0, Pos::new(0, 0), &a),
448                    DrawCell::on_layer(1, Pos::new(0, 0), &space),
449                ]
450                .into_iter(),
451            )
452            .expect("draw_layers failed");
453        assert_eq!(backend.format_view(), "···\n");
454    }
455
456    /// `layer_grid` reports where a cell actually landed, which the composited `grid` cannot.
457    #[test]
458    fn layer_grid_exposes_the_raw_per_layer_stream() {
459        let mut backend = Headless::new(3, 1);
460        let a = Tile::new('a', Style::default());
461        let hash = Tile::new('#', Style::default());
462        backend
463            .draw_layers(
464                [
465                    DrawCell::on_layer(0, Pos::new(0, 0), &a),
466                    DrawCell::on_layer(1, Pos::new(0, 0), &hash),
467                ]
468                .into_iter(),
469            )
470            .expect("draw_layers failed");
471        let raw = backend.layer_grid();
472        assert_eq!(raw.tile(0, Pos::new(0, 0)).map(Tile::glyph), Some('a'));
473        assert_eq!(raw.tile(1, Pos::new(0, 0)).map(Tile::glyph), Some('#'));
474        assert_eq!(backend.grid()[Pos::new(0, 0)].glyph(), '#');
475    }
476
477    /// The single-layer path (every cell backend, which receives a pre-flattened stream) must
478    /// not allocate the composited grid at all: `grid` is the received content directly.
479    #[test]
480    fn single_layer_stream_skips_the_composited_grid() {
481        let mut backend = Headless::new(3, 1);
482        let a = Tile::new('a', Style::default());
483        backend
484            .draw_layers(core::iter::once(DrawCell::on_layer(0, Pos::new(0, 0), &a)))
485            .expect("draw_layers failed");
486        assert!(backend.composited.is_none());
487        assert_eq!(backend.format_view(), "a··\n");
488    }
489
490    #[test]
491    fn test_format_view_snapshot() {
492        use crate::terminal::Terminal;
493        let backend = Headless::new(10, 3);
494        let mut term = Terminal::new(backend);
495        term.draw(|s| {
496            s.put((1, 1), 'H', Style::default());
497            s.put((2, 1), 'i', Style::default());
498        })
499        .expect("draw failed");
500        let view = term.backend().format_view();
501        insta::assert_snapshot!(view, @r#"
502        ··········
503        ·Hi·······
504        ··········
505        "#);
506    }
507
508    /// A multi-cell span's covered cells are its text fallback, so a cell backend must render
509    /// all four glyphs. This is the deliberate difference from `WIDE_CHAR_SPACER`, which
510    /// `format_view` blanks out just above this test's code path.
511    #[test]
512    fn test_format_view_renders_span_fallback_glyphs() {
513        use crate::terminal::Terminal;
514        let backend = Headless::new(6, 3);
515        let mut term = Terminal::new(backend);
516        term.draw(|s| {
517            s.put_span((1, 0), &["C=", "[]"], Style::default())
518                .expect("span write");
519        })
520        .expect("draw failed");
521        let view = term.backend().format_view();
522        insta::assert_snapshot!(view, @r#"
523        ·C=···
524        ·[]···
525        ······
526        "#);
527    }
528
529    #[test]
530    fn test_format_styled_unstyled_matches_format_view_text() {
531        use crate::terminal::Terminal;
532        let backend = Headless::new(6, 2);
533        let mut term = Terminal::new(backend);
534        term.draw(|s| {
535            s.put((1, 0), 'H', Style::default());
536        })
537        .expect("draw failed");
538        // No non-default color anywhere, so this is just format_view with a reset per row.
539        assert_eq!(
540            term.backend().format_styled(),
541            "\x1b[0m·H····\n\x1b[0m······\n"
542        );
543    }
544
545    #[test]
546    fn test_format_styled_emits_fg_and_bg_sgr_on_change() {
547        use crate::terminal::Terminal;
548        let backend = Headless::new(3, 1);
549        let mut term = Terminal::new(backend);
550        term.draw(|s| {
551            let style = Style::new().fg(Color::RED).bg(Color::BLUE);
552            s.put((1, 0), 'x', style);
553        })
554        .expect("draw failed");
555        assert_eq!(
556            term.backend().format_styled(),
557            "\x1b[0m·\x1b[0m\x1b[31;44mx\x1b[0m·\n"
558        );
559    }
560
561    #[test]
562    fn test_format_styled_rgb_and_indexed() {
563        use crate::terminal::Terminal;
564        let backend = Headless::new(2, 1);
565        let mut term = Terminal::new(backend);
566        term.draw(|s| {
567            s.put((0, 0), 'a', Style::new().fg(Color::rgb(1, 2, 3)));
568            s.put((1, 0), 'b', Style::new().bg(Color::Indexed(200)));
569        })
570        .expect("draw failed");
571        assert_eq!(
572            term.backend().format_styled(),
573            "\x1b[0m\x1b[38;2;1;2;3ma\x1b[0m\x1b[48;5;200mb\x1b[0m\n"
574        );
575    }
576
577    #[test]
578    fn test_format_styled_spacer_cells_carry_no_style() {
579        use crate::terminal::Terminal;
580        let backend = Headless::new(4, 1);
581        let mut term = Terminal::new(backend);
582        term.draw(|s| {
583            s.put_span((0, 0), &["[]"], Style::new().fg(Color::GREEN))
584                .expect("span write");
585        })
586        .expect("draw failed");
587        // Both cells of the span share the styled glyph fallback (see
588        // `test_format_view_renders_span_fallback_glyphs`); this asserts a real spacer, produced
589        // by a wide EGC grapheme, drops style instead of inheriting the lead cell's.
590        #[cfg(feature = "egc")]
591        {
592            let mut term = Terminal::new(Headless::new(4, 1));
593            term.draw(|s| {
594                s.put((0, 0), 'あ', Style::new().fg(Color::GREEN));
595            })
596            .expect("draw failed");
597            let styled = term.backend().format_styled();
598            assert!(styled.contains("\x1b[32mあ"));
599            // The spacer cell after the wide glyph is blank and resets rather than repeating
600            // the green foreground.
601            assert!(!styled.contains("\x1b[32m "));
602        }
603    }
604}