Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions docs/concepts/mouse.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,41 @@ The active gesture lives on `App.activeGesture`. It is drained on `FOCUS_OUT` an

`MouseMoveEvent` and `MouseUpEvent` do NOT bubble — they go directly to the captured handler.

`onMouseEnter` / `onMouseLeave` continue to fire normally during a captured gesture: they are side-effect-only and don't compete with `onMove` for the input. A `<DropTarget>` (or any other passive observer) sitting under the cursor mid-drag can react to the cursor entering it without the gesture initiator coordinating anything.

## Tentative capture

Use `event.captureGestureTentatively(...)` when a press might be a click OR a drag and you want actual motion to disambiguate.

```tsx
<Box onMouseDown={(e) => {
e.captureGestureTentatively({
onMove: (move) => { /* fires only after first motion */ },
onUp: (up) => { /* fires only if the gesture promoted */ },
})
}} />
```

- **Press**: gesture installs, but selection-start + multi-click still run normally so click dispatch on release works.
- **First motion**: PROMOTES the gesture. Cancels any in-progress selection; clears the tentative flag; fires `onMove`.
- **Release without motion**: DROPS the gesture silently — `onUp` does NOT fire — and falls through to normal click dispatch. Descendants with `onClick` get their click.

`<Draggable>` uses this so a press-and-release on a descendant `<Button>` reliably reaches the button's `onClick`. Confirmed `captureGesture` (always-immediate) and tentative capture share the same first-call-wins slot — descendants and ancestors can't override each other.

## Programmatic hit-testing

Inside a captured gesture's `onMove`, call `hitTest(rootElement, col, row)` to resolve "what element is at this cursor right now?" The same z-aware paint-order sort the renderer uses; the element returned matches what's painted at that cell. Useful for custom drop-target detection, drag-ghost positioning, hover-during-drag UX.

```tsx
import { hitTest } from '@yokai-tui/renderer'

// Inside your gesture's onMove:
const target = hitTest(rootRef.current, move.col, move.row)
if (target?.attributes.dropZone === 'inbox') {
setHoveredInbox(true)
}
```

## See also
- [Events](../concepts/events.md)
- [Box](../components/box.md)
Expand Down
3 changes: 2 additions & 1 deletion docs/reference/events.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ Inherits from internal `MouseEvent` base.
**Methods**:

- `stopImmediatePropagation()`.
- `captureGesture(handlers: GestureHandlers): void` — claim subsequent mouse-motion events and the eventual release for this drag. After capture, motion routes to `handlers.onMove` even when the cursor leaves the originally pressed element; release fires `handlers.onUp` and clears the capture. The normal release path (onClick, selection finish) is skipped for that release. Selection extension is suppressed for the gesture's lifetime. Capture is first-call-wins within one dispatch: once a descendant claims the press, later calls are ignored and ancestors do not receive the remaining `onMouseDown` bubble.
- `captureGesture(handlers: GestureHandlers): void` — claim subsequent mouse-motion events and the eventual release for this drag. After capture, motion routes to `handlers.onMove` even when the cursor leaves the originally pressed element; release fires `handlers.onUp` and clears the capture. The normal release path (onClick, selection finish) is skipped for that release. Selection extension is suppressed for the gesture's lifetime. Capture is first-call-wins within one dispatch: once a descendant claims the press, later calls are ignored and ancestors do not receive the remaining `onMouseDown` bubble. **`onMouseEnter` / `onMouseLeave` continue to fire normally** during a captured gesture — drop targets and other passive observers can react to the cursor position without reinventing hit-testing.
- `captureGestureTentatively(handlers: GestureHandlers): void` — same as `captureGesture` BUT only commits to the gesture if motion follows. On release-without-motion the gesture is silently dropped (`onUp` does NOT fire) and normal click dispatch runs as if no gesture had been captured. The first mouse-motion event PROMOTES the gesture (cancels any in-progress selection, fires `onMove`). Use when a press might be a click OR a drag and you want actual motion to disambiguate — `<Draggable>` uses this so descendants with `onClick` still work on a press-and-release.

**Fires when**: mouse press, while mouse tracking is active.

