mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
fix(p2): touch selection, Copy and Paste in the web terminal
Every touch put the transparent input field under the finger, so a long press on output selected in the empty field (Paste only) and never a word of the output; the field's focus also opened the keyboard, whose resize redrew all rows. Rows were replaced whenever output changed them, which dropped any selection. Now a long press on a character of the output is Chrome's own (word, handles, Copy); only a blank cell gets the field for Paste. Touch contextmenus are never cancelled. While output is selected rows are not redrawn (15 s after the last change, or until the selection ends or the user types, pastes or scrolls). Copy joins soft-wrapped rows (new Frame row flag from libghostty) and drops the blanks that fill rows.
This commit is contained in:
parent
a5d209afd0
commit
1137075c78
5 changed files with 304 additions and 58 deletions
|
|
@ -897,6 +897,12 @@ translated phone drawer and intercept chat taps.
|
|||
Web: route-owned side sheet or resizable bottom panel, tabs, new/close/foreground-job confirmation, browser-native
|
||||
selection/copy, mouse/touch scrolling, pastes, IME corrections and the remembered Ctrl/Alt/Esc/Tab/arrows/symbol key
|
||||
row. Placement is remembered. The wasm, wrapper, renderer and input helper are independent copies under p2.
|
||||
Touch: a long press on a character of the output is Chrome's (word selection, handles, Copy); one on a blank cell
|
||||
puts the transparent textarea under the finger for Chrome's Paste (sent as a paste, bracketed when the program asks).
|
||||
Touch long presses and their contextmenu are never cancelled. While output is selected, rows are not redrawn (15 s
|
||||
after the selection last changed, or until it ends, the user types, pastes or scrolls), since redrawing a row drops
|
||||
a selection in it. Copy joins soft-wrapped rows and drops the blanks that fill rows. `terminal-clipboard.test.ts`
|
||||
covers this as far as CDP can (headless Chromium has no long-press gesture, selection handles or native menus).
|
||||
SSH configuration comes from the envs plugin and the conversation's location environment; the separate `terminal-sessions.json`
|
||||
mapping is no longer read. Host keys use `ssh/known_hosts` and `pi-env-<name>` aliases. `envs.test.ts` exercises a
|
||||
remote terminal against a disposable localhost SSH fixture; no real remote host is used. Windows PTYs remain unsupported. The workspace has an owner-only
|
||||
|
|
|
|||
|
|
@ -47,6 +47,11 @@ import "./terminal-clipboard.css";
|
|||
// terminal's cursor some other way (Enter, arrows, a paste), so the keyboard's idea of the text matches the field. A
|
||||
// field kept empty by cancelling every insertion does not: the keyboard deletes and corrects text the field never
|
||||
// had, which changes nothing and sends nothing.
|
||||
//
|
||||
// Touch: a long press on output text is the browser's own (word selection, handles, Copy); a long press on a blank
|
||||
// spot puts the textarea under the finger, so the browser offers Paste into it. Example: on "$ ls -la", a long press
|
||||
// on "ls" selects "ls"; one right of the prompt's end gets the Paste menu. While output is selected, rows are not
|
||||
// redrawn (`#held`): replacing a row's spans would drop the selection the moment the program printed.
|
||||
|
||||
/** Where terminals open: a tab of the workspace panel, or a panel below the chat. Kept per browser. */
|
||||
export type TerminalPlacement = "side" | "bottom";
|
||||
|
|
@ -58,6 +63,10 @@ const ACK_BATCH_BYTES = 64 * 1024;
|
|||
const ACK_DELAY_MS = 50;
|
||||
/** The server pings every 15 s (TERMINAL_PING_MS in service.ts): a socket silent for three of them is replaced. */
|
||||
const SILENCE_TIMEOUT_MS = 45_000;
|
||||
/** A touch held this long without moving is a long press (before Chrome's own, about 400 to 500 ms). */
|
||||
const LONG_PRESS_MS = 350;
|
||||
/** Selected output stays drawn as it is for this long after the selection last changed, despite new output. */
|
||||
const SELECTION_HOLD_MS = 15_000;
|
||||
const PLACEMENT = "p2-terminal-position";
|
||||
|
||||
export function terminalPlacement(): TerminalPlacement {
|
||||
|
|
@ -330,13 +339,19 @@ class TerminalSession {
|
|||
};
|
||||
#fling = 0;
|
||||
#longPress?: ReturnType<typeof setTimeout>;
|
||||
/** Rows are not redrawn while output is selected (`#holdForSelection`), until `#holdTimer` or `#release`. */
|
||||
#held = false;
|
||||
#holdTimer?: ReturnType<typeof setTimeout>;
|
||||
/** The next frame redraws every row (after a resize, a snapshot or a theme change). */
|
||||
#full = false;
|
||||
/** The textarea's text the terminal got, with its cursor after it (see the top of this file). */
|
||||
#typed = "";
|
||||
#composing = false;
|
||||
readonly #conversation: () => number;
|
||||
readonly #selectionChange = () => this.#caretMoved();
|
||||
readonly #selectionChange = () => {
|
||||
this.#caretMoved();
|
||||
this.#holdForSelection();
|
||||
};
|
||||
|
||||
constructor(options: {
|
||||
control: Held<typeof Terminals>;
|
||||
|
|
@ -402,9 +417,11 @@ class TerminalSession {
|
|||
this.#pressed = undefined;
|
||||
this.#touch = undefined;
|
||||
});
|
||||
// The right button goes to programs that track the mouse.
|
||||
// The right button goes to programs that track the mouse. A long press (pointerType "touch") is never a right
|
||||
// button: cancelling it would take the selection's Copy or the field's Paste menu away.
|
||||
screen.addEventListener("contextmenu", (event) => {
|
||||
if (
|
||||
event.pointerType !== "touch" &&
|
||||
!input.hasAttribute("data-native-menu") &&
|
||||
window.getSelection()?.isCollapsed !== false &&
|
||||
this.#vt?.mouseTracking() &&
|
||||
|
|
@ -412,6 +429,7 @@ class TerminalSession {
|
|||
)
|
||||
event.preventDefault();
|
||||
});
|
||||
screen.addEventListener("copy", (event) => this.#copy(event));
|
||||
}
|
||||
|
||||
restartHost(): void {
|
||||
|
|
@ -430,6 +448,7 @@ class TerminalSession {
|
|||
this.#stopped = true;
|
||||
this.#connection?.stop();
|
||||
clearTimeout(this.#longPress);
|
||||
clearTimeout(this.#holdTimer);
|
||||
clearTimeout(this.#ackTimer);
|
||||
cancelAnimationFrame(this.#pumpFrame);
|
||||
cancelAnimationFrame(this.#frame);
|
||||
|
|
@ -468,6 +487,7 @@ class TerminalSession {
|
|||
|
||||
/** Keys from the on-screen key row. */
|
||||
key(input: KeyInput): void {
|
||||
this.#release();
|
||||
const bytes = this.#vt?.encodeKey(input);
|
||||
if (bytes) this.#send(bytes);
|
||||
this.#clearInput();
|
||||
|
|
@ -475,6 +495,7 @@ class TerminalSession {
|
|||
|
||||
/** Text from the key row. */
|
||||
text(text: string): void {
|
||||
this.#release();
|
||||
this.#send(this.#encodeText(text).bytes);
|
||||
this.#clearInput();
|
||||
}
|
||||
|
|
@ -487,6 +508,7 @@ class TerminalSession {
|
|||
}
|
||||
|
||||
paste(text: string): void {
|
||||
this.#release();
|
||||
const bytes = this.#vt?.encodePaste(text);
|
||||
if (bytes) this.#send(bytes);
|
||||
this.#clearInput();
|
||||
|
|
@ -545,6 +567,77 @@ class TerminalSession {
|
|||
this.#clearInput();
|
||||
}
|
||||
|
||||
/** The document's selection, when it is a range of the output rows. */
|
||||
#outputSelection(): Range | undefined {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.isCollapsed || selection.rangeCount === 0) return undefined;
|
||||
const range = selection.getRangeAt(0);
|
||||
return range.intersectsNode(this.#rows) ? range : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Selected output keeps its rows: a redraw replaces a changed row's spans, and a selection in them is gone (Chrome
|
||||
* then also drops its handles and Copy menu). Each change of the selection holds the rows for SELECTION_HOLD_MS more;
|
||||
* a selection that ends (Copy, a tap elsewhere) draws what arrived meanwhile at once.
|
||||
*/
|
||||
#holdForSelection(): void {
|
||||
clearTimeout(this.#holdTimer);
|
||||
if (this.#outputSelection()) {
|
||||
this.#held = true;
|
||||
this.#holdTimer = setTimeout(() => this.#release(), SELECTION_HOLD_MS);
|
||||
} else this.#release();
|
||||
}
|
||||
|
||||
/** Draw again: the selection ended or expired, or the user typed, pasted or scrolled. */
|
||||
#release(): void {
|
||||
clearTimeout(this.#holdTimer);
|
||||
if (!this.#held) return;
|
||||
this.#held = false;
|
||||
this.#schedule();
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy selected output as a terminal does: a soft-wrapped line (a long command) as one line, without the blanks
|
||||
* that fill each row to the terminal's width.
|
||||
*/
|
||||
#copy(event: ClipboardEvent): void {
|
||||
const range = this.#outputSelection();
|
||||
if (!range || !event.clipboardData || !this.#rows.contains(range.commonAncestorContainer)) return;
|
||||
const rows = [...this.#rows.children].filter((row) => range.intersectsNode(row));
|
||||
let text = "";
|
||||
for (const [index, row] of rows.entries()) {
|
||||
const part = document.createRange();
|
||||
part.selectNodeContents(row);
|
||||
if (row.contains(range.startContainer)) part.setStart(range.startContainer, range.startOffset);
|
||||
if (row.contains(range.endContainer)) part.setEnd(range.endContainer, range.endOffset);
|
||||
const last = index === rows.length - 1;
|
||||
if (row.hasAttribute("data-wrapped") && !last) text += part.toString();
|
||||
else text += part.toString().trimEnd() + (last ? "" : "\n");
|
||||
}
|
||||
event.preventDefault();
|
||||
event.clipboardData.setData("text/plain", text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a character of the output is under a point: a long press there selects it natively; on a blank cell (the
|
||||
* cursor's, the rest of a line) the textarea goes under the finger for Paste. Columns come from the row's spans:
|
||||
* ASCII runs have a character per column, other characters are `.cell` boxes of their width (drawRow).
|
||||
*/
|
||||
#textAt(x: number, y: number): boolean {
|
||||
const box = this.#rows.getBoundingClientRect();
|
||||
const row = this.#rows.children[Math.floor((y - box.top) / this.#cell.height)];
|
||||
const column = Math.floor((x - box.left) / this.#cell.width);
|
||||
let start = 0;
|
||||
for (const span of row?.children ?? []) {
|
||||
const text = span.textContent ?? "";
|
||||
const cell = span instanceof HTMLElement && span.classList.contains("cell");
|
||||
const width = cell ? Math.round(Number.parseFloat(span.style.width) / this.#cell.width) : text.length;
|
||||
if (column < start + width) return /\S/.test(cell ? text : (text[column - start] ?? ""));
|
||||
start += width;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
#keydown(event: KeyboardEvent): void {
|
||||
if (event.isComposing || event.keyCode === 229 || event.key === "Dead" || event.key === "Unidentified") return;
|
||||
// The browser's own shortcuts: copy, paste, select all.
|
||||
|
|
@ -613,7 +706,8 @@ class TerminalSession {
|
|||
|
||||
// Mouse buttons go to programs that track the mouse, as in a terminal; Shift keeps the browser's text selection.
|
||||
// A finger taps, long-presses for the native menu, or scrolls (`scroll`). Capture only once it moves: rows can be
|
||||
// replaced while scrolling, but capture during a stationary long press interferes with Chrome's text menu.
|
||||
// replaced while scrolling, but capture during a stationary long press interferes with Chrome's text menu. Never
|
||||
// cancel a touch's pointerdown, touchstart or contextmenu: that takes away the long press's selection and menus.
|
||||
#pointerDown(event: PointerEvent): void {
|
||||
if (event.pointerType === "touch") {
|
||||
cancelAnimationFrame(this.#fling);
|
||||
|
|
@ -627,7 +721,7 @@ class TerminalSession {
|
|||
clearTimeout(this.#longPress);
|
||||
delete this.#input.dataset.nativeMenu;
|
||||
// Leave an existing output selection alone: Chrome owns its Copy menu and handles.
|
||||
if (window.getSelection()?.isCollapsed === false) return;
|
||||
if (this.#outputSelection()) return;
|
||||
this.#scrolled = 0;
|
||||
const { clientX: x, clientY: y } = event;
|
||||
const touch = {
|
||||
|
|
@ -641,16 +735,21 @@ class TerminalSession {
|
|||
velocity: 0,
|
||||
};
|
||||
this.#touch = touch;
|
||||
// Put a real, transparent editing target under the finger BEFORE Chrome's long-press deadline.
|
||||
// Do not capture the pointer or cancel touch/contextmenu: those suppress the native text menu.
|
||||
// On output text the long press is Chrome's: it selects the word under the finger. The textarea must not be
|
||||
// there, or Chrome selects in the (empty) field and offers only Paste.
|
||||
const text = this.#textAt(x, y);
|
||||
if (!text) {
|
||||
// A blank spot: put a real, transparent editing target under the finger BEFORE Chrome's long-press deadline.
|
||||
const box = (this.#input.parentElement ?? this.#screen).getBoundingClientRect();
|
||||
this.#input.dataset.nativeMenu = "";
|
||||
this.#input.style.transform = `translate(${x - box.left - this.#input.offsetLeft - 22}px, ${y - box.top - this.#input.offsetTop - 22}px)`;
|
||||
}
|
||||
// A long press neither scrolls when the finger then moves (it extends Chrome's selection) nor taps on lifting.
|
||||
this.#longPress = setTimeout(() => {
|
||||
if (this.#touch !== touch || touch.moved || window.getSelection()?.isCollapsed === false) return;
|
||||
if (this.#touch !== touch || touch.moved || this.#outputSelection()) return;
|
||||
touch.longPressed = true;
|
||||
this.#input.focus({ preventScroll: true });
|
||||
}, 350);
|
||||
if (!text) this.#input.focus({ preventScroll: true });
|
||||
}, LONG_PRESS_MS);
|
||||
return;
|
||||
}
|
||||
if (event.shiftKey || !this.#vt?.mouseTracking()) return;
|
||||
|
|
@ -734,6 +833,7 @@ class TerminalSession {
|
|||
): void {
|
||||
const vt = this.#vt;
|
||||
if (!vt) return;
|
||||
this.#release();
|
||||
this.#scrolled += pixels;
|
||||
const rows = Math.trunc(this.#scrolled / this.#cell.height);
|
||||
if (!rows) return;
|
||||
|
|
@ -923,6 +1023,8 @@ class TerminalSession {
|
|||
this.#vt = loaded.terminal({ cols: terminal.cols, rows: terminal.rows, scrollback: 10_000 });
|
||||
this.#snapshot = true;
|
||||
this.#ready = true;
|
||||
// A selection in the previous terminal's rows must not keep this one from being drawn.
|
||||
this.#release();
|
||||
this.#pump();
|
||||
this.#emit({
|
||||
current: terminal,
|
||||
|
|
@ -1077,7 +1179,8 @@ class TerminalSession {
|
|||
|
||||
#draw(): void {
|
||||
const vt = this.#vt;
|
||||
if (!vt) return;
|
||||
// Libghostty keeps the changes until the next frame, so the release draws everything that arrived meanwhile.
|
||||
if (!vt || (this.#held && this.#outputSelection())) return;
|
||||
const all = this.#full;
|
||||
this.#full = false;
|
||||
const frame = vt.frame(all);
|
||||
|
|
@ -1087,9 +1190,12 @@ class TerminalSession {
|
|||
this.#rows.append(row);
|
||||
}
|
||||
while (this.#rows.children.length > frame.rows) this.#rows.lastElementChild?.remove();
|
||||
for (const { y, cells } of frame.changed) {
|
||||
for (const { y, cells, wrapped } of frame.changed) {
|
||||
const row = this.#rows.children[y];
|
||||
if (row instanceof HTMLElement) drawRow(row, cells, this.#cell.width);
|
||||
if (!(row instanceof HTMLElement)) continue;
|
||||
drawRow(row, cells, this.#cell.width);
|
||||
// Copying joins it with the next row (`#copy`).
|
||||
row.toggleAttribute("data-wrapped", wrapped);
|
||||
}
|
||||
const cursor = frame.cursor;
|
||||
this.#cursor.hidden = !cursor;
|
||||
|
|
|
|||
|
|
@ -30,7 +30,10 @@ const RENDER_CURSOR_X = 15;
|
|||
const RENDER_CURSOR_Y = 16;
|
||||
const RENDER_CURSOR_WIDE_TAIL = 17;
|
||||
const ROW_DIRTY = 1;
|
||||
const ROW_RAW = 2;
|
||||
const ROW_CELLS = 3;
|
||||
// GhosttyRowData
|
||||
const ROW_DATA_WRAP = 1;
|
||||
const CELL_RAW = 1;
|
||||
const CELL_STYLE = 2;
|
||||
const CELL_GRAPHEMES_LEN = 3;
|
||||
|
|
@ -82,8 +85,8 @@ export interface Frame {
|
|||
blinking: boolean;
|
||||
color?: number;
|
||||
};
|
||||
/** Changed rows, top to bottom, by viewport row. */
|
||||
changed: { y: number; cells: Cell[] }[];
|
||||
/** Changed rows, top to bottom, by viewport row; `wrapped`: soft-wrapped, its line continues on the next row. */
|
||||
changed: { y: number; cells: Cell[]; wrapped: boolean }[];
|
||||
}
|
||||
|
||||
export interface KeyInput {
|
||||
|
|
@ -687,6 +690,9 @@ export class VtTerminal {
|
|||
ghostty.call("ghostty_render_state_row_get", this.#rows, ROW_DIRTY, scratch);
|
||||
const rowDirty = ghostty.view().getUint8(scratch) !== 0;
|
||||
if (!(all || dirty === "full" || rowDirty)) continue;
|
||||
ghostty.call("ghostty_render_state_row_get", this.#rows, ROW_RAW, scratch);
|
||||
ghostty.call("ghostty_row_get", ghostty.view().getBigUint64(scratch, true), ROW_DATA_WRAP, scratch);
|
||||
const wrapped = ghostty.view().getUint8(scratch) !== 0;
|
||||
ghostty.view().setUint32(scratch, this.#cells, true);
|
||||
ghostty.call("ghostty_render_state_row_get", this.#rows, ROW_CELLS, scratch);
|
||||
const cells: Cell[] = [];
|
||||
|
|
@ -732,7 +738,7 @@ export class VtTerminal {
|
|||
: -1;
|
||||
cells.push({ text, width, fg, bg, flags: cellFlags });
|
||||
}
|
||||
changed.push({ y, cells });
|
||||
changed.push({ y, cells, wrapped });
|
||||
ghostty.view().setUint8(scratch, 0);
|
||||
ghostty.call("ghostty_render_state_row_set", this.#rows, 0, scratch);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,19 @@ function terminal(cols = 20, rows = 5, onReply?: (bytes: Uint8Array) => void): P
|
|||
return ghostty.then((loaded) => loaded.terminal({ cols, rows, scrollback: 1000, ...(onReply ? { onReply } : {}) }));
|
||||
}
|
||||
|
||||
// The web view joins these rows when copying, so a long command copies as one line.
|
||||
test("ghostty: marks soft-wrapped rows", async () => {
|
||||
const vt = await terminal(10, 4);
|
||||
vt.write(encoder.encode("0123456789abcdef\r\nshort"));
|
||||
const frame = vt.frame(true);
|
||||
assert.deepEqual(text(frame).slice(0, 3), ["0123456789", "abcdef", "short"]);
|
||||
assert.deepEqual(
|
||||
frame.changed.map((row) => row.wrapped),
|
||||
[true, false, false, false],
|
||||
);
|
||||
vt.free();
|
||||
});
|
||||
|
||||
test("ghostty: renders text, styles, colors and the cursor", async () => {
|
||||
const vt = await terminal();
|
||||
vt.write(encoder.encode("hello\r\n\x1b[1;31mred\x1b[0m \x1b[7mrev\x1b[0m\r\n\x1b[38;2;1;2;3m日本"));
|
||||
|
|
|
|||
|
|
@ -17,10 +17,13 @@ try {
|
|||
unavailable = String(error);
|
||||
}
|
||||
|
||||
test(
|
||||
"touch terminal native paste target, clipboard key, denial, scrolling and Copy selection",
|
||||
{ timeout: 60000, skip: !browserTests ? "no Chromium" : unavailable },
|
||||
async () => {
|
||||
// Touch on the terminal (Terminal.tsx). Headless Chromium has no long-press gesture: CDP touches never select a word,
|
||||
// open Chrome's Copy/Paste menus or show selection handles (a held touch ends as a tap). These tests check what the
|
||||
// native gestures depend on: where the textarea is during a long press, that no touch event is cancelled, the paste
|
||||
// and copy events, and that a selection survives output. The native menus themselves need a phone.
|
||||
|
||||
/** A page with a touch screen showing a new terminal; its socket is wrapped so tests see sent bytes and inject output. */
|
||||
async function withTerminal(run: (page: Browser, origin: string) => Promise<void>): Promise<void> {
|
||||
const shell = process.env.SHELL;
|
||||
process.env.SHELL = "/bin/sh";
|
||||
const fixture = await startFixture({ web: true });
|
||||
|
|
@ -33,7 +36,6 @@ test(
|
|||
const origin = `http://127.0.0.1:${fixture.server.port}`;
|
||||
browser = await Browser.start();
|
||||
const page = browser;
|
||||
|
||||
await page.metrics(390, 844);
|
||||
await page.call("Emulation.setTouchEmulationEnabled", { enabled: true, maxTouchPoints: 1 });
|
||||
await page.call("Page.addScriptToEvaluateOnNewDocument", {
|
||||
|
|
@ -55,7 +57,33 @@ test(
|
|||
await page.wait(
|
||||
"document.querySelector('.terminal-row')?.textContent.includes('$') || document.querySelector('.terminal-row')?.textContent.includes('#')",
|
||||
);
|
||||
await run(page, origin);
|
||||
} finally {
|
||||
await browser?.close();
|
||||
const host = await connectTerminalHost(fixture.dataDir, APP);
|
||||
host.resume();
|
||||
host.write(encodeFrame({ type: "shutdown" }));
|
||||
await new Promise((resolve) => host.once("close", resolve));
|
||||
await fixture.close();
|
||||
if (shell === undefined) delete process.env.SHELL;
|
||||
else process.env.SHELL = shell;
|
||||
}
|
||||
}
|
||||
|
||||
/** Output from the program, straight into the page's terminal (the shell does not see it). */
|
||||
function output(page: Browser, text: string): Promise<unknown> {
|
||||
return page.evaluate(
|
||||
`window.terminalSocket.dispatchEvent(new MessageEvent('message',{data:new TextEncoder().encode(${JSON.stringify(text)}).buffer}))`,
|
||||
);
|
||||
}
|
||||
|
||||
test(
|
||||
"touch terminal: a long press on a blank spot is a native paste target; clipboard key, denial, scrolling",
|
||||
{ timeout: 60000, skip: !browserTests ? "no Chromium" : unavailable },
|
||||
() =>
|
||||
withTerminal(async (page, origin) => {
|
||||
await page.wait("document.querySelector('button[aria-label=\"Paste into terminal\"]')");
|
||||
// A blank cell: the prompt is on the first row.
|
||||
let point = await page.evaluate<{ x: number; y: number }>(
|
||||
`(()=>{const r=document.querySelector('.terminal-content').getBoundingClientRect();return {x:r.left+r.width/2,y:r.top+Math.min(70,r.height/2)}})()`,
|
||||
);
|
||||
|
|
@ -184,15 +212,102 @@ test(
|
|||
false,
|
||||
);
|
||||
await touch("touchEnd");
|
||||
} finally {
|
||||
await browser?.close();
|
||||
const host = await connectTerminalHost(fixture.dataDir, APP);
|
||||
host.resume();
|
||||
host.write(encodeFrame({ type: "shutdown" }));
|
||||
await new Promise((resolve) => host.once("close", resolve));
|
||||
await fixture.close();
|
||||
if (shell === undefined) delete process.env.SHELL;
|
||||
else process.env.SHELL = shell;
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
test(
|
||||
"touch terminal: a long press on output stays native, the selection survives output, Copy joins wrapped rows",
|
||||
{ timeout: 60000, skip: !browserTests ? "no Chromium" : unavailable },
|
||||
() =>
|
||||
withTerminal(async (page) => {
|
||||
// Every touch event and contextmenu after the page's own listeners ran: none may be cancelled.
|
||||
await page.evaluate(
|
||||
`window.cancelled=[];for(const type of ['touchstart','touchmove','touchend','pointerdown','pointerup','contextmenu'])window.addEventListener(type,event=>{if(event.defaultPrevented)window.cancelled.push(type)});`,
|
||||
);
|
||||
// A program that tracks the mouse: a tap clicks in it, so a long press that ended as a tap would send bytes.
|
||||
await output(page, "\x1b[?1000h\r\nselect-me please\r\n");
|
||||
await page.wait(
|
||||
"[...document.querySelectorAll('.terminal-row')].some(row=>row.textContent.includes('select-me'))",
|
||||
);
|
||||
const word = `(()=>{const row=[...document.querySelectorAll('.terminal-row')].find(row=>row.textContent.includes('select-me'));
|
||||
const walker=document.createTreeWalker(row,NodeFilter.SHOW_TEXT);let node;while((node=walker.nextNode())&&!node.data.includes('select-me'));
|
||||
const range=document.createRange();const start=node.data.indexOf('select-me');range.setStart(node,start);range.setEnd(node,start+9);return range;})()`;
|
||||
const point = await page.evaluate<{ x: number; y: number }>(
|
||||
`(()=>{const r=${word}.getBoundingClientRect();return {x:r.left+r.width/2,y:r.top+r.height/2}})()`,
|
||||
);
|
||||
const sent = await page.evaluate<number>("window.terminalBytes.length");
|
||||
await page.call("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ ...point, id: 1 }] });
|
||||
await delay(700);
|
||||
const held = await page.evaluate<{ native: boolean; hit: string; focused: boolean }>(
|
||||
`(()=>{const e=document.querySelector('.terminal-input');return {native:e.hasAttribute('data-native-menu'),hit:document.elementFromPoint(${point.x},${point.y}).closest('.terminal-row')?'row':document.elementFromPoint(${point.x},${point.y}).className,focused:document.activeElement===e}})()`,
|
||||
);
|
||||
// Chrome selects the word under the finger, not in a textarea moved there, and the keyboard stays down.
|
||||
assert.deepEqual(held, { native: false, hit: "row", focused: false });
|
||||
// What Chrome's long press does next, which CDP cannot: select the word and send a touch contextmenu.
|
||||
await page.evaluate(
|
||||
`(()=>{const selection=getSelection();selection.removeAllRanges();selection.addRange(${word});})()`,
|
||||
);
|
||||
assert.equal(
|
||||
await page.evaluate<boolean>(
|
||||
"document.querySelector('.terminal-row').dispatchEvent(new PointerEvent('contextmenu',{pointerType:'touch',bubbles:true,cancelable:true}))",
|
||||
),
|
||||
true,
|
||||
);
|
||||
await page.call("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
|
||||
await delay(200);
|
||||
assert.equal(await page.evaluate<number>("window.terminalBytes.length"), sent, "a long press is no tap");
|
||||
assert.deepEqual(await page.evaluate<string[]>("window.cancelled"), []);
|
||||
// A touch contextmenu is never the program's right button, even with nothing selected; a mouse's is.
|
||||
await page.evaluate("getSelection().removeAllRanges()");
|
||||
assert.equal(
|
||||
await page.evaluate<boolean>(
|
||||
"document.querySelector('.terminal-row').dispatchEvent(new PointerEvent('contextmenu',{pointerType:'touch',bubbles:true,cancelable:true}))",
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
await page.evaluate<boolean>(
|
||||
"document.querySelector('.terminal-row').dispatchEvent(new PointerEvent('contextmenu',{pointerType:'mouse',bubbles:true,cancelable:true}))",
|
||||
),
|
||||
false,
|
||||
);
|
||||
await output(page, "\x1b[?1000l");
|
||||
|
||||
// Output while the word is selected scrolls the program's screen but not the drawn rows.
|
||||
await page.evaluate(
|
||||
`(()=>{const selection=getSelection();selection.removeAllRanges();selection.addRange(${word});})()`,
|
||||
);
|
||||
await delay(100);
|
||||
const lines = Array.from({ length: 60 }, (_, index) => `flood-${index}\r\n`).join("");
|
||||
await output(page, lines);
|
||||
await delay(300);
|
||||
assert.equal(await page.evaluate<string>("getSelection().toString()"), "select-me");
|
||||
assert.equal(
|
||||
await page.evaluate<boolean>(
|
||||
"document.querySelector('.terminal-content').textContent.includes('flood-59')",
|
||||
),
|
||||
false,
|
||||
);
|
||||
const copy = `(()=>{const data=new DataTransfer();document.querySelector('.terminal-row').dispatchEvent(new ClipboardEvent('copy',{clipboardData:data,bubbles:true,cancelable:true}));return data.getData('text/plain')})()`;
|
||||
assert.equal(await page.evaluate<string>(copy), "select-me");
|
||||
// The selection ends (Copy, a tap): what arrived meanwhile is drawn.
|
||||
await page.evaluate("getSelection().removeAllRanges()");
|
||||
await page.wait("document.querySelector('.terminal-content').textContent.includes('flood-59')");
|
||||
|
||||
// A line longer than the terminal is wide copies as one line; rows lose the blanks that fill them.
|
||||
const long = `wrap-${"a".repeat(120)}-end`;
|
||||
await output(page, `\x1b[2J\x1b[H${long}\r\nnext \r\n`);
|
||||
await page.wait("document.querySelector('.terminal-content').textContent.includes('next')");
|
||||
const wrapped = await page.evaluate<number>(
|
||||
"[...document.querySelectorAll('.terminal-row')].filter(row=>row.hasAttribute('data-wrapped')).length",
|
||||
);
|
||||
assert(wrapped >= 2, `${wrapped} wrapped rows`);
|
||||
await page.evaluate(
|
||||
`(()=>{const rows=[...document.querySelectorAll('.terminal-row')];const last=rows.findIndex(row=>row.textContent.startsWith('next'));
|
||||
const range=document.createRange();range.setStart(rows[0],0);range.setEnd(rows[last],rows[last].childNodes.length);
|
||||
const selection=getSelection();selection.removeAllRanges();selection.addRange(range);})()`,
|
||||
);
|
||||
assert.equal(await page.evaluate<string>(copy), `${long}\nnext`);
|
||||
await page.evaluate("getSelection().removeAllRanges()");
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue