@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/ffi-wrapper.js CHANGED
@@ -16,9 +16,67 @@ 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;
20
23
  // Monitor scale factor as a percentage (100 = no scaling, 125 = 125%)
21
24
  export const DEFAULT_SCALE_PERCENT = 100;
25
+ // 2026-09-24 - Claude Code (Fable 5.1), at Bob's direction: monitor arrangement
26
+ // save/restore folded in from %OneDrive%\xfer\bin\displays.ps1 (see
27
+ // .llm/screens-restore.md). Everything below down to WindowsAPI is for that.
28
+ // DISPLAY_DEVICEW.StateFlags
29
+ export const DISPLAY_DEVICE_ATTACHED_TO_DESKTOP = 0x1;
30
+ export const DISPLAY_DEVICE_PRIMARY_DEVICE = 0x4;
31
+ export const DISPLAY_DEVICE_MIRRORING_DRIVER = 0x8;
32
+ // EnumDisplayDevicesW dwFlags: DeviceID becomes the device interface path,
33
+ // \\?\DISPLAY#<EdidId>#<instance>#{guid}, which is what names the registry
34
+ // key holding the monitor's EDID.
35
+ export const EDD_GET_DEVICE_INTERFACE_NAME = 0x1;
36
+ // EnumDisplaySettingsW iModeNum
37
+ const ENUM_CURRENT_SETTINGS = -1;
38
+ // DEVMODEW.dmFields: only the position is being set
39
+ const DM_POSITION = 0x20;
40
+ // ChangeDisplaySettingsExW dwflags
41
+ const CDS_UPDATEREGISTRY = 0x1;
42
+ const CDS_SET_PRIMARY = 0x10;
43
+ const CDS_NORESET = 0x10000000;
44
+ /** ChangeDisplaySettingsExW return codes */
45
+ export const DISP_CHANGE_SUCCESSFUL = 0;
46
+ export const DISP_CHANGE_RESTART = 1;
47
+ export const DISP_CHANGE_FAILED = -1;
48
+ export const DISP_CHANGE_BADMODE = -2;
49
+ export const DISP_CHANGE_NOTUPDATED = -3;
50
+ export const DISP_CHANGE_BADFLAGS = -4;
51
+ export const DISP_CHANGE_BADPARAM = -5;
52
+ export const DISP_CHANGE_BADDUALVIEW = -6;
53
+ // Registry: RegGetValueW dwFlags restricting the value type, and the
54
+ // predefined HKLM handle. The SDK defines HKEY_LOCAL_MACHINE as
55
+ // (HKEY)(ULONG_PTR)(LONG)0x80000002, i.e. SIGN-extended on 64-bit, so the
56
+ // value that has to cross the FFI boundary is 0xFFFFFFFF80000002, not
57
+ // 0x80000002.
58
+ const RRF_RT_REG_BINARY = 0x08;
59
+ const HKEY_LOCAL_MACHINE = 0xffffffff80000002n;
60
+ const ERROR_SUCCESS = 0;
61
+ // Largest registry value this reads. EDIDs are 128-512 bytes.
62
+ const REG_VALUE_MAX = 4096;
63
+ /** A fixed-size WCHAR array out of a struct buffer, up to its terminator */
64
+ function wideString(buf, offset, chars) {
65
+ const text = buf.toString('utf16le', offset, offset + chars * 2);
66
+ const end = text.indexOf('\0');
67
+ return end >= 0 ? text.substring(0, end) : text;
68
+ }
69
+ // DISPLAY_DEVICEW: cb DWORD, DeviceName WCHAR[32], DeviceString WCHAR[128],
70
+ // StateFlags DWORD, DeviceID WCHAR[128], DeviceKey WCHAR[128]. 840 bytes.
71
+ const DISPLAY_DEVICEW_SIZE = 840;
72
+ const DD_DEVICENAME = 4, DD_DEVICESTRING = 68, DD_STATEFLAGS = 324, DD_DEVICEID = 328, DD_DEVICEKEY = 584;
73
+ // DEVMODEW (the W layout - the A one has the position at 44): dmDeviceName
74
+ // WCHAR[32] @0, four WORDs @64 (dmSize @68), dmFields @72, then the position
75
+ // union @76, ... dmPelsWidth @172, dmPelsHeight @176, dmDisplayFrequency @184.
76
+ // 220 bytes, and dmSize must say so or the call fails.
77
+ const DEVMODEW_SIZE = 220;
78
+ const DM_SIZE = 68, DM_FIELDS = 72, DM_POSITION_X = 76, DM_POSITION_Y = 80;
79
+ const DM_PELSWIDTH = 172, DM_PELSHEIGHT = 176, DM_DISPLAYFREQUENCY = 184;
22
80
  async function initializeFFI() {
23
81
  if (isBun) {
24
82
  // @ts-ignore - bun:ffi is only available in Bun
@@ -110,6 +168,44 @@ async function initializeFFI() {
110
168
  catch {
111
169
  // Leave null - callers fall back to 100%
112
170
  }
171
+ // A display DC reports the panel's physical size in millimetres, straight
172
+ // from the driver's EDID. That is the only measurement of a monitor
173
+ // available: the scale factor above is a user preference, so a 160 DPI
174
+ // panel left at 100% claims to be a 96 DPI one. Optional, like shcore -
175
+ // callers fall back to nominal DPI when this returns null.
176
+ let CreateDCW = null, GetDeviceCaps = null, DeleteDC = null;
177
+ try {
178
+ const gdi32 = koffi.load('gdi32.dll');
179
+ CreateDCW = gdi32.func('void * __stdcall CreateDCW(const char16_t *pwszDriver, const char16_t *pwszDevice, const char16_t *pszPort, const void *pdm)');
180
+ GetDeviceCaps = gdi32.func('int __stdcall GetDeviceCaps(void *hdc, int index)');
181
+ DeleteDC = gdi32.func('bool __stdcall DeleteDC(void *hdc)');
182
+ }
183
+ catch {
184
+ CreateDCW = null;
185
+ }
186
+ // Monitor arrangement. These three are in every user32 there is, so no
187
+ // try around them: a missing one is a broken install, not a variant.
188
+ const EnumDisplayDevicesWRaw = lib.func('bool __stdcall EnumDisplayDevicesW(const char16_t *lpDevice, uint32_t iDevNum, void *lpDisplayDevice, uint32_t dwFlags)');
189
+ const EnumDisplaySettingsWRaw = lib.func('bool __stdcall EnumDisplaySettingsW(const char16_t *lpszDeviceName, int iModeNum, void *lpDevMode)');
190
+ const ChangeDisplaySettingsExWRaw = lib.func('int __stdcall ChangeDisplaySettingsExW(const char16_t *lpszDeviceName, void *lpDevMode, void *hwnd, uint32_t dwflags, void *lParam)');
191
+ // The monitor's EDID lives in the registry; that is where the serial
192
+ // number and the friendly name come from. Optional like gdi32: without
193
+ // it monitors are identified by EDID id alone and the listing shows the
194
+ // serial column empty, which is visible rather than silent.
195
+ let RegGetValueWRaw = null;
196
+ try {
197
+ const advapi32 = koffi.load('advapi32.dll');
198
+ RegGetValueWRaw = advapi32.func('int __stdcall RegGetValueW(uintptr_t hkey, const char16_t *lpSubKey, const char16_t *lpValue, uint32_t dwFlags, void *pdwType, void *pvData, void *pcbData)');
199
+ }
200
+ catch {
201
+ RegGetValueWRaw = null; // Justified: RegGetBinaryHKLM below returns null, and monitors.ts reports the missing serial
202
+ }
203
+ /** DEVMODEW buffer holding an adapter's current mode, or null when it has none */
204
+ function currentModeBuffer(device) {
205
+ const buf = Buffer.alloc(DEVMODEW_SIZE);
206
+ buf.writeUInt16LE(DEVMODEW_SIZE, DM_SIZE);
207
+ return EnumDisplaySettingsWRaw(device, ENUM_CURRENT_SETTINGS, buf) ? buf : null;
208
+ }
113
209
  // Keep references to registered callbacks to prevent garbage collection
114
210
  // These must persist for the entire duration of the native callback invocation
115
211
  const __callbackRefs = [];
@@ -154,14 +250,18 @@ async function initializeFFI() {
154
250
  return result;
155
251
  },
156
252
  GetMonitorInfoW: (hMonitor, lpmi) => {
157
- // MONITORINFO structure:
253
+ // MONITORINFOEXW structure:
158
254
  // DWORD cbSize (4 bytes)
159
255
  // RECT rcMonitor (16 bytes)
160
256
  // RECT rcWork (16 bytes)
161
257
  // DWORD dwFlags (4 bytes)
162
- // Total: 40 bytes
163
- const buffer = Buffer.alloc(40);
164
- buffer.writeUInt32LE(40, 0); // cbSize
258
+ // WCHAR szDevice[32] (64 bytes) - the EX part, and the reason for
259
+ // asking: the device name is what gdi32 wants to open a DC on
260
+ // this monitor, and the only reliable tie from an HMONITOR to a
261
+ // \\.\DISPLAYn. cbSize is what selects EX over plain MONITORINFO.
262
+ // Total: 104 bytes
263
+ const buffer = Buffer.alloc(104);
264
+ buffer.writeUInt32LE(104, 0); // cbSize
165
265
  const result = GetMonitorInfoW(hMonitor, buffer);
166
266
  if (result) {
167
267
  lpmi.cbSize = buffer.readUInt32LE(0);
@@ -178,6 +278,9 @@ async function initializeFFI() {
178
278
  Bottom: buffer.readInt32LE(32),
179
279
  };
180
280
  lpmi.dwFlags = buffer.readUInt32LE(36);
281
+ const device = buffer.toString('utf16le', 40, 104);
282
+ const end = device.indexOf('\0');
283
+ lpmi.szDevice = end >= 0 ? device.substring(0, end) : device;
181
284
  }
182
285
  return result;
183
286
  },
@@ -188,6 +291,87 @@ async function initializeFFI() {
188
291
  const hr = GetScaleFactorForMonitorRaw(hMonitor, scale);
189
292
  return hr === 0 ? (scale.readUInt32LE(0) || DEFAULT_SCALE_PERCENT) : DEFAULT_SCALE_PERCENT;
190
293
  },
294
+ GetPhysicalSizeMm: (deviceName) => {
295
+ if (!CreateDCW || !deviceName)
296
+ return null;
297
+ let hdc = null;
298
+ try {
299
+ // An information DC would do, but CreateDCW on the DISPLAY
300
+ // driver is what every monitor exposes; the caps are the same.
301
+ hdc = CreateDCW('DISPLAY', deviceName, null, null);
302
+ if (!hdc)
303
+ return null;
304
+ const width = GetDeviceCaps(hdc, HORZSIZE);
305
+ const height = GetDeviceCaps(hdc, VERTSIZE);
306
+ return width > 0 && height > 0 ? { width, height } : null;
307
+ }
308
+ catch {
309
+ return null; // Best-effort: no measurement is not an error
310
+ }
311
+ finally {
312
+ if (hdc) {
313
+ try {
314
+ DeleteDC(hdc);
315
+ }
316
+ catch { /* nothing useful to do */ }
317
+ }
318
+ }
319
+ },
320
+ EnumDisplayDevicesW: (device, iDevNum, flags) => {
321
+ const buf = Buffer.alloc(DISPLAY_DEVICEW_SIZE);
322
+ buf.writeUInt32LE(DISPLAY_DEVICEW_SIZE, 0); // cb
323
+ if (!EnumDisplayDevicesWRaw(device, iDevNum, buf, flags))
324
+ return null;
325
+ return {
326
+ deviceName: wideString(buf, DD_DEVICENAME, 32),
327
+ deviceString: wideString(buf, DD_DEVICESTRING, 128),
328
+ stateFlags: buf.readUInt32LE(DD_STATEFLAGS),
329
+ deviceID: wideString(buf, DD_DEVICEID, 128),
330
+ deviceKey: wideString(buf, DD_DEVICEKEY, 128),
331
+ };
332
+ },
333
+ EnumDisplaySettingsW: (device) => {
334
+ const buf = currentModeBuffer(device);
335
+ if (!buf)
336
+ return null;
337
+ return {
338
+ x: buf.readInt32LE(DM_POSITION_X),
339
+ y: buf.readInt32LE(DM_POSITION_Y),
340
+ width: buf.readUInt32LE(DM_PELSWIDTH),
341
+ height: buf.readUInt32LE(DM_PELSHEIGHT),
342
+ hz: buf.readUInt32LE(DM_DISPLAYFREQUENCY),
343
+ };
344
+ },
345
+ StageDisplayPosition: (device, x, y, primary) => {
346
+ // Start from the current mode so everything but the position is
347
+ // what the adapter already has; dmFields says only the position
348
+ // counts. CDS_NORESET stages it, CDS_UPDATEREGISTRY makes Windows
349
+ // remember this arrangement for the current set of monitors.
350
+ const buf = currentModeBuffer(device);
351
+ if (!buf)
352
+ return DISP_CHANGE_BADPARAM;
353
+ buf.writeUInt32LE(DM_POSITION, DM_FIELDS);
354
+ buf.writeInt32LE(x, DM_POSITION_X);
355
+ buf.writeInt32LE(y, DM_POSITION_Y);
356
+ let flags = CDS_UPDATEREGISTRY | CDS_NORESET;
357
+ if (primary)
358
+ flags |= CDS_SET_PRIMARY;
359
+ return ChangeDisplaySettingsExWRaw(device, buf, null, flags >>> 0, null);
360
+ },
361
+ ApplyDisplayChanges: () => {
362
+ return ChangeDisplaySettingsExWRaw(null, null, null, 0, null);
363
+ },
364
+ RegGetBinaryHKLM: (subKey, valueName) => {
365
+ if (!RegGetValueWRaw)
366
+ return null;
367
+ const data = Buffer.alloc(REG_VALUE_MAX);
368
+ const size = Buffer.alloc(4);
369
+ size.writeUInt32LE(REG_VALUE_MAX, 0);
370
+ const rc = RegGetValueWRaw(HKEY_LOCAL_MACHINE, subKey, valueName, RRF_RT_REG_BINARY, null, data, size);
371
+ if (rc !== ERROR_SUCCESS)
372
+ return null;
373
+ return data.subarray(0, size.readUInt32LE(0));
374
+ },
191
375
  };
192
376
  }
193
377
  const user32 = await initializeFFI();
package/index.d.ts CHANGED
@@ -4,10 +4,12 @@
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 } from './screennums.js';
10
+ import { showScreenNumbers, OVERLAY_SECONDS, OVERLAY_INCHES } from './screennums.js';
11
+ import { MonitorInfo, enumerateMonitors } from './monitors.js';
12
+ import { SavedMonitor, ScreenLayout, RestoreResult, DEFAULT_LAYOUT_NAME, layoutsPath, getLayout, saveLayout, machineLayouts, restoreLayout, watchLayout, formatMonitors } from './screenlayout.js';
11
13
  export interface WindowConfig {
12
14
  name: string;
13
15
  regex?: boolean;
@@ -23,8 +25,17 @@ export interface WindowConfig {
23
25
  minimize?: boolean;
24
26
  maximize?: boolean;
25
27
  }
26
- export type ConfigFile = WindowConfig | WindowConfig[];
27
- export { RECT, ScreenInfo, WindowInfo, WindowState, TabInfo, enumerateScreens, sortScreens, enumerateWindows, findWindowsByPattern, getWindowState, setWindowState, isMinimized, isMaximized, isNormal, minimizeWindow, maximizeWindow, restoreWindow, enumerateTabs, mightHaveTabs, TAB_ENUMERATION_NOTE, showScreenNumbers, user32 };
28
+ /**
29
+ * The wrapped config form: monitors first, then windows. `screens` is the
30
+ * name of a saved monitor layout, an inline layout, or just its monitors
31
+ * array. Either key may be omitted.
32
+ */
33
+ export interface LayoutFile {
34
+ screens?: string | ScreenLayout | SavedMonitor[];
35
+ windows?: WindowConfig[];
36
+ }
37
+ export type ConfigFile = WindowConfig | WindowConfig[] | LayoutFile;
38
+ 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, MonitorInfo, SavedMonitor, ScreenLayout, RestoreResult, DEFAULT_LAYOUT_NAME, layoutsPath, enumerateMonitors, getLayout, saveLayout, machineLayouts, restoreLayout, watchLayout, formatMonitors };
28
39
  /**
29
40
  * Convert screen-relative coordinates to global coordinates
30
41
  * @param x - X coordinate relative to screen
package/index.js CHANGED
@@ -4,16 +4,22 @@
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 } from './screennums.js';
10
+ import { showScreenNumbers, OVERLAY_SECONDS, OVERLAY_INCHES } from './screennums.js';
11
+ // 2026-09-24 - Claude Code (Fable 5.1), at Bob's direction: monitor arrangement
12
+ // save/restore (-screens), folded in from displays.ps1. See .llm/screens-restore.md.
13
+ import { enumerateMonitors } from './monitors.js';
14
+ import { DEFAULT_LAYOUT_NAME, layoutsPath, machine, getLayout, saveLayout, machineLayouts, resolveLayout, restoreLayout, watchLayout, formatMonitors } from './screenlayout.js';
11
15
  import { join, resolve, isAbsolute } from 'path';
12
16
  import { readFileSync, writeFileSync, existsSync } from 'fs';
13
17
  import { parse as parseJsonc, printParseErrorCode } from 'jsonc-parser';
14
18
  import * as packageJson from './package.json' with { type: 'json' };
19
+ /** The -screens verbs. No verb means list. */
20
+ const SCREEN_VERBS = ['list', 'save', 'restore', 'layouts', 'watch'];
15
21
  // Re-export public API for library usage
