@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/README.md +296 -200
- package/ffi-wrapper.d.ts +56 -0
- package/ffi-wrapper.js +188 -4
- package/index.d.ts +15 -4
- package/index.js +195 -53
- package/monitors.d.ts +67 -0
- package/monitors.js +146 -0
- package/package.json +10 -3
- package/screenlayout.d.ts +108 -0
- package/screenlayout.js +243 -0
- package/screennums.d.ts +4 -1
- package/screennums.js +48 -16
- package/screens.d.ts +27 -0
- package/screens.js +54 -1
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
|
-
//
|
|
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
|
-
//
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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 (
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
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
|
-
|
|
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;
|