@bobfrankston/winpos 2.0.50 → 2.0.52

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/README.md CHANGED
@@ -89,6 +89,7 @@ Screens are numbered starting from 0, sorted by position:
89
89
  - `-title <name>` - Window title/pattern (alternative to positional)
90
90
  - `-pos x,y[,screen]` - Position (comma-separated, screen optional)
91
91
  - `-size w,h` - Window size (width,height)
92
+ - `-numsize <inches>` - Physical size of the screen-number overlays (default 2)
92
93
  - `-min` - Minimize window
93
94
  - `-max` - Maximize window
94
95
  - `-load <file>` - Load config from JSON file
@@ -96,6 +97,37 @@ Screens are numbered starting from 0, sorted by position:
96
97
  - `*` - List all windows
97
98
  - `**` - List all windows including ignored
98
99
 
100
+ ## Identifying Screens
101
+
102
+ `winpos` with no arguments, and `winpos *` (or `**`), flash each screen's number
103
+ in the middle of that screen for 5 seconds - the same idea as the Identify
104
+ button in Windows display settings, so you can see which physical monitor is
105
+ screen 0, 1, 2 ... before positioning anything there. With no arguments the
106
+ numbers come up alongside the usage text and its screen table.
107
+
108
+ The numbers are drawn with [msger](https://www.npmjs.com/package/@bobfrankston/msger)
109
+ and all appear at once. They close themselves; winpos returns immediately
110
+ without waiting for them. If msger is unavailable the listing still works, just
111
+ without the numbers.
112
+
113
+ Each overlay is 2 inches square **on the glass**, not 2 inches' worth of pixels
114
+ - `-numsize <inches>` changes that. Sizing anything in pixels (or in CSS
115
+ inches, which are just 96 pixels) comes out a different physical size on every
116
+ monitor, because Windows' scale factor is a preference rather than a
117
+ measurement: a 28" 4K panel left at 100% claims 96 DPI while really being 160.
118
+ winpos asks the display driver for the panel's physical millimetres (EDID, via
119
+ a GDI display DC) and works out each screen's true DPI, so the same square of
120
+ glass lights up on every monitor. Drivers that report nothing fall back to the
121
+ old pixel sizing.
122
+
123
+ ```typescript
124
+ import { showScreenNumbers, enumerateScreens, sortScreens, screenDpi } from '@bobfrankston/winpos';
125
+
126
+ const screens = sortScreens(enumerateScreens());
127
+ await showScreenNumbers(screens, 5, 2); // 5 seconds, 2 inches
128
+ console.log(screens.map(screenDpi)); // True DPI per screen
129
+ ```
130
+
99
131
  ## Parameters
100
132
 
101
133
  ### Positional (legacy)
package/ffi-wrapper.d.ts CHANGED
@@ -25,8 +25,11 @@ export interface MONITORINFO {
25
25
  rcMonitor: RECT;
26
26
  rcWork: RECT;
27
27
  dwFlags: number;
28
+ /** Display device name, e.g. \\.\DISPLAY1 (MONITORINFOEXW szDevice) */
29
+ szDevice?: string;
28
30
  }
29
31
  export declare const MONITORINFOF_PRIMARY = 1;
