···33 *
44 * These test the pure math functions extracted from display-watcher.ts:
55 * - findDisplayForPoint: determine which display contains a point
66- * - isWindowAccessible: check if a window title bar is reachable
77- * - findBestNewDisplay: find the closest display to place a displaced window
88- * - calculateGroupRepositionedBounds: group-based distance scaling for new display
99- * - computeHomeDisplay: compute home display info for a displaced window
1010- * - findMatchingNewDisplay: match a displaced window's home to a new display
1111- * - computeRestoredBounds: compute restored bounds for a displaced window
66+ * - isWindowAccessible: check if a window has >=30% area overlap with any display
77+ * - findBestNewDisplay: find the nearest display by center-point proximity
128 *
139 * All functions are pure (no Electron dependencies) so they can run
1410 * under ELECTRON_RUN_AS_NODE=1 without a real display server.
···3430 workArea: Rectangle;
3531}
36323737-interface WindowHomeDisplay {
3838- displayId: number;
3939- resolution: { width: number; height: number };
4040- relativePosition: {
4141- centerX: number;
4242- centerY: number;
4343- width: number;
4444- height: number;
4545- };
4646-}
4747-4833// ============================================================================
4934// Pure logic functions (extracted from display-watcher.ts - identical implementations)
5035// ============================================================================
···6651}
67526853function isWindowAccessible(displays: DisplaySnapshot[], bounds: Rectangle): boolean {
6969- const centerX = bounds.x + Math.round(bounds.width / 2);
7070- const topY = bounds.y + 15;
7171- return findDisplayForPoint(displays, centerX, topY) !== null;
5454+ const windowArea = bounds.width * bounds.height;
5555+5656+ // Zero-size windows are always accessible
5757+ if (windowArea <= 0) return true;
5858+5959+ for (const d of displays) {
6060+ const wa = d.workArea;
6161+6262+ const overlapX = Math.max(bounds.x, wa.x);
6363+ const overlapY = Math.max(bounds.y, wa.y);
6464+ const overlapRight = Math.min(bounds.x + bounds.width, wa.x + wa.width);
6565+ const overlapBottom = Math.min(bounds.y + bounds.height, wa.y + wa.height);
6666+6767+ if (overlapRight > overlapX && overlapBottom > overlapY) {
6868+ const overlapArea = (overlapRight - overlapX) * (overlapBottom - overlapY);
6969+ if (overlapArea / windowArea >= 0.3) {
7070+ return true;
7171+ }
7272+ }
7373+ }
7474+7575+ return false;
7276}
73777478function findBestNewDisplay(
···8185 }
82868387 if (oldDisplay) {
8484- const sameId = newDisplays.find(d => d.id === oldDisplay.id);
8585- if (sameId) return sameId;
8686-8788 const oldCenterX = oldDisplay.workArea.x + oldDisplay.workArea.width / 2;
8889 const oldCenterY = oldDisplay.workArea.y + oldDisplay.workArea.height / 2;
8990···105106 return primary || newDisplays[0];
106107}
107108108108-function calculateGroupRepositionedBounds(
109109- windowBounds: Rectangle[],
110110- oldDisplay: DisplaySnapshot,
111111- newDisplay: DisplaySnapshot
112112-): Rectangle[] {
113113- const oldWA = oldDisplay.workArea;
114114- const newWA = newDisplay.workArea;
115115-116116- const scaleX = oldWA.width > 0 ? newWA.width / oldWA.width : 1;
117117- const scaleY = oldWA.height > 0 ? newWA.height / oldWA.height : 1;
118118-119119- const centers = windowBounds.map(wb => ({
120120- x: wb.x + wb.width / 2,
121121- y: wb.y + wb.height / 2,
122122- }));
123123-124124- let minCX = Infinity, maxCX = -Infinity;
125125- let minCY = Infinity, maxCY = -Infinity;
126126- for (const c of centers) {
127127- minCX = Math.min(minCX, c.x);
128128- maxCX = Math.max(maxCX, c.x);
129129- minCY = Math.min(minCY, c.y);
130130- maxCY = Math.max(maxCY, c.y);
131131- }
132132-133133- const groupCenterX = (minCX + maxCX) / 2;
134134- const groupCenterY = (minCY + maxCY) / 2;
135135-136136- const groupRelX = oldWA.width > 0 ? (groupCenterX - oldWA.x) / oldWA.width : 0.5;
137137- const groupRelY = oldWA.height > 0 ? (groupCenterY - oldWA.y) / oldWA.height : 0.5;
138138- const newGroupCenterX = newWA.x + groupRelX * newWA.width;
139139- const newGroupCenterY = newWA.y + groupRelY * newWA.height;
140140-141141- const results: Rectangle[] = [];
142142-143143- for (let i = 0; i < windowBounds.length; i++) {
144144- const wb = windowBounds[i];
145145- const center = centers[i];
146146-147147- const offsetX = center.x - groupCenterX;
148148- const offsetY = center.y - groupCenterY;
149149-150150- const scaledOffsetX = offsetX * scaleX;
151151- const scaledOffsetY = offsetY * scaleY;
152152-153153- let newW = Math.round(wb.width * scaleX);
154154- let newH = Math.round(wb.height * scaleY);
155155-156156- newW = Math.max(newW, 200);
157157- newH = Math.max(newH, 150);
158158-159159- newW = Math.min(newW, newWA.width);
160160- newH = Math.min(newH, newWA.height);
161161-162162- const newCenterX = newGroupCenterX + scaledOffsetX;
163163- const newCenterY = newGroupCenterY + scaledOffsetY;
164164-165165- let newX = Math.round(newCenterX - newW / 2);
166166- let newY = Math.round(newCenterY - newH / 2);
167167-168168- newX = Math.max(newWA.x, Math.min(newX, newWA.x + newWA.width - newW));
169169- newY = Math.max(newWA.y, Math.min(newY, newWA.y + newWA.height - newH));
170170-171171- results.push({ x: newX, y: newY, width: newW, height: newH });
172172- }
173173-174174- return results;
175175-}
176176-177177-/**
178178- * Compute home display info for a window being displaced from a display.
179179- * Pure version of saveWindowHomeDisplay (no BrowserWindow dependency).
180180- */
181181-function computeHomeDisplay(
182182- bounds: Rectangle,
183183- display: DisplaySnapshot
184184-): WindowHomeDisplay {
185185- const wa = display.workArea;
186186- const centerX = bounds.x + bounds.width / 2;
187187- const centerY = bounds.y + bounds.height / 2;
188188-189189- return {
190190- displayId: display.id,
191191- resolution: { width: wa.width, height: wa.height },
192192- relativePosition: {
193193- centerX: wa.width > 0 ? (centerX - wa.x) / wa.width : 0.5,
194194- centerY: wa.height > 0 ? (centerY - wa.y) / wa.height : 0.5,
195195- width: wa.width > 0 ? bounds.width / wa.width : 0.5,
196196- height: wa.height > 0 ? bounds.height / wa.height : 0.5,
197197- },
198198- };
199199-}
200200-201201-/**
202202- * Find a matching new display for a displaced window's home display.
203203- * Pure version identical to display-watcher.ts implementation.
204204- */
205205-function findMatchingNewDisplay(
206206- home: WindowHomeDisplay,
207207- addedDisplays: DisplaySnapshot[]
208208-): DisplaySnapshot | null {
209209- const byId = addedDisplays.find(d => d.id === home.displayId);
210210- if (byId) return byId;
211211-212212- const tolerance = 0.05;
213213- for (const d of addedDisplays) {
214214- const wa = d.workArea;
215215- const widthRatio = home.resolution.width > 0
216216- ? Math.abs(wa.width - home.resolution.width) / home.resolution.width
217217- : (wa.width === 0 ? 0 : 1);
218218- const heightRatio = home.resolution.height > 0
219219- ? Math.abs(wa.height - home.resolution.height) / home.resolution.height
220220- : (wa.height === 0 ? 0 : 1);
221221-222222- if (widthRatio <= tolerance && heightRatio <= tolerance) {
223223- return d;
224224- }
225225- }
226226-227227- return null;
228228-}
229229-230230-/**
231231- * Compute restored bounds for a window returning to its home display.
232232- * Pure version of restoreWindowToHomeDisplay (returns bounds instead of calling setBounds).
233233- */
234234-function computeRestoredBounds(
235235- home: WindowHomeDisplay,
236236- display: DisplaySnapshot
237237-): Rectangle {
238238- const wa = display.workArea;
239239- const rel = home.relativePosition;
240240-241241- let newW = Math.round(rel.width * wa.width);
242242- let newH = Math.round(rel.height * wa.height);
243243-244244- newW = Math.max(newW, 200);
245245- newH = Math.max(newH, 150);
246246-247247- newW = Math.min(newW, wa.width);
248248- newH = Math.min(newH, wa.height);
249249-250250- const centerX = wa.x + rel.centerX * wa.width;
251251- const centerY = wa.y + rel.centerY * wa.height;
252252-253253- let newX = Math.round(centerX - newW / 2);
254254- let newY = Math.round(centerY - newH / 2);
255255-256256- newX = Math.max(wa.x, Math.min(newX, wa.x + wa.width - newW));
257257- newY = Math.max(wa.y, Math.min(newY, wa.y + wa.height - newH));
258258-259259- return { x: newX, y: newY, width: newW, height: newH };
260260-}
261261-262109// ============================================================================
263110// Test fixtures
264111// ============================================================================
···328175 });
329176 });
330177331331- describe("isWindowAccessible", () => {
178178+ describe("isWindowAccessible (area overlap)", () => {
332179 const displays = [display1440, display1080];
333180334181 it("window fully on primary display is accessible", () => {
···347194 assert.strictEqual(isWindowAccessible(displays, { x: 500, y: 5000, width: 800, height: 600 }), false);
348195 });
349196350350- it("window partially off-screen but title bar reachable is accessible", () => {
351351- assert.strictEqual(isWindowAccessible(displays, { x: 100, y: 1200, width: 800, height: 600 }), true);
197197+ it("zero-size window is always accessible", () => {
198198+ assert.strictEqual(isWindowAccessible(displays, { x: -5000, y: -5000, width: 0, height: 0 }), true);
199199+ });
200200+201201+ it("window with zero width is accessible", () => {
202202+ assert.strictEqual(isWindowAccessible(displays, { x: 100, y: 100, width: 0, height: 600 }), true);
203203+ });
204204+205205+ it("window with exactly 30% overlap is accessible", () => {
206206+ // Window 1000x1000 = 1,000,000 area
207207+ // Need overlap of 300,000 (30%)
208208+ // Place window so only 300px width overlaps with display1440 workArea
209209+ // Window at x=-700, width=1000 -> overlaps from x=0 to x=300 (300px wide)
210210+ // y=100, height=1000 -> overlaps from y=100 to y=1100 (1000px tall, within workArea 25-1440)
211211+ // Overlap = 300 * 1000 = 300,000 = exactly 30% of 1,000,000
212212+ assert.strictEqual(isWindowAccessible(displays, { x: -700, y: 100, width: 1000, height: 1000 }), true);
213213+ });
214214+215215+ it("window with less than 30% overlap is not accessible", () => {
216216+ // Window 1000x1000 = 1,000,000 area
217217+ // Place so only 299px width overlaps
218218+ // x=-701, width=1000 -> overlaps from x=0 to x=299 (299px wide)
219219+ // y=100, height=1000 -> overlaps 1000px tall
220220+ // Overlap = 299 * 1000 = 299,000 < 30% of 1,000,000
221221+ assert.strictEqual(isWindowAccessible(displays, { x: -701, y: 100, width: 1000, height: 1000 }), false);
222222+ });
223223+224224+ it("window spanning two displays counts overlap per display", () => {
225225+ // Window at the boundary between display1440 and display1080
226226+ // If 30% is on either display, it's accessible
227227+ assert.strictEqual(isWindowAccessible(displays, { x: 2400, y: 100, width: 320, height: 600 }), true);
352228 });
353229354354- it("window above screen with title bar off-screen is not accessible", () => {
355355- assert.strictEqual(isWindowAccessible(displays, { x: 100, y: -500, width: 800, height: 600 }), false);
230230+ it("large window mostly off-screen but >30% on display is accessible", () => {
231231+ // 800x600 window with top-left at (-400, 100)
232232+ // Overlaps display1440 from x=0 to x=400 (400px), y=100 to y=700 (600px)
233233+ // Overlap = 400*600 = 240,000; window area = 480,000; ratio = 50% > 30%
234234+ assert.strictEqual(isWindowAccessible(displays, { x: -400, y: 100, width: 800, height: 600 }), true);
356235 });
357236358358- it("uses center X for accessibility check", () => {
359359- assert.strictEqual(isWindowAccessible(displays, { x: 2400, y: 100, width: 1120, height: 600 }), true);
237237+ it("window far off-screen with no overlap is not accessible", () => {
238238+ assert.strictEqual(isWindowAccessible(displays, { x: 10000, y: 10000, width: 800, height: 600 }), false);
360239 });
361240 });
362241363242 describe("findBestNewDisplay", () => {
364364- it("returns same display if ID matches", () => {
365365- const result = findBestNewDisplay(display1440, [display1440, display1080], 1);
366366- assert.strictEqual(result.id, 1);
367367- });
368368-369369- it("returns closest display when old display is removed", () => {
243243+ it("returns closest display by center distance when old display is known", () => {
370244 const result = findBestNewDisplay(display1080, [display1440], 1);
371245 assert.strictEqual(result.id, 1);
372246 });
···381255 assert.strictEqual(result.id, 2);
382256 });
383257384384- it("picks closest by center distance when multiple candidates", () => {
258258+ it("picks nearest by center distance — no ID preference", () => {
259259+ // Even though display1440 has the same ID relationship, proximity wins
385260 const farRight: DisplaySnapshot = {
386261 id: 99,
387262 bounds: { x: 5000, y: 0, width: 1920, height: 1080 },
388263 workArea: { x: 5000, y: 25, width: 1920, height: 1055 },
389264 };
390265 const result = findBestNewDisplay(farRight, [display1440, display1080], 1);
266266+ // display1080 center (~3520, 552) is closer to farRight center (~5960, 552)
267267+ // than display1440 center (~1280, 732)
391268 assert.strictEqual(result.id, 2);
392269 });
393270394394- it("throws when no displays available", () => {
395395- assert.throws(() => findBestNewDisplay(display1440, [], 1), /No displays available/);
396396- });
397397- });
398398-399399- describe("calculateGroupRepositionedBounds (single window)", () => {
400400- it("centers a centered window on the new display with scaled dimensions", () => {
401401- const winBounds = [{ x: 880, y: 420, width: 800, height: 600 }];
402402- const [result] = calculateGroupRepositionedBounds(winBounds, display1440, displayLaptop);
403403- // Width scales by 1440/2560 = 0.5625, so 800 * 0.5625 = 450
404404- assert.ok(result.width >= 400 && result.width <= 500, `width=${result.width} should scale ~450`);
405405- assert.ok(result.height >= 300 && result.height <= 420, `height=${result.height} should scale`);
406406- });
407407-408408- it("preserves relative position for a single window", () => {
409409- // Window at top-left of old display
410410- const [result] = calculateGroupRepositionedBounds(
411411- [{ x: 0, y: 25, width: 800, height: 600 }], display1440, displayLaptop
412412- );
413413- // Single window: center maps to same relative position
414414- // Old center: (400, 325), rel: (400/2560, 300/1415) = (0.156, 0.212)
415415- // New center: (0.156*1440, 25+0.212*875) = (225, 210)
416416- // Scaled size: ~450x371, so top-left: ~(0, 25)
417417- assert.ok(result.x >= 0 && result.x <= 10, `x=${result.x} should be near left`);
418418- assert.ok(result.y >= 25 && result.y <= 35, `y=${result.y} should be near top`);
419419- });
420420-421421- it("clamps bottom-right window within new workArea", () => {
422422- const [result] = calculateGroupRepositionedBounds(
423423- [{ x: 1760, y: 840, width: 800, height: 600 }], display1440, displayLaptop
424424- );
425425- const rightEdge = displayLaptop.workArea.x + displayLaptop.workArea.width;
426426- const bottomEdge = displayLaptop.workArea.y + displayLaptop.workArea.height;
427427- assert.ok(result.x + result.width <= rightEdge,
428428- `right edge ${result.x + result.width} should not exceed ${rightEdge}`);
429429- assert.ok(result.y + result.height <= bottomEdge,
430430- `bottom edge ${result.y + result.height} should not exceed ${bottomEdge}`);
431431- });
432432-433433- it("enforces minimum window size of 200x150", () => {
434434- const tinyDisplay: DisplaySnapshot = {
271271+ it("picks display directly adjacent over distant one", () => {
272272+ // Old display was at x=2560 (like display1080), two candidates: adjacent and far
273273+ const adjacent: DisplaySnapshot = {
435274 id: 10,
436436- bounds: { x: 0, y: 0, width: 400, height: 300 },
437437- workArea: { x: 0, y: 25, width: 400, height: 275 },
438438- };
439439- const [result] = calculateGroupRepositionedBounds(
440440- [{ x: 0, y: 25, width: 100, height: 80 }], display1440, tinyDisplay
441441- );
442442- assert.ok(result.width >= 200, `width ${result.width} should be >= 200`);
443443- assert.ok(result.height >= 150, `height ${result.height} should be >= 150`);
444444- });
445445-446446- it("caps window size to new workArea dimensions", () => {
447447- const [result] = calculateGroupRepositionedBounds(
448448- [{ x: 0, y: 25, width: 2560, height: 1415 }], display1440, displayLaptop
449449- );
450450- assert.ok(result.width <= displayLaptop.workArea.width);
451451- assert.ok(result.height <= displayLaptop.workArea.height);
452452- });
453453-454454- it("same display dimensions produces same bounds for centered window", () => {
455455- const winBounds = { x: 500, y: 300, width: 800, height: 600 };
456456- const [result] = calculateGroupRepositionedBounds([winBounds], display1440, display1440);
457457- assert.strictEqual(result.x, winBounds.x);
458458- assert.strictEqual(result.y, winBounds.y);
459459- assert.strictEqual(result.width, winBounds.width);
460460- assert.strictEqual(result.height, winBounds.height);
461461- });
462462-463463- it("handles secondary display offset correctly", () => {
464464- const [result] = calculateGroupRepositionedBounds(
465465- [{ x: 2660, y: 100, width: 800, height: 600 }], display1080, display1440
466466- );
467467- assert.ok(result.x >= 0 && result.x < 200, `x=${result.x} should be near left on primary`);
468468- });
469469-470470- it("proportionally scales window going from large to small display", () => {
471471- const [result] = calculateGroupRepositionedBounds(
472472- [{ x: 640, y: 25, width: 1280, height: 700 }], display1440, displayLaptop
473473- );
474474- const widthRatio = result.width / displayLaptop.workArea.width;
475475- assert.ok(widthRatio > 0.4 && widthRatio < 0.6,
476476- `width ratio ${widthRatio.toFixed(2)} should be ~0.5`);
477477- });
478478-479479- it("handles zero-size old workArea gracefully", () => {
480480- const zeroDisplay: DisplaySnapshot = {
481481- id: 99,
482482- bounds: { x: 0, y: 0, width: 0, height: 0 },
483483- workArea: { x: 0, y: 0, width: 0, height: 0 },
484484- };
485485- const [result] = calculateGroupRepositionedBounds(
486486- [{ x: 0, y: 0, width: 800, height: 600 }], zeroDisplay, displayLaptop
487487- );
488488- assert.ok(result.width > 0 && result.height > 0, "should produce valid dimensions");
489489- });
490490- });
491491-492492- describe("calculateGroupRepositionedBounds (multi-window distance scaling)", () => {
493493- it("scales distance between two windows proportionally (small to large)", () => {
494494- // Two windows 500px apart horizontally on laptop (1440x875)
495495- const wins = [
496496- { x: 200, y: 200, width: 400, height: 300 },
497497- { x: 700, y: 200, width: 400, height: 300 },
498498- ];
499499- // Old centers: (400, 350) and (900, 350), distance = 500px
500500- const results = calculateGroupRepositionedBounds(wins, displayLaptop, display1440);
501501- // Scale factor X: 2560/1440 = 1.778
502502- // Expected distance: 500 * 1.778 = ~889px
503503- const dist = Math.abs((results[1].x + results[1].width/2) - (results[0].x + results[0].width/2));
504504- assert.ok(dist > 800 && dist < 1000,
505505- `distance ${dist} should be ~889 (500 * 2560/1440)`);
506506- });
507507-508508- it("scales distance between two windows proportionally (large to small)", () => {
509509- // Two windows 1000px apart on big display (2560x1415)
510510- const wins = [
511511- { x: 400, y: 400, width: 600, height: 400 },
512512- { x: 1400, y: 400, width: 600, height: 400 },
513513- ];
514514- // Old centers: (700, 600) and (1700, 600), distance = 1000px
515515- const results = calculateGroupRepositionedBounds(wins, display1440, displayLaptop);
516516- // Scale factor X: 1440/2560 = 0.5625
517517- // Expected distance: 1000 * 0.5625 = ~563px
518518- const dist = Math.abs((results[1].x + results[1].width/2) - (results[0].x + results[0].width/2));
519519- assert.ok(dist > 480 && dist < 640,
520520- `distance ${dist} should be ~563 (1000 * 1440/2560)`);
521521- });
522522-523523- it("preserves relative arrangement of windows in a grid", () => {
524524- // 2x2 grid of windows on laptop
525525- const wins = [
526526- { x: 100, y: 50, width: 400, height: 300 }, // top-left
527527- { x: 700, y: 50, width: 400, height: 300 }, // top-right
528528- { x: 100, y: 450, width: 400, height: 300 }, // bottom-left
529529- { x: 700, y: 450, width: 400, height: 300 }, // bottom-right
530530- ];
531531- const results = calculateGroupRepositionedBounds(wins, displayLaptop, display1440);
532532- // top-left should still be left of top-right
533533- assert.ok(results[0].x < results[1].x, "top-left should be left of top-right");
534534- // top-left should still be above bottom-left
535535- assert.ok(results[0].y < results[2].y, "top-left should be above bottom-left");
536536- // top-right should still be above bottom-right
537537- assert.ok(results[1].y < results[3].y, "top-right should be above bottom-right");
538538- // bottom-left should still be left of bottom-right
539539- assert.ok(results[2].x < results[3].x, "bottom-left should be left of bottom-right");
540540- });
541541-542542- it("windows spread out more on larger display", () => {
543543- // Three windows in a row on laptop
544544- const wins = [
545545- { x: 100, y: 200, width: 300, height: 300 },
546546- { x: 500, y: 200, width: 300, height: 300 },
547547- { x: 900, y: 200, width: 300, height: 300 },
548548- ];
549549- const resultsLarge = calculateGroupRepositionedBounds(wins, displayLaptop, display1440);
550550- // Bounding width of results should be larger than original
551551- const origSpan = (900 + 300) - 100; // 1100px
552552- const newSpan = (resultsLarge[2].x + resultsLarge[2].width) - resultsLarge[0].x;
553553- assert.ok(newSpan > origSpan,
554554- `new span ${newSpan} should be larger than original ${origSpan}`);
555555- });
556556-557557- it("windows cluster closer on smaller display", () => {
558558- // Three windows in a row on big display
559559- const wins = [
560560- { x: 200, y: 400, width: 500, height: 400 },
561561- { x: 900, y: 400, width: 500, height: 400 },
562562- { x: 1600, y: 400, width: 500, height: 400 },
563563- ];
564564- const resultsSmall = calculateGroupRepositionedBounds(wins, display1440, displayLaptop);
565565- // Bounding width should be smaller
566566- const origSpan = (1600 + 500) - 200; // 1900px
567567- const newSpan = (resultsSmall[2].x + resultsSmall[2].width) - resultsSmall[0].x;
568568- assert.ok(newSpan < origSpan,
569569- `new span ${newSpan} should be smaller than original ${origSpan}`);
570570- });
571571-572572- it("all windows clamped to stay within new workArea", () => {
573573- // Windows spread across the full width of big display
574574- const wins = [
575575- { x: 0, y: 25, width: 600, height: 400 },
576576- { x: 1960, y: 25, width: 600, height: 400 },
577577- ];
578578- const results = calculateGroupRepositionedBounds(wins, display1440, displayLaptop);
579579- const wa = displayLaptop.workArea;
580580- for (const r of results) {
581581- assert.ok(r.x >= wa.x, `x=${r.x} should be >= ${wa.x}`);
582582- assert.ok(r.y >= wa.y, `y=${r.y} should be >= ${wa.y}`);
583583- assert.ok(r.x + r.width <= wa.x + wa.width,
584584- `right edge ${r.x + r.width} should be <= ${wa.x + wa.width}`);
585585- assert.ok(r.y + r.height <= wa.y + wa.height,
586586- `bottom edge ${r.y + r.height} should be <= ${wa.y + wa.height}`);
587587- }
588588- });
589589-590590- it("single window in group behaves same as single-element array", () => {
591591- const win = { x: 500, y: 300, width: 800, height: 600 };
592592- const [result] = calculateGroupRepositionedBounds([win], display1440, displayLaptop);
593593- // Should be deterministic
594594- const [result2] = calculateGroupRepositionedBounds([win], display1440, displayLaptop);
595595- assert.strictEqual(result.x, result2.x);
596596- assert.strictEqual(result.y, result2.y);
597597- assert.strictEqual(result.width, result2.width);
598598- assert.strictEqual(result.height, result2.height);
599599- });
600600- });
601601-602602- describe("computeHomeDisplay", () => {
603603- it("computes relative position for centered window", () => {
604604- // Window centered on 2560x1415 workArea (with 25px menu bar)
605605- const bounds = { x: 880, y: 420, width: 800, height: 600 };
606606- const home = computeHomeDisplay(bounds, display1440);
607607-608608- assert.strictEqual(home.displayId, 1);
609609- assert.strictEqual(home.resolution.width, 2560);
610610- assert.strictEqual(home.resolution.height, 1415);
611611-612612- // Center of window: (1280, 720), relative to workArea (0, 25, 2560, 1415)
613613- // relX = 1280 / 2560 = 0.5, relY = (720 - 25) / 1415 ~ 0.491
614614- assert.ok(Math.abs(home.relativePosition.centerX - 0.5) < 0.01,
615615- `centerX ${home.relativePosition.centerX} should be ~0.5`);
616616- assert.ok(Math.abs(home.relativePosition.centerY - 0.491) < 0.01,
617617- `centerY ${home.relativePosition.centerY} should be ~0.491`);
618618-619619- // Width ratio: 800 / 2560 = 0.3125
620620- assert.ok(Math.abs(home.relativePosition.width - 0.3125) < 0.001,
621621- `width ratio ${home.relativePosition.width} should be ~0.3125`);
622622- });
623623-624624- it("computes relative position for window on secondary display", () => {
625625- // Window at (2660, 100) on display starting at x=2560
626626- const bounds = { x: 2660, y: 100, width: 800, height: 600 };
627627- const home = computeHomeDisplay(bounds, display1080);
628628-629629- assert.strictEqual(home.displayId, 2);
630630- // Center: (3060, 400), relative to workArea (2560, 25, 1920, 1055)
631631- // relX = (3060 - 2560) / 1920 = 500/1920 ~ 0.260
632632- assert.ok(Math.abs(home.relativePosition.centerX - 0.260) < 0.01,
633633- `centerX ${home.relativePosition.centerX} should be ~0.260`);
634634- });
635635-636636- it("handles zero-size workArea gracefully", () => {
637637- const zeroDisplay: DisplaySnapshot = {
638638- id: 99,
639639- bounds: { x: 0, y: 0, width: 0, height: 0 },
640640- workArea: { x: 0, y: 0, width: 0, height: 0 },
641641- };
642642- const home = computeHomeDisplay({ x: 0, y: 0, width: 800, height: 600 }, zeroDisplay);
643643- assert.strictEqual(home.relativePosition.centerX, 0.5);
644644- assert.strictEqual(home.relativePosition.centerY, 0.5);
645645- });
646646- });
647647-648648- describe("findMatchingNewDisplay", () => {
649649- it("matches by display ID first", () => {
650650- const home: WindowHomeDisplay = {
651651- displayId: 2,
652652- resolution: { width: 1920, height: 1055 },
653653- relativePosition: { centerX: 0.5, centerY: 0.5, width: 0.4, height: 0.5 },
654654- };
655655- const result = findMatchingNewDisplay(home, [display1440, display1080]);
656656- assert.strictEqual(result?.id, 2);
657657- });
658658-659659- it("falls back to resolution match within 5% tolerance", () => {
660660- // Home display was 1920x1055, new display is slightly different but within 5%
661661- const home: WindowHomeDisplay = {
662662- displayId: 99, // Different ID
663663- resolution: { width: 1920, height: 1055 },
664664- relativePosition: { centerX: 0.5, centerY: 0.5, width: 0.4, height: 0.5 },
665665- };
666666- // display1080 has workArea 1920x1055 - exact match
667667- const result = findMatchingNewDisplay(home, [display1440, display1080]);
668668- assert.strictEqual(result?.id, 2);
669669- });
670670-671671- it("matches display with resolution within 5% tolerance", () => {
672672- const home: WindowHomeDisplay = {
673673- displayId: 99,
674674- resolution: { width: 1920, height: 1055 },
675675- relativePosition: { centerX: 0.5, centerY: 0.5, width: 0.4, height: 0.5 },
676676- };
677677- // Create a display with ~4% different resolution
678678- const similar: DisplaySnapshot = {
679679- id: 50,
680680- bounds: { x: 0, y: 0, width: 2000, height: 1100 },
681681- workArea: { x: 0, y: 25, width: 1996, height: 1095 }, // ~4% wider, ~3.8% taller
682682- };
683683- const result = findMatchingNewDisplay(home, [similar]);
684684- assert.strictEqual(result?.id, 50);
685685- });
686686-687687- it("rejects display with resolution beyond 5% tolerance", () => {
688688- const home: WindowHomeDisplay = {
689689- displayId: 99,
690690- resolution: { width: 1920, height: 1055 },
691691- relativePosition: { centerX: 0.5, centerY: 0.5, width: 0.4, height: 0.5 },
692692- };
693693- // display1440 has workArea 2560x1415 - way too different
694694- const result = findMatchingNewDisplay(home, [display1440]);
695695- assert.strictEqual(result, null);
696696- });
697697-698698- it("returns null when no displays match", () => {
699699- const home: WindowHomeDisplay = {
700700- displayId: 99,
701701- resolution: { width: 3840, height: 2135 },
702702- relativePosition: { centerX: 0.5, centerY: 0.5, width: 0.4, height: 0.5 },
703703- };
704704- const result = findMatchingNewDisplay(home, [display1440, display1080, displayLaptop]);
705705- assert.strictEqual(result, null);
706706- });
707707-708708- it("returns null for empty added displays list", () => {
709709- const home: WindowHomeDisplay = {
710710- displayId: 2,
711711- resolution: { width: 1920, height: 1055 },
712712- relativePosition: { centerX: 0.5, centerY: 0.5, width: 0.4, height: 0.5 },
713713- };
714714- const result = findMatchingNewDisplay(home, []);
715715- assert.strictEqual(result, null);
716716- });
717717-718718- it("prefers ID match over resolution match", () => {
719719- const home: WindowHomeDisplay = {
720720- displayId: 2,
721721- resolution: { width: 1920, height: 1055 },
722722- relativePosition: { centerX: 0.5, centerY: 0.5, width: 0.4, height: 0.5 },
723723- };
724724- // Both display1080 (id=2, exact resolution) and a similar-resolution display
725725- const similar: DisplaySnapshot = {
726726- id: 50,
727727- bounds: { x: 0, y: 0, width: 1920, height: 1080 },
728728- workArea: { x: 0, y: 25, width: 1920, height: 1055 },
729729- };
730730- const result = findMatchingNewDisplay(home, [similar, display1080]);
731731- assert.strictEqual(result?.id, 2); // ID match wins
732732- });
733733- });
734734-735735- describe("computeRestoredBounds", () => {
736736- it("restores window to same position on same-sized display", () => {
737737- const bounds = { x: 880, y: 420, width: 800, height: 600 };
738738- const home = computeHomeDisplay(bounds, display1440);
739739- const restored = computeRestoredBounds(home, display1440);
740740-741741- assert.strictEqual(restored.x, bounds.x);
742742- assert.strictEqual(restored.y, bounds.y);
743743- assert.strictEqual(restored.width, bounds.width);
744744- assert.strictEqual(restored.height, bounds.height);
745745- });
746746-747747- it("restores to correct relative position on different display location", () => {
748748- // Window centered on external display at (2560, 25, 1920, 1055)
749749- const bounds = { x: 3120, y: 252, width: 800, height: 600 };
750750- const home = computeHomeDisplay(bounds, display1080);
751751-752752- // Reconnect at same resolution but different position
753753- const reconnected: DisplaySnapshot = {
754754- id: 2,
755755- bounds: { x: 0, y: 0, width: 1920, height: 1080 },
756756- workArea: { x: 0, y: 25, width: 1920, height: 1055 },
757757- };
758758- const restored = computeRestoredBounds(home, reconnected);
759759-760760- // Should be at the same relative position but shifted by workArea offset
761761- // Original center relative: ((3520-2560)/1920, (552-25)/1055) = (0.5, 0.5)
762762- // On reconnected: center = (960, 552.5), so x = 960-400=560, y = 553-300=253
763763- assert.ok(Math.abs(restored.x - 560) <= 1,
764764- `x=${restored.x} should be ~560`);
765765- assert.ok(Math.abs(restored.y - 252) <= 1,
766766- `y=${restored.y} should be ~252`);
767767- assert.strictEqual(restored.width, 800);
768768- assert.strictEqual(restored.height, 600);
769769- });
770770-771771- it("scales window size proportionally for different resolution", () => {
772772- // Window on big display
773773- const bounds = { x: 880, y: 420, width: 800, height: 600 };
774774- const home = computeHomeDisplay(bounds, display1440);
775775-776776- // Restore to smaller display
777777- const restored = computeRestoredBounds(home, displayLaptop);
778778-779779- // Width ratio was 800/2560 = 0.3125, on laptop: 0.3125 * 1440 = 450
780780- assert.strictEqual(restored.width, 450);
781781- // Height ratio was 600/1415 ~ 0.424, on laptop: 0.424 * 875 = 371
782782- assert.ok(Math.abs(restored.height - 371) <= 1,
783783- `height=${restored.height} should be ~371`);
784784- });
785785-786786- it("clamps restored window within new workArea", () => {
787787- // Window at far bottom-right corner
788788- const bounds = { x: 2200, y: 1200, width: 400, height: 300 };
789789- const home = computeHomeDisplay(bounds, display1440);
790790-791791- const restored = computeRestoredBounds(home, displayLaptop);
792792- const wa = displayLaptop.workArea;
793793-794794- assert.ok(restored.x >= wa.x, `x=${restored.x} should be >= ${wa.x}`);
795795- assert.ok(restored.y >= wa.y, `y=${restored.y} should be >= ${wa.y}`);
796796- assert.ok(restored.x + restored.width <= wa.x + wa.width,
797797- `right edge ${restored.x + restored.width} should be <= ${wa.x + wa.width}`);
798798- assert.ok(restored.y + restored.height <= wa.y + wa.height,
799799- `bottom edge ${restored.y + restored.height} should be <= ${wa.y + wa.height}`);
800800- });
801801-802802- it("enforces minimum size of 200x150", () => {
803803- // Very small relative window
804804- const home: WindowHomeDisplay = {
805805- displayId: 1,
806806- resolution: { width: 2560, height: 1415 },
807807- relativePosition: { centerX: 0.5, centerY: 0.5, width: 0.01, height: 0.01 },
808808- };
809809- const restored = computeRestoredBounds(home, displayLaptop);
810810- assert.ok(restored.width >= 200, `width=${restored.width} should be >= 200`);
811811- assert.ok(restored.height >= 150, `height=${restored.height} should be >= 150`);
812812- });
813813-814814- it("round-trips through save and restore on same display", () => {
815815- // Test various window positions
816816- const positions = [
817817- { x: 0, y: 25, width: 800, height: 600 }, // top-left
818818- { x: 1760, y: 840, width: 800, height: 600 }, // bottom-right
819819- { x: 880, y: 420, width: 800, height: 600 }, // centered
820820- { x: 200, y: 25, width: 1200, height: 1000 }, // large
821821- ];
822822-823823- for (const bounds of positions) {
824824- const home = computeHomeDisplay(bounds, display1440);
825825- const restored = computeRestoredBounds(home, display1440);
826826-827827- assert.ok(Math.abs(restored.x - bounds.x) <= 1,
828828- `x: ${restored.x} should be ~${bounds.x}`);
829829- assert.ok(Math.abs(restored.y - bounds.y) <= 1,
830830- `y: ${restored.y} should be ~${bounds.y}`);
831831- assert.strictEqual(restored.width, bounds.width);
832832- assert.strictEqual(restored.height, bounds.height);
833833- }
834834- });
835835- });
836836-837837- describe("home display tracking (integration)", () => {
838838- it("simulates full unplug/replug cycle: save, displace, restore", () => {
839839- // Step 1: Window on external display (display1080)
840840- const originalBounds = { x: 3120, y: 252, width: 800, height: 600 };
841841-842842- // Step 2: External display removed - save home display info
843843- const home = computeHomeDisplay(originalBounds, display1080);
844844- assert.strictEqual(home.displayId, 2);
845845-846846- // Step 3: External display re-added (same ID)
847847- const matchingDisplay = findMatchingNewDisplay(home, [display1080]);
848848- assert.notStrictEqual(matchingDisplay, null);
849849- assert.strictEqual(matchingDisplay!.id, 2);
850850-851851- // Step 4: Restore window
852852- const restored = computeRestoredBounds(home, matchingDisplay!);
853853- assert.strictEqual(restored.x, originalBounds.x);
854854- assert.strictEqual(restored.y, originalBounds.y);
855855- assert.strictEqual(restored.width, originalBounds.width);
856856- assert.strictEqual(restored.height, originalBounds.height);
857857- });
858858-859859- it("restores to different-ID display with matching resolution", () => {
860860- // Window on external display
861861- const originalBounds = { x: 3120, y: 252, width: 800, height: 600 };
862862- const home = computeHomeDisplay(originalBounds, display1080);
863863-864864- // Display comes back with different ID but same resolution
865865- const newExternal: DisplaySnapshot = {
866866- id: 42,
867275 bounds: { x: 2560, y: 0, width: 1920, height: 1080 },
868276 workArea: { x: 2560, y: 25, width: 1920, height: 1055 },
869277 };
870870-871871- const match = findMatchingNewDisplay(home, [newExternal]);
872872- assert.notStrictEqual(match, null);
873873- assert.strictEqual(match!.id, 42);
874874-875875- const restored = computeRestoredBounds(home, match!);
876876- // Same relative position, same resolution = same absolute position
877877- assert.strictEqual(restored.x, originalBounds.x);
878878- assert.strictEqual(restored.y, originalBounds.y);
879879- });
880880-881881- it("does not restore when no matching display is found", () => {
882882- const originalBounds = { x: 3120, y: 252, width: 800, height: 600 };
883883- const home = computeHomeDisplay(originalBounds, display1080);
884884-885885- // Only a very different display is added
886886- const different: DisplaySnapshot = {
887887- id: 50,
888888- bounds: { x: 0, y: 0, width: 3840, height: 2160 },
889889- workArea: { x: 0, y: 25, width: 3840, height: 2135 },
278278+ const far: DisplaySnapshot = {
279279+ id: 20,
280280+ bounds: { x: 8000, y: 0, width: 1920, height: 1080 },
281281+ workArea: { x: 8000, y: 25, width: 1920, height: 1055 },
890282 };
891891-892892- const match = findMatchingNewDisplay(home, [different]);
893893- assert.strictEqual(match, null);
283283+ const result = findBestNewDisplay(display1080, [far, adjacent], 10);
284284+ assert.strictEqual(result.id, 10);
894285 });
895286896896- it("handles multiple windows on different removed displays", () => {
897897- // Window 1 on display1440, Window 2 on display1080
898898- const bounds1 = { x: 500, y: 300, width: 800, height: 600 };
899899- const bounds2 = { x: 3000, y: 200, width: 600, height: 400 };
900900-901901- const home1 = computeHomeDisplay(bounds1, display1440);
902902- const home2 = computeHomeDisplay(bounds2, display1080);
903903-904904- assert.strictEqual(home1.displayId, 1);
905905- assert.strictEqual(home2.displayId, 2);
906906-907907- // Both displays come back
908908- const match1 = findMatchingNewDisplay(home1, [display1440, display1080]);
909909- const match2 = findMatchingNewDisplay(home2, [display1440, display1080]);
910910-911911- assert.strictEqual(match1!.id, 1);
912912- assert.strictEqual(match2!.id, 2);
913913-914914- const restored1 = computeRestoredBounds(home1, match1!);
915915- const restored2 = computeRestoredBounds(home2, match2!);
916916-917917- assert.strictEqual(restored1.x, bounds1.x);
918918- assert.strictEqual(restored1.y, bounds1.y);
919919- assert.strictEqual(restored2.x, bounds2.x);
920920- assert.strictEqual(restored2.y, bounds2.y);
287287+ it("throws when no displays available", () => {
288288+ assert.throws(() => findBestNewDisplay(display1440, [], 1), /No displays available/);
921289 });
922290 });
923291924924- // ==========================================================================
925925- // New tests for pre-debounce capture, suppress timer cancellation, isRemoval
926926- // ==========================================================================
927927-928928- describe("captureWindowBounds", () => {
929929- // Pure version of captureWindowBounds that accepts mock windows
930930- interface MockWindow {
292292+ describe("safety net: no-op when all windows accessible", () => {
293293+ // Simulates the handleDisplayChange logic as a pure function
294294+ interface MockWindowEntry {
931295 id: number;
932932- destroyed: boolean;
933933- visible: boolean;
934296 bounds: Rectangle;
297297+ isBackground: boolean;
935298 }
936299937937- function captureWindowBounds(windows: MockWindow[]): Map<number, Rectangle> {
938938- const result = new Map<number, Rectangle>();
300300+ function simulateHandleDisplayChange(
301301+ windows: MockWindowEntry[],
302302+ oldDisplays: DisplaySnapshot[],
303303+ newDisplays: DisplaySnapshot[],
304304+ primaryDisplayId: number
305305+ ): { rescued: number; windowBounds: Map<number, Rectangle> } {
306306+ let rescued = 0;
307307+ const windowBounds = new Map<number, Rectangle>();
308308+939309 for (const win of windows) {
940940- if (win.destroyed || !win.visible) continue;
941941- result.set(win.id, win.bounds);
942942- }
943943- return result;
944944- }
310310+ if (win.isBackground) continue;
945311946946- it("captures bounds of all visible, non-destroyed windows", () => {
947947- const windows: MockWindow[] = [
948948- { id: 1, destroyed: false, visible: true, bounds: { x: 100, y: 200, width: 800, height: 600 } },
949949- { id: 2, destroyed: false, visible: true, bounds: { x: 300, y: 400, width: 600, height: 400 } },
950950- ];
951951- const result = captureWindowBounds(windows);
952952- assert.strictEqual(result.size, 2);
953953- assert.deepStrictEqual(result.get(1), { x: 100, y: 200, width: 800, height: 600 });
954954- assert.deepStrictEqual(result.get(2), { x: 300, y: 400, width: 600, height: 400 });
955955- });
312312+ if (isWindowAccessible(newDisplays, win.bounds)) {
313313+ windowBounds.set(win.id, win.bounds);
314314+ continue;
315315+ }
956316957957- it("skips destroyed windows", () => {
958958- const windows: MockWindow[] = [
959959- { id: 1, destroyed: true, visible: true, bounds: { x: 100, y: 200, width: 800, height: 600 } },
960960- { id: 2, destroyed: false, visible: true, bounds: { x: 300, y: 400, width: 600, height: 400 } },
961961- ];
962962- const result = captureWindowBounds(windows);
963963- assert.strictEqual(result.size, 1);
964964- assert.strictEqual(result.has(1), false);
965965- assert.strictEqual(result.has(2), true);
966966- });
317317+ // Rescue: find old display, then nearest new display, center window
318318+ const centerX = win.bounds.x + Math.round(win.bounds.width / 2);
319319+ const centerY = win.bounds.y + Math.round(win.bounds.height / 2);
320320+ const oldDisplay = findDisplayForPoint(oldDisplays, centerX, centerY);
321321+ const newDisplay = findBestNewDisplay(oldDisplay, newDisplays, primaryDisplayId);
322322+ const wa = newDisplay.workArea;
967323968968- it("skips non-visible windows", () => {
969969- const windows: MockWindow[] = [
970970- { id: 1, destroyed: false, visible: false, bounds: { x: 100, y: 200, width: 800, height: 600 } },
971971- { id: 2, destroyed: false, visible: true, bounds: { x: 300, y: 400, width: 600, height: 400 } },
972972- ];
973973- const result = captureWindowBounds(windows);
974974- assert.strictEqual(result.size, 1);
975975- assert.strictEqual(result.has(1), false);
976976- assert.strictEqual(result.has(2), true);
977977- });
324324+ // Preserve original size, only reposition
325325+ const x = wa.x + Math.round((wa.width - win.bounds.width) / 2);
326326+ const y = wa.y + Math.round((wa.height - win.bounds.height) / 2);
978327979979- it("skips windows that are both destroyed and non-visible", () => {
980980- const windows: MockWindow[] = [
981981- { id: 1, destroyed: true, visible: false, bounds: { x: 100, y: 200, width: 800, height: 600 } },
982982- ];
983983- const result = captureWindowBounds(windows);
984984- assert.strictEqual(result.size, 0);
985985- });
328328+ windowBounds.set(win.id, { x, y, width: win.bounds.width, height: win.bounds.height });
329329+ rescued++;
330330+ }
986331987987- it("returns empty map when no windows exist", () => {
988988- const result = captureWindowBounds([]);
989989- assert.strictEqual(result.size, 0);
990990- });
332332+ return { rescued, windowBounds };
333333+ }
991334992992- it("returns empty map when all windows are destroyed or hidden", () => {
993993- const windows: MockWindow[] = [
994994- { id: 1, destroyed: true, visible: true, bounds: { x: 0, y: 0, width: 800, height: 600 } },
995995- { id: 2, destroyed: false, visible: false, bounds: { x: 0, y: 0, width: 800, height: 600 } },
996996- { id: 3, destroyed: true, visible: false, bounds: { x: 0, y: 0, width: 800, height: 600 } },
335335+ it("does nothing when all windows are on valid displays", () => {
336336+ const windows: MockWindowEntry[] = [
337337+ { id: 1, bounds: { x: 100, y: 100, width: 800, height: 600 }, isBackground: false },
338338+ { id: 2, bounds: { x: 3000, y: 100, width: 800, height: 600 }, isBackground: false },
997339 ];
998998- const result = captureWindowBounds(windows);
999999- assert.strictEqual(result.size, 0);
340340+ const result = simulateHandleDisplayChange(
341341+ windows,
342342+ [display1440, display1080],
343343+ [display1440, display1080],
344344+ 1
345345+ );
346346+ assert.strictEqual(result.rescued, 0);
347347+ // Windows should keep their original bounds
348348+ assert.deepStrictEqual(result.windowBounds.get(1), { x: 100, y: 100, width: 800, height: 600 });
349349+ assert.deepStrictEqual(result.windowBounds.get(2), { x: 3000, y: 100, width: 800, height: 600 });
1000350 });
10011001- });
100235110031003- describe("suppressDisplayRepositioning timer cancellation", () => {
10041004- // Model the state machine of suppress toggling and timer/bounds management.
10051005- // This mirrors the logic in suppressDisplayRepositioning() from display-watcher.ts.
10061006- interface WatcherState {
10071007- suppressRepositioning: boolean;
10081008- debounceTimer: ReturnType<typeof setTimeout> | null;
10091009- preDebounceWindowBounds: Map<number, Rectangle> | null;
10101010- }
10111011-10121012- function suppressDisplayRepositioning(state: WatcherState, suppress: boolean): void {
10131013- state.suppressRepositioning = suppress;
10141014- if (suppress) {
10151015- if (state.debounceTimer) {
10161016- clearTimeout(state.debounceTimer);
10171017- state.debounceTimer = null;
10181018- }
10191019- state.preDebounceWindowBounds = null;
10201020- } else {
10211021- if (state.debounceTimer) {
10221022- clearTimeout(state.debounceTimer);
10231023- state.debounceTimer = null;
10241024- }
10251025- state.preDebounceWindowBounds = null;
10261026- }
10271027- }
10281028-10291029- it("cancels pending debounce timer when suppression is enabled", () => {
10301030- const state: WatcherState = {
10311031- suppressRepositioning: false,
10321032- debounceTimer: setTimeout(() => {}, 100000),
10331033- preDebounceWindowBounds: new Map([[1, { x: 0, y: 0, width: 800, height: 600 }]]),
10341034- };
10351035- suppressDisplayRepositioning(state, true);
10361036- assert.strictEqual(state.debounceTimer, null);
10371037- assert.strictEqual(state.preDebounceWindowBounds, null);
10381038- assert.strictEqual(state.suppressRepositioning, true);
10391039- });
10401040-10411041- it("cancels pending debounce timer when suppression is disabled", () => {
10421042- const state: WatcherState = {
10431043- suppressRepositioning: true,
10441044- debounceTimer: setTimeout(() => {}, 100000),
10451045- preDebounceWindowBounds: new Map([[2, { x: 100, y: 100, width: 400, height: 300 }]]),
10461046- };
10471047- suppressDisplayRepositioning(state, false);
10481048- assert.strictEqual(state.debounceTimer, null);
10491049- assert.strictEqual(state.preDebounceWindowBounds, null);
10501050- assert.strictEqual(state.suppressRepositioning, false);
10511051- });
10521052-10531053- it("clears pre-debounce bounds when enabling suppression even without timer", () => {
10541054- const state: WatcherState = {
10551055- suppressRepositioning: false,
10561056- debounceTimer: null,
10571057- preDebounceWindowBounds: new Map([[1, { x: 0, y: 0, width: 800, height: 600 }]]),
10581058- };
10591059- suppressDisplayRepositioning(state, true);
10601060- assert.strictEqual(state.preDebounceWindowBounds, null);
10611061- assert.strictEqual(state.debounceTimer, null);
10621062- });
10631063-10641064- it("clears pre-debounce bounds when disabling suppression even without timer", () => {
10651065- const state: WatcherState = {
10661066- suppressRepositioning: true,
10671067- debounceTimer: null,
10681068- preDebounceWindowBounds: new Map([[3, { x: 200, y: 200, width: 600, height: 400 }]]),
10691069- };
10701070- suppressDisplayRepositioning(state, false);
10711071- assert.strictEqual(state.preDebounceWindowBounds, null);
10721072- assert.strictEqual(state.debounceTimer, null);
10731073- });
10741074-10751075- it("is safe to call with no timer and no bounds", () => {
10761076- const state: WatcherState = {
10771077- suppressRepositioning: false,
10781078- debounceTimer: null,
10791079- preDebounceWindowBounds: null,
10801080- };
10811081- suppressDisplayRepositioning(state, true);
10821082- assert.strictEqual(state.suppressRepositioning, true);
10831083- assert.strictEqual(state.debounceTimer, null);
10841084- assert.strictEqual(state.preDebounceWindowBounds, null);
10851085- });
10861086-10871087- it("prevents stale debounced handler from firing after suppress toggle", () => {
10881088- // Simulate: display event fires, starts debounce timer, then suppress(true)
10891089- // is called before timer fires. The timer should be cancelled.
10901090- let handlerFired = false;
10911091- const state: WatcherState = {
10921092- suppressRepositioning: false,
10931093- debounceTimer: setTimeout(() => { handlerFired = true; }, 50),
10941094- preDebounceWindowBounds: new Map([[1, { x: 0, y: 0, width: 800, height: 600 }]]),
10951095- };
10961096-10971097- suppressDisplayRepositioning(state, true);
10981098-10991099- // Verify timer was cleared (handler should not fire)
11001100- assert.strictEqual(state.debounceTimer, null);
11011101- // Wait longer than the timer would have fired to confirm it was cancelled
11021102- return new Promise<void>((resolve) => {
11031103- setTimeout(() => {
11041104- assert.strictEqual(handlerFired, false, "debounced handler should NOT have fired after suppression");
11051105- resolve();
11061106- }, 100);
11071107- });
352352+ it("skips background windows", () => {
353353+ const windows: MockWindowEntry[] = [
354354+ { id: 1, bounds: { x: -5000, y: -5000, width: 800, height: 600 }, isBackground: true },
355355+ ];
356356+ const result = simulateHandleDisplayChange(
357357+ windows,
358358+ [display1440],
359359+ [display1440],
360360+ 1
361361+ );
362362+ assert.strictEqual(result.rescued, 0);
363363+ assert.strictEqual(result.windowBounds.size, 0);
1108364 });
1109365 });
111036611111111- describe("onDisplayChange isRemoval flag and pre-debounce capture", () => {
11121112- // Model the onDisplayChange logic as a pure state machine.
11131113- // This mirrors the debounce + pre-debounce capture logic from display-watcher.ts.
11141114- interface MockWindow {
367367+ describe("safety net: rescue orphaned windows", () => {
368368+ interface MockWindowEntry {
1115369 id: number;
11161116- destroyed: boolean;
11171117- visible: boolean;
1118370 bounds: Rectangle;
371371+ isBackground: boolean;
1119372 }
112037311211121- interface DebounceState {
11221122- debounceTimer: ReturnType<typeof setTimeout> | null;
11231123- preDebounceWindowBounds: Map<number, Rectangle> | null;
11241124- handleDisplayChangeCalled: boolean;
11251125- }
374374+ function simulateHandleDisplayChange(
375375+ windows: MockWindowEntry[],
376376+ oldDisplays: DisplaySnapshot[],
377377+ newDisplays: DisplaySnapshot[],
378378+ primaryDisplayId: number
379379+ ): { rescued: number; windowBounds: Map<number, Rectangle> } {
380380+ let rescued = 0;
381381+ const windowBounds = new Map<number, Rectangle>();
112638211271127- function captureWindowBounds(windows: MockWindow[]): Map<number, Rectangle> {
11281128- const result = new Map<number, Rectangle>();
1129383 for (const win of windows) {
11301130- if (win.destroyed || !win.visible) continue;
11311131- result.set(win.id, win.bounds);
11321132- }
11331133- return result;
11341134- }
384384+ if (win.isBackground) continue;
385385+386386+ if (isWindowAccessible(newDisplays, win.bounds)) {
387387+ windowBounds.set(win.id, win.bounds);
388388+ continue;
389389+ }
390390+391391+ const centerX = win.bounds.x + Math.round(win.bounds.width / 2);
392392+ const centerY = win.bounds.y + Math.round(win.bounds.height / 2);
393393+ const oldDisplay = findDisplayForPoint(oldDisplays, centerX, centerY);
394394+ const newDisplay = findBestNewDisplay(oldDisplay, newDisplays, primaryDisplayId);
395395+ const wa = newDisplay.workArea;
396396+397397+ // Preserve original size, only reposition
398398+ const x = wa.x + Math.round((wa.width - win.bounds.width) / 2);
399399+ const y = wa.y + Math.round((wa.height - win.bounds.height) / 2);
113540011361136- function onDisplayChange(
11371137- state: DebounceState,
11381138- windows: MockWindow[],
11391139- isRemoval: boolean,
11401140- debounceMs: number
11411141- ): void {
11421142- // On the FIRST event in a debounce window, capture bounds eagerly if removal
11431143- if (!state.debounceTimer && isRemoval) {
11441144- state.preDebounceWindowBounds = captureWindowBounds(windows);
401401+ windowBounds.set(win.id, { x, y, width: win.bounds.width, height: win.bounds.height });
402402+ rescued++;
1145403 }
114640411471147- if (state.debounceTimer) {
11481148- clearTimeout(state.debounceTimer);
11491149- }
11501150- state.debounceTimer = setTimeout(() => {
11511151- state.debounceTimer = null;
11521152- state.handleDisplayChangeCalled = true;
11531153- state.preDebounceWindowBounds = null;
11541154- }, debounceMs);
405405+ return { rescued, windowBounds };
1155406 }
115640711571157- it("captures window bounds on first display-removed event (no existing timer)", () => {
11581158- const windows: MockWindow[] = [
11591159- { id: 1, destroyed: false, visible: true, bounds: { x: 100, y: 200, width: 800, height: 600 } },
11601160- { id: 2, destroyed: false, visible: true, bounds: { x: 2700, y: 100, width: 600, height: 400 } },
408408+ it("rescues window that was on removed display", () => {
409409+ // Window was on display1080 (secondary), which is now gone
410410+ // macOS may have moved it to some off-screen position
411411+ const windows: MockWindowEntry[] = [
412412+ { id: 1, bounds: { x: 5000, y: 5000, width: 800, height: 600 }, isBackground: false },
1161413 ];
11621162- const state: DebounceState = {
11631163- debounceTimer: null,
11641164- preDebounceWindowBounds: null,
11651165- handleDisplayChangeCalled: false,
11661166- };
11671167-11681168- onDisplayChange(state, windows, true, 500);
11691169-11701170- assert.notStrictEqual(state.preDebounceWindowBounds, null);
11711171- assert.strictEqual(state.preDebounceWindowBounds!.size, 2);
11721172- assert.deepStrictEqual(state.preDebounceWindowBounds!.get(1), { x: 100, y: 200, width: 800, height: 600 });
11731173- assert.deepStrictEqual(state.preDebounceWindowBounds!.get(2), { x: 2700, y: 100, width: 600, height: 400 });
11741174-11751175- // Cleanup
11761176- if (state.debounceTimer) clearTimeout(state.debounceTimer);
414414+ const result = simulateHandleDisplayChange(
415415+ windows,
416416+ [display1440, display1080],
417417+ [display1440], // display1080 removed
418418+ 1
419419+ );
420420+ assert.strictEqual(result.rescued, 1);
421421+ const newBounds = result.windowBounds.get(1)!;
422422+ // Should be centered on display1440's workArea
423423+ const wa = display1440.workArea;
424424+ assert.ok(newBounds.x >= wa.x && newBounds.x + newBounds.width <= wa.x + wa.width,
425425+ "window should be within display1440 workArea horizontally");
426426+ assert.ok(newBounds.y >= wa.y && newBounds.y + newBounds.height <= wa.y + wa.height,
427427+ "window should be within display1440 workArea vertically");
1177428 });
117842911791179- it("does NOT re-capture bounds on subsequent events during debounce window", () => {
11801180- const originalWindows: MockWindow[] = [
11811181- { id: 1, destroyed: false, visible: true, bounds: { x: 100, y: 200, width: 800, height: 600 } },
430430+ it("preserves window size when rescuing (if it fits)", () => {
431431+ const windows: MockWindowEntry[] = [
432432+ { id: 1, bounds: { x: -5000, y: -5000, width: 800, height: 600 }, isBackground: false },
1182433 ];
11831183- const state: DebounceState = {
11841184- debounceTimer: null,
11851185- preDebounceWindowBounds: null,
11861186- handleDisplayChangeCalled: false,
11871187- };
434434+ const result = simulateHandleDisplayChange(
435435+ windows,
436436+ [display1440],
437437+ [display1440],
438438+ 1
439439+ );
440440+ assert.strictEqual(result.rescued, 1);
441441+ const newBounds = result.windowBounds.get(1)!;
442442+ assert.strictEqual(newBounds.width, 800);
443443+ assert.strictEqual(newBounds.height, 600);
444444+ });
118844511891189- // First event captures bounds
11901190- onDisplayChange(state, originalWindows, true, 500);
11911191- const capturedBounds = state.preDebounceWindowBounds;
11921192- assert.notStrictEqual(capturedBounds, null);
11931193-11941194- // macOS relocates window (simulated by different bounds)
11951195- const relocatedWindows: MockWindow[] = [
11961196- { id: 1, destroyed: false, visible: true, bounds: { x: 500, y: 300, width: 800, height: 600 } },
446446+ it("preserves window size when rescuing oversized window to smaller display", () => {
447447+ const windows: MockWindowEntry[] = [
448448+ { id: 1, bounds: { x: -5000, y: -5000, width: 5000, height: 3000 }, isBackground: false },
1197449 ];
11981198-11991199- // Second event during debounce - should NOT overwrite pre-debounce bounds
12001200- onDisplayChange(state, relocatedWindows, true, 500);
12011201- assert.strictEqual(state.preDebounceWindowBounds, capturedBounds,
12021202- "pre-debounce bounds should NOT be overwritten by subsequent events");
12031203- assert.deepStrictEqual(state.preDebounceWindowBounds!.get(1), { x: 100, y: 200, width: 800, height: 600 },
12041204- "bounds should still reflect the ORIGINAL positions");
12051205-12061206- // Cleanup
12071207- if (state.debounceTimer) clearTimeout(state.debounceTimer);
450450+ const result = simulateHandleDisplayChange(
451451+ windows,
452452+ [display1440],
453453+ [displayLaptop], // smaller display
454454+ 3
455455+ );
456456+ assert.strictEqual(result.rescued, 1);
457457+ const newBounds = result.windowBounds.get(1)!;
458458+ // Size preserved — never resize, only reposition
459459+ assert.strictEqual(newBounds.width, 5000);
460460+ assert.strictEqual(newBounds.height, 3000);
1208461 });
120946212101210- it("does NOT capture bounds for non-removal events (isRemoval=false)", () => {
12111211- const windows: MockWindow[] = [
12121212- { id: 1, destroyed: false, visible: true, bounds: { x: 100, y: 200, width: 800, height: 600 } },
463463+ it("centers rescued window on the target display", () => {
464464+ const windows: MockWindowEntry[] = [
465465+ { id: 1, bounds: { x: -5000, y: -5000, width: 800, height: 600 }, isBackground: false },
1213466 ];
12141214- const state: DebounceState = {
12151215- debounceTimer: null,
12161216- preDebounceWindowBounds: null,
12171217- handleDisplayChangeCalled: false,
12181218- };
12191219-12201220- onDisplayChange(state, windows, false, 500);
12211221-12221222- assert.strictEqual(state.preDebounceWindowBounds, null,
12231223- "should NOT capture bounds for non-removal events");
12241224-12251225- // Cleanup
12261226- if (state.debounceTimer) clearTimeout(state.debounceTimer);
467467+ const result = simulateHandleDisplayChange(
468468+ windows,
469469+ [display1440],
470470+ [display1440],
471471+ 1
472472+ );
473473+ const newBounds = result.windowBounds.get(1)!;
474474+ const wa = display1440.workArea;
475475+ const expectedX = wa.x + Math.round((wa.width - 800) / 2);
476476+ const expectedY = wa.y + Math.round((wa.height - 600) / 2);
477477+ assert.strictEqual(newBounds.x, expectedX);
478478+ assert.strictEqual(newBounds.y, expectedY);
1227479 });
122848012291229- it("clears pre-debounce bounds after debounced handler fires", async () => {
12301230- const windows: MockWindow[] = [
12311231- { id: 1, destroyed: false, visible: true, bounds: { x: 100, y: 200, width: 800, height: 600 } },
481481+ it("rescues to nearest display based on old display proximity", () => {
482482+ // Window was on a display far to the right (old position known)
483483+ const farRightOld: DisplaySnapshot = {
484484+ id: 99,
485485+ bounds: { x: 5000, y: 0, width: 1920, height: 1080 },
486486+ workArea: { x: 5000, y: 25, width: 1920, height: 1055 },
487487+ };
488488+ const windows: MockWindowEntry[] = [
489489+ { id: 1, bounds: { x: 5500, y: 200, width: 800, height: 600 }, isBackground: false },
1232490 ];
12331233- const state: DebounceState = {
12341234- debounceTimer: null,
12351235- preDebounceWindowBounds: null,
12361236- handleDisplayChangeCalled: false,
12371237- };
12381238-12391239- onDisplayChange(state, windows, true, 30);
12401240- assert.notStrictEqual(state.preDebounceWindowBounds, null);
12411241-12421242- await new Promise<void>((resolve) => {
12431243- setTimeout(() => {
12441244- assert.strictEqual(state.preDebounceWindowBounds, null,
12451245- "pre-debounce bounds should be cleared after handler fires");
12461246- assert.strictEqual(state.handleDisplayChangeCalled, true,
12471247- "handleDisplayChange should have been called");
12481248- assert.strictEqual(state.debounceTimer, null,
12491249- "debounce timer should be null after handler fires");
12501250- resolve();
12511251- }, 80);
12521252- });
491491+ // Old layout had farRightOld, new layout has display1440 and display1080
492492+ const result = simulateHandleDisplayChange(
493493+ windows,
494494+ [display1440, display1080, farRightOld],
495495+ [display1440, display1080], // farRightOld removed
496496+ 1
497497+ );
498498+ assert.strictEqual(result.rescued, 1);
499499+ // Should end up on display1080 (closer to where farRightOld was)
500500+ const newBounds = result.windowBounds.get(1)!;
501501+ const wa1080 = display1080.workArea;
502502+ assert.ok(newBounds.x >= wa1080.x && newBounds.x + newBounds.width <= wa1080.x + wa1080.width,
503503+ `window at x=${newBounds.x} should be on display1080 (workArea starts at ${wa1080.x})`);
1253504 });
125450512551255- it("resets debounce timer on each subsequent event", () => {
12561256- const windows: MockWindow[] = [
12571257- { id: 1, destroyed: false, visible: true, bounds: { x: 0, y: 0, width: 800, height: 600 } },
506506+ it("handles multiple orphaned windows", () => {
507507+ const windows: MockWindowEntry[] = [
508508+ { id: 1, bounds: { x: -5000, y: -5000, width: 800, height: 600 }, isBackground: false },
509509+ { id: 2, bounds: { x: 10000, y: 10000, width: 600, height: 400 }, isBackground: false },
510510+ { id: 3, bounds: { x: 500, y: 200, width: 800, height: 600 }, isBackground: false }, // accessible
1258511 ];
12591259- const state: DebounceState = {
12601260- debounceTimer: null,
12611261- preDebounceWindowBounds: null,
12621262- handleDisplayChangeCalled: false,
12631263- };
12641264-12651265- onDisplayChange(state, windows, true, 500);
12661266- const firstTimer = state.debounceTimer;
12671267- assert.notStrictEqual(firstTimer, null);
12681268-12691269- // Second event should create a new timer
12701270- onDisplayChange(state, windows, false, 500);
12711271- assert.notStrictEqual(state.debounceTimer, null);
12721272- // Timer reference changes because old was cleared and new was created
12731273- // (Node may reuse timer refs but the clearTimeout+setTimeout cycle happens)
12741274- assert.notStrictEqual(state.debounceTimer, firstTimer,
12751275- "debounce timer should be reset on subsequent event");
12761276-12771277- // Cleanup
12781278- if (state.debounceTimer) clearTimeout(state.debounceTimer);
512512+ const result = simulateHandleDisplayChange(
513513+ windows,
514514+ [display1440],
515515+ [display1440],
516516+ 1
517517+ );
518518+ assert.strictEqual(result.rescued, 2);
519519+ // Window 3 should keep its original bounds
520520+ assert.deepStrictEqual(result.windowBounds.get(3), { x: 500, y: 200, width: 800, height: 600 });
1279521 });
1280522 });
128152312821282- describe("Phase 1 pre-debounce bounds fallback", () => {
12831283- // Tests the pattern: _preDebounceWindowBounds?.get(win.id) ?? win.getBounds()
12841284- // This is used in handleDisplayChange Phase 1 to correctly identify which display
12851285- // a window was on BEFORE macOS auto-relocated it.
12861286-12871287- interface MockWindow {
12881288- id: number;
12891289- currentBounds: Rectangle; // what getBounds() returns NOW (post-relocation)
12901290- }
12911291-12921292- function getEffectiveBounds(
12931293- preDebounceWindowBounds: Map<number, Rectangle> | null,
12941294- win: MockWindow
12951295- ): Rectangle {
12961296- return preDebounceWindowBounds?.get(win.id) ?? win.currentBounds;
12971297- }
12981298-12991299- it("uses pre-debounce bounds when available", () => {
13001300- const preDebounce = new Map<number, Rectangle>([
13011301- [1, { x: 2700, y: 100, width: 800, height: 600 }], // was on external display
13021302- ]);
13031303- const win: MockWindow = {
524524+ describe("findBestNewDisplay proximity (no ID preference)", () => {
525525+ it("does not prefer matching display ID over closer display", () => {
526526+ // Old display had id=1 and was on the right side
527527+ const oldRight: DisplaySnapshot = {
1304528 id: 1,
13051305- currentBounds: { x: 100, y: 100, width: 800, height: 600 }, // macOS moved it to laptop
529529+ bounds: { x: 3000, y: 0, width: 1920, height: 1080 },
530530+ workArea: { x: 3000, y: 25, width: 1920, height: 1055 },
1306531 };
13071307-13081308- const bounds = getEffectiveBounds(preDebounce, win);
13091309- assert.deepStrictEqual(bounds, { x: 2700, y: 100, width: 800, height: 600 },
13101310- "should use pre-debounce bounds (original position on external display)");
13111311- });
13121312-13131313- it("falls back to current bounds when pre-debounce map is null", () => {
13141314- const win: MockWindow = {
532532+ // New layout: display with id=1 is now on the LEFT, display id=2 is on the RIGHT
533533+ const newLeft: DisplaySnapshot = {
1315534 id: 1,
13161316- currentBounds: { x: 100, y: 100, width: 800, height: 600 },
535535+ bounds: { x: 0, y: 0, width: 1920, height: 1080 },
536536+ workArea: { x: 0, y: 25, width: 1920, height: 1055 },
1317537 };
13181318-13191319- const bounds = getEffectiveBounds(null, win);
13201320- assert.deepStrictEqual(bounds, { x: 100, y: 100, width: 800, height: 600 },
13211321- "should fall back to current bounds when no pre-debounce map");
13221322- });
13231323-13241324- it("falls back to current bounds when window not in pre-debounce map", () => {
13251325- const preDebounce = new Map<number, Rectangle>([
13261326- [2, { x: 2700, y: 100, width: 800, height: 600 }], // different window
13271327- ]);
13281328- const win: MockWindow = {
13291329- id: 1,
13301330- currentBounds: { x: 100, y: 100, width: 800, height: 600 },
538538+ const newRight: DisplaySnapshot = {
539539+ id: 2,
540540+ bounds: { x: 2000, y: 0, width: 1920, height: 1080 },
541541+ workArea: { x: 2000, y: 25, width: 1920, height: 1055 },
1331542 };
13321332-13331333- const bounds = getEffectiveBounds(preDebounce, win);
13341334- assert.deepStrictEqual(bounds, { x: 100, y: 100, width: 800, height: 600 },
13351335- "should fall back to current bounds when window not in pre-debounce map");
13361336- });
13371337-13381338- it("correctly identifies window's home display using pre-debounce bounds", () => {
13391339- // Scenario: external display (display1080) removed. macOS moved window to laptop.
13401340- // Pre-debounce bounds show the window was at (2700, 100) - on display1080.
13411341- // Current bounds show (100, 100) - on the laptop display.
13421342- const preDebounce = new Map<number, Rectangle>([
13431343- [1, { x: 2700, y: 100, width: 800, height: 600 }],
13441344- ]);
13451345- const win: MockWindow = {
13461346- id: 1,
13471347- currentBounds: { x: 100, y: 100, width: 800, height: 600 },
13481348- };
13491349-13501350- const bounds = getEffectiveBounds(preDebounce, win);
13511351- const centerX = bounds.x + Math.round(bounds.width / 2);
13521352- const centerY = bounds.y + Math.round(bounds.height / 2);
13531353-13541354- // With pre-debounce bounds, center is at (3100, 400) -> on display1080
13551355- const oldDisplays = [display1440, display1080];
13561356- const found = findDisplayForPoint(oldDisplays, centerX, centerY);
13571357- assert.strictEqual(found?.id, 2,
13581358- "should find the window was on display1080 using pre-debounce bounds");
543543+ // findBestNewDisplay uses proximity only — should pick newRight (closer to oldRight center)
544544+ const result = findBestNewDisplay(oldRight, [newLeft, newRight], 1);
545545+ assert.strictEqual(result.id, 2, "should pick closer display, not matching ID");
1359546 });
136054713611361- it("without pre-debounce bounds, mis-identifies display after macOS relocation", () => {
13621362- // Same scenario but WITHOUT pre-debounce bounds - demonstrates the problem
13631363- const win: MockWindow = {
13641364- id: 1,
13651365- currentBounds: { x: 100, y: 100, width: 800, height: 600 },
548548+ it("picks single available display regardless of old display position", () => {
549549+ const farAway: DisplaySnapshot = {
550550+ id: 99,
551551+ bounds: { x: 10000, y: 10000, width: 1920, height: 1080 },
552552+ workArea: { x: 10000, y: 10025, width: 1920, height: 1055 },
1366553 };
13671367-13681368- const bounds = getEffectiveBounds(null, win);
13691369- const centerX = bounds.x + Math.round(bounds.width / 2);
13701370- const centerY = bounds.y + Math.round(bounds.height / 2);
13711371-13721372- // Without pre-debounce, center is at (500, 400) -> on display1440 (laptop),
13731373- // even though the window WAS on display1080 before macOS moved it
13741374- const oldDisplays = [display1440, display1080];
13751375- const found = findDisplayForPoint(oldDisplays, centerX, centerY);
13761376- assert.strictEqual(found?.id, 1,
13771377- "without pre-debounce, window appears to be on display1440 (wrong - macOS already moved it)");
13781378- });
13791379-13801380- it("handles multiple windows with mixed pre-debounce availability", () => {
13811381- // Window 1: was on external, captured in pre-debounce
13821382- // Window 2: was on laptop, NOT in pre-debounce (was visible but different ID)
13831383- // Window 3: new window created after capture, NOT in pre-debounce
13841384- const preDebounce = new Map<number, Rectangle>([
13851385- [1, { x: 2700, y: 100, width: 800, height: 600 }],
13861386- [2, { x: 200, y: 200, width: 600, height: 400 }],
13871387- ]);
13881388-13891389- const win1: MockWindow = { id: 1, currentBounds: { x: 50, y: 50, width: 800, height: 600 } };
13901390- const win2: MockWindow = { id: 2, currentBounds: { x: 200, y: 200, width: 600, height: 400 } };
13911391- const win3: MockWindow = { id: 3, currentBounds: { x: 300, y: 300, width: 500, height: 350 } };
13921392-13931393- assert.deepStrictEqual(getEffectiveBounds(preDebounce, win1),
13941394- { x: 2700, y: 100, width: 800, height: 600 },
13951395- "win1 should use pre-debounce bounds");
13961396- assert.deepStrictEqual(getEffectiveBounds(preDebounce, win2),
13971397- { x: 200, y: 200, width: 600, height: 400 },
13981398- "win2 should use pre-debounce bounds");
13991399- assert.deepStrictEqual(getEffectiveBounds(preDebounce, win3),
14001400- { x: 300, y: 300, width: 500, height: 350 },
14011401- "win3 should fall back to current bounds (not in pre-debounce)");
14021402- });
14031403-14041404- it("end-to-end: pre-debounce bounds enable correct home display save on removal", () => {
14051405- // Full scenario: external display removed, pre-debounce captured, Phase 1 saves home
14061406- const preDebounce = new Map<number, Rectangle>([
14071407- [1, { x: 2700, y: 100, width: 800, height: 600 }], // was on display1080
14081408- [2, { x: 500, y: 300, width: 600, height: 400 }], // was on display1440
14091409- ]);
14101410-14111411- // After macOS relocates windows, both are on display1440
14121412- const windows = [
14131413- { id: 1, currentBounds: { x: 100, y: 100, width: 800, height: 600 } },
14141414- { id: 2, currentBounds: { x: 500, y: 300, width: 600, height: 400 } },
14151415- ];
14161416-14171417- const removedDisplayIds = new Set([display1080.id]);
14181418- const oldDisplays = [display1440, display1080];
14191419- const savedHomes = new Map<number, WindowHomeDisplay>();
14201420-14211421- for (const win of windows) {
14221422- const bounds = getEffectiveBounds(preDebounce, win);
14231423- const centerX = bounds.x + Math.round(bounds.width / 2);
14241424- const centerY = bounds.y + Math.round(bounds.height / 2);
14251425-14261426- const oldDisplay = findDisplayForPoint(oldDisplays, centerX, centerY);
14271427- if (oldDisplay && removedDisplayIds.has(oldDisplay.id)) {
14281428- savedHomes.set(win.id, computeHomeDisplay(bounds, oldDisplay));
14291429- }
14301430- }
14311431-14321432- // Only window 1 should be saved as displaced (it was on the removed display)
14331433- assert.strictEqual(savedHomes.size, 1, "only window on removed display should be saved");
14341434- assert.strictEqual(savedHomes.has(1), true);
14351435- assert.strictEqual(savedHomes.has(2), false);
14361436- assert.strictEqual(savedHomes.get(1)!.displayId, display1080.id);
554554+ const result = findBestNewDisplay(farAway, [display1440], 1);
555555+ assert.strictEqual(result.id, 1);
1437556 });
1438557 });
1439558});
+78-657
backend/electron/display-watcher.ts
···11/**
22- * Display Change Watcher
22+ * Display Change Watcher — Safety Net
33 *
44 * Monitors display configuration changes (monitor plug/unplug, resolution changes)
55- * and repositions windows to maintain approximate relative positions.
55+ * and rescues genuinely orphaned windows that end up off-screen.
66 *
77- * When a display is removed or resized, windows that were on that display may end up
88- * off-screen or bunched in corners. This module detects those cases and moves windows
99- * to valid positions on the remaining/changed displays.
77+ * macOS handles window migration natively when displays are added/removed.
88+ * This module only intervenes when a window has less than 30% area overlap
99+ * with any display workArea — meaning it's truly inaccessible to the user.
1010 *
1111- * Algorithm (three-phase display change handling):
1111+ * Algorithm (single-pass safety net):
1212 * - Snapshot display layout at startup and after each change
1313- * - Phase 1 (Save): When displays are removed, save "home display" info for displaced
1414- * windows (display ID, resolution, relative position within the workArea)
1515- * - Phase 2 (Restore): When displays are added and there are displaced windows, try to
1616- * match each window's home display to a newly added display (by ID first, then by
1717- * resolution with 5% tolerance). Restore matched windows to their original positions.
1818- * - Phase 3 (Reposition): For remaining windows on changed displays, use group-based
1919- * distance scaling:
2020- * 1. Find the bounding box of all window centers on the old display
2121- * 2. Calculate the center of that bounding box
2222- * 3. For each window, compute its offset from the bounding box center
2323- * 4. Scale those offsets by (new display size / old display size)
2424- * 5. Place windows at (new display center + scaled offset)
2525- * 6. Scale window dimensions proportionally
2626- * 7. Clamp to keep all windows on-screen
2727- * - This preserves the spatial LAYOUT and scales DISTANCES between windows,
2828- * rather than mapping each window proportional position independently.
2929- * - Skip windows that are still fully visible on a valid display
1313+ * - On display change: check every window's overlap with current displays
1414+ * - If a window has <30% overlap with ANY display, it's orphaned:
1515+ * find the nearest display and center the window on it
1616+ * - Window size is NEVER changed — only position is adjusted
1717+ * - If all windows are accessible (99% of cases), do nothing
3018 */
31193220import { BrowserWindow, screen, Display } from 'electron';
···4634interface WindowEntry {
4735 win: BrowserWindow;
4836 bounds: Electron.Rectangle;
4949-}
5050-5151-/** Records a window's "home" display info when it gets displaced */
5252-interface WindowHomeDisplay {
5353- displayId: number;
5454- resolution: { width: number; height: number };
5555- relativePosition: {
5656- centerX: number; // 0-1 ratio within workArea width
5757- centerY: number; // 0-1 ratio within workArea height
5858- width: number; // 0-1 ratio within workArea width
5959- height: number; // 0-1 ratio within workArea height
6060- };
6137}
62386339/** Last known display layout */
6440let _previousDisplays: DisplaySnapshot[] = [];
65416666-/** Displaced windows awaiting restoration, keyed by BrowserWindow id */
6767-let _windowHomeDisplays: Map<number, WindowHomeDisplay> = new Map();
6868-6969-/** Continuously-updated window positions. Updated on every window move/resize.
7070- * Used by Phase 1 instead of pre-debounce capture, since macOS relocates windows
7171- * BEFORE the display-removed event fires (pre-debounce capture is already too late). */
7272-let _trackedPositions: Map<number, { bounds: Electron.Rectangle; displaySnapshot: DisplaySnapshot }> = new Map();
7373-7474-/** Pre-debounce snapshot of window positions — DEPRECATED, kept as fallback.
7575- * macOS moves windows BEFORE the event fires, making this unreliable. */
7676-let _preDebounceWindowBounds: Map<number, Electron.Rectangle> | null = null;
7777-7842/** Debounce timer for display changes (macOS fires multiple events rapidly) */
7943let _debounceTimer: ReturnType<typeof setTimeout> | null = null;
8044const DEBOUNCE_MS = 500;
81458282-/** When true, display changes update the snapshot but skip window repositioning.
8383- * Used during session restore to prevent newly restored windows from being moved. */
8484-let _suppressRepositioning = false;
8585-8686-/**
8787- * Suppress window repositioning during a critical section (e.g., session restore).
8888- * The display snapshot is still updated on changes, but windows are not moved.
8989- * Call with true before restore, false after.
9090- */
9191-export function suppressDisplayRepositioning(suppress: boolean): void {
9292- _suppressRepositioning = suppress;
9393- if (suppress) {
9494- // Cancel any pending debounced display change handler.
9595- // Without this, a display event that fired before suppression could trigger
9696- // repositioning AFTER suppression ends (the debounce timer outlasts the suppress window).
9797- if (_debounceTimer) {
9898- clearTimeout(_debounceTimer);
9999- _debounceTimer = null;
100100- }
101101- _preDebounceWindowBounds = null;
102102- console.log("[display-watcher] Repositioning SUPPRESSED (session restore in progress)");
103103- } else {
104104- // Cancel any pending debounced events that accumulated during suppression.
105105- // These would use stale snapshots and could reposition windows incorrectly.
106106- if (_debounceTimer) {
107107- clearTimeout(_debounceTimer);
108108- _debounceTimer = null;
109109- }
110110- _preDebounceWindowBounds = null;
111111- // Take a fresh snapshot so the watcher has the correct baseline
112112- // after session restore created new windows
113113- _previousDisplays = snapshotDisplays();
114114- console.log("[display-watcher] Repositioning RE-ENABLED, snapshot refreshed");
115115- }
116116-}
117117-11846/**
11947 * Take a snapshot of the current display layout.
12048 */
···1497715078/**
15179 * Check if a window is still accessible on the current displays.
152152- * "Accessible" means the window center-top region (title bar area)
153153- * is on a valid display, so the user can still grab and move it.
8080+ * "Accessible" means the window has at least 30% area overlap with
8181+ * any display's workArea, so the user can still interact with it.
8282+ * Zero-size windows are always considered accessible (hidden/minimized).
15483 */
15584function isWindowAccessible(displays: DisplaySnapshot[], bounds: Electron.Rectangle): boolean {
156156- const centerX = bounds.x + Math.round(bounds.width / 2);
157157- const topY = bounds.y + 15; // Title bar midpoint
158158- return findDisplayForPoint(displays, centerX, topY) !== null;
8585+ const windowArea = bounds.width * bounds.height;
8686+8787+ // Zero-size windows are always accessible (hidden, minimized, etc.)
8888+ if (windowArea <= 0) return true;
8989+9090+ for (const d of displays) {
9191+ const wa = d.workArea;
9292+9393+ // Calculate overlap rectangle
9494+ const overlapX = Math.max(bounds.x, wa.x);
9595+ const overlapY = Math.max(bounds.y, wa.y);
9696+ const overlapRight = Math.min(bounds.x + bounds.width, wa.x + wa.width);
9797+ const overlapBottom = Math.min(bounds.y + bounds.height, wa.y + wa.height);
9898+9999+ if (overlapRight > overlapX && overlapBottom > overlapY) {
100100+ const overlapArea = (overlapRight - overlapX) * (overlapBottom - overlapY);
101101+ if (overlapArea / windowArea >= 0.3) {
102102+ return true;
103103+ }
104104+ }
105105+ }
106106+107107+ return false;
159108}
160109161110/**
162111 * Find the best new display to place a window on.
163163- * Tries to match by display ID first, then by proximity of old display position.
112112+ * Finds the nearest display by center-point proximity.
113113+ * Falls back to primary display when no old display info is available.
164114 */
165115function findBestNewDisplay(
166116 oldDisplay: DisplaySnapshot | null,
···176126 };
177127 }
178128179179- // If we know the old display, try to find it by ID in the new layout
129129+ // If we know the old display, find the nearest new display by center distance
180130 if (oldDisplay) {
181181- const sameId = newDisplays.find(d => d.id === oldDisplay.id);
182182- if (sameId) return sameId;
183183-184184- // Find the display whose workArea center is closest to the old display center
185131 const oldCenterX = oldDisplay.workArea.x + oldDisplay.workArea.width / 2;
186132 const oldCenterY = oldDisplay.workArea.y + oldDisplay.workArea.height / 2;
187133···208154}
209155210156/**
211211- * Calculate repositioned bounds for a group of windows moving between displays.
212212- *
213213- * Uses center-of-group scaling: finds the bounding box of all window centers,
214214- * then scales each window offset from that center by the display size ratio.
215215- * This preserves the spatial layout and scales distances proportionally.
216216- *
217217- * For a single window, this degrades gracefully: the window center is mapped
218218- * to the same relative position on the new display, with scaled dimensions.
219219- *
220220- * @param windowBounds Array of window bounds to reposition
221221- * @param oldDisplay The display windows are moving from
222222- * @param newDisplay The display windows are moving to
223223- * @returns Array of new bounds, one per input window
224224- */
225225-function calculateGroupRepositionedBounds(
226226- windowBounds: Electron.Rectangle[],
227227- oldDisplay: DisplaySnapshot,
228228- newDisplay: DisplaySnapshot
229229-): Electron.Rectangle[] {
230230- const oldWA = oldDisplay.workArea;
231231- const newWA = newDisplay.workArea;
232232-233233- // Scale factors for distances
234234- const scaleX = oldWA.width > 0 ? newWA.width / oldWA.width : 1;
235235- const scaleY = oldWA.height > 0 ? newWA.height / oldWA.height : 1;
236236-237237- // Calculate the center of each window
238238- const centers = windowBounds.map(wb => ({
239239- x: wb.x + wb.width / 2,
240240- y: wb.y + wb.height / 2,
241241- }));
242242-243243- // Find the bounding box of all window centers
244244- let minCX = Infinity, maxCX = -Infinity;
245245- let minCY = Infinity, maxCY = -Infinity;
246246- for (const c of centers) {
247247- minCX = Math.min(minCX, c.x);
248248- maxCX = Math.max(maxCX, c.x);
249249- minCY = Math.min(minCY, c.y);
250250- maxCY = Math.max(maxCY, c.y);
251251- }
252252-253253- // Center of the bounding box of window centers (on old display)
254254- const groupCenterX = (minCX + maxCX) / 2;
255255- const groupCenterY = (minCY + maxCY) / 2;
256256-257257- // Map the group center to new display: preserve its relative position within the workArea
258258- const groupRelX = oldWA.width > 0 ? (groupCenterX - oldWA.x) / oldWA.width : 0.5;
259259- const groupRelY = oldWA.height > 0 ? (groupCenterY - oldWA.y) / oldWA.height : 0.5;
260260- const newGroupCenterX = newWA.x + groupRelX * newWA.width;
261261- const newGroupCenterY = newWA.y + groupRelY * newWA.height;
262262-263263- const results: Electron.Rectangle[] = [];
264264-265265- for (let i = 0; i < windowBounds.length; i++) {
266266- const wb = windowBounds[i];
267267- const center = centers[i];
268268-269269- // Offset from group center on old display
270270- const offsetX = center.x - groupCenterX;
271271- const offsetY = center.y - groupCenterY;
272272-273273- // Scale the offset by display size ratio
274274- const scaledOffsetX = offsetX * scaleX;
275275- const scaledOffsetY = offsetY * scaleY;
276276-277277- // Scale window dimensions proportionally
278278- let newW = Math.round(wb.width * scaleX);
279279- let newH = Math.round(wb.height * scaleY);
280280-281281- // Enforce minimum window size
282282- newW = Math.max(newW, 200);
283283- newH = Math.max(newH, 150);
284284-285285- // Cap to new workArea size
286286- newW = Math.min(newW, newWA.width);
287287- newH = Math.min(newH, newWA.height);
288288-289289- // New center = new group center + scaled offset
290290- const newCenterX = newGroupCenterX + scaledOffsetX;
291291- const newCenterY = newGroupCenterY + scaledOffsetY;
292292-293293- // Convert center to top-left
294294- let newX = Math.round(newCenterX - newW / 2);
295295- let newY = Math.round(newCenterY - newH / 2);
296296-297297- // Clamp so window stays fully within the new workArea
298298- newX = Math.max(newWA.x, Math.min(newX, newWA.x + newWA.width - newW));
299299- newY = Math.max(newWA.y, Math.min(newY, newWA.y + newWA.height - newH));
300300-301301- results.push({ x: newX, y: newY, width: newW, height: newH });
302302- }
303303-304304- return results;
305305-}
306306-307307-/**
308308- * Reposition a group of windows from an old display to a new display,
309309- * preserving their spatial layout with scaled distances.
310310- */
311311-function repositionWindowGroup(
312312- entries: WindowEntry[],
313313- oldDisplay: DisplaySnapshot,
314314- newDisplay: DisplaySnapshot
315315-): void {
316316- const windowBounds = entries.map(e => e.bounds);
317317- const newBoundsList = calculateGroupRepositionedBounds(windowBounds, oldDisplay, newDisplay);
318318-319319- for (let i = 0; i < entries.length; i++) {
320320- const { win, bounds } = entries[i];
321321- const newBounds = newBoundsList[i];
322322-323323- DEBUG && console.log(
324324- `[display-watcher] Repositioning window ${win.id}: ` +
325325- `(${bounds.x},${bounds.y} ${bounds.width}x${bounds.height}) -> ` +
326326- `(${newBounds.x},${newBounds.y} ${newBounds.width}x${newBounds.height}) ` +
327327- `[display ${oldDisplay.id} -> ${newDisplay.id}]`
328328- );
329329-330330- 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})`);
331331- win.setBounds(newBounds);
332332- }
333333-}
334334-335335-/**
336336- * Save a window's home display info before it gets displaced.
337337- * Captures the window's relative position within the display's workArea
338338- * so it can be restored later if the same display reappears.
339339- */
340340-function saveWindowHomeDisplay(
341341- win: BrowserWindow,
342342- bounds: Electron.Rectangle,
343343- display: DisplaySnapshot
344344-): void {
345345- const wa = display.workArea;
346346- const centerX = bounds.x + bounds.width / 2;
347347- const centerY = bounds.y + bounds.height / 2;
348348-349349- const homeDisplay: WindowHomeDisplay = {
350350- displayId: display.id,
351351- resolution: { width: wa.width, height: wa.height },
352352- relativePosition: {
353353- centerX: wa.width > 0 ? (centerX - wa.x) / wa.width : 0.5,
354354- centerY: wa.height > 0 ? (centerY - wa.y) / wa.height : 0.5,
355355- width: wa.width > 0 ? bounds.width / wa.width : 0.5,
356356- height: wa.height > 0 ? bounds.height / wa.height : 0.5,
357357- },
358358- };
359359-360360- _windowHomeDisplays.set(win.id, homeDisplay);
361361- console.log(
362362- `[display-watcher] Saved home display for window ${win.id}: ` +
363363- `display ${display.id} (${wa.x},${wa.y} ${wa.width}x${wa.height}), ` +
364364- `rel center (${homeDisplay.relativePosition.centerX.toFixed(3)}, ${homeDisplay.relativePosition.centerY.toFixed(3)})`
365365- );
366366-}
367367-368368-/**
369369- * Find a matching new display for a displaced window's home display.
370370- * Matches by display ID first, then falls back to resolution matching
371371- * within a 5% tolerance.
372372- */
373373-function findMatchingNewDisplay(
374374- home: WindowHomeDisplay,
375375- addedDisplays: DisplaySnapshot[]
376376-): DisplaySnapshot | null {
377377- // First try exact display ID match
378378- const byId = addedDisplays.find(d => d.id === home.displayId);
379379- if (byId) return byId;
380380-381381- // Fallback: match by resolution within 5% tolerance
382382- const tolerance = 0.05;
383383- for (const d of addedDisplays) {
384384- const wa = d.workArea;
385385- const widthRatio = home.resolution.width > 0
386386- ? Math.abs(wa.width - home.resolution.width) / home.resolution.width
387387- : (wa.width === 0 ? 0 : 1);
388388- const heightRatio = home.resolution.height > 0
389389- ? Math.abs(wa.height - home.resolution.height) / home.resolution.height
390390- : (wa.height === 0 ? 0 : 1);
391391-392392- console.log(
393393- `[display-watcher] Resolution check: home (${home.resolution.width}x${home.resolution.height}) ` +
394394- `vs display ${d.id} (${wa.width}x${wa.height}), ` +
395395- `delta (${(widthRatio * 100).toFixed(1)}%, ${(heightRatio * 100).toFixed(1)}%) ` +
396396- `${widthRatio <= tolerance && heightRatio <= tolerance ? 'MATCH' : 'no match'}`
397397- );
398398- if (widthRatio <= tolerance && heightRatio <= tolerance) {
399399- return d;
400400- }
401401- }
402402-403403- return null;
404404-}
405405-406406-/**
407407- * Restore a window to its saved home display position.
408408- * Places the window at the same relative position within the new display's workArea.
409409- */
410410-function restoreWindowToHomeDisplay(
411411- win: BrowserWindow,
412412- home: WindowHomeDisplay,
413413- display: DisplaySnapshot
414414-): void {
415415- const wa = display.workArea;
416416- const rel = home.relativePosition;
417417-418418- // Restore window dimensions from relative sizes
419419- let newW = Math.round(rel.width * wa.width);
420420- let newH = Math.round(rel.height * wa.height);
421421-422422- // Enforce minimum window size
423423- newW = Math.max(newW, 200);
424424- newH = Math.max(newH, 150);
425425-426426- // Cap to workArea size
427427- newW = Math.min(newW, wa.width);
428428- newH = Math.min(newH, wa.height);
429429-430430- // Restore center position from relative coordinates
431431- const centerX = wa.x + rel.centerX * wa.width;
432432- const centerY = wa.y + rel.centerY * wa.height;
433433-434434- // Convert center to top-left
435435- let newX = Math.round(centerX - newW / 2);
436436- let newY = Math.round(centerY - newH / 2);
437437-438438- // Clamp within workArea
439439- newX = Math.max(wa.x, Math.min(newX, wa.x + wa.width - newW));
440440- newY = Math.max(wa.y, Math.min(newY, wa.y + wa.height - newH));
441441-442442- const newBounds = { x: newX, y: newY, width: newW, height: newH };
443443-444444- DEBUG && console.log(
445445- `[display-watcher] Restoring window ${win.id} to home display ${display.id}: ` +
446446- `(${newBounds.x},${newBounds.y} ${newBounds.width}x${newBounds.height})`
447447- );
448448-449449- console.log(`[display-watcher] RESTORING window ${win.id} to home display: (${newBounds.x},${newBounds.y} ${newBounds.width}x${newBounds.height})`);
450450- win.setBounds(newBounds);
451451-}
452452-453453-/**
454157 * Handle a display configuration change.
455455- * Three-phase approach:
456456- * Phase 1: Save home display info for windows on removed displays
457457- * Phase 2: Restore displaced windows to matching newly-added displays
458458- * Phase 3: Reposition remaining off-screen/resized windows (existing logic)
158158+ * Single-pass safety net: check all windows, rescue any that are orphaned.
459159 */
460160function handleDisplayChange(): void {
461461- console.log(`[display-watcher] handleDisplayChange fired — checking for window repositioning`);
462462- if (_suppressRepositioning) {
463463- console.log("[display-watcher] Repositioning suppressed during session restore — updating snapshot only");
464464- _previousDisplays = snapshotDisplays();
465465- return;
466466- }
467161 const newDisplays = snapshotDisplays();
468162 const oldDisplays = _previousDisplays;
469163···473167 `New: ${newDisplays.length} display(s) [${newDisplays.map(d => `${d.id}(${d.workArea.x},${d.workArea.y} ${d.workArea.width}x${d.workArea.height})`).join(', ')}]`
474168 );
475169476476- // Build maps for quick lookup
477477- const newById = new Map(newDisplays.map(d => [d.id, d]));
478478- const oldById = new Map(oldDisplays.map(d => [d.id, d]));
479479-480480- // Determine which displays were removed, added, or changed
481481- const removedDisplayIds = new Set<number>();
482482- const changedDisplayIds = new Set<number>();
483483- for (const old of oldDisplays) {
484484- const cur = newById.get(old.id);
485485- if (!cur) {
486486- removedDisplayIds.add(old.id);
487487- changedDisplayIds.add(old.id);
488488- } else if (
489489- cur.workArea.x !== old.workArea.x ||
490490- cur.workArea.y !== old.workArea.y ||
491491- cur.workArea.width !== old.workArea.width ||
492492- cur.workArea.height !== old.workArea.height
493493- ) {
494494- changedDisplayIds.add(old.id);
495495- }
496496- }
497497-498498- const addedDisplays: DisplaySnapshot[] = [];
499499- for (const d of newDisplays) {
500500- if (!oldById.has(d.id)) {
501501- addedDisplays.push(d);
502502- }
503503- }
504504-505505- console.log(
506506- `[display-watcher] Removed: ${[...removedDisplayIds].join(", ") || "none"}, ` +
507507- `Added: ${addedDisplays.map(d => `${d.id}(${d.workArea.x},${d.workArea.y} ${d.workArea.width}x${d.workArea.height})`).join(", ") || "none"}, ` +
508508- `Changed: ${[...changedDisplayIds].join(", ") || "none"}`
509509- );
510510-511511- // ========================================================================
512512- // Phase 1 (Save): Save home display info for windows on removed displays.
513513- // Use pre-debounce window bounds if available — macOS auto-relocates windows from
514514- // disconnected displays BEFORE our debounced handler runs, so win.getBounds() at this
515515- // point returns the post-relocation position (on the laptop), not the original position
516516- // (on the external display). The pre-debounce capture gives us the true positions.
517517- // ========================================================================
518518- if (removedDisplayIds.size > 0) {
519519- const allWindows = BrowserWindow.getAllWindows();
520520- for (const win of allWindows) {
521521- if (win.isDestroyed()) continue;
522522- if (!win.isVisible()) continue;
523523-524524- // Skip the background window
525525- const entry = getWindowInfo(win.id);
526526- if (entry && entry.params.address === WEB_CORE_ADDRESS) continue;
527527-528528- // Only save if we haven't already saved a home display for this window
529529- if (_windowHomeDisplays.has(win.id)) continue;
530530-531531- // Priority order for position data:
532532- // 1. Continuously-tracked position (most reliable — captured before macOS moved anything)
533533- // 2. Pre-debounce capture (unreliable — macOS already moved windows by this point)
534534- // 3. Current bounds (worst — definitely post-relocation)
535535- const tracked = _trackedPositions.get(win.id);
536536- const preDebBounds = _preDebounceWindowBounds?.get(win.id);
537537- const currentBounds = win.getBounds();
538538- const bounds = tracked?.bounds ?? preDebBounds ?? currentBounds;
539539- const source = tracked ? 'tracked' : preDebBounds ? 'preDeb' : 'current';
540540-541541- const centerX = bounds.x + Math.round(bounds.width / 2);
542542- const centerY = bounds.y + Math.round(bounds.height / 2);
543543-544544- // Use tracked display if available (already resolved), otherwise look up from old displays
545545- let oldDisplay: DisplaySnapshot | null = null;
546546- if (tracked && removedDisplayIds.has(tracked.displaySnapshot.id)) {
547547- oldDisplay = tracked.displaySnapshot;
548548- } else {
549549- oldDisplay = findDisplayForPoint(oldDisplays, centerX, centerY);
550550- }
551551-552552- console.log(
553553- `[display-watcher] Phase 1 window ${win.id}: ` +
554554- `source=${source}, bounds=(${bounds.x},${bounds.y} ${bounds.width}x${bounds.height}), ` +
555555- `current=(${currentBounds.x},${currentBounds.y}), ` +
556556- `oldDisplay=${oldDisplay ? `${oldDisplay.id}(removed=${removedDisplayIds.has(oldDisplay.id)})` : 'null'}`
557557- );
558558- if (oldDisplay && removedDisplayIds.has(oldDisplay.id)) {
559559- saveWindowHomeDisplay(win, bounds, oldDisplay);
560560- console.log(`[display-watcher] Phase 1 SAVED home for window ${win.id} on display ${oldDisplay.id}`);
561561- }
562562- }
563563-564564- console.log(
565565- `[display-watcher] Phase 1 SUMMARY: ${_windowHomeDisplays.size} displaced window(s)`
566566- );
567567-568568- // ==================================================================
569569- // Phase 1b (Redistribute): Reposition displaced windows onto remaining
570570- // displays using their TRACKED (real pre-disconnect) positions.
571571- // macOS jams all windows into top-left of the laptop — override that
572572- // with proper group-scaled redistribution preserving relative layout.
573573- // ==================================================================
574574- if (_windowHomeDisplays.size > 0 && newDisplays.length > 0) {
575575- // Group displaced windows by their home display
576576- const displaceGroups = new Map<number, WindowEntry[]>();
577577- for (const [winId, home] of _windowHomeDisplays) {
578578- const win = BrowserWindow.fromId(winId);
579579- if (!win || win.isDestroyed()) continue;
580580- const tracked = _trackedPositions.get(winId);
581581- if (!tracked) continue;
582582-583583- if (!displaceGroups.has(home.displayId)) displaceGroups.set(home.displayId, []);
584584- displaceGroups.get(home.displayId)!.push({ win, bounds: tracked.bounds });
585585- }
586586-587587- for (const [displayId, entries] of displaceGroups) {
588588- const oldDisp = oldById.get(displayId);
589589- if (!oldDisp) continue;
590590- const targetDisp = findBestNewDisplay(oldDisp, newDisplays);
591591- console.log(
592592- `[display-watcher] Phase 1b: redistributing ${entries.length} window(s) ` +
593593- `from removed display ${displayId} -> display ${targetDisp.id} ` +
594594- `(${targetDisp.workArea.width}x${targetDisp.workArea.height})`
595595- );
596596- repositionWindowGroup(entries, oldDisp, targetDisp);
597597- }
598598- }
599599- }
600600-601601- // ========================================================================
602602- // Phase 2 (Restore): Restore displaced windows to matching new displays
603603- // ========================================================================
604604- // Track windows handled by Phase 1b/2 so Phase 3 skips them
605605- const handledWindows = new Set<number>();
606606-607607- // Mark Phase 1b windows as handled
608608- if (removedDisplayIds.size > 0) {
609609- for (const [winId] of _windowHomeDisplays) {
610610- handledWindows.add(winId);
611611- }
612612- }
613613-614614- console.log(`[display-watcher] Phase 2: addedDisplays=${addedDisplays.length}, displacedWindows=${_windowHomeDisplays.size}`);
615615- if (addedDisplays.length > 0 && _windowHomeDisplays.size > 0) {
616616- const restoredWindowIds: number[] = [];
617617-618618- for (const [winId, home] of _windowHomeDisplays) {
619619- const win = BrowserWindow.fromId(winId);
620620- if (!win || win.isDestroyed()) {
621621- console.log(`[display-watcher] Phase 2 window ${winId}: destroyed/gone, cleaning up`);
622622- restoredWindowIds.push(winId); // Clean up stale entries
623623- continue;
624624- }
625625-626626- const matchingDisplay = findMatchingNewDisplay(home, addedDisplays);
627627- console.log(
628628- `[display-watcher] Phase 2 window ${winId}: ` +
629629- `home display=${home.displayId} (${home.resolution.width}x${home.resolution.height}), ` +
630630- `match=${matchingDisplay ? `${matchingDisplay.id}(${matchingDisplay.workArea.width}x${matchingDisplay.workArea.height})` : 'NONE'}`
631631- );
632632- if (matchingDisplay) {
633633- restoreWindowToHomeDisplay(win, home, matchingDisplay);
634634- restoredWindowIds.push(winId);
635635- handledWindows.add(winId);
636636- }
637637- }
170170+ const allWindows = BrowserWindow.getAllWindows();
171171+ let rescuedCount = 0;
638172639639- // Remove restored/stale entries from the map
640640- for (const id of restoredWindowIds) {
641641- _windowHomeDisplays.delete(id);
642642- }
643643-644644- console.log(
645645- `[display-watcher] Phase 2 SUMMARY: Restored ${restoredWindowIds.length} window(s), ` +
646646- `${_windowHomeDisplays.size} still displaced`
647647- );
648648- }
649649-650650- // ========================================================================
651651- // Phase 3 (Reposition): Handle remaining off-screen/resized windows
652652- // ========================================================================
653653-654654- if (changedDisplayIds.size === 0 && addedDisplays.length === 0) {
655655- DEBUG && console.log("[display-watcher] Phase 3: No display workAreas changed, skipping repositioning");
656656- _previousDisplays = newDisplays;
657657- return;
658658- }
659659-660660- DEBUG && console.log(`[display-watcher] Phase 3: Changed display IDs: ${[...changedDisplayIds].join(", ")}`);
661661-662662- // Collect windows that need repositioning, grouped by (oldDisplay -> newDisplay) transition
663663- // Key: "oldDisplayId->newDisplayId"
664664- const transitionGroups = new Map<string, {
665665- entries: WindowEntry[];
666666- oldDisplay: DisplaySnapshot;
667667- newDisplay: DisplaySnapshot;
668668- }>();
669669-670670- // Also track orphaned windows (center off-screen, no old display found)
671671- const orphanedWindows: WindowEntry[] = [];
672672-673673- const allWindows = BrowserWindow.getAllWindows();
674173 for (const win of allWindows) {
675174 if (win.isDestroyed()) continue;
676175 if (!win.isVisible()) continue;
···679178 const entry = getWindowInfo(win.id);
680179 if (entry && entry.params.address === WEB_CORE_ADDRESS) continue;
681180682682- // Skip windows already handled by Phase 1b (redistributed) or Phase 2 (restored) —
683683- // their coordinates are correct for the new display layout, but would
684684- // appear to be on a "changed" display when checked against oldDisplays.
685685- if (handledWindows.has(win.id)) {
686686- console.log(`[display-watcher] Phase 3: skipping window ${win.id} (handled by Phase 1b/2)`);
687687- continue;
688688- }
689689-690181 const bounds = win.getBounds();
691691- const centerX = bounds.x + Math.round(bounds.width / 2);
692692- const centerY = bounds.y + Math.round(bounds.height / 2);
693182694694- // Check if window is still accessible on a current display
183183+ // Check if window is accessible on current displays
695184 if (isWindowAccessible(newDisplays, bounds)) {
696696- // Window is still reachable - but check if its display changed size
697697- const oldDisplay = findDisplayForPoint(oldDisplays, centerX, centerY);
698698- if (oldDisplay && changedDisplayIds.has(oldDisplay.id)) {
699699- const newDisplay = newById.get(oldDisplay.id);
700700- if (newDisplay) {
701701- // Same display ID but different size - needs repositioning
702702- const key = `${oldDisplay.id}->${newDisplay.id}`;
703703- if (!transitionGroups.has(key)) {
704704- transitionGroups.set(key, { entries: [], oldDisplay, newDisplay });
705705- }
706706- transitionGroups.get(key)!.entries.push({ win, bounds });
707707- }
708708- }
709709- // Otherwise, window is fine where it is
710185 continue;
711186 }
712187713713- // Window center is off-screen - find where it was and move it
188188+ // Window is orphaned — rescue it
189189+ const centerX = bounds.x + Math.round(bounds.width / 2);
190190+ const centerY = bounds.y + Math.round(bounds.height / 2);
191191+192192+ // Find which old display this window was on
714193 const oldDisplay = findDisplayForPoint(oldDisplays, centerX, centerY);
715715- if (oldDisplay) {
716716- const newDisplay = findBestNewDisplay(oldDisplay, newDisplays);
717717- const key = `${oldDisplay.id}->${newDisplay.id}`;
718718- if (!transitionGroups.has(key)) {
719719- transitionGroups.set(key, { entries: [], oldDisplay, newDisplay });
720720- }
721721- transitionGroups.get(key)!.entries.push({ win, bounds });
722722- } else {
723723- // Cannot determine old display
724724- orphanedWindows.push({ win, bounds });
725725- }
726726- }
194194+195195+ // Find the best new display to place it on
196196+ const newDisplay = findBestNewDisplay(oldDisplay, newDisplays);
197197+ const wa = newDisplay.workArea;
198198+199199+ // Position window on the target display, preserving original size.
200200+ // Only reposition, never resize — macOS handles display transitions
201201+ // and windows should keep their size even if larger than the new display.
202202+ const x = wa.x + Math.round((wa.width - bounds.width) / 2);
203203+ const y = wa.y + Math.round((wa.height - bounds.height) / 2);
727204728728- // Reposition each group with distance scaling
729729- for (const [key, group] of transitionGroups) {
730730- DEBUG && console.log(
731731- `[display-watcher] Repositioning group [${key}]: ${group.entries.length} window(s)`
205205+ console.log(
206206+ `[display-watcher] Rescuing orphaned window ${win.id}: ` +
207207+ `(${bounds.x},${bounds.y} ${bounds.width}x${bounds.height}) -> ` +
208208+ `(${x},${y} ${bounds.width}x${bounds.height}) [display ${newDisplay.id}]`
732209 );
733733- repositionWindowGroup(group.entries, group.oldDisplay, group.newDisplay);
210210+211211+ win.setBounds({ x, y, width: bounds.width, height: bounds.height });
212212+ rescuedCount++;
734213 }
735214736736- // Handle orphaned windows: center them on the best available display
737737- for (const { win, bounds } of orphanedWindows) {
738738- const newDisplay = findBestNewDisplay(null, newDisplays);
739739- const wa = newDisplay.workArea;
740740- const w = Math.min(bounds.width, wa.width);
741741- const h = Math.min(bounds.height, wa.height);
742742- const x = wa.x + Math.round((wa.width - w) / 2);
743743- const y = wa.y + Math.round((wa.height - h) / 2);
744744- DEBUG && console.log(
745745- `[display-watcher] Centering orphaned window ${win.id} on display ${newDisplay.id}: (${x},${y} ${w}x${h})`
746746- );
747747- win.setBounds({ x, y, width: w, height: h });
215215+ if (rescuedCount === 0) {
216216+ DEBUG && console.log("[display-watcher] All windows accessible, no rescue needed");
217217+ } else {
218218+ console.log(`[display-watcher] Rescued ${rescuedCount} orphaned window(s)`);
748219 }
749220750221 // Update snapshot for next change
···752223}
753224754225/**
755755- * Capture a snapshot of all window bounds right now.
756756- * Called immediately (before debounce) on display-removed events so we know
757757- * which display each window was on BEFORE macOS auto-relocates them.
758758- */
759759-function captureWindowBounds(): Map<number, Electron.Rectangle> {
760760- const bounds = new Map<number, Electron.Rectangle>();
761761- const allWindows = BrowserWindow.getAllWindows();
762762- for (const win of allWindows) {
763763- if (win.isDestroyed() || !win.isVisible()) continue;
764764- bounds.set(win.id, win.getBounds());
765765- }
766766- return bounds;
767767-}
768768-769769-/**
770226 * Debounced display change handler.
771227 * macOS fires multiple display events in quick succession (e.g. resolution + workArea).
772228 * We debounce to handle them as a single batch.
773773- *
774774- * @param isRemoval - if true, capture window bounds immediately before macOS moves them
775229 */
776776-function onDisplayChange(isRemoval = false): void {
777777- // On the FIRST event in a debounce window, capture window bounds eagerly.
778778- // macOS moves windows from disconnected displays before our debounced handler fires,
779779- // so we need the pre-move positions to correctly identify which display each window was on.
780780- if (!_debounceTimer && isRemoval) {
781781- _preDebounceWindowBounds = captureWindowBounds();
782782- console.log(`[display-watcher] Captured pre-debounce window bounds: ${_preDebounceWindowBounds.size} windows`);
783783- for (const [id, b] of _preDebounceWindowBounds) {
784784- console.log(`[display-watcher] window ${id}: (${b.x},${b.y} ${b.width}x${b.height})`);
785785- }
786786- }
787787-230230+function onDisplayChange(): void {
788231 if (_debounceTimer) {
789232 clearTimeout(_debounceTimer);
790233 }
791234 _debounceTimer = setTimeout(() => {
792235 _debounceTimer = null;
793236 handleDisplayChange();
794794- // Clear pre-debounce bounds after handling
795795- _preDebounceWindowBounds = null;
796237 }, DEBOUNCE_MS);
797238}
798239···817258818259 screen.on('display-removed', () => {
819260 console.log("[display-watcher] display-removed event");
820820- onDisplayChange(/* isRemoval */ true);
261261+ onDisplayChange();
821262 });
822263823264 screen.on('display-metrics-changed', (_event: Electron.Event, _display: Display, changedMetrics: string[]) => {
···832273}
833274834275/**
835835- * Start tracking a window's position for home display restoration.
836836- * Call this after creating any visible window. Attaches move/resize
837837- * listeners so we always know each window's true position and display,
838838- * even if macOS relocates windows before the display-removed event fires.
276276+ * Track a window for display change handling.
277277+ * Call this after creating any visible window.
278278+ * Attaches a closed listener to clean up.
839279 */
840280export function trackWindow(win: BrowserWindow): void {
841841- const updateTracking = () => {
842842- if (win.isDestroyed() || !win.isVisible()) return;
843843- const bounds = win.getBounds();
844844- const centerX = bounds.x + Math.round(bounds.width / 2);
845845- const centerY = bounds.y + Math.round(bounds.height / 2);
846846- // Use _previousDisplays (stable snapshot) to determine which display this window is on
847847- const display = findDisplayForPoint(_previousDisplays, centerX, centerY);
848848- if (display) {
849849- _trackedPositions.set(win.id, { bounds: { ...bounds }, displaySnapshot: display });
850850- }
851851- };
852852-853853- // Save initial position
854854- updateTracking();
855855-856856- // Update on moves — 'moved' fires at end of move on macOS
857857- win.on('moved', updateTracking);
858858- win.on('resize', updateTracking);
859859-860860- // Clean up on close
281281+ // Clean up on close (future-proofing for any per-window state)
861282 win.on('closed', () => {
862862- _trackedPositions.delete(win.id);
863863- _windowHomeDisplays.delete(win.id);
283283+ // No per-window state to clean up in the safety-net approach,
284284+ // but keeping the hook for extensibility.
864285 });
865286}
866287
+1
backend/electron/ipc.ts
···26372637 popupParams.groupMode = groupMode;
26382638 }
26392639 registerWindow(popupWin.id, source, popupParams);
26402640+ trackWindow(popupWin);
26402641 coordinator.pushWindow(popupWin.id);
2641264226422643 // Set mode context (inherit group mode or detect from URL)
+4
backend/electron/main.ts
···1616import { registerLocalShortcut, unregisterLocalShortcut, handleLocalShortcut, registerGlobalShortcut, unregisterGlobalShortcut, unregisterShortcutsForAddress } from './shortcuts.js';
1717import { scopes, publish, subscribe, setExtensionBroadcaster, getSystemAddress } from './pubsub.js';
1818import { APP_DEF_WIDTH, APP_DEF_HEIGHT, WEB_CORE_ADDRESS, getPreloadPath, isTestProfile, isDevProfile, isHeadless, getProfile, DEBUG } from './config.js';
1919+import { trackWindow } from './display-watcher.js';
1920import { addEscHandler, winDevtoolsConfig, closeOrHideWindow, getSystemThemeBackgroundColor, getPrefs } from './windows.js';
2021import { getProfileSession, getPartitionString, getCurrentProfileId } from './session-partition.js';
2122import { getIzuiCoordinator } from './izui-state.js';
···1045104610461047 // Add escape key handler
10471048 addEscHandler(newWin);
10491049+10501050+ // Track window for display change handling
10511051+ trackWindow(newWin);
1048105210491053 // Set up DevTools if requested
10501054 winDevtoolsConfig(newWin);
+1-13
backend/electron/session.ts
···1414import { BrowserWindow, dialog, screen } from 'electron';
1515import { getDb, getContextEntry, addContextEntry } from './datastore.js';
1616import { getAllWindows, getBackgroundWindow } from './main.js';
1717-import { suppressDisplayRepositioning } from "./display-watcher.js";
1717+1818import { publish, scopes as PubSubScopes, getSystemAddress } from './pubsub.js';
1919import { DEBUG, isTestProfile } from './config.js';
2020···534534 // Track which window was focused for later focus restoration
535535 let focusedWindowId: number | null = null;
536536537537- // Suppress display-watcher repositioning during restore.
538538- // macOS can fire display-metrics-changed (workArea) when the app becomes active
539539- // (e.g., dock visibility changes, menu bar adjustments). Without suppression,
540540- // the display-watcher would reposition newly restored windows using proportional
541541- // scaling, losing their exact saved positions (especially at screen edges).
542542- suppressDisplayRepositioning(true);
543543-544537 for (const descriptor of sortedWindows) {
545538 try {
546539 // Validate bounds: check if saved center point falls within any active display
···646639 }
647640 }
648641649649-650650- // Re-enable display-watcher repositioning now that all windows are restored.
651651- // This also refreshes the display snapshot so future display changes use
652652- // the correct baseline (with restored windows in their final positions).
653653- suppressDisplayRepositioning(false);
654642 // Check for partial restore — warn if majority of windows failed
655643 if (result.failed > 0 && result.total > 2 && result.failed > result.total / 2) {
656644 console.warn(`[session] Partial restore: ${result.failed}/${result.total} windows failed`);