16
- export { WindowState, enumerateScreens, sortScreens, enumerateWindows, findWindowsByPattern, getWindowState, setWindowState, isMinimized, isMaximized, isNormal, minimizeWindow, maximizeWindow, restoreWindow, enumerateTabs, mightHaveTabs, TAB_ENUMERATION_NOTE, showScreenNumbers, user32 };
22
+ 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, DEFAULT_LAYOUT_NAME, layoutsPath, enumerateMonitors, getLayout, saveLayout, machineLayouts, restoreLayout, watchLayout, formatMonitors };
17
23
  // WindowConfig and ConfigFile types are exported from interface definitions above
18
24
  // Note: screenPosToPos is exported separately below (after the function definition)
19
25
  /**
@@ -37,6 +43,17 @@ function loadConfigFile(filePath) {
37
43
  }
38
44
  return parsed;
39
45
  }
46
+ /** Replace the entry with the same name, or append */
47
+ function mergeWindowConfig(list, config) {
48
+ const idx = list.findIndex(e => e.name === config.name);
49
+ if (idx >= 0) {
50
+ list[idx] = config;
51
+ }
52
+ else {
53
+ list.push(config);
54
+ }
55
+ return list;
56
+ }
40
57
  /**
41
58
  * Save window config to JSON file
42
59
  * If file exists and contains array, merge; otherwise create/overwrite
@@ -46,16 +63,13 @@ function saveConfigFile(filePath, config, merge = false) {
46
63
  let finalConfig;
47
64
  if (merge && existsSync(absPath)) {
48
65
  const existing = loadConfigFile(absPath);
49
- if (Array.isArray(existing)) {
50
- // Find and update existing entry with same name, or append
51
- const idx = existing.findIndex(e => e.name === config.name);
52
- if (idx >= 0) {
53
- existing[idx] = config;
54
- }
55
- else {
56
- existing.push(config);
57
- }
58
- finalConfig = existing;
66
+ if (isLayoutFile(existing)) {
67
+ // 2026-09-24 - Claude Code (Fable 5.1): the wrapped form keeps its
68
+ // screens section; only the windows list is merged into.
69
+ finalConfig = { ...existing, windows: mergeWindowConfig(existing.windows ?? [], config) };
70
+ }
71
+ else if (Array.isArray(existing)) {
72
+ finalConfig = mergeWindowConfig(existing, config);
59
73
  }
60
74
  else {
61
75
  // Convert single entry to array
@@ -179,12 +193,42 @@ function parseArgs(args) {
179
193
  opts.windowTitle = args[i];
180
194
  }
181
195
  break;
196
+ case 'numsize':
197
+ i++;
198
+ if (i < args.length) {
199
+ const inches = parseFloat(args[i]);
200
+ if (inches > 0)
201
+ opts.numSize = inches;
202
+ else
203
+ console.log(`Ignoring -numsize ${args[i]}: expected a size in inches`);
204
+ }
205
+ break;
182
206
  case 'min':
183
207
  opts.minimize = true;
184
208
  break;
185
209
  case 'max':
186
210
  opts.maximize = true;
187
211
  break;
212
+ case 'screens': {
213
+ // -screens [list|save|restore|layouts|watch] [name]. Bare
214
+ // -screens lists. The name is optional and only for an
215
+ // alternate layout; the machine's standard one needs none.
216
+ const next = args[i + 1];
217
+ const verb = next?.toLowerCase();
218
+ if (next && !next.startsWith('-') && !SCREEN_VERBS.includes(verb)) {
219
+ console.error(`Unknown -screens command '${next}'. Expected one of: ${SCREEN_VERBS.join(', ')}`);
220
+ process.exit(1);
221
+ }
222
+ opts.screens = { verb: (SCREEN_VERBS.includes(verb) ? verb : 'list') };
223
+ if (opts.screens.verb !== 'list')
224
+ i++;
225
+ const takesName = opts.screens.verb === 'save' || opts.screens.verb === 'restore' || opts.screens.verb === 'watch';
226
+ if (takesName && i + 1 < args.length && !args[i + 1].startsWith('-')) {
227
+ i++;
228
+ opts.screens.name = args[i];
229
+ }
230
+ break;
231
+ }
188
232
  case 'help':
189
233
  case '?':
190
234
  return opts; // Will trigger usage display
@@ -289,6 +333,7 @@ function applyWindowConfig(config, screensInfo) {
289
333
  let dbg = false;
290
334
  let dbgVerbose = false; // -dbg flag for detailed output
291
335
  let screens = [];
336
+ let numSize = OVERLAY_INCHES; // -numsize: screen-number overlay size in inches
292
337
  /**
293
338
  * Convert screen-relative coordinates to global coordinates
294
339
  * @param x - X coordinate relative to screen
@@ -427,7 +472,7 @@ function listAllWindows(includeIgnored = false) {
427
472
  const screen = screens[i];
428
473
  // console.log(`${i} Screen: ${screen.deviceName} (${screen.bounds.Top}/${screen.bounds.Bottom}x${screen.bounds.Left}/${screen.bounds.Right})`);
429
474
  const b = screen.bounds;
430
- console.log(`${i} Screen: ${screen.deviceName} ${padn(b.Left)},${padn(b.Top)} (lower right ${padn(b.Right)} ${padn(b.Bottom)})`);
475
+ console.log(`${i} Screen: ${screen.deviceName} ${padn(b.Left)},${padn(b.Top)} (lower right ${padn(b.Right)} ${padn(b.Bottom)}) ${screen.name ?? ''}`);
431
476
  }
432
477
  console.log();
433
478
  const windows = enumerateWindows(includeIgnored).sort((a, b) => a.title.localeCompare(b.title));
@@ -450,7 +495,7 @@ function listAllWindows(includeIgnored = false) {
450
495
  console.log('Command format: tswinpos <title> <x> <y> <screen> [<width> <height>]');
451
496
  // Flash each screen's number on that screen - launched last so the overlays
452
497
  // can't turn up in the listing above. Detached, so we don't wait for them.
453
- void showScreenNumbers(screens);
498
+ void showScreenNumbers(screens, OVERLAY_SECONDS, numSize);
454
499
  }
455
500
  function usage(msg = '') {
456
501
  printUsage(msg);
@@ -469,6 +514,7 @@ function printUsage(msg = '') {
469
514
  console.log(' winpos <title> min | -min');
470
515
  console.log(' winpos <config.json|config.jsonc>');
471
516
  console.log(' winpos -load <config.json|config.jsonc> [-save <output.json>]');
517
+ console.log(' winpos -screens [list | save [name] | restore [name] | layouts | watch [name]]');
472
518
  console.log();
473
519
  console.log('Options:');
474
520
  console.log(' -d Debug mode');
@@ -478,10 +524,14 @@ function printUsage(msg = '') {
478
524
  console.log(' -title name Window title/pattern (alternative to positional)');
479
525
  console.log(' -pos x,y[,s] Position (x,y with optional screen index)');
480
526
  console.log(' -size w,h Window size (width,height)');
527
+ console.log(` -numsize n Screen-number overlay size in inches (default ${OVERLAY_INCHES})`);
481
528
  console.log(' -min Minimize window');
482
529
  console.log(' -max Maximize window');
483
530
  console.log(' -load file Load config from JSON/JSONC file (comments + trailing commas OK)');
484
531
  console.log(' -save file Save config to JSON file (merges if file exists; comments not preserved)');
532
+ console.log(' -screens Monitor arrangement: list (default), save, restore, layouts, watch');
533
+ console.log(` A name is only for an alternate layout; the standard one is '${DEFAULT_LAYOUT_NAME}'.`);
534
+ console.log(` Layouts are kept per machine in ${layoutsPath}`);
485
535
  console.log();
486
536
  console.log('Position/size values can be pixels or percentages (e.g. 50%)');
487
537
  console.log('Title can be regex (/pattern/) or prefix (title*)');
@@ -491,12 +541,121 @@ function printUsage(msg = '') {
491
541
  console.log();
492
542
  console.log('JSON format: {"name":"app","regex":false,"pos":{"x":0,"y":0,"screen":0},"size":{"w":800,"h":600}}');
493
543
  console.log(' or array: [{...}, {...}]');
544
+ console.log(' or {"screens":"default","windows":[{...}]} to restore the monitor arrangement first');
494
545
  console.log();
495
- console.log('Screen | X Y | W H ');
496
- console.log('------------------------------------');
546
+ console.log('Screen | X Y | W H | Monitor');
547
+ console.log('---------------------------------------------');
497
548
  for (let i = 0; i < screens.length; i++) {
498
549
  const screen = screens[i];
499
- 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)}`);
550
+ 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)} | ${screen.name ?? ''}`);
551
+ }
552
+ }
553
+ /**
554
+ * The -screens commands. Returns the exit code; throws for failures that
555
+ * should end with a message on stderr (run() prints it).
556
+ */
557
+ async function runScreens(cmd) {
558
+ const name = cmd.name ?? DEFAULT_LAYOUT_NAME;
559
+ switch (cmd.verb) {
560
+ case 'list': {
561
+ for (const line of formatMonitors(enumerateMonitors(), screens))
562
+ console.log(line);
563
+ return 0;
564
+ }
565
+ case 'layouts': {
566
+ const all = machineLayouts();
567
+ const names = Object.keys(all).sort();
568
+ if (names.length === 0) {
569
+ console.log(`No monitor layouts saved for ${machine} in ${layoutsPath}`);
570
+ return 0;
571
+ }
572
+ for (const n of names)
573
+ console.log(`${n.padEnd(16)} saved ${all[n].saved} [${all[n].monitors.map(m => m.name).join(', ')}]`);
574
+ return 0;
575
+ }
576
+ case 'save': {
577
+ const layout = saveLayout(name);
578
+ console.log(`Saved monitor layout '${name}' for ${machine} (${layout.monitors.length} monitors) to ${layoutsPath}`);
579
+ for (const line of formatMonitors(enumerateMonitors(), screens))
580
+ console.log(line);
581
+ return 0;
582
+ }
583
+ case 'restore': {
584
+ const layout = getLayout(name);
585
+ if (!layout)
586
+ throw new Error(`No monitor layout '${name}' saved for ${machine}. Run 'winpos -screens layouts' to see what exists.`);
587
+ const result = await restoreLayout(layout);
588
+ // Renumber against what is on the desktop now, so the Scr column is current
589
+ screens = sortScreens(enumerateScreens());
590
+ for (const line of formatMonitors(result.after, screens))
591
+ console.log(line);
592
+ if (!result.ok) {
593
+ console.log(`${result.mismatches.length} monitor(s) did not land where asked (Windows closes gaps when a monitor is missing).`);
594
+ return 1;
595
+ }
596
+ return 0;
597
+ }
598
+ case 'watch':
599
+ await watchLayout(name); // stays resident; only returns by throwing
600
+ return 0;
601
+ }
602
+ }
603
+ /** Whether a parsed config is the wrapped {screens, windows} form */
604
+ function isLayoutFile(config) {
605
+ return !Array.isArray(config) && !('name' in config) && ('screens' in config || 'windows' in config);
606
+ }
607
+ /**
608
+ * winpos <config.json> / -load: monitors first when the file has a `screens`
609
+ * section, then the windows. The screen numbers the window entries use are
610
+ * derived from the arrangement, so it has to be right before any window moves.
611
+ */
612
+ async function runConfigFile(opts) {
613
+ const config = loadConfigFile(opts.loadFile);
614
+ let wrapped = null;
615
+ let configs;
616
+ if (isLayoutFile(config)) {
617
+ wrapped = config;
618
+ configs = wrapped.windows ?? [];
619
+ }
620
+ else {
621
+ configs = Array.isArray(config) ? config : [config];
622
+ }
623
+ if (wrapped?.screens !== undefined) {
624
+ const layout = resolveLayout(wrapped.screens);
625
+ if (!layout)
626
+ throw new Error(`No monitor layout '${wrapped.screens}' saved for ${machine} in ${layoutsPath}`);
627
+ const result = await restoreLayout(layout);
628
+ if (!result.ok) {
629
+ console.log(`${result.mismatches.length} monitor(s) did not land where asked; continuing with the windows.`);
630
+ process.exitCode = 1;
631
+ }
632
+ screens = sortScreens(enumerateScreens());
633
+ }
634
+ // If CLI params specify a window, merge into loaded config
635
+ if (opts.windowTitle && (opts.pos || opts.size || opts.minimize || opts.maximize)) {
636
+ const cliConfig = buildConfigFromOptions(opts);
637
+ if (cliConfig) {
638
+ // Find matching entry by name and override it
639
+ const idx = configs.findIndex(c => c.name === cliConfig.name);
640
+ if (idx >= 0) {
641
+ configs[idx] = cliConfig;
642
+ }
643
+ else {
644
+ configs.push(cliConfig);
645
+ }
646
+ }
647
+ }
648
+ // Apply all configs
649
+ for (const cfg of configs) {
650
+ applyWindowConfig(cfg, screens);
651
+ }
652
+ // Save merged config if requested - in the same form it was read
653
+ if (opts.saveFile !== undefined) {
654
+ const savePath = opts.saveFile || opts.loadFile; // Default to loadFile if -save has no arg
655
+ const absPath = isAbsolute(savePath) ? savePath : resolve(process.cwd(), savePath);
656
+ const finalConfig = wrapped ? { ...wrapped, windows: configs } : configs.length === 1 ? configs[0] : configs;
657
+ writeFileSync(absPath, JSON.stringify(finalConfig, null, 2), 'utf-8');
658
+ console.log(`Config saved to: ${absPath}`);
500
659
  }
501
660
  }
