diff --git a/mobile/src/terminal/document/document-terminal-shape.ts b/mobile/src/terminal/document/document-terminal-shape.ts index b5807efdf7f..60067acfd4f 100644 --- a/mobile/src/terminal/document/document-terminal-shape.ts +++ b/mobile/src/terminal/document/document-terminal-shape.ts @@ -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 diff --git a/mobile/src/terminal/document/selection-range.test.ts b/mobile/src/terminal/document/selection-range.test.ts new file mode 100644 index 00000000000..2ea75091bb6 --- /dev/null +++ b/mobile/src/terminal/document/selection-range.test.ts @@ -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((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) +}) diff --git a/mobile/src/terminal/document/selection-range.ts b/mobile/src/terminal/document/selection-range.ts index bde95116758..b38d4cadf85 100644 --- a/mobile/src/terminal/document/selection-range.ts +++ b/mobile/src/terminal/document/selection-range.ts @@ -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 = {