Skip to content

DOM renderer paints wide-character continuation cells as U+0020, adding a stray column after every CJK/emoji glyph #71

@shigurenimo

Description

@shigurenimo

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):

│あいうえお│
│ABCDE     │

Actual:

│あ い う え お│
│ABCDE     │

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

  1. 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.
  2. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions