Skip to main content

render_ansi/
lib.rs

1use std::fmt::Write;
2
3use highlight_spans::{Grammar, HighlightError, HighlightResult, SpanHighlighter};
4use theme_engine::{Style, Theme};
5use thiserror::Error;
6use unicode_segmentation::UnicodeSegmentation;
7use unicode_width::UnicodeWidthStr;
8
9const CSI: &str = "\x1b[";
10const SGR_RESET: &str = "\x1b[0m";
11const EL_TO_END: &str = "\x1b[K";
12const TAB_STOP: usize = 8;
13const ANSI_256_LEVELS: [u8; 6] = [0, 95, 135, 175, 215, 255];
14const COLOR_MODE_NAMES: [&str; 3] = ["truecolor", "ansi256", "ansi16"];
15
16#[derive(Debug, Clone, Copy, Eq, PartialEq, Default)]
17pub enum ColorMode {
18    #[default]
19    TrueColor,
20    Ansi256,
21    Ansi16,
22}
23
24impl ColorMode {
25    /// Parses a color mode from user input.
26    ///
27    /// Accepts `"truecolor"`, `"24bit"`, `"24-bit"`, `"ansi256"`, `"256"`,
28    /// `"ansi16"`, and `"16"`.
29    #[must_use]
30    pub fn from_name(input: &str) -> Option<Self> {
31        match input.trim().to_ascii_lowercase().as_str() {
32            "truecolor" | "24bit" | "24-bit" | "rgb" => Some(Self::TrueColor),
33            "ansi256" | "256" | "xterm256" | "xterm-256" => Some(Self::Ansi256),
34            "ansi16" | "16" | "xterm16" | "xterm-16" | "basic" => Some(Self::Ansi16),
35            _ => None,
36        }
37    }
38
39    /// Returns the canonical CLI/config name for this mode.
40    #[must_use]
41    pub const fn name(self) -> &'static str {
42        match self {
43            Self::TrueColor => "truecolor",
44            Self::Ansi256 => "ansi256",
45            Self::Ansi16 => "ansi16",
46        }
47    }
48
49    /// Returns all canonical color mode names.
50    #[must_use]
51    pub const fn supported_names() -> &'static [&'static str] {
52        &COLOR_MODE_NAMES
53    }
54}
55
56#[derive(Debug, Clone, Copy, Eq, PartialEq)]
57pub struct StyledSpan {
58    pub start_byte: usize,
59    pub end_byte: usize,
60    pub style: Option<Style>,
61}
62
63#[derive(Debug, Clone, Eq, PartialEq)]
64struct StyledCell {
65    text: String,
66    style: Option<Style>,
67    width: usize,
68}
69
70#[derive(Debug, Clone)]
71pub struct IncrementalRenderer {
72    width: usize,
73    height: usize,
74    origin_row: usize,
75    origin_col: usize,
76    color_mode: ColorMode,
77    prev_lines: Vec<Vec<StyledCell>>,
78}
79
80impl IncrementalRenderer {
81    /// Creates an incremental renderer with a bounded viewport size.
82    ///
83    /// A minimum viewport size of `1x1` is enforced.
84    /// The render origin defaults to terminal row `1`, column `1`.
85    #[must_use]
86    pub fn new(width: usize, height: usize) -> Self {
87        Self {
88            width: width.max(1),
89            height: height.max(1),
90            origin_row: 1,
91            origin_col: 1,
92            color_mode: ColorMode::TrueColor,
93            prev_lines: Vec::new(),
94        }
95    }
96
97    /// Resizes the viewport and clips cached state to the new bounds.
98    pub fn resize(&mut self, width: usize, height: usize) {
99        self.width = width.max(1);
100        self.height = height.max(1);
101        self.prev_lines = clip_lines_to_viewport(&self.prev_lines, self.width, self.height);
102    }
103
104    /// Clears all cached frame state for this renderer.
105    pub fn clear_state(&mut self) {
106        self.prev_lines.clear();
107    }
108
109    /// Sets the terminal origin used for generated CUP cursor positions.
110    ///
111    /// The origin is 1-based terminal coordinates (`row`, `col`) in display cells.
112    /// Values lower than `1` are clamped to `1`.
113    pub fn set_origin(&mut self, row: usize, col: usize) {
114        self.origin_row = row.max(1);
115        self.origin_col = col.max(1);
116    }
117
118    /// Returns the current 1-based terminal origin (`row`, `col`).
119    #[must_use]
120    pub fn origin(&self) -> (usize, usize) {
121        (self.origin_row, self.origin_col)
122    }
123
124    /// Sets the ANSI color mode used by this renderer.
125    pub fn set_color_mode(&mut self, color_mode: ColorMode) {
126        self.color_mode = color_mode;
127    }
128
129    /// Returns the current ANSI color mode.
130    #[must_use]
131    pub fn color_mode(&self) -> ColorMode {
132        self.color_mode
133    }
134
135    /// Renders only the VT patch from the cached frame to `source`.
136    ///
137    /// The method validates input spans, projects them to styled terminal cells,
138    /// diffs against previous state, and returns only changed cursor/style output.
139    ///
140    /// # Errors
141    ///
142    /// Returns an error when spans are out of bounds, unsorted, or overlapping.
143    pub fn render_patch(
144        &mut self,
145        source: &[u8],
146        spans: &[StyledSpan],
147    ) -> Result<String, RenderError> {
148        validate_spans(source.len(), spans)?;
149        let curr_lines = build_styled_cells(source, spans, self.width, self.height);
150        let patch = diff_lines_to_patch(
151            &self.prev_lines,
152            &curr_lines,
153            self.origin_row,
154            self.origin_col,
155            self.color_mode,
156        );
157        self.prev_lines = curr_lines;
158        Ok(patch)
159    }
160
161    /// Runs highlight + theme resolution + incremental diff in one call.
162    ///
163    /// # Errors
164    ///
165    /// Returns an error if highlighting fails or spans fail validation.
166    pub fn highlight_to_patch(
167        &mut self,
168        highlighter: &mut SpanHighlighter,
169        source: &[u8],
170        flavor: Grammar,
171        theme: &Theme,
172    ) -> Result<String, RenderError> {
173        let highlight = highlighter.highlight(source, flavor)?;
174        let styled = resolve_styled_spans_for_source(source.len(), &highlight, theme)?;
175        self.render_patch(source, &styled)
176    }
177}
178
179/// Incremental renderer for a single mutable line without terminal width assumptions.
180///
181/// This renderer avoids absolute cursor positioning. It assumes each emitted
182/// patch is written to the same terminal line and the cursor remains at the end
183/// of the previously rendered line.
184#[derive(Debug, Clone, Default)]
185pub struct StreamLineRenderer {
186    color_mode: ColorMode,
187    prev_line: Vec<StyledCell>,
188}
189
190impl StreamLineRenderer {
191    /// Creates a line renderer with truecolor output.
192    #[must_use]
193    pub fn new() -> Self {
194        Self::default()
195    }
196
197    /// Clears prior line state.
198    pub fn clear_state(&mut self) {
199        self.prev_line.clear();
200    }
201
202    /// Sets the ANSI color mode used by this renderer.
203    pub fn set_color_mode(&mut self, color_mode: ColorMode) {
204        self.color_mode = color_mode;
205    }
206
207    /// Returns the current ANSI color mode.
208    #[must_use]
209    pub fn color_mode(&self) -> ColorMode {
210        self.color_mode
211    }
212
213    /// Renders a width-independent patch for a single line.
214    ///
215    /// # Errors
216    ///
217    /// Returns an error when spans are invalid or input contains a newline.
218    pub fn render_line_patch(
219        &mut self,
220        source: &[u8],
221        spans: &[StyledSpan],
222    ) -> Result<String, RenderError> {
223        validate_spans(source.len(), spans)?;
224        if source.contains(&b'\n') {
225            return Err(RenderError::MultiLineInput);
226        }
227
228        let curr_line = build_styled_line_cells(source, spans);
229        let patch = diff_single_line_to_patch(&self.prev_line, &curr_line, self.color_mode);
230        self.prev_line = curr_line;
231        Ok(patch)
232    }
233
234    /// Runs highlight + theme resolution + stream-safe single-line diff.
235    ///
236    /// # Errors
237    ///
238    /// Returns an error if highlighting fails, spans are invalid, or input has newlines.
239    pub fn highlight_line_to_patch(
240        &mut self,
241        highlighter: &mut SpanHighlighter,
242        source: &[u8],
243        flavor: Grammar,
244        theme: &Theme,
245    ) -> Result<String, RenderError> {
246        let highlight = highlighter.highlight(source, flavor)?;
247        let styled = resolve_styled_spans_for_source(source.len(), &highlight, theme)?;
248        self.render_line_patch(source, &styled)
249    }
250}
251
252#[derive(Debug, Error)]
253pub enum RenderError {
254    #[error("highlighting failed: {0}")]
255    Highlight(#[from] HighlightError),
256    #[error("invalid span range {start_byte}..{end_byte} for source length {source_len}")]
257    SpanOutOfBounds {
258        start_byte: usize,
259        end_byte: usize,
260        source_len: usize,
261    },
262    #[error(
263        "spans must be sorted and non-overlapping: prev_end={prev_end}, next_start={next_start}"
264    )]
265    OverlappingSpans { prev_end: usize, next_start: usize },
266    #[error("invalid attr_id {attr_id}; attrs length is {attrs_len}")]
267    InvalidAttrId { attr_id: usize, attrs_len: usize },
268    #[error("stream line patch requires single-line input without newlines")]
269    MultiLineInput,
270}
271
272/// Resolves highlight spans into renderable spans by attaching theme styles.
273///
274/// Resolved capture styles are layered over the theme `normal` style, so token
275/// styles inherit unspecified fields (for example background color).
276///
277/// # Errors
278///
279/// Returns [`RenderError::InvalidAttrId`] when a span references a missing attribute.
280pub fn resolve_styled_spans(
281    highlight: &HighlightResult,
282    theme: &Theme,
283) -> Result<Vec<StyledSpan>, RenderError> {
284    let normal_style = theme.get_exact("normal").copied();
285    let mut out = Vec::with_capacity(highlight.spans.len());
286    for span in &highlight.spans {
287        let Some(attr) = highlight.attrs.get(span.attr_id) else {
288            return Err(RenderError::InvalidAttrId {
289                attr_id: span.attr_id,
290                attrs_len: highlight.attrs.len(),
291            });
292        };
293        let capture_style = theme.resolve(&attr.capture_name).copied();
294        out.push(StyledSpan {
295            start_byte: span.start_byte,
296            end_byte: span.end_byte,
297            style: merge_styles(normal_style, capture_style),
298        });
299    }
300    Ok(out)
301}
302
303/// Resolves styled spans and fills uncovered byte ranges with `normal` style.
304fn resolve_styled_spans_for_source(
305    source_len: usize,
306    highlight: &HighlightResult,
307    theme: &Theme,
308) -> Result<Vec<StyledSpan>, RenderError> {
309    let spans = resolve_styled_spans(highlight, theme)?;
310    Ok(fill_uncovered_ranges_with_style(
311        source_len,
312        spans,
313        theme.get_exact("normal").copied(),
314    ))
315}
316
317/// Merges an overlay style over a base style.
318///
319/// Color fields in `overlay` replace the base when present. Boolean attributes
320/// are merged with logical OR.
321fn merge_styles(base: Option<Style>, overlay: Option<Style>) -> Option<Style> {
322    match (base, overlay) {
323        (None, None) => None,
324        (Some(base), None) => Some(base),
325        (None, Some(overlay)) => Some(overlay),
326        (Some(base), Some(overlay)) => Some(Style {
327            fg: overlay.fg.or(base.fg),
328            bg: overlay.bg.or(base.bg),
329            bold: base.bold || overlay.bold,
330            italic: base.italic || overlay.italic,
331            underline: base.underline || overlay.underline,
332        }),
333    }
334}
335
336/// Inserts default-style spans for byte ranges not covered by highlight spans.
337fn fill_uncovered_ranges_with_style(
338    source_len: usize,
339    spans: Vec<StyledSpan>,
340    default_style: Option<Style>,
341) -> Vec<StyledSpan> {
342    let Some(default_style) = default_style else {
343        return spans;
344    };
345
346    let mut out = Vec::with_capacity(spans.len().saturating_mul(2).saturating_add(1));
347    let mut cursor = 0usize;
348    for span in spans {
349        if cursor < span.start_byte {
350            out.push(StyledSpan {
351                start_byte: cursor,
352                end_byte: span.start_byte,
353                style: Some(default_style),
354            });
355        }
356
357        if span.start_byte < span.end_byte {
358            out.push(span);
359        }
360        cursor = cursor.max(span.end_byte);
361    }
362
363    if cursor < source_len {
364        out.push(StyledSpan {
365            start_byte: cursor,
366            end_byte: source_len,
367            style: Some(default_style),
368        });
369    }
370
371    out
372}
373
374/// Renders a source buffer and styled spans into a single ANSI string.
375///
376/// # Errors
377///
378/// Returns an error when spans are out of bounds, unsorted, or overlapping.
379pub fn render_ansi(source: &[u8], spans: &[StyledSpan]) -> Result<String, RenderError> {
380    render_ansi_with_mode(source, spans, ColorMode::TrueColor)
381}
382
383/// Renders a source buffer and styled spans into a single ANSI string.
384///
385/// # Errors
386///
387/// Returns an error when spans are out of bounds, unsorted, or overlapping.
388pub fn render_ansi_with_mode(
389    source: &[u8],
390    spans: &[StyledSpan],
391    color_mode: ColorMode,
392) -> Result<String, RenderError> {
393    validate_spans(source.len(), spans)?;
394
395    let mut out = String::new();
396    let mut cursor = 0usize;
397    for span in spans {
398        if cursor < span.start_byte {
399            out.push_str(&String::from_utf8_lossy(&source[cursor..span.start_byte]));
400        }
401        append_styled_segment(
402            &mut out,
403            &source[span.start_byte..span.end_byte],
404            span.style,
405            color_mode,
406        );
407        cursor = span.end_byte;
408    }
409
410    if cursor < source.len() {
411        out.push_str(&String::from_utf8_lossy(&source[cursor..]));
412    }
413
414    Ok(out)
415}
416
417/// Renders a source buffer and styled spans into per-line ANSI strings.
418///
419/// Spans that cross line boundaries are clipped per line.
420///
421/// # Errors
422///
423/// Returns an error when spans are out of bounds, unsorted, or overlapping.
424pub fn render_ansi_lines(source: &[u8], spans: &[StyledSpan]) -> Result<Vec<String>, RenderError> {
425    render_ansi_lines_with_mode(source, spans, ColorMode::TrueColor)
426}
427
428/// Renders a source buffer and styled spans into per-line ANSI strings.
429///
430/// Spans that cross line boundaries are clipped per line.
431///
432/// # Errors
433///
434/// Returns an error when spans are out of bounds, unsorted, or overlapping.
435pub fn render_ansi_lines_with_mode(
436    source: &[u8],
437    spans: &[StyledSpan],
438    color_mode: ColorMode,
439) -> Result<Vec<String>, RenderError> {
440    validate_spans(source.len(), spans)?;
441
442    let line_ranges = compute_line_ranges(source);
443    let mut lines = Vec::with_capacity(line_ranges.len());
444    let mut span_cursor = 0usize;
445
446    for (line_start, line_end) in line_ranges {
447        while span_cursor < spans.len() && spans[span_cursor].end_byte <= line_start {
448            span_cursor += 1;
449        }
450
451        let mut line = String::new();
452        let mut cursor = line_start;
453        let mut i = span_cursor;
454        while i < spans.len() {
455            let span = spans[i];
456            if span.start_byte >= line_end {
457                break;
458            }
459
460            let seg_start = span.start_byte.max(line_start);
461            let seg_end = span.end_byte.min(line_end);
462            if cursor < seg_start {
463                line.push_str(&String::from_utf8_lossy(&source[cursor..seg_start]));
464            }
465            append_styled_segment(
466                &mut line,
467                &source[seg_start..seg_end],
468                span.style,
469                color_mode,
470            );
471            cursor = seg_end;
472            i += 1;
473        }
474
475        if cursor < line_end {
476            line.push_str(&String::from_utf8_lossy(&source[cursor..line_end]));
477        }
478
479        lines.push(line);
480    }
481
482    Ok(lines)
483}
484
485/// Highlights and renders a source buffer to ANSI output.
486///
487/// This convenience API creates a temporary [`SpanHighlighter`].
488///
489/// # Errors
490///
491/// Returns an error if highlighting fails or if rendered spans are invalid.
492pub fn highlight_to_ansi(
493    source: &[u8],
494    flavor: Grammar,
495    theme: &Theme,
496) -> Result<String, RenderError> {
497    let mut highlighter = SpanHighlighter::new()?;
498    highlight_to_ansi_with_highlighter_and_mode(
499        &mut highlighter,
500        source,
501        flavor,
502        theme,
503        ColorMode::TrueColor,
504    )
505}
506
507/// Highlights and renders a source buffer using a caller-provided highlighter.
508///
509/// # Errors
510///
511/// Returns an error if highlighting fails or if rendered spans are invalid.
512pub fn highlight_to_ansi_with_highlighter(
513    highlighter: &mut SpanHighlighter,
514    source: &[u8],
515    flavor: Grammar,
516    theme: &Theme,
517) -> Result<String, RenderError> {
518    highlight_to_ansi_with_highlighter_and_mode(
519        highlighter,
520        source,
521        flavor,
522        theme,
523        ColorMode::TrueColor,
524    )
525}
526
527/// Highlights and renders a source buffer with an explicit ANSI color mode.
528///
529/// This convenience API creates a temporary [`SpanHighlighter`].
530///
531/// # Errors
532///
533/// Returns an error if highlighting fails or if rendered spans are invalid.
534pub fn highlight_to_ansi_with_mode(
535    source: &[u8],
536    flavor: Grammar,
537    theme: &Theme,
538    color_mode: ColorMode,
539) -> Result<String, RenderError> {
540    let mut highlighter = SpanHighlighter::new()?;
541    highlight_to_ansi_with_highlighter_and_mode(&mut highlighter, source, flavor, theme, color_mode)
542}
543
544/// Highlights and renders a source buffer using a caller-provided highlighter and color mode.
545///
546/// # Errors
547///
548/// Returns an error if highlighting fails or if rendered spans are invalid.
549pub fn highlight_to_ansi_with_highlighter_and_mode(
550    highlighter: &mut SpanHighlighter,
551    source: &[u8],
552    flavor: Grammar,
553    theme: &Theme,
554    color_mode: ColorMode,
555) -> Result<String, RenderError> {
556    let highlight = highlighter.highlight(source, flavor)?;
557    let styled = resolve_styled_spans_for_source(source.len(), &highlight, theme)?;
558    render_ansi_with_mode(source, &styled, color_mode)
559}
560
561/// Highlights line-oriented input and returns ANSI output per line.
562///
563/// This convenience API creates a temporary [`SpanHighlighter`].
564///
565/// # Errors
566///
567/// Returns an error if highlighting fails or if rendered spans are invalid.
568pub fn highlight_lines_to_ansi_lines<S: AsRef<str>>(
569    lines: &[S],
570    flavor: Grammar,
571    theme: &Theme,
572) -> Result<Vec<String>, RenderError> {
573    let mut highlighter = SpanHighlighter::new()?;
574    highlight_lines_to_ansi_lines_with_highlighter_and_mode(
575        &mut highlighter,
576        lines,
577        flavor,
578        theme,
579        ColorMode::TrueColor,
580    )
581}
582
583/// Highlights line-oriented input with a caller-provided highlighter.
584///
585/// # Errors
586///
587/// Returns an error if highlighting fails or if rendered spans are invalid.
588pub fn highlight_lines_to_ansi_lines_with_highlighter<S: AsRef<str>>(
589    highlighter: &mut SpanHighlighter,
590    lines: &[S],
591    flavor: Grammar,
592    theme: &Theme,
593) -> Result<Vec<String>, RenderError> {
594    highlight_lines_to_ansi_lines_with_highlighter_and_mode(
595        highlighter,
596        lines,
597        flavor,
598        theme,
599        ColorMode::TrueColor,
600    )
601}
602
603/// Highlights line-oriented input and returns ANSI output per line using a color mode.
604///
605/// This convenience API creates a temporary [`SpanHighlighter`].
606///
607/// # Errors
608///
609/// Returns an error if highlighting fails or if rendered spans are invalid.
610pub fn highlight_lines_to_ansi_lines_with_mode<S: AsRef<str>>(
611    lines: &[S],
612    flavor: Grammar,
613    theme: &Theme,
614    color_mode: ColorMode,
615) -> Result<Vec<String>, RenderError> {
616    let mut highlighter = SpanHighlighter::new()?;
617    highlight_lines_to_ansi_lines_with_highlighter_and_mode(
618        &mut highlighter,
619        lines,
620        flavor,
621        theme,
622        color_mode,
623    )
624}
625
626/// Highlights line-oriented input with a caller-provided highlighter and color mode.
627///
628/// # Errors
629///
630/// Returns an error if highlighting fails or if rendered spans are invalid.
631pub fn highlight_lines_to_ansi_lines_with_highlighter_and_mode<S: AsRef<str>>(
632    highlighter: &mut SpanHighlighter,
633    lines: &[S],
634    flavor: Grammar,
635    theme: &Theme,
636    color_mode: ColorMode,
637) -> Result<Vec<String>, RenderError> {
638    let highlight = highlighter.highlight_lines(lines, flavor)?;
639    let source = lines
640        .iter()
641        .map(AsRef::as_ref)
642        .collect::<Vec<_>>()
643        .join("\n");
644    let styled = resolve_styled_spans_for_source(source.len(), &highlight, theme)?;
645    render_ansi_lines_with_mode(source.as_bytes(), &styled, color_mode)
646}
647
648/// Clips cached styled lines to the current viewport bounds.
649fn clip_lines_to_viewport(
650    lines: &[Vec<StyledCell>],
651    width: usize,
652    height: usize,
653) -> Vec<Vec<StyledCell>> {
654    lines
655        .iter()
656        .take(height)
657        .map(|line| line.iter().take(width).cloned().collect::<Vec<_>>())
658        .collect::<Vec<_>>()
659}
660
661/// Projects source bytes and spans into styled terminal cells for diffing.
662///
663/// Cells are grapheme-based and each cell tracks its terminal display width.
664fn build_styled_cells(
665    source: &[u8],
666    spans: &[StyledSpan],
667    width: usize,
668    height: usize,
669) -> Vec<Vec<StyledCell>> {
670    let mut lines = Vec::new();
671    let mut line = Vec::new();
672    let mut line_display_width = 0usize;
673    let mut span_cursor = 0usize;
674
675    let rendered = String::from_utf8_lossy(source);
676    for (byte_idx, grapheme) in rendered.grapheme_indices(true) {
677        while span_cursor < spans.len() && spans[span_cursor].end_byte <= byte_idx {
678            span_cursor += 1;
679        }
680
681        let style = if let Some(span) = spans.get(span_cursor) {
682            if byte_idx >= span.start_byte && byte_idx < span.end_byte {
683                span.style
684            } else {
685                None
686            }
687        } else {
688            None
689        };
690
691        if grapheme == "\n" {
692            lines.push(line);
693            if lines.len() >= height {
694                return lines;
695            }
696            line = Vec::new();
697            line_display_width = 0;
698            continue;
699        }
700
701        let cell_width = display_width_for_grapheme(grapheme, line_display_width);
702        if line_display_width + cell_width <= width || cell_width == 0 {
703            line.push(StyledCell {
704                text: grapheme.to_string(),
705                style,
706                width: cell_width,
707            });
708            line_display_width += cell_width;
709        }
710    }
711
712    lines.push(line);
713    lines.truncate(height);
714    lines
715}
716
717/// Projects a single-line source and spans into styled cells for line diffing.
718fn build_styled_line_cells(source: &[u8], spans: &[StyledSpan]) -> Vec<StyledCell> {
719    let mut line = Vec::new();
720    let mut line_display_width = 0usize;
721    let mut span_cursor = 0usize;
722
723    let rendered = String::from_utf8_lossy(source);
724    for (byte_idx, grapheme) in rendered.grapheme_indices(true) {
725        while span_cursor < spans.len() && spans[span_cursor].end_byte <= byte_idx {
726            span_cursor += 1;
727        }
728
729        let style = if let Some(span) = spans.get(span_cursor) {
730            if byte_idx >= span.start_byte && byte_idx < span.end_byte {
731                span.style
732            } else {
733                None
734            }
735        } else {
736            None
737        };
738
739        if grapheme == "\n" {
740            break;
741        }
742
743        let cell_width = display_width_for_grapheme(grapheme, line_display_width);
744        line.push(StyledCell {
745            text: grapheme.to_string(),
746            style,
747            width: cell_width,
748        });
749        line_display_width = line_display_width.saturating_add(cell_width);
750    }
751
752    line
753}
754
755/// Returns the terminal display width of a grapheme at a given display column.
756fn display_width_for_grapheme(grapheme: &str, line_display_width: usize) -> usize {
757    if grapheme == "\t" {
758        return tab_width_at(line_display_width, TAB_STOP);
759    }
760    UnicodeWidthStr::width(grapheme)
761}
762
763/// Returns how many display columns a tab advances at `display_col`.
764fn tab_width_at(display_col: usize, tab_stop: usize) -> usize {
765    let stop = tab_stop.max(1);
766    let remainder = display_col % stop;
767    if remainder == 0 {
768        stop
769    } else {
770        stop - remainder
771    }
772}
773
774/// Builds a VT patch by diffing previous and current styled lines.
775///
776/// `origin_row` and `origin_col` are 1-based terminal coordinates.
777/// Column calculations are display-width based (not byte-count based).
778fn diff_lines_to_patch(
779    prev_lines: &[Vec<StyledCell>],
780    curr_lines: &[Vec<StyledCell>],
781    origin_row: usize,
782    origin_col: usize,
783    color_mode: ColorMode,
784) -> String {
785    let mut out = String::new();
786    let line_count = prev_lines.len().max(curr_lines.len());
787    let origin_row0 = origin_row.saturating_sub(1);
788    let origin_col0 = origin_col.saturating_sub(1);
789
790    for row in 0..line_count {
791        let prev = prev_lines.get(row).map(Vec::as_slice).unwrap_or(&[]);
792        let curr = curr_lines.get(row).map(Vec::as_slice).unwrap_or(&[]);
793
794        let Some(first_diff) = first_diff_index(prev, curr) else {
795            continue;
796        };
797
798        let diff_col = display_columns_before(curr, first_diff) + 1;
799        let absolute_row = origin_row0 + row + 1;
800        let absolute_col = origin_col0 + diff_col;
801        write_cup(&mut out, absolute_row, absolute_col);
802        append_styled_cells(&mut out, &curr[first_diff..], color_mode);
803
804        if curr.len() < prev.len() {
805            out.push_str(EL_TO_END);
806        }
807    }
808
809    out
810}
811
812/// Builds a single-line VT patch using only relative backward cursor motion.
813///
814/// Assumes cursor starts at the end of `prev_line` and remains on the same line.
815fn diff_single_line_to_patch(
816    prev_line: &[StyledCell],
817    curr_line: &[StyledCell],
818    color_mode: ColorMode,
819) -> String {
820    let mut out = String::new();
821    let Some(first_diff) = first_diff_index(prev_line, curr_line) else {
822        return out;
823    };
824
825    let prev_width = display_columns_before(prev_line, prev_line.len());
826    let prefix_width = display_columns_before(prev_line, first_diff);
827    let cols_back = prev_width.saturating_sub(prefix_width);
828    write_cub(&mut out, cols_back);
829    append_styled_cells(&mut out, &curr_line[first_diff..], color_mode);
830    if curr_line.len() < prev_line.len() {
831        out.push_str(EL_TO_END);
832    }
833    out
834}
835
836/// Returns the accumulated display columns before `idx`.
837fn display_columns_before(cells: &[StyledCell], idx: usize) -> usize {
838    cells.iter().take(idx).map(|cell| cell.width).sum::<usize>()
839}
840
841/// Returns the first differing cell index between two lines, if any.
842fn first_diff_index(prev: &[StyledCell], curr: &[StyledCell]) -> Option<usize> {
843    let shared = prev.len().min(curr.len());
844    for idx in 0..shared {
845        if prev[idx] != curr[idx] {
846            return Some(idx);
847        }
848    }
849
850    if prev.len() != curr.len() {
851        return Some(shared);
852    }
853
854    None
855}
856
857/// Writes a CUP cursor-position sequence into `out`.
858fn write_cup(out: &mut String, row: usize, col: usize) {
859    let _ = write!(out, "{CSI}{row};{col}H");
860}
861
862/// Writes a relative cursor-left (CUB) sequence into `out`.
863fn write_cub(out: &mut String, cols: usize) {
864    if cols == 0 {
865        return;
866    }
867    let _ = write!(out, "{CSI}{cols}D");
868}
869
870/// Appends styled cells as text and SGR transitions.
871fn append_styled_cells(out: &mut String, cells: &[StyledCell], color_mode: ColorMode) {
872    if cells.is_empty() {
873        return;
874    }
875
876    let mut active_style = None;
877    for cell in cells {
878        write_style_transition(out, active_style, cell.style, color_mode);
879        out.push_str(&cell.text);
880        active_style = cell.style;
881    }
882
883    if active_style.is_some() {
884        out.push_str(SGR_RESET);
885    }
886}
887
888/// Emits the minimal SGR transition between `previous` and `next`.
889fn write_style_transition(
890    out: &mut String,
891    previous: Option<Style>,
892    next: Option<Style>,
893    color_mode: ColorMode,
894) {
895    if previous == next {
896        return;
897    }
898
899    match (previous, next) {
900        (None, None) => {}
901        (Some(_), None) => out.push_str(SGR_RESET),
902        (None, Some(style)) => {
903            if let Some(open) = style_open_sgr(Some(style), color_mode) {
904                out.push_str(&open);
905            }
906        }
907        (Some(_), Some(style)) => {
908            out.push_str(SGR_RESET);
909            if let Some(open) = style_open_sgr(Some(style), color_mode) {
910                out.push_str(&open);
911            }
912        }
913    }
914}
915
916/// Appends a single styled byte segment to `out`.
917fn append_styled_segment(
918    out: &mut String,
919    text: &[u8],
920    style: Option<Style>,
921    color_mode: ColorMode,
922) {
923    if text.is_empty() {
924        return;
925    }
926
927    if let Some(open) = style_open_sgr(style, color_mode) {
928        out.push_str(&open);
929        out.push_str(&String::from_utf8_lossy(text));
930        out.push_str(SGR_RESET);
931        return;
932    }
933
934    out.push_str(&String::from_utf8_lossy(text));
935}
936
937/// Converts a style into an opening SGR sequence.
938///
939/// Returns `None` when the style carries no terminal attributes.
940fn style_open_sgr(style: Option<Style>, color_mode: ColorMode) -> Option<String> {
941    let style = style?;
942    let mut parts = Vec::new();
943    if let Some(fg) = style.fg {
944        let sgr = match color_mode {
945            ColorMode::TrueColor => format!("38;2;{};{};{}", fg.r, fg.g, fg.b),
946            ColorMode::Ansi256 => format!("38;5;{}", rgb_to_ansi256(fg.r, fg.g, fg.b)),
947            ColorMode::Ansi16 => format!("{}", ansi16_fg_sgr(rgb_to_ansi16(fg.r, fg.g, fg.b))),
948        };
949        parts.push(sgr);
950    }
951    if style.bold {
952        parts.push("1".to_string());
953    }
954    if style.italic {
955        parts.push("3".to_string());
956    }
957    if style.underline {
958        parts.push("4".to_string());
959    }
960
961    if parts.is_empty() {
962        return None;
963    }
964
965    Some(format!("\x1b[{}m", parts.join(";")))
966}
967
968/// Quantizes an RGB color to the nearest ANSI 256-color palette index.
969fn rgb_to_ansi256(r: u8, g: u8, b: u8) -> u8 {
970    let (r_idx, r_level) = nearest_ansi_level(r);
971    let (g_idx, g_level) = nearest_ansi_level(g);
972    let (b_idx, b_level) = nearest_ansi_level(b);
973
974    let cube_index = 16 + (36 * r_idx) + (6 * g_idx) + b_idx;
975    let cube_distance = squared_distance((r, g, b), (r_level, g_level, b_level));
976
977    let gray_index = (((i32::from(r) + i32::from(g) + i32::from(b)) / 3 - 8 + 5) / 10).clamp(0, 23);
978    let gray_level = (8 + gray_index * 10) as u8;
979    let gray_distance = squared_distance((r, g, b), (gray_level, gray_level, gray_level));
980
981    if gray_distance < cube_distance {
982        232 + gray_index as u8
983    } else {
984        cube_index as u8
985    }
986}
987
988/// Quantizes an RGB color to an ANSI 16-color palette index.
989///
990/// This mapping favors hue preservation over pure Euclidean distance so
991/// pastel/editor-theme colors do not collapse into mostly white/gray.
992fn rgb_to_ansi16(r: u8, g: u8, b: u8) -> usize {
993    let rf = f32::from(r) / 255.0;
994    let gf = f32::from(g) / 255.0;
995    let bf = f32::from(b) / 255.0;
996
997    let max = rf.max(gf).max(bf);
998    let min = rf.min(gf).min(bf);
999    let delta = max - min;
1000
1001    // Low-saturation colors map to grayscale variants.
1002    if delta < 0.08 || (max > 0.0 && (delta / max) < 0.18) {
1003        return if max < 0.20 {
1004            0
1005        } else if max < 0.45 {
1006            8
1007        } else if max < 0.80 {
1008            7
1009        } else {
1010            15
1011        };
1012    }
1013
1014    let mut hue = if (max - rf).abs() < f32::EPSILON {
1015        60.0 * ((gf - bf) / delta).rem_euclid(6.0)
1016    } else if (max - gf).abs() < f32::EPSILON {
1017        60.0 * (((bf - rf) / delta) + 2.0)
1018    } else {
1019        60.0 * (((rf - gf) / delta) + 4.0)
1020    };
1021    if hue < 0.0 {
1022        hue += 360.0;
1023    }
1024
1025    let base = if !(30.0..330.0).contains(&hue) {
1026        1 // red
1027    } else if hue < 90.0 {
1028        3 // yellow
1029    } else if hue < 150.0 {
1030        2 // green
1031    } else if hue < 210.0 {
1032        6 // cyan
1033    } else if hue < 270.0 {
1034        4 // blue
1035    } else {
1036        5 // magenta
1037    };
1038
1039    // Bright variant for lighter colors.
1040    let bright = max >= 0.62;
1041    if bright {
1042        base + 8
1043    } else {
1044        base
1045    }
1046}
1047
1048/// Returns the ANSI SGR foreground code for a 16-color palette index.
1049fn ansi16_fg_sgr(index: usize) -> u8 {
1050    if index < 8 {
1051        30 + index as u8
1052    } else {
1053        90 + (index as u8 - 8)
1054    }
1055}
1056
1057/// Returns the nearest ANSI cube level index and channel value.
1058fn nearest_ansi_level(value: u8) -> (usize, u8) {
1059    let mut best_idx = 0usize;
1060    let mut best_diff = i16::MAX;
1061    for (idx, level) in ANSI_256_LEVELS.iter().enumerate() {
1062        let diff = (i16::from(value) - i16::from(*level)).abs();
1063        if diff < best_diff {
1064            best_diff = diff;
1065            best_idx = idx;
1066        }
1067    }
1068    (best_idx, ANSI_256_LEVELS[best_idx])
1069}
1070
1071/// Returns squared Euclidean distance in RGB space.
1072fn squared_distance(lhs: (u8, u8, u8), rhs: (u8, u8, u8)) -> i32 {
1073    let dr = i32::from(lhs.0) - i32::from(rhs.0);
1074    let dg = i32::from(lhs.1) - i32::from(rhs.1);
1075    let db = i32::from(lhs.2) - i32::from(rhs.2);
1076    (dr * dr) + (dg * dg) + (db * db)
1077}
1078
1079/// Returns byte ranges for each line in `source` (excluding trailing newlines).
1080fn compute_line_ranges(source: &[u8]) -> Vec<(usize, usize)> {
1081    let mut ranges = Vec::new();
1082    let mut line_start = 0usize;
1083    for (i, byte) in source.iter().enumerate() {
1084        if *byte == b'\n' {
1085            ranges.push((line_start, i));
1086            line_start = i + 1;
1087        }
1088    }
1089    ranges.push((line_start, source.len()));
1090    ranges
1091}
1092
1093/// Validates that spans are in-bounds, sorted, and non-overlapping.
1094///
1095/// # Errors
1096///
1097/// Returns [`RenderError::SpanOutOfBounds`] or [`RenderError::OverlappingSpans`]
1098/// when invariants are violated.
1099fn validate_spans(source_len: usize, spans: &[StyledSpan]) -> Result<(), RenderError> {
1100    let mut prev_end = 0usize;
1101    for (i, span) in spans.iter().enumerate() {
1102        if span.start_byte > span.end_byte || span.end_byte > source_len {
1103            return Err(RenderError::SpanOutOfBounds {
1104                start_byte: span.start_byte,
1105                end_byte: span.end_byte,
1106                source_len,
1107            });
1108        }
1109        if i > 0 && span.start_byte < prev_end {
1110            return Err(RenderError::OverlappingSpans {
1111                prev_end,
1112                next_start: span.start_byte,
1113            });
1114        }
1115        prev_end = span.end_byte;
1116    }
1117    Ok(())
1118}
1119
1120#[cfg(test)]
1121mod tests {
1122    use super::{
1123        highlight_lines_to_ansi_lines, highlight_to_ansi, render_ansi, render_ansi_lines,
1124        render_ansi_with_mode, resolve_styled_spans, resolve_styled_spans_for_source, ColorMode,
1125        IncrementalRenderer, RenderError, StreamLineRenderer, StyledSpan,
1126    };
1127    use highlight_spans::{Attr, Grammar, HighlightResult, Span, SpanHighlighter};
1128    use theme_engine::{load_theme, Rgb, Style, Theme};
1129
1130    #[test]
1131    /// Verifies a styled segment is wrapped with expected SGR codes.
1132    fn renders_basic_styled_segment() {
1133        let source = b"abc";
1134        let spans = [StyledSpan {
1135            start_byte: 1,
1136            end_byte: 2,
1137            style: Some(Style {
1138                fg: Some(Rgb::new(255, 0, 0)),
1139                bold: true,
1140                ..Style::default()
1141            }),
1142        }];
1143        let out = render_ansi(source, &spans).expect("failed to render");
1144        assert_eq!(out, "a\x1b[38;2;255;0;0;1mb\x1b[0mc");
1145    }
1146
1147    #[test]
1148    /// Verifies stream line renderer paints the initial line as-is.
1149    fn stream_line_renderer_emits_initial_line() {
1150        let mut renderer = StreamLineRenderer::new();
1151        let patch = renderer
1152            .render_line_patch(b"hello", &[])
1153            .expect("initial stream patch failed");
1154        assert_eq!(patch, "hello");
1155    }
1156
1157    #[test]
1158    /// Verifies stream line renderer emits a suffix-only patch with CUB backtracking.
1159    fn stream_line_renderer_emits_suffix_with_backtracking() {
1160        let mut renderer = StreamLineRenderer::new();
1161        let _ = renderer
1162            .render_line_patch(b"hello", &[])
1163            .expect("initial stream patch failed");
1164        let patch = renderer
1165            .render_line_patch(b"heLlo", &[])
1166            .expect("delta stream patch failed");
1167        assert_eq!(patch, "\x1b[3DLlo");
1168    }
1169
1170    #[test]
1171    /// Verifies stream line renderer clears trailing cells when line becomes shorter.
1172    fn stream_line_renderer_clears_removed_tail() {
1173        let mut renderer = StreamLineRenderer::new();
1174        let _ = renderer
1175            .render_line_patch(b"hello", &[])
1176            .expect("initial stream patch failed");
1177        let patch = renderer
1178            .render_line_patch(b"he", &[])
1179            .expect("delta stream patch failed");
1180        assert_eq!(patch, "\x1b[3D\x1b[K");
1181    }
1182
1183    #[test]
1184    /// Verifies stream line renderer emits nothing for unchanged input.
1185    fn stream_line_renderer_is_noop_when_unchanged() {
1186        let mut renderer = StreamLineRenderer::new();
1187        let _ = renderer
1188            .render_line_patch(b"hello", &[])
1189            .expect("initial stream patch failed");
1190        let patch = renderer
1191            .render_line_patch(b"hello", &[])
1192            .expect("delta stream patch failed");
1193        assert!(patch.is_empty());
1194    }
1195
1196    #[test]
1197    /// Verifies stream line renderer rejects multi-line input.
1198    fn stream_line_renderer_rejects_multiline_input() {
1199        let mut renderer = StreamLineRenderer::new();
1200        let err = renderer
1201            .render_line_patch(b"hello\nworld", &[])
1202            .expect_err("expected multiline rejection");
1203        assert!(matches!(err, RenderError::MultiLineInput));
1204    }
1205
1206    #[test]
1207    /// Verifies stream line backtracking uses display width for wide graphemes.
1208    fn stream_line_renderer_uses_display_width_for_wide_graphemes() {
1209        let mut renderer = StreamLineRenderer::new();
1210        let _ = renderer
1211            .render_line_patch("a界!".as_bytes(), &[])
1212            .expect("initial stream patch failed");
1213        let patch = renderer
1214            .render_line_patch("a界?".as_bytes(), &[])
1215            .expect("delta stream patch failed");
1216        assert_eq!(patch, "\x1b[1D?");
1217    }
1218
1219    #[test]
1220    /// Verifies ANSI-256 mode emits indexed foreground color SGR.
1221    fn renders_ansi256_styled_segment() {
1222        let source = b"abc";
1223        let spans = [StyledSpan {
1224            start_byte: 1,
1225            end_byte: 2,
1226            style: Some(Style {
1227                fg: Some(Rgb::new(255, 0, 0)),
1228                bold: true,
1229                ..Style::default()
1230            }),
1231        }];
1232        let out =
1233            render_ansi_with_mode(source, &spans, ColorMode::Ansi256).expect("failed to render");
1234        assert_eq!(out, "a\x1b[38;5;196;1mb\x1b[0mc");
1235    }
1236
1237    #[test]
1238    /// Verifies ANSI-16 mode emits basic/bright indexed foreground color SGR.
1239    fn renders_ansi16_styled_segment() {
1240        let source = b"abc";
1241        let spans = [StyledSpan {
1242            start_byte: 1,
1243            end_byte: 2,
1244            style: Some(Style {
1245                fg: Some(Rgb::new(255, 0, 0)),
1246                bold: true,
1247                ..Style::default()
1248            }),
1249        }];
1250        let out =
1251            render_ansi_with_mode(source, &spans, ColorMode::Ansi16).expect("failed to render");
1252        assert_eq!(out, "a\x1b[91;1mb\x1b[0mc");
1253    }
1254
1255    #[test]
1256    /// Verifies ANSI-16 mode keeps hue for saturated non-red colors.
1257    fn renders_ansi16_preserves_non_gray_hue() {
1258        let source = b"abc";
1259        let spans = [StyledSpan {
1260            start_byte: 1,
1261            end_byte: 2,
1262            style: Some(Style {
1263                fg: Some(Rgb::new(130, 170, 255)),
1264                ..Style::default()
1265            }),
1266        }];
1267        let out =
1268            render_ansi_with_mode(source, &spans, ColorMode::Ansi16).expect("failed to render");
1269        assert!(
1270            out.contains("\x1b[94m"),
1271            "expected bright blue ANSI16 code, got {out:?}"
1272        );
1273    }
1274
1275    #[test]
1276    /// Verifies multiline spans are clipped and rendered per line.
1277    fn renders_per_line_output_for_multiline_span() {
1278        let source = b"ab\ncd";
1279        let spans = [StyledSpan {
1280            start_byte: 1,
1281            end_byte: 5,
1282            style: Some(Style {
1283                fg: Some(Rgb::new(1, 2, 3)),
1284                ..Style::default()
1285            }),
1286        }];
1287
1288        let lines = render_ansi_lines(source, &spans).expect("failed to render lines");
1289        assert_eq!(lines.len(), 2);
1290        assert_eq!(lines[0], "a\x1b[38;2;1;2;3mb\x1b[0m");
1291        assert_eq!(lines[1], "\x1b[38;2;1;2;3mcd\x1b[0m");
1292    }
1293
1294    #[test]
1295    /// Verifies overlapping spans are rejected.
1296    fn rejects_overlapping_spans() {
1297        let spans = [
1298            StyledSpan {
1299                start_byte: 0,
1300                end_byte: 2,
1301                style: None,
1302            },
1303            StyledSpan {
1304                start_byte: 1,
1305                end_byte: 3,
1306                style: None,
1307            },
1308        ];
1309        let err = render_ansi(b"abcd", &spans).expect_err("expected overlap error");
1310        assert!(matches!(err, RenderError::OverlappingSpans { .. }));
1311    }
1312
1313    #[test]
1314    /// Verifies end-to-end highlight plus ANSI rendering works.
1315    fn highlights_source_to_ansi() {
1316        let theme = Theme::from_json_str(
1317            r#"{
1318  "styles": {
1319    "normal": { "fg": { "r": 220, "g": 220, "b": 220 } },
1320    "number": { "fg": { "r": 255, "g": 180, "b": 120 } }
1321  }
1322}"#,
1323        )
1324        .expect("theme parse failed");
1325
1326        let out = highlight_to_ansi(b"set x = 42", Grammar::ObjectScript, &theme)
1327            .expect("highlight+render failed");
1328        assert!(out.contains("42"));
1329        assert!(out.contains("\x1b["));
1330    }
1331
1332    #[test]
1333    /// Verifies capture styles inherit missing fields from `normal`.
1334    fn resolve_styled_spans_inherits_from_normal() {
1335        let theme = Theme::from_json_str(
1336            r#"{
1337  "styles": {
1338    "normal": {
1339      "fg": { "r": 10, "g": 11, "b": 12 },
1340      "bg": { "r": 200, "g": 201, "b": 202 },
1341      "italic": true
1342    },
1343    "keyword": { "fg": { "r": 250, "g": 1, "b": 2 } }
1344  }
1345}"#,
1346        )
1347        .expect("theme parse failed");
1348        let highlight = HighlightResult {
1349            attrs: vec![Attr {
1350                id: 0,
1351                capture_name: "keyword".to_string(),
1352            }],
1353            spans: vec![Span {
1354                attr_id: 0,
1355                start_byte: 0,
1356                end_byte: 3,
1357            }],
1358        };
1359
1360        let styled = resolve_styled_spans(&highlight, &theme).expect("resolve failed");
1361        assert_eq!(styled.len(), 1);
1362        let style = styled[0].style.expect("missing style");
1363        assert_eq!(style.fg, Some(Rgb::new(250, 1, 2)));
1364        assert_eq!(style.bg, Some(Rgb::new(200, 201, 202)));
1365        assert!(style.italic);
1366    }
1367
1368    #[test]
1369    /// Verifies high-level resolution fills uncovered byte ranges with `normal`.
1370    fn resolve_styled_spans_for_source_fills_uncovered_ranges() {
1371        let theme = Theme::from_json_str(
1372            r#"{
1373  "styles": {
1374    "normal": {
1375      "fg": { "r": 1, "g": 2, "b": 3 },
1376      "bg": { "r": 4, "g": 5, "b": 6 }
1377    },
1378    "keyword": { "fg": { "r": 7, "g": 8, "b": 9 } }
1379  }
1380}"#,
1381        )
1382        .expect("theme parse failed");
1383        let highlight = HighlightResult {
1384            attrs: vec![Attr {
1385                id: 0,
1386                capture_name: "keyword".to_string(),
1387            }],
1388            spans: vec![Span {
1389                attr_id: 0,
1390                start_byte: 1,
1391                end_byte: 2,
1392            }],
1393        };
1394
1395        let styled =
1396            resolve_styled_spans_for_source(4, &highlight, &theme).expect("resolve failed");
1397        assert_eq!(styled.len(), 3);
1398        assert_eq!(
1399            styled[0],
1400            StyledSpan {
1401                start_byte: 0,
1402                end_byte: 1,
1403                style: Some(Style {
1404                    fg: Some(Rgb::new(1, 2, 3)),
1405                    bg: Some(Rgb::new(4, 5, 6)),
1406                    ..Style::default()
1407                }),
1408            }
1409        );
1410        assert_eq!(
1411            styled[1],
1412            StyledSpan {
1413                start_byte: 1,
1414                end_byte: 2,
1415                style: Some(Style {
1416                    fg: Some(Rgb::new(7, 8, 9)),
1417                    bg: Some(Rgb::new(4, 5, 6)),
1418                    ..Style::default()
1419                }),
1420            }
1421        );
1422        assert_eq!(
1423            styled[2],
1424            StyledSpan {
1425                start_byte: 2,
1426                end_byte: 4,
1427                style: Some(Style {
1428                    fg: Some(Rgb::new(1, 2, 3)),
1429                    bg: Some(Rgb::new(4, 5, 6)),
1430                    ..Style::default()
1431                }),
1432            }
1433        );
1434    }
1435
1436    #[test]
1437    /// Verifies line-oriented highlight rendering preserves line count.
1438    fn highlights_lines_to_ansi_lines() {
1439        let theme = load_theme("tokyo-night").expect("failed to load built-in theme");
1440        let lines = vec!["set x = 1", "set y = 2"];
1441        let rendered = highlight_lines_to_ansi_lines(&lines, Grammar::ObjectScript, &theme)
1442            .expect("failed to highlight lines");
1443        assert_eq!(rendered.len(), 2);
1444    }
1445
1446    #[test]
1447    /// Verifies incremental patches include only changed line suffixes.
1448    fn incremental_renderer_emits_only_changed_line_suffix() {
1449        let mut renderer = IncrementalRenderer::new(120, 40);
1450        let first = renderer
1451            .render_patch(b"abc\nxyz", &[])
1452            .expect("first patch failed");
1453        assert!(first.contains("\x1b[1;1Habc"));
1454        assert!(first.contains("\x1b[2;1Hxyz"));
1455
1456        let second = renderer
1457            .render_patch(b"abc\nxYz", &[])
1458            .expect("second patch failed");
1459        assert_eq!(second, "\x1b[2;2HYz");
1460
1461        let third = renderer
1462            .render_patch(b"abc\nxYz", &[])
1463            .expect("third patch failed");
1464        assert!(third.is_empty());
1465    }
1466
1467    #[test]
1468    /// Verifies configured origin offsets CUP coordinates in emitted patches.
1469    fn incremental_renderer_applies_origin_offset() {
1470        let mut renderer = IncrementalRenderer::new(120, 40);
1471        renderer.set_origin(4, 7);
1472
1473        let first = renderer
1474            .render_patch(b"abc", &[])
1475            .expect("first patch failed");
1476        assert_eq!(first, "\x1b[4;7Habc");
1477
1478        let second = renderer
1479            .render_patch(b"abC", &[])
1480            .expect("second patch failed");
1481        assert_eq!(second, "\x1b[4;9HC");
1482    }
1483
1484    #[test]
1485    /// Verifies incremental renderer can emit ANSI-256 foreground colors.
1486    fn incremental_renderer_supports_ansi256_mode() {
1487        let mut renderer = IncrementalRenderer::new(120, 40);
1488        renderer.set_color_mode(ColorMode::Ansi256);
1489        let spans = [StyledSpan {
1490            start_byte: 0,
1491            end_byte: 2,
1492            style: Some(Style {
1493                fg: Some(Rgb::new(255, 0, 0)),
1494                ..Style::default()
1495            }),
1496        }];
1497
1498        let patch = renderer
1499            .render_patch(b"ab", &spans)
1500            .expect("patch generation failed");
1501        assert!(patch.contains("\x1b[38;5;196m"));
1502        assert!(!patch.contains("38;2;"));
1503    }
1504
1505    #[test]
1506    /// Verifies incremental renderer can emit ANSI-16 foreground colors.
1507    fn incremental_renderer_supports_ansi16_mode() {
1508        let mut renderer = IncrementalRenderer::new(120, 40);
1509        renderer.set_color_mode(ColorMode::Ansi16);
1510        let spans = [StyledSpan {
1511            start_byte: 0,
1512            end_byte: 2,
1513            style: Some(Style {
1514                fg: Some(Rgb::new(255, 0, 0)),
1515                ..Style::default()
1516            }),
1517        }];
1518
1519        let patch = renderer
1520            .render_patch(b"ab", &spans)
1521            .expect("patch generation failed");
1522        assert!(patch.contains("\x1b[91m"));
1523        assert!(!patch.contains("38;2;"));
1524        assert!(!patch.contains("38;5;"));
1525    }
1526
1527    #[test]
1528    /// Verifies CUP columns account for wide grapheme display widths.
1529    fn incremental_renderer_uses_display_width_for_wide_graphemes() {
1530        let mut renderer = IncrementalRenderer::new(120, 40);
1531        let _ = renderer
1532            .render_patch("a界".as_bytes(), &[])
1533            .expect("first patch failed");
1534
1535        let patch = renderer
1536            .render_patch("a界!".as_bytes(), &[])
1537            .expect("second patch failed");
1538        assert_eq!(patch, "\x1b[1;4H!");
1539    }
1540
1541    #[test]
1542    /// Verifies tab cells advance to the next configured tab stop for CUP columns.
1543    fn incremental_renderer_uses_display_width_for_tabs() {
1544        let mut renderer = IncrementalRenderer::new(120, 40);
1545        let _ = renderer
1546            .render_patch(b"a\tb", &[])
1547            .expect("first patch failed");
1548
1549        let patch = renderer
1550            .render_patch(b"a\tB", &[])
1551            .expect("second patch failed");
1552        assert_eq!(patch, "\x1b[1;9HB");
1553    }
1554
1555    #[test]
1556    /// Verifies incremental patches clear removed trailing cells.
1557    fn incremental_renderer_clears_removed_tail() {
1558        let mut renderer = IncrementalRenderer::new(120, 40);
1559        let _ = renderer
1560            .render_patch(b"hello", &[])
1561            .expect("first patch failed");
1562
1563        let patch = renderer
1564            .render_patch(b"he", &[])
1565            .expect("second patch failed");
1566        assert_eq!(patch, "\x1b[1;3H\x1b[K");
1567    }
1568
1569    #[test]
1570    /// Verifies incremental rendering works with the highlight pipeline.
1571    fn incremental_renderer_supports_highlight_pipeline() {
1572        let theme = Theme::from_json_str(
1573            r#"{
1574  "styles": {
1575    "normal": { "fg": { "r": 220, "g": 220, "b": 220 } },
1576    "keyword": { "fg": { "r": 255, "g": 0, "b": 0 } }
1577  }
1578}"#,
1579        )
1580        .expect("theme parse failed");
1581        let mut highlighter = SpanHighlighter::new().expect("highlighter init failed");
1582        let mut renderer = IncrementalRenderer::new(120, 40);
1583
1584        let patch = renderer
1585            .highlight_to_patch(&mut highlighter, b"SELECT 1", Grammar::Sql, &theme)
1586            .expect("highlight patch failed");
1587        assert!(patch.contains("\x1b[1;1H"));
1588        assert!(patch.contains("SELECT"));
1589    }
1590}