[CursorWrap] Update coordinate mapping (#43542)

## Summary of the Pull Request
Update coordinate mapping across monitors

---

### Testing instructions

#### Single monitor
- Enable CursorWrap - the cursor should wrap around the top/bottom and
left/right edges of the display.

- Single monitor - Add a second monitor:

- If you have a USB monitor, add the monitor to your PC, CursorWrap
should detect the new monitor and wrapping should occur from the edges
of the monitor depending on monitor layout - for example, if the monitor
is added to the left of the main display then the cursor should move
freely between the left edge of the main monitor to the right edge of
the added monitor - the cursor should wrap from the left edge of the
added monitor to the right edge of the main monitor (same for top/bottom
if the new monitor is added above/below the main monitor).

#### Multi monitor
- If you have a static multi-monitor layout cursor should wrap for outer
edges of the monitor setup, for example, if you have three monitors in a
layout of [1][0][2] then the cursor should wrap from the right edge of
[2] to the left edge of [1], top/bottom should wrap on each monitor.

#### Issues
- If you detect any issues then run the Capture-MonitorLayout.ps1 file,
this will generate a JSON file with your monitor layout, attach the file
to a new comment.

---

<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [ ] Closes: #xxx
<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments

## Validation Steps Performed
validated on three monitor setup and laptop with an external monitor

---------

Co-authored-by: Niels Laute <niels.laute@live.nl>
Co-authored-by: Leilei Zhang <leilzh@microsoft.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Mike Hall
2026-01-08 09:48:10 +00:00
committed by GitHub
parent 4aec8f9d0e
commit 4704e3edb8
6 changed files with 1833 additions and 29 deletions

View File

@@ -0,0 +1,66 @@
# Validating/Testing Cursor Wrap.
If a user determines that CursorWrap isn't working on their PC there are some steps you can take to determine why CursorWrap functionality might not be working as expected.
Note that for a single monitor cursor wrap should always work since all monitor edges are not touching/overlapping with other monitors - the cursor should always wrap to the opposite edge of the same monitor.
Multi-monitor is supported through building a polygon shape for the outer edges of all monitors, inner monitor edges are ignored, movement of the cursor from one monitor to an adjacent monitor is handled by Windows - CursorWrap doesn't get involved in monitor-to-monitor movement, only outer-edges.
We have seen a couple of computer setups that have multi-monitors where CursorWrap doesn't work as expected, this appears to be due to a monitor not being 'snapped' to the edge of an adjacent monitor - If you use Display Settings in Windows you can move monitors around, these appear to 'snap' to an edge of an existing monitor.
What to do if Cursor Wrapping isn't working as expected ?
1. in the CursorWrapTests folder there's a PowerShell script called `Capture-MonitorLayout.ps1` - this will generate a .json file in the form `"$($env:USERNAME)_monitor_layout.json` - the .json file contains an array of monitors, their position, size, dpi, and scaling.
2. Use `CursorWrapTests/monitor_layout_tests.py` to validate the monitor layout/wrapping behavior (uses the json file from point 1 above).
3. Use `analyze_test_results.py` to analyze the monitor layout test output and provide information about why wrapping might not be working
To run `monitor_layout_tests.py` you will need Python installed on your PC.
Run `python monitor_layout_tests.py --layout-file <path to json file>` you can also add an optional `--verbose` to view verbose output.
monitor_layout_tests.py will produce an output file called `test_report.json` - the contents of the file will look like this (this is from a single monitor test).
```json
{
"summary": {
"total_configs": 1,
"passed": 1,
"failed": 0,
"total_issues": 0,
"pass_rate": "100.00%"
},
"failures": [],
"recommendations": [
"All tests passed - edge detection logic is working correctly!"
]
}
```
If there are failures (the failures array is not empty) you can run the second python application called `analyze_test_results.py`
Supported options include:
```text
-h, --help show this help message and exit
--report REPORT Path to test report JSON file
--detailed Show detailed failure listing
--copilot Generate GitHub Copilot-friendly fix prompt
```
Running the analyze_test_results.py script against our single monitor test results produces the following:
```text
python .\analyze_test_results.py --detailed
================================================================================
CURSORWRAP TEST RESULTS ANALYSIS
================================================================================
Total Configurations Tested: 1
Passed: 1 (100.00%)
Failed: 0
Total Issues: 0
✓ ALL TESTS PASSED! Edge detection logic is working correctly.
✓ No failures to analyze!
```

View File

@@ -0,0 +1,266 @@
#!/usr/bin/env pwsh
<#
.SYNOPSIS
Captures the current monitor layout configuration for CursorWrap testing.
.DESCRIPTION
Queries Windows for all connected monitors and saves their configuration
(position, size, DPI, primary status) to a JSON file that can be used
for testing the CursorWrap module.
.PARAMETER OutputPath
Path where the JSON file will be saved. Default: monitor_layout.json
.EXAMPLE
.\Capture-MonitorLayout.ps1
.EXAMPLE
.\Capture-MonitorLayout.ps1 -OutputPath "my_setup.json"
#>
param(
[Parameter(Mandatory=$false)]
[string]$OutputPath = "$($env:USERNAME)_monitor_layout.json"
)
# Add Windows Forms for screen enumeration
Add-Type -AssemblyName System.Windows.Forms
function Get-MonitorDPI {
param([System.Windows.Forms.Screen]$Screen)
# Try to get DPI using P/Invoke with multiple methods
Add-Type @"
using System;
using System.Runtime.InteropServices;
public class DisplayConfig {
[DllImport("user32.dll")]
public static extern IntPtr MonitorFromPoint(POINT pt, uint dwFlags);
[DllImport("shcore.dll")]
public static extern int GetDpiForMonitor(IntPtr hmonitor, int dpiType, out uint dpiX, out uint dpiY);
[DllImport("user32.dll")]
public static extern bool GetMonitorInfo(IntPtr hMonitor, ref MONITORINFOEX lpmi);
[DllImport("user32.dll")]
public static extern IntPtr MonitorFromWindow(IntPtr hwnd, uint dwFlags);
[DllImport("gdi32.dll")]
public static extern int GetDeviceCaps(IntPtr hdc, int nIndex);
[DllImport("user32.dll")]
public static extern IntPtr GetDC(IntPtr hWnd);
[DllImport("user32.dll")]
public static extern int ReleaseDC(IntPtr hWnd, IntPtr hDC);
[StructLayout(LayoutKind.Sequential)]
public struct POINT {
public int X;
public int Y;
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]
public struct MONITORINFOEX {
public int cbSize;
public RECT rcMonitor;
public RECT rcWork;
public uint dwFlags;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)]
public string szDevice;
}
[StructLayout(LayoutKind.Sequential)]
public struct RECT {
public int Left;
public int Top;
public int Right;
public int Bottom;
}
public const uint MONITOR_DEFAULTTOPRIMARY = 1;
public const int MDT_EFFECTIVE_DPI = 0;
public const int MDT_ANGULAR_DPI = 1;
public const int MDT_RAW_DPI = 2;
public const int LOGPIXELSX = 88;
public const int LOGPIXELSY = 90;
}
"@ -ErrorAction SilentlyContinue
try {
$point = New-Object DisplayConfig+POINT
$point.X = $Screen.Bounds.Left + ($Screen.Bounds.Width / 2)
$point.Y = $Screen.Bounds.Top + ($Screen.Bounds.Height / 2)
$hMonitor = [DisplayConfig]::MonitorFromPoint($point, 1)
# Method 1: Try GetDpiForMonitor (Windows 8.1+)
[uint]$dpiX = 0
[uint]$dpiY = 0
$result = [DisplayConfig]::GetDpiForMonitor($hMonitor, 0, [ref]$dpiX, [ref]$dpiY)
if ($result -eq 0 -and $dpiX -gt 0) {
Write-Verbose "DPI detected via GetDpiForMonitor: $dpiX"
return $dpiX
}
# Method 2: Try RAW DPI
$result = [DisplayConfig]::GetDpiForMonitor($hMonitor, 2, [ref]$dpiX, [ref]$dpiY)
if ($result -eq 0 -and $dpiX -gt 0) {
Write-Verbose "DPI detected via RAW DPI: $dpiX"
return $dpiX
}
# Method 3: Try getting device context DPI (legacy method)
$hdc = [DisplayConfig]::GetDC([IntPtr]::Zero)
if ($hdc -ne [IntPtr]::Zero) {
$dpiValue = [DisplayConfig]::GetDeviceCaps($hdc, 88) # LOGPIXELSX
[DisplayConfig]::ReleaseDC([IntPtr]::Zero, $hdc)
if ($dpiValue -gt 0) {
Write-Verbose "DPI detected via GetDeviceCaps: $dpiValue"
return $dpiValue
}
}
}
catch {
Write-Verbose "DPI detection error: $($_.Exception.Message)"
}
Write-Warning "Could not detect DPI for $($Screen.DeviceName), using default 96 DPI"
return 96 # Standard 96 DPI (100% scaling)
}
function Capture-MonitorLayout {
Write-Host "Capturing monitor layout..." -ForegroundColor Cyan
Write-Host "=" * 80
$screens = [System.Windows.Forms.Screen]::AllScreens
$monitors = @()
foreach ($screen in $screens) {
$isPrimary = $screen.Primary
$bounds = $screen.Bounds
$dpi = Get-MonitorDPI -Screen $screen
$monitor = [ordered]@{
left = $bounds.Left
top = $bounds.Top
right = $bounds.Right
bottom = $bounds.Bottom
width = $bounds.Width
height = $bounds.Height
dpi = $dpi
scaling_percent = [math]::Round(($dpi / 96.0) * 100, 0)
primary = $isPrimary
device_name = $screen.DeviceName
}
$monitors += $monitor
# Display info
$primaryTag = if ($isPrimary) { " [PRIMARY]" } else { "" }
$scaling = [math]::Round(($dpi / 96.0) * 100, 0)
Write-Host "`nMonitor $($monitors.Count)$primaryTag" -ForegroundColor Green
Write-Host " Device: $($screen.DeviceName)"
Write-Host " Position: ($($bounds.Left), $($bounds.Top))"
Write-Host " Size: $($bounds.Width)x$($bounds.Height)"
Write-Host " DPI: $dpi ($scaling% scaling)"
Write-Host " Bounds: [$($bounds.Left), $($bounds.Top), $($bounds.Right), $($bounds.Bottom)]"
}
# Create output object
$output = [ordered]@{
captured_at = (Get-Date -Format "yyyy-MM-ddTHH:mm:sszzz")
computer_name = $env:COMPUTERNAME
user_name = $env:USERNAME
monitor_count = $monitors.Count
monitors = $monitors
}
# Save to JSON
$json = $output | ConvertTo-Json -Depth 10
Set-Content -Path $OutputPath -Value $json -Encoding UTF8
Write-Host "`n" + ("=" * 80)
Write-Host "Monitor layout saved to: $OutputPath" -ForegroundColor Green
Write-Host "Total monitors captured: $($monitors.Count)" -ForegroundColor Cyan
Write-Host "`nYou can now use this file with the test script:" -ForegroundColor Yellow
Write-Host " python monitor_layout_tests.py --layout-file $OutputPath" -ForegroundColor White
return $output
}
# Main execution
try {
$layout = Capture-MonitorLayout
# Display summary
Write-Host "`n" + ("=" * 80)
Write-Host "SUMMARY" -ForegroundColor Cyan
Write-Host ("=" * 80)
Write-Host "Configuration Name: $($layout.computer_name)"
Write-Host "Captured: $($layout.captured_at)"
Write-Host "Monitors: $($layout.monitor_count)"
# Calculate desktop dimensions
$widths = @($layout.monitors | ForEach-Object { $_.width })
$heights = @($layout.monitors | ForEach-Object { $_.height })
$totalWidth = ($widths | Measure-Object -Sum).Sum
$maxHeight = ($heights | Measure-Object -Maximum).Maximum
Write-Host "Total Desktop Width: $totalWidth pixels"
Write-Host "Max Desktop Height: $maxHeight pixels"
# Analyze potential coordinate issues
Write-Host "`n" + ("=" * 80)
Write-Host "COORDINATE ANALYSIS" -ForegroundColor Cyan
Write-Host ("=" * 80)
# Check for gaps between monitors
if ($layout.monitor_count -gt 1) {
$hasGaps = $false
for ($i = 0; $i -lt $layout.monitor_count - 1; $i++) {
$m1 = $layout.monitors[$i]
for ($j = $i + 1; $j -lt $layout.monitor_count; $j++) {
$m2 = $layout.monitors[$j]
# Check horizontal gap
$hGap = [Math]::Min([Math]::Abs($m1.right - $m2.left), [Math]::Abs($m2.right - $m1.left))
# Check vertical overlap
$vOverlapStart = [Math]::Max($m1.top, $m2.top)
$vOverlapEnd = [Math]::Min($m1.bottom, $m2.bottom)
$vOverlap = $vOverlapEnd - $vOverlapStart
if ($hGap -gt 50 -and $vOverlap -gt 0) {
Write-Host "⚠ Gap detected between Monitor $($i+1) and Monitor $($j+1): ${hGap}px horizontal gap" -ForegroundColor Yellow
Write-Host " Vertical overlap: ${vOverlap}px" -ForegroundColor Yellow
Write-Host " This may indicate a Windows coordinate bug if monitors appear snapped in Display Settings" -ForegroundColor Yellow
$hasGaps = $true
}
}
}
if (-not $hasGaps) {
Write-Host "✓ No unexpected gaps detected" -ForegroundColor Green
}
}
# DPI/Scaling notes
Write-Host "`nDPI/Scaling Impact on Coordinates:" -ForegroundColor Cyan
Write-Host "• Coordinate values (left, top, right, bottom) are in LOGICAL PIXELS"
Write-Host "• These are DPI-independent virtual coordinates"
Write-Host "• Physical pixels = Logical pixels × (DPI / 96)"
Write-Host "• Example: 1920 logical pixels at 150% scaling = 1920 × 1.5 = 2880 physical pixels"
Write-Host "• Windows snaps monitors using logical pixel coordinates"
Write-Host "• If monitors appear snapped but coordinates show gaps, this is a Windows bug"
exit 0
}
catch {
Write-Host "`nError capturing monitor layout:" -ForegroundColor Red
Write-Host $_.Exception.Message -ForegroundColor Red
Write-Host $_.ScriptStackTrace -ForegroundColor DarkGray
exit 1
}

