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