diff --git a/docs/adrs/045.client.smooth-mouse-wheel.md b/docs/adrs/045.client.smooth-mouse-wheel.md index 8be67d8..d9dcb72 100644 --- a/docs/adrs/045.client.smooth-mouse-wheel.md +++ b/docs/adrs/045.client.smooth-mouse-wheel.md @@ -48,7 +48,7 @@ In detail: - The first deltas of a trackpad gesture come after a pause, so they glide over a frame or two; the second short gap in a row ends that. - End-to-end in headless Chrome (wheel notches, a 16 ms trackpad stream and the replayed TrackPoint capture; shell scrollback, a mouse-tracking app and an alternate-screen app): tick and arrow totals match one per cell height of travel, trackpad scrolling paints in the frame its event arrives, and reversals, output while gliding and the ends of scrollback behave. - The classifier needs a couple of events to settle; until then a notch after trackpad scrolling gets the sparse glide instead of the wheel glide. A trackpad flick with integer deltas that happen to be multiples of each other may glide for 125 ms. -- Chromium divides a wheel notch's pixels by the page zoom, so at 110% a 100 px notch arrives as 90.909… px and no longer looks like a notch. VS Code multiplies the deltas back by its own zoom factor before classifying them. A page cannot read the zoom directly, but its window's outer width is in screen pixels and its inner width in CSS pixels: `WheelStream` takes their ratio, snapped to Chromium's preset zoom levels (`features/scroll/page-zoom.ts`; 1 when it is near none, as with a side panel open), and classifies the deltas multiplied by it. The classifier also compares two notches by their ratio, not a floating-point remainder, which left 181.818… / 90.909… short of a multiple. Replaying notches of 100, 200 and 300 px at every preset from 50% to 200% went from up to 3 of 5 events taken for a TrackPoint's (at 67, 75, 90, 110, 150 and 175%) to none, and fractional trackpad deltas are still a trackpad at every zoom (2026-10-15). +- VS Code multiplies Chromium deltas by the page zoom before classifying; webtty does not, so at a browser zoom other than 100% a Chromium wheel's deltas may not be integers, and its notches get the sparse glide instead of the wheel glide. ## Considered Options diff --git a/docs/specs/architecture.md b/docs/specs/architecture.md index 392fa4a..ced6698 100644 --- a/docs/specs/architecture.md +++ b/docs/specs/architecture.md @@ -150,7 +150,7 @@ src/ find/ find-bar.ts (view), find-format.ts (util), find-keys.ts (util; takes the shortcut lookup, never imports keyboard/), find-feature.ts keyboard/ key-bindings.ts, shortcut-map.ts (was shortcuts.ts), shortcut-commands.ts, screen-clear.ts (Cmd+K), font-zoom.ts, key-dispatcher.ts (feature), font-zoom-feature.ts compose/ composer.ts (view + dispose), composer-geometry.ts (util) - scroll/ smooth-scroll.ts, scroll-split.ts, wheel-classifier.ts (VS Code MIT notice), wheel-stream.ts, page-zoom.ts, scroll-feature.ts + scroll/ smooth-scroll.ts, scroll-split.ts, wheel-classifier.ts (VS Code MIT notice), wheel-stream.ts, scroll-feature.ts kitty/ protocol/ apc-splitter.ts, apc-command.ts, chunk-assembler.ts, kitty-reply.ts, png-size.ts, cell-geometry.ts, control-scanner.ts graphics/ image-store.ts, image-decoder.ts, placements.ts, placement-geometry.ts, kitty-limits.ts, diff --git a/scripts/check-coverage.ts b/scripts/check-coverage.ts index 7d3a605..a14fcd4 100644 --- a/scripts/check-coverage.ts +++ b/scripts/check-coverage.ts @@ -61,7 +61,6 @@ export const PURE_MODULES: readonly string[] = [ 'src/client/features/kitty/protocol/kitty-reply.ts', 'src/client/features/kitty/protocol/png-size.ts', 'src/client/features/paste/non-text-paste.ts', - 'src/client/features/scroll/page-zoom.ts', 'src/client/features/scroll/scroll-split.ts', 'src/client/layout/edge-rects.ts', 'src/client/layout/fit-insets.ts', diff --git a/src/client/features/scroll/page-zoom.test.ts b/src/client/features/scroll/page-zoom.test.ts deleted file mode 100644 index 224c959..0000000 --- a/src/client/features/scroll/page-zoom.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, test } from 'bun:test'; -import { pageZoom, snapZoom, ZOOM_LEVELS } from './page-zoom'; - -describe('snapZoom', () => { - test.each([ - [1, 1], - [1.1, 1.1], - [1.105, 1.1], - [0.667, 2 / 3], - [1 / 3, 1 / 3], - [1.75, 1.75], - [5, 5], - // Near no preset (a side panel open), or nonsense: 1 - [1.3, 1], - [0.6, 1], - [0, 1], - [Number.NaN, 1], - ])('%p is %p', (ratio, zoom) => expect(snapZoom(ratio)).toBe(zoom)); - - test('every preset snaps to itself', () => { - for (const level of ZOOM_LEVELS) expect(snapZoom(level)).toBe(level); - }); -}); - -describe('pageZoom', () => { - test('is the outer width over the inner width, snapped to a preset', () => { - expect(pageZoom({ outerWidth: 1400, innerWidth: 1273 })).toBe(1.1); - expect(pageZoom({ outerWidth: 1400, innerWidth: 1400 })).toBe(1); - expect(pageZoom({ outerWidth: 1400, innerWidth: 2100 })).toBe(2 / 3); - }); - - test('is 1 without the sizes, as outside a browser', () => { - expect(pageZoom({})).toBe(1); - expect(pageZoom({ outerWidth: 0, innerWidth: 800 })).toBe(1); - }); -}); diff --git a/src/client/features/scroll/page-zoom.ts b/src/client/features/scroll/page-zoom.ts deleted file mode 100644 index f634ccd..0000000 --- a/src/client/features/scroll/page-zoom.ts +++ /dev/null @@ -1,57 +0,0 @@ -// The browser's page zoom, as far as a page can tell it (util, pure, ADR 045): Chromium -// divides a mouse wheel notch's pixels by the zoom, so at 110% a notch of 100 px arrives as -// 90.909… and no longer looks like a notch. VS Code multiplies the deltas back by its own -// zoom factor before classifying them; a page has no such value, but the window's outer -// width is in screen pixels and its inner width in CSS pixels, so their ratio is the zoom. - -/** Chromium's preset zoom levels (`kPresetZoomFactors`), which the menu and Cmd+/- step through. */ -export const ZOOM_LEVELS = [ - 0.25, - 1 / 3, - 0.5, - 2 / 3, - 0.75, - 0.8, - 0.9, - 1, - 1.1, - 1.25, - 1.5, - 1.75, - 2, - 2.5, - 3, - 4, - 5, -] as const; - -/** - * How far the measured ratio may be from a preset, relative: the outer width can include a - * window frame or a scrollbar of a few pixels. - */ -const TOLERANCE = 0.02; - -/** The preset zoom level `ratio` stands for, or 1 when it is near none (a side panel open). */ -export function snapZoom(ratio: number): number { - if (!Number.isFinite(ratio) || ratio <= 0) return 1; - let best = 1; - let bestError = TOLERANCE; - for (const level of ZOOM_LEVELS) { - const error = Math.abs(ratio / level - 1); - if (error < bestError) { - best = level; - bestError = error; - } - } - return best; -} - -/** The window sizes the zoom is read from. */ -export type ZoomWindow = Partial>; - -/** The page zoom of `win`, 1 when its sizes are missing. */ -export function pageZoom(win: ZoomWindow): number { - const { outerWidth, innerWidth } = win; - if (!outerWidth || !innerWidth) return 1; - return snapZoom(outerWidth / innerWidth); -} diff --git a/src/client/features/scroll/wheel-classifier.test.ts b/src/client/features/scroll/wheel-classifier.test.ts index 33e6c63..74c5105 100644 --- a/src/client/features/scroll/wheel-classifier.test.ts +++ b/src/client/features/scroll/wheel-classifier.test.ts @@ -41,17 +41,6 @@ describe('WheelClassifier', () => { ).toBe(false); }); - test('notches that are multiples of each other count, though floating point leaves a remainder', () => { - // 100 and 200 px notches at a 110% page zoom - expect( - feed([ - [0, 100 / 1.1], - [0, 200 / 1.1], - [0, 100 / 1.1], - ]), - ).toBe(true); - }); - test('no events is not a wheel', () => { expect(new WheelClassifier().isPhysicalMouseWheel()).toBe(false); }); diff --git a/src/client/features/scroll/wheel-classifier.ts b/src/client/features/scroll/wheel-classifier.ts index da320ba..ee1c102 100644 --- a/src/client/features/scroll/wheel-classifier.ts +++ b/src/client/features/scroll/wheel-classifier.ts @@ -39,24 +39,17 @@ export class WheelClassifier { if (!isAlmostInt(deltaX) || !isAlmostInt(deltaY)) score += 0.25; // A wheel's notches are multiples of each other; a trackpad accelerates smoothly. const prev = this.previous; - if (prev && isMultiple(deltaX, prev.deltaX) && isMultiple(deltaY, prev.deltaY)) score -= 0.5; + if (prev) { + const [ax, ay] = [Math.abs(deltaX), Math.abs(deltaY)]; + const [px, py] = [Math.abs(prev.deltaX), Math.abs(prev.deltaY)]; + const minX = Math.max(Math.min(ax, px), 1); + const minY = Math.max(Math.min(ay, py), 1); + if (Math.max(ax, px) % minX === 0 && Math.max(ay, py) % minY === 0) score -= 0.5; + } return Math.min(Math.max(score, 0), 1); } } -/** - * Whether the larger of two deltas is a whole multiple of the smaller (taken as at least 1), - * by their ratio: at a browser zoom other than 100%, Chromium divides a notch's pixels by the - * zoom, and notches of 90.909… and 181.818… px leave a remainder in floating point though - * one is twice the other. (webtty: VS Code compares remainders, after multiplying by its own - * zoom factor, which a page cannot read.) - */ -function isMultiple(a: number, b: number): boolean { - const low = Math.max(Math.min(Math.abs(a), Math.abs(b)), 1); - const ratio = Math.max(Math.abs(a), Math.abs(b)) / low; - return Math.abs(ratio - Math.round(ratio)) < 0.01; -} - function isAlmostInt(value: number): boolean { return Math.abs(Math.round(value) - value) < 0.01 + Number.EPSILON * 100; } diff --git a/src/client/features/scroll/wheel-stream.test.ts b/src/client/features/scroll/wheel-stream.test.ts index 3eaea3b..8202ef4 100644 --- a/src/client/features/scroll/wheel-stream.test.ts +++ b/src/client/features/scroll/wheel-stream.test.ts @@ -1,5 +1,4 @@ import { describe, expect, test } from 'bun:test'; -import { easeOutCubic, linear } from '../../vendor/ghostty-web'; import { STREAM_OMEGA, WHEEL_GLIDE_MS, WheelStream } from './wheel-stream'; describe('WheelStream.forApp', () => { @@ -129,18 +128,3 @@ describe('WheelStream classifies each event once', () => { expect(s.forApp(second)).toEqual(pace); }); }); - -describe('WheelStream at a page zoom', () => { - const at = (deltaY: number, timeStamp: number) => - ({ deltaX: 0, deltaY, deltaMode: 0, timeStamp }) as WheelEvent; - /** Accelerating notches of 100, 200 and 300 px as Chromium reports them at `zoom`. */ - const lastPace = (s: WheelStream, zoom: number) => - [1, 2, 3, 2].map((n, i) => s.forScrollback(at((-100 * n) / zoom, 1000 + i * 300))).at(-1); - - test('a wheel at 110% eases out as a wheel, once the deltas are taken back to 100%', () => { - const zoomed = new WheelStream({ outerWidth: 1400, innerWidth: 1273 }); - expect(lastPace(zoomed, 1.1)).toEqual({ duration: WHEEL_GLIDE_MS, ease: easeOutCubic }); - // Without the zoom, the same deltas glide linearly, as a TrackPoint's sparse stream - expect(lastPace(new WheelStream({}), 1.1)?.ease).toBe(linear); - }); -}); diff --git a/src/client/features/scroll/wheel-stream.ts b/src/client/features/scroll/wheel-stream.ts index 47e95ed..2c66dc1 100644 --- a/src/client/features/scroll/wheel-stream.ts +++ b/src/client/features/scroll/wheel-stream.ts @@ -18,7 +18,6 @@ * - Trackpad deltas, a frame apart, move at once. */ import { AT_ONCE, easeOutCubic, FRAME_MS, linear, type Pace } from '../../vendor/ghostty-web'; -import { pageZoom, type ZoomWindow } from './page-zoom'; import { WheelClassifier } from './wheel-classifier'; /** How long a mouse wheel notch glides to its target: VS Code's `smoothScrollDuration`. */ @@ -48,8 +47,8 @@ export const FAST_STREAM_MS = 60; /** The spring's stiffness, rad/s: about 70 ms to settle, smoothing over a few deltas. */ export const STREAM_OMEGA = 28; -/** The browser object the stream asks whether the OS wants less motion, and the page zoom. */ -export interface WheelStreamDeps extends ZoomWindow { +/** The browser object the stream asks whether the OS wants less motion. */ +export interface WheelStreamDeps { /** `window.matchMedia`; without one, motion is full. */ matchMedia?(query: string): { matches: boolean }; } @@ -115,11 +114,7 @@ export class WheelStream { const dir = Math.sign(e.deltaY); const sameDir = dir === this.lastDir; this.lastDir = dir; - // In the deltas' size before the page zoom divided them, as VS Code classifies them - if (e.deltaMode === 0) { - const zoom = pageZoom(this.deps); - this.classifier.accept((e.deltaX || 0) * zoom, e.deltaY * zoom); - } + if (e.deltaMode === 0) this.classifier.accept(e.deltaX || 0, e.deltaY); const wheel = e.deltaMode !== 0 || this.classifier.isPhysicalMouseWheel(); // A stream turns sparse on its second long gap in a row, as it stops being sparse on // its second short one: the first long gap is the pause before any gesture, and