Skip to main content

render_ansi/
lib.rs

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