use crate::protocol::types::Face; use super::frame::Frame; #[derive(Debug, Clone, PartialEq, Eq)] pub enum WriteInstruction { MoveCursor { row: usize, col: usize }, SetFace(Face), Text(String), ShowCursor { row: usize, col: usize }, HideCursor, } pub fn diff(previous: &Frame, next: &Frame) -> Vec { // A resized grid has no cell-by-cell correspondence, so repaint every next-frame cell. let dimensions_changed = previous.width != next.width || previous.height != next.height; let mut instructions = Vec::new(); for row in 0..next.height { let mut column = 0; while column < next.width { let Some(next_cell) = next.cell(row, column) else { break; }; let changed = dimensions_changed || previous .cell(row, column) .is_none_or(|previous_cell| previous_cell != next_cell); if !changed { column += 1; continue; } // Merge only adjacent changed cells with the same face; unchanged gaps stay separate. let face = next_cell.face; let run_start = column; let mut text = String::new(); while column < next.width { let Some(next_cell) = next.cell(row, column) else { break; }; let changed = dimensions_changed || previous .cell(row, column) .is_none_or(|previous_cell| previous_cell != next_cell); if !changed || next_cell.face != face { break; } next_cell.append_text(&mut text); column += 1; } if text.is_empty() { // A changed wide-glyph trailing cell must still clear old terminal content. text.push(' '); } instructions.push(WriteInstruction::MoveCursor { row, col: run_start, }); instructions.push(WriteInstruction::SetFace(face)); instructions.push(WriteInstruction::Text(text)); } } // Text runs move the terminal cursor; restore the editor cursor after repainting. let cells_changed = !instructions.is_empty(); match next.cursor { Some((row, col)) if previous.cursor != next.cursor || cells_changed => { instructions.push(WriteInstruction::ShowCursor { row, col }); } None if previous.cursor.is_some() => instructions.push(WriteInstruction::HideCursor), _ => {} } instructions } #[cfg(test)] mod tests { use super::*; use crate::protocol::types::{Color, Coord, DisplayAtom, NamedColor}; use crate::render::frame::{Cell, build_base_frame}; fn blank(width: usize, height: usize) -> Frame { Frame::blank(width, height, &Face::default()) } fn with_cells(width: usize, cells: &[(usize, &str, Face)]) -> Frame { let mut frame = blank(width, 1); for (column, text, face) in cells { frame.set_cell(0, *column, Cell::new(text, *face)); } frame } #[test] fn unchanged_frames_produce_no_instructions() { let frame = with_cells(3, &[(0, "a", Face::default())]); assert!(diff(&frame, &frame).is_empty()); } #[test] fn changed_cell_produces_one_write_run() { let previous = blank(3, 1); let next = with_cells(3, &[(1, "x", Face::default())]); assert_eq!( diff(&previous, &next), vec![ WriteInstruction::MoveCursor { row: 0, col: 1 }, WriteInstruction::SetFace(Face::default()), WriteInstruction::Text("x".into()), ] ); } #[test] fn adjacent_changed_cells_with_the_same_face_are_merged() { let previous = blank(4, 1); let next = with_cells(4, &[(1, "x", Face::default()), (2, "y", Face::default())]); assert_eq!( diff(&previous, &next), vec![ WriteInstruction::MoveCursor { row: 0, col: 1 }, WriteInstruction::SetFace(Face::default()), WriteInstruction::Text("xy".into()), ] ); } #[test] fn unchanged_gaps_are_not_bridged() { let previous = blank(4, 1); let next = with_cells(4, &[(0, "x", Face::default()), (2, "y", Face::default())]); let instructions = diff(&previous, &next); assert_eq!( instructions, vec![ WriteInstruction::MoveCursor { row: 0, col: 0 }, WriteInstruction::SetFace(Face::default()), WriteInstruction::Text("x".into()), WriteInstruction::MoveCursor { row: 0, col: 2 }, WriteInstruction::SetFace(Face::default()), WriteInstruction::Text("y".into()), ] ); } #[test] fn changed_cells_with_different_faces_form_separate_runs() { let previous = blank(3, 1); let red = Face { fg: Color::Named(NamedColor::Red), ..Face::default() }; let blue = Face { fg: Color::Named(NamedColor::Blue), ..Face::default() }; let next = with_cells(3, &[(0, "r", red), (1, "b", blue)]); assert_eq!( diff(&previous, &next), vec![ WriteInstruction::MoveCursor { row: 0, col: 0 }, WriteInstruction::SetFace(red), WriteInstruction::Text("r".into()), WriteInstruction::MoveCursor { row: 0, col: 1 }, WriteInstruction::SetFace(blue), WriteInstruction::Text("b".into()), ] ); } #[test] fn dimension_change_forces_a_full_repaint() { let previous = blank(1, 1); let next = with_cells(3, &[(1, "x", Face::default())]); assert_eq!( diff(&previous, &next), vec![ WriteInstruction::MoveCursor { row: 0, col: 0 }, WriteInstruction::SetFace(Face::default()), WriteInstruction::Text(" x ".into()), ] ); } #[test] fn resized_draw_repaints_rows_that_were_padding_before_resize() { let default_face = Face::default(); let lines = |count| { (0..count) .map(|line| { vec![DisplayAtom { face: default_face, contents: format!("{line:02}\n"), }] }) .collect::>() }; let previous_lines = lines(24); let next_lines = lines(52); let previous = build_base_frame( 2, 52, &previous_lines, Coord { line: 0, column: 0 }, &default_face, &Face::default(), 0, ); let next = build_base_frame( 2, 52, &next_lines, Coord { line: 0, column: 0 }, &default_face, &Face::default(), 0, ); assert_eq!(previous.cell(51, 0).unwrap().text(), "~"); assert_eq!(next.cell(51, 0).unwrap().text(), "5"); assert!(diff(&previous, &next).iter().any(|instruction| { matches!(instruction, WriteInstruction::MoveCursor { row: 51, .. }) })); } #[test] fn cursor_changes_are_reported_after_cell_writes() { let previous = blank(2, 1); let mut next = with_cells(2, &[(0, "x", Face::default())]); next.cursor = Some((0, 1)); assert_eq!( diff(&previous, &next), vec![ WriteInstruction::MoveCursor { row: 0, col: 0 }, WriteInstruction::SetFace(Face::default()), WriteInstruction::Text("x".into()), WriteInstruction::ShowCursor { row: 0, col: 1 }, ] ); } #[test] fn cursor_visibility_transitions_are_reported() { let mut previous = blank(2, 1); previous.cursor = Some((0, 1)); let next = blank(2, 1); assert_eq!(diff(&previous, &next), vec![WriteInstruction::HideCursor]); } }