Skip to content

fix(buffer): preserve wide characters under translucent overlays#791

Open
aarcamp wants to merge 6 commits intoanomalyco:mainfrom
aarcamp:fix-wide-char-alpha-blending
Open

fix(buffer): preserve wide characters under translucent overlays#791
aarcamp wants to merge 6 commits intoanomalyco:mainfrom
aarcamp:fix-wide-char-alpha-blending

Conversation

@aarcamp
Copy link
Copy Markdown
Contributor

@aarcamp aarcamp commented Mar 8, 2026

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:

  • wide CJK text remains readable under translucent fills when fully inside the overlay
  • wide emoji-like graphemes under translucent fills appear as []
  • translucent box edges stay visually straight when crossing wide graphemes
  • small translucent overlays preserve normal narrow-text behavior when no wide span is involved
  • clipped and buffered translucent boxes now match direct rendering more closely, including under overflow clipping
  • repainting after overlays move or change no longer leaves behind wide-glyph corruption or ghosting

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

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Mar 8, 2026

@opentui/core

npm i https://pkg.pr.new/@opentui/core@f81c205

@opentui/react

npm i https://pkg.pr.new/@opentui/react@f81c205

@opentui/solid

npm i https://pkg.pr.new/@opentui/solid@f81c205

@opentui/core-darwin-arm64

npm i https://pkg.pr.new/@opentui/core-darwin-arm64@f81c205

@opentui/core-darwin-x64

npm i https://pkg.pr.new/@opentui/core-darwin-x64@f81c205

@opentui/core-linux-arm64

npm i https://pkg.pr.new/@opentui/core-linux-arm64@f81c205

@opentui/core-linux-x64

npm i https://pkg.pr.new/@opentui/core-linux-x64@f81c205

@opentui/core-win32-arm64

npm i https://pkg.pr.new/@opentui/core-win32-arm64@f81c205

@opentui/core-win32-x64

npm i https://pkg.pr.new/@opentui/core-win32-x64@f81c205

commit: f81c205

@simonklee
Copy link
Copy Markdown
Member

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 drawText(" ", 3, 0, ...) over 東 at x=2 tints only x=3, and a 1×1 respectAlpha child framebuffer placed at x=3 does the same thing. The fillRect() workaround also has a scissor-bleed issue. Once it decides to tint the whole grapheme span, it writes beyond the clipped cell range.

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.

@aarcamp aarcamp force-pushed the fix-wide-char-alpha-blending branch 4 times, most recently from df4704f to f1e2c65 Compare March 10, 2026 00:24
@aarcamp
Copy link
Copy Markdown
Contributor Author

aarcamp commented Mar 10, 2026

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.

Good call, I pushed an update. What do you think of setCellWithAlphaBlending() + BlendCursor() re: generality?

I also added the following SVG visualization to packages/core/docs/ because the wide variety of cases is way too hard to all keep straight in my head:

wide-grapheme-alpha-blending-tests

@ariane-emory
Copy link
Copy Markdown

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!

@simonklee
Copy link
Copy Markdown
Member

I just smoke tested this branch. Is this result what you expect after your changes?

family.mp4

@aarcamp
Copy link
Copy Markdown
Contributor Author

aarcamp commented Mar 15, 2026

I just smoke tested this branch. Is this result what you expect after your changes?

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

Screenshot 2026-03-15 at 6 44 47 AM

@simonklee
Copy link
Copy Markdown
Member

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

@simonklee
Copy link
Copy Markdown
Member

@kommander what do you think?

@kommander
Copy link
Copy Markdown
Collaborator

@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.

@aarcamp
Copy link
Copy Markdown
Contributor Author

aarcamp commented Mar 15, 2026

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.

What about making it configurable with a policy?

pub const AlphaOverlayPreserveMask = u8;

