diff --git a/docs/adrs/023.client.font-size-zoom.md b/docs/adrs/023.client.font-size-zoom.md new file mode 100644 index 0000000..96385da --- /dev/null +++ b/docs/adrs/023.client.font-size-zoom.md @@ -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()`. + +```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. diff --git a/docs/specs/client.md b/docs/specs/client.md index 5f3f088..c447819 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -1,6 +1,6 @@ # SPEC: Client -**Last Updated:** 2026-03-27 +**Last Updated:** 2026-03-31 --- @@ -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`: @@ -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) | ✅ | diff --git a/src/client/index.ts b/src/client/index.ts index 1184b03..8cef13b 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -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) {