Select complete Korean terminal words on long press (#7353)

* fix(terminal): select complete Korean words on long press

Wide-character continuation cells carry a blank character but occupy no extra glyph. Treat only actual cells as word separators so either half selects the complete word while real spaces still delimit it.

* fix(terminal): preserve selected words until a drag begins

An unmoved touch release contracted the word selected by the long-press timer. Only extend selection after crossing the existing drag threshold. The Android regression checks the copied whole word after the finger lifts.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
This commit is contained in:
paseo-bot[bot]
2026-10-19 07:03:24 +02:00
committed by GitHub
parent 23e388b054
commit 644d66ba4f
4 changed files with 89 additions and 9 deletions
@@ -0,0 +1,19 @@
# Prerequisite: a native terminal showing the output of echo '한글 문장'
# on its first row, with no active selection. Use a dedicated QA workspace.
# Coordinates target the first half of 문 on a 1080x1920 phone at default
# code size. Override WORD_X/WORD_Y for other device geometry.
context platform=android timeout=30000 retries=0
env APP_ID=sh.paseo.debug
env WORD_X=105
env WORD_Y=251
open "${APP_ID}"
wait "label=\"Terminal input\"" 10000
wait "label=\"한글 문장\"" 10000
longpress "${WORD_X}" "${WORD_Y}" 650
wait "id=\"terminal-copy\"" 5000
press "id=\"terminal-copy\""
# Copy clears the selection; only Android's clipboard preview has this exact
# text, since the unselected terminal row contains the full sentence.
wait "text=\"문장\"" 3000
close
@@ -868,7 +868,9 @@ function NativeTerminalEmulator({
panState.movedBeyondTapTolerance = true;
}
if (selectionPress?.status === "selecting") {
updateSelectionAtPoint(resolvePanResponderPoint(selectionPress, event, gesture));
if (panState.movedBeyondTapTolerance) {
updateSelectionAtPoint(resolvePanResponderPoint(selectionPress, event, gesture));
}
return;
}
@@ -915,7 +917,10 @@ function NativeTerminalEmulator({
onPanResponderRelease: (event, gesture) => {
clearSelectionLongPressTimeout();
const selectionPress = selectionPressRef.current;
if (selectionPress?.status === "selecting") {
if (
selectionPress?.status === "selecting" &&
panStateRef.current.movedBeyondTapTolerance
) {
updateSelectionAtPoint(resolvePanResponderPoint(selectionPress, event, gesture));
}
selectionPressRef.current = null;
@@ -151,6 +151,61 @@ describe("native terminal selection", () => {
});
});
it.each([
{ col: 0, expected: "한글", startCol: 0, endCol: 3 },
{ col: 1, expected: "한글", startCol: 0, endCol: 3 },
{ col: 2, expected: "한글", startCol: 0, endCol: 3 },
{ col: 3, expected: "한글", startCol: 0, endCol: 3 },
{ col: 5, expected: "문장", startCol: 5, endCol: 8 },
{ col: 6, expected: "문장", startCol: 5, endCol: 8 },
{ col: 7, expected: "문장", startCol: 5, endCol: 8 },
{ col: 8, expected: "문장", startCol: 5, endCol: 8 },
])(
"selects the whole Korean word from column $col, including continuation cells",
async ({ col, expected, startCol, endCol }) => {
const terminal = createNativeHeadlessTerminal({ rows: 3, cols: 30 });
try {
await terminal.write("한글 문장");
const bounds = terminal.getBufferBounds();
const row = bounds.cursorRow;
const selection = resolveTerminalWordSelection({ terminal, coordinate: { row, col } });
expect({ selection, text: extractTerminalSelectedText({ terminal, selection }) }).toEqual({
selection: {
start: { row, col: startCol },
end: { row, col: endCol },
coordinateEpoch: bounds.coordinateEpoch,
},
text: expected,
});
} finally {
terminal.dispose();
}
},
);
it.each([
{ text: "한글 문장", col: 4, expected: "" },
{ text: "(한글)", col: 2, expected: "한글" },
{ text: "漢字 test", col: 1, expected: "漢字" },
{ text: "a한b x", col: 2, expected: "a한b" },
{ text: "🙂🙂 ok", col: 1, expected: "🙂🙂" },
{ text: "e\u0301한 ok", col: 2, expected: "e\u0301한" },
{ text: "한글", col: 3, expected: "한글" },
])("preserves word boundaries in $text at column $col", async ({ text, col, expected }) => {
const terminal = createNativeHeadlessTerminal({ rows: 3, cols: 30 });
try {
await terminal.write(text);
const selection = resolveTerminalWordSelection({
terminal,
coordinate: { row: terminal.getBufferBounds().cursorRow, col },
});
expect(extractTerminalSelectedText({ terminal, selection })).toBe(expected);
} finally {
terminal.dispose();
}
});
it("maps screen coordinates through the current visible window", () => {
expect(
hitTestTerminalSelectionCell({
@@ -1,4 +1,4 @@
import type { NativeHeadlessTerminal } from "./headless-terminal-state";
import type { NativeHeadlessTerminal, NativeTerminalCell } from "./headless-terminal-state";
import type { TerminalGridCellMetrics } from "./terminal-grid-metrics";
export interface TerminalBufferCoordinate {
@@ -101,6 +101,10 @@ interface TerminalSelectionState {
const TERMINAL_WORD_SEPARATORS = " ()[]{}',\"`";
function isTerminalWordSeparator(cell: NativeTerminalCell): boolean {
return cell.width !== 0 && TERMINAL_WORD_SEPARATORS.includes(cell.char || " ");
}
function compareCoordinates(
left: TerminalBufferCoordinate,
right: TerminalBufferCoordinate,
@@ -183,8 +187,7 @@ export function resolveTerminalWordSelection(
if (!cells || input.coordinate.col < 0 || input.coordinate.col >= cells.length) {
return null;
}
const target = cells[input.coordinate.col]?.char || " ";
if (TERMINAL_WORD_SEPARATORS.includes(target)) {
if (isTerminalWordSeparator(cells[input.coordinate.col])) {
return {
start: input.coordinate,
end: input.coordinate,
@@ -195,13 +198,11 @@ export function resolveTerminalWordSelection(
let startCol = input.coordinate.col;
let endCol = input.coordinate.col;
while (startCol > 0) {
const previous = cells[startCol - 1]?.char || " ";
if (TERMINAL_WORD_SEPARATORS.includes(previous)) break;
if (isTerminalWordSeparator(cells[startCol - 1])) break;
startCol -= 1;
}
while (endCol + 1 < cells.length) {
const next = cells[endCol + 1]?.char || " ";
if (TERMINAL_WORD_SEPARATORS.includes(next)) break;
if (isTerminalWordSeparator(cells[endCol + 1])) break;
endCol += 1;
}