mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-31 21:52:54 +00:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user