Skip to content

feat(settings): Ctrl/Cmd + and - step the window zoom - #484

Open
Adam-Dalloul wants to merge 4 commits into
xintaofei:mainfrom
Adam-Dalloul:feat/zoom-keyboard-shortcuts
Open

feat(settings): Ctrl/Cmd + and - step the window zoom#484
Adam-Dalloul wants to merge 4 commits into
xintaofei:mainfrom
Adam-Dalloul:feat/zoom-keyboard-shortcuts

Conversation

@Adam-Dalloul

@Adam-Dalloul Adam-Dalloul commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Settings → Window zoom is a discrete menu. This binds the usual keys to those same rungs:

  • Ctrl/Cmd + or = zooms in one step
  • Ctrl/Cmd - zooms out one step
  • Ctrl/Cmd 0 resets to 100%

Rungs are 80 / 90 / 100 / 110 / 125 / 150 / 175 / 200 / 250 / 300. Capture-phase so the webview does not eat the keys as page zoom. Same persist path as the Settings menu.

Also listed under Settings → Shortcuts.

@Adam-Dalloul
Adam-Dalloul force-pushed the feat/zoom-keyboard-shortcuts branch from 6933a3a to 7005a5a Compare August 16, 2026 17:03
Same discrete rungs as Settings and Ctrl/Cmd +/-. Adds 175, 200, 250,
and 300 so the top step is 300% instead of 150%.
@Adam-Dalloul
Adam-Dalloul force-pushed the feat/zoom-keyboard-shortcuts branch from 7005a5a to 1876641 Compare August 17, 2026 01:05
@xintaofei

Copy link
Copy Markdown
Owner

Thanks for this — ⌘/Ctrl + - 0 is a genuinely missing affordance, and I like that you reused the existing discrete rungs instead of inventing a second, continuous zoom model. Putting the listener in AppearanceProvider is also the right call: it's mounted from the root layout, so it covers the settings and pet windows and web mode, whereas WorkspaceChromeController would have missed them. Extracting stepZoom with a unit test and remembering to keep VALID_ZOOMS in the pre-paint inline script in sync are both nice touches — that second one is easy to miss.

I ran the branch: tsc --noEmit, ESLint and Prettier are clean, and the full suite is green (313 files / 4198 tests). Hygiene is good.

There's one thing I'd like fixed before merging, plus a few smaller ones. Details below.


Blocking

1. "Zoom in" / "Zoom out" show up as rebindable in Settings, but nothing reads the binding

zoom_in / zoom_out / zoom_reset are added to SHORTCUT_DEFINITIONS and DEFAULT_SHORTCUTS, so ShortcutSettings renders three rows with the full record / conflict-check / reset-to-default UI. But at runtime only zoom_reset is actually consulted (appearance-provider.tsx:754, :782) — zoom in/out go through the hardcoded isZoomInShortcutEvent / isZoomOutShortcutEvent, which take no binding argument (appearance-provider.tsx:772, :777).

So if a user rebinds "Zoom in" to, say, ⌘⇧Z: the value is normalized, persisted, the button label updates and a success toast fires — and then nothing happens when they press it, while ⌘= keeps zooming. Worse, rebinding frees mod+= in the settings conflict checker, so they can now legitimately assign ⌘= to another action and get both behaviors (the zoom handler only calls preventDefault(), not stopPropagation()).

Two ways out, either is fine:

  • Wire them up: match against shortcuts.zoom_in / shortcuts.zoom_out like every other action. If you want to keep the shift/numpad tolerance, put it in the matcher for the bound key (treat =+ and -_ as the same physical key) rather than in a separate predicate that bypasses the binding.
  • Or make them honest: drop zoom_in / zoom_out from SHORTCUT_DEFINITIONS, keep them fixed, and just document them. zoom_reset is already wired correctly and can stay.

2. if (event.repeat) return runs before the match, so held keys aren't prevented

appearance-provider.tsx:771. Two consequences:

  • Holding ⌘- doesn't repeat, so it's 7 taps to walk 100% → 300%. Every other app repeats here.
  • In server/browser mode (codeg-server / Docker / remote access) the un-prevented repeat events reach the browser's own zoom, which then compounds on top of the app's rem zoom.

Moving the repeat guard after the match — and still calling preventDefault() — fixes both. Or just allow repeats.

3. The terminal loses ⌃_ (readline/emacs undo)

The listener is a bare window capture handler with no target check, and mod in this codebase means Ctrl or Cmd (hasMod = event.metaKey || event.ctrlKey). isZoomOutShortcutEvent also deliberately ignores shiftKey, so ⌃⇧- (= ⌃_) matches too.

Net effect with the integrated terminal focused — on macOS as well as Linux/Windows — pressing ⌃_ sends undo to readline and steps the app zoom. Both fire.

terminal-context.tsx already has the pattern for this (isInTerminalRegion, terminal-context.tsx:304); reusing it here would keep terminal muscle memory intact. I know ⌃K-vs-toggle_search has the same shape today, so this isn't a regression you introduced — but zoom keys collide with a lot more shell muscle memory than ⌘K does, so I'd rather not widen it.

