| author | |
| committer | |
| log | 06404107261cae24394d6abe39984fb445c5aff5 |
| tree | e8a72e90a199b29e65d6f8c64e5aa1512fc5f387 |
| parent | 2c7267ad43a05cf3e30343400c76fd9ac47698e7 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>5 files changed, 108 insertions(+), 23 deletions(-)
apps/desktop/src/app/DesktopLifecycle.test.ts+1| ... | ... | @@ -78,6 +78,7 @@ describe("DesktopLifecycle", () => { |
| 78 | 78 | handleBackendNotReady: Effect.void, |
| 79 | 79 | flushMainWindowBounds: Effect.void, |
| 80 | 80 | dispatchMenuAction: () => Effect.void, |
| 81 | zoomMain: () => Effect.void, | |
| 81 | 82 | syncAppearance: Effect.void, |
| 82 | 83 | }); |
| 83 | 84 |
apps/desktop/src/backend/DesktopBackendPool.test.ts+1| ... | ... | @@ -91,6 +91,7 @@ function makePoolLayer( |
| 91 | 91 | handleBackendNotReady: Effect.void, |
| 92 | 92 | flushMainWindowBounds: Effect.void, |
| 93 | 93 | dispatchMenuAction: () => Effect.die("unexpected menu action"), |
| 94 | zoomMain: () => Effect.die("unexpected zoom"), | |
| 94 | 95 | syncAppearance: Effect.void, |
| 95 | 96 | } satisfies DesktopWindow.DesktopWindow["Service"]), |
| 96 | 97 | ), |
apps/desktop/src/window/DesktopApplicationMenu.test.ts+61-19| ... | ... | @@ -81,6 +81,8 @@ const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred<string>) => |
| 81 | 81 | handleBackendNotReady: Effect.void, |
| 82 | 82 | flushMainWindowBounds: Effect.void, |
| 83 | 83 | dispatchMenuAction: (action) => Deferred.succeed(selectedAction, action).pipe(Effect.asVoid), |
| 84 | zoomMain: (direction) => | |
| 85 | Deferred.succeed(selectedAction, `zoom-${direction}`).pipe(Effect.asVoid), | |
| 84 | 86 | syncAppearance: Effect.void, |
| 85 | 87 | } satisfies DesktopWindow.DesktopWindow["Service"]); |
| 86 | 88 | |
| ... | ... | @@ -94,6 +96,30 @@ const makeElectronMenuLayer = ( |
| 94 | 96 | showContextMenu: () => Effect.succeed(Option.none()), |
| 95 | 97 | } satisfies ElectronMenu.ElectronMenu["Service"]); |
| 96 | 98 | |
| 99 | const configureMenu = ( | |
| 100 | selectedAction: Deferred.Deferred<string>, | |
| 101 | applicationMenuTemplate: Deferred.Deferred<readonly Electron.MenuItemConstructorOptions[]>, | |
| 102 | ) => | |
| 103 | Effect.gen(function* () { | |
| 104 | const menu = yield* DesktopApplicationMenu.DesktopApplicationMenu; | |
| 105 | yield* menu.configure; | |
| 106 | }).pipe( | |
| 107 | Effect.provide( | |
| 108 | DesktopApplicationMenu.layer.pipe( | |
| 109 | Layer.provideMerge(makeElectronMenuLayer(applicationMenuTemplate)), | |
| 110 | Layer.provideMerge(makeDesktopWindowLayer(selectedAction)), | |
| 111 | Layer.provideMerge(desktopUpdatesLayer), | |
| 112 | Layer.provideMerge(electronDialogLayer), | |
| 113 | Layer.provideMerge(electronAppLayer), | |
| 114 | Layer.provideMerge( | |
| 115 | DesktopEnvironment.layer(environmentInput).pipe( | |
| 116 | Layer.provide(Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({}))), | |
| 117 | ), | |
| 118 | ), | |
| 119 | ), | |
| 120 | ), | |
| 121 | ); | |
| 122 | ||
| 97 | 123 | describe("DesktopApplicationMenu", () => { |
| 98 | 124 | it.effect("installs the native menu and routes Settings through DesktopWindow", () => |
| 99 | 125 | Effect.gen(function* () { |
| ... | ... | @@ -101,25 +127,7 @@ describe("DesktopApplicationMenu", () => { |
| 101 | 127 | const applicationMenuTemplate = |
| 102 | 128 | yield* Deferred.make<readonly Electron.MenuItemConstructorOptions[]>(); |
| 103 | 129 | |
| 104 | yield* Effect.gen(function* () { | |
| 105 | const menu = yield* DesktopApplicationMenu.DesktopApplicationMenu; | |
| 106 | yield* menu.configure; | |
| 107 | }).pipe( | |
| 108 | Effect.provide( | |
| 109 | DesktopApplicationMenu.layer.pipe( | |
| 110 | Layer.provideMerge(makeElectronMenuLayer(applicationMenuTemplate)), | |
| 111 | Layer.provideMerge(makeDesktopWindowLayer(selectedAction)), | |
| 112 | Layer.provideMerge(desktopUpdatesLayer), | |
| 113 | Layer.provideMerge(electronDialogLayer), | |
| 114 | Layer.provideMerge(electronAppLayer), | |
| 115 | Layer.provideMerge( | |
| 116 | DesktopEnvironment.layer(environmentInput).pipe( | |
| 117 | Layer.provide(Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({}))), | |
| 118 | ), | |
| 119 | ), | |
| 120 | ), | |
| 121 | ), | |
| 122 | ); | |
| 130 | yield* configureMenu(selectedAction, applicationMenuTemplate); | |
| 123 | 131 | |
| 124 | 132 | const template = yield* Deferred.await(applicationMenuTemplate); |
| 125 | 133 | const fileMenu = template.find((item) => item.label === "File"); |
| ... | ... | @@ -138,4 +146,38 @@ describe("DesktopApplicationMenu", () => { |
| 138 | 146 | assert.equal(yield* Deferred.await(selectedAction), "open-settings"); |
| 139 | 147 | }), |
| 140 | 148 | ); |
| 149 | ||
| 150 | // Zoom must route through DesktopWindow.zoomMain instead of the Electron | |
| 151 | // zoom roles: the roles zoom whichever webContents has focus, which breaks | |
| 152 | // app zoom while an embedded preview WebContentsView holds focus. | |
| 153 | it.effect("routes View menu zoom to the main window instead of zoom roles", () => | |
| 154 | Effect.gen(function* () { | |
| 155 | const selectedAction = yield* Deferred.make<string>(); | |
| 156 | const applicationMenuTemplate = | |
| 157 | yield* Deferred.make<readonly Electron.MenuItemConstructorOptions[]>(); | |
| 158 | ||
| 159 | yield* configureMenu(selectedAction, applicationMenuTemplate); | |
| 160 | ||
| 161 | const template = yield* Deferred.await(applicationMenuTemplate); | |
| 162 | const viewMenu = template.find((item) => item.label === "View"); | |
| 163 | assert.isDefined(viewMenu); | |
| 164 | if (!Array.isArray(viewMenu.submenu)) { | |
| 165 | throw new Error("Expected View menu submenu to be an array."); | |
| 166 | } | |
| 167 | ||
| 168 | assert.isUndefined( | |
| 169 | viewMenu.submenu.find((item) => item.role?.toLowerCase().includes("zoom")), | |
| 170 | ); | |
| 171 | ||
| 172 | const zoomIn = viewMenu.submenu.find((item) => item.label === "Zoom In"); | |
| 173 | assert.isDefined(zoomIn); | |
| 174 | assert.equal(zoomIn.accelerator, "CmdOrCtrl+="); | |
| 175 | if (typeof zoomIn.click !== "function") { | |
| 176 | throw new Error("Expected Zoom In menu item to have a click handler."); | |
| 177 | } | |
| 178 | ||
| 179 | zoomIn.click({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent); | |
| 180 | assert.equal(yield* Deferred.await(selectedAction), "zoom-in"); | |
| 181 | }), | |
| 182 | ); | |
| 141 | 183 | }); |
apps/desktop/src/window/DesktopApplicationMenu.ts+25-4| ... | ... | @@ -49,6 +49,13 @@ const dispatchMenuAction = Effect.fn("desktop.menu.dispatchMenuAction")(function |
| 49 | 49 | yield* desktopWindow.dispatchMenuAction(action); |
| 50 | 50 | }); |
| 51 | 51 | |
| 52 | const zoomMainWindow = Effect.fn("desktop.menu.zoomMainWindow")(function* ( | |
| 53 | direction: DesktopWindow.MainWindowZoomDirection, | |
| 54 | ): Effect.fn.Return<void, never, DesktopWindow.DesktopWindow> { | |
| 55 | const desktopWindow = yield* DesktopWindow.DesktopWindow; | |
| 56 | yield* desktopWindow.zoomMain(direction); | |
| 57 | }); | |
| 58 | ||
| 52 | 59 | const checkForUpdatesFromMenu = Effect.gen(function* () { |
| 53 | 60 | const updates = yield* DesktopUpdates.DesktopUpdates; |
| 54 | 61 | const electronDialog = yield* ElectronDialog.ElectronDialog; |
| ... | ... | @@ -127,6 +134,9 @@ export const make = Effect.gen(function* () { |
| 127 | 134 | const settingsClick = () => { |
| 128 | 135 | runMenuEffect("open-settings", dispatchMenuAction("open-settings")); |
| 129 | 136 | }; |
| 137 | const zoomClick = (direction: DesktopWindow.MainWindowZoomDirection) => () => { | |
| 138 | runMenuEffect(`zoom-${direction}`, zoomMainWindow(direction)); | |
| 139 | }; | |
| 130 | 140 | const template: Electron.MenuItemConstructorOptions[] = []; |
| 131 | 141 | |
| 132 | 142 | if (environment.platform === "darwin") { |
| ... | ... | @@ -181,10 +191,21 @@ export const make = Effect.gen(function* () { |
| 181 | 191 | { role: "forceReload" }, |
| 182 | 192 | { role: "toggleDevTools" }, |
| 183 | 193 | { type: "separator" }, |
| 184 | { role: "resetZoom" }, | |
| 185 | { role: "zoomIn", accelerator: "CmdOrCtrl+=" }, | |
| 186 | { role: "zoomIn", accelerator: "CmdOrCtrl+Plus", visible: false }, | |
| 187 | { role: "zoomOut" }, | |
| 194 | /* | |
| 195 | Not the zoom roles: those act on the focused webContents, so with | |
| 196 | an embedded preview WebContentsView focused they zoom the guest | |
| 197 | page and the app UI appears stuck. These always zoom the main | |
| 198 | window (see DesktopWindow.zoomMain). | |
| 199 | */ | |
| 200 | { label: "Actual Size", accelerator: "CmdOrCtrl+0", click: zoomClick("reset") }, | |
| 201 | { label: "Zoom In", accelerator: "CmdOrCtrl+=", click: zoomClick("in") }, | |
| 202 | { | |
| 203 | label: "Zoom In", | |
| 204 | accelerator: "CmdOrCtrl+Plus", | |
| 205 | visible: false, | |
| 206 | click: zoomClick("in"), | |
| 207 | }, | |
| 208 | { label: "Zoom Out", accelerator: "CmdOrCtrl+-", click: zoomClick("out") }, | |
| 188 | 209 | { type: "separator" }, |
| 189 | 210 | { role: "togglefullscreen" }, |
| 190 | 211 | ], |
apps/desktop/src/window/DesktopWindow.ts+20| ... | ... | @@ -61,6 +61,8 @@ export type DesktopWindowError = |
| 61 | 61 | | ElectronWindow.ElectronWindowCreateError |
| 62 | 62 | | PreviewManager.PreviewManagerError; |
| 63 | 63 | |
| 64 | export type MainWindowZoomDirection = "in" | "out" | "reset"; | |
| 65 | ||
| 64 | 66 | export class DesktopWindow extends Context.Service< |
| 65 | 67 | DesktopWindow, |
| 66 | 68 | { |
| ... | ... | @@ -87,6 +89,12 @@ export class DesktopWindow extends Context.Service< |
| 87 | 89 | readonly handleBackendNotReady: Effect.Effect<void>; |
| 88 | 90 | readonly flushMainWindowBounds: Effect.Effect<void>; |
| 89 | 91 | readonly dispatchMenuAction: (action: string) => Effect.Effect<void, DesktopWindowError>; |
| 92 | // Zooms the main window's own webContents. The Electron `zoomIn`/`zoomOut` | |
| 93 | // menu roles act on whichever webContents has keyboard focus, so with an | |
| 94 | // embedded preview WebContentsView (or DevTools) focused they zoom the | |
| 95 | // guest page instead of the app UI. The menu routes here to always target | |
| 96 | // the main window. | |
| 97 | readonly zoomMain: (direction: MainWindowZoomDirection) => Effect.Effect<void>; | |
| 90 | 98 | readonly syncAppearance: Effect.Effect<void>; |
| 91 | 99 | } |
| 92 | 100 | >()("@t3tools/desktop/window/DesktopWindow") {} |
| ... | ... | @@ -836,6 +844,18 @@ export const make = Effect.gen(function* () { |
| 836 | 844 | |
| 837 | 845 | send(); |
| 838 | 846 | }), |
| 847 | zoomMain: Effect.fn("desktop.window.zoomMain")(function* (direction) { | |
| 848 | yield* Effect.annotateCurrentSpan({ direction }); | |
| 849 | const window = yield* focusedMainWindow; | |
| 850 | if (Option.isNone(window) || window.value.isDestroyed()) { | |
| 851 | return; | |
| 852 | } | |
| 853 | const webContents = window.value.webContents; | |
| 854 | // Same step size as the Electron zoomIn/zoomOut menu roles. | |
| 855 | webContents.setZoomLevel( | |
| 856 | direction === "reset" ? 0 : webContents.getZoomLevel() + (direction === "in" ? 0.5 : -0.5), | |
| 857 | ); | |
| 858 | }), | |
| 839 | 859 | syncAppearance: Effect.gen(function* () { |
| 840 | 860 | const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; |
| 841 | 861 | yield* electronWindow.syncAllAppearance((window) => |