Skip to main content

saorsa_core/
renderer.rs

1//! ANSI escape sequence renderer.
2//!
3//! Takes cell changes from the buffer diff and produces terminal output
4//! with minimal escape sequences.
5
6use std::collections::HashMap;
7use std::fmt::Write;
8
9use crate::buffer::CellChange;
10use crate::cell::Cell;
11use crate::color::{Color, NamedColor};
12use crate::style::Style;
13use crate::terminal::ColorSupport;
14
15/// Renders cell changes into ANSI escape sequences.
16pub struct Renderer {
17    color_support: ColorSupport,
18    synchronized_output: bool,
19}
20
21impl Renderer {
22    /// Create a new renderer with the given color support level.
23    pub fn new(color_support: ColorSupport, synchronized_output: bool) -> Self {
24        Self {
25            color_support,
26            synchronized_output,
27        }
28    }
29
30    /// Render a set of cell changes into a string of ANSI escape sequences.
31    pub fn render(&self, changes: &[CellChange]) -> String {
32        if changes.is_empty() {
33            return String::new();
34        }
35
36        let mut output = String::with_capacity(changes.len() * 16);
37
38        // Begin synchronized output if supported
39        if self.synchronized_output {
40            output.push_str("\x1b[?2026h");
41        }
42
43        let mut last_x: Option<u16> = None;
44        let mut last_y: Option<u16> = None;
45        let mut last_style = Style::default();
46        let mut style_active = false;
47
48        for change in changes {
49            // Skip continuation cells — they don't produce output
50            if change.cell.width == 0 {
51                continue;
52            }
53
54            // Cursor positioning: only emit if not already at the right position
55            let need_move = !matches!((last_x, last_y), (Some(lx), Some(ly)) if ly == change.y && lx == change.x);
56            if need_move {
57                // ANSI cursor position is 1-based
58                let _ = write!(output, "\x1b[{};{}H", change.y + 1, change.x + 1);
59            }
60
61            // Style diffing: only emit changed attributes
62            self.write_style_diff(&mut output, &last_style, &change.cell.style, style_active);
63            last_style = change.cell.style.clone();
64            style_active = true;
65
66            // Write the grapheme
67            output.push_str(&change.cell.grapheme);
68
69            // Track cursor position (advances by cell width)
70            last_x = Some(change.x + u16::from(change.cell.width));
71            last_y = Some(change.y);
72        }
73
74        // Reset style at the end
75        if style_active && !last_style.is_empty() {
76            output.push_str("\x1b[0m");
77        }
78
79        // End synchronized output if supported
80        if self.synchronized_output {
81            output.push_str("\x1b[?2026l");
82        }
83
84        output
85    }
86
87    /// Render cell changes using batched output for fewer escape sequences.
88    ///
89    /// Groups consecutive same-row cells into [`DeltaBatch`]es, then renders
90    /// each batch with a single cursor-move and minimal style transitions.
91    /// This can produce shorter output than [`render`](Self::render) when
92    /// many adjacent cells change.
93    pub fn render_batched(&self, changes: &[CellChange]) -> String {
94        let batches = batch_changes(changes);
95        if batches.is_empty() {
96            return String::new();
97        }
98
99        let mut output = String::with_capacity(changes.len() * 12);
100
101        if self.synchronized_output {
102            output.push_str("\x1b[?2026h");
103        }
104
105        let mut last_style = Style::default();
106        let mut style_active = false;
107        let mut last_cursor_x: Option<u16> = None;
108        let mut last_cursor_y: Option<u16> = None;
109
110        for batch in &batches {
111            // Emit cursor move if not already at the right position
112            let need_move = !matches!(
113                (last_cursor_x, last_cursor_y),
114                (Some(lx), Some(ly)) if ly == batch.y && lx == batch.x
115            );
116            if need_move {
117                let _ = write!(output, "\x1b[{};{}H", batch.y + 1, batch.x + 1);
118            }
119
120            let mut cursor_x = batch.x;
121            for cell in &batch.cells {
122                self.write_style_diff(&mut output, &last_style, &cell.style, style_active);
123                last_style = cell.style.clone();
124                style_active = true;
125
126                output.push_str(&cell.grapheme);
127                cursor_x += u16::from(cell.width);
128            }
129
130            last_cursor_x = Some(cursor_x);
131            last_cursor_y = Some(batch.y);
132        }
133
134        if style_active && !last_style.is_empty() {
135            output.push_str("\x1b[0m");
136        }
137
138        if self.synchronized_output {
139            output.push_str("\x1b[?2026l");
140        }
141
142        output
143    }
144
145    /// Render cell changes with cursor hidden and combined SGR sequences.
146    ///
147    /// This variant wraps the output with cursor-hide (`\x1b[?25l`) at the
148    /// start and cursor-show (`\x1b[?25h`) at the end, which prevents cursor
149    /// flicker during rendering. Style attributes are emitted as combined SGR
150    /// sequences (e.g. `\x1b[1;3;31m` instead of separate codes).
151    pub fn render_optimized(&self, changes: &[CellChange]) -> String {
152        if changes.is_empty() {
153            return String::new();
154        }
155
156        let mut output = String::with_capacity(changes.len() * 16);
157
158        // Hide cursor during rendering
159        output.push_str("\x1b[?25l");
160
161        // Begin synchronized output if supported
162        if self.synchronized_output {
163            output.push_str("\x1b[?2026h");
164        }
165
166        let mut last_x: Option<u16> = None;
167        let mut last_y: Option<u16> = None;
168        let mut last_style = Style::default();
169        let mut style_active = false;
170
171        for change in changes {
172            // Skip continuation cells
173            if change.cell.width == 0 {
174                continue;
175            }
176
177            // Cursor positioning
178            let need_move = !matches!((last_x, last_y), (Some(lx), Some(ly)) if ly == change.y && lx == change.x);
179            if need_move {
180                let _ = write!(output, "\x1b[{};{}H", change.y + 1, change.x + 1);
181            }
182
183            // Use combined SGR sequences for efficient style changes
184            if !style_active
185                || needs_reset(&last_style, &change.cell.style)
186                || last_style != change.cell.style
187            {
188                if style_active && !last_style.is_empty() {
189                    output.push_str("\x1b[0m");
190                }
191                let sgr = build_sgr_sequence(&change.cell.style, self.color_support);
192                output.push_str(&sgr);
193            }
194
195            last_style = change.cell.style.clone();
196            style_active = true;
197
198            output.push_str(&change.cell.grapheme);
199
200            last_x = Some(change.x + u16::from(change.cell.width));
201            last_y = Some(change.y);
202        }
203
204        // Reset style at the end
205        if style_active && !last_style.is_empty() {
206            output.push_str("\x1b[0m");
207        }
208
209        // End synchronized output if supported
210        if self.synchronized_output {
211            output.push_str("\x1b[?2026l");
212        }
213
214        // Show cursor again
215        output.push_str("\x1b[?25h");
216
217        output
218    }
219
220    /// Write the minimal SGR sequence to transition from `prev` to `next` style.
221    fn write_style_diff(&self, output: &mut String, prev: &Style, next: &Style, active: bool) {
222        if !active || needs_reset(prev, next) {
223            // Full reset if we turned off an attribute or if not yet active
224            if active && !prev.is_empty() {
225                output.push_str("\x1b[0m");
226            }
227            // Apply all attributes from next
228            self.write_full_style(output, next);
229            return;
230        }
231
232        // Incremental: only emit changed attributes
233        if prev.fg != next.fg {
234            self.write_fg(output, &next.fg);
235        }
236        if prev.bg != next.bg {
237            self.write_bg(output, &next.bg);
238        }
239        if !prev.bold && next.bold {
240            output.push_str("\x1b[1m");
241        }
242        if !prev.dim && next.dim {
243            output.push_str("\x1b[2m");
244        }
245        if !prev.italic && next.italic {
246            output.push_str("\x1b[3m");
247        }
248        if !prev.underline && next.underline {
249            output.push_str("\x1b[4m");
250        }
251        if !prev.reverse && next.reverse {
252            output.push_str("\x1b[7m");
253        }
254        if !prev.strikethrough && next.strikethrough {
255            output.push_str("\x1b[9m");
256        }
257    }
258
259    /// Write a full style (all attributes from scratch).
260    fn write_full_style(&self, output: &mut String, style: &Style) {
261        self.write_fg(output, &style.fg);
262        self.write_bg(output, &style.bg);
263        if style.bold {
264            output.push_str("\x1b[1m");
265        }
266        if style.dim {
267            output.push_str("\x1b[2m");
268        }
269        if style.italic {
270            output.push_str("\x1b[3m");
271        }
272        if style.underline {
273            output.push_str("\x1b[4m");
274        }
275        if style.reverse {
276            output.push_str("\x1b[7m");
277        }
278        if style.strikethrough {
279            output.push_str("\x1b[9m");
280        }
281    }
282
283    /// Write a foreground color SGR sequence.
284    fn write_fg(&self, output: &mut String, color: &Option<Color>) {
285        match color {
286            None => {}
287            Some(c) => {
288                let downgraded = self.downgrade_color(c);
289                write_fg_color(output, &downgraded);
290            }
291        }
292    }
293
294    /// Write a background color SGR sequence.
295    fn write_bg(&self, output: &mut String, color: &Option<Color>) {
296        match color {
297            None => {}
298            Some(c) => {
299                let downgraded = self.downgrade_color(c);
300                write_bg_color(output, &downgraded);
301            }
302        }
303    }
304
305    /// Downgrade a color to match the terminal's color support level.
306    ///
307    /// Respects the `NO_COLOR` environment variable per https://no-color.org/
308    fn downgrade_color<'a>(&self, color: &'a Color) -> std::borrow::Cow<'a, Color> {
309        // Check NO_COLOR environment variable
310        if std::env::var("NO_COLOR").is_ok() {
311            return std::borrow::Cow::Owned(Color::Reset);
312        }
313
314        match self.color_support {
315            ColorSupport::TrueColor => std::borrow::Cow::Borrowed(color),
316            ColorSupport::Extended256 => match color {
317                Color::Rgb { r, g, b } => {
318                    std::borrow::Cow::Owned(Color::Indexed(rgb_to_256(*r, *g, *b)))
319                }
320                _ => std::borrow::Cow::Borrowed(color),
321            },
322            ColorSupport::Basic16 => match color {
323                Color::Rgb { r, g, b } => {
324                    std::borrow::Cow::Owned(Color::Named(rgb_to_16(*r, *g, *b)))
325                }
326                Color::Indexed(i) => std::borrow::Cow::Owned(Color::Named(index_to_named(*i))),
327                _ => std::borrow::Cow::Borrowed(color),
328            },
329            ColorSupport::NoColor => std::borrow::Cow::Owned(Color::Reset),
330        }
331    }
332}
333
334/// Check if transitioning from `prev` to `next` requires a full SGR reset.
335/// This is needed when we're turning OFF an attribute (e.g., bold was on, now off).
336fn needs_reset(prev: &Style, next: &Style) -> bool {
337    (prev.bold && !next.bold)
338        || (prev.dim && !next.dim)
339        || (prev.italic && !next.italic)
340        || (prev.underline && !next.underline)
341        || (prev.reverse && !next.reverse)
342        || (prev.strikethrough && !next.strikethrough)
343}
344
345/// Build a single combined SGR sequence for all active attributes of a style.
346///
347/// Instead of emitting separate `\x1b[1m\x1b[3m\x1b[31m` sequences for
348/// bold, italic, and red foreground, this produces a single `\x1b[1;3;31m`.
349/// Returns an empty string if the style has no active attributes.
350pub fn build_sgr_sequence(style: &Style, color_support: ColorSupport) -> String {
351    let mut codes: Vec<String> = Vec::new();
352
353    // Text attributes
354    if style.bold {
355        codes.push("1".to_string());
356    }
357    if style.dim {
358        codes.push("2".to_string());
359    }
360    if style.italic {
361        codes.push("3".to_string());
362    }
363    if style.underline {
364        codes.push("4".to_string());
365    }
366    if style.reverse {
367        codes.push("7".to_string());
368    }
369    if style.strikethrough {
370        codes.push("9".to_string());
371    }
372
373    // Foreground color
374    if let Some(ref fg) = style.fg {
375        let downgraded = downgrade_color_standalone(fg, color_support);
376        codes.extend(fg_color_codes(&downgraded));
377    }
378
379    // Background color
380    if let Some(ref bg) = style.bg {
381        let downgraded = downgrade_color_standalone(bg, color_support);
382        codes.extend(bg_color_codes(&downgraded));
383    }
384
385    if codes.is_empty() {
386        return String::new();
387    }
388
389    format!("\x1b[{}m", codes.join(";"))
390}
391
392/// Downgrade a color to match the given color support level (standalone version).
393///
394/// Respects the `NO_COLOR` environment variable per https://no-color.org/
395fn downgrade_color_standalone(color: &Color, support: ColorSupport) -> Color {
396    // Check NO_COLOR environment variable
397    if std::env::var("NO_COLOR").is_ok() {
398        return Color::Reset;
399    }
400
401    match support {
402        ColorSupport::TrueColor => color.clone(),
403        ColorSupport::Extended256 => match color {
404            Color::Rgb { r, g, b } => Color::Indexed(rgb_to_256(*r, *g, *b)),
405            _ => color.clone(),
406        },
407        ColorSupport::Basic16 => match color {
408            Color::Rgb { r, g, b } => Color::Named(rgb_to_16(*r, *g, *b)),
409            Color::Indexed(i) => Color::Named(index_to_named(*i)),
410            _ => color.clone(),
411        },
412        ColorSupport::NoColor => Color::Reset,
413    }
414}
415
416/// Return the SGR parameter codes for a foreground color (without the ESC[ prefix or m suffix).
417fn fg_color_codes(color: &Color) -> Vec<String> {
418    match color {
419        Color::Rgb { r, g, b } => vec![
420            "38".to_string(),
421            "2".to_string(),
422            r.to_string(),
423            g.to_string(),
424            b.to_string(),
425        ],
426        Color::Indexed(i) => vec!["38".to_string(), "5".to_string(), i.to_string()],
427        Color::Named(n) => vec![named_fg_code(n).to_string()],
428        Color::Reset => vec!["39".to_string()],
429    }
430}
431
432/// Return the SGR parameter codes for a background color (without the ESC[ prefix or m suffix).
433fn bg_color_codes(color: &Color) -> Vec<String> {
434    match color {
435        Color::Rgb { r, g, b } => vec![
436            "48".to_string(),
437            "2".to_string(),
438            r.to_string(),
439            g.to_string(),
440            b.to_string(),
441        ],
442        Color::Indexed(i) => vec!["48".to_string(), "5".to_string(), i.to_string()],
443        Color::Named(n) => vec![named_bg_code(n).to_string()],
444        Color::Reset => vec!["49".to_string()],
445    }
446}
447
448/// Write an SGR foreground color escape sequence.
449fn write_fg_color(output: &mut String, color: &Color) {
450    match color {
451        Color::Rgb { r, g, b } => {
452            let _ = write!(output, "\x1b[38;2;{r};{g};{b}m");
453        }
454        Color::Indexed(i) => {
455            let _ = write!(output, "\x1b[38;5;{i}m");
456        }
457        Color::Named(n) => {
458            let _ = write!(output, "\x1b[{}m", named_fg_code(n));
459        }
460        Color::Reset => {
461            output.push_str("\x1b[39m");
462        }
463    }
464}
465
466/// Write an SGR background color escape sequence.
467fn write_bg_color(output: &mut String, color: &Color) {
468    match color {
469        Color::Rgb { r, g, b } => {
470            let _ = write!(output, "\x1b[48;2;{r};{g};{b}m");
471        }
472        Color::Indexed(i) => {
473            let _ = write!(output, "\x1b[48;5;{i}m");
474        }
475        Color::Named(n) => {
476            let _ = write!(output, "\x1b[{}m", named_bg_code(n));
477        }
478        Color::Reset => {
479            output.push_str("\x1b[49m");
480        }
481    }
482}
483
484/// Get the SGR code for a named foreground color.
485fn named_fg_code(color: &NamedColor) -> u8 {
486    match color {
487        NamedColor::Black => 30,
488        NamedColor::Red => 31,
489        NamedColor::Green => 32,
490        NamedColor::Yellow => 33,
491        NamedColor::Blue => 34,
492        NamedColor::Magenta => 35,
493        NamedColor::Cyan => 36,
494        NamedColor::White => 37,
495        NamedColor::BrightBlack => 90,
496        NamedColor::BrightRed => 91,
497        NamedColor::BrightGreen => 92,
498        NamedColor::BrightYellow => 93,
499        NamedColor::BrightBlue => 94,
500        NamedColor::BrightMagenta => 95,
501        NamedColor::BrightCyan => 96,
502        NamedColor::BrightWhite => 97,
503    }
504}
505
506/// Get the SGR code for a named background color.
507fn named_bg_code(color: &NamedColor) -> u8 {
508    match color {
509        NamedColor::Black => 40,
510        NamedColor::Red => 41,
511        NamedColor::Green => 42,
512        NamedColor::Yellow => 43,
513        NamedColor::Blue => 44,
514        NamedColor::Magenta => 45,
515        NamedColor::Cyan => 46,
516        NamedColor::White => 47,
517        NamedColor::BrightBlack => 100,
518        NamedColor::BrightRed => 101,
519        NamedColor::BrightGreen => 102,
520        NamedColor::BrightYellow => 103,
521        NamedColor::BrightBlue => 104,
522        NamedColor::BrightMagenta => 105,
523        NamedColor::BrightCyan => 106,
524        NamedColor::BrightWhite => 107,
525    }
526}
527
528/// Color mapper with caching for perceptually accurate downgrading.
529///
530/// Uses CIELAB color space for perceptual distance matching, which better
531/// matches human color perception than Euclidean RGB distance.
532#[derive(Debug)]
533pub struct ColorMapper {
534    /// Cache for RGB → 256-color index mappings.
535    cache_256: HashMap<(u8, u8, u8), u8>,
536    /// Cache for RGB → 16-color named mappings.
537    cache_16: HashMap<(u8, u8, u8), NamedColor>,
538}
539
540impl Default for ColorMapper {
541    fn default() -> Self {
542        Self::new()
543    }
544}
545
546impl ColorMapper {
547    /// Create a new color mapper with empty caches.
548    pub fn new() -> Self {
549        Self {
550            cache_256: HashMap::new(),
551            cache_16: HashMap::new(),
552        }
553    }
554
555    /// Map RGB to the nearest 256-color palette index using CIELAB distance.
556    pub fn map_to_256(&mut self, r: u8, g: u8, b: u8) -> u8 {
557        if let Some(&cached) = self.cache_256.get(&(r, g, b)) {
558            return cached;
559        }
560
561        let result = rgb_to_256(r, g, b);
562        self.cache_256.insert((r, g, b), result);
563        result
564    }
565
566    /// Map RGB to the nearest 16-color named color using CIELAB distance.
567    pub fn map_to_16(&mut self, r: u8, g: u8, b: u8) -> NamedColor {
568        if let Some(&cached) = self.cache_16.get(&(r, g, b)) {
569            return cached;
570        }
571
572        let result = rgb_to_16(r, g, b);
573        self.cache_16.insert((r, g, b), result);
574        result
575    }
576
577    /// Clear all cached mappings.
578    pub fn clear_cache(&mut self) {
579        self.cache_256.clear();
580        self.cache_16.clear();
581    }
582}
583
584/// LAB color representation for perceptual distance calculation.
585#[derive(Debug, Clone, Copy)]
586struct Lab {
587    l: f32,
588    a: f32,
589    b: f32,
590}
591
592/// Convert RGB to CIELAB color space using D65 illuminant.
593///
594/// This conversion provides perceptually uniform color space where
595/// Euclidean distance matches human perception of color difference.
596fn rgb_to_lab(r: u8, g: u8, b: u8) -> Lab {
597    // Step 1: RGB to linear RGB
598    let r_linear = srgb_to_linear(r);
599    let g_linear = srgb_to_linear(g);
600    let b_linear = srgb_to_linear(b);
601
602    // Step 2: Linear RGB to XYZ (using D65 illuminant)
603    let x = r_linear * 0.4124 + g_linear * 0.3576 + b_linear * 0.1805;
604    let y = r_linear * 0.2126 + g_linear * 0.7152 + b_linear * 0.0722;
605    let z = r_linear * 0.0193 + g_linear * 0.1192 + b_linear * 0.9505;
606
607    // Step 3: XYZ to LAB (D65 reference white)
608    let x_n = 0.95047;
609    let y_n = 1.0;
610    let z_n = 1.08883;
611
612    let fx = lab_f(x / x_n);
613    let fy = lab_f(y / y_n);
614    let fz = lab_f(z / z_n);
615
616    let l = 116.0 * fy - 16.0;
617    let a = 500.0 * (fx - fy);
618    let b = 200.0 * (fy - fz);
619
620    Lab { l, a, b }
621}
622
623/// Convert sRGB value (0-255) to linear RGB (0.0-1.0).
624fn srgb_to_linear(c: u8) -> f32 {
625    let c_norm = f32::from(c) / 255.0;
626    if c_norm <= 0.04045 {
627        c_norm / 12.92
628    } else {
629        ((c_norm + 0.055) / 1.055).powf(2.4)
630    }
631}
632
633/// LAB conversion helper function.
634fn lab_f(t: f32) -> f32 {
635    let delta: f32 = 6.0 / 29.0;
636    if t > delta.powi(3) {
637        t.cbrt()
638    } else {
639        t / (3.0 * delta.powi(2)) + 4.0 / 29.0
640    }
641}
642
643/// Calculate perceptual distance between two LAB colors.
644fn lab_distance(lab1: Lab, lab2: Lab) -> f32 {
645    let dl = lab1.l - lab2.l;
646    let da = lab1.a - lab2.a;
647    let db = lab1.b - lab2.b;
648    (dl * dl + da * da + db * db).sqrt()
649}
650
651/// Convert RGB to the nearest 256-color palette index using CIELAB distance.
652///
653/// The 256-color palette is:
654/// - 0-7: standard colors
655/// - 8-15: bright colors
656/// - 16-231: 6x6x6 color cube
657/// - 232-255: grayscale ramp
658pub fn rgb_to_256(r: u8, g: u8, b: u8) -> u8 {
659    let source_lab = rgb_to_lab(r, g, b);
660
661    let mut best_idx = 16_u8;
662    let mut best_distance = f32::MAX;
663
664    // Check grayscale ramp (232-255)
665    for i in 0..24_u8 {
666        let gray = 8 + 10 * i;
667        let lab = rgb_to_lab(gray, gray, gray);
668        let dist = lab_distance(source_lab, lab);
669        if dist < best_distance {
670            best_distance = dist;
671            best_idx = 232 + i;
672        }
673    }
674
675    // Check 6x6x6 color cube (16-231)
676    for ri in 0..6_u8 {
677        for gi in 0..6_u8 {
678            for bi in 0..6_u8 {
679                let r_val = if ri == 0 { 0 } else { 55 + 40 * ri };
680                let g_val = if gi == 0 { 0 } else { 55 + 40 * gi };
681                let b_val = if bi == 0 { 0 } else { 55 + 40 * bi };
682                let lab = rgb_to_lab(r_val, g_val, b_val);
683                let dist = lab_distance(source_lab, lab);
684                if dist < best_distance {
685                    best_distance = dist;
686                    best_idx = 16 + 36 * ri + 6 * gi + bi;
687                }
688            }
689        }
690    }
691
692    // Check basic 16 colors (0-15) for better matches
693    let basic_16_rgb = [
694        (0, 0, 0),       // 0: Black
695        (128, 0, 0),     // 1: Red
696        (0, 128, 0),     // 2: Green
697        (128, 128, 0),   // 3: Yellow
698        (0, 0, 128),     // 4: Blue
699        (128, 0, 128),   // 5: Magenta
700        (0, 128, 128),   // 6: Cyan
701        (192, 192, 192), // 7: White
702        (128, 128, 128), // 8: Bright Black
703        (255, 0, 0),     // 9: Bright Red
704        (0, 255, 0),     // 10: Bright Green
705        (255, 255, 0),   // 11: Bright Yellow
706        (0, 0, 255),     // 12: Bright Blue
707        (255, 0, 255),   // 13: Bright Magenta
708        (0, 255, 255),   // 14: Bright Cyan
709        (255, 255, 255), // 15: Bright White
710    ];
711
712    for (i, (cr, cg, cb)) in basic_16_rgb.iter().enumerate() {
713        let lab = rgb_to_lab(*cr, *cg, *cb);
714        let dist = lab_distance(source_lab, lab);
715        if dist < best_distance {
716            best_distance = dist;
717            best_idx = i as u8;
718        }
719    }
720
721    best_idx
722}
723
724/// Convert RGB to the nearest named 16-color ANSI color using CIELAB distance.
725pub fn rgb_to_16(r: u8, g: u8, b: u8) -> NamedColor {
726    let source_lab = rgb_to_lab(r, g, b);
727
728    let candidates: [(NamedColor, (u8, u8, u8)); 16] = [
729        (NamedColor::Black, (0, 0, 0)),
730        (NamedColor::Red, (128, 0, 0)),
731        (NamedColor::Green, (0, 128, 0)),
732        (NamedColor::Yellow, (128, 128, 0)),
733        (NamedColor::Blue, (0, 0, 128)),
734        (NamedColor::Magenta, (128, 0, 128)),
735        (NamedColor::Cyan, (0, 128, 128)),
736        (NamedColor::White, (192, 192, 192)),
737        (NamedColor::BrightBlack, (128, 128, 128)),
738        (NamedColor::BrightRed, (255, 0, 0)),
739        (NamedColor::BrightGreen, (0, 255, 0)),
740        (NamedColor::BrightYellow, (255, 255, 0)),
741        (NamedColor::BrightBlue, (0, 0, 255)),
742        (NamedColor::BrightMagenta, (255, 0, 255)),
743        (NamedColor::BrightCyan, (0, 255, 255)),
744        (NamedColor::BrightWhite, (255, 255, 255)),
745    ];
746
747    let mut best = NamedColor::White;
748    let mut best_distance = f32::MAX;
749
750    for (name, (cr, cg, cb)) in &candidates {
751        let lab = rgb_to_lab(*cr, *cg, *cb);
752        let dist = lab_distance(source_lab, lab);
753        if dist < best_distance {
754            best_distance = dist;
755            best = *name;
756        }
757    }
758
759    best
760}
761
762/// Map an 8-bit color channel to a 6-level color cube index.
763#[allow(dead_code)] // Reserved for future 256-color quantization
764fn color_cube_index(val: u8) -> u8 {
765    if val < 48 {
766        0
767    } else if val < 115 {
768        1
769    } else {
770        ((u16::from(val) - 35) / 40) as u8
771    }
772}
773
774/// Convert RGB to the nearest named 16-color ANSI color.
775///
776/// This is an alias for `rgb_to_16` for backward compatibility.
777pub fn rgb_to_named(r: u8, g: u8, b: u8) -> NamedColor {
778    rgb_to_16(r, g, b)
779}
780
781/// Represents a contiguous run of changed cells in the same row.
782///
783/// Batching consecutive cells allows the renderer to emit fewer cursor-move
784/// escape sequences, since cells in the same batch are adjacent.
785#[derive(Debug, Clone, PartialEq, Eq)]
786pub struct DeltaBatch {
787    /// Starting column.
788    pub x: u16,
789    /// Row.
790    pub y: u16,
791    /// The cells in this batch (left to right).
792    pub cells: Vec<Cell>,
793}
794
795/// Computes delta batches from cell changes, grouping consecutive same-row cells.
796///
797/// Two changes are grouped into the same batch when they are on the same row
798/// and the second change's x position equals the first change's x plus the
799/// first change's cell width (i.e., they are visually adjacent). Continuation
800/// cells (width 0) are skipped.
801pub fn batch_changes(changes: &[CellChange]) -> Vec<DeltaBatch> {
802    let mut batches: Vec<DeltaBatch> = Vec::new();
803
804    for change in changes {
805        // Skip continuation cells
806        if change.cell.width == 0 {
807            continue;
808        }
809
810        let can_extend = match batches.last() {
811            Some(batch) => {
812                batch.y == change.y && {
813                    // Calculate the expected next x position
814                    let last_cell_x = batch.x;
815                    let total_width: u16 = batch.cells.iter().map(|c| u16::from(c.width)).sum();
816                    last_cell_x + total_width == change.x
817                }
818            }
819            None => false,
820        };
821
822        if can_extend {
823            match batches.last_mut() {
824                Some(batch) => batch.cells.push(change.cell.clone()),
825                None => unreachable!(),
826            }
827        } else {
828            batches.push(DeltaBatch {
829                x: change.x,
830                y: change.y,
831                cells: vec![change.cell.clone()],
832            });
833        }
834    }
835
836    batches
837}
838
839/// Convert a 256-color index to the nearest named 16-color.
840fn index_to_named(idx: u8) -> NamedColor {
841    match idx {
842        0 => NamedColor::Black,
843        1 => NamedColor::Red,
844        2 => NamedColor::Green,
845        3 => NamedColor::Yellow,
846        4 => NamedColor::Blue,
847        5 => NamedColor::Magenta,
848        6 => NamedColor::Cyan,
849        7 => NamedColor::White,
850        8 => NamedColor::BrightBlack,
851        9 => NamedColor::BrightRed,
852        10 => NamedColor::BrightGreen,
853        11 => NamedColor::BrightYellow,
854        12 => NamedColor::BrightBlue,
855        13 => NamedColor::BrightMagenta,
856        14 => NamedColor::BrightCyan,
857        15 => NamedColor::BrightWhite,
858        16..=231 => {
859            // Color cube: convert index back to approximate RGB
860            let idx = idx - 16;
861            let b_idx = idx % 6;
862            let g_idx = (idx / 6) % 6;
863            let r_idx = idx / 36;
864            let r = if r_idx == 0 { 0 } else { 55 + 40 * r_idx };
865            let g = if g_idx == 0 { 0 } else { 55 + 40 * g_idx };
866            let b = if b_idx == 0 { 0 } else { 55 + 40 * b_idx };
867            rgb_to_16(r, g, b)
868        }
869        _ => {
870            // Grayscale ramp: 232-255 → 8, 18, 28, ..., 238
871            let gray = 8 + 10 * (idx - 232);
872            rgb_to_16(gray, gray, gray)
873        }
874    }
875}
876
877#[cfg(test)]
878mod tests {
879    use super::*;
880    use crate::buffer::CellChange;
881    use crate::cell::Cell;
882
883    #[test]
884    fn render_empty_changes() {
885        let renderer = Renderer::new(ColorSupport::TrueColor, false);
886        let output = renderer.render(&[]);
887        assert!(output.is_empty());
888    }
889
890    #[test]
891    fn render_cursor_position() {
892        let renderer = Renderer::new(ColorSupport::TrueColor, false);
893        let changes = vec![CellChange {
894            x: 5,
895            y: 3,
896            cell: Cell::new("A", Style::default()),
897        }];
898        let output = renderer.render(&changes);
899        // Row 4, Col 6 (1-based)
900        assert!(output.contains("\x1b[4;6H"));
901        assert!(output.contains('A'));
902    }
903
904    #[test]
905    fn render_adjacent_cells_no_redundant_move() {
906        let renderer = Renderer::new(ColorSupport::TrueColor, false);
907        let changes = vec![
908            CellChange {
909                x: 0,
910                y: 0,
911                cell: Cell::new("A", Style::default()),
912            },
913            CellChange {
914                x: 1,
915                y: 0,
916                cell: Cell::new("B", Style::default()),
917            },
918        ];
919        let output = renderer.render(&changes);
920        // Should have one cursor position, then A, then B without another position
921        let move_count = output.matches("\x1b[").count();
922        // One for the initial cursor position
923        assert_eq!(move_count, 1, "output: {output:?}");
924    }
925
926    #[test]
927    fn render_fg_truecolor() {
928        let renderer = Renderer::new(ColorSupport::TrueColor, false);
929        let style = Style::new().fg(Color::Rgb {
930            r: 255,
931            g: 128,
932            b: 0,
933        });
934        let changes = vec![CellChange {
935            x: 0,
936            y: 0,
937            cell: Cell::new("X", style),
938        }];
939        let output = renderer.render(&changes);
940        assert!(output.contains("\x1b[38;2;255;128;0m"));
941    }
942
943    #[test]
944    fn render_bg_truecolor() {
945        let renderer = Renderer::new(ColorSupport::TrueColor, false);
946        let style = Style::new().bg(Color::Rgb {
947            r: 0,
948            g: 128,
949            b: 255,
950        });
951        let changes = vec![CellChange {
952            x: 0,
953            y: 0,
954            cell: Cell::new("X", style),
955        }];
956        let output = renderer.render(&changes);
957        assert!(output.contains("\x1b[48;2;0;128;255m"));
958    }
959
960    #[test]
961    fn render_bold_italic() {
962        let renderer = Renderer::new(ColorSupport::TrueColor, false);
963        let style = Style::new().bold(true).italic(true);
964        let changes = vec![CellChange {
965            x: 0,
966            y: 0,
967            cell: Cell::new("X", style),
968        }];
969        let output = renderer.render(&changes);
970        assert!(output.contains("\x1b[1m")); // bold
971        assert!(output.contains("\x1b[3m")); // italic
972    }
973
974    #[test]
975    fn render_named_color() {
976        let renderer = Renderer::new(ColorSupport::TrueColor, false);
977        let style = Style::new().fg(Color::Named(NamedColor::Red));
978        let changes = vec![CellChange {
979            x: 0,
980            y: 0,
981            cell: Cell::new("X", style),
982        }];
983        let output = renderer.render(&changes);
984        assert!(output.contains("\x1b[31m")); // red fg
985    }
986
987    #[test]
988    fn render_indexed_color() {
989        let renderer = Renderer::new(ColorSupport::TrueColor, false);
990        let style = Style::new().fg(Color::Indexed(42));
991        let changes = vec![CellChange {
992            x: 0,
993            y: 0,
994            cell: Cell::new("X", style),
995        }];
996        let output = renderer.render(&changes);
997        assert!(output.contains("\x1b[38;5;42m"));
998    }
999
1000    #[test]
1001    fn render_style_reset_at_end() {
1002        let renderer = Renderer::new(ColorSupport::TrueColor, false);
1003        let style = Style::new().bold(true);
1004        let changes = vec![CellChange {
1005            x: 0,
1006            y: 0,
1007            cell: Cell::new("X", style),
1008        }];
1009        let output = renderer.render(&changes);
1010        assert!(output.ends_with("\x1b[0m"));
1011    }
1012
1013    #[test]
1014    fn render_no_reset_for_default_style() {
1015        let renderer = Renderer::new(ColorSupport::TrueColor, false);
1016        let changes = vec![CellChange {
1017            x: 0,
1018            y: 0,
1019            cell: Cell::new("X", Style::default()),
1020        }];
1021        let output = renderer.render(&changes);
1022        assert!(!output.contains("\x1b[0m"));
1023    }
1024
1025    #[test]
1026    fn render_skip_continuation_cells() {
1027        let renderer = Renderer::new(ColorSupport::TrueColor, false);
1028        let changes = vec![
1029            CellChange {
1030                x: 0,
1031                y: 0,
1032                cell: Cell::new("\u{4e16}", Style::default()), // 世 width=2
1033            },
1034            CellChange {
1035                x: 1,
1036                y: 0,
1037                cell: Cell::continuation(), // width=0
1038            },
1039        ];
1040        let output = renderer.render(&changes);
1041        // The continuation cell should not appear in output
1042        assert!(output.contains("\u{4e16}"));
1043        // Should only have one cursor move
1044        let esc_count = output.matches("\x1b[").count();
1045        assert_eq!(esc_count, 1);
1046    }
1047
1048    #[test]
1049    fn synchronized_output_wrapping() {
1050        let renderer = Renderer::new(ColorSupport::TrueColor, true);
1051        let changes = vec![CellChange {
1052            x: 0,
1053            y: 0,
1054            cell: Cell::new("A", Style::default()),
1055        }];
1056        let output = renderer.render(&changes);
1057        assert!(output.starts_with("\x1b[?2026h"));
1058        assert!(output.ends_with("\x1b[?2026l"));
1059    }
1060
1061    #[test]
1062    fn no_sync_when_disabled() {
1063        let renderer = Renderer::new(ColorSupport::TrueColor, false);
1064        let changes = vec![CellChange {
1065            x: 0,
1066            y: 0,
1067            cell: Cell::new("A", Style::default()),
1068        }];
1069        let output = renderer.render(&changes);
1070        assert!(!output.contains("\x1b[?2026h"));
1071        assert!(!output.contains("\x1b[?2026l"));
1072    }
1073
1074    // Color downgrading tests
1075
1076    #[test]
1077    fn truecolor_passthrough() {
1078        let renderer = Renderer::new(ColorSupport::TrueColor, false);
1079        let style = Style::new().fg(Color::Rgb {
1080            r: 100,
1081            g: 200,
1082            b: 50,
1083        });
1084        let changes = vec![CellChange {
1085            x: 0,
1086            y: 0,
1087            cell: Cell::new("X", style),
1088        }];
1089        let output = renderer.render(&changes);
1090        assert!(output.contains("\x1b[38;2;100;200;50m"));
1091    }
1092
1093    #[test]
1094    fn truecolor_to_256() {
1095        let renderer = Renderer::new(ColorSupport::Extended256, false);
1096        let style = Style::new().fg(Color::Rgb { r: 255, g: 0, b: 0 });
1097        let changes = vec![CellChange {
1098            x: 0,
1099            y: 0,
1100            cell: Cell::new("X", style),
1101        }];
1102        let output = renderer.render(&changes);
1103        // Should use 256-color index, not truecolor
1104        assert!(output.contains("\x1b[38;5;"));
1105        assert!(!output.contains("\x1b[38;2;"));
1106    }
1107
1108    #[test]
1109    fn truecolor_to_16() {
1110        let renderer = Renderer::new(ColorSupport::Basic16, false);
1111        let style = Style::new().fg(Color::Rgb { r: 255, g: 0, b: 0 });
1112        let changes = vec![CellChange {
1113            x: 0,
1114            y: 0,
1115            cell: Cell::new("X", style),
1116        }];
1117        let output = renderer.render(&changes);
1118        // Should use named color code (bright red = 91)
1119        assert!(output.contains("\x1b[91m"));
1120    }
1121
1122    #[test]
1123    fn no_color_strips_all() {
1124        let renderer = Renderer::new(ColorSupport::NoColor, false);
1125        let style = Style::new()
1126            .fg(Color::Rgb { r: 255, g: 0, b: 0 })
1127            .bg(Color::Named(NamedColor::Blue));
1128        let changes = vec![CellChange {
1129            x: 0,
1130            y: 0,
1131            cell: Cell::new("X", style),
1132        }];
1133        let output = renderer.render(&changes);
1134        // Should use reset colors, not any specific color
1135        assert!(output.contains("\x1b[39m")); // fg reset
1136        assert!(output.contains("\x1b[49m")); // bg reset
1137    }
1138
1139    // Color conversion unit tests
1140
1141    #[test]
1142    fn rgb_to_256_pure_red() {
1143        let idx = rgb_to_256(255, 0, 0);
1144        // Pure red in color cube: r=5, g=0, b=0 → 16 + 36*5 + 6*0 + 0 = 196
1145        assert_eq!(idx, 196);
1146    }
1147
1148    #[test]
1149    fn rgb_to_256_grayscale() {
1150        let idx = rgb_to_256(128, 128, 128);
1151        // Grayscale: (128-8)*24/240 = 12 → 232 + 12 = 244
1152        assert_eq!(idx, 244);
1153    }
1154
1155    #[test]
1156    fn rgb_to_256_black() {
1157        let idx = rgb_to_256(0, 0, 0);
1158        assert_eq!(idx, 16); // near-black in grayscale
1159    }
1160
1161    #[test]
1162    fn rgb_to_named_pure_red() {
1163        let named = rgb_to_named(255, 0, 0);
1164        assert_eq!(named, NamedColor::BrightRed);
1165    }
1166
1167    #[test]
1168    fn rgb_to_named_pure_black() {
1169        let named = rgb_to_named(0, 0, 0);
1170        assert_eq!(named, NamedColor::Black);
1171    }
1172
1173    #[test]
1174    fn rgb_to_named_pure_white() {
1175        let named = rgb_to_named(255, 255, 255);
1176        assert_eq!(named, NamedColor::BrightWhite);
1177    }
1178
1179    // --- Delta batching tests ---
1180
1181    #[test]
1182    fn batch_changes_empty() {
1183        let batches = batch_changes(&[]);
1184        assert!(batches.is_empty());
1185    }
1186
1187    #[test]
1188    fn batch_changes_single_cell() {
1189        let changes = vec![CellChange {
1190            x: 3,
1191            y: 1,
1192            cell: Cell::new("A", Style::default()),
1193        }];
1194        let batches = batch_changes(&changes);
1195        assert_eq!(batches.len(), 1);
1196        assert_eq!(batches[0].x, 3);
1197        assert_eq!(batches[0].y, 1);
1198        assert_eq!(batches[0].cells.len(), 1);
1199        assert_eq!(batches[0].cells[0].grapheme, "A");
1200    }
1201
1202    #[test]
1203    fn batch_changes_consecutive_same_row() {
1204        let changes = vec![
1205            CellChange {
1206                x: 0,
1207                y: 0,
1208                cell: Cell::new("A", Style::default()),
1209            },
1210            CellChange {
1211                x: 1,
1212                y: 0,
1213                cell: Cell::new("B", Style::default()),
1214            },
1215            CellChange {
1216                x: 2,
1217                y: 0,
1218                cell: Cell::new("C", Style::default()),
1219            },
1220        ];
1221        let batches = batch_changes(&changes);
1222        assert_eq!(batches.len(), 1);
1223        assert_eq!(batches[0].cells.len(), 3);
1224        assert_eq!(batches[0].cells[0].grapheme, "A");
1225        assert_eq!(batches[0].cells[1].grapheme, "B");
1226        assert_eq!(batches[0].cells[2].grapheme, "C");
1227    }
1228
1229    #[test]
1230    fn batch_changes_different_rows() {
1231        let changes = vec![
1232            CellChange {
1233                x: 0,
1234                y: 0,
1235                cell: Cell::new("A", Style::default()),
1236            },
1237            CellChange {
1238                x: 0,
1239                y: 1,
1240                cell: Cell::new("B", Style::default()),
1241            },
1242        ];
1243        let batches = batch_changes(&changes);
1244        assert_eq!(batches.len(), 2);
1245        assert_eq!(batches[0].y, 0);
1246        assert_eq!(batches[1].y, 1);
1247    }
1248
1249    #[test]
1250    fn batch_changes_gap_in_column() {
1251        let changes = vec![
1252            CellChange {
1253                x: 0,
1254                y: 0,
1255                cell: Cell::new("A", Style::default()),
1256            },
1257            CellChange {
1258                x: 5,
1259                y: 0,
1260                cell: Cell::new("B", Style::default()),
1261            },
1262        ];
1263        let batches = batch_changes(&changes);
1264        assert_eq!(batches.len(), 2);
1265        assert_eq!(batches[0].x, 0);
1266        assert_eq!(batches[1].x, 5);
1267    }
1268
1269    #[test]
1270    fn batch_changes_skips_continuation_cells() {
1271        let changes = vec![
1272            CellChange {
1273                x: 0,
1274                y: 0,
1275                cell: Cell::new("\u{4e16}", Style::default()), // width=2
1276            },
1277            CellChange {
1278                x: 1,
1279                y: 0,
1280                cell: Cell::continuation(), // width=0
1281            },
1282            CellChange {
1283                x: 2,
1284                y: 0,
1285                cell: Cell::new("A", Style::default()),
1286            },
1287        ];
1288        let batches = batch_changes(&changes);
1289        // The wide char (width=2) starts at x=0, next expected x is 2,
1290        // and "A" is at x=2, so they should be in one batch.
1291        assert_eq!(batches.len(), 1);
1292        assert_eq!(batches[0].cells.len(), 2);
1293        assert_eq!(batches[0].cells[0].grapheme, "\u{4e16}");
1294        assert_eq!(batches[0].cells[1].grapheme, "A");
1295    }
1296
1297    #[test]
1298    fn batch_changes_wide_characters() {
1299        let changes = vec![
1300            CellChange {
1301                x: 0,
1302                y: 0,
1303                cell: Cell::new("\u{4e16}", Style::default()), // width=2
1304            },
1305            CellChange {
1306                x: 1,
1307                y: 0,
1308                cell: Cell::continuation(),
1309            },
1310            CellChange {
1311                x: 2,
1312                y: 0,
1313                cell: Cell::new("\u{754c}", Style::default()), // width=2
1314            },
1315        ];
1316        let batches = batch_changes(&changes);
1317        assert_eq!(batches.len(), 1);
1318        assert_eq!(batches[0].cells.len(), 2);
1319    }
1320
1321    #[test]
1322    fn render_batched_empty() {
1323        let renderer = Renderer::new(ColorSupport::TrueColor, false);
1324        let output = renderer.render_batched(&[]);
1325        assert!(output.is_empty());
1326    }
1327
1328    #[test]
1329    fn render_batched_produces_valid_output() {
1330        let renderer = Renderer::new(ColorSupport::TrueColor, false);
1331        let changes = vec![
1332            CellChange {
1333                x: 0,
1334                y: 0,
1335                cell: Cell::new("A", Style::default()),
1336            },
1337            CellChange {
1338                x: 1,
1339                y: 0,
1340                cell: Cell::new("B", Style::default()),
1341            },
1342        ];
1343        let output = renderer.render_batched(&changes);
1344        assert!(output.contains('A'));
1345        assert!(output.contains('B'));
1346        // Should have cursor positioning
1347        assert!(output.contains("\x1b[1;1H"));
1348    }
1349
1350    #[test]
1351    fn render_batched_no_longer_than_render() {
1352        let renderer = Renderer::new(ColorSupport::TrueColor, false);
1353        let style = Style::new().fg(Color::Named(NamedColor::Red));
1354        let changes = vec![
1355            CellChange {
1356                x: 0,
1357                y: 0,
1358                cell: Cell::new("A", style.clone()),
1359            },
1360            CellChange {
1361                x: 1,
1362                y: 0,
1363                cell: Cell::new("B", style.clone()),
1364            },
1365            CellChange {
1366                x: 2,
1367                y: 0,
1368                cell: Cell::new("C", style),
1369            },
1370        ];
1371        let normal_output = renderer.render(&changes);
1372        let batched_output = renderer.render_batched(&changes);
1373        // Batched should produce output no longer than normal render
1374        assert!(batched_output.len() <= normal_output.len());
1375    }
1376
1377    #[test]
1378    fn render_batched_with_styles() {
1379        let renderer = Renderer::new(ColorSupport::TrueColor, false);
1380        let style = Style::new().bold(true).fg(Color::Named(NamedColor::Blue));
1381        let changes = vec![CellChange {
1382            x: 0,
1383            y: 0,
1384            cell: Cell::new("X", style),
1385        }];
1386        let output = renderer.render_batched(&changes);
1387        assert!(output.contains("\x1b[1m")); // bold
1388        assert!(output.contains("\x1b[34m")); // blue
1389        assert!(output.contains('X'));
1390        assert!(output.ends_with("\x1b[0m")); // reset
1391    }
1392
1393    // --- Task 6: render_optimized and build_sgr_sequence tests ---
1394
1395    #[test]
1396    fn render_optimized_starts_with_cursor_hide() {
1397        let renderer = Renderer::new(ColorSupport::TrueColor, false);
1398        let changes = vec![CellChange {
1399            x: 0,
1400            y: 0,
1401            cell: Cell::new("A", Style::default()),
1402        }];
1403        let output = renderer.render_optimized(&changes);
1404        assert!(output.starts_with("\x1b[?25l"));
1405    }
1406
1407    #[test]
1408    fn render_optimized_ends_with_cursor_show() {
1409        let renderer = Renderer::new(ColorSupport::TrueColor, false);
1410        let changes = vec![CellChange {
1411            x: 0,
1412            y: 0,
1413            cell: Cell::new("A", Style::default()),
1414        }];
1415        let output = renderer.render_optimized(&changes);
1416        assert!(output.ends_with("\x1b[?25h"));
1417    }
1418
1419    #[test]
1420    fn render_optimized_sync_before_cursor_show() {
1421        let renderer = Renderer::new(ColorSupport::TrueColor, true);
1422        let changes = vec![CellChange {
1423            x: 0,
1424            y: 0,
1425            cell: Cell::new("A", Style::default()),
1426        }];
1427        let output = renderer.render_optimized(&changes);
1428        // Order should be: cursor hide, sync start, ..., sync end, cursor show
1429        assert!(output.starts_with("\x1b[?25l\x1b[?2026h"));
1430        assert!(output.ends_with("\x1b[?2026l\x1b[?25h"));
1431    }
1432
1433    #[test]
1434    fn build_sgr_combined_bold_italic_red() {
1435        let style = Style::new()
1436            .bold(true)
1437            .italic(true)
1438            .fg(Color::Named(NamedColor::Red));
1439        let sgr = build_sgr_sequence(&style, ColorSupport::TrueColor);
1440        // Should be a single SGR sequence like \x1b[1;3;31m
1441        assert!(sgr.starts_with("\x1b["));
1442        assert!(sgr.ends_with('m'));
1443        // Verify all codes are present in the combined sequence
1444        assert!(sgr.contains("1;"));
1445        assert!(sgr.contains(";3;"));
1446        assert!(sgr.contains("31"));
1447        // Should NOT have multiple separate sequences
1448        let esc_count = sgr.matches("\x1b[").count();
1449        assert_eq!(esc_count, 1);
1450    }
1451
1452    #[test]
1453    fn build_sgr_default_style_is_empty() {
1454        let style = Style::default();
1455        let sgr = build_sgr_sequence(&style, ColorSupport::TrueColor);
1456        assert!(sgr.is_empty());
1457    }
1458
1459    #[test]
1460    fn render_optimized_contains_correct_content() {
1461        let renderer = Renderer::new(ColorSupport::TrueColor, false);
1462        let style = Style::new().bold(true).fg(Color::Named(NamedColor::Green));
1463        let changes = vec![CellChange {
1464            x: 0,
1465            y: 0,
1466            cell: Cell::new("Z", style),
1467        }];
1468        let output = renderer.render_optimized(&changes);
1469        // Should contain the grapheme
1470        assert!(output.contains('Z'));
1471        // Should contain bold (1) and green (32) in a combined sequence
1472        assert!(output.contains("1;"));
1473        assert!(output.contains("32"));
1474        // Should have reset before cursor show
1475        assert!(output.contains("\x1b[0m"));
1476    }
1477
1478    #[test]
1479    fn render_optimized_empty_is_empty() {
1480        let renderer = Renderer::new(ColorSupport::TrueColor, false);
1481        let output = renderer.render_optimized(&[]);
1482        assert!(output.is_empty());
1483    }
1484
1485    #[test]
1486    fn build_sgr_truecolor_rgb() {
1487        let style = Style::new().fg(Color::Rgb {
1488            r: 100,
1489            g: 200,
1490            b: 50,
1491        });
1492        let sgr = build_sgr_sequence(&style, ColorSupport::TrueColor);
1493        assert_eq!(sgr, "\x1b[38;2;100;200;50m");
1494    }
1495
1496    #[test]
1497    fn build_sgr_fg_and_bg() {
1498        let style = Style::new()
1499            .fg(Color::Named(NamedColor::Red))
1500            .bg(Color::Named(NamedColor::Blue));
1501        let sgr = build_sgr_sequence(&style, ColorSupport::TrueColor);
1502        // Single escape, contains both fg and bg codes
1503        let esc_count = sgr.matches("\x1b[").count();
1504        assert_eq!(esc_count, 1);
1505        assert!(sgr.contains("31")); // red fg
1506        assert!(sgr.contains("44")); // blue bg
1507    }
1508
1509    // --- Task 4: Enhanced color downgrading tests ---
1510
1511    #[test]
1512    fn color_mapper_caches_256_mappings() {
1513        let mut mapper = ColorMapper::new();
1514        let idx1 = mapper.map_to_256(255, 0, 0);
1515        let idx2 = mapper.map_to_256(255, 0, 0);
1516        assert_eq!(idx1, idx2);
1517        // Verify cache is being used
1518        assert_eq!(mapper.cache_256.len(), 1);
1519    }
1520
1521    #[test]
1522    fn color_mapper_caches_16_mappings() {
1523        let mut mapper = ColorMapper::new();
1524        let name1 = mapper.map_to_16(255, 0, 0);
1525        let name2 = mapper.map_to_16(255, 0, 0);
1526        assert_eq!(name1, name2);
1527        assert_eq!(name1, NamedColor::BrightRed);
1528        // Verify cache is being used
1529        assert_eq!(mapper.cache_16.len(), 1);
1530    }
1531
1532    #[test]
1533    fn color_mapper_clear_cache() {
1534        let mut mapper = ColorMapper::new();
1535        mapper.map_to_256(255, 0, 0);
1536        mapper.map_to_16(255, 0, 0);
1537        assert_eq!(mapper.cache_256.len(), 1);
1538        assert_eq!(mapper.cache_16.len(), 1);
1539        mapper.clear_cache();
1540        assert_eq!(mapper.cache_256.len(), 0);
1541        assert_eq!(mapper.cache_16.len(), 0);
1542    }
1543
1544    #[test]
1545    fn rgb_to_lab_black() {
1546        let lab = rgb_to_lab(0, 0, 0);
1547        // Black should have L near 0
1548        assert!(lab.l < 1.0);
1549    }
1550
1551    #[test]
1552    fn rgb_to_lab_white() {
1553        let lab = rgb_to_lab(255, 255, 255);
1554        // White should have L near 100
1555        assert!(lab.l > 99.0);
1556    }
1557
1558    #[test]
1559    fn lab_distance_same_color() {
1560        let lab1 = rgb_to_lab(128, 128, 128);
1561        let lab2 = rgb_to_lab(128, 128, 128);
1562        let dist = lab_distance(lab1, lab2);
1563        assert!(dist < 0.001);
1564    }
1565
1566    #[test]
1567    fn lab_distance_different_colors() {
1568        let lab1 = rgb_to_lab(255, 0, 0); // Red
1569        let lab2 = rgb_to_lab(0, 0, 255); // Blue
1570        let dist = lab_distance(lab1, lab2);
1571        // Should be significantly different
1572        assert!(dist > 100.0);
1573    }
1574
1575    #[test]
1576    fn rgb_to_16_pure_colors() {
1577        assert_eq!(rgb_to_16(255, 0, 0), NamedColor::BrightRed);
1578        assert_eq!(rgb_to_16(0, 255, 0), NamedColor::BrightGreen);
1579        assert_eq!(rgb_to_16(0, 0, 255), NamedColor::BrightBlue);
1580        assert_eq!(rgb_to_16(255, 255, 0), NamedColor::BrightYellow);
1581        assert_eq!(rgb_to_16(255, 0, 255), NamedColor::BrightMagenta);
1582        assert_eq!(rgb_to_16(0, 255, 255), NamedColor::BrightCyan);
1583        assert_eq!(rgb_to_16(0, 0, 0), NamedColor::Black);
1584        assert_eq!(rgb_to_16(255, 255, 255), NamedColor::BrightWhite);
1585    }
1586
1587    #[test]
1588    fn rgb_to_16_dark_colors() {
1589        assert_eq!(rgb_to_16(128, 0, 0), NamedColor::Red);
1590        assert_eq!(rgb_to_16(0, 128, 0), NamedColor::Green);
1591        assert_eq!(rgb_to_16(0, 0, 128), NamedColor::Blue);
1592    }
1593
1594    #[test]
1595    fn rgb_to_256_with_lab_pure_red() {
1596        let idx = rgb_to_256(255, 0, 0);
1597        // Should map to a red color in the palette
1598        // The exact index may vary with CIELAB, but should be in red range
1599        assert!((9..=231).contains(&idx)); // Valid 256-color range
1600    }
1601
1602    #[test]
1603    fn rgb_to_256_with_lab_grayscale() {
1604        let idx = rgb_to_256(128, 128, 128);
1605        // Should map to grayscale ramp or basic gray
1606        assert!(idx >= 8); // Valid grayscale range
1607    }
1608
1609    #[test]
1610    fn rgb_to_256_with_lab_pure_black() {
1611        let idx = rgb_to_256(0, 0, 0);
1612        // Should be black (0) or near-black in grayscale
1613        assert!(idx <= 16);
1614    }
1615
1616    #[test]
1617    fn rgb_to_256_with_lab_pure_white() {
1618        let idx = rgb_to_256(255, 255, 255);
1619        // Should be bright white (15) or near-white
1620        assert!(idx == 15 || idx >= 231);
1621    }
1622
1623    #[test]
1624    fn no_color_environment_variable() {
1625        // Set NO_COLOR environment variable
1626        unsafe {
1627            std::env::set_var("NO_COLOR", "1");
1628        }
1629
1630        let renderer = Renderer::new(ColorSupport::TrueColor, false);
1631        let style = Style::new().fg(Color::Rgb { r: 255, g: 0, b: 0 });
1632        let changes = vec![CellChange {
1633            x: 0,
1634            y: 0,
1635            cell: Cell::new("X", style),
1636        }];
1637        let output = renderer.render(&changes);
1638
1639        // Should use reset color, not any specific color
1640        assert!(output.contains("\x1b[39m")); // fg reset
1641        assert!(!output.contains("\x1b[38;2;")); // No truecolor
1642
1643        // Clean up
1644        unsafe {
1645            std::env::remove_var("NO_COLOR");
1646        }
1647    }
1648
1649    #[test]
1650    fn no_color_strips_all_colors() {
1651        // Set NO_COLOR environment variable
1652        unsafe {
1653            std::env::set_var("NO_COLOR", "1");
1654        }
1655
1656        let renderer = Renderer::new(ColorSupport::TrueColor, false);
1657        let style = Style::new()
1658            .fg(Color::Rgb { r: 255, g: 0, b: 0 })
1659            .bg(Color::Named(NamedColor::Blue));
1660        let changes = vec![CellChange {
1661            x: 0,
1662            y: 0,
1663            cell: Cell::new("X", style),
1664        }];
1665        let output = renderer.render(&changes);
1666
1667        // Should use reset colors for both fg and bg
1668        assert!(output.contains("\x1b[39m")); // fg reset
1669        assert!(output.contains("\x1b[49m")); // bg reset
1670
1671        // Clean up
1672        unsafe {
1673            std::env::remove_var("NO_COLOR");
1674        }
1675    }
1676
1677    #[test]
1678    fn no_color_overrides_color_support() {
1679        // Set NO_COLOR environment variable
1680        unsafe {
1681            std::env::set_var("NO_COLOR", "1");
1682        }
1683
1684        // Even with TrueColor support, NO_COLOR should strip colors
1685        let renderer = Renderer::new(ColorSupport::TrueColor, false);
1686        let style = Style::new().fg(Color::Rgb {
1687            r: 100,
1688            g: 200,
1689            b: 50,
1690        });
1691        let changes = vec![CellChange {
1692            x: 0,
1693            y: 0,
1694            cell: Cell::new("X", style),
1695        }];
1696        let output = renderer.render(&changes);
1697
1698        assert!(output.contains("\x1b[39m"));
1699        assert!(!output.contains("\x1b[38;2;"));
1700
1701        // Clean up
1702        unsafe {
1703            std::env::remove_var("NO_COLOR");
1704        }
1705    }
1706
1707    #[test]
1708    fn downgrade_color_standalone_no_color() {
1709        // Set NO_COLOR environment variable
1710        unsafe {
1711            std::env::set_var("NO_COLOR", "1");
1712        }
1713
1714        let color = Color::Rgb { r: 255, g: 0, b: 0 };
1715        let result = downgrade_color_standalone(&color, ColorSupport::TrueColor);
1716        assert_eq!(result, Color::Reset);
1717
1718        // Clean up
1719        unsafe {
1720            std::env::remove_var("NO_COLOR");
1721        }
1722    }
1723
1724    #[test]
1725    fn build_sgr_with_no_color() {
1726        // Set NO_COLOR environment variable
1727        unsafe {
1728            std::env::set_var("NO_COLOR", "1");
1729        }
1730
1731        let style = Style::new()
1732            .bold(true)
1733            .fg(Color::Rgb { r: 255, g: 0, b: 0 });
1734        let sgr = build_sgr_sequence(&style, ColorSupport::TrueColor);
1735
1736        // Should include bold but fg should be reset
1737        assert!(sgr.contains('1'));
1738        assert!(sgr.contains("39")); // fg reset
1739
1740        // Clean up
1741        unsafe {
1742            std::env::remove_var("NO_COLOR");
1743        }
1744    }
1745
1746    #[test]
1747    fn rgb_to_16_perceptual_accuracy() {
1748        // Test that similar colors map to the same named color
1749        // These are all "reddish" colors
1750        let c1 = rgb_to_16(255, 0, 0);
1751        let c2 = rgb_to_16(255, 10, 10);
1752        let c3 = rgb_to_16(250, 0, 5);
1753        // All should be bright red
1754        assert_eq!(c1, NamedColor::BrightRed);
1755        assert_eq!(c2, NamedColor::BrightRed);
1756        assert_eq!(c3, NamedColor::BrightRed);
1757    }
1758
1759    #[test]
1760    fn rgb_to_256_perceptual_better_than_euclidean() {
1761        // Test a specific case where CIELAB should be better
1762        // Light green (128, 255, 128) should map closer to green than to white
1763        let idx = rgb_to_256(128, 255, 128);
1764        // Convert back to check
1765        let is_greenish = if idx <= 15 {
1766            matches!(
1767                idx,
1768                2 | 10 // Green or BrightGreen
1769            )
1770        } else if (16..=231).contains(&idx) {
1771            // In color cube, check if green component is dominant
1772            let idx = idx - 16;
1773            let g_idx = (idx / 6) % 6;
1774            g_idx >= 4 // High green component
1775        } else {
1776            false // Grayscale - not green
1777        };
1778        assert!(is_greenish, "idx={idx} should be greenish");
1779    }
1780}