1use std::fmt::Write;
21use std::ops::Range;
22
23use crate::cell::{Cell, CellFlags, Color, HyperlinkRegistry, SgrFlags};
24use crate::grid::Grid;
25use crate::scrollback::Scrollback;
26
27#[derive(Debug, Clone, PartialEq, Eq, Default)]
34pub enum ExportRange {
35 #[default]
37 Viewport,
38 ScrollbackOnly,
40 Full,
42 Lines(Range<u32>),
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
48pub enum LineEnding {
49 #[default]
51 Lf,
52 CrLf,
54}
55
56impl LineEnding {
57 #[must_use]
59 pub fn as_str(self) -> &'static str {
60 match self {
61 Self::Lf => "\n",
62 Self::CrLf => "\r\n",
63 }
64 }
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
69pub enum ColorDepth {
70 NoColor,
72 Named16,
74 Indexed256,
76 #[default]
78 TrueColor,
79}
80
81#[derive(Debug, Clone)]
88pub struct AnsiExportOptions {
89 pub range: ExportRange,
91 pub color_depth: ColorDepth,
93 pub line_ending: LineEnding,
95 pub trim_trailing: bool,
97 pub reset_at_end: bool,
99 pub join_soft_wraps: bool,
101}
102
103impl Default for AnsiExportOptions {
104 fn default() -> Self {
105 Self {
106 range: ExportRange::Viewport,
107 color_depth: ColorDepth::TrueColor,
108 line_ending: LineEnding::Lf,
109 trim_trailing: true,
110 reset_at_end: true,
111 join_soft_wraps: true,
112 }
113 }
114}
115
116#[derive(Debug, Clone)]
123pub struct HtmlExportOptions {
124 pub range: ExportRange,
126 pub inline_styles: bool,
128 pub class_prefix: String,
130 pub font_family: String,
132 pub font_size: String,
134 pub render_hyperlinks: bool,
136 pub line_ending: LineEnding,
138 pub trim_trailing: bool,
140}
141
142impl Default for HtmlExportOptions {
143 fn default() -> Self {
144 Self {
145 range: ExportRange::Viewport,
146 inline_styles: true,
147 class_prefix: "ft".into(),
148 font_family: "monospace".into(),
149 font_size: "14px".into(),
150 render_hyperlinks: true,
151 line_ending: LineEnding::Lf,
152 trim_trailing: true,
153 }
154 }
155}
156
157#[derive(Debug, Clone)]
164pub struct TextExportOptions {
165 pub range: ExportRange,
167 pub line_ending: LineEnding,
169 pub trim_trailing: bool,
171 pub join_soft_wraps: bool,
173 pub include_combining: bool,
175}
176
177impl Default for TextExportOptions {
178 fn default() -> Self {
179 Self {
180 range: ExportRange::Viewport,
181 line_ending: LineEnding::Lf,
182 trim_trailing: true,
183 join_soft_wraps: true,
184 include_combining: true,
185 }
186 }
187}
188
189pub struct ExportContext<'a> {
196 pub grid: &'a Grid,
198 pub scrollback: &'a Scrollback,
200 pub hyperlinks: &'a HyperlinkRegistry,
202}
203
204impl<'a> ExportContext<'a> {
205 #[must_use]
207 pub fn new(
208 grid: &'a Grid,
209 scrollback: &'a Scrollback,
210 hyperlinks: &'a HyperlinkRegistry,
211 ) -> Self {
212 Self {
213 grid,
214 scrollback,
215 hyperlinks,
216 }
217 }
218}
219
220#[derive(Debug, Clone)]
224pub struct ExportRow<'a> {
225 pub cells: &'a [Cell],
227 pub is_soft_wrapped: bool,
230}
231
232#[must_use]
239pub fn resolve_rows<'a>(
240 grid: &'a Grid,
241 scrollback: &'a Scrollback,
242 range: &ExportRange,
243) -> Vec<ExportRow<'a>> {
244 match range {
245 ExportRange::Viewport => (0..grid.rows())
246 .filter_map(|r| {
247 grid.row_cells(r).map(|cells| ExportRow {
248 cells,
249 is_soft_wrapped: false,
250 })
251 })
252 .collect(),
253 ExportRange::ScrollbackOnly => resolve_scrollback_rows(scrollback),
254 ExportRange::Full => {
255 let mut rows = resolve_scrollback_rows(scrollback);
256 for r in 0..grid.rows() {
261 if let Some(cells) = grid.row_cells(r) {
262 rows.push(ExportRow {
263 cells,
264 is_soft_wrapped: false,
265 });
266 }
267 }
268 rows
269 }
270 ExportRange::Lines(line_range) => {
271 let sb_len = scrollback.len() as u32;
272 let mut rows = Vec::new();
273 for line_idx in line_range.start..line_range.end {
274 if line_idx < sb_len {
275 if let Some(sb_line) = scrollback.get(line_idx as usize) {
276 let next_wrapped = scrollback
278 .get(line_idx as usize + 1)
279 .is_some_and(|next| next.wrapped);
280 rows.push(ExportRow {
281 cells: &sb_line.cells,
282 is_soft_wrapped: next_wrapped,
283 });
284 }
285 } else {
286 let grid_row = (line_idx - sb_len) as u16;
287 if let Some(cells) = grid.row_cells(grid_row) {
288 rows.push(ExportRow {
289 cells,
290 is_soft_wrapped: false,
291 });
292 }
293 }
294 }
295 rows
296 }
297 }
298}
299
300fn resolve_scrollback_rows(scrollback: &Scrollback) -> Vec<ExportRow<'_>> {
302 let len = scrollback.len();
303 let mut rows = Vec::with_capacity(len);
304 for i in 0..len {
305 let line = scrollback.get(i).unwrap();
306 let next_wrapped = scrollback.get(i + 1).is_some_and(|next| next.wrapped);
308 rows.push(ExportRow {
309 cells: &line.cells,
310 is_soft_wrapped: next_wrapped,
311 });
312 }
313 rows
314}
315
316fn row_cells_to_text(cells: &[Cell], include_combining: bool, trim_trailing: bool) -> String {
323 let mut buf = String::with_capacity(cells.len());
324 for cell in cells {
325 if cell.flags.contains(CellFlags::WIDE_CONTINUATION) {
326 continue;
327 }
328 buf.push(cell.content());
329 if include_combining {
330 for &mark in cell.combining_marks() {
331 buf.push(mark);
332 }
333 }
334 }
335 if trim_trailing {
336 let trimmed_len = buf.trim_end_matches(' ').len();
337 buf.truncate(trimmed_len);
338 }
339 buf
340}
341
342#[must_use]
348pub fn export_text(ctx: &ExportContext<'_>, opts: &TextExportOptions) -> String {
349 let rows = resolve_rows(ctx.grid, ctx.scrollback, &opts.range);
350 if rows.is_empty() {
351 return String::new();
352 }
353
354 let line_end = opts.line_ending.as_str();
355 let mut out = String::new();
356
357 for (i, row) in rows.iter().enumerate() {
358 let text = row_cells_to_text(row.cells, opts.include_combining, opts.trim_trailing);
359 out.push_str(&text);
360
361 if i + 1 < rows.len() {
365 let skip_newline = opts.join_soft_wraps && row.is_soft_wrapped;
366 if !skip_newline {
367 out.push_str(line_end);
368 }
369 }
370 }
371
372 out
373}
374
375#[must_use]
388pub fn export_ansi(ctx: &ExportContext<'_>, opts: &AnsiExportOptions) -> String {
389 let text_opts = TextExportOptions {
392 range: opts.range.clone(),
393 line_ending: opts.line_ending,
394 trim_trailing: opts.trim_trailing,
395 join_soft_wraps: opts.join_soft_wraps,
396 include_combining: true,
397 };
398 let mut out = export_text(ctx, &text_opts);
399 if opts.reset_at_end {
400 out.push_str("\x1b[0m");
401 }
402 out
403}
404
405#[must_use]
413pub fn color_to_sgr(color: Color, layer: u8, depth: ColorDepth) -> Option<String> {
414 match depth {
415 ColorDepth::NoColor => None,
416 ColorDepth::Named16 => match color {
417 Color::Default => None,
418 Color::Named(n) if n < 8 => {
419 let base = if layer == 38 { 30 } else { 40 };
420 Some(format!("{}", base + n))
421 }
422 Color::Named(n) if n < 16 => {
423 let base = if layer == 38 { 90 } else { 100 };
424 Some(format!("{}", base + (n - 8)))
425 }
426 _ => None,
428 },
429 ColorDepth::Indexed256 => match color {
430 Color::Default => None,
431 Color::Named(n) => Some(format!("{layer};5;{n}")),
432 Color::Indexed(n) => Some(format!("{layer};5;{n}")),
433 Color::Rgb(r, g, b) => {
434 let idx = rgb_to_256(r, g, b);
436 Some(format!("{layer};5;{idx}"))
437 }
438 },
439 ColorDepth::TrueColor => match color {
440 Color::Default => None,
441 Color::Named(n) => Some(format!("{layer};5;{n}")),
442 Color::Indexed(n) => Some(format!("{layer};5;{n}")),
443 Color::Rgb(r, g, b) => Some(format!("{layer};2;{r};{g};{b}")),
444 },
445 }
446}
447
448#[must_use]
453pub fn rgb_to_256(r: u8, g: u8, b: u8) -> u8 {
454 if r == g && g == b {
456 if r < 8 {
457 return 16; }
459 if r > 248 {
460 return 231; }
462 return 232 + (((r as u16 - 8) * 23 + 123) / 247) as u8;
464 }
465 let ri = ((r as u16 * 5 + 127) / 255) as u8;
467 let gi = ((g as u16 * 5 + 127) / 255) as u8;
468 let bi = ((b as u16 * 5 + 127) / 255) as u8;
469 16 + 36 * ri + 6 * gi + bi
470}
471
472#[must_use]
477pub fn sgr_flags_to_params(flags: SgrFlags) -> Vec<u8> {
478 let mut params = Vec::new();
479 if flags.contains(SgrFlags::BOLD) {
480 params.push(1);
481 }
482 if flags.contains(SgrFlags::DIM) {
483 params.push(2);
484 }
485 if flags.contains(SgrFlags::ITALIC) {
486 params.push(3);
487 }
488 if flags.contains(SgrFlags::UNDERLINE) {
489 params.push(4);
490 }
491 if flags.contains(SgrFlags::BLINK) {
492 params.push(5);
493 }
494 if flags.contains(SgrFlags::INVERSE) {
495 params.push(7);
496 }
497 if flags.contains(SgrFlags::HIDDEN) {
498 params.push(8);
499 }
500 if flags.contains(SgrFlags::STRIKETHROUGH) {
501 params.push(9);
502 }
503 if flags.contains(SgrFlags::DOUBLE_UNDERLINE) {
504 params.push(21);
505 }
506 if flags.contains(SgrFlags::OVERLINE) {
507 params.push(53);
508 }
509 params
510}
511
512#[must_use]
526pub fn export_html(ctx: &ExportContext<'_>, opts: &HtmlExportOptions) -> String {
527 let text_opts = TextExportOptions {
530 range: opts.range.clone(),
531 line_ending: opts.line_ending,
532 trim_trailing: opts.trim_trailing,
533 join_soft_wraps: false,
534 include_combining: true,
535 };
536 let text = export_text(ctx, &text_opts);
537 let escaped = html_escape(&text);
538
539 let mut out = String::with_capacity(escaped.len() + 200);
540 write!(
541 out,
542 "<pre class=\"{}\" style=\"font-family:{};font-size:{};\">",
543 opts.class_prefix, opts.font_family, opts.font_size,
544 )
545 .unwrap();
546 out.push_str(&escaped);
547 out.push_str("</pre>");
548 out
549}
550
551#[must_use]
555pub fn html_escape(text: &str) -> String {
556 let mut out = String::with_capacity(text.len());
557 for ch in text.chars() {
558 match ch {
559 '&' => out.push_str("&"),
560 '<' => out.push_str("<"),
561 '>' => out.push_str(">"),
562 '"' => out.push_str("""),
563 '\'' => out.push_str("'"),
564 _ => out.push(ch),
565 }
566 }
567 out
568}
569
570#[cfg(test)]
573mod tests {
574 use super::*;
575 use crate::cell::{Cell, HyperlinkRegistry, SgrAttrs};
576 use crate::grid::Grid;
577 use crate::scrollback::Scrollback;
578
579 fn make_grid(cols: u16, lines: &[&str]) -> Grid {
582 let rows = lines.len() as u16;
583 let mut g = Grid::new(cols, rows);
584 for (r, text) in lines.iter().enumerate() {
585 for (c, ch) in text.chars().enumerate() {
586 if c >= cols as usize {
587 break;
588 }
589 g.cell_mut(r as u16, c as u16).unwrap().set_content(ch, 1);
590 }
591 }
592 g
593 }
594
595 fn make_scrollback(lines: &[(&str, bool)]) -> Scrollback {
596 let mut sb = Scrollback::new(64);
597 for (text, wrapped) in lines {
598 let cells: Vec<Cell> = text.chars().map(Cell::new).collect();
599 sb.push_row(&cells, *wrapped);
600 }
601 sb
602 }
603
604 fn default_ctx<'a>(
605 grid: &'a Grid,
606 scrollback: &'a Scrollback,
607 hyperlinks: &'a HyperlinkRegistry,
608 ) -> ExportContext<'a> {
609 ExportContext::new(grid, scrollback, hyperlinks)
610 }
611
612 #[test]
615 fn resolve_viewport_only() {
616 let grid = make_grid(5, &["hello", "world"]);
617 let sb = Scrollback::new(0);
618 let rows = resolve_rows(&grid, &sb, &ExportRange::Viewport);
619 assert_eq!(rows.len(), 2);
620 assert_eq!(rows[0].cells.len(), 5);
621 assert_eq!(rows[0].cells[0].content(), 'h');
622 assert!(!rows[0].is_soft_wrapped);
623 }
624
625 #[test]
626 fn resolve_scrollback_only() {
627 let grid = Grid::new(5, 1);
628 let sb = make_scrollback(&[("aaa", false), ("bbb", true), ("ccc", false)]);
629 let rows = resolve_rows(&grid, &sb, &ExportRange::ScrollbackOnly);
630 assert_eq!(rows.len(), 3);
631 assert!(rows[0].is_soft_wrapped);
633 assert!(!rows[1].is_soft_wrapped);
635 assert!(!rows[2].is_soft_wrapped);
636 }
637
638 #[test]
639 fn resolve_full_includes_both() {
640 let grid = make_grid(5, &["grid"]);
641 let sb = make_scrollback(&[("sb", false)]);
642 let rows = resolve_rows(&grid, &sb, &ExportRange::Full);
643 assert_eq!(rows.len(), 2);
644 assert_eq!(rows[0].cells[0].content(), 's');
645 assert_eq!(rows[1].cells[0].content(), 'g');
646 }
647
648 #[test]
649 fn resolve_lines_range_across_boundary() {
650 let grid = make_grid(5, &["vp0", "vp1"]);
651 let sb = make_scrollback(&[("sb0", false), ("sb1", false)]);
652 let rows = resolve_rows(&grid, &sb, &ExportRange::Lines(1..3));
654 assert_eq!(rows.len(), 2);
655 assert_eq!(rows[0].cells[0].content(), 's'); assert_eq!(rows[1].cells[0].content(), 'v'); }
658
659 #[test]
660 fn resolve_lines_empty_range() {
661 let grid = make_grid(5, &["x"]);
662 let sb = Scrollback::new(0);
663 let rows = resolve_rows(&grid, &sb, &ExportRange::Lines(0..0));
664 assert!(rows.is_empty());
665 }
666
667 #[test]
668 fn resolve_lines_beyond_bounds() {
669 let grid = make_grid(5, &["x"]);
670 let sb = Scrollback::new(0);
671 let rows = resolve_rows(&grid, &sb, &ExportRange::Lines(5..10));
672 assert!(rows.is_empty());
673 }
674
675 #[test]
678 fn text_export_viewport_basic() {
679 let grid = make_grid(10, &["hello", "world"]);
680 let sb = Scrollback::new(0);
681 let reg = HyperlinkRegistry::new();
682 let ctx = default_ctx(&grid, &sb, ®);
683
684 let text = export_text(&ctx, &TextExportOptions::default());
685 assert_eq!(text, "hello\nworld");
686 }
687
688 #[test]
689 fn text_export_trims_trailing_spaces() {
690 let grid = make_grid(10, &["hi"]);
691 let sb = Scrollback::new(0);
692 let reg = HyperlinkRegistry::new();
693 let ctx = default_ctx(&grid, &sb, ®);
694
695 let text = export_text(&ctx, &TextExportOptions::default());
696 assert_eq!(text, "hi");
697 }
698
699 #[test]
700 fn text_export_no_trim() {
701 let grid = make_grid(5, &["ab"]);
702 let sb = Scrollback::new(0);
703 let reg = HyperlinkRegistry::new();
704 let ctx = default_ctx(&grid, &sb, ®);
705
706 let opts = TextExportOptions {
707 trim_trailing: false,
708 ..Default::default()
709 };
710 let text = export_text(&ctx, &opts);
711 assert_eq!(text, "ab "); }
713
714 #[test]
715 fn text_export_crlf_line_endings() {
716 let grid = make_grid(5, &["aa", "bb"]);
717 let sb = Scrollback::new(0);
718 let reg = HyperlinkRegistry::new();
719 let ctx = default_ctx(&grid, &sb, ®);
720
721 let opts = TextExportOptions {
722 line_ending: LineEnding::CrLf,
723 ..Default::default()
724 };
725 let text = export_text(&ctx, &opts);
726 assert_eq!(text, "aa\r\nbb");
727 }
728
729 #[test]
730 fn text_export_joins_soft_wraps() {
731 let grid = Grid::new(5, 1);
732 let sb = make_scrollback(&[("hello", false), ("world", true)]);
733 let reg = HyperlinkRegistry::new();
734 let ctx = default_ctx(&grid, &sb, ®);
735
736 let opts = TextExportOptions {
737 range: ExportRange::ScrollbackOnly,
738 join_soft_wraps: true,
739 ..Default::default()
740 };
741 let text = export_text(&ctx, &opts);
742 assert_eq!(text, "helloworld");
743 }
744
745 #[test]
746 fn text_export_no_join_soft_wraps() {
747 let grid = Grid::new(5, 1);
748 let sb = make_scrollback(&[("hello", false), ("world", true)]);
749 let reg = HyperlinkRegistry::new();
750 let ctx = default_ctx(&grid, &sb, ®);
751
752 let opts = TextExportOptions {
753 range: ExportRange::ScrollbackOnly,
754 join_soft_wraps: false,
755 ..Default::default()
756 };
757 let text = export_text(&ctx, &opts);
758 assert_eq!(text, "hello\nworld");
759 }
760
761 #[test]
762 fn text_export_full_range() {
763 let grid = make_grid(10, &["viewport"]);
764 let sb = make_scrollback(&[("history", false)]);
765 let reg = HyperlinkRegistry::new();
766 let ctx = default_ctx(&grid, &sb, ®);
767
768 let opts = TextExportOptions {
769 range: ExportRange::Full,
770 ..Default::default()
771 };
772 let text = export_text(&ctx, &opts);
773 assert_eq!(text, "history\nviewport");
774 }
775
776 #[test]
777 fn text_export_empty_grid() {
778 let grid = Grid::new(0, 0);
779 let sb = Scrollback::new(0);
780 let reg = HyperlinkRegistry::new();
781 let ctx = default_ctx(&grid, &sb, ®);
782
783 let text = export_text(&ctx, &TextExportOptions::default());
784 assert_eq!(text, "");
785 }
786
787 #[test]
788 fn text_export_wide_char_appears_once() {
789 let mut grid = Grid::new(10, 1);
790 let (lead, cont) = Cell::wide('\u{4E2D}', SgrAttrs::default()); *grid.cell_mut(0, 0).unwrap() = lead;
792 *grid.cell_mut(0, 1).unwrap() = cont;
793 grid.cell_mut(0, 2).unwrap().set_content('x', 1);
794
795 let sb = Scrollback::new(0);
796 let reg = HyperlinkRegistry::new();
797 let ctx = default_ctx(&grid, &sb, ®);
798
799 let text = export_text(&ctx, &TextExportOptions::default());
800 assert_eq!(text, "中x");
801 }
802
803 #[test]
804 fn text_export_combining_marks() {
805 let mut grid = Grid::new(10, 1);
806 grid.cell_mut(0, 0).unwrap().set_content('e', 1);
807 grid.cell_mut(0, 0).unwrap().push_combining('\u{0301}');
808
809 let sb = Scrollback::new(0);
810 let reg = HyperlinkRegistry::new();
811 let ctx = default_ctx(&grid, &sb, ®);
812
813 let opts = TextExportOptions {
814 include_combining: true,
815 ..Default::default()
816 };
817 let text = export_text(&ctx, &opts);
818 assert_eq!(text, "e\u{0301}");
819
820 let opts_no_combining = TextExportOptions {
821 include_combining: false,
822 ..Default::default()
823 };
824 let text = export_text(&ctx, &opts_no_combining);
825 assert_eq!(text, "e");
826 }
827
828 #[test]
829 fn text_export_lines_range() {
830 let grid = make_grid(5, &["vp"]);
831 let sb = make_scrollback(&[("sb0", false), ("sb1", false)]);
832 let reg = HyperlinkRegistry::new();
833 let ctx = default_ctx(&grid, &sb, ®);
834
835 let opts = TextExportOptions {
836 range: ExportRange::Lines(1..3),
837 ..Default::default()
838 };
839 let text = export_text(&ctx, &opts);
840 assert_eq!(text, "sb1\nvp");
841 }
842
843 #[test]
846 fn ansi_export_stub_includes_reset() {
847 let grid = make_grid(5, &["hi"]);
848 let sb = Scrollback::new(0);
849 let reg = HyperlinkRegistry::new();
850 let ctx = default_ctx(&grid, &sb, ®);
851
852 let result = export_ansi(&ctx, &AnsiExportOptions::default());
853 assert!(result.ends_with("\x1b[0m"));
854 assert!(result.contains("hi"));
855 }
856
857 #[test]
858 fn ansi_export_stub_no_reset() {
859 let grid = make_grid(5, &["hi"]);
860 let sb = Scrollback::new(0);
861 let reg = HyperlinkRegistry::new();
862 let ctx = default_ctx(&grid, &sb, ®);
863
864 let opts = AnsiExportOptions {
865 reset_at_end: false,
866 ..Default::default()
867 };
868 let result = export_ansi(&ctx, &opts);
869 assert!(!result.ends_with("\x1b[0m"));
870 }
871
872 #[test]
875 fn html_export_stub_wraps_in_pre() {
876 let grid = make_grid(5, &["hi"]);
877 let sb = Scrollback::new(0);
878 let reg = HyperlinkRegistry::new();
879 let ctx = default_ctx(&grid, &sb, ®);
880
881 let result = export_html(&ctx, &HtmlExportOptions::default());
882 assert!(result.starts_with("<pre"));
883 assert!(result.ends_with("</pre>"));
884 assert!(result.contains("hi"));
885 }
886
887 #[test]
888 fn html_export_escapes_special_chars() {
889 let grid = make_grid(20, &["<b>test</b> & \"ok\""]);
890 let sb = Scrollback::new(0);
891 let reg = HyperlinkRegistry::new();
892 let ctx = default_ctx(&grid, &sb, ®);
893
894 let result = export_html(&ctx, &HtmlExportOptions::default());
895 assert!(result.contains("<b>"));
896 assert!(result.contains("&"));
897 assert!(result.contains("""));
898 }
899
900 #[test]
903 fn html_escape_special_chars() {
904 assert_eq!(html_escape("<>&\"'"), "<>&"'");
905 assert_eq!(html_escape("hello"), "hello");
906 assert_eq!(html_escape(""), "");
907 }
908
909 #[test]
910 fn color_to_sgr_truecolor() {
911 assert_eq!(
912 color_to_sgr(Color::Rgb(255, 0, 128), 38, ColorDepth::TrueColor),
913 Some("38;2;255;0;128".into())
914 );
915 assert_eq!(
916 color_to_sgr(Color::Named(1), 38, ColorDepth::TrueColor),
917 Some("38;5;1".into())
918 );
919 assert_eq!(
920 color_to_sgr(Color::Indexed(200), 48, ColorDepth::TrueColor),
921 Some("48;5;200".into())
922 );
923 assert_eq!(
924 color_to_sgr(Color::Default, 38, ColorDepth::TrueColor),
925 None
926 );
927 }
928
929 #[test]
930 fn color_to_sgr_named16() {
931 assert_eq!(
932 color_to_sgr(Color::Named(1), 38, ColorDepth::Named16),
933 Some("31".into()) );
935 assert_eq!(
936 color_to_sgr(Color::Named(9), 38, ColorDepth::Named16),
937 Some("91".into()) );
939 assert_eq!(
940 color_to_sgr(Color::Named(0), 48, ColorDepth::Named16),
941 Some("40".into())
942 );
943 assert_eq!(
945 color_to_sgr(Color::Rgb(255, 0, 0), 38, ColorDepth::Named16),
946 None
947 );
948 }
949
950 #[test]
951 fn color_to_sgr_no_color() {
952 assert_eq!(
953 color_to_sgr(Color::Rgb(255, 0, 0), 38, ColorDepth::NoColor),
954 None
955 );
956 assert_eq!(color_to_sgr(Color::Named(1), 38, ColorDepth::NoColor), None);
957 }
958
959 #[test]
960 fn color_to_sgr_indexed256() {
961 assert_eq!(
962 color_to_sgr(Color::Indexed(42), 38, ColorDepth::Indexed256),
963 Some("38;5;42".into())
964 );
965 let result = color_to_sgr(Color::Rgb(255, 0, 0), 38, ColorDepth::Indexed256);
967 assert!(result.is_some());
968 assert!(result.unwrap().starts_with("38;5;"));
969 }
970
971 #[test]
972 fn rgb_to_256_grayscale() {
973 assert_eq!(rgb_to_256(0, 0, 0), 16);
975 assert_eq!(rgb_to_256(255, 255, 255), 231);
977 let idx = rgb_to_256(128, 128, 128);
979 assert!(idx >= 232);
980 }
981
982 #[test]
983 fn rgb_to_256_color_cube() {
984 let idx = rgb_to_256(255, 0, 0);
986 assert_eq!(idx, 16 + 36 * 5); }
988
989 #[test]
990 fn sgr_flags_to_params_empty() {
991 assert!(sgr_flags_to_params(SgrFlags::empty()).is_empty());
992 }
993
994 #[test]
995 fn sgr_flags_to_params_all_flags() {
996 let flags = SgrFlags::BOLD
997 | SgrFlags::DIM
998 | SgrFlags::ITALIC
999 | SgrFlags::UNDERLINE
1000 | SgrFlags::BLINK
1001 | SgrFlags::INVERSE
1002 | SgrFlags::HIDDEN
1003 | SgrFlags::STRIKETHROUGH
1004 | SgrFlags::DOUBLE_UNDERLINE
1005 | SgrFlags::OVERLINE;
1006 let params = sgr_flags_to_params(flags);
1007 assert_eq!(params, vec![1, 2, 3, 4, 5, 7, 8, 9, 21, 53]);
1008 }
1009
1010 #[test]
1011 fn sgr_flags_to_params_single() {
1012 assert_eq!(sgr_flags_to_params(SgrFlags::BOLD), vec![1]);
1013 assert_eq!(sgr_flags_to_params(SgrFlags::ITALIC), vec![3]);
1014 assert_eq!(sgr_flags_to_params(SgrFlags::STRIKETHROUGH), vec![9]);
1015 }
1016
1017 #[test]
1020 fn line_ending_as_str() {
1021 assert_eq!(LineEnding::Lf.as_str(), "\n");
1022 assert_eq!(LineEnding::CrLf.as_str(), "\r\n");
1023 }
1024
1025 #[test]
1026 fn line_ending_default_is_lf() {
1027 assert_eq!(LineEnding::default(), LineEnding::Lf);
1028 }
1029
1030 #[test]
1033 fn ansi_options_default() {
1034 let opts = AnsiExportOptions::default();
1035 assert_eq!(opts.range, ExportRange::Viewport);
1036 assert_eq!(opts.color_depth, ColorDepth::TrueColor);
1037 assert!(opts.trim_trailing);
1038 assert!(opts.reset_at_end);
1039 assert!(opts.join_soft_wraps);
1040 }
1041
1042 #[test]
1043 fn html_options_default() {
1044 let opts = HtmlExportOptions::default();
1045 assert_eq!(opts.range, ExportRange::Viewport);
1046 assert!(opts.inline_styles);
1047 assert_eq!(opts.class_prefix, "ft");
1048 assert!(opts.render_hyperlinks);
1049 assert!(opts.trim_trailing);
1050 }
1051
1052 #[test]
1053 fn text_options_default() {
1054 let opts = TextExportOptions::default();
1055 assert_eq!(opts.range, ExportRange::Viewport);
1056 assert!(opts.trim_trailing);
1057 assert!(opts.join_soft_wraps);
1058 assert!(opts.include_combining);
1059 }
1060
1061 #[test]
1064 fn text_export_is_deterministic() {
1065 let grid = make_grid(10, &["hello", "world"]);
1066 let sb = make_scrollback(&[("history", false)]);
1067 let reg = HyperlinkRegistry::new();
1068 let ctx = default_ctx(&grid, &sb, ®);
1069
1070 let opts = TextExportOptions {
1071 range: ExportRange::Full,
1072 ..Default::default()
1073 };
1074
1075 let a = export_text(&ctx, &opts);
1076 let b = export_text(&ctx, &opts);
1077 assert_eq!(a, b, "export_text must be deterministic for fixed inputs");
1078 }
1079
1080 #[test]
1081 fn row_cells_to_text_basic() {
1082 let cells: Vec<Cell> = "hello".chars().map(Cell::new).collect();
1083 assert_eq!(row_cells_to_text(&cells, true, true), "hello");
1084 }
1085
1086 #[test]
1087 fn row_cells_to_text_trims_trailing() {
1088 let mut cells: Vec<Cell> = "hi".chars().map(Cell::new).collect();
1089 cells.push(Cell::default()); cells.push(Cell::default()); assert_eq!(row_cells_to_text(&cells, true, true), "hi");
1092 assert_eq!(row_cells_to_text(&cells, true, false), "hi ");
1093 }
1094
1095 #[test]
1096 fn row_cells_to_text_wide_char() {
1097 let (lead, cont) = Cell::wide('中', SgrAttrs::default());
1098 let mut cells = vec![lead, cont];
1099 cells.push(Cell::new('x'));
1100 assert_eq!(row_cells_to_text(&cells, true, true), "中x");
1101 }
1102
1103 #[test]
1104 fn row_cells_to_text_combining() {
1105 let mut cell = Cell::new('e');
1106 cell.push_combining('\u{0301}');
1107 let cells = vec![cell];
1108 assert_eq!(row_cells_to_text(&cells, true, true), "e\u{0301}");
1109 assert_eq!(row_cells_to_text(&cells, false, true), "e");
1110 }
1111}