1#![forbid(unsafe_code)]
2
3use std::io::{self, BufWriter, Write};
36
37use crate::ansi::{self, EraseLineMode};
38use crate::buffer::Buffer;
39use crate::cell::{Cell, CellAttrs, GraphemeId, PackedRgba, StyleFlags};
40use crate::char_width;
41use crate::counting_writer::{CountingWriter, PresentStats, StatsCollector};
42use crate::diff::{BufferDiff, ChangeRun};
43use crate::display_width;
44use crate::grapheme_pool::GraphemePool;
45use crate::link_registry::LinkRegistry;
46use crate::sanitize::sanitize;
47
48pub use ftui_core::terminal_capabilities::TerminalCapabilities;
49
50const BUFFER_CAPACITY: usize = 64 * 1024;
52const MAX_SAFE_HYPERLINK_URL_BYTES: usize = 4096;
54
55#[inline]
56fn is_safe_hyperlink_url(url: &str) -> bool {
57 url.len() <= MAX_SAFE_HYPERLINK_URL_BYTES && !url.chars().any(char::is_control)
58}
59
60mod cost_model {
71 use smallvec::SmallVec;
72
73 use super::ChangeRun;
74
75 #[inline]
77 fn digit_count(n: u16) -> usize {
78 if n < 10 {
82 1
83 } else if n < 100 {
84 2
85 } else if n < 1000 {
86 3
87 } else if n < 10000 {
88 4
89 } else {
90 5
91 }
92 }
93
94 #[inline]
96 pub fn cup_cost(row: u16, col: u16) -> usize {
97 4 + digit_count(row.saturating_add(1)) + digit_count(col.saturating_add(1))
99 }
100
101 #[inline]
103 pub fn cha_cost(col: u16) -> usize {
104 3 + digit_count(col.saturating_add(1))
106 }
107
108 #[inline]
110 pub fn cuf_cost(n: u16) -> usize {
111 match n {
112 0 => 0,
113 1 => 3, _ => 3 + digit_count(n),
115 }
116 }
117
118 #[inline]
120 pub fn cub_cost(n: u16) -> usize {
121 match n {
122 0 => 0,
123 1 => 3, _ => 3 + digit_count(n),
125 }
126 }
127
128 pub fn cheapest_move_cost(
131 from_x: Option<u16>,
132 from_y: Option<u16>,
133 to_x: u16,
134 to_y: u16,
135 ) -> usize {
136 if from_x == Some(to_x) && from_y == Some(to_y) {
138 return 0;
139 }
140
141 match (from_x, from_y) {
142 (Some(fx), Some(fy)) if fy == to_y => {
143 let cha = cha_cost(to_x);
148 if to_x > fx {
149 let cuf = cuf_cost(to_x - fx);
150 cha.min(cuf)
151 } else if to_x < fx {
152 let cub = cub_cost(fx - to_x);
153 cha.min(cub)
154 } else {
155 0
156 }
157 }
158 _ => cup_cost(to_y, to_x),
159 }
160 }
161
162 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
164 pub struct RowSpan {
165 pub y: u16,
167 pub x0: u16,
169 pub x1: u16,
171 }
172
173 #[derive(Debug, Clone, PartialEq, Eq)]
179 pub struct RowPlan {
180 spans: SmallVec<[RowSpan; 8]>,
181 total_cost: usize,
182 }
183
184 impl RowPlan {
185 #[inline]
186 #[must_use]
187 pub fn spans(&self) -> &[RowSpan] {
188 &self.spans
189 }
190
191 #[inline]
193 #[allow(dead_code)] pub fn total_cost(&self) -> usize {
195 self.total_cost
196 }
197 }
198
199 #[derive(Debug, Default)]
204 pub struct RowPlanScratch {
205 prefix_cells: Vec<usize>,
206 dp: Vec<usize>,
207 prev: Vec<usize>,
208 }
209
210 #[allow(dead_code)]
219 pub fn plan_row(row_runs: &[ChangeRun], prev_x: Option<u16>, prev_y: Option<u16>) -> RowPlan {
220 let mut scratch = RowPlanScratch::default();
221 plan_row_reuse(row_runs, prev_x, prev_y, &mut scratch)
222 }
223
224 pub fn plan_row_reuse(
227 row_runs: &[ChangeRun],
228 prev_x: Option<u16>,
229 prev_y: Option<u16>,
230 scratch: &mut RowPlanScratch,
231 ) -> RowPlan {
232 if row_runs.is_empty() {
233 return RowPlan {
234 spans: SmallVec::new(),
235 total_cost: 0,
236 };
237 }
238
239 let row_y = row_runs[0].y;
240 let run_count = row_runs.len();
241
242 if run_count == 1 {
243 let run = row_runs[0];
244 let mut spans: SmallVec<[RowSpan; 8]> = SmallVec::new();
245 spans.push(RowSpan {
246 y: row_y,
247 x0: run.x0,
248 x1: run.x1,
249 });
250 return RowPlan {
251 spans,
252 total_cost: cheapest_move_cost(prev_x, prev_y, run.x0, row_y)
253 .saturating_add(run.len()),
254 };
255 }
256
257 scratch.prefix_cells.clear();
259 scratch.prefix_cells.resize(run_count + 1, 0);
260 scratch.dp.clear();
261 scratch.dp.resize(run_count, usize::MAX);
262 scratch.prev.clear();
263 scratch.prev.resize(run_count, 0);
264
265 for (i, run) in row_runs.iter().enumerate() {
267 scratch.prefix_cells[i + 1] = scratch.prefix_cells[i] + run.len();
268 }
269
270 for j in 0..run_count {
272 let mut best_cost = usize::MAX;
273 let mut best_i = j;
274
275 for i in (0..=j).rev() {
280 let changed_cells = scratch.prefix_cells[j + 1] - scratch.prefix_cells[i];
281 let total_cells =
282 (row_runs[j].x1 as usize).saturating_sub(row_runs[i].x0 as usize) + 1;
283 let gap_cells = total_cells.saturating_sub(changed_cells);
284
285 if gap_cells > 32 {
286 break;
287 }
288
289 let from_x = if i == 0 {
290 prev_x
291 } else {
292 Some(row_runs[i - 1].x1.saturating_add(1))
293 };
294 let from_y = if i == 0 { prev_y } else { Some(row_y) };
295
296 let move_cost = cheapest_move_cost(from_x, from_y, row_runs[i].x0, row_y);
297 let gap_overhead = gap_cells * 2; let emit_cost = changed_cells + gap_overhead;
299
300 let prev_cost = if i == 0 { 0 } else { scratch.dp[i - 1] };
301 let cost = prev_cost
302 .saturating_add(move_cost)
303 .saturating_add(emit_cost);
304
305 if cost < best_cost {
306 best_cost = cost;
307 best_i = i;
308 }
309 }
310
311 scratch.dp[j] = best_cost;
312 scratch.prev[j] = best_i;
313 }
314
315 let mut spans: SmallVec<[RowSpan; 8]> = SmallVec::new();
317 let mut j = run_count - 1;
318 loop {
319 let i = scratch.prev[j];
320 spans.push(RowSpan {
321 y: row_y,
322 x0: row_runs[i].x0,
323 x1: row_runs[j].x1,
324 });
325 if i == 0 {
326 break;
327 }
328 j = i - 1;
329 }
330 spans.reverse();
331
332 RowPlan {
333 spans,
334 total_cost: scratch.dp[run_count - 1],
335 }
336 }
337}
338
339#[derive(Debug, Clone, Copy, PartialEq, Eq)]
341struct CellStyle {
342 fg: PackedRgba,
343 bg: PackedRgba,
344 attrs: StyleFlags,
345}
346
347#[derive(Debug, Clone, Copy, PartialEq, Eq)]
348enum PreparedContent {
349 Empty,
350 Char(char),
351 Grapheme(GraphemeId),
352}
353
354impl PreparedContent {
355 #[inline]
356 fn from_cell(cell: &Cell) -> (Self, usize) {
357 let content = cell.content;
358 if let Some(grapheme_id) = content.grapheme_id() {
359 (Self::Grapheme(grapheme_id), content.width())
360 } else if let Some(ch) = content.as_char() {
361 let width = if ch.is_ascii() {
362 match ch {
363 '\t' | '\n' | '\r' => 1,
364 ' '..='~' => 1,
365 _ => 0,
366 }
367 } else {
368 char_width(ch)
369 };
370 (Self::Char(ch), width)
371 } else {
372 (Self::Empty, 0)
373 }
374 }
375}
376
377impl Default for CellStyle {
378 fn default() -> Self {
379 Self {
380 fg: PackedRgba::TRANSPARENT,
381 bg: PackedRgba::TRANSPARENT,
382 attrs: StyleFlags::empty(),
383 }
384 }
385}
386impl CellStyle {
387 fn from_cell(cell: &Cell) -> Self {
388 Self {
389 fg: cell.fg,
390 bg: cell.bg,
391 attrs: cell.attrs.flags(),
392 }
393 }
394}
395
396pub struct Presenter<W: Write> {
401 writer: CountingWriter<BufWriter<W>>,
403 current_style: Option<CellStyle>,
405 current_link: Option<u32>,
407 cursor_x: Option<u16>,
409 cursor_y: Option<u16>,
411 viewport_offset_y: u16,
413 capabilities: TerminalCapabilities,
415 hyperlinks_enabled: bool,
417 plan_scratch: cost_model::RowPlanScratch,
420 runs_buf: Vec<ChangeRun>,
422}
423
424impl<W: Write> Presenter<W> {
425 pub fn new(writer: W, capabilities: TerminalCapabilities) -> Self {
427 Self {
428 writer: CountingWriter::new(BufWriter::with_capacity(BUFFER_CAPACITY, writer)),
429 current_style: None,
430 current_link: None,
431 cursor_x: None,
432 cursor_y: None,
433 viewport_offset_y: 0,
434 hyperlinks_enabled: capabilities.use_hyperlinks(),
435 capabilities,
436 plan_scratch: cost_model::RowPlanScratch::default(),
437 runs_buf: Vec::new(),
438 }
439 }
440
441 pub fn writer_mut(&mut self) -> &mut W {
447 self.writer.inner_mut().get_mut()
448 }
449
450 pub fn counting_writer_mut(&mut self) -> &mut CountingWriter<BufWriter<W>> {
455 &mut self.writer
456 }
457
458 pub fn set_viewport_offset_y(&mut self, offset: u16) {
463 self.viewport_offset_y = offset;
464 }
465
466 #[inline]
468 pub fn capabilities(&self) -> &TerminalCapabilities {
469 &self.capabilities
470 }
471
472 pub fn present(&mut self, buffer: &Buffer, diff: &BufferDiff) -> io::Result<PresentStats> {
481 self.present_with_pool(buffer, diff, None, None)
482 }
483
484 pub fn present_with_pool(
486 &mut self,
487 buffer: &Buffer,
488 diff: &BufferDiff,
489 pool: Option<&GraphemePool>,
490 links: Option<&LinkRegistry>,
491 ) -> io::Result<PresentStats> {
492 let bracket_supported = self.capabilities.use_sync_output();
493
494 #[cfg(feature = "tracing")]
495 let _span = tracing::info_span!(
496 "present",
497 width = buffer.width(),
498 height = buffer.height(),
499 changes = diff.len()
500 );
501 #[cfg(feature = "tracing")]
502 let _guard = _span.enter();
503
504 #[cfg(feature = "tracing")]
505 let fallback_used = !bracket_supported;
506 #[cfg(feature = "tracing")]
507 let _sync_span = tracing::info_span!(
508 "render.sync_bracket",
509 bracket_supported,
510 fallback_used,
511 frame_bytes = tracing::field::Empty,
512 );
513 #[cfg(feature = "tracing")]
514 let _sync_guard = _sync_span.enter();
515
516 diff.runs_into(&mut self.runs_buf);
518 let run_count = self.runs_buf.len();
519 let cells_changed = diff.len();
520
521 self.writer.reset_counter();
523 let collector = StatsCollector::start(cells_changed, run_count);
524
525 if bracket_supported {
529 if let Err(err) = ansi::sync_begin(&mut self.writer) {
530 let _ = ansi::sync_end(&mut self.writer);
533 let _ = self.writer.flush();
534 return Err(err);
535 }
536 } else {
537 #[cfg(feature = "tracing")]
538 tracing::warn!("sync brackets unsupported; falling back to cursor-hide strategy");
539 ansi::cursor_hide(&mut self.writer)?;
540 }
541
542 let emit_result = self.emit_diff_runs(buffer, pool, links);
544
545 let frame_end_result = self.finish_frame();
547
548 let bracket_end_result = if bracket_supported {
549 ansi::sync_end(&mut self.writer)
550 } else {
551 ansi::cursor_show(&mut self.writer)
552 };
553
554 let flush_result = self.writer.flush();
555
556 let cleanup_error = frame_end_result
560 .err()
561 .or_else(|| bracket_end_result.err())
562 .or_else(|| flush_result.err());
563 if let Some(err) = cleanup_error {
564 return Err(err);
565 }
566 emit_result?;
567
568 let stats = collector.finish(self.writer.bytes_written());
569
570 #[cfg(feature = "tracing")]
571 {
572 _sync_span.record("frame_bytes", stats.bytes_emitted);
573 stats.log();
574 tracing::trace!("frame presented");
575 }
576
577 Ok(stats)
578 }
579
580 pub fn emit_diff_runs(
586 &mut self,
587 buffer: &Buffer,
588 pool: Option<&GraphemePool>,
589 links: Option<&LinkRegistry>,
590 ) -> io::Result<()> {
591 #[cfg(feature = "tracing")]
592 let _span = tracing::debug_span!("emit_diff");
593 #[cfg(feature = "tracing")]
594 let _guard = _span.enter();
595
596 #[cfg(feature = "tracing")]
597 tracing::trace!(run_count = self.runs_buf.len(), "emitting runs (reuse)");
598
599 let mut i = 0;
601 while i < self.runs_buf.len() {
602 let row_y = self.runs_buf[i].y;
603
604 let row_start = i;
606 while i < self.runs_buf.len() && self.runs_buf[i].y == row_y {
607 i += 1;
608 }
609 let row_runs = &self.runs_buf[row_start..i];
610
611 let plan = cost_model::plan_row_reuse(
612 row_runs,
613 self.cursor_x,
614 self.cursor_y,
615 &mut self.plan_scratch,
616 );
617
618 #[cfg(feature = "tracing")]
619 tracing::trace!(
620 row = row_y,
621 spans = plan.spans().len(),
622 cost = plan.total_cost(),
623 "row plan"
624 );
625
626 let row = buffer.row_cells(row_y);
627 for span in plan.spans() {
628 self.move_cursor_optimal(span.x0, span.y)?;
629 let start = span.x0 as usize;
631 let end = span.x1 as usize;
632 debug_assert!(start <= end);
633 debug_assert!(end < row.len());
634 let mut idx = start;
635 while idx <= end {
636 let cell = &row[idx];
637 self.emit_cell(idx as u16, cell, pool, links)?;
638
639 let mut advance = 1usize;
648 let width = cell.content.width();
649 let should_repair_invalid_tail = cell.content.as_char().is_some()
650 || (cell.content.is_grapheme() && width == 2);
651 if width > 1 && should_repair_invalid_tail {
652 for off in 1..width {
653 let tx = idx + off;
654 if tx >= row.len() {
655 break;
656 }
657 if row[tx].is_continuation() {
658 if tx <= end {
659 advance = advance.max(off + 1);
660 }
661 continue;
662 }
663 self.move_cursor_optimal(tx as u16, span.y)?;
665 self.emit_orphan_continuation_space(tx as u16, links)?;
666 if tx <= end {
667 advance = advance.max(off + 1);
668 }
669 }
670 }
671
672 idx = idx.saturating_add(advance);
673 }
674 }
675 }
676 Ok(())
677 }
678
679 pub fn prepare_runs(&mut self, diff: &BufferDiff) {
683 diff.runs_into(&mut self.runs_buf);
684 }
685
686 pub fn finish_frame(&mut self) -> io::Result<()> {
691 let reset_result = ansi::sgr_reset(&mut self.writer);
692 self.current_style = None;
693
694 let hyperlink_close_result = if self.current_link.is_some() {
695 let res = ansi::hyperlink_end(&mut self.writer);
696 if res.is_ok() {
697 self.current_link = None;
698 }
699 Some(res)
700 } else {
701 None
702 };
703
704 if let Some(err) = reset_result
705 .err()
706 .or_else(|| hyperlink_close_result.and_then(Result::err))
707 {
708 return Err(err);
709 }
710
711 Ok(())
712 }
713
714 pub fn finish_frame_best_effort(&mut self) {
716 let _ = ansi::sgr_reset(&mut self.writer);
717 self.current_style = None;
718
719 if self.current_link.is_some() {
720 let _ = ansi::hyperlink_end(&mut self.writer);
721 self.current_link = None;
722 }
723 }
724
725 fn emit_cell(
727 &mut self,
728 x: u16,
729 cell: &Cell,
730 pool: Option<&GraphemePool>,
731 links: Option<&LinkRegistry>,
732 ) -> io::Result<()> {
733 if let Some(cx) = self.cursor_x {
740 if cx != x && !cell.is_continuation() {
741 if let Some(y) = self.cursor_y {
743 self.move_cursor_optimal(x, y)?;
744 }
745 }
746 } else {
747 if let Some(y) = self.cursor_y {
749 self.move_cursor_optimal(x, y)?;
750 }
751 }
752
753 if cell.is_continuation() {
762 match self.cursor_x {
763 Some(cx) if cx > x => return Ok(()),
765 Some(cx) => {
766 if cx < x
769 && let Some(y) = self.cursor_y
770 {
771 self.move_cursor_optimal(x, y)?;
772 }
773 return self.emit_orphan_continuation_space(x, links);
774 }
775 None => {
777 if let Some(y) = self.cursor_y {
778 self.move_cursor_optimal(x, y)?;
779 }
780 return self.emit_orphan_continuation_space(x, links);
781 }
782 }
783 }
784
785 self.emit_style_changes(cell)?;
787
788 self.emit_link_changes(cell, links)?;
790
791 let (prepared_content, raw_width) = PreparedContent::from_cell(cell);
792
793 let is_zero_width_content = raw_width == 0 && !cell.is_empty() && !cell.is_continuation();
796
797 if is_zero_width_content {
798 self.writer.write_all(b"\xEF\xBF\xBD")?;
800 } else {
801 self.emit_content(prepared_content, raw_width, pool)?;
803 }
804
805 if let Some(cx) = self.cursor_x {
807 let width = if cell.is_empty() || is_zero_width_content {
810 1
811 } else {
812 raw_width
813 };
814 self.cursor_x = Some(cx.saturating_add(width as u16));
815 }
816
817 Ok(())
818 }
819
820 fn emit_orphan_continuation_space(
825 &mut self,
826 x: u16,
827 links: Option<&LinkRegistry>,
828 ) -> io::Result<()> {
829 let blank = Cell::default();
830 self.emit_style_changes(&blank)?;
831 self.emit_link_changes(&blank, links)?;
832 self.writer.write_all(b" ")?;
833 self.cursor_x = Some(x.saturating_add(1));
834 Ok(())
835 }
836
837 fn emit_style_changes(&mut self, cell: &Cell) -> io::Result<()> {
843 let new_style = CellStyle::from_cell(cell);
844
845 if self.current_style == Some(new_style) {
847 return Ok(());
848 }
849
850 match self.current_style {
851 None => {
852 self.emit_style_full(new_style)?;
854 }
855 Some(old_style) => {
856 self.emit_style_delta(old_style, new_style)?;
857 }
858 }
859
860 self.current_style = Some(new_style);
861 Ok(())
862 }
863
864 fn emit_style_full(&mut self, style: CellStyle) -> io::Result<()> {
866 ansi::sgr_reset(&mut self.writer)?;
867 if style.fg.a() > 0 {
868 ansi::sgr_fg_packed(&mut self.writer, style.fg)?;
869 }
870 if style.bg.a() > 0 {
871 ansi::sgr_bg_packed(&mut self.writer, style.bg)?;
872 }
873 if !style.attrs.is_empty() {
874 ansi::sgr_flags(&mut self.writer, style.attrs)?;
875 }
876 Ok(())
877 }
878
879 #[inline]
880 fn dec_len_u8(value: u8) -> u32 {
881 if value >= 100 {
882 3
883 } else if value >= 10 {
884 2
885 } else {
886 1
887 }
888 }
889
890 #[inline]
891 fn sgr_code_len(code: u8) -> u32 {
892 2 + Self::dec_len_u8(code) + 1
893 }
894
895 #[inline]
896 fn sgr_flags_len(flags: StyleFlags) -> u32 {
897 if flags.is_empty() {
898 return 0;
899 }
900 let mut count = 0u32;
901 let mut digits = 0u32;
902 for (flag, codes) in ansi::FLAG_TABLE {
903 if flags.contains(flag) {
904 count += 1;
905 digits += Self::dec_len_u8(codes.on);
906 }
907 }
908 if count == 0 {
909 return 0;
910 }
911 3 + digits + (count - 1)
912 }
913
914 #[inline]
915 fn sgr_flags_off_len(flags: StyleFlags) -> u32 {
916 if flags.is_empty() {
917 return 0;
918 }
919 let mut len = 0u32;
920 for (flag, codes) in ansi::FLAG_TABLE {
921 if flags.contains(flag) {
922 len += Self::sgr_code_len(codes.off);
923 }
924 }
925 len
926 }
927
928 #[inline]
929 fn sgr_rgb_len(color: PackedRgba) -> u32 {
930 10 + Self::dec_len_u8(color.r()) + Self::dec_len_u8(color.g()) + Self::dec_len_u8(color.b())
931 }
932
933 fn emit_style_delta(&mut self, old: CellStyle, new: CellStyle) -> io::Result<()> {
938 let attrs_removed = old.attrs & !new.attrs;
939 let attrs_added = new.attrs & !old.attrs;
940 let fg_changed = old.fg != new.fg;
941 let bg_changed = old.bg != new.bg;
942
943 if old.attrs == new.attrs {
947 if fg_changed {
948 ansi::sgr_fg_packed(&mut self.writer, new.fg)?;
949 }
950 if bg_changed {
951 ansi::sgr_bg_packed(&mut self.writer, new.bg)?;
952 }
953 return Ok(());
954 }
955
956 let mut collateral = StyleFlags::empty();
957 if attrs_removed.contains(StyleFlags::BOLD) && new.attrs.contains(StyleFlags::DIM) {
958 collateral |= StyleFlags::DIM;
959 }
960 if attrs_removed.contains(StyleFlags::DIM) && new.attrs.contains(StyleFlags::BOLD) {
961 collateral |= StyleFlags::BOLD;
962 }
963
964 let mut delta_len = 0u32;
965 delta_len += Self::sgr_flags_off_len(attrs_removed);
966 delta_len += Self::sgr_flags_len(collateral);
967 delta_len += Self::sgr_flags_len(attrs_added);
968 if fg_changed {
969 delta_len += if new.fg.a() == 0 {
970 5
971 } else {
972 Self::sgr_rgb_len(new.fg)
973 };
974 }
975 if bg_changed {
976 delta_len += if new.bg.a() == 0 {
977 5
978 } else {
979 Self::sgr_rgb_len(new.bg)
980 };
981 }
982
983 let mut baseline_len = 4u32;
984 if new.fg.a() > 0 {
985 baseline_len += Self::sgr_rgb_len(new.fg);
986 }
987 if new.bg.a() > 0 {
988 baseline_len += Self::sgr_rgb_len(new.bg);
989 }
990 baseline_len += Self::sgr_flags_len(new.attrs);
991
992 if delta_len > baseline_len {
993 return self.emit_style_full(new);
994 }
995
996 if !attrs_removed.is_empty() {
998 let collateral = ansi::sgr_flags_off(&mut self.writer, attrs_removed, new.attrs)?;
999 if !collateral.is_empty() {
1001 ansi::sgr_flags(&mut self.writer, collateral)?;
1002 }
1003 }
1004
1005 if !attrs_added.is_empty() {
1007 ansi::sgr_flags(&mut self.writer, attrs_added)?;
1008 }
1009
1010 if fg_changed {
1012 ansi::sgr_fg_packed(&mut self.writer, new.fg)?;
1013 }
1014
1015 if bg_changed {
1017 ansi::sgr_bg_packed(&mut self.writer, new.bg)?;
1018 }
1019
1020 Ok(())
1021 }
1022
1023 fn emit_link_changes(&mut self, cell: &Cell, links: Option<&LinkRegistry>) -> io::Result<()> {
1025 if !self.hyperlinks_enabled {
1028 if self.current_link.is_none() {
1029 return Ok(());
1030 }
1031 if self.current_link.is_some() {
1032 ansi::hyperlink_end(&mut self.writer)?;
1033 }
1034 self.current_link = None;
1035 return Ok(());
1036 }
1037
1038 let raw_link_id = cell.attrs.link_id();
1039 let new_link = if raw_link_id == CellAttrs::LINK_ID_NONE {
1040 None
1041 } else {
1042 Some(raw_link_id)
1043 };
1044
1045 if self.current_link == new_link {
1047 return Ok(());
1048 }
1049
1050 if self.current_link.is_some() {
1052 ansi::hyperlink_end(&mut self.writer)?;
1053 }
1054
1055 let actually_opened = if let (Some(link_id), Some(registry)) = (new_link, links)
1057 && let Some(url) = registry.get(link_id)
1058 && is_safe_hyperlink_url(url)
1059 {
1060 ansi::hyperlink_start(&mut self.writer, url)?;
1061 true
1062 } else {
1063 false
1064 };
1065
1066 self.current_link = if actually_opened { new_link } else { None };
1068 Ok(())
1069 }
1070
1071 fn emit_content(
1073 &mut self,
1074 content: PreparedContent,
1075 raw_width: usize,
1076 pool: Option<&GraphemePool>,
1077 ) -> io::Result<()> {
1078 match content {
1079 PreparedContent::Grapheme(grapheme_id) => {
1080 if let Some(pool) = pool
1081 && let Some(text) = pool.get(grapheme_id)
1082 {
1083 let safe = sanitize(text);
1084 if !safe.is_empty() && display_width(safe.as_ref()) == raw_width {
1085 return self.writer.write_all(safe.as_bytes());
1086 }
1087 }
1088 if raw_width > 0 {
1092 for _ in 0..raw_width {
1093 self.writer.write_all(b"?")?;
1094 }
1095 }
1096 Ok(())
1097 }
1098 PreparedContent::Char(ch) => {
1099 if ch.is_ascii() {
1100 let byte = if ch.is_ascii_control() {
1105 b' '
1106 } else {
1107 ch as u8
1108 };
1109 return self.writer.write_all(&[byte]);
1110 }
1111 let safe_ch = if ch.is_control() { ' ' } else { ch };
1113 let mut buf = [0u8; 4];
1114 let encoded = safe_ch.encode_utf8(&mut buf);
1115 self.writer.write_all(encoded.as_bytes())
1116 }
1117 PreparedContent::Empty => {
1118 self.writer.write_all(b" ")
1120 }
1121 }
1122 }
1123
1124 fn move_cursor_to(&mut self, x: u16, y: u16) -> io::Result<()> {
1126 if self.cursor_x == Some(x) && self.cursor_y == Some(y) {
1128 return Ok(());
1129 }
1130
1131 ansi::cup(
1133 &mut self.writer,
1134 y.saturating_add(self.viewport_offset_y),
1135 x,
1136 )?;
1137 self.cursor_x = Some(x);
1138 self.cursor_y = Some(y);
1139 Ok(())
1140 }
1141
1142 fn move_cursor_optimal(&mut self, x: u16, y: u16) -> io::Result<()> {
1147 if self.cursor_x == Some(x) && self.cursor_y == Some(y) {
1149 return Ok(());
1150 }
1151
1152 let same_row = self.cursor_y == Some(y);
1154 let actual_y = y.saturating_add(self.viewport_offset_y);
1155
1156 if same_row {
1157 if let Some(cx) = self.cursor_x {
1158 if x > cx {
1159 let dx = x - cx;
1161 let cuf = cost_model::cuf_cost(dx);
1162 let cha = cost_model::cha_cost(x);
1163 let cup = cost_model::cup_cost(actual_y, x);
1164
1165 if cuf <= cha && cuf <= cup {
1166 ansi::cuf(&mut self.writer, dx)?;
1167 } else if cha <= cup {
1168 ansi::cha(&mut self.writer, x)?;
1169 } else {
1170 ansi::cup(&mut self.writer, actual_y, x)?;
1171 }
1172 } else if x < cx {
1173 let dx = cx - x;
1175 let cub = cost_model::cub_cost(dx);
1176 let cha = cost_model::cha_cost(x);
1177 let cup = cost_model::cup_cost(actual_y, x);
1178
1179 if cha <= cub && cha <= cup {
1180 ansi::cha(&mut self.writer, x)?;
1181 } else if cub <= cup {
1182 ansi::cub(&mut self.writer, dx)?;
1183 } else {
1184 ansi::cup(&mut self.writer, actual_y, x)?;
1185 }
1186 } else {
1187 }
1189 } else {
1190 ansi::cup(&mut self.writer, actual_y, x)?;
1193 }
1194 } else {
1195 ansi::cup(&mut self.writer, actual_y, x)?;
1197 }
1198
1199 self.cursor_x = Some(x);
1200 self.cursor_y = Some(y);
1201 Ok(())
1202 }
1203
1204 pub fn clear_screen(&mut self) -> io::Result<()> {
1206 ansi::erase_display(&mut self.writer, ansi::EraseDisplayMode::All)?;
1207 ansi::cup(&mut self.writer, 0, 0)?;
1208 self.cursor_x = Some(0);
1209 self.cursor_y = Some(0);
1210 self.writer.flush()
1211 }
1212
1213 pub fn clear_line(&mut self, y: u16) -> io::Result<()> {
1215 self.move_cursor_to(0, y)?;
1216 ansi::erase_line(&mut self.writer, EraseLineMode::All)?;
1217 self.writer.flush()
1218 }
1219
1220 pub fn hide_cursor(&mut self) -> io::Result<()> {
1222 ansi::cursor_hide(&mut self.writer)?;
1223 self.writer.flush()
1224 }
1225
1226 pub fn show_cursor(&mut self) -> io::Result<()> {
1228 ansi::cursor_show(&mut self.writer)?;
1229 self.writer.flush()
1230 }
1231
1232 pub fn position_cursor(&mut self, x: u16, y: u16) -> io::Result<()> {
1234 self.move_cursor_to(x, y)?;
1235 self.writer.flush()
1236 }
1237
1238 pub fn reset(&mut self) {
1242 self.current_style = None;
1243 self.current_link = None;
1244 self.cursor_x = None;
1245 self.cursor_y = None;
1246 }
1247
1248 pub fn flush(&mut self) -> io::Result<()> {
1250 self.writer.flush()
1251 }
1252
1253 pub fn into_inner(self) -> Result<W, io::Error> {
1257 self.writer
1258 .into_inner() .into_inner() .map_err(|e| e.into_error())
1261 }
1262}
1263
1264#[cfg(test)]
1265mod tests {
1266 use super::*;
1267 use crate::cell::{CellAttrs, CellContent};
1268 use crate::link_registry::LinkRegistry;
1269
1270 fn test_presenter() -> Presenter<Vec<u8>> {
1271 let caps = TerminalCapabilities::basic();
1272 Presenter::new(Vec::new(), caps)
1273 }
1274
1275 fn test_presenter_with_sync() -> Presenter<Vec<u8>> {
1276 let mut caps = TerminalCapabilities::basic();
1277 caps.sync_output = true;
1278 Presenter::new(Vec::new(), caps)
1279 }
1280
1281 fn test_presenter_with_hyperlinks() -> Presenter<Vec<u8>> {
1282 let mut caps = TerminalCapabilities::basic();
1283 caps.osc8_hyperlinks = true;
1284 Presenter::new(Vec::new(), caps)
1285 }
1286
1287 fn get_output(presenter: Presenter<Vec<u8>>) -> Vec<u8> {
1288 presenter.into_inner().unwrap()
1289 }
1290
1291 fn legacy_plan_row(
1292 row_runs: &[ChangeRun],
1293 prev_x: Option<u16>,
1294 prev_y: Option<u16>,
1295 ) -> Vec<cost_model::RowSpan> {
1296 if row_runs.is_empty() {
1297 return Vec::new();
1298 }
1299
1300 if row_runs.len() == 1 {
1301 let run = row_runs[0];
1302 return vec![cost_model::RowSpan {
1303 y: run.y,
1304 x0: run.x0,
1305 x1: run.x1,
1306 }];
1307 }
1308
1309 let row_y = row_runs[0].y;
1310 let first_x = row_runs[0].x0;
1311 let last_x = row_runs[row_runs.len() - 1].x1;
1312
1313 let mut sparse_cost: usize = 0;
1315 let mut cursor_x = prev_x;
1316 let mut cursor_y = prev_y;
1317
1318 for run in row_runs {
1319 let move_cost = cost_model::cheapest_move_cost(cursor_x, cursor_y, run.x0, run.y);
1320 let cells = (run.x1 as usize).saturating_sub(run.x0 as usize) + 1;
1321 sparse_cost += move_cost + cells;
1322 cursor_x = Some(run.x1.saturating_add(1));
1323 cursor_y = Some(row_y);
1324 }
1325
1326 let merge_move = cost_model::cheapest_move_cost(prev_x, prev_y, first_x, row_y);
1328 let total_cells = (last_x as usize).saturating_sub(first_x as usize) + 1;
1329 let changed_cells: usize = row_runs
1330 .iter()
1331 .map(|r| (r.x1 as usize).saturating_sub(r.x0 as usize) + 1)
1332 .sum();
1333 let gap_cells = total_cells.saturating_sub(changed_cells);
1334 let gap_overhead = gap_cells * 2;
1335 let merged_cost = merge_move + changed_cells + gap_overhead;
1336
1337 if merged_cost < sparse_cost {
1338 vec![cost_model::RowSpan {
1339 y: row_y,
1340 x0: first_x,
1341 x1: last_x,
1342 }]
1343 } else {
1344 row_runs
1345 .iter()
1346 .map(|run| cost_model::RowSpan {
1347 y: run.y,
1348 x0: run.x0,
1349 x1: run.x1,
1350 })
1351 .collect()
1352 }
1353 }
1354
1355 fn emit_spans_for_output(buffer: &Buffer, spans: &[cost_model::RowSpan]) -> Vec<u8> {
1356 let mut presenter = test_presenter();
1357
1358 for span in spans {
1359 presenter
1360 .move_cursor_optimal(span.x0, span.y)
1361 .expect("cursor move should succeed");
1362 for x in span.x0..=span.x1 {
1363 let cell = buffer.get_unchecked(x, span.y);
1364 presenter
1365 .emit_cell(x, cell, None, None)
1366 .expect("emit_cell should succeed");
1367 }
1368 }
1369
1370 presenter
1371 .writer
1372 .write_all(b"\x1b[0m")
1373 .expect("reset should succeed");
1374
1375 presenter.into_inner().expect("presenter output")
1376 }
1377
1378 #[test]
1379 fn empty_diff_produces_minimal_output() {
1380 let mut presenter = test_presenter();
1381 let buffer = Buffer::new(10, 10);
1382 let diff = BufferDiff::new();
1383
1384 presenter.present(&buffer, &diff).unwrap();
1385 let output = get_output(presenter);
1386
1387 assert!(output.starts_with(ansi::CURSOR_HIDE));
1389 assert!(output.ends_with(ansi::CURSOR_SHOW));
1390 assert!(
1392 output.windows(b"\x1b[0m".len()).any(|w| w == b"\x1b[0m"),
1393 "SGR reset should be present"
1394 );
1395 }
1396
1397 #[test]
1398 fn sync_output_wraps_frame() {
1399 let mut presenter = test_presenter_with_sync();
1400 let mut buffer = Buffer::new(3, 1);
1401 buffer.set_raw(0, 0, Cell::from_char('X'));
1402
1403 let old = Buffer::new(3, 1);
1404 let diff = BufferDiff::compute(&old, &buffer);
1405
1406 presenter.present(&buffer, &diff).unwrap();
1407 let output = get_output(presenter);
1408
1409 assert!(
1410 output.starts_with(ansi::SYNC_BEGIN),
1411 "sync output should begin with DEC 2026 begin"
1412 );
1413 assert!(
1414 output.ends_with(ansi::SYNC_END),
1415 "sync output should end with DEC 2026 end"
1416 );
1417 }
1418
1419 #[test]
1420 fn sync_output_obeys_mux_policy() {
1421 let caps = TerminalCapabilities::builder()
1422 .sync_output(true)
1423 .in_tmux(true)
1424 .build();
1425 let mut presenter = Presenter::new(Vec::new(), caps);
1426
1427 let mut buffer = Buffer::new(2, 1);
1428 buffer.set_raw(0, 0, Cell::from_char('X'));
1429 let old = Buffer::new(2, 1);
1430 let diff = BufferDiff::compute(&old, &buffer);
1431
1432 presenter.present(&buffer, &diff).unwrap();
1433 let output = get_output(presenter);
1434
1435 assert!(
1436 !output
1437 .windows(ansi::SYNC_BEGIN.len())
1438 .any(|w| w == ansi::SYNC_BEGIN),
1439 "tmux policy should suppress sync begin"
1440 );
1441 assert!(
1442 !output
1443 .windows(ansi::SYNC_END.len())
1444 .any(|w| w == ansi::SYNC_END),
1445 "tmux policy should suppress sync end"
1446 );
1447 }
1448
1449 #[test]
1450 fn hyperlink_sequences_emitted_and_closed() {
1451 let mut presenter = test_presenter_with_hyperlinks();
1452 let mut buffer = Buffer::new(3, 1);
1453
1454 let mut registry = LinkRegistry::new();
1455 let link_id = registry.register("https://example.com");
1456 let linked = Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id));
1457 buffer.set_raw(0, 0, linked);
1458
1459 let old = Buffer::new(3, 1);
1460 let diff = BufferDiff::compute(&old, &buffer);
1461
1462 presenter
1463 .present_with_pool(&buffer, &diff, None, Some(®istry))
1464 .unwrap();
1465 let output = get_output(presenter);
1466
1467 let start = b"\x1b]8;;https://example.com\x07";
1468 let end = b"\x1b]8;;\x07";
1469
1470 let start_pos = output
1471 .windows(start.len())
1472 .position(|w| w == start)
1473 .expect("hyperlink start not found");
1474 let end_pos = output
1475 .windows(end.len())
1476 .position(|w| w == end)
1477 .expect("hyperlink end not found");
1478 let char_pos = output
1479 .iter()
1480 .position(|&b| b == b'L')
1481 .expect("linked character not found");
1482
1483 assert!(start_pos < char_pos, "link start should precede text");
1484 assert!(char_pos < end_pos, "link end should follow text");
1485 }
1486
1487 #[test]
1488 fn single_cell_change() {
1489 let mut presenter = test_presenter();
1490 let mut buffer = Buffer::new(10, 10);
1491 buffer.set_raw(5, 5, Cell::from_char('X'));
1492
1493 let old = Buffer::new(10, 10);
1494 let diff = BufferDiff::compute(&old, &buffer);
1495
1496 presenter.present(&buffer, &diff).unwrap();
1497 let output = get_output(presenter);
1498
1499 let output_str = String::from_utf8_lossy(&output);
1501 assert!(output_str.contains("X"));
1502 assert!(output_str.contains("\x1b[")); }
1504
1505 #[test]
1506 fn style_tracking_avoids_redundant_sgr() {
1507 let mut presenter = test_presenter();
1508 let mut buffer = Buffer::new(10, 1);
1509
1510 let fg = PackedRgba::rgb(255, 0, 0);
1512 buffer.set_raw(0, 0, Cell::from_char('A').with_fg(fg));
1513 buffer.set_raw(1, 0, Cell::from_char('B').with_fg(fg));
1514 buffer.set_raw(2, 0, Cell::from_char('C').with_fg(fg));
1515
1516 let old = Buffer::new(10, 1);
1517 let diff = BufferDiff::compute(&old, &buffer);
1518
1519 presenter.present(&buffer, &diff).unwrap();
1520 let output = get_output(presenter);
1521
1522 let output_str = String::from_utf8_lossy(&output);
1524 let sgr_count = output_str.matches("\x1b[38;2").count();
1525 assert_eq!(
1527 sgr_count, 1,
1528 "Expected 1 SGR fg sequence, got {}",
1529 sgr_count
1530 );
1531 }
1532
1533 #[test]
1534 fn reset_reapplies_style_after_clear() {
1535 let mut presenter = test_presenter();
1536 let mut buffer = Buffer::new(1, 1);
1537 let styled = Cell::from_char('A').with_fg(PackedRgba::rgb(10, 20, 30));
1538 buffer.set_raw(0, 0, styled);
1539
1540 let old = Buffer::new(1, 1);
1541 let diff = BufferDiff::compute(&old, &buffer);
1542
1543 presenter.present(&buffer, &diff).unwrap();
1544 presenter.reset();
1545 presenter.present(&buffer, &diff).unwrap();
1546
1547 let output = get_output(presenter);
1548 let output_str = String::from_utf8_lossy(&output);
1549 let sgr_count = output_str.matches("\x1b[38;2").count();
1550
1551 assert_eq!(
1552 sgr_count, 2,
1553 "Expected style to be re-applied after reset, got {sgr_count} sequences"
1554 );
1555 }
1556
1557 #[test]
1558 fn cursor_position_optimized() {
1559 let mut presenter = test_presenter();
1560 let mut buffer = Buffer::new(10, 5);
1561
1562 buffer.set_raw(3, 2, Cell::from_char('A'));
1564 buffer.set_raw(4, 2, Cell::from_char('B'));
1565 buffer.set_raw(5, 2, Cell::from_char('C'));
1566
1567 let old = Buffer::new(10, 5);
1568 let diff = BufferDiff::compute(&old, &buffer);
1569
1570 presenter.present(&buffer, &diff).unwrap();
1571 let output = get_output(presenter);
1572
1573 let output_str = String::from_utf8_lossy(&output);
1575 let _cup_count = output_str.matches("\x1b[").filter(|_| true).count();
1576
1577 assert!(
1579 output_str.contains("ABC")
1580 || (output_str.contains('A')
1581 && output_str.contains('B')
1582 && output_str.contains('C'))
1583 );
1584 }
1585
1586 #[test]
1587 fn sync_output_wrapped_when_supported() {
1588 let mut presenter = test_presenter_with_sync();
1589 let buffer = Buffer::new(10, 10);
1590 let diff = BufferDiff::new();
1591
1592 presenter.present(&buffer, &diff).unwrap();
1593 let output = get_output(presenter);
1594
1595 assert!(output.starts_with(ansi::SYNC_BEGIN));
1597 assert!(
1598 output
1599 .windows(ansi::SYNC_END.len())
1600 .any(|w| w == ansi::SYNC_END)
1601 );
1602 }
1603
1604 #[test]
1605 fn clear_screen_works() {
1606 let mut presenter = test_presenter();
1607 presenter.clear_screen().unwrap();
1608 let output = get_output(presenter);
1609
1610 assert!(output.windows(b"\x1b[2J".len()).any(|w| w == b"\x1b[2J"));
1612 }
1613
1614 #[test]
1615 fn cursor_visibility() {
1616 let mut presenter = test_presenter();
1617
1618 presenter.hide_cursor().unwrap();
1619 presenter.show_cursor().unwrap();
1620
1621 let output = get_output(presenter);
1622 let output_str = String::from_utf8_lossy(&output);
1623
1624 assert!(output_str.contains("\x1b[?25l")); assert!(output_str.contains("\x1b[?25h")); }
1627
1628 #[test]
1629 fn reset_clears_state() {
1630 let mut presenter = test_presenter();
1631 presenter.cursor_x = Some(50);
1632 presenter.cursor_y = Some(20);
1633 presenter.current_style = Some(CellStyle::default());
1634
1635 presenter.reset();
1636
1637 assert!(presenter.cursor_x.is_none());
1638 assert!(presenter.cursor_y.is_none());
1639 assert!(presenter.current_style.is_none());
1640 }
1641
1642 #[test]
1643 fn position_cursor() {
1644 let mut presenter = test_presenter();
1645 presenter.position_cursor(10, 5).unwrap();
1646
1647 let output = get_output(presenter);
1648 assert!(
1650 output
1651 .windows(b"\x1b[6;11H".len())
1652 .any(|w| w == b"\x1b[6;11H")
1653 );
1654 }
1655
1656 #[test]
1657 fn skip_cursor_move_when_already_at_position() {
1658 let mut presenter = test_presenter();
1659 presenter.cursor_x = Some(5);
1660 presenter.cursor_y = Some(3);
1661
1662 presenter.move_cursor_to(5, 3).unwrap();
1664
1665 let output = get_output(presenter);
1667 assert!(output.is_empty());
1668 }
1669
1670 #[test]
1671 fn continuation_cells_skipped() {
1672 let mut presenter = test_presenter();
1673 let mut buffer = Buffer::new(10, 1);
1674
1675 buffer.set_raw(0, 0, Cell::from_char('ä¸'));
1677 buffer.set_raw(1, 0, Cell::CONTINUATION);
1679
1680 let old = Buffer::new(10, 1);
1682 let diff = BufferDiff::compute(&old, &buffer);
1683
1684 presenter.present(&buffer, &diff).unwrap();
1685 let output = get_output(presenter);
1686
1687 let output_str = String::from_utf8_lossy(&output);
1689 assert!(output_str.contains('ä¸'));
1690 }
1691
1692 #[test]
1693 fn continuation_at_run_start_clears_orphan_tail() {
1694 let mut presenter = test_presenter();
1695 let mut old = Buffer::new(3, 1);
1696 let mut new = Buffer::new(3, 1);
1697
1698 old.set_raw(0, 0, Cell::from_char('ä¸'));
1704 new.set_raw(0, 0, Cell::from_char('ä¸'));
1705 old.set_raw(1, 0, Cell::from_char('X'));
1706 new.set_raw(1, 0, Cell::CONTINUATION);
1707
1708 let diff = BufferDiff::compute(&old, &new);
1709 assert_eq!(diff.changes(), &[(1u16, 0u16)]);
1710
1711 presenter.present(&new, &diff).unwrap();
1712 let output = get_output(presenter);
1713
1714 assert!(
1715 output.contains(&b' '),
1716 "orphan continuation should be cleared with a space"
1717 );
1718 }
1719
1720 #[test]
1721 fn continuation_cleanup_resets_style_and_closes_link_before_space() {
1722 let mut presenter = test_presenter_with_hyperlinks();
1723 let mut links = LinkRegistry::new();
1724 let link_id = links.register("https://example.com");
1725
1726 let styled = Cell::from_char('X')
1727 .with_fg(PackedRgba::rgb(255, 0, 0))
1728 .with_bg(PackedRgba::rgb(0, 0, 255))
1729 .with_attrs(CellAttrs::new(StyleFlags::UNDERLINE, link_id));
1730 presenter.current_style = Some(CellStyle::from_cell(&styled));
1731 presenter.current_link = Some(link_id);
1732 presenter.cursor_x = Some(0);
1733 presenter.cursor_y = Some(0);
1734
1735 presenter
1736 .emit_cell(0, &Cell::CONTINUATION, None, Some(&links))
1737 .unwrap();
1738 let output = presenter.into_inner().unwrap();
1739
1740 let reset = b"\x1b[0m";
1741 let close = b"\x1b]8;;\x07";
1742 let reset_pos = output
1743 .windows(reset.len())
1744 .position(|window| window == reset)
1745 .expect("continuation cleanup should reset SGR state");
1746 let close_pos = output
1747 .windows(close.len())
1748 .position(|window| window == close)
1749 .expect("continuation cleanup should close OSC 8");
1750 let space_pos = output
1751 .iter()
1752 .position(|&byte| byte == b' ')
1753 .expect("continuation cleanup should emit a space");
1754
1755 assert!(
1756 reset_pos < space_pos,
1757 "cleanup reset must precede the blank"
1758 );
1759 assert!(
1760 close_pos < space_pos,
1761 "cleanup link close must precede the blank"
1762 );
1763 }
1764
1765 #[test]
1766 fn wide_char_missing_continuation_causes_drift() {
1767 let mut presenter = test_presenter();
1768 let mut buffer = Buffer::new(10, 1);
1769
1770 buffer.set_raw(0, 0, Cell::from_char('ä¸'));
1772 let old = Buffer::new(10, 1);
1775 let diff = BufferDiff::compute(&old, &buffer);
1776
1777 presenter.present(&buffer, &diff).unwrap();
1778 let output = get_output(presenter);
1779
1780 let output_str = String::from_utf8_lossy(&output);
1790
1791 assert!(output_str.contains('ä¸'));
1793
1794 let has_correction = output_str.contains("\x1b[D")
1800 || output_str.contains("\x1b[2G")
1801 || output_str.contains("\x1b[1;2H");
1802
1803 assert!(
1804 has_correction,
1805 "Presenter should correct cursor drift when wide char tail is missing. Output: {:?}",
1806 output_str
1807 );
1808 }
1809
1810 #[test]
1811 fn hyperlink_emitted_with_registry() {
1812 let mut presenter = test_presenter_with_hyperlinks();
1813 let mut buffer = Buffer::new(10, 1);
1814 let mut links = LinkRegistry::new();
1815
1816 let link_id = links.register("https://example.com");
1817 let cell = Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id));
1818 buffer.set_raw(0, 0, cell);
1819
1820 let old = Buffer::new(10, 1);
1821 let diff = BufferDiff::compute(&old, &buffer);
1822
1823 presenter
1824 .present_with_pool(&buffer, &diff, None, Some(&links))
1825 .unwrap();
1826 let output = get_output(presenter);
1827 let output_str = String::from_utf8_lossy(&output);
1828
1829 assert!(
1831 output_str.contains("\x1b]8;;https://example.com\x07"),
1832 "Expected OSC 8 open, got: {:?}",
1833 output_str
1834 );
1835 assert!(
1837 output_str.contains("\x1b]8;;\x07"),
1838 "Expected OSC 8 close, got: {:?}",
1839 output_str
1840 );
1841 }
1842
1843 #[test]
1844 fn hyperlink_not_emitted_without_registry() {
1845 let mut presenter = test_presenter_with_hyperlinks();
1846 let mut buffer = Buffer::new(10, 1);
1847
1848 let cell = Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), 1));
1850 buffer.set_raw(0, 0, cell);
1851
1852 let old = Buffer::new(10, 1);
1853 let diff = BufferDiff::compute(&old, &buffer);
1854
1855 presenter.present(&buffer, &diff).unwrap();
1857 let output = get_output(presenter);
1858 let output_str = String::from_utf8_lossy(&output);
1859
1860 assert!(
1862 !output_str.contains("\x1b]8;"),
1863 "OSC 8 should not appear without registry, got: {:?}",
1864 output_str
1865 );
1866 }
1867
1868 #[test]
1869 fn hyperlink_not_emitted_for_unknown_id() {
1870 let mut presenter = test_presenter_with_hyperlinks();
1871 let mut buffer = Buffer::new(10, 1);
1872 let links = LinkRegistry::new();
1873
1874 let cell = Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), 42));
1875 buffer.set_raw(0, 0, cell);
1876
1877 let old = Buffer::new(10, 1);
1878 let diff = BufferDiff::compute(&old, &buffer);
1879
1880 presenter
1881 .present_with_pool(&buffer, &diff, None, Some(&links))
1882 .unwrap();
1883 let output = get_output(presenter);
1884 let output_str = String::from_utf8_lossy(&output);
1885
1886 assert!(
1887 !output_str.contains("\x1b]8;"),
1888 "OSC 8 should not appear for unknown link IDs, got: {:?}",
1889 output_str
1890 );
1891 assert!(output_str.contains('L'));
1892 }
1893
1894 #[test]
1895 fn hyperlink_closed_at_frame_end() {
1896 let mut presenter = test_presenter_with_hyperlinks();
1897 let mut buffer = Buffer::new(10, 1);
1898 let mut links = LinkRegistry::new();
1899
1900 let link_id = links.register("https://example.com");
1901 for x in 0..5 {
1903 buffer.set_raw(
1904 x,
1905 0,
1906 Cell::from_char('A').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
1907 );
1908 }
1909
1910 let old = Buffer::new(10, 1);
1911 let diff = BufferDiff::compute(&old, &buffer);
1912
1913 presenter
1914 .present_with_pool(&buffer, &diff, None, Some(&links))
1915 .unwrap();
1916 let output = get_output(presenter);
1917
1918 let close_seq = b"\x1b]8;;\x07";
1920 assert!(
1921 output.windows(close_seq.len()).any(|w| w == close_seq),
1922 "Link must be closed at frame end"
1923 );
1924 }
1925
1926 #[test]
1927 fn hyperlink_transitions_between_links() {
1928 let mut presenter = test_presenter_with_hyperlinks();
1929 let mut buffer = Buffer::new(10, 1);
1930 let mut links = LinkRegistry::new();
1931
1932 let link_a = links.register("https://a.com");
1933 let link_b = links.register("https://b.com");
1934
1935 buffer.set_raw(
1936 0,
1937 0,
1938 Cell::from_char('A').with_attrs(CellAttrs::new(StyleFlags::empty(), link_a)),
1939 );
1940 buffer.set_raw(
1941 1,
1942 0,
1943 Cell::from_char('B').with_attrs(CellAttrs::new(StyleFlags::empty(), link_b)),
1944 );
1945 buffer.set_raw(2, 0, Cell::from_char('C')); let old = Buffer::new(10, 1);
1948 let diff = BufferDiff::compute(&old, &buffer);
1949
1950 presenter
1951 .present_with_pool(&buffer, &diff, None, Some(&links))
1952 .unwrap();
1953 let output = get_output(presenter);
1954 let output_str = String::from_utf8_lossy(&output);
1955
1956 assert!(output_str.contains("https://a.com"));
1958 assert!(output_str.contains("https://b.com"));
1959
1960 let close_count = output_str.matches("\x1b]8;;\x07").count();
1962 assert!(
1963 close_count >= 2,
1964 "Expected at least 2 link close sequences (transition + frame end), got {}",
1965 close_count
1966 );
1967 }
1968
1969 #[test]
1970 fn hyperlink_obeys_mux_policy_even_when_capability_flag_set() {
1971 let caps = TerminalCapabilities::builder()
1972 .osc8_hyperlinks(true)
1973 .in_tmux(true)
1974 .build();
1975 let mut presenter = Presenter::new(Vec::new(), caps);
1976 let mut buffer = Buffer::new(3, 1);
1977 let mut links = LinkRegistry::new();
1978 let link_id = links.register("https://example.com");
1979 buffer.set_raw(
1980 0,
1981 0,
1982 Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
1983 );
1984
1985 let old = Buffer::new(3, 1);
1986 let diff = BufferDiff::compute(&old, &buffer);
1987 presenter
1988 .present_with_pool(&buffer, &diff, None, Some(&links))
1989 .unwrap();
1990
1991 let output = get_output(presenter);
1992 let output_str = String::from_utf8_lossy(&output);
1993 assert!(
1994 !output_str.contains("\x1b]8;"),
1995 "tmux policy should suppress OSC 8 sequences"
1996 );
1997 assert!(output_str.contains('L'));
1998 }
1999
2000 #[test]
2001 fn hyperlink_disabled_policy_noops_when_no_link_is_open() {
2002 let mut presenter = test_presenter();
2003 presenter
2004 .emit_link_changes(&Cell::from_char('X'), None)
2005 .unwrap();
2006 assert!(presenter.into_inner().unwrap().is_empty());
2007 }
2008
2009 #[test]
2010 fn hyperlink_disabled_policy_still_closes_stale_open_link() {
2011 let mut presenter = test_presenter();
2012 presenter.current_link = Some(7);
2013 presenter
2014 .emit_link_changes(&Cell::from_char('X'), None)
2015 .unwrap();
2016 assert_eq!(presenter.into_inner().unwrap(), b"\x1b]8;;\x07");
2017 }
2018
2019 #[test]
2020 fn hyperlink_unsafe_url_not_emitted() {
2021 let mut presenter = test_presenter_with_hyperlinks();
2022 let mut buffer = Buffer::new(3, 1);
2023 let mut links = LinkRegistry::new();
2024 let link_id = links.register("https://example.com/\x1b[?2026h");
2025 buffer.set_raw(
2026 0,
2027 0,
2028 Cell::from_char('X').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
2029 );
2030
2031 let old = Buffer::new(3, 1);
2032 let diff = BufferDiff::compute(&old, &buffer);
2033 presenter
2034 .present_with_pool(&buffer, &diff, None, Some(&links))
2035 .unwrap();
2036
2037 let output = get_output(presenter);
2038 let output_str = String::from_utf8_lossy(&output);
2039 assert!(
2040 !output_str.contains("\x1b]8;;https://example.com/"),
2041 "unsafe hyperlink URL should be suppressed"
2042 );
2043 assert!(
2044 !output_str.contains("\x1b[?2026h"),
2045 "control payload must never be emitted via OSC 8"
2046 );
2047 assert!(output_str.contains('X'));
2048 }
2049
2050 #[test]
2051 fn hyperlink_overlong_url_not_emitted() {
2052 let mut presenter = test_presenter_with_hyperlinks();
2053 let mut buffer = Buffer::new(3, 1);
2054 let mut links = LinkRegistry::new();
2055 let long_url = format!(
2056 "https://example.com/{}",
2057 "a".repeat(MAX_SAFE_HYPERLINK_URL_BYTES + 1)
2058 );
2059 let link_id = links.register(&long_url);
2060 buffer.set_raw(
2061 0,
2062 0,
2063 Cell::from_char('Y').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
2064 );
2065
2066 let old = Buffer::new(3, 1);
2067 let diff = BufferDiff::compute(&old, &buffer);
2068 presenter
2069 .present_with_pool(&buffer, &diff, None, Some(&links))
2070 .unwrap();
2071
2072 let output = get_output(presenter);
2073 let output_str = String::from_utf8_lossy(&output);
2074 assert!(
2075 !output_str.contains("\x1b]8;;https://example.com/"),
2076 "overlong hyperlink URL should be suppressed"
2077 );
2078 assert!(output_str.contains('Y'));
2079 }
2080
2081 #[test]
2086 fn sync_output_not_wrapped_when_unsupported() {
2087 let mut presenter = test_presenter(); let buffer = Buffer::new(10, 10);
2090 let diff = BufferDiff::new();
2091
2092 presenter.present(&buffer, &diff).unwrap();
2093 let output = get_output(presenter);
2094
2095 assert!(
2097 !output
2098 .windows(ansi::SYNC_BEGIN.len())
2099 .any(|w| w == ansi::SYNC_BEGIN),
2100 "Sync begin should not appear when sync_output is disabled"
2101 );
2102 assert!(
2103 !output
2104 .windows(ansi::SYNC_END.len())
2105 .any(|w| w == ansi::SYNC_END),
2106 "Sync end should not appear when sync_output is disabled"
2107 );
2108
2109 assert!(
2111 output.starts_with(ansi::CURSOR_HIDE),
2112 "Fallback should start with cursor hide"
2113 );
2114 assert!(
2115 output.ends_with(ansi::CURSOR_SHOW),
2116 "Fallback should end with cursor show"
2117 );
2118 }
2119
2120 #[test]
2121 fn present_flushes_buffered_output() {
2122 let mut presenter = test_presenter();
2125 let mut buffer = Buffer::new(5, 1);
2126 buffer.set_raw(0, 0, Cell::from_char('T'));
2127 buffer.set_raw(1, 0, Cell::from_char('E'));
2128 buffer.set_raw(2, 0, Cell::from_char('S'));
2129 buffer.set_raw(3, 0, Cell::from_char('T'));
2130
2131 let old = Buffer::new(5, 1);
2132 let diff = BufferDiff::compute(&old, &buffer);
2133
2134 presenter.present(&buffer, &diff).unwrap();
2135 let output = get_output(presenter);
2136 let output_str = String::from_utf8_lossy(&output);
2137
2138 assert!(
2140 output_str.contains("TEST"),
2141 "Expected 'TEST' in flushed output"
2142 );
2143 }
2144
2145 #[test]
2146 fn present_stats_reports_cells_and_bytes() {
2147 let mut presenter = test_presenter();
2148 let mut buffer = Buffer::new(10, 1);
2149
2150 for i in 0..5 {
2152 buffer.set_raw(i, 0, Cell::from_char('X'));
2153 }
2154
2155 let old = Buffer::new(10, 1);
2156 let diff = BufferDiff::compute(&old, &buffer);
2157
2158 let stats = presenter.present(&buffer, &diff).unwrap();
2159
2160 assert_eq!(stats.cells_changed, 5, "Expected 5 cells changed");
2162 assert!(stats.bytes_emitted > 0, "Expected some bytes written");
2163 assert!(stats.run_count >= 1, "Expected at least 1 run");
2164 }
2165
2166 #[test]
2171 fn cursor_tracking_after_wide_char() {
2172 let mut presenter = test_presenter();
2173 presenter.cursor_x = Some(0);
2174 presenter.cursor_y = Some(0);
2175
2176 let mut buffer = Buffer::new(10, 1);
2177 buffer.set_raw(0, 0, Cell::from_char('ä¸'));
2179 buffer.set_raw(1, 0, Cell::CONTINUATION);
2180 buffer.set_raw(2, 0, Cell::from_char('A'));
2182
2183 let old = Buffer::new(10, 1);
2184 let diff = BufferDiff::compute(&old, &buffer);
2185
2186 presenter.present(&buffer, &diff).unwrap();
2187
2188 let output = get_output(presenter);
2191 let output_str = String::from_utf8_lossy(&output);
2192
2193 assert!(output_str.contains('ä¸'));
2195 assert!(output_str.contains('A'));
2196 }
2197
2198 #[test]
2199 fn cursor_position_after_multiple_runs() {
2200 let mut presenter = test_presenter();
2201 let mut buffer = Buffer::new(20, 3);
2202
2203 buffer.set_raw(0, 0, Cell::from_char('A'));
2205 buffer.set_raw(1, 0, Cell::from_char('B'));
2206 buffer.set_raw(5, 2, Cell::from_char('X'));
2207 buffer.set_raw(6, 2, Cell::from_char('Y'));
2208
2209 let old = Buffer::new(20, 3);
2210 let diff = BufferDiff::compute(&old, &buffer);
2211
2212 presenter.present(&buffer, &diff).unwrap();
2213 let output = get_output(presenter);
2214 let output_str = String::from_utf8_lossy(&output);
2215
2216 assert!(output_str.contains('A'));
2218 assert!(output_str.contains('B'));
2219 assert!(output_str.contains('X'));
2220 assert!(output_str.contains('Y'));
2221
2222 let cup_count = output_str.matches("\x1b[").count();
2224 assert!(
2225 cup_count >= 2,
2226 "Expected at least 2 escape sequences for multiple runs"
2227 );
2228 }
2229
2230 #[test]
2235 fn style_with_all_flags() {
2236 let mut presenter = test_presenter();
2237 let mut buffer = Buffer::new(5, 1);
2238
2239 let all_flags = StyleFlags::BOLD
2241 | StyleFlags::DIM
2242 | StyleFlags::ITALIC
2243 | StyleFlags::UNDERLINE
2244 | StyleFlags::BLINK
2245 | StyleFlags::REVERSE
2246 | StyleFlags::STRIKETHROUGH;
2247
2248 let cell = Cell::from_char('X').with_attrs(CellAttrs::new(all_flags, 0));
2249 buffer.set_raw(0, 0, cell);
2250
2251 let old = Buffer::new(5, 1);
2252 let diff = BufferDiff::compute(&old, &buffer);
2253
2254 presenter.present(&buffer, &diff).unwrap();
2255 let output = get_output(presenter);
2256 let output_str = String::from_utf8_lossy(&output);
2257
2258 assert!(output_str.contains('X'));
2260 assert!(output_str.contains("\x1b["), "Expected SGR sequences");
2262 }
2263
2264 #[test]
2265 fn style_transitions_between_different_colors() {
2266 let mut presenter = test_presenter();
2267 let mut buffer = Buffer::new(3, 1);
2268
2269 buffer.set_raw(
2271 0,
2272 0,
2273 Cell::from_char('R').with_fg(PackedRgba::rgb(255, 0, 0)),
2274 );
2275 buffer.set_raw(
2276 1,
2277 0,
2278 Cell::from_char('G').with_fg(PackedRgba::rgb(0, 255, 0)),
2279 );
2280 buffer.set_raw(
2281 2,
2282 0,
2283 Cell::from_char('B').with_fg(PackedRgba::rgb(0, 0, 255)),
2284 );
2285
2286 let old = Buffer::new(3, 1);
2287 let diff = BufferDiff::compute(&old, &buffer);
2288
2289 presenter.present(&buffer, &diff).unwrap();
2290 let output = get_output(presenter);
2291 let output_str = String::from_utf8_lossy(&output);
2292
2293 assert!(output_str.contains("38;2;255;0;0"), "Expected red fg");
2295 assert!(output_str.contains("38;2;0;255;0"), "Expected green fg");
2296 assert!(output_str.contains("38;2;0;0;255"), "Expected blue fg");
2297 }
2298
2299 #[test]
2304 fn link_at_buffer_boundaries() {
2305 let mut presenter = test_presenter_with_hyperlinks();
2306 let mut buffer = Buffer::new(5, 1);
2307 let mut links = LinkRegistry::new();
2308
2309 let link_id = links.register("https://boundary.test");
2310
2311 buffer.set_raw(
2313 0,
2314 0,
2315 Cell::from_char('F').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
2316 );
2317 buffer.set_raw(
2319 4,
2320 0,
2321 Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
2322 );
2323
2324 let old = Buffer::new(5, 1);
2325 let diff = BufferDiff::compute(&old, &buffer);
2326
2327 presenter
2328 .present_with_pool(&buffer, &diff, None, Some(&links))
2329 .unwrap();
2330 let output = get_output(presenter);
2331 let output_str = String::from_utf8_lossy(&output);
2332
2333 assert!(output_str.contains("https://boundary.test"));
2335 assert!(output_str.contains('F'));
2337 assert!(output_str.contains('L'));
2338 }
2339
2340 #[test]
2341 fn link_state_cleared_after_reset() {
2342 let mut presenter = test_presenter();
2343 let mut links = LinkRegistry::new();
2344 let link_id = links.register("https://example.com");
2345
2346 presenter.current_link = Some(link_id);
2348 presenter.current_style = Some(CellStyle::default());
2349 presenter.cursor_x = Some(5);
2350 presenter.cursor_y = Some(3);
2351
2352 presenter.reset();
2353
2354 assert!(
2356 presenter.current_link.is_none(),
2357 "current_link should be None after reset"
2358 );
2359 assert!(
2360 presenter.current_style.is_none(),
2361 "current_style should be None after reset"
2362 );
2363 assert!(
2364 presenter.cursor_x.is_none(),
2365 "cursor_x should be None after reset"
2366 );
2367 assert!(
2368 presenter.cursor_y.is_none(),
2369 "cursor_y should be None after reset"
2370 );
2371 }
2372
2373 #[test]
2374 fn link_transitions_linked_unlinked_linked() {
2375 let mut presenter = test_presenter_with_hyperlinks();
2376 let mut buffer = Buffer::new(5, 1);
2377 let mut links = LinkRegistry::new();
2378
2379 let link_id = links.register("https://toggle.test");
2380
2381 buffer.set_raw(
2383 0,
2384 0,
2385 Cell::from_char('A').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
2386 );
2387 buffer.set_raw(1, 0, Cell::from_char('B')); buffer.set_raw(
2389 2,
2390 0,
2391 Cell::from_char('C').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
2392 );
2393
2394 let old = Buffer::new(5, 1);
2395 let diff = BufferDiff::compute(&old, &buffer);
2396
2397 presenter
2398 .present_with_pool(&buffer, &diff, None, Some(&links))
2399 .unwrap();
2400 let output = get_output(presenter);
2401 let output_str = String::from_utf8_lossy(&output);
2402
2403 let url_count = output_str.matches("https://toggle.test").count();
2405 assert!(
2406 url_count >= 2,
2407 "Expected link to open at least twice, got {} occurrences",
2408 url_count
2409 );
2410
2411 let close_count = output_str.matches("\x1b]8;;\x07").count();
2413 assert!(
2414 close_count >= 2,
2415 "Expected at least 2 link closes, got {}",
2416 close_count
2417 );
2418 }
2419
2420 #[test]
2425 fn multiple_presents_maintain_correct_state() {
2426 let mut presenter = test_presenter();
2427 let mut buffer = Buffer::new(10, 1);
2428
2429 buffer.set_raw(0, 0, Cell::from_char('1'));
2431 let old = Buffer::new(10, 1);
2432 let diff = BufferDiff::compute(&old, &buffer);
2433 presenter.present(&buffer, &diff).unwrap();
2434
2435 let prev = buffer.clone();
2437 buffer.set_raw(1, 0, Cell::from_char('2'));
2438 let diff = BufferDiff::compute(&prev, &buffer);
2439 presenter.present(&buffer, &diff).unwrap();
2440
2441 let prev = buffer.clone();
2443 buffer.set_raw(2, 0, Cell::from_char('3'));
2444 let diff = BufferDiff::compute(&prev, &buffer);
2445 presenter.present(&buffer, &diff).unwrap();
2446
2447 let output = get_output(presenter);
2448 let output_str = String::from_utf8_lossy(&output);
2449
2450 assert!(output_str.contains('1'));
2452 assert!(output_str.contains('2'));
2453 assert!(output_str.contains('3'));
2454 }
2455
2456 #[test]
2461 fn sgr_delta_fg_only_change_no_reset() {
2462 let mut presenter = test_presenter();
2464 let mut buffer = Buffer::new(3, 1);
2465
2466 let fg1 = PackedRgba::rgb(255, 0, 0);
2467 let fg2 = PackedRgba::rgb(0, 255, 0);
2468 buffer.set_raw(0, 0, Cell::from_char('A').with_fg(fg1));
2469 buffer.set_raw(1, 0, Cell::from_char('B').with_fg(fg2));
2470
2471 let old = Buffer::new(3, 1);
2472 let diff = BufferDiff::compute(&old, &buffer);
2473
2474 presenter.present(&buffer, &diff).unwrap();
2475 let output = get_output(presenter);
2476 let output_str = String::from_utf8_lossy(&output);
2477
2478 let reset_count = output_str.matches("\x1b[0m").count();
2481 assert_eq!(
2483 reset_count, 2,
2484 "Expected 2 resets (initial + frame end), got {} in: {:?}",
2485 reset_count, output_str
2486 );
2487 }
2488
2489 #[test]
2490 fn sgr_delta_bg_only_change_no_reset() {
2491 let mut presenter = test_presenter();
2492 let mut buffer = Buffer::new(3, 1);
2493
2494 let bg1 = PackedRgba::rgb(0, 0, 255);
2495 let bg2 = PackedRgba::rgb(255, 255, 0);
2496 buffer.set_raw(0, 0, Cell::from_char('A').with_bg(bg1));
2497 buffer.set_raw(1, 0, Cell::from_char('B').with_bg(bg2));
2498
2499 let old = Buffer::new(3, 1);
2500 let diff = BufferDiff::compute(&old, &buffer);
2501
2502 presenter.present(&buffer, &diff).unwrap();
2503 let output = get_output(presenter);
2504 let output_str = String::from_utf8_lossy(&output);
2505
2506 let reset_count = output_str.matches("\x1b[0m").count();
2508 assert_eq!(
2509 reset_count, 2,
2510 "Expected 2 resets, got {} in: {:?}",
2511 reset_count, output_str
2512 );
2513 }
2514
2515 #[test]
2516 fn sgr_delta_attr_addition_no_reset() {
2517 let mut presenter = test_presenter();
2518 let mut buffer = Buffer::new(3, 1);
2519
2520 let attrs1 = CellAttrs::new(StyleFlags::BOLD, 0);
2522 let attrs2 = CellAttrs::new(StyleFlags::BOLD | StyleFlags::ITALIC, 0);
2523 buffer.set_raw(0, 0, Cell::from_char('A').with_attrs(attrs1));
2524 buffer.set_raw(1, 0, Cell::from_char('B').with_attrs(attrs2));
2525
2526 let old = Buffer::new(3, 1);
2527 let diff = BufferDiff::compute(&old, &buffer);
2528
2529 presenter.present(&buffer, &diff).unwrap();
2530 let output = get_output(presenter);
2531 let output_str = String::from_utf8_lossy(&output);
2532
2533 let reset_count = output_str.matches("\x1b[0m").count();
2535 assert_eq!(
2536 reset_count, 2,
2537 "Expected 2 resets, got {} in: {:?}",
2538 reset_count, output_str
2539 );
2540 assert!(
2542 output_str.contains("\x1b[3m"),
2543 "Expected italic-on sequence in: {:?}",
2544 output_str
2545 );
2546 }
2547
2548 #[test]
2549 fn sgr_delta_attr_removal_uses_off_code() {
2550 let mut presenter = test_presenter();
2551 let mut buffer = Buffer::new(3, 1);
2552
2553 let attrs1 = CellAttrs::new(StyleFlags::BOLD | StyleFlags::ITALIC, 0);
2555 let attrs2 = CellAttrs::new(StyleFlags::BOLD, 0);
2556 buffer.set_raw(0, 0, Cell::from_char('A').with_attrs(attrs1));
2557 buffer.set_raw(1, 0, Cell::from_char('B').with_attrs(attrs2));
2558
2559 let old = Buffer::new(3, 1);
2560 let diff = BufferDiff::compute(&old, &buffer);
2561
2562 presenter.present(&buffer, &diff).unwrap();
2563 let output = get_output(presenter);
2564 let output_str = String::from_utf8_lossy(&output);
2565
2566 assert!(
2568 output_str.contains("\x1b[23m"),
2569 "Expected italic-off sequence in: {:?}",
2570 output_str
2571 );
2572 let reset_count = output_str.matches("\x1b[0m").count();
2574 assert_eq!(
2575 reset_count, 2,
2576 "Expected 2 resets, got {} in: {:?}",
2577 reset_count, output_str
2578 );
2579 }
2580
2581 #[test]
2582 fn sgr_delta_bold_dim_collateral_re_enables() {
2583 let mut presenter = test_presenter();
2586 let mut buffer = Buffer::new(3, 1);
2587
2588 let attrs1 = CellAttrs::new(StyleFlags::BOLD | StyleFlags::DIM, 0);
2590 let attrs2 = CellAttrs::new(StyleFlags::DIM, 0);
2591 buffer.set_raw(0, 0, Cell::from_char('A').with_attrs(attrs1));
2592 buffer.set_raw(1, 0, Cell::from_char('B').with_attrs(attrs2));
2593
2594 let old = Buffer::new(3, 1);
2595 let diff = BufferDiff::compute(&old, &buffer);
2596
2597 presenter.present(&buffer, &diff).unwrap();
2598 let output = get_output(presenter);
2599 let output_str = String::from_utf8_lossy(&output);
2600
2601 assert!(
2603 output_str.contains("\x1b[22m"),
2604 "Expected bold-off (22) in: {:?}",
2605 output_str
2606 );
2607 assert!(
2608 output_str.contains("\x1b[2m"),
2609 "Expected dim re-enable (2) in: {:?}",
2610 output_str
2611 );
2612 }
2613
2614 #[test]
2615 fn sgr_delta_same_style_no_output() {
2616 let mut presenter = test_presenter();
2617 let mut buffer = Buffer::new(3, 1);
2618
2619 let fg = PackedRgba::rgb(255, 0, 0);
2620 let attrs = CellAttrs::new(StyleFlags::BOLD, 0);
2621 buffer.set_raw(0, 0, Cell::from_char('A').with_fg(fg).with_attrs(attrs));
2622 buffer.set_raw(1, 0, Cell::from_char('B').with_fg(fg).with_attrs(attrs));
2623 buffer.set_raw(2, 0, Cell::from_char('C').with_fg(fg).with_attrs(attrs));
2624
2625 let old = Buffer::new(3, 1);
2626 let diff = BufferDiff::compute(&old, &buffer);
2627
2628 presenter.present(&buffer, &diff).unwrap();
2629 let output = get_output(presenter);
2630 let output_str = String::from_utf8_lossy(&output);
2631
2632 let fg_count = output_str.matches("38;2;255;0;0").count();
2634 assert_eq!(
2635 fg_count, 1,
2636 "Expected 1 fg sequence, got {} in: {:?}",
2637 fg_count, output_str
2638 );
2639 }
2640
2641 #[test]
2642 fn sgr_delta_cost_dominance_never_exceeds_baseline() {
2643 let transitions: Vec<(CellStyle, CellStyle)> = vec![
2646 (
2648 CellStyle {
2649 fg: PackedRgba::rgb(255, 0, 0),
2650 bg: PackedRgba::TRANSPARENT,
2651 attrs: StyleFlags::empty(),
2652 },
2653 CellStyle {
2654 fg: PackedRgba::rgb(0, 255, 0),
2655 bg: PackedRgba::TRANSPARENT,
2656 attrs: StyleFlags::empty(),
2657 },
2658 ),
2659 (
2661 CellStyle {
2662 fg: PackedRgba::TRANSPARENT,
2663 bg: PackedRgba::rgb(255, 0, 0),
2664 attrs: StyleFlags::empty(),
2665 },
2666 CellStyle {
2667 fg: PackedRgba::TRANSPARENT,
2668 bg: PackedRgba::rgb(0, 0, 255),
2669 attrs: StyleFlags::empty(),
2670 },
2671 ),
2672 (
2674 CellStyle {
2675 fg: PackedRgba::rgb(100, 100, 100),
2676 bg: PackedRgba::TRANSPARENT,
2677 attrs: StyleFlags::BOLD,
2678 },
2679 CellStyle {
2680 fg: PackedRgba::rgb(100, 100, 100),
2681 bg: PackedRgba::TRANSPARENT,
2682 attrs: StyleFlags::BOLD | StyleFlags::ITALIC,
2683 },
2684 ),
2685 (
2687 CellStyle {
2688 fg: PackedRgba::rgb(100, 100, 100),
2689 bg: PackedRgba::TRANSPARENT,
2690 attrs: StyleFlags::BOLD | StyleFlags::ITALIC,
2691 },
2692 CellStyle {
2693 fg: PackedRgba::rgb(100, 100, 100),
2694 bg: PackedRgba::TRANSPARENT,
2695 attrs: StyleFlags::BOLD,
2696 },
2697 ),
2698 ];
2699
2700 for (old_style, new_style) in &transitions {
2701 let delta_buf = {
2703 let mut delta_presenter = {
2704 let caps = TerminalCapabilities::basic();
2705 Presenter::new(Vec::new(), caps)
2706 };
2707 delta_presenter.current_style = Some(*old_style);
2708 delta_presenter
2709 .emit_style_delta(*old_style, *new_style)
2710 .unwrap();
2711 delta_presenter.into_inner().unwrap()
2712 };
2713
2714 let reset_buf = {
2716 let mut reset_presenter = {
2717 let caps = TerminalCapabilities::basic();
2718 Presenter::new(Vec::new(), caps)
2719 };
2720 reset_presenter.emit_style_full(*new_style).unwrap();
2721 reset_presenter.into_inner().unwrap()
2722 };
2723
2724 assert!(
2725 delta_buf.len() <= reset_buf.len(),
2726 "Delta ({} bytes) exceeded reset+apply ({} bytes) for {:?} -> {:?}.\n\
2727 Delta: {:?}\nReset: {:?}",
2728 delta_buf.len(),
2729 reset_buf.len(),
2730 old_style,
2731 new_style,
2732 String::from_utf8_lossy(&delta_buf),
2733 String::from_utf8_lossy(&reset_buf),
2734 );
2735 }
2736 }
2737
2738 #[test]
2745 fn sgr_delta_evidence_ledger() {
2746 use std::io::Write as _;
2747
2748 const SEED: u64 = 0xDEAD_BEEF_CAFE;
2750
2751 let mut rng_state = SEED;
2753 let mut next_u64 = || -> u64 {
2754 rng_state = rng_state.wrapping_mul(6364136223846793005).wrapping_add(1);
2755 rng_state
2756 };
2757
2758 let random_style = |rng: &mut dyn FnMut() -> u64| -> CellStyle {
2759 let v = rng();
2760 let fg = if v & 1 == 0 {
2761 PackedRgba::TRANSPARENT
2762 } else {
2763 let r = ((v >> 8) & 0xFF) as u8;
2764 let g = ((v >> 16) & 0xFF) as u8;
2765 let b = ((v >> 24) & 0xFF) as u8;
2766 PackedRgba::rgb(r, g, b)
2767 };
2768 let v2 = rng();
2769 let bg = if v2 & 1 == 0 {
2770 PackedRgba::TRANSPARENT
2771 } else {
2772 let r = ((v2 >> 8) & 0xFF) as u8;
2773 let g = ((v2 >> 16) & 0xFF) as u8;
2774 let b = ((v2 >> 24) & 0xFF) as u8;
2775 PackedRgba::rgb(r, g, b)
2776 };
2777 let attrs = StyleFlags::from_bits_truncate(rng() as u8);
2778 CellStyle { fg, bg, attrs }
2779 };
2780
2781 let mut ledger = Vec::new();
2782 let num_transitions = 200;
2783
2784 for i in 0..num_transitions {
2785 let old_style = random_style(&mut next_u64);
2786 let new_style = random_style(&mut next_u64);
2787
2788 let mut delta_p = {
2790 let caps = TerminalCapabilities::basic();
2791 Presenter::new(Vec::new(), caps)
2792 };
2793 delta_p.current_style = Some(old_style);
2794 delta_p.emit_style_delta(old_style, new_style).unwrap();
2795 let delta_out = delta_p.into_inner().unwrap();
2796
2797 let mut reset_p = {
2799 let caps = TerminalCapabilities::basic();
2800 Presenter::new(Vec::new(), caps)
2801 };
2802 reset_p.emit_style_full(new_style).unwrap();
2803 let reset_out = reset_p.into_inner().unwrap();
2804
2805 let delta_bytes = delta_out.len();
2806 let baseline_bytes = reset_out.len();
2807
2808 let attrs_removed = old_style.attrs & !new_style.attrs;
2810 let removed_count = attrs_removed.bits().count_ones();
2811 let fg_changed = old_style.fg != new_style.fg;
2812 let bg_changed = old_style.bg != new_style.bg;
2813 let used_fallback = removed_count >= 3 && fg_changed && bg_changed;
2814
2815 assert!(
2817 delta_bytes <= baseline_bytes,
2818 "Transition {i}: delta ({delta_bytes}B) > baseline ({baseline_bytes}B)"
2819 );
2820
2821 writeln!(
2823 &mut ledger,
2824 "{{\"seed\":{SEED},\"i\":{i},\"from_fg\":\"{:?}\",\"from_bg\":\"{:?}\",\
2825 \"from_attrs\":{},\"to_fg\":\"{:?}\",\"to_bg\":\"{:?}\",\"to_attrs\":{},\
2826 \"delta_bytes\":{delta_bytes},\"baseline_bytes\":{baseline_bytes},\
2827 \"cost_delta\":{},\"used_fallback\":{used_fallback}}}",
2828 old_style.fg,
2829 old_style.bg,
2830 old_style.attrs.bits(),
2831 new_style.fg,
2832 new_style.bg,
2833 new_style.attrs.bits(),
2834 baseline_bytes as isize - delta_bytes as isize,
2835 )
2836 .unwrap();
2837 }
2838
2839 let text = String::from_utf8(ledger).unwrap();
2841 let lines: Vec<&str> = text.lines().collect();
2842 assert_eq!(lines.len(), num_transitions);
2843
2844 let mut total_saved: isize = 0;
2846 for line in &lines {
2847 let cd_start = line.find("\"cost_delta\":").unwrap() + 13;
2849 let cd_end = line[cd_start..].find(',').unwrap() + cd_start;
2850 let cd: isize = line[cd_start..cd_end].parse().unwrap();
2851 total_saved += cd;
2852 }
2853 assert!(
2854 total_saved >= 0,
2855 "Total byte savings should be non-negative, got {total_saved}"
2856 );
2857 }
2858
2859 #[test]
2862 fn e2e_style_stress_with_byte_metrics() {
2863 let width = 40u16;
2864 let height = 10u16;
2865
2866 let mut buffer = Buffer::new(width, height);
2868 for y in 0..height {
2869 for x in 0..width {
2870 let i = (y as usize * width as usize + x as usize) as u8;
2871 let fg = PackedRgba::rgb(i, 255 - i, i.wrapping_mul(3));
2872 let bg = if i.is_multiple_of(4) {
2873 PackedRgba::rgb(i.wrapping_mul(7), i.wrapping_mul(11), i.wrapping_mul(13))
2874 } else {
2875 PackedRgba::TRANSPARENT
2876 };
2877 let flags = StyleFlags::from_bits_truncate(i % 128);
2878 let ch = char::from_u32(('!' as u32) + (i as u32 % 90)).unwrap_or('?');
2879 let cell = Cell::from_char(ch)
2880 .with_fg(fg)
2881 .with_bg(bg)
2882 .with_attrs(CellAttrs::new(flags, 0));
2883 buffer.set_raw(x, y, cell);
2884 }
2885 }
2886
2887 let blank = Buffer::new(width, height);
2889 let diff = BufferDiff::compute(&blank, &buffer);
2890 let mut presenter = test_presenter();
2891 presenter.present(&buffer, &diff).unwrap();
2892 let frame1_bytes = presenter.into_inner().unwrap().len();
2893
2894 let mut buffer2 = Buffer::new(width, height);
2896 for y in 0..height {
2897 for x in 0..width {
2898 let i = (y as usize * width as usize + x as usize + 1) as u8;
2899 let fg = PackedRgba::rgb(i, 255 - i, i.wrapping_mul(3));
2900 let bg = if i.is_multiple_of(4) {
2901 PackedRgba::rgb(i.wrapping_mul(7), i.wrapping_mul(11), i.wrapping_mul(13))
2902 } else {
2903 PackedRgba::TRANSPARENT
2904 };
2905 let flags = StyleFlags::from_bits_truncate(i % 128);
2906 let ch = char::from_u32(('!' as u32) + (i as u32 % 90)).unwrap_or('?');
2907 let cell = Cell::from_char(ch)
2908 .with_fg(fg)
2909 .with_bg(bg)
2910 .with_attrs(CellAttrs::new(flags, 0));
2911 buffer2.set_raw(x, y, cell);
2912 }
2913 }
2914
2915 let diff2 = BufferDiff::compute(&buffer, &buffer2);
2917 let mut presenter2 = test_presenter();
2918 presenter2.present(&buffer2, &diff2).unwrap();
2919 let frame2_bytes = presenter2.into_inner().unwrap().len();
2920
2921 assert!(
2924 frame2_bytes > 0,
2925 "Second frame should produce output for style churn"
2926 );
2927 assert!(!diff2.is_empty(), "Style shift should produce changes");
2928
2929 assert!(
2934 frame2_bytes <= frame1_bytes * 2,
2935 "Incremental frame ({frame2_bytes}B) unreasonably large vs full ({frame1_bytes}B)"
2936 );
2937 }
2938
2939 #[test]
2944 fn cost_model_empty_row_single_run() {
2945 let runs = [ChangeRun::new(5, 10, 20)];
2947 let plan = cost_model::plan_row(&runs, None, None);
2948 assert_eq!(plan.spans().len(), 1);
2949 assert_eq!(plan.spans()[0].x0, 10);
2950 assert_eq!(plan.spans()[0].x1, 20);
2951 assert!(plan.total_cost() > 0);
2952 }
2953
2954 #[test]
2955 fn cost_model_full_row_merges() {
2956 let runs = [ChangeRun::new(0, 0, 2), ChangeRun::new(0, 77, 79)];
2962 let plan = cost_model::plan_row(&runs, None, None);
2963 assert_eq!(plan.spans().len(), 2);
2965 assert_eq!(plan.spans()[0].x0, 0);
2966 assert_eq!(plan.spans()[0].x1, 2);
2967 assert_eq!(plan.spans()[1].x0, 77);
2968 assert_eq!(plan.spans()[1].x1, 79);
2969 }
2970
2971 #[test]
2972 fn cost_model_adjacent_runs_merge() {
2973 let runs = [
2976 ChangeRun::new(3, 10, 10),
2977 ChangeRun::new(3, 12, 12),
2978 ChangeRun::new(3, 14, 14),
2979 ChangeRun::new(3, 16, 16),
2980 ChangeRun::new(3, 18, 18),
2981 ChangeRun::new(3, 20, 20),
2982 ChangeRun::new(3, 22, 22),
2983 ChangeRun::new(3, 24, 24),
2984 ];
2985 let plan = cost_model::plan_row(&runs, None, None);
2986 assert_eq!(plan.spans().len(), 1);
2989 assert_eq!(plan.spans()[0].x0, 10);
2990 assert_eq!(plan.spans()[0].x1, 24);
2991 }
2992
2993 #[test]
2994 fn cost_model_single_cell_stays_sparse() {
2995 let runs = [ChangeRun::new(0, 40, 40)];
2996 let plan = cost_model::plan_row(&runs, Some(0), Some(0));
2997 assert_eq!(plan.spans().len(), 1);
2998 assert_eq!(plan.spans()[0].x0, 40);
2999 assert_eq!(plan.spans()[0].x1, 40);
3000 }
3001
3002 #[test]
3003 fn cost_model_cup_vs_cha_vs_cuf() {
3004 assert!(cost_model::cuf_cost(1) <= cost_model::cha_cost(5));
3006 assert!(cost_model::cuf_cost(3) <= cost_model::cup_cost(0, 5));
3007
3008 let cha = cost_model::cha_cost(5);
3010 let cup = cost_model::cup_cost(0, 5);
3011 assert!(cha <= cup);
3012
3013 let cost = cost_model::cheapest_move_cost(Some(5), Some(0), 6, 0);
3015 assert_eq!(cost, 3); }
3017
3018 #[test]
3019 fn cost_model_digit_estimation_accuracy() {
3020 let mut buf = Vec::new();
3022 ansi::cup(&mut buf, 0, 0).unwrap();
3023 assert_eq!(buf.len(), cost_model::cup_cost(0, 0));
3024
3025 buf.clear();
3026 ansi::cup(&mut buf, 9, 9).unwrap();
3027 assert_eq!(buf.len(), cost_model::cup_cost(9, 9));
3028
3029 buf.clear();
3030 ansi::cup(&mut buf, 99, 99).unwrap();
3031 assert_eq!(buf.len(), cost_model::cup_cost(99, 99));
3032
3033 buf.clear();
3034 ansi::cha(&mut buf, 0).unwrap();
3035 assert_eq!(buf.len(), cost_model::cha_cost(0));
3036
3037 buf.clear();
3038 ansi::cuf(&mut buf, 1).unwrap();
3039 assert_eq!(buf.len(), cost_model::cuf_cost(1));
3040
3041 buf.clear();
3042 ansi::cuf(&mut buf, 10).unwrap();
3043 assert_eq!(buf.len(), cost_model::cuf_cost(10));
3044 }
3045
3046 #[test]
3047 fn cost_model_merged_row_produces_correct_output() {
3048 let width = 30u16;
3050 let mut buffer = Buffer::new(width, 1);
3051
3052 for col in [5u16, 10, 15, 20] {
3054 let ch = char::from_u32('A' as u32 + col as u32 % 26).unwrap();
3055 buffer.set_raw(col, 0, Cell::from_char(ch));
3056 }
3057
3058 let old = Buffer::new(width, 1);
3059 let diff = BufferDiff::compute(&old, &buffer);
3060
3061 let mut presenter = test_presenter();
3063 presenter.present(&buffer, &diff).unwrap();
3064 let output = presenter.into_inner().unwrap();
3065 let output_str = String::from_utf8_lossy(&output);
3066
3067 for col in [5u16, 10, 15, 20] {
3068 let ch = char::from_u32('A' as u32 + col as u32 % 26).unwrap();
3069 assert!(
3070 output_str.contains(ch),
3071 "Missing character '{ch}' at col {col} in output"
3072 );
3073 }
3074 }
3075
3076 #[test]
3077 fn cost_model_optimal_cursor_uses_cuf_on_same_row() {
3078 let mut presenter = test_presenter();
3080 presenter.cursor_x = Some(5);
3081 presenter.cursor_y = Some(0);
3082 presenter.move_cursor_optimal(6, 0).unwrap();
3083 let output = presenter.into_inner().unwrap();
3084 assert_eq!(&output, b"\x1b[C", "Should use CUF for +1 column move");
3086 }
3087
3088 #[test]
3089 fn cost_model_optimal_cursor_uses_cha_on_same_row_backward() {
3090 let mut presenter = test_presenter();
3091 presenter.cursor_x = Some(10);
3092 presenter.cursor_y = Some(3);
3093
3094 let target_x = 2;
3095 let target_y = 3;
3096 let cha_cost = cost_model::cha_cost(target_x);
3097 let cup_cost = cost_model::cup_cost(target_y, target_x);
3098 assert!(
3099 cha_cost <= cup_cost,
3100 "Expected CHA to be cheaper for backward move (cha={cha_cost}, cup={cup_cost})"
3101 );
3102
3103 presenter.move_cursor_optimal(target_x, target_y).unwrap();
3104 let output = presenter.into_inner().unwrap();
3105 let mut expected = Vec::new();
3106 ansi::cha(&mut expected, target_x).unwrap();
3107 assert_eq!(output, expected, "Should use CHA for backward move");
3108 }
3109
3110 #[test]
3111 fn cost_model_optimal_cursor_uses_cup_on_row_change() {
3112 let mut presenter = test_presenter();
3113 presenter.cursor_x = Some(4);
3114 presenter.cursor_y = Some(1);
3115
3116 presenter.move_cursor_optimal(7, 4).unwrap();
3117 let output = presenter.into_inner().unwrap();
3118 let mut expected = Vec::new();
3119 ansi::cup(&mut expected, 4, 7).unwrap();
3120 assert_eq!(output, expected, "Should use CUP when row changes");
3121 }
3122
3123 #[test]
3124 fn cost_model_chooses_full_row_when_cheaper() {
3125 let width = 40u16;
3128 let mut buffer = Buffer::new(width, 1);
3129
3130 for col in (0..20).step_by(2) {
3132 buffer.set_raw(col, 0, Cell::from_char('X'));
3133 }
3134
3135 let old = Buffer::new(width, 1);
3136 let diff = BufferDiff::compute(&old, &buffer);
3137 let runs = diff.runs();
3138
3139 let row_runs: Vec<_> = runs.iter().filter(|r| r.y == 0).copied().collect();
3141 if row_runs.len() > 1 {
3142 let plan = cost_model::plan_row(&row_runs, None, None);
3143 assert!(
3144 plan.spans().len() == 1,
3145 "Expected single merged span for many small runs, got {} spans",
3146 plan.spans().len()
3147 );
3148 assert_eq!(plan.spans()[0].x0, 0);
3149 assert_eq!(plan.spans()[0].x1, 18);
3150 }
3151 }
3152
3153 #[test]
3154 fn perf_cost_model_overhead() {
3155 use std::time::Instant;
3157
3158 let runs: Vec<ChangeRun> = (0..100)
3159 .map(|i| ChangeRun::new(0, i * 3, i * 3 + 1))
3160 .collect();
3161
3162 let (iterations, max_ms) = if cfg!(debug_assertions) {
3163 (1_000, 1_000u128)
3164 } else {
3165 (10_000, 500u128)
3166 };
3167
3168 let start = Instant::now();
3169 for _ in 0..iterations {
3170 let _ = cost_model::plan_row(&runs, None, None);
3171 }
3172 let elapsed = start.elapsed();
3173
3174 assert!(
3176 elapsed.as_millis() < max_ms,
3177 "Cost model planning too slow: {elapsed:?} for {iterations} iterations"
3178 );
3179 }
3180
3181 #[test]
3182 fn perf_legacy_vs_dp_worst_case_sparse() {
3183 use std::time::Instant;
3184
3185 let width = 200u16;
3186 let height = 1u16;
3187 let mut buffer = Buffer::new(width, height);
3188
3189 for col in (0..40).step_by(2) {
3191 buffer.set_raw(col, 0, Cell::from_char('X'));
3192 }
3193 for col in (160..200).step_by(2) {
3194 buffer.set_raw(col, 0, Cell::from_char('Y'));
3195 }
3196
3197 let blank = Buffer::new(width, height);
3198 let diff = BufferDiff::compute(&blank, &buffer);
3199 let runs = diff.runs();
3200 let row_runs: Vec<_> = runs.iter().filter(|r| r.y == 0).copied().collect();
3201
3202 let dp_plan = cost_model::plan_row(&row_runs, None, None);
3203 let legacy_spans = legacy_plan_row(&row_runs, None, None);
3204
3205 let dp_output = emit_spans_for_output(&buffer, dp_plan.spans());
3206 let legacy_output = emit_spans_for_output(&buffer, &legacy_spans);
3207
3208 assert!(
3209 dp_output.len() <= legacy_output.len(),
3210 "DP output should be <= legacy output (dp={}, legacy={})",
3211 dp_output.len(),
3212 legacy_output.len()
3213 );
3214
3215 let (iterations, max_ms) = if cfg!(debug_assertions) {
3216 (1_000, 1_000u128)
3217 } else {
3218 (10_000, 500u128)
3219 };
3220 let start = Instant::now();
3221 for _ in 0..iterations {
3222 let _ = cost_model::plan_row(&row_runs, None, None);
3223 }
3224 let dp_elapsed = start.elapsed();
3225
3226 let start = Instant::now();
3227 for _ in 0..iterations {
3228 let _ = legacy_plan_row(&row_runs, None, None);
3229 }
3230 let legacy_elapsed = start.elapsed();
3231
3232 assert!(
3233 dp_elapsed.as_millis() < max_ms,
3234 "DP planning too slow: {dp_elapsed:?} for {iterations} iterations"
3235 );
3236
3237 let _ = legacy_elapsed;
3238 }
3239
3240 fn build_style_heavy_scene(width: u16, height: u16, seed: u64) -> Buffer {
3246 let mut buffer = Buffer::new(width, height);
3247 let mut rng = seed;
3248 let mut next = || -> u64 {
3249 rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1);
3250 rng
3251 };
3252 for y in 0..height {
3253 for x in 0..width {
3254 let v = next();
3255 let ch = char::from_u32(('!' as u32) + (v as u32 % 90)).unwrap_or('?');
3256 let fg = PackedRgba::rgb((v >> 8) as u8, (v >> 16) as u8, (v >> 24) as u8);
3257 let bg = if v & 3 == 0 {
3258 PackedRgba::rgb((v >> 32) as u8, (v >> 40) as u8, (v >> 48) as u8)
3259 } else {
3260 PackedRgba::TRANSPARENT
3261 };
3262 let flags = StyleFlags::from_bits_truncate((v >> 56) as u8);
3263 let cell = Cell::from_char(ch)
3264 .with_fg(fg)
3265 .with_bg(bg)
3266 .with_attrs(CellAttrs::new(flags, 0));
3267 buffer.set_raw(x, y, cell);
3268 }
3269 }
3270 buffer
3271 }
3272
3273 fn build_sparse_update(base: &Buffer, seed: u64) -> Buffer {
3275 let mut buffer = base.clone();
3276 let width = base.width();
3277 let height = base.height();
3278 let mut rng = seed;
3279 let mut next = || -> u64 {
3280 rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1);
3281 rng
3282 };
3283 let change_count = (width as usize * height as usize) / 10;
3284 for _ in 0..change_count {
3285 let v = next();
3286 let x = (v % width as u64) as u16;
3287 let y = ((v >> 16) % height as u64) as u16;
3288 let ch = char::from_u32(('A' as u32) + (v as u32 % 26)).unwrap_or('?');
3289 buffer.set_raw(x, y, Cell::from_char(ch));
3290 }
3291 buffer
3292 }
3293
3294 #[test]
3295 fn snapshot_presenter_equivalence() {
3296 let buffer = build_style_heavy_scene(40, 10, 0xDEAD_CAFE_1234);
3299 let blank = Buffer::new(40, 10);
3300 let diff = BufferDiff::compute(&blank, &buffer);
3301
3302 let mut presenter = test_presenter();
3303 presenter.present(&buffer, &diff).unwrap();
3304 let output = presenter.into_inner().unwrap();
3305
3306 let checksum = {
3308 let mut hash: u64 = 0xcbf29ce484222325; for &byte in &output {
3310 hash ^= byte as u64;
3311 hash = hash.wrapping_mul(0x100000001b3); }
3313 hash
3314 };
3315
3316 let mut presenter2 = test_presenter();
3318 presenter2.present(&buffer, &diff).unwrap();
3319 let output2 = presenter2.into_inner().unwrap();
3320 assert_eq!(output, output2, "Presenter output must be deterministic");
3321
3322 let _ = checksum; }
3325
3326 #[test]
3327 fn perf_presenter_microbench() {
3328 use std::env;
3329 use std::io::Write as _;
3330 use std::time::Instant;
3331
3332 let width = 120u16;
3333 let height = 40u16;
3334 let seed = 0x00BE_EFCA_FE42;
3335 let scene = build_style_heavy_scene(width, height, seed);
3336 let blank = Buffer::new(width, height);
3337 let diff_full = BufferDiff::compute(&blank, &scene);
3338
3339 let scene2 = build_sparse_update(&scene, seed.wrapping_add(1));
3341 let diff_sparse = BufferDiff::compute(&scene, &scene2);
3342
3343 let mut jsonl = Vec::new();
3344 let iterations = env::var("FTUI_PRESENTER_BENCH_ITERS")
3345 .ok()
3346 .and_then(|value| value.parse::<u32>().ok())
3347 .unwrap_or(50);
3348
3349 let runs_full = diff_full.runs();
3350 let runs_sparse = diff_sparse.runs();
3351
3352 let plan_rows = |runs: &[ChangeRun]| -> (usize, usize) {
3353 let mut idx = 0;
3354 let mut total_cost = 0usize;
3355 let mut span_count = 0usize;
3356 let mut prev_x = None;
3357 let mut prev_y = None;
3358
3359 while idx < runs.len() {
3360 let y = runs[idx].y;
3361 let start = idx;
3362 while idx < runs.len() && runs[idx].y == y {
3363 idx += 1;
3364 }
3365
3366 let plan = cost_model::plan_row(&runs[start..idx], prev_x, prev_y);
3367 span_count += plan.spans().len();
3368 total_cost = total_cost.saturating_add(plan.total_cost());
3369 if let Some(last) = plan.spans().last() {
3370 prev_x = Some(last.x1);
3371 prev_y = Some(y);
3372 }
3373 }
3374
3375 (total_cost, span_count)
3376 };
3377
3378 for i in 0..iterations {
3379 let (diff_ref, buf_ref, runs_ref, label) = if i % 2 == 0 {
3380 (&diff_full, &scene, &runs_full, "full")
3381 } else {
3382 (&diff_sparse, &scene2, &runs_sparse, "sparse")
3383 };
3384
3385 let plan_start = Instant::now();
3386 let (plan_cost, plan_spans) = plan_rows(runs_ref);
3387 let plan_time_us = plan_start.elapsed().as_micros() as u64;
3388
3389 let mut presenter = test_presenter();
3390 let start = Instant::now();
3391 let stats = presenter.present(buf_ref, diff_ref).unwrap();
3392 let elapsed_us = start.elapsed().as_micros() as u64;
3393 let output = presenter.into_inner().unwrap();
3394
3395 let checksum = {
3397 let mut hash: u64 = 0xcbf29ce484222325;
3398 for &b in &output {
3399 hash ^= b as u64;
3400 hash = hash.wrapping_mul(0x100000001b3);
3401 }
3402 hash
3403 };
3404
3405 writeln!(
3406 &mut jsonl,
3407 "{{\"seed\":{seed},\"width\":{width},\"height\":{height},\
3408 \"scene\":\"{label}\",\"changes\":{},\"runs\":{},\
3409 \"plan_cost\":{plan_cost},\"plan_spans\":{plan_spans},\
3410 \"plan_time_us\":{plan_time_us},\"bytes\":{},\
3411 \"emit_time_us\":{elapsed_us},\
3412 \"checksum\":\"{checksum:016x}\"}}",
3413 stats.cells_changed, stats.run_count, stats.bytes_emitted,
3414 )
3415 .unwrap();
3416 }
3417
3418 let text = String::from_utf8(jsonl).unwrap();
3419 let lines: Vec<&str> = text.lines().collect();
3420 assert_eq!(lines.len(), iterations as usize);
3421
3422 let full_checksums: Vec<&str> = lines
3424 .iter()
3425 .filter(|l| l.contains("\"full\""))
3426 .map(|l| {
3427 let start = l.find("\"checksum\":\"").unwrap() + 12;
3428 let end = l[start..].find('"').unwrap() + start;
3429 &l[start..end]
3430 })
3431 .collect();
3432 assert!(full_checksums.len() > 1);
3433 assert!(
3434 full_checksums.windows(2).all(|w| w[0] == w[1]),
3435 "Full frame checksums should be identical across runs"
3436 );
3437
3438 let full_bytes: Vec<u64> = lines
3440 .iter()
3441 .filter(|l| l.contains("\"full\""))
3442 .map(|l| {
3443 let start = l.find("\"bytes\":").unwrap() + 8;
3444 let end = l[start..].find(',').unwrap() + start;
3445 l[start..end].parse::<u64>().unwrap()
3446 })
3447 .collect();
3448 let sparse_bytes: Vec<u64> = lines
3449 .iter()
3450 .filter(|l| l.contains("\"sparse\""))
3451 .map(|l| {
3452 let start = l.find("\"bytes\":").unwrap() + 8;
3453 let end = l[start..].find(',').unwrap() + start;
3454 l[start..end].parse::<u64>().unwrap()
3455 })
3456 .collect();
3457
3458 let avg_full: u64 = full_bytes.iter().sum::<u64>() / full_bytes.len() as u64;
3459 let avg_sparse: u64 = sparse_bytes.iter().sum::<u64>() / sparse_bytes.len() as u64;
3460 assert!(
3461 avg_sparse < avg_full,
3462 "Sparse updates ({avg_sparse}B) should emit fewer bytes than full ({avg_full}B)"
3463 );
3464 }
3465
3466 #[test]
3467 fn perf_emit_style_delta_microbench() {
3468 use std::env;
3469 use std::io::Write as _;
3470 use std::time::Instant;
3471
3472 let iterations = env::var("FTUI_EMIT_STYLE_BENCH_ITERS")
3473 .ok()
3474 .and_then(|value| value.parse::<u32>().ok())
3475 .unwrap_or(200);
3476 let mode = env::var("FTUI_EMIT_STYLE_BENCH_MODE").unwrap_or_default();
3477 let emit_json = mode != "raw";
3478
3479 let mut styles = Vec::with_capacity(128);
3480 let mut rng = 0x00A5_A51E_AF42_u64;
3481 let mut next = || -> u64 {
3482 rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1);
3483 rng
3484 };
3485
3486 for _ in 0..128 {
3487 let v = next();
3488 let fg = PackedRgba::rgb(
3489 (v & 0xFF) as u8,
3490 ((v >> 8) & 0xFF) as u8,
3491 ((v >> 16) & 0xFF) as u8,
3492 );
3493 let bg = PackedRgba::rgb(
3494 ((v >> 24) & 0xFF) as u8,
3495 ((v >> 32) & 0xFF) as u8,
3496 ((v >> 40) & 0xFF) as u8,
3497 );
3498 let flags = StyleFlags::from_bits_truncate((v >> 48) as u8);
3499 let cell = Cell::from_char('A')
3500 .with_fg(fg)
3501 .with_bg(bg)
3502 .with_attrs(CellAttrs::new(flags, 0));
3503 styles.push(CellStyle::from_cell(&cell));
3504 }
3505
3506 let mut presenter = test_presenter();
3507 let mut jsonl = Vec::new();
3508 let mut sink = 0u64;
3509
3510 for i in 0..iterations {
3511 let old = styles[i as usize % styles.len()];
3512 let new = styles[(i as usize + 1) % styles.len()];
3513
3514 presenter.writer.reset_counter();
3515 presenter.writer.inner_mut().get_mut().clear();
3516
3517 let start = Instant::now();
3518 presenter.emit_style_delta(old, new).unwrap();
3519 let elapsed_us = start.elapsed().as_micros() as u64;
3520 let bytes = presenter.writer.bytes_written();
3521
3522 if emit_json {
3523 writeln!(
3524 &mut jsonl,
3525 "{{\"iter\":{i},\"emit_time_us\":{elapsed_us},\"bytes\":{bytes}}}"
3526 )
3527 .unwrap();
3528 } else {
3529 sink = sink.wrapping_add(elapsed_us ^ bytes);
3530 }
3531 }
3532
3533 if emit_json {
3534 let text = String::from_utf8(jsonl).unwrap();
3535 let lines: Vec<&str> = text.lines().collect();
3536 assert_eq!(lines.len() as u32, iterations);
3537 } else {
3538 std::hint::black_box(sink);
3539 }
3540 }
3541
3542 #[test]
3543 fn e2e_presenter_stress_deterministic() {
3544 use crate::terminal_model::TerminalModel;
3547
3548 let width = 60u16;
3549 let height = 20u16;
3550 let num_frames = 10;
3551
3552 let mut prev_buffer = Buffer::new(width, height);
3553 let mut presenter = test_presenter();
3554 let mut model = TerminalModel::new(width as usize, height as usize);
3555 let mut rng = 0x5D2E_55DE_5D42_u64;
3556 let mut next = || -> u64 {
3557 rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1);
3558 rng
3559 };
3560
3561 for _frame in 0..num_frames {
3562 let mut buffer = prev_buffer.clone();
3564 let changes = (width as usize * height as usize) / 5;
3565 for _ in 0..changes {
3566 let v = next();
3567 let x = (v % width as u64) as u16;
3568 let y = ((v >> 16) % height as u64) as u16;
3569 let ch = char::from_u32(('!' as u32) + (v as u32 % 90)).unwrap_or('?');
3570 let fg = PackedRgba::rgb((v >> 8) as u8, (v >> 24) as u8, (v >> 40) as u8);
3571 let cell = Cell::from_char(ch).with_fg(fg);
3572 buffer.set_raw(x, y, cell);
3573 }
3574
3575 let diff = BufferDiff::compute(&prev_buffer, &buffer);
3576 presenter.present(&buffer, &diff).unwrap();
3577
3578 prev_buffer = buffer;
3579 }
3580
3581 let output = presenter.into_inner().unwrap();
3583 model.process(&output);
3584
3585 let mut checked = 0;
3587 for y in 0..height {
3588 for x in 0..width {
3589 let buf_cell = prev_buffer.get_unchecked(x, y);
3590 if !buf_cell.is_empty()
3591 && let Some(model_cell) = model.cell(x as usize, y as usize)
3592 {
3593 let expected = buf_cell.content.as_char().unwrap_or(' ');
3594 let mut buf = [0u8; 4];
3595 let expected_str = expected.encode_utf8(&mut buf);
3596 if model_cell.text.as_str() == expected_str {
3597 checked += 1;
3598 }
3599 }
3600 }
3601 }
3602
3603 let total_nonempty = (0..height)
3606 .flat_map(|y| (0..width).map(move |x| (x, y)))
3607 .filter(|&(x, y)| !prev_buffer.get_unchecked(x, y).is_empty())
3608 .count();
3609
3610 assert!(
3611 checked > total_nonempty * 80 / 100,
3612 "Frame {num_frames}: only {checked}/{total_nonempty} cells match final buffer"
3613 );
3614 }
3615
3616 #[test]
3617 fn style_state_persists_across_frames() {
3618 let mut presenter = test_presenter();
3619 let fg = PackedRgba::rgb(100, 150, 200);
3620
3621 let mut buffer = Buffer::new(5, 1);
3623 buffer.set_raw(0, 0, Cell::from_char('A').with_fg(fg));
3624 let old = Buffer::new(5, 1);
3625 let diff = BufferDiff::compute(&old, &buffer);
3626 presenter.present(&buffer, &diff).unwrap();
3627
3628 assert!(
3631 presenter.current_style.is_none(),
3632 "Style should be reset after frame end"
3633 );
3634 }
3635
3636 #[test]
3643 fn cost_cup_zero_zero() {
3644 assert_eq!(cost_model::cup_cost(0, 0), 6);
3646 }
3647
3648 #[test]
3649 fn cost_cup_max_max() {
3650 assert_eq!(cost_model::cup_cost(u16::MAX, u16::MAX), 14);
3653 }
3654
3655 #[test]
3656 fn cost_cha_zero() {
3657 assert_eq!(cost_model::cha_cost(0), 4);
3659 }
3660
3661 #[test]
3662 fn cost_cha_max() {
3663 assert_eq!(cost_model::cha_cost(u16::MAX), 8);
3665 }
3666
3667 #[test]
3668 fn cost_cuf_zero_is_free() {
3669 assert_eq!(cost_model::cuf_cost(0), 0);
3670 }
3671
3672 #[test]
3673 fn cost_cuf_one_is_three() {
3674 assert_eq!(cost_model::cuf_cost(1), 3);
3676 }
3677
3678 #[test]
3679 fn cost_cuf_two_has_digit() {
3680 assert_eq!(cost_model::cuf_cost(2), 4);
3682 }
3683
3684 #[test]
3685 fn cost_cuf_max() {
3686 assert_eq!(cost_model::cuf_cost(u16::MAX), 8);
3688 }
3689
3690 #[test]
3691 fn cost_cheapest_move_already_at_target() {
3692 assert_eq!(cost_model::cheapest_move_cost(Some(5), Some(3), 5, 3), 0);
3693 }
3694
3695 #[test]
3696 fn cost_cheapest_move_unknown_position() {
3697 let cost = cost_model::cheapest_move_cost(None, None, 5, 3);
3699 assert_eq!(cost, cost_model::cup_cost(3, 5));
3700 }
3701
3702 #[test]
3703 fn cost_cheapest_move_known_y_unknown_x() {
3704 let cost = cost_model::cheapest_move_cost(None, Some(3), 5, 3);
3706 assert_eq!(cost, cost_model::cup_cost(3, 5));
3707 }
3708
3709 #[test]
3710 fn cost_cheapest_move_backward_same_row() {
3711 let cost = cost_model::cheapest_move_cost(Some(50), Some(0), 5, 0);
3713 let cha = cost_model::cha_cost(5);
3714 let cub = cost_model::cub_cost(45);
3715 assert_eq!(cost, cha.min(cub));
3716 assert!(cost_model::cup_cost(0, 5) > cha);
3717 }
3718
3719 #[test]
3720 fn cost_cheapest_move_forward_same_row() {
3721 let cost = cost_model::cheapest_move_cost(Some(5), Some(0), 50, 0);
3722 let cha = cost_model::cha_cost(50);
3723 let cuf = cost_model::cuf_cost(45);
3724 assert_eq!(cost, cha.min(cuf));
3725 assert!(cost_model::cup_cost(0, 50) > cha);
3726 }
3727
3728 #[test]
3729 fn cost_cheapest_move_same_row_same_col() {
3730 assert_eq!(cost_model::cheapest_move_cost(Some(0), Some(0), 0, 0), 0);
3732 }
3733
3734 #[test]
3737 fn cost_cup_digit_boundaries() {
3738 let mut buf = Vec::new();
3739 for (row, col) in [
3740 (0u16, 0u16),
3741 (8, 8),
3742 (9, 9),
3743 (98, 98),
3744 (99, 99),
3745 (998, 998),
3746 (999, 999),
3747 (9998, 9998),
3748 (9999, 9999),
3749 (u16::MAX, u16::MAX),
3750 ] {
3751 buf.clear();
3752 ansi::cup(&mut buf, row, col).unwrap();
3753 assert_eq!(
3754 buf.len(),
3755 cost_model::cup_cost(row, col),
3756 "CUP cost mismatch at ({row}, {col})"
3757 );
3758 }
3759 }
3760
3761 #[test]
3762 fn cost_cha_digit_boundaries() {
3763 let mut buf = Vec::new();
3764 for col in [0u16, 8, 9, 98, 99, 998, 999, 9998, 9999, u16::MAX] {
3765 buf.clear();
3766 ansi::cha(&mut buf, col).unwrap();
3767 assert_eq!(
3768 buf.len(),
3769 cost_model::cha_cost(col),
3770 "CHA cost mismatch at col {col}"
3771 );
3772 }
3773 }
3774
3775 #[test]
3776 fn cost_cuf_digit_boundaries() {
3777 let mut buf = Vec::new();
3778 for n in [1u16, 2, 9, 10, 99, 100, 999, 1000, 9999, 10000, u16::MAX] {
3779 buf.clear();
3780 ansi::cuf(&mut buf, n).unwrap();
3781 assert_eq!(
3782 buf.len(),
3783 cost_model::cuf_cost(n),
3784 "CUF cost mismatch for n={n}"
3785 );
3786 }
3787 }
3788
3789 #[test]
3792 fn plan_row_reuse_matches_plan_row() {
3793 let runs = [
3794 ChangeRun::new(5, 2, 4),
3795 ChangeRun::new(5, 8, 10),
3796 ChangeRun::new(5, 20, 25),
3797 ];
3798 let plan1 = cost_model::plan_row(&runs, Some(0), Some(5));
3799 let mut scratch = cost_model::RowPlanScratch::default();
3800 let plan2 = cost_model::plan_row_reuse(&runs, Some(0), Some(5), &mut scratch);
3801 assert_eq!(plan1, plan2);
3802 }
3803
3804 #[test]
3805 fn plan_row_reuse_single_run_matches_plan_row() {
3806 let runs = [ChangeRun::new(7, 18, 24)];
3807 let plan1 = cost_model::plan_row(&runs, Some(2), Some(7));
3808 let mut scratch = cost_model::RowPlanScratch::default();
3809 let plan2 = cost_model::plan_row_reuse(&runs, Some(2), Some(7), &mut scratch);
3810 assert_eq!(plan1, plan2);
3811 assert_eq!(
3812 plan2.total_cost(),
3813 cost_model::cheapest_move_cost(Some(2), Some(7), 18, 7) + runs[0].len()
3814 );
3815 }
3816
3817 #[test]
3818 fn plan_row_reuse_across_different_sizes() {
3819 let mut scratch = cost_model::RowPlanScratch::default();
3821
3822 let large_runs: Vec<ChangeRun> = (0..20)
3823 .map(|i| ChangeRun::new(0, i * 4, i * 4 + 1))
3824 .collect();
3825 let plan_large = cost_model::plan_row_reuse(&large_runs, None, None, &mut scratch);
3826 assert!(!plan_large.spans().is_empty());
3827
3828 let small_runs = [ChangeRun::new(1, 5, 8)];
3829 let plan_small = cost_model::plan_row_reuse(&small_runs, None, None, &mut scratch);
3830 assert_eq!(plan_small.spans().len(), 1);
3831 assert_eq!(plan_small.spans()[0].x0, 5);
3832 assert_eq!(plan_small.spans()[0].x1, 8);
3833 }
3834
3835 #[test]
3838 fn plan_row_gap_exactly_32_cells() {
3839 let runs = [ChangeRun::new(0, 0, 0), ChangeRun::new(0, 33, 33)];
3842 let plan = cost_model::plan_row(&runs, None, None);
3843 assert!(
3847 plan.spans().len() <= 2,
3848 "32-cell gap should still consider merge"
3849 );
3850 }
3851
3852 #[test]
3853 fn plan_row_gap_33_cells_stays_sparse() {
3854 let runs = [ChangeRun::new(0, 0, 0), ChangeRun::new(0, 34, 34)];
3857 let plan = cost_model::plan_row(&runs, None, None);
3858 assert_eq!(
3859 plan.spans().len(),
3860 2,
3861 "33-cell gap should stay sparse (gap > 32 breaks)"
3862 );
3863 }
3864
3865 #[test]
3868 fn plan_row_many_sparse_spans() {
3869 let runs = [
3871 ChangeRun::new(0, 0, 0),
3872 ChangeRun::new(0, 40, 40),
3873 ChangeRun::new(0, 80, 80),
3874 ChangeRun::new(0, 120, 120),
3875 ChangeRun::new(0, 160, 160),
3876 ChangeRun::new(0, 200, 200),
3877 ];
3878 let plan = cost_model::plan_row(&runs, None, None);
3879 assert_eq!(plan.spans().len(), 6, "Should have 6 separate sparse spans");
3881 }
3882
3883 #[test]
3886 fn cell_style_default_is_transparent_no_attrs() {
3887 let style = CellStyle::default();
3888 assert_eq!(style.fg, PackedRgba::TRANSPARENT);
3889 assert_eq!(style.bg, PackedRgba::TRANSPARENT);
3890 assert!(style.attrs.is_empty());
3891 }
3892
3893 #[test]
3894 fn cell_style_from_cell_captures_all() {
3895 let fg = PackedRgba::rgb(10, 20, 30);
3896 let bg = PackedRgba::rgb(40, 50, 60);
3897 let flags = StyleFlags::BOLD | StyleFlags::ITALIC;
3898 let cell = Cell::from_char('X')
3899 .with_fg(fg)
3900 .with_bg(bg)
3901 .with_attrs(CellAttrs::new(flags, 5));
3902 let style = CellStyle::from_cell(&cell);
3903 assert_eq!(style.fg, fg);
3904 assert_eq!(style.bg, bg);
3905 assert_eq!(style.attrs, flags);
3906 }
3907
3908 #[test]
3909 fn cell_style_eq_and_clone() {
3910 let a = CellStyle {
3911 fg: PackedRgba::rgb(1, 2, 3),
3912 bg: PackedRgba::TRANSPARENT,
3913 attrs: StyleFlags::DIM,
3914 };
3915 let b = a;
3916 assert_eq!(a, b);
3917 }
3918
3919 #[test]
3922 fn sgr_flags_len_empty() {
3923 assert_eq!(Presenter::<Vec<u8>>::sgr_flags_len(StyleFlags::empty()), 0);
3924 }
3925
3926 #[test]
3927 fn sgr_flags_len_single() {
3928 let len = Presenter::<Vec<u8>>::sgr_flags_len(StyleFlags::BOLD);
3930 assert!(len > 0);
3931 let mut buf = Vec::new();
3933 ansi::sgr_flags(&mut buf, StyleFlags::BOLD).unwrap();
3934 assert_eq!(len as usize, buf.len());
3935 }
3936
3937 #[test]
3938 fn sgr_flags_len_multiple() {
3939 let flags = StyleFlags::BOLD | StyleFlags::ITALIC | StyleFlags::UNDERLINE;
3940 let len = Presenter::<Vec<u8>>::sgr_flags_len(flags);
3941 let mut buf = Vec::new();
3942 ansi::sgr_flags(&mut buf, flags).unwrap();
3943 assert_eq!(len as usize, buf.len());
3944 }
3945
3946 #[test]
3947 fn sgr_flags_off_len_empty() {
3948 assert_eq!(
3949 Presenter::<Vec<u8>>::sgr_flags_off_len(StyleFlags::empty()),
3950 0
3951 );
3952 }
3953
3954 #[test]
3955 fn sgr_rgb_len_matches_actual() {
3956 let color = PackedRgba::rgb(0, 0, 0);
3957 let estimated = Presenter::<Vec<u8>>::sgr_rgb_len(color);
3958 assert!(estimated > 0);
3961 }
3962
3963 #[test]
3964 fn sgr_rgb_len_large_values() {
3965 let color = PackedRgba::rgb(255, 255, 255);
3966 let small_color = PackedRgba::rgb(0, 0, 0);
3967 let large_len = Presenter::<Vec<u8>>::sgr_rgb_len(color);
3968 let small_len = Presenter::<Vec<u8>>::sgr_rgb_len(small_color);
3969 assert!(large_len > small_len);
3971 }
3972
3973 #[test]
3974 fn dec_len_u8_boundaries() {
3975 assert_eq!(Presenter::<Vec<u8>>::dec_len_u8(0), 1);
3976 assert_eq!(Presenter::<Vec<u8>>::dec_len_u8(9), 1);
3977 assert_eq!(Presenter::<Vec<u8>>::dec_len_u8(10), 2);
3978 assert_eq!(Presenter::<Vec<u8>>::dec_len_u8(99), 2);
3979 assert_eq!(Presenter::<Vec<u8>>::dec_len_u8(100), 3);
3980 assert_eq!(Presenter::<Vec<u8>>::dec_len_u8(255), 3);
3981 }
3982
3983 #[test]
3986 fn sgr_delta_all_attrs_removed_at_once() {
3987 let mut presenter = test_presenter();
3988 let all_flags = StyleFlags::BOLD
3989 | StyleFlags::DIM
3990 | StyleFlags::ITALIC
3991 | StyleFlags::UNDERLINE
3992 | StyleFlags::BLINK
3993 | StyleFlags::REVERSE
3994 | StyleFlags::STRIKETHROUGH;
3995 let old = CellStyle {
3996 fg: PackedRgba::rgb(100, 100, 100),
3997 bg: PackedRgba::TRANSPARENT,
3998 attrs: all_flags,
3999 };
4000 let new = CellStyle {
4001 fg: PackedRgba::rgb(100, 100, 100),
4002 bg: PackedRgba::TRANSPARENT,
4003 attrs: StyleFlags::empty(),
4004 };
4005
4006 presenter.current_style = Some(old);
4007 presenter.emit_style_delta(old, new).unwrap();
4008 let output = presenter.into_inner().unwrap();
4009
4010 assert!(!output.is_empty());
4013 }
4014
4015 #[test]
4016 fn sgr_delta_fg_to_transparent() {
4017 let mut presenter = test_presenter();
4018 let old = CellStyle {
4019 fg: PackedRgba::rgb(200, 100, 50),
4020 bg: PackedRgba::TRANSPARENT,
4021 attrs: StyleFlags::empty(),
4022 };
4023 let new = CellStyle {
4024 fg: PackedRgba::TRANSPARENT,
4025 bg: PackedRgba::TRANSPARENT,
4026 attrs: StyleFlags::empty(),
4027 };
4028
4029 presenter.current_style = Some(old);
4030 presenter.emit_style_delta(old, new).unwrap();
4031 let output = presenter.into_inner().unwrap();
4032 let output_str = String::from_utf8_lossy(&output);
4033
4034 assert!(!output.is_empty(), "Should emit fg removal: {output_str:?}");
4037 }
4038
4039 #[test]
4040 fn sgr_delta_bg_to_transparent() {
4041 let mut presenter = test_presenter();
4042 let old = CellStyle {
4043 fg: PackedRgba::TRANSPARENT,
4044 bg: PackedRgba::rgb(30, 60, 90),
4045 attrs: StyleFlags::empty(),
4046 };
4047 let new = CellStyle {
4048 fg: PackedRgba::TRANSPARENT,
4049 bg: PackedRgba::TRANSPARENT,
4050 attrs: StyleFlags::empty(),
4051 };
4052
4053 presenter.current_style = Some(old);
4054 presenter.emit_style_delta(old, new).unwrap();
4055 let output = presenter.into_inner().unwrap();
4056 assert!(!output.is_empty(), "Should emit bg removal");
4057 }
4058
4059 #[test]
4060 fn sgr_delta_dim_removed_bold_stays() {
4061 let mut presenter = test_presenter();
4065 let mut buffer = Buffer::new(3, 1);
4066
4067 let attrs1 = CellAttrs::new(StyleFlags::BOLD | StyleFlags::DIM, 0);
4068 let attrs2 = CellAttrs::new(StyleFlags::BOLD, 0);
4069 buffer.set_raw(0, 0, Cell::from_char('A').with_attrs(attrs1));
4070 buffer.set_raw(1, 0, Cell::from_char('B').with_attrs(attrs2));
4071
4072 let old = Buffer::new(3, 1);
4073 let diff = BufferDiff::compute(&old, &buffer);
4074
4075 presenter.present(&buffer, &diff).unwrap();
4076 let output = get_output(presenter);
4077 let output_str = String::from_utf8_lossy(&output);
4078
4079 assert!(
4081 output_str.contains("\x1b[22m"),
4082 "Expected dim-off (22) in: {output_str:?}"
4083 );
4084 assert!(
4085 output_str.contains("\x1b[1m"),
4086 "Expected bold re-enable (1) in: {output_str:?}"
4087 );
4088 }
4089
4090 #[test]
4091 fn sgr_delta_fallback_to_full_reset_when_cheaper() {
4092 let mut presenter = test_presenter();
4094 let old = CellStyle {
4095 fg: PackedRgba::rgb(10, 20, 30),
4096 bg: PackedRgba::rgb(40, 50, 60),
4097 attrs: StyleFlags::BOLD
4098 | StyleFlags::DIM
4099 | StyleFlags::ITALIC
4100 | StyleFlags::UNDERLINE
4101 | StyleFlags::STRIKETHROUGH,
4102 };
4103 let new = CellStyle {
4104 fg: PackedRgba::TRANSPARENT,
4105 bg: PackedRgba::TRANSPARENT,
4106 attrs: StyleFlags::empty(),
4107 };
4108
4109 presenter.current_style = Some(old);
4110 presenter.emit_style_delta(old, new).unwrap();
4111 let output = presenter.into_inner().unwrap();
4112 let output_str = String::from_utf8_lossy(&output);
4113
4114 assert!(
4116 output_str.contains("\x1b[0m"),
4117 "Expected full reset fallback: {output_str:?}"
4118 );
4119 }
4120
4121 #[test]
4124 fn emit_cell_control_char_replaced_with_fffd() {
4125 let mut presenter = test_presenter();
4126 presenter.cursor_x = Some(0);
4127 presenter.cursor_y = Some(0);
4128
4129 let cell = Cell::from_char('\x01');
4132 presenter.emit_cell(0, &cell, None, None).unwrap();
4133 let output = presenter.into_inner().unwrap();
4134 let output_str = String::from_utf8_lossy(&output);
4135
4136 assert!(
4138 output_str.contains('\u{FFFD}'),
4139 "Control char (width 0) should be replaced with U+FFFD, got: {output:?}"
4140 );
4141 assert!(
4142 !output.contains(&0x01),
4143 "Raw control char should not appear"
4144 );
4145 }
4146
4147 #[test]
4148 fn emit_content_empty_cell_emits_space() {
4149 let mut presenter = test_presenter();
4150 presenter.cursor_x = Some(0);
4151 presenter.cursor_y = Some(0);
4152
4153 let cell = Cell::default();
4154 assert!(cell.is_empty());
4155 presenter.emit_cell(0, &cell, None, None).unwrap();
4156 let output = presenter.into_inner().unwrap();
4157 assert!(output.contains(&b' '), "Empty cell should emit space");
4158 }
4159
4160 #[test]
4161 fn emit_content_ascii_char_emits_single_byte() {
4162 let mut presenter = test_presenter();
4163 presenter
4164 .emit_content(PreparedContent::Char('A'), 1, None)
4165 .unwrap();
4166 let output = presenter.into_inner().unwrap();
4167 assert_eq!(output, b"A");
4168 }
4169
4170 #[test]
4171 fn emit_content_ascii_control_sanitizes_to_space() {
4172 let mut presenter = test_presenter();
4173 presenter
4174 .emit_content(PreparedContent::Char('\n'), 1, None)
4175 .unwrap();
4176 let output = presenter.into_inner().unwrap();
4177 assert_eq!(output, b" ");
4178 }
4179
4180 #[test]
4181 fn prepared_content_ascii_widths_match_char_width_contract() {
4182 for ch in ['A', ' ', '\n', '\r', '\x1f', '\x7f'] {
4183 let cell = Cell::from_char(ch);
4184 let (prepared, width) = PreparedContent::from_cell(&cell);
4185 assert_eq!(prepared, PreparedContent::Char(ch));
4186 assert_eq!(width, char_width(ch), "width mismatch for {ch:?}");
4187 }
4188 }
4189
4190 #[test]
4191 fn prepared_content_tab_uses_canonicalized_space() {
4192 let cell = Cell::from_char('\t');
4193 let (prepared, width) = PreparedContent::from_cell(&cell);
4194 assert_eq!(prepared, PreparedContent::Char(' '));
4195 assert_eq!(width, 1);
4196 }
4197
4198 #[test]
4199 fn prepared_content_nul_uses_empty_cell_representation() {
4200 let cell = Cell::from_char('\0');
4201 let (prepared, width) = PreparedContent::from_cell(&cell);
4202 assert_eq!(prepared, PreparedContent::Empty);
4203 assert_eq!(width, 0);
4204 }
4205
4206 #[test]
4207 fn emit_content_grapheme_sanitizes_escape_sequences() {
4208 let mut presenter = test_presenter();
4209 presenter.cursor_x = Some(0);
4210 presenter.cursor_y = Some(0);
4211
4212 let mut pool = GraphemePool::new();
4213 let gid = pool.intern("A\x1b[31mB\x1b[0m", 2);
4214 let cell = Cell::new(CellContent::from_grapheme(gid));
4215 presenter.emit_cell(0, &cell, Some(&pool), None).unwrap();
4216
4217 let output = presenter.into_inner().unwrap();
4218 let output_str = String::from_utf8_lossy(&output);
4219 assert!(
4220 output_str.contains("AB"),
4221 "sanitized grapheme should preserve visible payload"
4222 );
4223 assert!(
4224 !output_str.contains("\x1b[31m"),
4225 "raw escape sequence must not be emitted"
4226 );
4227 }
4228
4229 #[test]
4230 fn emit_content_grapheme_width_mismatch_uses_placeholders() {
4231 let mut presenter = test_presenter();
4232 let mut pool = GraphemePool::new();
4233 let gid = pool.intern("A\x07", 2);
4234
4235 presenter
4236 .emit_content(PreparedContent::Grapheme(gid), 2, Some(&pool))
4237 .unwrap();
4238
4239 let output = presenter.into_inner().unwrap();
4240 assert_eq!(output, b"??");
4241 }
4242
4243 #[test]
4244 fn wide_grapheme_tail_repair_does_not_blank_unrelated_following_cells() {
4245 let mut presenter = test_presenter();
4246 let mut pool = GraphemePool::new();
4247 let gid = pool.intern("XYZ", 3);
4248 let mut buffer = Buffer::new(8, 1);
4249
4250 buffer.set_raw(0, 0, Cell::new(CellContent::from_grapheme(gid)));
4251 buffer.set_raw(1, 0, Cell::from_char('a'));
4252 buffer.set_raw(2, 0, Cell::from_char('b'));
4253 buffer.set_raw(3, 0, Cell::from_char('c'));
4254
4255 let old = Buffer::new(8, 1);
4256 let diff = BufferDiff::compute(&old, &buffer);
4257
4258 presenter
4259 .present_with_pool(&buffer, &diff, Some(&pool), None)
4260 .unwrap();
4261
4262 let output = presenter.into_inner().unwrap();
4263 let output_str = String::from_utf8_lossy(&output);
4264 let visible = sanitize(output_str.as_ref());
4265
4266 assert!(
4267 visible.contains("XYZabc"),
4268 "width-3 grapheme repair must not erase following cells: {:?}",
4269 visible
4270 );
4271 }
4272
4273 #[test]
4276 fn continuation_cell_cursor_x_none() {
4277 let mut presenter = test_presenter();
4278 presenter.cursor_x = None;
4280 presenter.cursor_y = Some(0);
4281
4282 let cell = Cell::CONTINUATION;
4283 presenter.emit_cell(5, &cell, None, None).unwrap();
4284 let output = presenter.into_inner().unwrap();
4285
4286 assert!(
4288 output.contains(&b' '),
4289 "Should emit a space for continuation with unknown cursor_x"
4290 );
4291 }
4292
4293 #[test]
4294 fn continuation_cell_cursor_already_past() {
4295 let mut presenter = test_presenter();
4296 presenter.cursor_x = Some(10);
4298 presenter.cursor_y = Some(0);
4299
4300 let cell = Cell::CONTINUATION;
4301 presenter.emit_cell(5, &cell, None, None).unwrap();
4302 let output = presenter.into_inner().unwrap();
4303
4304 assert!(
4306 output.is_empty(),
4307 "Should skip continuation when cursor is past it"
4308 );
4309 }
4310
4311 #[test]
4314 fn clear_line_positions_cursor_and_erases() {
4315 let mut presenter = test_presenter();
4316 presenter.clear_line(5).unwrap();
4317 let output = get_output(presenter);
4318 let output_str = String::from_utf8_lossy(&output);
4319
4320 assert!(
4322 output_str.contains("\x1b[2K"),
4323 "Should contain erase line sequence"
4324 );
4325 }
4326
4327 #[test]
4330 fn into_inner_returns_accumulated_output() {
4331 let mut presenter = test_presenter();
4332 presenter.position_cursor(0, 0).unwrap();
4333 let inner = presenter.into_inner().unwrap();
4334 assert!(!inner.is_empty(), "into_inner should return buffered data");
4335 }
4336
4337 #[test]
4340 fn move_cursor_optimal_same_row_forward_large() {
4341 let mut presenter = test_presenter();
4342 presenter.cursor_x = Some(0);
4343 presenter.cursor_y = Some(0);
4344
4345 presenter.move_cursor_optimal(100, 0).unwrap();
4347 let output = presenter.into_inner().unwrap();
4348
4349 let cuf = cost_model::cuf_cost(100);
4351 let cha = cost_model::cha_cost(100);
4352 let cup = cost_model::cup_cost(0, 100);
4353 let cheapest = cuf.min(cha).min(cup);
4354 assert_eq!(output.len(), cheapest, "Should pick cheapest cursor move");
4355 }
4356
4357 #[test]
4358 fn move_cursor_optimal_same_row_backward_to_zero() {
4359 let mut presenter = test_presenter();
4360 presenter.cursor_x = Some(50);
4361 presenter.cursor_y = Some(0);
4362
4363 presenter.move_cursor_optimal(0, 0).unwrap();
4364 let output = presenter.into_inner().unwrap();
4365
4366 let mut expected = Vec::new();
4369 ansi::cha(&mut expected, 0).unwrap();
4370 assert_eq!(output, expected, "Should use CHA for backward to col 0");
4371 }
4372
4373 #[test]
4374 fn move_cursor_optimal_unknown_cursor_uses_cup() {
4375 let mut presenter = test_presenter();
4376 presenter.move_cursor_optimal(10, 5).unwrap();
4378 let output = presenter.into_inner().unwrap();
4379 let mut expected = Vec::new();
4380 ansi::cup(&mut expected, 5, 10).unwrap();
4381 assert_eq!(output, expected, "Should use CUP when cursor is unknown");
4382 }
4383
4384 #[test]
4387 fn sync_wrap_order_begin_content_reset_end() {
4388 let mut presenter = test_presenter_with_sync();
4389 let mut buffer = Buffer::new(3, 1);
4390 buffer.set_raw(0, 0, Cell::from_char('Z'));
4391
4392 let old = Buffer::new(3, 1);
4393 let diff = BufferDiff::compute(&old, &buffer);
4394
4395 presenter.present(&buffer, &diff).unwrap();
4396 let output = get_output(presenter);
4397
4398 let sync_begin_pos = output
4399 .windows(ansi::SYNC_BEGIN.len())
4400 .position(|w| w == ansi::SYNC_BEGIN)
4401 .expect("sync begin missing");
4402 let z_pos = output
4403 .iter()
4404 .position(|&b| b == b'Z')
4405 .expect("character Z missing");
4406 let reset_pos = output
4407 .windows(b"\x1b[0m".len())
4408 .rposition(|w| w == b"\x1b[0m")
4409 .expect("SGR reset missing");
4410 let sync_end_pos = output
4411 .windows(ansi::SYNC_END.len())
4412 .rposition(|w| w == ansi::SYNC_END)
4413 .expect("sync end missing");
4414
4415 assert!(sync_begin_pos < z_pos, "sync begin before content");
4416 assert!(z_pos < reset_pos, "content before reset");
4417 assert!(reset_pos < sync_end_pos, "reset before sync end");
4418 }
4419
4420 #[test]
4423 fn style_none_after_each_frame() {
4424 let mut presenter = test_presenter();
4425 let fg = PackedRgba::rgb(255, 128, 64);
4426
4427 for _ in 0..5 {
4428 let mut buffer = Buffer::new(3, 1);
4429 buffer.set_raw(0, 0, Cell::from_char('X').with_fg(fg));
4430 let old = Buffer::new(3, 1);
4431 let diff = BufferDiff::compute(&old, &buffer);
4432 presenter.present(&buffer, &diff).unwrap();
4433
4434 assert!(
4436 presenter.current_style.is_none(),
4437 "Style should be None after frame end"
4438 );
4439 assert!(
4440 presenter.current_link.is_none(),
4441 "Link should be None after frame end"
4442 );
4443 }
4444 }
4445
4446 #[test]
4449 fn link_closed_at_frame_end_even_if_all_cells_linked() {
4450 let mut presenter = test_presenter();
4451 let mut buffer = Buffer::new(3, 1);
4452 let mut links = LinkRegistry::new();
4453 let link_id = links.register("https://all-linked.test");
4454
4455 for x in 0..3 {
4457 buffer.set_raw(
4458 x,
4459 0,
4460 Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
4461 );
4462 }
4463
4464 let old = Buffer::new(3, 1);
4465 let diff = BufferDiff::compute(&old, &buffer);
4466 presenter
4467 .present_with_pool(&buffer, &diff, None, Some(&links))
4468 .unwrap();
4469
4470 assert!(
4472 presenter.current_link.is_none(),
4473 "Link must be closed at frame end"
4474 );
4475 }
4476
4477 #[test]
4480 fn present_stats_empty_diff() {
4481 let mut presenter = test_presenter();
4482 let buffer = Buffer::new(10, 10);
4483 let diff = BufferDiff::new();
4484 let stats = presenter.present(&buffer, &diff).unwrap();
4485
4486 assert_eq!(stats.cells_changed, 0);
4487 assert_eq!(stats.run_count, 0);
4488 assert!(stats.bytes_emitted > 0);
4490 }
4491
4492 #[test]
4493 fn present_stats_full_row() {
4494 let mut presenter = test_presenter();
4495 let mut buffer = Buffer::new(10, 1);
4496 for x in 0..10 {
4497 buffer.set_raw(x, 0, Cell::from_char('A'));
4498 }
4499 let old = Buffer::new(10, 1);
4500 let diff = BufferDiff::compute(&old, &buffer);
4501 let stats = presenter.present(&buffer, &diff).unwrap();
4502
4503 assert_eq!(stats.cells_changed, 10);
4504 assert!(stats.run_count >= 1);
4505 assert!(stats.bytes_emitted > 10, "Should include ANSI overhead");
4506 }
4507
4508 #[test]
4511 fn capabilities_accessor() {
4512 let mut caps = TerminalCapabilities::basic();
4513 caps.sync_output = true;
4514 let presenter = Presenter::new(Vec::<u8>::new(), caps);
4515 assert!(presenter.capabilities().sync_output);
4516 }
4517
4518 #[test]
4521 fn flush_succeeds_on_empty_presenter() {
4522 let mut presenter = test_presenter();
4523 presenter.flush().unwrap();
4524 let output = get_output(presenter);
4525 assert!(output.is_empty());
4526 }
4527
4528 #[test]
4531 fn row_plan_total_cost_matches_dp() {
4532 let runs = [ChangeRun::new(3, 5, 10), ChangeRun::new(3, 15, 20)];
4533 let plan = cost_model::plan_row(&runs, None, None);
4534 assert!(plan.total_cost() > 0);
4535 }
4538
4539 #[test]
4542 fn sgr_delta_hot_path_only_fg_change() {
4543 let mut presenter = test_presenter();
4544 let old = CellStyle {
4545 fg: PackedRgba::rgb(255, 0, 0),
4546 bg: PackedRgba::rgb(0, 0, 0),
4547 attrs: StyleFlags::BOLD | StyleFlags::ITALIC,
4548 };
4549 let new = CellStyle {
4550 fg: PackedRgba::rgb(0, 255, 0),
4551 bg: PackedRgba::rgb(0, 0, 0),
4552 attrs: StyleFlags::BOLD | StyleFlags::ITALIC, };
4554
4555 presenter.current_style = Some(old);
4556 presenter.emit_style_delta(old, new).unwrap();
4557 let output = presenter.into_inner().unwrap();
4558 let output_str = String::from_utf8_lossy(&output);
4559
4560 assert!(output_str.contains("38;2;0;255;0"), "Should emit new fg");
4562 assert!(
4563 !output_str.contains("\x1b[0m"),
4564 "No reset needed for color-only change"
4565 );
4566 assert!(
4568 !output_str.contains("\x1b[1m"),
4569 "Bold should not be re-emitted"
4570 );
4571 }
4572
4573 #[test]
4574 fn sgr_delta_hot_path_both_colors_change() {
4575 let mut presenter = test_presenter();
4576 let old = CellStyle {
4577 fg: PackedRgba::rgb(1, 2, 3),
4578 bg: PackedRgba::rgb(4, 5, 6),
4579 attrs: StyleFlags::UNDERLINE,
4580 };
4581 let new = CellStyle {
4582 fg: PackedRgba::rgb(7, 8, 9),
4583 bg: PackedRgba::rgb(10, 11, 12),
4584 attrs: StyleFlags::UNDERLINE, };
4586
4587 presenter.current_style = Some(old);
4588 presenter.emit_style_delta(old, new).unwrap();
4589 let output = presenter.into_inner().unwrap();
4590 let output_str = String::from_utf8_lossy(&output);
4591
4592 assert!(output_str.contains("38;2;7;8;9"), "Should emit new fg");
4593 assert!(output_str.contains("48;2;10;11;12"), "Should emit new bg");
4594 assert!(!output_str.contains("\x1b[0m"), "No reset for color-only");
4595 }
4596
4597 #[test]
4600 fn emit_style_full_default_is_just_reset() {
4601 let mut presenter = test_presenter();
4602 let default_style = CellStyle::default();
4603 presenter.emit_style_full(default_style).unwrap();
4604 let output = presenter.into_inner().unwrap();
4605
4606 assert_eq!(output, b"\x1b[0m");
4608 }
4609
4610 #[test]
4611 fn emit_style_full_with_all_properties() {
4612 let mut presenter = test_presenter();
4613 let style = CellStyle {
4614 fg: PackedRgba::rgb(10, 20, 30),
4615 bg: PackedRgba::rgb(40, 50, 60),
4616 attrs: StyleFlags::BOLD | StyleFlags::ITALIC,
4617 };
4618 presenter.emit_style_full(style).unwrap();
4619 let output = presenter.into_inner().unwrap();
4620 let output_str = String::from_utf8_lossy(&output);
4621
4622 assert!(output_str.contains("\x1b[0m"), "Should start with reset");
4624 assert!(output_str.contains("38;2;10;20;30"), "Should have fg");
4625 assert!(output_str.contains("48;2;40;50;60"), "Should have bg");
4626 }
4627
4628 #[test]
4631 fn present_multiple_rows_different_strategies() {
4632 let mut presenter = test_presenter();
4633 let mut buffer = Buffer::new(80, 5);
4634
4635 for x in (0..20).step_by(2) {
4637 buffer.set_raw(x, 0, Cell::from_char('D'));
4638 }
4639 buffer.set_raw(0, 2, Cell::from_char('L'));
4641 buffer.set_raw(79, 2, Cell::from_char('R'));
4642 buffer.set_raw(40, 4, Cell::from_char('M'));
4644
4645 let old = Buffer::new(80, 5);
4646 let diff = BufferDiff::compute(&old, &buffer);
4647 presenter.present(&buffer, &diff).unwrap();
4648 let output = get_output(presenter);
4649 let output_str = String::from_utf8_lossy(&output);
4650
4651 assert!(output_str.contains('D'));
4652 assert!(output_str.contains('L'));
4653 assert!(output_str.contains('R'));
4654 assert!(output_str.contains('M'));
4655 }
4656
4657 #[test]
4658 fn zero_width_chars_replaced_with_placeholder() {
4659 let mut presenter = test_presenter();
4660 let mut buffer = Buffer::new(5, 1);
4661
4662 let zw_char = '\u{0301}';
4666
4667 assert_eq!(Cell::from_char(zw_char).content.width(), 0);
4669
4670 buffer.set_raw(0, 0, Cell::from_char(zw_char));
4671 buffer.set_raw(1, 0, Cell::from_char('A'));
4672
4673 let old = Buffer::new(5, 1);
4674 let diff = BufferDiff::compute(&old, &buffer);
4675
4676 presenter.present(&buffer, &diff).unwrap();
4677 let output = get_output(presenter);
4678 let output_str = String::from_utf8_lossy(&output);
4679
4680 assert!(
4682 output_str.contains("\u{FFFD}"),
4683 "Expected replacement character for zero-width content, got: {:?}",
4684 output_str
4685 );
4686
4687 assert!(
4689 !output_str.contains(zw_char),
4690 "Should not contain raw zero-width char"
4691 );
4692
4693 assert!(
4695 output_str.contains('A'),
4696 "Should contain subsequent character 'A'"
4697 );
4698 }
4699}
4700
4701#[cfg(test)]
4702mod proptests {
4703 use super::*;
4704 use crate::cell::{Cell, PackedRgba};
4705 use crate::diff::BufferDiff;
4706 use crate::terminal_model::TerminalModel;
4707 use proptest::prelude::*;
4708
4709 fn test_presenter() -> Presenter<Vec<u8>> {
4711 let caps = TerminalCapabilities::basic();
4712 Presenter::new(Vec::new(), caps)
4713 }
4714
4715 proptest! {
4716 #[test]
4719 fn presenter_roundtrip_characters(
4720 width in 5u16..40,
4721 height in 3u16..20,
4722 num_chars in 1usize..50, ) {
4724 let mut buffer = Buffer::new(width, height);
4725 let mut changed_positions = std::collections::HashSet::new();
4726
4727 for i in 0..num_chars {
4729 let x = (i * 7 + 3) as u16 % width;
4730 let y = (i * 11 + 5) as u16 % height;
4731 let ch = char::from_u32(('A' as u32) + (i as u32 % 26)).unwrap();
4732 buffer.set_raw(x, y, Cell::from_char(ch));
4733 changed_positions.insert((x, y));
4734 }
4735
4736 let mut presenter = test_presenter();
4738 let old = Buffer::new(width, height);
4739 let diff = BufferDiff::compute(&old, &buffer);
4740 presenter.present(&buffer, &diff).unwrap();
4741 let output = presenter.into_inner().unwrap();
4742
4743 let mut model = TerminalModel::new(width as usize, height as usize);
4745 model.process(&output);
4746
4747 for &(x, y) in &changed_positions {
4749 let buf_cell = buffer.get_unchecked(x, y);
4750 let expected_ch = buf_cell.content.as_char().unwrap_or(' ');
4751 let mut expected_buf = [0u8; 4];
4752 let expected_str = expected_ch.encode_utf8(&mut expected_buf);
4753
4754 if let Some(model_cell) = model.cell(x as usize, y as usize) {
4755 prop_assert_eq!(
4756 model_cell.text.as_str(),
4757 expected_str,
4758 "Character mismatch at ({}, {})", x, y
4759 );
4760 }
4761 }
4762 }
4763
4764 #[test]
4766 fn style_reset_after_present(
4767 width in 5u16..30,
4768 height in 3u16..15,
4769 num_styled in 1usize..20,
4770 ) {
4771 let mut buffer = Buffer::new(width, height);
4772
4773 for i in 0..num_styled {
4775 let x = (i * 7) as u16 % width;
4776 let y = (i * 11) as u16 % height;
4777 let fg = PackedRgba::rgb(
4778 ((i * 31) % 256) as u8,
4779 ((i * 47) % 256) as u8,
4780 ((i * 71) % 256) as u8,
4781 );
4782 buffer.set_raw(x, y, Cell::from_char('X').with_fg(fg));
4783 }
4784
4785 let mut presenter = test_presenter();
4787 let old = Buffer::new(width, height);
4788 let diff = BufferDiff::compute(&old, &buffer);
4789 presenter.present(&buffer, &diff).unwrap();
4790 let output = presenter.into_inner().unwrap();
4791 let output_str = String::from_utf8_lossy(&output);
4792
4793 prop_assert!(
4795 output_str.contains("\x1b[0m"),
4796 "Output should contain SGR reset"
4797 );
4798 }
4799
4800 #[test]
4802 fn empty_diff_minimal_output(
4803 width in 5u16..50,
4804 height in 3u16..25,
4805 ) {
4806 let buffer = Buffer::new(width, height);
4807 let diff = BufferDiff::new(); let mut presenter = test_presenter();
4810 presenter.present(&buffer, &diff).unwrap();
4811 let output = presenter.into_inner().unwrap();
4812
4813 prop_assert!(output.len() < 50, "Empty diff should have minimal output");
4816 }
4817
4818 #[test]
4823 fn diff_size_bounds(
4824 width in 5u16..30,
4825 height in 3u16..15,
4826 ) {
4827 let old = Buffer::new(width, height);
4829 let mut new = Buffer::new(width, height);
4830
4831 for y in 0..height {
4832 for x in 0..width {
4833 new.set_raw(x, y, Cell::from_char('X'));
4834 }
4835 }
4836
4837 let diff = BufferDiff::compute(&old, &new);
4838
4839 prop_assert_eq!(
4841 diff.len(),
4842 (width as usize) * (height as usize),
4843 "Full change should have all cells in diff"
4844 );
4845 }
4846
4847 #[test]
4849 fn presenter_cursor_consistency(
4850 width in 10u16..40,
4851 height in 5u16..20,
4852 num_runs in 1usize..10,
4853 ) {
4854 let mut buffer = Buffer::new(width, height);
4855
4856 for i in 0..num_runs {
4858 let start_x = (i * 5) as u16 % (width - 5);
4859 let y = i as u16 % height;
4860 for x in start_x..(start_x + 3) {
4861 buffer.set_raw(x, y, Cell::from_char('A'));
4862 }
4863 }
4864
4865 let mut presenter = test_presenter();
4867 let old = Buffer::new(width, height);
4868
4869 for _ in 0..3 {
4870 let diff = BufferDiff::compute(&old, &buffer);
4871 presenter.present(&buffer, &diff).unwrap();
4872 }
4873
4874 let output = presenter.into_inner().unwrap();
4876 prop_assert!(!output.is_empty(), "Should produce some output");
4877 }
4878
4879 #[test]
4883 fn sgr_delta_transition_equivalence(
4884 width in 5u16..20,
4885 height in 3u16..10,
4886 num_styled in 2usize..15,
4887 ) {
4888 let mut buffer = Buffer::new(width, height);
4889 let mut expected: std::collections::HashMap<(u16, u16), char> =
4891 std::collections::HashMap::new();
4892
4893 for i in 0..num_styled {
4895 let x = (i * 3 + 1) as u16 % width;
4896 let y = (i * 5 + 2) as u16 % height;
4897 let ch = char::from_u32(('A' as u32) + (i as u32 % 26)).unwrap();
4898 let fg = PackedRgba::rgb(
4899 ((i * 73) % 256) as u8,
4900 ((i * 137) % 256) as u8,
4901 ((i * 41) % 256) as u8,
4902 );
4903 let bg = if i % 3 == 0 {
4904 PackedRgba::rgb(
4905 ((i * 29) % 256) as u8,
4906 ((i * 53) % 256) as u8,
4907 ((i * 97) % 256) as u8,
4908 )
4909 } else {
4910 PackedRgba::TRANSPARENT
4911 };
4912 let flags_bits = ((i * 37) % 256) as u8;
4913 let flags = StyleFlags::from_bits_truncate(flags_bits);
4914 let cell = Cell::from_char(ch)
4915 .with_fg(fg)
4916 .with_bg(bg)
4917 .with_attrs(CellAttrs::new(flags, 0));
4918 buffer.set_raw(x, y, cell);
4919 expected.insert((x, y), ch);
4920 }
4921
4922 let mut presenter = test_presenter();
4924 let old = Buffer::new(width, height);
4925 let diff = BufferDiff::compute(&old, &buffer);
4926 presenter.present(&buffer, &diff).unwrap();
4927 let output = presenter.into_inner().unwrap();
4928
4929 let mut model = TerminalModel::new(width as usize, height as usize);
4931 model.process(&output);
4932
4933 for (&(x, y), &ch) in &expected {
4934 let mut buf = [0u8; 4];
4935 let expected_str = ch.encode_utf8(&mut buf);
4936
4937 if let Some(model_cell) = model.cell(x as usize, y as usize) {
4938 prop_assert_eq!(
4939 model_cell.text.as_str(),
4940 expected_str,
4941 "Character mismatch at ({}, {}) with delta engine", x, y
4942 );
4943 }
4944 }
4945 }
4946
4947 #[test]
4951 fn dp_emit_equivalence(
4952 width in 20u16..60,
4953 height in 5u16..15,
4954 num_changes in 5usize..30,
4955 ) {
4956 let mut buffer = Buffer::new(width, height);
4957 let mut expected: std::collections::HashMap<(u16, u16), char> =
4958 std::collections::HashMap::new();
4959
4960 for i in 0..num_changes {
4962 let x = (i * 7 + 3) as u16 % width;
4963 let y = (i * 3 + 1) as u16 % height;
4964 let ch = char::from_u32(('A' as u32) + (i as u32 % 26)).unwrap();
4965 buffer.set_raw(x, y, Cell::from_char(ch));
4966 expected.insert((x, y), ch);
4967 }
4968
4969 let mut presenter = test_presenter();
4971 let old = Buffer::new(width, height);
4972 let diff = BufferDiff::compute(&old, &buffer);
4973 presenter.present(&buffer, &diff).unwrap();
4974 let output = presenter.into_inner().unwrap();
4975
4976 let mut model = TerminalModel::new(width as usize, height as usize);
4978 model.process(&output);
4979
4980 for (&(x, y), &ch) in &expected {
4981 let mut buf = [0u8; 4];
4982 let expected_str = ch.encode_utf8(&mut buf);
4983
4984 if let Some(model_cell) = model.cell(x as usize, y as usize) {
4985 prop_assert_eq!(
4986 model_cell.text.as_str(),
4987 expected_str,
4988 "DP cost model: character mismatch at ({}, {})", x, y
4989 );
4990 }
4991 }
4992 }
4993 }
4994}