mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(mobile): select Unicode words at rendered cell boundaries (#31434)
Fixes #30301 Supersedes community PR #30325. Original contributor credit is preserved below. Co-authored-by: Wooseong Kim <innocarpe@gmail.com>
This commit is contained in:
@@ -33,6 +33,8 @@ export type TerminalInitialOscLink = {
|
||||
* every reader here is a truthiness test rather than a comparison.
|
||||
*/
|
||||
export type TerminalDocumentCell = {
|
||||
getChars?: () => string
|
||||
getWidth?: () => number
|
||||
isBgDefault: () => boolean
|
||||
extended?: { urlId?: number }
|
||||
isInverse: () => number
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { Unicode11Addon } from '@xterm/addon-unicode11'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createTerminalDocumentScope, type TerminalDocumentScope } from './document-scope'
|
||||
import { seedWordSelection } from './selection-range'
|
||||
import { armLongPress, dispatcherShouldBlockSurface } from './tap-dispatch'
|
||||
import { handleDragMove, stopEdgeScroll } from './selection-overlay'
|
||||
import { terminalDocumentDouble } from './document-terminal-double.test-support'
|
||||
|
||||
const terminals: Terminal[] = []
|
||||
|
||||
afterEach(() => {
|
||||
terminals.splice(0).forEach((term) => term.dispose())
|
||||
vi.unstubAllGlobals()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
async function fixture(text: string) {
|
||||
const term = new Terminal({ cols: 60, rows: 3, allowProposedApi: true })
|
||||
terminals.push(term)
|
||||
term.loadAddon(new Unicode11Addon())
|
||||
term.unicode.activeVersion = '11'
|
||||
await new Promise<void>((resolve) => term.write(text, resolve))
|
||||
const scope = createTerminalDocumentScope()
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the real xterm exposes the narrower document terminal API.
|
||||
scope.term = term as unknown as TerminalDocumentScope['term']
|
||||
const select = vi.spyOn(term, 'select').mockImplementation(() => {})
|
||||
const selected = () => {
|
||||
const [col, row, length] = select.mock.calls.at(-1)!
|
||||
return term.buffer.active.getLine(row)?.translateToString(false, col, col + length)
|
||||
}
|
||||
return { term, scope, select, selected }
|
||||
}
|
||||
|
||||
describe('word selection uses the rendered xterm cell range', () => {
|
||||
it.each([
|
||||
['합성 행 0001', 0, '합성', 0, 4],
|
||||
['합성 행 0001', 1, '합성', 0, 4],
|
||||
['합성 행 0001', 2, '합성', 0, 4],
|
||||
['합성 행 0001', 3, '합성', 0, 4],
|
||||
['합성 행 0001', 5, '행', 5, 2],
|
||||
['합성 행 0001', 6, '행', 5, 2],
|
||||
['합성 행 0001', 10, '0001', 8, 4],
|
||||
['🙂 hello', 0, '🙂', 0, 2],
|
||||
['🙂 hello', 1, '🙂', 0, 2],
|
||||
['🙂 hello', 3, 'hello', 3, 5],
|
||||
['cafe\u0301 done', 3, 'cafe\u0301', 0, 4],
|
||||
['cafe\u0301 done', 5, 'done', 5, 4],
|
||||
['𐐀x done', 0, '𐐀x', 0, 2],
|
||||
['🧑💻 done', 0, '🧑💻', 0, 4],
|
||||
['🧑💻 done', 3, '🧑💻', 0, 4],
|
||||
['🇰🇷 done', 0, '🇰🇷', 0, 2],
|
||||
['가 나', 2, ' ', 2, 1],
|
||||
['before /tmp/file.ts after', 10, '/tmp/file.ts', 7, 12],
|
||||
['', 6, ' ', 6, 1]
|
||||
] as const)('selects %s at cell %i', async (text, col, expected, start, length) => {
|
||||
const f = await fixture(text)
|
||||
seedWordSelection(f.scope, col, 0)
|
||||
expect(f.select).toHaveBeenLastCalledWith(start, 0, length)
|
||||
expect(f.selected()).toBe(expected)
|
||||
expect(f.scope.sel).toEqual({
|
||||
anchor: { col: start, row: 0 },
|
||||
focus: { col: start + length - 1, row: 0 },
|
||||
activeHandle: null
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps cell mapping when a legacy WebView has no grapheme segmenter', async () => {
|
||||
const f = await fixture('합성 🙂 cafe\u0301')
|
||||
vi.stubGlobal('Intl', Object.assign(Object.create(Intl), { Segmenter: undefined }))
|
||||
seedWordSelection(f.scope, 3, 0)
|
||||
expect(f.select).toHaveBeenLastCalledWith(0, 0, 4)
|
||||
expect(f.selected()).toBe('합성')
|
||||
seedWordSelection(f.scope, 6, 0)
|
||||
expect(f.select).toHaveBeenLastCalledWith(5, 0, 2)
|
||||
expect(f.selected()).toBe('🙂')
|
||||
})
|
||||
|
||||
it('keeps ASCII behavior for a terminal seam without cell readers', async () => {
|
||||
const f = await fixture('hello world')
|
||||
const line = f.term.buffer.active.getLine(0)!
|
||||
const getLine = vi.spyOn(f.term.buffer.active, 'getLine').mockReturnValue(line)
|
||||
vi.spyOn(line, 'getCell').mockReturnValue(undefined)
|
||||
seedWordSelection(f.scope, 8, 0)
|
||||
expect(f.select).toHaveBeenLastCalledWith(6, 0, 5)
|
||||
expect(getLine).toHaveBeenCalledWith(0)
|
||||
})
|
||||
|
||||
it('keeps an absolute scrollback row and tolerates an evicted line', async () => {
|
||||
const f = await fixture('한글\r\nsecond\r\nthird\r\nfourth')
|
||||
expect(f.term.buffer.active.viewportY).toBeGreaterThan(0)
|
||||
seedWordSelection(f.scope, 2, 0)
|
||||
expect(f.select).toHaveBeenLastCalledWith(0, 0, 4)
|
||||
expect(f.selected()).toBe('한글')
|
||||
seedWordSelection(f.scope, 2, 99)
|
||||
expect(f.select).toHaveBeenLastCalledWith(2, 99, 1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Unicode selection boundaries across every rendered column', () => {
|
||||
it.each([
|
||||
['한글', 4],
|
||||
['cafe\u0301', 4],
|
||||
['𐐀x', 2],
|
||||
['👍🏽', 4],
|
||||
['👨👩👧👦', 8],
|
||||
['🏳️🌈', 3],
|
||||
['🇰🇷', 2],
|
||||
['❤️', 1]
|
||||
] as const)('keeps %s whole at each of its %i cells after wide text', async (text, width) => {
|
||||
const f = await fixture(`가 ${text} next`)
|
||||
for (let col = 3; col < 3 + width; col++) {
|
||||
seedWordSelection(f.scope, col, 0)
|
||||
expect(f.select).toHaveBeenLastCalledWith(3, 0, width)
|
||||
expect(f.selected()).toBe(text)
|
||||
}
|
||||
seedWordSelection(f.scope, 3 + width + 1, 0)
|
||||
expect(f.selected()).toBe('next')
|
||||
})
|
||||
|
||||
it('keeps the last wide word on the row and the next wrapped row independent', async () => {
|
||||
const f = await fixture(' '.repeat(56) + '한글next')
|
||||
for (const col of [56, 57, 58, 59]) {
|
||||
seedWordSelection(f.scope, col, 0)
|
||||
expect(f.select).toHaveBeenLastCalledWith(56, 0, 4)
|
||||
expect(f.selected()).toBe('한글')
|
||||
}
|
||||
seedWordSelection(f.scope, 0, 1)
|
||||
expect(f.select).toHaveBeenLastCalledWith(0, 1, 4)
|
||||
expect(f.selected()).toBe('next')
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the Unicode seed while the long-press finger owns a forward or backward drag', async () => {
|
||||
const f = await fixture('가 한글 next')
|
||||
const surface = document.createElement('div')
|
||||
f.scope.term = {
|
||||
...terminalDocumentDouble().terminal,
|
||||
cols: f.term.cols,
|
||||
rows: f.term.rows,
|
||||
buffer: f.scope.term!.buffer,
|
||||
select: f.select,
|
||||
_core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 15 } } } } }
|
||||
}
|
||||
Object.assign(f.scope, {
|
||||
surface,
|
||||
selectionOverlay: document.createElement('div'),
|
||||
handleStart: document.createElement('div'),
|
||||
handleEnd: document.createElement('div'),
|
||||
selMenu: document.createElement('div'),
|
||||
viewportRect: () => ({ left: 0, top: 50, width: 480, height: 200 }),
|
||||
currentScale: 1,
|
||||
userScale: 1
|
||||
})
|
||||
f.scope.touchDispatch.mode = 'surface'
|
||||
f.scope.touchDispatch.touchId = 7
|
||||
f.scope.tapCandidate = { x: 36, y: 57, t: Date.now(), identifier: 7 }
|
||||
vi.useFakeTimers()
|
||||
armLongPress(f.scope, new Touch({ identifier: 7, target: surface, clientX: 36, clientY: 57 }))
|
||||
vi.advanceTimersByTime(500)
|
||||
expect(f.selected()).toBe('한글')
|
||||
expect(f.scope.sel?.anchor).toEqual({ col: 3, row: 0 })
|
||||
expect(f.scope.sel?.focus).toEqual({ col: 6, row: 0 })
|
||||
expect(f.scope.touchDispatch).toMatchObject({ mode: 'select-drag', touchId: 7 })
|
||||
expect(dispatcherShouldBlockSurface(f.scope)).toBe(true)
|
||||
expect(f.scope.tapCandidate).toBeNull()
|
||||
|
||||
handleDragMove(f.scope, 'end', 11 * 8 + 4, 57)
|
||||
expect(f.selected()).toBe('한글 next')
|
||||
handleDragMove(f.scope, 'end', 4, 57)
|
||||
expect(f.select).toHaveBeenLastCalledWith(0, 0, 4)
|
||||
expect(f.selected()).toBe('가 한')
|
||||
stopEdgeScroll(f.scope)
|
||||
})
|
||||
@@ -1,9 +1,62 @@
|
||||
import { getLineText } from './cell-geometry'
|
||||
import type { TerminalDocumentScope, TerminalDocumentSelection } from './document-scope'
|
||||
import type { TerminalDocumentLine } from './document-terminal-shape'
|
||||
|
||||
/** What counts as one word for select-all and for word seeding. */
|
||||
const WORD_RE = /[\p{L}\p{N}_./:@~+=?&#%-]/u
|
||||
|
||||
type SelectionUnit = { text: string; start: number; end: number }
|
||||
|
||||
function selectionUnits(line: TerminalDocumentLine, cols: number): SelectionUnit[] | null {
|
||||
const cells: SelectionUnit[] = []
|
||||
for (let col = 0; col < Math.min(cols, line.length);) {
|
||||
const cell = line.getCell?.(col)
|
||||
if (!cell?.getChars || !cell.getWidth) {
|
||||
return null
|
||||
}
|
||||
const width = cell.getWidth()
|
||||
if (width <= 0) {
|
||||
col++
|
||||
continue
|
||||
}
|
||||
cells.push({ text: cell.getChars() || ' ', start: col, end: col + width - 1 })
|
||||
col += width
|
||||
}
|
||||
if (typeof Intl.Segmenter !== 'function') {
|
||||
return cells
|
||||
}
|
||||
// A grapheme can occupy several xterm cells; UTF-16 length is never its cell width.
|
||||
const columns = cells.flatMap((cell) => Array.from({ length: cell.text.length }, () => cell))
|
||||
const text = cells.map((cell) => cell.text).join('')
|
||||
return Array.from(
|
||||
new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(text),
|
||||
(part) => ({
|
||||
text: part.segment,
|
||||
start: columns[part.index].start,
|
||||
end: columns[part.index + part.segment.length - 1].end
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function wordCellRange(line: TerminalDocumentLine | undefined, col: number, cols: number) {
|
||||
const units = line && selectionUnits(line, cols)
|
||||
const index = units?.findIndex((unit) => unit.start <= col && col <= unit.end) ?? -1
|
||||
if (!units || index < 0) {
|
||||
return null
|
||||
}
|
||||
let start = index
|
||||
let end = index
|
||||
if (WORD_RE.test(units[index].text)) {
|
||||
while (start > 0 && WORD_RE.test(units[start - 1].text)) {
|
||||
start--
|
||||
}
|
||||
while (end + 1 < units.length && WORD_RE.test(units[end + 1].text)) {
|
||||
end++
|
||||
}
|
||||
}
|
||||
return { start: units[start].start, end: units[end].end }
|
||||
}
|
||||
|
||||
/** The ordered ends of the selection, whichever way the user dragged it. */
|
||||
export type TerminalSelectionRange = {
|
||||
start: TerminalDocumentSelection['anchor']
|
||||
@@ -11,6 +64,17 @@ export type TerminalSelectionRange = {
|
||||
}
|
||||
|
||||
export function seedWordSelection(scope: TerminalDocumentScope, col: number, absRow: number) {
|
||||
const cells = wordCellRange(scope.term?.buffer.active.getLine(absRow), col, scope.term?.cols ?? 0)
|
||||
if (cells) {
|
||||
scope.sel = {
|
||||
anchor: { col: cells.start, row: absRow },
|
||||
focus: { col: cells.end, row: absRow },
|
||||
activeHandle: null
|
||||
}
|
||||
applyXtermSelection(scope)
|
||||
return
|
||||
}
|
||||
// Small host/test seams can expose text only; keep their existing ASCII behavior.
|
||||
const line = getLineText(scope, absRow)
|
||||
if (!line) {
|
||||
scope.sel = {
|
||||
|
||||
Reference in New Issue
Block a user