diff --git a/packages/iocraft/src/canvas.rs b/packages/iocraft/src/canvas.rs index c3622f5..ef0b35c 100644 --- a/packages/iocraft/src/canvas.rs +++ b/packages/iocraft/src/canvas.rs @@ -248,142 +248,165 @@ impl Canvas { } } - fn write_impl( - &self, - mut w: W, - ansi: bool, - omit_final_newline: bool, - ) -> io::Result<()> { - if ansi { - write!(w, csi!("0m"))?; - } + fn row(&self, y: usize) -> &[CanvasCell] { + let Some(row) = self.cells.get(y) else { + return &[]; + }; + let last_non_empty = row.iter().rposition(|cell| !cell.is_empty()); + &row[..last_non_empty.map_or(0, |i| i + 1)] + } + + pub(crate) fn row_eq(&self, other: &Self, y: usize) -> bool { + self.width == other.width && self.row(y) == other.row(y) + } + + /// Writes a single row. + /// + /// In ANSI mode the caller must ensure that SGR state is reset (e.g. via + /// `CSI 0 m`) before invoking this method; the function does not emit a + /// leading reset of its own. It always leaves SGR state reset on return, + /// so consecutive calls (or any subsequent writer use) start from a clean + /// state. + fn write_row_impl(&self, y: usize, mut w: W, ansi: bool) -> io::Result<()> { + let row = self.row(y); let mut background_color = None; let mut text_style = CanvasTextStyle::default(); + let mut col = 0; + let mut did_clear_line = false; + while col < row.len() { + let cell = &row[col]; - for y in 0..self.cells.len() { - let row = &self.cells[y]; - let last_non_empty = row.iter().rposition(|cell| !cell.is_empty()); - let row = &row[..last_non_empty.map_or(0, |i| i + 1)]; - let mut col = 0; - let mut did_clear_line = false; - while col < row.len() { - let cell = &row[col]; - - if ansi { - // For certain changes, we need to reset all attributes. - let mut needs_reset = false; - if let Some(c) = &cell.character { - if c.style.weight != text_style.weight && c.style.weight == Weight::Normal { - needs_reset = true; - } - if !c.style.underline && text_style.underline { - needs_reset = true; - } - if !c.style.italic && text_style.italic { - needs_reset = true; - } - if !c.style.invert && text_style.invert { - needs_reset = true; - } - } else if text_style.underline || text_style.invert { + if ansi { + let mut needs_reset = false; + if let Some(c) = &cell.character { + if c.style.weight != text_style.weight && c.style.weight == Weight::Normal { needs_reset = true; } - if needs_reset { - write!(w, csi!("0m"))?; - background_color = None; - text_style = CanvasTextStyle::default(); + if !c.style.underline && text_style.underline { + needs_reset = true; } + if !c.style.italic && text_style.italic { + needs_reset = true; + } + if !c.style.invert && text_style.invert { + needs_reset = true; + } + } else if text_style.underline || text_style.invert { + needs_reset = true; + } + if needs_reset { + write!(w, csi!("0m"))?; + background_color = None; + text_style = CanvasTextStyle::default(); + } - if let Some(c) = &cell.character { - if c.style.color != text_style.color { - write!( - w, - csi!("{}m"), - Colored::ForegroundColor(c.style.color.unwrap_or(Color::Reset)) - )?; - } - - if c.style.weight != text_style.weight { - match c.style.weight { - Weight::Bold => write!(w, csi!("{}m"), Attribute::Bold.sgr())?, - Weight::Normal => {} - Weight::Light => write!(w, csi!("{}m"), Attribute::Dim.sgr())?, - } - } - - if c.style.underline && !text_style.underline { - write!(w, csi!("{}m"), Attribute::Underlined.sgr())?; - } - - if c.style.italic && !text_style.italic { - write!(w, csi!("{}m"), Attribute::Italic.sgr())?; - } + if let Some(c) = &cell.character { + if c.style.color != text_style.color { + write!( + w, + csi!("{}m"), + Colored::ForegroundColor(c.style.color.unwrap_or(Color::Reset)) + )?; + } - if c.style.invert && !text_style.invert { - write!(w, csi!("{}m"), Attribute::Reverse.sgr())?; + if c.style.weight != text_style.weight { + match c.style.weight { + Weight::Bold => write!(w, csi!("{}m"), Attribute::Bold.sgr())?, + Weight::Normal => {} + Weight::Light => write!(w, csi!("{}m"), Attribute::Dim.sgr())?, } - - text_style = c.style; } - } - if let Some(c) = &cell.character { - col += c.value.width().max(1); - } else { - col += 1; - } + if c.style.underline && !text_style.underline { + write!(w, csi!("{}m"), Attribute::Underlined.sgr())?; + } - if ansi && col >= self.width { - // go ahead and clear until end of line. we need to do this before writing - // the last character, because if we're at the end of the terminal row, the - // cursor won't change position and the last character would be erased - // if we did it later - // see: https://github.com/ccbrown/iocraft/issues/83 - - // make sure to reset the background before clearing - // see: https://github.com/ccbrown/iocraft/issues/142 - if background_color.is_some() { - write!(w, csi!("{}m"), Colored::BackgroundColor(Color::Reset))?; - background_color = None; + if c.style.italic && !text_style.italic { + write!(w, csi!("{}m"), Attribute::Italic.sgr())?; } - write!(w, csi!("K"))?; - did_clear_line = true; - } + if c.style.invert && !text_style.invert { + write!(w, csi!("{}m"), Attribute::Reverse.sgr())?; + } - if ansi && cell.background_color != background_color { - write!( - w, - csi!("{}m"), - Colored::BackgroundColor(cell.background_color.unwrap_or(Color::Reset)) - )?; - background_color = cell.background_color; + text_style = c.style; } + } - if let Some(c) = &cell.character { - write!(w, "{}{}", c.value, " ".repeat(c.required_padding()))?; - } else { - w.write_all(b" ")?; - } + if let Some(c) = &cell.character { + col += c.value.width().max(1); + } else { + col += 1; } - if ansi { - // if the background color is set, we need to reset it + + if ansi && col >= self.width { if background_color.is_some() { write!(w, csi!("{}m"), Colored::BackgroundColor(Color::Reset))?; background_color = None; } - if !did_clear_line { - // clear until end of line - write!(w, csi!("K"))?; - } + + write!(w, csi!("K"))?; + did_clear_line = true; + } + + if ansi && cell.background_color != background_color { + write!( + w, + csi!("{}m"), + Colored::BackgroundColor(cell.background_color.unwrap_or(Color::Reset)) + )?; + background_color = cell.background_color; + } + + if let Some(c) = &cell.character { + write!(w, "{}{}", c.value, " ".repeat(c.required_padding()))?; + } else { + w.write_all(b" ")?; + } + } + if ansi { + if background_color.is_some() { + write!(w, csi!("{}m"), Colored::BackgroundColor(Color::Reset))?; + } + if !did_clear_line { + write!(w, csi!("K"))?; } + write!(w, csi!("0m"))?; + } + Ok(()) + } + + /// Writes a single row's ANSI representation without a trailing newline. + /// + /// The caller must ensure SGR state is reset before this is called (the + /// terminal's default state qualifies). The function leaves SGR state + /// reset on return, so a sequence of calls — separated only by cursor + /// movement — will each start from a clean state. + pub(crate) fn write_ansi_row_without_newline( + &self, + y: usize, + w: W, + ) -> io::Result<()> { + self.write_row_impl(y, w, true) + } + + fn write_impl( + &self, + mut w: W, + ansi: bool, + omit_final_newline: bool, + ) -> io::Result<()> { + if ansi { + // Seed clean SGR state for the first row. Subsequent rows rely on + // the trailing reset of the previous row. + write!(w, csi!("0m"))?; + } + for y in 0..self.cells.len() { + self.write_row_impl(y, &mut w, ansi)?; let is_final_line = y == self.cells.len() - 1; if !omit_final_newline || !is_final_line { if ansi { - if is_final_line { - write!(w, csi!("0m"))?; - } // add a carriage return in case we're in raw mode w.write_all(b"\r\n")?; } else { @@ -391,9 +414,6 @@ impl Canvas { } } } - if ansi && omit_final_newline { - write!(w, csi!("0m"))?; - } w.flush()?; Ok(()) } @@ -581,6 +601,7 @@ mod tests { canvas.write_ansi(&mut actual).unwrap(); let mut expected = Vec::new(); + // row 0 write!(expected, csi!("0m")).unwrap(); write!(expected, " ").unwrap(); write!(expected, csi!("{}m"), Colored::BackgroundColor(Color::Red)).unwrap(); @@ -592,7 +613,9 @@ mod tests { ) .unwrap(); write!(expected, csi!("K")).unwrap(); + write!(expected, csi!("0m")).unwrap(); write!(expected, "\r\n").unwrap(); + // row 1 write!(expected, " ").unwrap(); write!(expected, csi!("{}m"), Colored::BackgroundColor(Color::Red)).unwrap(); write!(expected, " ").unwrap(); @@ -603,7 +626,9 @@ mod tests { ) .unwrap(); write!(expected, csi!("K")).unwrap(); + write!(expected, csi!("0m")).unwrap(); write!(expected, "\r\n").unwrap(); + // row 2 write!(expected, csi!("K")).unwrap(); write!(expected, csi!("0m")).unwrap(); write!(expected, "\r\n").unwrap(); @@ -649,6 +674,7 @@ mod tests { Colored::BackgroundColor(Color::Reset) ) .unwrap(); + write!(expected, csi!("0m")).unwrap(); write!(expected, "\r\n").unwrap(); // line 2 @@ -669,6 +695,7 @@ mod tests { Colored::BackgroundColor(Color::Reset) ) .unwrap(); + write!(expected, csi!("0m")).unwrap(); write!(expected, "\r\n").unwrap(); // line 3 @@ -884,12 +911,17 @@ mod tests { .unwrap(); let mut expected = Vec::new(); + // row 0 write!(expected, csi!("0m")).unwrap(); write!(expected, "hello!").unwrap(); write!(expected, csi!("K")).unwrap(); + write!(expected, csi!("0m")).unwrap(); write!(expected, "\r\n").unwrap(); + // row 1 write!(expected, csi!("K")).unwrap(); + write!(expected, csi!("0m")).unwrap(); write!(expected, "\r\n").unwrap(); + // row 2 (final, no newline) write!(expected, csi!("K")).unwrap(); write!(expected, csi!("0m")).unwrap(); @@ -1024,4 +1056,81 @@ line two assert_eq!(sv.get_text(0, 0, 6, 1), "hello"); assert_eq!(sv.get_text(0, 0, 6, 2), "hello\nworld"); } + + #[test] + fn test_row_eq_same_content() { + let mut a = Canvas::new(10, 2); + let mut b = Canvas::new(10, 2); + a.subview_mut(0, 0, 0, 0, 10, 2) + .set_text(0, 0, "hello", CanvasTextStyle::default()); + b.subview_mut(0, 0, 0, 0, 10, 2) + .set_text(0, 0, "hello", CanvasTextStyle::default()); + + assert!(a.row_eq(&b, 0)); + assert!(a.row_eq(&b, 1)); + } + + #[test] + fn test_row_eq_different_content() { + let mut a = Canvas::new(10, 1); + let mut b = Canvas::new(10, 1); + a.subview_mut(0, 0, 0, 0, 10, 1) + .set_text(0, 0, "hello", CanvasTextStyle::default()); + b.subview_mut(0, 0, 0, 0, 10, 1) + .set_text(0, 0, "world", CanvasTextStyle::default()); + + assert!(!a.row_eq(&b, 0)); + } + + #[test] + fn test_row_eq_different_widths() { + let mut a = Canvas::new(10, 1); + let mut b = Canvas::new(20, 1); + a.subview_mut(0, 0, 0, 0, 10, 1) + .set_text(0, 0, "hello", CanvasTextStyle::default()); + b.subview_mut(0, 0, 0, 0, 20, 1) + .set_text(0, 0, "hello", CanvasTextStyle::default()); + + assert!(!a.row_eq(&b, 0)); + } + + #[test] + fn test_row_eq_out_of_bounds() { + let a = Canvas::new(10, 1); + let b = Canvas::new(10, 2); + + // row 1 is out of bounds for a, but exists (empty) in b + assert!(a.row_eq(&b, 1)); + } + + #[test] + fn test_write_ansi_row_without_newline() { + let mut canvas = Canvas::new(10, 2); + canvas + .subview_mut(0, 0, 0, 0, 10, 2) + .set_text(0, 0, "hello", CanvasTextStyle::default()); + canvas + .subview_mut(0, 0, 0, 0, 10, 2) + .set_text(0, 1, "world", CanvasTextStyle::default()); + + // Each row renders without a leading reset (caller's contract is to + // provide clean SGR state) but always leaves SGR state reset on return. + let mut row0 = Vec::new(); + canvas.write_ansi_row_without_newline(0, &mut row0).unwrap(); + + let mut expected0 = Vec::new(); + write!(expected0, "hello").unwrap(); + write!(expected0, csi!("K")).unwrap(); + write!(expected0, csi!("0m")).unwrap(); + assert_eq!(row0, expected0); + + let mut row1 = Vec::new(); + canvas.write_ansi_row_without_newline(1, &mut row1).unwrap(); + + let mut expected1 = Vec::new(); + write!(expected1, "world").unwrap(); + write!(expected1, csi!("K")).unwrap(); + write!(expected1, csi!("0m")).unwrap(); + assert_eq!(row1, expected1); + } } diff --git a/packages/iocraft/src/components/text.rs b/packages/iocraft/src/components/text.rs index d9abc3c..630af92 100644 --- a/packages/iocraft/src/components/text.rs +++ b/packages/iocraft/src/components/text.rs @@ -329,13 +329,15 @@ mod tests { canvas.write_ansi(&mut actual).unwrap(); let mut expected = Vec::new(); + // row 0 write!(expected, csi!("0m")).unwrap(); write!(expected, " ").unwrap(); write!(expected, csi!("{}m"), Attribute::Underlined.sgr()).unwrap(); write!(expected, "this is an").unwrap(); write!(expected, csi!("K")).unwrap(); - write!(expected, "\r\n").unwrap(); write!(expected, csi!("0m")).unwrap(); + write!(expected, "\r\n").unwrap(); + // row 1 write!(expected, " ").unwrap(); write!(expected, csi!("{}m"), Attribute::Underlined.sgr()).unwrap(); write!(expected, "alignment test").unwrap(); diff --git a/packages/iocraft/src/render.rs b/packages/iocraft/src/render.rs index 3a3f546..f07f6d9 100644 --- a/packages/iocraft/src/render.rs +++ b/packages/iocraft/src/render.rs @@ -470,10 +470,12 @@ impl<'a> Tree<'a> { let output = self.render(terminal_size.map(|(w, _)| w as usize), Some(&mut term)); if output.did_clear_terminal_output || prev_canvas.as_ref() != Some(&output.canvas) { - if !output.did_clear_terminal_output { - term.clear_canvas()?; - } - term.write_canvas(&output.canvas)?; + let prev = if output.did_clear_terminal_output { + None + } else { + prev_canvas.as_ref() + }; + term.write_canvas(prev, &output.canvas)?; } prev_canvas = Some(output.canvas); Ok(()) diff --git a/packages/iocraft/src/terminal.rs b/packages/iocraft/src/terminal.rs index a0cce93..3ff20e2 100644 --- a/packages/iocraft/src/terminal.rs +++ b/packages/iocraft/src/terminal.rs @@ -122,7 +122,7 @@ trait TerminalImpl: Write + Send { fn is_raw_mode_enabled(&self) -> bool; fn clear_canvas(&mut self) -> io::Result<()>; - fn write_canvas(&mut self, canvas: &Canvas) -> io::Result<()>; + fn write_canvas(&mut self, prev: Option<&Canvas>, canvas: &Canvas) -> io::Result<()>; fn event_stream(&mut self) -> io::Result>; fn dest(&mut self) -> &mut dyn Write; fn alt(&mut self) -> &mut dyn Write; @@ -152,6 +152,7 @@ struct StdTerminal<'a> { mouse_capture: bool, raw_mode_enabled: bool, enabled_keyboard_enhancement: bool, + prev_canvas_top_row: u16, prev_canvas_height: u16, size: Option<(u16, u16)>, } @@ -198,26 +199,141 @@ impl TerminalImpl for StdTerminal<'_> { return Ok(()); } - if !self.fullscreen { - if let Some(size) = self.size { - if self.prev_canvas_height >= size.1 { - // We have to clear the entire terminal to avoid leaving artifacts. - // See: https://github.com/ccbrown/iocraft/issues/118 + if self.fullscreen { + self.dest + .queue(cursor::MoveTo(0, self.prev_canvas_top_row))? + .queue(terminal::Clear(terminal::ClearType::FromCursorDown))?; + return Ok(()); + } + + if let Some(size) = self.size { + if self.prev_canvas_height >= size.1 { + // We have to clear the entire terminal to avoid leaving artifacts. + // See: https://github.com/ccbrown/iocraft/issues/118 + self.dest + .queue(terminal::Clear(terminal::ClearType::All))? + .queue(terminal::Clear(terminal::ClearType::Purge))? + .queue(cursor::MoveTo(0, 0))?; + return Ok(()); + } + } + + clear_canvas_inline(&mut *self.dest, self.prev_canvas_height) + } + + fn write_canvas(&mut self, prev: Option<&Canvas>, canvas: &Canvas) -> io::Result<()> { + let Some(prev) = prev else { + // No previous canvas: full write. + if self.fullscreen { + self.prev_canvas_top_row = 0; + self.dest.queue(cursor::MoveTo(0, 0))?; + } + self.prev_canvas_height = canvas.height() as _; + canvas.write_ansi_without_final_newline(&mut *self.dest)?; + return Ok(()); + }; + + if self.fullscreen { + // Fullscreen: absolute positioning. + let top_row = self.prev_canvas_top_row; + let max_height = prev.height().max(canvas.height()); + for y in 0..max_height { + if prev.row_eq(canvas, y) { + continue; + } + self.dest.queue(cursor::MoveTo(0, top_row + y as u16))?; + if y < canvas.height() { + canvas.write_ansi_row_without_newline(y, &mut *self.dest)?; + } else { self.dest - .queue(terminal::Clear(terminal::ClearType::All))? - .queue(terminal::Clear(terminal::ClearType::Purge))? - .queue(cursor::MoveTo(0, 0))?; + .queue(terminal::Clear(terminal::ClearType::CurrentLine))?; + } + } + if canvas.height() > 0 { + self.dest + .queue(cursor::MoveTo(0, top_row + canvas.height() as u16 - 1))?; + } + self.prev_canvas_height = canvas.height() as _; + return Ok(()); + } + + // Inline: row diff with relative cursor movement. + let prev_height = prev.height(); + let new_height = canvas.height(); + let max_height = prev_height.max(new_height); + let mut current_y = prev_height.saturating_sub(1); + + for y in 0..max_height { + if prev.row_eq(canvas, y) { + continue; + } + // If a changed row has scrolled off the top of the visible area, + // we can't reach it with cursor movement — fall back to full rewrite. + if let Some((_cols, term_h)) = self.size { + let visible_start = prev_height.saturating_sub(term_h as usize); + if y < visible_start { + self.clear_canvas()?; + self.prev_canvas_height = canvas.height() as _; + canvas.write_ansi_without_final_newline(&mut *self.dest)?; return Ok(()); } } + match y.cmp(¤t_y) { + std::cmp::Ordering::Less => { + self.dest + .queue(cursor::MoveToPreviousLine((current_y - y) as u16))?; + } + std::cmp::Ordering::Greater => { + // Lines within the previous canvas already exist in the + // terminal and can be reached with MoveToNextLine (CSI E). + // Lines beyond prev_height don't exist yet — we must emit + // \r\n to create them, since CSI E won't extend the + // scrollback when the cursor is at the bottom of the screen. + let last_existing_line = prev_height.saturating_sub(1).max(current_y); + if y <= last_existing_line { + self.dest + .queue(cursor::MoveToNextLine((y - current_y) as u16))?; + } else { + let move_to_last = last_existing_line.saturating_sub(current_y); + if move_to_last > 0 { + self.dest + .queue(cursor::MoveToNextLine(move_to_last as u16))?; + } + let new_lines = y - last_existing_line; + for _ in 0..new_lines { + self.dest.write_all(b"\r\n")?; + } + } + } + std::cmp::Ordering::Equal => { + self.dest.queue(cursor::MoveToColumn(0))?; + } + } + current_y = y; + + if y < new_height { + canvas.write_ansi_row_without_newline(y, &mut *self.dest)?; + } else { + self.dest + .queue(terminal::Clear(terminal::ClearType::CurrentLine))?; + } } - clear_canvas_inline(&mut *self.dest, self.prev_canvas_height) - } + // Reposition cursor to last row of new canvas. + let target_y = new_height.saturating_sub(1); + match target_y.cmp(¤t_y) { + std::cmp::Ordering::Greater => { + self.dest + .queue(cursor::MoveToNextLine((target_y - current_y) as u16))?; + } + std::cmp::Ordering::Less => { + self.dest + .queue(cursor::MoveToPreviousLine((current_y - target_y) as u16))?; + } + std::cmp::Ordering::Equal => {} + } - fn write_canvas(&mut self, canvas: &Canvas) -> io::Result<()> { - self.prev_canvas_height = canvas.height() as _; - canvas.write_ansi_without_final_newline(self)?; + self.prev_canvas_height = new_height as _; Ok(()) } @@ -275,6 +391,7 @@ impl<'a> StdTerminal<'a> { mouse_capture, raw_mode_enabled: false, enabled_keyboard_enhancement: false, + prev_canvas_top_row: 0, prev_canvas_height: 0, size: None, }; @@ -405,7 +522,7 @@ impl TerminalImpl for MockTerminal { Ok(()) } - fn write_canvas(&mut self, canvas: &Canvas) -> io::Result<()> { + fn write_canvas(&mut self, _prev: Option<&Canvas>, canvas: &Canvas) -> io::Result<()> { let _ = self.output.unbounded_send(canvas.clone()); Ok(()) } @@ -485,8 +602,8 @@ impl<'a> Terminal<'a> { self.inner.clear_canvas() } - pub fn write_canvas(&mut self, canvas: &Canvas) -> io::Result<()> { - self.inner.write_canvas(canvas) + pub fn write_canvas(&mut self, prev: Option<&Canvas>, canvas: &Canvas) -> io::Result<()> { + self.inner.write_canvas(prev, canvas) } pub fn received_ctrl_c(&self) -> bool { @@ -622,6 +739,30 @@ impl Drop for SynchronizedUpdate<'_, '_> { mod tests { use super::*; use crate::prelude::*; + use crossterm::QueueableCommand; + use std::sync::{Arc, Mutex}; + + #[derive(Clone, Default)] + struct TestWriter { + buf: Arc>>, + } + + impl Write for TestWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.buf.lock().unwrap().extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + + fn new_test_writer() -> (TestWriter, Arc>>) { + let writer = TestWriter::default(); + let buf = writer.buf.clone(); + (writer, buf) + } #[test] fn test_std_terminal() { @@ -639,7 +780,7 @@ mod tests { assert!(!terminal.received_ctrl_c()); assert!(!terminal.is_raw_mode_enabled()); let canvas = Canvas::new(10, 1); - terminal.write_canvas(&canvas).unwrap(); + terminal.write_canvas(None, &canvas).unwrap(); } fn render_canvas_to_vt(canvas: &Canvas, cols: usize, rows: usize) -> avt::Vt { @@ -660,7 +801,7 @@ mod tests { } #[test] - fn test_write_canvas_single_line_cursor_position() { + fn test_inline_rewrite_single_line_cursor() { let mut canvas = Canvas::new(10, 1); canvas .subview_mut(0, 0, 0, 0, 10, 1) @@ -684,7 +825,7 @@ mod tests { } #[test] - fn test_write_canvas_multi_line_cursor_position() { + fn test_inline_rewrite_multi_line_cursor() { let mut canvas = Canvas::new(10, 3); canvas .subview_mut(0, 0, 0, 0, 10, 3) @@ -726,7 +867,7 @@ mod tests { } #[test] - fn test_write_canvas_no_extra_blank_line() { + fn test_inline_rewrite_no_extra_blank_line() { let mut canvas = Canvas::new(10, 2); canvas .subview_mut(0, 0, 0, 0, 10, 2) @@ -757,6 +898,624 @@ mod tests { assert_eq!(vt.cursor().row, 1); } + #[test] + fn test_fullscreen_diff_preserves_origin() { + let mut prev = Canvas::new(10, 2); + prev.subview_mut(0, 0, 0, 0, 10, 2) + .set_text(0, 0, "first", CanvasTextStyle::default()); + prev.subview_mut(0, 0, 0, 0, 10, 2) + .set_text(0, 1, "second", CanvasTextStyle::default()); + + let mut next = Canvas::new(10, 2); + next.subview_mut(0, 0, 0, 0, 10, 2) + .set_text(0, 0, "first", CanvasTextStyle::default()); + next.subview_mut(0, 0, 0, 0, 10, 2) + .set_text(0, 1, "changed", CanvasTextStyle::default()); + + let (dest, diff_buf) = new_test_writer(); + let mut term = new_fullscreen_term(dest, 1, prev.height() as _); + term.write_canvas(Some(&prev), &next).unwrap(); + + let mut setup = Vec::new(); + write!(setup, "log\r\n").unwrap(); + prev.write_ansi_without_final_newline(&mut setup).unwrap(); + setup.extend_from_slice(&*diff_buf.lock().unwrap()); + + let mut vt = avt::Vt::new(10, 5); + vt.feed_str(&String::from_utf8(setup).unwrap()); + + assert_eq!(vt.line(0).text(), "log "); + assert_eq!(vt.line(1).text(), "first "); + assert_eq!(vt.line(2).text(), "changed "); + assert_eq!( + vt.cursor().row, + 2, + "cursor should stay on the canvas bottom" + ); + } + + #[test] + fn test_fullscreen_clear_preserves_output_above() { + let mut canvas = Canvas::new(10, 2); + canvas + .subview_mut(0, 0, 0, 0, 10, 2) + .set_text(0, 0, "first", CanvasTextStyle::default()); + canvas + .subview_mut(0, 0, 0, 0, 10, 2) + .set_text(0, 1, "second", CanvasTextStyle::default()); + + let (dest, clear_buf) = new_test_writer(); + let mut term = new_fullscreen_term(dest, 1, canvas.height() as _); + term.clear_canvas().unwrap(); + + let mut setup = Vec::new(); + write!(setup, "log\r\n").unwrap(); + canvas.write_ansi_without_final_newline(&mut setup).unwrap(); + write!(setup, "\r\ntail").unwrap(); + setup.queue(cursor::MoveTo(0, 0)).unwrap(); + setup.extend_from_slice(&*clear_buf.lock().unwrap()); + + let mut vt = avt::Vt::new(10, 5); + vt.feed_str(&String::from_utf8(setup).unwrap()); + + assert_eq!(vt.line(0).text(), "log "); + assert_eq!(vt.line(1).text(), " "); + assert_eq!(vt.line(2).text(), " "); + assert_eq!(vt.line(3).text(), " "); + } + + fn new_fullscreen_term( + dest: TestWriter, + prev_canvas_top_row: u16, + prev_canvas_height: u16, + ) -> StdTerminal<'static> { + StdTerminal { + input_is_terminal: false, + dest: Box::new(dest), + alt: Box::new(io::sink()), + fullscreen: true, + mouse_capture: false, + raw_mode_enabled: false, + enabled_keyboard_enhancement: false, + prev_canvas_top_row, + prev_canvas_height, + size: None, + } + } + + fn new_inline_term(dest: TestWriter, prev_canvas_height: u16) -> StdTerminal<'static> { + new_inline_term_with_size(dest, prev_canvas_height, (10, 10)) + } + + fn new_inline_term_with_size( + dest: TestWriter, + prev_canvas_height: u16, + term_size: (u16, u16), + ) -> StdTerminal<'static> { + StdTerminal { + input_is_terminal: false, + dest: Box::new(dest), + alt: Box::new(io::sink()), + fullscreen: false, + mouse_capture: false, + raw_mode_enabled: false, + enabled_keyboard_enhancement: false, + prev_canvas_top_row: 0, + prev_canvas_height, + size: Some(term_size), + } + } + + /// Run an inline diff (prev → next) and return the raw diff bytes plus + /// an `avt::Vt` showing the final visible state. + fn inline_diff_vt(prev: &Canvas, next: &Canvas, term_size: (u16, u16)) -> (Vec, avt::Vt) { + let (dest, diff_buf) = new_test_writer(); + let mut term = new_inline_term_with_size(dest, prev.height() as _, term_size); + term.write_canvas(Some(prev), next).unwrap(); + + let diff = diff_buf.lock().unwrap().clone(); + let mut setup = Vec::new(); + prev.write_ansi_without_final_newline(&mut setup).unwrap(); + setup.extend_from_slice(&diff); + + let mut vt = avt::Vt::new(term_size.0 as _, term_size.1 as _); + vt.feed_str(&String::from_utf8(setup).unwrap()); + (diff, vt) + } + + #[test] + fn test_inline_diff_unchanged_row_skipped() { + let mut prev = Canvas::new(10, 2); + prev.subview_mut(0, 0, 0, 0, 10, 2) + .set_text(0, 0, "first", CanvasTextStyle::default()); + prev.subview_mut(0, 0, 0, 0, 10, 2) + .set_text(0, 1, "second", CanvasTextStyle::default()); + + let mut next = Canvas::new(10, 2); + next.subview_mut(0, 0, 0, 0, 10, 2) + .set_text(0, 0, "first", CanvasTextStyle::default()); + next.subview_mut(0, 0, 0, 0, 10, 2) + .set_text(0, 1, "changed", CanvasTextStyle::default()); + + let (dest, diff_buf) = new_test_writer(); + let mut term = new_inline_term(dest, prev.height() as _); + term.write_canvas(Some(&prev), &next).unwrap(); + + // Build vt: render prev, then apply diff output. + let mut setup = Vec::new(); + prev.write_ansi_without_final_newline(&mut setup).unwrap(); + setup.extend_from_slice(&*diff_buf.lock().unwrap()); + + let mut vt = avt::Vt::new(10, 5); + vt.feed_str(&String::from_utf8(setup).unwrap()); + + assert_eq!(vt.line(0).text(), "first "); + assert_eq!(vt.line(1).text(), "changed "); + assert_eq!(vt.cursor().row, 1); + } + + #[test] + fn test_inline_diff_shrinking() { + let mut prev = Canvas::new(10, 3); + prev.subview_mut(0, 0, 0, 0, 10, 3) + .set_text(0, 0, "aaa", CanvasTextStyle::default()); + prev.subview_mut(0, 0, 0, 0, 10, 3) + .set_text(0, 1, "bbb", CanvasTextStyle::default()); + prev.subview_mut(0, 0, 0, 0, 10, 3) + .set_text(0, 2, "ccc", CanvasTextStyle::default()); + + let mut next = Canvas::new(10, 2); + next.subview_mut(0, 0, 0, 0, 10, 2) + .set_text(0, 0, "aaa", CanvasTextStyle::default()); + next.subview_mut(0, 0, 0, 0, 10, 2) + .set_text(0, 1, "ddd", CanvasTextStyle::default()); + + let (dest, diff_buf) = new_test_writer(); + let mut term = new_inline_term(dest, prev.height() as _); + term.write_canvas(Some(&prev), &next).unwrap(); + + let mut setup = Vec::new(); + prev.write_ansi_without_final_newline(&mut setup).unwrap(); + setup.extend_from_slice(&*diff_buf.lock().unwrap()); + + let mut vt = avt::Vt::new(10, 5); + vt.feed_str(&String::from_utf8(setup).unwrap()); + + assert_eq!(vt.line(0).text(), "aaa "); + assert_eq!(vt.line(1).text(), "ddd "); + assert_eq!( + vt.line(2).text(), + " ", + "old row 2 should be cleared" + ); + assert_eq!(vt.cursor().row, 1, "cursor on last row of new canvas"); + } + + #[test] + fn test_inline_diff_growing() { + let mut prev = Canvas::new(10, 2); + prev.subview_mut(0, 0, 0, 0, 10, 2) + .set_text(0, 0, "aaa", CanvasTextStyle::default()); + prev.subview_mut(0, 0, 0, 0, 10, 2) + .set_text(0, 1, "bbb", CanvasTextStyle::default()); + + let mut next = Canvas::new(10, 3); + next.subview_mut(0, 0, 0, 0, 10, 3) + .set_text(0, 0, "aaa", CanvasTextStyle::default()); + next.subview_mut(0, 0, 0, 0, 10, 3) + .set_text(0, 1, "bbb", CanvasTextStyle::default()); + next.subview_mut(0, 0, 0, 0, 10, 3) + .set_text(0, 2, "ccc", CanvasTextStyle::default()); + + let (dest, diff_buf) = new_test_writer(); + let mut term = new_inline_term(dest, prev.height() as _); + term.write_canvas(Some(&prev), &next).unwrap(); + + let mut setup = Vec::new(); + prev.write_ansi_without_final_newline(&mut setup).unwrap(); + setup.extend_from_slice(&*diff_buf.lock().unwrap()); + + let mut vt = avt::Vt::new(10, 5); + vt.feed_str(&String::from_utf8(setup).unwrap()); + + assert_eq!(vt.line(0).text(), "aaa "); + assert_eq!(vt.line(1).text(), "bbb "); + assert_eq!(vt.line(2).text(), "ccc "); + assert_eq!(vt.cursor().row, 2, "cursor on last row of new canvas"); + } + + #[test] + fn test_inline_diff_non_adjacent_rows_forward() { + // Two non-adjacent rows change within the existing canvas. The diff + // visits row 1 first (moving the cursor up from row 4), then row 3 + // (moving forward but still within the old canvas). This exercises the + // Greater branch when y < prev_height. + let style = CanvasTextStyle::default(); + + let mut prev = Canvas::new(10, 5); + for i in 0..5 { + prev.subview_mut(0, 0, 0, 0, 10, 5) + .set_text(0, i, &format!("row{i}"), style); + } + + let mut next = Canvas::new(10, 5); + for i in 0..5 { + next.subview_mut(0, 0, 0, 0, 10, 5) + .set_text(0, i, &format!("row{i}"), style); + } + // Use same-length replacements to avoid masking the bug with + // trailing-cell issues in write_ansi_row_without_newline. + next.subview_mut(0, 0, 0, 0, 10, 5) + .set_text(0, 1, "AAA1", style); + next.subview_mut(0, 0, 0, 0, 10, 5) + .set_text(0, 3, "BBB3", style); + + let (_diff, vt) = inline_diff_vt(&prev, &next, (10, 10)); + + assert_eq!(vt.line(0).text(), "row0 "); + assert_eq!(vt.line(1).text(), "AAA1 "); + assert_eq!(vt.line(2).text(), "row2 "); + assert_eq!(vt.line(3).text(), "BBB3 "); + assert_eq!(vt.line(4).text(), "row4 "); + } + + #[test] + fn test_inline_diff_growing_at_bottom_of_screen() { + // Simulate the canvas being at the bottom of the terminal so that + // growing from 1 row to 2 requires scrolling. MoveToNextLine (CSI E) + // won't create new lines at the screen bottom — only \r\n will. + let style = CanvasTextStyle::default(); + + let mut prev = Canvas::new(10, 1); + prev.subview_mut(0, 0, 0, 0, 10, 1) + .set_text(0, 0, "hello", style); + + let mut next = Canvas::new(10, 2); + next.subview_mut(0, 0, 0, 0, 10, 2) + .set_text(0, 0, "hello", style); + next.subview_mut(0, 0, 0, 0, 10, 2) + .set_text(0, 1, "world", style); + + let (dest, diff_buf) = new_test_writer(); + let mut term = new_inline_term(dest, prev.height() as _); + term.write_canvas(Some(&prev), &next).unwrap(); + + // Fill the VT so the canvas starts on the last row, then apply the diff. + let mut setup = Vec::new(); + let vt_rows = 5; + for i in 0..vt_rows - 1 { + write!(setup, "line{i}\r\n").unwrap(); + } + prev.write_ansi_without_final_newline(&mut setup).unwrap(); + setup.extend_from_slice(&*diff_buf.lock().unwrap()); + + let mut vt = avt::Vt::new(10, vt_rows); + vt.feed_str(&String::from_utf8(setup).unwrap()); + + // The VT should have scrolled: line0 is gone, canvas occupies last 2 rows. + assert_eq!(vt.line(vt_rows - 2).text(), "hello "); + assert_eq!(vt.line(vt_rows - 1).text(), "world "); + assert_eq!( + vt.cursor().row, + vt_rows - 1, + "cursor on last row of new canvas" + ); + } + + #[test] + fn test_inline_diff_identical_canvas_is_noop() { + let mut canvas = Canvas::new(10, 2); + canvas + .subview_mut(0, 0, 0, 0, 10, 2) + .set_text(0, 0, "hello", CanvasTextStyle::default()); + canvas + .subview_mut(0, 0, 0, 0, 10, 2) + .set_text(0, 1, "world", CanvasTextStyle::default()); + + let (dest, diff_buf) = new_test_writer(); + let mut term = new_inline_term(dest, canvas.height() as _); + term.write_canvas(Some(&canvas), &canvas).unwrap(); + + assert!( + diff_buf.lock().unwrap().is_empty(), + "identical canvas should produce no output" + ); + } + + #[test] + fn test_fullscreen_diff_identical_canvas_is_noop() { + let mut canvas = Canvas::new(10, 2); + canvas + .subview_mut(0, 0, 0, 0, 10, 2) + .set_text(0, 0, "hello", CanvasTextStyle::default()); + canvas + .subview_mut(0, 0, 0, 0, 10, 2) + .set_text(0, 1, "world", CanvasTextStyle::default()); + + let (dest, diff_buf) = new_test_writer(); + let mut term = new_fullscreen_term(dest, 0, canvas.height() as _); + term.write_canvas(Some(&canvas), &canvas).unwrap(); + + // Fullscreen always queues a final MoveTo for cursor repositioning, + // but no row content should be written. Verify by checking the output + // contains no row data (the only bytes are the trailing MoveTo). + let buf = diff_buf.lock().unwrap(); + let s = String::from_utf8(buf.clone()).unwrap(); + assert!( + !s.contains("hello") && !s.contains("world"), + "identical canvas should not rewrite any row content" + ); + } + + #[test] + fn test_inline_diff_styled_text_preserved() { + let bold_style = CanvasTextStyle { + weight: Weight::Bold, + color: Some(Color::Red), + ..Default::default() + }; + + let mut prev = Canvas::new(10, 2); + prev.subview_mut(0, 0, 0, 0, 10, 2) + .set_text(0, 0, "hello", bold_style); + prev.subview_mut(0, 0, 0, 0, 10, 2) + .set_text(0, 1, "old", CanvasTextStyle::default()); + + let mut next = Canvas::new(10, 2); + next.subview_mut(0, 0, 0, 0, 10, 2) + .set_text(0, 0, "hello", bold_style); + next.subview_mut(0, 0, 0, 0, 10, 2) + .set_text(0, 1, "new", bold_style); + + let (dest, diff_buf) = new_test_writer(); + let mut term = new_inline_term(dest, prev.height() as _); + term.write_canvas(Some(&prev), &next).unwrap(); + + let mut setup = Vec::new(); + prev.write_ansi_without_final_newline(&mut setup).unwrap(); + setup.extend_from_slice(&*diff_buf.lock().unwrap()); + + let mut vt = avt::Vt::new(10, 5); + vt.feed_str(&String::from_utf8(setup).unwrap()); + + // Row 0 unchanged: bold red "hello" + let row0 = vt.line(0); + assert_eq!(row0.text(), "hello "); + assert!(row0.cells()[0].pen().is_bold()); + assert!(row0.cells()[0].pen().foreground().is_some()); + + // Row 1 updated: bold red "new" + let row1 = vt.line(1); + assert_eq!(row1.text(), "new "); + assert!(row1.cells()[0].pen().is_bold()); + assert!(row1.cells()[0].pen().foreground().is_some()); + } + + #[test] + fn test_fullscreen_diff_styled_text_preserved() { + let underline_style = CanvasTextStyle { + underline: true, + color: Some(Color::Green), + ..Default::default() + }; + + let mut prev = Canvas::new(10, 2); + prev.subview_mut(0, 0, 0, 0, 10, 2) + .set_text(0, 0, "keep", underline_style); + prev.subview_mut(0, 0, 0, 0, 10, 2) + .set_text(0, 1, "old", CanvasTextStyle::default()); + + let mut next = Canvas::new(10, 2); + next.subview_mut(0, 0, 0, 0, 10, 2) + .set_text(0, 0, "keep", underline_style); + next.subview_mut(0, 0, 0, 0, 10, 2) + .set_text(0, 1, "new", underline_style); + + let (dest, diff_buf) = new_test_writer(); + let mut term = new_fullscreen_term(dest, 0, prev.height() as _); + term.write_canvas(Some(&prev), &next).unwrap(); + + let mut setup = Vec::new(); + prev.write_ansi_without_final_newline(&mut setup).unwrap(); + setup.extend_from_slice(&*diff_buf.lock().unwrap()); + + let mut vt = avt::Vt::new(10, 5); + vt.feed_str(&String::from_utf8(setup).unwrap()); + + // Row 0 unchanged + let row0 = vt.line(0); + assert_eq!(row0.text(), "keep "); + assert!(row0.cells()[0].pen().is_underline()); + + // Row 1 updated with underline green + let row1 = vt.line(1); + assert_eq!(row1.text(), "new "); + assert!(row1.cells()[0].pen().is_underline()); + assert!(row1.cells()[0].pen().foreground().is_some()); + } + + #[test] + fn test_inline_diff_at_terminal_height_boundary() { + // Canvas height == terminal height uses the normal diff path when only + // visible rows changed (no off-screen changes trigger a fallback). + let mut prev = Canvas::new(10, 5); + prev.subview_mut(0, 0, 0, 0, 10, 5) + .set_text(0, 0, "aaa", CanvasTextStyle::default()); + prev.subview_mut(0, 0, 0, 0, 10, 5) + .set_text(0, 4, "bbb", CanvasTextStyle::default()); + + let mut next = Canvas::new(10, 5); + next.subview_mut(0, 0, 0, 0, 10, 5) + .set_text(0, 0, "aaa", CanvasTextStyle::default()); + next.subview_mut(0, 0, 0, 0, 10, 5) + .set_text(0, 4, "ccc", CanvasTextStyle::default()); + + let (_diff, vt) = inline_diff_vt(&prev, &next, (10, 5)); + + assert_eq!(vt.line(0).text(), "aaa "); + assert_eq!(vt.line(4).text(), "ccc "); + } + + #[test] + fn test_inline_diff_tall_canvas_visible_change() { + // Canvas (8 rows) taller than terminal (5 rows). Only the last row + // changes, which is in the visible area — the normal diff path should + // handle it without a full clear+rewrite. + let style = CanvasTextStyle::default(); + + let mut prev = Canvas::new(10, 8); + for i in 0..8 { + prev.subview_mut(0, 0, 0, 0, 10, 8) + .set_text(0, i, &format!("row{i}"), style); + } + + let mut next = Canvas::new(10, 8); + for i in 0..7 { + next.subview_mut(0, 0, 0, 0, 10, 8) + .set_text(0, i, &format!("row{i}"), style); + } + next.subview_mut(0, 0, 0, 0, 10, 8) + .set_text(0, 7, "CHANGED", style); + + let (diff, vt) = inline_diff_vt(&prev, &next, (10, 5)); + + // Should NOT contain a full clear (ClearAll = ESC[2J) + let diff_str = String::from_utf8_lossy(&diff); + assert!( + !diff_str.contains("\x1b[2J"), + "expected row-level diff, not full clear; got: {diff_str:?}" + ); + + // The bottom 5 rows of the 8-row canvas are visible in the terminal. + assert_eq!(vt.line(0).text(), "row3 "); + assert_eq!(vt.line(4).text(), "CHANGED "); + } + + #[test] + fn test_inline_diff_tall_canvas_offscreen_change() { + // Canvas (8 rows) taller than terminal (5 rows). A row above the + // visible area changes — this must trigger the full-rewrite fallback + // since we can't cursor to an off-screen row. + let style = CanvasTextStyle::default(); + + let mut prev = Canvas::new(10, 8); + for i in 0..8 { + prev.subview_mut(0, 0, 0, 0, 10, 8) + .set_text(0, i, &format!("row{i}"), style); + } + + let mut next = Canvas::new(10, 8); + for i in 0..8 { + next.subview_mut(0, 0, 0, 0, 10, 8) + .set_text(0, i, &format!("row{i}"), style); + } + // Change row 1, which is above the visible area (visible_start = 8-5 = 3). + next.subview_mut(0, 0, 0, 0, 10, 8) + .set_text(0, 1, "OFFSCR", style); + + let (diff, vt) = inline_diff_vt(&prev, &next, (10, 5)); + + // Should contain a full clear (ClearAll = ESC[2J, because + // prev_canvas_height >= term_height triggers the heavy clear path). + let diff_str = String::from_utf8_lossy(&diff); + assert!( + diff_str.contains("\x1b[2J"), + "expected full clear fallback; got: {diff_str:?}" + ); + + // After full rewrite, the bottom 5 rows of the new canvas are visible. + assert_eq!(vt.line(0).text(), "row3 "); + assert_eq!(vt.line(4).text(), "row7 "); + } + + #[test] + fn test_inline_diff_sequential_updates() { + let style = CanvasTextStyle::default(); + + let mut c1 = Canvas::new(10, 2); + c1.subview_mut(0, 0, 0, 0, 10, 2) + .set_text(0, 0, "aaa", style); + c1.subview_mut(0, 0, 0, 0, 10, 2) + .set_text(0, 1, "bbb", style); + + let mut c2 = Canvas::new(10, 2); + c2.subview_mut(0, 0, 0, 0, 10, 2) + .set_text(0, 0, "aaa", style); + c2.subview_mut(0, 0, 0, 0, 10, 2) + .set_text(0, 1, "ccc", style); + + let mut c3 = Canvas::new(10, 3); + c3.subview_mut(0, 0, 0, 0, 10, 3) + .set_text(0, 0, "xxx", style); + c3.subview_mut(0, 0, 0, 0, 10, 3) + .set_text(0, 1, "ccc", style); + c3.subview_mut(0, 0, 0, 0, 10, 3) + .set_text(0, 2, "ddd", style); + + let (dest, buf) = new_test_writer(); + let mut term = new_inline_term(dest, c1.height() as _); + + // First diff: c1 -> c2 + term.write_canvas(Some(&c1), &c2).unwrap(); + // Second diff: c2 -> c3 + term.write_canvas(Some(&c2), &c3).unwrap(); + + let mut setup = Vec::new(); + c1.write_ansi_without_final_newline(&mut setup).unwrap(); + setup.extend_from_slice(&*buf.lock().unwrap()); + + let mut vt = avt::Vt::new(10, 6); + vt.feed_str(&String::from_utf8(setup).unwrap()); + + assert_eq!(vt.line(0).text(), "xxx "); + assert_eq!(vt.line(1).text(), "ccc "); + assert_eq!(vt.line(2).text(), "ddd "); + assert_eq!(vt.cursor().row, 2, "cursor on last row of final canvas"); + } + + #[test] + fn test_fullscreen_diff_sequential_updates() { + let style = CanvasTextStyle::default(); + + let mut c1 = Canvas::new(10, 2); + c1.subview_mut(0, 0, 0, 0, 10, 2) + .set_text(0, 0, "aaa", style); + c1.subview_mut(0, 0, 0, 0, 10, 2) + .set_text(0, 1, "bbb", style); + + let mut c2 = Canvas::new(10, 2); + c2.subview_mut(0, 0, 0, 0, 10, 2) + .set_text(0, 0, "aaa", style); + c2.subview_mut(0, 0, 0, 0, 10, 2) + .set_text(0, 1, "ccc", style); + + let mut c3 = Canvas::new(10, 3); + c3.subview_mut(0, 0, 0, 0, 10, 3) + .set_text(0, 0, "xxx", style); + c3.subview_mut(0, 0, 0, 0, 10, 3) + .set_text(0, 1, "ccc", style); + c3.subview_mut(0, 0, 0, 0, 10, 3) + .set_text(0, 2, "ddd", style); + + let (dest, buf) = new_test_writer(); + let mut term = new_fullscreen_term(dest, 0, c1.height() as _); + + term.write_canvas(Some(&c1), &c2).unwrap(); + term.write_canvas(Some(&c2), &c3).unwrap(); + + let mut setup = Vec::new(); + c1.write_ansi_without_final_newline(&mut setup).unwrap(); + setup.extend_from_slice(&*buf.lock().unwrap()); + + let mut vt = avt::Vt::new(10, 6); + vt.feed_str(&String::from_utf8(setup).unwrap()); + + assert_eq!(vt.line(0).text(), "xxx "); + assert_eq!(vt.line(1).text(), "ccc "); + assert_eq!(vt.line(2).text(), "ddd "); + assert_eq!(vt.cursor().row, 2, "cursor on last row of final canvas"); + } + #[test] fn test_borrowed_writers() { let mut stdout_buf: Vec = Vec::new(); @@ -772,9 +1531,145 @@ mod tests { ) .unwrap(); let canvas = Canvas::new(10, 1); - terminal.write_canvas(&canvas).unwrap(); + terminal.write_canvas(None, &canvas).unwrap(); } assert!(!stdout_buf.is_empty()); } + + /// Helper: build a pair of 10×5 canvases (4 content rows + 1 footer) that + /// differ only in a single cell's background color on `changed_row`, + /// simulating a mouse-highlight overlay. + fn make_fullscreen_diff_canvases(changed_row: usize) -> (Canvas, Canvas) { + let style = CanvasTextStyle::default(); + let width = 10; + let height = 5; + + let build = |highlight: bool| { + let mut c = Canvas::new(width, height); + let mut sv = c.subview_mut(0, 0, 0, 0, width, height); + for y in 0..4u32 { + sv.set_text(0, y as isize, &format!("row{y}"), style); + } + sv.set_text(0, 4, "FOOTER", style); + sv.set_background_color(0, 4, width, 1, Color::Green); + if highlight { + sv.set_background_color(0, changed_row as isize, 1, 1, Color::Yellow); + } + c + }; + + (build(false), build(true)) + } + + /// Verify that with `prev_canvas_top_row = 0` the fullscreen row-level + /// diff writes each changed row to its correct terminal position. + /// + /// Uses a layout with numbered content rows and a distinct footer, where + /// a single cell changes between frames (as a mouse-highlight overlay + /// would cause). + #[test] + fn test_fullscreen_diff_zero_top_row_renders_correctly() { + let (prev, next) = make_fullscreen_diff_canvases(2); + let width = prev.width(); + let height = prev.height(); + + let (dest, buf) = new_test_writer(); + let mut term = new_fullscreen_term(dest, 0, height as _); + term.write_canvas(Some(&prev), &next).unwrap(); + + // Replay: write prev canvas as the baseline already on screen, + // then apply the diff on top. + let mut setup = Vec::new(); + prev.write_ansi_without_final_newline(&mut setup).unwrap(); + setup.extend_from_slice(&*buf.lock().unwrap()); + + let mut vt = avt::Vt::new(width, height + 2); + vt.feed_str(&String::from_utf8(setup).unwrap()); + + assert_eq!(vt.line(0).text(), "row0 "); + assert_eq!(vt.line(1).text(), "row1 "); + assert_eq!(vt.line(2).text(), "row2 "); + assert_eq!(vt.line(3).text(), "row3 "); + assert_eq!( + vt.line(4).text(), + "FOOTER ", + "every row must appear at its correct terminal position" + ); + } + + /// Counterpart: with a non-zero `prev_canvas_top_row`, every changed row Y + /// is written to terminal line `top_row + Y` instead of line Y. Unchanged + /// rows are skipped by `row_eq`, so the corruption is never self-correcting. + /// + /// This demonstrates why `prev_canvas_top_row` must be anchored at 0 in + /// fullscreen mode — any stale cursor position causes the entire diff to + /// be offset. + #[test] + fn test_fullscreen_diff_nonzero_top_row_offsets_changed_rows() { + let (prev, next) = make_fullscreen_diff_canvases(1); + let width = prev.width(); + let height = prev.height(); + + // With top_row = 2 (simulating a stale cursor value), the diff for + // changed row 1 writes to terminal position 2+1 = 3 instead of 1. + let (dest, buf) = new_test_writer(); + let mut term = new_fullscreen_term(dest, 2, height as _); + term.write_canvas(Some(&prev), &next).unwrap(); + + let mut setup = Vec::new(); + prev.write_ansi_without_final_newline(&mut setup).unwrap(); + setup.extend_from_slice(&*buf.lock().unwrap()); + + let mut vt = avt::Vt::new(width, height + 4); + vt.feed_str(&String::from_utf8(setup).unwrap()); + + // Row 1's diff landed at terminal line 3 (offset 2+1) instead of 1, + // overwriting row 3's original content. + assert_eq!( + vt.line(3).text(), + "row1 ", + "row 1's diff landed at terminal line 3 (offset 2+1) instead of line 1" + ); + } + + /// Regression test: exercises the full initial-write → diff pipeline. + /// + /// `write_canvas(None, …)` must set `prev_canvas_top_row` to 0. This path + /// runs both on the very first frame and whenever `clear_terminal_output()` + /// triggers a full rewrite on a subsequent frame — in either case the + /// cursor may not be at (0, 0), so the code must reset it explicitly. + /// + /// Without the fix, the old code called `cursor::position()` which returns + /// a stale value inside `BeginSynchronizedUpdate` on real terminals, and + /// fails outright in non-TTY test environments (timeout → panic). + #[test] + fn test_fullscreen_initial_write_sets_zero_top_row() { + let (initial, next) = make_fullscreen_diff_canvases(2); + let width = initial.width(); + let height = initial.height(); + + let (dest, buf) = new_test_writer(); + let mut term = new_fullscreen_term(dest, 99, 0); // start with intentionally wrong value + + // The initial write must set prev_canvas_top_row = 0 (the fix). + // Without the fix, this panics due to cursor::position() timeout. + term.write_canvas(None, &initial).unwrap(); + assert_eq!( + term.prev_canvas_top_row, 0, + "initial fullscreen write must anchor prev_canvas_top_row at 0" + ); + + // Subsequent diff should render correctly with top_row = 0. + term.write_canvas(Some(&initial), &next).unwrap(); + + let mut vt = avt::Vt::new(width, height + 2); + vt.feed_str(&String::from_utf8(buf.lock().unwrap().clone()).unwrap()); + + assert_eq!(vt.line(0).text(), "row0 "); + assert_eq!(vt.line(1).text(), "row1 "); + assert_eq!(vt.line(2).text(), "row2 "); + assert_eq!(vt.line(3).text(), "row3 "); + assert_eq!(vt.line(4).text(), "FOOTER "); + } }