diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index f04a49f82af..c700b6f4712 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -67,7 +67,10 @@ const desktopUpdatesLayer = Layer.succeed(DesktopUpdates.DesktopUpdates, { install: Effect.die("unexpected install"), } satisfies DesktopUpdates.DesktopUpdates["Service"]); -const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred) => +const makeDesktopWindowLayer = ( + selectedAction: Deferred.Deferred, + appearanceSynced?: Deferred.Deferred, +) => Layer.succeed(DesktopWindow.DesktopWindow, { createMain: Effect.die("unexpected createMain"), ensureMain: Effect.die("unexpected ensureMain"), @@ -79,7 +82,9 @@ const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred) => handleBackendNotReady: Effect.void, flushMainWindowBounds: Effect.void, dispatchMenuAction: (action) => Deferred.succeed(selectedAction, action).pipe(Effect.asVoid), - syncAppearance: Effect.void, + syncAppearance: appearanceSynced + ? Deferred.succeed(appearanceSynced, undefined).pipe(Effect.asVoid) + : Effect.void, } satisfies DesktopWindow.DesktopWindow["Service"]); const makeElectronMenuLayer = ( @@ -136,4 +141,57 @@ describe("DesktopApplicationMenu", () => { assert.equal(yield* Deferred.await(selectedAction), "open-settings"); }), ); + + it.effect("updates zoom and syncs window appearance from the native View menu", () => + Effect.gen(function* () { + const selectedAction = yield* Deferred.make(); + const appearanceSynced = yield* Deferred.make(); + const applicationMenuTemplate = + yield* Deferred.make(); + + yield* Effect.gen(function* () { + const menu = yield* DesktopApplicationMenu.DesktopApplicationMenu; + yield* menu.configure; + }).pipe( + Effect.provide( + DesktopApplicationMenu.layer.pipe( + Layer.provideMerge(makeElectronMenuLayer(applicationMenuTemplate)), + Layer.provideMerge(makeDesktopWindowLayer(selectedAction, appearanceSynced)), + Layer.provideMerge(desktopUpdatesLayer), + Layer.provideMerge(electronDialogLayer), + Layer.provideMerge(electronAppLayer), + Layer.provideMerge( + DesktopEnvironment.layer(environmentInput).pipe( + Layer.provide(Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({}))), + ), + ), + ), + ), + ); + + const template = yield* Deferred.await(applicationMenuTemplate); + const viewMenu = template.find((item) => item.label === "View"); + assert.isDefined(viewMenu); + if (!Array.isArray(viewMenu.submenu)) { + throw new Error("Expected View menu submenu to be an array."); + } + const zoomInItem = viewMenu.submenu.find( + (item) => item.label === "Zoom In" && item.visible !== false, + ); + assert.isDefined(zoomInItem); + if (typeof zoomInItem.click !== "function") { + throw new Error("Expected Zoom In menu item to have a click handler."); + } + + const webContents = { zoomLevel: 1 }; + zoomInItem.click( + {} as Electron.MenuItem, + { webContents } as Electron.BrowserWindow, + {} as KeyboardEvent, + ); + + yield* Deferred.await(appearanceSynced); + assert.equal(webContents.zoomLevel, 1.5); + }), + ); }); diff --git a/apps/desktop/src/window/DesktopApplicationMenu.ts b/apps/desktop/src/window/DesktopApplicationMenu.ts index a52707627b0..5a93572d8b1 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.ts @@ -99,6 +99,7 @@ const handleCheckForUpdatesMenuClick = Effect.gen(function* () { export const make = Effect.gen(function* () { const electronApp = yield* ElectronApp.ElectronApp; const electronMenu = yield* ElectronMenu.ElectronMenu; + const desktopWindow = yield* DesktopWindow.DesktopWindow; const environment = yield* DesktopEnvironment.DesktopEnvironment; const appName = yield* electronApp.name; const context = yield* Effect.context(); @@ -120,6 +121,53 @@ export const make = Effect.gen(function* () { ); }; + const zoomClick = + ( + action: string, + updateZoomLevel: (zoomLevel: number) => number, + ): NonNullable => + (_menuItem, window) => { + if (!window || !("webContents" in window)) { + return; + } + + const webContents = window.webContents as Electron.WebContents; + webContents.zoomLevel = updateZoomLevel(webContents.zoomLevel); + runMenuEffect(action, desktopWindow.syncAppearance); + }; + + const zoomMenuItems: Electron.MenuItemConstructorOptions[] = + environment.platform === "darwin" + ? [ + { role: "resetZoom" }, + { role: "zoomIn", accelerator: "CmdOrCtrl+=" }, + { role: "zoomIn", accelerator: "CmdOrCtrl+Plus", visible: false }, + { role: "zoomOut" }, + ] + : [ + { + label: "Actual Size", + accelerator: "CmdOrCtrl+0", + click: zoomClick("reset-zoom", () => 0), + }, + { + label: "Zoom In", + accelerator: "CmdOrCtrl+=", + click: zoomClick("zoom-in", (zoomLevel) => zoomLevel + 0.5), + }, + { + label: "Zoom In", + accelerator: "CmdOrCtrl+Plus", + visible: false, + click: zoomClick("zoom-in", (zoomLevel) => zoomLevel + 0.5), + }, + { + label: "Zoom Out", + accelerator: "CmdOrCtrl+-", + click: zoomClick("zoom-out", (zoomLevel) => zoomLevel - 0.5), + }, + ]; + const configure = Effect.gen(function* () { const checkForUpdatesClick = () => { runMenuEffect("check-for-updates", handleCheckForUpdatesMenuClick); @@ -181,10 +229,7 @@ export const make = Effect.gen(function* () { { role: "forceReload" }, { role: "toggleDevTools" }, { type: "separator" }, - { role: "resetZoom" }, - { role: "zoomIn", accelerator: "CmdOrCtrl+=" }, - { role: "zoomIn", accelerator: "CmdOrCtrl+Plus", visible: false }, - { role: "zoomOut" }, + ...zoomMenuItems, { type: "separator" }, { role: "togglefullscreen" }, ], diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 3cd06cfcf32..80916783aa3 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -58,12 +58,13 @@ const environmentInput = { runningUnderArm64Translation: false, } satisfies DesktopEnvironment.MakeDesktopEnvironmentInput; -function makeFakeBrowserWindow() { +function makeFakeBrowserWindow(initialZoomFactor = 1) { const windowListeners = new Map void>(); const webContentsListeners = new Map void>(); const webContents = { copyImageAt: vi.fn(), getURL: vi.fn(() => "t3code-dev://app/"), + getZoomFactor: vi.fn(() => initialZoomFactor), isLoadingMainFrame: vi.fn(() => false), on: vi.fn((eventName: string, listener: (...args: readonly unknown[]) => void) => { webContentsListeners.set(eventName, listener); @@ -107,6 +108,7 @@ function makeFakeBrowserWindow() { window: window as unknown as Electron.BrowserWindow, getBounds: window.getBounds, getNormalBounds: window.getNormalBounds, + getZoomFactor: webContents.getZoomFactor, isDestroyed: window.isDestroyed, isFullScreen: window.isFullScreen, isMaximized: window.isMaximized, @@ -117,6 +119,7 @@ function makeFakeBrowserWindow() { reload: webContents.reload, send: webContents.send, setAutoHideCursor: window.setAutoHideCursor, + setTitleBarOverlay: window.setTitleBarOverlay, webContentsListeners, windowListeners, }; @@ -158,17 +161,18 @@ const electronThemeLayer = Layer.succeed(ElectronTheme.ElectronTheme, { onUpdated: () => Effect.void, } satisfies ElectronTheme.ElectronTheme["Service"]); -const desktopEnvironmentLayer = DesktopEnvironment.layer(environmentInput).pipe( - Layer.provide( - Layer.mergeAll( - NodeServices.layer, - DesktopConfig.layerTest({ - T3CODE_PORT: "3773", - VITE_DEV_SERVER_URL: "http://127.0.0.1:5733", - }), +const makeDesktopEnvironmentLayer = (platform: NodeJS.Platform = environmentInput.platform) => + DesktopEnvironment.layer({ ...environmentInput, platform }).pipe( + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + DesktopConfig.layerTest({ + T3CODE_PORT: "3773", + VITE_DEV_SERVER_URL: "http://127.0.0.1:5733", + }), + ), ), - ), -); + ); const desktopWindowBoundsEquivalence = Schema.toEquivalence( DesktopAppSettings.DesktopWindowBoundsSchema, @@ -186,6 +190,7 @@ function makeTestLayer(input: { bounds: DesktopAppSettings.DesktopWindowBounds, ) => Effect.Effect; readonly openedExternalUrls?: unknown[]; + readonly platform?: NodeJS.Platform; }) { let desktopSettings = input.desktopSettings ?? DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS; const desktopAppSettingsLayer = Layer.succeed(DesktopAppSettings.DesktopAppSettings, { @@ -244,7 +249,7 @@ function makeTestLayer(input: { Layer.provide( Layer.mergeAll( desktopAssetsLayer, - desktopEnvironmentLayer, + makeDesktopEnvironmentLayer(input.platform), desktopAppSettingsLayer, desktopServerExposureLayer, DesktopState.layer, @@ -343,7 +348,7 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n Layer.provide( Layer.mergeAll( desktopAssetsLayer, - desktopEnvironmentLayer, + makeDesktopEnvironmentLayer(), DesktopAppSettings.layerTest(), desktopServerExposureLayer, electronMenuLayer, @@ -405,6 +410,64 @@ describe("DesktopWindow", () => { ); }); + it("grows the title bar overlay with renderer zoom", () => { + assert.equal(DesktopWindow.resolveTitleBarOverlayHeight(0.8), 40); + assert.equal(DesktopWindow.resolveTitleBarOverlayHeight(1), 40); + assert.equal(DesktopWindow.resolveTitleBarOverlayHeight(1.5), 60); + assert.equal(DesktopWindow.resolveTitleBarOverlayHeight(Number.NaN), 40); + }); + + it.effect("keeps the Linux title bar overlay in sync with renderer zoom", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(1.5); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + platform: "linux", + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const didFinishLoad = fakeWindow.webContentsListeners.get("did-finish-load"); + const beforeInputEvent = fakeWindow.webContentsListeners.get("before-input-event"); + if (!didFinishLoad || !beforeInputEvent) { + return yield* Effect.die("renderer zoom listeners were not registered"); + } + + didFinishLoad(); + assert.deepEqual(fakeWindow.setTitleBarOverlay.mock.calls.at(-1), [{ height: 60 }]); + + yield* desktopWindow.syncAppearance; + assert.deepEqual(fakeWindow.setTitleBarOverlay.mock.calls.at(-1), [ + { + color: "#01000000", + height: 60, + symbolColor: "#1f2937", + }, + ]); + + fakeWindow.getZoomFactor.mockReturnValue(2); + beforeInputEvent( + {}, + { + type: "keyDown", + control: true, + meta: false, + alt: false, + key: "+", + }, + ); + yield* Effect.promise(() => new Promise((resolve) => setImmediate(resolve))); + assert.deepEqual(fakeWindow.setTitleBarOverlay.mock.calls.at(-1), [{ height: 80 }]); + }).pipe(Effect.provide(layer)); + }), + ); + it.effect("does not open a development window until the backend is ready", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 40788009d3b..f4b79668e67 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -184,6 +184,7 @@ export function isRetryableDevelopmentRendererLoadFailure(input: { function getWindowTitleBarOptions( shouldUseDarkColors: boolean, platform: NodeJS.Platform, + zoomFactor = 1, ): WindowTitleBarOptions { if (platform === "darwin") { return { @@ -196,12 +197,41 @@ function getWindowTitleBarOptions( titleBarStyle: "hidden", titleBarOverlay: { color: TITLEBAR_COLOR, - height: TITLEBAR_HEIGHT, + height: resolveTitleBarOverlayHeight(zoomFactor), symbolColor: shouldUseDarkColors ? TITLEBAR_DARK_SYMBOL_COLOR : TITLEBAR_LIGHT_SYMBOL_COLOR, }, }; } +export function resolveTitleBarOverlayHeight(zoomFactor: number): number { + if (!Number.isFinite(zoomFactor) || zoomFactor <= 1) { + return TITLEBAR_HEIGHT; + } + return Math.round(TITLEBAR_HEIGHT * zoomFactor); +} + +function syncWindowTitleBarHeight(window: Electron.BrowserWindow, platform: NodeJS.Platform): void { + if (platform === "darwin" || window.isDestroyed()) { + return; + } + window.setTitleBarOverlay({ + height: resolveTitleBarOverlayHeight(window.webContents.getZoomFactor()), + }); +} + +function isRendererZoomShortcut( + input: Pick, + platform: NodeJS.Platform, +): boolean { + const modifierPressed = platform === "darwin" ? input.meta : input.control; + return ( + input.type === "keyDown" && + modifierPressed && + !input.alt && + ["+", "=", "-", "_", "0", ")"].includes(input.key) + ); +} + function syncWindowAppearance( window: Electron.BrowserWindow, shouldUseDarkColors: boolean, @@ -213,7 +243,11 @@ function syncWindowAppearance( } window.setBackgroundColor(getInitialWindowBackgroundColor(shouldUseDarkColors)); - const { titleBarOverlay } = getWindowTitleBarOptions(shouldUseDarkColors, platform); + const { titleBarOverlay } = getWindowTitleBarOptions( + shouldUseDarkColors, + platform, + window.webContents.getZoomFactor(), + ); if (typeof titleBarOverlay === "object") { window.setTitleBarOverlay(titleBarOverlay); } @@ -590,7 +624,19 @@ export const make = Effect.gen(function* () { clearDevelopmentLoadRetry(); developmentLoadRetryIndex = 0; window.setTitle(environment.displayName); + syncWindowTitleBarHeight(window, environment.platform); }); + if (environment.platform !== "darwin") { + const syncTitleBarHeightAfterZoom = () => { + setImmediate(() => syncWindowTitleBarHeight(window, environment.platform)); + }; + window.webContents.on("zoom-changed", syncTitleBarHeightAfterZoom); + window.webContents.on("before-input-event", (_event, input) => { + if (isRendererZoomShortcut(input, environment.platform)) { + syncTitleBarHeightAfterZoom(); + } + }); + } window.webContents.on( "did-fail-load", (_event, errorCode, errorDescription, validatedURL, isMainFrame) => { diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index df06c431fd2..d501ed7d308 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -42,7 +42,7 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({ return (