View File

@@ -0,0 +1,430 @@
"""
Test Results Analyzer for CursorWrap Monitor Layout Tests
Analyzes test_report.json and provides detailed explanations of failures,
patterns, and recommendations.
"""
import json
import sys
from collections import defaultdict
from typing import Dict, List, Any
class TestResultAnalyzer:
"""Analyzes test results and provides insights"""
def __init__(self, report_path: str = "test_report.json"):
with open(report_path, 'r') as f:
self.report = json.load(f)
self.failures = self.report.get('failures', [])
self.summary = self.report.get('summary', {})
self.recommendations = self.report.get('recommendations', [])
def print_overview(self):
"""Print test overview"""
print("=" * 80)
print("CURSORWRAP TEST RESULTS ANALYSIS")
print("=" * 80)
print(f"\nTotal Configurations Tested: {self.summary.get('total_configs', 0)}")
print(f"Passed: {self.summary.get('passed', 0)} ({self.summary.get('pass_rate', 'N/A')})")
print(f"Failed: {self.summary.get('failed', 0)}")
print(f"Total Issues: {self.summary.get('total_issues', 0)}")
if self.summary.get('passed', 0) == self.summary.get('total_configs', 0):
print("\n✓ ALL TESTS PASSED! Edge detection logic is working correctly.")
return
print(f"\n{self.summary.get('total_issues', 0)} issues detected\n")
def analyze_failure_patterns(self):
"""Analyze and categorize failure patterns"""
print("=" * 80)
print("FAILURE PATTERN ANALYSIS")
print("=" * 80)
# Group by test type
by_test_type = defaultdict(list)
for failure in self.failures:
by_test_type[failure['test_name']].append(failure)
# Group by configuration
by_config = defaultdict(list)
for failure in self.failures:
by_config[failure['monitor_config']].append(failure)
print(f"\n1. Failures by Test Type:")
for test_type, failures in sorted(by_test_type.items(), key=lambda x: len(x[1]), reverse=True):
print(f"{test_type}: {len(failures)} failures")
print(f"\n2. Configurations with Failures:")
for config, failures in sorted(by_config.items(), key=lambda x: len(x[1]), reverse=True):
print(f"{config}")
print(f" {len(failures)} issues")
return by_test_type, by_config
def analyze_wrap_calculation_failures(self, failures: List[Dict[str, Any]]):
"""Detailed analysis of wrap calculation failures"""
print("\n" + "=" * 80)
print("WRAP CALCULATION FAILURE ANALYSIS")
print("=" * 80)
# Analyze cursor positions
positions = []
configs = set()
for failure in failures:
configs.add(failure['monitor_config'])
# Extract position from expected message
if 'test_point' in failure.get('details', {}):
pos = failure['details']['test_point']
positions.append(pos)
print(f"\nAffected Configurations: {len(configs)}")
for config in sorted(configs):
print(f"{config}")
if positions:
print(f"\nFailed Test Points: {len(positions)}")
# Analyze if failures are at edges
edge_positions = defaultdict(int)
for x, y in positions:
# Simplified edge detection
if x <= 10:
edge_positions['left edge'] += 1
elif y <= 10:
edge_positions['top edge'] += 1
else:
edge_positions['other'] += 1
if edge_positions:
print("\nPosition Distribution:")
for pos_type, count in edge_positions.items():
print(f"{pos_type}: {count}")
def explain_common_issues(self):
"""Explain common issues found in results"""
print("\n" + "=" * 80)
print("COMMON ISSUE EXPLANATIONS")
print("=" * 80)
has_wrap_failures = any(f['test_name'] == 'wrap_calculation' for f in self.failures)
has_edge_failures = any(f['test_name'] == 'single_monitor_edges' for f in self.failures)
has_touching_failures = any(f['test_name'] == 'touching_monitors' for f in self.failures)
if has_wrap_failures:
print("\n⚠ WRAP CALCULATION FAILURES")
print("-" * 80)
print("Issue: Cursor is on an outer edge but wrapping is not occurring.")
print("\nLikely Causes:")
print(" 1. Partial Overlap Problem:")
print(" • When monitors have different sizes (e.g., 4K + 1080p)")
print(" • Only part of an edge is actually adjacent to another monitor")
print(" • Current code marks the ENTIRE edge as non-outer if ANY part is adjacent")
print(" • This prevents wrapping even in regions where it should occur")
print("\n 2. Edge Detection Logic:")
print(" • Check IdentifyOuterEdges() in MonitorTopology.cpp")
print(" • Consider segmenting edges based on actual overlap regions")
print("\n 3. Test Point Selection:")
print(" • Failures may be at corners or quarter points")
print(" • Indicates edge behavior varies along its length")
if has_edge_failures:
print("\n⚠ SINGLE MONITOR EDGE FAILURES")
print("-" * 80)
print("Issue: Single monitor should have exactly 4 outer edges.")
print("\nThis indicates a fundamental problem in edge detection baseline.")
if has_touching_failures:
print("\n⚠ TOUCHING MONITORS FAILURES")
print("-" * 80)
print("Issue: Adjacent monitors not detected correctly.")
print("\nCheck EdgesAreAdjacent() logic and 50px tolerance settings.")
def print_recommendations(self):
"""Print recommendations from the report"""
if not self.recommendations:
return
print("\n" + "=" * 80)
print("RECOMMENDATIONS")
print("=" * 80)
for i, rec in enumerate(self.recommendations, 1):
print(f"\n{i}. {rec}")
def detailed_failure_dump(self):
"""Print all failure details"""
print("\n" + "=" * 80)
print("DETAILED FAILURE LISTING")
print("=" * 80)
for i, failure in enumerate(self.failures, 1):
print(f"\n[{i}] {failure['test_name']}")
print(f"Configuration: {failure['monitor_config']}")
print(f"Expected: {failure['expected']}")
print(f"Actual: {failure['actual']}")
if 'details' in failure:
details = failure['details']
if 'edge' in details:
edge = details['edge']
print(f"Edge: {edge.get('edge_type', 'N/A')} at position {edge.get('position', 'N/A')}, "
f"range [{edge.get('range_start', 'N/A')}, {edge.get('range_end', 'N/A')}]")
if 'test_point' in details:
print(f"Test Point: {details['test_point']}")
print("-" * 80)
def generate_github_copilot_prompt(self):
"""Generate a prompt suitable for GitHub Copilot to fix the issues"""
print("\n" + "=" * 80)
print("GITHUB COPILOT FIX PROMPT")
print("=" * 80)
print("\n```markdown")
print("# CursorWrap Edge Detection Bug Report")
print()
print("## Test Results Summary")
print(f"- Total Configurations Tested: {self.summary.get('total_configs', 0)}")
print(f"- Pass Rate: {self.summary.get('pass_rate', 'N/A')}")
print(f"- Failed Tests: {self.summary.get('failed', 0)}")
print(f"- Total Issues: {self.summary.get('total_issues', 0)}")
print()
# Group failures
by_test_type = defaultdict(list)
for failure in self.failures:
by_test_type[failure['test_name']].append(failure)
print("## Critical Issues Found")
print()
# Analyze wrap calculation failures
if 'wrap_calculation' in by_test_type:
failures = by_test_type['wrap_calculation']
configs = set(f['monitor_config'] for f in failures)
print("### 1. Wrap Calculation Failures (PARTIAL OVERLAP BUG)")
print()
print(f"**Count**: {len(failures)} failures across {len(configs)} configuration(s)")
print()
print("**Affected Configurations**:")
for config in sorted(configs):
print(f"- {config}")
print()
print("**Root Cause Analysis**:")
print()
print("The current implementation in `MonitorTopology::IdentifyOuterEdges()` marks an")
print("ENTIRE edge as non-outer if ANY portion of that edge is adjacent to another monitor.")
print()
print("**Problem Scenario**: 1080p monitor + 4K monitor at bottom-right")
print("```")
print("4K Monitor (3840x2160 at 0,0)")
print("┌────────────────────────────────────────┐")
print("│ │ <- Y: 0-1080 NO adjacent monitor")
print("│ │ RIGHT EDGE SHOULD BE OUTER")
print("│ │")
print("│ │┌──────────┐")
print("│ ││ 1080p │ <- Y: 1080-2160 HAS adjacent")
print("└────────────────────────────────────────┘│ at │ RIGHT EDGE NOT OUTER")
print(" │ (3840, │")
print(" │ 1080) │")
print(" └──────────┘")
print("```")
print()
print("**Current Behavior**: Right edge of 4K monitor is marked as NON-OUTER for entire")
print("range (Y: 0-2160) because it detects adjacency in the bottom portion (Y: 1080-2160).")
print()
print("**Expected Behavior**: Right edge should be:")
print("- OUTER from Y: 0 to Y: 1080 (no adjacent monitor)")
print("- NON-OUTER from Y: 1080 to Y: 2160 (adjacent to 1080p monitor)")
print()
print("**Failed Test Examples**:")
print()
for i, failure in enumerate(failures[:3], 1): # Show first 3
details = failure.get('details', {})
test_point = details.get('test_point', 'N/A')
edge = details.get('edge', {})
edge_type = edge.get('edge_type', 'N/A')
position = edge.get('position', 'N/A')
range_start = edge.get('range_start', 'N/A')
range_end = edge.get('range_end', 'N/A')
print(f"{i}. **Configuration**: {failure['monitor_config']}")
print(f" - Test Point: {test_point}")
print(f" - Edge: {edge_type} at X={position}, Y range=[{range_start}, {range_end}]")
print(f" - Expected: Cursor wraps to opposite edge")
print(f" - Actual: No wrap occurred (edge incorrectly marked as non-outer)")
print()
if len(failures) > 3:
print(f" ... and {len(failures) - 3} more similar failures")
print()
# Other failure types
if 'single_monitor_edges' in by_test_type:
print("### 2. Single Monitor Edge Detection Failures")
print()
print(f"**Count**: {len(by_test_type['single_monitor_edges'])} failures")
print()
print("Single monitor configurations should have exactly 4 outer edges.")
print("This indicates a fundamental problem in baseline edge detection.")
print()
if 'touching_monitors' in by_test_type:
print("### 3. Adjacent Monitor Detection Failures")
print()
print(f"**Count**: {len(by_test_type['touching_monitors'])} failures")
print()
print("Adjacent monitors not being detected correctly by EdgesAreAdjacent().")
print()
print("## Required Code Changes")
print()
print("### File: `MonitorTopology.cpp`")
print()
print("**Change 1**: Modify `IdentifyOuterEdges()` to support partial edge adjacency")
print()
print("Instead of marking entire edges as outer/non-outer, the code needs to:")
print()
print("1. **Segment edges** based on actual overlap regions with adjacent monitors")
print("2. Create **sub-edges** for portions of an edge that have different outer status")
print("3. Update `IsOnOuterEdge()` to check if the **cursor's specific position** is on an outer portion")
print()
print("**Proposed Approach**:")
print()
print("```cpp")
print("// Instead of: edge.isOuter = true/false for entire edge")
print("// Use: Store list of outer ranges for each edge")
print()
print("struct MonitorEdge {")
print(" // ... existing fields ...")
print(" std::vector<std::pair<int, int>> outerRanges; // Ranges where edge is outer")
print("};")
print()
print("// In IdentifyOuterEdges():")
print("// For each edge, find ALL adjacent opposite edges")
print("// Calculate which portions of the edge have NO adjacent opposite")
print("// Store these as outer ranges")
print()
print("// In IsOnOuterEdge():")
print("// Check if cursor position falls within any outer range")
print("if (edge.type == EdgeType::Left || edge.type == EdgeType::Right) {")
print(" // Check if cursorPos.y is in any outer range")
print("} else {")
print(" // Check if cursorPos.x is in any outer range")
print("}")
print("```")
print()
print("**Change 2**: Update `EdgesAreAdjacent()` validation")
print()
print("The 50px tolerance logic is correct but needs to return overlap range info:")
print()
print("```cpp")
print("struct AdjacencyResult {")
print(" bool isAdjacent;")
print(" int overlapStart; // Where the adjacency begins")
print(" int overlapEnd; // Where the adjacency ends")
print("};")
print()
print("AdjacencyResult CheckEdgeAdjacency(const MonitorEdge& edge1, ")
print(" const MonitorEdge& edge2, ")
print(" int tolerance);")
print("```")
print()
print("## Test Validation")
print()
print("After implementing changes, run:")
print("```bash")
print("python monitor_layout_tests.py --max-monitors 10")
print("```")
print()
print("Expected results:")
print("- All 21+ configurations should pass")
print("- Specifically, the 4K+1080p configuration should pass all 5 test points per edge")
print("- Wrap calculation should work correctly at partial overlap boundaries")
print()
print("## Files to Modify")
print()
print("1. `MonitorTopology.h` - Update MonitorEdge structure")
print("2. `MonitorTopology.cpp` - Implement segmented edge detection")
print(" - `IdentifyOuterEdges()` - Main logic change")
print(" - `IsOnOuterEdge()` - Check position against ranges")
print(" - `EdgesAreAdjacent()` - Optionally return range info")
print()
print("```")
def run_analysis(self, detailed: bool = False, copilot_mode: bool = False):
"""Run complete analysis"""
if copilot_mode:
self.generate_github_copilot_prompt()
return
self.print_overview()
if not self.failures:
print("\n✓ No failures to analyze!")
return
by_test_type, by_config = self.analyze_failure_patterns()
# Specific analysis for wrap calculation failures
if 'wrap_calculation' in by_test_type:
self.analyze_wrap_calculation_failures(by_test_type['wrap_calculation'])
self.explain_common_issues()
self.print_recommendations()
if detailed:
self.detailed_failure_dump()
def main():
"""Main entry point"""
import argparse
parser = argparse.ArgumentParser(
description="Analyze CursorWrap test results"
)
parser.add_argument(
"--report",
default="test_report.json",
help="Path to test report JSON file"
)
parser.add_argument(
"--detailed",
action="store_true",
help="Show detailed failure listing"
)
parser.add_argument(
"--copilot",
action="store_true",
help="Generate GitHub Copilot-friendly fix prompt"
)
args = parser.parse_args()
try:
analyzer = TestResultAnalyzer(args.report)
analyzer.run_analysis(detailed=args.detailed, copilot_mode=args.copilot)
# Exit with error code if there were failures
sys.exit(0 if not analyzer.failures else 1)
except FileNotFoundError:
print(f"Error: Could not find report file: {args.report}")
print("\nRun monitor_layout_tests.py first to generate the report.")
sys.exit(1)
except json.JSONDecodeError:
print(f"Error: Invalid JSON in report file: {args.report}")
sys.exit(1)
except Exception as e:
print(f"Error analyzing report: {e}")
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,892 @@
"""
Monitor Layout Edge Detection Test Suite for CursorWrap
This script validates the edge detection and wrapping logic across thousands of
monitor configurations without requiring the full PowerToys build environment.
Tests:
- 1-4 monitor configurations
- Common resolutions and DPI scales
- Various arrangements (horizontal, vertical, L-shape, grid)
- Edge detection (touching vs. gap)
- Wrap calculations
Output: JSON report with failures for GitHub Copilot analysis
"""
import json
from dataclasses import dataclass, asdict
from typing import List, Tuple, Dict, Optional
from enum import Enum
import sys
# ============================================================================
# Data Structures (mirrors C++ implementation)
# ============================================================================
@dataclass
class MonitorInfo:
"""Represents a physical monitor"""
left: int
top: int
right: int
bottom: int
dpi: int = 96
primary: bool = False
@property
def width(self) -> int:
return self.right - self.left
@property
def height(self) -> int:
return self.bottom - self.top
@property
def center_x(self) -> int:
return (self.left + self.right) // 2
@property
def center_y(self) -> int:
return (self.top + self.bottom) // 2
class EdgeType(Enum):
LEFT = "Left"
RIGHT = "Right"
TOP = "Top"
BOTTOM = "Bottom"
@dataclass
class Edge:
"""Represents a monitor edge"""
edge_type: EdgeType
position: int # x for vertical, y for horizontal
range_start: int
range_end: int
monitor_index: int
def overlaps(self, other: 'Edge', tolerance: int = 1) -> bool:
"""Check if two edges overlap in their perpendicular range"""
if self.edge_type != other.edge_type:
return False
if abs(self.position - other.position) > tolerance:
return False
return not (
self.range_end <= other.range_start or other.range_end <= self.range_start)
@dataclass
class TestFailure:
"""Records a test failure for analysis"""
test_name: str
monitor_config: str
expected: str
actual: str
details: Dict
# ============================================================================
# Edge Detection Logic (Python implementation of C++ logic)
# ============================================================================
class MonitorTopology:
"""Implements the edge detection logic to be validated"""
ADJACENCY_TOLERANCE = 50 # Pixels - tolerance for detecting adjacent edges (matches C++ implementation)
EDGE_THRESHOLD = 1 # Pixels - cursor must be within this distance to trigger wrap
def __init__(self, monitors: List[MonitorInfo]):
self.monitors = monitors
self.outer_edges: List[Edge] = []
self._detect_outer_edges()
def _detect_outer_edges(self):
"""Detect which edges are outer (can wrap)"""
all_edges = self._collect_all_edges()
for edge in all_edges:
if self._is_outer_edge(edge, all_edges):
self.outer_edges.append(edge)
def _collect_all_edges(self) -> List[Edge]:
"""Collect all edges from all monitors"""
edges = []
for idx, mon in enumerate(self.monitors):
edges.append(
Edge(
EdgeType.LEFT,
mon.left,
mon.top,
mon.bottom,
idx))
edges.append(
Edge(
EdgeType.RIGHT,
mon.right,
mon.top,
mon.bottom,
idx))
edges.append(Edge(EdgeType.TOP, mon.top, mon.left, mon.right, idx))
edges.append(
Edge(
EdgeType.BOTTOM,
mon.bottom,
mon.left,
mon.right,
idx))
return edges
def _is_outer_edge(self, edge: Edge, all_edges: List[Edge]) -> bool:
"""
Determine if an edge is "outer" (can wrap)
Rules:
1. If edge has an adjacent opposite edge (within 50px tolerance AND overlapping range), it's NOT outer
2. Otherwise, edge IS outer
Note: This matches C++ EdgesAreAdjacent() logic
"""
opposite_type = self._get_opposite_edge_type(edge.edge_type)
# Find opposite edges that overlap in perpendicular range
opposite_edges = [e for e in all_edges
if e.edge_type == opposite_type
and e.monitor_index != edge.monitor_index
and self._ranges_overlap(edge.range_start, edge.range_end,
e.range_start, e.range_end)]
if not opposite_edges:
return True # No opposite edges = outer edge
# Check if any opposite edge is adjacent (within tolerance)
for opp in opposite_edges:
distance = abs(edge.position - opp.position)
if distance <= self.ADJACENCY_TOLERANCE:
return False # Adjacent edge found = not outer
return True # No adjacent edges = outer
@staticmethod
def _get_opposite_edge_type(edge_type: EdgeType) -> EdgeType:
"""Get the opposite edge type"""
opposites = {
EdgeType.LEFT: EdgeType.RIGHT,
EdgeType.RIGHT: EdgeType.LEFT,
EdgeType.TOP: EdgeType.BOTTOM,
EdgeType.BOTTOM: EdgeType.TOP
}
return opposites[edge_type]
@staticmethod
def _ranges_overlap(
a_start: int,
a_end: int,
b_start: int,
b_end: int) -> bool:
"""Check if two 1D ranges overlap"""
return not (a_end <= b_start or b_end <= a_start)
def calculate_wrap_position(self, x: int, y: int) -> Tuple[int, int]:
"""Calculate where cursor should wrap to"""
# Find which outer edge was crossed and calculate wrap
# At corners, multiple edges may match - try all and return first successful wrap
for edge in self.outer_edges:
if self._is_on_edge(x, y, edge):
new_x, new_y = self._wrap_from_edge(x, y, edge)
if (new_x, new_y) != (x, y):
# Wrap succeeded
return (new_x, new_y)
return (x, y) # No wrap
def _is_on_edge(self, x: int, y: int, edge: Edge) -> bool:
"""Check if point is on the given edge"""
tolerance = 2 # Pixels
if edge.edge_type in (EdgeType.LEFT, EdgeType.RIGHT):
return (abs(x - edge.position) <= tolerance and
edge.range_start <= y <= edge.range_end)
else:
return (abs(y - edge.position) <= tolerance and
edge.range_start <= x <= edge.range_end)
def _wrap_from_edge(self, x: int, y: int, edge: Edge) -> Tuple[int, int]:
"""Calculate wrap destination from an outer edge"""
opposite_type = self._get_opposite_edge_type(edge.edge_type)
# Find opposite outer edges that overlap
opposite_edges = [e for e in self.outer_edges
if e.edge_type == opposite_type
and self._point_in_range(x, y, e)]
if not opposite_edges:
return (x, y) # No wrap destination
# Find closest opposite edge
target_edge = min(opposite_edges,
key=lambda e: abs(e.position - edge.position))
# Calculate new position
if edge.edge_type in (EdgeType.LEFT, EdgeType.RIGHT):
return (target_edge.position, y)
else:
return (x, target_edge.position)
@staticmethod
def _point_in_range(x: int, y: int, edge: Edge) -> bool:
"""Check if point's perpendicular coordinate is in edge's range"""
if edge.edge_type in (EdgeType.LEFT, EdgeType.RIGHT):
return edge.range_start <= y <= edge.range_end
else:
return edge.range_start <= x <= edge.range_end
# ============================================================================
# Test Configuration Generators
# ============================================================================
class TestConfigGenerator:
"""Generates comprehensive test configurations"""
# Common resolutions
RESOLUTIONS = [
(1920, 1080), # 1080p
(2560, 1440), # 1440p
(3840, 2160), # 4K
(3440, 1440), # Ultrawide
(1920, 1200), # 16:10
]
# DPI scales
DPI_SCALES = [96, 120, 144, 192] # 100%, 125%, 150%, 200%
@classmethod
def load_from_file(cls, filepath: str) -> List[List[MonitorInfo]]:
"""Load monitor configuration from captured JSON file"""
# Handle UTF-8 with BOM (PowerShell default)
with open(filepath, 'r', encoding='utf-8-sig') as f:
data = json.load(f)
monitors = []
for mon in data.get('monitors', []):
monitor = MonitorInfo(
left=mon['left'],
top=mon['top'],
right=mon['right'],
bottom=mon['bottom'],
dpi=mon.get('dpi', 96),
primary=mon.get('primary', False)
)
monitors.append(monitor)
return [monitors] if monitors else []
@classmethod
def generate_all_configs(cls,
max_monitors: int = 4) -> List[List[MonitorInfo]]:
"""Generate all test configurations"""
configs = []
# Single monitor (baseline)
configs.extend(cls._single_monitor_configs())
# Two monitors (most common)
if max_monitors >= 2:
configs.extend(cls._two_monitor_configs())
# Three monitors
if max_monitors >= 3:
configs.extend(cls._three_monitor_configs())
# Four monitors
if max_monitors >= 4:
configs.extend(cls._four_monitor_configs())
# Five+ monitors
if max_monitors >= 5:
configs.extend(cls._five_plus_monitor_configs(max_monitors))
return configs
@classmethod
def _single_monitor_configs(cls) -> List[List[MonitorInfo]]:
"""Single monitor configurations"""
configs = []
for width, height in cls.RESOLUTIONS[:3]: # Limit for single monitor
for dpi in cls.DPI_SCALES[:2]: # Limit DPI variations
mon = MonitorInfo(0, 0, width, height, dpi, True)
configs.append([mon])
return configs
@classmethod
def _two_monitor_configs(cls) -> List[List[MonitorInfo]]:
"""Two monitor configurations"""
configs = []
# Both 1080p for simplicity
res1, res2 = cls.RESOLUTIONS[0], cls.RESOLUTIONS[0]
# Horizontal (touching)
configs.append([
MonitorInfo(0, 0, res1[0], res1[1], primary=True),
MonitorInfo(res1[0], 0, res1[0] + res2[0], res2[1])
])
# Vertical (touching)
configs.append([
MonitorInfo(0, 0, res1[0], res1[1], primary=True),
MonitorInfo(0, res1[1], res2[0], res1[1] + res2[1])
])
# Different resolutions
res_big = cls.RESOLUTIONS[2] # 4K
configs.append([
MonitorInfo(0, 0, res1[0], res1[1], primary=True),
MonitorInfo(res1[0], 0, res1[0] + res_big[0], res_big[1])
])
# Offset alignment (common real-world scenario)
offset = 200
configs.append([
MonitorInfo(0, offset, res1[0], offset + res1[1], primary=True),
MonitorInfo(res1[0], 0, res1[0] + res2[0], res2[1])
])
return configs
@classmethod
def _three_monitor_configs(cls) -> List[List[MonitorInfo]]:
"""Three monitor configurations"""
configs = []
res = cls.RESOLUTIONS[0] # 1080p
# Linear horizontal
configs.append([
MonitorInfo(0, 0, res[0], res[1], primary=True),
MonitorInfo(res[0], 0, res[0] * 2, res[1]),
MonitorInfo(res[0] * 2, 0, res[0] * 3, res[1])
])
# L-shape (common gaming setup)
configs.append([
MonitorInfo(0, 0, res[0], res[1], primary=True),
MonitorInfo(res[0], 0, res[0] * 2, res[1]),
MonitorInfo(0, res[1], res[0], res[1] * 2)
])
# Vertical stack
configs.append([
MonitorInfo(0, 0, res[0], res[1], primary=True),
MonitorInfo(0, res[1], res[0], res[1] * 2),
MonitorInfo(0, res[1] * 2, res[0], res[1] * 3)
])
return configs
@classmethod
def _four_monitor_configs(cls) -> List[List[MonitorInfo]]:
"""Four monitor configurations"""
configs = []
res = cls.RESOLUTIONS[0] # 1080p
# 2x2 grid (classic)
configs.append([
MonitorInfo(0, 0, res[0], res[1], primary=True),
MonitorInfo(res[0], 0, res[0] * 2, res[1]),
MonitorInfo(0, res[1], res[0], res[1] * 2),
MonitorInfo(res[0], res[1], res[0] * 2, res[1] * 2)
])
# Linear horizontal
configs.append([
MonitorInfo(0, 0, res[0], res[1], primary=True),
MonitorInfo(res[0], 0, res[0] * 2, res[1]),
MonitorInfo(res[0] * 2, 0, res[0] * 3, res[1]),
MonitorInfo(res[0] * 3, 0, res[0] * 4, res[1])
])
return configs
@classmethod
def _five_plus_monitor_configs(cls, max_count: int) -> List[List[MonitorInfo]]:
"""Five to ten monitor configurations"""
configs = []
res = cls.RESOLUTIONS[0] # 1080p
# Linear horizontal (5-10 monitors)
for count in range(5, min(max_count + 1, 11)):
monitor_list = []
for i in range(count):
is_primary = (i == 0)
monitor_list.append(
MonitorInfo(res[0] * i, 0, res[0] * (i + 1), res[1], primary=is_primary)
)
configs.append(monitor_list)
return configs
# ============================================================================
# Test Validators
# ============================================================================
class EdgeDetectionValidator:
"""Validates edge detection logic"""
@staticmethod
def validate_single_monitor(
monitors: List[MonitorInfo]) -> Optional[TestFailure]:
"""Single monitor should have 4 outer edges"""
topology = MonitorTopology(monitors)
expected_count = 4
actual_count = len(topology.outer_edges)
if actual_count != expected_count:
return TestFailure(
test_name="single_monitor_edges",
monitor_config=EdgeDetectionValidator._describe_config(
monitors),
expected=f"{expected_count} outer edges",
actual=f"{actual_count} outer edges",
details={"edges": [asdict(e) for e in topology.outer_edges]}
)
return None
@staticmethod
def validate_touching_monitors(
monitors: List[MonitorInfo]) -> Optional[TestFailure]:
"""Touching monitors should have no gap between them"""
topology = MonitorTopology(monitors)
# For 2 touching monitors horizontally, expect 6 outer edges (not 8)
if len(monitors) == 2:
# Check if they're aligned horizontally and touching
m1, m2 = monitors
if m1.right == m2.left and m1.top == m2.top and m1.bottom == m2.bottom:
expected = 6 # 2 internal edges removed
actual = len(topology.outer_edges)
if actual != expected:
return TestFailure(
test_name="touching_monitors",
monitor_config=EdgeDetectionValidator._describe_config(
monitors),
expected=f"{expected} outer edges (2 touching edges removed)",
actual=f"{actual} outer edges",
details={"edges": [asdict(e)
for e in topology.outer_edges]}
)
return None
@staticmethod
def validate_wrap_calculation(
monitors: List[MonitorInfo]) -> List[TestFailure]:
"""Validate cursor wrap calculations"""
failures = []
topology = MonitorTopology(monitors)
# Test wrapping at each outer edge with multiple points
for edge in topology.outer_edges:
test_points = EdgeDetectionValidator._get_test_points_on_edge(
edge, monitors)
for test_point in test_points:
x, y = test_point
# Check if there's actually a valid wrap destination
# (some outer edges may not have opposite edges due to partial overlap)
opposite_type = topology._get_opposite_edge_type(edge.edge_type)
has_opposite = any(
e.edge_type == opposite_type and
topology._point_in_range(x, y, e)
for e in topology.outer_edges
)
if not has_opposite:
# No wrap destination available - this is OK for partial overlaps
continue
new_x, new_y = topology.calculate_wrap_position(x, y)
# Verify wrap happened (position changed)
if (new_x, new_y) == (x, y):
# Should have wrapped but didn't
failure = TestFailure(
test_name="wrap_calculation",
monitor_config=EdgeDetectionValidator._describe_config(
monitors),
expected=f"Cursor should wrap from ({x},{y})",
actual=f"No wrap occurred",
details={
"edge": asdict(edge),
"test_point": (x, y)
}
)
failures.append(failure)
return failures
@staticmethod
def _get_test_points_on_edge(
edge: Edge, monitors: List[MonitorInfo]) -> List[Tuple[int, int]]:
"""Get multiple test points on the given edge (5 points: top/left corner, quarter, center, three-quarter, bottom/right corner)"""
monitor = monitors[edge.monitor_index]
points = []
if edge.edge_type == EdgeType.LEFT:
x = monitor.left
for ratio in [0.0, 0.25, 0.5, 0.75, 1.0]:
y = int(monitor.top + (monitor.height - 1) * ratio)
points.append((x, y))
elif edge.edge_type == EdgeType.RIGHT:
x = monitor.right - 1
for ratio in [0.0, 0.25, 0.5, 0.75, 1.0]:
y = int(monitor.top + (monitor.height - 1) * ratio)
points.append((x, y))
elif edge.edge_type == EdgeType.TOP:
y = monitor.top
for ratio in [0.0, 0.25, 0.5, 0.75, 1.0]:
x = int(monitor.left + (monitor.width - 1) * ratio)
points.append((x, y))
elif edge.edge_type == EdgeType.BOTTOM:
y = monitor.bottom - 1
for ratio in [0.0, 0.25, 0.5, 0.75, 1.0]:
x = int(monitor.left + (monitor.width - 1) * ratio)
points.append((x, y))
return points
@staticmethod
def _describe_config(monitors: List[MonitorInfo]) -> str:
"""Generate human-readable config description"""
if len(monitors) == 1:
m = monitors[0]
return f"Single {m.width}x{m.height} @{m.dpi}DPI"
desc = f"{len(monitors)} monitors: "
for i, m in enumerate(monitors):
desc += f"M{i}({m.width}x{m.height} at {m.left},{m.top}) "
return desc.strip()
# ============================================================================
# Test Runner
# ============================================================================
class TestRunner:
"""Orchestrates the test execution"""
def __init__(self, max_monitors: int = 10, verbose: bool = False, layout_file: str = None):
self.max_monitors = max_monitors
self.verbose = verbose
self.layout_file = layout_file
self.failures: List[TestFailure] = []
self.test_count = 0
self.passed_count = 0
def _print_layout_diagram(self, monitors: List[MonitorInfo]):
"""Print a text-based diagram of the monitor layout"""
print("\n" + "=" * 80)
print("MONITOR LAYOUT DIAGRAM")
print("=" * 80)
# Find bounds of entire desktop
min_x = min(m.left for m in monitors)
min_y = min(m.top for m in monitors)
max_x = max(m.right for m in monitors)
max_y = max(m.bottom for m in monitors)
# Calculate scale to fit in ~70 chars wide
desktop_width = max_x - min_x
desktop_height = max_y - min_y
# Scale factor: target 70 chars width
scale = desktop_width / 70.0
if scale < 1:
scale = 1
# Create grid (70 chars wide, proportional height)
grid_width = 70
grid_height = max(10, int(desktop_height / scale))
grid_height = min(grid_height, 30) # Cap at 30 lines
# Initialize grid with spaces
grid = [[' ' for _ in range(grid_width)] for _ in range(grid_height)]
# Draw each monitor
for idx, mon in enumerate(monitors):
# Convert monitor coords to grid coords
x1 = int((mon.left - min_x) / scale)
y1 = int((mon.top - min_y) / scale)
x2 = int((mon.right - min_x) / scale)
y2 = int((mon.bottom - min_y) / scale)
# Clamp to grid
x1 = max(0, min(x1, grid_width - 1))
x2 = max(0, min(x2, grid_width))
y1 = max(0, min(y1, grid_height - 1))
y2 = max(0, min(y2, grid_height))
# Draw monitor border and fill
char = str(idx) if idx < 10 else chr(65 + idx - 10) # 0-9, then A-Z
for y in range(y1, y2):
for x in range(x1, x2):
if y < grid_height and x < grid_width:
# Draw borders
if y == y1 or y == y2 - 1:
grid[y][x] = ''
elif x == x1 or x == x2 - 1:
grid[y][x] = ''
else:
grid[y][x] = char
# Draw corners
if y1 < grid_height and x1 < grid_width:
grid[y1][x1] = ''
if y1 < grid_height and x2 - 1 < grid_width:
grid[y1][x2 - 1] = ''
if y2 - 1 < grid_height and x1 < grid_width:
grid[y2 - 1][x1] = ''
if y2 - 1 < grid_height and x2 - 1 < grid_width:
grid[y2 - 1][x2 - 1] = ''
# Print grid
print()
for row in grid:
print(''.join(row))
# Print legend
print("\n" + "-" * 80)
print("MONITOR DETAILS:")
print("-" * 80)
for idx, mon in enumerate(monitors):
char = str(idx) if idx < 10 else chr(65 + idx - 10)
primary = " [PRIMARY]" if mon.primary else ""
scaling = int((mon.dpi / 96.0) * 100)
print(f" [{char}] Monitor {idx}{primary}")
print(f" Position: ({mon.left}, {mon.top})")
print(f" Size: {mon.width}x{mon.height}")
print(f" DPI: {mon.dpi} ({scaling}% scaling)")
print(f" Bounds: [{mon.left}, {mon.top}, {mon.right}, {mon.bottom}]")
print("=" * 80 + "\n")
def run_all_tests(self):
"""Execute all test configurations"""
print("=" * 80)
print("CursorWrap Monitor Layout Edge Detection Test Suite")
print("=" * 80)
# Load or generate configs
if self.layout_file:
print(f"\nLoading monitor layout from {self.layout_file}...")
configs = TestConfigGenerator.load_from_file(self.layout_file)
# Show visual diagram for captured layouts
if configs:
self._print_layout_diagram(configs[0])
else:
print("\nGenerating test configurations...")
configs = TestConfigGenerator.generate_all_configs(self.max_monitors)
total_tests = len(configs)
print(f"Testing {total_tests} configuration(s)")
print("=" * 80)
# Run tests
for i, config in enumerate(configs, 1):
self._run_test_config(config, i, total_tests)
# Report results
self._print_summary()
self._save_report()
def _run_test_config(
self,
monitors: List[MonitorInfo],
iteration: int,
total: int):
"""Run all validators on a single configuration"""
desc = EdgeDetectionValidator._describe_config(monitors)
if not self.verbose:
# Minimal output: just progress
progress = (iteration / total) * 100
print(
f"\r[{iteration}/{total}] {progress:5.1f}% - Testing: {desc[:60]:<60}", end="", flush=True)
else:
print(f"\n[{iteration}/{total}] Testing: {desc}")
# Run validators
self.test_count += 1
config_passed = True
# Single monitor validation
if len(monitors) == 1:
failure = EdgeDetectionValidator.validate_single_monitor(monitors)
if failure:
self.failures.append(failure)
config_passed = False
# Touching monitors validation (2+ monitors)
if len(monitors) >= 2:
failure = EdgeDetectionValidator.validate_touching_monitors(monitors)
if failure:
self.failures.append(failure)
config_passed = False
# Wrap calculation validation
wrap_failures = EdgeDetectionValidator.validate_wrap_calculation(monitors)
if wrap_failures:
self.failures.extend(wrap_failures)
config_passed = False
if config_passed:
self.passed_count += 1
if self.verbose and not config_passed:
print(f" ? FAILED ({len([f for f in self.failures if desc in f.monitor_config])} issues)")
elif self.verbose:
print(" ? PASSED")
def _print_summary(self):
"""Print test summary"""
print("\n\n" + "=" * 80)
print("TEST SUMMARY")
print("=" * 80)
print(f"Total Configurations: {self.test_count}")
print(f"Passed: {self.passed_count} ({self.passed_count/self.test_count*100:.1f}%)")
print(f"Failed: {self.test_count - self.passed_count} ({(self.test_count - self.passed_count)/self.test_count*100:.1f}%)")
print(f"Total Issues Found: {len(self.failures)}")
print("=" * 80)
if self.failures:
print("\n?? FAILURES DETECTED - See test_report.json for details")
print("\nTop 5 Failure Types:")
failure_types = {}
for f in self.failures:
failure_types[f.test_name] = failure_types.get(f.test_name, 0) + 1
for test_name, count in sorted(failure_types.items(), key=lambda x: x[1], reverse=True)[:5]:
print(f" - {test_name}: {count} failures")
else:
print("\n? ALL TESTS PASSED!")
def _save_report(self):
"""Save detailed JSON report"""
# Helper to convert enums to strings
def convert_for_json(obj):
if isinstance(obj, dict):
return {k: convert_for_json(v) for k, v in obj.items()}
elif isinstance(obj, list):
return [convert_for_json(item) for item in obj]
elif isinstance(obj, Enum):
return obj.value
else:
return obj
report = {
"summary": {
"total_configs": self.test_count,
"passed": self.passed_count,
"failed": self.test_count - self.passed_count,
"total_issues": len(self.failures),
"pass_rate": f"{self.passed_count/self.test_count*100:.2f}%"
},
"failures": convert_for_json([asdict(f) for f in self.failures]),
"recommendations": self._generate_recommendations()
}
output_file = "test_report.json"
with open(output_file, "w") as f:
json.dump(report, f, indent=2)
print(f"\n?? Detailed report saved to: {output_file}")
def _generate_recommendations(self) -> List[str]:
"""Generate recommendations based on failures"""
recommendations = []
failure_types = {}
for f in self.failures:
failure_types[f.test_name] = failure_types.get(f.test_name, 0) + 1
if "single_monitor_edges" in failure_types:
recommendations.append(
"Single monitor edge detection failing - verify baseline case in MonitorTopology::_detect_outer_edges()"
)
if "touching_monitors" in failure_types:
recommendations.append(
f"Adjacent monitor detection failing ({failure_types['touching_monitors']} cases) - "
"review ADJACENCY_TOLERANCE (50px) and edge overlap logic in EdgesAreAdjacent()"
)
if "wrap_calculation" in failure_types:
recommendations.append(
f"Wrap calculation failing ({failure_types['wrap_calculation']} cases) - "
"review CursorWrapCore::HandleMouseMove() wrap destination logic"
)
if not recommendations:
recommendations.append("All tests passed - edge detection logic is working correctly!")
return recommendations
# ============================================================================
# Main Entry Point
# ============================================================================
# ============================================================================
# Main Entry Point
# ============================================================================
def main():
"""Main entry point"""
import argparse
parser = argparse.ArgumentParser(
description="CursorWrap Monitor Layout Edge Detection Test Suite"
)
parser.add_argument(
"--max-monitors",
type=int,
default=10,
help="Maximum number of monitors to test (1-10)"
)
parser.add_argument(
"--verbose",
action="store_true",
help="Enable verbose output"
)
parser.add_argument(
"--layout-file",
type=str,
help="Use captured monitor layout JSON file instead of generated configs"
)
args = parser.parse_args()
if not args.layout_file:
# Validate max_monitors only for generated configs
if args.max_monitors < 1 or args.max_monitors > 10:
print("Error: max-monitors must be between 1 and 10")
sys.exit(1)
runner = TestRunner(
max_monitors=args.max_monitors,
verbose=args.verbose,
layout_file=args.layout_file
)
runner.run_all_tests()
# Exit with error code if tests failed
sys.exit(0 if not runner.failures else 1)
if __name__ == "__main__":
main()

