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; see ["Driving `Headless` with synthetic
5//! events"](https://github.com/crates-lurey-io/retroglyph/blob/main/docs/testing.md#driving-headless-with-synthetic-events)
6//! for the full workflow.
7//!
8//! The styled-snapshot encoders ([`Headless::format_styled`], `sgr_color`) emit Select Graphic
9//! Rendition (SGR) parameters per ECMA-48 5th ed. section 8.3.117: 30-37/40-47 for the standard
10//! foreground/background colors, 90-97/100-107 for the bright variants, `38;5;n`/`48;5;n` for
11//! 256-color indices, and `38;2;r;g;b`/`48;2;r;g;b` for 24-bit truecolor.
12//! (<https://www.ecma-international.org/publications-and-standards/standards/ecma-48/>)
13
14use crate::backend::{Cursor, CursorStyle, DrawCell, Input, Output};
15use crate::color::Color;
16use crate::color::Style;
17use crate::event::{Event, coalesces_with};
18use crate::grid::{Grid, Pos, Size};
19use crate::tile::Tile;
20use alloc::collections::VecDeque;
21use alloc::string::String;
22use core::fmt::Write as _;
23use core::time::Duration;
24use ixy::HasSize;
25
26/// In-memory backend for testing.
27///
28/// Stores presented content and allows injecting synthetic events.
29pub struct Headless {
30    layers: Grid,
31    composited: Option<Grid>,
32    cursor_visible: bool,
33    cursor_pos: Pos,
34    cursor_style: CursorStyle,
35    event_queue: VecDeque<Event>,
36}
37
38impl Headless {
39    /// Creates a new headless backend of the given dimensions.
40    #[must_use]
41    pub fn new(width: u16, height: u16) -> Self {
42        Self {
43            layers: Grid::new(width, height),
44            composited: None,
45            cursor_visible: false,
46            cursor_pos: Pos::default(),
47            cursor_style: CursorStyle::default(),
48            event_queue: VecDeque::new(),
49        }
50    }
51
52    /// Returns the composited frame: every layer this backend has been sent, flattened into a
53    /// single layer under the [`TileFlags::EMPTY`](crate::tile::TileFlags) transparency rule
54    /// documented on [`crate::grid`].
55    ///
56    /// For the usual case, a backend left at the default
57    /// [`composites_layers`](Output::composites_layers) of `false`,
58    /// [`crate::terminal::Terminal::present`] has already flattened the frame and only layer 0 is
59    /// ever written, so this is simply the received content. Use
60    /// [`layer_grid`](Self::layer_grid) to inspect the raw per-layer state instead.
61    #[must_use]
62    pub fn grid(&self) -> &Grid {
63        self.composited.as_ref().unwrap_or(&self.layers)
64    }
65
66    /// Returns the raw, un-composited per-layer state, as received.
67    ///
68    /// Only interesting for a backend wrapping this one that returns `true` from
69    /// [`composites_layers`](Output::composites_layers) and so receives the multi-layer stream:
70    /// this is what lets a test assert which layer a cell arrived on, rather than only what the
71    /// composited frame looks like. Otherwise identical to [`grid`](Self::grid).
72    #[must_use]
73    pub const fn layer_grid(&self) -> &Grid {
74        &self.layers
75    }
76
77    /// Recomputes [`grid`](Self::grid) from [`layer_grid`](Self::layer_grid).
78    ///
79    /// A no-op while only layer 0 has ever been written, which keeps the single-layer path (every
80    /// cell backend) free of both the flatten and the second grid's allocation.
81    fn recomposite(&mut self) {
82        if self.layers.max_layer() == 0 {
83            return;
84        }
85        let size = self.layers.size();
86        let dst = self
87            .composited
88            .get_or_insert_with(|| Grid::new(size.width(), size.height()));
89        self.layers.flatten_into(dst);
90    }
91
92    /// Returns the cursor visibility.
93    #[must_use]
94    pub const fn cursor_visible(&self) -> bool {
95        self.cursor_visible
96    }
97
98    /// Returns the cursor position.
99    #[must_use]
100    pub const fn cursor_position(&self) -> Pos {
101        self.cursor_pos
102    }
103
104    /// Returns the cursor's shape and blink behavior, as last set by
105    /// [`Cursor::set_cursor_style`](crate::backend::Cursor::set_cursor_style).
106    #[must_use]
107    pub const fn cursor_style(&self) -> CursorStyle {
108        self.cursor_style
109    }
110
111    /// Injects a synthetic event into the queue.
112    ///
113    /// Coalesces consecutive `Mouse(Moved)` or same-button `Mouse(Drag)` events with the queue's
114    /// current tail (see [`coalesces_with`]), matching the `retroglyph-window` and
115    /// `retroglyph-terminal-wasm` backends this stands in for during tests (retroglyph#768): a
116    /// caller pushing a burst of pointer positions before draining the queue sees only the latest
117    /// one, the same as it would against a real backend.
118    pub fn push_event(&mut self, event: Event) {
119        if let Some(back) = self.event_queue.back_mut()
120            && coalesces_with(&event, back)
121        {
122            *back = event;
123            return;
124        }
125        self.event_queue.push_back(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                    Self::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            '·'
199        } else {
200            cell.glyph()
201        };
202        (glyph, is_spacer)
203    }
204
205    /// Appends the SGR codes for `style`'s non-default foreground/background to `out`, as a
206    /// single `\x1b[...m` sequence.
207    ///
208    /// A `Color::Default` channel is left unset, relying on the caller's preceding `\x1b[0m`
209    /// reset rather than emitting an explicit `39`/`49` reset code. Emits nothing at all when
210    /// both channels are `Color::Default`.
211    fn push_sgr(out: &mut String, style: Style) {
212        let mut params = String::new();
213        if let Some(code) = Self::sgr_color(style.foreground(), false) {
214            let _ = write!(params, "{code}");
215        }
216        if let Some(code) = Self::sgr_color(style.background(), true) {
217            if !params.is_empty() {
218                params.push(';');
219            }
220            let _ = write!(params, "{code}");
221        }
222        if !params.is_empty() {
223            let _ = write!(out, "\x1b[{params}m");
224        }
225    }
226
227    /// The SGR parameter string for `color` in the foreground (`bg: false`) or background
228    /// (`bg: true`) slot, or `None` for `Color::Default` (nothing to emit).
229    ///
230    /// Codes follow ECMA-48 SGR (see this module's file-level docs): 30/40 base for standard
231    /// colors, 90/100 for bright, offset by the color index within its group of 8.
232    fn sgr_color(color: Color, bg: bool) -> Option<String> {
233        match color {
234            Color::Default => None,
235            Color::Ansi(ansi) => {
236                let index = ansi.to_index();
237                let base = match (index < 8, bg) {
238                    (true, false) => 30,
239                    (true, true) => 40,
240                    (false, false) => 90,
241                    (false, true) => 100,
242                };
243                Some(alloc::format!("{}", base + index % 8))
244            }
245            Color::Indexed(index) => Some(alloc::format!("{};5;{index}", if bg { 48 } else { 38 })),
246            Color::Rgb { r, g, b } => {
247                Some(alloc::format!("{};2;{r};{g};{b}", if bg { 48 } else { 38 }))
248            }
249        }
250    }
251}
252
253impl Output for Headless {
254    type Error = core::convert::Infallible;
255
256    fn draw_layers<'a, I>(&mut self, content: I) -> Result<(), Self::Error>
257    where
258        I: Iterator<Item = DrawCell<'a>>,
259    {
260        for cell in content {
261            let pos = cell.pos;
262            // A `DrawCell` is already-resolved content from some source grid, replayed here
263            // cell-by-cell at that same source position and on that same source layer, not a
264            // caller placing a new tile at an arbitrary destination. `put_tile`'s sanitizing
265            // (span role, `WIDE_CHAR`/spacer synthesis, overlap clearing) exists for the latter;
266            // using it here would strip `SPAN_ANCHOR`/`SPAN_COVERED` from a faithfully-positioned
267            // replay (retroglyph#984) the same way it should from a moved one, so this writes
268            // straight through `tile_mut_or_alloc` instead. That never fails on an in-bounds `pos`
269            // (sourced from this same grid's own geometry).
270            //
271            // Keeping each layer separate rather than folding everything onto layer 0 is what
272            // makes a raw multi-layer stream replay correctly (retroglyph#1084): the stream is a
273            // per-layer diff, so a cell occluded on a higher layer this frame and revealed the
274            // next arrives only as the higher layer's erase, with nothing resent for the layer
275            // below. Only retained per-layer state can composite that back.
276            if let Some(t) = self.layers.tile_mut_or_alloc(cell.layer, pos) {
277                *t = *cell.tile;
278            }
279            // Rebuild the side-table entry from the parts that arrived, so a headless capture
280            // round-trips both members rather than only the grapheme.
281            let extra = crate::grid::TileExtra {
282                grapheme: cell.grapheme.map(alloc::sync::Arc::from),
283                tint: cell.tint,
284            };
285            self.layers.set_extra(cell.layer, pos.x, pos.y, extra);
286        }
287        self.recomposite();
288        Ok(())
289    }
290
291    fn resize(&mut self, size: Size) {
292        self.layers.resize(size.width(), size.height());
293        // Resized in lockstep rather than dropped: `flatten_into` requires matching dimensions,
294        // and `Grid::resize` preserves the overlapping region, so the composited view survives a
295        // resize the same way a real backend's surface would.
296        if let Some(composited) = self.composited.as_mut() {
297            composited.resize(size.width(), size.height());
298        }
299    }
300
301    fn flush(&mut self) -> Result<(), Self::Error> {
302        Ok(())
303    }
304
305    fn size(&self) -> Size {
306        Size::new(self.layers.width(), self.layers.height())
307    }
308
309    fn clear(&mut self) -> Result<(), Self::Error> {
310        self.layers.clear_all();
311        if let Some(composited) = self.composited.as_mut() {
312            composited.clear_all();
313        }
314        Ok(())
315    }
316}
317
318impl Input for Headless {
319    fn poll_event(&mut self, _timeout: Duration) -> Option<Event> {
320        self.event_queue.pop_front()
321    }
322
323    fn push_event(&mut self, event: Event) {
324        Self::push_event(self, event);
325    }
326}
327
328impl Cursor for Headless {
329    fn set_cursor_visible(&mut self, visible: bool) {
330        self.cursor_visible = visible;
331    }
332
333    fn set_cursor_position(&mut self, position: Pos) {
334        self.cursor_pos = position;
335    }
336
337    fn set_cursor_style(&mut self, style: CursorStyle) {
338        self.cursor_style = style;
339    }
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345
346    #[test]
347    fn test_headless_new() {
348        let backend = Headless::new(80, 25);
349        assert_eq!(backend.grid().width(), 80);
350        assert_eq!(backend.grid().height(), 25);
351    }
352
353    #[test]
354    fn test_headless_cursor_style_defaults_and_records_set_cursor_style() {
355        use crate::backend::CursorStyle;
356
357        let mut backend = Headless::new(10, 10);
358        assert_eq!(backend.cursor_style(), CursorStyle::BlinkingBlock);
359        Cursor::set_cursor_style(&mut backend, CursorStyle::SteadyBar);
360        assert_eq!(backend.cursor_style(), CursorStyle::SteadyBar);
361    }
362
363    #[test]
364    fn test_headless_events() {
365        let mut backend = Headless::new(10, 10);
366        let event = Event::Close;
367        backend.push_event(event);
368        assert_eq!(backend.poll_event(Duration::ZERO), Some(Event::Close));
369        assert_eq!(backend.poll_event(Duration::ZERO), None);
370    }
371
372    fn moved(x: u16) -> Event {
373        use crate::event::{KeyModifiers, MouseEvent, MouseEventKind};
374        Event::Mouse(MouseEvent {
375            kind: MouseEventKind::Moved,
376            position: Pos { x, y: 0 },
377            pixel_position: None,
378            modifiers: KeyModifiers::NONE,
379        })
380    }
381
382    /// Regression test for retroglyph#768: `Headless` must coalesce a burst of consecutive
383    /// `Moved` events the same way `retroglyph-window` and `retroglyph-terminal-wasm` do, so
384    /// `TestHarness`-driven tests stay faithful to the real backends.
385    #[test]
386    fn consecutive_moved_events_coalesce_to_one() {
387        let mut backend = Headless::new(10, 10);
388        for x in 0..1_000u16 {
389            backend.push_event(moved(x));
390        }
391        assert_eq!(backend.event_queue.len(), 1);
392        assert_eq!(backend.poll_event(Duration::ZERO), Some(moved(999)));
393        assert_eq!(backend.poll_event(Duration::ZERO), None);
394    }
395
396    /// A non-`Moved` event between two `Moved` bursts must not be swallowed: only *consecutive*
397    /// `Moved` events collapse.
398    #[test]
399    fn non_moved_event_breaks_coalescing() {
400        use crate::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
401        let mut backend = Headless::new(10, 10);
402        backend.push_event(moved(1));
403        backend.push_event(moved(2));
404        backend.push_event(Event::Mouse(MouseEvent {
405            kind: MouseEventKind::Down(MouseButton::Left),
406            position: Pos { x: 2, y: 0 },
407            pixel_position: None,
408            modifiers: KeyModifiers::NONE,
409        }));
410        backend.push_event(moved(3));
411        assert_eq!(backend.event_queue.len(), 3);
412        assert_eq!(backend.poll_event(Duration::ZERO), Some(moved(2)));
413        assert!(matches!(
414            backend.poll_event(Duration::ZERO),
415            Some(Event::Mouse(MouseEvent {
416                kind: MouseEventKind::Down(MouseButton::Left),
417                ..
418            }))
419        ));
420        assert_eq!(backend.poll_event(Duration::ZERO), Some(moved(3)));
421    }
422
423    /// A transparent (untouched, `EMPTY`) cell on a higher layer must not erase opaque content
424    /// below it when the raw multi-layer stream is replayed (retroglyph#1084). `Grid::diff`
425    /// yields every cell of a newly allocated layer, so the blank parts of layer 1 arrive right
426    /// after layer 0's real content.
427    #[test]
428    fn draw_layers_treats_empty_higher_layer_cells_as_transparent() {
429        let mut backend = Headless::new(3, 1);
430        let a = Tile::new('a', Style::default());
431        let hash = Tile::new('#', Style::default());
432        let blank = Tile::default();
433        backend
434            .draw_layers(
435                [
436                    DrawCell::on_layer(0, Pos::new(0, 0), &a),
437                    // Layer 1 arrives in full, blanks included.
438                    DrawCell::on_layer(1, Pos::new(0, 0), &blank),
439                    DrawCell::on_layer(1, Pos::new(1, 0), &blank),
440                    DrawCell::on_layer(1, Pos::new(2, 0), &hash),
441                ]
442                .into_iter(),
443            )
444            .expect("draw_layers failed");
445        assert_eq!(backend.format_view(), "a·#\n");
446    }
447
448    /// The stream is a per-layer *diff*, so revealing a cell sends only the erase on the layer
449    /// that used to occlude it. Nothing is resent for the layer below, which is why `Headless`
450    /// has to retain each layer rather than composite eagerly into one grid.
451    #[test]
452    fn draw_layers_reveals_the_layer_below_when_an_occluder_is_erased() {
453        let mut backend = Headless::new(3, 1);
454        let a = Tile::new('a', Style::default());
455        let hash = Tile::new('#', Style::default());
456        let blank = Tile::default();
457        backend
458            .draw_layers(
459                [
460                    DrawCell::on_layer(0, Pos::new(0, 0), &a),
461                    DrawCell::on_layer(1, Pos::new(0, 0), &hash),
462                ]
463                .into_iter(),
464            )
465            .expect("draw_layers failed");
466        assert_eq!(backend.format_view(), "#··\n", "layer 1 occludes layer 0");
467
468        // Only layer 1's erase is sent: layer 0 is unchanged, so it contributes nothing.
469        backend
470            .draw_layers(core::iter::once(DrawCell::on_layer(
471                1,
472                Pos::new(0, 0),
473                &blank,
474            )))
475            .expect("draw_layers failed");
476        assert_eq!(
477            backend.format_view(),
478            "a··\n",
479            "erasing the occluder must restore layer 0's tile, which was never resent"
480        );
481    }
482
483    /// An explicit space is `EMPTY`-clear and so stays opaque, matching `flatten_into` and the
484    /// transparency model documented on [`crate::grid`].
485    #[test]
486    fn draw_layers_keeps_an_explicit_space_on_a_higher_layer_opaque() {
487        let mut backend = Headless::new(3, 1);
488        let a = Tile::new('a', Style::default());
489        let space = Tile::new(' ', Style::default());
490        backend
491            .draw_layers(
492                [
493                    DrawCell::on_layer(0, Pos::new(0, 0), &a),
494                    DrawCell::on_layer(1, Pos::new(0, 0), &space),
495                ]
496                .into_iter(),
497            )
498            .expect("draw_layers failed");
499        assert_eq!(backend.format_view(), "···\n");
500    }
501
502    /// `layer_grid` reports where a cell actually landed, which the composited `grid` cannot.
503    #[test]
504    fn layer_grid_exposes_the_raw_per_layer_stream() {
505        let mut backend = Headless::new(3, 1);
506        let a = Tile::new('a', Style::default());
507        let hash = Tile::new('#', Style::default());
508        backend
509            .draw_layers(
510                [
511                    DrawCell::on_layer(0, Pos::new(0, 0), &a),
512                    DrawCell::on_layer(1, Pos::new(0, 0), &hash),
513                ]
514                .into_iter(),
515            )
516            .expect("draw_layers failed");
517        let raw = backend.layer_grid();
518        assert_eq!(raw.tile(0, Pos::new(0, 0)).map(Tile::glyph), Some('a'));
519        assert_eq!(raw.tile(1, Pos::new(0, 0)).map(Tile::glyph), Some('#'));
520        assert_eq!(backend.grid()[Pos::new(0, 0)].glyph(), '#');
521    }
522
523    /// The single-layer path (every cell backend, which receives a pre-flattened stream) must
524    /// not allocate the composited grid at all: `grid` is the received content directly.
525    #[test]
526    fn single_layer_stream_skips_the_composited_grid() {
527        let mut backend = Headless::new(3, 1);
528        let a = Tile::new('a', Style::default());
529        backend
530            .draw_layers(core::iter::once(DrawCell::on_layer(0, Pos::new(0, 0), &a)))
531            .expect("draw_layers failed");
532        assert!(backend.composited.is_none());
533        assert_eq!(backend.format_view(), "a··\n");
534    }
535
536    #[test]
537    fn test_format_view_snapshot() {
538        use crate::terminal::Terminal;
539        let backend = Headless::new(10, 3);
540        let mut term = Terminal::new(backend);
541        term.draw(|s| {
542            s.put((1, 1), 'H', Style::default());
543            s.put((2, 1), 'i', Style::default());
544        })
545        .expect("draw failed");
546        let view = term.backend().format_view();
547        insta::assert_snapshot!(view, @r#"
548        ··········
549        ·Hi·······
550        ··········
551        "#);
552    }
553
554    /// A multi-cell span's covered cells are its text fallback, so a cell backend must render
555    /// all four glyphs. This is the deliberate difference from `WIDE_CHAR_SPACER`, which
556    /// `format_view` blanks out just above this test's code path.
557    #[test]
558    fn test_format_view_renders_span_fallback_glyphs() {
559        use crate::terminal::Terminal;
560        let backend = Headless::new(6, 3);
561        let mut term = Terminal::new(backend);
562        term.draw(|s| {
563            s.put_span((1, 0), &["C=", "[]"], Style::default())
564                .expect("span write");
565        })
566        .expect("draw failed");
567        let view = term.backend().format_view();
568        insta::assert_snapshot!(view, @r#"
569        ·C=···
570        ·[]···
571        ······
572        "#);
573    }
574
575    #[test]
576    fn test_format_styled_unstyled_matches_format_view_text() {
577        use crate::terminal::Terminal;
578        let backend = Headless::new(6, 2);
579        let mut term = Terminal::new(backend);
580        term.draw(|s| {
581            s.put((1, 0), 'H', Style::default());
582        })
583        .expect("draw failed");
584        // No non-default color anywhere, so this is just format_view with a reset per row.
585        assert_eq!(
586            term.backend().format_styled(),
587            "\x1b[0m·H····\n\x1b[0m······\n"
588        );
589    }
590
591    #[test]
592    fn test_format_styled_emits_fg_and_bg_sgr_on_change() {
593        use crate::terminal::Terminal;
594        let backend = Headless::new(3, 1);
595        let mut term = Terminal::new(backend);
596        term.draw(|s| {
597            let style = Style::new().fg(Color::RED).bg(Color::BLUE);
598            s.put((1, 0), 'x', style);
599        })
600        .expect("draw failed");
601        assert_eq!(
602            term.backend().format_styled(),
603            "\x1b[0m·\x1b[0m\x1b[31;44mx\x1b[0m·\n"
604        );
605    }
606
607    #[test]
608    fn test_format_styled_rgb_and_indexed() {
609        use crate::terminal::Terminal;
610        let backend = Headless::new(2, 1);
611        let mut term = Terminal::new(backend);
612        term.draw(|s| {
613            s.put(
614                (0, 0),
615                'a',
616                Style::new().fg(Color::Rgb { r: 1, g: 2, b: 3 }),
617            );
618            s.put((1, 0), 'b', Style::new().bg(Color::Indexed(200)));
619        })
620        .expect("draw failed");
621        assert_eq!(
622            term.backend().format_styled(),
623            "\x1b[0m\x1b[38;2;1;2;3ma\x1b[0m\x1b[48;5;200mb\x1b[0m\n"
624        );
625    }
626
627    #[test]
628    fn test_format_styled_spacer_cells_carry_no_style() {
629        use crate::terminal::Terminal;
630        let backend = Headless::new(4, 1);
631        let mut term = Terminal::new(backend);
632        term.draw(|s| {
633            s.put_span((0, 0), &["[]"], Style::new().fg(Color::GREEN))
634                .expect("span write");
635        })
636        .expect("draw failed");
637        // Both cells of the span share the styled glyph fallback (see
638        // `test_format_view_renders_span_fallback_glyphs`); this asserts a real spacer, produced
639        // by a wide EGC grapheme, drops style instead of inheriting the lead cell's.
640        #[cfg(feature = "egc")]
641        {
642            let mut term = Terminal::new(Headless::new(4, 1));
643            term.draw(|s| {
644                s.put((0, 0), 'あ', Style::new().fg(Color::GREEN));
645            })
646            .expect("draw failed");
647            let styled = term.backend().format_styled();
648            assert!(styled.contains("\x1b[32mあ"));
649            // The spacer cell after the wide glyph is blank and resets rather than repeating
650            // the green foreground.
651            assert!(!styled.contains("\x1b[32m "));
652        }
653    }
654}