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 #[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 #[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 #[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 #[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 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 pub fn clear_state(&mut self) {
107 self.prev_lines.clear();
108 }
109
110 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 #[must_use]
121 pub fn origin(&self) -> (usize, usize) {
122 (self.origin_row, self.origin_col)
123 }
124
125 pub fn set_color_mode(&mut self, color_mode: ColorMode) {
127 self.color_mode = color_mode;
128 }
129
130 #[must_use]
132 pub fn color_mode(&self) -> ColorMode {
133 self.color_mode
134 }
135
136 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 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 #[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 #[must_use]
202 pub fn session_count(&self) -> usize {
203 self.sessions.len()
204 }
205
206 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 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 pub fn remove_session(&mut self, session_id: &str) -> bool {
227 self.sessions.remove(session_id).is_some()
228 }
229
230 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 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 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
289pub 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
320fn 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
334fn 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
353fn 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
391pub fn render_ansi(source: &[u8], spans: &[StyledSpan]) -> Result<String, RenderError> {
397 render_ansi_with_mode(source, spans, ColorMode::TrueColor)
398}
399
400pub 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
434pub fn render_ansi_lines(source: &[u8], spans: &[StyledSpan]) -> Result<Vec<String>, RenderError> {
442 render_ansi_lines_with_mode(source, spans, ColorMode::TrueColor)
443}
444
445pub 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
502pub 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
524pub 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
544pub 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
561pub 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
578pub 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
600pub 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
620pub 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
643pub 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
665fn 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
678fn 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
734fn 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
742fn 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
753fn 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
791fn display_columns_before(cells: &[StyledCell], idx: usize) -> usize {
793 cells.iter().take(idx).map(|cell| cell.width).sum::<usize>()
794}
795
796fn 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
812fn write_cup(out: &mut String, row: usize, col: usize) {
814 let _ = write!(out, "{CSI}{row};{col}H");
815}
816
817fn 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
835fn 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
863fn 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
884fn 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
915fn 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
935fn 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 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 } else if hue < 90.0 {
975 3 } else if hue < 150.0 {
977 2 } else if hue < 210.0 {
979 6 } else if hue < 270.0 {
981 4 } else {
983 5 };
985
986 let bright = max >= 0.62;
988 if bright {
989 base + 8
990 } else {
991 base
992 }
993}
994
995fn 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
1004fn 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
1018fn 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
1026fn 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
1040fn 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}