diff --git a/mobile/package-lock.json b/mobile/package-lock.json index dbd08467..a640ee9a 100644 --- a/mobile/package-lock.json +++ b/mobile/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@tauri-apps/api": "^2.11.1", "@tauri-apps/plugin-barcode-scanner": "^2.5.0", + "@xterm/addon-webgl": "^0.19.0", "@xterm/xterm": "^6.0.0" }, "devDependencies": { @@ -526,6 +527,12 @@ "@tauri-apps/api": "^2.12.0" } }, + "node_modules/@xterm/addon-webgl": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz", + "integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==", + "license": "MIT" + }, "node_modules/@xterm/xterm": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", diff --git a/mobile/package.json b/mobile/package.json index cb033638..19002305 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -11,6 +11,7 @@ "dependencies": { "@tauri-apps/api": "^2.11.1", "@tauri-apps/plugin-barcode-scanner": "^2.5.0", + "@xterm/addon-webgl": "^0.19.0", "@xterm/xterm": "^6.0.0" }, "devDependencies": { diff --git a/mobile/src/main.ts b/mobile/src/main.ts index 31bc092d..9186affe 100644 --- a/mobile/src/main.ts +++ b/mobile/src/main.ts @@ -2,6 +2,7 @@ import "@xterm/xterm/css/xterm.css"; import "./style.css"; import { Terminal } from "@xterm/xterm"; +import { WebglAddon } from "@xterm/addon-webgl"; import type { ITheme } from "@xterm/xterm"; import * as scanner from "@tauri-apps/plugin-barcode-scanner"; @@ -21,6 +22,30 @@ import logoUrl from "./assets/logo.svg?url"; const app = document.getElementById("app")!; +// The keyboard. The WebView runs edge to edge and is never resized for it +// (lib.rs `edge_to_edge`): the keyboard simply covers the bottom of the page. +// What is left is the visual viewport, so the app is sized to that, and the +// dock and the message box sit on top of the keyboard. Screens that lay out +// by size hear it as a window resize. +{ + const view = window.visualViewport; + let last = 0; + const fitView = () => { + if (!view) return; + const height = Math.round(view.height); + // iOS scrolls the page to show a focused field; the app does its own. + if (window.scrollY) window.scrollTo(0, 0); + if (height === last) return; + last = height; + const up = height < window.innerHeight - 80; + document.documentElement.classList.toggle("keyboard", up); + app.style.height = up ? `${height}px` : ""; + window.dispatchEvent(new CustomEvent("viewport")); + }; + view?.addEventListener("resize", fitView); + view?.addEventListener("scroll", fitView); +} + // --------------------------------------------------------------------------- // A tiny DOM helper. Four screens do not justify a framework, and the // terminal — the one heavy view — is xterm.js either way. @@ -1307,6 +1332,7 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, } // Typing brings a pane scrolled back into its history to the prompt. term.scrollToBottom(); + showCursor(); void input(data); }; @@ -1490,28 +1516,98 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, // mostly vertical, it scrolls the buffer here, a row at a time, and // coasts after the finger lifts; mostly sideways, it is left to the // native pan of a pane wider than the phone. - let touch: { x: number; y: number; axis: "x" | "y" | null; carry: number; samples: [number, number][] } | null = - null; + let touch: { x: number; y: number; axis: "x" | "y" | null; samples: [number, number][] } | null = null; let coast = 0; + // Finger movement not yet applied, and the frame that will apply it. + let pending = 0; + let frame = 0; const rowHeight = () => { const drawn = screenEl.querySelector(".xterm-screen"); return drawn && term.rows ? drawn.clientHeight / term.rows : 14; }; - // Moves the buffer by a distance in pixels, keeping what is short of a - // row for the next move. Dragging down goes back in the scrollback. - const scrollBy = (dy: number, carry: number) => { - const total = carry + dy; - const rows = Math.trunc(total / rowHeight()); - if (rows) term.scrollLines(-rows); - return total - rows * rowHeight(); + // Scrolls the pane's box so the cursor's line shows, when the pane is + // taller than the room left (the keyboard is up). Not while reading back + // through history. + const showCursor = () => { + const buf = term.buffer.active; + if (buf.viewportY < buf.baseY) return; + const pad = parseFloat(getComputedStyle(screenEl).paddingTop) || 0; + const top = pad + buf.cursorY * rowHeight(); + const bottom = top + rowHeight() + pad; + if (bottom > screenEl.scrollTop + screenEl.clientHeight) screenEl.scrollTop = bottom - screenEl.clientHeight; + else if (top < screenEl.scrollTop) screenEl.scrollTop = Math.max(0, top - pad); + }; + // How far past the top row's top edge the view is, in pixels: the part + // of a row a swipe has moved that xterm, which scrolls whole rows, cannot + // show. The drawn screen is shifted up by it, so the text follows the + // finger pixel by pixel instead of jumping a row at a time. The GPU moves + // the image; nothing is redrawn for it. + let frac = 0; + // The shift waiting for xterm to draw the rows it goes with. + let shiftAfterRender: number | null = null; + const shift = (px: number) => { + const drawn = screenEl.querySelector(".xterm-screen"); + if (drawn) drawn.style.transform = px ? `translate3d(0, ${-px}px, 0)` : ""; + }; + const setFrac = (px: number, afterRender = false) => { + frac = px; + // xterm draws a scroll on its next frame, not at once. A shift for the + // new rows put on the old ones would jolt the text a row for a frame, + // on every row crossed; so it waits for the draw. + if (afterRender) shiftAfterRender = px; + else { + shiftAfterRender = null; + shift(px); + } + }; + term.onRender(() => { + if (shiftAfterRender === null) return; + shift(shiftAfterRender); + shiftAfterRender = null; + }); + // Anything else that scrolls the buffer — output, typing — lands on a + // whole row. + let ownScroll = false; + term.onScroll(() => { + if (!ownScroll && frac) setFrac(0); + }); + // Moves the view by a distance in pixels. Dragging down goes back in the + // scrollback. + const scrollBy = (dy: number) => { + // With the keyboard up the pane can be taller than what is left of the + // screen, and its box scrolls too: back through history, the box goes + // to its top before the buffer moves; forward, the buffer comes to the + // prompt before the box goes to its bottom. + const buf = term.buffer.active; + const boxRoom = screenEl.scrollHeight - screenEl.clientHeight; + if ((dy > 0 && screenEl.scrollTop > 0) || (dy < 0 && buf.viewportY >= buf.baseY && screenEl.scrollTop < boxRoom)) { + screenEl.scrollTop -= dy; + return; + } + const row = rowHeight(); + // Where the view is, from the top of the scrollback, and where it goes; + // the bottom is the prompt with nothing shifted. + const at = buf.viewportY * row + frac; + const to = Math.min(Math.max(0, at - dy), buf.baseY * row); + const line = Math.floor(to / row); + const crossed = line !== buf.viewportY; + if (crossed) { + ownScroll = true; + term.scrollToLine(line); + ownScroll = false; + } + setFrac(to - line * row, crossed); }; screenEl.addEventListener( "touchstart", (e) => { cancelAnimationFrame(coast); + cancelAnimationFrame(frame); + frame = 0; + pending = 0; if (e.touches.length !== 1) return (touch = null); const t = e.touches[0]; - touch = { x: t.clientX, y: t.clientY, axis: null, carry: 0, samples: [[t.clientY, e.timeStamp]] }; + touch = { x: t.clientX, y: t.clientY, axis: null, samples: [[t.clientY, e.timeStamp]] }; }, { passive: true }, ); @@ -1529,9 +1625,17 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, if (touch.axis !== "y") return; e.preventDefault(); const last = touch.samples[touch.samples.length - 1][0]; - touch.carry = scrollBy(t.clientY - last, touch.carry); + pending += t.clientY - last; touch.samples.push([t.clientY, e.timeStamp]); if (touch.samples.length > 5) touch.samples.shift(); + // A 120 Hz screen sends a move every 8 ms; the terminal redraws once + // a frame, with all of them. + if (!frame) + frame = requestAnimationFrame(() => { + frame = 0; + if (touch) scrollBy(pending); + pending = 0; + }); }, { passive: false }, ); @@ -1558,7 +1662,6 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, const [y1, t1] = lifted.samples[lifted.samples.length - 1]; // Pixels per millisecond at the lift, eased out over about a second. let velocity = (y1 - y0) / Math.max(1, t1 - t0); - let carry = lifted.carry; let at = performance.now(); const step = (now: number) => { if (!alive) return; @@ -1566,7 +1669,7 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, at = now; velocity *= Math.pow(0.995, dt); if (Math.abs(velocity) < 0.02) return; - carry = scrollBy(velocity * dt, carry); + scrollBy(velocity * dt); coast = requestAnimationFrame(step); }; coast = requestAnimationFrame(step); @@ -1659,6 +1762,10 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, fit(); }; term.onCursorMove(follow); + // Output running on below what the keyboard leaves showing: kept in sight. + term.onCursorMove(() => { + if (document.documentElement.classList.contains("keyboard")) showCursor(); + }); // The whole buffer, scrollback included, as lines of text. A row the // terminal wrapped joins the one before it, so a long URL or path comes // out whole; its trailing blanks are real and kept. @@ -1694,6 +1801,13 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, }; window.addEventListener("resize", fit); window.addEventListener("resize", regridSoon); + // The keyboard came up or went down: the pane's box grew or shrank, so a + // taken-over pane regrids, and the cursor's line is kept in sight. + const viewport = () => { + regridSoon(); + requestAnimationFrame(showCursor); + }; + window.addEventListener("viewport", viewport); darkScheme.addEventListener("change", retheme); const setState = (cls: string, label: string) => { @@ -1813,6 +1927,7 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, window.removeEventListener("online", online); window.removeEventListener("resize", fit); window.removeEventListener("resize", regridSoon); + window.removeEventListener("viewport", viewport); clearTimeout(regrid); darkScheme.removeEventListener("change", retheme); if (handle !== null) api.paneClose(handle); @@ -1828,6 +1943,16 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string, document.fonts.load("12px Hack").finally(() => { if (!alive) return; term.open(screenEl); + // Drawn on the GPU: the DOM renderer lays every row out again on each + // line scrolled, which a phone cannot do at the finger's pace. If the + // context is lost, xterm goes back to the DOM renderer. + try { + const webgl = new WebglAddon(); + webgl.onContextLoss(() => webgl.dispose()); + term.loadAddon(webgl); + } catch { + // No WebGL here: the DOM renderer stays. + } fit(); edited(); open(); diff --git a/mobile/src/style.css b/mobile/src/style.css index b301523d..9f87ea94 100644 --- a/mobile/src/style.css +++ b/mobile/src/style.css @@ -53,6 +53,10 @@ --mono: Hack, "SF Mono", Menlo, ui-monospace, monospace; --ease: cubic-bezier(0.32, 0.72, 0, 1); + /* The home indicator's gap; none while the keyboard is up, which covers + it (main.ts sets `keyboard` on ). */ + --safe-bottom: env(safe-area-inset-bottom); + color-scheme: light dark; font: 17px/1.35 var(--ui); letter-spacing: -0.005em; @@ -108,6 +112,10 @@ body { height: 100dvh; } +html.keyboard { + --safe-bottom: 0px; +} + button { font: inherit; color: inherit; @@ -306,7 +314,7 @@ code { overflow-y: auto; overscroll-behavior-y: contain; -webkit-overflow-scrolling: touch; - padding: 0 max(0px, env(safe-area-inset-right)) calc(32px + env(safe-area-inset-bottom)) + padding: 0 max(0px, env(safe-area-inset-right)) calc(32px + var(--safe-bottom)) max(0px, env(safe-area-inset-left)); } @@ -326,7 +334,7 @@ code { /* Clear of the floating bar at the bottom. */ .screen.docked .scroll { - padding-bottom: calc(104px + env(safe-area-inset-bottom)); + padding-bottom: calc(104px + var(--safe-bottom)); } .large-title { @@ -822,7 +830,7 @@ html.scanning #app { justify-items: center; align-items: center; gap: 24px; - padding: env(safe-area-inset-top) 24px calc(28px + env(safe-area-inset-bottom)); + padding: env(safe-area-inset-top) 24px calc(28px + var(--safe-bottom)); color: #fff; } @@ -931,7 +939,7 @@ html.scanning #app { .dock { flex: none; - padding: 12px max(16px, env(safe-area-inset-right)) calc(12px + env(safe-area-inset-bottom)) + padding: 12px max(16px, env(safe-area-inset-right)) calc(12px + var(--safe-bottom)) max(16px, env(safe-area-inset-left)); background: var(--canvas); } @@ -1182,6 +1190,12 @@ html.scanning #app { user-select: text; } +/* Shifted by part of a row while a swipe scrolls it (main.ts `setFrac`): + kept on its own layer so that is a move, not a repaint. */ +.term .xterm-screen { + will-change: transform; +} + /* The field typing into the terminal goes through: focusable, so the keyboard and its input methods work, but never seen. 16px, or iOS zooms the page when it takes focus. */ @@ -1276,7 +1290,7 @@ html.scanning #app { display: flex; flex-direction: column; gap: 8px; - padding: 10px max(12px, env(safe-area-inset-right)) calc(10px + env(safe-area-inset-bottom)) + padding: 10px max(12px, env(safe-area-inset-right)) calc(10px + var(--safe-bottom)) max(12px, env(safe-area-inset-left)); background: var(--canvas); box-shadow: 0 -0.5px 0 var(--hair); @@ -1423,7 +1437,7 @@ html.scanning #app { z-index: 3; left: max(16px, env(safe-area-inset-left)); right: max(16px, env(safe-area-inset-right)); - bottom: calc(12px + env(safe-area-inset-bottom)); + bottom: calc(12px + var(--safe-bottom)); display: flex; align-items: center; gap: 10px; @@ -1521,7 +1535,7 @@ html.scanning #app { gap: 20px; width: 100%; max-height: calc(100% - env(safe-area-inset-top) - 24px); - padding: 10px 20px calc(20px + env(safe-area-inset-bottom)); + padding: 10px 20px calc(20px + var(--safe-bottom)); border-radius: 44px; background: var(--canvas); box-shadow: var(--shadow);