Compare commits

...

1 Commits

Author SHA1 Message Date
alt-glitch
fcf49f313e 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
6 changed files with 583 additions and 0 deletions

View File

@@ -15,6 +15,7 @@
import { createCliRenderer } from '@opentui/core'
import { render } from '@opentui/solid'
import { installMultiClickSelection } from '../src/boundary/multiClickSelect.ts'
import { createSessionStore } from '../src/logic/store.ts'
import { App } from '../src/view/App.tsx'
import { ThemeProvider } from '../src/view/theme.tsx'
@@ -37,6 +38,9 @@ const renderer = await createCliRenderer({
useKittyKeyboard: {},
useMouse: true
})
// Same seam the live entry installs (boundary/renderer.ts) so the demo smokes
// double-click word / triple-click line / drag-extend too.
installMultiClickSelection(renderer)
void render(
() => (

View File

@@ -0,0 +1,130 @@
/**
* 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)
}
}

View File

@@ -15,6 +15,7 @@ import { Deferred, Effect } from 'effect'
import { RendererError } from './errors.ts'
import { installFfiCoordSafety } from './ffiSafe.ts'
import { getLog } from './log.ts'
import { installMultiClickSelection } from './multiClickSelect.ts'
import { installSyntaxStyleDegrade } from './nativeHandles.ts'
// Node-FFI seam: clamp negative draw coordinates BEFORE the u32 FFI marshaling
@@ -93,6 +94,9 @@ export const acquireRenderer = Effect.fn('Renderer.acquire')(function* (options:
useMouse: options.mouse
})
guardRendererErrorHandlers(created, preexisting)
// Editor-grade mouse selection: double-click word, triple-click line,
// drag extends with the clicked span held (see multiClickSelect.ts).
installMultiClickSelection(created)
return created
},
catch: cause => new RendererError({ cause })

View File

@@ -0,0 +1,168 @@
/**
* Multi-click selection logic — double-click selects the word, triple-click the
* line, and a drag after either extends word-by-word / line-by-line while the
* originally clicked span stays selected (native macOS / VS Code behavior).
* Ported from the Ink fork's `hermes-ink/src/ink/selection.ts` (wordBoundsAt /
* selectLineAt / extendSelection) onto OpenTUI's screen model: the rendered
* frame is a flat grid of codepoints (`OptimizedBuffer.buffers.char`), so word
* scanning reads the frame the user actually sees — concealed markdown, tool
* chrome and all.
*
* Pure string/number work, no OpenTUI imports — the boundary shim
* (`boundary/multiClickSelect.ts`) adapts the live buffer to `ScreenText`.
*/
/** Screen-buffer cell coordinates (0-indexed col/row). */
export interface Point {
readonly x: number
readonly y: number
}
/** Inclusive span from `lo` to `hi` in reading order (row-major). */
export interface Span {
readonly lo: Point
readonly hi: Point
}
/** The multi-clicked span a drag extends from. */
export interface AnchorSpan extends Span {
readonly kind: 'word' | 'line'
}
/** Read-only view of the rendered frame's character grid. */
export interface ScreenText {
readonly width: number
readonly height: number
/** Unicode codepoint at cell (x,y); 0 marks a wide-char continuation cell. */
readonly codepointAt: (x: number, y: number) => number
}
/** -1 if a < b, 1 if a > b, 0 if equal (reading order: row then col). */
export function comparePoints(a: Point, b: Point): number {
if (a.y !== b.y) return a.y < b.y ? -1 : 1
if (a.x !== b.x) return a.x < b.x ? -1 : 1
return 0
}
// Unicode-aware word character matcher: letters (any script), digits, and the
// punctuation set iTerm2 treats as word-part by default (`/-+\~_.`). Matching
// iTerm2's default means double-clicking a path like `src/logic/multiClick.ts`
// selects the whole path — the muscle memory terminal users have.
const WORD_CHAR = /[\p{L}\p{N}_/.\-+~\\]/u
/**
* Character class for double-click word-expansion: 0 = whitespace/empty,
* 1 = word char, 2 = other punctuation. Cells with the same class as the
* clicked cell are one run; a class change is a boundary — so double-click on
* `foo` selects `foo`, on `->` selects `->`, on spaces the whitespace run.
*/
function charClass(cp: number): 0 | 1 | 2 {
if (cp === 0 || cp === 32) return 0
if (WORD_CHAR.test(String.fromCodePoint(cp))) return 1
return 2
}
/**
* Bounds of the same-class character run at (x, y), or null when the click is
* out of bounds. Wide-char continuation cells (codepoint 0) belong to the head
* glyph at their left: a click on one resolves to the head, the left scan
* steps over them to the head's class, and the right scan includes them in the
* span so the highlight covers the full glyph.
*/
export function wordSpanAt(screen: ScreenText, x: number, y: number): Span | null {
if (y < 0 || y >= screen.height || x < 0 || x >= screen.width) return null
// Land on a continuation cell → step back to the wide-char head.
let c = x
while (c > 0 && screen.codepointAt(c, y) === 0) c -= 1
const cls = charClass(screen.codepointAt(c, y))
let lo = c
while (lo > 0) {
let prev = lo - 1
while (prev > 0 && screen.codepointAt(prev, y) === 0) prev -= 1
if (charClass(screen.codepointAt(prev, y)) !== cls) break
lo = prev
}
let hi = c
while (hi < screen.width - 1) {
const cp = screen.codepointAt(hi + 1, y)
// A continuation cell after a run member is the tail of the run's last
// wide glyph — include it and keep scanning.
if (cp !== 0 && charClass(cp) !== cls) break
hi += 1
}
return { lo: { x: lo, y }, hi: { x: hi, y } }
}
/** The full row as a span (triple-click). Null when the row is out of bounds —
* per-renderable `getSelectedText` trims what shouldn't copy, matching the
* Ink fork where line-select spans the visual row. */
export function lineSpanAt(screen: ScreenText, y: number): Span | null {
if (y < 0 || y >= screen.height || screen.width <= 0) return null
return { lo: { x: 0, y }, hi: { x: screen.width - 1, y } }
}
/**
* Where a drag at (x, y) puts the selection while an anchor span is held:
* the span under the mouse (word at the pointer, or its row in line mode;
* raw cell fallback when the pointer is out of bounds) is merged with the
* anchor span so the original word/line always stays selected.
*/
export function extendedSelection(
span: AnchorSpan,
screen: ScreenText,
x: number,
y: number
): { anchor: Point; focus: Point } {
let mouseLo: Point
let mouseHi: Point
if (span.kind === 'word') {
const b = wordSpanAt(screen, x, y)
mouseLo = b ? b.lo : { x, y }
mouseHi = b ? b.hi : { x, y }
} else {
const row = Math.max(0, Math.min(y, screen.height - 1))
mouseLo = { x: 0, y: row }
mouseHi = { x: screen.width - 1, y: row }
}
// Mouse target entirely before the anchor span → grow backward from its end;
// entirely after → grow forward from its start; overlapping → just the span.
if (comparePoints(mouseHi, span.lo) < 0) return { anchor: span.hi, focus: mouseLo }
if (comparePoints(mouseLo, span.hi) > 0) return { anchor: span.lo, focus: mouseHi }
return { anchor: span.lo, focus: span.hi }
}
/** Same chain window the Ink fork uses (`App.tsx` MULTI_CLICK_*). */
export const MULTI_CLICK_TIMEOUT_MS = 500
export const MULTI_CLICK_DISTANCE = 1
/**
* Click-chain counter: a press within MULTI_CLICK_TIMEOUT_MS and
* MULTI_CLICK_DISTANCE of the previous press continues the chain, otherwise
* the count resets to 1. The returned count is capped at 3 — quadruple+
* clicks stay line-select, like every terminal/editor.
*/
export function createClickCounter(): (x: number, y: number, now: number) => 1 | 2 | 3 {
let lastTime = 0
let lastX = -1
let lastY = -1
let count = 0
return (x, y, now) => {
const chained =
now - lastTime <= MULTI_CLICK_TIMEOUT_MS &&
Math.abs(x - lastX) <= MULTI_CLICK_DISTANCE &&
Math.abs(y - lastY) <= MULTI_CLICK_DISTANCE
count = chained ? count + 1 : 1
lastTime = now
lastX = x
lastY = y
return count >= 3 ? 3 : (count as 1 | 2)
}
}

View File

@@ -26,6 +26,7 @@ import type { JSX } from '@opentui/solid'
import { createMemo } from 'solid-js'
import { installFfiCoordSafety } from '../../boundary/ffiSafe.ts'
import { installMultiClickSelection } from '../../boundary/multiClickSelect.ts'
// Headless renders go through the same node:ffi seam as the live TUI — install
// the negative-coordinate shim here too (the live path installs it in
@@ -55,6 +56,11 @@ export interface RenderProbe {
readonly resize: (width: number, height: number) => void
/** Left-click at screen cell (x, y) via the mock mouse, then settle a pass. */
readonly click: (x: number, y: number) => Promise<void>
/** The raw mock mouse (pressDown / moveTo / release / doubleClick / …) for
* multi-click + drag scenarios — pair with `settle()`. */
readonly mouse: TestRendererSetup['mockMouse']
/** The live selection's copyable text ('' when there is none). */
readonly selectedText: () => string
/** Mouse-wheel at screen cell (x, y) via the mock mouse, then settle a pass. */
readonly scroll: (x: number, y: number, direction: 'up' | 'down') => Promise<void>
/** The mock keyboard (typeText / pressArrow / pressEnter / …) — pair with `settle()`. */
@@ -78,6 +84,9 @@ export async function renderProbe(
// never flushes it), so keyboard-driven tests can press Escape.
kittyKeyboard: options?.kittyKeyboard ?? false
})
// Same multi-click selection seam as the live renderer (boundary/renderer.ts
// installs it after createCliRenderer) so mouse tests exercise the shim.
installMultiClickSelection(setup.renderer)
// renderOnce → flush → renderOnce: flush awaits async work (scrollbox measure,
// Tree-sitter markdown tokenization) that a single sync pass would miss. The
// native `<markdown internalBlockMode="top-level">` commits blocks over several
@@ -98,6 +107,14 @@ export async function renderProbe(
await setup.renderOnce()
await setup.flush()
},
mouse: setup.mockMouse,
selectedText: () => {
try {
return setup.renderer.getSelection()?.getSelectedText() ?? ''
} catch {
return ''
}
},
scroll: async (x, y, direction) => {
await setup.mockMouse.scroll(x, y, direction)
await setup.renderOnce()

View File

@@ -0,0 +1,260 @@
/**
* Multi-click selection (double-click word, triple-click line, drag-extend
* with the clicked span held). Layers:
* 1. pure: word/line span scanning over a fake char grid + the click-chain
* counter + the extend arithmetic (logic/multiClick.ts).
* 2. frames: the real mouse path through the shim (boundary/multiClickSelect.ts,
* installed by test/lib/render.ts exactly like the live renderer) — what
* the user double/triple-clicks is what getSelectedText() returns, and a
* held drag grows the selection without losing the original word.
*/
import { describe, expect, test } from 'vitest'
import {
createClickCounter,
extendedSelection,
lineSpanAt,
wordSpanAt,
type AnchorSpan,
type ScreenText
} from '../logic/multiClick.ts'
import { renderProbe } from './lib/render.ts'
/** Build a ScreenText from string rows; '\0' marks a wide-char continuation cell. */
function screenOf(...rows: string[]): ScreenText {
const width = Math.max(...rows.map(row => row.length))
return {
width,
height: rows.length,
codepointAt: (x, y) => {
const ch = rows[y]?.[x]
if (ch === undefined) return 32
return ch === '\0' ? 0 : ch.codePointAt(0)!
}
}
}
describe('wordSpanAt — same-class run scanning', () => {
const screen = screenOf('alpha beta-gamma --> "quoted"')
// 0123456789...
test('click inside a word selects the word run', () => {
expect(wordSpanAt(screen, 2, 0)).toEqual({ lo: { x: 0, y: 0 }, hi: { x: 4, y: 0 } })
})
test('hyphen/path chars are word chars (iTerm2 set): beta-gamma is one run', () => {
expect(wordSpanAt(screen, 8, 0)).toEqual({ lo: { x: 6, y: 0 }, hi: { x: 15, y: 0 } })
})
test('click on whitespace selects the whitespace run', () => {
expect(wordSpanAt(screen, 5, 0)).toEqual({ lo: { x: 5, y: 0 }, hi: { x: 5, y: 0 } })
})
test('punctuation run (not word, not space) is its own class', () => {
// `-->`: `-` and `>` … `-` is a word char in the iTerm2 set, so the run
// splits: `--` belongs to word class, `>` is punctuation. Click the `>`.
expect(wordSpanAt(screen, 19, 0)).toEqual({ lo: { x: 19, y: 0 }, hi: { x: 19, y: 0 } })
})
test('quotes break a word run', () => {
expect(wordSpanAt(screen, 23, 0)).toEqual({ lo: { x: 22, y: 0 }, hi: { x: 27, y: 0 } })
})
test('out of bounds → null', () => {
expect(wordSpanAt(screen, -1, 0)).toBeNull()
expect(wordSpanAt(screen, 0, 1)).toBeNull()
expect(wordSpanAt(screen, screen.width, 0)).toBeNull()
})
test('wide-char continuation cells join their head run', () => {
// "日\0本\0 x" — two wide glyphs (head + continuation) then space + x.
const wide = screenOf('日\0本\0 x')
// Click the continuation cell of 日 → run covers both glyphs incl. tails.
expect(wordSpanAt(wide, 1, 0)).toEqual({ lo: { x: 0, y: 0 }, hi: { x: 3, y: 0 } })
expect(wordSpanAt(wide, 5, 0)).toEqual({ lo: { x: 5, y: 0 }, hi: { x: 5, y: 0 } })
})
})
describe('lineSpanAt', () => {
test('full row span, null out of bounds', () => {
const screen = screenOf('one', 'two')
expect(lineSpanAt(screen, 1)).toEqual({ lo: { x: 0, y: 1 }, hi: { x: 2, y: 1 } })
expect(lineSpanAt(screen, 2)).toBeNull()
expect(lineSpanAt(screen, -1)).toBeNull()
})
})
describe('extendedSelection — drag with the clicked span held', () => {
const screen = screenOf('alpha beta gamma')
const beta: AnchorSpan = { lo: { x: 6, y: 0 }, hi: { x: 9, y: 0 }, kind: 'word' }
test('drag forward grows from the span start to the word under the mouse', () => {
expect(extendedSelection(beta, screen, 13, 0)).toEqual({
anchor: { x: 6, y: 0 },
focus: { x: 15, y: 0 }
})
})
test('drag backward flips the anchor to the span end', () => {
expect(extendedSelection(beta, screen, 2, 0)).toEqual({
anchor: { x: 9, y: 0 },
focus: { x: 0, y: 0 }
})
})
test('mouse over the span keeps exactly the span', () => {
expect(extendedSelection(beta, screen, 7, 0)).toEqual({
anchor: { x: 6, y: 0 },
focus: { x: 9, y: 0 }
})
})
test('line mode extends row-by-row and clamps to the grid', () => {
const lines = screenOf('one', 'two', 'three')
const middle: AnchorSpan = { lo: { x: 0, y: 1 }, hi: { x: 4, y: 1 }, kind: 'line' }
expect(extendedSelection(middle, lines, 1, 9)).toEqual({
anchor: { x: 0, y: 1 },
focus: { x: 4, y: 2 }
})
expect(extendedSelection(middle, lines, 1, -5)).toEqual({
anchor: { x: 4, y: 1 },
focus: { x: 0, y: 0 }
})
})
})
describe('createClickCounter — the 500ms / 1-cell chain', () => {
test('chains at the same spot, caps at 3, resets on distance and time', () => {
const count = createClickCounter()
expect(count(10, 5, 1000)).toBe(1)
expect(count(10, 5, 1100)).toBe(2)
expect(count(11, 5, 1200)).toBe(3) // 1 cell of slop allowed
expect(count(11, 5, 1300)).toBe(3) // quadruple+ stays line-select
expect(count(14, 5, 1350)).toBe(1) // too far → fresh chain
expect(count(14, 5, 1900)).toBe(1) // too late → fresh chain
})
})
describe('frames — the real mouse path', () => {
const LINE_ONE = 'alpha beta-gamma delta'
const LINE_TWO = 'second row of words'
async function mountLines() {
const probe = await renderProbe(
() => (
<box flexDirection="column">
<text content={LINE_ONE} />
<text content={LINE_TWO} />
</box>
),
{ height: 6, width: 40 }
)
const frame = await probe.waitForFrame(f => f.includes('alpha') && f.includes('second'))
const rows = frame.split('\n')
const y1 = rows.findIndex(row => row.includes('alpha'))
const y2 = rows.findIndex(row => row.includes('second'))
expect(y1).toBeGreaterThanOrEqual(0)
expect(y2).toBeGreaterThanOrEqual(0)
const x = (token: string) => {
const col = (rows[y1] ?? '').indexOf(token)
expect(col).toBeGreaterThanOrEqual(0)
return col
}
return { probe, rows, y1, y2, x }
}
test('double-click selects the word under the cursor', async () => {
const { probe, x, y1 } = await mountLines()
try {
await probe.mouse.doubleClick(x('alpha') + 1, y1)
await probe.settle()
expect(probe.selectedText()).toBe('alpha')
} finally {
probe.destroy()
}
})
test('double-click on a hyphenated token selects the whole token', async () => {
const { probe, x, y1 } = await mountLines()
try {
await probe.mouse.doubleClick(x('beta') + 2, y1)
await probe.settle()
expect(probe.selectedText()).toBe('beta-gamma')
} finally {
probe.destroy()
}
})
test('triple-click selects the line', async () => {
const { probe, x, y1 } = await mountLines()
try {
const col = x('beta')
await probe.mouse.doubleClick(col, y1)
await probe.mouse.click(col, y1)
await probe.settle()
expect(probe.selectedText().trimEnd()).toBe(LINE_ONE)
} finally {
probe.destroy()
}
})
test('double-click then drag extends word-by-word without losing the word', async () => {
const { probe, x, y1 } = await mountLines()
try {
const col = x('beta') + 1
await probe.mouse.click(col, y1)
await probe.mouse.pressDown(col, y1) // second press of the chain → word held
await probe.mouse.moveTo(x('delta') + 1, y1) // drag into the next word
await probe.mouse.release(x('delta') + 1, y1)
await probe.settle()
expect(probe.selectedText()).toBe('beta-gamma delta')
} finally {
probe.destroy()
}
})
test('double-click then drag backward keeps the word and grows left', async () => {
const { probe, x, y1 } = await mountLines()
try {
const col = x('beta') + 1
await probe.mouse.click(col, y1)
await probe.mouse.pressDown(col, y1)
await probe.mouse.moveTo(x('alpha') + 1, y1)
await probe.mouse.release(x('alpha') + 1, y1)
await probe.settle()
expect(probe.selectedText()).toBe('alpha beta-gamma')
} finally {
probe.destroy()
}
})
test('triple-click then drag extends line-by-line', async () => {
const { probe, x, y1, y2 } = await mountLines()
try {
const col = x('beta')
await probe.mouse.doubleClick(col, y1)
await probe.mouse.pressDown(col, y1) // third press of the chain → line held
await probe.mouse.moveTo(col, y2)
await probe.mouse.release(col, y2)
await probe.settle()
const text = probe.selectedText()
expect(text).toContain(LINE_ONE)
expect(text).toContain(LINE_TWO)
} finally {
probe.destroy()
}
})
test('a plain drag still does character selection', async () => {
const { probe, x, y1 } = await mountLines()
try {
// Far from any prior click (fresh probe) — drag from inside `alpha` to
// inside `beta-gamma`; chars, not words.
await probe.mouse.drag(x('alpha') + 2, y1, x('beta') + 2, y1)
await probe.settle()
expect(probe.selectedText()).toBe('pha be')
} finally {
probe.destroy()
}
})
})