1use std::fmt::Write;
2
3use highlight_spans::{Grammar, HighlightError, HighlightResult, SpanHighlighter};
4use theme_engine::{Style, Theme};
5use thiserror::Error;
6use unicode_segmentation::UnicodeSegmentation;
7use unicode_width::UnicodeWidthStr;
8
9const CSI: &str = "\x1b[";
10const SGR_RESET: &str = "\x1b[0m";
11const EL_TO_END: &str = "\x1b[K";
12const TAB_STOP: usize = 8;
13const ANSI_256_LEVELS: [u8; 6] = [0, 95, 135, 175, 215, 255];
14const COLOR_MODE_NAMES: [&str; 3] = ["truecolor", "ansi256", "ansi16"];
15
16#[derive(Debug, Clone, Copy, Eq, PartialEq, Default)]
17pub enum ColorMode {
18 #[default]
19 TrueColor,
20 Ansi256,
21 Ansi16,
22}
23
24impl ColorMode {
25 #[must_use]
30 pub fn from_name(input: &str) -> Option<Self> {
31 match input.trim().to_ascii_lowercase().as_str() {
32 "truecolor" | "24bit" | "24-bit" | "rgb" => Some(Self::TrueColor),
33 "ansi256" | "256" | "xterm256" | "xterm-256" => Some(Self::Ansi256),
34 "ansi16" | "16" | "xterm16" | "xterm-16" | "basic" => Some(Self::Ansi16),
35 _ => None,
36 }
37 }
38
39 #[must_use]
41 pub const fn name(self) -> &'static str {
42 match self {
43 Self::TrueColor => "truecolor",
44 Self::Ansi256 => "ansi256",
45 Self::Ansi16 => "ansi16",
46 }
47 }
48
49 #[must_use]
51 pub const fn supported_names() -> &'static [&'static str] {
52 &COLOR_MODE_NAMES
53 }
54}
55
56#[derive(Debug, Clone, Copy, Eq, PartialEq)]
57pub struct StyledSpan {
58 pub start_byte: usize,
59 pub end_byte: usize,
60 pub style: Option<Style>,
61}
62
63#[derive(Debug, Clone, Eq, PartialEq)]
64struct StyledCell {
65 text: String,
66 style: Option<Style>,
67 width: usize,
68}
69
70#[derive(Debug, Clone)]
71pub struct IncrementalRenderer {
72 width: usize,
73 height: usize,
74 origin_row: usize,
75 origin_col: usize,
76 color_mode: ColorMode,
77 prev_lines: Vec<Vec<StyledCell>>,
78}
79
80impl IncrementalRenderer {
81 #[must_use]
86 pub fn new(width: usize, height: usize) -> Self {
87 Self {
88 width: width.max(1),
89 height: height.max(1),
90 origin_row: 1,
91 origin_col: 1,
92 color_mode: ColorMode::TrueColor,
93 prev_lines: Vec::new(),
94 }
95 }
96
97 pub fn resize(&mut self, width: usize, height: usize) {
99 self.width = width.max(1);
100 self.height = height.max(1);
101 self.prev_lines = clip_lines_to_viewport(&self.prev_lines, self.width, self.height);
102 }
103
104 pub fn clear_state(&mut self) {
106 self.prev_lines.clear();
107 }
108
109 pub fn set_origin(&mut self, row: usize, col: usize) {
114 self.origin_row = row.max(1);
115 self.origin_col = col.max(1);
116 }
117
118 #[must_use]
120 pub fn origin(&self) -> (usize, usize) {
121 (self.origin_row, self.origin_col)
122 }
123
124 pub fn set_color_mode(&mut self, color_mode: ColorMode) {
126 self.color_mode = color_mode;
127 }
128
129 #[must_use]
131 pub fn color_mode(&self) -> ColorMode {
132 self.color_mode
133 }
134
135 pub fn render_patch(
144 &mut self,
145 source: &[u8],
146 spans: &[StyledSpan],
147 ) -> Result<String, RenderError> {
148 validate_spans(source.len(), spans)?;
149 let curr_lines = build_styled_cells(source, spans, self.width, self.height);
150 let patch = diff_lines_to_patch(
151 &self.prev_lines,
152 &curr_lines,
153 self.origin_row,
154 self.origin_col,
155 self.color_mode,
156 );
157 self.prev_lines = curr_lines;
158 Ok(patch)
159 }
160
161 pub fn highlight_to_patch(
167 &mut self,
168 highlighter: &mut SpanHighlighter,
169 source: &[u8],
170 flavor: Grammar,
171 theme: &Theme,
172 ) -> Result<String, RenderError> {
173 let highlight = highlighter.highlight(source, flavor)?;
174 let styled = resolve_styled_spans_for_source(source.len(), &highlight, theme)?;
175 self.render_patch(source, &styled)
176 }
177}
178
179#[derive(Debug, Clone, Default)]
185pub struct StreamLineRenderer {
186 color_mode: ColorMode,
187 prev_line: Vec<StyledCell>,
188}
189
190impl StreamLineRenderer {
191 #[must_use]
193 pub fn new() -> Self {
194 Self::default()
195 }
196
197 pub fn clear_state(&mut self) {
199 self.prev_line.clear();
200 }
201
202 pub fn set_color_mode(&mut self, color_mode: ColorMode) {
204 self.color_mode = color_mode;
205 }
206
207 #[must_use]
209 pub fn color_mode(&self) -> ColorMode {
210 self.color_mode
211 }
212
213 pub fn render_line_patch(
219 &mut self,
220 source: &[u8],
221 spans: &[StyledSpan],
222 ) -> Result<String, RenderError> {
223 validate_spans(source.len(), spans)?;
224 if source.contains(&b'\n') {
225 return Err(RenderError::MultiLineInput);
226 }
227
228 let curr_line = build_styled_line_cells(source, spans);
229 let patch = diff_single_line_to_patch(&self.prev_line, &curr_line, self.color_mode);
230 self.prev_line = curr_line;
231 Ok(patch)
232 }
233
234 pub fn highlight_line_to_patch(
240 &mut self,
241 highlighter: &mut SpanHighlighter,
242 source: &[u8],
243 flavor: Grammar,
244 theme: &Theme,
245 ) -> Result<String, RenderError> {
246 let highlight = highlighter.highlight(source, flavor)?;
247 let styled = resolve_styled_spans_for_source(source.len(), &highlight, theme)?;
248 self.render_line_patch(source, &styled)
249 }
250}
251
252#[derive(Debug, Error)]
253pub enum RenderError {
254 #[error("highlighting failed: {0}")]
255 Highlight(#[from] HighlightError),
256 #[error("invalid span range {start_byte}..{end_byte} for source length {source_len}")]
257 SpanOutOfBounds {
258 start_byte: usize,
259 end_byte: usize,
260 source_len: usize,
261 },
262 #[error(
263 "spans must be sorted and non-overlapping: prev_end={prev_end}, next_start={next_start}"
264 )]
265 OverlappingSpans { prev_end: usize, next_start: usize },
266 #[error("invalid attr_id {attr_id}; attrs length is {attrs_len}")]
267 InvalidAttrId { attr_id: usize, attrs_len: usize },
268 #[error("stream line patch requires single-line input without newlines")]
269 MultiLineInput,
270}
271
272pub fn resolve_styled_spans(
281 highlight: &HighlightResult,
282 theme: &Theme,
283) -> Result<Vec<StyledSpan>, RenderError> {
284 let normal_style = theme.get_exact("normal").copied();
285 let mut out = Vec::with_capacity(highlight.spans.len());
286 for span in &highlight.spans {
287 let Some(attr) = highlight.attrs.get(span.attr_id) else {
288 return Err(RenderError::InvalidAttrId {
289 attr_id: span.attr_id,
290 attrs_len: highlight.attrs.len(),
291 });
292 };
293 let capture_style = theme.resolve(&attr.capture_name).copied();
294 out.push(StyledSpan {
295 start_byte: span.start_byte,
296 end_byte: span.end_byte,
297 style: merge_styles(normal_style, capture_style),
298 });
299 }
300 Ok(out)
301}
302
303fn resolve_styled_spans_for_source(
305 source_len: usize,
306 highlight: &HighlightResult,
307 theme: &Theme,
308) -> Result<Vec<StyledSpan>, RenderError> {
309 let spans = resolve_styled_spans(highlight, theme)?;
310 Ok(fill_uncovered_ranges_with_style(
311 source_len,
312 spans,
313 theme.get_exact("normal").copied(),
314 ))
315}
316
317fn merge_styles(base: Option<Style>, overlay: Option<Style>) -> Option<Style> {
322 match (base, overlay) {
323 (None, None) => None,
324 (Some(base), None) => Some(base),
325 (None, Some(overlay)) => Some(overlay),
326 (Some(base), Some(overlay)) => Some(Style {
327 fg: overlay.fg.or(base.fg),
328 bg: overlay.bg.or(base.bg),
329 bold: base.bold || overlay.bold,
330 italic: base.italic || overlay.italic,
331 underline: base.underline || overlay.underline,
332 }),
333 }
334}
335
336fn fill_uncovered_ranges_with_style(
338 source_len: usize,
339 spans: Vec<StyledSpan>,
340 default_style: Option<Style>,
341) -> Vec<StyledSpan> {
342 let Some(default_style) = default_style else {
343 return spans;
344 };
345
346 let mut out = Vec::with_capacity(spans.len().saturating_mul(2).saturating_add(1));
347 let mut cursor = 0usize;
348 for span in spans {
349 if cursor < span.start_byte {
350 out.push(StyledSpan {
351 start_byte: cursor,
352 end_byte: span.start_byte,
353 style: Some(default_style),
354 });
355 }
356
357 if span.start_byte < span.end_byte {
358 out.push(span);
359 }
360 cursor = cursor.max(span.end_byte);
361 }
362
363 if cursor < source_len {
364 out.push(StyledSpan {
365 start_byte: cursor,
366 end_byte: source_len,
367 style: Some(default_style),
368 });
369 }
370
371 out
372}
373
374pub fn render_ansi(source: &[u8], spans: &[StyledSpan]) -> Result<String, RenderError> {
380 render_ansi_with_mode(source, spans, ColorMode::TrueColor)
381}
382
383pub fn render_ansi_with_mode(
389 source: &[u8],
390 spans: &[StyledSpan],
391 color_mode: ColorMode,
392) -> Result<String, RenderError> {
393 validate_spans(source.len(), spans)?;
394
395 let mut out = String::new();
396 let mut cursor = 0usize;
397 for span in spans {
398 if cursor < span.start_byte {
399 out.push_str(&String::from_utf8_lossy(&source[cursor..span.start_byte]));
400 }
401 append_styled_segment(
402 &mut out,
403 &source[span.start_byte..span.end_byte],
404 span.style,
405 color_mode,
406 );
407 cursor = span.end_byte;
408 }
409
410 if cursor < source.len() {
411 out.push_str(&String::from_utf8_lossy(&source[cursor..]));
412 }
413
414 Ok(out)
415}
416
417pub fn render_ansi_lines(source: &[u8], spans: &[StyledSpan]) -> Result<Vec<String>, RenderError> {
425 render_ansi_lines_with_mode(source, spans, ColorMode::TrueColor)
426}
427
428pub fn render_ansi_lines_with_mode(
436 source: &[u8],
437 spans: &[StyledSpan],
438 color_mode: ColorMode,
439) -> Result<Vec<String>, RenderError> {
440 validate_spans(source.len(), spans)?;
441
442 let line_ranges = compute_line_ranges(source);
443 let mut lines = Vec::with_capacity(line_ranges.len());
444 let mut span_cursor = 0usize;
445
446 for (line_start, line_end) in line_ranges {
447 while span_cursor < spans.len() && spans[span_cursor].end_byte <= line_start {
448 span_cursor += 1;
449 }
450
451 let mut line = String::new();
452 let mut cursor = line_start;
453 let mut i = span_cursor;
454 while i < spans.len() {
455 let span = spans[i];
456 if span.start_byte >= line_end {
457 break;
458 }
459
460 let seg_start = span.start_byte.max(line_start);
461 let seg_end = span.end_byte.min(line_end);
462 if cursor < seg_start {
463 line.push_str(&String::from_utf8_lossy(&source[cursor..seg_start]));
464 }
465 append_styled_segment(
466 &mut line,
467 &source[seg_start..seg_end],
468 span.style,
469 color_mode,
470 );
471 cursor = seg_end;
472 i += 1;
473 }
474
475 if cursor < line_end {
476 line.push_str(&String::from_utf8_lossy(&source[cursor..line_end]));
477 }
478
479 lines.push(line);
480 }
481
482 Ok(lines)
483}
484
485pub fn highlight_to_ansi(
493 source: &[u8],
494 flavor: Grammar,
495 theme: &Theme,
496) -> Result<String, RenderError> {
497 let mut highlighter = SpanHighlighter::new()?;
498 highlight_to_ansi_with_highlighter_and_mode(
499 &mut highlighter,
500 source,
501 flavor,
502 theme,
503 ColorMode::TrueColor,
504 )
505}
506
507pub fn highlight_to_ansi_with_highlighter(
513 highlighter: &mut SpanHighlighter,
514 source: &[u8],
515 flavor: Grammar,
516 theme: &Theme,
517) -> Result<String, RenderError> {
518 highlight_to_ansi_with_highlighter_and_mode(
519 highlighter,
520 source,
521 flavor,
522 theme,
523 ColorMode::TrueColor,
524 )
525}
526
527pub fn highlight_to_ansi_with_mode(
535 source: &[u8],
536 flavor: Grammar,
537 theme: &Theme,
538 color_mode: ColorMode,
539) -> Result<String, RenderError> {
540 let mut highlighter = SpanHighlighter::new()?;
541 highlight_to_ansi_with_highlighter_and_mode(&mut highlighter, source, flavor, theme, color_mode)
542}
543
544pub fn highlight_to_ansi_with_highlighter_and_mode(
550 highlighter: &mut SpanHighlighter,
551 source: &[u8],
552 flavor: Grammar,
553 theme: &Theme,
554 color_mode: ColorMode,
555) -> Result<String, RenderError> {
556 let highlight = highlighter.highlight(source, flavor)?;
557 let styled = resolve_styled_spans_for_source(source.len(), &highlight, theme)?;
558 render_ansi_with_mode(source, &styled, color_mode)
559}
560
561pub fn highlight_lines_to_ansi_lines<S: AsRef<str>>(
569 lines: &[S],
570 flavor: Grammar,
571 theme: &Theme,
572) -> Result<Vec<String>, RenderError> {
573 let mut highlighter = SpanHighlighter::new()?;
574 highlight_lines_to_ansi_lines_with_highlighter_and_mode(
575 &mut highlighter,
576 lines,
577 flavor,
578 theme,
579 ColorMode::TrueColor,
580 )
581}
582
583pub fn highlight_lines_to_ansi_lines_with_highlighter<S: AsRef<str>>(
589 highlighter: &mut SpanHighlighter,
590 lines: &[S],
591 flavor: Grammar,
592 theme: &Theme,
593) -> Result<Vec<String>, RenderError> {
594 highlight_lines_to_ansi_lines_with_highlighter_and_mode(
595 highlighter,
596 lines,
597 flavor,
598 theme,
599 ColorMode::TrueColor,
600 )
601}
602
603pub fn highlight_lines_to_ansi_lines_with_mode<S: AsRef<str>>(
611 lines: &[S],
612 flavor: Grammar,
613 theme: &Theme,
614 color_mode: ColorMode,
615) -> Result<Vec<String>, RenderError> {
616 let mut highlighter = SpanHighlighter::new()?;
617 highlight_lines_to_ansi_lines_with_highlighter_and_mode(
618 &mut highlighter,
619 lines,
620 flavor,
621 theme,
622 color_mode,
623 )
624}
625
626pub fn highlight_lines_to_ansi_lines_with_highlighter_and_mode<S: AsRef<str>>(
632 highlighter: &mut SpanHighlighter,
633 lines: &[S],
634 flavor: Grammar,
635 theme: &Theme,
636 color_mode: ColorMode,
637) -> Result<Vec<String>, RenderError> {
638 let highlight = highlighter.highlight_lines(lines, flavor)?;
639 let source = lines
640 .iter()
641 .map(AsRef::as_ref)
642 .collect::<Vec<_>>()
643 .join("\n");
644 let styled = resolve_styled_spans_for_source(source.len(), &highlight, theme)?;
645 render_ansi_lines_with_mode(source.as_bytes(), &styled, color_mode)
646}
647
648fn clip_lines_to_viewport(
650 lines: &[Vec<StyledCell>],
651 width: usize,
652 height: usize,
653) -> Vec<Vec<StyledCell>> {
654 lines
655 .iter()
656 .take(height)
657 .map(|line| line.iter().take(width).cloned().collect::<Vec<_>>())
658 .collect::<Vec<_>>()
659}
660
661fn build_styled_cells(
665 source: &[u8],
666 spans: &[StyledSpan],
667 width: usize,
668 height: usize,
669) -> Vec<Vec<StyledCell>> {
670 let mut lines = Vec::new();
671 let mut line = Vec::new();
672 let mut line_display_width = 0usize;
673 let mut span_cursor = 0usize;
674
675 let rendered = String::from_utf8_lossy(source);
676 for (byte_idx, grapheme) in rendered.grapheme_indices(true) {
677 while span_cursor < spans.len() && spans[span_cursor].end_byte <= byte_idx {
678 span_cursor += 1;
679 }
680
681 let style = if let Some(span) = spans.get(span_cursor) {
682 if byte_idx >= span.start_byte && byte_idx < span.end_byte {
683 span.style
684 } else {
685 None
686 }
687 } else {
688 None
689 };
690
691 if grapheme == "\n" {
692 lines.push(line);
693 if lines.len() >= height {
694 return lines;
695 }
696 line = Vec::new();
697 line_display_width = 0;
698 continue;
699 }
700
701 let cell_width = display_width_for_grapheme(grapheme, line_display_width);
702 if line_display_width + cell_width <= width || cell_width == 0 {
703 line.push(StyledCell {
704 text: grapheme.to_string(),
705 style,
706 width: cell_width,
707 });
708 line_display_width += cell_width;
709 }
710 }
711
712 lines.push(line);
713 lines.truncate(height);
714 lines
715}
716
717fn build_styled_line_cells(source: &[u8], spans: &[StyledSpan]) -> Vec<StyledCell> {
719 let mut line = Vec::new();
720 let mut line_display_width = 0usize;
721 let mut span_cursor = 0usize;
722
723 let rendered = String::from_utf8_lossy(source);
724 for (byte_idx, grapheme) in rendered.grapheme_indices(true) {
725 while span_cursor < spans.len() && spans[span_cursor].end_byte <= byte_idx {
726 span_cursor += 1;
727 }
728
729 let style = if let Some(span) = spans.get(span_cursor) {
730 if byte_idx >= span.start_byte && byte_idx < span.end_byte {
731 span.style
732 } else {
733 None
734 }
735 } else {
736 None
737 };
738
739 if grapheme == "\n" {
740 break;
741 }
742
743 let cell_width = display_width_for_grapheme(grapheme, line_display_width);
744 line.push(StyledCell {
745 text: grapheme.to_string(),
746 style,
747 width: cell_width,
748 });
749 line_display_width = line_display_width.saturating_add(cell_width);
750 }
751
752 line
753}
754
755fn display_width_for_grapheme(grapheme: &str, line_display_width: usize) -> usize {
757 if grapheme == "\t" {
758 return tab_width_at(line_display_width, TAB_STOP);
759 }
760 UnicodeWidthStr::width(grapheme)
761}
762
763fn tab_width_at(display_col: usize, tab_stop: usize) -> usize {
765 let stop = tab_stop.max(1);
766 let remainder = display_col % stop;
767 if remainder == 0 {
768 stop
769 } else {
770 stop - remainder
771 }
772}
773
774fn diff_lines_to_patch(
779 prev_lines: &[Vec<StyledCell>],
780 curr_lines: &[Vec<StyledCell>],
781 origin_row: usize,
782 origin_col: usize,
783 color_mode: ColorMode,
784) -> String {
785 let mut out = String::new();
786 let line_count = prev_lines.len().max(curr_lines.len());
787 let origin_row0 = origin_row.saturating_sub(1);
788 let origin_col0 = origin_col.saturating_sub(1);
789
790 for row in 0..line_count {
791 let prev = prev_lines.get(row).map(Vec::as_slice).unwrap_or(&[]);
792 let curr = curr_lines.get(row).map(Vec::as_slice).unwrap_or(&[]);
793
794 let Some(first_diff) = first_diff_index(prev, curr) else {
795 continue;
796 };
797
798 let diff_col = display_columns_before(curr, first_diff) + 1;
799 let absolute_row = origin_row0 + row + 1;
800 let absolute_col = origin_col0 + diff_col;
801 write_cup(&mut out, absolute_row, absolute_col);
802 append_styled_cells(&mut out, &curr[first_diff..], color_mode);
803
804 if curr.len() < prev.len() {
805 out.push_str(EL_TO_END);
806 }
807 }
808
809 out
810}
811
812fn diff_single_line_to_patch(
816 prev_line: &[StyledCell],
817 curr_line: &[StyledCell],
818 color_mode: ColorMode,
819) -> String {
820 let mut out = String::new();
821 let Some(first_diff) = first_diff_index(prev_line, curr_line) else {
822 return out;
823 };
824
825 let prev_width = display_columns_before(prev_line, prev_line.len());
826 let prefix_width = display_columns_before(prev_line, first_diff);
827 let cols_back = prev_width.saturating_sub(prefix_width);
828 write_cub(&mut out, cols_back);
829 append_styled_cells(&mut out, &curr_line[first_diff..], color_mode);
830 if curr_line.len() < prev_line.len() {
831 out.push_str(EL_TO_END);
832 }
833 out
834}
835
836fn display_columns_before(cells: &[StyledCell], idx: usize) -> usize {
838 cells.iter().take(idx).map(|cell| cell.width).sum::<usize>()
839}
840
841fn first_diff_index(prev: &[StyledCell], curr: &[StyledCell]) -> Option<usize> {
843 let shared = prev.len().min(curr.len());
844 for idx in 0..shared {
845 if prev[idx] != curr[idx] {
846 return Some(idx);
847 }
848 }
849
850 if prev.len() != curr.len() {
851 return Some(shared);
852 }
853
854 None
855}
856
857fn write_cup(out: &mut String, row: usize, col: usize) {
859 let _ = write!(out, "{CSI}{row};{col}H");
860}
861
862fn write_cub(out: &mut String, cols: usize) {
864 if cols == 0 {
865 return;
866 }
867 let _ = write!(out, "{CSI}{cols}D");
868}
869
870fn append_styled_cells(out: &mut String, cells: &[StyledCell], color_mode: ColorMode) {
872 if cells.is_empty() {
873 return;
874 }
875
876 let mut active_style = None;
877 for cell in cells {
878 write_style_transition(out, active_style, cell.style, color_mode);
879 out.push_str(&cell.text);
880 active_style = cell.style;
881 }
882
883 if active_style.is_some() {
884 out.push_str(SGR_RESET);
885 }
886}
887
888fn write_style_transition(
890 out: &mut String,
891 previous: Option<Style>,
892 next: Option<Style>,
893 color_mode: ColorMode,
894) {
895 if previous == next {
896 return;
897 }
898
899 match (previous, next) {
900 (None, None) => {}
901 (Some(_), None) => out.push_str(SGR_RESET),
902 (None, Some(style)) => {
903 if let Some(open) = style_open_sgr(Some(style), color_mode) {
904 out.push_str(&open);
905 }
906 }
907 (Some(_), Some(style)) => {
908 out.push_str(SGR_RESET);
909 if let Some(open) = style_open_sgr(Some(style), color_mode) {
910 out.push_str(&open);
911 }
912 }
913 }
914}
915
916fn append_styled_segment(
918 out: &mut String,
919 text: &[u8],
920 style: Option<Style>,
921 color_mode: ColorMode,
922) {
923 if text.is_empty() {
924 return;
925 }
926
927 if let Some(open) = style_open_sgr(style, color_mode) {
928 out.push_str(&open);
929 out.push_str(&String::from_utf8_lossy(text));
930 out.push_str(SGR_RESET);
931 return;
932 }
933
934 out.push_str(&String::from_utf8_lossy(text));
935}
936
937fn style_open_sgr(style: Option<Style>, color_mode: ColorMode) -> Option<String> {
941 let style = style?;
942 let mut parts = Vec::new();
943 if let Some(fg) = style.fg {
944 let sgr = match color_mode {
945 ColorMode::TrueColor => format!("38;2;{};{};{}", fg.r, fg.g, fg.b),
946 ColorMode::Ansi256 => format!("38;5;{}", rgb_to_ansi256(fg.r, fg.g, fg.b)),
947 ColorMode::Ansi16 => format!("{}", ansi16_fg_sgr(rgb_to_ansi16(fg.r, fg.g, fg.b))),
948 };
949 parts.push(sgr);
950 }
951 if style.bold {
952 parts.push("1".to_string());
953 }
954 if style.italic {
955 parts.push("3".to_string());
956 }
957 if style.underline {
958 parts.push("4".to_string());
959 }
960
961 if parts.is_empty() {
962 return None;
963 }
964
965 Some(format!("\x1b[{}m", parts.join(";")))
966}
967
968fn rgb_to_ansi256(r: u8, g: u8, b: u8) -> u8 {
970 let (r_idx, r_level) = nearest_ansi_level(r);
971 let (g_idx, g_level) = nearest_ansi_level(g);
972 let (b_idx, b_level) = nearest_ansi_level(b);
973
974 let cube_index = 16 + (36 * r_idx) + (6 * g_idx) + b_idx;
975 let cube_distance = squared_distance((r, g, b), (r_level, g_level, b_level));
976
977 let gray_index = (((i32::from(r) + i32::from(g) + i32::from(b)) / 3 - 8 + 5) / 10).clamp(0, 23);
978 let gray_level = (8 + gray_index * 10) as u8;
979 let gray_distance = squared_distance((r, g, b), (gray_level, gray_level, gray_level));
980
981 if gray_distance < cube_distance {
982 232 + gray_index as u8
983 } else {
984 cube_index as u8
985 }
986}
987
988fn rgb_to_ansi16(r: u8, g: u8, b: u8) -> usize {
993 let rf = f32::from(r) / 255.0;
994 let gf = f32::from(g) / 255.0;
995 let bf = f32::from(b) / 255.0;
996
997 let max = rf.max(gf).max(bf);
998 let min = rf.min(gf).min(bf);
999 let delta = max - min;
1000
1001 if delta < 0.08 || (max > 0.0 && (delta / max) < 0.18) {
1003 return if max < 0.20 {
1004 0
1005 } else if max < 0.45 {
1006 8
1007 } else if max < 0.80 {
1008 7
1009 } else {
1010 15
1011 };
1012 }
1013
1014 let mut hue = if (max - rf).abs() < f32::EPSILON {
1015 60.0 * ((gf - bf) / delta).rem_euclid(6.0)
1016 } else if (max - gf).abs() < f32::EPSILON {
1017 60.0 * (((bf - rf) / delta) + 2.0)
1018 } else {
1019 60.0 * (((rf - gf) / delta) + 4.0)
1020 };
1021 if hue < 0.0 {
1022 hue += 360.0;
1023 }
1024
1025 let base = if !(30.0..330.0).contains(&hue) {
1026 1 } else if hue < 90.0 {
1028 3 } else if hue < 150.0 {
1030 2 } else if hue < 210.0 {
1032 6 } else if hue < 270.0 {
1034 4 } else {
1036 5 };
1038
1039 let bright = max >= 0.62;
1041 if bright {
1042 base + 8
1043 } else {
1044 base
1045 }
1046}
1047
1048fn ansi16_fg_sgr(index: usize) -> u8 {
1050 if index < 8 {
1051 30 + index as u8
1052 } else {
1053 90 + (index as u8 - 8)
1054 }
1055}
1056
1057fn nearest_ansi_level(value: u8) -> (usize, u8) {
1059 let mut best_idx = 0usize;
1060 let mut best_diff = i16::MAX;
1061 for (idx, level) in ANSI_256_LEVELS.iter().enumerate() {
1062 let diff = (i16::from(value) - i16::from(*level)).abs();
1063 if diff < best_diff {
1064 best_diff = diff;
1065 best_idx = idx;
1066 }
1067 }
1068 (best_idx, ANSI_256_LEVELS[best_idx])
1069}
1070
1071fn squared_distance(lhs: (u8, u8, u8), rhs: (u8, u8, u8)) -> i32 {
1073 let dr = i32::from(lhs.0) - i32::from(rhs.0);
1074 let dg = i32::from(lhs.1) - i32::from(rhs.1);
1075 let db = i32::from(lhs.2) - i32::from(rhs.2);
1076 (dr * dr) + (dg * dg) + (db * db)
1077}
1078
1079fn compute_line_ranges(source: &[u8]) -> Vec<(usize, usize)> {
1081 let mut ranges = Vec::new();
1082 let mut line_start = 0usize;
1083 for (i, byte) in source.iter().enumerate() {
1084 if *byte == b'\n' {
1085 ranges.push((line_start, i));
1086 line_start = i + 1;
1087 }
1088 }
1089 ranges.push((line_start, source.len()));
1090 ranges
1091}
1092
1093fn validate_spans(source_len: usize, spans: &[StyledSpan]) -> Result<(), RenderError> {
1100 let mut prev_end = 0usize;
1101 for (i, span) in spans.iter().enumerate() {
1102 if span.start_byte > span.end_byte || span.end_byte > source_len {
1103 return Err(RenderError::SpanOutOfBounds {
1104 start_byte: span.start_byte,
1105 end_byte: span.end_byte,
1106 source_len,
1107 });
1108 }
1109 if i > 0 && span.start_byte < prev_end {
1110 return Err(RenderError::OverlappingSpans {
1111 prev_end,
1112 next_start: span.start_byte,
1113 });
1114 }
1115 prev_end = span.end_byte;
1116 }
1117 Ok(())
1118}
1119
1120#[cfg(test)]
1121mod tests {
1122 use super::{
1123 highlight_lines_to_ansi_lines, highlight_to_ansi, render_ansi, render_ansi_lines,
1124 render_ansi_with_mode, resolve_styled_spans, resolve_styled_spans_for_source, ColorMode,
1125 IncrementalRenderer, RenderError, StreamLineRenderer, StyledSpan,
1126 };
1127 use highlight_spans::{Attr, Grammar, HighlightResult, Span, SpanHighlighter};
1128 use theme_engine::{load_theme, Rgb, Style, Theme};
1129
1130 #[test]
1131 fn renders_basic_styled_segment() {
1133 let source = b"abc";
1134 let spans = [StyledSpan {
1135 start_byte: 1,
1136 end_byte: 2,
1137 style: Some(Style {
1138 fg: Some(Rgb::new(255, 0, 0)),
1139 bold: true,
1140 ..Style::default()
1141 }),
1142 }];
1143 let out = render_ansi(source, &spans).expect("failed to render");
1144 assert_eq!(out, "a\x1b[38;2;255;0;0;1mb\x1b[0mc");
1145 }
1146
1147 #[test]
1148 fn stream_line_renderer_emits_initial_line() {
1150 let mut renderer = StreamLineRenderer::new();
1151 let patch = renderer
1152 .render_line_patch(b"hello", &[])
1153 .expect("initial stream patch failed");
1154 assert_eq!(patch, "hello");
1155 }
1156
1157 #[test]
1158 fn stream_line_renderer_emits_suffix_with_backtracking() {
1160 let mut renderer = StreamLineRenderer::new();
1161 let _ = renderer
1162 .render_line_patch(b"hello", &[])
1163 .expect("initial stream patch failed");
1164 let patch = renderer
1165 .render_line_patch(b"heLlo", &[])
1166 .expect("delta stream patch failed");
1167 assert_eq!(patch, "\x1b[3DLlo");
1168 }
1169
1170 #[test]
1171 fn stream_line_renderer_clears_removed_tail() {
1173 let mut renderer = StreamLineRenderer::new();
1174 let _ = renderer
1175 .render_line_patch(b"hello", &[])
1176 .expect("initial stream patch failed");
1177 let patch = renderer
1178 .render_line_patch(b"he", &[])
1179 .expect("delta stream patch failed");
1180 assert_eq!(patch, "\x1b[3D\x1b[K");
1181 }
1182
1183 #[test]
1184 fn stream_line_renderer_is_noop_when_unchanged() {
1186 let mut renderer = StreamLineRenderer::new();
1187 let _ = renderer
1188 .render_line_patch(b"hello", &[])
1189 .expect("initial stream patch failed");
1190 let patch = renderer
1191 .render_line_patch(b"hello", &[])
1192 .expect("delta stream patch failed");
1193 assert!(patch.is_empty());
1194 }
1195
1196 #[test]
1197 fn stream_line_renderer_rejects_multiline_input() {
1199 let mut renderer = StreamLineRenderer::new();
1200 let err = renderer
1201 .render_line_patch(b"hello\nworld", &[])
1202 .expect_err("expected multiline rejection");
1203 assert!(matches!(err, RenderError::MultiLineInput));
1204 }
1205
1206 #[test]
1207 fn stream_line_renderer_uses_display_width_for_wide_graphemes() {
1209 let mut renderer = StreamLineRenderer::new();
1210 let _ = renderer
1211 .render_line_patch("a界!".as_bytes(), &[])
1212 .expect("initial stream patch failed");
1213 let patch = renderer
1214 .render_line_patch("a界?".as_bytes(), &[])
1215 .expect("delta stream patch failed");
1216 assert_eq!(patch, "\x1b[1D?");
1217 }
1218
1219 #[test]
1220 fn renders_ansi256_styled_segment() {
1222 let source = b"abc";
1223 let spans = [StyledSpan {
1224 start_byte: 1,
1225 end_byte: 2,
1226 style: Some(Style {
1227 fg: Some(Rgb::new(255, 0, 0)),
1228 bold: true,
1229 ..Style::default()
1230 }),
1231 }];
1232 let out =
1233 render_ansi_with_mode(source, &spans, ColorMode::Ansi256).expect("failed to render");
1234 assert_eq!(out, "a\x1b[38;5;196;1mb\x1b[0mc");
1235 }
1236
1237 #[test]
1238 fn renders_ansi16_styled_segment() {
1240 let source = b"abc";
1241 let spans = [StyledSpan {
1242 start_byte: 1,
1243 end_byte: 2,
1244 style: Some(Style {
1245 fg: Some(Rgb::new(255, 0, 0)),
1246 bold: true,
1247 ..Style::default()
1248 }),
1249 }];
1250 let out =
1251 render_ansi_with_mode(source, &spans, ColorMode::Ansi16).expect("failed to render");
1252 assert_eq!(out, "a\x1b[91;1mb\x1b[0mc");
1253 }
1254
1255 #[test]
1256 fn renders_ansi16_preserves_non_gray_hue() {
1258 let source = b"abc";
1259 let spans = [StyledSpan {
1260 start_byte: 1,
1261 end_byte: 2,
1262 style: Some(Style {
1263 fg: Some(Rgb::new(130, 170, 255)),
1264 ..Style::default()
1265 }),
1266 }];
1267 let out =
1268 render_ansi_with_mode(source, &spans, ColorMode::Ansi16).expect("failed to render");
1269 assert!(
1270 out.contains("\x1b[94m"),
1271 "expected bright blue ANSI16 code, got {out:?}"
1272 );
1273 }
1274
1275 #[test]
1276 fn renders_per_line_output_for_multiline_span() {
1278 let source = b"ab\ncd";
1279 let spans = [StyledSpan {
1280 start_byte: 1,
1281 end_byte: 5,
1282 style: Some(Style {
1283 fg: Some(Rgb::new(1, 2, 3)),
1284 ..Style::default()
1285 }),
1286 }];
1287
1288 let lines = render_ansi_lines(source, &spans).expect("failed to render lines");
1289 assert_eq!(lines.len(), 2);
1290 assert_eq!(lines[0], "a\x1b[38;2;1;2;3mb\x1b[0m");
1291 assert_eq!(lines[1], "\x1b[38;2;1;2;3mcd\x1b[0m");
1292 }
1293
1294 #[test]
1295 fn rejects_overlapping_spans() {
1297 let spans = [
1298 StyledSpan {
1299 start_byte: 0,
1300 end_byte: 2,
1301 style: None,
1302 },
1303 StyledSpan {
1304 start_byte: 1,
1305 end_byte: 3,
1306 style: None,
1307 },
1308 ];
1309 let err = render_ansi(b"abcd", &spans).expect_err("expected overlap error");
1310 assert!(matches!(err, RenderError::OverlappingSpans { .. }));
1311 }
1312
1313 #[test]
1314 fn highlights_source_to_ansi() {
1316 let theme = Theme::from_json_str(
1317 r#"{
1318 "styles": {
1319 "normal": { "fg": { "r": 220, "g": 220, "b": 220 } },
1320 "number": { "fg": { "r": 255, "g": 180, "b": 120 } }
1321 }
1322}"#,
1323 )
1324 .expect("theme parse failed");
1325
1326 let out = highlight_to_ansi(b"set x = 42", Grammar::ObjectScript, &theme)
1327 .expect("highlight+render failed");
1328 assert!(out.contains("42"));
1329 assert!(out.contains("\x1b["));
1330 }
1331
1332 #[test]
1333 fn resolve_styled_spans_inherits_from_normal() {
1335 let theme = Theme::from_json_str(
1336 r#"{
1337 "styles": {
1338 "normal": {
1339 "fg": { "r": 10, "g": 11, "b": 12 },
1340 "bg": { "r": 200, "g": 201, "b": 202 },
1341 "italic": true
1342 },
1343 "keyword": { "fg": { "r": 250, "g": 1, "b": 2 } }
1344 }
1345}"#,
1346 )
1347 .expect("theme parse failed");
1348 let highlight = HighlightResult {
1349 attrs: vec![Attr {
1350 id: 0,
1351 capture_name: "keyword".to_string(),
1352 }],
1353 spans: vec![Span {
1354 attr_id: 0,
1355 start_byte: 0,
1356 end_byte: 3,
1357 }],
1358 };
1359
1360 let styled = resolve_styled_spans(&highlight, &theme).expect("resolve failed");
1361 assert_eq!(styled.len(), 1);
1362 let style = styled[0].style.expect("missing style");
1363 assert_eq!(style.fg, Some(Rgb::new(250, 1, 2)));
1364 assert_eq!(style.bg, Some(Rgb::new(200, 201, 202)));
1365 assert!(style.italic);
1366 }
1367
1368 #[test]
1369 fn resolve_styled_spans_for_source_fills_uncovered_ranges() {
1371 let theme = Theme::from_json_str(
1372 r#"{
1373 "styles": {
1374 "normal": {
1375 "fg": { "r": 1, "g": 2, "b": 3 },
1376 "bg": { "r": 4, "g": 5, "b": 6 }
1377 },
1378 "keyword": { "fg": { "r": 7, "g": 8, "b": 9 } }
1379 }
1380}"#,
1381 )
1382 .expect("theme parse failed");
1383 let highlight = HighlightResult {
1384 attrs: vec![Attr {
1385 id: 0,
1386 capture_name: "keyword".to_string(),
1387 }],
1388 spans: vec![Span {
1389 attr_id: 0,
1390 start_byte: 1,
1391 end_byte: 2,
1392 }],
1393 };
1394
1395 let styled =
1396 resolve_styled_spans_for_source(4, &highlight, &theme).expect("resolve failed");
1397 assert_eq!(styled.len(), 3);
1398 assert_eq!(
1399 styled[0],
1400 StyledSpan {
1401 start_byte: 0,
1402 end_byte: 1,
1403 style: Some(Style {
1404 fg: Some(Rgb::new(1, 2, 3)),
1405 bg: Some(Rgb::new(4, 5, 6)),
1406 ..Style::default()
1407 }),
1408 }
1409 );
1410 assert_eq!(
1411 styled[1],
1412 StyledSpan {
1413 start_byte: 1,
1414 end_byte: 2,
1415 style: Some(Style {
1416 fg: Some(Rgb::new(7, 8, 9)),
1417 bg: Some(Rgb::new(4, 5, 6)),
1418 ..Style::default()
1419 }),
1420 }
1421 );
1422 assert_eq!(
1423 styled[2],
1424 StyledSpan {
1425 start_byte: 2,
1426 end_byte: 4,
1427 style: Some(Style {
1428 fg: Some(Rgb::new(1, 2, 3)),
1429 bg: Some(Rgb::new(4, 5, 6)),
1430 ..Style::default()
1431 }),
1432 }
1433 );
1434 }
1435
1436 #[test]
1437 fn highlights_lines_to_ansi_lines() {
1439 let theme = load_theme("tokyo-night").expect("failed to load built-in theme");
1440 let lines = vec!["set x = 1", "set y = 2"];
1441 let rendered = highlight_lines_to_ansi_lines(&lines, Grammar::ObjectScript, &theme)
1442 .expect("failed to highlight lines");
1443 assert_eq!(rendered.len(), 2);
1444 }
1445
1446 #[test]
1447 fn incremental_renderer_emits_only_changed_line_suffix() {
1449 let mut renderer = IncrementalRenderer::new(120, 40);
1450 let first = renderer
1451 .render_patch(b"abc\nxyz", &[])
1452 .expect("first patch failed");
1453 assert!(first.contains("\x1b[1;1Habc"));
1454 assert!(first.contains("\x1b[2;1Hxyz"));
1455
1456 let second = renderer
1457 .render_patch(b"abc\nxYz", &[])
1458 .expect("second patch failed");
1459 assert_eq!(second, "\x1b[2;2HYz");
1460
1461 let third = renderer
1462 .render_patch(b"abc\nxYz", &[])
1463 .expect("third patch failed");
1464 assert!(third.is_empty());
1465 }
1466
1467 #[test]
1468 fn incremental_renderer_applies_origin_offset() {
1470 let mut renderer = IncrementalRenderer::new(120, 40);
1471 renderer.set_origin(4, 7);
1472
1473 let first = renderer
1474 .render_patch(b"abc", &[])
1475 .expect("first patch failed");
1476 assert_eq!(first, "\x1b[4;7Habc");
1477
1478 let second = renderer
1479 .render_patch(b"abC", &[])
1480 .expect("second patch failed");
1481 assert_eq!(second, "\x1b[4;9HC");
1482 }
1483
1484 #[test]
1485 fn incremental_renderer_supports_ansi256_mode() {
1487 let mut renderer = IncrementalRenderer::new(120, 40);
1488 renderer.set_color_mode(ColorMode::Ansi256);
1489 let spans = [StyledSpan {
1490 start_byte: 0,
1491 end_byte: 2,
1492 style: Some(Style {
1493 fg: Some(Rgb::new(255, 0, 0)),
1494 ..Style::default()
1495 }),
1496 }];
1497
1498 let patch = renderer
1499 .render_patch(b"ab", &spans)
1500 .expect("patch generation failed");
1501 assert!(patch.contains("\x1b[38;5;196m"));
1502 assert!(!patch.contains("38;2;"));
1503 }
1504
1505 #[test]
1506 fn incremental_renderer_supports_ansi16_mode() {
1508 let mut renderer = IncrementalRenderer::new(120, 40);
1509 renderer.set_color_mode(ColorMode::Ansi16);
1510 let spans = [StyledSpan {
1511 start_byte: 0,
1512 end_byte: 2,
1513 style: Some(Style {
1514 fg: Some(Rgb::new(255, 0, 0)),
1515 ..Style::default()
1516 }),
1517 }];
1518
1519 let patch = renderer
1520 .render_patch(b"ab", &spans)
1521 .expect("patch generation failed");
1522 assert!(patch.contains("\x1b[91m"));
1523 assert!(!patch.contains("38;2;"));
1524 assert!(!patch.contains("38;5;"));
1525 }
1526
1527 #[test]
1528 fn incremental_renderer_uses_display_width_for_wide_graphemes() {
1530 let mut renderer = IncrementalRenderer::new(120, 40);
1531 let _ = renderer
1532 .render_patch("a界".as_bytes(), &[])
1533 .expect("first patch failed");
1534
1535 let patch = renderer
1536 .render_patch("a界!".as_bytes(), &[])
1537 .expect("second patch failed");
1538 assert_eq!(patch, "\x1b[1;4H!");
1539 }
1540
1541 #[test]
1542 fn incremental_renderer_uses_display_width_for_tabs() {
1544 let mut renderer = IncrementalRenderer::new(120, 40);
1545 let _ = renderer
1546 .render_patch(b"a\tb", &[])
1547 .expect("first patch failed");
1548
1549 let patch = renderer
1550 .render_patch(b"a\tB", &[])
1551 .expect("second patch failed");
1552 assert_eq!(patch, "\x1b[1;9HB");
1553 }
1554
1555 #[test]
1556 fn incremental_renderer_clears_removed_tail() {
1558 let mut renderer = IncrementalRenderer::new(120, 40);
1559 let _ = renderer
1560 .render_patch(b"hello", &[])
1561 .expect("first patch failed");
1562
1563 let patch = renderer
1564 .render_patch(b"he", &[])
1565 .expect("second patch failed");
1566 assert_eq!(patch, "\x1b[1;3H\x1b[K");
1567 }
1568
1569 #[test]
1570 fn incremental_renderer_supports_highlight_pipeline() {
1572 let theme = Theme::from_json_str(
1573 r#"{
1574 "styles": {
1575 "normal": { "fg": { "r": 220, "g": 220, "b": 220 } },
1576 "keyword": { "fg": { "r": 255, "g": 0, "b": 0 } }
1577 }
1578}"#,
1579 )
1580 .expect("theme parse failed");
1581 let mut highlighter = SpanHighlighter::new().expect("highlighter init failed");
1582 let mut renderer = IncrementalRenderer::new(120, 40);
1583
1584 let patch = renderer
1585 .highlight_to_patch(&mut highlighter, b"SELECT 1", Grammar::Sql, &theme)
1586 .expect("highlight patch failed");
1587 assert!(patch.contains("\x1b[1;1H"));
1588 assert!(patch.contains("SELECT"));
1589 }
1590}