@flighthq/screen 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,910 @@
1
+ import type { ScreenBackend, ScreenChangeEvent, ScreenInfo, ScreenMode, ScreenSignals } from '@flighthq/types';
2
+
3
+ import {
4
+ attachScreenSignals,
5
+ createScreenInfo,
6
+ createScreenMode,
7
+ createScreenSignals,
8
+ createWebScreenBackend,
9
+ detachScreenSignals,
10
+ dipToScreenPoint,
11
+ dipToScreenRect,
12
+ disposeScreenSignals,
13
+ enableScreenSignals,
14
+ getPrimaryScreen,
15
+ getScreenBackend,
16
+ getScreenBounds,
17
+ getScreenById,
18
+ getScreenContainingRect,
19
+ getScreenCurrentMode,
20
+ getScreenCursorPosition,
21
+ getScreenCursorScreen,
22
+ getScreenDetailPermission,
23
+ getScreenModes,
24
+ getScreenNearestPoint,
25
+ getScreenNearestRect,
26
+ getScreens,
27
+ getScreenWorkArea,
28
+ onScreenChange,
29
+ onScreenDetailPermissionChange,
30
+ refreshScreens,
31
+ requestScreenDetails,
32
+ screenToDipPoint,
33
+ screenToDipRect,
34
+ setScreenBackend,
35
+ } from './screen';
36
+ function makeScreenInfo(overrides: Partial<ScreenInfo> = {}): ScreenInfo {
37
+ return { ...createScreenInfo(), ...overrides };
38
+ }
39
+
40
+ function fakeBackend(screens: Partial<ScreenInfo>[]): ScreenBackend & {
41
+ fire: (event?: Partial<ScreenChangeEvent>) => void;
42
+ } {
43
+ let listener: ((event: Readonly<ScreenChangeEvent>) => void) | null = null;
44
+ const infos: ScreenInfo[] = screens.map((s, i) => makeScreenInfo({ id: i, isPrimary: i === 0, ...s }));
45
+ return {
46
+ getScreens(out) {
47
+ out.length = infos.length;
48
+ for (let i = 0; i < infos.length; i += 1) {
49
+ if (out[i] === undefined) out[i] = createScreenInfo();
50
+ Object.assign(out[i], infos[i]);
51
+ }
52
+ return out;
53
+ },
54
+ getPrimaryScreen(out) {
55
+ const primary = infos.find((s) => s.isPrimary) ?? infos[0];
56
+ if (primary !== undefined) Object.assign(out, primary);
57
+ return out;
58
+ },
59
+ subscribe(l) {
60
+ listener = l;
61
+ return () => {
62
+ listener = null;
63
+ };
64
+ },
65
+ getCursorPosition(out) {
66
+ out.x = 42;
67
+ out.y = 84;
68
+ return out;
69
+ },
70
+ fire(event?: Partial<ScreenChangeEvent>) {
71
+ const defaultScreen = infos[0] ?? createScreenInfo();
72
+ listener?.({
73
+ kind: 'ScreenMetricsChanged',
74
+ screen: defaultScreen,
75
+ changedMetrics: { bounds: true, workArea: false, scaleFactor: false, orientation: false },
76
+ ...event,
77
+ });
78
+ },
79
+ };
80
+ }
81
+
82
+ afterEach(() => setScreenBackend(null));
83
+ describe('attachScreenSignals', () => {
84
+ it('fans out ScreenAdded to onScreenAdded signal', () => {
85
+ const backend = fakeBackend([{ width: 1920, height: 1080 }]);
86
+ setScreenBackend(backend);
87
+ const signals = createScreenSignals();
88
+ const received: ScreenInfo[] = [];
89
+ signals.onScreenAdded.emit = (s) => received.push(s);
90
+ attachScreenSignals(signals);
91
+ backend.fire({ kind: 'ScreenAdded', changedMetrics: null });
92
+ expect(received).toHaveLength(1);
93
+ detachScreenSignals(signals);
94
+ });
95
+
96
+ it('fans out ScreenRemoved to onScreenRemoved signal', () => {
97
+ const backend = fakeBackend([{}]);
98
+ setScreenBackend(backend);
99
+ const signals = createScreenSignals();
100
+ const removed: ScreenInfo[] = [];
101
+ signals.onScreenRemoved.emit = (s) => removed.push(s);
102
+ attachScreenSignals(signals);
103
+ backend.fire({ kind: 'ScreenRemoved', changedMetrics: null });
104
+ expect(removed).toHaveLength(1);
105
+ detachScreenSignals(signals);
106
+ });
107
+
108
+ it('fans out ScreenMetricsChanged to onScreenMetricsChanged signal', () => {
109
+ const backend = fakeBackend([{}]);
110
+ setScreenBackend(backend);
111
+ const signals = createScreenSignals();
112
+ const events: ScreenChangeEvent[] = [];
113
+ signals.onScreenMetricsChanged.emit = (e) => events.push(e);
114
+ attachScreenSignals(signals);
115
+ backend.fire();
116
+ expect(events).toHaveLength(1);
117
+ expect(events[0].kind).toBe('ScreenMetricsChanged');
118
+ detachScreenSignals(signals);
119
+ });
120
+
121
+ it('is idempotent — tears down prior subscription before re-attaching', () => {
122
+ const backend = fakeBackend([{}]);
123
+ setScreenBackend(backend);
124
+ const signals = createScreenSignals();
125
+ let count = 0;
126
+ signals.onScreenMetricsChanged.emit = () => count++;
127
+ attachScreenSignals(signals);
128
+ attachScreenSignals(signals); // second attach: should not double-fire
129
+ backend.fire();
130
+ expect(count).toBe(1);
131
+ detachScreenSignals(signals);
132
+ });
133
+ });
134
+ describe('createScreenInfo', () => {
135
+ it('allocates with scaleFactor 1 and isPrimary false', () => {
136
+ const info = createScreenInfo();
137
+ expect(info.scaleFactor).toBe(1);
138
+ expect(info.isPrimary).toBe(false);
139
+ expect(info.width).toBe(0);
140
+ expect(info.height).toBe(0);
141
+ });
142
+
143
+ it('defaults sentinel fields to -1 and empty/false', () => {
144
+ const info = createScreenInfo();
145
+ expect(info.rotation).toBe(-1);
146
+ expect(info.refreshRate).toBe(-1);
147
+ expect(info.colorDepth).toBe(-1);
148
+ expect(info.pixelDepth).toBe(-1);
149
+ expect(info.physicalWidth).toBe(-1);
150
+ expect(info.physicalHeight).toBe(-1);
151
+ expect(info.maxLuminance).toBe(-1);
152
+ expect(info.depthPerComponent).toBe(-1);
153
+ expect(info.dpi).toBe(-1);
154
+ expect(info.label).toBe('');
155
+ expect(info.colorSpace).toBe('srgb');
156
+ expect(info.isHdr).toBe(false);
157
+ expect(info.internal).toBe(false);
158
+ expect(info.monochrome).toBe(false);
159
+ expect(info.touchSupport).toBe('unknown');
160
+ expect(info.orientation).toBe('Landscape');
161
+ });
162
+ });
163
+ describe('createScreenMode', () => {
164
+ it('allocates with sentinel fields', () => {
165
+ const mode = createScreenMode();
166
+ expect(mode.width).toBe(0);
167
+ expect(mode.height).toBe(0);
168
+ expect(mode.refreshRate).toBe(-1);
169
+ expect(mode.colorDepth).toBe(-1);
170
+ expect(mode.pixelFormat).toBe('');
171
+ });
172
+ });
173
+ describe('createScreenSignals', () => {
174
+ it('allocates inert signals', () => {
175
+ const signals = createScreenSignals();
176
+ expect(signals.onScreenAdded).toBeDefined();
177
+ expect(signals.onScreenRemoved).toBeDefined();
178
+ expect(signals.onScreenMetricsChanged).toBeDefined();
179
+ });
180
+ });
181
+ describe('createWebScreenBackend', () => {
182
+ it('returns a backend whose reads fill out without throwing', () => {
183
+ const backend = createWebScreenBackend();
184
+ const primary = createScreenInfo();
185
+ expect(() => backend.getPrimaryScreen(primary)).not.toThrow();
186
+ const screens: ScreenInfo[] = [];
187
+ expect(() => backend.getScreens(screens)).not.toThrow();
188
+ expect(Array.isArray(screens)).toBe(true);
189
+ });
190
+
191
+ it('returns an unsubscribe function from subscribe without throwing', () => {
192
+ const backend = createWebScreenBackend();
193
+ let unsubscribe: (() => void) | undefined;
194
+ expect(() => {
195
+ unsubscribe = backend.subscribe(() => {});
196
+ }).not.toThrow();
197
+ expect(typeof unsubscribe).toBe('function');
198
+ expect(() => unsubscribe?.()).not.toThrow();
199
+ });
200
+
201
+ it('getCursorPosition returns a numeric point', () => {
202
+ const backend = createWebScreenBackend();
203
+ const out = { x: 0, y: 0 };
204
+ expect(() => backend.getCursorPosition(out)).not.toThrow();
205
+ expect(typeof out.x).toBe('number');
206
+ expect(typeof out.y).toBe('number');
207
+ });
208
+
209
+ it('getModes returns at least one entry', () => {
210
+ const backend = createWebScreenBackend();
211
+ const screen = createScreenInfo();
212
+ const modes: ScreenMode[] = [];
213
+ backend.getModes?.(screen, modes);
214
+ expect(modes.length).toBeGreaterThanOrEqual(1);
215
+ });
216
+
217
+ it('upgrades to multi-monitor via _upgrade when Screen Details API is available', () => {
218
+ const backend = createWebScreenBackend() as ReturnType<typeof createWebScreenBackend> & {
219
+ _upgrade?: (d: unknown) => void;
220
+ };
221
+ // Simulate a ScreenDetails object with two monitors.
222
+ const fakeDetails = {
223
+ currentScreen: {
224
+ left: 0,
225
+ top: 0,
226
+ width: 1920,
227
+ height: 1080,
228
+ availLeft: 0,
229
+ availTop: 0,
230
+ availWidth: 1920,
231
+ availHeight: 1040,
232
+ colorDepth: 24,
233
+ pixelDepth: 24,
234
+ devicePixelRatio: 1,
235
+ refreshRate: 144,
236
+ isPrimary: true,
237
+ isInternal: false,
238
+ label: 'Main Display',
239
+ },
240
+ screens: [
241
+ {
242
+ left: 0,
243
+ top: 0,
244
+ width: 1920,
245
+ height: 1080,
246
+ availLeft: 0,
247
+ availTop: 0,
248
+ availWidth: 1920,
249
+ availHeight: 1040,
250
+ colorDepth: 24,
251
+ pixelDepth: 24,
252
+ devicePixelRatio: 1,
253
+ refreshRate: 144,
254
+ isPrimary: true,
255
+ isInternal: false,
256
+ label: 'Main Display',
257
+ },
258
+ {
259
+ left: 1920,
260
+ top: 0,
261
+ width: 2560,
262
+ height: 1440,
263
+ availLeft: 1920,
264
+ availTop: 0,
265
+ availWidth: 2560,
266
+ availHeight: 1440,
267
+ colorDepth: 30,
268
+ pixelDepth: 30,
269
+ devicePixelRatio: 2,
270
+ refreshRate: 60,
271
+ isPrimary: false,
272
+ isInternal: false,
273
+ label: 'External Monitor',
274
+ },
275
+ ],
276
+ addEventListener: vi.fn(),
277
+ removeEventListener: vi.fn(),
278
+ };
279
+ expect(typeof backend._upgrade).toBe('function');
280
+ backend._upgrade!(fakeDetails);
281
+
282
+ // getScreens now returns two monitors.
283
+ const out: ScreenInfo[] = [];
284
+ backend.getScreens(out);
285
+ expect(out).toHaveLength(2);
286
+ expect(out[0].width).toBe(1920);
287
+ expect(out[0].refreshRate).toBe(144);
288
+ expect(out[0].label).toBe('Main Display');
289
+ expect(out[1].width).toBe(2560);
290
+ expect(out[1].x).toBe(1920);
291
+ expect(out[1].refreshRate).toBe(60);
292
+ expect(out[1].label).toBe('External Monitor');
293
+ expect(out[1].scaleFactor).toBe(2);
294
+ });
295
+
296
+ it('getPrimaryScreen returns the isPrimary screen after Screen Details upgrade', () => {
297
+ const backend = createWebScreenBackend() as ReturnType<typeof createWebScreenBackend> & {
298
+ _upgrade?: (d: unknown) => void;
299
+ };
300
+ const fakeDetails = {
301
+ currentScreen: {
302
+ left: 1920,
303
+ top: 0,
304
+ width: 2560,
305
+ height: 1440,
306
+ availLeft: 1920,
307
+ availTop: 0,
308
+ availWidth: 2560,
309
+ availHeight: 1440,
310
+ colorDepth: 30,
311
+ pixelDepth: 30,
312
+ devicePixelRatio: 2,
313
+ refreshRate: 60,
314
+ isPrimary: false,
315
+ isInternal: false,
316
+ label: 'External',
317
+ },
318
+ screens: [
319
+ {
320
+ left: 0,
321
+ top: 0,
322
+ width: 1920,
323
+ height: 1080,
324
+ availLeft: 0,
325
+ availTop: 0,
326
+ availWidth: 1920,
327
+ availHeight: 1040,
328
+ colorDepth: 24,
329
+ pixelDepth: 24,
330
+ devicePixelRatio: 1,
331
+ refreshRate: 120,
332
+ isPrimary: true,
333
+ isInternal: true,
334
+ label: 'Built-in',
335
+ },
336
+ {
337
+ left: 1920,
338
+ top: 0,
339
+ width: 2560,
340
+ height: 1440,
341
+ availLeft: 1920,
342
+ availTop: 0,
343
+ availWidth: 2560,
344
+ availHeight: 1440,
345
+ colorDepth: 30,
346
+ pixelDepth: 30,
347
+ devicePixelRatio: 2,
348
+ refreshRate: 60,
349
+ isPrimary: false,
350
+ isInternal: false,
351
+ label: 'External',
352
+ },
353
+ ],
354
+ addEventListener: vi.fn(),
355
+ removeEventListener: vi.fn(),
356
+ };
357
+ backend._upgrade!(fakeDetails);
358
+ const out = createScreenInfo();
359
+ backend.getPrimaryScreen(out);
360
+ expect(out.isPrimary).toBe(true);
361
+ expect(out.width).toBe(1920);
362
+ expect(out.label).toBe('Built-in');
363
+ expect(out.internal).toBe(true);
364
+ expect(out.refreshRate).toBe(120);
365
+ });
366
+ });
367
+ describe('detachScreenSignals', () => {
368
+ it('stops delivery after detach', () => {
369
+ const backend = fakeBackend([{}]);
370
+ setScreenBackend(backend);
371
+ const signals = createScreenSignals();
372
+ let count = 0;
373
+ signals.onScreenMetricsChanged.emit = () => count++;
374
+ attachScreenSignals(signals);
375
+ backend.fire();
376
+ detachScreenSignals(signals);
377
+ backend.fire();
378
+ expect(count).toBe(1);
379
+ });
380
+
381
+ it('is safe to call when not attached', () => {
382
+ const signals = createScreenSignals();
383
+ expect(() => detachScreenSignals(signals)).not.toThrow();
384
+ });
385
+ });
386
+ describe('dipToScreenPoint', () => {
387
+ it('converts DIP to physical pixels', () => {
388
+ const screen = makeScreenInfo({ x: 0, y: 0, scaleFactor: 2 });
389
+ const out = { x: 0, y: 0 };
390
+ dipToScreenPoint(screen, { x: 10, y: 20 }, out);
391
+ expect(out.x).toBe(20);
392
+ expect(out.y).toBe(40);
393
+ });
394
+
395
+ it('accounts for screen origin offset', () => {
396
+ const screen = makeScreenInfo({ x: 100, y: 50, scaleFactor: 2 });
397
+ const out = { x: 0, y: 0 };
398
+ dipToScreenPoint(screen, { x: 110, y: 60 }, out);
399
+ expect(out.x).toBe(20);
400
+ expect(out.y).toBe(20);
401
+ });
402
+
403
+ it('is alias-safe when out is the same object as point', () => {
404
+ const screen = makeScreenInfo({ x: 0, y: 0, scaleFactor: 3 });
405
+ const point = { x: 5, y: 10 };
406
+ dipToScreenPoint(screen, point, point);
407
+ expect(point.x).toBe(15);
408
+ expect(point.y).toBe(30);
409
+ });
410
+ });
411
+ describe('dipToScreenRect', () => {
412
+ it('scales and offsets the rect', () => {
413
+ const screen = makeScreenInfo({ x: 0, y: 0, scaleFactor: 2 });
414
+ const out = { x: 0, y: 0, width: 0, height: 0 };
415
+ dipToScreenRect(screen, { x: 10, y: 20, width: 50, height: 100 }, out);
416
+ expect(out.x).toBe(20);
417
+ expect(out.y).toBe(40);
418
+ expect(out.width).toBe(100);
419
+ expect(out.height).toBe(200);
420
+ });
421
+
422
+ it('is alias-safe when out is the same object as rect', () => {
423
+ const screen = makeScreenInfo({ x: 0, y: 0, scaleFactor: 2 });
424
+ const rect = { x: 5, y: 10, width: 20, height: 30 };
425
+ dipToScreenRect(screen, rect, rect);
426
+ expect(rect.x).toBe(10);
427
+ expect(rect.y).toBe(20);
428
+ expect(rect.width).toBe(40);
429
+ expect(rect.height).toBe(60);
430
+ });
431
+ });
432
+ describe('disposeScreenSignals', () => {
433
+ it('stops delivery and is safe to call multiple times', () => {
434
+ const backend = fakeBackend([{}]);
435
+ setScreenBackend(backend);
436
+ const signals = createScreenSignals();
437
+ attachScreenSignals(signals);
438
+ expect(() => disposeScreenSignals(signals)).not.toThrow();
439
+ expect(() => disposeScreenSignals(signals)).not.toThrow();
440
+ });
441
+ });
442
+ describe('enableScreenSignals', () => {
443
+ it('returns a ScreenSignals group with inert signals', () => {
444
+ const signals = enableScreenSignals();
445
+ expect(signals.onScreenAdded).toBeDefined();
446
+ expect(signals.onScreenMetricsChanged).toBeDefined();
447
+ expect(signals.onScreenRemoved).toBeDefined();
448
+ });
449
+ });
450
+ describe('getPrimaryScreen', () => {
451
+ it('fills and returns the passed out object', () => {
452
+ setScreenBackend(fakeBackend([{ width: 1920, isPrimary: true }]));
453
+ const out = createScreenInfo();
454
+ const result = getPrimaryScreen(out);
455
+ expect(result).toBe(out);
456
+ expect(out.isPrimary).toBe(true);
457
+ });
458
+ });
459
+ describe('getScreenBackend', () => {
460
+ it('falls back to a web backend', () => {
461
+ expect(getScreenBackend()).not.toBeNull();
462
+ });
463
+
464
+ it('returns the registered backend', () => {
465
+ const backend = fakeBackend([{}]);
466
+ setScreenBackend(backend);
467
+ expect(getScreenBackend()).toBe(backend);
468
+ });
469
+ });
470
+ describe('getScreenBounds', () => {
471
+ it('fills out with screen x/y/width/height', () => {
472
+ const screen = makeScreenInfo({ x: 10, y: 20, width: 1920, height: 1080 });
473
+ const out = { x: 0, y: 0, width: 0, height: 0 };
474
+ const result = getScreenBounds(screen, out);
475
+ expect(result).toBe(out);
476
+ expect(out.x).toBe(10);
477
+ expect(out.y).toBe(20);
478
+ expect(out.width).toBe(1920);
479
+ expect(out.height).toBe(1080);
480
+ });
481
+ });
482
+ describe('getScreenById', () => {
483
+ it('returns the screen with matching id', () => {
484
+ setScreenBackend(fakeBackend([{ width: 1920 }, { width: 2560 }]));
485
+ const out = createScreenInfo();
486
+ const result = getScreenById(1, out);
487
+ expect(result).toBe(out);
488
+ expect(out.width).toBe(2560);
489
+ });
490
+
491
+ it('returns null when id not found', () => {
492
+ setScreenBackend(fakeBackend([{ width: 1920 }]));
493
+ const out = createScreenInfo();
494
+ expect(getScreenById(99, out)).toBeNull();
495
+ });
496
+ });
497
+ describe('getScreenContainingRect', () => {
498
+ it('returns the screen with the most overlap', () => {
499
+ setScreenBackend(
500
+ fakeBackend([
501
+ { id: 0, x: 0, y: 0, width: 1920, height: 1080, isPrimary: true },
502
+ { id: 1, x: 1920, y: 0, width: 1920, height: 1080, isPrimary: false },
503
+ ]),
504
+ );
505
+ const out = createScreenInfo();
506
+ // Rect spans from x=1900 to x=2100: 20px on screen 0, 180px on screen 1 → screen 1 wins.
507
+ getScreenContainingRect({ x: 1900, y: 0, width: 200, height: 100 }, out);
508
+ expect(out.id).toBe(1);
509
+ });
510
+
511
+ it('falls back to nearest by center when no overlap', () => {
512
+ setScreenBackend(
513
+ fakeBackend([
514
+ { id: 0, x: 0, y: 0, width: 1920, height: 1080, isPrimary: true },
515
+ { id: 1, x: 1920, y: 0, width: 1920, height: 1080, isPrimary: false },
516
+ ]),
517
+ );
518
+ const out = createScreenInfo();
519
+ // Rect is far to the right — nearest center is screen 1
520
+ getScreenContainingRect({ x: 5000, y: 0, width: 10, height: 10 }, out);
521
+ expect(out.id).toBe(1);
522
+ });
523
+ });
524
+ describe('getScreenCurrentMode', () => {
525
+ it('derives a mode from the screen fields', () => {
526
+ const screen = makeScreenInfo({ width: 2560, height: 1440, refreshRate: 144, colorDepth: 32 });
527
+ const out = createScreenMode();
528
+ const result = getScreenCurrentMode(screen, out);
529
+ expect(result).toBe(out);
530
+ expect(out.width).toBe(2560);
531
+ expect(out.height).toBe(1440);
532
+ expect(out.refreshRate).toBe(144);
533
+ expect(out.colorDepth).toBe(32);
534
+ });
535
+ });
536
+ describe('getScreenCursorPosition', () => {
537
+ it('returns a point using the backend getCursorPosition', () => {
538
+ setScreenBackend(fakeBackend([{}]));
539
+ const out = { x: 0, y: 0 };
540
+ const result = getScreenCursorPosition(out);
541
+ expect(result).toBe(out);
542
+ expect(out.x).toBe(42);
543
+ expect(out.y).toBe(84);
544
+ });
545
+
546
+ it('returns (0,0) sentinel from the web backend before first pointermove', () => {
547
+ const backend = createWebScreenBackend();
548
+ const out = { x: 99, y: 99 };
549
+ backend.getCursorPosition(out);
550
+ // Web backend returns 0,0 sentinel until a pointermove occurs in a real browser.
551
+ expect(typeof out.x).toBe('number');
552
+ expect(typeof out.y).toBe('number');
553
+ });
554
+ });
555
+ describe('getScreenCursorScreen', () => {
556
+ it('returns the screen nearest to the cursor position', () => {
557
+ const backend = fakeBackend([
558
+ { id: 0, x: 0, y: 0, width: 1920, height: 1080, isPrimary: true },
559
+ { id: 1, x: 1920, y: 0, width: 1920, height: 1080, isPrimary: false },
560
+ ]);
561
+ // Override getCursorPosition to point to screen 1.
562
+ backend.getCursorPosition = (out) => {
563
+ out.x = 2000;
564
+ out.y = 500;
565
+ return out;
566
+ };
567
+ setScreenBackend(backend);
568
+ const out = createScreenInfo();
569
+ getScreenCursorScreen(out);
570
+ expect(out.id).toBe(1);
571
+ });
572
+ });
573
+ describe('getScreenDetailPermission', () => {
574
+ it('returns a valid permission state string without throwing', async () => {
575
+ const state = await getScreenDetailPermission();
576
+ expect(['granted', 'denied', 'prompt']).toContain(state);
577
+ });
578
+
579
+ it('returns prompt when the Permissions API is unavailable (jsdom)', async () => {
580
+ // jsdom does not implement navigator.permissions.query for window-management.
581
+ const state = await getScreenDetailPermission();
582
+ expect(state).toBe('prompt');
583
+ });
584
+ });
585
+ describe('getScreenModes', () => {
586
+ it('calls getModes on the backend when available', () => {
587
+ const modes: ScreenMode[] = [{ width: 1920, height: 1080, refreshRate: 60, colorDepth: 32, pixelFormat: '' }];
588
+ const backend = fakeBackend([{}]);
589
+ backend.getModes = (_s, out) => {
590
+ out.length = 1;
591
+ out[0] = modes[0];
592
+ return out;
593
+ };
594
+ setScreenBackend(backend);
595
+ const screen = createScreenInfo();
596
+ const out: ScreenMode[] = [];
597
+ getScreenModes(screen, out);
598
+ expect(out).toHaveLength(1);
599
+ expect(out[0].width).toBe(1920);
600
+ });
601
+
602
+ it('falls back to a synthetic single mode when getModes is absent', () => {
603
+ const backend = fakeBackend([{ width: 3840, height: 2160 }]);
604
+ delete (backend as Partial<ScreenBackend>).getModes;
605
+ setScreenBackend(backend);
606
+ const screen = makeScreenInfo({ width: 3840, height: 2160 });
607
+ const out: ScreenMode[] = [];
608
+ getScreenModes(screen, out);
609
+ expect(out).toHaveLength(1);
610
+ expect(out[0].width).toBe(3840);
611
+ });
612
+ });
613
+ describe('getScreenNearestPoint', () => {
614
+ it('returns the screen that contains the point', () => {
615
+ setScreenBackend(
616
+ fakeBackend([
617
+ { id: 0, x: 0, y: 0, width: 1920, height: 1080, isPrimary: true },
618
+ { id: 1, x: 1920, y: 0, width: 1920, height: 1080, isPrimary: false },
619
+ ]),
620
+ );
621
+ const out = createScreenInfo();
622
+ getScreenNearestPoint({ x: 2000, y: 100 }, out);
623
+ expect(out.id).toBe(1);
624
+ });
625
+
626
+ it('returns the nearest screen when point is outside all screens', () => {
627
+ setScreenBackend(
628
+ fakeBackend([
629
+ { id: 0, x: 0, y: 0, width: 1920, height: 1080, isPrimary: true },
630
+ { id: 1, x: 1920, y: 0, width: 1920, height: 1080, isPrimary: false },
631
+ ]),
632
+ );
633
+ const out = createScreenInfo();
634
+ // Point far to the right — nearest is screen 1
635
+ getScreenNearestPoint({ x: 9999, y: 540 }, out);
636
+ expect(out.id).toBe(1);
637
+ });
638
+ });
639
+ describe('getScreenNearestRect', () => {
640
+ it('delegates to getScreenContainingRect', () => {
641
+ setScreenBackend(
642
+ fakeBackend([
643
+ { id: 0, x: 0, y: 0, width: 1920, height: 1080, isPrimary: true },
644
+ { id: 1, x: 1920, y: 0, width: 1920, height: 1080, isPrimary: false },
645
+ ]),
646
+ );
647
+ const out = createScreenInfo();
648
+ getScreenNearestRect({ x: 1920, y: 0, width: 200, height: 100 }, out);
649
+ expect(out.id).toBe(1);
650
+ });
651
+ });
652
+ describe('getScreens', () => {
653
+ it('fills the out array to the screen count and returns it', () => {
654
+ setScreenBackend(fakeBackend([{ width: 1920 }, { width: 2560 }, { width: 3840 }]));
655
+ const out: ScreenInfo[] = [];
656
+ const result = getScreens(out);
657
+ expect(result).toBe(out);
658
+ expect(out.length).toBe(3);
659
+ expect(out[0].isPrimary).toBe(true);
660
+ });
661
+ });
662
+ describe('getScreenWorkArea', () => {
663
+ it('fills out with screen x/y/workWidth/workHeight', () => {
664
+ const screen = makeScreenInfo({ x: 0, y: 0, workWidth: 1920, workHeight: 1040 });
665
+ const out = { x: 0, y: 0, width: 0, height: 0 };
666
+ const result = getScreenWorkArea(screen, out);
667
+ expect(result).toBe(out);
668
+ expect(out.width).toBe(1920);
669
+ expect(out.height).toBe(1040);
670
+ });
671
+ });
672
+ describe('onScreenChange', () => {
673
+ it('delivers change events to the listener and unsubscribes', () => {
674
+ const backend = fakeBackend([{}]);
675
+ setScreenBackend(backend);
676
+ const events: ScreenChangeEvent[] = [];
677
+ const unsubscribe = onScreenChange((e) => events.push(e));
678
+ backend.fire();
679
+ expect(events).toHaveLength(1);
680
+ expect(events[0].kind).toBe('ScreenMetricsChanged');
681
+ unsubscribe();
682
+ backend.fire();
683
+ expect(events).toHaveLength(1);
684
+ });
685
+ });
686
+ describe('onScreenDetailPermissionChange', () => {
687
+ it('returns a no-op unsubscribe when the Permissions API is unavailable', () => {
688
+ const hadPermissions = 'permissions' in navigator;
689
+ const original = (navigator as { permissions?: unknown }).permissions;
690
+ delete (navigator as { permissions?: unknown }).permissions;
691
+ try {
692
+ const unsubscribe = onScreenDetailPermissionChange(() => {});
693
+ expect(typeof unsubscribe).toBe('function');
694
+ expect(() => unsubscribe()).not.toThrow();
695
+ } finally {
696
+ if (hadPermissions) (navigator as { permissions?: unknown }).permissions = original;
697
+ }
698
+ });
699
+
700
+ it('delivers the new state when the permission status fires a change', async () => {
701
+ const listeners: Array<() => void> = [];
702
+ const status = {
703
+ state: 'prompt' as 'denied' | 'granted' | 'prompt',
704
+ addEventListener: (_type: 'change', l: () => void) => listeners.push(l),
705
+ removeEventListener: (_type: 'change', l: () => void) => {
706
+ const i = listeners.indexOf(l);
707
+ if (i >= 0) listeners.splice(i, 1);
708
+ },
709
+ };
710
+ const original = (navigator as { permissions?: unknown }).permissions;
711
+ (navigator as { permissions?: unknown }).permissions = {
712
+ query: () => Promise.resolve(status),
713
+ };
714
+ try {
715
+ const states: string[] = [];
716
+ const unsubscribe = onScreenDetailPermissionChange((s) => states.push(s));
717
+ // Let the query promise resolve so the change listener is registered.
718
+ await Promise.resolve();
719
+ await Promise.resolve();
720
+ status.state = 'granted';
721
+ for (const l of listeners) l();
722
+ expect(states).toEqual(['granted']);
723
+ unsubscribe();
724
+ expect(listeners).toHaveLength(0);
725
+ } finally {
726
+ if (original === undefined) delete (navigator as { permissions?: unknown }).permissions;
727
+ else (navigator as { permissions?: unknown }).permissions = original;
728
+ }
729
+ });
730
+ });
731
+ describe('refreshScreens', () => {
732
+ it('is callable without throwing', () => {
733
+ expect(() => refreshScreens()).not.toThrow();
734
+ });
735
+ });
736
+ describe('requestScreenDetails', () => {
737
+ it('returns false in jsdom where getScreenDetails is unavailable', async () => {
738
+ const result = await requestScreenDetails();
739
+ expect(result).toBe(false);
740
+ });
741
+
742
+ it('returns true and upgrades the web backend when getScreenDetails resolves', async () => {
743
+ // Install a fresh web backend so requestScreenDetails can upgrade it.
744
+ const webBackend = createWebScreenBackend();
745
+ setScreenBackend(webBackend);
746
+
747
+ const fakeDetails = {
748
+ currentScreen: {
749
+ left: 0,
750
+ top: 0,
751
+ width: 1920,
752
+ height: 1080,
753
+ availLeft: 0,
754
+ availTop: 0,
755
+ availWidth: 1920,
756
+ availHeight: 1040,
757
+ colorDepth: 24,
758
+ pixelDepth: 24,
759
+ devicePixelRatio: 1,
760
+ refreshRate: 60,
761
+ isPrimary: true,
762
+ isInternal: false,
763
+ label: 'Monitor A',
764
+ },
765
+ screens: [
766
+ {
767
+ left: 0,
768
+ top: 0,
769
+ width: 1920,
770
+ height: 1080,
771
+ availLeft: 0,
772
+ availTop: 0,
773
+ availWidth: 1920,
774
+ availHeight: 1040,
775
+ colorDepth: 24,
776
+ pixelDepth: 24,
777
+ devicePixelRatio: 1,
778
+ refreshRate: 60,
779
+ isPrimary: true,
780
+ isInternal: false,
781
+ label: 'Monitor A',
782
+ },
783
+ {
784
+ left: 1920,
785
+ top: 0,
786
+ width: 1280,
787
+ height: 1024,
788
+ availLeft: 1920,
789
+ availTop: 0,
790
+ availWidth: 1280,
791
+ availHeight: 1024,
792
+ colorDepth: 24,
793
+ pixelDepth: 24,
794
+ devicePixelRatio: 1,
795
+ refreshRate: 75,
796
+ isPrimary: false,
797
+ isInternal: false,
798
+ label: 'Monitor B',
799
+ },
800
+ ],
801
+ addEventListener: vi.fn(),
802
+ removeEventListener: vi.fn(),
803
+ };
804
+
805
+ // Temporarily inject getScreenDetails onto window.
806
+ const win = window as Window & { getScreenDetails?: () => Promise<unknown> };
807
+ win.getScreenDetails = () => Promise.resolve(fakeDetails);
808
+ try {
809
+ const result = await requestScreenDetails();
810
+ expect(result).toBe(true);
811
+ // After upgrade, getScreens returns two monitors.
812
+ const out: ScreenInfo[] = [];
813
+ getScreens(out);
814
+ expect(out).toHaveLength(2);
815
+ expect(out[0].label).toBe('Monitor A');
816
+ expect(out[1].label).toBe('Monitor B');
817
+ expect(out[1].refreshRate).toBe(75);
818
+ } finally {
819
+ delete win.getScreenDetails;
820
+ }
821
+ });
822
+
823
+ it('returns false when getScreenDetails rejects', async () => {
824
+ const win = window as Window & { getScreenDetails?: () => Promise<unknown> };
825
+ win.getScreenDetails = () => Promise.reject(new Error('Permission denied'));
826
+ try {
827
+ const result = await requestScreenDetails();
828
+ expect(result).toBe(false);
829
+ } finally {
830
+ delete win.getScreenDetails;
831
+ }
832
+ });
833
+ });
834
+ describe('screenToDipPoint', () => {
835
+ it('converts physical pixels to DIP', () => {
836
+ const screen = makeScreenInfo({ x: 0, y: 0, scaleFactor: 2 });
837
+ const out = { x: 0, y: 0 };
838
+ screenToDipPoint(screen, { x: 20, y: 40 }, out);
839
+ expect(out.x).toBe(10);
840
+ expect(out.y).toBe(20);
841
+ });
842
+
843
+ it('accounts for screen origin offset', () => {
844
+ const screen = makeScreenInfo({ x: 100, y: 50, scaleFactor: 2 });
845
+ const out = { x: 0, y: 0 };
846
+ screenToDipPoint(screen, { x: 20, y: 20 }, out);
847
+ expect(out.x).toBe(110);
848
+ expect(out.y).toBe(60);
849
+ });
850
+
851
+ it('is alias-safe when out is the same object as point', () => {
852
+ const screen = makeScreenInfo({ x: 0, y: 0, scaleFactor: 4 });
853
+ const point = { x: 16, y: 32 };
854
+ screenToDipPoint(screen, point, point);
855
+ expect(point.x).toBe(4);
856
+ expect(point.y).toBe(8);
857
+ });
858
+
859
+ it('is the inverse of dipToScreenPoint', () => {
860
+ const screen = makeScreenInfo({ x: 200, y: 100, scaleFactor: 2 });
861
+ const original = { x: 250, y: 150 };
862
+ const physical = { x: 0, y: 0 };
863
+ dipToScreenPoint(screen, original, physical);
864
+ const recovered = { x: 0, y: 0 };
865
+ screenToDipPoint(screen, physical, recovered);
866
+ expect(recovered.x).toBeCloseTo(original.x);
867
+ expect(recovered.y).toBeCloseTo(original.y);
868
+ });
869
+ });
870
+ describe('screenToDipRect', () => {
871
+ it('scales the rect back to DIP', () => {
872
+ const screen = makeScreenInfo({ x: 0, y: 0, scaleFactor: 2 });
873
+ const out = { x: 0, y: 0, width: 0, height: 0 };
874
+ screenToDipRect(screen, { x: 20, y: 40, width: 100, height: 200 }, out);
875
+ expect(out.x).toBe(10);
876
+ expect(out.y).toBe(20);
877
+ expect(out.width).toBe(50);
878
+ expect(out.height).toBe(100);
879
+ });
880
+
881
+ it('is alias-safe when out is the same object as rect', () => {
882
+ const screen = makeScreenInfo({ x: 0, y: 0, scaleFactor: 2 });
883
+ const rect = { x: 10, y: 20, width: 40, height: 60 };
884
+ screenToDipRect(screen, rect, rect);
885
+ expect(rect.x).toBe(5);
886
+ expect(rect.y).toBe(10);
887
+ expect(rect.width).toBe(20);
888
+ expect(rect.height).toBe(30);
889
+ });
890
+
891
+ it('is the inverse of dipToScreenRect', () => {
892
+ const screen = makeScreenInfo({ x: 0, y: 0, scaleFactor: 3 });
893
+ const original = { x: 10, y: 20, width: 100, height: 200 };
894
+ const physical = { x: 0, y: 0, width: 0, height: 0 };
895
+ dipToScreenRect(screen, original, physical);
896
+ const recovered = { x: 0, y: 0, width: 0, height: 0 };
897
+ screenToDipRect(screen, physical, recovered);
898
+ expect(recovered.x).toBeCloseTo(original.x);
899
+ expect(recovered.y).toBeCloseTo(original.y);
900
+ expect(recovered.width).toBeCloseTo(original.width);
901
+ expect(recovered.height).toBeCloseTo(original.height);
902
+ });
903
+ });
904
+ describe('setScreenBackend', () => {
905
+ it('clears back to the web fallback when passed null', () => {
906
+ setScreenBackend(fakeBackend([{}]));
907
+ setScreenBackend(null);
908
+ expect(getScreenBackend()).not.toBeNull();
909
+ });
910
+ });