···808081818282import { trackWindow } from './display-watcher.js';
8383+import type { Placement } from './window-placement.js';
83848485import {
8586 publish,
···479480 */
480481const pendingWindowKeys = new Set<string>();
481482483483+/**
484484+ * Returns true if the window's current bounds are still substantially
485485+ * within at least one display's workArea (>=50% area visible). Hidden
486486+ * windows (zero area) are considered accessible — they'll get sane
487487+ * bounds when shown.
488488+ *
489489+ * Used by reuse paths (URL match, keepLive key match) to detect windows
490490+ * stranded by a display change while they were hidden, since the
491491+ * display-watcher safety net skips invisible windows.
492492+ */
493493+function isWindowAccessibleNow(bounds: Electron.Rectangle): boolean {
494494+ const winArea = bounds.width * bounds.height;
495495+ if (winArea <= 0) return true;
496496+ for (const d of screen.getAllDisplays()) {
497497+ const wa = d.workArea;
498498+ const overlapX = Math.max(bounds.x, wa.x);
499499+ const overlapY = Math.max(bounds.y, wa.y);
500500+ const overlapRight = Math.min(bounds.x + bounds.width, wa.x + wa.width);
501501+ const overlapBottom = Math.min(bounds.y + bounds.height, wa.y + wa.height);
502502+ if (overlapRight <= overlapX || overlapBottom <= overlapY) continue;
503503+ const overlapArea = (overlapRight - overlapX) * (overlapBottom - overlapY);
504504+ if (overlapArea / winArea >= 0.5) return true;
505505+ }
506506+ return false;
507507+}
508508+509509+/**
510510+ * Reposition a window onto the cursor's display, preserving size where
511511+ * possible (clamped to the new display's workArea). Used when a reused
512512+ * window's stored bounds no longer fit any display.
513513+ */
514514+function repositionOnCursorDisplay(win: BrowserWindow, reason: string): void {
515515+ const bounds = win.getBounds();
516516+ const cursorDisplay = screen.getDisplayNearestPoint(screen.getCursorScreenPoint());
517517+ const wa = cursorDisplay.workArea;
518518+ const newWidth = Math.min(bounds.width, wa.width);
519519+ const newHeight = Math.min(bounds.height, wa.height);
520520+ const x = wa.x + Math.round((wa.width - newWidth) / 2);
521521+ const y = wa.y + Math.round((wa.height - newHeight) / 2);
522522+ console.log(
523523+ `[window-reuse] Repositioning ${win.id} (${reason}) to cursor display ${cursorDisplay.id}: ` +
524524+ `(${bounds.x},${bounds.y} ${bounds.width}x${bounds.height}) -> (${x},${y} ${newWidth}x${newHeight})`
525525+ );
526526+ win.setBounds({ x, y, width: newWidth, height: newHeight });
527527+}
528528+482529// Exported reference to the window-open handler so other IPC surfaces (like v2 tile
483530// IPC) can delegate to it without duplicating ~1300 lines of window creation logic.
484531// Populated by registerWindowHandlers() at startup.
···538585 DEBUG && console.log('Reused window transient from appFocused:', existingData.params.transient, 'appFocused:', coordinator.isAppFocused());
539586540587 if (!isHeadless()) {
588588+ // Display-change safety for keepLive reuse:
589589+ //
590590+ // Windows opened with `center: true` (e.g. cmd panel) want to
591591+ // re-center on the active display every time they're shown —
592592+ // "center" means "centered on the display the user is using
593593+ // right now," not "centered once at creation."
594594+ //
595595+ // Other keepLive windows (page-host, slides, modals) keep
596596+ // their position between invocations EXCEPT when their stored
597597+ // bounds no longer fit any display — that happens when a
598598+ // display was unplugged or the layout swapped while the
599599+ // window was hidden, and the display-watcher safety net
600600+ // skipped them because invisible windows aren't rescued.
601601+ if (existingData.params.center === true) {
602602+ const bounds = existingWindow.window.getBounds();
603603+ const cursorDisplay = screen.getDisplayNearestPoint(screen.getCursorScreenPoint());
604604+ const wa = cursorDisplay.workArea;
605605+ const x = wa.x + Math.round((wa.width - bounds.width) / 2);
606606+ const y = wa.y + Math.round((wa.height - bounds.height) / 2);
607607+ if (x !== bounds.x || y !== bounds.y) {
608608+ DEBUG && console.log(`[window-open] Re-centering keepLive window ${existingWindow.id} (${bounds.x},${bounds.y}) -> (${x},${y}) on display ${cursorDisplay.id}`);
609609+ existingWindow.window.setBounds({ x, y, width: bounds.width, height: bounds.height });
610610+ }
611611+ } else if (!isWindowAccessibleNow(existingWindow.window.getBounds())) {
612612+ repositionOnCursorDisplay(existingWindow.window, 'keepLive-reuse stale bounds');
613613+ }
614614+541615 // Stamp show-time so the modal blur handler can suppress the
542616 // spurious blur that NSPanel + `alwaysOnTop:'floating'` emits
543617 // during the first ~200ms after show(). Without this, hitting
···578652 if (existingByUrl) {
579653 DEBUG && console.log('Reusing existing window with same URL:', url);
580654 if (!isHeadless()) {
655655+ // Display-change safety: if the existing window's bounds are
656656+ // stranded (display unplugged or layout swap left it off-screen),
657657+ // move it onto the cursor's display before show(). The
658658+ // display-watcher safety net only runs at display-change time
659659+ // and skips invisible windows, so a window closed before the
660660+ // change and reopened after needs this catch.
661661+ if (!isWindowAccessibleNow(existingByUrl.window.getBounds())) {
662662+ repositionOnCursorDisplay(existingByUrl.window, 'url-reuse stale bounds');
663663+ }
581664 existingByUrl.window.show();
582665 existingByUrl.window.focus();
583666 }
···1035111810361119 console.log('[izui] Window role:', role, 'for:', url);
1037112011211121+ // Phase 2: derive `Placement` intent from the legacy flag soup and
11221122+ // record it on the registry. This is data-only; the existing
11231123+ // positioning logic above still runs. Phase 3 will read this field
11241124+ // (and Phase 1's `computePlacement`) to replace the ad-hoc branches.
11251125+ //
11261126+ // Order of precedence (intent wins over coords):
11271127+ // screenEdge > center > parent w/ no explicit position
11281128+ // > explicit x/y > cursor-display-fallback
11291129+ //
11301130+ // Notes:
11311131+ // - "explicit x/y" is judged against the ORIGINAL `options.x/y`
11321132+ // fields the caller passed in — not the derived `winOptions.x/y`,
11331133+ // which the parent-centering branch above may have populated.
11341134+ // - Slides emits `screenEdge` as 'Up'|'Down'|'Left'|'Right'; the
11351135+ // Placement type uses 'top'|'bottom'|'left'|'right'. The lookup
11361136+ // table below normalises both spellings.
11371137+ let placement: Placement;
11381138+ const screenEdgeRaw = (options as { screenEdge?: unknown }).screenEdge;
11391139+ const edgeMap: Record<string, 'top' | 'bottom' | 'left' | 'right'> = {
11401140+ Up: 'top', Down: 'bottom', Left: 'left', Right: 'right',
11411141+ top: 'top', bottom: 'bottom', left: 'left', right: 'right',
11421142+ };
11431143+ const mappedEdge = typeof screenEdgeRaw === 'string' ? edgeMap[screenEdgeRaw] : undefined;
11441144+ const callerProvidedX = options.x !== undefined && !Number.isNaN(parseInt(options.x));
11451145+ const callerProvidedY = options.y !== undefined && !Number.isNaN(parseInt(options.y));
11461146+ const callerProvidedXY = callerProvidedX && callerProvidedY;
11471147+ if (mappedEdge) {
11481148+ placement = { mode: 'edge', edge: mappedEdge };
11491149+ } else if (options.center === true) {
11501150+ placement = { mode: 'centered' };
11511151+ } else if (isRealParent && !callerProvidedXY && options.centerOnParent !== false) {
11521152+ placement = { mode: 'parent-centered', parentId: openerWindow!.id };
11531153+ } else if (callerProvidedXY) {
11541154+ placement = { mode: 'manual' };
11551155+ } else {
11561156+ placement = { mode: 'cursor-display-fallback' };
11571157+ }
11581158+10381159 const windowParams = {
10391160 ...options,
10401161 address: url,
10411162 transient: isTransient,
10421163 parentWindowId,
10431164 role,
11651165+ placement,
10441166 };
10451045- console.log('Adding window to manager:', win.id, 'escapeMode:', windowParams.escapeMode, 'modal:', windowParams.modal, 'keepLive:', windowParams.keepLive, 'transient:', isTransient);
11671167+ console.log('Adding window to manager:', win.id, 'escapeMode:', windowParams.escapeMode, 'modal:', windowParams.modal, 'keepLive:', windowParams.keepLive, 'transient:', isTransient, 'placement:', placement);
10461168 registerWindow(win.id, msg.source, windowParams);
10471169 const coordinator = getIzuiCoordinator();
10481170 coordinator.pushWindow(win.id);
···12351357 popupWin.setBackgroundColor('#00000000');
12361358 }
1237135912381238- // Register in window manager with proper IZUI role
13601360+ // Register in window manager with proper IZUI role.
13611361+ //
13621362+ // Phase 2: webview popups are spawned from a real parent (the opener
13631363+ // is a content window) and lack any caller-supplied positioning,
13641364+ // so they map to `parent-centered`. The fullscreen-canvas resize
13651365+ // happens immediately above, but the placement intent is still
13661366+ // "this window belongs with its parent." Phase 3 / 5 may revisit
13671367+ // — for now, just record the intent so consumers don't see undefined.
13681368+ const popupPlacement: Placement = { mode: 'parent-centered', parentId: openerWinId };
12391369 const popupParams: Record<string, unknown> = {
12401370 address: popupUrl,
12411371 transient: isTransient,
12421372 parentWindowId: openerWinId,
12431373 role: 'child-content',
13741374+ placement: popupPlacement,
12441375 };
12451376 if (spaceMode) {
12461377 popupParams.spaceMode = spaceMode;
+7
backend/electron/main.ts
···3232import { getProfileSession, getPartitionString, getCurrentProfileId } from './session-partition.js';
3333import { getIzuiCoordinator } from './izui-state.js';
3434import { clearLastFocusedVisibleWindowId } from './ipc.js';
3535+import type { Placement } from './window-placement.js';
35363637// Configuration
3738export interface AppConfig {
···12621263 // so any `findWindowByKey(WEB_CORE_ADDRESS, 'background-core')`
12631264 // lookups continue to work.
12641265 const systemAddress = getSystemAddress();
12661266+ // Phase 2: record `placement` intent on the registry. The core background
12671267+ // window is hidden infrastructure (never positioned by the user, never
12681268+ // visible), but we still tag it with the catch-all so consumers reading
12691269+ // `params.placement` never see undefined. See docs/window-placement-refactor.md.
12701270+ const corePlacement: Placement = { mode: 'cursor-display-fallback' };
12651271 registerWindow(win.id, systemAddress, {
12661272 key: 'background-core',
12671273 address: WEB_CORE_ADDRESS,
12741274+ placement: corePlacement,
12681275 });
1269127612701277 // Devtools for the background window (dev-profile only, not tests /
+8
features/slides/background.js
···145145 x,
146146 y,
147147148148+ // Phase 2: pass `screenEdge` through alongside the legacy x/y so the
149149+ // window-open path in main records `placement: { mode: 'edge', edge }`
150150+ // on the registry. The renderer math above stays in place — Phase 4
151151+ // deletes it once main-process `computePlacement` is wired up. When
152152+ // both screenEdge AND explicit x/y are present, intent wins over
153153+ // coords (see ipc.ts placement-derivation precedence).
154154+ screenEdge: item.screenEdge,
155155+148156 // tracking
149157 trackingSource: 'slide',
150158 trackingSourceId: item.screenEdge ? `slide_${item.screenEdge}` : 'slide',