pub const preserve_none: AlphaOverlayPreserveMask = 0;
pub const preserve_text: AlphaOverlayPreserveMask = 1 << 0;
pub const preserve_emoji: AlphaOverlayPreserveMask = 1 << 1;
pub const preserve_all: AlphaOverlayPreserveMask = preserve_text | preserve_emoji;

pub fn pushAlphaOverlayPreserveMask(self: *OptimizedBuffer, mask: AlphaOverlayPreserveMask) !void

export const AlphaOverlayPreserve = {
  none: 0,
  text: 1 << 0,
  emoji: 1 << 1,
  all: (1 << 0) | (1 << 1),
} as const

buffer.pushAlphaOverlayPreserveMask(AlphaOverlayPreserve.text)
buffer.fillRect(...)
buffer.popAlphaOverlayPreserveMask()

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.

Chars that cannot be tinted could be renderer with a placeholder so it doesn't just look empty.

How about using [] (width 2), [·] (width 3), [··] (width 4) for the placeholders.

@aarcamp
Copy link
Copy Markdown
Contributor Author

aarcamp commented Mar 15, 2026

It's not a bug in the blending logic, just a limitation.

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.

@simonklee
Copy link
Copy Markdown
Member

It's not a bug in the blending logic, just a limitation.

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.

@aarcamp
Copy link
Copy Markdown
Contributor Author

aarcamp commented Mar 15, 2026

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

  • box fills / scrims / drag overlays: preserve_none
  • pure text overlays: preserve_text (user can override to add preserve_emoji behavior if desired)

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

@simonklee
Copy link
Copy Markdown
Member

import {
  createCliRenderer,
  FrameBufferRenderable,
  RGBA,
  TextRenderable,
  BoxRenderable,
  OptimizedBuffer,
  t,
  bold,
  underline,
  fg,
  type MouseEvent,
  type KeyEvent,
} from "../index.js"
import type { CliRenderer, RenderContext } from "../index.js"
import { setupCommonDemoKeys } from "./lib/standalone-keys.js"

const GRAPHEME_LINES: string[] = [
  "東京都  北京市  서울시  大阪府  名古屋  横浜市  上海市",
  "👨‍👩‍👧‍👦  👩🏽‍💻  🏳️‍🌈  🇺🇸  🇩🇪  🇯🇵  🇮🇳  家族  絵文字  🎉🎊🎈",
  "こんにちは世界  你好世界  안녕하세요  สวัสดี  مرحبا",
  "漢字テスト  中文测试  한국어  日本語  繁體中文  简体中文",
  "🚀 Full-width: ABCDEF  Half: abcdef  ½ ⅞ ⅓",
  "混合テキスト mixed text with 漢字 and emoji 🎯",
]

const HEADER_HEIGHT = 2

let nextZIndex = 101
let draggableBoxes: DraggableBox[] = []
let scrimVisible = false
let scrim: BoxRenderable | null = null
let headerDisplay: TextRenderable | null = null

class DraggableBox extends BoxRenderable {
  private isDragging = false
  private dragOffsetX = 0
  private dragOffsetY = 0
  private alphaPercentage: number

  constructor(
    ctx: RenderContext,
    id: string,
    x: number,
    y: number,
    width: number,
    height: number,
    bg: RGBA,
    zIndex: number,
  ) {
    super(ctx, {
      id,
      width,
      height,
      zIndex,
      backgroundColor: bg,
      position: "absolute",
      left: x,
      top: y,
    })
    this.alphaPercentage = Math.round(bg.a * 100)
  }

  protected renderSelf(buffer: OptimizedBuffer): void {
    super.renderSelf(buffer)

    const alphaText = `${this.alphaPercentage}%`
    const centerX = this.x + Math.floor(this.width / 2 - alphaText.length / 2)
    const centerY = this.y + Math.floor(this.height / 2)

    buffer.drawText(alphaText, centerX, centerY, RGBA.fromInts(255, 255, 255, 220))
  }

