| 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,6 +78,7 @@ describe("DesktopLifecycle", () => { |
| 78 | handleBackendNotReady: Effect.void, | 78 | handleBackendNotReady: Effect.void, |
| 79 | flushMainWindowBounds: Effect.void, | 79 | flushMainWindowBounds: Effect.void, |
| 80 | dispatchMenuAction: () => Effect.void, | 80 | dispatchMenuAction: () => Effect.void, |
| 81 | zoomMain: () => Effect.void, | ||
| 81 | syncAppearance: Effect.void, | 82 | syncAppearance: Effect.void, |
| 82 | }); | 83 | }); |
| 83 | 84 |
apps/desktop/src/backend/DesktopBackendPool.test.ts+1| ... | @@ -91,6 +91,7 @@ function makePoolLayer( | ... | @@ -91,6 +91,7 @@ function makePoolLayer( |
| 91 | handleBackendNotReady: Effect.void, | 91 | handleBackendNotReady: Effect.void, |
| 92 | flushMainWindowBounds: Effect.void, | 92 | flushMainWindowBounds: Effect.void, |
| 93 | dispatchMenuAction: () => Effect.die("unexpected menu action"), | 93 | dispatchMenuAction: () => Effect.die("unexpected menu action"), |
| 94 | zoomMain: () => Effect.die("unexpected zoom"), | ||
| 94 | syncAppearance: Effect.void, | 95 | syncAppearance: Effect.void, |
| 95 | } satisfies DesktopWindow.DesktopWindow["Service"]), | 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,6 +81,8 @@ const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred<string>) => |
| 81 | handleBackendNotReady: Effect.void, | 81 | handleBackendNotReady: Effect.void, |
| 82 | flushMainWindowBounds: Effect.void, | 82 | flushMainWindowBounds: Effect.void, |
| 83 | dispatchMenuAction: (action) => Deferred.succeed(selectedAction, action).pipe(Effect.asVoid), | 83 | dispatchMenuAction: (action) => Deferred.succeed(selectedAction, action).pipe(Effect.asVoid), |
| 84 | zoomMain: (direction) => | ||
| 85 | Deferred.succeed(selectedAction, `zoom-${direction}`).pipe(Effect.asVoid), | ||
| 84 | syncAppearance: Effect.void, | 86 | syncAppearance: Effect.void, |
| 85 | } satisfies DesktopWindow.DesktopWindow["Service"]); | 87 | } satisfies DesktopWindow.DesktopWindow["Service"]); |
| 86 | 88 | ||
| ... | @@ -94,6 +96,30 @@ const makeElectronMenuLayer = ( | ... | @@ -94,6 +96,30 @@ const makeElectronMenuLayer = ( |
| 94 | showContextMenu: () => Effect.succeed(Option.none()), | 96 | showContextMenu: () => Effect.succeed(Option.none()), |
| 95 | } satisfies ElectronMenu.ElectronMenu["Service"]); | 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 | describe("DesktopApplicationMenu", () => { | 123 | describe("DesktopApplicationMenu", () => { |
| 98 | it.effect("installs the native menu and routes Settings through DesktopWindow", () => | 124 | it.effect("installs the native menu and routes Settings through DesktopWindow", () => |
| 99 | Effect.gen(function* () { | 125 | Effect.gen(function* () { |
| ... | @@ -101,25 +127,7 @@ describe("DesktopApplicationMenu", () => { | ... | @@ -101,25 +127,7 @@ describe("DesktopApplicationMenu", () => { |
| 101 | const applicationMenuTemplate = | 127 | const applicationMenuTemplate = |
| 102 | yield* Deferred.make<readonly Electron.MenuItemConstructorOptions[]>(); | 128 | yield* Deferred.make<readonly Electron.MenuItemConstructorOptions[]>(); |
| 103 | 129 | ||
| 104 | yield* Effect.gen(function* () { | 130 | yield* configureMenu(selectedAction, applicationMenuTemplate); |
| 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 | ); | ||
| 123 | 131 | ||
| 124 | const template = yield* Deferred.await(applicationMenuTemplate); | 132 | const template = yield* Deferred.await(applicationMenuTemplate); |
| 125 | const fileMenu = template.find((item) => item.label === "File"); | 133 | const fileMenu = template.find((item) => item.label === "File"); |
| ... | @@ -138,4 +146,38 @@ describe("DesktopApplicationMenu", () => { | ... | @@ -138,4 +146,38 @@ describe("DesktopApplicationMenu", () => { |
| 138 | assert.equal(yield* Deferred.await(selectedAction), "open-settings"); | 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,6 +49,13 @@ const dispatchMenuAction = Effect.fn("desktop.menu.dispatchMenuAction")(function |
| 49 | yield* desktopWindow.dispatchMenuAction(action); | 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 | const checkForUpdatesFromMenu = Effect.gen(function* () { | 59 | const checkForUpdatesFromMenu = Effect.gen(function* () { |
| 53 | const updates = yield* DesktopUpdates.DesktopUpdates; | 60 | const updates = yield* DesktopUpdates.DesktopUpdates; |
| 54 | const electronDialog = yield* ElectronDialog.ElectronDialog; | 61 | const electronDialog = yield* ElectronDialog.ElectronDialog; |
| ... | @@ -127,6 +134,9 @@ export const make = Effect.gen(function* () { | ... | @@ -127,6 +134,9 @@ export const make = Effect.gen(function* () { |
| 127 | const settingsClick = () => { | 134 | const settingsClick = () => { |
| 128 | runMenuEffect("open-settings", dispatchMenuAction("open-settings")); | 135 | runMenuEffect("open-settings", dispatchMenuAction("open-settings")); |
| 129 | }; | 136 | }; |
| 137 | const zoomClick = (direction: DesktopWindow.MainWindowZoomDirection) => () => { | ||
| 138 | runMenuEffect(`zoom-${direction}`, zoomMainWindow(direction)); | ||
| 139 | }; | ||
| 130 | const template: Electron.MenuItemConstructorOptions[] = []; | 140 | const template: Electron.MenuItemConstructorOptions[] = []; |
| 131 | 141 | ||
| 132 | if (environment.platform === "darwin") { | 142 | if (environment.platform === "darwin") { |
| ... | @@ -181,10 +191,21 @@ export const make = Effect.gen(function* () { | ... | @@ -181,10 +191,21 @@ export const make = Effect.gen(function* () { |
| 181 | { role: "forceReload" }, | 191 | { role: "forceReload" }, |
| 182 | { role: "toggleDevTools" }, | 192 | { role: "toggleDevTools" }, |
| 183 | { type: "separator" }, | 193 | { type: "separator" }, |
| 184 | { role: "resetZoom" }, | 194 | /* |
| 185 | { role: "zoomIn", accelerator: "CmdOrCtrl+=" }, | 195 | Not the zoom roles: those act on the focused webContents, so with |
| 186 | { role: "zoomIn", accelerator: "CmdOrCtrl+Plus", visible: false }, | 196 | an embedded preview WebContentsView focused they zoom the guest |
| 187 | { role: "zoomOut" }, | 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 | { type: "separator" }, | 209 | { type: "separator" }, |
| 189 | { role: "togglefullscreen" }, | 210 | { role: "togglefullscreen" }, |
| 190 | ], | 211 | ], |
apps/desktop/src/window/DesktopWindow.ts+20| ... | @@ -61,6 +61,8 @@ export type DesktopWindowError = | ... | @@ -61,6 +61,8 @@ export type DesktopWindowError = |
| 61 | | ElectronWindow.ElectronWindowCreateError | 61 | | ElectronWindow.ElectronWindowCreateError |
| 62 | | PreviewManager.PreviewManagerError; | 62 | | PreviewManager.PreviewManagerError; |
| 63 | 63 | ||
| 64 | export type MainWindowZoomDirection = "in" | "out" | "reset"; | ||
| 65 | |||
| 64 | export class DesktopWindow extends Context.Service< | 66 | export class DesktopWindow extends Context.Service< |
| 65 | DesktopWindow, | 67 | DesktopWindow, |
| 66 | { | 68 | { |
| ... | @@ -87,6 +89,12 @@ export class DesktopWindow extends Context.Service< | ... | @@ -87,6 +89,12 @@ export class DesktopWindow extends Context.Service< |
| 87 | readonly handleBackendNotReady: Effect.Effect<void>; | 89 | readonly handleBackendNotReady: Effect.Effect<void>; |
| 88 | readonly flushMainWindowBounds: Effect.Effect<void>; | 90 | readonly flushMainWindowBounds: Effect.Effect<void>; |
| 89 | readonly dispatchMenuAction: (action: string) => Effect.Effect<void, DesktopWindowError>; | 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 | readonly syncAppearance: Effect.Effect<void>; | 98 | readonly syncAppearance: Effect.Effect<void>; |
| 91 | } | 99 | } |
| 92 | >()("@t3tools/desktop/window/DesktopWindow") {} | 100 | >()("@t3tools/desktop/window/DesktopWindow") {} |
| ... | @@ -836,6 +844,18 @@ export const make = Effect.gen(function* () { | ... | @@ -836,6 +844,18 @@ export const make = Effect.gen(function* () { |
| 836 | 844 | ||
| 837 | send(); | 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 | syncAppearance: Effect.gen(function* () { | 859 | syncAppearance: Effect.gen(function* () { |
| 840 | const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; | 860 | const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; |
| 841 | yield* electronWindow.syncAllAppearance((window) => | 861 | yield* electronWindow.syncAllAppearance((window) => |