Skip to content

fix(renderer): keep columns aligned on terminals without mode 2027 - #131

Open
dgageot wants to merge 4 commits into
charmbracelet:mainfrom
dgageot:sidebar
Open

fix(renderer): keep columns aligned on terminals without mode 2027#131
dgageot wants to merge 4 commits into
charmbracelet:mainfrom
dgageot:sidebar

Conversation

@dgageot

@dgageot dgageot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

On terminals that don't negotiate Unicode grapheme width (DEC mode 2027),
such as Apple Terminal.app, emoji and other clusters may advance the cursor
by a different number of columns than the renderer models. Content drawn
after them on the same line landed at random columns, and incremental
updates left residue behind.

  • Re-anchor the cursor before narrow cells, erases, and moves once a
    drift-prone glyph was drawn, instead of only at end of line
  • Treat narrow multi-rune clusters (VS16/ZWJ sequences like ⛓️‍💥) as drift
    sources, not just wide cells
  • Erase and redraw changed lines wholly around drift-prone glyphs, since
    cell-level diffing can't trust the terminal's actual screen content there

Terminals that negotiate mode 2027 are unaffected and keep the minimal
diff path.

Tested on Terminal, iTerm and Ghostty

dgageot added 4 commits July 3, 2026 17:36
Reproduces wide-glyph cursor drift with a chat-style layout with a
vertical separator and sidebar.

Signed-off-by: David Gageot <david.gageot@docker.com>
On terminals that don't negotiate mode 2027 (e.g. Terminal.app), cursor
column drifts after wide glyphs whose width the terminal measures
differently. The previous fallback only re-anchored at end of line, so
anything drawn after an emoji on the same line landed at a random column.

Now drift is tracked lazily and re-anchored before the next narrow cell,
before ECH/EL (which act at the cursor position), and via CR before
cursor moves. The overwrite-move optimisation is also disabled across
wide cells. Adjacent wide glyphs still don't emit per-cell CHA.

Assisted-By: Claude (Anthropic)
Under the wcwidth fallback (no mode 2027), VS16/ZWJ sequences like
"⛓️‍💥" are stored as a single width-1 cell holding several runes, but
terminals such as Terminal.app advance the cursor by more than one
column. Drift detection only triggered on cells wider than 1, so
everything after such a cluster was shifted. Now any cell with more
than one rune also sets the drift flag.

Assisted-By: Claude (Anthropic)
…ental updates

On terminals without mode 2027 (e.g. Terminal.app), glyphs like emoji may be
drawn wider or narrower than modeled, so cell-level diffing leaves residue or
gaps. When a changed line contains wide or multi-rune cells and 2027 isn't
negotiated, erase and redraw the line wholly instead of diffing.

Assisted-By: Claude
@dgageot

dgageot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor Author

Failing test seems flaky. Fix here: #132

@taciturnaxolotl

taciturnaxolotl commented Jul 29, 2026

Copy link
Copy Markdown
Member

hi! thank you for looking into this! your diagnosis about VS16/ZWJ clusters being invisible to a width-only check seems correct but I think the re-anchoring mechanism makes things worse rather than better.

some issues

If you draw two VS16 clusters and a separator without an incremental update:

uv.NewStyledString("☹️☹️|X").Draw(scr, scr.Bounds())
s.Render(scr.RenderBuffer)
screen
main ☹️☹️|X
this branch |X

The emoji are gone on first paint. Same result for ZWJ sequences.

The raw bytes from that:

main:        ☹️☹️|X
this branch: \x1b[K ☹️ \x1b[2G ☹️ \x1b[3G |X
write ☹️      screen="☹️"     cursor=2   (renderer believes 1)
\x1b[2G      screen="☹️"     cursor=1   <- anchors INSIDE the glyph
write ☹️      screen=" ☹️"    cursor=3   <- overwrote emoji #1
\x1b[3G      screen=" ☹️"    cursor=2   <- inside again
write |X     screen="  |X"   cursor=4   <- overwrote emoji #2

Each re-anchor targets the renderer's next column, but that column is inside a cluster the terminal painted wider, so the following write erases it.

The re-anchor destination is computed with the same width model the terminal disagrees with, so the destination is itself wrong. Re-anchoring to a wrong column can't restore sync.

main only treats Width > 1 cells as drift sources, so narrow VS16 clusters
slip through and the column model desyncs. A later targeted update then
lands in the wrong place:

setup:       "☹️☹️abc"
then update the cell the renderer calls column 2

main:        "☹️Zbabc"   <- Z hit part of emoji #2, stale "a" left behind
this branch: "  Zbc"     <- emoji were already destroyed at setup

So main corrupts on update and this branch corrupts on first paint. Both need
work.

performance

Separately: the whole-line redraw takes a 100x30 emoji screen from 212 to about
2450 bytes per frame. This is the the default render path for most terminals (Terminal.app, iTerm2, Alacritty, tmux, VS Code; only ghostty, kitty, wezterm and contour negotiate 2027), so it's a real throughput cost over ssh. I have a variant that keeps cell-level diffing and trims it to about 2230, but it doesn't fix the first-paint problem.

The core difficulty is that we can't compute a correct absolute column without
knowing the glyph's painted extent. Options as I see them:

  1. Negotiate mode 2027 where available and accept legacy behaviour elsewhere.
  2. Query cursor position (DSR) after drift-prone glyphs to learn true extents.
    Costs a round trip, but it's the only way to get ground truth.
  3. Treat lines containing these clusters as un-diffable and rewrite them from
    column 0 with no mid-line re-anchoring at all, so the cursor is never
    repositioned into a glyph.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants