From 3d389c1935d4ff0b9fb96838aff971ed5f7dbb3b Mon Sep 17 00:00:00 2001 From: jesse23 <643912+jesse23@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:46:39 -0400 Subject: [PATCH] fix(client): scroll ticks wait for the app's answer, and a slow app gets at most 12 a go Ticks sent to an app go out once its output has answered the last ones (leaving no synchronized update open), the ticks due meanwhile together, so an app that redraws slowly (Claude Code fullscreen inside herdr) no longer falls behind and jumps. While the app's answers average over 40 ms, a go sends at most 12 ticks and drops the rest past one more go, so a fast TrackPoint push moves evenly. ADR 069. --- docs/adrs/045.client.smooth-mouse-wheel.md | 2 +- ...69.client.scroll-ticks-wait-for-the-app.md | 48 +++++++ docs/specs/client.md | 1 + src/client/vendor/ghostty-web/PROVENANCE.md | 1 + .../vendor/ghostty-web/core/sync-hold.ts | 5 + .../ghostty-web/core/terminal-controllers.ts | 7 +- .../ghostty-web/core/wheel-controller.ts | 7 +- src/client/vendor/ghostty-web/wheel.ts | 85 ++++++++++- .../tests/core/terminal-controllers.test.ts | 12 ++ .../tests/core/wheel-controller.test.ts | 6 +- src/client/vendor/tests/input/wheel.test.ts | 132 +++++++++++++++++- 11 files changed, 294 insertions(+), 12 deletions(-) create mode 100644 docs/adrs/069.client.scroll-ticks-wait-for-the-app.md diff --git a/docs/adrs/045.client.smooth-mouse-wheel.md b/docs/adrs/045.client.smooth-mouse-wheel.md index d9dcb72..5c76321 100644 --- a/docs/adrs/045.client.smooth-mouse-wheel.md +++ b/docs/adrs/045.client.smooth-mouse-wheel.md @@ -35,7 +35,7 @@ In detail: - **The glide ignores its own line moves.** Rounding the offset to device pixels can put the viewport on the next line while the position is just below it; ghostty-web's `onScroll` then reported a line other than `floor(position)`, and the glide took it for an outside move and stopped, losing the rest of its distance (a 100 px notch stopped at 75 px in the end-to-end test). - The glide moves the same fractional position as the trackpad, so it renders through the overscan canvas (ADR 043) and only copies rows when it crosses lines. - A trackpad delta finishes the glide at its target and applies there. Output snapping to the bottom, keyboard scrolling, a scrollbar drag or a resize stops the glide where ghostty-web put the viewport. -- **Pace the ticks sent to apps.** Mouse-tracking apps and the alternate screen still get whole-line ticks (SGR scroll buttons, ADR 040, or arrow keys), which they scroll themselves, and the tick counts are unchanged. But they no longer go out in one burst, as the scrollback glides: `TickPacer` sends a wheel notch's ticks evenly over 125 ms and a sparse delta's over its gap plus a frame, or follows the same spring while the stream is fast (ticks per frame of a hard push went from 4–13 to 9–10), a few per frame, starting from its latest frame like the glide. The ticks go out evenly whatever the pace's easing, as an app moves in whole lines, and the count is rounded up, so the first tick goes out on the next frame and a one-tick notch is not held back. `WheelStream.forApp` makes the call with the same classifier; trackpad deltas, which already come every frame, still send their ticks at once, and a zero-spread event flushes any queued ticks first. A direction change drops the queued ticks. +- **Pace the ticks sent to apps.** Mouse-tracking apps and the alternate screen still get whole-line ticks (SGR scroll buttons, ADR 040, or arrow keys), which they scroll themselves, and the tick counts are unchanged. But they no longer go out in one burst, as the scrollback glides: `TickPacer` sends a wheel notch's ticks evenly over 125 ms and a sparse delta's over its gap plus a frame, or follows the same spring while the stream is fast (ticks per frame of a hard push went from 4–13 to 9–10), a few per frame, starting from its latest frame like the glide. The ticks go out evenly whatever the pace's easing, as an app moves in whole lines, and the count is rounded up, so the first tick goes out on the next frame and a one-tick notch is not held back. `WheelStream.forApp` makes the call with the same classifier; trackpad deltas, which already come every frame, still send their ticks at once, and a zero-spread event flushes any queued ticks first. A direction change drops the queued ticks. (Update: [ADR 069](069.client.scroll-ticks-wait-for-the-app.md) holds the next ticks until the app answers and sends those due meanwhile together, at most 12 a go to an app slower than 40 ms.) - **Count arrow keys by distance.** On the alternate screen without mouse tracking (`less`, `man`), the vendored terminal sent `round(deltaY / 33)` arrow keys per event, at most 5, with no remainder kept: slow trackpad deltas and Firefox's 3-line notches sent nothing, and a hard TrackPoint push was capped at 5 lines. `wheelArrows` now sends one per line of travel (pixel deltas by cell height, line deltas by line, page deltas by rows), keeping the remainder like `wheelTicks`. With application cursor keys (DECCKM), which `less` sets, they are `ESC O A`/`ESC O B`; `less` ignored `ESC [ A`/`ESC [ B`, so the wheel did nothing in it. - **Respect reduced motion.** With `prefers-reduced-motion: reduce`, wheel and sparse deltas scroll the scrollback at once instead of gliding, and a wheel notch's ticks go to apps at once. A sparse stream's ticks stay paced: pacing times input, it does not animate, and sending them at once brought back bursts of 20 ticks every other frame. diff --git a/docs/adrs/069.client.scroll-ticks-wait-for-the-app.md b/docs/adrs/069.client.scroll-ticks-wait-for-the-app.md new file mode 100644 index 0000000..1550ccc --- /dev/null +++ b/docs/adrs/069.client.scroll-ticks-wait-for-the-app.md @@ -0,0 +1,48 @@ +# ADR 069: client — Scroll ticks wait for the app's answer + +**SPEC:** [client](../specs/client.md) +**Status:** Accepted +**Date:** 2026-10-15 + +--- + +## Context + +Scrolling Claude Code's fullscreen mode inside herdr lagged and then jumped a long way, while Claude Code on its own, pi on its own and pi inside herdr scrolled smoothly. With a TrackPoint, Claude Code inside herdr moved continuously but in uneven jumps. 1.15 lagged the same way. Measured on a real herdr session (several Claude Code panes, a 204 × 109 pane at Retina scale): + +- **webtty was idle.** A Chrome performance trace of the lag showed the page's main thread busy 5% of the time, one frame over 16 ms in 12 s, and stretches of 2.5 s in which 43 scroll ticks went out and no output came back. +- **The server passed everything on at once.** A trace of the server's input and output (1,186 ticks in about 25 s) found output flow control ([ADR 048](048.webtty.output-flow-control.md)) never blocked, at most 78 KB unacknowledged against its 2 MB limit, and every PTY chunk sent as it came. From a tick reaching the PTY to herdr's next output: 104 ms at the median, 1.0 s at the 90th percentile, 3.7 s at worst. +- **herdr redraws its whole screen for every tick.** Each tick came back as one synchronized update (DEC mode 2026) of about 35 KB, starting 30–45 ms after the tick and arriving in about 35 chunks over 8 ms, however little of the screen changed. Claude Code renders its pane, then herdr its screen. +- **Paced ticks doubled the work.** [ADR 045](045.client.smooth-mouse-wheel.md) spreads an event's ticks over frames, about one a frame, so each reached herdr and Claude Code alone and each was redrawn. Replaying a fast scroll (about 45 ticks a second) on that session at its size, paced ticks brought back 967–1,182 KB of redraws against 490–568 KB for the same ticks sent at once. Where the app needs 100 ms or more a redraw, ticks a frame apart queue up in it, and when it catches up it applies them together: the jump. +- **A TrackPoint is far faster than its old capture.** A capture on the machine in question: light pushes of 4 px every 130–350 ms, hard ones of 300–370 px every 15–60 ms, the hardest 1,150 px every 30 ms, about 2,700 lines a second. Inside herdr, Claude Code shows about a dozen updates a second, so a hard push has to move 24 lines an update. + +No terminal setting changes herdr's frame time; herdr has none. + +## Decision + +- **Ticks wait for the app's answer.** `TickPacer` (`wheel.ts`) remembers when ticks last went out. Until the app answers, it sends no more: the ticks that come due meanwhile, from the pace's glide or spring or at once, are sent together when it does. An app that answers within a frame (pi, vim, Claude Code on its own) gets its ticks as before. +- **The answer is output that leaves no synchronized update open.** The write pipeline's `wroteOutput` tells the wheel controller (`WheelController.answered`) after each write, unless the engine still has mode 2026 on (`SyncHold.inUpdate`). herdr's frame is a single update in 35 chunks, and the next ticks wait for its end rather than its first chunk. Output for other reasons (another pane, a spinner) also counts, which at worst lets ticks go a little early, as before this change. +- **An app that does not answer gets the next ticks after `ANSWER_WAIT_MS` (100 ms)**, so an app that redraws nothing for a tick (at its top already) is not held. +- **A slow app gets at most `SLOW_GO_TICKS` (12) ticks a go.** The pacer keeps a running average of the time the app takes to answer (at most `ANSWER_WAIT_MS` a sample). While it is over `SLOW_ANSWER_MS` (40 ms), a go sends 12 ticks at most, keeps one more go queued and drops the rest, so updates move evenly and the scroll stops when the push does. A fast app is never capped. +- The arrow keys sent to an alternate-screen app without mouse tracking ([ADR 045](045.client.smooth-mouse-wheel.md)) go through the same pacer and wait the same way. + +## Considered Options + +- **Send ticks at once, as 1.15 did.** Rejected: it lagged the same way, and pi and vim lose ADR 045's even pace. +- **Answer on the first output after ticks.** The first version. Equal in every measure here, but it let ticks reach herdr in the middle of its frame; the end of the update is when the app has finished. +- **Throttle on the server (hold PTY input until output).** Rejected: the server cannot tell scroll ticks from typing, and the client already knows its pace. +- **Even out the go sizes without a cap.** Rejected: a hard push inside herdr still moved about 24 lines an update, only more steadily. +- **Cap every app.** Rejected: replaying the TrackPoint capture with an app answering in 10–25 ms, a 12-tick cap cut a hard push to 59% of its distance where nothing was wrong. +- **Slow the TrackPoint down everywhere.** Rejected: it scrolls well in pi and Claude Code on their own. + +## Consequences + +- On the herdr session above at its size, replaying the fast scroll: 31–42 ms from tick to output at the 90th percentile and 45–48 ms at worst, against 50–125 ms and 67–212 ms with paced ticks, and 48–79 ms and 49–116 ms for 1.15. +- Replaying the TrackPoint capture through the wheel code against an app answering in 35–100 ms (Claude Code inside herdr): every update during the hard push moves 12 lines, against a median of 24 and up to 190 before, and the push scrolls 28% as far. Against an app answering in 10–25 ms, nothing changes: the same 125 updates and 1,388 lines. A touchpad capture (1–5 px every 16 ms) never reaches the cap and is unchanged. +- Claude Code inside herdr still follows a scroll at herdr's pace, about one update every 30–45 ms here, slower than on its own: herdr redraws its whole screen for each tick, which only herdr can change. +- An app that answers slowly for other reasons, such as one over a slow link, is capped too while its average stays over 40 ms. +- Scroll benchmark ([SPEC](../specs/scroll-benchmark.md)), 2026-10-15, headless Chrome, the build before this change (before) against this one (after), medians of two runs with a trackpad, a wheel and a TrackPoint, plus a touchpad capture (1–5 px every 16 ms) on the fullscreen and herdr scenarios: + - **Unchanged:** pi fullscreen and classic, Claude Code classic, vim, nvim, yazi, the shell (`seq`), the stream test and pi inside herdr, with every input; the touchpad capture everywhere; no blinks. + - **Claude Code inside herdr:** with a wheel, 28.5 → 35.9 paints a second and 50 → 33 ms between paints at the 90th percentile; with a TrackPoint, 6.2 → 9.7 and 375 → 83 ms. Headless herdr answered within 40 ms, so the cap did not apply. + - **Claude Code on its own and `less`, with a wheel:** 46 → 36 and 40 → 35 paints a second. Ticks the app has not answered yet go in pairs, so it redraws less often for the same distance (Claude Code: 25 → 33 ms at the 90th percentile, 73 → 65 px a paint). + - **Claude Code on its own with a TrackPoint:** 158 → 67 ms between paints at the 90th percentile; one blink in one of two runs (0 before), as in [ADR 068](068.scroll-benchmark.app-modes-and-scroll-behaviour.md)'s runs. diff --git a/docs/specs/client.md b/docs/specs/client.md index a782c39..8d6b554 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -383,6 +383,7 @@ When a session ends (shell exits → WS close code `4001`) or the server stops ( | Scroll copies rows | A whole-line scroll copies the rows still in view with one canvas self-copy and draws only the rows that came into view; a frame with nothing changed, including one parked in scrollback, draws nothing; every partial frame equals a full redraw | [ADR 043](../adrs/043.client.scroll-copy-and-overscan.md) | ✅ | | Smooth scroll via overscan | Pixel-smooth scrollback draws 16 extra rows above and below the viewport (from the scrollback provider), when they come into view, and translates the canvas across them before copying rows, replacing ADR 040's strip canvas | [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 043](../adrs/043.client.scroll-copy-and-overscan.md) | ✅ | | Smooth mouse wheel | A mouse wheel notch glides to its target over 125 ms (ease-out cubic), and notches during a glide add up; a sparse stream of pixel deltas (a TrackPoint) glides each delta linearly over the time it took to arrive, or follows a spring while the stream is fast, and in apps the scroll ticks of wheel notches and sparse deltas are paced over the same times instead of sent in a burst; a trackpad still scrolls immediately. VS Code's classifier tells the two apart by their recent deltas | [ADR 045](../adrs/045.client.smooth-mouse-wheel.md) | ✅ | +| Scroll ticks wait for the app | Scroll ticks and arrow keys sent to an app go out once the app has answered the last ones with output that leaves no synchronized update open (or after 100 ms without an answer); the ticks due meanwhile go together, so an app that redraws slowly (Claude Code's fullscreen mode inside herdr) redraws once for them instead of falling behind and jumping. While the app's answers take over 40 ms on average, a go sends at most 12 ticks and the rest past one more go are dropped | [ADR 069](../adrs/069.client.scroll-ticks-wait-for-the-app.md) | ✅ | | ~~Glyph cache~~ | ~~Full repaints copy glyphs from an atlas canvas instead of calling `fillText` per cell~~: in Chrome it took about twice the script and GPU time of `fillText`, and `measureText` ink boxes were not exact enough | [ADR 044](../adrs/044.client.glyph-cache.md) | ❌ rejected | | Mouse scroll | When the PTY app enables mouse tracking (e.g. vim `set mouse=a`), the terminal forwards wheel events as mouse buttons 4/5 at the pointer's cell, encoded by Ghostty in the app's format (`\x1b[<64/65;col;rowM` under SGR), instead of arrow keys, so apps scroll their buffer rather than move the cursor | [ADR 017](../adrs/017.client.mouse-scroll.md), [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 049](../adrs/049.client.official-mouse-cursor-paste.md) | ✅ | | Keyboard bindings | Capture-phase `keydown` handler intercepts configured `key`+`mods` combos and sends `chars` to PTY; defaults to `[]` (no built-in bindings) | [ADR 018](../adrs/018.key-bindings.config-support.md) | ✅ | diff --git a/src/client/vendor/ghostty-web/PROVENANCE.md b/src/client/vendor/ghostty-web/PROVENANCE.md index 16e0b01..58abfd4 100644 --- a/src/client/vendor/ghostty-web/PROVENANCE.md +++ b/src/client/vendor/ghostty-web/PROVENANCE.md @@ -68,6 +68,7 @@ webtty edits to these files are tracked in git from the commit that added them; - `core/render-loop.ts`, `core/write-pipeline.ts`, `core/terminal-controllers.ts`, `core/terminal-emitters.ts`, `terminal.ts`, `engine/engine-types.ts`: a frame inside the app's synchronized update (DEC mode 2026) skips its render, and so does the echo render after input; the write pipeline hands each write's output to `SyncHold`, whose policy is Ghostty's: every `CSI ? 2026 h` restarts a 1 s timer, and when it runs out the mode is turned off. `Terminal.renderHeld(now)` tells the embedder's own painting (the kitty overlay) to hold too, and `Terminal.reset()` hands the controllers' engine state to `TerminalControllers.engineReplaced`; after a held frame the write pipeline feeds output up to where an update ends, paints and feeds the rest, and `Terminal.onUpdatePainted` (a new `TerminalEmitters.updatePainted`) tells the embedder; `syncHoldHost` is exported with the other host builders, and `GhosttyTerminal.setMode` sets a mode as the app would (ADR 067). - `core/terminal-controllers.ts`: the builders of the controllers' hosts (`focusHost`, `findHost`, `writeHost`, `renderLoopHost`, `scrollbarHost`, `mouseHost`, `wheelHost`) are exported, so a test pins where each member forwards (ADR 064). - `render/cell-painter.ts`: a line's text pass sets the font and the fill colour only when they change from the cell before, from four cached font strings, and a blank cell (a space or no codepoint, with no underline, strikethrough or link line) draws no text. Upstream built and set the font and set the colour for every cell and drew each space with `fillText`. Pixel-identical; under herdr's full-screen redraws, rows took 58% less time to paint in headless Chrome. The test canvas logs a painted row at its own clear, since a blank row draws no text. +- `wheel.ts`, `core/wheel-controller.ts`, `core/terminal-controllers.ts`, `core/sync-hold.ts`: scroll ticks wait for the app (ADR 069). `TickPacer` sends no more ticks until `answered()` (or `ANSWER_WAIT_MS`), sending those due meanwhile together, and to an app whose answers average over `SLOW_ANSWER_MS` at most `SLOW_GO_TICKS` a go, dropping the rest past one more go. The write host's `wroteOutput` calls `WheelController.answered` unless `SyncHold.inUpdate()`. ## webtty-original files diff --git a/src/client/vendor/ghostty-web/core/sync-hold.ts b/src/client/vendor/ghostty-web/core/sync-hold.ts index 12dbeaf..b7d0c2e 100644 --- a/src/client/vendor/ghostty-web/core/sync-hold.ts +++ b/src/client/vendor/ghostty-web/core/sync-hold.ts @@ -74,6 +74,11 @@ export class SyncHold { return this.host.active() && (this.since === null || now - this.since < SYNC_HOLD_MAX_MS); } + /** The app's synchronized update is still open: the output written so far began one that has not ended. */ + inUpdate(): boolean { + return this.host.active(); + } + /** The render loop held a frame's paint. */ frameHeld(): void { this.waiting = true; diff --git a/src/client/vendor/ghostty-web/core/terminal-controllers.ts b/src/client/vendor/ghostty-web/core/terminal-controllers.ts index 2e0d666..8a58b38 100644 --- a/src/client/vendor/ghostty-web/core/terminal-controllers.ts +++ b/src/client/vendor/ghostty-web/core/terminal-controllers.ts @@ -235,7 +235,12 @@ export function writeHost(term: ControllersHost, inner: Inner, core: TerminalCon paint(inner, core); inner.emit.updatePainted.fire(); }, - wroteOutput: (data) => core.sync.wrote(data), + wroteOutput: (data) => { + core.sync.wrote(data); + // The app's answer to the scroll ticks it was sent: its output, once a synchronized + // update in it has ended, so the next ticks do not reach it mid-frame (ADR 069) + if (!core.sync.inUpdate()) core.wheel.answered(); + }, // Not inside a synchronized update: the frame after it ends paints (ADR 067) renderEcho: () => { if (!core.sync.held()) paint(inner, core); diff --git a/src/client/vendor/ghostty-web/core/wheel-controller.ts b/src/client/vendor/ghostty-web/core/wheel-controller.ts index 41da14a..e09d4a1 100644 --- a/src/client/vendor/ghostty-web/core/wheel-controller.ts +++ b/src/client/vendor/ghostty-web/core/wheel-controller.ts @@ -32,7 +32,7 @@ export class WheelController implements Disposable { /** `pacer` sends the ticks and arrows over frames; by default to `host.send`. */ constructor( private readonly host: WheelHost, - private readonly pacer: Pick = new TickPacer((data) => + private readonly pacer: Pick = new TickPacer((data) => host.send(data), ), ) {} @@ -58,6 +58,11 @@ export class WheelController implements Disposable { ); } + /** The app's output arrived: ticks held for its answer may go (ADR 069). */ + answered(): void { + this.pacer.answered(); + } + /** Removes the listener and drops the queued ticks. Calling it again does nothing. */ dispose(): void { this.pacer.cancel(); diff --git a/src/client/vendor/ghostty-web/wheel.ts b/src/client/vendor/ghostty-web/wheel.ts index 28d805a..cb48d08 100644 --- a/src/client/vendor/ghostty-web/wheel.ts +++ b/src/client/vendor/ghostty-web/wheel.ts @@ -83,7 +83,38 @@ export function wheelArrowKey(dir: number, applicationCursor: boolean): string { * so the first tick goes out on the next frame and a 1-tick notch is not held * back. A direction change drops the queued ticks, as `wheelTicks` drops its * remainder. + * + * Ticks that went out wait for the app to answer, with output that leaves no synchronized + * update open (the terminal calls {@link TickPacer.answered}), before the next go: + * the ticks due meanwhile are sent together. An app that redraws its whole screen per + * tick (Claude Code's fullscreen mode, through herdr) took 100 ms or more a redraw on a + * large screen, so ticks sent a frame apart queued up in it and it jumped when it caught + * up; given together, it redraws once for them. An app that answers within a frame gets + * them as before, and one that does not answer at all every {@link ANSWER_WAIT_MS}. A + * slow app (answers over {@link SLOW_ANSWER_MS} on average) gets at most + * {@link SLOW_GO_TICKS} ticks a go. (webtty, ADR 069) */ +/** + * How long ticks that went out wait for the app's answer before the next go anyway, in ms: + * an app may not redraw for a tick (already at its top). (webtty, ADR 069) + */ +export const ANSWER_WAIT_MS = 100; + +/** + * An app whose answers take this long on average is slow, in ms: Claude Code's fullscreen + * mode through herdr took 35-100 ms; pi, Claude Code on its own and vim, 10-25 ms. + * (webtty, ADR 069) + */ +export const SLOW_ANSWER_MS = 40; + +/** + * The most ticks one go sends to a slow app. It shows a dozen updates a second, so a hard + * TrackPoint push (300 lines a second and more) moved 24 lines an update, up to 190, as + * uneven jumps; capped, the updates are even and the push scrolls less far. Ticks past one + * more go are dropped, so the scroll stops when the push does. (webtty, ADR 069) + */ +export const SLOW_GO_TICKS = 12; + export class TickPacer { /** Progress through the queued ticks: `glide.value` ticks are due, `sent` went out. */ private readonly glide = new Glide(); @@ -91,6 +122,10 @@ export class TickPacer { private seq = ''; private dir = 0; private frame = 0; + /** When ticks last went out, until the app answers; null once it has. */ + private awaitingSince: number | null = null; + /** The app's answer time, a running average in ms. */ + private answerMs = 0; constructor(private readonly send: (data: string) => void) {} @@ -102,8 +137,18 @@ export class TickPacer { this.seq = seq; const target = this.glide.target + count; if (pace.duration <= 0 && !pace.omega) { - this.send(seq.repeat(target - this.sent)); - this.reset(); + const now = performance.now(); + if (!this.waiting(now)) { + this.go(target - this.sent, now); + if (this.sent >= target) { + this.reset(); + return; + } + } + // Due at once, but the app has not answered the last ticks (they go together when it + // does), or is slow and takes them a go at a time + this.glide.jump(target); + if (!this.frame) this.frame = requestAnimationFrame(this.step); return; } // Evenly, whatever the pace's easing: an app moves in whole lines, so easing @@ -112,6 +157,14 @@ export class TickPacer { if (!this.frame) this.frame = requestAnimationFrame(this.step); } + /** The app answered the ticks that went out (its output arrived): the next ones may go. */ + answered(): void { + if (this.awaitingSince === null) return; + const took = Math.min(performance.now() - this.awaitingSince, ANSWER_WAIT_MS); + this.answerMs = this.answerMs ? 0.7 * this.answerMs + 0.3 * took : took; + this.awaitingSince = null; + } + /** Drops the queued ticks. */ cancel(): void { this.reset(); @@ -130,14 +183,34 @@ export class TickPacer { this.glide.step(now, 1e-3); const target = this.glide.target; const due = Math.min(target, Math.ceil(this.glide.value - 1e-9)) - this.sent; - if (due > 0) { - this.send(this.seq.repeat(due)); - this.sent += due; - } + if (due > 0 && !this.waiting(now)) this.go(due, now); if (this.sent < target) { this.frame = requestAnimationFrame(this.step); } else { this.reset(); } }; + + /** Sends the `due` ticks, at most {@link SLOW_GO_TICKS} of them to a slow app. */ + private go(due: number, now: number): void { + if (this.answerMs <= SLOW_ANSWER_MS || due <= SLOW_GO_TICKS) { + this.emit(due, now); + return; + } + // Ticks past one more go are dropped: the app could not have shown them evenly + this.sent += Math.max(0, due - 2 * SLOW_GO_TICKS); + this.emit(SLOW_GO_TICKS, now); + } + + /** Sends `count` ticks, which then wait for the app's answer. */ + private emit(count: number, now: number): void { + this.send(this.seq.repeat(count)); + this.sent += count; + this.awaitingSince = now; + } + + /** Ticks went out under {@link ANSWER_WAIT_MS} ago and the app has not answered them. */ + private waiting(now: number): boolean { + return this.awaitingSince !== null && now - this.awaitingSince < ANSWER_WAIT_MS; + } } diff --git a/src/client/vendor/tests/core/terminal-controllers.test.ts b/src/client/vendor/tests/core/terminal-controllers.test.ts index 060e695..c49ee32 100644 --- a/src/client/vendor/tests/core/terminal-controllers.test.ts +++ b/src/client/vendor/tests/core/terminal-controllers.test.ts @@ -211,6 +211,7 @@ function world() { (e) => void log.push(`unpin ${(e as unknown) === parts.engine}`), ); spyOn(core.sync, 'wrote').mockImplementation((data) => void log.push(`wrote ${data}`)); + spyOn(core.wheel, 'answered').mockImplementation(() => void log.push('answered')); spyOn(core.scrollbar, 'drag').mockImplementation(() => true); spyOn(core.mouse, 'encode').mockImplementation(() => 'report'); return { log, parts, term, inner, core, sync }; @@ -290,12 +291,23 @@ describe('TerminalControllers hosts', () => { 'pin true', 'unpin true', 'wrote o', + 'answered', 'render false undefined', 'render false undefined', 'update painted', ]); }); + test("output answers the app's scroll ticks once no synchronized update is open (ADR 069)", () => { + const { log, term, inner, core, sync } = world(); + const host = writeHost(term, inner, core); + sync.on = true; + host.wroteOutput('begun'); + sync.on = false; + host.wroteOutput('ended'); + expect(log).toEqual(['wrote begun', 'wrote ended', 'answered']); + }); + test("sync hold: the engine's mode 2026, read and turned off", () => { const { log, inner, sync } = world(); const host = syncHoldHost(inner); diff --git a/src/client/vendor/tests/core/wheel-controller.test.ts b/src/client/vendor/tests/core/wheel-controller.test.ts index 1deb2a5..788d1d4 100644 --- a/src/client/vendor/tests/core/wheel-controller.test.ts +++ b/src/client/vendor/tests/core/wheel-controller.test.ts @@ -37,6 +37,7 @@ function wheelOn(options: Options = {}) { push: (seq: string, dir: number, count: number, pace: Pace) => pushed.push([seq, dir, count, pace]), cancel: () => log.push('cancel'), + answered: () => log.push('answered'), }; const controller = new WheelController(host, pacer); controller.attach(container as unknown as HTMLElement); @@ -227,8 +228,11 @@ describe('WheelController in mouse-tracking apps', () => { encodeMouse: (event) => mouse.encode(event), send: (data) => sent.push(data), }); - const send = (deltaY: number, clientX: number, clientY: number, mods: object = {}) => + // The app answers each event's ticks at once, so the next ones go (ADR 069) + const send = (deltaY: number, clientX: number, clientY: number, mods: object = {}) => { wheel.handle(wheelEvent(deltaY, { clientX, clientY, ...mods }).event); + wheel.answered(); + }; return { sent, send, free: () => wasmTerm.free() }; } diff --git a/src/client/vendor/tests/input/wheel.test.ts b/src/client/vendor/tests/input/wheel.test.ts index 945369c..2462faf 100644 --- a/src/client/vendor/tests/input/wheel.test.ts +++ b/src/client/vendor/tests/input/wheel.test.ts @@ -1,5 +1,7 @@ import { describe, expect, test } from 'bun:test'; import { + ANSWER_WAIT_MS, + SLOW_GO_TICKS, TickPacer, type WheelAccumulator, wheelArrowKey, @@ -76,7 +78,14 @@ describe('wheelArrows', () => { }); describe('TickPacer', () => { - function frames(run: (pacer: TickPacer, sent: string[], tick: (ms: number) => void) => void) { + /** + * Runs `run` on fake frames. With `answers` (the default) the app answers every frame, as + * one that redraws within a frame does; without it, only when the test calls `answered`. + */ + function frames( + run: (pacer: TickPacer, sent: string[], tick: (ms: number) => void) => void, + answers = true, + ) { const g = globalThis as { requestAnimationFrame?: unknown; cancelAnimationFrame?: unknown; @@ -95,7 +104,9 @@ describe('TickPacer', () => { g.performance = { now: () => now }; const sent: string[] = []; try { - run(new TickPacer((d) => sent.push(d)), sent, (ms) => { + const pacer = new TickPacer((d) => sent.push(d)); + run(pacer, sent, (ms) => { + if (answers) pacer.answered(); now += ms; const cb = pending; pending = null; @@ -175,6 +186,7 @@ describe('TickPacer', () => { pacer.push('A', -1, 10, { duration: 100 }); tick(1000 / 60); const before = sent.join('').length; + pacer.answered(); pacer.push('B', 1, 1, { duration: 0 }); expect(sent.join('').length).toBe(before + 1); expect(sent.at(-1)).toBe('B'); @@ -185,6 +197,122 @@ describe('TickPacer', () => { }); }); +describe('TickPacer waiting for the app', () => { + function slowApp(run: (pacer: TickPacer, sent: string[], tick: (ms: number) => void) => void) { + const g = globalThis as { + requestAnimationFrame?: unknown; + cancelAnimationFrame?: unknown; + performance?: unknown; + }; + const saved = [g.requestAnimationFrame, g.cancelAnimationFrame, g.performance]; + let now = 0; + let pending: ((t: number) => void) | null = null; + g.requestAnimationFrame = (cb: (t: number) => void) => { + pending = cb; + return 1; + }; + g.cancelAnimationFrame = () => { + pending = null; + }; + g.performance = { now: () => now }; + const sent: string[] = []; + try { + run(new TickPacer((d) => sent.push(d)), sent, (ms) => { + now += ms; + const cb = pending; + pending = null; + cb?.(now); + }); + } finally { + [g.requestAnimationFrame, g.cancelAnimationFrame, g.performance] = saved; + } + } + const frame = 1000 / 60; + + test('ticks due while the app redraws go together once it answers', () => { + slowApp((pacer, sent, tick) => { + // 12 ticks over 200 ms, about one a frame, to an app that takes 100 ms a redraw + pacer.push('A', -1, 12, { duration: 200 }); + tick(frame); + expect(sent).toHaveLength(1); + for (let i = 0; i < 5; i++) tick(frame); + expect(sent).toHaveLength(1); + pacer.answered(); + tick(frame); + expect(sent).toHaveLength(2); + // The second go carries every tick due during the wait + expect(sent[1].length).toBeGreaterThan(1); + for (let i = 0; i < 20; i++) { + if (i % 6 === 5) pacer.answered(); + tick(frame); + } + expect(sent.join('').length).toBe(12); + // A few goes for twelve ticks, not twelve + expect(sent.length).toBeLessThanOrEqual(5); + }); + }); + + test('ticks due at once wait for the answer too', () => { + slowApp((pacer, sent, tick) => { + pacer.push('A', -1, 2, { duration: 0 }); + pacer.push('A', -1, 3, { duration: 0 }); + expect(sent).toEqual(['AA']); + tick(frame); + expect(sent).toEqual(['AA']); + pacer.answered(); + tick(frame); + expect(sent).toEqual(['AA', 'AAA']); + }); + }); + + test('a slow app gets at most SLOW_GO_TICKS a go, and ticks past one more go are dropped', () => { + slowApp((pacer, sent, tick) => { + // Two goes answered after 60 ms: the app is slow + for (let i = 0; i < 2; i++) { + pacer.push('A', -1, 1, { duration: 0 }); + tick(60); + pacer.answered(); + } + sent.length = 0; + pacer.push('A', -1, 50, { duration: 0 }); + expect(sent).toEqual(['A'.repeat(SLOW_GO_TICKS)]); + tick(60); + pacer.answered(); + tick(frame); + // One more go of the rest; the other 26 are dropped + expect(sent).toEqual(['A'.repeat(SLOW_GO_TICKS), 'A'.repeat(SLOW_GO_TICKS)]); + tick(60); + pacer.answered(); + for (let i = 0; i < 5; i++) tick(frame); + expect(sent).toHaveLength(2); + }); + }); + + test('a fast app gets every tick due, however many', () => { + slowApp((pacer, sent, tick) => { + for (let i = 0; i < 3; i++) { + pacer.push('A', -1, 1, { duration: 0 }); + tick(10); + pacer.answered(); + } + sent.length = 0; + pacer.push('A', -1, 50, { duration: 0 }); + expect(sent).toEqual(['A'.repeat(50)]); + }); + }); + + test('an app that does not answer gets the next ticks after ANSWER_WAIT_MS', () => { + slowApp((pacer, sent, tick) => { + pacer.push('A', -1, 1, { duration: 0 }); + pacer.push('A', -1, 1, { duration: 0 }); + tick(ANSWER_WAIT_MS - 10); + expect(sent).toEqual(['A']); + tick(20); + expect(sent).toEqual(['A', 'A']); + }); + }); +}); + describe('wheelArrowKey', () => { test('normal cursor keys are ESC [ A/B', () => { expect(wheelArrowKey(-1, false)).toBe('\x1b[A');