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 #[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 #[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 #[must_use]
56 pub const fn supported_names() -> &'static [&'static str] {
57 &COLOR_MODE_NAMES
58 }
59}
60
61#[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#[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#[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#[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#[must_use]
106pub const fn osc_reset_default_foreground() -> &'static str {
107 OSC_RESET_DEFAULT_FG
108}
109
110#[must_use]
112pub const fn osc_reset_default_background() -> &'static str {
113 OSC_RESET_DEFAULT_BG
114}
115
116#[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 #[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 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 pub fn clear_state(&mut self) {
174 self.prev_lines.clear();
175 }
176
177 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 #[must_use]
188 pub fn origin(&self) -> (usize, usize) {
189 (self.origin_row, self.origin_col)
190 }
191
192 pub fn set_color_mode(&mut self, color_mode: ColorMode) {
194 self.color_mode = color_mode;
195 }
196
197 #[must_use]
199 pub fn color_mode(&self) -> ColorMode {
200 self.color_mode
201 }
202
203 pub fn set_preserve_terminal_background(&mut self, preserve_terminal_background: bool) {
208 self.preserve_terminal_background = preserve_terminal_background;
209 }
210
211 #[must_use]
213 pub fn preserve_terminal_background(&self) -> bool {
214 self.preserve_terminal_background
215 }
216
217 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 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#[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 #[must_use]
277 pub fn new() -> Self {
278 Self::default()
279 }
280
281 pub fn clear_state(&mut self) {
283 self.prev_line.clear();
284 }
285
286 pub fn set_color_mode(&mut self, color_mode: ColorMode) {
288 self.color_mode = color_mode;
289 }
290
291 #[must_use]
293 pub fn color_mode(&self) -> ColorMode {
294 self.color_mode
295 }
296
297 pub fn set_preserve_terminal_background(&mut self, preserve_terminal_background: bool) {
302 self.preserve_terminal_background = preserve_terminal_background;
303 }
304
305 #[must_use]
307 pub fn preserve_terminal_background(&self) -> bool {
308 self.preserve_terminal_background
309 }
310
311 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 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
385pub 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
416fn 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
430fn 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
449fn 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
487pub fn render_ansi(source: &[u8], spans: &[StyledSpan]) -> Result<String, RenderError> {
493 render_ansi_with_mode(source, spans, ColorMode::TrueColor)
494}
495
496pub 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
514pub 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
553pub fn render_ansi_lines(source: &[u8], spans: &[StyledSpan]) -> Result<Vec<String>, RenderError> {
561 render_ansi_lines_with_mode(source, spans, ColorMode::TrueColor)
562}
563
564pub 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
584pub 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
645pub 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
667pub 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
687pub 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
709pub 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
732pub 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
754pub 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
772pub 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
794pub 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
814pub 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
836pub 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
861pub 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
883pub 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
911fn 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
924fn 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
980fn 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
1018fn 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
1026fn 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
1037fn 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
1081fn 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
1111fn display_columns_before(cells: &[StyledCell], idx: usize) -> usize {
1113 cells.iter().take(idx).map(|cell| cell.width).sum::<usize>()
1114}
1115
1116fn 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
1132fn write_cup(out: &mut String, row: usize, col: usize) {
1134 let _ = write!(out, "{CSI}{row};{col}H");
1135}
1136
1137fn write_cub(out: &mut String, cols: usize) {
1139 if cols == 0 {
1140 return;
1141 }
1142 let _ = write!(out, "{CSI}{cols}D");
1143}
1144
1145fn 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
1174fn 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
1207fn 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
1229fn 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
1276fn 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
1296fn 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 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 } else if hue < 90.0 {
1336 3 } else if hue < 150.0 {
1338 2 } else if hue < 210.0 {
1340 6 } else if hue < 270.0 {
1342 4 } else {
1344 5 };
1346
1347 let bright = max >= 0.62;
1349 if bright {
1350 base + 8
1351 } else {
1352 base
1353 }
1354}
1355
1356fn 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
1365fn 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
1374fn 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
1388fn 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
1396fn 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
1410fn 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}