mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-11 20:58:51 +08:00
Compare commits
1 Commits
feat/opent
...
feat/opent
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fcf49f313e |
@@ -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(
|
||||
() => (
|
||||
|
||||
130
ui-opentui/src/boundary/multiClickSelect.ts
Normal file
130
ui-opentui/src/boundary/multiClickSelect.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
168
ui-opentui/src/logic/multiClick.ts
Normal file
168
ui-opentui/src/logic/multiClick.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
260
ui-opentui/src/test/multiClick.test.tsx
Normal file
260
ui-opentui/src/test/multiClick.test.tsx
Normal 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user