diff --git a/changelog.d/touch-scroll-by-finger-travel.md b/changelog.d/touch-scroll-by-finger-travel.md new file mode 100644 index 0000000000..7dcd47cba1 --- /dev/null +++ b/changelog.d/touch-scroll-by-finger-travel.md @@ -0,0 +1,4 @@ +### Fixed +- Dragging the terminal on a phone now scrolls by how far the finger travels: a quick swipe goes as + far as a slow drag over the same distance, one scroll step per three rows of travel, and a short + flick of at least half a step still scrolls once when the finger lifts. diff --git a/scripts/terminal-pinch-regression.ts b/scripts/terminal-pinch-regression.ts index 1a26ed7f71..9ca341fce2 100644 --- a/scripts/terminal-pinch-regression.ts +++ b/scripts/terminal-pinch-regression.ts @@ -23,13 +23,17 @@ export async function checkTerminalPinch(browser: Browser, origin: string, paneI // Keep the size saved by the gesture when this same page reloads. if (!localStorage.getItem("herdr-web-ui:settings")) localStorage.setItem("herdr-web-ui:settings", JSON.stringify({ language: "en" })); localStorage.setItem(`herdr-web-ui:view:${paneId}`, "terminal"); - const touchWindow = window as Window & { wheels_: number; remaining_: number[] }; + const touchWindow = window as Window & { wheels_: number; wheelModes_: number[]; remaining_: number[] }; touchWindow.wheels_ = 0; + touchWindow.wheelModes_ = []; touchWindow.remaining_ = []; document.addEventListener("touchend", (event) => { touchWindow.remaining_ = Array.from(event.touches, (touch) => touch.identifier); }, { capture: true }); - document.addEventListener("wheel", () => touchWindow.wheels_++, { capture: true }); + document.addEventListener("wheel", (event) => { + touchWindow.wheels_++; + touchWindow.wheelModes_.push(event.deltaMode); + }, { capture: true }); }, { paneId }); try { const page = await context.newPage(); @@ -59,6 +63,9 @@ export async function checkTerminalPinch(browser: Browser, origin: string, paneI transform: (document.querySelector(".pane-terminal .xterm") as HTMLElement).style.transform, computedTransform: getComputedStyle(document.querySelector(".pane-terminal .xterm")!).transform, wheels: (window as Window & { wheels_: number }).wheels_, + wheelModes: (window as Window & { wheelModes_: number[] }).wheelModes_.slice(), + cell: document.querySelector(".pane-terminal .xterm-screen")!.getBoundingClientRect().height + / document.querySelectorAll(".pane-terminal .xterm-rows > div").length, remaining: (window as Window & { remaining_: number[] }).remaining_, scale: visualViewport!.scale, focused: document.hasFocus(), @@ -83,18 +90,44 @@ export async function checkTerminalPinch(browser: Browser, origin: string, paneI } }; const release = () => cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] }); + // a wheel report per three rows of travel: 150 px is more than that even at the largest cell the 22 px font makes; + // the drag ends at cy, where a following pinch puts this finger, so that jump is no vertical travel const dragOne = async (): Promise => { - await cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x: cx, y: cy - 60, id: 1 }] }); + await cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x: cx, y: cy - 150, id: 1 }] }); for (let step = 1; step <= 5; step++) { - await cdp.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x: cx, y: cy - 60 + 60 * step / 5, id: 1 }] }); + await cdp.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x: cx, y: cy - 150 + 150 * step / 5, id: 1 }] }); } }; + /** one finger drags `distance` px down in `moves` touchmoves; resolves to the wheels it sent, each line mode (1) */ + const swipe = async (distance: number, moves: number): Promise => { + const before = await probe(); + await cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x: cx, y: cy - distance / 2, id: 1 }] }); + for (let step = 1; step <= moves; step++) { + await cdp.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x: cx, y: cy - distance / 2 + distance * step / moves, id: 1 }] }); + } + await release(); + const after = await probe(); + assert(after.wheelModes.slice(before.wheelModes.length).every((mode) => mode === 1), + `a ${moves}-move swipe sends line-mode wheels only`); + return after.wheels - before.wheels; + }; + const baseline = await probe(); assert.equal(baseline.fontPx, 13, "baseline font is 13 px"); assert(!(await badge.isVisible()), "badge is hidden before pinching"); + // a quick swipe scrolls as far as a slow drag over the same travel: one wheel report per three rows + const fast = await swipe(300, 2); + const slow = await swipe(300, 30); + const expected = 300 / (3 * baseline.cell); + assert(fast >= Math.floor(expected) - 1 && fast <= Math.ceil(expected), `a 300 px swipe sends about ${expected.toFixed(1)} wheels (sent ${fast})`); + assert.equal(fast, slow, "a 2-move swipe and a 30-move drag over 300 px send the same wheel count"); + // a flick shorter than one step still scrolls once it reaches half a step when the finger lifts + const step = 3 * baseline.cell; + assert.equal(await swipe(Math.round(step * 0.7), 4), 1, `a flick of 0.7 step (${Math.round(step * 0.7)} px) sends one wheel`); + assert.equal(await swipe(Math.round(step * 0.3), 4), 0, `a flick of 0.3 step (${Math.round(step * 0.3)} px) sends none`); const r0 = resizes; - const w0 = baseline.wheels; + const w0 = (await probe()).wheels; await spreadTo(100, 154); await until(async () => (await probe()).fontPx === 20, "pinch out renders 20 px before release"); await until(async () => (await probe()).rows < baseline.rows, "pinch out refits fewer local rows before release"); @@ -170,8 +203,10 @@ export async function checkTerminalPinch(browser: Browser, origin: string, paneI assert.equal((await probe()).fontPx, 20, "partial release keeps the live 20 px font"); assert.equal((await probe()).saved, 20, "partial release saves 20 px"); assert(!(await badge.isVisible()), "the first lifted finger hides the size badge"); + // three wheel steps of travel at the 20 px cell: a broken pinch-stroke guard would send wheels here + const leftover = 3 * 3 * (await probe()).cell; for (let step = 1; step <= 5; step++) { - await cdp.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x: cx - 77, y: cy + 60 * step / 5, id: 1 }] }); + await cdp.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x: cx - 77, y: cy + leftover * step / 5, id: 1 }] }); } assert.equal((await probe()).wheels, w1, "the finger remaining after a pinch does not scroll"); await release(); diff --git a/src/AGENTS.md b/src/AGENTS.md index f9268ace2b..5d1ea6b869 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -51,7 +51,7 @@ React 19 + xterm.js browser client. The render-state machine, xterm lifecycle an | Terminal view/lifecycle | components/PaneTerminal.tsx + TerminalViewportTools.tsx + lib/terminalViewport.ts | one xterm per pane mount; `term.reset()` + re-attach on paneId change; `active` gates every automatic focus (including async uploads and prompt answers), and inactive siblings never take focus; theme + font size applied through `term.options` (no remount) and re-fit; the grid re-fits when the lens returns to terminal; ended / reconnecting pills only under the terminal lens (ChatView says them inline); viewport tools draw only native ranges validated against xterm's current cells, never reconstructed history. Automatic `jump:false` searches require an explicit response carrying `matches` and `scroll`, and no read-only/legacy bridge gets background search POSTs. The separate scrollbar calls herdr's shared `pane.scroll`; xterm remains at zero history. | | Roles / observe mode | components/PaneTerminal.tsx | NO UI control — App connects as `interact` and never asks for another role; `role-ack` still applies the consequences if one arrives: stdin gate (`disableStdin`), `pane-geometry` adoption, force-refit + resize when returning to interact | | Held-input draft | components/PaneTerminal.tsx + lib/draft.ts | input during a disconnect accumulates in a draft (printable only); the review banner offers Send/Discard after reconnect | -| Geometry/fit | components/PaneTerminal.tsx | fit on attach, ResizeObserver on the host, `focus`/`visibilitychange` refit, and a click or key in the terminal while another device's grid holds the pty (`reclaim`: a window that kept its focus gets no focus event) — all SKIPPED while observing; only a tab in use (`inUse`: visible AND `document.hasFocus()`) resizes the pty from the observer, the refit, `role-ack`, a font load or the lens return; out of use, a pane attach (a reload, the next pane after one closed) goes with `keep_size`, and blur or hidden marks `keepSize` so a background reconnect adopts the grid: a window left behind another app must not take the pane from a phone or herdr's own TUI; with `settings.releasePaneAway` on (off by default), after `RELEASE_AFTER_MS` out of use (from a blur, hidden, or a pane attach made out of use) it also detaches (`released`), and a touch screen (`coarse`) that goes hidden does so at once whatever the setting, since a phone's page can freeze before the timer runs, because herdr holds a pane at an attach's size until that attach ends and its TUI cannot take it back, and watches the pane instead (`socket.watch`, feature "watch": a read-only view at this grid that leaves the pane's size alone, written as it comes; a grid change starts it again; banner "View only…"); a server without the feature, or a `watch-end`, leaves it paused on its last screen. It attaches again on focus or a pointerdown (unwatch first); a released tab moving to another pane watches that one; it keeps the pane while a message it queued waits (`queueWaits`), and never releases a mirrored pane or an embedded page (the site's demo). `HerdrSocket.resize` sends nothing for a pane it is not attached to: the server would resize the holder's attach; single-finger touch drag becomes synthetic wheel events (listeners `passive: false` only there) | +| Geometry/fit | components/PaneTerminal.tsx | fit on attach, ResizeObserver on the host, `focus`/`visibilitychange` refit, and a click or key in the terminal while another device's grid holds the pty (`reclaim`: a window that kept its focus gets no focus event) — all SKIPPED while observing; only a tab in use (`inUse`: visible AND `document.hasFocus()`) resizes the pty from the observer, the refit, `role-ack`, a font load or the lens return; out of use, a pane attach (a reload, the next pane after one closed) goes with `keep_size`, and blur or hidden marks `keepSize` so a background reconnect adopts the grid: a window left behind another app must not take the pane from a phone or herdr's own TUI; with `settings.releasePaneAway` on (off by default), after `RELEASE_AFTER_MS` out of use (from a blur, hidden, or a pane attach made out of use) it also detaches (`released`), and a touch screen (`coarse`) that goes hidden does so at once whatever the setting, since a phone's page can freeze before the timer runs, because herdr holds a pane at an attach's size until that attach ends and its TUI cannot take it back, and watches the pane instead (`socket.watch`, feature "watch": a read-only view at this grid that leaves the pane's size alone, written as it comes; a grid change starts it again; banner "View only…"); a server without the feature, or a `watch-end`, leaves it paused on its last screen. It attaches again on focus or a pointerdown (unwatch first); a released tab moving to another pane watches that one; it keeps the pane while a message it queued waits (`queueWaits`), and never releases a mirrored pane or an embedded page (the site's demo). `HerdrSocket.resize` sends nothing for a pane it is not attached to: the server would resize the holder's attach; single-finger touch drag becomes synthetic line-mode wheel events, one per `HERDR_MOUSE_SCROLL_LINES` rows of finger travel gathered over the gesture, plus one at touchend when at least half a step is left (`finalStep`) (lib/wheelSteps.ts, shared with a selecting drag's wheel; xterm sends one report per wheel event whatever its delta) (listeners `passive: false` only there) | | Key bar | components/KeyBar.tsx + KeyBarSettings.tsx + lib/keyBar.ts + lib/keys.ts | `settings.keyBarItems` owns the complete ordered row; absent legacy records migrate from `keyBarExtras`, explicit empty lists stay empty. Ordinary keys inherit held modifiers; configured combinations are exact. Taps go through `term.input()` into the guarded onData path. PaneTerminal owns sticky Ctrl/Alt/Shift, clearing state on pane/lens change, disconnect or modifier removal; hidden while observing | | Chat composer | components/Composer.tsx + lib/compose.ts + lib/mentions.ts + lib/modelName.ts + PendingMessages.tsx | the model pill shows a name only for an id `modelLabel` matches whole, else the id as received in mono: never add a guessed name; Enter or Mod+Enter sends per `settings.enterSends`, IME Enter never sends; `/` commands cache 60s per pane, `@` files debounce 150ms; one Send/Stop control for every agent, no Queue button/menu; working Send uses `socket.submit` queue delivery only with `pending-input`, otherwise keeps the draft; pending rows offer an explicit Send now action on server IDs, retained uncertain text never retries; Stop is Escape via `onAbort`, also from the box's own Escape while a draft shows Send in its place | | Chat lens | components/ChatView.tsx + lib/conversationRefresh.ts + Markdown.tsx (lib/markdown.ts) + lib/workBlocks.ts + lib/toolVerbs.ts + PromptCard.tsx | `splitTurn` folds tools/thinking/narration into one `.work-block` and honors explicit Codex commentary/final phases; a row is verb + object by exact tool id (`toolVerb`), the id kept in its title and opened detail; duration = `end_ts − ts`; only mounted visible Chat subscribes to `conversation-watch` on its attached machine-bound socket; `conversation-changed` uses a serial coordinator with push reads at least 2s apart, a visible-only 10s backstop (2s while `conversationPushes` is false: an older bridge or a released pane) and immediate initial/send/visibility/reconnect refresh; a visible scrollback stand-in is invalidated by the pane's `pty-data` through a ref listener (`onTerminalOutput`), never by React state per frame; newest and older pages share the coordinator; a send refresh preserves history/scroll, a pane change resets; ChatView polls `/api/pane/prompt` every 2s whatever the pane's status (a menu can wait while herdr reports idle: Codex's trust check, pi's dialogs, Claude Code's `/model` list) and owns the prompt; PromptCard answers through `/api/pane/prompt/answer` (409 → re-read) and is drawn through a portal into PaneTerminal's `.prompt-dock`, over the input card and outside the transcript | diff --git a/src/components/PaneTerminal.tsx b/src/components/PaneTerminal.tsx index b0815d4956..f7f33ffeb9 100644 --- a/src/components/PaneTerminal.tsx +++ b/src/components/PaneTerminal.tsx @@ -57,6 +57,7 @@ import { useMediaQuery } from "../lib/useMediaQuery.ts"; import { modalOpen } from "../lib/focus.ts"; import { pinchFontSize } from "../lib/pinchFontSize.ts"; import { narrowPaneFontSize } from "../lib/narrowPaneFont.ts"; +import { finalStep, HERDR_MOUSE_SCROLL_LINES, wheelSteps } from "../lib/wheelSteps.ts"; /** How long a resize must rest before the grid refits and the pty follows it. */ const RESIZE_SETTLE_MS = 120; @@ -776,6 +777,9 @@ export function PaneTerminal({ sendScroll(d); }); }; + /** one row's height on screen, in CSS pixels (a selecting drag's wheel and a finger's travel) */ + const cellHeight = (): number => + (term.element?.querySelector(".xterm-screen")?.getBoundingClientRect().height ?? term.rows) / term.rows; /** positive lines show older text */ const scrollDrag = (d: Drag, lines: number): void => { if (d.top === null) return; @@ -790,11 +794,11 @@ export function PaneTerminal({ const dragWheel = (event: WheelEvent): void => { const d = drag; if (!d || d.top === null) return; - const lineHeight = (term.element?.querySelector(".xterm-screen")?.getBoundingClientRect().height ?? term.rows) / term.rows; - d.wheelPixels += event.deltaMode === WheelEvent.DOM_DELTA_LINE ? event.deltaY * lineHeight : event.deltaY; - const lines = Math.trunc(d.wheelPixels / lineHeight); + const lineHeight = cellHeight(); + const { steps: lines, rest } = wheelSteps( + d.wheelPixels + (event.deltaMode === WheelEvent.DOM_DELTA_LINE ? event.deltaY * lineHeight : event.deltaY), lineHeight); + d.wheelPixels = rest; if (lines === 0) return; - d.wheelPixels -= lines * lineHeight; scrollDrag(d, -lines); }; const stopEdge = (): void => { @@ -1345,6 +1349,8 @@ export function PaneTerminal({ // Two fingers pinch the font size: the font follows them locally, and the shared pty resizes once when one lifts. let touchX = 0; let touchY = 0; + /** finger travel of this gesture not yet sent as a wheel report */ + let touchPixels = 0; let tracking = false; let pinch: { first: number; second: number; startSize: number; startDistance: number; size: number } | null = null; // from a second finger until every finger lifts: the stroke is a pinch, never a scroll @@ -1413,6 +1419,7 @@ export function PaneTerminal({ return; } tracking = event.touches.length === 1; + touchPixels = 0; const first = event.touches[0]; if (tracking && first) { touchX = first.clientX; @@ -1459,12 +1466,30 @@ export function PaneTerminal({ host.scrollBy(across, delta); return; } - if (delta !== 0) { - const target = term.element ?? host; - target.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: delta, clientX: first.clientX, clientY: first.clientY })); + // xterm sends herdr one report per wheel event however far it went, and herdr scrolls a + // few lines per report: one line-mode wheel per that many rows of finger travel keeps a + // swipe as far as a slow drag, and the text under the finger + const { steps, rest } = wheelSteps(touchPixels + delta, cellHeight() * HERDR_MOUSE_SCROLL_LINES); + touchPixels = rest; + sendTouchWheels(steps); + }; + /** `steps` line-mode wheel reports at the finger, each one herdr scroll step in the sign's direction */ + const sendTouchWheels = (steps: number): void => { + const target = term.element ?? host; + for (let sent = 0; sent < Math.abs(steps); sent += 1) { + target.dispatchEvent(new WheelEvent("wheel", { + bubbles: true, cancelable: true, deltaY: Math.sign(steps), deltaMode: WheelEvent.DOM_DELTA_LINE, + clientX: touchX, clientY: touchY, + })); } }; const onTouchEnd = (event: TouchEvent): void => { + // a lifted one-finger drag sends what is left once it reaches half a step: a flick shorter + // than one step still scrolls, and a long drag loses at most half a step at its end + if (tracking && event.type === "touchend" && !pinchStroke && !adopted()) { + sendTouchWheels(finalStep(touchPixels, cellHeight() * HERDR_MOUSE_SCROLL_LINES)); + } + touchPixels = 0; tracking = false; if (!pinchStroke) return; claimPinch(event); diff --git a/src/lib/wheelSteps.test.ts b/src/lib/wheelSteps.test.ts new file mode 100644 index 0000000000..f577f8da95 --- /dev/null +++ b/src/lib/wheelSteps.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "bun:test"; +import { finalStep, HERDR_MOUSE_SCROLL_LINES, wheelSteps } from "./wheelSteps.ts"; + +/** wheel reports a gesture of these moves sends, as the touch handler gathers them */ +function reports(moves: number[], cellHeight: number): number { + let pixels = 0; + let sent = 0; + for (const move of moves) { + const { steps, rest } = wheelSteps(pixels + move, cellHeight * HERDR_MOUSE_SCROLL_LINES); + pixels = rest; + sent += steps; + } + return sent; +} + +describe("wheelSteps", () => { + test("a fast swipe sends as many reports as a slow drag over the same distance", () => { + const fast = reports(Array(5).fill(60), 20); + const slow = reports(Array(30).fill(10), 20); + expect(fast).toBe(slow); + expect(fast).toBe(5); + }); + + test("the report count follows finger travel, not the number of moves", () => { + expect(reports([300], 20)).toBe(5); + expect(reports(Array(300).fill(1), 20)).toBe(5); + }); + + test("steps keep the direction and the rest carries to the next move", () => { + expect(wheelSteps(130, 60)).toEqual({ steps: 2, rest: 10 }); + expect(wheelSteps(-130, 60)).toEqual({ steps: -2, rest: -10 }); + expect(wheelSteps(59, 60)).toEqual({ steps: 0, rest: 59 }); + }); + + test("a reversal pays back the gathered travel before scrolling the other way", () => { + expect(reports([50, -50, -50], 20)).toBe(0); + expect(reports([50, -50, -130], 20)).toBe(-2); + }); + + test("an unlaid-out terminal makes no steps", () => { + expect(wheelSteps(500, 0)).toEqual({ steps: 0, rest: 0 }); + expect(wheelSteps(500, Number.NaN)).toEqual({ steps: 0, rest: 0 }); + }); +}); + +describe("finalStep", () => { + test("half a step or more left at the lift sends one more report in its direction", () => { + expect(finalStep(36, 60)).toBe(1); + expect(finalStep(30, 60)).toBe(1); + expect(finalStep(-36, 60)).toBe(-1); + }); + + test("less than half a step left sends nothing", () => { + expect(finalStep(24, 60)).toBe(0); + expect(finalStep(-24, 60)).toBe(0); + expect(finalStep(0, 60)).toBe(0); + }); + + test("an unlaid-out terminal makes no step", () => { + expect(finalStep(36, 0)).toBe(0); + expect(finalStep(Number.NaN, 60)).toBe(0); + }); +}); diff --git a/src/lib/wheelSteps.ts b/src/lib/wheelSteps.ts new file mode 100644 index 0000000000..26243be6db --- /dev/null +++ b/src/lib/wheelSteps.ts @@ -0,0 +1,26 @@ +/** + * Lines herdr scrolls a pane per wheel report: its `ui.mouse_scroll_lines` default. The browser + * cannot read the PC's herdr config, so a changed value only changes how far one report goes. + */ +export const HERDR_MOUSE_SCROLL_LINES = 3; + +/** + * Whole steps of `stepPixels` in the pixels gathered so far, and the pixels left for the next + * move. Steps keep the sign of the travel; the rest keeps it too, so a reversal first pays back + * what was gathered the other way. No step size (an unlaid-out terminal) makes no steps. + */ +export function wheelSteps(pixels: number, stepPixels: number): { steps: number; rest: number } { + if (!(stepPixels > 0) || !Number.isFinite(pixels)) return { steps: 0, rest: 0 }; + const steps = Math.trunc(pixels / stepPixels); + return { steps, rest: pixels - steps * stepPixels }; +} + +/** + * The last step a gesture sends when the finger lifts: one more in the travel's direction when + * what is left reaches half a step, so a short flick still scrolls and a long drag loses no more + * than half a step at its end. No step size makes no step. + */ +export function finalStep(rest: number, stepPixels: number): -1 | 0 | 1 { + if (!(stepPixels > 0) || !Number.isFinite(rest)) return 0; + return Math.abs(rest) >= stepPixels / 2 ? (Math.sign(rest) as -1 | 1) : 0; +}