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, PackedRgba, StyleFlags};
40use crate::counting_writer::{CountingWriter, PresentStats, StatsCollector};
41use crate::diff::{BufferDiff, ChangeRun};
42use crate::grapheme_pool::GraphemePool;
43use crate::link_registry::LinkRegistry;
44
45pub use ftui_core::terminal_capabilities::TerminalCapabilities;
46
47const BUFFER_CAPACITY: usize = 64 * 1024;
49
50mod cost_model {
61 use super::ChangeRun;
62
63 #[inline]
65 fn digit_count(n: u16) -> usize {
66 if n >= 10000 {
67 5
68 } else if n >= 1000 {
69 4
70 } else if n >= 100 {
71 3
72 } else if n >= 10 {
73 2
74 } else {
75 1
76 }
77 }
78
79 #[inline]
81 pub fn cup_cost(row: u16, col: u16) -> usize {
82 4 + digit_count(row.saturating_add(1)) + digit_count(col.saturating_add(1))
84 }
85
86 #[inline]
88 pub fn cha_cost(col: u16) -> usize {
89 3 + digit_count(col.saturating_add(1))
91 }
92
93 #[inline]
95 pub fn cuf_cost(n: u16) -> usize {
96 match n {
97 0 => 0,
98 1 => 3, _ => 3 + digit_count(n),
100 }
101 }
102
103 pub fn cheapest_move_cost(
106 from_x: Option<u16>,
107 from_y: Option<u16>,
108 to_x: u16,
109 to_y: u16,
110 ) -> usize {
111 if from_x == Some(to_x) && from_y == Some(to_y) {
113 return 0;
114 }
115
116 let cup = cup_cost(to_y, to_x);
117
118 match (from_x, from_y) {
119 (Some(fx), Some(fy)) if fy == to_y => {
120 let cha = cha_cost(to_x);
122 if to_x > fx {
123 let cuf = cuf_cost(to_x - fx);
124 cup.min(cha).min(cuf)
125 } else if to_x == fx {
126 0
127 } else {
128 cup.min(cha)
130 }
131 }
132 _ => cup,
133 }
134 }
135
136 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
138 pub struct RowSpan {
139 pub y: u16,
141 pub x0: u16,
143 pub x1: u16,
145 }
146
147 #[derive(Debug, Clone, PartialEq, Eq)]
149 pub struct RowPlan {
150 spans: Vec<RowSpan>,
151 total_cost: usize,
152 }
153
154 impl RowPlan {
155 #[inline]
156 pub fn spans(&self) -> &[RowSpan] {
157 &self.spans
158 }
159
160 #[inline]
162 #[allow(dead_code)] pub fn total_cost(&self) -> usize {
164 self.total_cost
165 }
166 }
167
168 pub fn plan_row(row_runs: &[ChangeRun], prev_x: Option<u16>, prev_y: Option<u16>) -> RowPlan {
177 debug_assert!(!row_runs.is_empty());
178
179 let row_y = row_runs[0].y;
180 let run_count = row_runs.len();
181
182 let mut prefix_cells = vec![0usize; run_count + 1];
184 for (i, run) in row_runs.iter().enumerate() {
185 prefix_cells[i + 1] = prefix_cells[i] + run.len() as usize;
186 }
187
188 let mut dp = vec![usize::MAX; run_count];
190 let mut prev = vec![0usize; run_count];
191
192 for j in 0..run_count {
193 let mut best_cost = usize::MAX;
194 let mut best_i = j;
195
196 for i in (0..=j).rev() {
201 let changed_cells = prefix_cells[j + 1] - prefix_cells[i];
202 let total_cells = (row_runs[j].x1 - row_runs[i].x0 + 1) as usize;
203 let gap_cells = total_cells - changed_cells;
204
205 if gap_cells > 32 {
206 break;
207 }
208
209 let from_x = if i == 0 {
210 prev_x
211 } else {
212 Some(row_runs[i - 1].x1.saturating_add(1))
213 };
214 let from_y = if i == 0 { prev_y } else { Some(row_y) };
215
216 let move_cost = cheapest_move_cost(from_x, from_y, row_runs[i].x0, row_y);
217 let gap_overhead = gap_cells * 2; let emit_cost = changed_cells + gap_overhead;
219
220 let prev_cost = if i == 0 { 0 } else { dp[i - 1] };
221 let cost = prev_cost
222 .saturating_add(move_cost)
223 .saturating_add(emit_cost);
224
225 if cost < best_cost {
226 best_cost = cost;
227 best_i = i;
228 }
229 }
230
231 dp[j] = best_cost;
232 prev[j] = best_i;
233 }
234
235 let mut spans = Vec::new();
237 let mut j = run_count - 1;
238 loop {
239 let i = prev[j];
240 spans.push(RowSpan {
241 y: row_y,
242 x0: row_runs[i].x0,
243 x1: row_runs[j].x1,
244 });
245 if i == 0 {
246 break;
247 }
248 j = i - 1;
249 }
250 spans.reverse();
251
252 RowPlan {
253 spans,
254 total_cost: dp[run_count - 1],
255 }
256 }
257}
258
259#[derive(Debug, Clone, Copy, PartialEq, Eq)]
261struct CellStyle {
262 fg: PackedRgba,
263 bg: PackedRgba,
264 attrs: StyleFlags,
265}
266
267impl Default for CellStyle {
268 fn default() -> Self {
269 Self {
270 fg: PackedRgba::TRANSPARENT,
271 bg: PackedRgba::TRANSPARENT,
272 attrs: StyleFlags::empty(),
273 }
274 }
275}
276impl CellStyle {
277 fn from_cell(cell: &Cell) -> Self {
278 Self {
279 fg: cell.fg,
280 bg: cell.bg,
281 attrs: cell.attrs.flags(),
282 }
283 }
284}
285
286pub struct Presenter<W: Write> {
291 writer: CountingWriter<BufWriter<W>>,
293 current_style: Option<CellStyle>,
295 current_link: Option<u32>,
297 cursor_x: Option<u16>,
299 cursor_y: Option<u16>,
301 capabilities: TerminalCapabilities,
303}
304
305impl<W: Write> Presenter<W> {
306 pub fn new(writer: W, capabilities: TerminalCapabilities) -> Self {
308 Self {
309 writer: CountingWriter::new(BufWriter::with_capacity(BUFFER_CAPACITY, writer)),
310 current_style: None,
311 current_link: None,
312 cursor_x: None,
313 cursor_y: None,
314 capabilities,
315 }
316 }
317
318 #[inline]
320 pub fn capabilities(&self) -> &TerminalCapabilities {
321 &self.capabilities
322 }
323
324 pub fn present(&mut self, buffer: &Buffer, diff: &BufferDiff) -> io::Result<PresentStats> {
333 self.present_with_pool(buffer, diff, None, None)
334 }
335
336 pub fn present_with_pool(
338 &mut self,
339 buffer: &Buffer,
340 diff: &BufferDiff,
341 pool: Option<&GraphemePool>,
342 links: Option<&LinkRegistry>,
343 ) -> io::Result<PresentStats> {
344 #[cfg(feature = "tracing")]
345 let _span = tracing::info_span!(
346 "present",
347 width = buffer.width(),
348 height = buffer.height(),
349 changes = diff.len()
350 );
351 #[cfg(feature = "tracing")]
352 let _guard = _span.enter();
353
354 let runs = diff.runs();
356 let run_count = runs.len();
357 let cells_changed = diff.len();
358
359 self.writer.reset_counter();
361 let collector = StatsCollector::start(cells_changed, run_count);
362
363 if self.capabilities.sync_output {
365 ansi::sync_begin(&mut self.writer)?;
366 }
367
368 self.emit_runs(buffer, &runs, pool, links)?;
370
371 ansi::sgr_reset(&mut self.writer)?;
373 self.current_style = None;
374
375 if self.current_link.is_some() {
377 ansi::hyperlink_end(&mut self.writer)?;
378 self.current_link = None;
379 }
380
381 if self.capabilities.sync_output {
383 ansi::sync_end(&mut self.writer)?;
384 }
385
386 self.writer.flush()?;
387
388 let stats = collector.finish(self.writer.bytes_written());
389
390 #[cfg(feature = "tracing")]
391 {
392 stats.log();
393 tracing::trace!("frame presented");
394 }
395
396 Ok(stats)
397 }
398
399 fn emit_runs(
405 &mut self,
406 buffer: &Buffer,
407 runs: &[ChangeRun],
408 pool: Option<&GraphemePool>,
409 links: Option<&LinkRegistry>,
410 ) -> io::Result<()> {
411 #[cfg(feature = "tracing")]
412 let _span = tracing::debug_span!("emit_diff");
413 #[cfg(feature = "tracing")]
414 let _guard = _span.enter();
415
416 #[cfg(feature = "tracing")]
417 tracing::trace!(run_count = runs.len(), "emitting runs");
418
419 let mut i = 0;
421 while i < runs.len() {
422 let row_y = runs[i].y;
423
424 let row_start = i;
426 while i < runs.len() && runs[i].y == row_y {
427 i += 1;
428 }
429 let row_runs = &runs[row_start..i];
430
431 let plan = cost_model::plan_row(row_runs, self.cursor_x, self.cursor_y);
432
433 #[cfg(feature = "tracing")]
434 tracing::trace!(
435 row = row_y,
436 spans = plan.spans().len(),
437 cost = plan.total_cost(),
438 "row plan"
439 );
440
441 for span in plan.spans() {
442 self.move_cursor_optimal(span.x0, span.y)?;
443 for x in span.x0..=span.x1 {
444 let cell = buffer.get_unchecked(x, span.y);
445 self.emit_cell(x, cell, pool, links)?;
446 }
447 }
448 }
449 Ok(())
450 }
451
452 fn emit_cell(
454 &mut self,
455 x: u16,
456 cell: &Cell,
457 pool: Option<&GraphemePool>,
458 links: Option<&LinkRegistry>,
459 ) -> io::Result<()> {
460 let is_orphan = cell.is_continuation() && self.cursor_x.is_some_and(|cx| cx <= x);
468
469 if cell.is_continuation() && !is_orphan {
470 return Ok(());
471 }
472
473 let effective_cell = if is_orphan { &Cell::default() } else { cell };
475
476 self.emit_style_changes(effective_cell)?;
478
479 self.emit_link_changes(effective_cell, links)?;
481
482 let raw_width = effective_cell.content.width();
485 let is_zero_width_content =
486 raw_width == 0 && !effective_cell.is_empty() && !effective_cell.is_continuation();
487
488 if is_zero_width_content {
489 self.writer.write_all(b"\xEF\xBF\xBD")?;
491 } else {
492 self.emit_content(effective_cell, pool)?;
494 }
495
496 if let Some(cx) = self.cursor_x {
498 let width = if effective_cell.is_empty() || is_zero_width_content {
501 1
502 } else {
503 raw_width
504 };
505 self.cursor_x = Some(cx.saturating_add(width as u16));
506 }
507
508 Ok(())
509 }
510
511 fn emit_style_changes(&mut self, cell: &Cell) -> io::Result<()> {
517 let new_style = CellStyle::from_cell(cell);
518
519 if self.current_style == Some(new_style) {
521 return Ok(());
522 }
523
524 match self.current_style {
525 None => {
526 self.emit_style_full(new_style)?;
529 }
530 Some(old_style) => {
531 self.emit_style_delta(old_style, new_style)?;
532 }
533 }
534
535 self.current_style = Some(new_style);
536 Ok(())
537 }
538
539 fn emit_style_full(&mut self, style: CellStyle) -> io::Result<()> {
541 ansi::sgr_reset(&mut self.writer)?;
542 if style.fg.a() > 0 {
543 ansi::sgr_fg_packed(&mut self.writer, style.fg)?;
544 }
545 if style.bg.a() > 0 {
546 ansi::sgr_bg_packed(&mut self.writer, style.bg)?;
547 }
548 if !style.attrs.is_empty() {
549 ansi::sgr_flags(&mut self.writer, style.attrs)?;
550 }
551 Ok(())
552 }
553
554 #[inline]
555 fn dec_len_u8(value: u8) -> u32 {
556 if value >= 100 {
557 3
558 } else if value >= 10 {
559 2
560 } else {
561 1
562 }
563 }
564
565 #[inline]
566 fn sgr_code_len(code: u8) -> u32 {
567 2 + Self::dec_len_u8(code) + 1
568 }
569
570 #[inline]
571 fn sgr_flags_len(flags: StyleFlags) -> u32 {
572 if flags.is_empty() {
573 return 0;
574 }
575 let mut count = 0u32;
576 let mut digits = 0u32;
577 for (flag, codes) in ansi::FLAG_TABLE {
578 if flags.contains(flag) {
579 count += 1;
580 digits += Self::dec_len_u8(codes.on);
581 }
582 }
583 if count == 0 {
584 return 0;
585 }
586 3 + digits + (count - 1)
587 }
588
589 #[inline]
590 fn sgr_flags_off_len(flags: StyleFlags) -> u32 {
591 if flags.is_empty() {
592 return 0;
593 }
594 let mut len = 0u32;
595 for (flag, codes) in ansi::FLAG_TABLE {
596 if flags.contains(flag) {
597 len += Self::sgr_code_len(codes.off);
598 }
599 }
600 len
601 }
602
603 #[inline]
604 fn sgr_rgb_len(color: PackedRgba) -> u32 {
605 10 + Self::dec_len_u8(color.r()) + Self::dec_len_u8(color.g()) + Self::dec_len_u8(color.b())
606 }
607
608 fn emit_style_delta(&mut self, old: CellStyle, new: CellStyle) -> io::Result<()> {
613 let attrs_removed = old.attrs & !new.attrs;
614 let attrs_added = new.attrs & !old.attrs;
615 let fg_changed = old.fg != new.fg;
616 let bg_changed = old.bg != new.bg;
617
618 let mut collateral = StyleFlags::empty();
619 if attrs_removed.contains(StyleFlags::BOLD) && new.attrs.contains(StyleFlags::DIM) {
620 collateral |= StyleFlags::DIM;
621 }
622 if attrs_removed.contains(StyleFlags::DIM) && new.attrs.contains(StyleFlags::BOLD) {
623 collateral |= StyleFlags::BOLD;
624 }
625
626 let mut delta_len = 0u32;
627 delta_len += Self::sgr_flags_off_len(attrs_removed);
628 delta_len += Self::sgr_flags_len(collateral);
629 delta_len += Self::sgr_flags_len(attrs_added);
630 if fg_changed {
631 delta_len += if new.fg.a() == 0 {
632 5
633 } else {
634 Self::sgr_rgb_len(new.fg)
635 };
636 }
637 if bg_changed {
638 delta_len += if new.bg.a() == 0 {
639 5
640 } else {
641 Self::sgr_rgb_len(new.bg)
642 };
643 }
644
645 let mut baseline_len = 4u32;
646 if new.fg.a() > 0 {
647 baseline_len += Self::sgr_rgb_len(new.fg);
648 }
649 if new.bg.a() > 0 {
650 baseline_len += Self::sgr_rgb_len(new.bg);
651 }
652 baseline_len += Self::sgr_flags_len(new.attrs);
653
654 if delta_len > baseline_len {
655 return self.emit_style_full(new);
656 }
657
658 if !attrs_removed.is_empty() {
660 let collateral = ansi::sgr_flags_off(&mut self.writer, attrs_removed, new.attrs)?;
661 if !collateral.is_empty() {
663 ansi::sgr_flags(&mut self.writer, collateral)?;
664 }
665 }
666
667 if !attrs_added.is_empty() {
669 ansi::sgr_flags(&mut self.writer, attrs_added)?;
670 }
671
672 if fg_changed {
674 ansi::sgr_fg_packed(&mut self.writer, new.fg)?;
675 }
676
677 if bg_changed {
679 ansi::sgr_bg_packed(&mut self.writer, new.bg)?;
680 }
681
682 Ok(())
683 }
684
685 fn emit_link_changes(&mut self, cell: &Cell, links: Option<&LinkRegistry>) -> io::Result<()> {
687 let raw_link_id = cell.attrs.link_id();
688 let new_link = if raw_link_id == CellAttrs::LINK_ID_NONE {
689 None
690 } else {
691 Some(raw_link_id)
692 };
693
694 if self.current_link == new_link {
696 return Ok(());
697 }
698
699 if self.current_link.is_some() {
701 ansi::hyperlink_end(&mut self.writer)?;
702 }
703
704 let actually_opened = if let (Some(link_id), Some(registry)) = (new_link, links)
706 && let Some(url) = registry.get(link_id)
707 {
708 ansi::hyperlink_start(&mut self.writer, url)?;
709 true
710 } else {
711 false
712 };
713
714 self.current_link = if actually_opened { new_link } else { None };
716 Ok(())
717 }
718
719 fn emit_content(&mut self, cell: &Cell, pool: Option<&GraphemePool>) -> io::Result<()> {
721 if let Some(grapheme_id) = cell.content.grapheme_id() {
723 if let Some(pool) = pool
724 && let Some(text) = pool.get(grapheme_id)
725 {
726 return self.writer.write_all(text.as_bytes());
727 }
728 let width = cell.content.width();
731 if width > 0 {
732 for _ in 0..width {
733 self.writer.write_all(b"?")?;
734 }
735 }
736 return Ok(());
737 }
738
739 if let Some(ch) = cell.content.as_char() {
741 let safe_ch = if ch.is_control() { ' ' } else { ch };
743 let mut buf = [0u8; 4];
744 let encoded = safe_ch.encode_utf8(&mut buf);
745 self.writer.write_all(encoded.as_bytes())
746 } else {
747 self.writer.write_all(b" ")
749 }
750 }
751
752 fn move_cursor_to(&mut self, x: u16, y: u16) -> io::Result<()> {
754 if self.cursor_x == Some(x) && self.cursor_y == Some(y) {
756 return Ok(());
757 }
758
759 ansi::cup(&mut self.writer, y, x)?;
761 self.cursor_x = Some(x);
762 self.cursor_y = Some(y);
763 Ok(())
764 }
765
766 fn move_cursor_optimal(&mut self, x: u16, y: u16) -> io::Result<()> {
771 if self.cursor_x == Some(x) && self.cursor_y == Some(y) {
773 return Ok(());
774 }
775
776 let same_row = self.cursor_y == Some(y);
778 let forward = same_row && self.cursor_x.is_some_and(|cx| x > cx);
779
780 if same_row && forward {
781 let dx = x - self.cursor_x.unwrap();
782 let cuf = cost_model::cuf_cost(dx);
783 let cha = cost_model::cha_cost(x);
784 let cup = cost_model::cup_cost(y, x);
785
786 if cuf <= cha && cuf <= cup {
787 ansi::cuf(&mut self.writer, dx)?;
788 } else if cha <= cup {
789 ansi::cha(&mut self.writer, x)?;
790 } else {
791 ansi::cup(&mut self.writer, y, x)?;
792 }
793 } else if same_row {
794 let cha = cost_model::cha_cost(x);
796 let cup = cost_model::cup_cost(y, x);
797 if cha <= cup {
798 ansi::cha(&mut self.writer, x)?;
799 } else {
800 ansi::cup(&mut self.writer, y, x)?;
801 }
802 } else {
803 ansi::cup(&mut self.writer, y, x)?;
805 }
806
807 self.cursor_x = Some(x);
808 self.cursor_y = Some(y);
809 Ok(())
810 }
811
812 pub fn clear_screen(&mut self) -> io::Result<()> {
814 ansi::erase_display(&mut self.writer, ansi::EraseDisplayMode::All)?;
815 ansi::cup(&mut self.writer, 0, 0)?;
816 self.cursor_x = Some(0);
817 self.cursor_y = Some(0);
818 self.writer.flush()
819 }
820
821 pub fn clear_line(&mut self, y: u16) -> io::Result<()> {
823 self.move_cursor_to(0, y)?;
824 ansi::erase_line(&mut self.writer, EraseLineMode::All)?;
825 self.writer.flush()
826 }
827
828 pub fn hide_cursor(&mut self) -> io::Result<()> {
830 ansi::cursor_hide(&mut self.writer)?;
831 self.writer.flush()
832 }
833
834 pub fn show_cursor(&mut self) -> io::Result<()> {
836 ansi::cursor_show(&mut self.writer)?;
837 self.writer.flush()
838 }
839
840 pub fn position_cursor(&mut self, x: u16, y: u16) -> io::Result<()> {
842 self.move_cursor_to(x, y)?;
843 self.writer.flush()
844 }
845
846 pub fn reset(&mut self) {
850 self.current_style = None;
851 self.current_link = None;
852 self.cursor_x = None;
853 self.cursor_y = None;
854 }
855
856 pub fn flush(&mut self) -> io::Result<()> {
858 self.writer.flush()
859 }
860
861 pub fn into_inner(self) -> Result<W, io::Error> {
865 self.writer
866 .into_inner() .into_inner() .map_err(|e| e.into_error())
869 }
870}
871
872#[cfg(test)]
873mod tests {
874 use super::*;
875 use crate::cell::CellAttrs;
876 use crate::link_registry::LinkRegistry;
877
878 fn test_presenter() -> Presenter<Vec<u8>> {
879 let caps = TerminalCapabilities::basic();
880 Presenter::new(Vec::new(), caps)
881 }
882
883 fn test_presenter_with_sync() -> Presenter<Vec<u8>> {
884 let mut caps = TerminalCapabilities::basic();
885 caps.sync_output = true;
886 Presenter::new(Vec::new(), caps)
887 }
888
889 fn get_output(presenter: Presenter<Vec<u8>>) -> Vec<u8> {
890 presenter.into_inner().unwrap()
891 }
892
893 fn legacy_plan_row(
894 row_runs: &[ChangeRun],
895 prev_x: Option<u16>,
896 prev_y: Option<u16>,
897 ) -> Vec<cost_model::RowSpan> {
898 if row_runs.is_empty() {
899 return Vec::new();
900 }
901
902 if row_runs.len() == 1 {
903 let run = row_runs[0];
904 return vec![cost_model::RowSpan {
905 y: run.y,
906 x0: run.x0,
907 x1: run.x1,
908 }];
909 }
910
911 let row_y = row_runs[0].y;
912 let first_x = row_runs[0].x0;
913 let last_x = row_runs[row_runs.len() - 1].x1;
914
915 let mut sparse_cost: usize = 0;
917 let mut cursor_x = prev_x;
918 let mut cursor_y = prev_y;
919
920 for run in row_runs {
921 let move_cost = cost_model::cheapest_move_cost(cursor_x, cursor_y, run.x0, run.y);
922 let cells = (run.x1 - run.x0 + 1) as usize;
923 sparse_cost += move_cost + cells;
924 cursor_x = Some(run.x1.saturating_add(1));
925 cursor_y = Some(row_y);
926 }
927
928 let merge_move = cost_model::cheapest_move_cost(prev_x, prev_y, first_x, row_y);
930 let total_cells = (last_x - first_x + 1) as usize;
931 let changed_cells: usize = row_runs.iter().map(|r| (r.x1 - r.x0 + 1) as usize).sum();
932 let gap_cells = total_cells - changed_cells;
933 let gap_overhead = gap_cells * 2;
934 let merged_cost = merge_move + changed_cells + gap_overhead;
935
936 if merged_cost < sparse_cost {
937 vec![cost_model::RowSpan {
938 y: row_y,
939 x0: first_x,
940 x1: last_x,
941 }]
942 } else {
943 row_runs
944 .iter()
945 .map(|run| cost_model::RowSpan {
946 y: run.y,
947 x0: run.x0,
948 x1: run.x1,
949 })
950 .collect()
951 }
952 }
953
954 fn emit_spans_for_output(buffer: &Buffer, spans: &[cost_model::RowSpan]) -> Vec<u8> {
955 let mut presenter = test_presenter();
956
957 for span in spans {
958 presenter
959 .move_cursor_optimal(span.x0, span.y)
960 .expect("cursor move should succeed");
961 for x in span.x0..=span.x1 {
962 let cell = buffer.get_unchecked(x, span.y);
963 presenter
964 .emit_cell(x, cell, None, None)
965 .expect("emit_cell should succeed");
966 }
967 }
968
969 presenter
970 .writer
971 .write_all(b"\x1b[0m")
972 .expect("reset should succeed");
973
974 presenter.into_inner().expect("presenter output")
975 }
976
977 #[test]
978 fn empty_diff_produces_minimal_output() {
979 let mut presenter = test_presenter();
980 let buffer = Buffer::new(10, 10);
981 let diff = BufferDiff::new();
982
983 presenter.present(&buffer, &diff).unwrap();
984 let output = get_output(presenter);
985
986 assert!(output.starts_with(b"\x1b[0m"));
988 }
989
990 #[test]
991 fn sync_output_wraps_frame() {
992 let mut presenter = test_presenter_with_sync();
993 let mut buffer = Buffer::new(3, 1);
994 buffer.set_raw(0, 0, Cell::from_char('X'));
995
996 let old = Buffer::new(3, 1);
997 let diff = BufferDiff::compute(&old, &buffer);
998
999 presenter.present(&buffer, &diff).unwrap();
1000 let output = get_output(presenter);
1001
1002 assert!(
1003 output.starts_with(ansi::SYNC_BEGIN),
1004 "sync output should begin with DEC 2026 begin"
1005 );
1006 assert!(
1007 output.ends_with(ansi::SYNC_END),
1008 "sync output should end with DEC 2026 end"
1009 );
1010 }
1011
1012 #[test]
1013 fn hyperlink_sequences_emitted_and_closed() {
1014 let mut presenter = test_presenter();
1015 let mut buffer = Buffer::new(3, 1);
1016
1017 let mut registry = LinkRegistry::new();
1018 let link_id = registry.register("https://example.com");
1019 let linked = Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id));
1020 buffer.set_raw(0, 0, linked);
1021
1022 let old = Buffer::new(3, 1);
1023 let diff = BufferDiff::compute(&old, &buffer);
1024
1025 presenter
1026 .present_with_pool(&buffer, &diff, None, Some(®istry))
1027 .unwrap();
1028 let output = get_output(presenter);
1029
1030 let start = b"\x1b]8;;https://example.com\x1b\\";
1031 let end = b"\x1b]8;;\x1b\\";
1032
1033 let start_pos = output
1034 .windows(start.len())
1035 .position(|w| w == start)
1036 .expect("hyperlink start not found");
1037 let end_pos = output
1038 .windows(end.len())
1039 .position(|w| w == end)
1040 .expect("hyperlink end not found");
1041 let char_pos = output
1042 .iter()
1043 .position(|&b| b == b'L')
1044 .expect("linked character not found");
1045
1046 assert!(start_pos < char_pos, "link start should precede text");
1047 assert!(char_pos < end_pos, "link end should follow text");
1048 }
1049
1050 #[test]
1051 fn single_cell_change() {
1052 let mut presenter = test_presenter();
1053 let mut buffer = Buffer::new(10, 10);
1054 buffer.set_raw(5, 5, Cell::from_char('X'));
1055
1056 let old = Buffer::new(10, 10);
1057 let diff = BufferDiff::compute(&old, &buffer);
1058
1059 presenter.present(&buffer, &diff).unwrap();
1060 let output = get_output(presenter);
1061
1062 let output_str = String::from_utf8_lossy(&output);
1064 assert!(output_str.contains("X"));
1065 assert!(output_str.contains("\x1b[")); }
1067
1068 #[test]
1069 fn style_tracking_avoids_redundant_sgr() {
1070 let mut presenter = test_presenter();
1071 let mut buffer = Buffer::new(10, 1);
1072
1073 let fg = PackedRgba::rgb(255, 0, 0);
1075 buffer.set_raw(0, 0, Cell::from_char('A').with_fg(fg));
1076 buffer.set_raw(1, 0, Cell::from_char('B').with_fg(fg));
1077 buffer.set_raw(2, 0, Cell::from_char('C').with_fg(fg));
1078
1079 let old = Buffer::new(10, 1);
1080 let diff = BufferDiff::compute(&old, &buffer);
1081
1082 presenter.present(&buffer, &diff).unwrap();
1083 let output = get_output(presenter);
1084
1085 let output_str = String::from_utf8_lossy(&output);
1087 let sgr_count = output_str.matches("\x1b[38;2").count();
1088 assert_eq!(
1090 sgr_count, 1,
1091 "Expected 1 SGR fg sequence, got {}",
1092 sgr_count
1093 );
1094 }
1095
1096 #[test]
1097 fn reset_reapplies_style_after_clear() {
1098 let mut presenter = test_presenter();
1099 let mut buffer = Buffer::new(1, 1);
1100 let styled = Cell::from_char('A').with_fg(PackedRgba::rgb(10, 20, 30));
1101 buffer.set_raw(0, 0, styled);
1102
1103 let old = Buffer::new(1, 1);
1104 let diff = BufferDiff::compute(&old, &buffer);
1105
1106 presenter.present(&buffer, &diff).unwrap();
1107 presenter.reset();
1108 presenter.present(&buffer, &diff).unwrap();
1109
1110 let output = get_output(presenter);
1111 let output_str = String::from_utf8_lossy(&output);
1112 let sgr_count = output_str.matches("\x1b[38;2").count();
1113
1114 assert_eq!(
1115 sgr_count, 2,
1116 "Expected style to be re-applied after reset, got {sgr_count} sequences"
1117 );
1118 }
1119
1120 #[test]
1121 fn cursor_position_optimized() {
1122 let mut presenter = test_presenter();
1123 let mut buffer = Buffer::new(10, 5);
1124
1125 buffer.set_raw(3, 2, Cell::from_char('A'));
1127 buffer.set_raw(4, 2, Cell::from_char('B'));
1128 buffer.set_raw(5, 2, Cell::from_char('C'));
1129
1130 let old = Buffer::new(10, 5);
1131 let diff = BufferDiff::compute(&old, &buffer);
1132
1133 presenter.present(&buffer, &diff).unwrap();
1134 let output = get_output(presenter);
1135
1136 let output_str = String::from_utf8_lossy(&output);
1138 let _cup_count = output_str.matches("\x1b[").filter(|_| true).count();
1139
1140 assert!(
1142 output_str.contains("ABC")
1143 || (output_str.contains('A')
1144 && output_str.contains('B')
1145 && output_str.contains('C'))
1146 );
1147 }
1148
1149 #[test]
1150 fn sync_output_wrapped_when_supported() {
1151 let mut presenter = test_presenter_with_sync();
1152 let buffer = Buffer::new(10, 10);
1153 let diff = BufferDiff::new();
1154
1155 presenter.present(&buffer, &diff).unwrap();
1156 let output = get_output(presenter);
1157
1158 assert!(output.starts_with(ansi::SYNC_BEGIN));
1160 assert!(
1161 output
1162 .windows(ansi::SYNC_END.len())
1163 .any(|w| w == ansi::SYNC_END)
1164 );
1165 }
1166
1167 #[test]
1168 fn clear_screen_works() {
1169 let mut presenter = test_presenter();
1170 presenter.clear_screen().unwrap();
1171 let output = get_output(presenter);
1172
1173 assert!(output.windows(b"\x1b[2J".len()).any(|w| w == b"\x1b[2J"));
1175 }
1176
1177 #[test]
1178 fn cursor_visibility() {
1179 let mut presenter = test_presenter();
1180
1181 presenter.hide_cursor().unwrap();
1182 presenter.show_cursor().unwrap();
1183
1184 let output = get_output(presenter);
1185 let output_str = String::from_utf8_lossy(&output);
1186
1187 assert!(output_str.contains("\x1b[?25l")); assert!(output_str.contains("\x1b[?25h")); }
1190
1191 #[test]
1192 fn reset_clears_state() {
1193 let mut presenter = test_presenter();
1194 presenter.cursor_x = Some(50);
1195 presenter.cursor_y = Some(20);
1196 presenter.current_style = Some(CellStyle::default());
1197
1198 presenter.reset();
1199
1200 assert!(presenter.cursor_x.is_none());
1201 assert!(presenter.cursor_y.is_none());
1202 assert!(presenter.current_style.is_none());
1203 }
1204
1205 #[test]
1206 fn position_cursor() {
1207 let mut presenter = test_presenter();
1208 presenter.position_cursor(10, 5).unwrap();
1209
1210 let output = get_output(presenter);
1211 assert!(
1213 output
1214 .windows(b"\x1b[6;11H".len())
1215 .any(|w| w == b"\x1b[6;11H")
1216 );
1217 }
1218
1219 #[test]
1220 fn skip_cursor_move_when_already_at_position() {
1221 let mut presenter = test_presenter();
1222 presenter.cursor_x = Some(5);
1223 presenter.cursor_y = Some(3);
1224
1225 presenter.move_cursor_to(5, 3).unwrap();
1227
1228 let output = get_output(presenter);
1230 assert!(output.is_empty());
1231 }
1232
1233 #[test]
1234 fn continuation_cells_skipped() {
1235 let mut presenter = test_presenter();
1236 let mut buffer = Buffer::new(10, 1);
1237
1238 buffer.set_raw(0, 0, Cell::from_char('ä¸'));
1240 buffer.set_raw(1, 0, Cell::CONTINUATION);
1242
1243 let old = Buffer::new(10, 1);
1245 let diff = BufferDiff::compute(&old, &buffer);
1246
1247 presenter.present(&buffer, &diff).unwrap();
1248 let output = get_output(presenter);
1249
1250 let output_str = String::from_utf8_lossy(&output);
1252 assert!(output_str.contains('ä¸'));
1253 }
1254
1255 #[test]
1256 fn wide_char_missing_continuation_causes_drift() {
1257 let mut presenter = test_presenter();
1258 let mut buffer = Buffer::new(10, 1);
1259
1260 buffer.set_raw(0, 0, Cell::from_char('ä¸'));
1262 let old = Buffer::new(10, 1);
1265 let diff = BufferDiff::compute(&old, &buffer);
1266
1267 presenter.present(&buffer, &diff).unwrap();
1268 let output = get_output(presenter);
1269 let _output_str = String::from_utf8_lossy(&output);
1270
1271 }
1306
1307 #[test]
1308 fn hyperlink_emitted_with_registry() {
1309 let mut presenter = test_presenter();
1310 let mut buffer = Buffer::new(10, 1);
1311 let mut links = LinkRegistry::new();
1312
1313 let link_id = links.register("https://example.com");
1314 let cell = Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id));
1315 buffer.set_raw(0, 0, cell);
1316
1317 let old = Buffer::new(10, 1);
1318 let diff = BufferDiff::compute(&old, &buffer);
1319
1320 presenter
1321 .present_with_pool(&buffer, &diff, None, Some(&links))
1322 .unwrap();
1323 let output = get_output(presenter);
1324 let output_str = String::from_utf8_lossy(&output);
1325
1326 assert!(
1328 output_str.contains("\x1b]8;;https://example.com\x1b\\"),
1329 "Expected OSC 8 open, got: {:?}",
1330 output_str
1331 );
1332 assert!(
1334 output_str.contains("\x1b]8;;\x1b\\"),
1335 "Expected OSC 8 close, got: {:?}",
1336 output_str
1337 );
1338 }
1339
1340 #[test]
1341 fn hyperlink_not_emitted_without_registry() {
1342 let mut presenter = test_presenter();
1343 let mut buffer = Buffer::new(10, 1);
1344
1345 let cell = Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), 1));
1347 buffer.set_raw(0, 0, cell);
1348
1349 let old = Buffer::new(10, 1);
1350 let diff = BufferDiff::compute(&old, &buffer);
1351
1352 presenter.present(&buffer, &diff).unwrap();
1354 let output = get_output(presenter);
1355 let output_str = String::from_utf8_lossy(&output);
1356
1357 assert!(
1359 !output_str.contains("\x1b]8;"),
1360 "OSC 8 should not appear without registry, got: {:?}",
1361 output_str
1362 );
1363 }
1364
1365 #[test]
1366 fn hyperlink_not_emitted_for_unknown_id() {
1367 let mut presenter = test_presenter();
1368 let mut buffer = Buffer::new(10, 1);
1369 let links = LinkRegistry::new();
1370
1371 let cell = Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), 42));
1372 buffer.set_raw(0, 0, cell);
1373
1374 let old = Buffer::new(10, 1);
1375 let diff = BufferDiff::compute(&old, &buffer);
1376
1377 presenter
1378 .present_with_pool(&buffer, &diff, None, Some(&links))
1379 .unwrap();
1380 let output = get_output(presenter);
1381 let output_str = String::from_utf8_lossy(&output);
1382
1383 assert!(
1384 !output_str.contains("\x1b]8;"),
1385 "OSC 8 should not appear for unknown link IDs, got: {:?}",
1386 output_str
1387 );
1388 assert!(output_str.contains('L'));
1389 }
1390
1391 #[test]
1392 fn hyperlink_closed_at_frame_end() {
1393 let mut presenter = test_presenter();
1394 let mut buffer = Buffer::new(10, 1);
1395 let mut links = LinkRegistry::new();
1396
1397 let link_id = links.register("https://example.com");
1398 for x in 0..5 {
1400 buffer.set_raw(
1401 x,
1402 0,
1403 Cell::from_char('A').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
1404 );
1405 }
1406
1407 let old = Buffer::new(10, 1);
1408 let diff = BufferDiff::compute(&old, &buffer);
1409
1410 presenter
1411 .present_with_pool(&buffer, &diff, None, Some(&links))
1412 .unwrap();
1413 let output = get_output(presenter);
1414
1415 let close_seq = b"\x1b]8;;\x1b\\";
1417 assert!(
1418 output.windows(close_seq.len()).any(|w| w == close_seq),
1419 "Link must be closed at frame end"
1420 );
1421 }
1422
1423 #[test]
1424 fn hyperlink_transitions_between_links() {
1425 let mut presenter = test_presenter();
1426 let mut buffer = Buffer::new(10, 1);
1427 let mut links = LinkRegistry::new();
1428
1429 let link_a = links.register("https://a.com");
1430 let link_b = links.register("https://b.com");
1431
1432 buffer.set_raw(
1433 0,
1434 0,
1435 Cell::from_char('A').with_attrs(CellAttrs::new(StyleFlags::empty(), link_a)),
1436 );
1437 buffer.set_raw(
1438 1,
1439 0,
1440 Cell::from_char('B').with_attrs(CellAttrs::new(StyleFlags::empty(), link_b)),
1441 );
1442 buffer.set_raw(2, 0, Cell::from_char('C')); let old = Buffer::new(10, 1);
1445 let diff = BufferDiff::compute(&old, &buffer);
1446
1447 presenter
1448 .present_with_pool(&buffer, &diff, None, Some(&links))
1449 .unwrap();
1450 let output = get_output(presenter);
1451 let output_str = String::from_utf8_lossy(&output);
1452
1453 assert!(output_str.contains("https://a.com"));
1455 assert!(output_str.contains("https://b.com"));
1456
1457 let close_count = output_str.matches("\x1b]8;;\x1b\\").count();
1459 assert!(
1460 close_count >= 2,
1461 "Expected at least 2 link close sequences (transition + frame end), got {}",
1462 close_count
1463 );
1464 }
1465
1466 #[test]
1471 fn sync_output_not_wrapped_when_unsupported() {
1472 let mut presenter = test_presenter(); let buffer = Buffer::new(10, 10);
1475 let diff = BufferDiff::new();
1476
1477 presenter.present(&buffer, &diff).unwrap();
1478 let output = get_output(presenter);
1479
1480 assert!(
1482 !output.starts_with(ansi::SYNC_BEGIN),
1483 "Sync begin should not appear when sync_output is disabled"
1484 );
1485 assert!(
1486 !output
1487 .windows(ansi::SYNC_END.len())
1488 .any(|w| w == ansi::SYNC_END),
1489 "Sync end should not appear when sync_output is disabled"
1490 );
1491 }
1492
1493 #[test]
1494 fn present_flushes_buffered_output() {
1495 let mut presenter = test_presenter();
1498 let mut buffer = Buffer::new(5, 1);
1499 buffer.set_raw(0, 0, Cell::from_char('T'));
1500 buffer.set_raw(1, 0, Cell::from_char('E'));
1501 buffer.set_raw(2, 0, Cell::from_char('S'));
1502 buffer.set_raw(3, 0, Cell::from_char('T'));
1503
1504 let old = Buffer::new(5, 1);
1505 let diff = BufferDiff::compute(&old, &buffer);
1506
1507 presenter.present(&buffer, &diff).unwrap();
1508 let output = get_output(presenter);
1509 let output_str = String::from_utf8_lossy(&output);
1510
1511 assert!(
1513 output_str.contains("TEST"),
1514 "Expected 'TEST' in flushed output"
1515 );
1516 }
1517
1518 #[test]
1519 fn present_stats_reports_cells_and_bytes() {
1520 let mut presenter = test_presenter();
1521 let mut buffer = Buffer::new(10, 1);
1522
1523 for i in 0..5 {
1525 buffer.set_raw(i, 0, Cell::from_char('X'));
1526 }
1527
1528 let old = Buffer::new(10, 1);
1529 let diff = BufferDiff::compute(&old, &buffer);
1530
1531 let stats = presenter.present(&buffer, &diff).unwrap();
1532
1533 assert_eq!(stats.cells_changed, 5, "Expected 5 cells changed");
1535 assert!(stats.bytes_emitted > 0, "Expected some bytes written");
1536 assert!(stats.run_count >= 1, "Expected at least 1 run");
1537 }
1538
1539 #[test]
1544 fn cursor_tracking_after_wide_char() {
1545 let mut presenter = test_presenter();
1546 presenter.cursor_x = Some(0);
1547 presenter.cursor_y = Some(0);
1548
1549 let mut buffer = Buffer::new(10, 1);
1550 buffer.set_raw(0, 0, Cell::from_char('ä¸'));
1552 buffer.set_raw(1, 0, Cell::CONTINUATION);
1553 buffer.set_raw(2, 0, Cell::from_char('A'));
1555
1556 let old = Buffer::new(10, 1);
1557 let diff = BufferDiff::compute(&old, &buffer);
1558
1559 presenter.present(&buffer, &diff).unwrap();
1560
1561 let output = get_output(presenter);
1564 let output_str = String::from_utf8_lossy(&output);
1565
1566 assert!(output_str.contains('ä¸'));
1568 assert!(output_str.contains('A'));
1569 }
1570
1571 #[test]
1572 fn cursor_position_after_multiple_runs() {
1573 let mut presenter = test_presenter();
1574 let mut buffer = Buffer::new(20, 3);
1575
1576 buffer.set_raw(0, 0, Cell::from_char('A'));
1578 buffer.set_raw(1, 0, Cell::from_char('B'));
1579 buffer.set_raw(5, 2, Cell::from_char('X'));
1580 buffer.set_raw(6, 2, Cell::from_char('Y'));
1581
1582 let old = Buffer::new(20, 3);
1583 let diff = BufferDiff::compute(&old, &buffer);
1584
1585 presenter.present(&buffer, &diff).unwrap();
1586 let output = get_output(presenter);
1587 let output_str = String::from_utf8_lossy(&output);
1588
1589 assert!(output_str.contains('A'));
1591 assert!(output_str.contains('B'));
1592 assert!(output_str.contains('X'));
1593 assert!(output_str.contains('Y'));
1594
1595 let cup_count = output_str.matches("\x1b[").count();
1597 assert!(
1598 cup_count >= 2,
1599 "Expected at least 2 escape sequences for multiple runs"
1600 );
1601 }
1602
1603 #[test]
1608 fn style_with_all_flags() {
1609 let mut presenter = test_presenter();
1610 let mut buffer = Buffer::new(5, 1);
1611
1612 let all_flags = StyleFlags::BOLD
1614 | StyleFlags::DIM
1615 | StyleFlags::ITALIC
1616 | StyleFlags::UNDERLINE
1617 | StyleFlags::BLINK
1618 | StyleFlags::REVERSE
1619 | StyleFlags::STRIKETHROUGH;
1620
1621 let cell = Cell::from_char('X').with_attrs(CellAttrs::new(all_flags, 0));
1622 buffer.set_raw(0, 0, cell);
1623
1624 let old = Buffer::new(5, 1);
1625 let diff = BufferDiff::compute(&old, &buffer);
1626
1627 presenter.present(&buffer, &diff).unwrap();
1628 let output = get_output(presenter);
1629 let output_str = String::from_utf8_lossy(&output);
1630
1631 assert!(output_str.contains('X'));
1633 assert!(output_str.contains("\x1b["), "Expected SGR sequences");
1635 }
1636
1637 #[test]
1638 fn style_transitions_between_different_colors() {
1639 let mut presenter = test_presenter();
1640 let mut buffer = Buffer::new(3, 1);
1641
1642 buffer.set_raw(
1644 0,
1645 0,
1646 Cell::from_char('R').with_fg(PackedRgba::rgb(255, 0, 0)),
1647 );
1648 buffer.set_raw(
1649 1,
1650 0,
1651 Cell::from_char('G').with_fg(PackedRgba::rgb(0, 255, 0)),
1652 );
1653 buffer.set_raw(
1654 2,
1655 0,
1656 Cell::from_char('B').with_fg(PackedRgba::rgb(0, 0, 255)),
1657 );
1658
1659 let old = Buffer::new(3, 1);
1660 let diff = BufferDiff::compute(&old, &buffer);
1661
1662 presenter.present(&buffer, &diff).unwrap();
1663 let output = get_output(presenter);
1664 let output_str = String::from_utf8_lossy(&output);
1665
1666 assert!(output_str.contains("38;2;255;0;0"), "Expected red fg");
1668 assert!(output_str.contains("38;2;0;255;0"), "Expected green fg");
1669 assert!(output_str.contains("38;2;0;0;255"), "Expected blue fg");
1670 }
1671
1672 #[test]
1677 fn link_at_buffer_boundaries() {
1678 let mut presenter = test_presenter();
1679 let mut buffer = Buffer::new(5, 1);
1680 let mut links = LinkRegistry::new();
1681
1682 let link_id = links.register("https://boundary.test");
1683
1684 buffer.set_raw(
1686 0,
1687 0,
1688 Cell::from_char('F').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
1689 );
1690 buffer.set_raw(
1692 4,
1693 0,
1694 Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
1695 );
1696
1697 let old = Buffer::new(5, 1);
1698 let diff = BufferDiff::compute(&old, &buffer);
1699
1700 presenter
1701 .present_with_pool(&buffer, &diff, None, Some(&links))
1702 .unwrap();
1703 let output = get_output(presenter);
1704 let output_str = String::from_utf8_lossy(&output);
1705
1706 assert!(output_str.contains("https://boundary.test"));
1708 assert!(output_str.contains('F'));
1710 assert!(output_str.contains('L'));
1711 }
1712
1713 #[test]
1714 fn link_state_cleared_after_reset() {
1715 let mut presenter = test_presenter();
1716 let mut links = LinkRegistry::new();
1717 let link_id = links.register("https://example.com");
1718
1719 presenter.current_link = Some(link_id);
1721 presenter.current_style = Some(CellStyle::default());
1722 presenter.cursor_x = Some(5);
1723 presenter.cursor_y = Some(3);
1724
1725 presenter.reset();
1726
1727 assert!(
1729 presenter.current_link.is_none(),
1730 "current_link should be None after reset"
1731 );
1732 assert!(
1733 presenter.current_style.is_none(),
1734 "current_style should be None after reset"
1735 );
1736 assert!(
1737 presenter.cursor_x.is_none(),
1738 "cursor_x should be None after reset"
1739 );
1740 assert!(
1741 presenter.cursor_y.is_none(),
1742 "cursor_y should be None after reset"
1743 );
1744 }
1745
1746 #[test]
1747 fn link_transitions_linked_unlinked_linked() {
1748 let mut presenter = test_presenter();
1749 let mut buffer = Buffer::new(5, 1);
1750 let mut links = LinkRegistry::new();
1751
1752 let link_id = links.register("https://toggle.test");
1753
1754 buffer.set_raw(
1756 0,
1757 0,
1758 Cell::from_char('A').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
1759 );
1760 buffer.set_raw(1, 0, Cell::from_char('B')); buffer.set_raw(
1762 2,
1763 0,
1764 Cell::from_char('C').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
1765 );
1766
1767 let old = Buffer::new(5, 1);
1768 let diff = BufferDiff::compute(&old, &buffer);
1769
1770 presenter
1771 .present_with_pool(&buffer, &diff, None, Some(&links))
1772 .unwrap();
1773 let output = get_output(presenter);
1774 let output_str = String::from_utf8_lossy(&output);
1775
1776 let url_count = output_str.matches("https://toggle.test").count();
1778 assert!(
1779 url_count >= 2,
1780 "Expected link to open at least twice, got {} occurrences",
1781 url_count
1782 );
1783
1784 let close_count = output_str.matches("\x1b]8;;\x1b\\").count();
1786 assert!(
1787 close_count >= 2,
1788 "Expected at least 2 link closes, got {}",
1789 close_count
1790 );
1791 }
1792
1793 #[test]
1798 fn multiple_presents_maintain_correct_state() {
1799 let mut presenter = test_presenter();
1800 let mut buffer = Buffer::new(10, 1);
1801
1802 buffer.set_raw(0, 0, Cell::from_char('1'));
1804 let old = Buffer::new(10, 1);
1805 let diff = BufferDiff::compute(&old, &buffer);
1806 presenter.present(&buffer, &diff).unwrap();
1807
1808 let prev = buffer.clone();
1810 buffer.set_raw(1, 0, Cell::from_char('2'));
1811 let diff = BufferDiff::compute(&prev, &buffer);
1812 presenter.present(&buffer, &diff).unwrap();
1813
1814 let prev = buffer.clone();
1816 buffer.set_raw(2, 0, Cell::from_char('3'));
1817 let diff = BufferDiff::compute(&prev, &buffer);
1818 presenter.present(&buffer, &diff).unwrap();
1819
1820 let output = get_output(presenter);
1821 let output_str = String::from_utf8_lossy(&output);
1822
1823 assert!(output_str.contains('1'));
1825 assert!(output_str.contains('2'));
1826 assert!(output_str.contains('3'));
1827 }
1828
1829 #[test]
1834 fn sgr_delta_fg_only_change_no_reset() {
1835 let mut presenter = test_presenter();
1837 let mut buffer = Buffer::new(3, 1);
1838
1839 let fg1 = PackedRgba::rgb(255, 0, 0);
1840 let fg2 = PackedRgba::rgb(0, 255, 0);
1841 buffer.set_raw(0, 0, Cell::from_char('A').with_fg(fg1));
1842 buffer.set_raw(1, 0, Cell::from_char('B').with_fg(fg2));
1843
1844 let old = Buffer::new(3, 1);
1845 let diff = BufferDiff::compute(&old, &buffer);
1846
1847 presenter.present(&buffer, &diff).unwrap();
1848 let output = get_output(presenter);
1849 let output_str = String::from_utf8_lossy(&output);
1850
1851 let reset_count = output_str.matches("\x1b[0m").count();
1854 assert_eq!(
1856 reset_count, 2,
1857 "Expected 2 resets (initial + frame end), got {} in: {:?}",
1858 reset_count, output_str
1859 );
1860 }
1861
1862 #[test]
1863 fn sgr_delta_bg_only_change_no_reset() {
1864 let mut presenter = test_presenter();
1865 let mut buffer = Buffer::new(3, 1);
1866
1867 let bg1 = PackedRgba::rgb(0, 0, 255);
1868 let bg2 = PackedRgba::rgb(255, 255, 0);
1869 buffer.set_raw(0, 0, Cell::from_char('A').with_bg(bg1));
1870 buffer.set_raw(1, 0, Cell::from_char('B').with_bg(bg2));
1871
1872 let old = Buffer::new(3, 1);
1873 let diff = BufferDiff::compute(&old, &buffer);
1874
1875 presenter.present(&buffer, &diff).unwrap();
1876 let output = get_output(presenter);
1877 let output_str = String::from_utf8_lossy(&output);
1878
1879 let reset_count = output_str.matches("\x1b[0m").count();
1881 assert_eq!(
1882 reset_count, 2,
1883 "Expected 2 resets, got {} in: {:?}",
1884 reset_count, output_str
1885 );
1886 }
1887
1888 #[test]
1889 fn sgr_delta_attr_addition_no_reset() {
1890 let mut presenter = test_presenter();
1891 let mut buffer = Buffer::new(3, 1);
1892
1893 let attrs1 = CellAttrs::new(StyleFlags::BOLD, 0);
1895 let attrs2 = CellAttrs::new(StyleFlags::BOLD | StyleFlags::ITALIC, 0);
1896 buffer.set_raw(0, 0, Cell::from_char('A').with_attrs(attrs1));
1897 buffer.set_raw(1, 0, Cell::from_char('B').with_attrs(attrs2));
1898
1899 let old = Buffer::new(3, 1);
1900 let diff = BufferDiff::compute(&old, &buffer);
1901
1902 presenter.present(&buffer, &diff).unwrap();
1903 let output = get_output(presenter);
1904 let output_str = String::from_utf8_lossy(&output);
1905
1906 let reset_count = output_str.matches("\x1b[0m").count();
1908 assert_eq!(
1909 reset_count, 2,
1910 "Expected 2 resets, got {} in: {:?}",
1911 reset_count, output_str
1912 );
1913 assert!(
1915 output_str.contains("\x1b[3m"),
1916 "Expected italic-on sequence in: {:?}",
1917 output_str
1918 );
1919 }
1920
1921 #[test]
1922 fn sgr_delta_attr_removal_uses_off_code() {
1923 let mut presenter = test_presenter();
1924 let mut buffer = Buffer::new(3, 1);
1925
1926 let attrs1 = CellAttrs::new(StyleFlags::BOLD | StyleFlags::ITALIC, 0);
1928 let attrs2 = CellAttrs::new(StyleFlags::BOLD, 0);
1929 buffer.set_raw(0, 0, Cell::from_char('A').with_attrs(attrs1));
1930 buffer.set_raw(1, 0, Cell::from_char('B').with_attrs(attrs2));
1931
1932 let old = Buffer::new(3, 1);
1933 let diff = BufferDiff::compute(&old, &buffer);
1934
1935 presenter.present(&buffer, &diff).unwrap();
1936 let output = get_output(presenter);
1937 let output_str = String::from_utf8_lossy(&output);
1938
1939 assert!(
1941 output_str.contains("\x1b[23m"),
1942 "Expected italic-off sequence in: {:?}",
1943 output_str
1944 );
1945 let reset_count = output_str.matches("\x1b[0m").count();
1947 assert_eq!(
1948 reset_count, 2,
1949 "Expected 2 resets, got {} in: {:?}",
1950 reset_count, output_str
1951 );
1952 }
1953
1954 #[test]
1955 fn sgr_delta_bold_dim_collateral_re_enables() {
1956 let mut presenter = test_presenter();
1959 let mut buffer = Buffer::new(3, 1);
1960
1961 let attrs1 = CellAttrs::new(StyleFlags::BOLD | StyleFlags::DIM, 0);
1963 let attrs2 = CellAttrs::new(StyleFlags::DIM, 0);
1964 buffer.set_raw(0, 0, Cell::from_char('A').with_attrs(attrs1));
1965 buffer.set_raw(1, 0, Cell::from_char('B').with_attrs(attrs2));
1966
1967 let old = Buffer::new(3, 1);
1968 let diff = BufferDiff::compute(&old, &buffer);
1969
1970 presenter.present(&buffer, &diff).unwrap();
1971 let output = get_output(presenter);
1972 let output_str = String::from_utf8_lossy(&output);
1973
1974 assert!(
1976 output_str.contains("\x1b[22m"),
1977 "Expected bold-off (22) in: {:?}",
1978 output_str
1979 );
1980 assert!(
1981 output_str.contains("\x1b[2m"),
1982 "Expected dim re-enable (2) in: {:?}",
1983 output_str
1984 );
1985 }
1986
1987 #[test]
1988 fn sgr_delta_same_style_no_output() {
1989 let mut presenter = test_presenter();
1990 let mut buffer = Buffer::new(3, 1);
1991
1992 let fg = PackedRgba::rgb(255, 0, 0);
1993 let attrs = CellAttrs::new(StyleFlags::BOLD, 0);
1994 buffer.set_raw(0, 0, Cell::from_char('A').with_fg(fg).with_attrs(attrs));
1995 buffer.set_raw(1, 0, Cell::from_char('B').with_fg(fg).with_attrs(attrs));
1996 buffer.set_raw(2, 0, Cell::from_char('C').with_fg(fg).with_attrs(attrs));
1997
1998 let old = Buffer::new(3, 1);
1999 let diff = BufferDiff::compute(&old, &buffer);
2000
2001 presenter.present(&buffer, &diff).unwrap();
2002 let output = get_output(presenter);
2003 let output_str = String::from_utf8_lossy(&output);
2004
2005 let fg_count = output_str.matches("38;2;255;0;0").count();
2007 assert_eq!(
2008 fg_count, 1,
2009 "Expected 1 fg sequence, got {} in: {:?}",
2010 fg_count, output_str
2011 );
2012 }
2013
2014 #[test]
2015 fn sgr_delta_cost_dominance_never_exceeds_baseline() {
2016 let transitions: Vec<(CellStyle, CellStyle)> = vec![
2019 (
2021 CellStyle {
2022 fg: PackedRgba::rgb(255, 0, 0),
2023 bg: PackedRgba::TRANSPARENT,
2024 attrs: StyleFlags::empty(),
2025 },
2026 CellStyle {
2027 fg: PackedRgba::rgb(0, 255, 0),
2028 bg: PackedRgba::TRANSPARENT,
2029 attrs: StyleFlags::empty(),
2030 },
2031 ),
2032 (
2034 CellStyle {
2035 fg: PackedRgba::TRANSPARENT,
2036 bg: PackedRgba::rgb(255, 0, 0),
2037 attrs: StyleFlags::empty(),
2038 },
2039 CellStyle {
2040 fg: PackedRgba::TRANSPARENT,
2041 bg: PackedRgba::rgb(0, 0, 255),
2042 attrs: StyleFlags::empty(),
2043 },
2044 ),
2045 (
2047 CellStyle {
2048 fg: PackedRgba::rgb(100, 100, 100),
2049 bg: PackedRgba::TRANSPARENT,
2050 attrs: StyleFlags::BOLD,
2051 },
2052 CellStyle {
2053 fg: PackedRgba::rgb(100, 100, 100),
2054 bg: PackedRgba::TRANSPARENT,
2055 attrs: StyleFlags::BOLD | StyleFlags::ITALIC,
2056 },
2057 ),
2058 (
2060 CellStyle {
2061 fg: PackedRgba::rgb(100, 100, 100),
2062 bg: PackedRgba::TRANSPARENT,
2063 attrs: StyleFlags::BOLD | StyleFlags::ITALIC,
2064 },
2065 CellStyle {
2066 fg: PackedRgba::rgb(100, 100, 100),
2067 bg: PackedRgba::TRANSPARENT,
2068 attrs: StyleFlags::BOLD,
2069 },
2070 ),
2071 ];
2072
2073 for (old_style, new_style) in &transitions {
2074 let delta_buf = {
2076 let mut delta_presenter = {
2077 let caps = TerminalCapabilities::basic();
2078 Presenter::new(Vec::new(), caps)
2079 };
2080 delta_presenter.current_style = Some(*old_style);
2081 delta_presenter
2082 .emit_style_delta(*old_style, *new_style)
2083 .unwrap();
2084 delta_presenter.into_inner().unwrap()
2085 };
2086
2087 let reset_buf = {
2089 let mut reset_presenter = {
2090 let caps = TerminalCapabilities::basic();
2091 Presenter::new(Vec::new(), caps)
2092 };
2093 reset_presenter.emit_style_full(*new_style).unwrap();
2094 reset_presenter.into_inner().unwrap()
2095 };
2096
2097 assert!(
2098 delta_buf.len() <= reset_buf.len(),
2099 "Delta ({} bytes) exceeded reset+apply ({} bytes) for {:?} -> {:?}.\n\
2100 Delta: {:?}\nReset: {:?}",
2101 delta_buf.len(),
2102 reset_buf.len(),
2103 old_style,
2104 new_style,
2105 String::from_utf8_lossy(&delta_buf),
2106 String::from_utf8_lossy(&reset_buf),
2107 );
2108 }
2109 }
2110
2111 #[test]
2118 fn sgr_delta_evidence_ledger() {
2119 use std::io::Write as _;
2120
2121 const SEED: u64 = 0xDEAD_BEEF_CAFE;
2123
2124 let mut rng_state = SEED;
2126 let mut next_u64 = || -> u64 {
2127 rng_state = rng_state.wrapping_mul(6364136223846793005).wrapping_add(1);
2128 rng_state
2129 };
2130
2131 let random_style = |rng: &mut dyn FnMut() -> u64| -> CellStyle {
2132 let v = rng();
2133 let fg = if v & 1 == 0 {
2134 PackedRgba::TRANSPARENT
2135 } else {
2136 let r = ((v >> 8) & 0xFF) as u8;
2137 let g = ((v >> 16) & 0xFF) as u8;
2138 let b = ((v >> 24) & 0xFF) as u8;
2139 PackedRgba::rgb(r, g, b)
2140 };
2141 let v2 = rng();
2142 let bg = if v2 & 1 == 0 {
2143 PackedRgba::TRANSPARENT
2144 } else {
2145 let r = ((v2 >> 8) & 0xFF) as u8;
2146 let g = ((v2 >> 16) & 0xFF) as u8;
2147 let b = ((v2 >> 24) & 0xFF) as u8;
2148 PackedRgba::rgb(r, g, b)
2149 };
2150 let attrs = StyleFlags::from_bits_truncate(rng() as u8);
2151 CellStyle { fg, bg, attrs }
2152 };
2153
2154 let mut ledger = Vec::new();
2155 let num_transitions = 200;
2156
2157 for i in 0..num_transitions {
2158 let old_style = random_style(&mut next_u64);
2159 let new_style = random_style(&mut next_u64);
2160
2161 let mut delta_p = {
2163 let caps = TerminalCapabilities::basic();
2164 Presenter::new(Vec::new(), caps)
2165 };
2166 delta_p.current_style = Some(old_style);
2167 delta_p.emit_style_delta(old_style, new_style).unwrap();
2168 let delta_out = delta_p.into_inner().unwrap();
2169
2170 let mut reset_p = {
2172 let caps = TerminalCapabilities::basic();
2173 Presenter::new(Vec::new(), caps)
2174 };
2175 reset_p.emit_style_full(new_style).unwrap();
2176 let reset_out = reset_p.into_inner().unwrap();
2177
2178 let delta_bytes = delta_out.len();
2179 let baseline_bytes = reset_out.len();
2180
2181 let attrs_removed = old_style.attrs & !new_style.attrs;
2183 let removed_count = attrs_removed.bits().count_ones();
2184 let fg_changed = old_style.fg != new_style.fg;
2185 let bg_changed = old_style.bg != new_style.bg;
2186 let used_fallback = removed_count >= 3 && fg_changed && bg_changed;
2187
2188 assert!(
2190 delta_bytes <= baseline_bytes,
2191 "Transition {i}: delta ({delta_bytes}B) > baseline ({baseline_bytes}B)"
2192 );
2193
2194 writeln!(
2196 &mut ledger,
2197 "{{\"seed\":{SEED},\"i\":{i},\"from_fg\":\"{:?}\",\"from_bg\":\"{:?}\",\
2198 \"from_attrs\":{},\"to_fg\":\"{:?}\",\"to_bg\":\"{:?}\",\"to_attrs\":{},\
2199 \"delta_bytes\":{delta_bytes},\"baseline_bytes\":{baseline_bytes},\
2200 \"cost_delta\":{},\"used_fallback\":{used_fallback}}}",
2201 old_style.fg,
2202 old_style.bg,
2203 old_style.attrs.bits(),
2204 new_style.fg,
2205 new_style.bg,
2206 new_style.attrs.bits(),
2207 baseline_bytes as isize - delta_bytes as isize,
2208 )
2209 .unwrap();
2210 }
2211
2212 let text = String::from_utf8(ledger).unwrap();
2214 let lines: Vec<&str> = text.lines().collect();
2215 assert_eq!(lines.len(), num_transitions);
2216
2217 let mut total_saved: isize = 0;
2219 for line in &lines {
2220 let cd_start = line.find("\"cost_delta\":").unwrap() + 13;
2222 let cd_end = line[cd_start..].find(',').unwrap() + cd_start;
2223 let cd: isize = line[cd_start..cd_end].parse().unwrap();
2224 total_saved += cd;
2225 }
2226 assert!(
2227 total_saved >= 0,
2228 "Total byte savings should be non-negative, got {total_saved}"
2229 );
2230 }
2231
2232 #[test]
2235 fn e2e_style_stress_with_byte_metrics() {
2236 let width = 40u16;
2237 let height = 10u16;
2238
2239 let mut buffer = Buffer::new(width, height);
2241 for y in 0..height {
2242 for x in 0..width {
2243 let i = (y as usize * width as usize + x as usize) as u8;
2244 let fg = PackedRgba::rgb(i, 255 - i, i.wrapping_mul(3));
2245 let bg = if i.is_multiple_of(4) {
2246 PackedRgba::rgb(i.wrapping_mul(7), i.wrapping_mul(11), i.wrapping_mul(13))
2247 } else {
2248 PackedRgba::TRANSPARENT
2249 };
2250 let flags = StyleFlags::from_bits_truncate(i % 128);
2251 let ch = char::from_u32(('!' as u32) + (i as u32 % 90)).unwrap_or('?');
2252 let cell = Cell::from_char(ch)
2253 .with_fg(fg)
2254 .with_bg(bg)
2255 .with_attrs(CellAttrs::new(flags, 0));
2256 buffer.set_raw(x, y, cell);
2257 }
2258 }
2259
2260 let blank = Buffer::new(width, height);
2262 let diff = BufferDiff::compute(&blank, &buffer);
2263 let mut presenter = test_presenter();
2264 presenter.present(&buffer, &diff).unwrap();
2265 let frame1_bytes = presenter.into_inner().unwrap().len();
2266
2267 let mut buffer2 = Buffer::new(width, height);
2269 for y in 0..height {
2270 for x in 0..width {
2271 let i = (y as usize * width as usize + x as usize + 1) as u8;
2272 let fg = PackedRgba::rgb(i, 255 - i, i.wrapping_mul(3));
2273 let bg = if i.is_multiple_of(4) {
2274 PackedRgba::rgb(i.wrapping_mul(7), i.wrapping_mul(11), i.wrapping_mul(13))
2275 } else {
2276 PackedRgba::TRANSPARENT
2277 };
2278 let flags = StyleFlags::from_bits_truncate(i % 128);
2279 let ch = char::from_u32(('!' as u32) + (i as u32 % 90)).unwrap_or('?');
2280 let cell = Cell::from_char(ch)
2281 .with_fg(fg)
2282 .with_bg(bg)
2283 .with_attrs(CellAttrs::new(flags, 0));
2284 buffer2.set_raw(x, y, cell);
2285 }
2286 }
2287
2288 let diff2 = BufferDiff::compute(&buffer, &buffer2);
2290 let mut presenter2 = test_presenter();
2291 presenter2.present(&buffer2, &diff2).unwrap();
2292 let frame2_bytes = presenter2.into_inner().unwrap().len();
2293
2294 assert!(
2297 frame2_bytes > 0,
2298 "Second frame should produce output for style churn"
2299 );
2300 assert!(!diff2.is_empty(), "Style shift should produce changes");
2301
2302 assert!(
2307 frame2_bytes <= frame1_bytes * 2,
2308 "Incremental frame ({frame2_bytes}B) unreasonably large vs full ({frame1_bytes}B)"
2309 );
2310 }
2311
2312 #[test]
2317 fn cost_model_empty_row_single_run() {
2318 let runs = [ChangeRun::new(5, 10, 20)];
2320 let plan = cost_model::plan_row(&runs, None, None);
2321 assert_eq!(plan.spans().len(), 1);
2322 assert_eq!(plan.spans()[0].x0, 10);
2323 assert_eq!(plan.spans()[0].x1, 20);
2324 assert!(plan.total_cost() > 0);
2325 }
2326
2327 #[test]
2328 fn cost_model_full_row_merges() {
2329 let runs = [ChangeRun::new(0, 0, 2), ChangeRun::new(0, 77, 79)];
2335 let plan = cost_model::plan_row(&runs, None, None);
2336 assert_eq!(plan.spans().len(), 2);
2338 assert_eq!(plan.spans()[0].x0, 0);
2339 assert_eq!(plan.spans()[0].x1, 2);
2340 assert_eq!(plan.spans()[1].x0, 77);
2341 assert_eq!(plan.spans()[1].x1, 79);
2342 }
2343
2344 #[test]
2345 fn cost_model_adjacent_runs_merge() {
2346 let runs = [
2349 ChangeRun::new(3, 10, 10),
2350 ChangeRun::new(3, 12, 12),
2351 ChangeRun::new(3, 14, 14),
2352 ChangeRun::new(3, 16, 16),
2353 ChangeRun::new(3, 18, 18),
2354 ChangeRun::new(3, 20, 20),
2355 ChangeRun::new(3, 22, 22),
2356 ChangeRun::new(3, 24, 24),
2357 ];
2358 let plan = cost_model::plan_row(&runs, None, None);
2359 assert_eq!(plan.spans().len(), 1);
2362 assert_eq!(plan.spans()[0].x0, 10);
2363 assert_eq!(plan.spans()[0].x1, 24);
2364 }
2365
2366 #[test]
2367 fn cost_model_single_cell_stays_sparse() {
2368 let runs = [ChangeRun::new(0, 40, 40)];
2369 let plan = cost_model::plan_row(&runs, Some(0), Some(0));
2370 assert_eq!(plan.spans().len(), 1);
2371 assert_eq!(plan.spans()[0].x0, 40);
2372 assert_eq!(plan.spans()[0].x1, 40);
2373 }
2374
2375 #[test]
2376 fn cost_model_cup_vs_cha_vs_cuf() {
2377 assert!(cost_model::cuf_cost(1) <= cost_model::cha_cost(5));
2379 assert!(cost_model::cuf_cost(3) <= cost_model::cup_cost(0, 5));
2380
2381 let cha = cost_model::cha_cost(5);
2383 let cup = cost_model::cup_cost(0, 5);
2384 assert!(cha <= cup);
2385
2386 let cost = cost_model::cheapest_move_cost(Some(5), Some(0), 6, 0);
2388 assert_eq!(cost, 3); }
2390
2391 #[test]
2392 fn cost_model_digit_estimation_accuracy() {
2393 let mut buf = Vec::new();
2395 ansi::cup(&mut buf, 0, 0).unwrap();
2396 assert_eq!(buf.len(), cost_model::cup_cost(0, 0));
2397
2398 buf.clear();
2399 ansi::cup(&mut buf, 9, 9).unwrap();
2400 assert_eq!(buf.len(), cost_model::cup_cost(9, 9));
2401
2402 buf.clear();
2403 ansi::cup(&mut buf, 99, 99).unwrap();
2404 assert_eq!(buf.len(), cost_model::cup_cost(99, 99));
2405
2406 buf.clear();
2407 ansi::cha(&mut buf, 0).unwrap();
2408 assert_eq!(buf.len(), cost_model::cha_cost(0));
2409
2410 buf.clear();
2411 ansi::cuf(&mut buf, 1).unwrap();
2412 assert_eq!(buf.len(), cost_model::cuf_cost(1));
2413
2414 buf.clear();
2415 ansi::cuf(&mut buf, 10).unwrap();
2416 assert_eq!(buf.len(), cost_model::cuf_cost(10));
2417 }
2418
2419 #[test]
2420 fn cost_model_merged_row_produces_correct_output() {
2421 let width = 30u16;
2423 let mut buffer = Buffer::new(width, 1);
2424
2425 for col in [5u16, 10, 15, 20] {
2427 let ch = char::from_u32('A' as u32 + col as u32 % 26).unwrap();
2428 buffer.set_raw(col, 0, Cell::from_char(ch));
2429 }
2430
2431 let old = Buffer::new(width, 1);
2432 let diff = BufferDiff::compute(&old, &buffer);
2433
2434 let mut presenter = test_presenter();
2436 presenter.present(&buffer, &diff).unwrap();
2437 let output = presenter.into_inner().unwrap();
2438 let output_str = String::from_utf8_lossy(&output);
2439
2440 for col in [5u16, 10, 15, 20] {
2441 let ch = char::from_u32('A' as u32 + col as u32 % 26).unwrap();
2442 assert!(
2443 output_str.contains(ch),
2444 "Missing character '{ch}' at col {col} in output"
2445 );
2446 }
2447 }
2448
2449 #[test]
2450 fn cost_model_optimal_cursor_uses_cuf_on_same_row() {
2451 let mut presenter = test_presenter();
2453 presenter.cursor_x = Some(5);
2454 presenter.cursor_y = Some(0);
2455 presenter.move_cursor_optimal(6, 0).unwrap();
2456 let output = presenter.into_inner().unwrap();
2457 assert_eq!(&output, b"\x1b[C", "Should use CUF for +1 column move");
2459 }
2460
2461 #[test]
2462 fn cost_model_optimal_cursor_uses_cha_on_same_row_backward() {
2463 let mut presenter = test_presenter();
2464 presenter.cursor_x = Some(10);
2465 presenter.cursor_y = Some(3);
2466
2467 let target_x = 2;
2468 let target_y = 3;
2469 let cha_cost = cost_model::cha_cost(target_x);
2470 let cup_cost = cost_model::cup_cost(target_y, target_x);
2471 assert!(
2472 cha_cost <= cup_cost,
2473 "Expected CHA to be cheaper for backward move (cha={cha_cost}, cup={cup_cost})"
2474 );
2475
2476 presenter.move_cursor_optimal(target_x, target_y).unwrap();
2477 let output = presenter.into_inner().unwrap();
2478 let mut expected = Vec::new();
2479 ansi::cha(&mut expected, target_x).unwrap();
2480 assert_eq!(output, expected, "Should use CHA for backward move");
2481 }
2482
2483 #[test]
2484 fn cost_model_optimal_cursor_uses_cup_on_row_change() {
2485 let mut presenter = test_presenter();
2486 presenter.cursor_x = Some(4);
2487 presenter.cursor_y = Some(1);
2488
2489 presenter.move_cursor_optimal(7, 4).unwrap();
2490 let output = presenter.into_inner().unwrap();
2491 let mut expected = Vec::new();
2492 ansi::cup(&mut expected, 4, 7).unwrap();
2493 assert_eq!(output, expected, "Should use CUP when row changes");
2494 }
2495
2496 #[test]
2497 fn cost_model_chooses_full_row_when_cheaper() {
2498 let width = 40u16;
2501 let mut buffer = Buffer::new(width, 1);
2502
2503 for col in (0..20).step_by(2) {
2505 buffer.set_raw(col, 0, Cell::from_char('X'));
2506 }
2507
2508 let old = Buffer::new(width, 1);
2509 let diff = BufferDiff::compute(&old, &buffer);
2510 let runs = diff.runs();
2511
2512 let row_runs: Vec<_> = runs.iter().filter(|r| r.y == 0).copied().collect();
2514 if row_runs.len() > 1 {
2515 let plan = cost_model::plan_row(&row_runs, None, None);
2516 assert!(
2517 plan.spans().len() == 1,
2518 "Expected single merged span for many small runs, got {} spans",
2519 plan.spans().len()
2520 );
2521 assert_eq!(plan.spans()[0].x0, 0);
2522 assert_eq!(plan.spans()[0].x1, 18);
2523 }
2524 }
2525
2526 #[test]
2527 fn perf_cost_model_overhead() {
2528 use std::time::Instant;
2530
2531 let runs: Vec<ChangeRun> = (0..100)
2532 .map(|i| ChangeRun::new(0, i * 3, i * 3 + 1))
2533 .collect();
2534
2535 let (iterations, max_ms) = if cfg!(debug_assertions) {
2536 (1_000, 1_000u128)
2537 } else {
2538 (10_000, 500u128)
2539 };
2540
2541 let start = Instant::now();
2542 for _ in 0..iterations {
2543 let _ = cost_model::plan_row(&runs, None, None);
2544 }
2545 let elapsed = start.elapsed();
2546
2547 assert!(
2549 elapsed.as_millis() < max_ms,
2550 "Cost model planning too slow: {elapsed:?} for {iterations} iterations"
2551 );
2552 }
2553
2554 #[test]
2555 fn perf_legacy_vs_dp_worst_case_sparse() {
2556 use std::time::Instant;
2557
2558 let width = 200u16;
2559 let height = 1u16;
2560 let mut buffer = Buffer::new(width, height);
2561
2562 for col in (0..40).step_by(2) {
2564 buffer.set_raw(col, 0, Cell::from_char('X'));
2565 }
2566 for col in (160..200).step_by(2) {
2567 buffer.set_raw(col, 0, Cell::from_char('Y'));
2568 }
2569
2570 let blank = Buffer::new(width, height);
2571 let diff = BufferDiff::compute(&blank, &buffer);
2572 let runs = diff.runs();
2573 let row_runs: Vec<_> = runs.iter().filter(|r| r.y == 0).copied().collect();
2574
2575 let dp_plan = cost_model::plan_row(&row_runs, None, None);
2576 let legacy_spans = legacy_plan_row(&row_runs, None, None);
2577
2578 let dp_output = emit_spans_for_output(&buffer, dp_plan.spans());
2579 let legacy_output = emit_spans_for_output(&buffer, &legacy_spans);
2580
2581 assert!(
2582 dp_output.len() <= legacy_output.len(),
2583 "DP output should be <= legacy output (dp={}, legacy={})",
2584 dp_output.len(),
2585 legacy_output.len()
2586 );
2587
2588 let (iterations, max_ms) = if cfg!(debug_assertions) {
2589 (1_000, 1_000u128)
2590 } else {
2591 (10_000, 500u128)
2592 };
2593 let start = Instant::now();
2594 for _ in 0..iterations {
2595 let _ = cost_model::plan_row(&row_runs, None, None);
2596 }
2597 let dp_elapsed = start.elapsed();
2598
2599 let start = Instant::now();
2600 for _ in 0..iterations {
2601 let _ = legacy_plan_row(&row_runs, None, None);
2602 }
2603 let legacy_elapsed = start.elapsed();
2604
2605 assert!(
2606 dp_elapsed.as_millis() < max_ms,
2607 "DP planning too slow: {dp_elapsed:?} for {iterations} iterations"
2608 );
2609
2610 let _ = legacy_elapsed;
2611 }
2612
2613 fn build_style_heavy_scene(width: u16, height: u16, seed: u64) -> Buffer {
2619 let mut buffer = Buffer::new(width, height);
2620 let mut rng = seed;
2621 let mut next = || -> u64 {
2622 rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1);
2623 rng
2624 };
2625 for y in 0..height {
2626 for x in 0..width {
2627 let v = next();
2628 let ch = char::from_u32(('!' as u32) + (v as u32 % 90)).unwrap_or('?');
2629 let fg = PackedRgba::rgb((v >> 8) as u8, (v >> 16) as u8, (v >> 24) as u8);
2630 let bg = if v & 3 == 0 {
2631 PackedRgba::rgb((v >> 32) as u8, (v >> 40) as u8, (v >> 48) as u8)
2632 } else {
2633 PackedRgba::TRANSPARENT
2634 };
2635 let flags = StyleFlags::from_bits_truncate((v >> 56) as u8);
2636 let cell = Cell::from_char(ch)
2637 .with_fg(fg)
2638 .with_bg(bg)
2639 .with_attrs(CellAttrs::new(flags, 0));
2640 buffer.set_raw(x, y, cell);
2641 }
2642 }
2643 buffer
2644 }
2645
2646 fn build_sparse_update(base: &Buffer, seed: u64) -> Buffer {
2648 let mut buffer = base.clone();
2649 let width = base.width();
2650 let height = base.height();
2651 let mut rng = seed;
2652 let mut next = || -> u64 {
2653 rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1);
2654 rng
2655 };
2656 let change_count = (width as usize * height as usize) / 10;
2657 for _ in 0..change_count {
2658 let v = next();
2659 let x = (v % width as u64) as u16;
2660 let y = ((v >> 16) % height as u64) as u16;
2661 let ch = char::from_u32(('A' as u32) + (v as u32 % 26)).unwrap_or('?');
2662 buffer.set_raw(x, y, Cell::from_char(ch));
2663 }
2664 buffer
2665 }
2666
2667 #[test]
2668 fn snapshot_presenter_equivalence() {
2669 let buffer = build_style_heavy_scene(40, 10, 0xDEAD_CAFE_1234);
2672 let blank = Buffer::new(40, 10);
2673 let diff = BufferDiff::compute(&blank, &buffer);
2674
2675 let mut presenter = test_presenter();
2676 presenter.present(&buffer, &diff).unwrap();
2677 let output = presenter.into_inner().unwrap();
2678
2679 let checksum = {
2681 let mut hash: u64 = 0xcbf29ce484222325; for &byte in &output {
2683 hash ^= byte as u64;
2684 hash = hash.wrapping_mul(0x100000001b3); }
2686 hash
2687 };
2688
2689 let mut presenter2 = test_presenter();
2691 presenter2.present(&buffer, &diff).unwrap();
2692 let output2 = presenter2.into_inner().unwrap();
2693 assert_eq!(output, output2, "Presenter output must be deterministic");
2694
2695 let _ = checksum; }
2698
2699 #[test]
2700 fn perf_presenter_microbench() {
2701 use std::env;
2702 use std::io::Write as _;
2703 use std::time::Instant;
2704
2705 let width = 120u16;
2706 let height = 40u16;
2707 let seed = 0x00BE_EFCA_FE42;
2708 let scene = build_style_heavy_scene(width, height, seed);
2709 let blank = Buffer::new(width, height);
2710 let diff_full = BufferDiff::compute(&blank, &scene);
2711
2712 let scene2 = build_sparse_update(&scene, seed.wrapping_add(1));
2714 let diff_sparse = BufferDiff::compute(&scene, &scene2);
2715
2716 let mut jsonl = Vec::new();
2717 let iterations = env::var("FTUI_PRESENTER_BENCH_ITERS")
2718 .ok()
2719 .and_then(|value| value.parse::<u32>().ok())
2720 .unwrap_or(50);
2721
2722 let runs_full = diff_full.runs();
2723 let runs_sparse = diff_sparse.runs();
2724
2725 let plan_rows = |runs: &[ChangeRun]| -> (usize, usize) {
2726 let mut idx = 0;
2727 let mut total_cost = 0usize;
2728 let mut span_count = 0usize;
2729 let mut prev_x = None;
2730 let mut prev_y = None;
2731
2732 while idx < runs.len() {
2733 let y = runs[idx].y;
2734 let start = idx;
2735 while idx < runs.len() && runs[idx].y == y {
2736 idx += 1;
2737 }
2738
2739 let plan = cost_model::plan_row(&runs[start..idx], prev_x, prev_y);
2740 span_count += plan.spans().len();
2741 total_cost = total_cost.saturating_add(plan.total_cost());
2742 if let Some(last) = plan.spans().last() {
2743 prev_x = Some(last.x1);
2744 prev_y = Some(y);
2745 }
2746 }
2747
2748 (total_cost, span_count)
2749 };
2750
2751 for i in 0..iterations {
2752 let (diff_ref, buf_ref, runs_ref, label) = if i % 2 == 0 {
2753 (&diff_full, &scene, &runs_full, "full")
2754 } else {
2755 (&diff_sparse, &scene2, &runs_sparse, "sparse")
2756 };
2757
2758 let plan_start = Instant::now();
2759 let (plan_cost, plan_spans) = plan_rows(runs_ref);
2760 let plan_time_us = plan_start.elapsed().as_micros() as u64;
2761
2762 let mut presenter = test_presenter();
2763 let start = Instant::now();
2764 let stats = presenter.present(buf_ref, diff_ref).unwrap();
2765 let elapsed_us = start.elapsed().as_micros() as u64;
2766 let output = presenter.into_inner().unwrap();
2767
2768 let checksum = {
2770 let mut hash: u64 = 0xcbf29ce484222325;
2771 for &b in &output {
2772 hash ^= b as u64;
2773 hash = hash.wrapping_mul(0x100000001b3);
2774 }
2775 hash
2776 };
2777
2778 writeln!(
2779 &mut jsonl,
2780 "{{\"seed\":{seed},\"width\":{width},\"height\":{height},\
2781 \"scene\":\"{label}\",\"changes\":{},\"runs\":{},\
2782 \"plan_cost\":{plan_cost},\"plan_spans\":{plan_spans},\
2783 \"plan_time_us\":{plan_time_us},\"bytes\":{},\
2784 \"emit_time_us\":{elapsed_us},\
2785 \"checksum\":\"{checksum:016x}\"}}",
2786 stats.cells_changed, stats.run_count, stats.bytes_emitted,
2787 )
2788 .unwrap();
2789 }
2790
2791 let text = String::from_utf8(jsonl).unwrap();
2792 let lines: Vec<&str> = text.lines().collect();
2793 assert_eq!(lines.len(), iterations as usize);
2794
2795 let full_checksums: Vec<&str> = lines
2797 .iter()
2798 .filter(|l| l.contains("\"full\""))
2799 .map(|l| {
2800 let start = l.find("\"checksum\":\"").unwrap() + 12;
2801 let end = l[start..].find('"').unwrap() + start;
2802 &l[start..end]
2803 })
2804 .collect();
2805 assert!(full_checksums.len() > 1);
2806 assert!(
2807 full_checksums.windows(2).all(|w| w[0] == w[1]),
2808 "Full frame checksums should be identical across runs"
2809 );
2810
2811 let full_bytes: Vec<u64> = lines
2813 .iter()
2814 .filter(|l| l.contains("\"full\""))
2815 .map(|l| {
2816 let start = l.find("\"bytes\":").unwrap() + 8;
2817 let end = l[start..].find(',').unwrap() + start;
2818 l[start..end].parse::<u64>().unwrap()
2819 })
2820 .collect();
2821 let sparse_bytes: Vec<u64> = lines
2822 .iter()
2823 .filter(|l| l.contains("\"sparse\""))
2824 .map(|l| {
2825 let start = l.find("\"bytes\":").unwrap() + 8;
2826 let end = l[start..].find(',').unwrap() + start;
2827 l[start..end].parse::<u64>().unwrap()
2828 })
2829 .collect();
2830
2831 let avg_full: u64 = full_bytes.iter().sum::<u64>() / full_bytes.len() as u64;
2832 let avg_sparse: u64 = sparse_bytes.iter().sum::<u64>() / sparse_bytes.len() as u64;
2833 assert!(
2834 avg_sparse < avg_full,
2835 "Sparse updates ({avg_sparse}B) should emit fewer bytes than full ({avg_full}B)"
2836 );
2837 }
2838
2839 #[test]
2840 fn perf_emit_style_delta_microbench() {
2841 use std::env;
2842 use std::io::Write as _;
2843 use std::time::Instant;
2844
2845 let iterations = env::var("FTUI_EMIT_STYLE_BENCH_ITERS")
2846 .ok()
2847 .and_then(|value| value.parse::<u32>().ok())
2848 .unwrap_or(200);
2849 let mode = env::var("FTUI_EMIT_STYLE_BENCH_MODE").unwrap_or_default();
2850 let emit_json = mode != "raw";
2851
2852 let mut styles = Vec::with_capacity(128);
2853 let mut rng = 0x00A5_A51E_AF42_u64;
2854 let mut next = || -> u64 {
2855 rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1);
2856 rng
2857 };
2858
2859 for _ in 0..128 {
2860 let v = next();
2861 let fg = PackedRgba::rgb(
2862 (v & 0xFF) as u8,
2863 ((v >> 8) & 0xFF) as u8,
2864 ((v >> 16) & 0xFF) as u8,
2865 );
2866 let bg = PackedRgba::rgb(
2867 ((v >> 24) & 0xFF) as u8,
2868 ((v >> 32) & 0xFF) as u8,
2869 ((v >> 40) & 0xFF) as u8,
2870 );
2871 let flags = StyleFlags::from_bits_truncate((v >> 48) as u8);
2872 let cell = Cell::from_char('A')
2873 .with_fg(fg)
2874 .with_bg(bg)
2875 .with_attrs(CellAttrs::new(flags, 0));
2876 styles.push(CellStyle::from_cell(&cell));
2877 }
2878
2879 let mut presenter = test_presenter();
2880 let mut jsonl = Vec::new();
2881 let mut sink = 0u64;
2882
2883 for i in 0..iterations {
2884 let old = styles[i as usize % styles.len()];
2885 let new = styles[(i as usize + 1) % styles.len()];
2886
2887 presenter.writer.reset_counter();
2888 presenter.writer.inner_mut().get_mut().clear();
2889
2890 let start = Instant::now();
2891 presenter.emit_style_delta(old, new).unwrap();
2892 let elapsed_us = start.elapsed().as_micros() as u64;
2893 let bytes = presenter.writer.bytes_written();
2894
2895 if emit_json {
2896 writeln!(
2897 &mut jsonl,
2898 "{{\"iter\":{i},\"emit_time_us\":{elapsed_us},\"bytes\":{bytes}}}"
2899 )
2900 .unwrap();
2901 } else {
2902 sink = sink.wrapping_add(elapsed_us ^ bytes);
2903 }
2904 }
2905
2906 if emit_json {
2907 let text = String::from_utf8(jsonl).unwrap();
2908 let lines: Vec<&str> = text.lines().collect();
2909 assert_eq!(lines.len() as u32, iterations);
2910 } else {
2911 std::hint::black_box(sink);
2912 }
2913 }
2914
2915 #[test]
2916 fn e2e_presenter_stress_deterministic() {
2917 use crate::terminal_model::TerminalModel;
2920
2921 let width = 60u16;
2922 let height = 20u16;
2923 let num_frames = 10;
2924
2925 let mut prev_buffer = Buffer::new(width, height);
2926 let mut presenter = test_presenter();
2927 let mut model = TerminalModel::new(width as usize, height as usize);
2928 let mut rng = 0x5D2E_55DE_5D42_u64;
2929 let mut next = || -> u64 {
2930 rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1);
2931 rng
2932 };
2933
2934 for _frame in 0..num_frames {
2935 let mut buffer = prev_buffer.clone();
2937 let changes = (width as usize * height as usize) / 5;
2938 for _ in 0..changes {
2939 let v = next();
2940 let x = (v % width as u64) as u16;
2941 let y = ((v >> 16) % height as u64) as u16;
2942 let ch = char::from_u32(('!' as u32) + (v as u32 % 90)).unwrap_or('?');
2943 let fg = PackedRgba::rgb((v >> 8) as u8, (v >> 24) as u8, (v >> 40) as u8);
2944 let cell = Cell::from_char(ch).with_fg(fg);
2945 buffer.set_raw(x, y, cell);
2946 }
2947
2948 let diff = BufferDiff::compute(&prev_buffer, &buffer);
2949 presenter.present(&buffer, &diff).unwrap();
2950
2951 prev_buffer = buffer;
2952 }
2953
2954 let output = presenter.into_inner().unwrap();
2956 model.process(&output);
2957
2958 let mut checked = 0;
2960 for y in 0..height {
2961 for x in 0..width {
2962 let buf_cell = prev_buffer.get_unchecked(x, y);
2963 if !buf_cell.is_empty()
2964 && let Some(model_cell) = model.cell(x as usize, y as usize)
2965 {
2966 let expected = buf_cell.content.as_char().unwrap_or(' ');
2967 let mut buf = [0u8; 4];
2968 let expected_str = expected.encode_utf8(&mut buf);
2969 if model_cell.text.as_str() == expected_str {
2970 checked += 1;
2971 }
2972 }
2973 }
2974 }
2975
2976 let total_nonempty = (0..height)
2979 .flat_map(|y| (0..width).map(move |x| (x, y)))
2980 .filter(|&(x, y)| !prev_buffer.get_unchecked(x, y).is_empty())
2981 .count();
2982
2983 assert!(
2984 checked > total_nonempty * 80 / 100,
2985 "Frame {num_frames}: only {checked}/{total_nonempty} cells match final buffer"
2986 );
2987 }
2988
2989 #[test]
2990 fn style_state_persists_across_frames() {
2991 let mut presenter = test_presenter();
2992 let fg = PackedRgba::rgb(100, 150, 200);
2993
2994 let mut buffer = Buffer::new(5, 1);
2996 buffer.set_raw(0, 0, Cell::from_char('A').with_fg(fg));
2997 let old = Buffer::new(5, 1);
2998 let diff = BufferDiff::compute(&old, &buffer);
2999 presenter.present(&buffer, &diff).unwrap();
3000
3001 assert!(
3004 presenter.current_style.is_none(),
3005 "Style should be reset after frame end"
3006 );
3007 }
3008
3009 #[test]
3010 fn zero_width_chars_replaced_with_placeholder() {
3011 let mut presenter = test_presenter();
3012 let mut buffer = Buffer::new(5, 1);
3013
3014 let zw_char = '\u{0301}';
3018
3019 assert_eq!(Cell::from_char(zw_char).content.width(), 0);
3021
3022 buffer.set_raw(0, 0, Cell::from_char(zw_char));
3023 buffer.set_raw(1, 0, Cell::from_char('A'));
3024
3025 let old = Buffer::new(5, 1);
3026 let diff = BufferDiff::compute(&old, &buffer);
3027
3028 presenter.present(&buffer, &diff).unwrap();
3029 let output = get_output(presenter);
3030 let output_str = String::from_utf8_lossy(&output);
3031
3032 assert!(
3034 output_str.contains("\u{FFFD}"),
3035 "Expected replacement character for zero-width content, got: {:?}",
3036 output_str
3037 );
3038
3039 assert!(
3041 !output_str.contains(zw_char),
3042 "Should not contain raw zero-width char"
3043 );
3044
3045 assert!(
3047 output_str.contains('A'),
3048 "Should contain subsequent character 'A'"
3049 );
3050 }
3051}
3052
3053#[cfg(test)]
3054mod proptests {
3055 use super::*;
3056 use crate::cell::{Cell, PackedRgba};
3057 use crate::diff::BufferDiff;
3058 use crate::terminal_model::TerminalModel;
3059 use proptest::prelude::*;
3060
3061 fn test_presenter() -> Presenter<Vec<u8>> {
3063 let caps = TerminalCapabilities::basic();
3064 Presenter::new(Vec::new(), caps)
3065 }
3066
3067 proptest! {
3068 #[test]
3071 fn presenter_roundtrip_characters(
3072 width in 5u16..40,
3073 height in 3u16..20,
3074 num_chars in 1usize..50, ) {
3076 let mut buffer = Buffer::new(width, height);
3077 let mut changed_positions = std::collections::HashSet::new();
3078
3079 for i in 0..num_chars {
3081 let x = (i * 7 + 3) as u16 % width;
3082 let y = (i * 11 + 5) as u16 % height;
3083 let ch = char::from_u32(('A' as u32) + (i as u32 % 26)).unwrap();
3084 buffer.set_raw(x, y, Cell::from_char(ch));
3085 changed_positions.insert((x, y));
3086 }
3087
3088 let mut presenter = test_presenter();
3090 let old = Buffer::new(width, height);
3091 let diff = BufferDiff::compute(&old, &buffer);
3092 presenter.present(&buffer, &diff).unwrap();
3093 let output = presenter.into_inner().unwrap();
3094
3095 let mut model = TerminalModel::new(width as usize, height as usize);
3097 model.process(&output);
3098
3099 for &(x, y) in &changed_positions {
3101 let buf_cell = buffer.get_unchecked(x, y);
3102 let expected_ch = buf_cell.content.as_char().unwrap_or(' ');
3103 let mut expected_buf = [0u8; 4];
3104 let expected_str = expected_ch.encode_utf8(&mut expected_buf);
3105
3106 if let Some(model_cell) = model.cell(x as usize, y as usize) {
3107 prop_assert_eq!(
3108 model_cell.text.as_str(),
3109 expected_str,
3110 "Character mismatch at ({}, {})", x, y
3111 );
3112 }
3113 }
3114 }
3115
3116 #[test]
3118 fn style_reset_after_present(
3119 width in 5u16..30,
3120 height in 3u16..15,
3121 num_styled in 1usize..20,
3122 ) {
3123 let mut buffer = Buffer::new(width, height);
3124
3125 for i in 0..num_styled {
3127 let x = (i * 7) as u16 % width;
3128 let y = (i * 11) as u16 % height;
3129 let fg = PackedRgba::rgb(
3130 ((i * 31) % 256) as u8,
3131 ((i * 47) % 256) as u8,
3132 ((i * 71) % 256) as u8,
3133 );
3134 buffer.set_raw(x, y, Cell::from_char('X').with_fg(fg));
3135 }
3136
3137 let mut presenter = test_presenter();
3139 let old = Buffer::new(width, height);
3140 let diff = BufferDiff::compute(&old, &buffer);
3141 presenter.present(&buffer, &diff).unwrap();
3142 let output = presenter.into_inner().unwrap();
3143 let output_str = String::from_utf8_lossy(&output);
3144
3145 prop_assert!(
3147 output_str.contains("\x1b[0m"),
3148 "Output should contain SGR reset"
3149 );
3150 }
3151
3152 #[test]
3154 fn empty_diff_minimal_output(
3155 width in 5u16..50,
3156 height in 3u16..25,
3157 ) {
3158 let buffer = Buffer::new(width, height);
3159 let diff = BufferDiff::new(); let mut presenter = test_presenter();
3162 presenter.present(&buffer, &diff).unwrap();
3163 let output = presenter.into_inner().unwrap();
3164
3165 prop_assert!(output.len() < 50, "Empty diff should have minimal output");
3168 }
3169
3170 #[test]
3175 fn diff_size_bounds(
3176 width in 5u16..30,
3177 height in 3u16..15,
3178 ) {
3179 let old = Buffer::new(width, height);
3181 let mut new = Buffer::new(width, height);
3182
3183 for y in 0..height {
3184 for x in 0..width {
3185 new.set_raw(x, y, Cell::from_char('X'));
3186 }
3187 }
3188
3189 let diff = BufferDiff::compute(&old, &new);
3190
3191 prop_assert_eq!(
3193 diff.len(),
3194 (width as usize) * (height as usize),
3195 "Full change should have all cells in diff"
3196 );
3197 }
3198
3199 #[test]
3201 fn presenter_cursor_consistency(
3202 width in 10u16..40,
3203 height in 5u16..20,
3204 num_runs in 1usize..10,
3205 ) {
3206 let mut buffer = Buffer::new(width, height);
3207
3208 for i in 0..num_runs {
3210 let start_x = (i * 5) as u16 % (width - 5);
3211 let y = i as u16 % height;
3212 for x in start_x..(start_x + 3) {
3213 buffer.set_raw(x, y, Cell::from_char('A'));
3214 }
3215 }
3216
3217 let mut presenter = test_presenter();
3219 let old = Buffer::new(width, height);
3220
3221 for _ in 0..3 {
3222 let diff = BufferDiff::compute(&old, &buffer);
3223 presenter.present(&buffer, &diff).unwrap();
3224 }
3225
3226 let output = presenter.into_inner().unwrap();
3228 prop_assert!(!output.is_empty(), "Should produce some output");
3229 }
3230
3231 #[test]
3235 fn sgr_delta_transition_equivalence(
3236 width in 5u16..20,
3237 height in 3u16..10,
3238 num_styled in 2usize..15,
3239 ) {
3240 let mut buffer = Buffer::new(width, height);
3241 let mut expected: std::collections::HashMap<(u16, u16), char> =
3243 std::collections::HashMap::new();
3244
3245 for i in 0..num_styled {
3247 let x = (i * 3 + 1) as u16 % width;
3248 let y = (i * 5 + 2) as u16 % height;
3249 let ch = char::from_u32(('A' as u32) + (i as u32 % 26)).unwrap();
3250 let fg = PackedRgba::rgb(
3251 ((i * 73) % 256) as u8,
3252 ((i * 137) % 256) as u8,
3253 ((i * 41) % 256) as u8,
3254 );
3255 let bg = if i % 3 == 0 {
3256 PackedRgba::rgb(
3257 ((i * 29) % 256) as u8,
3258 ((i * 53) % 256) as u8,
3259 ((i * 97) % 256) as u8,
3260 )
3261 } else {
3262 PackedRgba::TRANSPARENT
3263 };
3264 let flags_bits = ((i * 37) % 256) as u8;
3265 let flags = StyleFlags::from_bits_truncate(flags_bits);
3266 let cell = Cell::from_char(ch)
3267 .with_fg(fg)
3268 .with_bg(bg)
3269 .with_attrs(CellAttrs::new(flags, 0));
3270 buffer.set_raw(x, y, cell);
3271 expected.insert((x, y), ch);
3272 }
3273
3274 let mut presenter = test_presenter();
3276 let old = Buffer::new(width, height);
3277 let diff = BufferDiff::compute(&old, &buffer);
3278 presenter.present(&buffer, &diff).unwrap();
3279 let output = presenter.into_inner().unwrap();
3280
3281 let mut model = TerminalModel::new(width as usize, height as usize);
3283 model.process(&output);
3284
3285 for (&(x, y), &ch) in &expected {
3286 let mut buf = [0u8; 4];
3287 let expected_str = ch.encode_utf8(&mut buf);
3288
3289 if let Some(model_cell) = model.cell(x as usize, y as usize) {
3290 prop_assert_eq!(
3291 model_cell.text.as_str(),
3292 expected_str,
3293 "Character mismatch at ({}, {}) with delta engine", x, y
3294 );
3295 }
3296 }
3297 }
3298
3299 #[test]
3303 fn dp_emit_equivalence(
3304 width in 20u16..60,
3305 height in 5u16..15,
3306 num_changes in 5usize..30,
3307 ) {
3308 let mut buffer = Buffer::new(width, height);
3309 let mut expected: std::collections::HashMap<(u16, u16), char> =
3310 std::collections::HashMap::new();
3311
3312 for i in 0..num_changes {
3314 let x = (i * 7 + 3) as u16 % width;
3315 let y = (i * 3 + 1) as u16 % height;
3316 let ch = char::from_u32(('A' as u32) + (i as u32 % 26)).unwrap();
3317 buffer.set_raw(x, y, Cell::from_char(ch));
3318 expected.insert((x, y), ch);
3319 }
3320
3321 let mut presenter = test_presenter();
3323 let old = Buffer::new(width, height);
3324 let diff = BufferDiff::compute(&old, &buffer);
3325 presenter.present(&buffer, &diff).unwrap();
3326 let output = presenter.into_inner().unwrap();
3327
3328 let mut model = TerminalModel::new(width as usize, height as usize);
3330 model.process(&output);
3331
3332 for (&(x, y), &ch) in &expected {
3333 let mut buf = [0u8; 4];
3334 let expected_str = ch.encode_utf8(&mut buf);
3335
3336 if let Some(model_cell) = model.cell(x as usize, y as usize) {
3337 prop_assert_eq!(
3338 model_cell.text.as_str(),
3339 expected_str,
3340 "DP cost model: character mismatch at ({}, {})", x, y
3341 );
3342 }
3343 }
3344 }
3345 }
3346}