1#![forbid(unsafe_code)]
2
3use crate::buffer::{Buffer, DirtySpan};
42use crate::cell::Cell;
43
44const BLOCK_SIZE: usize = 4;
51
52const ROW_BLOCK_SIZE: usize = 32;
56
57#[cfg(test)]
58fn span_diagnostics(buf: &Buffer) -> String {
59 let stats = buf.dirty_span_stats();
60 if !buf.dirty_span_config().enabled {
61 return format!("dirty spans disabled; stats={stats:?}");
62 }
63 let mut rows = Vec::new();
64 for y in 0..buf.height() {
65 let Some(row) = buf.dirty_span_row(y) else {
66 continue;
67 };
68 if row.is_full() {
69 rows.push(format!("{y}:full"));
70 continue;
71 }
72 if row.spans().is_empty() {
73 continue;
74 }
75 let spans = row
76 .spans()
77 .iter()
78 .map(|span| format!("[{}, {})", span.x0, span.x1))
79 .collect::<Vec<_>>()
80 .join(",");
81 rows.push(format!("{y}:{spans}"));
82 }
83 format!("stats={stats:?} rows=[{}]", rows.join(" | "))
84}
85
86#[inline]
90fn scan_row_changes_range(
91 old_row: &[Cell],
92 new_row: &[Cell],
93 y: u16,
94 x_offset: u16,
95 changes: &mut Vec<(u16, u16)>,
96) {
97 debug_assert_eq!(old_row.len(), new_row.len());
98 let len = old_row.len();
99 let blocks = len / BLOCK_SIZE;
100 let remainder = len % BLOCK_SIZE;
101
102 for block_idx in 0..blocks {
104 let base = block_idx * BLOCK_SIZE;
105 let base_x = x_offset + base as u16;
106 let old_block = &old_row[base..base + BLOCK_SIZE];
107 let new_block = &new_row[base..base + BLOCK_SIZE];
108
109 for i in 0..BLOCK_SIZE {
112 if !old_block[i].bits_eq(&new_block[i]) {
113 changes.push((base_x + i as u16, y));
114 }
115 }
116 }
117
118 let rem_base = blocks * BLOCK_SIZE;
120 let rem_base_x = x_offset + rem_base as u16;
121 for i in 0..remainder {
122 if !old_row[rem_base + i].bits_eq(&new_row[rem_base + i]) {
123 changes.push((rem_base_x + i as u16, y));
124 }
125 }
126}
127
128#[inline]
133fn scan_row_changes(old_row: &[Cell], new_row: &[Cell], y: u16, changes: &mut Vec<(u16, u16)>) {
134 scan_row_changes_blockwise(old_row, new_row, y, changes);
135}
136
137#[inline]
140fn scan_row_changes_blockwise(
141 old_row: &[Cell],
142 new_row: &[Cell],
143 y: u16,
144 changes: &mut Vec<(u16, u16)>,
145) {
146 debug_assert_eq!(old_row.len(), new_row.len());
147 let len = old_row.len();
148 let blocks = len / ROW_BLOCK_SIZE;
149 let remainder = len % ROW_BLOCK_SIZE;
150
151 for block_idx in 0..blocks {
152 let base = block_idx * ROW_BLOCK_SIZE;
153 let old_block = &old_row[base..base + ROW_BLOCK_SIZE];
154 let new_block = &new_row[base..base + ROW_BLOCK_SIZE];
155 if old_block == new_block {
156 continue;
157 }
158 scan_row_changes_range(old_block, new_block, y, base as u16, changes);
159 }
160
161 if remainder > 0 {
162 let base = blocks * ROW_BLOCK_SIZE;
163 let old_block = &old_row[base..base + remainder];
164 let new_block = &new_row[base..base + remainder];
165 if old_block != new_block {
166 scan_row_changes_range(old_block, new_block, y, base as u16, changes);
167 }
168 }
169}
170
171#[inline]
173fn scan_row_changes_spans(
174 old_row: &[Cell],
175 new_row: &[Cell],
176 y: u16,
177 spans: &[DirtySpan],
178 changes: &mut Vec<(u16, u16)>,
179) {
180 for span in spans {
181 let start = span.x0 as usize;
182 let end = span.x1 as usize;
183 if start >= end || start >= old_row.len() {
184 continue;
185 }
186 let end = end.min(old_row.len());
187 let old_slice = &old_row[start..end];
188 let new_slice = &new_row[start..end];
189 if old_slice == new_slice {
190 continue;
191 }
192 scan_row_changes_range(old_slice, new_slice, y, span.x0, changes);
193 }
194}
195
196#[inline]
197fn scan_row_changes_range_if_needed(
198 old_row: &[Cell],
199 new_row: &[Cell],
200 y: u16,
201 start: usize,
202 end: usize,
203 changes: &mut Vec<(u16, u16)>,
204) {
205 if start >= end || start >= old_row.len() {
206 return;
207 }
208 let end = end.min(old_row.len());
209 let old_slice = &old_row[start..end];
210 let new_slice = &new_row[start..end];
211 if old_slice == new_slice {
212 return;
213 }
214 scan_row_changes_range(old_slice, new_slice, y, start as u16, changes);
215}
216
217#[inline]
218#[allow(clippy::too_many_arguments)]
219fn scan_row_tiles(
220 old_row: &[Cell],
221 new_row: &[Cell],
222 y: u16,
223 width: usize,
224 tile_w: usize,
225 tiles_x: usize,
226 tile_row_base: usize,
227 dirty_tiles: &[bool],
228 changes: &mut Vec<(u16, u16)>,
229) {
230 for tile_x in 0..tiles_x {
231 let tile_idx = tile_row_base + tile_x;
232 if !dirty_tiles[tile_idx] {
233 continue;
234 }
235 let start = tile_x * tile_w;
236 let end = ((tile_x + 1) * tile_w).min(width);
237 scan_row_changes_range_if_needed(old_row, new_row, y, start, end, changes);
238 }
239}
240
241#[inline]
242#[allow(clippy::too_many_arguments)]
243fn scan_row_tiles_spans(
244 old_row: &[Cell],
245 new_row: &[Cell],
246 y: u16,
247 width: usize,
248 tile_w: usize,
249 tiles_x: usize,
250 tile_row_base: usize,
251 dirty_tiles: &[bool],
252 spans: &[DirtySpan],
253 changes: &mut Vec<(u16, u16)>,
254) {
255 if spans.is_empty() {
256 return;
257 }
258 let max_x = width.saturating_sub(1);
259 for span in spans {
260 let span_start = span.x0 as usize;
261 let span_end_exclusive = span.x1 as usize;
262 if span_start >= span_end_exclusive || span_start > max_x {
263 continue;
264 }
265 let span_end = span_end_exclusive.saturating_sub(1).min(max_x);
266 let tile_x_start = span_start / tile_w;
267 let tile_x_end = span_end / tile_w;
268 for tile_x in tile_x_start..=tile_x_end {
269 if tile_x >= tiles_x {
270 break;
271 }
272 let tile_idx = tile_row_base + tile_x;
273 if !dirty_tiles[tile_idx] {
274 continue;
275 }
276 let tile_start = tile_x * tile_w;
277 let tile_end = ((tile_x + 1) * tile_w).min(width);
278 let seg_start = span_start.max(tile_start);
279 let seg_end = span_end.min(tile_end.saturating_sub(1));
280 if seg_start > seg_end {
281 continue;
282 }
283 scan_row_changes_range_if_needed(old_row, new_row, y, seg_start, seg_end + 1, changes);
284 }
285 }
286}
287
288const TILE_SIZE_MIN: u16 = 8;
293const TILE_SIZE_MAX: u16 = 64;
294
295#[inline]
296fn clamp_tile_size(value: u16) -> u16 {
297 value.clamp(TILE_SIZE_MIN, TILE_SIZE_MAX)
298}
299
300#[inline]
301fn div_ceil_usize(n: usize, d: usize) -> usize {
302 debug_assert!(d > 0);
303 n.div_ceil(d)
304}
305
306#[derive(Debug, Clone)]
308pub struct TileDiffConfig {
309 pub enabled: bool,
311 pub tile_w: u16,
313 pub tile_h: u16,
315 pub min_cells_for_tiles: usize,
317 pub dense_cell_ratio: f64,
319 pub dense_tile_ratio: f64,
321 pub max_tiles: usize,
323}
324
325impl Default for TileDiffConfig {
326 fn default() -> Self {
327 Self {
328 enabled: true,
329 tile_w: 16,
330 tile_h: 8,
331 min_cells_for_tiles: 12_000,
332 dense_cell_ratio: 0.25,
333 dense_tile_ratio: 0.60,
334 max_tiles: 4096,
335 }
336 }
337}
338
339impl TileDiffConfig {
340 pub fn with_enabled(mut self, enabled: bool) -> Self {
342 self.enabled = enabled;
343 self
344 }
345
346 pub fn with_tile_size(mut self, tile_w: u16, tile_h: u16) -> Self {
348 self.tile_w = tile_w;
349 self.tile_h = tile_h;
350 self
351 }
352
353 pub fn with_min_cells_for_tiles(mut self, min_cells: usize) -> Self {
355 self.min_cells_for_tiles = min_cells;
356 self
357 }
358
359 pub fn with_dense_cell_ratio(mut self, ratio: f64) -> Self {
361 self.dense_cell_ratio = ratio;
362 self
363 }
364
365 pub fn with_dense_tile_ratio(mut self, ratio: f64) -> Self {
367 self.dense_tile_ratio = ratio;
368 self
369 }
370
371 pub fn with_max_tiles(mut self, max_tiles: usize) -> Self {
373 self.max_tiles = max_tiles;
374 self
375 }
376}
377
378#[derive(Debug, Clone, Copy, PartialEq, Eq)]
380pub enum TileDiffFallback {
381 Disabled,
382 SmallScreen,
383 DirtyAll,
384 DenseCells,
385 DenseTiles,
386 TooManyTiles,
387 Overflow,
388}
389
390impl TileDiffFallback {
391 pub const fn as_str(self) -> &'static str {
392 match self {
393 Self::Disabled => "disabled",
394 Self::SmallScreen => "small_screen",
395 Self::DirtyAll => "dirty_all",
396 Self::DenseCells => "dense_cells",
397 Self::DenseTiles => "dense_tiles",
398 Self::TooManyTiles => "too_many_tiles",
399 Self::Overflow => "overflow",
400 }
401 }
402}
403
404#[derive(Debug, Clone, Copy)]
406pub struct TileParams {
407 pub width: u16,
408 pub height: u16,
409 pub tile_w: u16,
410 pub tile_h: u16,
411 pub tiles_x: usize,
412 pub tiles_y: usize,
413}
414
415impl TileParams {
416 #[inline]
417 pub fn total_tiles(self) -> usize {
418 self.tiles_x * self.tiles_y
419 }
420
421 #[inline]
422 pub fn total_cells(self) -> usize {
423 self.width as usize * self.height as usize
424 }
425}
426
427#[derive(Debug, Clone, Copy)]
429pub struct TileDiffStats {
430 pub width: u16,
431 pub height: u16,
432 pub tile_w: u16,
433 pub tile_h: u16,
434 pub tiles_x: usize,
435 pub tiles_y: usize,
436 pub total_tiles: usize,
437 pub dirty_cells: usize,
438 pub dirty_tiles: usize,
439 pub dirty_cell_ratio: f64,
440 pub dirty_tile_ratio: f64,
441 pub scanned_tiles: usize,
442 pub skipped_tiles: usize,
443 pub sat_build_cells: usize,
444 pub scan_cells_estimate: usize,
445 pub fallback: Option<TileDiffFallback>,
446}
447
448#[derive(Debug, Default, Clone)]
450pub struct TileDiffBuilder {
451 tile_counts: Vec<u32>,
452 sat: Vec<u32>,
453 dirty_tiles: Vec<bool>,
454}
455
456#[derive(Debug, Clone)]
458pub struct TileDiffPlan<'a> {
459 pub params: TileParams,
460 pub stats: TileDiffStats,
461 pub dirty_tiles: &'a [bool],
462 pub tile_counts: &'a [u32],
463 pub sat: &'a [u32],
464}
465
466#[derive(Debug, Clone)]
468pub enum TileDiffBuild<'a> {
469 UseTiles(TileDiffPlan<'a>),
470 Fallback(TileDiffStats),
471}
472
473impl TileDiffBuilder {
474 pub fn new() -> Self {
475 Self::default()
476 }
477
478 pub fn build<'a>(
479 &'a mut self,
480 config: &TileDiffConfig,
481 width: u16,
482 height: u16,
483 dirty_bits: &[u8],
484 dirty_cells: usize,
485 dirty_all: bool,
486 ) -> TileDiffBuild<'a> {
487 let tile_w = clamp_tile_size(config.tile_w);
488 let tile_h = clamp_tile_size(config.tile_h);
489 let width_usize = width as usize;
490 let height_usize = height as usize;
491 let tiles_x = div_ceil_usize(width_usize, tile_w as usize);
492 let tiles_y = div_ceil_usize(height_usize, tile_h as usize);
493 let total_tiles = tiles_x * tiles_y;
494 let total_cells = width_usize * height_usize;
495 let dirty_cell_ratio = if total_cells == 0 {
496 0.0
497 } else {
498 dirty_cells as f64 / total_cells as f64
499 };
500
501 let mut stats = TileDiffStats {
502 width,
503 height,
504 tile_w,
505 tile_h,
506 tiles_x,
507 tiles_y,
508 total_tiles,
509 dirty_cells,
510 dirty_tiles: 0,
511 dirty_cell_ratio,
512 dirty_tile_ratio: 0.0,
513 scanned_tiles: 0,
514 skipped_tiles: total_tiles,
515 sat_build_cells: 0,
516 scan_cells_estimate: 0,
517 fallback: None,
518 };
519
520 if !config.enabled {
521 stats.fallback = Some(TileDiffFallback::Disabled);
522 return TileDiffBuild::Fallback(stats);
523 }
524
525 if total_cells < config.min_cells_for_tiles {
526 stats.fallback = Some(TileDiffFallback::SmallScreen);
527 return TileDiffBuild::Fallback(stats);
528 }
529
530 if dirty_all {
531 stats.fallback = Some(TileDiffFallback::DirtyAll);
532 return TileDiffBuild::Fallback(stats);
533 }
534
535 if dirty_cell_ratio >= config.dense_cell_ratio {
536 stats.fallback = Some(TileDiffFallback::DenseCells);
537 return TileDiffBuild::Fallback(stats);
538 }
539
540 if total_tiles > config.max_tiles {
541 stats.fallback = Some(TileDiffFallback::TooManyTiles);
542 return TileDiffBuild::Fallback(stats);
543 }
544
545 debug_assert_eq!(dirty_bits.len(), total_cells);
546 if dirty_bits.len() < total_cells {
547 stats.fallback = Some(TileDiffFallback::Overflow);
548 return TileDiffBuild::Fallback(stats);
549 }
550
551 self.tile_counts.resize(total_tiles, 0);
552 self.tile_counts.fill(0);
553 self.dirty_tiles.resize(total_tiles, false);
554 self.dirty_tiles.fill(false);
555
556 let tile_w_usize = tile_w as usize;
557 let tile_h_usize = tile_h as usize;
558 let mut overflow = false;
559
560 for y in 0..height_usize {
561 let row_start = y * width_usize;
562 let tile_y = y / tile_h_usize;
563 for x in 0..width_usize {
564 let idx = row_start + x;
565 if dirty_bits[idx] == 0 {
566 continue;
567 }
568 let tile_x = x / tile_w_usize;
569 let tile_idx = tile_y * tiles_x + tile_x;
570 match self.tile_counts[tile_idx].checked_add(1) {
571 Some(value) => self.tile_counts[tile_idx] = value,
572 None => {
573 overflow = true;
574 break;
575 }
576 }
577 }
578 if overflow {
579 break;
580 }
581 }
582
583 if overflow {
584 stats.fallback = Some(TileDiffFallback::Overflow);
585 return TileDiffBuild::Fallback(stats);
586 }
587
588 let mut dirty_tiles = 0usize;
589 for (idx, count) in self.tile_counts.iter().enumerate() {
590 if *count > 0 {
591 self.dirty_tiles[idx] = true;
592 dirty_tiles += 1;
593 }
594 }
595
596 stats.dirty_tiles = dirty_tiles;
597 stats.dirty_tile_ratio = if total_tiles == 0 {
598 0.0
599 } else {
600 dirty_tiles as f64 / total_tiles as f64
601 };
602 stats.scanned_tiles = dirty_tiles;
603 stats.skipped_tiles = total_tiles.saturating_sub(dirty_tiles);
604 stats.sat_build_cells = total_cells;
605 stats.scan_cells_estimate = dirty_tiles * tile_w_usize * tile_h_usize;
606
607 if stats.dirty_tile_ratio >= config.dense_tile_ratio {
608 stats.fallback = Some(TileDiffFallback::DenseTiles);
609 return TileDiffBuild::Fallback(stats);
610 }
611
612 let sat_w = tiles_x + 1;
613 let sat_h = tiles_y + 1;
614 let sat_len = sat_w * sat_h;
615 self.sat.resize(sat_len, 0);
616 self.sat.fill(0);
617
618 for ty in 0..tiles_y {
619 let row_base = (ty + 1) * sat_w;
620 let prev_base = ty * sat_w;
621 for tx in 0..tiles_x {
622 let count = self.tile_counts[ty * tiles_x + tx] as u64;
623 let above = self.sat[prev_base + tx + 1] as u64;
624 let left = self.sat[row_base + tx] as u64;
625 let diag = self.sat[prev_base + tx] as u64;
626 let value = count + above + left - diag;
627 if value > u32::MAX as u64 {
628 stats.fallback = Some(TileDiffFallback::Overflow);
629 return TileDiffBuild::Fallback(stats);
630 }
631 self.sat[row_base + tx + 1] = value as u32;
632 }
633 }
634
635 let params = TileParams {
636 width,
637 height,
638 tile_w,
639 tile_h,
640 tiles_x,
641 tiles_y,
642 };
643
644 TileDiffBuild::UseTiles(TileDiffPlan {
645 params,
646 stats,
647 dirty_tiles: &self.dirty_tiles,
648 tile_counts: &self.tile_counts,
649 sat: &self.sat,
650 })
651 }
652}
653
654#[inline]
655fn reserve_changes_capacity(width: u16, height: u16, changes: &mut Vec<(u16, u16)>) {
656 let estimated_changes = (width as usize * height as usize) / 20;
658 let additional = estimated_changes.saturating_sub(changes.len());
659 if additional > 0 {
660 changes.reserve(additional);
661 }
662}
663
664fn compute_changes(old: &Buffer, new: &Buffer, changes: &mut Vec<(u16, u16)>) {
665 #[cfg(feature = "tracing")]
666 let _span = tracing::debug_span!("diff_compute", width = old.width(), height = old.height());
667 #[cfg(feature = "tracing")]
668 let _guard = _span.enter();
669
670 assert_eq!(old.width(), new.width(), "buffer widths must match");
671 assert_eq!(old.height(), new.height(), "buffer heights must match");
672
673 let width = old.width();
674 let height = old.height();
675 let w = width as usize;
676
677 changes.clear();
678 reserve_changes_capacity(width, height, changes);
679
680 let old_cells = old.cells();
681 let new_cells = new.cells();
682
683 for y in 0..height {
685 let row_start = y as usize * w;
686 let old_row = &old_cells[row_start..row_start + w];
687 let new_row = &new_cells[row_start..row_start + w];
688
689 scan_row_changes(old_row, new_row, y, changes);
693 }
694
695 #[cfg(feature = "tracing")]
696 tracing::trace!(changes = changes.len(), "diff computed");
697}
698
699fn compute_dirty_changes(
700 old: &Buffer,
701 new: &Buffer,
702 changes: &mut Vec<(u16, u16)>,
703 tile_builder: &mut TileDiffBuilder,
704 tile_config: &TileDiffConfig,
705 tile_stats_out: &mut Option<TileDiffStats>,
706) {
707 assert_eq!(old.width(), new.width(), "buffer widths must match");
708 assert_eq!(old.height(), new.height(), "buffer heights must match");
709
710 let width = old.width();
711 let height = old.height();
712 let w = width as usize;
713
714 changes.clear();
715 reserve_changes_capacity(width, height, changes);
716
717 let old_cells = old.cells();
718 let new_cells = new.cells();
719 let dirty = new.dirty_rows();
720
721 *tile_stats_out = None;
722 let tile_build = tile_builder.build(
723 tile_config,
724 width,
725 height,
726 new.dirty_bits(),
727 new.dirty_cell_count(),
728 new.dirty_all(),
729 );
730
731 if let TileDiffBuild::UseTiles(plan) = tile_build {
732 *tile_stats_out = Some(plan.stats);
733 let tile_w = plan.params.tile_w as usize;
734 let tile_h = plan.params.tile_h as usize;
735 let tiles_x = plan.params.tiles_x;
736 let dirty_tiles = plan.dirty_tiles;
737
738 for y in 0..height {
739 if !dirty[y as usize] {
740 continue;
741 }
742
743 let row_start = y as usize * w;
744 let old_row = &old_cells[row_start..row_start + w];
745 let new_row = &new_cells[row_start..row_start + w];
746
747 if old_row == new_row {
748 continue;
749 }
750
751 let tile_y = y as usize / tile_h;
752 let tile_row_base = tile_y * tiles_x;
753 debug_assert!(tile_row_base + tiles_x <= dirty_tiles.len());
754
755 let span_row = new.dirty_span_row(y);
756 if let Some(span_row) = span_row {
757 if span_row.is_full() {
758 scan_row_tiles(
759 old_row,
760 new_row,
761 y,
762 w,
763 tile_w,
764 tiles_x,
765 tile_row_base,
766 dirty_tiles,
767 changes,
768 );
769 continue;
770 }
771 let spans = span_row.spans();
772 if spans.is_empty() {
773 scan_row_tiles(
774 old_row,
775 new_row,
776 y,
777 w,
778 tile_w,
779 tiles_x,
780 tile_row_base,
781 dirty_tiles,
782 changes,
783 );
784 continue;
785 }
786 scan_row_tiles_spans(
787 old_row,
788 new_row,
789 y,
790 w,
791 tile_w,
792 tiles_x,
793 tile_row_base,
794 dirty_tiles,
795 spans,
796 changes,
797 );
798 } else {
799 scan_row_tiles(
800 old_row,
801 new_row,
802 y,
803 w,
804 tile_w,
805 tiles_x,
806 tile_row_base,
807 dirty_tiles,
808 changes,
809 );
810 }
811 }
812 return;
813 }
814
815 if let TileDiffBuild::Fallback(stats) = tile_build {
816 *tile_stats_out = Some(stats);
817 }
818
819 for y in 0..height {
820 if !dirty[y as usize] {
822 continue;
823 }
824
825 let row_start = y as usize * w;
826 let old_row = &old_cells[row_start..row_start + w];
827 let new_row = &new_cells[row_start..row_start + w];
828
829 if old_row == new_row {
832 continue;
833 }
834
835 let span_row = new.dirty_span_row(y);
836 if let Some(span_row) = span_row {
837 if span_row.is_full() {
838 scan_row_changes(old_row, new_row, y, changes);
839 continue;
840 }
841 let spans = span_row.spans();
842 if spans.is_empty() {
843 scan_row_changes(old_row, new_row, y, changes);
844 continue;
845 }
846 scan_row_changes_spans(old_row, new_row, y, spans, changes);
847 } else {
848 scan_row_changes(old_row, new_row, y, changes);
849 }
850 }
851}
852
853#[derive(Debug, Clone, Copy, PartialEq, Eq)]
858pub struct ChangeRun {
859 pub y: u16,
861 pub x0: u16,
863 pub x1: u16,
865}
866
867impl ChangeRun {
868 #[inline]
870 pub const fn new(y: u16, x0: u16, x1: u16) -> Self {
871 debug_assert!(x0 <= x1);
872 Self { y, x0, x1 }
873 }
874
875 #[inline]
877 pub const fn len(&self) -> u16 {
878 self.x1 - self.x0 + 1
879 }
880
881 #[inline]
883 pub const fn is_empty(&self) -> bool {
884 self.x1 < self.x0
885 }
886}
887
888#[derive(Debug, Clone, Default)]
892pub struct BufferDiff {
893 changes: Vec<(u16, u16)>,
895 tile_builder: TileDiffBuilder,
897 tile_config: TileDiffConfig,
899 last_tile_stats: Option<TileDiffStats>,
901}
902
903impl BufferDiff {
904 pub fn new() -> Self {
906 Self {
907 changes: Vec::new(),
908 tile_builder: TileDiffBuilder::default(),
909 tile_config: TileDiffConfig::default(),
910 last_tile_stats: None,
911 }
912 }
913
914 pub fn with_capacity(capacity: usize) -> Self {
916 let mut diff = Self::new();
917 diff.changes = Vec::with_capacity(capacity);
918 diff
919 }
920
921 pub fn full(width: u16, height: u16) -> Self {
926 if width == 0 || height == 0 {
927 return Self::new();
928 }
929
930 let total = width as usize * height as usize;
931 let mut changes = Vec::with_capacity(total);
932 for y in 0..height {
933 for x in 0..width {
934 changes.push((x, y));
935 }
936 }
937 let mut diff = Self::new();
938 diff.changes = changes;
939 diff
940 }
941
942 pub fn compute(old: &Buffer, new: &Buffer) -> Self {
965 let mut diff = Self::new();
966 diff.compute_into(old, new);
967 diff
968 }
969
970 pub fn compute_into(&mut self, old: &Buffer, new: &Buffer) {
972 self.last_tile_stats = None;
973 compute_changes(old, new, &mut self.changes);
974 }
975
976 pub fn compute_dirty(old: &Buffer, new: &Buffer) -> Self {
988 let mut diff = Self::new();
989 diff.compute_dirty_into(old, new);
990 diff
991 }
992
993 pub fn compute_dirty_into(&mut self, old: &Buffer, new: &Buffer) {
995 compute_dirty_changes(
996 old,
997 new,
998 &mut self.changes,
999 &mut self.tile_builder,
1000 &self.tile_config,
1001 &mut self.last_tile_stats,
1002 );
1003 }
1004
1005 #[inline]
1007 pub fn len(&self) -> usize {
1008 self.changes.len()
1009 }
1010
1011 #[inline]
1013 pub fn is_empty(&self) -> bool {
1014 self.changes.is_empty()
1015 }
1016
1017 #[inline]
1019 pub fn changes(&self) -> &[(u16, u16)] {
1020 &self.changes
1021 }
1022
1023 #[inline]
1025 pub fn last_tile_stats(&self) -> Option<TileDiffStats> {
1026 self.last_tile_stats
1027 }
1028
1029 #[inline]
1031 pub fn tile_config_mut(&mut self) -> &mut TileDiffConfig {
1032 &mut self.tile_config
1033 }
1034
1035 pub fn runs(&self) -> Vec<ChangeRun> {
1040 #[cfg(feature = "tracing")]
1041 let _span = tracing::debug_span!("diff_runs", changes = self.changes.len());
1042 #[cfg(feature = "tracing")]
1043 let _guard = _span.enter();
1044
1045 if self.changes.is_empty() {
1046 return Vec::new();
1047 }
1048
1049 let sorted = &self.changes;
1052 let len = sorted.len();
1053
1054 let mut runs = Vec::with_capacity(len);
1057
1058 let mut i = 0;
1059
1060 while i < len {
1061 let (x0, y) = sorted[i];
1062 let mut x1 = x0;
1063 i += 1;
1064
1065 while i < len {
1067 let (x, yy) = sorted[i];
1068 if yy != y || x != x1.saturating_add(1) {
1069 break;
1070 }
1071 x1 = x;
1072 i += 1;
1073 }
1074
1075 runs.push(ChangeRun::new(y, x0, x1));
1076 }
1077
1078 #[cfg(feature = "tracing")]
1079 tracing::trace!(run_count = runs.len(), "runs coalesced");
1080
1081 runs
1082 }
1083
1084 #[inline]
1086 pub fn iter(&self) -> impl Iterator<Item = (u16, u16)> + '_ {
1087 self.changes.iter().copied()
1088 }
1089
1090 pub fn clear(&mut self) {
1092 self.changes.clear();
1093 }
1094}
1095
1096#[cfg(test)]
1097mod tests {
1098 fn is_coverage_run() -> bool {
1099 if let Ok(value) = std::env::var("FTUI_COVERAGE") {
1100 let value = value.to_ascii_lowercase();
1101 if matches!(value.as_str(), "1" | "true" | "yes") {
1102 return true;
1103 }
1104 if matches!(value.as_str(), "0" | "false" | "no") {
1105 return false;
1106 }
1107 }
1108 std::env::var("LLVM_PROFILE_FILE").is_ok() || std::env::var("CARGO_LLVM_COV").is_ok()
1109 }
1110 use super::*;
1111 use crate::cell::{Cell, PackedRgba};
1112
1113 #[test]
1114 fn empty_diff_when_buffers_identical() {
1115 let buf1 = Buffer::new(10, 10);
1116 let buf2 = Buffer::new(10, 10);
1117 let diff = BufferDiff::compute(&buf1, &buf2);
1118
1119 assert!(diff.is_empty());
1120 assert_eq!(diff.len(), 0);
1121 }
1122
1123 #[test]
1124 fn full_diff_marks_all_cells() {
1125 let diff = BufferDiff::full(3, 2);
1126 assert_eq!(diff.len(), 6);
1127 assert_eq!(diff.changes()[0], (0, 0));
1128 assert_eq!(diff.changes()[5], (2, 1));
1129 }
1130
1131 #[test]
1132 fn single_cell_change_detected() {
1133 let old = Buffer::new(10, 10);
1134 let mut new = Buffer::new(10, 10);
1135
1136 new.set_raw(5, 5, Cell::from_char('X'));
1137 let diff = BufferDiff::compute(&old, &new);
1138
1139 assert_eq!(diff.len(), 1);
1140 assert_eq!(diff.changes(), &[(5, 5)]);
1141 }
1142
1143 #[test]
1144 fn dirty_row_false_positive_skipped() {
1145 let old = Buffer::new(8, 2);
1146 let mut new = old.clone();
1147
1148 new.clear_dirty();
1150 new.set_raw(2, 0, Cell::default());
1152
1153 let diff = BufferDiff::compute_dirty(&old, &new);
1154 assert!(
1155 diff.is_empty(),
1156 "dirty row with no changes should be skipped"
1157 );
1158 }
1159
1160 #[test]
1161 fn multiple_scattered_changes_detected() {
1162 let old = Buffer::new(10, 10);
1163 let mut new = Buffer::new(10, 10);
1164
1165 new.set_raw(0, 0, Cell::from_char('A'));
1166 new.set_raw(9, 9, Cell::from_char('B'));
1167 new.set_raw(5, 3, Cell::from_char('C'));
1168
1169 let diff = BufferDiff::compute(&old, &new);
1170
1171 assert_eq!(diff.len(), 3);
1172 let changes = diff.changes();
1174 assert!(changes.contains(&(0, 0)));
1175 assert!(changes.contains(&(9, 9)));
1176 assert!(changes.contains(&(5, 3)));
1177 }
1178
1179 #[test]
1180 fn runs_coalesces_adjacent_cells() {
1181 let old = Buffer::new(10, 10);
1182 let mut new = Buffer::new(10, 10);
1183
1184 new.set_raw(3, 5, Cell::from_char('A'));
1186 new.set_raw(4, 5, Cell::from_char('B'));
1187 new.set_raw(5, 5, Cell::from_char('C'));
1188
1189 let diff = BufferDiff::compute(&old, &new);
1190 let runs = diff.runs();
1191
1192 assert_eq!(runs.len(), 1);
1193 assert_eq!(runs[0].y, 5);
1194 assert_eq!(runs[0].x0, 3);
1195 assert_eq!(runs[0].x1, 5);
1196 assert_eq!(runs[0].len(), 3);
1197 }
1198
1199 #[test]
1200 fn runs_handles_gaps_correctly() {
1201 let old = Buffer::new(10, 10);
1202 let mut new = Buffer::new(10, 10);
1203
1204 new.set_raw(0, 0, Cell::from_char('A'));
1206 new.set_raw(1, 0, Cell::from_char('B'));
1207 new.set_raw(3, 0, Cell::from_char('C'));
1209 new.set_raw(4, 0, Cell::from_char('D'));
1210
1211 let diff = BufferDiff::compute(&old, &new);
1212 let runs = diff.runs();
1213
1214 assert_eq!(runs.len(), 2);
1215
1216 assert_eq!(runs[0].y, 0);
1217 assert_eq!(runs[0].x0, 0);
1218 assert_eq!(runs[0].x1, 1);
1219
1220 assert_eq!(runs[1].y, 0);
1221 assert_eq!(runs[1].x0, 3);
1222 assert_eq!(runs[1].x1, 4);
1223 }
1224
1225 #[test]
1226 fn runs_handles_max_column_without_overflow() {
1227 let mut diff = BufferDiff::new();
1228 diff.changes = vec![(u16::MAX, 0)];
1229
1230 let runs = diff.runs();
1231
1232 assert_eq!(runs.len(), 1);
1233 assert_eq!(runs[0], ChangeRun::new(0, u16::MAX, u16::MAX));
1234 }
1235
1236 #[test]
1237 fn runs_handles_multiple_rows() {
1238 let old = Buffer::new(10, 10);
1239 let mut new = Buffer::new(10, 10);
1240
1241 new.set_raw(0, 0, Cell::from_char('A'));
1243 new.set_raw(1, 0, Cell::from_char('B'));
1244 new.set_raw(5, 2, Cell::from_char('C'));
1245 new.set_raw(0, 5, Cell::from_char('D'));
1246
1247 let diff = BufferDiff::compute(&old, &new);
1248 let runs = diff.runs();
1249
1250 assert_eq!(runs.len(), 3);
1251
1252 assert_eq!(runs[0].y, 0);
1254 assert_eq!(runs[0].x0, 0);
1255 assert_eq!(runs[0].x1, 1);
1256
1257 assert_eq!(runs[1].y, 2);
1259 assert_eq!(runs[1].x0, 5);
1260 assert_eq!(runs[1].x1, 5);
1261
1262 assert_eq!(runs[2].y, 5);
1264 assert_eq!(runs[2].x0, 0);
1265 assert_eq!(runs[2].x1, 0);
1266 }
1267
1268 #[test]
1269 fn empty_runs_from_empty_diff() {
1270 let diff = BufferDiff::new();
1271 let runs = diff.runs();
1272 assert!(runs.is_empty());
1273 }
1274
1275 #[test]
1276 fn change_run_len() {
1277 let run = ChangeRun::new(0, 5, 10);
1278 assert_eq!(run.len(), 6);
1279
1280 let single = ChangeRun::new(0, 5, 5);
1281 assert_eq!(single.len(), 1);
1282 }
1283
1284 #[test]
1285 fn color_changes_detected() {
1286 let old = Buffer::new(10, 10);
1287 let mut new = Buffer::new(10, 10);
1288
1289 new.set_raw(5, 5, Cell::default().with_fg(PackedRgba::rgb(255, 0, 0)));
1291
1292 let diff = BufferDiff::compute(&old, &new);
1293 assert_eq!(diff.len(), 1);
1294 }
1295
1296 #[test]
1297 fn diff_iter() {
1298 let old = Buffer::new(5, 5);
1299 let mut new = Buffer::new(5, 5);
1300 new.set_raw(1, 1, Cell::from_char('X'));
1301 new.set_raw(2, 2, Cell::from_char('Y'));
1302
1303 let diff = BufferDiff::compute(&old, &new);
1304 let positions: Vec<_> = diff.iter().collect();
1305
1306 assert_eq!(positions.len(), 2);
1307 assert!(positions.contains(&(1, 1)));
1308 assert!(positions.contains(&(2, 2)));
1309 }
1310
1311 #[test]
1312 fn diff_clear() {
1313 let old = Buffer::new(5, 5);
1314 let mut new = Buffer::new(5, 5);
1315 new.set_raw(1, 1, Cell::from_char('X'));
1316
1317 let mut diff = BufferDiff::compute(&old, &new);
1318 assert_eq!(diff.len(), 1);
1319
1320 diff.clear();
1321 assert!(diff.is_empty());
1322 }
1323
1324 #[test]
1325 fn with_capacity() {
1326 let diff = BufferDiff::with_capacity(100);
1327 assert!(diff.is_empty());
1328 }
1329
1330 #[test]
1331 fn full_buffer_change() {
1332 let old = Buffer::new(5, 5);
1333 let mut new = Buffer::new(5, 5);
1334
1335 for y in 0..5 {
1337 for x in 0..5 {
1338 new.set_raw(x, y, Cell::from_char('#'));
1339 }
1340 }
1341
1342 let diff = BufferDiff::compute(&old, &new);
1343 assert_eq!(diff.len(), 25);
1344
1345 let runs = diff.runs();
1347 assert_eq!(runs.len(), 5);
1348
1349 for (i, run) in runs.iter().enumerate() {
1350 assert_eq!(run.y, i as u16);
1351 assert_eq!(run.x0, 0);
1352 assert_eq!(run.x1, 4);
1353 assert_eq!(run.len(), 5);
1354 }
1355 }
1356
1357 #[test]
1358 fn row_major_order_preserved() {
1359 let old = Buffer::new(3, 3);
1360 let mut new = Buffer::new(3, 3);
1361
1362 new.set_raw(2, 2, Cell::from_char('C'));
1364 new.set_raw(0, 0, Cell::from_char('A'));
1365 new.set_raw(1, 1, Cell::from_char('B'));
1366
1367 let diff = BufferDiff::compute(&old, &new);
1368
1369 let changes = diff.changes();
1371 assert_eq!(changes[0], (0, 0));
1372 assert_eq!(changes[1], (1, 1));
1373 assert_eq!(changes[2], (2, 2));
1374 }
1375
1376 #[test]
1377 fn blockwise_scan_preserves_sparse_row_changes() {
1378 let old = Buffer::new(64, 2);
1379 let mut new = old.clone();
1380
1381 new.set_raw(1, 0, Cell::from_char('A'));
1382 new.set_raw(33, 0, Cell::from_char('B'));
1383 new.set_raw(62, 1, Cell::from_char('C'));
1384
1385 let diff = BufferDiff::compute(&old, &new);
1386 assert_eq!(diff.changes(), &[(1, 0), (33, 0), (62, 1)]);
1387 }
1388
1389 #[test]
1390 fn rows_with_no_changes_are_skipped() {
1391 let old = Buffer::new(4, 3);
1392 let mut new = old.clone();
1393
1394 new.set_raw(1, 1, Cell::from_char('X'));
1395 new.set_raw(3, 1, Cell::from_char('Y'));
1396
1397 let diff = BufferDiff::compute(&old, &new);
1398 assert_eq!(diff.len(), 2);
1399 assert!(diff.changes().iter().all(|&(_, y)| y == 1));
1400 }
1401
1402 #[test]
1403 fn clear_retains_capacity_for_reuse() {
1404 let mut diff = BufferDiff::with_capacity(16);
1405 diff.changes.extend_from_slice(&[(0, 0), (1, 0), (2, 0)]);
1406 let capacity = diff.changes.capacity();
1407
1408 diff.clear();
1409
1410 assert!(diff.is_empty());
1411 assert!(diff.changes.capacity() >= capacity);
1412 }
1413
1414 #[test]
1415 #[should_panic(expected = "buffer widths must match")]
1416 fn compute_panics_on_width_mismatch() {
1417 let old = Buffer::new(5, 5);
1418 let new = Buffer::new(4, 5);
1419 let _ = BufferDiff::compute(&old, &new);
1420 }
1421
1422 #[test]
1427 fn block_scan_alignment_exact_block() {
1428 let old = Buffer::new(4, 1);
1430 let mut new = Buffer::new(4, 1);
1431 new.set_raw(2, 0, Cell::from_char('X'));
1432
1433 let diff = BufferDiff::compute(&old, &new);
1434 assert_eq!(diff.len(), 1);
1435 assert_eq!(diff.changes(), &[(2, 0)]);
1436 }
1437
1438 #[test]
1439 fn block_scan_alignment_remainder() {
1440 let old = Buffer::new(7, 1);
1442 let mut new = Buffer::new(7, 1);
1443 new.set_raw(1, 0, Cell::from_char('A'));
1445 new.set_raw(5, 0, Cell::from_char('B'));
1447 new.set_raw(6, 0, Cell::from_char('C'));
1448
1449 let diff = BufferDiff::compute(&old, &new);
1450 assert_eq!(diff.len(), 3);
1451 assert_eq!(diff.changes(), &[(1, 0), (5, 0), (6, 0)]);
1452 }
1453
1454 #[test]
1455 fn block_scan_single_cell_row() {
1456 let old = Buffer::new(1, 1);
1458 let mut new = Buffer::new(1, 1);
1459 new.set_raw(0, 0, Cell::from_char('X'));
1460
1461 let diff = BufferDiff::compute(&old, &new);
1462 assert_eq!(diff.len(), 1);
1463 assert_eq!(diff.changes(), &[(0, 0)]);
1464 }
1465
1466 #[test]
1467 fn block_scan_two_cell_row() {
1468 let old = Buffer::new(2, 1);
1470 let mut new = Buffer::new(2, 1);
1471 new.set_raw(0, 0, Cell::from_char('A'));
1472 new.set_raw(1, 0, Cell::from_char('B'));
1473
1474 let diff = BufferDiff::compute(&old, &new);
1475 assert_eq!(diff.len(), 2);
1476 assert_eq!(diff.changes(), &[(0, 0), (1, 0)]);
1477 }
1478
1479 #[test]
1480 fn block_scan_three_cell_row() {
1481 let old = Buffer::new(3, 1);
1483 let mut new = Buffer::new(3, 1);
1484 new.set_raw(2, 0, Cell::from_char('X'));
1485
1486 let diff = BufferDiff::compute(&old, &new);
1487 assert_eq!(diff.len(), 1);
1488 assert_eq!(diff.changes(), &[(2, 0)]);
1489 }
1490
1491 #[test]
1492 fn block_scan_multiple_blocks_sparse() {
1493 let old = Buffer::new(80, 1);
1495 let mut new = Buffer::new(80, 1);
1496
1497 for block in (0..20).step_by(2) {
1499 let x = (block * 4 + 1) as u16;
1500 new.set_raw(x, 0, Cell::from_char('X'));
1501 }
1502
1503 let diff = BufferDiff::compute(&old, &new);
1504 assert_eq!(diff.len(), 10);
1505 assert_eq!(
1506 diff.changes(),
1507 &[
1508 (1, 0),
1509 (9, 0),
1510 (17, 0),
1511 (25, 0),
1512 (33, 0),
1513 (41, 0),
1514 (49, 0),
1515 (57, 0),
1516 (65, 0),
1517 (73, 0)
1518 ]
1519 );
1520 }
1521
1522 #[test]
1523 fn block_scan_full_block_unchanged_skip() {
1524 let old = Buffer::new(20, 1);
1526 let mut new = Buffer::new(20, 1);
1527
1528 new.set_raw(19, 0, Cell::from_char('Z'));
1530
1531 let diff = BufferDiff::compute(&old, &new);
1532 assert_eq!(diff.len(), 1);
1533 assert_eq!(diff.changes(), &[(19, 0)]);
1534 }
1535
1536 #[test]
1537 fn block_scan_wide_row_all_changed() {
1538 let old = Buffer::new(120, 1);
1540 let mut new = Buffer::new(120, 1);
1541 for x in 0..120 {
1542 new.set_raw(x, 0, Cell::from_char('#'));
1543 }
1544
1545 let diff = BufferDiff::compute(&old, &new);
1546 assert_eq!(diff.len(), 120);
1547 }
1548
1549 #[test]
1550 fn perf_block_scan_vs_scalar_baseline() {
1551 use std::time::Instant;
1554
1555 let width = 200u16;
1556 let height = 50u16;
1557 let old = Buffer::new(width, height);
1558 let mut new = Buffer::new(width, height);
1559
1560 for i in 0..1000 {
1562 let x = (i * 7 + 3) as u16 % width;
1563 let y = (i * 11 + 5) as u16 % height;
1564 let ch = char::from_u32(('A' as u32) + (i as u32 % 26)).unwrap();
1565 new.set_raw(x, y, Cell::from_char(ch));
1566 }
1567
1568 let iterations = 1000u32;
1569 let samples = std::env::var("FTUI_DIFF_BLOCK_SAMPLES")
1570 .ok()
1571 .and_then(|value| value.parse::<usize>().ok())
1572 .unwrap_or(50)
1573 .clamp(1, iterations as usize);
1574 let iters_per_sample = (iterations / samples as u32).max(1) as u64;
1575
1576 let mut times_us = Vec::with_capacity(samples);
1577 let mut last_checksum = 0u64;
1578
1579 for _ in 0..samples {
1580 let start = Instant::now();
1581 for _ in 0..iters_per_sample {
1582 let diff = BufferDiff::compute(&old, &new);
1583 assert!(!diff.is_empty());
1584 last_checksum = fnv1a_hash(diff.changes());
1585 }
1586 let elapsed = start.elapsed();
1587 let per_iter = (elapsed.as_micros() as u64) / iters_per_sample;
1588 times_us.push(per_iter);
1589 }
1590
1591 times_us.sort_unstable();
1592 let len = times_us.len();
1593 let p50 = times_us[len / 2];
1594 let p95 = times_us[((len as f64 * 0.95) as usize).min(len.saturating_sub(1))];
1595 let p99 = times_us[((len as f64 * 0.99) as usize).min(len.saturating_sub(1))];
1596 let mean = times_us
1597 .iter()
1598 .copied()
1599 .map(|value| value as f64)
1600 .sum::<f64>()
1601 / len as f64;
1602 let variance = times_us
1603 .iter()
1604 .map(|value| {
1605 let delta = *value as f64 - mean;
1606 delta * delta
1607 })
1608 .sum::<f64>()
1609 / len as f64;
1610
1611 eprintln!(
1613 "{{\"ts\":\"2026-02-04T00:00:00Z\",\"event\":\"block_scan_baseline\",\"width\":{},\"height\":{},\"samples\":{},\"iters_per_sample\":{},\"p50_us\":{},\"p95_us\":{},\"p99_us\":{},\"mean_us\":{:.2},\"variance_us\":{:.2},\"checksum\":\"0x{:016x}\"}}",
1614 width, height, samples, iters_per_sample, p50, p95, p99, mean, variance, last_checksum
1615 );
1616
1617 #[allow(unexpected_cfgs)]
1618 let budget_us = if is_coverage_run() { 3_000u64 } else { 500u64 };
1619 assert!(
1620 p95 <= budget_us,
1621 "Diff too slow: p95={p95}µs (budget {budget_us}µs) for {width}x{height}"
1622 );
1623 }
1624 #[test]
1629 fn unit_run_coalescing_invariants() {
1630 let old = Buffer::new(80, 24);
1633 let mut new = Buffer::new(80, 24);
1634
1635 for x in 0..=2 {
1637 new.set_raw(x, 0, Cell::from_char('A'));
1638 }
1639 for x in 10..=12 {
1640 new.set_raw(x, 0, Cell::from_char('B'));
1641 }
1642 for x in 40..=45 {
1644 new.set_raw(x, 5, Cell::from_char('C'));
1645 }
1646 new.set_raw(79, 23, Cell::from_char('Z'));
1648
1649 let diff = BufferDiff::compute(&old, &new);
1650 let runs = diff.runs();
1651
1652 for w in runs.windows(2) {
1654 assert!(
1655 (w[0].y, w[0].x0) < (w[1].y, w[1].x0),
1656 "runs must be sorted: {:?} should precede {:?}",
1657 w[0],
1658 w[1]
1659 );
1660 }
1661
1662 let total_cells: usize = runs.iter().map(|r| r.len() as usize).sum();
1664 assert_eq!(
1665 total_cells,
1666 diff.len(),
1667 "runs must cover all changes exactly"
1668 );
1669
1670 for w in runs.windows(2) {
1672 if w[0].y == w[1].y {
1673 assert!(
1674 w[1].x0 > w[0].x1.saturating_add(1),
1675 "adjacent runs on same row should be merged: {:?} and {:?}",
1676 w[0],
1677 w[1]
1678 );
1679 }
1680 }
1681
1682 assert_eq!(runs.len(), 4);
1684 assert_eq!(runs[0], ChangeRun::new(0, 0, 2));
1685 assert_eq!(runs[1], ChangeRun::new(0, 10, 12));
1686 assert_eq!(runs[2], ChangeRun::new(5, 40, 45));
1687 assert_eq!(runs[3], ChangeRun::new(23, 79, 79));
1688 }
1689
1690 fn fnv1a_hash(data: &[(u16, u16)]) -> u64 {
1696 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
1697 for &(x, y) in data {
1698 for byte in x.to_le_bytes().iter().chain(y.to_le_bytes().iter()) {
1699 hash ^= *byte as u64;
1700 hash = hash.wrapping_mul(0x0100_0000_01b3);
1701 }
1702 }
1703 hash
1704 }
1705
1706 fn build_golden_scene(width: u16, height: u16, seed: u64) -> Buffer {
1709 let mut buf = Buffer::new(width, height);
1710 let mut rng = seed;
1711
1712 for x in 0..width {
1714 buf.set_raw(x, 0, Cell::from_char('='));
1715 }
1716
1717 for x in 0..width {
1719 buf.set_raw(x, height - 1, Cell::from_char('-'));
1720 }
1721
1722 let count = (width as u64 * height as u64 / 10).max(5);
1724 for _ in 0..count {
1725 rng = rng.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
1726 let x = ((rng >> 16) as u16) % width;
1727 rng = rng.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
1728 let y = ((rng >> 16) as u16) % height;
1729 rng = rng.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
1730 let ch = char::from_u32('A' as u32 + (rng % 26) as u32).unwrap();
1731 buf.set_raw(x, y, Cell::from_char(ch));
1732 }
1733
1734 buf
1735 }
1736
1737 #[test]
1738 fn golden_diff_80x24() {
1739 let old = Buffer::new(80, 24);
1740 let new = build_golden_scene(80, 24, 0x000D_01DE_5EED_0001);
1741
1742 let diff = BufferDiff::compute(&old, &new);
1743 let checksum = fnv1a_hash(diff.changes());
1744
1745 let diff2 = BufferDiff::compute(&old, &new);
1747 assert_eq!(
1748 fnv1a_hash(diff2.changes()),
1749 checksum,
1750 "diff must be deterministic"
1751 );
1752
1753 assert!(
1755 diff.len() >= 160,
1756 "80x24 golden scene should have at least 160 changes (header+status), got {}",
1757 diff.len()
1758 );
1759
1760 let runs = diff.runs();
1761 assert_eq!(runs[0].y, 0, "first run should be header row");
1763 assert_eq!(runs[0].x0, 0);
1764 assert_eq!(runs[0].x1, 79);
1765 assert!(
1766 runs.last().unwrap().y == 23,
1767 "last row should contain status bar"
1768 );
1769 }
1770
1771 #[test]
1772 fn golden_diff_120x40() {
1773 let old = Buffer::new(120, 40);
1774 let new = build_golden_scene(120, 40, 0x000D_01DE_5EED_0002);
1775
1776 let diff = BufferDiff::compute(&old, &new);
1777 let checksum = fnv1a_hash(diff.changes());
1778
1779 let diff2 = BufferDiff::compute(&old, &new);
1781 assert_eq!(fnv1a_hash(diff2.changes()), checksum);
1782
1783 assert!(
1785 diff.len() >= 240,
1786 "120x40 golden scene should have >=240 changes, got {}",
1787 diff.len()
1788 );
1789
1790 let dirty = BufferDiff::compute_dirty(&old, &new);
1792 assert_eq!(
1793 fnv1a_hash(dirty.changes()),
1794 checksum,
1795 "dirty diff must produce identical changes"
1796 );
1797 }
1798
1799 #[test]
1800 fn golden_sparse_update() {
1801 let old = build_golden_scene(80, 24, 0x000D_01DE_5EED_0003);
1803 let mut new = old.clone();
1804
1805 new.set_raw(10, 5, Cell::from_char('!'));
1807 new.set_raw(11, 5, Cell::from_char('@'));
1808 new.set_raw(40, 12, Cell::from_char('#'));
1809 new.set_raw(70, 20, Cell::from_char('$'));
1810 new.set_raw(0, 23, Cell::from_char('%'));
1811
1812 let diff = BufferDiff::compute(&old, &new);
1813 let checksum = fnv1a_hash(diff.changes());
1814
1815 let diff2 = BufferDiff::compute(&old, &new);
1817 assert_eq!(fnv1a_hash(diff2.changes()), checksum);
1818
1819 assert!(
1821 diff.len() <= 5,
1822 "sparse update should have <=5 changes, got {}",
1823 diff.len()
1824 );
1825 assert!(
1826 diff.len() >= 3,
1827 "sparse update should have >=3 changes, got {}",
1828 diff.len()
1829 );
1830 }
1831
1832 #[test]
1837 fn e2e_random_scene_replay() {
1838 let width = 80u16;
1841 let height = 24u16;
1842 let base_seed: u64 = 0x5C3E_E3E1_A442u64;
1843
1844 let mut checksums = Vec::new();
1845
1846 for frame in 0..10u64 {
1847 let seed = base_seed.wrapping_add(frame.wrapping_mul(0x9E37_79B9_7F4A_7C15));
1848 let old = build_golden_scene(width, height, seed);
1849 let new = build_golden_scene(width, height, seed.wrapping_add(1));
1850
1851 let diff = BufferDiff::compute(&old, &new);
1852 let dirty_diff = BufferDiff::compute_dirty(&old, &new);
1853
1854 assert_eq!(
1856 diff.changes(),
1857 dirty_diff.changes(),
1858 "frame {frame}: dirty diff must match full diff"
1859 );
1860
1861 checksums.push(fnv1a_hash(diff.changes()));
1862 }
1863
1864 for frame in 0..10u64 {
1866 let seed = base_seed.wrapping_add(frame.wrapping_mul(0x9E37_79B9_7F4A_7C15));
1867 let old = build_golden_scene(width, height, seed);
1868 let new = build_golden_scene(width, height, seed.wrapping_add(1));
1869
1870 let diff = BufferDiff::compute(&old, &new);
1871 assert_eq!(
1872 fnv1a_hash(diff.changes()),
1873 checksums[frame as usize],
1874 "frame {frame}: checksum mismatch on replay"
1875 );
1876 }
1877 }
1878
1879 #[test]
1884 fn perf_diff_microbench() {
1885 use std::time::Instant;
1886
1887 let scenarios: &[(u16, u16, &str, u64)] = &[
1888 (80, 24, "full_frame", 0xBE4C_0001u64),
1889 (80, 24, "sparse_update", 0xBE4C_0002u64),
1890 (120, 40, "full_frame", 0xBE4C_0003u64),
1891 (120, 40, "sparse_update", 0xBE4C_0004u64),
1892 ];
1893
1894 let iterations = std::env::var("FTUI_DIFF_BENCH_ITERS")
1895 .ok()
1896 .and_then(|value| value.parse::<u32>().ok())
1897 .unwrap_or(50u32);
1898
1899 for &(width, height, scene_type, seed) in scenarios {
1900 let old = Buffer::new(width, height);
1901 let new = match scene_type {
1902 "full_frame" => build_golden_scene(width, height, seed),
1903 "sparse_update" => {
1904 let mut buf = old.clone();
1905 buf.set_raw(10, 5, Cell::from_char('!'));
1906 buf.set_raw(40, 12, Cell::from_char('#'));
1907 buf.set_raw(70 % width, 20 % height, Cell::from_char('$'));
1908 buf
1909 }
1910 _ => unreachable!(),
1911 };
1912
1913 let mut times_us = Vec::with_capacity(iterations as usize);
1914 let mut last_changes = 0usize;
1915 let mut last_runs = 0usize;
1916 let mut last_checksum = 0u64;
1917
1918 for _ in 0..iterations {
1919 let start = Instant::now();
1920 let diff = BufferDiff::compute(&old, &new);
1921 let runs = diff.runs();
1922 let elapsed = start.elapsed();
1923
1924 last_changes = diff.len();
1925 last_runs = runs.len();
1926 last_checksum = fnv1a_hash(diff.changes());
1927 times_us.push(elapsed.as_micros() as u64);
1928 }
1929
1930 times_us.sort();
1931 let len = times_us.len();
1932 let p50 = times_us[len / 2];
1933 let p95 = times_us[((len as f64 * 0.95) as usize).min(len.saturating_sub(1))];
1934 let p99 = times_us[((len as f64 * 0.99) as usize).min(len.saturating_sub(1))];
1935
1936 eprintln!(
1938 "{{\"ts\":\"2026-02-03T00:00:00Z\",\"seed\":{},\"width\":{},\"height\":{},\"scene\":\"{}\",\"changes\":{},\"runs\":{},\"p50_us\":{},\"p95_us\":{},\"p99_us\":{},\"checksum\":\"0x{:016x}\"}}",
1939 seed,
1940 width,
1941 height,
1942 scene_type,
1943 last_changes,
1944 last_runs,
1945 p50,
1946 p95,
1947 p99,
1948 last_checksum
1949 );
1950
1951 let budget_us = match (width, height) {
1954 (80, 24) => 500, (120, 40) => 1000, _ => 2000,
1957 };
1958
1959 for _ in 0..3 {
1961 let diff = BufferDiff::compute(&old, &new);
1962 assert_eq!(
1963 fnv1a_hash(diff.changes()),
1964 last_checksum,
1965 "diff must be deterministic"
1966 );
1967 }
1968
1969 if p95 > budget_us {
1971 eprintln!(
1972 "WARN: {scene_type} {width}x{height} p95={p95}µs exceeds budget {budget_us}µs"
1973 );
1974 }
1975 }
1976 }
1977
1978 #[test]
1983 fn perf_dirty_diff_large_screen_regression() {
1984 use std::time::Instant;
1985
1986 let iterations = std::env::var("FTUI_DIFF_BENCH_ITERS")
1987 .ok()
1988 .and_then(|value| value.parse::<u32>().ok())
1989 .unwrap_or(50u32);
1990
1991 let max_slowdown = std::env::var("FTUI_DIRTY_DIFF_MAX_SLOWDOWN")
1992 .ok()
1993 .and_then(|value| value.parse::<f64>().ok())
1994 .unwrap_or(2.0);
1995
1996 let cases: &[(u16, u16, &str, f64)] = &[
1997 (200, 60, "sparse_5pct", 5.0),
1998 (240, 80, "sparse_5pct", 5.0),
1999 (200, 60, "single_row", 0.0),
2000 (240, 80, "single_row", 0.0),
2001 ];
2002
2003 for &(width, height, pattern, pct) in cases {
2004 let old = Buffer::new(width, height);
2005 let mut new = old.clone();
2006
2007 if pattern == "single_row" {
2008 for x in 0..width {
2009 new.set_raw(x, 0, Cell::from_char('X'));
2010 }
2011 } else {
2012 let total = width as usize * height as usize;
2013 let to_change = ((total as f64) * pct / 100.0) as usize;
2014 for i in 0..to_change {
2015 let x = (i * 7 + 3) as u16 % width;
2016 let y = (i * 11 + 5) as u16 % height;
2017 let ch = char::from_u32(('A' as u32) + (i as u32 % 26)).unwrap();
2018 new.set_raw(x, y, Cell::from_char(ch));
2019 }
2020 }
2021
2022 let full = BufferDiff::compute(&old, &new);
2024 let dirty = BufferDiff::compute_dirty(&old, &new);
2025 let change_count = full.len();
2026 let dirty_rows = new.dirty_row_count();
2027 assert_eq!(
2028 full.changes(),
2029 dirty.changes(),
2030 "dirty diff must match full diff for {width}x{height} {pattern}"
2031 );
2032
2033 let mut full_times = Vec::with_capacity(iterations as usize);
2034 let mut dirty_times = Vec::with_capacity(iterations as usize);
2035
2036 for _ in 0..iterations {
2037 let start = Instant::now();
2038 let diff = BufferDiff::compute(&old, &new);
2039 std::hint::black_box(diff.len());
2040 full_times.push(start.elapsed().as_micros() as u64);
2041
2042 let start = Instant::now();
2043 let diff = BufferDiff::compute_dirty(&old, &new);
2044 std::hint::black_box(diff.len());
2045 dirty_times.push(start.elapsed().as_micros() as u64);
2046 }
2047
2048 full_times.sort();
2049 dirty_times.sort();
2050
2051 let len = full_times.len();
2052 let p50_idx = len / 2;
2053 let p95_idx = ((len as f64 * 0.95) as usize).min(len.saturating_sub(1));
2054
2055 let full_p50 = full_times[p50_idx];
2056 let full_p95 = full_times[p95_idx];
2057 let dirty_p50 = dirty_times[p50_idx];
2058 let dirty_p95 = dirty_times[p95_idx];
2059
2060 let denom = full_p50.max(1) as f64;
2061 let ratio = dirty_p50 as f64 / denom;
2062
2063 eprintln!(
2064 "{{\"ts\":\"2026-02-03T00:00:00Z\",\"event\":\"diff_regression\",\"width\":{},\"height\":{},\"pattern\":\"{}\",\"iterations\":{},\"changes\":{},\"dirty_rows\":{},\"full_p50_us\":{},\"full_p95_us\":{},\"dirty_p50_us\":{},\"dirty_p95_us\":{},\"slowdown_ratio\":{:.3},\"max_slowdown\":{}}}",
2065 width,
2066 height,
2067 pattern,
2068 iterations,
2069 change_count,
2070 dirty_rows,
2071 full_p50,
2072 full_p95,
2073 dirty_p50,
2074 dirty_p95,
2075 ratio,
2076 max_slowdown
2077 );
2078
2079 assert!(
2080 ratio <= max_slowdown,
2081 "dirty diff regression: {width}x{height} {pattern} ratio {ratio:.2} exceeds {max_slowdown}"
2082 );
2083 }
2084 }
2085
2086 #[test]
2087 fn tile_diff_matches_compute_for_sparse_tiles() {
2088 let width = 200;
2089 let height = 60;
2090 let old = Buffer::new(width, height);
2091 let mut new = old.clone();
2092
2093 new.clear_dirty();
2094 for x in 0..10u16 {
2095 new.set_raw(x, 0, Cell::from_char('X'));
2096 }
2097
2098 let full = BufferDiff::compute(&old, &new);
2099 let dirty = BufferDiff::compute_dirty(&old, &new);
2100
2101 assert_eq!(full.changes(), dirty.changes());
2102 let stats = dirty
2103 .last_tile_stats()
2104 .expect("tile stats should be recorded");
2105 assert!(
2106 stats.fallback.is_none(),
2107 "tile path should be used for sparse tiles"
2108 );
2109 }
2110
2111 fn make_dirty_buffer(width: u16, height: u16, changes: &[(u16, u16, char)]) -> Buffer {
2112 let mut buffer = Buffer::new(width, height);
2113 buffer.clear_dirty();
2114 for &(x, y, ch) in changes {
2115 buffer.set_raw(x, y, Cell::from_char(ch));
2116 }
2117 buffer
2118 }
2119
2120 fn tile_stats_for_config(old: &Buffer, new: &Buffer, config: TileDiffConfig) -> TileDiffStats {
2121 let mut diff = BufferDiff::new();
2122 *diff.tile_config_mut() = config;
2123 diff.compute_dirty_into(old, new);
2124 diff.last_tile_stats()
2125 .expect("tile stats should be recorded")
2126 }
2127
2128 #[test]
2129 fn tile_fallback_disabled_when_config_off() {
2130 let width = 64;
2131 let height = 32;
2132 let old = Buffer::new(width, height);
2133 let new = make_dirty_buffer(width, height, &[(0, 0, 'X')]);
2134
2135 let config = TileDiffConfig {
2136 enabled: false,
2137 min_cells_for_tiles: 0,
2138 dense_cell_ratio: 1.1,
2139 dense_tile_ratio: 1.1,
2140 max_tiles: usize::MAX,
2141 ..Default::default()
2142 };
2143
2144 let stats = tile_stats_for_config(&old, &new, config);
2145 assert_eq!(stats.fallback, Some(TileDiffFallback::Disabled));
2146 }
2147
2148 #[test]
2149 fn tile_fallback_small_screen_when_below_threshold() {
2150 let width = 64;
2151 let height = 32;
2152 let old = Buffer::new(width, height);
2153 let new = make_dirty_buffer(width, height, &[(1, 2, 'Y')]);
2154
2155 let config = TileDiffConfig {
2156 enabled: true,
2157 min_cells_for_tiles: width as usize * height as usize + 1,
2158 dense_cell_ratio: 1.1,
2159 dense_tile_ratio: 1.1,
2160 max_tiles: usize::MAX,
2161 ..Default::default()
2162 };
2163
2164 let stats = tile_stats_for_config(&old, &new, config);
2165 assert_eq!(stats.fallback, Some(TileDiffFallback::SmallScreen));
2166 }
2167
2168 #[test]
2169 fn tile_fallback_too_many_tiles_when_budget_exceeded() {
2170 let width = 64;
2171 let height = 64;
2172 let old = Buffer::new(width, height);
2173 let new = make_dirty_buffer(width, height, &[(2, 3, 'Z')]);
2174
2175 let config = TileDiffConfig {
2176 enabled: true,
2177 tile_w: 8,
2178 tile_h: 8,
2179 min_cells_for_tiles: 0,
2180 dense_cell_ratio: 1.1,
2181 dense_tile_ratio: 1.1,
2182 max_tiles: 4,
2183 };
2184
2185 let stats = tile_stats_for_config(&old, &new, config);
2186 assert_eq!(stats.fallback, Some(TileDiffFallback::TooManyTiles));
2187 }
2188
2189 #[test]
2190 fn tile_fallback_dense_tiles_when_ratio_exceeded() {
2191 let width = 64;
2192 let height = 32;
2193 let old = Buffer::new(width, height);
2194 let new = make_dirty_buffer(width, height, &[(0, 0, 'A'), (24, 0, 'B')]);
2195
2196 let config = TileDiffConfig {
2197 enabled: true,
2198 tile_w: 8,
2199 tile_h: 8,
2200 min_cells_for_tiles: 0,
2201 dense_cell_ratio: 1.1,
2202 dense_tile_ratio: 0.05,
2203 max_tiles: usize::MAX / 4,
2204 };
2205
2206 let stats = tile_stats_for_config(&old, &new, config);
2207 assert_eq!(stats.fallback, Some(TileDiffFallback::DenseTiles));
2208 }
2209
2210 fn lcg_next(state: &mut u64) -> u64 {
2211 *state = state
2212 .wrapping_mul(6364136223846793005)
2213 .wrapping_add(1442695040888963407);
2214 *state
2215 }
2216
2217 fn apply_random_changes(buf: &mut Buffer, seed: u64, count: usize) {
2218 let width = buf.width().max(1) as u64;
2219 let height = buf.height().max(1) as u64;
2220 let mut state = seed;
2221 for i in 0..count {
2222 let v = lcg_next(&mut state);
2223 let x = (v % width) as u16;
2224 let y = ((v >> 32) % height) as u16;
2225 let ch = char::from_u32(('A' as u32) + ((i as u32) % 26)).unwrap();
2226 buf.set_raw(x, y, Cell::from_char(ch));
2227 }
2228 }
2229
2230 fn tile_diag(stats: &TileDiffStats) -> String {
2231 let tile_size = stats.tile_w as usize * stats.tile_h as usize;
2232 format!(
2233 "tile_size={tile_size}, dirty_tiles={}, skipped_tiles={}, dirty_cells={}, dirty_tile_ratio={:.3}, dirty_cell_ratio={:.3}, scanned_tiles={}, fallback={:?}",
2234 stats.dirty_tiles,
2235 stats.skipped_tiles,
2236 stats.dirty_cells,
2237 stats.dirty_tile_ratio,
2238 stats.dirty_cell_ratio,
2239 stats.scanned_tiles,
2240 stats.fallback
2241 )
2242 }
2243
2244 fn diff_with_forced_tiles(old: &Buffer, new: &Buffer) -> (BufferDiff, TileDiffStats) {
2245 let mut diff = BufferDiff::new();
2246 {
2247 let config = diff.tile_config_mut();
2248 config.enabled = true;
2249 config.tile_w = 8;
2250 config.tile_h = 8;
2251 config.min_cells_for_tiles = 0;
2252 config.dense_cell_ratio = 1.1;
2253 config.dense_tile_ratio = 1.1;
2254 config.max_tiles = usize::MAX / 4;
2255 }
2256 diff.compute_dirty_into(old, new);
2257 let stats = diff
2258 .last_tile_stats()
2259 .expect("tile stats should be recorded");
2260 (diff, stats)
2261 }
2262
2263 fn assert_tile_diff_equivalence(old: &Buffer, new: &Buffer, label: &str) {
2264 let full = BufferDiff::compute(old, new);
2265 let (dirty, stats) = diff_with_forced_tiles(old, new);
2266 let diag = tile_diag(&stats);
2267 assert!(
2268 stats.fallback.is_none(),
2269 "tile diff fallback ({label}) {w}x{h}: {diag}",
2270 w = old.width(),
2271 h = old.height()
2272 );
2273 assert!(
2274 full.changes() == dirty.changes(),
2275 "tile diff mismatch ({label}) {w}x{h}: {diag}",
2276 w = old.width(),
2277 h = old.height()
2278 );
2279 }
2280
2281 #[test]
2282 fn tile_diff_equivalence_small_and_odd_sizes() {
2283 let cases: &[(u16, u16, usize)] = &[
2284 (1, 1, 1),
2285 (2, 1, 1),
2286 (1, 2, 1),
2287 (5, 3, 4),
2288 (7, 13, 12),
2289 (15, 9, 20),
2290 (31, 5, 12),
2291 ];
2292
2293 for (idx, &(width, height, changes)) in cases.iter().enumerate() {
2294 let old = Buffer::new(width, height);
2295 let mut new = old.clone();
2296 new.clear_dirty();
2297 apply_random_changes(&mut new, 0xC0FFEE_u64 + idx as u64, changes);
2298 assert_tile_diff_equivalence(&old, &new, "small_odd");
2299 }
2300 }
2301
2302 #[test]
2303 fn tile_diff_equivalence_large_sparse_random() {
2304 let cases: &[(u16, u16)] = &[(200, 60), (240, 80)];
2305 for (idx, &(width, height)) in cases.iter().enumerate() {
2306 let old = Buffer::new(width, height);
2307 let mut new = old.clone();
2308 new.clear_dirty();
2309 let total = width as usize * height as usize;
2310 let changes = (total / 100).max(1);
2311 apply_random_changes(&mut new, 0xDEADBEEF_u64 + idx as u64, changes);
2312 assert_tile_diff_equivalence(&old, &new, "large_sparse");
2313 }
2314 }
2315
2316 #[test]
2317 fn tile_diff_equivalence_row_and_full_buffer() {
2318 let width = 200u16;
2319 let height = 60u16;
2320 let old = Buffer::new(width, height);
2321
2322 let mut row = old.clone();
2323 row.clear_dirty();
2324 for x in 0..width {
2325 row.set_raw(x, 0, Cell::from_char('R'));
2326 }
2327 assert_tile_diff_equivalence(&old, &row, "single_row");
2328
2329 let mut full = old.clone();
2330 full.clear_dirty();
2331 for y in 0..height {
2332 for x in 0..width {
2333 full.set_raw(x, y, Cell::from_char('F'));
2334 }
2335 }
2336 assert_tile_diff_equivalence(&old, &full, "full_buffer");
2337 }
2338}
2339
2340#[cfg(test)]
2341mod proptests {
2342 use super::*;
2343 use crate::cell::Cell;
2344 use ftui_core::geometry::Rect;
2345 use proptest::prelude::*;
2346
2347 #[test]
2349 fn diff_apply_produces_target() {
2350 proptest::proptest!(|(
2351 width in 5u16..50,
2352 height in 5u16..30,
2353 num_changes in 0usize..200,
2354 )| {
2355 let old = Buffer::new(width, height);
2357
2358 let mut new = old.clone();
2360 for i in 0..num_changes {
2361 let x = (i * 7 + 3) as u16 % width;
2362 let y = (i * 11 + 5) as u16 % height;
2363 let ch = char::from_u32(('A' as u32) + (i as u32 % 26)).unwrap();
2364 new.set_raw(x, y, Cell::from_char(ch));
2365 }
2366
2367 let diff = BufferDiff::compute(&old, &new);
2369
2370 let mut result = old.clone();
2372 for (x, y) in diff.iter() {
2373 let cell = *new.get_unchecked(x, y);
2374 result.set_raw(x, y, cell);
2375 }
2376
2377 for y in 0..height {
2379 for x in 0..width {
2380 let result_cell = result.get_unchecked(x, y);
2381 let new_cell = new.get_unchecked(x, y);
2382 prop_assert!(
2383 result_cell.bits_eq(new_cell),
2384 "Mismatch at ({}, {})",
2385 x,
2386 y
2387 );
2388 }
2389 }
2390 });
2391 }
2392
2393 #[test]
2395 fn identical_buffers_empty_diff() {
2396 proptest::proptest!(|(width in 1u16..100, height in 1u16..50)| {
2397 let buf = Buffer::new(width, height);
2398 let diff = BufferDiff::compute(&buf, &buf);
2399 prop_assert!(diff.is_empty(), "Identical buffers should have empty diff");
2400 });
2401 }
2402
2403 #[test]
2405 fn diff_contains_only_real_changes() {
2406 proptest::proptest!(|(
2407 width in 5u16..50,
2408 height in 5u16..30,
2409 num_changes in 0usize..100,
2410 )| {
2411 let old = Buffer::new(width, height);
2412 let mut new = old.clone();
2413
2414 for i in 0..num_changes {
2415 let x = (i * 7 + 3) as u16 % width;
2416 let y = (i * 11 + 5) as u16 % height;
2417 new.set_raw(x, y, Cell::from_char('X'));
2418 }
2419
2420 let diff = BufferDiff::compute(&old, &new);
2421
2422 for (x, y) in diff.iter() {
2424 let old_cell = old.get_unchecked(x, y);
2425 let new_cell = new.get_unchecked(x, y);
2426 prop_assert!(
2427 !old_cell.bits_eq(new_cell),
2428 "Diff includes unchanged cell at ({}, {})",
2429 x,
2430 y
2431 );
2432 }
2433 });
2434 }
2435
2436 #[test]
2438 fn runs_are_contiguous() {
2439 proptest::proptest!(|(width in 10u16..80, height in 5u16..30)| {
2440 let old = Buffer::new(width, height);
2441 let mut new = old.clone();
2442
2443 for y in 0..height.min(5) {
2445 for x in 0..width.min(10) {
2446 new.set_raw(x, y, Cell::from_char('#'));
2447 }
2448 }
2449
2450 let diff = BufferDiff::compute(&old, &new);
2451 let runs = diff.runs();
2452
2453 for run in runs {
2455 prop_assert!(run.x1 >= run.x0, "Run has invalid range");
2456 prop_assert!(!run.is_empty(), "Run should not be empty");
2457
2458 for x in run.x0..=run.x1 {
2460 let old_cell = old.get_unchecked(x, run.y);
2461 let new_cell = new.get_unchecked(x, run.y);
2462 prop_assert!(
2463 !old_cell.bits_eq(new_cell),
2464 "Run includes unchanged cell at ({}, {})",
2465 x,
2466 run.y
2467 );
2468 }
2469 }
2470 });
2471 }
2472
2473 #[test]
2475 fn runs_cover_all_changes() {
2476 proptest::proptest!(|(
2477 width in 10u16..60,
2478 height in 5u16..30,
2479 num_changes in 1usize..100,
2480 )| {
2481 let old = Buffer::new(width, height);
2482 let mut new = old.clone();
2483
2484 for i in 0..num_changes {
2485 let x = (i * 13 + 7) as u16 % width;
2486 let y = (i * 17 + 3) as u16 % height;
2487 new.set_raw(x, y, Cell::from_char('X'));
2488 }
2489
2490 let diff = BufferDiff::compute(&old, &new);
2491 let runs = diff.runs();
2492
2493 let mut run_cells: std::collections::HashSet<(u16, u16)> =
2495 std::collections::HashSet::new();
2496 for run in &runs {
2497 for x in run.x0..=run.x1 {
2498 let was_new = run_cells.insert((x, run.y));
2499 prop_assert!(was_new, "Duplicate cell ({}, {}) in runs", x, run.y);
2500 }
2501 }
2502
2503 for (x, y) in diff.iter() {
2505 prop_assert!(
2506 run_cells.contains(&(x, y)),
2507 "Change at ({}, {}) not covered by runs",
2508 x,
2509 y
2510 );
2511 }
2512
2513 prop_assert_eq!(
2514 run_cells.len(),
2515 diff.len(),
2516 "Run cell count should match diff change count"
2517 );
2518 });
2519 }
2520
2521 #[test]
2525 fn block_scan_matches_scalar() {
2526 proptest::proptest!(|(
2527 width in 1u16..200,
2528 height in 1u16..20,
2529 num_changes in 0usize..200,
2530 )| {
2531 use crate::cell::PackedRgba;
2532
2533 let old = Buffer::new(width, height);
2534 let mut new = Buffer::new(width, height);
2535
2536 for i in 0..num_changes {
2537 let x = (i * 13 + 7) as u16 % width;
2538 let y = (i * 17 + 3) as u16 % height;
2539 let fg = PackedRgba::rgb(
2540 ((i * 31) % 256) as u8,
2541 ((i * 47) % 256) as u8,
2542 ((i * 71) % 256) as u8,
2543 );
2544 new.set_raw(x, y, Cell::from_char('X').with_fg(fg));
2545 }
2546
2547 let diff = BufferDiff::compute(&old, &new);
2548
2549 let mut scalar_changes = Vec::new();
2551 for y in 0..height {
2552 for x in 0..width {
2553 let old_cell = old.get_unchecked(x, y);
2554 let new_cell = new.get_unchecked(x, y);
2555 if !old_cell.bits_eq(new_cell) {
2556 scalar_changes.push((x, y));
2557 }
2558 }
2559 }
2560
2561 prop_assert_eq!(
2562 diff.changes(),
2563 &scalar_changes[..],
2564 "Block scan should match scalar scan"
2565 );
2566 });
2567 }
2568
2569 #[test]
2575 fn property_diff_equivalence() {
2576 proptest::proptest!(|(
2577 width in 1u16..120,
2578 height in 1u16..40,
2579 num_changes in 0usize..300,
2580 )| {
2581 let old = Buffer::new(width, height);
2582 let mut new = Buffer::new(width, height);
2583
2584 for i in 0..num_changes {
2586 let x = (i * 13 + 7) as u16 % width;
2587 let y = (i * 17 + 3) as u16 % height;
2588 let ch = char::from_u32(('A' as u32) + (i as u32 % 26)).unwrap();
2589 let fg = crate::cell::PackedRgba::rgb(
2590 ((i * 31) % 256) as u8,
2591 ((i * 47) % 256) as u8,
2592 ((i * 71) % 256) as u8,
2593 );
2594 new.set_raw(x, y, Cell::from_char(ch).with_fg(fg));
2595 }
2596
2597 let full = BufferDiff::compute(&old, &new);
2598 let dirty = BufferDiff::compute_dirty(&old, &new);
2599
2600 prop_assert_eq!(
2601 full.changes(),
2602 dirty.changes(),
2603 "dirty diff must match full diff (width={}, height={}, changes={})",
2604 width,
2605 height,
2606 num_changes
2607 );
2608
2609 let full_runs = full.runs();
2611 let dirty_runs = dirty.runs();
2612 prop_assert_eq!(full_runs.len(), dirty_runs.len(), "run count must match");
2613 for (fr, dr) in full_runs.iter().zip(dirty_runs.iter()) {
2614 prop_assert_eq!(fr, dr, "run mismatch");
2615 }
2616 });
2617 }
2618
2619 #[test]
2622 fn property_diff_equivalence_complex_spans() {
2623 proptest::proptest!(|(
2624 width in 10u16..100,
2625 height in 10u16..50,
2626 ops in proptest::collection::vec(
2627 prop_oneof![
2628 (Just(0u8), any::<u16>(), any::<u16>(), any::<char>()),
2630 (Just(1u8), any::<u16>(), any::<u16>(), any::<char>()),
2632 ],
2633 1..50
2634 )
2635 )| {
2636 let old = Buffer::new(width, height);
2637 let mut new = Buffer::new(width, height);
2638
2639 new.clear_dirty();
2641
2642 for (op_type, x, y, ch) in ops {
2643 let x = x % width;
2644 let y = y % height;
2645 let cell = Cell::from_char(ch);
2646
2647 match op_type {
2648 0 => new.set(x, y, cell),
2649 1 => {
2650 let w = ((x + 10).min(width) - x).max(1);
2652 let h = ((y + 5).min(height) - y).max(1);
2653 new.fill(Rect::new(x, y, w, h), cell);
2654 }
2655 _ => unreachable!(),
2656 }
2657 }
2658
2659 let full = BufferDiff::compute(&old, &new);
2660 let dirty = BufferDiff::compute_dirty(&old, &new);
2661
2662 prop_assert_eq!(
2663 full.changes(),
2664 dirty.changes(),
2665 "dirty diff (spans) must match full diff; {}",
2666 super::span_diagnostics(&new)
2667 );
2668 });
2669 }
2670
2671 #[test]
2679 fn diff_is_idempotent() {
2680 proptest::proptest!(|(
2681 width in 5u16..60,
2682 height in 5u16..30,
2683 num_changes in 0usize..100,
2684 )| {
2685 let mut buf_a = Buffer::new(width, height);
2686 let mut buf_b = Buffer::new(width, height);
2687
2688 for i in 0..num_changes {
2690 let x = (i * 13 + 7) as u16 % width;
2691 let y = (i * 17 + 3) as u16 % height;
2692 buf_b.set_raw(x, y, Cell::from_char('X'));
2693 }
2694
2695 let diff = BufferDiff::compute(&buf_a, &buf_b);
2697
2698 for (x, y) in diff.iter() {
2700 let cell = *buf_b.get_unchecked(x, y);
2701 buf_a.set_raw(x, y, cell);
2702 }
2703
2704 let diff_after_first = BufferDiff::compute(&buf_a, &buf_b);
2706 prop_assert!(
2707 diff_after_first.is_empty(),
2708 "After applying diff once, buffers should be identical (diff was {} changes)",
2709 diff_after_first.len()
2710 );
2711
2712 let before_second = buf_a.clone();
2714 for (x, y) in diff.iter() {
2715 let cell = *buf_b.get_unchecked(x, y);
2716 buf_a.set_raw(x, y, cell);
2717 }
2718
2719 let diff_after_second = BufferDiff::compute(&before_second, &buf_a);
2721 prop_assert!(
2722 diff_after_second.is_empty(),
2723 "Second diff application should be a no-op"
2724 );
2725 });
2726 }
2727
2728 #[test]
2741 fn no_ghosting_after_clear() {
2742 proptest::proptest!(|(
2743 width in 10u16..80,
2744 height in 5u16..30,
2745 num_content_cells in 1usize..200,
2746 )| {
2747 let old = Buffer::new(width, height);
2749
2750 let mut new = Buffer::new(width, height);
2752 let mut expected_changes = std::collections::HashSet::new();
2753
2754 for i in 0..num_content_cells {
2755 let x = (i * 13 + 7) as u16 % width;
2756 let y = (i * 17 + 3) as u16 % height;
2757 new.set_raw(x, y, Cell::from_char('#'));
2758 expected_changes.insert((x, y));
2759 }
2760
2761 let diff = BufferDiff::compute(&old, &new);
2762
2763 for (x, y) in expected_changes {
2766 let in_diff = diff.iter().any(|(dx, dy)| dx == x && dy == y);
2767 prop_assert!(
2768 in_diff,
2769 "Content cell at ({}, {}) missing from diff - would ghost",
2770 x,
2771 y
2772 );
2773 }
2774
2775 for (x, y) in diff.iter() {
2777 let old_cell = old.get_unchecked(x, y);
2778 let new_cell = new.get_unchecked(x, y);
2779 prop_assert!(
2780 !old_cell.bits_eq(new_cell),
2781 "Diff includes unchanged cell at ({}, {})",
2782 x,
2783 y
2784 );
2785 }
2786 });
2787 }
2788
2789 #[test]
2797 fn diff_changes_are_monotonic() {
2798 proptest::proptest!(|(
2799 width in 10u16..80,
2800 height in 5u16..30,
2801 num_changes in 1usize..200,
2802 )| {
2803 let old = Buffer::new(width, height);
2804 let mut new = old.clone();
2805
2806 for i in 0..num_changes {
2808 let x = (i * 37 + 11) as u16 % width;
2809 let y = (i * 53 + 7) as u16 % height;
2810 new.set_raw(x, y, Cell::from_char('M'));
2811 }
2812
2813 let diff = BufferDiff::compute(&old, &new);
2814 let changes: Vec<_> = diff.iter().collect();
2815
2816 for window in changes.windows(2) {
2818 let (x1, y1) = window[0];
2819 let (x2, y2) = window[1];
2820
2821 let is_monotonic = y1 < y2 || (y1 == y2 && x1 < x2);
2822 prop_assert!(
2823 is_monotonic,
2824 "Changes not monotonic: ({}, {}) should come before ({}, {})",
2825 x1,
2826 y1,
2827 x2,
2828 y2
2829 );
2830 }
2831 });
2832 }
2833}
2834
2835#[cfg(test)]
2836mod span_edge_cases {
2837 use super::*;
2838 use crate::cell::Cell;
2839 use proptest::prelude::*;
2840
2841 #[test]
2842 fn test_span_diff_u16_max_width() {
2843 let width = 65000;
2845 let height = 1;
2846 let old = Buffer::new(width, height);
2847 let mut new = Buffer::new(width, height);
2848
2849 new.clear_dirty();
2851
2852 new.set_raw(0, 0, Cell::from_char('A'));
2854 new.set_raw(32500, 0, Cell::from_char('B'));
2855 new.set_raw(64999, 0, Cell::from_char('C'));
2856
2857 let full = BufferDiff::compute(&old, &new);
2858 let dirty = BufferDiff::compute_dirty(&old, &new);
2859
2860 assert_eq!(full.changes(), dirty.changes());
2861 assert_eq!(full.len(), 3);
2862
2863 let changes = full.changes();
2865 assert!(changes.contains(&(0, 0)));
2866 assert!(changes.contains(&(32500, 0)));
2867 assert!(changes.contains(&(64999, 0)));
2868 }
2869
2870 #[test]
2871 fn test_span_full_row_dirty_overflow() {
2872 let width = 1000;
2873 let height = 1;
2874 let old = Buffer::new(width, height);
2875 let mut new = Buffer::new(width, height);
2876 new.clear_dirty(); for i in 0..70 {
2881 let x = (i * 10) as u16;
2882 new.set_raw(x, 0, Cell::from_char('X'));
2883 }
2884
2885 let stats = new.dirty_span_stats();
2887 assert!(
2888 stats.rows_full_dirty > 0,
2889 "Should have overflowed to full row"
2890 );
2891 assert_eq!(
2892 stats.rows_with_spans, 0,
2893 "Should have cleared spans on overflow"
2894 );
2895
2896 let full = BufferDiff::compute(&old, &new);
2897 let dirty = BufferDiff::compute_dirty(&old, &new);
2898
2899 assert_eq!(full.changes(), dirty.changes());
2900 assert_eq!(full.len(), 70);
2901 }
2902
2903 #[test]
2904 fn test_span_diff_empty_rows() {
2905 let width = 100;
2906 let height = 10;
2907 let old = Buffer::new(width, height);
2908 let mut new = Buffer::new(width, height);
2909 new.clear_dirty(); let dirty = BufferDiff::compute_dirty(&old, &new);
2913 assert!(dirty.is_empty());
2914 }
2915
2916 proptest! {
2917 #[test]
2918 fn property_span_diff_equivalence_large(
2919 width in 1000u16..5000,
2920 height in 10u16..50,
2921 changes in proptest::collection::vec((0u16..5000, 0u16..50), 0..100)
2922 ) {
2923 let w = width.min(2000);
2925 let h = height.min(50);
2926
2927 let old = Buffer::new(w, h);
2928 let mut new = Buffer::new(w, h);
2929 new.clear_dirty();
2930
2931 for (raw_x, raw_y) in changes {
2933 let x = raw_x % w;
2934 let y = raw_y % h;
2935 new.set_raw(x, y, Cell::from_char('Z'));
2936 }
2937
2938 let full = BufferDiff::compute(&old, &new);
2939 let dirty = BufferDiff::compute_dirty(&old, &new);
2940
2941 prop_assert_eq!(
2942 full.changes(),
2943 dirty.changes(),
2944 "Large buffer mismatch: w={}, h={}, {}",
2945 w,
2946 h,
2947 super::span_diagnostics(&new)
2948 );
2949 }
2950 }
2951}