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:
Mario Zechner 2026-11-05 11:58:59 +01:00
parent a5d209afd0
commit 1137075c78
5 changed files with 304 additions and 58 deletions

View file

@ -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

View file

@ -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.
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)`;
// 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;

View file

@ -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);
}

View file

@ -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日本"));

View file

@ -17,27 +17,29 @@ 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 () => {
const shell = process.env.SHELL;
process.env.SHELL = "/bin/sh";
const fixture = await startFixture({ web: true });
let browser: Browser | undefined;
try {
const owner = fixture.browser();
const id = (
await (await holdIn(owner, Sessions, {}, "owner")).create({ cwd: fixture.workspace }, BACKGROUND_CONTEXT)
).id;
const origin = `http://127.0.0.1:${fixture.server.port}`;
browser = await Browser.start();
const page = browser;
// 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.
await page.metrics(390, 844);
await page.call("Emulation.setTouchEmulationEnabled", { enabled: true, maxTouchPoints: 1 });
await page.call("Page.addScriptToEvaluateOnNewDocument", {
source: `
/** 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 });
let browser: Browser | undefined;
try {
const owner = fixture.browser();
const id = (
await (await holdIn(owner, Sessions, {}, "owner")).create({ cwd: fixture.workspace }, BACKGROUND_CONTEXT)
).id;
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", {
source: `
window.terminalBytes=[];
window.WebSocket=new Proxy(window.WebSocket,{construct(Target,args){
const socket=Reflect.construct(Target,args);
@ -48,14 +50,40 @@ test(
}
return socket;
}});`,
});
await page.open(`${origin}/s/${id}`);
await page.wait("document.querySelector('button[aria-label=Terminal]')");
await page.evaluate("document.querySelector('button[aria-label=Terminal]').click()");
await page.wait(
"document.querySelector('.terminal-row')?.textContent.includes('$') || document.querySelector('.terminal-row')?.textContent.includes('#')",
);
});
await page.open(`${origin}/s/${id}`);
await page.wait("document.querySelector('button[aria-label=Terminal]')");
await page.evaluate("document.querySelector('button[aria-label=Terminal]').click()");
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)}})()`,
);
@ -77,8 +105,8 @@ test(
contextAllowed: boolean;
}>(
`(()=>{const e=document.querySelector('.terminal-input'),r=e.getBoundingClientRect(),s=getComputedStyle(e);
const event=new MouseEvent('contextmenu',{bubbles:true,cancelable:true});
return {underFinger:document.elementFromPoint(${point.x},${point.y})===e,focused:document.activeElement===e,width:r.width,height:r.height,pointer:s.pointerEvents,selection:s.userSelect,background:s.backgroundColor,contextAllowed:e.dispatchEvent(event)}})()`,
const event=new MouseEvent('contextmenu',{bubbles:true,cancelable:true});
return {underFinger:document.elementFromPoint(${point.x},${point.y})===e,focused:document.activeElement===e,width:r.width,height:r.height,pointer:s.pointerEvents,selection:s.userSelect,background:s.backgroundColor,contextAllowed:e.dispatchEvent(event)}})()`,
);
assert(native.underFinger && native.focused, JSON.stringify(native));
assert(native.width >= 40 && native.height >= 40);
@ -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()");
}),
);