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
35 changes: 35 additions & 0 deletions docs/adrs/040.client.pixel-smooth-scroll.md
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions docs/specs/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) | ✅ |
Expand Down
2 changes: 1 addition & 1 deletion docs/specs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
20 changes: 18 additions & 2 deletions src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -253,6 +257,7 @@ function connect(): void {
ws.onmessage = (event: MessageEvent<string>) => {
applyDecscusr(term, event.data);
graphics.feed(event.data);
smoothScroll.onOutput();
};

ws.onclose = (event: CloseEvent) => {
Expand Down Expand Up @@ -287,18 +292,29 @@ 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;
if (dir !== scrollDir) {
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;
Expand Down
38 changes: 38 additions & 0 deletions src/client/smooth-scroll.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
174 changes: 174 additions & 0 deletions src/client/smooth-scroll.ts
Original file line number Diff line number Diff line change
@@ -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<WheelEvent, 'deltaY' | 'deltaMode'>,
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<ReturnType<Terminal['getScrollbackLine']>>[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<HTMLCanvasElement>('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;
}
}
}
Loading