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 presentation_width: u16,
427}
428
429impl<W: Write> Presenter<W> {
430 pub fn new(writer: W, capabilities: TerminalCapabilities) -> Self {
432 Self {
433 writer: CountingWriter::new(BufWriter::with_capacity(BUFFER_CAPACITY, writer)),
434 current_style: None,
435 current_link: None,
436 cursor_x: None,
437 cursor_y: None,
438 viewport_offset_y: 0,
439 hyperlinks_enabled: capabilities.use_hyperlinks(),
440 capabilities,
441 plan_scratch: cost_model::RowPlanScratch::default(),
442 runs_buf: Vec::new(),
443 presentation_width: 0,
444 }
445 }
446
447 pub fn writer_mut(&mut self) -> &mut W {
453 self.writer.inner_mut().get_mut()
454 }
455
456 pub fn counting_writer_mut(&mut self) -> &mut CountingWriter<BufWriter<W>> {
461 &mut self.writer
462 }
463
464 pub fn set_viewport_offset_y(&mut self, offset: u16) {
469 self.viewport_offset_y = offset;
470 }
471
472 #[inline]
474 pub fn capabilities(&self) -> &TerminalCapabilities {
475 &self.capabilities
476 }
477
478 pub fn present(&mut self, buffer: &Buffer, diff: &BufferDiff) -> io::Result<PresentStats> {
487 self.present_with_pool(buffer, diff, None, None)
488 }
489
490 pub fn present_with_pool(
492 &mut self,
493 buffer: &Buffer,
494 diff: &BufferDiff,
495 pool: Option<&GraphemePool>,
496 links: Option<&LinkRegistry>,
497 ) -> io::Result<PresentStats> {
498 self.presentation_width = buffer.width();
499 let bracket_supported = self.capabilities.use_sync_output();
500
501 #[cfg(feature = "tracing")]
502 let _span = tracing::info_span!(
503 "present",
504 width = buffer.width(),
505 height = buffer.height(),
506 changes = diff.len()
507 );
508 #[cfg(feature = "tracing")]
509 let _guard = _span.enter();
510
511 #[cfg(feature = "tracing")]
512 let fallback_used = !bracket_supported;
513 #[cfg(feature = "tracing")]
514 let _sync_span = tracing::info_span!(
515 "render.sync_bracket",
516 bracket_supported,
517 fallback_used,
518 frame_bytes = tracing::field::Empty,
519 );
520 #[cfg(feature = "tracing")]
521 let _sync_guard = _sync_span.enter();
522
523 diff.runs_into(&mut self.runs_buf);
525 let run_count = self.runs_buf.len();
526 let cells_changed = diff.len();
527
528 self.writer.reset_counter();
530 let collector = StatsCollector::start(cells_changed, run_count);
531
532 if bracket_supported {
536 if let Err(err) = ansi::sync_begin(&mut self.writer) {
537 let _ = ansi::sync_end(&mut self.writer);
540 let _ = self.writer.flush();
541 return Err(err);
542 }
543 } else {
544 #[cfg(feature = "tracing")]
545 tracing::warn!("sync brackets unsupported; falling back to cursor-hide strategy");
546 ansi::cursor_hide(&mut self.writer)?;
547 }
548
549 let emit_result = self.emit_diff_runs(buffer, pool, links);
551
552 let frame_end_result = self.finish_frame();
554
555 let bracket_end_result = if bracket_supported {
556 ansi::sync_end(&mut self.writer)
557 } else {
558 ansi::cursor_show(&mut self.writer)
559 };
560
561 let flush_result = self.writer.flush();
562
563 let cleanup_error = frame_end_result
567 .err()
568 .or_else(|| bracket_end_result.err())
569 .or_else(|| flush_result.err());
570 if let Some(err) = cleanup_error {
571 return Err(err);
572 }
573 emit_result?;
574
575 let stats = collector.finish(self.writer.bytes_written());
576
577 #[cfg(feature = "tracing")]
578 {
579 _sync_span.record("frame_bytes", stats.bytes_emitted);
580 stats.log();
581 tracing::trace!("frame presented");
582 }
583
584 Ok(stats)
585 }
586
587 pub fn emit_diff_runs(
593 &mut self,
594 buffer: &Buffer,
595 pool: Option<&GraphemePool>,
596 links: Option<&LinkRegistry>,
597 ) -> io::Result<()> {
598 #[cfg(feature = "tracing")]
599 let _span = tracing::debug_span!("emit_diff");
600 #[cfg(feature = "tracing")]
601 let _guard = _span.enter();
602
603 #[cfg(feature = "tracing")]
604 tracing::trace!(run_count = self.runs_buf.len(), "emitting runs (reuse)");
605
606 let mut i = 0;
608 while i < self.runs_buf.len() {
609 let row_y = self.runs_buf[i].y;
610
611 let row_start = i;
613 while i < self.runs_buf.len() && self.runs_buf[i].y == row_y {
614 i += 1;
615 }
616 let row_runs = &self.runs_buf[row_start..i];
617
618 if row_runs.len() == 1 {
619 let run = row_runs[0];
620
621 #[cfg(feature = "tracing")]
622 tracing::trace!(
623 row = row_y,
624 spans = 1,
625 cost =
626 cost_model::cheapest_move_cost(self.cursor_x, self.cursor_y, run.x0, row_y)
627 .saturating_add(run.len()),
628 "row plan single-run fast path"
629 );
630
631 let row = buffer.row_cells(row_y);
632 self.emit_row_span(row, run.y, run.x0, run.x1, pool, links)?;
633 continue;
634 }
635
636 let plan = cost_model::plan_row_reuse(
637 row_runs,
638 self.cursor_x,
639 self.cursor_y,
640 &mut self.plan_scratch,
641 );
642
643 #[cfg(feature = "tracing")]
644 tracing::trace!(
645 row = row_y,
646 spans = plan.spans().len(),
647 cost = plan.total_cost(),
648 "row plan"
649 );
650
651 let row = buffer.row_cells(row_y);
652 for span in plan.spans() {
653 self.emit_row_span(row, span.y, span.x0, span.x1, pool, links)?;
654 }
655 }
656 Ok(())
657 }
658
659 #[inline]
660 fn emit_row_span(
661 &mut self,
662 row: &[Cell],
663 y: u16,
664 x0: u16,
665 x1: u16,
666 pool: Option<&GraphemePool>,
667 links: Option<&LinkRegistry>,
668 ) -> io::Result<()> {
669 self.move_cursor_optimal(x0, y)?;
670 let start = x0 as usize;
672 let end = x1 as usize;
673 debug_assert!(start <= end);
674 debug_assert!(end < row.len());
675 let mut idx = start;
676 while idx <= end {
677 let cell = &row[idx];
678 self.emit_cell(idx as u16, cell, pool, links)?;
679
680 let mut advance = 1usize;
689 let width = cell.content.width();
690 let should_repair_invalid_tail =
691 cell.content.as_char().is_some() || (cell.content.is_grapheme() && width == 2);
692 if width > 1 && should_repair_invalid_tail {
693 for off in 1..width {
694 let tx = idx + off;
695 if tx >= row.len() {
696 break;
697 }
698 if row[tx].is_continuation() {
699 if tx <= end {
700 advance = advance.max(off + 1);
701 }
702 continue;
703 }
704 self.move_cursor_optimal(tx as u16, y)?;
706 self.emit_orphan_continuation_space(tx as u16, links)?;
707 if tx <= end {
708 advance = advance.max(off + 1);
709 }
710 }
711 }
712
713 idx = idx.saturating_add(advance);
714 }
715
716 Ok(())
717 }
718
719 pub fn prepare_runs(&mut self, diff: &BufferDiff) {
723 diff.runs_into(&mut self.runs_buf);
724 }
725
726 pub fn finish_frame(&mut self) -> io::Result<()> {
731 let reset_result = ansi::sgr_reset(&mut self.writer);
732 self.current_style = None;
733
734 let hyperlink_close_result = if self.current_link.is_some() {
735 let res = ansi::hyperlink_end(&mut self.writer);
736 if res.is_ok() {
737 self.current_link = None;
738 }
739 Some(res)
740 } else {
741 None
742 };
743
744 if let Some(err) = reset_result
745 .err()
746 .or_else(|| hyperlink_close_result.and_then(Result::err))
747 {
748 return Err(err);
749 }
750
751 Ok(())
752 }
753
754 pub fn finish_frame_best_effort(&mut self) {
756 let _ = ansi::sgr_reset(&mut self.writer);
757 self.current_style = None;
758
759 if self.current_link.is_some() {
760 let _ = ansi::hyperlink_end(&mut self.writer);
761 self.current_link = None;
762 }
763 }
764
765 fn emit_cell(
767 &mut self,
768 x: u16,
769 cell: &Cell,
770 pool: Option<&GraphemePool>,
771 links: Option<&LinkRegistry>,
772 ) -> io::Result<()> {
773 if let Some(cx) = self.cursor_x {
780 if cx != x && !cell.is_continuation() {
781 if let Some(y) = self.cursor_y {
783 self.move_cursor_optimal(x, y)?;
784 }
785 }
786 } else {
787 if let Some(y) = self.cursor_y {
789 self.move_cursor_optimal(x, y)?;
790 }
791 }
792
793 if cell.is_continuation() {
802 match self.cursor_x {
803 Some(cx) if cx > x => return Ok(()),
805 Some(cx) => {
806 if cx < x
809 && let Some(y) = self.cursor_y
810 {
811 self.move_cursor_optimal(x, y)?;
812 }
813 return self.emit_orphan_continuation_space(x, links);
814 }
815 None => {
817 if let Some(y) = self.cursor_y {
818 self.move_cursor_optimal(x, y)?;
819 }
820 return self.emit_orphan_continuation_space(x, links);
821 }
822 }
823 }
824
825 self.emit_style_changes(cell)?;
827
828 self.emit_link_changes(cell, links)?;
830
831 let (prepared_content, raw_width) = PreparedContent::from_cell(cell);
832
833 let is_zero_width_content = raw_width == 0 && !cell.is_empty() && !cell.is_continuation();
836
837 if is_zero_width_content {
838 self.writer.write_all(b"\xEF\xBF\xBD")?;
840 } else {
841 self.emit_content(prepared_content, raw_width, pool)?;
843 }
844
845 if let Some(cx) = self.cursor_x {
847 let width = if cell.is_empty() || is_zero_width_content {
850 1
851 } else {
852 raw_width
853 };
854 self.cursor_x = Self::advance_or_invalidate(cx, width as u16, self.presentation_width);
855 }
856
857 Ok(())
858 }
859
860 fn advance_or_invalidate(cx: u16, width: u16, presentation_width: u16) -> Option<u16> {
869 let next = cx.saturating_add(width);
870 if presentation_width > 0 && next >= presentation_width {
871 None
872 } else {
873 Some(next)
874 }
875 }
876
877 fn emit_orphan_continuation_space(
882 &mut self,
883 x: u16,
884 links: Option<&LinkRegistry>,
885 ) -> io::Result<()> {
886 let blank = Cell::default();
887 self.emit_style_changes(&blank)?;
888 self.emit_link_changes(&blank, links)?;
889 self.writer.write_all(b" ")?;
890 self.cursor_x = Self::advance_or_invalidate(x, 1, self.presentation_width);
891 Ok(())
892 }
893
894 fn emit_style_changes(&mut self, cell: &Cell) -> io::Result<()> {
900 let new_style = CellStyle::from_cell(cell);
901
902 if self.current_style == Some(new_style) {
904 return Ok(());
905 }
906
907 match self.current_style {
908 None => {
909 self.emit_style_full(new_style)?;
911 }
912 Some(old_style) => {
913 self.emit_style_delta(old_style, new_style)?;
914 }
915 }
916
917 self.current_style = Some(new_style);
918 Ok(())
919 }
920
921 fn emit_style_full(&mut self, style: CellStyle) -> io::Result<()> {
923 ansi::sgr_reset(&mut self.writer)?;
924 if style.fg.a() > 0 {
925 ansi::sgr_fg_packed(&mut self.writer, style.fg)?;
926 }
927 if style.bg.a() > 0 {
928 ansi::sgr_bg_packed(&mut self.writer, style.bg)?;
929 }
930 if !style.attrs.is_empty() {
931 ansi::sgr_flags(&mut self.writer, style.attrs)?;
932 }
933 Ok(())
934 }
935
936 #[inline]
937 fn dec_len_u8(value: u8) -> u32 {
938 if value >= 100 {
939 3
940 } else if value >= 10 {
941 2
942 } else {
943 1
944 }
945 }
946
947 #[inline]
948 fn sgr_code_len(code: u8) -> u32 {
949 2 + Self::dec_len_u8(code) + 1
950 }
951
952 #[inline]
953 fn sgr_flags_len(flags: StyleFlags) -> u32 {
954 if flags.is_empty() {
955 return 0;
956 }
957 let mut count = 0u32;
958 let mut digits = 0u32;
959 for (flag, codes) in ansi::FLAG_TABLE {
960 if flags.contains(flag) {
961 count += 1;
962 digits += Self::dec_len_u8(codes.on);
963 }
964 }
965 if count == 0 {
966 return 0;
967 }
968 3 + digits + (count - 1)
969 }
970
971 #[inline]
972 fn sgr_flags_off_len(flags: StyleFlags) -> u32 {
973 if flags.is_empty() {
974 return 0;
975 }
976 let mut len = 0u32;
977 for (flag, codes) in ansi::FLAG_TABLE {
978 if flags.contains(flag) {
979 len += Self::sgr_code_len(codes.off);
980 }
981 }
982 len
983 }
984
985 #[inline]
986 fn sgr_rgb_len(color: PackedRgba) -> u32 {
987 10 + Self::dec_len_u8(color.r()) + Self::dec_len_u8(color.g()) + Self::dec_len_u8(color.b())
988 }
989
990 fn emit_style_delta(&mut self, old: CellStyle, new: CellStyle) -> io::Result<()> {
995 let attrs_removed = old.attrs & !new.attrs;
996 let attrs_added = new.attrs & !old.attrs;
997 let fg_changed = old.fg != new.fg;
998 let bg_changed = old.bg != new.bg;
999
1000 if old.attrs == new.attrs {
1004 if fg_changed {
1005 ansi::sgr_fg_packed(&mut self.writer, new.fg)?;
1006 }
1007 if bg_changed {
1008 ansi::sgr_bg_packed(&mut self.writer, new.bg)?;
1009 }
1010 return Ok(());
1011 }
1012
1013 let mut collateral = StyleFlags::empty();
1014 if attrs_removed.contains(StyleFlags::BOLD) && new.attrs.contains(StyleFlags::DIM) {
1015 collateral |= StyleFlags::DIM;
1016 }
1017 if attrs_removed.contains(StyleFlags::DIM) && new.attrs.contains(StyleFlags::BOLD) {
1018 collateral |= StyleFlags::BOLD;
1019 }
1020
1021 let mut delta_len = 0u32;
1022 delta_len += Self::sgr_flags_off_len(attrs_removed);
1023 delta_len += Self::sgr_flags_len(collateral);
1024 delta_len += Self::sgr_flags_len(attrs_added);
1025 if fg_changed {
1026 delta_len += if new.fg.a() == 0 {
1027 5
1028 } else {
1029 Self::sgr_rgb_len(new.fg)
1030 };
1031 }
1032 if bg_changed {
1033 delta_len += if new.bg.a() == 0 {
1034 5
1035 } else {
1036 Self::sgr_rgb_len(new.bg)
1037 };
1038 }
1039
1040 let mut baseline_len = 4u32;
1041 if new.fg.a() > 0 {
1042 baseline_len += Self::sgr_rgb_len(new.fg);
1043 }
1044 if new.bg.a() > 0 {
1045 baseline_len += Self::sgr_rgb_len(new.bg);
1046 }
1047 baseline_len += Self::sgr_flags_len(new.attrs);
1048
1049 if delta_len > baseline_len {
1050 return self.emit_style_full(new);
1051 }
1052
1053 if !attrs_removed.is_empty() {
1055 let collateral = ansi::sgr_flags_off(&mut self.writer, attrs_removed, new.attrs)?;
1056 if !collateral.is_empty() {
1058 ansi::sgr_flags(&mut self.writer, collateral)?;
1059 }
1060 }
1061
1062 if !attrs_added.is_empty() {
1064 ansi::sgr_flags(&mut self.writer, attrs_added)?;
1065 }
1066
1067 if fg_changed {
1069 ansi::sgr_fg_packed(&mut self.writer, new.fg)?;
1070 }
1071
1072 if bg_changed {
1074 ansi::sgr_bg_packed(&mut self.writer, new.bg)?;
1075 }
1076
1077 Ok(())
1078 }
1079
1080 fn emit_link_changes(&mut self, cell: &Cell, links: Option<&LinkRegistry>) -> io::Result<()> {
1082 if !self.hyperlinks_enabled {
1085 if self.current_link.is_none() {
1086 return Ok(());
1087 }
1088 if self.current_link.is_some() {
1089 ansi::hyperlink_end(&mut self.writer)?;
1090 }
1091 self.current_link = None;
1092 return Ok(());
1093 }
1094
1095 let raw_link_id = cell.attrs.link_id();
1096 let new_link = if raw_link_id == CellAttrs::LINK_ID_NONE {
1097 None
1098 } else {
1099 Some(raw_link_id)
1100 };
1101
1102 if self.current_link == new_link {
1104 return Ok(());
1105 }
1106
1107 if self.current_link.is_some() {
1109 ansi::hyperlink_end(&mut self.writer)?;
1110 }
1111
1112 let actually_opened = if let (Some(link_id), Some(registry)) = (new_link, links)
1114 && let Some(url) = registry.get(link_id)
1115 && is_safe_hyperlink_url(url)
1116 {
1117 ansi::hyperlink_start(&mut self.writer, url)?;
1118 true
1119 } else {
1120 false
1121 };
1122
1123 self.current_link = if actually_opened { new_link } else { None };
1125 Ok(())
1126 }
1127
1128 fn emit_content(
1130 &mut self,
1131 content: PreparedContent,
1132 raw_width: usize,
1133 pool: Option<&GraphemePool>,
1134 ) -> io::Result<()> {
1135 match content {
1136 PreparedContent::Grapheme(grapheme_id) => {
1137 if let Some(pool) = pool
1138 && let Some(text) = pool.get(grapheme_id)
1139 {
1140 let safe = sanitize(text);
1141 if !safe.is_empty() && display_width(safe.as_ref()) == raw_width {
1142 return self.writer.write_all(safe.as_bytes());
1143 }
1144 }
1145 if raw_width > 0 {
1149 for _ in 0..raw_width {
1150 self.writer.write_all(b"?")?;
1151 }
1152 }
1153 Ok(())
1154 }
1155 PreparedContent::Char(ch) => {
1156 if ch.is_ascii() {
1157 let byte = if ch.is_ascii_control() {
1162 b' '
1163 } else {
1164 ch as u8
1165 };
1166 return self.writer.write_all(&[byte]);
1167 }
1168 let safe_ch = if ch.is_control() { ' ' } else { ch };
1170 let mut buf = [0u8; 4];
1171 let encoded = safe_ch.encode_utf8(&mut buf);
1172 self.writer.write_all(encoded.as_bytes())
1173 }
1174 PreparedContent::Empty => {
1175 self.writer.write_all(b" ")
1177 }
1178 }
1179 }
1180
1181 fn move_cursor_to(&mut self, x: u16, y: u16) -> io::Result<()> {
1183 if self.cursor_x == Some(x) && self.cursor_y == Some(y) {
1185 return Ok(());
1186 }
1187
1188 ansi::cup(
1190 &mut self.writer,
1191 y.saturating_add(self.viewport_offset_y),
1192 x,
1193 )?;
1194 self.cursor_x = Some(x);
1195 self.cursor_y = Some(y);
1196 Ok(())
1197 }
1198
1199 fn move_cursor_optimal(&mut self, x: u16, y: u16) -> io::Result<()> {
1204 if self.cursor_x == Some(x) && self.cursor_y == Some(y) {
1206 return Ok(());
1207 }
1208
1209 let same_row = self.cursor_y == Some(y);
1211 let actual_y = y.saturating_add(self.viewport_offset_y);
1212
1213 if same_row {
1214 if let Some(cx) = self.cursor_x {
1215 if x > cx {
1216 let dx = x - cx;
1218 let cuf = cost_model::cuf_cost(dx);
1219 let cha = cost_model::cha_cost(x);
1220 let cup = cost_model::cup_cost(actual_y, x);
1221
1222 if cuf <= cha && cuf <= cup {
1223 ansi::cuf(&mut self.writer, dx)?;
1224 } else if cha <= cup {
1225 ansi::cha(&mut self.writer, x)?;
1226 } else {
1227 ansi::cup(&mut self.writer, actual_y, x)?;
1228 }
1229 } else if x < cx {
1230 let dx = cx - x;
1232 let cub = cost_model::cub_cost(dx);
1233 let cha = cost_model::cha_cost(x);
1234 let cup = cost_model::cup_cost(actual_y, x);
1235
1236 if cha <= cub && cha <= cup {
1237 ansi::cha(&mut self.writer, x)?;
1238 } else if cub <= cup {
1239 ansi::cub(&mut self.writer, dx)?;
1240 } else {
1241 ansi::cup(&mut self.writer, actual_y, x)?;
1242 }
1243 } else {
1244 }
1246 } else {
1247 ansi::cup(&mut self.writer, actual_y, x)?;
1250 }
1251 } else {
1252 ansi::cup(&mut self.writer, actual_y, x)?;
1254 }
1255
1256 self.cursor_x = Some(x);
1257 self.cursor_y = Some(y);
1258 Ok(())
1259 }
1260
1261 pub fn clear_screen(&mut self) -> io::Result<()> {
1263 ansi::erase_display(&mut self.writer, ansi::EraseDisplayMode::All)?;
1264 ansi::cup(&mut self.writer, 0, 0)?;
1265 self.cursor_x = Some(0);
1266 self.cursor_y = if self.viewport_offset_y == 0 {
1272 Some(0)
1273 } else {
1274 None
1275 };
1276 self.writer.flush()
1277 }
1278
1279 pub fn clear_line(&mut self, y: u16) -> io::Result<()> {
1281 self.move_cursor_to(0, y)?;
1282 ansi::erase_line(&mut self.writer, EraseLineMode::All)?;
1283 self.writer.flush()
1284 }
1285
1286 pub fn hide_cursor(&mut self) -> io::Result<()> {
1288 ansi::cursor_hide(&mut self.writer)?;
1289 self.writer.flush()
1290 }
1291
1292 pub fn show_cursor(&mut self) -> io::Result<()> {
1294 ansi::cursor_show(&mut self.writer)?;
1295 self.writer.flush()
1296 }
1297
1298 pub fn position_cursor(&mut self, x: u16, y: u16) -> io::Result<()> {
1300 self.move_cursor_to(x, y)?;
1301 self.writer.flush()
1302 }
1303
1304 pub fn reset(&mut self) {
1308 self.current_style = None;
1309 self.current_link = None;
1310 self.cursor_x = None;
1311 self.cursor_y = None;
1312 }
1313
1314 pub fn flush(&mut self) -> io::Result<()> {
1316 self.writer.flush()
1317 }
1318
1319 pub fn into_inner(self) -> Result<W, io::Error> {
1323 self.writer
1324 .into_inner() .into_inner() .map_err(|e| e.into_error())
1327 }
1328}
1329
1330#[cfg(test)]
1331mod tests {
1332 use super::*;
1333 use crate::cell::{CellAttrs, CellContent};
1334 use crate::link_registry::LinkRegistry;
1335
1336 fn test_presenter() -> Presenter<Vec<u8>> {
1337 let caps = TerminalCapabilities::basic();
1338 Presenter::new(Vec::new(), caps)
1339 }
1340
1341 fn test_presenter_with_sync() -> Presenter<Vec<u8>> {
1342 let mut caps = TerminalCapabilities::basic();
1343 caps.sync_output = true;
1344 Presenter::new(Vec::new(), caps)
1345 }
1346
1347 fn test_presenter_with_hyperlinks() -> Presenter<Vec<u8>> {
1348 let mut caps = TerminalCapabilities::basic();
1349 caps.osc8_hyperlinks = true;
1350 Presenter::new(Vec::new(), caps)
1351 }
1352
1353 fn get_output(presenter: Presenter<Vec<u8>>) -> Vec<u8> {
1354 presenter.into_inner().unwrap()
1355 }
1356
1357 fn legacy_plan_row(
1358 row_runs: &[ChangeRun],
1359 prev_x: Option<u16>,
1360 prev_y: Option<u16>,
1361 ) -> Vec<cost_model::RowSpan> {
1362 if row_runs.is_empty() {
1363 return Vec::new();
1364 }
1365
1366 if row_runs.len() == 1 {
1367 let run = row_runs[0];
1368 return vec![cost_model::RowSpan {
1369 y: run.y,
1370 x0: run.x0,
1371 x1: run.x1,
1372 }];
1373 }
1374
1375 let row_y = row_runs[0].y;
1376 let first_x = row_runs[0].x0;
1377 let last_x = row_runs[row_runs.len() - 1].x1;
1378
1379 let mut sparse_cost: usize = 0;
1381 let mut cursor_x = prev_x;
1382 let mut cursor_y = prev_y;
1383
1384 for run in row_runs {
1385 let move_cost = cost_model::cheapest_move_cost(cursor_x, cursor_y, run.x0, run.y);
1386 let cells = (run.x1 as usize).saturating_sub(run.x0 as usize) + 1;
1387 sparse_cost += move_cost + cells;
1388 cursor_x = Some(run.x1.saturating_add(1));
1389 cursor_y = Some(row_y);
1390 }
1391
1392 let merge_move = cost_model::cheapest_move_cost(prev_x, prev_y, first_x, row_y);
1394 let total_cells = (last_x as usize).saturating_sub(first_x as usize) + 1;
1395 let changed_cells: usize = row_runs
1396 .iter()
1397 .map(|r| (r.x1 as usize).saturating_sub(r.x0 as usize) + 1)
1398 .sum();
1399 let gap_cells = total_cells.saturating_sub(changed_cells);
1400 let gap_overhead = gap_cells * 2;
1401 let merged_cost = merge_move + changed_cells + gap_overhead;
1402
1403 if merged_cost < sparse_cost {
1404 vec![cost_model::RowSpan {
1405 y: row_y,
1406 x0: first_x,
1407 x1: last_x,
1408 }]
1409 } else {
1410 row_runs
1411 .iter()
1412 .map(|run| cost_model::RowSpan {
1413 y: run.y,
1414 x0: run.x0,
1415 x1: run.x1,
1416 })
1417 .collect()
1418 }
1419 }
1420
1421 fn emit_spans_for_output(buffer: &Buffer, spans: &[cost_model::RowSpan]) -> Vec<u8> {
1422 let mut presenter = test_presenter();
1423
1424 for span in spans {
1425 presenter
1426 .move_cursor_optimal(span.x0, span.y)
1427 .expect("cursor move should succeed");
1428 for x in span.x0..=span.x1 {
1429 let cell = buffer.get_unchecked(x, span.y);
1430 presenter
1431 .emit_cell(x, cell, None, None)
1432 .expect("emit_cell should succeed");
1433 }
1434 }
1435
1436 presenter
1437 .writer
1438 .write_all(b"\x1b[0m")
1439 .expect("reset should succeed");
1440
1441 presenter.into_inner().expect("presenter output")
1442 }
1443
1444 fn emit_spans_with_links_for_output(
1445 buffer: &Buffer,
1446 spans: &[cost_model::RowSpan],
1447 links: &LinkRegistry,
1448 ) -> Vec<u8> {
1449 let mut presenter = test_presenter_with_hyperlinks();
1450
1451 for span in spans {
1452 presenter
1453 .move_cursor_optimal(span.x0, span.y)
1454 .expect("cursor move should succeed");
1455 for x in span.x0..=span.x1 {
1456 let cell = buffer.get_unchecked(x, span.y);
1457 presenter
1458 .emit_cell(x, cell, None, Some(links))
1459 .expect("emit_cell should succeed");
1460 }
1461 }
1462
1463 presenter
1464 .finish_frame()
1465 .expect("frame cleanup should succeed");
1466 presenter.into_inner().expect("presenter output")
1467 }
1468
1469 #[test]
1470 fn empty_diff_produces_minimal_output() {
1471 let mut presenter = test_presenter();
1472 let buffer = Buffer::new(10, 10);
1473 let diff = BufferDiff::new();
1474
1475 presenter.present(&buffer, &diff).unwrap();
1476 let output = get_output(presenter);
1477
1478 assert!(output.starts_with(ansi::CURSOR_HIDE));
1480 assert!(output.ends_with(ansi::CURSOR_SHOW));
1481 assert!(
1483 output.windows(b"\x1b[0m".len()).any(|w| w == b"\x1b[0m"),
1484 "SGR reset should be present"
1485 );
1486 }
1487
1488 #[test]
1489 fn sync_output_wraps_frame() {
1490 let mut presenter = test_presenter_with_sync();
1491 let mut buffer = Buffer::new(3, 1);
1492 buffer.set_raw(0, 0, Cell::from_char('X'));
1493
1494 let old = Buffer::new(3, 1);
1495 let diff = BufferDiff::compute(&old, &buffer);
1496
1497 presenter.present(&buffer, &diff).unwrap();
1498 let output = get_output(presenter);
1499
1500 assert!(
1501 output.starts_with(ansi::SYNC_BEGIN),
1502 "sync output should begin with DEC 2026 begin"
1503 );
1504 assert!(
1505 output.ends_with(ansi::SYNC_END),
1506 "sync output should end with DEC 2026 end"
1507 );
1508 }
1509
1510 #[test]
1511 fn sync_output_obeys_mux_policy() {
1512 let caps = TerminalCapabilities::builder()
1513 .sync_output(true)
1514 .in_tmux(true)
1515 .build();
1516 let mut presenter = Presenter::new(Vec::new(), caps);
1517
1518 let mut buffer = Buffer::new(2, 1);
1519 buffer.set_raw(0, 0, Cell::from_char('X'));
1520 let old = Buffer::new(2, 1);
1521 let diff = BufferDiff::compute(&old, &buffer);
1522
1523 presenter.present(&buffer, &diff).unwrap();
1524 let output = get_output(presenter);
1525
1526 assert!(
1527 !output
1528 .windows(ansi::SYNC_BEGIN.len())
1529 .any(|w| w == ansi::SYNC_BEGIN),
1530 "tmux policy should suppress sync begin"
1531 );
1532 assert!(
1533 !output
1534 .windows(ansi::SYNC_END.len())
1535 .any(|w| w == ansi::SYNC_END),
1536 "tmux policy should suppress sync end"
1537 );
1538 }
1539
1540 #[test]
1541 fn hyperlink_sequences_emitted_and_closed() {
1542 let mut presenter = test_presenter_with_hyperlinks();
1543 let mut buffer = Buffer::new(3, 1);
1544
1545 let mut registry = LinkRegistry::new();
1546 let link_id = registry.register("https://example.com");
1547 let linked = Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id));
1548 buffer.set_raw(0, 0, linked);
1549
1550 let old = Buffer::new(3, 1);
1551 let diff = BufferDiff::compute(&old, &buffer);
1552
1553 presenter
1554 .present_with_pool(&buffer, &diff, None, Some(®istry))
1555 .unwrap();
1556 let output = get_output(presenter);
1557
1558 let start = b"\x1b]8;;https://example.com\x07";
1559 let end = b"\x1b]8;;\x07";
1560
1561 let start_pos = output
1562 .windows(start.len())
1563 .position(|w| w == start)
1564 .expect("hyperlink start not found");
1565 let end_pos = output
1566 .windows(end.len())
1567 .position(|w| w == end)
1568 .expect("hyperlink end not found");
1569 let char_pos = output
1570 .iter()
1571 .position(|&b| b == b'L')
1572 .expect("linked character not found");
1573
1574 assert!(start_pos < char_pos, "link start should precede text");
1575 assert!(char_pos < end_pos, "link end should follow text");
1576 }
1577
1578 #[test]
1579 fn single_cell_change() {
1580 let mut presenter = test_presenter();
1581 let mut buffer = Buffer::new(10, 10);
1582 buffer.set_raw(5, 5, Cell::from_char('X'));
1583
1584 let old = Buffer::new(10, 10);
1585 let diff = BufferDiff::compute(&old, &buffer);
1586
1587 presenter.present(&buffer, &diff).unwrap();
1588 let output = get_output(presenter);
1589
1590 let output_str = String::from_utf8_lossy(&output);
1592 assert!(output_str.contains("X"));
1593 assert!(output_str.contains("\x1b[")); }
1595
1596 #[test]
1597 fn style_tracking_avoids_redundant_sgr() {
1598 let mut presenter = test_presenter();
1599 let mut buffer = Buffer::new(10, 1);
1600
1601 let fg = PackedRgba::rgb(255, 0, 0);
1603 buffer.set_raw(0, 0, Cell::from_char('A').with_fg(fg));
1604 buffer.set_raw(1, 0, Cell::from_char('B').with_fg(fg));
1605 buffer.set_raw(2, 0, Cell::from_char('C').with_fg(fg));
1606
1607 let old = Buffer::new(10, 1);
1608 let diff = BufferDiff::compute(&old, &buffer);
1609
1610 presenter.present(&buffer, &diff).unwrap();
1611 let output = get_output(presenter);
1612
1613 let output_str = String::from_utf8_lossy(&output);
1615 let sgr_count = output_str.matches("\x1b[38;2").count();
1616 assert_eq!(
1618 sgr_count, 1,
1619 "Expected 1 SGR fg sequence, got {}",
1620 sgr_count
1621 );
1622 }
1623
1624 #[test]
1625 fn reset_reapplies_style_after_clear() {
1626 let mut presenter = test_presenter();
1627 let mut buffer = Buffer::new(1, 1);
1628 let styled = Cell::from_char('A').with_fg(PackedRgba::rgb(10, 20, 30));
1629 buffer.set_raw(0, 0, styled);
1630
1631 let old = Buffer::new(1, 1);
1632 let diff = BufferDiff::compute(&old, &buffer);
1633
1634 presenter.present(&buffer, &diff).unwrap();
1635 presenter.reset();
1636 presenter.present(&buffer, &diff).unwrap();
1637
1638 let output = get_output(presenter);
1639 let output_str = String::from_utf8_lossy(&output);
1640 let sgr_count = output_str.matches("\x1b[38;2").count();
1641
1642 assert_eq!(
1643 sgr_count, 2,
1644 "Expected style to be re-applied after reset, got {sgr_count} sequences"
1645 );
1646 }
1647
1648 #[test]
1649 fn cursor_position_optimized() {
1650 let mut presenter = test_presenter();
1651 let mut buffer = Buffer::new(10, 5);
1652
1653 buffer.set_raw(3, 2, Cell::from_char('A'));
1655 buffer.set_raw(4, 2, Cell::from_char('B'));
1656 buffer.set_raw(5, 2, Cell::from_char('C'));
1657
1658 let old = Buffer::new(10, 5);
1659 let diff = BufferDiff::compute(&old, &buffer);
1660
1661 presenter.present(&buffer, &diff).unwrap();
1662 let output = get_output(presenter);
1663
1664 let output_str = String::from_utf8_lossy(&output);
1666 let _cup_count = output_str.matches("\x1b[").filter(|_| true).count();
1667
1668 assert!(
1670 output_str.contains("ABC")
1671 || (output_str.contains('A')
1672 && output_str.contains('B')
1673 && output_str.contains('C'))
1674 );
1675 }
1676
1677 #[test]
1678 fn sync_output_wrapped_when_supported() {
1679 let mut presenter = test_presenter_with_sync();
1680 let buffer = Buffer::new(10, 10);
1681 let diff = BufferDiff::new();
1682
1683 presenter.present(&buffer, &diff).unwrap();
1684 let output = get_output(presenter);
1685
1686 assert!(output.starts_with(ansi::SYNC_BEGIN));
1688 assert!(
1689 output
1690 .windows(ansi::SYNC_END.len())
1691 .any(|w| w == ansi::SYNC_END)
1692 );
1693 }
1694
1695 #[test]
1696 fn clear_screen_works() {
1697 let mut presenter = test_presenter();
1698 presenter.clear_screen().unwrap();
1699 let output = get_output(presenter);
1700
1701 assert!(output.windows(b"\x1b[2J".len()).any(|w| w == b"\x1b[2J"));
1703 }
1704
1705 #[test]
1706 fn cursor_advance_invalidates_at_last_column() {
1707 assert_eq!(
1712 Presenter::<Vec<u8>>::advance_or_invalidate(119, 1, 120),
1713 None
1714 );
1715 assert_eq!(
1716 Presenter::<Vec<u8>>::advance_or_invalidate(118, 2, 120),
1717 None
1718 );
1719 assert_eq!(
1720 Presenter::<Vec<u8>>::advance_or_invalidate(100, 2, 120),
1721 Some(102)
1722 );
1723 assert_eq!(
1725 Presenter::<Vec<u8>>::advance_or_invalidate(119, 1, 0),
1726 Some(120)
1727 );
1728 }
1729
1730 #[test]
1731 fn move_after_end_of_row_emission_is_absolute() {
1732 let mut presenter = test_presenter();
1733 presenter.presentation_width = 120;
1734 presenter.cursor_x = None;
1736 presenter.cursor_y = Some(20);
1737 presenter.move_cursor_optimal(110, 20).unwrap();
1738 let output = get_output(presenter);
1739 let s = String::from_utf8_lossy(&output);
1740 assert!(s.contains("\x1b[21;111H"), "output: {s:?}");
1743 assert!(!s.ends_with('D'), "relative CUB emitted: {s:?}");
1744 }
1745
1746 #[test]
1747 fn clear_screen_with_viewport_offset_forces_absolute_next_move() {
1748 let mut presenter = test_presenter();
1749 presenter.set_viewport_offset_y(5);
1750 presenter.clear_screen().unwrap();
1751 assert_eq!(presenter.cursor_x, Some(0));
1754 assert_eq!(presenter.cursor_y, None);
1755 presenter.move_cursor_optimal(3, 0).unwrap();
1756 let output = get_output(presenter);
1757 let s = String::from_utf8_lossy(&output);
1758 assert!(s.contains("\x1b[6;4H"), "output: {s:?}");
1761 }
1762
1763 #[test]
1764 fn cursor_visibility() {
1765 let mut presenter = test_presenter();
1766
1767 presenter.hide_cursor().unwrap();
1768 presenter.show_cursor().unwrap();
1769
1770 let output = get_output(presenter);
1771 let output_str = String::from_utf8_lossy(&output);
1772
1773 assert!(output_str.contains("\x1b[?25l")); assert!(output_str.contains("\x1b[?25h")); }
1776
1777 #[test]
1778 fn reset_clears_state() {
1779 let mut presenter = test_presenter();
1780 presenter.cursor_x = Some(50);
1781 presenter.cursor_y = Some(20);
1782 presenter.current_style = Some(CellStyle::default());
1783
1784 presenter.reset();
1785
1786 assert!(presenter.cursor_x.is_none());
1787 assert!(presenter.cursor_y.is_none());
1788 assert!(presenter.current_style.is_none());
1789 }
1790
1791 #[test]
1792 fn position_cursor() {
1793 let mut presenter = test_presenter();
1794 presenter.position_cursor(10, 5).unwrap();
1795
1796 let output = get_output(presenter);
1797 assert!(
1799 output
1800 .windows(b"\x1b[6;11H".len())
1801 .any(|w| w == b"\x1b[6;11H")
1802 );
1803 }
1804
1805 #[test]
1806 fn skip_cursor_move_when_already_at_position() {
1807 let mut presenter = test_presenter();
1808 presenter.cursor_x = Some(5);
1809 presenter.cursor_y = Some(3);
1810
1811 presenter.move_cursor_to(5, 3).unwrap();
1813
1814 let output = get_output(presenter);
1816 assert!(output.is_empty());
1817 }
1818
1819 #[test]
1820 fn continuation_cells_skipped() {
1821 let mut presenter = test_presenter();
1822 let mut buffer = Buffer::new(10, 1);
1823
1824 buffer.set_raw(0, 0, Cell::from_char('中'));
1826 buffer.set_raw(1, 0, Cell::CONTINUATION);
1828
1829 let old = Buffer::new(10, 1);
1831 let diff = BufferDiff::compute(&old, &buffer);
1832
1833 presenter.present(&buffer, &diff).unwrap();
1834 let output = get_output(presenter);
1835
1836 let output_str = String::from_utf8_lossy(&output);
1838 assert!(output_str.contains('中'));
1839 }
1840
1841 #[test]
1842 fn continuation_at_run_start_clears_orphan_tail() {
1843 let mut presenter = test_presenter();
1844 let mut old = Buffer::new(3, 1);
1845 let mut new = Buffer::new(3, 1);
1846
1847 old.set_raw(0, 0, Cell::from_char('中'));
1853 new.set_raw(0, 0, Cell::from_char('中'));
1854 old.set_raw(1, 0, Cell::from_char('X'));
1855 new.set_raw(1, 0, Cell::CONTINUATION);
1856
1857 let diff = BufferDiff::compute(&old, &new);
1858 assert_eq!(diff.changes(), &[(1u16, 0u16)]);
1859
1860 presenter.present(&new, &diff).unwrap();
1861 let output = get_output(presenter);
1862
1863 assert!(
1864 output.contains(&b' '),
1865 "orphan continuation should be cleared with a space"
1866 );
1867 }
1868
1869 #[test]
1870 fn continuation_cleanup_resets_style_and_closes_link_before_space() {
1871 let mut presenter = test_presenter_with_hyperlinks();
1872 let mut links = LinkRegistry::new();
1873 let link_id = links.register("https://example.com");
1874
1875 let styled = Cell::from_char('X')
1876 .with_fg(PackedRgba::rgb(255, 0, 0))
1877 .with_bg(PackedRgba::rgb(0, 0, 255))
1878 .with_attrs(CellAttrs::new(StyleFlags::UNDERLINE, link_id));
1879 presenter.current_style = Some(CellStyle::from_cell(&styled));
1880 presenter.current_link = Some(link_id);
1881 presenter.cursor_x = Some(0);
1882 presenter.cursor_y = Some(0);
1883
1884 presenter
1885 .emit_cell(0, &Cell::CONTINUATION, None, Some(&links))
1886 .unwrap();
1887 let output = presenter.into_inner().unwrap();
1888
1889 let reset = b"\x1b[0m";
1890 let close = b"\x1b]8;;\x07";
1891 let reset_pos = output
1892 .windows(reset.len())
1893 .position(|window| window == reset)
1894 .expect("continuation cleanup should reset SGR state");
1895 let close_pos = output
1896 .windows(close.len())
1897 .position(|window| window == close)
1898 .expect("continuation cleanup should close OSC 8");
1899 let space_pos = output
1900 .iter()
1901 .position(|&byte| byte == b' ')
1902 .expect("continuation cleanup should emit a space");
1903
1904 assert!(
1905 reset_pos < space_pos,
1906 "cleanup reset must precede the blank"
1907 );
1908 assert!(
1909 close_pos < space_pos,
1910 "cleanup link close must precede the blank"
1911 );
1912 }
1913
1914 #[test]
1915 fn wide_char_missing_continuation_causes_drift() {
1916 let mut presenter = test_presenter();
1917 let mut buffer = Buffer::new(10, 1);
1918
1919 buffer.set_raw(0, 0, Cell::from_char('中'));
1921 let old = Buffer::new(10, 1);
1924 let diff = BufferDiff::compute(&old, &buffer);
1925
1926 presenter.present(&buffer, &diff).unwrap();
1927 let output = get_output(presenter);
1928
1929 let output_str = String::from_utf8_lossy(&output);
1939
1940 assert!(output_str.contains('中'));
1942
1943 let has_correction = output_str.contains("\x1b[D")
1949 || output_str.contains("\x1b[2G")
1950 || output_str.contains("\x1b[1;2H");
1951
1952 assert!(
1953 has_correction,
1954 "Presenter should correct cursor drift when wide char tail is missing. Output: {:?}",
1955 output_str
1956 );
1957 }
1958
1959 #[test]
1960 fn hyperlink_emitted_with_registry() {
1961 let mut presenter = test_presenter_with_hyperlinks();
1962 let mut buffer = Buffer::new(10, 1);
1963 let mut links = LinkRegistry::new();
1964
1965 let link_id = links.register("https://example.com");
1966 let cell = Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id));
1967 buffer.set_raw(0, 0, cell);
1968
1969 let old = Buffer::new(10, 1);
1970 let diff = BufferDiff::compute(&old, &buffer);
1971
1972 presenter
1973 .present_with_pool(&buffer, &diff, None, Some(&links))
1974 .unwrap();
1975 let output = get_output(presenter);
1976 let output_str = String::from_utf8_lossy(&output);
1977
1978 assert!(
1980 output_str.contains("\x1b]8;;https://example.com\x07"),
1981 "Expected OSC 8 open, got: {:?}",
1982 output_str
1983 );
1984 assert!(
1986 output_str.contains("\x1b]8;;\x07"),
1987 "Expected OSC 8 close, got: {:?}",
1988 output_str
1989 );
1990 }
1991
1992 #[test]
1993 fn hyperlink_not_emitted_without_registry() {
1994 let mut presenter = test_presenter_with_hyperlinks();
1995 let mut buffer = Buffer::new(10, 1);
1996
1997 let cell = Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), 1));
1999 buffer.set_raw(0, 0, cell);
2000
2001 let old = Buffer::new(10, 1);
2002 let diff = BufferDiff::compute(&old, &buffer);
2003
2004 presenter.present(&buffer, &diff).unwrap();
2006 let output = get_output(presenter);
2007 let output_str = String::from_utf8_lossy(&output);
2008
2009 assert!(
2011 !output_str.contains("\x1b]8;"),
2012 "OSC 8 should not appear without registry, got: {:?}",
2013 output_str
2014 );
2015 }
2016
2017 #[test]
2018 fn hyperlink_not_emitted_for_unknown_id() {
2019 let mut presenter = test_presenter_with_hyperlinks();
2020 let mut buffer = Buffer::new(10, 1);
2021 let links = LinkRegistry::new();
2022
2023 let cell = Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), 42));
2024 buffer.set_raw(0, 0, cell);
2025
2026 let old = Buffer::new(10, 1);
2027 let diff = BufferDiff::compute(&old, &buffer);
2028
2029 presenter
2030 .present_with_pool(&buffer, &diff, None, Some(&links))
2031 .unwrap();
2032 let output = get_output(presenter);
2033 let output_str = String::from_utf8_lossy(&output);
2034
2035 assert!(
2036 !output_str.contains("\x1b]8;"),
2037 "OSC 8 should not appear for unknown link IDs, got: {:?}",
2038 output_str
2039 );
2040 assert!(output_str.contains('L'));
2041 }
2042
2043 #[test]
2044 fn hyperlink_closed_at_frame_end() {
2045 let mut presenter = test_presenter_with_hyperlinks();
2046 let mut buffer = Buffer::new(10, 1);
2047 let mut links = LinkRegistry::new();
2048
2049 let link_id = links.register("https://example.com");
2050 for x in 0..5 {
2052 buffer.set_raw(
2053 x,
2054 0,
2055 Cell::from_char('A').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
2056 );
2057 }
2058
2059 let old = Buffer::new(10, 1);
2060 let diff = BufferDiff::compute(&old, &buffer);
2061
2062 presenter
2063 .present_with_pool(&buffer, &diff, None, Some(&links))
2064 .unwrap();
2065 let output = get_output(presenter);
2066
2067 let close_seq = b"\x1b]8;;\x07";
2069 assert!(
2070 output.windows(close_seq.len()).any(|w| w == close_seq),
2071 "Link must be closed at frame end"
2072 );
2073 }
2074
2075 #[test]
2076 fn hyperlink_transitions_between_links() {
2077 let mut presenter = test_presenter_with_hyperlinks();
2078 let mut buffer = Buffer::new(10, 1);
2079 let mut links = LinkRegistry::new();
2080
2081 let link_a = links.register("https://a.com");
2082 let link_b = links.register("https://b.com");
2083
2084 buffer.set_raw(
2085 0,
2086 0,
2087 Cell::from_char('A').with_attrs(CellAttrs::new(StyleFlags::empty(), link_a)),
2088 );
2089 buffer.set_raw(
2090 1,
2091 0,
2092 Cell::from_char('B').with_attrs(CellAttrs::new(StyleFlags::empty(), link_b)),
2093 );
2094 buffer.set_raw(2, 0, Cell::from_char('C')); let old = Buffer::new(10, 1);
2097 let diff = BufferDiff::compute(&old, &buffer);
2098
2099 presenter
2100 .present_with_pool(&buffer, &diff, None, Some(&links))
2101 .unwrap();
2102 let output = get_output(presenter);
2103 let output_str = String::from_utf8_lossy(&output);
2104
2105 assert!(output_str.contains("https://a.com"));
2107 assert!(output_str.contains("https://b.com"));
2108
2109 let close_count = output_str.matches("\x1b]8;;\x07").count();
2111 assert!(
2112 close_count >= 2,
2113 "Expected at least 2 link close sequences (transition + frame end), got {}",
2114 close_count
2115 );
2116 }
2117
2118 #[test]
2119 fn hyperlink_obeys_mux_policy_even_when_capability_flag_set() {
2120 let caps = TerminalCapabilities::builder()
2121 .osc8_hyperlinks(true)
2122 .in_tmux(true)
2123 .build();
2124 let mut presenter = Presenter::new(Vec::new(), caps);
2125 let mut buffer = Buffer::new(3, 1);
2126 let mut links = LinkRegistry::new();
2127 let link_id = links.register("https://example.com");
2128 buffer.set_raw(
2129 0,
2130 0,
2131 Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
2132 );
2133
2134 let old = Buffer::new(3, 1);
2135 let diff = BufferDiff::compute(&old, &buffer);
2136 presenter
2137 .present_with_pool(&buffer, &diff, None, Some(&links))
2138 .unwrap();
2139
2140 let output = get_output(presenter);
2141 let output_str = String::from_utf8_lossy(&output);
2142 assert!(
2143 !output_str.contains("\x1b]8;"),
2144 "tmux policy should suppress OSC 8 sequences"
2145 );
2146 assert!(output_str.contains('L'));
2147 }
2148
2149 #[test]
2150 fn hyperlink_disabled_policy_noops_when_no_link_is_open() {
2151 let mut presenter = test_presenter();
2152 presenter
2153 .emit_link_changes(&Cell::from_char('X'), None)
2154 .unwrap();
2155 assert!(presenter.into_inner().unwrap().is_empty());
2156 }
2157
2158 #[test]
2159 fn hyperlink_disabled_policy_still_closes_stale_open_link() {
2160 let mut presenter = test_presenter();
2161 presenter.current_link = Some(7);
2162 presenter
2163 .emit_link_changes(&Cell::from_char('X'), None)
2164 .unwrap();
2165 assert_eq!(presenter.into_inner().unwrap(), b"\x1b]8;;\x07");
2166 }
2167
2168 #[test]
2169 fn hyperlink_unsafe_url_not_emitted() {
2170 let mut presenter = test_presenter_with_hyperlinks();
2171 let mut buffer = Buffer::new(3, 1);
2172 let mut links = LinkRegistry::new();
2173 let link_id = links.register("https://example.com/\x1b[?2026h");
2174 buffer.set_raw(
2175 0,
2176 0,
2177 Cell::from_char('X').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
2178 );
2179
2180 let old = Buffer::new(3, 1);
2181 let diff = BufferDiff::compute(&old, &buffer);
2182 presenter
2183 .present_with_pool(&buffer, &diff, None, Some(&links))
2184 .unwrap();
2185
2186 let output = get_output(presenter);
2187 let output_str = String::from_utf8_lossy(&output);
2188 assert!(
2189 !output_str.contains("\x1b]8;;https://example.com/"),
2190 "unsafe hyperlink URL should be suppressed"
2191 );
2192 assert!(
2193 !output_str.contains("\x1b[?2026h"),
2194 "control payload must never be emitted via OSC 8"
2195 );
2196 assert!(output_str.contains('X'));
2197 }
2198
2199 #[test]
2200 fn hyperlink_overlong_url_not_emitted() {
2201 let mut presenter = test_presenter_with_hyperlinks();
2202 let mut buffer = Buffer::new(3, 1);
2203 let mut links = LinkRegistry::new();
2204 let long_url = format!(
2205 "https://example.com/{}",
2206 "a".repeat(MAX_SAFE_HYPERLINK_URL_BYTES + 1)
2207 );
2208 let link_id = links.register(&long_url);
2209 buffer.set_raw(
2210 0,
2211 0,
2212 Cell::from_char('Y').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
2213 );
2214
2215 let old = Buffer::new(3, 1);
2216 let diff = BufferDiff::compute(&old, &buffer);
2217 presenter
2218 .present_with_pool(&buffer, &diff, None, Some(&links))
2219 .unwrap();
2220
2221 let output = get_output(presenter);
2222 let output_str = String::from_utf8_lossy(&output);
2223 assert!(
2224 !output_str.contains("\x1b]8;;https://example.com/"),
2225 "overlong hyperlink URL should be suppressed"
2226 );
2227 assert!(output_str.contains('Y'));
2228 }
2229
2230 #[test]
2235 fn sync_output_not_wrapped_when_unsupported() {
2236 let mut presenter = test_presenter(); let buffer = Buffer::new(10, 10);
2239 let diff = BufferDiff::new();
2240
2241 presenter.present(&buffer, &diff).unwrap();
2242 let output = get_output(presenter);
2243
2244 assert!(
2246 !output
2247 .windows(ansi::SYNC_BEGIN.len())
2248 .any(|w| w == ansi::SYNC_BEGIN),
2249 "Sync begin should not appear when sync_output is disabled"
2250 );
2251 assert!(
2252 !output
2253 .windows(ansi::SYNC_END.len())
2254 .any(|w| w == ansi::SYNC_END),
2255 "Sync end should not appear when sync_output is disabled"
2256 );
2257
2258 assert!(
2260 output.starts_with(ansi::CURSOR_HIDE),
2261 "Fallback should start with cursor hide"
2262 );
2263 assert!(
2264 output.ends_with(ansi::CURSOR_SHOW),
2265 "Fallback should end with cursor show"
2266 );
2267 }
2268
2269 #[test]
2270 fn present_flushes_buffered_output() {
2271 let mut presenter = test_presenter();
2274 let mut buffer = Buffer::new(5, 1);
2275 buffer.set_raw(0, 0, Cell::from_char('T'));
2276 buffer.set_raw(1, 0, Cell::from_char('E'));
2277 buffer.set_raw(2, 0, Cell::from_char('S'));
2278 buffer.set_raw(3, 0, Cell::from_char('T'));
2279
2280 let old = Buffer::new(5, 1);
2281 let diff = BufferDiff::compute(&old, &buffer);
2282
2283 presenter.present(&buffer, &diff).unwrap();
2284 let output = get_output(presenter);
2285 let output_str = String::from_utf8_lossy(&output);
2286
2287 assert!(
2289 output_str.contains("TEST"),
2290 "Expected 'TEST' in flushed output"
2291 );
2292 }
2293
2294 #[test]
2295 fn present_stats_reports_cells_and_bytes() {
2296 let mut presenter = test_presenter();
2297 let mut buffer = Buffer::new(10, 1);
2298
2299 for i in 0..5 {
2301 buffer.set_raw(i, 0, Cell::from_char('X'));
2302 }
2303
2304 let old = Buffer::new(10, 1);
2305 let diff = BufferDiff::compute(&old, &buffer);
2306
2307 let stats = presenter.present(&buffer, &diff).unwrap();
2308
2309 assert_eq!(stats.cells_changed, 5, "Expected 5 cells changed");
2311 assert!(stats.bytes_emitted > 0, "Expected some bytes written");
2312 assert!(stats.run_count >= 1, "Expected at least 1 run");
2313 }
2314
2315 #[test]
2320 fn cursor_tracking_after_wide_char() {
2321 let mut presenter = test_presenter();
2322 presenter.cursor_x = Some(0);
2323 presenter.cursor_y = Some(0);
2324
2325 let mut buffer = Buffer::new(10, 1);
2326 buffer.set_raw(0, 0, Cell::from_char('中'));
2328 buffer.set_raw(1, 0, Cell::CONTINUATION);
2329 buffer.set_raw(2, 0, Cell::from_char('A'));
2331
2332 let old = Buffer::new(10, 1);
2333 let diff = BufferDiff::compute(&old, &buffer);
2334
2335 presenter.present(&buffer, &diff).unwrap();
2336
2337 let output = get_output(presenter);
2340 let output_str = String::from_utf8_lossy(&output);
2341
2342 assert!(output_str.contains('中'));
2344 assert!(output_str.contains('A'));
2345 }
2346
2347 #[test]
2348 fn cursor_position_after_multiple_runs() {
2349 let mut presenter = test_presenter();
2350 let mut buffer = Buffer::new(20, 3);
2351
2352 buffer.set_raw(0, 0, Cell::from_char('A'));
2354 buffer.set_raw(1, 0, Cell::from_char('B'));
2355 buffer.set_raw(5, 2, Cell::from_char('X'));
2356 buffer.set_raw(6, 2, Cell::from_char('Y'));
2357
2358 let old = Buffer::new(20, 3);
2359 let diff = BufferDiff::compute(&old, &buffer);
2360
2361 presenter.present(&buffer, &diff).unwrap();
2362 let output = get_output(presenter);
2363 let output_str = String::from_utf8_lossy(&output);
2364
2365 assert!(output_str.contains('A'));
2367 assert!(output_str.contains('B'));
2368 assert!(output_str.contains('X'));
2369 assert!(output_str.contains('Y'));
2370
2371 let cup_count = output_str.matches("\x1b[").count();
2373 assert!(
2374 cup_count >= 2,
2375 "Expected at least 2 escape sequences for multiple runs"
2376 );
2377 }
2378
2379 #[test]
2384 fn style_with_all_flags() {
2385 let mut presenter = test_presenter();
2386 let mut buffer = Buffer::new(5, 1);
2387
2388 let all_flags = StyleFlags::BOLD
2390 | StyleFlags::DIM
2391 | StyleFlags::ITALIC
2392 | StyleFlags::UNDERLINE
2393 | StyleFlags::BLINK
2394 | StyleFlags::REVERSE
2395 | StyleFlags::STRIKETHROUGH;
2396
2397 let cell = Cell::from_char('X').with_attrs(CellAttrs::new(all_flags, 0));
2398 buffer.set_raw(0, 0, cell);
2399
2400 let old = Buffer::new(5, 1);
2401 let diff = BufferDiff::compute(&old, &buffer);
2402
2403 presenter.present(&buffer, &diff).unwrap();
2404 let output = get_output(presenter);
2405 let output_str = String::from_utf8_lossy(&output);
2406
2407 assert!(output_str.contains('X'));
2409 assert!(output_str.contains("\x1b["), "Expected SGR sequences");
2411 }
2412
2413 #[test]
2414 fn style_transitions_between_different_colors() {
2415 let mut presenter = test_presenter();
2416 let mut buffer = Buffer::new(3, 1);
2417
2418 buffer.set_raw(
2420 0,
2421 0,
2422 Cell::from_char('R').with_fg(PackedRgba::rgb(255, 0, 0)),
2423 );
2424 buffer.set_raw(
2425 1,
2426 0,
2427 Cell::from_char('G').with_fg(PackedRgba::rgb(0, 255, 0)),
2428 );
2429 buffer.set_raw(
2430 2,
2431 0,
2432 Cell::from_char('B').with_fg(PackedRgba::rgb(0, 0, 255)),
2433 );
2434
2435 let old = Buffer::new(3, 1);
2436 let diff = BufferDiff::compute(&old, &buffer);
2437
2438 presenter.present(&buffer, &diff).unwrap();
2439 let output = get_output(presenter);
2440 let output_str = String::from_utf8_lossy(&output);
2441
2442 assert!(output_str.contains("38;2;255;0;0"), "Expected red fg");
2444 assert!(output_str.contains("38;2;0;255;0"), "Expected green fg");
2445 assert!(output_str.contains("38;2;0;0;255"), "Expected blue fg");
2446 }
2447
2448 #[test]
2453 fn link_at_buffer_boundaries() {
2454 let mut presenter = test_presenter_with_hyperlinks();
2455 let mut buffer = Buffer::new(5, 1);
2456 let mut links = LinkRegistry::new();
2457
2458 let link_id = links.register("https://boundary.test");
2459
2460 buffer.set_raw(
2462 0,
2463 0,
2464 Cell::from_char('F').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
2465 );
2466 buffer.set_raw(
2468 4,
2469 0,
2470 Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
2471 );
2472
2473 let old = Buffer::new(5, 1);
2474 let diff = BufferDiff::compute(&old, &buffer);
2475
2476 presenter
2477 .present_with_pool(&buffer, &diff, None, Some(&links))
2478 .unwrap();
2479 let output = get_output(presenter);
2480 let output_str = String::from_utf8_lossy(&output);
2481
2482 assert!(output_str.contains("https://boundary.test"));
2484 assert!(output_str.contains('F'));
2486 assert!(output_str.contains('L'));
2487 }
2488
2489 #[test]
2490 fn link_state_cleared_after_reset() {
2491 let mut presenter = test_presenter();
2492 let mut links = LinkRegistry::new();
2493 let link_id = links.register("https://example.com");
2494
2495 presenter.current_link = Some(link_id);
2497 presenter.current_style = Some(CellStyle::default());
2498 presenter.cursor_x = Some(5);
2499 presenter.cursor_y = Some(3);
2500
2501 presenter.reset();
2502
2503 assert!(
2505 presenter.current_link.is_none(),
2506 "current_link should be None after reset"
2507 );
2508 assert!(
2509 presenter.current_style.is_none(),
2510 "current_style should be None after reset"
2511 );
2512 assert!(
2513 presenter.cursor_x.is_none(),
2514 "cursor_x should be None after reset"
2515 );
2516 assert!(
2517 presenter.cursor_y.is_none(),
2518 "cursor_y should be None after reset"
2519 );
2520 }
2521
2522 #[test]
2523 fn link_transitions_linked_unlinked_linked() {
2524 let mut presenter = test_presenter_with_hyperlinks();
2525 let mut buffer = Buffer::new(5, 1);
2526 let mut links = LinkRegistry::new();
2527
2528 let link_id = links.register("https://toggle.test");
2529
2530 buffer.set_raw(
2532 0,
2533 0,
2534 Cell::from_char('A').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
2535 );
2536 buffer.set_raw(1, 0, Cell::from_char('B')); buffer.set_raw(
2538 2,
2539 0,
2540 Cell::from_char('C').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
2541 );
2542
2543 let old = Buffer::new(5, 1);
2544 let diff = BufferDiff::compute(&old, &buffer);
2545
2546 presenter
2547 .present_with_pool(&buffer, &diff, None, Some(&links))
2548 .unwrap();
2549 let output = get_output(presenter);
2550 let output_str = String::from_utf8_lossy(&output);
2551
2552 let url_count = output_str.matches("https://toggle.test").count();
2554 assert!(
2555 url_count >= 2,
2556 "Expected link to open at least twice, got {} occurrences",
2557 url_count
2558 );
2559
2560 let close_count = output_str.matches("\x1b]8;;\x07").count();
2562 assert!(
2563 close_count >= 2,
2564 "Expected at least 2 link closes, got {}",
2565 close_count
2566 );
2567 }
2568
2569 #[test]
2574 fn multiple_presents_maintain_correct_state() {
2575 let mut presenter = test_presenter();
2576 let mut buffer = Buffer::new(10, 1);
2577
2578 buffer.set_raw(0, 0, Cell::from_char('1'));
2580 let old = Buffer::new(10, 1);
2581 let diff = BufferDiff::compute(&old, &buffer);
2582 presenter.present(&buffer, &diff).unwrap();
2583
2584 let prev = buffer.clone();
2586 buffer.set_raw(1, 0, Cell::from_char('2'));
2587 let diff = BufferDiff::compute(&prev, &buffer);
2588 presenter.present(&buffer, &diff).unwrap();
2589
2590 let prev = buffer.clone();
2592 buffer.set_raw(2, 0, Cell::from_char('3'));
2593 let diff = BufferDiff::compute(&prev, &buffer);
2594 presenter.present(&buffer, &diff).unwrap();
2595
2596 let output = get_output(presenter);
2597 let output_str = String::from_utf8_lossy(&output);
2598
2599 assert!(output_str.contains('1'));
2601 assert!(output_str.contains('2'));
2602 assert!(output_str.contains('3'));
2603 }
2604
2605 #[test]
2610 fn sgr_delta_fg_only_change_no_reset() {
2611 let mut presenter = test_presenter();
2613 let mut buffer = Buffer::new(3, 1);
2614
2615 let fg1 = PackedRgba::rgb(255, 0, 0);
2616 let fg2 = PackedRgba::rgb(0, 255, 0);
2617 buffer.set_raw(0, 0, Cell::from_char('A').with_fg(fg1));
2618 buffer.set_raw(1, 0, Cell::from_char('B').with_fg(fg2));
2619
2620 let old = Buffer::new(3, 1);
2621 let diff = BufferDiff::compute(&old, &buffer);
2622
2623 presenter.present(&buffer, &diff).unwrap();
2624 let output = get_output(presenter);
2625 let output_str = String::from_utf8_lossy(&output);
2626
2627 let reset_count = output_str.matches("\x1b[0m").count();
2630 assert_eq!(
2632 reset_count, 2,
2633 "Expected 2 resets (initial + frame end), got {} in: {:?}",
2634 reset_count, output_str
2635 );
2636 }
2637
2638 #[test]
2639 fn sgr_delta_bg_only_change_no_reset() {
2640 let mut presenter = test_presenter();
2641 let mut buffer = Buffer::new(3, 1);
2642
2643 let bg1 = PackedRgba::rgb(0, 0, 255);
2644 let bg2 = PackedRgba::rgb(255, 255, 0);
2645 buffer.set_raw(0, 0, Cell::from_char('A').with_bg(bg1));
2646 buffer.set_raw(1, 0, Cell::from_char('B').with_bg(bg2));
2647
2648 let old = Buffer::new(3, 1);
2649 let diff = BufferDiff::compute(&old, &buffer);
2650
2651 presenter.present(&buffer, &diff).unwrap();
2652 let output = get_output(presenter);
2653 let output_str = String::from_utf8_lossy(&output);
2654
2655 let reset_count = output_str.matches("\x1b[0m").count();
2657 assert_eq!(
2658 reset_count, 2,
2659 "Expected 2 resets, got {} in: {:?}",
2660 reset_count, output_str
2661 );
2662 }
2663
2664 #[test]
2665 fn sgr_delta_attr_addition_no_reset() {
2666 let mut presenter = test_presenter();
2667 let mut buffer = Buffer::new(3, 1);
2668
2669 let attrs1 = CellAttrs::new(StyleFlags::BOLD, 0);
2671 let attrs2 = CellAttrs::new(StyleFlags::BOLD | StyleFlags::ITALIC, 0);
2672 buffer.set_raw(0, 0, Cell::from_char('A').with_attrs(attrs1));
2673 buffer.set_raw(1, 0, Cell::from_char('B').with_attrs(attrs2));
2674
2675 let old = Buffer::new(3, 1);
2676 let diff = BufferDiff::compute(&old, &buffer);
2677
2678 presenter.present(&buffer, &diff).unwrap();
2679 let output = get_output(presenter);
2680 let output_str = String::from_utf8_lossy(&output);
2681
2682 let reset_count = output_str.matches("\x1b[0m").count();
2684 assert_eq!(
2685 reset_count, 2,
2686 "Expected 2 resets, got {} in: {:?}",
2687 reset_count, output_str
2688 );
2689 assert!(
2691 output_str.contains("\x1b[3m"),
2692 "Expected italic-on sequence in: {:?}",
2693 output_str
2694 );
2695 }
2696
2697 #[test]
2698 fn sgr_delta_attr_removal_uses_off_code() {
2699 let mut presenter = test_presenter();
2700 let mut buffer = Buffer::new(3, 1);
2701
2702 let attrs1 = CellAttrs::new(StyleFlags::BOLD | StyleFlags::ITALIC, 0);
2704 let attrs2 = CellAttrs::new(StyleFlags::BOLD, 0);
2705 buffer.set_raw(0, 0, Cell::from_char('A').with_attrs(attrs1));
2706 buffer.set_raw(1, 0, Cell::from_char('B').with_attrs(attrs2));
2707
2708 let old = Buffer::new(3, 1);
2709 let diff = BufferDiff::compute(&old, &buffer);
2710
2711 presenter.present(&buffer, &diff).unwrap();
2712 let output = get_output(presenter);
2713 let output_str = String::from_utf8_lossy(&output);
2714
2715 assert!(
2717 output_str.contains("\x1b[23m"),
2718 "Expected italic-off sequence in: {:?}",
2719 output_str
2720 );
2721 let reset_count = output_str.matches("\x1b[0m").count();
2723 assert_eq!(
2724 reset_count, 2,
2725 "Expected 2 resets, got {} in: {:?}",
2726 reset_count, output_str
2727 );
2728 }
2729
2730 #[test]
2731 fn sgr_delta_bold_dim_collateral_re_enables() {
2732 let mut presenter = test_presenter();
2735 let mut buffer = Buffer::new(3, 1);
2736
2737 let attrs1 = CellAttrs::new(StyleFlags::BOLD | StyleFlags::DIM, 0);
2739 let attrs2 = CellAttrs::new(StyleFlags::DIM, 0);
2740 buffer.set_raw(0, 0, Cell::from_char('A').with_attrs(attrs1));
2741 buffer.set_raw(1, 0, Cell::from_char('B').with_attrs(attrs2));
2742
2743 let old = Buffer::new(3, 1);
2744 let diff = BufferDiff::compute(&old, &buffer);
2745
2746 presenter.present(&buffer, &diff).unwrap();
2747 let output = get_output(presenter);
2748 let output_str = String::from_utf8_lossy(&output);
2749
2750 assert!(
2752 output_str.contains("\x1b[22m"),
2753 "Expected bold-off (22) in: {:?}",
2754 output_str
2755 );
2756 assert!(
2757 output_str.contains("\x1b[2m"),
2758 "Expected dim re-enable (2) in: {:?}",
2759 output_str
2760 );
2761 }
2762
2763 #[test]
2764 fn sgr_delta_same_style_no_output() {
2765 let mut presenter = test_presenter();
2766 let mut buffer = Buffer::new(3, 1);
2767
2768 let fg = PackedRgba::rgb(255, 0, 0);
2769 let attrs = CellAttrs::new(StyleFlags::BOLD, 0);
2770 buffer.set_raw(0, 0, Cell::from_char('A').with_fg(fg).with_attrs(attrs));
2771 buffer.set_raw(1, 0, Cell::from_char('B').with_fg(fg).with_attrs(attrs));
2772 buffer.set_raw(2, 0, Cell::from_char('C').with_fg(fg).with_attrs(attrs));
2773
2774 let old = Buffer::new(3, 1);
2775 let diff = BufferDiff::compute(&old, &buffer);
2776
2777 presenter.present(&buffer, &diff).unwrap();
2778 let output = get_output(presenter);
2779 let output_str = String::from_utf8_lossy(&output);
2780
2781 let fg_count = output_str.matches("38;2;255;0;0").count();
2783 assert_eq!(
2784 fg_count, 1,
2785 "Expected 1 fg sequence, got {} in: {:?}",
2786 fg_count, output_str
2787 );
2788 }
2789
2790 #[test]
2791 fn sgr_delta_cost_dominance_never_exceeds_baseline() {
2792 let transitions: Vec<(CellStyle, CellStyle)> = vec![
2795 (
2797 CellStyle {
2798 fg: PackedRgba::rgb(255, 0, 0),
2799 bg: PackedRgba::TRANSPARENT,
2800 attrs: StyleFlags::empty(),
2801 },
2802 CellStyle {
2803 fg: PackedRgba::rgb(0, 255, 0),
2804 bg: PackedRgba::TRANSPARENT,
2805 attrs: StyleFlags::empty(),
2806 },
2807 ),
2808 (
2810 CellStyle {
2811 fg: PackedRgba::TRANSPARENT,
2812 bg: PackedRgba::rgb(255, 0, 0),
2813 attrs: StyleFlags::empty(),
2814 },
2815 CellStyle {
2816 fg: PackedRgba::TRANSPARENT,
2817 bg: PackedRgba::rgb(0, 0, 255),
2818 attrs: StyleFlags::empty(),
2819 },
2820 ),
2821 (
2823 CellStyle {
2824 fg: PackedRgba::rgb(100, 100, 100),
2825 bg: PackedRgba::TRANSPARENT,
2826 attrs: StyleFlags::BOLD,
2827 },
2828 CellStyle {
2829 fg: PackedRgba::rgb(100, 100, 100),
2830 bg: PackedRgba::TRANSPARENT,
2831 attrs: StyleFlags::BOLD | StyleFlags::ITALIC,
2832 },
2833 ),
2834 (
2836 CellStyle {
2837 fg: PackedRgba::rgb(100, 100, 100),
2838 bg: PackedRgba::TRANSPARENT,
2839 attrs: StyleFlags::BOLD | StyleFlags::ITALIC,
2840 },
2841 CellStyle {
2842 fg: PackedRgba::rgb(100, 100, 100),
2843 bg: PackedRgba::TRANSPARENT,
2844 attrs: StyleFlags::BOLD,
2845 },
2846 ),
2847 ];
2848
2849 for (old_style, new_style) in &transitions {
2850 let delta_buf = {
2852 let mut delta_presenter = {
2853 let caps = TerminalCapabilities::basic();
2854 Presenter::new(Vec::new(), caps)
2855 };
2856 delta_presenter.current_style = Some(*old_style);
2857 delta_presenter
2858 .emit_style_delta(*old_style, *new_style)
2859 .unwrap();
2860 delta_presenter.into_inner().unwrap()
2861 };
2862
2863 let reset_buf = {
2865 let mut reset_presenter = {
2866 let caps = TerminalCapabilities::basic();
2867 Presenter::new(Vec::new(), caps)
2868 };
2869 reset_presenter.emit_style_full(*new_style).unwrap();
2870 reset_presenter.into_inner().unwrap()
2871 };
2872
2873 assert!(
2874 delta_buf.len() <= reset_buf.len(),
2875 "Delta ({} bytes) exceeded reset+apply ({} bytes) for {:?} -> {:?}.\n\
2876 Delta: {:?}\nReset: {:?}",
2877 delta_buf.len(),
2878 reset_buf.len(),
2879 old_style,
2880 new_style,
2881 String::from_utf8_lossy(&delta_buf),
2882 String::from_utf8_lossy(&reset_buf),
2883 );
2884 }
2885 }
2886
2887 #[test]
2894 fn sgr_delta_evidence_ledger() {
2895 use std::io::Write as _;
2896
2897 const SEED: u64 = 0xDEAD_BEEF_CAFE;
2899
2900 let mut rng_state = SEED;
2902 let mut next_u64 = || -> u64 {
2903 rng_state = rng_state.wrapping_mul(6364136223846793005).wrapping_add(1);
2904 rng_state
2905 };
2906
2907 let random_style = |rng: &mut dyn FnMut() -> u64| -> CellStyle {
2908 let v = rng();
2909 let fg = if v & 1 == 0 {
2910 PackedRgba::TRANSPARENT
2911 } else {
2912 let r = ((v >> 8) & 0xFF) as u8;
2913 let g = ((v >> 16) & 0xFF) as u8;
2914 let b = ((v >> 24) & 0xFF) as u8;
2915 PackedRgba::rgb(r, g, b)
2916 };
2917 let v2 = rng();
2918 let bg = if v2 & 1 == 0 {
2919 PackedRgba::TRANSPARENT
2920 } else {
2921 let r = ((v2 >> 8) & 0xFF) as u8;
2922 let g = ((v2 >> 16) & 0xFF) as u8;
2923 let b = ((v2 >> 24) & 0xFF) as u8;
2924 PackedRgba::rgb(r, g, b)
2925 };
2926 let attrs = StyleFlags::from_bits_truncate(rng() as u8);
2927 CellStyle { fg, bg, attrs }
2928 };
2929
2930 let mut ledger = Vec::new();
2931 let num_transitions = 200;
2932
2933 for i in 0..num_transitions {
2934 let old_style = random_style(&mut next_u64);
2935 let new_style = random_style(&mut next_u64);
2936
2937 let mut delta_p = {
2939 let caps = TerminalCapabilities::basic();
2940 Presenter::new(Vec::new(), caps)
2941 };
2942 delta_p.current_style = Some(old_style);
2943 delta_p.emit_style_delta(old_style, new_style).unwrap();
2944 let delta_out = delta_p.into_inner().unwrap();
2945
2946 let mut reset_p = {
2948 let caps = TerminalCapabilities::basic();
2949 Presenter::new(Vec::new(), caps)
2950 };
2951 reset_p.emit_style_full(new_style).unwrap();
2952 let reset_out = reset_p.into_inner().unwrap();
2953
2954 let delta_bytes = delta_out.len();
2955 let baseline_bytes = reset_out.len();
2956
2957 let attrs_removed = old_style.attrs & !new_style.attrs;
2959 let removed_count = attrs_removed.bits().count_ones();
2960 let fg_changed = old_style.fg != new_style.fg;
2961 let bg_changed = old_style.bg != new_style.bg;
2962 let used_fallback = removed_count >= 3 && fg_changed && bg_changed;
2963
2964 assert!(
2966 delta_bytes <= baseline_bytes,
2967 "Transition {i}: delta ({delta_bytes}B) > baseline ({baseline_bytes}B)"
2968 );
2969
2970 writeln!(
2972 &mut ledger,
2973 "{{\"seed\":{SEED},\"i\":{i},\"from_fg\":\"{:?}\",\"from_bg\":\"{:?}\",\
2974 \"from_attrs\":{},\"to_fg\":\"{:?}\",\"to_bg\":\"{:?}\",\"to_attrs\":{},\
2975 \"delta_bytes\":{delta_bytes},\"baseline_bytes\":{baseline_bytes},\
2976 \"cost_delta\":{},\"used_fallback\":{used_fallback}}}",
2977 old_style.fg,
2978 old_style.bg,
2979 old_style.attrs.bits(),
2980 new_style.fg,
2981 new_style.bg,
2982 new_style.attrs.bits(),
2983 baseline_bytes as isize - delta_bytes as isize,
2984 )
2985 .unwrap();
2986 }
2987
2988 let text = String::from_utf8(ledger).unwrap();
2990 let lines: Vec<&str> = text.lines().collect();
2991 assert_eq!(lines.len(), num_transitions);
2992
2993 let mut total_saved: isize = 0;
2995 for line in &lines {
2996 let cd_start = line.find("\"cost_delta\":").unwrap() + 13;
2998 let cd_end = line[cd_start..].find(',').unwrap() + cd_start;
2999 let cd: isize = line[cd_start..cd_end].parse().unwrap();
3000 total_saved += cd;
3001 }
3002 assert!(
3003 total_saved >= 0,
3004 "Total byte savings should be non-negative, got {total_saved}"
3005 );
3006 }
3007
3008 #[test]
3011 fn e2e_style_stress_with_byte_metrics() {
3012 let width = 40u16;
3013 let height = 10u16;
3014
3015 let mut buffer = Buffer::new(width, height);
3017 for y in 0..height {
3018 for x in 0..width {
3019 let i = (y as usize * width as usize + x as usize) as u8;
3020 let fg = PackedRgba::rgb(i, 255 - i, i.wrapping_mul(3));
3021 let bg = if i.is_multiple_of(4) {
3022 PackedRgba::rgb(i.wrapping_mul(7), i.wrapping_mul(11), i.wrapping_mul(13))
3023 } else {
3024 PackedRgba::TRANSPARENT
3025 };
3026 let flags = StyleFlags::from_bits_truncate(i % 128);
3027 let ch = char::from_u32(('!' as u32) + (i as u32 % 90)).unwrap_or('?');
3028 let cell = Cell::from_char(ch)
3029 .with_fg(fg)
3030 .with_bg(bg)
3031 .with_attrs(CellAttrs::new(flags, 0));
3032 buffer.set_raw(x, y, cell);
3033 }
3034 }
3035
3036 let blank = Buffer::new(width, height);
3038 let diff = BufferDiff::compute(&blank, &buffer);
3039 let mut presenter = test_presenter();
3040 presenter.present(&buffer, &diff).unwrap();
3041 let frame1_bytes = presenter.into_inner().unwrap().len();
3042
3043 let mut buffer2 = Buffer::new(width, height);
3045 for y in 0..height {
3046 for x in 0..width {
3047 let i = (y as usize * width as usize + x as usize + 1) as u8;
3048 let fg = PackedRgba::rgb(i, 255 - i, i.wrapping_mul(3));
3049 let bg = if i.is_multiple_of(4) {
3050 PackedRgba::rgb(i.wrapping_mul(7), i.wrapping_mul(11), i.wrapping_mul(13))
3051 } else {
3052 PackedRgba::TRANSPARENT
3053 };
3054 let flags = StyleFlags::from_bits_truncate(i % 128);
3055 let ch = char::from_u32(('!' as u32) + (i as u32 % 90)).unwrap_or('?');
3056 let cell = Cell::from_char(ch)
3057 .with_fg(fg)
3058 .with_bg(bg)
3059 .with_attrs(CellAttrs::new(flags, 0));
3060 buffer2.set_raw(x, y, cell);
3061 }
3062 }
3063
3064 let diff2 = BufferDiff::compute(&buffer, &buffer2);
3066 let mut presenter2 = test_presenter();
3067 presenter2.present(&buffer2, &diff2).unwrap();
3068 let frame2_bytes = presenter2.into_inner().unwrap().len();
3069
3070 assert!(
3073 frame2_bytes > 0,
3074 "Second frame should produce output for style churn"
3075 );
3076 assert!(!diff2.is_empty(), "Style shift should produce changes");
3077
3078 assert!(
3083 frame2_bytes <= frame1_bytes * 2,
3084 "Incremental frame ({frame2_bytes}B) unreasonably large vs full ({frame1_bytes}B)"
3085 );
3086 }
3087
3088 #[test]
3093 fn cost_model_empty_row_single_run() {
3094 let runs = [ChangeRun::new(5, 10, 20)];
3096 let plan = cost_model::plan_row(&runs, None, None);
3097 assert_eq!(plan.spans().len(), 1);
3098 assert_eq!(plan.spans()[0].x0, 10);
3099 assert_eq!(plan.spans()[0].x1, 20);
3100 assert!(plan.total_cost() > 0);
3101 }
3102
3103 #[test]
3104 fn cost_model_full_row_merges() {
3105 let runs = [ChangeRun::new(0, 0, 2), ChangeRun::new(0, 77, 79)];
3111 let plan = cost_model::plan_row(&runs, None, None);
3112 assert_eq!(plan.spans().len(), 2);
3114 assert_eq!(plan.spans()[0].x0, 0);
3115 assert_eq!(plan.spans()[0].x1, 2);
3116 assert_eq!(plan.spans()[1].x0, 77);
3117 assert_eq!(plan.spans()[1].x1, 79);
3118 }
3119
3120 #[test]
3121 fn cost_model_adjacent_runs_merge() {
3122 let runs = [
3125 ChangeRun::new(3, 10, 10),
3126 ChangeRun::new(3, 12, 12),
3127 ChangeRun::new(3, 14, 14),
3128 ChangeRun::new(3, 16, 16),
3129 ChangeRun::new(3, 18, 18),
3130 ChangeRun::new(3, 20, 20),
3131 ChangeRun::new(3, 22, 22),
3132 ChangeRun::new(3, 24, 24),
3133 ];
3134 let plan = cost_model::plan_row(&runs, None, None);
3135 assert_eq!(plan.spans().len(), 1);
3138 assert_eq!(plan.spans()[0].x0, 10);
3139 assert_eq!(plan.spans()[0].x1, 24);
3140 }
3141
3142 #[test]
3143 fn cost_model_single_cell_stays_sparse() {
3144 let runs = [ChangeRun::new(0, 40, 40)];
3145 let plan = cost_model::plan_row(&runs, Some(0), Some(0));
3146 assert_eq!(plan.spans().len(), 1);
3147 assert_eq!(plan.spans()[0].x0, 40);
3148 assert_eq!(plan.spans()[0].x1, 40);
3149 }
3150
3151 #[test]
3152 fn cost_model_cup_vs_cha_vs_cuf() {
3153 assert!(cost_model::cuf_cost(1) <= cost_model::cha_cost(5));
3155 assert!(cost_model::cuf_cost(3) <= cost_model::cup_cost(0, 5));
3156
3157 let cha = cost_model::cha_cost(5);
3159 let cup = cost_model::cup_cost(0, 5);
3160 assert!(cha <= cup);
3161
3162 let cost = cost_model::cheapest_move_cost(Some(5), Some(0), 6, 0);
3164 assert_eq!(cost, 3); }
3166
3167 #[test]
3168 fn cost_model_digit_estimation_accuracy() {
3169 let mut buf = Vec::new();
3171 ansi::cup(&mut buf, 0, 0).unwrap();
3172 assert_eq!(buf.len(), cost_model::cup_cost(0, 0));
3173
3174 buf.clear();
3175 ansi::cup(&mut buf, 9, 9).unwrap();
3176 assert_eq!(buf.len(), cost_model::cup_cost(9, 9));
3177
3178 buf.clear();
3179 ansi::cup(&mut buf, 99, 99).unwrap();
3180 assert_eq!(buf.len(), cost_model::cup_cost(99, 99));
3181
3182 buf.clear();
3183 ansi::cha(&mut buf, 0).unwrap();
3184 assert_eq!(buf.len(), cost_model::cha_cost(0));
3185
3186 buf.clear();
3187 ansi::cuf(&mut buf, 1).unwrap();
3188 assert_eq!(buf.len(), cost_model::cuf_cost(1));
3189
3190 buf.clear();
3191 ansi::cuf(&mut buf, 10).unwrap();
3192 assert_eq!(buf.len(), cost_model::cuf_cost(10));
3193 }
3194
3195 #[test]
3196 fn cost_model_merged_row_produces_correct_output() {
3197 let width = 30u16;
3199 let mut buffer = Buffer::new(width, 1);
3200
3201 for col in [5u16, 10, 15, 20] {
3203 let ch = char::from_u32('A' as u32 + col as u32 % 26).unwrap();
3204 buffer.set_raw(col, 0, Cell::from_char(ch));
3205 }
3206
3207 let old = Buffer::new(width, 1);
3208 let diff = BufferDiff::compute(&old, &buffer);
3209
3210 let mut presenter = test_presenter();
3212 presenter.present(&buffer, &diff).unwrap();
3213 let output = presenter.into_inner().unwrap();
3214 let output_str = String::from_utf8_lossy(&output);
3215
3216 for col in [5u16, 10, 15, 20] {
3217 let ch = char::from_u32('A' as u32 + col as u32 % 26).unwrap();
3218 assert!(
3219 output_str.contains(ch),
3220 "Missing character '{ch}' at col {col} in output"
3221 );
3222 }
3223 }
3224
3225 #[test]
3226 fn cost_model_optimal_cursor_uses_cuf_on_same_row() {
3227 let mut presenter = test_presenter();
3229 presenter.cursor_x = Some(5);
3230 presenter.cursor_y = Some(0);
3231 presenter.move_cursor_optimal(6, 0).unwrap();
3232 let output = presenter.into_inner().unwrap();
3233 assert_eq!(&output, b"\x1b[C", "Should use CUF for +1 column move");
3235 }
3236
3237 #[test]
3238 fn cost_model_optimal_cursor_uses_cha_on_same_row_backward() {
3239 let mut presenter = test_presenter();
3240 presenter.cursor_x = Some(10);
3241 presenter.cursor_y = Some(3);
3242
3243 let target_x = 2;
3244 let target_y = 3;
3245 let cha_cost = cost_model::cha_cost(target_x);
3246 let cup_cost = cost_model::cup_cost(target_y, target_x);
3247 assert!(
3248 cha_cost <= cup_cost,
3249 "Expected CHA to be cheaper for backward move (cha={cha_cost}, cup={cup_cost})"
3250 );
3251
3252 presenter.move_cursor_optimal(target_x, target_y).unwrap();
3253 let output = presenter.into_inner().unwrap();
3254 let mut expected = Vec::new();
3255 ansi::cha(&mut expected, target_x).unwrap();
3256 assert_eq!(output, expected, "Should use CHA for backward move");
3257 }
3258
3259 #[test]
3260 fn cost_model_optimal_cursor_uses_cup_on_row_change() {
3261 let mut presenter = test_presenter();
3262 presenter.cursor_x = Some(4);
3263 presenter.cursor_y = Some(1);
3264
3265 presenter.move_cursor_optimal(7, 4).unwrap();
3266 let output = presenter.into_inner().unwrap();
3267 let mut expected = Vec::new();
3268 ansi::cup(&mut expected, 4, 7).unwrap();
3269 assert_eq!(output, expected, "Should use CUP when row changes");
3270 }
3271
3272 #[test]
3273 fn cost_model_chooses_full_row_when_cheaper() {
3274 let width = 40u16;
3277 let mut buffer = Buffer::new(width, 1);
3278
3279 for col in (0..20).step_by(2) {
3281 buffer.set_raw(col, 0, Cell::from_char('X'));
3282 }
3283
3284 let old = Buffer::new(width, 1);
3285 let diff = BufferDiff::compute(&old, &buffer);
3286 let runs = diff.runs();
3287
3288 let row_runs: Vec<_> = runs.iter().filter(|r| r.y == 0).copied().collect();
3290 if row_runs.len() > 1 {
3291 let plan = cost_model::plan_row(&row_runs, None, None);
3292 assert!(
3293 plan.spans().len() == 1,
3294 "Expected single merged span for many small runs, got {} spans",
3295 plan.spans().len()
3296 );
3297 assert_eq!(plan.spans()[0].x0, 0);
3298 assert_eq!(plan.spans()[0].x1, 18);
3299 }
3300 }
3301
3302 #[test]
3303 fn perf_cost_model_overhead() {
3304 use std::time::Instant;
3306
3307 let runs: Vec<ChangeRun> = (0..100)
3308 .map(|i| ChangeRun::new(0, i * 3, i * 3 + 1))
3309 .collect();
3310
3311 let (iterations, max_ms) = if cfg!(debug_assertions) {
3312 (1_000, 1_000u128)
3313 } else {
3314 (10_000, 500u128)
3315 };
3316
3317 let start = Instant::now();
3318 for _ in 0..iterations {
3319 let _ = cost_model::plan_row(&runs, None, None);
3320 }
3321 let elapsed = start.elapsed();
3322
3323 assert!(
3325 elapsed.as_millis() < max_ms,
3326 "Cost model planning too slow: {elapsed:?} for {iterations} iterations"
3327 );
3328 }
3329
3330 #[test]
3331 fn perf_legacy_vs_dp_worst_case_sparse() {
3332 use std::time::Instant;
3333
3334 let width = 200u16;
3335 let height = 1u16;
3336 let mut buffer = Buffer::new(width, height);
3337
3338 for col in (0..40).step_by(2) {
3340 buffer.set_raw(col, 0, Cell::from_char('X'));
3341 }
3342 for col in (160..200).step_by(2) {
3343 buffer.set_raw(col, 0, Cell::from_char('Y'));
3344 }
3345
3346 let blank = Buffer::new(width, height);
3347 let diff = BufferDiff::compute(&blank, &buffer);
3348 let runs = diff.runs();
3349 let row_runs: Vec<_> = runs.iter().filter(|r| r.y == 0).copied().collect();
3350
3351 let dp_plan = cost_model::plan_row(&row_runs, None, None);
3352 let legacy_spans = legacy_plan_row(&row_runs, None, None);
3353
3354 let dp_output = emit_spans_for_output(&buffer, dp_plan.spans());
3355 let legacy_output = emit_spans_for_output(&buffer, &legacy_spans);
3356
3357 assert!(
3358 dp_output.len() <= legacy_output.len(),
3359 "DP output should be <= legacy output (dp={}, legacy={})",
3360 dp_output.len(),
3361 legacy_output.len()
3362 );
3363
3364 let (iterations, max_ms) = if cfg!(debug_assertions) {
3365 (1_000, 1_000u128)
3366 } else {
3367 (10_000, 500u128)
3368 };
3369 let start = Instant::now();
3370 for _ in 0..iterations {
3371 let _ = cost_model::plan_row(&row_runs, None, None);
3372 }
3373 let dp_elapsed = start.elapsed();
3374
3375 let start = Instant::now();
3376 for _ in 0..iterations {
3377 let _ = legacy_plan_row(&row_runs, None, None);
3378 }
3379 let legacy_elapsed = start.elapsed();
3380
3381 assert!(
3382 dp_elapsed.as_millis() < max_ms,
3383 "DP planning too slow: {dp_elapsed:?} for {iterations} iterations"
3384 );
3385
3386 let _ = legacy_elapsed;
3387 }
3388
3389 fn build_style_heavy_scene(width: u16, height: u16, seed: u64) -> Buffer {
3395 let mut buffer = Buffer::new(width, height);
3396 let mut rng = seed;
3397 let mut next = || -> u64 {
3398 rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1);
3399 rng
3400 };
3401 for y in 0..height {
3402 for x in 0..width {
3403 let v = next();
3404 let ch = char::from_u32(('!' as u32) + (v as u32 % 90)).unwrap_or('?');
3405 let fg = PackedRgba::rgb((v >> 8) as u8, (v >> 16) as u8, (v >> 24) as u8);
3406 let bg = if v & 3 == 0 {
3407 PackedRgba::rgb((v >> 32) as u8, (v >> 40) as u8, (v >> 48) as u8)
3408 } else {
3409 PackedRgba::TRANSPARENT
3410 };
3411 let flags = StyleFlags::from_bits_truncate((v >> 56) as u8);
3412 let cell = Cell::from_char(ch)
3413 .with_fg(fg)
3414 .with_bg(bg)
3415 .with_attrs(CellAttrs::new(flags, 0));
3416 buffer.set_raw(x, y, cell);
3417 }
3418 }
3419 buffer
3420 }
3421
3422 fn build_sparse_update(base: &Buffer, seed: u64) -> Buffer {
3424 let mut buffer = base.clone();
3425 let width = base.width();
3426 let height = base.height();
3427 let mut rng = seed;
3428 let mut next = || -> u64 {
3429 rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1);
3430 rng
3431 };
3432 let change_count = (width as usize * height as usize) / 10;
3433 for _ in 0..change_count {
3434 let v = next();
3435 let x = (v % width as u64) as u16;
3436 let y = ((v >> 16) % height as u64) as u16;
3437 let ch = char::from_u32(('A' as u32) + (v as u32 % 26)).unwrap_or('?');
3438 buffer.set_raw(x, y, Cell::from_char(ch));
3439 }
3440 buffer
3441 }
3442
3443 #[test]
3444 fn snapshot_presenter_equivalence() {
3445 let buffer = build_style_heavy_scene(40, 10, 0xDEAD_CAFE_1234);
3448 let blank = Buffer::new(40, 10);
3449 let diff = BufferDiff::compute(&blank, &buffer);
3450
3451 let mut presenter = test_presenter();
3452 presenter.present(&buffer, &diff).unwrap();
3453 let output = presenter.into_inner().unwrap();
3454
3455 let checksum = {
3457 let mut hash: u64 = 0xcbf29ce484222325; for &byte in &output {
3459 hash ^= byte as u64;
3460 hash = hash.wrapping_mul(0x100000001b3); }
3462 hash
3463 };
3464
3465 let mut presenter2 = test_presenter();
3467 presenter2.present(&buffer, &diff).unwrap();
3468 let output2 = presenter2.into_inner().unwrap();
3469 assert_eq!(output, output2, "Presenter output must be deterministic");
3470
3471 let _ = checksum; }
3474
3475 #[test]
3476 fn perf_presenter_microbench() {
3477 use std::env;
3478 use std::io::Write as _;
3479 use std::time::Instant;
3480
3481 let width = 120u16;
3482 let height = 40u16;
3483 let seed = 0x00BE_EFCA_FE42;
3484 let scene = build_style_heavy_scene(width, height, seed);
3485 let blank = Buffer::new(width, height);
3486 let diff_full = BufferDiff::compute(&blank, &scene);
3487
3488 let scene2 = build_sparse_update(&scene, seed.wrapping_add(1));
3490 let diff_sparse = BufferDiff::compute(&scene, &scene2);
3491
3492 let mut jsonl = Vec::new();
3493 let iterations = env::var("FTUI_PRESENTER_BENCH_ITERS")
3494 .ok()
3495 .and_then(|value| value.parse::<u32>().ok())
3496 .unwrap_or(50);
3497
3498 let runs_full = diff_full.runs();
3499 let runs_sparse = diff_sparse.runs();
3500
3501 let plan_rows = |runs: &[ChangeRun]| -> (usize, usize) {
3502 let mut idx = 0;
3503 let mut total_cost = 0usize;
3504 let mut span_count = 0usize;
3505 let mut prev_x = None;
3506 let mut prev_y = None;
3507
3508 while idx < runs.len() {
3509 let y = runs[idx].y;
3510 let start = idx;
3511 while idx < runs.len() && runs[idx].y == y {
3512 idx += 1;
3513 }
3514
3515 let plan = cost_model::plan_row(&runs[start..idx], prev_x, prev_y);
3516 span_count += plan.spans().len();
3517 total_cost = total_cost.saturating_add(plan.total_cost());
3518 if let Some(last) = plan.spans().last() {
3519 prev_x = Some(last.x1);
3520 prev_y = Some(y);
3521 }
3522 }
3523
3524 (total_cost, span_count)
3525 };
3526
3527 for i in 0..iterations {
3528 let (diff_ref, buf_ref, runs_ref, label) = if i % 2 == 0 {
3529 (&diff_full, &scene, &runs_full, "full")
3530 } else {
3531 (&diff_sparse, &scene2, &runs_sparse, "sparse")
3532 };
3533
3534 let plan_start = Instant::now();
3535 let (plan_cost, plan_spans) = plan_rows(runs_ref);
3536 let plan_time_us = plan_start.elapsed().as_micros() as u64;
3537
3538 let mut presenter = test_presenter();
3539 let start = Instant::now();
3540 let stats = presenter.present(buf_ref, diff_ref).unwrap();
3541 let elapsed_us = start.elapsed().as_micros() as u64;
3542 let output = presenter.into_inner().unwrap();
3543
3544 let checksum = {
3546 let mut hash: u64 = 0xcbf29ce484222325;
3547 for &b in &output {
3548 hash ^= b as u64;
3549 hash = hash.wrapping_mul(0x100000001b3);
3550 }
3551 hash
3552 };
3553
3554 writeln!(
3555 &mut jsonl,
3556 "{{\"seed\":{seed},\"width\":{width},\"height\":{height},\
3557 \"scene\":\"{label}\",\"changes\":{},\"runs\":{},\
3558 \"plan_cost\":{plan_cost},\"plan_spans\":{plan_spans},\
3559 \"plan_time_us\":{plan_time_us},\"bytes\":{},\
3560 \"emit_time_us\":{elapsed_us},\
3561 \"checksum\":\"{checksum:016x}\"}}",
3562 stats.cells_changed, stats.run_count, stats.bytes_emitted,
3563 )
3564 .unwrap();
3565 }
3566
3567 let text = String::from_utf8(jsonl).unwrap();
3568 let lines: Vec<&str> = text.lines().collect();
3569 assert_eq!(lines.len(), iterations as usize);
3570
3571 let full_checksums: Vec<&str> = lines
3573 .iter()
3574 .filter(|l| l.contains("\"full\""))
3575 .map(|l| {
3576 let start = l.find("\"checksum\":\"").unwrap() + 12;
3577 let end = l[start..].find('"').unwrap() + start;
3578 &l[start..end]
3579 })
3580 .collect();
3581 assert!(full_checksums.len() > 1);
3582 assert!(
3583 full_checksums.windows(2).all(|w| w[0] == w[1]),
3584 "Full frame checksums should be identical across runs"
3585 );
3586
3587 let full_bytes: Vec<u64> = lines
3589 .iter()
3590 .filter(|l| l.contains("\"full\""))
3591 .map(|l| {
3592 let start = l.find("\"bytes\":").unwrap() + 8;
3593 let end = l[start..].find(',').unwrap() + start;
3594 l[start..end].parse::<u64>().unwrap()
3595 })
3596 .collect();
3597 let sparse_bytes: Vec<u64> = lines
3598 .iter()
3599 .filter(|l| l.contains("\"sparse\""))
3600 .map(|l| {
3601 let start = l.find("\"bytes\":").unwrap() + 8;
3602 let end = l[start..].find(',').unwrap() + start;
3603 l[start..end].parse::<u64>().unwrap()
3604 })
3605 .collect();
3606
3607 let avg_full: u64 = full_bytes.iter().sum::<u64>() / full_bytes.len() as u64;
3608 let avg_sparse: u64 = sparse_bytes.iter().sum::<u64>() / sparse_bytes.len() as u64;
3609 assert!(
3610 avg_sparse < avg_full,
3611 "Sparse updates ({avg_sparse}B) should emit fewer bytes than full ({avg_full}B)"
3612 );
3613 }
3614
3615 #[test]
3616 fn perf_emit_style_delta_microbench() {
3617 use std::env;
3618 use std::io::Write as _;
3619 use std::time::Instant;
3620
3621 let iterations = env::var("FTUI_EMIT_STYLE_BENCH_ITERS")
3622 .ok()
3623 .and_then(|value| value.parse::<u32>().ok())
3624 .unwrap_or(200);
3625 let mode = env::var("FTUI_EMIT_STYLE_BENCH_MODE").unwrap_or_default();
3626 let emit_json = mode != "raw";
3627
3628 let mut styles = Vec::with_capacity(128);
3629 let mut rng = 0x00A5_A51E_AF42_u64;
3630 let mut next = || -> u64 {
3631 rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1);
3632 rng
3633 };
3634
3635 for _ in 0..128 {
3636 let v = next();
3637 let fg = PackedRgba::rgb(
3638 (v & 0xFF) as u8,
3639 ((v >> 8) & 0xFF) as u8,
3640 ((v >> 16) & 0xFF) as u8,
3641 );
3642 let bg = PackedRgba::rgb(
3643 ((v >> 24) & 0xFF) as u8,
3644 ((v >> 32) & 0xFF) as u8,
3645 ((v >> 40) & 0xFF) as u8,
3646 );
3647 let flags = StyleFlags::from_bits_truncate((v >> 48) as u8);
3648 let cell = Cell::from_char('A')
3649 .with_fg(fg)
3650 .with_bg(bg)
3651 .with_attrs(CellAttrs::new(flags, 0));
3652 styles.push(CellStyle::from_cell(&cell));
3653 }
3654
3655 let mut presenter = test_presenter();
3656 let mut jsonl = Vec::new();
3657 let mut sink = 0u64;
3658
3659 for i in 0..iterations {
3660 let old = styles[i as usize % styles.len()];
3661 let new = styles[(i as usize + 1) % styles.len()];
3662
3663 presenter.writer.reset_counter();
3664 presenter.writer.inner_mut().get_mut().clear();
3665
3666 let start = Instant::now();
3667 presenter.emit_style_delta(old, new).unwrap();
3668 let elapsed_us = start.elapsed().as_micros() as u64;
3669 let bytes = presenter.writer.bytes_written();
3670
3671 if emit_json {
3672 writeln!(
3673 &mut jsonl,
3674 "{{\"iter\":{i},\"emit_time_us\":{elapsed_us},\"bytes\":{bytes}}}"
3675 )
3676 .unwrap();
3677 } else {
3678 sink = sink.wrapping_add(elapsed_us ^ bytes);
3679 }
3680 }
3681
3682 if emit_json {
3683 let text = String::from_utf8(jsonl).unwrap();
3684 let lines: Vec<&str> = text.lines().collect();
3685 assert_eq!(lines.len() as u32, iterations);
3686 } else {
3687 std::hint::black_box(sink);
3688 }
3689 }
3690
3691 #[test]
3692 fn e2e_presenter_stress_deterministic() {
3693 use crate::terminal_model::TerminalModel;
3696
3697 let width = 60u16;
3698 let height = 20u16;
3699 let num_frames = 10;
3700
3701 let mut prev_buffer = Buffer::new(width, height);
3702 let mut presenter = test_presenter();
3703 let mut model = TerminalModel::new(width as usize, height as usize);
3704 let mut rng = 0x5D2E_55DE_5D42_u64;
3705 let mut next = || -> u64 {
3706 rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1);
3707 rng
3708 };
3709
3710 for _frame in 0..num_frames {
3711 let mut buffer = prev_buffer.clone();
3713 let changes = (width as usize * height as usize) / 5;
3714 for _ in 0..changes {
3715 let v = next();
3716 let x = (v % width as u64) as u16;
3717 let y = ((v >> 16) % height as u64) as u16;
3718 let ch = char::from_u32(('!' as u32) + (v as u32 % 90)).unwrap_or('?');
3719 let fg = PackedRgba::rgb((v >> 8) as u8, (v >> 24) as u8, (v >> 40) as u8);
3720 let cell = Cell::from_char(ch).with_fg(fg);
3721 buffer.set_raw(x, y, cell);
3722 }
3723
3724 let diff = BufferDiff::compute(&prev_buffer, &buffer);
3725 presenter.present(&buffer, &diff).unwrap();
3726
3727 prev_buffer = buffer;
3728 }
3729
3730 let output = presenter.into_inner().unwrap();
3732 model.process(&output);
3733
3734 let mut checked = 0;
3736 for y in 0..height {
3737 for x in 0..width {
3738 let buf_cell = prev_buffer.get_unchecked(x, y);
3739 if !buf_cell.is_empty()
3740 && let Some(model_cell) = model.cell(x as usize, y as usize)
3741 {
3742 let expected = buf_cell.content.as_char().unwrap_or(' ');
3743 let mut buf = [0u8; 4];
3744 let expected_str = expected.encode_utf8(&mut buf);
3745 if model_cell.text.as_str() == expected_str {
3746 checked += 1;
3747 }
3748 }
3749 }
3750 }
3751
3752 let total_nonempty = (0..height)
3755 .flat_map(|y| (0..width).map(move |x| (x, y)))
3756 .filter(|&(x, y)| !prev_buffer.get_unchecked(x, y).is_empty())
3757 .count();
3758
3759 assert!(
3760 checked > total_nonempty * 80 / 100,
3761 "Frame {num_frames}: only {checked}/{total_nonempty} cells match final buffer"
3762 );
3763 }
3764
3765 #[test]
3766 fn style_state_persists_across_frames() {
3767 let mut presenter = test_presenter();
3768 let fg = PackedRgba::rgb(100, 150, 200);
3769
3770 let mut buffer = Buffer::new(5, 1);
3772 buffer.set_raw(0, 0, Cell::from_char('A').with_fg(fg));
3773 let old = Buffer::new(5, 1);
3774 let diff = BufferDiff::compute(&old, &buffer);
3775 presenter.present(&buffer, &diff).unwrap();
3776
3777 assert!(
3780 presenter.current_style.is_none(),
3781 "Style should be reset after frame end"
3782 );
3783 }
3784
3785 #[test]
3792 fn cost_cup_zero_zero() {
3793 assert_eq!(cost_model::cup_cost(0, 0), 6);
3795 }
3796
3797 #[test]
3798 fn cost_cup_max_max() {
3799 assert_eq!(cost_model::cup_cost(u16::MAX, u16::MAX), 14);
3802 }
3803
3804 #[test]
3805 fn cost_cha_zero() {
3806 assert_eq!(cost_model::cha_cost(0), 4);
3808 }
3809
3810 #[test]
3811 fn cost_cha_max() {
3812 assert_eq!(cost_model::cha_cost(u16::MAX), 8);
3814 }
3815
3816 #[test]
3817 fn cost_cuf_zero_is_free() {
3818 assert_eq!(cost_model::cuf_cost(0), 0);
3819 }
3820
3821 #[test]
3822 fn cost_cuf_one_is_three() {
3823 assert_eq!(cost_model::cuf_cost(1), 3);
3825 }
3826
3827 #[test]
3828 fn cost_cuf_two_has_digit() {
3829 assert_eq!(cost_model::cuf_cost(2), 4);
3831 }
3832
3833 #[test]
3834 fn cost_cuf_max() {
3835 assert_eq!(cost_model::cuf_cost(u16::MAX), 8);
3837 }
3838
3839 #[test]
3840 fn cost_cheapest_move_already_at_target() {
3841 assert_eq!(cost_model::cheapest_move_cost(Some(5), Some(3), 5, 3), 0);
3842 }
3843
3844 #[test]
3845 fn cost_cheapest_move_unknown_position() {
3846 let cost = cost_model::cheapest_move_cost(None, None, 5, 3);
3848 assert_eq!(cost, cost_model::cup_cost(3, 5));
3849 }
3850
3851 #[test]
3852 fn cost_cheapest_move_known_y_unknown_x() {
3853 let cost = cost_model::cheapest_move_cost(None, Some(3), 5, 3);
3855 assert_eq!(cost, cost_model::cup_cost(3, 5));
3856 }
3857
3858 #[test]
3859 fn cost_cheapest_move_backward_same_row() {
3860 let cost = cost_model::cheapest_move_cost(Some(50), Some(0), 5, 0);
3862 let cha = cost_model::cha_cost(5);
3863 let cub = cost_model::cub_cost(45);
3864 assert_eq!(cost, cha.min(cub));
3865 assert!(cost_model::cup_cost(0, 5) > cha);
3866 }
3867
3868 #[test]
3869 fn cost_cheapest_move_forward_same_row() {
3870 let cost = cost_model::cheapest_move_cost(Some(5), Some(0), 50, 0);
3871 let cha = cost_model::cha_cost(50);
3872 let cuf = cost_model::cuf_cost(45);
3873 assert_eq!(cost, cha.min(cuf));
3874 assert!(cost_model::cup_cost(0, 50) > cha);
3875 }
3876
3877 #[test]
3878 fn cost_cheapest_move_same_row_same_col() {
3879 assert_eq!(cost_model::cheapest_move_cost(Some(0), Some(0), 0, 0), 0);
3881 }
3882
3883 #[test]
3886 fn cost_cup_digit_boundaries() {
3887 let mut buf = Vec::new();
3888 for (row, col) in [
3889 (0u16, 0u16),
3890 (8, 8),
3891 (9, 9),
3892 (98, 98),
3893 (99, 99),
3894 (998, 998),
3895 (999, 999),
3896 (9998, 9998),
3897 (9999, 9999),
3898 (u16::MAX, u16::MAX),
3899 ] {
3900 buf.clear();
3901 ansi::cup(&mut buf, row, col).unwrap();
3902 assert_eq!(
3903 buf.len(),
3904 cost_model::cup_cost(row, col),
3905 "CUP cost mismatch at ({row}, {col})"
3906 );
3907 }
3908 }
3909
3910 #[test]
3911 fn cost_cha_digit_boundaries() {
3912 let mut buf = Vec::new();
3913 for col in [0u16, 8, 9, 98, 99, 998, 999, 9998, 9999, u16::MAX] {
3914 buf.clear();
3915 ansi::cha(&mut buf, col).unwrap();
3916 assert_eq!(
3917 buf.len(),
3918 cost_model::cha_cost(col),
3919 "CHA cost mismatch at col {col}"
3920 );
3921 }
3922 }
3923
3924 #[test]
3925 fn cost_cuf_digit_boundaries() {
3926 let mut buf = Vec::new();
3927 for n in [1u16, 2, 9, 10, 99, 100, 999, 1000, 9999, 10000, u16::MAX] {
3928 buf.clear();
3929 ansi::cuf(&mut buf, n).unwrap();
3930 assert_eq!(
3931 buf.len(),
3932 cost_model::cuf_cost(n),
3933 "CUF cost mismatch for n={n}"
3934 );
3935 }
3936 }
3937
3938 #[test]
3941 fn plan_row_reuse_matches_plan_row() {
3942 let runs = [
3943 ChangeRun::new(5, 2, 4),
3944 ChangeRun::new(5, 8, 10),
3945 ChangeRun::new(5, 20, 25),
3946 ];
3947 let plan1 = cost_model::plan_row(&runs, Some(0), Some(5));
3948 let mut scratch = cost_model::RowPlanScratch::default();
3949 let plan2 = cost_model::plan_row_reuse(&runs, Some(0), Some(5), &mut scratch);
3950 assert_eq!(plan1, plan2);
3951 }
3952
3953 #[test]
3954 fn plan_row_reuse_single_run_matches_plan_row() {
3955 let runs = [ChangeRun::new(7, 18, 24)];
3956 let plan1 = cost_model::plan_row(&runs, Some(2), Some(7));
3957 let mut scratch = cost_model::RowPlanScratch::default();
3958 let plan2 = cost_model::plan_row_reuse(&runs, Some(2), Some(7), &mut scratch);
3959 assert_eq!(plan1, plan2);
3960 assert_eq!(
3961 plan2.total_cost(),
3962 cost_model::cheapest_move_cost(Some(2), Some(7), 18, 7) + runs[0].len()
3963 );
3964 }
3965
3966 #[test]
3967 fn emit_diff_runs_single_run_matches_planned_span_output() {
3968 let mut links = LinkRegistry::new();
3969 let link_id = links.register("https://example.com/single-run");
3970 let mut buffer = Buffer::new(16, 3);
3971
3972 for (offset, ch) in ['A', 'B', 'C', 'D'].into_iter().enumerate() {
3973 let x = 4 + offset as u16;
3974 let cell = Cell::from_char(ch)
3975 .with_fg(PackedRgba::rgb(10 + offset as u8, 20, 30))
3976 .with_bg(PackedRgba::rgb(1, 2 + offset as u8, 3))
3977 .with_attrs(CellAttrs::new(StyleFlags::BOLD, link_id));
3978 buffer.set_raw(x, 1, cell);
3979 }
3980
3981 let blank = Buffer::new(16, 3);
3982 let diff = BufferDiff::compute(&blank, &buffer);
3983 let runs = diff.runs();
3984 assert_eq!(runs.len(), 1, "fixture should produce one contiguous run");
3985 let run = runs[0];
3986
3987 let mut fast_path = test_presenter_with_hyperlinks();
3988 fast_path.prepare_runs(&diff);
3989 fast_path
3990 .emit_diff_runs(&buffer, None, Some(&links))
3991 .expect("single-run fast path should emit");
3992 fast_path
3993 .finish_frame()
3994 .expect("single-run fast path cleanup should succeed");
3995 let fast_output = fast_path.into_inner().expect("fast path output");
3996
3997 let planned_output = emit_spans_with_links_for_output(
3998 &buffer,
3999 &[cost_model::RowSpan {
4000 y: run.y,
4001 x0: run.x0,
4002 x1: run.x1,
4003 }],
4004 &links,
4005 );
4006
4007 assert_eq!(
4008 fast_output, planned_output,
4009 "single-run fast path must emit the same bytes as the planned one-span path"
4010 );
4011 assert!(
4012 fast_output
4013 .windows(b"https://example.com/single-run".len())
4014 .any(|window| window == b"https://example.com/single-run"),
4015 "linked single-run cells should still emit the hyperlink payload"
4016 );
4017 }
4018
4019 #[test]
4020 fn plan_row_reuse_across_different_sizes() {
4021 let mut scratch = cost_model::RowPlanScratch::default();
4023
4024 let large_runs: Vec<ChangeRun> = (0..20)
4025 .map(|i| ChangeRun::new(0, i * 4, i * 4 + 1))
4026 .collect();
4027 let plan_large = cost_model::plan_row_reuse(&large_runs, None, None, &mut scratch);
4028 assert!(!plan_large.spans().is_empty());
4029
4030 let small_runs = [ChangeRun::new(1, 5, 8)];
4031 let plan_small = cost_model::plan_row_reuse(&small_runs, None, None, &mut scratch);
4032 assert_eq!(plan_small.spans().len(), 1);
4033 assert_eq!(plan_small.spans()[0].x0, 5);
4034 assert_eq!(plan_small.spans()[0].x1, 8);
4035 }
4036
4037 #[test]
4040 fn plan_row_gap_exactly_32_cells() {
4041 let runs = [ChangeRun::new(0, 0, 0), ChangeRun::new(0, 33, 33)];
4044 let plan = cost_model::plan_row(&runs, None, None);
4045 assert!(
4049 plan.spans().len() <= 2,
4050 "32-cell gap should still consider merge"
4051 );
4052 }
4053
4054 #[test]
4055 fn plan_row_gap_33_cells_stays_sparse() {
4056 let runs = [ChangeRun::new(0, 0, 0), ChangeRun::new(0, 34, 34)];
4059 let plan = cost_model::plan_row(&runs, None, None);
4060 assert_eq!(
4061 plan.spans().len(),
4062 2,
4063 "33-cell gap should stay sparse (gap > 32 breaks)"
4064 );
4065 }
4066
4067 #[test]
4070 fn plan_row_many_sparse_spans() {
4071 let runs = [
4073 ChangeRun::new(0, 0, 0),
4074 ChangeRun::new(0, 40, 40),
4075 ChangeRun::new(0, 80, 80),
4076 ChangeRun::new(0, 120, 120),
4077 ChangeRun::new(0, 160, 160),
4078 ChangeRun::new(0, 200, 200),
4079 ];
4080 let plan = cost_model::plan_row(&runs, None, None);
4081 assert_eq!(plan.spans().len(), 6, "Should have 6 separate sparse spans");
4083 }
4084
4085 #[test]
4088 fn cell_style_default_is_transparent_no_attrs() {
4089 let style = CellStyle::default();
4090 assert_eq!(style.fg, PackedRgba::TRANSPARENT);
4091 assert_eq!(style.bg, PackedRgba::TRANSPARENT);
4092 assert!(style.attrs.is_empty());
4093 }
4094
4095 #[test]
4096 fn cell_style_from_cell_captures_all() {
4097 let fg = PackedRgba::rgb(10, 20, 30);
4098 let bg = PackedRgba::rgb(40, 50, 60);
4099 let flags = StyleFlags::BOLD | StyleFlags::ITALIC;
4100 let cell = Cell::from_char('X')
4101 .with_fg(fg)
4102 .with_bg(bg)
4103 .with_attrs(CellAttrs::new(flags, 5));
4104 let style = CellStyle::from_cell(&cell);
4105 assert_eq!(style.fg, fg);
4106 assert_eq!(style.bg, bg);
4107 assert_eq!(style.attrs, flags);
4108 }
4109
4110 #[test]
4111 fn cell_style_eq_and_clone() {
4112 let a = CellStyle {
4113 fg: PackedRgba::rgb(1, 2, 3),
4114 bg: PackedRgba::TRANSPARENT,
4115 attrs: StyleFlags::DIM,
4116 };
4117 let b = a;
4118 assert_eq!(a, b);
4119 }
4120
4121 #[test]
4124 fn sgr_flags_len_empty() {
4125 assert_eq!(Presenter::<Vec<u8>>::sgr_flags_len(StyleFlags::empty()), 0);
4126 }
4127
4128 #[test]
4129 fn sgr_flags_len_single() {
4130 let len = Presenter::<Vec<u8>>::sgr_flags_len(StyleFlags::BOLD);
4132 assert!(len > 0);
4133 let mut buf = Vec::new();
4135 ansi::sgr_flags(&mut buf, StyleFlags::BOLD).unwrap();
4136 assert_eq!(len as usize, buf.len());
4137 }
4138
4139 #[test]
4140 fn sgr_flags_len_multiple() {
4141 let flags = StyleFlags::BOLD | StyleFlags::ITALIC | StyleFlags::UNDERLINE;
4142 let len = Presenter::<Vec<u8>>::sgr_flags_len(flags);
4143 let mut buf = Vec::new();
4144 ansi::sgr_flags(&mut buf, flags).unwrap();
4145 assert_eq!(len as usize, buf.len());
4146 }
4147
4148 #[test]
4149 fn sgr_flags_off_len_empty() {
4150 assert_eq!(
4151 Presenter::<Vec<u8>>::sgr_flags_off_len(StyleFlags::empty()),
4152 0
4153 );
4154 }
4155
4156 #[test]
4157 fn sgr_rgb_len_matches_actual() {
4158 let color = PackedRgba::rgb(0, 0, 0);
4159 let estimated = Presenter::<Vec<u8>>::sgr_rgb_len(color);
4160 assert!(estimated > 0);
4163 }
4164
4165 #[test]
4166 fn sgr_rgb_len_large_values() {
4167 let color = PackedRgba::rgb(255, 255, 255);
4168 let small_color = PackedRgba::rgb(0, 0, 0);
4169 let large_len = Presenter::<Vec<u8>>::sgr_rgb_len(color);
4170 let small_len = Presenter::<Vec<u8>>::sgr_rgb_len(small_color);
4171 assert!(large_len > small_len);
4173 }
4174
4175 #[test]
4176 fn dec_len_u8_boundaries() {
4177 assert_eq!(Presenter::<Vec<u8>>::dec_len_u8(0), 1);
4178 assert_eq!(Presenter::<Vec<u8>>::dec_len_u8(9), 1);
4179 assert_eq!(Presenter::<Vec<u8>>::dec_len_u8(10), 2);
4180 assert_eq!(Presenter::<Vec<u8>>::dec_len_u8(99), 2);
4181 assert_eq!(Presenter::<Vec<u8>>::dec_len_u8(100), 3);
4182 assert_eq!(Presenter::<Vec<u8>>::dec_len_u8(255), 3);
4183 }
4184
4185 #[test]
4188 fn sgr_delta_all_attrs_removed_at_once() {
4189 let mut presenter = test_presenter();
4190 let all_flags = StyleFlags::BOLD
4191 | StyleFlags::DIM
4192 | StyleFlags::ITALIC
4193 | StyleFlags::UNDERLINE
4194 | StyleFlags::BLINK
4195 | StyleFlags::REVERSE
4196 | StyleFlags::STRIKETHROUGH;
4197 let old = CellStyle {
4198 fg: PackedRgba::rgb(100, 100, 100),
4199 bg: PackedRgba::TRANSPARENT,
4200 attrs: all_flags,
4201 };
4202 let new = CellStyle {
4203 fg: PackedRgba::rgb(100, 100, 100),
4204 bg: PackedRgba::TRANSPARENT,
4205 attrs: StyleFlags::empty(),
4206 };
4207
4208 presenter.current_style = Some(old);
4209 presenter.emit_style_delta(old, new).unwrap();
4210 let output = presenter.into_inner().unwrap();
4211
4212 assert!(!output.is_empty());
4215 }
4216
4217 #[test]
4218 fn sgr_delta_fg_to_transparent() {
4219 let mut presenter = test_presenter();
4220 let old = CellStyle {
4221 fg: PackedRgba::rgb(200, 100, 50),
4222 bg: PackedRgba::TRANSPARENT,
4223 attrs: StyleFlags::empty(),
4224 };
4225 let new = CellStyle {
4226 fg: PackedRgba::TRANSPARENT,
4227 bg: PackedRgba::TRANSPARENT,
4228 attrs: StyleFlags::empty(),
4229 };
4230
4231 presenter.current_style = Some(old);
4232 presenter.emit_style_delta(old, new).unwrap();
4233 let output = presenter.into_inner().unwrap();
4234 let output_str = String::from_utf8_lossy(&output);
4235
4236 assert!(!output.is_empty(), "Should emit fg removal: {output_str:?}");
4239 }
4240
4241 #[test]
4242 fn sgr_delta_bg_to_transparent() {
4243 let mut presenter = test_presenter();
4244 let old = CellStyle {
4245 fg: PackedRgba::TRANSPARENT,
4246 bg: PackedRgba::rgb(30, 60, 90),
4247 attrs: StyleFlags::empty(),
4248 };
4249 let new = CellStyle {
4250 fg: PackedRgba::TRANSPARENT,
4251 bg: PackedRgba::TRANSPARENT,
4252 attrs: StyleFlags::empty(),
4253 };
4254
4255 presenter.current_style = Some(old);
4256 presenter.emit_style_delta(old, new).unwrap();
4257 let output = presenter.into_inner().unwrap();
4258 assert!(!output.is_empty(), "Should emit bg removal");
4259 }
4260
4261 #[test]
4262 fn sgr_delta_dim_removed_bold_stays() {
4263 let mut presenter = test_presenter();
4267 let mut buffer = Buffer::new(3, 1);
4268
4269 let attrs1 = CellAttrs::new(StyleFlags::BOLD | StyleFlags::DIM, 0);
4270 let attrs2 = CellAttrs::new(StyleFlags::BOLD, 0);
4271 buffer.set_raw(0, 0, Cell::from_char('A').with_attrs(attrs1));
4272 buffer.set_raw(1, 0, Cell::from_char('B').with_attrs(attrs2));
4273
4274 let old = Buffer::new(3, 1);
4275 let diff = BufferDiff::compute(&old, &buffer);
4276
4277 presenter.present(&buffer, &diff).unwrap();
4278 let output = get_output(presenter);
4279 let output_str = String::from_utf8_lossy(&output);
4280
4281 assert!(
4283 output_str.contains("\x1b[22m"),
4284 "Expected dim-off (22) in: {output_str:?}"
4285 );
4286 assert!(
4287 output_str.contains("\x1b[1m"),
4288 "Expected bold re-enable (1) in: {output_str:?}"
4289 );
4290 }
4291
4292 #[test]
4293 fn sgr_delta_fallback_to_full_reset_when_cheaper() {
4294 let mut presenter = test_presenter();
4296 let old = CellStyle {
4297 fg: PackedRgba::rgb(10, 20, 30),
4298 bg: PackedRgba::rgb(40, 50, 60),
4299 attrs: StyleFlags::BOLD
4300 | StyleFlags::DIM
4301 | StyleFlags::ITALIC
4302 | StyleFlags::UNDERLINE
4303 | StyleFlags::STRIKETHROUGH,
4304 };
4305 let new = CellStyle {
4306 fg: PackedRgba::TRANSPARENT,
4307 bg: PackedRgba::TRANSPARENT,
4308 attrs: StyleFlags::empty(),
4309 };
4310
4311 presenter.current_style = Some(old);
4312 presenter.emit_style_delta(old, new).unwrap();
4313 let output = presenter.into_inner().unwrap();
4314 let output_str = String::from_utf8_lossy(&output);
4315
4316 assert!(
4318 output_str.contains("\x1b[0m"),
4319 "Expected full reset fallback: {output_str:?}"
4320 );
4321 }
4322
4323 #[test]
4326 fn emit_cell_control_char_replaced_with_fffd() {
4327 let mut presenter = test_presenter();
4328 presenter.cursor_x = Some(0);
4329 presenter.cursor_y = Some(0);
4330
4331 let cell = Cell::from_char('\x01');
4334 presenter.emit_cell(0, &cell, None, None).unwrap();
4335 let output = presenter.into_inner().unwrap();
4336 let output_str = String::from_utf8_lossy(&output);
4337
4338 assert!(
4340 output_str.contains('\u{FFFD}'),
4341 "Control char (width 0) should be replaced with U+FFFD, got: {output:?}"
4342 );
4343 assert!(
4344 !output.contains(&0x01),
4345 "Raw control char should not appear"
4346 );
4347 }
4348
4349 #[test]
4350 fn emit_content_empty_cell_emits_space() {
4351 let mut presenter = test_presenter();
4352 presenter.cursor_x = Some(0);
4353 presenter.cursor_y = Some(0);
4354
4355 let cell = Cell::default();
4356 assert!(cell.is_empty());
4357 presenter.emit_cell(0, &cell, None, None).unwrap();
4358 let output = presenter.into_inner().unwrap();
4359 assert!(output.contains(&b' '), "Empty cell should emit space");
4360 }
4361
4362 #[test]
4363 fn emit_content_ascii_char_emits_single_byte() {
4364 let mut presenter = test_presenter();
4365 presenter
4366 .emit_content(PreparedContent::Char('A'), 1, None)
4367 .unwrap();
4368 let output = presenter.into_inner().unwrap();
4369 assert_eq!(output, b"A");
4370 }
4371
4372 #[test]
4373 fn emit_content_ascii_control_sanitizes_to_space() {
4374 let mut presenter = test_presenter();
4375 presenter
4376 .emit_content(PreparedContent::Char('\n'), 1, None)
4377 .unwrap();
4378 let output = presenter.into_inner().unwrap();
4379 assert_eq!(output, b" ");
4380 }
4381
4382 #[test]
4383 fn prepared_content_ascii_widths_match_char_width_contract() {
4384 for ch in ['A', ' ', '\n', '\r', '\x1f', '\x7f'] {
4385 let cell = Cell::from_char(ch);
4386 let (prepared, width) = PreparedContent::from_cell(&cell);
4387 assert_eq!(prepared, PreparedContent::Char(ch));
4388 assert_eq!(width, char_width(ch), "width mismatch for {ch:?}");
4389 }
4390 }
4391
4392 #[test]
4393 fn prepared_content_tab_uses_canonicalized_space() {
4394 let cell = Cell::from_char('\t');
4395 let (prepared, width) = PreparedContent::from_cell(&cell);
4396 assert_eq!(prepared, PreparedContent::Char(' '));
4397 assert_eq!(width, 1);
4398 }
4399
4400 #[test]
4401 fn prepared_content_nul_uses_empty_cell_representation() {
4402 let cell = Cell::from_char('\0');
4403 let (prepared, width) = PreparedContent::from_cell(&cell);
4404 assert_eq!(prepared, PreparedContent::Empty);
4405 assert_eq!(width, 0);
4406 }
4407
4408 #[test]
4409 fn emit_content_grapheme_sanitizes_escape_sequences() {
4410 let mut presenter = test_presenter();
4411 presenter.cursor_x = Some(0);
4412 presenter.cursor_y = Some(0);
4413
4414 let mut pool = GraphemePool::new();
4415 let gid = pool.intern("A\x1b[31mB\x1b[0m", 2);
4416 let cell = Cell::new(CellContent::from_grapheme(gid));
4417 presenter.emit_cell(0, &cell, Some(&pool), None).unwrap();
4418
4419 let output = presenter.into_inner().unwrap();
4420 let output_str = String::from_utf8_lossy(&output);
4421 assert!(
4422 output_str.contains("AB"),
4423 "sanitized grapheme should preserve visible payload"
4424 );
4425 assert!(
4426 !output_str.contains("\x1b[31m"),
4427 "raw escape sequence must not be emitted"
4428 );
4429 }
4430
4431 #[test]
4432 fn emit_content_grapheme_width_mismatch_uses_placeholders() {
4433 let mut presenter = test_presenter();
4434 let mut pool = GraphemePool::new();
4435 let gid = pool.intern("A\x07", 2);
4436
4437 presenter
4438 .emit_content(PreparedContent::Grapheme(gid), 2, Some(&pool))
4439 .unwrap();
4440
4441 let output = presenter.into_inner().unwrap();
4442 assert_eq!(output, b"??");
4443 }
4444
4445 #[test]
4446 fn wide_grapheme_tail_repair_does_not_blank_unrelated_following_cells() {
4447 let mut presenter = test_presenter();
4448 let mut pool = GraphemePool::new();
4449 let gid = pool.intern("XYZ", 3);
4450 let mut buffer = Buffer::new(8, 1);
4451
4452 buffer.set_raw(0, 0, Cell::new(CellContent::from_grapheme(gid)));
4453 buffer.set_raw(1, 0, Cell::from_char('a'));
4454 buffer.set_raw(2, 0, Cell::from_char('b'));
4455 buffer.set_raw(3, 0, Cell::from_char('c'));
4456
4457 let old = Buffer::new(8, 1);
4458 let diff = BufferDiff::compute(&old, &buffer);
4459
4460 presenter
4461 .present_with_pool(&buffer, &diff, Some(&pool), None)
4462 .unwrap();
4463
4464 let output = presenter.into_inner().unwrap();
4465 let output_str = String::from_utf8_lossy(&output);
4466 let visible = sanitize(output_str.as_ref());
4467
4468 assert!(
4469 visible.contains("XYZabc"),
4470 "width-3 grapheme repair must not erase following cells: {:?}",
4471 visible
4472 );
4473 }
4474
4475 #[test]
4478 fn continuation_cell_cursor_x_none() {
4479 let mut presenter = test_presenter();
4480 presenter.cursor_x = None;
4482 presenter.cursor_y = Some(0);
4483
4484 let cell = Cell::CONTINUATION;
4485 presenter.emit_cell(5, &cell, None, None).unwrap();
4486 let output = presenter.into_inner().unwrap();
4487
4488 assert!(
4490 output.contains(&b' '),
4491 "Should emit a space for continuation with unknown cursor_x"
4492 );
4493 }
4494
4495 #[test]
4496 fn continuation_cell_cursor_already_past() {
4497 let mut presenter = test_presenter();
4498 presenter.cursor_x = Some(10);
4500 presenter.cursor_y = Some(0);
4501
4502 let cell = Cell::CONTINUATION;
4503 presenter.emit_cell(5, &cell, None, None).unwrap();
4504 let output = presenter.into_inner().unwrap();
4505
4506 assert!(
4508 output.is_empty(),
4509 "Should skip continuation when cursor is past it"
4510 );
4511 }
4512
4513 #[test]
4516 fn clear_line_positions_cursor_and_erases() {
4517 let mut presenter = test_presenter();
4518 presenter.clear_line(5).unwrap();
4519 let output = get_output(presenter);
4520 let output_str = String::from_utf8_lossy(&output);
4521
4522 assert!(
4524 output_str.contains("\x1b[2K"),
4525 "Should contain erase line sequence"
4526 );
4527 }
4528
4529 #[test]
4532 fn into_inner_returns_accumulated_output() {
4533 let mut presenter = test_presenter();
4534 presenter.position_cursor(0, 0).unwrap();
4535 let inner = presenter.into_inner().unwrap();
4536 assert!(!inner.is_empty(), "into_inner should return buffered data");
4537 }
4538
4539 #[test]
4542 fn move_cursor_optimal_same_row_forward_large() {
4543 let mut presenter = test_presenter();
4544 presenter.cursor_x = Some(0);
4545 presenter.cursor_y = Some(0);
4546
4547 presenter.move_cursor_optimal(100, 0).unwrap();
4549 let output = presenter.into_inner().unwrap();
4550
4551 let cuf = cost_model::cuf_cost(100);
4553 let cha = cost_model::cha_cost(100);
4554 let cup = cost_model::cup_cost(0, 100);
4555 let cheapest = cuf.min(cha).min(cup);
4556 assert_eq!(output.len(), cheapest, "Should pick cheapest cursor move");
4557 }
4558
4559 #[test]
4560 fn move_cursor_optimal_same_row_backward_to_zero() {
4561 let mut presenter = test_presenter();
4562 presenter.cursor_x = Some(50);
4563 presenter.cursor_y = Some(0);
4564
4565 presenter.move_cursor_optimal(0, 0).unwrap();
4566 let output = presenter.into_inner().unwrap();
4567
4568 let mut expected = Vec::new();
4571 ansi::cha(&mut expected, 0).unwrap();
4572 assert_eq!(output, expected, "Should use CHA for backward to col 0");
4573 }
4574
4575 #[test]
4576 fn move_cursor_optimal_unknown_cursor_uses_cup() {
4577 let mut presenter = test_presenter();
4578 presenter.move_cursor_optimal(10, 5).unwrap();
4580 let output = presenter.into_inner().unwrap();
4581 let mut expected = Vec::new();
4582 ansi::cup(&mut expected, 5, 10).unwrap();
4583 assert_eq!(output, expected, "Should use CUP when cursor is unknown");
4584 }
4585
4586 #[test]
4589 fn sync_wrap_order_begin_content_reset_end() {
4590 let mut presenter = test_presenter_with_sync();
4591 let mut buffer = Buffer::new(3, 1);
4592 buffer.set_raw(0, 0, Cell::from_char('Z'));
4593
4594 let old = Buffer::new(3, 1);
4595 let diff = BufferDiff::compute(&old, &buffer);
4596
4597 presenter.present(&buffer, &diff).unwrap();
4598 let output = get_output(presenter);
4599
4600 let sync_begin_pos = output
4601 .windows(ansi::SYNC_BEGIN.len())
4602 .position(|w| w == ansi::SYNC_BEGIN)
4603 .expect("sync begin missing");
4604 let z_pos = output
4605 .iter()
4606 .position(|&b| b == b'Z')
4607 .expect("character Z missing");
4608 let reset_pos = output
4609 .windows(b"\x1b[0m".len())
4610 .rposition(|w| w == b"\x1b[0m")
4611 .expect("SGR reset missing");
4612 let sync_end_pos = output
4613 .windows(ansi::SYNC_END.len())
4614 .rposition(|w| w == ansi::SYNC_END)
4615 .expect("sync end missing");
4616
4617 assert!(sync_begin_pos < z_pos, "sync begin before content");
4618 assert!(z_pos < reset_pos, "content before reset");
4619 assert!(reset_pos < sync_end_pos, "reset before sync end");
4620 }
4621
4622 #[test]
4625 fn style_none_after_each_frame() {
4626 let mut presenter = test_presenter();
4627 let fg = PackedRgba::rgb(255, 128, 64);
4628
4629 for _ in 0..5 {
4630 let mut buffer = Buffer::new(3, 1);
4631 buffer.set_raw(0, 0, Cell::from_char('X').with_fg(fg));
4632 let old = Buffer::new(3, 1);
4633 let diff = BufferDiff::compute(&old, &buffer);
4634 presenter.present(&buffer, &diff).unwrap();
4635
4636 assert!(
4638 presenter.current_style.is_none(),
4639 "Style should be None after frame end"
4640 );
4641 assert!(
4642 presenter.current_link.is_none(),
4643 "Link should be None after frame end"
4644 );
4645 }
4646 }
4647
4648 #[test]
4651 fn link_closed_at_frame_end_even_if_all_cells_linked() {
4652 let mut presenter = test_presenter();
4653 let mut buffer = Buffer::new(3, 1);
4654 let mut links = LinkRegistry::new();
4655 let link_id = links.register("https://all-linked.test");
4656
4657 for x in 0..3 {
4659 buffer.set_raw(
4660 x,
4661 0,
4662 Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
4663 );
4664 }
4665
4666 let old = Buffer::new(3, 1);
4667 let diff = BufferDiff::compute(&old, &buffer);
4668 presenter
4669 .present_with_pool(&buffer, &diff, None, Some(&links))
4670 .unwrap();
4671
4672 assert!(
4674 presenter.current_link.is_none(),
4675 "Link must be closed at frame end"
4676 );
4677 }
4678
4679 #[test]
4682 fn present_stats_empty_diff() {
4683 let mut presenter = test_presenter();
4684 let buffer = Buffer::new(10, 10);
4685 let diff = BufferDiff::new();
4686 let stats = presenter.present(&buffer, &diff).unwrap();
4687
4688 assert_eq!(stats.cells_changed, 0);
4689 assert_eq!(stats.run_count, 0);
4690 assert!(stats.bytes_emitted > 0);
4692 }
4693
4694 #[test]
4695 fn present_stats_full_row() {
4696 let mut presenter = test_presenter();
4697 let mut buffer = Buffer::new(10, 1);
4698 for x in 0..10 {
4699 buffer.set_raw(x, 0, Cell::from_char('A'));
4700 }
4701 let old = Buffer::new(10, 1);
4702 let diff = BufferDiff::compute(&old, &buffer);
4703 let stats = presenter.present(&buffer, &diff).unwrap();
4704
4705 assert_eq!(stats.cells_changed, 10);
4706 assert!(stats.run_count >= 1);
4707 assert!(stats.bytes_emitted > 10, "Should include ANSI overhead");
4708 }
4709
4710 #[test]
4713 fn capabilities_accessor() {
4714 let mut caps = TerminalCapabilities::basic();
4715 caps.sync_output = true;
4716 let presenter = Presenter::new(Vec::<u8>::new(), caps);
4717 assert!(presenter.capabilities().sync_output);
4718 }
4719
4720 #[test]
4723 fn flush_succeeds_on_empty_presenter() {
4724 let mut presenter = test_presenter();
4725 presenter.flush().unwrap();
4726 let output = get_output(presenter);
4727 assert!(output.is_empty());
4728 }
4729
4730 #[test]
4733 fn row_plan_total_cost_matches_dp() {
4734 let runs = [ChangeRun::new(3, 5, 10), ChangeRun::new(3, 15, 20)];
4735 let plan = cost_model::plan_row(&runs, None, None);
4736 assert!(plan.total_cost() > 0);
4737 }
4740
4741 #[test]
4744 fn sgr_delta_hot_path_only_fg_change() {
4745 let mut presenter = test_presenter();
4746 let old = CellStyle {
4747 fg: PackedRgba::rgb(255, 0, 0),
4748 bg: PackedRgba::rgb(0, 0, 0),
4749 attrs: StyleFlags::BOLD | StyleFlags::ITALIC,
4750 };
4751 let new = CellStyle {
4752 fg: PackedRgba::rgb(0, 255, 0),
4753 bg: PackedRgba::rgb(0, 0, 0),
4754 attrs: StyleFlags::BOLD | StyleFlags::ITALIC, };
4756
4757 presenter.current_style = Some(old);
4758 presenter.emit_style_delta(old, new).unwrap();
4759 let output = presenter.into_inner().unwrap();
4760 let output_str = String::from_utf8_lossy(&output);
4761
4762 assert!(output_str.contains("38;2;0;255;0"), "Should emit new fg");
4764 assert!(
4765 !output_str.contains("\x1b[0m"),
4766 "No reset needed for color-only change"
4767 );
4768 assert!(
4770 !output_str.contains("\x1b[1m"),
4771 "Bold should not be re-emitted"
4772 );
4773 }
4774
4775 #[test]
4776 fn sgr_delta_hot_path_both_colors_change() {
4777 let mut presenter = test_presenter();
4778 let old = CellStyle {
4779 fg: PackedRgba::rgb(1, 2, 3),
4780 bg: PackedRgba::rgb(4, 5, 6),
4781 attrs: StyleFlags::UNDERLINE,
4782 };
4783 let new = CellStyle {
4784 fg: PackedRgba::rgb(7, 8, 9),
4785 bg: PackedRgba::rgb(10, 11, 12),
4786 attrs: StyleFlags::UNDERLINE, };
4788
4789 presenter.current_style = Some(old);
4790 presenter.emit_style_delta(old, new).unwrap();
4791 let output = presenter.into_inner().unwrap();
4792 let output_str = String::from_utf8_lossy(&output);
4793
4794 assert!(output_str.contains("38;2;7;8;9"), "Should emit new fg");
4795 assert!(output_str.contains("48;2;10;11;12"), "Should emit new bg");
4796 assert!(!output_str.contains("\x1b[0m"), "No reset for color-only");
4797 }
4798
4799 #[test]
4802 fn emit_style_full_default_is_just_reset() {
4803 let mut presenter = test_presenter();
4804 let default_style = CellStyle::default();
4805 presenter.emit_style_full(default_style).unwrap();
4806 let output = presenter.into_inner().unwrap();
4807
4808 assert_eq!(output, b"\x1b[0m");
4810 }
4811
4812 #[test]
4813 fn emit_style_full_with_all_properties() {
4814 let mut presenter = test_presenter();
4815 let style = CellStyle {
4816 fg: PackedRgba::rgb(10, 20, 30),
4817 bg: PackedRgba::rgb(40, 50, 60),
4818 attrs: StyleFlags::BOLD | StyleFlags::ITALIC,
4819 };
4820 presenter.emit_style_full(style).unwrap();
4821 let output = presenter.into_inner().unwrap();
4822 let output_str = String::from_utf8_lossy(&output);
4823
4824 assert!(output_str.contains("\x1b[0m"), "Should start with reset");
4826 assert!(output_str.contains("38;2;10;20;30"), "Should have fg");
4827 assert!(output_str.contains("48;2;40;50;60"), "Should have bg");
4828 }
4829
4830 #[test]
4833 fn present_multiple_rows_different_strategies() {
4834 let mut presenter = test_presenter();
4835 let mut buffer = Buffer::new(80, 5);
4836
4837 for x in (0..20).step_by(2) {
4839 buffer.set_raw(x, 0, Cell::from_char('D'));
4840 }
4841 buffer.set_raw(0, 2, Cell::from_char('L'));
4843 buffer.set_raw(79, 2, Cell::from_char('R'));
4844 buffer.set_raw(40, 4, Cell::from_char('M'));
4846
4847 let old = Buffer::new(80, 5);
4848 let diff = BufferDiff::compute(&old, &buffer);
4849 presenter.present(&buffer, &diff).unwrap();
4850 let output = get_output(presenter);
4851 let output_str = String::from_utf8_lossy(&output);
4852
4853 assert!(output_str.contains('D'));
4854 assert!(output_str.contains('L'));
4855 assert!(output_str.contains('R'));
4856 assert!(output_str.contains('M'));
4857 }
4858
4859 #[test]
4860 fn zero_width_chars_replaced_with_placeholder() {
4861 let mut presenter = test_presenter();
4862 let mut buffer = Buffer::new(5, 1);
4863
4864 let zw_char = '\u{0301}';
4868
4869 assert_eq!(Cell::from_char(zw_char).content.width(), 0);
4871
4872 buffer.set_raw(0, 0, Cell::from_char(zw_char));
4873 buffer.set_raw(1, 0, Cell::from_char('A'));
4874
4875 let old = Buffer::new(5, 1);
4876 let diff = BufferDiff::compute(&old, &buffer);
4877
4878 presenter.present(&buffer, &diff).unwrap();
4879 let output = get_output(presenter);
4880 let output_str = String::from_utf8_lossy(&output);
4881
4882 assert!(
4884 output_str.contains("\u{FFFD}"),
4885 "Expected replacement character for zero-width content, got: {:?}",
4886 output_str
4887 );
4888
4889 assert!(
4891 !output_str.contains(zw_char),
4892 "Should not contain raw zero-width char"
4893 );
4894
4895 assert!(
4897 output_str.contains('A'),
4898 "Should contain subsequent character 'A'"
4899 );
4900 }
4901}
4902
4903#[cfg(test)]
4904mod proptests {
4905 use super::*;
4906 use crate::cell::{Cell, PackedRgba};
4907 use crate::diff::BufferDiff;
4908 use crate::terminal_model::TerminalModel;
4909 use proptest::prelude::*;
4910
4911 fn test_presenter() -> Presenter<Vec<u8>> {
4913 let caps = TerminalCapabilities::basic();
4914 Presenter::new(Vec::new(), caps)
4915 }
4916
4917 proptest! {
4918 #[test]
4921 fn presenter_roundtrip_characters(
4922 width in 5u16..40,
4923 height in 3u16..20,
4924 num_chars in 1usize..50, ) {
4926 let mut buffer = Buffer::new(width, height);
4927 let mut changed_positions = std::collections::HashSet::new();
4928
4929 for i in 0..num_chars {
4931 let x = (i * 7 + 3) as u16 % width;
4932 let y = (i * 11 + 5) as u16 % height;
4933 let ch = char::from_u32(('A' as u32) + (i as u32 % 26)).unwrap();
4934 buffer.set_raw(x, y, Cell::from_char(ch));
4935 changed_positions.insert((x, y));
4936 }
4937
4938 let mut presenter = test_presenter();
4940 let old = Buffer::new(width, height);
4941 let diff = BufferDiff::compute(&old, &buffer);
4942 presenter.present(&buffer, &diff).unwrap();
4943 let output = presenter.into_inner().unwrap();
4944
4945 let mut model = TerminalModel::new(width as usize, height as usize);
4947 model.process(&output);
4948
4949 for &(x, y) in &changed_positions {
4951 let buf_cell = buffer.get_unchecked(x, y);
4952 let expected_ch = buf_cell.content.as_char().unwrap_or(' ');
4953 let mut expected_buf = [0u8; 4];
4954 let expected_str = expected_ch.encode_utf8(&mut expected_buf);
4955
4956 if let Some(model_cell) = model.cell(x as usize, y as usize) {
4957 prop_assert_eq!(
4958 model_cell.text.as_str(),
4959 expected_str,
4960 "Character mismatch at ({}, {})", x, y
4961 );
4962 }
4963 }
4964 }
4965
4966 #[test]
4968 fn style_reset_after_present(
4969 width in 5u16..30,
4970 height in 3u16..15,
4971 num_styled in 1usize..20,
4972 ) {
4973 let mut buffer = Buffer::new(width, height);
4974
4975 for i in 0..num_styled {
4977 let x = (i * 7) as u16 % width;
4978 let y = (i * 11) as u16 % height;
4979 let fg = PackedRgba::rgb(
4980 ((i * 31) % 256) as u8,
4981 ((i * 47) % 256) as u8,
4982 ((i * 71) % 256) as u8,
4983 );
4984 buffer.set_raw(x, y, Cell::from_char('X').with_fg(fg));
4985 }
4986
4987 let mut presenter = test_presenter();
4989 let old = Buffer::new(width, height);
4990 let diff = BufferDiff::compute(&old, &buffer);
4991 presenter.present(&buffer, &diff).unwrap();
4992 let output = presenter.into_inner().unwrap();
4993 let output_str = String::from_utf8_lossy(&output);
4994
4995 prop_assert!(
4997 output_str.contains("\x1b[0m"),
4998 "Output should contain SGR reset"
4999 );
5000 }
5001
5002 #[test]
5004 fn empty_diff_minimal_output(
5005 width in 5u16..50,
5006 height in 3u16..25,
5007 ) {
5008 let buffer = Buffer::new(width, height);
5009 let diff = BufferDiff::new(); let mut presenter = test_presenter();
5012 presenter.present(&buffer, &diff).unwrap();
5013 let output = presenter.into_inner().unwrap();
5014
5015 prop_assert!(output.len() < 50, "Empty diff should have minimal output");
5018 }
5019
5020 #[test]
5025 fn diff_size_bounds(
5026 width in 5u16..30,
5027 height in 3u16..15,
5028 ) {
5029 let old = Buffer::new(width, height);
5031 let mut new = Buffer::new(width, height);
5032
5033 for y in 0..height {
5034 for x in 0..width {
5035 new.set_raw(x, y, Cell::from_char('X'));
5036 }
5037 }
5038
5039 let diff = BufferDiff::compute(&old, &new);
5040
5041 prop_assert_eq!(
5043 diff.len(),
5044 (width as usize) * (height as usize),
5045 "Full change should have all cells in diff"
5046 );
5047 }
5048
5049 #[test]
5051 fn presenter_cursor_consistency(
5052 width in 10u16..40,
5053 height in 5u16..20,
5054 num_runs in 1usize..10,
5055 ) {
5056 let mut buffer = Buffer::new(width, height);
5057
5058 for i in 0..num_runs {
5060 let start_x = (i * 5) as u16 % (width - 5);
5061 let y = i as u16 % height;
5062 for x in start_x..(start_x + 3) {
5063 buffer.set_raw(x, y, Cell::from_char('A'));
5064 }
5065 }
5066
5067 let mut presenter = test_presenter();
5069 let old = Buffer::new(width, height);
5070
5071 for _ in 0..3 {
5072 let diff = BufferDiff::compute(&old, &buffer);
5073 presenter.present(&buffer, &diff).unwrap();
5074 }
5075
5076 let output = presenter.into_inner().unwrap();
5078 prop_assert!(!output.is_empty(), "Should produce some output");
5079 }
5080
5081 #[test]
5085 fn sgr_delta_transition_equivalence(
5086 width in 5u16..20,
5087 height in 3u16..10,
5088 num_styled in 2usize..15,
5089 ) {
5090 let mut buffer = Buffer::new(width, height);
5091 let mut expected: std::collections::HashMap<(u16, u16), char> =
5093 std::collections::HashMap::new();
5094
5095 for i in 0..num_styled {
5097 let x = (i * 3 + 1) as u16 % width;
5098 let y = (i * 5 + 2) as u16 % height;
5099 let ch = char::from_u32(('A' as u32) + (i as u32 % 26)).unwrap();
5100 let fg = PackedRgba::rgb(
5101 ((i * 73) % 256) as u8,
5102 ((i * 137) % 256) as u8,
5103 ((i * 41) % 256) as u8,
5104 );
5105 let bg = if i % 3 == 0 {
5106 PackedRgba::rgb(
5107 ((i * 29) % 256) as u8,
5108 ((i * 53) % 256) as u8,
5109 ((i * 97) % 256) as u8,
5110 )
5111 } else {
5112 PackedRgba::TRANSPARENT
5113 };
5114 let flags_bits = ((i * 37) % 256) as u8;
5115 let flags = StyleFlags::from_bits_truncate(flags_bits);
5116 let cell = Cell::from_char(ch)
5117 .with_fg(fg)
5118 .with_bg(bg)
5119 .with_attrs(CellAttrs::new(flags, 0));
5120 buffer.set_raw(x, y, cell);
5121 expected.insert((x, y), ch);
5122 }
5123
5124 let mut presenter = test_presenter();
5126 let old = Buffer::new(width, height);
5127 let diff = BufferDiff::compute(&old, &buffer);
5128 presenter.present(&buffer, &diff).unwrap();
5129 let output = presenter.into_inner().unwrap();
5130
5131 let mut model = TerminalModel::new(width as usize, height as usize);
5133 model.process(&output);
5134
5135 for (&(x, y), &ch) in &expected {
5136 let mut buf = [0u8; 4];
5137 let expected_str = ch.encode_utf8(&mut buf);
5138
5139 if let Some(model_cell) = model.cell(x as usize, y as usize) {
5140 prop_assert_eq!(
5141 model_cell.text.as_str(),
5142 expected_str,
5143 "Character mismatch at ({}, {}) with delta engine", x, y
5144 );
5145 }
5146 }
5147 }
5148
5149 #[test]
5153 fn dp_emit_equivalence(
5154 width in 20u16..60,
5155 height in 5u16..15,
5156 num_changes in 5usize..30,
5157 ) {
5158 let mut buffer = Buffer::new(width, height);
5159 let mut expected: std::collections::HashMap<(u16, u16), char> =
5160 std::collections::HashMap::new();
5161
5162 for i in 0..num_changes {
5164 let x = (i * 7 + 3) as u16 % width;
5165 let y = (i * 3 + 1) as u16 % height;
5166 let ch = char::from_u32(('A' as u32) + (i as u32 % 26)).unwrap();
5167 buffer.set_raw(x, y, Cell::from_char(ch));
5168 expected.insert((x, y), ch);
5169 }
5170
5171 let mut presenter = test_presenter();
5173 let old = Buffer::new(width, height);
5174 let diff = BufferDiff::compute(&old, &buffer);
5175 presenter.present(&buffer, &diff).unwrap();
5176 let output = presenter.into_inner().unwrap();
5177
5178 let mut model = TerminalModel::new(width as usize, height as usize);
5180 model.process(&output);
5181
5182 for (&(x, y), &ch) in &expected {
5183 let mut buf = [0u8; 4];
5184 let expected_str = ch.encode_utf8(&mut buf);
5185
5186 if let Some(model_cell) = model.cell(x as usize, y as usize) {
5187 prop_assert_eq!(
5188 model_cell.text.as_str(),
5189 expected_str,
5190 "DP cost model: character mismatch at ({}, {})", x, y
5191 );
5192 }
5193 }
5194 }
5195 }
5196}