[Cursor Wrap] Update edge wrap model, update simulator, add cursor logging, add settings support to ModuleLoader (#45915)

This PR adds new options for disabling wrap, updates the wrapping model,
extends the simulator and cursor logging.

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

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

The PR adds a new option for disabling cursor wrapping, exposing three
options: None - wrapping is not disabled, Ctrl key - if this is pressed
then wrapping is disabled, Shift key - if this is pressed then wrapping
is disabled, this would enable a user to temporarily disable wrapping if
they wanted to get close to a monitor edge without wrapping (auto-hide
status bar for example).

The cursor wrap edge model has been updated to mirror Windows
monitor-to-monitor cursor movement, this should ensure there aren't any
non-wrappable edges.

A new test tool has been added 'CursorLog' this is a monitor aware,
dpi/scaling aware Win32 application that captures mouse movement across
monitors to a log file, the log contains one line per mouse movement
which includes: Monitor, x, y, scale, dpi.

The wrapping simulator has been updated to include the new wrapping
model and support mouse cursor log playback.

## Validation Steps Performed
The updated CursorWrap has been tested on a single monitor (laptop) and
multi-monitor desktop PC with monitors being offset to test
edge/wrapping behavior.

---------

Co-authored-by: Mike Hall <mikehall@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: vanzue <vanzue@outlook.com>
This commit is contained in:
Niels Laute
2026-03-04 14:56:32 +01:00
committed by GitHub
parent d28f312b81
commit 86860df314
23 changed files with 4658 additions and 67 deletions

View File

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

View File

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

View File

@@ -13,13 +13,11 @@
<PropertyGroup Condition="'$(Configuration)'=='Debug'" Label="Configuration">
<ConfigurationType>DynamicLibrary</ConfigurationType>
<UseDebugLibraries>true</UseDebugLibraries>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)'=='Release'" Label="Configuration">
<ConfigurationType>DynamicLibrary</ConfigurationType>
<UseDebugLibraries>false</UseDebugLibraries>
<WholeProgramOptimization>true</WholeProgramOptimization>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
@@ -112,12 +110,8 @@
</ProjectReference>
</ItemGroup>
<ItemGroup>
<None Include="COMPLETE_REWRITE_SUMMARY.md" />
<None Include="CRITICAL_BUG_ANALYSIS.md" />
<None Include="CURSOR_WRAP_FIX_ANALYSIS.md" />
<None Include="DEBUG_GUIDE.md" />
<None Include="CursorWrapTests\WrapSimulator\test_new_algorithm.py" />
<None Include="packages.config" />
<None Include="VERTICAL_WRAP_BUG_FIX.md" />
</ItemGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
<ImportGroup Label="ExtensionTargets">

View File

@@ -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>(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;
}

View File

@@ -8,6 +8,24 @@
#include <string>
#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<MonitorInfo> 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;
};

View File

@@ -0,0 +1,7 @@
<Solution>
<Configurations>
<Platform Name="x64" />
<Platform Name="x86" />
</Configurations>
<Project Path="CursorLog/CursorLog.vcxproj" Id="646f6684-9f11-42cd-8b35-b2954404f985" />
</Solution>

View File

