Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/adrs/045.client.smooth-mouse-wheel.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/specs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 0 additions & 1 deletion scripts/check-coverage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
36 changes: 0 additions & 36 deletions src/client/features/scroll/page-zoom.test.ts

This file was deleted.

57 changes: 0 additions & 57 deletions src/client/features/scroll/page-zoom.ts

This file was deleted.

11 changes: 0 additions & 11 deletions src/client/features/scroll/wheel-classifier.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
21 changes: 7 additions & 14 deletions src/client/features/scroll/wheel-classifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
16 changes: 0 additions & 16 deletions src/client/features/scroll/wheel-stream.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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);
});
});
11 changes: 3 additions & 8 deletions src/client/features/scroll/wheel-stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`. */
Expand Down Expand Up @@ -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 };
}
Expand Down Expand Up @@ -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
Expand Down
Loading