diff --git a/packages/runtime-tests/integration/layout/grapheme-clip.test.tsx b/packages/runtime-tests/integration/layout/grapheme-clip.test.tsx index 38d1754..c274339 100644 --- a/packages/runtime-tests/integration/layout/grapheme-clip.test.tsx +++ b/packages/runtime-tests/integration/layout/grapheme-clip.test.tsx @@ -1,7 +1,7 @@ import { defineComponent } from "vue"; import { describe, expect, test } from "vite-plus/test"; import { render } from "@vue-tui/testing"; -import { Box, Text } from "@vue-tui/runtime"; +import { Box, Text, Transform } from "@vue-tui/runtime"; import stripAnsi from "strip-ansi"; import stringWidth from "string-width"; @@ -67,4 +67,86 @@ describe("grapheme-aware clipping (issue #21)", () => { expect(lines[0]).toBe("coabc"); expect(lines[1]).toBe("ctπŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦"); }); + + // A write op that starts EXACTLY at the right clip edge (x === clip.x2) must NOT + // be short-circuited as a whole β€” it has to take the per-line clip path so its + // transformers still run on the (now empty) clipped slice. A transformer that + // produces output from EMPTY input (e.g. `() => 'δΈ­'`) emits its output AT the + // clip edge. Matches Ink output.ts:188 (strict `x > clip.x2`), then the + // `lines.entries()` loop running `transformer('')`. Captured against the built + // Ink reference (/tmp/ink @40b3a75, columns=100, renderToString): + // transform `() => 'δΈ­'` at x=4 of a width=4 overflow=hidden Box β†’ " δΈ­". + test("transformer that emits from empty input still runs at the right clip edge (Ink parity)", async () => { + const { lastFrame } = await render( + defineComponent(() => () => ( + + + "δΈ­"}> + x + + + + )), + { columns: 100 }, + ); + // Ink reference: " δΈ­" β€” 4 leading spaces then δΈ­ painted at the clip edge. + expect(stripAnsi(lastFrame()!)).toBe(" δΈ­"); + }); + + // A transformer that APPENDS to its (empty, clipped) input also runs at the edge: + // Ink calls it with "" β†’ "" + "X" = "X", painted at the clip edge. + test("transformer that appends to empty input runs at the right clip edge (Ink parity)", async () => { + const { lastFrame } = await render( + defineComponent(() => () => ( + + + `${s}X`}> + q + + + + )), + { columns: 100 }, + ); + // Ink reference: " X" β€” the clipped slice is "", the transform appends "X". + expect(stripAnsi(lastFrame()!)).toBe(" X"); + }); + + // Control: an IDENTITY transform at the same clip edge must emit NOTHING β€” the + // clipped slice is empty, identity returns "", characters.length === 0 β†’ skip. + // Proves the fix only resurrects ops whose transformer produces output from + // empty input; ops with no net output at the edge stay clipped away. + test("identity transform at the right clip edge emits nothing (control)", async () => { + const { lastFrame } = await render( + defineComponent(() => () => ( + + + s}> + x + + + + )), + { columns: 100 }, + ); + // Ink reference: "" β€” clipped to empty, identity transform emits nothing. + expect(stripAnsi(lastFrame()!)).toBe(""); + }); + + // Control: plain text (no transformer) at the right clip edge emits nothing β€” + // clipped to empty β†’ characters.length === 0 β†’ skip. Ink reference: "". + test("plain text at the right clip edge emits nothing (control)", async () => { + const { lastFrame } = await render( + defineComponent(() => () => ( + + + x + + + )), + { columns: 100 }, + ); + // Ink reference: "" β€” clipped to empty. + expect(stripAnsi(lastFrame()!)).toBe(""); + }); }); diff --git a/packages/runtime/src/paint/paint.ts b/packages/runtime/src/paint/paint.ts index 3b0471c..d04242b 100644 --- a/packages/runtime/src/paint/paint.ts +++ b/packages/runtime/src/paint/paint.ts @@ -168,8 +168,15 @@ class Output { ? { x1: clip.x1, x2: clip.x2 } : null; - // Safe early skip: entire write starts at or past right clip edge - if (clipH && x >= clipH.x2) continue; + // Safe early skip: entire write starts strictly PAST the right clip edge. + // Must be strict `>` (not `>=`), matching Ink output.ts:188 (`x > clip.x2`): + // an op that starts AT x === clip.x2 still has to take the per-line clip path + // so its transformers run on the (empty) clipped slice. A transformer that + // produces output from empty input (e.g. `() => 'δΈ­'`) emits AT the clip edge; + // a whole-op `>=` skip would wrongly drop it. The inner per-line clip below + // already uses strict `>`, so for normal/identity ops x === clip.x2 still + // clips to empty β†’ characters.length === 0 β†’ skip (no net output). + if (clipH && x > clipH.x2) continue; let offsetY = 0; @@ -205,7 +212,7 @@ class Output { // whole and the kept content begins at this origin with NO leading // offset. (We deliberately do NOT advance the origin by the dropped // glyph's extra column β€” that produced a vue-tui-specific leading space - // that Ink never emits; see G63 decisions-log in parity-ledger.md.) + // that Ink never emits.) if (lineX < clipH.x1) lineX = clipH.x1; const maxWidth = clipH.x2 - lineX; line = safeSliceEnd(sliceAnsi(line, from, to), maxWidth);