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#[derive(Debug, Clone, PartialEq, Default)]
15pub struct CellStyle {
16 pub bold: bool,
18 pub faint: bool,
20 pub italic: bool,
22 pub underline: bool,
24 pub reverse: bool,
26 pub fg_color: Option<String>,
28 pub bg_color: Option<String>,
30 pub hyperlink: Option<crate::utils::ActiveHyperlink>,
32 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#[derive(Debug, Clone, PartialEq, Default)]
96pub struct Cell {
97 pub symbol: String,
100 pub style: CellStyle,
102 pub width: u8,
105 pub transparent: bool,
107}
108
109fn is_opaque(cell: &Cell) -> bool {
110 cell.width > 0 && !cell.transparent
111}
112
113#[derive(Debug, Clone, PartialEq)]
115enum ShadowRegion {
116 Complement(Vec<Vec<bool>>),
118 Rect(Rect),
120}
121
122#[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
152pub 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 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 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 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 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 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 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 let sgr = cell.style.sgr_sequence();
589 if !sgr.is_empty() {
590 line.push_str(&sgr);
591 }
592 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 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 assert!(cells[1].style.bold);
659 }
660
661 #[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 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 #[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 assert!(out.lines[0].starts_with("\x1b[2m1"));
820 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 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 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 #[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 #[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}