diff --git a/packages/core/docs/wide-grapheme-alpha-blending-tests.svg b/packages/core/docs/wide-grapheme-alpha-blending-tests.svg new file mode 100644 index 000000000..41bc770eb --- /dev/null +++ b/packages/core/docs/wide-grapheme-alpha-blending-tests.svg @@ -0,0 +1,444 @@ + + Wide grapheme alpha-blending test map + Twelve panels illustrating the current wide-grapheme alpha-blending contract: wide text is preserved through interior translucent fills, wide emoji are replaced with [] placeholders, scissor clipping only updates cells inside the clip, boundary-band fills clip crossed spans with fillRectClipWideGraphemes, and renderer repaints preclear placeholder spans before restoring emoji and following cells. + + + + + + + + + + + + + + + + + + Wide Grapheme Alpha-Blending Test Map + WG = wide text. EM = wide emoji. Lavender = original wide text. Gold = original emoji. Orange = translucent overlay. + Pale green = overlay-filled cell. Mint = preserved wide text. Rose = [] placeholder. Blue = clipped edge fill. + + + wide text grapheme + + wide emoji grapheme + + translucent overlay + + overlay-filled cell + + preserved wide text + + [] placeholder + + + clipped edge fill + + scissor clip + + + + 1. Basic preservation + setCellWithAlphaBlending() + BEFORE + + + + + WG + + SETCELLWITHALPHABLENDING() + + + + + + WG + + AFTER + + + + + + + + + + + WG + + + + + + 2. Emoji placeholder + fillRect() over a wide emoji + BEFORE + + + + + EM + + FILLRECT() + + + + + + EM + + wide emoji become [] instead of bleeding through + AFTER + + + + + + + + + + + [ + ] + + + + + + 3. Multi-space pass + drawText(" ") over one span + BEFORE + + + + + WG + + DRAWTEXT(" ") + + + + + + + WG + + second space is skipped, not re-blended + AFTER + + + + + WG + + + + + + 4. Multiple spans + drawText() across two graphemes + BEFORE + + + + + + + WG + WG + + DRAWTEXT(" ") + + + + + + + + WG + WG + + cursor resets after the gap between spans + AFTER + + + + + + + + + + + WG + WG + + + + + + 5. Scissor clipping + fillRect() must not bleed outside clip + BEFORE + + + + + WG + + FILLRECT() WITH SCISSOR + + + + + + + WG + + only the clipped cell may change + AFTER + + + + + WG + + + + + + 6. Edge clip, left + fillRectClipWideGraphemes() + BEFORE + + + + + WG + + FILLRECTCLIPWIDEGRAPHEMES() + + + + + + WG + + crossed span is cleared; only the inside cell fills + AFTER + + + + + + + + + 7. Edge clip, right + fillRectClipWideGraphemes() + BEFORE + + + + + WG + + FILLRECTCLIPWIDEGRAPHEMES() + + + + + + WG + + mirrored edge case: right half stays untouched + AFTER + + + + + + + + + 8. Scissor, then full write + a clipped write must not suppress a later full one + BEFORE + + + + + WG + + FILLRECT() CLIPPED, THEN FILLRECT() + + + + + + + + WG + + later full write still reaches the start cell + AFTER + + + + + WG + + + + + + 9. Hybrid fill: edge row + perimeter uses clipped fill + BEFORE + + + + + WG + + BOX EDGE BAND + + + + + + WG + + crossed span is cleared at the edge + AFTER + + + + + + + + + 10. Hybrid fill: interior row + interior uses preserved fill + BEFORE + + + + + WG + + BOX INTERIOR FILL + + + + + + WG + + fully interior wide text stays visible + AFTER + + + + + + + WG + + + + + + 11. Renderer preclear + placeholder span repaints back to emoji + BEFORE + + + + + [ + ] + + RENDER() RESTORES WIDE EMOJI + + + + [ + ] + + renderer clears the old span first + AFTER + + + + + EM + + + + + + 12. Renderer follow-up cell + fallback repaint before following ASCII + BEFORE + + + + + [ + ] + A + + RENDER() REPAINTS EMOJI, THEN B + + + + [ + ] + A + + fallback path repositions before B + AFTER + + + + + EM + B + + + diff --git a/packages/core/src/Renderable.ts b/packages/core/src/Renderable.ts index 993ba357c..2584019e8 100644 --- a/packages/core/src/Renderable.ts +++ b/packages/core/src/Renderable.ts @@ -1333,15 +1333,15 @@ export abstract class Renderable extends BaseRenderable { const shouldPushScissor = this._overflow !== "visible" && this.width > 0 && this.height > 0 if (shouldPushScissor) { - const scissorRect = this.getScissorRect() + const scissorRect = this.getOverflowClipRect() renderList.push({ action: "pushScissorRect", x: scissorRect.x, y: scissorRect.y, width: scissorRect.width, height: scissorRect.height, - screenX: this.x, - screenY: this.y, + screenX: scissorRect.x, + screenY: scissorRect.y, }) } const visibleChildren = this._getVisibleChildren() @@ -1403,6 +1403,20 @@ export abstract class Renderable extends BaseRenderable { } } + public getOverflowClipRect(): { x: number; y: number; width: number; height: number } { + const scissorRect = this.getScissorRect() + if (!this.buffered) { + return scissorRect + } + + return { + x: scissorRect.x + this.x, + y: scissorRect.y + this.y, + width: scissorRect.width, + height: scissorRect.height, + } + } + protected renderSelf(buffer: OptimizedBuffer, deltaTime: number): void { // Default implementation: do nothing // Override this method to provide custom rendering diff --git a/packages/core/src/buffer.ts b/packages/core/src/buffer.ts index ea32b45dc..f9af893e2 100644 --- a/packages/core/src/buffer.ts +++ b/packages/core/src/buffer.ts @@ -291,6 +291,13 @@ export class OptimizedBuffer { this.lib.bufferFillRect(this.bufferPtr, x, y, width, height, bg) } + // Clip any wide grapheme spans crossed by this fill instead of tinting the + // full span. Overlay edge bands use this to keep box geometry straight. + public fillRectClipWideGraphemes(x: number, y: number, width: number, height: number, bg: RGBA): void { + this.guard() + this.lib.bufferFillRectClipWideGraphemes(this.bufferPtr, x, y, width, height, bg) + } + public colorMatrix( matrix: Float32Array, cellMask: Float32Array, diff --git a/packages/core/src/examples/wide-grapheme-overlay-demo.ts b/packages/core/src/examples/wide-grapheme-overlay-demo.ts index 59636f570..85360ba9e 100644 --- a/packages/core/src/examples/wide-grapheme-overlay-demo.ts +++ b/packages/core/src/examples/wide-grapheme-overlay-demo.ts @@ -7,7 +7,6 @@ import { OptimizedBuffer, t, bold, - underline, fg, type MouseEvent, type KeyEvent, @@ -16,12 +15,12 @@ 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 🎯", + "W2 CJK: 東京都 北京市 서울시 大阪府 名古屋 横浜市 上海市", + "W2 emoji: 👨‍👩‍👧‍👦 👩🏽‍💻 👩‍🚀 🇺🇸 🇩🇪 🇯🇵 🇮🇳 🎉🎊🎈", + "VS16 off/on: ♥ / ♥️ ☀ / ☀️ ✈ / ✈️ ☎ / ☎️ ☺ / ☺️ ✂ / ✂️", + "ZWJ + flags: 👩‍🚀 🧑‍🚀 👨‍👩‍👧‍👦 🧑‍💻 🇺🇸 🇩🇪 🇯🇵 🇮🇳", + "Mixed: こんにちは世界 你好世界 안녕하세요 สวัสดี مرحبا", + "Half/full: ABCDEF abcdef ½ ⅞ ⅓ 漢字 emoji 🎯", ] const HEADER_HEIGHT = 2 @@ -37,6 +36,7 @@ class DraggableBox extends BoxRenderable { private dragOffsetX = 0 private dragOffsetY = 0 private alphaPercentage: number + private showAlphaLabel: boolean constructor( ctx: RenderContext, @@ -47,6 +47,8 @@ class DraggableBox extends BoxRenderable { height: number, bg: RGBA, zIndex: number, + bordered = false, + showAlphaLabel = true, ) { super(ctx, { id, @@ -54,16 +56,24 @@ class DraggableBox extends BoxRenderable { height, zIndex, backgroundColor: bg, + border: bordered, + borderColor: bordered ? RGBA.fromInts(255, 255, 255, 235) : undefined, + borderStyle: bordered ? "rounded" : undefined, position: "absolute", left: x, top: y, }) this.alphaPercentage = Math.round(bg.a * 100) + this.showAlphaLabel = showAlphaLabel } protected renderSelf(buffer: OptimizedBuffer): void { super.renderSelf(buffer) + if (!this.showAlphaLabel) { + return + } + 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) @@ -94,7 +104,7 @@ class DraggableBox extends BoxRenderable { 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)) + this.y = Math.max(HEADER_HEIGHT, Math.min(newY, this._ctx.height - this.height)) event.stopPropagation() } @@ -139,7 +149,8 @@ function toggleScrim(renderer: CliRenderer) { 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`)}` + headerDisplay.content = t`${bold(fg("#00D4AA")("Wide Grapheme Overlay"))} ${fg("#A8A8B2")(`| ${dimLabel} | Ctrl+C: quit`)} +${fg("#7A8394")("Compare the small bordered probe, one large bordered box, and the plain translucent fills across the CJK/emoji lines")}` } export function run(renderer: CliRenderer): void { @@ -167,6 +178,21 @@ export function run(renderer: CliRenderer): void { const background = new GraphemeBackground(renderer, "wg-background", renderer.terminalWidth, bgHeight) root.add(background) + const edgeProbe = new DraggableBox( + renderer, + "wg-edge-probe", + 11, + HEADER_HEIGHT, + 6, + 3, + RGBA.fromValues(64 / 255, 176 / 255, 255 / 255, 128 / 255), + 90, + true, + false, + ) + root.add(edgeProbe) + draggableBoxes.push(edgeProbe) + // Full-screen dimming scrim (same as opencode dialog backdrop: RGBA(0,0,0,150)) scrim = new BoxRenderable(renderer, { id: "wg-scrim", @@ -191,6 +217,7 @@ export function run(renderer: CliRenderer): void { 8, RGBA.fromValues(64 / 255, 176 / 255, 255 / 255, 128 / 255), 100, + true, ) root.add(box1) draggableBoxes.push(box1) diff --git a/packages/core/src/renderables/Box.test.ts b/packages/core/src/renderables/Box.test.ts index 0b17406f3..962a9593f 100644 --- a/packages/core/src/renderables/Box.test.ts +++ b/packages/core/src/renderables/Box.test.ts @@ -1,15 +1,38 @@ import { test, expect, describe, beforeEach, afterEach, spyOn } from "bun:test" -import { BoxRenderable, type BoxOptions } from "./Box.js" +import { BoxRenderable } from "./Box.js" import { createTestRenderer, type TestRenderer } from "../testing/test-renderer.js" +import { TextRenderable } from "./Text.js" +import { RGBA } from "../lib/RGBA.js" +import type { OptimizedBuffer } from "../buffer.js" import type { BorderStyle } from "../lib/border.js" +import type { CapturedFrame } from "../types.js" let testRenderer: TestRenderer let renderOnce: () => Promise -let captureFrame: () => string +let captureCharFrame: () => string +let captureSpans: () => CapturedFrame let warnSpy: ReturnType +function getBgAt(buffer: TestRenderer["currentRenderBuffer"], x: number, y: number): RGBA { + const index = (y * buffer.width + x) * 4 + return RGBA.fromValues( + buffer.buffers.bg[index] ?? 0, + buffer.buffers.bg[index + 1] ?? 0, + buffer.buffers.bg[index + 2] ?? 0, + buffer.buffers.bg[index + 3] ?? 0, + ) +} + beforeEach(async () => { - ;({ renderer: testRenderer, renderOnce, captureCharFrame: captureFrame } = await createTestRenderer({})) + ;({ + renderer: testRenderer, + renderOnce, + captureCharFrame, + captureSpans, + } = await createTestRenderer({ + width: 24, + height: 6, + })) warnSpy = spyOn(console, "warn").mockImplementation(() => {}) }) @@ -176,7 +199,7 @@ describe("BoxRenderable - border titles (top and bottom)", () => { testRenderer.root.add(box) await renderOnce() - const lines = captureFrame().split("\n") + const lines = captureCharFrame().split("\n") expect(lines[0].slice(0, 16)).toBe("┌─Top──────────┐") expect(lines[4].slice(0, 16)).toBe("└──────────Bot─┘") @@ -199,7 +222,738 @@ describe("BoxRenderable - border titles (top and bottom)", () => { testRenderer.root.add(box) await renderOnce() - const lines = captureFrame().split("\n") + const lines = captureCharFrame().split("\n") expect(lines[4].slice(0, 18)).toBe(expectedBorder) }) }) + +describe("BoxRenderable - wide grapheme alpha fill", () => { + test("leaves wide text untouched when a transparent box would otherwise take the clipping path", async () => { + testRenderer.root.add( + new TextRenderable(testRenderer, { + id: "line-0", + content: "ab東cd", + position: "absolute", + left: 0, + top: 0, + }), + ) + + testRenderer.root.add( + new BoxRenderable(testRenderer, { + id: "overlay", + position: "absolute", + left: 3, + top: 0, + width: 4, + height: 1, + border: false, + backgroundColor: "transparent", + }), + ) + + await renderOnce() + + const topRow = captureCharFrame().split("\n")[0] + expect(topRow?.startsWith("ab東cd")).toBe(true) + }) + + test("clips a partially offscreen translucent fill to the visible rect", async () => { + testRenderer.root.add( + new TextRenderable(testRenderer, { + id: "line-0", + content: "abcd", + position: "absolute", + left: 0, + top: 0, + }), + ) + + testRenderer.root.add( + new BoxRenderable(testRenderer, { + id: "overlay", + position: "absolute", + left: -1, + top: 0, + width: 2, + height: 1, + border: false, + backgroundColor: RGBA.fromInts(0, 136, 255, 24), + }), + ) + + await renderOnce() + + const spans = captureSpans().lines[0].spans + expect(spans[0]?.text).toBe("a") + expect(spans[0]?.bg.a).toBeGreaterThan(0) + expect(spans[1]?.text.startsWith("bcd")).toBe(true) + expect(spans[1]?.bg.a).toBe(0) + }) + + test("keeps narrow text visible when a small translucent fill does not touch any wide grapheme", async () => { + testRenderer.root.add( + new TextRenderable(testRenderer, { + id: "line-0", + content: "abcd東", + position: "absolute", + left: 0, + top: 0, + }), + ) + testRenderer.root.add( + new TextRenderable(testRenderer, { + id: "line-1", + content: "efgh", + position: "absolute", + left: 0, + top: 1, + }), + ) + + testRenderer.root.add( + new BoxRenderable(testRenderer, { + id: "overlay", + position: "absolute", + left: 1, + top: 0, + width: 2, + height: 2, + border: false, + backgroundColor: RGBA.fromInts(0, 136, 255, 24), + }), + ) + + await renderOnce() + + const [topRow, middleRow] = captureCharFrame().split("\n") + expect(topRow.startsWith("abcd東")).toBe(true) + expect(middleRow.startsWith("efgh")).toBe(true) + }) + + test("clips boundary-crossing wide graphemes while preserving fully interior graphemes", async () => { + testRenderer.root.add( + new TextRenderable(testRenderer, { + id: "line-0", + content: "ab東cd", + position: "absolute", + left: 0, + top: 0, + }), + ) + testRenderer.root.add( + new TextRenderable(testRenderer, { + id: "line-1", + content: "abcd東ef", + position: "absolute", + left: 0, + top: 1, + }), + ) + + testRenderer.root.add( + new BoxRenderable(testRenderer, { + id: "overlay", + position: "absolute", + left: 3, + top: 0, + width: 6, + height: 3, + border: false, + backgroundColor: RGBA.fromInts(0, 136, 255, 24), + }), + ) + + await renderOnce() + + const [topRow, middleRow] = captureCharFrame().split("\n") + expect(topRow.startsWith("ab ")).toBe(true) + expect(middleRow.startsWith("abc 東ef")).toBe(true) + }) + + test("applies the same clipping when the translucent overlay box is buffered", async () => { + testRenderer.root.add( + new TextRenderable(testRenderer, { + id: "line-0", + content: "ab東cd", + position: "absolute", + left: 0, + top: 0, + }), + ) + testRenderer.root.add( + new TextRenderable(testRenderer, { + id: "line-1", + content: "abcd東ef", + position: "absolute", + left: 0, + top: 1, + }), + ) + + testRenderer.root.add( + new BoxRenderable(testRenderer, { + id: "overlay", + buffered: true, + position: "absolute", + left: 3, + top: 0, + width: 6, + height: 3, + border: false, + backgroundColor: RGBA.fromInts(0, 136, 255, 24), + }), + ) + + await renderOnce() + + const [topRow, middleRow] = captureCharFrame().split("\n") + expect(topRow.startsWith("ab ")).toBe(true) + expect(middleRow.startsWith("abc 東ef")).toBe(true) + }) + + test("treats ancestor overflow clipping as a visible edge for wide-grapheme clipping", async () => { + testRenderer.root.add( + new TextRenderable(testRenderer, { + id: "line-0", + content: "ab東cd", + position: "absolute", + left: 0, + top: 1, + }), + ) + + const parent = new BoxRenderable(testRenderer, { + id: "parent", + position: "absolute", + left: 3, + top: 0, + width: 3, + height: 3, + border: false, + shouldFill: false, + overflow: "hidden", + }) + testRenderer.root.add(parent) + + parent.add( + new BoxRenderable(testRenderer, { + id: "overlay", + position: "absolute", + left: -1, + top: 0, + width: 4, + height: 3, + border: false, + backgroundColor: RGBA.fromInts(0, 136, 255, 24), + }), + ) + + await renderOnce() + + const middleRow = captureCharFrame().split("\n")[1] + expect(middleRow?.startsWith("ab c ")).toBe(true) + }) + + test("treats buffered ancestor overflow clipping as a visible edge for wide-grapheme clipping", async () => { + testRenderer.root.add( + new TextRenderable(testRenderer, { + id: "line-0", + content: "ab東cd", + position: "absolute", + left: 0, + top: 1, + }), + ) + + const parent = new BoxRenderable(testRenderer, { + id: "parent", + buffered: true, + position: "absolute", + left: 3, + top: 0, + width: 3, + height: 3, + border: false, + shouldFill: false, + overflow: "hidden", + }) + testRenderer.root.add(parent) + + parent.add( + new BoxRenderable(testRenderer, { + id: "overlay", + position: "absolute", + left: -1, + top: 0, + width: 4, + height: 3, + border: false, + backgroundColor: RGBA.fromInts(0, 136, 255, 24), + }), + ) + + await renderOnce() + + const middleRow = captureCharFrame().split("\n")[1] + expect(middleRow?.startsWith("ab c ")).toBe(true) + }) + + test("clips buffered overflow-hidden children in screen coordinates", async () => { + const parent = new BoxRenderable(testRenderer, { + id: "parent", + buffered: true, + position: "absolute", + left: 3, + top: 0, + width: 3, + height: 3, + border: false, + shouldFill: false, + overflow: "hidden", + }) + testRenderer.root.add(parent) + + parent.add( + new TextRenderable(testRenderer, { + id: "child", + content: "abcdef", + position: "absolute", + left: -1, + top: 1, + }), + ) + + await renderOnce() + + const middleRow = captureCharFrame().split("\n")[1] + expect(middleRow?.startsWith(" bc")).toBe(true) + }) + + test("uses the border ring as the clipped perimeter so no interior space is wasted", async () => { + testRenderer.root.add( + new TextRenderable(testRenderer, { + id: "line-0", + content: "abcd東ef", + position: "absolute", + left: 0, + top: 1, + }), + ) + + testRenderer.root.add( + new BoxRenderable(testRenderer, { + id: "overlay", + position: "absolute", + left: 3, + top: 0, + width: 6, + height: 3, + border: true, + backgroundColor: RGBA.fromInts(0, 136, 255, 24), + }), + ) + + await renderOnce() + + const [topRow, middleRow] = captureCharFrame().split("\n") + expect(topRow?.startsWith(" ┌────┐")).toBe(true) + expect(middleRow?.startsWith("abc│東ef│")).toBe(true) + }) + + test("does not double-tint border cells when the fill already covers the border ring", async () => { + testRenderer.root.add( + new BoxRenderable(testRenderer, { + id: "overlay", + position: "absolute", + left: 1, + top: 0, + width: 6, + height: 3, + border: true, + backgroundColor: RGBA.fromInts(64, 176, 255, 128), + }), + ) + + await renderOnce() + + const borderBg = getBgAt(testRenderer.currentRenderBuffer, 2, 0) + const interiorBg = getBgAt(testRenderer.currentRenderBuffer, 2, 1) + + expect(borderBg.r).toBeCloseTo(interiorBg.r, 6) + expect(borderBg.g).toBeCloseTo(interiorBg.g, 6) + expect(borderBg.b).toBeCloseTo(interiorBg.b, 6) + expect(borderBg.a).toBeCloseTo(interiorBg.a, 6) + }) + + test("renders buffered opaque boxes from framebuffer-local coordinates", async () => { + testRenderer.root.add( + new TextRenderable(testRenderer, { + id: "line-0", + content: "abcdefgh", + position: "absolute", + left: 0, + top: 1, + }), + ) + + testRenderer.root.add( + new BoxRenderable(testRenderer, { + id: "overlay", + buffered: true, + position: "absolute", + left: 3, + top: 1, + width: 4, + height: 1, + border: false, + backgroundColor: "#ff0000", + }), + ) + + await renderOnce() + + const middleRow = captureCharFrame().split("\n")[1] + expect(middleRow?.startsWith("abc h")).toBe(true) + }) + + test("retains buffered opaque framebuffer contents across unrelated rerenders", async () => { + let firstFrame = true + const box = new BoxRenderable(testRenderer, { + id: "overlay", + buffered: true, + position: "absolute", + left: 0, + top: 0, + width: 5, + height: 1, + border: false, + shouldFill: false, + backgroundColor: "#000000", + renderAfter(buffer) { + if (!firstFrame) { + return + } + + buffer.drawText("A", 0, 0, RGBA.fromInts(255, 255, 255, 255)) + firstFrame = false + }, + }) + const tick = new TextRenderable(testRenderer, { + id: "tick", + content: "x", + position: "absolute", + left: 0, + top: 1, + }) + + testRenderer.root.add(box) + testRenderer.root.add(tick) + + await renderOnce() + expect(captureCharFrame().split("\n")[0]?.startsWith("A")).toBe(true) + + tick.content = "y" + await renderOnce() + + expect(captureCharFrame().split("\n")[0]?.startsWith("A")).toBe(true) + }) + + test("keeps renderAfter callbacks framebuffer-local while clipping wide grapheme edges", async () => { + testRenderer.root.add( + new TextRenderable(testRenderer, { + id: "line-0", + content: "ab東cd", + position: "absolute", + left: 0, + top: 0, + }), + ) + testRenderer.root.add( + new TextRenderable(testRenderer, { + id: "line-1", + content: "abcd東ef", + position: "absolute", + left: 0, + top: 1, + }), + ) + + testRenderer.root.add( + new BoxRenderable(testRenderer, { + id: "overlay", + buffered: true, + position: "absolute", + left: 3, + top: 0, + width: 6, + height: 3, + border: false, + backgroundColor: RGBA.fromInts(0, 136, 255, 24), + renderAfter(buffer) { + buffer.drawText("X", 5, 0, RGBA.fromInts(255, 255, 255, 255)) + }, + }), + ) + + await renderOnce() + + const [topRow, middleRow] = captureCharFrame().split("\n") + expect(topRow?.startsWith("ab X")).toBe(true) + expect(middleRow?.startsWith("abc 東ef")).toBe(true) + }) + + test("keeps renderBefore callbacks beneath direct translucent fill for buffered boxes", async () => { + testRenderer.root.add( + new BoxRenderable(testRenderer, { + id: "overlay", + buffered: true, + position: "absolute", + left: 3, + top: 1, + width: 4, + height: 1, + border: false, + backgroundColor: RGBA.fromInts(0, 136, 255, 24), + renderBefore(buffer) { + buffer.drawText("X", 0, 0, RGBA.fromInts(255, 255, 255, 255)) + }, + }), + ) + + await renderOnce() + + const xSpan = captureSpans().lines[1].spans.find((span) => span.text.includes("X")) + expect(xSpan).toBeDefined() + expect(xSpan!.fg.r).toBeLessThan(1) + expect(xSpan!.bg.a).toBeGreaterThan(0) + }) + + test("does not accumulate translucent buffered box tint across identical rerenders", async () => { + testRenderer.root.add( + new BoxRenderable(testRenderer, { + id: "overlay", + buffered: true, + position: "absolute", + left: 4, + top: 1, + width: 4, + height: 2, + border: false, + backgroundColor: RGBA.fromInts(0, 136, 255, 24), + renderAfter(buffer) { + buffer.drawText("X", 0, 0, RGBA.fromInts(255, 255, 255, 255)) + }, + }), + ) + + await renderOnce() + const firstSpan = captureSpans().lines[1].spans.find((span) => span.text.includes("X")) + expect(firstSpan).toBeDefined() + + await renderOnce() + const secondSpan = captureSpans().lines[1].spans.find((span) => span.text.includes("X")) + expect(secondSpan).toBeDefined() + + expect(secondSpan!.bg.r).toBeCloseTo(firstSpan!.bg.r, 6) + expect(secondSpan!.bg.g).toBeCloseTo(firstSpan!.bg.g, 6) + expect(secondSpan!.bg.b).toBeCloseTo(firstSpan!.bg.b, 6) + expect(secondSpan!.bg.a).toBeCloseTo(firstSpan!.bg.a, 6) + }) + + test("does not accumulate translucent framebuffer writes across unrelated rerenders when the box is otherwise opaque", async () => { + const box = new BoxRenderable(testRenderer, { + id: "overlay", + buffered: true, + position: "absolute", + left: 1, + top: 1, + width: 3, + height: 1, + border: false, + shouldFill: false, + backgroundColor: "#000000", + renderAfter(buffer) { + buffer.fillRect(0, 0, 1, 1, RGBA.fromInts(255, 0, 0, 128)) + }, + }) + const tick = new TextRenderable(testRenderer, { + id: "tick", + content: "x", + position: "absolute", + left: 0, + top: 2, + }) + + testRenderer.root.add(box) + testRenderer.root.add(tick) + + await renderOnce() + const firstRed = captureSpans().lines[1].spans[1]?.bg.r + expect(firstRed).toBeDefined() + + tick.content = "y" + await renderOnce() + const secondRed = captureSpans().lines[1].spans[1]?.bg.r + expect(secondRed).toBeDefined() + expect(secondRed).toBeCloseTo(firstRed!, 6) + }) + + test("keeps subclass renderSelf framebuffer-local while clipping wide grapheme edges", async () => { + class BufferedLabelBox extends BoxRenderable { + protected override renderSelf(buffer: OptimizedBuffer): void { + super.renderSelf(buffer) + buffer.drawText("X", 5, 0, RGBA.fromInts(255, 255, 255, 255)) + } + } + + testRenderer.root.add( + new TextRenderable(testRenderer, { + id: "line-0", + content: "ab東cd", + position: "absolute", + left: 0, + top: 0, + }), + ) + testRenderer.root.add( + new TextRenderable(testRenderer, { + id: "line-1", + content: "abcd東ef", + position: "absolute", + left: 0, + top: 1, + }), + ) + + testRenderer.root.add( + new BufferedLabelBox(testRenderer, { + id: "overlay", + buffered: true, + position: "absolute", + left: 3, + top: 0, + width: 6, + height: 3, + border: false, + backgroundColor: RGBA.fromInts(0, 136, 255, 24), + }), + ) + + await renderOnce() + + const [topRow, middleRow] = captureCharFrame().split("\n") + expect(topRow?.startsWith("ab X")).toBe(true) + expect(middleRow?.startsWith("abc 東ef")).toBe(true) + }) + + test("does not replay stale framebuffer contents after switching back from bypass rendering", async () => { + const box = new BoxRenderable(testRenderer, { + id: "overlay", + buffered: true, + position: "absolute", + left: 2, + top: 1, + width: 3, + height: 1, + border: false, + shouldFill: false, + backgroundColor: RGBA.fromInts(0, 136, 255, 24), + renderAfter(buffer) { + buffer.drawText("A", 0, 0, RGBA.fromInts(255, 255, 255, 255)) + }, + }) + testRenderer.root.add(box) + + await renderOnce() + expect(captureCharFrame().split("\n")[1]?.startsWith(" A")).toBe(true) + + box.renderAfter = undefined + await renderOnce() + expect(captureCharFrame().split("\n")[1]?.trim()).toBe("") + + box.renderAfter = function (buffer) { + buffer.drawText("B", 1, 0, RGBA.fromInts(255, 255, 255, 255)) + } + await renderOnce() + + const middleRow = captureCharFrame().split("\n")[1] + expect(middleRow?.startsWith(" AB")).toBe(false) + expect(middleRow?.startsWith(" B")).toBe(true) + }) + + test("clears stale framebuffer contents when opaque rendering resumes after a bypassed frame", async () => { + const box = new BoxRenderable(testRenderer, { + id: "overlay", + buffered: true, + position: "absolute", + left: 2, + top: 1, + width: 3, + height: 1, + border: false, + shouldFill: false, + backgroundColor: "#ff0000", + renderAfter(buffer) { + buffer.drawText("A", 0, 0, RGBA.fromInts(255, 255, 255, 255)) + }, + }) + testRenderer.root.add(box) + + await renderOnce() + expect(captureCharFrame().split("\n")[1]?.startsWith(" A")).toBe(true) + + box.renderAfter = undefined + box.backgroundColor = RGBA.fromInts(0, 136, 255, 24) + await renderOnce() + expect(captureCharFrame().split("\n")[1]?.trim()).toBe("") + + box.backgroundColor = "#ff0000" + box.renderAfter = function (buffer) { + buffer.drawText("B", 1, 0, RGBA.fromInts(255, 255, 255, 255)) + } + await renderOnce() + + const middleRow = captureCharFrame().split("\n")[1] + expect(middleRow?.startsWith(" AB")).toBe(false) + expect(middleRow?.startsWith(" B")).toBe(true) + }) + + test("does not replace emoji when a buffered transparent framebuffer draw is otherwise empty", async () => { + class TransparentBufferedBox extends BoxRenderable { + protected override renderSelf(buffer: OptimizedBuffer): void { + super.renderSelf(buffer) + } + } + + testRenderer.root.add( + new TextRenderable(testRenderer, { + id: "line-0", + content: "ab👋cd", + position: "absolute", + left: 0, + top: 2, + }), + ) + + testRenderer.root.add( + new TransparentBufferedBox(testRenderer, { + id: "overlay", + buffered: true, + position: "absolute", + left: 1, + top: 1, + width: 8, + height: 3, + border: false, + shouldFill: false, + backgroundColor: "transparent", + }), + ) + + await renderOnce() + + const row = captureCharFrame().split("\n")[2] + expect(row?.startsWith("ab👋cd")).toBe(true) + }) +}) diff --git a/packages/core/src/renderables/Box.ts b/packages/core/src/renderables/Box.ts index 97b617990..4c871df70 100644 --- a/packages/core/src/renderables/Box.ts +++ b/packages/core/src/renderables/Box.ts @@ -14,6 +14,49 @@ import { type ColorInput, RGBA, parseColor } from "../lib/RGBA.js" import { isValidPercentage } from "../lib/renderable.validations.js" import type { RenderContext } from "../types.js" +const CHAR_FLAG_MASK = 0xc0000000 >>> 0 +const CHAR_FLAG_GRAPHEME = 0x80000000 >>> 0 +const CHAR_FLAG_CONTINUATION = 0xc0000000 >>> 0 +const CHAR_EXT_RIGHT_SHIFT = 28 +const CHAR_EXT_LEFT_SHIFT = 26 +const CHAR_EXT_MASK = 0x3 +const TRANSPARENT = RGBA.fromValues(0, 0, 0, 0) + +function isWideClusterCell(charCode: number): boolean { + const flag = (charCode & CHAR_FLAG_MASK) >>> 0 + if (flag !== CHAR_FLAG_GRAPHEME && flag !== CHAR_FLAG_CONTINUATION) { + return false + } + + const left = (charCode >>> CHAR_EXT_LEFT_SHIFT) & CHAR_EXT_MASK + const right = (charCode >>> CHAR_EXT_RIGHT_SHIFT) & CHAR_EXT_MASK + return left + right > 0 +} + +interface Rect { + x: number + y: number + width: number + height: number +} + +function intersectRects(a: Rect, b: Rect): Rect | null { + const startX = Math.max(a.x, b.x) + const startY = Math.max(a.y, b.y) + const endX = Math.min(a.x + a.width - 1, b.x + b.width - 1) + const endY = Math.min(a.y + a.height - 1, b.y + b.height - 1) + + if (startX > endX || startY > endY) { + return null + } + + return { + x: startX, + y: startY, + width: endX - startX + 1, + height: endY - startY + 1, + } +} export interface BoxOptions extends RenderableOptions { backgroundColor?: string | RGBA borderStyle?: BorderStyle @@ -56,6 +99,9 @@ export class BoxRenderable extends Renderable { protected _titleAlignment: "left" | "center" | "right" protected _bottomTitle?: string protected _bottomTitleAlignment: "left" | "center" | "right" + private suppressFillDuringFrameBufferRender = false + private frameBufferNeedsReset = false + private frameBufferRendered = false protected _defaultOptions = { backgroundColor: "transparent", @@ -237,20 +283,70 @@ export class BoxRenderable extends Renderable { } } - protected renderSelf(buffer: OptimizedBuffer): void { - const currentBorderColor = this._focused ? this._focusedBorderColor : this._borderColor + public override render(buffer: OptimizedBuffer, deltaTime: number): void { + let shouldDrawFrameBuffer = false + + if (!this.buffered || !this.frameBuffer) { + this.renderToBuffer(buffer, deltaTime) + } else if (this.canReuseFrameBuffer(buffer)) { + shouldDrawFrameBuffer = true + } else if (this.shouldBypassFrameBuffer(buffer)) { + // The framebuffer is skipped entirely on this path, so any retained + // contents must be discarded before we use it again later. + this.frameBufferNeedsReset = true + this.frameBufferRendered = false + this.renderToBuffer(buffer, deltaTime) + } else { + if (this.frameBufferNeedsReset || this.shouldClearFrameBufferBeforeRender(buffer)) { + // Only reset retained contents when this box would otherwise alpha-blend + // onto its own previous framebuffer output. + this.frameBuffer.clear(RGBA.fromValues(0, 0, 0, 0)) + this.frameBufferNeedsReset = false + this.frameBufferRendered = false + } + const shouldRenderFillDirectly = this.shouldRenderFillDirectly(buffer) + const shouldRenderBeforeUnderDirectFill = shouldRenderFillDirectly && !!this.renderBefore + if (shouldRenderBeforeUnderDirectFill) { + this.renderBefore!.call(this, this.frameBuffer, deltaTime) + buffer.drawFrameBuffer(this.x, this.y, this.frameBuffer) + this.frameBuffer.clear(RGBA.fromValues(0, 0, 0, 0)) + } + if (shouldRenderFillDirectly) { + this.renderFill(buffer) + this.suppressFillDuringFrameBufferRender = true + } + this.renderToBuffer(this.frameBuffer, deltaTime, { + skipRenderBefore: shouldRenderBeforeUnderDirectFill, + }) + this.frameBufferRendered = true + shouldDrawFrameBuffer = true + } + this.markClean() + this._ctx.addToHitGrid(this.x, this.y, this.width, this.height, this.num) + + if (shouldDrawFrameBuffer) { + buffer.drawFrameBuffer(this.x, this.y, this.frameBuffer!) + } + } + protected renderSelf(buffer: OptimizedBuffer, _deltaTime: number): void { + const currentBorderColor = this.getCurrentBorderColor() + const { x, y } = this.getRenderOrigin(buffer) + if (!this.suppressFillDuringFrameBufferRender) { + this.renderFill(buffer) + } + const borderBackgroundColor = this.shouldFill ? TRANSPARENT : this._backgroundColor buffer.drawBox({ - x: this.x, - y: this.y, + x, + y, width: this.width, height: this.height, borderStyle: this._borderStyle, customBorderChars: this._customBorderChars, border: this._border, borderColor: currentBorderColor, - backgroundColor: this._backgroundColor, - shouldFill: this.shouldFill, + backgroundColor: borderBackgroundColor, + shouldFill: false, title: this._title, titleAlignment: this._titleAlignment, bottomTitle: this._bottomTitle, @@ -258,6 +354,234 @@ export class BoxRenderable extends Renderable { }) } + private shouldBypassFrameBuffer(buffer: OptimizedBuffer): boolean { + if (!this.buffered || !this.frameBuffer) { + return false + } + + // Bypass is only safe for the plain Box path. Hooks or renderSelf + // overrides depend on framebuffer-local ordering/coordinates. + if (this.renderBefore || this.renderAfter) { + return false + } + + const resolvedRenderSelf = Object.getPrototypeOf(this).renderSelf + if (resolvedRenderSelf !== BoxRenderable.prototype.renderSelf) { + return false + } + + return this.hasTranslucentParts(buffer) + } + + private shouldClearFrameBufferBeforeRender(buffer: OptimizedBuffer): boolean { + return this.hasTranslucentParts(buffer) + } + + private canReuseFrameBuffer(buffer: OptimizedBuffer): boolean { + if (!this.frameBufferRendered || this.frameBufferNeedsReset || this.isDirty || this.live) { + return false + } + + if (this.shouldBypassFrameBuffer(buffer)) { + return false + } + + return !this.shouldRenderFillDirectly(buffer) + } + + private shouldRenderFillDirectly(buffer: OptimizedBuffer): boolean { + return this.hasTranslucentFill(buffer) + } + + private hasTranslucentParts(buffer: OptimizedBuffer): boolean { + return this.hasTranslucentFill(buffer) || this.hasTranslucentBorder() + } + + private hasTranslucentFill(buffer: OptimizedBuffer): boolean { + return this._backgroundColor.a < 1 || buffer.getCurrentOpacity() < 1 + } + + private hasTranslucentBorder(): boolean { + return this.getCurrentBorderColor().a < 1 + } + + private getCurrentBorderColor(): RGBA { + return this._focused ? this._focusedBorderColor : this._borderColor + } + + private renderToBuffer( + buffer: OptimizedBuffer, + deltaTime: number, + options: { skipRenderBefore?: boolean } = {}, + ): void { + try { + if (!options.skipRenderBefore && this.renderBefore) { + this.renderBefore.call(this, buffer, deltaTime) + } + + this.renderSelf(buffer, deltaTime) + + if (this.renderAfter) { + this.renderAfter.call(this, buffer, deltaTime) + } + } finally { + this.suppressFillDuringFrameBufferRender = false + } + } + private isRenderingToOwnFrameBuffer(buffer: OptimizedBuffer): boolean { + return this.buffered && this.frameBuffer === buffer + } + + private getRenderOrigin(buffer: OptimizedBuffer): { x: number; y: number } { + if (this.isRenderingToOwnFrameBuffer(buffer)) { + return { x: 0, y: 0 } + } + + return { x: this.x, y: this.y } + } + + // Translucent fills only need the hybrid strategy when the perimeter + // actually crosses a wide span. Otherwise the normal preserved fill path + // can be used for the whole rect, which keeps small narrow-text overlays + // from collapsing into an all-edge clipped fill. + private renderFill(buffer: OptimizedBuffer): void { + if (!this.shouldFill || this.width <= 0 || this.height <= 0) { + return + } + + if (this._backgroundColor.a * buffer.getCurrentOpacity() <= 0) { + return + } + + const fillRect = this.getClippedFillRect(buffer) + if (!fillRect) { + return + } + + const backgroundIsTranslucent = this._backgroundColor.a < 1 || buffer.getCurrentOpacity() < 1 + if (!backgroundIsTranslucent) { + buffer.fillRect(fillRect.x, fillRect.y, fillRect.width, fillRect.height, this._backgroundColor) + return + } + + if (!this.fillPerimeterTouchesWideGrapheme(buffer, fillRect)) { + buffer.fillRect(fillRect.x, fillRect.y, fillRect.width, fillRect.height, this._backgroundColor) + return + } + + // Width/height <= 2 means there is no interior left after reserving a + // one-cell perimeter band, so the whole translucent fill has to use the + // clipping path. + if (fillRect.width <= 2 || fillRect.height <= 2) { + buffer.fillRectClipWideGraphemes(fillRect.x, fillRect.y, fillRect.width, fillRect.height, this._backgroundColor) + return + } + + buffer.fillRectClipWideGraphemes(fillRect.x, fillRect.y, fillRect.width, 1, this._backgroundColor) + buffer.fillRectClipWideGraphemes( + fillRect.x, + fillRect.y + fillRect.height - 1, + fillRect.width, + 1, + this._backgroundColor, + ) + buffer.fillRectClipWideGraphemes(fillRect.x, fillRect.y + 1, 1, fillRect.height - 2, this._backgroundColor) + buffer.fillRectClipWideGraphemes( + fillRect.x + fillRect.width - 1, + fillRect.y + 1, + 1, + fillRect.height - 2, + this._backgroundColor, + ) + buffer.fillRect(fillRect.x + 1, fillRect.y + 1, fillRect.width - 2, fillRect.height - 2, this._backgroundColor) + } + + private fillPerimeterTouchesWideGrapheme( + buffer: OptimizedBuffer, + fillRect: { x: number; y: number; width: number; height: number }, + ): boolean { + const chars = buffer.buffers.char + const top = fillRect.y + const bottom = fillRect.y + fillRect.height - 1 + const left = fillRect.x + const right = fillRect.x + fillRect.width - 1 + const rowStride = buffer.width + + for (let x = left; x <= right; x++) { + if (isWideClusterCell(chars[top * rowStride + x])) { + return true + } + if (bottom !== top && isWideClusterCell(chars[bottom * rowStride + x])) { + return true + } + } + + for (let y = top + 1; y < bottom; y++) { + if (isWideClusterCell(chars[y * rowStride + left])) { + return true + } + if (right !== left && isWideClusterCell(chars[y * rowStride + right])) { + return true + } + } + + return false + } + + private getClippedFillRect(buffer: OptimizedBuffer): Rect | null { + if (this.width <= 0 || this.height <= 0) { + return null + } + + const { x, y } = this.getRenderOrigin(buffer) + const requestedRect = { + x, + y, + width: this.width, + height: this.height, + } + const visibleClipRect = this.getVisibleClipRect(buffer) + if (!visibleClipRect) { + return null + } + + return intersectRects(requestedRect, visibleClipRect) + } + + private getVisibleClipRect(buffer: OptimizedBuffer): Rect | null { + if (this.isRenderingToOwnFrameBuffer(buffer)) { + return { + x: 0, + y: 0, + width: buffer.width, + height: buffer.height, + } + } + + let clipRect: Rect = { + x: 0, + y: 0, + width: buffer.width, + height: buffer.height, + } + + let ancestor = this.parent + while (ancestor) { + if (ancestor.overflow !== "visible" && ancestor.width > 0 && ancestor.height > 0) { + const ancestorClipRect = ancestor.getOverflowClipRect() + const nextClipRect = intersectRects(clipRect, ancestorClipRect) + if (!nextClipRect) { + return null + } + clipRect = nextClipRect + } + + ancestor = ancestor.parent + } + + return clipRect + } + protected getScissorRect(): { x: number; y: number; width: number; height: number } { const baseRect = super.getScissorRect() diff --git a/packages/core/src/zig.ts b/packages/core/src/zig.ts index 90f79ac1d..66dbec856 100644 --- a/packages/core/src/zig.ts +++ b/packages/core/src/zig.ts @@ -366,6 +366,10 @@ function getOpenTUILib(libPath?: string) { args: ["ptr", "i32", "i32", "u32", "u32", "ptr", "u32", "ptr", "ptr", "ptr", "u32", "ptr", "u32"], returns: "void", }, + bufferFillRectClipWideGraphemes: { + args: ["ptr", "u32", "u32", "u32", "u32", "ptr"], + returns: "void", + }, bufferPushScissorRect: { args: ["ptr", "i32", "i32", "u32", "u32"], returns: "void", @@ -394,7 +398,6 @@ function getOpenTUILib(libPath?: string) { args: ["ptr"], returns: "void", }, - addToHitGrid: { args: ["ptr", "i32", "i32", "u32", "u32", "u32"], returns: "void", @@ -1448,6 +1451,14 @@ export interface RenderLib { attributes?: number, ) => void bufferFillRect: (buffer: Pointer, x: number, y: number, width: number, height: number, color: RGBA) => void + bufferFillRectClipWideGraphemes: ( + buffer: Pointer, + x: number, + y: number, + width: number, + height: number, + color: RGBA, + ) => void bufferColorMatrix: ( buffer: Pointer, matrixPtr: Pointer, @@ -2188,6 +2199,18 @@ class FFIRenderLib implements RenderLib { this.opentui.symbols.bufferFillRect(buffer, x, y, width, height, bg) } + public bufferFillRectClipWideGraphemes( + buffer: Pointer, + x: number, + y: number, + width: number, + height: number, + color: RGBA, + ) { + const bg = color.buffer + this.opentui.symbols.bufferFillRectClipWideGraphemes(buffer, x, y, width, height, bg) + } + public bufferColorMatrix( buffer: Pointer, matrixPtr: Pointer, diff --git a/packages/core/src/zig/buffer.zig b/packages/core/src/zig/buffer.zig index 7575bbdeb..13b652b12 100644 --- a/packages/core/src/zig/buffer.zig +++ b/packages/core/src/zig/buffer.zig @@ -169,6 +169,48 @@ pub const OptimizedBuffer = struct { scissor_stack: std.ArrayListUnmanaged(ClipRect), opacity_stack: std.ArrayListUnmanaged(f32), + /// Ephemeral cursor for left-to-right alpha-blend passes. + /// Tracks the last wide-grapheme span that was blended so that + /// subsequent same-style space writes within the same span are skipped. + /// Create one per pass/row; do NOT store on the buffer. + const BlendCursor = struct { + span_end_x: u32 = 0, + active: bool = false, + fg: RGBA = .{ 0.0, 0.0, 0.0, 0.0 }, + bg: RGBA = .{ 0.0, 0.0, 0.0, 0.0 }, + attributes: u32 = 0, + + fn sameStyle(self: *const BlendCursor, fg: RGBA, bg: RGBA, attributes: u32) bool { + return self.attributes == attributes and + self.fg[0] == fg[0] and + self.fg[1] == fg[1] and + self.fg[2] == fg[2] and + self.fg[3] == fg[3] and + self.bg[0] == bg[0] and + self.bg[1] == bg[1] and + self.bg[2] == bg[2] and + self.bg[3] == bg[3]; + } + + /// Alpha-blend with span-skip awareness. Use this instead of + /// setCellWithAlphaBlending() when iterating left-to-right. + fn blendAt(self: *BlendCursor, buf: *OptimizedBuffer, x: u32, y: u32, char: u32, fg: RGBA, bg: RGBA, attributes: u32) !u32 { + if (self.active and x <= self.span_end_x and char == DEFAULT_SPACE_CHAR and self.sameStyle(fg, bg, attributes)) { + return self.span_end_x; + } + self.active = false; + const result_x = try buf.setCellWithAlphaBlending(x, y, char, fg, bg, attributes); + if (result_x > x) { + self.span_end_x = result_x; + self.fg = fg; + self.bg = bg; + self.attributes = attributes; + self.active = true; + } + return result_x; + } + }; + const InitOptions = struct { respectAlpha: bool = false, blendBackdropColor: ?RGBA = null, @@ -592,15 +634,14 @@ pub const OptimizedBuffer = struct { return self.coordsToIndex(x, y); } - /// Write cell data at index and update link tracker. - fn writeCellAndLinks(self: *OptimizedBuffer, index: u32, cell: Cell) void { + /// Write style (fg/bg/attributes) at index and update link tracker. + fn writeStyleAndLinks(self: *OptimizedBuffer, index: u32, fg: RGBA, bg: RGBA, attributes: u32) void { const prev_link_id = ansi.TextAttributes.getLinkId(self.buffer.attributes[index]); - const new_link_id = ansi.TextAttributes.getLinkId(cell.attributes); + const new_link_id = ansi.TextAttributes.getLinkId(attributes); - self.buffer.char[index] = cell.char; - self.buffer.fg[index] = cell.fg; - self.buffer.bg[index] = cell.bg; - self.buffer.attributes[index] = cell.attributes; + self.buffer.fg[index] = fg; + self.buffer.bg[index] = bg; + self.buffer.attributes[index] = attributes; if (prev_link_id != 0 and prev_link_id != new_link_id) { self.link_tracker.removeCellRef(prev_link_id); @@ -610,6 +651,138 @@ pub const OptimizedBuffer = struct { } } + /// Write cell data at index and update link tracker. + fn writeCellAndLinks(self: *OptimizedBuffer, index: u32, cell: Cell) void { + self.buffer.char[index] = cell.char; + self.writeStyleAndLinks(index, cell.fg, cell.bg, cell.attributes); + } + + fn cellAtIndex(self: *const OptimizedBuffer, index: u32) Cell { + return Cell{ + .char = self.buffer.char[index], + .fg = self.buffer.fg[index], + .bg = self.buffer.bg[index], + .attributes = self.buffer.attributes[index], + }; + } + + pub const WideCharKind = enum { wide_text, emoji, unknown }; + + fn isEmojiLikeSequence(bytes: []const u8) bool { + var offset: usize = 0; + while (offset < bytes.len) { + const cp_len = std.unicode.utf8ByteSequenceLength(bytes[offset]) catch return false; + if (offset + cp_len > bytes.len) return false; + const cp = std.unicode.utf8Decode(bytes[offset .. offset + cp_len]) catch return false; + + if (cp == 0x200D or cp == 0xFE0F or cp == 0x20E3) return true; + if (cp >= 0x1F1E6 and cp <= 0x1F1FF) return true; + if (cp >= 0x1F3FB and cp <= 0x1F3FF) return true; + + offset += cp_len; + } + + return false; + } + + /// Classify wide cells in the order most likely to preserve overlay intent: + /// explicit emoji signals first, East Asian width next, then multi-codepoint + /// sequence heuristics for ZWJ/flags/modifiers. + pub fn classifyWideChar(self: *const OptimizedBuffer, char: u32) WideCharKind { + const grapheme_bytes: ?[]const u8 = blk: { + if (!gp.isGraphemeChar(char) and !gp.isContinuationChar(char)) break :blk null; + const id = gp.graphemeIdFromChar(char); + const bytes = self.pool.get(id) catch return .unknown; + if (bytes.len == 0) return .unknown; + break :blk bytes; + }; + const cp: u21 = blk: { + if (grapheme_bytes == null) { + if (char > 0x10FFFF) return .unknown; + break :blk @intCast(char); + } + + const bytes = grapheme_bytes.?; + const seq_len = std.unicode.utf8ByteSequenceLength(bytes[0]) catch return .unknown; + if (seq_len > bytes.len) return .unknown; + break :blk std.unicode.utf8Decode(bytes[0..seq_len]) catch return .unknown; + }; + + if (uucode.get(.is_emoji_presentation, cp)) return .emoji; + + const eaw = uucode.get(.east_asian_width, cp); + if (eaw == .fullwidth or eaw == .wide) return .wide_text; + + if (grapheme_bytes) |bytes| { + const seq_len = std.unicode.utf8ByteSequenceLength(bytes[0]) catch return .unknown; + if (seq_len < bytes.len) { + if (isEmojiLikeSequence(bytes)) return .emoji; + return .wide_text; + } + } + + return .unknown; + } + + fn shouldPlaceholderWideChar(self: *const OptimizedBuffer, char: u32) bool { + return switch (self.classifyWideChar(char)) { + .emoji => true, + .wide_text, .unknown => false, + }; + } + + const GraphemeSpan = struct { + start: u32, + end: u32, + id: u32, + }; + + fn graphemeSpanForIndex(self: *const OptimizedBuffer, index: u32, char: u32) GraphemeSpan { + const row_start = index - (index % self.width); + const row_end = row_start + self.width - 1; + const left = gp.charLeftExtent(char); + const right = gp.charRightExtent(char); + + return .{ + .start = index - @min(left, index - row_start), + .end = index + @min(right, row_end - index), + .id = gp.graphemeIdFromChar(char), + }; + } + + fn applyCellStyleToGraphemeSpan(self: *OptimizedBuffer, span: GraphemeSpan, style: Cell) void { + var span_i: u32 = span.start; + while (span_i <= span.end) : (span_i += 1) { + if (!self.isPointInScissor(@intCast(span_i % self.width), @intCast(span_i / self.width))) continue; + + const span_char = self.buffer.char[span_i]; + if (!(gp.isGraphemeChar(span_char) or gp.isContinuationChar(span_char))) continue; + if (gp.graphemeIdFromChar(span_char) != span.id) continue; + + self.writeStyleAndLinks(span_i, style.fg, style.bg, style.attributes); + } + } + + /// Blend a translucent overlay onto an entire wide-grapheme span uniformly. + /// Reads the canonical start cell of the span, blends once, and writes + /// the result to every cell in the span. Returns the rightmost + /// x-coordinate of the span. + fn blendPreservedGraphemeSpan(self: *OptimizedBuffer, index: u32, overlayCell: Cell) u32 { + const span_char = self.buffer.char[index]; + const span = self.graphemeSpanForIndex(index, span_char); + const start_char = self.buffer.char[span.start]; + // If the canonical start cell is clipped away, blend from the visible + // cell we were asked to update so partial-span writes still have a + // style source. + const style_source_index = if (gp.isGraphemeChar(start_char) and gp.graphemeIdFromChar(start_char) == span.id) + span.start + else + index; + const blended = self.blendCells(overlayCell, self.cellAtIndex(style_source_index)); + self.applyCellStyleToGraphemeSpan(span, blended); + return span.end % self.width; + } + pub fn get(self: *const OptimizedBuffer, x: u32, y: u32) ?Cell { if (x >= self.width or y >= self.height) return null; @@ -728,7 +901,7 @@ pub const OptimizedBuffer = struct { return bytes_written; } - pub fn blendCells(self: *const OptimizedBuffer, overlayCell: Cell, destCell: Cell) Cell { + fn blendAlphaCells(self: *const OptimizedBuffer, overlayCell: Cell, destCell: Cell, allow_preserve_char: bool) Cell { const hasBgAlpha = isRGBAWithAlpha(overlayCell.bg); const hasFgAlpha = isRGBAWithAlpha(overlayCell.fg); @@ -740,12 +913,11 @@ pub const OptimizedBuffer = struct { const charIsDefaultSpace = overlayCell.char == DEFAULT_SPACE_CHAR; const destNotZero = destCell.char != 0; const destNotDefaultSpace = destCell.char != DEFAULT_SPACE_CHAR; - const destWidthIsOne = gp.encodedCharWidth(destCell.char) == 1; - const preserveChar = (charIsDefaultSpace and + const preserveChar = (allow_preserve_char and + charIsDefaultSpace and destNotZero and - destNotDefaultSpace and - destWidthIsOne); + destNotDefaultSpace); const finalChar = if (preserveChar) destCell.char else overlayCell.char; var finalFg: RGBA = undefined; @@ -783,6 +955,40 @@ pub const OptimizedBuffer = struct { return overlayCell; } + pub fn blendCells(self: *const OptimizedBuffer, overlayCell: Cell, destCell: Cell) Cell { + return self.blendAlphaCells(overlayCell, destCell, true); + } + + fn blendCellsWithoutPreservingChar(self: *const OptimizedBuffer, overlayCell: Cell, destCell: Cell) Cell { + return self.blendAlphaCells(overlayCell, destCell, false); + } + + // Render a stable ASCII placeholder using the overlay tint instead of + // trying to recolor emoji bytes, which terminals treat opaquely. + fn renderPlaceholder(self: *OptimizedBuffer, span_start: u32, width: u32, overlayCell: Cell, destCell: Cell) void { + const blendedBg = blendColors(overlayCell.bg, destCell.bg, self.blendBackdropColor); + const blendedFg = blendColors(overlayCell.bg, destCell.fg, self.blendBackdropColor); + const placeholderBg = RGBA{ blendedBg[0], blendedBg[1], blendedBg[2], overlayCell.bg[3] }; + const y = span_start / self.width; + const x = span_start % self.width; + const attrs = overlayCell.attributes; + + self.set(x, y, Cell{ .char = '[', .fg = blendedFg, .bg = placeholderBg, .attributes = attrs }); + if (width > 1 and x + 1 < self.width) { + self.set(x + 1, y, Cell{ .char = ']', .fg = blendedFg, .bg = placeholderBg, .attributes = attrs }); + } + + var extra: u32 = 2; + while (extra < width) : (extra += 1) { + if (x + extra < self.width) { + self.set(x + extra, y, Cell{ .char = DEFAULT_SPACE_CHAR, .fg = blendedFg, .bg = placeholderBg, .attributes = attrs }); + } + } + } + + /// Alpha-blend an overlay cell onto the destination at (x, y). + /// Wide text spans are preserved and tinted uniformly. Wide emoji use a + /// placeholder span instead of rendering tinted color emoji. pub fn setCellWithAlphaBlending( self: *OptimizedBuffer, x: u32, @@ -791,27 +997,70 @@ pub const OptimizedBuffer = struct { fg: RGBA, bg: RGBA, attributes: u32, - ) !void { - if (!self.isPointInScissor(@intCast(x), @intCast(y))) return; + ) !u32 { + if (!self.isPointInScissor(@intCast(x), @intCast(y))) return x; + + const index = self.coordsToIndex(x, y); // Apply current opacity from the stack const opacity = self.getCurrentOpacity(); if (isFullyOpaque(opacity, fg, bg)) { self.set(x, y, Cell{ .char = char, .fg = fg, .bg = bg, .attributes = attributes }); - return; + return x; } const effectiveFg = RGBA{ fg[0], fg[1], fg[2], fg[3] * opacity }; const effectiveBg = RGBA{ bg[0], bg[1], bg[2], bg[3] * opacity }; + if (effectiveFg[3] == 0.0 and effectiveBg[3] == 0.0) return x; const overlayCell = Cell{ .char = char, .fg = effectiveFg, .bg = effectiveBg, .attributes = attributes }; - if (self.get(x, y)) |destCell| { - const blendedCell = self.blendCells(overlayCell, destCell); - self.set(x, y, blendedCell); - } else { + const destCell = self.get(x, y) orelse { self.set(x, y, overlayCell); + return x; + }; + const blendedCell = self.blendCells(overlayCell, destCell); + const preservesDestChar = blendedCell.char == destCell.char and + (gp.isGraphemeChar(destCell.char) or gp.isContinuationChar(destCell.char)); + if (!preservesDestChar) { + self.set(x, y, blendedCell); + return x; + } + + const isWideSpan = gp.charLeftExtent(destCell.char) + gp.charRightExtent(destCell.char) > 0; + if (!isWideSpan) { + // Single-cell grapheme: update just this cell. + self.writeStyleAndLinks(index, blendedCell.fg, blendedCell.bg, blendedCell.attributes); + return x; + } + + // A transparent space should be a no-op for an already-preserved wide + // span; return the span end so left-to-right callers can skip the rest. + if (overlayCell.char == DEFAULT_SPACE_CHAR and overlayCell.bg[3] == 0.0) { + const span = self.graphemeSpanForIndex(index, destCell.char); + return span.end % self.width; } + + if (overlayCell.char == DEFAULT_SPACE_CHAR and self.shouldPlaceholderWideChar(destCell.char)) { + const span = self.graphemeSpanForIndex(index, destCell.char); + const span_start_x = span.start % self.width; + const span_end_x = span.end % self.width; + const span_y = span.start / self.width; + if (self.isPointInScissor(@intCast(span_start_x), @intCast(span_y)) and + self.isPointInScissor(@intCast(span_end_x), @intCast(span_y))) + { + const width = span.end - span.start + 1; + const start_cell = self.cellAtIndex(span.start); + self.renderPlaceholder(span.start, width, overlayCell, start_cell); + return span_end_x; + } + + return self.blendPreservedGraphemeSpan(index, overlayCell); + } + + // Wide grapheme: resolve the full span, blend once from the canonical + // start cell, and write uniformly. + return self.blendPreservedGraphemeSpan(index, overlayCell); } pub fn setCellWithAlphaBlendingRaw( @@ -852,6 +1101,71 @@ pub const OptimizedBuffer = struct { } } + /// Alpha-blend an overlay cell while clipping any intersected wide + /// grapheme span instead of tinting that full span uniformly. Box edge + /// bands use this path so geometric edges stay straight even when they + /// cut through a wide glyph. + pub fn setCellWithAlphaBlendingClipWideGraphemes( + self: *OptimizedBuffer, + x: u32, + y: u32, + char: u32, + fg: RGBA, + bg: RGBA, + attributes: u32, + ) !void { + if (!self.isPointInScissor(@intCast(x), @intCast(y))) return; + + const opacity = self.getCurrentOpacity(); + if (isFullyOpaque(opacity, fg, bg)) { + self.set(x, y, Cell{ .char = char, .fg = fg, .bg = bg, .attributes = attributes }); + return; + } + + const effectiveFg = RGBA{ fg[0], fg[1], fg[2], fg[3] * opacity }; + const effectiveBg = RGBA{ bg[0], bg[1], bg[2], bg[3] * opacity }; + if (char == DEFAULT_SPACE_CHAR and effectiveBg[3] == 0.0) return; + if (effectiveFg[3] == 0.0 and effectiveBg[3] == 0.0) return; + const overlayCell = Cell{ .char = char, .fg = effectiveFg, .bg = effectiveBg, .attributes = attributes }; + + if (self.get(x, y)) |destCell| { + const blendedCell = self.blendCellsWithoutPreservingChar(overlayCell, destCell); + self.set(x, y, blendedCell); + } else { + self.set(x, y, overlayCell); + } + } + + const FillBounds = struct { + start_x: u32, + start_y: u32, + end_x: u32, + end_y: u32, + }; + + fn clippedFillBounds(self: *const OptimizedBuffer, x: u32, y: u32, width: u32, height: u32) ?FillBounds { + if (self.width == 0 or self.height == 0 or width == 0 or height == 0) return null; + if (x >= self.width or y >= self.height) return null; + if (!self.isRectInScissor(@intCast(x), @intCast(y), width, height)) return null; + + const maxEndX = self.width - 1; + const maxEndY = self.height - 1; + const requestedEndX = x + width - 1; + const requestedEndY = y + height - 1; + const endX = @min(maxEndX, requestedEndX); + const endY = @min(maxEndY, requestedEndY); + if (x > endX or y > endY) return null; + + const clippedRect = self.clipRectToScissor(@intCast(x), @intCast(y), endX - x + 1, endY - y + 1) orelse return null; + + return .{ + .start_x = @max(x, @as(u32, @intCast(clippedRect.x))), + .start_y = @max(y, @as(u32, @intCast(clippedRect.y))), + .end_x = @min(endX, @as(u32, @intCast(clippedRect.x + @as(i32, @intCast(clippedRect.width)) - 1))), + .end_y = @min(endY, @as(u32, @intCast(clippedRect.y + @as(i32, @intCast(clippedRect.height)) - 1))), + }; + } + pub fn drawChar( self: *OptimizedBuffer, char: u32, @@ -864,7 +1178,7 @@ pub const OptimizedBuffer = struct { if (!self.isPointInScissor(@intCast(x), @intCast(y))) return; if (isRGBAWithAlpha(bg) or isRGBAWithAlpha(fg)) { - try self.setCellWithAlphaBlending(x, y, char, fg, bg, attributes); + _ = try self.setCellWithAlphaBlending(x, y, char, fg, bg, attributes); } else { self.set(x, y, Cell{ .char = char, @@ -883,46 +1197,27 @@ pub const OptimizedBuffer = struct { height: u32, bg: RGBA, ) !void { - if (self.width == 0 or self.height == 0 or width == 0 or height == 0) return; - if (x >= self.width or y >= self.height) return; - - if (!self.isRectInScissor(@intCast(x), @intCast(y), width, height)) return; - - const startX = x; - const startY = y; - const maxEndX = if (x < self.width) self.width - 1 else 0; - const maxEndY = if (y < self.height) self.height - 1 else 0; - const requestedEndX = x + width - 1; - const requestedEndY = y + height - 1; - const endX = @min(maxEndX, requestedEndX); - const endY = @min(maxEndY, requestedEndY); - - if (startX > endX or startY > endY) return; - - const clippedRect = self.clipRectToScissor(@intCast(startX), @intCast(startY), endX - startX + 1, endY - startY + 1) orelse return; - const clippedStartX = @max(startX, @as(u32, @intCast(clippedRect.x))); - const clippedStartY = @max(startY, @as(u32, @intCast(clippedRect.y))); - const clippedEndX = @min(endX, @as(u32, @intCast(clippedRect.x + @as(i32, @intCast(clippedRect.width)) - 1))); - const clippedEndY = @min(endY, @as(u32, @intCast(clippedRect.y + @as(i32, @intCast(clippedRect.height)) - 1))); + if (bg[3] * self.getCurrentOpacity() == 0.0) return; + const bounds = self.clippedFillBounds(x, y, width, height) orelse return; - const opacity = self.getCurrentOpacity(); - const hasAlpha = isRGBAWithAlpha(bg) or opacity < 1.0; + const hasAlpha = isRGBAWithAlpha(bg) or self.getCurrentOpacity() < 1.0; const linkAware = self.link_tracker.hasAny(); if (hasAlpha or self.grapheme_tracker.hasAny() or linkAware) { - var fillY = clippedStartY; - while (fillY <= clippedEndY) : (fillY += 1) { - var fillX = clippedStartX; - while (fillX <= clippedEndX) : (fillX += 1) { - try self.setCellWithAlphaBlending(fillX, fillY, DEFAULT_SPACE_CHAR, .{ 1.0, 1.0, 1.0, 1.0 }, bg, 0); + var fillY = bounds.start_y; + while (fillY <= bounds.end_y) : (fillY += 1) { + var cursor = BlendCursor{}; + var fillX = bounds.start_x; + while (fillX <= bounds.end_x) : (fillX += 1) { + fillX = try cursor.blendAt(self, fillX, fillY, DEFAULT_SPACE_CHAR, .{ 1.0, 1.0, 1.0, 1.0 }, bg, 0); } } } else { // For non-alpha (fully opaque) backgrounds with no graphemes or links, we can do direct filling - var fillY = clippedStartY; - while (fillY <= clippedEndY) : (fillY += 1) { - const rowStartIndex = self.coordsToIndex(@intCast(clippedStartX), @intCast(fillY)); - const rowWidth = clippedEndX - clippedStartX + 1; + var fillY = bounds.start_y; + while (fillY <= bounds.end_y) : (fillY += 1) { + const rowStartIndex = self.coordsToIndex(@intCast(bounds.start_x), @intCast(fillY)); + const rowWidth = bounds.end_x - bounds.start_x + 1; const rowSliceChar = self.buffer.char[rowStartIndex .. rowStartIndex + rowWidth]; const rowSliceFg = self.buffer.fg[rowStartIndex .. rowStartIndex + rowWidth]; @@ -937,6 +1232,33 @@ pub const OptimizedBuffer = struct { } } + pub fn fillRectClipWideGraphemes( + self: *OptimizedBuffer, + x: u32, + y: u32, + width: u32, + height: u32, + bg: RGBA, + ) !void { + if (bg[3] * self.getCurrentOpacity() == 0.0) return; + const bounds = self.clippedFillBounds(x, y, width, height) orelse return; + + const hasAlpha = isRGBAWithAlpha(bg) or self.getCurrentOpacity() < 1.0; + const linkAware = self.link_tracker.hasAny(); + + if (hasAlpha or self.grapheme_tracker.hasAny() or linkAware) { + var fillY = bounds.start_y; + while (fillY <= bounds.end_y) : (fillY += 1) { + var fillX = bounds.start_x; + while (fillX <= bounds.end_x) : (fillX += 1) { + try self.setCellWithAlphaBlendingClipWideGraphemes(fillX, fillY, DEFAULT_SPACE_CHAR, .{ 1.0, 1.0, 1.0, 1.0 }, bg, 0); + } + } + } else { + try self.fillRect(x, y, width, height, bg); + } + } + pub fn drawText( self: *OptimizedBuffer, text: []const u8, @@ -962,6 +1284,7 @@ pub const OptimizedBuffer = struct { var byte_offset: u32 = 0; var col: u32 = 0; var special_idx: usize = 0; + var cursor = BlendCursor{}; while (byte_offset < text.len) { const charX = x + advance_cells; @@ -1013,7 +1336,8 @@ pub const OptimizedBuffer = struct { if (tab_x >= self.width) break; if (isRGBAWithAlpha(bgColor)) { - try self.setCellWithAlphaBlending( + _ = try cursor.blendAt( + self, tab_x, y, DEFAULT_SPACE_CHAR, @@ -1044,7 +1368,7 @@ pub const OptimizedBuffer = struct { } if (isRGBAWithAlpha(bgColor)) { - try self.setCellWithAlphaBlending(charX, y, encoded_char, fg, bgColor, attributes); + _ = try cursor.blendAt(self, charX, y, encoded_char, fg, bgColor, attributes); } else { self.set(charX, y, Cell{ .char = encoded_char, @@ -1125,6 +1449,7 @@ pub const OptimizedBuffer = struct { var dY = clippedStartY; while (dY <= clippedEndY) : (dY += 1) { var lastDrawnGraphemeId: u32 = 0; + var cursor = BlendCursor{}; var dX = clippedStartX; while (dX <= clippedEndX) : (dX += 1) { @@ -1151,7 +1476,7 @@ pub const OptimizedBuffer = struct { if (graphemeId != lastDrawnGraphemeId) { // We haven't drawn the start character for this grapheme (likely out of bounds to the left) // Draw a space with the same attributes to fill the cell - self.setCellWithAlphaBlending(@intCast(dX), @intCast(dY), DEFAULT_SPACE_CHAR, srcFg, srcBg, srcAttr) catch {}; + _ = cursor.blendAt(self, @intCast(dX), @intCast(dY), DEFAULT_SPACE_CHAR, srcFg, srcBg, srcAttr) catch @as(u32, @intCast(dX)); } continue; } @@ -1160,7 +1485,7 @@ pub const OptimizedBuffer = struct { lastDrawnGraphemeId = srcChar & gp.GRAPHEME_ID_MASK; } - self.setCellWithAlphaBlending(@intCast(dX), @intCast(dY), srcChar, srcFg, srcBg, srcAttr) catch {}; + _ = cursor.blendAt(self, @intCast(dX), @intCast(dY), srcChar, srcFg, srcBg, srcAttr) catch 0; continue; } @@ -1222,6 +1547,7 @@ pub const OptimizedBuffer = struct { if (currentY >= bufferBottomY) break; currentX = x; + var cursor = BlendCursor{}; var column_in_line: u32 = 0; globalCharPos = vline.col_offset; @@ -1486,13 +1812,15 @@ pub const OptimizedBuffer = struct { var tab_col: u32 = 0; while (tab_col < g_width) : (tab_col += 1) { - if (currentX + @as(i32, @intCast(tab_col)) >= @as(i32, @intCast(self.width))) break; + const tab_x_i32 = currentX + @as(i32, @intCast(tab_col)); + if (tab_x_i32 >= @as(i32, @intCast(self.width))) break; const char = if (tab_col == 0 and tab_indicator != null) tab_indicator.? else DEFAULT_SPACE_CHAR; const fg = if (tab_col == 0 and tab_indicator_color != null) tab_indicator_color.? else drawFg; - try self.setCellWithAlphaBlending( - @intCast(currentX + @as(i32, @intCast(tab_col))), + _ = try cursor.blendAt( + self, + @intCast(tab_x_i32), @intCast(currentY), char, fg, @@ -1515,7 +1843,8 @@ pub const OptimizedBuffer = struct { encoded_char = gp.packGraphemeStart(gid & gp.GRAPHEME_ID_MASK, g_width); } - try self.setCellWithAlphaBlending( + _ = try cursor.blendAt( + self, @intCast(currentX), @intCast(currentY), encoded_char, @@ -1770,7 +2099,7 @@ pub const OptimizedBuffer = struct { char = if (borderSides.right) borderChars[@intFromEnum(BorderCharIndex.topRight)] else borderChars[@intFromEnum(BorderCharIndex.horizontal)]; } - try self.setCellWithAlphaBlending(@intCast(drawX), @intCast(startY), char, borderColor, backgroundColor, 0); + _ = try self.setCellWithAlphaBlending(@intCast(drawX), @intCast(startY), char, borderColor, backgroundColor, 0); } } } @@ -1793,7 +2122,7 @@ pub const OptimizedBuffer = struct { char = if (borderSides.right) borderChars[@intFromEnum(BorderCharIndex.bottomRight)] else borderChars[@intFromEnum(BorderCharIndex.horizontal)]; } - try self.setCellWithAlphaBlending(@intCast(drawX), @intCast(endY), char, borderColor, backgroundColor, 0); + _ = try self.setCellWithAlphaBlending(@intCast(drawX), @intCast(endY), char, borderColor, backgroundColor, 0); } } } @@ -1808,12 +2137,12 @@ pub const OptimizedBuffer = struct { while (drawY <= verticalEndY) : (drawY += 1) { // Left border if (borderSides.left and isAtActualLeft and startX >= 0 and startX < @as(i32, @intCast(self.width))) { - try self.setCellWithAlphaBlending(@intCast(startX), @intCast(drawY), borderChars[@intFromEnum(BorderCharIndex.vertical)], borderColor, backgroundColor, 0); + _ = try self.setCellWithAlphaBlending(@intCast(startX), @intCast(drawY), borderChars[@intFromEnum(BorderCharIndex.vertical)], borderColor, backgroundColor, 0); } // Right border if (borderSides.right and isAtActualRight and endX >= 0 and endX < @as(i32, @intCast(self.width))) { - try self.setCellWithAlphaBlending(@intCast(endX), @intCast(drawY), borderChars[@intFromEnum(BorderCharIndex.vertical)], borderColor, backgroundColor, 0); + _ = try self.setCellWithAlphaBlending(@intCast(endX), @intCast(drawY), borderChars[@intFromEnum(BorderCharIndex.vertical)], borderColor, backgroundColor, 0); } } } @@ -1892,6 +2221,7 @@ pub const OptimizedBuffer = struct { var y_cell = posY; while (y_cell < self.height) : (y_cell += 1) { + var cursor = BlendCursor{}; var x_cell = posX; while (x_cell < self.width) : (x_cell += 1) { if (!self.isPointInScissor(@intCast(x_cell), @intCast(y_cell))) { @@ -1917,7 +2247,7 @@ pub const OptimizedBuffer = struct { const cellResult = renderQuadrantBlock(pixelsRgba); - try self.setCellWithAlphaBlending(x_cell, y_cell, cellResult.char, cellResult.fg, cellResult.bg, 0); + _ = try cursor.blendAt(self, x_cell, y_cell, cellResult.char, cellResult.fg, cellResult.bg, 0); } } } @@ -1938,6 +2268,8 @@ pub const OptimizedBuffer = struct { const numCells = dataLen / cellResultSize; const bufferWidthCells = terminalWidthCells; + var cursor = BlendCursor{}; + var lastRow: u32 = 0; var i: usize = 0; while (i < numCells) : (i += 1) { const cellDataOffset = i * cellResultSize; @@ -1945,6 +2277,11 @@ pub const OptimizedBuffer = struct { const cellX = posX + @as(u32, @intCast(i % bufferWidthCells)); const cellY = posY + @as(u32, @intCast(i / bufferWidthCells)); + if (cellY != lastRow) { + cursor = .{}; + lastRow = cellY; + } + if (cellX >= terminalWidthCells or cellY >= terminalHeightCells) continue; if (cellX >= self.width or cellY >= self.height) continue; @@ -1967,7 +2304,7 @@ pub const OptimizedBuffer = struct { char = BLOCK_CHAR; } - self.setCellWithAlphaBlending(cellX, cellY, char, fg, bg, 0) catch {}; + _ = cursor.blendAt(self, cellX, cellY, char, fg, bg, 0) catch 0; } } @@ -2017,6 +2354,7 @@ pub const OptimizedBuffer = struct { srcY += 1; destY += 1; }) { + var cursor = BlendCursor{}; var srcX: u32 = startX; var destX: u32 = destStartX; while (srcX < startX + visibleWidth) : ({ @@ -2036,7 +2374,7 @@ pub const OptimizedBuffer = struct { const fg: RGBA = .{ baseFg[0], baseFg[1], baseFg[2], gray * baseFg[3] * opacity }; if (graphemeAware or linkAware) { - self.setCellWithAlphaBlending(destX, destY, char, fg, bg, 0) catch {}; + _ = cursor.blendAt(self, destX, destY, char, fg, bg, 0) catch 0; } else { self.setCellWithAlphaBlendingRaw(destX, destY, char, fg, bg, 0) catch {}; } @@ -2087,6 +2425,7 @@ pub const OptimizedBuffer = struct { cellY += 1; destY += 1; }) { + var cursor = BlendCursor{}; var cellX: u32 = startX; var destX: u32 = destStartX; while (cellX < startX + visibleWidth) : ({ @@ -2118,7 +2457,7 @@ pub const OptimizedBuffer = struct { const fg: RGBA = .{ baseFg[0], baseFg[1], baseFg[2], gray * baseFg[3] * opacity }; if (graphemeAware or linkAware) { - self.setCellWithAlphaBlending(destX, destY, char, fg, bg, 0) catch {}; + _ = cursor.blendAt(self, destX, destY, char, fg, bg, 0) catch 0; } else { self.setCellWithAlphaBlendingRaw(destX, destY, char, fg, bg, 0) catch {}; } diff --git a/packages/core/src/zig/lib.zig b/packages/core/src/zig/lib.zig index b95b7e0b8..9716c8e3b 100644 --- a/packages/core/src/zig/lib.zig +++ b/packages/core/src/zig/lib.zig @@ -518,7 +518,7 @@ export fn bufferDrawText(bufferPtr: *buffer.OptimizedBuffer, text: [*]const u8, export fn bufferSetCellWithAlphaBlending(bufferPtr: *buffer.OptimizedBuffer, x: u32, y: u32, char: u32, fg: [*]const f32, bg: [*]const f32, attributes: u32) void { const rgbaFg = utils.f32PtrToRGBA(fg); const rgbaBg = utils.f32PtrToRGBA(bg); - bufferPtr.setCellWithAlphaBlending(x, y, char, rgbaFg, rgbaBg, attributes) catch {}; + _ = bufferPtr.setCellWithAlphaBlending(x, y, char, rgbaFg, rgbaBg, attributes) catch return; } export fn bufferSetCell(bufferPtr: *buffer.OptimizedBuffer, x: u32, y: u32, char: u32, fg: [*]const f32, bg: [*]const f32, attributes: u32) void { @@ -538,6 +538,11 @@ export fn bufferFillRect(bufferPtr: *buffer.OptimizedBuffer, x: u32, y: u32, wid bufferPtr.fillRect(x, y, width, height, rgbaBg) catch {}; } +export fn bufferFillRectClipWideGraphemes(bufferPtr: *buffer.OptimizedBuffer, x: u32, y: u32, width: u32, height: u32, bg: [*]const f32) void { + const rgbaBg = utils.f32PtrToRGBA(bg); + bufferPtr.fillRectClipWideGraphemes(x, y, width, height, rgbaBg) catch {}; +} + export fn bufferColorMatrix(bufferPtr: *buffer.OptimizedBuffer, matrixPtr: [*]const f32, cellMaskPtr: [*]const f32, cellMaskCount: usize, strength: f32, target: u8) void { if (cellMaskCount == 0) return; const matrix = matrixPtr[0..16]; diff --git a/packages/core/src/zig/renderer.zig b/packages/core/src/zig/renderer.zig index bc739e81c..e9a669d71 100644 --- a/packages/core/src/zig/renderer.zig +++ b/packages/core/src/zig/renderer.zig @@ -635,6 +635,12 @@ pub const CliRenderer = struct { var runStart: i64 = -1; var runLength: u32 = 0; + // Tracks continuation cells already covered by a grapheme emitted + // in this pass so we do not print trailing spaces into that span. + var lastEmittedGraphemeEnd: u32 = 0; + // Tracks old wide spans already blanked this row so repainting any + // of their cells does not repeatedly clear the same terminal cells. + var lastClearedOldGraphemeEnd: u32 = 0; for (0..self.width) |ux| { const x = @as(u32, @intCast(ux)); @@ -642,6 +648,15 @@ pub const CliRenderer = struct { const nextCell = self.nextRenderBuffer.get(x, y); if (currentCell == null or nextCell == null) continue; + // A lead-cell sync writes the grapheme's continuation cells into + // currentRenderBuffer immediately. When we reach those tails later + // in the same left-to-right pass, they can now compare equal even + // though the grapheme was just emitted. Skipping them here keeps + // the active run open so fallback output does not inject cursor + // moves between adjacent wide graphemes. + if (gp.isContinuationChar(nextCell.?.char) and x < lastEmittedGraphemeEnd) { + continue; + } if (!force) { const charEqual = currentCell.?.char == nextCell.?.char; @@ -719,6 +734,28 @@ pub const CliRenderer = struct { ansi.TextAttributes.applyAttributesOutputWriter(writer, cell.attributes) catch {}; } + var clearedOldWideSpan = false; + if (!force and (gp.isGraphemeChar(currentCell.?.char) or gp.isContinuationChar(currentCell.?.char)) and currentCell.?.char != cell.char) { + const oldLeft = gp.charLeftExtent(currentCell.?.char); + const oldRight = gp.charRightExtent(currentCell.?.char); + const oldWidth = oldLeft + 1 + oldRight; + const oldStartX = x - oldLeft; + const oldEndX = oldStartX + oldWidth; + if (oldWidth > 1 and oldEndX > lastClearedOldGraphemeEnd) { + // Clear the old terminal footprint first: the buffer can + // already be correct while the terminal still shows stale + // cells from the previous wide grapheme. + ansi.ANSI.moveToOutput(writer, oldStartX + 1, y + 1 + self.renderOffset) catch {}; + var clear_from_start_i: u32 = 0; + while (clear_from_start_i < oldWidth) : (clear_from_start_i += 1) { + writer.writeByte(' ') catch {}; + } + ansi.ANSI.moveToOutput(writer, x + 1, y + 1 + self.renderOffset) catch {}; + lastClearedOldGraphemeEnd = oldEndX; + clearedOldWideSpan = true; + } + } + // Handle grapheme characters if (gp.isGraphemeChar(cell.char)) { const gid: u32 = gp.graphemeIdFromChar(cell.char); @@ -729,11 +766,19 @@ pub const CliRenderer = struct { if (bytes.len > 0) { const capabilities = self.terminal.getCapabilities(); const graphemeWidth = gp.charRightExtent(cell.char) + 1; + lastEmittedGraphemeEnd = x + graphemeWidth; + if (!force and graphemeWidth > 1 and currentCell.?.char != cell.char and !clearedOldWideSpan) { + var clear_i: u32 = 0; + while (clear_i < graphemeWidth) : (clear_i += 1) { + writer.writeByte(' ') catch {}; + } + ansi.ANSI.moveToOutput(writer, x + 1, y + 1 + self.renderOffset) catch {}; + } if (capabilities.explicit_width) { ansi.ANSI.explicitWidthOutput(writer, graphemeWidth, bytes) catch {}; } else { writer.writeAll(bytes) catch {}; - if (capabilities.explicit_cursor_positioning) { + if (capabilities.explicit_cursor_positioning and graphemeWidth > 1) { const nextX = x + graphemeWidth; if (nextX < self.width) { ansi.ANSI.moveToOutput(writer, nextX + 1, y + 1 + self.renderOffset) catch {}; @@ -742,11 +787,13 @@ pub const CliRenderer = struct { } } } else if (gp.isContinuationChar(cell.char)) { - // Intentionally do not write a space for continuation cells. - // NOTE: disabled to fix 2-cell emoji rendering when the two - // cells have distinct colors (space overwrite can break glyph output) - - // writer.writeByte(' ') catch {}; + // Only clear continuation cells that are not part of the + // wide grapheme we just emitted. Writing a space into an + // already-emitted span can split 2-cell glyphs, including + // emoji whose two cells end up with distinct styling. + if (x >= lastEmittedGraphemeEnd) { + writer.writeByte(' ') catch {}; + } } else { const len = std.unicode.utf8Encode(@intCast(cell.char), &utf8Buf) catch 1; writer.writeAll(utf8Buf[0..len]) catch {}; diff --git a/packages/core/src/zig/tests/buffer_test.zig b/packages/core/src/zig/tests/buffer_test.zig index b72c3f910..5ebf82fd4 100644 --- a/packages/core/src/zig/tests/buffer_test.zig +++ b/packages/core/src/zig/tests/buffer_test.zig @@ -2228,7 +2228,7 @@ test "OptimizedBuffer - blendColors with transparent destination" { const semi_white = RGBA{ 1.0, 1.0, 1.0, 0.5 }; const transparent_fg = RGBA{ 0.0, 0.0, 0.0, 0.0 }; - try buf.setCellWithAlphaBlending(0, 0, 'X', semi_white, transparent_fg, 0); + _ = try buf.setCellWithAlphaBlending(0, 0, 'X', semi_white, transparent_fg, 0); const cell = buf.get(0, 0).?; try std.testing.expect(cell.fg[0] > 0.45); @@ -2255,7 +2255,7 @@ test "OptimizedBuffer - blend backdrop flattens transparent destination" { const opaque_fg = RGBA{ 1.0, 1.0, 1.0, 1.0 }; const semi_black_bg = RGBA{ 0.0, 0.0, 0.0, 0.5 }; - try buf.setCellWithAlphaBlending(0, 0, buffer_mod.DEFAULT_SPACE_CHAR, opaque_fg, semi_black_bg, 0); + _ = try buf.setCellWithAlphaBlending(0, 0, buffer_mod.DEFAULT_SPACE_CHAR, opaque_fg, semi_black_bg, 0); const cell = buf.get(0, 0).?; try std.testing.expect(cell.bg[0] > 0.45); @@ -2555,3 +2555,1044 @@ test "renderer - CJK graphemes shifting left must preserve continuation cells (# const id8 = gp.graphemeIdFromChar(cell8.char); try std.testing.expectEqual(id7, id8); } + +// ── Wide-grapheme alpha-blending tests ────────────────────────────────── +// +// These tests verify the wide-grapheme alpha-blending contract on the +// paths covered here: setCellWithAlphaBlending(), drawText(), +// drawFrameBuffer(), and fillRect(). +// +// The fixture graphemes used here are width-2 CJK characters. The contract is: +// a translucent space overlay must preserve the underlying wide grapheme and, +// when preservation happens, tint the grapheme span uniformly across its cells. +// +// Covered cases: +// 1. Basic preservation: overlay space keeps the underlying char. +// 2. Partial overlap: hitting a continuation cell tints the whole span. +// 3. Multi-cell pass: a row of spaces doesn't double-blend a single span. +// 4. Multiple spans: the skip cursor resets between distinct graphemes. +// 5. Scissor clipping: span expansion doesn't bleed outside the scissor. +// 6. Non-space override: a real character still overwrites mid-span. +// 7. Cross-call compose: independent overlays stack across separate calls. +// 8. Scissor then full: a clipped write doesn't suppress a later full write. +// +// Most tests place a width-2 grapheme, apply an overlay through one of these +// APIs, and then assert grapheme preservation and/or the expected background +// color behavior. + +test "setCellWithAlphaBlending preserves wide characters under translucent overlay" { + const pool = gp.initGlobalPool(std.testing.allocator); + defer gp.deinitGlobalPool(); + + var buf = try OptimizedBuffer.init( + std.testing.allocator, + 10, + 1, + .{ .pool = pool, .id = "test-buffer" }, + ); + defer buf.deinit(); + + const bg = RGBA{ 0.0, 0.0, 0.0, 1.0 }; + const fg = RGBA{ 1.0, 1.0, 1.0, 1.0 }; + try buf.clear(bg, null); + + // Write a width-2 grapheme (東) at column 2 + const gid = try pool.alloc("東"); + const grapheme_start = gp.packGraphemeStart(gid & gp.GRAPHEME_ID_MASK, 2); + buf.set(2, 0, .{ .char = grapheme_start, .fg = fg, .bg = bg, .attributes = 0 }); + + // Verify grapheme start + continuation are present + const start_before = buf.get(2, 0).?; + const cont_before = buf.get(3, 0).?; + try std.testing.expect(gp.isGraphemeChar(start_before.char)); + try std.testing.expect(gp.isContinuationChar(cont_before.char)); + + // Apply a translucent overlay (like NestedTmuxOverlay's #0088ff18) over the whole row + const overlay_bg = RGBA{ 0.0, 0.533, 1.0, 0.094 }; // #0088ff18 + const overlay_fg = RGBA{ 0.0, 0.0, 0.0, 0.0 }; + for (0..10) |x| { + _ = try buf.setCellWithAlphaBlending(@intCast(x), 0, ' ', overlay_fg, overlay_bg, 0); + } + + // Grapheme start and continuation must still be present + const start_after = buf.get(2, 0).?; + const cont_after = buf.get(3, 0).?; + try std.testing.expect(gp.isGraphemeChar(start_after.char)); + try std.testing.expect(gp.isContinuationChar(cont_after.char)); + + // Chars must be identical (same grapheme ID, same extents) + try std.testing.expectEqual(start_before.char, start_after.char); + try std.testing.expectEqual(cont_before.char, cont_after.char); + + // Background should be tinted (not identical to the original pure black) + try std.testing.expect(start_after.bg[1] > 0.01); // green channel gained from #0088ff + try std.testing.expect(cont_after.bg[1] > 0.01); + + // Both cells should have the SAME blended bg (no double-blending) + try std.testing.expectEqual(start_after.bg[0], cont_after.bg[0]); + try std.testing.expectEqual(start_after.bg[1], cont_after.bg[1]); + try std.testing.expectEqual(start_after.bg[2], cont_after.bg[2]); +} + +test "drawText translucent space over continuation cell tints the whole grapheme span" { + const pool = gp.initGlobalPool(std.testing.allocator); + defer gp.deinitGlobalPool(); + + var buf = try OptimizedBuffer.init( + std.testing.allocator, + 10, + 1, + .{ .pool = pool, .id = "test-buffer" }, + ); + defer buf.deinit(); + + const bg = RGBA{ 0.0, 0.0, 0.0, 1.0 }; + const fg = RGBA{ 1.0, 1.0, 1.0, 1.0 }; + try buf.clear(bg, null); + + // Write a width-2 grapheme (東) at column 2 + const gid = try pool.alloc("東"); + const grapheme_start = gp.packGraphemeStart(gid & gp.GRAPHEME_ID_MASK, 2); + buf.set(2, 0, .{ .char = grapheme_start, .fg = fg, .bg = bg, .attributes = 0 }); + + // drawText a single translucent space at x=3 (the continuation cell only) + const overlay_bg = RGBA{ 0.0, 0.533, 1.0, 0.094 }; + const overlay_fg = RGBA{ 1.0, 1.0, 1.0, 1.0 }; + try buf.drawText(" ", 3, 0, overlay_fg, overlay_bg, 0); + + // Grapheme must survive + const start_after = buf.get(2, 0).?; + const cont_after = buf.get(3, 0).?; + try std.testing.expect(gp.isGraphemeChar(start_after.char)); + try std.testing.expect(gp.isContinuationChar(cont_after.char)); + + // Both cells should be tinted (not just x=3) + try std.testing.expect(start_after.bg[1] > 0.01); + try std.testing.expect(cont_after.bg[1] > 0.01); + + // Both cells should have the SAME blended bg (uniform tint across the span) + try std.testing.expectEqual(start_after.bg[0], cont_after.bg[0]); + try std.testing.expectEqual(start_after.bg[1], cont_after.bg[1]); + try std.testing.expectEqual(start_after.bg[2], cont_after.bg[2]); +} + +test "drawFrameBuffer 1x1 translucent child over continuation cell tints the whole grapheme span" { + const pool = gp.initGlobalPool(std.testing.allocator); + defer gp.deinitGlobalPool(); + + var buf = try OptimizedBuffer.init( + std.testing.allocator, + 10, + 1, + .{ .pool = pool, .id = "test-buffer" }, + ); + defer buf.deinit(); + + const bg = RGBA{ 0.0, 0.0, 0.0, 1.0 }; + const fg = RGBA{ 1.0, 1.0, 1.0, 1.0 }; + try buf.clear(bg, null); + + // Write a width-2 grapheme (東) at column 2 + const gid = try pool.alloc("東"); + const grapheme_start = gp.packGraphemeStart(gid & gp.GRAPHEME_ID_MASK, 2); + buf.set(2, 0, .{ .char = grapheme_start, .fg = fg, .bg = bg, .attributes = 0 }); + + // Create a 1×1 child framebuffer with a translucent background + var child = try OptimizedBuffer.init( + std.testing.allocator, + 1, + 1, + .{ .pool = pool, .id = "child-buffer" }, + ); + defer child.deinit(); + child.setRespectAlpha(true); + const overlay_bg = RGBA{ 0.0, 0.533, 1.0, 0.094 }; + try child.clear(overlay_bg, null); + + // Composite the 1×1 child at dest x=3 (the continuation cell only) + buf.drawFrameBuffer(3, 0, child, null, null, null, null); + + // Grapheme must survive + const start_after = buf.get(2, 0).?; + const cont_after = buf.get(3, 0).?; + try std.testing.expect(gp.isGraphemeChar(start_after.char)); + try std.testing.expect(gp.isContinuationChar(cont_after.char)); + + // Both cells should be tinted (not just x=3) + try std.testing.expect(start_after.bg[1] > 0.01); + try std.testing.expect(cont_after.bg[1] > 0.01); + + // Both cells should have the SAME blended bg (uniform tint across the span) + try std.testing.expectEqual(start_after.bg[0], cont_after.bg[0]); + try std.testing.expectEqual(start_after.bg[1], cont_after.bg[1]); + try std.testing.expectEqual(start_after.bg[2], cont_after.bg[2]); +} + +test "drawFrameBuffer preserves later per-cell overlay colors within a wide grapheme span" { + const pool = gp.initGlobalPool(std.testing.allocator); + defer gp.deinitGlobalPool(); + + var buf = try OptimizedBuffer.init( + std.testing.allocator, + 10, + 1, + .{ .pool = pool, .id = "test-buffer" }, + ); + defer buf.deinit(); + + var ref = try OptimizedBuffer.init( + std.testing.allocator, + 10, + 1, + .{ .pool = pool, .id = "ref-buffer" }, + ); + defer ref.deinit(); + + const base_bg = RGBA{ 0.0, 0.0, 0.0, 1.0 }; + const fg = RGBA{ 1.0, 1.0, 1.0, 1.0 }; + try buf.clear(base_bg, null); + try ref.clear(base_bg, null); + + const gid = try pool.alloc("東"); + const grapheme_start = gp.packGraphemeStart(gid & gp.GRAPHEME_ID_MASK, 2); + buf.set(2, 0, .{ .char = grapheme_start, .fg = fg, .bg = base_bg, .attributes = 0 }); + ref.set(2, 0, .{ .char = grapheme_start, .fg = fg, .bg = base_bg, .attributes = 0 }); + + var child = try OptimizedBuffer.init( + std.testing.allocator, + 2, + 1, + .{ .pool = pool, .id = "child-buffer" }, + ); + defer child.deinit(); + child.setRespectAlpha(true); + try child.clear(RGBA{ 0.0, 0.0, 0.0, 0.0 }, null); + + const blue_bg = RGBA{ 0.0, 0.0, 1.0, 0.5 }; + const red_bg = RGBA{ 1.0, 0.0, 0.0, 0.5 }; + child.set(0, 0, .{ .char = buffer_mod.DEFAULT_SPACE_CHAR, .fg = fg, .bg = blue_bg, .attributes = 0 }); + child.set(1, 0, .{ .char = buffer_mod.DEFAULT_SPACE_CHAR, .fg = fg, .bg = red_bg, .attributes = 0 }); + + buf.drawFrameBuffer(2, 0, child, null, null, null, null); + + _ = try ref.setCellWithAlphaBlending(2, 0, buffer_mod.DEFAULT_SPACE_CHAR, fg, blue_bg, 0); + _ = try ref.setCellWithAlphaBlending(3, 0, buffer_mod.DEFAULT_SPACE_CHAR, fg, red_bg, 0); + + const actual_start = buf.get(2, 0).?; + const actual_cont = buf.get(3, 0).?; + const expected_start = ref.get(2, 0).?; + const expected_cont = ref.get(3, 0).?; + + try std.testing.expect(gp.isGraphemeChar(actual_start.char)); + try std.testing.expect(gp.isContinuationChar(actual_cont.char)); + try std.testing.expectEqual(expected_start.bg[0], actual_start.bg[0]); + try std.testing.expectEqual(expected_start.bg[1], actual_start.bg[1]); + try std.testing.expectEqual(expected_start.bg[2], actual_start.bg[2]); + try std.testing.expectEqual(expected_start.bg[3], actual_start.bg[3]); + try std.testing.expectEqual(expected_cont.bg[0], actual_cont.bg[0]); + try std.testing.expectEqual(expected_cont.bg[1], actual_cont.bg[1]); + try std.testing.expectEqual(expected_cont.bg[2], actual_cont.bg[2]); + try std.testing.expectEqual(expected_cont.bg[3], actual_cont.bg[3]); +} + +test "fillRect with translucent partial overlap tints the whole grapheme span" { + const pool = gp.initGlobalPool(std.testing.allocator); + defer gp.deinitGlobalPool(); + + var buf = try OptimizedBuffer.init( + std.testing.allocator, + 10, + 1, + .{ .pool = pool, .id = "test-buffer" }, + ); + defer buf.deinit(); + + const bg = RGBA{ 0.0, 0.0, 0.0, 1.0 }; + const fg = RGBA{ 1.0, 1.0, 1.0, 1.0 }; + try buf.clear(bg, null); + + const gid = try pool.alloc("東"); + const grapheme_start = gp.packGraphemeStart(gid & gp.GRAPHEME_ID_MASK, 2); + buf.set(2, 0, .{ .char = grapheme_start, .fg = fg, .bg = bg, .attributes = 0 }); + + const start_before = buf.get(2, 0).?; + const cont_before = buf.get(3, 0).?; + try std.testing.expect(gp.isGraphemeChar(start_before.char)); + try std.testing.expect(gp.isContinuationChar(cont_before.char)); + + const overlay_bg = RGBA{ 0.0, 0.533, 1.0, 0.094 }; + try buf.fillRect(3, 0, 1, 1, overlay_bg); + + const start_after = buf.get(2, 0).?; + const cont_after = buf.get(3, 0).?; + try std.testing.expect(gp.isGraphemeChar(start_after.char)); + try std.testing.expect(gp.isContinuationChar(cont_after.char)); + + try std.testing.expectEqual(start_before.char, start_after.char); + try std.testing.expectEqual(cont_before.char, cont_after.char); + + try std.testing.expect(start_after.bg[1] > 0.01); + try std.testing.expect(cont_after.bg[1] > 0.01); + + try std.testing.expectEqual(start_after.bg[0], cont_after.bg[0]); + try std.testing.expectEqual(start_after.bg[1], cont_after.bg[1]); + try std.testing.expectEqual(start_after.bg[2], cont_after.bg[2]); +} + +test "fillRectClipWideGraphemes clips a left-edge overlap instead of tinting outside the box" { + const pool = gp.initGlobalPool(std.testing.allocator); + defer gp.deinitGlobalPool(); + + var buf = try OptimizedBuffer.init( + std.testing.allocator, + 10, + 1, + .{ .pool = pool, .id = "test-buffer" }, + ); + defer buf.deinit(); + + const bg = RGBA{ 0.0, 0.0, 0.0, 1.0 }; + const fg = RGBA{ 1.0, 1.0, 1.0, 1.0 }; + try buf.clear(bg, null); + + const gid = try pool.alloc("東"); + const grapheme_start = gp.packGraphemeStart(gid & gp.GRAPHEME_ID_MASK, 2); + buf.set(2, 0, .{ .char = grapheme_start, .fg = fg, .bg = bg, .attributes = 0 }); + + const overlay_bg = RGBA{ 0.0, 0.533, 1.0, 0.094 }; + try buf.fillRectClipWideGraphemes(3, 0, 1, 1, overlay_bg); + + const outside = buf.get(2, 0).?; + const inside = buf.get(3, 0).?; + + try std.testing.expectEqual(@as(u32, buffer_mod.DEFAULT_SPACE_CHAR), outside.char); + try std.testing.expectEqual(@as(u32, buffer_mod.DEFAULT_SPACE_CHAR), inside.char); + try std.testing.expectEqual(bg[0], outside.bg[0]); + try std.testing.expectEqual(bg[1], outside.bg[1]); + try std.testing.expectEqual(bg[2], outside.bg[2]); + try std.testing.expect(inside.bg[1] > 0.01); +} + +test "fillRectClipWideGraphemes clips a right-edge overlap instead of tinting outside the box" { + const pool = gp.initGlobalPool(std.testing.allocator); + defer gp.deinitGlobalPool(); + + var buf = try OptimizedBuffer.init( + std.testing.allocator, + 10, + 1, + .{ .pool = pool, .id = "test-buffer" }, + ); + defer buf.deinit(); + + const bg = RGBA{ 0.0, 0.0, 0.0, 1.0 }; + const fg = RGBA{ 1.0, 1.0, 1.0, 1.0 }; + try buf.clear(bg, null); + + const gid = try pool.alloc("東"); + const grapheme_start = gp.packGraphemeStart(gid & gp.GRAPHEME_ID_MASK, 2); + buf.set(2, 0, .{ .char = grapheme_start, .fg = fg, .bg = bg, .attributes = 0 }); + + const overlay_bg = RGBA{ 0.0, 0.533, 1.0, 0.094 }; + try buf.fillRectClipWideGraphemes(2, 0, 1, 1, overlay_bg); + + const inside = buf.get(2, 0).?; + const outside = buf.get(3, 0).?; + + try std.testing.expectEqual(@as(u32, buffer_mod.DEFAULT_SPACE_CHAR), inside.char); + try std.testing.expectEqual(@as(u32, buffer_mod.DEFAULT_SPACE_CHAR), outside.char); + try std.testing.expect(inside.bg[1] > 0.01); + try std.testing.expectEqual(bg[0], outside.bg[0]); + try std.testing.expectEqual(bg[1], outside.bg[1]); + try std.testing.expectEqual(bg[2], outside.bg[2]); +} + +test "fillRectClipWideGraphemes with transparent background is a no-op over wide graphemes" { + const pool = gp.initGlobalPool(std.testing.allocator); + defer gp.deinitGlobalPool(); + + var buf = try OptimizedBuffer.init( + std.testing.allocator, + 10, + 1, + .{ .pool = pool, .id = "test-buffer" }, + ); + defer buf.deinit(); + + const bg = RGBA{ 0.0, 0.0, 0.0, 1.0 }; + const fg = RGBA{ 1.0, 1.0, 1.0, 1.0 }; + try buf.clear(bg, null); + + const gid = try pool.alloc("東"); + const grapheme_start = gp.packGraphemeStart(gid & gp.GRAPHEME_ID_MASK, 2); + buf.set(2, 0, .{ .char = grapheme_start, .fg = fg, .bg = bg, .attributes = 0 }); + + const start_before = buf.get(2, 0).?; + const cont_before = buf.get(3, 0).?; + try std.testing.expect(gp.isGraphemeChar(start_before.char)); + try std.testing.expect(gp.isContinuationChar(cont_before.char)); + + try buf.fillRectClipWideGraphemes(3, 0, 1, 1, RGBA{ 0.0, 0.0, 0.0, 0.0 }); + + const start_after = buf.get(2, 0).?; + const cont_after = buf.get(3, 0).?; + try std.testing.expectEqual(start_before.char, start_after.char); + try std.testing.expectEqual(cont_before.char, cont_after.char); + try std.testing.expectEqual(start_before.bg[0], start_after.bg[0]); + try std.testing.expectEqual(start_before.bg[1], start_after.bg[1]); + try std.testing.expectEqual(start_before.bg[2], start_after.bg[2]); + try std.testing.expectEqual(cont_before.bg[0], cont_after.bg[0]); + try std.testing.expectEqual(cont_before.bg[1], cont_after.bg[1]); + try std.testing.expectEqual(cont_before.bg[2], cont_after.bg[2]); +} + +test "drawText multi-space translucent overlay over wide grapheme does not double-blend" { + const pool = gp.initGlobalPool(std.testing.allocator); + defer gp.deinitGlobalPool(); + + var buf = try OptimizedBuffer.init( + std.testing.allocator, + 10, + 1, + .{ .pool = pool, .id = "test-buffer" }, + ); + defer buf.deinit(); + + const bg = RGBA{ 0.0, 0.0, 0.0, 1.0 }; + const fg = RGBA{ 1.0, 1.0, 1.0, 1.0 }; + try buf.clear(bg, null); + + // Write a width-2 grapheme (東) at column 2 + const gid = try pool.alloc("東"); + const grapheme_start = gp.packGraphemeStart(gid & gp.GRAPHEME_ID_MASK, 2); + buf.set(2, 0, .{ .char = grapheme_start, .fg = fg, .bg = bg, .attributes = 0 }); + + // Build the reference: apply a single setCellWithAlphaBlending at x=2 + // which span-blends both cells exactly once. + var ref = try OptimizedBuffer.init( + std.testing.allocator, + 10, + 1, + .{ .pool = pool, .id = "ref-buffer" }, + ); + defer ref.deinit(); + try ref.clear(bg, null); + ref.set(2, 0, .{ .char = grapheme_start, .fg = fg, .bg = bg, .attributes = 0 }); + const overlay_bg = RGBA{ 0.0, 0.533, 1.0, 0.094 }; + const overlay_fg = RGBA{ 1.0, 1.0, 1.0, 1.0 }; + _ = try ref.setCellWithAlphaBlending(2, 0, ' ', overlay_fg, overlay_bg, 0); + const expected_bg = ref.get(2, 0).?.bg; + + // Now draw TWO translucent spaces covering both cells of the wide grapheme. + // Without the span-skip fix, cell x=2 gets double-blended. + try buf.drawText(" ", 2, 0, overlay_fg, overlay_bg, 0); + + // Grapheme must survive + const start_after = buf.get(2, 0).?; + const cont_after = buf.get(3, 0).?; + try std.testing.expect(gp.isGraphemeChar(start_after.char)); + try std.testing.expect(gp.isContinuationChar(cont_after.char)); + + // Both cells must have the same bg (uniform tint) + try std.testing.expectEqual(start_after.bg[0], cont_after.bg[0]); + try std.testing.expectEqual(start_after.bg[1], cont_after.bg[1]); + try std.testing.expectEqual(start_after.bg[2], cont_after.bg[2]); + + // The tint must match a single-blend reference (no double-blend) + try std.testing.expectEqual(expected_bg[0], start_after.bg[0]); + try std.testing.expectEqual(expected_bg[1], start_after.bg[1]); + try std.testing.expectEqual(expected_bg[2], start_after.bg[2]); +} + +test "drawText translucent spaces over multiple separate wide graphemes" { + const pool = gp.initGlobalPool(std.testing.allocator); + defer gp.deinitGlobalPool(); + + var buf = try OptimizedBuffer.init( + std.testing.allocator, + 10, + 1, + .{ .pool = pool, .id = "test-buffer" }, + ); + defer buf.deinit(); + + const bg = RGBA{ 0.0, 0.0, 0.0, 1.0 }; + const fg = RGBA{ 1.0, 1.0, 1.0, 1.0 }; + try buf.clear(bg, null); + + // Place two separate wide graphemes: 東 at x=1 and 京 at x=5 + const gid1 = try pool.alloc("東"); + const gs1 = gp.packGraphemeStart(gid1 & gp.GRAPHEME_ID_MASK, 2); + buf.set(1, 0, .{ .char = gs1, .fg = fg, .bg = bg, .attributes = 0 }); + + const gid2 = try pool.alloc("京"); + const gs2 = gp.packGraphemeStart(gid2 & gp.GRAPHEME_ID_MASK, 2); + buf.set(5, 0, .{ .char = gs2, .fg = fg, .bg = bg, .attributes = 0 }); + + // Build single-blend reference for each grapheme independently + var ref = try OptimizedBuffer.init( + std.testing.allocator, + 10, + 1, + .{ .pool = pool, .id = "ref-buffer" }, + ); + defer ref.deinit(); + try ref.clear(bg, null); + ref.set(1, 0, .{ .char = gs1, .fg = fg, .bg = bg, .attributes = 0 }); + ref.set(5, 0, .{ .char = gs2, .fg = fg, .bg = bg, .attributes = 0 }); + const overlay_bg = RGBA{ 0.0, 0.533, 1.0, 0.094 }; + const overlay_fg = RGBA{ 1.0, 1.0, 1.0, 1.0 }; + _ = try ref.setCellWithAlphaBlending(1, 0, ' ', overlay_fg, overlay_bg, 0); + _ = try ref.setCellWithAlphaBlending(5, 0, ' ', overlay_fg, overlay_bg, 0); + const expected_bg1 = ref.get(1, 0).?.bg; + const expected_bg2 = ref.get(5, 0).?.bg; + + // Draw translucent spaces across the whole row — hits both graphemes, + // plus the gap at x=3-4 which has normal cells. The span tracker + // must reset between the two graphemes. + try buf.drawText(" ", 0, 0, overlay_fg, overlay_bg, 0); + + // First grapheme (x=1-2): preserved, uniform tint, no double-blend + const g1_start = buf.get(1, 0).?; + const g1_cont = buf.get(2, 0).?; + try std.testing.expect(gp.isGraphemeChar(g1_start.char)); + try std.testing.expect(gp.isContinuationChar(g1_cont.char)); + try std.testing.expectEqual(g1_start.bg[0], g1_cont.bg[0]); + try std.testing.expectEqual(g1_start.bg[1], g1_cont.bg[1]); + try std.testing.expectEqual(g1_start.bg[2], g1_cont.bg[2]); + try std.testing.expectEqual(expected_bg1[0], g1_start.bg[0]); + try std.testing.expectEqual(expected_bg1[1], g1_start.bg[1]); + try std.testing.expectEqual(expected_bg1[2], g1_start.bg[2]); + + // Second grapheme (x=5-6): same checks — tracker must have reset + const g2_start = buf.get(5, 0).?; + const g2_cont = buf.get(6, 0).?; + try std.testing.expect(gp.isGraphemeChar(g2_start.char)); + try std.testing.expect(gp.isContinuationChar(g2_cont.char)); + try std.testing.expectEqual(g2_start.bg[0], g2_cont.bg[0]); + try std.testing.expectEqual(g2_start.bg[1], g2_cont.bg[1]); + try std.testing.expectEqual(g2_start.bg[2], g2_cont.bg[2]); + try std.testing.expectEqual(expected_bg2[0], g2_start.bg[0]); + try std.testing.expectEqual(expected_bg2[1], g2_start.bg[1]); + try std.testing.expectEqual(expected_bg2[2], g2_start.bg[2]); +} + +test "scissor clips span expansion — does not bleed outside clipped region" { + const pool = gp.initGlobalPool(std.testing.allocator); + defer gp.deinitGlobalPool(); + + var buf = try OptimizedBuffer.init( + std.testing.allocator, + 10, + 1, + .{ .pool = pool, .id = "test-buffer" }, + ); + defer buf.deinit(); + + const bg = RGBA{ 0.0, 0.0, 0.0, 1.0 }; + const fg = RGBA{ 1.0, 1.0, 1.0, 1.0 }; + try buf.clear(bg, null); + + // Write 東 at x=2 (occupies x=2 and x=3) + const gid = try pool.alloc("東"); + const grapheme_start = gp.packGraphemeStart(gid & gp.GRAPHEME_ID_MASK, 2); + buf.set(2, 0, .{ .char = grapheme_start, .fg = fg, .bg = bg, .attributes = 0 }); + + const start_bg_before = buf.get(2, 0).?.bg; + + // Set scissor to x=3 only (1 cell wide), so x=2 is outside the scissor + try buf.pushScissorRect(3, 0, 1, 1); + + // fillRect at x=3 — the span expansion should NOT write to x=2 + // because x=2 is outside the scissor. + const overlay_bg = RGBA{ 0.0, 0.533, 1.0, 0.094 }; + try buf.fillRect(3, 0, 1, 1, overlay_bg); + + buf.popScissorRect(); + + const start_after = buf.get(2, 0).?; + const cont_after = buf.get(3, 0).?; + + // Grapheme structure must survive + try std.testing.expect(gp.isGraphemeChar(start_after.char)); + try std.testing.expect(gp.isContinuationChar(cont_after.char)); + + // x=3 (inside scissor) should be tinted + try std.testing.expect(cont_after.bg[1] > 0.01); + + // x=2 (outside scissor) must NOT be tinted — bg should be unchanged + try std.testing.expectEqual(start_bg_before[0], start_after.bg[0]); + try std.testing.expectEqual(start_bg_before[1], start_after.bg[1]); + try std.testing.expectEqual(start_bg_before[2], start_after.bg[2]); +} + +test "drawText non-space character overwrites wide grapheme even after span blend" { + const pool = gp.initGlobalPool(std.testing.allocator); + defer gp.deinitGlobalPool(); + + var buf = try OptimizedBuffer.init( + std.testing.allocator, + 10, + 1, + .{ .pool = pool, .id = "test-buffer" }, + ); + defer buf.deinit(); + + const bg = RGBA{ 0.0, 0.0, 0.0, 1.0 }; + const fg = RGBA{ 1.0, 1.0, 1.0, 1.0 }; + try buf.clear(bg, null); + + // Write 東 at x=2 (occupies x=2 and x=3) + const gid = try pool.alloc("東"); + const grapheme_start = gp.packGraphemeStart(gid & gp.GRAPHEME_ID_MASK, 2); + buf.set(2, 0, .{ .char = grapheme_start, .fg = fg, .bg = bg, .attributes = 0 }); + + // drawText " B" at x=2: space at x=2 should tint the span, + // then 'B' at x=3 should overwrite the continuation cell. + // The span-skip logic must NOT skip non-space characters. + const overlay_bg = RGBA{ 0.0, 0.533, 1.0, 0.094 }; + const overlay_fg = RGBA{ 1.0, 1.0, 1.0, 1.0 }; + try buf.drawText(" B", 2, 0, overlay_fg, overlay_bg, 0); + + const cell3 = buf.get(3, 0).?; + // 'B' is ASCII 66 — should have overwritten the continuation cell + try std.testing.expectEqual(@as(u32, 'B'), cell3.char); +} + +test "drawFrameBuffer mixed space-then-char child overwrites continuation cell" { + const pool = gp.initGlobalPool(std.testing.allocator); + defer gp.deinitGlobalPool(); + + var buf = try OptimizedBuffer.init( + std.testing.allocator, + 10, + 1, + .{ .pool = pool, .id = "test-buffer" }, + ); + defer buf.deinit(); + + const bg = RGBA{ 0.0, 0.0, 0.0, 1.0 }; + const fg = RGBA{ 1.0, 1.0, 1.0, 1.0 }; + try buf.clear(bg, null); + + // Write 東 at x=2 (occupies x=2 and x=3) + const gid = try pool.alloc("東"); + const grapheme_start = gp.packGraphemeStart(gid & gp.GRAPHEME_ID_MASK, 2); + buf.set(2, 0, .{ .char = grapheme_start, .fg = fg, .bg = bg, .attributes = 0 }); + + // Create a 2×1 child framebuffer with respectAlpha containing " B": + // cell 0 = translucent space, cell 1 = 'B' + var child = try OptimizedBuffer.init( + std.testing.allocator, + 2, + 1, + .{ .pool = pool, .id = "child-buffer" }, + ); + defer child.deinit(); + child.setRespectAlpha(true); + const overlay_bg = RGBA{ 0.0, 0.533, 1.0, 0.094 }; + const overlay_fg = RGBA{ 1.0, 1.0, 1.0, 1.0 }; + try child.clear(overlay_bg, null); + // Overwrite cell 1 with 'B' (fully opaque) + child.set(1, 0, .{ .char = 'B', .fg = overlay_fg, .bg = RGBA{ 0.0, 0.0, 0.0, 1.0 }, .attributes = 0 }); + + // Composite at dest x=2: space lands on grapheme start, 'B' on continuation + buf.drawFrameBuffer(2, 0, child, null, null, null, null); + + // The space at x=2 should have triggered span blending, tinting the + // grapheme. But 'B' at x=3 must NOT be skipped by the span guard — + // it should overwrite the continuation cell. + const cell3 = buf.get(3, 0).?; + try std.testing.expectEqual(@as(u32, 'B'), cell3.char); +} + +test "two separate alpha overlays over the same wide grapheme blend twice" { + const pool = gp.initGlobalPool(std.testing.allocator); + defer gp.deinitGlobalPool(); + + var buf = try OptimizedBuffer.init( + std.testing.allocator, + 10, + 1, + .{ .pool = pool, .id = "test-buffer" }, + ); + defer buf.deinit(); + + const bg = RGBA{ 0.0, 0.0, 0.0, 1.0 }; + const fg = RGBA{ 1.0, 1.0, 1.0, 1.0 }; + try buf.clear(bg, null); + + // Write 東 at x=2 (occupies x=2 and x=3) + const gid = try pool.alloc("東"); + const grapheme_start = gp.packGraphemeStart(gid & gp.GRAPHEME_ID_MASK, 2); + buf.set(2, 0, .{ .char = grapheme_start, .fg = fg, .bg = bg, .attributes = 0 }); + + // Build single-blend reference + var ref = try OptimizedBuffer.init( + std.testing.allocator, + 10, + 1, + .{ .pool = pool, .id = "ref-buffer" }, + ); + defer ref.deinit(); + try ref.clear(bg, null); + ref.set(2, 0, .{ .char = grapheme_start, .fg = fg, .bg = bg, .attributes = 0 }); + + const overlay_bg = RGBA{ 0.0, 0.533, 1.0, 0.094 }; + const overlay_fg = RGBA{ 1.0, 1.0, 1.0, 1.0 }; + + // Apply two separate fillRect overlays + try buf.fillRect(2, 0, 2, 1, overlay_bg); + try buf.fillRect(2, 0, 2, 1, overlay_bg); + + // Apply two overlays to reference using setCellWithAlphaBlending directly + _ = try ref.setCellWithAlphaBlending(2, 0, ' ', overlay_fg, overlay_bg, 0); + _ = try ref.setCellWithAlphaBlending(2, 0, ' ', overlay_fg, overlay_bg, 0); + + const expected_bg = ref.get(2, 0).?.bg; + const actual_start = buf.get(2, 0).?; + const actual_cont = buf.get(3, 0).?; + + // Grapheme must survive + try std.testing.expect(gp.isGraphemeChar(actual_start.char)); + try std.testing.expect(gp.isContinuationChar(actual_cont.char)); + + // Both cells should match double-blended reference + try std.testing.expectEqual(expected_bg[0], actual_start.bg[0]); + try std.testing.expectEqual(expected_bg[1], actual_start.bg[1]); + try std.testing.expectEqual(expected_bg[2], actual_start.bg[2]); + try std.testing.expectEqual(expected_bg[0], actual_cont.bg[0]); + try std.testing.expectEqual(expected_bg[1], actual_cont.bg[1]); + try std.testing.expectEqual(expected_bg[2], actual_cont.bg[2]); + + // Double-blend must differ from single-blend + const single_ref = try OptimizedBuffer.init( + std.testing.allocator, + 10, + 1, + .{ .pool = pool, .id = "single-ref" }, + ); + defer single_ref.deinit(); + try single_ref.clear(bg, null); + single_ref.set(2, 0, .{ .char = grapheme_start, .fg = fg, .bg = bg, .attributes = 0 }); + _ = try single_ref.setCellWithAlphaBlending(2, 0, ' ', overlay_fg, overlay_bg, 0); + const single_bg = single_ref.get(2, 0).?.bg; + try std.testing.expect(actual_start.bg[2] > single_bg[2]); +} + +test "scissored partial-span tint followed by unclipped tint applies to both cells" { + const pool = gp.initGlobalPool(std.testing.allocator); + defer gp.deinitGlobalPool(); + + var buf = try OptimizedBuffer.init( + std.testing.allocator, + 10, + 1, + .{ .pool = pool, .id = "test-buffer" }, + ); + defer buf.deinit(); + + const bg = RGBA{ 0.0, 0.0, 0.0, 1.0 }; + const fg = RGBA{ 1.0, 1.0, 1.0, 1.0 }; + try buf.clear(bg, null); + + // Write 東 at x=2 (occupies x=2 and x=3) + const gid = try pool.alloc("東"); + const grapheme_start = gp.packGraphemeStart(gid & gp.GRAPHEME_ID_MASK, 2); + buf.set(2, 0, .{ .char = grapheme_start, .fg = fg, .bg = bg, .attributes = 0 }); + + const overlay_bg = RGBA{ 0.0, 0.533, 1.0, 0.094 }; + + // First: scissored fillRect that only covers x=3 (continuation cell) + try buf.pushScissorRect(3, 0, 1, 1); + try buf.fillRect(3, 0, 1, 1, overlay_bg); + buf.popScissorRect(); + + // x=2 should be untouched (outside scissor), x=3 tinted + const bg_after_first_x2 = buf.get(2, 0).?.bg; + try std.testing.expectEqual(bg[1], bg_after_first_x2[1]); + try std.testing.expect(buf.get(3, 0).?.bg[1] > 0.01); + + // Second: unclipped fillRect covering x=2 — must NOT be suppressed + try buf.fillRect(2, 0, 1, 1, overlay_bg); + + const final_x2 = buf.get(2, 0).?; + const final_x3 = buf.get(3, 0).?; + + // Grapheme must survive + try std.testing.expect(gp.isGraphemeChar(final_x2.char)); + try std.testing.expect(gp.isContinuationChar(final_x3.char)); + + // x=2 must now be tinted (the second fillRect must have applied) + try std.testing.expect(final_x2.bg[1] > 0.01); +} + +test "alpha overlay preserves wide text but replaces emoji with placeholder" { + const pool = gp.initGlobalPool(std.testing.allocator); + defer gp.deinitGlobalPool(); + + var buf = try OptimizedBuffer.init( + std.testing.allocator, + 10, + 1, + .{ .pool = pool, .id = "test-preserve-text-placeholder-emoji" }, + ); + defer buf.deinit(); + + const bg = RGBA{ 0.0, 0.0, 0.0, 1.0 }; + const fg = RGBA{ 1.0, 1.0, 1.0, 1.0 }; + try buf.clear(bg, null); + + const cjk_gid = try pool.alloc("東"); + buf.set(0, 0, .{ .char = gp.packGraphemeStart(cjk_gid & gp.GRAPHEME_ID_MASK, 2), .fg = fg, .bg = bg, .attributes = 0 }); + + const emoji_gid = try pool.alloc("👋"); + buf.set(4, 0, .{ .char = gp.packGraphemeStart(emoji_gid & gp.GRAPHEME_ID_MASK, 2), .fg = fg, .bg = bg, .attributes = 0 }); + + try buf.fillRect(0, 0, 10, 1, RGBA{ 0.0, 0.0, 1.0, 0.5 }); + + try std.testing.expect(gp.isGraphemeChar(buf.get(0, 0).?.char)); + try std.testing.expectEqual(@as(u32, '['), buf.get(4, 0).?.char); + try std.testing.expectEqual(@as(u32, ']'), buf.get(5, 0).?.char); +} + +test "placeholder rendering produces bracket characters and blanks extra cells" { + const pool = gp.initGlobalPool(std.testing.allocator); + defer gp.deinitGlobalPool(); + + var buf = try OptimizedBuffer.init( + std.testing.allocator, + 12, + 1, + .{ .pool = pool, .id = "test-placeholder-rendering" }, + ); + defer buf.deinit(); + + const bg = RGBA{ 0.0, 0.0, 0.0, 1.0 }; + const fg = RGBA{ 1.0, 1.0, 1.0, 1.0 }; + try buf.clear(bg, null); + + const emoji_gid = try pool.alloc("👋"); + buf.set(2, 0, .{ .char = gp.packGraphemeStart(emoji_gid & gp.GRAPHEME_ID_MASK, 4), .fg = fg, .bg = bg, .attributes = 0 }); + + try buf.fillRect(2, 0, 4, 1, RGBA{ 1.0, 0.0, 0.0, 0.5 }); + + try std.testing.expectEqual(@as(u32, '['), buf.get(2, 0).?.char); + try std.testing.expectEqual(@as(u32, ']'), buf.get(3, 0).?.char); + try std.testing.expectEqual(@as(u32, buffer_mod.DEFAULT_SPACE_CHAR), buf.get(4, 0).?.char); + try std.testing.expectEqual(@as(u32, buffer_mod.DEFAULT_SPACE_CHAR), buf.get(5, 0).?.char); +} + +test "classifyWideChar distinguishes wide text, emoji, and wide non-emoji text" { + const pool = gp.initGlobalPool(std.testing.allocator); + defer gp.deinitGlobalPool(); + + var buf = try OptimizedBuffer.init( + std.testing.allocator, + 12, + 1, + .{ .pool = pool, .id = "test-classify-wide-char" }, + ); + defer buf.deinit(); + + try std.testing.expectEqual(OptimizedBuffer.WideCharKind.wide_text, buf.classifyWideChar(0x6771)); + + const cjk_gid = try pool.alloc("東"); + try std.testing.expectEqual( + OptimizedBuffer.WideCharKind.wide_text, + buf.classifyWideChar(gp.packGraphemeStart(cjk_gid & gp.GRAPHEME_ID_MASK, 2)), + ); + + const emoji_gid = try pool.alloc("👋"); + try std.testing.expectEqual( + OptimizedBuffer.WideCharKind.emoji, + buf.classifyWideChar(gp.packGraphemeStart(emoji_gid & gp.GRAPHEME_ID_MASK, 2)), + ); + + const dark_bg = RGBA{ 0.0, 0.0, 0.0, 1.0 }; + const fg = RGBA{ 1.0, 1.0, 1.0, 1.0 }; + try buf.clear(dark_bg, null); + try buf.drawText("नमस्ते", 0, 0, fg, dark_bg, 0); + try std.testing.expectEqual(OptimizedBuffer.WideCharKind.wide_text, buf.classifyWideChar(buf.get(2, 0).?.char)); +} + +test "setCellWithAlphaBlending on emoji produces placeholder" { + const pool = gp.initGlobalPool(std.testing.allocator); + defer gp.deinitGlobalPool(); + + var buf = try OptimizedBuffer.init( + std.testing.allocator, + 10, + 1, + .{ .pool = pool, .id = "test-emoji-placeholder" }, + ); + defer buf.deinit(); + + const bg = RGBA{ 0.0, 0.0, 0.0, 1.0 }; + const fg = RGBA{ 1.0, 1.0, 1.0, 1.0 }; + try buf.clear(bg, null); + + const emoji_gid = try pool.alloc("👋"); + buf.set(2, 0, .{ .char = gp.packGraphemeStart(emoji_gid & gp.GRAPHEME_ID_MASK, 2), .fg = fg, .bg = bg, .attributes = 0 }); + + _ = try buf.setCellWithAlphaBlending(2, 0, buffer_mod.DEFAULT_SPACE_CHAR, .{ 1.0, 1.0, 1.0, 1.0 }, RGBA{ 1.0, 0.0, 0.0, 0.5 }, 0); + try std.testing.expectEqual(@as(u32, '['), buf.get(2, 0).?.char); + try std.testing.expectEqual(@as(u32, ']'), buf.get(3, 0).?.char); +} + +test "transparent space alpha overlay leaves emoji unchanged" { + const pool = gp.initGlobalPool(std.testing.allocator); + defer gp.deinitGlobalPool(); + + var buf = try OptimizedBuffer.init( + std.testing.allocator, + 10, + 1, + .{ .pool = pool, .id = "test-transparent-emoji-noop" }, + ); + defer buf.deinit(); + + const bg = RGBA{ 0.0, 0.0, 0.0, 1.0 }; + const fg = RGBA{ 1.0, 1.0, 1.0, 1.0 }; + try buf.clear(bg, null); + + const emoji_gid = try pool.alloc("👋"); + const emoji_start = gp.packGraphemeStart(emoji_gid & gp.GRAPHEME_ID_MASK, 2); + buf.set(2, 0, .{ .char = emoji_start, .fg = fg, .bg = bg, .attributes = 0 }); + + const start_before = buf.get(2, 0).?; + const cont_before = buf.get(3, 0).?; + + _ = try buf.setCellWithAlphaBlending(2, 0, buffer_mod.DEFAULT_SPACE_CHAR, fg, RGBA{ 0.0, 0.0, 0.0, 0.0 }, 0); + + const start_after = buf.get(2, 0).?; + const cont_after = buf.get(3, 0).?; + try std.testing.expectEqual(start_before.char, start_after.char); + try std.testing.expectEqual(cont_before.char, cont_after.char); + try std.testing.expectEqualStrings("👋", try pool.get(gp.graphemeIdFromChar(start_after.char))); +} + +test "transparent space alpha overlay preserves visible foreground on blank cells" { + const pool = gp.initGlobalPool(std.testing.allocator); + defer gp.deinitGlobalPool(); + + var buf = try OptimizedBuffer.init( + std.testing.allocator, + 4, + 1, + .{ .pool = pool, .id = "test-transparent-space-visible-fg" }, + ); + defer buf.deinit(); + + const bg = RGBA{ 0.0, 0.0, 0.0, 1.0 }; + const fg = RGBA{ 0.5, 0.5, 0.5, 1.0 }; + try buf.clear(bg, null); + + _ = try buf.setCellWithAlphaBlending(1, 0, buffer_mod.DEFAULT_SPACE_CHAR, fg, RGBA{ 0.0, 0.0, 0.0, 0.0 }, 0); + + const cell = buf.get(1, 0).?; + const epsilon: f32 = 0.01; + try std.testing.expectEqual(buffer_mod.DEFAULT_SPACE_CHAR, cell.char); + try std.testing.expect(@abs(cell.fg[0] - fg[0]) < epsilon); + try std.testing.expect(@abs(cell.fg[1] - fg[1]) < epsilon); + try std.testing.expect(@abs(cell.fg[2] - fg[2]) < epsilon); + try std.testing.expect(@abs(cell.bg[3] - bg[3]) < epsilon); +} + +test "fillRect with transparent background leaves blank-cell styles unchanged" { + const pool = gp.initGlobalPool(std.testing.allocator); + defer gp.deinitGlobalPool(); + + var buf = try OptimizedBuffer.init( + std.testing.allocator, + 4, + 1, + .{ .pool = pool, .id = "test-transparent-fill-noop" }, + ); + defer buf.deinit(); + + const bg = RGBA{ 0.0, 0.0, 0.0, 1.0 }; + const fg = RGBA{ 0.0, 0.0, 0.0, 1.0 }; + const attributes: u32 = 1; + try buf.clear(bg, null); + buf.set(1, 0, .{ .char = buffer_mod.DEFAULT_SPACE_CHAR, .fg = fg, .bg = bg, .attributes = attributes }); + + try buf.fillRect(1, 0, 1, 1, RGBA{ 0.0, 0.0, 0.0, 0.0 }); + + const cell = buf.get(1, 0).?; + try std.testing.expectEqual(buffer_mod.DEFAULT_SPACE_CHAR, cell.char); + try std.testing.expectEqual(fg[0], cell.fg[0]); + try std.testing.expectEqual(fg[1], cell.fg[1]); + try std.testing.expectEqual(fg[2], cell.fg[2]); + try std.testing.expectEqual(fg[3], cell.fg[3]); + try std.testing.expectEqual(bg[0], cell.bg[0]); + try std.testing.expectEqual(bg[1], cell.bg[1]); + try std.testing.expectEqual(bg[2], cell.bg[2]); + try std.testing.expectEqual(bg[3], cell.bg[3]); + try std.testing.expectEqual(attributes, cell.attributes); +} + +test "fillRect preserves wide non-emoji text grapheme" { + const pool = gp.initGlobalPool(std.testing.allocator); + defer gp.deinitGlobalPool(); + + var buf = try OptimizedBuffer.init( + std.testing.allocator, + 12, + 1, + .{ .pool = pool, .id = "test-wide-non-emoji-text" }, + ); + defer buf.deinit(); + + const bg = RGBA{ 0.0, 0.0, 0.0, 1.0 }; + const fg = RGBA{ 1.0, 1.0, 1.0, 1.0 }; + try buf.clear(bg, null); + try buf.drawText("नमस्ते", 0, 0, fg, bg, 0); + + try buf.fillRect(0, 0, 8, 1, RGBA{ 1.0, 0.0, 0.0, 0.5 }); + + try std.testing.expect(gp.isGraphemeChar(buf.get(2, 0).?.char)); + try std.testing.expect(gp.isContinuationChar(buf.get(3, 0).?.char)); +} + +test "emoji graphemes survive placeholder replacement across frames" { + const pool = gp.initGlobalPool(std.testing.allocator); + defer gp.deinitGlobalPool(); + + var fb = try OptimizedBuffer.init( + std.testing.allocator, + 10, + 1, + .{ .pool = pool, .id = "framebuffer" }, + ); + defer fb.deinit(); + + const dark_bg = RGBA{ 0.04, 0.05, 0.08, 1.0 }; + const fg = RGBA{ 1.0, 1.0, 1.0, 1.0 }; + try fb.clear(dark_bg, null); + + const gid = try pool.alloc("👋"); + fb.set(2, 0, .{ .char = gp.packGraphemeStart(gid & gp.GRAPHEME_ID_MASK, 2), .fg = fg, .bg = dark_bg, .attributes = 0 }); + + var buf = try OptimizedBuffer.init( + std.testing.allocator, + 10, + 1, + .{ .pool = pool, .id = "main-buffer" }, + ); + defer buf.deinit(); + + try buf.clear(dark_bg, null); + buf.drawFrameBuffer(0, 0, fb, null, null, null, null); + try buf.fillRect(0, 0, 10, 1, RGBA{ 0.0, 0.0, 1.0, 0.5 }); + try std.testing.expectEqual(@as(u32, '['), buf.get(2, 0).?.char); + + try buf.clear(dark_bg, null); + buf.drawFrameBuffer(0, 0, fb, null, null, null, null); + + const start = buf.get(2, 0).?; + const cont = buf.get(3, 0).?; + try std.testing.expect(gp.isGraphemeChar(start.char)); + try std.testing.expect(gp.isContinuationChar(cont.char)); + try std.testing.expectEqualStrings("👋", try pool.get(gp.graphemeIdFromChar(start.char))); +} diff --git a/packages/core/src/zig/tests/renderer_test.zig b/packages/core/src/zig/tests/renderer_test.zig index cf0f7b3ff..9d7c655d7 100644 --- a/packages/core/src/zig/tests/renderer_test.zig +++ b/packages/core/src/zig/tests/renderer_test.zig @@ -905,6 +905,40 @@ test "renderer - explicit_cursor_positioning emits cursor move after wide graphe try std.testing.expect(std.mem.indexOf(u8, output, "\x1b[1;3H") != null); } +test "renderer - fallback path does not emit post-grapheme cursor moves" { + const pool = gp.initGlobalPool(std.testing.allocator); + defer gp.deinitGlobalPool(); + + var cli_renderer = try CliRenderer.create( + std.testing.allocator, + 20, + 2, + pool, + true, + ); + defer cli_renderer.destroy(); + + cli_renderer.terminal.caps.explicit_cursor_positioning = false; + cli_renderer.terminal.caps.explicit_width = false; + + const dark_bg = RGBA{ 0.04, 0.05, 0.08, 1.0 }; + const tinted_bg = RGBA{ 0.0, 0.0, 1.0, 0.5 }; + const fg = RGBA{ 1.0, 1.0, 1.0, 1.0 }; + + const first_buffer = cli_renderer.getNextBuffer(); + try first_buffer.clear(dark_bg, null); + try first_buffer.drawText("👋 ", 0, 0, fg, dark_bg, 0); + cli_renderer.render(false); + + const second_buffer = cli_renderer.getNextBuffer(); + try second_buffer.clear(dark_bg, null); + try second_buffer.drawText("👋 ", 0, 0, fg, tinted_bg, 0); + cli_renderer.render(false); + + const output = cli_renderer.getLastOutputForTest(); + try std.testing.expect(std.mem.indexOf(u8, output, "👋\x1b[1;3H") == null); +} + test "renderer - explicit_cursor_positioning produces more cursor moves" { const pool = gp.initGlobalPool(std.testing.allocator); defer gp.deinitGlobalPool(); @@ -977,6 +1011,7 @@ test "renderer - explicit_cursor_positioning produces more cursor moves" { } } + try std.testing.expect(count_without > 0); try std.testing.expect(count_with > count_without); } @@ -1015,3 +1050,191 @@ test "renderer - explicit_cursor_positioning with CJK characters" { try std.testing.expect(std.mem.indexOf(u8, output, "\x1b[1;3H") != null); } + +test "renderer - repainting CJK text under translucent overlay keeps wide text in fallback output" { + const pool = gp.initGlobalPool(std.testing.allocator); + defer gp.deinitGlobalPool(); + var local_link_pool = link.LinkPool.init(std.testing.allocator); + defer local_link_pool.deinit(); + + var tb = try TextBuffer.init(std.testing.allocator, pool, &local_link_pool, .unicode); + defer tb.deinit(); + + try tb.setText( + \\const GRAPHEME_LINES: string[] = [ + \\ "W2 CJK: 東京都 北京市 서울시 大阪府", + \\ "Mixed: こんにちは世界 你好世界 안녕하세요", + \\] + ); + + var view = try TextBufferView.init(std.testing.allocator, tb); + defer view.deinit(); + + var cli_renderer = try CliRenderer.create( + std.testing.allocator, + 90, + 6, + pool, + true, + ); + defer cli_renderer.destroy(); + + cli_renderer.terminal.caps.explicit_cursor_positioning = false; + cli_renderer.terminal.caps.explicit_width = false; + + const dark_bg = RGBA{ 0.0, 0.0, 0.0, 1.0 }; + const tint_bg = RGBA{ 1.0, 0.0, 0.0, 0.15 }; + + const first_buffer = cli_renderer.getNextBuffer(); + try first_buffer.clear(dark_bg, null); + try first_buffer.drawTextBuffer(view, 0, 0); + cli_renderer.render(false); + + const second_buffer = cli_renderer.getNextBuffer(); + try second_buffer.clear(dark_bg, null); + try second_buffer.drawTextBuffer(view, 0, 0); + try second_buffer.fillRect(0, 0, 90, 6, tint_bg); + cli_renderer.render(false); + + const output = cli_renderer.getLastOutputForTest(); + try std.testing.expect(std.mem.indexOf(u8, output, "東京都") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "北京市") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "서울시") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "こんにちは世界") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "你好世界") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "안녕하세요") != null); +} + +test "renderer - explicit_cursor_positioning does not emit continuation spaces after wide graphemes" { + const pool = gp.initGlobalPool(std.testing.allocator); + defer gp.deinitGlobalPool(); + + var cli_renderer = try CliRenderer.create( + std.testing.allocator, + 80, + 24, + pool, + true, + ); + defer cli_renderer.destroy(); + + cli_renderer.terminal.caps.explicit_cursor_positioning = true; + cli_renderer.terminal.caps.explicit_width = false; + + const next_buffer = cli_renderer.getNextBuffer(); + const fg = RGBA{ 1.0, 1.0, 1.0, 1.0 }; + const bg = RGBA{ 0.0, 0.0, 0.0, 1.0 }; + try next_buffer.drawText("世X", 0, 0, fg, bg, 0); + + cli_renderer.render(false); + + const output = cli_renderer.getLastOutputForTest(); + + try std.testing.expect(std.mem.indexOf(u8, output, "世") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "X") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "\x1b[1;3H X") == null); +} + +test "renderer - repainting wide emoji after placeholder preclears span" { + const pool = gp.initGlobalPool(std.testing.allocator); + defer gp.deinitGlobalPool(); + + var cli_renderer = try CliRenderer.create( + std.testing.allocator, + 20, + 2, + pool, + true, + ); + defer cli_renderer.destroy(); + + cli_renderer.terminal.caps.explicit_cursor_positioning = true; + cli_renderer.terminal.caps.explicit_width = false; + + const dark_bg = RGBA{ 0.04, 0.05, 0.08, 1.0 }; + const tinted_bg = RGBA{ 0.0, 0.0, 1.0, 0.5 }; + const fg = RGBA{ 1.0, 1.0, 1.0, 1.0 }; + + const first_buffer = cli_renderer.getNextBuffer(); + try first_buffer.clear(dark_bg, null); + try first_buffer.drawText("[]", 2, 0, fg, tinted_bg, 0); + cli_renderer.render(false); + + const second_buffer = cli_renderer.getNextBuffer(); + try second_buffer.clear(dark_bg, null); + try second_buffer.drawText("👋", 2, 0, fg, dark_bg, 0); + cli_renderer.render(false); + + const output = cli_renderer.getLastOutputForTest(); + try std.testing.expect(std.mem.indexOf(u8, output, " \x1b[1;3H👋") != null); +} + +test "renderer - explicit_width repainting wide emoji preclears span" { + const pool = gp.initGlobalPool(std.testing.allocator); + defer gp.deinitGlobalPool(); + + var cli_renderer = try CliRenderer.create( + std.testing.allocator, + 20, + 2, + pool, + true, + ); + defer cli_renderer.destroy(); + + cli_renderer.terminal.caps.explicit_cursor_positioning = false; + cli_renderer.terminal.caps.explicit_width = true; + + const dark_bg = RGBA{ 0.04, 0.05, 0.08, 1.0 }; + const tinted_bg = RGBA{ 0.0, 0.0, 1.0, 0.5 }; + const fg = RGBA{ 1.0, 1.0, 1.0, 1.0 }; + + const first_buffer = cli_renderer.getNextBuffer(); + try first_buffer.clear(dark_bg, null); + try first_buffer.drawText("[]", 2, 0, fg, tinted_bg, 0); + cli_renderer.render(false); + + const second_buffer = cli_renderer.getNextBuffer(); + try second_buffer.clear(dark_bg, null); + try second_buffer.drawText("👋", 2, 0, fg, dark_bg, 0); + cli_renderer.render(false); + + const output = cli_renderer.getLastOutputForTest(); + try std.testing.expect(std.mem.indexOf(u8, output, " \x1b[1;3H\x1b]66;w=2;") != null); +} + +test "renderer - fallback repainting wide emoji preclears span before following cell redraw" { + const pool = gp.initGlobalPool(std.testing.allocator); + defer gp.deinitGlobalPool(); + + var cli_renderer = try CliRenderer.create( + std.testing.allocator, + 20, + 2, + pool, + true, + ); + defer cli_renderer.destroy(); + + cli_renderer.terminal.caps.explicit_cursor_positioning = false; + cli_renderer.terminal.caps.explicit_width = false; + + const dark_bg = RGBA{ 0.04, 0.05, 0.08, 1.0 }; + const tinted_bg = RGBA{ 0.0, 0.0, 1.0, 0.5 }; + const fg = RGBA{ 1.0, 1.0, 1.0, 1.0 }; + + const first_buffer = cli_renderer.getNextBuffer(); + try first_buffer.clear(dark_bg, null); + try first_buffer.drawText("[]A", 2, 0, fg, tinted_bg, 0); + cli_renderer.render(false); + + const second_buffer = cli_renderer.getNextBuffer(); + try second_buffer.clear(dark_bg, null); + try second_buffer.drawText("👋B", 2, 0, fg, dark_bg, 0); + cli_renderer.render(false); + + const output = cli_renderer.getLastOutputForTest(); + try std.testing.expect(std.mem.indexOf(u8, output, " \x1b[1;3H👋") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "👋B") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "\x1b[1;5H") == null); +}