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 2 — record placement intent on every window

+148 -2
+133 -2
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 84 84 85 import { 85 86 publish, ··· 479 480 */ 480 481 const pendingWindowKeys = new Set<string>(); 481 482 483 + /** 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 + * 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 + */ 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; 505 + } 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 }); 527 + } 528 + 482 529 // Exported reference to the window-open handler so other IPC surfaces (like v2 tile 483 530 // IPC) can delegate to it without duplicating ~1300 lines of window creation logic. 484 531 // Populated by registerWindowHandlers() at startup. ··· 538 585 DEBUG && console.log('Reused window transient from appFocused:', existingData.params.transient, 'appFocused:', coordinator.isAppFocused()); 539 586 540 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 + } 614 + 541 615 // Stamp show-time so the modal blur handler can suppress the 542 616 // spurious blur that NSPanel + `alwaysOnTop:'floating'` emits 543 617 // during the first ~200ms after show(). Without this, hitting ··· 578 652 if (existingByUrl) { 579 653 DEBUG && console.log('Reusing existing window with same URL:', url); 580 654 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 + } 581 664 existingByUrl.window.show(); 582 665 existingByUrl.window.focus(); 583 666 } ··· 1035 1118 1036 1119 console.log('[izui] Window role:', role, 'for:', url); 1037 1120 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 + 1038 1159 const windowParams = { 1039 1160 ...options, 1040 1161 address: url, 1041 1162 transient: isTransient, 1042 1163 parentWindowId, 1043 1164 role, 1165 + placement, 1044 1166 }; 1045 - console.log('Adding window to manager:', win.id, 'escapeMode:', windowParams.escapeMode, 'modal:', windowParams.modal, 'keepLive:', windowParams.keepLive, 'transient:', isTransient); 1167 + console.log('Adding window to manager:', win.id, 'escapeMode:', windowParams.escapeMode, 'modal:', windowParams.modal, 'keepLive:', windowParams.keepLive, 'transient:', isTransient, 'placement:', placement); 1046 1168 registerWindow(win.id, msg.source, windowParams); 1047 1169 const coordinator = getIzuiCoordinator(); 1048 1170 coordinator.pushWindow(win.id); ··· 1235 1357 popupWin.setBackgroundColor('#00000000'); 1236 1358 } 1237 1359 1238 - // Register in window manager with proper IZUI role 1360 + // Register in window manager with proper IZUI role. 1361 + // 1362 + // Phase 2: webview popups are spawned from a real parent (the opener 1363 + // is a content window) and lack any caller-supplied positioning, 1364 + // so they map to `parent-centered`. The fullscreen-canvas resize 1365 + // happens immediately above, but the placement intent is still 1366 + // "this window belongs with its parent." Phase 3 / 5 may revisit 1367 + // — for now, just record the intent so consumers don't see undefined. 1368 + const popupPlacement: Placement = { mode: 'parent-centered', parentId: openerWinId }; 1239 1369 const popupParams: Record<string, unknown> = { 1240 1370 address: popupUrl, 1241 1371 transient: isTransient, 1242 1372 parentWindowId: openerWinId, 1243 1373 role: 'child-content', 1374 + placement: popupPlacement, 1244 1375 }; 1245 1376 if (spaceMode) { 1246 1377 popupParams.spaceMode = spaceMode;
+7
backend/electron/main.ts
··· 32 32 import { getProfileSession, getPartitionString, getCurrentProfileId } from './session-partition.js'; 33 33 import { getIzuiCoordinator } from './izui-state.js'; 34 34 import { clearLastFocusedVisibleWindowId } from './ipc.js'; 35 + import type { Placement } from './window-placement.js'; 35 36 36 37 // Configuration 37 38 export interface AppConfig { ··· 1262 1263 // so any `findWindowByKey(WEB_CORE_ADDRESS, 'background-core')` 1263 1264 // lookups continue to work. 1264 1265 const systemAddress = getSystemAddress(); 1266 + // Phase 2: record `placement` intent on the registry. The core background 1267 + // window is hidden infrastructure (never positioned by the user, never 1268 + // visible), but we still tag it with the catch-all so consumers reading 1269 + // `params.placement` never see undefined. See docs/window-placement-refactor.md. 1270 + const corePlacement: Placement = { mode: 'cursor-display-fallback' }; 1265 1271 registerWindow(win.id, systemAddress, { 1266 1272 key: 'background-core', 1267 1273 address: WEB_CORE_ADDRESS, 1274 + placement: corePlacement, 1268 1275 }); 1269 1276 1270 1277 // Devtools for the background window (dev-profile only, not tests /
+8
features/slides/background.js
··· 145 145 x, 146 146 y, 147 147 148 + // Phase 2: pass `screenEdge` through alongside the legacy x/y so the 149 + // window-open path in main records `placement: { mode: 'edge', edge }` 150 + // on the registry. The renderer math above stays in place — Phase 4 151 + // deletes it once main-process `computePlacement` is wired up. When 152 + // both screenEdge AND explicit x/y are present, intent wins over 153 + // coords (see ipc.ts placement-derivation precedence). 154 + screenEdge: item.screenEdge, 155 + 148 156 // tracking 149 157 trackingSource: 'slide', 150 158 trackingSourceId: item.screenEdge ? `slide_${item.screenEdge}` : 'slide',