Skip to content
178 changes: 178 additions & 0 deletions docs/adrs/023.client.font-size-zoom.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
# ADR 023: Client — Runtime font-size zoom via Ctrl/Cmd +/-

**SPEC:** [client](../specs/client.md)
**Status:** Accepted
**Date:** 2026-03-31

---

## Context

The terminal font size is set once at startup from `config.fontSize`. There is
no in-session way to increase or decrease it. Users familiar with VS Code and
native terminal emulators expect `Ctrl/Cmd` + `=` to zoom in, `Ctrl/Cmd` + `-`
to zoom out, and `Ctrl/Cmd` + `0` to reset — without holding Shift for `+`.

The feature has two non-obvious constraints:

1. **Browser page-zoom conflict.** The same key combinations trigger the
browser's own page-zoom. The handler must `preventDefault()` to suppress it.

2. **ghostty-web API surface.** `Terminal` (the public class) does not expose
`setFontSize()` directly. The method lives on `CanvasRenderer`, which is an
internal class not accessible from outside the package.

---

## Decision

Attach a `keydown` listener to `window` in capture phase. On `Ctrl` or `Meta`
plus `=`/`+`, `-`, or `0`: call `preventDefault()` and `stopPropagation()`,
update `currentFontSize`, write to `term.options.fontSize`, and call `fit()`.

Comment thread
jesse23 marked this conversation as resolved.
```ts
let currentFontSize = Math.min(32, Math.max(6, config.fontSize));
window.addEventListener(
'keydown',
(e: KeyboardEvent) => {
if (!e.ctrlKey && !e.metaKey) return;
const zoomIn = e.code === 'Equal' || e.code === 'NumpadAdd';
const zoomOut = (e.code === 'Minus' && !e.shiftKey) || e.code === 'NumpadSubtract';
const zoomReset = (e.code === 'Digit0' && !e.shiftKey) || e.code === 'Numpad0';
if (!zoomIn && !zoomOut && !zoomReset) return;
e.preventDefault();
e.stopPropagation();
if (zoomIn) currentFontSize = Math.min(32, currentFontSize + 1);
else if (zoomOut) currentFontSize = Math.max(6, currentFontSize - 1);
else currentFontSize = Math.min(32, Math.max(6, config.fontSize));
term.options.fontSize = currentFontSize;
fit();
},
{ capture: true },
);
```

### Why `e.code` for key detection

`KeyboardEvent.key` is layout-dependent: it reflects the character produced with
the current keyboard locale and active modifiers. On a US layout `Ctrl+=` yields
`e.key === '='`; with Shift held it yields `'+'`; on non-US layouts the same
physical key may yield yet another character. Matching `e.key` therefore produces
inconsistent behaviour across keyboards.

`KeyboardEvent.code` identifies the physical key regardless of locale or shift
state. `e.code === 'Equal'` matches the `=`/`+` key on any layout — so both
`Ctrl+=` (no Shift) and `Ctrl++` (Shift+`=`) trigger zoom-in, consistent with
VS Code. `!e.shiftKey` guards on `Minus` and `Digit0` prevent `_` and `)` from
accidentally firing zoom-out and zoom-reset.

Numpad variants (`NumpadAdd`, `NumpadSubtract`, `Numpad0`) are included so users
with a numeric keypad get the same shortcuts without extra configuration.

### Why `term.options.fontSize = n` instead of a direct renderer call

`term.renderer` is typed `CanvasRenderer | undefined` where `CanvasRenderer` is
not exported. Accessing it at runtime produces a TypeScript error and couples the
call site to an undocumented internal.

The correct path is the xterm.js options-proxy pattern already present in
ghostty-web: `term.options` is a `Proxy` whose setter calls
`this.handleOptionChange(key, value)` for every assigned property. For
`'fontSize'` the handler executes:

```js
// ghostty-web dist/ghostty-web.js — Terminal.handleOptionChange
this.renderer && (this.renderer.setFontSize(this.options.fontSize), this.handleFontChange());
```

Assigning `term.options.fontSize = n` therefore triggers the full internal
update — renderer font change and canvas resize — through the documented public
surface.

### Why `window` and not `container`

Zoom is a viewport-level gesture. Users expect it to work whether the terminal
div has focus or not, exactly as browser page-zoom does. Attaching to `window`
ensures the shortcuts fire regardless of which element is focused.

### Why `{ capture: true }`

ghostty-web registers its own `keydown` listener on the container during
`term.open()`. Without capture, the browser dispatches the event to the container
listener first; ghostty-web may consume it or forward characters to the PTY
before the zoom handler sees it. Capture phase guarantees interception before
any bubbling-phase listener.

`preventDefault()` in the same handler stops the browser page-zoom. Without it,
both the terminal zoom and the page zoom would fire on the same keystroke.

`stopPropagation()` prevents the event from reaching ghostty-web's own `keydown`
listener on the container. Without it, ghostty-web would still see the event and
forward the literal character (`=`, `-`, `0`) to the PTY as typed input.

### Why `fit()` after every zoom step

`term.options.fontSize = n` resizes the canvas pixel buffer to match the new
font metrics. The canvas dimensions change, so the gap between canvas and
container changes too. `fit()` re-measures and redistributes the gap as padding
(see ADR 022), keeping the canvas centred at the new size.

### Font size bounds

| Bound | Value | Reason |
|-------|-------|--------|
| Minimum | 6 | Below ~6px glyphs are illegible; ghostty-web may also produce rendering artefacts |
| Maximum | 32 | Covers all practical use; beyond this a single line barely fits the viewport |
| Reset | `clamp(config.fontSize, 6, 32)` | Returns to the server-configured default, clamped so a config value outside the range does not invert the zoom direction |

---

## Considered Options

