From c4161c99a7285c13bee5838ec3ea305bd54ced2e Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Tue, 26 May 2026 00:49:27 +0800 Subject: [PATCH] fix: Box defaults to flexDirection='row', flexShrink=1, flexWrap='nowrap', flexGrow=0 Match Ink's Box defaults by setting yoga properties at node creation time. This ensures proper row-based layout regardless of Vue's prop patching order. Updated affected test snapshots and added explicit flexDirection="column" where tests relied on the old column default. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/background-color.test.tsx | 37 ++++---- .../integration/components/borders.test.tsx | 78 +++++++---------- .../integration/components/text.test.tsx | 2 +- .../integration/layout/box-defaults.test.tsx | 87 +++++++++++++++++++ packages/runtime/src/host/yoga.ts | 11 +++ 5 files changed, 148 insertions(+), 67 deletions(-) create mode 100644 packages/runtime-tests/integration/layout/box-defaults.test.tsx diff --git a/packages/runtime-tests/integration/components/background-color.test.tsx b/packages/runtime-tests/integration/components/background-color.test.tsx index 87c6da1..6c3786e 100644 --- a/packages/runtime-tests/integration/components/background-color.test.tsx +++ b/packages/runtime-tests/integration/components/background-color.test.tsx @@ -114,10 +114,9 @@ test("Multiple Text elements inherit same background", async () => { )), { columns: 100 }, ); - expect(lastFrame()).toMatchInlineSnapshot(` - "Hello  - World " - `); + expect(lastFrame()).toMatchInlineSnapshot( + `"Hello World"`, + ); }); test("Mixed text with and without background inheritance", async () => { @@ -131,11 +130,9 @@ test("Mixed text with and without background inheritance", async () => { )), { columns: 100 }, ); - expect(lastFrame()).toMatchInlineSnapshot(` - "Inherited  - No BG  - Red BG " - `); + expect(lastFrame()).toMatchInlineSnapshot( + `"Inherited No BG Red BG"`, + ); }); test("Complex nested structure with background inheritance", async () => { @@ -153,11 +150,9 @@ test("Complex nested structure with background inheritance", async () => { )), { columns: 100 }, ); - expect(lastFrame()).toMatchInlineSnapshot(` - "Outer:   - Inner:   - Explicit" - `); + expect(lastFrame()).toMatchInlineSnapshot( + `"Outer: Inner: Explicit"`, + ); }); test("Box background with standard color", async () => { @@ -246,7 +241,7 @@ test("Box background fills entire area with standard color", async () => { { columns: 100 }, ); expect(lastFrame()).toMatchInlineSnapshot(` - "Hello  + "Hello     " `); @@ -317,7 +312,7 @@ test("Box background with border fills content area", async () => { ); expect(lastFrame()).toMatchInlineSnapshot(` "╭────────╮ - │Hi │ + │Hi │ │ │ │ │ ╰────────╯" @@ -335,7 +330,7 @@ test("Box background with padding fills entire padded area", async () => { ); expect(lastFrame()).toMatchInlineSnapshot(` "  -  Hi   +  Hi       " @@ -358,8 +353,8 @@ test("Box background with center alignment fills entire area", async () => { { columns: 100 }, ); expect(lastFrame()).toMatchInlineSnapshot(` - "  - Hi  + " Hi  +    " `); }); @@ -422,8 +417,8 @@ test("Box backgroundColor fills full width on every line when text wraps", async { columns: 100 }, ); expect(lastFrame()).toMatchInlineSnapshot(` - "Hello   - World!! " + "Hello    + World!! " `); }); diff --git a/packages/runtime-tests/integration/components/borders.test.tsx b/packages/runtime-tests/integration/components/borders.test.tsx index 72c787b..dd32ac5 100644 --- a/packages/runtime-tests/integration/components/borders.test.tsx +++ b/packages/runtime-tests/integration/components/borders.test.tsx @@ -187,7 +187,7 @@ test("single node - box with horizontal alignment", async () => { ); expect(lastFrame()).toMatchInlineSnapshot(` "╭──────────────────╮ - │Hello World │ + │ Hello World │ ╰──────────────────╯" `); }); @@ -204,6 +204,14 @@ test("single node - box with vertical alignment", async () => { ); expect(lastFrame()).toMatchInlineSnapshot(` "╭───────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ │Hello World│ │ │ │ │ @@ -214,14 +222,6 @@ test("single node - box with vertical alignment", async () => { │ │ │ │ │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ ╰───────────╯" `); }); @@ -377,7 +377,7 @@ test("multiple nodes - box with horizontal alignment", async () => { ); expect(lastFrame()).toMatchInlineSnapshot(` "╭──────────────────╮ - │Hello World │ + │ Hello World │ ╰──────────────────╯" `); }); @@ -394,6 +394,14 @@ test("multiple nodes - box with vertical alignment", async () => { ); expect(lastFrame()).toMatchInlineSnapshot(` "╭───────────╮ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ + │ │ │Hello World│ │ │ │ │ @@ -404,14 +412,6 @@ test("multiple nodes - box with vertical alignment", async () => { │ │ │ │ │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ - │ │ ╰───────────╯" `); }); @@ -486,11 +486,11 @@ test("nested boxes", async () => { expect(lastFrame()).toMatchInlineSnapshot(` "╭──────────────────────────────────────╮ │ │ - │ ╭──────────────────────────────────╮ │ - │ │ │ │ - │ │ Hello World │ │ - │ │ │ │ - │ ╰──────────────────────────────────╯ │ + │ ╭─────────────╮ │ + │ │ │ │ + │ │ Hello World │ │ + │ │ │ │ + │ ╰─────────────╯ │ │ │ ╰──────────────────────────────────────╯" `); @@ -515,17 +515,11 @@ test("nested boxes - fit-content box with wide characters on flex-direction row" { columns: 100 }, ); expect(lastFrame()).toMatchInlineSnapshot(` - "╭────────────╮ - │╭──────────╮│ - ││ミスター ││ - │╰──────────╯│ - │╭──────────╮│ - ││スポック ││ - │╰──────────╯│ - │╭──────────╮│ - ││カーク船長││ - │╰──────────╯│ - ╰────────────╯" + "╭────────────────────────────────╮ + │╭────────╮╭────────╮╭──────────╮│ + ││ミスター││スポック││カーク船長││ + │╰────────╯╰────────╯╰──────────╯│ + ╰────────────────────────────────╯" `); }); @@ -548,17 +542,11 @@ test("nested boxes - fit-content box with emojis on flex-direction row", async ( { columns: 100 }, ); expect(lastFrame()).toMatchInlineSnapshot(` - "╭────╮ - │╭──╮│ - ││🦾││ - │╰──╯│ - │╭──╮│ - ││🌏││ - │╰──╯│ - │╭──╮│ - ││😋││ - │╰──╯│ - ╰────╯" + "╭────────────╮ + │╭──╮╭──╮╭──╮│ + ││🦾││🌏││😋││ + │╰──╯╰──╯╰──╯│ + ╰────────────╯" `); }); diff --git a/packages/runtime-tests/integration/components/text.test.tsx b/packages/runtime-tests/integration/components/text.test.tsx index 0fc018d..ed999a8 100644 --- a/packages/runtime-tests/integration/components/text.test.tsx +++ b/packages/runtime-tests/integration/components/text.test.tsx @@ -173,7 +173,7 @@ test("remeasure text when text nodes are changed", async () => { const add = shallowRef(false); const { lastFrame } = await render( defineComponent(() => () => ( - + abc{add.value ? x : null} )), diff --git a/packages/runtime-tests/integration/layout/box-defaults.test.tsx b/packages/runtime-tests/integration/layout/box-defaults.test.tsx new file mode 100644 index 0000000..a1c9663 --- /dev/null +++ b/packages/runtime-tests/integration/layout/box-defaults.test.tsx @@ -0,0 +1,87 @@ +import { defineComponent } from "vue"; +import { expect, test } from "vite-plus/test"; +import { render } from "@vue-tui/testing"; +import { Box, Text } from "@vue-tui/runtime"; + +test("Box defaults to flexDirection='row'", async () => { + const { lastFrame } = await render( + defineComponent(() => () => ( + + A + B + + )), + { columns: 100 }, + ); + expect(lastFrame({ trimLines: true })).toBe("AB"); +}); + +test("Box defaults to flexShrink=1", async () => { + // Two boxes inside a constrained parent: if flexShrink defaults to 1, + // they should shrink to fit rather than overflowing. + const { lastFrame } = await render( + defineComponent(() => () => ( + + + AAAAAAAA + + + BBBBBBBB + + + )), + { columns: 100 }, + ); + // Both boxes should shrink to fit within 10 columns + const frame = lastFrame({ trimLines: true })!; + // The frame should be at most 10 chars wide + const maxLineWidth = Math.max(...frame.split("\n").map((l) => l.length)); + expect(maxLineWidth).toBeLessThanOrEqual(10); +}); + +test("Box defaults to flexWrap='nowrap'", async () => { + // With nowrap, items stay on one line even if they overflow + const { lastFrame } = await render( + defineComponent(() => () => ( + + Hello + World + + )), + { columns: 100 }, + ); + // Items should be on the same line (row direction, nowrap) + const frame = lastFrame({ trimLines: true })!; + expect(frame.split("\n")).toHaveLength(1); + expect(frame).toBe("HelloWorld"); +}); + +test("Box defaults to flexGrow=0", async () => { + // A box with no explicit flexGrow should not grow to fill available space + const { lastFrame } = await render( + defineComponent(() => () => ( + + + A + + + )), + { columns: 100 }, + ); + // The inner box should only be as wide as its content (1 char), not 20 + const frame = lastFrame({ trimLines: true })!; + expect(frame).toBe("A"); +}); + +test("user-provided props override Box defaults", async () => { + const { lastFrame } = await render( + defineComponent(() => () => ( + + A + B + + )), + { columns: 100 }, + ); + expect(lastFrame({ trimLines: true })).toBe("A\nB"); +}); diff --git a/packages/runtime/src/host/yoga.ts b/packages/runtime/src/host/yoga.ts index d7603fd..2fd3f48 100644 --- a/packages/runtime/src/host/yoga.ts +++ b/packages/runtime/src/host/yoga.ts @@ -48,6 +48,17 @@ export function attachYoga(node: YogaCarrier): void { if (node.type === "static") { (node.yoga as YogaNode).setDisplay(Yoga.DISPLAY_NONE); } + // Box nodes match Ink's defaults: row direction, shrinkable, no wrap. + // These are set at the yoga level so they work regardless of whether props + // are passed through Vue's reactive system (which may include undefined + // overrides or border defaults). User-provided props override these via + // patchProp which runs after attachYoga. + if (node.type === "box") { + (node.yoga as YogaNode).setFlexDirection(Yoga.FLEX_DIRECTION_ROW); + (node.yoga as YogaNode).setFlexShrink(1); + (node.yoga as YogaNode).setFlexWrap(Yoga.WRAP_NO_WRAP); + (node.yoga as YogaNode).setFlexGrow(0); + } } export function detachYoga(node: YogaCarrier): void {