Skip to main content

photon_ui/
compositor.rs

1use unicode_width::UnicodeWidthChar;
2
3use crate::{
4    layer::Shadow,
5    layout::Rect,
6    renderer::{
7        ImageCommand,
8        Rendered,
9    },
10    utils::AnsiCodeTracker,
11};
12
13/// Normalized style state for a single terminal cell.
14#[derive(Debug, Clone, PartialEq, Default)]
15pub struct CellStyle {
16    /// Bold (SGR 1) is active.
17    pub bold: bool,
18    /// Faint / dim (SGR 2) is active.
19    pub faint: bool,
20    /// Italic (SGR 3) is active.
21    pub italic: bool,
22    /// Underline (SGR 4) is active.
23    pub underline: bool,
24    /// Reverse video (SGR 7) is active.
25    pub reverse: bool,
26    /// Active foreground color SGR parameter.
27    pub fg_color: Option<String>,
28    /// Active background color SGR parameter.
29    pub bg_color: Option<String>,
30    /// Active OSC 8 hyperlink, if any.
31    pub hyperlink: Option<crate::utils::ActiveHyperlink>,
32    /// Unrecognized ANSI sequences that should be emitted verbatim before this
33    /// cell's symbol.
34    pub prefix: String,
35}
36
37impl CellStyle {
38    fn from_tracker(tracker: &AnsiCodeTracker, prefix: &str) -> Self {
39        Self {
40            bold: tracker.bold,
41            faint: tracker.faint,
42            italic: tracker.italic,
43            underline: tracker.underline,
44            reverse: tracker.reverse,
45            fg_color: tracker.fg_color.clone(),
46            bg_color: tracker.bg_color.clone(),
47            hyperlink: tracker.hyperlink.clone(),
48            prefix: prefix.to_string(),
49        }
50    }
51
52    fn has_sgr(&self) -> bool {
53        self.bold ||
54            self.faint ||
55            self.italic ||
56            self.underline ||
57            self.reverse ||
58            self.fg_color.is_some() ||
59            self.bg_color.is_some() ||
60            !self.prefix.is_empty()
61    }
62
63    fn sgr_sequence(&self) -> String {
64        let mut parts = Vec::new();
65        if self.bold {
66            parts.push("1");
67        }
68        if self.faint {
69            parts.push("2");
70        }
71        if self.italic {
72            parts.push("3");
73        }
74        if self.underline {
75            parts.push("4");
76        }
77        if self.reverse {
78            parts.push("7");
79        }
80        if let Some(ref fg) = self.fg_color {
81            parts.push(fg.as_str());
82        }
83        if let Some(ref bg) = self.bg_color {
84            parts.push(bg.as_str());
85        }
86        if parts.is_empty() {
87            String::new()
88        } else {
89            format!("\x1b[{}m", parts.join(";"))
90        }
91    }
92}
93
94/// A single display cell in the compositor's internal grid.
95#[derive(Debug, Clone, PartialEq, Default)]
96pub struct Cell {
97    /// The visual symbol for this cell. Empty when this cell is the second half
98    /// of a full-width character.
99    pub symbol: String,
100    /// The normalized style for this cell.
101    pub style: CellStyle,
102    /// Display width: `0` for a continuation spacer, `1` for normal, `2` for
103    /// the first half of a full-width character.
104    pub width: u8,
105    /// Whether this cell is transparent padding beyond the line's real content.
106    pub transparent: bool,
107}
108
109fn is_opaque(cell: &Cell) -> bool {
110    cell.width > 0 && !cell.transparent
111}
112
113/// Region over which a shadow is applied to lower layers.
114#[derive(Debug, Clone, PartialEq)]
115enum ShadowRegion {
116    /// Every cell except those covered by the layer that cast the shadow.
117    Complement(Vec<Vec<bool>>),
118    /// A concrete rectangle.
119    Rect(Rect),
120}
121
122/// A shadow cast by one layer onto layers below it.
123#[derive(Debug, Clone, PartialEq)]
124struct ShadowMask {
125    region: ShadowRegion,
126    style: CellStyle,
127}
128
129impl ShadowMask {
130    fn covers(&self, row: usize, col: usize) -> bool {
131        match &self.region {
132            | ShadowRegion::Complement(covered) => {
133                if let Some(row_mask) = covered.get(row) &&
134                    let Some(cell) = row_mask.get(col)
135                {
136                    return !cell;
137                }
138                false
139            },
140            | ShadowRegion::Rect(rect) => {
141                let r = row as u16;
142                let c = col as u16;
143                r >= rect.y &&
144                    r < rect.y.saturating_add(rect.height) &&
145                    c >= rect.x &&
146                    c < rect.x.saturating_add(rect.width)
147            },
148        }
149    }
150}
151
152/// Composites multiple full-terminal-size layer buffers front-to-back.
153///
154/// Layers are added from highest index (front) to lowest index (back). Each
155/// layer is rendered independently; cells already covered by a higher layer are
156/// stripped from lower layers.
157pub struct Compositor {
158    width: usize,
159    height: usize,
160    output: Vec<Vec<Cell>>,
161    covered: Vec<Vec<bool>>,
162    shadows: Vec<ShadowMask>,
163    cursor: Option<(usize, usize)>,
164    images: Vec<ImageCommand>,
165}
166
167impl Compositor {
168    /// Create a compositor for the given terminal size.
169    pub fn new(width: u16, height: u16) -> Self {
170        let w = width as usize;
171        let h = height as usize;
172        Self {
173            width: w,
174            height: h,
175            output: vec![vec![Cell::default(); w]; h],
176            covered: vec![vec![false; w]; h],
177            shadows: Vec::new(),
178            cursor: None,
179            images: Vec::new(),
180        }
181    }
182
183    /// Add a layer's rendered buffer. Layers must be added front-to-back.
184    pub fn add_layer(&mut self, rendered: &Rendered, shadow: &Shadow) {
185        let grid = parse_rendered(rendered, self.width, self.height);
186        let mut layer_covered = vec![vec![false; self.width]; self.height];
187
188        for (r, row) in grid.iter().enumerate() {
189            let mut col = 0usize;
190            for cell in row {
191                if cell.width == 0 {
192                    continue;
193                }
194                let w = cell.width as usize;
195                let end = col + w;
196                let opaque = is_opaque(cell);
197                if opaque && end <= self.width && !self.is_covered(r, col, w) {
198                    let style = self.apply_shadows(r, col, &cell.style);
199                    self.output[r][col] = Cell {
200                        symbol: cell.symbol.clone(),
201                        style: style.clone(),
202                        width: cell.width,
203                        transparent: false,
204                    };
205                    if w == 2 {
206                        self.output[r][col + 1] = Cell {
207                            symbol: String::new(),
208                            style,
209                            width: 0,
210                            transparent: false,
211                        };
212                    }
213                    self.mark_covered(r, col, w);
214                }
215                if opaque {
216                    layer_covered[r][col] = true;
217                    if w == 2 {
218                        layer_covered[r][col + 1] = true;
219                    }
220                }
221                col += w;
222            }
223        }
224
225        if self.cursor.is_none() &&
226            let Some((r, c)) = rendered.cursor &&
227            r < self.height &&
228            c < self.width
229        {
230            self.cursor = Some((r, c));
231        }
232
233        self.images.extend(rendered.images.clone());
234        self.add_shadow(shadow, &layer_covered);
235    }
236
237    /// Consume the compositor and produce the final composite frame.
238    pub fn finalize(self) -> Rendered {
239        let mut lines = Vec::with_capacity(self.height);
240        for row in self.output {
241            lines.push(encode_cells_to_line(&row));
242        }
243        Rendered {
244            lines,
245            cursor: self.cursor,
246            images: self.images,
247        }
248    }
249
250    fn is_covered(&self, row: usize, col: usize, width: usize) -> bool {
251        if let Some(row_mask) = self.covered.get(row) {
252            for c in col..col + width {
253                if let Some(true) = row_mask.get(c) {
254                    return true;
255                }
256            }
257        }
258        false
259    }
260
261    fn mark_covered(&mut self, row: usize, col: usize, width: usize) {
262        if let Some(row_mask) = self.covered.get_mut(row) {
263            for c in col..col + width {
264                if let Some(cell) = row_mask.get_mut(c) {
265                    *cell = true;
266                }
267            }
268        }
269    }
270
271    fn apply_shadows(&self, row: usize, col: usize, style: &CellStyle) -> CellStyle {
272        let mut result = style.clone();
273        for shadow in &self.shadows {
274            if shadow.covers(row, col) {
275                result = merge_style(result, &shadow.style);
276            }
277        }
278        result
279    }
280
281    fn add_shadow(&mut self, shadow: &Shadow, layer_covered: &[Vec<bool>]) {
282        match shadow {
283            | Shadow::None => {},
284            | Shadow::Dim { style } => {
285                let cell_style = parse_style_string(style);
286                self.shadows.push(ShadowMask {
287                    region: ShadowRegion::Complement(layer_covered.to_vec()),
288                    style: cell_style,
289                });
290            },
291            | Shadow::Drop {
292                style,
293                offset_x,
294                offset_y,
295            } => {
296                let cell_style = parse_style_string(style);
297                if let Some(rect) = compute_bbox(layer_covered) {
298                    let right = (rect.x as i16).saturating_add(rect.width as i16);
299                    let bottom = (rect.y as i16).saturating_add(rect.height as i16);
300                    let ox = *offset_x;
301                    let oy = *offset_y;
302
303                    if ox > 0 {
304                        let x = right;
305                        let y = (rect.y as i16).saturating_add(oy);
306                        let shadow_rect =
307                            Rect::new(x.max(0) as u16, y.max(0) as u16, ox as u16, rect.height);
308                        self.shadows.push(ShadowMask {
309                            region: ShadowRegion::Rect(shadow_rect),
310                            style: cell_style.clone(),
311                        });
312                    } else if ox < 0 {
313                        let x = (rect.x as i16).saturating_add(ox);
314                        let y = (rect.y as i16).saturating_add(oy);
315                        let shadow_rect = Rect::new(
316                            x.max(0) as u16,
317                            y.max(0) as u16,
318                            ox.unsigned_abs(),
319                            rect.height,
320                        );
321                        self.shadows.push(ShadowMask {
322                            region: ShadowRegion::Rect(shadow_rect),
323                            style: cell_style.clone(),
324                        });
325                    }
326
327                    if oy > 0 {
328                        let x = (rect.x as i16).saturating_add(ox);
329                        let y = bottom;
330                        let shadow_rect =
331                            Rect::new(x.max(0) as u16, y.max(0) as u16, rect.width, oy as u16);
332                        self.shadows.push(ShadowMask {
333                            region: ShadowRegion::Rect(shadow_rect),
334                            style: cell_style.clone(),
335                        });
336                    } else if oy < 0 {
337                        let x = (rect.x as i16).saturating_add(ox);
338                        let y = (rect.y as i16).saturating_add(oy);
339                        let shadow_rect = Rect::new(
340                            x.max(0) as u16,
341                            y.max(0) as u16,
342                            rect.width,
343                            oy.unsigned_abs(),
344                        );
345                        self.shadows.push(ShadowMask {
346                            region: ShadowRegion::Rect(shadow_rect),
347                            style: cell_style,
348                        });
349                    }
350                }
351            },
352        }
353    }
354}
355
356fn merge_style(mut target: CellStyle, source: &CellStyle) -> CellStyle {
357    target.bold = target.bold || source.bold;
358    target.faint = target.faint || source.faint;
359    target.italic = target.italic || source.italic;
360    target.underline = target.underline || source.underline;
361    target.reverse = target.reverse || source.reverse;
362    if target.fg_color.is_none() && source.fg_color.is_some() {
363        target.fg_color = source.fg_color.clone();
364    }
365    if target.bg_color.is_none() && source.bg_color.is_some() {
366        target.bg_color = source.bg_color.clone();
367    }
368    if target.hyperlink.is_none() && source.hyperlink.is_some() {
369        target.hyperlink = source.hyperlink.clone();
370    }
371    if !source.prefix.is_empty() {
372        target.prefix.push_str(&source.prefix);
373    }
374    target
375}
376
377fn parse_style_string(style: &str) -> CellStyle {
378    let mut tracker = AnsiCodeTracker::new();
379    let mut prefix = String::new();
380    let mut chars = style.chars().peekable();
381    while let Some(ch) = chars.next() {
382        if ch == '\x1b' &&
383            let Some(seq) = extract_sequence(&mut chars)
384        {
385            let before = tracker.clone();
386            tracker.process(&seq);
387            if tracker == before {
388                prefix.push_str(&seq);
389            }
390        }
391    }
392    CellStyle::from_tracker(&tracker, &prefix)
393}
394
395fn compute_bbox(covered: &[Vec<bool>]) -> Option<Rect> {
396    let mut min_row: Option<usize> = None;
397    let mut max_row: Option<usize> = None;
398    let mut min_col: Option<usize> = None;
399    let mut max_col: Option<usize> = None;
400
401    for (r, row) in covered.iter().enumerate() {
402        for (c, cell) in row.iter().enumerate() {
403            if *cell {
404                if min_row.is_none() {
405                    min_row = Some(r);
406                }
407                max_row = Some(r);
408                if min_col.is_none_or(|m| c < m) {
409                    min_col = Some(c);
410                }
411                if max_col.is_none_or(|m| c > m) {
412                    max_col = Some(c);
413                }
414            }
415        }
416    }
417
418    match (min_row, max_row, min_col, max_col) {
419        | (Some(min_r), Some(max_r), Some(min_c), Some(max_c)) => Some(Rect::new(
420            min_c as u16,
421            min_r as u16,
422            (max_c - min_c + 1) as u16,
423            (max_r - min_r + 1) as u16,
424        )),
425        | _ => None,
426    }
427}
428
429fn parse_rendered(rendered: &Rendered, width: usize, height: usize) -> Vec<Vec<Cell>> {
430    let mut grid = vec![Vec::new(); height];
431    for (r, line) in rendered.lines.iter().enumerate() {
432        if r >= height {
433            break;
434        }
435        grid[r] = parse_line_to_cells(line, width);
436    }
437    grid
438}
439
440fn parse_line_to_cells(line: &str, max_width: usize) -> Vec<Cell> {
441    let mut cells: Vec<Cell> = Vec::new();
442    let mut tracker = AnsiCodeTracker::new();
443    let mut prefix = String::new();
444    let mut visible_width = 0usize;
445    let mut chars = line.chars().peekable();
446
447    while let Some(ch) = chars.next() {
448        if ch == '\x1b' {
449            if let Some(seq) = extract_sequence(&mut chars) {
450                let before = tracker.clone();
451                tracker.process(&seq);
452                if tracker == before {
453                    prefix.push_str(&seq);
454                }
455            }
456            continue;
457        }
458
459        let w = ch.width().unwrap_or(0);
460        if w == 0 {
461            if let Some(last) = cells.last_mut() {
462                last.symbol.push(ch);
463            }
464            continue;
465        }
466
467        if visible_width + w > max_width {
468            break;
469        }
470
471        let style = CellStyle::from_tracker(&tracker, &prefix);
472        prefix.clear();
473        cells.push(Cell {
474            symbol: ch.to_string(),
475            style,
476            width: w as u8,
477            transparent: false,
478        });
479        if w == 2 {
480            cells.push(Cell {
481                symbol: String::new(),
482                style: CellStyle::default(),
483                width: 0,
484                transparent: false,
485            });
486        }
487        visible_width += w;
488    }
489
490    // Compute the real content range: columns between the first and last
491    // non-whitespace / styled character. Plain whitespace outside this range is
492    // transparent padding; plain whitespace inside the range is intentional
493    // spacing and remains opaque.
494    let mut first_content_col: Option<usize> = None;
495    let mut last_content_col = 0usize;
496    let mut col = 0usize;
497    for cell in &cells {
498        if cell.width == 0 {
499            continue;
500        }
501        if !cell.symbol.trim().is_empty() || cell.style.has_sgr() {
502            if first_content_col.is_none() {
503                first_content_col = Some(col);
504            }
505            last_content_col = col + cell.width as usize;
506        }
507        col += cell.width as usize;
508    }
509
510    let first = first_content_col.unwrap_or(0);
511    let mut col = 0usize;
512    for cell in &mut cells {
513        if cell.width == 0 {
514            continue;
515        }
516        if (col < first || col >= last_content_col) && !cell.style.has_sgr() {
517            cell.transparent = true;
518        }
519        col += cell.width as usize;
520    }
521
522    cells
523}
524
525fn extract_sequence(chars: &mut std::iter::Peekable<std::str::Chars>) -> Option<String> {
526    let mut seq = String::from('\x1b');
527    match chars.peek() {
528        | Some(&'[') => {
529            chars.next();
530            seq.push('[');
531            while let Some(&c) = chars.peek() {
532                chars.next();
533                seq.push(c);
534                if c.is_alphabetic() {
535                    return Some(seq);
536                }
537            }
538        },
539        | Some(&']') => {
540            chars.next();
541            seq.push(']');
542            while let Some(&c) = chars.peek() {
543                chars.next();
544                seq.push(c);
545                if c == '\x07' {
546                    return Some(seq);
547                }
548                if c == '\x1b' &&
549                    let Some(&'\\') = chars.peek()
550                {
551                    chars.next();
552                    seq.push('\\');
553                    return Some(seq);
554                }
555            }
556        },
557        | _ => {},
558    }
559    None
560}
561
562fn encode_cells_to_line(cells: &[Cell]) -> String {
563    let mut line = String::new();
564    let mut current = CellStyle::default();
565
566    for cell in cells {
567        if cell.width == 0 {
568            continue;
569        }
570
571        if cell.style != current {
572            // Close hyperlink if it is changing.
573            if current.hyperlink != cell.style.hyperlink &&
574                let Some(ref link) = current.hyperlink
575            {
576                line.push_str(&format!("\x1b]8;;{}", link.terminator));
577            }
578            // Reset SGR when any parsed SGR attribute is currently active.
579            // Emit an explicit intensity reset first to work around macOS
580            // terminals that do not reliably clear bold on `\x1b[0m` alone.
581            if current.has_sgr() {
582                if current.bold || current.faint {
583                    line.push_str("\x1b[22m");
584                }
585                line.push_str("\x1b[0m");
586            }
587            // Apply new SGR.
588            let sgr = cell.style.sgr_sequence();
589            if !sgr.is_empty() {
590                line.push_str(&sgr);
591            }
592            // Open new hyperlink if it differs from current.
593            if cell.style.hyperlink != current.hyperlink &&
594                let Some(ref link) = cell.style.hyperlink
595            {
596                line.push_str(&format!(
597                    "\x1b]8;{};{}{}",
598                    link.params, link.url, link.terminator
599                ));
600            }
601            // Emit any unrecognized prefix sequences.
602            if !cell.style.prefix.is_empty() {
603                line.push_str(&cell.style.prefix);
604            }
605            current = cell.style.clone();
606        }
607
608        line.push_str(&cell.symbol);
609    }
610
611    if let Some(ref link) = current.hyperlink {
612        line.push_str(&format!("\x1b]8;;{}", link.terminator));
613    }
614    if current.has_sgr() {
615        if current.bold || current.faint {
616            line.push_str("\x1b[22m");
617        }
618        line.push_str("\x1b[0m");
619    }
620
621    line
622}
623
624#[cfg(test)]
625mod tests {
626    use super::*;
627
628    fn rendered_from(lines: &[&str]) -> Rendered {
629        Rendered {
630            lines: lines.iter().map(|s| s.to_string()).collect(),
631            cursor: None,
632            images: Vec::new(),
633        }
634    }
635
636    #[test]
637    fn cell_parse_empty_line() {
638        let cells = parse_line_to_cells("", 10);
639        assert!(cells.is_empty());
640    }
641
642    #[test]
643    fn cell_parse_plain_text() {
644        let cells = parse_line_to_cells("abc", 10);
645        assert_eq!(cells.len(), 3);
646        assert_eq!(cells[0].symbol, "a");
647        assert_eq!(cells[1].symbol, "b");
648        assert_eq!(cells[2].symbol, "c");
649        assert_eq!(cells[0].width, 1);
650    }
651
652    #[test]
653    fn cell_parse_ansi_bold() {
654        let cells = parse_line_to_cells("\x1b[1mhi\x1b[0m", 10);
655        assert_eq!(cells.len(), 2);
656        assert!(cells[0].style.bold);
657        // The reset comes after 'i', so 'i' is still bold at parse time.
658        assert!(cells[1].style.bold);
659    }
660
661    /// Regression: `\x1b[0m` must reset active SGR state so subsequent cells
662    /// are not incorrectly styled.
663    #[test]
664    fn cell_parse_ansi_reset_clears_state() {
665        let cells = parse_line_to_cells("\x1b[1m\x1b[31mA\x1b[0mB", 10);
666        assert_eq!(cells.len(), 2);
667        assert!(cells[0].style.bold);
668        assert_eq!(cells[0].style.fg_color, Some("31".to_string()));
669        assert!(!cells[1].style.bold);
670        assert!(cells[1].style.fg_color.is_none());
671    }
672
673    #[test]
674    fn cell_parse_ansi_colors() {
675        let cells = parse_line_to_cells("\x1b[31;44mX", 10);
676        assert_eq!(cells.len(), 1);
677        assert_eq!(cells[0].style.fg_color, Some("31".to_string()));
678        assert_eq!(cells[0].style.bg_color, Some("44".to_string()));
679    }
680
681    #[test]
682    fn cell_parse_hyperlink() {
683        let cells = parse_line_to_cells("\x1b]8;;https://example.com\x1b\\link\x1b]8;;\x1b\\", 10);
684        assert_eq!(cells.len(), 4);
685        // The hyperlink is active for all visible characters; the close
686        // sequence follows the last character.
687        assert!(cells[0].style.hyperlink.is_some());
688        assert!(cells[3].style.hyperlink.is_some());
689    }
690
691    #[test]
692    fn cell_parse_cjk_and_emoji() {
693        let cells = parse_line_to_cells("漢a", 10);
694        assert_eq!(cells.len(), 3);
695        assert_eq!(cells[0].symbol, "漢");
696        assert_eq!(cells[0].width, 2);
697        assert_eq!(cells[1].width, 0);
698        assert_eq!(cells[2].symbol, "a");
699    }
700
701    #[test]
702    fn cell_encode_plain_text() {
703        let cells = parse_line_to_cells("abc", 10);
704        let line = encode_cells_to_line(&cells);
705        assert_eq!(line, "abc");
706    }
707
708    #[test]
709    fn cell_roundtrip_preserves_visible_width() {
710        let original = "\x1b[31mred\x1b[0m \x1b[1mbold\x1b[0m";
711        let cells = parse_line_to_cells(original, 20);
712        let encoded = encode_cells_to_line(&cells);
713        assert_eq!(
714            crate::utils::visible_width(&encoded),
715            crate::utils::visible_width(original)
716        );
717    }
718
719    #[test]
720    fn cell_encode_resets_at_line_end() {
721        let cells = parse_line_to_cells("\x1b[31mred", 10);
722        let line = encode_cells_to_line(&cells);
723        assert!(line.ends_with("\x1b[0m"));
724    }
725
726    /// Regression: bold cells must emit an explicit bold-off before the full
727    /// reset so macOS terminals don't leak bold weight.
728    #[test]
729    fn cell_encode_bold_emits_bold_off() {
730        let cells = parse_line_to_cells("\x1b[1mbold\x1b[0m", 10);
731        let line = encode_cells_to_line(&cells);
732        assert!(line.contains("\x1b[22m\x1b[0m"));
733    }
734
735    #[test]
736    fn compositor_empty_layers() {
737        let mut comp = Compositor::new(10, 2);
738        comp.add_layer(&rendered_from(&["", ""]), &Shadow::None);
739        let out = comp.finalize();
740        assert_eq!(out.lines.len(), 2);
741        assert_eq!(out.lines[0], "");
742    }
743
744    #[test]
745    fn compositor_single_layer_passthrough() {
746        let mut comp = Compositor::new(5, 1);
747        comp.add_layer(&rendered_from(&["hello"]), &Shadow::None);
748        let out = comp.finalize();
749        assert_eq!(out.lines[0], "hello");
750    }
751
752    #[test]
753    fn compositor_two_layers_full_occlusion() {
754        let mut comp = Compositor::new(5, 1);
755        comp.add_layer(&rendered_from(&["WORLD"]), &Shadow::None);
756        comp.add_layer(&rendered_from(&["hello"]), &Shadow::None);
757        let out = comp.finalize();
758        assert_eq!(out.lines[0], "WORLD");
759    }
760
761    #[test]
762    fn compositor_three_layers_partial_occlusion() {
763        let mut comp = Compositor::new(5, 1);
764        comp.add_layer(&rendered_from(&["ABC  "]), &Shadow::None);
765        comp.add_layer(&rendered_from(&["  XYZ"]), &Shadow::None);
766        comp.add_layer(&rendered_from(&["12345"]), &Shadow::None);
767        let out = comp.finalize();
768        assert_eq!(out.lines[0], "ABCYZ");
769    }
770
771    #[test]
772    fn compositor_cursor_topmost_wins() {
773        let mut top = rendered_from(&["top"]);
774        top.cursor = Some((0, 2));
775        let mut bottom = rendered_from(&["bottom"]);
776        bottom.cursor = Some((0, 1));
777        let mut comp = Compositor::new(5, 1);
778        comp.add_layer(&top, &Shadow::None);
779        comp.add_layer(&bottom, &Shadow::None);
780        let out = comp.finalize();
781        assert_eq!(out.cursor, Some((0, 2)));
782    }
783
784    #[test]
785    fn compositor_images_merged_from_all_layers() {
786        let mut top = rendered_from(&["top"]);
787        top.images.push(ImageCommand {
788            id: 1,
789            data: "a".into(),
790            row: 0,
791            col: 0,
792        });
793        let mut bottom = rendered_from(&["bottom"]);
794        bottom.images.push(ImageCommand {
795            id: 2,
796            data: "b".into(),
797            row: 0,
798            col: 0,
799        });
800        let mut comp = Compositor::new(5, 1);
801        comp.add_layer(&top, &Shadow::None);
802        comp.add_layer(&bottom, &Shadow::None);
803        let out = comp.finalize();
804        assert_eq!(out.images.len(), 2);
805    }
806
807    #[test]
808    fn compositor_dim_shadow_applies_to_exposed_lower_cells() {
809        let mut comp = Compositor::new(5, 1);
810        comp.add_layer(
811            &rendered_from(&[" ABC "]),
812            &Shadow::Dim {
813                style: "\x1b[2m".into(),
814            },
815        );
816        comp.add_layer(&rendered_from(&["12345"]), &Shadow::None);
817        let out = comp.finalize();
818        // Columns 0 and 4 are exposed and should be dimmed.
819        assert!(out.lines[0].starts_with("\x1b[2m1"));
820        // Columns 1-3 are covered by the top layer.
821        assert!(out.lines[0].contains("ABC"));
822    }
823
824    #[test]
825    fn compositor_drop_shadow_offset_positive() {
826        let mut comp = Compositor::new(6, 3);
827        comp.add_layer(
828            &rendered_from(&["", " AB ", ""]),
829            &Shadow::Drop {
830                style: "\x1b[2m".into(),
831                offset_x: 1,
832                offset_y: 1,
833            },
834        );
835        comp.add_layer(
836            &rendered_from(&["XXXXXX", "XXXXXX", "XXXXXX"]),
837            &Shadow::None,
838        );
839        let out = comp.finalize();
840        // Drop shadow is rendered as an offset fringe. For a 2x1 object at
841        // (1, 1) with offset (1, 1), only the bottom strip at row 2 is shaded.
842        assert!(!out.lines[0].contains("\x1b[2m"));
843        assert!(!out.lines[1].contains("\x1b[2m"));
844        assert!(out.lines[2].contains("\x1b[2m"));
845    }
846
847    #[test]
848    fn compositor_shadow_none_is_identity() {
849        let mut comp = Compositor::new(5, 1);
850        comp.add_layer(&rendered_from(&["hello"]), &Shadow::None);
851        let out = comp.finalize();
852        assert_eq!(out.lines[0], "hello");
853    }
854
855    #[test]
856    fn compositor_ansi_reset_preserved_at_boundaries() {
857        let mut comp = Compositor::new(10, 1);
858        comp.add_layer(&rendered_from(&["     world"]), &Shadow::None);
859        comp.add_layer(
860            &rendered_from(&["\x1b[44mhello\x1b[0m     "]),
861            &Shadow::None,
862        );
863        let out = comp.finalize();
864        // The blue background from the bottom layer must be reset before the
865        // top layer's plain text begins.
866        assert!(out.lines[0].contains("\x1b[0m"));
867    }
868
869    #[test]
870    fn compositor_no_panic_on_oversized_layer() {
871        let mut comp = Compositor::new(3, 1);
872        comp.add_layer(&rendered_from(&["hello world"]), &Shadow::None);
873        let out = comp.finalize();
874        assert!(out.lines[0].len() <= 11);
875    }
876
877    /// Regression: the compositor must preserve 24-bit truecolor codes.
878    /// Previously AnsiCodeTracker only stored "38" / "48", so encoded output
879    /// dropped the RGB parameters and buttons rendered with the wrong colors.
880    #[test]
881    fn compositor_preserves_truecolor_roundtrip() {
882        let orange = "\x1b[48;2;250;82;15m";
883        let line = format!("{}hello\x1b[0m", orange);
884        let mut comp = Compositor::new(10, 1);
885        comp.add_layer(&rendered_from(&[&line]), &Shadow::None);
886        let out = comp.finalize();
887        assert!(
888            out.lines[0].contains(orange),
889            "truecolor bg code lost: {}",
890            out.lines[0]
891        );
892    }
893
894    /// Regression: the compositor must preserve 256-color codes.
895    #[test]
896    fn compositor_preserves_256_color_roundtrip() {
897        let color = "\x1b[38;5;196m";
898        let line = format!("{}hello\x1b[0m", color);
899        let mut comp = Compositor::new(10, 1);
900        comp.add_layer(&rendered_from(&[&line]), &Shadow::None);
901        let out = comp.finalize();
902        assert!(
903            out.lines[0].contains(color),
904            "256-color fg code lost: {}",
905            out.lines[0]
906        );
907    }
908
909    #[test]
910    fn sgr_sequence_includes_all_attributes() {
911        let style = CellStyle {
912            bold: true,
913            faint: true,
914            italic: true,
915            underline: true,
916            reverse: true,
917            fg_color: Some("31".into()),
918            bg_color: Some("44".into()),
919            ..CellStyle::default()
920        };
921        let seq = style.sgr_sequence();
922        assert!(seq.contains('1'));
923        assert!(seq.contains('2'));
924        assert!(seq.contains('3'));
925        assert!(seq.contains('4'));
926        assert!(seq.contains('7'));
927        assert!(seq.contains("31"));
928        assert!(seq.contains("44"));
929    }
930
931    #[test]
932    fn sgr_sequence_empty_when_no_attributes() {
933        let style = CellStyle::default();
934        assert!(style.sgr_sequence().is_empty());
935    }
936
937    #[test]
938    fn shadow_region_complement_out_of_bounds() {
939        let covered = vec![vec![true, false]];
940        let mask = ShadowMask {
941            region: ShadowRegion::Complement(covered),
942            style: CellStyle::default(),
943        };
944        assert!(!mask.covers(5, 0));
945        assert!(!mask.covers(0, 5));
946    }
947
948    #[test]
949    fn drop_shadow_negative_offsets() {
950        let mut comp = Compositor::new(6, 3);
951        comp.add_layer(
952            &rendered_from(&["", " AB ", ""]),
953            &Shadow::Drop {
954                style: "\x1b[2m".into(),
955                offset_x: -1,
956                offset_y: -1,
957            },
958        );
959        comp.add_layer(
960            &rendered_from(&["XXXXXX", "XXXXXX", "XXXXXX"]),
961            &Shadow::None,
962        );
963        let out = comp.finalize();
964        assert!(out.lines[0].contains("\x1b[2m"));
965    }
966
967    #[test]
968    fn compute_bbox_empty_returns_none() {
969        assert!(compute_bbox(&[vec![false, false]]).is_none());
970    }
971
972    #[test]
973    fn parse_rendered_truncates_tall_layers() {
974        let rendered = rendered_from(&["a", "b", "c", "d", "e"]);
975        let grid = parse_rendered(&rendered, 1, 2);
976        assert_eq!(grid.len(), 2);
977    }
978
979    #[test]
980    fn parse_line_to_cells_width_zero_continuation() {
981        let cells = parse_line_to_cells("a\u{0300}b", 10);
982        assert!(cells.len() >= 2);
983    }
984
985    #[test]
986    fn extract_sequence_malformed_returns_none() {
987        let mut chars = "\x1b[".chars().peekable();
988        assert!(extract_sequence(&mut chars).is_none());
989    }
990
991    #[test]
992    fn encode_cells_with_hyperlink_roundtrip() {
993        let open = "\x1b]8;;https://example.com\x1b\\";
994        let close = "\x1b]8;;\x1b\\";
995        let line = format!("{}link{}", open, close);
996        let cells = parse_line_to_cells(&line, 10);
997        let encoded = encode_cells_to_line(&cells);
998        assert!(encoded.contains("\x1b]8;;https://example.com"));
999        assert!(encoded.contains("\x1b]8;;"));
1000    }
1001
1002    #[test]
1003    fn merge_style_combines_attributes() {
1004        let a = CellStyle {
1005            bold: true,
1006            fg_color: Some("31".into()),
1007            ..CellStyle::default()
1008        };
1009        let b = CellStyle {
1010            faint: true,
1011            bg_color: Some("44".into()),
1012            hyperlink: Some(crate::utils::ActiveHyperlink {
1013                params: "".into(),
1014                url: "https://x".into(),
1015                terminator: "\x1b\\".into(),
1016            }),
1017            prefix: "\x1b[?25l".into(),
1018            ..CellStyle::default()
1019        };
1020        let merged = merge_style(a, &b);
1021        assert!(merged.bold);
1022        assert!(merged.faint);
1023        assert_eq!(merged.fg_color, Some("31".into()));
1024        assert_eq!(merged.bg_color, Some("44".into()));
1025        assert!(merged.hyperlink.is_some());
1026        assert!(merged.prefix.contains("\x1b[?25l"));
1027    }
1028
1029    #[test]
1030    fn parse_style_string_preserves_unrecognized_prefix() {
1031        let style = parse_style_string("\x1b[?25l\x1b[31m");
1032        assert!(style.prefix.contains("\x1b[?25l"));
1033        assert_eq!(style.fg_color, Some("31".into()));
1034    }
1035}