From 57555eb170d96c088d87cad7bc1227b5b8e6620c Mon Sep 17 00:00:00 2001 From: Samuel Corsi-House Date: Tue, 17 Mar 2026 23:54:38 -0400 Subject: [PATCH 1/9] feat: add row-level diff rendering for fullscreen and inline modes Extract per-row rendering from Canvas so individual rows can be written and compared independently. The terminal now diffs against the previous canvas and only re-renders rows that changed, reducing flicker and redundant output in both fullscreen and inline modes. In fullscreen mode, absolute cursor positioning targets only changed rows. In inline mode, relative cursor movement achieves the same. The clear_canvas path in fullscreen mode now preserves output above the canvas area. Co-Authored-By: Samuel Corsi-House Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/iocraft/src/canvas.rs | 319 ++++++++---- packages/iocraft/src/components/text.rs | 3 + packages/iocraft/src/render.rs | 10 +- packages/iocraft/src/terminal.rs | 635 +++++++++++++++++++++++- 4 files changed, 831 insertions(+), 136 deletions(-) diff --git a/packages/iocraft/src/canvas.rs b/packages/iocraft/src/canvas.rs index c3622f5..0692a51 100644 --- a/packages/iocraft/src/canvas.rs +++ b/packages/iocraft/src/canvas.rs @@ -248,142 +248,150 @@ impl Canvas { } } - fn write_impl( - &self, - mut w: W, - ansi: bool, - omit_final_newline: bool, - ) -> io::Result<()> { + 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) + } + + fn write_row_impl(&self, y: usize, mut w: W, ansi: bool) -> io::Result<()> { + let row = self.row(y); if ansi { write!(w, csi!("0m"))?; } 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(()) + } + + 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<()> { + 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 +399,6 @@ impl Canvas { } } } - if ansi && omit_final_newline { - write!(w, csi!("0m"))?; - } w.flush()?; Ok(()) } @@ -581,6 +586,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 +598,10 @@ mod tests { ) .unwrap(); write!(expected, csi!("K")).unwrap(); + write!(expected, csi!("0m")).unwrap(); write!(expected, "\r\n").unwrap(); + // row 1 + write!(expected, csi!("0m")).unwrap(); write!(expected, " ").unwrap(); write!(expected, csi!("{}m"), Colored::BackgroundColor(Color::Red)).unwrap(); write!(expected, " ").unwrap(); @@ -603,7 +612,10 @@ mod tests { ) .unwrap(); write!(expected, csi!("K")).unwrap(); + write!(expected, csi!("0m")).unwrap(); write!(expected, "\r\n").unwrap(); + // row 2 + write!(expected, csi!("0m")).unwrap(); write!(expected, csi!("K")).unwrap(); write!(expected, csi!("0m")).unwrap(); write!(expected, "\r\n").unwrap(); @@ -649,9 +661,11 @@ mod tests { Colored::BackgroundColor(Color::Reset) ) .unwrap(); + write!(expected, csi!("0m")).unwrap(); write!(expected, "\r\n").unwrap(); // line 2 + write!(expected, csi!("0m")).unwrap(); write!(expected, csi!("{}m"), Colored::BackgroundColor(Color::Red)).unwrap(); write!(expected, " ").unwrap(); write!( @@ -669,9 +683,11 @@ mod tests { Colored::BackgroundColor(Color::Reset) ) .unwrap(); + write!(expected, csi!("0m")).unwrap(); write!(expected, "\r\n").unwrap(); // line 3 + write!(expected, csi!("0m")).unwrap(); write!(expected, csi!("{}m"), Colored::BackgroundColor(Color::Red)).unwrap(); write!(expected, " ").unwrap(); write!( @@ -884,12 +900,19 @@ 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!("0m")).unwrap(); write!(expected, csi!("K")).unwrap(); + write!(expected, csi!("0m")).unwrap(); write!(expected, "\r\n").unwrap(); + // row 2 (final, no newline) + write!(expected, csi!("0m")).unwrap(); write!(expected, csi!("K")).unwrap(); write!(expected, csi!("0m")).unwrap(); @@ -1024,4 +1047,82 @@ 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 should render independently with its own reset + let mut row0 = Vec::new(); + canvas.write_ansi_row_without_newline(0, &mut row0).unwrap(); + + let mut expected0 = Vec::new(); + write!(expected0, csi!("0m")).unwrap(); + 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, csi!("0m")).unwrap(); + 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..e5d4c5c 100644 --- a/packages/iocraft/src/components/text.rs +++ b/packages/iocraft/src/components/text.rs @@ -329,12 +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, csi!("0m")).unwrap(); write!(expected, "\r\n").unwrap(); + // row 1 write!(expected, csi!("0m")).unwrap(); write!(expected, " ").unwrap(); write!(expected, csi!("{}m"), Attribute::Underlined.sgr()).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..4cc983c 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,123 @@ 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 - self.dest - .queue(terminal::Clear(terminal::ClearType::All))? - .queue(terminal::Clear(terminal::ClearType::Purge))? - .queue(cursor::MoveTo(0, 0))?; - return Ok(()); - } + 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, canvas: &Canvas) -> io::Result<()> { - self.prev_canvas_height = canvas.height() as _; - canvas.write_ansi_without_final_newline(self)?; + 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.dest.flush()?; + self.alt.flush()?; + self.prev_canvas_top_row = cursor::position()?.1; + } + 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::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: fall back to full clear + rewrite when canvas >= terminal height. + if let Some(size) = self.size { + if canvas.height() as u16 >= size.1 || self.prev_canvas_height >= size.1 { + self.clear_canvas()?; + self.prev_canvas_height = canvas.height() as _; + canvas.write_ansi_without_final_newline(&mut *self.dest)?; + 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; + } + match y.cmp(¤t_y) { + std::cmp::Ordering::Less => { + self.dest + .queue(cursor::MoveToPreviousLine((current_y - y) as u16))?; + } + std::cmp::Ordering::Greater => { + self.dest + .queue(cursor::MoveToNextLine((y - current_y) as u16))?; + } + 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))?; + } + } + + // 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 => {} + } + + self.prev_canvas_height = new_height as _; Ok(()) } @@ -275,6 +373,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 +504,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 +584,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 +721,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 +762,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 +783,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 +807,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 +849,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 +880,472 @@ 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> { + 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((10, 10)), + } + } + + #[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_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 should fall back to full clear + rewrite. + 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 (dest, diff_buf) = new_test_writer(); + // Terminal is 10x5, canvas is also 5 rows tall, triggering the fallback. + let mut term = 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: prev.height() as _, + size: Some((10, 5)), + }; + term.write_canvas(Some(&prev), &next).unwrap(); + + // The fallback does clear + full rewrite, so just verify the final + // visible state is correct by feeding the diff output into a vt that + // already shows the prev canvas. + 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(4).text(), "ccc "); + } + + #[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,7 +1361,7 @@ 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()); From 2bba7ef8f1951a61f0f62a0b9d52367e8f901227 Mon Sep 17 00:00:00 2001 From: "Owen W. Taylor" Date: Mon, 23 Mar 2026 20:08:17 -0400 Subject: [PATCH 2/9] Fix inline diff failing to extend scrollback at bottom of screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the canvas grows beyond its previous height, MoveToNextLine (CSI E) was used to reach the new rows. CSI E only repositions within existing terminal content — it won't create new lines when the cursor is at the bottom of the screen. Use \r\n instead for rows beyond the previous canvas height to actually extend the scrollback. Co-Authored-By: Claude Opus 4.6 --- packages/iocraft/src/terminal.rs | 131 ++++++++++++++++++++++++++++++- 1 file changed, 128 insertions(+), 3 deletions(-) diff --git a/packages/iocraft/src/terminal.rs b/packages/iocraft/src/terminal.rs index 4cc983c..beba25b 100644 --- a/packages/iocraft/src/terminal.rs +++ b/packages/iocraft/src/terminal.rs @@ -284,8 +284,26 @@ impl TerminalImpl for StdTerminal<'_> { .queue(cursor::MoveToPreviousLine((current_y - y) as u16))?; } std::cmp::Ordering::Greater => { - self.dest - .queue(cursor::MoveToNextLine((y - current_y) as u16))?; + // 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))?; @@ -966,6 +984,14 @@ mod tests { } 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), @@ -976,10 +1002,31 @@ mod tests { enabled_keyboard_enhancement: false, prev_canvas_top_row: 0, prev_canvas_height, - size: Some((10, 10)), + 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); @@ -1081,6 +1128,84 @@ mod tests { 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); From 4a2bd915cfe5e1a3b4c380b95e28cf5213206582 Mon Sep 17 00:00:00 2001 From: "Owen W. Taylor" Date: Mon, 23 Mar 2026 22:54:28 -0400 Subject: [PATCH 3/9] Avoid full-screen clear flicker for tall inline canvases Instead of unconditionally clearing when the canvas height >= terminal height, check each changed row during the diff. Only fall back to a full rewrite if a changed row is above the visible area (off-screen). When only visible rows changed, the normal row-level diff handles it without any flicker. Co-Authored-By: Claude Opus 4.6 --- packages/iocraft/src/terminal.rs | 122 ++++++++++++++++++++++--------- 1 file changed, 86 insertions(+), 36 deletions(-) diff --git a/packages/iocraft/src/terminal.rs b/packages/iocraft/src/terminal.rs index beba25b..c6494a1 100644 --- a/packages/iocraft/src/terminal.rs +++ b/packages/iocraft/src/terminal.rs @@ -258,16 +258,6 @@ impl TerminalImpl for StdTerminal<'_> { return Ok(()); } - // Inline: fall back to full clear + rewrite when canvas >= terminal height. - if let Some(size) = self.size { - if canvas.height() as u16 >= size.1 || self.prev_canvas_height >= size.1 { - self.clear_canvas()?; - self.prev_canvas_height = canvas.height() as _; - canvas.write_ansi_without_final_newline(&mut *self.dest)?; - return Ok(()); - } - } - // Inline: row diff with relative cursor movement. let prev_height = prev.height(); let new_height = canvas.height(); @@ -278,6 +268,17 @@ impl TerminalImpl for StdTerminal<'_> { 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 @@ -1340,7 +1341,8 @@ mod tests { #[test] fn test_inline_diff_at_terminal_height_boundary() { - // Canvas height == terminal height should fall back to full clear + rewrite. + // 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()); @@ -1353,36 +1355,84 @@ mod tests { next.subview_mut(0, 0, 0, 0, 10, 5) .set_text(0, 4, "ccc", CanvasTextStyle::default()); - let (dest, diff_buf) = new_test_writer(); - // Terminal is 10x5, canvas is also 5 rows tall, triggering the fallback. - let mut term = 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: prev.height() as _, - size: Some((10, 5)), - }; - term.write_canvas(Some(&prev), &next).unwrap(); - - // The fallback does clear + full rewrite, so just verify the final - // visible state is correct by feeding the diff output into a vt that - // already shows the prev canvas. - 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()); + 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(); From 3028dd626b66b90fbe13f56474e164b4fce82019 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Wed, 25 Mar 2026 08:02:10 +0000 Subject: [PATCH 4/9] fix: apply rustfmt to terminal.rs Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/iocraft/src/terminal.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/iocraft/src/terminal.rs b/packages/iocraft/src/terminal.rs index c6494a1..27a136a 100644 --- a/packages/iocraft/src/terminal.rs +++ b/packages/iocraft/src/terminal.rs @@ -1009,11 +1009,7 @@ mod tests { /// 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) { + 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(); From a7df264fcdd26d8bd1213bdf520a9f7dee0f9241 Mon Sep 17 00:00:00 2001 From: Djordje Golubovic Date: Thu, 16 Apr 2026 11:17:18 +0200 Subject: [PATCH 5/9] fix: use prev_canvas_top_row=0 for fullscreen initial write MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In fullscreen (alternate screen) mode, EnterAlternateScreen guarantees the cursor starts at (0, 0). The previous code called cursor::position() to determine prev_canvas_top_row, but inside BeginSynchronizedUpdate some terminals return a stale cursor position from the main screen. The wrong prev_canvas_top_row causes all subsequent row-level diffs to use incorrect absolute positions — every MoveTo(0, top_row + y) is offset by the stale value, so changed rows get written to wrong terminal positions, corrupting the visible display. Fix: set prev_canvas_top_row = 0 unconditionally for fullscreen mode and queue an explicit MoveTo(0, 0) as a safety measure. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/iocraft/src/terminal.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/iocraft/src/terminal.rs b/packages/iocraft/src/terminal.rs index 27a136a..0713a4e 100644 --- a/packages/iocraft/src/terminal.rs +++ b/packages/iocraft/src/terminal.rs @@ -227,7 +227,12 @@ impl TerminalImpl for StdTerminal<'_> { if self.fullscreen { self.dest.flush()?; self.alt.flush()?; - self.prev_canvas_top_row = cursor::position()?.1; + // In fullscreen (alternate screen) the cursor is guaranteed to + // be at (0, 0) after EnterAlternateScreen. Calling + // cursor::position() inside BeginSynchronizedUpdate can return + // a stale value from the main screen on some terminals. + 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)?; From 2bfc8c475a1c91dca4b8d3762d655863aaef78c3 Mon Sep 17 00:00:00 2001 From: Djordje Golubovic Date: Thu, 16 Apr 2026 12:02:56 +0200 Subject: [PATCH 6/9] test: add regression tests for fullscreen row-diff cursor offset bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add three tests for the fullscreen prev_canvas_top_row behavior: - test_fullscreen_initial_write_sets_zero_top_row: Exercises the full initial-write → diff pipeline. Verifies that write_canvas(None, …) anchors prev_canvas_top_row at 0. Without the fix, cursor::position() is called and fails in non-TTY test environments (timeout), so this test reliably catches the regression. - test_fullscreen_diff_zero_top_row_renders_correctly: Verifies that with prev_canvas_top_row = 0, a single-cell diff (simulating mouse overlay) writes each changed row to its correct terminal position. - test_fullscreen_diff_nonzero_top_row_offsets_changed_rows: Demonstrates the root cause: 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. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/iocraft/src/terminal.rs | 144 +++++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) diff --git a/packages/iocraft/src/terminal.rs b/packages/iocraft/src/terminal.rs index 0713a4e..d826942 100644 --- a/packages/iocraft/src/terminal.rs +++ b/packages/iocraft/src/terminal.rs @@ -1542,4 +1542,148 @@ mod tests { 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)) + } + + /// Regression test for the fullscreen row-diff rendering bug. + /// + /// In fullscreen (alternate screen) mode, `prev_canvas_top_row` must be 0 + /// so that row-level diffs use `MoveTo(0, y)`. This test verifies that + /// with `prev_canvas_top_row = 0` the diff writes each changed row to its + /// correct terminal position. + /// + /// Simulates the scenario from `examples/fullscreen_mouse_overlay_bug.rs`: + /// 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" + ); + // Row 3's original "row3" content is gone — proof that the offset + // corrupts rows that should have been left untouched. + assert_ne!( + vt.line(3).text(), + "row3 ", + "row 3 was overwritten by the misplaced diff for row 1" + ); + } + + /// End-to-end test: exercises the full initial-write → diff pipeline. + /// + /// With the fix, `write_canvas(None, …)` anchors `prev_canvas_top_row` at 0 + /// without querying `cursor::position()`. Without the fix, the initial + /// write calls `cursor::position()` which will fail in a non-TTY test + /// environment (timeout), causing this test to panic — thereby catching + /// the regression. + #[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 "); + } } From 5ae83edc54b8cd819f17fafd6fb36b1a7da61af3 Mon Sep 17 00:00:00 2001 From: Djordje Golubovic Date: Thu, 16 Apr 2026 18:05:41 +0200 Subject: [PATCH 7/9] Remove incorrect comment. Remove unnecessary flushes. --- examples/fullscreen.rs | 2 +- packages/iocraft/src/terminal.rs | 42 +++++++++++--------------------- 2 files changed, 15 insertions(+), 29 deletions(-) diff --git a/examples/fullscreen.rs b/examples/fullscreen.rs index 41d4424..88f7e06 100644 --- a/examples/fullscreen.rs +++ b/examples/fullscreen.rs @@ -35,7 +35,7 @@ fn Example(mut hooks: Hooks) -> impl Into> { element! { View( width, - height, + height: height - (time.get().timestamp() % 10) as u16, background_color: Color::DarkGrey, border_style: BorderStyle::Double, border_color: Color::Blue, diff --git a/packages/iocraft/src/terminal.rs b/packages/iocraft/src/terminal.rs index d826942..3ff20e2 100644 --- a/packages/iocraft/src/terminal.rs +++ b/packages/iocraft/src/terminal.rs @@ -225,12 +225,6 @@ impl TerminalImpl for StdTerminal<'_> { let Some(prev) = prev else { // No previous canvas: full write. if self.fullscreen { - self.dest.flush()?; - self.alt.flush()?; - // In fullscreen (alternate screen) the cursor is guaranteed to - // be at (0, 0) after EnterAlternateScreen. Calling - // cursor::position() inside BeginSynchronizedUpdate can return - // a stale value from the main screen on some terminals. self.prev_canvas_top_row = 0; self.dest.queue(cursor::MoveTo(0, 0))?; } @@ -1568,16 +1562,12 @@ mod tests { (build(false), build(true)) } - /// Regression test for the fullscreen row-diff rendering bug. + /// Verify that with `prev_canvas_top_row = 0` the fullscreen row-level + /// diff writes each changed row to its correct terminal position. /// - /// In fullscreen (alternate screen) mode, `prev_canvas_top_row` must be 0 - /// so that row-level diffs use `MoveTo(0, y)`. This test verifies that - /// with `prev_canvas_top_row = 0` the diff writes each changed row to its - /// correct terminal position. - /// - /// Simulates the scenario from `examples/fullscreen_mouse_overlay_bug.rs`: - /// a layout with numbered content rows and a distinct footer, where a single - /// cell changes between frames (as a mouse-highlight overlay would cause). + /// 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); @@ -1641,22 +1631,18 @@ mod tests { "row1 ", "row 1's diff landed at terminal line 3 (offset 2+1) instead of line 1" ); - // Row 3's original "row3" content is gone — proof that the offset - // corrupts rows that should have been left untouched. - assert_ne!( - vt.line(3).text(), - "row3 ", - "row 3 was overwritten by the misplaced diff for row 1" - ); } - /// End-to-end test: exercises the full initial-write → diff pipeline. + /// 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. /// - /// With the fix, `write_canvas(None, …)` anchors `prev_canvas_top_row` at 0 - /// without querying `cursor::position()`. Without the fix, the initial - /// write calls `cursor::position()` which will fail in a non-TTY test - /// environment (timeout), causing this test to panic — thereby catching - /// the regression. + /// 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); From 4aa8fc8a88732d3d35126cf90497336a5202a834 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Tue, 5 May 2026 08:25:32 -0500 Subject: [PATCH 8/9] fix: drop leftover height variation in fullscreen example Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/fullscreen.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/fullscreen.rs b/examples/fullscreen.rs index 88f7e06..41d4424 100644 --- a/examples/fullscreen.rs +++ b/examples/fullscreen.rs @@ -35,7 +35,7 @@ fn Example(mut hooks: Hooks) -> impl Into> { element! { View( width, - height: height - (time.get().timestamp() % 10) as u16, + height, background_color: Color::DarkGrey, border_style: BorderStyle::Double, border_color: Color::Blue, From 5b23023f2868852c682c0708dcfb89b6681ff2a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Tue, 5 May 2026 08:26:47 -0500 Subject: [PATCH 9/9] perf: drop redundant leading SGR reset from per-row canvas writer write_row_impl previously emitted csi!("0m") at both the start and end of every row. Each row's trailing reset already leaves the writer in a clean SGR state, so the leading reset of the next row is redundant. Document the contract on write_ansi_row_without_newline (callers must ensure clean SGR state; function leaves clean SGR state) and seed the state once in write_impl for the public canvas writers. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/iocraft/src/canvas.rs | 32 +++++++++++++++---------- packages/iocraft/src/components/text.rs | 1 - 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/packages/iocraft/src/canvas.rs b/packages/iocraft/src/canvas.rs index 0692a51..ef0b35c 100644 --- a/packages/iocraft/src/canvas.rs +++ b/packages/iocraft/src/canvas.rs @@ -260,11 +260,15 @@ impl Canvas { 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); - if ansi { - write!(w, csi!("0m"))?; - } let mut background_color = None; let mut text_style = CanvasTextStyle::default(); @@ -373,6 +377,12 @@ impl Canvas { 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, @@ -387,6 +397,11 @@ impl Canvas { 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; @@ -601,7 +616,6 @@ mod tests { write!(expected, csi!("0m")).unwrap(); write!(expected, "\r\n").unwrap(); // row 1 - write!(expected, csi!("0m")).unwrap(); write!(expected, " ").unwrap(); write!(expected, csi!("{}m"), Colored::BackgroundColor(Color::Red)).unwrap(); write!(expected, " ").unwrap(); @@ -615,7 +629,6 @@ mod tests { write!(expected, csi!("0m")).unwrap(); write!(expected, "\r\n").unwrap(); // row 2 - write!(expected, csi!("0m")).unwrap(); write!(expected, csi!("K")).unwrap(); write!(expected, csi!("0m")).unwrap(); write!(expected, "\r\n").unwrap(); @@ -665,7 +678,6 @@ mod tests { write!(expected, "\r\n").unwrap(); // line 2 - write!(expected, csi!("0m")).unwrap(); write!(expected, csi!("{}m"), Colored::BackgroundColor(Color::Red)).unwrap(); write!(expected, " ").unwrap(); write!( @@ -687,7 +699,6 @@ mod tests { write!(expected, "\r\n").unwrap(); // line 3 - write!(expected, csi!("0m")).unwrap(); write!(expected, csi!("{}m"), Colored::BackgroundColor(Color::Red)).unwrap(); write!(expected, " ").unwrap(); write!( @@ -907,12 +918,10 @@ mod tests { write!(expected, csi!("0m")).unwrap(); write!(expected, "\r\n").unwrap(); // row 1 - write!(expected, csi!("0m")).unwrap(); write!(expected, csi!("K")).unwrap(); write!(expected, csi!("0m")).unwrap(); write!(expected, "\r\n").unwrap(); // row 2 (final, no newline) - write!(expected, csi!("0m")).unwrap(); write!(expected, csi!("K")).unwrap(); write!(expected, csi!("0m")).unwrap(); @@ -1104,12 +1113,12 @@ line two .subview_mut(0, 0, 0, 0, 10, 2) .set_text(0, 1, "world", CanvasTextStyle::default()); - // each row should render independently with its own reset + // 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, csi!("0m")).unwrap(); write!(expected0, "hello").unwrap(); write!(expected0, csi!("K")).unwrap(); write!(expected0, csi!("0m")).unwrap(); @@ -1119,7 +1128,6 @@ line two canvas.write_ansi_row_without_newline(1, &mut row1).unwrap(); let mut expected1 = Vec::new(); - write!(expected1, csi!("0m")).unwrap(); write!(expected1, "world").unwrap(); write!(expected1, csi!("K")).unwrap(); write!(expected1, csi!("0m")).unwrap(); diff --git a/packages/iocraft/src/components/text.rs b/packages/iocraft/src/components/text.rs index e5d4c5c..630af92 100644 --- a/packages/iocraft/src/components/text.rs +++ b/packages/iocraft/src/components/text.rs @@ -338,7 +338,6 @@ mod tests { write!(expected, csi!("0m")).unwrap(); write!(expected, "\r\n").unwrap(); // row 1 - write!(expected, csi!("0m")).unwrap(); write!(expected, " ").unwrap(); write!(expected, csi!("{}m"), Attribute::Underlined.sgr()).unwrap(); write!(expected, "alignment test").unwrap();