use std::fmt::Write;
use crate::frame::TerminalFrame;
use crate::region::Region;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CellChange {
Unchanged,
TextChanged,
StyleChanged,
BothChanged,
}
#[derive(Debug, Clone)]
pub struct ChangedRegion {
pub region: Region,
pub change_type: CellChange,
}
#[derive(Debug, Clone)]
pub struct FrameDiff {
changes: Vec<Vec<CellChange>>,
cols: u16,
rows: u16,
}
impl FrameDiff {
pub fn cols(&self) -> u16 {
self.cols
}
pub fn rows(&self) -> u16 {
self.rows
}
pub fn compute(before: &TerminalFrame, after: &TerminalFrame) -> Self {
let cols = before.cols().max(after.cols());
let rows = before.rows().max(after.rows());
let mut changes = Vec::with_capacity(usize::from(rows));
for row in 0..rows {
let mut row_changes = Vec::with_capacity(usize::from(cols));
for col in 0..cols {
let change = compare_cell(before, after, row, col);
row_changes.push(change);
}
changes.push(row_changes);
}
Self {
changes,
cols,
rows,
}
}
pub fn cell_change(&self, row: u16, col: u16) -> Option<CellChange> {
self.changes
.get(usize::from(row))
.and_then(|row_changes| row_changes.get(usize::from(col)).copied())
}
pub fn is_identical(&self) -> bool {
self.changes
.iter()
.all(|row| row.iter().all(|change| *change == CellChange::Unchanged))
}
pub fn changed_regions(&self) -> Vec<ChangedRegion> {
let mut regions = Vec::new();
for (row_index, row_changes) in self.changes.iter().enumerate() {
let row = u16::try_from(row_index).unwrap_or(0);
let mut span_start: Option<(u16, CellChange)> = None;
for (col_index, &change) in row_changes.iter().enumerate() {
let col = u16::try_from(col_index).unwrap_or(0);
if change == CellChange::Unchanged {
if let Some((start_col, change_type)) = span_start.take() {
regions.push(ChangedRegion {
region: Region::new(start_col, row, col - start_col, 1),
change_type,
});
}
} else if let Some((_, ref mut current_type)) = span_start {
if *current_type != change {
*current_type = CellChange::BothChanged;
}
} else {
span_start = Some((col, change));
}
}
if let Some((start_col, change_type)) = span_start {
regions.push(ChangedRegion {
region: Region::new(start_col, row, self.cols - start_col, 1),
change_type,
});
}
}
regions
}
pub fn summary(&self) -> Vec<String> {
self.changed_regions()
.iter()
.map(|changed_region| {
let row = changed_region.region.row;
let start_col = changed_region.region.col;
let end_col = start_col + changed_region.region.width - 1;
let change_label = match changed_region.change_type {
CellChange::TextChanged => "text changed",
CellChange::StyleChanged => "style changed",
CellChange::BothChanged => "text and style changed",
CellChange::Unchanged => "unchanged",
};
let mut description = String::new();
if start_col == end_col {
let _ = write!(description, "row {row}, col {start_col}: {change_label}");
} else {
let _ = write!(
description,
"row {row}, cols {start_col}-{end_col}: {change_label}"
);
}
description
})
.collect()
}
}
fn compare_cell(before: &TerminalFrame, after: &TerminalFrame, row: u16, col: u16) -> CellChange {
let before_in_bounds = row < before.rows() && col < before.cols();
let after_in_bounds = row < after.rows() && col < after.cols();
if !before_in_bounds || !after_in_bounds {
return CellChange::BothChanged;
}
let text_changed = before.cell_text(row, col) != after.cell_text(row, col);
let style_changed = before.fg_color(row, col) != after.fg_color(row, col)
|| before.bg_color(row, col) != after.bg_color(row, col)
|| before.cell_style(row, col) != after.cell_style(row, col);
match (text_changed, style_changed) {
(false, false) => CellChange::Unchanged,
(true, false) => CellChange::TextChanged,
(false, true) => CellChange::StyleChanged,
(true, true) => CellChange::BothChanged,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn identical_frames_produce_no_changes() {
let data = b"Hello, World!";
let frame_a = TerminalFrame::new(80, 24, data);
let frame_b = TerminalFrame::new(80, 24, data);
let diff = FrameDiff::compute(&frame_a, &frame_b);
assert!(diff.is_identical());
assert!(diff.changed_regions().is_empty());
assert!(diff.summary().is_empty());
}
#[test]
fn single_cell_text_change_detected() {
let frame_a = TerminalFrame::new(80, 24, b"ABC");
let frame_b = TerminalFrame::new(80, 24, b"AXC");
let diff = FrameDiff::compute(&frame_a, &frame_b);
assert!(!diff.is_identical());
assert_eq!(diff.cell_change(0, 0), Some(CellChange::Unchanged));
assert_eq!(diff.cell_change(0, 1), Some(CellChange::TextChanged));
assert_eq!(diff.cell_change(0, 2), Some(CellChange::Unchanged));
}
#[test]
fn adjacent_changes_merge_into_region() {
let frame_a = TerminalFrame::new(80, 24, b"AAAAAA");
let frame_b = TerminalFrame::new(80, 24, b"ABBBBA");
let diff = FrameDiff::compute(&frame_a, &frame_b);
let regions = diff.changed_regions();
assert_eq!(regions.len(), 1);
assert_eq!(regions[0].region.col, 1);
assert_eq!(regions[0].region.width, 4);
assert_eq!(regions[0].change_type, CellChange::TextChanged);
}
#[test]
fn summary_formats_human_readable_text() {
let frame_a = TerminalFrame::new(80, 24, b"ABC");
let frame_b = TerminalFrame::new(80, 24, b"AXC");
let diff = FrameDiff::compute(&frame_a, &frame_b);
let summary = diff.summary();
assert_eq!(summary.len(), 1);
assert!(summary[0].contains("row 0"));
assert!(summary[0].contains("col 1"));
assert!(summary[0].contains("text changed"));
}
#[test]
fn style_change_detected() {
let frame_a = TerminalFrame::new(80, 24, b"A");
let frame_b = TerminalFrame::new(80, 24, b"\x1b[1mA\x1b[0m");
let diff = FrameDiff::compute(&frame_a, &frame_b);
assert!(!diff.is_identical());
assert_eq!(diff.cell_change(0, 0), Some(CellChange::StyleChanged));
}
#[test]
fn out_of_bounds_cell_returns_none() {
let frame = TerminalFrame::new(10, 5, b"Hi");
let diff = FrameDiff::compute(&frame, &frame);
assert!(diff.cell_change(100, 100).is_none());
}
#[test]
fn different_size_frames_mark_extra_cells() {
let frame_a = TerminalFrame::new(5, 1, b"Hello");
let frame_b = TerminalFrame::new(10, 1, b"Hello");
let diff = FrameDiff::compute(&frame_a, &frame_b);
assert_eq!(diff.cell_change(0, 0), Some(CellChange::Unchanged));
assert_eq!(diff.cell_change(0, 5), Some(CellChange::BothChanged));
}
#[test]
fn shrunk_frame_marks_removed_cols_as_changed() {
let frame_a = TerminalFrame::new(10, 1, b"HelloWorld");
let frame_b = TerminalFrame::new(5, 1, b"Hello");
let diff = FrameDiff::compute(&frame_a, &frame_b);
assert_eq!(diff.cols(), 10);
assert_eq!(diff.cell_change(0, 0), Some(CellChange::Unchanged));
assert_eq!(diff.cell_change(0, 4), Some(CellChange::Unchanged));
assert_eq!(diff.cell_change(0, 5), Some(CellChange::BothChanged));
assert_eq!(diff.cell_change(0, 9), Some(CellChange::BothChanged));
}
#[test]
fn shrunk_frame_marks_removed_rows_as_changed() {
let frame_a = TerminalFrame::new(5, 3, b"A\nB\nC");
let frame_b = TerminalFrame::new(5, 1, b"A");
let diff = FrameDiff::compute(&frame_a, &frame_b);
assert_eq!(diff.rows(), 3);
assert!(!diff.is_identical());
assert_eq!(diff.cell_change(1, 0), Some(CellChange::BothChanged));
assert_eq!(diff.cell_change(2, 0), Some(CellChange::BothChanged));
}
#[test]
fn summary_single_col_format() {
let frame_a = TerminalFrame::new(80, 24, b"A");
let frame_b = TerminalFrame::new(80, 24, b"B");
let diff = FrameDiff::compute(&frame_a, &frame_b);
let summary = diff.summary();
assert_eq!(summary.len(), 1);
assert!(summary[0].contains("col 0:"));
}
}