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 smallvec::SmallVec;
62
63 use super::ChangeRun;
64
65 #[inline]
67 fn digit_count(n: u16) -> usize {
68 if n >= 10000 {
69 5
70 } else if n >= 1000 {
71 4
72 } else if n >= 100 {
73 3
74 } else if n >= 10 {
75 2
76 } else {
77 1
78 }
79 }
80
81 #[inline]
83 pub fn cup_cost(row: u16, col: u16) -> usize {
84 4 + digit_count(row.saturating_add(1)) + digit_count(col.saturating_add(1))
86 }
87
88 #[inline]
90 pub fn cha_cost(col: u16) -> usize {
91 3 + digit_count(col.saturating_add(1))
93 }
94
95 #[inline]
97 pub fn cuf_cost(n: u16) -> usize {
98 match n {
99 0 => 0,
100 1 => 3, _ => 3 + digit_count(n),
102 }
103 }
104
105 pub fn cheapest_move_cost(
108 from_x: Option<u16>,
109 from_y: Option<u16>,
110 to_x: u16,
111 to_y: u16,
112 ) -> usize {
113 if from_x == Some(to_x) && from_y == Some(to_y) {
115 return 0;
116 }
117
118 let cup = cup_cost(to_y, to_x);
119
120 match (from_x, from_y) {
121 (Some(fx), Some(fy)) if fy == to_y => {
122 let cha = cha_cost(to_x);
124 if to_x > fx {
125 let cuf = cuf_cost(to_x - fx);
126 cup.min(cha).min(cuf)
127 } else if to_x == fx {
128 0
129 } else {
130 cup.min(cha)
132 }
133 }
134 _ => cup,
135 }
136 }
137
138 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
140 pub struct RowSpan {
141 pub y: u16,
143 pub x0: u16,
145 pub x1: u16,
147 }
148
149 #[derive(Debug, Clone, PartialEq, Eq)]
154 pub struct RowPlan {
155 spans: SmallVec<[RowSpan; 4]>,
156 total_cost: usize,
157 }
158
159 impl RowPlan {
160 #[inline]
161 #[must_use]
162 pub fn spans(&self) -> &[RowSpan] {
163 &self.spans
164 }
165
166 #[inline]
168 #[allow(dead_code)] pub fn total_cost(&self) -> usize {
170 self.total_cost
171 }
172 }
173
174 #[derive(Debug, Default)]
179 pub struct RowPlanScratch {
180 prefix_cells: Vec<usize>,
181 dp: Vec<usize>,
182 prev: Vec<usize>,
183 }
184
185 pub fn plan_row(row_runs: &[ChangeRun], prev_x: Option<u16>, prev_y: Option<u16>) -> RowPlan {
194 let mut scratch = RowPlanScratch::default();
195 plan_row_reuse(row_runs, prev_x, prev_y, &mut scratch)
196 }
197
198 pub fn plan_row_reuse(
201 row_runs: &[ChangeRun],
202 prev_x: Option<u16>,
203 prev_y: Option<u16>,
204 scratch: &mut RowPlanScratch,
205 ) -> RowPlan {
206 debug_assert!(!row_runs.is_empty());
207
208 let row_y = row_runs[0].y;
209 let run_count = row_runs.len();
210
211 scratch.prefix_cells.clear();
213 scratch.prefix_cells.resize(run_count + 1, 0);
214 scratch.dp.clear();
215 scratch.dp.resize(run_count, usize::MAX);
216 scratch.prev.clear();
217 scratch.prev.resize(run_count, 0);
218
219 for (i, run) in row_runs.iter().enumerate() {
221 scratch.prefix_cells[i + 1] = scratch.prefix_cells[i] + run.len() as usize;
222 }
223
224 for j in 0..run_count {
226 let mut best_cost = usize::MAX;
227 let mut best_i = j;
228
229 for i in (0..=j).rev() {
234 let changed_cells = scratch.prefix_cells[j + 1] - scratch.prefix_cells[i];
235 let total_cells = (row_runs[j].x1 - row_runs[i].x0 + 1) as usize;
236 let gap_cells = total_cells - changed_cells;
237
238 if gap_cells > 32 {
239 break;
240 }
241
242 let from_x = if i == 0 {
243 prev_x
244 } else {
245 Some(row_runs[i - 1].x1.saturating_add(1))
246 };
247 let from_y = if i == 0 { prev_y } else { Some(row_y) };
248
249 let move_cost = cheapest_move_cost(from_x, from_y, row_runs[i].x0, row_y);
250 let gap_overhead = gap_cells * 2; let emit_cost = changed_cells + gap_overhead;
252
253 let prev_cost = if i == 0 { 0 } else { scratch.dp[i - 1] };
254 let cost = prev_cost
255 .saturating_add(move_cost)
256 .saturating_add(emit_cost);
257
258 if cost < best_cost {
259 best_cost = cost;
260 best_i = i;
261 }
262 }
263
264 scratch.dp[j] = best_cost;
265 scratch.prev[j] = best_i;
266 }
267
268 let mut spans: SmallVec<[RowSpan; 4]> = SmallVec::new();
270 let mut j = run_count - 1;
271 loop {
272 let i = scratch.prev[j];
273 spans.push(RowSpan {
274 y: row_y,
275 x0: row_runs[i].x0,
276 x1: row_runs[j].x1,
277 });
278 if i == 0 {
279 break;
280 }
281 j = i - 1;
282 }
283 spans.reverse();
284
285 RowPlan {
286 spans,
287 total_cost: scratch.dp[run_count - 1],
288 }
289 }
290}
291
292#[derive(Debug, Clone, Copy, PartialEq, Eq)]
294struct CellStyle {
295 fg: PackedRgba,
296 bg: PackedRgba,
297 attrs: StyleFlags,
298}
299
300impl Default for CellStyle {
301 fn default() -> Self {
302 Self {
303 fg: PackedRgba::TRANSPARENT,
304 bg: PackedRgba::TRANSPARENT,
305 attrs: StyleFlags::empty(),
306 }
307 }
308}
309impl CellStyle {
310 fn from_cell(cell: &Cell) -> Self {
311 Self {
312 fg: cell.fg,
313 bg: cell.bg,
314 attrs: cell.attrs.flags(),
315 }
316 }
317}
318
319pub struct Presenter<W: Write> {
324 writer: CountingWriter<BufWriter<W>>,
326 current_style: Option<CellStyle>,
328 current_link: Option<u32>,
330 cursor_x: Option<u16>,
332 cursor_y: Option<u16>,
334 capabilities: TerminalCapabilities,
336 plan_scratch: cost_model::RowPlanScratch,
339 runs_buf: Vec<ChangeRun>,
341}
342
343impl<W: Write> Presenter<W> {
344 pub fn new(writer: W, capabilities: TerminalCapabilities) -> Self {
346 Self {
347 writer: CountingWriter::new(BufWriter::with_capacity(BUFFER_CAPACITY, writer)),
348 current_style: None,
349 current_link: None,
350 cursor_x: None,
351 cursor_y: None,
352 capabilities,
353 plan_scratch: cost_model::RowPlanScratch::default(),
354 runs_buf: Vec::new(),
355 }
356 }
357
358 #[inline]
360 pub fn capabilities(&self) -> &TerminalCapabilities {
361 &self.capabilities
362 }
363
364 pub fn present(&mut self, buffer: &Buffer, diff: &BufferDiff) -> io::Result<PresentStats> {
373 self.present_with_pool(buffer, diff, None, None)
374 }
375
376 pub fn present_with_pool(
378 &mut self,
379 buffer: &Buffer,
380 diff: &BufferDiff,
381 pool: Option<&GraphemePool>,
382 links: Option<&LinkRegistry>,
383 ) -> io::Result<PresentStats> {
384 #[cfg(feature = "tracing")]
385 let _span = tracing::info_span!(
386 "present",
387 width = buffer.width(),
388 height = buffer.height(),
389 changes = diff.len()
390 );
391 #[cfg(feature = "tracing")]
392 let _guard = _span.enter();
393
394 diff.runs_into(&mut self.runs_buf);
396 let run_count = self.runs_buf.len();
397 let cells_changed = diff.len();
398
399 self.writer.reset_counter();
401 let collector = StatsCollector::start(cells_changed, run_count);
402
403 if self.capabilities.sync_output {
405 ansi::sync_begin(&mut self.writer)?;
406 }
407
408 self.emit_runs_reuse(buffer, pool, links)?;
410
411 ansi::sgr_reset(&mut self.writer)?;
413 self.current_style = None;
414
415 if self.current_link.is_some() {
417 ansi::hyperlink_end(&mut self.writer)?;
418 self.current_link = None;
419 }
420
421 if self.capabilities.sync_output {
423 ansi::sync_end(&mut self.writer)?;
424 }
425
426 self.writer.flush()?;
427
428 let stats = collector.finish(self.writer.bytes_written());
429
430 #[cfg(feature = "tracing")]
431 {
432 stats.log();
433 tracing::trace!("frame presented");
434 }
435
436 Ok(stats)
437 }
438
439 #[allow(dead_code)] fn emit_runs(
446 &mut self,
447 buffer: &Buffer,
448 runs: &[ChangeRun],
449 pool: Option<&GraphemePool>,
450 links: Option<&LinkRegistry>,
451 ) -> io::Result<()> {
452 #[cfg(feature = "tracing")]
453 let _span = tracing::debug_span!("emit_diff");
454 #[cfg(feature = "tracing")]
455 let _guard = _span.enter();
456
457 #[cfg(feature = "tracing")]
458 tracing::trace!(run_count = runs.len(), "emitting runs");
459
460 let mut i = 0;
462 while i < runs.len() {
463 let row_y = runs[i].y;
464
465 let row_start = i;
467 while i < runs.len() && runs[i].y == row_y {
468 i += 1;
469 }
470 let row_runs = &runs[row_start..i];
471
472 let plan = cost_model::plan_row(row_runs, self.cursor_x, self.cursor_y);
473
474 #[cfg(feature = "tracing")]
475 tracing::trace!(
476 row = row_y,
477 spans = plan.spans().len(),
478 cost = plan.total_cost(),
479 "row plan"
480 );
481
482 let row = buffer.row_cells(row_y);
483 for span in plan.spans() {
484 self.move_cursor_optimal(span.x0, span.y)?;
485 let start = span.x0 as usize;
487 let end = span.x1 as usize;
488 debug_assert!(start <= end);
489 debug_assert!(end < row.len());
490
491 let mut idx = start;
492 for cell in &row[start..=end] {
493 self.emit_cell(idx as u16, cell, pool, links)?;
494 idx += 1;
495 }
496 }
497 }
498 Ok(())
499 }
500
501 fn emit_runs_reuse(
504 &mut self,
505 buffer: &Buffer,
506 pool: Option<&GraphemePool>,
507 links: Option<&LinkRegistry>,
508 ) -> io::Result<()> {
509 #[cfg(feature = "tracing")]
510 let _span = tracing::debug_span!("emit_diff");
511 #[cfg(feature = "tracing")]
512 let _guard = _span.enter();
513
514 #[cfg(feature = "tracing")]
515 tracing::trace!(run_count = self.runs_buf.len(), "emitting runs (reuse)");
516
517 let mut i = 0;
519 while i < self.runs_buf.len() {
520 let row_y = self.runs_buf[i].y;
521
522 let row_start = i;
524 while i < self.runs_buf.len() && self.runs_buf[i].y == row_y {
525 i += 1;
526 }
527 let row_runs = &self.runs_buf[row_start..i];
528
529 let plan = cost_model::plan_row_reuse(
530 row_runs,
531 self.cursor_x,
532 self.cursor_y,
533 &mut self.plan_scratch,
534 );
535
536 #[cfg(feature = "tracing")]
537 tracing::trace!(
538 row = row_y,
539 spans = plan.spans().len(),
540 cost = plan.total_cost(),
541 "row plan"
542 );
543
544 let row = buffer.row_cells(row_y);
545 for span in plan.spans() {
546 self.move_cursor_optimal(span.x0, span.y)?;
547 let start = span.x0 as usize;
549 let end = span.x1 as usize;
550 debug_assert!(start <= end);
551 debug_assert!(end < row.len());
552
553 let mut idx = start;
554 for cell in &row[start..=end] {
555 self.emit_cell(idx as u16, cell, pool, links)?;
556 idx += 1;
557 }
558 }
559 }
560 Ok(())
561 }
562
563 fn emit_cell(
565 &mut self,
566 x: u16,
567 cell: &Cell,
568 pool: Option<&GraphemePool>,
569 links: Option<&LinkRegistry>,
570 ) -> io::Result<()> {
571 if cell.is_continuation() {
580 match self.cursor_x {
581 Some(cx) if cx > x => return Ok(()),
583 Some(cx) => {
585 ansi::cuf(&mut self.writer, 1)?;
586 self.cursor_x = Some(cx.saturating_add(1));
587 return Ok(());
588 }
589 None => {
591 ansi::cuf(&mut self.writer, 1)?;
592 self.cursor_x = Some(x.saturating_add(1));
593 return Ok(());
594 }
595 }
596 }
597
598 self.emit_style_changes(cell)?;
600
601 self.emit_link_changes(cell, links)?;
603
604 let raw_width = cell.content.width();
607 let is_zero_width_content = raw_width == 0 && !cell.is_empty() && !cell.is_continuation();
608
609 if is_zero_width_content {
610 self.writer.write_all(b"\xEF\xBF\xBD")?;
612 } else {
613 self.emit_content(cell, pool)?;
615 }
616
617 if let Some(cx) = self.cursor_x {
619 let width = if cell.is_empty() || is_zero_width_content {
622 1
623 } else {
624 raw_width
625 };
626 self.cursor_x = Some(cx.saturating_add(width as u16));
627 }
628
629 Ok(())
630 }
631
632 fn emit_style_changes(&mut self, cell: &Cell) -> io::Result<()> {
638 let new_style = CellStyle::from_cell(cell);
639
640 if self.current_style == Some(new_style) {
642 return Ok(());
643 }
644
645 match self.current_style {
646 None => {
647 self.emit_style_full(new_style)?;
650 }
651 Some(old_style) => {
652 self.emit_style_delta(old_style, new_style)?;
653 }
654 }
655
656 self.current_style = Some(new_style);
657 Ok(())
658 }
659
660 fn emit_style_full(&mut self, style: CellStyle) -> io::Result<()> {
662 ansi::sgr_reset(&mut self.writer)?;
663 if style.fg.a() > 0 {
664 ansi::sgr_fg_packed(&mut self.writer, style.fg)?;
665 }
666 if style.bg.a() > 0 {
667 ansi::sgr_bg_packed(&mut self.writer, style.bg)?;
668 }
669 if !style.attrs.is_empty() {
670 ansi::sgr_flags(&mut self.writer, style.attrs)?;
671 }
672 Ok(())
673 }
674
675 #[inline]
676 fn dec_len_u8(value: u8) -> u32 {
677 if value >= 100 {
678 3
679 } else if value >= 10 {
680 2
681 } else {
682 1
683 }
684 }
685
686 #[inline]
687 fn sgr_code_len(code: u8) -> u32 {
688 2 + Self::dec_len_u8(code) + 1
689 }
690
691 #[inline]
692 fn sgr_flags_len(flags: StyleFlags) -> u32 {
693 if flags.is_empty() {
694 return 0;
695 }
696 let mut count = 0u32;
697 let mut digits = 0u32;
698 for (flag, codes) in ansi::FLAG_TABLE {
699 if flags.contains(flag) {
700 count += 1;
701 digits += Self::dec_len_u8(codes.on);
702 }
703 }
704 if count == 0 {
705 return 0;
706 }
707 3 + digits + (count - 1)
708 }
709
710 #[inline]
711 fn sgr_flags_off_len(flags: StyleFlags) -> u32 {
712 if flags.is_empty() {
713 return 0;
714 }
715 let mut len = 0u32;
716 for (flag, codes) in ansi::FLAG_TABLE {
717 if flags.contains(flag) {
718 len += Self::sgr_code_len(codes.off);
719 }
720 }
721 len
722 }
723
724 #[inline]
725 fn sgr_rgb_len(color: PackedRgba) -> u32 {
726 10 + Self::dec_len_u8(color.r()) + Self::dec_len_u8(color.g()) + Self::dec_len_u8(color.b())
727 }
728
729 fn emit_style_delta(&mut self, old: CellStyle, new: CellStyle) -> io::Result<()> {
734 let attrs_removed = old.attrs & !new.attrs;
735 let attrs_added = new.attrs & !old.attrs;
736 let fg_changed = old.fg != new.fg;
737 let bg_changed = old.bg != new.bg;
738
739 if old.attrs == new.attrs {
743 if fg_changed {
744 ansi::sgr_fg_packed(&mut self.writer, new.fg)?;
745 }
746 if bg_changed {
747 ansi::sgr_bg_packed(&mut self.writer, new.bg)?;
748 }
749 return Ok(());
750 }
751
752 let mut collateral = StyleFlags::empty();
753 if attrs_removed.contains(StyleFlags::BOLD) && new.attrs.contains(StyleFlags::DIM) {
754 collateral |= StyleFlags::DIM;
755 }
756 if attrs_removed.contains(StyleFlags::DIM) && new.attrs.contains(StyleFlags::BOLD) {
757 collateral |= StyleFlags::BOLD;
758 }
759
760 let mut delta_len = 0u32;
761 delta_len += Self::sgr_flags_off_len(attrs_removed);
762 delta_len += Self::sgr_flags_len(collateral);
763 delta_len += Self::sgr_flags_len(attrs_added);
764 if fg_changed {
765 delta_len += if new.fg.a() == 0 {
766 5
767 } else {
768 Self::sgr_rgb_len(new.fg)
769 };
770 }
771 if bg_changed {
772 delta_len += if new.bg.a() == 0 {
773 5
774 } else {
775 Self::sgr_rgb_len(new.bg)
776 };
777 }
778
779 let mut baseline_len = 4u32;
780 if new.fg.a() > 0 {
781 baseline_len += Self::sgr_rgb_len(new.fg);
782 }
783 if new.bg.a() > 0 {
784 baseline_len += Self::sgr_rgb_len(new.bg);
785 }
786 baseline_len += Self::sgr_flags_len(new.attrs);
787
788 if delta_len > baseline_len {
789 return self.emit_style_full(new);
790 }
791
792 if !attrs_removed.is_empty() {
794 let collateral = ansi::sgr_flags_off(&mut self.writer, attrs_removed, new.attrs)?;
795 if !collateral.is_empty() {
797 ansi::sgr_flags(&mut self.writer, collateral)?;
798 }
799 }
800
801 if !attrs_added.is_empty() {
803 ansi::sgr_flags(&mut self.writer, attrs_added)?;
804 }
805
806 if fg_changed {
808 ansi::sgr_fg_packed(&mut self.writer, new.fg)?;
809 }
810
811 if bg_changed {
813 ansi::sgr_bg_packed(&mut self.writer, new.bg)?;
814 }
815
816 Ok(())
817 }
818
819 fn emit_link_changes(&mut self, cell: &Cell, links: Option<&LinkRegistry>) -> io::Result<()> {
821 let raw_link_id = cell.attrs.link_id();
822 let new_link = if raw_link_id == CellAttrs::LINK_ID_NONE {
823 None
824 } else {
825 Some(raw_link_id)
826 };
827
828 if self.current_link == new_link {
830 return Ok(());
831 }
832
833 if self.current_link.is_some() {
835 ansi::hyperlink_end(&mut self.writer)?;
836 }
837
838 let actually_opened = if let (Some(link_id), Some(registry)) = (new_link, links)
840 && let Some(url) = registry.get(link_id)
841 {
842 ansi::hyperlink_start(&mut self.writer, url)?;
843 true
844 } else {
845 false
846 };
847
848 self.current_link = if actually_opened { new_link } else { None };
850 Ok(())
851 }
852
853 fn emit_content(&mut self, cell: &Cell, pool: Option<&GraphemePool>) -> io::Result<()> {
855 if let Some(grapheme_id) = cell.content.grapheme_id() {
857 if let Some(pool) = pool
858 && let Some(text) = pool.get(grapheme_id)
859 {
860 return self.writer.write_all(text.as_bytes());
861 }
862 let width = cell.content.width();
865 if width > 0 {
866 for _ in 0..width {
867 self.writer.write_all(b"?")?;
868 }
869 }
870 return Ok(());
871 }
872
873 if let Some(ch) = cell.content.as_char() {
875 let safe_ch = if ch.is_control() { ' ' } else { ch };
877 let mut buf = [0u8; 4];
878 let encoded = safe_ch.encode_utf8(&mut buf);
879 self.writer.write_all(encoded.as_bytes())
880 } else {
881 self.writer.write_all(b" ")
883 }
884 }
885
886 fn move_cursor_to(&mut self, x: u16, y: u16) -> io::Result<()> {
888 if self.cursor_x == Some(x) && self.cursor_y == Some(y) {
890 return Ok(());
891 }
892
893 ansi::cup(&mut self.writer, y, x)?;
895 self.cursor_x = Some(x);
896 self.cursor_y = Some(y);
897 Ok(())
898 }
899
900 fn move_cursor_optimal(&mut self, x: u16, y: u16) -> io::Result<()> {
905 if self.cursor_x == Some(x) && self.cursor_y == Some(y) {
907 return Ok(());
908 }
909
910 let same_row = self.cursor_y == Some(y);
912 let forward = same_row && self.cursor_x.is_some_and(|cx| x > cx);
913
914 if same_row && forward {
915 let dx = x - self.cursor_x.expect("cursor_x guaranteed by forward check");
916 let cuf = cost_model::cuf_cost(dx);
917 let cha = cost_model::cha_cost(x);
918 let cup = cost_model::cup_cost(y, x);
919
920 if cuf <= cha && cuf <= cup {
921 ansi::cuf(&mut self.writer, dx)?;
922 } else if cha <= cup {
923 ansi::cha(&mut self.writer, x)?;
924 } else {
925 ansi::cup(&mut self.writer, y, x)?;
926 }
927 } else if same_row {
928 let cha = cost_model::cha_cost(x);
930 let cup = cost_model::cup_cost(y, x);
931 if cha <= cup {
932 ansi::cha(&mut self.writer, x)?;
933 } else {
934 ansi::cup(&mut self.writer, y, x)?;
935 }
936 } else {
937 ansi::cup(&mut self.writer, y, x)?;
939 }
940
941 self.cursor_x = Some(x);
942 self.cursor_y = Some(y);
943 Ok(())
944 }
945
946 pub fn clear_screen(&mut self) -> io::Result<()> {
948 ansi::erase_display(&mut self.writer, ansi::EraseDisplayMode::All)?;
949 ansi::cup(&mut self.writer, 0, 0)?;
950 self.cursor_x = Some(0);
951 self.cursor_y = Some(0);
952 self.writer.flush()
953 }
954
955 pub fn clear_line(&mut self, y: u16) -> io::Result<()> {
957 self.move_cursor_to(0, y)?;
958 ansi::erase_line(&mut self.writer, EraseLineMode::All)?;
959 self.writer.flush()
960 }
961
962 pub fn hide_cursor(&mut self) -> io::Result<()> {
964 ansi::cursor_hide(&mut self.writer)?;
965 self.writer.flush()
966 }
967
968 pub fn show_cursor(&mut self) -> io::Result<()> {
970 ansi::cursor_show(&mut self.writer)?;
971 self.writer.flush()
972 }
973
974 pub fn position_cursor(&mut self, x: u16, y: u16) -> io::Result<()> {
976 self.move_cursor_to(x, y)?;
977 self.writer.flush()
978 }
979
980 pub fn reset(&mut self) {
984 self.current_style = None;
985 self.current_link = None;
986 self.cursor_x = None;
987 self.cursor_y = None;
988 }
989
990 pub fn flush(&mut self) -> io::Result<()> {
992 self.writer.flush()
993 }
994
995 pub fn into_inner(self) -> Result<W, io::Error> {
999 self.writer
1000 .into_inner() .into_inner() .map_err(|e| e.into_error())
1003 }
1004}
1005
1006#[cfg(test)]
1007mod tests {
1008 use super::*;
1009 use crate::cell::CellAttrs;
1010 use crate::link_registry::LinkRegistry;
1011
1012 fn test_presenter() -> Presenter<Vec<u8>> {
1013 let caps = TerminalCapabilities::basic();
1014 Presenter::new(Vec::new(), caps)
1015 }
1016
1017 fn test_presenter_with_sync() -> Presenter<Vec<u8>> {
1018 let mut caps = TerminalCapabilities::basic();
1019 caps.sync_output = true;
1020 Presenter::new(Vec::new(), caps)
1021 }
1022
1023 fn get_output(presenter: Presenter<Vec<u8>>) -> Vec<u8> {
1024 presenter.into_inner().unwrap()
1025 }
1026
1027 fn legacy_plan_row(
1028 row_runs: &[ChangeRun],
1029 prev_x: Option<u16>,
1030 prev_y: Option<u16>,
1031 ) -> Vec<cost_model::RowSpan> {
1032 if row_runs.is_empty() {
1033 return Vec::new();
1034 }
1035
1036 if row_runs.len() == 1 {
1037 let run = row_runs[0];
1038 return vec![cost_model::RowSpan {
1039 y: run.y,
1040 x0: run.x0,
1041 x1: run.x1,
1042 }];
1043 }
1044
1045 let row_y = row_runs[0].y;
1046 let first_x = row_runs[0].x0;
1047 let last_x = row_runs[row_runs.len() - 1].x1;
1048
1049 let mut sparse_cost: usize = 0;
1051 let mut cursor_x = prev_x;
1052 let mut cursor_y = prev_y;
1053
1054 for run in row_runs {
1055 let move_cost = cost_model::cheapest_move_cost(cursor_x, cursor_y, run.x0, run.y);
1056 let cells = (run.x1 - run.x0 + 1) as usize;
1057 sparse_cost += move_cost + cells;
1058 cursor_x = Some(run.x1.saturating_add(1));
1059 cursor_y = Some(row_y);
1060 }
1061
1062 let merge_move = cost_model::cheapest_move_cost(prev_x, prev_y, first_x, row_y);
1064 let total_cells = (last_x - first_x + 1) as usize;
1065 let changed_cells: usize = row_runs.iter().map(|r| (r.x1 - r.x0 + 1) as usize).sum();
1066 let gap_cells = total_cells - changed_cells;
1067 let gap_overhead = gap_cells * 2;
1068 let merged_cost = merge_move + changed_cells + gap_overhead;
1069
1070 if merged_cost < sparse_cost {
1071 vec![cost_model::RowSpan {
1072 y: row_y,
1073 x0: first_x,
1074 x1: last_x,
1075 }]
1076 } else {
1077 row_runs
1078 .iter()
1079 .map(|run| cost_model::RowSpan {
1080 y: run.y,
1081 x0: run.x0,
1082 x1: run.x1,
1083 })
1084 .collect()
1085 }
1086 }
1087
1088 fn emit_spans_for_output(buffer: &Buffer, spans: &[cost_model::RowSpan]) -> Vec<u8> {
1089 let mut presenter = test_presenter();
1090
1091 for span in spans {
1092 presenter
1093 .move_cursor_optimal(span.x0, span.y)
1094 .expect("cursor move should succeed");
1095 for x in span.x0..=span.x1 {
1096 let cell = buffer.get_unchecked(x, span.y);
1097 presenter
1098 .emit_cell(x, cell, None, None)
1099 .expect("emit_cell should succeed");
1100 }
1101 }
1102
1103 presenter
1104 .writer
1105 .write_all(b"\x1b[0m")
1106 .expect("reset should succeed");
1107
1108 presenter.into_inner().expect("presenter output")
1109 }
1110
1111 #[test]
1112 fn empty_diff_produces_minimal_output() {
1113 let mut presenter = test_presenter();
1114 let buffer = Buffer::new(10, 10);
1115 let diff = BufferDiff::new();
1116
1117 presenter.present(&buffer, &diff).unwrap();
1118 let output = get_output(presenter);
1119
1120 assert!(output.starts_with(b"\x1b[0m"));
1122 }
1123
1124 #[test]
1125 fn sync_output_wraps_frame() {
1126 let mut presenter = test_presenter_with_sync();
1127 let mut buffer = Buffer::new(3, 1);
1128 buffer.set_raw(0, 0, Cell::from_char('X'));
1129
1130 let old = Buffer::new(3, 1);
1131 let diff = BufferDiff::compute(&old, &buffer);
1132
1133 presenter.present(&buffer, &diff).unwrap();
1134 let output = get_output(presenter);
1135
1136 assert!(
1137 output.starts_with(ansi::SYNC_BEGIN),
1138 "sync output should begin with DEC 2026 begin"
1139 );
1140 assert!(
1141 output.ends_with(ansi::SYNC_END),
1142 "sync output should end with DEC 2026 end"
1143 );
1144 }
1145
1146 #[test]
1147 fn hyperlink_sequences_emitted_and_closed() {
1148 let mut presenter = test_presenter();
1149 let mut buffer = Buffer::new(3, 1);
1150
1151 let mut registry = LinkRegistry::new();
1152 let link_id = registry.register("https://example.com");
1153 let linked = Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id));
1154 buffer.set_raw(0, 0, linked);
1155
1156 let old = Buffer::new(3, 1);
1157 let diff = BufferDiff::compute(&old, &buffer);
1158
1159 presenter
1160 .present_with_pool(&buffer, &diff, None, Some(®istry))
1161 .unwrap();
1162 let output = get_output(presenter);
1163
1164 let start = b"\x1b]8;;https://example.com\x1b\\";
1165 let end = b"\x1b]8;;\x1b\\";
1166
1167 let start_pos = output
1168 .windows(start.len())
1169 .position(|w| w == start)
1170 .expect("hyperlink start not found");
1171 let end_pos = output
1172 .windows(end.len())
1173 .position(|w| w == end)
1174 .expect("hyperlink end not found");
1175 let char_pos = output
1176 .iter()
1177 .position(|&b| b == b'L')
1178 .expect("linked character not found");
1179
1180 assert!(start_pos < char_pos, "link start should precede text");
1181 assert!(char_pos < end_pos, "link end should follow text");
1182 }
1183
1184 #[test]
1185 fn single_cell_change() {
1186 let mut presenter = test_presenter();
1187 let mut buffer = Buffer::new(10, 10);
1188 buffer.set_raw(5, 5, Cell::from_char('X'));
1189
1190 let old = Buffer::new(10, 10);
1191 let diff = BufferDiff::compute(&old, &buffer);
1192
1193 presenter.present(&buffer, &diff).unwrap();
1194 let output = get_output(presenter);
1195
1196 let output_str = String::from_utf8_lossy(&output);
1198 assert!(output_str.contains("X"));
1199 assert!(output_str.contains("\x1b[")); }
1201
1202 #[test]
1203 fn style_tracking_avoids_redundant_sgr() {
1204 let mut presenter = test_presenter();
1205 let mut buffer = Buffer::new(10, 1);
1206
1207 let fg = PackedRgba::rgb(255, 0, 0);
1209 buffer.set_raw(0, 0, Cell::from_char('A').with_fg(fg));
1210 buffer.set_raw(1, 0, Cell::from_char('B').with_fg(fg));
1211 buffer.set_raw(2, 0, Cell::from_char('C').with_fg(fg));
1212
1213 let old = Buffer::new(10, 1);
1214 let diff = BufferDiff::compute(&old, &buffer);
1215
1216 presenter.present(&buffer, &diff).unwrap();
1217 let output = get_output(presenter);
1218
1219 let output_str = String::from_utf8_lossy(&output);
1221 let sgr_count = output_str.matches("\x1b[38;2").count();
1222 assert_eq!(
1224 sgr_count, 1,
1225 "Expected 1 SGR fg sequence, got {}",
1226 sgr_count
1227 );
1228 }
1229
1230 #[test]
1231 fn reset_reapplies_style_after_clear() {
1232 let mut presenter = test_presenter();
1233 let mut buffer = Buffer::new(1, 1);
1234 let styled = Cell::from_char('A').with_fg(PackedRgba::rgb(10, 20, 30));
1235 buffer.set_raw(0, 0, styled);
1236
1237 let old = Buffer::new(1, 1);
1238 let diff = BufferDiff::compute(&old, &buffer);
1239
1240 presenter.present(&buffer, &diff).unwrap();
1241 presenter.reset();
1242 presenter.present(&buffer, &diff).unwrap();
1243
1244 let output = get_output(presenter);
1245 let output_str = String::from_utf8_lossy(&output);
1246 let sgr_count = output_str.matches("\x1b[38;2").count();
1247
1248 assert_eq!(
1249 sgr_count, 2,
1250 "Expected style to be re-applied after reset, got {sgr_count} sequences"
1251 );
1252 }
1253
1254 #[test]
1255 fn cursor_position_optimized() {
1256 let mut presenter = test_presenter();
1257 let mut buffer = Buffer::new(10, 5);
1258
1259 buffer.set_raw(3, 2, Cell::from_char('A'));
1261 buffer.set_raw(4, 2, Cell::from_char('B'));
1262 buffer.set_raw(5, 2, Cell::from_char('C'));
1263
1264 let old = Buffer::new(10, 5);
1265 let diff = BufferDiff::compute(&old, &buffer);
1266
1267 presenter.present(&buffer, &diff).unwrap();
1268 let output = get_output(presenter);
1269
1270 let output_str = String::from_utf8_lossy(&output);
1272 let _cup_count = output_str.matches("\x1b[").filter(|_| true).count();
1273
1274 assert!(
1276 output_str.contains("ABC")
1277 || (output_str.contains('A')
1278 && output_str.contains('B')
1279 && output_str.contains('C'))
1280 );
1281 }
1282
1283 #[test]
1284 fn sync_output_wrapped_when_supported() {
1285 let mut presenter = test_presenter_with_sync();
1286 let buffer = Buffer::new(10, 10);
1287 let diff = BufferDiff::new();
1288
1289 presenter.present(&buffer, &diff).unwrap();
1290 let output = get_output(presenter);
1291
1292 assert!(output.starts_with(ansi::SYNC_BEGIN));
1294 assert!(
1295 output
1296 .windows(ansi::SYNC_END.len())
1297 .any(|w| w == ansi::SYNC_END)
1298 );
1299 }
1300
1301 #[test]
1302 fn clear_screen_works() {
1303 let mut presenter = test_presenter();
1304 presenter.clear_screen().unwrap();
1305 let output = get_output(presenter);
1306
1307 assert!(output.windows(b"\x1b[2J".len()).any(|w| w == b"\x1b[2J"));
1309 }
1310
1311 #[test]
1312 fn cursor_visibility() {
1313 let mut presenter = test_presenter();
1314
1315 presenter.hide_cursor().unwrap();
1316 presenter.show_cursor().unwrap();
1317
1318 let output = get_output(presenter);
1319 let output_str = String::from_utf8_lossy(&output);
1320
1321 assert!(output_str.contains("\x1b[?25l")); assert!(output_str.contains("\x1b[?25h")); }
1324
1325 #[test]
1326 fn reset_clears_state() {
1327 let mut presenter = test_presenter();
1328 presenter.cursor_x = Some(50);
1329 presenter.cursor_y = Some(20);
1330 presenter.current_style = Some(CellStyle::default());
1331
1332 presenter.reset();
1333
1334 assert!(presenter.cursor_x.is_none());
1335 assert!(presenter.cursor_y.is_none());
1336 assert!(presenter.current_style.is_none());
1337 }
1338
1339 #[test]
1340 fn position_cursor() {
1341 let mut presenter = test_presenter();
1342 presenter.position_cursor(10, 5).unwrap();
1343
1344 let output = get_output(presenter);
1345 assert!(
1347 output
1348 .windows(b"\x1b[6;11H".len())
1349 .any(|w| w == b"\x1b[6;11H")
1350 );
1351 }
1352
1353 #[test]
1354 fn skip_cursor_move_when_already_at_position() {
1355 let mut presenter = test_presenter();
1356 presenter.cursor_x = Some(5);
1357 presenter.cursor_y = Some(3);
1358
1359 presenter.move_cursor_to(5, 3).unwrap();
1361
1362 let output = get_output(presenter);
1364 assert!(output.is_empty());
1365 }
1366
1367 #[test]
1368 fn continuation_cells_skipped() {
1369 let mut presenter = test_presenter();
1370 let mut buffer = Buffer::new(10, 1);
1371
1372 buffer.set_raw(0, 0, Cell::from_char('ä¸'));
1374 buffer.set_raw(1, 0, Cell::CONTINUATION);
1376
1377 let old = Buffer::new(10, 1);
1379 let diff = BufferDiff::compute(&old, &buffer);
1380
1381 presenter.present(&buffer, &diff).unwrap();
1382 let output = get_output(presenter);
1383
1384 let output_str = String::from_utf8_lossy(&output);
1386 assert!(output_str.contains('ä¸'));
1387 }
1388
1389 #[test]
1390 fn continuation_at_run_start_advances_cursor_without_overwriting() {
1391 let mut presenter = test_presenter();
1392 let mut old = Buffer::new(3, 1);
1393 let mut new = Buffer::new(3, 1);
1394
1395 old.set_raw(0, 0, Cell::from_char('ä¸'));
1401 new.set_raw(0, 0, Cell::from_char('ä¸'));
1402 old.set_raw(1, 0, Cell::from_char('X'));
1403 new.set_raw(1, 0, Cell::CONTINUATION);
1404
1405 let diff = BufferDiff::compute(&old, &new);
1406 assert_eq!(diff.changes(), &[(1u16, 0u16)]);
1407
1408 presenter.present(&new, &diff).unwrap();
1409 let output = get_output(presenter);
1410
1411 assert!(output.windows(3).any(|w| w == b"\x1b[C"));
1413 assert!(
1414 !output.contains(&b' '),
1415 "should not write a space when advancing over a continuation cell"
1416 );
1417 }
1418
1419 #[test]
1420 fn wide_char_missing_continuation_causes_drift() {
1421 let mut presenter = test_presenter();
1422 let mut buffer = Buffer::new(10, 1);
1423
1424 buffer.set_raw(0, 0, Cell::from_char('ä¸'));
1426 let old = Buffer::new(10, 1);
1429 let diff = BufferDiff::compute(&old, &buffer);
1430
1431 presenter.present(&buffer, &diff).unwrap();
1432 let output = get_output(presenter);
1433 let _output_str = String::from_utf8_lossy(&output);
1434
1435 }
1470
1471 #[test]
1472 fn hyperlink_emitted_with_registry() {
1473 let mut presenter = test_presenter();
1474 let mut buffer = Buffer::new(10, 1);
1475 let mut links = LinkRegistry::new();
1476
1477 let link_id = links.register("https://example.com");
1478 let cell = Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id));
1479 buffer.set_raw(0, 0, cell);
1480
1481 let old = Buffer::new(10, 1);
1482 let diff = BufferDiff::compute(&old, &buffer);
1483
1484 presenter
1485 .present_with_pool(&buffer, &diff, None, Some(&links))
1486 .unwrap();
1487 let output = get_output(presenter);
1488 let output_str = String::from_utf8_lossy(&output);
1489
1490 assert!(
1492 output_str.contains("\x1b]8;;https://example.com\x1b\\"),
1493 "Expected OSC 8 open, got: {:?}",
1494 output_str
1495 );
1496 assert!(
1498 output_str.contains("\x1b]8;;\x1b\\"),
1499 "Expected OSC 8 close, got: {:?}",
1500 output_str
1501 );
1502 }
1503
1504 #[test]
1505 fn hyperlink_not_emitted_without_registry() {
1506 let mut presenter = test_presenter();
1507 let mut buffer = Buffer::new(10, 1);
1508
1509 let cell = Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), 1));
1511 buffer.set_raw(0, 0, cell);
1512
1513 let old = Buffer::new(10, 1);
1514 let diff = BufferDiff::compute(&old, &buffer);
1515
1516 presenter.present(&buffer, &diff).unwrap();
1518 let output = get_output(presenter);
1519 let output_str = String::from_utf8_lossy(&output);
1520
1521 assert!(
1523 !output_str.contains("\x1b]8;"),
1524 "OSC 8 should not appear without registry, got: {:?}",
1525 output_str
1526 );
1527 }
1528
1529 #[test]
1530 fn hyperlink_not_emitted_for_unknown_id() {
1531 let mut presenter = test_presenter();
1532 let mut buffer = Buffer::new(10, 1);
1533 let links = LinkRegistry::new();
1534
1535 let cell = Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), 42));
1536 buffer.set_raw(0, 0, cell);
1537
1538 let old = Buffer::new(10, 1);
1539 let diff = BufferDiff::compute(&old, &buffer);
1540
1541 presenter
1542 .present_with_pool(&buffer, &diff, None, Some(&links))
1543 .unwrap();
1544 let output = get_output(presenter);
1545 let output_str = String::from_utf8_lossy(&output);
1546
1547 assert!(
1548 !output_str.contains("\x1b]8;"),
1549 "OSC 8 should not appear for unknown link IDs, got: {:?}",
1550 output_str
1551 );
1552 assert!(output_str.contains('L'));
1553 }
1554
1555 #[test]
1556 fn hyperlink_closed_at_frame_end() {
1557 let mut presenter = test_presenter();
1558 let mut buffer = Buffer::new(10, 1);
1559 let mut links = LinkRegistry::new();
1560
1561 let link_id = links.register("https://example.com");
1562 for x in 0..5 {
1564 buffer.set_raw(
1565 x,
1566 0,
1567 Cell::from_char('A').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
1568 );
1569 }
1570
1571 let old = Buffer::new(10, 1);
1572 let diff = BufferDiff::compute(&old, &buffer);
1573
1574 presenter
1575 .present_with_pool(&buffer, &diff, None, Some(&links))
1576 .unwrap();
1577 let output = get_output(presenter);
1578
1579 let close_seq = b"\x1b]8;;\x1b\\";
1581 assert!(
1582 output.windows(close_seq.len()).any(|w| w == close_seq),
1583 "Link must be closed at frame end"
1584 );
1585 }
1586
1587 #[test]
1588 fn hyperlink_transitions_between_links() {
1589 let mut presenter = test_presenter();
1590 let mut buffer = Buffer::new(10, 1);
1591 let mut links = LinkRegistry::new();
1592
1593 let link_a = links.register("https://a.com");
1594 let link_b = links.register("https://b.com");
1595
1596 buffer.set_raw(
1597 0,
1598 0,
1599 Cell::from_char('A').with_attrs(CellAttrs::new(StyleFlags::empty(), link_a)),
1600 );
1601 buffer.set_raw(
1602 1,
1603 0,
1604 Cell::from_char('B').with_attrs(CellAttrs::new(StyleFlags::empty(), link_b)),
1605 );
1606 buffer.set_raw(2, 0, Cell::from_char('C')); let old = Buffer::new(10, 1);
1609 let diff = BufferDiff::compute(&old, &buffer);
1610
1611 presenter
1612 .present_with_pool(&buffer, &diff, None, Some(&links))
1613 .unwrap();
1614 let output = get_output(presenter);
1615 let output_str = String::from_utf8_lossy(&output);
1616
1617 assert!(output_str.contains("https://a.com"));
1619 assert!(output_str.contains("https://b.com"));
1620
1621 let close_count = output_str.matches("\x1b]8;;\x1b\\").count();
1623 assert!(
1624 close_count >= 2,
1625 "Expected at least 2 link close sequences (transition + frame end), got {}",
1626 close_count
1627 );
1628 }
1629
1630 #[test]
1635 fn sync_output_not_wrapped_when_unsupported() {
1636 let mut presenter = test_presenter(); let buffer = Buffer::new(10, 10);
1639 let diff = BufferDiff::new();
1640
1641 presenter.present(&buffer, &diff).unwrap();
1642 let output = get_output(presenter);
1643
1644 assert!(
1646 !output.starts_with(ansi::SYNC_BEGIN),
1647 "Sync begin should not appear when sync_output is disabled"
1648 );
1649 assert!(
1650 !output
1651 .windows(ansi::SYNC_END.len())
1652 .any(|w| w == ansi::SYNC_END),
1653 "Sync end should not appear when sync_output is disabled"
1654 );
1655 }
1656
1657 #[test]
1658 fn present_flushes_buffered_output() {
1659 let mut presenter = test_presenter();
1662 let mut buffer = Buffer::new(5, 1);
1663 buffer.set_raw(0, 0, Cell::from_char('T'));
1664 buffer.set_raw(1, 0, Cell::from_char('E'));
1665 buffer.set_raw(2, 0, Cell::from_char('S'));
1666 buffer.set_raw(3, 0, Cell::from_char('T'));
1667
1668 let old = Buffer::new(5, 1);
1669 let diff = BufferDiff::compute(&old, &buffer);
1670
1671 presenter.present(&buffer, &diff).unwrap();
1672 let output = get_output(presenter);
1673 let output_str = String::from_utf8_lossy(&output);
1674
1675 assert!(
1677 output_str.contains("TEST"),
1678 "Expected 'TEST' in flushed output"
1679 );
1680 }
1681
1682 #[test]
1683 fn present_stats_reports_cells_and_bytes() {
1684 let mut presenter = test_presenter();
1685 let mut buffer = Buffer::new(10, 1);
1686
1687 for i in 0..5 {
1689 buffer.set_raw(i, 0, Cell::from_char('X'));
1690 }
1691
1692 let old = Buffer::new(10, 1);
1693 let diff = BufferDiff::compute(&old, &buffer);
1694
1695 let stats = presenter.present(&buffer, &diff).unwrap();
1696
1697 assert_eq!(stats.cells_changed, 5, "Expected 5 cells changed");
1699 assert!(stats.bytes_emitted > 0, "Expected some bytes written");
1700 assert!(stats.run_count >= 1, "Expected at least 1 run");
1701 }
1702
1703 #[test]
1708 fn cursor_tracking_after_wide_char() {
1709 let mut presenter = test_presenter();
1710 presenter.cursor_x = Some(0);
1711 presenter.cursor_y = Some(0);
1712
1713 let mut buffer = Buffer::new(10, 1);
1714 buffer.set_raw(0, 0, Cell::from_char('ä¸'));
1716 buffer.set_raw(1, 0, Cell::CONTINUATION);
1717 buffer.set_raw(2, 0, Cell::from_char('A'));
1719
1720 let old = Buffer::new(10, 1);
1721 let diff = BufferDiff::compute(&old, &buffer);
1722
1723 presenter.present(&buffer, &diff).unwrap();
1724
1725 let output = get_output(presenter);
1728 let output_str = String::from_utf8_lossy(&output);
1729
1730 assert!(output_str.contains('ä¸'));
1732 assert!(output_str.contains('A'));
1733 }
1734
1735 #[test]
1736 fn cursor_position_after_multiple_runs() {
1737 let mut presenter = test_presenter();
1738 let mut buffer = Buffer::new(20, 3);
1739
1740 buffer.set_raw(0, 0, Cell::from_char('A'));
1742 buffer.set_raw(1, 0, Cell::from_char('B'));
1743 buffer.set_raw(5, 2, Cell::from_char('X'));
1744 buffer.set_raw(6, 2, Cell::from_char('Y'));
1745
1746 let old = Buffer::new(20, 3);
1747 let diff = BufferDiff::compute(&old, &buffer);
1748
1749 presenter.present(&buffer, &diff).unwrap();
1750 let output = get_output(presenter);
1751 let output_str = String::from_utf8_lossy(&output);
1752
1753 assert!(output_str.contains('A'));
1755 assert!(output_str.contains('B'));
1756 assert!(output_str.contains('X'));
1757 assert!(output_str.contains('Y'));
1758
1759 let cup_count = output_str.matches("\x1b[").count();
1761 assert!(
1762 cup_count >= 2,
1763 "Expected at least 2 escape sequences for multiple runs"
1764 );
1765 }
1766
1767 #[test]
1772 fn style_with_all_flags() {
1773 let mut presenter = test_presenter();
1774 let mut buffer = Buffer::new(5, 1);
1775
1776 let all_flags = StyleFlags::BOLD
1778 | StyleFlags::DIM
1779 | StyleFlags::ITALIC
1780 | StyleFlags::UNDERLINE
1781 | StyleFlags::BLINK
1782 | StyleFlags::REVERSE
1783 | StyleFlags::STRIKETHROUGH;
1784
1785 let cell = Cell::from_char('X').with_attrs(CellAttrs::new(all_flags, 0));
1786 buffer.set_raw(0, 0, cell);
1787
1788 let old = Buffer::new(5, 1);
1789 let diff = BufferDiff::compute(&old, &buffer);
1790
1791 presenter.present(&buffer, &diff).unwrap();
1792 let output = get_output(presenter);
1793 let output_str = String::from_utf8_lossy(&output);
1794
1795 assert!(output_str.contains('X'));
1797 assert!(output_str.contains("\x1b["), "Expected SGR sequences");
1799 }
1800
1801 #[test]
1802 fn style_transitions_between_different_colors() {
1803 let mut presenter = test_presenter();
1804 let mut buffer = Buffer::new(3, 1);
1805
1806 buffer.set_raw(
1808 0,
1809 0,
1810 Cell::from_char('R').with_fg(PackedRgba::rgb(255, 0, 0)),
1811 );
1812 buffer.set_raw(
1813 1,
1814 0,
1815 Cell::from_char('G').with_fg(PackedRgba::rgb(0, 255, 0)),
1816 );
1817 buffer.set_raw(
1818 2,
1819 0,
1820 Cell::from_char('B').with_fg(PackedRgba::rgb(0, 0, 255)),
1821 );
1822
1823 let old = Buffer::new(3, 1);
1824 let diff = BufferDiff::compute(&old, &buffer);
1825
1826 presenter.present(&buffer, &diff).unwrap();
1827 let output = get_output(presenter);
1828 let output_str = String::from_utf8_lossy(&output);
1829
1830 assert!(output_str.contains("38;2;255;0;0"), "Expected red fg");
1832 assert!(output_str.contains("38;2;0;255;0"), "Expected green fg");
1833 assert!(output_str.contains("38;2;0;0;255"), "Expected blue fg");
1834 }
1835
1836 #[test]
1841 fn link_at_buffer_boundaries() {
1842 let mut presenter = test_presenter();
1843 let mut buffer = Buffer::new(5, 1);
1844 let mut links = LinkRegistry::new();
1845
1846 let link_id = links.register("https://boundary.test");
1847
1848 buffer.set_raw(
1850 0,
1851 0,
1852 Cell::from_char('F').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
1853 );
1854 buffer.set_raw(
1856 4,
1857 0,
1858 Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
1859 );
1860
1861 let old = Buffer::new(5, 1);
1862 let diff = BufferDiff::compute(&old, &buffer);
1863
1864 presenter
1865 .present_with_pool(&buffer, &diff, None, Some(&links))
1866 .unwrap();
1867 let output = get_output(presenter);
1868 let output_str = String::from_utf8_lossy(&output);
1869
1870 assert!(output_str.contains("https://boundary.test"));
1872 assert!(output_str.contains('F'));
1874 assert!(output_str.contains('L'));
1875 }
1876
1877 #[test]
1878 fn link_state_cleared_after_reset() {
1879 let mut presenter = test_presenter();
1880 let mut links = LinkRegistry::new();
1881 let link_id = links.register("https://example.com");
1882
1883 presenter.current_link = Some(link_id);
1885 presenter.current_style = Some(CellStyle::default());
1886 presenter.cursor_x = Some(5);
1887 presenter.cursor_y = Some(3);
1888
1889 presenter.reset();
1890
1891 assert!(
1893 presenter.current_link.is_none(),
1894 "current_link should be None after reset"
1895 );
1896 assert!(
1897 presenter.current_style.is_none(),
1898 "current_style should be None after reset"
1899 );
1900 assert!(
1901 presenter.cursor_x.is_none(),
1902 "cursor_x should be None after reset"
1903 );
1904 assert!(
1905 presenter.cursor_y.is_none(),
1906 "cursor_y should be None after reset"
1907 );
1908 }
1909
1910 #[test]
1911 fn link_transitions_linked_unlinked_linked() {
1912 let mut presenter = test_presenter();
1913 let mut buffer = Buffer::new(5, 1);
1914 let mut links = LinkRegistry::new();
1915
1916 let link_id = links.register("https://toggle.test");
1917
1918 buffer.set_raw(
1920 0,
1921 0,
1922 Cell::from_char('A').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
1923 );
1924 buffer.set_raw(1, 0, Cell::from_char('B')); buffer.set_raw(
1926 2,
1927 0,
1928 Cell::from_char('C').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
1929 );
1930
1931 let old = Buffer::new(5, 1);
1932 let diff = BufferDiff::compute(&old, &buffer);
1933
1934 presenter
1935 .present_with_pool(&buffer, &diff, None, Some(&links))
1936 .unwrap();
1937 let output = get_output(presenter);
1938 let output_str = String::from_utf8_lossy(&output);
1939
1940 let url_count = output_str.matches("https://toggle.test").count();
1942 assert!(
1943 url_count >= 2,
1944 "Expected link to open at least twice, got {} occurrences",
1945 url_count
1946 );
1947
1948 let close_count = output_str.matches("\x1b]8;;\x1b\\").count();
1950 assert!(
1951 close_count >= 2,
1952 "Expected at least 2 link closes, got {}",
1953 close_count
1954 );
1955 }
1956
1957 #[test]
1962 fn multiple_presents_maintain_correct_state() {
1963 let mut presenter = test_presenter();
1964 let mut buffer = Buffer::new(10, 1);
1965
1966 buffer.set_raw(0, 0, Cell::from_char('1'));
1968 let old = Buffer::new(10, 1);
1969 let diff = BufferDiff::compute(&old, &buffer);
1970 presenter.present(&buffer, &diff).unwrap();
1971
1972 let prev = buffer.clone();
1974 buffer.set_raw(1, 0, Cell::from_char('2'));
1975 let diff = BufferDiff::compute(&prev, &buffer);
1976 presenter.present(&buffer, &diff).unwrap();
1977
1978 let prev = buffer.clone();
1980 buffer.set_raw(2, 0, Cell::from_char('3'));
1981 let diff = BufferDiff::compute(&prev, &buffer);
1982 presenter.present(&buffer, &diff).unwrap();
1983
1984 let output = get_output(presenter);
1985 let output_str = String::from_utf8_lossy(&output);
1986
1987 assert!(output_str.contains('1'));
1989 assert!(output_str.contains('2'));
1990 assert!(output_str.contains('3'));
1991 }
1992
1993 #[test]
1998 fn sgr_delta_fg_only_change_no_reset() {
1999 let mut presenter = test_presenter();
2001 let mut buffer = Buffer::new(3, 1);
2002
2003 let fg1 = PackedRgba::rgb(255, 0, 0);
2004 let fg2 = PackedRgba::rgb(0, 255, 0);
2005 buffer.set_raw(0, 0, Cell::from_char('A').with_fg(fg1));
2006 buffer.set_raw(1, 0, Cell::from_char('B').with_fg(fg2));
2007
2008 let old = Buffer::new(3, 1);
2009 let diff = BufferDiff::compute(&old, &buffer);
2010
2011 presenter.present(&buffer, &diff).unwrap();
2012 let output = get_output(presenter);
2013 let output_str = String::from_utf8_lossy(&output);
2014
2015 let reset_count = output_str.matches("\x1b[0m").count();
2018 assert_eq!(
2020 reset_count, 2,
2021 "Expected 2 resets (initial + frame end), got {} in: {:?}",
2022 reset_count, output_str
2023 );
2024 }
2025
2026 #[test]
2027 fn sgr_delta_bg_only_change_no_reset() {
2028 let mut presenter = test_presenter();
2029 let mut buffer = Buffer::new(3, 1);
2030
2031 let bg1 = PackedRgba::rgb(0, 0, 255);
2032 let bg2 = PackedRgba::rgb(255, 255, 0);
2033 buffer.set_raw(0, 0, Cell::from_char('A').with_bg(bg1));
2034 buffer.set_raw(1, 0, Cell::from_char('B').with_bg(bg2));
2035
2036 let old = Buffer::new(3, 1);
2037 let diff = BufferDiff::compute(&old, &buffer);
2038
2039 presenter.present(&buffer, &diff).unwrap();
2040 let output = get_output(presenter);
2041 let output_str = String::from_utf8_lossy(&output);
2042
2043 let reset_count = output_str.matches("\x1b[0m").count();
2045 assert_eq!(
2046 reset_count, 2,
2047 "Expected 2 resets, got {} in: {:?}",
2048 reset_count, output_str
2049 );
2050 }
2051
2052 #[test]
2053 fn sgr_delta_attr_addition_no_reset() {
2054 let mut presenter = test_presenter();
2055 let mut buffer = Buffer::new(3, 1);
2056
2057 let attrs1 = CellAttrs::new(StyleFlags::BOLD, 0);
2059 let attrs2 = CellAttrs::new(StyleFlags::BOLD | StyleFlags::ITALIC, 0);
2060 buffer.set_raw(0, 0, Cell::from_char('A').with_attrs(attrs1));
2061 buffer.set_raw(1, 0, Cell::from_char('B').with_attrs(attrs2));
2062
2063 let old = Buffer::new(3, 1);
2064 let diff = BufferDiff::compute(&old, &buffer);
2065
2066 presenter.present(&buffer, &diff).unwrap();
2067 let output = get_output(presenter);
2068 let output_str = String::from_utf8_lossy(&output);
2069
2070 let reset_count = output_str.matches("\x1b[0m").count();
2072 assert_eq!(
2073 reset_count, 2,
2074 "Expected 2 resets, got {} in: {:?}",
2075 reset_count, output_str
2076 );
2077 assert!(
2079 output_str.contains("\x1b[3m"),
2080 "Expected italic-on sequence in: {:?}",
2081 output_str
2082 );
2083 }
2084
2085 #[test]
2086 fn sgr_delta_attr_removal_uses_off_code() {
2087 let mut presenter = test_presenter();
2088 let mut buffer = Buffer::new(3, 1);
2089
2090 let attrs1 = CellAttrs::new(StyleFlags::BOLD | StyleFlags::ITALIC, 0);
2092 let attrs2 = CellAttrs::new(StyleFlags::BOLD, 0);
2093 buffer.set_raw(0, 0, Cell::from_char('A').with_attrs(attrs1));
2094 buffer.set_raw(1, 0, Cell::from_char('B').with_attrs(attrs2));
2095
2096 let old = Buffer::new(3, 1);
2097 let diff = BufferDiff::compute(&old, &buffer);
2098
2099 presenter.present(&buffer, &diff).unwrap();
2100 let output = get_output(presenter);
2101 let output_str = String::from_utf8_lossy(&output);
2102
2103 assert!(
2105 output_str.contains("\x1b[23m"),
2106 "Expected italic-off sequence in: {:?}",
2107 output_str
2108 );
2109 let reset_count = output_str.matches("\x1b[0m").count();
2111 assert_eq!(
2112 reset_count, 2,
2113 "Expected 2 resets, got {} in: {:?}",
2114 reset_count, output_str
2115 );
2116 }
2117
2118 #[test]
2119 fn sgr_delta_bold_dim_collateral_re_enables() {
2120 let mut presenter = test_presenter();
2123 let mut buffer = Buffer::new(3, 1);
2124
2125 let attrs1 = CellAttrs::new(StyleFlags::BOLD | StyleFlags::DIM, 0);
2127 let attrs2 = CellAttrs::new(StyleFlags::DIM, 0);
2128 buffer.set_raw(0, 0, Cell::from_char('A').with_attrs(attrs1));
2129 buffer.set_raw(1, 0, Cell::from_char('B').with_attrs(attrs2));
2130
2131 let old = Buffer::new(3, 1);
2132 let diff = BufferDiff::compute(&old, &buffer);
2133
2134 presenter.present(&buffer, &diff).unwrap();
2135 let output = get_output(presenter);
2136 let output_str = String::from_utf8_lossy(&output);
2137
2138 assert!(
2140 output_str.contains("\x1b[22m"),
2141 "Expected bold-off (22) in: {:?}",
2142 output_str
2143 );
2144 assert!(
2145 output_str.contains("\x1b[2m"),
2146 "Expected dim re-enable (2) in: {:?}",
2147 output_str
2148 );
2149 }
2150
2151 #[test]
2152 fn sgr_delta_same_style_no_output() {
2153 let mut presenter = test_presenter();
2154 let mut buffer = Buffer::new(3, 1);
2155
2156 let fg = PackedRgba::rgb(255, 0, 0);
2157 let attrs = CellAttrs::new(StyleFlags::BOLD, 0);
2158 buffer.set_raw(0, 0, Cell::from_char('A').with_fg(fg).with_attrs(attrs));
2159 buffer.set_raw(1, 0, Cell::from_char('B').with_fg(fg).with_attrs(attrs));
2160 buffer.set_raw(2, 0, Cell::from_char('C').with_fg(fg).with_attrs(attrs));
2161
2162 let old = Buffer::new(3, 1);
2163 let diff = BufferDiff::compute(&old, &buffer);
2164
2165 presenter.present(&buffer, &diff).unwrap();
2166 let output = get_output(presenter);
2167 let output_str = String::from_utf8_lossy(&output);
2168
2169 let fg_count = output_str.matches("38;2;255;0;0").count();
2171 assert_eq!(
2172 fg_count, 1,
2173 "Expected 1 fg sequence, got {} in: {:?}",
2174 fg_count, output_str
2175 );
2176 }
2177
2178 #[test]
2179 fn sgr_delta_cost_dominance_never_exceeds_baseline() {
2180 let transitions: Vec<(CellStyle, CellStyle)> = vec![
2183 (
2185 CellStyle {
2186 fg: PackedRgba::rgb(255, 0, 0),
2187 bg: PackedRgba::TRANSPARENT,
2188 attrs: StyleFlags::empty(),
2189 },
2190 CellStyle {
2191 fg: PackedRgba::rgb(0, 255, 0),
2192 bg: PackedRgba::TRANSPARENT,
2193 attrs: StyleFlags::empty(),
2194 },
2195 ),
2196 (
2198 CellStyle {
2199 fg: PackedRgba::TRANSPARENT,
2200 bg: PackedRgba::rgb(255, 0, 0),
2201 attrs: StyleFlags::empty(),
2202 },
2203 CellStyle {
2204 fg: PackedRgba::TRANSPARENT,
2205 bg: PackedRgba::rgb(0, 0, 255),
2206 attrs: StyleFlags::empty(),
2207 },
2208 ),
2209 (
2211 CellStyle {
2212 fg: PackedRgba::rgb(100, 100, 100),
2213 bg: PackedRgba::TRANSPARENT,
2214 attrs: StyleFlags::BOLD,
2215 },
2216 CellStyle {
2217 fg: PackedRgba::rgb(100, 100, 100),
2218 bg: PackedRgba::TRANSPARENT,
2219 attrs: StyleFlags::BOLD | StyleFlags::ITALIC,
2220 },
2221 ),
2222 (
2224 CellStyle {
2225 fg: PackedRgba::rgb(100, 100, 100),
2226 bg: PackedRgba::TRANSPARENT,
2227 attrs: StyleFlags::BOLD | StyleFlags::ITALIC,
2228 },
2229 CellStyle {
2230 fg: PackedRgba::rgb(100, 100, 100),
2231 bg: PackedRgba::TRANSPARENT,
2232 attrs: StyleFlags::BOLD,
2233 },
2234 ),
2235 ];
2236
2237 for (old_style, new_style) in &transitions {
2238 let delta_buf = {
2240 let mut delta_presenter = {
2241 let caps = TerminalCapabilities::basic();
2242 Presenter::new(Vec::new(), caps)
2243 };
2244 delta_presenter.current_style = Some(*old_style);
2245 delta_presenter
2246 .emit_style_delta(*old_style, *new_style)
2247 .unwrap();
2248 delta_presenter.into_inner().unwrap()
2249 };
2250
2251 let reset_buf = {
2253 let mut reset_presenter = {
2254 let caps = TerminalCapabilities::basic();
2255 Presenter::new(Vec::new(), caps)
2256 };
2257 reset_presenter.emit_style_full(*new_style).unwrap();
2258 reset_presenter.into_inner().unwrap()
2259 };
2260
2261 assert!(
2262 delta_buf.len() <= reset_buf.len(),
2263 "Delta ({} bytes) exceeded reset+apply ({} bytes) for {:?} -> {:?}.\n\
2264 Delta: {:?}\nReset: {:?}",
2265 delta_buf.len(),
2266 reset_buf.len(),
2267 old_style,
2268 new_style,
2269 String::from_utf8_lossy(&delta_buf),
2270 String::from_utf8_lossy(&reset_buf),
2271 );
2272 }
2273 }
2274
2275 #[test]
2282 fn sgr_delta_evidence_ledger() {
2283 use std::io::Write as _;
2284
2285 const SEED: u64 = 0xDEAD_BEEF_CAFE;
2287
2288 let mut rng_state = SEED;
2290 let mut next_u64 = || -> u64 {
2291 rng_state = rng_state.wrapping_mul(6364136223846793005).wrapping_add(1);
2292 rng_state
2293 };
2294
2295 let random_style = |rng: &mut dyn FnMut() -> u64| -> CellStyle {
2296 let v = rng();
2297 let fg = if v & 1 == 0 {
2298 PackedRgba::TRANSPARENT
2299 } else {
2300 let r = ((v >> 8) & 0xFF) as u8;
2301 let g = ((v >> 16) & 0xFF) as u8;
2302 let b = ((v >> 24) & 0xFF) as u8;
2303 PackedRgba::rgb(r, g, b)
2304 };
2305 let v2 = rng();
2306 let bg = if v2 & 1 == 0 {
2307 PackedRgba::TRANSPARENT
2308 } else {
2309 let r = ((v2 >> 8) & 0xFF) as u8;
2310 let g = ((v2 >> 16) & 0xFF) as u8;
2311 let b = ((v2 >> 24) & 0xFF) as u8;
2312 PackedRgba::rgb(r, g, b)
2313 };
2314 let attrs = StyleFlags::from_bits_truncate(rng() as u8);
2315 CellStyle { fg, bg, attrs }
2316 };
2317
2318 let mut ledger = Vec::new();
2319 let num_transitions = 200;
2320
2321 for i in 0..num_transitions {
2322 let old_style = random_style(&mut next_u64);
2323 let new_style = random_style(&mut next_u64);
2324
2325 let mut delta_p = {
2327 let caps = TerminalCapabilities::basic();
2328 Presenter::new(Vec::new(), caps)
2329 };
2330 delta_p.current_style = Some(old_style);
2331 delta_p.emit_style_delta(old_style, new_style).unwrap();
2332 let delta_out = delta_p.into_inner().unwrap();
2333
2334 let mut reset_p = {
2336 let caps = TerminalCapabilities::basic();
2337 Presenter::new(Vec::new(), caps)
2338 };
2339 reset_p.emit_style_full(new_style).unwrap();
2340 let reset_out = reset_p.into_inner().unwrap();
2341
2342 let delta_bytes = delta_out.len();
2343 let baseline_bytes = reset_out.len();
2344
2345 let attrs_removed = old_style.attrs & !new_style.attrs;
2347 let removed_count = attrs_removed.bits().count_ones();
2348 let fg_changed = old_style.fg != new_style.fg;
2349 let bg_changed = old_style.bg != new_style.bg;
2350 let used_fallback = removed_count >= 3 && fg_changed && bg_changed;
2351
2352 assert!(
2354 delta_bytes <= baseline_bytes,
2355 "Transition {i}: delta ({delta_bytes}B) > baseline ({baseline_bytes}B)"
2356 );
2357
2358 writeln!(
2360 &mut ledger,
2361 "{{\"seed\":{SEED},\"i\":{i},\"from_fg\":\"{:?}\",\"from_bg\":\"{:?}\",\
2362 \"from_attrs\":{},\"to_fg\":\"{:?}\",\"to_bg\":\"{:?}\",\"to_attrs\":{},\
2363 \"delta_bytes\":{delta_bytes},\"baseline_bytes\":{baseline_bytes},\
2364 \"cost_delta\":{},\"used_fallback\":{used_fallback}}}",
2365 old_style.fg,
2366 old_style.bg,
2367 old_style.attrs.bits(),
2368 new_style.fg,
2369 new_style.bg,
2370 new_style.attrs.bits(),
2371 baseline_bytes as isize - delta_bytes as isize,
2372 )
2373 .unwrap();
2374 }
2375
2376 let text = String::from_utf8(ledger).unwrap();
2378 let lines: Vec<&str> = text.lines().collect();
2379 assert_eq!(lines.len(), num_transitions);
2380
2381 let mut total_saved: isize = 0;
2383 for line in &lines {
2384 let cd_start = line.find("\"cost_delta\":").unwrap() + 13;
2386 let cd_end = line[cd_start..].find(',').unwrap() + cd_start;
2387 let cd: isize = line[cd_start..cd_end].parse().unwrap();
2388 total_saved += cd;
2389 }
2390 assert!(
2391 total_saved >= 0,
2392 "Total byte savings should be non-negative, got {total_saved}"
2393 );
2394 }
2395
2396 #[test]
2399 fn e2e_style_stress_with_byte_metrics() {
2400 let width = 40u16;
2401 let height = 10u16;
2402
2403 let mut buffer = Buffer::new(width, height);
2405 for y in 0..height {
2406 for x in 0..width {
2407 let i = (y as usize * width as usize + x as usize) as u8;
2408 let fg = PackedRgba::rgb(i, 255 - i, i.wrapping_mul(3));
2409 let bg = if i.is_multiple_of(4) {
2410 PackedRgba::rgb(i.wrapping_mul(7), i.wrapping_mul(11), i.wrapping_mul(13))
2411 } else {
2412 PackedRgba::TRANSPARENT
2413 };
2414 let flags = StyleFlags::from_bits_truncate(i % 128);
2415 let ch = char::from_u32(('!' as u32) + (i as u32 % 90)).unwrap_or('?');
2416 let cell = Cell::from_char(ch)
2417 .with_fg(fg)
2418 .with_bg(bg)
2419 .with_attrs(CellAttrs::new(flags, 0));
2420 buffer.set_raw(x, y, cell);
2421 }
2422 }
2423
2424 let blank = Buffer::new(width, height);
2426 let diff = BufferDiff::compute(&blank, &buffer);
2427 let mut presenter = test_presenter();
2428 presenter.present(&buffer, &diff).unwrap();
2429 let frame1_bytes = presenter.into_inner().unwrap().len();
2430
2431 let mut buffer2 = Buffer::new(width, height);
2433 for y in 0..height {
2434 for x in 0..width {
2435 let i = (y as usize * width as usize + x as usize + 1) as u8;
2436 let fg = PackedRgba::rgb(i, 255 - i, i.wrapping_mul(3));
2437 let bg = if i.is_multiple_of(4) {
2438 PackedRgba::rgb(i.wrapping_mul(7), i.wrapping_mul(11), i.wrapping_mul(13))
2439 } else {
2440 PackedRgba::TRANSPARENT
2441 };
2442 let flags = StyleFlags::from_bits_truncate(i % 128);
2443 let ch = char::from_u32(('!' as u32) + (i as u32 % 90)).unwrap_or('?');
2444 let cell = Cell::from_char(ch)
2445 .with_fg(fg)
2446 .with_bg(bg)
2447 .with_attrs(CellAttrs::new(flags, 0));
2448 buffer2.set_raw(x, y, cell);
2449 }
2450 }
2451
2452 let diff2 = BufferDiff::compute(&buffer, &buffer2);
2454 let mut presenter2 = test_presenter();
2455 presenter2.present(&buffer2, &diff2).unwrap();
2456 let frame2_bytes = presenter2.into_inner().unwrap().len();
2457
2458 assert!(
2461 frame2_bytes > 0,
2462 "Second frame should produce output for style churn"
2463 );
2464 assert!(!diff2.is_empty(), "Style shift should produce changes");
2465
2466 assert!(
2471 frame2_bytes <= frame1_bytes * 2,
2472 "Incremental frame ({frame2_bytes}B) unreasonably large vs full ({frame1_bytes}B)"
2473 );
2474 }
2475
2476 #[test]
2481 fn cost_model_empty_row_single_run() {
2482 let runs = [ChangeRun::new(5, 10, 20)];
2484 let plan = cost_model::plan_row(&runs, None, None);
2485 assert_eq!(plan.spans().len(), 1);
2486 assert_eq!(plan.spans()[0].x0, 10);
2487 assert_eq!(plan.spans()[0].x1, 20);
2488 assert!(plan.total_cost() > 0);
2489 }
2490
2491 #[test]
2492 fn cost_model_full_row_merges() {
2493 let runs = [ChangeRun::new(0, 0, 2), ChangeRun::new(0, 77, 79)];
2499 let plan = cost_model::plan_row(&runs, None, None);
2500 assert_eq!(plan.spans().len(), 2);
2502 assert_eq!(plan.spans()[0].x0, 0);
2503 assert_eq!(plan.spans()[0].x1, 2);
2504 assert_eq!(plan.spans()[1].x0, 77);
2505 assert_eq!(plan.spans()[1].x1, 79);
2506 }
2507
2508 #[test]
2509 fn cost_model_adjacent_runs_merge() {
2510 let runs = [
2513 ChangeRun::new(3, 10, 10),
2514 ChangeRun::new(3, 12, 12),
2515 ChangeRun::new(3, 14, 14),
2516 ChangeRun::new(3, 16, 16),
2517 ChangeRun::new(3, 18, 18),
2518 ChangeRun::new(3, 20, 20),
2519 ChangeRun::new(3, 22, 22),
2520 ChangeRun::new(3, 24, 24),
2521 ];
2522 let plan = cost_model::plan_row(&runs, None, None);
2523 assert_eq!(plan.spans().len(), 1);
2526 assert_eq!(plan.spans()[0].x0, 10);
2527 assert_eq!(plan.spans()[0].x1, 24);
2528 }
2529
2530 #[test]
2531 fn cost_model_single_cell_stays_sparse() {
2532 let runs = [ChangeRun::new(0, 40, 40)];
2533 let plan = cost_model::plan_row(&runs, Some(0), Some(0));
2534 assert_eq!(plan.spans().len(), 1);
2535 assert_eq!(plan.spans()[0].x0, 40);
2536 assert_eq!(plan.spans()[0].x1, 40);
2537 }
2538
2539 #[test]
2540 fn cost_model_cup_vs_cha_vs_cuf() {
2541 assert!(cost_model::cuf_cost(1) <= cost_model::cha_cost(5));
2543 assert!(cost_model::cuf_cost(3) <= cost_model::cup_cost(0, 5));
2544
2545 let cha = cost_model::cha_cost(5);
2547 let cup = cost_model::cup_cost(0, 5);
2548 assert!(cha <= cup);
2549
2550 let cost = cost_model::cheapest_move_cost(Some(5), Some(0), 6, 0);
2552 assert_eq!(cost, 3); }
2554
2555 #[test]
2556 fn cost_model_digit_estimation_accuracy() {
2557 let mut buf = Vec::new();
2559 ansi::cup(&mut buf, 0, 0).unwrap();
2560 assert_eq!(buf.len(), cost_model::cup_cost(0, 0));
2561
2562 buf.clear();
2563 ansi::cup(&mut buf, 9, 9).unwrap();
2564 assert_eq!(buf.len(), cost_model::cup_cost(9, 9));
2565
2566 buf.clear();
2567 ansi::cup(&mut buf, 99, 99).unwrap();
2568 assert_eq!(buf.len(), cost_model::cup_cost(99, 99));
2569
2570 buf.clear();
2571 ansi::cha(&mut buf, 0).unwrap();
2572 assert_eq!(buf.len(), cost_model::cha_cost(0));
2573
2574 buf.clear();
2575 ansi::cuf(&mut buf, 1).unwrap();
2576 assert_eq!(buf.len(), cost_model::cuf_cost(1));
2577
2578 buf.clear();
2579 ansi::cuf(&mut buf, 10).unwrap();
2580 assert_eq!(buf.len(), cost_model::cuf_cost(10));
2581 }
2582
2583 #[test]
2584 fn cost_model_merged_row_produces_correct_output() {
2585 let width = 30u16;
2587 let mut buffer = Buffer::new(width, 1);
2588
2589 for col in [5u16, 10, 15, 20] {
2591 let ch = char::from_u32('A' as u32 + col as u32 % 26).unwrap();
2592 buffer.set_raw(col, 0, Cell::from_char(ch));
2593 }
2594
2595 let old = Buffer::new(width, 1);
2596 let diff = BufferDiff::compute(&old, &buffer);
2597
2598 let mut presenter = test_presenter();
2600 presenter.present(&buffer, &diff).unwrap();
2601 let output = presenter.into_inner().unwrap();
2602 let output_str = String::from_utf8_lossy(&output);
2603
2604 for col in [5u16, 10, 15, 20] {
2605 let ch = char::from_u32('A' as u32 + col as u32 % 26).unwrap();
2606 assert!(
2607 output_str.contains(ch),
2608 "Missing character '{ch}' at col {col} in output"
2609 );
2610 }
2611 }
2612
2613 #[test]
2614 fn cost_model_optimal_cursor_uses_cuf_on_same_row() {
2615 let mut presenter = test_presenter();
2617 presenter.cursor_x = Some(5);
2618 presenter.cursor_y = Some(0);
2619 presenter.move_cursor_optimal(6, 0).unwrap();
2620 let output = presenter.into_inner().unwrap();
2621 assert_eq!(&output, b"\x1b[C", "Should use CUF for +1 column move");
2623 }
2624
2625 #[test]
2626 fn cost_model_optimal_cursor_uses_cha_on_same_row_backward() {
2627 let mut presenter = test_presenter();
2628 presenter.cursor_x = Some(10);
2629 presenter.cursor_y = Some(3);
2630
2631 let target_x = 2;
2632 let target_y = 3;
2633 let cha_cost = cost_model::cha_cost(target_x);
2634 let cup_cost = cost_model::cup_cost(target_y, target_x);
2635 assert!(
2636 cha_cost <= cup_cost,
2637 "Expected CHA to be cheaper for backward move (cha={cha_cost}, cup={cup_cost})"
2638 );
2639
2640 presenter.move_cursor_optimal(target_x, target_y).unwrap();
2641 let output = presenter.into_inner().unwrap();
2642 let mut expected = Vec::new();
2643 ansi::cha(&mut expected, target_x).unwrap();
2644 assert_eq!(output, expected, "Should use CHA for backward move");
2645 }
2646
2647 #[test]
2648 fn cost_model_optimal_cursor_uses_cup_on_row_change() {
2649 let mut presenter = test_presenter();
2650 presenter.cursor_x = Some(4);
2651 presenter.cursor_y = Some(1);
2652
2653 presenter.move_cursor_optimal(7, 4).unwrap();
2654 let output = presenter.into_inner().unwrap();
2655 let mut expected = Vec::new();
2656 ansi::cup(&mut expected, 4, 7).unwrap();
2657 assert_eq!(output, expected, "Should use CUP when row changes");
2658 }
2659
2660 #[test]
2661 fn cost_model_chooses_full_row_when_cheaper() {
2662 let width = 40u16;
2665 let mut buffer = Buffer::new(width, 1);
2666
2667 for col in (0..20).step_by(2) {
2669 buffer.set_raw(col, 0, Cell::from_char('X'));
2670 }
2671
2672 let old = Buffer::new(width, 1);
2673 let diff = BufferDiff::compute(&old, &buffer);
2674 let runs = diff.runs();
2675
2676 let row_runs: Vec<_> = runs.iter().filter(|r| r.y == 0).copied().collect();
2678 if row_runs.len() > 1 {
2679 let plan = cost_model::plan_row(&row_runs, None, None);
2680 assert!(
2681 plan.spans().len() == 1,
2682 "Expected single merged span for many small runs, got {} spans",
2683 plan.spans().len()
2684 );
2685 assert_eq!(plan.spans()[0].x0, 0);
2686 assert_eq!(plan.spans()[0].x1, 18);
2687 }
2688 }
2689
2690 #[test]
2691 fn perf_cost_model_overhead() {
2692 use std::time::Instant;
2694
2695 let runs: Vec<ChangeRun> = (0..100)
2696 .map(|i| ChangeRun::new(0, i * 3, i * 3 + 1))
2697 .collect();
2698
2699 let (iterations, max_ms) = if cfg!(debug_assertions) {
2700 (1_000, 1_000u128)
2701 } else {
2702 (10_000, 500u128)
2703 };
2704
2705 let start = Instant::now();
2706 for _ in 0..iterations {
2707 let _ = cost_model::plan_row(&runs, None, None);
2708 }
2709 let elapsed = start.elapsed();
2710
2711 assert!(
2713 elapsed.as_millis() < max_ms,
2714 "Cost model planning too slow: {elapsed:?} for {iterations} iterations"
2715 );
2716 }
2717
2718 #[test]
2719 fn perf_legacy_vs_dp_worst_case_sparse() {
2720 use std::time::Instant;
2721
2722 let width = 200u16;
2723 let height = 1u16;
2724 let mut buffer = Buffer::new(width, height);
2725
2726 for col in (0..40).step_by(2) {
2728 buffer.set_raw(col, 0, Cell::from_char('X'));
2729 }
2730 for col in (160..200).step_by(2) {
2731 buffer.set_raw(col, 0, Cell::from_char('Y'));
2732 }
2733
2734 let blank = Buffer::new(width, height);
2735 let diff = BufferDiff::compute(&blank, &buffer);
2736 let runs = diff.runs();
2737 let row_runs: Vec<_> = runs.iter().filter(|r| r.y == 0).copied().collect();
2738
2739 let dp_plan = cost_model::plan_row(&row_runs, None, None);
2740 let legacy_spans = legacy_plan_row(&row_runs, None, None);
2741
2742 let dp_output = emit_spans_for_output(&buffer, dp_plan.spans());
2743 let legacy_output = emit_spans_for_output(&buffer, &legacy_spans);
2744
2745 assert!(
2746 dp_output.len() <= legacy_output.len(),
2747 "DP output should be <= legacy output (dp={}, legacy={})",
2748 dp_output.len(),
2749 legacy_output.len()
2750 );
2751
2752 let (iterations, max_ms) = if cfg!(debug_assertions) {
2753 (1_000, 1_000u128)
2754 } else {
2755 (10_000, 500u128)
2756 };
2757 let start = Instant::now();
2758 for _ in 0..iterations {
2759 let _ = cost_model::plan_row(&row_runs, None, None);
2760 }
2761 let dp_elapsed = start.elapsed();
2762
2763 let start = Instant::now();
2764 for _ in 0..iterations {
2765 let _ = legacy_plan_row(&row_runs, None, None);
2766 }
2767 let legacy_elapsed = start.elapsed();
2768
2769 assert!(
2770 dp_elapsed.as_millis() < max_ms,
2771 "DP planning too slow: {dp_elapsed:?} for {iterations} iterations"
2772 );
2773
2774 let _ = legacy_elapsed;
2775 }
2776
2777 fn build_style_heavy_scene(width: u16, height: u16, seed: u64) -> Buffer {
2783 let mut buffer = Buffer::new(width, height);
2784 let mut rng = seed;
2785 let mut next = || -> u64 {
2786 rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1);
2787 rng
2788 };
2789 for y in 0..height {
2790 for x in 0..width {
2791 let v = next();
2792 let ch = char::from_u32(('!' as u32) + (v as u32 % 90)).unwrap_or('?');
2793 let fg = PackedRgba::rgb((v >> 8) as u8, (v >> 16) as u8, (v >> 24) as u8);
2794 let bg = if v & 3 == 0 {
2795 PackedRgba::rgb((v >> 32) as u8, (v >> 40) as u8, (v >> 48) as u8)
2796 } else {
2797 PackedRgba::TRANSPARENT
2798 };
2799 let flags = StyleFlags::from_bits_truncate((v >> 56) as u8);
2800 let cell = Cell::from_char(ch)
2801 .with_fg(fg)
2802 .with_bg(bg)
2803 .with_attrs(CellAttrs::new(flags, 0));
2804 buffer.set_raw(x, y, cell);
2805 }
2806 }
2807 buffer
2808 }
2809
2810 fn build_sparse_update(base: &Buffer, seed: u64) -> Buffer {
2812 let mut buffer = base.clone();
2813 let width = base.width();
2814 let height = base.height();
2815 let mut rng = seed;
2816 let mut next = || -> u64 {
2817 rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1);
2818 rng
2819 };
2820 let change_count = (width as usize * height as usize) / 10;
2821 for _ in 0..change_count {
2822 let v = next();
2823 let x = (v % width as u64) as u16;
2824 let y = ((v >> 16) % height as u64) as u16;
2825 let ch = char::from_u32(('A' as u32) + (v as u32 % 26)).unwrap_or('?');
2826 buffer.set_raw(x, y, Cell::from_char(ch));
2827 }
2828 buffer
2829 }
2830
2831 #[test]
2832 fn snapshot_presenter_equivalence() {
2833 let buffer = build_style_heavy_scene(40, 10, 0xDEAD_CAFE_1234);
2836 let blank = Buffer::new(40, 10);
2837 let diff = BufferDiff::compute(&blank, &buffer);
2838
2839 let mut presenter = test_presenter();
2840 presenter.present(&buffer, &diff).unwrap();
2841 let output = presenter.into_inner().unwrap();
2842
2843 let checksum = {
2845 let mut hash: u64 = 0xcbf29ce484222325; for &byte in &output {
2847 hash ^= byte as u64;
2848 hash = hash.wrapping_mul(0x100000001b3); }
2850 hash
2851 };
2852
2853 let mut presenter2 = test_presenter();
2855 presenter2.present(&buffer, &diff).unwrap();
2856 let output2 = presenter2.into_inner().unwrap();
2857 assert_eq!(output, output2, "Presenter output must be deterministic");
2858
2859 let _ = checksum; }
2862
2863 #[test]
2864 fn perf_presenter_microbench() {
2865 use std::env;
2866 use std::io::Write as _;
2867 use std::time::Instant;
2868
2869 let width = 120u16;
2870 let height = 40u16;
2871 let seed = 0x00BE_EFCA_FE42;
2872 let scene = build_style_heavy_scene(width, height, seed);
2873 let blank = Buffer::new(width, height);
2874 let diff_full = BufferDiff::compute(&blank, &scene);
2875
2876 let scene2 = build_sparse_update(&scene, seed.wrapping_add(1));
2878 let diff_sparse = BufferDiff::compute(&scene, &scene2);
2879
2880 let mut jsonl = Vec::new();
2881 let iterations = env::var("FTUI_PRESENTER_BENCH_ITERS")
2882 .ok()
2883 .and_then(|value| value.parse::<u32>().ok())
2884 .unwrap_or(50);
2885
2886 let runs_full = diff_full.runs();
2887 let runs_sparse = diff_sparse.runs();
2888
2889 let plan_rows = |runs: &[ChangeRun]| -> (usize, usize) {
2890 let mut idx = 0;
2891 let mut total_cost = 0usize;
2892 let mut span_count = 0usize;
2893 let mut prev_x = None;
2894 let mut prev_y = None;
2895
2896 while idx < runs.len() {
2897 let y = runs[idx].y;
2898 let start = idx;
2899 while idx < runs.len() && runs[idx].y == y {
2900 idx += 1;
2901 }
2902
2903 let plan = cost_model::plan_row(&runs[start..idx], prev_x, prev_y);
2904 span_count += plan.spans().len();
2905 total_cost = total_cost.saturating_add(plan.total_cost());
2906 if let Some(last) = plan.spans().last() {
2907 prev_x = Some(last.x1);
2908 prev_y = Some(y);
2909 }
2910 }
2911
2912 (total_cost, span_count)
2913 };
2914
2915 for i in 0..iterations {
2916 let (diff_ref, buf_ref, runs_ref, label) = if i % 2 == 0 {
2917 (&diff_full, &scene, &runs_full, "full")
2918 } else {
2919 (&diff_sparse, &scene2, &runs_sparse, "sparse")
2920 };
2921
2922 let plan_start = Instant::now();
2923 let (plan_cost, plan_spans) = plan_rows(runs_ref);
2924 let plan_time_us = plan_start.elapsed().as_micros() as u64;
2925
2926 let mut presenter = test_presenter();
2927 let start = Instant::now();
2928 let stats = presenter.present(buf_ref, diff_ref).unwrap();
2929 let elapsed_us = start.elapsed().as_micros() as u64;
2930 let output = presenter.into_inner().unwrap();
2931
2932 let checksum = {
2934 let mut hash: u64 = 0xcbf29ce484222325;
2935 for &b in &output {
2936 hash ^= b as u64;
2937 hash = hash.wrapping_mul(0x100000001b3);
2938 }
2939 hash
2940 };
2941
2942 writeln!(
2943 &mut jsonl,
2944 "{{\"seed\":{seed},\"width\":{width},\"height\":{height},\
2945 \"scene\":\"{label}\",\"changes\":{},\"runs\":{},\
2946 \"plan_cost\":{plan_cost},\"plan_spans\":{plan_spans},\
2947 \"plan_time_us\":{plan_time_us},\"bytes\":{},\
2948 \"emit_time_us\":{elapsed_us},\
2949 \"checksum\":\"{checksum:016x}\"}}",
2950 stats.cells_changed, stats.run_count, stats.bytes_emitted,
2951 )
2952 .unwrap();
2953 }
2954
2955 let text = String::from_utf8(jsonl).unwrap();
2956 let lines: Vec<&str> = text.lines().collect();
2957 assert_eq!(lines.len(), iterations as usize);
2958
2959 let full_checksums: Vec<&str> = lines
2961 .iter()
2962 .filter(|l| l.contains("\"full\""))
2963 .map(|l| {
2964 let start = l.find("\"checksum\":\"").unwrap() + 12;
2965 let end = l[start..].find('"').unwrap() + start;
2966 &l[start..end]
2967 })
2968 .collect();
2969 assert!(full_checksums.len() > 1);
2970 assert!(
2971 full_checksums.windows(2).all(|w| w[0] == w[1]),
2972 "Full frame checksums should be identical across runs"
2973 );
2974
2975 let full_bytes: Vec<u64> = lines
2977 .iter()
2978 .filter(|l| l.contains("\"full\""))
2979 .map(|l| {
2980 let start = l.find("\"bytes\":").unwrap() + 8;
2981 let end = l[start..].find(',').unwrap() + start;
2982 l[start..end].parse::<u64>().unwrap()
2983 })
2984 .collect();
2985 let sparse_bytes: Vec<u64> = lines
2986 .iter()
2987 .filter(|l| l.contains("\"sparse\""))
2988 .map(|l| {
2989 let start = l.find("\"bytes\":").unwrap() + 8;
2990 let end = l[start..].find(',').unwrap() + start;
2991 l[start..end].parse::<u64>().unwrap()
2992 })
2993 .collect();
2994
2995 let avg_full: u64 = full_bytes.iter().sum::<u64>() / full_bytes.len() as u64;
2996 let avg_sparse: u64 = sparse_bytes.iter().sum::<u64>() / sparse_bytes.len() as u64;
2997 assert!(
2998 avg_sparse < avg_full,
2999 "Sparse updates ({avg_sparse}B) should emit fewer bytes than full ({avg_full}B)"
3000 );
3001 }
3002
3003 #[test]
3004 fn perf_emit_style_delta_microbench() {
3005 use std::env;
3006 use std::io::Write as _;
3007 use std::time::Instant;
3008
3009 let iterations = env::var("FTUI_EMIT_STYLE_BENCH_ITERS")
3010 .ok()
3011 .and_then(|value| value.parse::<u32>().ok())
3012 .unwrap_or(200);
3013 let mode = env::var("FTUI_EMIT_STYLE_BENCH_MODE").unwrap_or_default();
3014 let emit_json = mode != "raw";
3015
3016 let mut styles = Vec::with_capacity(128);
3017 let mut rng = 0x00A5_A51E_AF42_u64;
3018 let mut next = || -> u64 {
3019 rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1);
3020 rng
3021 };
3022
3023 for _ in 0..128 {
3024 let v = next();
3025 let fg = PackedRgba::rgb(
3026 (v & 0xFF) as u8,
3027 ((v >> 8) & 0xFF) as u8,
3028 ((v >> 16) & 0xFF) as u8,
3029 );
3030 let bg = PackedRgba::rgb(
3031 ((v >> 24) & 0xFF) as u8,
3032 ((v >> 32) & 0xFF) as u8,
3033 ((v >> 40) & 0xFF) as u8,
3034 );
3035 let flags = StyleFlags::from_bits_truncate((v >> 48) as u8);
3036 let cell = Cell::from_char('A')
3037 .with_fg(fg)
3038 .with_bg(bg)
3039 .with_attrs(CellAttrs::new(flags, 0));
3040 styles.push(CellStyle::from_cell(&cell));
3041 }
3042
3043 let mut presenter = test_presenter();
3044 let mut jsonl = Vec::new();
3045 let mut sink = 0u64;
3046
3047 for i in 0..iterations {
3048 let old = styles[i as usize % styles.len()];
3049 let new = styles[(i as usize + 1) % styles.len()];
3050
3051 presenter.writer.reset_counter();
3052 presenter.writer.inner_mut().get_mut().clear();
3053
3054 let start = Instant::now();
3055 presenter.emit_style_delta(old, new).unwrap();
3056 let elapsed_us = start.elapsed().as_micros() as u64;
3057 let bytes = presenter.writer.bytes_written();
3058
3059 if emit_json {
3060 writeln!(
3061 &mut jsonl,
3062 "{{\"iter\":{i},\"emit_time_us\":{elapsed_us},\"bytes\":{bytes}}}"
3063 )
3064 .unwrap();
3065 } else {
3066 sink = sink.wrapping_add(elapsed_us ^ bytes);
3067 }
3068 }
3069
3070 if emit_json {
3071 let text = String::from_utf8(jsonl).unwrap();
3072 let lines: Vec<&str> = text.lines().collect();
3073 assert_eq!(lines.len() as u32, iterations);
3074 } else {
3075 std::hint::black_box(sink);
3076 }
3077 }
3078
3079 #[test]
3080 fn e2e_presenter_stress_deterministic() {
3081 use crate::terminal_model::TerminalModel;
3084
3085 let width = 60u16;
3086 let height = 20u16;
3087 let num_frames = 10;
3088
3089 let mut prev_buffer = Buffer::new(width, height);
3090 let mut presenter = test_presenter();
3091 let mut model = TerminalModel::new(width as usize, height as usize);
3092 let mut rng = 0x5D2E_55DE_5D42_u64;
3093 let mut next = || -> u64 {
3094 rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1);
3095 rng
3096 };
3097
3098 for _frame in 0..num_frames {
3099 let mut buffer = prev_buffer.clone();
3101 let changes = (width as usize * height as usize) / 5;
3102 for _ in 0..changes {
3103 let v = next();
3104 let x = (v % width as u64) as u16;
3105 let y = ((v >> 16) % height as u64) as u16;
3106 let ch = char::from_u32(('!' as u32) + (v as u32 % 90)).unwrap_or('?');
3107 let fg = PackedRgba::rgb((v >> 8) as u8, (v >> 24) as u8, (v >> 40) as u8);
3108 let cell = Cell::from_char(ch).with_fg(fg);
3109 buffer.set_raw(x, y, cell);
3110 }
3111
3112 let diff = BufferDiff::compute(&prev_buffer, &buffer);
3113 presenter.present(&buffer, &diff).unwrap();
3114
3115 prev_buffer = buffer;
3116 }
3117
3118 let output = presenter.into_inner().unwrap();
3120 model.process(&output);
3121
3122 let mut checked = 0;
3124 for y in 0..height {
3125 for x in 0..width {
3126 let buf_cell = prev_buffer.get_unchecked(x, y);
3127 if !buf_cell.is_empty()
3128 && let Some(model_cell) = model.cell(x as usize, y as usize)
3129 {
3130 let expected = buf_cell.content.as_char().unwrap_or(' ');
3131 let mut buf = [0u8; 4];
3132 let expected_str = expected.encode_utf8(&mut buf);
3133 if model_cell.text.as_str() == expected_str {
3134 checked += 1;
3135 }
3136 }
3137 }
3138 }
3139
3140 let total_nonempty = (0..height)
3143 .flat_map(|y| (0..width).map(move |x| (x, y)))
3144 .filter(|&(x, y)| !prev_buffer.get_unchecked(x, y).is_empty())
3145 .count();
3146
3147 assert!(
3148 checked > total_nonempty * 80 / 100,
3149 "Frame {num_frames}: only {checked}/{total_nonempty} cells match final buffer"
3150 );
3151 }
3152
3153 #[test]
3154 fn style_state_persists_across_frames() {
3155 let mut presenter = test_presenter();
3156 let fg = PackedRgba::rgb(100, 150, 200);
3157
3158 let mut buffer = Buffer::new(5, 1);
3160 buffer.set_raw(0, 0, Cell::from_char('A').with_fg(fg));
3161 let old = Buffer::new(5, 1);
3162 let diff = BufferDiff::compute(&old, &buffer);
3163 presenter.present(&buffer, &diff).unwrap();
3164
3165 assert!(
3168 presenter.current_style.is_none(),
3169 "Style should be reset after frame end"
3170 );
3171 }
3172
3173 #[test]
3180 fn cost_cup_zero_zero() {
3181 assert_eq!(cost_model::cup_cost(0, 0), 6);
3183 }
3184
3185 #[test]
3186 fn cost_cup_max_max() {
3187 assert_eq!(cost_model::cup_cost(u16::MAX, u16::MAX), 14);
3190 }
3191
3192 #[test]
3193 fn cost_cha_zero() {
3194 assert_eq!(cost_model::cha_cost(0), 4);
3196 }
3197
3198 #[test]
3199 fn cost_cha_max() {
3200 assert_eq!(cost_model::cha_cost(u16::MAX), 8);
3202 }
3203
3204 #[test]
3205 fn cost_cuf_zero_is_free() {
3206 assert_eq!(cost_model::cuf_cost(0), 0);
3207 }
3208
3209 #[test]
3210 fn cost_cuf_one_is_three() {
3211 assert_eq!(cost_model::cuf_cost(1), 3);
3213 }
3214
3215 #[test]
3216 fn cost_cuf_two_has_digit() {
3217 assert_eq!(cost_model::cuf_cost(2), 4);
3219 }
3220
3221 #[test]
3222 fn cost_cuf_max() {
3223 assert_eq!(cost_model::cuf_cost(u16::MAX), 8);
3225 }
3226
3227 #[test]
3228 fn cost_cheapest_move_already_at_target() {
3229 assert_eq!(cost_model::cheapest_move_cost(Some(5), Some(3), 5, 3), 0);
3230 }
3231
3232 #[test]
3233 fn cost_cheapest_move_unknown_position() {
3234 let cost = cost_model::cheapest_move_cost(None, None, 5, 3);
3236 assert_eq!(cost, cost_model::cup_cost(3, 5));
3237 }
3238
3239 #[test]
3240 fn cost_cheapest_move_known_y_unknown_x() {
3241 let cost = cost_model::cheapest_move_cost(None, Some(3), 5, 3);
3243 assert_eq!(cost, cost_model::cup_cost(3, 5));
3244 }
3245
3246 #[test]
3247 fn cost_cheapest_move_backward_same_row() {
3248 let cost = cost_model::cheapest_move_cost(Some(50), Some(0), 5, 0);
3250 let cha = cost_model::cha_cost(5);
3251 let cup = cost_model::cup_cost(0, 5);
3252 assert_eq!(cost, cha.min(cup));
3253 }
3254
3255 #[test]
3256 fn cost_cheapest_move_same_row_same_col() {
3257 assert_eq!(cost_model::cheapest_move_cost(Some(0), Some(0), 0, 0), 0);
3259 }
3260
3261 #[test]
3264 fn cost_cup_digit_boundaries() {
3265 let mut buf = Vec::new();
3266 for (row, col) in [
3267 (0u16, 0u16),
3268 (8, 8),
3269 (9, 9),
3270 (98, 98),
3271 (99, 99),
3272 (998, 998),
3273 (999, 999),
3274 (9998, 9998),
3275 (9999, 9999),
3276 (u16::MAX, u16::MAX),
3277 ] {
3278 buf.clear();
3279 ansi::cup(&mut buf, row, col).unwrap();
3280 assert_eq!(
3281 buf.len(),
3282 cost_model::cup_cost(row, col),
3283 "CUP cost mismatch at ({row}, {col})"
3284 );
3285 }
3286 }
3287
3288 #[test]
3289 fn cost_cha_digit_boundaries() {
3290 let mut buf = Vec::new();
3291 for col in [0u16, 8, 9, 98, 99, 998, 999, 9998, 9999, u16::MAX] {
3292 buf.clear();
3293 ansi::cha(&mut buf, col).unwrap();
3294 assert_eq!(
3295 buf.len(),
3296 cost_model::cha_cost(col),
3297 "CHA cost mismatch at col {col}"
3298 );
3299 }
3300 }
3301
3302 #[test]
3303 fn cost_cuf_digit_boundaries() {
3304 let mut buf = Vec::new();
3305 for n in [1u16, 2, 9, 10, 99, 100, 999, 1000, 9999, 10000, u16::MAX] {
3306 buf.clear();
3307 ansi::cuf(&mut buf, n).unwrap();
3308 assert_eq!(
3309 buf.len(),
3310 cost_model::cuf_cost(n),
3311 "CUF cost mismatch for n={n}"
3312 );
3313 }
3314 }
3315
3316 #[test]
3319 fn plan_row_reuse_matches_plan_row() {
3320 let runs = [
3321 ChangeRun::new(5, 2, 4),
3322 ChangeRun::new(5, 8, 10),
3323 ChangeRun::new(5, 20, 25),
3324 ];
3325 let plan1 = cost_model::plan_row(&runs, Some(0), Some(5));
3326 let mut scratch = cost_model::RowPlanScratch::default();
3327 let plan2 = cost_model::plan_row_reuse(&runs, Some(0), Some(5), &mut scratch);
3328 assert_eq!(plan1, plan2);
3329 }
3330
3331 #[test]
3332 fn plan_row_reuse_across_different_sizes() {
3333 let mut scratch = cost_model::RowPlanScratch::default();
3335
3336 let large_runs: Vec<ChangeRun> = (0..20)
3337 .map(|i| ChangeRun::new(0, i * 4, i * 4 + 1))
3338 .collect();
3339 let plan_large = cost_model::plan_row_reuse(&large_runs, None, None, &mut scratch);
3340 assert!(!plan_large.spans().is_empty());
3341
3342 let small_runs = [ChangeRun::new(1, 5, 8)];
3343 let plan_small = cost_model::plan_row_reuse(&small_runs, None, None, &mut scratch);
3344 assert_eq!(plan_small.spans().len(), 1);
3345 assert_eq!(plan_small.spans()[0].x0, 5);
3346 assert_eq!(plan_small.spans()[0].x1, 8);
3347 }
3348
3349 #[test]
3352 fn plan_row_gap_exactly_32_cells() {
3353 let runs = [ChangeRun::new(0, 0, 0), ChangeRun::new(0, 33, 33)];
3356 let plan = cost_model::plan_row(&runs, None, None);
3357 assert!(
3361 plan.spans().len() <= 2,
3362 "32-cell gap should still consider merge"
3363 );
3364 }
3365
3366 #[test]
3367 fn plan_row_gap_33_cells_stays_sparse() {
3368 let runs = [ChangeRun::new(0, 0, 0), ChangeRun::new(0, 34, 34)];
3371 let plan = cost_model::plan_row(&runs, None, None);
3372 assert_eq!(
3373 plan.spans().len(),
3374 2,
3375 "33-cell gap should stay sparse (gap > 32 breaks)"
3376 );
3377 }
3378
3379 #[test]
3382 fn plan_row_many_sparse_spans() {
3383 let runs = [
3385 ChangeRun::new(0, 0, 0),
3386 ChangeRun::new(0, 40, 40),
3387 ChangeRun::new(0, 80, 80),
3388 ChangeRun::new(0, 120, 120),
3389 ChangeRun::new(0, 160, 160),
3390 ChangeRun::new(0, 200, 200),
3391 ];
3392 let plan = cost_model::plan_row(&runs, None, None);
3393 assert_eq!(plan.spans().len(), 6, "Should have 6 separate sparse spans");
3395 }
3396
3397 #[test]
3400 fn cell_style_default_is_transparent_no_attrs() {
3401 let style = CellStyle::default();
3402 assert_eq!(style.fg, PackedRgba::TRANSPARENT);
3403 assert_eq!(style.bg, PackedRgba::TRANSPARENT);
3404 assert!(style.attrs.is_empty());
3405 }
3406
3407 #[test]
3408 fn cell_style_from_cell_captures_all() {
3409 let fg = PackedRgba::rgb(10, 20, 30);
3410 let bg = PackedRgba::rgb(40, 50, 60);
3411 let flags = StyleFlags::BOLD | StyleFlags::ITALIC;
3412 let cell = Cell::from_char('X')
3413 .with_fg(fg)
3414 .with_bg(bg)
3415 .with_attrs(CellAttrs::new(flags, 5));
3416 let style = CellStyle::from_cell(&cell);
3417 assert_eq!(style.fg, fg);
3418 assert_eq!(style.bg, bg);
3419 assert_eq!(style.attrs, flags);
3420 }
3421
3422 #[test]
3423 fn cell_style_eq_and_clone() {
3424 let a = CellStyle {
3425 fg: PackedRgba::rgb(1, 2, 3),
3426 bg: PackedRgba::TRANSPARENT,
3427 attrs: StyleFlags::DIM,
3428 };
3429 let b = a;
3430 assert_eq!(a, b);
3431 }
3432
3433 #[test]
3436 fn sgr_flags_len_empty() {
3437 assert_eq!(Presenter::<Vec<u8>>::sgr_flags_len(StyleFlags::empty()), 0);
3438 }
3439
3440 #[test]
3441 fn sgr_flags_len_single() {
3442 let len = Presenter::<Vec<u8>>::sgr_flags_len(StyleFlags::BOLD);
3444 assert!(len > 0);
3445 let mut buf = Vec::new();
3447 ansi::sgr_flags(&mut buf, StyleFlags::BOLD).unwrap();
3448 assert_eq!(len as usize, buf.len());
3449 }
3450
3451 #[test]
3452 fn sgr_flags_len_multiple() {
3453 let flags = StyleFlags::BOLD | StyleFlags::ITALIC | StyleFlags::UNDERLINE;
3454 let len = Presenter::<Vec<u8>>::sgr_flags_len(flags);
3455 let mut buf = Vec::new();
3456 ansi::sgr_flags(&mut buf, flags).unwrap();
3457 assert_eq!(len as usize, buf.len());
3458 }
3459
3460 #[test]
3461 fn sgr_flags_off_len_empty() {
3462 assert_eq!(
3463 Presenter::<Vec<u8>>::sgr_flags_off_len(StyleFlags::empty()),
3464 0
3465 );
3466 }
3467
3468 #[test]
3469 fn sgr_rgb_len_matches_actual() {
3470 let color = PackedRgba::rgb(0, 0, 0);
3471 let estimated = Presenter::<Vec<u8>>::sgr_rgb_len(color);
3472 assert!(estimated > 0);
3475 }
3476
3477 #[test]
3478 fn sgr_rgb_len_large_values() {
3479 let color = PackedRgba::rgb(255, 255, 255);
3480 let small_color = PackedRgba::rgb(0, 0, 0);
3481 let large_len = Presenter::<Vec<u8>>::sgr_rgb_len(color);
3482 let small_len = Presenter::<Vec<u8>>::sgr_rgb_len(small_color);
3483 assert!(large_len > small_len);
3485 }
3486
3487 #[test]
3488 fn dec_len_u8_boundaries() {
3489 assert_eq!(Presenter::<Vec<u8>>::dec_len_u8(0), 1);
3490 assert_eq!(Presenter::<Vec<u8>>::dec_len_u8(9), 1);
3491 assert_eq!(Presenter::<Vec<u8>>::dec_len_u8(10), 2);
3492 assert_eq!(Presenter::<Vec<u8>>::dec_len_u8(99), 2);
3493 assert_eq!(Presenter::<Vec<u8>>::dec_len_u8(100), 3);
3494 assert_eq!(Presenter::<Vec<u8>>::dec_len_u8(255), 3);
3495 }
3496
3497 #[test]
3500 fn sgr_delta_all_attrs_removed_at_once() {
3501 let mut presenter = test_presenter();
3502 let all_flags = StyleFlags::BOLD
3503 | StyleFlags::DIM
3504 | StyleFlags::ITALIC
3505 | StyleFlags::UNDERLINE
3506 | StyleFlags::BLINK
3507 | StyleFlags::REVERSE
3508 | StyleFlags::STRIKETHROUGH;
3509 let old = CellStyle {
3510 fg: PackedRgba::rgb(100, 100, 100),
3511 bg: PackedRgba::TRANSPARENT,
3512 attrs: all_flags,
3513 };
3514 let new = CellStyle {
3515 fg: PackedRgba::rgb(100, 100, 100),
3516 bg: PackedRgba::TRANSPARENT,
3517 attrs: StyleFlags::empty(),
3518 };
3519
3520 presenter.current_style = Some(old);
3521 presenter.emit_style_delta(old, new).unwrap();
3522 let output = presenter.into_inner().unwrap();
3523
3524 assert!(!output.is_empty());
3527 }
3528
3529 #[test]
3530 fn sgr_delta_fg_to_transparent() {
3531 let mut presenter = test_presenter();
3532 let old = CellStyle {
3533 fg: PackedRgba::rgb(200, 100, 50),
3534 bg: PackedRgba::TRANSPARENT,
3535 attrs: StyleFlags::empty(),
3536 };
3537 let new = CellStyle {
3538 fg: PackedRgba::TRANSPARENT,
3539 bg: PackedRgba::TRANSPARENT,
3540 attrs: StyleFlags::empty(),
3541 };
3542
3543 presenter.current_style = Some(old);
3544 presenter.emit_style_delta(old, new).unwrap();
3545 let output = presenter.into_inner().unwrap();
3546 let output_str = String::from_utf8_lossy(&output);
3547
3548 assert!(!output.is_empty(), "Should emit fg removal: {output_str:?}");
3551 }
3552
3553 #[test]
3554 fn sgr_delta_bg_to_transparent() {
3555 let mut presenter = test_presenter();
3556 let old = CellStyle {
3557 fg: PackedRgba::TRANSPARENT,
3558 bg: PackedRgba::rgb(30, 60, 90),
3559 attrs: StyleFlags::empty(),
3560 };
3561 let new = CellStyle {
3562 fg: PackedRgba::TRANSPARENT,
3563 bg: PackedRgba::TRANSPARENT,
3564 attrs: StyleFlags::empty(),
3565 };
3566
3567 presenter.current_style = Some(old);
3568 presenter.emit_style_delta(old, new).unwrap();
3569 let output = presenter.into_inner().unwrap();
3570 assert!(!output.is_empty(), "Should emit bg removal");
3571 }
3572
3573 #[test]
3574 fn sgr_delta_dim_removed_bold_stays() {
3575 let mut presenter = test_presenter();
3579 let mut buffer = Buffer::new(3, 1);
3580
3581 let attrs1 = CellAttrs::new(StyleFlags::BOLD | StyleFlags::DIM, 0);
3582 let attrs2 = CellAttrs::new(StyleFlags::BOLD, 0);
3583 buffer.set_raw(0, 0, Cell::from_char('A').with_attrs(attrs1));
3584 buffer.set_raw(1, 0, Cell::from_char('B').with_attrs(attrs2));
3585
3586 let old = Buffer::new(3, 1);
3587 let diff = BufferDiff::compute(&old, &buffer);
3588
3589 presenter.present(&buffer, &diff).unwrap();
3590 let output = get_output(presenter);
3591 let output_str = String::from_utf8_lossy(&output);
3592
3593 assert!(
3595 output_str.contains("\x1b[22m"),
3596 "Expected dim-off (22) in: {output_str:?}"
3597 );
3598 assert!(
3599 output_str.contains("\x1b[1m"),
3600 "Expected bold re-enable (1) in: {output_str:?}"
3601 );
3602 }
3603
3604 #[test]
3605 fn sgr_delta_fallback_to_full_reset_when_cheaper() {
3606 let mut presenter = test_presenter();
3608 let old = CellStyle {
3609 fg: PackedRgba::rgb(10, 20, 30),
3610 bg: PackedRgba::rgb(40, 50, 60),
3611 attrs: StyleFlags::BOLD
3612 | StyleFlags::DIM
3613 | StyleFlags::ITALIC
3614 | StyleFlags::UNDERLINE
3615 | StyleFlags::STRIKETHROUGH,
3616 };
3617 let new = CellStyle {
3618 fg: PackedRgba::TRANSPARENT,
3619 bg: PackedRgba::TRANSPARENT,
3620 attrs: StyleFlags::empty(),
3621 };
3622
3623 presenter.current_style = Some(old);
3624 presenter.emit_style_delta(old, new).unwrap();
3625 let output = presenter.into_inner().unwrap();
3626 let output_str = String::from_utf8_lossy(&output);
3627
3628 assert!(
3630 output_str.contains("\x1b[0m"),
3631 "Expected full reset fallback: {output_str:?}"
3632 );
3633 }
3634
3635 #[test]
3638 fn emit_cell_control_char_replaced_with_fffd() {
3639 let mut presenter = test_presenter();
3640 presenter.cursor_x = Some(0);
3641 presenter.cursor_y = Some(0);
3642
3643 let cell = Cell::from_char('\x01');
3646 presenter.emit_cell(0, &cell, None, None).unwrap();
3647 let output = presenter.into_inner().unwrap();
3648 let output_str = String::from_utf8_lossy(&output);
3649
3650 assert!(
3652 output_str.contains('\u{FFFD}'),
3653 "Control char (width 0) should be replaced with U+FFFD, got: {output:?}"
3654 );
3655 assert!(
3656 !output.contains(&0x01),
3657 "Raw control char should not appear"
3658 );
3659 }
3660
3661 #[test]
3662 fn emit_content_empty_cell_emits_space() {
3663 let mut presenter = test_presenter();
3664 presenter.cursor_x = Some(0);
3665 presenter.cursor_y = Some(0);
3666
3667 let cell = Cell::default();
3668 assert!(cell.is_empty());
3669 presenter.emit_cell(0, &cell, None, None).unwrap();
3670 let output = presenter.into_inner().unwrap();
3671 assert!(output.contains(&b' '), "Empty cell should emit space");
3672 }
3673
3674 #[test]
3677 fn continuation_cell_cursor_x_none() {
3678 let mut presenter = test_presenter();
3679 presenter.cursor_x = None;
3681 presenter.cursor_y = Some(0);
3682
3683 let cell = Cell::CONTINUATION;
3684 presenter.emit_cell(5, &cell, None, None).unwrap();
3685 let output = presenter.into_inner().unwrap();
3686
3687 assert!(
3689 output.windows(3).any(|w| w == b"\x1b[C"),
3690 "Should emit CUF(1) for continuation with unknown cursor_x"
3691 );
3692 }
3693
3694 #[test]
3695 fn continuation_cell_cursor_already_past() {
3696 let mut presenter = test_presenter();
3697 presenter.cursor_x = Some(10);
3699 presenter.cursor_y = Some(0);
3700
3701 let cell = Cell::CONTINUATION;
3702 presenter.emit_cell(5, &cell, None, None).unwrap();
3703 let output = presenter.into_inner().unwrap();
3704
3705 assert!(
3707 output.is_empty(),
3708 "Should skip continuation when cursor is past it"
3709 );
3710 }
3711
3712 #[test]
3715 fn clear_line_positions_cursor_and_erases() {
3716 let mut presenter = test_presenter();
3717 presenter.clear_line(5).unwrap();
3718 let output = get_output(presenter);
3719 let output_str = String::from_utf8_lossy(&output);
3720
3721 assert!(
3723 output_str.contains("\x1b[2K"),
3724 "Should contain erase line sequence"
3725 );
3726 }
3727
3728 #[test]
3731 fn into_inner_returns_accumulated_output() {
3732 let mut presenter = test_presenter();
3733 presenter.position_cursor(0, 0).unwrap();
3734 let inner = presenter.into_inner().unwrap();
3735 assert!(!inner.is_empty(), "into_inner should return buffered data");
3736 }
3737
3738 #[test]
3741 fn move_cursor_optimal_same_row_forward_large() {
3742 let mut presenter = test_presenter();
3743 presenter.cursor_x = Some(0);
3744 presenter.cursor_y = Some(0);
3745
3746 presenter.move_cursor_optimal(100, 0).unwrap();
3748 let output = presenter.into_inner().unwrap();
3749
3750 let cuf = cost_model::cuf_cost(100);
3752 let cha = cost_model::cha_cost(100);
3753 let cup = cost_model::cup_cost(0, 100);
3754 let cheapest = cuf.min(cha).min(cup);
3755 assert_eq!(output.len(), cheapest, "Should pick cheapest cursor move");
3756 }
3757
3758 #[test]
3759 fn move_cursor_optimal_same_row_backward_to_zero() {
3760 let mut presenter = test_presenter();
3761 presenter.cursor_x = Some(50);
3762 presenter.cursor_y = Some(0);
3763
3764 presenter.move_cursor_optimal(0, 0).unwrap();
3765 let output = presenter.into_inner().unwrap();
3766
3767 let mut expected = Vec::new();
3770 ansi::cha(&mut expected, 0).unwrap();
3771 assert_eq!(output, expected, "Should use CHA for backward to col 0");
3772 }
3773
3774 #[test]
3775 fn move_cursor_optimal_unknown_cursor_uses_cup() {
3776 let mut presenter = test_presenter();
3777 presenter.move_cursor_optimal(10, 5).unwrap();
3779 let output = presenter.into_inner().unwrap();
3780 let mut expected = Vec::new();
3781 ansi::cup(&mut expected, 5, 10).unwrap();
3782 assert_eq!(output, expected, "Should use CUP when cursor is unknown");
3783 }
3784
3785 #[test]
3788 fn sync_wrap_order_begin_content_reset_end() {
3789 let mut presenter = test_presenter_with_sync();
3790 let mut buffer = Buffer::new(3, 1);
3791 buffer.set_raw(0, 0, Cell::from_char('Z'));
3792
3793 let old = Buffer::new(3, 1);
3794 let diff = BufferDiff::compute(&old, &buffer);
3795
3796 presenter.present(&buffer, &diff).unwrap();
3797 let output = get_output(presenter);
3798
3799 let sync_begin_pos = output
3800 .windows(ansi::SYNC_BEGIN.len())
3801 .position(|w| w == ansi::SYNC_BEGIN)
3802 .expect("sync begin missing");
3803 let z_pos = output
3804 .iter()
3805 .position(|&b| b == b'Z')
3806 .expect("character Z missing");
3807 let reset_pos = output
3808 .windows(b"\x1b[0m".len())
3809 .rposition(|w| w == b"\x1b[0m")
3810 .expect("SGR reset missing");
3811 let sync_end_pos = output
3812 .windows(ansi::SYNC_END.len())
3813 .rposition(|w| w == ansi::SYNC_END)
3814 .expect("sync end missing");
3815
3816 assert!(sync_begin_pos < z_pos, "sync begin before content");
3817 assert!(z_pos < reset_pos, "content before reset");
3818 assert!(reset_pos < sync_end_pos, "reset before sync end");
3819 }
3820
3821 #[test]
3824 fn style_none_after_each_frame() {
3825 let mut presenter = test_presenter();
3826 let fg = PackedRgba::rgb(255, 128, 64);
3827
3828 for _ in 0..5 {
3829 let mut buffer = Buffer::new(3, 1);
3830 buffer.set_raw(0, 0, Cell::from_char('X').with_fg(fg));
3831 let old = Buffer::new(3, 1);
3832 let diff = BufferDiff::compute(&old, &buffer);
3833 presenter.present(&buffer, &diff).unwrap();
3834
3835 assert!(
3837 presenter.current_style.is_none(),
3838 "Style should be None after frame end"
3839 );
3840 assert!(
3841 presenter.current_link.is_none(),
3842 "Link should be None after frame end"
3843 );
3844 }
3845 }
3846
3847 #[test]
3850 fn link_closed_at_frame_end_even_if_all_cells_linked() {
3851 let mut presenter = test_presenter();
3852 let mut buffer = Buffer::new(3, 1);
3853 let mut links = LinkRegistry::new();
3854 let link_id = links.register("https://all-linked.test");
3855
3856 for x in 0..3 {
3858 buffer.set_raw(
3859 x,
3860 0,
3861 Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
3862 );
3863 }
3864
3865 let old = Buffer::new(3, 1);
3866 let diff = BufferDiff::compute(&old, &buffer);
3867 presenter
3868 .present_with_pool(&buffer, &diff, None, Some(&links))
3869 .unwrap();
3870
3871 assert!(
3873 presenter.current_link.is_none(),
3874 "Link must be closed at frame end"
3875 );
3876 }
3877
3878 #[test]
3881 fn present_stats_empty_diff() {
3882 let mut presenter = test_presenter();
3883 let buffer = Buffer::new(10, 10);
3884 let diff = BufferDiff::new();
3885 let stats = presenter.present(&buffer, &diff).unwrap();
3886
3887 assert_eq!(stats.cells_changed, 0);
3888 assert_eq!(stats.run_count, 0);
3889 assert!(stats.bytes_emitted > 0);
3891 }
3892
3893 #[test]
3894 fn present_stats_full_row() {
3895 let mut presenter = test_presenter();
3896 let mut buffer = Buffer::new(10, 1);
3897 for x in 0..10 {
3898 buffer.set_raw(x, 0, Cell::from_char('A'));
3899 }
3900 let old = Buffer::new(10, 1);
3901 let diff = BufferDiff::compute(&old, &buffer);
3902 let stats = presenter.present(&buffer, &diff).unwrap();
3903
3904 assert_eq!(stats.cells_changed, 10);
3905 assert!(stats.run_count >= 1);
3906 assert!(stats.bytes_emitted > 10, "Should include ANSI overhead");
3907 }
3908
3909 #[test]
3912 fn capabilities_accessor() {
3913 let mut caps = TerminalCapabilities::basic();
3914 caps.sync_output = true;
3915 let presenter = Presenter::new(Vec::<u8>::new(), caps);
3916 assert!(presenter.capabilities().sync_output);
3917 }
3918
3919 #[test]
3922 fn flush_succeeds_on_empty_presenter() {
3923 let mut presenter = test_presenter();
3924 presenter.flush().unwrap();
3925 let output = get_output(presenter);
3926 assert!(output.is_empty());
3927 }
3928
3929 #[test]
3932 fn row_plan_total_cost_matches_dp() {
3933 let runs = [ChangeRun::new(3, 5, 10), ChangeRun::new(3, 15, 20)];
3934 let plan = cost_model::plan_row(&runs, None, None);
3935 assert!(plan.total_cost() > 0);
3936 }
3939
3940 #[test]
3943 fn sgr_delta_hot_path_only_fg_change() {
3944 let mut presenter = test_presenter();
3945 let old = CellStyle {
3946 fg: PackedRgba::rgb(255, 0, 0),
3947 bg: PackedRgba::rgb(0, 0, 0),
3948 attrs: StyleFlags::BOLD | StyleFlags::ITALIC,
3949 };
3950 let new = CellStyle {
3951 fg: PackedRgba::rgb(0, 255, 0),
3952 bg: PackedRgba::rgb(0, 0, 0),
3953 attrs: StyleFlags::BOLD | StyleFlags::ITALIC, };
3955
3956 presenter.current_style = Some(old);
3957 presenter.emit_style_delta(old, new).unwrap();
3958 let output = presenter.into_inner().unwrap();
3959 let output_str = String::from_utf8_lossy(&output);
3960
3961 assert!(output_str.contains("38;2;0;255;0"), "Should emit new fg");
3963 assert!(
3964 !output_str.contains("\x1b[0m"),
3965 "No reset needed for color-only change"
3966 );
3967 assert!(
3969 !output_str.contains("\x1b[1m"),
3970 "Bold should not be re-emitted"
3971 );
3972 }
3973
3974 #[test]
3975 fn sgr_delta_hot_path_both_colors_change() {
3976 let mut presenter = test_presenter();
3977 let old = CellStyle {
3978 fg: PackedRgba::rgb(1, 2, 3),
3979 bg: PackedRgba::rgb(4, 5, 6),
3980 attrs: StyleFlags::UNDERLINE,
3981 };
3982 let new = CellStyle {
3983 fg: PackedRgba::rgb(7, 8, 9),
3984 bg: PackedRgba::rgb(10, 11, 12),
3985 attrs: StyleFlags::UNDERLINE, };
3987
3988 presenter.current_style = Some(old);
3989 presenter.emit_style_delta(old, new).unwrap();
3990 let output = presenter.into_inner().unwrap();
3991 let output_str = String::from_utf8_lossy(&output);
3992
3993 assert!(output_str.contains("38;2;7;8;9"), "Should emit new fg");
3994 assert!(output_str.contains("48;2;10;11;12"), "Should emit new bg");
3995 assert!(!output_str.contains("\x1b[0m"), "No reset for color-only");
3996 }
3997
3998 #[test]
4001 fn emit_style_full_default_is_just_reset() {
4002 let mut presenter = test_presenter();
4003 let default_style = CellStyle::default();
4004 presenter.emit_style_full(default_style).unwrap();
4005 let output = presenter.into_inner().unwrap();
4006
4007 assert_eq!(output, b"\x1b[0m");
4009 }
4010
4011 #[test]
4012 fn emit_style_full_with_all_properties() {
4013 let mut presenter = test_presenter();
4014 let style = CellStyle {
4015 fg: PackedRgba::rgb(10, 20, 30),
4016 bg: PackedRgba::rgb(40, 50, 60),
4017 attrs: StyleFlags::BOLD | StyleFlags::ITALIC,
4018 };
4019 presenter.emit_style_full(style).unwrap();
4020 let output = presenter.into_inner().unwrap();
4021 let output_str = String::from_utf8_lossy(&output);
4022
4023 assert!(output_str.contains("\x1b[0m"), "Should start with reset");
4025 assert!(output_str.contains("38;2;10;20;30"), "Should have fg");
4026 assert!(output_str.contains("48;2;40;50;60"), "Should have bg");
4027 }
4028
4029 #[test]
4032 fn present_multiple_rows_different_strategies() {
4033 let mut presenter = test_presenter();
4034 let mut buffer = Buffer::new(80, 5);
4035
4036 for x in (0..20).step_by(2) {
4038 buffer.set_raw(x, 0, Cell::from_char('D'));
4039 }
4040 buffer.set_raw(0, 2, Cell::from_char('L'));
4042 buffer.set_raw(79, 2, Cell::from_char('R'));
4043 buffer.set_raw(40, 4, Cell::from_char('M'));
4045
4046 let old = Buffer::new(80, 5);
4047 let diff = BufferDiff::compute(&old, &buffer);
4048 presenter.present(&buffer, &diff).unwrap();
4049 let output = get_output(presenter);
4050 let output_str = String::from_utf8_lossy(&output);
4051
4052 assert!(output_str.contains('D'));
4053 assert!(output_str.contains('L'));
4054 assert!(output_str.contains('R'));
4055 assert!(output_str.contains('M'));
4056 }
4057
4058 #[test]
4059 fn zero_width_chars_replaced_with_placeholder() {
4060 let mut presenter = test_presenter();
4061 let mut buffer = Buffer::new(5, 1);
4062
4063 let zw_char = '\u{0301}';
4067
4068 assert_eq!(Cell::from_char(zw_char).content.width(), 0);
4070
4071 buffer.set_raw(0, 0, Cell::from_char(zw_char));
4072 buffer.set_raw(1, 0, Cell::from_char('A'));
4073
4074 let old = Buffer::new(5, 1);
4075 let diff = BufferDiff::compute(&old, &buffer);
4076
4077 presenter.present(&buffer, &diff).unwrap();
4078 let output = get_output(presenter);
4079 let output_str = String::from_utf8_lossy(&output);
4080
4081 assert!(
4083 output_str.contains("\u{FFFD}"),
4084 "Expected replacement character for zero-width content, got: {:?}",
4085 output_str
4086 );
4087
4088 assert!(
4090 !output_str.contains(zw_char),
4091 "Should not contain raw zero-width char"
4092 );
4093
4094 assert!(
4096 output_str.contains('A'),
4097 "Should contain subsequent character 'A'"
4098 );
4099 }
4100}
4101
4102#[cfg(test)]
4103mod proptests {
4104 use super::*;
4105 use crate::cell::{Cell, PackedRgba};
4106 use crate::diff::BufferDiff;
4107 use crate::terminal_model::TerminalModel;
4108 use proptest::prelude::*;
4109
4110 fn test_presenter() -> Presenter<Vec<u8>> {
4112 let caps = TerminalCapabilities::basic();
4113 Presenter::new(Vec::new(), caps)
4114 }
4115
4116 proptest! {
4117 #[test]
4120 fn presenter_roundtrip_characters(
4121 width in 5u16..40,
4122 height in 3u16..20,
4123 num_chars in 1usize..50, ) {
4125 let mut buffer = Buffer::new(width, height);
4126 let mut changed_positions = std::collections::HashSet::new();
4127
4128 for i in 0..num_chars {
4130 let x = (i * 7 + 3) as u16 % width;
4131 let y = (i * 11 + 5) as u16 % height;
4132 let ch = char::from_u32(('A' as u32) + (i as u32 % 26)).unwrap();
4133 buffer.set_raw(x, y, Cell::from_char(ch));
4134 changed_positions.insert((x, y));
4135 }
4136
4137 let mut presenter = test_presenter();
4139 let old = Buffer::new(width, height);
4140 let diff = BufferDiff::compute(&old, &buffer);
4141 presenter.present(&buffer, &diff).unwrap();
4142 let output = presenter.into_inner().unwrap();
4143
4144 let mut model = TerminalModel::new(width as usize, height as usize);
4146 model.process(&output);
4147
4148 for &(x, y) in &changed_positions {
4150 let buf_cell = buffer.get_unchecked(x, y);
4151 let expected_ch = buf_cell.content.as_char().unwrap_or(' ');
4152 let mut expected_buf = [0u8; 4];
4153 let expected_str = expected_ch.encode_utf8(&mut expected_buf);
4154
4155 if let Some(model_cell) = model.cell(x as usize, y as usize) {
4156 prop_assert_eq!(
4157 model_cell.text.as_str(),
4158 expected_str,
4159 "Character mismatch at ({}, {})", x, y
4160 );
4161 }
4162 }
4163 }
4164
4165 #[test]
4167 fn style_reset_after_present(
4168 width in 5u16..30,
4169 height in 3u16..15,
4170 num_styled in 1usize..20,
4171 ) {
4172 let mut buffer = Buffer::new(width, height);
4173
4174 for i in 0..num_styled {
4176 let x = (i * 7) as u16 % width;
4177 let y = (i * 11) as u16 % height;
4178 let fg = PackedRgba::rgb(
4179 ((i * 31) % 256) as u8,
4180 ((i * 47) % 256) as u8,
4181 ((i * 71) % 256) as u8,
4182 );
4183 buffer.set_raw(x, y, Cell::from_char('X').with_fg(fg));
4184 }
4185
4186 let mut presenter = test_presenter();
4188 let old = Buffer::new(width, height);
4189 let diff = BufferDiff::compute(&old, &buffer);
4190 presenter.present(&buffer, &diff).unwrap();
4191 let output = presenter.into_inner().unwrap();
4192 let output_str = String::from_utf8_lossy(&output);
4193
4194 prop_assert!(
4196 output_str.contains("\x1b[0m"),
4197 "Output should contain SGR reset"
4198 );
4199 }
4200
4201 #[test]
4203 fn empty_diff_minimal_output(
4204 width in 5u16..50,
4205 height in 3u16..25,
4206 ) {
4207 let buffer = Buffer::new(width, height);
4208 let diff = BufferDiff::new(); let mut presenter = test_presenter();
4211 presenter.present(&buffer, &diff).unwrap();
4212 let output = presenter.into_inner().unwrap();
4213
4214 prop_assert!(output.len() < 50, "Empty diff should have minimal output");
4217 }
4218
4219 #[test]
4224 fn diff_size_bounds(
4225 width in 5u16..30,
4226 height in 3u16..15,
4227 ) {
4228 let old = Buffer::new(width, height);
4230 let mut new = Buffer::new(width, height);
4231
4232 for y in 0..height {
4233 for x in 0..width {
4234 new.set_raw(x, y, Cell::from_char('X'));
4235 }
4236 }
4237
4238 let diff = BufferDiff::compute(&old, &new);
4239
4240 prop_assert_eq!(
4242 diff.len(),
4243 (width as usize) * (height as usize),
4244 "Full change should have all cells in diff"
4245 );
4246 }
4247
4248 #[test]
4250 fn presenter_cursor_consistency(
4251 width in 10u16..40,
4252 height in 5u16..20,
4253 num_runs in 1usize..10,
4254 ) {
4255 let mut buffer = Buffer::new(width, height);
4256
4257 for i in 0..num_runs {
4259 let start_x = (i * 5) as u16 % (width - 5);
4260 let y = i as u16 % height;
4261 for x in start_x..(start_x + 3) {
4262 buffer.set_raw(x, y, Cell::from_char('A'));
4263 }
4264 }
4265
4266 let mut presenter = test_presenter();
4268 let old = Buffer::new(width, height);
4269
4270 for _ in 0..3 {
4271 let diff = BufferDiff::compute(&old, &buffer);
4272 presenter.present(&buffer, &diff).unwrap();
4273 }
4274
4275 let output = presenter.into_inner().unwrap();
4277 prop_assert!(!output.is_empty(), "Should produce some output");
4278 }
4279
4280 #[test]
4284 fn sgr_delta_transition_equivalence(
4285 width in 5u16..20,
4286 height in 3u16..10,
4287 num_styled in 2usize..15,
4288 ) {
4289 let mut buffer = Buffer::new(width, height);
4290 let mut expected: std::collections::HashMap<(u16, u16), char> =
4292 std::collections::HashMap::new();
4293
4294 for i in 0..num_styled {
4296 let x = (i * 3 + 1) as u16 % width;
4297 let y = (i * 5 + 2) as u16 % height;
4298 let ch = char::from_u32(('A' as u32) + (i as u32 % 26)).unwrap();
4299 let fg = PackedRgba::rgb(
4300 ((i * 73) % 256) as u8,
4301 ((i * 137) % 256) as u8,
4302 ((i * 41) % 256) as u8,
4303 );
4304 let bg = if i % 3 == 0 {
4305 PackedRgba::rgb(
4306 ((i * 29) % 256) as u8,
4307 ((i * 53) % 256) as u8,
4308 ((i * 97) % 256) as u8,
4309 )
4310 } else {
4311 PackedRgba::TRANSPARENT
4312 };
4313 let flags_bits = ((i * 37) % 256) as u8;
4314 let flags = StyleFlags::from_bits_truncate(flags_bits);
4315 let cell = Cell::from_char(ch)
4316 .with_fg(fg)
4317 .with_bg(bg)
4318 .with_attrs(CellAttrs::new(flags, 0));
4319 buffer.set_raw(x, y, cell);
4320 expected.insert((x, y), ch);
4321 }
4322
4323 let mut presenter = test_presenter();
4325 let old = Buffer::new(width, height);
4326 let diff = BufferDiff::compute(&old, &buffer);
4327 presenter.present(&buffer, &diff).unwrap();
4328 let output = presenter.into_inner().unwrap();
4329
4330 let mut model = TerminalModel::new(width as usize, height as usize);
4332 model.process(&output);
4333
4334 for (&(x, y), &ch) in &expected {
4335 let mut buf = [0u8; 4];
4336 let expected_str = ch.encode_utf8(&mut buf);
4337
4338 if let Some(model_cell) = model.cell(x as usize, y as usize) {
4339 prop_assert_eq!(
4340 model_cell.text.as_str(),
4341 expected_str,
4342 "Character mismatch at ({}, {}) with delta engine", x, y
4343 );
4344 }
4345 }
4346 }
4347
4348 #[test]
4352 fn dp_emit_equivalence(
4353 width in 20u16..60,
4354 height in 5u16..15,
4355 num_changes in 5usize..30,
4356 ) {
4357 let mut buffer = Buffer::new(width, height);
4358 let mut expected: std::collections::HashMap<(u16, u16), char> =
4359 std::collections::HashMap::new();
4360
4361 for i in 0..num_changes {
4363 let x = (i * 7 + 3) as u16 % width;
4364 let y = (i * 3 + 1) as u16 % height;
4365 let ch = char::from_u32(('A' as u32) + (i as u32 % 26)).unwrap();
4366 buffer.set_raw(x, y, Cell::from_char(ch));
4367 expected.insert((x, y), ch);
4368 }
4369
4370 let mut presenter = test_presenter();
4372 let old = Buffer::new(width, height);
4373 let diff = BufferDiff::compute(&old, &buffer);
4374 presenter.present(&buffer, &diff).unwrap();
4375 let output = presenter.into_inner().unwrap();
4376
4377 let mut model = TerminalModel::new(width as usize, height as usize);
4379 model.process(&output);
4380
4381 for (&(x, y), &ch) in &expected {
4382 let mut buf = [0u8; 4];
4383 let expected_str = ch.encode_utf8(&mut buf);
4384
4385 if let Some(model_cell) = model.cell(x as usize, y as usize) {
4386 prop_assert_eq!(
4387 model_cell.text.as_str(),
4388 expected_str,
4389 "DP cost model: character mismatch at ({}, {})", x, y
4390 );
4391 }
4392 }
4393 }
4394 }
4395}