fix(buffer): preserve wide characters under translucent overlays#791
fix(buffer): preserve wide characters under translucent overlays#791aarcamp wants to merge 6 commits intoanomalyco:mainfrom
Conversation
@opentui/core
@opentui/react
@opentui/solid
@opentui/core-darwin-arm64
@opentui/core-darwin-x64
@opentui/core-linux-arm64
@opentui/core-linux-x64
@opentui/core-win32-arm64
@opentui/core-win32-x64
commit: |
|
This correctly identifies the issue that blendCells() don't to preserve characters with display width > 1, so any translucent overlay replaced wide graphemes with spaces. Removing the destWidthIsOne guard is the right call, and the double-blend fix in setCellWithAlphaBlending() is sound: if the blended result preserves the same grapheme/continuation char, it skips set() and writes style back to only the touched cell, which avoids continuation cells getting blended twice in left-to-right loops. The problem is what happens next. Because the fix is cell-local, partial overlap creates split styling. If 東 occupies x=2 and x=3 and an overlay only covers x=3, only the continuation cell gets tinted. One visual grapheme now has two different background colors across its cells, which isn't a stable model. You fix this by adding span-snapping logic inside fillRect(), when it detects it's touching part of a preserved grapheme span, it manually expands the tint to the whole span. That's the bit that feels wrong, and I think the instinct is right. The real rule should be "if you alpha-blend over part of a wide grapheme, tint the whole span", now lives only in fillRect(). But drawText() and drawFrameBuffer() still use the cell-local path in setCellWithAlphaBlending(), so they can still split a wide grapheme on partial overlap. I reproduced this: a translucent I prefer a general fix: Define one central rule for "alpha overlay over a preserved wide grapheme" and put it in a single helper that all alpha-writing paths use. The logic would be: inspect the destination cell; if the char isn't preserved, do the normal blend; if it's preserved but narrow, update one cell; if it's part of a wide grapheme, resolve the whole span, blend once from a canonical source cell, and write the result to the entire span. Then setCellWithAlphaBlending() becomes the source of truth and fillRect(), drawText(), and drawFrameBuffer() all get correct behavior without needing their own special cases. |
df4704f to
f1e2c65
Compare
Good call, I pushed an update. What do you think of setCellWithAlphaBlending() + BlendCursor() re: generality? I also added the following SVG visualization to |
f1e2c65 to
c84ec57
Compare
|
I spent about six hours last night trying to figure out what was happening when I stumbled upon this issue in my own fork of OpenCode. I dearly hope that this PR will come in to a safe landing! |
|
I just smoke tested this branch. Is this result what you expect after your changes? family.mp4 |
@simonklee The commands dialog in opencode is opaque, is it not? For me it is not see-thru even for single width chars. Here's a quick test w/ red tinted translucent overlay and some emojis:
|
|
Okay - i think this is more of a design decision. But i don't love that emojis bleed through. CJK characters blend fine in your branch, while a family emoji under a 60% opacity scrim looks fully bright while the surrounding background gets properly darkened. It's not a bug in the blending logic, just a limitation. One option is to detect whether the grapheme is emoji vs CJK and only preserve the ones terminals can actually tint. Another one is to have some threshold and use a heuristic approach. alpha.mp4 |
|
@kommander what do you think? |
|
@simonklee what you said earlier, I'd try a simple unicode range limitation for characters that can be tinted. Emojis bleeding through just looks weird. Chars that cannot be tinted could be renderer with a placeholder so it doesn't just look empty. |
What about making it configurable with a policy? I tend to agree that emoji colors bleeding through can look weird so probably the default should be just 'text'. But I wouldn't assume there's no use case for preserving emojis too. Note, the intent of the push/pop pattern is to allow different compositing policies to apply to different drawing scopes within the same buffer.
How about using |
Yeah for this reason I'm wondering if we can merge this as-is and supply a folllow-up PR for the emoji treatment? IMO it's already a lot better since right now wide chars are just ignored entirely. |
My point is that technically it's technically not a bug — but from a ux point of view it's a regression. Another thing, if in this pr you have distortion on that draggable rect. You can see it in the video. That doesn't happen in main. |
Thanks, I didn't notice the distortion issue at first. This suggests the next step would be to implement something like the preserve mask policy I proposed above. Then we can set appropriate defaults by caller, i.e.:
Does that sound reasonable? (Tbh I don't know I'd agree the emoji color bleed is a ux regression necessarily but the draggable box distortion definitely is!) |
|
2ecb934 to
5e2c772
Compare
@simonklee Please check now. It's now a much larger change set (mostly due to added tests) but the code updates have been split 6 diffs for easier review. Note that I modified the wide-grapheme-overlay-demo.ts script a bit (to add a couple of bordered boxes for testing) and included a recording in the PR description.
FWIW I decided against this, so emojis never bleed through and are always replaced with
This was one of the trickier parts, but it's fully working, and self-optimizes based on whether the box edge is touching a wide span or not (see updated description and screen recording). |
06e4a79 to
e8013ef
Compare
e8013ef to
a432191
Compare
2f1fd10 to
447a2ed
Compare
0551165 to
13b337f
Compare
|
@kommander I only now just found your full-unicode-demo.ts script. This PR works perfectly w/ the wide-char-grapheme-overlay-demo (near as I can tell), but it seems full-unicode-demo may have some issues. Can you check it and let me know which behaviours you'd consider unsolved and I'll try to fix? |
Treat wide grapheme spans as a single unit during alpha blending so translucent fills do not split CJK and other wide text across their continuation cells. Add span-aware buffer blending, keep scissor handling coherent across partial writes, and cover the behavior with native tests for single spans, multi-space writes, multiple spans, and clipped updates.
Narrow the overlay policy so wide emoji no longer bleed through translucent fills. Wide text still preserves its grapheme span, but wide emoji are classified separately and rendered as [] placeholders. Add the placeholder rendering path and native tests for emoji replacement, multi-frame restore behavior, and wide non-emoji text classification.
Fix the renderer paths where replacing or restoring a wide grapheme could leave stale terminal cells behind even after the buffer state was correct. Preclear old wide spans before repaint, handle the fallback follow-up cell case, and add renderer regressions for the capability paths that previously left ghosts or delayed redraws.
Keep translucent box edges straight without giving up preserved wide text inside the fill. Add a clipped edge-band fill path for perimeter cells and keep the preserved fill behavior for the interior. Expose the helper through the TS/native boundary and add buffer and Box tests for left and right edge clipping plus interior preservation.
Only use the clipped perimeter fill when a translucent box edge actually crosses a wide grapheme. If the perimeter does not touch any wide spans, the whole fill can use the normal preserved path. This keeps small narrow-text overlays, including 2x2 fills, from collapsing into an all-edge clipped fill. Add a Box regression that keeps narrow text visible even when unrelated wide text exists elsewhere in the buffer.
Refresh the demo and SVG so they describe the final wide-grapheme alpha-blending behavior. Document the curated wide-grapheme samples that remain in scope, preserved wide text, [] placeholders for wide emoji, clipped edge bands, and the renderer repaint cases needed to restore emoji cleanly.
13b337f to
b806ae0
Compare
|
I still got my fingers crossed that this, or some other solution, to the double-width emojis plus overlays problem can be merged. Great to see that you're still working on it! |
@ariane-emory I wouldn't say I'm actively working on it, just clearing the conflicts I noticed today. Waiting for feedback so I can address any remaining issues. I may take another look at the full-unicode-demo if I find time next week. |

