feat(runtime): add mouse input API (#245)
CI / Check, build & test (push) Has been cancelled
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:
@@ -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.
|
||||
@@ -0,0 +1,7 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head></head>
|
||||
<body>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createApp } from "@vue-tui/runtime";
|
||||
import App from "./app.vue";
|
||||
|
||||
createApp(App).mount({ fullscreen: true });
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
declare module "*.vue" {
|
||||
import type { Component } from "vue";
|
||||
const component: Component;
|
||||
export default component;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue"]
|
||||
}
|
||||
@@ -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()],
|
||||
});
|
||||
@@ -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()],
|
||||
});
|
||||
+16
-5
@@ -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>>>();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Generated
+28
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user