@bobfrankston/winpos 2.0.51 → 2.0.53

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/screennums.js CHANGED
@@ -6,13 +6,33 @@
6
6
  * Rendered with msger (@bobfrankston/msger). The overlays are detached and
7
7
  * self-closing, so winpos exits immediately and leaves them up.
8
8
  */
9
- import { screenScale } from './screens.js';
10
- const CARD = 260; // Overlay content size in pixels (square)
11
- const FRAME_W = 16; // Window border added around that content
12
- const FRAME_H = 46; // Border + title bar
9
+ import { screenDpi, screenScale } from './screens.js';
10
+ /** Default overlay size, in real inches on the glass */
11
+ export const OVERLAY_INCHES = 2;
13
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
+ }
14
30
  /**
15
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.
16
36
  */
17
37
  function overlayHtml(index, screen, seconds) {
18
38
  const b = screen.bounds;
@@ -26,18 +46,28 @@ function overlayHtml(index, screen, seconds) {
26
46
  return `<!doctype html>
27
47
  <html><head><meta charset="utf-8"><title>winpos screen ${index}</title>
28
48
  <style>
29
- html, body { height: 100%; margin: 0; }
49
+ html, body { height: 100%; margin: 0; background: transparent; }
30
50
  body {
31
- display: flex; flex-direction: column;
32
- align-items: center; justify-content: center;
33
- background: #1e1e1e; color: #f0f0f0;
51
+ display: flex; align-items: center; justify-content: center;
34
52
  font-family: "Segoe UI", system-ui, sans-serif;
35
53
  user-select: none;
36
54
  }
37
- .num { font-size: 160px; font-weight: 700; line-height: 1; }
38
- .label { font-size: 16px; opacity: 0.65; margin-top: 12px; }
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; }
39
69
  </style></head>
40
- <body><div class="num">${index}</div><div class="label">${label}</div>
70
+ <body><div class="card"><div class="num">${index}</div><div class="label">${label}</div></div>
41
71
  <script>
42
72
  // The page dismisses itself: msger's own timeout is only checked when the
43
73
  // window sees an event, so an overlay nobody touches sits there forever.
@@ -60,8 +90,9 @@ function overlayHtml(index, screen, seconds) {
60
90
  *
61
91
  * @param screens - Screens in winpos order (the index shown is the array index)
62
92
  * @param seconds - How long each overlay stays up
93
+ * @param inches - Size of each overlay in real inches, measured on the glass
63
94
  */
64
- export async function showScreenNumbers(screens, seconds = OVERLAY_SECONDS) {
95
+ export async function showScreenNumbers(screens, seconds = OVERLAY_SECONDS, inches = OVERLAY_INCHES) {
65
96
  let showMessageBox;
66
97
  try {
67
98
  ({ showMessageBox } = await import('@bobfrankston/msger'));
@@ -79,16 +110,17 @@ export async function showScreenNumbers(screens, seconds = OVERLAY_SECONDS) {
79
110
  // Without this, the overlay on a 125% monitor whose origin is a few
80
111
  // thousand pixels out lands far right of center (5K screen, 768px off).
81
112
  const scale = screenScale(screen);
82
- const x = b.Left / scale + (b.Width - CARD - FRAME_W) / 2;
83
- const y = b.Top / scale + (b.Height - CARD - FRAME_H) / 2;
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;
84
116
  return showMessageBox({
85
117
  title: `winpos screen ${i}`,
86
118
  html: overlayHtml(i, screen, seconds),
87
119
  rawHtml: true, // No msger template, no OK button
88
- size: { width: CARD, height: CARD },
120
+ overlay: true, // Frameless, transparent, click-through, on top
121
+ size: { width: card, height: card },
89
122
  pos: { x: Math.round(x), y: Math.round(y) },
90
123
  timeout: seconds + 5, // Backstop if the page never runs
91
- alwaysOnTop: true,
92
124
  detach: true, // Outlives winpos itself
93
125
  // Own WebView2 profile per overlay: msger windows sharing one
94
126
  // user-data dir also share a browser process, and starting all of
package/screens.d.ts CHANGED
@@ -3,6 +3,14 @@
3
3
  */
4
4
  export interface ScreenInfo {
5
5
  deviceName: string;
6
+ /**
7
+ * Which physical monitor this is: EDID id + serial (e.g. GSM7721:106NTKF55597),
8
+ * the key monitor layouts use. Undefined when the identity could not be read.
9
+ * 2026-09-24 - Claude Code (Fable 5.1): added with the -screens commands.
10
+ */
11
+ id?: string;
12
+ /** The monitor's friendly name from its EDID, e.g. "LG HDR 5K" */
13
+ name?: string;
6
14
  bounds: {
7
15
  Left: number;
8
16
  Top: number;
@@ -14,9 +22,28 @@ export interface ScreenInfo {
14
22
  primary: boolean;
15
23
  /** Display scaling of this monitor as a percent: 100, 125, 150... */
16
24
  scalePercent: number;
25
+ /** Physical panel size in millimetres (EDID), when the driver reports it */
26
+ physicalMm?: {
27
+ width: number;
28
+ height: number;
29
+ };
17
30
  }
18
31
  /** Display scaling of a screen as a factor (1.25 for a 125% display) */
19
32
  export declare function screenScale(screen: ScreenInfo): number;
33
+ /** What CSS assumes an inch is, and what Windows calls 100% scaling */
34
+ export declare const NOMINAL_DPI = 96;
35
+ /**
36
+ * True pixels-per-inch of a screen, measured from the panel's physical size.
37
+ *
38
+ * Not the same thing as the scale factor: scaling is a preference, so a 28"
39
+ * 4K panel left at 100% reports 96 DPI while actually being 160. Anything
40
+ * sized in pixels - or in CSS inches, which are just 96 pixels - therefore
41
+ * comes out a different physical size on each monitor.
42
+ *
43
+ * Falls back to nominal (96 x scale) when the driver won't say, which is the
44
+ * old pixel-sized behaviour.
45
+ */
46
+ export declare function screenDpi(screen: ScreenInfo): number;
20
47
  export declare function enumerateScreens(): ScreenInfo[];
21
48
  export declare function sortScreens(screens: ScreenInfo[]): ScreenInfo[];
22
49
  //# sourceMappingURL=screens.d.ts.map
package/screens.js CHANGED
@@ -2,10 +2,38 @@
2
2
  * Screen management - enumerate and sort displays
3
3
  */
4
4
  import { user32, isBun, MONITORINFOF_PRIMARY, DEFAULT_SCALE_PERCENT } from './ffi-wrapper.js';
5
+ import { enumerateMonitors } from './monitors.js';
5
6
  /** Display scaling of a screen as a factor (1.25 for a 125% display) */
6
7
  export function screenScale(screen) {
7
8
  return (screen.scalePercent || DEFAULT_SCALE_PERCENT) / 100;
8
9
  }
10
+ /** What CSS assumes an inch is, and what Windows calls 100% scaling */
11
+ export const NOMINAL_DPI = 96;
12
+ // A real monitor outside this range means the driver lied about its size
13
+ // (some report the whole video wall, projectors report nothing sensible).
14
+ const MIN_PLAUSIBLE_DPI = 30;
15
+ const MAX_PLAUSIBLE_DPI = 700;
16
+ /**
17
+ * True pixels-per-inch of a screen, measured from the panel's physical size.
18
+ *
19
+ * Not the same thing as the scale factor: scaling is a preference, so a 28"
20
+ * 4K panel left at 100% reports 96 DPI while actually being 160. Anything
21
+ * sized in pixels - or in CSS inches, which are just 96 pixels - therefore
22
+ * comes out a different physical size on each monitor.
23
+ *
24
+ * Falls back to nominal (96 x scale) when the driver won't say, which is the
25
+ * old pixel-sized behaviour.
26
+ */
27
+ export function screenDpi(screen) {
28
+ const scale = screenScale(screen);
29
+ const mm = screen.physicalMm;
30
+ if (!mm || mm.width <= 0)
31
+ return NOMINAL_DPI * scale;
32
+ // bounds are logical pixels, so undo the scaling to get what the panel
33
+ // actually lights up - that is what spans those millimetres.
34
+ const dpi = (screen.bounds.Width * scale) / (mm.width / 25.4);
35
+ return dpi >= MIN_PLAUSIBLE_DPI && dpi <= MAX_PLAUSIBLE_DPI ? dpi : NOMINAL_DPI * scale;
36
+ }
9
37
  export function enumerateScreens() {
10
38
  const screens = [];
11
39
  let monitorIndex = 0;
@@ -20,8 +48,12 @@ export function enumerateScreens() {
20
48
  const monitorInfo = {};
21
49
  if (user32.GetMonitorInfoW(hMonitor, monitorInfo)) {
22
50
  const rcMonitor = monitorInfo.rcMonitor;
51
+ // The driver's own name for this monitor - enumeration
52
+ // order is not the DISPLAYn numbering, so a name made up
53
+ // from the index would point gdi32 at the wrong panel.
54
+ const deviceName = monitorInfo.szDevice || `\\\\.\\DISPLAY${monitorIndex + 1}`;
23
55
  screens.push({
24
- deviceName: `\\\\.\\DISPLAY${monitorIndex + 1}`,
56
+ deviceName,
25
57
  bounds: {
26
58
  Left: rcMonitor.Left,
27
59
  Top: rcMonitor.Top,
@@ -32,6 +64,7 @@ export function enumerateScreens() {
32
64
  },
33
65
  primary: (monitorInfo.dwFlags & MONITORINFOF_PRIMARY) !== 0,
34
66
  scalePercent: user32.GetScaleFactorForMonitor?.(hMonitor) ?? DEFAULT_SCALE_PERCENT,
67
+ physicalMm: user32.GetPhysicalSizeMm?.(deviceName) ?? undefined,
35
68
  });
36
69
  monitorIndex++;
37
70
  }
@@ -77,6 +110,26 @@ export function enumerateScreens() {
77
110
  scalePercent: DEFAULT_SCALE_PERCENT,
78
111
  });
79
112
  }
113
+ // 2026-09-24 - Claude Code (Fable 5.1), at Bob's direction: tag each screen
114
+ // with the monitor's identity, joined on the adapter name. One identity
115
+ // mechanism for both halves of winpos: the window layouts and the monitor
116
+ // layouts name the same panel the same way.
117
+ try {
118
+ const byDevice = new Map(enumerateMonitors().map(m => [m.device, m]));
119
+ for (const screen of screens) {
120
+ const monitor = byDevice.get(screen.deviceName);
121
+ if (!monitor)
122
+ continue;
123
+ screen.id = monitor.id;
124
+ screen.name = monitor.name;
125
+ }
126
+ }
127
+ catch (error) {
128
+ // Justified swallow: identity is decoration here - window positioning
129
+ // works from bounds alone and always has. The -screens commands call
130
+ // enumerateMonitors() themselves and report this same error there,
131
+ // so it is not lost, only deferred to the command that needs it.
132
+ }
80
133
  return screens;
81
134
  }
82
135
  // Tops within this many pixels are treated as the same row (DPI/rounding noise).