  protected onMouseEvent(event: MouseEvent): void {
    switch (event.type) {
      case "down":
        this.isDragging = true
        this.dragOffsetX = event.x - this.x
        this.dragOffsetY = event.y - this.y
        this.zIndex = nextZIndex++
        event.stopPropagation()
        break

      case "drag-end":
        if (this.isDragging) {
          this.isDragging = false
          event.stopPropagation()
        }
        break

      case "drag":
        if (this.isDragging) {
          const newX = event.x - this.dragOffsetX
          const newY = event.y - this.dragOffsetY

          this.x = Math.max(0, Math.min(newX, this._ctx.width - this.width))
          this.y = Math.max(0, Math.min(newY, this._ctx.height - this.height))

          event.stopPropagation()
        }
        break
    }
  }
}

class GraphemeBackground extends FrameBufferRenderable {
  constructor(ctx: RenderContext, id: string, width: number, height: number) {
    super(ctx, {
      id,
      width,
      height,
      position: "absolute",
      left: 0,
      top: HEADER_HEIGHT,
      respectAlpha: false,
    })

    this.fillGraphemes(width, height)
  }

  private fillGraphemes(width: number, height: number) {
    const fgColor = RGBA.fromInts(220, 220, 220, 255)
    const bgColor = RGBA.fromInts(10, 14, 20, 255)
    this.frameBuffer.clear(bgColor)
    for (let y = 0; y < height; y++) {
      const line = GRAPHEME_LINES[y % GRAPHEME_LINES.length]
      this.frameBuffer.drawText(line, 2, y, fgColor, bgColor)
    }
  }
}

function toggleScrim(renderer: CliRenderer) {
  scrimVisible = !scrimVisible
  if (scrim) scrim.visible = scrimVisible
  updateHeader()
  renderer.requestRender()
}

function updateHeader() {
  if (!headerDisplay) return
  const dimLabel = scrimVisible ? "D: hide scrim" : "D: show scrim"
  headerDisplay.content = t`${bold(fg("#00D4AA")("Wide Grapheme Overlay"))} ${fg("#A8A8B2")(`| ${dimLabel} | Drag boxes over CJK/emoji | Ctrl+C: quit`)}`
}

export function run(renderer: CliRenderer): void {
  renderer.start()
  renderer.setBackgroundColor("#0A0E14")

  const root = new BoxRenderable(renderer, { id: "wg-overlay-root" })
  renderer.root.add(root)

  // Header row
  headerDisplay = new TextRenderable(renderer, {
    id: "wg-header",
    height: HEADER_HEIGHT,
    position: "absolute",
    left: 2,
    top: 0,
    zIndex: 200,
    selectable: false,
  })
  updateHeader()
  root.add(headerDisplay)

  // Background filled with repeating wide grapheme lines, below the header
  const bgHeight = renderer.terminalHeight - HEADER_HEIGHT
  const background = new GraphemeBackground(renderer, "wg-background", renderer.terminalWidth, bgHeight)
  root.add(background)

  // Full-screen dimming scrim (same as opencode dialog backdrop: RGBA(0,0,0,150))
  scrim = new BoxRenderable(renderer, {
    id: "wg-scrim",
    position: "absolute",
    left: 0,
    top: HEADER_HEIGHT,
    width: renderer.terminalWidth,
    height: bgHeight,
    backgroundColor: RGBA.fromInts(0, 0, 0, 150),
    zIndex: 50,
  })
  scrim.visible = false
  root.add(scrim)

  // Draggable boxes at various alpha levels
  const box1 = new DraggableBox(
    renderer,
    "wg-box-50",
    4,
    HEADER_HEIGHT + 1,
    25,
    8,
    RGBA.fromValues(64 / 255, 176 / 255, 255 / 255, 128 / 255),
    100,
  )
  root.add(box1)
  draggableBoxes.push(box1)

  const box2 = new DraggableBox(
    renderer,
    "wg-box-75",
    20,
    HEADER_HEIGHT + 5,
    25,
    8,
    RGBA.fromValues(255 / 255, 107 / 255, 129 / 255, 192 / 255),
    100,
  )
  root.add(box2)
  draggableBoxes.push(box2)

  const box3 = new DraggableBox(
    renderer,
    "wg-box-25",
    40,
    HEADER_HEIGHT + 3,
    25,
    8,
    RGBA.fromValues(139 / 255, 69 / 255, 193 / 255, 64 / 255),
    100,
  )
  root.add(box3)
  draggableBoxes.push(box3)

  const box4 = new DraggableBox(
    renderer,
    "wg-box-opaque",
    60,
    HEADER_HEIGHT + 7,
    25,
    8,
    RGBA.fromValues(30 / 255, 30 / 255, 42 / 255, 1.0),
    100,
  )
  root.add(box4)
  draggableBoxes.push(box4)

  renderer.keyInput.on("keypress", (key: KeyEvent) => {
    if (key.name === "d") {
      key.preventDefault()
      toggleScrim(renderer)
    }
  })

  renderer.on("resize", (width: number, height: number) => {
    const h = height - HEADER_HEIGHT
    background.width = width
    background.height = h
    if (scrim) {
      scrim.width = width
      scrim.height = h
    }
    renderer.requestRender()
  })
}

