From e42ae3f0f0690568a09d66f1e7887aa697c263af Mon Sep 17 00:00:00 2001 From: Matheus Nogueira Date: Mon, 6 Apr 2026 20:42:55 -0300 Subject: [PATCH] Fix terminal zoom shortcuts on Linux layouts (#347) --- src/main/index.ts | 9 ++ src/main/menu/register-app-menu.ts | 28 +++++-- src/main/window/createMainWindow.test.ts | 83 +++++++++++++++++++ src/main/window/createMainWindow.ts | 37 ++++++++- .../src/components/settings/ShortcutsPane.tsx | 15 ++++ 5 files changed, 165 insertions(+), 7 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index fe457bb5831..68674635efe 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -67,6 +67,15 @@ app.whenReady().then(async () => { onCheckForUpdates: () => checkForUpdatesFromMenu(), onOpenSettings: () => { mainWindow?.webContents.send('ui:openSettings') + }, + onZoomIn: () => { + mainWindow?.webContents.send('terminal:zoom', 'in') + }, + onZoomOut: () => { + mainWindow?.webContents.send('terminal:zoom', 'out') + }, + onZoomReset: () => { + mainWindow?.webContents.send('terminal:zoom', 'reset') } }) registerCoreHandlers(store, runtime) diff --git a/src/main/menu/register-app-menu.ts b/src/main/menu/register-app-menu.ts index f4e43953a1e..19707622789 100644 --- a/src/main/menu/register-app-menu.ts +++ b/src/main/menu/register-app-menu.ts @@ -3,11 +3,17 @@ import { Menu, app } from 'electron' type RegisterAppMenuOptions = { onOpenSettings: () => void onCheckForUpdates: () => void + onZoomIn: () => void + onZoomOut: () => void + onZoomReset: () => void } export function registerAppMenu({ onOpenSettings, - onCheckForUpdates + onCheckForUpdates, + onZoomIn, + onZoomOut, + onZoomReset }: RegisterAppMenuOptions): void { const template: Electron.MenuItemConstructorOptions[] = [ { @@ -53,19 +59,31 @@ export function registerAppMenu({ { role: 'toggleDevTools' }, { type: 'separator' }, { - label: 'Actual Size', + label: 'Reset Size', accelerator: 'CmdOrCtrl+0', - registerAccelerator: false + // Why: Some keyboard layouts/platforms intercept Cmd/Ctrl+zoom chords + // before before-input-event fires. Binding the menu accelerator gives + // us a reliable cross-platform fallback path. + click: () => onZoomReset() }, { label: 'Zoom In', accelerator: 'CmdOrCtrl+=', - registerAccelerator: false + click: () => onZoomIn() }, { label: 'Zoom Out', accelerator: 'CmdOrCtrl+-', - registerAccelerator: false + click: () => onZoomOut() + }, + { + label: 'Zoom Out (Shift Alias)', + // Why: Some Linux keyboard layouts report the top-row minus chord as + // an underscore accelerator. Keep this hidden alias so Ctrl+- and + // Ctrl+_ can both route to terminal zoom out. + accelerator: 'CmdOrCtrl+_', + visible: false, + click: () => onZoomOut() }, { type: 'separator' }, { role: 'togglefullscreen' } diff --git a/src/main/window/createMainWindow.test.ts b/src/main/window/createMainWindow.test.ts index 96e2aac26a8..8e7bc2d9b8c 100644 --- a/src/main/window/createMainWindow.test.ts +++ b/src/main/window/createMainWindow.test.ts @@ -96,4 +96,87 @@ describe('createMainWindow', () => { expect(fileNavigationPreventDefault).toHaveBeenCalledTimes(1) expect(openExternalMock).toHaveBeenCalledTimes(4) }) + + it('supports all minus key variants for terminal zoom out', () => { + const windowHandlers: Record void> = {} + const webContents = { + on: vi.fn((event, handler) => { + windowHandlers[event] = handler + }), + setZoomLevel: vi.fn(), + setWindowOpenHandler: vi.fn(), + send: vi.fn() + } + const browserWindowInstance = { + webContents, + on: vi.fn(), + maximize: vi.fn(), + show: vi.fn(), + loadFile: vi.fn(), + loadURL: vi.fn() + } + browserWindowMock.mockImplementation(function () { + return browserWindowInstance + }) + + createMainWindow(null) + + const beforeInputEvent = windowHandlers['before-input-event'] + + for (const input of [ + { type: 'keyDown', control: true, meta: false, alt: false, key: '-' }, + { type: 'keyDown', control: true, meta: false, alt: false, key: '_' }, + { type: 'keyDown', control: true, meta: false, alt: false, key: 'Minus' }, + { type: 'keyDown', control: true, meta: false, alt: false, key: 'Subtract' }, + { type: 'keyDown', control: true, meta: false, alt: false, key: '', code: 'Minus' }, + { type: 'keyDown', control: true, meta: false, alt: false, key: '', code: 'NumpadSubtract' } + ]) { + const preventDefault = vi.fn() + beforeInputEvent({ preventDefault } as never, input as never) + expect(preventDefault).toHaveBeenCalledTimes(1) + } + + expect(webContents.send).toHaveBeenCalledTimes(6) + expect(webContents.send).toHaveBeenNthCalledWith(1, 'terminal:zoom', 'out') + expect(webContents.send).toHaveBeenNthCalledWith(2, 'terminal:zoom', 'out') + expect(webContents.send).toHaveBeenNthCalledWith(3, 'terminal:zoom', 'out') + expect(webContents.send).toHaveBeenNthCalledWith(4, 'terminal:zoom', 'out') + expect(webContents.send).toHaveBeenNthCalledWith(5, 'terminal:zoom', 'out') + expect(webContents.send).toHaveBeenNthCalledWith(6, 'terminal:zoom', 'out') + }) + + it('routes Electron zoom command events to terminal zoom', () => { + const windowHandlers: Record void> = {} + const webContents = { + on: vi.fn((event, handler) => { + windowHandlers[event] = handler + }), + setZoomLevel: vi.fn(), + setWindowOpenHandler: vi.fn(), + send: vi.fn() + } + const browserWindowInstance = { + webContents, + on: vi.fn(), + maximize: vi.fn(), + show: vi.fn(), + loadFile: vi.fn(), + loadURL: vi.fn() + } + browserWindowMock.mockImplementation(function () { + return browserWindowInstance + }) + + createMainWindow(null) + + const onZoomChanged = windowHandlers['zoom-changed'] + const preventDefault = vi.fn() + onZoomChanged({ preventDefault } as never, 'out') + onZoomChanged({ preventDefault } as never, 'in') + + expect(preventDefault).toHaveBeenCalledTimes(2) + expect(webContents.send).toHaveBeenCalledTimes(2) + expect(webContents.send).toHaveBeenNthCalledWith(1, 'terminal:zoom', 'out') + expect(webContents.send).toHaveBeenNthCalledWith(2, 'terminal:zoom', 'in') + }) }) diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index 18839ddbf86..577c329146c 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -25,6 +25,27 @@ function normalizeExternalUrl(rawUrl: string): string | null { } } +function isZoomInShortcut(input: Electron.Input): boolean { + return input.key === '=' || input.key === '+' || input.code === 'NumpadAdd' +} + +function isZoomOutShortcut(input: Electron.Input): boolean { + // Why: Electron reports Cmd/Ctrl+Minus differently across layouts and devices: + // some emit '-' while shifted layouts emit '_', and other layouts/devices + // report symbolic names like "Minus"/"Subtract" in either key or code. + // We accept all known variants so zoom out remains reachable everywhere. + const key = (input.key ?? '').toLowerCase() + const code = (input.code ?? '').toLowerCase() + return ( + key === '-' || + key === '_' || + key.includes('minus') || + key.includes('subtract') || + code.includes('minus') || + code.includes('subtract') + ) +} + export function createMainWindow(store: Store | null): BrowserWindow { const mainWindow = new BrowserWindow({ width: 1200, @@ -103,10 +124,10 @@ export function createMainWindow(store: Store | null): BrowserWindow { return } - if (input.key === '=' || input.key === '+') { + if (isZoomInShortcut(input)) { event.preventDefault() mainWindow.webContents.send('terminal:zoom', 'in') - } else if (input.key === '-') { + } else if (isZoomOutShortcut(input)) { event.preventDefault() mainWindow.webContents.send('terminal:zoom', 'out') } else if (input.key === '0' && !input.shift) { @@ -115,6 +136,18 @@ export function createMainWindow(store: Store | null): BrowserWindow { } }) + mainWindow.webContents.on('zoom-changed', (event, zoomDirection) => { + // Why: Some keyboard layouts/platforms consume Ctrl/Cmd+Minus before + // before-input-event fires, but still emit Electron's zoom command. We + // reroute that command to terminal zoom so zoom-out remains reachable. + event.preventDefault() + if (zoomDirection === 'in') { + mainWindow.webContents.send('terminal:zoom', 'in') + } else if (zoomDirection === 'out') { + mainWindow.webContents.send('terminal:zoom', 'out') + } + }) + // Intercept window close so the renderer can show a confirmation dialog // when terminals with running processes would be killed. The renderer // replies with 'window:confirm-close' to proceed, or does nothing to cancel. diff --git a/src/renderer/src/components/settings/ShortcutsPane.tsx b/src/renderer/src/components/settings/ShortcutsPane.tsx index e8f2910425b..dbbcefe20e1 100644 --- a/src/renderer/src/components/settings/ShortcutsPane.tsx +++ b/src/renderer/src/components/settings/ShortcutsPane.tsx @@ -72,6 +72,21 @@ const SHORTCUT_GROUP_DEFINITIONS: ShortcutGroupDefinition[] = [ action: 'Toggle Source Control', searchKeywords: ['shortcut', 'source control'], keys: ({ mod, shift }) => [mod, shift, 'G'] + }, + { + action: 'Zoom In', + searchKeywords: ['shortcut', 'zoom', 'in', 'scale'], + keys: ({ mod, shift }) => (mod === 'Ctrl' ? [mod, shift, '+'] : [mod, '+']) + }, + { + action: 'Zoom Out', + searchKeywords: ['shortcut', 'zoom', 'out', 'scale'], + keys: ({ mod, shift }) => (mod === 'Ctrl' ? [mod, shift, '-'] : [mod, '-']) + }, + { + action: 'Reset Size', + searchKeywords: ['shortcut', 'zoom', 'reset', 'size', 'actual'], + keys: ({ mod }) => [mod, '0'] } ] },