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;
44use crate::sanitize::sanitize;
45
46pub use ftui_core::terminal_capabilities::TerminalCapabilities;
47
48const BUFFER_CAPACITY: usize = 64 * 1024;
50const MAX_SAFE_HYPERLINK_URL_BYTES: usize = 4096;
52
53#[inline]
54fn is_safe_hyperlink_url(url: &str) -> bool {
55 url.len() <= MAX_SAFE_HYPERLINK_URL_BYTES && !url.chars().any(char::is_control)
56}
57
58mod cost_model {
69 use smallvec::SmallVec;
70
71 use super::ChangeRun;
72
73 #[inline]
75 fn digit_count(n: u16) -> usize {
76 if n >= 10000 {
77 5
78 } else if n >= 1000 {
79 4
80 } else if n >= 100 {
81 3
82 } else if n >= 10 {
83 2
84 } else {
85 1
86 }
87 }
88
89 #[inline]
91 pub fn cup_cost(row: u16, col: u16) -> usize {
92 4 + digit_count(row.saturating_add(1)) + digit_count(col.saturating_add(1))
94 }
95
96 #[inline]
98 pub fn cha_cost(col: u16) -> usize {
99 3 + digit_count(col.saturating_add(1))
101 }
102
103 #[inline]
105 pub fn cuf_cost(n: u16) -> usize {
106 match n {
107 0 => 0,
108 1 => 3, _ => 3 + digit_count(n),
110 }
111 }
112
113 pub fn cheapest_move_cost(
116 from_x: Option<u16>,
117 from_y: Option<u16>,
118 to_x: u16,
119 to_y: u16,
120 ) -> usize {
121 if from_x == Some(to_x) && from_y == Some(to_y) {
123 return 0;
124 }
125
126 let cup = cup_cost(to_y, to_x);
127
128 match (from_x, from_y) {
129 (Some(fx), Some(fy)) if fy == to_y => {
130 let cha = cha_cost(to_x);
132 if to_x > fx {
133 let cuf = cuf_cost(to_x - fx);
134 cup.min(cha).min(cuf)
135 } else if to_x == fx {
136 0
137 } else {
138 cup.min(cha)
140 }
141 }
142 _ => cup,
143 }
144 }
145
146 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
148 pub struct RowSpan {
149 pub y: u16,
151 pub x0: u16,
153 pub x1: u16,
155 }
156
157 #[derive(Debug, Clone, PartialEq, Eq)]
162 pub struct RowPlan {
163 spans: SmallVec<[RowSpan; 4]>,
164 total_cost: usize,
165 }
166
167 impl RowPlan {
168 #[inline]
169 #[must_use]
170 pub fn spans(&self) -> &[RowSpan] {
171 &self.spans
172 }
173
174 #[inline]
176 #[allow(dead_code)] pub fn total_cost(&self) -> usize {
178 self.total_cost
179 }
180 }
181
182 #[derive(Debug, Default)]
187 pub struct RowPlanScratch {
188 prefix_cells: Vec<usize>,
189 dp: Vec<usize>,
190 prev: Vec<usize>,
191 }
192
193 #[allow(dead_code)]
202 pub fn plan_row(row_runs: &[ChangeRun], prev_x: Option<u16>, prev_y: Option<u16>) -> RowPlan {
203 let mut scratch = RowPlanScratch::default();
204 plan_row_reuse(row_runs, prev_x, prev_y, &mut scratch)
205 }
206
207 pub fn plan_row_reuse(
210 row_runs: &[ChangeRun],
211 prev_x: Option<u16>,
212 prev_y: Option<u16>,
213 scratch: &mut RowPlanScratch,
214 ) -> RowPlan {
215 debug_assert!(!row_runs.is_empty());
216
217 let row_y = row_runs[0].y;
218 let run_count = row_runs.len();
219
220 scratch.prefix_cells.clear();
222 scratch.prefix_cells.resize(run_count + 1, 0);
223 scratch.dp.clear();
224 scratch.dp.resize(run_count, usize::MAX);
225 scratch.prev.clear();
226 scratch.prev.resize(run_count, 0);
227
228 for (i, run) in row_runs.iter().enumerate() {
230 scratch.prefix_cells[i + 1] = scratch.prefix_cells[i] + run.len() as usize;
231 }
232
233 for j in 0..run_count {
235 let mut best_cost = usize::MAX;
236 let mut best_i = j;
237
238 for i in (0..=j).rev() {
243 let changed_cells = scratch.prefix_cells[j + 1] - scratch.prefix_cells[i];
244 let total_cells = (row_runs[j].x1 - row_runs[i].x0 + 1) as usize;
245 let gap_cells = total_cells - changed_cells;
246
247 if gap_cells > 32 {
248 break;
249 }
250
251 let from_x = if i == 0 {
252 prev_x
253 } else {
254 Some(row_runs[i - 1].x1.saturating_add(1))
255 };
256 let from_y = if i == 0 { prev_y } else { Some(row_y) };
257
258 let move_cost = cheapest_move_cost(from_x, from_y, row_runs[i].x0, row_y);
259 let gap_overhead = gap_cells * 2; let emit_cost = changed_cells + gap_overhead;
261
262 let prev_cost = if i == 0 { 0 } else { scratch.dp[i - 1] };
263 let cost = prev_cost
264 .saturating_add(move_cost)
265 .saturating_add(emit_cost);
266
267 if cost < best_cost {
268 best_cost = cost;
269 best_i = i;
270 }
271 }
272
273 scratch.dp[j] = best_cost;
274 scratch.prev[j] = best_i;
275 }
276
277 let mut spans: SmallVec<[RowSpan; 4]> = SmallVec::new();
279 let mut j = run_count - 1;
280 loop {
281 let i = scratch.prev[j];
282 spans.push(RowSpan {
283 y: row_y,
284 x0: row_runs[i].x0,
285 x1: row_runs[j].x1,
286 });
287 if i == 0 {
288 break;
289 }
290 j = i - 1;
291 }
292 spans.reverse();
293
294 RowPlan {
295 spans,
296 total_cost: scratch.dp[run_count - 1],
297 }
298 }
299}
300
301#[derive(Debug, Clone, Copy, PartialEq, Eq)]
303struct CellStyle {
304 fg: PackedRgba,
305 bg: PackedRgba,
306 attrs: StyleFlags,
307}
308
309impl Default for CellStyle {
310 fn default() -> Self {
311 Self {
312 fg: PackedRgba::TRANSPARENT,
313 bg: PackedRgba::TRANSPARENT,
314 attrs: StyleFlags::empty(),
315 }
316 }
317}
318impl CellStyle {
319 fn from_cell(cell: &Cell) -> Self {
320 Self {
321 fg: cell.fg,
322 bg: cell.bg,
323 attrs: cell.attrs.flags(),
324 }
325 }
326}
327
328pub struct Presenter<W: Write> {
333 writer: CountingWriter<BufWriter<W>>,
335 current_style: Option<CellStyle>,
337 current_link: Option<u32>,
339 cursor_x: Option<u16>,
341 cursor_y: Option<u16>,
343 viewport_offset_y: u16,
345 capabilities: TerminalCapabilities,
347 plan_scratch: cost_model::RowPlanScratch,
350 runs_buf: Vec<ChangeRun>,
352}
353
354impl<W: Write> Presenter<W> {
355 pub fn new(writer: W, capabilities: TerminalCapabilities) -> Self {
357 Self {
358 writer: CountingWriter::new(BufWriter::with_capacity(BUFFER_CAPACITY, writer)),
359 current_style: None,
360 current_link: None,
361 cursor_x: None,
362 cursor_y: None,
363 viewport_offset_y: 0,
364 capabilities,
365 plan_scratch: cost_model::RowPlanScratch::default(),
366 runs_buf: Vec::new(),
367 }
368 }
369
370 pub fn writer_mut(&mut self) -> &mut W {
376 self.writer.inner_mut().get_mut()
377 }
378
379 pub fn counting_writer_mut(&mut self) -> &mut CountingWriter<BufWriter<W>> {
384 &mut self.writer
385 }
386
387 pub fn set_viewport_offset_y(&mut self, offset: u16) {
392 self.viewport_offset_y = offset;
393 }
394
395 #[inline]
397 pub fn capabilities(&self) -> &TerminalCapabilities {
398 &self.capabilities
399 }
400
401 pub fn present(&mut self, buffer: &Buffer, diff: &BufferDiff) -> io::Result<PresentStats> {
410 self.present_with_pool(buffer, diff, None, None)
411 }
412
413 pub fn present_with_pool(
415 &mut self,
416 buffer: &Buffer,
417 diff: &BufferDiff,
418 pool: Option<&GraphemePool>,
419 links: Option<&LinkRegistry>,
420 ) -> io::Result<PresentStats> {
421 let bracket_supported = self.capabilities.use_sync_output();
422
423 #[cfg(feature = "tracing")]
424 let _span = tracing::info_span!(
425 "present",
426 width = buffer.width(),
427 height = buffer.height(),
428 changes = diff.len()
429 );
430 #[cfg(feature = "tracing")]
431 let _guard = _span.enter();
432
433 #[cfg(feature = "tracing")]
434 let fallback_used = !bracket_supported;
435 #[cfg(feature = "tracing")]
436 let _sync_span = tracing::info_span!(
437 "render.sync_bracket",
438 bracket_supported,
439 fallback_used,
440 frame_bytes = tracing::field::Empty,
441 );
442 #[cfg(feature = "tracing")]
443 let _sync_guard = _sync_span.enter();
444
445 diff.runs_into(&mut self.runs_buf);
447 let run_count = self.runs_buf.len();
448 let cells_changed = diff.len();
449
450 self.writer.reset_counter();
452 let collector = StatsCollector::start(cells_changed, run_count);
453
454 if bracket_supported {
458 if let Err(err) = ansi::sync_begin(&mut self.writer) {
459 let _ = ansi::sync_end(&mut self.writer);
462 let _ = self.writer.flush();
463 return Err(err);
464 }
465 } else {
466 #[cfg(feature = "tracing")]
467 tracing::warn!("sync brackets unsupported; falling back to cursor-hide strategy");
468 ansi::cursor_hide(&mut self.writer)?;
469 }
470
471 let emit_result = self.emit_diff_runs(buffer, pool, links);
473
474 let reset_result = ansi::sgr_reset(&mut self.writer);
476 self.current_style = None;
477
478 let hyperlink_close_result = if self.current_link.is_some() {
479 let res = ansi::hyperlink_end(&mut self.writer);
480 if res.is_ok() {
481 self.current_link = None;
482 }
483 Some(res)
484 } else {
485 None
486 };
487
488 let bracket_end_result = if bracket_supported {
489 ansi::sync_end(&mut self.writer)
490 } else {
491 ansi::cursor_show(&mut self.writer)
492 };
493
494 let flush_result = self.writer.flush();
495
496 let cleanup_error = reset_result
500 .err()
501 .or_else(|| hyperlink_close_result.and_then(Result::err))
502 .or_else(|| bracket_end_result.err())
503 .or_else(|| flush_result.err());
504 if let Some(err) = cleanup_error {
505 return Err(err);
506 }
507 emit_result?;
508
509 let stats = collector.finish(self.writer.bytes_written());
510
511 #[cfg(feature = "tracing")]
512 {
513 _sync_span.record("frame_bytes", stats.bytes_emitted);
514 stats.log();
515 tracing::trace!("frame presented");
516 }
517
518 Ok(stats)
519 }
520
521 pub fn emit_diff_runs(
527 &mut self,
528 buffer: &Buffer,
529 pool: Option<&GraphemePool>,
530 links: Option<&LinkRegistry>,
531 ) -> io::Result<()> {
532 #[cfg(feature = "tracing")]
533 let _span = tracing::debug_span!("emit_diff");
534 #[cfg(feature = "tracing")]
535 let _guard = _span.enter();
536
537 #[cfg(feature = "tracing")]
538 tracing::trace!(run_count = self.runs_buf.len(), "emitting runs (reuse)");
539
540 let mut i = 0;
542 while i < self.runs_buf.len() {
543 let row_y = self.runs_buf[i].y;
544
545 let row_start = i;
547 while i < self.runs_buf.len() && self.runs_buf[i].y == row_y {
548 i += 1;
549 }
550 let row_runs = &self.runs_buf[row_start..i];
551
552 let plan = cost_model::plan_row_reuse(
553 row_runs,
554 self.cursor_x,
555 self.cursor_y,
556 &mut self.plan_scratch,
557 );
558
559 #[cfg(feature = "tracing")]
560 tracing::trace!(
561 row = row_y,
562 spans = plan.spans().len(),
563 cost = plan.total_cost(),
564 "row plan"
565 );
566
567 let row = buffer.row_cells(row_y);
568 for span in plan.spans() {
569 self.move_cursor_optimal(span.x0, span.y)?;
570 let start = span.x0 as usize;
572 let end = span.x1 as usize;
573 debug_assert!(start <= end);
574 debug_assert!(end < row.len());
575
576 let mut idx = start;
577 for cell in &row[start..=end] {
578 self.emit_cell(idx as u16, cell, pool, links)?;
579 idx += 1;
580 }
581 }
582 }
583 Ok(())
584 }
585
586 pub fn prepare_runs(&mut self, diff: &BufferDiff) {
590 diff.runs_into(&mut self.runs_buf);
591 }
592
593 fn emit_cell(
595 &mut self,
596 x: u16,
597 cell: &Cell,
598 pool: Option<&GraphemePool>,
599 links: Option<&LinkRegistry>,
600 ) -> io::Result<()> {
601 if cell.is_continuation() {
610 match self.cursor_x {
611 Some(cx) if cx > x => return Ok(()),
613 Some(cx) => {
615 ansi::cuf(&mut self.writer, 1)?;
616 self.cursor_x = Some(cx.saturating_add(1));
617 return Ok(());
618 }
619 None => {
621 ansi::cuf(&mut self.writer, 1)?;
622 self.cursor_x = Some(x.saturating_add(1));
623 return Ok(());
624 }
625 }
626 }
627
628 self.emit_style_changes(cell)?;
630
631 self.emit_link_changes(cell, links)?;
633
634 let raw_width = cell.content.width();
637 let is_zero_width_content = raw_width == 0 && !cell.is_empty() && !cell.is_continuation();
638
639 if is_zero_width_content {
640 self.writer.write_all(b"\xEF\xBF\xBD")?;
642 } else {
643 self.emit_content(cell, pool)?;
645 }
646
647 if let Some(cx) = self.cursor_x {
649 let width = if cell.is_empty() || is_zero_width_content {
652 1
653 } else {
654 raw_width
655 };
656 self.cursor_x = Some(cx.saturating_add(width as u16));
657 }
658
659 Ok(())
660 }
661
662 fn emit_style_changes(&mut self, cell: &Cell) -> io::Result<()> {
668 let new_style = CellStyle::from_cell(cell);
669
670 if self.current_style == Some(new_style) {
672 return Ok(());
673 }
674
675 match self.current_style {
676 None => {
677 self.emit_style_full(new_style)?;
680 }
681 Some(old_style) => {
682 self.emit_style_delta(old_style, new_style)?;
683 }
684 }
685
686 self.current_style = Some(new_style);
687 Ok(())
688 }
689
690 fn emit_style_full(&mut self, style: CellStyle) -> io::Result<()> {
692 ansi::sgr_reset(&mut self.writer)?;
693 if style.fg.a() > 0 {
694 ansi::sgr_fg_packed(&mut self.writer, style.fg)?;
695 }
696 if style.bg.a() > 0 {
697 ansi::sgr_bg_packed(&mut self.writer, style.bg)?;
698 }
699 if !style.attrs.is_empty() {
700 ansi::sgr_flags(&mut self.writer, style.attrs)?;
701 }
702 Ok(())
703 }
704
705 #[inline]
706 fn dec_len_u8(value: u8) -> u32 {
707 if value >= 100 {
708 3
709 } else if value >= 10 {
710 2
711 } else {
712 1
713 }
714 }
715
716 #[inline]
717 fn sgr_code_len(code: u8) -> u32 {
718 2 + Self::dec_len_u8(code) + 1
719 }
720
721 #[inline]
722 fn sgr_flags_len(flags: StyleFlags) -> u32 {
723 if flags.is_empty() {
724 return 0;
725 }
726 let mut count = 0u32;
727 let mut digits = 0u32;
728 for (flag, codes) in ansi::FLAG_TABLE {
729 if flags.contains(flag) {
730 count += 1;
731 digits += Self::dec_len_u8(codes.on);
732 }
733 }
734 if count == 0 {
735 return 0;
736 }
737 3 + digits + (count - 1)
738 }
739
740 #[inline]
741 fn sgr_flags_off_len(flags: StyleFlags) -> u32 {
742 if flags.is_empty() {
743 return 0;
744 }
745 let mut len = 0u32;
746 for (flag, codes) in ansi::FLAG_TABLE {
747 if flags.contains(flag) {
748 len += Self::sgr_code_len(codes.off);
749 }
750 }
751 len
752 }
753
754 #[inline]
755 fn sgr_rgb_len(color: PackedRgba) -> u32 {
756 10 + Self::dec_len_u8(color.r()) + Self::dec_len_u8(color.g()) + Self::dec_len_u8(color.b())
757 }
758
759 fn emit_style_delta(&mut self, old: CellStyle, new: CellStyle) -> io::Result<()> {
764 let attrs_removed = old.attrs & !new.attrs;
765 let attrs_added = new.attrs & !old.attrs;
766 let fg_changed = old.fg != new.fg;
767 let bg_changed = old.bg != new.bg;
768
769 if old.attrs == new.attrs {
773 if fg_changed {
774 ansi::sgr_fg_packed(&mut self.writer, new.fg)?;
775 }
776 if bg_changed {
777 ansi::sgr_bg_packed(&mut self.writer, new.bg)?;
778 }
779 return Ok(());
780 }
781
782 let mut collateral = StyleFlags::empty();
783 if attrs_removed.contains(StyleFlags::BOLD) && new.attrs.contains(StyleFlags::DIM) {
784 collateral |= StyleFlags::DIM;
785 }
786 if attrs_removed.contains(StyleFlags::DIM) && new.attrs.contains(StyleFlags::BOLD) {
787 collateral |= StyleFlags::BOLD;
788 }
789
790 let mut delta_len = 0u32;
791 delta_len += Self::sgr_flags_off_len(attrs_removed);
792 delta_len += Self::sgr_flags_len(collateral);
793 delta_len += Self::sgr_flags_len(attrs_added);
794 if fg_changed {
795 delta_len += if new.fg.a() == 0 {
796 5
797 } else {
798 Self::sgr_rgb_len(new.fg)
799 };
800 }
801 if bg_changed {
802 delta_len += if new.bg.a() == 0 {
803 5
804 } else {
805 Self::sgr_rgb_len(new.bg)
806 };
807 }
808
809 let mut baseline_len = 4u32;
810 if new.fg.a() > 0 {
811 baseline_len += Self::sgr_rgb_len(new.fg);
812 }
813 if new.bg.a() > 0 {
814 baseline_len += Self::sgr_rgb_len(new.bg);
815 }
816 baseline_len += Self::sgr_flags_len(new.attrs);
817
818 if delta_len > baseline_len {
819 return self.emit_style_full(new);
820 }
821
822 if !attrs_removed.is_empty() {
824 let collateral = ansi::sgr_flags_off(&mut self.writer, attrs_removed, new.attrs)?;
825 if !collateral.is_empty() {
827 ansi::sgr_flags(&mut self.writer, collateral)?;
828 }
829 }
830
831 if !attrs_added.is_empty() {
833 ansi::sgr_flags(&mut self.writer, attrs_added)?;
834 }
835
836 if fg_changed {
838 ansi::sgr_fg_packed(&mut self.writer, new.fg)?;
839 }
840
841 if bg_changed {
843 ansi::sgr_bg_packed(&mut self.writer, new.bg)?;
844 }
845
846 Ok(())
847 }
848
849 fn emit_link_changes(&mut self, cell: &Cell, links: Option<&LinkRegistry>) -> io::Result<()> {
851 if !self.capabilities.use_hyperlinks() {
854 if self.current_link.is_some() {
855 ansi::hyperlink_end(&mut self.writer)?;
856 }
857 self.current_link = None;
858 return Ok(());
859 }
860
861 let raw_link_id = cell.attrs.link_id();
862 let new_link = if raw_link_id == CellAttrs::LINK_ID_NONE {
863 None
864 } else {
865 Some(raw_link_id)
866 };
867
868 if self.current_link == new_link {
870 return Ok(());
871 }
872
873 if self.current_link.is_some() {
875 ansi::hyperlink_end(&mut self.writer)?;
876 }
877
878 let actually_opened = if let (Some(link_id), Some(registry)) = (new_link, links)
880 && let Some(url) = registry.get(link_id)
881 && is_safe_hyperlink_url(url)
882 {
883 ansi::hyperlink_start(&mut self.writer, url)?;
884 true
885 } else {
886 false
887 };
888
889 self.current_link = if actually_opened { new_link } else { None };
891 Ok(())
892 }
893
894 fn emit_content(&mut self, cell: &Cell, pool: Option<&GraphemePool>) -> io::Result<()> {
896 if let Some(grapheme_id) = cell.content.grapheme_id() {
898 if let Some(pool) = pool
899 && let Some(text) = pool.get(grapheme_id)
900 {
901 let safe = sanitize(text);
902 if !safe.is_empty() {
903 return self.writer.write_all(safe.as_bytes());
904 }
905 }
906 let width = cell.content.width();
909 if width > 0 {
910 for _ in 0..width {
911 self.writer.write_all(b"?")?;
912 }
913 }
914 return Ok(());
915 }
916
917 if let Some(ch) = cell.content.as_char() {
919 let safe_ch = if ch.is_control() { ' ' } else { ch };
921 let mut buf = [0u8; 4];
922 let encoded = safe_ch.encode_utf8(&mut buf);
923 self.writer.write_all(encoded.as_bytes())
924 } else {
925 self.writer.write_all(b" ")
927 }
928 }
929
930 fn move_cursor_to(&mut self, x: u16, y: u16) -> io::Result<()> {
932 if self.cursor_x == Some(x) && self.cursor_y == Some(y) {
934 return Ok(());
935 }
936
937 ansi::cup(&mut self.writer, y, x)?;
939 self.cursor_x = Some(x);
940 self.cursor_y = Some(y);
941 Ok(())
942 }
943
944 fn move_cursor_optimal(&mut self, x: u16, y: u16) -> io::Result<()> {
949 if self.cursor_x == Some(x) && self.cursor_y == Some(y) {
951 return Ok(());
952 }
953
954 let same_row = self.cursor_y == Some(y);
956 let forward = same_row && self.cursor_x.is_some_and(|cx| x > cx);
957
958 if same_row && forward {
959 let dx = x - self.cursor_x.expect("cursor_x guaranteed by forward check");
960 let cuf = cost_model::cuf_cost(dx);
961 let cha = cost_model::cha_cost(x);
962 let cup = cost_model::cup_cost(y, x);
963
964 if cuf <= cha && cuf <= cup {
965 ansi::cuf(&mut self.writer, dx)?;
966 } else if cha <= cup {
967 ansi::cha(&mut self.writer, x)?;
968 } else {
969 ansi::cup(&mut self.writer, y, x)?;
970 }
971 } else if same_row {
972 let cha = cost_model::cha_cost(x);
974 let cup = cost_model::cup_cost(y, x);
975 if cha <= cup {
976 ansi::cha(&mut self.writer, x)?;
977 } else {
978 ansi::cup(&mut self.writer, y, x)?;
979 }
980 } else {
981 ansi::cup(&mut self.writer, y, x)?;
983 }
984
985 self.cursor_x = Some(x);
986 self.cursor_y = Some(y);
987 Ok(())
988 }
989
990 pub fn clear_screen(&mut self) -> io::Result<()> {
992 ansi::erase_display(&mut self.writer, ansi::EraseDisplayMode::All)?;
993 ansi::cup(&mut self.writer, 0, 0)?;
994 self.cursor_x = Some(0);
995 self.cursor_y = Some(0);
996 self.writer.flush()
997 }
998
999 pub fn clear_line(&mut self, y: u16) -> io::Result<()> {
1001 self.move_cursor_to(0, y)?;
1002 ansi::erase_line(&mut self.writer, EraseLineMode::All)?;
1003 self.writer.flush()
1004 }
1005
1006 pub fn hide_cursor(&mut self) -> io::Result<()> {
1008 ansi::cursor_hide(&mut self.writer)?;
1009 self.writer.flush()
1010 }
1011
1012 pub fn show_cursor(&mut self) -> io::Result<()> {
1014 ansi::cursor_show(&mut self.writer)?;
1015 self.writer.flush()
1016 }
1017
1018 pub fn position_cursor(&mut self, x: u16, y: u16) -> io::Result<()> {
1020 self.move_cursor_to(x, y)?;
1021 self.writer.flush()
1022 }
1023
1024 pub fn reset(&mut self) {
1028 self.current_style = None;
1029 self.current_link = None;
1030 self.cursor_x = None;
1031 self.cursor_y = None;
1032 }
1033
1034 pub fn flush(&mut self) -> io::Result<()> {
1036 self.writer.flush()
1037 }
1038
1039 pub fn into_inner(self) -> Result<W, io::Error> {
1043 self.writer
1044 .into_inner() .into_inner() .map_err(|e| e.into_error())
1047 }
1048}
1049
1050#[cfg(test)]
1051mod tests {
1052 use super::*;
1053 use crate::cell::{CellAttrs, CellContent};
1054 use crate::link_registry::LinkRegistry;
1055
1056 fn test_presenter() -> Presenter<Vec<u8>> {
1057 let caps = TerminalCapabilities::basic();
1058 Presenter::new(Vec::new(), caps)
1059 }
1060
1061 fn test_presenter_with_sync() -> Presenter<Vec<u8>> {
1062 let mut caps = TerminalCapabilities::basic();
1063 caps.sync_output = true;
1064 Presenter::new(Vec::new(), caps)
1065 }
1066
1067 fn test_presenter_with_hyperlinks() -> Presenter<Vec<u8>> {
1068 let mut caps = TerminalCapabilities::basic();
1069 caps.osc8_hyperlinks = true;
1070 Presenter::new(Vec::new(), caps)
1071 }
1072
1073 fn get_output(presenter: Presenter<Vec<u8>>) -> Vec<u8> {
1074 presenter.into_inner().unwrap()
1075 }
1076
1077 fn legacy_plan_row(
1078 row_runs: &[ChangeRun],
1079 prev_x: Option<u16>,
1080 prev_y: Option<u16>,
1081 ) -> Vec<cost_model::RowSpan> {
1082 if row_runs.is_empty() {
1083 return Vec::new();
1084 }
1085
1086 if row_runs.len() == 1 {
1087 let run = row_runs[0];
1088 return vec![cost_model::RowSpan {
1089 y: run.y,
1090 x0: run.x0,
1091 x1: run.x1,
1092 }];
1093 }
1094
1095 let row_y = row_runs[0].y;
1096 let first_x = row_runs[0].x0;
1097 let last_x = row_runs[row_runs.len() - 1].x1;
1098
1099 let mut sparse_cost: usize = 0;
1101 let mut cursor_x = prev_x;
1102 let mut cursor_y = prev_y;
1103
1104 for run in row_runs {
1105 let move_cost = cost_model::cheapest_move_cost(cursor_x, cursor_y, run.x0, run.y);
1106 let cells = (run.x1 - run.x0 + 1) as usize;
1107 sparse_cost += move_cost + cells;
1108 cursor_x = Some(run.x1.saturating_add(1));
1109 cursor_y = Some(row_y);
1110 }
1111
1112 let merge_move = cost_model::cheapest_move_cost(prev_x, prev_y, first_x, row_y);
1114 let total_cells = (last_x - first_x + 1) as usize;
1115 let changed_cells: usize = row_runs.iter().map(|r| (r.x1 - r.x0 + 1) as usize).sum();
1116 let gap_cells = total_cells - changed_cells;
1117 let gap_overhead = gap_cells * 2;
1118 let merged_cost = merge_move + changed_cells + gap_overhead;
1119
1120 if merged_cost < sparse_cost {
1121 vec![cost_model::RowSpan {
1122 y: row_y,
1123 x0: first_x,
1124 x1: last_x,
1125 }]
1126 } else {
1127 row_runs
1128 .iter()
1129 .map(|run| cost_model::RowSpan {
1130 y: run.y,
1131 x0: run.x0,
1132 x1: run.x1,
1133 })
1134 .collect()
1135 }
1136 }
1137
1138 fn emit_spans_for_output(buffer: &Buffer, spans: &[cost_model::RowSpan]) -> Vec<u8> {
1139 let mut presenter = test_presenter();
1140
1141 for span in spans {
1142 presenter
1143 .move_cursor_optimal(span.x0, span.y)
1144 .expect("cursor move should succeed");
1145 for x in span.x0..=span.x1 {
1146 let cell = buffer.get_unchecked(x, span.y);
1147 presenter
1148 .emit_cell(x, cell, None, None)
1149 .expect("emit_cell should succeed");
1150 }
1151 }
1152
1153 presenter
1154 .writer
1155 .write_all(b"\x1b[0m")
1156 .expect("reset should succeed");
1157
1158 presenter.into_inner().expect("presenter output")
1159 }
1160
1161 #[test]
1162 fn empty_diff_produces_minimal_output() {
1163 let mut presenter = test_presenter();
1164 let buffer = Buffer::new(10, 10);
1165 let diff = BufferDiff::new();
1166
1167 presenter.present(&buffer, &diff).unwrap();
1168 let output = get_output(presenter);
1169
1170 assert!(output.starts_with(ansi::CURSOR_HIDE));
1172 assert!(output.ends_with(ansi::CURSOR_SHOW));
1173 assert!(
1175 output.windows(b"\x1b[0m".len()).any(|w| w == b"\x1b[0m"),
1176 "SGR reset should be present"
1177 );
1178 }
1179
1180 #[test]
1181 fn sync_output_wraps_frame() {
1182 let mut presenter = test_presenter_with_sync();
1183 let mut buffer = Buffer::new(3, 1);
1184 buffer.set_raw(0, 0, Cell::from_char('X'));
1185
1186 let old = Buffer::new(3, 1);
1187 let diff = BufferDiff::compute(&old, &buffer);
1188
1189 presenter.present(&buffer, &diff).unwrap();
1190 let output = get_output(presenter);
1191
1192 assert!(
1193 output.starts_with(ansi::SYNC_BEGIN),
1194 "sync output should begin with DEC 2026 begin"
1195 );
1196 assert!(
1197 output.ends_with(ansi::SYNC_END),
1198 "sync output should end with DEC 2026 end"
1199 );
1200 }
1201
1202 #[test]
1203 fn sync_output_obeys_mux_policy() {
1204 let caps = TerminalCapabilities::builder()
1205 .sync_output(true)
1206 .in_tmux(true)
1207 .build();
1208 let mut presenter = Presenter::new(Vec::new(), caps);
1209
1210 let mut buffer = Buffer::new(2, 1);
1211 buffer.set_raw(0, 0, Cell::from_char('X'));
1212 let old = Buffer::new(2, 1);
1213 let diff = BufferDiff::compute(&old, &buffer);
1214
1215 presenter.present(&buffer, &diff).unwrap();
1216 let output = get_output(presenter);
1217
1218 assert!(
1219 !output
1220 .windows(ansi::SYNC_BEGIN.len())
1221 .any(|w| w == ansi::SYNC_BEGIN),
1222 "tmux policy should suppress sync begin"
1223 );
1224 assert!(
1225 !output
1226 .windows(ansi::SYNC_END.len())
1227 .any(|w| w == ansi::SYNC_END),
1228 "tmux policy should suppress sync end"
1229 );
1230 }
1231
1232 #[test]
1233 fn hyperlink_sequences_emitted_and_closed() {
1234 let mut presenter = test_presenter_with_hyperlinks();
1235 let mut buffer = Buffer::new(3, 1);
1236
1237 let mut registry = LinkRegistry::new();
1238 let link_id = registry.register("https://example.com");
1239 let linked = Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id));
1240 buffer.set_raw(0, 0, linked);
1241
1242 let old = Buffer::new(3, 1);
1243 let diff = BufferDiff::compute(&old, &buffer);
1244
1245 presenter
1246 .present_with_pool(&buffer, &diff, None, Some(®istry))
1247 .unwrap();
1248 let output = get_output(presenter);
1249
1250 let start = b"\x1b]8;;https://example.com\x1b\\";
1251 let end = b"\x1b]8;;\x1b\\";
1252
1253 let start_pos = output
1254 .windows(start.len())
1255 .position(|w| w == start)
1256 .expect("hyperlink start not found");
1257 let end_pos = output
1258 .windows(end.len())
1259 .position(|w| w == end)
1260 .expect("hyperlink end not found");
1261 let char_pos = output
1262 .iter()
1263 .position(|&b| b == b'L')
1264 .expect("linked character not found");
1265
1266 assert!(start_pos < char_pos, "link start should precede text");
1267 assert!(char_pos < end_pos, "link end should follow text");
1268 }
1269
1270 #[test]
1271 fn single_cell_change() {
1272 let mut presenter = test_presenter();
1273 let mut buffer = Buffer::new(10, 10);
1274 buffer.set_raw(5, 5, Cell::from_char('X'));
1275
1276 let old = Buffer::new(10, 10);
1277 let diff = BufferDiff::compute(&old, &buffer);
1278
1279 presenter.present(&buffer, &diff).unwrap();
1280 let output = get_output(presenter);
1281
1282 let output_str = String::from_utf8_lossy(&output);
1284 assert!(output_str.contains("X"));
1285 assert!(output_str.contains("\x1b[")); }
1287
1288 #[test]
1289 fn style_tracking_avoids_redundant_sgr() {
1290 let mut presenter = test_presenter();
1291 let mut buffer = Buffer::new(10, 1);
1292
1293 let fg = PackedRgba::rgb(255, 0, 0);
1295 buffer.set_raw(0, 0, Cell::from_char('A').with_fg(fg));
1296 buffer.set_raw(1, 0, Cell::from_char('B').with_fg(fg));
1297 buffer.set_raw(2, 0, Cell::from_char('C').with_fg(fg));
1298
1299 let old = Buffer::new(10, 1);
1300 let diff = BufferDiff::compute(&old, &buffer);
1301
1302 presenter.present(&buffer, &diff).unwrap();
1303 let output = get_output(presenter);
1304
1305 let output_str = String::from_utf8_lossy(&output);
1307 let sgr_count = output_str.matches("\x1b[38;2").count();
1308 assert_eq!(
1310 sgr_count, 1,
1311 "Expected 1 SGR fg sequence, got {}",
1312 sgr_count
1313 );
1314 }
1315
1316 #[test]
1317 fn reset_reapplies_style_after_clear() {
1318 let mut presenter = test_presenter();
1319 let mut buffer = Buffer::new(1, 1);
1320 let styled = Cell::from_char('A').with_fg(PackedRgba::rgb(10, 20, 30));
1321 buffer.set_raw(0, 0, styled);
1322
1323 let old = Buffer::new(1, 1);
1324 let diff = BufferDiff::compute(&old, &buffer);
1325
1326 presenter.present(&buffer, &diff).unwrap();
1327 presenter.reset();
1328 presenter.present(&buffer, &diff).unwrap();
1329
1330 let output = get_output(presenter);
1331 let output_str = String::from_utf8_lossy(&output);
1332 let sgr_count = output_str.matches("\x1b[38;2").count();
1333
1334 assert_eq!(
1335 sgr_count, 2,
1336 "Expected style to be re-applied after reset, got {sgr_count} sequences"
1337 );
1338 }
1339
1340 #[test]
1341 fn cursor_position_optimized() {
1342 let mut presenter = test_presenter();
1343 let mut buffer = Buffer::new(10, 5);
1344
1345 buffer.set_raw(3, 2, Cell::from_char('A'));
1347 buffer.set_raw(4, 2, Cell::from_char('B'));
1348 buffer.set_raw(5, 2, Cell::from_char('C'));
1349
1350 let old = Buffer::new(10, 5);
1351 let diff = BufferDiff::compute(&old, &buffer);
1352
1353 presenter.present(&buffer, &diff).unwrap();
1354 let output = get_output(presenter);
1355
1356 let output_str = String::from_utf8_lossy(&output);
1358 let _cup_count = output_str.matches("\x1b[").filter(|_| true).count();
1359
1360 assert!(
1362 output_str.contains("ABC")
1363 || (output_str.contains('A')
1364 && output_str.contains('B')
1365 && output_str.contains('C'))
1366 );
1367 }
1368
1369 #[test]
1370 fn sync_output_wrapped_when_supported() {
1371 let mut presenter = test_presenter_with_sync();
1372 let buffer = Buffer::new(10, 10);
1373 let diff = BufferDiff::new();
1374
1375 presenter.present(&buffer, &diff).unwrap();
1376 let output = get_output(presenter);
1377
1378 assert!(output.starts_with(ansi::SYNC_BEGIN));
1380 assert!(
1381 output
1382 .windows(ansi::SYNC_END.len())
1383 .any(|w| w == ansi::SYNC_END)
1384 );
1385 }
1386
1387 #[test]
1388 fn clear_screen_works() {
1389 let mut presenter = test_presenter();
1390 presenter.clear_screen().unwrap();
1391 let output = get_output(presenter);
1392
1393 assert!(output.windows(b"\x1b[2J".len()).any(|w| w == b"\x1b[2J"));
1395 }
1396
1397 #[test]
1398 fn cursor_visibility() {
1399 let mut presenter = test_presenter();
1400
1401 presenter.hide_cursor().unwrap();
1402 presenter.show_cursor().unwrap();
1403
1404 let output = get_output(presenter);
1405 let output_str = String::from_utf8_lossy(&output);
1406
1407 assert!(output_str.contains("\x1b[?25l")); assert!(output_str.contains("\x1b[?25h")); }
1410
1411 #[test]
1412 fn reset_clears_state() {
1413 let mut presenter = test_presenter();
1414 presenter.cursor_x = Some(50);
1415 presenter.cursor_y = Some(20);
1416 presenter.current_style = Some(CellStyle::default());
1417
1418 presenter.reset();
1419
1420 assert!(presenter.cursor_x.is_none());
1421 assert!(presenter.cursor_y.is_none());
1422 assert!(presenter.current_style.is_none());
1423 }
1424
1425 #[test]
1426 fn position_cursor() {
1427 let mut presenter = test_presenter();
1428 presenter.position_cursor(10, 5).unwrap();
1429
1430 let output = get_output(presenter);
1431 assert!(
1433 output
1434 .windows(b"\x1b[6;11H".len())
1435 .any(|w| w == b"\x1b[6;11H")
1436 );
1437 }
1438
1439 #[test]
1440 fn skip_cursor_move_when_already_at_position() {
1441 let mut presenter = test_presenter();
1442 presenter.cursor_x = Some(5);
1443 presenter.cursor_y = Some(3);
1444
1445 presenter.move_cursor_to(5, 3).unwrap();
1447
1448 let output = get_output(presenter);
1450 assert!(output.is_empty());
1451 }
1452
1453 #[test]
1454 fn continuation_cells_skipped() {
1455 let mut presenter = test_presenter();
1456 let mut buffer = Buffer::new(10, 1);
1457
1458 buffer.set_raw(0, 0, Cell::from_char('ä¸'));
1460 buffer.set_raw(1, 0, Cell::CONTINUATION);
1462
1463 let old = Buffer::new(10, 1);
1465 let diff = BufferDiff::compute(&old, &buffer);
1466
1467 presenter.present(&buffer, &diff).unwrap();
1468 let output = get_output(presenter);
1469
1470 let output_str = String::from_utf8_lossy(&output);
1472 assert!(output_str.contains('ä¸'));
1473 }
1474
1475 #[test]
1476 fn continuation_at_run_start_advances_cursor_without_overwriting() {
1477 let mut presenter = test_presenter();
1478 let mut old = Buffer::new(3, 1);
1479 let mut new = Buffer::new(3, 1);
1480
1481 old.set_raw(0, 0, Cell::from_char('ä¸'));
1487 new.set_raw(0, 0, Cell::from_char('ä¸'));
1488 old.set_raw(1, 0, Cell::from_char('X'));
1489 new.set_raw(1, 0, Cell::CONTINUATION);
1490
1491 let diff = BufferDiff::compute(&old, &new);
1492 assert_eq!(diff.changes(), &[(1u16, 0u16)]);
1493
1494 presenter.present(&new, &diff).unwrap();
1495 let output = get_output(presenter);
1496
1497 assert!(output.windows(3).any(|w| w == b"\x1b[C"));
1499 assert!(
1500 !output.contains(&b' '),
1501 "should not write a space when advancing over a continuation cell"
1502 );
1503 }
1504
1505 #[test]
1506 fn wide_char_missing_continuation_causes_drift() {
1507 let mut presenter = test_presenter();
1508 let mut buffer = Buffer::new(10, 1);
1509
1510 buffer.set_raw(0, 0, Cell::from_char('ä¸'));
1512 let old = Buffer::new(10, 1);
1515 let diff = BufferDiff::compute(&old, &buffer);
1516
1517 presenter.present(&buffer, &diff).unwrap();
1518 let output = get_output(presenter);
1519 let _output_str = String::from_utf8_lossy(&output);
1520
1521 }
1556
1557 #[test]
1558 fn hyperlink_emitted_with_registry() {
1559 let mut presenter = test_presenter_with_hyperlinks();
1560 let mut buffer = Buffer::new(10, 1);
1561 let mut links = LinkRegistry::new();
1562
1563 let link_id = links.register("https://example.com");
1564 let cell = Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id));
1565 buffer.set_raw(0, 0, cell);
1566
1567 let old = Buffer::new(10, 1);
1568 let diff = BufferDiff::compute(&old, &buffer);
1569
1570 presenter
1571 .present_with_pool(&buffer, &diff, None, Some(&links))
1572 .unwrap();
1573 let output = get_output(presenter);
1574 let output_str = String::from_utf8_lossy(&output);
1575
1576 assert!(
1578 output_str.contains("\x1b]8;;https://example.com\x1b\\"),
1579 "Expected OSC 8 open, got: {:?}",
1580 output_str
1581 );
1582 assert!(
1584 output_str.contains("\x1b]8;;\x1b\\"),
1585 "Expected OSC 8 close, got: {:?}",
1586 output_str
1587 );
1588 }
1589
1590 #[test]
1591 fn hyperlink_not_emitted_without_registry() {
1592 let mut presenter = test_presenter_with_hyperlinks();
1593 let mut buffer = Buffer::new(10, 1);
1594
1595 let cell = Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), 1));
1597 buffer.set_raw(0, 0, cell);
1598
1599 let old = Buffer::new(10, 1);
1600 let diff = BufferDiff::compute(&old, &buffer);
1601
1602 presenter.present(&buffer, &diff).unwrap();
1604 let output = get_output(presenter);
1605 let output_str = String::from_utf8_lossy(&output);
1606
1607 assert!(
1609 !output_str.contains("\x1b]8;"),
1610 "OSC 8 should not appear without registry, got: {:?}",
1611 output_str
1612 );
1613 }
1614
1615 #[test]
1616 fn hyperlink_not_emitted_for_unknown_id() {
1617 let mut presenter = test_presenter_with_hyperlinks();
1618 let mut buffer = Buffer::new(10, 1);
1619 let links = LinkRegistry::new();
1620
1621 let cell = Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), 42));
1622 buffer.set_raw(0, 0, cell);
1623
1624 let old = Buffer::new(10, 1);
1625 let diff = BufferDiff::compute(&old, &buffer);
1626
1627 presenter
1628 .present_with_pool(&buffer, &diff, None, Some(&links))
1629 .unwrap();
1630 let output = get_output(presenter);
1631 let output_str = String::from_utf8_lossy(&output);
1632
1633 assert!(
1634 !output_str.contains("\x1b]8;"),
1635 "OSC 8 should not appear for unknown link IDs, got: {:?}",
1636 output_str
1637 );
1638 assert!(output_str.contains('L'));
1639 }
1640
1641 #[test]
1642 fn hyperlink_closed_at_frame_end() {
1643 let mut presenter = test_presenter_with_hyperlinks();
1644 let mut buffer = Buffer::new(10, 1);
1645 let mut links = LinkRegistry::new();
1646
1647 let link_id = links.register("https://example.com");
1648 for x in 0..5 {
1650 buffer.set_raw(
1651 x,
1652 0,
1653 Cell::from_char('A').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
1654 );
1655 }
1656
1657 let old = Buffer::new(10, 1);
1658 let diff = BufferDiff::compute(&old, &buffer);
1659
1660 presenter
1661 .present_with_pool(&buffer, &diff, None, Some(&links))
1662 .unwrap();
1663 let output = get_output(presenter);
1664
1665 let close_seq = b"\x1b]8;;\x1b\\";
1667 assert!(
1668 output.windows(close_seq.len()).any(|w| w == close_seq),
1669 "Link must be closed at frame end"
1670 );
1671 }
1672
1673 #[test]
1674 fn hyperlink_transitions_between_links() {
1675 let mut presenter = test_presenter_with_hyperlinks();
1676 let mut buffer = Buffer::new(10, 1);
1677 let mut links = LinkRegistry::new();
1678
1679 let link_a = links.register("https://a.com");
1680 let link_b = links.register("https://b.com");
1681
1682 buffer.set_raw(
1683 0,
1684 0,
1685 Cell::from_char('A').with_attrs(CellAttrs::new(StyleFlags::empty(), link_a)),
1686 );
1687 buffer.set_raw(
1688 1,
1689 0,
1690 Cell::from_char('B').with_attrs(CellAttrs::new(StyleFlags::empty(), link_b)),
1691 );
1692 buffer.set_raw(2, 0, Cell::from_char('C')); let old = Buffer::new(10, 1);
1695 let diff = BufferDiff::compute(&old, &buffer);
1696
1697 presenter
1698 .present_with_pool(&buffer, &diff, None, Some(&links))
1699 .unwrap();
1700 let output = get_output(presenter);
1701 let output_str = String::from_utf8_lossy(&output);
1702
1703 assert!(output_str.contains("https://a.com"));
1705 assert!(output_str.contains("https://b.com"));
1706
1707 let close_count = output_str.matches("\x1b]8;;\x1b\\").count();
1709 assert!(
1710 close_count >= 2,
1711 "Expected at least 2 link close sequences (transition + frame end), got {}",
1712 close_count
1713 );
1714 }
1715
1716 #[test]
1717 fn hyperlink_obeys_mux_policy_even_when_capability_flag_set() {
1718 let caps = TerminalCapabilities::builder()
1719 .osc8_hyperlinks(true)
1720 .in_tmux(true)
1721 .build();
1722 let mut presenter = Presenter::new(Vec::new(), caps);
1723 let mut buffer = Buffer::new(3, 1);
1724 let mut links = LinkRegistry::new();
1725 let link_id = links.register("https://example.com");
1726 buffer.set_raw(
1727 0,
1728 0,
1729 Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
1730 );
1731
1732 let old = Buffer::new(3, 1);
1733 let diff = BufferDiff::compute(&old, &buffer);
1734 presenter
1735 .present_with_pool(&buffer, &diff, None, Some(&links))
1736 .unwrap();
1737
1738 let output = get_output(presenter);
1739 let output_str = String::from_utf8_lossy(&output);
1740 assert!(
1741 !output_str.contains("\x1b]8;"),
1742 "tmux policy should suppress OSC 8 sequences"
1743 );
1744 assert!(output_str.contains('L'));
1745 }
1746
1747 #[test]
1748 fn hyperlink_unsafe_url_not_emitted() {
1749 let mut presenter = test_presenter_with_hyperlinks();
1750 let mut buffer = Buffer::new(3, 1);
1751 let mut links = LinkRegistry::new();
1752 let link_id = links.register("https://example.com/\x1b[?2026h");
1753 buffer.set_raw(
1754 0,
1755 0,
1756 Cell::from_char('X').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
1757 );
1758
1759 let old = Buffer::new(3, 1);
1760 let diff = BufferDiff::compute(&old, &buffer);
1761 presenter
1762 .present_with_pool(&buffer, &diff, None, Some(&links))
1763 .unwrap();
1764
1765 let output = get_output(presenter);
1766 let output_str = String::from_utf8_lossy(&output);
1767 assert!(
1768 !output_str.contains("\x1b]8;;https://example.com/"),
1769 "unsafe hyperlink URL should be suppressed"
1770 );
1771 assert!(
1772 !output_str.contains("\x1b[?2026h"),
1773 "control payload must never be emitted via OSC 8"
1774 );
1775 assert!(output_str.contains('X'));
1776 }
1777
1778 #[test]
1779 fn hyperlink_overlong_url_not_emitted() {
1780 let mut presenter = test_presenter_with_hyperlinks();
1781 let mut buffer = Buffer::new(3, 1);
1782 let mut links = LinkRegistry::new();
1783 let long_url = format!(
1784 "https://example.com/{}",
1785 "a".repeat(MAX_SAFE_HYPERLINK_URL_BYTES + 1)
1786 );
1787 let link_id = links.register(&long_url);
1788 buffer.set_raw(
1789 0,
1790 0,
1791 Cell::from_char('Y').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
1792 );
1793
1794 let old = Buffer::new(3, 1);
1795 let diff = BufferDiff::compute(&old, &buffer);
1796 presenter
1797 .present_with_pool(&buffer, &diff, None, Some(&links))
1798 .unwrap();
1799
1800 let output = get_output(presenter);
1801 let output_str = String::from_utf8_lossy(&output);
1802 assert!(
1803 !output_str.contains("\x1b]8;;https://example.com/"),
1804 "overlong hyperlink URL should be suppressed"
1805 );
1806 assert!(output_str.contains('Y'));
1807 }
1808
1809 #[test]
1814 fn sync_output_not_wrapped_when_unsupported() {
1815 let mut presenter = test_presenter(); let buffer = Buffer::new(10, 10);
1818 let diff = BufferDiff::new();
1819
1820 presenter.present(&buffer, &diff).unwrap();
1821 let output = get_output(presenter);
1822
1823 assert!(
1825 !output
1826 .windows(ansi::SYNC_BEGIN.len())
1827 .any(|w| w == ansi::SYNC_BEGIN),
1828 "Sync begin should not appear when sync_output is disabled"
1829 );
1830 assert!(
1831 !output
1832 .windows(ansi::SYNC_END.len())
1833 .any(|w| w == ansi::SYNC_END),
1834 "Sync end should not appear when sync_output is disabled"
1835 );
1836
1837 assert!(
1839 output.starts_with(ansi::CURSOR_HIDE),
1840 "Fallback should start with cursor hide"
1841 );
1842 assert!(
1843 output.ends_with(ansi::CURSOR_SHOW),
1844 "Fallback should end with cursor show"
1845 );
1846 }
1847
1848 #[test]
1849 fn present_flushes_buffered_output() {
1850 let mut presenter = test_presenter();
1853 let mut buffer = Buffer::new(5, 1);
1854 buffer.set_raw(0, 0, Cell::from_char('T'));
1855 buffer.set_raw(1, 0, Cell::from_char('E'));
1856 buffer.set_raw(2, 0, Cell::from_char('S'));
1857 buffer.set_raw(3, 0, Cell::from_char('T'));
1858
1859 let old = Buffer::new(5, 1);
1860 let diff = BufferDiff::compute(&old, &buffer);
1861
1862 presenter.present(&buffer, &diff).unwrap();
1863 let output = get_output(presenter);
1864 let output_str = String::from_utf8_lossy(&output);
1865
1866 assert!(
1868 output_str.contains("TEST"),
1869 "Expected 'TEST' in flushed output"
1870 );
1871 }
1872
1873 #[test]
1874 fn present_stats_reports_cells_and_bytes() {
1875 let mut presenter = test_presenter();
1876 let mut buffer = Buffer::new(10, 1);
1877
1878 for i in 0..5 {
1880 buffer.set_raw(i, 0, Cell::from_char('X'));
1881 }
1882
1883 let old = Buffer::new(10, 1);
1884 let diff = BufferDiff::compute(&old, &buffer);
1885
1886 let stats = presenter.present(&buffer, &diff).unwrap();
1887
1888 assert_eq!(stats.cells_changed, 5, "Expected 5 cells changed");
1890 assert!(stats.bytes_emitted > 0, "Expected some bytes written");
1891 assert!(stats.run_count >= 1, "Expected at least 1 run");
1892 }
1893
1894 #[test]
1899 fn cursor_tracking_after_wide_char() {
1900 let mut presenter = test_presenter();
1901 presenter.cursor_x = Some(0);
1902 presenter.cursor_y = Some(0);
1903
1904 let mut buffer = Buffer::new(10, 1);
1905 buffer.set_raw(0, 0, Cell::from_char('ä¸'));
1907 buffer.set_raw(1, 0, Cell::CONTINUATION);
1908 buffer.set_raw(2, 0, Cell::from_char('A'));
1910
1911 let old = Buffer::new(10, 1);
1912 let diff = BufferDiff::compute(&old, &buffer);
1913
1914 presenter.present(&buffer, &diff).unwrap();
1915
1916 let output = get_output(presenter);
1919 let output_str = String::from_utf8_lossy(&output);
1920
1921 assert!(output_str.contains('ä¸'));
1923 assert!(output_str.contains('A'));
1924 }
1925
1926 #[test]
1927 fn cursor_position_after_multiple_runs() {
1928 let mut presenter = test_presenter();
1929 let mut buffer = Buffer::new(20, 3);
1930
1931 buffer.set_raw(0, 0, Cell::from_char('A'));
1933 buffer.set_raw(1, 0, Cell::from_char('B'));
1934 buffer.set_raw(5, 2, Cell::from_char('X'));
1935 buffer.set_raw(6, 2, Cell::from_char('Y'));
1936
1937 let old = Buffer::new(20, 3);
1938 let diff = BufferDiff::compute(&old, &buffer);
1939
1940 presenter.present(&buffer, &diff).unwrap();
1941 let output = get_output(presenter);
1942 let output_str = String::from_utf8_lossy(&output);
1943
1944 assert!(output_str.contains('A'));
1946 assert!(output_str.contains('B'));
1947 assert!(output_str.contains('X'));
1948 assert!(output_str.contains('Y'));
1949
1950 let cup_count = output_str.matches("\x1b[").count();
1952 assert!(
1953 cup_count >= 2,
1954 "Expected at least 2 escape sequences for multiple runs"
1955 );
1956 }
1957
1958 #[test]
1963 fn style_with_all_flags() {
1964 let mut presenter = test_presenter();
1965 let mut buffer = Buffer::new(5, 1);
1966
1967 let all_flags = StyleFlags::BOLD
1969 | StyleFlags::DIM
1970 | StyleFlags::ITALIC
1971 | StyleFlags::UNDERLINE
1972 | StyleFlags::BLINK
1973 | StyleFlags::REVERSE
1974 | StyleFlags::STRIKETHROUGH;
1975
1976 let cell = Cell::from_char('X').with_attrs(CellAttrs::new(all_flags, 0));
1977 buffer.set_raw(0, 0, cell);
1978
1979 let old = Buffer::new(5, 1);
1980 let diff = BufferDiff::compute(&old, &buffer);
1981
1982 presenter.present(&buffer, &diff).unwrap();
1983 let output = get_output(presenter);
1984 let output_str = String::from_utf8_lossy(&output);
1985
1986 assert!(output_str.contains('X'));
1988 assert!(output_str.contains("\x1b["), "Expected SGR sequences");
1990 }
1991
1992 #[test]
1993 fn style_transitions_between_different_colors() {
1994 let mut presenter = test_presenter();
1995 let mut buffer = Buffer::new(3, 1);
1996
1997 buffer.set_raw(
1999 0,
2000 0,
2001 Cell::from_char('R').with_fg(PackedRgba::rgb(255, 0, 0)),
2002 );
2003 buffer.set_raw(
2004 1,
2005 0,
2006 Cell::from_char('G').with_fg(PackedRgba::rgb(0, 255, 0)),
2007 );
2008 buffer.set_raw(
2009 2,
2010 0,
2011 Cell::from_char('B').with_fg(PackedRgba::rgb(0, 0, 255)),
2012 );
2013
2014 let old = Buffer::new(3, 1);
2015 let diff = BufferDiff::compute(&old, &buffer);
2016
2017 presenter.present(&buffer, &diff).unwrap();
2018 let output = get_output(presenter);
2019 let output_str = String::from_utf8_lossy(&output);
2020
2021 assert!(output_str.contains("38;2;255;0;0"), "Expected red fg");
2023 assert!(output_str.contains("38;2;0;255;0"), "Expected green fg");
2024 assert!(output_str.contains("38;2;0;0;255"), "Expected blue fg");
2025 }
2026
2027 #[test]
2032 fn link_at_buffer_boundaries() {
2033 let mut presenter = test_presenter();
2034 let mut buffer = Buffer::new(5, 1);
2035 let mut links = LinkRegistry::new();
2036
2037 let link_id = links.register("https://boundary.test");
2038
2039 buffer.set_raw(
2041 0,
2042 0,
2043 Cell::from_char('F').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
2044 );
2045 buffer.set_raw(
2047 4,
2048 0,
2049 Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
2050 );
2051
2052 let old = Buffer::new(5, 1);
2053 let diff = BufferDiff::compute(&old, &buffer);
2054
2055 presenter
2056 .present_with_pool(&buffer, &diff, None, Some(&links))
2057 .unwrap();
2058 let output = get_output(presenter);
2059 let output_str = String::from_utf8_lossy(&output);
2060
2061 assert!(output_str.contains("https://boundary.test"));
2063 assert!(output_str.contains('F'));
2065 assert!(output_str.contains('L'));
2066 }
2067
2068 #[test]
2069 fn link_state_cleared_after_reset() {
2070 let mut presenter = test_presenter();
2071 let mut links = LinkRegistry::new();
2072 let link_id = links.register("https://example.com");
2073
2074 presenter.current_link = Some(link_id);
2076 presenter.current_style = Some(CellStyle::default());
2077 presenter.cursor_x = Some(5);
2078 presenter.cursor_y = Some(3);
2079
2080 presenter.reset();
2081
2082 assert!(
2084 presenter.current_link.is_none(),
2085 "current_link should be None after reset"
2086 );
2087 assert!(
2088 presenter.current_style.is_none(),
2089 "current_style should be None after reset"
2090 );
2091 assert!(
2092 presenter.cursor_x.is_none(),
2093 "cursor_x should be None after reset"
2094 );
2095 assert!(
2096 presenter.cursor_y.is_none(),
2097 "cursor_y should be None after reset"
2098 );
2099 }
2100
2101 #[test]
2102 fn link_transitions_linked_unlinked_linked() {
2103 let mut presenter = test_presenter();
2104 let mut buffer = Buffer::new(5, 1);
2105 let mut links = LinkRegistry::new();
2106
2107 let link_id = links.register("https://toggle.test");
2108
2109 buffer.set_raw(
2111 0,
2112 0,
2113 Cell::from_char('A').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
2114 );
2115 buffer.set_raw(1, 0, Cell::from_char('B')); buffer.set_raw(
2117 2,
2118 0,
2119 Cell::from_char('C').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
2120 );
2121
2122 let old = Buffer::new(5, 1);
2123 let diff = BufferDiff::compute(&old, &buffer);
2124
2125 presenter
2126 .present_with_pool(&buffer, &diff, None, Some(&links))
2127 .unwrap();
2128 let output = get_output(presenter);
2129 let output_str = String::from_utf8_lossy(&output);
2130
2131 let url_count = output_str.matches("https://toggle.test").count();
2133 assert!(
2134 url_count >= 2,
2135 "Expected link to open at least twice, got {} occurrences",
2136 url_count
2137 );
2138
2139 let close_count = output_str.matches("\x1b]8;;\x1b\\").count();
2141 assert!(
2142 close_count >= 2,
2143 "Expected at least 2 link closes, got {}",
2144 close_count
2145 );
2146 }
2147
2148 #[test]
2153 fn multiple_presents_maintain_correct_state() {
2154 let mut presenter = test_presenter();
2155 let mut buffer = Buffer::new(10, 1);
2156
2157 buffer.set_raw(0, 0, Cell::from_char('1'));
2159 let old = Buffer::new(10, 1);
2160 let diff = BufferDiff::compute(&old, &buffer);
2161 presenter.present(&buffer, &diff).unwrap();
2162
2163 let prev = buffer.clone();
2165 buffer.set_raw(1, 0, Cell::from_char('2'));
2166 let diff = BufferDiff::compute(&prev, &buffer);
2167 presenter.present(&buffer, &diff).unwrap();
2168
2169 let prev = buffer.clone();
2171 buffer.set_raw(2, 0, Cell::from_char('3'));
2172 let diff = BufferDiff::compute(&prev, &buffer);
2173 presenter.present(&buffer, &diff).unwrap();
2174
2175 let output = get_output(presenter);
2176 let output_str = String::from_utf8_lossy(&output);
2177
2178 assert!(output_str.contains('1'));
2180 assert!(output_str.contains('2'));
2181 assert!(output_str.contains('3'));
2182 }
2183
2184 #[test]
2189 fn sgr_delta_fg_only_change_no_reset() {
2190 let mut presenter = test_presenter();
2192 let mut buffer = Buffer::new(3, 1);
2193
2194 let fg1 = PackedRgba::rgb(255, 0, 0);
2195 let fg2 = PackedRgba::rgb(0, 255, 0);
2196 buffer.set_raw(0, 0, Cell::from_char('A').with_fg(fg1));
2197 buffer.set_raw(1, 0, Cell::from_char('B').with_fg(fg2));
2198
2199 let old = Buffer::new(3, 1);
2200 let diff = BufferDiff::compute(&old, &buffer);
2201
2202 presenter.present(&buffer, &diff).unwrap();
2203 let output = get_output(presenter);
2204 let output_str = String::from_utf8_lossy(&output);
2205
2206 let reset_count = output_str.matches("\x1b[0m").count();
2209 assert_eq!(
2211 reset_count, 2,
2212 "Expected 2 resets (initial + frame end), got {} in: {:?}",
2213 reset_count, output_str
2214 );
2215 }
2216
2217 #[test]
2218 fn sgr_delta_bg_only_change_no_reset() {
2219 let mut presenter = test_presenter();
2220 let mut buffer = Buffer::new(3, 1);
2221
2222 let bg1 = PackedRgba::rgb(0, 0, 255);
2223 let bg2 = PackedRgba::rgb(255, 255, 0);
2224 buffer.set_raw(0, 0, Cell::from_char('A').with_bg(bg1));
2225 buffer.set_raw(1, 0, Cell::from_char('B').with_bg(bg2));
2226
2227 let old = Buffer::new(3, 1);
2228 let diff = BufferDiff::compute(&old, &buffer);
2229
2230 presenter.present(&buffer, &diff).unwrap();
2231 let output = get_output(presenter);
2232 let output_str = String::from_utf8_lossy(&output);
2233
2234 let reset_count = output_str.matches("\x1b[0m").count();
2236 assert_eq!(
2237 reset_count, 2,
2238 "Expected 2 resets, got {} in: {:?}",
2239 reset_count, output_str
2240 );
2241 }
2242
2243 #[test]
2244 fn sgr_delta_attr_addition_no_reset() {
2245 let mut presenter = test_presenter();
2246 let mut buffer = Buffer::new(3, 1);
2247
2248 let attrs1 = CellAttrs::new(StyleFlags::BOLD, 0);
2250 let attrs2 = CellAttrs::new(StyleFlags::BOLD | StyleFlags::ITALIC, 0);
2251 buffer.set_raw(0, 0, Cell::from_char('A').with_attrs(attrs1));
2252 buffer.set_raw(1, 0, Cell::from_char('B').with_attrs(attrs2));
2253
2254 let old = Buffer::new(3, 1);
2255 let diff = BufferDiff::compute(&old, &buffer);
2256
2257 presenter.present(&buffer, &diff).unwrap();
2258 let output = get_output(presenter);
2259 let output_str = String::from_utf8_lossy(&output);
2260
2261 let reset_count = output_str.matches("\x1b[0m").count();
2263 assert_eq!(
2264 reset_count, 2,
2265 "Expected 2 resets, got {} in: {:?}",
2266 reset_count, output_str
2267 );
2268 assert!(
2270 output_str.contains("\x1b[3m"),
2271 "Expected italic-on sequence in: {:?}",
2272 output_str
2273 );
2274 }
2275
2276 #[test]
2277 fn sgr_delta_attr_removal_uses_off_code() {
2278 let mut presenter = test_presenter();
2279 let mut buffer = Buffer::new(3, 1);
2280
2281 let attrs1 = CellAttrs::new(StyleFlags::BOLD | StyleFlags::ITALIC, 0);
2283 let attrs2 = CellAttrs::new(StyleFlags::BOLD, 0);
2284 buffer.set_raw(0, 0, Cell::from_char('A').with_attrs(attrs1));
2285 buffer.set_raw(1, 0, Cell::from_char('B').with_attrs(attrs2));
2286
2287 let old = Buffer::new(3, 1);
2288 let diff = BufferDiff::compute(&old, &buffer);
2289
2290 presenter.present(&buffer, &diff).unwrap();
2291 let output = get_output(presenter);
2292 let output_str = String::from_utf8_lossy(&output);
2293
2294 assert!(
2296 output_str.contains("\x1b[23m"),
2297 "Expected italic-off sequence in: {:?}",
2298 output_str
2299 );
2300 let reset_count = output_str.matches("\x1b[0m").count();
2302 assert_eq!(
2303 reset_count, 2,
2304 "Expected 2 resets, got {} in: {:?}",
2305 reset_count, output_str
2306 );
2307 }
2308
2309 #[test]
2310 fn sgr_delta_bold_dim_collateral_re_enables() {
2311 let mut presenter = test_presenter();
2314 let mut buffer = Buffer::new(3, 1);
2315
2316 let attrs1 = CellAttrs::new(StyleFlags::BOLD | StyleFlags::DIM, 0);
2318 let attrs2 = CellAttrs::new(StyleFlags::DIM, 0);
2319 buffer.set_raw(0, 0, Cell::from_char('A').with_attrs(attrs1));
2320 buffer.set_raw(1, 0, Cell::from_char('B').with_attrs(attrs2));
2321
2322 let old = Buffer::new(3, 1);
2323 let diff = BufferDiff::compute(&old, &buffer);
2324
2325 presenter.present(&buffer, &diff).unwrap();
2326 let output = get_output(presenter);
2327 let output_str = String::from_utf8_lossy(&output);
2328
2329 assert!(
2331 output_str.contains("\x1b[22m"),
2332 "Expected bold-off (22) in: {:?}",
2333 output_str
2334 );
2335 assert!(
2336 output_str.contains("\x1b[2m"),
2337 "Expected dim re-enable (2) in: {:?}",
2338 output_str
2339 );
2340 }
2341
2342 #[test]
2343 fn sgr_delta_same_style_no_output() {
2344 let mut presenter = test_presenter();
2345 let mut buffer = Buffer::new(3, 1);
2346
2347 let fg = PackedRgba::rgb(255, 0, 0);
2348 let attrs = CellAttrs::new(StyleFlags::BOLD, 0);
2349 buffer.set_raw(0, 0, Cell::from_char('A').with_fg(fg).with_attrs(attrs));
2350 buffer.set_raw(1, 0, Cell::from_char('B').with_fg(fg).with_attrs(attrs));
2351 buffer.set_raw(2, 0, Cell::from_char('C').with_fg(fg).with_attrs(attrs));
2352
2353 let old = Buffer::new(3, 1);
2354 let diff = BufferDiff::compute(&old, &buffer);
2355
2356 presenter.present(&buffer, &diff).unwrap();
2357 let output = get_output(presenter);
2358 let output_str = String::from_utf8_lossy(&output);
2359
2360 let fg_count = output_str.matches("38;2;255;0;0").count();
2362 assert_eq!(
2363 fg_count, 1,
2364 "Expected 1 fg sequence, got {} in: {:?}",
2365 fg_count, output_str
2366 );
2367 }
2368
2369 #[test]
2370 fn sgr_delta_cost_dominance_never_exceeds_baseline() {
2371 let transitions: Vec<(CellStyle, CellStyle)> = vec![
2374 (
2376 CellStyle {
2377 fg: PackedRgba::rgb(255, 0, 0),
2378 bg: PackedRgba::TRANSPARENT,
2379 attrs: StyleFlags::empty(),
2380 },
2381 CellStyle {
2382 fg: PackedRgba::rgb(0, 255, 0),
2383 bg: PackedRgba::TRANSPARENT,
2384 attrs: StyleFlags::empty(),
2385 },
2386 ),
2387 (
2389 CellStyle {
2390 fg: PackedRgba::TRANSPARENT,
2391 bg: PackedRgba::rgb(255, 0, 0),
2392 attrs: StyleFlags::empty(),
2393 },
2394 CellStyle {
2395 fg: PackedRgba::TRANSPARENT,
2396 bg: PackedRgba::rgb(0, 0, 255),
2397 attrs: StyleFlags::empty(),
2398 },
2399 ),
2400 (
2402 CellStyle {
2403 fg: PackedRgba::rgb(100, 100, 100),
2404 bg: PackedRgba::TRANSPARENT,
2405 attrs: StyleFlags::BOLD,
2406 },
2407 CellStyle {
2408 fg: PackedRgba::rgb(100, 100, 100),
2409 bg: PackedRgba::TRANSPARENT,
2410 attrs: StyleFlags::BOLD | StyleFlags::ITALIC,
2411 },
2412 ),
2413 (
2415 CellStyle {
2416 fg: PackedRgba::rgb(100, 100, 100),
2417 bg: PackedRgba::TRANSPARENT,
2418 attrs: StyleFlags::BOLD | StyleFlags::ITALIC,
2419 },
2420 CellStyle {
2421 fg: PackedRgba::rgb(100, 100, 100),
2422 bg: PackedRgba::TRANSPARENT,
2423 attrs: StyleFlags::BOLD,
2424 },
2425 ),
2426 ];
2427
2428 for (old_style, new_style) in &transitions {
2429 let delta_buf = {
2431 let mut delta_presenter = {
2432 let caps = TerminalCapabilities::basic();
2433 Presenter::new(Vec::new(), caps)
2434 };
2435 delta_presenter.current_style = Some(*old_style);
2436 delta_presenter
2437 .emit_style_delta(*old_style, *new_style)
2438 .unwrap();
2439 delta_presenter.into_inner().unwrap()
2440 };
2441
2442 let reset_buf = {
2444 let mut reset_presenter = {
2445 let caps = TerminalCapabilities::basic();
2446 Presenter::new(Vec::new(), caps)
2447 };
2448 reset_presenter.emit_style_full(*new_style).unwrap();
2449 reset_presenter.into_inner().unwrap()
2450 };
2451
2452 assert!(
2453 delta_buf.len() <= reset_buf.len(),
2454 "Delta ({} bytes) exceeded reset+apply ({} bytes) for {:?} -> {:?}.\n\
2455 Delta: {:?}\nReset: {:?}",
2456 delta_buf.len(),
2457 reset_buf.len(),
2458 old_style,
2459 new_style,
2460 String::from_utf8_lossy(&delta_buf),
2461 String::from_utf8_lossy(&reset_buf),
2462 );
2463 }
2464 }
2465
2466 #[test]
2473 fn sgr_delta_evidence_ledger() {
2474 use std::io::Write as _;
2475
2476 const SEED: u64 = 0xDEAD_BEEF_CAFE;
2478
2479 let mut rng_state = SEED;
2481 let mut next_u64 = || -> u64 {
2482 rng_state = rng_state.wrapping_mul(6364136223846793005).wrapping_add(1);
2483 rng_state
2484 };
2485
2486 let random_style = |rng: &mut dyn FnMut() -> u64| -> CellStyle {
2487 let v = rng();
2488 let fg = if v & 1 == 0 {
2489 PackedRgba::TRANSPARENT
2490 } else {
2491 let r = ((v >> 8) & 0xFF) as u8;
2492 let g = ((v >> 16) & 0xFF) as u8;
2493 let b = ((v >> 24) & 0xFF) as u8;
2494 PackedRgba::rgb(r, g, b)
2495 };
2496 let v2 = rng();
2497 let bg = if v2 & 1 == 0 {
2498 PackedRgba::TRANSPARENT
2499 } else {
2500 let r = ((v2 >> 8) & 0xFF) as u8;
2501 let g = ((v2 >> 16) & 0xFF) as u8;
2502 let b = ((v2 >> 24) & 0xFF) as u8;
2503 PackedRgba::rgb(r, g, b)
2504 };
2505 let attrs = StyleFlags::from_bits_truncate(rng() as u8);
2506 CellStyle { fg, bg, attrs }
2507 };
2508
2509 let mut ledger = Vec::new();
2510 let num_transitions = 200;
2511
2512 for i in 0..num_transitions {
2513 let old_style = random_style(&mut next_u64);
2514 let new_style = random_style(&mut next_u64);
2515
2516 let mut delta_p = {
2518 let caps = TerminalCapabilities::basic();
2519 Presenter::new(Vec::new(), caps)
2520 };
2521 delta_p.current_style = Some(old_style);
2522 delta_p.emit_style_delta(old_style, new_style).unwrap();
2523 let delta_out = delta_p.into_inner().unwrap();
2524
2525 let mut reset_p = {
2527 let caps = TerminalCapabilities::basic();
2528 Presenter::new(Vec::new(), caps)
2529 };
2530 reset_p.emit_style_full(new_style).unwrap();
2531 let reset_out = reset_p.into_inner().unwrap();
2532
2533 let delta_bytes = delta_out.len();
2534 let baseline_bytes = reset_out.len();
2535
2536 let attrs_removed = old_style.attrs & !new_style.attrs;
2538 let removed_count = attrs_removed.bits().count_ones();
2539 let fg_changed = old_style.fg != new_style.fg;
2540 let bg_changed = old_style.bg != new_style.bg;
2541 let used_fallback = removed_count >= 3 && fg_changed && bg_changed;
2542
2543 assert!(
2545 delta_bytes <= baseline_bytes,
2546 "Transition {i}: delta ({delta_bytes}B) > baseline ({baseline_bytes}B)"
2547 );
2548
2549 writeln!(
2551 &mut ledger,
2552 "{{\"seed\":{SEED},\"i\":{i},\"from_fg\":\"{:?}\",\"from_bg\":\"{:?}\",\
2553 \"from_attrs\":{},\"to_fg\":\"{:?}\",\"to_bg\":\"{:?}\",\"to_attrs\":{},\
2554 \"delta_bytes\":{delta_bytes},\"baseline_bytes\":{baseline_bytes},\
2555 \"cost_delta\":{},\"used_fallback\":{used_fallback}}}",
2556 old_style.fg,
2557 old_style.bg,
2558 old_style.attrs.bits(),
2559 new_style.fg,
2560 new_style.bg,
2561 new_style.attrs.bits(),
2562 baseline_bytes as isize - delta_bytes as isize,
2563 )
2564 .unwrap();
2565 }
2566
2567 let text = String::from_utf8(ledger).unwrap();
2569 let lines: Vec<&str> = text.lines().collect();
2570 assert_eq!(lines.len(), num_transitions);
2571
2572 let mut total_saved: isize = 0;
2574 for line in &lines {
2575 let cd_start = line.find("\"cost_delta\":").unwrap() + 13;
2577 let cd_end = line[cd_start..].find(',').unwrap() + cd_start;
2578 let cd: isize = line[cd_start..cd_end].parse().unwrap();
2579 total_saved += cd;
2580 }
2581 assert!(
2582 total_saved >= 0,
2583 "Total byte savings should be non-negative, got {total_saved}"
2584 );
2585 }
2586
2587 #[test]
2590 fn e2e_style_stress_with_byte_metrics() {
2591 let width = 40u16;
2592 let height = 10u16;
2593
2594 let mut buffer = Buffer::new(width, height);
2596 for y in 0..height {
2597 for x in 0..width {
2598 let i = (y as usize * width as usize + x as usize) as u8;
2599 let fg = PackedRgba::rgb(i, 255 - i, i.wrapping_mul(3));
2600 let bg = if i.is_multiple_of(4) {
2601 PackedRgba::rgb(i.wrapping_mul(7), i.wrapping_mul(11), i.wrapping_mul(13))
2602 } else {
2603 PackedRgba::TRANSPARENT
2604 };
2605 let flags = StyleFlags::from_bits_truncate(i % 128);
2606 let ch = char::from_u32(('!' as u32) + (i as u32 % 90)).unwrap_or('?');
2607 let cell = Cell::from_char(ch)
2608 .with_fg(fg)
2609 .with_bg(bg)
2610 .with_attrs(CellAttrs::new(flags, 0));
2611 buffer.set_raw(x, y, cell);
2612 }
2613 }
2614
2615 let blank = Buffer::new(width, height);
2617 let diff = BufferDiff::compute(&blank, &buffer);
2618 let mut presenter = test_presenter();
2619 presenter.present(&buffer, &diff).unwrap();
2620 let frame1_bytes = presenter.into_inner().unwrap().len();
2621
2622 let mut buffer2 = Buffer::new(width, height);
2624 for y in 0..height {
2625 for x in 0..width {
2626 let i = (y as usize * width as usize + x as usize + 1) as u8;
2627 let fg = PackedRgba::rgb(i, 255 - i, i.wrapping_mul(3));
2628 let bg = if i.is_multiple_of(4) {
2629 PackedRgba::rgb(i.wrapping_mul(7), i.wrapping_mul(11), i.wrapping_mul(13))
2630 } else {
2631 PackedRgba::TRANSPARENT
2632 };
2633 let flags = StyleFlags::from_bits_truncate(i % 128);
2634 let ch = char::from_u32(('!' as u32) + (i as u32 % 90)).unwrap_or('?');
2635 let cell = Cell::from_char(ch)
2636 .with_fg(fg)
2637 .with_bg(bg)
2638 .with_attrs(CellAttrs::new(flags, 0));
2639 buffer2.set_raw(x, y, cell);
2640 }
2641 }
2642
2643 let diff2 = BufferDiff::compute(&buffer, &buffer2);
2645 let mut presenter2 = test_presenter();
2646 presenter2.present(&buffer2, &diff2).unwrap();
2647 let frame2_bytes = presenter2.into_inner().unwrap().len();
2648
2649 assert!(
2652 frame2_bytes > 0,
2653 "Second frame should produce output for style churn"
2654 );
2655 assert!(!diff2.is_empty(), "Style shift should produce changes");
2656
2657 assert!(
2662 frame2_bytes <= frame1_bytes * 2,
2663 "Incremental frame ({frame2_bytes}B) unreasonably large vs full ({frame1_bytes}B)"
2664 );
2665 }
2666
2667 #[test]
2672 fn cost_model_empty_row_single_run() {
2673 let runs = [ChangeRun::new(5, 10, 20)];
2675 let plan = cost_model::plan_row(&runs, None, None);
2676 assert_eq!(plan.spans().len(), 1);
2677 assert_eq!(plan.spans()[0].x0, 10);
2678 assert_eq!(plan.spans()[0].x1, 20);
2679 assert!(plan.total_cost() > 0);
2680 }
2681
2682 #[test]
2683 fn cost_model_full_row_merges() {
2684 let runs = [ChangeRun::new(0, 0, 2), ChangeRun::new(0, 77, 79)];
2690 let plan = cost_model::plan_row(&runs, None, None);
2691 assert_eq!(plan.spans().len(), 2);
2693 assert_eq!(plan.spans()[0].x0, 0);
2694 assert_eq!(plan.spans()[0].x1, 2);
2695 assert_eq!(plan.spans()[1].x0, 77);
2696 assert_eq!(plan.spans()[1].x1, 79);
2697 }
2698
2699 #[test]
2700 fn cost_model_adjacent_runs_merge() {
2701 let runs = [
2704 ChangeRun::new(3, 10, 10),
2705 ChangeRun::new(3, 12, 12),
2706 ChangeRun::new(3, 14, 14),
2707 ChangeRun::new(3, 16, 16),
2708 ChangeRun::new(3, 18, 18),
2709 ChangeRun::new(3, 20, 20),
2710 ChangeRun::new(3, 22, 22),
2711 ChangeRun::new(3, 24, 24),
2712 ];
2713 let plan = cost_model::plan_row(&runs, None, None);
2714 assert_eq!(plan.spans().len(), 1);
2717 assert_eq!(plan.spans()[0].x0, 10);
2718 assert_eq!(plan.spans()[0].x1, 24);
2719 }
2720
2721 #[test]
2722 fn cost_model_single_cell_stays_sparse() {
2723 let runs = [ChangeRun::new(0, 40, 40)];
2724 let plan = cost_model::plan_row(&runs, Some(0), Some(0));
2725 assert_eq!(plan.spans().len(), 1);
2726 assert_eq!(plan.spans()[0].x0, 40);
2727 assert_eq!(plan.spans()[0].x1, 40);
2728 }
2729
2730 #[test]
2731 fn cost_model_cup_vs_cha_vs_cuf() {
2732 assert!(cost_model::cuf_cost(1) <= cost_model::cha_cost(5));
2734 assert!(cost_model::cuf_cost(3) <= cost_model::cup_cost(0, 5));
2735
2736 let cha = cost_model::cha_cost(5);
2738 let cup = cost_model::cup_cost(0, 5);
2739 assert!(cha <= cup);
2740
2741 let cost = cost_model::cheapest_move_cost(Some(5), Some(0), 6, 0);
2743 assert_eq!(cost, 3); }
2745
2746 #[test]
2747 fn cost_model_digit_estimation_accuracy() {
2748 let mut buf = Vec::new();
2750 ansi::cup(&mut buf, 0, 0).unwrap();
2751 assert_eq!(buf.len(), cost_model::cup_cost(0, 0));
2752
2753 buf.clear();
2754 ansi::cup(&mut buf, 9, 9).unwrap();
2755 assert_eq!(buf.len(), cost_model::cup_cost(9, 9));
2756
2757 buf.clear();
2758 ansi::cup(&mut buf, 99, 99).unwrap();
2759 assert_eq!(buf.len(), cost_model::cup_cost(99, 99));
2760
2761 buf.clear();
2762 ansi::cha(&mut buf, 0).unwrap();
2763 assert_eq!(buf.len(), cost_model::cha_cost(0));
2764
2765 buf.clear();
2766 ansi::cuf(&mut buf, 1).unwrap();
2767 assert_eq!(buf.len(), cost_model::cuf_cost(1));
2768
2769 buf.clear();
2770 ansi::cuf(&mut buf, 10).unwrap();
2771 assert_eq!(buf.len(), cost_model::cuf_cost(10));
2772 }
2773
2774 #[test]
2775 fn cost_model_merged_row_produces_correct_output() {
2776 let width = 30u16;
2778 let mut buffer = Buffer::new(width, 1);
2779
2780 for col in [5u16, 10, 15, 20] {
2782 let ch = char::from_u32('A' as u32 + col as u32 % 26).unwrap();
2783 buffer.set_raw(col, 0, Cell::from_char(ch));
2784 }
2785
2786 let old = Buffer::new(width, 1);
2787 let diff = BufferDiff::compute(&old, &buffer);
2788
2789 let mut presenter = test_presenter();
2791 presenter.present(&buffer, &diff).unwrap();
2792 let output = presenter.into_inner().unwrap();
2793 let output_str = String::from_utf8_lossy(&output);
2794
2795 for col in [5u16, 10, 15, 20] {
2796 let ch = char::from_u32('A' as u32 + col as u32 % 26).unwrap();
2797 assert!(
2798 output_str.contains(ch),
2799 "Missing character '{ch}' at col {col} in output"
2800 );
2801 }
2802 }
2803
2804 #[test]
2805 fn cost_model_optimal_cursor_uses_cuf_on_same_row() {
2806 let mut presenter = test_presenter();
2808 presenter.cursor_x = Some(5);
2809 presenter.cursor_y = Some(0);
2810 presenter.move_cursor_optimal(6, 0).unwrap();
2811 let output = presenter.into_inner().unwrap();
2812 assert_eq!(&output, b"\x1b[C", "Should use CUF for +1 column move");
2814 }
2815
2816 #[test]
2817 fn cost_model_optimal_cursor_uses_cha_on_same_row_backward() {
2818 let mut presenter = test_presenter();
2819 presenter.cursor_x = Some(10);
2820 presenter.cursor_y = Some(3);
2821
2822 let target_x = 2;
2823 let target_y = 3;
2824 let cha_cost = cost_model::cha_cost(target_x);
2825 let cup_cost = cost_model::cup_cost(target_y, target_x);
2826 assert!(
2827 cha_cost <= cup_cost,
2828 "Expected CHA to be cheaper for backward move (cha={cha_cost}, cup={cup_cost})"
2829 );
2830
2831 presenter.move_cursor_optimal(target_x, target_y).unwrap();
2832 let output = presenter.into_inner().unwrap();
2833 let mut expected = Vec::new();
2834 ansi::cha(&mut expected, target_x).unwrap();
2835 assert_eq!(output, expected, "Should use CHA for backward move");
2836 }
2837
2838 #[test]
2839 fn cost_model_optimal_cursor_uses_cup_on_row_change() {
2840 let mut presenter = test_presenter();
2841 presenter.cursor_x = Some(4);
2842 presenter.cursor_y = Some(1);
2843
2844 presenter.move_cursor_optimal(7, 4).unwrap();
2845 let output = presenter.into_inner().unwrap();
2846 let mut expected = Vec::new();
2847 ansi::cup(&mut expected, 4, 7).unwrap();
2848 assert_eq!(output, expected, "Should use CUP when row changes");
2849 }
2850
2851 #[test]
2852 fn cost_model_chooses_full_row_when_cheaper() {
2853 let width = 40u16;
2856 let mut buffer = Buffer::new(width, 1);
2857
2858 for col in (0..20).step_by(2) {
2860 buffer.set_raw(col, 0, Cell::from_char('X'));
2861 }
2862
2863 let old = Buffer::new(width, 1);
2864 let diff = BufferDiff::compute(&old, &buffer);
2865 let runs = diff.runs();
2866
2867 let row_runs: Vec<_> = runs.iter().filter(|r| r.y == 0).copied().collect();
2869 if row_runs.len() > 1 {
2870 let plan = cost_model::plan_row(&row_runs, None, None);
2871 assert!(
2872 plan.spans().len() == 1,
2873 "Expected single merged span for many small runs, got {} spans",
2874 plan.spans().len()
2875 );
2876 assert_eq!(plan.spans()[0].x0, 0);
2877 assert_eq!(plan.spans()[0].x1, 18);
2878 }
2879 }
2880
2881 #[test]
2882 fn perf_cost_model_overhead() {
2883 use std::time::Instant;
2885
2886 let runs: Vec<ChangeRun> = (0..100)
2887 .map(|i| ChangeRun::new(0, i * 3, i * 3 + 1))
2888 .collect();
2889
2890 let (iterations, max_ms) = if cfg!(debug_assertions) {
2891 (1_000, 1_000u128)
2892 } else {
2893 (10_000, 500u128)
2894 };
2895
2896 let start = Instant::now();
2897 for _ in 0..iterations {
2898 let _ = cost_model::plan_row(&runs, None, None);
2899 }
2900 let elapsed = start.elapsed();
2901
2902 assert!(
2904 elapsed.as_millis() < max_ms,
2905 "Cost model planning too slow: {elapsed:?} for {iterations} iterations"
2906 );
2907 }
2908
2909 #[test]
2910 fn perf_legacy_vs_dp_worst_case_sparse() {
2911 use std::time::Instant;
2912
2913 let width = 200u16;
2914 let height = 1u16;
2915 let mut buffer = Buffer::new(width, height);
2916
2917 for col in (0..40).step_by(2) {
2919 buffer.set_raw(col, 0, Cell::from_char('X'));
2920 }
2921 for col in (160..200).step_by(2) {
2922 buffer.set_raw(col, 0, Cell::from_char('Y'));
2923 }
2924
2925 let blank = Buffer::new(width, height);
2926 let diff = BufferDiff::compute(&blank, &buffer);
2927 let runs = diff.runs();
2928 let row_runs: Vec<_> = runs.iter().filter(|r| r.y == 0).copied().collect();
2929
2930 let dp_plan = cost_model::plan_row(&row_runs, None, None);
2931 let legacy_spans = legacy_plan_row(&row_runs, None, None);
2932
2933 let dp_output = emit_spans_for_output(&buffer, dp_plan.spans());
2934 let legacy_output = emit_spans_for_output(&buffer, &legacy_spans);
2935
2936 assert!(
2937 dp_output.len() <= legacy_output.len(),
2938 "DP output should be <= legacy output (dp={}, legacy={})",
2939 dp_output.len(),
2940 legacy_output.len()
2941 );
2942
2943 let (iterations, max_ms) = if cfg!(debug_assertions) {
2944 (1_000, 1_000u128)
2945 } else {
2946 (10_000, 500u128)
2947 };
2948 let start = Instant::now();
2949 for _ in 0..iterations {
2950 let _ = cost_model::plan_row(&row_runs, None, None);
2951 }
2952 let dp_elapsed = start.elapsed();
2953
2954 let start = Instant::now();
2955 for _ in 0..iterations {
2956 let _ = legacy_plan_row(&row_runs, None, None);
2957 }
2958 let legacy_elapsed = start.elapsed();
2959
2960 assert!(
2961 dp_elapsed.as_millis() < max_ms,
2962 "DP planning too slow: {dp_elapsed:?} for {iterations} iterations"
2963 );
2964
2965 let _ = legacy_elapsed;
2966 }
2967
2968 fn build_style_heavy_scene(width: u16, height: u16, seed: u64) -> Buffer {
2974 let mut buffer = Buffer::new(width, height);
2975 let mut rng = seed;
2976 let mut next = || -> u64 {
2977 rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1);
2978 rng
2979 };
2980 for y in 0..height {
2981 for x in 0..width {
2982 let v = next();
2983 let ch = char::from_u32(('!' as u32) + (v as u32 % 90)).unwrap_or('?');
2984 let fg = PackedRgba::rgb((v >> 8) as u8, (v >> 16) as u8, (v >> 24) as u8);
2985 let bg = if v & 3 == 0 {
2986 PackedRgba::rgb((v >> 32) as u8, (v >> 40) as u8, (v >> 48) as u8)
2987 } else {
2988 PackedRgba::TRANSPARENT
2989 };
2990 let flags = StyleFlags::from_bits_truncate((v >> 56) as u8);
2991 let cell = Cell::from_char(ch)
2992 .with_fg(fg)
2993 .with_bg(bg)
2994 .with_attrs(CellAttrs::new(flags, 0));
2995 buffer.set_raw(x, y, cell);
2996 }
2997 }
2998 buffer
2999 }
3000
3001 fn build_sparse_update(base: &Buffer, seed: u64) -> Buffer {
3003 let mut buffer = base.clone();
3004 let width = base.width();
3005 let height = base.height();
3006 let mut rng = seed;
3007 let mut next = || -> u64 {
3008 rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1);
3009 rng
3010 };
3011 let change_count = (width as usize * height as usize) / 10;
3012 for _ in 0..change_count {
3013 let v = next();
3014 let x = (v % width as u64) as u16;
3015 let y = ((v >> 16) % height as u64) as u16;
3016 let ch = char::from_u32(('A' as u32) + (v as u32 % 26)).unwrap_or('?');
3017 buffer.set_raw(x, y, Cell::from_char(ch));
3018 }
3019 buffer
3020 }
3021
3022 #[test]
3023 fn snapshot_presenter_equivalence() {
3024 let buffer = build_style_heavy_scene(40, 10, 0xDEAD_CAFE_1234);
3027 let blank = Buffer::new(40, 10);
3028 let diff = BufferDiff::compute(&blank, &buffer);
3029
3030 let mut presenter = test_presenter();
3031 presenter.present(&buffer, &diff).unwrap();
3032 let output = presenter.into_inner().unwrap();
3033
3034 let checksum = {
3036 let mut hash: u64 = 0xcbf29ce484222325; for &byte in &output {
3038 hash ^= byte as u64;
3039 hash = hash.wrapping_mul(0x100000001b3); }
3041 hash
3042 };
3043
3044 let mut presenter2 = test_presenter();
3046 presenter2.present(&buffer, &diff).unwrap();
3047 let output2 = presenter2.into_inner().unwrap();
3048 assert_eq!(output, output2, "Presenter output must be deterministic");
3049
3050 let _ = checksum; }
3053
3054 #[test]
3055 fn perf_presenter_microbench() {
3056 use std::env;
3057 use std::io::Write as _;
3058 use std::time::Instant;
3059
3060 let width = 120u16;
3061 let height = 40u16;
3062 let seed = 0x00BE_EFCA_FE42;
3063 let scene = build_style_heavy_scene(width, height, seed);
3064 let blank = Buffer::new(width, height);
3065 let diff_full = BufferDiff::compute(&blank, &scene);
3066
3067 let scene2 = build_sparse_update(&scene, seed.wrapping_add(1));
3069 let diff_sparse = BufferDiff::compute(&scene, &scene2);
3070
3071 let mut jsonl = Vec::new();
3072 let iterations = env::var("FTUI_PRESENTER_BENCH_ITERS")
3073 .ok()
3074 .and_then(|value| value.parse::<u32>().ok())
3075 .unwrap_or(50);
3076
3077 let runs_full = diff_full.runs();
3078 let runs_sparse = diff_sparse.runs();
3079
3080 let plan_rows = |runs: &[ChangeRun]| -> (usize, usize) {
3081 let mut idx = 0;
3082 let mut total_cost = 0usize;
3083 let mut span_count = 0usize;
3084 let mut prev_x = None;
3085 let mut prev_y = None;
3086
3087 while idx < runs.len() {
3088 let y = runs[idx].y;
3089 let start = idx;
3090 while idx < runs.len() && runs[idx].y == y {
3091 idx += 1;
3092 }
3093
3094 let plan = cost_model::plan_row(&runs[start..idx], prev_x, prev_y);
3095 span_count += plan.spans().len();
3096 total_cost = total_cost.saturating_add(plan.total_cost());
3097 if let Some(last) = plan.spans().last() {
3098 prev_x = Some(last.x1);
3099 prev_y = Some(y);
3100 }
3101 }
3102
3103 (total_cost, span_count)
3104 };
3105
3106 for i in 0..iterations {
3107 let (diff_ref, buf_ref, runs_ref, label) = if i % 2 == 0 {
3108 (&diff_full, &scene, &runs_full, "full")
3109 } else {
3110 (&diff_sparse, &scene2, &runs_sparse, "sparse")
3111 };
3112
3113 let plan_start = Instant::now();
3114 let (plan_cost, plan_spans) = plan_rows(runs_ref);
3115 let plan_time_us = plan_start.elapsed().as_micros() as u64;
3116
3117 let mut presenter = test_presenter();
3118 let start = Instant::now();
3119 let stats = presenter.present(buf_ref, diff_ref).unwrap();
3120 let elapsed_us = start.elapsed().as_micros() as u64;
3121 let output = presenter.into_inner().unwrap();
3122
3123 let checksum = {
3125 let mut hash: u64 = 0xcbf29ce484222325;
3126 for &b in &output {
3127 hash ^= b as u64;
3128 hash = hash.wrapping_mul(0x100000001b3);
3129 }
3130 hash
3131 };
3132
3133 writeln!(
3134 &mut jsonl,
3135 "{{\"seed\":{seed},\"width\":{width},\"height\":{height},\
3136 \"scene\":\"{label}\",\"changes\":{},\"runs\":{},\
3137 \"plan_cost\":{plan_cost},\"plan_spans\":{plan_spans},\
3138 \"plan_time_us\":{plan_time_us},\"bytes\":{},\
3139 \"emit_time_us\":{elapsed_us},\
3140 \"checksum\":\"{checksum:016x}\"}}",
3141 stats.cells_changed, stats.run_count, stats.bytes_emitted,
3142 )
3143 .unwrap();
3144 }
3145
3146 let text = String::from_utf8(jsonl).unwrap();
3147 let lines: Vec<&str> = text.lines().collect();
3148 assert_eq!(lines.len(), iterations as usize);
3149
3150 let full_checksums: Vec<&str> = lines
3152 .iter()
3153 .filter(|l| l.contains("\"full\""))
3154 .map(|l| {
3155 let start = l.find("\"checksum\":\"").unwrap() + 12;
3156 let end = l[start..].find('"').unwrap() + start;
3157 &l[start..end]
3158 })
3159 .collect();
3160 assert!(full_checksums.len() > 1);
3161 assert!(
3162 full_checksums.windows(2).all(|w| w[0] == w[1]),
3163 "Full frame checksums should be identical across runs"
3164 );
3165
3166 let full_bytes: Vec<u64> = lines
3168 .iter()
3169 .filter(|l| l.contains("\"full\""))
3170 .map(|l| {
3171 let start = l.find("\"bytes\":").unwrap() + 8;
3172 let end = l[start..].find(',').unwrap() + start;
3173 l[start..end].parse::<u64>().unwrap()
3174 })
3175 .collect();
3176 let sparse_bytes: Vec<u64> = lines
3177 .iter()
3178 .filter(|l| l.contains("\"sparse\""))
3179 .map(|l| {
3180 let start = l.find("\"bytes\":").unwrap() + 8;
3181 let end = l[start..].find(',').unwrap() + start;
3182 l[start..end].parse::<u64>().unwrap()
3183 })
3184 .collect();
3185
3186 let avg_full: u64 = full_bytes.iter().sum::<u64>() / full_bytes.len() as u64;
3187 let avg_sparse: u64 = sparse_bytes.iter().sum::<u64>() / sparse_bytes.len() as u64;
3188 assert!(
3189 avg_sparse < avg_full,
3190 "Sparse updates ({avg_sparse}B) should emit fewer bytes than full ({avg_full}B)"
3191 );
3192 }
3193
3194 #[test]
3195 fn perf_emit_style_delta_microbench() {
3196 use std::env;
3197 use std::io::Write as _;
3198 use std::time::Instant;
3199
3200 let iterations = env::var("FTUI_EMIT_STYLE_BENCH_ITERS")
3201 .ok()
3202 .and_then(|value| value.parse::<u32>().ok())
3203 .unwrap_or(200);
3204 let mode = env::var("FTUI_EMIT_STYLE_BENCH_MODE").unwrap_or_default();
3205 let emit_json = mode != "raw";
3206
3207 let mut styles = Vec::with_capacity(128);
3208 let mut rng = 0x00A5_A51E_AF42_u64;
3209 let mut next = || -> u64 {
3210 rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1);
3211 rng
3212 };
3213
3214 for _ in 0..128 {
3215 let v = next();
3216 let fg = PackedRgba::rgb(
3217 (v & 0xFF) as u8,
3218 ((v >> 8) & 0xFF) as u8,
3219 ((v >> 16) & 0xFF) as u8,
3220 );
3221 let bg = PackedRgba::rgb(
3222 ((v >> 24) & 0xFF) as u8,
3223 ((v >> 32) & 0xFF) as u8,
3224 ((v >> 40) & 0xFF) as u8,
3225 );
3226 let flags = StyleFlags::from_bits_truncate((v >> 48) as u8);
3227 let cell = Cell::from_char('A')
3228 .with_fg(fg)
3229 .with_bg(bg)
3230 .with_attrs(CellAttrs::new(flags, 0));
3231 styles.push(CellStyle::from_cell(&cell));
3232 }
3233
3234 let mut presenter = test_presenter();
3235 let mut jsonl = Vec::new();
3236 let mut sink = 0u64;
3237
3238 for i in 0..iterations {
3239 let old = styles[i as usize % styles.len()];
3240 let new = styles[(i as usize + 1) % styles.len()];
3241
3242 presenter.writer.reset_counter();
3243 presenter.writer.inner_mut().get_mut().clear();
3244
3245 let start = Instant::now();
3246 presenter.emit_style_delta(old, new).unwrap();
3247 let elapsed_us = start.elapsed().as_micros() as u64;
3248 let bytes = presenter.writer.bytes_written();
3249
3250 if emit_json {
3251 writeln!(
3252 &mut jsonl,
3253 "{{\"iter\":{i},\"emit_time_us\":{elapsed_us},\"bytes\":{bytes}}}"
3254 )
3255 .unwrap();
3256 } else {
3257 sink = sink.wrapping_add(elapsed_us ^ bytes);
3258 }
3259 }
3260
3261 if emit_json {
3262 let text = String::from_utf8(jsonl).unwrap();
3263 let lines: Vec<&str> = text.lines().collect();
3264 assert_eq!(lines.len() as u32, iterations);
3265 } else {
3266 std::hint::black_box(sink);
3267 }
3268 }
3269
3270 #[test]
3271 fn e2e_presenter_stress_deterministic() {
3272 use crate::terminal_model::TerminalModel;
3275
3276 let width = 60u16;
3277 let height = 20u16;
3278 let num_frames = 10;
3279
3280 let mut prev_buffer = Buffer::new(width, height);
3281 let mut presenter = test_presenter();
3282 let mut model = TerminalModel::new(width as usize, height as usize);
3283 let mut rng = 0x5D2E_55DE_5D42_u64;
3284 let mut next = || -> u64 {
3285 rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1);
3286 rng
3287 };
3288
3289 for _frame in 0..num_frames {
3290 let mut buffer = prev_buffer.clone();
3292 let changes = (width as usize * height as usize) / 5;
3293 for _ in 0..changes {
3294 let v = next();
3295 let x = (v % width as u64) as u16;
3296 let y = ((v >> 16) % height as u64) as u16;
3297 let ch = char::from_u32(('!' as u32) + (v as u32 % 90)).unwrap_or('?');
3298 let fg = PackedRgba::rgb((v >> 8) as u8, (v >> 24) as u8, (v >> 40) as u8);
3299 let cell = Cell::from_char(ch).with_fg(fg);
3300 buffer.set_raw(x, y, cell);
3301 }
3302
3303 let diff = BufferDiff::compute(&prev_buffer, &buffer);
3304 presenter.present(&buffer, &diff).unwrap();
3305
3306 prev_buffer = buffer;
3307 }
3308
3309 let output = presenter.into_inner().unwrap();
3311 model.process(&output);
3312
3313 let mut checked = 0;
3315 for y in 0..height {
3316 for x in 0..width {
3317 let buf_cell = prev_buffer.get_unchecked(x, y);
3318 if !buf_cell.is_empty()
3319 && let Some(model_cell) = model.cell(x as usize, y as usize)
3320 {
3321 let expected = buf_cell.content.as_char().unwrap_or(' ');
3322 let mut buf = [0u8; 4];
3323 let expected_str = expected.encode_utf8(&mut buf);
3324 if model_cell.text.as_str() == expected_str {
3325 checked += 1;
3326 }
3327 }
3328 }
3329 }
3330
3331 let total_nonempty = (0..height)
3334 .flat_map(|y| (0..width).map(move |x| (x, y)))
3335 .filter(|&(x, y)| !prev_buffer.get_unchecked(x, y).is_empty())
3336 .count();
3337
3338 assert!(
3339 checked > total_nonempty * 80 / 100,
3340 "Frame {num_frames}: only {checked}/{total_nonempty} cells match final buffer"
3341 );
3342 }
3343
3344 #[test]
3345 fn style_state_persists_across_frames() {
3346 let mut presenter = test_presenter();
3347 let fg = PackedRgba::rgb(100, 150, 200);
3348
3349 let mut buffer = Buffer::new(5, 1);
3351 buffer.set_raw(0, 0, Cell::from_char('A').with_fg(fg));
3352 let old = Buffer::new(5, 1);
3353 let diff = BufferDiff::compute(&old, &buffer);
3354 presenter.present(&buffer, &diff).unwrap();
3355
3356 assert!(
3359 presenter.current_style.is_none(),
3360 "Style should be reset after frame end"
3361 );
3362 }
3363
3364 #[test]
3371 fn cost_cup_zero_zero() {
3372 assert_eq!(cost_model::cup_cost(0, 0), 6);
3374 }
3375
3376 #[test]
3377 fn cost_cup_max_max() {
3378 assert_eq!(cost_model::cup_cost(u16::MAX, u16::MAX), 14);
3381 }
3382
3383 #[test]
3384 fn cost_cha_zero() {
3385 assert_eq!(cost_model::cha_cost(0), 4);
3387 }
3388
3389 #[test]
3390 fn cost_cha_max() {
3391 assert_eq!(cost_model::cha_cost(u16::MAX), 8);
3393 }
3394
3395 #[test]
3396 fn cost_cuf_zero_is_free() {
3397 assert_eq!(cost_model::cuf_cost(0), 0);
3398 }
3399
3400 #[test]
3401 fn cost_cuf_one_is_three() {
3402 assert_eq!(cost_model::cuf_cost(1), 3);
3404 }
3405
3406 #[test]
3407 fn cost_cuf_two_has_digit() {
3408 assert_eq!(cost_model::cuf_cost(2), 4);
3410 }
3411
3412 #[test]
3413 fn cost_cuf_max() {
3414 assert_eq!(cost_model::cuf_cost(u16::MAX), 8);
3416 }
3417
3418 #[test]
3419 fn cost_cheapest_move_already_at_target() {
3420 assert_eq!(cost_model::cheapest_move_cost(Some(5), Some(3), 5, 3), 0);
3421 }
3422
3423 #[test]
3424 fn cost_cheapest_move_unknown_position() {
3425 let cost = cost_model::cheapest_move_cost(None, None, 5, 3);
3427 assert_eq!(cost, cost_model::cup_cost(3, 5));
3428 }
3429
3430 #[test]
3431 fn cost_cheapest_move_known_y_unknown_x() {
3432 let cost = cost_model::cheapest_move_cost(None, Some(3), 5, 3);
3434 assert_eq!(cost, cost_model::cup_cost(3, 5));
3435 }
3436
3437 #[test]
3438 fn cost_cheapest_move_backward_same_row() {
3439 let cost = cost_model::cheapest_move_cost(Some(50), Some(0), 5, 0);
3441 let cha = cost_model::cha_cost(5);
3442 let cup = cost_model::cup_cost(0, 5);
3443 assert_eq!(cost, cha.min(cup));
3444 }
3445
3446 #[test]
3447 fn cost_cheapest_move_same_row_same_col() {
3448 assert_eq!(cost_model::cheapest_move_cost(Some(0), Some(0), 0, 0), 0);
3450 }
3451
3452 #[test]
3455 fn cost_cup_digit_boundaries() {
3456 let mut buf = Vec::new();
3457 for (row, col) in [
3458 (0u16, 0u16),
3459 (8, 8),
3460 (9, 9),
3461 (98, 98),
3462 (99, 99),
3463 (998, 998),
3464 (999, 999),
3465 (9998, 9998),
3466 (9999, 9999),
3467 (u16::MAX, u16::MAX),
3468 ] {
3469 buf.clear();
3470 ansi::cup(&mut buf, row, col).unwrap();
3471 assert_eq!(
3472 buf.len(),
3473 cost_model::cup_cost(row, col),
3474 "CUP cost mismatch at ({row}, {col})"
3475 );
3476 }
3477 }
3478
3479 #[test]
3480 fn cost_cha_digit_boundaries() {
3481 let mut buf = Vec::new();
3482 for col in [0u16, 8, 9, 98, 99, 998, 999, 9998, 9999, u16::MAX] {
3483 buf.clear();
3484 ansi::cha(&mut buf, col).unwrap();
3485 assert_eq!(
3486 buf.len(),
3487 cost_model::cha_cost(col),
3488 "CHA cost mismatch at col {col}"
3489 );
3490 }
3491 }
3492
3493 #[test]
3494 fn cost_cuf_digit_boundaries() {
3495 let mut buf = Vec::new();
3496 for n in [1u16, 2, 9, 10, 99, 100, 999, 1000, 9999, 10000, u16::MAX] {
3497 buf.clear();
3498 ansi::cuf(&mut buf, n).unwrap();
3499 assert_eq!(
3500 buf.len(),
3501 cost_model::cuf_cost(n),
3502 "CUF cost mismatch for n={n}"
3503 );
3504 }
3505 }
3506
3507 #[test]
3510 fn plan_row_reuse_matches_plan_row() {
3511 let runs = [
3512 ChangeRun::new(5, 2, 4),
3513 ChangeRun::new(5, 8, 10),
3514 ChangeRun::new(5, 20, 25),
3515 ];
3516 let plan1 = cost_model::plan_row(&runs, Some(0), Some(5));
3517 let mut scratch = cost_model::RowPlanScratch::default();
3518 let plan2 = cost_model::plan_row_reuse(&runs, Some(0), Some(5), &mut scratch);
3519 assert_eq!(plan1, plan2);
3520 }
3521
3522 #[test]
3523 fn plan_row_reuse_across_different_sizes() {
3524 let mut scratch = cost_model::RowPlanScratch::default();
3526
3527 let large_runs: Vec<ChangeRun> = (0..20)
3528 .map(|i| ChangeRun::new(0, i * 4, i * 4 + 1))
3529 .collect();
3530 let plan_large = cost_model::plan_row_reuse(&large_runs, None, None, &mut scratch);
3531 assert!(!plan_large.spans().is_empty());
3532
3533 let small_runs = [ChangeRun::new(1, 5, 8)];
3534 let plan_small = cost_model::plan_row_reuse(&small_runs, None, None, &mut scratch);
3535 assert_eq!(plan_small.spans().len(), 1);
3536 assert_eq!(plan_small.spans()[0].x0, 5);
3537 assert_eq!(plan_small.spans()[0].x1, 8);
3538 }
3539
3540 #[test]
3543 fn plan_row_gap_exactly_32_cells() {
3544 let runs = [ChangeRun::new(0, 0, 0), ChangeRun::new(0, 33, 33)];
3547 let plan = cost_model::plan_row(&runs, None, None);
3548 assert!(
3552 plan.spans().len() <= 2,
3553 "32-cell gap should still consider merge"
3554 );
3555 }
3556
3557 #[test]
3558 fn plan_row_gap_33_cells_stays_sparse() {
3559 let runs = [ChangeRun::new(0, 0, 0), ChangeRun::new(0, 34, 34)];
3562 let plan = cost_model::plan_row(&runs, None, None);
3563 assert_eq!(
3564 plan.spans().len(),
3565 2,
3566 "33-cell gap should stay sparse (gap > 32 breaks)"
3567 );
3568 }
3569
3570 #[test]
3573 fn plan_row_many_sparse_spans() {
3574 let runs = [
3576 ChangeRun::new(0, 0, 0),
3577 ChangeRun::new(0, 40, 40),
3578 ChangeRun::new(0, 80, 80),
3579 ChangeRun::new(0, 120, 120),
3580 ChangeRun::new(0, 160, 160),
3581 ChangeRun::new(0, 200, 200),
3582 ];
3583 let plan = cost_model::plan_row(&runs, None, None);
3584 assert_eq!(plan.spans().len(), 6, "Should have 6 separate sparse spans");
3586 }
3587
3588 #[test]
3591 fn cell_style_default_is_transparent_no_attrs() {
3592 let style = CellStyle::default();
3593 assert_eq!(style.fg, PackedRgba::TRANSPARENT);
3594 assert_eq!(style.bg, PackedRgba::TRANSPARENT);
3595 assert!(style.attrs.is_empty());
3596 }
3597
3598 #[test]
3599 fn cell_style_from_cell_captures_all() {
3600 let fg = PackedRgba::rgb(10, 20, 30);
3601 let bg = PackedRgba::rgb(40, 50, 60);
3602 let flags = StyleFlags::BOLD | StyleFlags::ITALIC;
3603 let cell = Cell::from_char('X')
3604 .with_fg(fg)
3605 .with_bg(bg)
3606 .with_attrs(CellAttrs::new(flags, 5));
3607 let style = CellStyle::from_cell(&cell);
3608 assert_eq!(style.fg, fg);
3609 assert_eq!(style.bg, bg);
3610 assert_eq!(style.attrs, flags);
3611 }
3612
3613 #[test]
3614 fn cell_style_eq_and_clone() {
3615 let a = CellStyle {
3616 fg: PackedRgba::rgb(1, 2, 3),
3617 bg: PackedRgba::TRANSPARENT,
3618 attrs: StyleFlags::DIM,
3619 };
3620 let b = a;
3621 assert_eq!(a, b);
3622 }
3623
3624 #[test]
3627 fn sgr_flags_len_empty() {
3628 assert_eq!(Presenter::<Vec<u8>>::sgr_flags_len(StyleFlags::empty()), 0);
3629 }
3630
3631 #[test]
3632 fn sgr_flags_len_single() {
3633 let len = Presenter::<Vec<u8>>::sgr_flags_len(StyleFlags::BOLD);
3635 assert!(len > 0);
3636 let mut buf = Vec::new();
3638 ansi::sgr_flags(&mut buf, StyleFlags::BOLD).unwrap();
3639 assert_eq!(len as usize, buf.len());
3640 }
3641
3642 #[test]
3643 fn sgr_flags_len_multiple() {
3644 let flags = StyleFlags::BOLD | StyleFlags::ITALIC | StyleFlags::UNDERLINE;
3645 let len = Presenter::<Vec<u8>>::sgr_flags_len(flags);
3646 let mut buf = Vec::new();
3647 ansi::sgr_flags(&mut buf, flags).unwrap();
3648 assert_eq!(len as usize, buf.len());
3649 }
3650
3651 #[test]
3652 fn sgr_flags_off_len_empty() {
3653 assert_eq!(
3654 Presenter::<Vec<u8>>::sgr_flags_off_len(StyleFlags::empty()),
3655 0
3656 );
3657 }
3658
3659 #[test]
3660 fn sgr_rgb_len_matches_actual() {
3661 let color = PackedRgba::rgb(0, 0, 0);
3662 let estimated = Presenter::<Vec<u8>>::sgr_rgb_len(color);
3663 assert!(estimated > 0);
3666 }
3667
3668 #[test]
3669 fn sgr_rgb_len_large_values() {
3670 let color = PackedRgba::rgb(255, 255, 255);
3671 let small_color = PackedRgba::rgb(0, 0, 0);
3672 let large_len = Presenter::<Vec<u8>>::sgr_rgb_len(color);
3673 let small_len = Presenter::<Vec<u8>>::sgr_rgb_len(small_color);
3674 assert!(large_len > small_len);
3676 }
3677
3678 #[test]
3679 fn dec_len_u8_boundaries() {
3680 assert_eq!(Presenter::<Vec<u8>>::dec_len_u8(0), 1);
3681 assert_eq!(Presenter::<Vec<u8>>::dec_len_u8(9), 1);
3682 assert_eq!(Presenter::<Vec<u8>>::dec_len_u8(10), 2);
3683 assert_eq!(Presenter::<Vec<u8>>::dec_len_u8(99), 2);
3684 assert_eq!(Presenter::<Vec<u8>>::dec_len_u8(100), 3);
3685 assert_eq!(Presenter::<Vec<u8>>::dec_len_u8(255), 3);
3686 }
3687
3688 #[test]
3691 fn sgr_delta_all_attrs_removed_at_once() {
3692 let mut presenter = test_presenter();
3693 let all_flags = StyleFlags::BOLD
3694 | StyleFlags::DIM
3695 | StyleFlags::ITALIC
3696 | StyleFlags::UNDERLINE
3697 | StyleFlags::BLINK
3698 | StyleFlags::REVERSE
3699 | StyleFlags::STRIKETHROUGH;
3700 let old = CellStyle {
3701 fg: PackedRgba::rgb(100, 100, 100),
3702 bg: PackedRgba::TRANSPARENT,
3703 attrs: all_flags,
3704 };
3705 let new = CellStyle {
3706 fg: PackedRgba::rgb(100, 100, 100),
3707 bg: PackedRgba::TRANSPARENT,
3708 attrs: StyleFlags::empty(),
3709 };
3710
3711 presenter.current_style = Some(old);
3712 presenter.emit_style_delta(old, new).unwrap();
3713 let output = presenter.into_inner().unwrap();
3714
3715 assert!(!output.is_empty());
3718 }
3719
3720 #[test]
3721 fn sgr_delta_fg_to_transparent() {
3722 let mut presenter = test_presenter();
3723 let old = CellStyle {
3724 fg: PackedRgba::rgb(200, 100, 50),
3725 bg: PackedRgba::TRANSPARENT,
3726 attrs: StyleFlags::empty(),
3727 };
3728 let new = CellStyle {
3729 fg: PackedRgba::TRANSPARENT,
3730 bg: PackedRgba::TRANSPARENT,
3731 attrs: StyleFlags::empty(),
3732 };
3733
3734 presenter.current_style = Some(old);
3735 presenter.emit_style_delta(old, new).unwrap();
3736 let output = presenter.into_inner().unwrap();
3737 let output_str = String::from_utf8_lossy(&output);
3738
3739 assert!(!output.is_empty(), "Should emit fg removal: {output_str:?}");
3742 }
3743
3744 #[test]
3745 fn sgr_delta_bg_to_transparent() {
3746 let mut presenter = test_presenter();
3747 let old = CellStyle {
3748 fg: PackedRgba::TRANSPARENT,
3749 bg: PackedRgba::rgb(30, 60, 90),
3750 attrs: StyleFlags::empty(),
3751 };
3752 let new = CellStyle {
3753 fg: PackedRgba::TRANSPARENT,
3754 bg: PackedRgba::TRANSPARENT,
3755 attrs: StyleFlags::empty(),
3756 };
3757
3758 presenter.current_style = Some(old);
3759 presenter.emit_style_delta(old, new).unwrap();
3760 let output = presenter.into_inner().unwrap();
3761 assert!(!output.is_empty(), "Should emit bg removal");
3762 }
3763
3764 #[test]
3765 fn sgr_delta_dim_removed_bold_stays() {
3766 let mut presenter = test_presenter();
3770 let mut buffer = Buffer::new(3, 1);
3771
3772 let attrs1 = CellAttrs::new(StyleFlags::BOLD | StyleFlags::DIM, 0);
3773 let attrs2 = CellAttrs::new(StyleFlags::BOLD, 0);
3774 buffer.set_raw(0, 0, Cell::from_char('A').with_attrs(attrs1));
3775 buffer.set_raw(1, 0, Cell::from_char('B').with_attrs(attrs2));
3776
3777 let old = Buffer::new(3, 1);
3778 let diff = BufferDiff::compute(&old, &buffer);
3779
3780 presenter.present(&buffer, &diff).unwrap();
3781 let output = get_output(presenter);
3782 let output_str = String::from_utf8_lossy(&output);
3783
3784 assert!(
3786 output_str.contains("\x1b[22m"),
3787 "Expected dim-off (22) in: {output_str:?}"
3788 );
3789 assert!(
3790 output_str.contains("\x1b[1m"),
3791 "Expected bold re-enable (1) in: {output_str:?}"
3792 );
3793 }
3794
3795 #[test]
3796 fn sgr_delta_fallback_to_full_reset_when_cheaper() {
3797 let mut presenter = test_presenter();
3799 let old = CellStyle {
3800 fg: PackedRgba::rgb(10, 20, 30),
3801 bg: PackedRgba::rgb(40, 50, 60),
3802 attrs: StyleFlags::BOLD
3803 | StyleFlags::DIM
3804 | StyleFlags::ITALIC
3805 | StyleFlags::UNDERLINE
3806 | StyleFlags::STRIKETHROUGH,
3807 };
3808 let new = CellStyle {
3809 fg: PackedRgba::TRANSPARENT,
3810 bg: PackedRgba::TRANSPARENT,
3811 attrs: StyleFlags::empty(),
3812 };
3813
3814 presenter.current_style = Some(old);
3815 presenter.emit_style_delta(old, new).unwrap();
3816 let output = presenter.into_inner().unwrap();
3817 let output_str = String::from_utf8_lossy(&output);
3818
3819 assert!(
3821 output_str.contains("\x1b[0m"),
3822 "Expected full reset fallback: {output_str:?}"
3823 );
3824 }
3825
3826 #[test]
3829 fn emit_cell_control_char_replaced_with_fffd() {
3830 let mut presenter = test_presenter();
3831 presenter.cursor_x = Some(0);
3832 presenter.cursor_y = Some(0);
3833
3834 let cell = Cell::from_char('\x01');
3837 presenter.emit_cell(0, &cell, None, None).unwrap();
3838 let output = presenter.into_inner().unwrap();
3839 let output_str = String::from_utf8_lossy(&output);
3840
3841 assert!(
3843 output_str.contains('\u{FFFD}'),
3844 "Control char (width 0) should be replaced with U+FFFD, got: {output:?}"
3845 );
3846 assert!(
3847 !output.contains(&0x01),
3848 "Raw control char should not appear"
3849 );
3850 }
3851
3852 #[test]
3853 fn emit_content_empty_cell_emits_space() {
3854 let mut presenter = test_presenter();
3855 presenter.cursor_x = Some(0);
3856 presenter.cursor_y = Some(0);
3857
3858 let cell = Cell::default();
3859 assert!(cell.is_empty());
3860 presenter.emit_cell(0, &cell, None, None).unwrap();
3861 let output = presenter.into_inner().unwrap();
3862 assert!(output.contains(&b' '), "Empty cell should emit space");
3863 }
3864
3865 #[test]
3866 fn emit_content_grapheme_sanitizes_escape_sequences() {
3867 let mut presenter = test_presenter();
3868 presenter.cursor_x = Some(0);
3869 presenter.cursor_y = Some(0);
3870
3871 let mut pool = GraphemePool::new();
3872 let gid = pool.intern("A\x1b[31mB\x1b[0m", 2);
3873 let cell = Cell::new(CellContent::from_grapheme(gid));
3874 presenter.emit_cell(0, &cell, Some(&pool), None).unwrap();
3875
3876 let output = presenter.into_inner().unwrap();
3877 let output_str = String::from_utf8_lossy(&output);
3878 assert!(
3879 output_str.contains("AB"),
3880 "sanitized grapheme should preserve visible payload"
3881 );
3882 assert!(
3883 !output_str.contains("\x1b[31m"),
3884 "raw escape sequence must not be emitted"
3885 );
3886 }
3887
3888 #[test]
3891 fn continuation_cell_cursor_x_none() {
3892 let mut presenter = test_presenter();
3893 presenter.cursor_x = None;
3895 presenter.cursor_y = Some(0);
3896
3897 let cell = Cell::CONTINUATION;
3898 presenter.emit_cell(5, &cell, None, None).unwrap();
3899 let output = presenter.into_inner().unwrap();
3900
3901 assert!(
3903 output.windows(3).any(|w| w == b"\x1b[C"),
3904 "Should emit CUF(1) for continuation with unknown cursor_x"
3905 );
3906 }
3907
3908 #[test]
3909 fn continuation_cell_cursor_already_past() {
3910 let mut presenter = test_presenter();
3911 presenter.cursor_x = Some(10);
3913 presenter.cursor_y = Some(0);
3914
3915 let cell = Cell::CONTINUATION;
3916 presenter.emit_cell(5, &cell, None, None).unwrap();
3917 let output = presenter.into_inner().unwrap();
3918
3919 assert!(
3921 output.is_empty(),
3922 "Should skip continuation when cursor is past it"
3923 );
3924 }
3925
3926 #[test]
3929 fn clear_line_positions_cursor_and_erases() {
3930 let mut presenter = test_presenter();
3931 presenter.clear_line(5).unwrap();
3932 let output = get_output(presenter);
3933 let output_str = String::from_utf8_lossy(&output);
3934
3935 assert!(
3937 output_str.contains("\x1b[2K"),
3938 "Should contain erase line sequence"
3939 );
3940 }
3941
3942 #[test]
3945 fn into_inner_returns_accumulated_output() {
3946 let mut presenter = test_presenter();
3947 presenter.position_cursor(0, 0).unwrap();
3948 let inner = presenter.into_inner().unwrap();
3949 assert!(!inner.is_empty(), "into_inner should return buffered data");
3950 }
3951
3952 #[test]
3955 fn move_cursor_optimal_same_row_forward_large() {
3956 let mut presenter = test_presenter();
3957 presenter.cursor_x = Some(0);
3958 presenter.cursor_y = Some(0);
3959
3960 presenter.move_cursor_optimal(100, 0).unwrap();
3962 let output = presenter.into_inner().unwrap();
3963
3964 let cuf = cost_model::cuf_cost(100);
3966 let cha = cost_model::cha_cost(100);
3967 let cup = cost_model::cup_cost(0, 100);
3968 let cheapest = cuf.min(cha).min(cup);
3969 assert_eq!(output.len(), cheapest, "Should pick cheapest cursor move");
3970 }
3971
3972 #[test]
3973 fn move_cursor_optimal_same_row_backward_to_zero() {
3974 let mut presenter = test_presenter();
3975 presenter.cursor_x = Some(50);
3976 presenter.cursor_y = Some(0);
3977
3978 presenter.move_cursor_optimal(0, 0).unwrap();
3979 let output = presenter.into_inner().unwrap();
3980
3981 let mut expected = Vec::new();
3984 ansi::cha(&mut expected, 0).unwrap();
3985 assert_eq!(output, expected, "Should use CHA for backward to col 0");
3986 }
3987
3988 #[test]
3989 fn move_cursor_optimal_unknown_cursor_uses_cup() {
3990 let mut presenter = test_presenter();
3991 presenter.move_cursor_optimal(10, 5).unwrap();
3993 let output = presenter.into_inner().unwrap();
3994 let mut expected = Vec::new();
3995 ansi::cup(&mut expected, 5, 10).unwrap();
3996 assert_eq!(output, expected, "Should use CUP when cursor is unknown");
3997 }
3998
3999 #[test]
4002 fn sync_wrap_order_begin_content_reset_end() {
4003 let mut presenter = test_presenter_with_sync();
4004 let mut buffer = Buffer::new(3, 1);
4005 buffer.set_raw(0, 0, Cell::from_char('Z'));
4006
4007 let old = Buffer::new(3, 1);
4008 let diff = BufferDiff::compute(&old, &buffer);
4009
4010 presenter.present(&buffer, &diff).unwrap();
4011 let output = get_output(presenter);
4012
4013 let sync_begin_pos = output
4014 .windows(ansi::SYNC_BEGIN.len())
4015 .position(|w| w == ansi::SYNC_BEGIN)
4016 .expect("sync begin missing");
4017 let z_pos = output
4018 .iter()
4019 .position(|&b| b == b'Z')
4020 .expect("character Z missing");
4021 let reset_pos = output
4022 .windows(b"\x1b[0m".len())
4023 .rposition(|w| w == b"\x1b[0m")
4024 .expect("SGR reset missing");
4025 let sync_end_pos = output
4026 .windows(ansi::SYNC_END.len())
4027 .rposition(|w| w == ansi::SYNC_END)
4028 .expect("sync end missing");
4029
4030 assert!(sync_begin_pos < z_pos, "sync begin before content");
4031 assert!(z_pos < reset_pos, "content before reset");
4032 assert!(reset_pos < sync_end_pos, "reset before sync end");
4033 }
4034
4035 #[test]
4038 fn style_none_after_each_frame() {
4039 let mut presenter = test_presenter();
4040 let fg = PackedRgba::rgb(255, 128, 64);
4041
4042 for _ in 0..5 {
4043 let mut buffer = Buffer::new(3, 1);
4044 buffer.set_raw(0, 0, Cell::from_char('X').with_fg(fg));
4045 let old = Buffer::new(3, 1);
4046 let diff = BufferDiff::compute(&old, &buffer);
4047 presenter.present(&buffer, &diff).unwrap();
4048
4049 assert!(
4051 presenter.current_style.is_none(),
4052 "Style should be None after frame end"
4053 );
4054 assert!(
4055 presenter.current_link.is_none(),
4056 "Link should be None after frame end"
4057 );
4058 }
4059 }
4060
4061 #[test]
4064 fn link_closed_at_frame_end_even_if_all_cells_linked() {
4065 let mut presenter = test_presenter();
4066 let mut buffer = Buffer::new(3, 1);
4067 let mut links = LinkRegistry::new();
4068 let link_id = links.register("https://all-linked.test");
4069
4070 for x in 0..3 {
4072 buffer.set_raw(
4073 x,
4074 0,
4075 Cell::from_char('L').with_attrs(CellAttrs::new(StyleFlags::empty(), link_id)),
4076 );
4077 }
4078
4079 let old = Buffer::new(3, 1);
4080 let diff = BufferDiff::compute(&old, &buffer);
4081 presenter
4082 .present_with_pool(&buffer, &diff, None, Some(&links))
4083 .unwrap();
4084
4085 assert!(
4087 presenter.current_link.is_none(),
4088 "Link must be closed at frame end"
4089 );
4090 }
4091
4092 #[test]
4095 fn present_stats_empty_diff() {
4096 let mut presenter = test_presenter();
4097 let buffer = Buffer::new(10, 10);
4098 let diff = BufferDiff::new();
4099 let stats = presenter.present(&buffer, &diff).unwrap();
4100
4101 assert_eq!(stats.cells_changed, 0);
4102 assert_eq!(stats.run_count, 0);
4103 assert!(stats.bytes_emitted > 0);
4105 }
4106
4107 #[test]
4108 fn present_stats_full_row() {
4109 let mut presenter = test_presenter();
4110 let mut buffer = Buffer::new(10, 1);
4111 for x in 0..10 {
4112 buffer.set_raw(x, 0, Cell::from_char('A'));
4113 }
4114 let old = Buffer::new(10, 1);
4115 let diff = BufferDiff::compute(&old, &buffer);
4116 let stats = presenter.present(&buffer, &diff).unwrap();
4117
4118 assert_eq!(stats.cells_changed, 10);
4119 assert!(stats.run_count >= 1);
4120 assert!(stats.bytes_emitted > 10, "Should include ANSI overhead");
4121 }
4122
4123 #[test]
4126 fn capabilities_accessor() {
4127 let mut caps = TerminalCapabilities::basic();
4128 caps.sync_output = true;
4129 let presenter = Presenter::new(Vec::<u8>::new(), caps);
4130 assert!(presenter.capabilities().sync_output);
4131 }
4132
4133 #[test]
4136 fn flush_succeeds_on_empty_presenter() {
4137 let mut presenter = test_presenter();
4138 presenter.flush().unwrap();
4139 let output = get_output(presenter);
4140 assert!(output.is_empty());
4141 }
4142
4143 #[test]
4146 fn row_plan_total_cost_matches_dp() {
4147 let runs = [ChangeRun::new(3, 5, 10), ChangeRun::new(3, 15, 20)];
4148 let plan = cost_model::plan_row(&runs, None, None);
4149 assert!(plan.total_cost() > 0);
4150 }
4153
4154 #[test]
4157 fn sgr_delta_hot_path_only_fg_change() {
4158 let mut presenter = test_presenter();
4159 let old = CellStyle {
4160 fg: PackedRgba::rgb(255, 0, 0),
4161 bg: PackedRgba::rgb(0, 0, 0),
4162 attrs: StyleFlags::BOLD | StyleFlags::ITALIC,
4163 };
4164 let new = CellStyle {
4165 fg: PackedRgba::rgb(0, 255, 0),
4166 bg: PackedRgba::rgb(0, 0, 0),
4167 attrs: StyleFlags::BOLD | StyleFlags::ITALIC, };
4169
4170 presenter.current_style = Some(old);
4171 presenter.emit_style_delta(old, new).unwrap();
4172 let output = presenter.into_inner().unwrap();
4173 let output_str = String::from_utf8_lossy(&output);
4174
4175 assert!(output_str.contains("38;2;0;255;0"), "Should emit new fg");
4177 assert!(
4178 !output_str.contains("\x1b[0m"),
4179 "No reset needed for color-only change"
4180 );
4181 assert!(
4183 !output_str.contains("\x1b[1m"),
4184 "Bold should not be re-emitted"
4185 );
4186 }
4187
4188 #[test]
4189 fn sgr_delta_hot_path_both_colors_change() {
4190 let mut presenter = test_presenter();
4191 let old = CellStyle {
4192 fg: PackedRgba::rgb(1, 2, 3),
4193 bg: PackedRgba::rgb(4, 5, 6),
4194 attrs: StyleFlags::UNDERLINE,
4195 };
4196 let new = CellStyle {
4197 fg: PackedRgba::rgb(7, 8, 9),
4198 bg: PackedRgba::rgb(10, 11, 12),
4199 attrs: StyleFlags::UNDERLINE, };
4201
4202 presenter.current_style = Some(old);
4203 presenter.emit_style_delta(old, new).unwrap();
4204 let output = presenter.into_inner().unwrap();
4205 let output_str = String::from_utf8_lossy(&output);
4206
4207 assert!(output_str.contains("38;2;7;8;9"), "Should emit new fg");
4208 assert!(output_str.contains("48;2;10;11;12"), "Should emit new bg");
4209 assert!(!output_str.contains("\x1b[0m"), "No reset for color-only");
4210 }
4211
4212 #[test]
4215 fn emit_style_full_default_is_just_reset() {
4216 let mut presenter = test_presenter();
4217 let default_style = CellStyle::default();
4218 presenter.emit_style_full(default_style).unwrap();
4219 let output = presenter.into_inner().unwrap();
4220
4221 assert_eq!(output, b"\x1b[0m");
4223 }
4224
4225 #[test]
4226 fn emit_style_full_with_all_properties() {
4227 let mut presenter = test_presenter();
4228 let style = CellStyle {
4229 fg: PackedRgba::rgb(10, 20, 30),
4230 bg: PackedRgba::rgb(40, 50, 60),
4231 attrs: StyleFlags::BOLD | StyleFlags::ITALIC,
4232 };
4233 presenter.emit_style_full(style).unwrap();
4234 let output = presenter.into_inner().unwrap();
4235 let output_str = String::from_utf8_lossy(&output);
4236
4237 assert!(output_str.contains("\x1b[0m"), "Should start with reset");
4239 assert!(output_str.contains("38;2;10;20;30"), "Should have fg");
4240 assert!(output_str.contains("48;2;40;50;60"), "Should have bg");
4241 }
4242
4243 #[test]
4246 fn present_multiple_rows_different_strategies() {
4247 let mut presenter = test_presenter();
4248 let mut buffer = Buffer::new(80, 5);
4249
4250 for x in (0..20).step_by(2) {
4252 buffer.set_raw(x, 0, Cell::from_char('D'));
4253 }
4254 buffer.set_raw(0, 2, Cell::from_char('L'));
4256 buffer.set_raw(79, 2, Cell::from_char('R'));
4257 buffer.set_raw(40, 4, Cell::from_char('M'));
4259
4260 let old = Buffer::new(80, 5);
4261 let diff = BufferDiff::compute(&old, &buffer);
4262 presenter.present(&buffer, &diff).unwrap();
4263 let output = get_output(presenter);
4264 let output_str = String::from_utf8_lossy(&output);
4265
4266 assert!(output_str.contains('D'));
4267 assert!(output_str.contains('L'));
4268 assert!(output_str.contains('R'));
4269 assert!(output_str.contains('M'));
4270 }
4271
4272 #[test]
4273 fn zero_width_chars_replaced_with_placeholder() {
4274 let mut presenter = test_presenter();
4275 let mut buffer = Buffer::new(5, 1);
4276
4277 let zw_char = '\u{0301}';
4281
4282 assert_eq!(Cell::from_char(zw_char).content.width(), 0);
4284
4285 buffer.set_raw(0, 0, Cell::from_char(zw_char));
4286 buffer.set_raw(1, 0, Cell::from_char('A'));
4287
4288 let old = Buffer::new(5, 1);
4289 let diff = BufferDiff::compute(&old, &buffer);
4290
4291 presenter.present(&buffer, &diff).unwrap();
4292 let output = get_output(presenter);
4293 let output_str = String::from_utf8_lossy(&output);
4294
4295 assert!(
4297 output_str.contains("\u{FFFD}"),
4298 "Expected replacement character for zero-width content, got: {:?}",
4299 output_str
4300 );
4301
4302 assert!(
4304 !output_str.contains(zw_char),
4305 "Should not contain raw zero-width char"
4306 );
4307
4308 assert!(
4310 output_str.contains('A'),
4311 "Should contain subsequent character 'A'"
4312 );
4313 }
4314}
4315
4316#[cfg(test)]
4317mod proptests {
4318 use super::*;
4319 use crate::cell::{Cell, PackedRgba};
4320 use crate::diff::BufferDiff;
4321 use crate::terminal_model::TerminalModel;
4322 use proptest::prelude::*;
4323
4324 fn test_presenter() -> Presenter<Vec<u8>> {
4326 let caps = TerminalCapabilities::basic();
4327 Presenter::new(Vec::new(), caps)
4328 }
4329
4330 proptest! {
4331 #[test]
4334 fn presenter_roundtrip_characters(
4335 width in 5u16..40,
4336 height in 3u16..20,
4337 num_chars in 1usize..50, ) {
4339 let mut buffer = Buffer::new(width, height);
4340 let mut changed_positions = std::collections::HashSet::new();
4341
4342 for i in 0..num_chars {
4344 let x = (i * 7 + 3) as u16 % width;
4345 let y = (i * 11 + 5) as u16 % height;
4346 let ch = char::from_u32(('A' as u32) + (i as u32 % 26)).unwrap();
4347 buffer.set_raw(x, y, Cell::from_char(ch));
4348 changed_positions.insert((x, y));
4349 }
4350
4351 let mut presenter = test_presenter();
4353 let old = Buffer::new(width, height);
4354 let diff = BufferDiff::compute(&old, &buffer);
4355 presenter.present(&buffer, &diff).unwrap();
4356 let output = presenter.into_inner().unwrap();
4357
4358 let mut model = TerminalModel::new(width as usize, height as usize);
4360 model.process(&output);
4361
4362 for &(x, y) in &changed_positions {
4364 let buf_cell = buffer.get_unchecked(x, y);
4365 let expected_ch = buf_cell.content.as_char().unwrap_or(' ');
4366 let mut expected_buf = [0u8; 4];
4367 let expected_str = expected_ch.encode_utf8(&mut expected_buf);
4368
4369 if let Some(model_cell) = model.cell(x as usize, y as usize) {
4370 prop_assert_eq!(
4371 model_cell.text.as_str(),
4372 expected_str,
4373 "Character mismatch at ({}, {})", x, y
4374 );
4375 }
4376 }
4377 }
4378
4379 #[test]
4381 fn style_reset_after_present(
4382 width in 5u16..30,
4383 height in 3u16..15,
4384 num_styled in 1usize..20,
4385 ) {
4386 let mut buffer = Buffer::new(width, height);
4387
4388 for i in 0..num_styled {
4390 let x = (i * 7) as u16 % width;
4391 let y = (i * 11) as u16 % height;
4392 let fg = PackedRgba::rgb(
4393 ((i * 31) % 256) as u8,
4394 ((i * 47) % 256) as u8,
4395 ((i * 71) % 256) as u8,
4396 );
4397 buffer.set_raw(x, y, Cell::from_char('X').with_fg(fg));
4398 }
4399
4400 let mut presenter = test_presenter();
4402 let old = Buffer::new(width, height);
4403 let diff = BufferDiff::compute(&old, &buffer);
4404 presenter.present(&buffer, &diff).unwrap();
4405 let output = presenter.into_inner().unwrap();
4406 let output_str = String::from_utf8_lossy(&output);
4407
4408 prop_assert!(
4410 output_str.contains("\x1b[0m"),
4411 "Output should contain SGR reset"
4412 );
4413 }
4414
4415 #[test]
4417 fn empty_diff_minimal_output(
4418 width in 5u16..50,
4419 height in 3u16..25,
4420 ) {
4421 let buffer = Buffer::new(width, height);
4422 let diff = BufferDiff::new(); let mut presenter = test_presenter();
4425 presenter.present(&buffer, &diff).unwrap();
4426 let output = presenter.into_inner().unwrap();
4427
4428 prop_assert!(output.len() < 50, "Empty diff should have minimal output");
4431 }
4432
4433 #[test]
4438 fn diff_size_bounds(
4439 width in 5u16..30,
4440 height in 3u16..15,
4441 ) {
4442 let old = Buffer::new(width, height);
4444 let mut new = Buffer::new(width, height);
4445
4446 for y in 0..height {
4447 for x in 0..width {
4448 new.set_raw(x, y, Cell::from_char('X'));
4449 }
4450 }
4451
4452 let diff = BufferDiff::compute(&old, &new);
4453
4454 prop_assert_eq!(
4456 diff.len(),
4457 (width as usize) * (height as usize),
4458 "Full change should have all cells in diff"
4459 );
4460 }
4461
4462 #[test]
4464 fn presenter_cursor_consistency(
4465 width in 10u16..40,
4466 height in 5u16..20,
4467 num_runs in 1usize..10,
4468 ) {
4469 let mut buffer = Buffer::new(width, height);
4470
4471 for i in 0..num_runs {
4473 let start_x = (i * 5) as u16 % (width - 5);
4474 let y = i as u16 % height;
4475 for x in start_x..(start_x + 3) {
4476 buffer.set_raw(x, y, Cell::from_char('A'));
4477 }
4478 }
4479
4480 let mut presenter = test_presenter();
4482 let old = Buffer::new(width, height);
4483
4484 for _ in 0..3 {
4485 let diff = BufferDiff::compute(&old, &buffer);
4486 presenter.present(&buffer, &diff).unwrap();
4487 }
4488
4489 let output = presenter.into_inner().unwrap();
4491 prop_assert!(!output.is_empty(), "Should produce some output");
4492 }
4493
4494 #[test]
4498 fn sgr_delta_transition_equivalence(
4499 width in 5u16..20,
4500 height in 3u16..10,
4501 num_styled in 2usize..15,
4502 ) {
4503 let mut buffer = Buffer::new(width, height);
4504 let mut expected: std::collections::HashMap<(u16, u16), char> =
4506 std::collections::HashMap::new();
4507
4508 for i in 0..num_styled {
4510 let x = (i * 3 + 1) as u16 % width;
4511 let y = (i * 5 + 2) as u16 % height;
4512 let ch = char::from_u32(('A' as u32) + (i as u32 % 26)).unwrap();
4513 let fg = PackedRgba::rgb(
4514 ((i * 73) % 256) as u8,
4515 ((i * 137) % 256) as u8,
4516 ((i * 41) % 256) as u8,
4517 );
4518 let bg = if i % 3 == 0 {
4519 PackedRgba::rgb(
4520 ((i * 29) % 256) as u8,
4521 ((i * 53) % 256) as u8,
4522 ((i * 97) % 256) as u8,
4523 )
4524 } else {
4525 PackedRgba::TRANSPARENT
4526 };
4527 let flags_bits = ((i * 37) % 256) as u8;
4528 let flags = StyleFlags::from_bits_truncate(flags_bits);
4529 let cell = Cell::from_char(ch)
4530 .with_fg(fg)
4531 .with_bg(bg)
4532 .with_attrs(CellAttrs::new(flags, 0));
4533 buffer.set_raw(x, y, cell);
4534 expected.insert((x, y), ch);
4535 }
4536
4537 let mut presenter = test_presenter();
4539 let old = Buffer::new(width, height);
4540 let diff = BufferDiff::compute(&old, &buffer);
4541 presenter.present(&buffer, &diff).unwrap();
4542 let output = presenter.into_inner().unwrap();
4543
4544 let mut model = TerminalModel::new(width as usize, height as usize);
4546 model.process(&output);
4547
4548 for (&(x, y), &ch) in &expected {
4549 let mut buf = [0u8; 4];
4550 let expected_str = ch.encode_utf8(&mut buf);
4551
4552 if let Some(model_cell) = model.cell(x as usize, y as usize) {
4553 prop_assert_eq!(
4554 model_cell.text.as_str(),
4555 expected_str,
4556 "Character mismatch at ({}, {}) with delta engine", x, y
4557 );
4558 }
4559 }
4560 }
4561
4562 #[test]
4566 fn dp_emit_equivalence(
4567 width in 20u16..60,
4568 height in 5u16..15,
4569 num_changes in 5usize..30,
4570 ) {
4571 let mut buffer = Buffer::new(width, height);
4572 let mut expected: std::collections::HashMap<(u16, u16), char> =
4573 std::collections::HashMap::new();
4574
4575 for i in 0..num_changes {
4577 let x = (i * 7 + 3) as u16 % width;
4578 let y = (i * 3 + 1) as u16 % height;
4579 let ch = char::from_u32(('A' as u32) + (i as u32 % 26)).unwrap();
4580 buffer.set_raw(x, y, Cell::from_char(ch));
4581 expected.insert((x, y), ch);
4582 }
4583
4584 let mut presenter = test_presenter();
4586 let old = Buffer::new(width, height);
4587 let diff = BufferDiff::compute(&old, &buffer);
4588 presenter.present(&buffer, &diff).unwrap();
4589 let output = presenter.into_inner().unwrap();
4590
4591 let mut model = TerminalModel::new(width as usize, height as usize);
4593 model.process(&output);
4594
4595 for (&(x, y), &ch) in &expected {
4596 let mut buf = [0u8; 4];
4597 let expected_str = ch.encode_utf8(&mut buf);
4598
4599 if let Some(model_cell) = model.cell(x as usize, y as usize) {
4600 prop_assert_eq!(
4601 model_cell.text.as_str(),
4602 expected_str,
4603 "DP cost model: character mismatch at ({}, {})", x, y
4604 );
4605 }
4606 }
4607 }
4608 }
4609}