experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor(window-placement): Phase 3 — wire computePlacement into reuse + fresh-open + display-watcher

+359 -149
+57
backend/electron/display-watcher.ts
··· 20 20 import { BrowserWindow, screen, Display } from 'electron'; 21 21 import { getWindowInfo } from './main.js'; 22 22 import { WEB_CORE_ADDRESS } from './config.js'; 23 + import { computePlacement, type Placement } from './window-placement.js'; 23 24 24 25 const DEBUG = !!process.env.DEBUG; 25 26 ··· 216 217 DEBUG && console.log("[display-watcher] All windows accessible, no rescue needed"); 217 218 } else { 218 219 console.log(`[display-watcher] Rescued ${rescuedCount} orphaned window(s)`); 220 + } 221 + 222 + // Second pass: per-window `computePlacement` using each window's 223 + // registry-stored `Placement` intent. Replaces the legacy 224 + // `center: true`-only second pass with a generic pass that respects 225 + // every mode: 226 + // - `centered` follows the cursor display every change. 227 + // - `edge` re-anchors to the cursor display's edge. 228 + // - `cursor-display-fallback` / `manual` only reposition if stranded 229 + // (already handled by pass 1, but the pure module's stranded 230 + // check is the same threshold so this is a safe no-op). 231 + // Skip the bgWindow, invisible windows, and any window that was 232 + // registered before Phase 2 (no stored placement → defense-in-depth 233 + // pass 1 above already caught it if needed). 234 + const liveDisplays = screen.getAllDisplays(); 235 + const cursorPoint = screen.getCursorScreenPoint(); 236 + let placedCount = 0; 237 + for (const win of allWindows) { 238 + if (win.isDestroyed()) continue; 239 + if (!win.isVisible()) continue; 240 + 241 + const entry = getWindowInfo(win.id); 242 + if (!entry || entry.params.address === WEB_CORE_ADDRESS) continue; 243 + const placement = entry.params.placement as Placement | undefined; 244 + if (!placement) continue; 245 + 246 + const bounds = win.getBounds(); 247 + let parentBounds: Electron.Rectangle | undefined; 248 + if (placement.mode === 'parent-centered') { 249 + const parent = BrowserWindow.fromId(placement.parentId); 250 + if (parent && !parent.isDestroyed()) { 251 + parentBounds = parent.getBounds(); 252 + } 253 + } 254 + 255 + const result = computePlacement({ 256 + placement, 257 + currentBounds: bounds, 258 + windowSize: { width: bounds.width, height: bounds.height }, 259 + displays: liveDisplays, 260 + cursorPoint, 261 + parentBounds, 262 + }); 263 + 264 + if (result.kind === 'reposition') { 265 + console.log( 266 + `[display-watcher] Repositioning window ${win.id} (${placement.mode}): ` + 267 + `(${bounds.x},${bounds.y} ${bounds.width}x${bounds.height}) -> ` + 268 + `(${result.bounds.x},${result.bounds.y} ${result.bounds.width}x${result.bounds.height})` 269 + ); 270 + win.setBounds(result.bounds); 271 + placedCount++; 272 + } 273 + } 274 + if (placedCount > 0) { 275 + console.log(`[display-watcher] Repositioned ${placedCount} window(s) via computePlacement`); 219 276 } 220 277 221 278 // Update snapshot for next change
+146 -149
backend/electron/ipc.ts
··· 80 80 81 81 82 82 import { trackWindow } from './display-watcher.js'; 83 - import type { Placement } from './window-placement.js'; 83 + import { 84 + computePlacement, 85 + computeInitialBounds, 86 + type Placement, 87 + } from './window-placement.js'; 84 88 85 89 import { 86 90 publish, ··· 481 485 const pendingWindowKeys = new Set<string>(); 482 486 483 487 /** 484 - * Returns true if the window's current bounds are still substantially 485 - * within at least one display's workArea (>=50% area visible). Hidden 486 - * windows (zero area) are considered accessible — they'll get sane 487 - * bounds when shown. 488 + * Apply a stored `Placement` to a reused window. Reads the placement 489 + * intent from the registry, snapshots displays + cursor + (optionally) 490 + * parent bounds, calls `computePlacement`, and applies the result. 488 491 * 489 - * Used by reuse paths (URL match, keepLive key match) to detect windows 490 - * stranded by a display change while they were hidden, since the 491 - * display-watcher safety net skips invisible windows. 492 + * Replaces the inline `isWindowAccessibleNow` + `repositionOnCursorDisplay` 493 + * helpers and the per-mode branch logic that used to live in the reuse 494 + * paths. Single source of truth for "where should this reused window go?" 495 + * is the pure module. 492 496 */ 493 - function isWindowAccessibleNow(bounds: Electron.Rectangle): boolean { 494 - const winArea = bounds.width * bounds.height; 495 - if (winArea <= 0) return true; 496 - for (const d of screen.getAllDisplays()) { 497 - const wa = d.workArea; 498 - const overlapX = Math.max(bounds.x, wa.x); 499 - const overlapY = Math.max(bounds.y, wa.y); 500 - const overlapRight = Math.min(bounds.x + bounds.width, wa.x + wa.width); 501 - const overlapBottom = Math.min(bounds.y + bounds.height, wa.y + wa.height); 502 - if (overlapRight <= overlapX || overlapBottom <= overlapY) continue; 503 - const overlapArea = (overlapRight - overlapX) * (overlapBottom - overlapY); 504 - if (overlapArea / winArea >= 0.5) return true; 497 + function applyPlacementOnReuse( 498 + win: BrowserWindow, 499 + placement: Placement | undefined, 500 + reason: string, 501 + ): void { 502 + if (!placement) return; 503 + const currentBounds = win.getBounds(); 504 + let parentBounds: Electron.Rectangle | undefined; 505 + if (placement.mode === 'parent-centered') { 506 + const parent = BrowserWindow.fromId(placement.parentId); 507 + if (parent && !parent.isDestroyed()) { 508 + parentBounds = parent.getBounds(); 509 + } 505 510 } 506 - return false; 507 - } 508 - 509 - /** 510 - * Reposition a window onto the cursor's display, preserving size where 511 - * possible (clamped to the new display's workArea). Used when a reused 512 - * window's stored bounds no longer fit any display. 513 - */ 514 - function repositionOnCursorDisplay(win: BrowserWindow, reason: string): void { 515 - const bounds = win.getBounds(); 516 - const cursorDisplay = screen.getDisplayNearestPoint(screen.getCursorScreenPoint()); 517 - const wa = cursorDisplay.workArea; 518 - const newWidth = Math.min(bounds.width, wa.width); 519 - const newHeight = Math.min(bounds.height, wa.height); 520 - const x = wa.x + Math.round((wa.width - newWidth) / 2); 521 - const y = wa.y + Math.round((wa.height - newHeight) / 2); 522 - console.log( 523 - `[window-reuse] Repositioning ${win.id} (${reason}) to cursor display ${cursorDisplay.id}: ` + 524 - `(${bounds.x},${bounds.y} ${bounds.width}x${bounds.height}) -> (${x},${y} ${newWidth}x${newHeight})` 525 - ); 526 - win.setBounds({ x, y, width: newWidth, height: newHeight }); 511 + const result = computePlacement({ 512 + placement, 513 + currentBounds, 514 + windowSize: { width: currentBounds.width, height: currentBounds.height }, 515 + displays: screen.getAllDisplays(), 516 + cursorPoint: screen.getCursorScreenPoint(), 517 + parentBounds, 518 + }); 519 + if (result.kind === 'reposition') { 520 + DEBUG && console.log( 521 + `[window-reuse] applyPlacementOnReuse (${reason}) ${win.id}: ` + 522 + `(${currentBounds.x},${currentBounds.y} ${currentBounds.width}x${currentBounds.height}) -> ` + 523 + `(${result.bounds.x},${result.bounds.y} ${result.bounds.width}x${result.bounds.height}) [mode:${placement.mode}]` 524 + ); 525 + win.setBounds(result.bounds); 526 + } 527 527 } 528 528 529 529 // Exported reference to the window-open handler so other IPC surfaces (like v2 tile ··· 585 585 DEBUG && console.log('Reused window transient from appFocused:', existingData.params.transient, 'appFocused:', coordinator.isAppFocused()); 586 586 587 587 if (!isHeadless()) { 588 - // Display-change safety for keepLive reuse: 589 - // 590 - // Windows opened with `center: true` (e.g. cmd panel) want to 591 - // re-center on the active display every time they're shown — 592 - // "center" means "centered on the display the user is using 593 - // right now," not "centered once at creation." 594 - // 595 - // Other keepLive windows (page-host, slides, modals) keep 596 - // their position between invocations EXCEPT when their stored 597 - // bounds no longer fit any display — that happens when a 598 - // display was unplugged or the layout swapped while the 599 - // window was hidden, and the display-watcher safety net 600 - // skipped them because invisible windows aren't rescued. 601 - if (existingData.params.center === true) { 602 - const bounds = existingWindow.window.getBounds(); 603 - const cursorDisplay = screen.getDisplayNearestPoint(screen.getCursorScreenPoint()); 604 - const wa = cursorDisplay.workArea; 605 - const x = wa.x + Math.round((wa.width - bounds.width) / 2); 606 - const y = wa.y + Math.round((wa.height - bounds.height) / 2); 607 - if (x !== bounds.x || y !== bounds.y) { 608 - DEBUG && console.log(`[window-open] Re-centering keepLive window ${existingWindow.id} (${bounds.x},${bounds.y}) -> (${x},${y}) on display ${cursorDisplay.id}`); 609 - existingWindow.window.setBounds({ x, y, width: bounds.width, height: bounds.height }); 610 - } 611 - } else if (!isWindowAccessibleNow(existingWindow.window.getBounds())) { 612 - repositionOnCursorDisplay(existingWindow.window, 'keepLive-reuse stale bounds'); 613 - } 588 + // Display-change safety for keepLive reuse: read the stored 589 + // `Placement` intent and let `computePlacement` decide whether 590 + // to reposition. Replaces the historical `center: true` / 591 + // `isWindowAccessibleNow` branch logic — the pure module 592 + // already encodes both semantics: 593 + // - `centered` mode → always re-center on cursor display 594 + // - other modes → only reposition if stranded 595 + // See backend/electron/window-placement.ts. 596 + applyPlacementOnReuse( 597 + existingWindow.window, 598 + existingData.params.placement as Placement | undefined, 599 + 'keepLive-reuse', 600 + ); 614 601 615 602 // Stamp show-time so the modal blur handler can suppress the 616 603 // spurious blur that NSPanel + `alwaysOnTop:'floating'` emits ··· 652 639 if (existingByUrl) { 653 640 DEBUG && console.log('Reusing existing window with same URL:', url); 654 641 if (!isHeadless()) { 655 - // Display-change safety: if the existing window's bounds are 656 - // stranded (display unplugged or layout swap left it off-screen), 657 - // move it onto the cursor's display before show(). The 658 - // display-watcher safety net only runs at display-change time 659 - // and skips invisible windows, so a window closed before the 660 - // change and reopened after needs this catch. 661 - if (!isWindowAccessibleNow(existingByUrl.window.getBounds())) { 662 - repositionOnCursorDisplay(existingByUrl.window, 'url-reuse stale bounds'); 663 - } 642 + // Display-change safety: read the stored `Placement` intent and 643 + // let `computePlacement` decide whether to reposition. The 644 + // stranded-rescue path inside the pure module replaces the 645 + // historical `isWindowAccessibleNow` check. 646 + const existingData = existingByUrl.data as { params: { placement?: Placement } }; 647 + applyPlacementOnReuse( 648 + existingByUrl.window, 649 + existingData.params.placement, 650 + 'url-reuse', 651 + ); 664 652 existingByUrl.window.show(); 665 653 existingByUrl.window.focus(); 666 654 } ··· 817 805 !INTERNAL_URLS.some(u => openerUrl === u) && 818 806 !INTERNAL_URL_PREFIXES.some(p => openerUrl.startsWith(p)); 819 807 820 - // Center window on parent if opener exists and no explicit position 821 - // Priority: explicit x/y > center on parent > center on screen 822 - if (options.centerOnParent !== false && 823 - isRealParent && 824 - winOptions.x === undefined && winOptions.y === undefined) { 825 - const bounds = openerWindow.getBounds(); 826 - const winW = winOptions.width as number || APP_DEF_WIDTH; 827 - const winH = winOptions.height as number || APP_DEF_HEIGHT; 828 - winOptions.x = Math.round(bounds.x + (bounds.width - winW) / 2); 829 - winOptions.y = Math.round(bounds.y + (bounds.height - winH) / 2); 830 - DEBUG && console.log('[window-open] Centered on parent window:', openerWindow.id, 'at', winOptions.x, winOptions.y); 808 + // Phase 3: derive `Placement` intent and use `computeInitialBounds` 809 + // to fill in x/y when not explicitly supplied. The placement derivation 810 + // mirrors what's recorded on the registry below — keep these two 811 + // call sites in sync (they consult the same option flags). 812 + // 813 + // Order of precedence (intent wins over coords): 814 + // screenEdge > center > parent w/ no explicit position 815 + // > explicit x/y > cursor-display-fallback 816 + // 817 + // Slides emits `screenEdge` as 'Up'|'Down'|'Left'|'Right'; map both 818 + // spellings to 'top'|'bottom'|'left'|'right'. 819 + const screenEdgeRaw = (options as { screenEdge?: unknown }).screenEdge; 820 + const edgeMap: Record<string, 'top' | 'bottom' | 'left' | 'right'> = { 821 + Up: 'top', Down: 'bottom', Left: 'left', Right: 'right', 822 + top: 'top', bottom: 'bottom', left: 'left', right: 'right', 823 + }; 824 + const mappedEdge = typeof screenEdgeRaw === 'string' ? edgeMap[screenEdgeRaw] : undefined; 825 + const callerProvidedX = options.x !== undefined && !Number.isNaN(parseInt(options.x)); 826 + const callerProvidedY = options.y !== undefined && !Number.isNaN(parseInt(options.y)); 827 + const callerProvidedXY = callerProvidedX && callerProvidedY; 828 + 829 + let placement: Placement; 830 + if (mappedEdge) { 831 + placement = { mode: 'edge', edge: mappedEdge }; 832 + } else if (options.center === true) { 833 + placement = { mode: 'centered' }; 834 + } else if (isRealParent && !callerProvidedXY && options.centerOnParent !== false) { 835 + placement = { mode: 'parent-centered', parentId: openerWindow!.id }; 836 + } else if (callerProvidedXY) { 837 + placement = { mode: 'manual' }; 838 + } else { 839 + placement = { mode: 'cursor-display-fallback' }; 831 840 } 832 841 833 - // Center window on screen if no position specified (fallback when no opener) 834 - // Use the display where the cursor is (the active display), not the primary display, 835 - // so windows open on the screen the user is currently working on. 836 - if (winOptions.x === undefined && winOptions.y === undefined) { 837 - const cursorDisplay = screen.getDisplayNearestPoint(screen.getCursorScreenPoint()); 838 - const wa = cursorDisplay.workArea; 842 + // Fill in winOptions.x/y when not already explicitly supplied. 843 + // For `edge` mode the caller may have passed stale x/y (slides used 844 + // to compute them in the renderer from window.screen) — placement 845 + // intent wins, so we override with `computeInitialBounds` output. 846 + // Off-screen explicit x/y were already dropped earlier in the 847 + // off-screen-bounds-validation block above. 848 + const needsCompute = 849 + mappedEdge !== undefined || 850 + winOptions.x === undefined || winOptions.y === undefined; 851 + if (needsCompute) { 839 852 const winW = winOptions.width as number || APP_DEF_WIDTH; 840 853 const winH = winOptions.height as number || APP_DEF_HEIGHT; 841 - winOptions.x = wa.x + Math.round((wa.width - winW) / 2); 842 - winOptions.y = wa.y + Math.round((wa.height - winH) / 2); 854 + let parentBounds: Electron.Rectangle | undefined; 855 + if (placement.mode === 'parent-centered' && openerWindow && !openerWindow.isDestroyed()) { 856 + parentBounds = openerWindow.getBounds(); 857 + } 858 + const initial = computeInitialBounds( 859 + placement, 860 + { width: winW, height: winH }, 861 + screen.getAllDisplays(), 862 + screen.getCursorScreenPoint(), 863 + parentBounds, 864 + ); 865 + winOptions.x = initial.x; 866 + winOptions.y = initial.y; 867 + // Note: width/height clamped to workArea by computeInitialBounds — 868 + // honor the clamp so `new BrowserWindow` doesn't get oversize hints. 869 + winOptions.width = initial.width; 870 + winOptions.height = initial.height; 871 + DEBUG && console.log( 872 + `[window-open] computeInitialBounds (${placement.mode}) -> ` + 873 + `(${initial.x},${initial.y} ${initial.width}x${initial.height})` 874 + ); 843 875 } 844 876 845 877 if (options.modal === true) { ··· 867 899 const CANVAS_TRIGGER_ZONE = 8; 868 900 const CANVAS_NAVBAR_HEIGHT = 36; // Always reserve space for navbar (avoids resize jumps) 869 901 870 - // When no explicit position is provided (e.g., external URLs, pubsub-triggered opens), 871 - // compute a centered position first so canvas bounds adjustment has valid coordinates. 872 - // Without this, undefined x/y produces NaN which breaks window positioning. 873 - // Use the display where the cursor is (the active display), not the primary display. 874 - if (winOptions.x === undefined || winOptions.y === undefined) { 875 - const cursorDisplay = screen.getDisplayNearestPoint(screen.getCursorScreenPoint()); 876 - const wa = cursorDisplay.workArea; 877 - const winW = winOptions.width as number || APP_DEF_WIDTH; 878 - const winH = winOptions.height as number || APP_DEF_HEIGHT; 879 - winOptions.x = wa.x + Math.round((wa.width - winW) / 2); 880 - winOptions.y = wa.y + Math.round((wa.height - winH) / 2); 881 - } 902 + // winOptions.x/y/width/height were filled in above by 903 + // `computeInitialBounds` (or by the caller's explicit x/y). The 904 + // historical "compute centered fallback when undefined" block has 905 + // moved into `window-placement.ts`. 882 906 883 907 // Maximize hint: the saved bounds for a maximized window are the OS 884 908 // window bounds (= work area), NOT the webview bounds — re-adding ··· 1118 1142 1119 1143 console.log('[izui] Window role:', role, 'for:', url); 1120 1144 1121 - // Phase 2: derive `Placement` intent from the legacy flag soup and 1122 - // record it on the registry. This is data-only; the existing 1123 - // positioning logic above still runs. Phase 3 will read this field 1124 - // (and Phase 1's `computePlacement`) to replace the ad-hoc branches. 1125 - // 1126 - // Order of precedence (intent wins over coords): 1127 - // screenEdge > center > parent w/ no explicit position 1128 - // > explicit x/y > cursor-display-fallback 1129 - // 1130 - // Notes: 1131 - // - "explicit x/y" is judged against the ORIGINAL `options.x/y` 1132 - // fields the caller passed in — not the derived `winOptions.x/y`, 1133 - // which the parent-centering branch above may have populated. 1134 - // - Slides emits `screenEdge` as 'Up'|'Down'|'Left'|'Right'; the 1135 - // Placement type uses 'top'|'bottom'|'left'|'right'. The lookup 1136 - // table below normalises both spellings. 1137 - let placement: Placement; 1138 - const screenEdgeRaw = (options as { screenEdge?: unknown }).screenEdge; 1139 - const edgeMap: Record<string, 'top' | 'bottom' | 'left' | 'right'> = { 1140 - Up: 'top', Down: 'bottom', Left: 'left', Right: 'right', 1141 - top: 'top', bottom: 'bottom', left: 'left', right: 'right', 1142 - }; 1143 - const mappedEdge = typeof screenEdgeRaw === 'string' ? edgeMap[screenEdgeRaw] : undefined; 1144 - const callerProvidedX = options.x !== undefined && !Number.isNaN(parseInt(options.x)); 1145 - const callerProvidedY = options.y !== undefined && !Number.isNaN(parseInt(options.y)); 1146 - const callerProvidedXY = callerProvidedX && callerProvidedY; 1147 - if (mappedEdge) { 1148 - placement = { mode: 'edge', edge: mappedEdge }; 1149 - } else if (options.center === true) { 1150 - placement = { mode: 'centered' }; 1151 - } else if (isRealParent && !callerProvidedXY && options.centerOnParent !== false) { 1152 - placement = { mode: 'parent-centered', parentId: openerWindow!.id }; 1153 - } else if (callerProvidedXY) { 1154 - placement = { mode: 'manual' }; 1155 - } else { 1156 - placement = { mode: 'cursor-display-fallback' }; 1157 - } 1158 - 1145 + // `placement` was derived earlier (in Group B fresh-open 1146 + // positioning) and the same value is recorded on the registry 1147 + // here so reuse paths + display-watcher can consume it. 1159 1148 const windowParams = { 1160 1149 ...options, 1161 1150 address: url, ··· 2130 2119 2131 2120 // Maximize window if requested (fills screen without OS fullscreen) 2132 2121 // Use explicit bounds instead of win.maximize() because panel-type windows 2133 - // on macOS don't respond to maximize() properly 2122 + // on macOS don't respond to maximize() properly. Route through 2123 + // `computeInitialBounds` with 'centered' + an oversize hint so the 2124 + // workArea-clamp produces a fill-the-screen rectangle on the cursor 2125 + // display — keeps every position decision in `ipc.ts` flowing 2126 + // through the pure module. 2134 2127 if (options.maximize === true && !isHeadless()) { 2135 - const display = screen.getDisplayNearestPoint(screen.getCursorScreenPoint()); 2136 - const wa = display.workArea; 2137 - win.setBounds({ x: wa.x, y: wa.y, width: wa.width, height: wa.height }); 2128 + const filled = computeInitialBounds( 2129 + { mode: 'centered' }, 2130 + { width: Number.MAX_SAFE_INTEGER, height: Number.MAX_SAFE_INTEGER }, 2131 + screen.getAllDisplays(), 2132 + screen.getCursorScreenPoint(), 2133 + ); 2134 + win.setBounds(filled); 2138 2135 } 2139 2136 2140 2137 // Handle overlay mode: hide other windows after this one is ready
+82
backend/electron/window-placement.test.ts
··· 11 11 12 12 import { 13 13 computePlacement, 14 + computeInitialBounds, 14 15 type Placement, 15 16 type PlacementInput, 16 17 type PlacementResult, ··· 580 581 cursorPoint: pt(960, 540), 581 582 })); 582 583 expectNoChange(result); 584 + }); 585 + }); 586 + 587 + describe('computeInitialBounds: fresh-window helper', () => { 588 + it('cursor-display-fallback centers on cursor display', () => { 589 + const displays = fakeDisplays([displayA, displayB]); 590 + const bounds = computeInitialBounds( 591 + { mode: 'cursor-display-fallback' }, 592 + { width: 800, height: 600 }, 593 + displays, 594 + pt(2880, 540), // cursor on display B 595 + ); 596 + assert.strictEqual(bounds.x, 1920 + Math.round((1920 - 800) / 2)); 597 + assert.strictEqual(bounds.y, Math.round((1080 - 600) / 2)); 598 + assert.strictEqual(bounds.width, 800); 599 + assert.strictEqual(bounds.height, 600); 600 + }); 601 + 602 + it('centered mode centers on cursor display', () => { 603 + const displays = fakeDisplays([displayA, displayB]); 604 + const bounds = computeInitialBounds( 605 + { mode: 'centered' }, 606 + { width: 600, height: 400 }, 607 + displays, 608 + pt(960, 540), 609 + ); 610 + assert.strictEqual(bounds.x, Math.round((1920 - 600) / 2)); 611 + assert.strictEqual(bounds.y, Math.round((1080 - 400) / 2)); 612 + }); 613 + 614 + it('parent-centered centers on parentBounds', () => { 615 + const displays = fakeDisplays([displayA]); 616 + const parent = rect(100, 100, 1000, 800); 617 + const bounds = computeInitialBounds( 618 + { mode: 'parent-centered', parentId: 42 }, 619 + { width: 400, height: 300 }, 620 + displays, 621 + pt(960, 540), 622 + parent, 623 + ); 624 + assert.strictEqual(bounds.x, 100 + Math.round((1000 - 400) / 2)); 625 + assert.strictEqual(bounds.y, 100 + Math.round((800 - 300) / 2)); 626 + }); 627 + 628 + it('parent-centered without parentBounds falls back to cursor center', () => { 629 + const displays = fakeDisplays([displayA]); 630 + const bounds = computeInitialBounds( 631 + { mode: 'parent-centered', parentId: 42 }, 632 + { width: 400, height: 300 }, 633 + displays, 634 + pt(960, 540), 635 + undefined, 636 + ); 637 + assert.strictEqual(bounds.x, Math.round((1920 - 400) / 2)); 638 + assert.strictEqual(bounds.y, Math.round((1080 - 300) / 2)); 639 + }); 640 + 641 + it('edge: top anchors to top of cursor display, X-centered', () => { 642 + const displays = fakeDisplays([displayA, displayB]); 643 + const bounds = computeInitialBounds( 644 + { mode: 'edge', edge: 'top' }, 645 + { width: 600, height: 200 }, 646 + displays, 647 + pt(2880, 540), // cursor on B 648 + ); 649 + assert.strictEqual(bounds.x, 1920 + Math.round((1920 - 600) / 2)); 650 + assert.strictEqual(bounds.y, 0); 651 + assert.strictEqual(bounds.width, 600); 652 + assert.strictEqual(bounds.height, 200); 653 + }); 654 + 655 + it('clamps oversize windows to workArea', () => { 656 + const displays = fakeDisplays([laptopOnly]); 657 + const bounds = computeInitialBounds( 658 + { mode: 'centered' }, 659 + { width: 9999, height: 9999 }, 660 + displays, 661 + pt(720, 450), 662 + ); 663 + assert.strictEqual(bounds.width, 1440); 664 + assert.strictEqual(bounds.height, 900); 583 665 }); 584 666 }); 585 667
+74
backend/electron/window-placement.ts
··· 372 372 } 373 373 } 374 374 } 375 + 376 + // --------------------------------------------------------------------------- 377 + // Fresh-window helper 378 + // --------------------------------------------------------------------------- 379 + 380 + /** 381 + * Compute initial bounds for a window that doesn't yet exist (no 382 + * `currentBounds` available). Used by the fresh-open path in `ipc.ts` to 383 + * derive `winOptions.x/y/width/height` BEFORE `new BrowserWindow()`. 384 + * 385 + * Unlike `computePlacement`, this never returns `no-change` — there's no 386 + * "current bounds" to compare against. It always produces a concrete 387 + * `Rectangle`, computed against the cursor / parent / edge per the 388 + * placement intent. 389 + * 390 + * Modes: 391 + * - `centered` and `cursor-display-fallback` both center on the cursor 392 + * display (for fresh opens, "fallback" reduces to "cursor center" since 393 + * no caller-supplied position exists at this stage). 394 + * - `edge` anchors to the requested edge of the cursor display. 395 + * - `parent-centered` centers on `parentBounds`. If no `parentBounds` 396 + * provided (parent destroyed mid-flight), falls back to cursor center. 397 + * - `manual` should never reach here in practice — manual placement means 398 + * the caller already supplied x/y. We treat it as cursor center as a 399 + * safety fallback rather than throwing. 400 + * 401 + * Returns a workArea-clamped `Rectangle`. If `displays` is empty, returns 402 + * a rect at (0,0) with the requested size — caller is presumably running 403 + * headless and the bounds will be ignored. 404 + */ 405 + export function computeInitialBounds( 406 + placement: Placement, 407 + size: { width: number; height: number }, 408 + displays: Electron.Display[], 409 + cursorPoint: Electron.Point, 410 + parentBounds?: Electron.Rectangle, 411 + ): Electron.Rectangle { 412 + if (displays.length === 0) { 413 + return { x: 0, y: 0, width: size.width, height: size.height }; 414 + } 415 + 416 + switch (placement.mode) { 417 + case 'centered': 418 + case 'cursor-display-fallback': 419 + case 'manual': { 420 + const target = pickCursorOrPrimaryDisplay(displays, cursorPoint)!; 421 + const clamped = clampSize(size, target.workArea); 422 + return centerOn(target.workArea, clamped); 423 + } 424 + 425 + case 'edge': { 426 + const target = pickCursorOrPrimaryDisplay(displays, cursorPoint)!; 427 + const clamped = clampSize(size, target.workArea); 428 + return anchorToEdge(target.workArea, clamped, placement.edge); 429 + } 430 + 431 + case 'parent-centered': { 432 + if (!parentBounds) { 433 + const target = pickCursorOrPrimaryDisplay(displays, cursorPoint)!; 434 + const clamped = clampSize(size, target.workArea); 435 + return centerOn(target.workArea, clamped); 436 + } 437 + const parentCenter: Electron.Point = { 438 + x: parentBounds.x + Math.round(parentBounds.width / 2), 439 + y: parentBounds.y + Math.round(parentBounds.height / 2), 440 + }; 441 + const targetDisplay = 442 + findDisplayForPoint(displays, parentCenter) ?? 443 + pickCursorOrPrimaryDisplay(displays, cursorPoint)!; 444 + const clamped = clampSize(size, targetDisplay.workArea); 445 + return centerOnRect(parentBounds, clamped); 446 + } 447 + } 448 + }