experiments in a post-browser web
10
fork

Configure Feed

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

refactor(display-watcher): replace 3-phase algorithm with safety-net-only approach

+373 -1840
+289 -1170
backend/electron/display-watcher.test.ts
··· 3 3 * 4 4 * These test the pure math functions extracted from display-watcher.ts: 5 5 * - findDisplayForPoint: determine which display contains a point 6 - * - isWindowAccessible: check if a window title bar is reachable 7 - * - findBestNewDisplay: find the closest display to place a displaced window 8 - * - calculateGroupRepositionedBounds: group-based distance scaling for new display 9 - * - computeHomeDisplay: compute home display info for a displaced window 10 - * - findMatchingNewDisplay: match a displaced window's home to a new display 11 - * - computeRestoredBounds: compute restored bounds for a displaced window 6 + * - isWindowAccessible: check if a window has >=30% area overlap with any display 7 + * - findBestNewDisplay: find the nearest display by center-point proximity 12 8 * 13 9 * All functions are pure (no Electron dependencies) so they can run 14 10 * under ELECTRON_RUN_AS_NODE=1 without a real display server. ··· 34 30 workArea: Rectangle; 35 31 } 36 32 37 - interface WindowHomeDisplay { 38 - displayId: number; 39 - resolution: { width: number; height: number }; 40 - relativePosition: { 41 - centerX: number; 42 - centerY: number; 43 - width: number; 44 - height: number; 45 - }; 46 - } 47 - 48 33 // ============================================================================ 49 34 // Pure logic functions (extracted from display-watcher.ts - identical implementations) 50 35 // ============================================================================ ··· 66 51 } 67 52 68 53 function isWindowAccessible(displays: DisplaySnapshot[], bounds: Rectangle): boolean { 69 - const centerX = bounds.x + Math.round(bounds.width / 2); 70 - const topY = bounds.y + 15; 71 - return findDisplayForPoint(displays, centerX, topY) !== null; 54 + const windowArea = bounds.width * bounds.height; 55 + 56 + // Zero-size windows are always accessible 57 + if (windowArea <= 0) return true; 58 + 59 + for (const d of displays) { 60 + const wa = d.workArea; 61 + 62 + const overlapX = Math.max(bounds.x, wa.x); 63 + const overlapY = Math.max(bounds.y, wa.y); 64 + const overlapRight = Math.min(bounds.x + bounds.width, wa.x + wa.width); 65 + const overlapBottom = Math.min(bounds.y + bounds.height, wa.y + wa.height); 66 + 67 + if (overlapRight > overlapX && overlapBottom > overlapY) { 68 + const overlapArea = (overlapRight - overlapX) * (overlapBottom - overlapY); 69 + if (overlapArea / windowArea >= 0.3) { 70 + return true; 71 + } 72 + } 73 + } 74 + 75 + return false; 72 76 } 73 77 74 78 function findBestNewDisplay( ··· 81 85 } 82 86 83 87 if (oldDisplay) { 84 - const sameId = newDisplays.find(d => d.id === oldDisplay.id); 85 - if (sameId) return sameId; 86 - 87 88 const oldCenterX = oldDisplay.workArea.x + oldDisplay.workArea.width / 2; 88 89 const oldCenterY = oldDisplay.workArea.y + oldDisplay.workArea.height / 2; 89 90 ··· 105 106 return primary || newDisplays[0]; 106 107 } 107 108 108 - function calculateGroupRepositionedBounds( 109 - windowBounds: Rectangle[], 110 - oldDisplay: DisplaySnapshot, 111 - newDisplay: DisplaySnapshot 112 - ): Rectangle[] { 113 - const oldWA = oldDisplay.workArea; 114 - const newWA = newDisplay.workArea; 115 - 116 - const scaleX = oldWA.width > 0 ? newWA.width / oldWA.width : 1; 117 - const scaleY = oldWA.height > 0 ? newWA.height / oldWA.height : 1; 118 - 119 - const centers = windowBounds.map(wb => ({ 120 - x: wb.x + wb.width / 2, 121 - y: wb.y + wb.height / 2, 122 - })); 123 - 124 - let minCX = Infinity, maxCX = -Infinity; 125 - let minCY = Infinity, maxCY = -Infinity; 126 - for (const c of centers) { 127 - minCX = Math.min(minCX, c.x); 128 - maxCX = Math.max(maxCX, c.x); 129 - minCY = Math.min(minCY, c.y); 130 - maxCY = Math.max(maxCY, c.y); 131 - } 132 - 133 - const groupCenterX = (minCX + maxCX) / 2; 134 - const groupCenterY = (minCY + maxCY) / 2; 135 - 136 - const groupRelX = oldWA.width > 0 ? (groupCenterX - oldWA.x) / oldWA.width : 0.5; 137 - const groupRelY = oldWA.height > 0 ? (groupCenterY - oldWA.y) / oldWA.height : 0.5; 138 - const newGroupCenterX = newWA.x + groupRelX * newWA.width; 139 - const newGroupCenterY = newWA.y + groupRelY * newWA.height; 140 - 141 - const results: Rectangle[] = []; 142 - 143 - for (let i = 0; i < windowBounds.length; i++) { 144 - const wb = windowBounds[i]; 145 - const center = centers[i]; 146 - 147 - const offsetX = center.x - groupCenterX; 148 - const offsetY = center.y - groupCenterY; 149 - 150 - const scaledOffsetX = offsetX * scaleX; 151 - const scaledOffsetY = offsetY * scaleY; 152 - 153 - let newW = Math.round(wb.width * scaleX); 154 - let newH = Math.round(wb.height * scaleY); 155 - 156 - newW = Math.max(newW, 200); 157 - newH = Math.max(newH, 150); 158 - 159 - newW = Math.min(newW, newWA.width); 160 - newH = Math.min(newH, newWA.height); 161 - 162 - const newCenterX = newGroupCenterX + scaledOffsetX; 163 - const newCenterY = newGroupCenterY + scaledOffsetY; 164 - 165 - let newX = Math.round(newCenterX - newW / 2); 166 - let newY = Math.round(newCenterY - newH / 2); 167 - 168 - newX = Math.max(newWA.x, Math.min(newX, newWA.x + newWA.width - newW)); 169 - newY = Math.max(newWA.y, Math.min(newY, newWA.y + newWA.height - newH)); 170 - 171 - results.push({ x: newX, y: newY, width: newW, height: newH }); 172 - } 173 - 174 - return results; 175 - } 176 - 177 - /** 178 - * Compute home display info for a window being displaced from a display. 179 - * Pure version of saveWindowHomeDisplay (no BrowserWindow dependency). 180 - */ 181 - function computeHomeDisplay( 182 - bounds: Rectangle, 183 - display: DisplaySnapshot 184 - ): WindowHomeDisplay { 185 - const wa = display.workArea; 186 - const centerX = bounds.x + bounds.width / 2; 187 - const centerY = bounds.y + bounds.height / 2; 188 - 189 - return { 190 - displayId: display.id, 191 - resolution: { width: wa.width, height: wa.height }, 192 - relativePosition: { 193 - centerX: wa.width > 0 ? (centerX - wa.x) / wa.width : 0.5, 194 - centerY: wa.height > 0 ? (centerY - wa.y) / wa.height : 0.5, 195 - width: wa.width > 0 ? bounds.width / wa.width : 0.5, 196 - height: wa.height > 0 ? bounds.height / wa.height : 0.5, 197 - }, 198 - }; 199 - } 200 - 201 - /** 202 - * Find a matching new display for a displaced window's home display. 203 - * Pure version identical to display-watcher.ts implementation. 204 - */ 205 - function findMatchingNewDisplay( 206 - home: WindowHomeDisplay, 207 - addedDisplays: DisplaySnapshot[] 208 - ): DisplaySnapshot | null { 209 - const byId = addedDisplays.find(d => d.id === home.displayId); 210 - if (byId) return byId; 211 - 212 - const tolerance = 0.05; 213 - for (const d of addedDisplays) { 214 - const wa = d.workArea; 215 - const widthRatio = home.resolution.width > 0 216 - ? Math.abs(wa.width - home.resolution.width) / home.resolution.width 217 - : (wa.width === 0 ? 0 : 1); 218 - const heightRatio = home.resolution.height > 0 219 - ? Math.abs(wa.height - home.resolution.height) / home.resolution.height 220 - : (wa.height === 0 ? 0 : 1); 221 - 222 - if (widthRatio <= tolerance && heightRatio <= tolerance) { 223 - return d; 224 - } 225 - } 226 - 227 - return null; 228 - } 229 - 230 - /** 231 - * Compute restored bounds for a window returning to its home display. 232 - * Pure version of restoreWindowToHomeDisplay (returns bounds instead of calling setBounds). 233 - */ 234 - function computeRestoredBounds( 235 - home: WindowHomeDisplay, 236 - display: DisplaySnapshot 237 - ): Rectangle { 238 - const wa = display.workArea; 239 - const rel = home.relativePosition; 240 - 241 - let newW = Math.round(rel.width * wa.width); 242 - let newH = Math.round(rel.height * wa.height); 243 - 244 - newW = Math.max(newW, 200); 245 - newH = Math.max(newH, 150); 246 - 247 - newW = Math.min(newW, wa.width); 248 - newH = Math.min(newH, wa.height); 249 - 250 - const centerX = wa.x + rel.centerX * wa.width; 251 - const centerY = wa.y + rel.centerY * wa.height; 252 - 253 - let newX = Math.round(centerX - newW / 2); 254 - let newY = Math.round(centerY - newH / 2); 255 - 256 - newX = Math.max(wa.x, Math.min(newX, wa.x + wa.width - newW)); 257 - newY = Math.max(wa.y, Math.min(newY, wa.y + wa.height - newH)); 258 - 259 - return { x: newX, y: newY, width: newW, height: newH }; 260 - } 261 - 262 109 // ============================================================================ 263 110 // Test fixtures 264 111 // ============================================================================ ··· 328 175 }); 329 176 }); 330 177 331 - describe("isWindowAccessible", () => { 178 + describe("isWindowAccessible (area overlap)", () => { 332 179 const displays = [display1440, display1080]; 333 180 334 181 it("window fully on primary display is accessible", () => { ··· 347 194 assert.strictEqual(isWindowAccessible(displays, { x: 500, y: 5000, width: 800, height: 600 }), false); 348 195 }); 349 196 350 - it("window partially off-screen but title bar reachable is accessible", () => { 351 - assert.strictEqual(isWindowAccessible(displays, { x: 100, y: 1200, width: 800, height: 600 }), true); 197 + it("zero-size window is always accessible", () => { 198 + assert.strictEqual(isWindowAccessible(displays, { x: -5000, y: -5000, width: 0, height: 0 }), true); 199 + }); 200 + 201 + it("window with zero width is accessible", () => { 202 + assert.strictEqual(isWindowAccessible(displays, { x: 100, y: 100, width: 0, height: 600 }), true); 203 + }); 204 + 205 + it("window with exactly 30% overlap is accessible", () => { 206 + // Window 1000x1000 = 1,000,000 area 207 + // Need overlap of 300,000 (30%) 208 + // Place window so only 300px width overlaps with display1440 workArea 209 + // Window at x=-700, width=1000 -> overlaps from x=0 to x=300 (300px wide) 210 + // y=100, height=1000 -> overlaps from y=100 to y=1100 (1000px tall, within workArea 25-1440) 211 + // Overlap = 300 * 1000 = 300,000 = exactly 30% of 1,000,000 212 + assert.strictEqual(isWindowAccessible(displays, { x: -700, y: 100, width: 1000, height: 1000 }), true); 213 + }); 214 + 215 + it("window with less than 30% overlap is not accessible", () => { 216 + // Window 1000x1000 = 1,000,000 area 217 + // Place so only 299px width overlaps 218 + // x=-701, width=1000 -> overlaps from x=0 to x=299 (299px wide) 219 + // y=100, height=1000 -> overlaps 1000px tall 220 + // Overlap = 299 * 1000 = 299,000 < 30% of 1,000,000 221 + assert.strictEqual(isWindowAccessible(displays, { x: -701, y: 100, width: 1000, height: 1000 }), false); 222 + }); 223 + 224 + it("window spanning two displays counts overlap per display", () => { 225 + // Window at the boundary between display1440 and display1080 226 + // If 30% is on either display, it's accessible 227 + assert.strictEqual(isWindowAccessible(displays, { x: 2400, y: 100, width: 320, height: 600 }), true); 352 228 }); 353 229 354 - it("window above screen with title bar off-screen is not accessible", () => { 355 - assert.strictEqual(isWindowAccessible(displays, { x: 100, y: -500, width: 800, height: 600 }), false); 230 + it("large window mostly off-screen but >30% on display is accessible", () => { 231 + // 800x600 window with top-left at (-400, 100) 232 + // Overlaps display1440 from x=0 to x=400 (400px), y=100 to y=700 (600px) 233 + // Overlap = 400*600 = 240,000; window area = 480,000; ratio = 50% > 30% 234 + assert.strictEqual(isWindowAccessible(displays, { x: -400, y: 100, width: 800, height: 600 }), true); 356 235 }); 357 236 358 - it("uses center X for accessibility check", () => { 359 - assert.strictEqual(isWindowAccessible(displays, { x: 2400, y: 100, width: 1120, height: 600 }), true); 237 + it("window far off-screen with no overlap is not accessible", () => { 238 + assert.strictEqual(isWindowAccessible(displays, { x: 10000, y: 10000, width: 800, height: 600 }), false); 360 239 }); 361 240 }); 362 241 363 242 describe("findBestNewDisplay", () => { 364 - it("returns same display if ID matches", () => { 365 - const result = findBestNewDisplay(display1440, [display1440, display1080], 1); 366 - assert.strictEqual(result.id, 1); 367 - }); 368 - 369 - it("returns closest display when old display is removed", () => { 243 + it("returns closest display by center distance when old display is known", () => { 370 244 const result = findBestNewDisplay(display1080, [display1440], 1); 371 245 assert.strictEqual(result.id, 1); 372 246 }); ··· 381 255 assert.strictEqual(result.id, 2); 382 256 }); 383 257 384 - it("picks closest by center distance when multiple candidates", () => { 258 + it("picks nearest by center distance — no ID preference", () => { 259 + // Even though display1440 has the same ID relationship, proximity wins 385 260 const farRight: DisplaySnapshot = { 386 261 id: 99, 387 262 bounds: { x: 5000, y: 0, width: 1920, height: 1080 }, 388 263 workArea: { x: 5000, y: 25, width: 1920, height: 1055 }, 389 264 }; 390 265 const result = findBestNewDisplay(farRight, [display1440, display1080], 1); 266 + // display1080 center (~3520, 552) is closer to farRight center (~5960, 552) 267 + // than display1440 center (~1280, 732) 391 268 assert.strictEqual(result.id, 2); 392 269 }); 393 270 394 - it("throws when no displays available", () => { 395 - assert.throws(() => findBestNewDisplay(display1440, [], 1), /No displays available/); 396 - }); 397 - }); 398 - 399 - describe("calculateGroupRepositionedBounds (single window)", () => { 400 - it("centers a centered window on the new display with scaled dimensions", () => { 401 - const winBounds = [{ x: 880, y: 420, width: 800, height: 600 }]; 402 - const [result] = calculateGroupRepositionedBounds(winBounds, display1440, displayLaptop); 403 - // Width scales by 1440/2560 = 0.5625, so 800 * 0.5625 = 450 404 - assert.ok(result.width >= 400 && result.width <= 500, `width=${result.width} should scale ~450`); 405 - assert.ok(result.height >= 300 && result.height <= 420, `height=${result.height} should scale`); 406 - }); 407 - 408 - it("preserves relative position for a single window", () => { 409 - // Window at top-left of old display 410 - const [result] = calculateGroupRepositionedBounds( 411 - [{ x: 0, y: 25, width: 800, height: 600 }], display1440, displayLaptop 412 - ); 413 - // Single window: center maps to same relative position 414 - // Old center: (400, 325), rel: (400/2560, 300/1415) = (0.156, 0.212) 415 - // New center: (0.156*1440, 25+0.212*875) = (225, 210) 416 - // Scaled size: ~450x371, so top-left: ~(0, 25) 417 - assert.ok(result.x >= 0 && result.x <= 10, `x=${result.x} should be near left`); 418 - assert.ok(result.y >= 25 && result.y <= 35, `y=${result.y} should be near top`); 419 - }); 420 - 421 - it("clamps bottom-right window within new workArea", () => { 422 - const [result] = calculateGroupRepositionedBounds( 423 - [{ x: 1760, y: 840, width: 800, height: 600 }], display1440, displayLaptop 424 - ); 425 - const rightEdge = displayLaptop.workArea.x + displayLaptop.workArea.width; 426 - const bottomEdge = displayLaptop.workArea.y + displayLaptop.workArea.height; 427 - assert.ok(result.x + result.width <= rightEdge, 428 - `right edge ${result.x + result.width} should not exceed ${rightEdge}`); 429 - assert.ok(result.y + result.height <= bottomEdge, 430 - `bottom edge ${result.y + result.height} should not exceed ${bottomEdge}`); 431 - }); 432 - 433 - it("enforces minimum window size of 200x150", () => { 434 - const tinyDisplay: DisplaySnapshot = { 271 + it("picks display directly adjacent over distant one", () => { 272 + // Old display was at x=2560 (like display1080), two candidates: adjacent and far 273 + const adjacent: DisplaySnapshot = { 435 274 id: 10, 436 - bounds: { x: 0, y: 0, width: 400, height: 300 }, 437 - workArea: { x: 0, y: 25, width: 400, height: 275 }, 438 - }; 439 - const [result] = calculateGroupRepositionedBounds( 440 - [{ x: 0, y: 25, width: 100, height: 80 }], display1440, tinyDisplay 441 - ); 442 - assert.ok(result.width >= 200, `width ${result.width} should be >= 200`); 443 - assert.ok(result.height >= 150, `height ${result.height} should be >= 150`); 444 - }); 445 - 446 - it("caps window size to new workArea dimensions", () => { 447 - const [result] = calculateGroupRepositionedBounds( 448 - [{ x: 0, y: 25, width: 2560, height: 1415 }], display1440, displayLaptop 449 - ); 450 - assert.ok(result.width <= displayLaptop.workArea.width); 451 - assert.ok(result.height <= displayLaptop.workArea.height); 452 - }); 453 - 454 - it("same display dimensions produces same bounds for centered window", () => { 455 - const winBounds = { x: 500, y: 300, width: 800, height: 600 }; 456 - const [result] = calculateGroupRepositionedBounds([winBounds], display1440, display1440); 457 - assert.strictEqual(result.x, winBounds.x); 458 - assert.strictEqual(result.y, winBounds.y); 459 - assert.strictEqual(result.width, winBounds.width); 460 - assert.strictEqual(result.height, winBounds.height); 461 - }); 462 - 463 - it("handles secondary display offset correctly", () => { 464 - const [result] = calculateGroupRepositionedBounds( 465 - [{ x: 2660, y: 100, width: 800, height: 600 }], display1080, display1440 466 - ); 467 - assert.ok(result.x >= 0 && result.x < 200, `x=${result.x} should be near left on primary`); 468 - }); 469 - 470 - it("proportionally scales window going from large to small display", () => { 471 - const [result] = calculateGroupRepositionedBounds( 472 - [{ x: 640, y: 25, width: 1280, height: 700 }], display1440, displayLaptop 473 - ); 474 - const widthRatio = result.width / displayLaptop.workArea.width; 475 - assert.ok(widthRatio > 0.4 && widthRatio < 0.6, 476 - `width ratio ${widthRatio.toFixed(2)} should be ~0.5`); 477 - }); 478 - 479 - it("handles zero-size old workArea gracefully", () => { 480 - const zeroDisplay: DisplaySnapshot = { 481 - id: 99, 482 - bounds: { x: 0, y: 0, width: 0, height: 0 }, 483 - workArea: { x: 0, y: 0, width: 0, height: 0 }, 484 - }; 485 - const [result] = calculateGroupRepositionedBounds( 486 - [{ x: 0, y: 0, width: 800, height: 600 }], zeroDisplay, displayLaptop 487 - ); 488 - assert.ok(result.width > 0 && result.height > 0, "should produce valid dimensions"); 489 - }); 490 - }); 491 - 492 - describe("calculateGroupRepositionedBounds (multi-window distance scaling)", () => { 493 - it("scales distance between two windows proportionally (small to large)", () => { 494 - // Two windows 500px apart horizontally on laptop (1440x875) 495 - const wins = [ 496 - { x: 200, y: 200, width: 400, height: 300 }, 497 - { x: 700, y: 200, width: 400, height: 300 }, 498 - ]; 499 - // Old centers: (400, 350) and (900, 350), distance = 500px 500 - const results = calculateGroupRepositionedBounds(wins, displayLaptop, display1440); 501 - // Scale factor X: 2560/1440 = 1.778 502 - // Expected distance: 500 * 1.778 = ~889px 503 - const dist = Math.abs((results[1].x + results[1].width/2) - (results[0].x + results[0].width/2)); 504 - assert.ok(dist > 800 && dist < 1000, 505 - `distance ${dist} should be ~889 (500 * 2560/1440)`); 506 - }); 507 - 508 - it("scales distance between two windows proportionally (large to small)", () => { 509 - // Two windows 1000px apart on big display (2560x1415) 510 - const wins = [ 511 - { x: 400, y: 400, width: 600, height: 400 }, 512 - { x: 1400, y: 400, width: 600, height: 400 }, 513 - ]; 514 - // Old centers: (700, 600) and (1700, 600), distance = 1000px 515 - const results = calculateGroupRepositionedBounds(wins, display1440, displayLaptop); 516 - // Scale factor X: 1440/2560 = 0.5625 517 - // Expected distance: 1000 * 0.5625 = ~563px 518 - const dist = Math.abs((results[1].x + results[1].width/2) - (results[0].x + results[0].width/2)); 519 - assert.ok(dist > 480 && dist < 640, 520 - `distance ${dist} should be ~563 (1000 * 1440/2560)`); 521 - }); 522 - 523 - it("preserves relative arrangement of windows in a grid", () => { 524 - // 2x2 grid of windows on laptop 525 - const wins = [ 526 - { x: 100, y: 50, width: 400, height: 300 }, // top-left 527 - { x: 700, y: 50, width: 400, height: 300 }, // top-right 528 - { x: 100, y: 450, width: 400, height: 300 }, // bottom-left 529 - { x: 700, y: 450, width: 400, height: 300 }, // bottom-right 530 - ]; 531 - const results = calculateGroupRepositionedBounds(wins, displayLaptop, display1440); 532 - // top-left should still be left of top-right 533 - assert.ok(results[0].x < results[1].x, "top-left should be left of top-right"); 534 - // top-left should still be above bottom-left 535 - assert.ok(results[0].y < results[2].y, "top-left should be above bottom-left"); 536 - // top-right should still be above bottom-right 537 - assert.ok(results[1].y < results[3].y, "top-right should be above bottom-right"); 538 - // bottom-left should still be left of bottom-right 539 - assert.ok(results[2].x < results[3].x, "bottom-left should be left of bottom-right"); 540 - }); 541 - 542 - it("windows spread out more on larger display", () => { 543 - // Three windows in a row on laptop 544 - const wins = [ 545 - { x: 100, y: 200, width: 300, height: 300 }, 546 - { x: 500, y: 200, width: 300, height: 300 }, 547 - { x: 900, y: 200, width: 300, height: 300 }, 548 - ]; 549 - const resultsLarge = calculateGroupRepositionedBounds(wins, displayLaptop, display1440); 550 - // Bounding width of results should be larger than original 551 - const origSpan = (900 + 300) - 100; // 1100px 552 - const newSpan = (resultsLarge[2].x + resultsLarge[2].width) - resultsLarge[0].x; 553 - assert.ok(newSpan > origSpan, 554 - `new span ${newSpan} should be larger than original ${origSpan}`); 555 - }); 556 - 557 - it("windows cluster closer on smaller display", () => { 558 - // Three windows in a row on big display 559 - const wins = [ 560 - { x: 200, y: 400, width: 500, height: 400 }, 561 - { x: 900, y: 400, width: 500, height: 400 }, 562 - { x: 1600, y: 400, width: 500, height: 400 }, 563 - ]; 564 - const resultsSmall = calculateGroupRepositionedBounds(wins, display1440, displayLaptop); 565 - // Bounding width should be smaller 566 - const origSpan = (1600 + 500) - 200; // 1900px 567 - const newSpan = (resultsSmall[2].x + resultsSmall[2].width) - resultsSmall[0].x; 568 - assert.ok(newSpan < origSpan, 569 - `new span ${newSpan} should be smaller than original ${origSpan}`); 570 - }); 571 - 572 - it("all windows clamped to stay within new workArea", () => { 573 - // Windows spread across the full width of big display 574 - const wins = [ 575 - { x: 0, y: 25, width: 600, height: 400 }, 576 - { x: 1960, y: 25, width: 600, height: 400 }, 577 - ]; 578 - const results = calculateGroupRepositionedBounds(wins, display1440, displayLaptop); 579 - const wa = displayLaptop.workArea; 580 - for (const r of results) { 581 - assert.ok(r.x >= wa.x, `x=${r.x} should be >= ${wa.x}`); 582 - assert.ok(r.y >= wa.y, `y=${r.y} should be >= ${wa.y}`); 583 - assert.ok(r.x + r.width <= wa.x + wa.width, 584 - `right edge ${r.x + r.width} should be <= ${wa.x + wa.width}`); 585 - assert.ok(r.y + r.height <= wa.y + wa.height, 586 - `bottom edge ${r.y + r.height} should be <= ${wa.y + wa.height}`); 587 - } 588 - }); 589 - 590 - it("single window in group behaves same as single-element array", () => { 591 - const win = { x: 500, y: 300, width: 800, height: 600 }; 592 - const [result] = calculateGroupRepositionedBounds([win], display1440, displayLaptop); 593 - // Should be deterministic 594 - const [result2] = calculateGroupRepositionedBounds([win], display1440, displayLaptop); 595 - assert.strictEqual(result.x, result2.x); 596 - assert.strictEqual(result.y, result2.y); 597 - assert.strictEqual(result.width, result2.width); 598 - assert.strictEqual(result.height, result2.height); 599 - }); 600 - }); 601 - 602 - describe("computeHomeDisplay", () => { 603 - it("computes relative position for centered window", () => { 604 - // Window centered on 2560x1415 workArea (with 25px menu bar) 605 - const bounds = { x: 880, y: 420, width: 800, height: 600 }; 606 - const home = computeHomeDisplay(bounds, display1440); 607 - 608 - assert.strictEqual(home.displayId, 1); 609 - assert.strictEqual(home.resolution.width, 2560); 610 - assert.strictEqual(home.resolution.height, 1415); 611 - 612 - // Center of window: (1280, 720), relative to workArea (0, 25, 2560, 1415) 613 - // relX = 1280 / 2560 = 0.5, relY = (720 - 25) / 1415 ~ 0.491 614 - assert.ok(Math.abs(home.relativePosition.centerX - 0.5) < 0.01, 615 - `centerX ${home.relativePosition.centerX} should be ~0.5`); 616 - assert.ok(Math.abs(home.relativePosition.centerY - 0.491) < 0.01, 617 - `centerY ${home.relativePosition.centerY} should be ~0.491`); 618 - 619 - // Width ratio: 800 / 2560 = 0.3125 620 - assert.ok(Math.abs(home.relativePosition.width - 0.3125) < 0.001, 621 - `width ratio ${home.relativePosition.width} should be ~0.3125`); 622 - }); 623 - 624 - it("computes relative position for window on secondary display", () => { 625 - // Window at (2660, 100) on display starting at x=2560 626 - const bounds = { x: 2660, y: 100, width: 800, height: 600 }; 627 - const home = computeHomeDisplay(bounds, display1080); 628 - 629 - assert.strictEqual(home.displayId, 2); 630 - // Center: (3060, 400), relative to workArea (2560, 25, 1920, 1055) 631 - // relX = (3060 - 2560) / 1920 = 500/1920 ~ 0.260 632 - assert.ok(Math.abs(home.relativePosition.centerX - 0.260) < 0.01, 633 - `centerX ${home.relativePosition.centerX} should be ~0.260`); 634 - }); 635 - 636 - it("handles zero-size workArea gracefully", () => { 637 - const zeroDisplay: DisplaySnapshot = { 638 - id: 99, 639 - bounds: { x: 0, y: 0, width: 0, height: 0 }, 640 - workArea: { x: 0, y: 0, width: 0, height: 0 }, 641 - }; 642 - const home = computeHomeDisplay({ x: 0, y: 0, width: 800, height: 600 }, zeroDisplay); 643 - assert.strictEqual(home.relativePosition.centerX, 0.5); 644 - assert.strictEqual(home.relativePosition.centerY, 0.5); 645 - }); 646 - }); 647 - 648 - describe("findMatchingNewDisplay", () => { 649 - it("matches by display ID first", () => { 650 - const home: WindowHomeDisplay = { 651 - displayId: 2, 652 - resolution: { width: 1920, height: 1055 }, 653 - relativePosition: { centerX: 0.5, centerY: 0.5, width: 0.4, height: 0.5 }, 654 - }; 655 - const result = findMatchingNewDisplay(home, [display1440, display1080]); 656 - assert.strictEqual(result?.id, 2); 657 - }); 658 - 659 - it("falls back to resolution match within 5% tolerance", () => { 660 - // Home display was 1920x1055, new display is slightly different but within 5% 661 - const home: WindowHomeDisplay = { 662 - displayId: 99, // Different ID 663 - resolution: { width: 1920, height: 1055 }, 664 - relativePosition: { centerX: 0.5, centerY: 0.5, width: 0.4, height: 0.5 }, 665 - }; 666 - // display1080 has workArea 1920x1055 - exact match 667 - const result = findMatchingNewDisplay(home, [display1440, display1080]); 668 - assert.strictEqual(result?.id, 2); 669 - }); 670 - 671 - it("matches display with resolution within 5% tolerance", () => { 672 - const home: WindowHomeDisplay = { 673 - displayId: 99, 674 - resolution: { width: 1920, height: 1055 }, 675 - relativePosition: { centerX: 0.5, centerY: 0.5, width: 0.4, height: 0.5 }, 676 - }; 677 - // Create a display with ~4% different resolution 678 - const similar: DisplaySnapshot = { 679 - id: 50, 680 - bounds: { x: 0, y: 0, width: 2000, height: 1100 }, 681 - workArea: { x: 0, y: 25, width: 1996, height: 1095 }, // ~4% wider, ~3.8% taller 682 - }; 683 - const result = findMatchingNewDisplay(home, [similar]); 684 - assert.strictEqual(result?.id, 50); 685 - }); 686 - 687 - it("rejects display with resolution beyond 5% tolerance", () => { 688 - const home: WindowHomeDisplay = { 689 - displayId: 99, 690 - resolution: { width: 1920, height: 1055 }, 691 - relativePosition: { centerX: 0.5, centerY: 0.5, width: 0.4, height: 0.5 }, 692 - }; 693 - // display1440 has workArea 2560x1415 - way too different 694 - const result = findMatchingNewDisplay(home, [display1440]); 695 - assert.strictEqual(result, null); 696 - }); 697 - 698 - it("returns null when no displays match", () => { 699 - const home: WindowHomeDisplay = { 700 - displayId: 99, 701 - resolution: { width: 3840, height: 2135 }, 702 - relativePosition: { centerX: 0.5, centerY: 0.5, width: 0.4, height: 0.5 }, 703 - }; 704 - const result = findMatchingNewDisplay(home, [display1440, display1080, displayLaptop]); 705 - assert.strictEqual(result, null); 706 - }); 707 - 708 - it("returns null for empty added displays list", () => { 709 - const home: WindowHomeDisplay = { 710 - displayId: 2, 711 - resolution: { width: 1920, height: 1055 }, 712 - relativePosition: { centerX: 0.5, centerY: 0.5, width: 0.4, height: 0.5 }, 713 - }; 714 - const result = findMatchingNewDisplay(home, []); 715 - assert.strictEqual(result, null); 716 - }); 717 - 718 - it("prefers ID match over resolution match", () => { 719 - const home: WindowHomeDisplay = { 720 - displayId: 2, 721 - resolution: { width: 1920, height: 1055 }, 722 - relativePosition: { centerX: 0.5, centerY: 0.5, width: 0.4, height: 0.5 }, 723 - }; 724 - // Both display1080 (id=2, exact resolution) and a similar-resolution display 725 - const similar: DisplaySnapshot = { 726 - id: 50, 727 - bounds: { x: 0, y: 0, width: 1920, height: 1080 }, 728 - workArea: { x: 0, y: 25, width: 1920, height: 1055 }, 729 - }; 730 - const result = findMatchingNewDisplay(home, [similar, display1080]); 731 - assert.strictEqual(result?.id, 2); // ID match wins 732 - }); 733 - }); 734 - 735 - describe("computeRestoredBounds", () => { 736 - it("restores window to same position on same-sized display", () => { 737 - const bounds = { x: 880, y: 420, width: 800, height: 600 }; 738 - const home = computeHomeDisplay(bounds, display1440); 739 - const restored = computeRestoredBounds(home, display1440); 740 - 741 - assert.strictEqual(restored.x, bounds.x); 742 - assert.strictEqual(restored.y, bounds.y); 743 - assert.strictEqual(restored.width, bounds.width); 744 - assert.strictEqual(restored.height, bounds.height); 745 - }); 746 - 747 - it("restores to correct relative position on different display location", () => { 748 - // Window centered on external display at (2560, 25, 1920, 1055) 749 - const bounds = { x: 3120, y: 252, width: 800, height: 600 }; 750 - const home = computeHomeDisplay(bounds, display1080); 751 - 752 - // Reconnect at same resolution but different position 753 - const reconnected: DisplaySnapshot = { 754 - id: 2, 755 - bounds: { x: 0, y: 0, width: 1920, height: 1080 }, 756 - workArea: { x: 0, y: 25, width: 1920, height: 1055 }, 757 - }; 758 - const restored = computeRestoredBounds(home, reconnected); 759 - 760 - // Should be at the same relative position but shifted by workArea offset 761 - // Original center relative: ((3520-2560)/1920, (552-25)/1055) = (0.5, 0.5) 762 - // On reconnected: center = (960, 552.5), so x = 960-400=560, y = 553-300=253 763 - assert.ok(Math.abs(restored.x - 560) <= 1, 764 - `x=${restored.x} should be ~560`); 765 - assert.ok(Math.abs(restored.y - 252) <= 1, 766 - `y=${restored.y} should be ~252`); 767 - assert.strictEqual(restored.width, 800); 768 - assert.strictEqual(restored.height, 600); 769 - }); 770 - 771 - it("scales window size proportionally for different resolution", () => { 772 - // Window on big display 773 - const bounds = { x: 880, y: 420, width: 800, height: 600 }; 774 - const home = computeHomeDisplay(bounds, display1440); 775 - 776 - // Restore to smaller display 777 - const restored = computeRestoredBounds(home, displayLaptop); 778 - 779 - // Width ratio was 800/2560 = 0.3125, on laptop: 0.3125 * 1440 = 450 780 - assert.strictEqual(restored.width, 450); 781 - // Height ratio was 600/1415 ~ 0.424, on laptop: 0.424 * 875 = 371 782 - assert.ok(Math.abs(restored.height - 371) <= 1, 783 - `height=${restored.height} should be ~371`); 784 - }); 785 - 786 - it("clamps restored window within new workArea", () => { 787 - // Window at far bottom-right corner 788 - const bounds = { x: 2200, y: 1200, width: 400, height: 300 }; 789 - const home = computeHomeDisplay(bounds, display1440); 790 - 791 - const restored = computeRestoredBounds(home, displayLaptop); 792 - const wa = displayLaptop.workArea; 793 - 794 - assert.ok(restored.x >= wa.x, `x=${restored.x} should be >= ${wa.x}`); 795 - assert.ok(restored.y >= wa.y, `y=${restored.y} should be >= ${wa.y}`); 796 - assert.ok(restored.x + restored.width <= wa.x + wa.width, 797 - `right edge ${restored.x + restored.width} should be <= ${wa.x + wa.width}`); 798 - assert.ok(restored.y + restored.height <= wa.y + wa.height, 799 - `bottom edge ${restored.y + restored.height} should be <= ${wa.y + wa.height}`); 800 - }); 801 - 802 - it("enforces minimum size of 200x150", () => { 803 - // Very small relative window 804 - const home: WindowHomeDisplay = { 805 - displayId: 1, 806 - resolution: { width: 2560, height: 1415 }, 807 - relativePosition: { centerX: 0.5, centerY: 0.5, width: 0.01, height: 0.01 }, 808 - }; 809 - const restored = computeRestoredBounds(home, displayLaptop); 810 - assert.ok(restored.width >= 200, `width=${restored.width} should be >= 200`); 811 - assert.ok(restored.height >= 150, `height=${restored.height} should be >= 150`); 812 - }); 813 - 814 - it("round-trips through save and restore on same display", () => { 815 - // Test various window positions 816 - const positions = [ 817 - { x: 0, y: 25, width: 800, height: 600 }, // top-left 818 - { x: 1760, y: 840, width: 800, height: 600 }, // bottom-right 819 - { x: 880, y: 420, width: 800, height: 600 }, // centered 820 - { x: 200, y: 25, width: 1200, height: 1000 }, // large 821 - ]; 822 - 823 - for (const bounds of positions) { 824 - const home = computeHomeDisplay(bounds, display1440); 825 - const restored = computeRestoredBounds(home, display1440); 826 - 827 - assert.ok(Math.abs(restored.x - bounds.x) <= 1, 828 - `x: ${restored.x} should be ~${bounds.x}`); 829 - assert.ok(Math.abs(restored.y - bounds.y) <= 1, 830 - `y: ${restored.y} should be ~${bounds.y}`); 831 - assert.strictEqual(restored.width, bounds.width); 832 - assert.strictEqual(restored.height, bounds.height); 833 - } 834 - }); 835 - }); 836 - 837 - describe("home display tracking (integration)", () => { 838 - it("simulates full unplug/replug cycle: save, displace, restore", () => { 839 - // Step 1: Window on external display (display1080) 840 - const originalBounds = { x: 3120, y: 252, width: 800, height: 600 }; 841 - 842 - // Step 2: External display removed - save home display info 843 - const home = computeHomeDisplay(originalBounds, display1080); 844 - assert.strictEqual(home.displayId, 2); 845 - 846 - // Step 3: External display re-added (same ID) 847 - const matchingDisplay = findMatchingNewDisplay(home, [display1080]); 848 - assert.notStrictEqual(matchingDisplay, null); 849 - assert.strictEqual(matchingDisplay!.id, 2); 850 - 851 - // Step 4: Restore window 852 - const restored = computeRestoredBounds(home, matchingDisplay!); 853 - assert.strictEqual(restored.x, originalBounds.x); 854 - assert.strictEqual(restored.y, originalBounds.y); 855 - assert.strictEqual(restored.width, originalBounds.width); 856 - assert.strictEqual(restored.height, originalBounds.height); 857 - }); 858 - 859 - it("restores to different-ID display with matching resolution", () => { 860 - // Window on external display 861 - const originalBounds = { x: 3120, y: 252, width: 800, height: 600 }; 862 - const home = computeHomeDisplay(originalBounds, display1080); 863 - 864 - // Display comes back with different ID but same resolution 865 - const newExternal: DisplaySnapshot = { 866 - id: 42, 867 275 bounds: { x: 2560, y: 0, width: 1920, height: 1080 }, 868 276 workArea: { x: 2560, y: 25, width: 1920, height: 1055 }, 869 277 }; 870 - 871 - const match = findMatchingNewDisplay(home, [newExternal]); 872 - assert.notStrictEqual(match, null); 873 - assert.strictEqual(match!.id, 42); 874 - 875 - const restored = computeRestoredBounds(home, match!); 876 - // Same relative position, same resolution = same absolute position 877 - assert.strictEqual(restored.x, originalBounds.x); 878 - assert.strictEqual(restored.y, originalBounds.y); 879 - }); 880 - 881 - it("does not restore when no matching display is found", () => { 882 - const originalBounds = { x: 3120, y: 252, width: 800, height: 600 }; 883 - const home = computeHomeDisplay(originalBounds, display1080); 884 - 885 - // Only a very different display is added 886 - const different: DisplaySnapshot = { 887 - id: 50, 888 - bounds: { x: 0, y: 0, width: 3840, height: 2160 }, 889 - workArea: { x: 0, y: 25, width: 3840, height: 2135 }, 278 + const far: DisplaySnapshot = { 279 + id: 20, 280 + bounds: { x: 8000, y: 0, width: 1920, height: 1080 }, 281 + workArea: { x: 8000, y: 25, width: 1920, height: 1055 }, 890 282 }; 891 - 892 - const match = findMatchingNewDisplay(home, [different]); 893 - assert.strictEqual(match, null); 283 + const result = findBestNewDisplay(display1080, [far, adjacent], 10); 284 + assert.strictEqual(result.id, 10); 894 285 }); 895 286 896 - it("handles multiple windows on different removed displays", () => { 897 - // Window 1 on display1440, Window 2 on display1080 898 - const bounds1 = { x: 500, y: 300, width: 800, height: 600 }; 899 - const bounds2 = { x: 3000, y: 200, width: 600, height: 400 }; 900 - 901 - const home1 = computeHomeDisplay(bounds1, display1440); 902 - const home2 = computeHomeDisplay(bounds2, display1080); 903 - 904 - assert.strictEqual(home1.displayId, 1); 905 - assert.strictEqual(home2.displayId, 2); 906 - 907 - // Both displays come back 908 - const match1 = findMatchingNewDisplay(home1, [display1440, display1080]); 909 - const match2 = findMatchingNewDisplay(home2, [display1440, display1080]); 910 - 911 - assert.strictEqual(match1!.id, 1); 912 - assert.strictEqual(match2!.id, 2); 913 - 914 - const restored1 = computeRestoredBounds(home1, match1!); 915 - const restored2 = computeRestoredBounds(home2, match2!); 916 - 917 - assert.strictEqual(restored1.x, bounds1.x); 918 - assert.strictEqual(restored1.y, bounds1.y); 919 - assert.strictEqual(restored2.x, bounds2.x); 920 - assert.strictEqual(restored2.y, bounds2.y); 287 + it("throws when no displays available", () => { 288 + assert.throws(() => findBestNewDisplay(display1440, [], 1), /No displays available/); 921 289 }); 922 290 }); 923 291 924 - // ========================================================================== 925 - // New tests for pre-debounce capture, suppress timer cancellation, isRemoval 926 - // ========================================================================== 927 - 928 - describe("captureWindowBounds", () => { 929 - // Pure version of captureWindowBounds that accepts mock windows 930 - interface MockWindow { 292 + describe("safety net: no-op when all windows accessible", () => { 293 + // Simulates the handleDisplayChange logic as a pure function 294 + interface MockWindowEntry { 931 295 id: number; 932 - destroyed: boolean; 933 - visible: boolean; 934 296 bounds: Rectangle; 297 + isBackground: boolean; 935 298 } 936 299 937 - function captureWindowBounds(windows: MockWindow[]): Map<number, Rectangle> { 938 - const result = new Map<number, Rectangle>(); 300 + function simulateHandleDisplayChange( 301 + windows: MockWindowEntry[], 302 + oldDisplays: DisplaySnapshot[], 303 + newDisplays: DisplaySnapshot[], 304 + primaryDisplayId: number 305 + ): { rescued: number; windowBounds: Map<number, Rectangle> } { 306 + let rescued = 0; 307 + const windowBounds = new Map<number, Rectangle>(); 308 + 939 309 for (const win of windows) { 940 - if (win.destroyed || !win.visible) continue; 941 - result.set(win.id, win.bounds); 942 - } 943 - return result; 944 - } 310 + if (win.isBackground) continue; 945 311 946 - it("captures bounds of all visible, non-destroyed windows", () => { 947 - const windows: MockWindow[] = [ 948 - { id: 1, destroyed: false, visible: true, bounds: { x: 100, y: 200, width: 800, height: 600 } }, 949 - { id: 2, destroyed: false, visible: true, bounds: { x: 300, y: 400, width: 600, height: 400 } }, 950 - ]; 951 - const result = captureWindowBounds(windows); 952 - assert.strictEqual(result.size, 2); 953 - assert.deepStrictEqual(result.get(1), { x: 100, y: 200, width: 800, height: 600 }); 954 - assert.deepStrictEqual(result.get(2), { x: 300, y: 400, width: 600, height: 400 }); 955 - }); 312 + if (isWindowAccessible(newDisplays, win.bounds)) { 313 + windowBounds.set(win.id, win.bounds); 314 + continue; 315 + } 956 316 957 - it("skips destroyed windows", () => { 958 - const windows: MockWindow[] = [ 959 - { id: 1, destroyed: true, visible: true, bounds: { x: 100, y: 200, width: 800, height: 600 } }, 960 - { id: 2, destroyed: false, visible: true, bounds: { x: 300, y: 400, width: 600, height: 400 } }, 961 - ]; 962 - const result = captureWindowBounds(windows); 963 - assert.strictEqual(result.size, 1); 964 - assert.strictEqual(result.has(1), false); 965 - assert.strictEqual(result.has(2), true); 966 - }); 317 + // Rescue: find old display, then nearest new display, center window 318 + const centerX = win.bounds.x + Math.round(win.bounds.width / 2); 319 + const centerY = win.bounds.y + Math.round(win.bounds.height / 2); 320 + const oldDisplay = findDisplayForPoint(oldDisplays, centerX, centerY); 321 + const newDisplay = findBestNewDisplay(oldDisplay, newDisplays, primaryDisplayId); 322 + const wa = newDisplay.workArea; 967 323 968 - it("skips non-visible windows", () => { 969 - const windows: MockWindow[] = [ 970 - { id: 1, destroyed: false, visible: false, bounds: { x: 100, y: 200, width: 800, height: 600 } }, 971 - { id: 2, destroyed: false, visible: true, bounds: { x: 300, y: 400, width: 600, height: 400 } }, 972 - ]; 973 - const result = captureWindowBounds(windows); 974 - assert.strictEqual(result.size, 1); 975 - assert.strictEqual(result.has(1), false); 976 - assert.strictEqual(result.has(2), true); 977 - }); 324 + // Preserve original size, only reposition 325 + const x = wa.x + Math.round((wa.width - win.bounds.width) / 2); 326 + const y = wa.y + Math.round((wa.height - win.bounds.height) / 2); 978 327 979 - it("skips windows that are both destroyed and non-visible", () => { 980 - const windows: MockWindow[] = [ 981 - { id: 1, destroyed: true, visible: false, bounds: { x: 100, y: 200, width: 800, height: 600 } }, 982 - ]; 983 - const result = captureWindowBounds(windows); 984 - assert.strictEqual(result.size, 0); 985 - }); 328 + windowBounds.set(win.id, { x, y, width: win.bounds.width, height: win.bounds.height }); 329 + rescued++; 330 + } 986 331 987 - it("returns empty map when no windows exist", () => { 988 - const result = captureWindowBounds([]); 989 - assert.strictEqual(result.size, 0); 990 - }); 332 + return { rescued, windowBounds }; 333 + } 991 334 992 - it("returns empty map when all windows are destroyed or hidden", () => { 993 - const windows: MockWindow[] = [ 994 - { id: 1, destroyed: true, visible: true, bounds: { x: 0, y: 0, width: 800, height: 600 } }, 995 - { id: 2, destroyed: false, visible: false, bounds: { x: 0, y: 0, width: 800, height: 600 } }, 996 - { id: 3, destroyed: true, visible: false, bounds: { x: 0, y: 0, width: 800, height: 600 } }, 335 + it("does nothing when all windows are on valid displays", () => { 336 + const windows: MockWindowEntry[] = [ 337 + { id: 1, bounds: { x: 100, y: 100, width: 800, height: 600 }, isBackground: false }, 338 + { id: 2, bounds: { x: 3000, y: 100, width: 800, height: 600 }, isBackground: false }, 997 339 ]; 998 - const result = captureWindowBounds(windows); 999 - assert.strictEqual(result.size, 0); 340 + const result = simulateHandleDisplayChange( 341 + windows, 342 + [display1440, display1080], 343 + [display1440, display1080], 344 + 1 345 + ); 346 + assert.strictEqual(result.rescued, 0); 347 + // Windows should keep their original bounds 348 + assert.deepStrictEqual(result.windowBounds.get(1), { x: 100, y: 100, width: 800, height: 600 }); 349 + assert.deepStrictEqual(result.windowBounds.get(2), { x: 3000, y: 100, width: 800, height: 600 }); 1000 350 }); 1001 - }); 1002 351 1003 - describe("suppressDisplayRepositioning timer cancellation", () => { 1004 - // Model the state machine of suppress toggling and timer/bounds management. 1005 - // This mirrors the logic in suppressDisplayRepositioning() from display-watcher.ts. 1006 - interface WatcherState { 1007 - suppressRepositioning: boolean; 1008 - debounceTimer: ReturnType<typeof setTimeout> | null; 1009 - preDebounceWindowBounds: Map<number, Rectangle> | null; 1010 - } 1011 - 1012 - function suppressDisplayRepositioning(state: WatcherState, suppress: boolean): void { 1013 - state.suppressRepositioning = suppress; 1014 - if (suppress) { 1015 - if (state.debounceTimer) { 1016 - clearTimeout(state.debounceTimer); 1017 - state.debounceTimer = null; 1018 - } 1019 - state.preDebounceWindowBounds = null; 1020 - } else { 1021 - if (state.debounceTimer) { 1022 - clearTimeout(state.debounceTimer); 1023 - state.debounceTimer = null; 1024 - } 1025 - state.preDebounceWindowBounds = null; 1026 - } 1027 - } 1028 - 1029 - it("cancels pending debounce timer when suppression is enabled", () => { 1030 - const state: WatcherState = { 1031 - suppressRepositioning: false, 1032 - debounceTimer: setTimeout(() => {}, 100000), 1033 - preDebounceWindowBounds: new Map([[1, { x: 0, y: 0, width: 800, height: 600 }]]), 1034 - }; 1035 - suppressDisplayRepositioning(state, true); 1036 - assert.strictEqual(state.debounceTimer, null); 1037 - assert.strictEqual(state.preDebounceWindowBounds, null); 1038 - assert.strictEqual(state.suppressRepositioning, true); 1039 - }); 1040 - 1041 - it("cancels pending debounce timer when suppression is disabled", () => { 1042 - const state: WatcherState = { 1043 - suppressRepositioning: true, 1044 - debounceTimer: setTimeout(() => {}, 100000), 1045 - preDebounceWindowBounds: new Map([[2, { x: 100, y: 100, width: 400, height: 300 }]]), 1046 - }; 1047 - suppressDisplayRepositioning(state, false); 1048 - assert.strictEqual(state.debounceTimer, null); 1049 - assert.strictEqual(state.preDebounceWindowBounds, null); 1050 - assert.strictEqual(state.suppressRepositioning, false); 1051 - }); 1052 - 1053 - it("clears pre-debounce bounds when enabling suppression even without timer", () => { 1054 - const state: WatcherState = { 1055 - suppressRepositioning: false, 1056 - debounceTimer: null, 1057 - preDebounceWindowBounds: new Map([[1, { x: 0, y: 0, width: 800, height: 600 }]]), 1058 - }; 1059 - suppressDisplayRepositioning(state, true); 1060 - assert.strictEqual(state.preDebounceWindowBounds, null); 1061 - assert.strictEqual(state.debounceTimer, null); 1062 - }); 1063 - 1064 - it("clears pre-debounce bounds when disabling suppression even without timer", () => { 1065 - const state: WatcherState = { 1066 - suppressRepositioning: true, 1067 - debounceTimer: null, 1068 - preDebounceWindowBounds: new Map([[3, { x: 200, y: 200, width: 600, height: 400 }]]), 1069 - }; 1070 - suppressDisplayRepositioning(state, false); 1071 - assert.strictEqual(state.preDebounceWindowBounds, null); 1072 - assert.strictEqual(state.debounceTimer, null); 1073 - }); 1074 - 1075 - it("is safe to call with no timer and no bounds", () => { 1076 - const state: WatcherState = { 1077 - suppressRepositioning: false, 1078 - debounceTimer: null, 1079 - preDebounceWindowBounds: null, 1080 - }; 1081 - suppressDisplayRepositioning(state, true); 1082 - assert.strictEqual(state.suppressRepositioning, true); 1083 - assert.strictEqual(state.debounceTimer, null); 1084 - assert.strictEqual(state.preDebounceWindowBounds, null); 1085 - }); 1086 - 1087 - it("prevents stale debounced handler from firing after suppress toggle", () => { 1088 - // Simulate: display event fires, starts debounce timer, then suppress(true) 1089 - // is called before timer fires. The timer should be cancelled. 1090 - let handlerFired = false; 1091 - const state: WatcherState = { 1092 - suppressRepositioning: false, 1093 - debounceTimer: setTimeout(() => { handlerFired = true; }, 50), 1094 - preDebounceWindowBounds: new Map([[1, { x: 0, y: 0, width: 800, height: 600 }]]), 1095 - }; 1096 - 1097 - suppressDisplayRepositioning(state, true); 1098 - 1099 - // Verify timer was cleared (handler should not fire) 1100 - assert.strictEqual(state.debounceTimer, null); 1101 - // Wait longer than the timer would have fired to confirm it was cancelled 1102 - return new Promise<void>((resolve) => { 1103 - setTimeout(() => { 1104 - assert.strictEqual(handlerFired, false, "debounced handler should NOT have fired after suppression"); 1105 - resolve(); 1106 - }, 100); 1107 - }); 352 + it("skips background windows", () => { 353 + const windows: MockWindowEntry[] = [ 354 + { id: 1, bounds: { x: -5000, y: -5000, width: 800, height: 600 }, isBackground: true }, 355 + ]; 356 + const result = simulateHandleDisplayChange( 357 + windows, 358 + [display1440], 359 + [display1440], 360 + 1 361 + ); 362 + assert.strictEqual(result.rescued, 0); 363 + assert.strictEqual(result.windowBounds.size, 0); 1108 364 }); 1109 365 }); 1110 366 1111 - describe("onDisplayChange isRemoval flag and pre-debounce capture", () => { 1112 - // Model the onDisplayChange logic as a pure state machine. 1113 - // This mirrors the debounce + pre-debounce capture logic from display-watcher.ts. 1114 - interface MockWindow { 367 + describe("safety net: rescue orphaned windows", () => { 368 + interface MockWindowEntry { 1115 369 id: number; 1116 - destroyed: boolean; 1117 - visible: boolean; 1118 370 bounds: Rectangle; 371 + isBackground: boolean; 1119 372 } 1120 373 1121 - interface DebounceState { 1122 - debounceTimer: ReturnType<typeof setTimeout> | null; 1123 - preDebounceWindowBounds: Map<number, Rectangle> | null; 1124 - handleDisplayChangeCalled: boolean; 1125 - } 374 + function simulateHandleDisplayChange( 375 + windows: MockWindowEntry[], 376 + oldDisplays: DisplaySnapshot[], 377 + newDisplays: DisplaySnapshot[], 378 + primaryDisplayId: number 379 + ): { rescued: number; windowBounds: Map<number, Rectangle> } { 380 + let rescued = 0; 381 + const windowBounds = new Map<number, Rectangle>(); 1126 382 1127 - function captureWindowBounds(windows: MockWindow[]): Map<number, Rectangle> { 1128 - const result = new Map<number, Rectangle>(); 1129 383 for (const win of windows) { 1130 - if (win.destroyed || !win.visible) continue; 1131 - result.set(win.id, win.bounds); 1132 - } 1133 - return result; 1134 - } 384 + if (win.isBackground) continue; 385 + 386 + if (isWindowAccessible(newDisplays, win.bounds)) { 387 + windowBounds.set(win.id, win.bounds); 388 + continue; 389 + } 390 + 391 + const centerX = win.bounds.x + Math.round(win.bounds.width / 2); 392 + const centerY = win.bounds.y + Math.round(win.bounds.height / 2); 393 + const oldDisplay = findDisplayForPoint(oldDisplays, centerX, centerY); 394 + const newDisplay = findBestNewDisplay(oldDisplay, newDisplays, primaryDisplayId); 395 + const wa = newDisplay.workArea; 396 + 397 + // Preserve original size, only reposition 398 + const x = wa.x + Math.round((wa.width - win.bounds.width) / 2); 399 + const y = wa.y + Math.round((wa.height - win.bounds.height) / 2); 1135 400 1136 - function onDisplayChange( 1137 - state: DebounceState, 1138 - windows: MockWindow[], 1139 - isRemoval: boolean, 1140 - debounceMs: number 1141 - ): void { 1142 - // On the FIRST event in a debounce window, capture bounds eagerly if removal 1143 - if (!state.debounceTimer && isRemoval) { 1144 - state.preDebounceWindowBounds = captureWindowBounds(windows); 401 + windowBounds.set(win.id, { x, y, width: win.bounds.width, height: win.bounds.height }); 402 + rescued++; 1145 403 } 1146 404 1147 - if (state.debounceTimer) { 1148 - clearTimeout(state.debounceTimer); 1149 - } 1150 - state.debounceTimer = setTimeout(() => { 1151 - state.debounceTimer = null; 1152 - state.handleDisplayChangeCalled = true; 1153 - state.preDebounceWindowBounds = null; 1154 - }, debounceMs); 405 + return { rescued, windowBounds }; 1155 406 } 1156 407 1157 - it("captures window bounds on first display-removed event (no existing timer)", () => { 1158 - const windows: MockWindow[] = [ 1159 - { id: 1, destroyed: false, visible: true, bounds: { x: 100, y: 200, width: 800, height: 600 } }, 1160 - { id: 2, destroyed: false, visible: true, bounds: { x: 2700, y: 100, width: 600, height: 400 } }, 408 + it("rescues window that was on removed display", () => { 409 + // Window was on display1080 (secondary), which is now gone 410 + // macOS may have moved it to some off-screen position 411 + const windows: MockWindowEntry[] = [ 412 + { id: 1, bounds: { x: 5000, y: 5000, width: 800, height: 600 }, isBackground: false }, 1161 413 ]; 1162 - const state: DebounceState = { 1163 - debounceTimer: null, 1164 - preDebounceWindowBounds: null, 1165 - handleDisplayChangeCalled: false, 1166 - }; 1167 - 1168 - onDisplayChange(state, windows, true, 500); 1169 - 1170 - assert.notStrictEqual(state.preDebounceWindowBounds, null); 1171 - assert.strictEqual(state.preDebounceWindowBounds!.size, 2); 1172 - assert.deepStrictEqual(state.preDebounceWindowBounds!.get(1), { x: 100, y: 200, width: 800, height: 600 }); 1173 - assert.deepStrictEqual(state.preDebounceWindowBounds!.get(2), { x: 2700, y: 100, width: 600, height: 400 }); 1174 - 1175 - // Cleanup 1176 - if (state.debounceTimer) clearTimeout(state.debounceTimer); 414 + const result = simulateHandleDisplayChange( 415 + windows, 416 + [display1440, display1080], 417 + [display1440], // display1080 removed 418 + 1 419 + ); 420 + assert.strictEqual(result.rescued, 1); 421 + const newBounds = result.windowBounds.get(1)!; 422 + // Should be centered on display1440's workArea 423 + const wa = display1440.workArea; 424 + assert.ok(newBounds.x >= wa.x && newBounds.x + newBounds.width <= wa.x + wa.width, 425 + "window should be within display1440 workArea horizontally"); 426 + assert.ok(newBounds.y >= wa.y && newBounds.y + newBounds.height <= wa.y + wa.height, 427 + "window should be within display1440 workArea vertically"); 1177 428 }); 1178 429 1179 - it("does NOT re-capture bounds on subsequent events during debounce window", () => { 1180 - const originalWindows: MockWindow[] = [ 1181 - { id: 1, destroyed: false, visible: true, bounds: { x: 100, y: 200, width: 800, height: 600 } }, 430 + it("preserves window size when rescuing (if it fits)", () => { 431 + const windows: MockWindowEntry[] = [ 432 + { id: 1, bounds: { x: -5000, y: -5000, width: 800, height: 600 }, isBackground: false }, 1182 433 ]; 1183 - const state: DebounceState = { 1184 - debounceTimer: null, 1185 - preDebounceWindowBounds: null, 1186 - handleDisplayChangeCalled: false, 1187 - }; 434 + const result = simulateHandleDisplayChange( 435 + windows, 436 + [display1440], 437 + [display1440], 438 + 1 439 + ); 440 + assert.strictEqual(result.rescued, 1); 441 + const newBounds = result.windowBounds.get(1)!; 442 + assert.strictEqual(newBounds.width, 800); 443 + assert.strictEqual(newBounds.height, 600); 444 + }); 1188 445 1189 - // First event captures bounds 1190 - onDisplayChange(state, originalWindows, true, 500); 1191 - const capturedBounds = state.preDebounceWindowBounds; 1192 - assert.notStrictEqual(capturedBounds, null); 1193 - 1194 - // macOS relocates window (simulated by different bounds) 1195 - const relocatedWindows: MockWindow[] = [ 1196 - { id: 1, destroyed: false, visible: true, bounds: { x: 500, y: 300, width: 800, height: 600 } }, 446 + it("preserves window size when rescuing oversized window to smaller display", () => { 447 + const windows: MockWindowEntry[] = [ 448 + { id: 1, bounds: { x: -5000, y: -5000, width: 5000, height: 3000 }, isBackground: false }, 1197 449 ]; 1198 - 1199 - // Second event during debounce - should NOT overwrite pre-debounce bounds 1200 - onDisplayChange(state, relocatedWindows, true, 500); 1201 - assert.strictEqual(state.preDebounceWindowBounds, capturedBounds, 1202 - "pre-debounce bounds should NOT be overwritten by subsequent events"); 1203 - assert.deepStrictEqual(state.preDebounceWindowBounds!.get(1), { x: 100, y: 200, width: 800, height: 600 }, 1204 - "bounds should still reflect the ORIGINAL positions"); 1205 - 1206 - // Cleanup 1207 - if (state.debounceTimer) clearTimeout(state.debounceTimer); 450 + const result = simulateHandleDisplayChange( 451 + windows, 452 + [display1440], 453 + [displayLaptop], // smaller display 454 + 3 455 + ); 456 + assert.strictEqual(result.rescued, 1); 457 + const newBounds = result.windowBounds.get(1)!; 458 + // Size preserved — never resize, only reposition 459 + assert.strictEqual(newBounds.width, 5000); 460 + assert.strictEqual(newBounds.height, 3000); 1208 461 }); 1209 462 1210 - it("does NOT capture bounds for non-removal events (isRemoval=false)", () => { 1211 - const windows: MockWindow[] = [ 1212 - { id: 1, destroyed: false, visible: true, bounds: { x: 100, y: 200, width: 800, height: 600 } }, 463 + it("centers rescued window on the target display", () => { 464 + const windows: MockWindowEntry[] = [ 465 + { id: 1, bounds: { x: -5000, y: -5000, width: 800, height: 600 }, isBackground: false }, 1213 466 ]; 1214 - const state: DebounceState = { 1215 - debounceTimer: null, 1216 - preDebounceWindowBounds: null, 1217 - handleDisplayChangeCalled: false, 1218 - }; 1219 - 1220 - onDisplayChange(state, windows, false, 500); 1221 - 1222 - assert.strictEqual(state.preDebounceWindowBounds, null, 1223 - "should NOT capture bounds for non-removal events"); 1224 - 1225 - // Cleanup 1226 - if (state.debounceTimer) clearTimeout(state.debounceTimer); 467 + const result = simulateHandleDisplayChange( 468 + windows, 469 + [display1440], 470 + [display1440], 471 + 1 472 + ); 473 + const newBounds = result.windowBounds.get(1)!; 474 + const wa = display1440.workArea; 475 + const expectedX = wa.x + Math.round((wa.width - 800) / 2); 476 + const expectedY = wa.y + Math.round((wa.height - 600) / 2); 477 + assert.strictEqual(newBounds.x, expectedX); 478 + assert.strictEqual(newBounds.y, expectedY); 1227 479 }); 1228 480 1229 - it("clears pre-debounce bounds after debounced handler fires", async () => { 1230 - const windows: MockWindow[] = [ 1231 - { id: 1, destroyed: false, visible: true, bounds: { x: 100, y: 200, width: 800, height: 600 } }, 481 + it("rescues to nearest display based on old display proximity", () => { 482 + // Window was on a display far to the right (old position known) 483 + const farRightOld: DisplaySnapshot = { 484 + id: 99, 485 + bounds: { x: 5000, y: 0, width: 1920, height: 1080 }, 486 + workArea: { x: 5000, y: 25, width: 1920, height: 1055 }, 487 + }; 488 + const windows: MockWindowEntry[] = [ 489 + { id: 1, bounds: { x: 5500, y: 200, width: 800, height: 600 }, isBackground: false }, 1232 490 ]; 1233 - const state: DebounceState = { 1234 - debounceTimer: null, 1235 - preDebounceWindowBounds: null, 1236 - handleDisplayChangeCalled: false, 1237 - }; 1238 - 1239 - onDisplayChange(state, windows, true, 30); 1240 - assert.notStrictEqual(state.preDebounceWindowBounds, null); 1241 - 1242 - await new Promise<void>((resolve) => { 1243 - setTimeout(() => { 1244 - assert.strictEqual(state.preDebounceWindowBounds, null, 1245 - "pre-debounce bounds should be cleared after handler fires"); 1246 - assert.strictEqual(state.handleDisplayChangeCalled, true, 1247 - "handleDisplayChange should have been called"); 1248 - assert.strictEqual(state.debounceTimer, null, 1249 - "debounce timer should be null after handler fires"); 1250 - resolve(); 1251 - }, 80); 1252 - }); 491 + // Old layout had farRightOld, new layout has display1440 and display1080 492 + const result = simulateHandleDisplayChange( 493 + windows, 494 + [display1440, display1080, farRightOld], 495 + [display1440, display1080], // farRightOld removed 496 + 1 497 + ); 498 + assert.strictEqual(result.rescued, 1); 499 + // Should end up on display1080 (closer to where farRightOld was) 500 + const newBounds = result.windowBounds.get(1)!; 501 + const wa1080 = display1080.workArea; 502 + assert.ok(newBounds.x >= wa1080.x && newBounds.x + newBounds.width <= wa1080.x + wa1080.width, 503 + `window at x=${newBounds.x} should be on display1080 (workArea starts at ${wa1080.x})`); 1253 504 }); 1254 505 1255 - it("resets debounce timer on each subsequent event", () => { 1256 - const windows: MockWindow[] = [ 1257 - { id: 1, destroyed: false, visible: true, bounds: { x: 0, y: 0, width: 800, height: 600 } }, 506 + it("handles multiple orphaned windows", () => { 507 + const windows: MockWindowEntry[] = [ 508 + { id: 1, bounds: { x: -5000, y: -5000, width: 800, height: 600 }, isBackground: false }, 509 + { id: 2, bounds: { x: 10000, y: 10000, width: 600, height: 400 }, isBackground: false }, 510 + { id: 3, bounds: { x: 500, y: 200, width: 800, height: 600 }, isBackground: false }, // accessible 1258 511 ]; 1259 - const state: DebounceState = { 1260 - debounceTimer: null, 1261 - preDebounceWindowBounds: null, 1262 - handleDisplayChangeCalled: false, 1263 - }; 1264 - 1265 - onDisplayChange(state, windows, true, 500); 1266 - const firstTimer = state.debounceTimer; 1267 - assert.notStrictEqual(firstTimer, null); 1268 - 1269 - // Second event should create a new timer 1270 - onDisplayChange(state, windows, false, 500); 1271 - assert.notStrictEqual(state.debounceTimer, null); 1272 - // Timer reference changes because old was cleared and new was created 1273 - // (Node may reuse timer refs but the clearTimeout+setTimeout cycle happens) 1274 - assert.notStrictEqual(state.debounceTimer, firstTimer, 1275 - "debounce timer should be reset on subsequent event"); 1276 - 1277 - // Cleanup 1278 - if (state.debounceTimer) clearTimeout(state.debounceTimer); 512 + const result = simulateHandleDisplayChange( 513 + windows, 514 + [display1440], 515 + [display1440], 516 + 1 517 + ); 518 + assert.strictEqual(result.rescued, 2); 519 + // Window 3 should keep its original bounds 520 + assert.deepStrictEqual(result.windowBounds.get(3), { x: 500, y: 200, width: 800, height: 600 }); 1279 521 }); 1280 522 }); 1281 523 1282 - describe("Phase 1 pre-debounce bounds fallback", () => { 1283 - // Tests the pattern: _preDebounceWindowBounds?.get(win.id) ?? win.getBounds() 1284 - // This is used in handleDisplayChange Phase 1 to correctly identify which display 1285 - // a window was on BEFORE macOS auto-relocated it. 1286 - 1287 - interface MockWindow { 1288 - id: number; 1289 - currentBounds: Rectangle; // what getBounds() returns NOW (post-relocation) 1290 - } 1291 - 1292 - function getEffectiveBounds( 1293 - preDebounceWindowBounds: Map<number, Rectangle> | null, 1294 - win: MockWindow 1295 - ): Rectangle { 1296 - return preDebounceWindowBounds?.get(win.id) ?? win.currentBounds; 1297 - } 1298 - 1299 - it("uses pre-debounce bounds when available", () => { 1300 - const preDebounce = new Map<number, Rectangle>([ 1301 - [1, { x: 2700, y: 100, width: 800, height: 600 }], // was on external display 1302 - ]); 1303 - const win: MockWindow = { 524 + describe("findBestNewDisplay proximity (no ID preference)", () => { 525 + it("does not prefer matching display ID over closer display", () => { 526 + // Old display had id=1 and was on the right side 527 + const oldRight: DisplaySnapshot = { 1304 528 id: 1, 1305 - currentBounds: { x: 100, y: 100, width: 800, height: 600 }, // macOS moved it to laptop 529 + bounds: { x: 3000, y: 0, width: 1920, height: 1080 }, 530 + workArea: { x: 3000, y: 25, width: 1920, height: 1055 }, 1306 531 }; 1307 - 1308 - const bounds = getEffectiveBounds(preDebounce, win); 1309 - assert.deepStrictEqual(bounds, { x: 2700, y: 100, width: 800, height: 600 }, 1310 - "should use pre-debounce bounds (original position on external display)"); 1311 - }); 1312 - 1313 - it("falls back to current bounds when pre-debounce map is null", () => { 1314 - const win: MockWindow = { 532 + // New layout: display with id=1 is now on the LEFT, display id=2 is on the RIGHT 533 + const newLeft: DisplaySnapshot = { 1315 534 id: 1, 1316 - currentBounds: { x: 100, y: 100, width: 800, height: 600 }, 535 + bounds: { x: 0, y: 0, width: 1920, height: 1080 }, 536 + workArea: { x: 0, y: 25, width: 1920, height: 1055 }, 1317 537 }; 1318 - 1319 - const bounds = getEffectiveBounds(null, win); 1320 - assert.deepStrictEqual(bounds, { x: 100, y: 100, width: 800, height: 600 }, 1321 - "should fall back to current bounds when no pre-debounce map"); 1322 - }); 1323 - 1324 - it("falls back to current bounds when window not in pre-debounce map", () => { 1325 - const preDebounce = new Map<number, Rectangle>([ 1326 - [2, { x: 2700, y: 100, width: 800, height: 600 }], // different window 1327 - ]); 1328 - const win: MockWindow = { 1329 - id: 1, 1330 - currentBounds: { x: 100, y: 100, width: 800, height: 600 }, 538 + const newRight: DisplaySnapshot = { 539 + id: 2, 540 + bounds: { x: 2000, y: 0, width: 1920, height: 1080 }, 541 + workArea: { x: 2000, y: 25, width: 1920, height: 1055 }, 1331 542 }; 1332 - 1333 - const bounds = getEffectiveBounds(preDebounce, win); 1334 - assert.deepStrictEqual(bounds, { x: 100, y: 100, width: 800, height: 600 }, 1335 - "should fall back to current bounds when window not in pre-debounce map"); 1336 - }); 1337 - 1338 - it("correctly identifies window's home display using pre-debounce bounds", () => { 1339 - // Scenario: external display (display1080) removed. macOS moved window to laptop. 1340 - // Pre-debounce bounds show the window was at (2700, 100) - on display1080. 1341 - // Current bounds show (100, 100) - on the laptop display. 1342 - const preDebounce = new Map<number, Rectangle>([ 1343 - [1, { x: 2700, y: 100, width: 800, height: 600 }], 1344 - ]); 1345 - const win: MockWindow = { 1346 - id: 1, 1347 - currentBounds: { x: 100, y: 100, width: 800, height: 600 }, 1348 - }; 1349 - 1350 - const bounds = getEffectiveBounds(preDebounce, win); 1351 - const centerX = bounds.x + Math.round(bounds.width / 2); 1352 - const centerY = bounds.y + Math.round(bounds.height / 2); 1353 - 1354 - // With pre-debounce bounds, center is at (3100, 400) -> on display1080 1355 - const oldDisplays = [display1440, display1080]; 1356 - const found = findDisplayForPoint(oldDisplays, centerX, centerY); 1357 - assert.strictEqual(found?.id, 2, 1358 - "should find the window was on display1080 using pre-debounce bounds"); 543 + // findBestNewDisplay uses proximity only — should pick newRight (closer to oldRight center) 544 + const result = findBestNewDisplay(oldRight, [newLeft, newRight], 1); 545 + assert.strictEqual(result.id, 2, "should pick closer display, not matching ID"); 1359 546 }); 1360 547 1361 - it("without pre-debounce bounds, mis-identifies display after macOS relocation", () => { 1362 - // Same scenario but WITHOUT pre-debounce bounds - demonstrates the problem 1363 - const win: MockWindow = { 1364 - id: 1, 1365 - currentBounds: { x: 100, y: 100, width: 800, height: 600 }, 548 + it("picks single available display regardless of old display position", () => { 549 + const farAway: DisplaySnapshot = { 550 + id: 99, 551 + bounds: { x: 10000, y: 10000, width: 1920, height: 1080 }, 552 + workArea: { x: 10000, y: 10025, width: 1920, height: 1055 }, 1366 553 }; 1367 - 1368 - const bounds = getEffectiveBounds(null, win); 1369 - const centerX = bounds.x + Math.round(bounds.width / 2); 1370 - const centerY = bounds.y + Math.round(bounds.height / 2); 1371 - 1372 - // Without pre-debounce, center is at (500, 400) -> on display1440 (laptop), 1373 - // even though the window WAS on display1080 before macOS moved it 1374 - const oldDisplays = [display1440, display1080]; 1375 - const found = findDisplayForPoint(oldDisplays, centerX, centerY); 1376 - assert.strictEqual(found?.id, 1, 1377 - "without pre-debounce, window appears to be on display1440 (wrong - macOS already moved it)"); 1378 - }); 1379 - 1380 - it("handles multiple windows with mixed pre-debounce availability", () => { 1381 - // Window 1: was on external, captured in pre-debounce 1382 - // Window 2: was on laptop, NOT in pre-debounce (was visible but different ID) 1383 - // Window 3: new window created after capture, NOT in pre-debounce 1384 - const preDebounce = new Map<number, Rectangle>([ 1385 - [1, { x: 2700, y: 100, width: 800, height: 600 }], 1386 - [2, { x: 200, y: 200, width: 600, height: 400 }], 1387 - ]); 1388 - 1389 - const win1: MockWindow = { id: 1, currentBounds: { x: 50, y: 50, width: 800, height: 600 } }; 1390 - const win2: MockWindow = { id: 2, currentBounds: { x: 200, y: 200, width: 600, height: 400 } }; 1391 - const win3: MockWindow = { id: 3, currentBounds: { x: 300, y: 300, width: 500, height: 350 } }; 1392 - 1393 - assert.deepStrictEqual(getEffectiveBounds(preDebounce, win1), 1394 - { x: 2700, y: 100, width: 800, height: 600 }, 1395 - "win1 should use pre-debounce bounds"); 1396 - assert.deepStrictEqual(getEffectiveBounds(preDebounce, win2), 1397 - { x: 200, y: 200, width: 600, height: 400 }, 1398 - "win2 should use pre-debounce bounds"); 1399 - assert.deepStrictEqual(getEffectiveBounds(preDebounce, win3), 1400 - { x: 300, y: 300, width: 500, height: 350 }, 1401 - "win3 should fall back to current bounds (not in pre-debounce)"); 1402 - }); 1403 - 1404 - it("end-to-end: pre-debounce bounds enable correct home display save on removal", () => { 1405 - // Full scenario: external display removed, pre-debounce captured, Phase 1 saves home 1406 - const preDebounce = new Map<number, Rectangle>([ 1407 - [1, { x: 2700, y: 100, width: 800, height: 600 }], // was on display1080 1408 - [2, { x: 500, y: 300, width: 600, height: 400 }], // was on display1440 1409 - ]); 1410 - 1411 - // After macOS relocates windows, both are on display1440 1412 - const windows = [ 1413 - { id: 1, currentBounds: { x: 100, y: 100, width: 800, height: 600 } }, 1414 - { id: 2, currentBounds: { x: 500, y: 300, width: 600, height: 400 } }, 1415 - ]; 1416 - 1417 - const removedDisplayIds = new Set([display1080.id]); 1418 - const oldDisplays = [display1440, display1080]; 1419 - const savedHomes = new Map<number, WindowHomeDisplay>(); 1420 - 1421 - for (const win of windows) { 1422 - const bounds = getEffectiveBounds(preDebounce, win); 1423 - const centerX = bounds.x + Math.round(bounds.width / 2); 1424 - const centerY = bounds.y + Math.round(bounds.height / 2); 1425 - 1426 - const oldDisplay = findDisplayForPoint(oldDisplays, centerX, centerY); 1427 - if (oldDisplay && removedDisplayIds.has(oldDisplay.id)) { 1428 - savedHomes.set(win.id, computeHomeDisplay(bounds, oldDisplay)); 1429 - } 1430 - } 1431 - 1432 - // Only window 1 should be saved as displaced (it was on the removed display) 1433 - assert.strictEqual(savedHomes.size, 1, "only window on removed display should be saved"); 1434 - assert.strictEqual(savedHomes.has(1), true); 1435 - assert.strictEqual(savedHomes.has(2), false); 1436 - assert.strictEqual(savedHomes.get(1)!.displayId, display1080.id); 554 + const result = findBestNewDisplay(farAway, [display1440], 1); 555 + assert.strictEqual(result.id, 1); 1437 556 }); 1438 557 }); 1439 558 });
+78 -657
backend/electron/display-watcher.ts
··· 1 1 /** 2 - * Display Change Watcher 2 + * Display Change Watcher — Safety Net 3 3 * 4 4 * Monitors display configuration changes (monitor plug/unplug, resolution changes) 5 - * and repositions windows to maintain approximate relative positions. 5 + * and rescues genuinely orphaned windows that end up off-screen. 6 6 * 7 - * When a display is removed or resized, windows that were on that display may end up 8 - * off-screen or bunched in corners. This module detects those cases and moves windows 9 - * to valid positions on the remaining/changed displays. 7 + * macOS handles window migration natively when displays are added/removed. 8 + * This module only intervenes when a window has less than 30% area overlap 9 + * with any display workArea — meaning it's truly inaccessible to the user. 10 10 * 11 - * Algorithm (three-phase display change handling): 11 + * Algorithm (single-pass safety net): 12 12 * - Snapshot display layout at startup and after each change 13 - * - Phase 1 (Save): When displays are removed, save "home display" info for displaced 14 - * windows (display ID, resolution, relative position within the workArea) 15 - * - Phase 2 (Restore): When displays are added and there are displaced windows, try to 16 - * match each window's home display to a newly added display (by ID first, then by 17 - * resolution with 5% tolerance). Restore matched windows to their original positions. 18 - * - Phase 3 (Reposition): For remaining windows on changed displays, use group-based 19 - * distance scaling: 20 - * 1. Find the bounding box of all window centers on the old display 21 - * 2. Calculate the center of that bounding box 22 - * 3. For each window, compute its offset from the bounding box center 23 - * 4. Scale those offsets by (new display size / old display size) 24 - * 5. Place windows at (new display center + scaled offset) 25 - * 6. Scale window dimensions proportionally 26 - * 7. Clamp to keep all windows on-screen 27 - * - This preserves the spatial LAYOUT and scales DISTANCES between windows, 28 - * rather than mapping each window proportional position independently. 29 - * - Skip windows that are still fully visible on a valid display 13 + * - On display change: check every window's overlap with current displays 14 + * - If a window has <30% overlap with ANY display, it's orphaned: 15 + * find the nearest display and center the window on it 16 + * - Window size is NEVER changed — only position is adjusted 17 + * - If all windows are accessible (99% of cases), do nothing 30 18 */ 31 19 32 20 import { BrowserWindow, screen, Display } from 'electron'; ··· 46 34 interface WindowEntry { 47 35 win: BrowserWindow; 48 36 bounds: Electron.Rectangle; 49 - } 50 - 51 - /** Records a window's "home" display info when it gets displaced */ 52 - interface WindowHomeDisplay { 53 - displayId: number; 54 - resolution: { width: number; height: number }; 55 - relativePosition: { 56 - centerX: number; // 0-1 ratio within workArea width 57 - centerY: number; // 0-1 ratio within workArea height 58 - width: number; // 0-1 ratio within workArea width 59 - height: number; // 0-1 ratio within workArea height 60 - }; 61 37 } 62 38 63 39 /** Last known display layout */ 64 40 let _previousDisplays: DisplaySnapshot[] = []; 65 41 66 - /** Displaced windows awaiting restoration, keyed by BrowserWindow id */ 67 - let _windowHomeDisplays: Map<number, WindowHomeDisplay> = new Map(); 68 - 69 - /** Continuously-updated window positions. Updated on every window move/resize. 70 - * Used by Phase 1 instead of pre-debounce capture, since macOS relocates windows 71 - * BEFORE the display-removed event fires (pre-debounce capture is already too late). */ 72 - let _trackedPositions: Map<number, { bounds: Electron.Rectangle; displaySnapshot: DisplaySnapshot }> = new Map(); 73 - 74 - /** Pre-debounce snapshot of window positions — DEPRECATED, kept as fallback. 75 - * macOS moves windows BEFORE the event fires, making this unreliable. */ 76 - let _preDebounceWindowBounds: Map<number, Electron.Rectangle> | null = null; 77 - 78 42 /** Debounce timer for display changes (macOS fires multiple events rapidly) */ 79 43 let _debounceTimer: ReturnType<typeof setTimeout> | null = null; 80 44 const DEBOUNCE_MS = 500; 81 45 82 - /** When true, display changes update the snapshot but skip window repositioning. 83 - * Used during session restore to prevent newly restored windows from being moved. */ 84 - let _suppressRepositioning = false; 85 - 86 - /** 87 - * Suppress window repositioning during a critical section (e.g., session restore). 88 - * The display snapshot is still updated on changes, but windows are not moved. 89 - * Call with true before restore, false after. 90 - */ 91 - export function suppressDisplayRepositioning(suppress: boolean): void { 92 - _suppressRepositioning = suppress; 93 - if (suppress) { 94 - // Cancel any pending debounced display change handler. 95 - // Without this, a display event that fired before suppression could trigger 96 - // repositioning AFTER suppression ends (the debounce timer outlasts the suppress window). 97 - if (_debounceTimer) { 98 - clearTimeout(_debounceTimer); 99 - _debounceTimer = null; 100 - } 101 - _preDebounceWindowBounds = null; 102 - console.log("[display-watcher] Repositioning SUPPRESSED (session restore in progress)"); 103 - } else { 104 - // Cancel any pending debounced events that accumulated during suppression. 105 - // These would use stale snapshots and could reposition windows incorrectly. 106 - if (_debounceTimer) { 107 - clearTimeout(_debounceTimer); 108 - _debounceTimer = null; 109 - } 110 - _preDebounceWindowBounds = null; 111 - // Take a fresh snapshot so the watcher has the correct baseline 112 - // after session restore created new windows 113 - _previousDisplays = snapshotDisplays(); 114 - console.log("[display-watcher] Repositioning RE-ENABLED, snapshot refreshed"); 115 - } 116 - } 117 - 118 46 /** 119 47 * Take a snapshot of the current display layout. 120 48 */ ··· 149 77 150 78 /** 151 79 * Check if a window is still accessible on the current displays. 152 - * "Accessible" means the window center-top region (title bar area) 153 - * is on a valid display, so the user can still grab and move it. 80 + * "Accessible" means the window has at least 30% area overlap with 81 + * any display's workArea, so the user can still interact with it. 82 + * Zero-size windows are always considered accessible (hidden/minimized). 154 83 */ 155 84 function isWindowAccessible(displays: DisplaySnapshot[], bounds: Electron.Rectangle): boolean { 156 - const centerX = bounds.x + Math.round(bounds.width / 2); 157 - const topY = bounds.y + 15; // Title bar midpoint 158 - return findDisplayForPoint(displays, centerX, topY) !== null; 85 + const windowArea = bounds.width * bounds.height; 86 + 87 + // Zero-size windows are always accessible (hidden, minimized, etc.) 88 + if (windowArea <= 0) return true; 89 + 90 + for (const d of displays) { 91 + const wa = d.workArea; 92 + 93 + // Calculate overlap rectangle 94 + const overlapX = Math.max(bounds.x, wa.x); 95 + const overlapY = Math.max(bounds.y, wa.y); 96 + const overlapRight = Math.min(bounds.x + bounds.width, wa.x + wa.width); 97 + const overlapBottom = Math.min(bounds.y + bounds.height, wa.y + wa.height); 98 + 99 + if (overlapRight > overlapX && overlapBottom > overlapY) { 100 + const overlapArea = (overlapRight - overlapX) * (overlapBottom - overlapY); 101 + if (overlapArea / windowArea >= 0.3) { 102 + return true; 103 + } 104 + } 105 + } 106 + 107 + return false; 159 108 } 160 109 161 110 /** 162 111 * Find the best new display to place a window on. 163 - * Tries to match by display ID first, then by proximity of old display position. 112 + * Finds the nearest display by center-point proximity. 113 + * Falls back to primary display when no old display info is available. 164 114 */ 165 115 function findBestNewDisplay( 166 116 oldDisplay: DisplaySnapshot | null, ··· 176 126 }; 177 127 } 178 128 179 - // If we know the old display, try to find it by ID in the new layout 129 + // If we know the old display, find the nearest new display by center distance 180 130 if (oldDisplay) { 181 - const sameId = newDisplays.find(d => d.id === oldDisplay.id); 182 - if (sameId) return sameId; 183 - 184 - // Find the display whose workArea center is closest to the old display center 185 131 const oldCenterX = oldDisplay.workArea.x + oldDisplay.workArea.width / 2; 186 132 const oldCenterY = oldDisplay.workArea.y + oldDisplay.workArea.height / 2; 187 133 ··· 208 154 } 209 155 210 156 /** 211 - * Calculate repositioned bounds for a group of windows moving between displays. 212 - * 213 - * Uses center-of-group scaling: finds the bounding box of all window centers, 214 - * then scales each window offset from that center by the display size ratio. 215 - * This preserves the spatial layout and scales distances proportionally. 216 - * 217 - * For a single window, this degrades gracefully: the window center is mapped 218 - * to the same relative position on the new display, with scaled dimensions. 219 - * 220 - * @param windowBounds Array of window bounds to reposition 221 - * @param oldDisplay The display windows are moving from 222 - * @param newDisplay The display windows are moving to 223 - * @returns Array of new bounds, one per input window 224 - */ 225 - function calculateGroupRepositionedBounds( 226 - windowBounds: Electron.Rectangle[], 227 - oldDisplay: DisplaySnapshot, 228 - newDisplay: DisplaySnapshot 229 - ): Electron.Rectangle[] { 230 - const oldWA = oldDisplay.workArea; 231 - const newWA = newDisplay.workArea; 232 - 233 - // Scale factors for distances 234 - const scaleX = oldWA.width > 0 ? newWA.width / oldWA.width : 1; 235 - const scaleY = oldWA.height > 0 ? newWA.height / oldWA.height : 1; 236 - 237 - // Calculate the center of each window 238 - const centers = windowBounds.map(wb => ({ 239 - x: wb.x + wb.width / 2, 240 - y: wb.y + wb.height / 2, 241 - })); 242 - 243 - // Find the bounding box of all window centers 244 - let minCX = Infinity, maxCX = -Infinity; 245 - let minCY = Infinity, maxCY = -Infinity; 246 - for (const c of centers) { 247 - minCX = Math.min(minCX, c.x); 248 - maxCX = Math.max(maxCX, c.x); 249 - minCY = Math.min(minCY, c.y); 250 - maxCY = Math.max(maxCY, c.y); 251 - } 252 - 253 - // Center of the bounding box of window centers (on old display) 254 - const groupCenterX = (minCX + maxCX) / 2; 255 - const groupCenterY = (minCY + maxCY) / 2; 256 - 257 - // Map the group center to new display: preserve its relative position within the workArea 258 - const groupRelX = oldWA.width > 0 ? (groupCenterX - oldWA.x) / oldWA.width : 0.5; 259 - const groupRelY = oldWA.height > 0 ? (groupCenterY - oldWA.y) / oldWA.height : 0.5; 260 - const newGroupCenterX = newWA.x + groupRelX * newWA.width; 261 - const newGroupCenterY = newWA.y + groupRelY * newWA.height; 262 - 263 - const results: Electron.Rectangle[] = []; 264 - 265 - for (let i = 0; i < windowBounds.length; i++) { 266 - const wb = windowBounds[i]; 267 - const center = centers[i]; 268 - 269 - // Offset from group center on old display 270 - const offsetX = center.x - groupCenterX; 271 - const offsetY = center.y - groupCenterY; 272 - 273 - // Scale the offset by display size ratio 274 - const scaledOffsetX = offsetX * scaleX; 275 - const scaledOffsetY = offsetY * scaleY; 276 - 277 - // Scale window dimensions proportionally 278 - let newW = Math.round(wb.width * scaleX); 279 - let newH = Math.round(wb.height * scaleY); 280 - 281 - // Enforce minimum window size 282 - newW = Math.max(newW, 200); 283 - newH = Math.max(newH, 150); 284 - 285 - // Cap to new workArea size 286 - newW = Math.min(newW, newWA.width); 287 - newH = Math.min(newH, newWA.height); 288 - 289 - // New center = new group center + scaled offset 290 - const newCenterX = newGroupCenterX + scaledOffsetX; 291 - const newCenterY = newGroupCenterY + scaledOffsetY; 292 - 293 - // Convert center to top-left 294 - let newX = Math.round(newCenterX - newW / 2); 295 - let newY = Math.round(newCenterY - newH / 2); 296 - 297 - // Clamp so window stays fully within the new workArea 298 - newX = Math.max(newWA.x, Math.min(newX, newWA.x + newWA.width - newW)); 299 - newY = Math.max(newWA.y, Math.min(newY, newWA.y + newWA.height - newH)); 300 - 301 - results.push({ x: newX, y: newY, width: newW, height: newH }); 302 - } 303 - 304 - return results; 305 - } 306 - 307 - /** 308 - * Reposition a group of windows from an old display to a new display, 309 - * preserving their spatial layout with scaled distances. 310 - */ 311 - function repositionWindowGroup( 312 - entries: WindowEntry[], 313 - oldDisplay: DisplaySnapshot, 314 - newDisplay: DisplaySnapshot 315 - ): void { 316 - const windowBounds = entries.map(e => e.bounds); 317 - const newBoundsList = calculateGroupRepositionedBounds(windowBounds, oldDisplay, newDisplay); 318 - 319 - for (let i = 0; i < entries.length; i++) { 320 - const { win, bounds } = entries[i]; 321 - const newBounds = newBoundsList[i]; 322 - 323 - DEBUG && console.log( 324 - `[display-watcher] Repositioning window ${win.id}: ` + 325 - `(${bounds.x},${bounds.y} ${bounds.width}x${bounds.height}) -> ` + 326 - `(${newBounds.x},${newBounds.y} ${newBounds.width}x${newBounds.height}) ` + 327 - `[display ${oldDisplay.id} -> ${newDisplay.id}]` 328 - ); 329 - 330 - console.log(`[display-watcher] REPOSITIONING window ${win.id}: (${bounds.x},${bounds.y} ${bounds.width}x${bounds.height}) -> (${newBounds.x},${newBounds.y} ${newBounds.width}x${newBounds.height})`); 331 - win.setBounds(newBounds); 332 - } 333 - } 334 - 335 - /** 336 - * Save a window's home display info before it gets displaced. 337 - * Captures the window's relative position within the display's workArea 338 - * so it can be restored later if the same display reappears. 339 - */ 340 - function saveWindowHomeDisplay( 341 - win: BrowserWindow, 342 - bounds: Electron.Rectangle, 343 - display: DisplaySnapshot 344 - ): void { 345 - const wa = display.workArea; 346 - const centerX = bounds.x + bounds.width / 2; 347 - const centerY = bounds.y + bounds.height / 2; 348 - 349 - const homeDisplay: WindowHomeDisplay = { 350 - displayId: display.id, 351 - resolution: { width: wa.width, height: wa.height }, 352 - relativePosition: { 353 - centerX: wa.width > 0 ? (centerX - wa.x) / wa.width : 0.5, 354 - centerY: wa.height > 0 ? (centerY - wa.y) / wa.height : 0.5, 355 - width: wa.width > 0 ? bounds.width / wa.width : 0.5, 356 - height: wa.height > 0 ? bounds.height / wa.height : 0.5, 357 - }, 358 - }; 359 - 360 - _windowHomeDisplays.set(win.id, homeDisplay); 361 - console.log( 362 - `[display-watcher] Saved home display for window ${win.id}: ` + 363 - `display ${display.id} (${wa.x},${wa.y} ${wa.width}x${wa.height}), ` + 364 - `rel center (${homeDisplay.relativePosition.centerX.toFixed(3)}, ${homeDisplay.relativePosition.centerY.toFixed(3)})` 365 - ); 366 - } 367 - 368 - /** 369 - * Find a matching new display for a displaced window's home display. 370 - * Matches by display ID first, then falls back to resolution matching 371 - * within a 5% tolerance. 372 - */ 373 - function findMatchingNewDisplay( 374 - home: WindowHomeDisplay, 375 - addedDisplays: DisplaySnapshot[] 376 - ): DisplaySnapshot | null { 377 - // First try exact display ID match 378 - const byId = addedDisplays.find(d => d.id === home.displayId); 379 - if (byId) return byId; 380 - 381 - // Fallback: match by resolution within 5% tolerance 382 - const tolerance = 0.05; 383 - for (const d of addedDisplays) { 384 - const wa = d.workArea; 385 - const widthRatio = home.resolution.width > 0 386 - ? Math.abs(wa.width - home.resolution.width) / home.resolution.width 387 - : (wa.width === 0 ? 0 : 1); 388 - const heightRatio = home.resolution.height > 0 389 - ? Math.abs(wa.height - home.resolution.height) / home.resolution.height 390 - : (wa.height === 0 ? 0 : 1); 391 - 392 - console.log( 393 - `[display-watcher] Resolution check: home (${home.resolution.width}x${home.resolution.height}) ` + 394 - `vs display ${d.id} (${wa.width}x${wa.height}), ` + 395 - `delta (${(widthRatio * 100).toFixed(1)}%, ${(heightRatio * 100).toFixed(1)}%) ` + 396 - `${widthRatio <= tolerance && heightRatio <= tolerance ? 'MATCH' : 'no match'}` 397 - ); 398 - if (widthRatio <= tolerance && heightRatio <= tolerance) { 399 - return d; 400 - } 401 - } 402 - 403 - return null; 404 - } 405 - 406 - /** 407 - * Restore a window to its saved home display position. 408 - * Places the window at the same relative position within the new display's workArea. 409 - */ 410 - function restoreWindowToHomeDisplay( 411 - win: BrowserWindow, 412 - home: WindowHomeDisplay, 413 - display: DisplaySnapshot 414 - ): void { 415 - const wa = display.workArea; 416 - const rel = home.relativePosition; 417 - 418 - // Restore window dimensions from relative sizes 419 - let newW = Math.round(rel.width * wa.width); 420 - let newH = Math.round(rel.height * wa.height); 421 - 422 - // Enforce minimum window size 423 - newW = Math.max(newW, 200); 424 - newH = Math.max(newH, 150); 425 - 426 - // Cap to workArea size 427 - newW = Math.min(newW, wa.width); 428 - newH = Math.min(newH, wa.height); 429 - 430 - // Restore center position from relative coordinates 431 - const centerX = wa.x + rel.centerX * wa.width; 432 - const centerY = wa.y + rel.centerY * wa.height; 433 - 434 - // Convert center to top-left 435 - let newX = Math.round(centerX - newW / 2); 436 - let newY = Math.round(centerY - newH / 2); 437 - 438 - // Clamp within workArea 439 - newX = Math.max(wa.x, Math.min(newX, wa.x + wa.width - newW)); 440 - newY = Math.max(wa.y, Math.min(newY, wa.y + wa.height - newH)); 441 - 442 - const newBounds = { x: newX, y: newY, width: newW, height: newH }; 443 - 444 - DEBUG && console.log( 445 - `[display-watcher] Restoring window ${win.id} to home display ${display.id}: ` + 446 - `(${newBounds.x},${newBounds.y} ${newBounds.width}x${newBounds.height})` 447 - ); 448 - 449 - console.log(`[display-watcher] RESTORING window ${win.id} to home display: (${newBounds.x},${newBounds.y} ${newBounds.width}x${newBounds.height})`); 450 - win.setBounds(newBounds); 451 - } 452 - 453 - /** 454 157 * Handle a display configuration change. 455 - * Three-phase approach: 456 - * Phase 1: Save home display info for windows on removed displays 457 - * Phase 2: Restore displaced windows to matching newly-added displays 458 - * Phase 3: Reposition remaining off-screen/resized windows (existing logic) 158 + * Single-pass safety net: check all windows, rescue any that are orphaned. 459 159 */ 460 160 function handleDisplayChange(): void { 461 - console.log(`[display-watcher] handleDisplayChange fired — checking for window repositioning`); 462 - if (_suppressRepositioning) { 463 - console.log("[display-watcher] Repositioning suppressed during session restore — updating snapshot only"); 464 - _previousDisplays = snapshotDisplays(); 465 - return; 466 - } 467 161 const newDisplays = snapshotDisplays(); 468 162 const oldDisplays = _previousDisplays; 469 163 ··· 473 167 `New: ${newDisplays.length} display(s) [${newDisplays.map(d => `${d.id}(${d.workArea.x},${d.workArea.y} ${d.workArea.width}x${d.workArea.height})`).join(', ')}]` 474 168 ); 475 169 476 - // Build maps for quick lookup 477 - const newById = new Map(newDisplays.map(d => [d.id, d])); 478 - const oldById = new Map(oldDisplays.map(d => [d.id, d])); 479 - 480 - // Determine which displays were removed, added, or changed 481 - const removedDisplayIds = new Set<number>(); 482 - const changedDisplayIds = new Set<number>(); 483 - for (const old of oldDisplays) { 484 - const cur = newById.get(old.id); 485 - if (!cur) { 486 - removedDisplayIds.add(old.id); 487 - changedDisplayIds.add(old.id); 488 - } else if ( 489 - cur.workArea.x !== old.workArea.x || 490 - cur.workArea.y !== old.workArea.y || 491 - cur.workArea.width !== old.workArea.width || 492 - cur.workArea.height !== old.workArea.height 493 - ) { 494 - changedDisplayIds.add(old.id); 495 - } 496 - } 497 - 498 - const addedDisplays: DisplaySnapshot[] = []; 499 - for (const d of newDisplays) { 500 - if (!oldById.has(d.id)) { 501 - addedDisplays.push(d); 502 - } 503 - } 504 - 505 - console.log( 506 - `[display-watcher] Removed: ${[...removedDisplayIds].join(", ") || "none"}, ` + 507 - `Added: ${addedDisplays.map(d => `${d.id}(${d.workArea.x},${d.workArea.y} ${d.workArea.width}x${d.workArea.height})`).join(", ") || "none"}, ` + 508 - `Changed: ${[...changedDisplayIds].join(", ") || "none"}` 509 - ); 510 - 511 - // ======================================================================== 512 - // Phase 1 (Save): Save home display info for windows on removed displays. 513 - // Use pre-debounce window bounds if available — macOS auto-relocates windows from 514 - // disconnected displays BEFORE our debounced handler runs, so win.getBounds() at this 515 - // point returns the post-relocation position (on the laptop), not the original position 516 - // (on the external display). The pre-debounce capture gives us the true positions. 517 - // ======================================================================== 518 - if (removedDisplayIds.size > 0) { 519 - const allWindows = BrowserWindow.getAllWindows(); 520 - for (const win of allWindows) { 521 - if (win.isDestroyed()) continue; 522 - if (!win.isVisible()) continue; 523 - 524 - // Skip the background window 525 - const entry = getWindowInfo(win.id); 526 - if (entry && entry.params.address === WEB_CORE_ADDRESS) continue; 527 - 528 - // Only save if we haven't already saved a home display for this window 529 - if (_windowHomeDisplays.has(win.id)) continue; 530 - 531 - // Priority order for position data: 532 - // 1. Continuously-tracked position (most reliable — captured before macOS moved anything) 533 - // 2. Pre-debounce capture (unreliable — macOS already moved windows by this point) 534 - // 3. Current bounds (worst — definitely post-relocation) 535 - const tracked = _trackedPositions.get(win.id); 536 - const preDebBounds = _preDebounceWindowBounds?.get(win.id); 537 - const currentBounds = win.getBounds(); 538 - const bounds = tracked?.bounds ?? preDebBounds ?? currentBounds; 539 - const source = tracked ? 'tracked' : preDebBounds ? 'preDeb' : 'current'; 540 - 541 - const centerX = bounds.x + Math.round(bounds.width / 2); 542 - const centerY = bounds.y + Math.round(bounds.height / 2); 543 - 544 - // Use tracked display if available (already resolved), otherwise look up from old displays 545 - let oldDisplay: DisplaySnapshot | null = null; 546 - if (tracked && removedDisplayIds.has(tracked.displaySnapshot.id)) { 547 - oldDisplay = tracked.displaySnapshot; 548 - } else { 549 - oldDisplay = findDisplayForPoint(oldDisplays, centerX, centerY); 550 - } 551 - 552 - console.log( 553 - `[display-watcher] Phase 1 window ${win.id}: ` + 554 - `source=${source}, bounds=(${bounds.x},${bounds.y} ${bounds.width}x${bounds.height}), ` + 555 - `current=(${currentBounds.x},${currentBounds.y}), ` + 556 - `oldDisplay=${oldDisplay ? `${oldDisplay.id}(removed=${removedDisplayIds.has(oldDisplay.id)})` : 'null'}` 557 - ); 558 - if (oldDisplay && removedDisplayIds.has(oldDisplay.id)) { 559 - saveWindowHomeDisplay(win, bounds, oldDisplay); 560 - console.log(`[display-watcher] Phase 1 SAVED home for window ${win.id} on display ${oldDisplay.id}`); 561 - } 562 - } 563 - 564 - console.log( 565 - `[display-watcher] Phase 1 SUMMARY: ${_windowHomeDisplays.size} displaced window(s)` 566 - ); 567 - 568 - // ================================================================== 569 - // Phase 1b (Redistribute): Reposition displaced windows onto remaining 570 - // displays using their TRACKED (real pre-disconnect) positions. 571 - // macOS jams all windows into top-left of the laptop — override that 572 - // with proper group-scaled redistribution preserving relative layout. 573 - // ================================================================== 574 - if (_windowHomeDisplays.size > 0 && newDisplays.length > 0) { 575 - // Group displaced windows by their home display 576 - const displaceGroups = new Map<number, WindowEntry[]>(); 577 - for (const [winId, home] of _windowHomeDisplays) { 578 - const win = BrowserWindow.fromId(winId); 579 - if (!win || win.isDestroyed()) continue; 580 - const tracked = _trackedPositions.get(winId); 581 - if (!tracked) continue; 582 - 583 - if (!displaceGroups.has(home.displayId)) displaceGroups.set(home.displayId, []); 584 - displaceGroups.get(home.displayId)!.push({ win, bounds: tracked.bounds }); 585 - } 586 - 587 - for (const [displayId, entries] of displaceGroups) { 588 - const oldDisp = oldById.get(displayId); 589 - if (!oldDisp) continue; 590 - const targetDisp = findBestNewDisplay(oldDisp, newDisplays); 591 - console.log( 592 - `[display-watcher] Phase 1b: redistributing ${entries.length} window(s) ` + 593 - `from removed display ${displayId} -> display ${targetDisp.id} ` + 594 - `(${targetDisp.workArea.width}x${targetDisp.workArea.height})` 595 - ); 596 - repositionWindowGroup(entries, oldDisp, targetDisp); 597 - } 598 - } 599 - } 600 - 601 - // ======================================================================== 602 - // Phase 2 (Restore): Restore displaced windows to matching new displays 603 - // ======================================================================== 604 - // Track windows handled by Phase 1b/2 so Phase 3 skips them 605 - const handledWindows = new Set<number>(); 606 - 607 - // Mark Phase 1b windows as handled 608 - if (removedDisplayIds.size > 0) { 609 - for (const [winId] of _windowHomeDisplays) { 610 - handledWindows.add(winId); 611 - } 612 - } 613 - 614 - console.log(`[display-watcher] Phase 2: addedDisplays=${addedDisplays.length}, displacedWindows=${_windowHomeDisplays.size}`); 615 - if (addedDisplays.length > 0 && _windowHomeDisplays.size > 0) { 616 - const restoredWindowIds: number[] = []; 617 - 618 - for (const [winId, home] of _windowHomeDisplays) { 619 - const win = BrowserWindow.fromId(winId); 620 - if (!win || win.isDestroyed()) { 621 - console.log(`[display-watcher] Phase 2 window ${winId}: destroyed/gone, cleaning up`); 622 - restoredWindowIds.push(winId); // Clean up stale entries 623 - continue; 624 - } 625 - 626 - const matchingDisplay = findMatchingNewDisplay(home, addedDisplays); 627 - console.log( 628 - `[display-watcher] Phase 2 window ${winId}: ` + 629 - `home display=${home.displayId} (${home.resolution.width}x${home.resolution.height}), ` + 630 - `match=${matchingDisplay ? `${matchingDisplay.id}(${matchingDisplay.workArea.width}x${matchingDisplay.workArea.height})` : 'NONE'}` 631 - ); 632 - if (matchingDisplay) { 633 - restoreWindowToHomeDisplay(win, home, matchingDisplay); 634 - restoredWindowIds.push(winId); 635 - handledWindows.add(winId); 636 - } 637 - } 170 + const allWindows = BrowserWindow.getAllWindows(); 171 + let rescuedCount = 0; 638 172 639 - // Remove restored/stale entries from the map 640 - for (const id of restoredWindowIds) { 641 - _windowHomeDisplays.delete(id); 642 - } 643 - 644 - console.log( 645 - `[display-watcher] Phase 2 SUMMARY: Restored ${restoredWindowIds.length} window(s), ` + 646 - `${_windowHomeDisplays.size} still displaced` 647 - ); 648 - } 649 - 650 - // ======================================================================== 651 - // Phase 3 (Reposition): Handle remaining off-screen/resized windows 652 - // ======================================================================== 653 - 654 - if (changedDisplayIds.size === 0 && addedDisplays.length === 0) { 655 - DEBUG && console.log("[display-watcher] Phase 3: No display workAreas changed, skipping repositioning"); 656 - _previousDisplays = newDisplays; 657 - return; 658 - } 659 - 660 - DEBUG && console.log(`[display-watcher] Phase 3: Changed display IDs: ${[...changedDisplayIds].join(", ")}`); 661 - 662 - // Collect windows that need repositioning, grouped by (oldDisplay -> newDisplay) transition 663 - // Key: "oldDisplayId->newDisplayId" 664 - const transitionGroups = new Map<string, { 665 - entries: WindowEntry[]; 666 - oldDisplay: DisplaySnapshot; 667 - newDisplay: DisplaySnapshot; 668 - }>(); 669 - 670 - // Also track orphaned windows (center off-screen, no old display found) 671 - const orphanedWindows: WindowEntry[] = []; 672 - 673 - const allWindows = BrowserWindow.getAllWindows(); 674 173 for (const win of allWindows) { 675 174 if (win.isDestroyed()) continue; 676 175 if (!win.isVisible()) continue; ··· 679 178 const entry = getWindowInfo(win.id); 680 179 if (entry && entry.params.address === WEB_CORE_ADDRESS) continue; 681 180 682 - // Skip windows already handled by Phase 1b (redistributed) or Phase 2 (restored) — 683 - // their coordinates are correct for the new display layout, but would 684 - // appear to be on a "changed" display when checked against oldDisplays. 685 - if (handledWindows.has(win.id)) { 686 - console.log(`[display-watcher] Phase 3: skipping window ${win.id} (handled by Phase 1b/2)`); 687 - continue; 688 - } 689 - 690 181 const bounds = win.getBounds(); 691 - const centerX = bounds.x + Math.round(bounds.width / 2); 692 - const centerY = bounds.y + Math.round(bounds.height / 2); 693 182 694 - // Check if window is still accessible on a current display 183 + // Check if window is accessible on current displays 695 184 if (isWindowAccessible(newDisplays, bounds)) { 696 - // Window is still reachable - but check if its display changed size 697 - const oldDisplay = findDisplayForPoint(oldDisplays, centerX, centerY); 698 - if (oldDisplay && changedDisplayIds.has(oldDisplay.id)) { 699 - const newDisplay = newById.get(oldDisplay.id); 700 - if (newDisplay) { 701 - // Same display ID but different size - needs repositioning 702 - const key = `${oldDisplay.id}->${newDisplay.id}`; 703 - if (!transitionGroups.has(key)) { 704 - transitionGroups.set(key, { entries: [], oldDisplay, newDisplay }); 705 - } 706 - transitionGroups.get(key)!.entries.push({ win, bounds }); 707 - } 708 - } 709 - // Otherwise, window is fine where it is 710 185 continue; 711 186 } 712 187 713 - // Window center is off-screen - find where it was and move it 188 + // Window is orphaned — rescue it 189 + const centerX = bounds.x + Math.round(bounds.width / 2); 190 + const centerY = bounds.y + Math.round(bounds.height / 2); 191 + 192 + // Find which old display this window was on 714 193 const oldDisplay = findDisplayForPoint(oldDisplays, centerX, centerY); 715 - if (oldDisplay) { 716 - const newDisplay = findBestNewDisplay(oldDisplay, newDisplays); 717 - const key = `${oldDisplay.id}->${newDisplay.id}`; 718 - if (!transitionGroups.has(key)) { 719 - transitionGroups.set(key, { entries: [], oldDisplay, newDisplay }); 720 - } 721 - transitionGroups.get(key)!.entries.push({ win, bounds }); 722 - } else { 723 - // Cannot determine old display 724 - orphanedWindows.push({ win, bounds }); 725 - } 726 - } 194 + 195 + // Find the best new display to place it on 196 + const newDisplay = findBestNewDisplay(oldDisplay, newDisplays); 197 + const wa = newDisplay.workArea; 198 + 199 + // Position window on the target display, preserving original size. 200 + // Only reposition, never resize — macOS handles display transitions 201 + // and windows should keep their size even if larger than the new display. 202 + const x = wa.x + Math.round((wa.width - bounds.width) / 2); 203 + const y = wa.y + Math.round((wa.height - bounds.height) / 2); 727 204 728 - // Reposition each group with distance scaling 729 - for (const [key, group] of transitionGroups) { 730 - DEBUG && console.log( 731 - `[display-watcher] Repositioning group [${key}]: ${group.entries.length} window(s)` 205 + console.log( 206 + `[display-watcher] Rescuing orphaned window ${win.id}: ` + 207 + `(${bounds.x},${bounds.y} ${bounds.width}x${bounds.height}) -> ` + 208 + `(${x},${y} ${bounds.width}x${bounds.height}) [display ${newDisplay.id}]` 732 209 ); 733 - repositionWindowGroup(group.entries, group.oldDisplay, group.newDisplay); 210 + 211 + win.setBounds({ x, y, width: bounds.width, height: bounds.height }); 212 + rescuedCount++; 734 213 } 735 214 736 - // Handle orphaned windows: center them on the best available display 737 - for (const { win, bounds } of orphanedWindows) { 738 - const newDisplay = findBestNewDisplay(null, newDisplays); 739 - const wa = newDisplay.workArea; 740 - const w = Math.min(bounds.width, wa.width); 741 - const h = Math.min(bounds.height, wa.height); 742 - const x = wa.x + Math.round((wa.width - w) / 2); 743 - const y = wa.y + Math.round((wa.height - h) / 2); 744 - DEBUG && console.log( 745 - `[display-watcher] Centering orphaned window ${win.id} on display ${newDisplay.id}: (${x},${y} ${w}x${h})` 746 - ); 747 - win.setBounds({ x, y, width: w, height: h }); 215 + if (rescuedCount === 0) { 216 + DEBUG && console.log("[display-watcher] All windows accessible, no rescue needed"); 217 + } else { 218 + console.log(`[display-watcher] Rescued ${rescuedCount} orphaned window(s)`); 748 219 } 749 220 750 221 // Update snapshot for next change ··· 752 223 } 753 224 754 225 /** 755 - * Capture a snapshot of all window bounds right now. 756 - * Called immediately (before debounce) on display-removed events so we know 757 - * which display each window was on BEFORE macOS auto-relocates them. 758 - */ 759 - function captureWindowBounds(): Map<number, Electron.Rectangle> { 760 - const bounds = new Map<number, Electron.Rectangle>(); 761 - const allWindows = BrowserWindow.getAllWindows(); 762 - for (const win of allWindows) { 763 - if (win.isDestroyed() || !win.isVisible()) continue; 764 - bounds.set(win.id, win.getBounds()); 765 - } 766 - return bounds; 767 - } 768 - 769 - /** 770 226 * Debounced display change handler. 771 227 * macOS fires multiple display events in quick succession (e.g. resolution + workArea). 772 228 * We debounce to handle them as a single batch. 773 - * 774 - * @param isRemoval - if true, capture window bounds immediately before macOS moves them 775 229 */ 776 - function onDisplayChange(isRemoval = false): void { 777 - // On the FIRST event in a debounce window, capture window bounds eagerly. 778 - // macOS moves windows from disconnected displays before our debounced handler fires, 779 - // so we need the pre-move positions to correctly identify which display each window was on. 780 - if (!_debounceTimer && isRemoval) { 781 - _preDebounceWindowBounds = captureWindowBounds(); 782 - console.log(`[display-watcher] Captured pre-debounce window bounds: ${_preDebounceWindowBounds.size} windows`); 783 - for (const [id, b] of _preDebounceWindowBounds) { 784 - console.log(`[display-watcher] window ${id}: (${b.x},${b.y} ${b.width}x${b.height})`); 785 - } 786 - } 787 - 230 + function onDisplayChange(): void { 788 231 if (_debounceTimer) { 789 232 clearTimeout(_debounceTimer); 790 233 } 791 234 _debounceTimer = setTimeout(() => { 792 235 _debounceTimer = null; 793 236 handleDisplayChange(); 794 - // Clear pre-debounce bounds after handling 795 - _preDebounceWindowBounds = null; 796 237 }, DEBOUNCE_MS); 797 238 } 798 239 ··· 817 258 818 259 screen.on('display-removed', () => { 819 260 console.log("[display-watcher] display-removed event"); 820 - onDisplayChange(/* isRemoval */ true); 261 + onDisplayChange(); 821 262 }); 822 263 823 264 screen.on('display-metrics-changed', (_event: Electron.Event, _display: Display, changedMetrics: string[]) => { ··· 832 273 } 833 274 834 275 /** 835 - * Start tracking a window's position for home display restoration. 836 - * Call this after creating any visible window. Attaches move/resize 837 - * listeners so we always know each window's true position and display, 838 - * even if macOS relocates windows before the display-removed event fires. 276 + * Track a window for display change handling. 277 + * Call this after creating any visible window. 278 + * Attaches a closed listener to clean up. 839 279 */ 840 280 export function trackWindow(win: BrowserWindow): void { 841 - const updateTracking = () => { 842 - if (win.isDestroyed() || !win.isVisible()) return; 843 - const bounds = win.getBounds(); 844 - const centerX = bounds.x + Math.round(bounds.width / 2); 845 - const centerY = bounds.y + Math.round(bounds.height / 2); 846 - // Use _previousDisplays (stable snapshot) to determine which display this window is on 847 - const display = findDisplayForPoint(_previousDisplays, centerX, centerY); 848 - if (display) { 849 - _trackedPositions.set(win.id, { bounds: { ...bounds }, displaySnapshot: display }); 850 - } 851 - }; 852 - 853 - // Save initial position 854 - updateTracking(); 855 - 856 - // Update on moves — 'moved' fires at end of move on macOS 857 - win.on('moved', updateTracking); 858 - win.on('resize', updateTracking); 859 - 860 - // Clean up on close 281 + // Clean up on close (future-proofing for any per-window state) 861 282 win.on('closed', () => { 862 - _trackedPositions.delete(win.id); 863 - _windowHomeDisplays.delete(win.id); 283 + // No per-window state to clean up in the safety-net approach, 284 + // but keeping the hook for extensibility. 864 285 }); 865 286 } 866 287
+1
backend/electron/ipc.ts
··· 2637 2637 popupParams.groupMode = groupMode; 2638 2638 } 2639 2639 registerWindow(popupWin.id, source, popupParams); 2640 + trackWindow(popupWin); 2640 2641 coordinator.pushWindow(popupWin.id); 2641 2642 2642 2643 // Set mode context (inherit group mode or detect from URL)
+4
backend/electron/main.ts
··· 16 16 import { registerLocalShortcut, unregisterLocalShortcut, handleLocalShortcut, registerGlobalShortcut, unregisterGlobalShortcut, unregisterShortcutsForAddress } from './shortcuts.js'; 17 17 import { scopes, publish, subscribe, setExtensionBroadcaster, getSystemAddress } from './pubsub.js'; 18 18 import { APP_DEF_WIDTH, APP_DEF_HEIGHT, WEB_CORE_ADDRESS, getPreloadPath, isTestProfile, isDevProfile, isHeadless, getProfile, DEBUG } from './config.js'; 19 + import { trackWindow } from './display-watcher.js'; 19 20 import { addEscHandler, winDevtoolsConfig, closeOrHideWindow, getSystemThemeBackgroundColor, getPrefs } from './windows.js'; 20 21 import { getProfileSession, getPartitionString, getCurrentProfileId } from './session-partition.js'; 21 22 import { getIzuiCoordinator } from './izui-state.js'; ··· 1045 1046 1046 1047 // Add escape key handler 1047 1048 addEscHandler(newWin); 1049 + 1050 + // Track window for display change handling 1051 + trackWindow(newWin); 1048 1052 1049 1053 // Set up DevTools if requested 1050 1054 winDevtoolsConfig(newWin);
+1 -13
backend/electron/session.ts
··· 14 14 import { BrowserWindow, dialog, screen } from 'electron'; 15 15 import { getDb, getContextEntry, addContextEntry } from './datastore.js'; 16 16 import { getAllWindows, getBackgroundWindow } from './main.js'; 17 - import { suppressDisplayRepositioning } from "./display-watcher.js"; 17 + 18 18 import { publish, scopes as PubSubScopes, getSystemAddress } from './pubsub.js'; 19 19 import { DEBUG, isTestProfile } from './config.js'; 20 20 ··· 534 534 // Track which window was focused for later focus restoration 535 535 let focusedWindowId: number | null = null; 536 536 537 - // Suppress display-watcher repositioning during restore. 538 - // macOS can fire display-metrics-changed (workArea) when the app becomes active 539 - // (e.g., dock visibility changes, menu bar adjustments). Without suppression, 540 - // the display-watcher would reposition newly restored windows using proportional 541 - // scaling, losing their exact saved positions (especially at screen edges). 542 - suppressDisplayRepositioning(true); 543 - 544 537 for (const descriptor of sortedWindows) { 545 538 try { 546 539 // Validate bounds: check if saved center point falls within any active display ··· 646 639 } 647 640 } 648 641 649 - 650 - // Re-enable display-watcher repositioning now that all windows are restored. 651 - // This also refreshes the display snapshot so future display changes use 652 - // the correct baseline (with restored windows in their final positions). 653 - suppressDisplayRepositioning(false); 654 642 // Check for partial restore — warn if majority of windows failed 655 643 if (result.failed > 0 && result.total > 2 && result.failed > result.total / 2) { 656 644 console.warn(`[session] Partial restore: ${result.failed}/${result.total} windows failed`);