feat(runtime): add mouse input API (#245)
CI / Check, build & test (push) Has been cancelled

* docs: record mouse input API design

* feat(runtime): add mouse input API

* fix(runtime): align mouse edge cases

* fix(runtime): suppress click after drag

* refactor(runtime): use template refs for dragging

* docs: record mouse API follow-ups

* fix(runtime): align useDraggable semantics

* fix(runtime): align mouse composable APIs

* fix(runtime): keep mouse input handler refs explicit

* fix(runtime): clean up mouse mode state

* fix(examples): use templates for mouse demo
This commit is contained in:
Yunfei He
2026-07-06 01:20:27 +08:00
committed by GitHub
parent 0a774ddc8c
commit 3e44c9a266
34 changed files with 2909 additions and 159 deletions
+388
View File
@@ -0,0 +1,388 @@
# Mouse input — design & decision record
> The public mouse-input API for `@vue-tui/runtime`: the event shape, the author surface, the
> dispatch model, and how it is gated to full-screen apps. Tracking:
> [#207](https://github.com/vuejs-ai/vue-tui/issues/207). Builds on the low-level stream
> `useMouseInput`, added in #237. Shared surface/SemVer rules live in
> [api-contract.md](./api-contract.md); Ink-alignment is explicitly **not** a constraint here — the
> deciding rules are **user-friendliness** and **following Vue/DOM conventions, not inventing names**.
>
> **Status:** design approved through the API shape and names; adversarially reviewed against source;
> implementation not started. §5 (forward-compat contract) is the load-bearing part.
## 1. What this is, and the scope of v1
`ScrollBox` ships no input and lets the app own the policy
([components/scroll-box.md](./components/scroll-box.md)); this is the other half — the **runtime**
owns pointer input, because decoding mouse bytes and flipping terminal modes is terminal-I/O work.
The model is **runtime-owned targeted dispatch**: the runtime hit-tests the pointer against its
layout tree and delivers the event to the element under the pointer, which bubbles up its ancestors
— exactly like the DOM (and Textual / OpenTUI / blessed). The raw-coordinate broadcast alternative
(Bubble Tea / Ratatui, where the app hit-tests itself) is kept only as the low-level escape hatch
(`useMouseInput`, §4.3).
The event **types** cover the full pointer space, but **v1 delivers a subset**:
- **v1 ships:** the hit-test + dispatch infrastructure; the `TuiMouseEvent` / `TuiWheelEvent`
types; element handler props `@mousedown` / `@mouseup` / `@click` / `@wheel`; **drag** via
`useDraggable` (which owns pointer **capture** internally); buttons **left / middle / right**. Wire
mode: `1002` (button + drag).
- **Deferred, additive later:** bare **hover** — `@mousemove` / `@mouseenter` / `@mouseleave` and
`useElementHover` — which needs the heavier `1003` mode (§9); an in-app selection + clipboard
layer; the side buttons and pixel mode.
## 2. The architecture in one picture (why `useMouseInput` works anywhere but `@click` needs full-screen)
There is **one** raw source; the high-level API is that same source **plus a hit-test step**. They
are not two parallel systems.
```
terminal bytes (SGR: absolute screen coordinates)
│
▼
parser → raw mouse event stream (each event carries absolute screenX/screenY) ← mode-independent
│
├───────────► useMouseInput: hands you the raw event as-is (any mode)
│
└───────────► hit-test + dispatch: map (screenX,screenY) → "which box"
→ deliver to that box's @click (full-screen only)
```
- The vertical spine (terminal → parser → raw stream) is **identical inline and full-screen** — the
bytes are the same, the absolute coordinates are the same. `useMouseInput` just reads that stream,
so it works in **any** mode.
- `@click` is **not** a separate system: it is that same stream **plus one extra step — hit-testing**
(turn the absolute coordinate into "which box"). That step, and only that step, needs the frame's
absolute screen origin, which is knowable only in full-screen (§3).
So: `useMouseInput` = raw stream; `@click` = raw stream + hit-test. Both consume the same internal
stream and share the same ref-counted mouse-mode switch (§4.3). The **only** full-screen-restricted
piece is the hit-test.
## 3. Why hit-testing needs full-screen, and how the gate is enforced
To convert an absolute click coordinate into "which box," the runtime must know where the frame's
top-left sits on the physical screen.
- **Inline:** vue-tui writes each frame at the cursor's current position and updates it _relative to
the previous frame_ (log-update-style line diffing — verified: `eraseLines`, `cursorUp`,
`cursorTo(0)`, never an absolute home in steady state). It never tracks the frame's absolute top
row, so an absolute click cannot be reliably mapped to a node. This is verbatim why Ink's
maintainer rejected `onClick`:
> "In the normal interactive path, Ink does not know or track the frame's absolute terminal origin
> … SGR mouse coordinates are absolute screen coordinates. So clicks will be offset or just hit the
> wrong element."
(Precise claim: the origin is _not stably knowable_ inline — a `clearTerminal` branch does home the
cursor occasionally, but not frame-to-frame. Content flushed outside the tracked layout, à la
`<Static>`, shifts rows too. So the conservative gate is a full-app full-screen declaration.)
- **Full-screen (alternate buffer):** vue-tui enters the alt buffer _before the first render_ and
paints from its top, so the frame's top-left **is** screen origin `(0,0)` and the conversion is
exact. (Robustness: emit `\x1b[H` before the first alt frame so origin `(0,0)` is guaranteed, not
reliant on the terminal homing on alt-buffer entry.)
Second, independent reason: enabling mouse tracking suppresses the terminal's native click-drag text
selection window-wide, including scrollback above an inline app. In full-screen the app owns the
whole viewport, so there is nothing shared to break.
### 3.1 The mode is `fullscreen`; enabling is automatic
**The mount option is renamed `alternateScreen` → `fullscreen`** (with `alternateScreen` kept as a
deprecated alias). It names the user's intent, not the terminal mechanism.
**There is no `mouse` option — enabling is fully automatic.** Mouse tracking turns on **when the app
actually uses mouse** (any element handler / `useDraggable` mounts) **and** the app is `fullscreen`,
via the existing ref-counted SGR-mode ownership (`acquireSgrMouseMode`). Rationale:
- **No explicit opt-in needed, because in full-screen there is no side effect to opt into.** The
reason mouse tracking is normally "opt-in with a warning" (it suppresses native selection) does not
apply once the app owns the whole screen. Declaring `fullscreen` _is_ the opt-in.
- **On-when-used, not blanket-on.** A full-screen app that uses no mouse never enables tracking, so
its users keep native selection. Only apps that actually wire mouse pay the selection tradeoff.
There is deliberately **no opt-out flag** either; an app that wants native selection simply doesn't
wire mouse.
### 3.2 Telling an inline author that `@click` won't work
`@click` type-checks everywhere (the mount-option↔template coupling isn't expressible in types), so
in an inline app a bound handler **silently never fires**. This is the one unavoidable exception to
"misuse is a compile error"; it is covered two ways so the author can't miss it:
- **Write-time (passive):** JSDoc on the handler props — hovering `@click` in the editor shows
"fires only in `fullscreen` mode; for raw mouse in inline mode use `useMouseInput()`."
- **Run-time (active):** when `patchProp` registers a mouse handler while mouse isn't armed (inline,
or full-screen with no fullscreen), it warns **once** (dev **and** prod — a real dead-end, not a
style nit), at **registration time**, not on first click, naming both fixes:
`app.mount({ fullscreen: true })`, or `useMouseInput()` for raw inline mouse.
Refusing to render the whole app is rejected as disproportionate; the correct "don't render" is the
runtime simply not delivering events, plus the warning.
## 4. The public surface
### 4.1 Naming — follow DOM/Vue, don't invent
Applying "follow Vue/DOM conventions" rigorously shaped both the names and the event fields. The
event object mirrors the **DOM `MouseEvent` / `WheelEvent`** field-for-field, so a Vue-web developer
already knows it. The two **type names** are prefixed `Tui` to avoid shadowing the DOM globals
of the same name; the **field** names stay DOM-exact.
```ts
/** Which button. String union (a deliberate, friendlier divergence from DOM's numeric `button`). */
export type MouseButton = "left" | "middle" | "right" | "back" | "forward";
interface MouseEventShared {
/** Button for down/up/click/drag; `null` for move/enter/leave/wheel. */
readonly button: MouseButton | null;
/** Buttons currently held. BEST-EFFORT: SGR reports one button/event, so it is reconstructed by
* tracking down/up; multi-button chords are unreliable across terminals. (DOM uses a numeric
* bitmask `buttons`; a set is friendlier.) */
readonly buttons: ReadonlySet<MouseButton>;
// Modifiers — flat, DOM-exact names.
readonly ctrlKey: boolean;
readonly shiftKey: boolean;
readonly altKey: boolean;
readonly metaKey: boolean;
// Coordinates — DOM-exact names, all 0-based.
readonly offsetX: number; // relative to `currentTarget`'s rendered box, not its content box; re-based as the event bubbles
readonly offsetY: number;
readonly screenX: number; // absolute terminal cell (1-based SGR wire value − 1)
readonly screenY: number;
// Dispatch/propagation — DOM-exact.
readonly target: MouseTarget | null; // deepest element under the pointer; constant while bubbling
readonly currentTarget: MouseTarget | null; // element whose handler is running; changes per hop
stopPropagation(): void;
preventDefault(): void; // reserved for forward-compat; no default action in v1, so a no-op today
readonly defaultPrevented: boolean; // always false in v1
readonly detail: number; // multi-click count (1,2,3…), meaningful on `click` — DOM's name
}
export interface TuiMouseEvent extends MouseEventShared {
readonly type:
| "down"
| "up"
| "click"
| "move"
| "drag"
| "dragstart"
| "dragend"
| "enter"
| "leave";
readonly movementX: number; // movement since the previous event of this gesture — DOM's name
readonly movementY: number;
}
/** DOM has `WheelEvent extends MouseEvent`; mirror that (prefixed to dodge the DOM globals). */
export interface TuiWheelEvent extends MouseEventShared {
readonly type: "wheel";
readonly button: null;
readonly deltaX: number; // DOM WheelEvent names. Sign encodes direction (deltaY > 0 = down).
readonly deltaY: number;
}
```
Applying the DOM lens fixed a real design trap for free: pointer movement is `movementX/movementY`
(DOM) and wheel scroll is `deltaX/deltaY` (DOM `WheelEvent`) — two _different_ DOM names, so the
"one event with two conflicting deltas" problem from an earlier draft disappears.
Every author-facing name maps to a real precedent, none invented:
| name | precedent |
| ------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- |
| `@mousedown` `@mouseup` `@click` `@wheel` (props `onMousedown`…`onWheel`) | DOM / Vue native event names, exact |
| every event field (`ctrlKey`, `offsetX`, `screenX`, `movementX`, `deltaX`, `detail`, `target`, `stopPropagation`…) | DOM `MouseEvent` / `WheelEvent`, exact |
| type names `TuiMouseEvent` / `TuiWheelEvent` | DOM `MouseEvent`/`WheelEvent`, `Tui`-prefixed like PixiJS's `Federated…`; also matches vue-tui's own `TuiApp` |
| `useDraggable` | VueUse `useDraggable`, exact |
| `useElementHover` (deferred) | VueUse `useElementHover`, exact |
| `useMouseInput`, `MouseInputEvent` | existing vue-tui exports (#237), unchanged |
| `fullscreen` mount option | intent-named; renames the mechanism-named `alternateScreen` |
Only the two names that collide with a DOM global are prefixed. `Tui` — not the brand `VueTui`, and
not a namespace — is the deliberate call: it matches PixiJS (a non-DOM renderer with DOM-shaped
events prefixes `Federated…`, a _concept_ word, never the brand) and vue-tui's own existing `TuiApp` /
`TuiNode` / `TuiRoot`. A namespace (`VueTui.MouseEvent`, React's `@types` pattern) was rejected
because modern TS discourages namespaces — the handbook prefers ES modules,
`@typescript-eslint/recommended` bans them in source (`no-namespace`), and TS 5.8 `erasableSyntaxOnly`
\+ Node's native type-stripping treat `namespace` as non-erasable (React gets away with it only
because its namespace lives in `.d.ts` files). `MouseButton`, `MouseTarget`, and `MouseHandlerProps`
have no DOM global to collide with, so they stay unprefixed (prefix only where there is an actual
clash — verified: vue-tui compiles with `lib: ["es2023"]`, so these names are clean internally; the
prefix is purely to protect a consumer whose own project pulls in the DOM lib).
### 4.2 High-level — element handler props (the 90% path)
```vue
<Box @mousedown="onDown" @click="onClick" @wheel="onWheel" />
```
```ts
/** v1 interface. Hover props deliberately absent (below). */
export interface MouseHandlerProps {
onMousedown?: (e: TuiMouseEvent) => void;
onMouseup?: (e: TuiMouseEvent) => void;
onClick?: (e: TuiMouseEvent) => void;
onWheel?: (e: TuiWheelEvent) => void;
}
```
`@mousemove` / `@mouseenter` / `@mouseleave` are **omitted** from the v1 interface (not shipped as
dead no-ops) — binding one is a vue-tsc error today; they arrive with mode `1003` (§9), purely
additively. Drag has **no** element prop: it is a gesture with capture, handled by `useDraggable`
(§4.3) — mirroring the web, which has no mouse-drag DOM event either (drag is a library/composable
there too).
There is **no** `@mouse` catch-all and **no** general `useMouse` composable — both were dropped as
redundant: `@mouse` duplicates binding the specific events (and the raw stream already is a
catch-all), and `useMouse(ref, handlers)` just re-expressed `@click`. `useDraggable`/`useElementHover`
survive because they add something element props can't (gesture state, capture, a reactive
`hovered`) and match VueUse.
**This is net-new renderer work:** `patchProp` currently _ignores_ `on*` props on host nodes. v1
records mouse handlers on the node so the dispatch layer finds them (this is also the hook that fires
the §3.2 inline warning), and `<Box>`/`<Text>` fall these props through to the host node, typed so
`@click` type-checks in templates.
### 4.3 Low-level — `useMouseInput`, and `useDraggable`
```ts
/** Existing (#237). The raw broadcast stream (§2): absolute coords, you hit-test yourself, any mode.
* The inline escape hatch. Its coords are 1-based (unchanged); see §8 for the base mismatch. */
export function useMouseInput(handler: MaybeRef<(e: MouseInputEvent) => void>, options?): void;
/** VueUse `useDraggable`, adapted to the terminal: the element position tracks the pointer during
* a drag, owning pointer capture internally. Returns element cell position + drag state. */
export type UseDraggableTarget = MaybeRefOrGetter<ComponentPublicInstance | null | undefined>;
export function useDraggable(
target: UseDraggableTarget, // a normal <Box>/<Text> template ref
options?: {
initialValue?: { x: number; y: number };
axis?: "x" | "y" | "both";
onStart?: (position: { x: number; y: number }, e: TuiMouseEvent) => void; // strict false cancels
onMove?: (position: { x: number; y: number }, e: TuiMouseEvent) => void;
onEnd?: (position: { x: number; y: number }, e: TuiMouseEvent) => void;
},
): {
readonly x: Ref<number>;
readonly y: Ref<number>;
readonly position: Readonly<Ref<{ x: number; y: number }>>;
readonly isDragging: Readonly<Ref<boolean>>;
};
```
**Pointer capture** (keep routing to one node until release, even as the pointer leaves it) is what
lets a drag survive leaving the element. `useDraggable` acquires it on button-down and releases on
button-up, so apps never touch capture directly in v1; the dispatch layer must support "bypass
hit-testing, route to node X" (§8).
`useDraggable` deliberately accepts a normal typed template ref, the same shape as
`shallowRef<InstanceType<typeof Box> | null>(null)`, and resolves it internally to a TUI node.
`MouseTarget` is not an input handle and there is no `useMouseTarget`: `MouseTarget` only appears on
delivered events as the public `target` / `currentTarget` wrapper. This keeps the author surface
aligned with VueUse and avoids exposing a second ref type just to start dragging.
The returned `x` / `y` are the draggable element's `left` / `top` cell position, initialized from
`initialValue` and updated by pointer delta during the drag. This is the part of VueUse
`useDraggable` that carries directly to vue-tui; terminal apps bind `x.value` / `y.value` to
`left` / `top` instead of binding a CSS `style` string. The event still exposes `screenX/Y` and
`movementX/Y` for custom gesture math.
## 5. Forward-compatibility contract — fix now vs add later
| Get right NOW (breaking to change later) | Add LATER (purely additive) |
| ------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------- |
| `offsetX/offsetY` (target rendered-box-relative) **and** `screenX/screenY` (absolute), all 0-based, always present | new `type` members (`move`, `enter`, `leave`) |
| `movementX/movementY` on `TuiMouseEvent`, `deltaX/deltaY` on `TuiWheelEvent` (never mixed) | new handler props (`onMousemove`, `onMouseenter`, `onMouseleave`) |
| `button` an **open** string union; wheel is `TuiWheelEvent`, not a button | populating `movementX/Y`, `buttons`, `detail` as gestures gain them |
| flat DOM modifier names (`ctrlKey/shiftKey/altKey/metaKey`) | emitting side buttons (`back`/`forward`/8–11) |
| `type` an **open** discriminator | `useElementHover` + hover via mode `1003` |
| `target`/`currentTarget` + `stopPropagation`/`preventDefault` on the event | additive event methods |
| `offsetX/offsetY` stay relative-to-`currentTarget`'s rendered box and re-base while bubbling | switching the default motion level (config) |
| `MouseTarget` exposes an **absolute** rect and does **not** expose the internal `TuiNode` | an in-app selection + OSC 52 clipboard layer |
| type names `TuiMouseEvent`/`TuiWheelEvent` (prefixed) | — |
## 6. Dispatch infrastructure (the hit map)
Each committed frame, build a map of every node's **absolute** cell rectangle in **paint order**
(vue-tui has no z-index; later paint ops overwrite earlier, so paint order _is_ stacking order — a
hit is the last-painted node covering the cell). The absolute rects come from the paint walk's
existing origin accumulation, captured by a new recording pass (the paint op-list keeps no node
identity today). The map **must honor** `overflow: "hidden"` clip rects and `position: "absolute"`
placement, or it reports hits on clipped/mispositioned nodes.
Per raw event: `hitTest(screenX, screenY)` → topmost node → build the event with `offsetX/offsetY`
re-based into that node's box → dispatch to its handlers, then **bubble up the parent chain until
`stopPropagation()`**. Each bubbling hop receives its own event object with that hop's
`currentTarget` and re-based offsets; the shared `stopPropagation()` closure is the only mutable
dispatch state. `click` is synthesized when `up` lands on the same target as the preceding `down`;
`detail` increments on repeat clicks at the same cell within a short window — driven by the commit
scheduler's timing hooks (not a bare timer), so tests stay deterministic. Capture bypasses `hitTest`.
Invariants from Ink's failure (§3): the map is built **only** in full-screen (known origin), and
content flushed outside the tracked layout (`<Static>`) is excluded so screen rows and layout rows
can't disagree.
The correctness gate is about dispatch and terminal mode: targeted handlers / `useDraggable` arm
mouse tracking only when there is a mouse registration and the app is full-screen. Building a
full-screen hit map on frames without a current mouse registration is allowed as an implementation
detail; gating that work on the controller's armed state is only a performance optimization, and it
must not change the public event / `MouseTarget.rect` contract.
**⚠️ Teardown must disable the actual mouse level (correctness bug if missed).** Today's disable
string is `\x1b[?1000l\x1b[?1006l`, used by both the async and the synchronous signal-exit paths.
`\x1b[?1000l` does **not** turn off `1002`/`1003`. Once v1 enables `1002`, exit / Ctrl-C / SIGINT
would leave the terminal spewing `<35;..M` on every move — the exact corruption the sync-restore
machinery exists to prevent. Simplest airtight fix: on teardown always emit
`\x1b[?1003l\x1b[?1002l\x1b[?1000l\x1b[?1006l` (disabling an un-set mode is a no-op). Degradation:
non-TTY / `TERM=dumb` enables nothing; handlers never fire.
## 7. What "level negotiation" actually requires (net-new work, not a tweak)
- **Parser widening.** `parseMouseInput` decodes wheel only and drops every non-wheel button; v1
decodes press / release / drag, and needs a dispatch channel beyond today's single wheel-only
`"mouse"` emitter.
- **Leveled enable.** Today's enable (`1000`) fires only on the 0→1 refcount transition; v1 requests
`1002` and must emit escapes on upgrade/downgrade transitions (`off`=1000 / `drag`=1002 /
`hover`=1003).
- Both the low-level `useMouseInput` and the high-level dispatch acquire the **same** underlying mode
through the shared refcount (§2) — one switch, not two.
## 8. Settled implementation notes and follow-ups (no effect on §5)
- **`MouseTarget` surface** — settled for v1: a thin public wrapper for event `target` /
`currentTarget`: stable identity + an **absolute** rect accessor from the paint walk. It must not
re-export `TuiNode`, must not be accepted as a way to recover a `TuiNode`, and must not be required
for ordinary template-ref composables such as `useDraggable`.
- **`useMouseInput` future** — its coords are **1-based**; the new events are **0-based**. Keep it as
the narrow wheel/raw stream, or replace with a `useRawMouse` delivering `TuiMouseEvent` — the
latter is a **breaking change** (coord base + shape), so decide it deliberately, not as a
"compatible" widening. Its handler source intentionally stays `MaybeRef`, not
`MaybeRefOrGetter`, because function handlers and getter functions have the same runtime shape.
Reactive handler replacement should pass a ref to the handler.
- **`useDraggable` follow-ups** — v1 carries over VueUse's element-position semantics,
`initialValue`, `axis`, and strict `false` from `onStart` to cancel a drag. The public TypeScript
callback return is `void` so normal expression callbacks like `onStart: () => calls.push(...)`
remain valid; runtime still checks the actual return value. VueUse's CSS `style`
helper does not map directly to terminal props; a future helper can return a vue-tui layout-prop
object if real examples need it. `handle` also remains additive.
- **Settled v1 mechanics** — handler storage lives on the node; pointer capture is owned and released
by `useDraggable`, including when the capturing node unmounts; mouse composables use a local
`tryOnScopeDispose` helper for scope-safe cleanup; `fullscreen` is the primary mount option and
`alternateScreen` remains only as a deprecated alias.
## 9. Deliberately out of scope (v1)
- **Bare hover** (`@mousemove` / `@mouseenter` / `@mouseleave`, `useElementHover`, mode `1003`): every
cursor move floods stdin (heavy over SSH) and suppresses selection more aggressively than `1002`.
Declared in the types, emitted later.
- **Side buttons** (back / forward / 8–11) and **pixel mode (1016).** v1 emits left/middle/right only.
- **In-app text selection + clipboard (OSC 52).** A whole subsystem (Textual/opencode ship their own);
the full-screen gate contains the tradeoff meanwhile, and Shift bypasses tracking for a native
selection in most terminals.
+7
View File
@@ -0,0 +1,7 @@
<!doctype html>
<html>
<head></head>
<body>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+23
View File
@@ -0,0 +1,23 @@
{
"name": "@vue-tui/example-mouse",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsdown",
"preview": "tsdown && node dist/main.mjs"
},
"dependencies": {
"@vue-tui/runtime": "workspace:*",
"vue": "^3.4.0"
},
"devDependencies": {
"@types/node": "catalog:",
"@vitejs/plugin-vue": "^6",
"@vue-tui/vite": "workspace:*",
"tsdown": "catalog:",
"unplugin-vue": "catalog:",
"vite": "catalog:"
}
}
+71
View File
@@ -0,0 +1,71 @@
<script setup lang="ts">
import { shallowRef } from "vue";
import {
Box,
Text,
useDraggable,
useInput,
type TuiMouseEvent,
type TuiWheelEvent,
} from "@vue-tui/runtime";
const clicks = shallowRef(0);
const lastClick = shallowRef("none");
const lastWheel = shallowRef("none");
const dragRef = shallowRef<InstanceType<typeof Box> | null>(null);
useInput((input) => {
if (input === "q") process.exit(0);
});
const { x: dragLeft, y: dragTop } = useDraggable(dragRef, {
initialValue: { x: 2, y: 7 },
});
function onPanelClick(event: TuiMouseEvent) {
clicks.value += 1;
lastClick.value = `${event.button} @ ${event.offsetX},${event.offsetY} (${event.detail})`;
}
function onPanelWheel(event: TuiWheelEvent) {
lastWheel.value = `${event.deltaX},${event.deltaY} @ ${event.offsetX},${event.offsetY}`;
}
</script>
<template>
<Box flexDirection="column" width="100%" height="100%" :paddingX="1" :paddingY="1">
<Text bold color="cyan">vue-tui mouse input</Text>
<Text dimColor>Click, wheel, or drag the block. Press q to quit.</Text>
<Box :marginTop="1" flexDirection="column">
<Text>Clicks: {{ clicks }}</Text>
<Text>Last click: {{ lastClick }}</Text>
<Text>Last wheel: {{ lastWheel }}</Text>
</Box>
<Box
:marginTop="1"
:width="50"
:height="10"
borderStyle="single"
borderColor="gray"
@click="onPanelClick"
@wheel="onPanelWheel"
>
<Box
ref="dragRef"
position="absolute"
:left="dragLeft"
:top="dragTop"
:width="8"
:height="3"
borderStyle="round"
borderColor="green"
alignItems="center"
justifyContent="center"
>
<Text color="green">drag</Text>
</Box>
</Box>
</Box>
</template>
+4
View File
@@ -0,0 +1,4 @@
import { createApp } from "@vue-tui/runtime";
import App from "./app.vue";
createApp(App).mount({ fullscreen: true });
+5
View File
@@ -0,0 +1,5 @@
declare module "*.vue" {
import type { Component } from "vue";
const component: Component;
export default component;
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"moduleResolution": "bundler",
"strict": true,
"noEmit": true,
"types": ["node"]
},
"include": ["src/**/*.ts", "src/**/*.vue"]
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from "tsdown";
import Vue from "unplugin-vue/rolldown";
export default defineConfig({
entry: ["src/main.ts"],
platform: "node",
format: "esm",
deps: { alwaysBundle: [/./], onlyBundle: false },
plugins: [Vue()],
});
+7
View File
@@ -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()],
});
@@ -12,8 +12,8 @@
// 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
// (`fs.writeSync(fd, …)`). The SGR mouse-disable escape must disable every mouse
// tracking level (`1003`, `1002`, `1000`) plus SGR coordinates (`1006`) and 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";
@@ -21,12 +21,23 @@ 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 { afterEach, beforeEach, 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 MOUSE_OFF = "\x1b[?1003l\x1b[?1002l\x1b[?1000l\x1b[?1006l";
const SHOW_CURSOR = "\x1b[?25h";
let previousTerm: string | undefined;
beforeEach(() => {
previousTerm = process.env["TERM"];
process.env["TERM"] = "xterm-256color";
});
afterEach(() => {
if (previousTerm === undefined) delete process.env["TERM"];
else process.env["TERM"] = previousTerm;
});
function makeFakeStdin(): NodeJS.ReadStream {
const s = new PassThrough() as unknown as NodeJS.ReadStream;
@@ -124,7 +135,7 @@ describe("SGR mouse disable on signal exit", () => {
// 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",
"all SGR mouse levels must be disabled via fs.writeSync on the signal-exit path",
).toContain(MOUSE_OFF);
});
@@ -1,5 +1,5 @@
import { defineComponent, nextTick, shallowRef } from "vue";
import { expect, test } from "vite-plus/test";
import { afterEach, beforeEach, expect, test } from "vite-plus/test";
import {
Box,
Text,
@@ -11,7 +11,19 @@ import {
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";
const ENABLE_SGR_DRAG_MOUSE = "\x1b[?1002h\x1b[?1006h";
const DISABLE_SGR_MOUSE = "\x1b[?1003l\x1b[?1002l\x1b[?1000l\x1b[?1006l";
let previousTerm: string | undefined;
beforeEach(() => {
previousTerm = process.env["TERM"];
process.env["TERM"] = "xterm-256color";
});
afterEach(() => {
if (previousTerm === undefined) delete process.env["TERM"];
else process.env["TERM"] = previousTerm;
});
async function settle() {
await nextTick();
@@ -57,6 +69,39 @@ test("useMouseInput enables SGR mouse mode and emits wheel events", async () =>
expect(writes.join("")).toContain(DISABLE_SGR_MOUSE);
});
test("useMouseInput accepts a handler ref", async () => {
const first: MouseInputEvent[] = [];
const second: MouseInputEvent[] = [];
const currentHandler = shallowRef((event: MouseInputEvent) => first.push(event));
const App = defineComponent(() => {
useMouseInput(currentHandler);
return () => <Text>listening</Text>;
});
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[<64;1;1M");
await settle();
currentHandler.value = (event: MouseInputEvent) => second.push(event);
stdin.emit("data", "\x1b[<65;2;3M");
await settle();
expect(first).toEqual([
{ type: "wheel", direction: "up", x: 1, y: 1, shift: false, meta: false, ctrl: false },
]);
expect(second).toEqual([
{ type: "wheel", direction: "down", x: 2, y: 3, shift: false, meta: false, ctrl: false },
]);
app.unmount();
});
test("useMouseInput keeps SGR mouse mode enabled until the last consumer releases it", async () => {
const showA = shallowRef(true);
const showB = shallowRef(true);
@@ -145,6 +190,87 @@ test("useMouseInput respects isActive", async () => {
app.unmount();
});
test("useMouseInput disables SGR mouse when support disappears before release", async () => {
const active = shallowRef(true);
const App = defineComponent(() => {
useMouseInput(() => {}, { isActive: active });
return () => <Text>listening</Text>;
});
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);
process.env["TERM"] = "dumb";
active.value = false;
await settle();
expect(countOccurrences(writes.join(""), DISABLE_SGR_MOUSE)).toBe(1);
app.unmount();
});
test("element mouse handlers upgrade useMouseInput to drag mode and downgrade on removal", async () => {
const showTarget = shallowRef(false);
const App = defineComponent(() => {
useMouseInput(() => {});
return () => (
<Box>
{showTarget.value ? <Box width={2} height={1} onClick={() => {}} /> : null}
<Text>raw</Text>
</Box>
);
});
const app = createApp(App);
const stdout = makeFakeWritable({ columns: 20, rows: 4 });
const stderr = makeFakeWritable();
const { stream: stdin } = makeFakeStdin();
const writes = captureWrites(stdout);
app.mount({
stdout,
stderr,
stdin,
debug: true,
exitOnCtrlC: false,
rawMode: "auto",
fullscreen: true,
});
await settle();
expect(countOccurrences(writes.join(""), ENABLE_SGR_MOUSE)).toBe(1);
expect(countOccurrences(writes.join(""), ENABLE_SGR_DRAG_MOUSE)).toBe(0);
expect(countOccurrences(writes.join(""), DISABLE_SGR_MOUSE)).toBe(0);
showTarget.value = true;
await settle();
expect(countOccurrences(writes.join(""), ENABLE_SGR_MOUSE)).toBe(1);
expect(countOccurrences(writes.join(""), ENABLE_SGR_DRAG_MOUSE)).toBe(1);
expect(countOccurrences(writes.join(""), DISABLE_SGR_MOUSE)).toBe(1);
showTarget.value = false;
await settle();
expect(countOccurrences(writes.join(""), ENABLE_SGR_MOUSE)).toBe(2);
expect(countOccurrences(writes.join(""), ENABLE_SGR_DRAG_MOUSE)).toBe(1);
expect(countOccurrences(writes.join(""), DISABLE_SGR_MOUSE)).toBe(2);
app.unmount();
await settle();
expect(countOccurrences(writes.join(""), DISABLE_SGR_MOUSE)).toBe(3);
});
test("useMouseInput consumes unsupported SGR mouse events before keyboard input", async () => {
const mouseEvents: MouseInputEvent[] = [];
const keyboardEvents: string[] = [];
@@ -0,0 +1,755 @@
import { defineComponent, nextTick, shallowRef } from "vue";
import { afterEach, beforeEach, expect, test, vi } from "vite-plus/test";
import {
Box,
Static,
Text,
createApp,
useDraggable,
type MouseTarget,
type TuiMouseEvent,
type TuiWheelEvent,
} from "@vue-tui/runtime";
import { captureWrites, makeFakeStdin, makeFakeWritable } from "../lifecycle/test-streams.ts";
const ENABLE_SGR_DRAG_MOUSE = "\x1b[?1002h\x1b[?1006h";
const DISABLE_SGR_MOUSE = "\x1b[?1003l\x1b[?1002l\x1b[?1000l\x1b[?1006l";
type BoxInstance = InstanceType<typeof Box>;
let previousTerm: string | undefined;
beforeEach(() => {
previousTerm = process.env["TERM"];
process.env["TERM"] = "xterm-256color";
});
afterEach(() => {
if (previousTerm === undefined) delete process.env["TERM"];
else process.env["TERM"] = previousTerm;
});
async function settle() {
await nextTick();
await nextTick();
await Promise.resolve();
}
function mountMouseApp(
component: ReturnType<typeof defineComponent>,
options: boolean | { fullscreen?: boolean; stdinIsTTY?: boolean } = true,
) {
const fullscreen = typeof options === "boolean" ? options : (options.fullscreen ?? true);
const app = createApp(component);
const stdout = makeFakeWritable({ columns: 20, rows: 8 });
const stderr = makeFakeWritable();
const { stream: stdin } = makeFakeStdin();
if (typeof options === "object" && options.stdinIsTTY === false) {
(stdin as { isTTY?: boolean }).isTTY = false;
}
const writes = captureWrites(stdout);
app.mount({
stdout,
stderr,
stdin,
debug: true,
exitOnCtrlC: false,
rawMode: "auto",
fullscreen,
});
return { app, stdin, writes };
}
test("fullscreen element handlers enable 1002 mode and receive hit-tested click events", async () => {
const clicks: TuiMouseEvent[] = [];
const App = defineComponent(() => () => (
<Box width={10} height={4}>
<Box width={4} height={2} onClick={(event) => clicks.push(event)}>
<Text>hit</Text>
</Box>
</Box>
));
const { app, stdin, writes } = mountMouseApp(App);
await settle();
expect(writes.join("")).toContain(ENABLE_SGR_DRAG_MOUSE);
stdin.emit("data", "\x1b[<0;4;2M\x1b[<0;4;2m");
await settle();
expect(clicks).toHaveLength(1);
expect(clicks[0]!.type).toBe("click");
expect(clicks[0]!.button).toBe("left");
expect(clicks[0]!.screenX).toBe(3);
expect(clicks[0]!.screenY).toBe(1);
expect(clicks[0]!.offsetX).toBe(3);
expect(clicks[0]!.offsetY).toBe(1);
expect(clicks[0]!.detail).toBe(1);
expect(clicks[0]!.target).toBe(clicks[0]!.currentTarget);
expect(clicks[0]!.target?.rect).toEqual({ x: 0, y: 0, width: 4, height: 2 });
app.unmount();
await settle();
expect(writes.join("")).toContain(DISABLE_SGR_MOUSE);
});
test("mouse events bubble and stopPropagation stops ancestor handlers", async () => {
const calls: string[] = [];
const stop = shallowRef(false);
const App = defineComponent(() => () => (
<Box width={10} height={4} onClick={() => calls.push("parent")}>
<Box
width={4}
height={2}
onClick={(event) => {
calls.push("child");
if (stop.value) event.stopPropagation();
}}
>
<Text>hit</Text>
</Box>
</Box>
));
const { app, stdin } = mountMouseApp(App);
await settle();
stdin.emit("data", "\x1b[<0;1;1M\x1b[<0;1;1m");
await settle();
expect(calls).toEqual(["child", "parent"]);
calls.length = 0;
stop.value = true;
await settle();
stdin.emit("data", "\x1b[<0;1;1M\x1b[<0;1;1m");
await settle();
expect(calls).toEqual(["child"]);
app.unmount();
});
test("mouse events rebase offsets while bubbling", async () => {
const calls: Array<[string, number, number]> = [];
const App = defineComponent(() => () => (
<Box
marginLeft={2}
width={5}
height={1}
onClick={(event) => calls.push(["parent", event.offsetX, event.offsetY])}
>
<Box
marginLeft={1}
width={2}
height={1}
onClick={(event) => calls.push(["child", event.offsetX, event.offsetY])}
/>
</Box>
));
const { app, stdin } = mountMouseApp(App);
await settle();
stdin.emit("data", "\x1b[<0;4;1M\x1b[<0;4;1m");
await settle();
expect(calls).toEqual([
["child", 0, 0],
["parent", 1, 0],
]);
app.unmount();
});
test("mouse events keep per-handler rebased event objects stable after bubbling", async () => {
let childEvent: TuiMouseEvent | undefined;
let parentEvent: TuiMouseEvent | undefined;
const App = defineComponent(() => () => (
<Box
marginLeft={2}
width={5}
height={1}
onClick={(event) => {
parentEvent = event;
}}
>
<Box
marginLeft={1}
width={2}
height={1}
onClick={(event) => {
childEvent = event;
}}
/>
</Box>
));
const { app, stdin } = mountMouseApp(App);
await settle();
stdin.emit("data", "\x1b[<0;4;1M\x1b[<0;4;1m");
await settle();
if (!childEvent || !parentEvent) throw new Error("expected bubbling click events");
expect(childEvent).not.toBe(parentEvent);
expect(childEvent.currentTarget).not.toBe(parentEvent.currentTarget);
expect(childEvent.offsetX).toBe(0);
expect(childEvent.offsetY).toBe(0);
expect(parentEvent.offsetX).toBe(1);
expect(parentEvent.offsetY).toBe(0);
app.unmount();
});
test("click detail increments for repeated clicks at the same target and cell", async () => {
const details: number[] = [];
const App = defineComponent(() => () => (
<Box width={4} height={2} onClick={(event) => details.push(event.detail)}>
<Text>hit</Text>
</Box>
));
const { app, stdin } = mountMouseApp(App);
await settle();
stdin.emit("data", "\x1b[<0;1;1M\x1b[<0;1;1m\x1b[<0;1;1M\x1b[<0;1;1m");
await settle();
expect(details).toEqual([1, 2]);
app.unmount();
});
test("click synthesis only requires down and up on the same target", async () => {
const clicks: TuiMouseEvent[] = [];
const App = defineComponent(() => () => (
<Box width={4} height={2} onClick={(event) => clicks.push(event)} />
));
const { app, stdin } = mountMouseApp(App);
await settle();
stdin.emit("data", "\x1b[<0;1;1M\x1b[<0;4;2m");
await settle();
expect(clicks).toHaveLength(1);
expect(clicks[0]!.screenX).toBe(3);
expect(clicks[0]!.screenY).toBe(1);
expect(clicks[0]!.offsetX).toBe(3);
expect(clicks[0]!.offsetY).toBe(1);
app.unmount();
});
test("click detail does not increment across cells on the same target", async () => {
const details: number[] = [];
const App = defineComponent(() => () => (
<Box width={4} height={2} onClick={(event) => details.push(event.detail)} />
));
const { app, stdin } = mountMouseApp(App);
await settle();
stdin.emit("data", "\x1b[<0;1;1M\x1b[<0;1;1m\x1b[<0;4;2M\x1b[<0;4;2m");
await settle();
expect(details).toEqual([1, 1]);
app.unmount();
});
test("click is not synthesized when down and up hit different targets", async () => {
const clicks: string[] = [];
const App = defineComponent(() => () => (
<Box height={1}>
<Box width={2} height={1} onClick={() => clicks.push("left")} />
<Box width={2} height={1} onClick={() => clicks.push("right")} />
</Box>
));
const { app, stdin } = mountMouseApp(App);
await settle();
stdin.emit("data", "\x1b[<0;1;1M\x1b[<0;4;1m");
await settle();
expect(clicks).toEqual([]);
app.unmount();
});
test("wheel events use DOM-shaped delta fields and no button", async () => {
const wheels: TuiWheelEvent[] = [];
const App = defineComponent(() => () => (
<Box width={4} height={2} onWheel={(event) => wheels.push(event)}>
<Text>hit</Text>
</Box>
));
const { app, stdin } = mountMouseApp(App);
await settle();
stdin.emit("data", "\x1b[<65;1;1M");
await settle();
expect(wheels).toHaveLength(1);
expect(wheels[0]!.type).toBe("wheel");
expect(wheels[0]!.button).toBe(null);
expect(wheels[0]!.deltaX).toBe(0);
expect(wheels[0]!.deltaY).toBe(1);
app.unmount();
});
test("inline element mouse handlers warn once and do not arm SGR mouse", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const App = defineComponent(() => () => (
<Box width={4} height={2} onClick={() => {}}>
<Text>inline</Text>
</Box>
));
const { app, writes } = mountMouseApp(App, false);
await settle();
expect(warn).toHaveBeenCalledTimes(1);
expect(warn.mock.calls[0]![0]).toContain("app.mount({ fullscreen: true })");
expect(warn.mock.calls[0]![0]).toContain("useMouseInput()");
expect(writes.join("")).not.toContain(ENABLE_SGR_DRAG_MOUSE);
warn.mockRestore();
app.unmount();
});
test("inline element mouse handlers warn in production too", async () => {
const previousNodeEnv = process.env["NODE_ENV"];
process.env["NODE_ENV"] = "production";
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const App = defineComponent(() => () => (
<Box width={4} height={2} onClick={() => {}}>
<Text>inline</Text>
</Box>
));
try {
const { app } = mountMouseApp(App, false);
await settle();
expect(warn).toHaveBeenCalledTimes(1);
app.unmount();
} finally {
if (previousNodeEnv === undefined) delete process.env["NODE_ENV"];
else process.env["NODE_ENV"] = previousNodeEnv;
warn.mockRestore();
}
});
test("TERM=dumb does not arm SGR mouse or deliver element handlers", async () => {
process.env["TERM"] = "dumb";
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const clicks: TuiMouseEvent[] = [];
const App = defineComponent(() => () => (
<Box width={4} height={2} onClick={(event) => clicks.push(event)}>
<Text>dumb</Text>
</Box>
));
try {
const { app, stdin, writes } = mountMouseApp(App);
await settle();
expect(warn).toHaveBeenCalledTimes(1);
expect(writes.join("")).not.toContain(ENABLE_SGR_DRAG_MOUSE);
stdin.emit("data", "\x1b[<0;1;1M\x1b[<0;1;1m");
await settle();
expect(clicks).toEqual([]);
app.unmount();
} finally {
process.env["TERM"] = "xterm-256color";
warn.mockRestore();
}
});
test("non-TTY stdin does not arm SGR mouse or throw for fullscreen handlers", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const clicks: TuiMouseEvent[] = [];
const App = defineComponent(() => () => (
<Box width={4} height={2} onClick={(event) => clicks.push(event)}>
<Text>pipe</Text>
</Box>
));
const { app, stdin, writes } = mountMouseApp(App, { stdinIsTTY: false });
await settle();
expect(warn).toHaveBeenCalledTimes(1);
expect(writes.join("")).not.toContain(ENABLE_SGR_DRAG_MOUSE);
stdin.emit("data", "\x1b[<0;1;1M\x1b[<0;1;1m");
await settle();
expect(clicks).toEqual([]);
warn.mockRestore();
app.unmount();
});
test("nested Text handlers receive virtual-text hit-test events", async () => {
const calls: TuiMouseEvent[] = [];
const App = defineComponent(() => () => (
<Text>
outer <Text onClick={(event) => calls.push(event)}>inner</Text>
</Text>
));
const { app, stdin } = mountMouseApp(App);
await settle();
stdin.emit("data", "\x1b[<0;8;1M\x1b[<0;8;1m");
await settle();
expect(calls).toHaveLength(1);
expect(calls[0]!.target).toBe(calls[0]!.currentTarget);
expect(calls[0]!.offsetX).toBe(1);
expect(calls[0]!.offsetY).toBe(0);
expect(calls[0]!.target?.rect).toEqual({ x: 6, y: 0, width: 5, height: 1 });
app.unmount();
});
test("hit-testing honors overflow hidden clipping", async () => {
const calls: TuiMouseEvent[] = [];
const App = defineComponent(() => () => (
<Box width={4} height={1} overflow="hidden">
<Box marginLeft={3} width={3} height={1} onClick={(event) => calls.push(event)} />
</Box>
));
const { app, stdin } = mountMouseApp(App);
await settle();
stdin.emit("data", "\x1b[<0;4;1M\x1b[<0;4;1m\x1b[<0;5;1M\x1b[<0;5;1m");
await settle();
expect(calls).toHaveLength(1);
expect(calls[0]!.screenX).toBe(3);
expect(calls[0]!.target?.rect).toEqual({ x: 3, y: 0, width: 1, height: 1 });
app.unmount();
});
test("hit-testing honors absolute positioning", async () => {
const calls: TuiMouseEvent[] = [];
const App = defineComponent(() => () => (
<Box width={6} height={3}>
<Box
position="absolute"
left={2}
top={1}
width={2}
height={1}
onClick={(event) => calls.push(event)}
/>
</Box>
));
const { app, stdin } = mountMouseApp(App);
await settle();
stdin.emit("data", "\x1b[<0;3;2M\x1b[<0;3;2m\x1b[<0;1;1M\x1b[<0;1;1m");
await settle();
expect(calls).toHaveLength(1);
expect(calls[0]!.offsetX).toBe(0);
expect(calls[0]!.offsetY).toBe(0);
expect(calls[0]!.target?.rect).toEqual({ x: 2, y: 1, width: 2, height: 1 });
app.unmount();
});
test("hit-testing excludes Static content", async () => {
const staticClicks: string[] = [];
const dynamicClicks: string[] = [];
const App = defineComponent(() => () => (
<Box>
<Box width={6} height={1} onClick={() => dynamicClicks.push("dynamic")}>
<Text>dyn</Text>
</Box>
<Static items={["history"]}>
{{
default: ({ index }: { index: number }) => (
<Box key={index} width={6} height={1} onClick={() => staticClicks.push("static")}>
<Text>static</Text>
</Box>
),
}}
</Static>
</Box>
));
const { app, stdin } = mountMouseApp(App);
await nextTick();
stdin.emit("data", "\x1b[<0;1;1M\x1b[<0;1;1m");
await settle();
expect(staticClicks).toEqual([]);
expect(dynamicClicks).toEqual(["dynamic"]);
app.unmount();
});
test("hit-testing prefers the last-painted overlapping node", async () => {
const calls: string[] = [];
const App = defineComponent(() => () => (
<Box width={4} height={2}>
<Box
position="absolute"
left={0}
top={0}
width={2}
height={1}
onClick={() => calls.push("first")}
/>
<Box
position="absolute"
left={0}
top={0}
width={2}
height={1}
onClick={() => calls.push("second")}
/>
</Box>
));
const { app, stdin } = mountMouseApp(App);
await settle();
stdin.emit("data", "\x1b[<0;1;1M\x1b[<0;1;1m");
await settle();
expect(calls).toEqual(["second"]);
app.unmount();
});
test("MouseTarget rect is cleared when a mounted node stops painting", async () => {
const target = shallowRef<MouseTarget | null>(null);
const hidden = shallowRef(false);
const App = defineComponent(() => () => (
<Box
width={4}
height={2}
display={hidden.value ? "none" : "flex"}
onClick={(event) => {
target.value = event.currentTarget;
}}
>
<Text>box</Text>
</Box>
));
const { app, stdin } = mountMouseApp(App);
await settle();
stdin.emit("data", "\x1b[<0;1;1M\x1b[<0;1;1m");
await settle();
expect(target.value?.rect).toEqual({ x: 0, y: 0, width: 4, height: 2 });
hidden.value = true;
await settle();
expect(target.value?.rect).toEqual({ x: 0, y: 0, width: 0, height: 0 });
app.unmount();
});
test("useDraggable tracks element position until release", async () => {
const dragTarget = shallowRef<BoxInstance | null>(null);
const moves: Array<[string, number, number, number, number, number, number]> = [];
const App = defineComponent(() => {
useDraggable(dragTarget, {
initialValue: { x: 2, y: 1 },
onStart: (position, event) =>
moves.push([
event.type,
position.x,
position.y,
event.screenX,
event.screenY,
event.movementX,
event.movementY,
]),
onMove: (position, event) =>
moves.push([
event.type,
position.x,
position.y,
event.screenX,
event.screenY,
event.movementX,
event.movementY,
]),
onEnd: (position, event) =>
moves.push([
event.type,
position.x,
position.y,
event.screenX,
event.screenY,
event.movementX,
event.movementY,
]),
});
return () => (
<Box width={4} height={2} ref={dragTarget}>
<Text>drag</Text>
</Box>
);
});
const { app, stdin } = mountMouseApp(App);
await settle();
stdin.emit("data", "\x1b[<0;1;1M\x1b[<65;20;1M\x1b[<32;8;4M\x1b[<0;8;4m");
await settle();
expect(moves).toEqual([
["dragstart", 2, 1, 0, 0, 0, 0],
["drag", 9, 4, 7, 3, 7, 3],
["dragend", 9, 4, 7, 3, 0, 0],
]);
app.unmount();
});
test("useDraggable honors axis", async () => {
const dragTarget = shallowRef<BoxInstance | null>(null);
const positions: Array<[number, number]> = [];
const App = defineComponent(() => {
useDraggable(dragTarget, {
initialValue: { x: 10, y: 20 },
axis: "x",
onMove: (position) => positions.push([position.x, position.y]),
onEnd: (position) => positions.push([position.x, position.y]),
});
return () => (
<Box width={4} height={2} ref={dragTarget}>
<Text>drag</Text>
</Box>
);
});
const { app, stdin } = mountMouseApp(App);
await settle();
stdin.emit("data", "\x1b[<0;1;1M\x1b[<32;6;4M\x1b[<0;6;4m");
await settle();
expect(positions).toEqual([
[15, 20],
[15, 20],
]);
app.unmount();
});
test("useDraggable lets onStart cancel capture", async () => {
const dragTarget = shallowRef<BoxInstance | null>(null);
const moves: string[] = [];
const App = defineComponent(() => {
useDraggable(dragTarget, {
onStart: () => {
moves.push("start");
return false;
},
onMove: () => moves.push("move"),
onEnd: () => moves.push("end"),
});
return () => (
<Box width={4} height={2} ref={dragTarget}>
<Text>drag</Text>
</Box>
);
});
const { app, stdin } = mountMouseApp(App);
await settle();
stdin.emit("data", "\x1b[<0;1;1M\x1b[<32;8;4M\x1b[<0;8;4m");
await settle();
expect(moves).toEqual(["start"]);
app.unmount();
});
test("useDraggable suppresses click after a drag movement", async () => {
const dragTarget = shallowRef<BoxInstance | null>(null);
const clicks: string[] = [];
const drags: string[] = [];
const App = defineComponent(() => {
useDraggable(dragTarget, {
onStart: () => drags.push("start"),
onMove: () => drags.push("move"),
onEnd: () => drags.push("end"),
});
return () => (
<Box width={4} height={2} ref={dragTarget} onClick={() => clicks.push("click")}>
<Text>drag</Text>
</Box>
);
});
const { app, stdin } = mountMouseApp(App);
await settle();
stdin.emit("data", "\x1b[<0;1;1M\x1b[<32;8;4M\x1b[<0;8;4m");
await settle();
expect(drags).toEqual(["start", "move", "end"]);
expect(clicks).toEqual([]);
app.unmount();
});
test("useDraggable releases pointer capture when the target unmounts", async () => {
const dragTarget = shallowRef<BoxInstance | null>(null);
const mounted = shallowRef(true);
const moves: string[] = [];
const App = defineComponent(() => {
useDraggable(dragTarget, {
onStart: () => moves.push("start"),
onMove: () => {
moves.push("move");
mounted.value = false;
},
onEnd: () => moves.push("end"),
});
return () => (
<Box width={8} height={2}>
{mounted.value ? (
<Box width={4} height={1} ref={dragTarget}>
<Text>drag</Text>
</Box>
) : (
<Box width={4} height={1}>
<Text>gone</Text>
</Box>
)}
</Box>
);
});
const { app, stdin } = mountMouseApp(App);
await settle();
stdin.emit("data", "\x1b[<0;1;1M\x1b[<32;6;1M");
await settle();
stdin.emit("data", "\x1b[<32;7;1M\x1b[<0;7;1m");
await settle();
expect(moves).toEqual(["start", "move"]);
app.unmount();
});
test("useDraggable captures middle and right button drags", async () => {
const cases = [
{ button: "middle", sequence: "\x1b[<1;1;1M\x1b[<33;6;2M\x1b[<1;6;2m" },
{ button: "right", sequence: "\x1b[<2;1;1M\x1b[<34;6;2M\x1b[<2;6;2m" },
] as const;
for (const item of cases) {
const dragTarget = shallowRef<BoxInstance | null>(null);
const moves: Array<[string, TuiMouseEvent["button"], number, number]> = [];
const App = defineComponent(() => {
useDraggable(dragTarget, {
onStart: (_position, event) =>
moves.push([event.type, event.button, event.screenX, event.screenY]),
onMove: (_position, event) =>
moves.push([event.type, event.button, event.screenX, event.screenY]),
onEnd: (_position, event) =>
moves.push([event.type, event.button, event.screenX, event.screenY]),
});
return () => (
<Box width={4} height={2} ref={dragTarget}>
<Text>drag</Text>
</Box>
);
});
const { app, stdin } = mountMouseApp(App);
await settle();
stdin.emit("data", item.sequence);
await settle();
expect(moves).toEqual([
["dragstart", item.button, 0, 0],
["drag", item.button, 5, 1],
["dragend", item.button, 5, 1],
]);
app.unmount();
await settle();
}
});
@@ -23,6 +23,7 @@ const PUBLIC_VALUE_EXPORTS = [
"useApp",
"useBoxMetrics",
"useCursor",
"useDraggable",
"useFocus",
"useFocusManager",
"useInput",
@@ -12,9 +12,17 @@
// `check:type` script). This file is named `*.test-d.ts` on purpose so vitest does NOT
// 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 {
shallowRef,
type ComponentPublicInstance,
type MaybeRef,
type MaybeRefOrGetter,
type Ref,
} from "vue";
import {
Box,
useApp,
useDraggable,
useInput,
useMouseInput,
usePaste,
@@ -35,7 +43,19 @@ import type {
NewlineProps,
SpacerProps,
Key,
MouseButton,
MouseHandlerProps,
MouseInputEvent,
MouseTarget,
MouseTargetRect,
TuiMouseEvent,
TuiMouseEventType,
TuiWheelEvent,
UseDraggableAxis,
UseDraggableOptions,
UseDraggablePosition,
UseDraggableReturn,
UseDraggableTarget,
WindowSize,
CursorPosition,
UseAppReturn,
@@ -55,6 +75,12 @@ expectTypeOf<TextProps["backgroundColor"]>().toEqualTypeOf<string | undefined>()
expectTypeOf<BoxProps["backgroundColor"]>().toEqualTypeOf<string | undefined>();
expectTypeOf<BoxProps["borderColor"]>().toEqualTypeOf<string | undefined>();
expectTypeOf<BoxProps["borderBackgroundColor"]>().toEqualTypeOf<string | undefined>();
expectTypeOf<BoxProps["onMousedown"]>().toEqualTypeOf<MouseHandlerProps["onMousedown"]>();
expectTypeOf<BoxProps["onMouseup"]>().toEqualTypeOf<MouseHandlerProps["onMouseup"]>();
expectTypeOf<BoxProps["onClick"]>().toEqualTypeOf<MouseHandlerProps["onClick"]>();
expectTypeOf<BoxProps["onWheel"]>().toEqualTypeOf<MouseHandlerProps["onWheel"]>();
expectTypeOf<TextProps["onClick"]>().toEqualTypeOf<MouseHandlerProps["onClick"]>();
expectTypeOf<TextProps["onWheel"]>().toEqualTypeOf<MouseHandlerProps["onWheel"]>();
expectTypeOf<StaticProps["items"]>().toEqualTypeOf<unknown[]>();
expectTypeOf<StaticProps<string>["items"]>().toEqualTypeOf<string[]>();
expectTypeOf<StaticProps["style"]>().toEqualTypeOf<StaticStyle | undefined>();
@@ -131,3 +157,50 @@ expectTypeOf<MouseInputEvent>().toEqualTypeOf<{
}>();
const mouseHandler = shallowRef((_event: MouseInputEvent) => {});
expectTypeOf(mouseHandler).toMatchTypeOf<Parameters<typeof useMouseInput>[0]>();
expectTypeOf<string>().toMatchTypeOf<MouseButton>();
expectTypeOf<MouseButton>().toMatchTypeOf<string>();
expectTypeOf<"back" | "forward">().toMatchTypeOf<MouseButton>();
expectTypeOf<string>().toMatchTypeOf<TuiMouseEventType>();
expectTypeOf<TuiMouseEventType>().toMatchTypeOf<string>();
expectTypeOf<MouseTargetRect>().toEqualTypeOf<{
readonly x: number;
readonly y: number;
readonly width: number;
readonly height: number;
}>();
expectTypeOf<MouseTarget["rect"]>().toEqualTypeOf<MouseTargetRect>();
expectTypeOf<TuiMouseEvent["type"]>().toEqualTypeOf<TuiMouseEventType>();
expectTypeOf<TuiMouseEvent["button"]>().toEqualTypeOf<MouseButton | null>();
expectTypeOf<TuiMouseEvent["buttons"]>().toEqualTypeOf<ReadonlySet<MouseButton>>();
expectTypeOf<TuiMouseEvent["target"]>().toEqualTypeOf<MouseTarget | null>();
expectTypeOf<TuiMouseEvent["currentTarget"]>().toEqualTypeOf<MouseTarget | null>();
expectTypeOf<TuiMouseEvent["movementX"]>().toEqualTypeOf<number>();
expectTypeOf<TuiMouseEvent["movementY"]>().toEqualTypeOf<number>();
expectTypeOf<TuiWheelEvent["type"]>().toEqualTypeOf<"wheel">();
expectTypeOf<TuiWheelEvent["button"]>().toEqualTypeOf<null>();
expectTypeOf<TuiWheelEvent["deltaX"]>().toEqualTypeOf<number>();
expectTypeOf<TuiWheelEvent["deltaY"]>().toEqualTypeOf<number>();
expectTypeOf<Parameters<typeof useMouseInput>[0]>().toEqualTypeOf<
MaybeRef<(event: MouseInputEvent) => void>
>();
const dragTarget = shallowRef<InstanceType<typeof Box> | null>(null);
expectTypeOf(dragTarget).toMatchTypeOf<Parameters<typeof useDraggable>[0]>();
expectTypeOf<Parameters<typeof useDraggable>[0]>().toEqualTypeOf<UseDraggableTarget>();
expectTypeOf<UseDraggableTarget>().toEqualTypeOf<
MaybeRefOrGetter<ComponentPublicInstance | null | undefined>
>();
expectTypeOf<ReturnType<typeof useDraggable>>().toEqualTypeOf<UseDraggableReturn>();
expectTypeOf<UseDraggableAxis>().toEqualTypeOf<"x" | "y" | "both">();
expectTypeOf<UseDraggableOptions["initialValue"]>().toEqualTypeOf<
UseDraggablePosition | undefined
>();
expectTypeOf<UseDraggableOptions["axis"]>().toEqualTypeOf<UseDraggableAxis | undefined>();
expectTypeOf<UseDraggableOptions["onStart"]>().toEqualTypeOf<
((position: UseDraggablePosition, event: TuiMouseEvent) => void) | undefined
>();
expectTypeOf<UseDraggableReturn["x"]>().toEqualTypeOf<Ref<number>>();
expectTypeOf<UseDraggableReturn["y"]>().toEqualTypeOf<Ref<number>>();
expectTypeOf<UseDraggableReturn["position"]>().toMatchTypeOf<Readonly<Ref<UseDraggablePosition>>>();
+6 -1
View File
@@ -1,5 +1,6 @@
import { type ExtractPublicPropTypes, type PropType } from "vue";
import cliBoxes from "cli-boxes";
import type { MouseHandlerProps } from "../mouse/events.ts";
type Spacing = number;
type FlexDirection = "row" | "row-reverse" | "column" | "column-reverse";
@@ -202,7 +203,11 @@ export const boxProps = {
ariaHidden: Boolean,
ariaRole: String as PropType<AriaRole>,
ariaState: Object as PropType<AriaState>,
onMousedown: Function as PropType<MouseHandlerProps["onMousedown"]>,
onMouseup: Function as PropType<MouseHandlerProps["onMouseup"]>,
onClick: Function as PropType<MouseHandlerProps["onClick"]>,
onWheel: Function as PropType<MouseHandlerProps["onWheel"]>,
};
/** Props accepted by `<Box>` — the vue-tui analogue of Ink's `BoxProps`. */
export type BoxProps = ExtractPublicPropTypes<typeof boxProps>;
export type BoxProps = ExtractPublicPropTypes<typeof boxProps> & MouseHandlerProps;
@@ -1,4 +1,5 @@
import { type ExtractPublicPropTypes, type PropType } from "vue";
import type { MouseHandlerProps } from "../mouse/events.ts";
type WrapMode =
| "wrap"
@@ -20,7 +21,11 @@ export const textProps = {
wrap: { type: String as PropType<WrapMode>, default: "wrap" },
ariaLabel: String,
ariaHidden: Boolean,
onMousedown: Function as PropType<MouseHandlerProps["onMousedown"]>,
onMouseup: Function as PropType<MouseHandlerProps["onMouseup"]>,
onClick: Function as PropType<MouseHandlerProps["onClick"]>,
onWheel: Function as PropType<MouseHandlerProps["onWheel"]>,
};
/** Props accepted by `<Text>` — the vue-tui analogue of Ink's `TextProps`. */
export type TextProps = ExtractPublicPropTypes<typeof textProps>;
export type TextProps = ExtractPublicPropTypes<typeof textProps> & MouseHandlerProps;
@@ -0,0 +1,7 @@
import { getCurrentScope, onScopeDispose } from "vue";
export function tryOnScopeDispose(cleanup: () => void): boolean {
if (!getCurrentScope()) return false;
onScopeDispose(cleanup);
return true;
}
@@ -1,6 +1,6 @@
import { nextTick, shallowRef, watchPostEffect, type Ref, type ShallowRef } from "vue";
import type { Node as YogaNode } from "yoga-layout";
import { addLayoutListener, type TuiNode, type TuiRoot } from "../host/nodes.ts";
import { addLayoutListener } from "../host/nodes.ts";
import { findRootNode, resolveTuiNode, resolveYogaNode } from "../host/resolve-node.ts";
// Yoga's `right`/`bottom` are omitted: always `0` for flow layout and
// unintuitive for absolute positioning. Matches Ink's BoxMetrics type.
@@ -29,91 +29,6 @@ export interface UseBoxMetricsReturn {
readonly hasMeasured: ShallowRef<boolean>;
}
/**
* A component whose root is a `v-if`/`v-else` (e.g. the template-authored `<Box>`)
* renders as a Vue Fragment, so its `$el` resolves to the fragment's BOUNDARY anchor
* (an empty `text-leaf`), NOT the real `tui-box` host node. The actual host node lives in
* the component's `subTree`. Walk that vnode tree to the first `el` that is a genuine
* host node — skipping the comment and empty-`text-leaf` anchors a fragment inserts —
* so a ref to a fragment-rooted Box still resolves to its `tui-box` host node.
*/
function hostElFromSubTree(instance: unknown): Record<string, unknown> | null {
const subTree = (instance as { subTree?: unknown })?.subTree;
return findHostEl(subTree);
}
function findHostEl(vnode: unknown): Record<string, unknown> | null {
if (!vnode || typeof vnode !== "object") return null;
const vn = vnode as { el?: unknown; component?: { subTree?: unknown }; children?: unknown };
const el = vn.el as Record<string, unknown> | undefined;
// A real host node carries a string `type` AND is not an empty boundary anchor.
if (el && typeof el.type === "string" && el.type !== "comment") {
if (!(el.type === "text-leaf" && el.value === "")) return el;
}
// A nested component (e.g. <Box> wrapping another component): descend its subTree.
if (vn.component?.subTree) {
const nested = findHostEl(vn.component.subTree);
if (nested) return nested;
}
// A fragment carries its real children in an array; the box vnode is among them.
if (Array.isArray(vn.children)) {
for (const child of vn.children) {
const found = findHostEl(child);
if (found) return found;
}
}
return null;
}
/**
* Resolve a ref value to the underlying TUI node with a yoga property.
* Handles direct TUI node refs, component instance refs whose `$el` IS the host
* node, and fragment-rooted component refs (whose `$el` is a boundary anchor, so
* the host node is found via the component's subTree).
*/
function resolveYogaNode(value: unknown): { yoga: YogaNode } | null {
if (!value) return null;
const obj = value as Record<string, unknown>;
// 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<string, unknown>).yoga) {
return obj.$el as { yoga: YogaNode };
}
// Fragment-rooted component (template <Box> with a root v-if): drill the subTree.
const host = hostElFromSubTree(obj.$);
if (host?.yoga) return host as { yoga: YogaNode };
return null;
}
/** Resolve a ref value to its underlying TUI node (for tree traversal). */
function resolveTuiNode(value: unknown): TuiNode | null {
if (!value) return null;
const obj = value as Record<string, unknown>;
if (typeof obj.type === "string") return obj as unknown as TuiNode;
// Vue component instance whose `$el` IS a real host node (non-template/unconditional
// root). An empty `text-leaf` `$el` is a fragment boundary anchor, not the element —
// fall through to the subTree drill below so we anchor traversal on the real node.
const el = obj.$el as Record<string, unknown> | undefined;
if (el && typeof el.type === "string" && !(el.type === "text-leaf" && el.value === "")) {
return el as unknown as TuiNode;
}
// Fragment-rooted component (template <Box> with a root v-if): drill the subTree.
const host = hostElFromSubTree(obj.$);
if (host && typeof host.type === "string") return host as unknown as TuiNode;
return null;
}
/** Walk up the DOM tree to find the root node. */
function findRootNode(node: TuiNode | null): TuiRoot | null {
let current: TuiNode | null = node;
while (current) {
if (current.type === "root") return current;
current = current.parent;
}
return null;
}
/**
* Imperative function that reads yoga computed dimensions from a TUI node.
*
@@ -0,0 +1,111 @@
import {
computed,
inject,
nextTick,
shallowRef,
toValue,
watch,
type ComponentPublicInstance,
type MaybeRefOrGetter,
type Ref,
} from "vue";
import { AppContextKey } from "../context.ts";
import { resolveTuiNode } from "../host/resolve-node.ts";
import type { TuiMouseEvent } from "../mouse/events.ts";
import { tryOnScopeDispose } from "./scope.ts";
export interface UseDraggablePosition {
readonly x: number;
readonly y: number;
}
export type UseDraggableAxis = "x" | "y" | "both";
export type UseDraggableTarget = MaybeRefOrGetter<ComponentPublicInstance | null | undefined>;
export interface UseDraggableOptions {
initialValue?: UseDraggablePosition;
axis?: UseDraggableAxis;
onStart?: (position: UseDraggablePosition, event: TuiMouseEvent) => void;
onMove?: (position: UseDraggablePosition, event: TuiMouseEvent) => void;
onEnd?: (position: UseDraggablePosition, event: TuiMouseEvent) => void;
}
export interface UseDraggableReturn {
readonly x: Ref<number>;
readonly y: Ref<number>;
readonly position: Readonly<Ref<UseDraggablePosition>>;
readonly isDragging: Readonly<Ref<boolean>>;
}
export function useDraggable(
target: UseDraggableTarget,
options: UseDraggableOptions = {},
): UseDraggableReturn {
const app = inject(AppContextKey);
if (!app) throw new Error("useDraggable() must be called inside a vue-tui render tree");
const x = shallowRef(options.initialValue?.x ?? 0);
const y = shallowRef(options.initialValue?.y ?? 0);
const position = computed(() => ({ x: x.value, y: y.value }));
const isDragging = shallowRef(false);
const axis = options.axis ?? "both";
let unregister: (() => void) | undefined;
let startPosition: UseDraggablePosition = { x: x.value, y: y.value };
let startPointer: UseDraggablePosition = { x: 0, y: 0 };
function clearRegistration() {
unregister?.();
unregister = undefined;
isDragging.value = false;
}
function updatePosition(event: TuiMouseEvent) {
const nextX = startPosition.x + event.screenX - startPointer.x;
const nextY = startPosition.y + event.screenY - startPointer.y;
if (axis !== "y") x.value = nextX;
if (axis !== "x") y.value = nextY;
}
watch(
() => toValue(target),
(value, _oldValue, onCleanup) => {
clearRegistration();
if (!value) return;
let cancelled = false;
onCleanup(() => {
cancelled = true;
clearRegistration();
});
void nextTick(() => {
if (cancelled) return;
const node = resolveTuiNode(value);
if (!node) return;
unregister = app.internal_mouse?.registerDraggable(node, {
onStart(event) {
const result = options.onStart?.(position.value, event) as unknown;
if (result === false) return false;
startPosition = { x: x.value, y: y.value };
startPointer = { x: event.screenX, y: event.screenY };
isDragging.value = true;
},
onMove(event) {
updatePosition(event);
options.onMove?.(position.value, event);
},
onEnd(event) {
updatePosition(event);
isDragging.value = false;
options.onEnd?.(position.value, event);
},
});
});
},
{ immediate: true, flush: "post" },
);
tryOnScopeDispose(clearRegistration);
return { x, y, position, isDragging };
}
@@ -1,14 +1,7 @@
import {
inject,
onScopeDispose,
toValue,
unref,
watch,
type MaybeRef,
type MaybeRefOrGetter,
} from "vue";
import { inject, toValue, unref, watch, type MaybeRef, type MaybeRefOrGetter } from "vue";
import { StdinContextKey } from "../context.ts";
import type { MouseInputEvent } from "../io/parse-mouse.ts";
import { tryOnScopeDispose } from "./scope.ts";
export type { MouseInputEvent } from "../io/parse-mouse.ts";
@@ -36,7 +29,7 @@ export function useMouseInput(
if (attached) return;
stdin!.acquireRawMode();
try {
mouseModeToken = stdin!.acquireSgrMouseMode();
mouseModeToken = stdin!.acquireSgrMouseMode("button");
stdin!.internal_eventEmitter.on("mouse", listener);
attached = true;
} catch (error) {
@@ -67,5 +60,5 @@ export function useMouseInput(
{ immediate: true, flush: "sync" },
);
onScopeDispose(detach);
tryOnScopeDispose(detach);
}
+5 -1
View File
@@ -1,6 +1,7 @@
import type { InjectionKey, ShallowRef } from "vue";
import type { EventEmitter } from "node:events";
import type { AnimationScheduler } from "./animation-scheduler.ts";
import type { MouseController } from "./mouse/controller.ts";
export interface CursorPosition {
x: number;
@@ -22,6 +23,7 @@ export interface AppContext {
writeToStderr: (data: string) => void;
cursorPosition: CursorPosition | undefined;
setCursorPosition: (pos: CursorPosition | undefined) => void;
internal_mouse?: MouseController;
}
export interface FocusContext {
@@ -50,10 +52,12 @@ export interface StdinContext {
acquireRawMode: () => void;
releaseRawMode: () => void;
setBracketedPasteMode: (enabled: boolean) => void;
acquireSgrMouseMode: () => symbol;
acquireSgrMouseMode: (level?: SgrMouseMode) => symbol;
releaseSgrMouseMode: (token: symbol) => void;
}
export type SgrMouseMode = "button" | "drag" | "hover";
export const AppContextKey: InjectionKey<AppContext> = Symbol("vue-tui:app");
export const FocusContextKey: InjectionKey<FocusContext> = Symbol("vue-tui:focus");
export const StdinContextKey: InjectionKey<StdinContext> = Symbol("vue-tui:stdin");
+53
View File
@@ -12,6 +12,7 @@ import {
type TuiNode,
type TuiRoot,
} from "./nodes.ts";
import type { MouseHandlerName, MouseHandlerProps } from "../mouse/events.ts";
import {
attachYoga,
detachYoga,
@@ -96,6 +97,42 @@ const STYLE_PROPS = new Set([
"paddingRight",
]);
const MOUSE_HANDLER_PROPS = new Set<MouseHandlerName>([
"onMousedown",
"onMouseup",
"onClick",
"onWheel",
]);
function isMouseHandlerProp(key: string): key is MouseHandlerName {
return MOUSE_HANDLER_PROPS.has(key as MouseHandlerName);
}
function setStoredMouseHandler(node: TuiNode, key: MouseHandlerName, handler: unknown): void {
const withHandlers = node as { mouseHandlers?: Partial<MouseHandlerProps> };
const handlers = (withHandlers.mouseHandlers ??= {});
if (typeof handler === "function") {
handlers[key] = handler as never;
return;
}
delete handlers[key];
}
function registerStoredMouseHandlers(node: TuiNode, root: TuiRoot): void {
const controller = root.appContext.internal_mouse;
if (!controller) return;
const handlers = (node as { mouseHandlers?: Partial<MouseHandlerProps> }).mouseHandlers;
if (handlers) {
for (const key of MOUSE_HANDLER_PROPS) {
const handler = handlers[key];
if (handler) controller.setHandler(node, key, handler);
}
}
if (isContainer(node)) {
for (const child of node.children) registerStoredMouseHandlers(child, root);
}
}
/** Walk up the DOM tree to find the root node. */
function findRoot(node: TuiNode): TuiRoot | null {
let current: TuiNode | null = node;
@@ -360,6 +397,8 @@ export function buildNodeOps(options: TtyRendererOptions): RendererOptions<TuiNo
const root = findRoot(child);
if (root) root.staticNode = child;
}
const root = findRoot(child);
if (root) registerStoredMouseHandlers(child, root);
onCommit();
}
@@ -367,6 +406,7 @@ export function buildNodeOps(options: TtyRendererOptions): RendererOptions<TuiNo
function remove(child: TuiNode): void {
const parent = child.parent;
if (!parent) return;
findRoot(child)?.appContext.internal_mouse?.removeNode(child);
// Track static node removal: clear root.staticNode only if it still
// points at this node. On key-driven remounts, insert() already
@@ -426,6 +466,19 @@ export function buildNodeOps(options: TtyRendererOptions): RendererOptions<TuiNo
}
function patchProp(el: TuiNode, key: string, prev: unknown, next: unknown): void {
if (isMouseHandlerProp(key)) {
if (el.type === "tui-box" || el.type === "tui-text" || el.type === "tui-virtual-text") {
const root = findRoot(el);
if (root?.appContext.internal_mouse) {
root.appContext.internal_mouse.setHandler(el, key, next);
} else {
setStoredMouseHandler(el, key, next);
}
}
onCommit();
return;
}
if (el.type === "tui-transform") {
if (key === "transform" && typeof next === "function") {
el.transform = next as (line: string, idx: number) => string;
+4
View File
@@ -1,5 +1,6 @@
import type { AppContext } from "../context.ts";
import type { Node as YogaNode } from "yoga-layout";
import type { MouseHandlerProps } from "../mouse/events.ts";
export type YogaNodeRef = YogaNode;
@@ -44,6 +45,7 @@ export interface TuiBox extends NodeBase {
children: TuiNode[];
yoga: YogaNodeRef;
props: BoxProps;
mouseHandlers?: Partial<MouseHandlerProps>;
paintDirty: boolean;
internal_accessibility?: {
role?: string;
@@ -56,6 +58,7 @@ export interface TuiText extends NodeBase {
children: TuiInlineNode[];
yoga: YogaNodeRef;
props: TextProps;
mouseHandlers?: Partial<MouseHandlerProps>;
measuredCache?: string;
}
@@ -66,6 +69,7 @@ export interface TuiVirtualText extends NodeBase {
parent: TuiText | TuiVirtualText | TuiTransform | null;
children: TuiInlineNode[];
props: TextProps;
mouseHandlers?: Partial<MouseHandlerProps>;
}
export interface TuiTextLeaf extends NodeBase {
+62
View File
@@ -0,0 +1,62 @@
import type { Node as YogaNode } from "yoga-layout";
import type { TuiNode, TuiRoot } from "./nodes.ts";
function hasYoga(value: unknown): value is { yoga: YogaNode } {
return Boolean(value && typeof value === "object" && "yoga" in value);
}
function hostElFromSubTree(instance: unknown): Record<string, unknown> | null {
const subTree = (instance as { subTree?: unknown })?.subTree;
return findHostEl(subTree);
}
function findHostEl(vnode: unknown): Record<string, unknown> | null {
if (!vnode || typeof vnode !== "object") return null;
const vn = vnode as { el?: unknown; component?: { subTree?: unknown }; children?: unknown };
const el = vn.el as Record<string, unknown> | undefined;
if (el && typeof el.type === "string" && el.type !== "comment") {
if (!(el.type === "text-leaf" && el.value === "")) return el;
}
if (vn.component?.subTree) {
const nested = findHostEl(vn.component.subTree);
if (nested) return nested;
}
if (Array.isArray(vn.children)) {
for (const child of vn.children) {
const found = findHostEl(child);
if (found) return found;
}
}
return null;
}
export function resolveTuiNode(value: unknown): TuiNode | null {
if (!value) return null;
const obj = value as Record<string, unknown>;
if (typeof obj.type === "string") return obj as unknown as TuiNode;
const el = obj.$el as Record<string, unknown> | undefined;
if (el && typeof el.type === "string" && !(el.type === "text-leaf" && el.value === "")) {
return el as unknown as TuiNode;
}
const host = hostElFromSubTree(obj.$);
if (host && typeof host.type === "string") return host as unknown as TuiNode;
return null;
}
export function resolveYogaNode(value: unknown): { yoga: YogaNode } | null {
if (hasYoga(value)) return value;
const el = (value as { $el?: unknown } | null | undefined)?.$el;
if (hasYoga(el)) return el;
const tuiNode = resolveTuiNode(value);
if (tuiNode && "yoga" in tuiNode) return tuiNode as { yoga: YogaNode };
return null;
}
export function findRootNode(node: TuiNode | null): TuiRoot | null {
let current: TuiNode | null = node;
while (current) {
if (current.type === "root") return current;
current = current.parent;
}
return null;
}
+17
View File
@@ -61,6 +61,23 @@ export {
type MouseInputEvent,
type UseMouseInputOptions,
} from "./composables/useMouseInput.ts";
export {
useDraggable,
type UseDraggableAxis,
type UseDraggableOptions,
type UseDraggablePosition,
type UseDraggableReturn,
type UseDraggableTarget,
} from "./composables/useDraggable.ts";
export type {
MouseButton,
MouseHandlerProps,
MouseTarget,
MouseTargetRect,
TuiMouseEvent,
TuiMouseEventType,
TuiWheelEvent,
} from "./mouse/events.ts";
export { usePaste, type UsePasteOptions } from "./composables/usePaste.ts";
export { useFocus, type UseFocusOptions } from "./composables/useFocus.ts";
export { useFocusManager } from "./composables/useFocusManager.ts";
@@ -0,0 +1,76 @@
import { describe, expect, test } from "vite-plus/test";
import { parseMouseInput, parseSgrMouseInput } from "./parse-mouse.ts";
describe("parseSgrMouseInput", () => {
test("decodes button press, release, and drag events", () => {
expect(parseSgrMouseInput("\x1b[<0;3;4M")).toEqual({
type: "down",
button: "left",
x: 3,
y: 4,
shift: false,
meta: false,
ctrl: false,
});
expect(parseSgrMouseInput("\x1b[<1;5;6m")).toEqual({
type: "up",
button: "middle",
x: 5,
y: 6,
shift: false,
meta: false,
ctrl: false,
});
expect(parseSgrMouseInput("\x1b[<34;7;8M")).toEqual({
type: "drag",
button: "right",
x: 7,
y: 8,
shift: false,
meta: false,
ctrl: false,
});
});
test("keeps SGR modifier bits on all event kinds", () => {
expect(parseSgrMouseInput("\x1b[<28;9;10M")).toEqual({
type: "down",
button: "left",
x: 9,
y: 10,
shift: true,
meta: true,
ctrl: true,
});
});
test("decodes vertical and horizontal wheel directions", () => {
expect(parseSgrMouseInput("\x1b[<64;1;2M")).toMatchObject({ type: "wheel", direction: "up" });
expect(parseSgrMouseInput("\x1b[<65;1;2M")).toMatchObject({ type: "wheel", direction: "down" });
expect(parseSgrMouseInput("\x1b[<66;1;2M")).toMatchObject({ type: "wheel", direction: "left" });
expect(parseSgrMouseInput("\x1b[<67;1;2M")).toMatchObject({
type: "wheel",
direction: "right",
});
});
test("drops unsupported side-button sequences", () => {
expect(parseSgrMouseInput("\x1b[<3;1;2M")).toBeUndefined();
expect(parseSgrMouseInput("\x1b[<35;1;2M")).toBeUndefined();
expect(parseSgrMouseInput("\x1b[<3;1;2m")).toBeUndefined();
});
test("keeps the public useMouseInput parser wheel-only and vertical-only", () => {
expect(parseMouseInput("\x1b[<64;1;2M")).toEqual({
type: "wheel",
direction: "up",
x: 1,
y: 2,
shift: false,
meta: false,
ctrl: false,
});
expect(parseMouseInput("\x1b[<0;1;2M")).toBeUndefined();
expect(parseMouseInput("\x1b[<66;1;2M")).toBeUndefined();
});
});
+120 -11
View File
@@ -3,8 +3,13 @@ const SHIFT_MASK = 4;
const META_MASK = 8;
const CTRL_MASK = 16;
const MODIFIER_MASK = SHIFT_MASK | META_MASK | CTRL_MASK;
const DRAG_MASK = 32;
const WHEEL_UP = 64;
const WHEEL_DOWN = 65;
const WHEEL_LEFT = 66;
const WHEEL_RIGHT = 67;
export type SgrMouseButton = "left" | "middle" | "right";
interface SgrMouseSequence {
readonly button: number;
@@ -23,6 +28,32 @@ export interface MouseInputEvent {
readonly ctrl: boolean;
}
export interface SgrMouseButtonEvent {
readonly type: "down" | "up" | "drag";
readonly button: SgrMouseButton;
/** 1-based SGR wire coordinate. */
readonly x: number;
/** 1-based SGR wire coordinate. */
readonly y: number;
readonly shift: boolean;
readonly meta: boolean;
readonly ctrl: boolean;
}
export interface SgrMouseWheelEvent {
readonly type: "wheel";
readonly direction: "up" | "down" | "left" | "right";
/** 1-based SGR wire coordinate. */
readonly x: number;
/** 1-based SGR wire coordinate. */
readonly y: number;
readonly shift: boolean;
readonly meta: boolean;
readonly ctrl: boolean;
}
export type SgrMouseEvent = SgrMouseButtonEvent | SgrMouseWheelEvent;
function parseSgrMouseSequence(input: string): SgrMouseSequence | undefined {
const match = SGR_MOUSE_INPUT.exec(input);
if (!match) return undefined;
@@ -40,20 +71,98 @@ 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;
function readModifiers(button: number) {
return {
shift: Boolean(button & SHIFT_MASK),
meta: Boolean(button & META_MASK),
ctrl: Boolean(button & CTRL_MASK),
};
}
const baseButton = sequence.button & ~MODIFIER_MASK;
if (baseButton !== WHEEL_UP && baseButton !== WHEEL_DOWN) return undefined;
function decodeButton(button: number): SgrMouseButton | undefined {
switch (button) {
case 0:
return "left";
case 1:
return "middle";
case 2:
return "right";
default:
return undefined;
}
}
function decodeWheelDirection(button: number): SgrMouseWheelEvent["direction"] | undefined {
switch (button) {
case WHEEL_UP:
return "up";
case WHEEL_DOWN:
return "down";
case WHEEL_LEFT:
return "left";
case WHEEL_RIGHT:
return "right";
default:
return undefined;
}
}
export function parseSgrMouseInput(input: string): SgrMouseEvent | undefined {
const sequence = parseSgrMouseSequence(input);
if (!sequence) return undefined;
const modifiers = readModifiers(sequence.button);
if (sequence.final === "M") {
const wheelButton = sequence.button & ~MODIFIER_MASK;
const wheelDirection = decodeWheelDirection(wheelButton);
if (wheelDirection) {
return {
type: "wheel",
direction: wheelDirection,
x: sequence.x,
y: sequence.y,
...modifiers,
};
}
const isDrag = Boolean(sequence.button & DRAG_MASK);
const button = decodeButton(sequence.button & ~(MODIFIER_MASK | DRAG_MASK));
if (!button) return undefined;
return {
type: isDrag ? "drag" : "down",
button,
x: sequence.x,
y: sequence.y,
...modifiers,
};
}
const button = decodeButton(sequence.button & ~(MODIFIER_MASK | DRAG_MASK));
if (!button) return undefined;
return {
type: "up",
button,
x: sequence.x,
y: sequence.y,
...modifiers,
};
}
export function parseMouseInput(input: string): MouseInputEvent | undefined {
const event = parseSgrMouseInput(input);
if (!event || event.type !== "wheel") return undefined;
if (event.direction !== "up" && event.direction !== "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),
direction: event.direction,
x: event.x,
y: event.y,
shift: event.shift,
meta: event.meta,
ctrl: event.ctrl,
};
}
+451
View File
@@ -0,0 +1,451 @@
import type { SgrMouseEvent } from "../io/parse-mouse.ts";
import type { StdinContext } from "../context.ts";
import { isContainer, type TuiContainer, type TuiNode } from "../host/nodes.ts";
import type {
MouseButton,
MouseHandlerName,
MouseHandlerProps,
MouseTarget,
MouseTargetRect,
TuiMouseEvent,
TuiWheelEvent,
} from "./events.ts";
import { forgetMouseTarget, getMouseTarget } from "./target.ts";
export interface MouseHitMapEntry {
readonly node: TuiNode;
readonly rect: MouseTargetRect;
}
export interface DraggableRegistration {
readonly onStart?: (event: TuiMouseEvent) => void | false;
readonly onMove?: (event: TuiMouseEvent) => void;
readonly onEnd?: (event: TuiMouseEvent) => void;
}
export interface MouseController {
readonly fullscreen: boolean;
setHandler(node: TuiNode, name: MouseHandlerName, handler: unknown): void;
updateHitMap(entries: readonly MouseHitMapEntry[]): void;
removeNode(node: TuiNode): void;
registerDraggable(node: TuiNode, registration: DraggableRegistration): () => void;
}
interface CreateMouseControllerOptions {
readonly stdin: StdinContext;
readonly fullscreen: boolean;
readonly now: () => number;
}
type MutableMouseEvent = TuiMouseEvent & {
currentTarget: MouseTarget | null;
offsetX: number;
offsetY: number;
};
type MutableWheelEvent = TuiWheelEvent & {
currentTarget: MouseTarget | null;
offsetX: number;
offsetY: number;
};
const CLICK_DETAIL_WINDOW_MS = 500;
const INLINE_MOUSE_WARNING =
"[vue-tui] Mouse handlers only fire in fullscreen mode. Use app.mount({ fullscreen: true }) for targeted element mouse events, or useMouseInput() for raw inline mouse input.";
function hasMouseHandlers(node: TuiNode): boolean {
const handlers = (node as { mouseHandlers?: Partial<MouseHandlerProps> }).mouseHandlers;
return Boolean(
handlers?.onMousedown || handlers?.onMouseup || handlers?.onClick || handlers?.onWheel,
);
}
function handlerNameFor(type: "down" | "up" | "click" | "wheel"): MouseHandlerName {
switch (type) {
case "down":
return "onMousedown";
case "up":
return "onMouseup";
case "click":
return "onClick";
case "wheel":
return "onWheel";
}
}
function rectContains(rect: MouseTargetRect, x: number, y: number): boolean {
return x >= rect.x && y >= rect.y && x < rect.x + rect.width && y < rect.y + rect.height;
}
function parentOf(node: TuiNode): TuiContainer | null {
return node.parent;
}
export function createMouseController(options: CreateMouseControllerOptions): MouseController {
const { stdin, fullscreen, now } = options;
const handlerNodes = new Set<TuiNode>();
const hitMap: MouseHitMapEntry[] = [];
const pressedButtons = new Set<MouseButton>();
const draggables = new Map<TuiNode, Set<DraggableRegistration>>();
let warnedInline = false;
let rawModeAcquired = false;
let mouseModeToken: symbol | undefined;
let lastPointer: { screenX: number; screenY: number } | undefined;
let lastDown: { node: TuiNode; button: MouseButton } | undefined;
let lastClick:
| {
node: TuiNode;
button: MouseButton;
screenX: number;
screenY: number;
time: number;
detail: number;
}
| undefined;
let capturedNode: TuiNode | undefined;
let activeDrag:
| { node: TuiNode; registration: DraggableRegistration; x: number; y: number; moved: boolean }
| undefined;
function warnInlineOnce() {
if (fullscreen || warnedInline) return;
warnedInline = true;
// Deliberately not NODE_ENV-gated: an inline mouse handler is a dead end in production too.
// eslint-disable-next-line no-console
console.warn(INLINE_MOUSE_WARNING);
}
function shouldArm(): boolean {
return fullscreen && (handlerNodes.size > 0 || draggables.size > 0);
}
function attach() {
if (rawModeAcquired) return;
stdin.acquireRawMode();
try {
mouseModeToken = stdin.acquireSgrMouseMode("drag");
stdin.internal_eventEmitter.on("internal_mouse", onRawMouse);
rawModeAcquired = true;
} catch (error) {
mouseModeToken = undefined;
stdin.releaseRawMode();
throw error;
}
}
function detach() {
if (!rawModeAcquired) return;
rawModeAcquired = false;
stdin.internal_eventEmitter.off("internal_mouse", onRawMouse);
if (mouseModeToken) {
stdin.releaseSgrMouseMode(mouseModeToken);
mouseModeToken = undefined;
}
stdin.releaseRawMode();
}
function reconcileArmed() {
if (shouldArm()) attach();
else detach();
}
function hitTest(screenX: number, screenY: number): TuiNode | undefined {
for (let index = hitMap.length - 1; index >= 0; index--) {
const entry = hitMap[index]!;
if (rectContains(entry.rect, screenX, screenY)) return entry.node;
}
return undefined;
}
function makeMouseEvent(
type: TuiMouseEvent["type"],
targetNode: TuiNode,
raw: Extract<SgrMouseEvent, { type: "down" | "up" | "drag" }>,
movementX: number,
movementY: number,
detail: number,
): { event: MutableMouseEvent; stopped: () => boolean } {
let stopped = false;
const target = getMouseTarget(targetNode);
const event = {
type,
button: raw.button,
buttons: new Set(pressedButtons),
ctrlKey: raw.ctrl,
shiftKey: raw.shift,
altKey: raw.meta,
metaKey: false,
offsetX: 0,
offsetY: 0,
screenX: raw.x - 1,
screenY: raw.y - 1,
target,
currentTarget: null,
stopPropagation() {
stopped = true;
},
preventDefault() {},
defaultPrevented: false,
detail,
movementX,
movementY,
} satisfies MutableMouseEvent;
return { event, stopped: () => stopped };
}
function makeWheelEvent(
targetNode: TuiNode,
raw: Extract<SgrMouseEvent, { type: "wheel" }>,
): { event: MutableWheelEvent; stopped: () => boolean } {
let stopped = false;
const target = getMouseTarget(targetNode);
const event = {
type: "wheel",
button: null,
buttons: new Set(pressedButtons),
ctrlKey: raw.ctrl,
shiftKey: raw.shift,
altKey: raw.meta,
metaKey: false,
offsetX: 0,
offsetY: 0,
screenX: raw.x - 1,
screenY: raw.y - 1,
target,
currentTarget: null,
stopPropagation() {
stopped = true;
},
preventDefault() {},
defaultPrevented: false,
detail: 0,
deltaX: raw.direction === "left" ? -1 : raw.direction === "right" ? 1 : 0,
deltaY: raw.direction === "up" ? -1 : raw.direction === "down" ? 1 : 0,
} satisfies MutableWheelEvent;
return { event, stopped: () => stopped };
}
function withCurrentTarget<Event extends MutableMouseEvent | MutableWheelEvent>(
event: Event,
currentTarget: MouseTarget,
): Event {
const rect = currentTarget.rect;
return {
...event,
currentTarget,
offsetX: event.screenX - rect.x,
offsetY: event.screenY - rect.y,
} as Event;
}
function dispatchMouseEvent(
type: "down" | "up" | "click",
targetNode: TuiNode,
raw: Extract<SgrMouseEvent, { type: "down" | "up" | "drag" }>,
movementX: number,
movementY: number,
detail: number,
) {
const { event, stopped } = makeMouseEvent(type, targetNode, raw, movementX, movementY, detail);
const handlerName = handlerNameFor(type);
let current: TuiNode | null = targetNode;
while (current) {
const handlers = (current as { mouseHandlers?: Partial<MouseHandlerProps> }).mouseHandlers;
const handler = handlers?.[handlerName] as ((event: TuiMouseEvent) => void) | undefined;
if (handler) {
handler(withCurrentTarget<MutableMouseEvent>(event, getMouseTarget(current)));
if (stopped()) return;
}
current = parentOf(current);
}
}
function dispatchWheelEvent(targetNode: TuiNode, raw: Extract<SgrMouseEvent, { type: "wheel" }>) {
const { event, stopped } = makeWheelEvent(targetNode, raw);
let current: TuiNode | null = targetNode;
while (current) {
const handlers = (current as { mouseHandlers?: Partial<MouseHandlerProps> }).mouseHandlers;
const handler = handlers?.onWheel;
if (handler) {
handler(withCurrentTarget<MutableWheelEvent>(event, getMouseTarget(current)));
if (stopped()) return;
}
current = parentOf(current);
}
}
function makeDragEvent(
type: "dragstart" | "drag" | "dragend",
node: TuiNode,
raw: Extract<SgrMouseEvent, { type: "down" | "up" | "drag" }>,
movementX: number,
movementY: number,
): TuiMouseEvent {
const { event } = makeMouseEvent(type, node, raw, movementX, movementY, 0);
return withCurrentTarget<MutableMouseEvent>(event, getMouseTarget(node));
}
function findDraggable(
start: TuiNode | undefined,
): { node: TuiNode; registration: DraggableRegistration } | undefined {
let current: TuiNode | null | undefined = start;
while (current) {
const registrations = draggables.get(current);
const registration = registrations?.values().next().value as
| DraggableRegistration
| undefined;
if (registration) return { node: current, registration };
current = parentOf(current);
}
return undefined;
}
function onRawMouse(raw: SgrMouseEvent) {
const screenX = raw.x - 1;
const screenY = raw.y - 1;
if (raw.type === "wheel") {
const targetNode = capturedNode ?? hitTest(screenX, screenY);
if (targetNode) dispatchWheelEvent(targetNode, raw);
return;
}
const movementX = lastPointer ? screenX - lastPointer.screenX : 0;
const movementY = lastPointer ? screenY - lastPointer.screenY : 0;
lastPointer = { screenX, screenY };
if (raw.type === "down") {
pressedButtons.add(raw.button);
} else if (raw.type === "up") {
pressedButtons.delete(raw.button);
}
const targetNode = capturedNode ?? hitTest(screenX, screenY);
if (!targetNode) return;
if (raw.type === "down") {
lastDown = { node: targetNode, button: raw.button };
dispatchMouseEvent("down", targetNode, raw, 0, 0, 0);
const draggable = findDraggable(targetNode);
if (draggable) {
const startResult = draggable.registration.onStart?.(
makeDragEvent("dragstart", draggable.node, raw, 0, 0),
);
if (startResult === false) return;
if (!draggables.get(draggable.node)?.has(draggable.registration)) return;
capturedNode = draggable.node;
activeDrag = { ...draggable, x: screenX, y: screenY, moved: false };
}
return;
}
if (raw.type === "drag") {
if (activeDrag) {
activeDrag.moved = true;
activeDrag.x = screenX;
activeDrag.y = screenY;
activeDrag.registration.onMove?.(
makeDragEvent("drag", activeDrag.node, raw, movementX, movementY),
);
}
return;
}
dispatchMouseEvent("up", targetNode, raw, movementX, movementY, 0);
const suppressClick = activeDrag?.moved === true;
if (activeDrag) {
activeDrag.registration.onEnd?.(
makeDragEvent("dragend", activeDrag.node, raw, movementX, movementY),
);
activeDrag = undefined;
capturedNode = undefined;
}
if (
!suppressClick &&
lastDown &&
lastDown.node === targetNode &&
lastDown.button === raw.button
) {
const time = now();
const detail =
lastClick &&
lastClick.node === targetNode &&
lastClick.button === raw.button &&
lastClick.screenX === screenX &&
lastClick.screenY === screenY &&
time - lastClick.time <= CLICK_DETAIL_WINDOW_MS
? lastClick.detail + 1
: 1;
lastClick = { node: targetNode, button: raw.button, screenX, screenY, time, detail };
dispatchMouseEvent("click", targetNode, raw, 0, 0, detail);
}
lastDown = undefined;
}
function removeNode(node: TuiNode) {
handlerNodes.delete(node);
draggables.delete(node);
if (capturedNode === node) {
capturedNode = undefined;
activeDrag = undefined;
}
if (lastDown?.node === node) lastDown = undefined;
if (lastClick?.node === node) lastClick = undefined;
forgetMouseTarget(node);
if (isContainer(node)) {
for (const child of node.children) removeNode(child);
}
}
return {
fullscreen,
setHandler(node, name, handler) {
const before = handlerNodes.has(node);
const handlers = ((node as { mouseHandlers?: Partial<MouseHandlerProps> }).mouseHandlers ??=
{});
if (typeof handler === "function") {
handlers[name] = handler as never;
if (!fullscreen) warnInlineOnce();
} else {
delete handlers[name];
}
const after = hasMouseHandlers(node);
if (before !== after) {
if (after) handlerNodes.add(node);
else handlerNodes.delete(node);
reconcileArmed();
}
},
updateHitMap(entries) {
hitMap.length = 0;
if (fullscreen) hitMap.push(...entries);
},
removeNode(node) {
removeNode(node);
reconcileArmed();
},
registerDraggable(node, registration) {
let registrations = draggables.get(node);
if (!registrations) {
registrations = new Set();
draggables.set(node, registrations);
}
registrations.add(registration);
if (!fullscreen) warnInlineOnce();
reconcileArmed();
return () => {
const current = draggables.get(node);
if (!current) return;
current.delete(registration);
if (current.size === 0) draggables.delete(node);
if (activeDrag?.registration === registration) {
activeDrag = undefined;
capturedNode = undefined;
}
reconcileArmed();
};
},
};
}
+88
View File
@@ -0,0 +1,88 @@
/** Which button. String union, deliberately friendlier than DOM's numeric `button`. */
export type MouseButton = "left" | "middle" | "right" | "back" | "forward" | (string & {});
export type TuiMouseEventType =
| "down"
| "up"
| "click"
| "move"
| "drag"
| "dragstart"
| "dragend"
| "enter"
| "leave"
| (string & {});
export interface MouseTargetRect {
/** Absolute terminal cell column, 0-based. */
readonly x: number;
/** Absolute terminal cell row, 0-based. */
readonly y: number;
readonly width: number;
readonly height: number;
}
export interface MouseTarget {
/** The target's absolute terminal-cell rectangle from the latest fullscreen frame. */
readonly rect: MouseTargetRect;
}
interface MouseEventShared {
/** Button for down/up/click/drag; `null` for move/enter/leave/wheel. */
readonly button: MouseButton | null;
/** Buttons currently held. Best-effort because SGR reports one button per event. */
readonly buttons: ReadonlySet<MouseButton>;
readonly ctrlKey: boolean;
readonly shiftKey: boolean;
readonly altKey: boolean;
readonly metaKey: boolean;
readonly offsetX: number;
readonly offsetY: number;
readonly screenX: number;
readonly screenY: number;
readonly target: MouseTarget | null;
readonly currentTarget: MouseTarget | null;
stopPropagation(): void;
preventDefault(): void;
readonly defaultPrevented: boolean;
readonly detail: number;
}
export interface TuiMouseEvent extends MouseEventShared {
readonly type: TuiMouseEventType;
readonly movementX: number;
readonly movementY: number;
}
export interface TuiWheelEvent extends MouseEventShared {
readonly type: "wheel";
readonly button: null;
readonly deltaX: number;
readonly deltaY: number;
}
/** v1 mouse handler props. Hover handlers are deliberately absent until mode 1003 ships. */
export interface MouseHandlerProps {
/**
* Fires only in `fullscreen` mode. For targeted element mouse events use
* `app.mount({ fullscreen: true })`; for raw inline mouse input use `useMouseInput()`.
*/
onMousedown?: (event: TuiMouseEvent) => void;
/**
* Fires only in `fullscreen` mode. For targeted element mouse events use
* `app.mount({ fullscreen: true })`; for raw inline mouse input use `useMouseInput()`.
*/
onMouseup?: (event: TuiMouseEvent) => void;
/**
* Fires only in `fullscreen` mode. For targeted element mouse events use
* `app.mount({ fullscreen: true })`; for raw inline mouse input use `useMouseInput()`.
*/
onClick?: (event: TuiMouseEvent) => void;
/**
* Fires only in `fullscreen` mode. For targeted element mouse events use
* `app.mount({ fullscreen: true })`; for raw inline mouse input use `useMouseInput()`.
*/
onWheel?: (event: TuiWheelEvent) => void;
}
export type MouseHandlerName = keyof MouseHandlerProps;
+43
View File
@@ -0,0 +1,43 @@
import { toRaw } from "vue";
import type { TuiNode } from "../host/nodes.ts";
import type { MouseTarget, MouseTargetRect } from "./events.ts";
const ZERO_RECT: MouseTargetRect = { x: 0, y: 0, width: 0, height: 0 };
const nodeToTarget = new WeakMap<TuiNode, InternalMouseTarget>();
const nodeRects = new WeakMap<TuiNode, MouseTargetRect>();
const internalTargetNodes = new WeakMap<InternalMouseTarget, TuiNode>();
class InternalMouseTarget implements MouseTarget {
constructor(node: TuiNode) {
internalTargetNodes.set(this, node);
}
get rect(): MouseTargetRect {
const node = internalTargetNodes.get(toRaw(this));
return node ? (nodeRects.get(node) ?? ZERO_RECT) : ZERO_RECT;
}
}
export function getMouseTarget(node: TuiNode): MouseTarget {
let target = nodeToTarget.get(node);
if (!target) {
target = new InternalMouseTarget(node);
nodeToTarget.set(node, target);
}
return target;
}
export function setMouseTargetRect(node: TuiNode, rect: MouseTargetRect): void {
nodeRects.set(node, rect);
}
export function clearMouseTargetRect(node: TuiNode): void {
nodeRects.delete(node);
}
export function forgetMouseTarget(node: TuiNode): void {
const target = nodeToTarget.get(node);
if (target) internalTargetNodes.delete(target);
nodeRects.delete(node);
nodeToTarget.delete(node);
}
+202 -7
View File
@@ -26,10 +26,13 @@ import {
createRoot as createIsoRoot,
createBox as createIsoBox,
advancesLineIndex,
isContainer,
} from "../host/nodes.ts";
import { calculateLayoutWithContentGuards } from "../host/layout-guards.ts";
import { wrapText, safeSliceEnd } from "../host/text-measure.ts";
import { attachYoga, detachYoga } from "../host/yoga.ts";
import type { MouseHitMapEntry } from "../mouse/controller.ts";
import { clearMouseTargetRect, setMouseTargetRect } from "../mouse/target.ts";
export type Transformer = (line: string, lineIndex: number) => string;
@@ -602,13 +605,145 @@ function fillBackground(
for (let i = 0; i < height; i++) output.write(x, y + i, [line], transformers);
}
export function paint(root: TuiNode): string {
interface HitRect {
readonly x: number;
readonly y: number;
readonly width: number;
readonly height: number;
}
export interface PaintOptions {
readonly hitMap?: MouseHitMapEntry[];
}
function intersectHitRect(rect: HitRect, clip: HitRect | undefined): HitRect | undefined {
if (!clip) return rect.width > 0 && rect.height > 0 ? rect : undefined;
const x1 = Math.max(rect.x, clip.x);
const y1 = Math.max(rect.y, clip.y);
const x2 = Math.min(rect.x + rect.width, clip.x + clip.width);
const y2 = Math.min(rect.y + rect.height, clip.y + clip.height);
const width = Math.max(0, x2 - x1);
const height = Math.max(0, y2 - y1);
return width > 0 && height > 0 ? { x: x1, y: y1, width, height } : undefined;
}
function recordHit(
hitMap: MouseHitMapEntry[] | undefined,
node: TuiNode,
rect: HitRect,
clip: HitRect | undefined,
) {
if (!hitMap) return;
const visible = intersectHitRect(rect, clip);
if (!visible) {
clearMouseTargetRect(node);
return;
}
setMouseTargetRect(node, visible);
hitMap?.push({ node, rect: visible });
}
function clearSubtreeHitRects(node: TuiNode): void {
clearMouseTargetRect(node);
if (!isContainer(node)) return;
for (const child of node.children) clearSubtreeHitRects(child);
}
interface InlineHitSpan {
readonly node: TuiVirtualText;
readonly prefix: string;
readonly text: string;
}
function collectVirtualTextSpans(
node: TuiText | TuiVirtualText,
inheritedBg: unknown,
prefix: string,
spans: InlineHitSpan[],
): string {
let out = "";
for (const child of node.children) {
if (child.type === "text-leaf") {
out += child.value;
continue;
}
if (child.type === "comment") continue;
if (child.type === "tui-virtual-text") {
const before = prefix + out;
const text = renderTextWithInlineStyles(child, inheritedBg);
spans.push({ node: child, prefix: before, text });
collectVirtualTextSpans(child, inheritedBg, before, spans);
out += text;
continue;
}
if (child.type === "tui-transform") {
const text = renderTransformAsText(child, inheritedBg);
out += text.length > 0 ? child.transform(text, 0) : text;
}
}
return out;
}
function lineColumnForTextOffset(
text: string,
wrapWidth: number,
wrapMode: TextProps["wrap"],
): { line: number; column: number } {
if (text.length === 0) return { line: 0, column: 0 };
const lines = wrapText(text, wrapWidth, wrapMode ?? "wrap");
const line = Math.max(0, lines.length - 1);
return { line, column: stringWidth(lines[line] ?? "") };
}
function recordVirtualTextHits(
hitMap: MouseHitMapEntry[] | undefined,
node: TuiText,
x: number,
y: number,
wrapWidth: number,
wrapMode: TextProps["wrap"],
inheritedBg: unknown,
clip: HitRect | undefined,
) {
if (!hitMap) return;
const spans: InlineHitSpan[] = [];
collectVirtualTextSpans(node, inheritedBg, "", spans);
if (wrapWidth <= 0) {
for (const span of spans) clearMouseTargetRect(span.node);
return;
}
for (const span of spans) {
const width = stringWidth(span.text);
if (width <= 0) {
clearMouseTargetRect(span.node);
continue;
}
const start = lineColumnForTextOffset(span.prefix, wrapWidth, wrapMode);
const end = lineColumnForTextOffset(span.prefix + span.text, wrapWidth, wrapMode);
const height = Math.max(1, end.line - start.line + 1);
const rectWidth =
height === 1 ? Math.max(1, end.column - start.column) : Math.max(1, wrapWidth);
recordHit(
hitMap,
span.node,
{
x: x + start.column,
y: y + start.line,
width: rectWidth,
height,
},
clip,
);
}
}
export function paint(root: TuiNode, options: PaintOptions = {}): string {
if (root.type !== "root") throw new Error("paint expects TuiRoot");
const layout = root.yoga.getComputedLayout();
const width = Math.max(1, Math.floor(layout.width));
const height = Math.max(1, Math.floor(layout.height));
const out = new Output(width, height);
paintNode(root, out, 0, 0, []);
paintNode(root, out, 0, 0, [], undefined, options.hitMap);
return out.get().output;
}
@@ -619,16 +754,23 @@ function paintNode(
y0: number,
transformers: Transformer[],
inheritedBg?: string,
hitMap?: MouseHitMapEntry[],
clip?: HitRect,
): void {
// display:none — yoga collapses the node to zero size but still reports a
// layout; skip painting the subtree entirely (matches Ink's renderNodeToOutput
// early-return) so hidden content never leaks onto visible siblings.
const yogaNode = (node as { yoga?: { getDisplay?: () => number } }).yoga;
if (yogaNode?.getDisplay?.() === Yoga.DISPLAY_NONE) return;
if (yogaNode?.getDisplay?.() === Yoga.DISPLAY_NONE) {
if (hitMap) clearSubtreeHitRects(node);
return;
}
switch (node.type) {
case "root": {
for (const child of node.children) paintNode(child, output, x0, y0, transformers);
for (const child of node.children) {
paintNode(child, output, x0, y0, transformers, undefined, hitMap, clip);
}
return;
}
case "tui-box": {
@@ -637,6 +779,7 @@ function paintNode(
const y = y0 + layout.top;
const w = Math.max(0, Math.floor(layout.width));
const h = Math.max(0, Math.floor(layout.height));
recordHit(hitMap, node, { x, y, width: w, height: h }, clip);
// Split the Box's own bg from the value threaded to children — they use
// different fallback rules, mirroring Ink's two separate guards:
// - FILL uses the Box's OWN bg with a FALSY guard (Ink render-background.ts:11
@@ -686,6 +829,22 @@ function paintNode(
});
clipped = true;
}
const bl = node.yoga.getComputedBorder(Yoga.EDGE_LEFT);
const br = node.yoga.getComputedBorder(Yoga.EDGE_RIGHT);
const bt = node.yoga.getComputedBorder(Yoga.EDGE_TOP);
const bb = node.yoga.getComputedBorder(Yoga.EDGE_BOTTOM);
const childClip =
clipH || clipV
? (intersectHitRect(
{
x: clipH ? x + bl : (clip?.x ?? x - 1_000_000_000),
y: clipV ? y + bt : (clip?.y ?? y - 1_000_000_000),
width: clipH ? w - bl - br : (clip?.width ?? 2_000_000_000),
height: clipV ? h - bt - bb : (clip?.height ?? 2_000_000_000),
},
clip,
) ?? { x: 0, y: 0, width: 0, height: 0 })
: clip;
const contentMetrics = getBoxContentMetrics(node, w, h);
// A Box with no inner content area has no legal paint region for FLOW
@@ -697,20 +856,33 @@ function paintNode(
for (const child of node.children) {
const childYoga = (child as { yoga?: { getPositionType?: () => number } }).yoga;
if (childYoga?.getPositionType?.() === Yoga.POSITION_TYPE_ABSOLUTE) {
paintNode(child, output, x, y, transformers, childBg);
paintNode(child, output, x, y, transformers, childBg, hitMap, childClip);
}
}
if (clipped) output.unclip();
return;
}
for (const child of node.children) paintNode(child, output, x, y, transformers, childBg);
for (const child of node.children) {
paintNode(child, output, x, y, transformers, childBg, hitMap, childClip);
}
if (clipped) output.unclip();
return;
}
case "tui-text": {
const layout = node.yoga.getComputedLayout();
recordHit(
hitMap,
node,
{
x: x0 + layout.left,
y: y0 + layout.top,
width: Math.max(0, Math.floor(layout.width)),
height: Math.max(0, Math.floor(layout.height)),
},
clip,
);
// Thread the INHERITED Box bg (NOT a pre-computed effective bg) into the
// squash. The Text's own backgroundColor — including an explicit "" opt-out —
// is resolved against this inherited bg inside applyOwnStyle
@@ -750,6 +922,16 @@ function paintNode(
}
}
}
recordVirtualTextHits(
hitMap,
node,
x0 + layout.left,
y0 + layout.top,
wrapWidth,
node.props.wrap,
inheritedBg,
clip,
);
output.write(x0 + layout.left, y0 + layout.top, wrapped, transformers);
return;
}
@@ -762,6 +944,17 @@ function paintNode(
const layout = node.yoga.getComputedLayout();
const x = x0 + layout.left;
const y = y0 + layout.top;
recordHit(
hitMap,
node,
{
x,
y,
width: Math.max(0, Math.floor(layout.width)),
height: Math.max(0, Math.floor(layout.height)),
},
clip,
);
const next = [node.transform, ...transformers];
// Standalone <Transform> with DIRECT inline children (bare strings,
// <Newline>, and no yoga-carrying <Text>/<Box> child): Ink models
@@ -784,7 +977,9 @@ function paintNode(
// Transform wrapping a yoga-carrying child (e.g. <Transform><Text>…)
// — recurse so the child <Text>/<Box> lays out and paints normally, with
// the transform pushed onto the line-transformers.
for (const child of node.children) paintNode(child, output, x, y, next, inheritedBg);
for (const child of node.children) {
paintNode(child, output, x, y, next, inheritedBg, hitMap, clip);
}
return;
}
case "tui-virtual-text":
+116 -27
View File
@@ -18,7 +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 { isSgrMouseInput, parseMouseInput, parseSgrMouseInput } 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";
@@ -33,6 +33,7 @@ import { findStatics, paintStaticNode } from "./paint/static-channel.ts";
import { createFrameWriter } from "./io/frame-writer.ts";
import { INTERNAL_FRAME_SINK, type FrameSink } from "./io/frame-sink.ts";
import { bsu, esu, shouldSynchronize } from "./io/write-synchronized.ts";
import { createMouseController, type MouseHitMapEntry } from "./mouse/controller.ts";
import {
AppContextKey,
FocusContextKey,
@@ -41,6 +42,7 @@ import {
type AppContext,
type CursorPosition,
type FocusContext,
type SgrMouseMode,
type StdinContext,
} from "./context.ts";
import {
@@ -132,14 +134,21 @@ export interface MountOptions {
*/
incrementalRendering?: boolean;
/**
* Render in the terminal's alternate screen buffer. When enabled, the
* terminal switches to a clean buffer on mount and restores the original
* content on unmount — no rendering artifacts are left behind.
* Render as a fullscreen terminal app in the alternate screen buffer. When
* enabled, the terminal switches to a clean buffer on mount and restores the
* original content on unmount — no rendering artifacts are left behind.
*
* Element mouse handlers (`@click`, `@wheel`, etc.) only fire in fullscreen
* mode because terminal mouse coordinates are absolute screen cells.
*
* Requires interactive mode and a TTY stdout. Silently ignored otherwise.
*
* @default false
*/
fullscreen?: boolean;
/**
* @deprecated Use `fullscreen` instead.
*/
alternateScreen?: boolean;
/**
* Configure kitty keyboard protocol support for enhanced keyboard input.
@@ -194,6 +203,10 @@ function shouldClearTerminalForFrame(opts: {
);
}
function supportsTerminalMouse(): boolean {
return process.env["TERM"] !== "dumb";
}
// Module-level registry: maps each NodeJS.WriteStream to the one live TuiApp
// that owns its renderer. Mirrors Ink's WeakMap<NodeJS.WriteStream, Ink> in
// instances.ts. Keyed weakly so closed/GC'd streams don't leak memory.
@@ -675,6 +688,12 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
// correctly handles piped streams where the property is absent.
const interactive = options.interactive ?? (!isInCi && Boolean(stdout.isTTY));
mountedInteractive = interactive;
const fullscreen =
Boolean(options.fullscreen ?? options.alternateScreen) &&
interactive &&
Boolean(stdout.isTTY);
const mouseFullscreen =
fullscreen && supportsTerminalMouse() && Boolean((stdin as { isTTY?: boolean }).isTTY);
// Frame coordination state — tracks the last rendered output so
// writeToStdout/writeToStderr can clear and restore the active frame.
@@ -1045,9 +1064,9 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
// hard:true) — matching Ink's onRender SR branch (ink.tsx:598-603). The
// <Static> channel is excluded here (skipStaticElements) just like
// render-to-string.ts; static output is handled separately by commit().
function renderFrame(width: number): string {
function renderFrame(width: number, hitMap?: MouseHitMapEntry[]): string {
if (!isScreenReaderEnabled) {
return paint(tuiRoot);
return paint(tuiRoot, { hitMap });
}
const linear = renderScreenReaderOutput(tuiRoot, { skipStaticElements: true });
return wrapAnsi(linear, width, { trim: false, hard: true });
@@ -1127,7 +1146,9 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
);
try {
emitLayoutListeners(tuiRoot);
const frame = renderFrame(w);
const hitMap = mouseFullscreen ? [] : undefined;
const frame = renderFrame(w, hitMap);
mouseController.updateHitMap(hitMap ?? []);
const outputHeight = frame === "" ? 0 : frame.split("\n").length;
if (debug) {
@@ -1263,6 +1284,12 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
immediate: unthrottled,
throttleMs: renderThrottleMs,
});
const mouseController = createMouseController({
stdin: stdinController,
fullscreen: mouseFullscreen,
now: scheduler.now,
});
appContext.internal_mouse = mouseController;
mountedScheduler = scheduler;
mountedCommit = commit;
scheduledCommit = scheduler.schedule;
@@ -1318,15 +1345,14 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
}
};
// Alternate screen: enter BEFORE rendering starts (matching Ink ink.tsx:428).
// Requires alternateScreen option + interactive + isTTY.
const alternateScreen =
Boolean(options.alternateScreen) && interactive && Boolean(stdout.isTTY);
if (alternateScreen) {
writeBestEffort(stdout, ansiEscapes.enterAlternativeScreen);
// Fullscreen: enter the alternate screen BEFORE rendering starts (matching Ink ink.tsx:428).
// Requires fullscreen option + interactive + isTTY. Emit home explicitly so
// targeted mouse hit-testing can treat the frame origin as screen (0,0).
if (fullscreen) {
writeBestEffort(stdout, ansiEscapes.enterAlternativeScreen + "\x1b[H");
writeBestEffort(stdout, "\x1b[?25l");
}
mountedAlternateScreen = alternateScreen;
mountedAlternateScreen = fullscreen;
// Patch console.log/warn/error etc. to route through writeToStdout /
// writeToStderr so console output doesn't corrupt the rendered frame.
@@ -1788,7 +1814,8 @@ function createStdinController(
let pendingFlushTimer: ReturnType<typeof setTimeout> | undefined;
const FLUSH_DELAY = 20; // ms, matching Ink
let bracketedPasteModeCount = 0;
const sgrMouseModeTokens = new Set<symbol>();
const sgrMouseModeTokens = new Map<symbol, SgrMouseMode>();
let activeSgrMouseMode: SgrMouseMode | undefined;
// 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
@@ -1797,8 +1824,7 @@ function createStdinController(
let everEnabledBracketedPaste = false;
let everEnabledSgrMouse = false;
const ENABLE_SGR_MOUSE = "\x1b[?1000h\x1b[?1006h";
const DISABLE_SGR_MOUSE = "\x1b[?1000l\x1b[?1006l";
const DISABLE_SGR_MOUSE = "\x1b[?1003l\x1b[?1002l\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
@@ -1811,6 +1837,10 @@ function createStdinController(
return Boolean(stdout.isTTY) && !stdout.destroyed && !stdout.writableEnded;
}
function canUseSgrMouseMode(): boolean {
return canWriteTerminalMode() && supportsTerminalMouse();
}
function writeTerminalMode(data: string, sync = false): void {
if (!canWriteTerminalMode()) return;
const stdout = appCtx.stdout;
@@ -1844,6 +1874,64 @@ function createStdinController(
writeTerminalMode(DISABLE_SGR_MOUSE, sync);
}
function enableSgrMouse(level: SgrMouseMode) {
switch (level) {
case "button":
writeTerminalMode("\x1b[?1000h\x1b[?1006h");
return;
case "drag":
writeTerminalMode("\x1b[?1002h\x1b[?1006h");
return;
case "hover":
writeTerminalMode("\x1b[?1003h\x1b[?1006h");
}
}
function sgrMouseModeRank(level: SgrMouseMode): number {
switch (level) {
case "button":
return 1;
case "drag":
return 2;
case "hover":
return 3;
}
}
function highestRequestedSgrMouseMode(): SgrMouseMode | undefined {
let highest: SgrMouseMode | undefined;
for (const level of sgrMouseModeTokens.values()) {
if (!highest || sgrMouseModeRank(level) > sgrMouseModeRank(highest)) {
highest = level;
}
}
return highest;
}
function reconcileSgrMouseMode() {
const next = highestRequestedSgrMouseMode();
if (!canUseSgrMouseMode()) {
if (activeSgrMouseMode) {
disableSgrMouse();
}
activeSgrMouseMode = undefined;
return;
}
if (next === activeSgrMouseMode) return;
if (!next) {
disableSgrMouse();
activeSgrMouseMode = undefined;
return;
}
if (activeSgrMouseMode) {
disableSgrMouse();
}
enableSgrMouse(next);
everEnabledSgrMouse = true;
activeSgrMouseMode = next;
}
function clearPendingFlush() {
if (pendingFlushTimer !== undefined) {
clearTimeout(pendingFlushTimer);
@@ -1879,7 +1967,11 @@ function createStdinController(
}
}
}
if (sgrMouseModeTokens.size > 0 && isSgrMouseInput(input)) {
if (activeSgrMouseMode && isSgrMouseInput(input)) {
const rawMouse = parseSgrMouseInput(input);
if (rawMouse && emitter.listenerCount("internal_mouse") > 0) {
emitter.emit("internal_mouse", rawMouse);
}
const mouse = parseMouseInput(input);
if (mouse && emitter.listenerCount("mouse") > 0) {
emitter.emit("mouse", mouse);
@@ -2094,20 +2186,15 @@ function createStdinController(
}
}
},
acquireSgrMouseMode() {
acquireSgrMouseMode(level: SgrMouseMode = "button") {
const token = Symbol("sgr-mouse");
if (sgrMouseModeTokens.size === 0) {
writeTerminalMode(ENABLE_SGR_MOUSE);
everEnabledSgrMouse = true;
}
sgrMouseModeTokens.add(token);
sgrMouseModeTokens.set(token, level);
reconcileSgrMouseMode();
return token;
},
releaseSgrMouseMode(token: symbol) {
if (!sgrMouseModeTokens.delete(token)) return;
if (sgrMouseModeTokens.size === 0) {
disableSgrMouse();
}
reconcileSgrMouseMode();
},
releaseRawMode() {
if (!appCtx.isRawModeSupported) return;
@@ -2175,6 +2262,7 @@ function createStdinController(
}
if (everEnabledSgrMouse) {
disableSgrMouse(true);
activeSgrMouseMode = undefined;
}
} else {
if (bracketedPasteModeCount > 0) {
@@ -2182,6 +2270,7 @@ function createStdinController(
}
if (sgrMouseModeTokens.size > 0) {
disableSgrMouse();
activeSgrMouseMode = undefined;
}
}
bracketedPasteModeCount = 0;
+8 -5
View File
@@ -3,6 +3,7 @@ import { queuePostFlushCb } from "@vue/runtime-core";
export interface CommitScheduler {
schedule: () => void;
flush: () => Promise<void>;
now: () => number;
/** Returns true when a trailing-edge commit is pending. */
hasPending: () => boolean;
/** Cancel any pending trailing-edge timer. */
@@ -19,6 +20,7 @@ export interface CommitSchedulerOptions {
* every tick); pass 0 there.
*/
throttleMs: number;
now?: () => number;
}
export function createCommitScheduler(
@@ -27,6 +29,7 @@ export function createCommitScheduler(
): CommitScheduler {
const immediate = options.immediate ?? false;
const throttleMs = options.throttleMs;
const now = options.now ?? Date.now;
let scheduled = false;
// Multiple concurrent flush() callers can be waiting on the same pending
// commit; settle all of them rather than overwriting a single resolver.
@@ -97,16 +100,16 @@ export function createCommitScheduler(
doCommit();
return;
}
const now = Date.now();
if (pendingAt === null) pendingAt = now;
if (now - pendingAt >= throttleMs) {
const currentTime = now();
if (pendingAt === null) pendingAt = currentTime;
if (currentTime - pendingAt >= throttleMs) {
// maxWait edge: deferred calls have been pushing the trailing edge
// for a full window — commit now, then re-arm an (empty) window so
// the next call defers instead of double-committing as leading.
// pendingAt is stamped AFTER the commit (es-toolkit does the same),
// so paint time doesn't eat into the next window.
doCommit();
pendingAt = Date.now();
pendingAt = now();
armTrailingWindow();
return;
}
@@ -150,5 +153,5 @@ export function createCommitScheduler(
drainFlushResolvers();
}
return { schedule, flush, hasPending, cancel };
return { schedule, flush, now, hasPending, cancel };
}
+28
View File
@@ -176,6 +176,34 @@ importers:
specifier: 'catalog:'
version: 7.2.0(@types/node@24.12.4)(esbuild@0.28.0)(tsx@4.22.3)(vue@3.5.34(typescript@6.0.3))
examples/mouse:
dependencies:
'@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
tsdown:
specifier: 'catalog:'
version: 0.22.3(tsx@4.22.3)(typescript@6.0.3)(vue-tsc@3.3.4(typescript@6.0.3))
unplugin-vue:
specifier: 'catalog:'
version: 7.2.0(@types/node@24.12.4)(esbuild@0.28.0)(tsx@4.22.3)(vue@3.5.34(typescript@6.0.3))
vite:
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':