(title, global: true);
+ if (window != null)
+ {
+ return window;
+ }
+ }
+ catch (Exception ex)
+ {
+ // Save the exception, but continue trying other variations
+ lastException = ex;
+ }
+ }
+
+ // If we couldn't find the window with any variation, throw an exception with details
+ throw new InvalidOperationException(
+ $"Failed to find {appType} window with title containing '{baseTitle}'. ");
+ }
+
+ private static void CopySettingsFileBeforeTests()
+ {
+ try
+ {
+ // Determine the assembly location and test files path
+ string? assemblyLocation = Path.GetDirectoryName(typeof(AdvancedPasteUITest).Assembly.Location);
+ if (assemblyLocation == null)
+ {
+ Debug.WriteLine("ERROR: Failed to get assembly location");
+ return;
+ }
+
+ string testFilesFolder = Path.Combine(assemblyLocation, "TestFiles");
+ if (!Directory.Exists(testFilesFolder))
+ {
+ Debug.WriteLine($"ERROR: Test files directory not found at: {testFilesFolder}");
+ return;
+ }
+
+ // Settings file source path
+ string settingsFileName = "settings.json";
+ string sourceSettingsPath = Path.Combine(testFilesFolder, settingsFileName);
+
+ // Make sure the source file exists
+ if (!File.Exists(sourceSettingsPath))
+ {
+ Debug.WriteLine($"ERROR: Settings file not found at: {sourceSettingsPath}");
+ return;
+ }
+
+ // Determine the target directory in %LOCALAPPDATA%
+ string targetDirectory = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "Microsoft",
+ "PowerToys",
+ "AdvancedPaste");
+
+ // Create the directory if it doesn't exist
+ if (!Directory.Exists(targetDirectory))
+ {
+ Directory.CreateDirectory(targetDirectory);
+ }
+
+ string targetSettingsPath = Path.Combine(targetDirectory, settingsFileName);
+
+ // Copy the file and overwrite if it exists
+ File.Copy(sourceSettingsPath, targetSettingsPath, true);
+
+ Debug.WriteLine($"Successfully copied settings file from {sourceSettingsPath} to {targetSettingsPath}");
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"ERROR copying settings file: {ex.Message}");
+ }
+ }
+ }
+}
diff --git a/src/modules/AdvancedPaste/UITest-AdvancedPaste/Helper/FileReader.cs b/src/modules/AdvancedPaste/UITest-AdvancedPaste/Helper/FileReader.cs
new file mode 100644
index 0000000000..d711fe010a
--- /dev/null
+++ b/src/modules/AdvancedPaste/UITest-AdvancedPaste/Helper/FileReader.cs
@@ -0,0 +1,85 @@
+// Copyright (c) Microsoft Corporation
+// The Microsoft Corporation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System;
+using System.IO;
+using System.Text;
+using System.Windows.Forms;
+
+namespace Microsoft.AdvancedPaste.UITests.Helper;
+
+public class FileReader
+{
+ public static string ReadContent(string filePath)
+ {
+ try
+ {
+ return File.ReadAllText(filePath);
+ }
+ catch (Exception ex)
+ {
+ throw new InvalidOperationException($"Failed to read file: {ex.Message}", ex);
+ }
+ }
+
+ public static string ReadRTFPlainText(string filePath)
+ {
+ try
+ {
+ using (var rtb = new System.Windows.Forms.RichTextBox())
+ {
+ rtb.Rtf = File.ReadAllText(filePath);
+ return rtb.Text;
+ }
+ }
+ catch (Exception ex)
+ {
+ throw new InvalidOperationException($"Failed to read plain text from file: {ex.Message}", ex);
+ }
+ }
+
+ ///
+ /// Compares the contents of two RTF files to check if they are consistent.
+ ///
+ /// Path to the first RTF file
+ /// Path to the second RTF file
+ /// If true, compares the raw RTF content (including formatting).
+ /// If false, compares only the plain text content.
+ ///
+ /// A tuple containing: (bool isConsistent, string firstContent, string secondContent)
+ /// - isConsistent: true if the files are consistent according to the comparison method
+ /// - firstContent: the content of the first file
+ /// - secondContent: the content of the second file
+ ///
+ public static (bool IsConsistent, string FirstContent, string SecondContent) CompareRtfFiles(
+ string firstFilePath,
+ string secondFilePath,
+ bool compareFormatting = false)
+ {
+ try
+ {
+ string firstContent, secondContent;
+
+ if (compareFormatting)
+ {
+ // Compare raw RTF content (including formatting)
+ firstContent = ReadContent(firstFilePath);
+ secondContent = ReadContent(secondFilePath);
+ }
+ else
+ {
+ // Compare only the plain text content
+ firstContent = ReadRTFPlainText(firstFilePath);
+ secondContent = ReadRTFPlainText(secondFilePath);
+ }
+
+ bool isConsistent = string.Equals(firstContent, secondContent, StringComparison.Ordinal);
+ return (isConsistent, firstContent, secondContent);
+ }
+ catch (Exception ex)
+ {
+ throw new InvalidOperationException($"Failed to compare RTF files: {ex.Message}", ex);
+ }
+ }
+}
diff --git a/src/modules/AdvancedPaste/UITest-AdvancedPaste/TestFiles/PasteAsJsonFile.xml b/src/modules/AdvancedPaste/UITest-AdvancedPaste/TestFiles/PasteAsJsonFile.xml
new file mode 100644
index 0000000000..90f0a1b454
--- /dev/null
+++ b/src/modules/AdvancedPaste/UITest-AdvancedPaste/TestFiles/PasteAsJsonFile.xml
@@ -0,0 +1,6 @@
+
+ Tove
+ Jani
+ Reminder
+ Don't forget me this weekend!
+
\ No newline at end of file
diff --git a/src/modules/AdvancedPaste/UITest-AdvancedPaste/TestFiles/PasteAsJsonResultFile.txt b/src/modules/AdvancedPaste/UITest-AdvancedPaste/TestFiles/PasteAsJsonResultFile.txt
new file mode 100644
index 0000000000..2bea5fd966
--- /dev/null
+++ b/src/modules/AdvancedPaste/UITest-AdvancedPaste/TestFiles/PasteAsJsonResultFile.txt
@@ -0,0 +1,8 @@
+{
+ "note": {
+ "to": "Tove",
+ "from": "Jani",
+ "heading": "Reminder",
+ "body": "Don't forget me this weekend!"
+ }
+}
\ No newline at end of file
diff --git a/src/modules/AdvancedPaste/UITest-AdvancedPaste/TestFiles/PasteAsMarkdownFile.html b/src/modules/AdvancedPaste/UITest-AdvancedPaste/TestFiles/PasteAsMarkdownFile.html
new file mode 100644
index 0000000000..097b3d4d2b
--- /dev/null
+++ b/src/modules/AdvancedPaste/UITest-AdvancedPaste/TestFiles/PasteAsMarkdownFile.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ The title Attribute
+
+ Mouse over this paragraph, to display the title attribute as a tooltip.
+
+
+
+
\ No newline at end of file
diff --git a/src/modules/AdvancedPaste/UITest-AdvancedPaste/TestFiles/PasteAsMarkdownResultFile.txt b/src/modules/AdvancedPaste/UITest-AdvancedPaste/TestFiles/PasteAsMarkdownResultFile.txt
new file mode 100644
index 0000000000..a383bfdb1b
--- /dev/null
+++ b/src/modules/AdvancedPaste/UITest-AdvancedPaste/TestFiles/PasteAsMarkdownResultFile.txt
@@ -0,0 +1,3 @@
+## The title Attribute
+
+Mouse over this paragraph, to display the title attribute as a tooltip.
\ No newline at end of file
diff --git a/src/modules/AdvancedPaste/UITest-AdvancedPaste/TestFiles/PasteAsPlainTextFilePlain.rtf b/src/modules/AdvancedPaste/UITest-AdvancedPaste/TestFiles/PasteAsPlainTextFilePlain.rtf
new file mode 100644
index 0000000000..c0d8a0402b
Binary files /dev/null and b/src/modules/AdvancedPaste/UITest-AdvancedPaste/TestFiles/PasteAsPlainTextFilePlain.rtf differ
diff --git a/src/modules/AdvancedPaste/UITest-AdvancedPaste/TestFiles/PasteAsPlainTextFilePlainNoRepeat.rtf b/src/modules/AdvancedPaste/UITest-AdvancedPaste/TestFiles/PasteAsPlainTextFilePlainNoRepeat.rtf
new file mode 100644
index 0000000000..67f5ed3dce
Binary files /dev/null and b/src/modules/AdvancedPaste/UITest-AdvancedPaste/TestFiles/PasteAsPlainTextFilePlainNoRepeat.rtf differ
diff --git a/src/modules/AdvancedPaste/UITest-AdvancedPaste/TestFiles/PasteAsPlainTextFileRaw.rtf b/src/modules/AdvancedPaste/UITest-AdvancedPaste/TestFiles/PasteAsPlainTextFileRaw.rtf
new file mode 100644
index 0000000000..be2ac272dd
Binary files /dev/null and b/src/modules/AdvancedPaste/UITest-AdvancedPaste/TestFiles/PasteAsPlainTextFileRaw.rtf differ
diff --git a/src/modules/AdvancedPaste/UITest-AdvancedPaste/TestFiles/settings.json b/src/modules/AdvancedPaste/UITest-AdvancedPaste/TestFiles/settings.json
new file mode 100644
index 0000000000..31ad05c701
--- /dev/null
+++ b/src/modules/AdvancedPaste/UITest-AdvancedPaste/TestFiles/settings.json
@@ -0,0 +1 @@
+{"properties":{"IsAdvancedAIEnabled":{"value":false},"ShowCustomPreview":{"value":true},"CloseAfterLosingFocus":{"value":false},"advanced-paste-ui-hotkey":{"win":true,"ctrl":false,"alt":false,"shift":true,"code":86,"key":""},"paste-as-plain-hotkey":{"win":true,"ctrl":true,"alt":true,"shift":false,"code":79,"key":""},"paste-as-markdown-hotkey":{"win":true,"ctrl":true,"alt":true,"shift":false,"code":77,"key":""},"paste-as-json-hotkey":{"win":true,"ctrl":true,"alt":true,"shift":false,"code":74,"key":""},"custom-actions":{"value":[]},"additional-actions":{"image-to-text":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"paste-as-file":{"isShown":true,"paste-as-txt-file":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"paste-as-png-file":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"paste-as-html-file":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true}},"transcode":{"isShown":true,"transcode-to-mp3":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"transcode-to-mp4":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true}}}},"name":"AdvancedPaste","version":"1"}
\ No newline at end of file
diff --git a/src/modules/AdvancedPaste/UITest-AdvancedPaste/UITestAdvancedPaste.md b/src/modules/AdvancedPaste/UITest-AdvancedPaste/UITestAdvancedPaste.md
new file mode 100644
index 0000000000..202ee43494
--- /dev/null
+++ b/src/modules/AdvancedPaste/UITest-AdvancedPaste/UITestAdvancedPaste.md
@@ -0,0 +1,41 @@
+## [Advanced Paste](tests-checklist-template-advanced-paste-section.md)
+ NOTES:
+ When using Advanced Paste, make sure that window focused while starting/using Advanced paste is text editor or has text input field focused (e.g. Word).
+ * Paste As Plain Text
+ - [x] Copy some rich text (e.g word of the text is different color, another work is bold, underlined, etd.).
+ - [x] Paste the text using standard Windows Ctrl + V shortcut and ensure that rich text is pasted (with all colors, formatting, etc.)
+ - [x] Paste the text using Paste As Plain Text activation shortcut and ensure that plain text without any formatting is pasted.
+ - [x] Paste again the text using standard Windows Ctrl + V shortcut and ensure the text is now pasted plain without formatting as well.
+ - [x] Copy some rich text again.
+ - [x] Open Advanced Paste window using hotkey, click Paste as Plain Text button and confirm that plain text without any formatting is pasted.
+ - [x] Copy some rich text again.
+ - [x] Open Advanced Paste window using hotkey, press Ctrl + 1 and confirm that plain text without any formatting is pasted.
+ * Paste As Markdown
+ - [] Open Settings and set Paste as Markdown directly hotkey
+ - [x] Copy some text (e.g. some HTML text - convertible to Markdown)
+ - [x] Paste the text using set hotkey and confirm that pasted text is converted to markdown
+ - [x] Copy some text (same as in the previous step or different. If nothing is coppied between steps, previously pasted Markdown text will be picked up from clipboard and converted again to nested Markdown).
+ - [x] Open Advanced Paste window using hotkey, click Paste as markdown button and confirm that pasted text is converted to markdown
+ - [x] Copy some text (same as in the previous step or different. If nothing is coppied between steps, previously pasted Markdown text will be picked up from clipboard and converted again to nested Markdown).
+ - [x] Open Advanced Paste window using hotkey, press Ctrl + 2 and confirm that pasted text is converted to markdown
+ * Paste As JSON
+ - [] Open Settings and set Paste as JSON directly hotkey
+ - [x] Copy some XML or CSV text (or any other text, it will be converted to simple JSON object)
+ - [x] Paste the text using set hotkey and confirm that pasted text is converted to JSON
+ - [x] Copy some text (same as in the previous step or different. If nothing is coppied between steps, previously pasted JSON text will be picked up from clipboard and converted again to nested JSON).
+ - [x] Open Advanced Paste window using hotkey, click Paste as markdown button and confirm that pasted text is converted to markdown
+ - [x] Copy some text (same as in the previous step or different. If nothing is coppied between steps, previously pasted JSON text will be picked up from clipboard and converted again to nested JSON).
+ - [x] Open Advanced Paste window using hotkey, press Ctrl + 3 and confirm that pasted text is converted to markdown
+ * Paste as custom format using AI
+ - [] Open Settings, navigate to Enable Paste with AI and set OpenAI key.
+ - [] Copy some text to clipboard. Any text.
+ - [] Open Advanced Paste window using hotkey, and confirm that Custom intput text box is now enabled. Write "Insert smiley after every word" and press Enter. Observe that result preview shows coppied text with smileys between words. Press Enter to paste the result and observe that it is pasted.
+ - [] Open Advanced Paste window using hotkey. Input some query (any, feel free to play around) and press Enter. When result is shown, click regenerate button, to see if new result is generated. Select one of the results and paste. Observe that correct result is pasted.
+ - [] Create few custom actions. Set up hotkey for custom actions and confirm they work. Enable/disable custom actions and confirm that the change is reflected in Advanced Paste UI - custom action is not listed. Try different ctrl + in-app shortcuts for custom actions. Try moving custom actions up/down and confirm that the change is reflected in Advanced Paste UI.
+ - [] Open Settings and disable Custom format preview. Open Advanced Paste window with hotkey, enter some query and press enter. Observe that result is now pasted right away, without showing the preview first.
+ - [] Open Settings and Disable Enable Paste with AI. Open Advanced Paste window with hotkey and observe that Custom Input text box is now disabled.
+ * Clipboard History
+ - [] Open Settings and Enable clipboard history (if not enabled already). Open Advanced Paste window with hotkey, click Clipboard history and try deleting some entry. Check OS clipboard history (Win+V), and confirm that the same entry no longer exist.
+ - [] Open Advanced Paste window with hotkey, click Clipboard history, and click any entry (but first). Observe that entry is put on top of clipboard history. Check OS clipboard history (Win+V), and confirm that the same entry is on top of the clipboard.
+ - [] Open Settings and Disable clipboard history. Open Advanced Paste window with hotkey and observe that Clipboard history button is disabled.
+ * Disable Advanced Paste, try different Advanced Paste hotkeys and confirm that it's disabled and nothing happens.
\ No newline at end of file
diff --git a/src/modules/Hosts/Hosts.FuzzTests/Hosts.FuzzTests.csproj b/src/modules/Hosts/Hosts.FuzzTests/HostsEditor.FuzzTests.csproj
similarity index 100%
rename from src/modules/Hosts/Hosts.FuzzTests/Hosts.FuzzTests.csproj
rename to src/modules/Hosts/Hosts.FuzzTests/HostsEditor.FuzzTests.csproj
diff --git a/src/modules/Hosts/Hosts.FuzzTests/OneFuzzConfig.json b/src/modules/Hosts/Hosts.FuzzTests/OneFuzzConfig.json
index cafb475fc6..6a5b9883f1 100644
--- a/src/modules/Hosts/Hosts.FuzzTests/OneFuzzConfig.json
+++ b/src/modules/Hosts/Hosts.FuzzTests/OneFuzzConfig.json
@@ -4,8 +4,8 @@
{
"fuzzer": {
"$type": "libfuzzerDotNet",
- "dll": "Hosts.FuzzTests.dll",
- "class": "Hosts.FuzzTests.FuzzTests",
+ "dll": "HostsEditor.FuzzTests.dll",
+ "class": "HostsEditor.FuzzTests.FuzzTests",
"method": "FuzzValidIPv4",
"FuzzingTargetBinaries": [
"PowerToys.Hosts.dll"
@@ -35,8 +35,8 @@
// the DLL and PDB files
// you will need to add any other files required
// (globs are supported)
- "Hosts.FuzzTests.dll",
- "Hosts.FuzzTests.pdb",
+ "HostsEditor.FuzzTests.dll",
+ "HostsEditor.FuzzTests.pdb",
"Microsoft.Windows.SDK.NET.dll",
"WinRT.Runtime.dll"
],
@@ -45,8 +45,8 @@
{
"fuzzer": {
"$type": "libfuzzerDotNet",
- "dll": "Hosts.FuzzTests.dll",
- "class": "Hosts.FuzzTests.FuzzTests",
+ "dll": "HostsEditor.FuzzTests.dll",
+ "class": "HostsEditor.FuzzTests.FuzzTests",
"method": "FuzzValidIPv6",
"FuzzingTargetBinaries": [
"PowerToys.Hosts.dll"
@@ -76,8 +76,8 @@
// the DLL and PDB files
// you will need to add any other files required
// (globs are supported)
- "Hosts.FuzzTests.dll",
- "Hosts.FuzzTests.pdb",
+ "HostsEditor.FuzzTests.dll",
+ "HostsEditor.FuzzTests.pdb",
"Microsoft.Windows.SDK.NET.dll",
"WinRT.Runtime.dll"
],
@@ -86,8 +86,8 @@
{
"fuzzer": {
"$type": "libfuzzerDotNet",
- "dll": "Hosts.FuzzTests.dll",
- "class": "Hosts.FuzzTests.FuzzTests",
+ "dll": "HostsEditor.FuzzTests.dll",
+ "class": "HostsEditor.FuzzTests.FuzzTests",
"method": "FuzzValidHosts",
"FuzzingTargetBinaries": [
"PowerToys.Hosts.dll"
@@ -117,8 +117,8 @@
// the DLL and PDB files
// you will need to add any other files required
// (globs are supported)
- "Hosts.FuzzTests.dll",
- "Hosts.FuzzTests.pdb",
+ "HostsEditor.FuzzTests.dll",
+ "HostsEditor.FuzzTests.pdb",
"Microsoft.Windows.SDK.NET.dll",
"WinRT.Runtime.dll"
],
@@ -127,8 +127,8 @@
{
"fuzzer": {
"$type": "libfuzzerDotNet",
- "dll": "Hosts.FuzzTests.dll",
- "class": "Hosts.FuzzTests.FuzzTests",
+ "dll": "HostsEditor.FuzzTests.dll",
+ "class": "HostsEditor.FuzzTests.FuzzTests",
"method": "FuzzWriteAsync",
"FuzzingTargetBinaries": [
"PowerToys.Hosts.dll"
@@ -160,8 +160,8 @@
// (globs are supported)
"Castle.Core.dll",
"CommunityToolkit.Mvvm.dll",
- "Hosts.FuzzTests.dll",
- "Hosts.FuzzTests.pdb",
+ "HostsEditor.FuzzTests.dll",
+ "HostsEditor.FuzzTests.pdb",
"Microsoft.Windows.SDK.NET.dll",
"Moq.dll",
"System.IO.Abstractions.dll",
diff --git a/src/modules/Hosts/Hosts.Tests/Hosts.Tests.csproj b/src/modules/Hosts/Hosts.Tests/HostsEditor.UnitTests.csproj
similarity index 100%
rename from src/modules/Hosts/Hosts.Tests/Hosts.Tests.csproj
rename to src/modules/Hosts/Hosts.Tests/HostsEditor.UnitTests.csproj
diff --git a/src/modules/Hosts/Hosts.Tests/HostsServiceTest.cs b/src/modules/Hosts/Hosts.Tests/HostsServiceTest.cs
index 8eaa37a348..81052fd101 100644
--- a/src/modules/Hosts/Hosts.Tests/HostsServiceTest.cs
+++ b/src/modules/Hosts/Hosts.Tests/HostsServiceTest.cs
@@ -298,5 +298,34 @@ namespace Hosts.Tests
var hidden = fileSystem.FileInfo.New(service.HostsFilePath).Attributes.HasFlag(FileAttributes.Hidden);
Assert.IsTrue(hidden);
}
+
+ [TestMethod]
+ public async Task NoLeadingSpaces_Disabled_RemovesIndent()
+ {
+ var content =
+ @"10.1.1.1 host host.local # comment
+10.1.1.2 host2 host2.local # another comment
+";
+
+ var expected =
+ @"10.1.1.1 host host.local # comment
+10.1.1.2 host2 host2.local # another comment
+# 10.1.1.30 host30 host30.local # new entry
+";
+
+ var fs = new CustomMockFileSystem();
+ var settings = new Mock();
+ settings.Setup(s => s.NoLeadingSpaces).Returns(true);
+ var svc = new HostsService(fs, settings.Object, _elevationHelper.Object);
+ fs.AddFile(svc.HostsFilePath, new MockFileData(content));
+
+ var data = await svc.ReadAsync();
+ var entries = data.Entries.ToList();
+ entries.Add(new Entry(0, "10.1.1.30", "host30 host30.local", "new entry", false));
+ await svc.WriteAsync(data.AdditionalLines, entries);
+
+ var result = fs.GetFile(svc.HostsFilePath);
+ Assert.AreEqual(expected, result.TextContents);
+ }
}
}
diff --git a/src/modules/Hosts/Hosts.UITests/Hosts.UITests.csproj b/src/modules/Hosts/Hosts.UITests/HostsEditor.UITests.csproj
similarity index 100%
rename from src/modules/Hosts/Hosts.UITests/Hosts.UITests.csproj
rename to src/modules/Hosts/Hosts.UITests/HostsEditor.UITests.csproj
diff --git a/src/modules/Hosts/Hosts/Settings/UserSettings.cs b/src/modules/Hosts/Hosts/Settings/UserSettings.cs
index 3530a3f74b..75da5d214d 100644
--- a/src/modules/Hosts/Hosts/Settings/UserSettings.cs
+++ b/src/modules/Hosts/Hosts/Settings/UserSettings.cs
@@ -26,6 +26,8 @@ namespace Hosts.Settings
private bool _loopbackDuplicates;
+ public bool NoLeadingSpaces { get; private set; }
+
public bool LoopbackDuplicates
{
get => _loopbackDuplicates;
@@ -88,6 +90,7 @@ namespace Hosts.Settings
AdditionalLinesPosition = (HostsAdditionalLinesPosition)settings.Properties.AdditionalLinesPosition;
Encoding = (HostsEncoding)settings.Properties.Encoding;
LoopbackDuplicates = settings.Properties.LoopbackDuplicates;
+ NoLeadingSpaces = settings.Properties.NoLeadingSpaces;
}
retry = false;
diff --git a/src/modules/Hosts/HostsUILib/Helpers/HostsService.cs b/src/modules/Hosts/HostsUILib/Helpers/HostsService.cs
index b07eb8f93c..83aa3544b1 100644
--- a/src/modules/Hosts/HostsUILib/Helpers/HostsService.cs
+++ b/src/modules/Hosts/HostsUILib/Helpers/HostsService.cs
@@ -157,7 +157,7 @@ namespace HostsUILib.Helpers
{
lineBuilder.Append('#').Append(' ');
}
- else if (anyDisabled)
+ else if (anyDisabled && !_userSettings.NoLeadingSpaces)
{
lineBuilder.Append(' ').Append(' ');
}
diff --git a/src/modules/Hosts/HostsUILib/Settings/IUserSettings.cs b/src/modules/Hosts/HostsUILib/Settings/IUserSettings.cs
index 21a8e6fa36..46c7a7dab5 100644
--- a/src/modules/Hosts/HostsUILib/Settings/IUserSettings.cs
+++ b/src/modules/Hosts/HostsUILib/Settings/IUserSettings.cs
@@ -19,5 +19,7 @@ namespace HostsUILib.Settings
event EventHandler LoopbackDuplicatesChanged;
public delegate void OpenSettingsFunction();
+
+ public bool NoLeadingSpaces { get; }
}
}
diff --git a/src/modules/MouseUtils/MouseHighlighter/MouseHighlighter.cpp b/src/modules/MouseUtils/MouseHighlighter/MouseHighlighter.cpp
index 25a95f4d39..05670742ec 100644
--- a/src/modules/MouseUtils/MouseHighlighter/MouseHighlighter.cpp
+++ b/src/modules/MouseUtils/MouseHighlighter/MouseHighlighter.cpp
@@ -5,6 +5,7 @@
#include "MouseHighlighter.h"
#include "trace.h"
#include
+#include
#ifdef COMPOSITION
namespace winrt
@@ -49,6 +50,9 @@ private:
void BringToFront();
HHOOK m_mouseHook = NULL;
static LRESULT CALLBACK MouseHookProc(int nCode, WPARAM wParam, LPARAM lParam) noexcept;
+ // Helpers for spotlight overlay
+ float GetDpiScale() const;
+ void UpdateSpotlightMask(float cx, float cy, float radius, bool show);
static constexpr auto m_className = L"MouseHighlighter";
static constexpr auto m_windowTitle = L"PowerToys Mouse Highlighter";
@@ -67,7 +71,14 @@ private:
winrt::CompositionSpriteShape m_leftPointer{ nullptr };
winrt::CompositionSpriteShape m_rightPointer{ nullptr };
winrt::CompositionSpriteShape m_alwaysPointer{ nullptr };
- winrt::CompositionSpriteShape m_spotlightPointer{ nullptr };
+ // Spotlight overlay (mask with soft feathered edge)
+ winrt::SpriteVisual m_overlay{ nullptr };
+ winrt::CompositionMaskBrush m_spotlightMask{ nullptr };
+ winrt::CompositionRadialGradientBrush m_spotlightMaskGradient{ nullptr };
+ winrt::CompositionColorBrush m_spotlightSource{ nullptr };
+ winrt::CompositionColorGradientStop m_maskStopCenter{ nullptr };
+ winrt::CompositionColorGradientStop m_maskStopInner{ nullptr };
+ winrt::CompositionColorGradientStop m_maskStopOuter{ nullptr };
bool m_leftPointerEnabled = true;
bool m_rightPointerEnabled = true;
@@ -123,6 +134,35 @@ bool Highlighter::CreateHighlighter()
m_shape.RelativeSizeAdjustment({ 1.0f, 1.0f });
m_root.Children().InsertAtTop(m_shape);
+ // Create spotlight overlay (soft feather, DPI-aware)
+ m_overlay = m_compositor.CreateSpriteVisual();
+ m_overlay.RelativeSizeAdjustment({ 1.0f, 1.0f });
+ m_spotlightSource = m_compositor.CreateColorBrush(m_alwaysColor);
+ m_spotlightMaskGradient = m_compositor.CreateRadialGradientBrush();
+ m_spotlightMaskGradient.MappingMode(winrt::CompositionMappingMode::Absolute);
+ // Center region fully transparent
+ m_maskStopCenter = m_compositor.CreateColorGradientStop();
+ m_maskStopCenter.Offset(0.0f);
+ m_maskStopCenter.Color(winrt::Windows::UI::ColorHelper::FromArgb(0, 0, 0, 0));
+ // Inner edge of feather (still transparent)
+ m_maskStopInner = m_compositor.CreateColorGradientStop();
+ m_maskStopInner.Offset(0.995f); // will be updated per-radius
+ m_maskStopInner.Color(winrt::Windows::UI::ColorHelper::FromArgb(0, 0, 0, 0));
+ // Outer edge (opaque mask -> overlay visible)
+ m_maskStopOuter = m_compositor.CreateColorGradientStop();
+ m_maskStopOuter.Offset(1.0f);
+ m_maskStopOuter.Color(winrt::Windows::UI::ColorHelper::FromArgb(255, 255, 255, 255));
+ m_spotlightMaskGradient.ColorStops().Append(m_maskStopCenter);
+ m_spotlightMaskGradient.ColorStops().Append(m_maskStopInner);
+ m_spotlightMaskGradient.ColorStops().Append(m_maskStopOuter);
+
+ m_spotlightMask = m_compositor.CreateMaskBrush();
+ m_spotlightMask.Source(m_spotlightSource);
+ m_spotlightMask.Mask(m_spotlightMaskGradient);
+ m_overlay.Brush(m_spotlightMask);
+ m_overlay.IsVisible(false);
+ m_root.Children().InsertAtTop(m_overlay);
+
return true;
}
catch (...)
@@ -165,12 +205,8 @@ void Highlighter::AddDrawingPoint(MouseButton button)
// always
if (m_spotlightMode)
{
- float borderThickness = static_cast(std::hypot(GetSystemMetrics(SM_CXVIRTUALSCREEN), GetSystemMetrics(SM_CYVIRTUALSCREEN)));
- circleGeometry.Radius({ static_cast(borderThickness / 2.0 + m_radius), static_cast(borderThickness / 2.0 + m_radius) });
- circleShape.FillBrush(nullptr);
- circleShape.StrokeBrush(m_compositor.CreateColorBrush(m_alwaysColor));
- circleShape.StrokeThickness(borderThickness);
- m_spotlightPointer = circleShape;
+ UpdateSpotlightMask(static_cast(pt.x), static_cast(pt.y), m_radius, true);
+ return;
}
else
{
@@ -209,20 +245,14 @@ void Highlighter::UpdateDrawingPointPosition(MouseButton button)
}
else
{
- // always
+ // always / spotlight idle
if (m_spotlightMode)
{
- if (m_spotlightPointer)
- {
- m_spotlightPointer.Offset({ static_cast(pt.x), static_cast(pt.y) });
- }
+ UpdateSpotlightMask(static_cast(pt.x), static_cast(pt.y), m_radius, true);
}
- else
+ else if (m_alwaysPointer)
{
- if (m_alwaysPointer)
- {
- m_alwaysPointer.Offset({ static_cast(pt.x), static_cast(pt.y) });
- }
+ m_alwaysPointer.Offset({ static_cast(pt.x), static_cast(pt.y) });
}
}
}
@@ -266,9 +296,9 @@ void Highlighter::ClearDrawingPoint()
{
if (m_spotlightMode)
{
- if (m_spotlightPointer)
+ if (m_overlay)
{
- m_spotlightPointer.StrokeBrush().as().Color(winrt::Windows::UI::ColorHelper::FromArgb(0, 0, 0, 0));
+ m_overlay.IsVisible(false);
}
}
else
@@ -421,7 +451,10 @@ void Highlighter::StopDrawing()
m_leftPointer = nullptr;
m_rightPointer = nullptr;
m_alwaysPointer = nullptr;
- m_spotlightPointer = nullptr;
+ if (m_overlay)
+ {
+ m_overlay.IsVisible(false);
+ }
ShowWindow(m_hwnd, SW_HIDE);
UnhookWindowsHookEx(m_mouseHook);
ClearDrawing();
@@ -452,6 +485,16 @@ void Highlighter::ApplySettings(MouseHighlighterSettings settings)
m_rightPointerEnabled = false;
}
+ // Keep spotlight overlay color updated
+ if (m_spotlightSource)
+ {
+ m_spotlightSource.Color(m_alwaysColor);
+ }
+ if (!m_spotlightMode && m_overlay)
+ {
+ m_overlay.IsVisible(false);
+ }
+
if (instance->m_visible)
{
instance->StopDrawing();
@@ -563,6 +606,43 @@ void Highlighter::Terminate()
}
}
+float Highlighter::GetDpiScale() const
+{
+ return static_cast(GetDpiForWindow(m_hwnd)) / 96.0f;
+}
+
+// Update spotlight radial mask center/radius with DPI-aware feather
+void Highlighter::UpdateSpotlightMask(float cx, float cy, float radius, bool show)
+{
+ if (!m_spotlightMaskGradient)
+ {
+ return;
+ }
+
+ m_spotlightMaskGradient.EllipseCenter({ cx, cy });
+ m_spotlightMaskGradient.EllipseRadius({ radius, radius });
+
+ const float dpiScale = GetDpiScale();
+ // Target a very fine edge: ~1 physical pixel, convert to DIPs: 1 / dpiScale
+ const float featherDip = 1.0f / (dpiScale > 0.0f ? dpiScale : 1.0f);
+ const float safeRadius = (std::max)(radius, 1.0f);
+ const float featherRel = (std::min)(0.25f, featherDip / safeRadius);
+
+ if (m_maskStopInner)
+ {
+ m_maskStopInner.Offset((std::max)(0.0f, 1.0f - featherRel));
+ }
+
+ if (m_spotlightSource)
+ {
+ m_spotlightSource.Color(m_alwaysColor);
+ }
+ if (m_overlay)
+ {
+ m_overlay.IsVisible(show);
+ }
+}
+
#pragma region MouseHighlighter_API
void MouseHighlighterApplySettings(MouseHighlighterSettings settings)
diff --git a/src/modules/MouseUtils/MousePointerCrosshairs/InclusiveCrosshairs.cpp b/src/modules/MouseUtils/MousePointerCrosshairs/InclusiveCrosshairs.cpp
index 937e9bfca3..61e292d7ee 100644
--- a/src/modules/MouseUtils/MousePointerCrosshairs/InclusiveCrosshairs.cpp
+++ b/src/modules/MouseUtils/MousePointerCrosshairs/InclusiveCrosshairs.cpp
@@ -27,6 +27,73 @@ struct InclusiveCrosshairs
void SwitchActivationMode();
void ApplySettings(InclusiveCrosshairsSettings& settings, bool applyToRuntimeObjects);
+public:
+ // Allow external callers to request a position update (thread-safe enqueue)
+ static void RequestUpdatePosition()
+ {
+ if (instance != nullptr)
+ {
+ auto dispatcherQueue = instance->m_dispatcherQueueController.DispatcherQueue();
+ dispatcherQueue.TryEnqueue([]() {
+ if (instance != nullptr)
+ {
+ instance->UpdateCrosshairsPosition();
+ }
+ });
+ }
+ }
+
+ static void EnsureOn()
+ {
+ if (instance != nullptr)
+ {
+ auto dispatcherQueue = instance->m_dispatcherQueueController.DispatcherQueue();
+ dispatcherQueue.TryEnqueue([]() {
+ if (instance != nullptr && !instance->m_drawing)
+ {
+ instance->StartDrawing();
+ }
+ });
+ }
+ }
+
+ static void EnsureOff()
+ {
+ if (instance != nullptr)
+ {
+ auto dispatcherQueue = instance->m_dispatcherQueueController.DispatcherQueue();
+ dispatcherQueue.TryEnqueue([]() {
+ if (instance != nullptr && instance->m_drawing)
+ {
+ instance->StopDrawing();
+ }
+ });
+ }
+ }
+
+ static void SetExternalControl(bool enabled)
+ {
+ if (instance != nullptr)
+ {
+ auto dispatcherQueue = instance->m_dispatcherQueueController.DispatcherQueue();
+ dispatcherQueue.TryEnqueue([enabled]() {
+ if (instance != nullptr)
+ {
+ instance->m_externalControl = enabled;
+ if (enabled && instance->m_mouseHook)
+ {
+ UnhookWindowsHookEx(instance->m_mouseHook);
+ instance->m_mouseHook = NULL;
+ }
+ else if (!enabled && instance->m_drawing && !instance->m_mouseHook)
+ {
+ instance->m_mouseHook = SetWindowsHookEx(WH_MOUSE_LL, MouseHookProc, instance->m_hinstance, 0);
+ }
+ }
+ });
+ }
+ }
+
private:
enum class MouseButton
{
@@ -69,6 +136,7 @@ private:
bool m_drawing = false;
bool m_destroyed = false;
bool m_hiddenCursor = false;
+ bool m_externalControl = false;
void SetAutoHideTimer() noexcept;
// Configurable Settings
@@ -264,9 +332,12 @@ LRESULT CALLBACK InclusiveCrosshairs::MouseHookProc(int nCode, WPARAM wParam, LP
if (nCode >= 0)
{
MSLLHOOKSTRUCT* hookData = reinterpret_cast(lParam);
- if (wParam == WM_MOUSEMOVE)
+ if (instance && !instance->m_externalControl)
{
- instance->UpdateCrosshairsPosition();
+ if (wParam == WM_MOUSEMOVE)
+ {
+ instance->UpdateCrosshairsPosition();
+ }
}
}
return CallNextHookEx(0, nCode, wParam, lParam);
@@ -527,6 +598,26 @@ bool InclusiveCrosshairsIsEnabled()
return (InclusiveCrosshairs::instance != nullptr);
}
+void InclusiveCrosshairsRequestUpdatePosition()
+{
+ InclusiveCrosshairs::RequestUpdatePosition();
+}
+
+void InclusiveCrosshairsEnsureOn()
+{
+ InclusiveCrosshairs::EnsureOn();
+}
+
+void InclusiveCrosshairsEnsureOff()
+{
+ InclusiveCrosshairs::EnsureOff();
+}
+
+void InclusiveCrosshairsSetExternalControl(bool enabled)
+{
+ InclusiveCrosshairs::SetExternalControl(enabled);
+}
+
int InclusiveCrosshairsMain(HINSTANCE hInstance, InclusiveCrosshairsSettings& settings)
{
Logger::info("Starting a crosshairs instance.");
diff --git a/src/modules/MouseUtils/MousePointerCrosshairs/InclusiveCrosshairs.h b/src/modules/MouseUtils/MousePointerCrosshairs/InclusiveCrosshairs.h
index 43456a4326..a6618d85bf 100644
--- a/src/modules/MouseUtils/MousePointerCrosshairs/InclusiveCrosshairs.h
+++ b/src/modules/MouseUtils/MousePointerCrosshairs/InclusiveCrosshairs.h
@@ -31,3 +31,7 @@ void InclusiveCrosshairsDisable();
bool InclusiveCrosshairsIsEnabled();
void InclusiveCrosshairsSwitch();
void InclusiveCrosshairsApplySettings(InclusiveCrosshairsSettings& settings);
+void InclusiveCrosshairsRequestUpdatePosition();
+void InclusiveCrosshairsEnsureOn();
+void InclusiveCrosshairsEnsureOff();
+void InclusiveCrosshairsSetExternalControl(bool enabled);
diff --git a/src/modules/MouseUtils/MousePointerCrosshairs/dllmain.cpp b/src/modules/MouseUtils/MousePointerCrosshairs/dllmain.cpp
index d2273c7efd..3dcee0d6a4 100644
--- a/src/modules/MouseUtils/MousePointerCrosshairs/dllmain.cpp
+++ b/src/modules/MouseUtils/MousePointerCrosshairs/dllmain.cpp
@@ -4,6 +4,15 @@
#include "trace.h"
#include "InclusiveCrosshairs.h"
#include "common/utils/color.h"
+#include
+#include
+#include
+#include
+
+extern void InclusiveCrosshairsRequestUpdatePosition();
+extern void InclusiveCrosshairsEnsureOn();
+extern void InclusiveCrosshairsEnsureOff();
+extern void InclusiveCrosshairsSetExternalControl(bool enabled);
// Non-Localizable strings
namespace
@@ -11,6 +20,7 @@ namespace
const wchar_t JSON_KEY_PROPERTIES[] = L"properties";
const wchar_t JSON_KEY_VALUE[] = L"value";
const wchar_t JSON_KEY_ACTIVATION_SHORTCUT[] = L"activation_shortcut";
+ const wchar_t JSON_KEY_GLIDING_ACTIVATION_SHORTCUT[] = L"gliding_cursor_activation_shortcut";
const wchar_t JSON_KEY_CROSSHAIRS_COLOR[] = L"crosshairs_color";
const wchar_t JSON_KEY_CROSSHAIRS_OPACITY[] = L"crosshairs_opacity";
const wchar_t JSON_KEY_CROSSHAIRS_RADIUS[] = L"crosshairs_radius";
@@ -21,13 +31,15 @@ namespace
const wchar_t JSON_KEY_CROSSHAIRS_IS_FIXED_LENGTH_ENABLED[] = L"crosshairs_is_fixed_length_enabled";
const wchar_t JSON_KEY_CROSSHAIRS_FIXED_LENGTH[] = L"crosshairs_fixed_length";
const wchar_t JSON_KEY_AUTO_ACTIVATE[] = L"auto_activate";
+ const wchar_t JSON_KEY_GLIDE_TRAVEL_SPEED[] = L"gliding_travel_speed";
+ const wchar_t JSON_KEY_GLIDE_DELAY_SPEED[] = L"gliding_delay_speed";
}
extern "C" IMAGE_DOS_HEADER __ImageBase;
HMODULE m_hModule;
-BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved)
+BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID /*lpReserved*/)
{
m_hModule = hModule;
switch (ul_reason_for_call)
@@ -57,8 +69,46 @@ private:
// The PowerToy state.
bool m_enabled = false;
- // Hotkey to invoke the module
- HotkeyEx m_hotkey;
+ // Additional hotkeys (legacy API) to support multiple shortcuts
+ Hotkey m_activationHotkey{}; // Crosshairs toggle
+ Hotkey m_glidingHotkey{}; // Gliding cursor state machine
+
+ // Shared state for worker threads (decoupled from this lifetime)
+ struct State
+ {
+ std::atomic stopX{ false };
+ std::atomic stopY{ false };
+
+ // positions and speeds
+ int currentXPos{ 0 };
+ int currentYPos{ 0 };
+ int currentXSpeed{ 0 }; // pixels per base window
+ int currentYSpeed{ 0 }; // pixels per base window
+ int xPosSnapshot{ 0 }; // xPos captured at end of horizontal scan
+
+ // Fractional accumulators to spread movement across 10ms ticks
+ double xFraction{ 0.0 };
+ double yFraction{ 0.0 };
+
+ // Speeds represent pixels per 200ms (min 5, max 60 enforced by UI/settings)
+ int fastHSpeed{ 30 }; // pixels per base window
+ int slowHSpeed{ 5 }; // pixels per base window
+ int fastVSpeed{ 30 }; // pixels per base window
+ int slowVSpeed{ 5 }; // pixels per base window
+ };
+
+ std::shared_ptr m_state;
+
+ // Worker threads
+ std::thread m_xThread;
+ std::thread m_yThread;
+
+ // Gliding cursor state machine
+ std::atomic m_glideState{ 0 }; // 0..4 like the AHK script
+
+ // Timer configuration: 10ms tick, speeds are defined per 200ms base window
+ static constexpr int kTimerTickMs = 10;
+ static constexpr int kBaseSpeedTickMs = 200; // mapping period for configured pixel counts
// Mouse Pointer Crosshairs specific settings
InclusiveCrosshairsSettings m_inclusiveCrosshairsSettings;
@@ -68,12 +118,17 @@ public:
MousePointerCrosshairs()
{
LoggerHelpers::init_logger(MODULE_NAME, L"ModuleInterface", LogSettings::mousePointerCrosshairsLoggerName);
+ m_state = std::make_shared();
init_settings();
};
// Destroy the powertoy and free memory
virtual void destroy() override
{
+ StopXTimer();
+ StopYTimer();
+ // Release shared state so worker threads (if any) exit when weak_ptr lock fails
+ m_state.reset();
delete this;
}
@@ -107,9 +162,7 @@ public:
// Signal from the Settings editor to call a custom action.
// This can be used to spawn more complex editors.
- virtual void call_custom_action(const wchar_t* action) override
- {
- }
+ virtual void call_custom_action(const wchar_t* /*action*/) override {}
// Called by the runner to pass the updated settings values as a serialized JSON.
virtual void set_config(const wchar_t* config) override
@@ -143,6 +196,9 @@ public:
{
m_enabled = false;
Trace::EnableMousePointerCrosshairs(false);
+ StopXTimer();
+ StopYTimer();
+ m_glideState = 0;
InclusiveCrosshairsDisable();
}
@@ -158,15 +214,249 @@ public:
return false;
}
- virtual std::optional GetHotkeyEx() override
+ // Legacy multi-hotkey support (like CropAndLock)
+ virtual size_t get_hotkeys(Hotkey* buffer, size_t buffer_size) override
{
- return m_hotkey;
+ if (buffer && buffer_size >= 2)
+ {
+ buffer[0] = m_activationHotkey; // Crosshairs toggle
+ buffer[1] = m_glidingHotkey; // Gliding cursor toggle
+ }
+ return 2;
}
- virtual void OnHotkeyEx() override
+ virtual bool on_hotkey(size_t hotkeyId) override
{
- InclusiveCrosshairsSwitch();
+ if (!m_enabled)
+ {
+ return false;
+ }
+
+ if (hotkeyId == 0)
+ {
+ InclusiveCrosshairsSwitch();
+ return true;
+ }
+ if (hotkeyId == 1)
+ {
+ HandleGlidingHotkey();
+ return true;
+ }
+ return false;
}
+
+private:
+ static void LeftClick()
+ {
+ INPUT inputs[2]{};
+ inputs[0].type = INPUT_MOUSE;
+ inputs[0].mi.dwFlags = MOUSEEVENTF_LEFTDOWN;
+ inputs[1].type = INPUT_MOUSE;
+ inputs[1].mi.dwFlags = MOUSEEVENTF_LEFTUP;
+ SendInput(2, inputs, sizeof(INPUT));
+ }
+
+ // Stateless helpers operating on shared State
+ static void PositionCursorX(const std::shared_ptr& s)
+ {
+ int screenW = GetSystemMetrics(SM_CXVIRTUALSCREEN);
+ int screenH = GetSystemMetrics(SM_CYVIRTUALSCREEN);
+ s->currentYPos = screenH / 2;
+
+ // Distribute movement over 10ms ticks to match pixels-per-base-window speeds
+ const double perTick = (static_cast(s->currentXSpeed) * kTimerTickMs) / static_cast(kBaseSpeedTickMs);
+ s->xFraction += perTick;
+ int step = static_cast(s->xFraction);
+ if (step > 0)
+ {
+ s->xFraction -= step;
+ s->currentXPos += step;
+ }
+
+ s->xPosSnapshot = s->currentXPos;
+ if (s->currentXPos >= screenW)
+ {
+ s->currentXPos = 0;
+ s->currentXSpeed = s->fastHSpeed;
+ s->xPosSnapshot = 0;
+ s->xFraction = 0.0; // reset fractional remainder on wrap
+ }
+ SetCursorPos(s->currentXPos, s->currentYPos);
+ // Ensure overlay crosshairs follow immediately
+ InclusiveCrosshairsRequestUpdatePosition();
+ }
+
+ static void PositionCursorY(const std::shared_ptr& s)
+ {
+ int screenH = GetSystemMetrics(SM_CYVIRTUALSCREEN);
+ // Keep X at snapshot
+ // Use s->xPosSnapshot captured during X pass
+
+ // Distribute movement over 10ms ticks to match pixels-per-base-window speeds
+ const double perTick = (static_cast(s->currentYSpeed) * kTimerTickMs) / static_cast(kBaseSpeedTickMs);
+ s->yFraction += perTick;
+ int step = static_cast(s->yFraction);
+ if (step > 0)
+ {
+ s->yFraction -= step;
+ s->currentYPos += step;
+ }
+
+ if (s->currentYPos >= screenH)
+ {
+ s->currentYPos = 0;
+ s->currentYSpeed = s->fastVSpeed;
+ s->yFraction = 0.0; // reset fractional remainder on wrap
+ }
+ SetCursorPos(s->xPosSnapshot, s->currentYPos);
+ // Ensure overlay crosshairs follow immediately
+ InclusiveCrosshairsRequestUpdatePosition();
+ }
+
+ void StartXTimer()
+ {
+ auto s = m_state;
+ if (!s)
+ {
+ return;
+ }
+ s->stopX = false;
+ std::weak_ptr wp = s;
+ m_xThread = std::thread([wp]() {
+ while (true)
+ {
+ auto sp = wp.lock();
+ if (!sp || sp->stopX.load())
+ {
+ break;
+ }
+ PositionCursorX(sp);
+ std::this_thread::sleep_for(std::chrono::milliseconds(kTimerTickMs));
+ }
+ });
+ }
+
+ void StopXTimer()
+ {
+ auto s = m_state;
+ if (s)
+ {
+ s->stopX = true;
+ }
+ if (m_xThread.joinable())
+ {
+ m_xThread.join();
+ }
+ }
+
+ void StartYTimer()
+ {
+ auto s = m_state;
+ if (!s)
+ {
+ return;
+ }
+ s->stopY = false;
+ std::weak_ptr wp = s;
+ m_yThread = std::thread([wp]() {
+ while (true)
+ {
+ auto sp = wp.lock();
+ if (!sp || sp->stopY.load())
+ {
+ break;
+ }
+ PositionCursorY(sp);
+ std::this_thread::sleep_for(std::chrono::milliseconds(kTimerTickMs));
+ }
+ });
+ }
+
+ void StopYTimer()
+ {
+ auto s = m_state;
+ if (s)
+ {
+ s->stopY = true;
+ }
+ if (m_yThread.joinable())
+ {
+ m_yThread.join();
+ }
+ }
+
+ void HandleGlidingHotkey()
+ {
+ auto s = m_state;
+ if (!s)
+ {
+ return;
+ }
+ // Simulate the AHK state machine
+ int state = m_glideState.load();
+ switch (state)
+ {
+ case 0:
+ {
+ // Ensure crosshairs on (do not toggle off if already on)
+ InclusiveCrosshairsEnsureOn();
+ // Disable internal mouse hook so we control position updates explicitly
+ InclusiveCrosshairsSetExternalControl(true);
+
+ s->currentXPos = 0;
+ s->currentXSpeed = s->fastHSpeed;
+ s->xFraction = 0.0;
+ s->yFraction = 0.0;
+ int y = GetSystemMetrics(SM_CYVIRTUALSCREEN) / 2;
+ SetCursorPos(0, y);
+ InclusiveCrosshairsRequestUpdatePosition();
+ m_glideState = 1;
+ StartXTimer();
+ break;
+ }
+ case 1:
+ {
+ // Slow horizontal
+ s->currentXSpeed = s->slowHSpeed;
+ m_glideState = 2;
+ break;
+ }
+ case 2:
+ {
+ // Stop horizontal, start vertical (fast)
+ StopXTimer();
+ s->currentYSpeed = s->fastVSpeed;
+ s->currentYPos = 0;
+ s->yFraction = 0.0;
+ SetCursorPos(s->xPosSnapshot, s->currentYPos);
+ InclusiveCrosshairsRequestUpdatePosition();
+ m_glideState = 3;
+ StartYTimer();
+ break;
+ }
+ case 3:
+ {
+ // Slow vertical
+ s->currentYSpeed = s->slowVSpeed;
+ m_glideState = 4;
+ break;
+ }
+ case 4:
+ default:
+ {
+ // Stop vertical, click, turn crosshairs off, re-enable internal tracking, reset state
+ StopYTimer();
+ m_glideState = 0;
+ LeftClick();
+ InclusiveCrosshairsEnsureOff();
+ InclusiveCrosshairsSetExternalControl(false);
+ s->xFraction = 0.0;
+ s->yFraction = 0.0;
+ break;
+ }
+ }
+ }
+
// Load the settings file.
void init_settings()
{
@@ -192,37 +482,44 @@ public:
{
try
{
- // Parse HotKey
+ // Parse primary activation HotKey (for centralized hook)
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_ACTIVATION_SHORTCUT);
auto hotkey = PowerToysSettings::HotkeyObject::from_json(jsonPropertiesObject);
- m_hotkey = HotkeyEx();
- if (hotkey.win_pressed())
- {
- m_hotkey.modifiersMask |= MOD_WIN;
- }
- if (hotkey.ctrl_pressed())
- {
- m_hotkey.modifiersMask |= MOD_CONTROL;
- }
-
- if (hotkey.shift_pressed())
- {
- m_hotkey.modifiersMask |= MOD_SHIFT;
- }
-
- if (hotkey.alt_pressed())
- {
- m_hotkey.modifiersMask |= MOD_ALT;
- }
-
- m_hotkey.vkCode = hotkey.get_code();
+ // Map to legacy Hotkey for multi-hotkey API
+ m_activationHotkey.win = hotkey.win_pressed();
+ m_activationHotkey.ctrl = hotkey.ctrl_pressed();
+ m_activationHotkey.shift = hotkey.shift_pressed();
+ m_activationHotkey.alt = hotkey.alt_pressed();
+ m_activationHotkey.key = static_cast(hotkey.get_code());
}
catch (...)
{
Logger::warn("Failed to initialize Mouse Pointer Crosshairs activation shortcut");
}
try
+ {
+ // Parse Gliding Cursor HotKey
+ auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_GLIDING_ACTIVATION_SHORTCUT);
+ auto hotkey = PowerToysSettings::HotkeyObject::from_json(jsonPropertiesObject);
+ m_glidingHotkey.win = hotkey.win_pressed();
+ m_glidingHotkey.ctrl = hotkey.ctrl_pressed();
+ m_glidingHotkey.shift = hotkey.shift_pressed();
+ m_glidingHotkey.alt = hotkey.alt_pressed();
+ m_glidingHotkey.key = static_cast(hotkey.get_code());
+ }
+ catch (...)
+ {
+ // note that this is also defined in src\settings-ui\Settings.UI.Library\MousePointerCrosshairsProperties.cs, DefaultGlidingCursorActivationShortcut
+ // both need to be kept in sync!
+ Logger::warn("Failed to initialize Gliding Cursor activation shortcut. Using default Win+Alt+.");
+ m_glidingHotkey.win = true;
+ m_glidingHotkey.alt = true;
+ m_glidingHotkey.ctrl = false;
+ m_glidingHotkey.shift = false;
+ m_glidingHotkey.key = VK_OEM_PERIOD;
+ }
+ try
{
// Parse Opacity
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_CROSSHAIRS_OPACITY);
@@ -272,7 +569,6 @@ public:
{
throw std::runtime_error("Invalid Radius value");
}
-
}
catch (...)
{
@@ -291,7 +587,6 @@ public:
{
throw std::runtime_error("Invalid Thickness value");
}
-
}
catch (...)
{
@@ -320,7 +615,7 @@ public:
{
// Parse border size
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_CROSSHAIRS_BORDER_SIZE);
- int value = static_cast (jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE));
+ int value = static_cast(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE));
if (value >= 0)
{
inclusiveCrosshairsSettings.crosshairsBorderSize = value;
@@ -383,20 +678,86 @@ public:
{
Logger::warn("Failed to initialize auto activate from settings. Will use default value");
}
+ try
+ {
+ // Parse Travel speed (fast speed mapping)
+ auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_GLIDE_TRAVEL_SPEED);
+ int value = static_cast(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE));
+ if (value >= 5 && value <= 60)
+ {
+ m_state->fastHSpeed = value;
+ m_state->fastVSpeed = value;
+ }
+ else if (value < 5)
+ {
+ m_state->fastHSpeed = 5; m_state->fastVSpeed = 5;
+ }
+ else
+ {
+ m_state->fastHSpeed = 60; m_state->fastVSpeed = 60;
+ }
+ }
+ catch (...)
+ {
+ Logger::warn("Failed to initialize gliding travel speed from settings. Using default 25.");
+ if (m_state)
+ {
+ m_state->fastHSpeed = 25;
+ m_state->fastVSpeed = 25;
+ }
+ }
+ try
+ {
+ // Parse Delay speed (slow speed mapping)
+ auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_GLIDE_DELAY_SPEED);
+ int value = static_cast(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE));
+ if (value >= 5 && value <= 60)
+ {
+ m_state->slowHSpeed = value;
+ m_state->slowVSpeed = value;
+ }
+ else if (value < 5)
+ {
+ m_state->slowHSpeed = 5; m_state->slowVSpeed = 5;
+ }
+ else
+ {
+ m_state->slowHSpeed = 60; m_state->slowVSpeed = 60;
+ }
+ }
+ catch (...)
+ {
+ Logger::warn("Failed to initialize gliding delay speed from settings. Using default 5.");
+ if (m_state)
+ {
+ m_state->slowHSpeed = 5;
+ m_state->slowVSpeed = 5;
+ }
+ }
}
else
{
Logger::info("Mouse Pointer Crosshairs settings are empty");
}
- if (!m_hotkey.modifiersMask)
+
+ if (m_activationHotkey.key == 0)
{
- Logger::info("Mouse Pointer Crosshairs is going to use default shortcut");
- m_hotkey.modifiersMask = MOD_WIN | MOD_ALT;
- m_hotkey.vkCode = 0x50; // P key
+ m_activationHotkey.win = true;
+ m_activationHotkey.alt = true;
+ m_activationHotkey.ctrl = false;
+ m_activationHotkey.shift = false;
+ m_activationHotkey.key = 'P';
+ }
+ if (m_glidingHotkey.key == 0)
+ {
+ m_glidingHotkey.win = true;
+ m_glidingHotkey.alt = true;
+ m_glidingHotkey.ctrl = false;
+ m_glidingHotkey.shift = false;
+ m_glidingHotkey.key = VK_OEM_PERIOD;
}
m_inclusiveCrosshairsSettings = inclusiveCrosshairsSettings;
}
-
};
extern "C" __declspec(dllexport) PowertoyModuleIface* __cdecl powertoy_create()
diff --git a/src/modules/MouseWithoutBorders/ModuleInterface/dllmain.cpp b/src/modules/MouseWithoutBorders/ModuleInterface/dllmain.cpp
index 33030fbdfb..29d7a781ae 100644
--- a/src/modules/MouseWithoutBorders/ModuleInterface/dllmain.cpp
+++ b/src/modules/MouseWithoutBorders/ModuleInterface/dllmain.cpp
@@ -556,6 +556,61 @@ public:
return m_enabled;
}
+ virtual size_t get_hotkeys(Hotkey* hotkeys, size_t buffer_size) override
+ {
+ constexpr size_t num_hotkeys = 4; // We have 4 hotkeys
+
+ if (hotkeys && buffer_size >= num_hotkeys)
+ {
+ PowerToysSettings::PowerToyValues values = PowerToysSettings::PowerToyValues::load_from_settings_file(MODULE_NAME);
+
+ // Cache the raw JSON object to avoid multiple parsing
+ json::JsonObject root_json = values.get_raw_json();
+ json::JsonObject properties_json = root_json.GetNamedObject(L"properties", json::JsonObject{});
+
+ size_t hotkey_index = 0;
+
+ // Helper lambda to extract hotkey from JSON properties
+ auto extract_hotkey = [&](const wchar_t* property_name) -> Hotkey {
+ if (properties_json.HasKey(property_name))
+ {
+ try
+ {
+ json::JsonObject hotkey_json = properties_json.GetNamedObject(property_name);
+
+ // Extract hotkey properties directly from JSON
+ bool win = hotkey_json.GetNamedBoolean(L"win", false);
+ bool ctrl = hotkey_json.GetNamedBoolean(L"ctrl", false);
+ bool alt = hotkey_json.GetNamedBoolean(L"alt", false);
+ bool shift = hotkey_json.GetNamedBoolean(L"shift", false);
+ unsigned char key = static_cast(
+ hotkey_json.GetNamedNumber(L"code", 0));
+
+ return { win, ctrl, shift, alt, key };
+ }
+ catch (...)
+ {
+ // If parsing individual hotkey fails, use defaults
+ return { false, false, false, false, 0 };
+ }
+ }
+ else
+ {
+ // Property doesn't exist, use defaults
+ return { false, false, false, false, 0 };
+ }
+ };
+
+ // Extract all hotkeys using the optimized helper
+ hotkeys[hotkey_index++] = extract_hotkey(L"ToggleEasyMouseShortcut"); // [0] Toggle Easy Mouse
+ hotkeys[hotkey_index++] = extract_hotkey(L"LockMachineShortcut"); // [1] Lock Machine
+ hotkeys[hotkey_index++] = extract_hotkey(L"Switch2AllPCShortcut"); // [2] Switch to All PCs
+ hotkeys[hotkey_index++] = extract_hotkey(L"ReconnectShortcut"); // [3] Reconnect
+ }
+
+ return num_hotkeys;
+ }
+
void launch_add_firewall_process()
{
Logger::trace(L"Starting Process to add firewall rule");
diff --git a/src/modules/PowerOCR/PowerOCR-UITests/PowerOCR.UITests.csproj b/src/modules/PowerOCR/PowerOCR-UITests/PowerOCR.UITests.csproj
new file mode 100644
index 0000000000..c2a51bb332
--- /dev/null
+++ b/src/modules/PowerOCR/PowerOCR-UITests/PowerOCR.UITests.csproj
@@ -0,0 +1,19 @@
+
+
+
+ PowerOCR.UITests
+ enable
+ enable
+ Library
+ false
+
+
+
+ ..\..\..\..\$(Platform)\$(Configuration)\tests\PowerOCR.UITests\
+
+
+
+
+
+
+
diff --git a/src/modules/PowerOCR/PowerOCR-UITests/PowerOCRTests.cs b/src/modules/PowerOCR/PowerOCR-UITests/PowerOCRTests.cs
new file mode 100644
index 0000000000..926729542a
--- /dev/null
+++ b/src/modules/PowerOCR/PowerOCR-UITests/PowerOCRTests.cs
@@ -0,0 +1,59 @@
+// Copyright (c) Microsoft Corporation
+// The Microsoft Corporation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using Microsoft.PowerToys.UITest;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using static Microsoft.PowerToys.UITest.UITestBase;
+
+namespace PowerOCR.UITests;
+
+[TestClass]
+public class PowerOCRTests : UITestBase
+{
+ public PowerOCRTests()
+ : base(PowerToysModule.PowerToysSettings, WindowSize.Medium)
+ {
+ }
+
+ [TestInitialize]
+ public void TestInitialize()
+ {
+ if (FindAll("Text Extractor").Count == 0)
+ {
+ // Expand Advanced list-group if needed
+ Find("System Tools").Click();
+ }
+
+ Find("Text Extractor").Click();
+
+ Find("Enable Text Extractor").Toggle(true);
+
+ SendKeys(Key.Win, Key.D);
+ }
+
+ [TestMethod("PowerOCR.DetectTextExtractor")]
+ [TestCategory("PowerOCR Detection")]
+ public void DetectTextExtractorTest()
+ {
+ try
+ {
+ SendKeys(Key.Win, Key.Shift, Key.T);
+
+ Thread.Sleep(5000);
+
+ var textExtractorWindow = Find("TextExtractor", 10000, true);
+
+ Assert.IsNotNull(textExtractorWindow, "TextExtractor window should be found after hotkey activation");
+
+ Console.WriteLine("✓ TextExtractor window detected successfully after hotkey activation");
+
+ SendKeys(Key.Esc);
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Failed to detect TextExtractor window: {ex.Message}");
+ Assert.Fail("TextExtractor window was not found after hotkey activation");
+ }
+ }
+}
diff --git a/src/modules/Workspaces/WorkspacesEditorUITest/WorkspacesEditorUITest.csproj b/src/modules/Workspaces/WorkspacesEditorUITest/Workspaces.Editor.UITests.csproj
similarity index 100%
rename from src/modules/Workspaces/WorkspacesEditorUITest/WorkspacesEditorUITest.csproj
rename to src/modules/Workspaces/WorkspacesEditorUITest/Workspaces.Editor.UITests.csproj
diff --git a/src/modules/Workspaces/WorkspacesLib.UnitTests/WorkspacesLibUnitTests.vcxproj b/src/modules/Workspaces/WorkspacesLib.UnitTests/WorkspacesLibUnitTests.vcxproj
index e3d18f54f3..14f87ef729 100644
--- a/src/modules/Workspaces/WorkspacesLib.UnitTests/WorkspacesLibUnitTests.vcxproj
+++ b/src/modules/Workspaces/WorkspacesLib.UnitTests/WorkspacesLibUnitTests.vcxproj
@@ -5,7 +5,7 @@
{A85D4D9F-9A39-4B5D-8B5A-9F2D5C9A8B4C}
Win32Proj
WorkspacesLibUnitTests
- WorkspacesLibUnitTests
+ Workspaces.Lib.UnitTests