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..9655643 --- /dev/null +++ b/docs/adrs/040.client.pixel-smooth-scroll.md @@ -0,0 +1,35 @@ +# ADR 040: client — Pixel-smooth scrollback scrolling + +**SPEC:** [client](../specs/client.md) +**Status:** Accepted +**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 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, 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. + +## 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/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) | ✅ | diff --git a/docs/specs/config.md b/docs/specs/config.md index c172d66..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). 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 649f636..511448a 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) => { @@ -287,10 +292,18 @@ 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, 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 => { - 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; @@ -298,7 +311,10 @@ term.attachCustomWheelEventHandler((e: WheelEvent): boolean => { scrollAccum = 0; scrollDir = dir; } - scrollAccum += 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; 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; + } + } +}