Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/cell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
103 changes: 86 additions & 17 deletions src/screen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -747,33 +747,102 @@ 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,
})
// pos.row is valid, since it comes directly from
// 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 && {
Comment on lines +789 to +790
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);
}
Comment on lines +842 to +844
}
prev_cell.append(c);
} else if pos.row > 0 {
let prev_row = self
.grid()
Expand Down
62 changes: 62 additions & 0 deletions tests/emoji_width.rs
Original file line number Diff line number Diff line change
@@ -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"
);
Comment on lines +57 to +61
}