Skip to main content

render_ansi/
lib.rs

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