502
661
  export function run(args) {
@@ -519,6 +678,8 @@ export function run(args) {
519
678
  // Set debug flags
520
679
  dbg = opts.debug;
521
680
  dbgVerbose = opts.debugVerbose;
681
+ if (opts.numSize)
682
+ numSize = opts.numSize;
522
683
  if (dbg)
523
684
  console.log('Debug mode enabled.');
524
685
  if (dbgVerbose)
@@ -528,43 +689,24 @@ export function run(args) {
528
689
  console.log(screens.length);
529
690
  process.exit(screens.length);
530
691
  }
531
- // Handle load file (JSON config)
692
+ // Handle -screens (monitor arrangement)
693
+ if (opts.screens) {
694
+ runScreens(opts.screens)
695
+ .then(code => process.exit(code))
696
+ .catch((e) => {
697
+ console.error(`winpos: ${e.message}`);
698
+ process.exit(1);
699
+ });
700
+ return;
701
+ }
702
+ // Handle load file (JSON config). Async since a `screens` section
703
+ // restores the monitor arrangement first and waits for it to settle.
532
704
  if (opts.loadFile) {
533
- try {
534
- const config = loadConfigFile(opts.loadFile);
535
- let configs = Array.isArray(config) ? config : [config];
536
- // If CLI params specify a window, merge into loaded config
537
- if (opts.windowTitle && (opts.pos || opts.size || opts.minimize || opts.maximize)) {
538
- const cliConfig = buildConfigFromOptions(opts);
539
- if (cliConfig) {
540
- // Find matching entry by name and override it
541
- const idx = configs.findIndex(c => c.name === cliConfig.name);
542
- if (idx >= 0) {
543
- configs[idx] = cliConfig;
544
- }
545
- else {
546
- configs.push(cliConfig);
547
- }
548
- }
549
- }
550
- // Apply all configs
551
- for (const cfg of configs) {
552
- applyWindowConfig(cfg, screens);
553
- }
554
- // Save merged config if requested
555
- if (opts.saveFile !== undefined) {
556
- const savePath = opts.saveFile || opts.loadFile; // Default to loadFile if -save has no arg
557
- const absPath = isAbsolute(savePath) ? savePath : resolve(process.cwd(), savePath);
558
- const finalConfig = configs.length === 1 ? configs[0] : configs;
559
- writeFileSync(absPath, JSON.stringify(finalConfig, null, 2), 'utf-8');
560
- console.log(`Config saved to: ${absPath}`);
561
- }
562
- return;
563
- }
564
- catch (e) {
705
+ runConfigFile(opts).catch((e) => {
565
706
  console.error(`Error loading config: ${e.message}`);
566
707
  process.exit(1);
567
- }
708
+ });
709
+ return;
568
710
  }
569
711
  // Handle new flag-based syntax (-pos, -size, -min, -max)
570
712
  if (opts.pos || opts.size || opts.minimize || opts.maximize) {
@@ -615,7 +757,7 @@ export function run(args) {
615
757
  // themselves; we only wait for the launch, because process.exit here would
616
758
  // kill the spawn in mid-flight and no numbers would ever appear.
617
759
  if (posArgs.length === 0) {
618
- const overlays = showScreenNumbers(screens);
760
+ const overlays = showScreenNumbers(screens, OVERLAY_SECONDS, numSize);
619
761
  printUsage();
620
762
  void overlays.finally(() => process.exit(1));
621
763
  return;