Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
13 changes: 13 additions & 0 deletions packages/renderer/src/components/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -827,6 +827,19 @@ 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.
if (col !== app.lastHoverCol || row !== app.lastHoverRow) {
app.lastHoverCol = col
app.lastHoverRow = row
app.props.onHoverAt(col, row)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid hit-testing drag hover against the drag ghost

When the captured gesture is a normal <Draggable>, the dragged box stays under the cursor and is rendered as an absolute node with the drag-time z boost, while onHoverAt ultimately calls dispatchHover/hitTest, which returns the topmost painted node. In that common drag-over-sibling case this newly added hover dispatch keeps hitting the draggable itself instead of the drop zone or passive observer underneath, so the documented onMouseEnter-during-drag behavior does not fire for the target the cursor is visually being dragged over unless the drag source does not cover the cursor.

Useful? React with 👍 / 👎.

}
return
}
// onSelectionDrag calls notifySelectionChange internally — no extra
Expand Down
104 changes: 103 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,104 @@ 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()
})
})
7 changes: 7 additions & 0 deletions packages/renderer/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,13 @@ export { default as measureElement } from './measure-element'
export { stringWidth } from './stringWidth'
export { wrapAnsi } from './wrapAnsi'

// Hit-testing — exposed so consumers can resolve "what element is at
// this cursor?" from inside a captured gesture's onMove handler (drop
// target detection, custom hover semantics, drag ghost positioning).
// Uses the same z-aware paint-order sort the renderer uses, so the
// element returned matches what's painted at that cell.
export { hitTest } from './hit-test'

// Color utilities
export { colorize, applyTextStyles, applyColor } from './colorize'
export type { ColorType } from './colorize'
Expand Down
Loading