From b6e0579e25b865d668c5bb116c067ec6b791d316 Mon Sep 17 00:00:00 2001 From: Junyi Hou Date: Fri, 29 May 2026 15:09:43 +0800 Subject: [PATCH] Treat VS16 emoji-presentation sequences as double width A text-presentation base codepoint followed by U+FE0F (variation selector-16) requests emoji presentation, which terminals and unicode-width's string-level width() render as two columns. The cell's wide flag was computed from the base char alone (width 1) and the VS16 was appended as a zero-width combining char without re-checking, so the sequence was stored as a single narrow cell, shifting every column after it by one. In Screen::text's combining-character branch, when appending the combiner grows the cell's contents to double width by UnicodeWidthStr measure while the cell is still flagged narrow, promote it to wide and insert a wide-continuation cell. If that continuation column already held the first half of an existing wide glyph, clear that glyph's continuation too, mirroring the width > 1 placement path so nothing is left orphaned. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cell.rs | 2 +- src/screen.rs | 103 ++++++++++++++++++++++++++++++++++++------- tests/emoji_width.rs | 62 ++++++++++++++++++++++++++ 3 files changed, 149 insertions(+), 18 deletions(-) create mode 100644 tests/emoji_width.rs diff --git a/src/cell.rs b/src/cell.rs index c000cf3..767a296 100644 --- a/src/cell.rs +++ b/src/cell.rs @@ -110,7 +110,7 @@ impl Cell { self.len & IS_WIDE_CONTINUATION != 0 } - fn set_wide(&mut self, wide: bool) { + pub(crate) fn set_wide(&mut self, wide: bool) { if wide { self.len |= IS_WIDE; } else { diff --git a/src/screen.rs b/src/screen.rs index 7dfec97..58815de 100644 --- a/src/screen.rs +++ b/src/screen.rs @@ -747,9 +747,12 @@ impl Screen { if width == 0 { if pos.col > 0 { - let mut prev_cell = self - .grid_mut() - .drawing_cell_mut(crate::grid::Pos { + // The combining character attaches to the base cell. If the + // cell immediately to the left is a wide continuation, the + // base is the cell before that. + let base_col = if self + .grid() + .drawing_cell(crate::grid::Pos { row: pos.row, col: pos.col - 1, }) @@ -757,23 +760,89 @@ impl Screen { // self.grid().pos() which we assume to always have a // valid row value. pos.col - 1 is valid because we just // checked for pos.col > 0. - .unwrap(); - if prev_cell.is_wide_continuation() { - prev_cell = self + .unwrap() + .is_wide_continuation() + { + // pos.col - 2 is valid because the cell at pos.col - 1 is + // a wide continuation character, which means there must be + // the first half of the wide character before it. + pos.col - 2 + } else { + pos.col - 1 + }; + let base_pos = crate::grid::Pos { + row: pos.row, + col: base_col, + }; + self.grid_mut() + .drawing_cell_mut(base_pos) + // base_col was just shown to be a valid column. + .unwrap() + .append(c); + + // U+FE0F (variation selector-16) promotes a text-presentation + // base char to emoji presentation, which is two columns at the + // string level (and to terminals/tmux), though our wide flag + // was set from the base char alone. If the cell grew to double + // width but is still flagged narrow, widen it and add a wide + // continuation so later columns stay aligned. + let cont_col = base_col + 1; + let promote = cont_col < size.cols && { + let cell = self + .grid() + .drawing_cell(base_pos) + // base_pos was just shown to be valid above. + .unwrap(); + !cell.is_wide() + && unicode_width::UnicodeWidthStr::width( + cell.contents(), + ) > 1 + }; + if promote { + self.grid_mut() + .drawing_cell_mut(base_pos) + .unwrap() + .set_wide(true); + let cont_pos = crate::grid::Pos { + row: pos.row, + col: cont_col, + }; + // If the continuation column already holds the first half + // of an existing wide glyph, clear that glyph's own + // continuation first so it isn't left orphaned (mirrors + // the wide-character placement in the width > 1 branch). + if self + .grid() + .drawing_cell(cont_pos) + // cont_col < size.cols was just checked. + .unwrap() + .is_wide() + && cont_col + 1 < size.cols + { + self.grid_mut() + .drawing_cell_mut(crate::grid::Pos { + row: pos.row, + col: cont_col + 1, + }) + // cont_col + 1 < size.cols was just checked. + .unwrap() + .clear(crate::attrs::Attrs::default()); + } + let cont = self .grid_mut() - .drawing_cell_mut(crate::grid::Pos { - row: pos.row, - col: pos.col - 2, - }) - // pos.row is valid, since it comes directly from - // self.grid().pos() which we assume to always have a - // valid row value. we know pos.col - 2 is valid - // because the cell at pos.col - 1 is a wide - // continuation character, which means there must be - // the first half of the wide character before it. + .drawing_cell_mut(cont_pos) + // cont_col < size.cols was just checked. .unwrap(); + cont.clear(crate::attrs::Attrs::default()); + cont.set_wide_continuation(true); + // If the cursor is sitting on the new continuation cell + // (the combining char arrived immediately after the base + // char, which is the normal case), step past it so later + // characters are not written over the wide glyph. + if pos.col == cont_col { + self.grid_mut().col_inc(1); + } } - prev_cell.append(c); } else if pos.row > 0 { let prev_row = self .grid() diff --git a/tests/emoji_width.rs b/tests/emoji_width.rs new file mode 100644 index 0000000..e68f5ba --- /dev/null +++ b/tests/emoji_width.rs @@ -0,0 +1,62 @@ +// Regression tests for emoji-presentation width: a base char + U+FE0F +// (VS16) is two columns, but vt100 used to store it as one narrow cell, +// shifting everything after it and leaving on-screen residue. + +fn cells(parser: &vt100::Parser, n: u16) -> Vec<(String, bool, bool)> { + let screen = parser.screen(); + (0..n) + .map(|col| { + let c = screen.cell(0, col).unwrap(); + ( + c.contents().to_string(), + c.is_wide(), + c.is_wide_continuation(), + ) + }) + .collect() +} + +#[test] +fn vs16_emoji_occupies_two_columns() { + let mut parser = vt100::Parser::new(1, 20, 0); + parser.process("A\u{2764}\u{FE0F}B".as_bytes()); + + let c = cells(&parser, 5); + assert_eq!(c[0], ("A".into(), false, false), "col0 = A"); + assert_eq!( + c[1], + ("\u{2764}\u{FE0F}".into(), true, false), + "col1 = ❤️ and must be WIDE" + ); + assert_eq!( + c[2], + (String::new(), false, true), + "col2 must be a wide continuation" + ); + assert_eq!(c[3], ("B".into(), false, false), "col3 = B (not shifted)"); + assert_eq!(parser.screen().cursor_position(), (0, 4)); +} + +#[test] +fn vs16_promotion_over_existing_wide_char_does_not_orphan_continuation() { + let mut parser = vt100::Parser::new(1, 6, 0); + parser.process("a\u{4E2D}".as_bytes()); + // Move the cursor back to col0 and write a text-presentation base + VS16. + // The base lands on col0; its continuation lands on col1, which is the + // first half of 中 — so promotion must also clear 中's now-stranded + // continuation at col2 instead of leaving it orphaned. + parser.process(b"\x1b[1;1H"); + parser.process("\u{2764}\u{FE0F}".as_bytes()); + + let c = cells(&parser, 4); + assert_eq!( + c[0], + ("\u{2764}\u{FE0F}".into(), true, false), + "col0 = ❤️ wide" + ); + assert_eq!(c[1].2, true, "col1 = ❤️ continuation"); + assert!( + !c[2].2, + "col2 (clobbered 中's old continuation) must be cleared, not orphaned" + ); +}