experiments in a post-browser web
10
fork

Configure Feed

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

refactor(window-placement): Phase 1 — pure placement module + unit tests

+987
+613
backend/electron/window-placement.test.ts
··· 1 + /** 2 + * Unit tests for backend/electron/window-placement.ts — the pure 3 + * `computePlacement` function. All display data is fabricated via the 4 + * `fakeDisplays(...)` helper; no real Electron `screen` API is touched. 5 + * 6 + * Runs under ELECTRON_RUN_AS_NODE=1 via `yarn test:unit`. 7 + */ 8 + 9 + import { describe, it } from 'node:test'; 10 + import * as assert from 'node:assert'; 11 + 12 + import { 13 + computePlacement, 14 + type Placement, 15 + type PlacementInput, 16 + type PlacementResult, 17 + } from './window-placement.js'; 18 + 19 + // --------------------------------------------------------------------------- 20 + // fakeDisplays — the lever the whole sprint depends on 21 + // --------------------------------------------------------------------------- 22 + 23 + interface DisplaySpec { 24 + id: number; 25 + x: number; 26 + y: number; 27 + width: number; 28 + height: number; 29 + /** Optional: smaller workArea (excluding menu bar / dock). Defaults to bounds. */ 30 + workArea?: { x: number; y: number; width: number; height: number }; 31 + /** Optional: marks this as "primary" — for our purposes, primary just means 32 + * the display whose top-left is at (0, 0). The helper enforces that for 33 + * the spec marked `primary: true` (handy ergonomics, no semantic effect 34 + * beyond bounds.x/y values). */ 35 + primary?: boolean; 36 + } 37 + 38 + /** 39 + * Build an array shaped like `Electron.Display[]` from compact specs. 40 + * Only fields used by `window-placement.ts` are populated; the rest are 41 + * filled with sensible defaults so TypeScript stops complaining when we 42 + * cast to `Electron.Display`. 43 + */ 44 + function fakeDisplays(specs: DisplaySpec[]): Electron.Display[] { 45 + return specs.map(s => { 46 + if (s.primary && (s.x !== 0 || s.y !== 0)) { 47 + throw new Error(`fakeDisplays: spec marked primary but bounds origin is (${s.x},${s.y})`); 48 + } 49 + const bounds = { x: s.x, y: s.y, width: s.width, height: s.height }; 50 + const workArea = s.workArea ?? { ...bounds }; 51 + // Fields below aren't referenced by computePlacement, but Electron.Display 52 + // is a structural type — we cast `as unknown as Electron.Display` to skip 53 + // the noise. 54 + return { 55 + id: s.id, 56 + bounds, 57 + workArea, 58 + // Filler fields for type compatibility — never read. 59 + workAreaSize: { width: workArea.width, height: workArea.height }, 60 + size: { width: bounds.width, height: bounds.height }, 61 + scaleFactor: 1, 62 + rotation: 0, 63 + internal: false, 64 + monochrome: false, 65 + accelerometerSupport: 'unknown', 66 + colorDepth: 24, 67 + colorSpace: 'srgb', 68 + depthPerComponent: 8, 69 + displayFrequency: 60, 70 + touchSupport: 'unknown', 71 + label: `display-${s.id}`, 72 + } as unknown as Electron.Display; 73 + }); 74 + } 75 + 76 + // --------------------------------------------------------------------------- 77 + // Convenience builders 78 + // --------------------------------------------------------------------------- 79 + 80 + function pt(x: number, y: number): Electron.Point { 81 + return { x, y }; 82 + } 83 + 84 + function rect(x: number, y: number, width: number, height: number): Electron.Rectangle { 85 + return { x, y, width, height }; 86 + } 87 + 88 + /** Compute a display's workArea center. */ 89 + function centerOf(d: Electron.Display): Electron.Point { 90 + const wa = d.workArea; 91 + return { x: wa.x + Math.floor(wa.width / 2), y: wa.y + Math.floor(wa.height / 2) }; 92 + } 93 + 94 + /** 95 + * Construct a `PlacementInput` with sensible defaults you can override. 96 + * Keeps test code readable. 97 + */ 98 + function input(partial: Partial<PlacementInput> & Pick<PlacementInput, 'placement'>): PlacementInput { 99 + return { 100 + currentBounds: rect(0, 0, 800, 600), 101 + windowSize: { width: 800, height: 600 }, 102 + displays: fakeDisplays([{ id: 1, x: 0, y: 0, width: 1920, height: 1080, primary: true }]), 103 + cursorPoint: pt(960, 540), 104 + ...partial, 105 + }; 106 + } 107 + 108 + /** Pull bounds out of a result, asserting kind === reposition. */ 109 + function expectReposition(result: PlacementResult): Electron.Rectangle { 110 + assert.strictEqual(result.kind, 'reposition', `expected reposition, got ${result.kind}`); 111 + return (result as Extract<PlacementResult, { kind: 'reposition' }>).bounds; 112 + } 113 + 114 + function expectNoChange(result: PlacementResult): void { 115 + assert.strictEqual(result.kind, 'no-change', `expected no-change, got ${result.kind}`); 116 + } 117 + 118 + // --------------------------------------------------------------------------- 119 + // Common display fixtures 120 + // --------------------------------------------------------------------------- 121 + 122 + const displayA = { id: 1, x: 0, y: 0, width: 1920, height: 1080, primary: true } as DisplaySpec; 123 + const displayB = { id: 2, x: 1920, y: 0, width: 1920, height: 1080 } as DisplaySpec; 124 + const laptopOnly = { id: 1, x: 0, y: 0, width: 1440, height: 900, primary: true } as DisplaySpec; 125 + const externalUnplugged = { id: 99, x: 1920, y: 0, width: 1920, height: 1080 } as DisplaySpec; 126 + 127 + // --------------------------------------------------------------------------- 128 + // Tests 129 + // --------------------------------------------------------------------------- 130 + 131 + describe('computePlacement: centered mode', () => { 132 + it('1. centered on cursor display A, displays unchanged, cursor on A → no-change', () => { 133 + const displays = fakeDisplays([displayA, displayB]); 134 + // Window already centered on A: A.workArea is 0,0 1920x1080, window 800x600 135 + // centered → x=560, y=240 136 + const result = computePlacement(input({ 137 + placement: { mode: 'centered' }, 138 + currentBounds: rect(560, 240, 800, 600), 139 + displays, 140 + cursorPoint: pt(960, 540), // on A 141 + })); 142 + expectNoChange(result); 143 + }); 144 + 145 + it('2. centered on A, cursor moves to B → reposition to B center', () => { 146 + const displays = fakeDisplays([displayA, displayB]); 147 + const result = computePlacement(input({ 148 + placement: { mode: 'centered' }, 149 + currentBounds: rect(560, 240, 800, 600), // currently centered on A 150 + displays, 151 + cursorPoint: pt(2880, 540), // on B 152 + })); 153 + const bounds = expectReposition(result); 154 + // B.workArea is 1920,0 1920x1080; centered window: x=1920+560=2480, y=240 155 + assert.strictEqual(bounds.x, 2480); 156 + assert.strictEqual(bounds.y, 240); 157 + assert.strictEqual(bounds.width, 800); 158 + assert.strictEqual(bounds.height, 600); 159 + }); 160 + 161 + it('3. centered, A removed entirely → reposition to (now-only) display', () => { 162 + // Window was on A at (560, 240), but A is gone — only laptop display remains. 163 + // The window's old bounds at x=560 ARE inside the laptop display (0..1440), 164 + // so it's NOT stranded. But because mode is `centered`, we must always 165 + // re-center on cursor display every call. 166 + const displays = fakeDisplays([laptopOnly]); 167 + const result = computePlacement(input({ 168 + placement: { mode: 'centered' }, 169 + currentBounds: rect(560, 240, 800, 600), 170 + displays, 171 + cursorPoint: pt(720, 450), 172 + windowSize: { width: 800, height: 600 }, 173 + })); 174 + const bounds = expectReposition(result); 175 + // laptopOnly: 1440x900 → centered 800x600: x=320, y=150 176 + assert.strictEqual(bounds.x, 320); 177 + assert.strictEqual(bounds.y, 150); 178 + }); 179 + 180 + it('centered: cursor off all displays → falls back to primary (origin display)', () => { 181 + const displays = fakeDisplays([displayA, displayB]); 182 + const result = computePlacement(input({ 183 + placement: { mode: 'centered' }, 184 + currentBounds: rect(0, 0, 800, 600), 185 + displays, 186 + cursorPoint: pt(-9999, -9999), // nowhere 187 + })); 188 + const bounds = expectReposition(result); 189 + // Falls back to A (primary at 0,0): centered → x=560, y=240 190 + assert.strictEqual(bounds.x, 560); 191 + assert.strictEqual(bounds.y, 240); 192 + }); 193 + }); 194 + 195 + describe('computePlacement: cursor-display-fallback mode', () => { 196 + it('4. window currently fits, cursor moves to other display → no-change', () => { 197 + const displays = fakeDisplays([displayA, displayB]); 198 + const result = computePlacement(input({ 199 + placement: { mode: 'cursor-display-fallback' }, 200 + currentBounds: rect(100, 100, 800, 600), // on A, fits 201 + displays, 202 + cursorPoint: pt(2880, 540), // moved to B 203 + })); 204 + expectNoChange(result); 205 + }); 206 + 207 + it('5. window stranded (display unplugged) → reposition to cursor display center', () => { 208 + // Window was on the external monitor, which is now gone. 209 + const displays = fakeDisplays([laptopOnly]); 210 + const result = computePlacement(input({ 211 + placement: { mode: 'cursor-display-fallback' }, 212 + currentBounds: rect(2400, 200, 800, 600), // off-screen for laptop 213 + displays, 214 + cursorPoint: pt(720, 450), // on laptop 215 + })); 216 + const bounds = expectReposition(result); 217 + assert.strictEqual(bounds.x, 320); 218 + assert.strictEqual(bounds.y, 150); 219 + }); 220 + }); 221 + 222 + describe('computePlacement: edge mode', () => { 223 + it('6. edge: top, cursor on A → bounds anchored to A top edge, X-centered', () => { 224 + const displays = fakeDisplays([displayA, displayB]); 225 + const result = computePlacement(input({ 226 + placement: { mode: 'edge', edge: 'top' }, 227 + currentBounds: rect(0, 500, 600, 200), 228 + windowSize: { width: 600, height: 200 }, 229 + displays, 230 + cursorPoint: pt(960, 540), // on A 231 + })); 232 + const bounds = expectReposition(result); 233 + // A.workArea 0,0 1920x1080; window 600x200 anchored top, X-centered: 234 + // x = (1920 - 600) / 2 = 660, y = 0 235 + assert.strictEqual(bounds.x, 660); 236 + assert.strictEqual(bounds.y, 0); 237 + assert.strictEqual(bounds.width, 600); 238 + assert.strictEqual(bounds.height, 200); 239 + }); 240 + 241 + it('7. edge: top, cursor moves to B → re-anchor to B top edge', () => { 242 + const displays = fakeDisplays([displayA, displayB]); 243 + const result = computePlacement(input({ 244 + placement: { mode: 'edge', edge: 'top' }, 245 + currentBounds: rect(660, 0, 600, 200), // top of A 246 + windowSize: { width: 600, height: 200 }, 247 + displays, 248 + cursorPoint: pt(2880, 540), // on B 249 + })); 250 + const bounds = expectReposition(result); 251 + // B.workArea 1920,0 1920x1080; window 600x200 top, X-centered: 252 + // x = 1920 + (1920-600)/2 = 2580, y = 0 253 + assert.strictEqual(bounds.x, 2580); 254 + assert.strictEqual(bounds.y, 0); 255 + }); 256 + 257 + it('edge: bottom anchors to bottom edge', () => { 258 + const displays = fakeDisplays([displayA]); 259 + const result = computePlacement(input({ 260 + placement: { mode: 'edge', edge: 'bottom' }, 261 + currentBounds: rect(0, 0, 600, 200), 262 + windowSize: { width: 600, height: 200 }, 263 + displays, 264 + cursorPoint: pt(960, 540), 265 + })); 266 + const bounds = expectReposition(result); 267 + // y = 0 + 1080 - 200 = 880, X-centered → x = 660 268 + assert.strictEqual(bounds.x, 660); 269 + assert.strictEqual(bounds.y, 880); 270 + }); 271 + 272 + it('edge: left anchors to left edge, Y-centered', () => { 273 + const displays = fakeDisplays([displayA]); 274 + const result = computePlacement(input({ 275 + placement: { mode: 'edge', edge: 'left' }, 276 + currentBounds: rect(500, 0, 300, 600), 277 + windowSize: { width: 300, height: 600 }, 278 + displays, 279 + cursorPoint: pt(960, 540), 280 + })); 281 + const bounds = expectReposition(result); 282 + // x = 0, y = (1080-600)/2 = 240 283 + assert.strictEqual(bounds.x, 0); 284 + assert.strictEqual(bounds.y, 240); 285 + }); 286 + 287 + it('edge: right anchors to right edge, Y-centered', () => { 288 + const displays = fakeDisplays([displayA]); 289 + const result = computePlacement(input({ 290 + placement: { mode: 'edge', edge: 'right' }, 291 + currentBounds: rect(0, 0, 300, 600), 292 + windowSize: { width: 300, height: 600 }, 293 + displays, 294 + cursorPoint: pt(960, 540), 295 + })); 296 + const bounds = expectReposition(result); 297 + // x = 1920 - 300 = 1620, y = 240 298 + assert.strictEqual(bounds.x, 1620); 299 + assert.strictEqual(bounds.y, 240); 300 + }); 301 + 302 + it('edge: top, currently already at correct edge position → no-change', () => { 303 + const displays = fakeDisplays([displayA]); 304 + const result = computePlacement(input({ 305 + placement: { mode: 'edge', edge: 'top' }, 306 + currentBounds: rect(660, 0, 600, 200), 307 + windowSize: { width: 600, height: 200 }, 308 + displays, 309 + cursorPoint: pt(960, 540), 310 + })); 311 + expectNoChange(result); 312 + }); 313 + }); 314 + 315 + describe('computePlacement: parent-centered mode', () => { 316 + it('8. parent on A → centered on parent bounds', () => { 317 + const displays = fakeDisplays([displayA, displayB]); 318 + const parentBounds = rect(200, 200, 1000, 700); // on A 319 + const result = computePlacement(input({ 320 + placement: { mode: 'parent-centered', parentId: 42 }, 321 + currentBounds: rect(0, 0, 400, 300), 322 + windowSize: { width: 400, height: 300 }, 323 + displays, 324 + cursorPoint: pt(960, 540), 325 + parentBounds, 326 + })); 327 + const bounds = expectReposition(result); 328 + // Parent center: 700, 550. Child 400x300 centered on parent rect: 329 + // x = 200 + (1000-400)/2 = 500, y = 200 + (700-300)/2 = 400 330 + assert.strictEqual(bounds.x, 500); 331 + assert.strictEqual(bounds.y, 400); 332 + }); 333 + 334 + it('9. parent destroyed (parentBounds undefined) → no-change (cursor-display-fallback semantics)', () => { 335 + const displays = fakeDisplays([displayA]); 336 + const result = computePlacement(input({ 337 + placement: { mode: 'parent-centered', parentId: 42 }, 338 + currentBounds: rect(100, 100, 400, 300), // not stranded 339 + windowSize: { width: 400, height: 300 }, 340 + displays, 341 + cursorPoint: pt(960, 540), 342 + parentBounds: undefined, 343 + })); 344 + expectNoChange(result); 345 + }); 346 + 347 + it('parent destroyed AND stranded → rescue (stranded path takes priority)', () => { 348 + const displays = fakeDisplays([laptopOnly]); 349 + const result = computePlacement(input({ 350 + placement: { mode: 'parent-centered', parentId: 42 }, 351 + currentBounds: rect(2400, 200, 400, 300), // stranded 352 + windowSize: { width: 400, height: 300 }, 353 + displays, 354 + cursorPoint: pt(720, 450), 355 + parentBounds: undefined, 356 + })); 357 + const bounds = expectReposition(result); 358 + // Centered on laptopOnly: x=520, y=300 359 + assert.strictEqual(bounds.x, 520); 360 + assert.strictEqual(bounds.y, 300); 361 + }); 362 + 363 + it('parent on B, child currently on A → reposition to parent center on B', () => { 364 + const displays = fakeDisplays([displayA, displayB]); 365 + const parentBounds = rect(2200, 100, 800, 600); // on B 366 + const result = computePlacement(input({ 367 + placement: { mode: 'parent-centered', parentId: 42 }, 368 + currentBounds: rect(100, 100, 400, 300), // on A 369 + windowSize: { width: 400, height: 300 }, 370 + displays, 371 + cursorPoint: pt(960, 540), 372 + parentBounds, 373 + })); 374 + const bounds = expectReposition(result); 375 + // Parent center: 2600, 400. Child centered: x=2400, y=250 376 + assert.strictEqual(bounds.x, 2400); 377 + assert.strictEqual(bounds.y, 250); 378 + }); 379 + }); 380 + 381 + describe('computePlacement: manual mode', () => { 382 + it('10. manual, window fits on some display → no-change', () => { 383 + const displays = fakeDisplays([displayA, displayB]); 384 + const result = computePlacement(input({ 385 + placement: { mode: 'manual' }, 386 + currentBounds: rect(700, 300, 600, 400), // on A, comfortable 387 + displays, 388 + cursorPoint: pt(2880, 540), // cursor on B; manual ignores 389 + })); 390 + expectNoChange(result); 391 + }); 392 + 393 + it('11. manual, window stranded → rescue to cursor display center', () => { 394 + // Stranded rescue applies to ALL modes including manual. 395 + const displays = fakeDisplays([laptopOnly]); 396 + const result = computePlacement(input({ 397 + placement: { mode: 'manual' }, 398 + currentBounds: rect(externalUnplugged.x + 100, 100, 800, 600), // off-screen 399 + windowSize: { width: 800, height: 600 }, 400 + displays, 401 + cursorPoint: pt(720, 450), 402 + })); 403 + const bounds = expectReposition(result); 404 + assert.strictEqual(bounds.x, 320); 405 + assert.strictEqual(bounds.y, 150); 406 + }); 407 + }); 408 + 409 + describe('computePlacement: clamping & edge cases', () => { 410 + it('12. window size larger than target display → output clamped to workArea', () => { 411 + const displays = fakeDisplays([laptopOnly]); // 1440x900 412 + const result = computePlacement(input({ 413 + placement: { mode: 'centered' }, 414 + currentBounds: rect(0, 0, 1, 1), 415 + windowSize: { width: 4000, height: 3000 }, // huge 416 + displays, 417 + cursorPoint: pt(720, 450), 418 + })); 419 + const bounds = expectReposition(result); 420 + // Clamped to workArea (1440x900), then centered: x=0, y=0 421 + assert.strictEqual(bounds.width, 1440); 422 + assert.strictEqual(bounds.height, 900); 423 + assert.strictEqual(bounds.x, 0); 424 + assert.strictEqual(bounds.y, 0); 425 + }); 426 + 427 + it('zero-size window is never stranded (treated as hidden/minimized)', () => { 428 + const displays = fakeDisplays([laptopOnly]); 429 + const result = computePlacement(input({ 430 + placement: { mode: 'manual' }, 431 + currentBounds: rect(99999, 99999, 0, 0), // way off, but zero size 432 + displays, 433 + cursorPoint: pt(720, 450), 434 + })); 435 + // Manual + not stranded (zero area is treated as "always accessible") → no-change 436 + expectNoChange(result); 437 + }); 438 + 439 + it('empty displays array → no-change for every mode', () => { 440 + const modes: Placement[] = [ 441 + { mode: 'centered' }, 442 + { mode: 'cursor-display-fallback' }, 443 + { mode: 'edge', edge: 'top' }, 444 + { mode: 'parent-centered', parentId: 1 }, 445 + { mode: 'manual' }, 446 + ]; 447 + for (const placement of modes) { 448 + const result = computePlacement(input({ 449 + placement, 450 + displays: [], 451 + cursorPoint: pt(0, 0), 452 + })); 453 + expectNoChange(result); 454 + } 455 + }); 456 + 457 + it('point not on any display: cursor falls back to primary (origin display)', () => { 458 + const displays = fakeDisplays([displayA, displayB]); 459 + const result = computePlacement(input({ 460 + placement: { mode: 'centered' }, 461 + currentBounds: rect(0, 0, 800, 600), 462 + displays, 463 + cursorPoint: pt(99999, 99999), // far away 464 + })); 465 + // Should still produce a valid centering on A (primary). 466 + const bounds = expectReposition(result); 467 + assert.ok(bounds.x >= 0 && bounds.x < 1920); 468 + assert.ok(bounds.y >= 0 && bounds.y < 1080); 469 + }); 470 + 471 + it('workArea smaller than bounds (menu bar / dock zones excluded)', () => { 472 + // Display at (0,0) 1920x1080 with a 25px menu bar at top. 473 + const displays = fakeDisplays([ 474 + { 475 + id: 1, x: 0, y: 0, width: 1920, height: 1080, primary: true, 476 + workArea: { x: 0, y: 25, width: 1920, height: 1055 }, 477 + }, 478 + ]); 479 + const result = computePlacement(input({ 480 + placement: { mode: 'centered' }, 481 + currentBounds: rect(0, 0, 800, 600), 482 + displays, 483 + cursorPoint: pt(960, 540), 484 + })); 485 + const bounds = expectReposition(result); 486 + // workArea 0,25 1920x1055; centered 800x600: 487 + // x = 560, y = 25 + (1055-600)/2 = 25 + 227 = 252 (rounded from 227.5) 488 + assert.strictEqual(bounds.x, 560); 489 + assert.strictEqual(bounds.y, 253); 490 + }); 491 + 492 + it('stranded window with negative coordinates → rescue', () => { 493 + const displays = fakeDisplays([laptopOnly]); 494 + const result = computePlacement(input({ 495 + placement: { mode: 'cursor-display-fallback' }, 496 + currentBounds: rect(-5000, -5000, 800, 600), 497 + displays, 498 + cursorPoint: pt(720, 450), 499 + })); 500 + const bounds = expectReposition(result); 501 + assert.strictEqual(bounds.x, 320); 502 + assert.strictEqual(bounds.y, 150); 503 + }); 504 + 505 + it('stranded threshold: 49% overlap → rescue', () => { 506 + // Display 1000x1000, window 1000x1000 with center at (500, 500)+offset. 507 + // Place window so 49% of its area is on the display. 508 + // Window 100x100, on display only at right strip 49 wide: 509 + // Window at x=951, y=0, width=100, height=100: overlap is 49x100 = 4900, area 10000 → 49%. 510 + const displays = fakeDisplays([ 511 + { id: 1, x: 0, y: 0, width: 1000, height: 1000, primary: true }, 512 + ]); 513 + const result = computePlacement(input({ 514 + placement: { mode: 'manual' }, 515 + currentBounds: rect(951, 0, 100, 100), 516 + windowSize: { width: 100, height: 100 }, 517 + displays, 518 + cursorPoint: pt(500, 500), 519 + })); 520 + const bounds = expectReposition(result); 521 + // Centered: x=450, y=450 522 + assert.strictEqual(bounds.x, 450); 523 + assert.strictEqual(bounds.y, 450); 524 + }); 525 + 526 + it('stranded threshold: 51% overlap → no rescue (manual stays put)', () => { 527 + // Window at x=949, width=100 → overlap is 51 wide on display (0..1000). 528 + // 51 * 100 = 5100 / 10000 = 51% → above threshold, manual leaves alone. 529 + const displays = fakeDisplays([ 530 + { id: 1, x: 0, y: 0, width: 1000, height: 1000, primary: true }, 531 + ]); 532 + const result = computePlacement(input({ 533 + placement: { mode: 'manual' }, 534 + currentBounds: rect(949, 0, 100, 100), 535 + windowSize: { width: 100, height: 100 }, 536 + displays, 537 + cursorPoint: pt(500, 500), 538 + })); 539 + expectNoChange(result); 540 + }); 541 + 542 + it('parent-centered: parent center off all displays falls back to cursor display', () => { 543 + const displays = fakeDisplays([displayA]); 544 + const parentBounds = rect(99000, 99000, 400, 300); // way off 545 + const result = computePlacement(input({ 546 + placement: { mode: 'parent-centered', parentId: 42 }, 547 + currentBounds: rect(100, 100, 200, 150), 548 + windowSize: { width: 200, height: 150 }, 549 + displays, 550 + cursorPoint: pt(960, 540), 551 + parentBounds, 552 + })); 553 + // Still proposes bounds: child centered on parent rect (parent rect is the 554 + // recorded position even if off-screen). The point of the targetDisplay 555 + // fallback is just to choose a clamping workArea — clamping uses A. 556 + const bounds = expectReposition(result); 557 + assert.strictEqual(bounds.width, 200); 558 + assert.strictEqual(bounds.height, 150); 559 + }); 560 + 561 + it('cursor-display-fallback when window already centered on cursor display → no-change', () => { 562 + const displays = fakeDisplays([displayA]); 563 + const result = computePlacement(input({ 564 + placement: { mode: 'cursor-display-fallback' }, 565 + currentBounds: rect(560, 240, 800, 600), 566 + displays, 567 + cursorPoint: pt(960, 540), 568 + })); 569 + expectNoChange(result); 570 + }); 571 + 572 + it('centered: re-center is exact pixel match → no spurious reposition', () => { 573 + const displays = fakeDisplays([displayA]); 574 + // Compute what centered should yield, then feed it back. 575 + const expected = rect(560, 240, 800, 600); 576 + const result = computePlacement(input({ 577 + placement: { mode: 'centered' }, 578 + currentBounds: expected, 579 + displays, 580 + cursorPoint: pt(960, 540), 581 + })); 582 + expectNoChange(result); 583 + }); 584 + }); 585 + 586 + describe('computePlacement: regression coverage from plan doc', () => { 587 + it('"External monitor unplugged, cmd panel still on the laptop screen but at old external coordinates"', () => { 588 + const displays = fakeDisplays([laptopOnly]); 589 + const result = computePlacement(input({ 590 + placement: { mode: 'centered' }, 591 + currentBounds: rect(2200, 200, 800, 600), // on the (now-missing) external 592 + displays, 593 + cursorPoint: centerOf(fakeDisplays([laptopOnly])[0]), 594 + })); 595 + const bounds = expectReposition(result); 596 + assert.strictEqual(bounds.x, 320); 597 + assert.strictEqual(bounds.y, 150); 598 + }); 599 + 600 + it('"Slide opened, user moves to second display, slide stays on first"', () => { 601 + const displays = fakeDisplays([displayA, displayB]); 602 + const result = computePlacement(input({ 603 + placement: { mode: 'edge', edge: 'top' }, 604 + currentBounds: rect(660, 0, 600, 200), // top of A 605 + windowSize: { width: 600, height: 200 }, 606 + displays, 607 + cursorPoint: pt(2880, 540), // user has moved to B 608 + })); 609 + const bounds = expectReposition(result); 610 + assert.strictEqual(bounds.x, 2580); // top of B, X-centered 611 + assert.strictEqual(bounds.y, 0); 612 + }); 613 + });
+374
backend/electron/window-placement.ts
··· 1 + /** 2 + * Window Placement — Pure Module 3 + * 4 + * Single source of truth for "where should this window go?" decisions. 5 + * Pure function from inputs to output: no `screen.*` calls, no 6 + * `BrowserWindow.*` references, no Electron runtime imports. The caller 7 + * is responsible for collecting the current display layout, cursor point, 8 + * window bounds, and parent bounds, and for applying the result via 9 + * `setBounds()` (only when `kind === 'reposition'`). 10 + * 11 + * This makes display-change behavior unit-testable without real 12 + * multi-monitor hardware — see window-placement.test.ts. 13 + * 14 + * See docs/window-placement-refactor.md for the sprint plan and the 15 + * full mapping of legacy flags (`center: true`, explicit `x`/`y`, 16 + * `screenEdge`, parent windows) to `Placement` modes. 17 + */ 18 + // 19 + // NOTE: Type-only imports of `Electron.Display`, `Electron.Rectangle`, 20 + // `Electron.Point` are fine — they're erased at compile time and don't 21 + // pull the Electron runtime into this module. Tests fabricate display 22 + // objects without touching the real `screen` API. 23 + 24 + /** 25 + * Discriminated union describing where a window wants to live. 26 + * 27 + * Recorded **once at window-open time** on `windowRegistry.params.placement` 28 + * and consulted on every reuse / display-change pass thereafter. 29 + */ 30 + export type Placement = 31 + | { mode: 'centered' } 32 + // Always centered on the cursor's display, every time the window 33 + // is shown. cmd panel, modal palettes, "center: true" callers. 34 + | { mode: 'cursor-display-fallback' } 35 + // Centered on the cursor display ONLY if no explicit position has 36 + // been observed yet. Once positioned, stays put across reuse — 37 + // unless stranded (see below). Page-host default. 38 + | { mode: 'edge'; edge: 'top' | 'bottom' | 'left' | 'right' } 39 + // Anchored to one edge of the cursor's display, every show. Slides. 40 + | { mode: 'parent-centered'; parentId: number } 41 + // Centered on the parent window's bounds. Quick-views, child dialogs. 42 + | { mode: 'manual' }; 43 + // User-positioned (drag, manual setBounds). Never auto-moved 44 + // EXCEPT when stranded. 45 + 46 + export interface PlacementInput { 47 + /** The placement intent recorded at open time. */ 48 + placement: Placement; 49 + /** Window's bounds right now (used for "is this still placed correctly?" check). */ 50 + currentBounds: Electron.Rectangle; 51 + /** Desired/intrinsic size — used when computing new bounds. */ 52 + windowSize: { width: number; height: number }; 53 + /** Snapshot of all displays (caller obtains via `screen.getAllDisplays()`). */ 54 + displays: Electron.Display[]; 55 + /** Cursor screen point (caller obtains via `screen.getCursorScreenPoint()`). */ 56 + cursorPoint: Electron.Point; 57 + /** Required for `parent-centered`. Undefined if parent window has been destroyed. */ 58 + parentBounds?: Electron.Rectangle; 59 + } 60 + 61 + export type PlacementResult = 62 + | { kind: 'no-change' } 63 + | { kind: 'reposition'; bounds: Electron.Rectangle }; 64 + 65 + // --------------------------------------------------------------------------- 66 + // Constants & helpers 67 + // --------------------------------------------------------------------------- 68 + 69 + /** 70 + * If a window has less than this fraction of its area on any display, it's 71 + * considered "stranded" and gets force-rescued to the cursor display 72 + * regardless of placement mode. Replaces the historical 30% threshold from 73 + * `display-watcher.ts` and `isWindowAccessibleNow` in `ipc.ts`. Bumped to 74 + * 50% so we rescue more aggressively — half the window off-screen is 75 + * effectively unusable. 76 + */ 77 + const STRANDED_THRESHOLD = 0.5; 78 + 79 + /** 80 + * Find the display containing a point. Tries `workArea` first (avoids 81 + * counting menu-bar / dock zones as "on display"); falls back to `bounds` 82 + * so that a point in the OS chrome zone still resolves correctly. 83 + * 84 + * Returns `null` if the point is not on any display. 85 + */ 86 + function findDisplayForPoint( 87 + displays: Electron.Display[], 88 + point: Electron.Point, 89 + ): Electron.Display | null { 90 + for (const d of displays) { 91 + const wa = d.workArea; 92 + if ( 93 + point.x >= wa.x && point.x < wa.x + wa.width && 94 + point.y >= wa.y && point.y < wa.y + wa.height 95 + ) { 96 + return d; 97 + } 98 + } 99 + for (const d of displays) { 100 + const b = d.bounds; 101 + if ( 102 + point.x >= b.x && point.x < b.x + b.width && 103 + point.y >= b.y && point.y < b.y + b.height 104 + ) { 105 + return d; 106 + } 107 + } 108 + return null; 109 + } 110 + 111 + /** 112 + * Pick the cursor's display, with sensible fallbacks. Prefer the display 113 + * the cursor is on; otherwise the first display marked primary by id (we 114 + * use bounds.x === 0 && bounds.y === 0 as a heuristic — Electron's 115 + * `screen.getPrimaryDisplay()` returns the display whose top-left is at 116 + * (0,0) on macOS); otherwise the first display in the list. Returns 117 + * `null` only if `displays` is empty. 118 + */ 119 + function pickCursorOrPrimaryDisplay( 120 + displays: Electron.Display[], 121 + cursorPoint: Electron.Point, 122 + ): Electron.Display | null { 123 + if (displays.length === 0) return null; 124 + const onCursor = findDisplayForPoint(displays, cursorPoint); 125 + if (onCursor) return onCursor; 126 + const atOrigin = displays.find(d => d.bounds.x === 0 && d.bounds.y === 0); 127 + return atOrigin ?? displays[0]; 128 + } 129 + 130 + /** 131 + * Return the fraction (0..1) of `bounds`' area that overlaps any display's 132 + * workArea. Zero-size windows are reported as 1 (fully accessible) — they 133 + * can't be stranded, they're hidden/minimized. 134 + */ 135 + function maxOverlapFraction( 136 + displays: Electron.Display[], 137 + bounds: Electron.Rectangle, 138 + ): number { 139 + const area = bounds.width * bounds.height; 140 + if (area <= 0) return 1; 141 + 142 + let best = 0; 143 + for (const d of displays) { 144 + const wa = d.workArea; 145 + const overlapX = Math.max(bounds.x, wa.x); 146 + const overlapY = Math.max(bounds.y, wa.y); 147 + const overlapRight = Math.min(bounds.x + bounds.width, wa.x + wa.width); 148 + const overlapBottom = Math.min(bounds.y + bounds.height, wa.y + wa.height); 149 + if (overlapRight > overlapX && overlapBottom > overlapY) { 150 + const overlap = (overlapRight - overlapX) * (overlapBottom - overlapY); 151 + const frac = overlap / area; 152 + if (frac > best) best = frac; 153 + } 154 + } 155 + return best; 156 + } 157 + 158 + /** Window has < STRANDED_THRESHOLD area on any display → needs rescue. */ 159 + function isStranded(displays: Electron.Display[], bounds: Electron.Rectangle): boolean { 160 + if (displays.length === 0) return false; // can't rescue if there are no displays 161 + return maxOverlapFraction(displays, bounds) < STRANDED_THRESHOLD; 162 + } 163 + 164 + /** 165 + * Clamp a desired size to never exceed the workArea. Output dimensions are 166 + * `min(desired, workArea)` — we never grow a window, only shrink to fit. 167 + */ 168 + function clampSize( 169 + size: { width: number; height: number }, 170 + workArea: Electron.Rectangle, 171 + ): { width: number; height: number } { 172 + return { 173 + width: Math.min(size.width, workArea.width), 174 + height: Math.min(size.height, workArea.height), 175 + }; 176 + } 177 + 178 + /** Center a (clamped) size on a workArea. */ 179 + function centerOn( 180 + workArea: Electron.Rectangle, 181 + size: { width: number; height: number }, 182 + ): Electron.Rectangle { 183 + return { 184 + x: workArea.x + Math.round((workArea.width - size.width) / 2), 185 + y: workArea.y + Math.round((workArea.height - size.height) / 2), 186 + width: size.width, 187 + height: size.height, 188 + }; 189 + } 190 + 191 + /** Center a (clamped) size on an arbitrary rectangle (e.g. parent bounds). */ 192 + function centerOnRect( 193 + rect: Electron.Rectangle, 194 + size: { width: number; height: number }, 195 + ): Electron.Rectangle { 196 + return { 197 + x: rect.x + Math.round((rect.width - size.width) / 2), 198 + y: rect.y + Math.round((rect.height - size.height) / 2), 199 + width: size.width, 200 + height: size.height, 201 + }; 202 + } 203 + 204 + /** Anchor a (clamped) size to one edge of a workArea. */ 205 + function anchorToEdge( 206 + workArea: Electron.Rectangle, 207 + size: { width: number; height: number }, 208 + edge: 'top' | 'bottom' | 'left' | 'right', 209 + ): Electron.Rectangle { 210 + // Design decision (not specified explicitly in plan): for top/bottom we 211 + // X-center; for left/right we Y-center. The "other axis" defaults to 212 + // centered on the cursor display. This matches what the slides feature 213 + // was doing in renderer math before this refactor. 214 + switch (edge) { 215 + case 'top': 216 + return { 217 + x: workArea.x + Math.round((workArea.width - size.width) / 2), 218 + y: workArea.y, 219 + width: size.width, 220 + height: size.height, 221 + }; 222 + case 'bottom': 223 + return { 224 + x: workArea.x + Math.round((workArea.width - size.width) / 2), 225 + y: workArea.y + workArea.height - size.height, 226 + width: size.width, 227 + height: size.height, 228 + }; 229 + case 'left': 230 + return { 231 + x: workArea.x, 232 + y: workArea.y + Math.round((workArea.height - size.height) / 2), 233 + width: size.width, 234 + height: size.height, 235 + }; 236 + case 'right': 237 + return { 238 + x: workArea.x + workArea.width - size.width, 239 + y: workArea.y + Math.round((workArea.height - size.height) / 2), 240 + width: size.width, 241 + height: size.height, 242 + }; 243 + } 244 + } 245 + 246 + /** Bounds-equality test — used to short-circuit `setBounds()` calls. */ 247 + function rectsEqual(a: Electron.Rectangle, b: Electron.Rectangle): boolean { 248 + return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height; 249 + } 250 + 251 + /** 252 + * Helper that yields a `PlacementResult`: returns `no-change` when the 253 + * computed bounds match `currentBounds`, otherwise `reposition`. 254 + */ 255 + function reposeOrNoChange( 256 + currentBounds: Electron.Rectangle, 257 + proposed: Electron.Rectangle, 258 + ): PlacementResult { 259 + if (rectsEqual(currentBounds, proposed)) return { kind: 'no-change' }; 260 + return { kind: 'reposition', bounds: proposed }; 261 + } 262 + 263 + // --------------------------------------------------------------------------- 264 + // Main entry point 265 + // --------------------------------------------------------------------------- 266 + 267 + /** 268 + * Decide where a window should go, given its placement intent + current 269 + * display layout + cursor. 270 + * 271 + * Contract: 272 + * - Pure. No global state, no Electron runtime calls. 273 + * - Returns `{ kind: 'no-change' }` if the window is already correctly 274 + * placed for its mode → caller skips `setBounds()`. 275 + * - For every mode, "stranded" rescue takes priority: if `currentBounds` 276 + * has less than 50% area overlap with any display, ALWAYS reposition 277 + * to the cursor display center, regardless of mode. (Replaces the old 278 + * `isWindowAccessibleNow` + `repositionOnCursorDisplay` helpers.) 279 + * - Output bounds are always clamped so width/height never exceed the 280 + * chosen display's workArea. 281 + * - If `displays` is empty, returns `no-change` — there's nowhere to 282 + * reposition to. Caller should treat this as "wait until displays 283 + * come back." 284 + */ 285 + export function computePlacement(input: PlacementInput): PlacementResult { 286 + const { placement, currentBounds, windowSize, displays, cursorPoint, parentBounds } = input; 287 + 288 + // No displays at all → nothing we can do. Early out before anything else. 289 + if (displays.length === 0) return { kind: 'no-change' }; 290 + 291 + // ------------------------------------------------------------------------- 292 + // Stranded-rescue path (priority over every mode, including 'manual'). 293 + // If the window is currently <50% on any display, force-reposition to the 294 + // cursor display center using the requested windowSize. This unifies what 295 + // the old `isWindowAccessibleNow` did for general windows with what the 296 + // `center: true` rescue did for cmd-style windows. 297 + // ------------------------------------------------------------------------- 298 + if (isStranded(displays, currentBounds)) { 299 + const target = pickCursorOrPrimaryDisplay(displays, cursorPoint); 300 + if (!target) return { kind: 'no-change' }; // unreachable given displays.length>0 301 + const size = clampSize(windowSize, target.workArea); 302 + const proposed = centerOn(target.workArea, size); 303 + return reposeOrNoChange(currentBounds, proposed); 304 + } 305 + 306 + // ------------------------------------------------------------------------- 307 + // Per-mode placement. 308 + // ------------------------------------------------------------------------- 309 + switch (placement.mode) { 310 + 311 + case 'centered': { 312 + // Always re-center on the cursor display, every call. cmd panel 313 + // semantics — follow the user. 314 + const target = pickCursorOrPrimaryDisplay(displays, cursorPoint); 315 + if (!target) return { kind: 'no-change' }; 316 + const size = clampSize(windowSize, target.workArea); 317 + const proposed = centerOn(target.workArea, size); 318 + return reposeOrNoChange(currentBounds, proposed); 319 + } 320 + 321 + case 'cursor-display-fallback': { 322 + // "Center on cursor display only if not yet placed." We approximate 323 + // "not yet placed" as "currentBounds happen to match what cursor- 324 + // -display-centering would produce" → no change. Otherwise: the 325 + // window is already positioned somewhere reasonable (we already 326 + // ruled out stranded above), so leave it. 327 + // 328 + // Design decision (not specified explicitly in plan): once a window 329 + // has any non-default position, we honor it. The stranded path 330 + // above handles the "display unplugged" case. This matches the 331 + // page-host behavior we want — open on cursor display, then stay 332 + // put across reuse. 333 + return { kind: 'no-change' }; 334 + } 335 + 336 + case 'edge': { 337 + // Anchor to one edge of the cursor display, every show. Re-anchor 338 + // when the cursor moves to a different display. 339 + const target = pickCursorOrPrimaryDisplay(displays, cursorPoint); 340 + if (!target) return { kind: 'no-change' }; 341 + const size = clampSize(windowSize, target.workArea); 342 + const proposed = anchorToEdge(target.workArea, size, placement.edge); 343 + return reposeOrNoChange(currentBounds, proposed); 344 + } 345 + 346 + case 'parent-centered': { 347 + // If parent bounds were not provided (parent destroyed), fall 348 + // through to cursor-display-fallback semantics: leave the window 349 + // alone unless stranded (which we already handled). Design decision 350 + // confirmed in the plan doc. 351 + if (!parentBounds) { 352 + return { kind: 'no-change' }; 353 + } 354 + // Center on parent. Clamp to the display that contains the parent's 355 + // center; if no display contains it, fall back to cursor display. 356 + const parentCenter: Electron.Point = { 357 + x: parentBounds.x + Math.round(parentBounds.width / 2), 358 + y: parentBounds.y + Math.round(parentBounds.height / 2), 359 + }; 360 + const targetDisplay = 361 + findDisplayForPoint(displays, parentCenter) ?? 362 + pickCursorOrPrimaryDisplay(displays, cursorPoint); 363 + if (!targetDisplay) return { kind: 'no-change' }; 364 + const size = clampSize(windowSize, targetDisplay.workArea); 365 + const proposed = centerOnRect(parentBounds, size); 366 + return reposeOrNoChange(currentBounds, proposed); 367 + } 368 + 369 + case 'manual': { 370 + // User-positioned. Never auto-move (stranded already handled). 371 + return { kind: 'no-change' }; 372 + } 373 + } 374 + }