hermes-agent/ui-opentui/src/boundary/multiClickSelect.ts
alt-glitch 8afb7bc570 opentui(v6): double-click word / triple-click line selection with held drag-extend
Editor-grade mouse selection parity with the Ink TUI (hermes-ink selection.ts):
a second click in the 500ms/1-cell chain selects the same-class character run
under the cursor (iTerm2 word set, wide-glyph aware), a third selects the line,
and dragging with the button held extends word-by-word / line-by-line while the
clicked span stays selected — anchor flips across the span on direction change.

Core knows only press-drag char selection, so this is a boundary shim
(multiClickSelect.ts) wrapping the renderer's startSelection/updateSelection
seam; word bounds read the presented frame's char grid. Native quirks probed
and pinned: per-renderable selection anchors are fixed at set time (anchor
flips restart the selection) and forward selections exclude the focus cell
(inclusive spans seed focus at hi+1). Pure scanning logic in logic/multiClick.ts;
20 new tests (pure + real-mouse-path frames); demo.tsx installs the seam for
tmux smokes.
2026-06-11 15:58:33 +05:30

131 lines
5.4 KiB
TypeScript

/**
* Multi-click selection — double-click selects the word, triple-click the
* line, drag after either extends by word/line with the clicked span held
* (boundary shim in the ffiSafe.ts / nativeHandles.ts mold).
*
* Why a shim: @opentui/core's renderer knows only press-drag character
* selection — `processSingleMouseEvent` calls `startSelection(renderable,x,y)`
* on a fresh left press and `updateSelection(renderable,x,y)` per drag step,
* with no click-count concept. Wrapping those two INSTANCE methods is the
* narrowest seam that adds multi-click without forking core: the press wrapper
* counts clicks (Ink's 500ms / 1-cell chain) and, on a multi-click, seeds the
* selection with the word/line span instead of a point; the drag wrapper snaps
* the focus to word/line bounds and flips the selection anchor to whichever
* end of the held span faces away from the pointer.
*
* Word/line bounds come from the presented frame (`currentRenderBuffer`'s
* char grid — the same buffer `captureCharFrame` reads in tests), so what
* highlights is exactly the run of characters the user sees. All wrapped paths
* degrade to core's plain character selection when anything is off (no
* buffer, destroyed renderer, out-of-bounds click) — selection must never
* throw out of the mouse pipeline.
*/
import type { CliRenderer } from '@opentui/core'
import type { AnchorSpan, Point, ScreenText } from '../logic/multiClick.ts'
import { comparePoints, createClickCounter, extendedSelection, lineSpanAt, wordSpanAt } from '../logic/multiClick.ts'
/** The renderable surface the shim needs (anchor tracking reads live x/y). */
interface AnchorRenderable {
readonly x: number
readonly y: number
}
/** The private renderer surface the shim wraps (runtime-verified shapes). */
interface RendererSeam {
startSelection(renderable: AnchorRenderable, x: number, y: number): void
updateSelection(
renderable: AnchorRenderable | undefined,
x: number,
y: number,
options?: { finishDragging?: boolean }
): void
currentRenderBuffer: {
width: number
height: number
buffers: { char: Uint32Array }
}
}
/** Adapt the presented frame to the pure logic's ScreenText; null when the
* buffer is unreadable (mid-teardown/resize) → degrade to char selection. */
function presentedFrame(seam: RendererSeam): ScreenText | null {
try {
const buffer = seam.currentRenderBuffer
const chars = buffer.buffers.char
const width = buffer.width
if (width <= 0 || buffer.height <= 0) return null
return {
width,
height: buffer.height,
codepointAt: (x, y) => chars[y * width + x] ?? 0
}
} catch {
return null
}
}
/**
* Native selection semantics (probed empirically, scratch test 2026-06-11):
* per-renderable native selection keeps the anchor from the initial
* `setLocalSelection` — the anchor args of later `updateLocalSelection` calls
* are IGNORED, so moving the anchor requires restarting the selection. And the
* selection is caret-style at the focus end: a forward selection covers cells
* `[anchor, focus)` (focus cell excluded) while a backward one covers
* `[focus, anchor]` (both included). Inclusive cell spans therefore translate
* to: forward focus = `hi + 1`, backward focus = `lo` exactly.
*/
function forwardFocusX(anchor: Point, focus: Point): number {
return comparePoints(focus, anchor) >= 0 ? focus.x + 1 : focus.x
}
/** Install the multi-click wrappers on a live renderer instance. */
export function installMultiClickSelection(renderer: CliRenderer): void {
const seam = renderer as unknown as RendererSeam
const nextClickCount = createClickCounter()
// The held span while a multi-click selection is live: cleared by the next
// single click (which starts a plain char selection). `anchor` mirrors the
// selection's current anchor end so drag steps only rebind it on a flip.
let held: { span: AnchorSpan; renderable: AnchorRenderable; anchor: Point } | null = null
const coreStart = seam.startSelection.bind(renderer)
const coreUpdate = seam.updateSelection.bind(renderer)
seam.startSelection = (renderable, x, y) => {
held = null
const clicks = nextClickCount(x, y, Date.now())
const screen = clicks >= 2 ? presentedFrame(seam) : null
const span = screen ? (clicks === 2 ? wordSpanAt(screen, x, y) : lineSpanAt(screen, y)) : null
if (!span) {
coreStart(renderable, x, y)
return
}
// Seed anchor at the span start, focus past its end (forward caret) — one
// start+update pair, exactly the calls a real press-then-drag would make.
coreStart(renderable, span.lo.x, span.lo.y)
coreUpdate(renderable, span.hi.x + 1, span.hi.y)
held = {
span: { ...span, kind: clicks === 2 ? 'word' : 'line' },
renderable,
anchor: span.lo
}
}
seam.updateSelection = (renderable, x, y, options) => {
const screen = held ? presentedFrame(seam) : null
if (!held || !screen) {
coreUpdate(renderable, x, y, options)
return
}
const { anchor, focus } = extendedSelection(held.span, screen, x, y)
if (anchor.x !== held.anchor.x || anchor.y !== held.anchor.y) {
// The anchor end flipped across the held span — native selection anchors
// are fixed at set time (see forwardFocusX note), so restart it there.
coreStart(held.renderable, anchor.x, anchor.y)
held = { ...held, anchor }
}
coreUpdate(renderable, forwardFocusX(anchor, focus), focus.y, options)
}
}