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