4. Recording any shortcut in Settings also zooms the UI

The recorder in shortcut-settings.tsx:59 calls event.stopPropagation(), but both it and the zoom handler are capture listeners on the same window node — stopPropagation() doesn't stop sibling listeners on the same target. So while the "Record" button is armed and the user presses ⌘= or ⌘-, the whole settings window zooms underneath them.

(And per #5 below, ⌘⇧= is then rejected as invalid anyway, so they get a zoom plus an error toast.)

5. + can't actually be recorded

shortcutFromKeyboardEvent joins parts with "+", so ⌘⇧= serializes to "mod+shift++"; normalizeShortcut splits on "+" and returns null, so updateShortcut fails and the user gets "invalid shortcut". normalizeShortcut("mod++") === null — I checked.

This one predates the PR, but the PR is what puts a +-shaped row in front of users, so it becomes reachable here.

6. i18n: the 9 non-English locales got English strings

zoom_in / zoom_out / zoom_reset titles and descriptions are English in ar, de, es, fr, ja, ko, pt, zh-CN and zh-TW. The convention here is that new UI strings ship translated in all ten (cf. toggle_custom_style in 4e54708).

Separately, Appearance.zoomLevel.sectionDescription was only extended in en.json, so the shortcut hint is invisible in every other language. Two copy notes on the English while you're in there: it says "Ctrl" only, and on macOS it's ⌘; and "one Settings rung" is a bit opaque as user-facing text.


Non-blocking

  • Split the 300% commit. feat(settings): raise window zoom to 300% widens ZOOM_LEVELS from 6 rungs to 10 and is an independent product decision from the keybinding. I checked and it's less risky than I first assumed — Monaco (file-workspace-panel.tsx:2291) and xterm (terminal-view.tsx:456) both scale their font size off zoomLevel, and the Rust side already clamps 50–300 — but I'd still rather see it land on its own with a screenshot at 300%.
  • The event.code === "Minus" / "Equal" fallback misfires on non-US layouts. { key: ")", code: "Minus", ctrlKey: true } returns zoom-out (that's the AZERTY shape). The event.key branch already covers the real - / + / = on those layouts, so I'd narrow the code fallback to the numpad keys or drop it.
  • matchShortcutEvent(event, "mod++") in isZoomInShortcutEvent is deadnormalizeShortcut("mod++") is null, so it's always false. Harmless (the fallback catches +), but it means the ⌃⇧+ test is passing through a different branch than it looks like.
  • The three new SPECIAL_KEY_ALIASES (keyboard-shortcuts.ts:135-137) are inert on the webviews we ship on: normalizeKeyToken returns early for single characters, and Chromium/WebKit report "+" / "-" / "=", never "Add" / "Subtract" / "Equal" ("Equal" is a code, never a key). And normalizeShortcut("mod+add")"mod++", which the parser then rejects — so the round-trip is broken. I'd drop them, or fix them along with feat: Markdown 预览模式(文件面板) #5.
  • zoomLevelRef.current = zoomLevel in the render body (appearance-provider.tsx:481). No demonstrated bug, but terminal-view.tsx:449 does the same sync inside an effect — worth matching.
  • No isComposing guard on the global listener, so an IME's own Ctrl/Cmd +/- commands can be intercepted. Probably falls out of whatever target policy 如何让ai写的代码符合规范 #3 gets.
  • Tests. keyboard-shortcuts.test.ts:134 and :138 are the same assertion twice. More useful coverage would be: the configurable path (rebind → new binding fires, old one doesn't), repeat events, and one non-US-layout event.
  • Docs. The canonical shortcut table lives in the docs site (guide/workspace.md and its zh/ twin), so the three new rows want a companion PR there.

None of this is structural — the shape of the change is right, it's mostly about making the Settings rows tell the truth and picking a target policy for the keys. Happy to look again once #1 is sorted; the rest can come along with it or as follow-ups if you'd prefer. Thanks again for taking this on! 🙏

Zoom in/out now match the Settings bindings instead of a hardcoded
Ctrl+= / Ctrl+-. Held keys repeat and still preventDefault. The
integrated terminal keeps Ctrl+_, recording a shortcut no longer
zooms the window, and + survives normalizeShortcut.
@Adam-Dalloul

Copy link
Copy Markdown
Contributor Author

Thanks for catching that. The Settings rows for zoom in/out weren't actually wired, which is on me.

Follow-up is up. Those bindings drive the listener now, and = / + and - / _ still count as the same key. Held keys repeat and still preventDefault so the browser doesn't also zoom. Terminal keeps Ctrl+_, recording a shortcut no longer zooms the settings window, and + can actually be recorded. Strings are translated, and the zoom hint no longer says Ctrl-only.

Also dropped the dead Add/Subtract/Equal aliases and limited the code fallback to the numpad so AZERTY Ctrl+) doesn't zoom out.

300% is still its own commit here. Happy to split it if you'd rather land that separately.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants