diff --git a/packages/runtime-tests/integration/layout/position.test.tsx b/packages/runtime-tests/integration/layout/position.test.tsx index afecc67..5f13e6f 100644 --- a/packages/runtime-tests/integration/layout/position.test.tsx +++ b/packages/runtime-tests/integration/layout/position.test.tsx @@ -59,6 +59,27 @@ test("absolute position with percentage bottom and right offsets", async () => { expect(lastFrame({ trimLines: true })).toBe("\n X\n\n"); }); +// Ink coerces a STRING position offset to a PERCENT (styles.ts +// applyPositionStyles: `typeof value === 'string'` → +// setPositionPercent(edge, parseFloat(value))). So a bare-numeric string like +// top="50" is 50% of the container height, NOT 50 absolute cells. This mirrors +// the "absolute position with percentage offsets" test (top="50%" left="50%") +// but with bare-numeric strings. Without the fix vue forwards "50" raw to +// setPosition → 50 absolute cells → pushed off-screen. +test("absolute position with bare numeric string offsets is a percent (Ink parity)", async () => { + const { lastFrame } = await render( + defineComponent(() => () => ( + + + X + + + )), + { columns: 100 }, + ); + expect(lastFrame({ trimLines: true })).toBe("\n\n X\n"); +}); + test("relative position offsets visual position while keeping flow", async () => { const { lastFrame } = await render( defineComponent(() => () => ( diff --git a/packages/runtime-tests/integration/layout/width-height.test.tsx b/packages/runtime-tests/integration/layout/width-height.test.tsx index aef4c55..630a3b2 100644 --- a/packages/runtime-tests/integration/layout/width-height.test.tsx +++ b/packages/runtime-tests/integration/layout/width-height.test.tsx @@ -1,7 +1,7 @@ -import { defineComponent, shallowRef, nextTick } from "vue"; +import { defineComponent, ref, shallowRef, nextTick, watchEffect } from "vue"; import { expect, test } from "vite-plus/test"; import { render } from "@vue-tui/testing"; -import { Box, Text } from "@vue-tui/runtime"; +import { Box, Text, useBoxMetrics } from "@vue-tui/runtime"; test("set width", async () => { const { lastFrame } = await render( @@ -33,6 +33,93 @@ test("set width in percent", async () => { expect(lastFrame({ trimLines: true })).toBe("A B"); }); +// Ink coerces a STRING width to a PERCENT (styles.ts applyDimensionStyles: +// `typeof style.width === 'string'` → setWidthPercent(parseInt(width, 10))). +// So a bare-numeric string is a PERCENT, NOT absolute cells: width="50" on a +// width=10 parent → 5 cells. Without the fix vue forwards "50" raw to +// setWidth → 50 absolute cells → "A" + 50 spaces + "B". +test("set width with bare numeric string is a percent (Ink parity)", async () => { + const { lastFrame } = await render( + defineComponent(() => () => ( + + + A + + B + + )), + { columns: 100 }, + ); + expect(lastFrame({ trimLines: true })).toBe("A B"); +}); + +// Ink uses parseInt(width, 10) which TRUNCATES the fraction: "55.9%" → 55%, NOT +// parseFloat which would give 55.9% → 56 cells (yoga rounds 55.9% of 100 to 56). +// Assert the COMPUTED width directly via useBoxMetrics so the test discriminates +// parseInt(55) from parseFloat(55.9→56): a paint-frame assertion can't, because +// trimLines collapses both a 55- and 56-cell box to the same column once the +// child text is left-aligned. RED on the pre-fix parseFloat path (width 56), +// GREEN after (width 55). +test("set width with fractional percent string truncates to 55 like Ink parseInt", async () => { + const computedWidth = shallowRef(-1); + const App = defineComponent(() => { + const boxRef = ref(null); + const metrics = useBoxMetrics(boxRef); + watchEffect(() => { + computedWidth.value = metrics.width.value; + }); + return () => ( + + + A + + B + + ); + }); + await render(App, { columns: 200 }); + // useBoxMetrics defers measurement to nextTick after the commit. + await nextTick(); + // parseInt("55.9", 10) → 55 → 55% of 100 = 55 cells (NOT parseFloat → 55.9 → 56). + expect(computedWidth.value).toBe(55); +}); + +// Ink: parseInt("", 10) → NaN, which yoga accepts via setWidthPercent without +// throwing. vue forwarded "" raw to setWidth("") which THROWS, crashing render. +test("set width to empty string does not throw and renders child (Ink parity)", async () => { + const { lastFrame } = await render( + defineComponent(() => () => ( + + + X + + + )), + { columns: 100 }, + ); + expect(lastFrame()).toContain("X"); +}); + +// Ink's applyDimensionStyles else-branch routes a non-number/non-string width to +// setWidthAuto() (styles.ts:669-671), so a junk value renders fine. vue used to +// forward it raw to setWidth(false) which THROWS ("Invalid value false for +// setWidth"), crashing the render. Lock the parity: junk width must not throw and +// must still render the child (auto sizing). Vue's [Number, String] prop +// validation only WARNS on `false` and still forwards it, so this path is real. +test("set width to a junk (non-number/non-string) value does not throw and renders child (Ink parity)", async () => { + const { lastFrame } = await render( + defineComponent(() => () => ( + + + X + + + )), + { columns: 100 }, + ); + expect(lastFrame()).toContain("X"); +}); + test("set min width", async () => { const { lastFrame: smallerFrame } = await render( defineComponent(() => () => ( diff --git a/packages/runtime/src/host/yoga.ts b/packages/runtime/src/host/yoga.ts index 9715134..4c0f025 100644 --- a/packages/runtime/src/host/yoga.ts +++ b/packages/runtime/src/host/yoga.ts @@ -178,14 +178,55 @@ export function removeYogaChild(parent: TuiContainer, child: TuiNode): void { // --- prop application ---------------------------------------------------- const YOGA_PROP_SETTERS: Record void> = { - width: (n, v) => - v == null ? n.setWidth("auto") : n.setWidth(v as number | "auto" | `${number}%`), - height: (n, v) => - v == null ? n.setHeight("auto") : n.setHeight(v as number | "auto" | `${number}%`), - // Ink default: minWidth=0 (yoga default). Reset to 0 on removal. (G19) - minWidth: (n, v) => n.setMinWidth(v == null ? 0 : (v as number | `${number}%`)), - // Ink default: minHeight=0 (yoga default). Reset to 0 on removal. (G19) - minHeight: (n, v) => n.setMinHeight(v == null ? 0 : (v as number | `${number}%`)), + // Mirror Ink's applyDimensionStyles width/height branch exactly (styles.ts:664-682): + // number → setWidth (absolute cells) + // string → setWidthPercent(Number.parseInt(v, 10)) — ANY string is a PERCENT, + // so a bare-numeric string like "50" is 50%, NOT 50 absolute cells, and + // parseInt TRUNCATES fractions ("55.9%" → 55%). parseInt("") → NaN, which + // yoga's percent setter accepts without throwing. + // else → setWidthAuto() — this is the load-bearing fallback (like flexBasis's + // setFlexBasisAuto): Vue's [Number, String] prop validation only WARNS on a + // bad runtime value (e.g. width={false}/{}/[]) and still forwards it, so + // without this branch the raw setWidth(false) THROWS ("Invalid value false + // for setWidth") and crashes the render where Ink renders fine via auto. + // null/undefined also land in the else branch → auto (the G19 removal reset, + // equivalent to the prior setWidth("auto")). + width: (n, v) => { + if (typeof v === "number") { + n.setWidth(v); + } else if (typeof v === "string") { + n.setWidthPercent(Number.parseInt(v, 10)); + } else { + n.setWidthAuto(); + } + }, + height: (n, v) => { + if (typeof v === "number") { + n.setHeight(v); + } else if (typeof v === "string") { + n.setHeightPercent(Number.parseInt(v, 10)); + } else { + n.setHeightAuto(); + } + }, + // Mirror Ink's applyDimensionStyles minWidth branch exactly (styles.ts:684-690): + // string → setMinWidthPercent(Number.parseInt(v, 10)) + // else → setMinWidth(v ?? 0) — number falls here (= setMinWidth(v)), and so + // does a junk value (e.g. minWidth={false} → setMinWidth(false), which THROWS + // in yoga exactly as it does in Ink, since `?? 0` only catches null/undefined). + // Ink has no auto fallback for min/max, so we faithfully forward junk to the + // cell setter and match Ink's behavior, including its throw. + // Ink default: minWidth=0 (yoga default) → null/undefined reset to 0 on removal. (G19) + minWidth: (n, v) => + typeof v === "string" + ? n.setMinWidthPercent(Number.parseInt(v, 10)) + : n.setMinWidth(v == null ? 0 : (v as number)), + // Mirror Ink's minHeight branch (styles.ts:692-698); see minWidth above. + // Ink default: minHeight=0 (yoga default) → null/undefined reset to 0 on removal. (G19) + minHeight: (n, v) => + typeof v === "string" + ? n.setMinHeightPercent(Number.parseInt(v, 10)) + : n.setMinHeight(v == null ? 0 : (v as number)), // Ink default: flexGrow=0 (Box.tsx hardcodes flexGrow:0). Reset to 0 on removal. (G19) flexGrow: (n, v) => n.setFlexGrow(v == null ? 0 : (v as number)), // Ink default: flexShrink=1 (Box.tsx hardcodes flexShrink:1). Reset to 1 on removal. (G19) @@ -275,32 +316,56 @@ const YOGA_PROP_SETTERS: Record void> = { // Yoga does not support per-axis overflow; these are accepted silently. overflowX: (_n, _v) => {}, overflowY: (_n, _v) => {}, + // Mirror Ink's applyDimensionStyles maxWidth branch (styles.ts:700-714): + // string → setMaxWidthPercent(Number.parseInt(v, 10)) + // else → setMaxWidth(v) — number falls here; a junk value (maxWidth={false}) + // forwards to setMaxWidth(false), which THROWS in yoga exactly as in Ink (Ink + // has no auto fallback for max). We map null/undefined → NaN here (yoga's "no + // max", the G19 removal reset) because Vue's host renderer can deliver raw + // null and setMaxWidth(null) throws ("Cannot read properties of null"); NaN is + // equivalent to Ink's else with an absent/undefined value. maxWidth: (n, v) => - v == null ? n.setMaxWidth(NaN as never) : n.setMaxWidth(v as number | `${number}%`), + typeof v === "string" + ? n.setMaxWidthPercent(Number.parseInt(v, 10)) + : n.setMaxWidth(v == null ? (NaN as never) : (v as number)), + // Mirror Ink's maxHeight branch (styles.ts:708-714); see maxWidth above. maxHeight: (n, v) => - v == null ? n.setMaxHeight(NaN as never) : n.setMaxHeight(v as number | `${number}%`), + typeof v === "string" + ? n.setMaxHeightPercent(Number.parseInt(v, 10)) + : n.setMaxHeight(v == null ? (NaN as never) : (v as number)), aspectRatio: (n, v) => v == null ? n.setAspectRatio(undefined as never) : n.setAspectRatio(v as number), alignContent: (n, v) => v == null ? n.setAlignContent(Yoga.ALIGN_FLEX_START) : n.setAlignContent(toAlign(v as string)), // Ink default: position=relative (yoga default). Reset to RELATIVE on removal. (G19) position: (n, v) => n.setPositionType(toPosition(v as string | undefined)), + // Mirror Ink's applyPositionStyles branch exactly (styles.ts:428-441): + // string → setPositionPercent(edge, Number.parseFloat(value)) — so a + // bare-numeric string like top="50" is 50% of the container, NOT 50 absolute + // cells. NOTE: Ink uses parseFloat for positions (preserving fractions) vs + // parseInt for dimensions (above) — that distinction is intentional, keep it. + // else → setPosition(edge, value) — number falls here; a junk value + // (top={false}) forwards to setPosition(edge, false), which THROWS in yoga + // exactly as in Ink (Ink has no auto fallback for positions). We map + // null/undefined → NaN here (yoga's auto, the G19 removal reset), the + // equivalent of Ink's else with an absent value (Vue can deliver raw null, + // and setPosition(edge, null) throws). top: (n, v) => - v == null - ? n.setPosition(Yoga.EDGE_TOP, NaN as never) - : n.setPosition(Yoga.EDGE_TOP, v as number | `${number}%`), + typeof v === "string" + ? n.setPositionPercent(Yoga.EDGE_TOP, Number.parseFloat(v)) + : n.setPosition(Yoga.EDGE_TOP, v == null ? (NaN as never) : (v as number)), right: (n, v) => - v == null - ? n.setPosition(Yoga.EDGE_RIGHT, NaN as never) - : n.setPosition(Yoga.EDGE_RIGHT, v as number | `${number}%`), + typeof v === "string" + ? n.setPositionPercent(Yoga.EDGE_RIGHT, Number.parseFloat(v)) + : n.setPosition(Yoga.EDGE_RIGHT, v == null ? (NaN as never) : (v as number)), bottom: (n, v) => - v == null - ? n.setPosition(Yoga.EDGE_BOTTOM, NaN as never) - : n.setPosition(Yoga.EDGE_BOTTOM, v as number | `${number}%`), + typeof v === "string" + ? n.setPositionPercent(Yoga.EDGE_BOTTOM, Number.parseFloat(v)) + : n.setPosition(Yoga.EDGE_BOTTOM, v == null ? (NaN as never) : (v as number)), left: (n, v) => - v == null - ? n.setPosition(Yoga.EDGE_LEFT, NaN as never) - : n.setPosition(Yoga.EDGE_LEFT, v as number | `${number}%`), + typeof v === "string" + ? n.setPositionPercent(Yoga.EDGE_LEFT, Number.parseFloat(v)) + : n.setPosition(Yoga.EDGE_LEFT, v == null ? (NaN as never) : (v as number)), }; function toFlexDirection(v: string): FlexDirection {