-
Notifications
You must be signed in to change notification settings - Fork 0
feat: runtime font-size zoom via Ctrl/Cmd +/-/0 #25
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
a34ca1b
feat: add Show HN entry for webtty with usage details
a7cbdb4
docs: document file DnD path analysis and move HN post to docs
ddee03a
feat: implement measured padding to fill canvas gaps in terminal
8c9c6ae
fix: guard ResizeObserver loop and clamp negative gap
b0690e2
feat: add runtime font-size zoom functionality via Ctrl/Cmd +/- short…
544cd8c
fix: stop zoom key propagation to prevent = and - leaking to PTY
76d6416
Merge remote-tracking branch 'origin/main' into jesse_dnd
7876c5e
docs: update last updated date and add font-size zoom functionality d…
c1f2ce8
fix: address Copilot review — e.code key detection, bounds clamp, ADR…
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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()`. | ||
|
|
||
| ```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. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.