Summary
@wterm/dom's renderer (renderer.ts _buildRowContent) iterates cells with no awareness of double-width "continuation" cells. The continuation half of a wide glyph carries char === 0 (Zig core) or char === 0x20 (Ghostty core) and is emitted as a literal U+0020 space. With any font that draws CJK at 2 cells (the standard for monospace CJK fonts — HackGen, Sarasa, Cica, M PLUS 1 Code, …), every wide character ends up taking 3 columns: the 2-cell glyph plus a stray 1-cell space.
Reproducer
@wterm/dom@0.3.0 + @wterm/ghostty@0.3.0, font with CJK = 2× ASCII:
term.write("│あいうえお│\n│ABCDE │\n");
Expected (xterm / iTerm2 / foot / alacritty):
Actual:
Right │ drifts 5 columns — one extra per wide char.
DOM textContent: あ い う え お with literal U+0020 between every kana.
Source
renderer.ts, in _buildRowContent:
const ch = inBounds && cp >= 32 ? String.fromCodePoint(cp) : " ";
getCell() returns { char, fg, bg, flags, fgRgb?, bgRgb? } — no width. The renderer can't distinguish a continuation cell from an empty one; both render as ' '.
Suggested fix
- Add
width: 0 | 1 | 2 to TerminalCore.getCell(). Both cores already track this internally — Ghostty has cell.wide (.spacer_tail / .wide / …) and serializes a width byte in wasm_api.zig, just doesn't surface it in JS.
- In
_buildRowContent: when cell.width === 0, skip the column (emit nothing). The preceding wide glyph already covers it visually.
(Distinct from #54, which is the Zig core's printChar not advancing by 2. Ghostty core already advances correctly; this is the DOM renderer's counterpart and can be fixed independently.)
Workaround
Until fixed, monkey-patch term.bridge.getCell (private; pin your @wterm/dom version) to substitute U+200B for tail cells when the previous cell is a wide codepoint. With a 2-cell-wide CJK font, the wide glyph then fills its allocated pair cleanly:
function isWideCodePoint(cp) {
return (
(cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo
(cp >= 0x2e80 && cp <= 0x303e) || // CJK radicals/Kangxi/symbols
(cp >= 0x3041 && cp <= 0x33ff) || // Kana, CJK compat
(cp >= 0x3400 && cp <= 0x4dbf) || // CJK Ext A
(cp >= 0x4e00 && cp <= 0x9fff) || // CJK Unified
(cp >= 0xac00 && cp <= 0xd7a3) || // Hangul syllables
(cp >= 0xf900 && cp <= 0xfaff) || // CJK compat ideographs
(cp >= 0xff00 && cp <= 0xff60) || // Fullwidth forms
(cp >= 0x1f300 && cp <= 0x1faff) || // Emoji & pictographs
(cp >= 0x20000 && cp <= 0x3fffd) // CJK Ext B+
);
}
await term.init();
const bridge = term.bridge;
const orig = bridge.getCell.bind(bridge);
const tailToZWSP = (cell, prev) =>
prev && isWideCodePoint(prev.char) ? { ...cell, char: 0x200b } : cell;
bridge.getCell = (r, col) =>
col > 0 ? tailToZWSP(orig(r, col), orig(r, col - 1)) : orig(r, col);
// mirror for getScrollbackCell similarly
Summary
@wterm/dom's renderer (renderer.ts_buildRowContent) iterates cells with no awareness of double-width "continuation" cells. The continuation half of a wide glyph carrieschar === 0(Zig core) orchar === 0x20(Ghostty core) and is emitted as a literalU+0020space. With any font that draws CJK at 2 cells (the standard for monospace CJK fonts — HackGen, Sarasa, Cica, M PLUS 1 Code, …), every wide character ends up taking 3 columns: the 2-cell glyph plus a stray 1-cell space.Reproducer
@wterm/dom@0.3.0+@wterm/ghostty@0.3.0, font with CJK = 2× ASCII:Expected (xterm / iTerm2 / foot / alacritty):
Actual:
Right
│drifts 5 columns — one extra per wide char.DOM textContent:
あ い う え おwith literalU+0020between every kana.Source
renderer.ts, in_buildRowContent:getCell()returns{ char, fg, bg, flags, fgRgb?, bgRgb? }— nowidth. The renderer can't distinguish a continuation cell from an empty one; both render as' '.Suggested fix
width: 0 | 1 | 2toTerminalCore.getCell(). Both cores already track this internally — Ghostty hascell.wide(.spacer_tail/.wide/ …) and serializes awidthbyte inwasm_api.zig, just doesn't surface it in JS._buildRowContent: whencell.width === 0, skip the column (emit nothing). The preceding wide glyph already covers it visually.(Distinct from #54, which is the Zig core's
printCharnot advancing by 2. Ghostty core already advances correctly; this is the DOM renderer's counterpart and can be fixed independently.)Workaround
Until fixed, monkey-patch
term.bridge.getCell(private; pin your@wterm/domversion) to substituteU+200Bfor tail cells when the previous cell is a wide codepoint. With a 2-cell-wide CJK font, the wide glyph then fills its allocated pair cleanly: