From ca8ba6530032b3a0d5d1da6ef3c725aefe666bd9 Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Tue, 26 May 2026 16:08:30 +0800 Subject: [PATCH] feat: add useBoxMetrics composable and measureElement function - useBoxMetrics returns reactive { width, height, left, top, hasMeasured } - measureElement returns imperative { width, height } snapshot - Handles Vue component instance refs via $el resolution Co-Authored-By: Claude Opus 4.7 (1M context) --- .../composables/use-box-metrics.test.tsx | 117 ++++++++++++++++++ .../runtime/src/composables/useBoxMetrics.ts | 117 ++++++++++++++++++ packages/runtime/src/index.ts | 6 + 3 files changed, 240 insertions(+) create mode 100644 packages/runtime-tests/integration/composables/use-box-metrics.test.tsx create mode 100644 packages/runtime/src/composables/useBoxMetrics.ts diff --git a/packages/runtime-tests/integration/composables/use-box-metrics.test.tsx b/packages/runtime-tests/integration/composables/use-box-metrics.test.tsx new file mode 100644 index 0000000..d2b800c --- /dev/null +++ b/packages/runtime-tests/integration/composables/use-box-metrics.test.tsx @@ -0,0 +1,117 @@ +import { defineComponent, nextTick, ref, shallowRef, watchEffect } from "vue"; +import { describe, expect, test } from "vite-plus/test"; +import { render } from "@vue-tui/testing"; +import { Box, Text, useBoxMetrics, measureElement } from "@vue-tui/runtime"; + +describe("useBoxMetrics", () => { + test("returns layout dimensions after render", async () => { + const dims = shallowRef({ w: 0, h: 0 }); + const App = defineComponent(() => { + const boxRef = ref(null); + const metrics = useBoxMetrics(boxRef); + watchEffect(() => { + dims.value = { w: metrics.width.value, h: metrics.height.value }; + }); + return () => ( + + test + + ); + }); + await render(App); + // useBoxMetrics defers measurement to nextTick after the commit + await nextTick(); + expect(dims.value.w).toBe(20); + expect(dims.value.h).toBe(5); + }); + + test("returns left and top positions", async () => { + const pos = shallowRef({ l: -1, t: -1 }); + const App = defineComponent(() => { + const boxRef = ref(null); + const metrics = useBoxMetrics(boxRef); + watchEffect(() => { + pos.value = { l: metrics.left.value, t: metrics.top.value }; + }); + return () => ( + + + inner + + + ); + }); + await render(App); + await nextTick(); + expect(pos.value.l).toBe(0); + expect(pos.value.t).toBe(0); + }); + + test("hasMeasured starts false", async () => { + let measured = false; + const App = defineComponent(() => { + const boxRef = ref(null); + const metrics = useBoxMetrics(boxRef); + // During setup, hasMeasured is false (layout not computed yet) + measured = metrics.hasMeasured.value; + return () => ( + + x + + ); + }); + await render(App); + // During setup, hasMeasured was false + expect(measured).toBe(false); + }); + + test("hasMeasured becomes true after layout", async () => { + const hasMeasuredRef = shallowRef(false); + const App = defineComponent(() => { + const boxRef = ref(null); + const metrics = useBoxMetrics(boxRef); + watchEffect(() => { + hasMeasuredRef.value = metrics.hasMeasured.value; + }); + return () => ( + + x + + ); + }); + await render(App); + await nextTick(); + expect(hasMeasuredRef.value).toBe(true); + }); +}); + +describe("measureElement", () => { + test("returns { width: 0, height: 0 } for null", () => { + expect(measureElement(null)).toEqual({ width: 0, height: 0 }); + }); + + test("returns { width: 0, height: 0 } for undefined", () => { + expect(measureElement(undefined)).toEqual({ width: 0, height: 0 }); + }); + + test("returns { width: 0, height: 0 } for object without yoga", () => { + expect(measureElement({})).toEqual({ width: 0, height: 0 }); + }); + + test("returns dimensions from a node with yoga property", () => { + const fakeYoga = { + getComputedWidth: () => 42, + getComputedHeight: () => 17, + }; + expect(measureElement({ yoga: fakeYoga })).toEqual({ width: 42, height: 17 }); + }); + + test("resolves through $el for component instances", () => { + const fakeYoga = { + getComputedWidth: () => 30, + getComputedHeight: () => 10, + }; + const fakeInstance = { $el: { yoga: fakeYoga } }; + expect(measureElement(fakeInstance)).toEqual({ width: 30, height: 10 }); + }); +}); diff --git a/packages/runtime/src/composables/useBoxMetrics.ts b/packages/runtime/src/composables/useBoxMetrics.ts new file mode 100644 index 0000000..035680c --- /dev/null +++ b/packages/runtime/src/composables/useBoxMetrics.ts @@ -0,0 +1,117 @@ +import { nextTick, shallowRef, watchPostEffect, type Ref, type ShallowRef } from "vue"; +import type { Node as YogaNode } from "yoga-layout"; + +// Yoga's `right`/`bottom` are omitted: always `0` for flow layout and +// unintuitive for absolute positioning. Matches Ink's BoxMetrics type. + +export interface BoxMetrics { + /** Element width. */ + readonly width: number; + /** Element height. */ + readonly height: number; + /** Distance from the left edge of the parent. */ + readonly left: number; + /** Distance from the top edge of the parent. */ + readonly top: number; +} + +export interface UseBoxMetricsResult { + /** Reactive element width. */ + readonly width: ShallowRef; + /** Reactive element height. */ + readonly height: ShallowRef; + /** Reactive distance from the left edge of the parent. */ + readonly left: ShallowRef; + /** Reactive distance from the top edge of the parent. */ + readonly top: ShallowRef; + /** Whether the currently tracked element has been measured in the latest layout pass. */ + readonly hasMeasured: ShallowRef; +} + +/** + * Resolve a ref value to the underlying TUI node with a yoga property. + * Handles both direct TUI node refs and Vue component instance refs (where + * the TUI node is accessible via `$el`). + */ +function resolveYogaNode(value: unknown): { yoga: YogaNode } | null { + if (!value) return null; + const obj = value as Record; + // Direct TUI node (e.g. from host element ref) + if (obj.yoga) return obj as { yoga: YogaNode }; + // Vue component instance — root host element is on $el + if (obj.$el && (obj.$el as Record).yoga) { + return obj.$el as { yoga: YogaNode }; + } + return null; +} + +/** + * Imperative function that reads yoga computed dimensions from a TUI node. + * + * Returns `{ width: 0, height: 0 }` before layout (when yoga node doesn't + * exist or hasn't been calculated). + * + * Note: `measureElement()` returns `{width: 0, height: 0}` when called during + * render (before layout is calculated). Call it from post-render code, such as + * `watchPostEffect`, `onMounted`, input handlers, or timer callbacks. + */ +export function measureElement(node: unknown): { width: number; height: number } { + const tuiNode = resolveYogaNode(node); + if (!tuiNode) return { width: 0, height: 0 }; + return { + width: tuiNode.yoga.getComputedWidth() ?? 0, + height: tuiNode.yoga.getComputedHeight() ?? 0, + }; +} + +/** + * Reactive composable that returns computed layout metrics for a tracked box element. + * Updates after each render commit when yoga layout has been calculated. + * + * Returns `{ width, height, left, top, hasMeasured }` where all values are + * reactive refs. `hasMeasured` starts `false` and becomes `true` after the + * first layout pass. + * + * @example + * ```tsx + * const boxRef = ref(null); + * const { width, height, left, top, hasMeasured } = useBoxMetrics(boxRef); + * return () => ( + * + * {hasMeasured.value ? `${width.value}x${height.value}` : "Measuring..."} + * + * ); + * ``` + */ +export function useBoxMetrics(ref: Ref): UseBoxMetricsResult { + const width = shallowRef(0); + const height = shallowRef(0); + const left = shallowRef(0); + const top = shallowRef(0); + const hasMeasured = shallowRef(false); + + function measure() { + const node = resolveYogaNode(ref.value); + if (!node) return; + width.value = node.yoga.getComputedWidth(); + height.value = node.yoga.getComputedHeight(); + left.value = node.yoga.getComputedLeft(); + top.value = node.yoga.getComputedTop(); + hasMeasured.value = true; + } + + // Re-measure after each render commit. watchPostEffect triggers when the + // ref changes (component mount / unmount). The yoga layout is calculated + // inside the commit scheduler's queuePostFlushCb, which may run after this + // watcher in the same flush cycle. We use nextTick to defer the read so + // that it runs after the scheduler's commit has called calculateLayout. + watchPostEffect(() => { + // Access ref.value to track the dependency — when the ref changes, + // this effect re-runs and schedules a new measurement. + const node = resolveYogaNode(ref.value); + if (!node) return; + void nextTick(measure); + }); + + return { width, height, left, top, hasMeasured }; +} diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 2797000..84c17b3 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -17,4 +17,10 @@ export { useStdin } from "./composables/useStdin.ts"; export { useStdout } from "./composables/useStdout.ts"; export { useStderr } from "./composables/useStderr.ts"; export { useTerminalSize } from "./composables/useTerminalSize.ts"; +export { + useBoxMetrics, + measureElement, + type BoxMetrics, + type UseBoxMetricsResult, +} from "./composables/useBoxMetrics.ts"; export type { DevState, DevErrorInfo } from "./hmr.ts";