diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index 5f5cbaa1d7fc..60fb1b0e2aa5 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -246,4 +246,38 @@ describe("DesktopApplicationMenu", () => { assert.equal(yield* Deferred.await(selectedAction), "zoom-in"); }), ); + + // The keypad sends its own key codes, so without these the number-row + // shortcuts are the only way to zoom. They stay hidden so the View menu + // lists each command once. + it.effect("zooms from numeric keypad accelerators without listing them twice", () => + Effect.gen(function* () { + for (const [accelerator, expected] of [ + ["CmdOrCtrl+numadd", "zoom-in"], + ["CmdOrCtrl+numsub", "zoom-out"], + ["CmdOrCtrl+num0", "zoom-reset"], + ] as const) { + const selectedAction = yield* Deferred.make(); + const applicationMenuTemplate = + yield* Deferred.make(); + + yield* configureMenu(selectedAction, applicationMenuTemplate); + + const template = yield* Deferred.await(applicationMenuTemplate); + const viewMenu = template.find((item) => item.label === "View"); + if (!viewMenu || !Array.isArray(viewMenu.submenu)) { + throw new Error("Expected View menu submenu to be an array."); + } + const item = viewMenu.submenu.find((entry) => entry.accelerator === accelerator); + assert.isDefined(item); + assert.equal(item.visible, false); + if (typeof item.click !== "function") { + throw new Error(`Expected ${accelerator} menu item to have a click handler.`); + } + + item.click({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent); + assert.equal(yield* Deferred.await(selectedAction), expected); + } + }), + ); }); diff --git a/apps/desktop/src/window/DesktopApplicationMenu.ts b/apps/desktop/src/window/DesktopApplicationMenu.ts index d3b8db895352..fe2ce5ba7339 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.ts @@ -249,6 +249,26 @@ export const make = Effect.gen(function* () { click: zoomClick("in"), }, { label: "Zoom Out", accelerator: "CmdOrCtrl+-", click: zoomClick("out") }, + // The numeric keypad sends its own key codes, so it needs its own + // accelerators; hidden so the menu lists each command once. + { + label: "Zoom In", + accelerator: "CmdOrCtrl+numadd", + visible: false, + click: zoomClick("in"), + }, + { + label: "Zoom Out", + accelerator: "CmdOrCtrl+numsub", + visible: false, + click: zoomClick("out"), + }, + { + label: "Actual Size", + accelerator: "CmdOrCtrl+num0", + visible: false, + click: zoomClick("reset"), + }, { type: "separator" }, { role: "togglefullscreen" }, ], diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index ab3405b34aa8..43f2544f013d 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -803,6 +803,43 @@ describe("DesktopWindow", () => { }), ); + // Ctrl+wheel and pinch never reach the menu accelerators: Chromium reports + // them as zoom-changed on the window's webContents and leaves the zoom to + // the app, so the same path (and preview restore) has to run from there. + it.effect("zooms the app from Ctrl+wheel like the menu does", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const previewZoomReapplies: number[] = []; + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + previewZoomReapplies, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const zoomChanged = fakeWindow.webContentsListeners.get("zoom-changed"); + if (!zoomChanged) { + return yield* Effect.die("zoom-changed listener was not registered"); + } + zoomChanged({}, "in"); + zoomChanged({}, "out"); + zoomChanged({}, "out"); + + assert.deepEqual( + fakeWindow.setZoomLevel.mock.calls.map(([level]) => level), + [0.5, 0, -0.5], + ); + assert.deepEqual(previewZoomReapplies, [0.5, 0, -0.5]); + }).pipe(Effect.provide(layer)); + }), + ); + it.effect("uses the persisted main window bounds when opening the window", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 754de3caa727..509c3c536b7d 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -362,6 +362,22 @@ export const make = Effect.gen(function* () { const currentMainWindow = electronWindow.currentMainOrFirst.pipe(Effect.flatMap(withoutSplash)); const focusedMainWindow = electronWindow.focusedMainOrFirst.pipe(Effect.flatMap(withoutSplash)); + // Same step size as the Electron zoomIn/zoomOut menu roles. Chromium pushes + // the new level down to embedded guests, which would zoom the previewed page + // along with the app UI. The preview browser keeps its own zoom, so put each + // guest back where the preview left it. + const zoomWindow = Effect.fn("desktop.window.zoomWindow")(function* ( + window: Electron.BrowserWindow, + direction: MainWindowZoomDirection, + ) { + const webContents = window.webContents; + webContents.setZoomLevel( + direction === "reset" ? 0 : webContents.getZoomLevel() + (direction === "in" ? 0.5 : -0.5), + ); + if (environment.platform === "darwin") syncMacosWindowButtons(window); + yield* previewManager.reapplyZoom(); + }); + const createWindow = Effect.fn("desktop.window.createWindow")(function* (): Effect.fn.Return< Electron.BrowserWindow, DesktopWindowError @@ -665,6 +681,14 @@ export const make = Effect.gen(function* () { window.webContents.on("input-event", (_event, input) => { if (input.type === "gestureScrollEnd") window.webContents.send(TRACKPAD_SCROLL_END_CHANNEL); }); + // Ctrl+wheel asks for zoom through this event rather than an accelerator, + // and Chromium leaves applying it to the app. Only the main window's own + // webContents emits it; a preview guest under the pointer gets its own + // event and keeps its own zoom. + window.webContents.on("zoom-changed", (_event, direction) => { + if (window.isDestroyed()) return; + runFork(zoomWindow(window, direction)); + }); window.on("page-title-updated", (event) => { event.preventDefault(); @@ -1010,16 +1034,7 @@ export const make = Effect.gen(function* () { if (Option.isNone(window) || window.value.isDestroyed()) { return; } - const webContents = window.value.webContents; - // Same step size as the Electron zoomIn/zoomOut menu roles. - webContents.setZoomLevel( - direction === "reset" ? 0 : webContents.getZoomLevel() + (direction === "in" ? 0.5 : -0.5), - ); - if (environment.platform === "darwin") syncMacosWindowButtons(window.value); - // Chromium pushes the new level down to embedded guests, which would zoom - // the previewed page along with the app UI. The preview browser keeps its - // own zoom, so put each guest back where the preview left it. - yield* previewManager.reapplyZoom(); + yield* zoomWindow(window.value, direction); }), syncAppearance: Effect.gen(function* () { const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors;