Files
vue-tui/packages/runtime-tests/integration/pty/fixtures/cursor-sibling-repaint.tsx
T
Yunfei He 86b94b9fa5 fix(runtime): re-assert the declared cursor every commit (persistent declaration) (#157)
A focused input's caret zombied to the bottom-left corner whenever an
unrelated repaint (spinner tick, log line, progress bar) committed
without re-declaring the cursor: the active cursor was gated on a
per-commit dirty/reference change, so an unrelated commit dropped it.

Real terminal programs that own an edit point re-place the caret there
every frame (vim emits an absolute CUP after each repaint, readline
re-lands the buffer offset on SIGWINCH, nano homes to its edit cell).
Match that: the runtime now re-emits the last-declared caret at the end
of every commit until the declaration changes or is cleared, so the
caret survives unrelated repaints in all component topologies. The
position is clamped to the visible region (D5) and a cleared
declaration emits no caret, so teardown still hands the cursor back.

This is a deliberate divergence FROM Ink, which re-asserts only when
the cursor's React component re-renders and so zombies the caret in
sibling/leaf topology too (run-verified). Aligning to Ink reduces bugs
only when Ink is correct; here matching Ink would preserve abnormal
behavior. Overrides the prior 2026-06-01 KEEP, whose rationale (avoid
diverging from Ink in the sibling direction) was overturned by running
real terminal apps. The {x,y} setCursorPosition API is unchanged (it
remains the IME primitive); the fix is an internal per-commit re-emit.

Red-first: a real-TTY PTY test with sibling-topology spinner state
asserts the spinner-only frame ends with the caret-restore suffix.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 17:08:05 +08:00

66 lines
2.5 KiB
TypeScript

import process from "node:process";
import { Box, Text, createApp, useCursor, useInput } from "@vue-tui/runtime";
import { defineComponent, h, onMounted, shallowRef } from "vue";
// Sibling-topology storyboard for the persistent-cursor-re-assertion divergence.
//
// The spinner state (`spin`) lives in a SIBLING of the `useCursor` input
// component, so a spinner tick re-renders only the sibling — the input child's
// own deps do not change. Under the old value/reference gate the input never
// re-declared its caret on that commit, so the caret zombied to the bottom-left
// corner. The fix re-emits the last-declared caret at the end of EVERY commit,
// so the caret survives the unrelated spinner repaint.
//
// Flow: type "hi" (caret declared at x = 2 + 2 = 4, y = 1), then a spinner-only
// repaint fires with NO further keystroke. The final frame must still end at the
// declared column, not the corner.
const SPIN = ["|", "/", "-", "\\"];
const spin = shallowRef(0);
const typed = shallowRef("");
// The spinner — a SIBLING of the input. Its repaint must not orphan the caret.
const Spinner = defineComponent(
() => () => h(Text, null, () => `${SPIN[spin.value % 4]} working...`),
);
// The input owns the caret via useCursor; it re-declares on each keystroke.
const Input = defineComponent(() => {
const { setCursorPosition } = useCursor();
useInput((input, key) => {
if (key.ctrl || key.meta || !input) return;
typed.value = typed.value + input;
});
return () => {
// Caret sits just after the typed text on row 1 (the input is line 2).
setCursorPosition({ x: 2 + typed.value.length, y: 1 });
return h(Text, null, () => `> ${typed.value}`);
};
});
const App = defineComponent(() => {
onMounted(() => {
process.stdout.write("__READY__");
});
return () => h(Box, { flexDirection: "column" }, [h(Spinner), h(Input)]);
});
const app = createApp(App);
app.mount({ stdout: process.stdout, exitOnCtrlC: false });
// Drive the storyboard once the app is mounted: the test writes "hi" to stdin,
// we wait for it to land, fire ONE spinner-only repaint (no keystroke), then
// exit so the captured byte stream ends right after the unrelated repaint.
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
void (async () => {
// Wait long enough for the two keystrokes to be processed and committed.
await sleep(400);
spin.value++; // spinner-only repaint — the caret must NOT zombie here
await sleep(200);
app.unmount();
})();
await app.waitUntilExit();
console.log("exited");