diff --git a/.agents/docs/ink-divergences.md b/.agents/docs/ink-divergences.md index 321b8d2..c529f62 100644 --- a/.agents/docs/ink-divergences.md +++ b/.agents/docs/ink-divergences.md @@ -455,6 +455,33 @@ different runtime behavior, ownership rule, or out-of-contract handling. (`throw {message:'x'}` once displayed `x` but rejected `[object Object]`). Introduced 2026-05-31; consistency fixed 2026-06-12. KEEP. [VOUCHED @hyf0] +### Re-measure text when the `wrap` prop changes at runtime + +- **Ink:** a runtime `wrap` (style `textWrap`) change goes through `commitUpdate` → + `applyStyles`, but `applyStyles` **ignores `textWrap` entirely** (styles.ts) and never + calls `yogaNode.markDirty()`. Only `setTextNodeValue` (a text-CONTENT change) dirties the + measure func. So when ONLY `wrap` toggles, yoga keeps the previously-measured height while + paint renders with the new wrap mode → layout and paint disagree. Run-verified vs v7.0.4 + (`/tmp/ink-verify`, debug-mode frame capture): a width-6 column `` with + `` over `"aaaa bbbb cccc"` and a `ZZZZ` sentinel below, toggled wrap→truncate, + yields `"aaaa …\n\n\nZZZZ"` — the truncated text paints on row 1 but yoga still reserves 3 + rows, stranding `ZZZZ` on row 4 with blank rows. Toggling text content alongside `wrap` + (which DOES `markDirty`) gives the correct `"aaaa …\nZZZZ"`, proving the cause. +- **vue-tui:** the host `patchProp` (`node-ops.ts`) calls `markTextDirty(el)` when the changed + STYLE_PROP is `wrap` on a `tui-text` node, so yoga re-measures and layout matches paint: + wrap→truncate collapses to `"aaaa …\nZZZZ"`, truncate→wrap grows to + `"aaaa\nbbbb\ncccc\nZZZZ"`. `wrap` is the only STYLE_PROP that affects measured height (the + measure func reads `text.props.wrap`); the rest (color/bold/border colors/…) are paint-only, + so this is the sole case. +- **Why:** aligning to Ink reduces bugs only where Ink is correct. Here Ink is itself + buggy — a stale cached measure that contradicts paint — so vue-tui deliberately diverges to + the obviously-correct behavior: render = f(current props), layout and paint agree. The fix + is minimal (one `markDirty`) and matches the layout Ink ALREADY produces whenever its measure + func happens to be invalidated. KEEP — proposed divergence, PENDING @hyf0 vouch (Ink is buggy + here; awaiting human bless before this is settled). Tests: + `text-wrap-remeasure.test.tsx` (both directions; RED without the fix, reproducing Ink's + stale frame). + ### Second `mount()` on a live stdout is an inert no-op - **Ink:** `render()` keeps one instance per stdout (`WeakMap`); a second diff --git a/packages/runtime-tests/integration/layout/text-wrap-remeasure.test.tsx b/packages/runtime-tests/integration/layout/text-wrap-remeasure.test.tsx new file mode 100644 index 0000000..125f767 --- /dev/null +++ b/packages/runtime-tests/integration/layout/text-wrap-remeasure.test.tsx @@ -0,0 +1,71 @@ +import { defineComponent, nextTick, shallowRef } from "vue"; +import { expect, test } from "vite-plus/test"; +import { render } from "@vue-tui/testing"; +import { Box, Text } from "@vue-tui/runtime"; + +// Changing a 's `wrap` prop at runtime changes how tall the text MEASURES +// (truncate = 1 row; wrap = 3 rows for this content/width), but `wrap` is not a +// yoga prop — it only feeds the text measure func. The measure result is cached +// by yoga, so a wrap-only change must re-mark the text dirty or yoga keeps the +// stale height while paint uses the new wrap mode → layout and paint disagree: +// stale blank rows (wrap→truncate) or stranded siblings. +// +// We anchor each frame against Ink v7.0.4 rendered standalone in *each* mode +// (the layout Ink produces when its measure func is correctly invalidated): +// wrap -> "aaaa\nbbbb\ncccc\nZZZZ" +// truncate -> "aaaa …\nZZZZ" +// (Ink itself has this latent bug on a wrap-ONLY change — see ink-divergences.md +// "Re-measure text when the `wrap` prop changes at runtime".) + +// Box width 6, column layout. "aaaa bbbb cccc" is 14 cols. +// - wrap: wraps to 3 rows ("aaaa" / "bbbb" / "cccc"), sentinel on row 4 +// - truncate: 1 row ("aaaa …"), sentinel on row 2 +const CONTENT = "aaaa bbbb cccc"; + +test("wrap -> truncate re-measures: text collapses, sentinel rises (no stale blank rows)", async () => { + const wrap = shallowRef<"wrap" | "truncate">("wrap"); + + const Dynamic = defineComponent(() => () => ( + + {CONTENT} + ZZZZ + + )); + + const { lastFrame } = await render(Dynamic, { columns: 40 }); + + // Initial wrap layout: 3 wrapped rows + sentinel. + expect(lastFrame()).toBe("aaaa\nbbbb\ncccc\nZZZZ"); + + wrap.value = "truncate"; + await nextTick(); + + // After re-measure the text is one truncated row and the sentinel rises to + // row 2. Before the fix the cached 3-row height persists, leaving stale blank + // rows and stranding the sentinel: "aaaa …\n\n\nZZZZ" (matches buggy Ink). + expect(lastFrame()).toBe("aaaa …\nZZZZ"); +}); + +test("truncate -> wrap re-measures: text grows to wrapped rows, sentinel descends", async () => { + const wrap = shallowRef<"wrap" | "truncate">("truncate"); + + const Dynamic = defineComponent(() => () => ( + + {CONTENT} + ZZZZ + + )); + + const { lastFrame } = await render(Dynamic, { columns: 40 }); + + // Initial truncate layout: 1 row + sentinel. + expect(lastFrame()).toBe("aaaa …\nZZZZ"); + + wrap.value = "wrap"; + await nextTick(); + + // After re-measure the text occupies 3 wrapped rows and the sentinel descends + // to row 4. Before the fix the cached 1-row height persists, so the wrapped + // rows overflow past the reserved space / overwrite the sentinel. + expect(lastFrame()).toBe("aaaa\nbbbb\ncccc\nZZZZ"); +}); diff --git a/packages/runtime/src/host/node-ops.ts b/packages/runtime/src/host/node-ops.ts index 0621eb8..735a6a0 100644 --- a/packages/runtime/src/host/node-ops.ts +++ b/packages/runtime/src/host/node-ops.ts @@ -480,6 +480,21 @@ export function buildNodeOps(options: TtyRendererOptions): RendererOptions }).props[key] = next; + // `wrap` is the one STYLE_PROP that changes a text node's MEASURED height + // (the measure func reads el.props.wrap to pick wrap/truncate/hard layout) + // yet is NOT a yoga prop — so it skips applyYogaProp and never invalidates + // yoga's cached measurement above. Without re-marking dirty, yoga keeps the + // OLD wrap mode's height while paint renders with the NEW wrap → layout and + // paint disagree (stale blank rows on wrap→truncate; overflow / overwritten + // siblings on truncate→wrap). markTextDirty forces a re-measure. Every other + // STYLE_PROP here (color/bold/border colors/…) is paint-only and never alters + // measured dimensions, so `wrap` is the sole case. NOTE: this also fixes a + // latent bug present in Ink v7.0.4 itself — Ink's applyStyles ignores + // textWrap and never markDirty()s, so a wrap-only change goes stale there too + // (a deliberate divergence pending a human vouch; see ink-divergences.md). + if (key === "wrap" && el.type === "tui-text") { + markTextDirty(el); + } } else if (key === "aria-role" || key === "ariaRole") { if (el.type === "tui-box") { el.internal_accessibility ??= {};