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 use super::*;
1099 use crate::cell::{Cell, PackedRgba};
1100
1101 #[test]
1102 fn empty_diff_when_buffers_identical() {
1103 let buf1 = Buffer::new(10, 10);
1104 let buf2 = Buffer::new(10, 10);
1105 let diff = BufferDiff::compute(&buf1, &buf2);
1106
1107 assert!(diff.is_empty());
1108 assert_eq!(diff.len(), 0);
1109 }
1110
1111 #[test]
1112 fn full_diff_marks_all_cells() {
1113 let diff = BufferDiff::full(3, 2);
1114 assert_eq!(diff.len(), 6);
1115 assert_eq!(diff.changes()[0], (0, 0));
1116 assert_eq!(diff.changes()[5], (2, 1));
1117 }
1118
1119 #[test]
1120 fn single_cell_change_detected() {
1121 let old = Buffer::new(10, 10);
1122 let mut new = Buffer::new(10, 10);
1123
1124 new.set_raw(5, 5, Cell::from_char('X'));
1125 let diff = BufferDiff::compute(&old, &new);
1126
1127 assert_eq!(diff.len(), 1);
1128 assert_eq!(diff.changes(), &[(5, 5)]);
1129 }
1130
1131 #[test]
1132 fn dirty_row_false_positive_skipped() {
1133 let old = Buffer::new(8, 2);
1134 let mut new = old.clone();
1135
1136 new.clear_dirty();
1138 new.set_raw(2, 0, Cell::default());
1140
1141 let diff = BufferDiff::compute_dirty(&old, &new);
1142 assert!(
1143 diff.is_empty(),
1144 "dirty row with no changes should be skipped"
1145 );
1146 }
1147
1148 #[test]
1149 fn multiple_scattered_changes_detected() {
1150 let old = Buffer::new(10, 10);
1151 let mut new = Buffer::new(10, 10);
1152
1153 new.set_raw(0, 0, Cell::from_char('A'));
1154 new.set_raw(9, 9, Cell::from_char('B'));
1155 new.set_raw(5, 3, Cell::from_char('C'));
1156
1157 let diff = BufferDiff::compute(&old, &new);
1158
1159 assert_eq!(diff.len(), 3);
1160 let changes = diff.changes();
1162 assert!(changes.contains(&(0, 0)));
1163 assert!(changes.contains(&(9, 9)));
1164 assert!(changes.contains(&(5, 3)));
1165 }
1166
1167 #[test]
1168 fn runs_coalesces_adjacent_cells() {
1169 let old = Buffer::new(10, 10);
1170 let mut new = Buffer::new(10, 10);
1171
1172 new.set_raw(3, 5, Cell::from_char('A'));
1174 new.set_raw(4, 5, Cell::from_char('B'));
1175 new.set_raw(5, 5, Cell::from_char('C'));
1176
1177 let diff = BufferDiff::compute(&old, &new);
1178 let runs = diff.runs();
1179
1180 assert_eq!(runs.len(), 1);
1181 assert_eq!(runs[0].y, 5);
1182 assert_eq!(runs[0].x0, 3);
1183 assert_eq!(runs[0].x1, 5);
1184 assert_eq!(runs[0].len(), 3);
1185 }
1186
1187 #[test]
1188 fn runs_handles_gaps_correctly() {
1189 let old = Buffer::new(10, 10);
1190 let mut new = Buffer::new(10, 10);
1191
1192 new.set_raw(0, 0, Cell::from_char('A'));
1194 new.set_raw(1, 0, Cell::from_char('B'));
1195 new.set_raw(3, 0, Cell::from_char('C'));
1197 new.set_raw(4, 0, Cell::from_char('D'));
1198
1199 let diff = BufferDiff::compute(&old, &new);
1200 let runs = diff.runs();
1201
1202 assert_eq!(runs.len(), 2);
1203
1204 assert_eq!(runs[0].y, 0);
1205 assert_eq!(runs[0].x0, 0);
1206 assert_eq!(runs[0].x1, 1);
1207
1208 assert_eq!(runs[1].y, 0);
1209 assert_eq!(runs[1].x0, 3);
1210 assert_eq!(runs[1].x1, 4);
1211 }
1212
1213 #[test]
1214 fn runs_handles_max_column_without_overflow() {
1215 let mut diff = BufferDiff::new();
1216 diff.changes = vec![(u16::MAX, 0)];
1217
1218 let runs = diff.runs();
1219
1220 assert_eq!(runs.len(), 1);
1221 assert_eq!(runs[0], ChangeRun::new(0, u16::MAX, u16::MAX));
1222 }
1223
1224 #[test]
1225 fn runs_handles_multiple_rows() {
1226 let old = Buffer::new(10, 10);
1227 let mut new = Buffer::new(10, 10);
1228
1229 new.set_raw(0, 0, Cell::from_char('A'));
1231 new.set_raw(1, 0, Cell::from_char('B'));
1232 new.set_raw(5, 2, Cell::from_char('C'));
1233 new.set_raw(0, 5, Cell::from_char('D'));
1234
1235 let diff = BufferDiff::compute(&old, &new);
1236 let runs = diff.runs();
1237
1238 assert_eq!(runs.len(), 3);
1239
1240 assert_eq!(runs[0].y, 0);
1242 assert_eq!(runs[0].x0, 0);
1243 assert_eq!(runs[0].x1, 1);
1244
1245 assert_eq!(runs[1].y, 2);
1247 assert_eq!(runs[1].x0, 5);
1248 assert_eq!(runs[1].x1, 5);
1249
1250 assert_eq!(runs[2].y, 5);
1252 assert_eq!(runs[2].x0, 0);
1253 assert_eq!(runs[2].x1, 0);
1254 }
1255
1256 #[test]
1257 fn empty_runs_from_empty_diff() {
1258 let diff = BufferDiff::new();
1259 let runs = diff.runs();
1260 assert!(runs.is_empty());
1261 }
1262
1263 #[test]
1264 fn change_run_len() {
1265 let run = ChangeRun::new(0, 5, 10);
1266 assert_eq!(run.len(), 6);
1267
1268 let single = ChangeRun::new(0, 5, 5);
1269 assert_eq!(single.len(), 1);
1270 }
1271
1272 #[test]
1273 fn color_changes_detected() {
1274 let old = Buffer::new(10, 10);
1275 let mut new = Buffer::new(10, 10);
1276
1277 new.set_raw(5, 5, Cell::default().with_fg(PackedRgba::rgb(255, 0, 0)));
1279
1280 let diff = BufferDiff::compute(&old, &new);
1281 assert_eq!(diff.len(), 1);
1282 }
1283
1284 #[test]
1285 fn diff_iter() {
1286 let old = Buffer::new(5, 5);
1287 let mut new = Buffer::new(5, 5);
1288 new.set_raw(1, 1, Cell::from_char('X'));
1289 new.set_raw(2, 2, Cell::from_char('Y'));
1290
1291 let diff = BufferDiff::compute(&old, &new);
1292 let positions: Vec<_> = diff.iter().collect();
1293
1294 assert_eq!(positions.len(), 2);
1295 assert!(positions.contains(&(1, 1)));
1296 assert!(positions.contains(&(2, 2)));
1297 }
1298
1299 #[test]
1300 fn diff_clear() {
1301 let old = Buffer::new(5, 5);
1302 let mut new = Buffer::new(5, 5);
1303 new.set_raw(1, 1, Cell::from_char('X'));
1304
1305 let mut diff = BufferDiff::compute(&old, &new);
1306 assert_eq!(diff.len(), 1);
1307
1308 diff.clear();
1309 assert!(diff.is_empty());
1310 }
1311
1312 #[test]
1313 fn with_capacity() {
1314 let diff = BufferDiff::with_capacity(100);
1315 assert!(diff.is_empty());
1316 }
1317
1318 #[test]
1319 fn full_buffer_change() {
1320 let old = Buffer::new(5, 5);
1321 let mut new = Buffer::new(5, 5);
1322
1323 for y in 0..5 {
1325 for x in 0..5 {
1326 new.set_raw(x, y, Cell::from_char('#'));
1327 }
1328 }
1329
1330 let diff = BufferDiff::compute(&old, &new);
1331 assert_eq!(diff.len(), 25);
1332
1333 let runs = diff.runs();
1335 assert_eq!(runs.len(), 5);
1336
1337 for (i, run) in runs.iter().enumerate() {
1338 assert_eq!(run.y, i as u16);
1339 assert_eq!(run.x0, 0);
1340 assert_eq!(run.x1, 4);
1341 assert_eq!(run.len(), 5);
1342 }
1343 }
1344
1345 #[test]
1346 fn row_major_order_preserved() {
1347 let old = Buffer::new(3, 3);
1348 let mut new = Buffer::new(3, 3);
1349
1350 new.set_raw(2, 2, Cell::from_char('C'));
1352 new.set_raw(0, 0, Cell::from_char('A'));
1353 new.set_raw(1, 1, Cell::from_char('B'));
1354
1355 let diff = BufferDiff::compute(&old, &new);
1356
1357 let changes = diff.changes();
1359 assert_eq!(changes[0], (0, 0));
1360 assert_eq!(changes[1], (1, 1));
1361 assert_eq!(changes[2], (2, 2));
1362 }
1363
1364 #[test]
1365 fn blockwise_scan_preserves_sparse_row_changes() {
1366 let old = Buffer::new(64, 2);
1367 let mut new = old.clone();
1368
1369 new.set_raw(1, 0, Cell::from_char('A'));
1370 new.set_raw(33, 0, Cell::from_char('B'));
1371 new.set_raw(62, 1, Cell::from_char('C'));
1372
1373 let diff = BufferDiff::compute(&old, &new);
1374 assert_eq!(diff.changes(), &[(1, 0), (33, 0), (62, 1)]);
1375 }
1376
1377 #[test]
1378 fn rows_with_no_changes_are_skipped() {
1379 let old = Buffer::new(4, 3);
1380 let mut new = old.clone();
1381
1382 new.set_raw(1, 1, Cell::from_char('X'));
1383 new.set_raw(3, 1, Cell::from_char('Y'));
1384
1385 let diff = BufferDiff::compute(&old, &new);
1386 assert_eq!(diff.len(), 2);
1387 assert!(diff.changes().iter().all(|&(_, y)| y == 1));
1388 }
1389
1390 #[test]
1391 fn clear_retains_capacity_for_reuse() {
1392 let mut diff = BufferDiff::with_capacity(16);
1393 diff.changes.extend_from_slice(&[(0, 0), (1, 0), (2, 0)]);
1394 let capacity = diff.changes.capacity();
1395
1396 diff.clear();
1397
1398 assert!(diff.is_empty());
1399 assert!(diff.changes.capacity() >= capacity);
1400 }
1401
1402 #[test]
1403 #[should_panic(expected = "buffer widths must match")]
1404 fn compute_panics_on_width_mismatch() {
1405 let old = Buffer::new(5, 5);
1406 let new = Buffer::new(4, 5);
1407 let _ = BufferDiff::compute(&old, &new);
1408 }
1409
1410 #[test]
1415 fn block_scan_alignment_exact_block() {
1416 let old = Buffer::new(4, 1);
1418 let mut new = Buffer::new(4, 1);
1419 new.set_raw(2, 0, Cell::from_char('X'));
1420
1421 let diff = BufferDiff::compute(&old, &new);
1422 assert_eq!(diff.len(), 1);
1423 assert_eq!(diff.changes(), &[(2, 0)]);
1424 }
1425
1426 #[test]
1427 fn block_scan_alignment_remainder() {
1428 let old = Buffer::new(7, 1);
1430 let mut new = Buffer::new(7, 1);
1431 new.set_raw(1, 0, Cell::from_char('A'));
1433 new.set_raw(5, 0, Cell::from_char('B'));
1435 new.set_raw(6, 0, Cell::from_char('C'));
1436
1437 let diff = BufferDiff::compute(&old, &new);
1438 assert_eq!(diff.len(), 3);
1439 assert_eq!(diff.changes(), &[(1, 0), (5, 0), (6, 0)]);
1440 }
1441
1442 #[test]
1443 fn block_scan_single_cell_row() {
1444 let old = Buffer::new(1, 1);
1446 let mut new = Buffer::new(1, 1);
1447 new.set_raw(0, 0, Cell::from_char('X'));
1448
1449 let diff = BufferDiff::compute(&old, &new);
1450 assert_eq!(diff.len(), 1);
1451 assert_eq!(diff.changes(), &[(0, 0)]);
1452 }
1453
1454 #[test]
1455 fn block_scan_two_cell_row() {
1456 let old = Buffer::new(2, 1);
1458 let mut new = Buffer::new(2, 1);
1459 new.set_raw(0, 0, Cell::from_char('A'));
1460 new.set_raw(1, 0, Cell::from_char('B'));
1461
1462 let diff = BufferDiff::compute(&old, &new);
1463 assert_eq!(diff.len(), 2);
1464 assert_eq!(diff.changes(), &[(0, 0), (1, 0)]);
1465 }
1466
1467 #[test]
1468 fn block_scan_three_cell_row() {
1469 let old = Buffer::new(3, 1);
1471 let mut new = Buffer::new(3, 1);
1472 new.set_raw(2, 0, Cell::from_char('X'));
1473
1474 let diff = BufferDiff::compute(&old, &new);
1475 assert_eq!(diff.len(), 1);
1476 assert_eq!(diff.changes(), &[(2, 0)]);
1477 }
1478
1479 #[test]
1480 fn block_scan_multiple_blocks_sparse() {
1481 let old = Buffer::new(80, 1);
1483 let mut new = Buffer::new(80, 1);
1484
1485 for block in (0..20).step_by(2) {
1487 let x = (block * 4 + 1) as u16;
1488 new.set_raw(x, 0, Cell::from_char('X'));
1489 }
1490
1491 let diff = BufferDiff::compute(&old, &new);
1492 assert_eq!(diff.len(), 10);
1493 assert_eq!(
1494 diff.changes(),
1495 &[
1496 (1, 0),
1497 (9, 0),
1498 (17, 0),
1499 (25, 0),
1500 (33, 0),
1501 (41, 0),
1502 (49, 0),
1503 (57, 0),
1504 (65, 0),
1505 (73, 0)
1506 ]
1507 );
1508 }
1509
1510 #[test]
1511 fn block_scan_full_block_unchanged_skip() {
1512 let old = Buffer::new(20, 1);
1514 let mut new = Buffer::new(20, 1);
1515
1516 new.set_raw(19, 0, Cell::from_char('Z'));
1518
1519 let diff = BufferDiff::compute(&old, &new);
1520 assert_eq!(diff.len(), 1);
1521 assert_eq!(diff.changes(), &[(19, 0)]);
1522 }
1523
1524 #[test]
1525 fn block_scan_wide_row_all_changed() {
1526 let old = Buffer::new(120, 1);
1528 let mut new = Buffer::new(120, 1);
1529 for x in 0..120 {
1530 new.set_raw(x, 0, Cell::from_char('#'));
1531 }
1532
1533 let diff = BufferDiff::compute(&old, &new);
1534 assert_eq!(diff.len(), 120);
1535 }
1536
1537 #[test]
1538 fn perf_block_scan_vs_scalar_baseline() {
1539 use std::time::Instant;
1542
1543 let width = 200u16;
1544 let height = 50u16;
1545 let old = Buffer::new(width, height);
1546 let mut new = Buffer::new(width, height);
1547
1548 for i in 0..1000 {
1550 let x = (i * 7 + 3) as u16 % width;
1551 let y = (i * 11 + 5) as u16 % height;
1552 let ch = char::from_u32(('A' as u32) + (i as u32 % 26)).unwrap();
1553 new.set_raw(x, y, Cell::from_char(ch));
1554 }
1555
1556 let iterations = 1000u32;
1557 let samples = std::env::var("FTUI_DIFF_BLOCK_SAMPLES")
1558 .ok()
1559 .and_then(|value| value.parse::<usize>().ok())
1560 .unwrap_or(50)
1561 .clamp(1, iterations as usize);
1562 let iters_per_sample = (iterations / samples as u32).max(1) as u64;
1563
1564 let mut times_us = Vec::with_capacity(samples);
1565 let mut last_checksum = 0u64;
1566
1567 for _ in 0..samples {
1568 let start = Instant::now();
1569 for _ in 0..iters_per_sample {
1570 let diff = BufferDiff::compute(&old, &new);
1571 assert!(!diff.is_empty());
1572 last_checksum = fnv1a_hash(diff.changes());
1573 }
1574 let elapsed = start.elapsed();
1575 let per_iter = (elapsed.as_micros() as u64) / iters_per_sample;
1576 times_us.push(per_iter);
1577 }
1578
1579 times_us.sort_unstable();
1580 let len = times_us.len();
1581 let p50 = times_us[len / 2];
1582 let p95 = times_us[((len as f64 * 0.95) as usize).min(len.saturating_sub(1))];
1583 let p99 = times_us[((len as f64 * 0.99) as usize).min(len.saturating_sub(1))];
1584 let mean = times_us
1585 .iter()
1586 .copied()
1587 .map(|value| value as f64)
1588 .sum::<f64>()
1589 / len as f64;
1590 let variance = times_us
1591 .iter()
1592 .map(|value| {
1593 let delta = *value as f64 - mean;
1594 delta * delta
1595 })
1596 .sum::<f64>()
1597 / len as f64;
1598
1599 eprintln!(
1601 "{{\"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}\"}}",
1602 width, height, samples, iters_per_sample, p50, p95, p99, mean, variance, last_checksum
1603 );
1604
1605 #[allow(unexpected_cfgs)]
1606 let budget_us = if std::env::var("LLVM_PROFILE_FILE").is_ok()
1607 || std::env::var("CARGO_LLVM_COV").is_ok()
1608 {
1609 3_000u64
1610 } else {
1611 500u64
1612 };
1613 assert!(
1614 p95 <= budget_us,
1615 "Diff too slow: p95={p95}µs (budget {budget_us}µs) for {width}x{height}"
1616 );
1617 }
1618 #[test]
1623 fn unit_run_coalescing_invariants() {
1624 let old = Buffer::new(80, 24);
1627 let mut new = Buffer::new(80, 24);
1628
1629 for x in 0..=2 {
1631 new.set_raw(x, 0, Cell::from_char('A'));
1632 }
1633 for x in 10..=12 {
1634 new.set_raw(x, 0, Cell::from_char('B'));
1635 }
1636 for x in 40..=45 {
1638 new.set_raw(x, 5, Cell::from_char('C'));
1639 }
1640 new.set_raw(79, 23, Cell::from_char('Z'));
1642
1643 let diff = BufferDiff::compute(&old, &new);
1644 let runs = diff.runs();
1645
1646 for w in runs.windows(2) {
1648 assert!(
1649 (w[0].y, w[0].x0) < (w[1].y, w[1].x0),
1650 "runs must be sorted: {:?} should precede {:?}",
1651 w[0],
1652 w[1]
1653 );
1654 }
1655
1656 let total_cells: usize = runs.iter().map(|r| r.len() as usize).sum();
1658 assert_eq!(
1659 total_cells,
1660 diff.len(),
1661 "runs must cover all changes exactly"
1662 );
1663
1664 for w in runs.windows(2) {
1666 if w[0].y == w[1].y {
1667 assert!(
1668 w[1].x0 > w[0].x1.saturating_add(1),
1669 "adjacent runs on same row should be merged: {:?} and {:?}",
1670 w[0],
1671 w[1]
1672 );
1673 }
1674 }
1675
1676 assert_eq!(runs.len(), 4);
1678 assert_eq!(runs[0], ChangeRun::new(0, 0, 2));
1679 assert_eq!(runs[1], ChangeRun::new(0, 10, 12));
1680 assert_eq!(runs[2], ChangeRun::new(5, 40, 45));
1681 assert_eq!(runs[3], ChangeRun::new(23, 79, 79));
1682 }
1683
1684 fn fnv1a_hash(data: &[(u16, u16)]) -> u64 {
1690 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
1691 for &(x, y) in data {
1692 for byte in x.to_le_bytes().iter().chain(y.to_le_bytes().iter()) {
1693 hash ^= *byte as u64;
1694 hash = hash.wrapping_mul(0x0100_0000_01b3);
1695 }
1696 }
1697 hash
1698 }
1699
1700 fn build_golden_scene(width: u16, height: u16, seed: u64) -> Buffer {
1703 let mut buf = Buffer::new(width, height);
1704 let mut rng = seed;
1705
1706 for x in 0..width {
1708 buf.set_raw(x, 0, Cell::from_char('='));
1709 }
1710
1711 for x in 0..width {
1713 buf.set_raw(x, height - 1, Cell::from_char('-'));
1714 }
1715
1716 let count = (width as u64 * height as u64 / 10).max(5);
1718 for _ in 0..count {
1719 rng = rng.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
1720 let x = ((rng >> 16) as u16) % width;
1721 rng = rng.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
1722 let y = ((rng >> 16) as u16) % height;
1723 rng = rng.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
1724 let ch = char::from_u32('A' as u32 + (rng % 26) as u32).unwrap();
1725 buf.set_raw(x, y, Cell::from_char(ch));
1726 }
1727
1728 buf
1729 }
1730
1731 #[test]
1732 fn golden_diff_80x24() {
1733 let old = Buffer::new(80, 24);
1734 let new = build_golden_scene(80, 24, 0x000D_01DE_5EED_0001);
1735
1736 let diff = BufferDiff::compute(&old, &new);
1737 let checksum = fnv1a_hash(diff.changes());
1738
1739 let diff2 = BufferDiff::compute(&old, &new);
1741 assert_eq!(
1742 fnv1a_hash(diff2.changes()),
1743 checksum,
1744 "diff must be deterministic"
1745 );
1746
1747 assert!(
1749 diff.len() >= 160,
1750 "80x24 golden scene should have at least 160 changes (header+status), got {}",
1751 diff.len()
1752 );
1753
1754 let runs = diff.runs();
1755 assert_eq!(runs[0].y, 0, "first run should be header row");
1757 assert_eq!(runs[0].x0, 0);
1758 assert_eq!(runs[0].x1, 79);
1759 assert!(
1760 runs.last().unwrap().y == 23,
1761 "last row should contain status bar"
1762 );
1763 }
1764
1765 #[test]
1766 fn golden_diff_120x40() {
1767 let old = Buffer::new(120, 40);
1768 let new = build_golden_scene(120, 40, 0x000D_01DE_5EED_0002);
1769
1770 let diff = BufferDiff::compute(&old, &new);
1771 let checksum = fnv1a_hash(diff.changes());
1772
1773 let diff2 = BufferDiff::compute(&old, &new);
1775 assert_eq!(fnv1a_hash(diff2.changes()), checksum);
1776
1777 assert!(
1779 diff.len() >= 240,
1780 "120x40 golden scene should have >=240 changes, got {}",
1781 diff.len()
1782 );
1783
1784 let dirty = BufferDiff::compute_dirty(&old, &new);
1786 assert_eq!(
1787 fnv1a_hash(dirty.changes()),
1788 checksum,
1789 "dirty diff must produce identical changes"
1790 );
1791 }
1792
1793 #[test]
1794 fn golden_sparse_update() {
1795 let old = build_golden_scene(80, 24, 0x000D_01DE_5EED_0003);
1797 let mut new = old.clone();
1798
1799 new.set_raw(10, 5, Cell::from_char('!'));
1801 new.set_raw(11, 5, Cell::from_char('@'));
1802 new.set_raw(40, 12, Cell::from_char('#'));
1803 new.set_raw(70, 20, Cell::from_char('$'));
1804 new.set_raw(0, 23, Cell::from_char('%'));
1805
1806 let diff = BufferDiff::compute(&old, &new);
1807 let checksum = fnv1a_hash(diff.changes());
1808
1809 let diff2 = BufferDiff::compute(&old, &new);
1811 assert_eq!(fnv1a_hash(diff2.changes()), checksum);
1812
1813 assert!(
1815 diff.len() <= 5,
1816 "sparse update should have <=5 changes, got {}",
1817 diff.len()
1818 );
1819 assert!(
1820 diff.len() >= 3,
1821 "sparse update should have >=3 changes, got {}",
1822 diff.len()
1823 );
1824 }
1825
1826 #[test]
1831 fn e2e_random_scene_replay() {
1832 let width = 80u16;
1835 let height = 24u16;
1836 let base_seed: u64 = 0x5C3E_E3E1_A442u64;
1837
1838 let mut checksums = Vec::new();
1839
1840 for frame in 0..10u64 {
1841 let seed = base_seed.wrapping_add(frame.wrapping_mul(0x9E37_79B9_7F4A_7C15));
1842 let old = build_golden_scene(width, height, seed);
1843 let new = build_golden_scene(width, height, seed.wrapping_add(1));
1844
1845 let diff = BufferDiff::compute(&old, &new);
1846 let dirty_diff = BufferDiff::compute_dirty(&old, &new);
1847
1848 assert_eq!(
1850 diff.changes(),
1851 dirty_diff.changes(),
1852 "frame {frame}: dirty diff must match full diff"
1853 );
1854
1855 checksums.push(fnv1a_hash(diff.changes()));
1856 }
1857
1858 for frame in 0..10u64 {
1860 let seed = base_seed.wrapping_add(frame.wrapping_mul(0x9E37_79B9_7F4A_7C15));
1861 let old = build_golden_scene(width, height, seed);
1862 let new = build_golden_scene(width, height, seed.wrapping_add(1));
1863
1864 let diff = BufferDiff::compute(&old, &new);
1865 assert_eq!(
1866 fnv1a_hash(diff.changes()),
1867 checksums[frame as usize],
1868 "frame {frame}: checksum mismatch on replay"
1869 );
1870 }
1871 }
1872
1873 #[test]
1878 fn perf_diff_microbench() {
1879 use std::time::Instant;
1880
1881 let scenarios: &[(u16, u16, &str, u64)] = &[
1882 (80, 24, "full_frame", 0xBE4C_0001u64),
1883 (80, 24, "sparse_update", 0xBE4C_0002u64),
1884 (120, 40, "full_frame", 0xBE4C_0003u64),
1885 (120, 40, "sparse_update", 0xBE4C_0004u64),
1886 ];
1887
1888 let iterations = std::env::var("FTUI_DIFF_BENCH_ITERS")
1889 .ok()
1890 .and_then(|value| value.parse::<u32>().ok())
1891 .unwrap_or(50u32);
1892
1893 for &(width, height, scene_type, seed) in scenarios {
1894 let old = Buffer::new(width, height);
1895 let new = match scene_type {
1896 "full_frame" => build_golden_scene(width, height, seed),
1897 "sparse_update" => {
1898 let mut buf = old.clone();
1899 buf.set_raw(10, 5, Cell::from_char('!'));
1900 buf.set_raw(40, 12, Cell::from_char('#'));
1901 buf.set_raw(70 % width, 20 % height, Cell::from_char('$'));
1902 buf
1903 }
1904 _ => unreachable!(),
1905 };
1906
1907 let mut times_us = Vec::with_capacity(iterations as usize);
1908 let mut last_changes = 0usize;
1909 let mut last_runs = 0usize;
1910 let mut last_checksum = 0u64;
1911
1912 for _ in 0..iterations {
1913 let start = Instant::now();
1914 let diff = BufferDiff::compute(&old, &new);
1915 let runs = diff.runs();
1916 let elapsed = start.elapsed();
1917
1918 last_changes = diff.len();
1919 last_runs = runs.len();
1920 last_checksum = fnv1a_hash(diff.changes());
1921 times_us.push(elapsed.as_micros() as u64);
1922 }
1923
1924 times_us.sort();
1925 let len = times_us.len();
1926 let p50 = times_us[len / 2];
1927 let p95 = times_us[((len as f64 * 0.95) as usize).min(len.saturating_sub(1))];
1928 let p99 = times_us[((len as f64 * 0.99) as usize).min(len.saturating_sub(1))];
1929
1930 eprintln!(
1932 "{{\"ts\":\"2026-02-03T00:00:00Z\",\"seed\":{},\"width\":{},\"height\":{},\"scene\":\"{}\",\"changes\":{},\"runs\":{},\"p50_us\":{},\"p95_us\":{},\"p99_us\":{},\"checksum\":\"0x{:016x}\"}}",
1933 seed,
1934 width,
1935 height,
1936 scene_type,
1937 last_changes,
1938 last_runs,
1939 p50,
1940 p95,
1941 p99,
1942 last_checksum
1943 );
1944
1945 let budget_us = match (width, height) {
1948 (80, 24) => 500, (120, 40) => 1000, _ => 2000,
1951 };
1952
1953 for _ in 0..3 {
1955 let diff = BufferDiff::compute(&old, &new);
1956 assert_eq!(
1957 fnv1a_hash(diff.changes()),
1958 last_checksum,
1959 "diff must be deterministic"
1960 );
1961 }
1962
1963 if p95 > budget_us {
1965 eprintln!(
1966 "WARN: {scene_type} {width}x{height} p95={p95}µs exceeds budget {budget_us}µs"
1967 );
1968 }
1969 }
1970 }
1971
1972 #[test]
1977 fn perf_dirty_diff_large_screen_regression() {
1978 use std::time::Instant;
1979
1980 let iterations = std::env::var("FTUI_DIFF_BENCH_ITERS")
1981 .ok()
1982 .and_then(|value| value.parse::<u32>().ok())
1983 .unwrap_or(50u32);
1984
1985 let max_slowdown = std::env::var("FTUI_DIRTY_DIFF_MAX_SLOWDOWN")
1986 .ok()
1987 .and_then(|value| value.parse::<f64>().ok())
1988 .unwrap_or(2.0);
1989
1990 let cases: &[(u16, u16, &str, f64)] = &[
1991 (200, 60, "sparse_5pct", 5.0),
1992 (240, 80, "sparse_5pct", 5.0),
1993 (200, 60, "single_row", 0.0),
1994 (240, 80, "single_row", 0.0),
1995 ];
1996
1997 for &(width, height, pattern, pct) in cases {
1998 let old = Buffer::new(width, height);
1999 let mut new = old.clone();
2000
2001 if pattern == "single_row" {
2002 for x in 0..width {
2003 new.set_raw(x, 0, Cell::from_char('X'));
2004 }
2005 } else {
2006 let total = width as usize * height as usize;
2007 let to_change = ((total as f64) * pct / 100.0) as usize;
2008 for i in 0..to_change {
2009 let x = (i * 7 + 3) as u16 % width;
2010 let y = (i * 11 + 5) as u16 % height;
2011 let ch = char::from_u32(('A' as u32) + (i as u32 % 26)).unwrap();
2012 new.set_raw(x, y, Cell::from_char(ch));
2013 }
2014 }
2015
2016 let full = BufferDiff::compute(&old, &new);
2018 let dirty = BufferDiff::compute_dirty(&old, &new);
2019 let change_count = full.len();
2020 let dirty_rows = new.dirty_row_count();
2021 assert_eq!(
2022 full.changes(),
2023 dirty.changes(),
2024 "dirty diff must match full diff for {width}x{height} {pattern}"
2025 );
2026
2027 let mut full_times = Vec::with_capacity(iterations as usize);
2028 let mut dirty_times = Vec::with_capacity(iterations as usize);
2029
2030 for _ in 0..iterations {
2031 let start = Instant::now();
2032 let diff = BufferDiff::compute(&old, &new);
2033 std::hint::black_box(diff.len());
2034 full_times.push(start.elapsed().as_micros() as u64);
2035
2036 let start = Instant::now();
2037 let diff = BufferDiff::compute_dirty(&old, &new);
2038 std::hint::black_box(diff.len());
2039 dirty_times.push(start.elapsed().as_micros() as u64);
2040 }
2041
2042 full_times.sort();
2043 dirty_times.sort();
2044
2045 let len = full_times.len();
2046 let p50_idx = len / 2;
2047 let p95_idx = ((len as f64 * 0.95) as usize).min(len.saturating_sub(1));
2048
2049 let full_p50 = full_times[p50_idx];
2050 let full_p95 = full_times[p95_idx];
2051 let dirty_p50 = dirty_times[p50_idx];
2052 let dirty_p95 = dirty_times[p95_idx];
2053
2054 let denom = full_p50.max(1) as f64;
2055 let ratio = dirty_p50 as f64 / denom;
2056
2057 eprintln!(
2058 "{{\"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\":{}}}",
2059 width,
2060 height,
2061 pattern,
2062 iterations,
2063 change_count,
2064 dirty_rows,
2065 full_p50,
2066 full_p95,
2067 dirty_p50,
2068 dirty_p95,
2069 ratio,
2070 max_slowdown
2071 );
2072
2073 assert!(
2074 ratio <= max_slowdown,
2075 "dirty diff regression: {width}x{height} {pattern} ratio {ratio:.2} exceeds {max_slowdown}"
2076 );
2077 }
2078 }
2079
2080 #[test]
2081 fn tile_diff_matches_compute_for_sparse_tiles() {
2082 let width = 200;
2083 let height = 60;
2084 let old = Buffer::new(width, height);
2085 let mut new = old.clone();
2086
2087 new.clear_dirty();
2088 for x in 0..10u16 {
2089 new.set_raw(x, 0, Cell::from_char('X'));
2090 }
2091
2092 let full = BufferDiff::compute(&old, &new);
2093 let dirty = BufferDiff::compute_dirty(&old, &new);
2094
2095 assert_eq!(full.changes(), dirty.changes());
2096 let stats = dirty
2097 .last_tile_stats()
2098 .expect("tile stats should be recorded");
2099 assert!(
2100 stats.fallback.is_none(),
2101 "tile path should be used for sparse tiles"
2102 );
2103 }
2104
2105 fn make_dirty_buffer(width: u16, height: u16, changes: &[(u16, u16, char)]) -> Buffer {
2106 let mut buffer = Buffer::new(width, height);
2107 buffer.clear_dirty();
2108 for &(x, y, ch) in changes {
2109 buffer.set_raw(x, y, Cell::from_char(ch));
2110 }
2111 buffer
2112 }
2113
2114 fn tile_stats_for_config(old: &Buffer, new: &Buffer, config: TileDiffConfig) -> TileDiffStats {
2115 let mut diff = BufferDiff::new();
2116 *diff.tile_config_mut() = config;
2117 diff.compute_dirty_into(old, new);
2118 diff.last_tile_stats()
2119 .expect("tile stats should be recorded")
2120 }
2121
2122 #[test]
2123 fn tile_fallback_disabled_when_config_off() {
2124 let width = 64;
2125 let height = 32;
2126 let old = Buffer::new(width, height);
2127 let new = make_dirty_buffer(width, height, &[(0, 0, 'X')]);
2128
2129 let config = TileDiffConfig {
2130 enabled: false,
2131 min_cells_for_tiles: 0,
2132 dense_cell_ratio: 1.1,
2133 dense_tile_ratio: 1.1,
2134 max_tiles: usize::MAX,
2135 ..Default::default()
2136 };
2137
2138 let stats = tile_stats_for_config(&old, &new, config);
2139 assert_eq!(stats.fallback, Some(TileDiffFallback::Disabled));
2140 }
2141
2142 #[test]
2143 fn tile_fallback_small_screen_when_below_threshold() {
2144 let width = 64;
2145 let height = 32;
2146 let old = Buffer::new(width, height);
2147 let new = make_dirty_buffer(width, height, &[(1, 2, 'Y')]);
2148
2149 let config = TileDiffConfig {
2150 enabled: true,
2151 min_cells_for_tiles: width as usize * height as usize + 1,
2152 dense_cell_ratio: 1.1,
2153 dense_tile_ratio: 1.1,
2154 max_tiles: usize::MAX,
2155 ..Default::default()
2156 };
2157
2158 let stats = tile_stats_for_config(&old, &new, config);
2159 assert_eq!(stats.fallback, Some(TileDiffFallback::SmallScreen));
2160 }
2161
2162 #[test]
2163 fn tile_fallback_too_many_tiles_when_budget_exceeded() {
2164 let width = 64;
2165 let height = 64;
2166 let old = Buffer::new(width, height);
2167 let new = make_dirty_buffer(width, height, &[(2, 3, 'Z')]);
2168
2169 let config = TileDiffConfig {
2170 enabled: true,
2171 tile_w: 8,
2172 tile_h: 8,
2173 min_cells_for_tiles: 0,
2174 dense_cell_ratio: 1.1,
2175 dense_tile_ratio: 1.1,
2176 max_tiles: 4,
2177 };
2178
2179 let stats = tile_stats_for_config(&old, &new, config);
2180 assert_eq!(stats.fallback, Some(TileDiffFallback::TooManyTiles));
2181 }
2182
2183 #[test]
2184 fn tile_fallback_dense_tiles_when_ratio_exceeded() {
2185 let width = 64;
2186 let height = 32;
2187 let old = Buffer::new(width, height);
2188 let new = make_dirty_buffer(width, height, &[(0, 0, 'A'), (24, 0, 'B')]);
2189
2190 let config = TileDiffConfig {
2191 enabled: true,
2192 tile_w: 8,
2193 tile_h: 8,
2194 min_cells_for_tiles: 0,
2195 dense_cell_ratio: 1.1,
2196 dense_tile_ratio: 0.05,
2197 max_tiles: usize::MAX / 4,
2198 };
2199
2200 let stats = tile_stats_for_config(&old, &new, config);
2201 assert_eq!(stats.fallback, Some(TileDiffFallback::DenseTiles));
2202 }
2203
2204 fn lcg_next(state: &mut u64) -> u64 {
2205 *state = state
2206 .wrapping_mul(6364136223846793005)
2207 .wrapping_add(1442695040888963407);
2208 *state
2209 }
2210
2211 fn apply_random_changes(buf: &mut Buffer, seed: u64, count: usize) {
2212 let width = buf.width().max(1) as u64;
2213 let height = buf.height().max(1) as u64;
2214 let mut state = seed;
2215 for i in 0..count {
2216 let v = lcg_next(&mut state);
2217 let x = (v % width) as u16;
2218 let y = ((v >> 32) % height) as u16;
2219 let ch = char::from_u32(('A' as u32) + ((i as u32) % 26)).unwrap();
2220 buf.set_raw(x, y, Cell::from_char(ch));
2221 }
2222 }
2223
2224 fn tile_diag(stats: &TileDiffStats) -> String {
2225 let tile_size = stats.tile_w as usize * stats.tile_h as usize;
2226 format!(
2227 "tile_size={tile_size}, dirty_tiles={}, skipped_tiles={}, dirty_cells={}, dirty_tile_ratio={:.3}, dirty_cell_ratio={:.3}, scanned_tiles={}, fallback={:?}",
2228 stats.dirty_tiles,
2229 stats.skipped_tiles,
2230 stats.dirty_cells,
2231 stats.dirty_tile_ratio,
2232 stats.dirty_cell_ratio,
2233 stats.scanned_tiles,
2234 stats.fallback
2235 )
2236 }
2237
2238 fn diff_with_forced_tiles(old: &Buffer, new: &Buffer) -> (BufferDiff, TileDiffStats) {
2239 let mut diff = BufferDiff::new();
2240 {
2241 let config = diff.tile_config_mut();
2242 config.enabled = true;
2243 config.tile_w = 8;
2244 config.tile_h = 8;
2245 config.min_cells_for_tiles = 0;
2246 config.dense_cell_ratio = 1.1;
2247 config.dense_tile_ratio = 1.1;
2248 config.max_tiles = usize::MAX / 4;
2249 }
2250 diff.compute_dirty_into(old, new);
2251 let stats = diff
2252 .last_tile_stats()
2253 .expect("tile stats should be recorded");
2254 (diff, stats)
2255 }
2256
2257 fn assert_tile_diff_equivalence(old: &Buffer, new: &Buffer, label: &str) {
2258 let full = BufferDiff::compute(old, new);
2259 let (dirty, stats) = diff_with_forced_tiles(old, new);
2260 let diag = tile_diag(&stats);
2261 assert!(
2262 stats.fallback.is_none(),
2263 "tile diff fallback ({label}) {w}x{h}: {diag}",
2264 w = old.width(),
2265 h = old.height()
2266 );
2267 assert!(
2268 full.changes() == dirty.changes(),
2269 "tile diff mismatch ({label}) {w}x{h}: {diag}",
2270 w = old.width(),
2271 h = old.height()
2272 );
2273 }
2274
2275 #[test]
2276 fn tile_diff_equivalence_small_and_odd_sizes() {
2277 let cases: &[(u16, u16, usize)] = &[
2278 (1, 1, 1),
2279 (2, 1, 1),
2280 (1, 2, 1),
2281 (5, 3, 4),
2282 (7, 13, 12),
2283 (15, 9, 20),
2284 (31, 5, 12),
2285 ];
2286
2287 for (idx, &(width, height, changes)) in cases.iter().enumerate() {
2288 let old = Buffer::new(width, height);
2289 let mut new = old.clone();
2290 new.clear_dirty();
2291 apply_random_changes(&mut new, 0xC0FFEE_u64 + idx as u64, changes);
2292 assert_tile_diff_equivalence(&old, &new, "small_odd");
2293 }
2294 }
2295
2296 #[test]
2297 fn tile_diff_equivalence_large_sparse_random() {
2298 let cases: &[(u16, u16)] = &[(200, 60), (240, 80)];
2299 for (idx, &(width, height)) in cases.iter().enumerate() {
2300 let old = Buffer::new(width, height);
2301 let mut new = old.clone();
2302 new.clear_dirty();
2303 let total = width as usize * height as usize;
2304 let changes = (total / 100).max(1);
2305 apply_random_changes(&mut new, 0xDEADBEEF_u64 + idx as u64, changes);
2306 assert_tile_diff_equivalence(&old, &new, "large_sparse");
2307 }
2308 }
2309
2310 #[test]
2311 fn tile_diff_equivalence_row_and_full_buffer() {
2312 let width = 200u16;
2313 let height = 60u16;
2314 let old = Buffer::new(width, height);
2315
2316 let mut row = old.clone();
2317 row.clear_dirty();
2318 for x in 0..width {
2319 row.set_raw(x, 0, Cell::from_char('R'));
2320 }
2321 assert_tile_diff_equivalence(&old, &row, "single_row");
2322
2323 let mut full = old.clone();
2324 full.clear_dirty();
2325 for y in 0..height {
2326 for x in 0..width {
2327 full.set_raw(x, y, Cell::from_char('F'));
2328 }
2329 }
2330 assert_tile_diff_equivalence(&old, &full, "full_buffer");
2331 }
2332}
2333
2334#[cfg(test)]
2335mod proptests {
2336 use super::*;
2337 use crate::cell::Cell;
2338 use ftui_core::geometry::Rect;
2339 use proptest::prelude::*;
2340
2341 #[test]
2343 fn diff_apply_produces_target() {
2344 proptest::proptest!(|(
2345 width in 5u16..50,
2346 height in 5u16..30,
2347 num_changes in 0usize..200,
2348 )| {
2349 let old = Buffer::new(width, height);
2351
2352 let mut new = old.clone();
2354 for i in 0..num_changes {
2355 let x = (i * 7 + 3) as u16 % width;
2356 let y = (i * 11 + 5) as u16 % height;
2357 let ch = char::from_u32(('A' as u32) + (i as u32 % 26)).unwrap();
2358 new.set_raw(x, y, Cell::from_char(ch));
2359 }
2360
2361 let diff = BufferDiff::compute(&old, &new);
2363
2364 let mut result = old.clone();
2366 for (x, y) in diff.iter() {
2367 let cell = *new.get_unchecked(x, y);
2368 result.set_raw(x, y, cell);
2369 }
2370
2371 for y in 0..height {
2373 for x in 0..width {
2374 let result_cell = result.get_unchecked(x, y);
2375 let new_cell = new.get_unchecked(x, y);
2376 prop_assert!(
2377 result_cell.bits_eq(new_cell),
2378 "Mismatch at ({}, {})",
2379 x,
2380 y
2381 );
2382 }
2383 }
2384 });
2385 }
2386
2387 #[test]
2389 fn identical_buffers_empty_diff() {
2390 proptest::proptest!(|(width in 1u16..100, height in 1u16..50)| {
2391 let buf = Buffer::new(width, height);
2392 let diff = BufferDiff::compute(&buf, &buf);
2393 prop_assert!(diff.is_empty(), "Identical buffers should have empty diff");
2394 });
2395 }
2396
2397 #[test]
2399 fn diff_contains_only_real_changes() {
2400 proptest::proptest!(|(
2401 width in 5u16..50,
2402 height in 5u16..30,
2403 num_changes in 0usize..100,
2404 )| {
2405 let old = Buffer::new(width, height);
2406 let mut new = old.clone();
2407
2408 for i in 0..num_changes {
2409 let x = (i * 7 + 3) as u16 % width;
2410 let y = (i * 11 + 5) as u16 % height;
2411 new.set_raw(x, y, Cell::from_char('X'));
2412 }
2413
2414 let diff = BufferDiff::compute(&old, &new);
2415
2416 for (x, y) in diff.iter() {
2418 let old_cell = old.get_unchecked(x, y);
2419 let new_cell = new.get_unchecked(x, y);
2420 prop_assert!(
2421 !old_cell.bits_eq(new_cell),
2422 "Diff includes unchanged cell at ({}, {})",
2423 x,
2424 y
2425 );
2426 }
2427 });
2428 }
2429
2430 #[test]
2432 fn runs_are_contiguous() {
2433 proptest::proptest!(|(width in 10u16..80, height in 5u16..30)| {
2434 let old = Buffer::new(width, height);
2435 let mut new = old.clone();
2436
2437 for y in 0..height.min(5) {
2439 for x in 0..width.min(10) {
2440 new.set_raw(x, y, Cell::from_char('#'));
2441 }
2442 }
2443
2444 let diff = BufferDiff::compute(&old, &new);
2445 let runs = diff.runs();
2446
2447 for run in runs {
2449 prop_assert!(run.x1 >= run.x0, "Run has invalid range");
2450 prop_assert!(!run.is_empty(), "Run should not be empty");
2451
2452 for x in run.x0..=run.x1 {
2454 let old_cell = old.get_unchecked(x, run.y);
2455 let new_cell = new.get_unchecked(x, run.y);
2456 prop_assert!(
2457 !old_cell.bits_eq(new_cell),
2458 "Run includes unchanged cell at ({}, {})",
2459 x,
2460 run.y
2461 );
2462 }
2463 }
2464 });
2465 }
2466
2467 #[test]
2469 fn runs_cover_all_changes() {
2470 proptest::proptest!(|(
2471 width in 10u16..60,
2472 height in 5u16..30,
2473 num_changes in 1usize..100,
2474 )| {
2475 let old = Buffer::new(width, height);
2476 let mut new = old.clone();
2477
2478 for i in 0..num_changes {
2479 let x = (i * 13 + 7) as u16 % width;
2480 let y = (i * 17 + 3) as u16 % height;
2481 new.set_raw(x, y, Cell::from_char('X'));
2482 }
2483
2484 let diff = BufferDiff::compute(&old, &new);
2485 let runs = diff.runs();
2486
2487 let mut run_cells: std::collections::HashSet<(u16, u16)> =
2489 std::collections::HashSet::new();
2490 for run in &runs {
2491 for x in run.x0..=run.x1 {
2492 let was_new = run_cells.insert((x, run.y));
2493 prop_assert!(was_new, "Duplicate cell ({}, {}) in runs", x, run.y);
2494 }
2495 }
2496
2497 for (x, y) in diff.iter() {
2499 prop_assert!(
2500 run_cells.contains(&(x, y)),
2501 "Change at ({}, {}) not covered by runs",
2502 x,
2503 y
2504 );
2505 }
2506
2507 prop_assert_eq!(
2508 run_cells.len(),
2509 diff.len(),
2510 "Run cell count should match diff change count"
2511 );
2512 });
2513 }
2514
2515 #[test]
2519 fn block_scan_matches_scalar() {
2520 proptest::proptest!(|(
2521 width in 1u16..200,
2522 height in 1u16..20,
2523 num_changes in 0usize..200,
2524 )| {
2525 use crate::cell::PackedRgba;
2526
2527 let old = Buffer::new(width, height);
2528 let mut new = Buffer::new(width, height);
2529
2530 for i in 0..num_changes {
2531 let x = (i * 13 + 7) as u16 % width;
2532 let y = (i * 17 + 3) as u16 % height;
2533 let fg = PackedRgba::rgb(
2534 ((i * 31) % 256) as u8,
2535 ((i * 47) % 256) as u8,
2536 ((i * 71) % 256) as u8,
2537 );
2538 new.set_raw(x, y, Cell::from_char('X').with_fg(fg));
2539 }
2540
2541 let diff = BufferDiff::compute(&old, &new);
2542
2543 let mut scalar_changes = Vec::new();
2545 for y in 0..height {
2546 for x in 0..width {
2547 let old_cell = old.get_unchecked(x, y);
2548 let new_cell = new.get_unchecked(x, y);
2549 if !old_cell.bits_eq(new_cell) {
2550 scalar_changes.push((x, y));
2551 }
2552 }
2553 }
2554
2555 prop_assert_eq!(
2556 diff.changes(),
2557 &scalar_changes[..],
2558 "Block scan should match scalar scan"
2559 );
2560 });
2561 }
2562
2563 #[test]
2569 fn property_diff_equivalence() {
2570 proptest::proptest!(|(
2571 width in 1u16..120,
2572 height in 1u16..40,
2573 num_changes in 0usize..300,
2574 )| {
2575 let old = Buffer::new(width, height);
2576 let mut new = Buffer::new(width, height);
2577
2578 for i in 0..num_changes {
2580 let x = (i * 13 + 7) as u16 % width;
2581 let y = (i * 17 + 3) as u16 % height;
2582 let ch = char::from_u32(('A' as u32) + (i as u32 % 26)).unwrap();
2583 let fg = crate::cell::PackedRgba::rgb(
2584 ((i * 31) % 256) as u8,
2585 ((i * 47) % 256) as u8,
2586 ((i * 71) % 256) as u8,
2587 );
2588 new.set_raw(x, y, Cell::from_char(ch).with_fg(fg));
2589 }
2590
2591 let full = BufferDiff::compute(&old, &new);
2592 let dirty = BufferDiff::compute_dirty(&old, &new);
2593
2594 prop_assert_eq!(
2595 full.changes(),
2596 dirty.changes(),
2597 "dirty diff must match full diff (width={}, height={}, changes={})",
2598 width,
2599 height,
2600 num_changes
2601 );
2602
2603 let full_runs = full.runs();
2605 let dirty_runs = dirty.runs();
2606 prop_assert_eq!(full_runs.len(), dirty_runs.len(), "run count must match");
2607 for (fr, dr) in full_runs.iter().zip(dirty_runs.iter()) {
2608 prop_assert_eq!(fr, dr, "run mismatch");
2609 }
2610 });
2611 }
2612
2613 #[test]
2616 fn property_diff_equivalence_complex_spans() {
2617 proptest::proptest!(|(
2618 width in 10u16..100,
2619 height in 10u16..50,
2620 ops in proptest::collection::vec(
2621 prop_oneof![
2622 (Just(0u8), any::<u16>(), any::<u16>(), any::<char>()),
2624 (Just(1u8), any::<u16>(), any::<u16>(), any::<char>()),
2626 ],
2627 1..50
2628 )
2629 )| {
2630 let old = Buffer::new(width, height);
2631 let mut new = Buffer::new(width, height);
2632
2633 new.clear_dirty();
2635
2636 for (op_type, x, y, ch) in ops {
2637 let x = x % width;
2638 let y = y % height;
2639 let cell = Cell::from_char(ch);
2640
2641 match op_type {
2642 0 => new.set(x, y, cell),
2643 1 => {
2644 let w = ((x + 10).min(width) - x).max(1);
2646 let h = ((y + 5).min(height) - y).max(1);
2647 new.fill(Rect::new(x, y, w, h), cell);
2648 }
2649 _ => unreachable!(),
2650 }
2651 }
2652
2653 let full = BufferDiff::compute(&old, &new);
2654 let dirty = BufferDiff::compute_dirty(&old, &new);
2655
2656 prop_assert_eq!(
2657 full.changes(),
2658 dirty.changes(),
2659 "dirty diff (spans) must match full diff; {}",
2660 super::span_diagnostics(&new)
2661 );
2662 });
2663 }
2664
2665 #[test]
2673 fn diff_is_idempotent() {
2674 proptest::proptest!(|(
2675 width in 5u16..60,
2676 height in 5u16..30,
2677 num_changes in 0usize..100,
2678 )| {
2679 let mut buf_a = Buffer::new(width, height);
2680 let mut buf_b = Buffer::new(width, height);
2681
2682 for i in 0..num_changes {
2684 let x = (i * 13 + 7) as u16 % width;
2685 let y = (i * 17 + 3) as u16 % height;
2686 buf_b.set_raw(x, y, Cell::from_char('X'));
2687 }
2688
2689 let diff = BufferDiff::compute(&buf_a, &buf_b);
2691
2692 for (x, y) in diff.iter() {
2694 let cell = *buf_b.get_unchecked(x, y);
2695 buf_a.set_raw(x, y, cell);
2696 }
2697
2698 let diff_after_first = BufferDiff::compute(&buf_a, &buf_b);
2700 prop_assert!(
2701 diff_after_first.is_empty(),
2702 "After applying diff once, buffers should be identical (diff was {} changes)",
2703 diff_after_first.len()
2704 );
2705
2706 let before_second = buf_a.clone();
2708 for (x, y) in diff.iter() {
2709 let cell = *buf_b.get_unchecked(x, y);
2710 buf_a.set_raw(x, y, cell);
2711 }
2712
2713 let diff_after_second = BufferDiff::compute(&before_second, &buf_a);
2715 prop_assert!(
2716 diff_after_second.is_empty(),
2717 "Second diff application should be a no-op"
2718 );
2719 });
2720 }
2721
2722 #[test]
2735 fn no_ghosting_after_clear() {
2736 proptest::proptest!(|(
2737 width in 10u16..80,
2738 height in 5u16..30,
2739 num_content_cells in 1usize..200,
2740 )| {
2741 let old = Buffer::new(width, height);
2743
2744 let mut new = Buffer::new(width, height);
2746 let mut expected_changes = std::collections::HashSet::new();
2747
2748 for i in 0..num_content_cells {
2749 let x = (i * 13 + 7) as u16 % width;
2750 let y = (i * 17 + 3) as u16 % height;
2751 new.set_raw(x, y, Cell::from_char('#'));
2752 expected_changes.insert((x, y));
2753 }
2754
2755 let diff = BufferDiff::compute(&old, &new);
2756
2757 for (x, y) in expected_changes {
2760 let in_diff = diff.iter().any(|(dx, dy)| dx == x && dy == y);
2761 prop_assert!(
2762 in_diff,
2763 "Content cell at ({}, {}) missing from diff - would ghost",
2764 x,
2765 y
2766 );
2767 }
2768
2769 for (x, y) in diff.iter() {
2771 let old_cell = old.get_unchecked(x, y);
2772 let new_cell = new.get_unchecked(x, y);
2773 prop_assert!(
2774 !old_cell.bits_eq(new_cell),
2775 "Diff includes unchanged cell at ({}, {})",
2776 x,
2777 y
2778 );
2779 }
2780 });
2781 }
2782
2783 #[test]
2791 fn diff_changes_are_monotonic() {
2792 proptest::proptest!(|(
2793 width in 10u16..80,
2794 height in 5u16..30,
2795 num_changes in 1usize..200,
2796 )| {
2797 let old = Buffer::new(width, height);
2798 let mut new = old.clone();
2799
2800 for i in 0..num_changes {
2802 let x = (i * 37 + 11) as u16 % width;
2803 let y = (i * 53 + 7) as u16 % height;
2804 new.set_raw(x, y, Cell::from_char('M'));
2805 }
2806
2807 let diff = BufferDiff::compute(&old, &new);
2808 let changes: Vec<_> = diff.iter().collect();
2809
2810 for window in changes.windows(2) {
2812 let (x1, y1) = window[0];
2813 let (x2, y2) = window[1];
2814
2815 let is_monotonic = y1 < y2 || (y1 == y2 && x1 < x2);
2816 prop_assert!(
2817 is_monotonic,
2818 "Changes not monotonic: ({}, {}) should come before ({}, {})",
2819 x1,
2820 y1,
2821 x2,
2822 y2
2823 );
2824 }
2825 });
2826 }
2827}
2828
2829#[cfg(test)]
2830mod span_edge_cases {
2831 use super::*;
2832 use crate::cell::Cell;
2833 use proptest::prelude::*;
2834
2835 #[test]
2836 fn test_span_diff_u16_max_width() {
2837 let width = 65000;
2839 let height = 1;
2840 let old = Buffer::new(width, height);
2841 let mut new = Buffer::new(width, height);
2842
2843 new.clear_dirty();
2845
2846 new.set_raw(0, 0, Cell::from_char('A'));
2848 new.set_raw(32500, 0, Cell::from_char('B'));
2849 new.set_raw(64999, 0, Cell::from_char('C'));
2850
2851 let full = BufferDiff::compute(&old, &new);
2852 let dirty = BufferDiff::compute_dirty(&old, &new);
2853
2854 assert_eq!(full.changes(), dirty.changes());
2855 assert_eq!(full.len(), 3);
2856
2857 let changes = full.changes();
2859 assert!(changes.contains(&(0, 0)));
2860 assert!(changes.contains(&(32500, 0)));
2861 assert!(changes.contains(&(64999, 0)));
2862 }
2863
2864 #[test]
2865 fn test_span_full_row_dirty_overflow() {
2866 let width = 1000;
2867 let height = 1;
2868 let old = Buffer::new(width, height);
2869 let mut new = Buffer::new(width, height);
2870 new.clear_dirty(); for i in 0..70 {
2875 let x = (i * 10) as u16;
2876 new.set_raw(x, 0, Cell::from_char('X'));
2877 }
2878
2879 let stats = new.dirty_span_stats();
2881 assert!(
2882 stats.rows_full_dirty > 0,
2883 "Should have overflowed to full row"
2884 );
2885 assert_eq!(
2886 stats.rows_with_spans, 0,
2887 "Should have cleared spans on overflow"
2888 );
2889
2890 let full = BufferDiff::compute(&old, &new);
2891 let dirty = BufferDiff::compute_dirty(&old, &new);
2892
2893 assert_eq!(full.changes(), dirty.changes());
2894 assert_eq!(full.len(), 70);
2895 }
2896
2897 #[test]
2898 fn test_span_diff_empty_rows() {
2899 let width = 100;
2900 let height = 10;
2901 let old = Buffer::new(width, height);
2902 let mut new = Buffer::new(width, height);
2903 new.clear_dirty(); let dirty = BufferDiff::compute_dirty(&old, &new);
2907 assert!(dirty.is_empty());
2908 }
2909
2910 proptest! {
2911 #[test]
2912 fn property_span_diff_equivalence_large(
2913 width in 1000u16..5000,
2914 height in 10u16..50,
2915 changes in proptest::collection::vec((0u16..5000, 0u16..50), 0..100)
2916 ) {
2917 let w = width.min(2000);
2919 let h = height.min(50);
2920
2921 let old = Buffer::new(w, h);
2922 let mut new = Buffer::new(w, h);
2923 new.clear_dirty();
2924
2925 for (raw_x, raw_y) in changes {
2927 let x = raw_x % w;
2928 let y = raw_y % h;
2929 new.set_raw(x, y, Cell::from_char('Z'));
2930 }
2931
2932 let full = BufferDiff::compute(&old, &new);
2933 let dirty = BufferDiff::compute_dirty(&old, &new);
2934
2935 prop_assert_eq!(
2936 full.changes(),
2937 dirty.changes(),
2938 "Large buffer mismatch: w={}, h={}, {}",
2939 w,
2940 h,
2941 super::span_diagnostics(&new)
2942 );
2943 }
2944 }
2945}