From 8afb7bc570b8a88d6f8207446e5be81d62180cdf Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Thu, 11 Jun 2026 15:58:33 +0530 Subject: [PATCH] opentui(v6): double-click word / triple-click line selection with held drag-extend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ui-opentui/scripts/demo.tsx | 4 + ui-opentui/src/boundary/multiClickSelect.ts | 130 ++++++++++ ui-opentui/src/boundary/renderer.ts | 4 + ui-opentui/src/logic/multiClick.ts | 168 +++++++++++++ ui-opentui/src/test/lib/render.ts | 17 ++ ui-opentui/src/test/multiClick.test.tsx | 260 ++++++++++++++++++++ 6 files changed, 583 insertions(+) create mode 100644 ui-opentui/src/boundary/multiClickSelect.ts create mode 100644 ui-opentui/src/logic/multiClick.ts create mode 100644 ui-opentui/src/test/multiClick.test.tsx diff --git a/ui-opentui/scripts/demo.tsx b/ui-opentui/scripts/demo.tsx index d1b0b53e3e9..8d6500c4ff5 100644 --- a/ui-opentui/scripts/demo.tsx +++ b/ui-opentui/scripts/demo.tsx @@ -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( () => ( diff --git a/ui-opentui/src/boundary/multiClickSelect.ts b/ui-opentui/src/boundary/multiClickSelect.ts new file mode 100644 index 00000000000..42787a4d460 --- /dev/null +++ b/ui-opentui/src/boundary/multiClickSelect.ts @@ -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) + } +} diff --git a/ui-opentui/src/boundary/renderer.ts b/ui-opentui/src/boundary/renderer.ts index f73c107e5a9..0411a495352 100644 --- a/ui-opentui/src/boundary/renderer.ts +++ b/ui-opentui/src/boundary/renderer.ts @@ -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 }) diff --git a/ui-opentui/src/logic/multiClick.ts b/ui-opentui/src/logic/multiClick.ts new file mode 100644 index 00000000000..96a1a4bd6d8 --- /dev/null +++ b/ui-opentui/src/logic/multiClick.ts @@ -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) + } +} diff --git a/ui-opentui/src/test/lib/render.ts b/ui-opentui/src/test/lib/render.ts index b0b614f9309..d4bd8638527 100644 --- a/ui-opentui/src/test/lib/render.ts +++ b/ui-opentui/src/test/lib/render.ts @@ -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 + /** 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 /** 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 `` 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() diff --git a/ui-opentui/src/test/multiClick.test.tsx b/ui-opentui/src/test/multiClick.test.tsx new file mode 100644 index 00000000000..d1e4a7814fd --- /dev/null +++ b/ui-opentui/src/test/multiClick.test.tsx @@ -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( + () => ( + + + + + ), + { 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() + } + }) +})