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 } }