Skip to content

terminal: preserve grapheme data when widening wraps - #14552

Merged
mitchellh merged 2 commits into
ghostty-org:mainfrom
fornwall:marks-from-above
Oct 10, 2026
Merged

mitchellh merged 2 commits into
ghostty-org:mainfrom
fornwall:marks-from-above

Conversation

@fornwall

@fornwall fornwall commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

With grapheme clustering enabled, a cluster can widen after it has been printed. Codepoints that join a cluster without changing its width, such as a zero-width joiner, are stored on the cluster's single narrow cell. A later joining codepoint can then make the cluster two columns wide (for example a heart, a zero-width joiner, then thumbs up).

When that happens in the last column and auto-wrap is enabled, the cluster wraps to the next line and its stored codepoints have to move with it. Copy them before wrapping instead of looking them up on the row above afterwards: that row isn't the original one when the wrap scrolls a single-row screen or a margin region, or doesn't scroll at all below the scroll region.

To reproduce, open a window and run the below using a safe build:

# \033[?2027h    Enable grapheme-cluster handling (mode 2027).
# \033[2J        Clear the visible screen.
# \033[1;2r      Set the scrolling region to rows 1–2 and home the cursor.
# \033[999;999H  Move to the bottom-right corner (coordinates are clamped).
# \u2764         Print a heart, initially one cell wide.
# \u200d         Append a zero-width joiner to the heart.
# \U0001f44d     Append thumbs up, widening the cluster and triggering wrapping.
# \033[r         Restore full-screen scrolling and home the cursor.
# \033[999;1H    Move to the first column of the bottom row (below grapheme).
# \n             Print a newline.
printf '\033[?2027h\033[2J\033[1;2r\033[999;999H\u2764\u200d\U0001f44d\033[r\033[999;1H\n'
  • Before: Panics in safe builds when the cluster widens at the bottom-right corner below a scroll region. In release builds it unwraps a missing grapheme lookup, which is undefined behavior (a ReleaseFast test run segfaults).
  • After: it wraps with its grapheme data intact.

AI disclosure: Initially created with the help of gpt-6 astra in codex. Iterated on, reviewed and tested by me.

@fornwall
fornwall requested a review from a team as a code owner October 6, 2026 03:01
@ghostty-bot ghostty-bot Bot added the vt Control sequence related label Oct 6, 2026

@mitchellh mitchellh left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you so much for your good work. You're clearly a very good AI driver and I appreciate it. For this one, due to where this is touching things, I'm going to ask if we can run some ghostty-bench against the terminal stream (to exercise this) with a variety of ghostty-gen data ranging from natural to a purposely hostile mix of inputs to see what the throughput effect of this change is.

I'm asking because print is the HOTTEST path in libghostty and I want to make sure this is thoughtfully done.

With grapheme clustering (mode 2027) enabled, a cluster can widen
after it has been printed. Codepoints that join a cluster without
changing its width, such as a zero-width joiner, are stored on the
cluster's single narrow cell. A later joining codepoint can then make
the cluster two columns wide (for example a heart, a zero-width joiner,
then thumbs up).

When that happens in the last column and auto-wrap is enabled, the
cluster wraps to the next line and its stored codepoints have to move
with it. Copy them before wrapping instead of looking them up on the
row above afterwards. That row isn't the original one when the wrap
scrolls a single-row screen or a margin region, or doesn't scroll at
all below the scroll region. So the codepoints were dropped, taken
from an unrelated cell, or the lookup panicked.

Signed-off-by: Fredrik Fornwall <fredrik@fornwall.net>
Re-attach the grapheme codepoints copied across a widening wrap with a
single setGraphemes call instead of appending them one at a time.
Appending probed the grapheme map and could reallocate the chunk once
per codepoint, which cost about 10% on input where every wrap carries
a maximal cluster.

Screen.setGraphemes mirrors appendGrapheme: on a full grapheme map or
allocator it grows the page's grapheme capacity, reloads the cell and
retries. Add a test that wraps onto a compacted page with no grapheme
capacity so that path is covered.

Signed-off-by: Fredrik Fornwall <fredrik@fornwall.net>
@fornwall

fornwall commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor Author

run some ghostty-bench against the terminal stream [..] because print is the HOTTEST path in libghostty

👍 Makes sense! Benchmark setup and result follows - let me know what you think about it and possible follow ups.

Setup: ghostty-bench +terminal-stream, 120 columns, 64 MiB per input with mode 2027 enabled. main runs twice (A/A) to show the noise floor. The instruction column is the perf stat count of instructions. CPU: AMD Ryzen 9 7950X3D. Linux distribution: ubuntu 26.04. Pinned to one isolated core.

Inputs (described here, see the attached benchmark script for details):

  • ascii-lines: ghostty-gen +styled (never enters print, measures layout only)
  • styled-cjk-emoji: +styled with wide/emoji weights, style rate 0.8, grapheme rate 0.2
  • utf8-invalid: +utf8 --ascii-printable-only --invalid-rate=0.05
  • hostile-fill: 119 x then a heart widened in the last column, alternating ZWJ + thumbs up and VS16
  • hostile-zwj: jump to last column, heart, ZWJ, thumbs up (every event wraps carrying grapheme data)
  • hostile-vs16: jump to last column, heart, VS16 (every event wraps carrying none)
  • hostile-long-cluster: jump to last column, heart, 62 combining acutes, ZWJ, thumbs up (64 attached codepoints)

Commits:

  • b10ba7e: The original PR.
  • 6dae79d on top re-attaches the copied codepoints with one setGraphemes call instead of one append each. Not at all sure that the complexity there is worth what it optimizes - I'm leaning towards dropping it, but let me know what you think.

Result: Median of 10 runs, change vs main:

input main b10ba7e time b10ba7e instr 6dae79d time 6dae79d instr main again
ascii-lines 129.9 ms −0.3% −0.0% +0.1% +0.0% −0.5%
styled-cjk-emoji 1.057 s −0.8% −0.0% −0.4% +0.0% +0.1%
utf8-invalid 759.9 ms −0.4% −0.0% −0.7% +0.0% +0.0%
hostile-fill 165.4 ms +0.3% +3.5% +0.9% +4.0% −0.0%
hostile-zwj 687.1 ms +8.4% +8.9% +11.6% +10.4% +0.1%
hostile-vs16 641.4 ms +0.4% +0.3% +1.7% +0.2% +0.4%
hostile-long-cluster 4.112 s +10.3% +14.4% −1.4% −2.3% −0.1%

Benchmark script: pr-14552-bench.py

@mitchellh
mitchellh enabled auto-merge October 10, 2026 19:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

vt Control sequence related

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants