diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt index b8fd30e638..c26d17f63c 100644 --- a/.github/actions/spell-check/expect.txt +++ b/.github/actions/spell-check/expect.txt @@ -67,6 +67,7 @@ ARPINSTALLLOCATION ARPPRODUCTICON ARRAYSIZE ARROWKEYS +arrowshape asf AShortcut ASingle @@ -537,6 +538,8 @@ HIBYTE hicon HIDEWINDOW Hif +highlightbackground +highlightthickness HIMAGELIST himl hinst @@ -627,6 +630,7 @@ inetcpl Infobar INFOEXAMPLE Infotip +initialfile INITDIALOG INITGUID INITTOLOGFONTSTRUCT @@ -809,6 +813,7 @@ Metadatas metafile metapackage mfc +mfalse Mgmt Microwaved midl @@ -867,6 +872,7 @@ msrc msstore msvcp MTND +mtrue MULTIPLEUSE multizone muxc @@ -1018,6 +1024,8 @@ OWNDC OWNERDRAWFIXED Packagemanager PACL +padx +pady PAINTSTRUCT PALETTEWINDOW PARENTNOTIFY @@ -1449,6 +1457,7 @@ STYLECHANGING subkeys sublang SUBMODULEUPDATE +sug Superbar sut svchost @@ -2033,6 +2042,7 @@ metadatamatters middleclickaction MIIM mikeclayton +mikehall minimizebox modelcontextprotocol mousehighlighter @@ -2153,6 +2163,7 @@ taskbar TESTONLY TEXTBOXNEWLINE textextractor +textvariable tgamma THEMECHANGED thickframe diff --git a/.github/actions/spell-check/patterns.txt b/.github/actions/spell-check/patterns.txt index 34b2ad9fe9..5f546b5ce9 100644 --- a/.github/actions/spell-check/patterns.txt +++ b/.github/actions/spell-check/patterns.txt @@ -289,3 +289,6 @@ St&yle # Microsoft Store URLs and product IDs ms-windows-store://\S+ + +# ANSI color codes +(?:\\(?:u00|x)1[Bb]|\\03[1-7]|\x1b|\\u\{1[Bb]\})\[\d+(?:;\d+)*m diff --git a/src/modules/MouseUtils/CursorWrap/CursorWrap.vcxproj b/src/modules/MouseUtils/CursorWrap/CursorWrap.vcxproj index 70ef9f352f..a47a241ce4 100644 --- a/src/modules/MouseUtils/CursorWrap/CursorWrap.vcxproj +++ b/src/modules/MouseUtils/CursorWrap/CursorWrap.vcxproj @@ -13,13 +13,11 @@ DynamicLibrary true - Unicode DynamicLibrary false - true Unicode @@ -112,12 +110,8 @@ - - - - + - @@ -130,4 +124,4 @@ - + \ No newline at end of file diff --git a/src/modules/MouseUtils/CursorWrap/CursorWrapCore.cpp b/src/modules/MouseUtils/CursorWrap/CursorWrapCore.cpp index c1d4a9b36b..e462045aa4 100644 --- a/src/modules/MouseUtils/CursorWrap/CursorWrapCore.cpp +++ b/src/modules/MouseUtils/CursorWrap/CursorWrapCore.cpp @@ -163,6 +163,39 @@ void CursorWrapCore::UpdateMonitorInfo() Logger::info(L"======= UPDATE MONITOR INFO END ======="); } +void CursorWrapCore::ResetWrapState() +{ + m_hasPreviousPosition = false; + m_hasLastWrapDestination = false; + m_previousPosition = { LONG_MIN, LONG_MIN }; + m_lastWrapDestination = { LONG_MIN, LONG_MIN }; +} + +CursorDirection CursorWrapCore::CalculateDirection(const POINT& currentPos) const +{ + CursorDirection dir = { 0, 0 }; + if (m_hasPreviousPosition) + { + dir.dx = currentPos.x - m_previousPosition.x; + dir.dy = currentPos.y - m_previousPosition.y; + } + return dir; +} + +bool CursorWrapCore::IsWithinWrapThreshold(const POINT& currentPos) const +{ + if (!m_hasLastWrapDestination) + { + return false; + } + + int dx = currentPos.x - m_lastWrapDestination.x; + int dy = currentPos.y - m_lastWrapDestination.y; + int distanceSquared = dx * dx + dy * dy; + + return distanceSquared <= (WRAP_DISTANCE_THRESHOLD * WRAP_DISTANCE_THRESHOLD); +} + POINT CursorWrapCore::HandleMouseMove(const POINT& currentPos, bool disableWrapDuringDrag, int wrapMode, bool disableOnSingleMonitor) { // Check if wrapping should be disabled on single monitor @@ -176,6 +209,8 @@ POINT CursorWrapCore::HandleMouseMove(const POINT& currentPos, bool disableWrapD loggedOnce = true; } #endif + m_previousPosition = currentPos; + m_hasPreviousPosition = true; return currentPos; } @@ -185,9 +220,31 @@ POINT CursorWrapCore::HandleMouseMove(const POINT& currentPos, bool disableWrapD #ifdef _DEBUG OutputDebugStringW(L"[CursorWrap] [DRAG] Left mouse button down - skipping wrap\n"); #endif + m_previousPosition = currentPos; + m_hasPreviousPosition = true; return currentPos; } + // Check distance threshold to prevent rapid oscillation + if (IsWithinWrapThreshold(currentPos)) + { +#ifdef _DEBUG + OutputDebugStringW(L"[CursorWrap] [THRESHOLD] Cursor within wrap threshold - skipping wrap\n"); +#endif + m_previousPosition = currentPos; + m_hasPreviousPosition = true; + return currentPos; + } + + // Clear wrap destination threshold once cursor moves away + if (m_hasLastWrapDestination && !IsWithinWrapThreshold(currentPos)) + { + m_hasLastWrapDestination = false; + } + + // Calculate cursor movement direction + CursorDirection direction = CalculateDirection(currentPos); + // Convert int wrapMode to WrapMode enum WrapMode mode = static_cast(wrapMode); @@ -195,6 +252,7 @@ POINT CursorWrapCore::HandleMouseMove(const POINT& currentPos, bool disableWrapD { std::wostringstream oss; oss << L"[CursorWrap] [MOVE] Cursor at (" << currentPos.x << L", " << currentPos.y << L")"; + oss << L" direction=(" << direction.dx << L", " << direction.dy << L")"; // Get current monitor and identify which one HMONITOR currentMonitor = MonitorFromPoint(currentPos, MONITOR_DEFAULTTONEAREST); @@ -229,9 +287,9 @@ POINT CursorWrapCore::HandleMouseMove(const POINT& currentPos, bool disableWrapD // Get current monitor HMONITOR currentMonitor = MonitorFromPoint(currentPos, MONITOR_DEFAULTTONEAREST); - // Check if cursor is on an outer edge (filtered by wrap mode) + // Check if cursor is on an outer edge (filtered by wrap mode and direction) EdgeType edgeType; - if (!m_topology.IsOnOuterEdge(currentMonitor, currentPos, edgeType, mode)) + if (!m_topology.IsOnOuterEdge(currentMonitor, currentPos, edgeType, mode, &direction)) { #ifdef _DEBUG static bool lastWasNotOuter = false; @@ -241,6 +299,8 @@ POINT CursorWrapCore::HandleMouseMove(const POINT& currentPos, bool disableWrapD lastWasNotOuter = true; } #endif + m_previousPosition = currentPos; + m_hasPreviousPosition = true; return currentPos; // Not on an outer edge } @@ -278,5 +338,16 @@ POINT CursorWrapCore::HandleMouseMove(const POINT& currentPos, bool disableWrapD } #endif + // Update tracking state + m_previousPosition = currentPos; + m_hasPreviousPosition = true; + + // Store wrap destination for threshold checking + if (newPos.x != currentPos.x || newPos.y != currentPos.y) + { + m_lastWrapDestination = newPos; + m_hasLastWrapDestination = true; + } + return newPos; } diff --git a/src/modules/MouseUtils/CursorWrap/CursorWrapCore.h b/src/modules/MouseUtils/CursorWrap/CursorWrapCore.h index d8472efd08..4a471ec9f3 100644 --- a/src/modules/MouseUtils/CursorWrap/CursorWrapCore.h +++ b/src/modules/MouseUtils/CursorWrap/CursorWrapCore.h @@ -8,6 +8,24 @@ #include #include "MonitorTopology.h" +// Distance threshold to prevent rapid back-and-forth wrapping (in pixels) +constexpr int WRAP_DISTANCE_THRESHOLD = 50; + +// Cursor movement direction +struct CursorDirection +{ + int dx; // Horizontal movement (positive = right, negative = left) + int dy; // Vertical movement (positive = down, negative = up) + + bool IsMovingLeft() const { return dx < 0; } + bool IsMovingRight() const { return dx > 0; } + bool IsMovingUp() const { return dy < 0; } + bool IsMovingDown() const { return dy > 0; } + + // Returns true if horizontal movement is dominant + bool IsPrimarilyHorizontal() const { return abs(dx) >= abs(dy); } +}; + // Core cursor wrapping engine class CursorWrapCore { @@ -25,11 +43,28 @@ public: size_t GetMonitorCount() const { return m_monitors.size(); } const MonitorTopology& GetTopology() const { return m_topology; } + // Reset wrap state (call when disabling/re-enabling) + void ResetWrapState(); + private: #ifdef _DEBUG std::wstring GenerateTopologyJSON() const; #endif + // Calculate movement direction from previous position + CursorDirection CalculateDirection(const POINT& currentPos) const; + + // Check if cursor is within threshold distance of last wrap position + bool IsWithinWrapThreshold(const POINT& currentPos) const; + std::vector m_monitors; MonitorTopology m_topology; + + // Movement tracking for direction-based edge priority + POINT m_previousPosition = { LONG_MIN, LONG_MIN }; + bool m_hasPreviousPosition = false; + + // Wrap stability: prevent rapid oscillation + POINT m_lastWrapDestination = { LONG_MIN, LONG_MIN }; + bool m_hasLastWrapDestination = false; }; diff --git a/src/modules/MouseUtils/CursorWrap/CursorWrapTests/CursorLog/CursorLog.slnx b/src/modules/MouseUtils/CursorWrap/CursorWrapTests/CursorLog/CursorLog.slnx new file mode 100644 index 0000000000..b6c71a5b05 --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/CursorWrapTests/CursorLog/CursorLog.slnx @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/modules/MouseUtils/CursorWrap/CursorWrapTests/CursorLog/CursorLog/CursorLog.cpp b/src/modules/MouseUtils/CursorWrap/CursorWrapTests/CursorLog/CursorLog/CursorLog.cpp new file mode 100644 index 0000000000..a137879965 --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/CursorWrapTests/CursorLog/CursorLog/CursorLog.cpp @@ -0,0 +1,196 @@ +// CursorLog.cpp : Monitors mouse position and logs to file with monitor/DPI info +// + +#include +#include +#include +#include +#include +#include + +#pragma comment(lib, "Shcore.lib") + +// Global variables +std::ofstream g_outputFile; +HHOOK g_mouseHook = nullptr; +POINT g_lastPosition = { LONG_MIN, LONG_MIN }; +DWORD g_mainThreadId = 0; + +// Get monitor information for a given point +std::string GetMonitorInfo(POINT pt, UINT* dpiX, UINT* dpiY) +{ + HMONITOR hMonitor = MonitorFromPoint(pt, MONITOR_DEFAULTTONEAREST); + if (!hMonitor) + return "Unknown"; + + MONITORINFOEX monitorInfo = {}; + monitorInfo.cbSize = sizeof(MONITORINFOEX); + GetMonitorInfo(hMonitor, &monitorInfo); + + // Get DPI for this monitor + if (SUCCEEDED(GetDpiForMonitor(hMonitor, MDT_EFFECTIVE_DPI, dpiX, dpiY))) + { + // DPI retrieved successfully + } + else + { + *dpiX = 96; + *dpiY = 96; + } + + // Convert device name to string using proper wide-to-narrow conversion + std::wstring deviceName(monitorInfo.szDevice); + int sizeNeeded = WideCharToMultiByte(CP_UTF8, 0, deviceName.c_str(), static_cast(deviceName.length()), nullptr, 0, nullptr, nullptr); + std::string result(sizeNeeded, 0); + WideCharToMultiByte(CP_UTF8, 0, deviceName.c_str(), static_cast(deviceName.length()), &result[0], sizeNeeded, nullptr, nullptr); + return result; +} + +// Calculate scale factor from DPI +constexpr double GetScaleFactor(UINT dpi) +{ + return static_cast(dpi) / 96.0; +} + +// Low-level mouse hook callback +LRESULT CALLBACK LowLevelMouseProc(int nCode, WPARAM wParam, LPARAM lParam) +{ + if (nCode == HC_ACTION && wParam == WM_MOUSEMOVE) + { + MSLLHOOKSTRUCT* mouseStruct = reinterpret_cast(lParam); + POINT pt = mouseStruct->pt; + + // Only log if position changed + if (pt.x != g_lastPosition.x || pt.y != g_lastPosition.y) + { + g_lastPosition = pt; + + UINT dpiX = 96, dpiY = 96; + std::string monitorName = GetMonitorInfo(pt, &dpiX, &dpiY); + double scale = GetScaleFactor(dpiX); + + if (g_outputFile.is_open()) + { + g_outputFile << monitorName + << "," << pt.x + << "," << pt.y + << "," << dpiX + << "," << static_cast(scale * 100) << "%" + << "\n"; + g_outputFile.flush(); + } + } + } + + return CallNextHookEx(g_mouseHook, nCode, wParam, lParam); +} + +// Console control handler for clean shutdown +BOOL WINAPI ConsoleHandler(DWORD ctrlType) +{ + if (ctrlType == CTRL_C_EVENT || ctrlType == CTRL_CLOSE_EVENT) + { + std::cout << "\nShutting down..." << std::endl; + + if (g_mouseHook) + { + UnhookWindowsHookEx(g_mouseHook); + g_mouseHook = nullptr; + } + + if (g_outputFile.is_open()) + { + g_outputFile.close(); + } + + // Post quit message to the main thread to exit the message loop + PostThreadMessage(g_mainThreadId, WM_QUIT, 0, 0); + + return TRUE; + } + return FALSE; +} + +int main(int argc, char* argv[]) +{ + // Set DPI awareness FIRST, before any other Windows API calls + SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); + + // Store main thread ID for clean shutdown + g_mainThreadId = GetCurrentThreadId(); + + // Check command line arguments + if (argc != 2) + { + std::cerr << "Usage: CursorLog.exe " << std::endl; + return 1; + } + + std::filesystem::path outputPath(argv[1]); + std::filesystem::path parentPath = outputPath.parent_path(); + + // Validate the directory exists + if (!parentPath.empty() && !std::filesystem::exists(parentPath)) + { + std::cerr << "Error: The directory '" << parentPath.string() << "' does not exist." << std::endl; + return 1; + } + + // Check if file exists and prompt for overwrite + if (std::filesystem::exists(outputPath)) + { + std::cout << "File '" << outputPath.string() << "' already exists. Overwrite? (y/n): "; + char response; + std::cin >> response; + + if (response != 'y' && response != 'Y') + { + std::cout << "Operation cancelled." << std::endl; + return 0; + } + } + + // Open output file + g_outputFile.open(outputPath, std::ios::out | std::ios::trunc); + if (!g_outputFile.is_open()) + { + std::cerr << "Error: Unable to create or open file '" << outputPath.string() << "'." << std::endl; + return 1; + } + + std::cout << "Logging mouse position to: " << outputPath.string() << std::endl; + std::cout << "Press Ctrl+C to stop..." << std::endl; + + // Set up console control handler + SetConsoleCtrlHandler(ConsoleHandler, TRUE); + + // Install low-level mouse hook + g_mouseHook = SetWindowsHookEx(WH_MOUSE_LL, LowLevelMouseProc, nullptr, 0); + if (!g_mouseHook) + { + std::cerr << "Error: Failed to install mouse hook. Error code: " << GetLastError() << std::endl; + g_outputFile.close(); + return 1; + } + + // Message loop - required for low-level hooks + MSG msg; + while (GetMessage(&msg, nullptr, 0, 0)) + { + TranslateMessage(&msg); + DispatchMessage(&msg); + } + + // Cleanup + if (g_mouseHook) + { + UnhookWindowsHookEx(g_mouseHook); + } + + if (g_outputFile.is_open()) + { + g_outputFile.close(); + } + + return 0; +} diff --git a/src/modules/MouseUtils/CursorWrap/CursorWrapTests/CursorLog/CursorLog/CursorLog.vcxproj b/src/modules/MouseUtils/CursorWrap/CursorWrapTests/CursorLog/CursorLog/CursorLog.vcxproj new file mode 100644 index 0000000000..6a8afc811e --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/CursorWrapTests/CursorLog/CursorLog/CursorLog.vcxproj @@ -0,0 +1,135 @@ + + + + + Debug + Win32 + + + Release + Win32 + + + Debug + x64 + + + Release + x64 + + + + 18.0 + Win32Proj + {646f6684-9f11-42cd-8b35-b2954404f985} + CursorLog + 10.0 + + + + Application + true + v145 + Unicode + + + Application + false + v145 + true + Unicode + + + Application + true + v145 + Unicode + + + Application + false + v145 + true + Unicode + + + + + + + + + + + + + + + + + + + + + + Level3 + true + WIN32;_DEBUG;_CONSOLE;%(PreprocessorDefinitions) + true + stdcpp20NotUsing + + + Console + true + + + + + Level3 + true + true + true + WIN32;NDEBUG;_CONSOLE;%(PreprocessorDefinitions) + true + stdcpp20NotUsing + + + Console + true + + + + + Level3 + true + _DEBUG;_CONSOLE;%(PreprocessorDefinitions) + true + stdcpp20NotUsing + + + Console + true + + + + + Level3 + true + true + true + NDEBUG;_CONSOLE;%(PreprocessorDefinitions) + true + stdcpp20NotUsing + + + Console + true + + + + + + + + + diff --git a/src/modules/MouseUtils/CursorWrap/CursorWrapTests/CursorLog/CursorLog/CursorLog.vcxproj.filters b/src/modules/MouseUtils/CursorWrap/CursorWrapTests/CursorLog/CursorLog/CursorLog.vcxproj.filters new file mode 100644 index 0000000000..d9de80e268 --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/CursorWrapTests/CursorLog/CursorLog/CursorLog.vcxproj.filters @@ -0,0 +1,22 @@ + + + + + {4FC737F1-C7A5-4376-A066-2A32D752A2FF} + cpp;c;cc;cxx;c++;cppm;ixx;def;odl;idl;hpj;bat;asm;asmx + + + {93995380-89BD-4b04-88EB-625FBE52EBFB} + h;hh;hpp;hxx;h++;hm;inl;inc;ipp;xsd + + + {67DA6AB6-F800-4c08-8B7A-83BB121AAD01} + rc;ico;cur;bmp;dlg;rc2;rct;bin;rgs;gif;jpg;jpeg;jpe;resx;tiff;tif;png;wav;mfcribbon-ms + + + + + Source Files + + + \ No newline at end of file diff --git a/src/modules/MouseUtils/CursorWrap/CursorWrapTests/WrapSimulator/README.md b/src/modules/MouseUtils/CursorWrap/CursorWrapTests/WrapSimulator/README.md new file mode 100644 index 0000000000..c73c4bae4b --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/CursorWrapTests/WrapSimulator/README.md @@ -0,0 +1,287 @@ +# CursorWrap Simulator + +A Python visualization tool that displays monitor layouts and shows which edges will wrap to other monitors using the exact same logic as the PowerToys CursorWrap implementation. + +## Purpose + +This tool helps you: +- Visualize your multi-monitor setup +- Identify which screen edges are "outer edges" (edges that don't connect to another monitor) +- See where cursor wrapping will occur when you move the cursor to an outer edge +- **Find problem areas** where edges have NO wrap destination (shown in red) + +## Requirements + +- Python 3.6+ +- Tkinter (included with standard Python on Windows) + +## Usage + +### Command Line + +```bash +python wrap_simulator.py +``` + +### Without Arguments + +```bash +python wrap_simulator.py +``` + +This opens the application with no layout loaded. Use the "Load JSON" button to select a file. + +## JSON File Format + +The monitor layout JSON file should have this structure: + +```json +{ + "captured_at": "2026-02-16T08:50:34+00:00", + "computer_name": "MY-PC", + "user_name": "User", + "monitor_count": 3, + "monitors": [ + { + "left": 0, + "top": 0, + "right": 2560, + "bottom": 1440, + "width": 2560, + "height": 1440, + "dpi": 96, + "scaling_percent": 100.0, + "primary": true, + "device_name": "DISPLAY1" + } + ] +} +``` + +## Understanding the Visualization + +### Monitor Display +- **Gray rectangles**: Individual monitors +- **Orange border**: Primary monitor +- **Labels**: Show monitor index, device name, and resolution + +### Edge Bars (Outside Monitor Boundaries) + +Colored bars are drawn outside each **outer edge** (edges not adjacent to another monitor): + +| Color | Meaning | +|-------|---------| +| **Yellow** | Edge segment has a wrap destination ✓ | +| **Red with stripes** | NO wrap destination - Problem area! ⚠️ | + +The bar outline color indicates the edge type: +- Red = Left edge +- Teal = Right edge +- Blue = Top edge +- Green = Bottom edge + +### Interactive Features + +1. **Hover over edge segments**: + - See wrap destination info in the status bar + - Green arrow shows where the cursor would wrap to + - Green dashed rectangle highlights the destination + +2. **Click on edge segments**: + - Detailed information appears in the info panel + - Shows full problem analysis with reason codes + - Explains why wrapping does/doesn't occur + - Provides suggestions for fixing problems + + +3. **Wrap Mode Selection**: + - **Both**: Wrap in all directions (default) + - **Vertical Only**: Only top/bottom edges wrap + - **Horizontal Only**: Only left/right edges wrap + +4. **Export Analysis**: + - Click "Export Analysis" to save detailed diagnostic data + - Exports to JSON format for use in algorithm development + - Includes all problem segments with reason codes and suggestions + +5. **Edge Test Simulation** (NEW): + - Click "🧪 Test Edges" to start automated edge testing + - Visually animates cursor movement along ALL outer edges + - Shows wrap destination for each test point with colored lines: + - **Red circle**: Source position on outer edge + - **Green circle**: Wrap destination + - **Green dashed line**: Connection showing wrap path + - **Red X**: No wrap destination (problem area) + - Use "New Algorithm" checkbox to toggle between: + - **NEW**: Projection-based algorithm (eliminates dead zones) + - **OLD**: Direct overlap only (may have dead zones) + - Results summary shows per-edge coverage statistics + +## Problem Analysis + +When a segment has no wrap destination, the tool provides detailed analysis: + +### Problem Reason Codes + +| Code | Description | +|------|-------------| +| `WRAP_MODE_DISABLED` | Edge type disabled by current wrap mode setting | +| `NO_OPPOSITE_OUTER_EDGES` | No outer edges of the opposite type exist at all | +| `NO_OVERLAPPING_RANGE` | Opposite edges exist but don't cover this coordinate range | +| `SINGLE_MONITOR` | Only one monitor - nowhere to wrap to | + +### Diagnostic Details + +For `NO_OVERLAPPING_RANGE` problems, the tool shows: +- Distance to the nearest valid wrap destination +- List of available opposite edges sorted by distance +- Whether the gap is above/below or left/right of the segment +- Suggested fixes (extend monitors or adjust positions) + +## Sample Files + +Included sample layouts: + +- `sample_layout.json` - 3 monitors in a row with one offset +- `sample_staggered.json` - 3 monitors with staggered vertical positions (shows problem areas) +- `sample_with_gap.json` - 2 monitors with a gap between them + +## Exported Analysis Format + +The "Export Analysis" button generates a JSON file with this structure: + +```json +{ + "export_timestamp": "2026-02-16T08:50:34+00:00", + "wrap_mode": "BOTH", + "monitor_count": 3, + "monitors": [...], + "outer_edges": [...], + "problem_segments": [ + { + "source": { + "monitor_index": 0, + "monitor_name": "DISPLAY1", + "edge_type": "TOP", + "edge_position": 200, + "segment_range": {"start": 0, "end": 200}, + "segment_length_px": 200 + }, + "analysis": { + "reason_code": "NO_OVERLAPPING_RANGE", + "description": "No BOTTOM outer edge overlaps...", + "suggestion": "To fix: Either extend...", + "details": { + "gap_to_nearest": 200, + "available_opposite_edges": [...] + } + } + } + ], + "summary": { + "total_outer_edges": 8, + "total_problem_segments": 4, + "total_problem_pixels": 800, + "problems_by_reason": {"NO_OVERLAPPING_RANGE": 4}, + "has_problems": true + } +} +``` + +## How CursorWrap Logic Works + +### Original Algorithm (v1) + +1. **Outer Edge Detection**: An edge is "outer" if no other monitor's opposite edge is within 50 pixels AND has sufficient vertical/horizontal overlap + +2. **Wrap Destination**: When cursor reaches an outer edge: + - Find the opposite type outer edge (Left→Right, Top→Bottom, etc.) + - The destination must overlap with the cursor's perpendicular position + - Cursor warps to the furthest matching outer edge + +3. **Problem Areas**: If no opposite outer edge overlaps with a portion of an outer edge, that segment has no wrap destination - the cursor will simply stop at that edge. + +### Enhanced Algorithm (v2) - With Projection + +The enhanced algorithm eliminates dead zones by projecting cursor positions to valid destinations: + +1. **Direct Overlap**: If an opposite outer edge directly overlaps the cursor's perpendicular coordinate, use it (same as v1) + +2. **Nearest Edge Projection**: If no direct overlap exists: + - Find the nearest opposite outer edge by coordinate distance + - Calculate a projected position using offset-from-boundary approach + - The projection preserves relative position similar to how Windows handles monitor transitions + +3. **No Dead Zones**: Every point on every outer edge will have a valid wrap destination + +### Testing the Algorithm + +Use the included test script to validate both algorithms: + +```bash +python test_new_algorithm.py [layout_file.json] +``` + +This compares the old algorithm (with dead zones) against the new algorithm (with projection) and reports coverage. + +## Cursor Log Playback + +The simulator can play back recorded cursor movement logs to visualize how the cursor moves across monitors. + +### Loading a Cursor Log + +1. Click "Load Log" to select a cursor movement log file +2. Use the playback controls: + - **▶ Play / ⏸ Pause**: Start or pause playback + - **⏹ Stop**: Stop and reset to beginning + - **⏮ Reset**: Reset to beginning without stopping + - **Speed slider**: Adjust playback speed (10-500ms between frames) + +### Log File Format + +The cursor log file is CSV format with the following columns: + +``` +display_name,x,y,dpi,scaling% +``` + +Example: +```csv +\\.\DISPLAY1,1234,567,96,100% +\\.\DISPLAY2,2560,720,144,150% +\\.\DISPLAY3,-500,800,96,100% +``` + +- **display_name**: Windows display name (e.g., `\\.\DISPLAY1`) +- **x, y**: Screen coordinates +- **dpi**: Display DPI +- **scaling%**: Display scaling percentage (with or without % sign) + +Lines starting with `#` are treated as comments and ignored. + +### Playback Visualization + +- **Green cursor**: Normal movement within a monitor +- **Red cursor with burst effect**: Monitor transition detected +- **Blue trail**: Recent cursor movement path (fades over time) +- **Dashed red arrow**: Shows transition path between monitors + +The playback automatically slows down when a monitor transition is detected, making it easier to observe wrap behavior. + +### Sample Log File + +A sample cursor log file `sample_cursor_log.csv` is included that demonstrates cursor movement across a three-monitor setup. + +## Architecture + +The Python implementation mirrors the C++ code structure: + +- `MonitorTopology` class: Manages edge-based monitor layout +- `MonitorEdge` dataclass: Represents a single edge of a monitor +- `EdgeSegment` dataclass: A portion of an edge with wrap info +- `CursorLogEntry` dataclass: A single cursor movement log entry +- `WrapSimulatorApp`: Tkinter GUI application + +## Integration with PowerToys + +This tool is designed to validate and debug the CursorWrap feature. The JSON files can be generated by the debug build of CursorWrap or created manually for testing specific configurations. diff --git a/src/modules/MouseUtils/CursorWrap/CursorWrapTests/WrapSimulator/test_new_algorithm.py b/src/modules/MouseUtils/CursorWrap/CursorWrapTests/WrapSimulator/test_new_algorithm.py new file mode 100644 index 0000000000..ab894b1c44 --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/CursorWrapTests/WrapSimulator/test_new_algorithm.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +""" +Test script to validate the new projection-based wrapping algorithm. +""" + +import json +import sys +from wrap_simulator import MonitorTopology, MonitorInfo, WrapMode + +def test_layout(layout_file: str): + """Test a monitor layout with both old and new algorithms.""" + + # Load the layout + with open(layout_file, 'r') as f: + layout = json.load(f) + + # Create monitor info objects + monitors = [] + for i, m in enumerate(layout['monitors']): + monitors.append(MonitorInfo( + left=m['left'], top=m['top'], right=m['right'], bottom=m['bottom'], + width=m['width'], height=m['height'], dpi=m.get('dpi', 96), + scaling_percent=m.get('scaling_percent', 100), primary=m.get('primary', False), + device_name=m.get('device_name', f'DISPLAY{i+1}'), monitor_id=i + )) + + # Initialize topology + topology = MonitorTopology() + topology.initialize(monitors) + + print(f"Layout: {layout_file}") + print(f"Monitors: {len(monitors)}") + print(f"Outer edges: {len(topology.outer_edges)}") + + # Validate with OLD algorithm + print("\n--- OLD Algorithm (may have dead zones) ---") + old_problems = 0 + old_problem_details = [] + for edge in topology.outer_edges: + segments = topology.get_edge_segments_with_wrap_info(edge, WrapMode.BOTH) + for seg in segments: + if not seg.has_wrap_destination: + length = seg.end - seg.start + old_problems += length + detail = f"Mon {edge.monitor_index} {edge.edge_type.name} [{seg.start}-{seg.end}] ({length}px)" + old_problem_details.append(detail) + print(f" PROBLEM: {detail}") + print(f"Total problematic pixels: {old_problems}") + + # Validate with NEW algorithm + print("\n--- NEW Algorithm (with projection) ---") + result = topology.validate_all_edges_have_destinations(WrapMode.BOTH) + print(f"Total edge length: {result['total_edge_length']}px") + print(f"Covered: {result['covered_length']}px ({result['coverage_percent']:.1f}%)") + print(f"Uncovered: {result['uncovered_length']}px") + print(f"Fully covered: {result['is_fully_covered']}") + + if result['problem_areas']: + for prob in result['problem_areas']: + print(f" PROBLEM: {prob}") + + # Summary + print("\n--- COMPARISON ---") + print(f"Old algorithm dead zones: {old_problems}px") + print(f"New algorithm dead zones: {result['uncovered_length']}px") + if old_problems > 0 and result['uncovered_length'] == 0: + print("SUCCESS: New algorithm eliminates all dead zones!") + elif result['uncovered_length'] > 0: + print("WARNING: New algorithm still has dead zones") + else: + print("Both algorithms have no dead zones for this layout") + + return result['is_fully_covered'] + + +def main(): + layout_files = [ + 'mikehall_monitor_layout.json', + 'sample_layout.json', + 'sample_staggered.json', + ] + + # Allow specifying layout on command line + if len(sys.argv) > 1: + layout_files = sys.argv[1:] + + all_passed = True + for layout_file in layout_files: + try: + print(f"\n{'='*60}") + passed = test_layout(layout_file) + if not passed: + all_passed = False + except FileNotFoundError: + print(f"File not found: {layout_file}") + except Exception as e: + print(f"Error testing {layout_file}: {e}") + all_passed = False + + print(f"\n{'='*60}") + if all_passed: + print("ALL TESTS PASSED") + else: + print("SOME TESTS FAILED") + + return 0 if all_passed else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/modules/MouseUtils/CursorWrap/CursorWrapTests/WrapSimulator/wrap_simulator.py b/src/modules/MouseUtils/CursorWrap/CursorWrapTests/WrapSimulator/wrap_simulator.py new file mode 100644 index 0000000000..c681c000cf --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/CursorWrapTests/WrapSimulator/wrap_simulator.py @@ -0,0 +1,2375 @@ +#!/usr/bin/env python3 +""" +CursorWrap Simulator - Visualizes monitor wrap edges based on PowerToys CursorWrap logic. + +This tool loads a monitor layout JSON file and displays: +- Monitor rectangles scaled to fit the window +- Colored bars outside outer edges showing wrap destinations +- Problem areas where edges don't wrap to another location + +Usage: python wrap_simulator.py +""" + +import json +import sys +import tkinter as tk +from tkinter import ttk, filedialog, messagebox +from dataclasses import dataclass, field +from enum import IntEnum +from typing import List, Optional, Tuple, Dict +import argparse +import csv +import re + + +class EdgeType(IntEnum): + """Edge type enumeration matching C++ implementation.""" + LEFT = 0 + RIGHT = 1 + TOP = 2 + BOTTOM = 3 + + +class WrapMode(IntEnum): + """Wrap mode enumeration matching C++ implementation.""" + BOTH = 0 + VERTICAL_ONLY = 1 + HORIZONTAL_ONLY = 2 + + +@dataclass +class MonitorInfo: + """Monitor information structure matching C++ implementation.""" + left: int + top: int + right: int + bottom: int + width: int + height: int + dpi: int = 96 + scaling_percent: float = 100.0 + primary: bool = False + device_name: str = "" + monitor_id: int = 0 + + @property + def rect(self) -> Tuple[int, int, int, int]: + return (self.left, self.top, self.right, self.bottom) + + +@dataclass +class MonitorEdge: + """Represents a single edge of a monitor matching C++ implementation.""" + monitor_index: int + edge_type: EdgeType + start: int # For vertical edges: Y start; horizontal: X start + end: int # For vertical edges: Y end; horizontal: X end + position: int # For vertical edges: X coord; horizontal: Y coord + is_outer: bool = True + + def __hash__(self): + return hash((self.monitor_index, self.edge_type, self.start, self.end, self.position)) + + +class ProblemReason(IntEnum): + """Reasons why a wrap destination might not exist.""" + NONE = 0 # No problem - has wrap destination + WRAP_MODE_DISABLED = 1 # Edge type disabled by current wrap mode + NO_OPPOSITE_OUTER_EDGES = 2 # No outer edges of the opposite type exist + NO_OVERLAPPING_RANGE = 3 # Opposite edges exist but don't overlap this range + SINGLE_MONITOR = 4 # Only one monitor exists + + +@dataclass +class ProblemAnalysis: + """Detailed analysis of why a wrap problem exists.""" + reason: ProblemReason + description: str + suggestion: str + details: Dict = field(default_factory=dict) # Additional diagnostic info + + +@dataclass +class EdgeSegment: + """A segment of an edge with wrap destination info.""" + edge: MonitorEdge + start: int + end: int + wraps_to: Optional[MonitorEdge] = None # None means no wrap destination (problem area) + problem_analysis: Optional[ProblemAnalysis] = None # Analysis of why no wrap exists + + @property + def has_wrap_destination(self) -> bool: + return self.wraps_to is not None + + +@dataclass +class GapInfo: + """Information about gaps between monitors.""" + monitor1_index: int + monitor2_index: int + horizontal_gap: int + vertical_overlap: int + + +@dataclass +class CursorLogEntry: + """A single cursor movement log entry.""" + display_name: str # e.g., \\.\DISPLAY1 + x: int + y: int + dpi: int + scaling_percent: float + line_number: int = 0 + + @classmethod + def from_csv_line(cls, line: str, line_number: int = 0) -> Optional['CursorLogEntry']: + """Parse a CSV line into a CursorLogEntry.""" + try: + # Handle the format: \\.\DISPLAY1,1234,567,144,150% + parts = line.strip().split(',') + if len(parts) < 5: + return None + + display_name = parts[0].strip() + x = int(parts[1].strip()) + y = int(parts[2].strip()) + dpi = int(parts[3].strip()) + + # Parse scaling - remove % if present + scaling_str = parts[4].strip().rstrip('%') + scaling = float(scaling_str) + + return cls( + display_name=display_name, + x=x, + y=y, + dpi=dpi, + scaling_percent=scaling, + line_number=line_number + ) + except (ValueError, IndexError): + return None + + +class MonitorTopology: + """ + Monitor topology helper - manages edge-based monitor layout. + This is a Python port of the C++ MonitorTopology class. + """ + + ADJACENCY_TOLERANCE = 50 # Matching C++ tolerance exactly + + def __init__(self): + self.monitors: List[MonitorInfo] = [] + self.outer_edges: List[MonitorEdge] = [] + self.edge_map: Dict[Tuple[int, EdgeType], MonitorEdge] = {} + + def initialize(self, monitors: List[MonitorInfo]) -> None: + """Initialize topology from monitor list.""" + self.monitors = monitors + self.outer_edges.clear() + self.edge_map.clear() + + if not monitors: + return + + self._build_edge_map() + self._identify_outer_edges() + + def _build_edge_map(self) -> None: + """Create edges for each monitor using monitor index.""" + for idx, monitor in enumerate(self.monitors): + # Left edge + left_edge = MonitorEdge( + monitor_index=idx, + edge_type=EdgeType.LEFT, + position=monitor.left, + start=monitor.top, + end=monitor.bottom, + is_outer=True + ) + self.edge_map[(idx, EdgeType.LEFT)] = left_edge + + # Right edge (position is right - 1 to match C++) + right_edge = MonitorEdge( + monitor_index=idx, + edge_type=EdgeType.RIGHT, + position=monitor.right - 1, + start=monitor.top, + end=monitor.bottom, + is_outer=True + ) + self.edge_map[(idx, EdgeType.RIGHT)] = right_edge + + # Top edge + top_edge = MonitorEdge( + monitor_index=idx, + edge_type=EdgeType.TOP, + position=monitor.top, + start=monitor.left, + end=monitor.right, + is_outer=True + ) + self.edge_map[(idx, EdgeType.TOP)] = top_edge + + # Bottom edge (position is bottom - 1 to match C++) + bottom_edge = MonitorEdge( + monitor_index=idx, + edge_type=EdgeType.BOTTOM, + position=monitor.bottom - 1, + start=monitor.left, + end=monitor.right, + is_outer=True + ) + self.edge_map[(idx, EdgeType.BOTTOM)] = bottom_edge + + def _identify_outer_edges(self) -> None: + """Check each edge against all other edges to find adjacent ones.""" + # Make a copy of keys to iterate since we modify the dict values + for key1 in list(self.edge_map.keys()): + edge1 = self.edge_map[key1] + + for key2, edge2 in self.edge_map.items(): + if edge1.monitor_index == edge2.monitor_index: + continue # Same monitor + + if self._edges_are_adjacent(edge1, edge2): + edge1.is_outer = False + break + + if edge1.is_outer: + self.outer_edges.append(edge1) + + def _edges_are_adjacent(self, edge1: MonitorEdge, edge2: MonitorEdge) -> bool: + """ + Check if two edges are adjacent (within tolerance). + + This matches the C++ EdgesAreAdjacent implementation exactly: + - Edges must be opposite types (LEFT-RIGHT, RIGHT-LEFT, TOP-BOTTOM, BOTTOM-TOP) + - Positions must be within ADJACENCY_TOLERANCE pixels + - Perpendicular ranges must overlap by more than ADJACENCY_TOLERANCE + """ + # Edges must be opposite types to be adjacent + opposite_types = ( + (edge1.edge_type == EdgeType.LEFT and edge2.edge_type == EdgeType.RIGHT) or + (edge1.edge_type == EdgeType.RIGHT and edge2.edge_type == EdgeType.LEFT) or + (edge1.edge_type == EdgeType.TOP and edge2.edge_type == EdgeType.BOTTOM) or + (edge1.edge_type == EdgeType.BOTTOM and edge2.edge_type == EdgeType.TOP) + ) + + if not opposite_types: + return False + + # Check if positions are within tolerance + # For adjacent edges, positions should be close (e.g., right edge of mon1 at x=2559 + # should be adjacent to left edge of mon2 at x=2560) + if abs(edge1.position - edge2.position) > self.ADJACENCY_TOLERANCE: + return False + + # Check if perpendicular ranges overlap significantly + # (not just touching, but overlapping by more than tolerance) + overlap_start = max(edge1.start, edge2.start) + overlap_end = min(edge1.end, edge2.end) + + return overlap_end > overlap_start + self.ADJACENCY_TOLERANCE + + def find_opposite_outer_edge(self, from_edge: EdgeType, relative_position: int) -> Optional[MonitorEdge]: + """Find the opposite outer edge for wrapping (original method - for overlapping regions).""" + if from_edge == EdgeType.LEFT: + target_type = EdgeType.RIGHT + find_max = True + elif from_edge == EdgeType.RIGHT: + target_type = EdgeType.LEFT + find_max = False + elif from_edge == EdgeType.TOP: + target_type = EdgeType.BOTTOM + find_max = True + elif from_edge == EdgeType.BOTTOM: + target_type = EdgeType.TOP + find_max = False + else: + return None + + result = None + extreme_position = float('-inf') if find_max else float('inf') + + for edge in self.outer_edges: + if edge.edge_type != target_type: + continue + + # Check if this edge overlaps with the relative position + if relative_position >= edge.start and relative_position <= edge.end: + if (find_max and edge.position > extreme_position) or \ + (not find_max and edge.position < extreme_position): + extreme_position = edge.position + result = edge + + return result + + def find_nearest_opposite_edge(self, from_edge_type: EdgeType, cursor_coordinate: int, + source_edge: MonitorEdge) -> Tuple[Optional[MonitorEdge], bool, int]: + """ + Find the nearest opposite outer edge, including projection for non-overlapping regions. + This implements Windows-like behavior for cursor transitions. + + Returns: + Tuple of (target_edge, requires_projection, projected_coordinate) + - target_edge: The destination edge, or None if not found + - requires_projection: True if cursor position needs offset projection + - projected_coordinate: The calculated coordinate on the target edge + """ + # First, try to find an edge that directly overlaps + direct_match = self.find_opposite_outer_edge(from_edge_type, cursor_coordinate) + if direct_match is not None: + return (direct_match, False, cursor_coordinate) + + # No direct overlap - find the nearest opposite edge by coordinate distance + if from_edge_type == EdgeType.LEFT: + target_type = EdgeType.RIGHT + find_max = True + elif from_edge_type == EdgeType.RIGHT: + target_type = EdgeType.LEFT + find_max = False + elif from_edge_type == EdgeType.TOP: + target_type = EdgeType.BOTTOM + find_max = True + elif from_edge_type == EdgeType.BOTTOM: + target_type = EdgeType.TOP + find_max = False + else: + return (None, False, 0) + + best_distance = float('inf') + best_edge: Optional[MonitorEdge] = None + best_projected_coord = 0 + + for edge in self.outer_edges: + if edge.edge_type != target_type: + continue + + # Calculate distance from cursor coordinate to this edge's range + if cursor_coordinate < edge.start: + distance = edge.start - cursor_coordinate + projected_coord = edge.start # Clamp to edge start + elif cursor_coordinate > edge.end: + distance = cursor_coordinate - edge.end + projected_coord = edge.end # Clamp to edge end + else: + distance = 0 + projected_coord = cursor_coordinate + + # Choose the best edge: prefer closer edges, and among equals prefer extreme position + is_better = False + if distance < best_distance: + is_better = True + elif distance == best_distance and best_edge is not None: + if (find_max and edge.position > best_edge.position) or \ + (not find_max and edge.position < best_edge.position): + is_better = True + + if is_better: + best_distance = distance + best_edge = edge + best_projected_coord = projected_coord + + if best_edge is not None: + # Calculate projected position using offset-from-boundary approach + projected = self._calculate_projected_position(cursor_coordinate, source_edge, best_edge) + return (best_edge, True, projected) + + return (None, False, 0) + + def _calculate_projected_position(self, cursor_coordinate: int, source_edge: MonitorEdge, + target_edge: MonitorEdge) -> int: + """ + Calculate projected position for cursor in non-overlapping region. + Uses offset-from-boundary approach similar to Windows cursor transitions. + """ + # Find the shared boundary region between source and target edges + shared_start = max(source_edge.start, target_edge.start) + shared_end = min(source_edge.end, target_edge.end) + + if cursor_coordinate >= shared_start and cursor_coordinate <= shared_end: + # Cursor is in shared region - return as-is + return cursor_coordinate + + if cursor_coordinate < shared_start: + # Cursor is BEFORE the shared region (e.g., above shared area) + # Use offset from top of source non-shared region + offset_from_source_top = cursor_coordinate - source_edge.start + projected_coord = target_edge.start + offset_from_source_top + else: + # Cursor is AFTER the shared region (e.g., below shared area) + # Use offset from bottom of source non-shared region + offset_from_source_bottom = source_edge.end - cursor_coordinate + projected_coord = target_edge.end - offset_from_source_bottom + + # Clamp to target edge bounds + projected_coord = max(target_edge.start, min(projected_coord, target_edge.end)) + + return projected_coord + + def get_wrap_destination_with_projection(self, from_edge_type: EdgeType, cursor_coordinate: int, + source_edge: MonitorEdge) -> Tuple[Optional[MonitorEdge], int]: + """ + Get the wrap destination for a cursor position, including non-overlapping regions. + + Returns: + Tuple of (target_edge, target_coordinate) + """ + target_edge, requires_projection, projected_coord = self.find_nearest_opposite_edge( + from_edge_type, cursor_coordinate, source_edge) + + if target_edge is None: + return (None, cursor_coordinate) + + return (target_edge, projected_coord) + + def get_edge_segments_with_wrap_info(self, edge: MonitorEdge, wrap_mode: WrapMode) -> List[EdgeSegment]: + """ + Break an outer edge into segments based on wrap destinations. + Each segment either wraps to a specific destination or has no wrap (problem area). + + NOTE: This uses the ORIGINAL algorithm that requires direct overlap. + For the NEW algorithm with projection, use get_edge_segments_with_projection(). + """ + # Check if this edge type is allowed by wrap mode + is_horizontal_edge = edge.edge_type in (EdgeType.LEFT, EdgeType.RIGHT) + is_vertical_edge = edge.edge_type in (EdgeType.TOP, EdgeType.BOTTOM) + + if wrap_mode == WrapMode.VERTICAL_ONLY and is_horizontal_edge: + # Horizontal edges (left/right) are disabled in vertical-only mode + analysis = ProblemAnalysis( + reason=ProblemReason.WRAP_MODE_DISABLED, + description=f"Left/Right edges are disabled in Vertical Only wrap mode", + suggestion="Change wrap mode to 'Both' or 'Horizontal Only' to enable this edge", + details={"wrap_mode": wrap_mode.name, "edge_type": edge.edge_type.name} + ) + return [EdgeSegment(edge=edge, start=edge.start, end=edge.end, wraps_to=None, problem_analysis=analysis)] + + if wrap_mode == WrapMode.HORIZONTAL_ONLY and is_vertical_edge: + # Vertical edges (top/bottom) are disabled in horizontal-only mode + analysis = ProblemAnalysis( + reason=ProblemReason.WRAP_MODE_DISABLED, + description=f"Top/Bottom edges are disabled in Horizontal Only wrap mode", + suggestion="Change wrap mode to 'Both' or 'Vertical Only' to enable this edge", + details={"wrap_mode": wrap_mode.name, "edge_type": edge.edge_type.name} + ) + return [EdgeSegment(edge=edge, start=edge.start, end=edge.end, wraps_to=None, problem_analysis=analysis)] + + # Find all possible wrap destinations along this edge + # Sample points along the edge to find where wrap destinations change + segments: List[EdgeSegment] = [] + + current_start = edge.start + current_wrap = self.find_opposite_outer_edge(edge.edge_type, current_start) + + # Sample at 1-pixel intervals to find exact boundaries + for pos in range(edge.start + 1, edge.end + 1): + wrap_dest = self.find_opposite_outer_edge(edge.edge_type, pos) + + # Check if wrap destination changed + if self._wrap_dest_differs(current_wrap, wrap_dest): + # Close current segment + problem_analysis = None if current_wrap else self._analyze_wrap_problem(edge, current_start, pos) + segments.append(EdgeSegment( + edge=edge, + start=current_start, + end=pos, + wraps_to=current_wrap, + problem_analysis=problem_analysis + )) + current_start = pos + current_wrap = wrap_dest + + # Close final segment + problem_analysis = None if current_wrap else self._analyze_wrap_problem(edge, current_start, edge.end) + segments.append(EdgeSegment( + edge=edge, + start=current_start, + end=edge.end, + wraps_to=current_wrap, + problem_analysis=problem_analysis + )) + + return segments + + def get_edge_segments_with_projection(self, edge: MonitorEdge, wrap_mode: WrapMode) -> List[EdgeSegment]: + """ + Break an outer edge into segments based on wrap destinations using the NEW projection algorithm. + This algorithm eliminates dead zones by projecting cursor positions to the nearest valid destination. + + Every point on an outer edge will have a valid wrap destination (no problem areas). + """ + # Check if this edge type is allowed by wrap mode + is_horizontal_edge = edge.edge_type in (EdgeType.LEFT, EdgeType.RIGHT) + is_vertical_edge = edge.edge_type in (EdgeType.TOP, EdgeType.BOTTOM) + + if wrap_mode == WrapMode.VERTICAL_ONLY and is_horizontal_edge: + analysis = ProblemAnalysis( + reason=ProblemReason.WRAP_MODE_DISABLED, + description=f"Left/Right edges are disabled in Vertical Only wrap mode", + suggestion="Change wrap mode to 'Both' or 'Horizontal Only' to enable this edge", + details={"wrap_mode": wrap_mode.name, "edge_type": edge.edge_type.name} + ) + return [EdgeSegment(edge=edge, start=edge.start, end=edge.end, wraps_to=None, problem_analysis=analysis)] + + if wrap_mode == WrapMode.HORIZONTAL_ONLY and is_vertical_edge: + analysis = ProblemAnalysis( + reason=ProblemReason.WRAP_MODE_DISABLED, + description=f"Top/Bottom edges are disabled in Horizontal Only wrap mode", + suggestion="Change wrap mode to 'Both' or 'Vertical Only' to enable this edge", + details={"wrap_mode": wrap_mode.name, "edge_type": edge.edge_type.name} + ) + return [EdgeSegment(edge=edge, start=edge.start, end=edge.end, wraps_to=None, problem_analysis=analysis)] + + # With the new projection algorithm, find wrap destinations using find_nearest_opposite_edge + segments: List[EdgeSegment] = [] + + current_start = edge.start + current_wrap, _, _ = self.find_nearest_opposite_edge(edge.edge_type, current_start, edge) + + # Sample at 1-pixel intervals to find exact boundaries + for pos in range(edge.start + 1, edge.end + 1): + wrap_dest, _, _ = self.find_nearest_opposite_edge(edge.edge_type, pos, edge) + + # Check if wrap destination changed + if self._wrap_dest_differs(current_wrap, wrap_dest): + # Close current segment - with projection, there should always be a destination + segments.append(EdgeSegment( + edge=edge, + start=current_start, + end=pos, + wraps_to=current_wrap, + problem_analysis=None # No problems with new algorithm + )) + current_start = pos + current_wrap = wrap_dest + + # Close final segment + segments.append(EdgeSegment( + edge=edge, + start=current_start, + end=edge.end, + wraps_to=current_wrap, + problem_analysis=None + )) + + return segments + + def validate_all_edges_have_destinations(self, wrap_mode: WrapMode = WrapMode.BOTH) -> Dict: + """ + Validate that every point on every outer edge has a valid wrap destination. + This is used to verify the new projection-based algorithm eliminates dead zones. + + Returns: + Dict with validation results including any remaining problem areas + """ + results = { + "total_outer_edges": len(self.outer_edges), + "total_edge_length": 0, + "covered_length": 0, + "uncovered_length": 0, + "problem_areas": [], + "is_fully_covered": True + } + + for edge in self.outer_edges: + edge_length = edge.end - edge.start + results["total_edge_length"] += edge_length + + # Use the new projection algorithm + segments = self.get_edge_segments_with_projection(edge, wrap_mode) + + for segment in segments: + segment_length = segment.end - segment.start + if segment.has_wrap_destination: + results["covered_length"] += segment_length + else: + results["uncovered_length"] += segment_length + results["is_fully_covered"] = False + results["problem_areas"].append({ + "monitor_index": edge.monitor_index, + "edge_type": edge.edge_type.name, + "range": (segment.start, segment.end), + "length": segment_length, + "reason": segment.problem_analysis.reason.name if segment.problem_analysis else "UNKNOWN" + }) + + results["coverage_percent"] = (results["covered_length"] / results["total_edge_length"] * 100 + if results["total_edge_length"] > 0 else 0) + + return results + + def _analyze_wrap_problem(self, edge: MonitorEdge, range_start: int, range_end: int) -> ProblemAnalysis: + """ + Analyze why a wrap destination doesn't exist for a given edge range. + This provides detailed diagnostic information for debugging. + """ + # Determine the target edge type for wrapping + target_type = { + EdgeType.LEFT: EdgeType.RIGHT, + EdgeType.RIGHT: EdgeType.LEFT, + EdgeType.TOP: EdgeType.BOTTOM, + EdgeType.BOTTOM: EdgeType.TOP + }[edge.edge_type] + + # Check if only one monitor exists + if len(self.monitors) <= 1: + return ProblemAnalysis( + reason=ProblemReason.SINGLE_MONITOR, + description="Only one monitor exists - no wrap destinations possible", + suggestion="Connect additional monitors for cursor wrapping to work", + details={"monitor_count": len(self.monitors)} + ) + + # Find all opposite type outer edges + opposite_edges = [e for e in self.outer_edges if e.edge_type == target_type] + + if not opposite_edges: + # No opposite outer edges exist at all + all_edges_of_type = [(idx, et) for (idx, et), e in self.edge_map.items() if et == target_type] + inner_edges_info = [] + for idx, et in all_edges_of_type: + e = self.edge_map[(idx, et)] + if not e.is_outer: + # Find which monitor makes this edge inner + adjacent_monitor = self._find_adjacent_monitor(e) + inner_edges_info.append({ + "monitor_index": idx, + "edge_position": e.position, + "range": (e.start, e.end), + "adjacent_to_monitor": adjacent_monitor + }) + + return ProblemAnalysis( + reason=ProblemReason.NO_OPPOSITE_OUTER_EDGES, + description=f"No outer {target_type.name} edges exist in the monitor configuration", + suggestion=f"All {target_type.name} edges are adjacent to other monitors. " + f"The cursor wraps from {edge.edge_type.name} to the furthest {target_type.name} outer edge, " + f"but none exist.", + details={ + "source_edge_type": edge.edge_type.name, + "target_edge_type": target_type.name, + "inner_edges_count": len(inner_edges_info), + "inner_edges": inner_edges_info + } + ) + + # Opposite edges exist but don't overlap with this range + # Analyze why there's no overlap + range_mid = (range_start + range_end) // 2 + + # Find the closest opposite edges and their ranges + closest_edges = [] + for opp_edge in opposite_edges: + # Calculate how far this edge's range is from our range + if opp_edge.end < range_start: + # Edge is entirely below/left of our range + distance = range_start - opp_edge.end + position = "below" if edge.edge_type in (EdgeType.LEFT, EdgeType.RIGHT) else "left of" + elif opp_edge.start > range_end: + # Edge is entirely above/right of our range + distance = opp_edge.start - range_end + position = "above" if edge.edge_type in (EdgeType.LEFT, EdgeType.RIGHT) else "right of" + else: + # Overlaps - shouldn't happen if we're in this function + distance = 0 + position = "overlapping" + + closest_edges.append({ + "monitor_index": opp_edge.monitor_index, + "monitor_name": self.monitors[opp_edge.monitor_index].device_name if opp_edge.monitor_index < len(self.monitors) else "Unknown", + "edge_position": opp_edge.position, + "edge_range": (opp_edge.start, opp_edge.end), + "distance_to_segment": distance, + "relative_position": position + }) + + closest_edges.sort(key=lambda x: x["distance_to_segment"]) + + # Build a detailed description + if closest_edges: + nearest = closest_edges[0] + description = ( + f"No {target_type.name} outer edge overlaps with the coordinate range [{range_start}, {range_end}]. " + f"Nearest {target_type.name} edge is on Monitor {nearest['monitor_index']} ({nearest['monitor_name']}) " + f"with range [{nearest['edge_range'][0]}, {nearest['edge_range'][1]}], " + f"which is {nearest['distance_to_segment']}px {nearest['relative_position']} this segment." + ) + + # Calculate what range adjustment would fix this + if nearest["relative_position"] in ("below", "left of"): + suggestion = ( + f"To fix: Either extend the segment's monitor downward/leftward by {nearest['distance_to_segment']}px, " + f"or move Monitor {nearest['monitor_index']} upward/rightward. " + f"Alternatively, add another monitor that covers this range." + ) + elif nearest["relative_position"] in ("above", "right of"): + suggestion = ( + f"To fix: Either extend the segment's monitor upward/rightward by {nearest['distance_to_segment']}px, " + f"or move Monitor {nearest['monitor_index']} downward/leftward. " + f"Alternatively, add another monitor that covers this range." + ) + else: + suggestion = "Unexpected state - edge should overlap." + else: + description = f"No {target_type.name} outer edges found at all." + suggestion = "Check monitor configuration for missing outer edges." + + return ProblemAnalysis( + reason=ProblemReason.NO_OVERLAPPING_RANGE, + description=description, + suggestion=suggestion, + details={ + "source_edge_type": edge.edge_type.name, + "source_range": (range_start, range_end), + "target_edge_type": target_type.name, + "available_opposite_edges": closest_edges, + "gap_to_nearest": closest_edges[0]["distance_to_segment"] if closest_edges else None + } + ) + + def _find_adjacent_monitor(self, edge: MonitorEdge) -> Optional[int]: + """Find which monitor makes this edge an inner edge (not outer).""" + for (idx, et), other_edge in self.edge_map.items(): + if idx == edge.monitor_index: + continue + if self._edges_are_adjacent(edge, other_edge): + return idx + return None + + def _wrap_dest_differs(self, wrap1: Optional[MonitorEdge], wrap2: Optional[MonitorEdge]) -> bool: + """Check if two wrap destinations are different.""" + if wrap1 is None and wrap2 is None: + return False + if wrap1 is None or wrap2 is None: + return True + return (wrap1.monitor_index != wrap2.monitor_index or + wrap1.edge_type != wrap2.edge_type) + + def detect_monitor_gaps(self) -> List[GapInfo]: + """Detect gaps between monitors that should be snapped together.""" + gaps = [] + + for i in range(len(self.monitors)): + for j in range(i + 1, len(self.monitors)): + m1 = self.monitors[i] + m2 = self.monitors[j] + + # Check vertical overlap + v_overlap_start = max(m1.top, m2.top) + v_overlap_end = min(m1.bottom, m2.bottom) + v_overlap = v_overlap_end - v_overlap_start + + if v_overlap <= 0: + continue + + # Check horizontal gap + h_gap = min(abs(m1.right - m2.left), abs(m2.right - m1.left)) + + if h_gap > self.ADJACENCY_TOLERANCE: + gaps.append(GapInfo( + monitor1_index=i, + monitor2_index=j, + horizontal_gap=h_gap, + vertical_overlap=v_overlap + )) + + return gaps + + +class WrapSimulatorApp: + """Main application class for the CursorWrap Simulator.""" + + # Colors for different edge states + EDGE_COLORS = { + EdgeType.LEFT: "#FF6B6B", # Red + EdgeType.RIGHT: "#4ECDC4", # Teal + EdgeType.TOP: "#45B7D1", # Blue + EdgeType.BOTTOM: "#96CEB4", # Green + } + + WRAP_DESTINATION_COLOR = "#FFD93D" # Yellow for segments that wrap + NO_WRAP_COLOR = "#FF0000" # Bright red for problem areas (no wrap) + MONITOR_FILL = "#2C3E50" # Dark blue-gray + MONITOR_OUTLINE = "#ECF0F1" # Light gray + PRIMARY_HIGHLIGHT = "#F39C12" # Orange for primary monitor + + EDGE_BAR_WIDTH = 8 # Width of edge indicator bars + + def __init__(self, root: tk.Tk, json_path: Optional[str] = None): + self.root = root + self.root.title("CursorWrap Simulator") + self.root.minsize(1024, 768) + + self.topology = MonitorTopology() + self.monitors: List[MonitorInfo] = [] + self.wrap_mode = tk.IntVar(value=WrapMode.BOTH) + self.loaded_data: Optional[dict] = None # Store loaded JSON data + + # Scaling factors for display + self.scale = 1.0 + self.offset_x = 0 + self.offset_y = 0 + + # Track canvas item IDs for hover detection + self.monitor_items: Dict[int, int] = {} # canvas_id -> monitor_index + self.edge_segment_items: List[Tuple[int, EdgeSegment]] = [] # (canvas_id, segment) + self.highlight_items: List[int] = [] # IDs of temporary highlight elements + + # Cursor log playback state + self.cursor_log: List[CursorLogEntry] = [] + self.playback_index = 0 + self.playback_running = False + self.playback_speed = tk.IntVar(value=50) # milliseconds between frames + self.cursor_canvas_items: List[int] = [] # Canvas items for cursor visualization + self.cursor_trail: List[Tuple[int, int]] = [] # Trail of recent positions + self.max_trail_length = 50 + self.last_monitor_name: Optional[str] = None # Track monitor transitions + + # Edge test simulation state + self.edge_test_running = False + self.edge_test_points: List[Tuple[MonitorEdge, int, int, int]] = [] # (edge, coord, dest_x, dest_y) + self.edge_test_index = 0 + self.edge_test_canvas_items: List[int] = [] + self.edge_test_results: Dict = {} # Store test results + + self._setup_ui() + + if json_path: + self.load_json(json_path) + + def _setup_ui(self): + """Set up the user interface.""" + # Main container + main_frame = ttk.Frame(self.root) + main_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) + + # Top toolbar + toolbar = ttk.Frame(main_frame) + toolbar.pack(fill=tk.X, pady=(0, 5)) + + ttk.Button(toolbar, text="Load JSON", command=self._on_load_json).pack(side=tk.LEFT, padx=2) + ttk.Button(toolbar, text="Show Summary", command=self._show_summary).pack(side=tk.LEFT, padx=2) + ttk.Button(toolbar, text="Export Analysis", command=self._export_analysis).pack(side=tk.LEFT, padx=2) + + ttk.Separator(toolbar, orient=tk.VERTICAL).pack(side=tk.LEFT, fill=tk.Y, padx=10) + + # Cursor log playback controls + ttk.Button(toolbar, text="Load Log", command=self._on_load_cursor_log).pack(side=tk.LEFT, padx=2) + self.play_button = ttk.Button(toolbar, text="▶ Play", command=self._toggle_playback, state=tk.DISABLED) + self.play_button.pack(side=tk.LEFT, padx=2) + ttk.Button(toolbar, text="⏹ Stop", command=self._stop_playback).pack(side=tk.LEFT, padx=2) + ttk.Button(toolbar, text="⏮ Reset", command=self._reset_playback).pack(side=tk.LEFT, padx=2) + + ttk.Label(toolbar, text="Speed:").pack(side=tk.LEFT, padx=(5, 2)) + speed_scale = ttk.Scale(toolbar, from_=10, to=500, variable=self.playback_speed, + orient=tk.HORIZONTAL, length=80) + speed_scale.pack(side=tk.LEFT, padx=2) + ttk.Label(toolbar, text="(ms)").pack(side=tk.LEFT, padx=(0, 5)) + + ttk.Separator(toolbar, orient=tk.VERTICAL).pack(side=tk.LEFT, fill=tk.Y, padx=10) + + # Edge Test controls (NEW) + ttk.Button(toolbar, text="🧪 Test Edges", command=self._start_edge_test).pack(side=tk.LEFT, padx=2) + self.edge_test_stop_btn = ttk.Button(toolbar, text="⏹ Stop Test", command=self._stop_edge_test, state=tk.DISABLED) + self.edge_test_stop_btn.pack(side=tk.LEFT, padx=2) + + # Algorithm selection - triggers redraw when changed + self.use_new_algorithm = tk.BooleanVar(value=True) + ttk.Checkbutton(toolbar, text="New Algorithm", variable=self.use_new_algorithm, + command=self._on_algorithm_change).pack(side=tk.LEFT, padx=5) + + ttk.Separator(toolbar, orient=tk.VERTICAL).pack(side=tk.LEFT, fill=tk.Y, padx=10) + + ttk.Label(toolbar, text="Wrap Mode:").pack(side=tk.LEFT, padx=(0, 5)) + + modes = [("Both", WrapMode.BOTH), ("Vertical Only", WrapMode.VERTICAL_ONLY), + ("Horizontal Only", WrapMode.HORIZONTAL_ONLY)] + for text, mode in modes: + ttk.Radiobutton(toolbar, text=text, variable=self.wrap_mode, + value=mode, command=self._on_mode_change).pack(side=tk.LEFT, padx=2) + + # Content area with canvas and info panel + content_frame = ttk.PanedWindow(main_frame, orient=tk.HORIZONTAL) + content_frame.pack(fill=tk.BOTH, expand=True) + + # Canvas frame + canvas_frame = ttk.Frame(content_frame) + content_frame.add(canvas_frame, weight=3) + + self.canvas = tk.Canvas(canvas_frame, bg="#1a1a2e", highlightthickness=0) + self.canvas.pack(fill=tk.BOTH, expand=True) + + # Info panel + info_frame = ttk.Frame(content_frame, width=300) + content_frame.add(info_frame, weight=1) + + # Info panel content + ttk.Label(info_frame, text="Monitor Information", font=('TkDefaultFont', 10, 'bold')).pack(pady=5) + + self.info_text = tk.Text(info_frame, wrap=tk.WORD, font=('Consolas', 9), + bg='#2d2d2d', fg='#ffffff', height=20) + self.info_text.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) + + # Legend + legend_frame = ttk.LabelFrame(info_frame, text="Legend") + legend_frame.pack(fill=tk.X, padx=5, pady=5) + + self._create_legend_item(legend_frame, self.EDGE_COLORS[EdgeType.LEFT], "Left Edge (outer)") + self._create_legend_item(legend_frame, self.EDGE_COLORS[EdgeType.RIGHT], "Right Edge (outer)") + self._create_legend_item(legend_frame, self.EDGE_COLORS[EdgeType.TOP], "Top Edge (outer)") + self._create_legend_item(legend_frame, self.EDGE_COLORS[EdgeType.BOTTOM], "Bottom Edge (outer)") + self._create_legend_item(legend_frame, self.WRAP_DESTINATION_COLOR, "Has Wrap Destination") + self._create_legend_item(legend_frame, self.NO_WRAP_COLOR, "NO WRAP (Problem!)") + + # Playback status frame + playback_frame = ttk.LabelFrame(info_frame, text="Cursor Log Playback") + playback_frame.pack(fill=tk.X, padx=5, pady=5) + + self.playback_status_var = tk.StringVar(value="No log loaded") + ttk.Label(playback_frame, textvariable=self.playback_status_var, + font=('Consolas', 8)).pack(fill=tk.X, padx=5, pady=2) + + self.playback_position_var = tk.StringVar(value="Position: -") + ttk.Label(playback_frame, textvariable=self.playback_position_var, + font=('Consolas', 8)).pack(fill=tk.X, padx=5, pady=2) + + self.playback_monitor_var = tk.StringVar(value="Monitor: -") + ttk.Label(playback_frame, textvariable=self.playback_monitor_var, + font=('Consolas', 8)).pack(fill=tk.X, padx=5, pady=2) + + # Hover info label at bottom + self.hover_info_var = tk.StringVar(value="Hover over edges to see wrap information") + hover_label = ttk.Label(main_frame, textvariable=self.hover_info_var, + font=('Consolas', 9), anchor='w') + hover_label.pack(fill=tk.X, pady=(5, 0)) + + # Bind events + self.canvas.bind("", self._on_canvas_resize) + self.canvas.bind("", self._on_mouse_move) + self.canvas.bind("", self._on_click) + + def _create_legend_item(self, parent: ttk.Frame, color: str, text: str): + """Create a legend item with color swatch and label.""" + frame = ttk.Frame(parent) + frame.pack(fill=tk.X, padx=5, pady=2) + + swatch = tk.Canvas(frame, width=20, height=12, highlightthickness=1, + highlightbackground='gray') + swatch.create_rectangle(0, 0, 20, 12, fill=color, outline='') + swatch.pack(side=tk.LEFT, padx=(0, 5)) + + ttk.Label(frame, text=text, font=('TkDefaultFont', 8)).pack(side=tk.LEFT) + + def _on_load_json(self): + """Handle load JSON button click.""" + path = filedialog.askopenfilename( + title="Select Monitor Layout JSON", + filetypes=[("JSON files", "*.json"), ("All files", "*.*")] + ) + if path: + self.load_json(path) + + def _on_mode_change(self): + """Handle wrap mode change.""" + self._redraw() + + def _on_algorithm_change(self): + """Handle algorithm selection change - redraw to show updated wrap destinations.""" + self._redraw() + algo_name = "NEW (projection)" if self.use_new_algorithm.get() else "OLD (overlap only)" + self.hover_info_var.set(f"Algorithm changed to: {algo_name}") + + def _show_summary(self): + """Show the monitor summary info panel.""" + if self.loaded_data: + self._update_info_panel(self.loaded_data) + + def _export_analysis(self): + """Export detailed problem analysis to a JSON file.""" + if not self.monitors: + messagebox.showwarning("No Data", "Load a monitor layout first.") + return + + wrap_mode = WrapMode(self.wrap_mode.get()) + + # Build comprehensive analysis + analysis = { + "export_timestamp": str(self.loaded_data.get("captured_at", "unknown")), + "wrap_mode": wrap_mode.name, + "monitor_count": len(self.monitors), + "monitors": [], + "outer_edges": [], + "problem_segments": [], + "summary": {} + } + + # Monitor details + for i, mon in enumerate(self.monitors): + analysis["monitors"].append({ + "index": i, + "device_name": mon.device_name, + "bounds": {"left": mon.left, "top": mon.top, "right": mon.right, "bottom": mon.bottom}, + "size": {"width": mon.width, "height": mon.height}, + "primary": mon.primary + }) + + # Outer edges + for edge in self.topology.outer_edges: + analysis["outer_edges"].append({ + "monitor_index": edge.monitor_index, + "edge_type": edge.edge_type.name, + "position": edge.position, + "range": {"start": edge.start, "end": edge.end} + }) + + # Problem analysis + total_problem_pixels = 0 + problem_count_by_reason = {} + + for edge in self.topology.outer_edges: + segments = self.topology.get_edge_segments_with_wrap_info(edge, wrap_mode) + for seg in segments: + if not seg.has_wrap_destination: + total_problem_pixels += (seg.end - seg.start) + + problem_data = { + "source": { + "monitor_index": edge.monitor_index, + "monitor_name": self.monitors[edge.monitor_index].device_name, + "edge_type": edge.edge_type.name, + "edge_position": edge.position, + "segment_range": {"start": seg.start, "end": seg.end}, + "segment_length_px": seg.end - seg.start + } + } + + if seg.problem_analysis: + pa = seg.problem_analysis + reason_name = pa.reason.name + problem_count_by_reason[reason_name] = problem_count_by_reason.get(reason_name, 0) + 1 + + problem_data["analysis"] = { + "reason_code": reason_name, + "description": pa.description, + "suggestion": pa.suggestion, + "details": pa.details + } + + analysis["problem_segments"].append(problem_data) + + # Summary + analysis["summary"] = { + "total_outer_edges": len(self.topology.outer_edges), + "total_problem_segments": len(analysis["problem_segments"]), + "total_problem_pixels": total_problem_pixels, + "problems_by_reason": problem_count_by_reason, + "has_problems": len(analysis["problem_segments"]) > 0 + } + + # Save to file + path = filedialog.asksaveasfilename( + title="Export Problem Analysis", + defaultextension=".json", + filetypes=[("JSON files", "*.json"), ("All files", "*.*")], + initialfile="wrap_analysis.json" + ) + + if path: + try: + with open(path, 'w') as f: + json.dump(analysis, f, indent=2, default=str) + messagebox.showinfo("Export Complete", f"Analysis exported to:\n{path}") + except Exception as e: + messagebox.showerror("Export Error", f"Failed to save: {e}") + + # ========================================================================= + # Cursor Log Playback Methods + # ========================================================================= + + def _on_load_cursor_log(self): + """Load a cursor movement log file.""" + path = filedialog.askopenfilename( + title="Select Cursor Movement Log", + filetypes=[("CSV files", "*.csv"), ("Log files", "*.log"), ("Text files", "*.txt"), ("All files", "*.*")] + ) + if path: + self._load_cursor_log(path) + + def _load_cursor_log(self, path: str): + """Parse and load a cursor log file.""" + try: + self.cursor_log.clear() + self.playback_index = 0 + + with open(path, 'r') as f: + for line_num, line in enumerate(f, 1): + line = line.strip() + if not line or line.startswith('#'): + continue # Skip empty lines and comments + + entry = CursorLogEntry.from_csv_line(line, line_num) + if entry: + self.cursor_log.append(entry) + + if self.cursor_log: + self.play_button.config(state=tk.NORMAL) + self.playback_status_var.set(f"Loaded: {len(self.cursor_log)} positions") + self.playback_position_var.set(f"Position: 0 / {len(self.cursor_log)}") + self._clear_cursor_display() + + # Validate all positions if monitors are loaded + validation_summary = self._validate_cursor_log() + + if validation_summary: + messagebox.showwarning("Log Validation Issues", + f"Loaded {len(self.cursor_log)} positions from:\n{path}\n\n" + f"{validation_summary}") + else: + messagebox.showinfo("Log Loaded", + f"Loaded {len(self.cursor_log)} cursor positions from:\n{path}\n\n" + f"✓ All positions validated successfully.") + else: + messagebox.showwarning("No Data", "No valid cursor positions found in the file.") + + except Exception as e: + messagebox.showerror("Load Error", f"Failed to load log: {e}") + + def _validate_cursor_log(self) -> str: + """Validate all cursor log entries against monitor layout. Returns summary of issues.""" + if not self.monitors or not self.cursor_log: + return "" + + issues = { + 'outside_all': [], # Coordinates outside all monitors + 'wrong_monitor': [], # Claimed monitor doesn't match actual + } + + for i, entry in enumerate(self.cursor_log): + is_valid, actual_monitor, claimed_monitor, msg = self._validate_cursor_position(entry) + + if not is_valid: + if actual_monitor is None: + issues['outside_all'].append((i, entry)) + else: + issues['wrong_monitor'].append((i, entry, actual_monitor)) + + # Build summary + summary_parts = [] + + if issues['outside_all']: + summary_parts.append(f"⚠️ {len(issues['outside_all'])} positions OUTSIDE all monitors!") + # Show first few examples + for idx, entry in issues['outside_all'][:3]: + summary_parts.append(f" Line {entry.line_number}: ({entry.x}, {entry.y}) - {entry.display_name}") + if len(issues['outside_all']) > 3: + summary_parts.append(f" ... and {len(issues['outside_all']) - 3} more") + + if issues['wrong_monitor']: + summary_parts.append(f"⚠️ {len(issues['wrong_monitor'])} positions claim WRONG monitor!") + for idx, entry, actual in issues['wrong_monitor'][:3]: + summary_parts.append(f" Line {entry.line_number}: claims {entry.display_name}, " + f"actually in {actual.device_name}") + if len(issues['wrong_monitor']) > 3: + summary_parts.append(f" ... and {len(issues['wrong_monitor']) - 3} more") + + return "\n".join(summary_parts) + + def _toggle_playback(self): + """Toggle playback on/off.""" + if self.playback_running: + self._pause_playback() + else: + self._start_playback() + + def _start_playback(self): + """Start cursor log playback.""" + if not self.cursor_log: + return + + if not self.monitors: + messagebox.showwarning("No Monitors", "Load a monitor layout JSON first.") + return + + self.playback_running = True + self.play_button.config(text="⏸ Pause") + self._playback_step() + + def _pause_playback(self): + """Pause playback.""" + self.playback_running = False + self.play_button.config(text="▶ Play") + + def _stop_playback(self): + """Stop playback and reset.""" + self._pause_playback() + self._reset_playback() + + def _reset_playback(self): + """Reset playback to beginning.""" + self.playback_index = 0 + self.cursor_trail.clear() + self.last_monitor_name = None + self._clear_cursor_display() + if self.cursor_log: + self.playback_position_var.set(f"Position: 0 / {len(self.cursor_log)}") + self.playback_monitor_var.set("Monitor: -") + + def _playback_step(self): + """Execute one step of playback.""" + if not self.playback_running or self.playback_index >= len(self.cursor_log): + self._pause_playback() + if self.playback_index >= len(self.cursor_log): + self.playback_status_var.set("Playback complete") + return + + entry = self.cursor_log[self.playback_index] + + # Check for monitor transition + monitor_changed = (self.last_monitor_name is not None and + entry.display_name != self.last_monitor_name) + + # Update display + self._draw_cursor_position(entry, monitor_changed) + + # Update status + self.playback_position_var.set(f"Position: {self.playback_index + 1} / {len(self.cursor_log)}") + self.playback_monitor_var.set(f"Monitor: {entry.display_name} ({entry.x}, {entry.y})") + + if monitor_changed: + self.playback_status_var.set(f"⚡ TRANSITION: {self.last_monitor_name} → {entry.display_name}") + else: + self.playback_status_var.set(f"Playing... Line {entry.line_number}") + + self.last_monitor_name = entry.display_name + self.playback_index += 1 + + # Schedule next step + # Slow down on monitor transitions for visibility + delay = self.playback_speed.get() + if monitor_changed: + delay = max(delay * 3, 300) # At least 300ms pause on transitions + + self.root.after(delay, self._playback_step) + + def _clear_cursor_display(self): + """Clear cursor visualization from canvas.""" + for item_id in self.cursor_canvas_items: + self.canvas.delete(item_id) + self.cursor_canvas_items.clear() + + def _find_monitor_at_point(self, x: int, y: int) -> Optional[MonitorInfo]: + """Find which monitor contains the given global coordinates.""" + for monitor in self.monitors: + if (monitor.left <= x < monitor.right and + monitor.top <= y < monitor.bottom): + return monitor + return None + + def _validate_cursor_position(self, entry: CursorLogEntry) -> Tuple[bool, Optional[MonitorInfo], Optional[MonitorInfo], str]: + """ + Validate cursor position against monitor layout. + + Returns: + - is_valid: True if position is within a monitor + - actual_monitor: The monitor the coordinates are actually in (or None) + - claimed_monitor: The monitor the log claims (based on display_name) + - message: Explanation of any issues + """ + # Find the monitor the log claims + claimed_monitor = None + for monitor in self.monitors: + if monitor.device_name == entry.display_name.replace('\\\\.\\', ''): + claimed_monitor = monitor + break + + # Find the monitor the coordinates are actually in + actual_monitor = self._find_monitor_at_point(entry.x, entry.y) + + # Build validation message + if actual_monitor is None: + # Coordinates outside all monitors! + closest_monitor, distance, direction = self._find_closest_monitor(entry.x, entry.y) + message = (f"⚠️ INVALID: ({entry.x}, {entry.y}) is OUTSIDE all monitors!\n" + f" Claimed: {entry.display_name}\n" + f" Nearest: {closest_monitor.device_name if closest_monitor else 'None'} " + f"({distance}px {direction})") + return False, None, claimed_monitor, message + + elif claimed_monitor and actual_monitor.device_name != claimed_monitor.device_name: + # Coordinates are in a different monitor than claimed + message = (f"⚠️ MISMATCH: Log claims {entry.display_name} but\n" + f" ({entry.x}, {entry.y}) is actually in {actual_monitor.device_name}") + return False, actual_monitor, claimed_monitor, message + + else: + # Valid + return True, actual_monitor, claimed_monitor, "" + + def _find_closest_monitor(self, x: int, y: int) -> Tuple[Optional[MonitorInfo], int, str]: + """Find the closest monitor to the given point and the distance/direction.""" + if not self.monitors: + return None, 0, "" + + closest = None + min_distance = float('inf') + direction = "" + + for monitor in self.monitors: + # Calculate distance to monitor edges + dx = 0 + dy = 0 + dir_parts = [] + + if x < monitor.left: + dx = monitor.left - x + dir_parts.append("left") + elif x >= monitor.right: + dx = x - monitor.right + 1 + dir_parts.append("right") + + if y < monitor.top: + dy = monitor.top - y + dir_parts.append("above") + elif y >= monitor.bottom: + dy = y - monitor.bottom + 1 + dir_parts.append("below") + + distance = (dx * dx + dy * dy) ** 0.5 + + if distance < min_distance: + min_distance = distance + closest = monitor + direction = " and ".join(dir_parts) if dir_parts else "inside" + + return closest, int(min_distance), direction + + def _draw_cursor_position(self, entry: CursorLogEntry, is_transition: bool = False): + """Draw cursor position on canvas.""" + # Clear old cursor items (keep trail) + self._clear_cursor_display() + + # Validate cursor position + is_valid, actual_monitor, claimed_monitor, validation_msg = self._validate_cursor_position(entry) + + # Convert screen coordinates to canvas coordinates + canvas_x, canvas_y = self._screen_to_canvas(entry.x, entry.y) + + # Add to trail + self.cursor_trail.append((canvas_x, canvas_y)) + if len(self.cursor_trail) > self.max_trail_length: + self.cursor_trail.pop(0) + + # Draw trail (fading effect) + if len(self.cursor_trail) > 1: + for i in range(len(self.cursor_trail) - 1): + # Calculate opacity based on position in trail + alpha = int(255 * (i + 1) / len(self.cursor_trail)) + # Approximate with discrete colors + intensity = int(100 + 155 * (i + 1) / len(self.cursor_trail)) + + # Use orange trail for invalid positions + if not is_valid: + color = f"#ff{intensity:02x}00" # Orange gradient + else: + color = f"#{intensity:02x}{intensity:02x}ff" # Blue gradient + + x1, y1 = self.cursor_trail[i] + x2, y2 = self.cursor_trail[i + 1] + + line_id = self.canvas.create_line( + x1, y1, x2, y2, + fill=color, + width=2, + smooth=True + ) + self.cursor_canvas_items.append(line_id) + + # Determine cursor color based on validity and transition + if not is_valid: + cursor_color = '#FF8C00' # Orange for invalid/outside positions + # Draw warning indicator + for r in [25, 20, 15]: + warn_id = self.canvas.create_oval( + canvas_x - r, canvas_y - r, + canvas_x + r, canvas_y + r, + outline='#FF8C00', + width=2, + dash=(3, 3) + ) + self.cursor_canvas_items.append(warn_id) + + # Draw X mark for invalid + x_size = 12 + x1_id = self.canvas.create_line( + canvas_x - x_size, canvas_y - x_size, + canvas_x + x_size, canvas_y + x_size, + fill='#FF0000', width=3 + ) + x2_id = self.canvas.create_line( + canvas_x - x_size, canvas_y + x_size, + canvas_x + x_size, canvas_y - x_size, + fill='#FF0000', width=3 + ) + self.cursor_canvas_items.append(x1_id) + self.cursor_canvas_items.append(x2_id) + + # Update status with validation message + self.playback_status_var.set(validation_msg.split('\n')[0]) + + elif is_transition: + cursor_color = '#FF6B6B' # Red for transitions + # Highlight transitions with a larger, different colored marker + # Draw "burst" effect + for r in [20, 15, 10]: + burst_id = self.canvas.create_oval( + canvas_x - r, canvas_y - r, + canvas_x + r, canvas_y + r, + outline='#FF6B6B', + width=2 + ) + self.cursor_canvas_items.append(burst_id) + + # Draw transition line from previous position + if len(self.cursor_trail) >= 2: + prev_x, prev_y = self.cursor_trail[-2] + line_id = self.canvas.create_line( + prev_x, prev_y, canvas_x, canvas_y, + fill='#FF6B6B', + width=3, + dash=(5, 3), + arrow=tk.LAST, + arrowshape=(12, 15, 5) + ) + self.cursor_canvas_items.append(line_id) + else: + cursor_color = '#00FF00' # Green for normal movement + + # Draw current cursor position + cursor_size = 8 + + # Draw cursor dot + cursor_id = self.canvas.create_oval( + canvas_x - cursor_size, canvas_y - cursor_size, + canvas_x + cursor_size, canvas_y + cursor_size, + fill=cursor_color, + outline='white', + width=2 + ) + self.cursor_canvas_items.append(cursor_id) + + # Draw crosshair + crosshair_size = 15 + h_line = self.canvas.create_line( + canvas_x - crosshair_size, canvas_y, + canvas_x + crosshair_size, canvas_y, + fill='white', + width=1 + ) + v_line = self.canvas.create_line( + canvas_x, canvas_y - crosshair_size, + canvas_x, canvas_y + crosshair_size, + fill='white', + width=1 + ) + self.cursor_canvas_items.append(h_line) + self.cursor_canvas_items.append(v_line) + + # Draw coordinate label + label_text = f"({entry.x}, {entry.y})" + label_id = self.canvas.create_text( + canvas_x + 15, canvas_y - 15, + text=label_text, + fill='white', + font=('Consolas', 8), + anchor='sw' + ) + self.cursor_canvas_items.append(label_id) + + # If transition, also show the monitor names + if is_transition and self.last_monitor_name: + transition_label = f"{self.last_monitor_name} → {entry.display_name}" + trans_label_id = self.canvas.create_text( + canvas_x + 15, canvas_y + 15, + text=transition_label, + fill='#FF6B6B', + font=('Consolas', 9, 'bold'), + anchor='nw' + ) + self.cursor_canvas_items.append(trans_label_id) + + def _on_canvas_resize(self, event): + """Handle canvas resize.""" + self._redraw() + + def _on_mouse_move(self, event): + """Handle mouse movement for hover information.""" + # Clear previous highlights + for item_id in self.highlight_items: + self.canvas.delete(item_id) + self.highlight_items.clear() + + # Find what's under the cursor + items = self.canvas.find_overlapping(event.x - 2, event.y - 2, + event.x + 2, event.y + 2) + + info_lines = [] + + # Check edge segments + for item_id, segment in self.edge_segment_items: + if item_id in items: + edge = segment.edge + monitor = self.monitors[edge.monitor_index] + + info_lines.append(f"Edge Segment:") + info_lines.append(f" Monitor: {edge.monitor_index} ({monitor.device_name})") + info_lines.append(f" Edge: {edge.edge_type.name}") + info_lines.append(f" Range: {segment.start} to {segment.end}") + info_lines.append(f" Position: {edge.position}") + + if segment.wraps_to: + dest = segment.wraps_to + dest_monitor = self.monitors[dest.monitor_index] + info_lines.append(f" Wraps To:") + info_lines.append(f" Monitor: {dest.monitor_index} ({dest_monitor.device_name})") + info_lines.append(f" Edge: {dest.edge_type.name}") + info_lines.append(f" Position: {dest.position}") + + # Highlight the destination edge + self._highlight_destination(segment, dest) + else: + info_lines.append(f" ⚠️ NO WRAP DESTINATION!") + + info_lines.append("") + + # Check monitors + for item_id, mon_idx in self.monitor_items.items(): + if item_id in items: + monitor = self.monitors[mon_idx] + info_lines.append(f"Monitor {mon_idx}:") + info_lines.append(f" Device: {monitor.device_name}") + info_lines.append(f" Size: {monitor.width} x {monitor.height}") + info_lines.append(f" Position: ({monitor.left}, {monitor.top})") + info_lines.append(f" Bounds: ({monitor.left}, {monitor.top}) to ({monitor.right}, {monitor.bottom})") + info_lines.append(f" DPI: {monitor.dpi} ({monitor.scaling_percent}%)") + info_lines.append(f" Primary: {'Yes' if monitor.primary else 'No'}") + info_lines.append("") + + if info_lines: + self.hover_info_var.set(" | ".join(info_lines[:3])) # Show first 3 lines in status bar + else: + self.hover_info_var.set("Hover over edges or monitors for information") + + def _on_click(self, event): + """Handle mouse click to show detailed information.""" + items = self.canvas.find_overlapping(event.x - 2, event.y - 2, + event.x + 2, event.y + 2) + + # Check edge segments + for item_id, segment in self.edge_segment_items: + if item_id in items: + self._show_segment_detail(segment) + return + + def _show_segment_detail(self, segment: EdgeSegment): + """Show detailed information about a segment in a popup.""" + edge = segment.edge + monitor = self.monitors[edge.monitor_index] + + info = [] + info.append("=" * 45) + info.append("EDGE SEGMENT DETAIL") + info.append("=" * 45) + info.append("") + info.append(f"Source Monitor: {edge.monitor_index} ({monitor.device_name})") + info.append(f"Edge Type: {edge.edge_type.name}") + info.append(f"Edge Position: {edge.position}") + info.append(f"Segment Range: {segment.start} to {segment.end}") + info.append(f"Segment Length: {segment.end - segment.start} pixels") + info.append("") + + if segment.wraps_to: + dest = segment.wraps_to + dest_monitor = self.monitors[dest.monitor_index] + info.append("✓ WRAP DESTINATION EXISTS") + info.append("") + info.append(f"Destination Monitor: {dest.monitor_index} ({dest_monitor.device_name})") + info.append(f"Destination Edge: {dest.edge_type.name}") + info.append(f"Destination Position: {dest.position}") + info.append(f"Destination Range: [{dest.start}, {dest.end}]") + else: + info.append("⚠️ NO WRAP DESTINATION - PROBLEM AREA!") + info.append("") + wrap_mode = WrapMode(self.wrap_mode.get()) + info.append(f"Current Wrap Mode: {wrap_mode.name}") + info.append("") + + # Show detailed problem analysis if available + if segment.problem_analysis: + pa = segment.problem_analysis + info.append("-" * 45) + info.append("PROBLEM ANALYSIS") + info.append("-" * 45) + info.append("") + info.append(f"Reason Code: {pa.reason.name}") + info.append("") + info.append("Description:") + # Word wrap the description + desc_words = pa.description.split() + line = " " + for word in desc_words: + if len(line) + len(word) > 43: + info.append(line) + line = " " + line += word + " " + if line.strip(): + info.append(line) + + info.append("") + info.append("Suggested Fix:") + # Word wrap the suggestion + sug_words = pa.suggestion.split() + line = " " + for word in sug_words: + if len(line) + len(word) > 43: + info.append(line) + line = " " + line += word + " " + if line.strip(): + info.append(line) + + # Show additional diagnostic details + if pa.details: + info.append("") + info.append("-" * 45) + info.append("DIAGNOSTIC DETAILS") + info.append("-" * 45) + + if "gap_to_nearest" in pa.details and pa.details["gap_to_nearest"]: + info.append(f"Gap to nearest edge: {pa.details['gap_to_nearest']}px") + + if "source_range" in pa.details: + sr = pa.details["source_range"] + info.append(f"Source range: [{sr[0]}, {sr[1]}]") + + if "available_opposite_edges" in pa.details: + opp_edges = pa.details["available_opposite_edges"] + if opp_edges: + info.append("") + info.append("Opposite edges (sorted by distance):") + for i, oe in enumerate(opp_edges[:5]): # Show top 5 + info.append(f" {i+1}. Mon {oe['monitor_index']} ({oe['monitor_name']})") + info.append(f" Range: [{oe['edge_range'][0]}, {oe['edge_range'][1]}]") + info.append(f" Distance: {oe['distance_to_segment']}px {oe['relative_position']}") + else: + info.append("No opposite outer edges available!") + + if "inner_edges" in pa.details and pa.details["inner_edges"]: + info.append("") + info.append("Inner edges (not available for wrap):") + for ie in pa.details["inner_edges"]: + info.append(f" Mon {ie['monitor_index']}: " + f"adjacent to Mon {ie['adjacent_to_monitor']}") + else: + # Fallback to old logic if no analysis + is_horizontal_edge = edge.edge_type in (EdgeType.LEFT, EdgeType.RIGHT) + if wrap_mode == WrapMode.VERTICAL_ONLY and is_horizontal_edge: + info.append("Reason: Left/Right edges disabled in Vertical Only mode") + elif wrap_mode == WrapMode.HORIZONTAL_ONLY and not is_horizontal_edge: + info.append("Reason: Top/Bottom edges disabled in Horizontal Only mode") + else: + info.append("Reason: No opposite outer edge found for this range") + info.append("") + info.append("This means cursor movement at this edge won't wrap") + info.append("to another monitor - it will stop at the edge.") + + info.append("") + info.append("=" * 45) + + # Update info text widget + self.info_text.delete(1.0, tk.END) + self.info_text.insert(tk.END, "\n".join(info)) + + def _highlight_destination(self, source_segment: EdgeSegment, dest_edge: MonitorEdge): + """Draw highlight showing the wrap destination for a hovered segment.""" + bar_width = self.EDGE_BAR_WIDTH * 1.5 + + # Calculate the destination range that corresponds to this source segment + # Use relative positioning like the C++ code + source_edge = source_segment.edge + + # For the highlight, we show the full destination edge range that overlaps + # with the source segment range + dest_start = max(dest_edge.start, source_segment.start) + dest_end = min(dest_edge.end, source_segment.end) + + # Calculate canvas position for destination edge + if dest_edge.edge_type == EdgeType.LEFT: + x1, y1 = self._screen_to_canvas(dest_edge.position - bar_width / self.scale, dest_start) + x2, y2 = self._screen_to_canvas(dest_edge.position, dest_end) + x1 -= bar_width / 2 + elif dest_edge.edge_type == EdgeType.RIGHT: + x1, y1 = self._screen_to_canvas(dest_edge.position + 1, dest_start) + x2, y2 = self._screen_to_canvas(dest_edge.position + 1 + bar_width / self.scale, dest_end) + x2 += bar_width / 2 + elif dest_edge.edge_type == EdgeType.TOP: + x1, y1 = self._screen_to_canvas(dest_start, dest_edge.position - bar_width / self.scale) + x2, y2 = self._screen_to_canvas(dest_end, dest_edge.position) + y1 -= bar_width / 2 + elif dest_edge.edge_type == EdgeType.BOTTOM: + x1, y1 = self._screen_to_canvas(dest_start, dest_edge.position + 1) + x2, y2 = self._screen_to_canvas(dest_end, dest_edge.position + 1 + bar_width / self.scale) + y2 += bar_width / 2 + + # Draw highlight rectangle + highlight_id = self.canvas.create_rectangle( + x1 - 2, y1 - 2, x2 + 2, y2 + 2, + outline='#00FF00', # Bright green + width=3, + dash=(5, 3) + ) + self.highlight_items.append(highlight_id) + + # Draw arrow from source to destination + src_center = self._get_segment_center(source_segment) + dst_center = self._get_edge_center(dest_edge, dest_start, dest_end) + + arrow_id = self.canvas.create_line( + src_center[0], src_center[1], + dst_center[0], dst_center[1], + fill='#00FF00', + width=2, + arrow=tk.LAST, + arrowshape=(12, 15, 5), + dash=(8, 4) + ) + self.highlight_items.append(arrow_id) + + def _get_segment_center(self, segment: EdgeSegment) -> Tuple[float, float]: + """Get canvas center coordinates of an edge segment.""" + edge = segment.edge + mid = (segment.start + segment.end) / 2 + + if edge.edge_type == EdgeType.LEFT: + return self._screen_to_canvas(edge.position - self.EDGE_BAR_WIDTH / self.scale / 2, mid) + elif edge.edge_type == EdgeType.RIGHT: + return self._screen_to_canvas(edge.position + 1 + self.EDGE_BAR_WIDTH / self.scale / 2, mid) + elif edge.edge_type == EdgeType.TOP: + return self._screen_to_canvas(mid, edge.position - self.EDGE_BAR_WIDTH / self.scale / 2) + else: # BOTTOM + return self._screen_to_canvas(mid, edge.position + 1 + self.EDGE_BAR_WIDTH / self.scale / 2) + + def _get_edge_center(self, edge: MonitorEdge, start: int, end: int) -> Tuple[float, float]: + """Get canvas center coordinates of an edge range.""" + mid = (start + end) / 2 + + if edge.edge_type == EdgeType.LEFT: + return self._screen_to_canvas(edge.position - self.EDGE_BAR_WIDTH / self.scale / 2, mid) + elif edge.edge_type == EdgeType.RIGHT: + return self._screen_to_canvas(edge.position + 1 + self.EDGE_BAR_WIDTH / self.scale / 2, mid) + elif edge.edge_type == EdgeType.TOP: + return self._screen_to_canvas(mid, edge.position - self.EDGE_BAR_WIDTH / self.scale / 2) + else: # BOTTOM + return self._screen_to_canvas(mid, edge.position + 1 + self.EDGE_BAR_WIDTH / self.scale / 2) + + def load_json(self, path: str): + """Load monitor layout from JSON file.""" + try: + with open(path, 'r') as f: + data = json.load(f) + + self.monitors = [] + for i, mon_data in enumerate(data.get('monitors', [])): + monitor = MonitorInfo( + left=mon_data.get('left', 0), + top=mon_data.get('top', 0), + right=mon_data.get('right', 0), + bottom=mon_data.get('bottom', 0), + width=mon_data.get('width', 0), + height=mon_data.get('height', 0), + dpi=mon_data.get('dpi', 96), + scaling_percent=mon_data.get('scaling_percent', 100.0), + primary=mon_data.get('primary', False), + device_name=mon_data.get('device_name', f'DISPLAY{i+1}'), + monitor_id=i + ) + self.monitors.append(monitor) + + self.topology.initialize(self.monitors) + self.loaded_data = data + self._update_info_panel(data) + self._redraw() + + self.root.title(f"CursorWrap Simulator - {path}") + + except Exception as e: + messagebox.showerror("Error", f"Failed to load JSON: {e}") + + def _update_info_panel(self, data: dict): + """Update the info panel with loaded data.""" + self.info_text.delete(1.0, tk.END) + + lines = [] + lines.append(f"Captured: {data.get('captured_at', 'Unknown')}") + lines.append(f"Computer: {data.get('computer_name', 'Unknown')}") + lines.append(f"Monitor Count: {data.get('monitor_count', len(self.monitors))}") + lines.append("") + lines.append("=" * 40) + lines.append("") + + for i, monitor in enumerate(self.monitors): + lines.append(f"Monitor {i}: {monitor.device_name}") + lines.append(f" Size: {monitor.width} x {monitor.height}") + lines.append(f" Position: ({monitor.left}, {monitor.top})") + lines.append(f" DPI: {monitor.dpi} ({monitor.scaling_percent}%)") + lines.append(f" Primary: {'Yes' if monitor.primary else 'No'}") + lines.append("") + + lines.append("=" * 40) + lines.append("") + lines.append(f"Outer Edges: {len(self.topology.outer_edges)}") + + for edge in self.topology.outer_edges: + lines.append(f" Mon {edge.monitor_index} {edge.edge_type.name}: " + f"pos={edge.position}, range=[{edge.start}, {edge.end}]") + + # Check for problem areas (segments with no wrap destination) + lines.append("") + lines.append("=" * 40) + lines.append("WRAP ANALYSIS") + lines.append("=" * 40) + + wrap_mode = WrapMode(self.wrap_mode.get()) + problem_segments = [] + total_problem_pixels = 0 + + for edge in self.topology.outer_edges: + segments = self.topology.get_edge_segments_with_wrap_info(edge, wrap_mode) + for seg in segments: + if not seg.has_wrap_destination: + problem_segments.append(seg) + total_problem_pixels += (seg.end - seg.start) + + if problem_segments: + lines.append("") + lines.append(f"⚠️ PROBLEM AREAS: {len(problem_segments)} segments") + lines.append(f" Total: {total_problem_pixels} pixels without wrap") + + # Group by problem reason + by_reason: Dict[ProblemReason, List[EdgeSegment]] = {} + for seg in problem_segments: + reason = seg.problem_analysis.reason if seg.problem_analysis else ProblemReason.NONE + if reason not in by_reason: + by_reason[reason] = [] + by_reason[reason].append(seg) + + lines.append("") + lines.append("-" * 40) + + for reason, segs in by_reason.items(): + lines.append("") + lines.append(f"▸ {reason.name} ({len(segs)} segments)") + lines.append("") + + for seg in segs: + edge = seg.edge + mon = self.monitors[edge.monitor_index] + lines.append(f" • Mon {edge.monitor_index} ({mon.device_name}) {edge.edge_type.name}") + lines.append(f" Range: {seg.start} to {seg.end} ({seg.end - seg.start}px)") + + if seg.problem_analysis: + pa = seg.problem_analysis + lines.append(f"") + lines.append(f" CAUSE: {pa.description}") + lines.append(f"") + lines.append(f" FIX: {pa.suggestion}") + + # Show relevant details + if pa.details: + if "gap_to_nearest" in pa.details and pa.details["gap_to_nearest"]: + lines.append(f" Gap to nearest valid edge: {pa.details['gap_to_nearest']}px") + + if "available_opposite_edges" in pa.details: + opp_edges = pa.details["available_opposite_edges"] + if opp_edges: + lines.append(f"") + lines.append(f" Available opposite edges:") + for oe in opp_edges[:3]: # Show top 3 closest + lines.append(f" - Mon {oe['monitor_index']} ({oe['monitor_name']}): " + f"range [{oe['edge_range'][0]}, {oe['edge_range'][1]}], " + f"{oe['distance_to_segment']}px {oe['relative_position']}") + + lines.append("") + lines.append(" " + "-" * 36) + else: + lines.append("") + lines.append("✓ All outer edges have wrap destinations!") + + # Check for gaps + gaps = self.topology.detect_monitor_gaps() + if gaps: + lines.append("") + lines.append("⚠️ Monitor Gaps Detected:") + for gap in gaps: + lines.append(f" Between Mon {gap.monitor1_index} and Mon {gap.monitor2_index}:") + lines.append(f" Horizontal gap: {gap.horizontal_gap}px") + lines.append(f" Vertical overlap: {gap.vertical_overlap}px") + + self.info_text.insert(tk.END, "\n".join(lines)) + + def _calculate_scale(self): + """Calculate scale factor to fit monitors in canvas.""" + if not self.monitors: + return + + canvas_width = self.canvas.winfo_width() + canvas_height = self.canvas.winfo_height() + + if canvas_width < 10 or canvas_height < 10: + return + + # Find bounding box of all monitors + min_x = min(m.left for m in self.monitors) + max_x = max(m.right for m in self.monitors) + min_y = min(m.top for m in self.monitors) + max_y = max(m.bottom for m in self.monitors) + + layout_width = max_x - min_x + layout_height = max_y - min_y + + # Add padding for edge bars + padding = 50 + available_width = canvas_width - 2 * padding + available_height = canvas_height - 2 * padding + + # Calculate scale to fit + scale_x = available_width / layout_width if layout_width > 0 else 1 + scale_y = available_height / layout_height if layout_height > 0 else 1 + self.scale = min(scale_x, scale_y) + + # Calculate offset to center + scaled_width = layout_width * self.scale + scaled_height = layout_height * self.scale + self.offset_x = (canvas_width - scaled_width) / 2 - min_x * self.scale + self.offset_y = (canvas_height - scaled_height) / 2 - min_y * self.scale + + def _screen_to_canvas(self, x: int, y: int) -> Tuple[float, float]: + """Convert screen coordinates to canvas coordinates.""" + return (x * self.scale + self.offset_x, y * self.scale + self.offset_y) + + def _redraw(self): + """Redraw the entire canvas.""" + self.canvas.delete("all") + self.monitor_items.clear() + self.edge_segment_items.clear() + + if not self.monitors: + self.canvas.create_text( + self.canvas.winfo_width() / 2, + self.canvas.winfo_height() / 2, + text="Load a monitor layout JSON file to begin", + fill="white", + font=('TkDefaultFont', 14) + ) + return + + self._calculate_scale() + + # Draw monitors + self._draw_monitors() + + # Draw edge bars + self._draw_edge_bars() + + def _draw_monitors(self): + """Draw monitor rectangles.""" + for i, monitor in enumerate(self.monitors): + x1, y1 = self._screen_to_canvas(monitor.left, monitor.top) + x2, y2 = self._screen_to_canvas(monitor.right, monitor.bottom) + + # Draw monitor rectangle + outline_color = self.PRIMARY_HIGHLIGHT if monitor.primary else self.MONITOR_OUTLINE + outline_width = 3 if monitor.primary else 1 + + item_id = self.canvas.create_rectangle( + x1, y1, x2, y2, + fill=self.MONITOR_FILL, + outline=outline_color, + width=outline_width + ) + self.monitor_items[item_id] = i + + # Draw monitor label + center_x = (x1 + x2) / 2 + center_y = (y1 + y2) / 2 + + label_text = f"Monitor {i}\n{monitor.device_name}\n{monitor.width}x{monitor.height}" + if monitor.primary: + label_text += "\n[PRIMARY]" + + self.canvas.create_text( + center_x, center_y, + text=label_text, + fill="white", + font=('TkDefaultFont', 9), + justify=tk.CENTER + ) + + def _draw_edge_bars(self): + """Draw edge bars outside outer edges showing wrap destinations.""" + wrap_mode = WrapMode(self.wrap_mode.get()) + bar_width = self.EDGE_BAR_WIDTH + + # Use new algorithm if checkbox is checked + use_new = self.use_new_algorithm.get() + + for edge in self.topology.outer_edges: + if use_new: + segments = self.topology.get_edge_segments_with_projection(edge, wrap_mode) + else: + segments = self.topology.get_edge_segments_with_wrap_info(edge, wrap_mode) + + for segment in segments: + self._draw_edge_segment(segment, bar_width) + + def _draw_edge_segment(self, segment: EdgeSegment, bar_width: float): + """Draw a single edge segment with appropriate color.""" + edge = segment.edge + + # Determine color based on wrap destination + if segment.wraps_to is not None: + # Has wrap destination - use destination edge color mixed with yellow + color = self.WRAP_DESTINATION_COLOR + else: + # No wrap destination - problem area! + color = self.NO_WRAP_COLOR + + # Calculate segment position + if edge.edge_type == EdgeType.LEFT: + # Bar to the left of the monitor + x1, y1 = self._screen_to_canvas(edge.position - bar_width / self.scale, segment.start) + x2, y2 = self._screen_to_canvas(edge.position, segment.end) + x1 -= bar_width / 2 + elif edge.edge_type == EdgeType.RIGHT: + # Bar to the right of the monitor + x1, y1 = self._screen_to_canvas(edge.position + 1, segment.start) + x2, y2 = self._screen_to_canvas(edge.position + 1 + bar_width / self.scale, segment.end) + x2 += bar_width / 2 + elif edge.edge_type == EdgeType.TOP: + # Bar above the monitor + x1, y1 = self._screen_to_canvas(segment.start, edge.position - bar_width / self.scale) + x2, y2 = self._screen_to_canvas(segment.end, edge.position) + y1 -= bar_width / 2 + elif edge.edge_type == EdgeType.BOTTOM: + # Bar below the monitor + x1, y1 = self._screen_to_canvas(segment.start, edge.position + 1) + x2, y2 = self._screen_to_canvas(segment.end, edge.position + 1 + bar_width / self.scale) + y2 += bar_width / 2 + + # Draw the segment bar + # First draw the base edge color + base_color = self.EDGE_COLORS[edge.edge_type] + + item_id = self.canvas.create_rectangle( + x1, y1, x2, y2, + fill=color, + outline=base_color, + width=2 + ) + self.edge_segment_items.append((item_id, segment)) + + # If it's a problem area (no wrap), add a pattern or marker + if not segment.has_wrap_destination: + # Add diagonal lines pattern to make it more visible + if edge.edge_type in (EdgeType.LEFT, EdgeType.RIGHT): + # Vertical bar - horizontal stripes + for stripe_y in range(int(y1), int(y2), 6): + self.canvas.create_line(x1, stripe_y, x2, stripe_y, fill='white', width=1) + else: + # Horizontal bar - vertical stripes + for stripe_x in range(int(x1), int(x2), 6): + self.canvas.create_line(stripe_x, y1, stripe_x, y2, fill='white', width=1) + + # ========================================================================= + # Edge Test Simulation Methods + # ========================================================================= + + def _start_edge_test(self): + """Start the edge test simulation - tests wrap behavior at all outer edge points.""" + if not self.monitors: + messagebox.showwarning("No Data", "Load a monitor layout first.") + return + + if self.edge_test_running: + return + + # Stop any cursor log playback + self._stop_playback() + + # Generate test points for all outer edges + self.edge_test_points = self._generate_edge_test_points() + self.edge_test_index = 0 + self.edge_test_running = True + self.edge_test_results = { + "total_points": len(self.edge_test_points), + "tested": 0, + "with_destination": 0, + "without_destination": 0, + "by_edge": {} + } + + self.edge_test_stop_btn.config(state=tk.NORMAL) + self.play_button.config(state=tk.DISABLED) + + # Update info panel + self.info_text.delete(1.0, tk.END) + self.info_text.insert(tk.END, "EDGE TEST SIMULATION\n") + self.info_text.insert(tk.END, "=" * 40 + "\n\n") + self.info_text.insert(tk.END, f"Testing {len(self.edge_test_points)} edge points...\n") + self.info_text.insert(tk.END, f"Algorithm: {'NEW (with projection)' if self.use_new_algorithm.get() else 'OLD (direct overlap only)'}\n\n") + + # Start the test animation + self._edge_test_step() + + def _stop_edge_test(self): + """Stop the edge test simulation.""" + self.edge_test_running = False + self.edge_test_stop_btn.config(state=tk.DISABLED) + if self.cursor_log: + self.play_button.config(state=tk.NORMAL) + + # Clear test visualization + for item_id in self.edge_test_canvas_items: + self.canvas.delete(item_id) + self.edge_test_canvas_items.clear() + + # Show results summary + self._show_edge_test_results() + + def _generate_edge_test_points(self) -> List[Tuple[MonitorEdge, int, int, int]]: + """ + Generate test points along all outer edges. + Returns list of (edge, perpendicular_coord, source_x, source_y). + """ + points = [] + wrap_mode = WrapMode(self.wrap_mode.get()) + use_new = self.use_new_algorithm.get() + + # Sample every N pixels along each edge + sample_interval = 20 # pixels + + for edge in self.topology.outer_edges: + # Check if this edge type is enabled by wrap mode + is_horizontal_edge = edge.edge_type in (EdgeType.LEFT, EdgeType.RIGHT) + is_vertical_edge = edge.edge_type in (EdgeType.TOP, EdgeType.BOTTOM) + + if wrap_mode == WrapMode.VERTICAL_ONLY and is_horizontal_edge: + continue + if wrap_mode == WrapMode.HORIZONTAL_ONLY and is_vertical_edge: + continue + + # Generate sample points along the edge + for coord in range(edge.start, edge.end + 1, sample_interval): + # Calculate source position (on the edge) + if edge.edge_type == EdgeType.LEFT: + src_x, src_y = edge.position, coord + elif edge.edge_type == EdgeType.RIGHT: + src_x, src_y = edge.position, coord + elif edge.edge_type == EdgeType.TOP: + src_x, src_y = coord, edge.position + elif edge.edge_type == EdgeType.BOTTOM: + src_x, src_y = coord, edge.position + + # Calculate wrap destination + if use_new: + target_edge, dest_coord = self.topology.get_wrap_destination_with_projection( + edge.edge_type, coord, edge) + else: + target_edge = self.topology.find_opposite_outer_edge(edge.edge_type, coord) + dest_coord = coord if target_edge else None + + if target_edge: + # Calculate destination position + if target_edge.edge_type == EdgeType.LEFT: + dest_x, dest_y = target_edge.position, dest_coord + elif target_edge.edge_type == EdgeType.RIGHT: + dest_x, dest_y = target_edge.position, dest_coord + elif target_edge.edge_type == EdgeType.TOP: + dest_x, dest_y = dest_coord, target_edge.position + elif target_edge.edge_type == EdgeType.BOTTOM: + dest_x, dest_y = dest_coord, target_edge.position + + points.append((edge, coord, src_x, src_y, dest_x, dest_y, True)) + else: + # No destination + points.append((edge, coord, src_x, src_y, src_x, src_y, False)) + + return points + + def _edge_test_step(self): + """Perform one step of the edge test animation.""" + if not self.edge_test_running: + return + + if self.edge_test_index >= len(self.edge_test_points): + # Test complete + self._stop_edge_test() + return + + # Clear previous visualization + for item_id in self.edge_test_canvas_items: + self.canvas.delete(item_id) + self.edge_test_canvas_items.clear() + + # Get current test point + point = self.edge_test_points[self.edge_test_index] + edge, coord, src_x, src_y, dest_x, dest_y, has_dest = point + + # Update results + self.edge_test_results["tested"] += 1 + edge_key = f"Mon{edge.monitor_index}_{edge.edge_type.name}" + if edge_key not in self.edge_test_results["by_edge"]: + self.edge_test_results["by_edge"][edge_key] = {"total": 0, "success": 0, "fail": 0} + self.edge_test_results["by_edge"][edge_key]["total"] += 1 + + if has_dest: + self.edge_test_results["with_destination"] += 1 + self.edge_test_results["by_edge"][edge_key]["success"] += 1 + else: + self.edge_test_results["without_destination"] += 1 + self.edge_test_results["by_edge"][edge_key]["fail"] += 1 + + # Draw source cursor + cx, cy = self._screen_to_canvas(src_x, src_y) + cursor_size = 10 + + # Source cursor (red circle) + item = self.canvas.create_oval( + cx - cursor_size, cy - cursor_size, + cx + cursor_size, cy + cursor_size, + fill="#FF4444", outline="white", width=2 + ) + self.edge_test_canvas_items.append(item) + + # Draw destination and connection line if there's a valid destination + if has_dest and (dest_x != src_x or dest_y != src_y): + dx, dy = self._screen_to_canvas(dest_x, dest_y) + + # Connection line (dashed) + item = self.canvas.create_line( + cx, cy, dx, dy, + fill="#00FF00", width=2, dash=(5, 3) + ) + self.edge_test_canvas_items.append(item) + + # Destination cursor (green circle) + item = self.canvas.create_oval( + dx - cursor_size, dy - cursor_size, + dx + cursor_size, dy + cursor_size, + fill="#44FF44", outline="white", width=2 + ) + self.edge_test_canvas_items.append(item) + elif not has_dest: + # Draw X for no destination + item = self.canvas.create_line( + cx - cursor_size, cy - cursor_size, + cx + cursor_size, cy + cursor_size, + fill="#FF0000", width=3 + ) + self.edge_test_canvas_items.append(item) + item = self.canvas.create_line( + cx - cursor_size, cy + cursor_size, + cx + cursor_size, cy - cursor_size, + fill="#FF0000", width=3 + ) + self.edge_test_canvas_items.append(item) + + # Update hover info + status = "✓ WRAPS" if has_dest else "✗ NO WRAP" + self.hover_info_var.set( + f"Testing: Mon{edge.monitor_index} {edge.edge_type.name} @ {coord} → {status} " + f"[{self.edge_test_index + 1}/{len(self.edge_test_points)}]" + ) + + # Update status panel + progress = (self.edge_test_index + 1) / len(self.edge_test_points) * 100 + self.playback_status_var.set(f"Progress: {progress:.1f}%") + self.playback_position_var.set(f"Point: {self.edge_test_index + 1} / {len(self.edge_test_points)}") + + # Move to next point + self.edge_test_index += 1 + + # Schedule next step + speed = self.playback_speed.get() + self.root.after(speed, self._edge_test_step) + + def _show_edge_test_results(self): + """Show the edge test results summary.""" + results = self.edge_test_results + + self.info_text.delete(1.0, tk.END) + self.info_text.insert(tk.END, "EDGE TEST RESULTS\n") + self.info_text.insert(tk.END, "=" * 40 + "\n\n") + + total = results.get("total_points", 0) + tested = results.get("tested", 0) + success = results.get("with_destination", 0) + fail = results.get("without_destination", 0) + + self.info_text.insert(tk.END, f"Algorithm: {'NEW' if self.use_new_algorithm.get() else 'OLD'}\n\n") + self.info_text.insert(tk.END, f"Total Points: {total}\n") + self.info_text.insert(tk.END, f"Tested: {tested}\n") + self.info_text.insert(tk.END, f"With Destination: {success} ({success/total*100:.1f}%)\n" if total > 0 else "") + self.info_text.insert(tk.END, f"Without Destination: {fail} ({fail/total*100:.1f}%)\n\n" if total > 0 else "") + + if fail == 0 and total > 0: + self.info_text.insert(tk.END, "✅ ALL POINTS HAVE WRAP DESTINATIONS!\n\n") + elif fail > 0: + self.info_text.insert(tk.END, f"⚠️ {fail} POINTS WITHOUT DESTINATION\n\n") + + # Per-edge breakdown + self.info_text.insert(tk.END, "By Edge:\n") + self.info_text.insert(tk.END, "-" * 30 + "\n") + for edge_key, stats in results.get("by_edge", {}).items(): + status = "✓" if stats["fail"] == 0 else "✗" + self.info_text.insert(tk.END, + f"{status} {edge_key}: {stats['success']}/{stats['total']}\n") + + self.hover_info_var.set(f"Test complete: {success}/{total} points with destinations") + + +def main(): + parser = argparse.ArgumentParser( + description='CursorWrap Simulator - Visualize monitor wrap edges', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Example: + python wrap_simulator.py monitor_layout.json + +The JSON file should contain monitor layout information with the following structure: +{ + "monitors": [ + { + "left": 0, "top": 0, "right": 2560, "bottom": 1440, + "width": 2560, "height": 1440, + "primary": true, "device_name": "DISPLAY1" + }, + ... + ] +} + """ + ) + parser.add_argument('json_file', nargs='?', help='Path to monitor layout JSON file') + + args = parser.parse_args() + + root = tk.Tk() + app = WrapSimulatorApp(root, args.json_file) + root.mainloop() + + +if __name__ == '__main__': + main() diff --git a/src/modules/MouseUtils/CursorWrap/MonitorTopology.cpp b/src/modules/MouseUtils/CursorWrap/MonitorTopology.cpp index 8e613996c6..070e7a3521 100644 --- a/src/modules/MouseUtils/CursorWrap/MonitorTopology.cpp +++ b/src/modules/MouseUtils/CursorWrap/MonitorTopology.cpp @@ -4,6 +4,7 @@ #include "pch.h" #include "MonitorTopology.h" +#include "CursorWrapCore.h" // For CursorDirection struct #include "../../../common/logger/logger.h" #include #include @@ -13,6 +14,7 @@ void MonitorTopology::Initialize(const std::vector& monitors) Logger::info(L"======= TOPOLOGY INITIALIZATION START ======="); Logger::info(L"Initializing edge-based topology for {} monitors", monitors.size()); + m_monitors = monitors; m_outerEdges.clear(); m_edgeMap.clear(); @@ -163,10 +165,80 @@ bool MonitorTopology::EdgesAreAdjacent(const MonitorEdge& edge1, const MonitorEd int overlapStart = max(edge1.start, edge2.start); int overlapEnd = min(edge1.end, edge2.end); + return overlapEnd > overlapStart + tolerance; } -bool MonitorTopology::IsOnOuterEdge(HMONITOR monitor, const POINT& cursorPos, EdgeType& outEdgeType, WrapMode wrapMode) const +EdgeType MonitorTopology::PrioritizeEdgeByDirection(const std::vector& candidates, + const CursorDirection* direction) const +{ + if (candidates.empty()) + { + return EdgeType::Left; // Should not happen, but return a default + } + + if (candidates.size() == 1 || direction == nullptr) + { + return candidates[0]; + } + + // Prioritize based on movement direction + // If moving primarily horizontally, prefer horizontal edges (Left/Right) + // If moving primarily vertically, prefer vertical edges (Top/Bottom) + + if (direction->IsPrimarilyHorizontal()) + { + // Prefer Left if moving left, Right if moving right + if (direction->IsMovingLeft()) + { + for (EdgeType edge : candidates) + { + if (edge == EdgeType::Left) return edge; + } + } + else if (direction->IsMovingRight()) + { + for (EdgeType edge : candidates) + { + if (edge == EdgeType::Right) return edge; + } + } + // Fall back to any horizontal edge + for (EdgeType edge : candidates) + { + if (edge == EdgeType::Left || edge == EdgeType::Right) return edge; + } + } + else + { + // Prefer Top if moving up, Bottom if moving down + if (direction->IsMovingUp()) + { + for (EdgeType edge : candidates) + { + if (edge == EdgeType::Top) return edge; + } + } + else if (direction->IsMovingDown()) + { + for (EdgeType edge : candidates) + { + if (edge == EdgeType::Bottom) return edge; + } + } + // Fall back to any vertical edge + for (EdgeType edge : candidates) + { + if (edge == EdgeType::Top || edge == EdgeType::Bottom) return edge; + } + } + + // Default to first candidate + return candidates[0]; +} + +bool MonitorTopology::IsOnOuterEdge(HMONITOR monitor, const POINT& cursorPos, EdgeType& outEdgeType, + WrapMode wrapMode, const CursorDirection* direction) const { RECT monitorRect; if (!GetMonitorRect(monitor, monitorRect)) @@ -248,13 +320,40 @@ bool MonitorTopology::IsOnOuterEdge(HMONITOR monitor, const POINT& cursorPos, Ed return false; } - // Try each candidate edge and return first with valid wrap destination + // Prioritize candidates by movement direction at corners + EdgeType prioritizedEdge = PrioritizeEdgeByDirection(candidateEdges, direction); + + // Get the source edge info + auto sourceIt = m_edgeMap.find({monitorIndex, prioritizedEdge}); + if (sourceIt == m_edgeMap.end()) + { + return false; + } + + // Use the new FindNearestOppositeEdge which handles non-overlapping regions + int cursorCoord = (prioritizedEdge == EdgeType::Left || prioritizedEdge == EdgeType::Right) + ? cursorPos.y : cursorPos.x; + OppositeEdgeResult result = FindNearestOppositeEdge(prioritizedEdge, cursorCoord, sourceIt->second); + + if (result.found) + { + outEdgeType = prioritizedEdge; + return true; + } + + // If prioritized edge didn't work, try other candidates for (EdgeType candidate : candidateEdges) { - MonitorEdge oppositeEdge = FindOppositeOuterEdge(candidate, - (candidate == EdgeType::Left || candidate == EdgeType::Right) ? cursorPos.y : cursorPos.x); - - if (oppositeEdge.monitorIndex >= 0) + if (candidate == prioritizedEdge) continue; + + auto it = m_edgeMap.find({monitorIndex, candidate}); + if (it == m_edgeMap.end()) continue; + + int coord = (candidate == EdgeType::Left || candidate == EdgeType::Right) + ? cursorPos.y : cursorPos.x; + OppositeEdgeResult altResult = FindNearestOppositeEdge(candidate, coord, it->second); + + if (altResult.found) { outEdgeType = candidate; return true; @@ -280,16 +379,14 @@ POINT MonitorTopology::GetWrapDestination(HMONITOR fromMonitor, const POINT& cur } const MonitorEdge& fromEdge = it->second; + + // Get cursor coordinate perpendicular to the edge + int cursorCoord = (edgeType == EdgeType::Left || edgeType == EdgeType::Right) ? cursorPos.y : cursorPos.x; - // Calculate relative position on current edge (0.0 to 1.0) - double relativePos = GetRelativePosition(fromEdge, - (edgeType == EdgeType::Left || edgeType == EdgeType::Right) ? cursorPos.y : cursorPos.x); + // Use the new FindNearestOppositeEdge which handles non-overlapping regions + OppositeEdgeResult oppositeResult = FindNearestOppositeEdge(edgeType, cursorCoord, fromEdge); - // Find opposite outer edge - MonitorEdge oppositeEdge = FindOppositeOuterEdge(edgeType, - (edgeType == EdgeType::Left || edgeType == EdgeType::Right) ? cursorPos.y : cursorPos.x); - - if (oppositeEdge.monitorIndex < 0) + if (!oppositeResult.found) { // No opposite edge found, wrap within same monitor RECT monitorRect; @@ -321,15 +418,35 @@ POINT MonitorTopology::GetWrapDestination(HMONITOR fromMonitor, const POINT& cur if (edgeType == EdgeType::Left || edgeType == EdgeType::Right) { - // Horizontal edge -> vertical movement - result.x = oppositeEdge.position; - result.y = GetAbsolutePosition(oppositeEdge, relativePos); + // Horizontal wrapping (Left<->Right edges) + result.x = oppositeResult.edge.position; + + if (oppositeResult.requiresProjection) + { + // Use the pre-calculated projected coordinate for non-overlapping regions + result.y = oppositeResult.projectedCoordinate; + } + else + { + // Overlapping region - preserve Y coordinate + result.y = cursorPos.y; + } } else { - // Vertical edge -> horizontal movement - result.y = oppositeEdge.position; - result.x = GetAbsolutePosition(oppositeEdge, relativePos); + // Vertical wrapping (Top<->Bottom edges) + result.y = oppositeResult.edge.position; + + if (oppositeResult.requiresProjection) + { + // Use the pre-calculated projected coordinate for non-overlapping regions + result.x = oppositeResult.projectedCoordinate; + } + else + { + // Overlapping region - preserve X coordinate + result.x = cursorPos.x; + } } return result; @@ -387,6 +504,170 @@ MonitorEdge MonitorTopology::FindOppositeOuterEdge(EdgeType fromEdge, int relati return result; } +OppositeEdgeResult MonitorTopology::FindNearestOppositeEdge(EdgeType fromEdge, int cursorCoordinate, + const MonitorEdge& sourceEdge) const +{ + OppositeEdgeResult result; + result.found = false; + result.requiresProjection = false; + result.projectedCoordinate = 0; + result.edge.monitorIndex = -1; + + EdgeType targetType; + bool findMax; // true = find max position (furthest right/bottom), false = find min (furthest left/top) + + switch (fromEdge) + { + case EdgeType::Left: + targetType = EdgeType::Right; + findMax = true; + break; + case EdgeType::Right: + targetType = EdgeType::Left; + findMax = false; + break; + case EdgeType::Top: + targetType = EdgeType::Bottom; + findMax = true; + break; + case EdgeType::Bottom: + targetType = EdgeType::Top; + findMax = false; + break; + default: + return result; // Invalid edge type + } + + // First, try to find an edge that directly overlaps the cursor coordinate + MonitorEdge directMatch = FindOppositeOuterEdge(fromEdge, cursorCoordinate); + if (directMatch.monitorIndex >= 0) + { + result.found = true; + result.requiresProjection = false; + result.edge = directMatch; + result.projectedCoordinate = cursorCoordinate; // Not used, but set for completeness + return result; + } + + // No direct overlap - find the nearest opposite edge by coordinate distance + // This handles the "dead zone" case where cursor is in a non-overlapping region + + int bestDistance = INT_MAX; + MonitorEdge bestEdge = { .monitorIndex = -1 }; + int bestProjectedCoord = 0; + + for (const auto& edge : m_outerEdges) + { + if (edge.type != targetType) + { + continue; + } + + // Calculate distance from cursor coordinate to this edge's range + int distance = 0; + int projectedCoord = 0; + + if (cursorCoordinate < edge.start) + { + // Cursor is before the edge's start - project to edge start with offset + distance = edge.start - cursorCoordinate; + projectedCoord = edge.start; // Clamp to edge start + } + else if (cursorCoordinate > edge.end) + { + // Cursor is after the edge's end - project to edge end with offset + distance = cursorCoordinate - edge.end; + projectedCoord = edge.end; // Clamp to edge end + } + else + { + // Cursor overlaps - this shouldn't happen since we checked direct match + distance = 0; + projectedCoord = cursorCoordinate; + } + + // Choose the best edge: prefer closer edges, and among equals prefer extreme position + bool isBetter = false; + if (distance < bestDistance) + { + isBetter = true; + } + else if (distance == bestDistance && bestEdge.monitorIndex >= 0) + { + // Same distance - prefer the extreme position (furthest in wrap direction) + if ((findMax && edge.position > bestEdge.position) || + (!findMax && edge.position < bestEdge.position)) + { + isBetter = true; + } + } + + if (isBetter) + { + bestDistance = distance; + bestEdge = edge; + bestProjectedCoord = projectedCoord; + } + } + + if (bestEdge.monitorIndex >= 0) + { + result.found = true; + result.requiresProjection = true; + result.edge = bestEdge; + + // Calculate projected position using offset-from-boundary approach + result.projectedCoordinate = CalculateProjectedPosition(cursorCoordinate, sourceEdge, bestEdge); + + Logger::trace(L"FindNearestOppositeEdge: Non-overlapping wrap from {} to Mon {} edge, cursor={}, projected={}", + static_cast(fromEdge), bestEdge.monitorIndex, cursorCoordinate, result.projectedCoordinate); + } + + return result; +} + +int MonitorTopology::CalculateProjectedPosition(int cursorCoordinate, const MonitorEdge& sourceEdge, + const MonitorEdge& targetEdge) const +{ + // Windows behavior for non-overlapping regions: + // When cursor is in a region that doesn't overlap with the target edge, + // clamp to the nearest boundary of the target edge. + // This matches observed Windows cursor transition behavior. + + // Find the shared boundary region between source and target edges + int sharedStart = max(sourceEdge.start, targetEdge.start); + int sharedEnd = min(sourceEdge.end, targetEdge.end); + + if (cursorCoordinate >= sharedStart && cursorCoordinate <= sharedEnd) + { + // Cursor is in shared region - preserve the coordinate exactly + return cursorCoordinate; + } + + // For non-overlapping regions, clamp to the nearest boundary of the target edge + // This matches Windows behavior where the cursor is projected to the closest + // valid point on the destination edge + int projectedCoord; + + if (cursorCoordinate < sharedStart) + { + // Cursor is BEFORE the shared region (e.g., above shared area) + // Clamp to the start of the target edge (with small offset to stay within bounds) + projectedCoord = targetEdge.start + 1; + } + else + { + // Cursor is AFTER the shared region (e.g., below shared area) + // Clamp to the end of the target edge (with small offset to stay within bounds) + projectedCoord = targetEdge.end - 1; + } + + // Final bounds check + projectedCoord = max(targetEdge.start, min(projectedCoord, targetEdge.end - 1)); + + return projectedCoord; +} + double MonitorTopology::GetRelativePosition(const MonitorEdge& edge, int coordinate) const { if (edge.end == edge.start) @@ -411,6 +692,7 @@ int MonitorTopology::GetAbsolutePosition(const MonitorEdge& edge, double relativ return static_cast(result); } + std::vector MonitorTopology::DetectMonitorGaps() const { std::vector gaps; diff --git a/src/modules/MouseUtils/CursorWrap/MonitorTopology.h b/src/modules/MouseUtils/CursorWrap/MonitorTopology.h index 0dead8e351..f8b188ce6c 100644 --- a/src/modules/MouseUtils/CursorWrap/MonitorTopology.h +++ b/src/modules/MouseUtils/CursorWrap/MonitorTopology.h @@ -7,6 +7,9 @@ #include #include +// Forward declaration +struct CursorDirection; + // Monitor information structure struct MonitorInfo { @@ -44,6 +47,15 @@ struct MonitorEdge bool isOuter; // True if no adjacent monitor touches this edge }; +// Result of finding an opposite edge, including projection info for non-overlapping regions +struct OppositeEdgeResult +{ + MonitorEdge edge; + bool found; // True if an opposite edge was found + bool requiresProjection; // True if cursor position needs to be projected (non-overlapping region) + int projectedCoordinate; // The calculated coordinate on the target edge +}; + // Monitor topology helper - manages edge-based monitor layout struct MonitorTopology { @@ -51,7 +63,9 @@ struct MonitorTopology // Check if cursor is on an outer edge of the given monitor // wrapMode filters which edges are considered (Both, VerticalOnly, HorizontalOnly) - bool IsOnOuterEdge(HMONITOR monitor, const POINT& cursorPos, EdgeType& outEdgeType, WrapMode wrapMode) const; + // direction is used to prioritize edges at corners based on cursor movement + bool IsOnOuterEdge(HMONITOR monitor, const POINT& cursorPos, EdgeType& outEdgeType, + WrapMode wrapMode, const CursorDirection* direction = nullptr) const; // Get the wrap destination point for a cursor on an outer edge POINT GetWrapDestination(HMONITOR fromMonitor, const POINT& cursorPos, EdgeType edgeType) const; @@ -95,12 +109,26 @@ private: // Check if two edges are adjacent (within tolerance) bool EdgesAreAdjacent(const MonitorEdge& edge1, const MonitorEdge& edge2, int tolerance = 50) const; - // Find the opposite outer edge for wrapping + // Find the opposite outer edge for wrapping (original method - for overlapping regions) MonitorEdge FindOppositeOuterEdge(EdgeType fromEdge, int relativePosition) const; + + // Find the nearest opposite outer edge, including projection for non-overlapping regions + // This implements Windows-like behavior for cursor transitions + OppositeEdgeResult FindNearestOppositeEdge(EdgeType fromEdge, int cursorCoordinate, + const MonitorEdge& sourceEdge) const; + + // Calculate projected position for cursor in non-overlapping region + // Returns the coordinate on the destination edge using offset-from-boundary approach + int CalculateProjectedPosition(int cursorCoordinate, const MonitorEdge& sourceEdge, + const MonitorEdge& targetEdge) const; // Calculate relative position along an edge (0.0 to 1.0) double GetRelativePosition(const MonitorEdge& edge, int coordinate) const; // Convert relative position to absolute coordinate on target edge int GetAbsolutePosition(const MonitorEdge& edge, double relativePosition) const; + + // Prioritize edge candidates based on cursor movement direction + EdgeType PrioritizeEdgeByDirection(const std::vector& candidates, + const CursorDirection* direction) const; }; diff --git a/src/modules/MouseUtils/CursorWrap/dllmain.cpp b/src/modules/MouseUtils/CursorWrap/dllmain.cpp index b172f1c8b6..451c4e99bd 100644 --- a/src/modules/MouseUtils/CursorWrap/dllmain.cpp +++ b/src/modules/MouseUtils/CursorWrap/dllmain.cpp @@ -54,6 +54,7 @@ namespace const wchar_t JSON_KEY_AUTO_ACTIVATE[] = L"auto_activate"; const wchar_t JSON_KEY_DISABLE_WRAP_DURING_DRAG[] = L"disable_wrap_during_drag"; const wchar_t JSON_KEY_WRAP_MODE[] = L"wrap_mode"; + const wchar_t JSON_KEY_ACTIVATION_MODE[] = L"activation_mode"; const wchar_t JSON_KEY_DISABLE_ON_SINGLE_MONITOR[] = L"disable_cursor_wrap_on_single_monitor"; } @@ -83,6 +84,7 @@ private: bool m_disableWrapDuringDrag = true; // Default to true to prevent wrap during drag bool m_disableOnSingleMonitor = false; // Default to false int m_wrapMode = 0; // 0=Both (default), 1=VerticalOnly, 2=HorizontalOnly + int m_activationMode = 0; // 0=Always (default), 1=HoldingCtrl (disables wrap), 2=HoldingShift (disables wrap) // Mouse hook HHOOK m_mouseHook = nullptr; @@ -430,6 +432,21 @@ private: Logger::warn("Failed to initialize CursorWrap wrap mode from settings. Will use default value (0=Both)"); } + try + { + // Parse activation mode + auto propertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES); + if (propertiesObject.HasKey(JSON_KEY_ACTIVATION_MODE)) + { + auto activationModeObject = propertiesObject.GetNamedObject(JSON_KEY_ACTIVATION_MODE); + m_activationMode = static_cast(activationModeObject.GetNamedNumber(JSON_KEY_VALUE)); + } + } + catch (...) + { + Logger::warn("Failed to initialize CursorWrap activation mode from settings. Will use default value (0=Always)"); + } + try { // Parse disable on single monitor @@ -672,6 +689,26 @@ private: if (g_cursorWrapInstance && g_cursorWrapInstance->m_hookActive) { + // Check activation mode to determine if wrapping should be disabled + // 0=Always, 1=HoldingCtrl (disables wrap when Ctrl held), 2=HoldingShift (disables wrap when Shift held) + int activationMode = g_cursorWrapInstance->m_activationMode; + bool disableByKey = false; + + if (activationMode == 1) // HoldingCtrl - disable wrap when Ctrl is held + { + disableByKey = (GetAsyncKeyState(VK_CONTROL) & 0x8000) != 0; + } + else if (activationMode == 2) // HoldingShift - disable wrap when Shift is held + { + disableByKey = (GetAsyncKeyState(VK_SHIFT) & 0x8000) != 0; + } + + if (disableByKey) + { + // Key is held, do not wrap - let normal behavior happen + return CallNextHookEx(nullptr, nCode, wParam, lParam); + } + POINT newPos = g_cursorWrapInstance->m_core.HandleMouseMove( currentPos, g_cursorWrapInstance->m_disableWrapDuringDrag, diff --git a/src/settings-ui/Settings.UI.Library/CursorWrapProperties.cs b/src/settings-ui/Settings.UI.Library/CursorWrapProperties.cs index 228cf74998..2e504d75eb 100644 --- a/src/settings-ui/Settings.UI.Library/CursorWrapProperties.cs +++ b/src/settings-ui/Settings.UI.Library/CursorWrapProperties.cs @@ -25,6 +25,9 @@ namespace Microsoft.PowerToys.Settings.UI.Library [JsonPropertyName("wrap_mode")] public IntProperty WrapMode { get; set; } + [JsonPropertyName("activation_mode")] + public IntProperty ActivationMode { get; set; } + [JsonPropertyName("disable_cursor_wrap_on_single_monitor")] public BoolProperty DisableCursorWrapOnSingleMonitor { get; set; } @@ -34,6 +37,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library AutoActivate = new BoolProperty(false); DisableWrapDuringDrag = new BoolProperty(true); WrapMode = new IntProperty(0); // 0=Both (default), 1=VerticalOnly, 2=HorizontalOnly + ActivationMode = new IntProperty(0); // 0=Always (default), 1=HoldingCtrl, 2=HoldingShift DisableCursorWrapOnSingleMonitor = new BoolProperty(false); } } diff --git a/src/settings-ui/Settings.UI.Library/CursorWrapSettings.cs b/src/settings-ui/Settings.UI.Library/CursorWrapSettings.cs index fc918c37db..48655b580e 100644 --- a/src/settings-ui/Settings.UI.Library/CursorWrapSettings.cs +++ b/src/settings-ui/Settings.UI.Library/CursorWrapSettings.cs @@ -56,6 +56,13 @@ namespace Microsoft.PowerToys.Settings.UI.Library settingsUpgraded = true; } + // Add ActivationMode property if it doesn't exist (for users upgrading from older versions) + if (Properties.ActivationMode == null) + { + Properties.ActivationMode = new IntProperty(0); // Default to Always (0=Always, 1=HoldingCtrl, 2=HoldingShift) + settingsUpgraded = true; + } + // Add DisableCursorWrapOnSingleMonitor property if it doesn't exist (for users upgrading from older versions) if (Properties.DisableCursorWrapOnSingleMonitor == null) { diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/MouseUtilsPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/MouseUtilsPage.xaml index 1ded2db636..794cc2055b 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/MouseUtilsPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/MouseUtilsPage.xaml @@ -54,6 +54,13 @@ + + + + + + + diff --git a/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw b/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw index 012b33a1e3..8db269fed3 100644 --- a/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw +++ b/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw @@ -2504,6 +2504,26 @@ From there, simply click on one of the supported files in the File Explorer and Vertical and horizontal + + Wrapping activation + CursorWrap: Label for activation mode dropdown + + + Control when cursor wrapping occurs as the pointer reaches the screen edge. + CursorWrap: Description for activation mode dropdown + + + Always + CursorWrap: Activation mode - always wrap + + + Holding Ctrl + CursorWrap: Activation mode - disable wrap when Ctrl held + + + Holding Shift + CursorWrap: Activation mode - disable wrap when Shift held + Mouse Pointer Crosshairs Mouse as in the hardware peripheral. diff --git a/src/settings-ui/Settings.UI/ViewModels/MouseUtilsViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/MouseUtilsViewModel.cs index 678c090397..cc54869100 100644 --- a/src/settings-ui/Settings.UI/ViewModels/MouseUtilsViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/MouseUtilsViewModel.cs @@ -116,6 +116,9 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels // Null-safe access in case property wasn't upgraded yet - default to 0 (Both) _cursorWrapWrapMode = CursorWrapSettingsConfig.Properties.WrapMode?.Value ?? 0; + // Null-safe access in case property wasn't upgraded yet - default to 0 (Always) + _cursorWrapActivationMode = CursorWrapSettingsConfig.Properties.ActivationMode?.Value ?? 0; + // Null-safe access in case property wasn't upgraded yet - default to false _cursorWrapDisableOnSingleMonitor = CursorWrapSettingsConfig.Properties.DisableCursorWrapOnSingleMonitor?.Value ?? false; @@ -1110,6 +1113,34 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } + public int CursorWrapActivationMode + { + get + { + return _cursorWrapActivationMode; + } + + set + { + if (value != _cursorWrapActivationMode) + { + _cursorWrapActivationMode = value; + + // Ensure the property exists before setting value + if (CursorWrapSettingsConfig.Properties.ActivationMode == null) + { + CursorWrapSettingsConfig.Properties.ActivationMode = new IntProperty(value); + } + else + { + CursorWrapSettingsConfig.Properties.ActivationMode.Value = value; + } + + NotifyCursorWrapPropertyChanged(); + } + } + } + public bool CursorWrapDisableOnSingleMonitor { get @@ -1210,6 +1241,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels private bool _cursorWrapAutoActivate; private bool _cursorWrapDisableWrapDuringDrag; // Will be initialized in constructor from settings private int _cursorWrapWrapMode; // 0=Both, 1=VerticalOnly, 2=HorizontalOnly + private int _cursorWrapActivationMode; // 0=Always, 1=HoldingCtrl (disables wrap), 2=HoldingShift (disables wrap) private bool _cursorWrapDisableOnSingleMonitor; // Disable cursor wrap when only one monitor is connected } } diff --git a/tools/module_loader/src/SettingsLoader.cpp b/tools/module_loader/src/SettingsLoader.cpp index 2d1c869ba1..a5d36f079d 100644 --- a/tools/module_loader/src/SettingsLoader.cpp +++ b/tools/module_loader/src/SettingsLoader.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include SettingsLoader::SettingsLoader() @@ -180,3 +181,723 @@ std::wstring SettingsLoader::LoadSettings(const std::wstring& moduleName, const return L""; } + +std::wstring SettingsLoader::FindSettingsFilePath(const std::wstring& moduleName, const std::wstring& moduleDllPath) +{ + const std::wstring powerToysPrefix = L"PowerToys."; + + std::vector moduleNameVariants; + moduleNameVariants.push_back(moduleName); + + if (moduleName.find(powerToysPrefix) != 0) + { + moduleNameVariants.push_back(powerToysPrefix + moduleName); + } + else + { + moduleNameVariants.push_back(moduleName.substr(powerToysPrefix.length())); + } + + // Try module directory first + if (!moduleDllPath.empty()) + { + std::filesystem::path dllPath(moduleDllPath); + std::filesystem::path dllDirectory = dllPath.parent_path(); + std::wstring localSettingsPath = (dllDirectory / L"settings.json").wstring(); + + if (std::filesystem::exists(localSettingsPath)) + { + return localSettingsPath; + } + } + + // Try standard locations + for (const auto& variant : moduleNameVariants) + { + std::wstring settingsPath = GetSettingsPath(variant); + + if (std::filesystem::exists(settingsPath)) + { + return settingsPath; + } + + // Case-insensitive search + std::wstring root = GetPowerToysSettingsRoot(); + if (!root.empty() && std::filesystem::exists(root)) + { + try + { + for (const auto& entry : std::filesystem::directory_iterator(root)) + { + if (entry.is_directory()) + { + std::wstring dirName = entry.path().filename().wstring(); + if (_wcsicmp(dirName.c_str(), variant.c_str()) == 0) + { + std::wstring actualSettingsPath = entry.path().wstring() + L"\\settings.json"; + if (std::filesystem::exists(actualSettingsPath)) + { + return actualSettingsPath; + } + } + } + } + } + catch (...) {} + } + } + + return L""; +} + +void SettingsLoader::DisplaySettingsInfo(const std::wstring& moduleName, const std::wstring& moduleDllPath) +{ + std::wcout << L"\n"; + std::wcout << L"\033[1;36m"; // Cyan bold + std::wcout << L"+----------------------------------------------------------------+\n"; + std::wcout << L"| MODULE SETTINGS INFO |\n"; + std::wcout << L"+----------------------------------------------------------------+\n"; + std::wcout << L"\033[0m"; + + std::wcout << L"\n\033[1mModule:\033[0m " << moduleName << L"\n"; + + std::wstring settingsPath = FindSettingsFilePath(moduleName, moduleDllPath); + + if (settingsPath.empty()) + { + std::wcout << L"\033[1;33mSettings file:\033[0m Not found\n"; + std::wcout << L"\nNo settings file found for this module.\n"; + return; + } + + std::wcout << L"\033[1mSettings file:\033[0m " << settingsPath << L"\n\n"; + + std::wstring settingsJson = ReadFileContents(settingsPath); + if (settingsJson.empty()) + { + std::wcout << L"Unable to read settings file.\n"; + return; + } + + std::wcout << L"\033[1;32mCurrent Settings:\033[0m\n"; + std::wcout << L"-----------------------------------------------------------------\n"; + + DisplayJsonProperties(settingsJson, 0); + + std::wcout << L"-----------------------------------------------------------------\n\n"; +} + +void SettingsLoader::DisplayJsonProperties(const std::wstring& settingsJson, int indent) +{ + // Simple JSON parser for display - handles the PowerToys settings format + // Format: { "properties": { "key": { "value": ... }, ... } } + // Also handles hotkey settings: { "key": { "win": true, "alt": true, "code": 85 } } + + std::string json(settingsJson.begin(), settingsJson.end()); + + // Find "properties" section + size_t propsStart = json.find("\"properties\""); + if (propsStart == std::string::npos) + { + // If no properties section, just display the raw JSON + std::wcout << settingsJson << L"\n"; + return; + } + + // Find the opening brace after "properties": + size_t braceStart = json.find('{', propsStart + 12); + if (braceStart == std::string::npos) return; + + // Parse each property + size_t pos = braceStart + 1; + int braceCount = 1; + + while (pos < json.size() && braceCount > 0) + { + // Skip whitespace + while (pos < json.size() && std::isspace(json[pos])) pos++; + + // Look for property name + if (json[pos] == '"') + { + size_t nameStart = pos + 1; + size_t nameEnd = json.find('"', nameStart); + if (nameEnd == std::string::npos) break; + + std::string propName = json.substr(nameStart, nameEnd - nameStart); + + // Skip to the value object + pos = json.find('{', nameEnd); + if (pos == std::string::npos) break; + + size_t objStart = pos; + + // Check if this is a hotkey object (has "win", "code" etc. but no "value") + if (IsHotkeyObject(json, objStart)) + { + // Parse hotkey and display + size_t objEnd; + std::string hotkeyStr = ParseHotkeyObject(json, objStart, objEnd); + + std::wstring wPropName(propName.begin(), propName.end()); + std::wstring wHotkeyStr(hotkeyStr.begin(), hotkeyStr.end()); + + std::wcout << L" \033[1;34m" << wPropName << L"\033[0m: "; + std::wcout << L"\033[1;36m" << wHotkeyStr << L"\033[0m\n"; + + pos = objEnd + 1; + continue; + } + + // Regular property with "value" key + int innerBraceCount = 1; + pos++; + + std::string valueStr = ""; + bool foundValue = false; + + while (pos < json.size() && innerBraceCount > 0) + { + if (json[pos] == '{') innerBraceCount++; + else if (json[pos] == '}') innerBraceCount--; + else if (json[pos] == '"' && !foundValue) + { + size_t keyStart = pos + 1; + size_t keyEnd = json.find('"', keyStart); + if (keyEnd != std::string::npos) + { + std::string key = json.substr(keyStart, keyEnd - keyStart); + if (key == "value") + { + // Find the colon and then the value + size_t colonPos = json.find(':', keyEnd); + if (colonPos != std::string::npos) + { + size_t valStart = colonPos + 1; + while (valStart < json.size() && std::isspace(json[valStart])) valStart++; + + // Determine value type and extract + if (json[valStart] == '"') + { + size_t valEnd = json.find('"', valStart + 1); + if (valEnd != std::string::npos) + { + valueStr = json.substr(valStart + 1, valEnd - valStart - 1); + foundValue = true; + } + } + else + { + // Number or boolean + size_t valEnd = valStart; + while (valEnd < json.size() && json[valEnd] != ',' && json[valEnd] != '}' && !std::isspace(json[valEnd])) + { + valEnd++; + } + valueStr = json.substr(valStart, valEnd - valStart); + foundValue = true; + } + } + } + } + pos = keyEnd + 1; + continue; + } + pos++; + } + + // Print the property + std::wstring wPropName(propName.begin(), propName.end()); + std::wstring wValueStr(valueStr.begin(), valueStr.end()); + + std::wcout << L" \033[1;34m" << wPropName << L"\033[0m: "; + + // Color-code based on value type + if (valueStr == "true") + { + std::wcout << L"\033[1;32mtrue\033[0m"; + } + else if (valueStr == "false") + { + std::wcout << L"\033[1;31mfalse\033[0m"; + } + else if (!valueStr.empty() && (std::isdigit(valueStr[0]) || valueStr[0] == '-')) + { + std::wcout << L"\033[1;33m" << wValueStr << L"\033[0m"; + } + else + { + std::wcout << L"\033[1;35m\"" << wValueStr << L"\"\033[0m"; + } + std::wcout << L"\n"; + } + else if (json[pos] == '{') + { + braceCount++; + pos++; + } + else if (json[pos] == '}') + { + braceCount--; + pos++; + } + else + { + pos++; + } + } +} + +std::wstring SettingsLoader::GetSettingValue(const std::wstring& moduleName, const std::wstring& moduleDllPath, const std::wstring& key) +{ + std::wstring settingsPath = FindSettingsFilePath(moduleName, moduleDllPath); + if (settingsPath.empty()) return L""; + + std::wstring settingsJson = ReadFileContents(settingsPath); + if (settingsJson.empty()) return L""; + + // Simple JSON parser to find the specific key + std::string json(settingsJson.begin(), settingsJson.end()); + std::string searchKey(key.begin(), key.end()); + + // Look for "properties" -> key -> "value" + std::string searchPattern = "\"" + searchKey + "\""; + size_t keyPos = json.find(searchPattern); + if (keyPos == std::string::npos) return L""; + + // Find "value" within this property's object + size_t objStart = json.find('{', keyPos); + if (objStart == std::string::npos) return L""; + + size_t valueKeyPos = json.find("\"value\"", objStart); + if (valueKeyPos == std::string::npos) return L""; + + // Find the colon and extract value + size_t colonPos = json.find(':', valueKeyPos); + if (colonPos == std::string::npos) return L""; + + size_t valStart = colonPos + 1; + while (valStart < json.size() && std::isspace(json[valStart])) valStart++; + + std::string valueStr; + if (json[valStart] == '"') + { + size_t valEnd = json.find('"', valStart + 1); + if (valEnd != std::string::npos) + { + valueStr = json.substr(valStart + 1, valEnd - valStart - 1); + } + } + else + { + size_t valEnd = valStart; + while (valEnd < json.size() && json[valEnd] != ',' && json[valEnd] != '}' && !std::isspace(json[valEnd])) + { + valEnd++; + } + valueStr = json.substr(valStart, valEnd - valStart); + } + + return std::wstring(valueStr.begin(), valueStr.end()); +} + +bool SettingsLoader::SetSettingValue(const std::wstring& moduleName, const std::wstring& moduleDllPath, const std::wstring& key, const std::wstring& value) +{ + std::wstring settingsPath = FindSettingsFilePath(moduleName, moduleDllPath); + if (settingsPath.empty()) + { + std::wcerr << L"Error: Settings file not found\n"; + return false; + } + + std::wstring settingsJson = ReadFileContents(settingsPath); + if (settingsJson.empty()) + { + std::wcerr << L"Error: Unable to read settings file\n"; + return false; + } + + std::string json(settingsJson.begin(), settingsJson.end()); + std::string searchKey(key.begin(), key.end()); + std::string newValue(value.begin(), value.end()); + + // Find the property + std::string searchPattern = "\"" + searchKey + "\""; + size_t keyPos = json.find(searchPattern); + if (keyPos == std::string::npos) + { + // Setting not found - prompt user to add it + std::wcout << L"\033[1;33mWarning:\033[0m Setting '" << key << L"' not found in settings file.\n"; + std::wcout << L"This could be a new setting or a typo.\n\n"; + + if (PromptYesNo(L"Do you want to add this as a new setting?")) + { + std::string modifiedJson = AddNewProperty(json, searchKey, newValue); + if (modifiedJson.empty()) + { + std::wcerr << L"Error: Failed to add new property to settings file\n"; + return false; + } + + std::wstring newJson(modifiedJson.begin(), modifiedJson.end()); + if (WriteFileContents(settingsPath, newJson)) + { + std::wcout << L"\033[1;32m+\033[0m New setting '" << key << L"' added with value: " << value << L"\n"; + return true; + } + else + { + std::wcerr << L"Error: Failed to write settings file\n"; + return false; + } + } + else + { + std::wcout << L"Operation cancelled.\n"; + return false; + } + } + + // Find "value" within this property's object + size_t objStart = json.find('{', keyPos); + if (objStart == std::string::npos) return false; + + size_t valueKeyPos = json.find("\"value\"", objStart); + if (valueKeyPos == std::string::npos) return false; + + // Find the colon and the existing value + size_t colonPos = json.find(':', valueKeyPos); + if (colonPos == std::string::npos) return false; + + size_t valStart = colonPos + 1; + while (valStart < json.size() && std::isspace(json[valStart])) valStart++; + + size_t valEnd; + bool isString = (json[valStart] == '"'); + + if (isString) + { + valEnd = json.find('"', valStart + 1); + if (valEnd != std::string::npos) valEnd++; // Include closing quote + } + else + { + valEnd = valStart; + while (valEnd < json.size() && json[valEnd] != ',' && json[valEnd] != '}' && !std::isspace(json[valEnd])) + { + valEnd++; + } + } + + // Determine if new value should be quoted + bool newValueNeedsQuotes = false; + if (newValue != "true" && newValue != "false") + { + // Check if it's a number + bool isNumber = !newValue.empty(); + for (char c : newValue) + { + if (!std::isdigit(c) && c != '.' && c != '-') + { + isNumber = false; + break; + } + } + newValueNeedsQuotes = !isNumber; + } + + std::string replacement; + if (newValueNeedsQuotes) + { + replacement = "\"" + newValue + "\""; + } + else + { + replacement = newValue; + } + + // Replace the value + json = json.substr(0, valStart) + replacement + json.substr(valEnd); + + // Write back + std::wstring newJson(json.begin(), json.end()); + if (WriteFileContents(settingsPath, newJson)) + { + std::wcout << L"\033[1;32m?\033[0m Setting '" << key << L"' updated to: " << value << L"\n"; + return true; + } + else + { + std::wcerr << L"Error: Failed to write settings file\n"; + return false; + } +} + +bool SettingsLoader::WriteFileContents(const std::wstring& filePath, const std::wstring& contents) const +{ + std::ofstream file(filePath, std::ios::binary); + if (!file.is_open()) + { + return false; + } + + std::string utf8Contents(contents.begin(), contents.end()); + file << utf8Contents; + file.close(); + + return true; +} + +bool SettingsLoader::PromptYesNo(const std::wstring& prompt) +{ + std::wcout << prompt << L" [y/N]: "; + std::wcout.flush(); + + std::wstring input; + std::getline(std::wcin, input); + + // Trim whitespace + while (!input.empty() && iswspace(input.front())) input.erase(input.begin()); + while (!input.empty() && iswspace(input.back())) input.pop_back(); + + // Check for yes responses + return !input.empty() && (input[0] == L'y' || input[0] == L'Y'); +} + +std::string SettingsLoader::AddNewProperty(const std::string& json, const std::string& key, const std::string& value) +{ + // Find the "properties" section + size_t propsPos = json.find("\"properties\""); + if (propsPos == std::string::npos) + { + return ""; + } + + // Find the opening brace of properties object + size_t propsStart = json.find('{', propsPos); + if (propsStart == std::string::npos) + { + return ""; + } + + // Find the closing brace of properties object + int braceCount = 1; + size_t pos = propsStart + 1; + size_t propsEnd = std::string::npos; + + while (pos < json.size() && braceCount > 0) + { + if (json[pos] == '{') braceCount++; + else if (json[pos] == '}') + { + braceCount--; + if (braceCount == 0) propsEnd = pos; + } + pos++; + } + + if (propsEnd == std::string::npos) + { + return ""; + } + + // Determine if new value should be quoted + bool needsQuotes = false; + if (value != "true" && value != "false") + { + bool isNumber = !value.empty(); + for (char c : value) + { + if (!std::isdigit(c) && c != '.' && c != '-') + { + isNumber = false; + break; + } + } + needsQuotes = !isNumber; + } + + // Build the new property JSON + // Format: "key": { "value": ... } + std::string valueJson = needsQuotes ? ("\"" + value + "\"") : value; + std::string newProperty = ",\n \"" + key + "\": {\n \"value\": " + valueJson + "\n }"; + + // Check if properties object is empty (only whitespace between braces) + std::string propsContent = json.substr(propsStart + 1, propsEnd - propsStart - 1); + bool isEmpty = true; + for (char c : propsContent) + { + if (!std::isspace(c)) + { + isEmpty = false; + break; + } + } + + // Insert the new property before the closing brace of properties + std::string result; + if (isEmpty) + { + // No leading comma for empty properties + newProperty = "\n \"" + key + "\": {\n \"value\": " + valueJson + "\n }\n "; + } + + result = json.substr(0, propsEnd) + newProperty + json.substr(propsEnd); + return result; +} + +bool SettingsLoader::IsHotkeyObject(const std::string& json, size_t objStart) +{ + // A hotkey object has "win", "alt", "ctrl", "shift", and "code" fields + // Find the end of this object + int braceCount = 1; + size_t pos = objStart + 1; + size_t objEnd = objStart; + + while (pos < json.size() && braceCount > 0) + { + if (json[pos] == '{') braceCount++; + else if (json[pos] == '}') + { + braceCount--; + if (braceCount == 0) objEnd = pos; + } + pos++; + } + + if (objEnd <= objStart) return false; + + std::string objContent = json.substr(objStart, objEnd - objStart + 1); + + // Check for hotkey-specific fields + return (objContent.find("\"win\"") != std::string::npos || + objContent.find("\"code\"") != std::string::npos) && + objContent.find("\"value\"") == std::string::npos; +} + +std::string SettingsLoader::ParseHotkeyObject(const std::string& json, size_t objStart, size_t& objEnd) +{ + // Find the end of this object + int braceCount = 1; + size_t pos = objStart + 1; + objEnd = objStart; + + while (pos < json.size() && braceCount > 0) + { + if (json[pos] == '{') braceCount++; + else if (json[pos] == '}') + { + braceCount--; + if (braceCount == 0) objEnd = pos; + } + pos++; + } + + if (objEnd <= objStart) return ""; + + std::string objContent = json.substr(objStart, objEnd - objStart + 1); + + // Parse hotkey fields + bool win = false, ctrl = false, alt = false, shift = false; + int code = 0; + + // Helper to find boolean value + auto findBool = [&objContent](const std::string& key) -> bool { + size_t keyPos = objContent.find("\"" + key + "\""); + if (keyPos == std::string::npos) return false; + size_t colonPos = objContent.find(':', keyPos); + if (colonPos == std::string::npos) return false; + size_t valStart = colonPos + 1; + while (valStart < objContent.size() && std::isspace(objContent[valStart])) valStart++; + return objContent.substr(valStart, 4) == "true"; + }; + + // Helper to find integer value + auto findInt = [&objContent](const std::string& key) -> int { + size_t keyPos = objContent.find("\"" + key + "\""); + if (keyPos == std::string::npos) return 0; + size_t colonPos = objContent.find(':', keyPos); + if (colonPos == std::string::npos) return 0; + size_t valStart = colonPos + 1; + while (valStart < objContent.size() && std::isspace(objContent[valStart])) valStart++; + size_t valEnd = valStart; + while (valEnd < objContent.size() && (std::isdigit(objContent[valEnd]) || objContent[valEnd] == '-')) + valEnd++; + if (valEnd > valStart) + return std::stoi(objContent.substr(valStart, valEnd - valStart)); + return 0; + }; + + win = findBool("win"); + ctrl = findBool("ctrl"); + alt = findBool("alt"); + shift = findBool("shift"); + code = findInt("code"); + + // Build hotkey string + std::string result; + if (win) result += "Win+"; + if (ctrl) result += "Ctrl+"; + if (alt) result += "Alt+"; + if (shift) result += "Shift+"; + + // Convert virtual key code to key name + if (code > 0) + { + if (code >= 'A' && code <= 'Z') + { + result += static_cast(code); + } + else if (code >= '0' && code <= '9') + { + result += static_cast(code); + } + else + { + // Common VK codes + switch (code) + { + case 0x20: result += "Space"; break; + case 0x0D: result += "Enter"; break; + case 0x1B: result += "Escape"; break; + case 0x09: result += "Tab"; break; + case 0x08: result += "Backspace"; break; + case 0x2E: result += "Delete"; break; + case 0x24: result += "Home"; break; + case 0x23: result += "End"; break; + case 0x21: result += "PageUp"; break; + case 0x22: result += "PageDown"; break; + case 0x25: result += "Left"; break; + case 0x26: result += "Up"; break; + case 0x27: result += "Right"; break; + case 0x28: result += "Down"; break; + case 0x70: case 0x71: case 0x72: case 0x73: case 0x74: case 0x75: + case 0x76: case 0x77: case 0x78: case 0x79: case 0x7A: case 0x7B: + result += "F" + std::to_string(code - 0x70 + 1); + break; + case 0xC0: result += "`"; break; + case 0xBD: result += "-"; break; + case 0xBB: result += "="; break; + case 0xDB: result += "["; break; + case 0xDD: result += "]"; break; + case 0xDC: result += "\\"; break; + case 0xBA: result += ";"; break; + case 0xDE: result += "'"; break; + case 0xBC: result += ","; break; + case 0xBE: result += "."; break; + case 0xBF: result += "/"; break; + default: + result += "VK_0x" + std::to_string(code); + break; + } + } + } + + // Remove trailing + if no key code + if (!result.empty() && result.back() == '+') + { + result.pop_back(); + } + + return result.empty() ? "(not set)" : result; +} diff --git a/tools/module_loader/src/SettingsLoader.h b/tools/module_loader/src/SettingsLoader.h index e005fdd4b2..2042dbab9e 100644 --- a/tools/module_loader/src/SettingsLoader.h +++ b/tools/module_loader/src/SettingsLoader.h @@ -6,6 +6,8 @@ #include #include +#include +#include /// /// Utility class for discovering and loading PowerToy module settings @@ -31,6 +33,40 @@ public: /// Full path to the settings.json file std::wstring GetSettingsPath(const std::wstring& moduleName) const; + /// + /// Display settings information for a module + /// + /// Name of the module + /// Path to the module DLL + void DisplaySettingsInfo(const std::wstring& moduleName, const std::wstring& moduleDllPath); + + /// + /// Get a specific setting value + /// + /// Name of the module + /// Path to the module DLL + /// Setting key to retrieve + /// Value as string, or empty if not found + std::wstring GetSettingValue(const std::wstring& moduleName, const std::wstring& moduleDllPath, const std::wstring& key); + + /// + /// Set a specific setting value + /// + /// Name of the module + /// Path to the module DLL + /// Setting key to set + /// Value to set + /// True if successful + bool SetSettingValue(const std::wstring& moduleName, const std::wstring& moduleDllPath, const std::wstring& key, const std::wstring& value); + + /// + /// Find the actual settings file path (handles case-insensitivity) + /// + /// Name of the module + /// Path to the module DLL + /// Actual path to settings.json, or empty if not found + std::wstring FindSettingsFilePath(const std::wstring& moduleName, const std::wstring& moduleDllPath); + private: /// /// Get the PowerToys root settings directory @@ -44,4 +80,52 @@ private: /// Path to the file /// File contents as a string std::wstring ReadFileContents(const std::wstring& filePath) const; + + /// + /// Write a string to a text file + /// + /// Path to the file + /// Contents to write + /// True if successful + bool WriteFileContents(const std::wstring& filePath, const std::wstring& contents) const; + + /// + /// Parse settings properties from JSON and display them + /// + /// JSON string containing settings + /// Indentation level + void DisplayJsonProperties(const std::wstring& settingsJson, int indent = 0); + + /// + /// Parse a hotkey object from JSON and format it as a string (e.g., "Win+Alt+U") + /// + /// JSON string + /// Start position of the hotkey object + /// Output: end position of the hotkey object + /// Formatted hotkey string, or empty if not a valid hotkey + std::string ParseHotkeyObject(const std::string& json, size_t objStart, size_t& objEnd); + + /// + /// Check if a JSON object appears to be a hotkey settings object + /// + /// JSON string + /// Start position of the object + /// True if this looks like a hotkey object + bool IsHotkeyObject(const std::string& json, size_t objStart); + + /// + /// Prompt user for yes/no confirmation + /// + /// The question to ask + /// True if user answered yes + bool PromptYesNo(const std::wstring& prompt); + + /// + /// Add a new property to the JSON settings file + /// + /// The JSON string to modify + /// The property key to add + /// The value to set + /// Modified JSON string, or empty if failed + std::string AddNewProperty(const std::string& json, const std::string& key, const std::string& value); }; diff --git a/tools/module_loader/src/main.cpp b/tools/module_loader/src/main.cpp index fc9894e623..46cad03640 100644 --- a/tools/module_loader/src/main.cpp +++ b/tools/module_loader/src/main.cpp @@ -7,6 +7,8 @@ #include #include #include +#include +#include #include "ModuleLoader.h" #include "SettingsLoader.h" #include "HotkeyManager.h" @@ -17,9 +19,15 @@ namespace void PrintUsage() { std::wcout << L"PowerToys Module Loader - Standalone utility for loading and testing PowerToy modules\n\n"; - std::wcout << L"Usage: ModuleLoader.exe \n\n"; + std::wcout << L"Usage: ModuleLoader.exe [options]\n\n"; std::wcout << L"Arguments:\n"; std::wcout << L" module_dll_path Path to the PowerToy module DLL (e.g., CursorWrap.dll)\n\n"; + std::wcout << L"Options:\n"; + std::wcout << L" --info Display current module settings and exit\n"; + std::wcout << L" --get Get a specific setting value and exit\n"; + std::wcout << L" --set = Set a setting value (can be used multiple times)\n"; + std::wcout << L" --no-run Apply settings changes without running the module\n"; + std::wcout << L" --help Show this help message\n\n"; std::wcout << L"Behavior:\n"; std::wcout << L" - Automatically discovers settings from %%LOCALAPPDATA%%\\Microsoft\\PowerToys\\\\settings.json\n"; std::wcout << L" - Loads and enables the module\n"; @@ -27,10 +35,12 @@ namespace std::wcout << L" - Runs until Ctrl+C is pressed\n\n"; std::wcout << L"Examples:\n"; std::wcout << L" ModuleLoader.exe x64\\Debug\\modules\\CursorWrap.dll\n"; - std::wcout << L" ModuleLoader.exe \"C:\\Program Files\\PowerToys\\modules\\MouseHighlighter.dll\"\n\n"; + std::wcout << L" ModuleLoader.exe CursorWrap.dll --info\n"; + std::wcout << L" ModuleLoader.exe CursorWrap.dll --get wrap_mode\n"; + std::wcout << L" ModuleLoader.exe CursorWrap.dll --set wrap_mode=1\n"; + std::wcout << L" ModuleLoader.exe CursorWrap.dll --set auto_activate=true --no-run\n\n"; std::wcout << L"Notes:\n"; std::wcout << L" - Only non-UI modules are supported\n"; - std::wcout << L" - Module must have a valid settings.json file\n"; std::wcout << L" - Debug output is written to module's log directory\n"; } @@ -68,13 +78,151 @@ namespace return filename; } + + struct CommandLineOptions + { + std::wstring dllPath; + bool showInfo = false; + bool showHelp = false; + bool noRun = false; + std::wstring getKey; + std::vector> setValues; + }; + + CommandLineOptions ParseCommandLine(int argc, wchar_t* argv[]) + { + CommandLineOptions options; + + for (int i = 1; i < argc; i++) + { + std::wstring arg = argv[i]; + + if (arg == L"--help" || arg == L"-h" || arg == L"/?") + { + options.showHelp = true; + } + else if (arg == L"--info") + { + options.showInfo = true; + } + else if (arg == L"--no-run") + { + options.noRun = true; + } + else if (arg == L"--get" && i + 1 < argc) + { + options.getKey = argv[++i]; + } + else if (arg == L"--set" && i + 1 < argc) + { + std::wstring setValue = argv[++i]; + size_t eqPos = setValue.find(L'='); + if (eqPos != std::wstring::npos) + { + std::wstring key = setValue.substr(0, eqPos); + std::wstring value = setValue.substr(eqPos + 1); + options.setValues.push_back({key, value}); + } + else + { + std::wcerr << L"Warning: Invalid --set format. Use --set key=value\n"; + } + } + else if (arg[0] != L'-' && options.dllPath.empty()) + { + options.dllPath = arg; + } + } + + return options; + } } int wmain(int argc, wchar_t* argv[]) { - std::wcout << L"PowerToys Module Loader v1.0\n"; + // Enable UTF-8 console output for box-drawing characters + SetConsoleOutputCP(CP_UTF8); + + // Enable virtual terminal processing for ANSI escape codes (colors) + HANDLE hOut = GetStdHandle(STD_OUTPUT_HANDLE); + DWORD dwMode = 0; + if (GetConsoleMode(hOut, &dwMode)) + { + SetConsoleMode(hOut, dwMode | ENABLE_VIRTUAL_TERMINAL_PROCESSING); + } + + std::wcout << L"PowerToys Module Loader v1.1\n"; std::wcout << L"=============================\n\n"; + // Parse command-line arguments + auto options = ParseCommandLine(argc, argv); + + if (options.showHelp) + { + PrintUsage(); + return 0; + } + + if (options.dllPath.empty()) + { + std::wcerr << L"Error: Missing required argument \n\n"; + PrintUsage(); + return 1; + } + + // Validate DLL exists + if (!std::filesystem::exists(options.dllPath)) + { + std::wcerr << L"Error: Module DLL not found: " << options.dllPath << L"\n"; + return 1; + } + + // Extract module name from DLL path + std::wstring moduleName = ExtractModuleName(options.dllPath); + + // Create settings loader + SettingsLoader settingsLoader; + + // Handle --info option + if (options.showInfo) + { + settingsLoader.DisplaySettingsInfo(moduleName, options.dllPath); + return 0; + } + + // Handle --get option + if (!options.getKey.empty()) + { + std::wstring value = settingsLoader.GetSettingValue(moduleName, options.dllPath, options.getKey); + if (value.empty()) + { + std::wcerr << L"Setting '" << options.getKey << L"' not found.\n"; + return 1; + } + std::wcout << options.getKey << L"=" << value << L"\n"; + return 0; + } + + // Handle --set options + if (!options.setValues.empty()) + { + bool allSuccess = true; + for (const auto& [key, value] : options.setValues) + { + if (!settingsLoader.SetSettingValue(moduleName, options.dllPath, key, value)) + { + allSuccess = false; + } + } + + if (options.noRun) + { + return allSuccess ? 0 : 1; + } + + std::wcout << L"\n"; + } + // Check if PowerToys.exe is running HANDLE hSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); if (hSnapshot != INVALID_HANDLE_VALUE) @@ -99,26 +247,22 @@ int wmain(int argc, wchar_t* argv[]) if (powerToysRunning) { // Display warning with VT100 colors - // Yellow background (43m), black text (30m), bold (1m) std::wcout << L"\033[1;43;30m WARNING \033[0m PowerToys.exe is currently running!\n\n"; - // Red text for important message std::wcout << L"\033[1;31m"; std::wcout << L"Running ModuleLoader while PowerToys is active may cause conflicts:\n"; std::wcout << L" - Duplicate hotkey registrations\n"; std::wcout << L" - Conflicting module instances\n"; std::wcout << L" - Unexpected behavior\n"; - std::wcout << L"\033[0m\n"; // Reset color + std::wcout << L"\033[0m\n"; - // Cyan text for recommendation std::wcout << L"\033[1;36m"; std::wcout << L"RECOMMENDATION: Exit PowerToys before continuing.\n"; - std::wcout << L"\033[0m\n"; // Reset color + std::wcout << L"\033[0m\n"; - // Yellow text for prompt std::wcout << L"\033[1;33m"; std::wcout << L"Do you want to continue anyway? (y/N): "; - std::wcout << L"\033[0m"; // Reset color + std::wcout << L"\033[0m"; wchar_t response = L'\0'; std::wcin >> response; @@ -133,35 +277,14 @@ int wmain(int argc, wchar_t* argv[]) } } - // Parse command-line arguments - if (argc < 2) - { - std::wcerr << L"Error: Missing required argument \n\n"; - PrintUsage(); - return 1; - } - - const std::wstring dllPath = argv[1]; - - // Validate DLL exists - if (!std::filesystem::exists(dllPath)) - { - std::wcerr << L"Error: Module DLL not found: " << dllPath << L"\n"; - return 1; - } - - std::wcout << L"Loading module: " << dllPath << L"\n"; - - // Extract module name from DLL path - std::wstring moduleName = ExtractModuleName(dllPath); + std::wcout << L"Loading module: " << options.dllPath << L"\n"; std::wcout << L"Detected module name: " << moduleName << L"\n\n"; try { // Load settings for the module std::wcout << L"Loading settings...\n"; - SettingsLoader settingsLoader; - std::wstring settingsJson = settingsLoader.LoadSettings(moduleName, dllPath); + std::wstring settingsJson = settingsLoader.LoadSettings(moduleName, options.dllPath); if (settingsJson.empty()) { @@ -175,7 +298,7 @@ int wmain(int argc, wchar_t* argv[]) // Load the module DLL std::wcout << L"Loading module DLL...\n"; ModuleLoader moduleLoader; - if (!moduleLoader.Load(dllPath)) + if (!moduleLoader.Load(options.dllPath)) { std::wcerr << L"Error: Failed to load module DLL\n"; return 1;