From 4704e3edb8eb4f22a6a6d96d19ec647ea4f47e11 Mon Sep 17 00:00:00 2001 From: Mike Hall Date: Thu, 8 Jan 2026 09:48:10 +0000 Subject: [PATCH] [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. --- ## PR Checklist - [ ] Closes: #xxx - [ ] **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 ## 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 Co-authored-by: Leilei Zhang Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/actions/spell-check/expect.txt | 6 +- .../CursorWrapTests/CURSOR_WRAP_TESTS.md | 66 ++ .../CursorWrapTests/Capture-MonitorLayout.ps1 | 266 ++++++ .../CursorWrapTests/analyze_test_results.py | 430 +++++++++ .../CursorWrapTests/monitor_layout_tests.py | 892 ++++++++++++++++++ src/modules/MouseUtils/CursorWrap/dllmain.cpp | 202 +++- 6 files changed, 1833 insertions(+), 29 deletions(-) create mode 100644 src/modules/MouseUtils/CursorWrap/CursorWrapTests/CURSOR_WRAP_TESTS.md create mode 100644 src/modules/MouseUtils/CursorWrap/CursorWrapTests/Capture-MonitorLayout.ps1 create mode 100644 src/modules/MouseUtils/CursorWrap/CursorWrapTests/analyze_test_results.py create mode 100644 src/modules/MouseUtils/CursorWrap/CursorWrapTests/monitor_layout_tests.py diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt index d9e5f7e254..e384c89d13 100644 --- a/.github/actions/spell-check/expect.txt +++ b/.github/actions/spell-check/expect.txt @@ -215,6 +215,7 @@ cim CImage cla CLASSDC +classmethod CLASSNOTAVAILABLE CLEARTYPE clickable @@ -1602,7 +1603,7 @@ sharpfuzz SHCNE SHCNF SHCONTF -Shcore +shcore shellapi SHELLDETAILS SHELLDLL @@ -1701,6 +1702,7 @@ srw srwlock sse ssf +sszzz STACKFRAME stackoverflow STARTF @@ -1711,6 +1713,7 @@ STARTUPINFOW startupscreen STATFLAG STATICEDGE +staticmethod STATSTG stdafx STDAPI @@ -1877,6 +1880,7 @@ uild uitests UITo ULONGLONG +Ultrawide ums UMax UMin diff --git a/src/modules/MouseUtils/CursorWrap/CursorWrapTests/CURSOR_WRAP_TESTS.md b/src/modules/MouseUtils/CursorWrap/CursorWrapTests/CURSOR_WRAP_TESTS.md new file mode 100644 index 0000000000..3ca8229b9f --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/CursorWrapTests/CURSOR_WRAP_TESTS.md @@ -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 ` 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! +``` + diff --git a/src/modules/MouseUtils/CursorWrap/CursorWrapTests/Capture-MonitorLayout.ps1 b/src/modules/MouseUtils/CursorWrap/CursorWrapTests/Capture-MonitorLayout.ps1 new file mode 100644 index 0000000000..6d6e3da528 --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/CursorWrapTests/Capture-MonitorLayout.ps1 @@ -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 +} diff --git a/src/modules/MouseUtils/CursorWrap/CursorWrapTests/analyze_test_results.py b/src/modules/MouseUtils/CursorWrap/CursorWrapTests/analyze_test_results.py new file mode 100644 index 0000000000..f045119dee --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/CursorWrapTests/analyze_test_results.py @@ -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> 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() diff --git a/src/modules/MouseUtils/CursorWrap/CursorWrapTests/monitor_layout_tests.py b/src/modules/MouseUtils/CursorWrap/CursorWrapTests/monitor_layout_tests.py new file mode 100644 index 0000000000..0bb110faec --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/CursorWrapTests/monitor_layout_tests.py @@ -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() diff --git a/src/modules/MouseUtils/CursorWrap/dllmain.cpp b/src/modules/MouseUtils/CursorWrap/dllmain.cpp index 09342d3a88..ee026b7b12 100644 --- a/src/modules/MouseUtils/CursorWrap/dllmain.cpp +++ b/src/modules/MouseUtils/CursorWrap/dllmain.cpp @@ -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& 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 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 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 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 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(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 } }