Skip to main content

lattice_ui_tui/
cells_render.rs

1//! B2.4 (2026-06-04): `DisplayMatrix` → ratatui span conversion.
2//!
3//! The TUI consumes the canonical
4//! [`lattice_host::display_matrix::DisplayMatrix`] published by the
5//! cells worker and emits a `Vec<Span<'static>>` ready for ratatui's
6//! text widgets. This module is the substrate→TUI translation layer.
7//!
8//! ## Why a separate module
9//!
10//! A `DisplayLine` carries style-*tagged* byte runs
11//! ([`lattice_host::display_matrix::DisplayRun`]: a
12//! `lattice_syntax::Style` enum + non-style flag bits), NOT resolved
13//! colours — the renderer resolves `style → fg + modifiers` against the
14//! host theme at paint (truecolor `Color::Rgb`, or ratatui's
15//! nearest-named downsample in low-colour terminals).
16//! [`display_line_to_source_spans`] walks the runs, drops `INLAY` runs
17//! so the spans cover source bytes one-to-one with the rope line (the
18//! overlay pipeline positions overlays by source-byte offset), groups
19//! consecutive runs with the same resolved style, and emits one
20//! [`ratatui::text::Span`] per group.
21//!
22//! Pre-B2.4 this module converted the projected per-character cell grid
23//! (`cell_row_to_source_spans`); B2.4a cut the TUI over to the
24//! `DisplayMatrix` and B2.4b deleted the cell→span path. The remaining
25//! cell path (worker projection, GPU reader) goes away in B3/B4.
26
27use ratatui::style::{Color, Modifier, Style};
28use ratatui::text::Span;
29
30/// B2.4 (2026-06-04): the `DisplayLine` analogue of
31/// [`cell_row_to_source_spans`] — the TUI's source of document-body
32/// spans once it consumes the canonical `DisplayMatrix` directly
33/// instead of the projected cell grid. Walks `line.runs`, drops
34/// `INLAY` runs (so the spans cover source bytes one-to-one with the
35/// rope line — overlays positioned by source byte work unchanged), and
36/// resolves each run's syntax `style` + flags → a ratatui [`Style`] via
37/// the host theme.
38///
39/// **Resolution parity.** This reproduces the worker's
40/// `display_line_to_cell_row` projection byte-for-byte so the cutover is
41/// visually invisible: `style → fg` via `theme.syntax_style(..).fg`
42/// (→ `to_rgb_u32`); a `WS_TRAILING` marker run takes
43/// `theme.whitespace_trailing_style` fg; modifiers come from the host
44/// theme's per-style `Modifiers`. fg `0` maps to `None` (pane default),
45/// exactly as the cell path's `fg == 0`. Runs are grouped by the
46/// *resolved* ratatui style, so two runs with distinct syntax tags but
47/// identical resolved colour merge — matching the cell path, which
48/// grouped on resolved `(fg, bg, mods)`.
49///
50/// Returns an empty `Vec` for an empty line or one that is entirely
51/// inlay runs.
52pub fn display_line_to_source_spans(
53    line: &lattice_host::display_matrix::DisplayLine,
54    resolved: &lattice_host::ui::theme::ResolvedTheme,
55    ids: &lattice_host::ui::theme::BuiltinElementIds,
56) -> Vec<Span<'static>> {
57    use lattice_cells::cell_flags;
58    use lattice_host::ui::theme::resolve_syntax_style;
59    // Resolve the default + trailing fg once (mirrors the worker's
60    // `display_line_to_cell_row`). `default_fg` is the trailing fg's
61    // fallback, exactly as in the projection.
62    let default_fg = resolve_syntax_style(resolved, ids, lattice_syntax::Style::Default)
63        .fg
64        .map(|c| c.to_rgb_u32(0))
65        .unwrap_or(0);
66    let trailing_fg = resolved
67        .get(ids.whitespace_trailing)
68        .fg
69        .map(|c| c.to_rgb_u32(default_fg))
70        .unwrap_or(default_fg);
71
72    let mut spans: Vec<Span<'static>> = Vec::new();
73    let mut current_text = String::new();
74    let mut current_style: Option<Style> = None;
75    let mut byte_off = 0usize;
76    for run in line.runs.iter() {
77        let end = byte_off + run.len as usize;
78        let slice = &line.text[byte_off..end];
79        byte_off = end;
80        // Source spans exclude inlay runs.
81        if run.flags & cell_flags::INLAY != 0 {
82            continue;
83        }
84        let style = display_run_to_style(run, resolved, ids, trailing_fg);
85        if current_style == Some(style) {
86            current_text.push_str(slice);
87        } else {
88            if !current_text.is_empty() {
89                spans.push(Span::styled(
90                    std::mem::take(&mut current_text),
91                    current_style.unwrap_or_default(),
92                ));
93            }
94            current_text.push_str(slice);
95            current_style = Some(style);
96        }
97    }
98    if !current_text.is_empty() {
99        spans.push(Span::styled(
100            current_text,
101            current_style.unwrap_or_default(),
102        ));
103    }
104    spans
105}
106
107/// Resolve one `DisplayRun` (non-inlay) to a ratatui [`Style`] via the
108/// host theme. Mirrors `display_line_to_cell_row`'s per-run resolution:
109/// a `WS_TRAILING` marker run takes `trailing_fg`; otherwise the syntax
110/// style's fg. fg `0` ⇒ leave unset (pane default). Modifier bits come
111/// from the host theme's per-style `Modifiers`.
112fn display_run_to_style(
113    run: &lattice_host::display_matrix::DisplayRun,
114    resolved: &lattice_host::ui::theme::ResolvedTheme,
115    ids: &lattice_host::ui::theme::BuiltinElementIds,
116    trailing_fg: u32,
117) -> Style {
118    use lattice_cells::cell_flags;
119    let is_ws_marker = run.flags & cell_flags::WS_MARKER != 0;
120    let is_trailing = run.flags & cell_flags::WS_TRAILING != 0;
121    let host = lattice_host::ui::theme::resolve_syntax_style(resolved, ids, run.style);
122    // For `Style::Default` this is the theme's default fg — the same
123    // value `default_fg` resolves to — so no special-case is needed.
124    let style_fg = host.fg.map(|c| c.to_rgb_u32(0)).unwrap_or(0);
125    let fg = if is_ws_marker && is_trailing {
126        trailing_fg
127    } else {
128        style_fg
129    };
130    let mut style = Style::default();
131    if fg != 0 {
132        style = style.fg(rgb_u32_to_color(fg));
133    }
134    // DR.2: intra-line refinement. A refined run overrides its row's
135    // diff tint with a stronger one; foreground is untouched, which is
136    // what keeps the syntax colour visible underneath. Set here rather
137    // than by a byte-range walk in the renderer because run boundaries
138    // already account for tab expansion and whitespace markers — a
139    // source-byte walk over RENDERED content drifts on the first tab.
140    if let Some(kind) = run.refine {
141        let element = match kind {
142            lattice_cells::RefineKind::Added => ids.diff_add_refine_bg,
143            lattice_cells::RefineKind::Removed => ids.diff_remove_refine_bg,
144        };
145        if let Some(bg) = resolved.get(element).bg {
146            style = style.bg(rgb_u32_to_color(bg.to_rgb_u32(0)));
147        }
148    }
149    let m = &host.modifiers;
150    let mut mods = Modifier::empty();
151    if m.bold {
152        mods |= Modifier::BOLD;
153    }
154    if m.italic {
155        mods |= Modifier::ITALIC;
156    }
157    if m.underline {
158        mods |= Modifier::UNDERLINED;
159    }
160    if m.dim {
161        mods |= Modifier::DIM;
162    }
163    if m.reverse {
164        mods |= Modifier::REVERSED;
165    }
166    if !mods.is_empty() {
167        style = style.add_modifier(mods);
168    }
169    style
170}
171
172/// Convert a packed `0xRRGGBB` `u32` colour to a ratatui
173/// `Color::Rgb`. Centralised so the bit layout is one place to
174/// update if the cell-grid ever extends to RGBA.
175pub(crate) fn rgb_u32_to_color(rgb: u32) -> Color {
176    let r = ((rgb >> 16) & 0xff) as u8;
177    let g = ((rgb >> 8) & 0xff) as u8;
178    let b = (rgb & 0xff) as u8;
179    Color::Rgb(r, g, b)
180}
181
182#[cfg(test)]
183mod tests {
184    #![allow(clippy::unwrap_used)]
185    use super::*;
186    use lattice_cells::cell_flags;
187
188    /// Build a source-body `Vec<Span>` from `(text, fg_rgb)` segments —
189    /// the shape `display_line_to_source_spans` produces and the overlay
190    /// pipeline below consumes. `fg_rgb == 0` ⇒ pane-default fg
191    /// (`style.fg == None`). One span per segment (pass pre-grouped
192    /// segments); replaces the retired cell-grid body builders. The
193    /// overlay functions under test are renderer-generic over
194    /// `Vec<Span>`, so the bodies need no cell/display provenance.
195    fn body(parts: &[(&str, u32)]) -> Vec<Span<'static>> {
196        parts
197            .iter()
198            .map(|(text, fg)| {
199                let mut style = Style::default();
200                if *fg != 0 {
201                    style = style.fg(rgb_u32_to_color(*fg));
202                }
203                Span::styled(text.to_string(), style)
204            })
205            .collect()
206    }
207
208    // ---- DR.5 — a refined run actually reaches the painted style ----
209    //
210    // Every other refinement test in the tree stops one layer short of
211    // this: `lattice-diff` pins the computed byte ranges, and
212    // `cells_worker` pins that a `RefineSpan` splits runs and sets
213    // `DisplayRun.refine`. Nothing asserted that the flag then becomes
214    // a background on the painted style — which is exactly where
215    // "computes correctly, renders nothing" hides, and where a missing
216    // or unresolved theme element would be invisible to the whole
217    // existing suite.
218
219    /// A run carrying `RefineKind::Removed` paints the removed-refine
220    /// background, and its foreground is untouched — refinement is a
221    /// background axis, which is what keeps syntax colour readable
222    /// underneath it.
223    #[test]
224    fn a_refined_run_paints_the_refine_background() {
225        let (resolved, ids) = defaults();
226        let plain = DisplayRun {
227            len: 3,
228            style: lattice_syntax::Style::Default,
229            flags: 0,
230            refine: None,
231        };
232        let refined = DisplayRun {
233            refine: Some(lattice_cells::RefineKind::Removed),
234            ..plain.clone()
235        };
236
237        let plain_style = display_run_to_style(&plain, &resolved, &ids, 0);
238        let refined_style = display_run_to_style(&refined, &resolved, &ids, 0);
239
240        assert!(
241            plain_style.bg.is_none(),
242            "an unrefined run sets no background of its own"
243        );
244        assert!(
245            refined_style.bg.is_some(),
246            "a refined run must paint a background — the theme element \
247             resolves, and this is the assertion the rest of the DR \
248             suite could not make"
249        );
250        assert_eq!(
251            refined_style.fg, plain_style.fg,
252            "refinement is a background axis; the syntax fg survives"
253        );
254    }
255
256    /// The two sides resolve to DIFFERENT backgrounds. A single shared
257    /// colour would render an addition and a deletion identically,
258    /// which reads as a bug rather than as emphasis.
259    #[test]
260    fn the_two_refine_sides_paint_different_backgrounds() {
261        let (resolved, ids) = defaults();
262        let run = |kind| DisplayRun {
263            len: 3,
264            style: lattice_syntax::Style::Default,
265            flags: 0,
266            refine: Some(kind),
267        };
268        let added =
269            display_run_to_style(&run(lattice_cells::RefineKind::Added), &resolved, &ids, 0);
270        let removed =
271            display_run_to_style(&run(lattice_cells::RefineKind::Removed), &resolved, &ids, 0);
272        assert!(added.bg.is_some() && removed.bg.is_some());
273        assert_ne!(
274            added.bg, removed.bg,
275            "added and removed refinement must be distinguishable"
276        );
277    }
278
279    // ---- B2.4 — DisplayMatrix → source spans ----
280    //
281    // `display_line_to_source_spans` is the TUI's cutover from the
282    // projected cell grid to the canonical `DisplayMatrix`. These pin
283    // its resolution parity (style→theme fg, inlay drop, trailing fg,
284    // merge-across-dropped-inlay) so it stays byte-identical to the
285    // worker's `display_line_to_cell_row` projection the cell path used.
286
287    use lattice_host::display_matrix::{DisplayLine, DisplayRun};
288    use lattice_host::ui::theme::{
289        BuiltinElementIds, InMemoryThemeRegistry, ResolvedTheme, ThemeRegistry as _,
290        resolve_syntax_style,
291    };
292
293    /// T.5.b: build the resolved table + builtin ids from the
294    /// default registry — the same construction the renderer uses
295    /// at boot. Replaces the deleted `HostTheme::default()` +
296    /// `Theme::syntax_style` reads in these tests.
297    fn defaults() -> (std::sync::Arc<ResolvedTheme>, BuiltinElementIds) {
298        let reg = InMemoryThemeRegistry::with_defaults();
299        let resolved = reg.resolved();
300        let ids = BuiltinElementIds::capture(&reg);
301        (resolved, ids)
302    }
303
304    /// Build a `DisplayLine` from `(text, style, flags)` run specs.
305    fn dline(specs: &[(&str, lattice_syntax::Style, u16)]) -> DisplayLine {
306        let mut text = String::new();
307        let mut runs = Vec::new();
308        for (s, style, flags) in specs {
309            runs.push(DisplayRun {
310                len: s.len() as u32,
311                style: *style,
312                flags: *flags,
313                refine: None,
314            });
315            text.push_str(s);
316        }
317        let col_count = text.chars().count() as u32;
318        DisplayLine {
319            source_line: 0,
320            text: std::sync::Arc::from(text.as_str()),
321            runs: std::sync::Arc::from(runs.into_boxed_slice()),
322            col_map: std::sync::Arc::from([] as [(u32, u32); 0]),
323            conceals: std::sync::Arc::from([] as [lattice_cells::ConcealRange; 0]),
324            col_count,
325            fold: None,
326        }
327    }
328
329    /// Expected ratatui colour for a `0xRRGGBB` fg: `None` when `0`
330    /// (pane default), else `Color::Rgb`. Mirrors the resolver.
331    fn expect_color(rgb: u32) -> Option<Color> {
332        if rgb == 0 {
333            None
334        } else {
335            Some(Color::Rgb(
336                ((rgb >> 16) & 0xff) as u8,
337                ((rgb >> 8) & 0xff) as u8,
338                (rgb & 0xff) as u8,
339            ))
340        }
341    }
342
343    /// A keyword run resolves to the host theme's keyword fg (+ bold if
344    /// the theme sets it), exactly as the projection did.
345    #[test]
346    fn display_keyword_run_takes_theme_keyword_fg() {
347        let (resolved, ids) = defaults();
348        let kw_fg = resolve_syntax_style(&resolved, &ids, lattice_syntax::Style::Keyword)
349            .fg
350            .map(|c| c.to_rgb_u32(0))
351            .unwrap_or(0);
352        let line = dline(&[
353            ("fn", lattice_syntax::Style::Keyword, 0),
354            (" x", lattice_syntax::Style::Default, 0),
355        ]);
356        let spans = display_line_to_source_spans(&line, &resolved, &ids);
357        assert_eq!(spans[0].content.as_ref(), "fn");
358        assert_eq!(spans[0].style.fg, expect_color(kw_fg));
359    }
360
361    /// Inlay runs are dropped from the source-span output (overlays
362    /// position by source byte, so inlay text must not appear here).
363    #[test]
364    fn display_source_spans_drop_inlay_runs() {
365        let (resolved, ids) = defaults();
366        let line = dline(&[
367            ("hi", lattice_syntax::Style::Default, 0),
368            (": i32", lattice_syntax::Style::Default, cell_flags::INLAY),
369        ]);
370        let text: String = display_line_to_source_spans(&line, &resolved, &ids)
371            .iter()
372            .map(|s| s.content.as_ref().to_string())
373            .collect();
374        assert_eq!(text, "hi");
375    }
376
377    /// A `WS_TRAILING` marker run takes the theme's trailing-whitespace
378    /// fg (the cell path baked this into the cell `fg`).
379    #[test]
380    fn display_trailing_ws_run_takes_trailing_fg() {
381        let (resolved, ids) = defaults();
382        let default_fg = resolve_syntax_style(&resolved, &ids, lattice_syntax::Style::Default)
383            .fg
384            .map(|c| c.to_rgb_u32(0))
385            .unwrap_or(0);
386        let trailing_fg = resolved
387            .get(ids.whitespace_trailing)
388            .fg
389            .map(|c| c.to_rgb_u32(default_fg))
390            .unwrap_or(default_fg);
391        let line = dline(&[
392            ("x", lattice_syntax::Style::Default, 0),
393            (
394                "·",
395                lattice_syntax::Style::Default,
396                cell_flags::WS_MARKER | cell_flags::WS_TRAILING,
397            ),
398        ]);
399        let spans = display_line_to_source_spans(&line, &resolved, &ids);
400        let last = spans.last().unwrap();
401        assert_eq!(last.content.as_ref(), "·");
402        assert_eq!(last.style.fg, expect_color(trailing_fg));
403    }
404
405    /// Two same-style source runs separated by a dropped inlay merge
406    /// into one span — matches the cell path, which grouped on the
407    /// resolved style across the inlay-filtered cell stream.
408    #[test]
409    fn display_same_style_runs_merge_across_dropped_inlay() {
410        let (resolved, ids) = defaults();
411        let line = dline(&[
412            ("ab", lattice_syntax::Style::Default, 0),
413            ("INLAY", lattice_syntax::Style::Default, cell_flags::INLAY),
414            ("cd", lattice_syntax::Style::Default, 0),
415        ]);
416        let spans = display_line_to_source_spans(&line, &resolved, &ids);
417        assert_eq!(
418            spans.len(),
419            1,
420            "same-style runs merge across a dropped inlay"
421        );
422        assert_eq!(spans[0].content.as_ref(), "abcd");
423    }
424
425    /// A line of only inlay runs yields no source spans.
426    #[test]
427    fn display_all_inlay_line_yields_no_source_spans() {
428        let (resolved, ids) = defaults();
429        let line = dline(&[(": T", lattice_syntax::Style::Default, cell_flags::INLAY)]);
430        assert!(display_line_to_source_spans(&line, &resolved, &ids).is_empty());
431    }
432
433    // ---- S3.c.1 — whitespace decoration on cell-derived bodies ----
434    //
435    // Validates that `crate::render::apply_whitespace_decoration`
436    // walks cell-derived spans correctly. The decoration function
437    // consumes spans + line text opaquely and walks each char by
438    // utf-8 byte offset; cell-derived source spans cover the same
439    // source-byte positions one-to-one with `line_text`, so the
440    // classifier should fire at identical positions to the
441    // legacy RowPrepaint path.
442
443    use crate::render::{WhitespaceDecoration, apply_whitespace_decoration};
444    use ratatui::style::Style as TuiStyle;
445
446    fn ws_deco_all_off() -> WhitespaceDecoration {
447        WhitespaceDecoration {
448            tab: None,
449            trailing: None,
450            leading: None,
451            space: None,
452            eol: None,
453            style_normal: TuiStyle::default(),
454            style_trailing: TuiStyle::default(),
455        }
456    }
457
458    fn ws_deco(
459        tab: Option<char>,
460        trailing: Option<char>,
461        leading: Option<char>,
462        space: Option<char>,
463        eol: Option<char>,
464    ) -> WhitespaceDecoration {
465        WhitespaceDecoration {
466            tab,
467            trailing,
468            leading,
469            space,
470            eol,
471            style_normal: TuiStyle::default(),
472            style_trailing: TuiStyle::default(),
473        }
474    }
475
476    /// Helper: concatenate every span's text into one `String` so
477    /// tests can assert on the visible output without caring how
478    /// the spans were split.
479    fn collect_text(spans: &[Span<'static>]) -> String {
480        spans.iter().map(|s| s.content.as_ref()).collect()
481    }
482
483    /// Mid-line space cells get substituted by the `·` space glyph.
484    /// Cell-derived path produces source spans containing the
485    /// literal space; the classifier walks bytes and replaces.
486    #[test]
487    fn s3c1_mid_line_space_substituted() {
488        let fg = 0xcdd6f4;
489        let body = body(&[("a b", fg)]);
490        let line_text = "a b";
491        let d = ws_deco(None, None, None, Some('·'), None);
492        let out = apply_whitespace_decoration(body, line_text, &d);
493        assert_eq!(collect_text(&out), "a·b");
494    }
495
496    /// Leading-whitespace classification fires for spaces before
497    /// the first non-whitespace byte. Cell-derived spans don't
498    /// confuse the position tracking — `pos` advances by utf-8
499    /// byte length per char regardless of span boundaries.
500    #[test]
501    fn s3c1_leading_whitespace_substituted() {
502        let fg = 0xcdd6f4;
503        // `  hi` — two leading spaces.
504        let body = body(&[("  hi", fg)]);
505        let line_text = "  hi";
506        let d = ws_deco(None, None, Some('›'), None, None);
507        let out = apply_whitespace_decoration(body, line_text, &d);
508        assert_eq!(collect_text(&out), "››hi");
509    }
510
511    /// Trailing-whitespace classification fires for spaces after
512    /// the last non-whitespace byte. Cells-derived spans must
513    /// carry those trailing chars so the classifier sees them.
514    #[test]
515    fn s3c1_trailing_whitespace_substituted() {
516        let fg = 0xcdd6f4;
517        // `hi  ` — two trailing spaces.
518        let body = body(&[("hi  ", fg)]);
519        let line_text = "hi  ";
520        let d = ws_deco(None, Some('▷'), None, None, None);
521        let out = apply_whitespace_decoration(body, line_text, &d);
522        assert_eq!(collect_text(&out), "hi▷▷");
523    }
524
525    /// Tab cell substituted by the tab glyph. Cells carry the
526    /// `\t` codepoint verbatim — the converter preserves it; the
527    /// classifier substitutes.
528    #[test]
529    fn s3c1_tab_cell_substituted() {
530        let fg = 0xcdd6f4;
531        let body = body(&[("x\ty", fg)]);
532        let line_text = "x\ty";
533        let d = ws_deco(Some('→'), None, None, None, None);
534        let out = apply_whitespace_decoration(body, line_text, &d);
535        assert_eq!(collect_text(&out), "x→y");
536    }
537
538    /// EOL marker appends after every cell — including for cells-
539    /// derived bodies. Captures the contract that the EOL glyph
540    /// emit is independent of the input spans' provenance.
541    #[test]
542    fn s3c1_eol_marker_appends_after_cells() {
543        let fg = 0xcdd6f4;
544        let body = body(&[("hi", fg)]);
545        let line_text = "hi";
546        let d = ws_deco(None, None, None, None, Some('¶'));
547        let out = apply_whitespace_decoration(body, line_text, &d);
548        assert_eq!(collect_text(&out), "hi¶");
549    }
550
551    /// All-off whitespace decoration is a no-op: the cell-derived
552    /// body passes through unchanged. Defensive against any
553    /// future shortcut that might mutate input when no glyphs are
554    /// configured.
555    #[test]
556    fn s3c1_no_op_decoration_preserves_cell_spans() {
557        let fg = 0xcdd6f4;
558        let body_before = body(&[("a b", fg)]);
559        let line_text = "a b";
560        let d = ws_deco_all_off();
561        let body_after = apply_whitespace_decoration(body_before.clone(), line_text, &d);
562        // Same text, same span count, same styles.
563        assert_eq!(body_after.len(), body_before.len());
564        for (a, b) in body_after.iter().zip(body_before.iter()) {
565            assert_eq!(a.content.as_ref(), b.content.as_ref());
566            assert_eq!(a.style, b.style);
567        }
568    }
569
570    /// Whitespace decoration walks across span boundaries.
571    /// Construct a cell-derived body where the space sits between
572    /// two different-fg cells so it lands on a span boundary;
573    /// the classifier must still fire at the correct byte
574    /// position.
575    #[test]
576    fn s3c1_substitution_across_span_boundary() {
577        let fg_a = 0xff0000;
578        let fg_b = 0x00ff00;
579        // `a ` (fg_a, one span) then `b` (fg_b) — the space lands at the
580        // span boundary; the classifier must still fire at byte 1.
581        let body = body(&[("a ", fg_a), ("b", fg_b)]);
582        assert_eq!(body.len(), 2);
583        let line_text = "a b";
584        let d = ws_deco(None, None, None, Some('·'), None);
585        let out = apply_whitespace_decoration(body, line_text, &d);
586        assert_eq!(collect_text(&out), "a·b");
587    }
588
589    // ---- S3.c.2 — semantic-tokens overlay on cell-derived bodies ----
590    //
591    // `apply_semantic_token_overlay(spans, overlay_start,
592    // overlay_end, fg, modifiers)` is the LSP semantic-tokens
593    // pass. It walks spans by byte position; the portion of
594    // each span intersecting `[overlay_start, overlay_end)`
595    // gets fg replaced and the supplied modifiers OR-ed in.
596    // bg, underline, reverse from earlier passes are preserved.
597    //
598    // For cell-derived bodies, the invariant is that source
599    // spans cover source-byte positions one-to-one with
600    // `line_text`, so the overlay's byte walk fires at the
601    // correct positions regardless of how cells were grouped.
602
603    use crate::render::apply_semantic_token_overlay;
604
605    /// Helper: build a uniform-fg body covering one short line — one
606    /// span, the shape a single-style `DisplayLine` resolves to.
607    fn flat_body(text: &str, fg: u32) -> Vec<Span<'static>> {
608        body(&[(text, fg)])
609    }
610
611    /// Mid-row overlay: covers bytes [2, 6) of an 8-byte line.
612    /// Result: three spans — pre (unchanged) / mid (new fg +
613    /// modifiers) / post (unchanged).
614    #[test]
615    fn s3c2_overlay_splits_span_when_partial() {
616        let body = flat_body("abcdefgh", 0xcdd6f4);
617        let overlay_fg = Color::Rgb(0xff, 0x00, 0x00);
618        let overlay_mods = Modifier::ITALIC;
619        let out = apply_semantic_token_overlay(body, 2, 6, overlay_fg, overlay_mods);
620        assert_eq!(out.len(), 3);
621        assert_eq!(out[0].content.as_ref(), "ab");
622        assert_eq!(out[1].content.as_ref(), "cdef");
623        assert_eq!(out[2].content.as_ref(), "gh");
624        // Middle span has the overlay's fg + modifier set.
625        assert_eq!(out[1].style.fg, Some(overlay_fg));
626        assert!(out[1].style.add_modifier.contains(Modifier::ITALIC));
627        // Outer spans keep the cell-derived fg.
628        assert_eq!(out[0].style.fg, Some(Color::Rgb(0xcd, 0xd6, 0xf4)));
629        assert_eq!(out[2].style.fg, Some(Color::Rgb(0xcd, 0xd6, 0xf4)));
630    }
631
632    /// Overlay covering the entire body: fg replaced everywhere;
633    /// no pre/post slice needed.
634    #[test]
635    fn s3c2_overlay_full_coverage() {
636        let body = flat_body("hi", 0xcdd6f4);
637        let overlay_fg = Color::Rgb(0xff, 0xa5, 0x00);
638        let out = apply_semantic_token_overlay(body, 0, 2, overlay_fg, Modifier::empty());
639        // Exactly the original cells' span(s) with new fg.
640        let combined: String = out.iter().map(|s| s.content.as_ref()).collect();
641        assert_eq!(combined, "hi");
642        for s in &out {
643            assert_eq!(s.style.fg, Some(overlay_fg));
644        }
645    }
646
647    /// Overlay outside the body's byte range (start past EOL):
648    /// no-op pass-through, spans preserved.
649    #[test]
650    fn s3c2_overlay_outside_range_is_noop() {
651        let body = flat_body("abc", 0xcdd6f4);
652        let pre_text: String = body.iter().map(|s| s.content.as_ref()).collect();
653        let pre_styles: Vec<_> = body.iter().map(|s| s.style).collect();
654        let out = apply_semantic_token_overlay(body, 10, 20, Color::Red, Modifier::ITALIC);
655        let post_text: String = out.iter().map(|s| s.content.as_ref()).collect();
656        let post_styles: Vec<_> = out.iter().map(|s| s.style).collect();
657        assert_eq!(post_text, pre_text);
658        assert_eq!(post_styles, pre_styles);
659    }
660
661    /// Overlay preserves the cell's existing modifiers (bold from
662    /// syntax style) and ORs in the overlay's modifier (italic
663    /// from semantic). Captures the merge contract.
664    #[test]
665    fn s3c2_overlay_preserves_existing_modifiers() {
666        // Body span carries BOLD from syntax style.
667        let fg = 0xcba6f7;
668        let body = vec![Span::styled(
669            "kw".to_string(),
670            Style::default()
671                .fg(rgb_u32_to_color(fg))
672                .add_modifier(Modifier::BOLD),
673        )];
674        // Overlay adds ITALIC.
675        let out = apply_semantic_token_overlay(body, 0, 2, Color::Cyan, Modifier::ITALIC);
676        // One span (full coverage, same style); both modifiers
677        // present.
678        for s in &out {
679            assert!(s.style.add_modifier.contains(Modifier::BOLD));
680            assert!(s.style.add_modifier.contains(Modifier::ITALIC));
681        }
682    }
683
684    /// Overlay replaces fg only — bg from an earlier pass stays
685    /// untouched. Construct a cell with non-zero bg to seed it.
686    #[test]
687    fn s3c2_overlay_replaces_fg_keeps_bg() {
688        // Seed a body span with a bg from an earlier pass.
689        let body = vec![Span::styled(
690            "x".to_string(),
691            Style::default()
692                .fg(rgb_u32_to_color(0xcdd6f4))
693                .bg(rgb_u32_to_color(0x1e1e2e)),
694        )];
695        assert_eq!(body[0].style.bg, Some(Color::Rgb(0x1e, 0x1e, 0x2e)));
696        let out = apply_semantic_token_overlay(body, 0, 1, Color::Magenta, Modifier::empty());
697        // fg replaced, bg preserved.
698        assert_eq!(out[0].style.fg, Some(Color::Magenta));
699        assert_eq!(out[0].style.bg, Some(Color::Rgb(0x1e, 0x1e, 0x2e)));
700    }
701
702    // ---- S3.c.3 — bg-layer overlays on cell-derived bodies ----
703    //
704    // `apply_match_overlay` is the bg-layer engine for visual,
705    // hlsearch, current_match, substitute, and doc-highlight
706    // overlays. Unlike the semantic-tokens pass, it *replaces*
707    // the entire `Style` for the overlap region (the caller
708    // chooses fg + bg + modifiers as one bundle).
709    //
710    // `apply_underline_overlay` is the diagnostics-underline
711    // engine. It ADDs `Modifier::UNDERLINED` to the overlap
712    // region's existing style; fg / bg from earlier passes stay
713    // intact. The `severity_color` parameter is intentionally
714    // unused at paint time — see the upstream doc comment for
715    // terminal-compatibility reasons.
716
717    use crate::render::{apply_match_overlay, apply_underline_overlay};
718
719    /// Helper: yellow bg + black fg + bold — the canonical hlsearch
720    /// style used in the codebase's `match_style()` helper.
721    fn match_style_yellow_bg() -> TuiStyle {
722        TuiStyle::default()
723            .bg(Color::Yellow)
724            .fg(Color::Black)
725            .add_modifier(Modifier::BOLD)
726    }
727
728    /// Mid-row match overlay on a single-span cell body: splits
729    /// into pre/mid/post with the overlap region carrying the
730    /// overlay style verbatim (fg + bg + modifiers all replaced).
731    #[test]
732    fn s3c3_match_overlay_splits_single_span_body() {
733        let body = flat_body("abcdefgh", 0xcdd6f4);
734        let overlay = match_style_yellow_bg();
735        let out = apply_match_overlay(body, 2, 6, overlay);
736        assert_eq!(out.len(), 3);
737        assert_eq!(out[0].content.as_ref(), "ab");
738        assert_eq!(out[1].content.as_ref(), "cdef");
739        assert_eq!(out[2].content.as_ref(), "gh");
740        // Middle span: overlay style exactly.
741        assert_eq!(out[1].style.fg, Some(Color::Black));
742        assert_eq!(out[1].style.bg, Some(Color::Yellow));
743        assert!(out[1].style.add_modifier.contains(Modifier::BOLD));
744        // Outer spans keep the cell-derived fg, bg=None.
745        assert_eq!(out[0].style.fg, Some(Color::Rgb(0xcd, 0xd6, 0xf4)));
746        assert_eq!(out[0].style.bg, None);
747        assert_eq!(out[2].style.fg, Some(Color::Rgb(0xcd, 0xd6, 0xf4)));
748    }
749
750    /// Match overlay covering the entire body: every cell-derived
751    /// span's style becomes the overlay style (no pre / post
752    /// slices needed).
753    #[test]
754    fn s3c3_match_overlay_full_coverage() {
755        let body = flat_body("hi", 0xcdd6f4);
756        let overlay = match_style_yellow_bg();
757        let out = apply_match_overlay(body, 0, 2, overlay);
758        assert_eq!(collect_text(&out), "hi");
759        for s in &out {
760            assert_eq!(s.style.fg, Some(Color::Black));
761            assert_eq!(s.style.bg, Some(Color::Yellow));
762        }
763    }
764
765    /// Match overlay outside the body's byte range: no mutation.
766    /// Captures the no-op contract for ranges past EOL.
767    #[test]
768    fn s3c3_match_overlay_outside_range_noop() {
769        let body = flat_body("abc", 0xcdd6f4);
770        let pre_text = collect_text(&body);
771        let pre_styles: Vec<_> = body.iter().map(|s| s.style).collect();
772        let out = apply_match_overlay(body, 10, 20, match_style_yellow_bg());
773        assert_eq!(collect_text(&out), pre_text);
774        let post_styles: Vec<_> = out.iter().map(|s| s.style).collect();
775        assert_eq!(post_styles, pre_styles);
776    }
777
778    /// Match overlay across a fg boundary in a multi-span body:
779    /// both halves of the overlap region adopt the overlay style.
780    /// Captures the cross-boundary walk semantics for bg-layer
781    /// overlays.
782    #[test]
783    fn s3c3_match_overlay_spans_multi_span_body() {
784        let fg_a = 0xff0000;
785        let fg_b = 0x00ff00;
786        let body = body(&[("aaa", fg_a), ("bbb", fg_b)]);
787        assert_eq!(body.len(), 2);
788        let overlay = match_style_yellow_bg();
789        let out = apply_match_overlay(body, 2, 5, overlay);
790        // Walk the spans and verify the overlap [2, 5) carries
791        // the overlay style on BOTH sides of the fg-boundary
792        // at byte 3.
793        let mut cursor = 0usize;
794        for s in &out {
795            let len = s.content.len();
796            let span_start = cursor;
797            let span_end = cursor + len;
798            if span_start >= 2 && span_end <= 5 {
799                assert_eq!(
800                    s.style.bg,
801                    Some(Color::Yellow),
802                    "overlap span '{}' must carry overlay bg",
803                    s.content.as_ref()
804                );
805            }
806            cursor = span_end;
807        }
808    }
809
810    /// Match overlay's style assignment REPLACES the cell's
811    /// existing modifiers (it does not OR in). A cell carrying
812    /// BOLD from syntax style + an overlay style without BOLD
813    /// results in the overlay's modifier set, not the merged
814    /// one. This is the documented difference vs. the semantic
815    /// tokens overlay's `add_modifier` semantics.
816    #[test]
817    fn s3c3_match_overlay_replaces_modifiers() {
818        // Body span with BOLD syntax modifier.
819        let body = vec![Span::styled(
820            "x".to_string(),
821            Style::default()
822                .fg(rgb_u32_to_color(0xcba6f7))
823                .add_modifier(Modifier::BOLD),
824        )];
825        // Overlay style has ITALIC, NOT BOLD.
826        let overlay = TuiStyle::default()
827            .bg(Color::Yellow)
828            .add_modifier(Modifier::ITALIC);
829        let out = apply_match_overlay(body, 0, 1, overlay);
830        // Replaced: ITALIC present, BOLD absent.
831        assert!(out[0].style.add_modifier.contains(Modifier::ITALIC));
832        assert!(
833            !out[0].style.add_modifier.contains(Modifier::BOLD),
834            "match overlay must REPLACE the style — BOLD from syntax must be dropped"
835        );
836    }
837
838    /// Underline overlay (diagnostics): adds UNDERLINED modifier
839    /// to the overlap region; fg / bg from earlier passes stay
840    /// intact. Captures the additive contract for the diagnostic
841    /// layer.
842    #[test]
843    fn s3c3_underline_overlay_adds_only_underline() {
844        // "err" with BOLD + bg from earlier passes — one span.
845        let body = vec![Span::styled(
846            "err".to_string(),
847            Style::default()
848                .fg(rgb_u32_to_color(0xcdd6f4))
849                .bg(rgb_u32_to_color(0x1e1e2e))
850                .add_modifier(Modifier::BOLD),
851        )];
852        assert_eq!(body.len(), 1);
853        let out = apply_underline_overlay(body, 0, 3, Color::Red /* unused */);
854        // UNDERLINED added; fg / bg / BOLD preserved.
855        for s in &out {
856            assert!(s.style.add_modifier.contains(Modifier::UNDERLINED));
857            assert!(s.style.add_modifier.contains(Modifier::BOLD));
858            assert_eq!(s.style.fg, Some(Color::Rgb(0xcd, 0xd6, 0xf4)));
859            assert_eq!(s.style.bg, Some(Color::Rgb(0x1e, 0x1e, 0x2e)));
860        }
861    }
862
863    /// Underline overlay covering only part of the row: pre /
864    /// mid (underlined) / post slices. The mid keeps the cell's
865    /// existing style and only adds UNDERLINED.
866    #[test]
867    fn s3c3_underline_overlay_partial_coverage_keeps_outer_style() {
868        let body = flat_body("abcdef", 0xcdd6f4);
869        let out = apply_underline_overlay(body, 2, 4, Color::Red);
870        assert_eq!(out.len(), 3);
871        assert_eq!(out[0].content.as_ref(), "ab");
872        assert!(!out[0].style.add_modifier.contains(Modifier::UNDERLINED));
873        assert_eq!(out[1].content.as_ref(), "cd");
874        assert!(out[1].style.add_modifier.contains(Modifier::UNDERLINED));
875        // Mid keeps the cell's fg too — only the modifier is
876        // additive.
877        assert_eq!(out[1].style.fg, Some(Color::Rgb(0xcd, 0xd6, 0xf4)));
878        assert_eq!(out[2].content.as_ref(), "ef");
879        assert!(!out[2].style.add_modifier.contains(Modifier::UNDERLINED));
880    }
881
882    /// Multiple bg-layer overlays compose by sequential
883    /// application: doc-highlight (yellow bg) followed by visual
884    /// selection (cyan bg) leaves the cyan bg on the overlap —
885    /// the second pass's `apply_match_overlay` REPLACES the
886    /// first's. Captures the documented sequencing.
887    #[test]
888    fn s3c3_match_overlay_composes_by_sequence() {
889        let body = flat_body("abcdef", 0xcdd6f4);
890        let yellow = TuiStyle::default().bg(Color::Yellow).fg(Color::Black);
891        let cyan = TuiStyle::default().bg(Color::Cyan).fg(Color::Black);
892        // Doc-highlight: bytes [1, 5).
893        let after_dh = apply_match_overlay(body, 1, 5, yellow);
894        // Visual selection: bytes [2, 4) — replaces the inner
895        // portion of the doc-highlight bg.
896        let out = apply_match_overlay(after_dh, 2, 4, cyan);
897        // Walk and verify: byte 0 unchanged; byte 1 = yellow;
898        // bytes 2..4 = cyan; byte 4 = yellow; byte 5 = unchanged.
899        let mut cursor = 0usize;
900        for s in &out {
901            let len = s.content.len();
902            let mid = cursor + len / 2;
903            match mid {
904                0 => assert_eq!(s.style.bg, None),
905                1 => assert_eq!(s.style.bg, Some(Color::Yellow)),
906                2 | 3 => assert_eq!(s.style.bg, Some(Color::Cyan)),
907                4 => assert_eq!(s.style.bg, Some(Color::Yellow)),
908                5 => assert_eq!(s.style.bg, None),
909                _ => {}
910            }
911            cursor += len;
912        }
913    }
914
915    // ---- S3.c.4 — fold suffix + post-overlay inlay splice ----
916    //
917    // Two tail-of-pipeline passes wrap up the per-line render:
918    //
919    // 1. The post-overlay inlay splice (`splice_virtual_text_into_spans`)
920    //    inserts the LSP `inlayHint` virtual text into the body at
921    //    a source-byte offset. Cell-derived source spans cover
922    //    source bytes 1:1 with `line_text`, exactly matching the
923    //    RowPrepaint shape this splice was designed against.
924    // 2. The closed-fold `' ┄ N lines folded'` suffix is a plain
925    //    `Span::push` after every overlay — no byte-position math.
926    //    It composes trivially with any body shape.
927    //
928    // For cell-derived bodies these two passes are unchanged
929    // contractually; the tests here pin that against regression.
930
931    use crate::render::splice_virtual_text_into_spans;
932
933    fn dim_gray_style() -> TuiStyle {
934        TuiStyle::default()
935            .fg(Color::DarkGray)
936            .add_modifier(Modifier::ITALIC)
937    }
938
939    /// Inlay splice at byte 0 prepends the virtual text before
940    /// every cell-derived span. The body's first span stays
941    /// intact; the virtual span emits before it.
942    #[test]
943    fn s3c4_inlay_splice_at_byte_zero_prepends() {
944        let body = flat_body("hi", 0xcdd6f4);
945        let out = splice_virtual_text_into_spans(body, 0, ": ".to_string(), dim_gray_style());
946        assert_eq!(collect_text(&out), ": hi");
947        // First span is the virtual splice.
948        assert_eq!(out[0].content.as_ref(), ": ");
949        assert_eq!(out[0].style.fg, Some(Color::DarkGray));
950        // Source body follows.
951        assert_eq!(out[1].content.as_ref(), "hi");
952        assert_eq!(out[1].style.fg, Some(Color::Rgb(0xcd, 0xd6, 0xf4)));
953    }
954
955    /// Inlay splice mid-row splits the single cell-derived span
956    /// on the byte boundary. Captures the contract that the
957    /// splice walks cell-derived spans byte-by-byte.
958    #[test]
959    fn s3c4_inlay_splice_mid_span_splits_the_span() {
960        let body = flat_body("abcdef", 0xcdd6f4);
961        // One source span covers bytes [0, 6); splice at byte 3.
962        let out = splice_virtual_text_into_spans(body, 3, "[i]".to_string(), dim_gray_style());
963        // pre / inlay / post.
964        assert_eq!(out.len(), 3);
965        assert_eq!(out[0].content.as_ref(), "abc");
966        assert_eq!(out[1].content.as_ref(), "[i]");
967        assert_eq!(out[1].style.fg, Some(Color::DarkGray));
968        assert!(out[1].style.add_modifier.contains(Modifier::ITALIC));
969        assert_eq!(out[2].content.as_ref(), "def");
970        // Both source halves keep the cell's fg.
971        assert_eq!(out[0].style.fg, Some(Color::Rgb(0xcd, 0xd6, 0xf4)));
972        assert_eq!(out[2].style.fg, Some(Color::Rgb(0xcd, 0xd6, 0xf4)));
973    }
974
975    /// Inlay splice at a span boundary inserts cleanly between
976    /// two cell-derived spans without splitting either. Pin the
977    /// no-split contract — important so byte-position tracking
978    /// stays simple for downstream code that walks display
979    /// columns.
980    #[test]
981    fn s3c4_inlay_splice_at_span_boundary_does_not_split() {
982        let fg_a = 0xff0000;
983        let fg_b = 0x00ff00;
984        let body = body(&[("ab", fg_a), ("cd", fg_b)]);
985        assert_eq!(body.len(), 2);
986        // Splice at byte 2 — exactly the boundary between the
987        // two cell-derived spans.
988        let out = splice_virtual_text_into_spans(body, 2, "/*X*/".to_string(), dim_gray_style());
989        // Three spans: first body span "ab" / inlay "/*X*/" /
990        // second body span "cd". Neither body span split.
991        assert_eq!(out.len(), 3);
992        assert_eq!(out[0].content.as_ref(), "ab");
993        assert_eq!(out[0].style.fg, Some(Color::Rgb(0xff, 0x00, 0x00)));
994        assert_eq!(out[1].content.as_ref(), "/*X*/");
995        assert_eq!(out[2].content.as_ref(), "cd");
996        assert_eq!(out[2].style.fg, Some(Color::Rgb(0x00, 0xff, 0x00)));
997    }
998
999    /// Inlay splice past the end of the body (typical LSP `inlayHint`
1000    /// trailing annotation at EOL) appends the virtual text as the
1001    /// final span.
1002    #[test]
1003    fn s3c4_inlay_splice_past_end_appends() {
1004        let body = flat_body("hi", 0xcdd6f4);
1005        let out =
1006            splice_virtual_text_into_spans(body, 999, " → unit".to_string(), dim_gray_style());
1007        // Body spans first, virtual span last.
1008        assert_eq!(collect_text(&out), "hi → unit");
1009        let last = out.last().unwrap();
1010        assert_eq!(last.content.as_ref(), " → unit");
1011        assert_eq!(last.style.fg, Some(Color::DarkGray));
1012    }
1013
1014    /// Multiple inlay splices applied in reverse byte order (the
1015    /// production loop's pattern — `on_line.sort_by(|a, b|
1016    /// b.byte.cmp(&a.byte))` then splice) so earlier splices
1017    /// don't shift later ones. Validates the cell-derived body
1018    /// composes correctly with the same loop shape.
1019    #[test]
1020    fn s3c4_multiple_inlays_in_reverse_byte_order() {
1021        let body = flat_body("xy", 0xcdd6f4);
1022        // Two splices: at byte 1 and at byte 2. Apply in reverse
1023        // (byte 2 first, then byte 1) so the byte-1 splice's
1024        // offset stays valid.
1025        let after_second =
1026            splice_virtual_text_into_spans(body, 2, "/B/".to_string(), dim_gray_style());
1027        let after_first =
1028            splice_virtual_text_into_spans(after_second, 1, "/A/".to_string(), dim_gray_style());
1029        // Result: `x` `/A/` `y` `/B/` — both inlays at their
1030        // intended positions.
1031        assert_eq!(collect_text(&after_first), "x/A/y/B/");
1032    }
1033
1034    /// Fold suffix is a plain trailing-span push — composes with
1035    /// any body shape. Captures that cell-derived bodies don't
1036    /// need special handling.
1037    #[test]
1038    fn s3c4_fold_suffix_appends_after_cell_body() {
1039        let body = flat_body("fn main() {}", 0xcdd6f4);
1040        let pre_count = body.len();
1041        let mut out = body;
1042        // Mirror the closed-fold suffix push from
1043        // `compose_visible_lines_inner` line ~3590.
1044        out.push(Span::styled(
1045            " ┄ 3 lines folded".to_string(),
1046            TuiStyle::default().fg(Color::DarkGray),
1047        ));
1048        // Body untouched; one extra span at the tail.
1049        assert_eq!(out.len(), pre_count + 1);
1050        let last = out.last().unwrap();
1051        assert_eq!(last.content.as_ref(), " ┄ 3 lines folded");
1052        assert_eq!(last.style.fg, Some(Color::DarkGray));
1053    }
1054
1055    /// Inlay splice followed by fold suffix: the splice lands at
1056    /// its byte offset; the suffix appends at the very end after
1057    /// any inlay. Captures the documented ordering — overlays
1058    /// run first, then the inlay splice, then the fold suffix.
1059    #[test]
1060    fn s3c4_inlay_splice_then_fold_suffix_order() {
1061        let body = flat_body("ab", 0xcdd6f4);
1062        // Inlay at byte 2 (end-of-line).
1063        let mut after_inlay =
1064            splice_virtual_text_into_spans(body, 2, ": T".to_string(), dim_gray_style());
1065        // Fold suffix.
1066        after_inlay.push(Span::styled(
1067            " ┄ 5 lines folded".to_string(),
1068            TuiStyle::default().fg(Color::DarkGray),
1069        ));
1070        assert_eq!(collect_text(&after_inlay), "ab: T ┄ 5 lines folded");
1071        // Suffix is the LAST span; inlay is before it.
1072        let last = after_inlay.last().unwrap();
1073        assert!(last.content.as_ref().starts_with(" ┄"));
1074    }
1075
1076    /// Overlay spanning two different-fg cell-derived spans:
1077    /// each gets its overlapping portion fg-replaced. Captures
1078    /// the cross-boundary walk.
1079    #[test]
1080    fn s3c2_overlay_spans_multi_span_body() {
1081        let fg_a = 0xff0000;
1082        let fg_b = 0x00ff00;
1083        // 6 bytes: `aaabbb` — `aaa` (fg_a) + `bbb` (fg_b) → two spans.
1084        let body = body(&[("aaa", fg_a), ("bbb", fg_b)]);
1085        assert_eq!(body.len(), 2);
1086        // Overlay covers bytes [2, 5) — crossing the boundary at
1087        // byte 3.
1088        let overlay_fg = Color::Yellow;
1089        let out = apply_semantic_token_overlay(body, 2, 5, overlay_fg, Modifier::empty());
1090        // Expect:
1091        //  - "aa" (fg_a, unchanged)
1092        //  - "a"  (overlay fg)
1093        //  - "bb" (overlay fg)
1094        //  - "b"  (fg_b, unchanged)
1095        let combined: String = out.iter().map(|s| s.content.as_ref()).collect();
1096        assert_eq!(combined, "aaabbb");
1097        // Walk and check the overlay-fg covers byte positions
1098        // 2..5.
1099        let mut cursor = 0usize;
1100        for s in &out {
1101            let len = s.content.len();
1102            let overlap_start = cursor.max(2);
1103            let overlap_end = (cursor + len).min(5);
1104            if overlap_start < overlap_end {
1105                // This span overlaps the overlay range; if fully
1106                // inside, fg must be overlay_fg.
1107                if cursor >= 2 && cursor + len <= 5 {
1108                    assert_eq!(
1109                        s.style.fg,
1110                        Some(overlay_fg),
1111                        "span '{}' at byte {cursor} must carry overlay fg",
1112                        s.content.as_ref()
1113                    );
1114                }
1115            }
1116            cursor += len;
1117        }
1118    }
1119}