From 2c604ba62eca3ff9821763e1d2a3515f07767e38 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:44:31 +0800 Subject: [PATCH] fix(mobile): room for the keyboard, and smoother vertical scrolling - The keyboard: the WebView runs edge to edge and is not resized for it, so it covered the dock and the pane's last lines. The app now takes the size of the visual viewport, drops the home indicator's gap while the keyboard is up, and keeps the cursor's line in sight. - Scrolling: the terminal draws with xterm's WebGL renderer, which a scroll does not make lay every row out again. A swipe is applied once a frame, and moves the view by pixels: xterm scrolls whole rows, and the rest of a row is a GPU shift of the drawn screen, put on together with the rows it goes with. Claude-Session: https://claude.ai/code/session_01HVosmYyVSH3BrsLYx3tcSS --- mobile/package-lock.json | 7 ++ mobile/package.json | 1 + mobile/src/main.ts | 151 +++++++++++++++++++++++++++++++++++---- mobile/src/style.css | 28 ++++++-- 4 files changed, 167 insertions(+), 20 deletions(-) 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);