export function destroy(renderer: CliRenderer): void {
  renderer.clearFrameCallbacks()

  for (const box of draggableBoxes) {
    renderer.root.remove(box.id)
  }
  draggableBoxes = []
  nextZIndex = 101
  scrimVisible = false
  scrim = null
  headerDisplay = null

  renderer.root.remove("wg-overlay-root")
  renderer.setCursorPosition(0, 0, false)
}

if (import.meta.main) {
  const renderer = await createCliRenderer({
    exitOnCtrlC: true,
  })

  run(renderer)
  setupCommonDemoKeys(renderer)
  renderer.start()
}

@aarcamp aarcamp force-pushed the fix-wide-char-alpha-blending branch 2 times, most recently from 2ecb934 to 5e2c772 Compare March 27, 2026 03:13
@aarcamp
Copy link
Copy Markdown
Contributor Author

aarcamp commented Mar 27, 2026

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.

@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.

This suggests the next step would be to implement something like the preserve mask policy I proposed above.

FWIW I decided against this, so emojis never bleed through and are always replaced with [].

in this pr you have distortion on that draggable rect

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

@aarcamp aarcamp force-pushed the fix-wide-char-alpha-blending branch 2 times, most recently from 06e4a79 to e8013ef Compare March 27, 2026 11:32
@aarcamp aarcamp marked this pull request as draft March 27, 2026 11:40
@aarcamp aarcamp force-pushed the fix-wide-char-alpha-blending branch from e8013ef to a432191 Compare March 27, 2026 11:53
@aarcamp aarcamp marked this pull request as ready for review March 27, 2026 11:59
@aarcamp aarcamp marked this pull request as draft March 27, 2026 12:05
@aarcamp aarcamp force-pushed the fix-wide-char-alpha-blending branch 2 times, most recently from 2f1fd10 to 447a2ed Compare March 27, 2026 13:08
@aarcamp aarcamp marked this pull request as ready for review March 27, 2026 13:36
@aarcamp aarcamp force-pushed the fix-wide-char-alpha-blending branch 2 times, most recently from 0551165 to 13b337f Compare March 27, 2026 17:13
@aarcamp
Copy link
Copy Markdown
Contributor Author

aarcamp commented Mar 27, 2026

@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?

aarcamp added 6 commits April 10, 2026 07:37
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.
@aarcamp aarcamp force-pushed the fix-wide-char-alpha-blending branch from 13b337f to b806ae0 Compare April 10, 2026 11:30
@ariane-emory
Copy link
Copy Markdown

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!

@aarcamp
Copy link
Copy Markdown
Contributor Author

aarcamp commented Apr 10, 2026

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.

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.

4 participants