fix(buffer): clear behavior with VS16 wide emojis (#2063)

This fixes a bug where certain emojis like ⌨️ would sometimes be
"overlaid" onto existing content from the buffer, instead of properly
clearing.

[example demonstrating
bug](https://gist.github.com/nornagon/11a79d7a1f2e98aa129fedb4abccc530)

This PR was generated by Codex, and validated by me:
1. Behavior of the above example code was buggy before this fix (showed
overlaying "b" on top of the keyboard emoji), and fixed after.
2. The U+FE0F check is not strictly required, but I did note that emoji
without this char don't exhibit the buggy behavior, even without the
fix.

---------

Co-authored-by: Orhun Parmaksız <orhunparmaksiz@gmail.com>
This commit is contained in:
Jeremy Rose 2025-09-24 14:27:10 -07:00 committed by GitHub
parent 42a4e9e9af
commit a89d3d62ff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -495,6 +495,38 @@ impl Buffer {
if !current.skip && (current != previous || invalidated > 0) && to_skip == 0 {
let (x, y) = self.pos_of(i);
updates.push((x, y, &next_buffer[i]));
// If the current cell is multi-width, ensure the trailing cells are explicitly
// cleared when they previously contained non-blank content. Some terminals do not
// reliably clear the trailing cell(s) when printing a wide grapheme, which can
// result in visual artifacts (e.g., leftover characters). Emitting an explicit
// update for the trailing cells avoids this.
let symbol = current.symbol();
let cell_width = symbol.width();
// Work around terminals that fail to clear the trailing cell of certain
// emoji presentation sequences (those containing VS16 / U+FE0F).
// Only emit explicit clears for such sequences to avoid bloating diffs
// for standard wide characters (e.g., CJK), which terminals handle well.
let contains_vs16 = symbol.chars().any(|c| c == '\u{FE0F}');
if cell_width > 1 && contains_vs16 {
for k in 1..cell_width {
let j = i + k;
// Make sure that we are still inside the buffer.
if j >= next_buffer.len() || j >= previous_buffer.len() {
break;
}
let prev_trailing = &previous_buffer[j];
let next_trailing = &next_buffer[j];
if !next_trailing.skip && prev_trailing != next_trailing {
let (tx, ty) = self.pos_of(j);
// Push an explicit update for the trailing cell.
// This is expected to be a blank cell, but we use the actual
// content from the next buffer to handle cases where
// the user has explicitly set something else.
updates.push((tx, ty, next_trailing));
}
}
}
}
to_skip = current.symbol().width().saturating_sub(1);
@ -1248,6 +1280,9 @@ mod tests {
// Both eye and speech bubble include a 'display as emoji' variation selector
// Prior to unicode-width 0.2, this was incorrectly detected as width 4 for some reason
#[case::eye_speechbubble("👁️‍🗨️", "👁🗨xxxxx")]
// Keyboard keycap emoji: base symbol + VS16 for emoji presentation
// This should render as a single grapheme with width 2.
#[case::keyboard_emoji("⌨️", "xxxxx")]
fn renders_emoji(#[case] input: &str, #[case] expected: &str) {
use unicode_width::UnicodeWidthChar;
@ -1297,4 +1332,34 @@ mod tests {
assert_eq!(buffer.index_of(255, 256), 65791);
assert_eq!(buffer.pos_of(65791), (255, 256)); // previously (255, 0)
}
#[test]
fn diff_clears_trailing_cell_for_wide_grapheme() {
// Reproduce: write "ab", then overwrite with a wide emoji like "⌨️"
let prev = Buffer::with_lines(["ab"]); // width 2 area inferred
assert_eq!(prev.area.width, 2);
let mut next = Buffer::with_lines([" "]); // start with blanks
next.set_string(0, 0, "⌨️", Style::new());
// The next buffer contains a wide grapheme occupying cell 0 and implicitly cell 1.
// The debug formatting shows the hidden trailing space.
let expected_next = Buffer::with_lines(["⌨️"]);
assert_eq!(next, expected_next);
// The diff should include an update for (0,0) to draw the emoji. Depending on
// terminal behavior, it may or may not be necessary to explicitly clear (1,0).
// At minimum, ensure the first cell is updated and nothing incorrect is emitted.
let diff = prev.diff(&next);
assert!(
diff.iter()
.any(|(x, y, c)| *x == 0 && *y == 0 && c.symbol() == "⌨️")
);
// And it should explicitly clear the trailing cell (1,0) to avoid leftovers on terminals
// that don't automatically clear the following cell for wide characters.
assert!(
diff.iter()
.any(|(x, y, c)| *x == 1 && *y == 0 && c.symbol() == " ")
);
}
}