From d8d9296905dc4a8126e73cad0092632eec8a4f6b Mon Sep 17 00:00:00 2001 From: Doctor Wu Date: Sat, 4 Jul 2026 11:25:50 +0800 Subject: [PATCH] feat(components): add `ScrollBox` component (#237) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(runtime): add ScrollBox component * refactor(components): move ScrollBox into components package * feat(runtime): add mouse input composable * refactor(components): delegate ScrollBox mouse input * docs: describe mouse input composable * fix(runtime): consume unsupported SGR mouse input * fix(components): gate ScrollBox input on raw mode support * fix(runtime): require escape prefix for SGR mouse input * refactor(components): rename ScrollBox input props to wheel/keyboard Follow the components boolean-prop convention (bare noun, default false): enableMouse/enableKeyboard/isActive -> wheel/keyboard, both opt-in. Mouse- wheel is off by default because enabling terminal mouse tracking suppresses the terminal's native text selection window-wide. Record the convention in components-design-principles.md. * refactor(components): rename ScrollBox linesPerWheel + input tests - Rename the wheel-step prop wheelLines -> linesPerWheel. - Drop Home/End keyboard scroll for now — keyboard is PageUp/PageDown only. - Add tests: keyboard paging, and SGR mouse-mode disable on signal-exit (fs.writeSync path, mirroring the bracketed-paste test). * feat(examples): add ScrollBox streaming demo A streaming-log demo of : new lines arrive on a timer and stick to the bottom until you scroll up (wheel / PageUp / PageDown), then hold position while output keeps arriving. Press q to quit. --------- Co-authored-by: Yunfei He --- .agents/docs/components-design-principles.md | 15 +- .agents/docs/components/scroll-box.md | 43 ++++ .agents/docs/ink-divergences.md | 2 +- README.md | 38 ++-- examples/scroll-box/index.html | 7 + examples/scroll-box/package.json | 22 ++ examples/scroll-box/src/app.vue | 51 +++++ examples/scroll-box/src/main.ts | 4 + examples/scroll-box/src/vue-shim.d.ts | 5 + examples/scroll-box/tsconfig.json | 11 + examples/scroll-box/vite.config.ts | 7 + packages/components/README.md | 28 ++- packages/components/src/index.ts | 6 +- .../src/scroll-box/scroll-box-props.ts | 17 ++ ...l-box-render-to-string.sequential.test.tsx | 36 ++++ .../src/scroll-box/scroll-box.test.tsx | 179 ++++++++++++++++ .../components/src/scroll-box/scroll-box.vue | 95 +++++++++ .../src/{ => spinner}/spinner-props.ts | 0 .../src/{ => spinner}/spinner.test.tsx | 0 .../components/src/{ => spinner}/spinner.vue | 0 .../src/{ => spinner}/spinners.test.tsx | 0 .../components/src/{ => spinner}/spinners.ts | 0 ...se-disable-signal-exit.sequential.test.tsx | 152 +++++++++++++ .../composables/use-mouse-input.test.tsx | 200 ++++++++++++++++++ .../integration/public-api.test.ts | 1 + .../integration/public-types.test-d.ts | 28 ++- packages/runtime/README.md | 33 +-- .../runtime/src/composables/useMouseInput.ts | 71 +++++++ packages/runtime/src/context.ts | 2 + packages/runtime/src/index.ts | 5 + packages/runtime/src/io/parse-mouse.ts | 59 ++++++ packages/runtime/src/render-to-string.ts | 4 +- packages/runtime/src/render.ts | 88 ++++++-- pnpm-lock.yaml | 25 +++ 34 files changed, 1171 insertions(+), 63 deletions(-) create mode 100644 .agents/docs/components/scroll-box.md create mode 100644 examples/scroll-box/index.html create mode 100644 examples/scroll-box/package.json create mode 100644 examples/scroll-box/src/app.vue create mode 100644 examples/scroll-box/src/main.ts create mode 100644 examples/scroll-box/src/vue-shim.d.ts create mode 100644 examples/scroll-box/tsconfig.json create mode 100644 examples/scroll-box/vite.config.ts create mode 100644 packages/components/src/scroll-box/scroll-box-props.ts create mode 100644 packages/components/src/scroll-box/scroll-box-render-to-string.sequential.test.tsx create mode 100644 packages/components/src/scroll-box/scroll-box.test.tsx create mode 100644 packages/components/src/scroll-box/scroll-box.vue rename packages/components/src/{ => spinner}/spinner-props.ts (100%) rename packages/components/src/{ => spinner}/spinner.test.tsx (100%) rename packages/components/src/{ => spinner}/spinner.vue (100%) rename packages/components/src/{ => spinner}/spinners.test.tsx (100%) rename packages/components/src/{ => spinner}/spinners.ts (100%) create mode 100644 packages/runtime-tests/integration/composables/mouse-disable-signal-exit.sequential.test.tsx create mode 100644 packages/runtime-tests/integration/composables/use-mouse-input.test.tsx create mode 100644 packages/runtime/src/composables/useMouseInput.ts create mode 100644 packages/runtime/src/io/parse-mouse.ts diff --git a/.agents/docs/components-design-principles.md b/.agents/docs/components-design-principles.md index 149fd58..318ba44 100644 --- a/.agents/docs/components-design-principles.md +++ b/.agents/docs/components-design-principles.md @@ -5,10 +5,10 @@ > issue, not here. It records how components in `@vue-tui/components` should be _shaped_ and > _styled_, and the bar for adding one in the first place. > -> **Status:** active — the package now ships its first component, `Spinner` (see per-component +> **Status:** active — the package now ships `ScrollBox` and `Spinner` (see per-component > records below). The principles here are design intent for the package as a whole. > -> **Per-component records:** [spinner](./components/spinner.md). +> **Per-component records:** [scroll-box](./components/scroll-box.md), [spinner](./components/spinner.md). **The governing idea:** components in `@vue-tui/components` are **pure compositions of `@vue-tui/runtime` primitives**. The runtime owns the terminal-I/O and layout/commit boundary; @@ -119,6 +119,17 @@ consistent and to flag real authoring traps: _primitives_ — `tui-*` host tags, `isCustomElement`, camelCase host-prop binding — which a composition author, using only `` / ``, never touches.) +## Boolean prop naming & defaults + +[VOUCHED @hyf0] + +Component boolean props follow Vue-ecosystem and terminal-UI convention — not verb-prefixed toggles. + +- **A boolean prop is a noun or an adjective, never a verb.** `bordered`, `clearable`, `mouse`, `keys` — not `enableBorder` / `enableMouse`. None of the major Vue libraries (Element Plus, Naive UI, Vuetify, Ant Design Vue, PrimeVue) use an `enable*` boolean prop; the terminal-UI precedent (blessed) is bare `mouse` / `keys`. (`enable*` is a React-library pattern, e.g. TanStack Table — not idiomatic in Vue or in TUIs.) +- **Booleans default to `false`.** `` then reads as "turn foo on." A feature that must be on by default is named as its negative (`disabled`) so the prop still defaults `false`; avoid a verb-boolean that defaults `true`, which forces the backwards `:enable-foo="false"`. (Matches MUI's published API-design guidance.) +- **Name for precision — what is toggled, not the device.** A bare device noun reads ambiguously; prefer the specific behavior it controls (e.g. `wheel` for mouse-wheel scrolling rather than `mouse`, which would also imply clicks). +- **A prop with a global / terminal-wide side effect is opt-in (`false` by default), and the side effect is documented.** Example: enabling terminal mouse tracking suppresses the terminal's native text selection window-wide (users bypass with Shift) — so such a prop must be opt-in, not on by default. + ## Deliberately omitted - **No accessibility requirement.** Components are not required to set `ariaRole` / `ariaState`. diff --git a/.agents/docs/components/scroll-box.md b/.agents/docs/components/scroll-box.md new file mode 100644 index 0000000..ef31054 --- /dev/null +++ b/.agents/docs/components/scroll-box.md @@ -0,0 +1,43 @@ +# ScrollBox — decision record + +> Decisions specific to `@vue-tui/components`'s `ScrollBox`. Shared conventions live in +> [components-design-principles.md](../components-design-principles.md). Tracking: #221. + +`ScrollBox` is a bounded app-managed viewport for long terminal content that may keep updating, +such as streaming agent output. + +## Package placement + +- `ScrollBox` lives in `@vue-tui/components`, not `@vue-tui/runtime`. +- It is built only from the runtime public barrel: `Box`, `useBoxMetrics`, `useInput`, and + `useMouseInput`. +- It deliberately does not import `@vue-tui/runtime/internal`; SGR mouse-mode ownership and mouse + input decoding live in the runtime public `useMouseInput` capability. + +## Behavior + +- Mouse-wheel scrolling is opt-in via `wheel` (default `false`). Enabling it turns on terminal + mouse tracking, which suppresses the terminal's native text selection window-wide (users bypass + with Shift) — so it defaults off rather than on. +- Sticky-bottom is the core semantic: while sticky, content growth follows the bottom; after the + user scrolls up, content growth preserves the current viewport instead of jumping to the latest + output. +- Keyboard scrolling (`PageUp` / `PageDown`) is opt-in via + `keyboard` (default `false`). +- `linesPerWheel` (default `3`) sets how many lines each wheel event scrolls. +- `renderToString()` must not emit SGR mouse-mode sequences. + +## Input routing + +Wheel and keyboard input are global and gated per-input-type by the `wheel` and `keyboard` props; +there is no built-in pointer routing. With multiple ``es on screen, the app decides +which one responds by binding `wheel` / `keyboard` to app state (e.g. the focused pane) rather than +enabling every box at once. + +## Implementation notes + +- The viewport and content boxes are measured with `useBoxMetrics`. +- Scrolling is represented as `scrollTop` state and applied as negative `marginTop` on the inner + content box while the outer box clips with `overflowY:"hidden"`. +- SGR mouse mode is owned by runtime `useMouseInput`; `ScrollBox` only consumes wheel events and + updates its app-managed scroll offset. diff --git a/.agents/docs/ink-divergences.md b/.agents/docs/ink-divergences.md index 5631aa5..340c6ec 100644 --- a/.agents/docs/ink-divergences.md +++ b/.agents/docs/ink-divergences.md @@ -763,7 +763,7 @@ different runtime behavior, ownership rule, or out-of-contract handling. - **Ink:** the hooks read a React context whose **default** value is a no-op object, so calling e.g. `useStdin()` outside an Ink tree returns inert defaults without an error. - **vue-tui:** `useApp`, `useStdout`, `useStderr`, `useStdin`, `useWindowSize`, - `useFocus`, `useFocusManager`, `useInput`, `usePaste`, `useCursor`, and + `useFocus`, `useFocusManager`, `useInput`, `useMouseInput`, `usePaste`, `useCursor`, and `useIsScreenReaderEnabled` **throw** when their context is absent ("... must be called inside a vue-tui render tree"). `useBoxMetrics` and `useAnimation` do **not** throw: they fall back. `useBoxMetrics` reports zero metrics, and `useAnimation` drives a diff --git a/README.md b/README.md index 274532e..88bcaec 100644 --- a/README.md +++ b/README.md @@ -129,27 +129,29 @@ createApp(App).mount(); The [`@vue-tui/components`](./packages/components) package adds higher-level components composed from the runtime primitives — published separately from the core. -| Component | Description | -| ------------------------------------ | ------------------------------------------------------------------------------------------ | -| [``](./packages/components) | Animated loading spinner — built-in `dots`/`line` presets or custom frames, optional label | +| Component | Description | +| -------------------------------------- | ------------------------------------------------------------------------------------------ | +| [``](./packages/components) | Bounded scroll viewport with mouse-wheel scrolling and sticky-bottom behavior | +| [``](./packages/components) | Animated loading spinner — built-in `dots`/`line` presets or custom frames, optional label | ## Composables (Hooks) -| Composable | Description | -| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `useInput(handler, opts?)` | Handle keyboard input — receives `(input, key)` with modifier and arrow key detection | -| `usePaste(handler, opts?)` | Handle bracketed paste — receives the pasted `text` as a single event | -| `useFocus(opts?)` | Component-level focus — returns `{ isFocused, focus }` | -| `useFocusManager()` | App-level focus control — `focusNext()`, `focusPrevious()`, `focus(id)` | -| `useApp()` | App lifecycle — `{ exit(error?), waitUntilRenderFlush() }` | -| `useWindowSize()` | Reactive terminal dimensions — `{ columns, rows }` | -| `useStdin()` | Access stdin stream and raw mode control | -| `useStdout()` | Write directly to stdout | -| `useStderr()` | Write directly to stderr | -| `useBoxMetrics(ref)` | Measure a `` via a template ref — reactive `{ width, height, left, top, hasMeasured }` (or `measureElement(el)` for a one-off `{ width, height }` read) | -| `useCursor()` | Control the terminal cursor — `setCursorPosition(pos)` in output coordinates | -| `useIsScreenReaderEnabled()` | Whether a screen reader is active — returns a boolean for adapting accessible output | -| `useAnimation(opts?)` | Frame-based animation driver — reactive `{ frame, time, delta }` + `reset()` | +| Composable | Description | +| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `useInput(handler, opts?)` | Handle keyboard input — receives `(input, key)` with modifier and arrow key detection | +| `useMouseInput(handler, opts?)` | Handle terminal mouse input — currently SGR wheel events with ref-counted mouse-mode ownership | +| `usePaste(handler, opts?)` | Handle bracketed paste — receives the pasted `text` as a single event | +| `useFocus(opts?)` | Component-level focus — returns `{ isFocused, focus }` | +| `useFocusManager()` | App-level focus control — `focusNext()`, `focusPrevious()`, `focus(id)` | +| `useApp()` | App lifecycle — `{ exit(error?), waitUntilRenderFlush() }` | +| `useWindowSize()` | Reactive terminal dimensions — `{ columns, rows }` | +| `useStdin()` | Access stdin stream and raw mode control | +| `useStdout()` | Write directly to stdout | +| `useStderr()` | Write directly to stderr | +| `useBoxMetrics(ref)` | Measure a `` via a template ref — reactive `{ width, height, left, top, hasMeasured }` (or `measureElement(el)` for a one-off `{ width, height }` read) | +| `useCursor()` | Control the terminal cursor — `setCursorPosition(pos)` in output coordinates | +| `useIsScreenReaderEnabled()` | Whether a screen reader is active — returns a boolean for adapting accessible output | +| `useAnimation(opts?)` | Frame-based animation driver — reactive `{ frame, time, delta }` + `reset()` | ## Testing diff --git a/examples/scroll-box/index.html b/examples/scroll-box/index.html new file mode 100644 index 0000000..5de29f1 --- /dev/null +++ b/examples/scroll-box/index.html @@ -0,0 +1,7 @@ + + + + + + + diff --git a/examples/scroll-box/package.json b/examples/scroll-box/package.json new file mode 100644 index 0000000..a608c7a --- /dev/null +++ b/examples/scroll-box/package.json @@ -0,0 +1,22 @@ +{ + "name": "@vue-tui/example-scroll-box", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite build && node dist/main.js" + }, + "dependencies": { + "@vue-tui/components": "workspace:*", + "@vue-tui/runtime": "workspace:*", + "vue": "^3.4.0" + }, + "devDependencies": { + "@types/node": "catalog:", + "@vitejs/plugin-vue": "^6", + "@vue-tui/vite": "workspace:*", + "vite": "catalog:" + } +} diff --git a/examples/scroll-box/src/app.vue b/examples/scroll-box/src/app.vue new file mode 100644 index 0000000..dce13a5 --- /dev/null +++ b/examples/scroll-box/src/app.vue @@ -0,0 +1,51 @@ + + + diff --git a/examples/scroll-box/src/main.ts b/examples/scroll-box/src/main.ts new file mode 100644 index 0000000..ea08709 --- /dev/null +++ b/examples/scroll-box/src/main.ts @@ -0,0 +1,4 @@ +import { createApp } from "@vue-tui/runtime"; +import App from "./app.vue"; + +createApp(App).mount(); diff --git a/examples/scroll-box/src/vue-shim.d.ts b/examples/scroll-box/src/vue-shim.d.ts new file mode 100644 index 0000000..078b2fb --- /dev/null +++ b/examples/scroll-box/src/vue-shim.d.ts @@ -0,0 +1,5 @@ +declare module "*.vue" { + import type { Component } from "vue"; + const component: Component; + export default component; +} diff --git a/examples/scroll-box/tsconfig.json b/examples/scroll-box/tsconfig.json new file mode 100644 index 0000000..6d8758b --- /dev/null +++ b/examples/scroll-box/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true, + "jsx": "preserve" + }, + "include": ["src/**/*.ts", "src/**/*.vue"] +} diff --git a/examples/scroll-box/vite.config.ts b/examples/scroll-box/vite.config.ts new file mode 100644 index 0000000..2bdd8fc --- /dev/null +++ b/examples/scroll-box/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vite"; +import vue from "@vitejs/plugin-vue"; +import { vueTui } from "@vue-tui/vite"; + +export default defineConfig({ + plugins: [vue(), vueTui()], +}); diff --git a/packages/components/README.md b/packages/components/README.md index 91eda2d..3d5ccd8 100644 --- a/packages/components/README.md +++ b/packages/components/README.md @@ -3,7 +3,7 @@ High-level Vue components for [vue-tui](https://github.com/vuejs-ai/vue-tui), composed from `@vue-tui/runtime` primitives. -> Early days — the component set is small and growing. Currently: `Spinner`. +> Early days — the component set is small and growing. Currently: `ScrollBox`, `Spinner`. ## Install @@ -36,6 +36,32 @@ import { Spinner } from "@vue-tui/components"; | `color` | `string` | — | chalk color for the spinner glyph | | `label` | `string` | — | text shown next to the spinner | +## ScrollBox + +A bounded scroll viewport for long, updating content. Opt into mouse-wheel scrolling with `wheel`, +and sticky-bottom behavior keeps streaming output at the bottom only until the user scrolls up. + +```vue + + + +``` + +### Props + +| prop | type | default | description | +| --------------- | --------- | ------- | ------------------------------------------------------ | +| `wheel` | `boolean` | `false` | enable mouse-wheel scrolling (turns on mouse tracking) | +| `keyboard` | `boolean` | `false` | enable PageUp/PageDown scrolling | +| `linesPerWheel` | `number` | `3` | lines to scroll per wheel event | + ## License MIT diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index 37a5649..6b02174 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -1,2 +1,4 @@ -export { default as Spinner } from "./spinner.vue"; -export type { SpinnerProps } from "./spinner-props.ts"; +export { default as ScrollBox } from "./scroll-box/scroll-box.vue"; +export type { ScrollBoxProps } from "./scroll-box/scroll-box-props.ts"; +export { default as Spinner } from "./spinner/spinner.vue"; +export type { SpinnerProps } from "./spinner/spinner-props.ts"; diff --git a/packages/components/src/scroll-box/scroll-box-props.ts b/packages/components/src/scroll-box/scroll-box-props.ts new file mode 100644 index 0000000..2e6a377 --- /dev/null +++ b/packages/components/src/scroll-box/scroll-box-props.ts @@ -0,0 +1,17 @@ +import type { ExtractPublicPropTypes } from "vue"; + +export const scrollBoxProps = { + /** + * Enable mouse-wheel scrolling. Off by default: enabling terminal mouse + * tracking suppresses the terminal's native text selection window-wide + * (users bypass with Shift). + */ + wheel: Boolean, + /** Enable keyboard scrolling (PageUp / PageDown). */ + keyboard: Boolean, + /** Lines to scroll per wheel event. */ + linesPerWheel: { type: Number, default: 3 }, +}; + +/** Props accepted by ``. */ +export type ScrollBoxProps = ExtractPublicPropTypes; diff --git a/packages/components/src/scroll-box/scroll-box-render-to-string.sequential.test.tsx b/packages/components/src/scroll-box/scroll-box-render-to-string.sequential.test.tsx new file mode 100644 index 0000000..a6bb6ed --- /dev/null +++ b/packages/components/src/scroll-box/scroll-box-render-to-string.sequential.test.tsx @@ -0,0 +1,36 @@ +// This test patches process.stdout, a process-global stream, so it must stay sequential. +import { defineComponent } from "vue"; +import { expect, test } from "vite-plus/test"; +import { renderToString, Text } from "@vue-tui/runtime"; +import { ScrollBox } from "../index.ts"; + +test("ScrollBox does not enable mouse mode during renderToString", () => { + const writes: string[] = []; + const originalWrite = Reflect.get(process.stdout, "write") as typeof process.stdout.write; + const originalIsTTY = process.stdout.isTTY; + Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true }); + process.stdout.write = ((chunk: string | Uint8Array) => { + writes.push(String(chunk)); + return true; + }) as typeof process.stdout.write; + + try { + const App = defineComponent(() => { + return () => ( + + content + + ); + }); + + expect(renderToString(App)).toContain("content"); + expect(writes).not.toContain("\x1b[?1000h\x1b[?1006h"); + expect(writes).not.toContain("\x1b[?1000l\x1b[?1006l"); + } finally { + process.stdout.write = originalWrite; + Object.defineProperty(process.stdout, "isTTY", { + configurable: true, + value: originalIsTTY, + }); + } +}); diff --git a/packages/components/src/scroll-box/scroll-box.test.tsx b/packages/components/src/scroll-box/scroll-box.test.tsx new file mode 100644 index 0000000..fd77d62 --- /dev/null +++ b/packages/components/src/scroll-box/scroll-box.test.tsx @@ -0,0 +1,179 @@ +import { PassThrough } from "node:stream"; +import { defineComponent, nextTick, shallowRef } from "vue"; +import { expect, test } from "vite-plus/test"; +import { render } from "@vue-tui/testing"; +import { Box, Text, createApp } from "@vue-tui/runtime"; +import { ScrollBox } from "../index.ts"; + +const WHEEL_UP = "\x1b[<64;1;1M"; +// Escape sequences parseKeypress maps to pageUp/pageDown/home/end +// (see packages/runtime/src/io/parse-keypress.ts and the use-input tests). +const PAGE_UP = "\x1b[5~"; +const PAGE_DOWN = "\x1b[6~"; + +function messages(count: number): string[] { + return Array.from({ length: count }, (_, index) => `message ${index}`); +} + +function makeFakeWritable(options: { columns?: number; rows?: number } = {}): NodeJS.WriteStream { + const stream = new PassThrough() as unknown as NodeJS.WriteStream; + Object.assign(stream, { + columns: options.columns ?? 100, + rows: options.rows ?? 100, + isTTY: true, + }); + return stream; +} + +function makeNonTtyStdin(setRawModeCalls?: boolean[]): NodeJS.ReadStream { + const stream = new PassThrough() as unknown as NodeJS.ReadStream; + Object.assign(stream, { + isTTY: false, + setRawMode(this: NodeJS.ReadStream, mode: boolean) { + setRawModeCalls?.push(mode); + return this; + }, + setEncoding(this: NodeJS.ReadStream) { + return this; + }, + ref() {}, + unref() {}, + }); + return stream; +} + +async function flushAppErrors(): Promise { + await nextTick(); + await nextTick(); + await Promise.resolve(); + await Promise.resolve(); + await new Promise((resolve) => setImmediate(resolve)); + await Promise.resolve(); +} + +test("ScrollBox follows the bottom while sticky", async () => { + const items = shallowRef(messages(8)); + const App = defineComponent(() => { + return () => ( + + + {items.value.map((item) => ( + {item} + ))} + + + ); + }); + + const result = await render(App, { columns: 40, rows: 8 }); + try { + expect(result.lastFrame()).toContain("message 7"); + + items.value = [...items.value, "streaming latest"]; + await nextTick(); + await result.waitUntilRenderFlush(); + + expect(result.lastFrame()).toContain("streaming latest"); + } finally { + result.unmount(); + } +}); + +test("ScrollBox keeps the viewport detached after mouse wheel scroll while content grows", async () => { + const items = shallowRef(messages(12)); + const App = defineComponent(() => { + return () => ( + + + {items.value.map((item) => ( + {item} + ))} + + + ); + }); + + const result = await render(App, { columns: 40, rows: 8 }); + try { + expect(result.lastFrame()).toContain("message 11"); + + await result.stdin.write(WHEEL_UP); + await result.waitUntilRenderFlush(); + const scrolledFrame = result.lastFrame()!; + const anchor = scrolledFrame.match(/message \d+/)?.[0]; + expect(anchor).toBeDefined(); + expect(scrolledFrame).not.toContain("message 11"); + + items.value = [...items.value, "streaming latest"]; + await nextTick(); + await result.waitUntilRenderFlush(); + + const updatedFrame = result.lastFrame()!; + expect(updatedFrame).toContain(anchor); + expect(updatedFrame).not.toContain("streaming latest"); + } finally { + result.unmount(); + } +}); + +test("ScrollBox scrolls with keyboard paging", async () => { + const App = defineComponent(() => { + return () => ( + + + {messages(12).map((item) => ( + {item} + ))} + + + ); + }); + + const result = await render(App, { columns: 40, rows: 8 }); + try { + // Sticky at the bottom: the last message is visible. + expect(result.lastFrame()).toContain("message 11"); + + // PageUp scrolls up, so the last message leaves the viewport. + await result.stdin.write(PAGE_UP); + await result.waitUntilRenderFlush(); + expect(result.lastFrame()).not.toContain("message 11"); + + // PageDown scrolls back down to the bottom. + await result.stdin.write(PAGE_DOWN); + await result.waitUntilRenderFlush(); + expect(result.lastFrame()).toContain("message 11"); + } finally { + result.unmount(); + } +}); + +test("ScrollBox does not acquire raw mode when stdin does not support it", async () => { + const App = defineComponent(() => { + return () => ( + + content + + ); + }); + const app = createApp(App); + const stdout = makeFakeWritable(); + const stderr = makeFakeWritable(); + const setRawModeCalls: boolean[] = []; + const stdin = makeNonTtyStdin(setRawModeCalls); + let error: Error | undefined; + + app.waitUntilExit().catch((caught) => { + error = caught as Error; + }); + + try { + app.mount({ stdout, stdin, stderr, debug: true, exitOnCtrlC: false, rawMode: "auto" }); + await flushAppErrors(); + + expect(error).toBeUndefined(); + expect(setRawModeCalls).toEqual([]); + } finally { + app.unmount(); + } +}); diff --git a/packages/components/src/scroll-box/scroll-box.vue b/packages/components/src/scroll-box/scroll-box.vue new file mode 100644 index 0000000..c1f6248 --- /dev/null +++ b/packages/components/src/scroll-box/scroll-box.vue @@ -0,0 +1,95 @@ + + + diff --git a/packages/components/src/spinner-props.ts b/packages/components/src/spinner/spinner-props.ts similarity index 100% rename from packages/components/src/spinner-props.ts rename to packages/components/src/spinner/spinner-props.ts diff --git a/packages/components/src/spinner.test.tsx b/packages/components/src/spinner/spinner.test.tsx similarity index 100% rename from packages/components/src/spinner.test.tsx rename to packages/components/src/spinner/spinner.test.tsx diff --git a/packages/components/src/spinner.vue b/packages/components/src/spinner/spinner.vue similarity index 100% rename from packages/components/src/spinner.vue rename to packages/components/src/spinner/spinner.vue diff --git a/packages/components/src/spinners.test.tsx b/packages/components/src/spinner/spinners.test.tsx similarity index 100% rename from packages/components/src/spinners.test.tsx rename to packages/components/src/spinner/spinners.test.tsx diff --git a/packages/components/src/spinners.ts b/packages/components/src/spinner/spinners.ts similarity index 100% rename from packages/components/src/spinners.ts rename to packages/components/src/spinner/spinners.ts diff --git a/packages/runtime-tests/integration/composables/mouse-disable-signal-exit.sequential.test.tsx b/packages/runtime-tests/integration/composables/mouse-disable-signal-exit.sequential.test.tsx new file mode 100644 index 0000000..58d6952 --- /dev/null +++ b/packages/runtime-tests/integration/composables/mouse-disable-signal-exit.sequential.test.tsx @@ -0,0 +1,152 @@ +// Sequential: drives the runtime's signal-exit teardown by emitting a real +// process signal (`process.emit("SIGINT")`). That goes through signal-exit's +// patched `process.emit`, which uses a PROCESS-GLOBAL singleton emitter that +// fires its `exit` handlers exactly ONCE per process — so this must not race a +// concurrent sibling that also mounts an interactive app and registers its own +// onExit handler. We also spy `process.kill` (process-global) to neutralize +// signal-exit's re-raise so the worker survives. Grouped here to document the +// global-state constraint, per CLAUDE.md. +// +// Bug (same failure class as cursor / alt-screen / kitty / bracketed-paste): on +// the signal-exit path signal-exit re-raises the signal IMMEDIATELY after the +// callback returns (`{alwaysLast:false}`), so a buffered ASYNC `stdout.write` of +// a terminal-restore escape can be lost before the process dies. `teardown(true)` +// already writes show-cursor / leave-alt-screen / disable-kitty SYNCHRONOUSLY +// (`fs.writeSync(fd, …)`). The SGR mouse-disable escape `\x1b[?1000l\x1b[?1006l` +// (control sequences: CSI ? 1000 l and CSI ? 1006 l) must ALSO go out on the +// SYNCHRONOUS path — otherwise, when dropped, the terminal stays in mouse +// tracking mode and keeps suppressing native text selection window-wide. +import { PassThrough } from "node:stream"; +import * as fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { defineComponent } from "vue"; +import { describe, test, expect, vi } from "vite-plus/test"; +import { createApp, Text, useMouseInput } from "@vue-tui/runtime"; + +const MOUSE_ON = "\x1b[?1000h\x1b[?1006h"; +const MOUSE_OFF = "\x1b[?1000l\x1b[?1006l"; // CSI ? 1000 l + CSI ? 1006 l — SGR mouse OFF +const SHOW_CURSOR = "\x1b[?25h"; + +function makeFakeStdin(): NodeJS.ReadStream { + const s = new PassThrough() as unknown as NodeJS.ReadStream; + Object.assign(s, { + isTTY: true, + setRawMode() { + return s; + }, + setEncoding() { + return s; + }, + ref() {}, + unref() {}, + }); + return s; +} + +// A TTY-ish stdout whose `.fd` is a REAL temp-file fd. This mirrors a real +// terminal — where the stream AND its numeric fd both point at the same tty — +// but lets the test SEPARATE the two write mechanisms the runtime uses on +// teardown: +// • async → `stdout.write(...)` → recorded in `asyncWrites` +// • sync → `fs.writeSync(stream.fd,…)` → lands in the temp file +// So a restore escape that appears in the temp file came through the SYNCHRONOUS +// path (the one that survives signal-exit's immediate re-raise); one that only +// appears in `asyncWrites` is the lost-on-signal async write. +function makeFdBackedStdout(): { + stdout: NodeJS.WriteStream; + asyncWrites: string[]; + readSyncBytes: () => string; + cleanup: () => void; +} { + const filePath = path.join(os.tmpdir(), `vue-tui-sync-${process.pid}-${Date.now()}.bin`); + const fd = fs.openSync(filePath, "w+"); + + const inner = new PassThrough(); + const asyncWrites: string[] = []; + const realWrite = inner.write.bind(inner); + const stdout = inner as unknown as NodeJS.WriteStream; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (stdout as any).write = (data: any, ...rest: any[]) => { + asyncWrites.push(String(data)); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (realWrite as any)(String(data), ...rest); + }; + Object.assign(stdout, { columns: 80, rows: 24, isTTY: true, fd }); + + return { + stdout, + asyncWrites, + readSyncBytes: () => fs.readFileSync(filePath).toString(), + cleanup: () => { + fs.closeSync(fd); + fs.rmSync(filePath, { force: true }); + }, + }; +} + +const MouseApp = defineComponent(() => { + useMouseInput(() => {}); + return () => mouse; +}); + +describe("SGR mouse disable on signal exit", () => { + test("a signal-driven teardown writes the mouse-OFF escape SYNCHRONOUSLY (Finding A parity)", async () => { + const { stdout, asyncWrites, readSyncBytes, cleanup } = makeFdBackedStdout(); + const stdin = makeFakeStdin(); + + const app = createApp(MouseApp); + // interactive: true forces the signal-exit handler to register regardless of + // ambient CI/TTY detection (the resolved `interactive` flag gates it). + app.mount({ stdout, stdin, debug: false, exitOnCtrlC: false, interactive: true }); + + // Let useMouseInput's attach enable SGR mouse tracking (writes + // \x1b[?1000h\x1b[?1006h, async). + await new Promise((r) => setTimeout(r, 60)); + expect(asyncWrites.join("")).toContain(MOUSE_ON); + + // Drive the SIGNAL teardown path: process.emit goes through signal-exit's + // patched emit, which runs the runtime's onExit(() => teardown(true)). + // Neutralize the subsequent re-raise so the worker survives. + const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true); + try { + process.emit("SIGINT", "SIGINT"); + } finally { + killSpy.mockRestore(); + } + + const syncBytes = readSyncBytes(); + cleanup(); + + // Sanity: the sync restore path ran at all (show-cursor flushed synchronously). + expect(syncBytes, "show-cursor must flush synchronously on signal").toContain(SHOW_CURSOR); + // The bug: the mouse-OFF escape must ALSO go through the synchronous path, + // not only the async stdout.write that signal-exit's re-raise can drop. + expect( + syncBytes, + "\\x1b[?1000l\\x1b[?1006l must be written via fs.writeSync on the signal-exit path", + ).toContain(MOUSE_OFF); + }); + + test("the normal (non-signal) unmount still disables SGR mouse asynchronously", async () => { + const { stdout, asyncWrites, readSyncBytes, cleanup } = makeFdBackedStdout(); + const stdin = makeFakeStdin(); + + const app = createApp(MouseApp); + app.mount({ stdout, stdin, debug: false, exitOnCtrlC: false, interactive: true }); + + await new Promise((r) => setTimeout(r, 60)); + expect(asyncWrites.join("")).toContain(MOUSE_ON); + + // Plain unmount() is the async teardown path — no behavior change here. + app.unmount(); + await new Promise((r) => setTimeout(r, 10)); + + const syncBytes = readSyncBytes(); + cleanup(); + + // Mouse-OFF still emitted, via the async stream.write (not the sync fd path). + expect(asyncWrites.some((w) => w.includes(MOUSE_OFF))).toBe(true); + expect(syncBytes).not.toContain(MOUSE_OFF); + }); +}); diff --git a/packages/runtime-tests/integration/composables/use-mouse-input.test.tsx b/packages/runtime-tests/integration/composables/use-mouse-input.test.tsx new file mode 100644 index 0000000..7245949 --- /dev/null +++ b/packages/runtime-tests/integration/composables/use-mouse-input.test.tsx @@ -0,0 +1,200 @@ +import { defineComponent, nextTick, shallowRef } from "vue"; +import { expect, test } from "vite-plus/test"; +import { + Box, + Text, + createApp, + useInput, + useMouseInput, + type MouseInputEvent, +} from "@vue-tui/runtime"; +import { captureWrites, makeFakeStdin, makeFakeWritable } from "../lifecycle/test-streams.ts"; + +const ENABLE_SGR_MOUSE = "\x1b[?1000h\x1b[?1006h"; +const DISABLE_SGR_MOUSE = "\x1b[?1000l\x1b[?1006l"; + +async function settle() { + await nextTick(); + await nextTick(); + await Promise.resolve(); +} + +function countOccurrences(haystack: string, needle: string): number { + return haystack.split(needle).length - 1; +} + +test("useMouseInput enables SGR mouse mode and emits wheel events", async () => { + const events: MouseInputEvent[] = []; + const App = defineComponent(() => { + useMouseInput((event) => { + events.push(event); + }); + return () => listening; + }); + + const app = createApp(App); + const stdout = makeFakeWritable(); + const stderr = makeFakeWritable(); + const { stream: stdin } = makeFakeStdin(); + const writes = captureWrites(stdout); + + app.mount({ stdout, stderr, stdin, debug: true, exitOnCtrlC: false, rawMode: "auto" }); + await settle(); + + expect(writes.join("")).toContain(ENABLE_SGR_MOUSE); + + stdin.emit("data", "\x1b[<68;3;4M\x1b[<81;5;6M"); + await settle(); + + expect(events).toEqual([ + { type: "wheel", direction: "up", x: 3, y: 4, shift: true, meta: false, ctrl: false }, + { type: "wheel", direction: "down", x: 5, y: 6, shift: false, meta: false, ctrl: true }, + ]); + + app.unmount(); + await settle(); + + expect(writes.join("")).toContain(DISABLE_SGR_MOUSE); +}); + +test("useMouseInput keeps SGR mouse mode enabled until the last consumer releases it", async () => { + const showA = shallowRef(true); + const showB = shallowRef(true); + + const A = defineComponent(() => { + useMouseInput(() => {}); + return () => a; + }); + const B = defineComponent(() => { + useMouseInput(() => {}); + return () => b; + }); + const App = defineComponent(() => { + return () => ( + + {showA.value ? : null} + {showB.value ? : null} + + ); + }); + + const app = createApp(App); + const stdout = makeFakeWritable(); + const stderr = makeFakeWritable(); + const { stream: stdin } = makeFakeStdin(); + const writes = captureWrites(stdout); + + app.mount({ stdout, stderr, stdin, debug: true, exitOnCtrlC: false, rawMode: "auto" }); + await settle(); + + expect(countOccurrences(writes.join(""), ENABLE_SGR_MOUSE)).toBe(1); + expect(countOccurrences(writes.join(""), DISABLE_SGR_MOUSE)).toBe(0); + + showA.value = false; + await settle(); + + expect(countOccurrences(writes.join(""), ENABLE_SGR_MOUSE)).toBe(1); + expect(countOccurrences(writes.join(""), DISABLE_SGR_MOUSE)).toBe(0); + + showB.value = false; + await settle(); + + expect(countOccurrences(writes.join(""), ENABLE_SGR_MOUSE)).toBe(1); + expect(countOccurrences(writes.join(""), DISABLE_SGR_MOUSE)).toBe(1); + + app.unmount(); +}); + +test("useMouseInput respects isActive", async () => { + const active = shallowRef(false); + const events: MouseInputEvent[] = []; + const App = defineComponent(() => { + useMouseInput((event) => events.push(event), { isActive: active }); + return () => listening; + }); + + const app = createApp(App); + const stdout = makeFakeWritable(); + const stderr = makeFakeWritable(); + const { stream: stdin } = makeFakeStdin(); + const writes = captureWrites(stdout); + + app.mount({ stdout, stderr, stdin, debug: true, exitOnCtrlC: false, rawMode: "auto" }); + await settle(); + + expect(writes.join("")).not.toContain(ENABLE_SGR_MOUSE); + + stdin.emit("data", "\x1b[<64;1;1M"); + await settle(); + expect(events).toEqual([]); + + active.value = true; + await settle(); + expect(writes.join("")).toContain(ENABLE_SGR_MOUSE); + + stdin.emit("data", "\x1b[<65;1;1M"); + await settle(); + expect(events).toEqual([ + { type: "wheel", direction: "down", x: 1, y: 1, shift: false, meta: false, ctrl: false }, + ]); + + active.value = false; + await settle(); + expect(writes.join("")).toContain(DISABLE_SGR_MOUSE); + + app.unmount(); +}); + +test("useMouseInput consumes unsupported SGR mouse events before keyboard input", async () => { + const mouseEvents: MouseInputEvent[] = []; + const keyboardEvents: string[] = []; + const App = defineComponent(() => { + useMouseInput((event) => mouseEvents.push(event)); + useInput((input) => keyboardEvents.push(input)); + return () => listening; + }); + + const app = createApp(App); + const stdout = makeFakeWritable(); + const stderr = makeFakeWritable(); + const { stream: stdin } = makeFakeStdin(); + + app.mount({ stdout, stderr, stdin, debug: true, exitOnCtrlC: false, rawMode: "auto" }); + await settle(); + + stdin.emit("data", "\x1b[<0;10;5M\x1b[<0;10;5m\x1b[<64;10;5M"); + await settle(); + + expect(mouseEvents).toEqual([ + { type: "wheel", direction: "up", x: 10, y: 5, shift: false, meta: false, ctrl: false }, + ]); + expect(keyboardEvents).toEqual([]); + + app.unmount(); +}); + +test("useMouseInput does not consume bare CSI-like text", async () => { + const mouseEvents: MouseInputEvent[] = []; + const keyboardEvents: string[] = []; + const App = defineComponent(() => { + useMouseInput((event) => mouseEvents.push(event)); + useInput((input) => keyboardEvents.push(input)); + return () => listening; + }); + + const app = createApp(App); + const stdout = makeFakeWritable(); + const stderr = makeFakeWritable(); + const { stream: stdin } = makeFakeStdin(); + + app.mount({ stdout, stderr, stdin, debug: true, exitOnCtrlC: false, rawMode: "auto" }); + await settle(); + + stdin.emit("data", "[<64;10;5M"); + await settle(); + + expect(mouseEvents).toEqual([]); + expect(keyboardEvents).toEqual(["[<64;10;5M"]); + + app.unmount(); +}); diff --git a/packages/runtime-tests/integration/public-api.test.ts b/packages/runtime-tests/integration/public-api.test.ts index 6fa8c57..a931e1e 100644 --- a/packages/runtime-tests/integration/public-api.test.ts +++ b/packages/runtime-tests/integration/public-api.test.ts @@ -27,6 +27,7 @@ const PUBLIC_VALUE_EXPORTS = [ "useFocusManager", "useInput", "useIsScreenReaderEnabled", + "useMouseInput", "usePaste", "useStderr", "useStdin", diff --git a/packages/runtime-tests/integration/public-types.test-d.ts b/packages/runtime-tests/integration/public-types.test-d.ts index 2e022ea..851add6 100644 --- a/packages/runtime-tests/integration/public-types.test-d.ts +++ b/packages/runtime-tests/integration/public-types.test-d.ts @@ -13,7 +13,15 @@ // pick it up as a runtime test (its include is `*.test.ts`), while tsc still checks it. import { expectTypeOf } from "vite-plus/test"; import { shallowRef } from "vue"; -import { useApp, useInput, usePaste, useStdin, useStdout, useStderr } from "@vue-tui/runtime"; +import { + useApp, + useInput, + useMouseInput, + usePaste, + useStdin, + useStdout, + useStderr, +} from "@vue-tui/runtime"; import type { BoxProps, BoxLayoutStyle, @@ -27,6 +35,7 @@ import type { NewlineProps, SpacerProps, Key, + MouseInputEvent, WindowSize, CursorPosition, UseAppReturn, @@ -74,8 +83,9 @@ expectTypeOf().toEqualTypeOf<{ x: number; y: number }>(); // Composable return types: named per VueUse's `UseXReturn` convention, and shape-locked to // Ink's public hook returns. useStdin() in particular must expose ONLY Ink's `PublicProps` // (stdin/setRawMode/isRawModeSupported) — never the internal raw-mode/paste controller -// (acquireRawMode/releaseRawMode/setBracketedPasteMode/internal_*), which the framework's -// own composables reach via inject(StdinContextKey). +// (acquireRawMode/releaseRawMode/setBracketedPasteMode/acquireSgrMouseMode/ +// releaseSgrMouseMode/internal_*), which the framework's own composables reach via +// inject(StdinContextKey). expectTypeOf().toEqualTypeOf<{ readonly stdin: NodeJS.ReadStream; readonly setRawMode: (mode: boolean) => void; @@ -109,3 +119,15 @@ expectTypeOf(inputHandler).toMatchTypeOf[0]>(); const pasteHandler = shallowRef((_text: string) => {}); expectTypeOf(pasteHandler).toMatchTypeOf[0]>(); + +expectTypeOf().toEqualTypeOf<{ + readonly type: "wheel"; + readonly direction: "up" | "down"; + readonly x: number; + readonly y: number; + readonly shift: boolean; + readonly meta: boolean; + readonly ctrl: boolean; +}>(); +const mouseHandler = shallowRef((_event: MouseInputEvent) => {}); +expectTypeOf(mouseHandler).toMatchTypeOf[0]>(); diff --git a/packages/runtime/README.md b/packages/runtime/README.md index fa49ba8..29589bc 100644 --- a/packages/runtime/README.md +++ b/packages/runtime/README.md @@ -68,22 +68,23 @@ useInput((input) => { ## Composables -| Composable | Description | -| ---------------------------- | -------------------------------------------------------------------------------------------- | -| `useInput(handler, opts?)` | Keyboard input — `(input, key)` with modifier and arrow key detection | -| `useFocus(opts?)` | Component-level focus — returns `{ isFocused, focus }` | -| `useFocusManager()` | App-level focus — `focusNext()`, `focusPrevious()`, `focus(id)` | -| `useApp()` | App lifecycle — `{ exit(error?), waitUntilRenderFlush() }` | -| `useWindowSize()` | Reactive terminal dimensions — `{ columns, rows }` | -| `useAnimation(opts?)` | Frame-based animation loop — returns `{ frame, time, delta, reset }` | -| `useBoxMetrics(ref)` | Reactive layout metrics — `{ width, height, left, top, hasMeasured }` | -| `measureElement(node)` | Imperative read of computed `{ width, height }` from a yoga node | -| `useCursor()` | Position the terminal cursor — returns `setCursorPosition(pos)`; pass `undefined` to hide it | -| `usePaste(handler, opts?)` | Handle clipboard paste events | -| `useStdin()` | Access stdin stream and raw mode control | -| `useStdout()` | Write directly to stdout | -| `useStderr()` | Write directly to stderr | -| `useIsScreenReaderEnabled()` | Reactive `boolean` — whether screen-reader / accessibility mode is active | +| Composable | Description | +| ------------------------------- | -------------------------------------------------------------------------------------------- | +| `useInput(handler, opts?)` | Keyboard input — `(input, key)` with modifier and arrow key detection | +| `useMouseInput(handler, opts?)` | Terminal mouse input — currently SGR wheel events with ref-counted mouse-mode ownership | +| `useFocus(opts?)` | Component-level focus — returns `{ isFocused, focus }` | +| `useFocusManager()` | App-level focus — `focusNext()`, `focusPrevious()`, `focus(id)` | +| `useApp()` | App lifecycle — `{ exit(error?), waitUntilRenderFlush() }` | +| `useWindowSize()` | Reactive terminal dimensions — `{ columns, rows }` | +| `useAnimation(opts?)` | Frame-based animation loop — returns `{ frame, time, delta, reset }` | +| `useBoxMetrics(ref)` | Reactive layout metrics — `{ width, height, left, top, hasMeasured }` | +| `measureElement(node)` | Imperative read of computed `{ width, height }` from a yoga node | +| `useCursor()` | Position the terminal cursor — returns `setCursorPosition(pos)`; pass `undefined` to hide it | +| `usePaste(handler, opts?)` | Handle clipboard paste events | +| `useStdin()` | Access stdin stream and raw mode control | +| `useStdout()` | Write directly to stdout | +| `useStderr()` | Write directly to stderr | +| `useIsScreenReaderEnabled()` | Reactive `boolean` — whether screen-reader / accessibility mode is active | ## App Lifecycle diff --git a/packages/runtime/src/composables/useMouseInput.ts b/packages/runtime/src/composables/useMouseInput.ts new file mode 100644 index 0000000..c49bfca --- /dev/null +++ b/packages/runtime/src/composables/useMouseInput.ts @@ -0,0 +1,71 @@ +import { + inject, + onScopeDispose, + toValue, + unref, + watch, + type MaybeRef, + type MaybeRefOrGetter, +} from "vue"; +import { StdinContextKey } from "../context.ts"; +import type { MouseInputEvent } from "../io/parse-mouse.ts"; + +export type { MouseInputEvent } from "../io/parse-mouse.ts"; + +export interface UseMouseInputOptions { + isActive?: MaybeRefOrGetter; +} + +type MouseInputHandler = (event: MouseInputEvent) => void; + +export function useMouseInput( + handler: MaybeRef, + options: UseMouseInputOptions = {}, +): void { + const stdin = inject(StdinContextKey); + if (!stdin) throw new Error("useMouseInput() must be called inside a vue-tui render tree"); + + let attached = false; + let mouseModeToken: symbol | undefined; + + function listener(event: MouseInputEvent) { + unref(handler)(event); + } + + function attach() { + if (attached) return; + stdin!.acquireRawMode(); + try { + mouseModeToken = stdin!.acquireSgrMouseMode(); + stdin!.internal_eventEmitter.on("mouse", listener); + attached = true; + } catch (error) { + mouseModeToken = undefined; + stdin!.releaseRawMode(); + throw error; + } + } + + function detach() { + if (!attached) return; + attached = false; + stdin!.internal_eventEmitter.off("mouse", listener); + if (mouseModeToken) { + stdin!.releaseSgrMouseMode(mouseModeToken); + mouseModeToken = undefined; + } + stdin!.releaseRawMode(); + } + + const isActive = options.isActive ?? true; + watch( + () => toValue(isActive), + (value) => { + if (value) attach(); + else detach(); + }, + { immediate: true, flush: "sync" }, + ); + + onScopeDispose(detach); +} diff --git a/packages/runtime/src/context.ts b/packages/runtime/src/context.ts index e0ec062..bcab68b 100644 --- a/packages/runtime/src/context.ts +++ b/packages/runtime/src/context.ts @@ -50,6 +50,8 @@ export interface StdinContext { acquireRawMode: () => void; releaseRawMode: () => void; setBracketedPasteMode: (enabled: boolean) => void; + acquireSgrMouseMode: () => symbol; + releaseSgrMouseMode: (token: symbol) => void; } export const AppContextKey: InjectionKey = Symbol("vue-tui:app"); diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index e14c609..6250aa2 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -56,6 +56,11 @@ export { Transform, type TransformProps } from "./components/transform.ts"; export { useApp, type UseAppReturn } from "./composables/useApp.ts"; export { useInput, type Key, type UseInputOptions } from "./composables/useInput.ts"; +export { + useMouseInput, + type MouseInputEvent, + type UseMouseInputOptions, +} from "./composables/useMouseInput.ts"; export { usePaste, type UsePasteOptions } from "./composables/usePaste.ts"; export { useFocus, type UseFocusOptions } from "./composables/useFocus.ts"; export { useFocusManager } from "./composables/useFocusManager.ts"; diff --git a/packages/runtime/src/io/parse-mouse.ts b/packages/runtime/src/io/parse-mouse.ts new file mode 100644 index 0000000..7677ddc --- /dev/null +++ b/packages/runtime/src/io/parse-mouse.ts @@ -0,0 +1,59 @@ +const SGR_MOUSE_INPUT = /^\x1b\[<(\d+);(\d+);(\d+)([mM])$/; +const SHIFT_MASK = 4; +const META_MASK = 8; +const CTRL_MASK = 16; +const MODIFIER_MASK = SHIFT_MASK | META_MASK | CTRL_MASK; +const WHEEL_UP = 64; +const WHEEL_DOWN = 65; + +interface SgrMouseSequence { + readonly button: number; + readonly x: number; + readonly y: number; + readonly final: "M" | "m"; +} + +export interface MouseInputEvent { + readonly type: "wheel"; + readonly direction: "up" | "down"; + readonly x: number; + readonly y: number; + readonly shift: boolean; + readonly meta: boolean; + readonly ctrl: boolean; +} + +function parseSgrMouseSequence(input: string): SgrMouseSequence | undefined { + const match = SGR_MOUSE_INPUT.exec(input); + if (!match) return undefined; + + const button = Number(match[1]); + const x = Number(match[2]); + const y = Number(match[3]); + const final = match[4] as "M" | "m"; + if (x < 1 || y < 1) return undefined; + + return { button, x, y, final }; +} + +export function isSgrMouseInput(input: string): boolean { + return parseSgrMouseSequence(input) !== undefined; +} + +export function parseMouseInput(input: string): MouseInputEvent | undefined { + const sequence = parseSgrMouseSequence(input); + if (!sequence || sequence.final !== "M") return undefined; + + const baseButton = sequence.button & ~MODIFIER_MASK; + if (baseButton !== WHEEL_UP && baseButton !== WHEEL_DOWN) return undefined; + + return { + type: "wheel", + direction: baseButton === WHEEL_UP ? "up" : "down", + x: sequence.x, + y: sequence.y, + shift: Boolean(sequence.button & SHIFT_MASK), + meta: Boolean(sequence.button & META_MASK), + ctrl: Boolean(sequence.button & CTRL_MASK), + }; +} diff --git a/packages/runtime/src/render-to-string.ts b/packages/runtime/src/render-to-string.ts index 0d0b421..a7f5e0c 100644 --- a/packages/runtime/src/render-to-string.ts +++ b/packages/runtime/src/render-to-string.ts @@ -57,7 +57,7 @@ interface RenderToStringInternalOptions extends RenderToStringOptions { * any scenario where you need the rendered output as a string without * starting a persistent terminal application. * - * Terminal-specific composables (`useInput`, `useStdin`, `useStdout`, + * Terminal-specific composables (`useInput`, `useMouseInput`, `useStdin`, `useStdout`, * `useStderr`, `useApp`, `useFocus`, `useFocusManager`) return default * no-op values since there is no terminal session. They will not throw, but * they will not function as in a live terminal. @@ -296,5 +296,7 @@ function createNoOpStdinContext(): StdinContext { acquireRawMode: () => {}, releaseRawMode: () => {}, setBracketedPasteMode: () => {}, + acquireSgrMouseMode: () => Symbol("noop-sgr-mouse"), + releaseSgrMouseMode: () => {}, }; } diff --git a/packages/runtime/src/render.ts b/packages/runtime/src/render.ts index 57da8c1..c27cc77 100644 --- a/packages/runtime/src/render.ts +++ b/packages/runtime/src/render.ts @@ -18,6 +18,7 @@ import patchConsoleFn from "patch-console"; import ansiEscapes from "ansi-escapes"; import wrapAnsi from "wrap-ansi"; import { createInputParser, type InputEvent } from "./io/input-parser.ts"; +import { isSgrMouseInput, parseMouseInput } from "./io/parse-mouse.ts"; import { parseKeypress } from "./io/parse-keypress.ts"; import { createKittyKeyboardController, type KittyKeyboardOptions } from "./io/kitty-keyboard.ts"; import { createRoot, emitLayoutListeners, type TuiRoot, type TuiNode } from "./host/nodes.ts"; @@ -1787,20 +1788,46 @@ function createStdinController( let pendingFlushTimer: ReturnType | undefined; const FLUSH_DELAY = 20; // ms, matching Ink let bracketedPasteModeCount = 0; + const sgrMouseModeTokens = new Set(); // True once bracketed paste has been enabled at least once on this controller // (a usePaste mounted). Lets the signal-exit teardown re-issue a SYNCHRONOUS // paste-OFF even after Vue's unmount already ran the async disable and zeroed // bracketedPasteModeCount (see dispose(sync) below). let everEnabledBracketedPaste = false; + let everEnabledSgrMouse = false; - // Write the bracketed-paste-disable escape only when stdout can still take it. + const ENABLE_SGR_MOUSE = "\x1b[?1000h\x1b[?1006h"; + const DISABLE_SGR_MOUSE = "\x1b[?1000l\x1b[?1006l"; + + // Write terminal-mode escapes only when stdout can still take them. // `isTTY` stays cached-truthy after a stream is destroy()ed/end()ed, so gating - // the paste-OFF write on isTTY alone throws ERR_STREAM_DESTROYED on a teardown + // the restore write on isTTY alone throws ERR_STREAM_DESTROYED on a teardown // where stdout is already gone. Mirror Ink's `canWriteToStdout` guard // (App.tsx:620/633-635): isTTY AND `!destroyed && !writableEnded`. Matches the // render-level writeBestEffort helper, which isn't in this function's scope. - // + function canWriteTerminalMode(): boolean { + const stdout = appCtx.stdout; + return Boolean(stdout.isTTY) && !stdout.destroyed && !stdout.writableEnded; + } + + function writeTerminalMode(data: string, sync = false): void { + if (!canWriteTerminalMode()) return; + const stdout = appCtx.stdout; + if (sync) { + try { + // The base WriteStream type doesn't declare `fd`; tty/fs streams do. + const streamFd = (stdout as { fd?: number }).fd; + const fd = typeof streamFd === "number" ? streamFd : 1; + fsWriteSync(fd, data); + } catch { + // Best-effort restore during abrupt shutdown. + } + return; + } + stdout.write(data); + } + // sync (Finding A): on the signal-exit path signal-exit re-raises the signal // IMMEDIATELY after the teardown callback returns (`{alwaysLast:false}`), so a // buffered async `stdout.write` of `\x1b[?2004l` (CSI ? 2004 l — bracketed- @@ -1810,20 +1837,11 @@ function createStdinController( // / leave-alt-screen / disable-kitty restores. Falls back to fd 1 when the // stream has no numeric fd. function disableBracketedPaste(sync = false) { - const stdout = appCtx.stdout; - if (!stdout.isTTY || stdout.destroyed || stdout.writableEnded) return; - if (sync) { - try { - // The base WriteStream type doesn't declare `fd`; tty/fs streams do. - const streamFd = (stdout as { fd?: number }).fd; - const fd = typeof streamFd === "number" ? streamFd : 1; - fsWriteSync(fd, "\x1b[?2004l"); - } catch { - // Best-effort restore during abrupt shutdown. - } - return; - } - stdout.write("\x1b[?2004l"); + writeTerminalMode("\x1b[?2004l", sync); + } + + function disableSgrMouse(sync = false) { + writeTerminalMode(DISABLE_SGR_MOUSE, sync); } function clearPendingFlush() { @@ -1861,6 +1879,14 @@ function createStdinController( } } } + if (sgrMouseModeTokens.size > 0 && isSgrMouseInput(input)) { + const mouse = parseMouseInput(input); + if (mouse && emitter.listenerCount("mouse") > 0) { + emitter.emit("mouse", mouse); + } + return; + } + // Esc resets focus when focus is enabled if (input === "\x1b" && focusContext.enabled) { focusContext.blur(); @@ -2068,6 +2094,21 @@ function createStdinController( } } }, + acquireSgrMouseMode() { + const token = Symbol("sgr-mouse"); + if (sgrMouseModeTokens.size === 0) { + writeTerminalMode(ENABLE_SGR_MOUSE); + everEnabledSgrMouse = true; + } + sgrMouseModeTokens.add(token); + return token; + }, + releaseSgrMouseMode(token: symbol) { + if (!sgrMouseModeTokens.delete(token)) return; + if (sgrMouseModeTokens.size === 0) { + disableSgrMouse(); + } + }, releaseRawMode() { if (!appCtx.isRawModeSupported) return; if (localRefs === 0) return; @@ -2132,10 +2173,19 @@ function createStdinController( if (everEnabledBracketedPaste) { disableBracketedPaste(true); } - } else if (bracketedPasteModeCount > 0) { - disableBracketedPaste(); + if (everEnabledSgrMouse) { + disableSgrMouse(true); + } + } else { + if (bracketedPasteModeCount > 0) { + disableBracketedPaste(); + } + if (sgrMouseModeTokens.size > 0) { + disableSgrMouse(); + } } bracketedPasteModeCount = 0; + sgrMouseModeTokens.clear(); if (appCtx.isRawModeSupported) { const state = getRawModeState(stdin); // Drop this controller's outstanding refs (if Vue's unmount hasn't already diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 04a7696..f629b0f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -152,6 +152,31 @@ importers: specifier: 8.1.0 version: 8.1.0(@types/node@24.12.4)(esbuild@0.28.0)(tsx@4.22.3) + examples/scroll-box: + dependencies: + '@vue-tui/components': + specifier: workspace:* + version: link:../../packages/components + '@vue-tui/runtime': + specifier: workspace:* + version: link:../../packages/runtime + vue: + specifier: ^3.4.0 + version: 3.5.34(typescript@6.0.3) + devDependencies: + '@types/node': + specifier: ^24.12.4 + version: 24.12.4 + '@vitejs/plugin-vue': + specifier: ^6 + version: 6.0.7(vite@8.1.0(@types/node@24.12.4)(esbuild@0.28.0)(tsx@4.22.3))(vue@3.5.34(typescript@6.0.3)) + '@vue-tui/vite': + specifier: workspace:* + version: link:../../packages/vite + vite: + specifier: 8.1.0 + version: 8.1.0(@types/node@24.12.4)(esbuild@0.28.0)(tsx@4.22.3) + packages/components: devDependencies: '@types/node':