feat: add useCursor composable for terminal cursor control
- setCursorPosition propagates to app context for frame restoration - Cursor cleared on component unmount via onScopeDispose Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 () => <Text>cursor test</Text>;
|
||||||
|
});
|
||||||
|
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 () => <Text>reactive</Text>;
|
||||||
|
});
|
||||||
|
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 () => <Text>hidden</Text>;
|
||||||
|
});
|
||||||
|
const { lastFrame } = await render(App);
|
||||||
|
expect(lastFrame()).toContain("hidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cursor is cleared on unmount", async () => {
|
||||||
|
const App = defineComponent(() => {
|
||||||
|
useCursor();
|
||||||
|
return () => <Text>cursor</Text>;
|
||||||
|
});
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,6 +18,7 @@ test("public API exposes documented members", () => {
|
|||||||
"useStdout",
|
"useStdout",
|
||||||
"useStderr",
|
"useStderr",
|
||||||
"useTerminalSize",
|
"useTerminalSize",
|
||||||
|
"useCursor",
|
||||||
]) {
|
]) {
|
||||||
expect(api).toHaveProperty(k);
|
expect(api).toHaveProperty(k);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ export { useStdin } from "./composables/useStdin.ts";
|
|||||||
export { useStdout } from "./composables/useStdout.ts";
|
export { useStdout } from "./composables/useStdout.ts";
|
||||||
export { useStderr } from "./composables/useStderr.ts";
|
export { useStderr } from "./composables/useStderr.ts";
|
||||||
export { useTerminalSize } from "./composables/useTerminalSize.ts";
|
export { useTerminalSize } from "./composables/useTerminalSize.ts";
|
||||||
|
export { useCursor } from "./composables/useCursor.ts";
|
||||||
export {
|
export {
|
||||||
useBoxMetrics,
|
useBoxMetrics,
|
||||||
measureElement,
|
measureElement,
|
||||||
|
|||||||
Reference in New Issue
Block a user