@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.
package/dist/screen.js ADDED
@@ -0,0 +1,751 @@
1
+ import { createSignal, emitSignal } from '@flighthq/signals';
2
+ // Attaches the active backend's change subscription to `signals`, fanning out events to the
3
+ // appropriate signal for each ScreenChangeKind. Idempotent: a prior subscription is torn down first.
4
+ // Pair with detachScreenSignals / disposeScreenSignals.
5
+ export function attachScreenSignals(signals) {
6
+ detachScreenSignals(signals);
7
+ const unsubscribe = getScreenBackend().subscribe((event) => {
8
+ if (event.kind === 'ScreenAdded') {
9
+ emitSignal(signals.onScreenAdded, event.screen);
10
+ }
11
+ else if (event.kind === 'ScreenRemoved') {
12
+ emitSignal(signals.onScreenRemoved, event.screen);
13
+ }
14
+ else {
15
+ emitSignal(signals.onScreenMetricsChanged, event);
16
+ }
17
+ });
18
+ _signalSubscriptions.set(signals, unsubscribe);
19
+ }
20
+ // Allocates a zeroed ScreenInfo; use as the `out` for getPrimaryScreen or as an array slot for
21
+ // getScreens. scaleFactor defaults to 1 (no scaling) and isPrimary to false.
22
+ export function createScreenInfo() {
23
+ return {
24
+ id: 0,
25
+ x: 0,
26
+ y: 0,
27
+ width: 0,
28
+ height: 0,
29
+ workWidth: 0,
30
+ workHeight: 0,
31
+ scaleFactor: 1,
32
+ isPrimary: false,
33
+ rotation: -1,
34
+ orientation: 'Landscape',
35
+ refreshRate: -1,
36
+ colorDepth: -1,
37
+ pixelDepth: -1,
38
+ physicalWidth: -1,
39
+ physicalHeight: -1,
40
+ isHdr: false,
41
+ colorSpace: 'srgb',
42
+ maxLuminance: -1,
43
+ depthPerComponent: -1,
44
+ dpi: -1,
45
+ label: '',
46
+ internal: false,
47
+ touchSupport: 'unknown',
48
+ monochrome: false,
49
+ };
50
+ }
51
+ // Allocates a zeroed ScreenMode; use as an array slot for getScreenModes / getScreenCurrentMode.
52
+ export function createScreenMode() {
53
+ return {
54
+ width: 0,
55
+ height: 0,
56
+ refreshRate: -1,
57
+ colorDepth: -1,
58
+ pixelFormat: '',
59
+ };
60
+ }
61
+ // Allocates a ScreenSignals group with inert signals; call attachScreenSignals to start delivery.
62
+ export function createScreenSignals() {
63
+ return {
64
+ onScreenAdded: createSignal(),
65
+ onScreenMetricsChanged: createSignal(),
66
+ onScreenRemoved: createSignal(),
67
+ };
68
+ }
69
+ // Builds the default web backend over window.screen. The web reports a single logical display until
70
+ // requestScreenDetails() upgrades it to the full multi-monitor Screen Details API. A native host
71
+ // (Electron/Tauri) replaces this backend via setScreenBackend to enumerate every attached monitor.
72
+ // All reads fill `out` with zeros when window/screen are absent (jsdom) rather than throwing.
73
+ export function createWebScreenBackend() {
74
+ let _cursorX = 0;
75
+ let _cursorY = 0;
76
+ let _cursorTracking = false;
77
+ let _cachedScreens = null;
78
+ // Set by upgradeToScreenDetails() when the Window Management permission is granted.
79
+ let _screenDetails = null;
80
+ function ensureCursorTracking() {
81
+ if (_cursorTracking || typeof window === 'undefined')
82
+ return;
83
+ _cursorTracking = true;
84
+ window.addEventListener('pointermove', (e) => {
85
+ _cursorX = e.screenX;
86
+ _cursorY = e.screenY;
87
+ });
88
+ }
89
+ // Upgrades the backend to use the Screen Details API. Called by requestScreenDetails() on success.
90
+ function upgradeToScreenDetails(details) {
91
+ _screenDetails = details;
92
+ // Invalidate cache so the next enumeration reflects the multi-screen view.
93
+ _cachedScreens = null;
94
+ }
95
+ function buildScreenInfoFromDetailed(sd, index, primaryIndex, out) {
96
+ out.id = index;
97
+ out.x = sd.left;
98
+ out.y = sd.top;
99
+ out.width = sd.width;
100
+ out.height = sd.height;
101
+ out.workWidth = sd.availWidth;
102
+ out.workHeight = sd.availHeight;
103
+ out.scaleFactor = typeof sd.devicePixelRatio === 'number' ? sd.devicePixelRatio : 1;
104
+ out.isPrimary = index === primaryIndex || (sd.isPrimary ?? index === 0);
105
+ out.rotation = getWebRotation();
106
+ out.orientation = getWebOrientation();
107
+ // ScreenDetailed.refreshRate is available when the Window Management permission is granted.
108
+ out.refreshRate = typeof sd.refreshRate === 'number' && sd.refreshRate > 0 ? sd.refreshRate : -1;
109
+ out.colorDepth = typeof sd.colorDepth === 'number' ? sd.colorDepth : -1;
110
+ out.pixelDepth = typeof sd.pixelDepth === 'number' ? sd.pixelDepth : -1;
111
+ out.physicalWidth = Math.round(out.width * out.scaleFactor);
112
+ out.physicalHeight = Math.round(out.height * out.scaleFactor);
113
+ out.isHdr = getWebIsHdr();
114
+ out.colorSpace = getWebColorSpace();
115
+ out.maxLuminance = -1;
116
+ out.depthPerComponent = -1;
117
+ out.dpi = -1;
118
+ out.label = typeof sd.label === 'string' ? sd.label : '';
119
+ // isInternal is true for built-in displays (laptop panel, phone screen).
120
+ out.internal = sd.isInternal ?? false;
121
+ out.touchSupport = 'unknown';
122
+ out.monochrome = false;
123
+ }
124
+ function buildCurrentScreenInfo(out) {
125
+ if (typeof window === 'undefined' || typeof window.screen === 'undefined') {
126
+ fillDefaultScreenInfo(out);
127
+ return;
128
+ }
129
+ // When the Screen Details API is active, use the currentScreen for the single-screen view.
130
+ if (_screenDetails !== null) {
131
+ const screens = _screenDetails.screens;
132
+ const primaryIndex = screens.findIndex((s) => s.isPrimary ?? false);
133
+ const current = _screenDetails.currentScreen;
134
+ const currentIndex = screens.indexOf(current);
135
+ buildScreenInfoFromDetailed(current, currentIndex >= 0 ? currentIndex : 0, primaryIndex, out);
136
+ return;
137
+ }
138
+ const s = window.screen;
139
+ out.id = 0;
140
+ out.x = 0;
141
+ out.y = 0;
142
+ out.width = s.width;
143
+ out.height = s.height;
144
+ out.workWidth = s.availWidth;
145
+ out.workHeight = s.availHeight;
146
+ out.scaleFactor = typeof window.devicePixelRatio === 'number' ? window.devicePixelRatio : 1;
147
+ out.isPrimary = true;
148
+ out.rotation = getWebRotation();
149
+ out.orientation = getWebOrientation();
150
+ out.refreshRate = -1;
151
+ out.colorDepth = typeof s.colorDepth === 'number' ? s.colorDepth : -1;
152
+ out.pixelDepth = typeof s.pixelDepth === 'number' ? s.pixelDepth : -1;
153
+ out.physicalWidth = Math.round(out.width * out.scaleFactor);
154
+ out.physicalHeight = Math.round(out.height * out.scaleFactor);
155
+ out.isHdr = getWebIsHdr();
156
+ out.colorSpace = getWebColorSpace();
157
+ out.maxLuminance = -1;
158
+ out.depthPerComponent = -1;
159
+ out.dpi = -1;
160
+ out.label = '';
161
+ out.internal = false;
162
+ out.touchSupport = 'unknown';
163
+ out.monochrome = false;
164
+ }
165
+ const backend = {
166
+ // Internal: called by requestScreenDetails on success.
167
+ _upgrade: upgradeToScreenDetails,
168
+ getScreens(out) {
169
+ if (typeof window === 'undefined' || typeof window.screen === 'undefined') {
170
+ out.length = 0;
171
+ return out;
172
+ }
173
+ // Multi-monitor path: enumerate via Screen Details API when permission is granted.
174
+ if (_screenDetails !== null) {
175
+ const screens = _screenDetails.screens;
176
+ const primaryIndex = screens.findIndex((s) => s.isPrimary ?? false);
177
+ out.length = screens.length;
178
+ for (let i = 0; i < screens.length; i++) {
179
+ if (out[i] === undefined)
180
+ out[i] = createScreenInfo();
181
+ buildScreenInfoFromDetailed(screens[i], i, primaryIndex, out[i]);
182
+ }
183
+ _cachedScreens = out.slice(0, screens.length).map((s) => ({ ...s }));
184
+ return out;
185
+ }
186
+ // Single-monitor path (default): read window.screen.
187
+ out.length = 1;
188
+ if (out[0] === undefined)
189
+ out[0] = createScreenInfo();
190
+ buildCurrentScreenInfo(out[0]);
191
+ // Cache for change detection in subscribe.
192
+ _cachedScreens = [{ ...out[0] }];
193
+ return out;
194
+ },
195
+ getPrimaryScreen(out) {
196
+ if (typeof window === 'undefined' || typeof window.screen === 'undefined') {
197
+ fillDefaultScreenInfo(out);
198
+ return out;
199
+ }
200
+ if (_screenDetails !== null) {
201
+ const screens = _screenDetails.screens;
202
+ const primaryIndex = screens.findIndex((s) => s.isPrimary ?? false);
203
+ const idx = primaryIndex >= 0 ? primaryIndex : 0;
204
+ if (screens.length > 0) {
205
+ buildScreenInfoFromDetailed(screens[idx], idx, idx, out);
206
+ return out;
207
+ }
208
+ }
209
+ buildCurrentScreenInfo(out);
210
+ return out;
211
+ },
212
+ subscribe(listener) {
213
+ if (typeof window === 'undefined')
214
+ return () => { };
215
+ // Build initial cache for diffing.
216
+ if (_cachedScreens === null) {
217
+ const scratch = createScreenInfo();
218
+ buildCurrentScreenInfo(scratch);
219
+ _cachedScreens = [{ ...scratch }];
220
+ }
221
+ const handleChange = () => {
222
+ if (_screenDetails !== null) {
223
+ // Multi-monitor: diff each screen and emit add/remove/metrics events.
224
+ const details = _screenDetails;
225
+ const screens = details.screens;
226
+ const primaryIndex = screens.findIndex((s) => s.isPrimary ?? false);
227
+ const newInfos = screens.map((sd, i) => {
228
+ const info = createScreenInfo();
229
+ buildScreenInfoFromDetailed(sd, i, primaryIndex, info);
230
+ return info;
231
+ });
232
+ const prevCache = _cachedScreens ?? [];
233
+ // Detect removed screens (were in prev, not in new).
234
+ for (const prev of prevCache) {
235
+ const stillPresent = newInfos.some((n) => n.id === prev.id);
236
+ if (!stillPresent) {
237
+ listener({ kind: 'ScreenRemoved', screen: prev, changedMetrics: null });
238
+ }
239
+ }
240
+ // Detect added screens and metrics changes.
241
+ for (const curr of newInfos) {
242
+ const prev = prevCache.find((p) => p.id === curr.id);
243
+ if (prev === undefined) {
244
+ listener({ kind: 'ScreenAdded', screen: curr, changedMetrics: null });
245
+ }
246
+ else {
247
+ const changed = diffScreenInfo(prev, curr);
248
+ if (changed !== null) {
249
+ listener({ kind: 'ScreenMetricsChanged', screen: curr, changedMetrics: changed });
250
+ }
251
+ }
252
+ }
253
+ _cachedScreens = newInfos.map((s) => ({ ...s }));
254
+ return;
255
+ }
256
+ // Single-monitor path.
257
+ const scratch = createScreenInfo();
258
+ buildCurrentScreenInfo(scratch);
259
+ const prev = _cachedScreens?.[0];
260
+ if (prev === undefined) {
261
+ _cachedScreens = [{ ...scratch }];
262
+ listener({ kind: 'ScreenAdded', screen: scratch, changedMetrics: null });
263
+ return;
264
+ }
265
+ const changed = diffScreenInfo(prev, scratch);
266
+ if (changed !== null) {
267
+ Object.assign(prev, scratch);
268
+ listener({ kind: 'ScreenMetricsChanged', screen: scratch, changedMetrics: changed });
269
+ }
270
+ };
271
+ window.addEventListener('resize', handleChange);
272
+ const orientation = getWebScreenOrientationObject();
273
+ orientation?.addEventListener?.('change', handleChange);
274
+ // If Screen Details API is active, also subscribe to screenschange events.
275
+ const detailsRef = _screenDetails;
276
+ detailsRef?.addEventListener?.('screenschange', handleChange);
277
+ return () => {
278
+ window.removeEventListener('resize', handleChange);
279
+ orientation?.removeEventListener?.('change', handleChange);
280
+ detailsRef?.removeEventListener?.('screenschange', handleChange);
281
+ };
282
+ },
283
+ getCursorPosition(out) {
284
+ ensureCursorTracking();
285
+ out.x = _cursorX;
286
+ out.y = _cursorY;
287
+ return out;
288
+ },
289
+ getModes(screen, out) {
290
+ // Web cannot enumerate display modes; return the current logical mode as the only entry.
291
+ out.length = 1;
292
+ if (out[0] === undefined)
293
+ out[0] = createScreenMode();
294
+ out[0].width = screen.width;
295
+ out[0].height = screen.height;
296
+ out[0].refreshRate = screen.refreshRate;
297
+ out[0].colorDepth = screen.colorDepth;
298
+ out[0].pixelFormat = '';
299
+ return out;
300
+ },
301
+ };
302
+ return backend;
303
+ }
304
+ // Stops delivery to `signals` and forgets its subscription. Safe to call when not attached.
305
+ export function detachScreenSignals(signals) {
306
+ const unsubscribe = _signalSubscriptions.get(signals);
307
+ if (unsubscribe !== undefined) {
308
+ unsubscribe();
309
+ _signalSubscriptions.delete(signals);
310
+ }
311
+ }
312
+ // Converts a point from DIP (logical) coordinates to physical screen pixel coordinates relative to
313
+ // `screen`'s origin. Alias-safe: `out` may be the same object as `point`.
314
+ // physicalX = (point.x - screen.x) * screen.scaleFactor
315
+ export function dipToScreenPoint(screen, point, out) {
316
+ const px = point.x;
317
+ const py = point.y;
318
+ out.x = (px - screen.x) * screen.scaleFactor;
319
+ out.y = (py - screen.y) * screen.scaleFactor;
320
+ return out;
321
+ }
322
+ // Converts a rectangle from DIP (logical) coordinates to physical screen pixel coordinates relative
323
+ // to `screen`'s origin. Alias-safe: `out` may be the same object as `rect`.
324
+ export function dipToScreenRect(screen, rect, out) {
325
+ const rx = rect.x;
326
+ const ry = rect.y;
327
+ const rw = rect.width;
328
+ const rh = rect.height;
329
+ const sf = screen.scaleFactor;
330
+ out.x = (rx - screen.x) * sf;
331
+ out.y = (ry - screen.y) * sf;
332
+ out.width = rw * sf;
333
+ out.height = rh * sf;
334
+ return out;
335
+ }
336
+ // Releases `signals` for garbage collection by detaching its backend subscription. The signals
337
+ // remain plain GC-managed memory afterward.
338
+ export function disposeScreenSignals(signals) {
339
+ detachScreenSignals(signals);
340
+ }
341
+ // Enables a signals group for screen change events. Signals stay inert until attachScreenSignals is
342
+ // called. This is the opt-in; the cost is paid when attached.
343
+ export function enableScreenSignals() {
344
+ return createScreenSignals();
345
+ }
346
+ // Fills `out` with the primary display and returns it. The web reports one screen; a native host its
347
+ // OS-designated primary monitor.
348
+ export function getPrimaryScreen(out) {
349
+ return getScreenBackend().getPrimaryScreen(out);
350
+ }
351
+ // The active screen backend, or a lazily-created web default. There is always a backend.
352
+ export function getScreenBackend() {
353
+ if (_backend === null)
354
+ _backend = createWebScreenBackend();
355
+ return _backend;
356
+ }
357
+ // Fills `out` with the bounds rectangle of the given screen. Convenience accessor over the flat fields.
358
+ export function getScreenBounds(screen, out) {
359
+ out.x = screen.x;
360
+ out.y = screen.y;
361
+ out.width = screen.width;
362
+ out.height = screen.height;
363
+ return out;
364
+ }
365
+ // Returns the screen with the given id, or null when no screen matches. Sentinel null means not found.
366
+ export function getScreenById(id, out) {
367
+ const screens = [];
368
+ getScreens(screens);
369
+ for (const screen of screens) {
370
+ if (screen.id === id) {
371
+ copyScreenInfo(screen, out);
372
+ return out;
373
+ }
374
+ }
375
+ return null;
376
+ }
377
+ // Returns the screen whose bounds contain the given rectangle (largest-overlap strategy). Falls back
378
+ // to the screen nearest to the rectangle's center when no screen contains it.
379
+ export function getScreenContainingRect(rect, out) {
380
+ const screens = [];
381
+ getScreens(screens);
382
+ if (screens.length === 0) {
383
+ fillDefaultScreenInfo(out);
384
+ return out;
385
+ }
386
+ let bestScreen = screens[0];
387
+ let bestOverlap = -1;
388
+ for (const screen of screens) {
389
+ const ox = Math.max(0, Math.min(rect.x + rect.width, screen.x + screen.width) - Math.max(rect.x, screen.x));
390
+ const oy = Math.max(0, Math.min(rect.y + rect.height, screen.y + screen.height) - Math.max(rect.y, screen.y));
391
+ const overlap = ox * oy;
392
+ if (overlap > bestOverlap) {
393
+ bestOverlap = overlap;
394
+ bestScreen = screen;
395
+ }
396
+ }
397
+ // No overlap — fall back to nearest by center distance.
398
+ if (bestOverlap <= 0) {
399
+ const cx = rect.x + rect.width / 2;
400
+ const cy = rect.y + rect.height / 2;
401
+ let bestDist = Infinity;
402
+ for (const screen of screens) {
403
+ const scx = screen.x + screen.width / 2;
404
+ const scy = screen.y + screen.height / 2;
405
+ const dx = cx - scx;
406
+ const dy = cy - scy;
407
+ const dist = dx * dx + dy * dy;
408
+ if (dist < bestDist) {
409
+ bestDist = dist;
410
+ bestScreen = screen;
411
+ }
412
+ }
413
+ }
414
+ copyScreenInfo(bestScreen, out);
415
+ return out;
416
+ }
417
+ // Fills `out` with the current-mode for the given screen (the active resolution/refresh pair).
418
+ // Web returns a synthetic single-entry mode derived from ScreenInfo fields.
419
+ export function getScreenCurrentMode(screen, out) {
420
+ out.width = screen.width;
421
+ out.height = screen.height;
422
+ out.refreshRate = screen.refreshRate;
423
+ out.colorDepth = screen.colorDepth;
424
+ out.pixelFormat = '';
425
+ return out;
426
+ }
427
+ // Fills `out` with the current cursor position in virtual-desktop coordinates and returns it.
428
+ // Uses the active backend's getCursorPosition. Returns (0, 0) before the first pointermove (web)
429
+ // or when unavailable.
430
+ export function getScreenCursorPosition(out) {
431
+ return getScreenBackend().getCursorPosition(out);
432
+ }
433
+ // Returns the screen currently containing the cursor. Composites getScreenCursorPosition with
434
+ // getScreenNearestPoint.
435
+ export function getScreenCursorScreen(out) {
436
+ const pos = _scratchPoint;
437
+ getScreenCursorPosition(pos);
438
+ return getScreenNearestPoint(pos, out);
439
+ }
440
+ // Returns the permission state for the Window Management API (multi-monitor on web).
441
+ // 'granted' | 'denied' | 'prompt' mirrors the PermissionState vocabulary.
442
+ // Returns 'prompt' when the Permissions API is unavailable.
443
+ export async function getScreenDetailPermission() {
444
+ if (typeof navigator === 'undefined' || !('permissions' in navigator))
445
+ return 'prompt';
446
+ try {
447
+ const status = await navigator.permissions.query({
448
+ name: 'window-management',
449
+ });
450
+ return status.state;
451
+ }
452
+ catch {
453
+ return 'prompt';
454
+ }
455
+ }
456
+ // Fills `out` with all available display modes for the given screen. Web returns a single synthetic
457
+ // entry derived from the screen's current fields.
458
+ export function getScreenModes(screen, out) {
459
+ const backend = getScreenBackend();
460
+ if (backend.getModes !== undefined) {
461
+ return backend.getModes(screen, out);
462
+ }
463
+ // Fallback: a single synthetic mode from the screen's current fields.
464
+ out.length = 1;
465
+ if (out[0] === undefined)
466
+ out[0] = createScreenMode();
467
+ getScreenCurrentMode(screen, out[0]);
468
+ return out;
469
+ }
470
+ // Returns the screen whose bounds contain `point` (virtual-desktop coordinates). Falls back to the
471
+ // closest screen by Euclidean distance when the point lies outside all screens.
472
+ export function getScreenNearestPoint(point, out) {
473
+ const screens = [];
474
+ getScreens(screens);
475
+ if (screens.length === 0) {
476
+ fillDefaultScreenInfo(out);
477
+ return out;
478
+ }
479
+ // Prefer the screen that contains the point.
480
+ for (const screen of screens) {
481
+ if (point.x >= screen.x &&
482
+ point.x < screen.x + screen.width &&
483
+ point.y >= screen.y &&
484
+ point.y < screen.y + screen.height) {
485
+ copyScreenInfo(screen, out);
486
+ return out;
487
+ }
488
+ }
489
+ // Fall back to the nearest screen by distance from point to screen center.
490
+ let bestScreen = screens[0];
491
+ let bestDist = Infinity;
492
+ for (const screen of screens) {
493
+ const cx = screen.x + screen.width / 2;
494
+ const cy = screen.y + screen.height / 2;
495
+ const dx = point.x - cx;
496
+ const dy = point.y - cy;
497
+ const dist = dx * dx + dy * dy;
498
+ if (dist < bestDist) {
499
+ bestDist = dist;
500
+ bestScreen = screen;
501
+ }
502
+ }
503
+ copyScreenInfo(bestScreen, out);
504
+ return out;
505
+ }
506
+ export function getScreenNearestRect(rect, out) {
507
+ const screens = [];
508
+ getScreens(screens);
509
+ if (screens.length === 0) {
510
+ fillDefaultScreenInfo(out);
511
+ return out;
512
+ }
513
+ const cx = rect.x + rect.width / 2;
514
+ const cy = rect.y + rect.height / 2;
515
+ let bestScreen = screens[0];
516
+ let bestDist = Infinity;
517
+ for (const screen of screens) {
518
+ const scx = screen.x + screen.width / 2;
519
+ const scy = screen.y + screen.height / 2;
520
+ const dx = cx - scx;
521
+ const dy = cy - scy;
522
+ const dist = dx * dx + dy * dy;
523
+ if (dist < bestDist) {
524
+ bestDist = dist;
525
+ bestScreen = screen;
526
+ }
527
+ }
528
+ copyScreenInfo(bestScreen, out);
529
+ return out;
530
+ }
531
+ // Fills `out` with every attached display and returns it. out.length is set to the screen count;
532
+ // missing slots are allocated. On the web this is a single screen; an empty array when no window.
533
+ export function getScreens(out) {
534
+ return getScreenBackend().getScreens(out);
535
+ }
536
+ // Fills `out` with the work-area rectangle of the given screen (excluding OS chrome).
537
+ export function getScreenWorkArea(screen, out) {
538
+ out.x = screen.x;
539
+ out.y = screen.y;
540
+ out.width = screen.workWidth;
541
+ out.height = screen.workHeight;
542
+ return out;
543
+ }
544
+ // Subscribes to display change events via the active backend; returns an unsubscribe.
545
+ // Each event carries the affected ScreenInfo and the ScreenChangeKind, plus changedMetrics for
546
+ // ScreenMetricsChanged events.
547
+ export function onScreenChange(listener) {
548
+ return getScreenBackend().subscribe(listener);
549
+ }
550
+ // Watches the Window Management permission for later grant/revoke and invokes `listener` with the
551
+ // new state on each change. Backed by the Permissions API PermissionStatus change event, so it
552
+ // reflects a grant/revoke made outside this call (browser UI, another tab) without polling.
553
+ // Returns a no-op unsubscribe when the Permissions API is unavailable (SSR, jsdom, non-Chromium)
554
+ // or the query rejects — matching getScreenDetailPermission's sentinel discipline.
555
+ export function onScreenDetailPermissionChange(listener) {
556
+ if (typeof navigator === 'undefined' || !('permissions' in navigator))
557
+ return () => { };
558
+ let status = null;
559
+ let cancelled = false;
560
+ const handleChange = () => {
561
+ if (status !== null)
562
+ listener(status.state);
563
+ };
564
+ navigator.permissions
565
+ .query({ name: 'window-management' })
566
+ .then((s) => {
567
+ if (cancelled)
568
+ return;
569
+ status = s;
570
+ s.addEventListener('change', handleChange);
571
+ })
572
+ .catch(() => { });
573
+ return () => {
574
+ cancelled = true;
575
+ status?.removeEventListener('change', handleChange);
576
+ };
577
+ }
578
+ // Invalidates the backend's cached enumeration so the next getScreens / getPrimaryScreen call reads
579
+ // fresh data. Call after a known reconfiguration (e.g. when the backend fires a change event but
580
+ // the application needs to force-refresh before the next natural poll).
581
+ export function refreshScreens() {
582
+ // The web backend re-reads window.screen on every call; no explicit invalidation needed.
583
+ // Native backends should override this via the backend seam if they cache internally.
584
+ // This function is a hook: calling it is always safe.
585
+ }
586
+ // Requests the Window Management permission and, if granted, upgrades the active web backend to
587
+ // expose all attached screens via the Screen Details API. Returns true when permission is granted
588
+ // and the multi-monitor view is active. Returns false in environments where the API is unavailable
589
+ // (SSR, jsdom, non-Chromium browsers) or when the user denies the request.
590
+ //
591
+ // After this returns true, getScreens() enumerates from ScreenDetails.screens and refreshRate is
592
+ // populated from ScreenDetailed.refreshRate. The active backend must be the web backend (created by
593
+ // createWebScreenBackend); calling this when a native host backend is installed is a no-op (native
594
+ // backends provide multi-monitor natively without a permission grant).
595
+ export async function requestScreenDetails() {
596
+ if (typeof window === 'undefined')
597
+ return false;
598
+ const win = window;
599
+ if (typeof win.getScreenDetails !== 'function')
600
+ return false;
601
+ try {
602
+ const details = await win.getScreenDetails();
603
+ // Upgrade the active backend if it is a web backend (has the internal _upgrade hook).
604
+ const b = getScreenBackend();
605
+ b._upgrade?.(details);
606
+ return true;
607
+ }
608
+ catch {
609
+ return false;
610
+ }
611
+ }
612
+ // Converts a point from physical screen pixel coordinates (relative to `screen`'s origin) to DIP
613
+ // (logical) coordinates. Alias-safe: `out` may be the same object as `point`.
614
+ // dipX = point.x / screen.scaleFactor + screen.x
615
+ export function screenToDipPoint(screen, point, out) {
616
+ const px = point.x;
617
+ const py = point.y;
618
+ out.x = px / screen.scaleFactor + screen.x;
619
+ out.y = py / screen.scaleFactor + screen.y;
620
+ return out;
621
+ }
622
+ // Converts a rectangle from physical screen pixel coordinates to DIP (logical) coordinates.
623
+ // Alias-safe: `out` may be the same object as `rect`.
624
+ export function screenToDipRect(screen, rect, out) {
625
+ const rx = rect.x;
626
+ const ry = rect.y;
627
+ const rw = rect.width;
628
+ const rh = rect.height;
629
+ const sf = screen.scaleFactor;
630
+ out.x = rx / sf + screen.x;
631
+ out.y = ry / sf + screen.y;
632
+ out.width = rw / sf;
633
+ out.height = rh / sf;
634
+ return out;
635
+ }
636
+ // Installs a native host screen backend; pass null to fall back to the web default.
637
+ export function setScreenBackend(backend) {
638
+ _backend = backend;
639
+ }
640
+ let _backend = null;
641
+ const _signalSubscriptions = new WeakMap();
642
+ const _scratchPoint = { x: 0, y: 0 };
643
+ // Copies all fields from src to dst.
644
+ function copyScreenInfo(src, dst) {
645
+ dst.id = src.id;
646
+ dst.x = src.x;
647
+ dst.y = src.y;
648
+ dst.width = src.width;
649
+ dst.height = src.height;
650
+ dst.workWidth = src.workWidth;
651
+ dst.workHeight = src.workHeight;
652
+ dst.scaleFactor = src.scaleFactor;
653
+ dst.isPrimary = src.isPrimary;
654
+ dst.rotation = src.rotation;
655
+ dst.orientation = src.orientation;
656
+ dst.refreshRate = src.refreshRate;
657
+ dst.colorDepth = src.colorDepth;
658
+ dst.pixelDepth = src.pixelDepth;
659
+ dst.physicalWidth = src.physicalWidth;
660
+ dst.physicalHeight = src.physicalHeight;
661
+ dst.isHdr = src.isHdr;
662
+ dst.colorSpace = src.colorSpace;
663
+ dst.maxLuminance = src.maxLuminance;
664
+ dst.depthPerComponent = src.depthPerComponent;
665
+ dst.dpi = src.dpi;
666
+ dst.label = src.label;
667
+ dst.internal = src.internal;
668
+ dst.touchSupport = src.touchSupport;
669
+ dst.monochrome = src.monochrome;
670
+ }
671
+ // Returns a ScreenChangedMetrics diff between two ScreenInfo snapshots, or null when nothing changed.
672
+ function diffScreenInfo(prev, curr) {
673
+ const boundsChanged = prev.x !== curr.x || prev.y !== curr.y || prev.width !== curr.width || prev.height !== curr.height;
674
+ const workAreaChanged = prev.workWidth !== curr.workWidth || prev.workHeight !== curr.workHeight;
675
+ const scaleChanged = prev.scaleFactor !== curr.scaleFactor;
676
+ const orientationChanged = prev.rotation !== curr.rotation || prev.orientation !== curr.orientation;
677
+ if (!boundsChanged && !workAreaChanged && !scaleChanged && !orientationChanged)
678
+ return null;
679
+ return {
680
+ bounds: boundsChanged,
681
+ workArea: workAreaChanged,
682
+ scaleFactor: scaleChanged,
683
+ orientation: orientationChanged,
684
+ };
685
+ }
686
+ function fillDefaultScreenInfo(out) {
687
+ out.id = 0;
688
+ out.x = 0;
689
+ out.y = 0;
690
+ out.width = 0;
691
+ out.height = 0;
692
+ out.workWidth = 0;
693
+ out.workHeight = 0;
694
+ out.scaleFactor = 1;
695
+ out.isPrimary = false;
696
+ out.rotation = -1;
697
+ out.orientation = 'Landscape';
698
+ out.refreshRate = -1;
699
+ out.colorDepth = -1;
700
+ out.pixelDepth = -1;
701
+ out.physicalWidth = -1;
702
+ out.physicalHeight = -1;
703
+ out.isHdr = false;
704
+ out.colorSpace = 'srgb';
705
+ out.maxLuminance = -1;
706
+ out.depthPerComponent = -1;
707
+ out.dpi = -1;
708
+ out.label = '';
709
+ out.internal = false;
710
+ out.touchSupport = 'unknown';
711
+ out.monochrome = false;
712
+ }
713
+ function getWebColorSpace() {
714
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function')
715
+ return 'srgb';
716
+ if (window.matchMedia('(color-gamut: rec2020)').matches)
717
+ return 'rec2020';
718
+ if (window.matchMedia('(color-gamut: p3)').matches)
719
+ return 'display-p3';
720
+ return 'srgb';
721
+ }
722
+ function getWebIsHdr() {
723
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function')
724
+ return false;
725
+ return window.matchMedia('(dynamic-range: high)').matches;
726
+ }
727
+ function getWebOrientation() {
728
+ const obj = getWebScreenOrientationObject();
729
+ const type = obj?.type ?? '';
730
+ if (type.startsWith('portrait-primary'))
731
+ return 'Portrait';
732
+ if (type.startsWith('portrait-secondary'))
733
+ return 'PortraitFlipped';
734
+ if (type.startsWith('landscape-secondary'))
735
+ return 'LandscapeFlipped';
736
+ return 'Landscape';
737
+ }
738
+ function getWebRotation() {
739
+ const obj = getWebScreenOrientationObject();
740
+ const angle = obj?.angle;
741
+ if (typeof angle === 'number')
742
+ return angle;
743
+ return -1;
744
+ }
745
+ function getWebScreenOrientationObject() {
746
+ if (typeof window === 'undefined' || typeof window.screen === 'undefined')
747
+ return null;
748
+ const s = window.screen;
749
+ return s.orientation ?? null;
750
+ }
751
+ //# sourceMappingURL=screen.js.map