aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorJJ Liebig <jonathan.liebig@gmail.com>2026-09-24 21:00:57 +0200
committerGitHub <noreply@github.com>2026-09-24 21:00:57 +0200
commitafd9e19893db5320b29d73f71f0fad58561f59b0 (patch)
tree7cae6c44b413bdcde580c9b780f6096dab6971f7 /src
parentc411883ec639c9893ed9c33021c890485e91727b (diff)
perf: avoid redundant client ANSI encoding work (#4552)
refs #4546
Diffstat (limited to 'src')
-rw-r--r--src/client/state.rs8
-rw-r--r--src/protocol/render_ansi.rs139
2 files changed, 130 insertions, 17 deletions
diff --git a/src/client/state.rs b/src/client/state.rs
index e6b8abf3..2cadfa62 100644
--- a/src/client/state.rs
+++ b/src/client/state.rs
@@ -348,9 +348,11 @@ impl ClientState {
};
crate::render_prof::duration_since("client_surface_patch.encode", encode_started);
let write_started = crate::render_prof::timer();
- let mut stdout = io::stdout();
- stdout.write_all(&encoded.bytes)?;
- stdout.flush()?;
+ if !encoded.bytes.is_empty() {
+ let mut stdout = io::stdout();
+ stdout.write_all(&encoded.bytes)?;
+ stdout.flush()?;
+ }
crate::render_prof::duration_since("client_surface_patch.write", write_started);
let committed = self.blit_encoder.commit_patch(&rows, patch.cursor, encoded);
crate::render_prof::event(if committed {
diff --git a/src/protocol/render_ansi.rs b/src/protocol/render_ansi.rs
index fe3ab68f..98653601 100644
--- a/src/protocol/render_ansi.rs
+++ b/src/protocol/render_ansi.rs
@@ -147,6 +147,19 @@ impl BlitEncoder {
if rows.iter().any(|row| !patch_row_fits(frame, row)) || patch_rows_overlap(rows) {
return None;
}
+ // Metadata revisions need no terminal output. Keep visible cursors on
+ // the normal path because their suppression policy can change.
+ if rows.is_empty()
+ && cursor == frame.cursor
+ && cursor.as_ref().is_none_or(|cursor| !cursor.visible)
+ {
+ return Some(EncodedBlit {
+ bytes: Vec::new(),
+ full: false,
+ next_last_visible_cursor: self.last_visible_cursor,
+ next_last_cursor_shape: self.last_cursor_shape,
+ });
+ }
let mut bytes = Vec::new();
let mut next_last_visible_cursor = self.last_visible_cursor;
let mut next_last_cursor_shape = self.last_cursor_shape;
@@ -553,16 +566,17 @@ fn patch_cell_mut(rows: &mut [PaneSurfacePatchRow], x: u16, y: u16) -> Option<&m
}
fn patch_rows_overlap(rows: &[PaneSurfacePatchRow]) -> bool {
- rows.iter().enumerate().any(|(index, left)| {
- let left_end = left.x.saturating_add(left.cells.len() as u16);
- rows[index + 1..].iter().any(|right| {
- if left.y != right.y {
- return false;
- }
- let right_end = right.x.saturating_add(right.cells.len() as u16);
- left.x < right_end && right.x < left_end
- })
- })
+ if rows.len() < 2 {
+ return false;
+ }
+ let mut spans = rows
+ .iter()
+ .map(|row| (row.y, row.x, row.x.saturating_add(row.cells.len() as u16)))
+ .collect::<Vec<_>>();
+ spans.sort_unstable();
+ spans
+ .windows(2)
+ .any(|pair| pair[0].0 == pair[1].0 && pair[1].1 < pair[0].2)
}
fn patch_row_fits(frame: &FrameData, row: &PaneSurfacePatchRow) -> bool {
@@ -595,6 +609,7 @@ fn blit_patch_to(
) {
let _ = writer.write_all(b"\x1b[?2026h\x1b[?25l\x1b]8;;\x1b\\");
let mut last_sgr = String::new();
+ let mut last_style = None;
let mut active_hyperlink = None;
for row in rows {
let mut invalidated = 0usize;
@@ -612,6 +627,7 @@ fn blit_patch_to(
cursor_position,
cell,
&mut last_sgr,
+ &mut last_style,
&mut active_hyperlink,
frame,
);
@@ -838,6 +854,7 @@ fn write_ime_anchor_cursor_state(writer: &mut impl Write, cursor: HostCursorStat
fn write_all_cells(writer: &mut impl Write, frame: &FrameData) {
let mut last_sgr = String::new();
+ let mut last_style = None;
let mut active_hyperlink = None;
for row in 0..frame.height {
let mut to_skip = 0usize;
@@ -862,6 +879,7 @@ fn write_all_cells(writer: &mut impl Write, frame: &FrameData) {
cursor_position,
cell,
&mut last_sgr,
+ &mut last_style,
&mut active_hyperlink,
frame,
);
@@ -937,6 +955,7 @@ fn write_cell(
cursor_position: Option<(u16, u16)>,
cell: &CellData,
last_sgr: &mut String,
+ last_style: &mut Option<(u32, u32, u16)>,
active_hyperlink: &mut Option<String>,
frame: &FrameData,
) {
@@ -948,10 +967,14 @@ fn write_cell(
write_cursor_position(writer, position);
}
- let sgr = build_sgr(cell.fg, cell.bg, cell.modifier);
- if sgr != *last_sgr {
- let _ = writer.write_all(sgr.as_bytes());
- *last_sgr = sgr;
+ let style = (cell.fg, cell.bg, cell.modifier);
+ if *last_style != Some(style) {
+ let sgr = build_sgr(cell.fg, cell.bg, cell.modifier);
+ if sgr != *last_sgr {
+ let _ = writer.write_all(sgr.as_bytes());
+ *last_sgr = sgr;
+ }
+ *last_style = Some(style);
}
write_hyperlink_if_changed(writer, active_hyperlink, cell_hyperlink_uri(frame, cell));
@@ -976,6 +999,7 @@ fn cells_visually_equal(
fn write_changed_cells(writer: &mut impl Write, frame: &FrameData, prev: &FrameData) {
let mut last_sgr = String::new(); // Track last SGR to avoid redundant style changes.
+ let mut last_style = None;
let mut active_hyperlink = None;
let sanitized_hyperlinks = sanitized_frame_hyperlinks(frame);
let prev_sanitized_hyperlinks = sanitized_frame_hyperlinks(prev);
@@ -1008,6 +1032,7 @@ fn write_changed_cells(writer: &mut impl Write, frame: &FrameData, prev: &FrameD
cursor_position,
cell,
&mut last_sgr,
+ &mut last_style,
&mut active_hyperlink,
frame,
);
@@ -1140,6 +1165,26 @@ mod tests {
}
#[test]
+ fn repeated_and_equivalent_styles_keep_text_and_link_changes() {
+ let mut cells = vec![
+ make_cell("a", 0, 0, 0),
+ make_cell("b", 0, 0, 0),
+ make_cell("c", 0xff_00_00_00, 0, 0), // Unknown color also encodes as reset.
+ make_cell("d", 2, 0, 0),
+ make_cell("e", 0, 0, 0),
+ ];
+ cells[1].hyperlink = Some(0);
+ let mut frame = make_frame(5, 1, cells);
+ frame.hyperlinks.push("https://example.com".into());
+ let mut output = Vec::new();
+ write_all_cells(&mut output, &frame);
+ assert_eq!(
+ String::from_utf8(output).unwrap(),
+ "\x1b[1;1H\x1b[0;39;49ma\x1b]8;;https://example.com\x1b\\b\x1b]8;;\x1b\\c\x1b[0;31;49md\x1b[0;39;49me\x1b[0m"
+ );
+ }
+
+ #[test]
fn build_sgr_preserves_curly_underline_style() {
let modifier = crate::protocol::modifier_to_u16(
crate::protocol::modifier_with_underline_style(ratatui::style::Modifier::UNDERLINED, 3),
@@ -1855,6 +1900,72 @@ mod tests {
];
assert!(encoder.encode_patch(&rows, None, false).is_none());
+ let mut reversed = rows.clone();
+ reversed.reverse();
+ assert!(encoder.encode_patch(&reversed, None, false).is_none());
+
+ // Input order need not match screen order; touching runs are disjoint.
+ let disjoint = vec![
+ PaneSurfacePatchRow {
+ x: 2,
+ y: 0,
+ cells: vec![make_cell("Z", 0, 0, 0)],
+ },
+ rows[0].clone(),
+ ];
+ assert!(encoder.encode_patch(&disjoint, None, false).is_some());
+ }
+
+ #[test]
+ fn metadata_only_patches_do_not_write_but_cursor_changes_do() {
+ let mut frame = make_frame(3, 1, vec![make_cell("a", 0, 0, 0); 3]);
+ let mut encoder = BlitEncoder::new();
+ for cursor in [
+ None,
+ Some(CursorState {
+ x: 0,
+ y: 0,
+ visible: false,
+ shape: 2,
+ }),
+ ] {
+ frame.cursor = cursor.clone();
+ let initial = encoder.encode(&frame, false);
+ encoder.commit(frame.clone(), initial);
+ let encoded = encoder.encode_patch(&[], cursor.clone(), false).unwrap();
+ assert!(encoded.bytes.is_empty());
+ assert!(encoder.commit_patch(&[], cursor, encoded));
+ assert!(encoder.is_current(&frame));
+ }
+ for cursor in [
+ CursorState {
+ x: 2,
+ y: 0,
+ visible: false,
+ shape: 2,
+ },
+ CursorState {
+ x: 2,
+ y: 0,
+ visible: true,
+ shape: 2,
+ },
+ ] {
+ let encoded = encoder
+ .encode_patch(&[], Some(cursor.clone()), false)
+ .unwrap();
+ assert!(String::from_utf8_lossy(&encoded.bytes).contains("\x1b[1;3H"));
+ assert!(encoder.commit_patch(&[], Some(cursor), encoded));
+ }
+ // Switching to a client-drawn cursor must still hide the visible host cursor.
+ let encoded = encoder
+ .encode_patch(
+ &[],
+ encoder.last_frame.as_ref().unwrap().cursor.clone(),
+ true,
+ )
+ .unwrap();
+ assert!(String::from_utf8_lossy(&encoded.bytes).contains("\x1b[?25l"));
}
#[test]