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::{ColorDepth, 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(
139 from_x: Option<u16>,
140 from_y: Option<u16>,
141 to_x: u16,
142 to_y: u16,
143 ) -> usize {
144 if from_x == Some(to_x) && from_y == Some(to_y) {
146 return 0;
147 }
148
149 match (from_x, from_y) {
150 (Some(fx), Some(fy)) if fy == to_y => {
151 let cha = cha_cost(to_x);
156 if to_x > fx {
157 let cuf = cuf_cost(to_x - fx);
158 cha.min(cuf)
159 } else if to_x < fx {
160 let cub = cub_cost(fx - to_x);
161 cha.min(cub)
162 } else {
163 0
164 }
165 }
166 _ => cup_cost(to_y, to_x),
167 }
168 }
169
170 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
172 pub struct RowSpan {
173 pub y: u16,
175 pub x0: u16,
177 pub x1: u16,
179 }
180
181 #[derive(Debug, Clone, PartialEq, Eq)]
187 pub struct RowPlan {
188 spans: SmallVec<[RowSpan; 8]>,
189 total_cost: usize,
190 }
191
192 impl RowPlan {
193 #[inline]
194 #[must_use]
195 pub fn spans(&self) -> &[RowSpan] {
196 &self.spans
197 }
198
199 #[inline]
201 #[allow(dead_code)] pub fn total_cost(&self) -> usize {
203 self.total_cost
204 }
205 }
206
207 #[derive(Debug, Default)]
212 pub struct RowPlanScratch {
213 prefix_cells: Vec<usize>,
214 dp: Vec<usize>,
215 prev: Vec<usize>,
216 }
217
218 #[allow(dead_code)]
227 pub fn plan_row(row_runs: &[ChangeRun], prev_x: Option<u16>, prev_y: Option<u16>) -> RowPlan {
228 let mut scratch = RowPlanScratch::default();
229 plan_row_reuse(row_runs, prev_x, prev_y, &mut scratch)
230 }
231
232 pub fn plan_row_reuse(
235 row_runs: &[ChangeRun],
236 prev_x: Option<u16>,
237 prev_y: Option<u16>,
238 scratch: &mut RowPlanScratch,
239 ) -> RowPlan {
240 if row_runs.is_empty() {
241 return RowPlan {
242 spans: SmallVec::new(),
243 total_cost: 0,
244 };
245 }
246
247 let row_y = row_runs[0].y;
248 let run_count = row_runs.len();
249
250 if run_count == 1 {
251 let run = row_runs[0];
252 let mut spans: SmallVec<[RowSpan; 8]> = SmallVec::new();
253 spans.push(RowSpan {
254 y: row_y,
255 x0: run.x0,
256 x1: run.x1,
257 });
258 return RowPlan {
259 spans,
260 total_cost: cheapest_move_cost(prev_x, prev_y, run.x0, row_y)
261 .saturating_add(run.len()),
262 };
263 }
264
265 scratch.prefix_cells.clear();
267 scratch.prefix_cells.resize(run_count + 1, 0);
268 scratch.dp.clear();
269 scratch.dp.resize(run_count, usize::MAX);
270 scratch.prev.clear();
271 scratch.prev.resize(run_count, 0);
272
273 for (i, run) in row_runs.iter().enumerate() {
275 scratch.prefix_cells[i + 1] = scratch.prefix_cells[i] + run.len();
276 }
277
278 for j in 0..run_count {
280 let mut best_cost = usize::MAX;
281 let mut best_i = j;
282
283 for i in (0..=j).rev() {
288 let changed_cells = scratch.prefix_cells[j + 1] - scratch.prefix_cells[i];
289 let total_cells =
290 (row_runs[j].x1 as usize).saturating_sub(row_runs[i].x0 as usize) + 1;
291 let gap_cells = total_cells.saturating_sub(changed_cells);
292
293 if gap_cells > 32 {
294 break;
295 }
296
297 let from_x = if i == 0 {
298 prev_x
299 } else {
300 Some(row_runs[i - 1].x1.saturating_add(1))
301 };
302 let from_y = if i == 0 { prev_y } else { Some(row_y) };
303
304 let move_cost = cheapest_move_cost(from_x, from_y, row_runs[i].x0, row_y);
305 let gap_overhead = gap_cells * 2; let emit_cost = changed_cells + gap_overhead;
307
308 let prev_cost = if i == 0 { 0 } else { scratch.dp[i - 1] };
309 let cost = prev_cost
310 .saturating_add(move_cost)
311 .saturating_add(emit_cost);
312
313 if cost < best_cost {
314 best_cost = cost;
315 best_i = i;
316 }
317 }
318
319 scratch.dp[j] = best_cost;
320 scratch.prev[j] = best_i;
321 }
322
323 let mut spans: SmallVec<[RowSpan; 8]> = SmallVec::new();
325 let mut j = run_count - 1;
326 loop {
327 let i = scratch.prev[j];
328 spans.push(RowSpan {
329 y: row_y,
330 x0: row_runs[i].x0,
331 x1: row_runs[j].x1,
332 });
333 if i == 0 {
334 break;
335 }
336 j = i - 1;
337 }
338 spans.reverse();
339
340 RowPlan {
341 spans,
342 total_cost: scratch.dp[run_count - 1],
343 }
344 }
345}
346
347#[derive(Debug, Clone, Copy, PartialEq, Eq)]
349struct CellStyle {
350 fg: PackedRgba,
351 bg: PackedRgba,
352 attrs: StyleFlags,
353}
354
355#[derive(Debug, Clone, Copy, PartialEq, Eq)]
356enum PreparedContent {
357 Empty,
358 Char(char),
359 Grapheme(GraphemeId),
360}
361
362impl PreparedContent {
363 #[inline]
364 fn from_cell(cell: &Cell) -> (Self, usize) {
365 let content = cell.content;
366 if let Some(grapheme_id) = content.grapheme_id() {
367 (Self::Grapheme(grapheme_id), content.width())
368 } else if let Some(ch) = content.as_char() {
369 let width = if ch.is_ascii() {
370 match ch {
371 '\t' | '\n' | '\r' => 1,
372 ' '..='~' => 1,
373 _ => 0,
374 }
375 } else {
376 char_width(ch)
377 };
378 (Self::Char(ch), width)
379 } else {
380 (Self::Empty, 0)
381 }
382 }
383}
384
385impl Default for CellStyle {
386 fn default() -> Self {
387 Self {
388 fg: PackedRgba::TRANSPARENT,
389 bg: PackedRgba::TRANSPARENT,
390 attrs: StyleFlags::empty(),
391 }
392 }
393}
394impl CellStyle {
395 fn from_cell(cell: &Cell) -> Self {
396 Self {
397 fg: cell.fg,
398 bg: cell.bg,
399 attrs: cell.attrs.flags(),
400 }
401 }
402}
403
404#[derive(Debug, Clone, Copy, PartialEq, Eq)]
405enum ResolvedColor {
406 Suppressed,
407 Default,
408 Ansi16(u8),
409 Ansi256(u8),
410 TrueColor(u8, u8, u8),
411}
412
413pub struct Presenter<W: Write> {
418 writer: CountingWriter<BufWriter<W>>,
420 current_style: Option<CellStyle>,
422 current_link: Option<u32>,
424 cursor_x: Option<u16>,
426 cursor_y: Option<u16>,
428 viewport_offset_y: u16,
430 capabilities: TerminalCapabilities,
432 hyperlinks_enabled: bool,
434 plan_scratch: cost_model::RowPlanScratch,
437 runs_buf: Vec<ChangeRun>,
439 presentation_width: u16,
444}
445
446impl<W: Write> Presenter<W> {
447 pub fn new(writer: W, capabilities: TerminalCapabilities) -> Self {
449 Self {
450 writer: CountingWriter::new(BufWriter::with_capacity(BUFFER_CAPACITY, writer)),
451 current_style: None,
452 current_link: None,
453 cursor_x: None,
454 cursor_y: None,
455 viewport_offset_y: 0,
456 hyperlinks_enabled: capabilities.use_hyperlinks(),
457 capabilities,
458 plan_scratch: cost_model::RowPlanScratch::default(),
459 runs_buf: Vec::new(),
460 presentation_width: 0,
461 }
462 }
463
464 pub fn writer_mut(&mut self) -> &mut W {
470 self.writer.inner_mut().get_mut()
471 }
472
473 pub fn counting_writer_mut(&mut self) -> &mut CountingWriter<BufWriter<W>> {
478 &mut self.writer
479 }
480
481 pub fn set_viewport_offset_y(&mut self, offset: u16) {
486 self.viewport_offset_y = offset;
487 }
488
489 #[inline]
491 pub fn capabilities(&self) -> &TerminalCapabilities {
492 &self.capabilities
493 }
494
495 pub fn present(&mut self, buffer: &Buffer, diff: &BufferDiff) -> io::Result<PresentStats> {
504 self.present_with_pool(buffer, diff, None, None)
505 }
506
507 pub fn present_with_pool(
509 &mut self,
510 buffer: &Buffer,
511 diff: &BufferDiff,
512 pool: Option<&GraphemePool>,
513 links: Option<&LinkRegistry>,
514 ) -> io::Result<PresentStats> {
515 self.presentation_width = buffer.width();
516 let bracket_supported = self.capabilities.use_sync_output();
517
518 #[cfg(feature = "tracing")]
519 let _span = tracing::info_span!(
520 "present",
521 width = buffer.width(),
522 height = buffer.height(),
523 changes = diff.len()
524 );
525 #[cfg(feature = "tracing")]
526 let _guard = _span.enter();
527
528 #[cfg(feature = "tracing")]
529 let fallback_used = !bracket_supported;
530 #[cfg(feature = "tracing")]
531 let _sync_span = tracing::info_span!(
532 "render.sync_bracket",
533 bracket_supported,
534 fallback_used,
535 frame_bytes = tracing::field::Empty,
536 );
537 #[cfg(feature = "tracing")]
538 let _sync_guard = _sync_span.enter();
539
540 diff.runs_into(&mut self.runs_buf);
542 let run_count = self.runs_buf.len();
543 let cells_changed = diff.len();
544
545 self.writer.reset_counter();
547 let collector = StatsCollector::start(cells_changed, run_count);
548
549 if bracket_supported {
553 if let Err(err) = ansi::sync_begin(&mut self.writer) {
554 let _ = ansi::sync_end(&mut self.writer);
557 let _ = self.writer.flush();
558 return Err(err);
559 }
560 } else {
561 #[cfg(feature = "tracing")]
562 tracing::warn!("sync brackets unsupported; falling back to cursor-hide strategy");
563 ansi::cursor_hide(&mut self.writer)?;
564 }
565
566 let emit_result = self.emit_diff_runs(buffer, pool, links);
568
569 let frame_end_result = self.finish_frame();
571
572 let bracket_end_result = if bracket_supported {
573 ansi::sync_end(&mut self.writer)
574 } else {
575 ansi::cursor_show(&mut self.writer)
576 };
577
578 let flush_result = self.writer.flush();
579
580 let cleanup_error = frame_end_result
584 .err()
585 .or_else(|| bracket_end_result.err())
586 .or_else(|| flush_result.err());
587 if let Some(err) = cleanup_error {
588 return Err(err);
589 }
590 emit_result?;
591
592 let stats = collector.finish(self.writer.bytes_written());
593
594 #[cfg(feature = "tracing")]
595 {
596 _sync_span.record("frame_bytes", stats.bytes_emitted);
597 stats.log();
598 tracing::trace!("frame presented");
599 }
600
601 Ok(stats)
602 }
603
604 pub fn emit_diff_runs(
610 &mut self,
611 buffer: &Buffer,
612 pool: Option<&GraphemePool>,
613 links: Option<&LinkRegistry>,
614 ) -> io::Result<()> {
615 self.presentation_width = buffer.width();
622
623 #[cfg(feature = "tracing")]
624 let _span = tracing::debug_span!("emit_diff");
625 #[cfg(feature = "tracing")]
626 let _guard = _span.enter();
627
628 #[cfg(feature = "tracing")]
629 tracing::trace!(run_count = self.runs_buf.len(), "emitting runs (reuse)");
630
631 let mut i = 0;
633 while i < self.runs_buf.len() {
634 let row_y = self.runs_buf[i].y;
635
636 let row_start = i;
638 while i < self.runs_buf.len() && self.runs_buf[i].y == row_y {
639 i += 1;
640 }
641 let row_runs = &self.runs_buf[row_start..i];
642
643 if row_runs.len() == 1 {
644 let run = row_runs[0];
645
646 #[cfg(feature = "tracing")]
647 tracing::trace!(
648 row = row_y,
649 spans = 1,
650 cost =
651 cost_model::cheapest_move_cost(self.cursor_x, self.cursor_y, run.x0, row_y)
652 .saturating_add(run.len()),
653 "row plan single-run fast path"
654 );
655
656 let row = buffer.row_cells(row_y);
657 self.emit_row_span(row, run.y, run.x0, run.x1, pool, links)?;
658 continue;
659 }
660
661 let plan = cost_model::plan_row_reuse(
662 row_runs,
663 self.cursor_x,
664 self.cursor_y,
665 &mut self.plan_scratch,
666 );
667
668 #[cfg(feature = "tracing")]
669 tracing::trace!(
670 row = row_y,
671 spans = plan.spans().len(),
672 cost = plan.total_cost(),
673 "row plan"
674 );
675
676 let row = buffer.row_cells(row_y);
677 for span in plan.spans() {
678 self.emit_row_span(row, span.y, span.x0, span.x1, pool, links)?;
679 }
680 }
681 Ok(())
682 }
683
684 #[inline]
685 fn emit_row_span(
686 &mut self,
687 row: &[Cell],
688 y: u16,
689 x0: u16,
690 x1: u16,
691 pool: Option<&GraphemePool>,
692 links: Option<&LinkRegistry>,
693 ) -> io::Result<()> {
694 self.move_cursor_optimal(x0, y)?;
695 let start = x0 as usize;
697 let end = x1 as usize;
698 debug_assert!(start <= end);
699 debug_assert!(end < row.len());
700 let mut idx = start;
701 while idx <= end {
702 let cell = &row[idx];
703 self.emit_cell(idx as u16, cell, pool, links)?;
704
705 let mut advance = 1usize;
714 let width = cell.content.width();
715 let should_repair_invalid_tail =
716 cell.content.as_char().is_some() || (cell.content.is_grapheme() && width == 2);
717 if width > 1 && should_repair_invalid_tail {
718 for off in 1..width {
719 let tx = idx + off;
720 if tx >= row.len() {
721 break;
722 }
723 if row[tx].is_continuation() {
724 if tx <= end {
725 advance = advance.max(off + 1);
726 }
727 continue;
728 }
729 self.move_cursor_optimal(tx as u16, y)?;
731 self.emit_orphan_continuation_space(tx as u16, links)?;
732 if tx <= end {
733 advance = advance.max(off + 1);
734 }
735 }
736 }
737
738 idx = idx.saturating_add(advance);
739 }
740
741 Ok(())
742 }
743
744 pub fn prepare_runs(&mut self, diff: &BufferDiff) {
748 diff.runs_into(&mut self.runs_buf);
749 }
750
751 pub fn clip_runs_below(&mut self, max_rows: u16) {
758 self.runs_buf.retain(|run| run.y < max_rows);
759 }
760
761 pub fn finish_frame(&mut self) -> io::Result<()> {
766 let reset_result = ansi::sgr_reset(&mut self.writer);
767 self.current_style = None;
768
769 let hyperlink_close_result = if self.current_link.is_some() {
770 let res = ansi::hyperlink_end(&mut self.writer);
771 if res.is_ok() {
772 self.current_link = None;
773 }
774 Some(res)
775 } else {
776 None
777 };
778
779 if let Some(err) = reset_result
780 .err()
781 .or_else(|| hyperlink_close_result.and_then(Result::err))
782 {
783 return Err(err);
784 }
785
786 Ok(())
787 }
788
789 pub fn finish_frame_best_effort(&mut self) {
791 let _ = ansi::sgr_reset(&mut self.writer);
792 self.current_style = None;
793
794 if self.current_link.is_some() {
795 let _ = ansi::hyperlink_end(&mut self.writer);
796 self.current_link = None;
797 }
798 }
799
800 fn emit_cell(
802 &mut self,
803 x: u16,
804 cell: &Cell,
805 pool: Option<&GraphemePool>,
806 links: Option<&LinkRegistry>,
807 ) -> io::Result<()> {
808 if let Some(cx) = self.cursor_x {
815 if cx != x && !cell.is_continuation() {
816 if let Some(y) = self.cursor_y {
818 self.move_cursor_optimal(x, y)?;
819 }
820 }
821 } else {
822 if let Some(y) = self.cursor_y {
824 self.move_cursor_optimal(x, y)?;
825 }
826 }
827
828 if cell.is_continuation() {
837 match self.cursor_x {
838 Some(cx) if cx > x => return Ok(()),
840 Some(cx) => {
841 if cx < x
844 && let Some(y) = self.cursor_y
845 {
846 self.move_cursor_optimal(x, y)?;
847 }
848 return self.emit_orphan_continuation_space(x, links);
849 }
850 None => {
852 if let Some(y) = self.cursor_y {
853 self.move_cursor_optimal(x, y)?;
854 }
855 return self.emit_orphan_continuation_space(x, links);
856 }
857 }
858 }
859
860 self.emit_style_changes(cell)?;
862
863 self.emit_link_changes(cell, links)?;
865
866 let (prepared_content, raw_width) = PreparedContent::from_cell(cell);
867
868 let is_zero_width_content = raw_width == 0 && !cell.is_empty() && !cell.is_continuation();
871
872 if is_zero_width_content {
873 self.writer.write_all(b"\xEF\xBF\xBD")?;
875 } else {
876 self.emit_content(prepared_content, raw_width, pool)?;
878 }
879
880 if let Some(cx) = self.cursor_x {
882 let width = if cell.is_empty() || is_zero_width_content {
885 1
886 } else {
887 raw_width
888 };
889 self.cursor_x = Self::advance_or_invalidate(cx, width as u16, self.presentation_width);
890 }
891
892 Ok(())
893 }
894
895 fn advance_or_invalidate(cx: u16, width: u16, presentation_width: u16) -> Option<u16> {
904 let next = cx.saturating_add(width);
905 if presentation_width > 0 && next >= presentation_width {
906 None
907 } else {
908 Some(next)
909 }
910 }
911
912 fn emit_orphan_continuation_space(
917 &mut self,
918 x: u16,
919 links: Option<&LinkRegistry>,
920 ) -> io::Result<()> {
921 let blank = Cell::default();
922 self.emit_style_changes(&blank)?;
923 self.emit_link_changes(&blank, links)?;
924 self.writer.write_all(b" ")?;
925 self.cursor_x = Self::advance_or_invalidate(x, 1, self.presentation_width);
926 Ok(())
927 }
928
929 fn emit_style_changes(&mut self, cell: &Cell) -> io::Result<()> {
935 let new_style = CellStyle::from_cell(cell);
936
937 if self
939 .current_style
940 .is_some_and(|current| self.styles_equivalent(current, new_style))
941 {
942 return Ok(());
943 }
944
945 match self.current_style {
946 None => {
947 self.emit_style_full(new_style)?;
949 }
950 Some(old_style) => {
951 self.emit_style_delta(old_style, new_style)?;
952 }
953 }
954
955 self.current_style = Some(new_style);
956 Ok(())
957 }
958
959 fn emit_style_full(&mut self, style: CellStyle) -> io::Result<()> {
961 ansi::sgr_reset(&mut self.writer)?;
962 let fg = self.resolve_color(style.fg);
963 if !matches!(fg, ResolvedColor::Default | ResolvedColor::Suppressed) {
964 self.emit_foreground(fg)?;
965 }
966 let bg = self.resolve_color(style.bg);
967 if !matches!(bg, ResolvedColor::Default | ResolvedColor::Suppressed) {
968 self.emit_background(bg)?;
969 }
970 if !style.attrs.is_empty() {
971 ansi::sgr_flags(&mut self.writer, style.attrs)?;
972 }
973 Ok(())
974 }
975
976 #[inline]
977 fn resolve_color(&self, color: PackedRgba) -> ResolvedColor {
978 if self.capabilities.color_depth == ColorDepth::Mono {
979 return ResolvedColor::Suppressed;
980 }
981 if color.a() == 0 {
982 return ResolvedColor::Default;
983 }
984
985 match self.capabilities.color_depth {
986 ColorDepth::Mono => ResolvedColor::Suppressed,
987 ColorDepth::Ansi16 => {
988 ResolvedColor::Ansi16(ansi::rgb_to_ansi16(color.r(), color.g(), color.b()))
989 }
990 ColorDepth::Ansi256 => {
991 ResolvedColor::Ansi256(ansi::rgb_to_ansi256(color.r(), color.g(), color.b()))
992 }
993 ColorDepth::TrueColor => ResolvedColor::TrueColor(color.r(), color.g(), color.b()),
994 }
995 }
996
997 #[inline]
998 fn styles_equivalent(&self, left: CellStyle, right: CellStyle) -> bool {
999 left.attrs == right.attrs
1000 && self.resolve_color(left.fg) == self.resolve_color(right.fg)
1001 && self.resolve_color(left.bg) == self.resolve_color(right.bg)
1002 }
1003
1004 fn emit_foreground(&mut self, color: ResolvedColor) -> io::Result<()> {
1005 match color {
1006 ResolvedColor::Suppressed => Ok(()),
1007 ResolvedColor::Default => ansi::sgr_fg_default(&mut self.writer),
1008 ResolvedColor::Ansi16(index) => ansi::sgr_fg_16(&mut self.writer, index),
1009 ResolvedColor::Ansi256(index) => ansi::sgr_fg_256(&mut self.writer, index),
1010 ResolvedColor::TrueColor(r, g, b) => ansi::sgr_fg_rgb(&mut self.writer, r, g, b),
1011 }
1012 }
1013
1014 fn emit_background(&mut self, color: ResolvedColor) -> io::Result<()> {
1015 match color {
1016 ResolvedColor::Suppressed => Ok(()),
1017 ResolvedColor::Default => ansi::sgr_bg_default(&mut self.writer),
1018 ResolvedColor::Ansi16(index) => ansi::sgr_bg_16(&mut self.writer, index),
1019 ResolvedColor::Ansi256(index) => ansi::sgr_bg_256(&mut self.writer, index),
1020 ResolvedColor::TrueColor(r, g, b) => ansi::sgr_bg_rgb(&mut self.writer, r, g, b),
1021 }
1022 }
1023
1024 #[inline]
1025 fn dec_len_u8(value: u8) -> u32 {
1026 if value >= 100 {
1027 3
1028 } else if value >= 10 {
1029 2
1030 } else {
1031 1
1032 }
1033 }
1034
1035 #[inline]
1036 fn sgr_code_len(code: u8) -> u32 {
1037 2 + Self::dec_len_u8(code) + 1
1038 }
1039
1040 #[inline]
1041 fn sgr_flags_len(flags: StyleFlags) -> u32 {
1042 if flags.is_empty() {
1043 return 0;
1044 }
1045 let mut count = 0u32;
1046 let mut digits = 0u32;
1047 for (flag, codes) in ansi::FLAG_TABLE {
1048 if flags.contains(flag) {
1049 count += 1;
1050 digits += Self::dec_len_u8(codes.on);
1051 }
1052 }
1053 if count == 0 {
1054 return 0;
1055 }
1056 3 + digits + (count - 1)
1057 }
1058
1059 #[inline]
1060 fn sgr_flags_off_len(flags: StyleFlags) -> u32 {
1061 if flags.is_empty() {
1062 return 0;
1063 }
1064 let mut len = 0u32;
1065 for (flag, codes) in ansi::FLAG_TABLE {
1066 if flags.contains(flag) {
1067 len += Self::sgr_code_len(codes.off);
1068 }
1069 }
1070 len
1071 }
1072
1073 #[inline]
1074 fn sgr_truecolor_len(r: u8, g: u8, b: u8) -> u32 {
1075 10 + Self::dec_len_u8(r) + Self::dec_len_u8(g) + Self::dec_len_u8(b)
1076 }
1077
1078 #[inline]
1079 fn sgr_color_len(color: ResolvedColor, background: bool) -> u32 {
1080 match color {
1081 ResolvedColor::Suppressed => 0,
1082 ResolvedColor::Default => 5,
1083 ResolvedColor::Ansi16(index) if background && index >= 8 => 6,
1084 ResolvedColor::Ansi16(_) => 5,
1085 ResolvedColor::Ansi256(index) => 8 + Self::dec_len_u8(index),
1086 ResolvedColor::TrueColor(r, g, b) => Self::sgr_truecolor_len(r, g, b),
1087 }
1088 }
1089
1090 fn emit_style_delta(&mut self, old: CellStyle, new: CellStyle) -> io::Result<()> {
1095 let attrs_removed = old.attrs & !new.attrs;
1096 let attrs_added = new.attrs & !old.attrs;
1097 let old_fg = self.resolve_color(old.fg);
1098 let new_fg = self.resolve_color(new.fg);
1099 let old_bg = self.resolve_color(old.bg);
1100 let new_bg = self.resolve_color(new.bg);
1101 let fg_changed = old_fg != new_fg;
1102 let bg_changed = old_bg != new_bg;
1103
1104 if old.attrs == new.attrs {
1108 if fg_changed {
1109 self.emit_foreground(new_fg)?;
1110 }
1111 if bg_changed {
1112 self.emit_background(new_bg)?;
1113 }
1114 return Ok(());
1115 }
1116
1117 let mut collateral = StyleFlags::empty();
1118 if attrs_removed.contains(StyleFlags::BOLD) && new.attrs.contains(StyleFlags::DIM) {
1119 collateral |= StyleFlags::DIM;
1120 }
1121 if attrs_removed.contains(StyleFlags::DIM) && new.attrs.contains(StyleFlags::BOLD) {
1122 collateral |= StyleFlags::BOLD;
1123 }
1124 let collateral_reenable = collateral & !attrs_added;
1129
1130 let mut delta_len = 0u32;
1131 delta_len += Self::sgr_flags_off_len(attrs_removed);
1132 delta_len += Self::sgr_flags_len(collateral_reenable);
1133 delta_len += Self::sgr_flags_len(attrs_added);
1134 if fg_changed {
1135 delta_len += Self::sgr_color_len(new_fg, false);
1136 }
1137 if bg_changed {
1138 delta_len += Self::sgr_color_len(new_bg, true);
1139 }
1140
1141 let mut baseline_len = 4u32;
1142 if !matches!(new_fg, ResolvedColor::Default | ResolvedColor::Suppressed) {
1143 baseline_len += Self::sgr_color_len(new_fg, false);
1144 }
1145 if !matches!(new_bg, ResolvedColor::Default | ResolvedColor::Suppressed) {
1146 baseline_len += Self::sgr_color_len(new_bg, true);
1147 }
1148 baseline_len += Self::sgr_flags_len(new.attrs);
1149
1150 if delta_len > baseline_len {
1151 return self.emit_style_full(new);
1152 }
1153
1154 if !attrs_removed.is_empty() {
1156 let collateral = ansi::sgr_flags_off(&mut self.writer, attrs_removed, new.attrs)?;
1157 let reenable = collateral & !attrs_added;
1160 if !reenable.is_empty() {
1161 ansi::sgr_flags(&mut self.writer, reenable)?;
1162 }
1163 }
1164
1165 if !attrs_added.is_empty() {
1167 ansi::sgr_flags(&mut self.writer, attrs_added)?;
1168 }
1169
1170 if fg_changed {
1172 self.emit_foreground(new_fg)?;
1173 }
1174
1175 if bg_changed {
1177 self.emit_background(new_bg)?;
1178 }
1179
1180 Ok(())
1181 }
1182
1183 fn emit_link_changes(&mut self, cell: &Cell, links: Option<&LinkRegistry>) -> io::Result<()> {
1185 if !self.hyperlinks_enabled {
1188 if self.current_link.is_none() {
1189 return Ok(());
1190 }
1191 if self.current_link.is_some() {
1192 ansi::hyperlink_end(&mut self.writer)?;
1193 }
1194 self.current_link = None;
1195 return Ok(());
1196 }
1197
1198 let raw_link_id = cell.attrs.link_id();
1199 let new_link = if raw_link_id == CellAttrs::LINK_ID_NONE {
1200 None
1201 } else {
1202 Some(raw_link_id)
1203 };
1204
1205 if self.current_link == new_link {
1207 return Ok(());
1208 }
1209
1210 if self.current_link.is_some() {
1212 ansi::hyperlink_end(&mut self.writer)?;
1213 }
1214
1215 let actually_opened = if let (Some(link_id), Some(registry)) = (new_link, links)
1217 && let Some(url) = registry.get(link_id)
1218 && is_safe_hyperlink_url(url)
1219 {
1220 ansi::hyperlink_start(&mut self.writer, url)?;
1221 true
1222 } else {
1223 false
1224 };
1225
1226 self.current_link = if actually_opened { new_link } else { None };
1228 Ok(())
1229 }
1230
1231 fn emit_content(
1233 &mut self,
1234 content: PreparedContent,
1235 raw_width: usize,
1236 pool: Option<&GraphemePool>,
1237 ) -> io::Result<()> {
1238 match content {
1239 PreparedContent::Grapheme(grapheme_id) => {
1240 if let Some(pool) = pool
1241 && let Some(text) = pool.get(grapheme_id)
1242 {
1243 let safe = sanitize(text);
1244 if !safe.is_empty() && display_width(safe.as_ref()) == raw_width {
1245 return self.writer.write_all(safe.as_bytes());
1246 }
1247 }
1248 if raw_width > 0 {
1252 for _ in 0..raw_width {
1253 self.writer.write_all(b"?")?;
1254 }
1255 }
1256 Ok(())
1257 }
1258 PreparedContent::Char(ch) => {
1259 if ch.is_ascii() {
1260 let byte = if ch.is_ascii_control() {
1265 b' '
1266 } else {
1267 ch as u8
1268 };
1269 return self.writer.write_all(&[byte]);
1270 }
1271 let safe_ch = if ch.is_control() { ' ' } else { ch };
1273 let mut buf = [0u8; 4];
1274 let encoded = safe_ch.encode_utf8(&mut buf);
1275 self.writer.write_all(encoded.as_bytes())
1276 }
1277 PreparedContent::Empty => {
1278 self.writer.write_all(b" ")
1280 }
1281 }
1282 }
1283
1284 fn move_cursor_to(&mut self, x: u16, y: u16) -> io::Result<()> {
1286 if self.cursor_x == Some(x) && self.cursor_y == Some(y) {
1288 return Ok(());
1289 }
1290
1291 ansi::cup(
1293 &mut self.writer,
1294 y.saturating_add(self.viewport_offset_y),
1295 x,
1296 )?;
1297 self.cursor_x = Some(x);
1298 self.cursor_y = Some(y);
1299 Ok(())
1300 }
1301
1302 fn move_cursor_optimal(&mut self, x: u16, y: u16) -> io::Result<()> {
1307 if self.cursor_x == Some(x) && self.cursor_y == Some(y) {
1309 return Ok(());
1310 }
1311
1312 let same_row = self.cursor_y == Some(y);
1314 let actual_y = y.saturating_add(self.viewport_offset_y);
1315
1316 if same_row {
1317 if let Some(cx) = self.cursor_x {
1318 if x > cx {
1319 let dx = x - cx;
1321 let cuf = cost_model::cuf_cost(dx);
1322 let cha = cost_model::cha_cost(x);
1323 let cup = cost_model::cup_cost(actual_y, x);
1324
1325 if cuf <= cha && cuf <= cup {
1326 ansi::cuf(&mut self.writer, dx)?;
1327 } else if cha <= cup {
1328 ansi::cha(&mut self.writer, x)?;
1329 } else {
1330 ansi::cup(&mut self.writer, actual_y, x)?;
1331 }
1332 } else if x < cx {
1333 let dx = cx - x;
1335 let cub = cost_model::cub_cost(dx);
1336 let cha = cost_model::cha_cost(x);
1337 let cup = cost_model::cup_cost(actual_y, x);
1338
1339 if cha <= cub && cha <= cup {
1340 ansi::cha(&mut self.writer, x)?;
1341 } else if cub <= cup {
1342 ansi::cub(&mut self.writer, dx)?;
1343 } else {
1344 ansi::cup(&mut self.writer, actual_y, x)?;
1345 }
1346 } else {
1347 }
1349 } else {
1350 ansi::cup(&mut self.writer, actual_y, x)?;
1353 }
1354 } else {
1355 ansi::cup(&mut self.writer, actual_y, x)?;
1357 }
1358
1359 self.cursor_x = Some(x);
1360 self.cursor_y = Some(y);
1361 Ok(())
1362 }
1363
1364 pub fn clear_screen(&mut self) -> io::Result<()> {
1366 ansi::erase_display(&mut self.writer, ansi::EraseDisplayMode::All)?;
1367 ansi::cup(&mut self.writer, 0, 0)?;
1368 self.cursor_x = Some(0);
1369 self.cursor_y = if self.viewport_offset_y == 0 {
1375 Some(0)
1376 } else {
1377 None
1378 };
1379 self.writer.flush()
1380 }
1381
1382 pub fn clear_line(&mut self, y: u16) -> io::Result<()> {
1384 self.move_cursor_to(0, y)?;
1385 ansi::erase_line(&mut self.writer, EraseLineMode::All)?;
1386 self.writer.flush()
1387 }
1388
1389 pub fn hide_cursor(&mut self) -> io::Result<()> {
1391 ansi::cursor_hide(&mut self.writer)?;
1392 self.writer.flush()
1393 }
1394
1395 pub fn show_cursor(&mut self) -> io::Result<()> {
1397 ansi::cursor_show(&mut self.writer)?;
1398 self.writer.flush()
1399 }
1400
1401 pub fn position_cursor(&mut self, x: u16, y: u16) -> io::Result<()> {
1403 self.move_cursor_to(x, y)?;
1404 self.writer.flush()
1405 }
1406
1407 pub fn reset(&mut self) {
1411 self.current_style = None;
1412 self.current_link = None;
1413 self.cursor_x = None;
1414 self.cursor_y = None;
1415 }
1416
1417 pub fn flush(&mut self) -> io::Result<()> {
1419 self.writer.flush()
1420 }
1421
1422 pub fn into_inner(self) -> Result<W, io::Error> {
1426 self.writer
1427 .into_inner() .into_inner() .map_err(|e| e.into_error())
1430 }
1431}
1432
1433#[cfg(test)]
1434mod tests {
1435 use super::*;
1436 use crate::cell::{CellAttrs, CellContent};
1437 use crate::link_registry::LinkRegistry;
1438
1439 fn test_presenter() -> Presenter<Vec<u8>> {
1440 let mut caps = TerminalCapabilities::basic();
1441 caps.color_depth = ColorDepth::TrueColor;
1442 Presenter::new(Vec::new(), caps)
1443 }
1444
1445 fn test_presenter_with_sync() -> Presenter<Vec<u8>> {
1446 let mut caps = TerminalCapabilities::basic();
1447 caps.color_depth = ColorDepth::TrueColor;
1448 caps.sync_output = true;
1449 Presenter::new(Vec::new(), caps)
1450 }
1451
1452 fn test_presenter_with_hyperlinks() -> Presenter<Vec<u8>> {
1453 let mut caps = TerminalCapabilities::basic();
1454 caps.color_depth = ColorDepth::TrueColor;
1455 caps.osc8_hyperlinks = true;
1456 Presenter::new(Vec::new(), caps)
1457 }
1458
1459 fn get_output(presenter: Presenter<Vec<u8>>) -> Vec<u8> {
1460 presenter.into_inner().unwrap()
1461 }
1462
1463 fn presenter_for_color_depth(depth: ColorDepth) -> Presenter<Vec<u8>> {
1464 let mut caps = TerminalCapabilities::basic();
1465 caps.color_depth = depth;
1466 Presenter::new(Vec::new(), caps)
1467 }
1468
1469 #[test]
1470 fn style_emission_respects_each_color_depth() {
1471 let style = CellStyle {
1472 fg: PackedRgba::rgb(255, 0, 0),
1473 bg: PackedRgba::rgb(0, 0, 255),
1474 attrs: StyleFlags::empty(),
1475 };
1476
1477 let mut truecolor = presenter_for_color_depth(ColorDepth::TrueColor);
1478 truecolor.emit_style_full(style).unwrap();
1479 assert_eq!(
1480 get_output(truecolor),
1481 b"\x1b[0m\x1b[38;2;255;0;0m\x1b[48;2;0;0;255m"
1482 );
1483
1484 let mut ansi256 = presenter_for_color_depth(ColorDepth::Ansi256);
1485 ansi256.emit_style_full(style).unwrap();
1486 assert_eq!(get_output(ansi256), b"\x1b[0m\x1b[38;5;196m\x1b[48;5;21m");
1487
1488 let mut ansi16 = presenter_for_color_depth(ColorDepth::Ansi16);
1489 ansi16.emit_style_full(style).unwrap();
1490 assert_eq!(get_output(ansi16), b"\x1b[0m\x1b[91m\x1b[44m");
1491
1492 let mut mono = presenter_for_color_depth(ColorDepth::Mono);
1493 mono.emit_style_full(style).unwrap();
1494 assert_eq!(get_output(mono), b"\x1b[0m");
1495 }
1496
1497 #[test]
1498 fn downgraded_equivalent_colors_do_not_reemit_style() {
1499 let mut presenter = presenter_for_color_depth(ColorDepth::Ansi16);
1500 let first = CellStyle {
1501 fg: PackedRgba::rgb(255, 0, 0),
1502 bg: PackedRgba::TRANSPARENT,
1503 attrs: StyleFlags::empty(),
1504 };
1505 let equivalent = CellStyle {
1506 fg: PackedRgba::rgb(254, 1, 1),
1507 ..first
1508 };
1509
1510 presenter.current_style = Some(first);
1511 presenter.emit_style_delta(first, equivalent).unwrap();
1512 assert!(get_output(presenter).is_empty());
1513 }
1514
1515 fn legacy_plan_row(
1516 row_runs: &[ChangeRun],
1517 prev_x: Option<u16>,
1518 prev_y: Option<u16>,
1519 ) -> Vec<cost_model::RowSpan> {
1520 if row_runs.is_empty() {
1521 return Vec::new();
1522 }
1523
1524 if row_runs.len() == 1 {
1525 let run = row_runs[0];
1526 return vec![cost_model::RowSpan {
1527 y: run.y,
1528 x0: run.x0,
1529 x1: run.x1,
1530 }];
1531 }
1532
1533 let row_y = row_runs[0].y;
1534 let first_x = row_runs[0].x0;
1535 let last_x = row_runs[row_runs.len() - 1].x1;
1536
1537 let mut sparse_cost: usize = 0;
1539 let mut cursor_x = prev_x;
1540 let mut cursor_y = prev_y;
1541
1542 for run in row_runs {
1543 let move_cost = cost_model::cheapest_move_cost(cursor_x, cursor_y, run.x0, run.y);
1544 let cells = (run.x1 as usize).saturating_sub(run.x0 as usize) + 1;
1545 sparse_cost += move_cost + cells;
1546 cursor_x = Some(run.x1.saturating_add(1));
1547 cursor_y = Some(row_y);
1548 }
1549
1550 let merge_move = cost_model::cheapest_move_cost(prev_x, prev_y, first_x, row_y);
1552 let total_cells = (last_x as usize).saturating_sub(first_x as usize) + 1;
1553 let changed_cells: usize = row_runs
1554 .iter()
1555 .map(|r| (r.x1 as usize).saturating_sub(r.x0 as usize) + 1)
1556 .sum();
1557 let gap_cells = total_cells.saturating_sub(changed_cells);
1558 let gap_overhead = gap_cells * 2;
1559 let merged_cost = merge_move + changed_cells + gap_overhead;
1560
1561 if merged_cost < sparse_cost {
1562 vec![cost_model::RowSpan {
1563 y: row_y,
1564 x0: first_x,
1565 x1: last_x,
1566 }]
1567 } else {
1568 row_runs
1569 .iter()
1570 .map(|run| cost_model::RowSpan {
1571 y: run.y,
1572 x0: run.x0,
1573 x1: run.x1,
1574 })
1575 .collect()
1576 }
1577 }
1578
1579 fn emit_spans_for_output(buffer: &Buffer, spans: &[cost_model::RowSpan]) -> Vec<u8> {
1580 let mut presenter = test_presenter();
1581
1582 for span in spans {
1583 presenter
1584 .move_cursor_optimal(span.x0, span.y)
1585 .expect("cursor move should succeed");
1586 for x in span.x0..=span.x1 {
1587 let cell = buffer.get_unchecked(x, span.y);
1588 presenter
1589 .emit_cell(x, cell, None, None)
1590 .expect("emit_cell should succeed");
1591 }
1592 }
1593
1594 presenter
1595 .writer
1596 .write_all(b"\x1b[0m")
1597 .expect("reset should succeed");
1598
1599 presenter.into_inner().expect("presenter output")
1600 }
1601
1602 fn emit_spans_with_links_for_output(
1603 buffer: &Buffer,
1604 spans: &[cost_model::RowSpan],
1605 links: &LinkRegistry,
1606 ) -> Vec<u8> {
1607 let mut presenter = test_presenter_with_hyperlinks();
1608
1609 for span in spans {
1610 presenter
1611 .move_cursor_optimal(span.x0, span.y)
1612 .expect("cursor move should succeed");
1613 for x in span.x0..=span.x1 {
1614 let cell = buffer.get_unchecked(x, span.y);
1615 presenter
1616 .emit_cell(x, cell, None, Some(links))
1617 .expect("emit_cell should succeed");
1618 }
1619 }
1620
1621 presenter
1622 .finish_frame()
1623 .expect("frame cleanup should succeed");
1624 presenter.into_inner().expect("presenter output")
1625 }
1626
1627 #[test]
1628 fn empty_diff_produces_minimal_output() {
1629 let mut presenter = test_presenter();
1630 let buffer = Buffer::new(10, 10);
1631 let diff = BufferDiff::new();
1632
1633 presenter.present(&buffer, &diff).unwrap();
1634 let output = get_output(presenter);
1635
1636 assert!(output.starts_with(ansi::CURSOR_HIDE));
1638 assert!(output.ends_with(ansi::CURSOR_SHOW));
1639 assert!(
1641 output.windows(b"\x1b[0m".len()).any(|w| w == b"\x1b[0m"),
1642 "SGR reset should be present"
1643 );
1644 }
1645
1646 #[test]
1647 fn sync_output_wraps_frame() {
1648 let mut presenter = test_presenter_with_sync();
1649 let mut buffer = Buffer::new(3, 1);
1650 buffer.set_raw(0, 0, Cell::from_char('X'));
1651
1652 let old = Buffer::new(3, 1);
1653 let diff = BufferDiff::compute(&old, &buffer);
1654
1655 presenter.present(&buffer, &diff).unwrap();
1656 let output = get_output(presenter);
1657
1658 assert!(
1659 output.starts_with(ansi::SYNC_BEGIN),
1660 "sync output should begin with DEC 2026 begin"
1661 );
1662 assert!(
1663 output.ends_with(ansi::SYNC_END),
1664 "sync output should end with DEC 2026 end"
1665 );
1666 }
1667
1668 #[test]
1669 fn sync_output_obeys_mux_policy() {
1670 let caps = TerminalCapabilities::builder()
1671 .sync_output(true)
1672 .in_tmux(true)
1673 .build();
1674 let mut presenter = Presenter::new(Vec::new(), caps);
1675
1676 let mut buffer = Buffer::new(2, 1);
1677 buffer.set_raw(0, 0, Cell::from_char('X'));
1678 let old = Buffer::new(2, 1);
1679 let diff = BufferDiff::compute(&old, &buffer);
1680
1681 presenter.present(&buffer, &diff).unwrap();
1682 let output = get_output(presenter);
1683
1684 assert!(
1685 !output
1686 .windows(ansi::SYNC_BEGIN.len())
1687 .any(|w| w == ansi::SYNC_BEGIN),
1688 "tmux policy should suppress sync begin"
1689 );
1690 assert!(
1691 !output
1692 .windows(ansi::SYNC_END.len())
1693 .any(|w| w == ansi::SYNC_END),
1694 "tmux policy should suppress sync end"
1695 );
1696 }
1697
1698 #[test]
1699 fn hyperlink_sequences_emitted_and_closed() {
1700 let mut presenter = test_presenter_with_hyperlinks();
1701 let mut buffer = Buffer::new(3, 1);
1702
1703 let mut registry = LinkRegistry::new();
1704 let link_id = registry.register("https://example.com");
1705 let linked = Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id));
1706 buffer.set_raw(0, 0, linked);
1707
1708 let old = Buffer::new(3, 1);
1709 let diff = BufferDiff::compute(&old, &buffer);
1710
1711 presenter
1712 .present_with_pool(&buffer, &diff, None, Some(®istry))
1713 .unwrap();
1714 let output = get_output(presenter);
1715
1716 let start = b"\x1b]8;;https://example.com\x07";
1717 let end = b"\x1b]8;;\x07";
1718
1719 let start_pos = output
1720 .windows(start.len())
1721 .position(|w| w == start)
1722 .expect("hyperlink start not found");
1723 let end_pos = output
1724 .windows(end.len())
1725 .position(|w| w == end)
1726 .expect("hyperlink end not found");
1727 let char_pos = output
1728 .iter()
1729 .position(|&b| b == b'L')
1730 .expect("linked character not found");
1731
1732 assert!(start_pos < char_pos, "link start should precede text");
1733 assert!(char_pos < end_pos, "link end should follow text");
1734 }
1735
1736 #[test]
1737 fn single_cell_change() {
1738 let mut presenter = test_presenter();
1739 let mut buffer = Buffer::new(10, 10);
1740 buffer.set_raw(5, 5, Cell::from_char('X'));
1741
1742 let old = Buffer::new(10, 10);
1743 let diff = BufferDiff::compute(&old, &buffer);
1744
1745 presenter.present(&buffer, &diff).unwrap();
1746 let output = get_output(presenter);
1747
1748 let output_str = String::from_utf8_lossy(&output);
1750 assert!(output_str.contains("X"));
1751 assert!(output_str.contains("\x1b[")); }
1753
1754 #[test]
1755 fn style_tracking_avoids_redundant_sgr() {
1756 let mut presenter = test_presenter();
1757 let mut buffer = Buffer::new(10, 1);
1758
1759 let fg = PackedRgba::rgb(255, 0, 0);
1761 buffer.set_raw(0, 0, Cell::from_char('A').with_fg(fg));
1762 buffer.set_raw(1, 0, Cell::from_char('B').with_fg(fg));
1763 buffer.set_raw(2, 0, Cell::from_char('C').with_fg(fg));
1764
1765 let old = Buffer::new(10, 1);
1766 let diff = BufferDiff::compute(&old, &buffer);
1767
1768 presenter.present(&buffer, &diff).unwrap();
1769 let output = get_output(presenter);
1770
1771 let output_str = String::from_utf8_lossy(&output);
1773 let sgr_count = output_str.matches("\x1b[38;2").count();
1774 assert_eq!(
1776 sgr_count, 1,
1777 "Expected 1 SGR fg sequence, got {}",
1778 sgr_count
1779 );
1780 }
1781
1782 #[test]
1783 fn reset_reapplies_style_after_clear() {
1784 let mut presenter = test_presenter();
1785 let mut buffer = Buffer::new(1, 1);
1786 let styled = Cell::from_char('A').with_fg(PackedRgba::rgb(10, 20, 30));
1787 buffer.set_raw(0, 0, styled);
1788
1789 let old = Buffer::new(1, 1);
1790 let diff = BufferDiff::compute(&old, &buffer);
1791
1792 presenter.present(&buffer, &diff).unwrap();
1793 presenter.reset();
1794 presenter.present(&buffer, &diff).unwrap();
1795
1796 let output = get_output(presenter);
1797 let output_str = String::from_utf8_lossy(&output);
1798 let sgr_count = output_str.matches("\x1b[38;2").count();
1799
1800 assert_eq!(
1801 sgr_count, 2,
1802 "Expected style to be re-applied after reset, got {sgr_count} sequences"
1803 );
1804 }
1805
1806 #[test]
1807 fn cursor_position_optimized() {
1808 let mut presenter = test_presenter();
1809 let mut buffer = Buffer::new(10, 5);
1810
1811 buffer.set_raw(3, 2, Cell::from_char('A'));
1813 buffer.set_raw(4, 2, Cell::from_char('B'));
1814 buffer.set_raw(5, 2, Cell::from_char('C'));
1815
1816 let old = Buffer::new(10, 5);
1817 let diff = BufferDiff::compute(&old, &buffer);
1818
1819 presenter.present(&buffer, &diff).unwrap();
1820 let output = get_output(presenter);
1821
1822 let output_str = String::from_utf8_lossy(&output);
1824 let _cup_count = output_str.matches("\x1b[").filter(|_| true).count();
1825
1826 assert!(
1828 output_str.contains("ABC")
1829 || (output_str.contains('A')
1830 && output_str.contains('B')
1831 && output_str.contains('C'))
1832 );
1833 }
1834
1835 #[test]
1836 fn sync_output_wrapped_when_supported() {
1837 let mut presenter = test_presenter_with_sync();
1838 let buffer = Buffer::new(10, 10);
1839 let diff = BufferDiff::new();
1840
1841 presenter.present(&buffer, &diff).unwrap();
1842 let output = get_output(presenter);
1843
1844 assert!(output.starts_with(ansi::SYNC_BEGIN));
1846 assert!(
1847 output
1848 .windows(ansi::SYNC_END.len())
1849 .any(|w| w == ansi::SYNC_END)
1850 );
1851 }
1852
1853 #[test]
1854 fn clear_screen_works() {
1855 let mut presenter = test_presenter();
1856 presenter.clear_screen().unwrap();
1857 let output = get_output(presenter);
1858
1859 assert!(output.windows(b"\x1b[2J".len()).any(|w| w == b"\x1b[2J"));
1861 }
1862
1863 #[test]
1864 fn cursor_advance_invalidates_at_last_column() {
1865 assert_eq!(
1870 Presenter::<Vec<u8>>::advance_or_invalidate(119, 1, 120),
1871 None
1872 );
1873 assert_eq!(
1874 Presenter::<Vec<u8>>::advance_or_invalidate(118, 2, 120),
1875 None
1876 );
1877 assert_eq!(
1878 Presenter::<Vec<u8>>::advance_or_invalidate(100, 2, 120),
1879 Some(102)
1880 );
1881 assert_eq!(
1883 Presenter::<Vec<u8>>::advance_or_invalidate(119, 1, 0),
1884 Some(120)
1885 );
1886 }
1887
1888 #[test]
1889 fn move_after_end_of_row_emission_is_absolute() {
1890 let mut presenter = test_presenter();
1891 presenter.presentation_width = 120;
1892 presenter.cursor_x = None;
1894 presenter.cursor_y = Some(20);
1895 presenter.move_cursor_optimal(110, 20).unwrap();
1896 let output = get_output(presenter);
1897 let s = String::from_utf8_lossy(&output);
1898 assert!(s.contains("\x1b[21;111H"), "output: {s:?}");
1901 assert!(!s.ends_with('D'), "relative CUB emitted: {s:?}");
1902 }
1903
1904 #[test]
1905 fn clear_screen_with_viewport_offset_forces_absolute_next_move() {
1906 let mut presenter = test_presenter();
1907 presenter.set_viewport_offset_y(5);
1908 presenter.clear_screen().unwrap();
1909 assert_eq!(presenter.cursor_x, Some(0));
1912 assert_eq!(presenter.cursor_y, None);
1913 presenter.move_cursor_optimal(3, 0).unwrap();
1914 let output = get_output(presenter);
1915 let s = String::from_utf8_lossy(&output);
1916 assert!(s.contains("\x1b[6;4H"), "output: {s:?}");
1919 }
1920
1921 #[test]
1922 fn cursor_visibility() {
1923 let mut presenter = test_presenter();
1924
1925 presenter.hide_cursor().unwrap();
1926 presenter.show_cursor().unwrap();
1927
1928 let output = get_output(presenter);
1929 let output_str = String::from_utf8_lossy(&output);
1930
1931 assert!(output_str.contains("\x1b[?25l")); assert!(output_str.contains("\x1b[?25h")); }
1934
1935 #[test]
1936 fn reset_clears_state() {
1937 let mut presenter = test_presenter();
1938 presenter.cursor_x = Some(50);
1939 presenter.cursor_y = Some(20);
1940 presenter.current_style = Some(CellStyle::default());
1941
1942 presenter.reset();
1943
1944 assert!(presenter.cursor_x.is_none());
1945 assert!(presenter.cursor_y.is_none());
1946 assert!(presenter.current_style.is_none());
1947 }
1948
1949 #[test]
1950 fn position_cursor() {
1951 let mut presenter = test_presenter();
1952 presenter.position_cursor(10, 5).unwrap();
1953
1954 let output = get_output(presenter);
1955 assert!(
1957 output
1958 .windows(b"\x1b[6;11H".len())
1959 .any(|w| w == b"\x1b[6;11H")
1960 );
1961 }
1962
1963 #[test]
1964 fn skip_cursor_move_when_already_at_position() {
1965 let mut presenter = test_presenter();
1966 presenter.cursor_x = Some(5);
1967 presenter.cursor_y = Some(3);
1968
1969 presenter.move_cursor_to(5, 3).unwrap();
1971
1972 let output = get_output(presenter);
1974 assert!(output.is_empty());
1975 }
1976
1977 #[test]
1978 fn continuation_cells_skipped() {
1979 let mut presenter = test_presenter();
1980 let mut buffer = Buffer::new(10, 1);
1981
1982 buffer.set_raw(0, 0, Cell::from_char('中'));
1984 buffer.set_raw(1, 0, Cell::CONTINUATION);
1986
1987 let old = Buffer::new(10, 1);
1989 let diff = BufferDiff::compute(&old, &buffer);
1990
1991 presenter.present(&buffer, &diff).unwrap();
1992 let output = get_output(presenter);
1993
1994 let output_str = String::from_utf8_lossy(&output);
1996 assert!(output_str.contains('中'));
1997 }
1998
1999 #[test]
2000 fn continuation_at_run_start_clears_orphan_tail() {
2001 let mut presenter = test_presenter();
2002 let mut old = Buffer::new(3, 1);
2003 let mut new = Buffer::new(3, 1);
2004
2005 old.set_raw(0, 0, Cell::from_char('中'));
2011 new.set_raw(0, 0, Cell::from_char('中'));
2012 old.set_raw(1, 0, Cell::from_char('X'));
2013 new.set_raw(1, 0, Cell::CONTINUATION);
2014
2015 let diff = BufferDiff::compute(&old, &new);
2016 assert_eq!(diff.changes(), &[(1u16, 0u16)]);
2017
2018 presenter.present(&new, &diff).unwrap();
2019 let output = get_output(presenter);
2020
2021 assert!(
2022 output.contains(&b' '),
2023 "orphan continuation should be cleared with a space"
2024 );
2025 }
2026
2027 #[test]
2028 fn continuation_cleanup_resets_style_and_closes_link_before_space() {
2029 let mut presenter = test_presenter_with_hyperlinks();
2030 let mut links = LinkRegistry::new();
2031 let link_id = links.register("https://example.com");
2032
2033 let styled = Cell::from_char('X')
2034 .with_fg(PackedRgba::rgb(255, 0, 0))
2035 .with_bg(PackedRgba::rgb(0, 0, 255))
2036 .with_attrs(CellAttrs::new(StyleFlags::UNDERLINE, link_id));
2037 presenter.current_style = Some(CellStyle::from_cell(&styled));
2038 presenter.current_link = Some(link_id);
2039 presenter.cursor_x = Some(0);
2040 presenter.cursor_y = Some(0);
2041
2042 presenter
2043 .emit_cell(0, &Cell::CONTINUATION, None, Some(&links))
2044 .unwrap();
2045 let output = presenter.into_inner().unwrap();
2046
2047 let reset = b"\x1b[0m";
2048 let close = b"\x1b]8;;\x07";
2049 let reset_pos = output
2050 .windows(reset.len())
2051 .position(|window| window == reset)
2052 .expect("continuation cleanup should reset SGR state");
2053 let close_pos = output
2054 .windows(close.len())
2055 .position(|window| window == close)
2056 .expect("continuation cleanup should close OSC 8");
2057 let space_pos = output
2058 .iter()
2059 .position(|&byte| byte == b' ')
2060 .expect("continuation cleanup should emit a space");
2061
2062 assert!(
2063 reset_pos < space_pos,
2064 "cleanup reset must precede the blank"
2065 );
2066 assert!(
2067 close_pos < space_pos,
2068 "cleanup link close must precede the blank"
2069 );
2070 }
2071
2072 #[test]
2073 fn wide_char_missing_continuation_causes_drift() {
2074 let mut presenter = test_presenter();
2075 let mut buffer = Buffer::new(10, 1);
2076
2077 buffer.set_raw(0, 0, Cell::from_char('中'));
2079 let old = Buffer::new(10, 1);
2082 let diff = BufferDiff::compute(&old, &buffer);
2083
2084 presenter.present(&buffer, &diff).unwrap();
2085 let output = get_output(presenter);
2086
2087 let output_str = String::from_utf8_lossy(&output);
2097
2098 assert!(output_str.contains('中'));
2100
2101 let has_correction = output_str.contains("\x1b[D")
2107 || output_str.contains("\x1b[2G")
2108 || output_str.contains("\x1b[1;2H");
2109
2110 assert!(
2111 has_correction,
2112 "Presenter should correct cursor drift when wide char tail is missing. Output: {:?}",
2113 output_str
2114 );
2115 }
2116
2117 #[test]
2118 fn hyperlink_emitted_with_registry() {
2119 let mut presenter = test_presenter_with_hyperlinks();
2120 let mut buffer = Buffer::new(10, 1);
2121 let mut links = LinkRegistry::new();
2122
2123 let link_id = links.register("https://example.com");
2124 let cell = Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id));
2125 buffer.set_raw(0, 0, cell);
2126
2127 let old = Buffer::new(10, 1);
2128 let diff = BufferDiff::compute(&old, &buffer);
2129
2130 presenter
2131 .present_with_pool(&buffer, &diff, None, Some(&links))
2132 .unwrap();
2133 let output = get_output(presenter);
2134 let output_str = String::from_utf8_lossy(&output);
2135
2136 assert!(
2138 output_str.contains("\x1b]8;;https://example.com\x07"),
2139 "Expected OSC 8 open, got: {:?}",
2140 output_str
2141 );
2142 assert!(
2144 output_str.contains("\x1b]8;;\x07"),
2145 "Expected OSC 8 close, got: {:?}",
2146 output_str
2147 );
2148 }
2149
2150 #[test]
2151 fn hyperlink_not_emitted_without_registry() {
2152 let mut presenter = test_presenter_with_hyperlinks();
2153 let mut buffer = Buffer::new(10, 1);
2154
2155 let cell = Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), 1));
2157 buffer.set_raw(0, 0, cell);
2158
2159 let old = Buffer::new(10, 1);
2160 let diff = BufferDiff::compute(&old, &buffer);
2161
2162 presenter.present(&buffer, &diff).unwrap();
2164 let output = get_output(presenter);
2165 let output_str = String::from_utf8_lossy(&output);
2166
2167 assert!(
2169 !output_str.contains("\x1b]8;"),
2170 "OSC 8 should not appear without registry, got: {:?}",
2171 output_str
2172 );
2173 }
2174
2175 #[test]
2176 fn hyperlink_not_emitted_for_unknown_id() {
2177 let mut presenter = test_presenter_with_hyperlinks();
2178 let mut buffer = Buffer::new(10, 1);
2179 let links = LinkRegistry::new();
2180
2181 let cell = Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), 42));
2182 buffer.set_raw(0, 0, cell);
2183
2184 let old = Buffer::new(10, 1);
2185 let diff = BufferDiff::compute(&old, &buffer);
2186
2187 presenter
2188 .present_with_pool(&buffer, &diff, None, Some(&links))
2189 .unwrap();
2190 let output = get_output(presenter);
2191 let output_str = String::from_utf8_lossy(&output);
2192
2193 assert!(
2194 !output_str.contains("\x1b]8;"),
2195 "OSC 8 should not appear for unknown link IDs, got: {:?}",
2196 output_str
2197 );
2198 assert!(output_str.contains('L'));
2199 }
2200
2201 #[test]
2202 fn hyperlink_closed_at_frame_end() {
2203 let mut presenter = test_presenter_with_hyperlinks();
2204 let mut buffer = Buffer::new(10, 1);
2205 let mut links = LinkRegistry::new();
2206
2207 let link_id = links.register("https://example.com");
2208 for x in 0..5 {
2210 buffer.set_raw(
2211 x,
2212 0,
2213 Cell::from_char('A').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
2214 );
2215 }
2216
2217 let old = Buffer::new(10, 1);
2218 let diff = BufferDiff::compute(&old, &buffer);
2219
2220 presenter
2221 .present_with_pool(&buffer, &diff, None, Some(&links))
2222 .unwrap();
2223 let output = get_output(presenter);
2224
2225 let close_seq = b"\x1b]8;;\x07";
2227 assert!(
2228 output.windows(close_seq.len()).any(|w| w == close_seq),
2229 "Link must be closed at frame end"
2230 );
2231 }
2232
2233 #[test]
2234 fn hyperlink_transitions_between_links() {
2235 let mut presenter = test_presenter_with_hyperlinks();
2236 let mut buffer = Buffer::new(10, 1);
2237 let mut links = LinkRegistry::new();
2238
2239 let link_a = links.register("https://a.com");
2240 let link_b = links.register("https://b.com");
2241
2242 buffer.set_raw(
2243 0,
2244 0,
2245 Cell::from_char('A').with_attrs(CellAttrs::new(StyleFlags::empty(), link_a)),
2246 );
2247 buffer.set_raw(
2248 1,
2249 0,
2250 Cell::from_char('B').with_attrs(CellAttrs::new(StyleFlags::empty(), link_b)),
2251 );
2252 buffer.set_raw(2, 0, Cell::from_char('C')); let old = Buffer::new(10, 1);
2255 let diff = BufferDiff::compute(&old, &buffer);
2256
2257 presenter
2258 .present_with_pool(&buffer, &diff, None, Some(&links))
2259 .unwrap();
2260 let output = get_output(presenter);
2261 let output_str = String::from_utf8_lossy(&output);
2262
2263 assert!(output_str.contains("https://a.com"));
2265 assert!(output_str.contains("https://b.com"));
2266
2267 let close_count = output_str.matches("\x1b]8;;\x07").count();
2269 assert!(
2270 close_count >= 2,
2271 "Expected at least 2 link close sequences (transition + frame end), got {}",
2272 close_count
2273 );
2274 }
2275
2276 #[test]
2277 fn hyperlink_obeys_mux_policy_even_when_capability_flag_set() {
2278 let caps = TerminalCapabilities::builder()
2279 .osc8_hyperlinks(true)
2280 .in_tmux(true)
2281 .build();
2282 let mut presenter = Presenter::new(Vec::new(), caps);
2283 let mut buffer = Buffer::new(3, 1);
2284 let mut links = LinkRegistry::new();
2285 let link_id = links.register("https://example.com");
2286 buffer.set_raw(
2287 0,
2288 0,
2289 Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
2290 );
2291
2292 let old = Buffer::new(3, 1);
2293 let diff = BufferDiff::compute(&old, &buffer);
2294 presenter
2295 .present_with_pool(&buffer, &diff, None, Some(&links))
2296 .unwrap();
2297
2298 let output = get_output(presenter);
2299 let output_str = String::from_utf8_lossy(&output);
2300 assert!(
2301 !output_str.contains("\x1b]8;"),
2302 "tmux policy should suppress OSC 8 sequences"
2303 );
2304 assert!(output_str.contains('L'));
2305 }
2306
2307 #[test]
2308 fn hyperlink_disabled_policy_noops_when_no_link_is_open() {
2309 let mut presenter = test_presenter();
2310 presenter
2311 .emit_link_changes(&Cell::from_char('X'), None)
2312 .unwrap();
2313 assert!(presenter.into_inner().unwrap().is_empty());
2314 }
2315
2316 #[test]
2317 fn hyperlink_disabled_policy_still_closes_stale_open_link() {
2318 let mut presenter = test_presenter();
2319 presenter.current_link = Some(7);
2320 presenter
2321 .emit_link_changes(&Cell::from_char('X'), None)
2322 .unwrap();
2323 assert_eq!(presenter.into_inner().unwrap(), b"\x1b]8;;\x07");
2324 }
2325
2326 #[test]
2327 fn hyperlink_unsafe_url_not_emitted() {
2328 let mut presenter = test_presenter_with_hyperlinks();
2329 let mut buffer = Buffer::new(3, 1);
2330 let mut links = LinkRegistry::new();
2331 let link_id = links.register("https://example.com/\x1b[?2026h");
2332 buffer.set_raw(
2333 0,
2334 0,
2335 Cell::from_char('X').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
2336 );
2337
2338 let old = Buffer::new(3, 1);
2339 let diff = BufferDiff::compute(&old, &buffer);
2340 presenter
2341 .present_with_pool(&buffer, &diff, None, Some(&links))
2342 .unwrap();
2343
2344 let output = get_output(presenter);
2345 let output_str = String::from_utf8_lossy(&output);
2346 assert!(
2347 !output_str.contains("\x1b]8;;https://example.com/"),
2348 "unsafe hyperlink URL should be suppressed"
2349 );
2350 assert!(
2351 !output_str.contains("\x1b[?2026h"),
2352 "control payload must never be emitted via OSC 8"
2353 );
2354 assert!(output_str.contains('X'));
2355 }
2356
2357 #[test]
2358 fn hyperlink_overlong_url_not_emitted() {
2359 let mut presenter = test_presenter_with_hyperlinks();
2360 let mut buffer = Buffer::new(3, 1);
2361 let mut links = LinkRegistry::new();
2362 let long_url = format!(
2363 "https://example.com/{}",
2364 "a".repeat(MAX_SAFE_HYPERLINK_URL_BYTES + 1)
2365 );
2366 let link_id = links.register(&long_url);
2367 buffer.set_raw(
2368 0,
2369 0,
2370 Cell::from_char('Y').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
2371 );
2372
2373 let old = Buffer::new(3, 1);
2374 let diff = BufferDiff::compute(&old, &buffer);
2375 presenter
2376 .present_with_pool(&buffer, &diff, None, Some(&links))
2377 .unwrap();
2378
2379 let output = get_output(presenter);
2380 let output_str = String::from_utf8_lossy(&output);
2381 assert!(
2382 !output_str.contains("\x1b]8;;https://example.com/"),
2383 "overlong hyperlink URL should be suppressed"
2384 );
2385 assert!(output_str.contains('Y'));
2386 }
2387
2388 #[test]
2393 fn sync_output_not_wrapped_when_unsupported() {
2394 let mut presenter = test_presenter(); let buffer = Buffer::new(10, 10);
2397 let diff = BufferDiff::new();
2398
2399 presenter.present(&buffer, &diff).unwrap();
2400 let output = get_output(presenter);
2401
2402 assert!(
2404 !output
2405 .windows(ansi::SYNC_BEGIN.len())
2406 .any(|w| w == ansi::SYNC_BEGIN),
2407 "Sync begin should not appear when sync_output is disabled"
2408 );
2409 assert!(
2410 !output
2411 .windows(ansi::SYNC_END.len())
2412 .any(|w| w == ansi::SYNC_END),
2413 "Sync end should not appear when sync_output is disabled"
2414 );
2415
2416 assert!(
2418 output.starts_with(ansi::CURSOR_HIDE),
2419 "Fallback should start with cursor hide"
2420 );
2421 assert!(
2422 output.ends_with(ansi::CURSOR_SHOW),
2423 "Fallback should end with cursor show"
2424 );
2425 }
2426
2427 #[test]
2428 fn present_flushes_buffered_output() {
2429 let mut presenter = test_presenter();
2432 let mut buffer = Buffer::new(5, 1);
2433 buffer.set_raw(0, 0, Cell::from_char('T'));
2434 buffer.set_raw(1, 0, Cell::from_char('E'));
2435 buffer.set_raw(2, 0, Cell::from_char('S'));
2436 buffer.set_raw(3, 0, Cell::from_char('T'));
2437
2438 let old = Buffer::new(5, 1);
2439 let diff = BufferDiff::compute(&old, &buffer);
2440
2441 presenter.present(&buffer, &diff).unwrap();
2442 let output = get_output(presenter);
2443 let output_str = String::from_utf8_lossy(&output);
2444
2445 assert!(
2447 output_str.contains("TEST"),
2448 "Expected 'TEST' in flushed output"
2449 );
2450 }
2451
2452 #[test]
2453 fn present_stats_reports_cells_and_bytes() {
2454 let mut presenter = test_presenter();
2455 let mut buffer = Buffer::new(10, 1);
2456
2457 for i in 0..5 {
2459 buffer.set_raw(i, 0, Cell::from_char('X'));
2460 }
2461
2462 let old = Buffer::new(10, 1);
2463 let diff = BufferDiff::compute(&old, &buffer);
2464
2465 let stats = presenter.present(&buffer, &diff).unwrap();
2466
2467 assert_eq!(stats.cells_changed, 5, "Expected 5 cells changed");
2469 assert!(stats.bytes_emitted > 0, "Expected some bytes written");
2470 assert!(stats.run_count >= 1, "Expected at least 1 run");
2471 }
2472
2473 #[test]
2478 fn cursor_tracking_after_wide_char() {
2479 let mut presenter = test_presenter();
2480 presenter.cursor_x = Some(0);
2481 presenter.cursor_y = Some(0);
2482
2483 let mut buffer = Buffer::new(10, 1);
2484 buffer.set_raw(0, 0, Cell::from_char('中'));
2486 buffer.set_raw(1, 0, Cell::CONTINUATION);
2487 buffer.set_raw(2, 0, Cell::from_char('A'));
2489
2490 let old = Buffer::new(10, 1);
2491 let diff = BufferDiff::compute(&old, &buffer);
2492
2493 presenter.present(&buffer, &diff).unwrap();
2494
2495 let output = get_output(presenter);
2498 let output_str = String::from_utf8_lossy(&output);
2499
2500 assert!(output_str.contains('中'));
2502 assert!(output_str.contains('A'));
2503 }
2504
2505 #[test]
2506 fn cursor_position_after_multiple_runs() {
2507 let mut presenter = test_presenter();
2508 let mut buffer = Buffer::new(20, 3);
2509
2510 buffer.set_raw(0, 0, Cell::from_char('A'));
2512 buffer.set_raw(1, 0, Cell::from_char('B'));
2513 buffer.set_raw(5, 2, Cell::from_char('X'));
2514 buffer.set_raw(6, 2, Cell::from_char('Y'));
2515
2516 let old = Buffer::new(20, 3);
2517 let diff = BufferDiff::compute(&old, &buffer);
2518
2519 presenter.present(&buffer, &diff).unwrap();
2520 let output = get_output(presenter);
2521 let output_str = String::from_utf8_lossy(&output);
2522
2523 assert!(output_str.contains('A'));
2525 assert!(output_str.contains('B'));
2526 assert!(output_str.contains('X'));
2527 assert!(output_str.contains('Y'));
2528
2529 let cup_count = output_str.matches("\x1b[").count();
2531 assert!(
2532 cup_count >= 2,
2533 "Expected at least 2 escape sequences for multiple runs"
2534 );
2535 }
2536
2537 #[test]
2542 fn style_with_all_flags() {
2543 let mut presenter = test_presenter();
2544 let mut buffer = Buffer::new(5, 1);
2545
2546 let all_flags = StyleFlags::BOLD
2548 | StyleFlags::DIM
2549 | StyleFlags::ITALIC
2550 | StyleFlags::UNDERLINE
2551 | StyleFlags::BLINK
2552 | StyleFlags::REVERSE
2553 | StyleFlags::STRIKETHROUGH;
2554
2555 let cell = Cell::from_char('X').with_attrs(CellAttrs::new(all_flags, 0));
2556 buffer.set_raw(0, 0, cell);
2557
2558 let old = Buffer::new(5, 1);
2559 let diff = BufferDiff::compute(&old, &buffer);
2560
2561 presenter.present(&buffer, &diff).unwrap();
2562 let output = get_output(presenter);
2563 let output_str = String::from_utf8_lossy(&output);
2564
2565 assert!(output_str.contains('X'));
2567 assert!(output_str.contains("\x1b["), "Expected SGR sequences");
2569 }
2570
2571 #[test]
2572 fn style_transitions_between_different_colors() {
2573 let mut presenter = test_presenter();
2574 let mut buffer = Buffer::new(3, 1);
2575
2576 buffer.set_raw(
2578 0,
2579 0,
2580 Cell::from_char('R').with_fg(PackedRgba::rgb(255, 0, 0)),
2581 );
2582 buffer.set_raw(
2583 1,
2584 0,
2585 Cell::from_char('G').with_fg(PackedRgba::rgb(0, 255, 0)),
2586 );
2587 buffer.set_raw(
2588 2,
2589 0,
2590 Cell::from_char('B').with_fg(PackedRgba::rgb(0, 0, 255)),
2591 );
2592
2593 let old = Buffer::new(3, 1);
2594 let diff = BufferDiff::compute(&old, &buffer);
2595
2596 presenter.present(&buffer, &diff).unwrap();
2597 let output = get_output(presenter);
2598 let output_str = String::from_utf8_lossy(&output);
2599
2600 assert!(output_str.contains("38;2;255;0;0"), "Expected red fg");
2602 assert!(output_str.contains("38;2;0;255;0"), "Expected green fg");
2603 assert!(output_str.contains("38;2;0;0;255"), "Expected blue fg");
2604 }
2605
2606 #[test]
2611 fn link_at_buffer_boundaries() {
2612 let mut presenter = test_presenter_with_hyperlinks();
2613 let mut buffer = Buffer::new(5, 1);
2614 let mut links = LinkRegistry::new();
2615
2616 let link_id = links.register("https://boundary.test");
2617
2618 buffer.set_raw(
2620 0,
2621 0,
2622 Cell::from_char('F').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
2623 );
2624 buffer.set_raw(
2626 4,
2627 0,
2628 Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
2629 );
2630
2631 let old = Buffer::new(5, 1);
2632 let diff = BufferDiff::compute(&old, &buffer);
2633
2634 presenter
2635 .present_with_pool(&buffer, &diff, None, Some(&links))
2636 .unwrap();
2637 let output = get_output(presenter);
2638 let output_str = String::from_utf8_lossy(&output);
2639
2640 assert!(output_str.contains("https://boundary.test"));
2642 assert!(output_str.contains('F'));
2644 assert!(output_str.contains('L'));
2645 }
2646
2647 #[test]
2648 fn link_state_cleared_after_reset() {
2649 let mut presenter = test_presenter();
2650 let mut links = LinkRegistry::new();
2651 let link_id = links.register("https://example.com");
2652
2653 presenter.current_link = Some(link_id);
2655 presenter.current_style = Some(CellStyle::default());
2656 presenter.cursor_x = Some(5);
2657 presenter.cursor_y = Some(3);
2658
2659 presenter.reset();
2660
2661 assert!(
2663 presenter.current_link.is_none(),
2664 "current_link should be None after reset"
2665 );
2666 assert!(
2667 presenter.current_style.is_none(),
2668 "current_style should be None after reset"
2669 );
2670 assert!(
2671 presenter.cursor_x.is_none(),
2672 "cursor_x should be None after reset"
2673 );
2674 assert!(
2675 presenter.cursor_y.is_none(),
2676 "cursor_y should be None after reset"
2677 );
2678 }
2679
2680 #[test]
2681 fn link_transitions_linked_unlinked_linked() {
2682 let mut presenter = test_presenter_with_hyperlinks();
2683 let mut buffer = Buffer::new(5, 1);
2684 let mut links = LinkRegistry::new();
2685
2686 let link_id = links.register("https://toggle.test");
2687
2688 buffer.set_raw(
2690 0,
2691 0,
2692 Cell::from_char('A').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
2693 );
2694 buffer.set_raw(1, 0, Cell::from_char('B')); buffer.set_raw(
2696 2,
2697 0,
2698 Cell::from_char('C').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
2699 );
2700
2701 let old = Buffer::new(5, 1);
2702 let diff = BufferDiff::compute(&old, &buffer);
2703
2704 presenter
2705 .present_with_pool(&buffer, &diff, None, Some(&links))
2706 .unwrap();
2707 let output = get_output(presenter);
2708 let output_str = String::from_utf8_lossy(&output);
2709
2710 let url_count = output_str.matches("https://toggle.test").count();
2712 assert!(
2713 url_count >= 2,
2714 "Expected link to open at least twice, got {} occurrences",
2715 url_count
2716 );
2717
2718 let close_count = output_str.matches("\x1b]8;;\x07").count();
2720 assert!(
2721 close_count >= 2,
2722 "Expected at least 2 link closes, got {}",
2723 close_count
2724 );
2725 }
2726
2727 #[test]
2732 fn multiple_presents_maintain_correct_state() {
2733 let mut presenter = test_presenter();
2734 let mut buffer = Buffer::new(10, 1);
2735
2736 buffer.set_raw(0, 0, Cell::from_char('1'));
2738 let old = Buffer::new(10, 1);
2739 let diff = BufferDiff::compute(&old, &buffer);
2740 presenter.present(&buffer, &diff).unwrap();
2741
2742 let prev = buffer.clone();
2744 buffer.set_raw(1, 0, Cell::from_char('2'));
2745 let diff = BufferDiff::compute(&prev, &buffer);
2746 presenter.present(&buffer, &diff).unwrap();
2747
2748 let prev = buffer.clone();
2750 buffer.set_raw(2, 0, Cell::from_char('3'));
2751 let diff = BufferDiff::compute(&prev, &buffer);
2752 presenter.present(&buffer, &diff).unwrap();
2753
2754 let output = get_output(presenter);
2755 let output_str = String::from_utf8_lossy(&output);
2756
2757 assert!(output_str.contains('1'));
2759 assert!(output_str.contains('2'));
2760 assert!(output_str.contains('3'));
2761 }
2762
2763 #[test]
2768 fn sgr_delta_fg_only_change_no_reset() {
2769 let mut presenter = test_presenter();
2771 let mut buffer = Buffer::new(3, 1);
2772
2773 let fg1 = PackedRgba::rgb(255, 0, 0);
2774 let fg2 = PackedRgba::rgb(0, 255, 0);
2775 buffer.set_raw(0, 0, Cell::from_char('A').with_fg(fg1));
2776 buffer.set_raw(1, 0, Cell::from_char('B').with_fg(fg2));
2777
2778 let old = Buffer::new(3, 1);
2779 let diff = BufferDiff::compute(&old, &buffer);
2780
2781 presenter.present(&buffer, &diff).unwrap();
2782 let output = get_output(presenter);
2783 let output_str = String::from_utf8_lossy(&output);
2784
2785 let reset_count = output_str.matches("\x1b[0m").count();
2788 assert_eq!(
2790 reset_count, 2,
2791 "Expected 2 resets (initial + frame end), got {} in: {:?}",
2792 reset_count, output_str
2793 );
2794 }
2795
2796 #[test]
2797 fn sgr_delta_bg_only_change_no_reset() {
2798 let mut presenter = test_presenter();
2799 let mut buffer = Buffer::new(3, 1);
2800
2801 let bg1 = PackedRgba::rgb(0, 0, 255);
2802 let bg2 = PackedRgba::rgb(255, 255, 0);
2803 buffer.set_raw(0, 0, Cell::from_char('A').with_bg(bg1));
2804 buffer.set_raw(1, 0, Cell::from_char('B').with_bg(bg2));
2805
2806 let old = Buffer::new(3, 1);
2807 let diff = BufferDiff::compute(&old, &buffer);
2808
2809 presenter.present(&buffer, &diff).unwrap();
2810 let output = get_output(presenter);
2811 let output_str = String::from_utf8_lossy(&output);
2812
2813 let reset_count = output_str.matches("\x1b[0m").count();
2815 assert_eq!(
2816 reset_count, 2,
2817 "Expected 2 resets, got {} in: {:?}",
2818 reset_count, output_str
2819 );
2820 }
2821
2822 #[test]
2823 fn sgr_delta_attr_addition_no_reset() {
2824 let mut presenter = test_presenter();
2825 let mut buffer = Buffer::new(3, 1);
2826
2827 let attrs1 = CellAttrs::new(StyleFlags::BOLD, 0);
2829 let attrs2 = CellAttrs::new(StyleFlags::BOLD | StyleFlags::ITALIC, 0);
2830 buffer.set_raw(0, 0, Cell::from_char('A').with_attrs(attrs1));
2831 buffer.set_raw(1, 0, Cell::from_char('B').with_attrs(attrs2));
2832
2833 let old = Buffer::new(3, 1);
2834 let diff = BufferDiff::compute(&old, &buffer);
2835
2836 presenter.present(&buffer, &diff).unwrap();
2837 let output = get_output(presenter);
2838 let output_str = String::from_utf8_lossy(&output);
2839
2840 let reset_count = output_str.matches("\x1b[0m").count();
2842 assert_eq!(
2843 reset_count, 2,
2844 "Expected 2 resets, got {} in: {:?}",
2845 reset_count, output_str
2846 );
2847 assert!(
2849 output_str.contains("\x1b[3m"),
2850 "Expected italic-on sequence in: {:?}",
2851 output_str
2852 );
2853 }
2854
2855 #[test]
2856 fn sgr_delta_attr_removal_uses_off_code() {
2857 let mut presenter = test_presenter();
2858 let mut buffer = Buffer::new(3, 1);
2859
2860 let attrs1 = CellAttrs::new(StyleFlags::BOLD | StyleFlags::ITALIC, 0);
2862 let attrs2 = CellAttrs::new(StyleFlags::BOLD, 0);
2863 buffer.set_raw(0, 0, Cell::from_char('A').with_attrs(attrs1));
2864 buffer.set_raw(1, 0, Cell::from_char('B').with_attrs(attrs2));
2865
2866 let old = Buffer::new(3, 1);
2867 let diff = BufferDiff::compute(&old, &buffer);
2868
2869 presenter.present(&buffer, &diff).unwrap();
2870 let output = get_output(presenter);
2871 let output_str = String::from_utf8_lossy(&output);
2872
2873 assert!(
2875 output_str.contains("\x1b[23m"),
2876 "Expected italic-off sequence in: {:?}",
2877 output_str
2878 );
2879 let reset_count = output_str.matches("\x1b[0m").count();
2881 assert_eq!(
2882 reset_count, 2,
2883 "Expected 2 resets, got {} in: {:?}",
2884 reset_count, output_str
2885 );
2886 }
2887
2888 #[test]
2889 fn sgr_delta_bold_dim_collateral_re_enables() {
2890 let mut presenter = test_presenter();
2893 let mut buffer = Buffer::new(3, 1);
2894
2895 let attrs1 = CellAttrs::new(StyleFlags::BOLD | StyleFlags::DIM, 0);
2897 let attrs2 = CellAttrs::new(StyleFlags::DIM, 0);
2898 buffer.set_raw(0, 0, Cell::from_char('A').with_attrs(attrs1));
2899 buffer.set_raw(1, 0, Cell::from_char('B').with_attrs(attrs2));
2900
2901 let old = Buffer::new(3, 1);
2902 let diff = BufferDiff::compute(&old, &buffer);
2903
2904 presenter.present(&buffer, &diff).unwrap();
2905 let output = get_output(presenter);
2906 let output_str = String::from_utf8_lossy(&output);
2907
2908 assert!(
2910 output_str.contains("\x1b[22m"),
2911 "Expected bold-off (22) in: {:?}",
2912 output_str
2913 );
2914 assert!(
2915 output_str.contains("\x1b[2m"),
2916 "Expected dim re-enable (2) in: {:?}",
2917 output_str
2918 );
2919 }
2920
2921 #[test]
2922 fn sgr_delta_bold_to_dim_emits_dim_exactly_once() {
2923 let mut presenter = test_presenter();
2927 let mut buffer = Buffer::new(3, 1);
2928
2929 let attrs1 = CellAttrs::new(StyleFlags::BOLD, 0);
2930 let attrs2 = CellAttrs::new(StyleFlags::DIM, 0);
2931 buffer.set_raw(0, 0, Cell::from_char('A').with_attrs(attrs1));
2932 buffer.set_raw(1, 0, Cell::from_char('B').with_attrs(attrs2));
2933
2934 let old = Buffer::new(3, 1);
2935 let diff = BufferDiff::compute(&old, &buffer);
2936
2937 presenter.present(&buffer, &diff).unwrap();
2938 let output = get_output(presenter);
2939 let output_str = String::from_utf8_lossy(&output);
2940
2941 let dim_count = output_str.matches("\x1b[2m").count();
2942 assert_eq!(
2943 dim_count, 1,
2944 "Dim must be enabled exactly once in: {output_str:?}"
2945 );
2946 }
2947
2948 #[test]
2949 fn emit_diff_runs_arms_wrap_pending_safeguard() {
2950 let mut presenter = test_presenter();
2955 let mut buffer = Buffer::new(10, 1);
2956 for x in 0..10 {
2957 buffer.set_raw(x, 0, Cell::from_char('x'));
2958 }
2959 let old = Buffer::new(10, 1);
2960 let diff = BufferDiff::compute(&old, &buffer);
2961
2962 presenter.prepare_runs(&diff);
2963 presenter.emit_diff_runs(&buffer, None, None).unwrap();
2964
2965 assert_eq!(
2966 presenter.presentation_width, 10,
2967 "emit_diff_runs must arm the wrap-pending safeguard"
2968 );
2969 assert_eq!(
2970 presenter.cursor_x, None,
2971 "tracked column must be invalidated after emitting through the last column"
2972 );
2973 }
2974
2975 #[test]
2976 fn sgr_delta_same_style_no_output() {
2977 let mut presenter = test_presenter();
2978 let mut buffer = Buffer::new(3, 1);
2979
2980 let fg = PackedRgba::rgb(255, 0, 0);
2981 let attrs = CellAttrs::new(StyleFlags::BOLD, 0);
2982 buffer.set_raw(0, 0, Cell::from_char('A').with_fg(fg).with_attrs(attrs));
2983 buffer.set_raw(1, 0, Cell::from_char('B').with_fg(fg).with_attrs(attrs));
2984 buffer.set_raw(2, 0, Cell::from_char('C').with_fg(fg).with_attrs(attrs));
2985
2986 let old = Buffer::new(3, 1);
2987 let diff = BufferDiff::compute(&old, &buffer);
2988
2989 presenter.present(&buffer, &diff).unwrap();
2990 let output = get_output(presenter);
2991 let output_str = String::from_utf8_lossy(&output);
2992
2993 let fg_count = output_str.matches("38;2;255;0;0").count();
2995 assert_eq!(
2996 fg_count, 1,
2997 "Expected 1 fg sequence, got {} in: {:?}",
2998 fg_count, output_str
2999 );
3000 }
3001
3002 #[test]
3003 fn sgr_delta_cost_dominance_never_exceeds_baseline() {
3004 let transitions: Vec<(CellStyle, CellStyle)> = vec![
3007 (
3009 CellStyle {
3010 fg: PackedRgba::rgb(255, 0, 0),
3011 bg: PackedRgba::TRANSPARENT,
3012 attrs: StyleFlags::empty(),
3013 },
3014 CellStyle {
3015 fg: PackedRgba::rgb(0, 255, 0),
3016 bg: PackedRgba::TRANSPARENT,
3017 attrs: StyleFlags::empty(),
3018 },
3019 ),
3020 (
3022 CellStyle {
3023 fg: PackedRgba::TRANSPARENT,
3024 bg: PackedRgba::rgb(255, 0, 0),
3025 attrs: StyleFlags::empty(),
3026 },
3027 CellStyle {
3028 fg: PackedRgba::TRANSPARENT,
3029 bg: PackedRgba::rgb(0, 0, 255),
3030 attrs: StyleFlags::empty(),
3031 },
3032 ),
3033 (
3035 CellStyle {
3036 fg: PackedRgba::rgb(100, 100, 100),
3037 bg: PackedRgba::TRANSPARENT,
3038 attrs: StyleFlags::BOLD,
3039 },
3040 CellStyle {
3041 fg: PackedRgba::rgb(100, 100, 100),
3042 bg: PackedRgba::TRANSPARENT,
3043 attrs: StyleFlags::BOLD | StyleFlags::ITALIC,
3044 },
3045 ),
3046 (
3048 CellStyle {
3049 fg: PackedRgba::rgb(100, 100, 100),
3050 bg: PackedRgba::TRANSPARENT,
3051 attrs: StyleFlags::BOLD | StyleFlags::ITALIC,
3052 },
3053 CellStyle {
3054 fg: PackedRgba::rgb(100, 100, 100),
3055 bg: PackedRgba::TRANSPARENT,
3056 attrs: StyleFlags::BOLD,
3057 },
3058 ),
3059 ];
3060
3061 for (old_style, new_style) in &transitions {
3062 let delta_buf = {
3064 let mut delta_presenter = presenter_for_color_depth(ColorDepth::TrueColor);
3065 delta_presenter.current_style = Some(*old_style);
3066 delta_presenter
3067 .emit_style_delta(*old_style, *new_style)
3068 .unwrap();
3069 delta_presenter.into_inner().unwrap()
3070 };
3071
3072 let reset_buf = {
3074 let mut reset_presenter = presenter_for_color_depth(ColorDepth::TrueColor);
3075 reset_presenter.emit_style_full(*new_style).unwrap();
3076 reset_presenter.into_inner().unwrap()
3077 };
3078
3079 assert!(
3080 delta_buf.len() <= reset_buf.len(),
3081 "Delta ({} bytes) exceeded reset+apply ({} bytes) for {:?} -> {:?}.\n\
3082 Delta: {:?}\nReset: {:?}",
3083 delta_buf.len(),
3084 reset_buf.len(),
3085 old_style,
3086 new_style,
3087 String::from_utf8_lossy(&delta_buf),
3088 String::from_utf8_lossy(&reset_buf),
3089 );
3090 }
3091 }
3092
3093 #[test]
3100 fn sgr_delta_evidence_ledger() {
3101 use std::io::Write as _;
3102
3103 const SEED: u64 = 0xDEAD_BEEF_CAFE;
3105
3106 let mut rng_state = SEED;
3108 let mut next_u64 = || -> u64 {
3109 rng_state = rng_state.wrapping_mul(6364136223846793005).wrapping_add(1);
3110 rng_state
3111 };
3112
3113 let random_style = |rng: &mut dyn FnMut() -> u64| -> CellStyle {
3114 let v = rng();
3115 let fg = if v & 1 == 0 {
3116 PackedRgba::TRANSPARENT
3117 } else {
3118 let r = ((v >> 8) & 0xFF) as u8;
3119 let g = ((v >> 16) & 0xFF) as u8;
3120 let b = ((v >> 24) & 0xFF) as u8;
3121 PackedRgba::rgb(r, g, b)
3122 };
3123 let v2 = rng();
3124 let bg = if v2 & 1 == 0 {
3125 PackedRgba::TRANSPARENT
3126 } else {
3127 let r = ((v2 >> 8) & 0xFF) as u8;
3128 let g = ((v2 >> 16) & 0xFF) as u8;
3129 let b = ((v2 >> 24) & 0xFF) as u8;
3130 PackedRgba::rgb(r, g, b)
3131 };
3132 let attrs = StyleFlags::from_bits_truncate(rng() as u8);
3133 CellStyle { fg, bg, attrs }
3134 };
3135
3136 let mut ledger = Vec::new();
3137 let num_transitions = 200;
3138
3139 for i in 0..num_transitions {
3140 let old_style = random_style(&mut next_u64);
3141 let new_style = random_style(&mut next_u64);
3142
3143 let mut delta_p = presenter_for_color_depth(ColorDepth::TrueColor);
3145 delta_p.current_style = Some(old_style);
3146 delta_p.emit_style_delta(old_style, new_style).unwrap();
3147 let delta_out = delta_p.into_inner().unwrap();
3148
3149 let mut reset_p = presenter_for_color_depth(ColorDepth::TrueColor);
3151 reset_p.emit_style_full(new_style).unwrap();
3152 let reset_out = reset_p.into_inner().unwrap();
3153
3154 let delta_bytes = delta_out.len();
3155 let baseline_bytes = reset_out.len();
3156
3157 let attrs_removed = old_style.attrs & !new_style.attrs;
3159 let removed_count = attrs_removed.bits().count_ones();
3160 let fg_changed = old_style.fg != new_style.fg;
3161 let bg_changed = old_style.bg != new_style.bg;
3162 let used_fallback = removed_count >= 3 && fg_changed && bg_changed;
3163
3164 assert!(
3166 delta_bytes <= baseline_bytes,
3167 "Transition {i}: delta ({delta_bytes}B) > baseline ({baseline_bytes}B)"
3168 );
3169
3170 writeln!(
3172 &mut ledger,
3173 "{{\"seed\":{SEED},\"i\":{i},\"from_fg\":\"{:?}\",\"from_bg\":\"{:?}\",\
3174 \"from_attrs\":{},\"to_fg\":\"{:?}\",\"to_bg\":\"{:?}\",\"to_attrs\":{},\
3175 \"delta_bytes\":{delta_bytes},\"baseline_bytes\":{baseline_bytes},\
3176 \"cost_delta\":{},\"used_fallback\":{used_fallback}}}",
3177 old_style.fg,
3178 old_style.bg,
3179 old_style.attrs.bits(),
3180 new_style.fg,
3181 new_style.bg,
3182 new_style.attrs.bits(),
3183 baseline_bytes as isize - delta_bytes as isize,
3184 )
3185 .unwrap();
3186 }
3187
3188 let text = String::from_utf8(ledger).unwrap();
3190 let lines: Vec<&str> = text.lines().collect();
3191 assert_eq!(lines.len(), num_transitions);
3192
3193 let mut total_saved: isize = 0;
3195 for line in &lines {
3196 let cd_start = line.find("\"cost_delta\":").unwrap() + 13;
3198 let cd_end = line[cd_start..].find(',').unwrap() + cd_start;
3199 let cd: isize = line[cd_start..cd_end].parse().unwrap();
3200 total_saved += cd;
3201 }
3202 assert!(
3203 total_saved >= 0,
3204 "Total byte savings should be non-negative, got {total_saved}"
3205 );
3206 }
3207
3208 #[test]
3211 fn e2e_style_stress_with_byte_metrics() {
3212 let width = 40u16;
3213 let height = 10u16;
3214
3215 let mut buffer = Buffer::new(width, height);
3217 for y in 0..height {
3218 for x in 0..width {
3219 let i = (y as usize * width as usize + x as usize) as u8;
3220 let fg = PackedRgba::rgb(i, 255 - i, i.wrapping_mul(3));
3221 let bg = if i.is_multiple_of(4) {
3222 PackedRgba::rgb(i.wrapping_mul(7), i.wrapping_mul(11), i.wrapping_mul(13))
3223 } else {
3224 PackedRgba::TRANSPARENT
3225 };
3226 let flags = StyleFlags::from_bits_truncate(i % 128);
3227 let ch = char::from_u32(('!' as u32) + (i as u32 % 90)).unwrap_or('?');
3228 let cell = Cell::from_char(ch)
3229 .with_fg(fg)
3230 .with_bg(bg)
3231 .with_attrs(CellAttrs::new(flags, 0));
3232 buffer.set_raw(x, y, cell);
3233 }
3234 }
3235
3236 let blank = Buffer::new(width, height);
3238 let diff = BufferDiff::compute(&blank, &buffer);
3239 let mut presenter = test_presenter();
3240 presenter.present(&buffer, &diff).unwrap();
3241 let frame1_bytes = presenter.into_inner().unwrap().len();
3242
3243 let mut buffer2 = Buffer::new(width, height);
3245 for y in 0..height {
3246 for x in 0..width {
3247 let i = (y as usize * width as usize + x as usize + 1) as u8;
3248 let fg = PackedRgba::rgb(i, 255 - i, i.wrapping_mul(3));
3249 let bg = if i.is_multiple_of(4) {
3250 PackedRgba::rgb(i.wrapping_mul(7), i.wrapping_mul(11), i.wrapping_mul(13))
3251 } else {
3252 PackedRgba::TRANSPARENT
3253 };
3254 let flags = StyleFlags::from_bits_truncate(i % 128);
3255 let ch = char::from_u32(('!' as u32) + (i as u32 % 90)).unwrap_or('?');
3256 let cell = Cell::from_char(ch)
3257 .with_fg(fg)
3258 .with_bg(bg)
3259 .with_attrs(CellAttrs::new(flags, 0));
3260 buffer2.set_raw(x, y, cell);
3261 }
3262 }
3263
3264 let diff2 = BufferDiff::compute(&buffer, &buffer2);
3266 let mut presenter2 = test_presenter();
3267 presenter2.present(&buffer2, &diff2).unwrap();
3268 let frame2_bytes = presenter2.into_inner().unwrap().len();
3269
3270 assert!(
3273 frame2_bytes > 0,
3274 "Second frame should produce output for style churn"
3275 );
3276 assert!(!diff2.is_empty(), "Style shift should produce changes");
3277
3278 assert!(
3283 frame2_bytes <= frame1_bytes * 2,
3284 "Incremental frame ({frame2_bytes}B) unreasonably large vs full ({frame1_bytes}B)"
3285 );
3286 }
3287
3288 #[test]
3293 fn cost_model_empty_row_single_run() {
3294 let runs = [ChangeRun::new(5, 10, 20)];
3296 let plan = cost_model::plan_row(&runs, None, None);
3297 assert_eq!(plan.spans().len(), 1);
3298 assert_eq!(plan.spans()[0].x0, 10);
3299 assert_eq!(plan.spans()[0].x1, 20);
3300 assert!(plan.total_cost() > 0);
3301 }
3302
3303 #[test]
3304 fn cost_model_full_row_merges() {
3305 let runs = [ChangeRun::new(0, 0, 2), ChangeRun::new(0, 77, 79)];
3311 let plan = cost_model::plan_row(&runs, None, None);
3312 assert_eq!(plan.spans().len(), 2);
3314 assert_eq!(plan.spans()[0].x0, 0);
3315 assert_eq!(plan.spans()[0].x1, 2);
3316 assert_eq!(plan.spans()[1].x0, 77);
3317 assert_eq!(plan.spans()[1].x1, 79);
3318 }
3319
3320 #[test]
3321 fn cost_model_adjacent_runs_merge() {
3322 let runs = [
3325 ChangeRun::new(3, 10, 10),
3326 ChangeRun::new(3, 12, 12),
3327 ChangeRun::new(3, 14, 14),
3328 ChangeRun::new(3, 16, 16),
3329 ChangeRun::new(3, 18, 18),
3330 ChangeRun::new(3, 20, 20),
3331 ChangeRun::new(3, 22, 22),
3332 ChangeRun::new(3, 24, 24),
3333 ];
3334 let plan = cost_model::plan_row(&runs, None, None);
3335 assert_eq!(plan.spans().len(), 1);
3338 assert_eq!(plan.spans()[0].x0, 10);
3339 assert_eq!(plan.spans()[0].x1, 24);
3340 }
3341
3342 #[test]
3343 fn cost_model_single_cell_stays_sparse() {
3344 let runs = [ChangeRun::new(0, 40, 40)];
3345 let plan = cost_model::plan_row(&runs, Some(0), Some(0));
3346 assert_eq!(plan.spans().len(), 1);
3347 assert_eq!(plan.spans()[0].x0, 40);
3348 assert_eq!(plan.spans()[0].x1, 40);
3349 }
3350
3351 #[test]
3352 fn cost_model_cup_vs_cha_vs_cuf() {
3353 assert!(cost_model::cuf_cost(1) <= cost_model::cha_cost(5));
3355 assert!(cost_model::cuf_cost(3) <= cost_model::cup_cost(0, 5));
3356
3357 let cha = cost_model::cha_cost(5);
3359 let cup = cost_model::cup_cost(0, 5);
3360 assert!(cha <= cup);
3361
3362 let cost = cost_model::cheapest_move_cost(Some(5), Some(0), 6, 0);
3364 assert_eq!(cost, 3); }
3366
3367 #[test]
3368 fn cost_model_digit_estimation_accuracy() {
3369 let mut buf = Vec::new();
3371 ansi::cup(&mut buf, 0, 0).unwrap();
3372 assert_eq!(buf.len(), cost_model::cup_cost(0, 0));
3373
3374 buf.clear();
3375 ansi::cup(&mut buf, 9, 9).unwrap();
3376 assert_eq!(buf.len(), cost_model::cup_cost(9, 9));
3377
3378 buf.clear();
3379 ansi::cup(&mut buf, 99, 99).unwrap();
3380 assert_eq!(buf.len(), cost_model::cup_cost(99, 99));
3381
3382 buf.clear();
3383 ansi::cha(&mut buf, 0).unwrap();
3384 assert_eq!(buf.len(), cost_model::cha_cost(0));
3385
3386 buf.clear();
3387 ansi::cuf(&mut buf, 1).unwrap();
3388 assert_eq!(buf.len(), cost_model::cuf_cost(1));
3389
3390 buf.clear();
3391 ansi::cuf(&mut buf, 10).unwrap();
3392 assert_eq!(buf.len(), cost_model::cuf_cost(10));
3393 }
3394
3395 #[test]
3396 fn cost_model_merged_row_produces_correct_output() {
3397 let width = 30u16;
3399 let mut buffer = Buffer::new(width, 1);
3400
3401 for col in [5u16, 10, 15, 20] {
3403 let ch = char::from_u32('A' as u32 + col as u32 % 26).unwrap();
3404 buffer.set_raw(col, 0, Cell::from_char(ch));
3405 }
3406
3407 let old = Buffer::new(width, 1);
3408 let diff = BufferDiff::compute(&old, &buffer);
3409
3410 let mut presenter = test_presenter();
3412 presenter.present(&buffer, &diff).unwrap();
3413 let output = presenter.into_inner().unwrap();
3414 let output_str = String::from_utf8_lossy(&output);
3415
3416 for col in [5u16, 10, 15, 20] {
3417 let ch = char::from_u32('A' as u32 + col as u32 % 26).unwrap();
3418 assert!(
3419 output_str.contains(ch),
3420 "Missing character '{ch}' at col {col} in output"
3421 );
3422 }
3423 }
3424
3425 #[test]
3426 fn cost_model_optimal_cursor_uses_cuf_on_same_row() {
3427 let mut presenter = test_presenter();
3429 presenter.cursor_x = Some(5);
3430 presenter.cursor_y = Some(0);
3431 presenter.move_cursor_optimal(6, 0).unwrap();
3432 let output = presenter.into_inner().unwrap();
3433 assert_eq!(&output, b"\x1b[C", "Should use CUF for +1 column move");
3435 }
3436
3437 #[test]
3438 fn cost_model_optimal_cursor_uses_cha_on_same_row_backward() {
3439 let mut presenter = test_presenter();
3440 presenter.cursor_x = Some(10);
3441 presenter.cursor_y = Some(3);
3442
3443 let target_x = 2;
3444 let target_y = 3;
3445 let cha_cost = cost_model::cha_cost(target_x);
3446 let cup_cost = cost_model::cup_cost(target_y, target_x);
3447 assert!(
3448 cha_cost <= cup_cost,
3449 "Expected CHA to be cheaper for backward move (cha={cha_cost}, cup={cup_cost})"
3450 );
3451
3452 presenter.move_cursor_optimal(target_x, target_y).unwrap();
3453 let output = presenter.into_inner().unwrap();
3454 let mut expected = Vec::new();
3455 ansi::cha(&mut expected, target_x).unwrap();
3456 assert_eq!(output, expected, "Should use CHA for backward move");
3457 }
3458
3459 #[test]
3460 fn cost_model_optimal_cursor_uses_cup_on_row_change() {
3461 let mut presenter = test_presenter();
3462 presenter.cursor_x = Some(4);
3463 presenter.cursor_y = Some(1);
3464
3465 presenter.move_cursor_optimal(7, 4).unwrap();
3466 let output = presenter.into_inner().unwrap();
3467 let mut expected = Vec::new();
3468 ansi::cup(&mut expected, 4, 7).unwrap();
3469 assert_eq!(output, expected, "Should use CUP when row changes");
3470 }
3471
3472 #[test]
3473 fn cost_model_chooses_full_row_when_cheaper() {
3474 let width = 40u16;
3477 let mut buffer = Buffer::new(width, 1);
3478
3479 for col in (0..20).step_by(2) {
3481 buffer.set_raw(col, 0, Cell::from_char('X'));
3482 }
3483
3484 let old = Buffer::new(width, 1);
3485 let diff = BufferDiff::compute(&old, &buffer);
3486 let runs = diff.runs();
3487
3488 let row_runs: Vec<_> = runs.iter().filter(|r| r.y == 0).copied().collect();
3490 if row_runs.len() > 1 {
3491 let plan = cost_model::plan_row(&row_runs, None, None);
3492 assert!(
3493 plan.spans().len() == 1,
3494 "Expected single merged span for many small runs, got {} spans",
3495 plan.spans().len()
3496 );
3497 assert_eq!(plan.spans()[0].x0, 0);
3498 assert_eq!(plan.spans()[0].x1, 18);
3499 }
3500 }
3501
3502 #[test]
3503 fn perf_cost_model_overhead() {
3504 use std::time::Instant;
3506
3507 let runs: Vec<ChangeRun> = (0..100)
3508 .map(|i| ChangeRun::new(0, i * 3, i * 3 + 1))
3509 .collect();
3510
3511 let (iterations, max_ms) = if cfg!(debug_assertions) {
3512 (1_000, 1_000u128)
3513 } else {
3514 (10_000, 500u128)
3515 };
3516
3517 let start = Instant::now();
3518 for _ in 0..iterations {
3519 let _ = cost_model::plan_row(&runs, None, None);
3520 }
3521 let elapsed = start.elapsed();
3522
3523 assert!(
3525 elapsed.as_millis() < max_ms,
3526 "Cost model planning too slow: {elapsed:?} for {iterations} iterations"
3527 );
3528 }
3529
3530 #[test]
3531 fn perf_legacy_vs_dp_worst_case_sparse() {
3532 use std::time::Instant;
3533
3534 let width = 200u16;
3535 let height = 1u16;
3536 let mut buffer = Buffer::new(width, height);
3537
3538 for col in (0..40).step_by(2) {
3540 buffer.set_raw(col, 0, Cell::from_char('X'));
3541 }
3542 for col in (160..200).step_by(2) {
3543 buffer.set_raw(col, 0, Cell::from_char('Y'));
3544 }
3545
3546 let blank = Buffer::new(width, height);
3547 let diff = BufferDiff::compute(&blank, &buffer);
3548 let runs = diff.runs();
3549 let row_runs: Vec<_> = runs.iter().filter(|r| r.y == 0).copied().collect();
3550
3551 let dp_plan = cost_model::plan_row(&row_runs, None, None);
3552 let legacy_spans = legacy_plan_row(&row_runs, None, None);
3553
3554 let dp_output = emit_spans_for_output(&buffer, dp_plan.spans());
3555 let legacy_output = emit_spans_for_output(&buffer, &legacy_spans);
3556
3557 assert!(
3558 dp_output.len() <= legacy_output.len(),
3559 "DP output should be <= legacy output (dp={}, legacy={})",
3560 dp_output.len(),
3561 legacy_output.len()
3562 );
3563
3564 let (iterations, max_ms) = if cfg!(debug_assertions) {
3565 (1_000, 1_000u128)
3566 } else {
3567 (10_000, 500u128)
3568 };
3569 let start = Instant::now();
3570 for _ in 0..iterations {
3571 let _ = cost_model::plan_row(&row_runs, None, None);
3572 }
3573 let dp_elapsed = start.elapsed();
3574
3575 let start = Instant::now();
3576 for _ in 0..iterations {
3577 let _ = legacy_plan_row(&row_runs, None, None);
3578 }
3579 let legacy_elapsed = start.elapsed();
3580
3581 assert!(
3582 dp_elapsed.as_millis() < max_ms,
3583 "DP planning too slow: {dp_elapsed:?} for {iterations} iterations"
3584 );
3585
3586 let _ = legacy_elapsed;
3587 }
3588
3589 fn build_style_heavy_scene(width: u16, height: u16, seed: u64) -> Buffer {
3595 let mut buffer = Buffer::new(width, height);
3596 let mut rng = seed;
3597 let mut next = || -> u64 {
3598 rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1);
3599 rng
3600 };
3601 for y in 0..height {
3602 for x in 0..width {
3603 let v = next();
3604 let ch = char::from_u32(('!' as u32) + (v as u32 % 90)).unwrap_or('?');
3605 let fg = PackedRgba::rgb((v >> 8) as u8, (v >> 16) as u8, (v >> 24) as u8);
3606 let bg = if v & 3 == 0 {
3607 PackedRgba::rgb((v >> 32) as u8, (v >> 40) as u8, (v >> 48) as u8)
3608 } else {
3609 PackedRgba::TRANSPARENT
3610 };
3611 let flags = StyleFlags::from_bits_truncate((v >> 56) as u8);
3612 let cell = Cell::from_char(ch)
3613 .with_fg(fg)
3614 .with_bg(bg)
3615 .with_attrs(CellAttrs::new(flags, 0));
3616 buffer.set_raw(x, y, cell);
3617 }
3618 }
3619 buffer
3620 }
3621
3622 fn build_sparse_update(base: &Buffer, seed: u64) -> Buffer {
3624 let mut buffer = base.clone();
3625 let width = base.width();
3626 let height = base.height();
3627 let mut rng = seed;
3628 let mut next = || -> u64 {
3629 rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1);
3630 rng
3631 };
3632 let change_count = (width as usize * height as usize) / 10;
3633 for _ in 0..change_count {
3634 let v = next();
3635 let x = (v % width as u64) as u16;
3636 let y = ((v >> 16) % height as u64) as u16;
3637 let ch = char::from_u32(('A' as u32) + (v as u32 % 26)).unwrap_or('?');
3638 buffer.set_raw(x, y, Cell::from_char(ch));
3639 }
3640 buffer
3641 }
3642
3643 #[test]
3644 fn snapshot_presenter_equivalence() {
3645 let buffer = build_style_heavy_scene(40, 10, 0xDEAD_CAFE_1234);
3648 let blank = Buffer::new(40, 10);
3649 let diff = BufferDiff::compute(&blank, &buffer);
3650
3651 let mut presenter = test_presenter();
3652 presenter.present(&buffer, &diff).unwrap();
3653 let output = presenter.into_inner().unwrap();
3654
3655 let checksum = {
3657 let mut hash: u64 = 0xcbf29ce484222325; for &byte in &output {
3659 hash ^= byte as u64;
3660 hash = hash.wrapping_mul(0x100000001b3); }
3662 hash
3663 };
3664
3665 let mut presenter2 = test_presenter();
3667 presenter2.present(&buffer, &diff).unwrap();
3668 let output2 = presenter2.into_inner().unwrap();
3669 assert_eq!(output, output2, "Presenter output must be deterministic");
3670
3671 let _ = checksum; }
3674
3675 #[test]
3676 fn perf_presenter_microbench() {
3677 use std::env;
3678 use std::io::Write as _;
3679 use std::time::Instant;
3680
3681 let width = 120u16;
3682 let height = 40u16;
3683 let seed = 0x00BE_EFCA_FE42;
3684 let scene = build_style_heavy_scene(width, height, seed);
3685 let blank = Buffer::new(width, height);
3686 let diff_full = BufferDiff::compute(&blank, &scene);
3687
3688 let scene2 = build_sparse_update(&scene, seed.wrapping_add(1));
3690 let diff_sparse = BufferDiff::compute(&scene, &scene2);
3691
3692 let mut jsonl = Vec::new();
3693 let iterations = env::var("FTUI_PRESENTER_BENCH_ITERS")
3694 .ok()
3695 .and_then(|value| value.parse::<u32>().ok())
3696 .unwrap_or(50);
3697
3698 let runs_full = diff_full.runs();
3699 let runs_sparse = diff_sparse.runs();
3700
3701 let plan_rows = |runs: &[ChangeRun]| -> (usize, usize) {
3702 let mut idx = 0;
3703 let mut total_cost = 0usize;
3704 let mut span_count = 0usize;
3705 let mut prev_x = None;
3706 let mut prev_y = None;
3707
3708 while idx < runs.len() {
3709 let y = runs[idx].y;
3710 let start = idx;
3711 while idx < runs.len() && runs[idx].y == y {
3712 idx += 1;
3713 }
3714
3715 let plan = cost_model::plan_row(&runs[start..idx], prev_x, prev_y);
3716 span_count += plan.spans().len();
3717 total_cost = total_cost.saturating_add(plan.total_cost());
3718 if let Some(last) = plan.spans().last() {
3719 prev_x = Some(last.x1);
3720 prev_y = Some(y);
3721 }
3722 }
3723
3724 (total_cost, span_count)
3725 };
3726
3727 for i in 0..iterations {
3728 let (diff_ref, buf_ref, runs_ref, label) = if i % 2 == 0 {
3729 (&diff_full, &scene, &runs_full, "full")
3730 } else {
3731 (&diff_sparse, &scene2, &runs_sparse, "sparse")
3732 };
3733
3734 let plan_start = Instant::now();
3735 let (plan_cost, plan_spans) = plan_rows(runs_ref);
3736 let plan_time_us = plan_start.elapsed().as_micros() as u64;
3737
3738 let mut presenter = test_presenter();
3739 let start = Instant::now();
3740 let stats = presenter.present(buf_ref, diff_ref).unwrap();
3741 let elapsed_us = start.elapsed().as_micros() as u64;
3742 let output = presenter.into_inner().unwrap();
3743
3744 let checksum = {
3746 let mut hash: u64 = 0xcbf29ce484222325;
3747 for &b in &output {
3748 hash ^= b as u64;
3749 hash = hash.wrapping_mul(0x100000001b3);
3750 }
3751 hash
3752 };
3753
3754 writeln!(
3755 &mut jsonl,
3756 "{{\"seed\":{seed},\"width\":{width},\"height\":{height},\
3757 \"scene\":\"{label}\",\"changes\":{},\"runs\":{},\
3758 \"plan_cost\":{plan_cost},\"plan_spans\":{plan_spans},\
3759 \"plan_time_us\":{plan_time_us},\"bytes\":{},\
3760 \"emit_time_us\":{elapsed_us},\
3761 \"checksum\":\"{checksum:016x}\"}}",
3762 stats.cells_changed, stats.run_count, stats.bytes_emitted,
3763 )
3764 .unwrap();
3765 }
3766
3767 let text = String::from_utf8(jsonl).unwrap();
3768 let lines: Vec<&str> = text.lines().collect();
3769 assert_eq!(lines.len(), iterations as usize);
3770
3771 let full_checksums: Vec<&str> = lines
3773 .iter()
3774 .filter(|l| l.contains("\"full\""))
3775 .map(|l| {
3776 let start = l.find("\"checksum\":\"").unwrap() + 12;
3777 let end = l[start..].find('"').unwrap() + start;
3778 &l[start..end]
3779 })
3780 .collect();
3781 assert!(full_checksums.len() > 1);
3782 assert!(
3783 full_checksums.windows(2).all(|w| w[0] == w[1]),
3784 "Full frame checksums should be identical across runs"
3785 );
3786
3787 let full_bytes: Vec<u64> = lines
3789 .iter()
3790 .filter(|l| l.contains("\"full\""))
3791 .map(|l| {
3792 let start = l.find("\"bytes\":").unwrap() + 8;
3793 let end = l[start..].find(',').unwrap() + start;
3794 l[start..end].parse::<u64>().unwrap()
3795 })
3796 .collect();
3797 let sparse_bytes: Vec<u64> = lines
3798 .iter()
3799 .filter(|l| l.contains("\"sparse\""))
3800 .map(|l| {
3801 let start = l.find("\"bytes\":").unwrap() + 8;
3802 let end = l[start..].find(',').unwrap() + start;
3803 l[start..end].parse::<u64>().unwrap()
3804 })
3805 .collect();
3806
3807 let avg_full: u64 = full_bytes.iter().sum::<u64>() / full_bytes.len() as u64;
3808 let avg_sparse: u64 = sparse_bytes.iter().sum::<u64>() / sparse_bytes.len() as u64;
3809 assert!(
3810 avg_sparse < avg_full,
3811 "Sparse updates ({avg_sparse}B) should emit fewer bytes than full ({avg_full}B)"
3812 );
3813 }
3814
3815 #[test]
3816 fn perf_emit_style_delta_microbench() {
3817 use std::env;
3818 use std::io::Write as _;
3819 use std::time::Instant;
3820
3821 let iterations = env::var("FTUI_EMIT_STYLE_BENCH_ITERS")
3822 .ok()
3823 .and_then(|value| value.parse::<u32>().ok())
3824 .unwrap_or(200);
3825 let mode = env::var("FTUI_EMIT_STYLE_BENCH_MODE").unwrap_or_default();
3826 let emit_json = mode != "raw";
3827
3828 let mut styles = Vec::with_capacity(128);
3829 let mut rng = 0x00A5_A51E_AF42_u64;
3830 let mut next = || -> u64 {
3831 rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1);
3832 rng
3833 };
3834
3835 for _ in 0..128 {
3836 let v = next();
3837 let fg = PackedRgba::rgb(
3838 (v & 0xFF) as u8,
3839 ((v >> 8) & 0xFF) as u8,
3840 ((v >> 16) & 0xFF) as u8,
3841 );
3842 let bg = PackedRgba::rgb(
3843 ((v >> 24) & 0xFF) as u8,
3844 ((v >> 32) & 0xFF) as u8,
3845 ((v >> 40) & 0xFF) as u8,
3846 );
3847 let flags = StyleFlags::from_bits_truncate((v >> 48) as u8);
3848 let cell = Cell::from_char('A')
3849 .with_fg(fg)
3850 .with_bg(bg)
3851 .with_attrs(CellAttrs::new(flags, 0));
3852 styles.push(CellStyle::from_cell(&cell));
3853 }
3854
3855 let mut presenter = test_presenter();
3856 let mut jsonl = Vec::new();
3857 let mut sink = 0u64;
3858
3859 for i in 0..iterations {
3860 let old = styles[i as usize % styles.len()];
3861 let new = styles[(i as usize + 1) % styles.len()];
3862
3863 presenter.writer.reset_counter();
3864 presenter.writer.inner_mut().get_mut().clear();
3865
3866 let start = Instant::now();
3867 presenter.emit_style_delta(old, new).unwrap();
3868 let elapsed_us = start.elapsed().as_micros() as u64;
3869 let bytes = presenter.writer.bytes_written();
3870
3871 if emit_json {
3872 writeln!(
3873 &mut jsonl,
3874 "{{\"iter\":{i},\"emit_time_us\":{elapsed_us},\"bytes\":{bytes}}}"
3875 )
3876 .unwrap();
3877 } else {
3878 sink = sink.wrapping_add(elapsed_us ^ bytes);
3879 }
3880 }
3881
3882 if emit_json {
3883 let text = String::from_utf8(jsonl).unwrap();
3884 let lines: Vec<&str> = text.lines().collect();
3885 assert_eq!(lines.len() as u32, iterations);
3886 } else {
3887 std::hint::black_box(sink);
3888 }
3889 }
3890
3891 #[test]
3892 fn e2e_presenter_stress_deterministic() {
3893 use crate::terminal_model::TerminalModel;
3896
3897 let width = 60u16;
3898 let height = 20u16;
3899 let num_frames = 10;
3900
3901 let mut prev_buffer = Buffer::new(width, height);
3902 let mut presenter = test_presenter();
3903 let mut model = TerminalModel::new(width as usize, height as usize);
3904 let mut rng = 0x5D2E_55DE_5D42_u64;
3905 let mut next = || -> u64 {
3906 rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1);
3907 rng
3908 };
3909
3910 for _frame in 0..num_frames {
3911 let mut buffer = prev_buffer.clone();
3913 let changes = (width as usize * height as usize) / 5;
3914 for _ in 0..changes {
3915 let v = next();
3916 let x = (v % width as u64) as u16;
3917 let y = ((v >> 16) % height as u64) as u16;
3918 let ch = char::from_u32(('!' as u32) + (v as u32 % 90)).unwrap_or('?');
3919 let fg = PackedRgba::rgb((v >> 8) as u8, (v >> 24) as u8, (v >> 40) as u8);
3920 let cell = Cell::from_char(ch).with_fg(fg);
3921 buffer.set_raw(x, y, cell);
3922 }
3923
3924 let diff = BufferDiff::compute(&prev_buffer, &buffer);
3925 presenter.present(&buffer, &diff).unwrap();
3926
3927 prev_buffer = buffer;
3928 }
3929
3930 let output = presenter.into_inner().unwrap();
3932 model.process(&output);
3933
3934 let mut checked = 0;
3936 for y in 0..height {
3937 for x in 0..width {
3938 let buf_cell = prev_buffer.get_unchecked(x, y);
3939 if !buf_cell.is_empty()
3940 && let Some(model_cell) = model.cell(x as usize, y as usize)
3941 {
3942 let expected = buf_cell.content.as_char().unwrap_or(' ');
3943 let mut buf = [0u8; 4];
3944 let expected_str = expected.encode_utf8(&mut buf);
3945 if model_cell.text.as_str() == expected_str {
3946 checked += 1;
3947 }
3948 }
3949 }
3950 }
3951
3952 let total_nonempty = (0..height)
3955 .flat_map(|y| (0..width).map(move |x| (x, y)))
3956 .filter(|&(x, y)| !prev_buffer.get_unchecked(x, y).is_empty())
3957 .count();
3958
3959 assert!(
3960 checked > total_nonempty * 80 / 100,
3961 "Frame {num_frames}: only {checked}/{total_nonempty} cells match final buffer"
3962 );
3963 }
3964
3965 #[test]
3966 fn style_state_persists_across_frames() {
3967 let mut presenter = test_presenter();
3968 let fg = PackedRgba::rgb(100, 150, 200);
3969
3970 let mut buffer = Buffer::new(5, 1);
3972 buffer.set_raw(0, 0, Cell::from_char('A').with_fg(fg));
3973 let old = Buffer::new(5, 1);
3974 let diff = BufferDiff::compute(&old, &buffer);
3975 presenter.present(&buffer, &diff).unwrap();
3976
3977 assert!(
3980 presenter.current_style.is_none(),
3981 "Style should be reset after frame end"
3982 );
3983 }
3984
3985 #[test]
3992 fn cost_cup_zero_zero() {
3993 assert_eq!(cost_model::cup_cost(0, 0), 6);
3995 }
3996
3997 #[test]
3998 fn cost_cup_max_max() {
3999 assert_eq!(cost_model::cup_cost(u16::MAX, u16::MAX), 14);
4002 }
4003
4004 #[test]
4005 fn cost_cha_zero() {
4006 assert_eq!(cost_model::cha_cost(0), 4);
4008 }
4009
4010 #[test]
4011 fn cost_cha_max() {
4012 assert_eq!(cost_model::cha_cost(u16::MAX), 8);
4014 }
4015
4016 #[test]
4017 fn cost_cuf_zero_is_free() {
4018 assert_eq!(cost_model::cuf_cost(0), 0);
4019 }
4020
4021 #[test]
4022 fn cost_cuf_one_is_three() {
4023 assert_eq!(cost_model::cuf_cost(1), 3);
4025 }
4026
4027 #[test]
4028 fn cost_cuf_two_has_digit() {
4029 assert_eq!(cost_model::cuf_cost(2), 4);
4031 }
4032
4033 #[test]
4034 fn cost_cuf_max() {
4035 assert_eq!(cost_model::cuf_cost(u16::MAX), 8);
4037 }
4038
4039 #[test]
4040 fn cost_cheapest_move_already_at_target() {
4041 assert_eq!(cost_model::cheapest_move_cost(Some(5), Some(3), 5, 3), 0);
4042 }
4043
4044 #[test]
4045 fn cost_cheapest_move_unknown_position() {
4046 let cost = cost_model::cheapest_move_cost(None, None, 5, 3);
4048 assert_eq!(cost, cost_model::cup_cost(3, 5));
4049 }
4050
4051 #[test]
4052 fn cost_cheapest_move_known_y_unknown_x() {
4053 let cost = cost_model::cheapest_move_cost(None, Some(3), 5, 3);
4055 assert_eq!(cost, cost_model::cup_cost(3, 5));
4056 }
4057
4058 #[test]
4059 fn cost_cheapest_move_backward_same_row() {
4060 let cost = cost_model::cheapest_move_cost(Some(50), Some(0), 5, 0);
4062 let cha = cost_model::cha_cost(5);
4063 let cub = cost_model::cub_cost(45);
4064 assert_eq!(cost, cha.min(cub));
4065 assert!(cost_model::cup_cost(0, 5) > cha);
4066 }
4067
4068 #[test]
4069 fn cost_cheapest_move_forward_same_row() {
4070 let cost = cost_model::cheapest_move_cost(Some(5), Some(0), 50, 0);
4071 let cha = cost_model::cha_cost(50);
4072 let cuf = cost_model::cuf_cost(45);
4073 assert_eq!(cost, cha.min(cuf));
4074 assert!(cost_model::cup_cost(0, 50) > cha);
4075 }
4076
4077 #[test]
4078 fn cost_cheapest_move_same_row_same_col() {
4079 assert_eq!(cost_model::cheapest_move_cost(Some(0), Some(0), 0, 0), 0);
4081 }
4082
4083 #[test]
4086 fn cost_cup_digit_boundaries() {
4087 let mut buf = Vec::new();
4088 for (row, col) in [
4089 (0u16, 0u16),
4090 (8, 8),
4091 (9, 9),
4092 (98, 98),
4093 (99, 99),
4094 (998, 998),
4095 (999, 999),
4096 (9998, 9998),
4097 (9999, 9999),
4098 (u16::MAX, u16::MAX),
4099 ] {
4100 buf.clear();
4101 ansi::cup(&mut buf, row, col).unwrap();
4102 assert_eq!(
4103 buf.len(),
4104 cost_model::cup_cost(row, col),
4105 "CUP cost mismatch at ({row}, {col})"
4106 );
4107 }
4108 }
4109
4110 #[test]
4111 fn cost_cha_digit_boundaries() {
4112 let mut buf = Vec::new();
4113 for col in [0u16, 8, 9, 98, 99, 998, 999, 9998, 9999, u16::MAX] {
4114 buf.clear();
4115 ansi::cha(&mut buf, col).unwrap();
4116 assert_eq!(
4117 buf.len(),
4118 cost_model::cha_cost(col),
4119 "CHA cost mismatch at col {col}"
4120 );
4121 }
4122 }
4123
4124 #[test]
4125 fn cost_cuf_digit_boundaries() {
4126 let mut buf = Vec::new();
4127 for n in [1u16, 2, 9, 10, 99, 100, 999, 1000, 9999, 10000, u16::MAX] {
4128 buf.clear();
4129 ansi::cuf(&mut buf, n).unwrap();
4130 assert_eq!(
4131 buf.len(),
4132 cost_model::cuf_cost(n),
4133 "CUF cost mismatch for n={n}"
4134 );
4135 }
4136 }
4137
4138 #[test]
4141 fn plan_row_reuse_matches_plan_row() {
4142 let runs = [
4143 ChangeRun::new(5, 2, 4),
4144 ChangeRun::new(5, 8, 10),
4145 ChangeRun::new(5, 20, 25),
4146 ];
4147 let plan1 = cost_model::plan_row(&runs, Some(0), Some(5));
4148 let mut scratch = cost_model::RowPlanScratch::default();
4149 let plan2 = cost_model::plan_row_reuse(&runs, Some(0), Some(5), &mut scratch);
4150 assert_eq!(plan1, plan2);
4151 }
4152
4153 #[test]
4154 fn plan_row_reuse_single_run_matches_plan_row() {
4155 let runs = [ChangeRun::new(7, 18, 24)];
4156 let plan1 = cost_model::plan_row(&runs, Some(2), Some(7));
4157 let mut scratch = cost_model::RowPlanScratch::default();
4158 let plan2 = cost_model::plan_row_reuse(&runs, Some(2), Some(7), &mut scratch);
4159 assert_eq!(plan1, plan2);
4160 assert_eq!(
4161 plan2.total_cost(),
4162 cost_model::cheapest_move_cost(Some(2), Some(7), 18, 7) + runs[0].len()
4163 );
4164 }
4165
4166 #[test]
4167 fn emit_diff_runs_single_run_matches_planned_span_output() {
4168 let mut links = LinkRegistry::new();
4169 let link_id = links.register("https://example.com/single-run");
4170 let mut buffer = Buffer::new(16, 3);
4171
4172 for (offset, ch) in ['A', 'B', 'C', 'D'].into_iter().enumerate() {
4173 let x = 4 + offset as u16;
4174 let cell = Cell::from_char(ch)
4175 .with_fg(PackedRgba::rgb(10 + offset as u8, 20, 30))
4176 .with_bg(PackedRgba::rgb(1, 2 + offset as u8, 3))
4177 .with_attrs(CellAttrs::new(StyleFlags::BOLD, link_id));
4178 buffer.set_raw(x, 1, cell);
4179 }
4180
4181 let blank = Buffer::new(16, 3);
4182 let diff = BufferDiff::compute(&blank, &buffer);
4183 let runs = diff.runs();
4184 assert_eq!(runs.len(), 1, "fixture should produce one contiguous run");
4185 let run = runs[0];
4186
4187 let mut fast_path = test_presenter_with_hyperlinks();
4188 fast_path.prepare_runs(&diff);
4189 fast_path
4190 .emit_diff_runs(&buffer, None, Some(&links))
4191 .expect("single-run fast path should emit");
4192 fast_path
4193 .finish_frame()
4194 .expect("single-run fast path cleanup should succeed");
4195 let fast_output = fast_path.into_inner().expect("fast path output");
4196
4197 let planned_output = emit_spans_with_links_for_output(
4198 &buffer,
4199 &[cost_model::RowSpan {
4200 y: run.y,
4201 x0: run.x0,
4202 x1: run.x1,
4203 }],
4204 &links,
4205 );
4206
4207 assert_eq!(
4208 fast_output, planned_output,
4209 "single-run fast path must emit the same bytes as the planned one-span path"
4210 );
4211 assert!(
4212 fast_output
4213 .windows(b"https://example.com/single-run".len())
4214 .any(|window| window == b"https://example.com/single-run"),
4215 "linked single-run cells should still emit the hyperlink payload"
4216 );
4217 }
4218
4219 #[test]
4220 fn plan_row_reuse_across_different_sizes() {
4221 let mut scratch = cost_model::RowPlanScratch::default();
4223
4224 let large_runs: Vec<ChangeRun> = (0..20)
4225 .map(|i| ChangeRun::new(0, i * 4, i * 4 + 1))
4226 .collect();
4227 let plan_large = cost_model::plan_row_reuse(&large_runs, None, None, &mut scratch);
4228 assert!(!plan_large.spans().is_empty());
4229
4230 let small_runs = [ChangeRun::new(1, 5, 8)];
4231 let plan_small = cost_model::plan_row_reuse(&small_runs, None, None, &mut scratch);
4232 assert_eq!(plan_small.spans().len(), 1);
4233 assert_eq!(plan_small.spans()[0].x0, 5);
4234 assert_eq!(plan_small.spans()[0].x1, 8);
4235 }
4236
4237 #[test]
4240 fn plan_row_gap_exactly_32_cells() {
4241 let runs = [ChangeRun::new(0, 0, 0), ChangeRun::new(0, 33, 33)];
4244 let plan = cost_model::plan_row(&runs, None, None);
4245 assert!(
4249 plan.spans().len() <= 2,
4250 "32-cell gap should still consider merge"
4251 );
4252 }
4253
4254 #[test]
4255 fn plan_row_gap_33_cells_stays_sparse() {
4256 let runs = [ChangeRun::new(0, 0, 0), ChangeRun::new(0, 34, 34)];
4259 let plan = cost_model::plan_row(&runs, None, None);
4260 assert_eq!(
4261 plan.spans().len(),
4262 2,
4263 "33-cell gap should stay sparse (gap > 32 breaks)"
4264 );
4265 }
4266
4267 #[test]
4270 fn plan_row_many_sparse_spans() {
4271 let runs = [
4273 ChangeRun::new(0, 0, 0),
4274 ChangeRun::new(0, 40, 40),
4275 ChangeRun::new(0, 80, 80),
4276 ChangeRun::new(0, 120, 120),
4277 ChangeRun::new(0, 160, 160),
4278 ChangeRun::new(0, 200, 200),
4279 ];
4280 let plan = cost_model::plan_row(&runs, None, None);
4281 assert_eq!(plan.spans().len(), 6, "Should have 6 separate sparse spans");
4283 }
4284
4285 #[test]
4288 fn cell_style_default_is_transparent_no_attrs() {
4289 let style = CellStyle::default();
4290 assert_eq!(style.fg, PackedRgba::TRANSPARENT);
4291 assert_eq!(style.bg, PackedRgba::TRANSPARENT);
4292 assert!(style.attrs.is_empty());
4293 }
4294
4295 #[test]
4296 fn cell_style_from_cell_captures_all() {
4297 let fg = PackedRgba::rgb(10, 20, 30);
4298 let bg = PackedRgba::rgb(40, 50, 60);
4299 let flags = StyleFlags::BOLD | StyleFlags::ITALIC;
4300 let cell = Cell::from_char('X')
4301 .with_fg(fg)
4302 .with_bg(bg)
4303 .with_attrs(CellAttrs::new(flags, 5));
4304 let style = CellStyle::from_cell(&cell);
4305 assert_eq!(style.fg, fg);
4306 assert_eq!(style.bg, bg);
4307 assert_eq!(style.attrs, flags);
4308 }
4309
4310 #[test]
4311 fn cell_style_eq_and_clone() {
4312 let a = CellStyle {
4313 fg: PackedRgba::rgb(1, 2, 3),
4314 bg: PackedRgba::TRANSPARENT,
4315 attrs: StyleFlags::DIM,
4316 };
4317 let b = a;
4318 assert_eq!(a, b);
4319 }
4320
4321 #[test]
4324 fn sgr_flags_len_empty() {
4325 assert_eq!(Presenter::<Vec<u8>>::sgr_flags_len(StyleFlags::empty()), 0);
4326 }
4327
4328 #[test]
4329 fn sgr_flags_len_single() {
4330 let len = Presenter::<Vec<u8>>::sgr_flags_len(StyleFlags::BOLD);
4332 assert!(len > 0);
4333 let mut buf = Vec::new();
4335 ansi::sgr_flags(&mut buf, StyleFlags::BOLD).unwrap();
4336 assert_eq!(len as usize, buf.len());
4337 }
4338
4339 #[test]
4340 fn sgr_flags_len_multiple() {
4341 let flags = StyleFlags::BOLD | StyleFlags::ITALIC | StyleFlags::UNDERLINE;
4342 let len = Presenter::<Vec<u8>>::sgr_flags_len(flags);
4343 let mut buf = Vec::new();
4344 ansi::sgr_flags(&mut buf, flags).unwrap();
4345 assert_eq!(len as usize, buf.len());
4346 }
4347
4348 #[test]
4349 fn sgr_flags_off_len_empty() {
4350 assert_eq!(
4351 Presenter::<Vec<u8>>::sgr_flags_off_len(StyleFlags::empty()),
4352 0
4353 );
4354 }
4355
4356 #[test]
4357 fn sgr_truecolor_len_matches_actual() {
4358 let estimated = Presenter::<Vec<u8>>::sgr_truecolor_len(0, 0, 0);
4359 assert!(estimated > 0);
4362 }
4363
4364 #[test]
4365 fn sgr_truecolor_len_large_values() {
4366 let large_len = Presenter::<Vec<u8>>::sgr_truecolor_len(255, 255, 255);
4367 let small_len = Presenter::<Vec<u8>>::sgr_truecolor_len(0, 0, 0);
4368 assert!(large_len > small_len);
4370 }
4371
4372 #[test]
4373 fn dec_len_u8_boundaries() {
4374 assert_eq!(Presenter::<Vec<u8>>::dec_len_u8(0), 1);
4375 assert_eq!(Presenter::<Vec<u8>>::dec_len_u8(9), 1);
4376 assert_eq!(Presenter::<Vec<u8>>::dec_len_u8(10), 2);
4377 assert_eq!(Presenter::<Vec<u8>>::dec_len_u8(99), 2);
4378 assert_eq!(Presenter::<Vec<u8>>::dec_len_u8(100), 3);
4379 assert_eq!(Presenter::<Vec<u8>>::dec_len_u8(255), 3);
4380 }
4381
4382 #[test]
4385 fn sgr_delta_all_attrs_removed_at_once() {
4386 let mut presenter = test_presenter();
4387 let all_flags = StyleFlags::BOLD
4388 | StyleFlags::DIM
4389 | StyleFlags::ITALIC
4390 | StyleFlags::UNDERLINE
4391 | StyleFlags::BLINK
4392 | StyleFlags::REVERSE
4393 | StyleFlags::STRIKETHROUGH;
4394 let old = CellStyle {
4395 fg: PackedRgba::rgb(100, 100, 100),
4396 bg: PackedRgba::TRANSPARENT,
4397 attrs: all_flags,
4398 };
4399 let new = CellStyle {
4400 fg: PackedRgba::rgb(100, 100, 100),
4401 bg: PackedRgba::TRANSPARENT,
4402 attrs: StyleFlags::empty(),
4403 };
4404
4405 presenter.current_style = Some(old);
4406 presenter.emit_style_delta(old, new).unwrap();
4407 let output = presenter.into_inner().unwrap();
4408
4409 assert!(!output.is_empty());
4412 }
4413
4414 #[test]
4415 fn sgr_delta_fg_to_transparent() {
4416 let mut presenter = test_presenter();
4417 let old = CellStyle {
4418 fg: PackedRgba::rgb(200, 100, 50),
4419 bg: PackedRgba::TRANSPARENT,
4420 attrs: StyleFlags::empty(),
4421 };
4422 let new = CellStyle {
4423 fg: PackedRgba::TRANSPARENT,
4424 bg: PackedRgba::TRANSPARENT,
4425 attrs: StyleFlags::empty(),
4426 };
4427
4428 presenter.current_style = Some(old);
4429 presenter.emit_style_delta(old, new).unwrap();
4430 let output = presenter.into_inner().unwrap();
4431 let output_str = String::from_utf8_lossy(&output);
4432
4433 assert!(!output.is_empty(), "Should emit fg removal: {output_str:?}");
4436 }
4437
4438 #[test]
4439 fn sgr_delta_bg_to_transparent() {
4440 let mut presenter = test_presenter();
4441 let old = CellStyle {
4442 fg: PackedRgba::TRANSPARENT,
4443 bg: PackedRgba::rgb(30, 60, 90),
4444 attrs: StyleFlags::empty(),
4445 };
4446 let new = CellStyle {
4447 fg: PackedRgba::TRANSPARENT,
4448 bg: PackedRgba::TRANSPARENT,
4449 attrs: StyleFlags::empty(),
4450 };
4451
4452 presenter.current_style = Some(old);
4453 presenter.emit_style_delta(old, new).unwrap();
4454 let output = presenter.into_inner().unwrap();
4455 assert!(!output.is_empty(), "Should emit bg removal");
4456 }
4457
4458 #[test]
4459 fn sgr_delta_dim_removed_bold_stays() {
4460 let mut presenter = test_presenter();
4464 let mut buffer = Buffer::new(3, 1);
4465
4466 let attrs1 = CellAttrs::new(StyleFlags::BOLD | StyleFlags::DIM, 0);
4467 let attrs2 = CellAttrs::new(StyleFlags::BOLD, 0);
4468 buffer.set_raw(0, 0, Cell::from_char('A').with_attrs(attrs1));
4469 buffer.set_raw(1, 0, Cell::from_char('B').with_attrs(attrs2));
4470
4471 let old = Buffer::new(3, 1);
4472 let diff = BufferDiff::compute(&old, &buffer);
4473
4474 presenter.present(&buffer, &diff).unwrap();
4475 let output = get_output(presenter);
4476 let output_str = String::from_utf8_lossy(&output);
4477
4478 assert!(
4480 output_str.contains("\x1b[22m"),
4481 "Expected dim-off (22) in: {output_str:?}"
4482 );
4483 assert!(
4484 output_str.contains("\x1b[1m"),
4485 "Expected bold re-enable (1) in: {output_str:?}"
4486 );
4487 }
4488
4489 #[test]
4490 fn sgr_delta_fallback_to_full_reset_when_cheaper() {
4491 let mut presenter = test_presenter();
4493 let old = CellStyle {
4494 fg: PackedRgba::rgb(10, 20, 30),
4495 bg: PackedRgba::rgb(40, 50, 60),
4496 attrs: StyleFlags::BOLD
4497 | StyleFlags::DIM
4498 | StyleFlags::ITALIC
4499 | StyleFlags::UNDERLINE
4500 | StyleFlags::STRIKETHROUGH,
4501 };
4502 let new = CellStyle {
4503 fg: PackedRgba::TRANSPARENT,
4504 bg: PackedRgba::TRANSPARENT,
4505 attrs: StyleFlags::empty(),
4506 };
4507
4508 presenter.current_style = Some(old);
4509 presenter.emit_style_delta(old, new).unwrap();
4510 let output = presenter.into_inner().unwrap();
4511 let output_str = String::from_utf8_lossy(&output);
4512
4513 assert!(
4515 output_str.contains("\x1b[0m"),
4516 "Expected full reset fallback: {output_str:?}"
4517 );
4518 }
4519
4520 #[test]
4523 fn emit_cell_control_char_replaced_with_fffd() {
4524 let mut presenter = test_presenter();
4525 presenter.cursor_x = Some(0);
4526 presenter.cursor_y = Some(0);
4527
4528 let cell = Cell::from_char('\x01');
4531 presenter.emit_cell(0, &cell, None, None).unwrap();
4532 let output = presenter.into_inner().unwrap();
4533 let output_str = String::from_utf8_lossy(&output);
4534
4535 assert!(
4537 output_str.contains('\u{FFFD}'),
4538 "Control char (width 0) should be replaced with U+FFFD, got: {output:?}"
4539 );
4540 assert!(
4541 !output.contains(&0x01),
4542 "Raw control char should not appear"
4543 );
4544 }
4545
4546 #[test]
4547 fn emit_content_empty_cell_emits_space() {
4548 let mut presenter = test_presenter();
4549 presenter.cursor_x = Some(0);
4550 presenter.cursor_y = Some(0);
4551
4552 let cell = Cell::default();
4553 assert!(cell.is_empty());
4554 presenter.emit_cell(0, &cell, None, None).unwrap();
4555 let output = presenter.into_inner().unwrap();
4556 assert!(output.contains(&b' '), "Empty cell should emit space");
4557 }
4558
4559 #[test]
4560 fn emit_content_ascii_char_emits_single_byte() {
4561 let mut presenter = test_presenter();
4562 presenter
4563 .emit_content(PreparedContent::Char('A'), 1, None)
4564 .unwrap();
4565 let output = presenter.into_inner().unwrap();
4566 assert_eq!(output, b"A");
4567 }
4568
4569 #[test]
4570 fn emit_content_ascii_control_sanitizes_to_space() {
4571 let mut presenter = test_presenter();
4572 presenter
4573 .emit_content(PreparedContent::Char('\n'), 1, None)
4574 .unwrap();
4575 let output = presenter.into_inner().unwrap();
4576 assert_eq!(output, b" ");
4577 }
4578
4579 #[test]
4580 fn prepared_content_ascii_widths_match_char_width_contract() {
4581 for ch in ['A', ' ', '\n', '\r', '\x1f', '\x7f'] {
4582 let cell = Cell::from_char(ch);
4583 let (prepared, width) = PreparedContent::from_cell(&cell);
4584 assert_eq!(prepared, PreparedContent::Char(ch));
4585 assert_eq!(width, char_width(ch), "width mismatch for {ch:?}");
4586 }
4587 }
4588
4589 #[test]
4590 fn prepared_content_tab_uses_canonicalized_space() {
4591 let cell = Cell::from_char('\t');
4592 let (prepared, width) = PreparedContent::from_cell(&cell);
4593 assert_eq!(prepared, PreparedContent::Char(' '));
4594 assert_eq!(width, 1);
4595 }
4596
4597 #[test]
4598 fn prepared_content_nul_uses_empty_cell_representation() {
4599 let cell = Cell::from_char('\0');
4600 let (prepared, width) = PreparedContent::from_cell(&cell);
4601 assert_eq!(prepared, PreparedContent::Empty);
4602 assert_eq!(width, 0);
4603 }
4604
4605 #[test]
4606 fn emit_content_grapheme_sanitizes_escape_sequences() {
4607 let mut presenter = test_presenter();
4608 presenter.cursor_x = Some(0);
4609 presenter.cursor_y = Some(0);
4610
4611 let mut pool = GraphemePool::new();
4612 let gid = pool.intern("A\x1b[31mB\x1b[0m", 2);
4613 let cell = Cell::new(CellContent::from_grapheme(gid));
4614 presenter.emit_cell(0, &cell, Some(&pool), None).unwrap();
4615
4616 let output = presenter.into_inner().unwrap();
4617 let output_str = String::from_utf8_lossy(&output);
4618 assert!(
4619 output_str.contains("AB"),
4620 "sanitized grapheme should preserve visible payload"
4621 );
4622 assert!(
4623 !output_str.contains("\x1b[31m"),
4624 "raw escape sequence must not be emitted"
4625 );
4626 }
4627
4628 #[test]
4629 fn emit_content_grapheme_width_mismatch_uses_placeholders() {
4630 let mut presenter = test_presenter();
4631 let mut pool = GraphemePool::new();
4632 let gid = pool.intern("A\x07", 2);
4633
4634 presenter
4635 .emit_content(PreparedContent::Grapheme(gid), 2, Some(&pool))
4636 .unwrap();
4637
4638 let output = presenter.into_inner().unwrap();
4639 assert_eq!(output, b"??");
4640 }
4641
4642 #[test]
4643 fn wide_grapheme_tail_repair_does_not_blank_unrelated_following_cells() {
4644 let mut presenter = test_presenter();
4645 let mut pool = GraphemePool::new();
4646 let gid = pool.intern("XYZ", 3);
4647 let mut buffer = Buffer::new(8, 1);
4648
4649 buffer.set_raw(0, 0, Cell::new(CellContent::from_grapheme(gid)));
4650 buffer.set_raw(1, 0, Cell::from_char('a'));
4651 buffer.set_raw(2, 0, Cell::from_char('b'));
4652 buffer.set_raw(3, 0, Cell::from_char('c'));
4653
4654 let old = Buffer::new(8, 1);
4655 let diff = BufferDiff::compute(&old, &buffer);
4656
4657 presenter
4658 .present_with_pool(&buffer, &diff, Some(&pool), None)
4659 .unwrap();
4660
4661 let output = presenter.into_inner().unwrap();
4662 let output_str = String::from_utf8_lossy(&output);
4663 let visible = sanitize(output_str.as_ref());
4664
4665 assert!(
4666 visible.contains("XYZabc"),
4667 "width-3 grapheme repair must not erase following cells: {:?}",
4668 visible
4669 );
4670 }
4671
4672 #[test]
4675 fn continuation_cell_cursor_x_none() {
4676 let mut presenter = test_presenter();
4677 presenter.cursor_x = None;
4679 presenter.cursor_y = Some(0);
4680
4681 let cell = Cell::CONTINUATION;
4682 presenter.emit_cell(5, &cell, None, None).unwrap();
4683 let output = presenter.into_inner().unwrap();
4684
4685 assert!(
4687 output.contains(&b' '),
4688 "Should emit a space for continuation with unknown cursor_x"
4689 );
4690 }
4691
4692 #[test]
4693 fn continuation_cell_cursor_already_past() {
4694 let mut presenter = test_presenter();
4695 presenter.cursor_x = Some(10);
4697 presenter.cursor_y = Some(0);
4698
4699 let cell = Cell::CONTINUATION;
4700 presenter.emit_cell(5, &cell, None, None).unwrap();
4701 let output = presenter.into_inner().unwrap();
4702
4703 assert!(
4705 output.is_empty(),
4706 "Should skip continuation when cursor is past it"
4707 );
4708 }
4709
4710 #[test]
4713 fn clear_line_positions_cursor_and_erases() {
4714 let mut presenter = test_presenter();
4715 presenter.clear_line(5).unwrap();
4716 let output = get_output(presenter);
4717 let output_str = String::from_utf8_lossy(&output);
4718
4719 assert!(
4721 output_str.contains("\x1b[2K"),
4722 "Should contain erase line sequence"
4723 );
4724 }
4725
4726 #[test]
4729 fn into_inner_returns_accumulated_output() {
4730 let mut presenter = test_presenter();
4731 presenter.position_cursor(0, 0).unwrap();
4732 let inner = presenter.into_inner().unwrap();
4733 assert!(!inner.is_empty(), "into_inner should return buffered data");
4734 }
4735
4736 #[test]
4739 fn move_cursor_optimal_same_row_forward_large() {
4740 let mut presenter = test_presenter();
4741 presenter.cursor_x = Some(0);
4742 presenter.cursor_y = Some(0);
4743
4744 presenter.move_cursor_optimal(100, 0).unwrap();
4746 let output = presenter.into_inner().unwrap();
4747
4748 let cuf = cost_model::cuf_cost(100);
4750 let cha = cost_model::cha_cost(100);
4751 let cup = cost_model::cup_cost(0, 100);
4752 let cheapest = cuf.min(cha).min(cup);
4753 assert_eq!(output.len(), cheapest, "Should pick cheapest cursor move");
4754 }
4755
4756 #[test]
4757 fn move_cursor_optimal_same_row_backward_to_zero() {
4758 let mut presenter = test_presenter();
4759 presenter.cursor_x = Some(50);
4760 presenter.cursor_y = Some(0);
4761
4762 presenter.move_cursor_optimal(0, 0).unwrap();
4763 let output = presenter.into_inner().unwrap();
4764
4765 let mut expected = Vec::new();
4768 ansi::cha(&mut expected, 0).unwrap();
4769 assert_eq!(output, expected, "Should use CHA for backward to col 0");
4770 }
4771
4772 #[test]
4773 fn move_cursor_optimal_unknown_cursor_uses_cup() {
4774 let mut presenter = test_presenter();
4775 presenter.move_cursor_optimal(10, 5).unwrap();
4777 let output = presenter.into_inner().unwrap();
4778 let mut expected = Vec::new();
4779 ansi::cup(&mut expected, 5, 10).unwrap();
4780 assert_eq!(output, expected, "Should use CUP when cursor is unknown");
4781 }
4782
4783 #[test]
4786 fn sync_wrap_order_begin_content_reset_end() {
4787 let mut presenter = test_presenter_with_sync();
4788 let mut buffer = Buffer::new(3, 1);
4789 buffer.set_raw(0, 0, Cell::from_char('Z'));
4790
4791 let old = Buffer::new(3, 1);
4792 let diff = BufferDiff::compute(&old, &buffer);
4793
4794 presenter.present(&buffer, &diff).unwrap();
4795 let output = get_output(presenter);
4796
4797 let sync_begin_pos = output
4798 .windows(ansi::SYNC_BEGIN.len())
4799 .position(|w| w == ansi::SYNC_BEGIN)
4800 .expect("sync begin missing");
4801 let z_pos = output
4802 .iter()
4803 .position(|&b| b == b'Z')
4804 .expect("character Z missing");
4805 let reset_pos = output
4806 .windows(b"\x1b[0m".len())
4807 .rposition(|w| w == b"\x1b[0m")
4808 .expect("SGR reset missing");
4809 let sync_end_pos = output
4810 .windows(ansi::SYNC_END.len())
4811 .rposition(|w| w == ansi::SYNC_END)
4812 .expect("sync end missing");
4813
4814 assert!(sync_begin_pos < z_pos, "sync begin before content");
4815 assert!(z_pos < reset_pos, "content before reset");
4816 assert!(reset_pos < sync_end_pos, "reset before sync end");
4817 }
4818
4819 #[test]
4822 fn style_none_after_each_frame() {
4823 let mut presenter = test_presenter();
4824 let fg = PackedRgba::rgb(255, 128, 64);
4825
4826 for _ in 0..5 {
4827 let mut buffer = Buffer::new(3, 1);
4828 buffer.set_raw(0, 0, Cell::from_char('X').with_fg(fg));
4829 let old = Buffer::new(3, 1);
4830 let diff = BufferDiff::compute(&old, &buffer);
4831 presenter.present(&buffer, &diff).unwrap();
4832
4833 assert!(
4835 presenter.current_style.is_none(),
4836 "Style should be None after frame end"
4837 );
4838 assert!(
4839 presenter.current_link.is_none(),
4840 "Link should be None after frame end"
4841 );
4842 }
4843 }
4844
4845 #[test]
4848 fn link_closed_at_frame_end_even_if_all_cells_linked() {
4849 let mut presenter = test_presenter();
4850 let mut buffer = Buffer::new(3, 1);
4851 let mut links = LinkRegistry::new();
4852 let link_id = links.register("https://all-linked.test");
4853
4854 for x in 0..3 {
4856 buffer.set_raw(
4857 x,
4858 0,
4859 Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
4860 );
4861 }
4862
4863 let old = Buffer::new(3, 1);
4864 let diff = BufferDiff::compute(&old, &buffer);
4865 presenter
4866 .present_with_pool(&buffer, &diff, None, Some(&links))
4867 .unwrap();
4868
4869 assert!(
4871 presenter.current_link.is_none(),
4872 "Link must be closed at frame end"
4873 );
4874 }
4875
4876 #[test]
4879 fn present_stats_empty_diff() {
4880 let mut presenter = test_presenter();
4881 let buffer = Buffer::new(10, 10);
4882 let diff = BufferDiff::new();
4883 let stats = presenter.present(&buffer, &diff).unwrap();
4884
4885 assert_eq!(stats.cells_changed, 0);
4886 assert_eq!(stats.run_count, 0);
4887 assert!(stats.bytes_emitted > 0);
4889 }
4890
4891 #[test]
4892 fn present_stats_full_row() {
4893 let mut presenter = test_presenter();
4894 let mut buffer = Buffer::new(10, 1);
4895 for x in 0..10 {
4896 buffer.set_raw(x, 0, Cell::from_char('A'));
4897 }
4898 let old = Buffer::new(10, 1);
4899 let diff = BufferDiff::compute(&old, &buffer);
4900 let stats = presenter.present(&buffer, &diff).unwrap();
4901
4902 assert_eq!(stats.cells_changed, 10);
4903 assert!(stats.run_count >= 1);
4904 assert!(stats.bytes_emitted > 10, "Should include ANSI overhead");
4905 }
4906
4907 #[test]
4910 fn capabilities_accessor() {
4911 let mut caps = TerminalCapabilities::basic();
4912 caps.sync_output = true;
4913 let presenter = Presenter::new(Vec::<u8>::new(), caps);
4914 assert!(presenter.capabilities().sync_output);
4915 }
4916
4917 #[test]
4920 fn flush_succeeds_on_empty_presenter() {
4921 let mut presenter = test_presenter();
4922 presenter.flush().unwrap();
4923 let output = get_output(presenter);
4924 assert!(output.is_empty());
4925 }
4926
4927 #[test]
4930 fn row_plan_total_cost_matches_dp() {
4931 let runs = [ChangeRun::new(3, 5, 10), ChangeRun::new(3, 15, 20)];
4932 let plan = cost_model::plan_row(&runs, None, None);
4933 assert!(plan.total_cost() > 0);
4934 }
4937
4938 #[test]
4941 fn sgr_delta_hot_path_only_fg_change() {
4942 let mut presenter = test_presenter();
4943 let old = CellStyle {
4944 fg: PackedRgba::rgb(255, 0, 0),
4945 bg: PackedRgba::rgb(0, 0, 0),
4946 attrs: StyleFlags::BOLD | StyleFlags::ITALIC,
4947 };
4948 let new = CellStyle {
4949 fg: PackedRgba::rgb(0, 255, 0),
4950 bg: PackedRgba::rgb(0, 0, 0),
4951 attrs: StyleFlags::BOLD | StyleFlags::ITALIC, };
4953
4954 presenter.current_style = Some(old);
4955 presenter.emit_style_delta(old, new).unwrap();
4956 let output = presenter.into_inner().unwrap();
4957 let output_str = String::from_utf8_lossy(&output);
4958
4959 assert!(output_str.contains("38;2;0;255;0"), "Should emit new fg");
4961 assert!(
4962 !output_str.contains("\x1b[0m"),
4963 "No reset needed for color-only change"
4964 );
4965 assert!(
4967 !output_str.contains("\x1b[1m"),
4968 "Bold should not be re-emitted"
4969 );
4970 }
4971
4972 #[test]
4973 fn sgr_delta_hot_path_both_colors_change() {
4974 let mut presenter = test_presenter();
4975 let old = CellStyle {
4976 fg: PackedRgba::rgb(1, 2, 3),
4977 bg: PackedRgba::rgb(4, 5, 6),
4978 attrs: StyleFlags::UNDERLINE,
4979 };
4980 let new = CellStyle {
4981 fg: PackedRgba::rgb(7, 8, 9),
4982 bg: PackedRgba::rgb(10, 11, 12),
4983 attrs: StyleFlags::UNDERLINE, };
4985
4986 presenter.current_style = Some(old);
4987 presenter.emit_style_delta(old, new).unwrap();
4988 let output = presenter.into_inner().unwrap();
4989 let output_str = String::from_utf8_lossy(&output);
4990
4991 assert!(output_str.contains("38;2;7;8;9"), "Should emit new fg");
4992 assert!(output_str.contains("48;2;10;11;12"), "Should emit new bg");
4993 assert!(!output_str.contains("\x1b[0m"), "No reset for color-only");
4994 }
4995
4996 #[test]
4999 fn emit_style_full_default_is_just_reset() {
5000 let mut presenter = test_presenter();
5001 let default_style = CellStyle::default();
5002 presenter.emit_style_full(default_style).unwrap();
5003 let output = presenter.into_inner().unwrap();
5004
5005 assert_eq!(output, b"\x1b[0m");
5007 }
5008
5009 #[test]
5010 fn emit_style_full_with_all_properties() {
5011 let mut presenter = test_presenter();
5012 let style = CellStyle {
5013 fg: PackedRgba::rgb(10, 20, 30),
5014 bg: PackedRgba::rgb(40, 50, 60),
5015 attrs: StyleFlags::BOLD | StyleFlags::ITALIC,
5016 };
5017 presenter.emit_style_full(style).unwrap();
5018 let output = presenter.into_inner().unwrap();
5019 let output_str = String::from_utf8_lossy(&output);
5020
5021 assert!(output_str.contains("\x1b[0m"), "Should start with reset");
5023 assert!(output_str.contains("38;2;10;20;30"), "Should have fg");
5024 assert!(output_str.contains("48;2;40;50;60"), "Should have bg");
5025 }
5026
5027 #[test]
5030 fn present_multiple_rows_different_strategies() {
5031 let mut presenter = test_presenter();
5032 let mut buffer = Buffer::new(80, 5);
5033
5034 for x in (0..20).step_by(2) {
5036 buffer.set_raw(x, 0, Cell::from_char('D'));
5037 }
5038 buffer.set_raw(0, 2, Cell::from_char('L'));
5040 buffer.set_raw(79, 2, Cell::from_char('R'));
5041 buffer.set_raw(40, 4, Cell::from_char('M'));
5043
5044 let old = Buffer::new(80, 5);
5045 let diff = BufferDiff::compute(&old, &buffer);
5046 presenter.present(&buffer, &diff).unwrap();
5047 let output = get_output(presenter);
5048 let output_str = String::from_utf8_lossy(&output);
5049
5050 assert!(output_str.contains('D'));
5051 assert!(output_str.contains('L'));
5052 assert!(output_str.contains('R'));
5053 assert!(output_str.contains('M'));
5054 }
5055
5056 #[test]
5057 fn zero_width_chars_replaced_with_placeholder() {
5058 let mut presenter = test_presenter();
5059 let mut buffer = Buffer::new(5, 1);
5060
5061 let zw_char = '\u{0301}';
5065
5066 assert_eq!(Cell::from_char(zw_char).content.width(), 0);
5068
5069 buffer.set_raw(0, 0, Cell::from_char(zw_char));
5070 buffer.set_raw(1, 0, Cell::from_char('A'));
5071
5072 let old = Buffer::new(5, 1);
5073 let diff = BufferDiff::compute(&old, &buffer);
5074
5075 presenter.present(&buffer, &diff).unwrap();
5076 let output = get_output(presenter);
5077 let output_str = String::from_utf8_lossy(&output);
5078
5079 assert!(
5081 output_str.contains("\u{FFFD}"),
5082 "Expected replacement character for zero-width content, got: {:?}",
5083 output_str
5084 );
5085
5086 assert!(
5088 !output_str.contains(zw_char),
5089 "Should not contain raw zero-width char"
5090 );
5091
5092 assert!(
5094 output_str.contains('A'),
5095 "Should contain subsequent character 'A'"
5096 );
5097 }
5098}
5099
5100#[cfg(test)]
5101mod proptests {
5102 use super::*;
5103 use crate::cell::{Cell, PackedRgba};
5104 use crate::diff::BufferDiff;
5105 use crate::terminal_model::TerminalModel;
5106 use proptest::prelude::*;
5107
5108 fn test_presenter() -> Presenter<Vec<u8>> {
5110 let caps = TerminalCapabilities::basic();
5111 Presenter::new(Vec::new(), caps)
5112 }
5113
5114 proptest! {
5115 #[test]
5118 fn presenter_roundtrip_characters(
5119 width in 5u16..40,
5120 height in 3u16..20,
5121 num_chars in 1usize..50, ) {
5123 let mut buffer = Buffer::new(width, height);
5124 let mut changed_positions = std::collections::HashSet::new();
5125
5126 for i in 0..num_chars {
5128 let x = (i * 7 + 3) as u16 % width;
5129 let y = (i * 11 + 5) as u16 % height;
5130 let ch = char::from_u32(('A' as u32) + (i as u32 % 26)).unwrap();
5131 buffer.set_raw(x, y, Cell::from_char(ch));
5132 changed_positions.insert((x, y));
5133 }
5134
5135 let mut presenter = test_presenter();
5137 let old = Buffer::new(width, height);
5138 let diff = BufferDiff::compute(&old, &buffer);
5139 presenter.present(&buffer, &diff).unwrap();
5140 let output = presenter.into_inner().unwrap();
5141
5142 let mut model = TerminalModel::new(width as usize, height as usize);
5144 model.process(&output);
5145
5146 for &(x, y) in &changed_positions {
5148 let buf_cell = buffer.get_unchecked(x, y);
5149 let expected_ch = buf_cell.content.as_char().unwrap_or(' ');
5150 let mut expected_buf = [0u8; 4];
5151 let expected_str = expected_ch.encode_utf8(&mut expected_buf);
5152
5153 if let Some(model_cell) = model.cell(x as usize, y as usize) {
5154 prop_assert_eq!(
5155 model_cell.text.as_str(),
5156 expected_str,
5157 "Character mismatch at ({}, {})", x, y
5158 );
5159 }
5160 }
5161 }
5162
5163 #[test]
5165 fn style_reset_after_present(
5166 width in 5u16..30,
5167 height in 3u16..15,
5168 num_styled in 1usize..20,
5169 ) {
5170 let mut buffer = Buffer::new(width, height);
5171
5172 for i in 0..num_styled {
5174 let x = (i * 7) as u16 % width;
5175 let y = (i * 11) as u16 % height;
5176 let fg = PackedRgba::rgb(
5177 ((i * 31) % 256) as u8,
5178 ((i * 47) % 256) as u8,
5179 ((i * 71) % 256) as u8,
5180 );
5181 buffer.set_raw(x, y, Cell::from_char('X').with_fg(fg));
5182 }
5183
5184 let mut presenter = test_presenter();
5186 let old = Buffer::new(width, height);
5187 let diff = BufferDiff::compute(&old, &buffer);
5188 presenter.present(&buffer, &diff).unwrap();
5189 let output = presenter.into_inner().unwrap();
5190 let output_str = String::from_utf8_lossy(&output);
5191
5192 prop_assert!(
5194 output_str.contains("\x1b[0m"),
5195 "Output should contain SGR reset"
5196 );
5197 }
5198
5199 #[test]
5201 fn empty_diff_minimal_output(
5202 width in 5u16..50,
5203 height in 3u16..25,
5204 ) {
5205 let buffer = Buffer::new(width, height);
5206 let diff = BufferDiff::new(); let mut presenter = test_presenter();
5209 presenter.present(&buffer, &diff).unwrap();
5210 let output = presenter.into_inner().unwrap();
5211
5212 prop_assert!(output.len() < 50, "Empty diff should have minimal output");
5215 }
5216
5217 #[test]
5222 fn diff_size_bounds(
5223 width in 5u16..30,
5224 height in 3u16..15,
5225 ) {
5226 let old = Buffer::new(width, height);
5228 let mut new = Buffer::new(width, height);
5229
5230 for y in 0..height {
5231 for x in 0..width {
5232 new.set_raw(x, y, Cell::from_char('X'));
5233 }
5234 }
5235
5236 let diff = BufferDiff::compute(&old, &new);
5237
5238 prop_assert_eq!(
5240 diff.len(),
5241 (width as usize) * (height as usize),
5242 "Full change should have all cells in diff"
5243 );
5244 }
5245
5246 #[test]
5248 fn presenter_cursor_consistency(
5249 width in 10u16..40,
5250 height in 5u16..20,
5251 num_runs in 1usize..10,
5252 ) {
5253 let mut buffer = Buffer::new(width, height);
5254
5255 for i in 0..num_runs {
5257 let start_x = (i * 5) as u16 % (width - 5);
5258 let y = i as u16 % height;
5259 for x in start_x..(start_x + 3) {
5260 buffer.set_raw(x, y, Cell::from_char('A'));
5261 }
5262 }
5263
5264 let mut presenter = test_presenter();
5266 let old = Buffer::new(width, height);
5267
5268 for _ in 0..3 {
5269 let diff = BufferDiff::compute(&old, &buffer);
5270 presenter.present(&buffer, &diff).unwrap();
5271 }
5272
5273 let output = presenter.into_inner().unwrap();
5275 prop_assert!(!output.is_empty(), "Should produce some output");
5276 }
5277
5278 #[test]
5282 fn sgr_delta_transition_equivalence(
5283 width in 5u16..20,
5284 height in 3u16..10,
5285 num_styled in 2usize..15,
5286 ) {
5287 let mut buffer = Buffer::new(width, height);
5288 let mut expected: std::collections::HashMap<(u16, u16), char> =
5290 std::collections::HashMap::new();
5291
5292 for i in 0..num_styled {
5294 let x = (i * 3 + 1) as u16 % width;
5295 let y = (i * 5 + 2) as u16 % height;
5296 let ch = char::from_u32(('A' as u32) + (i as u32 % 26)).unwrap();
5297 let fg = PackedRgba::rgb(
5298 ((i * 73) % 256) as u8,
5299 ((i * 137) % 256) as u8,
5300 ((i * 41) % 256) as u8,
5301 );
5302 let bg = if i % 3 == 0 {
5303 PackedRgba::rgb(
5304 ((i * 29) % 256) as u8,
5305 ((i * 53) % 256) as u8,
5306 ((i * 97) % 256) as u8,
5307 )
5308 } else {
5309 PackedRgba::TRANSPARENT
5310 };
5311 let flags_bits = ((i * 37) % 256) as u8;
5312 let flags = StyleFlags::from_bits_truncate(flags_bits);
5313 let cell = Cell::from_char(ch)
5314 .with_fg(fg)
5315 .with_bg(bg)
5316 .with_attrs(CellAttrs::new(flags, 0));
5317 buffer.set_raw(x, y, cell);
5318 expected.insert((x, y), ch);
5319 }
5320
5321 let mut presenter = test_presenter();
5323 let old = Buffer::new(width, height);
5324 let diff = BufferDiff::compute(&old, &buffer);
5325 presenter.present(&buffer, &diff).unwrap();
5326 let output = presenter.into_inner().unwrap();
5327
5328 let mut model = TerminalModel::new(width as usize, height as usize);
5330 model.process(&output);
5331
5332 for (&(x, y), &ch) in &expected {
5333 let mut buf = [0u8; 4];
5334 let expected_str = ch.encode_utf8(&mut buf);
5335
5336 if let Some(model_cell) = model.cell(x as usize, y as usize) {
5337 prop_assert_eq!(
5338 model_cell.text.as_str(),
5339 expected_str,
5340 "Character mismatch at ({}, {}) with delta engine", x, y
5341 );
5342 }
5343 }
5344 }
5345
5346 #[test]
5350 fn dp_emit_equivalence(
5351 width in 20u16..60,
5352 height in 5u16..15,
5353 num_changes in 5usize..30,
5354 ) {
5355 let mut buffer = Buffer::new(width, height);
5356 let mut expected: std::collections::HashMap<(u16, u16), char> =
5357 std::collections::HashMap::new();
5358
5359 for i in 0..num_changes {
5361 let x = (i * 7 + 3) as u16 % width;
5362 let y = (i * 3 + 1) as u16 % height;
5363 let ch = char::from_u32(('A' as u32) + (i as u32 % 26)).unwrap();
5364 buffer.set_raw(x, y, Cell::from_char(ch));
5365 expected.insert((x, y), ch);
5366 }
5367
5368 let mut presenter = test_presenter();
5370 let old = Buffer::new(width, height);
5371 let diff = BufferDiff::compute(&old, &buffer);
5372 presenter.present(&buffer, &diff).unwrap();
5373 let output = presenter.into_inner().unwrap();
5374
5375 let mut model = TerminalModel::new(width as usize, height as usize);
5377 model.process(&output);
5378
5379 for (&(x, y), &ch) in &expected {
5380 let mut buf = [0u8; 4];
5381 let expected_str = ch.encode_utf8(&mut buf);
5382
5383 if let Some(model_cell) = model.cell(x as usize, y as usize) {
5384 prop_assert_eq!(
5385 model_cell.text.as_str(),
5386 expected_str,
5387 "DP cost model: character mismatch at ({}, {})", x, y
5388 );
5389 }
5390 }
5391 }
5392 }
5393}