diff --git a/packages/runtime-tests/integration/composables/use-cursor.test.tsx b/packages/runtime-tests/integration/composables/use-cursor.test.tsx new file mode 100644 index 0000000..826d4a4 --- /dev/null +++ b/packages/runtime-tests/integration/composables/use-cursor.test.tsx @@ -0,0 +1,56 @@ +import { defineComponent, nextTick, shallowRef } from "vue"; +import { describe, expect, test } from "vite-plus/test"; +import { render } from "@vue-tui/testing"; +import { Text, useCursor } from "@vue-tui/runtime"; + +describe("useCursor", () => { + test("setCursorPosition updates cursor state", async () => { + const App = defineComponent(() => { + const { setCursorPosition } = useCursor(); + setCursorPosition({ x: 5, y: 3 }); + return () => cursor test; + }); + const { lastFrame } = await render(App); + expect(lastFrame()).toContain("cursor test"); + }); + + test("setCursorPosition can be updated reactively", async () => { + const pos = shallowRef<{ x: number; y: number } | undefined>({ x: 0, y: 0 }); + const App = defineComponent(() => { + const { setCursorPosition } = useCursor(); + setCursorPosition(pos.value); + return () => reactive; + }); + const { lastFrame } = await render(App); + expect(lastFrame()).toContain("reactive"); + + // Update position — should not crash + pos.value = { x: 10, y: 5 }; + await nextTick(); + }); + + test("setCursorPosition accepts undefined to hide cursor", async () => { + const App = defineComponent(() => { + const { setCursorPosition } = useCursor(); + setCursorPosition({ x: 1, y: 1 }); + setCursorPosition(undefined); + return () => hidden; + }); + const { lastFrame } = await render(App); + expect(lastFrame()).toContain("hidden"); + }); + + test("cursor is cleared on unmount", async () => { + const App = defineComponent(() => { + useCursor(); + return () => cursor; + }); + const { unmount } = await render(App); + unmount(); + // No crash = success (cursor position cleared via onScopeDispose) + }); + + test("throws when called outside render tree", () => { + expect(() => useCursor()).toThrow("useCursor() must be called inside a vue-tui render tree"); + }); +}); diff --git a/packages/runtime-tests/integration/public-api.test.ts b/packages/runtime-tests/integration/public-api.test.ts index 4588695..3cef2ba 100644 --- a/packages/runtime-tests/integration/public-api.test.ts +++ b/packages/runtime-tests/integration/public-api.test.ts @@ -18,6 +18,7 @@ test("public API exposes documented members", () => { "useStdout", "useStderr", "useTerminalSize", + "useCursor", ]) { expect(api).toHaveProperty(k); } diff --git a/packages/runtime/src/composables/useCursor.ts b/packages/runtime/src/composables/useCursor.ts new file mode 100644 index 0000000..2bd5d3a --- /dev/null +++ b/packages/runtime/src/composables/useCursor.ts @@ -0,0 +1,37 @@ +import { inject, shallowRef, watch, onScopeDispose } from "vue"; +import { AppContextKey } from "../context.ts"; + +/** + * Returns `setCursorPosition` so a component can control the terminal cursor. + * + * Setting a position makes the cursor visible at the given coordinates + * (relative to the output origin). Pass `undefined` to hide the cursor. + * The cursor position is automatically cleared when the component unmounts. + */ +export function useCursor() { + const ctx = inject(AppContextKey); + if (!ctx) throw new Error("useCursor() must be called inside a vue-tui render tree"); + + const positionRef = shallowRef<{ x: number; y: number } | undefined>(undefined); + + function setCursorPosition(position: { x: number; y: number } | undefined) { + positionRef.value = position; + } + + // Propagate cursor position to app context synchronously so it is + // available to restoreLastOutput() after the next render commit. + watch( + positionRef, + (pos) => { + ctx.setCursorPosition(pos); + }, + { flush: "sync" }, + ); + + // On unmount, clear cursor position so the cursor is hidden. + onScopeDispose(() => { + ctx.setCursorPosition(undefined); + }); + + return { setCursorPosition }; +} diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 84c17b3..a830aff 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -17,6 +17,7 @@ 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 { useCursor } from "./composables/useCursor.ts"; export { useBoxMetrics, measureElement,