diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 8b6ab7c7d8a5e..4ec10232c32d1 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -814,11 +814,75 @@ describe("DesktopWindow", () => { assert.deepEqual(mainWindowBoundsUpdates, [{ x: 220, y: 140, width: 1380, height: 920 }]); assert.deepEqual(mainWindowMaximizedUpdates, [true]); assert.equal(fakeWindow.getNormalBounds.mock.calls.length, 1); - assert.equal(fakeWindow.getBounds.mock.calls.length, 0); }).pipe(Effect.provide(layer)); }), ); + it.effect.each([ + { normal: "on display 1", width: 1920, height: 1080, x: 220 }, + { normal: "on display 1", width: 1280, height: 720, x: 220 }, + { normal: "straddling both displays", width: 1920, height: 1080, x: 1800 }, + ])( + "persists bounds on the display a maximized window was moved to ($width x $height, normal $normal)", + ({ width, height, x }) => + Effect.gen(function* () { + const displayOne = { x: 0, y: 0, width: 1920, height: 1080 }; + const displayTwo = { x: 1920, y: 0, width, height }; + const getAllDisplays = vi.mocked(Electron.screen.getAllDisplays); + getAllDisplays.mockReturnValue([ + { bounds: displayOne, workArea: displayOne }, + { bounds: displayTwo, workArea: displayTwo }, + ] as unknown as Electron.Display[]); + const fakeWindow = makeFakeBrowserWindow(); + fakeWindow.isMaximized.mockReturnValue(true); + fakeWindow.getBounds.mockReturnValue(displayTwo); + fakeWindow.getNormalBounds.mockReturnValue({ x, y: 140, width: 1380, height: 920 }); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; + const mainWindowMaximizedUpdates: boolean[] = []; + const layer = layerTest({ + window: fakeWindow.window, + createCount, + mainWindow, + mainWindowBoundsUpdates, + mainWindowMaximizedUpdates, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const move = fakeWindow.windowListeners.get("move"); + if (!move) { + return yield* Effect.die("window move listener was not registered"); + } + move(); + yield* TestClock.adjust(500); + yield* Effect.promise(() => Promise.resolve()); + + const persisted = mainWindowBoundsUpdates.at(-1); + assert.isDefined(persisted, "maximized window bounds were not persisted"); + const persistedDisplay = [displayOne, displayTwo].findIndex( + (display) => + persisted.x >= display.x && + persisted.y >= display.y && + persisted.x + persisted.width <= display.x + display.width && + persisted.y + persisted.height <= display.y + display.height, + ); + assert.equal( + persistedDisplay, + 1, + `window maximized on display 2 persisted bounds ${JSON.stringify(persisted)}, which restore on display ${persistedDisplay + 1}`, + ); + assert.deepEqual(mainWindowMaximizedUpdates, [true]); + }).pipe( + Effect.provide(layer), + Effect.ensuring(Effect.sync(() => getAllDisplays.mockReset())), + ); + }), + ); + it.effect("preserves unrestorable bounds until the user changes the window", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); @@ -862,8 +926,7 @@ describe("DesktopWindow", () => { it.effect("flushes normal bounds when minimized before the debounce completes", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); - fakeWindow.getBounds.mockReturnValue({ x: -32_000, y: -32_000, width: 160, height: 28 }); - fakeWindow.getNormalBounds.mockReturnValue({ x: 180, y: 120, width: 1440, height: 960 }); + fakeWindow.getBounds.mockReturnValue({ x: 180, y: 120, width: 1440, height: 960 }); const createCount = yield* Ref.make(0); const mainWindow = yield* Ref.make>(Option.none()); const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; @@ -884,17 +947,141 @@ describe("DesktopWindow", () => { } resize(); yield* TestClock.adjust(250); + // Windows reports a minimized window at -32000. fakeWindow.isMinimized.mockReturnValue(true); + fakeWindow.getBounds.mockReturnValue({ x: -32_000, y: -32_000, width: 160, height: 28 }); yield* desktopWindow.flushMainWindowBounds; + resize(); + yield* TestClock.adjust(500); + yield* Effect.promise(() => Promise.resolve()); assert.deepEqual(mainWindowBoundsUpdates, [{ x: 180, y: 120, width: 1440, height: 960 }]); - assert.equal(fakeWindow.getBounds.mock.calls.length, 0); - assert.equal(fakeWindow.getNormalBounds.mock.calls.length, 1); }).pipe(Effect.provide(layer)); }), ); + it.effect.each([ + { window: "normal", write: "flush" }, + { window: "normal", write: "debounce" }, + { window: "maximized", write: "flush" }, + { window: "maximized", write: "debounce" }, + ] as const)("persists a $window window change minimized before its $write", (input) => + Effect.gen(function* () { + const displayOne = { x: 0, y: 0, width: 1920, height: 1080 }; + const displayTwo = { x: 1920, y: 0, width: 1920, height: 1080 }; + const getAllDisplays = vi.mocked(Electron.screen.getAllDisplays); + getAllDisplays.mockReturnValue([ + { bounds: displayOne, workArea: displayOne }, + { bounds: displayTwo, workArea: displayTwo }, + ] as unknown as Electron.Display[]); + const maximized = input.window === "maximized"; + const fakeWindow = makeFakeBrowserWindow(); + fakeWindow.isMaximized.mockReturnValue(maximized); + fakeWindow.getBounds.mockReturnValue( + maximized ? displayTwo : { x: 220, y: 140, width: 1380, height: 920 }, + ); + fakeWindow.getNormalBounds.mockReturnValue({ x: 220, y: 140, width: 1380, height: 920 }); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; + const mainWindowMaximizedUpdates: boolean[] = []; + const layer = layerTest({ + window: fakeWindow.window, + createCount, + mainWindow, + mainWindowBoundsUpdates, + mainWindowMaximizedUpdates, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const move = fakeWindow.windowListeners.get("move"); + if (!move) { + return yield* Effect.die("window move listener was not registered"); + } + move(); + yield* TestClock.adjust(250); + // Minimized windows report stale state: macOS unmaximized at the pre-move frame. + fakeWindow.isMinimized.mockReturnValue(true); + fakeWindow.isMaximized.mockReturnValue(false); + fakeWindow.getBounds.mockReturnValue({ x: 100, y: 100, width: 1100, height: 780 }); + fakeWindow.getNormalBounds.mockReturnValue({ x: 100, y: 100, width: 1100, height: 780 }); + if (input.write === "debounce") { + yield* TestClock.adjust(250); + yield* Effect.promise(() => Promise.resolve()); + } + yield* desktopWindow.flushMainWindowBounds; + + assert.deepEqual(mainWindowBoundsUpdates, [ + maximized + ? { x: 2140, y: 140, width: 1380, height: 920 } + : { x: 220, y: 140, width: 1380, height: 920 }, + ]); + assert.deepEqual(mainWindowMaximizedUpdates, [maximized]); + }).pipe( + Effect.provide(layer), + Effect.ensuring(Effect.sync(() => getAllDisplays.mockReset())), + ); + }), + ); + + it.effect("keeps the moved display when a maximized window quits minimized", () => + Effect.gen(function* () { + const displayOne = { x: 0, y: 0, width: 1920, height: 1080 }; + const displayTwo = { x: 1920, y: 0, width: 1920, height: 1080 }; + const getAllDisplays = vi.mocked(Electron.screen.getAllDisplays); + getAllDisplays.mockReturnValue([ + { bounds: displayOne, workArea: displayOne }, + { bounds: displayTwo, workArea: displayTwo }, + ] as unknown as Electron.Display[]); + const fakeWindow = makeFakeBrowserWindow(); + fakeWindow.isMaximized.mockReturnValue(true); + fakeWindow.getBounds.mockReturnValue(displayTwo); + fakeWindow.getNormalBounds.mockReturnValue({ x: 220, y: 140, width: 1380, height: 920 }); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const mainWindowBoundsUpdates: DesktopAppSettings.DesktopWindowBounds[] = []; + const mainWindowMaximizedUpdates: boolean[] = []; + const layer = layerTest({ + window: fakeWindow.window, + createCount, + mainWindow, + mainWindowBoundsUpdates, + mainWindowMaximizedUpdates, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + const move = fakeWindow.windowListeners.get("move"); + if (!move) { + return yield* Effect.die("window move listener was not registered"); + } + move(); + yield* TestClock.adjust(500); + yield* Effect.promise(() => Promise.resolve()); + const movedBounds = { x: 2140, y: 140, width: 1380, height: 920 }; + assert.deepEqual(mainWindowBoundsUpdates, [movedBounds]); + + // macOS reports a minimized window as unmaximized with its pre-move normal frame. + fakeWindow.isMinimized.mockReturnValue(true); + fakeWindow.isMaximized.mockReturnValue(false); + fakeWindow.getBounds.mockReturnValue({ x: 220, y: 140, width: 1380, height: 920 }); + yield* desktopWindow.flushMainWindowBounds; + + assert.deepEqual(mainWindowBoundsUpdates.at(-1), movedBounds); + assert.deepEqual(mainWindowMaximizedUpdates.at(-1), true); + }).pipe( + Effect.provide(layer), + Effect.ensuring(Effect.sync(() => getAllDisplays.mockReset())), + ); + }), + ); + it.effect("persists the current main window bounds before the window closes", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 20b43f91e63af..c353ead0903e2 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -148,6 +148,58 @@ function windowFitsWithinDisplay( ); } +function overlapArea(left: DisplayBounds, right: DisplayBounds): number { + const width = Math.min(left.x + left.width, right.x + right.width) - Math.max(left.x, right.x); + const height = Math.min(left.y + left.height, right.y + right.height) - Math.max(left.y, right.y); + return width > 0 && height > 0 ? width * height : 0; +} + +function displayMatching( + bounds: DisplayBounds, + displays: readonly DisplayBounds[], +): DisplayBounds | undefined { + let match: DisplayBounds | undefined; + let matchArea = 0; + for (const display of displays) { + const area = overlapArea(bounds, display); + if (area > matchArea) { + match = display; + matchArea = area; + } + } + return match; +} + +// Normal bounds of a maximized window can sit on, or straddle into, another display than the window. +function normalBoundsOnWindowDisplay( + normalBounds: DisplayBounds, + windowBounds: DisplayBounds, + displays: readonly DisplayBounds[], +): DisplayBounds { + const target = displayMatching(windowBounds, displays); + if (target === undefined || windowFitsWithinDisplay(normalBounds, target)) { + return normalBounds; + } + const source = displayMatching(normalBounds, displays) ?? target; + const width = Math.min(normalBounds.width, target.width); + const height = Math.min(normalBounds.height, target.height); + const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max); + return { + x: clamp(target.x + normalBounds.x - source.x, target.x, target.x + target.width - width), + y: clamp(target.y + normalBounds.y - source.y, target.y, target.y + target.height - height), + width, + height, + }; +} + +function readDisplayBoundsOrEmpty(): readonly DisplayBounds[] { + try { + return Electron.screen.getAllDisplays().map((display) => display.bounds); + } catch { + return []; + } +} + function windowBoundsEqual( left: DesktopAppSettings.DesktopWindowBounds, right: DesktopAppSettings.DesktopWindowBounds, @@ -406,33 +458,52 @@ export const make = Effect.gen(function* () { let boundsPersistFiber: Fiber.Fiber | undefined; let pendingBoundsPersistFiber: Fiber.Fiber | undefined; let boundsPersistenceEnabled = persistedBounds === null || restoredPersistedBounds; - const readPersistableBounds = (): DesktopAppSettings.DesktopWindowBounds | null => { + let lastPersistableState: { + readonly bounds: DesktopAppSettings.DesktopWindowBounds; + readonly isMaximized: boolean; + } | null = null; + const readPersistableState = () => { if (window.isDestroyed()) { return null; } + // Minimized windows report stale state on some platforms (macOS: not maximized, the + // pre-move normal frame; Windows: -32000), so they keep the state seen before minimizing. + if (window.isMinimized()) { + return lastPersistableState; + } + const isMaximized = window.isMaximized(); const bounds = - window.isFullScreen() || window.isMaximized() || window.isMinimized() - ? window.getNormalBounds() + window.isFullScreen() || isMaximized + ? normalBoundsOnWindowDisplay( + window.getNormalBounds(), + window.getBounds(), + readDisplayBoundsOrEmpty(), + ) : window.getBounds(); - return DesktopAppSettings.normalizeMainWindowBounds({ + const persistableBounds = DesktopAppSettings.normalizeMainWindowBounds({ x: Math.round(bounds.x), y: Math.round(bounds.y), width: Math.round(bounds.width), height: Math.round(bounds.height), }); + lastPersistableState = + persistableBounds === null ? null : { bounds: persistableBounds, isMaximized }; + return lastPersistableState; }; - const fallbackWindowBounds = boundsPersistenceEnabled ? null : readPersistableBounds(); + const fallbackWindowBounds = boundsPersistenceEnabled + ? null + : (readPersistableState()?.bounds ?? null); const fallbackWindowMaximized = persistedSettings.mainWindowMaximized; const persistCurrentBounds = (): Fiber.Fiber | undefined => { if (!boundsPersistenceEnabled) { return pendingBoundsPersistFiber; } - const bounds = readPersistableBounds(); - if (bounds === null) { + const state = readPersistableState(); + if (state === null) { return pendingBoundsPersistFiber; } pendingBoundsPersistFiber = runFork( - desktopSettings.setMainWindowBounds(bounds, window.isMaximized()).pipe( + desktopSettings.setMainWindowBounds(state.bounds, state.isMaximized).pipe( Effect.asVoid, Effect.catch((error) => logWindowWarning("failed to persist main window bounds", { @@ -444,16 +515,15 @@ export const make = Effect.gen(function* () { return pendingBoundsPersistFiber; }; const scheduleBoundsPersist = () => { - if (!boundsPersistenceEnabled) { - const currentBounds = readPersistableBounds(); - if ( - currentBounds === null || + const state = readPersistableState(); + if ( + !boundsPersistenceEnabled && + (state === null || (fallbackWindowBounds !== null && - windowBoundsEqual(currentBounds, fallbackWindowBounds) && - window.isMaximized() === fallbackWindowMaximized) - ) { - return; - } + windowBoundsEqual(state.bounds, fallbackWindowBounds) && + state.isMaximized === fallbackWindowMaximized)) + ) { + return; } boundsPersistenceEnabled = true; if (boundsPersistFiber !== undefined) {