32
+ export declare const DEFAULT_SCALE_PERCENT = 100;
30
33
  export interface WindowsAPI {
31
34
  EnumWindows: (callback: (hwnd: bigint, lParam: bigint) => boolean, lParam: bigint) => boolean;
32
35
  GetWindowTextW: (hwnd: bigint, text: any, maxCount: number) => number;
@@ -39,6 +42,13 @@ export interface WindowsAPI {
39
42
  GetWindowPlacement: (hwnd: bigint, placement: any) => boolean;
40
43
  EnumDisplayMonitors: (callback: (hMonitor: bigint, hdcMonitor: bigint, lprcMonitor: any, dwData: bigint) => boolean, dwData: bigint) => boolean;
41
44
  GetMonitorInfoW: (hMonitor: bigint, lpmi: any) => boolean;
45
+ /** Monitor scale factor as a percent (100, 125, 150...). 100 when unavailable. */
46
+ GetScaleFactorForMonitor?: (hMonitor: bigint) => number;
47
+ /** Physical panel size in millimetres from the display driver (EDID). Null when unavailable. */
48
+ GetPhysicalSizeMm?: (deviceName: string) => {
49
+ width: number;
50
+ height: number;
51
+ } | null;
42
52
  }
43
53
  declare const user32: WindowsAPI;
44
54
  export { user32, isBun };
package/ffi-wrapper.js CHANGED
@@ -16,7 +16,12 @@ export const SW_MINIMIZE = 6;
16
16
  export const SW_SHOWMINNOACTIVE = 7;
17
17
  export const SW_SHOWNA = 8;
18
18
  export const SW_RESTORE = 9;
19
+ // GetDeviceCaps indices for the panel's physical size in millimetres
20
+ const HORZSIZE = 4;
21
+ const VERTSIZE = 6;
19
22
  export const MONITORINFOF_PRIMARY = 1;
23
+ // Monitor scale factor as a percentage (100 = no scaling, 125 = 125%)
24
+ export const DEFAULT_SCALE_PERCENT = 100;
20
25
  async function initializeFFI() {
21
26
  if (isBun) {
22
27
  // @ts-ignore - bun:ffi is only available in Bun
@@ -95,6 +100,34 @@ async function initializeFFI() {
95
100
  const MonitorEnumProc = koffi.proto('bool __stdcall MonitorEnumProc(void *hMonitor, void *hdcMonitor, RECT *lprcMonitor, void *dwData)');
96
101
  const EnumDisplayMonitors = lib.func('bool __stdcall EnumDisplayMonitors(void *hdc, RECT *lprcClip, MonitorEnumProc *lpfnEnum, void *dwData)');
97
102
  const GetMonitorInfoW = lib.func('bool __stdcall GetMonitorInfoW(void *hMonitor, void *lpmi)');
103
+ // Per-monitor scaling lives in shcore.dll (Windows 8.1+). Optional: a
104
+ // missing shcore just means every monitor reports 100%.
105
+ // GetScaleFactorForMonitor, not GetDpiForMonitor: the DPI call reports the
106
+ // DPI *as the calling process sees it*, and winpos is DPI-unaware, so it
107
+ // always answers 96. This one reports the monitor's real scale factor.
108
+ let GetScaleFactorForMonitorRaw = null;
109
+ try {
110
+ const shcore = koffi.load('shcore.dll');
111
+ GetScaleFactorForMonitorRaw = shcore.func('int __stdcall GetScaleFactorForMonitor(void *hMonitor, void *pScale)');
112
+ }
113
+ catch {
114
+ // Leave null - callers fall back to 100%
115
+ }
116
+ // A display DC reports the panel's physical size in millimetres, straight
117
+ // from the driver's EDID. That is the only measurement of a monitor
118
+ // available: the scale factor above is a user preference, so a 160 DPI
119
+ // panel left at 100% claims to be a 96 DPI one. Optional, like shcore -
120
+ // callers fall back to nominal DPI when this returns null.
121
+ let CreateDCW = null, GetDeviceCaps = null, DeleteDC = null;
122
+ try {
123
+ const gdi32 = koffi.load('gdi32.dll');
124
+ CreateDCW = gdi32.func('void * __stdcall CreateDCW(const char16_t *pwszDriver, const char16_t *pwszDevice, const char16_t *pszPort, const void *pdm)');
125
+ GetDeviceCaps = gdi32.func('int __stdcall GetDeviceCaps(void *hdc, int index)');
126
+ DeleteDC = gdi32.func('bool __stdcall DeleteDC(void *hdc)');
127
+ }
128
+ catch {
129
+ CreateDCW = null;
130
+ }
98
131
  // Keep references to registered callbacks to prevent garbage collection
99
132
  // These must persist for the entire duration of the native callback invocation
100
133
  const __callbackRefs = [];
@@ -139,14 +172,18 @@ async function initializeFFI() {
139
172
  return result;
140
173
  },
141
174
  GetMonitorInfoW: (hMonitor, lpmi) => {
142
- // MONITORINFO structure:
175
+ // MONITORINFOEXW structure:
143
176
  // DWORD cbSize (4 bytes)
144
177
  // RECT rcMonitor (16 bytes)
145
178
  // RECT rcWork (16 bytes)
146
179
  // DWORD dwFlags (4 bytes)
147
- // Total: 40 bytes
148
- const buffer = Buffer.alloc(40);
149
- buffer.writeUInt32LE(40, 0); // cbSize
180
+ // WCHAR szDevice[32] (64 bytes) - the EX part, and the reason for
181
+ // asking: the device name is what gdi32 wants to open a DC on
182
+ // this monitor, and the only reliable tie from an HMONITOR to a
183
+ // \\.\DISPLAYn. cbSize is what selects EX over plain MONITORINFO.
184
+ // Total: 104 bytes
185
+ const buffer = Buffer.alloc(104);
186
+ buffer.writeUInt32LE(104, 0); // cbSize
150
187
  const result = GetMonitorInfoW(hMonitor, buffer);
151
188
  if (result) {
152
189
  lpmi.cbSize = buffer.readUInt32LE(0);
@@ -163,9 +200,45 @@ async function initializeFFI() {
163
200
  Bottom: buffer.readInt32LE(32),
164
201
  };
165
202
  lpmi.dwFlags = buffer.readUInt32LE(36);
203
+ const device = buffer.toString('utf16le', 40, 104);
204
+ const end = device.indexOf('\0');
205
+ lpmi.szDevice = end >= 0 ? device.substring(0, end) : device;
166
206
  }
167
207
  return result;
168
208
  },
209
+ GetScaleFactorForMonitor: (hMonitor) => {
210
+ if (!GetScaleFactorForMonitorRaw)
211
+ return DEFAULT_SCALE_PERCENT;
212
+ const scale = Buffer.alloc(4);
213
+ const hr = GetScaleFactorForMonitorRaw(hMonitor, scale);
214
+ return hr === 0 ? (scale.readUInt32LE(0) || DEFAULT_SCALE_PERCENT) : DEFAULT_SCALE_PERCENT;
215
+ },
216
+ GetPhysicalSizeMm: (deviceName) => {
217
+ if (!CreateDCW || !deviceName)
218
+ return null;
219
+ let hdc = null;
220
+ try {
221
+ // An information DC would do, but CreateDCW on the DISPLAY
222
+ // driver is what every monitor exposes; the caps are the same.
223
+ hdc = CreateDCW('DISPLAY', deviceName, null, null);
224
+ if (!hdc)
225
+ return null;
226
+ const width = GetDeviceCaps(hdc, HORZSIZE);
227
+ const height = GetDeviceCaps(hdc, VERTSIZE);
228
+ return width > 0 && height > 0 ? { width, height } : null;
229
+ }
230
+ catch {
231
+ return null; // Best-effort: no measurement is not an error
232
+ }
233
+ finally {
234
+ if (hdc) {
235
+ try {
236
+ DeleteDC(hdc);
237
+ }
238
+ catch { /* nothing useful to do */ }
239
+ }
240
+ }
241
+ },
169
242
  };
170
243
  }
171
244
  const user32 = await initializeFFI();
package/ignores.txt CHANGED
@@ -68,6 +68,7 @@ ViewDeferd*
68
68
  W
69
69
  Widgets
70
70
  Windows*
71
+ winpos screen *
71
72
  WinUI*
72
73
  WISPTIS
73
74
  WMS*
package/index.d.ts CHANGED
@@ -4,9 +4,10 @@
4
4
  * Cross-platform Node.js/Bun implementation with FFI
5
5
  */
6
6
  import { user32, RECT } from './ffi-wrapper.js';
7
- import { enumerateScreens, sortScreens, ScreenInfo } from './screens.js';
7
+ import { enumerateScreens, sortScreens, screenScale, screenDpi, ScreenInfo } from './screens.js';
8
8
  import { enumerateWindows, findWindowsByPattern, WindowInfo, WindowState, getWindowState, setWindowState, isMinimized, isMaximized, isNormal, minimizeWindow, maximizeWindow, restoreWindow } from './windows.js';
9
9
  import { enumerateTabs, TabInfo, mightHaveTabs, TAB_ENUMERATION_NOTE } from './tabs.js';
10
+ import { showScreenNumbers, OVERLAY_SECONDS, OVERLAY_INCHES } from './screennums.js';
10
11
  export interface WindowConfig {
11
12
  name: string;
12
13
  regex?: boolean;
@@ -23,7 +24,7 @@ export interface WindowConfig {
23
24
  maximize?: boolean;
24
25
  }
25
26
  export type ConfigFile = WindowConfig | WindowConfig[];
26
- export { RECT, ScreenInfo, WindowInfo, WindowState, TabInfo, enumerateScreens, sortScreens, enumerateWindows, findWindowsByPattern, getWindowState, setWindowState, isMinimized, isMaximized, isNormal, minimizeWindow, maximizeWindow, restoreWindow, enumerateTabs, mightHaveTabs, TAB_ENUMERATION_NOTE, user32 };
27
+ export { RECT, ScreenInfo, WindowInfo, WindowState, TabInfo, enumerateScreens, sortScreens, screenScale, screenDpi, enumerateWindows, findWindowsByPattern, getWindowState, setWindowState, isMinimized, isMaximized, isNormal, minimizeWindow, maximizeWindow, restoreWindow, enumerateTabs, mightHaveTabs, TAB_ENUMERATION_NOTE, showScreenNumbers, OVERLAY_INCHES, OVERLAY_SECONDS, user32 };
27
28
  /**
28
29
  * Convert screen-relative coordinates to global coordinates
29
30
  * @param x - X coordinate relative to screen
package/index.js CHANGED
@@ -4,15 +4,16 @@
4
4
  * Cross-platform Node.js/Bun implementation with FFI
5
5
  */
6
6
  import { user32 } from './ffi-wrapper.js';
7
- import { enumerateScreens, sortScreens } from './screens.js';
7
+ import { enumerateScreens, sortScreens, screenScale, screenDpi } from './screens.js';
8
8
  import { enumerateWindows, findWindowsByPattern, loadIgnorePatterns, WindowState, getWindowState, setWindowState, isMinimized, isMaximized, isNormal, minimizeWindow, maximizeWindow, restoreWindow } from './windows.js';
9
9
  import { enumerateTabs, mightHaveTabs, TAB_ENUMERATION_NOTE } from './tabs.js';
10
+ import { showScreenNumbers, OVERLAY_SECONDS, OVERLAY_INCHES } from './screennums.js';
10
11
  import { join, resolve, isAbsolute } from 'path';
11
12
  import { readFileSync, writeFileSync, existsSync } from 'fs';
12
13
  import { parse as parseJsonc, printParseErrorCode } from 'jsonc-parser';
13
14
  import * as packageJson from './package.json' with { type: 'json' };
14
15
  // Re-export public API for library usage
15
- export { WindowState, enumerateScreens, sortScreens, enumerateWindows, findWindowsByPattern, getWindowState, setWindowState, isMinimized, isMaximized, isNormal, minimizeWindow, maximizeWindow, restoreWindow, enumerateTabs, mightHaveTabs, TAB_ENUMERATION_NOTE, user32 };
16
+ export { WindowState, enumerateScreens, sortScreens, screenScale, screenDpi, enumerateWindows, findWindowsByPattern, getWindowState, setWindowState, isMinimized, isMaximized, isNormal, minimizeWindow, maximizeWindow, restoreWindow, enumerateTabs, mightHaveTabs, TAB_ENUMERATION_NOTE, showScreenNumbers, OVERLAY_INCHES, OVERLAY_SECONDS, user32 };
16
17
  // WindowConfig and ConfigFile types are exported from interface definitions above
17
18
  // Note: screenPosToPos is exported separately below (after the function definition)
18
19
  /**
@@ -178,6 +179,16 @@ function parseArgs(args) {
178
179
  opts.windowTitle = args[i];
179
180
  }
180
181
  break;
182
+ case 'numsize':
183
+ i++;
184
+ if (i < args.length) {
185
+ const inches = parseFloat(args[i]);
186
+ if (inches > 0)
187
+ opts.numSize = inches;
188
+ else
189
+ console.log(`Ignoring -numsize ${args[i]}: expected a size in inches`);
190
+ }
191
+ break;
181
192
  case 'min':
182
193
  opts.minimize = true;
183
194
  break;
@@ -288,6 +299,7 @@ function applyWindowConfig(config, screensInfo) {
288
299
  let dbg = false;
289
300
  let dbgVerbose = false; // -dbg flag for detailed output
290
301
  let screens = [];
302
+ let numSize = OVERLAY_INCHES; // -numsize: screen-number overlay size in inches
291
303
  /**
292
304
  * Convert screen-relative coordinates to global coordinates
293
305
  * @param x - X coordinate relative to screen
@@ -447,8 +459,19 @@ function listAllWindows(includeIgnored = false) {
447
459
  }
448
460
  console.log();
449
461
  console.log('Command format: tswinpos <title> <x> <y> <screen> [<width> <height>]');
462
+ // Flash each screen's number on that screen - launched last so the overlays
463
+ // can't turn up in the listing above. Detached, so we don't wait for them.
464
+ void showScreenNumbers(screens, OVERLAY_SECONDS, numSize);
450
465
  }
451
466
  function usage(msg = '') {
467
+ printUsage(msg);
468
+ process.exit(1);
469
+ }
470
+ /**
471
+ * The usage text on its own - no exit, so the no-argument case can print it
472
+ * while the screen-number overlays are still being launched.
473
+ */
474
+ function printUsage(msg = '') {
452
475
  if (msg)
453
476
  console.log(msg);
454
477
  console.log('Usage: winpos [options] <title> <x> <y> <screen> [<width> <height>]');
@@ -466,6 +489,7 @@ function usage(msg = '') {
466
489
  console.log(' -title name Window title/pattern (alternative to positional)');
467
490
  console.log(' -pos x,y[,s] Position (x,y with optional screen index)');
468
491
  console.log(' -size w,h Window size (width,height)');
492
+ console.log(` -numsize n Screen-number overlay size in inches (default ${OVERLAY_INCHES})`);
469
493
  console.log(' -min Minimize window');
470
494
  console.log(' -max Maximize window');
471
495
  console.log(' -load file Load config from JSON/JSONC file (comments + trailing commas OK)');
@@ -474,6 +498,8 @@ function usage(msg = '') {
474
498
  console.log('Position/size values can be pixels or percentages (e.g. 50%)');
475
499
  console.log('Title can be regex (/pattern/) or prefix (title*)');
476
500
  console.log('* = list windows, ** = list all including ignored');
501
+ console.log(` (both, like winpos with no arguments, also flash each screen's`);
502
+ console.log(` number on that screen for ${OVERLAY_SECONDS} seconds)`);
477
503
  console.log();
478
504
  console.log('JSON format: {"name":"app","regex":false,"pos":{"x":0,"y":0,"screen":0},"size":{"w":800,"h":600}}');
479
505
  console.log(' or array: [{...}, {...}]');
@@ -484,7 +510,6 @@ function usage(msg = '') {
484
510
  const screen = screens[i];
485
511
  console.log(`${i.toString().padStart(6)} | ${screen.bounds.Left.toString().padStart(5)} ${screen.bounds.Top.toString().padStart(5)} | ${screen.bounds.Width.toString().padStart(5)} ${screen.bounds.Height.toString().padStart(5)}`);
486
512
  }
487
- process.exit(1);
488
513
  }
489
514
  export function run(args) {
490
515
  // Initialize screens
@@ -506,6 +531,8 @@ export function run(args) {
506
531
  // Set debug flags
507
532
  dbg = opts.debug;
508
533
  dbgVerbose = opts.debugVerbose;
534
+ if (opts.numSize)
535
+ numSize = opts.numSize;
509
536
  if (dbg)
510
537
  console.log('Debug mode enabled.');
511
538
  if (dbgVerbose)
@@ -596,6 +623,17 @@ export function run(args) {
596
623
  showWindowInfo(posArgs[0]);
597
624
  return;
598
625
  }
626
+ // Nothing to act on (bare winpos, or -help): flash each screen's number on
627
+ // that screen while the usage text - screen table and all - prints, so the
628
+ // table has something to point at. The overlays time out and close
629
+ // themselves; we only wait for the launch, because process.exit here would
630
+ // kill the spawn in mid-flight and no numbers would ever appear.
631
+ if (posArgs.length === 0) {
632
+ const overlays = showScreenNumbers(screens, OVERLAY_SECONDS, numSize);
633
+ printUsage();
634
+ void overlays.finally(() => process.exit(1));
635
+ return;
636
+ }
599
637
  if (posArgs.length < 4) {
600
638
  usage();
601
639
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/winpos",
3
- "version": "2.0.50",
3
+ "version": "2.0.52",
4
4
  "description": "TypeScript implementation of winpos - Windows window positioning utility",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -32,6 +32,7 @@
32
32
  "@types/node": "^25.3.0"
33
33
  },
34
34
  "dependencies": {
35
+ "@bobfrankston/msger": "^0.1.424",
35
36
  "jsonc-parser": "^3.3.1",
36
37
  "koffi": "^2.9.2"
37
38
  },
@@ -45,5 +46,17 @@
45
46
  "bugs": {
46
47
  "url": "https://github.com/BobFrankston/winpos/issues"
47
48
  },
48
- "homepage": "https://github.com/BobFrankston/winpos#readme"
49
+ "homepage": "https://github.com/BobFrankston/winpos#readme",
50
+ ".dependencies": {
51
+ "@bobfrankston/msger": "file:../msgx/msger",
52
+ "jsonc-parser": "^3.3.1",
53
+ "koffi": "^2.9.2"
54
+ },
55
+ ".transformedSnapshot": {
56
+ "dependencies": {
57
+ "@bobfrankston/msger": "^0.1.424",
58
+ "jsonc-parser": "^3.3.1",
59
+ "koffi": "^2.9.2"
60
+ }
61
+ }
49
62
  }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Screen number overlay - briefly flashes each screen's winpos index in the
3
+ * middle of that screen, so you can see which physical display is screen 0,
4
+ * screen 1, ... without counting corners.
5
+ *
6
+ * Rendered with msger (@bobfrankston/msger). The overlays are detached and
7
+ * self-closing, so winpos exits immediately and leaves them up.
8
+ */
9
+ import { type ScreenInfo } from './screens.js';
10
+ /** Default overlay size, in real inches on the glass */
11
+ export declare const OVERLAY_INCHES = 2;
12
+ export declare const OVERLAY_SECONDS = 5;
13
+ /**
14
+ * Show the index of every screen in the middle of that screen.
15
+ *
16
+ * Never throws: the overlay is a convenience, so a missing or unhappy msger
17
+ * must not break the window listing it accompanies.
18
+ *
19
+ * @param screens - Screens in winpos order (the index shown is the array index)
20
+ * @param seconds - How long each overlay stays up
21
+ * @param inches - Size of each overlay in real inches, measured on the glass
22
+ */
23
+ export declare function showScreenNumbers(screens: ScreenInfo[], seconds?: number, inches?: number): Promise<void>;
24
+ //# sourceMappingURL=screennums.d.ts.map
package/screennums.js ADDED
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Screen number overlay - briefly flashes each screen's winpos index in the
3
+ * middle of that screen, so you can see which physical display is screen 0,
4
+ * screen 1, ... without counting corners.
5
+ *
6
+ * Rendered with msger (@bobfrankston/msger). The overlays are detached and
7
+ * self-closing, so winpos exits immediately and leaves them up.
8
+ */
9
+ import { screenDpi, screenScale } from './screens.js';
10
+ /** Default overlay size, in real inches on the glass */
11
+ export const OVERLAY_INCHES = 2;
12
+ export const OVERLAY_SECONDS = 5;
13
+ // Guard rails for the computed window size, in case a driver's reported
14
+ // dimensions survive the DPI sanity check but still make no sense.
15
+ const MIN_CARD = 80;
16
+ const MAX_CARD = 1200;
17
+ /**
18
+ * Overlay size in logical pixels so it measures `inches` on that panel.
19
+ *
20
+ * Pixels are not a length: the same 360-pixel square is 3.9 inches on a 93 DPI
21
+ * 32" 1440p panel and 2.2 inches on a 160 DPI 28" 4K one. Going through the
22
+ * screen's true DPI is what makes the number the same physical size on every
23
+ * monitor. The scale factor divides back out because msger positions and sizes
24
+ * windows in logical pixels, which Windows then multiplies by that factor.
25
+ */
26
+ function cardSize(screen, inches) {
27
+ const dips = (inches * screenDpi(screen)) / screenScale(screen);
28
+ return Math.round(Math.min(MAX_CARD, Math.max(MIN_CARD, dips)));
29
+ }
30
+ /**
31
+ * Full page for one overlay - big number, no msger template or buttons.
32
+ *
33
+ * Everything is sized in vh so the page fits whatever window the caller asked
34
+ * for: the window is what carries the physical size, and the digit is simply a
35
+ * fraction of it.
36
+ */
37
+ function overlayHtml(index, screen, seconds) {
38
+ const b = screen.bounds;
39
+ // Size in winpos's own coordinates - the numbers you'd type at it - plus
40
+ // the scaling, since that's why they don't match the monitor's spec sheet.
41
+ const label = [
42
+ `${b.Width} x ${b.Height}`,
43
+ screen.scalePercent !== 100 ? `${screen.scalePercent}%` : '',
44
+ screen.primary ? 'primary' : ''
45
+ ].filter(Boolean).join(' - ');
46
+ return `<!doctype html>
47
+ <html><head><meta charset="utf-8"><title>winpos screen ${index}</title>
48
+ <style>
49
+ html, body { height: 100%; margin: 0; background: transparent; }
50
+ body {
51
+ display: flex; align-items: center; justify-content: center;
52
+ font-family: "Segoe UI", system-ui, sans-serif;
53
+ user-select: none;
54
+ }
55
+ .card {
56
+ display: flex; flex-direction: column;
57
+ align-items: center; justify-content: center;
58
+ width: 100%; height: 100%;
59
+ box-sizing: border-box;
60
+ /* Solid black, white digits - the highest-contrast reading of the number.
61
+ msger's overlay window composites onto white rather than the desktop
62
+ here, so a translucent background just came out gray; painting it
63
+ opaque is both clearer and honest about what's actually drawn. */
64
+ background: #000000;
65
+ color: #ffffff;
66
+ }
67
+ .num { font-size: 62vh; font-weight: 700; line-height: 1; }
68
+ .label { font-size: 4.5vh; opacity: 0.75; margin-top: 3vh; }
69
+ </style></head>
70
+ <body><div class="card"><div class="num">${index}</div><div class="label">${label}</div></div>
71
+ <script>
72
+ // The page dismisses itself: msger's own timeout is only checked when the
73
+ // window sees an event, so an overlay nobody touches sits there forever.
74
+ // Posting a result over the IPC bridge is what the msger template does for
75
+ // a button click - it wakes the host, which then exits.
76
+ setTimeout(() => {
77
+ if (window.ipc && window.ipc.postMessage)
78
+ window.ipc.postMessage(JSON.stringify({ button: 'timeout', timeout: true }));
79
+ else
80
+ window.close();
81
+ }, ${Math.round(seconds * 1000)});
82
+ </script>
83
+ </body></html>`;
84
+ }
85
+ /**
86
+ * Show the index of every screen in the middle of that screen.
87
+ *
88
+ * Never throws: the overlay is a convenience, so a missing or unhappy msger
89
+ * must not break the window listing it accompanies.
90
+ *
91
+ * @param screens - Screens in winpos order (the index shown is the array index)
92
+ * @param seconds - How long each overlay stays up
93
+ * @param inches - Size of each overlay in real inches, measured on the glass
94
+ */
95
+ export async function showScreenNumbers(screens, seconds = OVERLAY_SECONDS, inches = OVERLAY_INCHES) {
96
+ let showMessageBox;
97
+ try {
98
+ ({ showMessageBox } = await import('@bobfrankston/msger'));
99
+ }
100
+ catch {
101
+ return; // msger not installed - skip the overlay silently
102
+ }
103
+ // All of them are launched at once so the numbers appear together.
104
+ await Promise.all(screens.map((screen, i) => {
105
+ const b = screen.bounds;
106
+ // msger takes its position in device-independent pixels, which it then
107
+ // scales by the target monitor's DPI. winpos's own coordinates are
108
+ // already DIPs *within* a monitor, but each monitor's origin is in
109
+ // physical pixels - so only the origin needs dividing by the scale.
110
+ // Without this, the overlay on a 125% monitor whose origin is a few
111
+ // thousand pixels out lands far right of center (5K screen, 768px off).
112
+ const scale = screenScale(screen);
113
+ const card = cardSize(screen, inches);
114
+ const x = b.Left / scale + (b.Width - card) / 2;
115
+ const y = b.Top / scale + (b.Height - card) / 2;
116
+ return showMessageBox({
117
+ title: `winpos screen ${i}`,
118
+ html: overlayHtml(i, screen, seconds),
119
+ rawHtml: true, // No msger template, no OK button
120
+ overlay: true, // Frameless, transparent, click-through, on top
121
+ size: { width: card, height: card },
122
+ pos: { x: Math.round(x), y: Math.round(y) },
123
+ timeout: seconds + 5, // Backstop if the page never runs
124
+ detach: true, // Outlives winpos itself
125
+ // Own WebView2 profile per overlay: msger windows sharing one
126
+ // user-data dir also share a browser process, and starting all of
127
+ // them at once made that shared process fall over - overlays came
128
+ // up one at a time and some died early. Separate profiles start in
129
+ // parallel and are independent.
130
+ profile: `winpos-screen-${i}`
131
+ }).catch(() => { });
132
+ }));
133
+ }
134
+ //# sourceMappingURL=screennums.js.map
package/screens.d.ts CHANGED
@@ -12,7 +12,30 @@ export interface ScreenInfo {
12
12
  Height: number;
13
13
  };
14
14
  primary: boolean;
15
+ /** Display scaling of this monitor as a percent: 100, 125, 150... */
16
+ scalePercent: number;
17
+ /** Physical panel size in millimetres (EDID), when the driver reports it */
18
+ physicalMm?: {
19
+ width: number;
20
+ height: number;
21
+ };
15
22
  }
23
+ /** Display scaling of a screen as a factor (1.25 for a 125% display) */
24
+ export declare function screenScale(screen: ScreenInfo): number;
25
+ /** What CSS assumes an inch is, and what Windows calls 100% scaling */
26
+ export declare const NOMINAL_DPI = 96;
27
+ /**
28
+ * True pixels-per-inch of a screen, measured from the panel's physical size.
29
+ *
30
+ * Not the same thing as the scale factor: scaling is a preference, so a 28"
31
+ * 4K panel left at 100% reports 96 DPI while actually being 160. Anything
32
+ * sized in pixels - or in CSS inches, which are just 96 pixels - therefore
33
+ * comes out a different physical size on each monitor.
34
+ *
35
+ * Falls back to nominal (96 x scale) when the driver won't say, which is the
36
+ * old pixel-sized behaviour.
37
+ */
38
+ export declare function screenDpi(screen: ScreenInfo): number;
16
39
  export declare function enumerateScreens(): ScreenInfo[];
17
40
  export declare function sortScreens(screens: ScreenInfo[]): ScreenInfo[];
18
41
  //# sourceMappingURL=screens.d.ts.map
package/screens.js CHANGED
@@ -1,7 +1,38 @@
1
1
  /**
2
2
  * Screen management - enumerate and sort displays
3
3
  */
4
- import { user32, isBun, MONITORINFOF_PRIMARY } from './ffi-wrapper.js';
4
+ import { user32, isBun, MONITORINFOF_PRIMARY, DEFAULT_SCALE_PERCENT } from './ffi-wrapper.js';
5
+ /** Display scaling of a screen as a factor (1.25 for a 125% display) */
6
+ export function screenScale(screen) {
7
+ return (screen.scalePercent || DEFAULT_SCALE_PERCENT) / 100;
8
+ }
9
+ /** What CSS assumes an inch is, and what Windows calls 100% scaling */
10
+ export const NOMINAL_DPI = 96;
11
+ // A real monitor outside this range means the driver lied about its size
12
+ // (some report the whole video wall, projectors report nothing sensible).
13
+ const MIN_PLAUSIBLE_DPI = 30;
14
+ const MAX_PLAUSIBLE_DPI = 700;
15
+ /**
16
+ * True pixels-per-inch of a screen, measured from the panel's physical size.
17
+ *
18
+ * Not the same thing as the scale factor: scaling is a preference, so a 28"
19
+ * 4K panel left at 100% reports 96 DPI while actually being 160. Anything
20
+ * sized in pixels - or in CSS inches, which are just 96 pixels - therefore
21
+ * comes out a different physical size on each monitor.
22
+ *
23
+ * Falls back to nominal (96 x scale) when the driver won't say, which is the
24
+ * old pixel-sized behaviour.
25
+ */
26
+ export function screenDpi(screen) {
27
+ const scale = screenScale(screen);
28
+ const mm = screen.physicalMm;
29
+ if (!mm || mm.width <= 0)
30
+ return NOMINAL_DPI * scale;
31
+ // bounds are logical pixels, so undo the scaling to get what the panel
32
+ // actually lights up - that is what spans those millimetres.
33
+ const dpi = (screen.bounds.Width * scale) / (mm.width / 25.4);
34
+ return dpi >= MIN_PLAUSIBLE_DPI && dpi <= MAX_PLAUSIBLE_DPI ? dpi : NOMINAL_DPI * scale;
35
+ }
5
36
  export function enumerateScreens() {
6
37
  const screens = [];
7
38
  let monitorIndex = 0;
@@ -16,8 +47,12 @@ export function enumerateScreens() {
16
47
  const monitorInfo = {};
17
48
  if (user32.GetMonitorInfoW(hMonitor, monitorInfo)) {
18
49
  const rcMonitor = monitorInfo.rcMonitor;
50
+ // The driver's own name for this monitor - enumeration
51
+ // order is not the DISPLAYn numbering, so a name made up
52
+ // from the index would point gdi32 at the wrong panel.
53
+ const deviceName = monitorInfo.szDevice || `\\\\.\\DISPLAY${monitorIndex + 1}`;
19
54
  screens.push({
20
- deviceName: `\\\\.\\DISPLAY${monitorIndex + 1}`,
55
+ deviceName,
21
56
  bounds: {
22
57
  Left: rcMonitor.Left,
23
58
  Top: rcMonitor.Top,
@@ -27,6 +62,8 @@ export function enumerateScreens() {
27
62
  Height: rcMonitor.Bottom - rcMonitor.Top,
28
63
  },
29
64
  primary: (monitorInfo.dwFlags & MONITORINFOF_PRIMARY) !== 0,
65
+ scalePercent: user32.GetScaleFactorForMonitor?.(hMonitor) ?? DEFAULT_SCALE_PERCENT,
66
+ physicalMm: user32.GetPhysicalSizeMm?.(deviceName) ?? undefined,
30
67
  });
31
68
  monitorIndex++;
32
69
  }
@@ -53,6 +90,7 @@ export function enumerateScreens() {
53
90
  Height: 1080,
54
91
  },
55
92
  primary: true,
93
+ scalePercent: DEFAULT_SCALE_PERCENT,
56
94
  });
57
95
  }
58
96
  // If no screens were found, add a default one
@@ -68,6 +106,7 @@ export function enumerateScreens() {
68
106
  Height: 1080,
69
107
  },
70
108
  primary: true,
109
+ scalePercent: DEFAULT_SCALE_PERCENT,
71
110
  });
72
111
  }
73
112
  return screens;
Binary file