/ src / modules / MouseUtils / CursorWrap / CursorWrapTests / Capture-MonitorLayout.ps1
Capture-MonitorLayout.ps1
  1  #!/usr/bin/env pwsh
  2  <#
  3  .SYNOPSIS
  4      Captures the current monitor layout configuration for CursorWrap testing.
  5  
  6  .DESCRIPTION
  7      Queries Windows for all connected monitors and saves their configuration
  8      (position, size, DPI, primary status) to a JSON file that can be used
  9      for testing the CursorWrap module.
 10      
 11      By default, potentially identifying information (computer name, user name,
 12      device names) is anonymized to protect privacy when sharing layout files.
 13  
 14  .PARAMETER OutputPath
 15      Path where the JSON file will be saved. Default: cursorwrap_monitor_layout.json
 16  
 17  .PARAMETER AddUserMachineNames
 18      Include computer name and user name in the output. By default these are
 19      blank to protect privacy when sharing layout files.
 20  
 21  .PARAMETER AddDeviceNames
 22      Include device names (e.g., \\.\DISPLAY1) in the output. By default these
 23      are anonymized to "DISPLAY1", "DISPLAY2", etc. to reduce fingerprinting.
 24  
 25  .PARAMETER Help
 26      Show this help message and exit.
 27  
 28  .EXAMPLE
 29      .\Capture-MonitorLayout.ps1
 30      Captures layout with privacy-safe defaults (no user/machine names).
 31      
 32  .EXAMPLE
 33      .\Capture-MonitorLayout.ps1 -OutputPath "my_setup.json"
 34      Saves to a custom filename.
 35  
 36  .EXAMPLE
 37      .\Capture-MonitorLayout.ps1 -AddUserMachineNames
 38      Includes computer name and user name in the output.
 39  
 40  .EXAMPLE
 41      .\Capture-MonitorLayout.ps1 -AddUserMachineNames -AddDeviceNames
 42      Includes all identifying information (useful for personal debugging).
 43  #>
 44  
 45  param(
 46      [Parameter(Mandatory=$false)]
 47      [string]$OutputPath = "cursorwrap_monitor_layout.json",
 48      
 49      [Parameter(Mandatory=$false)]
 50      [switch]$AddUserMachineNames,
 51      
 52      [Parameter(Mandatory=$false)]
 53      [switch]$AddDeviceNames,
 54      
 55      [Parameter(Mandatory=$false)]
 56      [Alias("h", "?")]
 57      [switch]$Help
 58  )
 59  
 60  # Show help if requested
 61  if ($Help) {
 62      Get-Help $MyInvocation.MyCommand.Path -Detailed
 63      exit 0
 64  }
 65  
 66  # Add Windows Forms for screen enumeration
 67  Add-Type -AssemblyName System.Windows.Forms
 68  
 69  function Get-MonitorDPI {
 70      param([System.Windows.Forms.Screen]$Screen)
 71      
 72      # Try to get DPI using P/Invoke with multiple methods
 73      Add-Type @"
 74  using System;
 75  using System.Runtime.InteropServices;
 76  public class DisplayConfig {
 77      [DllImport("user32.dll")]
 78      public static extern IntPtr MonitorFromPoint(POINT pt, uint dwFlags);
 79      
 80      [DllImport("shcore.dll")]
 81      public static extern int GetDpiForMonitor(IntPtr hmonitor, int dpiType, out uint dpiX, out uint dpiY);
 82      
 83      [DllImport("user32.dll")]
 84      public static extern bool GetMonitorInfo(IntPtr hMonitor, ref MONITORINFOEX lpmi);
 85      
 86      [DllImport("user32.dll")]
 87      public static extern IntPtr MonitorFromWindow(IntPtr hwnd, uint dwFlags);
 88      
 89      [DllImport("gdi32.dll")]
 90      public static extern int GetDeviceCaps(IntPtr hdc, int nIndex);
 91      
 92      [DllImport("user32.dll")]
 93      public static extern IntPtr GetDC(IntPtr hWnd);
 94      
 95      [DllImport("user32.dll")]
 96      public static extern int ReleaseDC(IntPtr hWnd, IntPtr hDC);
 97      
 98      [StructLayout(LayoutKind.Sequential)]
 99      public struct POINT {
100          public int X;
101          public int Y;
102      }
103      
104      [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]
105      public struct MONITORINFOEX {
106          public int cbSize;
107          public RECT rcMonitor;
108          public RECT rcWork;
109          public uint dwFlags;
110          [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)]
111          public string szDevice;
112      }
113      
114      [StructLayout(LayoutKind.Sequential)]
115      public struct RECT {
116          public int Left;
117          public int Top;
118          public int Right;
119          public int Bottom;
120      }
121      
122      public const uint MONITOR_DEFAULTTOPRIMARY = 1;
123      public const int MDT_EFFECTIVE_DPI = 0;
124      public const int MDT_ANGULAR_DPI = 1;
125      public const int MDT_RAW_DPI = 2;
126      public const int LOGPIXELSX = 88;
127      public const int LOGPIXELSY = 90;
128  }
129  "@ -ErrorAction SilentlyContinue
130      
131      try {
132          $point = New-Object DisplayConfig+POINT
133          $point.X = $Screen.Bounds.Left + ($Screen.Bounds.Width / 2)
134          $point.Y = $Screen.Bounds.Top + ($Screen.Bounds.Height / 2)
135          
136          $hMonitor = [DisplayConfig]::MonitorFromPoint($point, 1)
137          
138          # Method 1: Try GetDpiForMonitor (Windows 8.1+)
139          [uint]$dpiX = 0
140          [uint]$dpiY = 0
141          $result = [DisplayConfig]::GetDpiForMonitor($hMonitor, 0, [ref]$dpiX, [ref]$dpiY)
142          
143          if ($result -eq 0 -and $dpiX -gt 0) {
144              Write-Verbose "DPI detected via GetDpiForMonitor: $dpiX"
145              return $dpiX
146          }
147          
148          # Method 2: Try RAW DPI
149          $result = [DisplayConfig]::GetDpiForMonitor($hMonitor, 2, [ref]$dpiX, [ref]$dpiY)
150          if ($result -eq 0 -and $dpiX -gt 0) {
151              Write-Verbose "DPI detected via RAW DPI: $dpiX"
152              return $dpiX
153          }
154          
155          # Method 3: Try getting device context DPI (legacy method)
156          $hdc = [DisplayConfig]::GetDC([IntPtr]::Zero)
157          if ($hdc -ne [IntPtr]::Zero) {
158              $dpiValue = [DisplayConfig]::GetDeviceCaps($hdc, 88) # LOGPIXELSX
159              [DisplayConfig]::ReleaseDC([IntPtr]::Zero, $hdc)
160              if ($dpiValue -gt 0) {
161                  Write-Verbose "DPI detected via GetDeviceCaps: $dpiValue"
162                  return $dpiValue
163              }
164          }
165      }
166      catch {
167          Write-Verbose "DPI detection error: $($_.Exception.Message)"
168      }
169      
170      Write-Warning "Could not detect DPI for $($Screen.DeviceName), using default 96 DPI"
171      return 96  # Standard 96 DPI (100% scaling)
172  }
173  
174  function Capture-MonitorLayout {
175      Write-Host "Capturing monitor layout..." -ForegroundColor Cyan
176      Write-Host "=" * 80
177      
178      $screens = [System.Windows.Forms.Screen]::AllScreens
179      $monitors = @()
180      $monitorIndex = 1
181  
182      foreach ($screen in $screens) {
183          $isPrimary = $screen.Primary
184          $bounds = $screen.Bounds
185          $dpi = Get-MonitorDPI -Screen $screen
186          
187          # Anonymize device name by default to reduce fingerprinting
188          $deviceName = if ($AddDeviceNames) {
189              $screen.DeviceName
190          } else {
191              "DISPLAY$monitorIndex"
192          }
193          
194          $monitor = [ordered]@{
195              left = $bounds.Left
196              top = $bounds.Top
197              right = $bounds.Right
198              bottom = $bounds.Bottom
199              width = $bounds.Width
200              height = $bounds.Height
201              dpi = $dpi
202              scaling_percent = [math]::Round(($dpi / 96.0) * 100, 0)
203              primary = $isPrimary
204              device_name = $deviceName
205          }
206          
207          $monitors += $monitor
208          $monitorIndex++
209          
210          # Display info
211          $primaryTag = if ($isPrimary) { " [PRIMARY]" } else { "" }
212          $scaling = [math]::Round(($dpi / 96.0) * 100, 0)
213          
214          Write-Host "`nMonitor $($monitors.Count)$primaryTag" -ForegroundColor Green
215          Write-Host "  Device: $($screen.DeviceName)"
216          Write-Host "  Position: ($($bounds.Left), $($bounds.Top))"
217          Write-Host "  Size: $($bounds.Width)x$($bounds.Height)"
218          Write-Host "  DPI: $dpi ($scaling% scaling)"
219          Write-Host "  Bounds: [$($bounds.Left), $($bounds.Top), $($bounds.Right), $($bounds.Bottom)]"
220      }
221      
222      # Create output object with privacy-safe defaults
223      $output = [ordered]@{
224          captured_at = (Get-Date -Format "yyyy-MM-ddTHH:mm:sszzz")
225          computer_name = if ($AddUserMachineNames) { $env:COMPUTERNAME } else { "" }
226          user_name = if ($AddUserMachineNames) { $env:USERNAME } else { "" }
227          monitor_count = $monitors.Count
228          monitors = $monitors
229      }
230      
231      # Save to JSON
232      $json = $output | ConvertTo-Json -Depth 10
233      Set-Content -Path $OutputPath -Value $json -Encoding UTF8
234      
235      Write-Host "`n" + ("=" * 80)
236      Write-Host "Monitor layout saved to: $OutputPath" -ForegroundColor Green
237      Write-Host "Total monitors captured: $($monitors.Count)" -ForegroundColor Cyan
238      Write-Host "`nYou can now use this file with the test script:" -ForegroundColor Yellow
239      Write-Host "  python monitor_layout_tests.py --layout-file $OutputPath" -ForegroundColor White
240      
241      return $output
242  }
243  
244  # Main execution
245  try {
246      $layout = Capture-MonitorLayout
247      
248      # Display summary
249      Write-Host "`n" + ("=" * 80)
250      Write-Host "SUMMARY" -ForegroundColor Cyan
251      Write-Host ("=" * 80)
252      if ($layout.computer_name) {
253          Write-Host "Configuration Name: $($layout.computer_name)"
254      }
255      Write-Host "Captured: $($layout.captured_at)"
256      Write-Host "Monitors: $($layout.monitor_count)"
257      
258      # Privacy notice
259      if (-not $AddUserMachineNames -or -not $AddDeviceNames) {
260          Write-Host "`nPrivacy: " -NoNewline -ForegroundColor Yellow
261          $privacyNotes = @()
262          if (-not $AddUserMachineNames) { $privacyNotes += "user/machine names excluded" }
263          if (-not $AddDeviceNames) { $privacyNotes += "device names anonymized" }
264          Write-Host ($privacyNotes -join ", ") -ForegroundColor Yellow
265          Write-Host "  Use -AddUserMachineNames and/or -AddDeviceNames to include." -ForegroundColor DarkGray
266      }
267      
268      # Calculate desktop dimensions
269      $widths = @($layout.monitors | ForEach-Object { $_.width })
270      $heights = @($layout.monitors | ForEach-Object { $_.height })
271      
272      $totalWidth = ($widths | Measure-Object -Sum).Sum
273      $maxHeight = ($heights | Measure-Object -Maximum).Maximum
274      
275      Write-Host "Total Desktop Width: $totalWidth pixels"
276      Write-Host "Max Desktop Height: $maxHeight pixels"
277      
278      # Analyze potential coordinate issues
279      Write-Host "`n" + ("=" * 80)
280      Write-Host "COORDINATE ANALYSIS" -ForegroundColor Cyan
281      Write-Host ("=" * 80)
282      
283      # Check for gaps between monitors
284      if ($layout.monitor_count -gt 1) {
285          $hasGaps = $false
286          for ($i = 0; $i -lt $layout.monitor_count - 1; $i++) {
287              $m1 = $layout.monitors[$i]
288              for ($j = $i + 1; $j -lt $layout.monitor_count; $j++) {
289                  $m2 = $layout.monitors[$j]
290                  
291                  # Check horizontal gap
292                  $hGap = [Math]::Min([Math]::Abs($m1.right - $m2.left), [Math]::Abs($m2.right - $m1.left))
293                  # Check vertical overlap
294                  $vOverlapStart = [Math]::Max($m1.top, $m2.top)
295                  $vOverlapEnd = [Math]::Min($m1.bottom, $m2.bottom)
296                  $vOverlap = $vOverlapEnd - $vOverlapStart
297                  
298                  if ($hGap -gt 50 -and $vOverlap -gt 0) {
299                      Write-Host "⚠ Gap detected between Monitor $($i+1) and Monitor $($j+1): ${hGap}px horizontal gap" -ForegroundColor Yellow
300                      Write-Host "  Vertical overlap: ${vOverlap}px" -ForegroundColor Yellow
301                      Write-Host "  This may indicate a Windows coordinate bug if monitors appear snapped in Display Settings" -ForegroundColor Yellow
302                      $hasGaps = $true
303                  }
304              }
305          }
306          if (-not $hasGaps) {
307              Write-Host "✓ No unexpected gaps detected" -ForegroundColor Green
308          }
309      }
310      
311      # DPI/Scaling notes
312      Write-Host "`nDPI/Scaling Impact on Coordinates:" -ForegroundColor Cyan
313      Write-Host "• Coordinate values (left, top, right, bottom) are in LOGICAL PIXELS"
314      Write-Host "• These are DPI-independent virtual coordinates"
315      Write-Host "• Physical pixels = Logical pixels × (DPI / 96)"
316      Write-Host "• Example: 1920 logical pixels at 150% scaling = 1920 × 1.5 = 2880 physical pixels"
317      Write-Host "• Windows snaps monitors using logical pixel coordinates"
318      Write-Host "• If monitors appear snapped but coordinates show gaps, this is a Windows bug"
319      
320      exit 0
321  }
322  catch {
323      Write-Host "`nError capturing monitor layout:" -ForegroundColor Red
324      Write-Host $_.Exception.Message -ForegroundColor Red
325      Write-Host $_.ScriptStackTrace -ForegroundColor DarkGray
326      exit 1
327  }