This PR improves how alpha-blended overlays interact with wide terminal grapheme spans.
When a translucent overlay tints existing content without replacing its character, wide non-emoji graphemes are now blended span-wise instead of cell-by-cell. That preserves multi-cell text such as CJK under translucent fills instead of letting the fill overwrite or inconsistently affect individual cells of the grapheme. Wide emoji-like graphemes are handled differently: under those same translucent overlays they render as a stable ASCII [] placeholder, which avoids inconsistent results from trying to tint color emoji directly.
For translucent boxes, fills now use a hybrid strategy. Interior cells still use the span-preserving path, but perimeter cells switch to a clipped path when a box edge crosses a wide grapheme so box boundaries stay visually straight. That clipped-edge behavior is only used when the visible box perimeter actually touches a wide span, so small overlays over ordinary narrow text still take the normal path. The same logic now respects offscreen and overflow: hidden clipping, including buffered ancestors. For bordered boxes, the border ring is treated as the clipped perimeter so interior space is not needlessly reduced and border cells are not double-tinted.
Buffered box rendering was also tightened up so these translucent cases behave the same whether the box renders directly or through its framebuffer. Framebuffer-local rendering, reuse, and reset behavior were adjusted so buffered rerenders do not accumulate stale tint, replay stale contents, or misapply clipping when switching between buffered and bypassed paths.
The renderer was also updated to repaint changed wide grapheme spans more reliably. It now preclears replaced wide-span output, avoids emitting continuation spaces immediately after newly written wide graphemes, and only performs fallback cursor repositioning when that terminal path requires it. This prevents stale wide-glyph artifacts and ghosting after overlay-driven repaints, including placeholder-to-emoji transitions.
User-visible behavior:
The changes are covered by native buffer and renderer tests plus BoxRenderable regression tests, and the wide grapheme demo/test map was updated to reflect the new cases.
Screen recording using modified wide grapheme overlay demos:
fix-wide-char-alpha-blending.mp4