@@ -0,0 +1,196 @@
// CursorLog.cpp : Monitors mouse position and logs to file with monitor/DPI info
//
#include <iostream>
#include <fstream>
#include <string>
#include <filesystem>
#include <Windows.h>
#include <ShellScalingApi.h>
#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<int>(deviceName.length()), nullptr, 0, nullptr, nullptr);
std::string result(sizeNeeded, 0);
WideCharToMultiByte(CP_UTF8, 0, deviceName.c_str(), static_cast<int>(deviceName.length()), &result[0], sizeNeeded, nullptr, nullptr);
return result;
}
// Calculate scale factor from DPI
constexpr double GetScaleFactor(UINT dpi)
{
return static_cast<double>(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<MSLLHOOKSTRUCT*>(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<int>(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 <output_path_and_filename>" << 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;
}

View File

@@ -0,0 +1,135 @@
<?xml version="1.0" encoding="utf-8"?>
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<ItemGroup Label="ProjectConfigurations">
<ProjectConfiguration Include="Debug|Win32">
<Configuration>Debug</Configuration>
<Platform>Win32</Platform>
</ProjectConfiguration>
<ProjectConfiguration Include="Release|Win32">
<Configuration>Release</Configuration>
<Platform>Win32</Platform>
</ProjectConfiguration>
<ProjectConfiguration Include="Debug|x64">
<Configuration>Debug</Configuration>
<Platform>x64</Platform>
</ProjectConfiguration>
<ProjectConfiguration Include="Release|x64">
<Configuration>Release</Configuration>
<Platform>x64</Platform>
</ProjectConfiguration>
</ItemGroup>
<PropertyGroup Label="Globals">
<VCProjectVersion>18.0</VCProjectVersion>
<Keyword>Win32Proj</Keyword>
<ProjectGuid>{646f6684-9f11-42cd-8b35-b2954404f985}</ProjectGuid>
<RootNamespace>CursorLog</RootNamespace>
<WindowsTargetPlatformVersion>10.0</WindowsTargetPlatformVersion>
</PropertyGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" />
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'" Label="Configuration">
<ConfigurationType>Application</ConfigurationType>
<UseDebugLibraries>true</UseDebugLibraries>
<PlatformToolset>v145</PlatformToolset>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|Win32'" Label="Configuration">
<ConfigurationType>Application</ConfigurationType>
<UseDebugLibraries>false</UseDebugLibraries>
<PlatformToolset>v145</PlatformToolset>
<WholeProgramOptimization>true</WholeProgramOptimization>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'" Label="Configuration">
<ConfigurationType>Application</ConfigurationType>
<UseDebugLibraries>true</UseDebugLibraries>
<PlatformToolset>v145</PlatformToolset>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'" Label="Configuration">
<ConfigurationType>Application</ConfigurationType>
<UseDebugLibraries>false</UseDebugLibraries>
<PlatformToolset>v145</PlatformToolset>
<WholeProgramOptimization>true</WholeProgramOptimization>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />
<ImportGroup Label="ExtensionSettings">
</ImportGroup>
<ImportGroup Label="Shared">
</ImportGroup>
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'">
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
</ImportGroup>
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Release|Win32'">
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
</ImportGroup>
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
</ImportGroup>
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Release|x64'">
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
</ImportGroup>
<PropertyGroup Label="UserMacros" />
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'">
<ClCompile>
<WarningLevel>Level3</WarningLevel>
<SDLCheck>true</SDLCheck>
<PreprocessorDefinitions>WIN32;_DEBUG;_CONSOLE;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<ConformanceMode>true</ConformanceMode>
<LanguageStandard>stdcpp20</LanguageStandard><PrecompiledHeader>NotUsing</PrecompiledHeader>
</ClCompile>
<Link>
<SubSystem>Console</SubSystem>
<GenerateDebugInformation>true</GenerateDebugInformation>
</Link>
</ItemDefinitionGroup>
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|Win32'">
<ClCompile>
<WarningLevel>Level3</WarningLevel>
<FunctionLevelLinking>true</FunctionLevelLinking>
<IntrinsicFunctions>true</IntrinsicFunctions>
<SDLCheck>true</SDLCheck>
<PreprocessorDefinitions>WIN32;NDEBUG;_CONSOLE;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<ConformanceMode>true</ConformanceMode>
<LanguageStandard>stdcpp20</LanguageStandard><PrecompiledHeader>NotUsing</PrecompiledHeader>
</ClCompile>
<Link>
<SubSystem>Console</SubSystem>
<GenerateDebugInformation>true</GenerateDebugInformation>
</Link>
</ItemDefinitionGroup>
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">
<ClCompile>
<WarningLevel>Level3</WarningLevel>
<SDLCheck>true</SDLCheck>
<PreprocessorDefinitions>_DEBUG;_CONSOLE;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<ConformanceMode>true</ConformanceMode>
<LanguageStandard>stdcpp20</LanguageStandard><PrecompiledHeader>NotUsing</PrecompiledHeader>
</ClCompile>
<Link>
<SubSystem>Console</SubSystem>
<GenerateDebugInformation>true</GenerateDebugInformation>
</Link>
</ItemDefinitionGroup>
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'">
<ClCompile>
<WarningLevel>Level3</WarningLevel>
<FunctionLevelLinking>true</FunctionLevelLinking>
<IntrinsicFunctions>true</IntrinsicFunctions>
<SDLCheck>true</SDLCheck>
<PreprocessorDefinitions>NDEBUG;_CONSOLE;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<ConformanceMode>true</ConformanceMode>
<LanguageStandard>stdcpp20</LanguageStandard><PrecompiledHeader>NotUsing</PrecompiledHeader>
</ClCompile>
<Link>
<SubSystem>Console</SubSystem>
<GenerateDebugInformation>true</GenerateDebugInformation>
</Link>
</ItemDefinitionGroup>
<ItemGroup>
<ClCompile Include="CursorLog.cpp" />
</ItemGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
<ImportGroup Label="ExtensionTargets">
</ImportGroup>
</Project>

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<ItemGroup>
<Filter Include="Source Files">
<UniqueIdentifier>{4FC737F1-C7A5-4376-A066-2A32D752A2FF}</UniqueIdentifier>
<Extensions>cpp;c;cc;cxx;c++;cppm;ixx;def;odl;idl;hpj;bat;asm;asmx</Extensions>
</Filter>
<Filter Include="Header Files">
<UniqueIdentifier>{93995380-89BD-4b04-88EB-625FBE52EBFB}</UniqueIdentifier>
<Extensions>h;hh;hpp;hxx;h++;hm;inl;inc;ipp;xsd</Extensions>
</Filter>
<Filter Include="Resource Files">
<UniqueIdentifier>{67DA6AB6-F800-4c08-8B7A-83BB121AAD01}</UniqueIdentifier>
<Extensions>rc;ico;cur;bmp;dlg;rc2;rct;bin;rgs;gif;jpg;jpeg;jpe;resx;tiff;tif;png;wav;mfcribbon-ms</Extensions>
</Filter>
</ItemGroup>
<ItemGroup>
<ClCompile Include="CursorLog.cpp">
<Filter>Source Files</Filter>
</ClCompile>
</ItemGroup>
</Project>

View File

@@ -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 <path_to_monitor_layout.json>
```
### 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.

View File

@@ -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())

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,7 @@
#include "pch.h"
#include "MonitorTopology.h"
#include "CursorWrapCore.h" // For CursorDirection struct
#include "../../../common/logger/logger.h"
#include <algorithm>
#include <cmath>
@@ -13,6 +14,7 @@ void MonitorTopology::Initialize(const std::vector<MonitorInfo>& 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<EdgeType>& 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 (candidate == prioritizedEdge) continue;
if (oppositeEdge.monitorIndex >= 0)
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;
@@ -281,15 +380,13 @@ POINT MonitorTopology::GetWrapDestination(HMONITOR fromMonitor, const POINT& cur
const MonitorEdge& fromEdge = it->second;
// 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);
// Get cursor coordinate perpendicular to the edge
int cursorCoord = (edgeType == EdgeType::Left || edgeType == EdgeType::Right) ? cursorPos.y : cursorPos.x;
// Find opposite outer edge
MonitorEdge oppositeEdge = FindOppositeOuterEdge(edgeType,
(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);
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
{
// Vertical edge -> horizontal movement
result.y = oppositeEdge.position;
result.x = GetAbsolutePosition(oppositeEdge, relativePos);
// Overlapping region - preserve Y coordinate
result.y = cursorPos.y;
}
}
else
{
// 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<int>(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<int>(result);
}
std::vector<MonitorTopology::GapInfo> MonitorTopology::DetectMonitorGaps() const
{
std::vector<GapInfo> gaps;

View File

@@ -7,6 +7,9 @@
#include <vector>
#include <map>
// 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<EdgeType>& candidates,
const CursorDirection* direction) const;
};

View File

@@ -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<int>(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,

View File

@@ -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);
}
}

View File

@@ -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)
{

View File

@@ -54,6 +54,13 @@
<ComboBoxItem x:Uid="MouseUtils_CursorWrap_WrapMode_HorizontalOnly" />
</ComboBox>
</tkcontrols:SettingsCard>
<tkcontrols:SettingsCard Name="MouseUtilsCursorWrapActivationMode" x:Uid="MouseUtils_CursorWrap_ActivationMode">
<ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" SelectedIndex="{x:Bind Path=ViewModel.CursorWrapActivationMode, Mode=TwoWay}">
<ComboBoxItem x:Uid="MouseUtils_CursorWrap_ActivationMode_Always" />
<ComboBoxItem x:Uid="MouseUtils_CursorWrap_ActivationMode_HoldingCtrl" />
<ComboBoxItem x:Uid="MouseUtils_CursorWrap_ActivationMode_HoldingShift" />
</ComboBox>
</tkcontrols:SettingsCard>
<tkcontrols:SettingsCard ContentAlignment="Left" IsEnabled="{x:Bind ViewModel.IsCursorWrapEnabled, Mode=OneWay}">
<CheckBox x:Uid="MouseUtils_CursorWrap_DisableOnSingleMonitor" IsChecked="{x:Bind ViewModel.CursorWrapDisableOnSingleMonitor, Mode=TwoWay}" />
</tkcontrols:SettingsCard>

View File

@@ -2504,6 +2504,26 @@ From there, simply click on one of the supported files in the File Explorer and
<data name="MouseUtils_CursorWrap_WrapMode_Both.Content" xml:space="preserve">
<value>Vertical and horizontal</value>
</data>
<data name="MouseUtils_CursorWrap_ActivationMode.Header" xml:space="preserve">
<value>Wrapping activation</value>
<comment>CursorWrap: Label for activation mode dropdown</comment>
</data>
<data name="MouseUtils_CursorWrap_ActivationMode.Description" xml:space="preserve">
<value>Control when cursor wrapping occurs as the pointer reaches the screen edge.</value>
<comment>CursorWrap: Description for activation mode dropdown</comment>
</data>
<data name="MouseUtils_CursorWrap_ActivationMode_Always.Content" xml:space="preserve">
<value>Always</value>
<comment>CursorWrap: Activation mode - always wrap</comment>
</data>
<data name="MouseUtils_CursorWrap_ActivationMode_HoldingCtrl.Content" xml:space="preserve">
<value>Holding Ctrl</value>
<comment>CursorWrap: Activation mode - disable wrap when Ctrl held</comment>
</data>
<data name="MouseUtils_CursorWrap_ActivationMode_HoldingShift.Content" xml:space="preserve">
<value>Holding Shift</value>
<comment>CursorWrap: Activation mode - disable wrap when Shift held</comment>
</data>
<data name="Oobe_MouseUtils_MousePointerCrosshairs.Text" xml:space="preserve">
<value>Mouse Pointer Crosshairs</value>
<comment>Mouse as in the hardware peripheral.</comment>

View File

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

View File

@@ -7,6 +7,7 @@
#include <fstream>
#include <sstream>
#include <filesystem>
#include <cwctype>
#include <Shlobj.h>
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<std::wstring> 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<char>(code);
}
else if (code >= '0' && code <= '9')
{
result += static_cast<char>(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;
}

View File

@@ -6,6 +6,8 @@
#include <Windows.h>
#include <string>
#include <vector>
#include <utility>
/// <summary>
/// Utility class for discovering and loading PowerToy module settings
@@ -31,6 +33,40 @@ public:
/// <returns>Full path to the settings.json file</returns>
std::wstring GetSettingsPath(const std::wstring& moduleName) const;
/// <summary>
/// Display settings information for a module
/// </summary>
/// <param name="moduleName">Name of the module</param>
/// <param name="moduleDllPath">Path to the module DLL</param>
void DisplaySettingsInfo(const std::wstring& moduleName, const std::wstring& moduleDllPath);
/// <summary>
/// Get a specific setting value
/// </summary>
/// <param name="moduleName">Name of the module</param>
/// <param name="moduleDllPath">Path to the module DLL</param>
/// <param name="key">Setting key to retrieve</param>
/// <returns>Value as string, or empty if not found</returns>
std::wstring GetSettingValue(const std::wstring& moduleName, const std::wstring& moduleDllPath, const std::wstring& key);
/// <summary>
/// Set a specific setting value
/// </summary>
/// <param name="moduleName">Name of the module</param>
/// <param name="moduleDllPath">Path to the module DLL</param>
/// <param name="key">Setting key to set</param>
/// <param name="value">Value to set</param>
/// <returns>True if successful</returns>
bool SetSettingValue(const std::wstring& moduleName, const std::wstring& moduleDllPath, const std::wstring& key, const std::wstring& value);
/// <summary>
/// Find the actual settings file path (handles case-insensitivity)
/// </summary>
/// <param name="moduleName">Name of the module</param>
/// <param name="moduleDllPath">Path to the module DLL</param>
/// <returns>Actual path to settings.json, or empty if not found</returns>
std::wstring FindSettingsFilePath(const std::wstring& moduleName, const std::wstring& moduleDllPath);
private:
/// <summary>
/// Get the PowerToys root settings directory
@@ -44,4 +80,52 @@ private:
/// <param name="filePath">Path to the file</param>
/// <returns>File contents as a string</returns>
std::wstring ReadFileContents(const std::wstring& filePath) const;
/// <summary>
/// Write a string to a text file
/// </summary>
/// <param name="filePath">Path to the file</param>
/// <param name="contents">Contents to write</param>
/// <returns>True if successful</returns>
bool WriteFileContents(const std::wstring& filePath, const std::wstring& contents) const;
/// <summary>
/// Parse settings properties from JSON and display them
/// </summary>
/// <param name="settingsJson">JSON string containing settings</param>
/// <param name="indent">Indentation level</param>
void DisplayJsonProperties(const std::wstring& settingsJson, int indent = 0);
/// <summary>
/// Parse a hotkey object from JSON and format it as a string (e.g., "Win+Alt+U")
/// </summary>
/// <param name="json">JSON string</param>
/// <param name="objStart">Start position of the hotkey object</param>
/// <param name="objEnd">Output: end position of the hotkey object</param>
/// <returns>Formatted hotkey string, or empty if not a valid hotkey</returns>
std::string ParseHotkeyObject(const std::string& json, size_t objStart, size_t& objEnd);
/// <summary>
/// Check if a JSON object appears to be a hotkey settings object
/// </summary>
/// <param name="json">JSON string</param>
/// <param name="objStart">Start position of the object</param>
/// <returns>True if this looks like a hotkey object</returns>
bool IsHotkeyObject(const std::string& json, size_t objStart);
/// <summary>
/// Prompt user for yes/no confirmation
/// </summary>
/// <param name="prompt">The question to ask</param>
/// <returns>True if user answered yes</returns>
bool PromptYesNo(const std::wstring& prompt);
/// <summary>
/// Add a new property to the JSON settings file
/// </summary>
/// <param name="json">The JSON string to modify</param>
/// <param name="key">The property key to add</param>
/// <param name="value">The value to set</param>
/// <returns>Modified JSON string, or empty if failed</returns>
std::string AddNewProperty(const std::string& json, const std::string& key, const std::string& value);
};

View File

@@ -7,6 +7,8 @@
#include <iostream>
#include <string>
#include <filesystem>
#include <vector>
#include <utility>
#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 <module_dll_path>\n\n";
std::wcout << L"Usage: ModuleLoader.exe <module_dll_path> [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 <key> Get a specific setting value and exit\n";
std::wcout << L" --set <key>=<val> 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\\<ModuleName>\\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<std::pair<std::wstring, std::wstring>> 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 <module_dll_path>\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 <module_dll_path>\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;