From 8e803fdbae2d7f69f1849b7a5c84ad934580bc2e Mon Sep 17 00:00:00 2001 From: jesse23 Date: Mon, 12 Oct 2026 20:18:11 -0400 Subject: [PATCH 1/4] feat(client): pixel-smooth scrollback scrolling Keep the scroll position as fractional lines: ghostty-web renders the whole lines, the fraction becomes a device-pixel CSS offset on the canvas plus a one-row strip for the line sliding in above it. Normal screen only. See ADR 040. --- docs/adrs/040.client.pixel-smooth-scroll.md | 34 ++++ src/client/index.ts | 7 +- src/client/smooth-scroll.test.ts | 38 +++++ src/client/smooth-scroll.ts | 174 ++++++++++++++++++++ 4 files changed, 252 insertions(+), 1 deletion(-) create mode 100644 docs/adrs/040.client.pixel-smooth-scroll.md create mode 100644 src/client/smooth-scroll.test.ts create mode 100644 src/client/smooth-scroll.ts diff --git a/docs/adrs/040.client.pixel-smooth-scroll.md b/docs/adrs/040.client.pixel-smooth-scroll.md new file mode 100644 index 0000000..2e7ae9d --- /dev/null +++ b/docs/adrs/040.client.pixel-smooth-scroll.md @@ -0,0 +1,34 @@ +# ADR 040: client — Pixel-smooth scrollback scrolling + +**SPEC:** [client](../specs/client.md) +**Status:** Proposed +**Date:** 2026-10-12 + +--- + +## Context + +ghostty-web's viewport is a whole number of lines: `render()` floors `viewportY` when it picks rows, and draws only full rows. A macOS trackpad reports fine pixel deltas with native momentum, but ghostty-web turns them into a line count and eases toward it over 100 ms, so scrolling steps a line at a time and trails the fingers. + +## Decision + +Keep the scroll position as fractional lines in the client (`src/client/smooth-scroll.ts`) and take over wheel events on the normal screen when the app is not tracking the mouse: + +- The whole part goes to ghostty-web as `viewportY` (`scrollToLine`), which renders those rows as usual. +- The fraction becomes a CSS offset snapped to device pixels: the canvas is translated down by `fraction × cellHeight` with its bottom edge clipped, and a one-row strip above it shows the scrollback line sliding into view. The strip is drawn with ghostty-web's own `renderLine`, with the renderer's context swapped for the strip's, so it matches the canvas exactly. +- At a whole-line position the canvas has no transform and the strip is hidden; a resting terminal renders as before. +- ghostty-web's own viewport moves (output snapping to the bottom, keyboard scrolling, scrollbar drag, resize) reset the position to its line. + +The alternate screen and mouse-tracking apps keep their current behaviour: those apps scroll their own view in whole lines. + +## Consequences + +- Trackpad scrolling through scrollback follows the fingers pixel by pixel, with macOS momentum, and no easing lag. +- A mouse wheel with large pixel steps jumps by those steps without ghostty-web's easing. +- Uses private ghostty-web renderer members (`ctx`, `renderLine`); an upgrade that changes them breaks the strip. +- While at a fractional position: the overlay scrollbar moves with the canvas by under a line, Kitty graphics placements are not offset, and the bottom edge strip (ADR 035) extends the clipped last row. + +## Related Decisions + +- [ADR 017 — Mouse scroll in apps](017.client.mouse-scroll.md) +- [ADR 039 — Follow devicePixelRatio changes](039.client.device-pixel-ratio-change.md) diff --git a/src/client/index.ts b/src/client/index.ts index 649f636..e895204 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -6,6 +6,7 @@ import { computeGrid, computeInsets } from './fit'; import { KittyGraphics } from './graphics'; import { isDuplicateDrag, rewriteHoverMotion } from './mouse'; import { encodePaste } from './paste'; +import { SmoothScroll } from './smooth-scroll'; import { toTerminalTheme } from './theme'; interface KeyboardBinding { @@ -92,6 +93,8 @@ const edges = new EdgeExtender(container, config.padding); // IME / dictation composition shows at the cursor in the theme's colours, not as a // stray line under the canvas. See ADR 038. new Composer(term, container, terminalTheme); +// Pixel-smooth scrollback scrolling for trackpads. See ADR 040. +const smoothScroll = new SmoothScroll(term, container); // ghostty-web's FitAddon subtracts a fixed 15px scrollbar reserve, which leaves a // strip at the edges even though the scrollbar is an overlay drawn inside the canvas. @@ -126,6 +129,7 @@ function fit(): void { container.style.paddingTop = `${insets.top}px`; container.style.paddingBottom = `${insets.bottom}px`; edges.update(); + smoothScroll.update(); } // Both ResizeObserver and window 'resize' can fire for the same physical event. @@ -253,6 +257,7 @@ function connect(): void { ws.onmessage = (event: MessageEvent) => { applyDecscusr(term, event.data); graphics.feed(event.data); + smoothScroll.onOutput(); }; ws.onclose = (event: CloseEvent) => { @@ -290,7 +295,7 @@ connect(); let scrollAccum = 0; let scrollDir = 0; term.attachCustomWheelEventHandler((e: WheelEvent): boolean => { - if (!term.hasMouseTracking()) return false; + if (!term.hasMouseTracking()) return smoothScroll.handleWheel(e); const metrics = term.renderer?.getMetrics(); if (!metrics) return false; const dir = e.deltaY < 0 ? -1 : 1; diff --git a/src/client/smooth-scroll.test.ts b/src/client/smooth-scroll.test.ts new file mode 100644 index 0000000..20ae8a4 --- /dev/null +++ b/src/client/smooth-scroll.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from 'bun:test'; +import { splitScroll, wheelLines } from './smooth-scroll'; + +describe('splitScroll', () => { + test('a whole-line position has no offset', () => { + expect(splitScroll(3, 14, 2)).toEqual({ line: 3, offset: 0 }); + }); + + test('the fraction becomes a pixel offset below the whole line', () => { + expect(splitScroll(3.5, 14, 2)).toEqual({ line: 3, offset: 7 }); + }); + + test('snaps the offset to device pixels', () => { + // 0.3 x 14 = 4.2 CSS px = 8.4 device px at 2x, snapped to 8 device px. + expect(splitScroll(0.3, 14, 2)).toEqual({ line: 0, offset: 4 }); + expect(splitScroll(0.3, 14, 1)).toEqual({ line: 0, offset: 4 }); + expect(splitScroll(0.25, 14, 2)).toEqual({ line: 0, offset: 3.5 }); + }); + + test('rounding up to a full cell moves to the next line', () => { + expect(splitScroll(2.99, 14, 1)).toEqual({ line: 3, offset: 0 }); + }); +}); + +describe('wheelLines', () => { + test('converts pixel deltas by the cell height', () => { + expect(wheelLines({ deltaY: 7, deltaMode: 0 }, 14, 40)).toBe(0.5); + expect(wheelLines({ deltaY: -28, deltaMode: 0 }, 14, 40)).toBe(-2); + }); + + test('passes line deltas through', () => { + expect(wheelLines({ deltaY: 3, deltaMode: 1 }, 14, 40)).toBe(3); + }); + + test('scales page deltas by the row count', () => { + expect(wheelLines({ deltaY: -1, deltaMode: 2 }, 14, 40)).toBe(-40); + }); +}); diff --git a/src/client/smooth-scroll.ts b/src/client/smooth-scroll.ts new file mode 100644 index 0000000..0d6afec --- /dev/null +++ b/src/client/smooth-scroll.ts @@ -0,0 +1,174 @@ +/** + * Pixel-smooth scrollback scrolling. See ADR 040. + * + * ghostty-web draws whole rows only: its viewport is a line count, and a fractional + * viewportY is floored when rows are picked. A trackpad reports pixel deltas, so + * scrolling steps a whole line at a time behind a short easing animation. + * + * This keeps the scroll position as fractional lines. ghostty-web renders the whole + * part as usual; the fraction becomes a CSS offset: the canvas moves down by + * `fraction x cellHeight` (its bottom edge clipped), and a one-row strip above it + * shows the scrollback line that slides into view, drawn with ghostty-web's own + * renderLine. At a whole-line position the canvas has no transform and the strip is + * hidden, so a resting terminal renders exactly as before. + * + * Only the normal screen's scrollback scrolls this way. On the alternate screen, or + * when the app tracks the mouse, the app moves its own view in whole lines. + */ +import type { Terminal } from 'ghostty-web'; + +export interface ScrollSplit { + /** Whole lines scrolled up from the bottom: ghostty-web's viewportY. */ + line: number; + /** Extra offset in CSS pixels, snapped to device pixels, in `[0, cellHeight)`. */ + offset: number; +} + +/** Splits a fractional scroll `position` (lines up from the bottom) into whole lines and a pixel offset. */ +export function splitScroll(position: number, cellHeight: number, dpr: number): ScrollSplit { + const line = Math.floor(position); + const offset = Math.round((position - line) * cellHeight * dpr) / dpr; + // Rounding can reach a full cell; that is the next whole line with no offset. + if (offset >= cellHeight) return { line: line + 1, offset: 0 }; + return { line, offset }; +} + +/** Lines a wheel event scrolls toward the bottom (negative: toward the top of scrollback). */ +export function wheelLines( + e: Pick, + cellHeight: number, + rows: number, +): number { + if (e.deltaMode === 1) return e.deltaY; // DOM_DELTA_LINE + if (e.deltaMode === 2) return e.deltaY * rows; // DOM_DELTA_PAGE + return e.deltaY / cellHeight; +} + +type Cell = NonNullable>[number]; + +interface RendererInternals { + ctx: CanvasRenderingContext2D; + devicePixelRatio: number; + renderLine(line: Cell[], row: number, cols: number): void; +} + +export class SmoothScroll { + /** Lines scrolled up from the bottom; fractional between rows. */ + private position = 0; + private readonly strip: HTMLCanvasElement; + private readonly stripCtx: CanvasRenderingContext2D | null; + + constructor( + private readonly term: Terminal, + private readonly container: HTMLElement, + ) { + this.strip = document.createElement('canvas'); + this.stripCtx = this.strip.getContext('2d'); + this.strip.style.position = 'absolute'; + this.strip.style.pointerEvents = 'none'; + this.strip.style.display = 'none'; + // Excluded from the edge and graphics `canvas` lookups like the edge strips. + this.strip.dataset.edge = 'true'; + container.append(this.strip); + + // ghostty-web moves the viewport itself on new output (to the bottom), keyboard + // scrolling and scrollbar drags. Follow it whenever it lands on a different line. + term.onScroll((y: number) => { + if (y !== Math.floor(this.position)) this.jump(y); + }); + term.onResize(() => this.jump(term.viewportY)); + } + + /** Handles a wheel event on the normal screen. Returns false to leave it to ghostty-web. */ + handleWheel(e: WheelEvent): boolean { + if (this.term.wasmTerm?.isAlternateScreen()) return false; + const metrics = this.term.renderer?.getMetrics(); + if (!metrics) return false; + const max = this.term.getScrollbackLength(); + const lines = wheelLines(e, metrics.height, this.term.rows); + this.position = Math.max(0, Math.min(max, this.position - lines)); + this.apply(); + return true; + } + + /** New output arrived. ghostty-web only snaps to the bottom when viewportY > 0, so clear a sub-line offset here. */ + onOutput(): void { + if (this.term.viewportY === 0 && this.position !== 0) this.jump(0); + } + + /** Re-applies the offset after a fit moved or resized the canvas. */ + update(): void { + this.apply(); + } + + private jump(line: number): void { + this.position = line; + this.apply(); + } + + private apply(): void { + const renderer = this.term.renderer as unknown as RendererInternals | undefined; + const metrics = this.term.renderer?.getMetrics(); + const canvas = this.container.querySelector('canvas:not([data-edge])'); + if (!renderer || !metrics || !canvas) return; + const { line, offset } = splitScroll( + this.position, + metrics.height, + window.devicePixelRatio || 1, + ); + // Changes ghostty-web's viewportY synchronously; its render loop redraws the rows on + // the next frame, the same frame the transform below lands in. + if (line !== this.term.viewportY) this.term.scrollToLine(line); + if (offset === 0) { + canvas.style.transform = ''; + canvas.style.clipPath = ''; + this.strip.style.display = 'none'; + return; + } + canvas.style.transform = `translateY(${offset}px)`; + canvas.style.clipPath = `inset(0 0 ${offset}px 0)`; + this.drawStrip(renderer, canvas, line, offset, metrics.height); + } + + /** Draws the scrollback line just above the viewport into the strip over the canvas' top edge. */ + private drawStrip( + renderer: RendererInternals, + canvas: HTMLCanvasElement, + line: number, + offset: number, + cellHeight: number, + ): void { + const ctx = this.stripCtx; + const cells = this.term.getScrollbackLine(this.term.getScrollbackLength() - line - 1); + if (!ctx || !cells) { + this.strip.style.display = 'none'; + return; + } + const dpr = renderer.devicePixelRatio; + const width = canvas.offsetWidth; + const w = Math.round(width * dpr); + const h = Math.round(cellHeight * dpr); + if (this.strip.width !== w) this.strip.width = w; + if (this.strip.height !== h) this.strip.height = h; + this.strip.style.width = `${width}px`; + this.strip.style.height = `${cellHeight}px`; + this.strip.style.left = `${canvas.offsetLeft}px`; + this.strip.style.top = `${canvas.offsetTop + offset - cellHeight}px`; + this.strip.style.clipPath = `inset(${cellHeight - offset}px 0 0 0)`; + this.strip.style.display = 'block'; + + // renderLine draws row `r` at y = r x cellHeight into renderer.ctx. Borrow it with the + // strip's context, shifted down a row so row -1 lands at y = 0. Row -1 is never in a + // selection, so the line draws unselected. + ctx.setTransform(dpr, 0, 0, dpr, 0, cellHeight * dpr); + ctx.textBaseline = 'alphabetic'; + ctx.textAlign = 'left'; + const own = renderer.ctx; + renderer.ctx = ctx; + try { + renderer.renderLine(cells, -1, this.term.cols); + } finally { + renderer.ctx = own; + } + } +} From 40dd949c7a2e1eb0d361105572c0fb1e66afa64d Mon Sep 17 00:00:00 2001 From: jesse23 Date: Mon, 12 Oct 2026 20:20:37 -0400 Subject: [PATCH 2/4] feat(client): count trackpad scroll by distance in mouse-tracking apps Pixel wheel deltas send one SGR scroll tick per cell height scrolled (times mouseScrollSpeed) instead of one per event, so trackpad scrolling in herdr, Claude Code and vim tracks the fingers. See ADR 040. --- docs/adrs/040.client.pixel-smooth-scroll.md | 3 ++- docs/specs/config.md | 2 +- src/client/index.ts | 8 +++++++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/docs/adrs/040.client.pixel-smooth-scroll.md b/docs/adrs/040.client.pixel-smooth-scroll.md index 2e7ae9d..c9415ea 100644 --- a/docs/adrs/040.client.pixel-smooth-scroll.md +++ b/docs/adrs/040.client.pixel-smooth-scroll.md @@ -19,12 +19,13 @@ Keep the scroll position as fractional lines in the client (`src/client/smooth-s - At a whole-line position the canvas has no transform and the strip is hidden; a resting terminal renders as before. - ghostty-web's own viewport moves (output snapping to the bottom, keyboard scrolling, scrollbar drag, resize) reset the position to its line. -The alternate screen and mouse-tracking apps keep their current behaviour: those apps scroll their own view in whole lines. +The alternate screen and mouse-tracking apps scroll their own view in whole lines, so no pixel offset is possible there. For mouse-tracking apps (herdr, Claude Code's fullscreen TUI, vim `mouse=a`), count pixel wheel deltas by distance instead: one SGR scroll tick per cell height scrolled, times `mouseScrollSpeed` (ADR 017). Previously every wheel event was one tick, so a trackpad's many small events and momentum scrolled far ahead of the fingers. Line and page deltas still count one tick per event. ## Consequences - Trackpad scrolling through scrollback follows the fingers pixel by pixel, with macOS momentum, and no easing lag. - A mouse wheel with large pixel steps jumps by those steps without ghostty-web's easing. +- In mouse-tracking apps, trackpad scrolling now moves about one line per line of finger travel. On macOS, Chromium and Safari report mouse wheels in pixels too, so a wheel notch now sends a distance-based number of ticks instead of exactly one; users who raised `mouseScrollSpeed` to compensate for slow wheels may want to lower it. - Uses private ghostty-web renderer members (`ctx`, `renderLine`); an upgrade that changes them breaks the strip. - While at a fractional position: the overlay scrollbar moves with the canvas by under a line, Kitty graphics placements are not offset, and the bottom edge strip (ADR 035) extends the clipped last row. diff --git a/docs/specs/config.md b/docs/specs/config.md index c172d66..d083cac 100644 --- a/docs/specs/config.md +++ b/docs/specs/config.md @@ -114,7 +114,7 @@ All keys are optional — omit any key to use the default value. | `cursorStyleBlink` | boolean | `true` | Default blink state. Apps override at runtime via DECSCUSR — this is the startup default only. | | `copyOnSelect` | boolean | `true` | Auto-copy selection to clipboard on mouseup (kitty / Windows Terminal style) | | `rightClickBehavior` | string | `"default"` | Right-click behavior: `"copyPaste"` copies selection + clears it if selection exists, otherwise native menu; `"default"` always shows native context menu. Invalid values fall back to `"default"` | -| `mouseScrollSpeed` | number | `1` | Mouse wheel scroll speed multiplier for apps with mouse tracking (e.g. vim `set mouse=a`). `1` = one SGR event per wheel tick (default). Values `< 1` reduce rate (e.g. `0.5` fires every other tick); values `> 1` send multiple SGRs per tick. Must be `> 0`. | +| `mouseScrollSpeed` | number | `1` | Mouse wheel scroll speed multiplier for apps with mouse tracking (e.g. vim `set mouse=a`). `1` = one SGR event per wheel tick (default); for pixel deltas (trackpads, macOS Chromium/Safari wheels) a tick is one cell height of scroll distance. Values `< 1` reduce rate (e.g. `0.5` fires every other tick); values `> 1` send multiple SGRs per tick. Must be `> 0`. | | `padding` | number | `0` | Padding in px around the terminal canvas, on all four sides. The canvas is fitted to whole cells, so a few extra pixels can remain on top of this: on the right and at the bottom. The top and left gap is exactly `padding`. The padding is coloured by `theme.padding`, or `theme.background` if that is unset. | | `logs` | boolean | `false` | Write server stdout/stderr to `~/.config/webtty/server.log`. Appends on each start. Default `false` — server runs silently. | | `fontSize` | number | `13` | Font size in px | diff --git a/src/client/index.ts b/src/client/index.ts index e895204..a2f5512 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -292,6 +292,11 @@ connect(); // config.mouseScrollSpeed scales SGR events per wheel tick (default 1). // Values < 1 reduce rate via accumulation; values > 1 send multiple SGRs. // The accumulator resets on direction change to prevent cross-direction bleed. +// +// Pixel deltas (trackpads, and mouse wheels in Chromium/Safari on macOS) count by +// distance: one tick per cell height scrolled. A trackpad fires many small events per +// swipe, plus momentum, and counting each as a whole tick made apps scroll far ahead +// of the fingers. Line and page deltas still count one tick per event. See ADR 040. let scrollAccum = 0; let scrollDir = 0; term.attachCustomWheelEventHandler((e: WheelEvent): boolean => { @@ -303,7 +308,8 @@ term.attachCustomWheelEventHandler((e: WheelEvent): boolean => { scrollAccum = 0; scrollDir = dir; } - scrollAccum += config.mouseScrollSpeed; + const step = e.deltaMode === WheelEvent.DOM_DELTA_PIXEL ? Math.abs(e.deltaY) / metrics.height : 1; + scrollAccum += step * config.mouseScrollSpeed; const ticks = Math.trunc(scrollAccum); if (ticks === 0) return true; scrollAccum -= ticks; From 41936365bbfcb3a72ae30ab94029824cbed8be9a Mon Sep 17 00:00:00 2001 From: jesse23 Date: Mon, 12 Oct 2026 20:29:24 -0400 Subject: [PATCH 3/4] docs: accept ADR 040 and track pixel-smooth scrolling in the client spec --- docs/adrs/040.client.pixel-smooth-scroll.md | 2 +- docs/specs/client.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/adrs/040.client.pixel-smooth-scroll.md b/docs/adrs/040.client.pixel-smooth-scroll.md index c9415ea..d0306ce 100644 --- a/docs/adrs/040.client.pixel-smooth-scroll.md +++ b/docs/adrs/040.client.pixel-smooth-scroll.md @@ -1,7 +1,7 @@ # ADR 040: client — Pixel-smooth scrollback scrolling **SPEC:** [client](../specs/client.md) -**Status:** Proposed +**Status:** Accepted **Date:** 2026-10-12 --- diff --git a/docs/specs/client.md b/docs/specs/client.md index 72e4d67..8e089c3 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -166,6 +166,7 @@ When a session ends (shell exits → WS close code `4001`) or the server stops ( | Multiline text paste | Bracket text only when mode 2004 is enabled; otherwise send raw text (newlines may act as Enter in apps without bracketed paste), never Ctrl+V | [ADR 037](../adrs/037.client.multiline-text-paste.md) | ✅ | | IME / dictation at cursor | Focus stays on the input textarea, which follows the cursor cell; the composition shows at the cursor in the theme's font and colours instead of as a stray line under the canvas | [ADR 038](../adrs/038.client.ime-composition-at-cursor.md) | ✅ | | Device pixel ratio changes | Moving to a monitor with a different scale, or changing zoom, updates the renderer's pixel ratio and repaints instead of staying blurry until reload; fractional ratios render at the next whole ratio so the canvas doesn't resize every frame | [ADR 039](../adrs/039.client.device-pixel-ratio-change.md) | ✅ | +| Pixel-smooth scrollback | Trackpad scrolling through scrollback moves by pixels (canvas offset plus a one-row strip for the incoming line); in mouse-tracking apps, pixel deltas send one scroll tick per cell height of travel | [ADR 040](../adrs/040.client.pixel-smooth-scroll.md) | ✅ | | Mouse scroll | When the PTY app enables mouse tracking (e.g. vim `set mouse=a`), wheel events are forwarded as SGR mouse sequences (`\x1b[<64/65;col;rowM`) instead of arrow keys, so apps scroll their buffer rather than move the cursor | [ADR 017](../adrs/017.client.mouse-scroll.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) | ✅ | | Canvas gap fill | After each fit, distribute the gap between the container and canvas as symmetric padding so the canvas is centred at the new size | [ADR 022](../adrs/022.client.canvas-fill.md) | ✅ | From eeaad03434846be528b11d0a5612ba8b3db808aa Mon Sep 17 00:00:00 2001 From: jesse23 Date: Mon, 12 Oct 2026 20:35:58 -0400 Subject: [PATCH 4/4] fix(client): scroll mouse-tracking apps 1:1 with trackpad distance mouseScrollSpeed no longer multiplies pixel wheel deltas, so trackpad scrolling in herdr and Claude Code moves one line per cell height of travel, like native macOS scrolling and Ghostty's precision scrolling. It still scales line/page wheel deltas. See ADR 040. --- docs/adrs/040.client.pixel-smooth-scroll.md | 4 ++-- docs/specs/config.md | 2 +- src/client/index.ts | 15 ++++++++++----- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/docs/adrs/040.client.pixel-smooth-scroll.md b/docs/adrs/040.client.pixel-smooth-scroll.md index d0306ce..9655643 100644 --- a/docs/adrs/040.client.pixel-smooth-scroll.md +++ b/docs/adrs/040.client.pixel-smooth-scroll.md @@ -19,13 +19,13 @@ Keep the scroll position as fractional lines in the client (`src/client/smooth-s - At a whole-line position the canvas has no transform and the strip is hidden; a resting terminal renders as before. - ghostty-web's own viewport moves (output snapping to the bottom, keyboard scrolling, scrollbar drag, resize) reset the position to its line. -The alternate screen and mouse-tracking apps scroll their own view in whole lines, so no pixel offset is possible there. For mouse-tracking apps (herdr, Claude Code's fullscreen TUI, vim `mouse=a`), count pixel wheel deltas by distance instead: one SGR scroll tick per cell height scrolled, times `mouseScrollSpeed` (ADR 017). Previously every wheel event was one tick, so a trackpad's many small events and momentum scrolled far ahead of the fingers. Line and page deltas still count one tick per event. +The alternate screen and mouse-tracking apps scroll their own view in whole lines, so no pixel offset is possible there. For mouse-tracking apps (herdr, Claude Code's fullscreen TUI, vim `mouse=a`), count pixel wheel deltas by distance instead: one SGR scroll tick per cell height scrolled, 1:1 with the OS like native macOS scrolling. `mouseScrollSpeed` (ADR 017) does not apply to pixel deltas, as Ghostty skips its scroll multiplier for precision scrolls; at 1.5 it made apps scroll visibly faster than native. Previously every wheel event was one tick, so a trackpad's many small events and momentum scrolled far ahead of the fingers. Line and page deltas still count one tick per event. ## Consequences - Trackpad scrolling through scrollback follows the fingers pixel by pixel, with macOS momentum, and no easing lag. - A mouse wheel with large pixel steps jumps by those steps without ghostty-web's easing. -- In mouse-tracking apps, trackpad scrolling now moves about one line per line of finger travel. On macOS, Chromium and Safari report mouse wheels in pixels too, so a wheel notch now sends a distance-based number of ticks instead of exactly one; users who raised `mouseScrollSpeed` to compensate for slow wheels may want to lower it. +- In mouse-tracking apps, trackpad scrolling now moves about one line per line of finger travel. On macOS, Chromium and Safari report mouse wheels in pixels too, so a wheel notch now sends a distance-based number of ticks instead of exactly one, and `mouseScrollSpeed` no longer affects them; it still scales line/page wheels (e.g. Firefox). - Uses private ghostty-web renderer members (`ctx`, `renderLine`); an upgrade that changes them breaks the strip. - While at a fractional position: the overlay scrollbar moves with the canvas by under a line, Kitty graphics placements are not offset, and the bottom edge strip (ADR 035) extends the clipped last row. diff --git a/docs/specs/config.md b/docs/specs/config.md index d083cac..d6171ec 100644 --- a/docs/specs/config.md +++ b/docs/specs/config.md @@ -114,7 +114,7 @@ All keys are optional — omit any key to use the default value. | `cursorStyleBlink` | boolean | `true` | Default blink state. Apps override at runtime via DECSCUSR — this is the startup default only. | | `copyOnSelect` | boolean | `true` | Auto-copy selection to clipboard on mouseup (kitty / Windows Terminal style) | | `rightClickBehavior` | string | `"default"` | Right-click behavior: `"copyPaste"` copies selection + clears it if selection exists, otherwise native menu; `"default"` always shows native context menu. Invalid values fall back to `"default"` | -| `mouseScrollSpeed` | number | `1` | Mouse wheel scroll speed multiplier for apps with mouse tracking (e.g. vim `set mouse=a`). `1` = one SGR event per wheel tick (default); for pixel deltas (trackpads, macOS Chromium/Safari wheels) a tick is one cell height of scroll distance. Values `< 1` reduce rate (e.g. `0.5` fires every other tick); values `> 1` send multiple SGRs per tick. Must be `> 0`. | +| `mouseScrollSpeed` | number | `1` | Mouse wheel scroll speed multiplier for apps with mouse tracking (e.g. vim `set mouse=a`). `1` = one SGR event per wheel tick (default). Applies to line/page wheel deltas only: pixel deltas (trackpads, macOS Chromium/Safari wheels) always send one SGR event per cell height scrolled, 1:1 with the OS. Values `< 1` reduce rate (e.g. `0.5` fires every other tick); values `> 1` send multiple SGRs per tick. Must be `> 0`. | | `padding` | number | `0` | Padding in px around the terminal canvas, on all four sides. The canvas is fitted to whole cells, so a few extra pixels can remain on top of this: on the right and at the bottom. The top and left gap is exactly `padding`. The padding is coloured by `theme.padding`, or `theme.background` if that is unset. | | `logs` | boolean | `false` | Write server stdout/stderr to `~/.config/webtty/server.log`. Appends on each start. Default `false` — server runs silently. | | `fontSize` | number | `13` | Font size in px | diff --git a/src/client/index.ts b/src/client/index.ts index a2f5512..511448a 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -294,9 +294,12 @@ connect(); // The accumulator resets on direction change to prevent cross-direction bleed. // // Pixel deltas (trackpads, and mouse wheels in Chromium/Safari on macOS) count by -// distance: one tick per cell height scrolled. A trackpad fires many small events per -// swipe, plus momentum, and counting each as a whole tick made apps scroll far ahead -// of the fingers. Line and page deltas still count one tick per event. See ADR 040. +// distance: one tick per cell height scrolled, 1:1 with the OS like native macOS +// scrolling, and mouseScrollSpeed does not apply (Ghostty likewise skips its scroll +// multiplier for precision scrolls). A trackpad fires many small events per swipe, +// plus momentum, and counting each as a whole tick made apps scroll far ahead of the +// fingers. Line and page deltas still count one tick per event, times +// mouseScrollSpeed. See ADR 040. let scrollAccum = 0; let scrollDir = 0; term.attachCustomWheelEventHandler((e: WheelEvent): boolean => { @@ -308,8 +311,10 @@ term.attachCustomWheelEventHandler((e: WheelEvent): boolean => { scrollAccum = 0; scrollDir = dir; } - const step = e.deltaMode === WheelEvent.DOM_DELTA_PIXEL ? Math.abs(e.deltaY) / metrics.height : 1; - scrollAccum += step * config.mouseScrollSpeed; + scrollAccum += + e.deltaMode === WheelEvent.DOM_DELTA_PIXEL + ? Math.abs(e.deltaY) / metrics.height + : config.mouseScrollSpeed; const ticks = Math.trunc(scrollAccum); if (ticks === 0) return true; scrollAccum -= ticks;