View File

@@ -506,8 +506,58 @@ public:
return CallNextHookEx(nullptr, nCode, wParam, lParam);
}
// Helper method to check if there's a monitor adjacent in coordinate space (not grid)
bool HasAdjacentMonitorInCoordinateSpace(const RECT& currentMonitorRect, int direction)
{
// direction: 0=left, 1=right, 2=top, 3=bottom
const int tolerance = 50; // Allow small gaps
for (const auto& monitor : m_monitors)
{
bool isAdjacent = false;
switch (direction)
{
case 0: // Left - check if another monitor's right edge touches/overlaps our left edge
isAdjacent = (abs(monitor.rect.right - currentMonitorRect.left) <= tolerance) &&
(monitor.rect.bottom > currentMonitorRect.top + tolerance) &&
(monitor.rect.top < currentMonitorRect.bottom - tolerance);
break;
case 1: // Right - check if another monitor's left edge touches/overlaps our right edge
isAdjacent = (abs(monitor.rect.left - currentMonitorRect.right) <= tolerance) &&
(monitor.rect.bottom > currentMonitorRect.top + tolerance) &&
(monitor.rect.top < currentMonitorRect.bottom - tolerance);
break;
case 2: // Top - check if another monitor's bottom edge touches/overlaps our top edge
isAdjacent = (abs(monitor.rect.bottom - currentMonitorRect.top) <= tolerance) &&
(monitor.rect.right > currentMonitorRect.left + tolerance) &&
(monitor.rect.left < currentMonitorRect.right - tolerance);
break;
case 3: // Bottom - check if another monitor's top edge touches/overlaps our bottom edge
isAdjacent = (abs(monitor.rect.top - currentMonitorRect.bottom) <= tolerance) &&
(monitor.rect.right > currentMonitorRect.left + tolerance) &&
(monitor.rect.left < currentMonitorRect.right - tolerance);
break;
}
if (isAdjacent)
{
#ifdef _DEBUG
Logger::info(L"CursorWrap DEBUG: Found adjacent monitor in coordinate space (direction {})", direction);
#endif
return true;
}
}
return false;
}
// *** COMPLETELY REWRITTEN CURSOR WRAPPING LOGIC ***
// Implements vertical scrolling to bottom/top of vertical stack as requested
// Only wraps when there's NO adjacent monitor in the coordinate space
POINT HandleMouseMove(const POINT& currentPos)
{
POINT newPos = currentPos;
@@ -546,12 +596,22 @@ public:
// *** VERTICAL WRAPPING LOGIC - CONFIRMED WORKING ***
// Move to bottom of vertical stack when hitting top edge
// Only wrap if there's NO adjacent monitor in the coordinate space
if (currentPos.y <= currentMonitorInfo.rcMonitor.top)
{
#ifdef _DEBUG
Logger::info(L"CursorWrap DEBUG: ======= VERTICAL WRAP: TOP EDGE DETECTED =======");
#endif
// Check if there's an adjacent monitor above in coordinate space
if (HasAdjacentMonitorInCoordinateSpace(currentMonitorInfo.rcMonitor, 2))
{
#ifdef _DEBUG
Logger::info(L"CursorWrap DEBUG: SKIPPING WRAP - Adjacent monitor exists above (Windows will handle)");
#endif
return currentPos; // Let Windows handle natural cursor movement
}
// Find the bottom-most monitor in the vertical stack (same column)
HMONITOR bottomMonitor = nullptr;
@@ -604,6 +664,15 @@ public:
Logger::info(L"CursorWrap DEBUG: ======= VERTICAL WRAP: BOTTOM EDGE DETECTED =======");
#endif
// Check if there's an adjacent monitor below in coordinate space
if (HasAdjacentMonitorInCoordinateSpace(currentMonitorInfo.rcMonitor, 3))
{
#ifdef _DEBUG
Logger::info(L"CursorWrap DEBUG: SKIPPING WRAP - Adjacent monitor exists below (Windows will handle)");
#endif
return currentPos; // Let Windows handle natural cursor movement
}
// Find the top-most monitor in the vertical stack (same column)
HMONITOR topMonitor = nullptr;
@@ -653,13 +722,22 @@ public:
// *** FIXED HORIZONTAL WRAPPING LOGIC ***
// Move to opposite end of horizontal stack when hitting left/right edge
// Only handle horizontal wrapping if we haven't already wrapped vertically
// Only wrap if there's NO adjacent monitor in the coordinate space (let Windows handle natural transitions)
if (!wrapped && currentPos.x <= currentMonitorInfo.rcMonitor.left)
{
#ifdef _DEBUG
Logger::info(L"CursorWrap DEBUG: ======= HORIZONTAL WRAP: LEFT EDGE DETECTED =======");
#endif
// Check if there's an adjacent monitor to the left in coordinate space
if (HasAdjacentMonitorInCoordinateSpace(currentMonitorInfo.rcMonitor, 0))
{
#ifdef _DEBUG
Logger::info(L"CursorWrap DEBUG: SKIPPING WRAP - Adjacent monitor exists to the left (Windows will handle)");
#endif
return currentPos; // Let Windows handle natural cursor movement
}
// Find the right-most monitor in the horizontal stack (same row)
HMONITOR rightMonitor = nullptr;
@@ -712,6 +790,15 @@ public:
Logger::info(L"CursorWrap DEBUG: ======= HORIZONTAL WRAP: RIGHT EDGE DETECTED =======");
#endif
// Check if there's an adjacent monitor to the right in coordinate space
if (HasAdjacentMonitorInCoordinateSpace(currentMonitorInfo.rcMonitor, 1))
{
#ifdef _DEBUG
Logger::info(L"CursorWrap DEBUG: SKIPPING WRAP - Adjacent monitor exists to the right (Windows will handle)");
#endif
return currentPos; // Let Windows handle natural cursor movement
}
// Find the left-most monitor in the horizontal stack (same row)
HMONITOR leftMonitor = nullptr;
@@ -981,45 +1068,104 @@ void MonitorTopology::Initialize(const std::vector<MonitorInfo>& monitors)
}
else
{
// For more than 2 monitors, use the general algorithm
RECT totalBounds = monitors[0].rect;
for (const auto& monitor : monitors)
{
totalBounds.left = min(totalBounds.left, monitor.rect.left);
totalBounds.top = min(totalBounds.top, monitor.rect.top);
totalBounds.right = max(totalBounds.right, monitor.rect.right);
totalBounds.bottom = max(totalBounds.bottom, monitor.rect.bottom);
// For more than 2 monitors, use edge-based alignment algorithm
// This ensures monitors with aligned edges (e.g., top edges at same Y) are grouped in same row
// Helper lambda to check if two ranges overlap or are adjacent (with tolerance)
auto rangesOverlapOrTouch = [](int start1, int end1, int start2, int end2, int tolerance = 50) -> bool {
// Check if ranges overlap or are within tolerance distance
return (start1 <= end2 + tolerance) && (start2 <= end1 + tolerance);
};
// Sort monitors by horizontal position (left edge) for column assignment
std::vector<const MonitorInfo*> monitorsByX;
for (const auto& monitor : monitors) {
monitorsByX.push_back(&monitor);
}
std::sort(monitorsByX.begin(), monitorsByX.end(), [](const MonitorInfo* a, const MonitorInfo* b) {
return a->rect.left < b->rect.left;
});
// Sort monitors by vertical position (top edge) for row assignment
std::vector<const MonitorInfo*> monitorsByY;
for (const auto& monitor : monitors) {
monitorsByY.push_back(&monitor);
}
std::sort(monitorsByY.begin(), monitorsByY.end(), [](const MonitorInfo* a, const MonitorInfo* b) {
return a->rect.top < b->rect.top;
});
// Assign rows based on vertical overlap - monitors that overlap vertically should be in same row
std::map<const MonitorInfo*, int> monitorToRow;
int currentRow = 0;
for (size_t i = 0; i < monitorsByY.size(); i++) {
const auto* monitor = monitorsByY[i];
// Check if this monitor overlaps vertically with any monitor already assigned to current row
bool foundOverlap = false;
for (size_t j = 0; j < i; j++) {
const auto* other = monitorsByY[j];
if (monitorToRow[other] == currentRow) {
// Check vertical overlap
if (rangesOverlapOrTouch(monitor->rect.top, monitor->rect.bottom,
other->rect.top, other->rect.bottom)) {
monitorToRow[monitor] = currentRow;
foundOverlap = true;
break;
}
}
}
if (!foundOverlap) {
// Start new row if no overlap found and we have room
if (currentRow < 2 && i < monitorsByY.size() - 1) {
currentRow++;
}
monitorToRow[monitor] = currentRow;
}
}
int totalWidth = totalBounds.right - totalBounds.left;
int totalHeight = totalBounds.bottom - totalBounds.top;
int gridWidth = max(1, totalWidth / 3);
int gridHeight = max(1, totalHeight / 3);
// Assign columns based on horizontal position (left-to-right order)
// Monitors are already sorted by X coordinate (left edge)
std::map<const MonitorInfo*, int> monitorToCol;
// Place monitors in the 3x3 grid based on their center points
// For horizontal arrangement, distribute monitors evenly across columns
if (monitorsByX.size() == 1) {
// Single monitor - place in middle column
monitorToCol[monitorsByX[0]] = 1;
}
else if (monitorsByX.size() == 2) {
// Two monitors - place at opposite ends for wrapping
monitorToCol[monitorsByX[0]] = 0; // Leftmost monitor
monitorToCol[monitorsByX[1]] = 2; // Rightmost monitor
}
else {
// Three or more monitors - distribute across grid
for (size_t i = 0; i < monitorsByX.size() && i < 3; i++) {
monitorToCol[monitorsByX[i]] = static_cast<int>(i);
}
// If more than 3 monitors, place extras in rightmost column
for (size_t i = 3; i < monitorsByX.size(); i++) {
monitorToCol[monitorsByX[i]] = 2;
}
}
// Place monitors in grid using the computed row/column assignments
for (const auto& monitor : monitors)
{
HMONITOR hMonitor = MonitorFromRect(&monitor.rect, MONITOR_DEFAULTTONEAREST);
// Calculate center point of monitor
int centerX = (monitor.rect.left + monitor.rect.right) / 2;
int centerY = (monitor.rect.top + monitor.rect.bottom) / 2;
// Map to grid position
int col = (centerX - totalBounds.left) / gridWidth;
int row = (centerY - totalBounds.top) / gridHeight;
// Ensure we stay within bounds
col = max(0, min(2, col));
row = max(0, min(2, row));
int row = monitorToRow[&monitor];
int col = monitorToCol[&monitor];
grid[row][col] = hMonitor;
monitorToPosition[hMonitor] = {row, col, true};
positionToMonitor[{row, col}] = hMonitor;
#ifdef _DEBUG
Logger::info(L"CursorWrap DEBUG: Monitor {} placed at grid[{}][{}], center=({}, {})",
monitor.monitorId, row, col, centerX, centerY);
Logger::info(L"CursorWrap DEBUG: Monitor {} placed at grid[{}][{}] (left={}, top={}, right={}, bottom={})",
monitor.monitorId, row, col,
monitor.rect.left, monitor.rect.top, monitor.rect.right, monitor.rect.bottom);
#endif
}
}