From 1137075c78b9b00c4b7dedd80911be02b93bea6b Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 5 Nov 2026 11:58:59 +0100 Subject: [PATCH] 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. --- apps/p2/docs/p2-notes.md | 6 + apps/p2/plugins/terminals/Terminal.tsx | 136 ++++++++++++-- apps/p2/plugins/terminals/ghostty.ts | 12 +- apps/p2/test/p2/ghostty.test.ts | 13 ++ apps/p2/test/p2/terminal-clipboard.test.ts | 195 ++++++++++++++++----- 5 files changed, 304 insertions(+), 58 deletions(-) diff --git a/apps/p2/docs/p2-notes.md b/apps/p2/docs/p2-notes.md index f81b6a6d6..76158914e 100644 --- a/apps/p2/docs/p2-notes.md +++ b/apps/p2/docs/p2-notes.md @@ -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-` 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 diff --git a/apps/p2/plugins/terminals/Terminal.tsx b/apps/p2/plugins/terminals/Terminal.tsx index a8e1ca14e..87eb80a40 100644 --- a/apps/p2/plugins/terminals/Terminal.tsx +++ b/apps/p2/plugins/terminals/Terminal.tsx @@ -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; + /** Rows are not redrawn while output is selected (`#holdForSelection`), until `#holdTimer` or `#release`. */ + #held = false; + #holdTimer?: ReturnType; /** 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; @@ -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; diff --git a/apps/p2/plugins/terminals/ghostty.ts b/apps/p2/plugins/terminals/ghostty.ts index 78cc5ff78..2d5201f54 100644 --- a/apps/p2/plugins/terminals/ghostty.ts +++ b/apps/p2/plugins/terminals/ghostty.ts @@ -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); } diff --git a/apps/p2/test/p2/ghostty.test.ts b/apps/p2/test/p2/ghostty.test.ts index 4e22a0aff..24da0d0c7 100644 --- a/apps/p2/test/p2/ghostty.test.ts +++ b/apps/p2/test/p2/ghostty.test.ts @@ -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日本")); diff --git a/apps/p2/test/p2/terminal-clipboard.test.ts b/apps/p2/test/p2/terminal-clipboard.test.ts index 9688424ac..f41f6c624 100644 --- a/apps/p2/test/p2/terminal-clipboard.test.ts +++ b/apps/p2/test/p2/terminal-clipboard.test.ts @@ -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): Promise { + 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 { + 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("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( + "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("window.terminalBytes.length"), sent, "a long press is no tap"); + assert.deepEqual(await page.evaluate("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( + "document.querySelector('.terminal-row').dispatchEvent(new PointerEvent('contextmenu',{pointerType:'touch',bubbles:true,cancelable:true}))", + ), + true, + ); + assert.equal( + await page.evaluate( + "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("getSelection().toString()"), "select-me"); + assert.equal( + await page.evaluate( + "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(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( + "[...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(copy), `${long}\nnext`); + await page.evaluate("getSelection().removeAllRanges()"); + }), );