Expand Down
43 changes: 42 additions & 1 deletion packages/renderer/src/components/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,14 @@ type Props = {
// Dispatch hover (onMouseEnter/onMouseLeave) as the pointer moves over
// DOM elements. Called for mode-1003 motion events with no button held.
// No-op outside fullscreen (Ink.dispatchHover gates on altScreenActive).
readonly onHoverAt: (col: number, row: number) => void
//
// The optional `exclude` arg lets the App skip a subtree during hit-
// testing — used during a captured drag to make the dragged element
// invisible to drop-target detection (otherwise its drag-time z boost
// would make it the topmost painted node under the cursor and the
// hover would resolve to the draggable itself instead of the drop
// zone underneath).
readonly onHoverAt: (col: number, row: number, exclude?: DOMElement | null) => void
// Apply default wheel behavior at (col, row). Wheel remains a ParsedKey
// for useInput compatibility, but ScrollBox's built-in wheel binding is
// spatial and follows the box under the cursor.
Expand Down Expand Up @@ -251,6 +258,12 @@ export default class App extends PureComponent<Props, State> {
// window doesn't leave a dangling capture.
activeGesture: GestureHandlers | null = null
activeGestureTentative = false
// The DOM element whose onMouseDown handler captured the active
// gesture (the gesture "source"). Recorded by dispatchMouseDown.
// Used during drag-time hover dispatch so the dragged element itself
// doesn't intercept hover meant for drop targets underneath — see
// the dispatch site for the rationale.
activeGestureSource: DOMElement | null = null

// Timestamp of last stdin chunk. Used to detect long gaps (tmux attach,
// ssh reconnect, laptop wake) and trigger terminal mode re-assert.
Expand Down Expand Up @@ -652,6 +665,7 @@ function processKeysInBatch(
}
app.activeGesture = null
app.activeGestureTentative = false
app.activeGestureSource = null
}
const event = new TerminalFocusEvent('terminalblur')
app.internal_eventEmitter.emit('terminalblur', event)
Expand Down Expand Up @@ -787,6 +801,7 @@ export function handleMouseEvent(app: App, m: ParsedMouse): void {
}
app.activeGesture = null
app.activeGestureTentative = false
app.activeGestureSource = null
}
if (col === app.lastHoverCol && row === app.lastHoverRow) return
app.lastHoverCol = col
Expand All @@ -813,6 +828,8 @@ export function handleMouseEvent(app: App, m: ParsedMouse): void {
// capture.
if (app.activeGestureTentative) {
app.activeGestureTentative = false
// sourceNode stays — promotion keeps the same gesture, only
// the tentative-vs-confirmed status changes.
if (sel.isDragging || sel.anchor) {
clearSelection(sel)
app.props.onSelectionChange()
Expand All @@ -827,6 +844,25 @@ export function handleMouseEvent(app: App, m: ParsedMouse): void {
app.clickCount = 0
}
app.activeGesture.onMove?.(new MouseMoveEvent(col, row, m.button))
// Also dispatch hover during the captured drag. onMouseEnter /
// onMouseLeave are side-effect-only — they don't compete with
// the gesture's onMove for the input — so a drop target sitting
// under the cursor mid-drag can react to the cursor entering
// it (border highlight, insertion indicator, etc.) without the
// consumer reinventing hit-testing inside the gesture handler.
// dispatchHover dedupes via the hoveredNodes Set internally;
// same-cell calls are no-ops.
//
// Exclude the gesture source from hit-testing. <Draggable>
// gives itself a drag-time z boost so the dragged box paints
// above siblings; without exclusion the hit-test would resolve
// to the dragged box itself instead of the drop zone underneath.
// (Codex review on PR #85 / A20.)
if (col !== app.lastHoverCol || row !== app.lastHoverRow) {
app.lastHoverCol = col
app.lastHoverRow = row
app.props.onHoverAt(col, row, app.activeGestureSource)
}
return
}
// onSelectionDrag calls notifySelectionChange internally — no extra
Expand Down Expand Up @@ -855,6 +891,7 @@ export function handleMouseEvent(app: App, m: ParsedMouse): void {
}
app.activeGesture = null
app.activeGestureTentative = false
app.activeGestureSource = null
}
// Dispatch onMouseDown to the DOM tree. If a handler called
// event.captureGesture(...) (confirmed) or .captureGestureTentatively
Expand All @@ -869,6 +906,7 @@ export function handleMouseEvent(app: App, m: ParsedMouse): void {
if (gesture && !gesture.tentative) {
app.activeGesture = gesture.handlers
app.activeGestureTentative = false
app.activeGestureSource = gesture.sourceNode
// Reset multi-click chain too — a captured gesture interrupts
// any in-flight click cadence (releasing a drag at the same cell
// as a prior click shouldn't compose into a double-click).
Expand All @@ -881,6 +919,7 @@ export function handleMouseEvent(app: App, m: ParsedMouse): void {
// a press-without-motion still produces a valid click on release.
app.activeGesture = gesture.handlers
app.activeGestureTentative = true
app.activeGestureSource = gesture.sourceNode
}
// Fresh left press. Detect multi-click HERE (not on release) so the
// word/line highlight appears immediately and a subsequent drag can
Expand Down Expand Up @@ -935,11 +974,13 @@ export function handleMouseEvent(app: App, m: ParsedMouse): void {
app.activeGesture.onUp?.(new MouseUpEvent(col, row, m.button))
app.activeGesture = null
app.activeGestureTentative = false
app.activeGestureSource = null
return
}
// Tentative + no motion → drop and fall through to normal release.
app.activeGesture = null
app.activeGestureTentative = false
app.activeGestureSource = null
}
// Release: end the drag even for non-zero button codes. Some terminals
// encode release with the motion bit or button=3 "no button" (carried
Expand Down
35 changes: 35 additions & 0 deletions packages/renderer/src/events/mouse-event.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,41 @@ describe('dispatchMouseDown', () => {
expect(result?.tentative).toBe(true)
})

it('CapturedGesture carries the sourceNode that fired captureGesture', () => {
// Used by App-level coordinator to exclude the dragged element from
// drag-time hover hit-testing — without sourceNode the dragged box
// (with its drag-time z boost) would intercept hover meant for
// drop targets underneath.
const root = createNode('ink-root')
setRect(root, 0, 0, 10, 5)
const child = createNode('ink-box')
appendChildNode(root, child)
setRect(child, 0, 0, 5, 5)
child._eventHandlers = {
onMouseDown: (e: MouseDownEvent) => e.captureGesture({ onMove: vi.fn() }),
}
const result = dispatchMouseDown(root, 2, 2, 0)
expect(result?.sourceNode).toBe(child)
})

it('CapturedGesture sourceNode reflects the FIRST capturer (descendant beats ancestor)', () => {
const root = createNode('ink-root')
setRect(root, 0, 0, 10, 5)
const child = createNode('ink-box')
appendChildNode(root, child)
setRect(child, 0, 0, 5, 5)
// Child captures first; ancestor's later captureGesture is a no-op
// (first-call-wins). sourceNode must be child.
child._eventHandlers = {
onMouseDown: (e: MouseDownEvent) => e.captureGesture({ onMove: vi.fn() }),
}
root._eventHandlers = {
onMouseDown: (e: MouseDownEvent) => e.captureGesture({ onMove: vi.fn() }),
}
const result = dispatchMouseDown(root, 2, 2, 0)
expect(result?.sourceNode).toBe(child)
})

it('passes (col, row, button) into the event for handlers to read', () => {
const root = createNode('ink-root')
setRect(root, 0, 0, 10, 5)
Expand Down
141 changes: 140 additions & 1 deletion packages/renderer/src/hit-test.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@

import { describe, expect, it, vi } from 'vitest'
import { type DOMElement, appendChildNode, createNode, setAttribute } from './dom.js'
import type { EventHandlerProps } from './events/event-handlers.js'
import { FocusManager } from './focus.js'
import { dispatchClick } from './hit-test.js'
import { dispatchClick, dispatchHover, hitTest } from './hit-test.js'
import { nodeCache } from './node-cache.js'

function setRect(node: DOMElement, x: number, y: number, w: number, h: number): void {
Expand Down Expand Up @@ -118,3 +119,141 @@ describe('dispatchClick → click-to-focus', () => {
expect(focus).toHaveBeenCalledWith(outer)
})
})

// ── hitTest as a public consumer API ─────────────────────────────────
//
// hitTest is re-exported from the package index (PR for A20). Consumers
// inside a captured gesture's onMove handler can call it to resolve
// "what element is at this cursor right now?" without reinventing
// hit-testing — the canonical use is drop-target detection during
// custom drag flows.

describe('hitTest', () => {
function root() {
const r = createNode('ink-root')
setRect(r, 0, 0, 100, 100)
return r
}

it('returns the deepest element containing the cursor', () => {
const r = root()
const outer = createNode('ink-box')
setRect(outer, 10, 10, 30, 10)
appendChildNode(r, outer)
const inner = createNode('ink-box')
setRect(inner, 15, 12, 10, 5)
appendChildNode(outer, inner)

expect(hitTest(r, 17, 14)).toBe(inner)
})

it('returns null when the cursor is past the root rect', () => {
expect(hitTest(root(), 200, 200)).toBeNull()
})

it('walks up through siblings to find the cell-containing node', () => {
const r = root()
const outer = createNode('ink-box')
setRect(outer, 10, 10, 30, 10)
appendChildNode(r, outer)
// Cursor lands inside outer's rect but not on any inner child.
expect(hitTest(r, 12, 11)).toBe(outer)
})
})

// ── dispatchHover enter/leave diff ───────────────────────────────────
//
// Used by the App-level mouse routing AND now (post-A20) called even
// during captured gestures so drop targets can react to cursor entry.
// Pure-helper test — drives dispatchHover with a held `hovered` Set
// across calls and asserts enter/leave fire correctly on diffs.

describe('dispatchHover', () => {
it('fires onMouseEnter for newly-hovered nodes', () => {
const r = createNode('ink-root')
setRect(r, 0, 0, 100, 100)
const target = createNode('ink-box')
setRect(target, 10, 10, 20, 5)
appendChildNode(r, target)
const onMouseEnter = vi.fn()
target._eventHandlers = { onMouseEnter } as EventHandlerProps

const hovered = new Set<DOMElement>()
dispatchHover(r, 15, 12, hovered)
expect(onMouseEnter).toHaveBeenCalledTimes(1)
expect(hovered.has(target)).toBe(true)
})

it('fires onMouseLeave when cursor exits the hovered rect', () => {
const r = createNode('ink-root')
setRect(r, 0, 0, 100, 100)
const target = createNode('ink-box')
setRect(target, 10, 10, 20, 5)
appendChildNode(r, target)
const onMouseEnter = vi.fn()
const onMouseLeave = vi.fn()
target._eventHandlers = { onMouseEnter, onMouseLeave } as EventHandlerProps

const hovered = new Set<DOMElement>()
dispatchHover(r, 15, 12, hovered)
dispatchHover(r, 50, 50, hovered) // off the target
expect(onMouseLeave).toHaveBeenCalledTimes(1)
expect(hovered.has(target)).toBe(false)
})

it('idempotent on same-cell calls (no enter/leave when nothing changed)', () => {
const r = createNode('ink-root')
setRect(r, 0, 0, 100, 100)
const target = createNode('ink-box')
setRect(target, 10, 10, 20, 5)
appendChildNode(r, target)
const onMouseEnter = vi.fn()
const onMouseLeave = vi.fn()
target._eventHandlers = { onMouseEnter, onMouseLeave } as EventHandlerProps

const hovered = new Set<DOMElement>()
dispatchHover(r, 15, 12, hovered)
dispatchHover(r, 15, 12, hovered)
dispatchHover(r, 15, 12, hovered)
// First call entered. Subsequent same-cell calls dedupe.
expect(onMouseEnter).toHaveBeenCalledTimes(1)
expect(onMouseLeave).not.toHaveBeenCalled()
})

it('exclude param skips a subtree, hit-test resolves to next-topmost', () => {
// Setup mirrors the drag-over-sibling case: an absolute z-boosted
// "drag ghost" sits over a sibling drop zone. Without exclusion the
// hover hit-tests resolve to the ghost (it's on top); with the
// ghost excluded, hover correctly resolves to the drop zone.
const r = createNode('ink-root')
setRect(r, 0, 0, 100, 100)
const dropZone = createNode('ink-box')
setRect(dropZone, 10, 10, 30, 10)
appendChildNode(r, dropZone)
const dropEnter = vi.fn()
dropZone._eventHandlers = { onMouseEnter: dropEnter } as EventHandlerProps

// Drag ghost: absolute, high zIndex, OVER the drop zone.
const ghost = createNode('ink-box')
ghost.style = { position: 'absolute', zIndex: 100 }
setRect(ghost, 15, 12, 5, 3)
appendChildNode(r, ghost)
const ghostEnter = vi.fn()
ghost._eventHandlers = { onMouseEnter: ghostEnter } as EventHandlerProps

const hovered = new Set<DOMElement>()

// No exclude: ghost wins (it's on top).
dispatchHover(r, 17, 13, hovered)
expect(ghostEnter).toHaveBeenCalledTimes(1)
expect(dropEnter).not.toHaveBeenCalled()

// Reset and re-test with ghost excluded — drop zone wins instead.
hovered.clear()
ghostEnter.mockClear()
dropEnter.mockClear()
dispatchHover(r, 17, 13, hovered, ghost)
expect(ghostEnter).not.toHaveBeenCalled()
expect(dropEnter).toHaveBeenCalledTimes(1)
})
})
Loading
Loading