### Option A: CSS `transform: scale()` on the canvas

Apply a CSS scale transform to the canvas element to visually enlarge the
terminal without touching ghostty-web.

Rejected — CSS scaling a canvas scales the pixel buffer: rendered glyphs become
blurry (identical rejection reason as in ADR 022, Option A). Additionally, the
PTY cols/rows do not change, so the terminal application still thinks the
viewport is the original size and wraps lines accordingly.

### Option B: `term.renderer?.setFontSize(n)` direct call

Call `setFontSize` on `term.renderer` directly.

Rejected — `CanvasRenderer` is not exported from ghostty-web. TypeScript reports
`Property 'setFontSize' does not exist on type 'Terminal'` at compile time.
Casting through `any` would suppress the error but silently break if the
internal structure changes.

### Option C: `term.options.fontSize = n` via options proxy (chosen)

Use the documented xterm.js-compatible options assignment path.

Accepted — type-safe, uses the public API surface, and routes through
ghostty-web's own `handleOptionChange` so all internal side-effects (renderer
update, canvas resize) are handled consistently.

---

## Consequences

- In-session font zoom works with `Ctrl/Cmd` + `=`/`-`/`0`, matching VS Code
and native terminal emulator conventions.
- Browser page-zoom is suppressed on those key combinations for the lifetime of
the page. Users who want browser zoom must use the View menu or a different
shortcut.
- Font size is not persisted across sessions. Reload returns to `config.fontSize`.
- The zoom range (6–32) is hardcoded. A future config option
(`fontSizeMin`, `fontSizeMax`) could make it user-adjustable.

## Related Decisions

- [ADR 022 — Canvas gap fill via measured padding](022.client.canvas-fill.md):
`fit()` is called after every zoom step to recentre the canvas at the new size.
- [ADR 018 — Key-bindings config support](018.key-bindings.config-support.md):
zoom shortcuts bypass the configurable key-binding system intentionally — they
are a client-side UI gesture, not PTY input.
20 changes: 19 additions & 1 deletion docs/specs/client.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# SPEC: Client

**Last Updated:** 2026-03-27
**Last Updated:** 2026-03-31

---

Expand Down Expand Up @@ -111,6 +111,22 @@ A capture-phase `keydown` listener on the terminal container fires before ghostt

See [key-bindings spec](key-bindings.md) for the binding object schema and examples.

## Font-size Zoom

`Ctrl/Cmd` + `=`, `-`, or `0` adjust the terminal font size in-session, matching VS Code and native terminal conventions. These shortcuts are **not configurable** — they are a fixed client-side UI gesture, not PTY input, and cannot be overridden via `keyboardBindings`.

| Key | Action |
|-----|--------|
| `Ctrl/Cmd` + `=` or `+` | Increase font size by 1 (max 32) |
| `Ctrl/Cmd` + `-` | Decrease font size by 1 (min 6) |
| `Ctrl/Cmd` + `0` | Reset to `config.fontSize` (clamped to 6–32) |

Numpad `+`, `-`, and `0` are also recognised.

A capture-phase `keydown` listener on `window` fires first. It calls `preventDefault()` to suppress browser page-zoom and `stopPropagation()` to prevent the key from reaching ghostty-web's PTY input path. Font size is not persisted — reload returns to `config.fontSize`.

See [ADR 023](../adrs/023.client.font-size-zoom.md).

## Copy Behavior

Controlled by two config keys from `GET /api/config`:
Expand Down Expand Up @@ -148,3 +164,5 @@ When a session ends (shell exits → WS close code `4001`) or the server stops (
| Non-text paste | Ctrl+V with no `text/plain` in clipboard forwards `\x16` to PTY; TUI apps read non-text content via their native OS clipboard API | [ADR 014](../adrs/014.client.image-paste.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) | ✅ |
| Font-size zoom | `Ctrl/Cmd` + `=`/`-`/`0` adjust terminal font size in-session; not configurable; same shortcuts as VS Code | [ADR 023](../adrs/023.client.font-size-zoom.md) | ✅ |
27 changes: 27 additions & 0 deletions src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,33 @@ container.addEventListener(
{ capture: true },
);

// Intercept Ctrl/Cmd +/- to resize the font without Shift, matching VS Code.
// Uses window so it fires regardless of focus, and preventDefault stops the
// browser's own page-zoom from triggering at the same time. stopPropagation
// prevents ghostty-web from forwarding the key as literal PTY input.
// e.code is used for physical key identity, independent of keyboard layout.
// currentFontSize is clamped to [6, 32] on init so a config value outside
// that range never inverts the zoom direction on the first keypress.
let currentFontSize = Math.min(32, Math.max(6, config.fontSize));
window.addEventListener(
'keydown',
(e: KeyboardEvent) => {
if (!e.ctrlKey && !e.metaKey) return;
const zoomIn = e.code === 'Equal' || e.code === 'NumpadAdd';
const zoomOut = (e.code === 'Minus' && !e.shiftKey) || e.code === 'NumpadSubtract';
const zoomReset = (e.code === 'Digit0' && !e.shiftKey) || e.code === 'Numpad0';
if (!zoomIn && !zoomOut && !zoomReset) return;
e.preventDefault();
e.stopPropagation();
if (zoomIn) currentFontSize = Math.min(32, currentFontSize + 1);
else if (zoomOut) currentFontSize = Math.max(6, currentFontSize - 1);
else currentFontSize = Math.min(32, Math.max(6, config.fontSize));
term.options.fontSize = currentFontSize;
fit();
},
{ capture: true },
);

// Forward terminal keystrokes and input to the PTY over WebSocket.
term.onData((data: string) => {
if (ws && ws.readyState === WebSocket.OPEN) {
Expand Down
Loading