diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt index 7e460dba2f..004d9f4269 100644 --- a/.github/actions/spell-check/expect.txt +++ b/.github/actions/spell-check/expect.txt @@ -25,11 +25,14 @@ ADMINS adml admx advancedpaste +advancedpasteui +advancedpasteuishortcut advfirewall AFeature affordances AFX AGGREGATABLE +AHK AHybrid akv ALarger @@ -40,6 +43,7 @@ ALLINPUT Allman Allmodule ALLOWUNDO +allpc ALLVIEW ALPHATYPE AModifier @@ -629,6 +633,7 @@ HKCU hkey HKLM HKM +hkmng HKPD HKU HMD @@ -646,7 +651,11 @@ Hostx hotfixes hotkeycontrol HOTKEYF +hotkeylockmachine +hotkeyreconnect hotkeys +hotkeyswitch +hotkeytoggleeasymouse hotlight hotspot HPAINTBUFFER @@ -659,6 +668,7 @@ HROW hsb HSCROLL hsi +HSpeed HTCLIENT hthumbnail HTOUCHINPUT @@ -704,9 +714,12 @@ IMAGERESIZERCONTEXTMENU IMAGERESIZEREXT imageresizerinput imageresizersettings +imagetotext +imagetotextshortcut imagingdevices ime imgflip +inapp inbox INCONTACT Indo @@ -760,6 +773,7 @@ istep ith ITHUMBNAIL IUI +IUWP IWIC jfif jgeosdfsdsgmkedfgdfgdfgbkmhcgcflmi @@ -789,6 +803,7 @@ keyvault KILLFOCUS killrunner kmph +kvp Kybd lastcodeanalysissucceeded LASTEXITCODE @@ -827,6 +842,7 @@ localappdata localpackage LOCALSYSTEM LOCATIONCHANGE +LOCKMACHINE LOCKTYPE LOGFONT LOGFONTW @@ -912,6 +928,7 @@ MDL mdtext mdtxt mdwn +measuretool meme memicmp MENUITEMINFO @@ -961,6 +978,7 @@ MOUSEHWHEEL MOUSEINPUT mousejump mousepointer +mousepointercrosshairs mouseutils MOVESIZEEND MOVESIZESTART @@ -1100,6 +1118,7 @@ NOTSRCCOPY NOTSRCERASE notwindows NOTXORPEN +nowarn NOZORDER NPH npmjs @@ -1161,6 +1180,18 @@ PARENTRELATIVEFORADDRESSBAR PARENTRELATIVEPARSING parray PARTIALCONFIRMATIONDIALOGTITLE +pasteashtmlfile +pasteashtmlfileshortcut +pasteasjson +pasteasjsonshortcut +pasteasmarkdown +pasteasmarkdownshortcut +pasteasplaintext +pasteasplaintextshortcut +pasteaspngfile +pasteaspngfileshortcut +pasteastxtfile +pasteastxtfileshortcut PATCOPY PATHMUSTEXIST PATINVERT @@ -1228,6 +1259,7 @@ Pomodoro Popups POPUPWINDOW POSITIONITEM +powerocr POWERRENAMECONTEXTMENU powerrenameinput POWERRENAMETEST @@ -1368,6 +1400,7 @@ Removelnk renamable RENAMEONCOLLISION reparented +reparenthotkey reparenting reportfileaccesses requery @@ -1617,6 +1650,7 @@ STYLECHANGED STYLECHANGING subkeys sublang +Subdomain SUBMODULEUPDATE subresource Superbar @@ -1687,6 +1721,7 @@ THH THICKFRAME THISCOMPONENT throughs +thumbnailhotkey TILEDWINDOW TILLSON timedate @@ -1701,6 +1736,7 @@ tlb tlbimp tlc TNP +TOGGLEEASYMOUSE Toolhelp toolkitconverters toolwindow @@ -1714,6 +1750,7 @@ tracelogging tracerpt trackbar trafficmanager +transcodetomp transicc TRAYMOUSEMESSAGE triaging @@ -1828,6 +1865,7 @@ VSINSTALLDIR VSM vso vsonline +VSpeed vstemplate vstest VSTHRD @@ -1964,10 +2002,13 @@ XNamespace Xoshiro XPels XPixel +XPos XResource xsi +XSpeed XStr xstyler +XTimer XUP XVIRTUALSCREEN xxxxxx @@ -1977,7 +2018,10 @@ YIncrement yinle yinyue YPels +YPos YResolution +YSpeed +YTimer YStr YVIRTUALSCREEN ZEROINIT diff --git a/.pipelines/verifyNoticeMdAgainstNugetPackages.ps1 b/.pipelines/verifyNoticeMdAgainstNugetPackages.ps1 index e3120836c8..af9ab8ff6f 100644 --- a/.pipelines/verifyNoticeMdAgainstNugetPackages.ps1 +++ b/.pipelines/verifyNoticeMdAgainstNugetPackages.ps1 @@ -57,12 +57,19 @@ $totalList = $projFiles | ForEach-Object -Parallel { $p = -split $p $p = $p[1, 2] - $tempString = $p[0] + " " + $p[1] + $tempString = $p[0] - if(![string]::IsNullOrWhiteSpace($tempString)) + if([string]::IsNullOrWhiteSpace($tempString)) { - echo "- $tempString"; + Continue } + + if($tempString.StartsWith("Microsoft.") -Or $tempString.StartsWith("System.")) + { + Continue + } + + echo "- $tempString" } $csproj = $null; } diff --git a/.pipelines/verifyNugetPackages.ps1 b/.pipelines/verifyNugetPackages.ps1 index 54d0137121..c7cebbd383 100644 --- a/.pipelines/verifyNugetPackages.ps1 +++ b/.pipelines/verifyNugetPackages.ps1 @@ -21,4 +21,13 @@ if (-not $?) exit 1 } +# Ignore NU1503 on vcxproj files +dotnet restore $solution /nowarn:NU1503 +if ($lastExitCode -ne 0) +{ + $result = $lastExitCode + Write-Error "Error running dotnet restore, with the exit code $lastExitCode. Please verify logs on the nuget package versions." + exit $result +} + exit 0 diff --git a/Directory.Packages.props b/Directory.Packages.props index 8e7a47bf95..47546d9337 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -65,7 +65,8 @@ - + + @@ -103,7 +104,7 @@ - + @@ -113,4 +114,4 @@ - \ No newline at end of file + diff --git a/NOTICE.md b/NOTICE.md index 4e9ae35711..bedc11379d 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -1491,93 +1491,50 @@ SOFTWARE. ## NuGet Packages used by PowerToys -- AdaptiveCards.ObjectModel.WinUI3 2.0.0-beta -- AdaptiveCards.Rendering.WinUI3 2.1.0-beta -- AdaptiveCards.Templating 2.0.5 -- Appium.WebDriver 4.4.5 -- Azure.AI.OpenAI 1.0.0-beta.17 -- CoenM.ImageSharp.ImageHash 1.3.6 -- CommunityToolkit.Common 8.4.0 -- CommunityToolkit.Labs.WinUI.Controls.MarkdownTextBlock 0.1.250703-build.2173 -- CommunityToolkit.Mvvm 8.4.0 -- CommunityToolkit.WinUI.Animations 8.2.250402 -- CommunityToolkit.WinUI.Collections 8.2.250402 -- CommunityToolkit.WinUI.Controls.Primitives 8.2.250402 -- CommunityToolkit.WinUI.Controls.Segmented 8.2.250402 -- CommunityToolkit.WinUI.Controls.SettingsControls 8.2.250402 -- CommunityToolkit.WinUI.Controls.Sizers 8.2.250402 -- CommunityToolkit.WinUI.Converters 8.2.250402 -- CommunityToolkit.WinUI.Extensions 8.2.250402 -- CommunityToolkit.WinUI.UI.Controls.DataGrid 7.1.2 -- CommunityToolkit.WinUI.UI.Controls.Markdown 7.1.2 -- ControlzEx 6.0.0 -- HelixToolkit 2.24.0 -- HelixToolkit.Core.Wpf 2.24.0 -- hyjiacan.pinyin4net 4.1.1 -- Interop.Microsoft.Office.Interop.OneNote 1.1.0.2 -- LazyCache 2.4.0 -- Mages 3.0.0 -- Markdig.Signed 0.34.0 -- MessagePack 3.1.3 -- Microsoft.Bcl.AsyncInterfaces 10.0.0-preview.7.25380.108 -- Microsoft.Bot.AdaptiveExpressions.Core 4.23.0 -- Microsoft.CodeAnalysis.NetAnalyzers 10.0.0-preview.25380.108 -- Microsoft.Data.Sqlite 10.0.0-preview.7.25380.108 -- Microsoft.Diagnostics.Tracing.TraceEvent 3.1.16 -- Microsoft.DotNet.ILCompiler (A) -- Microsoft.Extensions.DependencyInjection 10.0.0-preview.7.25380.108 -- Microsoft.Extensions.Hosting 10.0.0-preview.7.25380.108 -- Microsoft.Extensions.Hosting.WindowsServices 10.0.0-preview.7.25380.108 -- Microsoft.Extensions.Logging 10.0.0-preview.7.25380.108 -- Microsoft.Extensions.Logging.Abstractions 10.0.0-preview.7.25380.108 -- Microsoft.NET.ILLink.Tasks (A) -- Microsoft.SemanticKernel 1.15.0 -- Microsoft.Toolkit.Uwp.Notifications 7.1.2 -- Microsoft.Web.WebView2 1.0.2903.40 -- Microsoft.Win32.SystemEvents 10.0.0-preview.7.25380.108 -- Microsoft.Windows.Compatibility 10.0.0-preview.7.25380.108 -- Microsoft.Windows.CsWin32 0.3.183 -- Microsoft.Windows.CsWinRT 2.2.0 -- Microsoft.Windows.SDK.BuildTools 10.0.26100.4188 -- Microsoft.WindowsAppSDK 1.7.250513003 -- Microsoft.WindowsPackageManager.ComInterop 1.10.340 -- Microsoft.Xaml.Behaviors.WinUI.Managed 2.0.9 -- Microsoft.Xaml.Behaviors.Wpf 1.1.39 -- ModernWpfUI 0.9.4 -- Moq 4.18.4 -- MSTest 3.8.3 -- NLog.Extensions.Logging 5.3.8 -- NLog.Schema 5.2.8 -- OpenAI 2.0.0 -- ReverseMarkdown 4.1.0 -- ScipBe.Common.Office.OneNote 3.0.1 -- SharpCompress 0.37.2 -- SkiaSharp.Views.WinUI 2.88.9 -- StreamJsonRpc 2.21.69 -- StyleCop.Analyzers 1.2.0-beta.556 -- System.CodeDom 10.0.0-preview.7.25380.108 -- System.CommandLine 2.0.0-beta4.22272.1 -- System.ComponentModel.Composition 10.0.0-preview.7.25380.108 -- System.Configuration.ConfigurationManager 10.0.0-preview.7.25380.108 -- System.Data.OleDb 10.0.0-preview.7.25380.108 -- System.Data.SqlClient 4.9.0 -- System.Diagnostics.EventLog 10.0.0-preview.7.25380.108 -- System.Diagnostics.PerformanceCounter 10.0.0-preview.7.25380.108 -- System.Drawing.Common 10.0.0-preview.7.25380.108 -- System.IO.Abstractions 22.0.13 -- System.IO.Abstractions.TestingHelpers 22.0.13 -- System.Management 10.0.0-preview.7.25380.108 -- System.Net.Http 4.3.4 -- System.Private.Uri 4.3.2 -- System.Reactive 6.0.1 -- System.Runtime.Caching 10.0.0-preview.7.25380.108 -- System.ServiceProcess.ServiceController 10.0.0-preview.7.25380.108 -- System.Text.Encoding.CodePages 10.0.0-preview.7.25380.108 -- System.Text.Json 10.0.0-preview.7.25380.108 -- System.Text.RegularExpressions 4.3.1 -- UnicodeInformation 2.6.0 -- UnitsNet 5.56.0 -- UTF.Unknown 2.5.1 -- WinUIEx 2.2.0 -- WPF-UI 3.0.5 -- WyHash 1.0.5 +- AdaptiveCards.ObjectModel.WinUI3 +- AdaptiveCards.Rendering.WinUI3 +- AdaptiveCards.Templating +- Appium.WebDriver +- Azure.AI.OpenAI +- CoenM.ImageSharp.ImageHash +- CommunityToolkit.Common +- CommunityToolkit.Labs.WinUI.Controls.MarkdownTextBlock +- CommunityToolkit.Mvvm +- CommunityToolkit.WinUI.Animations +- CommunityToolkit.WinUI.Collections +- CommunityToolkit.WinUI.Controls.Primitives +- CommunityToolkit.WinUI.Controls.Segmented +- CommunityToolkit.WinUI.Controls.SettingsControls +- CommunityToolkit.WinUI.Controls.Sizers +- CommunityToolkit.WinUI.Converters +- CommunityToolkit.WinUI.Extensions +- CommunityToolkit.WinUI.UI.Controls.DataGrid +- CommunityToolkit.WinUI.UI.Controls.Markdown +- ControlzEx +- HelixToolkit +- HelixToolkit.Core.Wpf +- hyjiacan.pinyin4net +- Interop.Microsoft.Office.Interop.OneNote +- LazyCache +- Mages +- Markdig.Signed +- MessagePack +- ModernWpfUI +- Moq +- MSTest +- NLog +- NLog.Extensions.Logging +- NLog.Schema +- OpenAI +- ReverseMarkdown +- ScipBe.Common.Office.OneNote +- SharpCompress +- SkiaSharp.Views.WinUI +- StreamJsonRpc +- StyleCop.Analyzers +- UnicodeInformation +- UnitsNet +- UTF.Unknown +- WinUIEx +- WPF-UI +- WyHash \ No newline at end of file diff --git a/PowerToys.sln b/PowerToys.sln index 00986aae29..2b6d42305f 100644 --- a/PowerToys.sln +++ b/PowerToys.sln @@ -262,6 +262,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "utils", "utils", "{B39DC643 src\common\utils\EventLocker.h = src\common\utils\EventLocker.h src\common\utils\EventWaiter.h = src\common\utils\EventWaiter.h src\common\utils\excluded_apps.h = src\common\utils\excluded_apps.h + src\common\utils\shell_ext_registration.h = src\common\utils\shell_ext_registration.h src\common\utils\exec.h = src\common\utils\exec.h src\common\utils\game_mode.h = src\common\utils\game_mode.h src\common\utils\gpo.h = src\common\utils\gpo.h @@ -788,6 +789,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.CmdPal.Ext.Window EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.CmdPal.Ext.UnitTestBase", "src\modules\cmdpal\Tests\Microsoft.CmdPal.Ext.UnitTestsBase\Microsoft.CmdPal.Ext.UnitTestBase.csproj", "{00D8659C-2068-40B6-8B86-759CD6284BBB}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.CmdPal.Ext.Apps.UnitTests", "src\modules\cmdpal\Tests\Microsoft.CmdPal.Ext.Apps.UnitTests\Microsoft.CmdPal.Ext.Apps.UnitTests.csproj", "{E816D7B1-4688-4ECB-97CC-3D8E798F3830}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.CmdPal.Ext.Bookmarks.UnitTests", "src\modules\cmdpal\Tests\Microsoft.CmdPal.Ext.Bookmarks.UnitTests\Microsoft.CmdPal.Ext.Bookmarks.UnitTests.csproj", "{E816D7B3-4688-4ECB-97CC-3D8E798F3832}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.CmdPal.Ext.WebSearch.UnitTests", "src\modules\cmdpal\Tests\Microsoft.CmdPal.Ext.WebSearch.UnitTests\Microsoft.CmdPal.Ext.WebSearch.UnitTests.csproj", "{E816D7B2-4688-4ECB-97CC-3D8E798F3831}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.CmdPal.Ext.Shell.UnitTests", "src\modules\cmdpal\Tests\Microsoft.CmdPal.Ext.Shell.UnitTests\Microsoft.CmdPal.Ext.Shell.UnitTests.csproj", "{E816D7B4-4688-4ECB-97CC-3D8E798F3833}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|ARM64 = Debug|ARM64 @@ -2850,6 +2859,38 @@ Global {00D8659C-2068-40B6-8B86-759CD6284BBB}.Release|ARM64.Build.0 = Release|ARM64 {00D8659C-2068-40B6-8B86-759CD6284BBB}.Release|x64.ActiveCfg = Release|x64 {00D8659C-2068-40B6-8B86-759CD6284BBB}.Release|x64.Build.0 = Release|x64 + {E816D7B1-4688-4ECB-97CC-3D8E798F3830}.Debug|ARM64.ActiveCfg = Debug|ARM64 + {E816D7B1-4688-4ECB-97CC-3D8E798F3830}.Debug|ARM64.Build.0 = Debug|ARM64 + {E816D7B1-4688-4ECB-97CC-3D8E798F3830}.Debug|x64.ActiveCfg = Debug|x64 + {E816D7B1-4688-4ECB-97CC-3D8E798F3830}.Debug|x64.Build.0 = Debug|x64 + {E816D7B1-4688-4ECB-97CC-3D8E798F3830}.Release|ARM64.ActiveCfg = Release|ARM64 + {E816D7B1-4688-4ECB-97CC-3D8E798F3830}.Release|ARM64.Build.0 = Release|ARM64 + {E816D7B1-4688-4ECB-97CC-3D8E798F3830}.Release|x64.ActiveCfg = Release|x64 + {E816D7B1-4688-4ECB-97CC-3D8E798F3830}.Release|x64.Build.0 = Release|x64 + {E816D7B3-4688-4ECB-97CC-3D8E798F3832}.Debug|ARM64.ActiveCfg = Debug|ARM64 + {E816D7B3-4688-4ECB-97CC-3D8E798F3832}.Debug|ARM64.Build.0 = Debug|ARM64 + {E816D7B3-4688-4ECB-97CC-3D8E798F3832}.Debug|x64.ActiveCfg = Debug|x64 + {E816D7B3-4688-4ECB-97CC-3D8E798F3832}.Debug|x64.Build.0 = Debug|x64 + {E816D7B3-4688-4ECB-97CC-3D8E798F3832}.Release|ARM64.ActiveCfg = Release|ARM64 + {E816D7B3-4688-4ECB-97CC-3D8E798F3832}.Release|ARM64.Build.0 = Release|ARM64 + {E816D7B3-4688-4ECB-97CC-3D8E798F3832}.Release|x64.ActiveCfg = Release|x64 + {E816D7B3-4688-4ECB-97CC-3D8E798F3832}.Release|x64.Build.0 = Release|x64 + {E816D7B2-4688-4ECB-97CC-3D8E798F3831}.Debug|ARM64.ActiveCfg = Debug|ARM64 + {E816D7B2-4688-4ECB-97CC-3D8E798F3831}.Debug|ARM64.Build.0 = Debug|ARM64 + {E816D7B2-4688-4ECB-97CC-3D8E798F3831}.Debug|x64.ActiveCfg = Debug|x64 + {E816D7B2-4688-4ECB-97CC-3D8E798F3831}.Debug|x64.Build.0 = Debug|x64 + {E816D7B2-4688-4ECB-97CC-3D8E798F3831}.Release|ARM64.ActiveCfg = Release|ARM64 + {E816D7B2-4688-4ECB-97CC-3D8E798F3831}.Release|ARM64.Build.0 = Release|ARM64 + {E816D7B2-4688-4ECB-97CC-3D8E798F3831}.Release|x64.ActiveCfg = Release|x64 + {E816D7B2-4688-4ECB-97CC-3D8E798F3831}.Release|x64.Build.0 = Release|x64 + {E816D7B4-4688-4ECB-97CC-3D8E798F3833}.Debug|ARM64.ActiveCfg = Debug|ARM64 + {E816D7B4-4688-4ECB-97CC-3D8E798F3833}.Debug|ARM64.Build.0 = Debug|ARM64 + {E816D7B4-4688-4ECB-97CC-3D8E798F3833}.Debug|x64.ActiveCfg = Debug|x64 + {E816D7B4-4688-4ECB-97CC-3D8E798F3833}.Debug|x64.Build.0 = Debug|x64 + {E816D7B4-4688-4ECB-97CC-3D8E798F3833}.Release|ARM64.ActiveCfg = Release|ARM64 + {E816D7B4-4688-4ECB-97CC-3D8E798F3833}.Release|ARM64.Build.0 = Release|ARM64 + {E816D7B4-4688-4ECB-97CC-3D8E798F3833}.Release|x64.ActiveCfg = Release|x64 + {E816D7B4-4688-4ECB-97CC-3D8E798F3833}.Release|x64.Build.0 = Release|x64 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -3161,6 +3202,10 @@ Global {E816D7AF-4688-4ECB-97CC-3D8E798F3828} = {8EF25507-2575-4ADE-BF7E-D23376903AB8} {E816D7B0-4688-4ECB-97CC-3D8E798F3829} = {8EF25507-2575-4ADE-BF7E-D23376903AB8} {00D8659C-2068-40B6-8B86-759CD6284BBB} = {8EF25507-2575-4ADE-BF7E-D23376903AB8} + {E816D7B1-4688-4ECB-97CC-3D8E798F3830} = {8EF25507-2575-4ADE-BF7E-D23376903AB8} + {E816D7B3-4688-4ECB-97CC-3D8E798F3832} = {8EF25507-2575-4ADE-BF7E-D23376903AB8} + {E816D7B2-4688-4ECB-97CC-3D8E798F3831} = {8EF25507-2575-4ADE-BF7E-D23376903AB8} + {E816D7B4-4688-4ECB-97CC-3D8E798F3833} = {8EF25507-2575-4ADE-BF7E-D23376903AB8} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {C3A2F9D1-7930-4EF4-A6FC-7EE0A99821D0} diff --git a/doc/devdocs/core/settings/settings-implementation.md b/doc/devdocs/core/settings/settings-implementation.md index d97aff2dac..defe59a3fa 100644 --- a/doc/devdocs/core/settings/settings-implementation.md +++ b/doc/devdocs/core/settings/settings-implementation.md @@ -71,6 +71,41 @@ When the user changes settings in the UI: 3. The runner calls the `set_config` function on the appropriate module 4. The module parses the JSON and applies the new settings +# Shortcut Conflict Detection + +Steps to enable conflict detection for a hotkey: + +### 1. Implement module interface for hotkeys +Ensure the module interface provides either `size_t get_hotkeys(Hotkey* hotkeys, size_t buffer_size)` or `std::optional GetHotkeyEx()`. + +- If not yet implemented, you need to add it so that it returns all hotkeys used by the module. +- **Important**: The order of the returned hotkeys matters. This order is used as an index to uniquely identify each hotkey for conflict detection and lookup. +- For reference, see: `src/modules/AdvancedPaste/AdvancedPasteModuleInterface/dllmain.cpp` + +### 2. Implement IHotkeyConfig in the module settings (UI side) +Make sure the module’s settings file inherits from `IHotkeyConfig` and implements `HotkeyAccessor[] GetAllHotkeyAccessors()`. + +- This method should return all hotkeys used in the module. +- **Important**: The order of the returned hotkeys must be consistent with step 1 (`get_hotkeys()` or `GetHotkeyEx()`). +- For reference, see: `src/settings-ui/Settings.UI.Library/AdvancedPasteSettings.cs` +- **_Note:_** `HotkeyAccessor` is a wrapper around HotkeySettings. +It provides both `getter` and `setter` methods to read and update the corresponding hotkey. +Additionally, each `HotkeyAccessor` requires a resource string that describes the purpose of the hotkey. +This string is typically defined in: `src/settings-ui/Settings.UI/Strings/en-us/Resources.resw` + +### 3. Update the module’s ViewModel +The corresponding ViewModel should inherit from `PageViewModelBase` and implement `Dictionary GetAllHotkeySettings()`. + +- This method should return all hotkeys, maintaining the same order as in steps 1 and 2. +- For reference, see: `src/settings-ui/Settings.UI/ViewModels/AdvancedPasteViewModel.cs` + +### 4. Ensure the module’s Views call `OnPageLoaded()` +Once the module’s view is loaded, make sure to invoke the ViewModel’s `OnPageLoaded()` method: +```cs +Loaded += (s, e) => ViewModel.OnPageLoaded(); +``` +- For reference, see: `src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPaste.xaml.cs` + ## Debugging Settings To debug settings issues: diff --git a/doc/images/icons/Mouse Crosshairs.png b/doc/images/icons/Mouse Crosshairs.png index 6b1dcb9c16..a2c64a72a4 100644 Binary files a/doc/images/icons/Mouse Crosshairs.png and b/doc/images/icons/Mouse Crosshairs.png differ diff --git a/installer/PowerToysSetup/FileLocksmith.wxs b/installer/PowerToysSetup/FileLocksmith.wxs index 085e60eaa7..5943ab4147 100644 --- a/installer/PowerToysSetup/FileLocksmith.wxs +++ b/installer/PowerToysSetup/FileLocksmith.wxs @@ -14,21 +14,6 @@ - - - - - - - - - - - - - - - @@ -38,7 +23,6 @@ - diff --git a/installer/PowerToysSetup/ImageResizer.wxs b/installer/PowerToysSetup/ImageResizer.wxs index 17da272014..67b5acf198 100644 --- a/installer/PowerToysSetup/ImageResizer.wxs +++ b/installer/PowerToysSetup/ImageResizer.wxs @@ -16,71 +16,6 @@ - - - - - - - - - - - - - - - - - - - - - - - @@ -90,7 +25,6 @@ - diff --git a/installer/PowerToysSetup/NewPlus.wxs b/installer/PowerToysSetup/NewPlus.wxs index 4dd1c67701..624c01fca2 100644 --- a/installer/PowerToysSetup/NewPlus.wxs +++ b/installer/PowerToysSetup/NewPlus.wxs @@ -18,19 +18,6 @@ - - - - - - - - - - - - - @@ -40,8 +27,7 @@ - - + @@ -81,7 +67,7 @@ - + diff --git a/installer/PowerToysSetup/PowerRename.wxs b/installer/PowerToysSetup/PowerRename.wxs index 1e722d9334..7aa357e207 100644 --- a/installer/PowerToysSetup/PowerRename.wxs +++ b/installer/PowerToysSetup/PowerRename.wxs @@ -14,22 +14,6 @@ - - - - - - - - - - - - - - - - @@ -39,7 +23,6 @@ - diff --git a/installer/PowerToysSetup/Product.wxs b/installer/PowerToysSetup/Product.wxs index f15b8a4714..77ffad8483 100644 --- a/installer/PowerToysSetup/Product.wxs +++ b/installer/PowerToysSetup/Product.wxs @@ -176,6 +176,18 @@ Installed AND (REMOVE="ALL") + + Installed AND (REMOVE="ALL") + + + Installed AND (REMOVE="ALL") + + + Installed AND (REMOVE="ALL") + + + Installed AND (REMOVE="ALL") + Installed AND (NOT UPGRADINGPRODUCTCODE) AND (REMOVE="ALL") @@ -437,6 +449,35 @@ Execute="deferred" BinaryKey="PTCustomActions" DllEntry="UnRegisterContextMenuPackagesCA" + /> + + + + + +#include +#include +#include + +#include "../logger/logger.h" + +namespace runtime_shell_ext +{ + struct Spec + { + // Mandatory + std::wstring clsid; // e.g. {GUID} + std::wstring sentinelKey; // e.g. Software\\Microsoft\\PowerToys\\ModuleName + std::wstring sentinelValue; // e.g. ContextMenuRegistered + std::vector dllFileCandidates; // relative filenames (pick first existing) + std::vector contextMenuHandlerKeyPaths; // full HKCU relative paths where default value = CLSID + + // Optional + std::wstring friendlyName; // if non-empty written as default under CLSID root + bool writeOptInEmptyValue = true; // write ContextMenuOptIn="" under CLSID root (legacy pattern) + bool writeThreadingModel = true; // write Apartment threading model + std::vector extraAssociationPaths; // additional key paths (DragDropHandlers etc.) default=CLSID + std::vector systemFileAssocExtensions; // e.g. .png -> Software\\Classes\\SystemFileAssociations\\.png\\ShellEx\\ContextMenuHandlers\\ + std::wstring systemFileAssocHandlerName; // e.g. ImageResizer + std::wstring representativeSystemExt; // used to decide if associations need repair (.png) + bool logRepairs = true; + }; + + namespace detail + { + // Minimal RAII wrapper for HKEY + struct unique_hkey + { + HKEY h{ nullptr }; + unique_hkey() = default; + explicit unique_hkey(HKEY handle) : h(handle) {} + ~unique_hkey() { if (h) RegCloseKey(h); } + unique_hkey(const unique_hkey&) = delete; + unique_hkey& operator=(const unique_hkey&) = delete; + unique_hkey(unique_hkey&& other) noexcept : h(other.h) { other.h = nullptr; } + unique_hkey& operator=(unique_hkey&& other) noexcept { if (this != &other) { if (h) RegCloseKey(h); h = other.h; other.h = nullptr; } return *this; } + HKEY get() const { return h; } + HKEY* put() { if (h) { RegCloseKey(h); h = nullptr; } return &h; } + }; + inline std::wstring base_dir_from_module(HMODULE h) + { + wchar_t buf[MAX_PATH]; + if (GetModuleFileNameW(h, buf, MAX_PATH)) + { + PathRemoveFileSpecW(buf); + return buf; + } + return L""; + } + + inline std::wstring pick_existing_dll(const std::wstring& base, const std::vector& candidates) + { + for (const auto& rel : candidates) + { + std::wstring full = base + L"\\" + rel; + if (GetFileAttributesW(full.c_str()) != INVALID_FILE_ATTRIBUTES) + { + return full; + } + } + if (!candidates.empty()) + { + return base + L"\\" + candidates.front(); + } + return L""; + } + + inline bool sentinel_exists(const Spec& spec) + { + unique_hkey key; + if (RegOpenKeyExW(HKEY_CURRENT_USER, spec.sentinelKey.c_str(), 0, KEY_READ, key.put()) != ERROR_SUCCESS) + return false; + DWORD v = 0; DWORD sz = sizeof(v); + return RegQueryValueExW(key.get(), spec.sentinelValue.c_str(), nullptr, nullptr, reinterpret_cast(&v), &sz) == ERROR_SUCCESS && v == 1; + } + + inline void write_sentinel(const Spec& spec) + { + unique_hkey key; + if (RegCreateKeyExW(HKEY_CURRENT_USER, spec.sentinelKey.c_str(), 0, nullptr, 0, KEY_WRITE, nullptr, key.put(), nullptr) == ERROR_SUCCESS) + { + DWORD one = 1; + RegSetValueExW(key.get(), spec.sentinelValue.c_str(), 0, REG_DWORD, reinterpret_cast(&one), sizeof(one)); + } + } + + inline void write_inproc_server(const Spec& spec, const std::wstring& dllPath) + { + using namespace std::string_literals; + std::wstring clsidRoot = L"Software\\Classes\\CLSID\\"s + spec.clsid; + std::wstring inprocKey = clsidRoot + L"\\InprocServer32"; + { + unique_hkey key; + if (RegCreateKeyExW(HKEY_CURRENT_USER, clsidRoot.c_str(), 0, nullptr, 0, KEY_WRITE, nullptr, key.put(), nullptr) == ERROR_SUCCESS) + { + if (!spec.friendlyName.empty()) + { + RegSetValueExW(key.get(), nullptr, 0, REG_SZ, reinterpret_cast(spec.friendlyName.c_str()), static_cast((spec.friendlyName.size() + 1) * sizeof(wchar_t))); + } + if (spec.writeOptInEmptyValue) + { + const wchar_t* optIn = L"ContextMenuOptIn"; + const wchar_t empty = L'\0'; + RegSetValueExW(key.get(), optIn, 0, REG_SZ, reinterpret_cast(&empty), sizeof(empty)); + } + } + } + unique_hkey key; + if (RegCreateKeyExW(HKEY_CURRENT_USER, inprocKey.c_str(), 0, nullptr, 0, KEY_WRITE, nullptr, key.put(), nullptr) == ERROR_SUCCESS) + { + RegSetValueExW(key.get(), nullptr, 0, REG_SZ, reinterpret_cast(dllPath.c_str()), static_cast((dllPath.size() + 1) * sizeof(wchar_t))); + if (spec.writeThreadingModel) + { + const wchar_t* tm = L"Apartment"; + RegSetValueExW(key.get(), L"ThreadingModel", 0, REG_SZ, reinterpret_cast(tm), static_cast((wcslen(tm) + 1) * sizeof(wchar_t))); + } + } + } + + inline std::wstring read_inproc_server(const Spec& spec) + { + using namespace std::string_literals; + std::wstring inprocKey = L"Software\\Classes\\CLSID\\"s + spec.clsid + L"\\InprocServer32"; + unique_hkey key; + if (RegOpenKeyExW(HKEY_CURRENT_USER, inprocKey.c_str(), 0, KEY_READ, key.put()) != ERROR_SUCCESS) + return L""; + wchar_t buf[MAX_PATH]; DWORD sz = sizeof(buf); + if (RegQueryValueExW(key.get(), nullptr, nullptr, nullptr, reinterpret_cast(buf), &sz) == ERROR_SUCCESS) + return std::wstring(buf); + return L""; + } + + inline void write_default_value_key(const std::wstring& keyPath, const std::wstring& value) + { + unique_hkey key; + if (RegCreateKeyExW(HKEY_CURRENT_USER, keyPath.c_str(), 0, nullptr, 0, KEY_WRITE, nullptr, key.put(), nullptr) == ERROR_SUCCESS) + { + RegSetValueExW(key.get(), nullptr, 0, REG_SZ, reinterpret_cast(value.c_str()), static_cast((value.size() + 1) * sizeof(wchar_t))); + } + } + + inline bool representative_association_exists(const Spec& spec) + { + using namespace std::string_literals; + if (spec.representativeSystemExt.empty() || spec.systemFileAssocHandlerName.empty()) + return true; + std::wstring keyPath = L"Software\\Classes\\SystemFileAssociations\\"s + spec.representativeSystemExt + L"\\ShellEx\\ContextMenuHandlers\\" + spec.systemFileAssocHandlerName; + unique_hkey key; + return RegOpenKeyExW(HKEY_CURRENT_USER, keyPath.c_str(), 0, KEY_READ, key.put()) == ERROR_SUCCESS; + } + } + + inline bool EnsureRegistered(const Spec& spec, HMODULE moduleInstance) + { + using namespace std::string_literals; + auto base = detail::base_dir_from_module(moduleInstance); + auto dllPath = detail::pick_existing_dll(base, spec.dllFileCandidates); + if (dllPath.empty()) + { + Logger::error(L"Runtime registration: cannot locate dll path for CLSID {}", spec.clsid); + return false; + } + bool exists = detail::sentinel_exists(spec); + bool repaired = false; + if (exists) + { + auto current = detail::read_inproc_server(spec); + if (_wcsicmp(current.c_str(), dllPath.c_str()) != 0) + { + detail::write_inproc_server(spec, dllPath); + repaired = true; + } + if (!detail::representative_association_exists(spec)) + { + repaired = true; + } + } + if (!exists) + { + detail::write_inproc_server(spec, dllPath); + } + if (!exists || repaired) + { + for (const auto& path : spec.contextMenuHandlerKeyPaths) + { + detail::write_default_value_key(path, spec.clsid); + } + for (const auto& path : spec.extraAssociationPaths) + { + detail::write_default_value_key(path, spec.clsid); + } + if (!spec.systemFileAssocExtensions.empty() && !spec.systemFileAssocHandlerName.empty()) + { + for (const auto& ext : spec.systemFileAssocExtensions) + { + std::wstring path = L"Software\\Classes\\SystemFileAssociations\\"s + ext + L"\\ShellEx\\ContextMenuHandlers\\" + spec.systemFileAssocHandlerName; + detail::write_default_value_key(path, spec.clsid); + } + } + } + if (!exists) + { + detail::write_sentinel(spec); + Logger::info(L"Runtime registration completed for CLSID {}", spec.clsid); + } + else if (repaired && spec.logRepairs) + { + Logger::info(L"Runtime registration repaired for CLSID {}", spec.clsid); + } + return true; + } + + inline bool Unregister(const Spec& spec) + { + using namespace std::string_literals; + // Remove handler key paths + for (const auto& path : spec.contextMenuHandlerKeyPaths) + { + RegDeleteTreeW(HKEY_CURRENT_USER, path.c_str()); + } + // Remove extra association paths (e.g., drag & drop handlers) + for (const auto& path : spec.extraAssociationPaths) + { + RegDeleteTreeW(HKEY_CURRENT_USER, path.c_str()); + } + // Remove per-extension system file association handler keys + if (!spec.systemFileAssocExtensions.empty() && !spec.systemFileAssocHandlerName.empty()) + { + for (const auto& ext : spec.systemFileAssocExtensions) + { + std::wstring keyPath = L"Software\\Classes\\SystemFileAssociations\\"s + ext + L"\\ShellEx\\ContextMenuHandlers\\" + spec.systemFileAssocHandlerName; + RegDeleteTreeW(HKEY_CURRENT_USER, keyPath.c_str()); + } + } + // Remove CLSID branch + if (!spec.clsid.empty()) + { + std::wstring clsidRoot = L"Software\\Classes\\CLSID\\"s + spec.clsid; + RegDeleteTreeW(HKEY_CURRENT_USER, clsidRoot.c_str()); + } + // Remove sentinel value (not deleting entire key to avoid disturbing other values) + if (!spec.sentinelKey.empty() && !spec.sentinelValue.empty()) + { + HKEY hKey{}; + if (RegOpenKeyExW(HKEY_CURRENT_USER, spec.sentinelKey.c_str(), 0, KEY_SET_VALUE, &hKey) == ERROR_SUCCESS) + { + RegDeleteValueW(hKey, spec.sentinelValue.c_str()); + RegCloseKey(hKey); + } + } + Logger::info(L"Successfully unregistered CLSID {}", spec.clsid); + return true; + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/dllmain.cpp b/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/dllmain.cpp index 896b362735..6af0d636ac 100644 --- a/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/dllmain.cpp +++ b/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/dllmain.cpp @@ -112,7 +112,7 @@ private: return {}; } - static Hotkey parse_single_hotkey(const winrt::Windows::Data::Json::JsonObject& jsonHotkeyObject) + static Hotkey parse_single_hotkey(const winrt::Windows::Data::Json::JsonObject& jsonHotkeyObject, bool isShown = true) { try { @@ -122,6 +122,7 @@ private: hotkey.shift = jsonHotkeyObject.GetNamedBoolean(JSON_KEY_SHIFT); hotkey.ctrl = jsonHotkeyObject.GetNamedBoolean(JSON_KEY_CTRL); hotkey.key = static_cast(jsonHotkeyObject.GetNamedNumber(JSON_KEY_CODE)); + hotkey.isShown = isShown; return hotkey; } catch (...) @@ -231,8 +232,10 @@ private: return false; } - void process_additional_action(const winrt::hstring& actionName, const winrt::Windows::Data::Json::IJsonValue& actionValue) + void process_additional_action(const winrt::hstring& actionName, const winrt::Windows::Data::Json::IJsonValue& actionValue, bool actionsGroupIsShown = true) { + bool actionIsShown = true; + if (actionValue.ValueType() != winrt::Windows::Data::Json::JsonValueType::Object) { return; @@ -240,9 +243,9 @@ private: const auto action = actionValue.GetObjectW(); - if (!action.GetNamedBoolean(JSON_KEY_IS_SHOWN, false)) + if (!action.GetNamedBoolean(JSON_KEY_IS_SHOWN, false) || !actionsGroupIsShown) { - return; + actionIsShown = false; } if (action.HasKey(JSON_KEY_SHORTCUT)) @@ -250,7 +253,7 @@ private: const AdditionalAction additionalAction { actionName.c_str(), - parse_single_hotkey(action.GetNamedObject(JSON_KEY_SHORTCUT)) + parse_single_hotkey(action.GetNamedObject(JSON_KEY_SHORTCUT), actionIsShown) }; m_additional_actions.push_back(additionalAction); @@ -259,12 +262,12 @@ private: { for (const auto& [subActionName, subAction] : action) { - process_additional_action(subActionName, subAction); + process_additional_action(subActionName, subAction, actionIsShown); } } } - void read_settings(PowerToysSettings::PowerToyValues& settings) + void read_settings(PowerToysSettings::PowerToyValues& settings) { const auto settingsObject = settings.get_raw_json(); @@ -317,9 +320,21 @@ private: { const auto additionalActions = propertiesObject.GetNamedObject(JSON_KEY_ADDITIONAL_ACTIONS); - for (const auto& [actionName, additionalAction] : additionalActions) + // Define the expected order to ensure consistent hotkey ID assignment + const std::vector expectedOrder = { + L"image-to-text", + L"paste-as-file", + L"transcode" + }; + + // Process actions in the predefined order + for (auto& actionKey : expectedOrder) { - process_additional_action(actionName, additionalAction); + if (additionalActions.HasKey(actionKey)) + { + const auto actionValue = additionalActions.GetNamedValue(actionKey); + process_additional_action(actionKey, actionValue); + } } } @@ -331,17 +346,14 @@ private: for (const auto& customAction : customActions) { const auto object = customAction.GetObjectW(); + bool actionIsShown = object.GetNamedBoolean(JSON_KEY_IS_SHOWN, false); - if (object.GetNamedBoolean(JSON_KEY_IS_SHOWN, false)) - { - const CustomAction customActionData - { - static_cast(object.GetNamedNumber(JSON_KEY_ID)), - parse_single_hotkey(object.GetNamedObject(JSON_KEY_SHORTCUT)) - }; + const CustomAction customActionData{ + static_cast(object.GetNamedNumber(JSON_KEY_ID)), + parse_single_hotkey(object.GetNamedObject(JSON_KEY_SHORTCUT), actionIsShown) + }; - m_custom_actions.push_back(customActionData); - } + m_custom_actions.push_back(customActionData); } } } diff --git a/src/modules/FileLocksmith/FileLocksmithExt/FileLocksmithExt.vcxproj b/src/modules/FileLocksmith/FileLocksmithExt/FileLocksmithExt.vcxproj index 0c285a8bfa..c67119808f 100644 --- a/src/modules/FileLocksmith/FileLocksmithExt/FileLocksmithExt.vcxproj +++ b/src/modules/FileLocksmith/FileLocksmithExt/FileLocksmithExt.vcxproj @@ -73,6 +73,7 @@ + diff --git a/src/modules/FileLocksmith/FileLocksmithExt/FileLocksmithExt.vcxproj.filters b/src/modules/FileLocksmith/FileLocksmithExt/FileLocksmithExt.vcxproj.filters index c3b4f47ebc..49bf0e21a9 100644 --- a/src/modules/FileLocksmith/FileLocksmithExt/FileLocksmithExt.vcxproj.filters +++ b/src/modules/FileLocksmith/FileLocksmithExt/FileLocksmithExt.vcxproj.filters @@ -27,6 +27,9 @@ Header Files + + Header Files + diff --git a/src/modules/FileLocksmith/FileLocksmithExt/PowerToysModule.cpp b/src/modules/FileLocksmith/FileLocksmithExt/PowerToysModule.cpp index ec755d99a3..7d188a2010 100644 --- a/src/modules/FileLocksmith/FileLocksmithExt/PowerToysModule.cpp +++ b/src/modules/FileLocksmith/FileLocksmithExt/PowerToysModule.cpp @@ -12,6 +12,7 @@ #include "FileLocksmithLib/Constants.h" #include "FileLocksmithLib/Settings.h" #include "FileLocksmithLib/Trace.h" +#include "RuntimeRegistration.h" #include "dllmain.h" #include "Generated Files/resource.h" @@ -82,12 +83,17 @@ public: { std::wstring path = get_module_folderpath(globals::instance); std::wstring packageUri = path + L"\\FileLocksmithContextMenuPackage.msix"; - if (!package::IsPackageRegisteredWithPowerToysVersion(constants::nonlocalizable::ContextMenuPackageName)) { package::RegisterSparsePackage(path, packageUri); } } + else + { +#if defined(ENABLE_REGISTRATION) || defined(NDEBUG) + FileLocksmithRuntimeRegistration::EnsureRegistered(); +#endif + } m_enabled = true; } @@ -95,6 +101,13 @@ public: virtual void disable() override { Logger::info(L"File Locksmith disabled"); + if (!package::IsWin11OrGreater()) + { +#if defined(ENABLE_REGISTRATION) || defined(NDEBUG) + FileLocksmithRuntimeRegistration::Unregister(); + Logger::info(L"File Locksmith context menu unregistered (Win10)"); +#endif + } m_enabled = false; } diff --git a/src/modules/FileLocksmith/FileLocksmithExt/RuntimeRegistration.h b/src/modules/FileLocksmith/FileLocksmithExt/RuntimeRegistration.h new file mode 100644 index 0000000000..4dd0d34bea --- /dev/null +++ b/src/modules/FileLocksmith/FileLocksmithExt/RuntimeRegistration.h @@ -0,0 +1,36 @@ +// Header-only runtime registration for FileLocksmith context menu extension. +#pragma once + +#include + +namespace globals { extern HMODULE instance; } + +namespace FileLocksmithRuntimeRegistration +{ + namespace + { + inline runtime_shell_ext::Spec BuildSpec() + { + runtime_shell_ext::Spec spec; + spec.clsid = L"{84D68575-E186-46AD-B0CB-BAEB45EE29C0}"; + spec.sentinelKey = L"Software\\Microsoft\\PowerToys\\FileLocksmith"; + spec.sentinelValue = L"ContextMenuRegistered"; + spec.dllFileCandidates = { L"PowerToys.FileLocksmithExt.dll" }; + spec.contextMenuHandlerKeyPaths = { + L"Software\\Classes\\AllFileSystemObjects\\ShellEx\\ContextMenuHandlers\\FileLocksmithExt", + L"Software\\Classes\\Drive\\ShellEx\\ContextMenuHandlers\\FileLocksmithExt" }; + spec.friendlyName = L"File Locksmith Shell Extension"; + return spec; + } + } + + inline bool EnsureRegistered() + { + return runtime_shell_ext::EnsureRegistered(BuildSpec(), globals::instance); + } + + inline void Unregister() + { + runtime_shell_ext::Unregister(BuildSpec()); + } +} 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/NewPlus/NewShellExtensionContextMenu/NewShellExtensionContextMenu.vcxproj b/src/modules/NewPlus/NewShellExtensionContextMenu/NewShellExtensionContextMenu.vcxproj index 9ac251c8ec..90058a503e 100644 --- a/src/modules/NewPlus/NewShellExtensionContextMenu/NewShellExtensionContextMenu.vcxproj +++ b/src/modules/NewPlus/NewShellExtensionContextMenu/NewShellExtensionContextMenu.vcxproj @@ -123,6 +123,7 @@ MakeAppx.exe pack /d . /p $(OutDir)NewPlusPackage.msix /nv + diff --git a/src/modules/NewPlus/NewShellExtensionContextMenu/NewShellExtensionContextMenu.vcxproj.filters b/src/modules/NewPlus/NewShellExtensionContextMenu/NewShellExtensionContextMenu.vcxproj.filters index 7d014eb00f..d8b2eaf9ea 100644 --- a/src/modules/NewPlus/NewShellExtensionContextMenu/NewShellExtensionContextMenu.vcxproj.filters +++ b/src/modules/NewPlus/NewShellExtensionContextMenu/NewShellExtensionContextMenu.vcxproj.filters @@ -84,6 +84,9 @@ Header Files + + Header Files + diff --git a/src/modules/NewPlus/NewShellExtensionContextMenu/RuntimeRegistration.h b/src/modules/NewPlus/NewShellExtensionContextMenu/RuntimeRegistration.h new file mode 100644 index 0000000000..91596d9e3d --- /dev/null +++ b/src/modules/NewPlus/NewShellExtensionContextMenu/RuntimeRegistration.h @@ -0,0 +1,36 @@ +// Header-only runtime registration for New+ Win10 context menu. +#pragma once + +#include +#include +#include + +// Provided by dll_main.cpp +extern HMODULE module_instance_handle; + +namespace NewPlusRuntimeRegistration +{ + namespace { + inline runtime_shell_ext::Spec BuildSpec() + { + runtime_shell_ext::Spec spec; + spec.clsid = L"{FF90D477-E32A-4BE8-8CC5-A502A97F5401}"; + spec.sentinelKey = L"Software\\Microsoft\\PowerToys\\NewPlus"; + spec.sentinelValue = L"ContextMenuRegisteredWin10"; + spec.dllFileCandidates = { L"PowerToys.NewPlus.ShellExtension.win10.dll" }; + spec.contextMenuHandlerKeyPaths = { L"Software\\Classes\\Directory\\background\\ShellEx\\ContextMenuHandlers\\NewPlusShellExtensionWin10" }; + spec.friendlyName = L"NewPlus Shell Extension Win10"; + return spec; + } + } + + inline bool EnsureRegisteredWin10() + { + return runtime_shell_ext::EnsureRegistered(BuildSpec(), module_instance_handle); + } + + inline void Unregister() + { + runtime_shell_ext::Unregister(BuildSpec()); + } +} diff --git a/src/modules/NewPlus/NewShellExtensionContextMenu/powertoys_module.cpp b/src/modules/NewPlus/NewShellExtensionContextMenu/powertoys_module.cpp index 303f072e3b..ad94431953 100644 --- a/src/modules/NewPlus/NewShellExtensionContextMenu/powertoys_module.cpp +++ b/src/modules/NewPlus/NewShellExtensionContextMenu/powertoys_module.cpp @@ -16,6 +16,7 @@ #include "trace.h" #include "new_utilities.h" #include "Generated Files/resource.h" +#include "RuntimeRegistration.h" // Note: Settings are managed via Settings and UI Settings class NewModule : public PowertoyModuleIface @@ -93,8 +94,16 @@ public: // Log telemetry Trace::EventToggleOnOff(true); - - newplus::utilities::register_msix_package(); + if (package::IsWin11OrGreater()) + { + newplus::utilities::register_msix_package(); + } + else + { +#if defined(ENABLE_REGISTRATION) || defined(NDEBUG) + NewPlusRuntimeRegistration::EnsureRegisteredWin10(); +#endif + } powertoy_new_enabled = true; } @@ -141,6 +150,13 @@ private: { Trace::EventToggleOnOff(false); } + if (!package::IsWin11OrGreater()) + { +#if defined(ENABLE_REGISTRATION) || defined(NDEBUG) + NewPlusRuntimeRegistration::Unregister(); + Logger::info(L"New+ context menu unregistered (Win10)"); +#endif + } powertoy_new_enabled = false; } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/CommandItemViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/CommandItemViewModel.cs index 4f589a4e2f..22dad16504 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/CommandItemViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/CommandItemViewModel.cs @@ -189,7 +189,7 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa } else { - return new SeparatorContextItemViewModel() as IContextItemViewModel; + return new SeparatorViewModel() as IContextItemViewModel; } }) .ToList(); @@ -350,7 +350,7 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa } else { - return new SeparatorContextItemViewModel() as IContextItemViewModel; + return new SeparatorViewModel() as IContextItemViewModel; } }) .ToList(); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/ContentPageViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/ContentPageViewModel.cs index 0c0f7c7c12..9665908474 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/ContentPageViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/ContentPageViewModel.cs @@ -119,7 +119,7 @@ public abstract partial class ContentPageViewModel : PageViewModel, ICommandBarC } else { - return new SeparatorContextItemViewModel(); + return new SeparatorViewModel(); } }) .ToList(); @@ -178,7 +178,7 @@ public abstract partial class ContentPageViewModel : PageViewModel, ICommandBarC } else { - return new SeparatorContextItemViewModel(); + return new SeparatorViewModel(); } }) .ToList(); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/FilterItemViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/FilterItemViewModel.cs new file mode 100644 index 0000000000..78fdb26286 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/FilterItemViewModel.cs @@ -0,0 +1,57 @@ +// 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.CmdPal.Core.ViewModels.Models; +using Microsoft.CommandPalette.Extensions; + +namespace Microsoft.CmdPal.Core.ViewModels; + +public partial class FilterItemViewModel : ExtensionObjectViewModel, IFilterItemViewModel +{ + private ExtensionObject _model; + + public string Id { get; set; } = string.Empty; + + public string Name { get; set; } = string.Empty; + + public IconInfoViewModel Icon { get; set; } = new(null); + + internal InitializedState Initialized { get; private set; } = InitializedState.Uninitialized; + + protected bool IsInitialized => IsInErrorState || Initialized.HasFlag(InitializedState.Initialized); + + public bool IsInErrorState => Initialized.HasFlag(InitializedState.Error); + + public FilterItemViewModel(IFilter filter, WeakReference context) + : base(context) + { + _model = new(filter); + } + + public override void InitializeProperties() + { + if (IsInitialized) + { + return; + } + + var filter = _model.Unsafe; + if (filter == null) + { + return; // throw? + } + + Id = filter.Id; + Name = filter.Name; + Icon = new(filter.Icon); + if (Icon is not null) + { + Icon.InitializeProperties(); + } + + UpdateProperty(nameof(Id)); + UpdateProperty(nameof(Name)); + UpdateProperty(nameof(Icon)); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/FiltersViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/FiltersViewModel.cs new file mode 100644 index 0000000000..511581558c --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/FiltersViewModel.cs @@ -0,0 +1,81 @@ +// 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 CommunityToolkit.Mvvm.ComponentModel; +using Microsoft.CmdPal.Core.ViewModels.Models; +using Microsoft.CommandPalette.Extensions; + +namespace Microsoft.CmdPal.Core.ViewModels; + +public partial class FiltersViewModel : ExtensionObjectViewModel +{ + private readonly ExtensionObject _filtersModel = new(null); + + [ObservableProperty] + public partial string CurrentFilterId { get; set; } = string.Empty; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShouldShowFilters))] + public partial IFilterItemViewModel[] Filters { get; set; } = []; + + public bool ShouldShowFilters => Filters.Length > 0; + + public FiltersViewModel(ExtensionObject filters, WeakReference context) + : base(context) + { + _filtersModel = filters; + } + + public override void InitializeProperties() + { + try + { + if (_filtersModel.Unsafe is not null) + { + var filters = _filtersModel.Unsafe.GetFilters(); + Filters = filters.Select(filter => + { + var filterItem = filter as IFilter; + if (filterItem != null) + { + var filterVM = new FilterItemViewModel(filterItem!, PageContext); + filterVM.InitializeProperties(); + + return filterVM; + } + else + { + return new SeparatorViewModel(); + } + }).ToArray(); + + CurrentFilterId = _filtersModel.Unsafe.CurrentFilterId; + + return; + } + } + catch (Exception ex) + { + ShowException(ex, _filtersModel.Unsafe?.GetType().Name); + } + + Filters = []; + CurrentFilterId = string.Empty; + } + + public override void SafeCleanup() + { + base.SafeCleanup(); + + foreach (var filter in Filters) + { + if (filter is FilterItemViewModel filterVM) + { + filterVM.SafeCleanup(); + } + } + + Filters = []; + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/IContextItemViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/IContextItemViewModel.cs index cad1af9d4d..a8f65b2634 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/IContextItemViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/IContextItemViewModel.cs @@ -2,12 +2,7 @@ // 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.Collections.Generic; using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Text; -using System.Threading.Tasks; namespace Microsoft.CmdPal.Core.ViewModels; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/IFilterItemViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/IFilterItemViewModel.cs new file mode 100644 index 0000000000..fb324bb42f --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/IFilterItemViewModel.cs @@ -0,0 +1,9 @@ +// 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. + +namespace Microsoft.CmdPal.Core.ViewModels; + +public interface IFilterItemViewModel +{ +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/ListViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/ListViewModel.cs index 10fc9445ad..f89b2a5906 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/ListViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/ListViewModel.cs @@ -26,6 +26,8 @@ public partial class ListViewModel : PageViewModel, IDisposable [ObservableProperty] public partial ObservableCollection FilteredItems { get; set; } = []; + public FiltersViewModel? Filters { get; set; } + private ObservableCollection Items { get; set; } = []; private readonly ExtensionObject _model; @@ -86,7 +88,7 @@ public partial class ListViewModel : PageViewModel, IDisposable // TODO: Does this need to hop to a _different_ thread, so that we don't block the extension while we're fetching? private void Model_ItemsChanged(object sender, IItemsChangedEventArgs args) => FetchItems(); - protected override void OnFilterUpdated(string filter) + protected override void OnSearchTextBoxUpdated(string searchTextBox) { //// TODO: Just temp testing, need to think about where we want to filter, as AdvancedCollectionView in View could be done, but then grouping need CollectionViewSource, maybe we do grouping in view //// and manage filtering below, but we should be smarter about this and understand caching and other requirements... @@ -104,7 +106,7 @@ public partial class ListViewModel : PageViewModel, IDisposable { if (_model.Unsafe is IDynamicListPage dynamic) { - dynamic.SearchText = filter; + dynamic.SearchText = searchTextBox; } } catch (Exception ex) @@ -127,6 +129,26 @@ public partial class ListViewModel : PageViewModel, IDisposable } } + public void UpdateCurrentFilter(string currentFilterId) + { + // We're getting called on the UI thread. + // Hop off to a BG thread to update the extension. + _ = Task.Run(() => + { + try + { + if (_model.Unsafe is IListPage listPage) + { + listPage.Filters?.CurrentFilterId = currentFilterId; + } + } + catch (Exception ex) + { + ShowException(ex, _model?.Unsafe?.Name); + } + }); + } + //// Run on background thread, from InitializeAsync or Model_ItemsChanged private void FetchItems() { @@ -305,7 +327,7 @@ public partial class ListViewModel : PageViewModel, IDisposable /// Apply our current filter text to the list of items, and update /// FilteredItems to match the results. /// - private void ApplyFilterUnderLock() => ListHelpers.InPlaceUpdateList(FilteredItems, FilterList(Items, Filter)); + private void ApplyFilterUnderLock() => ListHelpers.InPlaceUpdateList(FilteredItems, FilterList(Items, SearchTextBox)); /// /// Helper to generate a weighting for a given list item, based on title, @@ -507,6 +529,10 @@ public partial class ListViewModel : PageViewModel, IDisposable EmptyContent = new(new(model.EmptyContent), PageContext); EmptyContent.SlowInitializeProperties(); + Filters = new(new(model.Filters), PageContext); + Filters.InitializeProperties(); + UpdateProperty(nameof(Filters)); + FetchItems(); model.ItemsChanged += Model_ItemsChanged; } @@ -578,6 +604,10 @@ public partial class ListViewModel : PageViewModel, IDisposable EmptyContent = new(new(model.EmptyContent), PageContext); EmptyContent.SlowInitializeProperties(); break; + case nameof(Filters): + Filters = new(new(model.Filters), PageContext); + Filters.InitializeProperties(); + break; case nameof(IsLoading): UpdateEmptyContent(); break; @@ -641,6 +671,8 @@ public partial class ListViewModel : PageViewModel, IDisposable FilteredItems.Clear(); } + Filters?.SafeCleanup(); + var model = _model.Unsafe; if (model is not null) { diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/PageViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/PageViewModel.cs index 046c9fae93..5c445615be 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/PageViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/PageViewModel.cs @@ -32,7 +32,7 @@ public partial class PageViewModel : ExtensionObjectViewModel, IPageContext // This is set from the SearchBar [ObservableProperty] [NotifyPropertyChangedFor(nameof(ShowSuggestion))] - public partial string Filter { get; set; } = string.Empty; + public partial string SearchTextBox { get; set; } = string.Empty; [ObservableProperty] public virtual partial string PlaceholderText { get; private set; } = "Type here to search..."; @@ -41,7 +41,7 @@ public partial class PageViewModel : ExtensionObjectViewModel, IPageContext [NotifyPropertyChangedFor(nameof(ShowSuggestion))] public virtual partial string TextToSuggest { get; protected set; } = string.Empty; - public bool ShowSuggestion => !string.IsNullOrEmpty(TextToSuggest) && TextToSuggest != Filter; + public bool ShowSuggestion => !string.IsNullOrEmpty(TextToSuggest) && TextToSuggest != SearchTextBox; [ObservableProperty] public partial AppExtensionHost ExtensionHost { get; private set; } @@ -167,9 +167,9 @@ public partial class PageViewModel : ExtensionObjectViewModel, IPageContext } } - partial void OnFilterChanged(string oldValue, string newValue) => OnFilterUpdated(newValue); + partial void OnSearchTextBoxChanged(string oldValue, string newValue) => OnSearchTextBoxUpdated(newValue); - protected virtual void OnFilterUpdated(string filter) + protected virtual void OnSearchTextBoxUpdated(string searchTextBox) { // The base page has no notion of data, so we do nothing here... // subclasses should override. diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/SeparatorContextItemViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/SeparatorViewModel.cs similarity index 73% rename from src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/SeparatorContextItemViewModel.cs rename to src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/SeparatorViewModel.cs index 8d896bd341..a1c4696b35 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/SeparatorContextItemViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/SeparatorViewModel.cs @@ -9,6 +9,10 @@ using Microsoft.CommandPalette.Extensions; namespace Microsoft.CmdPal.Core.ViewModels; [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] -public partial class SeparatorContextItemViewModel() : IContextItemViewModel, ISeparatorContextItem +public partial class SeparatorViewModel() : + IContextItemViewModel, + IFilterItemViewModel, + ISeparatorContextItem, + ISeparatorFilterItem { } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AliasManager.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AliasManager.cs index 642e5ad4a9..72a295c83f 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AliasManager.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AliasManager.cs @@ -97,14 +97,27 @@ public partial class AliasManager : ObservableObject } } - // Look for the old alias, and remove it List toRemove = []; foreach (var kv in _aliases) { + // Look for the old aliases for the command, and remove it if (kv.Value.CommandId == commandId) { toRemove.Add(kv.Value); } + + // Look for the alias belonging to another command, and remove it + if (newAlias is not null && kv.Value.Alias == newAlias.Alias) + { + toRemove.Add(kv.Value); + + // Remove alias from other TopLevelViewModels it may be assigned to + var topLevelCommand = _topLevelCommandManager.LookupCommand(kv.Value.CommandId); + if (topLevelCommand is not null) + { + topLevelCommand.AliasText = string.Empty; + } + } } foreach (var alias in toRemove) diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentFormViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentFormViewModel.cs index 9728e8339e..9b2234fb16 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentFormViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentFormViewModel.cs @@ -2,6 +2,7 @@ // 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.Diagnostics.CodeAnalysis; using System.Text.Json; using AdaptiveCards.ObjectModel.WinUI3; using AdaptiveCards.Templating; @@ -96,109 +97,40 @@ public partial class ContentFormViewModel(IFormContent _form, WeakReference()` - // or similar will throw a System.InvalidCastException. - // - // Instead we have this horror show. - // - // The `action.ToJson()` blob ACTUALLY CONTAINS THE `type` field, which - // we can use to determine what kind of action it is. Then we can parse - // the JSON manually based on the type. - var actionJson = action.ToJson(); - - if (actionJson.TryGetValue("type", out var actionTypeValue)) + if (action is AdaptiveOpenUrlAction openUrlAction) { - var actionTypeString = actionTypeValue.GetString(); - Logger.LogTrace($"atString={actionTypeString}"); - - var actionType = actionTypeString switch - { - "Action.Submit" => ActionType.Submit, - "Action.Execute" => ActionType.Execute, - "Action.OpenUrl" => ActionType.OpenUrl, - _ => ActionType.Unsupported, - }; - - Logger.LogDebug($"{actionTypeString}->{actionType}"); - - switch (actionType) - { - case ActionType.OpenUrl: - { - HandleOpenUrlAction(action, actionJson); - } - - break; - case ActionType.Submit: - case ActionType.Execute: - { - HandleSubmitAction(action, actionJson, inputs); - } - - break; - default: - Logger.LogError($"{actionType} was an unexpected action `type`"); - break; - } - } - else - { - Logger.LogError($"actionJson.TryGetValue(type) failed"); - } - } - - private void HandleOpenUrlAction(IAdaptiveActionElement action, JsonObject actionJson) - { - if (actionJson.TryGetValue("url", out var actionUrlValue)) - { - var actionUrl = actionUrlValue.GetString() ?? string.Empty; - if (Uri.TryCreate(actionUrl, default(UriCreationOptions), out var uri)) - { - WeakReferenceMessenger.Default.Send(new(uri)); - } - else - { - Logger.LogError($"Failed to produce URI for {actionUrlValue}"); - } - } - } - - private void HandleSubmitAction( - IAdaptiveActionElement action, - JsonObject actionJson, - JsonObject inputs) - { - var dataString = string.Empty; - if (actionJson.TryGetValue("data", out var actionDataValue)) - { - dataString = actionDataValue.Stringify() ?? string.Empty; + WeakReferenceMessenger.Default.Send(new(openUrlAction.Url)); + return; } - var inputString = inputs.Stringify(); - _ = Task.Run(() => + if (action is AdaptiveSubmitAction or AdaptiveExecuteAction) { - try + // Get the data and inputs + var dataString = (action as AdaptiveSubmitAction)?.DataJson.Stringify() ?? string.Empty; + var inputString = inputs.Stringify(); + + _ = Task.Run(() => { - var model = _formModel.Unsafe!; - if (model != null) + try { - var result = model.SubmitForm(inputString, dataString); - Logger.LogDebug($"SubmitForm() returned {result}"); - WeakReferenceMessenger.Default.Send(new(new(result))); + var model = _formModel.Unsafe!; + if (model != null) + { + var result = model.SubmitForm(inputString, dataString); + WeakReferenceMessenger.Default.Send(new(new(result))); + } } - } - catch (Exception ex) - { - ShowException(ex); - } - }); + catch (Exception ex) + { + ShowException(ex); + } + }); + } } private static readonly string ErrorCardJson = """ diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelViewModel.cs index e73f5b09ba..e9b3f1bcca 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelViewModel.cs @@ -110,6 +110,8 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem get => Alias?.Alias ?? string.Empty; set { + var previousAlias = Alias?.Alias ?? string.Empty; + if (string.IsNullOrEmpty(value)) { Alias = null; @@ -126,9 +128,13 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem } } - HandleChangeAlias(); - OnPropertyChanged(nameof(AliasText)); - OnPropertyChanged(nameof(IsDirectAlias)); + // Only call HandleChangeAlias if there was an actual change. + if (previousAlias != Alias?.Alias) + { + HandleChangeAlias(); + OnPropertyChanged(nameof(AliasText)); + OnPropertyChanged(nameof(IsDirectAlias)); + } } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandBar.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandBar.xaml index 49fef61ecb..aba9ed477d 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandBar.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandBar.xaml @@ -41,14 +41,18 @@ + + + + ShouldConstrainToRootBounds="False" + SystemBackdrop="{ThemeResource AcrylicBackgroundFillColorDefaultBackdrop}"> @@ -161,6 +165,7 @@ x:Name="PrimaryButton" Padding="6,4,4,4" x:Load="{x:Bind IsLoaded, Mode=OneWay}" + AutomationProperties.AutomationId="PrimaryCommandButton" AutomationProperties.Name="{x:Bind ViewModel.PrimaryCommand.Name, Mode=OneWay}" Background="Transparent" Click="PrimaryButton_Clicked" @@ -180,6 +185,7 @@ x:Name="SecondaryButton" Padding="6,4,4,4" x:Load="{x:Bind IsLoaded, Mode=OneWay}" + AutomationProperties.AutomationId="SecondaryCommandButton" AutomationProperties.Name="{x:Bind ViewModel.SecondaryCommand.Name, Mode=OneWay}" Click="SecondaryButton_Clicked" Style="{StaticResource SubtleButtonStyle}" @@ -203,6 +209,7 @@ x:Name="MoreCommandsButton" x:Uid="MoreCommandsButton" Padding="6,4,4,4" + AutomationProperties.AutomationId="MoreContextMenuButton" Click="MoreCommandsButton_Clicked" Style="{StaticResource SubtleButtonStyle}" ToolTipService.ToolTip="Ctrl+K" diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContextMenu.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContextMenu.xaml index f3c4e5413e..e23fb815b0 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContextMenu.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContextMenu.xaml @@ -108,7 +108,7 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/FiltersDropDown.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/FiltersDropDown.xaml.cs new file mode 100644 index 0000000000..b51376dfa3 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/FiltersDropDown.xaml.cs @@ -0,0 +1,189 @@ +// 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.CmdPal.Core.ViewModels; +using Microsoft.CmdPal.UI.Views; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Input; +using Windows.System; + +namespace Microsoft.CmdPal.UI.Controls; + +public sealed partial class FiltersDropDown : UserControl, + ICurrentPageAware +{ + public PageViewModel? CurrentPageViewModel + { + get => (PageViewModel?)GetValue(CurrentPageViewModelProperty); + set => SetValue(CurrentPageViewModelProperty, value); + } + + public static readonly DependencyProperty CurrentPageViewModelProperty = + DependencyProperty.Register(nameof(CurrentPageViewModel), typeof(PageViewModel), typeof(FiltersDropDown), new PropertyMetadata(null, OnCurrentPageViewModelChanged)); + + private static void OnCurrentPageViewModelChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var @this = (FiltersDropDown)d; + + if (@this != null + && e.OldValue is PageViewModel old) + { + old.PropertyChanged -= @this.Page_PropertyChanged; + } + + // If this new page does not implement ListViewModel or if + // it doesn't contain Filters, we need to clear any filters + // that may have been set. + if (@this != null) + { + if (e.NewValue is ListViewModel listViewModel) + { + @this.ViewModel = listViewModel.Filters; + } + else + { + @this.ViewModel = null; + } + } + + if (@this != null + && e.NewValue is PageViewModel page) + { + page.PropertyChanged += @this.Page_PropertyChanged; + } + } + + public FiltersViewModel? ViewModel + { + get => (FiltersViewModel?)GetValue(ViewModelProperty); + set => SetValue(ViewModelProperty, value); + } + + public static readonly DependencyProperty ViewModelProperty = + DependencyProperty.Register(nameof(ViewModel), typeof(FiltersViewModel), typeof(FiltersDropDown), new PropertyMetadata(null)); + + public FiltersDropDown() + { + this.InitializeComponent(); + } + + // Used to handle the case when a ListPage's `Filters` may have changed + private void Page_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + { + var property = e.PropertyName; + + if (CurrentPageViewModel is ListViewModel list) + { + if (property == nameof(ListViewModel.Filters)) + { + ViewModel = list.Filters; + } + } + } + + private void FiltersComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (CurrentPageViewModel is ListViewModel listViewModel && + FiltersComboBox.SelectedItem is FilterItemViewModel filterItem) + { + listViewModel.UpdateCurrentFilter(filterItem.Id); + } + } + + private void FiltersComboBox_PreviewKeyDown(object sender, KeyRoutedEventArgs e) + { + if (e.Key == VirtualKey.Up) + { + NavigateUp(); + + e.Handled = true; + } + else if (e.Key == VirtualKey.Down) + { + NavigateDown(); + + e.Handled = true; + } + } + + private void NavigateUp() + { + var newIndex = FiltersComboBox.SelectedIndex; + + if (FiltersComboBox.SelectedIndex > 0) + { + newIndex--; + + while ( + newIndex >= 0 && + IsSeparator(FiltersComboBox.Items[newIndex]) && + newIndex != FiltersComboBox.SelectedIndex) + { + newIndex--; + } + + if (newIndex < 0) + { + newIndex = FiltersComboBox.Items.Count - 1; + + while ( + newIndex >= 0 && + IsSeparator(FiltersComboBox.Items[newIndex]) && + newIndex != FiltersComboBox.SelectedIndex) + { + newIndex--; + } + } + } + else + { + newIndex = FiltersComboBox.Items.Count - 1; + } + + FiltersComboBox.SelectedIndex = newIndex; + } + + private void NavigateDown() + { + var newIndex = FiltersComboBox.SelectedIndex; + + if (FiltersComboBox.SelectedIndex == FiltersComboBox.Items.Count - 1) + { + newIndex = 0; + } + else + { + newIndex++; + + while ( + newIndex < FiltersComboBox.Items.Count && + IsSeparator(FiltersComboBox.Items[newIndex]) && + newIndex != FiltersComboBox.SelectedIndex) + { + newIndex++; + } + + if (newIndex >= FiltersComboBox.Items.Count) + { + newIndex = 0; + + while ( + newIndex < FiltersComboBox.Items.Count && + IsSeparator(FiltersComboBox.Items[newIndex]) && + newIndex != FiltersComboBox.SelectedIndex) + { + newIndex++; + } + } + } + + FiltersComboBox.SelectedIndex = newIndex; + } + + private bool IsSeparator(object item) + { + return item is SeparatorViewModel; + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml index 379ea6b03d..80eb1a3ad6 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml @@ -22,6 +22,7 @@ MinHeight="32" VerticalAlignment="Stretch" VerticalContentAlignment="Stretch" + AutomationProperties.AutomationId="MainSearchBox" KeyDown="FilterBox_KeyDown" PlaceholderText="{x:Bind CurrentPageViewModel.PlaceholderText, Converter={StaticResource PlaceholderTextConverter}, Mode=OneWay}" PreviewKeyDown="FilterBox_PreviewKeyDown" diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml.cs index 7b34594b46..a5f02d76cb 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml.cs @@ -62,7 +62,7 @@ public sealed partial class SearchBar : UserControl, { // TODO: In some cases we probably want commands to clear a filter // somewhere in the process, so we need to figure out when that is. - @this.FilterBox.Text = page.Filter; + @this.FilterBox.Text = page.SearchTextBox; @this.FilterBox.Select(@this.FilterBox.Text.Length, 0); page.PropertyChanged += @this.Page_PropertyChanged; @@ -87,7 +87,7 @@ public sealed partial class SearchBar : UserControl, if (CurrentPageViewModel is not null) { - CurrentPageViewModel.Filter = string.Empty; + CurrentPageViewModel.SearchTextBox = string.Empty; } })); } @@ -145,7 +145,7 @@ public sealed partial class SearchBar : UserControl, // hack TODO GH #245 if (CurrentPageViewModel is not null) { - CurrentPageViewModel.Filter = FilterBox.Text; + CurrentPageViewModel.SearchTextBox = FilterBox.Text; } } @@ -156,7 +156,7 @@ public sealed partial class SearchBar : UserControl, // hack TODO GH #245 if (CurrentPageViewModel is not null) { - CurrentPageViewModel.Filter = FilterBox.Text; + CurrentPageViewModel.SearchTextBox = FilterBox.Text; } } } @@ -320,7 +320,7 @@ public sealed partial class SearchBar : UserControl, // Actually plumb Filtering to the view model if (CurrentPageViewModel is not null) { - CurrentPageViewModel.Filter = FilterBox.Text; + CurrentPageViewModel.SearchTextBox = FilterBox.Text; } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/ContextItemTemplateSelector.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/ContextItemTemplateSelector.cs index 3ab4dd1c0b..09fa902a4b 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/ContextItemTemplateSelector.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/ContextItemTemplateSelector.cs @@ -28,7 +28,7 @@ internal sealed partial class ContextItemTemplateSelector : DataTemplateSelector { li.IsEnabled = true; - if (item is SeparatorContextItemViewModel) + if (item is SeparatorViewModel) { li.IsEnabled = false; li.AllowFocusWhenDisabled = false; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/FilterTemplateSelector.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/FilterTemplateSelector.cs new file mode 100644 index 0000000000..2d12c82b28 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/FilterTemplateSelector.cs @@ -0,0 +1,36 @@ +// 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.CmdPal.Core.ViewModels; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace Microsoft.CmdPal.UI; + +internal sealed partial class FilterTemplateSelector : DataTemplateSelector +{ + public DataTemplate? Default { get; set; } + + public DataTemplate? Separator { get; set; } + + protected override DataTemplate? SelectTemplateCore(object item, DependencyObject dependencyObject) + { + DataTemplate? dataTemplate = Default; + + if (dependencyObject is ComboBoxItem comboBoxItem) + { + comboBoxItem.IsEnabled = true; + + if (item is SeparatorViewModel) + { + comboBoxItem.IsEnabled = false; + comboBoxItem.AllowFocusWhenDisabled = false; + comboBoxItem.AllowFocusOnInteraction = false; + dataTemplate = Separator; + } + } + + return dataTemplate; + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/TrayIconService.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/TrayIconService.cs index 224c851ff1..442341cc5e 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/TrayIconService.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/TrayIconService.cs @@ -92,7 +92,7 @@ internal sealed partial class TrayIconService { _popupMenu = PInvoke.CreatePopupMenu_SafeHandle(); PInvoke.InsertMenu(_popupMenu, 0, MENU_ITEM_FLAGS.MF_BYPOSITION | MENU_ITEM_FLAGS.MF_STRING, PInvoke.WM_USER + 1, RS_.GetString("TrayMenu_Settings")); - PInvoke.InsertMenu(_popupMenu, 1, MENU_ITEM_FLAGS.MF_BYPOSITION | MENU_ITEM_FLAGS.MF_STRING, PInvoke.WM_USER + 2, RS_.GetString("TrayMenu_Exit")); + PInvoke.InsertMenu(_popupMenu, 1, MENU_ITEM_FLAGS.MF_BYPOSITION | MENU_ITEM_FLAGS.MF_STRING, PInvoke.WM_USER + 2, RS_.GetString("TrayMenu_Close")); } } else diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml index 8f7c6ac7bd..dbb3818518 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml @@ -176,6 +176,7 @@ + @@ -320,6 +321,18 @@ + + + + + + + + + Settings - - Exit + + Close + Close as a verb, as in Close the application Direct diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/AllAppsCommandProviderTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/AllAppsCommandProviderTests.cs new file mode 100644 index 0000000000..e7fbc6859d --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/AllAppsCommandProviderTests.cs @@ -0,0 +1,119 @@ +// 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.Linq; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.Apps.UnitTests; + +[TestClass] +public class AllAppsCommandProviderTests : AppsTestBase +{ + [TestMethod] + public void ProviderHasDisplayName() + { + // Setup + var provider = new AllAppsCommandProvider(); + + // Assert + Assert.IsNotNull(provider.DisplayName); + Assert.IsTrue(provider.DisplayName.Length > 0); + } + + [TestMethod] + public void ProviderHasIcon() + { + // Setup + var provider = new AllAppsCommandProvider(); + + // Assert + Assert.IsNotNull(provider.Icon); + } + + [TestMethod] + public void TopLevelCommandsNotEmpty() + { + // Setup + var provider = new AllAppsCommandProvider(); + + // Act + var commands = provider.TopLevelCommands(); + + // Assert + Assert.IsNotNull(commands); + Assert.IsTrue(commands.Length > 0); + } + + [TestMethod] + public void LookupAppWithEmptyNameReturnsNotNull() + { + // Setup + var mockApp = TestDataHelper.CreateTestWin32Program("Notepad", "C:\\Windows\\System32\\notepad.exe"); + MockCache.AddWin32Program(mockApp); + var page = new AllAppsPage(MockCache); + + var provider = new AllAppsCommandProvider(page); + + // Act + var result = provider.LookupApp(string.Empty); + + // Assert + Assert.IsNotNull(result); + } + + [TestMethod] + public async Task ProviderWithMockData_LookupApp_ReturnsCorrectApp() + { + // Arrange + var testApp = TestDataHelper.CreateTestWin32Program("TestApp", "C:\\TestApp.exe"); + MockCache.AddWin32Program(testApp); + + var provider = new AllAppsCommandProvider(Page); + + // Wait for initialization to complete + await WaitForPageInitializationAsync(); + + // Act + var result = provider.LookupApp("TestApp"); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual("TestApp", result.Title); + } + + [TestMethod] + public async Task ProviderWithMockData_LookupApp_ReturnsNullForNonExistentApp() + { + // Arrange + var testApp = TestDataHelper.CreateTestWin32Program("TestApp", "C:\\TestApp.exe"); + MockCache.AddWin32Program(testApp); + + var provider = new AllAppsCommandProvider(Page); + + // Wait for initialization to complete + await WaitForPageInitializationAsync(); + + // Act + var result = provider.LookupApp("NonExistentApp"); + + // Assert + Assert.IsNull(result); + } + + [TestMethod] + public void ProviderWithMockData_TopLevelCommands_IncludesListItem() + { + // Arrange + var provider = new AllAppsCommandProvider(Page); + + // Act + var commands = provider.TopLevelCommands(); + + // Assert + Assert.IsNotNull(commands); + Assert.IsTrue(commands.Length >= 1); // At least the list item should be present + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/AllAppsPageTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/AllAppsPageTests.cs new file mode 100644 index 0000000000..3ac1eaff68 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/AllAppsPageTests.cs @@ -0,0 +1,97 @@ +// 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.Linq; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.Apps.UnitTests; + +[TestClass] +public class AllAppsPageTests : AppsTestBase +{ + [TestMethod] + public void AllAppsPage_Constructor_ThrowsOnNullAppCache() + { + // Act & Assert + Assert.ThrowsException(() => new AllAppsPage(null!)); + } + + [TestMethod] + public void AllAppsPage_WithMockCache_InitializesSuccessfully() + { + // Arrange + var mockCache = new MockAppCache(); + + // Act + var page = new AllAppsPage(mockCache); + + // Assert + Assert.IsNotNull(page); + Assert.IsNotNull(page.Name); + Assert.IsNotNull(page.Icon); + } + + [TestMethod] + public async Task AllAppsPage_GetItems_ReturnsEmptyWithEmptyCache() + { + // Act - Wait for initialization to complete + await WaitForPageInitializationAsync(); + var items = Page.GetItems(); + + // Assert + Assert.IsNotNull(items); + Assert.AreEqual(0, items.Length); + } + + [TestMethod] + public async Task AllAppsPage_GetItems_ReturnsAppsFromCacheAsync() + { + // Arrange + var mockCache = new MockAppCache(); + var win32App = TestDataHelper.CreateTestWin32Program("Notepad", "C:\\Windows\\System32\\notepad.exe"); + var uwpApp = TestDataHelper.CreateTestUWPApplication("Calculator"); + + mockCache.AddWin32Program(win32App); + mockCache.AddUWPApplication(uwpApp); + + var page = new AllAppsPage(mockCache); + + // Wait a bit for initialization to complete + await Task.Delay(100); + + // Act + var items = page.GetItems(); + + // Assert + Assert.IsNotNull(items); + Assert.AreEqual(2, items.Length); + + // we need to loop the items to ensure we got the correct ones + Assert.IsTrue(items.Any(i => i.Title == "Notepad")); + Assert.IsTrue(items.Any(i => i.Title == "Calculator")); + } + + [TestMethod] + public async Task AllAppsPage_GetPinnedApps_ReturnsEmptyWhenNoAppsArePinned() + { + // Arrange + var mockCache = new MockAppCache(); + var app = TestDataHelper.CreateTestWin32Program("TestApp", "C:\\TestApp.exe"); + mockCache.AddWin32Program(app); + + var page = new AllAppsPage(mockCache); + + // Wait a bit for initialization to complete + await Task.Delay(100); + + // Act + var pinnedApps = page.GetPinnedApps(); + + // Assert + Assert.IsNotNull(pinnedApps); + Assert.AreEqual(0, pinnedApps.Length); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/AppsTestBase.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/AppsTestBase.cs new file mode 100644 index 0000000000..4d1210db7b --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/AppsTestBase.cs @@ -0,0 +1,67 @@ +// 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.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.Apps.UnitTests; + +/// +/// Base class for Apps unit tests that provides common setup and teardown functionality. +/// +public abstract class AppsTestBase +{ + /// + /// Gets the mock application cache used in tests. + /// + protected MockAppCache MockCache { get; private set; } = null!; + + /// + /// Gets the AllAppsPage instance used in tests. + /// + protected AllAppsPage Page { get; private set; } = null!; + + /// + /// Sets up the test environment before each test method. + /// + /// A task representing the asynchronous setup operation. + [TestInitialize] + public virtual async Task Setup() + { + MockCache = new MockAppCache(); + Page = new AllAppsPage(MockCache); + + // Ensure initialization is complete + await MockCache.RefreshAsync(); + } + + /// + /// Cleans up the test environment after each test method. + /// + [TestCleanup] + public virtual void Cleanup() + { + MockCache?.Dispose(); + } + + /// + /// Forces synchronous initialization of the page for testing. + /// + protected void EnsurePageInitialized() + { + // Trigger BuildListItems by accessing items + _ = Page.GetItems(); + } + + /// + /// Waits for page initialization with timeout. + /// + /// The timeout in milliseconds. + /// A task representing the asynchronous wait operation. + protected async Task WaitForPageInitializationAsync(int timeoutMs = 1000) + { + await MockCache.RefreshAsync(); + EnsurePageInitialized(); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/Microsoft.CmdPal.Ext.Apps.UnitTests.csproj b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/Microsoft.CmdPal.Ext.Apps.UnitTests.csproj new file mode 100644 index 0000000000..d6a9638378 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/Microsoft.CmdPal.Ext.Apps.UnitTests.csproj @@ -0,0 +1,23 @@ + + + + + + false + true + Microsoft.CmdPal.Ext.Apps.UnitTests + $(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal\tests\ + false + false + + + + + + + + + + + + diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/MockAppCache.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/MockAppCache.cs new file mode 100644 index 0000000000..03530cb5ce --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/MockAppCache.cs @@ -0,0 +1,113 @@ +// 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.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.CmdPal.Ext.Apps.Programs; + +namespace Microsoft.CmdPal.Ext.Apps.UnitTests; + +/// +/// Mock implementation of IAppCache for unit testing. +/// +public class MockAppCache : IAppCache +{ + private readonly List _win32s = new(); + private readonly List _uwps = new(); + private bool _disposed; + private bool _shouldReload; + + /// + /// Gets the collection of Win32 programs. + /// + public IList Win32s => _win32s.AsReadOnly(); + + /// + /// Gets the collection of UWP applications. + /// + public IList UWPs => _uwps.AsReadOnly(); + + /// + /// Determines whether the cache should be reloaded. + /// + /// True if cache should be reloaded, false otherwise. + public bool ShouldReload() => _shouldReload; + + /// + /// Resets the reload flag. + /// + public void ResetReloadFlag() => _shouldReload = false; + + /// + /// Asynchronously refreshes the cache. + /// + /// A task representing the asynchronous refresh operation. + public async Task RefreshAsync() + { + // Simulate minimal async operation for testing + await Task.Delay(1); + } + + /// + /// Adds a Win32 program to the cache. + /// + /// The Win32 program to add. + /// Thrown when program is null. + public void AddWin32Program(Win32Program program) + { + ArgumentNullException.ThrowIfNull(program); + + _win32s.Add(program); + } + + /// + /// Adds a UWP application to the cache. + /// + /// The UWP application to add. + /// Thrown when app is null. + public void AddUWPApplication(IUWPApplication app) + { + ArgumentNullException.ThrowIfNull(app); + + _uwps.Add(app); + } + + /// + /// Clears all applications from the cache. + /// + public void ClearAll() + { + _win32s.Clear(); + _uwps.Clear(); + } + + /// + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + /// + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + /// + /// Releases unmanaged and - optionally - managed resources. + /// + /// true to release both managed and unmanaged resources; false to release only unmanaged resources. + protected virtual void Dispose(bool disposing) + { + if (!_disposed) + { + if (disposing) + { + // Clean up managed resources + _win32s.Clear(); + _uwps.Clear(); + } + + _disposed = true; + } + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/MockUWPApplication.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/MockUWPApplication.cs new file mode 100644 index 0000000000..ae39e70fef --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/MockUWPApplication.cs @@ -0,0 +1,140 @@ +// 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.Collections.Generic; +using Microsoft.CmdPal.Ext.Apps.Programs; +using Microsoft.CmdPal.Ext.Apps.Utils; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.Apps.UnitTests; + +/// +/// Mock implementation of IUWPApplication for unit testing. +/// +public class MockUWPApplication : IUWPApplication +{ + /// + /// Gets or sets the app list entry. + /// + public string AppListEntry { get; set; } = string.Empty; + + /// + /// Gets or sets the unique identifier. + /// + public string UniqueIdentifier { get; set; } = string.Empty; + + /// + /// Gets or sets the display name. + /// + public string DisplayName { get; set; } = string.Empty; + + /// + /// Gets or sets the description. + /// + public string Description { get; set; } = string.Empty; + + /// + /// Gets or sets the user model ID. + /// + public string UserModelId { get; set; } = string.Empty; + + /// + /// Gets or sets the background color. + /// + public string BackgroundColor { get; set; } = string.Empty; + + /// + /// Gets or sets the entry point. + /// + public string EntryPoint { get; set; } = string.Empty; + + /// + /// Gets or sets a value indicating whether the application is enabled. + /// + public bool Enabled { get; set; } = true; + + /// + /// Gets or sets a value indicating whether the application can run elevated. + /// + public bool CanRunElevated { get; set; } + + /// + /// Gets or sets the logo path. + /// + public string LogoPath { get; set; } = string.Empty; + + /// + /// Gets or sets the logo type. + /// + public LogoType LogoType { get; set; } = LogoType.Colored; + + /// + /// Gets or sets the UWP package. + /// + public UWP Package { get; set; } = null!; + + /// + /// Gets the name of the application. + /// + public string Name => DisplayName; + + /// + /// Gets the location of the application. + /// + public string Location => Package?.Location ?? string.Empty; + + /// + /// Gets the localized location of the application. + /// + public string LocationLocalized => Package?.LocationLocalized ?? string.Empty; + + /// + /// Gets the application identifier. + /// + /// The user model ID of the application. + public string GetAppIdentifier() + { + return UserModelId; + } + + /// + /// Gets the commands available for this application. + /// + /// A list of context items. + public List GetCommands() + { + return new List(); + } + + /// + /// Updates the logo path based on the specified theme. + /// + /// The theme to use for the logo. + public void UpdateLogoPath(Theme theme) + { + // Mock implementation - no-op for testing + } + + /// + /// Converts this UWP application to an AppItem. + /// + /// An AppItem representation of this UWP application. + public AppItem ToAppItem() + { + var iconPath = LogoType != LogoType.Error ? LogoPath : string.Empty; + return new AppItem() + { + Name = Name, + Subtitle = Description, + Type = "Packaged Application", // Equivalent to UWPApplication.Type() + IcoPath = iconPath, + DirPath = Location, + UserModelId = UserModelId, + IsPackaged = true, + Commands = GetCommands(), + AppIdentifier = GetAppIdentifier(), + }; + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/QueryTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/QueryTests.cs new file mode 100644 index 0000000000..e04c678b58 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/QueryTests.cs @@ -0,0 +1,45 @@ +// 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.Linq; +using Microsoft.CmdPal.Ext.UnitTestBase; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.Apps.UnitTests; + +[TestClass] +public class QueryTests : CommandPaletteUnitTestBase +{ + [TestMethod] + public void QueryReturnsExpectedResults() + { + // Arrange + var mockCache = new MockAppCache(); + var win32App = TestDataHelper.CreateTestWin32Program("Notepad", "C:\\Windows\\System32\\notepad.exe"); + var uwpApp = TestDataHelper.CreateTestUWPApplication("Calculator"); + mockCache.AddWin32Program(win32App); + mockCache.AddUWPApplication(uwpApp); + + for (var i = 0; i < 10; i++) + { + mockCache.AddWin32Program(TestDataHelper.CreateTestWin32Program($"App{i}")); + mockCache.AddUWPApplication(TestDataHelper.CreateTestUWPApplication($"UWP App {i}")); + } + + var page = new AllAppsPage(mockCache); + var provider = new AllAppsCommandProvider(page); + + // Act + var allItems = page.GetItems(); + + // Assert + var notepadResult = Query("notepad", allItems).FirstOrDefault(); + Assert.IsNotNull(notepadResult); + Assert.AreEqual("Notepad", notepadResult.Title); + + var calculatorResult = Query("cal", allItems).FirstOrDefault(); + Assert.IsNotNull(calculatorResult); + Assert.AreEqual("Calculator", calculatorResult.Title); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/Settings.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/Settings.cs new file mode 100644 index 0000000000..b48abaf32a --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/Settings.cs @@ -0,0 +1,58 @@ +// 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.Collections.Generic; +using Microsoft.CmdPal.Ext.Apps.Helpers; + +namespace Microsoft.CmdPal.Ext.Apps.UnitTests; + +public class Settings : ISettingsInterface +{ + private readonly bool enableStartMenuSource; + private readonly bool enableDesktopSource; + private readonly bool enableRegistrySource; + private readonly bool enablePathEnvironmentVariableSource; + private readonly List programSuffixes; + private readonly List runCommandSuffixes; + + public Settings( + bool enableStartMenuSource = true, + bool enableDesktopSource = true, + bool enableRegistrySource = true, + bool enablePathEnvironmentVariableSource = true, + List programSuffixes = null, + List runCommandSuffixes = null) + { + this.enableStartMenuSource = enableStartMenuSource; + this.enableDesktopSource = enableDesktopSource; + this.enableRegistrySource = enableRegistrySource; + this.enablePathEnvironmentVariableSource = enablePathEnvironmentVariableSource; + this.programSuffixes = programSuffixes ?? new List { "bat", "appref-ms", "exe", "lnk", "url" }; + this.runCommandSuffixes = runCommandSuffixes ?? new List { "bat", "appref-ms", "exe", "lnk", "url", "cpl", "msc" }; + } + + public bool EnableStartMenuSource => enableStartMenuSource; + + public bool EnableDesktopSource => enableDesktopSource; + + public bool EnableRegistrySource => enableRegistrySource; + + public bool EnablePathEnvironmentVariableSource => enablePathEnvironmentVariableSource; + + public List ProgramSuffixes => programSuffixes; + + public List RunCommandSuffixes => runCommandSuffixes; + + public static Settings CreateDefaultSettings() => new Settings(); + + public static Settings CreateDisabledSourcesSettings() => new Settings( + enableStartMenuSource: false, + enableDesktopSource: false, + enableRegistrySource: false, + enablePathEnvironmentVariableSource: false); + + public static Settings CreateCustomSuffixesSettings() => new Settings( + programSuffixes: new List { "exe", "bat" }, + runCommandSuffixes: new List { "exe", "bat", "cmd" }); +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/TestDataHelper.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/TestDataHelper.cs new file mode 100644 index 0000000000..88936e4285 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/TestDataHelper.cs @@ -0,0 +1,128 @@ +// 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.CmdPal.Ext.Apps.Programs; + +namespace Microsoft.CmdPal.Ext.Apps.UnitTests; + +/// +/// Helper class to create test data for unit tests. +/// +public static class TestDataHelper +{ + /// + /// Creates a test Win32 program with the specified parameters. + /// + /// The name of the application. + /// The full path to the application executable. + /// A value indicating whether the application is enabled. + /// A value indicating whether the application is valid. + /// A new Win32Program instance with the specified parameters. + public static Win32Program CreateTestWin32Program( + string name = "Test App", + string fullPath = "C:\\TestApp\\app.exe", + bool enabled = true, + bool valid = true) + { + return new Win32Program + { + Name = name, + FullPath = fullPath, + Enabled = enabled, + Valid = valid, + UniqueIdentifier = $"win32_{name}", + Description = $"Test description for {name}", + ExecutableName = "app.exe", + ParentDirectory = "C:\\TestApp", + AppType = Win32Program.ApplicationType.Win32Application, + }; + } + + /// + /// Creates a test UWP application with the specified parameters. + /// + /// The display name of the application. + /// The user model ID of the application. + /// A value indicating whether the application is enabled. + /// A new IUWPApplication instance with the specified parameters. + public static IUWPApplication CreateTestUWPApplication( + string displayName = "Test UWP App", + string userModelId = "TestPublisher.TestUWPApp_1.0.0.0_neutral__8wekyb3d8bbwe", + bool enabled = true) + { + return new MockUWPApplication + { + DisplayName = displayName, + UserModelId = userModelId, + Enabled = enabled, + UniqueIdentifier = $"uwp_{userModelId}", + Description = $"Test UWP description for {displayName}", + AppListEntry = "default", + BackgroundColor = "#000000", + EntryPoint = "TestApp.App", + CanRunElevated = false, + LogoPath = string.Empty, + Package = CreateMockUWPPackage(displayName, userModelId), + }; + } + + /// + /// Creates a mock UWP package for testing purposes. + /// + /// The display name of the package. + /// The user model ID of the package. + /// A new UWP package instance. + private static UWP CreateMockUWPPackage(string displayName, string userModelId) + { + var mockPackage = new MockPackage + { + Name = displayName, + FullName = userModelId, + FamilyName = $"{displayName}_8wekyb3d8bbwe", + InstalledLocation = $"C:\\Program Files\\WindowsApps\\{displayName}", + }; + + return new UWP(mockPackage) + { + Location = mockPackage.InstalledLocation, + LocationLocalized = mockPackage.InstalledLocation, + }; + } + + /// + /// Mock implementation of IPackage for testing purposes. + /// + private sealed class MockPackage : IPackage + { + /// + /// Gets or sets the name of the package. + /// + public string Name { get; set; } = string.Empty; + + /// + /// Gets or sets the full name of the package. + /// + public string FullName { get; set; } = string.Empty; + + /// + /// Gets or sets the family name of the package. + /// + public string FamilyName { get; set; } = string.Empty; + + /// + /// Gets or sets a value indicating whether the package is a framework package. + /// + public bool IsFramework { get; set; } + + /// + /// Gets or sets a value indicating whether the package is in development mode. + /// + public bool IsDevelopmentMode { get; set; } + + /// + /// Gets or sets the installed location of the package. + /// + public string InstalledLocation { get; set; } = string.Empty; + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkDataTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkDataTests.cs new file mode 100644 index 0000000000..2ee3deeb5d --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkDataTests.cs @@ -0,0 +1,42 @@ +// 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.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests; + +[TestClass] +public class BookmarkDataTests +{ + [TestMethod] + public void BookmarkDataWebUrlDetection() + { + // Act + var webBookmark = new BookmarkData + { + Name = "Test Site", + Bookmark = "https://test.com", + }; + + var nonWebBookmark = new BookmarkData + { + Name = "Local File", + Bookmark = "C:\\temp\\file.txt", + }; + + var placeholderBookmark = new BookmarkData + { + Name = "Placeholder", + Bookmark = "{Placeholder}", + }; + + // Assert + Assert.IsTrue(webBookmark.IsWebUrl()); + Assert.IsFalse(webBookmark.IsPlaceholder); + Assert.IsFalse(nonWebBookmark.IsWebUrl()); + Assert.IsFalse(nonWebBookmark.IsPlaceholder); + + Assert.IsTrue(placeholderBookmark.IsPlaceholder); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkJsonParserTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkJsonParserTests.cs new file mode 100644 index 0000000000..e442818f8a --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkJsonParserTests.cs @@ -0,0 +1,535 @@ +// 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.Collections.Generic; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests; + +[TestClass] +public class BookmarkJsonParserTests +{ + private BookmarkJsonParser _parser; + + [TestInitialize] + public void Setup() + { + _parser = new BookmarkJsonParser(); + } + + [TestMethod] + public void ParseBookmarks_ValidJson_ReturnsBookmarks() + { + // Arrange + var json = """ + { + "Data": [ + { + "Name": "Google", + "Bookmark": "https://www.google.com" + }, + { + "Name": "Local File", + "Bookmark": "C:\\temp\\file.txt" + } + ] + } + """; + + // Act + var result = _parser.ParseBookmarks(json); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(2, result.Data.Count); + Assert.AreEqual("Google", result.Data[0].Name); + Assert.AreEqual("https://www.google.com", result.Data[0].Bookmark); + Assert.AreEqual("Local File", result.Data[1].Name); + Assert.AreEqual("C:\\temp\\file.txt", result.Data[1].Bookmark); + } + + [TestMethod] + public void ParseBookmarks_EmptyJson_ReturnsEmptyBookmarks() + { + // Arrange + var json = "{}"; + + // Act + var result = _parser.ParseBookmarks(json); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(0, result.Data.Count); + } + + [TestMethod] + public void ParseBookmarks_NullJson_ReturnsEmptyBookmarks() + { + // Act + var result = _parser.ParseBookmarks(null); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(0, result.Data.Count); + } + + [TestMethod] + public void ParseBookmarks_WhitespaceJson_ReturnsEmptyBookmarks() + { + // Act + var result = _parser.ParseBookmarks(" "); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(0, result.Data.Count); + } + + [TestMethod] + public void ParseBookmarks_EmptyString_ReturnsEmptyBookmarks() + { + // Act + var result = _parser.ParseBookmarks(string.Empty); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(0, result.Data.Count); + } + + [TestMethod] + public void ParseBookmarks_InvalidJson_ReturnsEmptyBookmarks() + { + // Arrange + var invalidJson = "{invalid json}"; + + // Act + var result = _parser.ParseBookmarks(invalidJson); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(0, result.Data.Count); + } + + [TestMethod] + public void ParseBookmarks_MalformedJson_ReturnsEmptyBookmarks() + { + // Arrange + var malformedJson = """ + { + "Data": [ + { + "Name": "Google", + "Bookmark": "https://www.google.com" + }, + { + "Name": "Incomplete entry" + """; + + // Act + var result = _parser.ParseBookmarks(malformedJson); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(0, result.Data.Count); + } + + [TestMethod] + public void ParseBookmarks_JsonWithTrailingCommas_ParsesSuccessfully() + { + // Arrange - JSON with trailing commas (should be handled by AllowTrailingCommas option) + var json = """ + { + "Data": [ + { + "Name": "Google", + "Bookmark": "https://www.google.com", + }, + { + "Name": "Local File", + "Bookmark": "C:\\temp\\file.txt", + }, + ] + } + """; + + // Act + var result = _parser.ParseBookmarks(json); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(2, result.Data.Count); + Assert.AreEqual("Google", result.Data[0].Name); + Assert.AreEqual("https://www.google.com", result.Data[0].Bookmark); + } + + [TestMethod] + public void ParseBookmarks_JsonWithDifferentCasing_ParsesSuccessfully() + { + // Arrange - JSON with different property name casing (should be handled by PropertyNameCaseInsensitive option) + var json = """ + { + "data": [ + { + "name": "Google", + "bookmark": "https://www.google.com" + } + ] + } + """; + + // Act + var result = _parser.ParseBookmarks(json); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(1, result.Data.Count); + Assert.AreEqual("Google", result.Data[0].Name); + Assert.AreEqual("https://www.google.com", result.Data[0].Bookmark); + } + + [TestMethod] + public void SerializeBookmarks_ValidBookmarks_ReturnsJsonString() + { + // Arrange + var bookmarks = new Bookmarks + { + Data = new List + { + new BookmarkData { Name = "Google", Bookmark = "https://www.google.com" }, + new BookmarkData { Name = "Local File", Bookmark = "C:\\temp\\file.txt" }, + }, + }; + + // Act + var result = _parser.SerializeBookmarks(bookmarks); + + // Assert + Assert.IsNotNull(result); + Assert.IsTrue(result.Contains("Google")); + Assert.IsTrue(result.Contains("https://www.google.com")); + Assert.IsTrue(result.Contains("Local File")); + Assert.IsTrue(result.Contains("C:\\\\temp\\\\file.txt")); // Escaped backslashes in JSON + Assert.IsTrue(result.Contains("Data")); + } + + [TestMethod] + public void SerializeBookmarks_EmptyBookmarks_ReturnsValidJson() + { + // Arrange + var bookmarks = new Bookmarks(); + + // Act + var result = _parser.SerializeBookmarks(bookmarks); + + // Assert + Assert.IsNotNull(result); + Assert.IsTrue(result.Contains("Data")); + Assert.IsTrue(result.Contains("[]")); + } + + [TestMethod] + public void SerializeBookmarks_NullBookmarks_ReturnsEmptyString() + { + // Act + var result = _parser.SerializeBookmarks(null); + + // Assert + Assert.AreEqual(string.Empty, result); + } + + [TestMethod] + public void ParseBookmarks_RoundTripSerialization_PreservesData() + { + // Arrange + var originalBookmarks = new Bookmarks + { + Data = new List + { + new BookmarkData { Name = "Google", Bookmark = "https://www.google.com" }, + new BookmarkData { Name = "Local File", Bookmark = "C:\\temp\\file.txt" }, + new BookmarkData { Name = "Placeholder", Bookmark = "Open {file} in editor" }, + }, + }; + + // Act - Serialize then parse + var serializedJson = _parser.SerializeBookmarks(originalBookmarks); + var parsedBookmarks = _parser.ParseBookmarks(serializedJson); + + // Assert + Assert.IsNotNull(parsedBookmarks); + Assert.AreEqual(originalBookmarks.Data.Count, parsedBookmarks.Data.Count); + + for (var i = 0; i < originalBookmarks.Data.Count; i++) + { + Assert.AreEqual(originalBookmarks.Data[i].Name, parsedBookmarks.Data[i].Name); + Assert.AreEqual(originalBookmarks.Data[i].Bookmark, parsedBookmarks.Data[i].Bookmark); + Assert.AreEqual(originalBookmarks.Data[i].IsPlaceholder, parsedBookmarks.Data[i].IsPlaceholder); + } + } + + [TestMethod] + public void ParseBookmarks_JsonWithPlaceholderBookmarks_CorrectlyIdentifiesPlaceholders() + { + // Arrange + var json = """ + { + "Data": [ + { + "Name": "Regular URL", + "Bookmark": "https://www.google.com" + }, + { + "Name": "Placeholder Command", + "Bookmark": "notepad {file}" + }, + { + "Name": "Multiple Placeholders", + "Bookmark": "copy {source} {destination}" + } + ] + } + """; + + // Act + var result = _parser.ParseBookmarks(json); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(3, result.Data.Count); + + Assert.IsFalse(result.Data[0].IsPlaceholder); + Assert.IsTrue(result.Data[1].IsPlaceholder); + Assert.IsTrue(result.Data[2].IsPlaceholder); + } + + [TestMethod] + public void ParseBookmarks_IsWebUrl_CorrectlyIdentifiesWebUrls() + { + // Arrange + var json = """ + { + "Data": [ + { + "Name": "HTTPS Website", + "Bookmark": "https://www.google.com" + }, + { + "Name": "HTTP Website", + "Bookmark": "http://example.com" + }, + { + "Name": "Website without protocol", + "Bookmark": "www.github.com" + }, + { + "Name": "Local File Path", + "Bookmark": "C:\\Users\\test\\Documents\\file.txt" + }, + { + "Name": "Network Path", + "Bookmark": "\\\\server\\share\\file.txt" + }, + { + "Name": "Executable", + "Bookmark": "notepad.exe" + }, + { + "Name": "File URI", + "Bookmark": "file:///C:/temp/file.txt" + } + ] + } + """; + + // Act + var result = _parser.ParseBookmarks(json); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(7, result.Data.Count); + + // Web URLs should return true + Assert.IsTrue(result.Data[0].IsWebUrl(), "HTTPS URL should be identified as web URL"); + Assert.IsTrue(result.Data[1].IsWebUrl(), "HTTP URL should be identified as web URL"); + + // This case will fail. We need to consider if we need to support pure domain value in bookmark. + // Assert.IsTrue(result.Data[2].IsWebUrl(), "Domain without protocol should be identified as web URL"); + + // Non-web URLs should return false + Assert.IsFalse(result.Data[3].IsWebUrl(), "Local file path should not be identified as web URL"); + Assert.IsFalse(result.Data[4].IsWebUrl(), "Network path should not be identified as web URL"); + Assert.IsFalse(result.Data[5].IsWebUrl(), "Executable should not be identified as web URL"); + Assert.IsFalse(result.Data[6].IsWebUrl(), "File URI should not be identified as web URL"); + } + + [TestMethod] + public void ParseBookmarks_IsPlaceholder_CorrectlyIdentifiesPlaceholders() + { + // Arrange + var json = """ + { + "Data": [ + { + "Name": "Simple Placeholder", + "Bookmark": "notepad {file}" + }, + { + "Name": "Multiple Placeholders", + "Bookmark": "copy {source} to {destination}" + }, + { + "Name": "Web URL with Placeholder", + "Bookmark": "https://search.com?q={query}" + }, + { + "Name": "Complex Placeholder", + "Bookmark": "cmd /c echo {message} > {output_file}" + }, + { + "Name": "No Placeholder - Regular URL", + "Bookmark": "https://www.google.com" + }, + { + "Name": "No Placeholder - Local File", + "Bookmark": "C:\\temp\\file.txt" + }, + { + "Name": "False Positive - Only Opening Brace", + "Bookmark": "test { incomplete" + }, + { + "Name": "False Positive - Only Closing Brace", + "Bookmark": "test } incomplete" + }, + { + "Name": "Empty Placeholder", + "Bookmark": "command {}" + } + ] + } + """; + + // Act + var result = _parser.ParseBookmarks(json); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(9, result.Data.Count); + + // Should be identified as placeholders + Assert.IsTrue(result.Data[0].IsPlaceholder, "Simple placeholder should be identified"); + Assert.IsTrue(result.Data[1].IsPlaceholder, "Multiple placeholders should be identified"); + Assert.IsTrue(result.Data[2].IsPlaceholder, "Web URL with placeholder should be identified"); + Assert.IsTrue(result.Data[3].IsPlaceholder, "Complex placeholder should be identified"); + Assert.IsTrue(result.Data[8].IsPlaceholder, "Empty placeholder should be identified"); + + // Should NOT be identified as placeholders + Assert.IsFalse(result.Data[4].IsPlaceholder, "Regular URL should not be placeholder"); + Assert.IsFalse(result.Data[5].IsPlaceholder, "Local file should not be placeholder"); + Assert.IsFalse(result.Data[6].IsPlaceholder, "Only opening brace should not be placeholder"); + Assert.IsFalse(result.Data[7].IsPlaceholder, "Only closing brace should not be placeholder"); + } + + [TestMethod] + public void ParseBookmarks_MixedProperties_CorrectlyIdentifiesBothWebUrlAndPlaceholder() + { + // Arrange + var json = """ + { + "Data": [ + { + "Name": "Web URL with Placeholder", + "Bookmark": "https://google.com/search?q={query}" + }, + { + "Name": "Web URL without Placeholder", + "Bookmark": "https://github.com" + }, + { + "Name": "Local File with Placeholder", + "Bookmark": "notepad {file}" + }, + { + "Name": "Local File without Placeholder", + "Bookmark": "C:\\Windows\\notepad.exe" + } + ] + } + """; + + // Act + var result = _parser.ParseBookmarks(json); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(4, result.Data.Count); + + // Web URL with placeholder + Assert.IsTrue(result.Data[0].IsWebUrl(), "Web URL with placeholder should be identified as web URL"); + Assert.IsTrue(result.Data[0].IsPlaceholder, "Web URL with placeholder should be identified as placeholder"); + + // Web URL without placeholder + Assert.IsTrue(result.Data[1].IsWebUrl(), "Web URL without placeholder should be identified as web URL"); + Assert.IsFalse(result.Data[1].IsPlaceholder, "Web URL without placeholder should not be identified as placeholder"); + + // Local file with placeholder + Assert.IsFalse(result.Data[2].IsWebUrl(), "Local file with placeholder should not be identified as web URL"); + Assert.IsTrue(result.Data[2].IsPlaceholder, "Local file with placeholder should be identified as placeholder"); + + // Local file without placeholder + Assert.IsFalse(result.Data[3].IsWebUrl(), "Local file without placeholder should not be identified as web URL"); + Assert.IsFalse(result.Data[3].IsPlaceholder, "Local file without placeholder should not be identified as placeholder"); + } + + [TestMethod] + public void ParseBookmarks_EdgeCaseUrls_CorrectlyIdentifiesWebUrls() + { + // Arrange + var json = """ + { + "Data": [ + { + "Name": "FTP URL", + "Bookmark": "ftp://files.example.com" + }, + { + "Name": "HTTPS with port", + "Bookmark": "https://localhost:8080" + }, + { + "Name": "IP Address", + "Bookmark": "http://192.168.1.1" + }, + { + "Name": "Subdomain", + "Bookmark": "https://api.github.com" + }, + { + "Name": "Domain only", + "Bookmark": "example.com" + }, + { + "Name": "Not a URL - no dots", + "Bookmark": "localhost" + } + ] + } + """; + + // Act + var result = _parser.ParseBookmarks(json); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(6, result.Data.Count); + + Assert.IsFalse(result.Data[0].IsWebUrl(), "FTP URL should not be identified as web URL"); + Assert.IsTrue(result.Data[1].IsWebUrl(), "HTTPS with port should be identified as web URL"); + Assert.IsTrue(result.Data[2].IsWebUrl(), "IP Address with HTTP should be identified as web URL"); + Assert.IsTrue(result.Data[3].IsWebUrl(), "Subdomain should be identified as web URL"); + + // This case will fail. We need to consider if we need to support pure domain value in bookmark. + // Assert.IsTrue(result.Data[4].IsWebUrl(), "Domain only should be identified as web URL"); + Assert.IsFalse(result.Data[5].IsWebUrl(), "Single word without dots should not be identified as web URL"); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarksCommandProviderTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarksCommandProviderTests.cs new file mode 100644 index 0000000000..52f50727a7 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarksCommandProviderTests.cs @@ -0,0 +1,137 @@ +// 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.Collections.Generic; +using System.Linq; +using Microsoft.CmdPal.Ext.Bookmarks; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests; + +[TestClass] +public class BookmarksCommandProviderTests +{ + [TestMethod] + public void ProviderHasCorrectId() + { + // Setup + var mockDataSource = new MockBookmarkDataSource(); + var provider = new BookmarksCommandProvider(mockDataSource); + + // Assert + Assert.AreEqual("Bookmarks", provider.Id); + } + + [TestMethod] + public void ProviderHasDisplayName() + { + // Setup + var mockDataSource = new MockBookmarkDataSource(); + var provider = new BookmarksCommandProvider(mockDataSource); + + // Assert + Assert.IsNotNull(provider.DisplayName); + Assert.IsTrue(provider.DisplayName.Length > 0); + } + + [TestMethod] + public void ProviderHasIcon() + { + // Setup + var provider = new BookmarksCommandProvider(); + + // Assert + Assert.IsNotNull(provider.Icon); + } + + [TestMethod] + public void TopLevelCommandsNotEmpty() + { + // Setup + var provider = new BookmarksCommandProvider(); + + // Act + var commands = provider.TopLevelCommands(); + + // Assert + Assert.IsNotNull(commands); + Assert.IsTrue(commands.Length > 0); + } + + [TestMethod] + public void ProviderWithMockData_LoadsBookmarksCorrectly() + { + // Arrange + var jsonData = @"{ + ""Data"": [ + { + ""Name"": ""Test Bookmark"", + ""Bookmark"": ""https://test.com"" + }, + { + ""Name"": ""Another Bookmark"", + ""Bookmark"": ""https://another.com"" + } + ] + }"; + + var dataSource = new MockBookmarkDataSource(jsonData); + var provider = new BookmarksCommandProvider(dataSource); + + // Act + var commands = provider.TopLevelCommands(); + + // Assert + Assert.IsNotNull(commands); + + var addCommand = commands.Where(c => c.Title.Contains("Add bookmark")).FirstOrDefault(); + var testBookmark = commands.Where(c => c.Title.Contains("Test Bookmark")).FirstOrDefault(); + + // Should have three commands:Add + two custom bookmarks + Assert.AreEqual(3, commands.Length); + + Assert.IsNotNull(addCommand); + Assert.IsNotNull(testBookmark); + } + + [TestMethod] + public void ProviderWithEmptyData_HasOnlyAddCommand() + { + // Arrange + var dataSource = new MockBookmarkDataSource(@"{ ""Data"": [] }"); + var provider = new BookmarksCommandProvider(dataSource); + + // Act + var commands = provider.TopLevelCommands(); + + // Assert + Assert.IsNotNull(commands); + + // Only have Add command + Assert.AreEqual(1, commands.Length); + + var addCommand = commands.Where(c => c.Title.Contains("Add bookmark")).FirstOrDefault(); + Assert.IsNotNull(addCommand); + } + + [TestMethod] + public void ProviderWithInvalidData_HandlesGracefully() + { + // Arrange + var dataSource = new MockBookmarkDataSource("invalid json"); + var provider = new BookmarksCommandProvider(dataSource); + + // Act + var commands = provider.TopLevelCommands(); + + // Assert + Assert.IsNotNull(commands); + + // Only have one command. Will ignore json parse error. + Assert.AreEqual(1, commands.Length); + + var addCommand = commands.Where(c => c.Title.Contains("Add bookmark")).FirstOrDefault(); + Assert.IsNotNull(addCommand); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests.csproj b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests.csproj new file mode 100644 index 0000000000..07b6a9bfe5 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests.csproj @@ -0,0 +1,23 @@ + + + + + + false + true + Microsoft.CmdPal.Ext.Bookmarks.UnitTests + $(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal\tests\ + false + false + + + + + + + + + + + + diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/MockBookmarkDataSource.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/MockBookmarkDataSource.cs new file mode 100644 index 0000000000..ae3732559c --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/MockBookmarkDataSource.cs @@ -0,0 +1,24 @@ +// 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. +namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests; + +internal sealed class MockBookmarkDataSource : IBookmarkDataSource +{ + private string _jsonData; + + public MockBookmarkDataSource(string initialJsonData = "[]") + { + _jsonData = initialJsonData; + } + + public string GetBookmarkData() + { + return _jsonData; + } + + public void SaveBookmarkData(string jsonData) + { + _jsonData = jsonData; + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/QueryTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/QueryTests.cs new file mode 100644 index 0000000000..767460fa27 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/QueryTests.cs @@ -0,0 +1,55 @@ +// 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.Linq; +using Microsoft.CmdPal.Ext.UnitTestBase; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests; + +[TestClass] +public class QueryTests : CommandPaletteUnitTestBase +{ + [TestMethod] + public void ValidateBookmarksCreation() + { + // Setup + var bookmarks = Settings.CreateDefaultBookmarks(); + + // Assert + Assert.IsNotNull(bookmarks); + Assert.IsNotNull(bookmarks.Data); + Assert.AreEqual(2, bookmarks.Data.Count); + } + + [TestMethod] + public void ValidateBookmarkData() + { + // Setup + var bookmarks = Settings.CreateDefaultBookmarks(); + + // Act + var microsoftBookmark = bookmarks.Data.FirstOrDefault(b => b.Name == "Microsoft"); + var githubBookmark = bookmarks.Data.FirstOrDefault(b => b.Name == "GitHub"); + + // Assert + Assert.IsNotNull(microsoftBookmark); + Assert.AreEqual("https://www.microsoft.com", microsoftBookmark.Bookmark); + + Assert.IsNotNull(githubBookmark); + Assert.AreEqual("https://github.com", githubBookmark.Bookmark); + } + + [TestMethod] + public void ValidateWebUrlDetection() + { + // Setup + var bookmarks = Settings.CreateDefaultBookmarks(); + var microsoftBookmark = bookmarks.Data.FirstOrDefault(b => b.Name == "Microsoft"); + + // Assert + Assert.IsNotNull(microsoftBookmark); + Assert.IsTrue(microsoftBookmark.IsWebUrl()); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/Settings.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/Settings.cs new file mode 100644 index 0000000000..82d7cd1cad --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/Settings.cs @@ -0,0 +1,28 @@ +// 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. + +namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests; + +public static class Settings +{ + public static Bookmarks CreateDefaultBookmarks() + { + var bookmarks = new Bookmarks(); + + // Add some test bookmarks + bookmarks.Data.Add(new BookmarkData + { + Name = "Microsoft", + Bookmark = "https://www.microsoft.com", + }); + + bookmarks.Data.Add(new BookmarkData + { + Name = "GitHub", + Bookmark = "https://github.com", + }); + + return bookmarks; + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Calc.UnitTests/CloseOnEnterTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Calc.UnitTests/CloseOnEnterTests.cs new file mode 100644 index 0000000000..5c4cf39783 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Calc.UnitTests/CloseOnEnterTests.cs @@ -0,0 +1,60 @@ +// 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.Globalization; +using System.Linq; +using Microsoft.CmdPal.Ext.Calc.Helper; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Windows.Foundation; + +namespace Microsoft.CmdPal.Ext.Calc.UnitTests; + +[TestClass] +public class CloseOnEnterTests +{ + [TestMethod] + public void PrimaryIsCopy_WhenCloseOnEnterTrue() + { + var settings = new Settings(closeOnEnter: true); + TypedEventHandler handleSave = (s, e) => { }; + + var item = ResultHelper.CreateResult( + 4m, + CultureInfo.CurrentCulture, + CultureInfo.CurrentCulture, + "2+2", + settings, + handleSave); + + Assert.IsNotNull(item); + Assert.IsInstanceOfType(item.Command, typeof(CopyTextCommand)); + + var firstMore = item.MoreCommands.First(); + Assert.IsInstanceOfType(firstMore, typeof(CommandContextItem)); + Assert.IsInstanceOfType(((CommandItem)firstMore).Command, typeof(SaveCommand)); + } + + [TestMethod] + public void PrimaryIsSave_WhenCloseOnEnterFalse() + { + var settings = new Settings(closeOnEnter: false); + TypedEventHandler handleSave = (s, e) => { }; + + var item = ResultHelper.CreateResult( + 4m, + CultureInfo.CurrentCulture, + CultureInfo.CurrentCulture, + "2+2", + settings, + handleSave); + + Assert.IsNotNull(item); + Assert.IsInstanceOfType(item.Command, typeof(SaveCommand)); + + var firstMore = item.MoreCommands.First(); + Assert.IsInstanceOfType(firstMore, typeof(CommandContextItem)); + Assert.IsInstanceOfType(((CommandItem)firstMore).Command, typeof(CopyTextCommand)); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/Microsoft.CmdPal.Ext.Shell.UnitTests.csproj b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/Microsoft.CmdPal.Ext.Shell.UnitTests.csproj new file mode 100644 index 0000000000..74aeb04b39 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/Microsoft.CmdPal.Ext.Shell.UnitTests.csproj @@ -0,0 +1,23 @@ + + + + + + false + true + Microsoft.CmdPal.Ext.Shell.UnitTests + $(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal\tests\ + false + false + + + + + + + + + + + + diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/QueryTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/QueryTests.cs new file mode 100644 index 0000000000..bf9fcac406 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/QueryTests.cs @@ -0,0 +1,146 @@ +// 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.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.CmdPal.Common.Services; +using Microsoft.CmdPal.Ext.Shell.Pages; +using Microsoft.CmdPal.Ext.UnitTestBase; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Microsoft.CmdPal.Ext.Shell.UnitTests; + +[TestClass] +public class QueryTests : CommandPaletteUnitTestBase +{ + private static Mock CreateMockHistoryService(IList historyItems = null) + { + var mockHistoryService = new Mock(); + var history = historyItems ?? new List(); + + mockHistoryService.Setup(x => x.GetRunHistory()) + .Returns(() => history.ToList().AsReadOnly()); + + mockHistoryService.Setup(x => x.AddRunHistoryItem(It.IsAny())) + .Callback(item => + { + if (!string.IsNullOrWhiteSpace(item)) + { + history.Remove(item); + history.Insert(0, item); + } + }); + + mockHistoryService.Setup(x => x.ClearRunHistory()) + .Callback(() => history.Clear()); + + return mockHistoryService; + } + + private static Mock CreateMockHistoryServiceWithCommonCommands() + { + var commonCommands = new List + { + "ping google.com", + "ipconfig /all", + "curl https://api.github.com", + "dir", + "cd ..", + "git status", + "npm install", + "python --version", + }; + + return CreateMockHistoryService(commonCommands); + } + + [TestMethod] + public void ValidateHistoryFunctionality() + { + // Setup + var settings = Settings.CreateDefaultSettings(); + + // Act + settings.AddCmdHistory("test-command"); + + // Assert + Assert.AreEqual(1, settings.Count["test-command"]); + } + + [TestMethod] + [DataRow("ping bing.com", "ping.exe")] + [DataRow("curl bing.com", "curl.exe")] + [DataRow("ipconfig /all", "ipconfig.exe")] + public async Task QueryWithoutHistoryCommand(string command, string exeName) + { + // Setup + var settings = Settings.CreateDefaultSettings(); + var mockHistory = CreateMockHistoryService(); + + var pages = new ShellListPage(settings, mockHistory.Object); + + pages.UpdateSearchText(string.Empty, command); + + // wait for about 1s. + await Task.Delay(1000); + + var commandList = pages.GetItems(); + + Assert.AreEqual(1, commandList.Length); + + var executeCommand = commandList.FirstOrDefault(); + Assert.IsNotNull(executeCommand); + Assert.IsNotNull(executeCommand.Icon); + Assert.IsTrue(executeCommand.Title.Contains(exeName), $"expect ${exeName} but got ${executeCommand.Title}"); + } + + [TestMethod] + [DataRow("ping bing.com", "ping.exe")] + [DataRow("curl bing.com", "curl.exe")] + [DataRow("ipconfig /all", "ipconfig.exe")] + public async Task QueryWithHistoryCommands(string command, string exeName) + { + // Setup + var settings = Settings.CreateDefaultSettings(); + var mockHistoryService = CreateMockHistoryServiceWithCommonCommands(); + + var pages = new ShellListPage(settings, mockHistoryService.Object); + + // Test: Search for a command that exists in history + pages.UpdateSearchText(string.Empty, command); + + await Task.Delay(1000); + + var commandList = pages.GetItems(); + + // Should find at least the ping command from history + Assert.IsTrue(commandList.Length > 1); + + var expectedCommand = commandList.FirstOrDefault(); + Assert.IsNotNull(expectedCommand); + Assert.IsNotNull(expectedCommand.Icon); + Assert.IsTrue(expectedCommand.Title.Contains(exeName), $"expect ${exeName} but got ${expectedCommand.Title}"); + } + + [TestMethod] + public async Task EmptyQueryWithHistoryCommands() + { + // Setup + var settings = Settings.CreateDefaultSettings(); + var mockHistoryService = CreateMockHistoryServiceWithCommonCommands(); + + var pages = new ShellListPage(settings, mockHistoryService.Object); + + pages.UpdateSearchText("abcdefg", string.Empty); + + await Task.Delay(1000); + + var commandList = pages.GetItems(); + + // Should find at least the ping command from history + Assert.IsTrue(commandList.Length > 1); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/Settings.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/Settings.cs new file mode 100644 index 0000000000..953f252be8 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/Settings.cs @@ -0,0 +1,49 @@ +// 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.Collections.Generic; +using Microsoft.CmdPal.Ext.Shell.Helpers; + +namespace Microsoft.CmdPal.Ext.Shell.UnitTests; + +public class Settings : ISettingsInterface +{ + private readonly bool leaveShellOpen; + private readonly string shellCommandExecution; + private readonly bool runAsAdministrator; + private readonly Dictionary count; + + public Settings( + bool leaveShellOpen = false, + string shellCommandExecution = "0", + bool runAsAdministrator = false, + Dictionary count = null) + { + this.leaveShellOpen = leaveShellOpen; + this.shellCommandExecution = shellCommandExecution; + this.runAsAdministrator = runAsAdministrator; + this.count = count ?? new Dictionary(); + } + + public bool LeaveShellOpen => leaveShellOpen; + + public string ShellCommandExecution => shellCommandExecution; + + public bool RunAsAdministrator => runAsAdministrator; + + public Dictionary Count => count; + + public void AddCmdHistory(string cmdName) + { + count[cmdName] = count.TryGetValue(cmdName, out var currentCount) ? currentCount + 1 : 1; + } + + public static Settings CreateDefaultSettings() => new Settings(); + + public static Settings CreateLeaveShellOpenSettings() => new Settings(leaveShellOpen: true); + + public static Settings CreatePowerShellSettings() => new Settings(shellCommandExecution: "1"); + + public static Settings CreateAdministratorSettings() => new Settings(runAsAdministrator: true); +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/ShellCommandProviderTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/ShellCommandProviderTests.cs new file mode 100644 index 0000000000..42fb0900a4 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/ShellCommandProviderTests.cs @@ -0,0 +1,51 @@ +// 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.CmdPal.Common.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Microsoft.CmdPal.Ext.Shell.UnitTests; + +[TestClass] +public class ShellCommandProviderTests +{ + [TestMethod] + public void ProviderHasDisplayName() + { + // Setup + var mockHistoryService = new Mock(); + var provider = new ShellCommandsProvider(mockHistoryService.Object); + + // Assert + Assert.IsNotNull(provider.DisplayName); + Assert.IsTrue(provider.DisplayName.Length > 0); + } + + [TestMethod] + public void ProviderHasIcon() + { + // Setup + var mockHistoryService = new Mock(); + var provider = new ShellCommandsProvider(mockHistoryService.Object); + + // Assert + Assert.IsNotNull(provider.Icon); + } + + [TestMethod] + public void TopLevelCommandsNotEmpty() + { + // Setup + var mockHistoryService = new Mock(); + var provider = new ShellCommandsProvider(mockHistoryService.Object); + + // Act + var commands = provider.TopLevelCommands(); + + // Assert + Assert.IsNotNull(commands); + Assert.IsTrue(commands.Length > 0); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/Microsoft.CmdPal.Ext.WebSearch.UnitTests.csproj b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/Microsoft.CmdPal.Ext.WebSearch.UnitTests.csproj new file mode 100644 index 0000000000..d819beb7c7 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/Microsoft.CmdPal.Ext.WebSearch.UnitTests.csproj @@ -0,0 +1,23 @@ + + + + + + false + true + Microsoft.CmdPal.Ext.WebSearch.UnitTests + $(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal\tests\ + false + false + + + + + + + + + + + + diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/MockSettingsInterface.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/MockSettingsInterface.cs new file mode 100644 index 0000000000..853360674f --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/MockSettingsInterface.cs @@ -0,0 +1,73 @@ +// 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.Collections.Generic; +using Microsoft.CmdPal.Ext.WebSearch.Commands; +using Microsoft.CmdPal.Ext.WebSearch.Helpers; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.WebSearch.UnitTests; + +public class MockSettingsInterface : ISettingsInterface +{ + private readonly List _historyItems; + + public bool GlobalIfURI { get; set; } + + public string ShowHistory { get; set; } + + public MockSettingsInterface(string showHistory = "none", bool globalIfUri = true, List mockHistory = null) + { + _historyItems = mockHistory ?? new List(); + GlobalIfURI = globalIfUri; + ShowHistory = showHistory; + } + + public List LoadHistory() + { + var listItems = new List(); + foreach (var historyItem in _historyItems) + { + listItems.Add(new ListItem(new SearchWebCommand(historyItem.SearchString, this)) + { + Title = historyItem.SearchString, + Subtitle = historyItem.Timestamp.ToString("g", System.Globalization.CultureInfo.InvariantCulture), + }); + } + + listItems.Reverse(); + return listItems; + } + + public void SaveHistory(HistoryItem historyItem) + { + if (historyItem is null) + { + return; + } + + _historyItems.Add(historyItem); + + // Simulate the same logic as SettingsManager + if (int.TryParse(ShowHistory, out var maxHistoryItems) && maxHistoryItems > 0) + { + while (_historyItems.Count > maxHistoryItems) + { + _historyItems.RemoveAt(0); // Remove the oldest item + } + } + } + + // Helper method for testing + public void ClearHistory() + { + _historyItems.Clear(); + } + + // Helper method for testing + public int GetHistoryCount() + { + return _historyItems.Count; + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/QueryTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/QueryTests.cs new file mode 100644 index 0000000000..64a5366a61 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/QueryTests.cs @@ -0,0 +1,141 @@ +// 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.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.CmdPal.Ext.UnitTestBase; +using Microsoft.CmdPal.Ext.WebSearch.Commands; +using Microsoft.CmdPal.Ext.WebSearch.Helpers; +using Microsoft.CmdPal.Ext.WebSearch.Pages; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.WebSearch.UnitTests; + +[TestClass] +public class QueryTests : CommandPaletteUnitTestBase +{ + [TestMethod] + [DataRow("microsoft")] + [DataRow("windows")] + public async Task SearchInWebSearchPage(string query) + { + // Setup + var settings = new MockSettingsInterface(); + + var page = new WebSearchListPage(settings); + + // Act + page.UpdateSearchText(string.Empty, query); + await Task.Delay(1000); + + var listItem = page.GetItems(); + Assert.IsNotNull(listItem); + Assert.AreEqual(1, listItem.Length); + + var expectedItem = listItem.FirstOrDefault(); + + Assert.IsNotNull(expectedItem); + Assert.IsTrue(expectedItem.Subtitle.Contains("Search the web in"), $"Expected \"search the web in chrome/edge\" but got {expectedItem.Subtitle}"); + Assert.AreEqual(query, expectedItem.Title); + } + + [TestMethod] + public async Task LoadHistoryReturnsExpectedItems() + { + // Setup + var mockHistoryItems = new List + { + new HistoryItem("test search", DateTime.Parse("2024-01-01 13:00:00", CultureInfo.CurrentCulture)), + new HistoryItem("another search", DateTime.Parse("2024-01-02 13:00:00", CultureInfo.CurrentCulture)), + }; + + var settings = new MockSettingsInterface(mockHistory: mockHistoryItems, showHistory: "5"); + + var page = new WebSearchListPage(settings); + + // Act + page.UpdateSearchText("abcdef", string.Empty); + await Task.Delay(1000); + + var listItem = page.GetItems(); + + // Assert + Assert.IsNotNull(listItem); + Assert.AreEqual(2, listItem.Length); + + foreach (var item in listItem) + { + Assert.IsNotNull(item); + Assert.IsNotEmpty(item.Title); + Assert.IsNotEmpty(item.Subtitle); + } + } + + [TestMethod] + public async Task LoadHistoryMoreThanLimitation() + { + // Setup + var mockHistoryItems = new List + { + new HistoryItem("test search", DateTime.Parse("2024-01-01 13:00:00", CultureInfo.CurrentCulture)), + new HistoryItem("another search1", DateTime.Parse("2024-01-02 13:00:00", CultureInfo.CurrentCulture)), + new HistoryItem("another search2", DateTime.Parse("2024-01-03 13:00:00", CultureInfo.CurrentCulture)), + new HistoryItem("another search3", DateTime.Parse("2024-01-04 13:00:00", CultureInfo.CurrentCulture)), + new HistoryItem("another search4", DateTime.Parse("2024-01-05 13:00:00", CultureInfo.CurrentCulture)), + }; + + var settings = new MockSettingsInterface(mockHistory: mockHistoryItems, showHistory: "5"); + + var page = new WebSearchListPage(settings); + + mockHistoryItems.Add(new HistoryItem("another search5", DateTime.Parse("2024-01-06 13:00:00", CultureInfo.CurrentCulture))); + + // Act + page.UpdateSearchText("abcdef", string.Empty); + await Task.Delay(1000); + + var listItem = page.GetItems(); + + // Assert + Assert.IsNotNull(listItem); + + // Make sure only load five item. + Assert.AreEqual(5, listItem.Length); + } + + [TestMethod] + public async Task LoadHistoryWithDisableSetting() + { + // Setup + var mockHistoryItems = new List + { + new HistoryItem("test search", DateTime.Parse("2024-01-01 13:00:00", CultureInfo.CurrentCulture)), + new HistoryItem("another search1", DateTime.Parse("2024-01-02 13:00:00", CultureInfo.CurrentCulture)), + new HistoryItem("another search2", DateTime.Parse("2024-01-03 13:00:00", CultureInfo.CurrentCulture)), + new HistoryItem("another search3", DateTime.Parse("2024-01-04 13:00:00", CultureInfo.CurrentCulture)), + new HistoryItem("another search4", DateTime.Parse("2024-01-05 13:00:00", CultureInfo.CurrentCulture)), + new HistoryItem("another search5", DateTime.Parse("2024-01-06 13:00:00", CultureInfo.CurrentCulture)), + }; + + var settings = new MockSettingsInterface(mockHistory: mockHistoryItems, showHistory: "None"); + + var page = new WebSearchListPage(settings); + + // Act + page.UpdateSearchText("abcdef", string.Empty); + await Task.Delay(1000); + + var listItem = page.GetItems(); + + // Assert + Assert.IsNotNull(listItem); + + // Make sure only load five item. + Assert.AreEqual(0, listItem.Length); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/WebSearchCommandProviderTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/WebSearchCommandProviderTests.cs new file mode 100644 index 0000000000..c141d28d6e --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/WebSearchCommandProviderTests.cs @@ -0,0 +1,56 @@ +// 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.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.WebSearch.UnitTests; + +[TestClass] +public class WebSearchCommandProviderTests +{ + [TestMethod] + public void ProviderHasCorrectId() + { + // Setup + var provider = new WebSearchCommandsProvider(); + + // Assert + Assert.AreEqual("WebSearch", provider.Id); + } + + [TestMethod] + public void ProviderHasDisplayName() + { + // Setup + var provider = new WebSearchCommandsProvider(); + + // Assert + Assert.IsNotNull(provider.DisplayName); + Assert.IsTrue(provider.DisplayName.Length > 0); + } + + [TestMethod] + public void ProviderHasIcon() + { + // Setup + var provider = new WebSearchCommandsProvider(); + + // Assert + Assert.IsNotNull(provider.Icon); + } + + [TestMethod] + public void TopLevelCommandsNotEmpty() + { + // Setup + var provider = new WebSearchCommandsProvider(); + + // Act + var commands = provider.TopLevelCommands(); + + // Assert + Assert.IsNotNull(commands); + Assert.IsTrue(commands.Length > 0); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.UITests/CommandPaletteTestBase.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UITests/CommandPaletteTestBase.cs index ab7dac1a3a..a839c3e999 100644 --- a/src/modules/cmdpal/Tests/Microsoft.CmdPal.UITests/CommandPaletteTestBase.cs +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UITests/CommandPaletteTestBase.cs @@ -19,29 +19,22 @@ public class CommandPaletteTestBase : UITestBase { } - protected void SetSearchBox(string text) - { - Assert.AreEqual(this.Find("Type here to search...").SetText(text, true).Text, text); - } + protected void SetSearchBox(string text) => SetSearchBoxText(text); - protected void SetFilesExtensionSearchBox(string text) - { - Assert.AreEqual(this.Find("Search for files and folders...").SetText(text, true).Text, text); - } + protected void SetFilesExtensionSearchBox(string text) => SetSearchBoxText(text); - protected void SetCalculatorExtensionSearchBox(string text) - { - Assert.AreEqual(this.Find("Type an equation...").SetText(text, true).Text, text); - } + protected void SetCalculatorExtensionSearchBox(string text) => SetSearchBoxText(text); - protected void SetTimeAndDaterExtensionSearchBox(string text) + protected void SetTimeAndDaterExtensionSearchBox(string text) => SetSearchBoxText(text); + + private void SetSearchBoxText(string text) { - Assert.AreEqual(this.Find("Search values or type a custom time stamp...").SetText(text, true).Text, text); + Assert.AreEqual(this.Find(By.AccessibilityId("MainSearchBox")).SetText(text, true).Text, text); } protected void OpenContextMenu() { - var contextMenuButton = this.Find -internal sealed partial class PackageRepository : ListRepository, IProgramRepository +internal sealed partial class PackageRepository : ListRepository, IProgramRepository { private readonly IPackageCatalog _packageCatalog; diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarkJsonParser.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarkJsonParser.cs new file mode 100644 index 0000000000..7cc82c9c02 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarkJsonParser.cs @@ -0,0 +1,45 @@ +// 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.Collections.Generic; +using System.Text.Json; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.Bookmarks; + +public class BookmarkJsonParser +{ + public BookmarkJsonParser() + { + } + + public Bookmarks ParseBookmarks(string json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return new Bookmarks(); + } + + try + { + var bookmarks = JsonSerializer.Deserialize(json, BookmarkSerializationContext.Default.Bookmarks); + return bookmarks ?? new Bookmarks(); + } + catch (JsonException ex) + { + ExtensionHost.LogMessage($"parse bookmark data failed. ex: {ex.Message}"); + return new Bookmarks(); + } + } + + public string SerializeBookmarks(Bookmarks? bookmarks) + { + if (bookmarks == null) + { + return string.Empty; + } + + return JsonSerializer.Serialize(bookmarks, BookmarkSerializationContext.Default.Bookmarks); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Bookmarks.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Bookmarks.cs index 8f2e257782..b02eb54e0f 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Bookmarks.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Bookmarks.cs @@ -11,34 +11,4 @@ namespace Microsoft.CmdPal.Ext.Bookmarks; public sealed class Bookmarks { public List Data { get; set; } = []; - - private static readonly JsonSerializerOptions _jsonOptions = new() - { - IncludeFields = true, - }; - - public static Bookmarks ReadFromFile(string path) - { - var data = new Bookmarks(); - - // if the file exists, load it and append the new item - if (File.Exists(path)) - { - var jsonStringReading = File.ReadAllText(path); - - if (!string.IsNullOrEmpty(jsonStringReading)) - { - data = JsonSerializer.Deserialize(jsonStringReading, BookmarkSerializationContext.Default.Bookmarks) ?? new Bookmarks(); - } - } - - return data; - } - - public static void WriteToFile(string path, Bookmarks data) - { - var jsonString = JsonSerializer.Serialize(data, BookmarkSerializationContext.Default.Bookmarks); - - File.WriteAllText(BookmarksCommandProvider.StateJsonPath(), jsonString); - } } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarksCommandProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarksCommandProvider.cs index 081fb2bccb..1174685729 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarksCommandProvider.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarksCommandProvider.cs @@ -20,10 +20,20 @@ public partial class BookmarksCommandProvider : CommandProvider private readonly AddBookmarkPage _addNewCommand = new(null); + private readonly IBookmarkDataSource _dataSource; + private readonly BookmarkJsonParser _parser; private Bookmarks? _bookmarks; public BookmarksCommandProvider() + : this(new FileBookmarkDataSource(StateJsonPath())) { + } + + internal BookmarksCommandProvider(IBookmarkDataSource dataSource) + { + _dataSource = dataSource; + _parser = new BookmarkJsonParser(); + Id = "Bookmarks"; DisplayName = Resources.bookmarks_display_name; Icon = Icons.PinIcon; @@ -49,10 +59,14 @@ public partial class BookmarksCommandProvider : CommandProvider private void SaveAndUpdateCommands() { - if (_bookmarks is not null) + try { - var jsonPath = BookmarksCommandProvider.StateJsonPath(); - Bookmarks.WriteToFile(jsonPath, _bookmarks); + var jsonData = _parser.SerializeBookmarks(_bookmarks); + _dataSource.SaveBookmarkData(jsonData); + } + catch (Exception ex) + { + Logger.LogError($"Failed to save bookmarks: {ex.Message}"); } LoadCommands(); @@ -82,11 +96,8 @@ public partial class BookmarksCommandProvider : CommandProvider { try { - var jsonFile = StateJsonPath(); - if (File.Exists(jsonFile)) - { - _bookmarks = Bookmarks.ReadFromFile(jsonFile); - } + var jsonData = _dataSource.GetBookmarkData(); + _bookmarks = _parser.ParseBookmarks(jsonData); } catch (Exception ex) { diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/FileBookmarkDataSource.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/FileBookmarkDataSource.cs new file mode 100644 index 0000000000..a87859c3ce --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/FileBookmarkDataSource.cs @@ -0,0 +1,49 @@ +// 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 Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.Bookmarks; + +public class FileBookmarkDataSource : IBookmarkDataSource +{ + private readonly string _filePath; + + public FileBookmarkDataSource(string filePath) + { + _filePath = filePath; + } + + public string GetBookmarkData() + { + if (!File.Exists(_filePath)) + { + return string.Empty; + } + + try + { + return File.ReadAllText(_filePath); + } + catch (Exception ex) + { + ExtensionHost.LogMessage($"Read bookmark data failed. ex: {ex.Message}"); + return string.Empty; + } + } + + public void SaveBookmarkData(string jsonData) + { + try + { + File.WriteAllText(_filePath, jsonData); + } + catch (Exception ex) + { + ExtensionHost.LogMessage($"Failed to save bookmark data: {ex}"); + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/IBookmarkDataSource.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/IBookmarkDataSource.cs new file mode 100644 index 0000000000..7ed936a1c7 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/IBookmarkDataSource.cs @@ -0,0 +1,11 @@ +// 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. +namespace Microsoft.CmdPal.Ext.Bookmarks; + +public interface IBookmarkDataSource +{ + string GetBookmarkData(); + + void SaveBookmarkData(string jsonData); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Properties/AssemblyInfo.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..a74d97eeca --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Properties/AssemblyInfo.cs @@ -0,0 +1,7 @@ +// 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.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Microsoft.CmdPal.Ext.Bookmarks.UnitTests")] diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Commands/ExecuteItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Commands/ExecuteItem.cs index 4ca772dc1e..f41f5e0ab7 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Commands/ExecuteItem.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Commands/ExecuteItem.cs @@ -14,14 +14,14 @@ namespace Microsoft.CmdPal.Ext.Shell.Commands; internal sealed partial class ExecuteItem : InvokableCommand { - private readonly SettingsManager _settings; + private readonly ISettingsInterface _settings; private readonly RunAsType _runas; public string Cmd { get; internal set; } = string.Empty; private static readonly char[] Separator = [' ']; - public ExecuteItem(string cmd, SettingsManager settings, RunAsType type = RunAsType.None) + public ExecuteItem(string cmd, ISettingsInterface settings, RunAsType type = RunAsType.None) { if (type == RunAsType.Administrator) { diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/ISettingsInterface.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/ISettingsInterface.cs new file mode 100644 index 0000000000..4a03d55d3d --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/ISettingsInterface.cs @@ -0,0 +1,20 @@ +// 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.Collections.Generic; + +namespace Microsoft.CmdPal.Ext.Shell.Helpers; + +public interface ISettingsInterface +{ + public bool LeaveShellOpen { get; } + + public string ShellCommandExecution { get; } + + public bool RunAsAdministrator { get; } + + public Dictionary Count { get; } + + public void AddCmdHistory(string cmdName); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/SettingsManager.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/SettingsManager.cs index a39e723338..9d58bc939d 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/SettingsManager.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/SettingsManager.cs @@ -9,7 +9,7 @@ using Microsoft.CommandPalette.Extensions.Toolkit; namespace Microsoft.CmdPal.Ext.Shell.Helpers; -public class SettingsManager : JsonSettingsManager +public class SettingsManager : JsonSettingsManager, ISettingsInterface { private static readonly string _namespace = "shell"; diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/ShellListPageHelpers.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/ShellListPageHelpers.cs index 6a545c7225..eed1d71e49 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/ShellListPageHelpers.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/ShellListPageHelpers.cs @@ -17,9 +17,9 @@ namespace Microsoft.CmdPal.Ext.Shell.Helpers; public class ShellListPageHelpers { private static readonly CompositeFormat CmdHasBeenExecutedTimes = System.Text.CompositeFormat.Parse(Properties.Resources.cmd_has_been_executed_times); - private readonly SettingsManager _settings; + private readonly ISettingsInterface _settings; - public ShellListPageHelpers(SettingsManager settings) + public ShellListPageHelpers(ISettingsInterface settings) { _settings = settings; } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Pages/ShellListPage.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Pages/ShellListPage.cs index 4b99477d2a..fde17ba14c 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Pages/ShellListPage.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Pages/ShellListPage.cs @@ -35,7 +35,7 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable private bool _loadedInitialHistory; - public ShellListPage(SettingsManager settingsManager, IRunHistoryService runHistoryService, bool addBuiltins = false) + public ShellListPage(ISettingsInterface settingsManager, IRunHistoryService runHistoryService, bool addBuiltins = false) { Icon = Icons.RunV2Icon; Id = "com.microsoft.cmdpal.shell"; diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Properties/AssemblyInfo.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..e308b0e6cc --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Properties/AssemblyInfo.cs @@ -0,0 +1,7 @@ +// 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.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Microsoft.CmdPal.Ext.Shell.UnitTests")] diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Commands/SearchWebCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Commands/SearchWebCommand.cs index 1004f151a3..ad9b43f859 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Commands/SearchWebCommand.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Commands/SearchWebCommand.cs @@ -13,11 +13,11 @@ namespace Microsoft.CmdPal.Ext.WebSearch.Commands; internal sealed partial class SearchWebCommand : InvokableCommand { - private readonly SettingsManager _settingsManager; + private readonly ISettingsInterface _settingsManager; public string Arguments { get; internal set; } = string.Empty; - internal SearchWebCommand(string arguments, SettingsManager settingsManager) + internal SearchWebCommand(string arguments, ISettingsInterface settingsManager) { Arguments = arguments; BrowserInfo.UpdateIfTimePassed(); diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/ISettingsInterface.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/ISettingsInterface.cs new file mode 100644 index 0000000000..c9a5723c15 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/ISettingsInterface.cs @@ -0,0 +1,19 @@ +// 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.Collections.Generic; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.WebSearch.Helpers; + +public interface ISettingsInterface +{ + public bool GlobalIfURI { get; } + + public string ShowHistory { get; } + + public List LoadHistory(); + + public void SaveHistory(HistoryItem historyItem); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/SettingsManager.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/SettingsManager.cs index 300cb105fb..31bbdae697 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/SettingsManager.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/SettingsManager.cs @@ -14,7 +14,7 @@ using Microsoft.CommandPalette.Extensions.Toolkit; namespace Microsoft.CmdPal.Ext.WebSearch.Helpers; -public class SettingsManager : JsonSettingsManager +public class SettingsManager : JsonSettingsManager, ISettingsInterface { private readonly string _historyPath; diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Pages/WebSearchListPage.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Pages/WebSearchListPage.cs index faf65cd973..6814e83ddb 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Pages/WebSearchListPage.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Pages/WebSearchListPage.cs @@ -20,12 +20,12 @@ internal sealed partial class WebSearchListPage : DynamicListPage { private readonly string _iconPath = string.Empty; private readonly List? _historyItems; - private readonly SettingsManager _settingsManager; + private readonly ISettingsInterface _settingsManager; private static readonly CompositeFormat PluginInBrowserName = System.Text.CompositeFormat.Parse(Properties.Resources.plugin_in_browser_name); private static readonly CompositeFormat PluginOpen = System.Text.CompositeFormat.Parse(Properties.Resources.plugin_open); private List _allItems; - public WebSearchListPage(SettingsManager settingsManager) + public WebSearchListPage(ISettingsInterface settingsManager) { Name = Resources.command_item_title; Title = Resources.command_item_title; diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Properties/AssemblyInfo.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..b66aababe0 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Properties/AssemblyInfo.cs @@ -0,0 +1,7 @@ +// 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.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Microsoft.CmdPal.Ext.WebSearch.UnitTests")] diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Helpers/ServiceHelper.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Helpers/ServiceHelper.cs index 6e874b1581..feb7aac9c0 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Helpers/ServiceHelper.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Helpers/ServiceHelper.cs @@ -19,7 +19,7 @@ namespace Microsoft.CmdPal.Ext.WindowsServices.Helpers; public static class ServiceHelper { - public static IEnumerable Search(string search) + public static IEnumerable Search(string search, string filterId) { var services = ServiceController.GetServices().OrderBy(s => s.DisplayName); IEnumerable serviceList = []; @@ -44,6 +44,21 @@ public static class ServiceHelper serviceList = servicesStartsWith.Concat(servicesContains); } + switch (filterId) + { + case "running": + serviceList = serviceList.Where(w => w.Status == ServiceControllerStatus.Running); + break; + case "stopped": + serviceList = serviceList.Where(w => w.Status == ServiceControllerStatus.Stopped); + break; + case "paused": + serviceList = serviceList.Where(w => w.Status == ServiceControllerStatus.Paused); + break; + case "all": + break; + } + var result = serviceList.Select(s => { var serviceResult = ServiceResult.CreateServiceController(s); diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Pages/ServiceFilters.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Pages/ServiceFilters.cs new file mode 100644 index 0000000000..179315b0c3 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Pages/ServiceFilters.cs @@ -0,0 +1,26 @@ +// 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.CmdPal.Ext.WindowsServices; + +namespace Microsoft.CommandPalette.Extensions.Toolkit; + +public partial class ServiceFilters : Filters +{ + public ServiceFilters() + { + CurrentFilterId = "all"; + } + + public override IFilterItem[] GetFilters() + { + return [ + new Filter() { Id = "all", Name = "All Services" }, + new Separator(), + new Filter() { Id = "running", Name = "Running", Icon = Icons.GreenCircleIcon }, + new Filter() { Id = "stopped", Name = "Stopped", Icon = Icons.RedCircleIcon }, + new Filter() { Id = "paused", Name = "Paused", Icon = Icons.PauseIcon }, + ]; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Pages/ServicesListPage.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Pages/ServicesListPage.cs index 1f361b6b10..4892a1594b 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Pages/ServicesListPage.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Pages/ServicesListPage.cs @@ -16,13 +16,19 @@ internal sealed partial class ServicesListPage : DynamicListPage { Icon = Icons.ServicesIcon; Name = "Windows Services"; + + var filters = new ServiceFilters(); + filters.PropChanged += Filters_PropChanged; + Filters = filters; } + private void Filters_PropChanged(object sender, IPropChangedEventArgs args) => RaiseItemsChanged(); + public override void UpdateSearchText(string oldSearch, string newSearch) => RaiseItemsChanged(0); public override IListItem[] GetItems() { - var items = ServiceHelper.Search(SearchText).ToArray(); + var items = ServiceHelper.Search(SearchText, Filters.CurrentFilterId).ToArray(); return items; } diff --git a/src/modules/cmdpal/ext/ProcessMonitorExtension/ProcessMonitorExtension.csproj b/src/modules/cmdpal/ext/ProcessMonitorExtension/ProcessMonitorExtension.csproj index d36c277705..8ec263d9bb 100644 --- a/src/modules/cmdpal/ext/ProcessMonitorExtension/ProcessMonitorExtension.csproj +++ b/src/modules/cmdpal/ext/ProcessMonitorExtension/ProcessMonitorExtension.csproj @@ -31,6 +31,10 @@ + + + + diff --git a/src/modules/powerrename/dll/PowerRenameExt.vcxproj b/src/modules/powerrename/dll/PowerRenameExt.vcxproj index 32484c75b6..9c612a08ff 100644 --- a/src/modules/powerrename/dll/PowerRenameExt.vcxproj +++ b/src/modules/powerrename/dll/PowerRenameExt.vcxproj @@ -34,6 +34,7 @@ + diff --git a/src/modules/powerrename/dll/PowerRenameExt.vcxproj.filters b/src/modules/powerrename/dll/PowerRenameExt.vcxproj.filters index ffd2177829..f4f0ffcbe5 100644 --- a/src/modules/powerrename/dll/PowerRenameExt.vcxproj.filters +++ b/src/modules/powerrename/dll/PowerRenameExt.vcxproj.filters @@ -36,6 +36,9 @@ Header Files + + Header Files + diff --git a/src/modules/powerrename/dll/RuntimeRegistration.h b/src/modules/powerrename/dll/RuntimeRegistration.h new file mode 100644 index 0000000000..3cb06d5876 --- /dev/null +++ b/src/modules/powerrename/dll/RuntimeRegistration.h @@ -0,0 +1,37 @@ +// Header-only runtime registration for PowerRename context menu extension. +#pragma once + +#include + +// Provided by dllmain.cpp +extern HINSTANCE g_hInst; + +namespace PowerRenameRuntimeRegistration +{ + namespace + { + inline runtime_shell_ext::Spec BuildSpec() + { + runtime_shell_ext::Spec spec; + spec.clsid = L"{0440049F-D1DC-4E46-B27B-98393D79486B}"; + spec.sentinelKey = L"Software\\Microsoft\\PowerToys\\PowerRename"; + spec.sentinelValue = L"ContextMenuRegistered"; + spec.dllFileCandidates = { L"PowerToys.PowerRenameExt.dll" }; + spec.contextMenuHandlerKeyPaths = { + L"Software\\Classes\\AllFileSystemObjects\\ShellEx\\ContextMenuHandlers\\PowerRenameExt", + L"Software\\Classes\\Directory\\background\\ShellEx\\ContextMenuHandlers\\PowerRenameExt" }; + spec.friendlyName = L"PowerRename Shell Extension"; + return spec; + } + } + + inline bool EnsureRegistered() + { + return runtime_shell_ext::EnsureRegistered(BuildSpec(), g_hInst); + } + + inline void Unregister() + { + runtime_shell_ext::Unregister(BuildSpec()); + } +} diff --git a/src/modules/powerrename/dll/dllmain.cpp b/src/modules/powerrename/dll/dllmain.cpp index 4f9c918fb6..18c612a304 100644 --- a/src/modules/powerrename/dll/dllmain.cpp +++ b/src/modules/powerrename/dll/dllmain.cpp @@ -15,6 +15,7 @@ #include #include #include +#include "RuntimeRegistration.h" #include @@ -196,12 +197,17 @@ public: { std::wstring path = get_module_folderpath(g_hInst); std::wstring packageUri = path + L"\\PowerRenameContextMenuPackage.msix"; - if (!package::IsPackageRegisteredWithPowerToysVersion(PowerRenameConstants::ModulePackageDisplayName)) { package::RegisterSparsePackage(path, packageUri); } } + else + { +#if defined(ENABLE_REGISTRATION) || defined(NDEBUG) + PowerRenameRuntimeRegistration::EnsureRegistered(); +#endif + } } // Disable the powertoy @@ -209,6 +215,13 @@ public: { m_enabled = false; Logger::info(L"PowerRename disabled"); + if (!package::IsWin11OrGreater()) + { +#if defined(ENABLE_REGISTRATION) || defined(NDEBUG) + PowerRenameRuntimeRegistration::Unregister(); + Logger::info(L"PowerRename context menu unregistered (Win10)"); +#endif + } } // Returns if the powertoy is enabled diff --git a/src/modules/previewpane/powerpreview/CLSID.h b/src/modules/previewpane/powerpreview/CLSID.h index 4c866a6b80..0c9aeee0df 100644 --- a/src/modules/previewpane/powerpreview/CLSID.h +++ b/src/modules/previewpane/powerpreview/CLSID.h @@ -66,6 +66,7 @@ const std::vector> NativeToManagedClsid({ { CLSID_SHIMActivateMdPreviewHandler, CLSID_MdPreviewHandler }, { CLSID_SHIMActivatePdfPreviewHandler, CLSID_PdfPreviewHandler }, { CLSID_SHIMActivateGcodePreviewHandler, CLSID_GcodePreviewHandler }, + { CLSID_SHIMActivateBgcodePreviewHandler, CLSID_BgcodePreviewHandler }, { CLSID_SHIMActivateQoiPreviewHandler, CLSID_QoiPreviewHandler }, { CLSID_SHIMActivateSvgPreviewHandler, CLSID_SvgPreviewHandler }, { CLSID_SHIMActivateSvgThumbnailProvider, CLSID_SvgThumbnailProvider } diff --git a/src/runner/Resources.resx b/src/runner/Resources.resx index 3cc2f1ad36..c8eb5f25cc 100644 --- a/src/runner/Resources.resx +++ b/src/runner/Resources.resx @@ -176,10 +176,6 @@ Documentation - - Exit - Exit as a verb, as in Exit the application - Report bug @@ -193,4 +189,8 @@ Administrator + + Close + Close as a verb, as in Close the application + \ No newline at end of file diff --git a/src/runner/centralized_hotkeys.h b/src/runner/centralized_hotkeys.h index 29ef079f9e..bb503d332d 100644 --- a/src/runner/centralized_hotkeys.h +++ b/src/runner/centralized_hotkeys.h @@ -20,11 +20,13 @@ namespace CentralizedHotkeys { WORD modifiersMask; WORD vkCode; + int hotkeyID; - Shortcut(WORD modifiersMask = 0, WORD vkCode = 0) + Shortcut(WORD modifiersMask = 0, WORD vkCode = 0, const int hotkeyID = 0) { this->modifiersMask = modifiersMask; this->vkCode = vkCode; + this->hotkeyID = hotkeyID; } bool operator<(const Shortcut& key) const diff --git a/src/runner/general_settings.cpp b/src/runner/general_settings.cpp index 4fdf6b74d2..bb45f7f5ae 100644 --- a/src/runner/general_settings.cpp +++ b/src/runner/general_settings.cpp @@ -3,6 +3,7 @@ #include "auto_start_helper.h" #include "tray_icon.h" #include "Generated files/resource.h" +#include "hotkey_conflict_detector.h" #include #include "powertoy_module.h" @@ -204,11 +205,15 @@ void apply_general_settings(const json::JsonObject& general_configs, bool save) { Logger::info(L"apply_general_settings: Enabling powertoy {}", name); powertoy->enable(); + auto& hkmng = HotkeyConflictDetector::HotkeyConflictManager::GetInstance(); + hkmng.EnableHotkeyByModule(name); } else { Logger::info(L"apply_general_settings: Disabling powertoy {}", name); powertoy->disable(); + auto& hkmng = HotkeyConflictDetector::HotkeyConflictManager::GetInstance(); + hkmng.DisableHotkeyByModule(name); } // Sync the hotkey state with the module state, so it can be removed for disabled modules. powertoy.UpdateHotkeyEx(); @@ -315,6 +320,8 @@ void start_enabled_powertoys() { Logger::info(L"start_enabled_powertoys: Enabling powertoy {}", name); powertoy->enable(); + auto& hkmng = HotkeyConflictDetector::HotkeyConflictManager::GetInstance(); + hkmng.EnableHotkeyByModule(name); powertoy.UpdateHotkeyEx(); } } diff --git a/src/runner/hotkey_conflict_detector.cpp b/src/runner/hotkey_conflict_detector.cpp new file mode 100644 index 0000000000..14c8a1ecd9 --- /dev/null +++ b/src/runner/hotkey_conflict_detector.cpp @@ -0,0 +1,471 @@ +#include "pch.h" +#include "hotkey_conflict_detector.h" +#include +#include +#include +#include + +namespace HotkeyConflictDetector +{ + Hotkey ShortcutToHotkey(const CentralizedHotkeys::Shortcut& shortcut) + { + Hotkey hotkey; + + hotkey.win = (shortcut.modifiersMask & MOD_WIN) != 0; + hotkey.ctrl = (shortcut.modifiersMask & MOD_CONTROL) != 0; + hotkey.shift = (shortcut.modifiersMask & MOD_SHIFT) != 0; + hotkey.alt = (shortcut.modifiersMask & MOD_ALT) != 0; + + hotkey.key = shortcut.vkCode > 255 ? 0 : static_cast(shortcut.vkCode); + + return hotkey; + } + + HotkeyConflictManager* HotkeyConflictManager::instance = nullptr; + std::mutex HotkeyConflictManager::instanceMutex; + + HotkeyConflictManager& HotkeyConflictManager::GetInstance() + { + std::lock_guard lock(instanceMutex); + if (instance == nullptr) + { + instance = new HotkeyConflictManager(); + } + return *instance; + } + + HotkeyConflictType HotkeyConflictManager::HasConflict(Hotkey const& _hotkey, const wchar_t* _moduleName, const int _hotkeyID) + { + if (disabledHotkeys.find(_moduleName) != disabledHotkeys.end()) + { + return HotkeyConflictType::NoConflict; + } + + uint16_t handle = GetHotkeyHandle(_hotkey); + + if (handle == 0) + { + return HotkeyConflictType::NoConflict; + } + + // The order is important, first to check sys conflict and then inapp conflict + if (sysConflictHotkeyMap.find(handle) != sysConflictHotkeyMap.end()) + { + return HotkeyConflictType::SystemConflict; + } + + if (inAppConflictHotkeyMap.find(handle) != inAppConflictHotkeyMap.end()) + { + return HotkeyConflictType::InAppConflict; + } + + auto it = hotkeyMap.find(handle); + + if (it == hotkeyMap.end()) + { + return HasConflictWithSystemHotkey(_hotkey) ? + HotkeyConflictType::SystemConflict : + HotkeyConflictType::NoConflict; + } + + if (wcscmp(it->second.moduleName.c_str(), _moduleName) == 0 && it->second.hotkeyID == _hotkeyID) + { + // A shortcut matching its own assignment is not considered a conflict. + return HotkeyConflictType::NoConflict; + } + + return HotkeyConflictType::InAppConflict; + } + + HotkeyConflictType HotkeyConflictManager::HasConflict(Hotkey const& _hotkey) + { + uint16_t handle = GetHotkeyHandle(_hotkey); + + if (handle == 0) + { + return HotkeyConflictType::NoConflict; + } + + // The order is important, first to check sys conflict and then inapp conflict + if (sysConflictHotkeyMap.find(handle) != sysConflictHotkeyMap.end()) + { + return HotkeyConflictType::SystemConflict; + } + + if (inAppConflictHotkeyMap.find(handle) != inAppConflictHotkeyMap.end()) + { + return HotkeyConflictType::InAppConflict; + } + + auto it = hotkeyMap.find(handle); + + if (it == hotkeyMap.end()) + { + return HasConflictWithSystemHotkey(_hotkey) ? + HotkeyConflictType::SystemConflict : + HotkeyConflictType::NoConflict; + } + + return HotkeyConflictType::InAppConflict; + } + + // This function should only be called when a conflict has already been identified. + // It returns a list of all conflicting shortcuts. + std::vector HotkeyConflictManager::GetAllConflicts(Hotkey const& _hotkey) + { + std::vector conflicts; + uint16_t handle = GetHotkeyHandle(_hotkey); + + // Check in-app conflicts first + auto inAppIt = inAppConflictHotkeyMap.find(handle); + if (inAppIt != inAppConflictHotkeyMap.end()) + { + // Add all in-app conflicts + for (const auto& conflict : inAppIt->second) + { + conflicts.push_back(conflict); + } + + return conflicts; + } + + // Check system conflicts + auto sysIt = sysConflictHotkeyMap.find(handle); + if (sysIt != sysConflictHotkeyMap.end()) + { + HotkeyConflictInfo systemConflict; + systemConflict.hotkey = _hotkey; + systemConflict.moduleName = L"System"; + systemConflict.hotkeyID = 0; + + conflicts.push_back(systemConflict); + + return conflicts; + } + + // Check if there's a successfully registered hotkey that would conflict + auto registeredIt = hotkeyMap.find(handle); + if (registeredIt != hotkeyMap.end()) + { + conflicts.push_back(registeredIt->second); + + return conflicts; + } + + // If all the above conditions are ruled out, a system-level conflict is the only remaining explanation. + HotkeyConflictInfo systemConflict; + systemConflict.hotkey = _hotkey; + systemConflict.moduleName = L"System"; + systemConflict.hotkeyID = 0; + conflicts.push_back(systemConflict); + + return conflicts; + } + + bool HotkeyConflictManager::AddHotkey(Hotkey const& _hotkey, const wchar_t* _moduleName, const int _hotkeyID, bool isEnabled) + { + if (!isEnabled) + { + disabledHotkeys[_moduleName].push_back({ _hotkey, _moduleName, _hotkeyID }); + return true; + } + + uint16_t handle = GetHotkeyHandle(_hotkey); + + if (handle == 0) + { + return false; + } + + HotkeyConflictType conflictType = HasConflict(_hotkey, _moduleName, _hotkeyID); + if (conflictType != HotkeyConflictType::NoConflict) + { + if (conflictType == HotkeyConflictType::InAppConflict) + { + auto hotkeyFound = hotkeyMap.find(handle); + inAppConflictHotkeyMap[handle].insert({ _hotkey, _moduleName, _hotkeyID }); + + if (hotkeyFound != hotkeyMap.end()) + { + inAppConflictHotkeyMap[handle].insert(hotkeyFound->second); + hotkeyMap.erase(hotkeyFound); + } + } + else + { + sysConflictHotkeyMap[handle].insert({ _hotkey, _moduleName, _hotkeyID }); + } + return false; + } + + HotkeyConflictInfo hotkeyInfo; + hotkeyInfo.moduleName = _moduleName; + hotkeyInfo.hotkeyID = _hotkeyID; + hotkeyInfo.hotkey = _hotkey; + hotkeyMap[handle] = hotkeyInfo; + + return true; + } + + std::vector HotkeyConflictManager::RemoveHotkeyByModule(const std::wstring& moduleName) + { + std::vector removedHotkeys; + + if (disabledHotkeys.find(moduleName) != disabledHotkeys.end()) + { + disabledHotkeys.erase(moduleName); + } + + std::lock_guard lock(hotkeyMutex); + bool foundRecord = false; + + for (auto it = sysConflictHotkeyMap.begin(); it != sysConflictHotkeyMap.end();) + { + auto& conflictSet = it->second; + for (auto setIt = conflictSet.begin(); setIt != conflictSet.end();) + { + if (setIt->moduleName == moduleName) + { + removedHotkeys.push_back(*setIt); + setIt = conflictSet.erase(setIt); + foundRecord = true; + } + else + { + ++setIt; + } + } + if (conflictSet.empty()) + { + it = sysConflictHotkeyMap.erase(it); + } + else + { + ++it; + } + } + + for (auto it = inAppConflictHotkeyMap.begin(); it != inAppConflictHotkeyMap.end();) + { + auto& conflictSet = it->second; + uint16_t handle = it->first; + + for (auto setIt = conflictSet.begin(); setIt != conflictSet.end();) + { + if (setIt->moduleName == moduleName) + { + removedHotkeys.push_back(*setIt); + setIt = conflictSet.erase(setIt); + foundRecord = true; + } + else + { + ++setIt; + } + } + + if (conflictSet.empty()) + { + it = inAppConflictHotkeyMap.erase(it); + } + else if (conflictSet.size() == 1) + { + // Move the only remaining conflict to main map + const auto& onlyConflict = *conflictSet.begin(); + hotkeyMap[handle] = onlyConflict; + it = inAppConflictHotkeyMap.erase(it); + } + else + { + ++it; + } + } + + for (auto it = hotkeyMap.begin(); it != hotkeyMap.end();) + { + if (it->second.moduleName == moduleName) + { + uint16_t handle = it->first; + removedHotkeys.push_back(it->second); + it = hotkeyMap.erase(it); + foundRecord = true; + + auto inAppIt = inAppConflictHotkeyMap.find(handle); + if (inAppIt != inAppConflictHotkeyMap.end() && inAppIt->second.size() == 1) + { + // Move the only in-app conflict to main map + const auto& onlyConflict = *inAppIt->second.begin(); + hotkeyMap[handle] = onlyConflict; + inAppConflictHotkeyMap.erase(inAppIt); + } + } + else + { + ++it; + } + } + + return removedHotkeys; + } + + void HotkeyConflictManager::EnableHotkeyByModule(const std::wstring& moduleName) + { + if (disabledHotkeys.find(moduleName) == disabledHotkeys.end()) + { + return; // No disabled hotkeys for this module + } + + auto hotkeys = disabledHotkeys[moduleName]; + disabledHotkeys.erase(moduleName); + + for (const auto& hotkeyInfo : hotkeys) + { + // Re-add the hotkey as enabled + AddHotkey(hotkeyInfo.hotkey, moduleName.c_str(), hotkeyInfo.hotkeyID, true); + } + } + + void HotkeyConflictManager::DisableHotkeyByModule(const std::wstring& moduleName) + { + auto hotkeys = RemoveHotkeyByModule(moduleName); + disabledHotkeys[moduleName] = hotkeys; + } + + bool HotkeyConflictManager::HasConflictWithSystemHotkey(const Hotkey& hotkey) + { + // Convert PowerToys Hotkey format to Win32 RegisterHotKey format + UINT modifiers = 0; + if (hotkey.win) + { + modifiers |= MOD_WIN; + } + if (hotkey.ctrl) + { + modifiers |= MOD_CONTROL; + } + if (hotkey.alt) + { + modifiers |= MOD_ALT; + } + if (hotkey.shift) + { + modifiers |= MOD_SHIFT; + } + + // No modifiers or no key is not a valid hotkey + if (modifiers == 0 || hotkey.key == 0) + { + return false; + } + + // Use a unique ID for this test registration + const int hotkeyId = 0x0FFF; // Arbitrary ID for temporary registration + + // Try to register the hotkey with Windows, using nullptr instead of a window handle + if (!RegisterHotKey(nullptr, hotkeyId, modifiers, hotkey.key)) + { + // If registration fails with ERROR_HOTKEY_ALREADY_REGISTERED, it means the hotkey + // is already in use by the system or another application + if (GetLastError() == ERROR_HOTKEY_ALREADY_REGISTERED) + { + return true; + } + } + else + { + // If registration succeeds, unregister it immediately + UnregisterHotKey(nullptr, hotkeyId); + } + + return false; + } + + json::JsonObject HotkeyConflictManager::GetHotkeyConflictsAsJson() + { + std::lock_guard lock(hotkeyMutex); + + using namespace json; + JsonObject root; + + // Serialize hotkey to a unique string format for grouping + auto serializeHotkey = [](const Hotkey& hotkey) -> JsonObject { + JsonObject obj; + obj.Insert(L"win", value(hotkey.win)); + obj.Insert(L"ctrl", value(hotkey.ctrl)); + obj.Insert(L"shift", value(hotkey.shift)); + obj.Insert(L"alt", value(hotkey.alt)); + obj.Insert(L"key", value(static_cast(hotkey.key))); + return obj; + }; + + // New format: Group conflicts by hotkey + JsonArray inAppConflictsArray; + JsonArray sysConflictsArray; + + // Process in-app conflicts - only include hotkeys that are actually in conflict + for (const auto& [handle, conflicts] : inAppConflictHotkeyMap) + { + if (!conflicts.empty()) + { + JsonObject conflictGroup; + + // All entries have the same hotkey, so use the first one for the key + conflictGroup.Insert(L"hotkey", serializeHotkey(conflicts.begin()->hotkey)); + + // Create an array of module info without repeating the hotkey + JsonArray modules; + for (const auto& info : conflicts) + { + JsonObject moduleInfo; + moduleInfo.Insert(L"moduleName", value(info.moduleName)); + moduleInfo.Insert(L"hotkeyID", value(info.hotkeyID)); + modules.Append(moduleInfo); + } + + conflictGroup.Insert(L"modules", modules); + inAppConflictsArray.Append(conflictGroup); + } + } + + // Process system conflicts - only include hotkeys that are actually in conflict + for (const auto& [handle, conflicts] : sysConflictHotkeyMap) + { + if (!conflicts.empty()) + { + JsonObject conflictGroup; + + // All entries have the same hotkey, so use the first one for the key + conflictGroup.Insert(L"hotkey", serializeHotkey(conflicts.begin()->hotkey)); + + // Create an array of module info without repeating the hotkey + JsonArray modules; + for (const auto& info : conflicts) + { + JsonObject moduleInfo; + moduleInfo.Insert(L"moduleName", value(info.moduleName)); + moduleInfo.Insert(L"hotkeyID", value(info.hotkeyID)); + modules.Append(moduleInfo); + } + + conflictGroup.Insert(L"modules", modules); + sysConflictsArray.Append(conflictGroup); + } + } + + // Add the grouped conflicts to the root object + root.Insert(L"inAppConflicts", inAppConflictsArray); + root.Insert(L"sysConflicts", sysConflictsArray); + + return root; + } + + uint16_t HotkeyConflictManager::GetHotkeyHandle(const Hotkey& hotkey) + { + uint16_t handle = hotkey.key; + handle |= hotkey.win << 8; + handle |= hotkey.ctrl << 9; + handle |= hotkey.shift << 10; + handle |= hotkey.alt << 11; + return handle; + } +} \ No newline at end of file diff --git a/src/runner/hotkey_conflict_detector.h b/src/runner/hotkey_conflict_detector.h new file mode 100644 index 0000000000..c32954e3e4 --- /dev/null +++ b/src/runner/hotkey_conflict_detector.h @@ -0,0 +1,100 @@ +#pragma once +#include "pch.h" +#include +#include +#include + +#include "../modules/interface/powertoy_module_interface.h" +#include "centralized_hotkeys.h" +#include "common/utils/json.h" + +namespace HotkeyConflictDetector +{ + using Hotkey = PowertoyModuleIface::Hotkey; + using HotkeyEx = PowertoyModuleIface::HotkeyEx; + using Shortcut = CentralizedHotkeys::Shortcut; + + struct HotkeyConflictInfo + { + Hotkey hotkey; + std::wstring moduleName; + int hotkeyID = 0; + + inline bool operator==(const HotkeyConflictInfo& other) const + { + return hotkey == other.hotkey && + moduleName == other.moduleName && + hotkeyID == other.hotkeyID; + } + }; + + Hotkey ShortcutToHotkey(const CentralizedHotkeys::Shortcut& shortcut); + + enum HotkeyConflictType + { + NoConflict = 0, + SystemConflict = 1, + InAppConflict = 2, + }; + + class HotkeyConflictManager + { + public: + static HotkeyConflictManager& GetInstance(); + + HotkeyConflictType HasConflict(const Hotkey& hotkey, const wchar_t* moduleName, const int hotkeyID); + HotkeyConflictType HotkeyConflictManager::HasConflict(Hotkey const& _hotkey); + std::vector HotkeyConflictManager::GetAllConflicts(Hotkey const& hotkey); + bool AddHotkey(const Hotkey& hotkey, const wchar_t* moduleName, const int hotkeyID, bool isEnabled); + std::vector RemoveHotkeyByModule(const std::wstring& moduleName); + + void EnableHotkeyByModule(const std::wstring& moduleName); + void DisableHotkeyByModule(const std::wstring& moduleName); + + json::JsonObject GetHotkeyConflictsAsJson(); + + private: + static std::mutex instanceMutex; + static HotkeyConflictManager* instance; + + std::mutex hotkeyMutex; + // Hotkey in hotkeyMap means the hotkey has been registered successfully + std::unordered_map hotkeyMap; + // Hotkey in sysConflictHotkeyMap means the hotkey has conflict with system defined hotkeys + std::unordered_map> sysConflictHotkeyMap; + // Hotkey in inAppConflictHotkeyMap means the hotkey has conflict with other modules + std::unordered_map> inAppConflictHotkeyMap; + + std::unordered_map> disabledHotkeys; + + uint16_t GetHotkeyHandle(const Hotkey&); + bool HasConflictWithSystemHotkey(const Hotkey&); + + HotkeyConflictManager() = default; + }; +}; + +namespace std +{ + template<> + struct hash + { + size_t operator()(const HotkeyConflictDetector::HotkeyConflictInfo& info) const + { + + size_t hotkeyHash = + (info.hotkey.win ? 1ULL : 0ULL) | + ((info.hotkey.ctrl ? 1ULL : 0ULL) << 1) | + ((info.hotkey.shift ? 1ULL : 0ULL) << 2) | + ((info.hotkey.alt ? 1ULL : 0ULL) << 3) | + (static_cast(info.hotkey.key) << 4); + + size_t moduleHash = std::hash{}(info.moduleName); + size_t idHash = std::hash{}(info.hotkeyID); + + return hotkeyHash ^ + ((moduleHash << 1) | (moduleHash >> (sizeof(size_t) * 8 - 1))) ^ // rotate left 1 bit + ((idHash << 2) | (idHash >> (sizeof(size_t) * 8 - 2))); // rotate left 2 bits + } + }; +} diff --git a/src/runner/powertoy_module.cpp b/src/runner/powertoy_module.cpp index 32f856f465..eb1f7c4fd7 100644 --- a/src/runner/powertoy_module.cpp +++ b/src/runner/powertoy_module.cpp @@ -40,13 +40,14 @@ json::JsonObject PowertoyModule::json_config() const } PowertoyModule::PowertoyModule(PowertoyModuleIface* pt_module, HMODULE handle) : - handle(handle), pt_module(pt_module) + handle(handle), pt_module(pt_module), hkmng(HotkeyConflictDetector::HotkeyConflictManager::GetInstance()) { if (!pt_module) { throw std::runtime_error("Module not initialized"); } + remove_hotkey_records(); update_hotkeys(); UpdateHotkeyEx(); } @@ -63,19 +64,27 @@ void PowertoyModule::update_hotkeys() for (size_t i = 0; i < hotkeyCount; i++) { - CentralizedKeyboardHook::SetHotkeyAction(pt_module->get_key(), hotkeys[i], [modulePtr, i] { - Logger::trace(L"{} hotkey is invoked from Centralized keyboard hook", modulePtr->get_key()); - return modulePtr->on_hotkey(i); - }); + if (hotkeys[i].isShown) + { + hkmng.AddHotkey(hotkeys[i], pt_module->get_key(), static_cast(i), pt_module->is_enabled()); + + CentralizedKeyboardHook::SetHotkeyAction(pt_module->get_key(), hotkeys[i], [modulePtr, i] { + Logger::trace(L"{} hotkey is invoked from Centralized keyboard hook", modulePtr->get_key()); + return modulePtr->on_hotkey(i); + }); + } } } void PowertoyModule::UpdateHotkeyEx() { CentralizedHotkeys::UnregisterHotkeysForModule(pt_module->get_key()); + auto container = pt_module->GetHotkeyEx(); if (container.has_value() && pt_module->is_enabled()) { + hkmng.RemoveHotkeyByModule(pt_module->get_key()); + auto hotkey = container.value(); auto modulePtr = pt_module.get(); auto action = [modulePtr](WORD /*modifiersMask*/, WORD /*vkCode*/) { @@ -83,6 +92,9 @@ void PowertoyModule::UpdateHotkeyEx() modulePtr->OnHotkeyEx(); }; + HotkeyConflictDetector::Hotkey _hotkey = HotkeyConflictDetector::ShortcutToHotkey({ hotkey.modifiersMask, hotkey.vkCode }); + hkmng.AddHotkey(_hotkey, pt_module->get_key(), 0, pt_module->is_enabled()); // This is the only one activation hotkey, so we use "0" as the name. + CentralizedHotkeys::AddHotkeyAction({ hotkey.modifiersMask, hotkey.vkCode }, { pt_module->get_key(), action }); } diff --git a/src/runner/powertoy_module.h b/src/runner/powertoy_module.h index 9332e5f025..9b7a9a59bd 100644 --- a/src/runner/powertoy_module.h +++ b/src/runner/powertoy_module.h @@ -5,6 +5,7 @@ #include #include #include +#include "hotkey_conflict_detector.h" #include @@ -44,9 +45,17 @@ public: void UpdateHotkeyEx(); + inline void remove_hotkey_records() + { + hkmng.RemoveHotkeyByModule(pt_module->get_key()); + } + private: + HotkeyConflictDetector::HotkeyConflictManager& hkmng; std::unique_ptr handle; std::unique_ptr pt_module; + + }; PowertoyModule load_powertoy(const std::wstring_view filename); diff --git a/src/runner/resource.base.h b/src/runner/resource.base.h index 027f5b4281..7037f4342d 100644 --- a/src/runner/resource.base.h +++ b/src/runner/resource.base.h @@ -15,7 +15,7 @@ #define APPICON 101 #define ID_TRAY_MENU 102 -#define ID_EXIT_MENU_COMMAND 40001 +#define ID_CLOSE_MENU_COMMAND 40001 #define ID_SETTINGS_MENU_COMMAND 40002 #define ID_ABOUT_MENU_COMMAND 40003 #define ID_REPORT_BUG_COMMAND 40004 diff --git a/src/runner/runner.base.rc b/src/runner/runner.base.rc index 10a4555db8..367735ade4 100644 Binary files a/src/runner/runner.base.rc and b/src/runner/runner.base.rc differ diff --git a/src/runner/runner.vcxproj b/src/runner/runner.vcxproj index a55396a71a..90dafb5e45 100644 --- a/src/runner/runner.vcxproj +++ b/src/runner/runner.vcxproj @@ -51,6 +51,7 @@ + Create @@ -71,6 +72,7 @@ + diff --git a/src/runner/runner.vcxproj.filters b/src/runner/runner.vcxproj.filters index a91782fd24..812d7857a2 100644 --- a/src/runner/runner.vcxproj.filters +++ b/src/runner/runner.vcxproj.filters @@ -45,6 +45,9 @@ Utils + + Utils + @@ -93,6 +96,9 @@ Utils + + Utils + diff --git a/src/runner/settings_window.cpp b/src/runner/settings_window.cpp index e811ff5d65..b3ced3b858 100644 --- a/src/runner/settings_window.cpp +++ b/src/runner/settings_window.cpp @@ -13,6 +13,7 @@ #include "UpdateUtils.h" #include "centralized_kb_hook.h" #include "Generated files/resource.h" +#include "hotkey_conflict_detector.h" #include #include @@ -153,6 +154,8 @@ void send_json_config_to_module(const std::wstring& module_key, const std::wstri if (moduleIt != modules().end()) { moduleIt->second->set_config(settings.c_str()); + + moduleIt->second.remove_hotkey_records(); moduleIt->second.update_hotkeys(); moduleIt->second.UpdateHotkeyEx(); } @@ -249,6 +252,77 @@ void dispatch_received_json(const std::wstring& json_to_parse) const std::wstring save_file_location = PTSettingsHelper::get_root_save_folder_location() + language_filename; json::to_file(save_file_location, j); } + else if (name == L"check_hotkey_conflict") + { + try + { + PowertoyModuleIface::Hotkey hotkey; + hotkey.win = value.GetObjectW().GetNamedBoolean(L"win", false); + hotkey.ctrl = value.GetObjectW().GetNamedBoolean(L"ctrl", false); + hotkey.shift = value.GetObjectW().GetNamedBoolean(L"shift", false); + hotkey.alt = value.GetObjectW().GetNamedBoolean(L"alt", false); + hotkey.key = static_cast(value.GetObjectW().GetNamedNumber(L"key", 0)); + + std::wstring requestId = value.GetObjectW().GetNamedString(L"request_id", L"").c_str(); + + auto& hkmng = HotkeyConflictDetector::HotkeyConflictManager::GetInstance(); + bool hasConflict = hkmng.HasConflict(hotkey); + + json::JsonObject response; + response.SetNamedValue(L"response_type", json::JsonValue::CreateStringValue(L"hotkey_conflict_result")); + response.SetNamedValue(L"request_id", json::JsonValue::CreateStringValue(requestId)); + response.SetNamedValue(L"has_conflict", json::JsonValue::CreateBooleanValue(hasConflict)); + + if (hasConflict) + { + auto conflicts = hkmng.GetAllConflicts(hotkey); + if (!conflicts.empty()) + { + // Include all conflicts in the response + json::JsonArray allConflicts; + for (const auto& conflict : conflicts) + { + json::JsonObject conflictObj; + conflictObj.SetNamedValue(L"module", json::JsonValue::CreateStringValue(conflict.moduleName)); + conflictObj.SetNamedValue(L"hotkeyID", json::JsonValue::CreateNumberValue(conflict.hotkeyID)); + allConflicts.Append(conflictObj); + } + response.SetNamedValue(L"all_conflicts", allConflicts); + } + } + + std::unique_lock lock{ ipc_mutex }; + if (current_settings_ipc) + { + current_settings_ipc->send(response.Stringify().c_str()); + } + } + catch (...) + { + Logger::error(L"Failed to process hotkey conflict check request"); + } + } + else if (name == L"get_all_hotkey_conflicts") + { + try + { + auto& hkmng = HotkeyConflictDetector::HotkeyConflictManager::GetInstance(); + auto conflictsJson = hkmng.GetHotkeyConflictsAsJson(); + + // Add response type identifier + conflictsJson.SetNamedValue(L"response_type", json::JsonValue::CreateStringValue(L"all_hotkey_conflicts")); + + std::unique_lock lock{ ipc_mutex }; + if (current_settings_ipc) + { + current_settings_ipc->send(conflictsJson.Stringify().c_str()); + } + } + catch (...) + { + Logger::error(L"Failed to process get all hotkey conflicts request"); + } + } } return; } diff --git a/src/runner/tray_icon.cpp b/src/runner/tray_icon.cpp index 53da747539..749c921659 100644 --- a/src/runner/tray_icon.cpp +++ b/src/runner/tray_icon.cpp @@ -84,7 +84,7 @@ void handle_tray_command(HWND window, const WPARAM command_id, LPARAM lparam) open_settings_window(settings_window, false); } break; - case ID_EXIT_MENU_COMMAND: + case ID_CLOSE_MENU_COMMAND: if (h_menu) { DestroyMenu(h_menu); @@ -191,12 +191,12 @@ LRESULT __stdcall tray_icon_window_proc(HWND window, UINT message, WPARAM wparam if (h_menu) { static std::wstring settings_menuitem_label = GET_RESOURCE_STRING(IDS_SETTINGS_MENU_TEXT); - static std::wstring exit_menuitem_label = GET_RESOURCE_STRING(IDS_EXIT_MENU_TEXT); + static std::wstring close_menuitem_label = GET_RESOURCE_STRING(IDS_CLOSE_MENU_TEXT); static std::wstring submit_bug_menuitem_label = GET_RESOURCE_STRING(IDS_SUBMIT_BUG_TEXT); static std::wstring documentation_menuitem_label = GET_RESOURCE_STRING(IDS_DOCUMENTATION_MENU_TEXT); static std::wstring quick_access_menuitem_label = GET_RESOURCE_STRING(IDS_QUICK_ACCESS_MENU_TEXT); change_menu_item_text(ID_SETTINGS_MENU_COMMAND, settings_menuitem_label.data()); - change_menu_item_text(ID_EXIT_MENU_COMMAND, exit_menuitem_label.data()); + change_menu_item_text(ID_CLOSE_MENU_COMMAND, close_menuitem_label.data()); change_menu_item_text(ID_REPORT_BUG_COMMAND, submit_bug_menuitem_label.data()); bool bug_report_disabled = is_bug_report_running(); EnableMenuItem(h_sub_menu, ID_REPORT_BUG_COMMAND, MF_BYCOMMAND | (bug_report_disabled ? MF_GRAYED : MF_ENABLED)); diff --git a/src/settings-ui/Settings.UI.Library/AdvancedPasteAdditionalAction.cs b/src/settings-ui/Settings.UI.Library/AdvancedPasteAdditionalAction.cs index 28bed92012..1642ecf9c4 100644 --- a/src/settings-ui/Settings.UI.Library/AdvancedPasteAdditionalAction.cs +++ b/src/settings-ui/Settings.UI.Library/AdvancedPasteAdditionalAction.cs @@ -12,7 +12,9 @@ namespace Microsoft.PowerToys.Settings.UI.Library; public sealed partial class AdvancedPasteAdditionalAction : Observable, IAdvancedPasteAction { private HotkeySettings _shortcut = new(); - private bool _isShown = true; + private bool _isShown; + private bool _hasConflict; + private string _tooltip; [JsonPropertyName("shortcut")] public HotkeySettings Shortcut @@ -38,6 +40,20 @@ public sealed partial class AdvancedPasteAdditionalAction : Observable, IAdvance set => Set(ref _isShown, value); } + [JsonIgnore] + public bool HasConflict + { + get => _hasConflict; + set => Set(ref _hasConflict, value); + } + + [JsonIgnore] + public string Tooltip + { + get => _tooltip; + set => Set(ref _tooltip, value); + } + [JsonIgnore] public IEnumerable SubActions => []; } diff --git a/src/settings-ui/Settings.UI.Library/AdvancedPasteAdditionalActions.cs b/src/settings-ui/Settings.UI.Library/AdvancedPasteAdditionalActions.cs index 3b1a859364..6d908c617a 100644 --- a/src/settings-ui/Settings.UI.Library/AdvancedPasteAdditionalActions.cs +++ b/src/settings-ui/Settings.UI.Library/AdvancedPasteAdditionalActions.cs @@ -28,16 +28,23 @@ public sealed class AdvancedPasteAdditionalActions public IEnumerable GetAllActions() { - Queue queue = new([ImageToText, PasteAsFile, Transcode]); + return GetAllActionsRecursive([ImageToText, PasteAsFile, Transcode]); + } - while (queue.Count != 0) + /// + /// Changed to depth-first traversal to ensure ordered output + /// + /// The collection of actions to traverse + /// All actions returned in depth-first order + private static IEnumerable GetAllActionsRecursive(IEnumerable actions) + { + foreach (var action in actions) { - var action = queue.Dequeue(); yield return action; - foreach (var subAction in action.SubActions) + foreach (var subAction in GetAllActionsRecursive(action.SubActions)) { - queue.Enqueue(subAction); + yield return subAction; } } } diff --git a/src/settings-ui/Settings.UI.Library/AdvancedPasteCustomAction.cs b/src/settings-ui/Settings.UI.Library/AdvancedPasteCustomAction.cs index 971d24c93b..43baf89351 100644 --- a/src/settings-ui/Settings.UI.Library/AdvancedPasteCustomAction.cs +++ b/src/settings-ui/Settings.UI.Library/AdvancedPasteCustomAction.cs @@ -5,8 +5,8 @@ using System; using System.Collections.Generic; using System.Text.Json.Serialization; - using Microsoft.PowerToys.Settings.UI.Library.Helpers; +using Microsoft.PowerToys.Settings.UI.Library.HotkeyConflicts; namespace Microsoft.PowerToys.Settings.UI.Library; @@ -20,6 +20,8 @@ public sealed class AdvancedPasteCustomAction : Observable, IAdvancedPasteAction private bool _canMoveUp; private bool _canMoveDown; private bool _isValid; + private bool _hasConflict; + private string _tooltip; [JsonPropertyName("id")] public int Id @@ -65,7 +67,6 @@ public sealed class AdvancedPasteCustomAction : Observable, IAdvancedPasteAction // We null-coalesce here rather than outside this branch as we want to raise PropertyChanged when the setter is called // with null; the ShortcutControl depends on this. _shortcut = value ?? new(); - OnPropertyChanged(); } } @@ -99,6 +100,20 @@ public sealed class AdvancedPasteCustomAction : Observable, IAdvancedPasteAction private set => Set(ref _isValid, value); } + [JsonIgnore] + public bool HasConflict + { + get => _hasConflict; + set => Set(ref _hasConflict, value); + } + + [JsonIgnore] + public string Tooltip + { + get => _tooltip; + set => Set(ref _tooltip, value); + } + [JsonIgnore] public IEnumerable SubActions => []; @@ -118,6 +133,8 @@ public sealed class AdvancedPasteCustomAction : Observable, IAdvancedPasteAction IsShown = other.IsShown; CanMoveUp = other.CanMoveUp; CanMoveDown = other.CanMoveDown; + HasConflict = other.HasConflict; + Tooltip = other.Tooltip; } private HotkeySettings GetShortcutClone() diff --git a/src/settings-ui/Settings.UI.Library/AdvancedPasteSettings.cs b/src/settings-ui/Settings.UI.Library/AdvancedPasteSettings.cs index e3ba7d4122..ca9cdacff6 100644 --- a/src/settings-ui/Settings.UI.Library/AdvancedPasteSettings.cs +++ b/src/settings-ui/Settings.UI.Library/AdvancedPasteSettings.cs @@ -3,14 +3,16 @@ // See the LICENSE file in the project root for more information. using System; +using System.Collections.Generic; using System.Text.Json; using System.Text.Json.Serialization; - +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library.Helpers; using Microsoft.PowerToys.Settings.UI.Library.Interfaces; namespace Microsoft.PowerToys.Settings.UI.Library { - public class AdvancedPasteSettings : BasePTModuleSettings, ISettingsConfig + public class AdvancedPasteSettings : BasePTModuleSettings, ISettingsConfig, IHotkeyConfig { public const string ModuleName = "AdvancedPaste"; @@ -39,6 +41,64 @@ namespace Microsoft.PowerToys.Settings.UI.Library settingsUtils.SaveSettings(JsonSerializer.Serialize(this, options), ModuleName); } + public ModuleType GetModuleType() => ModuleType.AdvancedPaste; + + public HotkeyAccessor[] GetAllHotkeyAccessors() + { + var hotkeyAccessors = new List + { + new HotkeyAccessor( + () => Properties.PasteAsPlainTextShortcut, + value => Properties.PasteAsPlainTextShortcut = value ?? AdvancedPasteProperties.DefaultPasteAsPlainTextShortcut, + "PasteAsPlainText_Shortcut"), + new HotkeyAccessor( + () => Properties.AdvancedPasteUIShortcut, + value => Properties.AdvancedPasteUIShortcut = value ?? AdvancedPasteProperties.DefaultAdvancedPasteUIShortcut, + "AdvancedPasteUI_Shortcut"), + new HotkeyAccessor( + () => Properties.PasteAsMarkdownShortcut, + value => Properties.PasteAsMarkdownShortcut = value ?? new HotkeySettings(), + "PasteAsMarkdown_Shortcut"), + new HotkeyAccessor( + () => Properties.PasteAsJsonShortcut, + value => Properties.PasteAsJsonShortcut = value ?? new HotkeySettings(), + "PasteAsJson_Shortcut"), + }; + + string[] additionalActionHeaderKeys = + [ + "ImageToText", + "PasteAsTxtFile", + "PasteAsPngFile", + "PasteAsHtmlFile", + "TranscodeToMp3", + "TranscodeToMp4", + ]; + int index = 0; + foreach (var action in Properties.AdditionalActions.GetAllActions()) + { + if (action is AdvancedPasteAdditionalAction additionalAction) + { + hotkeyAccessors.Add(new HotkeyAccessor( + () => additionalAction.Shortcut, + value => additionalAction.Shortcut = value ?? new HotkeySettings(), + additionalActionHeaderKeys[index])); + index++; + } + } + + // Custom actions do not have localization header, just use the action name. + foreach (var customAction in Properties.CustomActions.Value) + { + hotkeyAccessors.Add(new HotkeyAccessor( + () => customAction.Shortcut, + value => customAction.Shortcut = value ?? new HotkeySettings(), + customAction.Name)); + } + + return hotkeyAccessors.ToArray(); + } + public string GetModuleName() => Name; diff --git a/src/settings-ui/Settings.UI.Library/AlwaysOnTopSettings.cs b/src/settings-ui/Settings.UI.Library/AlwaysOnTopSettings.cs index 449c1c0a76..cb7e138596 100644 --- a/src/settings-ui/Settings.UI.Library/AlwaysOnTopSettings.cs +++ b/src/settings-ui/Settings.UI.Library/AlwaysOnTopSettings.cs @@ -2,13 +2,15 @@ // 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.Collections.Generic; using System.Text.Json.Serialization; - +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library.Helpers; using Microsoft.PowerToys.Settings.UI.Library.Interfaces; namespace Microsoft.PowerToys.Settings.UI.Library { - public class AlwaysOnTopSettings : BasePTModuleSettings, ISettingsConfig + public class AlwaysOnTopSettings : BasePTModuleSettings, ISettingsConfig, IHotkeyConfig { public const string ModuleName = "AlwaysOnTop"; public const string ModuleVersion = "0.0.1"; @@ -28,6 +30,21 @@ namespace Microsoft.PowerToys.Settings.UI.Library return Name; } + public ModuleType GetModuleType() => ModuleType.AlwaysOnTop; + + public HotkeyAccessor[] GetAllHotkeyAccessors() + { + var hotkeyAccessors = new List + { + new HotkeyAccessor( + () => Properties.Hotkey.Value, + value => Properties.Hotkey.Value = value ?? AlwaysOnTopProperties.DefaultHotkeyValue, + "AlwaysOnTop_ActivationShortcut"), + }; + + return hotkeyAccessors.ToArray(); + } + public bool UpgradeSettingsConfiguration() { return false; diff --git a/src/settings-ui/Settings.UI.Library/ColorPickerSettings.cs b/src/settings-ui/Settings.UI.Library/ColorPickerSettings.cs index 641625e180..b601b75baa 100644 --- a/src/settings-ui/Settings.UI.Library/ColorPickerSettings.cs +++ b/src/settings-ui/Settings.UI.Library/ColorPickerSettings.cs @@ -7,14 +7,14 @@ using System.Collections.Generic; using System.Linq; using System.Text.Json; using System.Text.Json.Serialization; - using ManagedCommon; using Microsoft.PowerToys.Settings.UI.Library.Enumerations; +using Microsoft.PowerToys.Settings.UI.Library.Helpers; using Microsoft.PowerToys.Settings.UI.Library.Interfaces; namespace Microsoft.PowerToys.Settings.UI.Library { - public class ColorPickerSettings : BasePTModuleSettings, ISettingsConfig + public class ColorPickerSettings : BasePTModuleSettings, ISettingsConfig, IHotkeyConfig { public const string ModuleName = "ColorPicker"; @@ -64,6 +64,21 @@ namespace Microsoft.PowerToys.Settings.UI.Library return false; } + public ModuleType GetModuleType() => ModuleType.ColorPicker; + + public HotkeyAccessor[] GetAllHotkeyAccessors() + { + var hotkeyAccessors = new List + { + new HotkeyAccessor( + () => Properties.ActivationShortcut, + value => Properties.ActivationShortcut = value ?? Properties.DefaultActivationShortcut, + "Activation_Shortcut"), + }; + + return hotkeyAccessors.ToArray(); + } + public static object UpgradeSettings(object oldSettingsObject) { ColorPickerSettingsVersion1 oldSettings = (ColorPickerSettingsVersion1)oldSettingsObject; diff --git a/src/settings-ui/Settings.UI.Library/ColorPickerSettingsVersion1.cs b/src/settings-ui/Settings.UI.Library/ColorPickerSettingsVersion1.cs index 840788992d..1ddff1946f 100644 --- a/src/settings-ui/Settings.UI.Library/ColorPickerSettingsVersion1.cs +++ b/src/settings-ui/Settings.UI.Library/ColorPickerSettingsVersion1.cs @@ -5,7 +5,6 @@ using System; using System.Text.Json; using System.Text.Json.Serialization; - using Microsoft.PowerToys.Settings.UI.Library.Interfaces; namespace Microsoft.PowerToys.Settings.UI.Library diff --git a/src/settings-ui/Settings.UI.Library/CropAndLockSettings.cs b/src/settings-ui/Settings.UI.Library/CropAndLockSettings.cs index ed6600f287..517c4e8754 100644 --- a/src/settings-ui/Settings.UI.Library/CropAndLockSettings.cs +++ b/src/settings-ui/Settings.UI.Library/CropAndLockSettings.cs @@ -2,13 +2,15 @@ // 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.Collections.Generic; using System.Text.Json.Serialization; - +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library.Helpers; using Microsoft.PowerToys.Settings.UI.Library.Interfaces; namespace Microsoft.PowerToys.Settings.UI.Library { - public class CropAndLockSettings : BasePTModuleSettings, ISettingsConfig + public class CropAndLockSettings : BasePTModuleSettings, ISettingsConfig, IHotkeyConfig { public const string ModuleName = "CropAndLock"; public const string ModuleVersion = "0.0.1"; @@ -28,6 +30,25 @@ namespace Microsoft.PowerToys.Settings.UI.Library return Name; } + public ModuleType GetModuleType() => ModuleType.CropAndLock; + + public HotkeyAccessor[] GetAllHotkeyAccessors() + { + var hotkeyAccessors = new List + { + new HotkeyAccessor( + () => Properties.ReparentHotkey.Value, + value => Properties.ReparentHotkey.Value = value ?? CropAndLockProperties.DefaultReparentHotkeyValue, + "CropAndLock_ReparentActivation_Shortcut"), + new HotkeyAccessor( + () => Properties.ThumbnailHotkey.Value, + value => Properties.ThumbnailHotkey.Value = value ?? CropAndLockProperties.DefaultThumbnailHotkeyValue, + "CropAndLock_ThumbnailActivation_Shortcut"), + }; + + return hotkeyAccessors.ToArray(); + } + public bool UpgradeSettingsConfiguration() { return false; diff --git a/src/settings-ui/Settings.UI.Library/FindMyMouseSettings.cs b/src/settings-ui/Settings.UI.Library/FindMyMouseSettings.cs index aca45d0b01..fb00351ee2 100644 --- a/src/settings-ui/Settings.UI.Library/FindMyMouseSettings.cs +++ b/src/settings-ui/Settings.UI.Library/FindMyMouseSettings.cs @@ -2,13 +2,15 @@ // 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.Collections.Generic; using System.Text.Json.Serialization; - +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library.Helpers; using Microsoft.PowerToys.Settings.UI.Library.Interfaces; namespace Microsoft.PowerToys.Settings.UI.Library { - public class FindMyMouseSettings : BasePTModuleSettings, ISettingsConfig + public class FindMyMouseSettings : BasePTModuleSettings, ISettingsConfig, IHotkeyConfig { public const string ModuleName = "FindMyMouse"; @@ -27,6 +29,21 @@ namespace Microsoft.PowerToys.Settings.UI.Library return Name; } + public ModuleType GetModuleType() => ModuleType.FindMyMouse; + + public HotkeyAccessor[] GetAllHotkeyAccessors() + { + var hotkeyAccessors = new List + { + new HotkeyAccessor( + () => Properties.ActivationShortcut, + value => Properties.ActivationShortcut = value ?? Properties.DefaultActivationShortcut, + "MouseUtils_FindMyMouse_ActivationShortcut"), + }; + + return hotkeyAccessors.ToArray(); + } + // This can be utilized in the future if the settings.json file is to be modified/deleted. public bool UpgradeSettingsConfiguration() { diff --git a/src/settings-ui/Settings.UI.Library/Helpers/HotkeyAccessor.cs b/src/settings-ui/Settings.UI.Library/Helpers/HotkeyAccessor.cs new file mode 100644 index 0000000000..41c4d4af61 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/Helpers/HotkeyAccessor.cs @@ -0,0 +1,34 @@ +// 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.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.PowerToys.Settings.UI.Library.Helpers +{ + public class HotkeyAccessor + { + public Func Getter { get; } + + public Action Setter { get; } + + public HotkeyAccessor(Func getter, Action setter, string localizationHeaderKey = "") + { + Getter = getter ?? throw new ArgumentNullException(nameof(getter)); + Setter = setter ?? throw new ArgumentNullException(nameof(setter)); + LocalizationHeaderKey = localizationHeaderKey; + } + + public HotkeySettings Value + { + get => Getter(); + set => Setter(value); + } + + public string LocalizationHeaderKey { get; set; } + } +} diff --git a/src/settings-ui/Settings.UI.Library/HotkeyConflicts/AllHotkeyConflictsData.cs b/src/settings-ui/Settings.UI.Library/HotkeyConflicts/AllHotkeyConflictsData.cs new file mode 100644 index 0000000000..00d2145f29 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/HotkeyConflicts/AllHotkeyConflictsData.cs @@ -0,0 +1,19 @@ +// 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.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.PowerToys.Settings.UI.Library.HotkeyConflicts +{ + public class AllHotkeyConflictsData + { + public List InAppConflicts { get; set; } = new List(); + + public List SystemConflicts { get; set; } = new List(); + } +} diff --git a/src/settings-ui/Settings.UI.Library/HotkeyConflicts/AllHotkeyConflictsEventArgs.cs b/src/settings-ui/Settings.UI.Library/HotkeyConflicts/AllHotkeyConflictsEventArgs.cs new file mode 100644 index 0000000000..28f034d81b --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/HotkeyConflicts/AllHotkeyConflictsEventArgs.cs @@ -0,0 +1,22 @@ +// 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.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.PowerToys.Settings.UI.Library.HotkeyConflicts +{ + public class AllHotkeyConflictsEventArgs : EventArgs + { + public AllHotkeyConflictsData Conflicts { get; } + + public AllHotkeyConflictsEventArgs(AllHotkeyConflictsData conflicts) + { + Conflicts = conflicts; + } + } +} diff --git a/src/settings-ui/Settings.UI.Library/HotkeyConflicts/HotkeyConflictGroupData.cs b/src/settings-ui/Settings.UI.Library/HotkeyConflicts/HotkeyConflictGroupData.cs new file mode 100644 index 0000000000..a420ec7a2b --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/HotkeyConflicts/HotkeyConflictGroupData.cs @@ -0,0 +1,21 @@ +// 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.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.PowerToys.Settings.UI.Library.HotkeyConflicts +{ + public class HotkeyConflictGroupData + { + public HotkeyData Hotkey { get; set; } + + public bool IsSystemConflict { get; set; } + + public List Modules { get; set; } + } +} diff --git a/src/settings-ui/Settings.UI.Library/HotkeyConflicts/HotkeyConflictInfo.cs b/src/settings-ui/Settings.UI.Library/HotkeyConflicts/HotkeyConflictInfo.cs new file mode 100644 index 0000000000..193eb39d89 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/HotkeyConflicts/HotkeyConflictInfo.cs @@ -0,0 +1,23 @@ +// 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.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.PowerToys.Settings.UI.Library.HotkeyConflicts +{ + public class HotkeyConflictInfo + { + public bool IsSystemConflict { get; set; } + + public string ConflictingModuleName { get; set; } + + public int ConflictingHotkeyID { get; set; } + + public List AllConflictingModules { get; set; } = new List(); + } +} diff --git a/src/settings-ui/Settings.UI.Library/HotkeyConflicts/HotkeyData.cs b/src/settings-ui/Settings.UI.Library/HotkeyConflicts/HotkeyData.cs new file mode 100644 index 0000000000..9e416db7d9 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/HotkeyConflicts/HotkeyData.cs @@ -0,0 +1,71 @@ +// 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.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.PowerToys.Settings.UI.Library.Utilities; + +namespace Microsoft.PowerToys.Settings.UI.Library.HotkeyConflicts +{ + public class HotkeyData + { + public bool Win { get; set; } + + public bool Ctrl { get; set; } + + public bool Shift { get; set; } + + public bool Alt { get; set; } + + public int Key { get; set; } + + public List GetKeysList() + { + List shortcutList = new List(); + + if (Win) + { + shortcutList.Add(92); // The Windows key or button. + } + + if (Ctrl) + { + shortcutList.Add("Ctrl"); + } + + if (Alt) + { + shortcutList.Add("Alt"); + } + + if (Shift) + { + shortcutList.Add(16); // The Shift key or button. + } + + if (Key > 0) + { + switch (Key) + { + // https://learn.microsoft.com/uwp/api/windows.system.virtualkey?view=winrt-20348 + case 38: // The Up Arrow key or button. + case 40: // The Down Arrow key or button. + case 37: // The Left Arrow key or button. + case 39: // The Right Arrow key or button. + shortcutList.Add(Key); + break; + default: + var localKey = Helper.GetKeyName((uint)Key); + shortcutList.Add(localKey); + break; + } + } + + return shortcutList; + } + } +} diff --git a/src/settings-ui/Settings.UI.Library/HotkeyConflicts/ModuleConflictsData.cs b/src/settings-ui/Settings.UI.Library/HotkeyConflicts/ModuleConflictsData.cs new file mode 100644 index 0000000000..2b343693bd --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/HotkeyConflicts/ModuleConflictsData.cs @@ -0,0 +1,21 @@ +// 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.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.PowerToys.Settings.UI.Library.HotkeyConflicts +{ + public class ModuleConflictsData + { + public List InAppConflicts { get; set; } = new List(); + + public List SystemConflicts { get; set; } = new List(); + + public bool HasConflicts => InAppConflicts.Count > 0 || SystemConflicts.Count > 0; + } +} diff --git a/src/settings-ui/Settings.UI.Library/HotkeyConflicts/ModuleHotkeyData.cs b/src/settings-ui/Settings.UI.Library/HotkeyConflicts/ModuleHotkeyData.cs new file mode 100644 index 0000000000..f24e02e650 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/HotkeyConflicts/ModuleHotkeyData.cs @@ -0,0 +1,84 @@ +// 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.ComponentModel; +using System.Runtime.CompilerServices; +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library.Helpers; +using Windows.Web.AtomPub; + +namespace Microsoft.PowerToys.Settings.UI.Library.HotkeyConflicts +{ + public class ModuleHotkeyData : INotifyPropertyChanged + { + private string _moduleName; + private int _hotkeyID; + private HotkeySettings _hotkeySettings; + private bool _isSystemConflict; + + public event PropertyChangedEventHandler PropertyChanged; + + public string IconPath { get; set; } + + public string DisplayName { get; set; } + + public string Header { get; set; } + + protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + + public string ModuleName + { + get => _moduleName; + set + { + if (_moduleName != value) + { + _moduleName = value; + } + } + } + + public int HotkeyID + { + get => _hotkeyID; + set + { + if (_hotkeyID != value) + { + _hotkeyID = value; + } + } + } + + public HotkeySettings HotkeySettings + { + get => _hotkeySettings; + set + { + if (_hotkeySettings != value) + { + _hotkeySettings = value; + OnPropertyChanged(); + } + } + } + + public bool IsSystemConflict + { + get => _isSystemConflict; + set + { + if (_isSystemConflict != value) + { + _isSystemConflict = value; + } + } + } + + public ModuleType ModuleType { get; set; } + } +} diff --git a/src/settings-ui/Settings.UI.Library/HotkeySettings.cs b/src/settings-ui/Settings.UI.Library/HotkeySettings.cs index 89c1a1995d..724e1b5159 100644 --- a/src/settings-ui/Settings.UI.Library/HotkeySettings.cs +++ b/src/settings-ui/Settings.UI.Library/HotkeySettings.cs @@ -4,17 +4,29 @@ using System; using System.Collections.Generic; +using System.ComponentModel; using System.Globalization; +using System.Runtime.CompilerServices; using System.Text; using System.Text.Json.Serialization; - +using ManagedCommon; using Microsoft.PowerToys.Settings.UI.Library.Utilities; namespace Microsoft.PowerToys.Settings.UI.Library { - public record HotkeySettings : ICmdLineRepresentable + public record HotkeySettings : ICmdLineRepresentable, INotifyPropertyChanged { private const int VKTAB = 0x09; + private bool _hasConflict; + private string _conflictDescription; + private bool _isSystemConflict; + + public event PropertyChangedEventHandler PropertyChanged; + + protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } public HotkeySettings() { @@ -23,6 +35,8 @@ namespace Microsoft.PowerToys.Settings.UI.Library Alt = false; Shift = false; Code = 0; + + HasConflict = false; } /// @@ -40,6 +54,51 @@ namespace Microsoft.PowerToys.Settings.UI.Library Alt = alt; Shift = shift; Code = code; + HasConflict = false; + } + + public bool HasConflict + { + get => _hasConflict; + set + { + if (_hasConflict != value) + { + _hasConflict = value; + OnPropertyChanged(); + } + } + } + + public string ConflictDescription + { + get => _conflictDescription ?? string.Empty; + set + { + if (_conflictDescription != value) + { + _conflictDescription = value; + OnPropertyChanged(); + } + } + } + + public bool IsSystemConflict + { + get => _isSystemConflict; + set + { + if (_isSystemConflict != value) + { + _isSystemConflict = value; + OnPropertyChanged(); + } + } + } + + public virtual void UpdateConflictStatus() + { + Logger.LogInfo($"{this.ToString()}"); } [JsonPropertyName("win")] diff --git a/src/settings-ui/Settings.UI.Library/Interfaces/IHotkeyConfig.cs b/src/settings-ui/Settings.UI.Library/Interfaces/IHotkeyConfig.cs new file mode 100644 index 0000000000..ee38f51cad --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/Interfaces/IHotkeyConfig.cs @@ -0,0 +1,17 @@ +// 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.Collections.Generic; +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library.Helpers; + +namespace Microsoft.PowerToys.Settings.UI.Library.Interfaces +{ + public interface IHotkeyConfig + { + HotkeyAccessor[] GetAllHotkeyAccessors(); + + ModuleType GetModuleType(); + } +} diff --git a/src/settings-ui/Settings.UI.Library/MeasureToolSettings.cs b/src/settings-ui/Settings.UI.Library/MeasureToolSettings.cs index 5720c70ca5..e2d034eb21 100644 --- a/src/settings-ui/Settings.UI.Library/MeasureToolSettings.cs +++ b/src/settings-ui/Settings.UI.Library/MeasureToolSettings.cs @@ -2,13 +2,15 @@ // 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.Collections.Generic; using System.Text.Json.Serialization; - +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library.Helpers; using Microsoft.PowerToys.Settings.UI.Library.Interfaces; namespace Microsoft.PowerToys.Settings.UI.Library { - public class MeasureToolSettings : BasePTModuleSettings, ISettingsConfig + public class MeasureToolSettings : BasePTModuleSettings, ISettingsConfig, IHotkeyConfig { public const string ModuleName = "Measure Tool"; @@ -25,6 +27,21 @@ namespace Microsoft.PowerToys.Settings.UI.Library public string GetModuleName() => Name; + public ModuleType GetModuleType() => ModuleType.MeasureTool; + + public HotkeyAccessor[] GetAllHotkeyAccessors() + { + var hotkeyAccessors = new List + { + new HotkeyAccessor( + () => Properties.ActivationShortcut, + value => Properties.ActivationShortcut = value ?? Properties.DefaultActivationShortcut, + "MeasureTool_ActivationShortcut"), + }; + + return hotkeyAccessors.ToArray(); + } + // This can be utilized in the future if the settings.json file is to be modified/deleted. public bool UpgradeSettingsConfiguration() => false; diff --git a/src/settings-ui/Settings.UI.Library/MouseHighlighterSettings.cs b/src/settings-ui/Settings.UI.Library/MouseHighlighterSettings.cs index e23a7fe288..54f28c026b 100644 --- a/src/settings-ui/Settings.UI.Library/MouseHighlighterSettings.cs +++ b/src/settings-ui/Settings.UI.Library/MouseHighlighterSettings.cs @@ -2,15 +2,17 @@ // 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.Collections.Generic; using System.Globalization; using System.Runtime.InteropServices; using System.Text.Json.Serialization; - +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library.Helpers; using Microsoft.PowerToys.Settings.UI.Library.Interfaces; namespace Microsoft.PowerToys.Settings.UI.Library { - public class MouseHighlighterSettings : BasePTModuleSettings, ISettingsConfig + public class MouseHighlighterSettings : BasePTModuleSettings, ISettingsConfig, IHotkeyConfig { public const string ModuleName = "MouseHighlighter"; @@ -29,6 +31,21 @@ namespace Microsoft.PowerToys.Settings.UI.Library return Name; } + public ModuleType GetModuleType() => ModuleType.MouseHighlighter; + + public HotkeyAccessor[] GetAllHotkeyAccessors() + { + var hotkeyAccessors = new List + { + new HotkeyAccessor( + () => Properties.ActivationShortcut, + value => Properties.ActivationShortcut = value ?? Properties.DefaultActivationShortcut, + "MouseUtils_MouseHighlighter_ActivationShortcut"), + }; + + return hotkeyAccessors.ToArray(); + } + // This can be utilized in the future if the settings.json file is to be modified/deleted. public bool UpgradeSettingsConfiguration() { diff --git a/src/settings-ui/Settings.UI.Library/MouseJumpSettings.cs b/src/settings-ui/Settings.UI.Library/MouseJumpSettings.cs index 450e6aec93..a4c5a04555 100644 --- a/src/settings-ui/Settings.UI.Library/MouseJumpSettings.cs +++ b/src/settings-ui/Settings.UI.Library/MouseJumpSettings.cs @@ -3,16 +3,18 @@ // See the LICENSE file in the project root for more information. using System; +using System.Collections.Generic; using System.Text.Json; using System.Text.Json.Serialization; - +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library.Helpers; using Microsoft.PowerToys.Settings.UI.Library.Interfaces; using MouseJump.Common.Helpers; using MouseJump.Common.Models.Settings; namespace Microsoft.PowerToys.Settings.UI.Library { - public class MouseJumpSettings : BasePTModuleSettings, ISettingsConfig + public class MouseJumpSettings : BasePTModuleSettings, ISettingsConfig, IHotkeyConfig { public const string ModuleName = "MouseJump"; @@ -46,6 +48,21 @@ namespace Microsoft.PowerToys.Settings.UI.Library return Name; } + public ModuleType GetModuleType() => ModuleType.MouseJump; + + public HotkeyAccessor[] GetAllHotkeyAccessors() + { + var hotkeyAccessors = new List + { + new HotkeyAccessor( + () => Properties.ActivationShortcut, + value => Properties.ActivationShortcut = value ?? Properties.DefaultActivationShortcut, + "MouseUtils_MouseJump_ActivationShortcut"), + }; + + return hotkeyAccessors.ToArray(); + } + // This can be utilized in the future if the settings.json file is to be modified/deleted. public bool UpgradeSettingsConfiguration() { diff --git a/src/settings-ui/Settings.UI.Library/MousePointerCrosshairsProperties.cs b/src/settings-ui/Settings.UI.Library/MousePointerCrosshairsProperties.cs index 9b0e530a2a..54542194c0 100644 --- a/src/settings-ui/Settings.UI.Library/MousePointerCrosshairsProperties.cs +++ b/src/settings-ui/Settings.UI.Library/MousePointerCrosshairsProperties.cs @@ -13,9 +13,15 @@ namespace Microsoft.PowerToys.Settings.UI.Library [CmdConfigureIgnore] public HotkeySettings DefaultActivationShortcut => new HotkeySettings(true, false, true, false, 0x50); // Win + Alt + P + [CmdConfigureIgnore] + public HotkeySettings DefaultGlidingCursorActivationShortcut => new HotkeySettings(true, false, true, false, 0xBE); // Win + Alt + . + [JsonPropertyName("activation_shortcut")] public HotkeySettings ActivationShortcut { get; set; } + [JsonPropertyName("gliding_cursor_activation_shortcut")] + public HotkeySettings GlidingCursorActivationShortcut { get; set; } + [JsonPropertyName("crosshairs_color")] public StringProperty CrosshairsColor { get; set; } @@ -46,9 +52,16 @@ namespace Microsoft.PowerToys.Settings.UI.Library [JsonPropertyName("auto_activate")] public BoolProperty AutoActivate { get; set; } + [JsonPropertyName("gliding_travel_speed")] + public IntProperty GlidingTravelSpeed { get; set; } + + [JsonPropertyName("gliding_delay_speed")] + public IntProperty GlidingDelaySpeed { get; set; } + public MousePointerCrosshairsProperties() { ActivationShortcut = DefaultActivationShortcut; + GlidingCursorActivationShortcut = DefaultGlidingCursorActivationShortcut; CrosshairsColor = new StringProperty("#FF0000"); CrosshairsOpacity = new IntProperty(75); CrosshairsRadius = new IntProperty(20); @@ -59,6 +72,8 @@ namespace Microsoft.PowerToys.Settings.UI.Library CrosshairsIsFixedLengthEnabled = new BoolProperty(false); CrosshairsFixedLength = new IntProperty(1); AutoActivate = new BoolProperty(false); + GlidingTravelSpeed = new IntProperty(25); + GlidingDelaySpeed = new IntProperty(5); } } } diff --git a/src/settings-ui/Settings.UI.Library/MousePointerCrosshairsSettings.cs b/src/settings-ui/Settings.UI.Library/MousePointerCrosshairsSettings.cs index 2658a2adec..d814f115a1 100644 --- a/src/settings-ui/Settings.UI.Library/MousePointerCrosshairsSettings.cs +++ b/src/settings-ui/Settings.UI.Library/MousePointerCrosshairsSettings.cs @@ -2,13 +2,15 @@ // 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.Collections.Generic; using System.Text.Json.Serialization; - +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library.Helpers; using Microsoft.PowerToys.Settings.UI.Library.Interfaces; namespace Microsoft.PowerToys.Settings.UI.Library { - public class MousePointerCrosshairsSettings : BasePTModuleSettings, ISettingsConfig + public class MousePointerCrosshairsSettings : BasePTModuleSettings, ISettingsConfig, IHotkeyConfig { public const string ModuleName = "MousePointerCrosshairs"; @@ -27,6 +29,25 @@ namespace Microsoft.PowerToys.Settings.UI.Library return Name; } + public ModuleType GetModuleType() => ModuleType.MousePointerCrosshairs; + + public HotkeyAccessor[] GetAllHotkeyAccessors() + { + var hotkeyAccessors = new List + { + new HotkeyAccessor( + () => Properties.ActivationShortcut, + value => Properties.ActivationShortcut = value ?? Properties.DefaultActivationShortcut, + "MouseUtils_MousePointerCrosshairs_ActivationShortcut"), + new HotkeyAccessor( + () => Properties.GlidingCursorActivationShortcut, + value => Properties.GlidingCursorActivationShortcut = value ?? Properties.DefaultGlidingCursorActivationShortcut, + "MouseUtils_GlidingCursor"), + }; + + return hotkeyAccessors.ToArray(); + } + // This can be utilized in the future if the settings.json file is to be modified/deleted. public bool UpgradeSettingsConfiguration() { diff --git a/src/settings-ui/Settings.UI.Library/MouseWithoutBordersSettings.cs b/src/settings-ui/Settings.UI.Library/MouseWithoutBordersSettings.cs index 6a51a150e5..3cab182fec 100644 --- a/src/settings-ui/Settings.UI.Library/MouseWithoutBordersSettings.cs +++ b/src/settings-ui/Settings.UI.Library/MouseWithoutBordersSettings.cs @@ -3,15 +3,17 @@ // See the LICENSE file in the project root for more information. using System; +using System.Collections.Generic; using System.Diagnostics; using System.Text.Json; using System.Text.Json.Serialization; - +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library.Helpers; using Microsoft.PowerToys.Settings.UI.Library.Interfaces; namespace Microsoft.PowerToys.Settings.UI.Library { - public class MouseWithoutBordersSettings : BasePTModuleSettings, ISettingsConfig + public class MouseWithoutBordersSettings : BasePTModuleSettings, ISettingsConfig, IHotkeyConfig { public const string ModuleName = "MouseWithoutBorders"; @@ -37,6 +39,33 @@ namespace Microsoft.PowerToys.Settings.UI.Library return Name; } + public ModuleType GetModuleType() => ModuleType.MouseWithoutBorders; + + public HotkeyAccessor[] GetAllHotkeyAccessors() + { + var hotkeyAccessors = new List + { + new HotkeyAccessor( + () => Properties.ToggleEasyMouseShortcut, + value => Properties.ToggleEasyMouseShortcut = value ?? MouseWithoutBordersProperties.DefaultHotKeyToggleEasyMouse, + "MouseWithoutBorders_ToggleEasyMouseShortcut"), + new HotkeyAccessor( + () => Properties.LockMachineShortcut, + value => Properties.LockMachineShortcut = value ?? MouseWithoutBordersProperties.DefaultHotKeyLockMachine, + "MouseWithoutBorders_LockMachinesShortcut"), + new HotkeyAccessor( + () => Properties.Switch2AllPCShortcut, + value => Properties.Switch2AllPCShortcut = value ?? MouseWithoutBordersProperties.DefaultHotKeySwitch2AllPC, + "MouseWithoutBorders_Switch2AllPcShortcut"), + new HotkeyAccessor( + () => Properties.ReconnectShortcut, + value => Properties.ReconnectShortcut = value ?? MouseWithoutBordersProperties.DefaultHotKeyReconnect, + "MouseWithoutBorders_ReconnectShortcut"), + }; + + return hotkeyAccessors.ToArray(); + } + public HotkeySettings ConvertMouseWithoutBordersHotKeyToPowerToys(int value) { // VK_A <= value <= VK_Z diff --git a/src/settings-ui/Settings.UI.Library/PeekSettings.cs b/src/settings-ui/Settings.UI.Library/PeekSettings.cs index f5ad2a0e26..73993c72fa 100644 --- a/src/settings-ui/Settings.UI.Library/PeekSettings.cs +++ b/src/settings-ui/Settings.UI.Library/PeekSettings.cs @@ -3,14 +3,16 @@ // See the LICENSE file in the project root for more information. using System; +using System.Collections.Generic; using System.Text.Json; using System.Text.Json.Serialization; - +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library.Helpers; using Microsoft.PowerToys.Settings.UI.Library.Interfaces; namespace Microsoft.PowerToys.Settings.UI.Library { - public class PeekSettings : BasePTModuleSettings, ISettingsConfig + public class PeekSettings : BasePTModuleSettings, ISettingsConfig, IHotkeyConfig { public const string ModuleName = "Peek"; public const string ModuleVersion = "0.0.1"; @@ -35,6 +37,21 @@ namespace Microsoft.PowerToys.Settings.UI.Library return Name; } + public ModuleType GetModuleType() => ModuleType.Peek; + + public HotkeyAccessor[] GetAllHotkeyAccessors() + { + var hotkeyAccessors = new List + { + new HotkeyAccessor( + () => Properties.ActivationShortcut, + value => Properties.ActivationShortcut = value ?? Properties.DefaultActivationShortcut, + "Activation_Shortcut"), + }; + + return hotkeyAccessors.ToArray(); + } + public bool UpgradeSettingsConfiguration() { return false; diff --git a/src/settings-ui/Settings.UI.Library/PowerLauncherSettings.cs b/src/settings-ui/Settings.UI.Library/PowerLauncherSettings.cs index c21ce67df5..18d2c2da1c 100644 --- a/src/settings-ui/Settings.UI.Library/PowerLauncherSettings.cs +++ b/src/settings-ui/Settings.UI.Library/PowerLauncherSettings.cs @@ -6,12 +6,13 @@ using System; using System.Collections.Generic; using System.Text.Json; using System.Text.Json.Serialization; - +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library.Helpers; using Microsoft.PowerToys.Settings.UI.Library.Interfaces; namespace Microsoft.PowerToys.Settings.UI.Library { - public class PowerLauncherSettings : BasePTModuleSettings, ISettingsConfig + public class PowerLauncherSettings : BasePTModuleSettings, ISettingsConfig, IHotkeyConfig { public const string ModuleName = "PowerToys Run"; @@ -49,6 +50,21 @@ namespace Microsoft.PowerToys.Settings.UI.Library return Name; } + public ModuleType GetModuleType() => ModuleType.PowerLauncher; + + public HotkeyAccessor[] GetAllHotkeyAccessors() + { + var hotkeyAccessors = new List + { + new HotkeyAccessor( + () => Properties.OpenPowerLauncher, + value => Properties.OpenPowerLauncher = value ?? Properties.DefaultOpenPowerLauncher, + "PowerLauncher_OpenPowerLauncher"), + }; + + return hotkeyAccessors.ToArray(); + } + // This can be utilized in the future if the settings.json file is to be modified/deleted. public bool UpgradeSettingsConfiguration() { diff --git a/src/settings-ui/Settings.UI.Library/PowerOcrSettings.cs b/src/settings-ui/Settings.UI.Library/PowerOcrSettings.cs index 46d176d2b0..4a296832ae 100644 --- a/src/settings-ui/Settings.UI.Library/PowerOcrSettings.cs +++ b/src/settings-ui/Settings.UI.Library/PowerOcrSettings.cs @@ -3,14 +3,16 @@ // See the LICENSE file in the project root for more information. using System; +using System.Collections.Generic; using System.Text.Json; using System.Text.Json.Serialization; - +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library.Helpers; using Microsoft.PowerToys.Settings.UI.Library.Interfaces; namespace Microsoft.PowerToys.Settings.UI.Library { - public class PowerOcrSettings : BasePTModuleSettings, ISettingsConfig + public class PowerOcrSettings : BasePTModuleSettings, ISettingsConfig, IHotkeyConfig { public const string ModuleName = "TextExtractor"; @@ -42,6 +44,21 @@ namespace Microsoft.PowerToys.Settings.UI.Library public string GetModuleName() => Name; + public ModuleType GetModuleType() => ModuleType.PowerOCR; + + public HotkeyAccessor[] GetAllHotkeyAccessors() + { + var hotkeyAccessors = new List + { + new HotkeyAccessor( + () => Properties.ActivationShortcut, + value => Properties.ActivationShortcut = value ?? Properties.DefaultActivationShortcut, + "Activation_Shortcut"), + }; + + return hotkeyAccessors.ToArray(); + } + // This can be utilized in the future if the settings.json file is to be modified/deleted. public bool UpgradeSettingsConfiguration() => false; diff --git a/src/settings-ui/Settings.UI.Library/SettingsFactory.cs b/src/settings-ui/Settings.UI.Library/SettingsFactory.cs new file mode 100644 index 0000000000..2bb9e79121 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/SettingsFactory.cs @@ -0,0 +1,197 @@ +// 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.Collections.Generic; +using System.Linq; +using System.Reflection; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Settings.UI.Library.Helpers; +using Microsoft.PowerToys.Settings.UI.Library.Interfaces; + +namespace Microsoft.PowerToys.Settings.UI.Services +{ + /// + /// Factory service for getting PowerToys module Settings that implement IHotkeyConfig + /// + public class SettingsFactory + { + private readonly ISettingsUtils _settingsUtils; + private readonly Dictionary _settingsTypes; + + public SettingsFactory(ISettingsUtils settingsUtils) + { + _settingsUtils = settingsUtils ?? throw new ArgumentNullException(nameof(settingsUtils)); + _settingsTypes = DiscoverSettingsTypes(); + } + + /// + /// Dynamically discovers all Settings types that implement IHotkeyConfig + /// + private Dictionary DiscoverSettingsTypes() + { + var settingsTypes = new Dictionary(); + + // Get the Settings.UI.Library assembly + var assembly = Assembly.GetAssembly(typeof(IHotkeyConfig)); + if (assembly == null) + { + return settingsTypes; + } + + try + { + // Find all types that implement IHotkeyConfig and ISettingsConfig + var hotkeyConfigTypes = assembly.GetTypes() + .Where(type => + type.IsClass && + !type.IsAbstract && + typeof(IHotkeyConfig).IsAssignableFrom(type) && + typeof(ISettingsConfig).IsAssignableFrom(type)) + .ToList(); + + foreach (var type in hotkeyConfigTypes) + { + // Try to get the ModuleName using SettingsRepository + try + { + var repositoryType = typeof(SettingsRepository<>).MakeGenericType(type); + var getInstanceMethod = repositoryType.GetMethod("GetInstance", BindingFlags.Public | BindingFlags.Static); + var repository = getInstanceMethod?.Invoke(null, new object[] { _settingsUtils }); + + if (repository != null) + { + var settingsConfigProperty = repository.GetType().GetProperty("SettingsConfig"); + var settingsInstance = settingsConfigProperty?.GetValue(repository) as ISettingsConfig; + + if (settingsInstance != null) + { + var moduleName = settingsInstance.GetModuleName(); + if (!string.IsNullOrEmpty(moduleName)) + { + settingsTypes[moduleName] = type; + System.Diagnostics.Debug.WriteLine($"Discovered settings type: {type.Name} for module: {moduleName}"); + } + } + } + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Error getting module name for {type.Name}: {ex.Message}"); + } + } + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Error scanning assembly {assembly.FullName}: {ex.Message}"); + } + + return settingsTypes; + } + + public IHotkeyConfig GetFreshSettings(string moduleKey) + { + if (!_settingsTypes.TryGetValue(moduleKey, out var settingsType)) + { + return null; + } + + try + { + // Create a generic method call to _settingsUtils.GetSettingsOrDefault(moduleKey) + var getSettingsMethod = typeof(ISettingsUtils).GetMethod("GetSettingsOrDefault", new[] { typeof(string), typeof(string) }); + var genericMethod = getSettingsMethod?.MakeGenericMethod(settingsType); + + // Call GetSettingsOrDefault(moduleKey) to get fresh settings from file + var freshSettings = genericMethod?.Invoke(_settingsUtils, new object[] { moduleKey, "settings.json" }); + + return freshSettings as IHotkeyConfig; + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Error getting fresh settings for {moduleKey}: {ex.Message}"); + return null; + } + } + + /// + /// Gets a settings instance for the specified module using SettingsRepository + /// + /// The module key/name + /// The settings instance implementing IHotkeyConfig, or null if not found + public IHotkeyConfig GetSettings(string moduleKey) + { + if (!_settingsTypes.TryGetValue(moduleKey, out var settingsType)) + { + return null; + } + + try + { + var repositoryType = typeof(SettingsRepository<>).MakeGenericType(settingsType); + var getInstanceMethod = repositoryType.GetMethod("GetInstance", BindingFlags.Public | BindingFlags.Static); + var repository = getInstanceMethod?.Invoke(null, new object[] { _settingsUtils }); + + if (repository != null) + { + var settingsConfigProperty = repository.GetType().GetProperty("SettingsConfig"); + return settingsConfigProperty?.GetValue(repository) as IHotkeyConfig; + } + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Error getting Settings for {moduleKey}: {ex.Message}"); + } + + return null; + } + + /// + /// Gets all available module names that have settings implementing IHotkeyConfig + /// + /// List of module names + public List GetAvailableModuleNames() + { + return _settingsTypes.Keys.ToList(); + } + + /// + /// Gets all available settings that implement IHotkeyConfig + /// + /// Dictionary of module name to settings instance + public Dictionary GetAllHotkeySettings() + { + var result = new Dictionary(); + + foreach (var moduleKey in _settingsTypes.Keys) + { + try + { + var settings = GetSettings(moduleKey); + if (settings != null) + { + result[moduleKey] = settings; + } + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Error getting settings for {moduleKey}: {ex.Message}"); + } + } + + return result; + } + + /// + /// Gets a specific settings repository instance + /// + /// The settings type + /// The settings repository instance + public ISettingsRepository GetRepository() + where T : class, ISettingsConfig, new() + { + return SettingsRepository.GetInstance(_settingsUtils); + } + } +} diff --git a/src/settings-ui/Settings.UI.Library/ShortcutGuideSettings.cs b/src/settings-ui/Settings.UI.Library/ShortcutGuideSettings.cs index c39e757fe3..40174aeb81 100644 --- a/src/settings-ui/Settings.UI.Library/ShortcutGuideSettings.cs +++ b/src/settings-ui/Settings.UI.Library/ShortcutGuideSettings.cs @@ -2,13 +2,15 @@ // 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.Collections.Generic; using System.Text.Json.Serialization; - +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library.Helpers; using Microsoft.PowerToys.Settings.UI.Library.Interfaces; namespace Microsoft.PowerToys.Settings.UI.Library { - public class ShortcutGuideSettings : BasePTModuleSettings, ISettingsConfig + public class ShortcutGuideSettings : BasePTModuleSettings, ISettingsConfig, IHotkeyConfig { public const string ModuleName = "Shortcut Guide"; @@ -27,6 +29,21 @@ namespace Microsoft.PowerToys.Settings.UI.Library return Name; } + public ModuleType GetModuleType() => ModuleType.ShortcutGuide; + + public HotkeyAccessor[] GetAllHotkeyAccessors() + { + var hotkeyAccessors = new List + { + new HotkeyAccessor( + () => Properties.OpenShortcutGuide, + value => Properties.OpenShortcutGuide = value ?? Properties.DefaultOpenShortcutGuide, + "Activation_Shortcut"), + }; + + return hotkeyAccessors.ToArray(); + } + // This can be utilized in the future if the settings.json file is to be modified/deleted. public bool UpgradeSettingsConfiguration() { diff --git a/src/settings-ui/Settings.UI.Library/Telemetry/Events/ShortcutConflictControlClickedEvent.cs b/src/settings-ui/Settings.UI.Library/Telemetry/Events/ShortcutConflictControlClickedEvent.cs new file mode 100644 index 0000000000..3f5b8e9964 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/Telemetry/Events/ShortcutConflictControlClickedEvent.cs @@ -0,0 +1,20 @@ +// 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.Diagnostics.CodeAnalysis; +using System.Diagnostics.Tracing; +using Microsoft.PowerToys.Telemetry; +using Microsoft.PowerToys.Telemetry.Events; + +namespace Microsoft.PowerToys.Settings.UI.Library.Telemetry.Events +{ + [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] + public class ShortcutConflictControlClickedEvent : EventBase, IEvent + { + public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; + + public int ConflictCount { get; set; } + } +} diff --git a/src/settings-ui/Settings.UI.Library/Telemetry/Events/ShortcutConflictDetectedEvent.cs b/src/settings-ui/Settings.UI.Library/Telemetry/Events/ShortcutConflictDetectedEvent.cs new file mode 100644 index 0000000000..b8d7c13497 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/Telemetry/Events/ShortcutConflictDetectedEvent.cs @@ -0,0 +1,20 @@ +// 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.Diagnostics.CodeAnalysis; +using System.Diagnostics.Tracing; +using Microsoft.PowerToys.Telemetry; +using Microsoft.PowerToys.Telemetry.Events; + +namespace Microsoft.PowerToys.Settings.UI.Library.Telemetry.Events +{ + [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] + public class ShortcutConflictDetectedEvent : EventBase, IEvent + { + public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; + + public int ConflictCount { get; set; } + } +} diff --git a/src/settings-ui/Settings.UI.Library/Telemetry/Events/ShortcutConflictResolvedEvent.cs b/src/settings-ui/Settings.UI.Library/Telemetry/Events/ShortcutConflictResolvedEvent.cs new file mode 100644 index 0000000000..7f5bf56e82 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/Telemetry/Events/ShortcutConflictResolvedEvent.cs @@ -0,0 +1,20 @@ +// 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.Diagnostics.CodeAnalysis; +using System.Diagnostics.Tracing; +using Microsoft.PowerToys.Telemetry; +using Microsoft.PowerToys.Telemetry.Events; + +namespace Microsoft.PowerToys.Settings.UI.Library.Telemetry.Events +{ + [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] + public class ShortcutConflictResolvedEvent : EventBase, IEvent + { + public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; + + public string Source { get; set; } + } +} diff --git a/src/settings-ui/Settings.UI.Library/WorkspacesSettings.cs b/src/settings-ui/Settings.UI.Library/WorkspacesSettings.cs index 1e3ce2261e..fafb034935 100644 --- a/src/settings-ui/Settings.UI.Library/WorkspacesSettings.cs +++ b/src/settings-ui/Settings.UI.Library/WorkspacesSettings.cs @@ -3,14 +3,16 @@ // See the LICENSE file in the project root for more information. using System; +using System.Collections.Generic; using System.Text.Json; using System.Text.Json.Serialization; - +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library.Helpers; using Microsoft.PowerToys.Settings.UI.Library.Interfaces; namespace Microsoft.PowerToys.Settings.UI.Library { - public class WorkspacesSettings : BasePTModuleSettings, ISettingsConfig + public class WorkspacesSettings : BasePTModuleSettings, ISettingsConfig, IHotkeyConfig { public const string ModuleName = "Workspaces"; public const string ModuleVersion = "0.0.1"; @@ -39,6 +41,21 @@ namespace Microsoft.PowerToys.Settings.UI.Library return false; } + public ModuleType GetModuleType() => ModuleType.Workspaces; + + public HotkeyAccessor[] GetAllHotkeyAccessors() + { + var hotkeyAccessors = new List + { + new HotkeyAccessor( + () => Properties.Hotkey.Value, + value => Properties.Hotkey.Value = value ?? WorkspacesProperties.DefaultHotkeyValue, + "Workspaces_ActivationShortcut"), + }; + + return hotkeyAccessors.ToArray(); + } + public virtual void Save(ISettingsUtils settingsUtils) { // Save settings to file diff --git a/src/settings-ui/Settings.UI.UnitTests/ViewModelTests/PowerLauncherViewModelTest.cs b/src/settings-ui/Settings.UI.UnitTests/ViewModelTests/PowerLauncherViewModelTest.cs index f1084d498a..59b61559f4 100644 --- a/src/settings-ui/Settings.UI.UnitTests/ViewModelTests/PowerLauncherViewModelTest.cs +++ b/src/settings-ui/Settings.UI.UnitTests/ViewModelTests/PowerLauncherViewModelTest.cs @@ -3,8 +3,8 @@ // See the LICENSE file in the project root for more information. using System; - using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Settings.UI.Library.Interfaces; using Microsoft.PowerToys.Settings.UI.UnitTests.BackwardsCompatibility; using Microsoft.PowerToys.Settings.UI.ViewModels; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -13,7 +13,7 @@ using Moq; namespace ViewModelTests { [TestClass] - public class PowerLauncherViewModelTest + public class PowerLauncherViewModelTest : IDisposable { private sealed class SendCallbackMock { @@ -26,20 +26,48 @@ namespace ViewModelTests { TimesSent++; } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1313:Parameter names should begin with lower-case letter", Justification = "We actually don't validate setting, just calculate it was sent")] + public int OnSendIPC(string _) + { + TimesSent++; + return 0; + } } private PowerLauncherViewModel viewModel; private PowerLauncherSettings mockSettings; private SendCallbackMock sendCallbackMock; + private BackCompatTestProperties.MockSettingsRepository mockGeneralSettingsRepository; [TestInitialize] public void Initialize() { mockSettings = new PowerLauncherSettings(); sendCallbackMock = new SendCallbackMock(); + + var settingPathMock = new Mock(); + var mockGeneralIOProvider = BackCompatTestProperties.GetGeneralSettingsIOProvider("v0.22.0"); + var mockGeneralSettingsUtils = new SettingsUtils(mockGeneralIOProvider.Object, settingPathMock.Object); + mockGeneralSettingsRepository = new BackCompatTestProperties.MockSettingsRepository(mockGeneralSettingsUtils); + viewModel = new PowerLauncherViewModel( mockSettings, - new PowerLauncherViewModel.SendCallback(sendCallbackMock.OnSend)); + mockGeneralSettingsRepository, + sendCallbackMock.OnSendIPC, + () => false); + } + + [TestCleanup] + public void Cleanup() + { + viewModel?.Dispose(); + } + + public void Dispose() + { + viewModel?.Dispose(); + GC.SuppressFinalize(this); } /// @@ -67,7 +95,7 @@ namespace ViewModelTests // Initialise View Model with test Config files Func sendMockIPCConfigMSG = msg => { return 0; }; - PowerLauncherViewModel viewModel = new PowerLauncherViewModel(originalSettings, generalSettingsRepository, sendMockIPCConfigMSG, () => true); + using PowerLauncherViewModel viewModel = new PowerLauncherViewModel(originalSettings, generalSettingsRepository, sendMockIPCConfigMSG, () => true); // Verify that the old settings persisted Assert.AreEqual(originalGeneralSettings.Enabled.PowerLauncher, viewModel.EnablePowerLauncher); diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/MouseCrosshairs.png b/src/settings-ui/Settings.UI/Assets/Settings/Icons/MouseCrosshairs.png index ae940629b0..dea5a249b5 100644 Binary files a/src/settings-ui/Settings.UI/Assets/Settings/Icons/MouseCrosshairs.png and b/src/settings-ui/Settings.UI/Assets/Settings/Icons/MouseCrosshairs.png differ diff --git a/src/settings-ui/Settings.UI/Converters/BoolToConflictTypeConverter.cs b/src/settings-ui/Settings.UI/Converters/BoolToConflictTypeConverter.cs new file mode 100644 index 0000000000..826bfa19dd --- /dev/null +++ b/src/settings-ui/Settings.UI/Converters/BoolToConflictTypeConverter.cs @@ -0,0 +1,27 @@ +// 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 Microsoft.UI.Xaml.Data; + +namespace Microsoft.PowerToys.Settings.UI.Converters +{ + public partial class BoolToConflictTypeConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, string language) + { + if (value is bool isSystemConflict) + { + return isSystemConflict ? "System Conflict" : "In-App Conflict"; + } + + return "Unknown Conflict"; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/settings-ui/Settings.UI/Helpers/HotkeyConflictHelper.cs b/src/settings-ui/Settings.UI/Helpers/HotkeyConflictHelper.cs new file mode 100644 index 0000000000..d7f56fceea --- /dev/null +++ b/src/settings-ui/Settings.UI/Helpers/HotkeyConflictHelper.cs @@ -0,0 +1,73 @@ +// 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.Collections.Generic; +using System.Text.Json.Nodes; +using Microsoft.PowerToys.Settings.UI.Library; + +namespace Microsoft.PowerToys.Settings.UI.Helpers +{ + public class HotkeyConflictHelper + { + public delegate void HotkeyConflictCheckCallback(bool hasConflict, HotkeyConflictResponse conflicts); + + private static readonly Dictionary PendingHotkeyConflictChecks = new Dictionary(); + private static readonly object LockObject = new object(); + + public static void CheckHotkeyConflict(HotkeySettings hotkeySettings, Func ipcMSGCallBackFunc, HotkeyConflictCheckCallback callback) + { + if (hotkeySettings == null || ipcMSGCallBackFunc == null) + { + return; + } + + string requestId = GenerateRequestId(); + + lock (LockObject) + { + PendingHotkeyConflictChecks[requestId] = callback; + } + + var hotkeyObj = new JsonObject + { + ["request_id"] = requestId, + ["win"] = hotkeySettings.Win, + ["ctrl"] = hotkeySettings.Ctrl, + ["shift"] = hotkeySettings.Shift, + ["alt"] = hotkeySettings.Alt, + ["key"] = hotkeySettings.Code, + }; + + var requestObject = new JsonObject + { + ["check_hotkey_conflict"] = hotkeyObj, + }; + + ipcMSGCallBackFunc(requestObject.ToString()); + } + + public static void HandleHotkeyConflictResponse(HotkeyConflictResponse response) + { + if (response.AllConflicts.Count == 0) + { + return; + } + + HotkeyConflictCheckCallback callback = null; + + lock (LockObject) + { + if (PendingHotkeyConflictChecks.TryGetValue(response.RequestId, out callback)) + { + PendingHotkeyConflictChecks.Remove(response.RequestId); + } + } + + callback?.Invoke(response.HasConflict, response); + } + + private static string GenerateRequestId() => Guid.NewGuid().ToString(); + } +} diff --git a/src/settings-ui/Settings.UI/Helpers/HotkeyConflictResponse.cs b/src/settings-ui/Settings.UI/Helpers/HotkeyConflictResponse.cs new file mode 100644 index 0000000000..90803df64c --- /dev/null +++ b/src/settings-ui/Settings.UI/Helpers/HotkeyConflictResponse.cs @@ -0,0 +1,22 @@ +// 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.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.PowerToys.Settings.UI.Library.HotkeyConflicts; + +namespace Microsoft.PowerToys.Settings.UI.Helpers +{ + public class HotkeyConflictResponse + { + public string RequestId { get; set; } + + public bool HasConflict { get; set; } + + public List AllConflicts { get; set; } = new List(); + } +} diff --git a/src/settings-ui/Settings.UI/SerializationContext/SourceGenerationContextContext.cs b/src/settings-ui/Settings.UI/SerializationContext/SourceGenerationContextContext.cs index 8fd948fd86..bd72be5f8c 100644 --- a/src/settings-ui/Settings.UI/SerializationContext/SourceGenerationContextContext.cs +++ b/src/settings-ui/Settings.UI/SerializationContext/SourceGenerationContextContext.cs @@ -13,23 +13,29 @@ using Microsoft.PowerToys.Settings.UI.Library; namespace Microsoft.PowerToys.Settings.UI.SerializationContext; -[JsonSerializable(typeof(WINDOWPLACEMENT))] +[JsonSerializable(typeof(ActionMessage))] [JsonSerializable(typeof(AdvancedPasteSettings))] -[JsonSerializable(typeof(Dictionary>))] [JsonSerializable(typeof(AlwaysOnTopSettings))] [JsonSerializable(typeof(ColorPickerSettings))] [JsonSerializable(typeof(CropAndLockSettings))] +[JsonSerializable(typeof(Dictionary>))] [JsonSerializable(typeof(FileLocksmithSettings))] +[JsonSerializable(typeof(FindMyMouseSettings))] +[JsonSerializable(typeof(IList))] [JsonSerializable(typeof(MeasureToolSettings))] +[JsonSerializable(typeof(MouseHighlighterSettings))] +[JsonSerializable(typeof(MouseJumpSettings))] +[JsonSerializable(typeof(MousePointerCrosshairsSettings))] [JsonSerializable(typeof(MouseWithoutBordersSettings))] [JsonSerializable(typeof(NewPlusSettings))] [JsonSerializable(typeof(PeekSettings))] [JsonSerializable(typeof(PowerLauncherSettings))] [JsonSerializable(typeof(PowerOcrSettings))] +[JsonSerializable(typeof(PowerOcrSettings))] [JsonSerializable(typeof(RegistryPreviewSettings))] +[JsonSerializable(typeof(ShortcutGuideSettings))] +[JsonSerializable(typeof(WINDOWPLACEMENT))] [JsonSerializable(typeof(WorkspacesSettings))] -[JsonSerializable(typeof(IList))] -[JsonSerializable(typeof(ActionMessage))] public sealed partial class SourceGenerationContextContext : JsonSerializerContext { } diff --git a/src/settings-ui/Settings.UI/Services/GlobalHotkeyConflictManager.cs b/src/settings-ui/Settings.UI/Services/GlobalHotkeyConflictManager.cs new file mode 100644 index 0000000000..3971c0589e --- /dev/null +++ b/src/settings-ui/Settings.UI/Services/GlobalHotkeyConflictManager.cs @@ -0,0 +1,121 @@ +// 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.Collections.Generic; +using System.Linq; + +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Settings.UI.Library.HotkeyConflicts; + +namespace Microsoft.PowerToys.Settings.UI.Services +{ + public class GlobalHotkeyConflictManager + { + private readonly Func _sendIPCMessage; + + private static GlobalHotkeyConflictManager _instance; + private AllHotkeyConflictsData _currentConflicts = new AllHotkeyConflictsData(); + + public static GlobalHotkeyConflictManager Instance => _instance; + + public static void Initialize(Func sendIPCMessage) + { + _instance = new GlobalHotkeyConflictManager(sendIPCMessage); + } + + private GlobalHotkeyConflictManager(Func sendIPCMessage) + { + _sendIPCMessage = sendIPCMessage; + + IPCResponseService.AllHotkeyConflictsReceived += OnAllHotkeyConflictsReceived; + } + + public event EventHandler ConflictsUpdated; + + public void RequestAllConflicts() + { + var requestMessage = "{\"get_all_hotkey_conflicts\":{}}"; + _sendIPCMessage?.Invoke(requestMessage); + } + + private void OnAllHotkeyConflictsReceived(object sender, AllHotkeyConflictsEventArgs e) + { + _currentConflicts = e.Conflicts; + ConflictsUpdated?.Invoke(this, e); + } + + public bool HasConflictForHotkey(HotkeySettings hotkey, string moduleName, int hotkeyID) + { + if (hotkey == null) + { + return false; + } + + var allConflictGroups = _currentConflicts.InAppConflicts.Concat(_currentConflicts.SystemConflicts); + + foreach (var group in allConflictGroups) + { + if (IsHotkeyMatch(hotkey, group.Hotkey)) + { + if (!string.IsNullOrEmpty(moduleName) && hotkeyID >= 0) + { + var selfModule = group.Modules.FirstOrDefault(m => + m.ModuleName.Equals(moduleName, StringComparison.OrdinalIgnoreCase) && + m.HotkeyID == hotkeyID); + + if (selfModule != null && group.Modules.Count == 1) + { + return false; + } + } + + return true; + } + } + + return false; + } + + public HotkeyConflictInfo GetConflictInfo(HotkeySettings hotkey) + { + if (hotkey == null) + { + return null; + } + + var allConflictGroups = _currentConflicts.InAppConflicts.Concat(_currentConflicts.SystemConflicts); + + foreach (var group in allConflictGroups) + { + if (IsHotkeyMatch(hotkey, group.Hotkey)) + { + var conflictModules = group.Modules.Where(m => m != null).ToList(); + if (conflictModules.Count != 0) + { + var firstModule = conflictModules.First(); + return new HotkeyConflictInfo + { + IsSystemConflict = group.IsSystemConflict, + ConflictingModuleName = firstModule.ModuleName, + ConflictingHotkeyID = firstModule.HotkeyID, + AllConflictingModules = conflictModules.Select(m => $"{m.ModuleName}:{m.HotkeyID}").ToList(), + }; + } + } + } + + return null; + } + + private bool IsHotkeyMatch(HotkeySettings settings, HotkeyData data) + { + return settings.Win == data.Win && + settings.Ctrl == data.Ctrl && + settings.Shift == data.Shift && + settings.Alt == data.Alt && + settings.Code == data.Key; + } + } +} diff --git a/src/settings-ui/Settings.UI/Services/IPCResponseService.cs b/src/settings-ui/Settings.UI/Services/IPCResponseService.cs new file mode 100644 index 0000000000..ed16b43603 --- /dev/null +++ b/src/settings-ui/Settings.UI/Services/IPCResponseService.cs @@ -0,0 +1,199 @@ +// 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.Collections.Generic; + +using Microsoft.PowerToys.Settings.UI.Helpers; +using Microsoft.PowerToys.Settings.UI.Library.HotkeyConflicts; +using Microsoft.PowerToys.Settings.UI.Views; +using Windows.Data.Json; + +namespace Microsoft.PowerToys.Settings.UI.Services +{ + public class IPCResponseService + { + private static IPCResponseService _instance; + + public static IPCResponseService Instance => _instance ??= new IPCResponseService(); + + public static event EventHandler AllHotkeyConflictsReceived; + + public void RegisterForIPC() + { + ShellPage.ShellHandler?.IPCResponseHandleList.Add(ProcessIPCMessage); + } + + public void UnregisterFromIPC() + { + ShellPage.ShellHandler?.IPCResponseHandleList.Remove(ProcessIPCMessage); + } + + private void ProcessIPCMessage(JsonObject json) + { + try + { + if (json.TryGetValue("response_type", out IJsonValue responseTypeValue) && + responseTypeValue.ValueType == JsonValueType.String) + { + string responseType = responseTypeValue.GetString(); + + if (responseType.Equals("hotkey_conflict_result", StringComparison.Ordinal)) + { + ProcessHotkeyConflictResult(json); + } + else if (responseType.Equals("all_hotkey_conflicts", StringComparison.Ordinal)) + { + ProcessAllHotkeyConflicts(json); + } + } + } + catch (Exception) + { + } + } + + private void ProcessHotkeyConflictResult(JsonObject json) + { + string requestId = string.Empty; + if (json.TryGetValue("request_id", out IJsonValue requestIdValue) && + requestIdValue.ValueType == JsonValueType.String) + { + requestId = requestIdValue.GetString(); + } + + bool hasConflict = false; + if (json.TryGetValue("has_conflict", out IJsonValue hasConflictValue) && + hasConflictValue.ValueType == JsonValueType.Boolean) + { + hasConflict = hasConflictValue.GetBoolean(); + } + + var allConflicts = new List(); + + if (hasConflict) + { + // Parse the all_conflicts array + if (json.TryGetValue("all_conflicts", out IJsonValue allConflictsValue) && + allConflictsValue.ValueType == JsonValueType.Array) + { + var conflictsArray = allConflictsValue.GetArray(); + foreach (var conflictItem in conflictsArray) + { + if (conflictItem.ValueType == JsonValueType.Object) + { + var conflictObj = conflictItem.GetObject(); + + string moduleName = string.Empty; + int hotkeyID = -1; + + if (conflictObj.TryGetValue("module", out IJsonValue moduleValue) && + moduleValue.ValueType == JsonValueType.String) + { + moduleName = moduleValue.GetString(); + } + + if (conflictObj.TryGetValue("hotkeyID", out IJsonValue hotkeyValue) && + hotkeyValue.ValueType == JsonValueType.Number) + { + hotkeyID = (int)hotkeyValue.GetNumber(); + } + + allConflicts.Add(new ModuleHotkeyData + { + ModuleName = moduleName, + HotkeyID = hotkeyID, + }); + } + } + } + } + + var response = new HotkeyConflictResponse + { + RequestId = requestId, + HasConflict = hasConflict, + AllConflicts = allConflicts, + }; + + HotkeyConflictHelper.HandleHotkeyConflictResponse(response); + } + + private void ProcessAllHotkeyConflicts(JsonObject json) + { + var allConflicts = new AllHotkeyConflictsData(); + + if (json.TryGetValue("inAppConflicts", out IJsonValue inAppValue) && + inAppValue.ValueType == JsonValueType.Array) + { + var inAppArray = inAppValue.GetArray(); + foreach (var conflictGroup in inAppArray) + { + var conflictObj = conflictGroup.GetObject(); + var conflictData = ParseConflictGroup(conflictObj, false); + if (conflictData != null) + { + allConflicts.InAppConflicts.Add(conflictData); + } + } + } + + if (json.TryGetValue("sysConflicts", out IJsonValue sysValue) && + sysValue.ValueType == JsonValueType.Array) + { + var sysArray = sysValue.GetArray(); + foreach (var conflictGroup in sysArray) + { + var conflictObj = conflictGroup.GetObject(); + var conflictData = ParseConflictGroup(conflictObj, true); + if (conflictData != null) + { + allConflicts.SystemConflicts.Add(conflictData); + } + } + } + + AllHotkeyConflictsReceived?.Invoke(this, new AllHotkeyConflictsEventArgs(allConflicts)); + } + + private HotkeyConflictGroupData ParseConflictGroup(JsonObject conflictObj, bool isSystemConflict) + { + if (!conflictObj.TryGetValue("hotkey", out var hotkeyValue) || + !conflictObj.TryGetValue("modules", out var modulesValue)) + { + return null; + } + + var hotkeyObj = hotkeyValue.GetObject(); + bool win = hotkeyObj.TryGetValue("win", out var winVal) && winVal.GetBoolean(); + bool ctrl = hotkeyObj.TryGetValue("ctrl", out var ctrlVal) && ctrlVal.GetBoolean(); + bool shift = hotkeyObj.TryGetValue("shift", out var shiftVal) && shiftVal.GetBoolean(); + bool alt = hotkeyObj.TryGetValue("alt", out var altVal) && altVal.GetBoolean(); + int key = hotkeyObj.TryGetValue("key", out var keyVal) ? (int)keyVal.GetNumber() : 0; + + var conflictGroup = new HotkeyConflictGroupData + { + Hotkey = new HotkeyData { Win = win, Ctrl = ctrl, Shift = shift, Alt = alt, Key = key }, + IsSystemConflict = isSystemConflict, + Modules = new List(), + }; + + var modulesArray = modulesValue.GetArray(); + foreach (var module in modulesArray) + { + var moduleObj = module.GetObject(); + string moduleName = moduleObj.TryGetValue("moduleName", out var modNameVal) ? modNameVal.GetString() : string.Empty; + int hotkeyID = moduleObj.TryGetValue("hotkeyID", out var hotkeyIDVal) ? (int)hotkeyIDVal.GetNumber() : -1; + + conflictGroup.Modules.Add(new ModuleHotkeyData + { + ModuleName = moduleName, + HotkeyID = hotkeyID, + }); + } + + return conflictGroup; + } + } +} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/App.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/App.xaml.cs index f3649555fd..d5bd0977e1 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/App.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/App.xaml.cs @@ -232,6 +232,12 @@ namespace Microsoft.PowerToys.Settings.UI }); ipcmanager.Start(); + GlobalHotkeyConflictManager.Initialize(message => + { + ipcmanager.Send(message); + return 0; + }); + if (!ShowOobe && !ShowScoobe && !ShowFlyout) { settingsWindow = new MainWindow(); @@ -320,10 +326,18 @@ namespace Microsoft.PowerToys.Settings.UI WindowHelpers.ForceTopBorder1PixelInsetOnWindows10(WindowNative.GetWindowHandle(settingsWindow)); settingsWindow.Activate(); settingsWindow.NavigateToSection(StartupPage); + + // In DEBUG mode, we might not have IPC set up, so provide a dummy implementation + GlobalHotkeyConflictManager.Initialize(message => + { + // In debug mode, just log or do nothing + System.Diagnostics.Debug.WriteLine($"IPC Message: {message}"); + return 0; + }); #else - /* If we try to run Settings as a standalone app, it will start PowerToys.exe if not running and open Settings again through it in the Dashboard page. */ - Common.UI.SettingsDeepLink.OpenSettings(Common.UI.SettingsDeepLink.SettingsWindow.Dashboard, true); - Exit(); + /* If we try to run Settings as a standalone app, it will start PowerToys.exe if not running and open Settings again through it in the Dashboard page. */ + Common.UI.SettingsDeepLink.OpenSettings(Common.UI.SettingsDeepLink.SettingsWindow.Dashboard, true); + Exit(); #endif } } diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/ShortcutConflictControl.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/ShortcutConflictControl.xaml index ed3e153682..69a7a1084d 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/ShortcutConflictControl.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/ShortcutConflictControl.xaml @@ -8,7 +8,7 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="d"> - + - + \ No newline at end of file diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/ShortcutConflictControl.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/ShortcutConflictControl.xaml.cs index 9b0c0f4574..7195b159e1 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/ShortcutConflictControl.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/ShortcutConflictControl.xaml.cs @@ -4,37 +4,143 @@ using System; using System.Collections.Generic; -using System.IO; +using System.ComponentModel; using System.Linq; -using System.Runtime.InteropServices.WindowsRuntime; +using Microsoft.PowerToys.Settings.UI.Library.HotkeyConflicts; +using Microsoft.PowerToys.Settings.UI.Library.Telemetry.Events; +using Microsoft.PowerToys.Settings.UI.SettingsXAML.Controls.Dashboard; +using Microsoft.PowerToys.Telemetry; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; -using Microsoft.UI.Xaml.Controls.Primitives; -using Microsoft.UI.Xaml.Data; -using Microsoft.UI.Xaml.Input; -using Microsoft.UI.Xaml.Media; -using Microsoft.UI.Xaml.Navigation; -using Windows.Foundation; -using Windows.Foundation.Collections; +using Microsoft.Windows.ApplicationModel.Resources; namespace Microsoft.PowerToys.Settings.UI.Controls { - public sealed partial class ShortcutConflictControl : UserControl + public sealed partial class ShortcutConflictControl : UserControl, INotifyPropertyChanged { + private static readonly ResourceLoader ResourceLoader = Helpers.ResourceLoaderInstance.ResourceLoader; + + private static bool _telemetryEventSent; + + public static readonly DependencyProperty AllHotkeyConflictsDataProperty = + DependencyProperty.Register( + nameof(AllHotkeyConflictsData), + typeof(AllHotkeyConflictsData), + typeof(ShortcutConflictControl), + new PropertyMetadata(null, OnAllHotkeyConflictsDataChanged)); + + public AllHotkeyConflictsData AllHotkeyConflictsData + { + get => (AllHotkeyConflictsData)GetValue(AllHotkeyConflictsDataProperty); + set => SetValue(AllHotkeyConflictsDataProperty, value); + } + + public int ConflictCount + { + get + { + if (AllHotkeyConflictsData == null) + { + return 0; + } + + int count = 0; + if (AllHotkeyConflictsData.InAppConflicts != null) + { + count += AllHotkeyConflictsData.InAppConflicts.Count; + } + + if (AllHotkeyConflictsData.SystemConflicts != null) + { + count += AllHotkeyConflictsData.SystemConflicts.Count; + } + + return count; + } + } + + public string ConflictText + { + get + { + var count = ConflictCount; + return count switch + { + 0 => ResourceLoader.GetString("ShortcutConflictControl_NoConflictsFound"), + 1 => ResourceLoader.GetString("ShortcutConflictControl_SingleConflictFound"), + _ => string.Format( + System.Globalization.CultureInfo.CurrentCulture, + ResourceLoader.GetString("ShortcutConflictControl_MultipleConflictsFound"), + count), + }; + } + } + + public bool HasConflicts => ConflictCount > 0; + + private static void OnAllHotkeyConflictsDataChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is ShortcutConflictControl control) + { + control.UpdateProperties(); + } + } + + public event PropertyChangedEventHandler PropertyChanged; + + private void UpdateProperties() + { + OnPropertyChanged(nameof(ConflictCount)); + OnPropertyChanged(nameof(ConflictText)); + OnPropertyChanged(nameof(HasConflicts)); + + // Update visibility based on conflict count + Visibility = HasConflicts ? Visibility.Visible : Visibility.Collapsed; + + if (!_telemetryEventSent && HasConflicts) + { + // Log telemetry event when conflicts are detected + PowerToysTelemetry.Log.WriteEvent(new ShortcutConflictDetectedEvent() + { + ConflictCount = ConflictCount, + }); + + _telemetryEventSent = true; + } + } + + private void OnPropertyChanged(string propertyName) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + public ShortcutConflictControl() { InitializeComponent(); - GetShortcutConflicts(); - } + DataContext = this; - private void GetShortcutConflicts() - { - // TO DO: Implement the logic to retrieve and display shortcut conflicts. Make sure to Collapse this control if not conflicts are found. + // Initially hide the control if no conflicts + Visibility = HasConflicts ? Visibility.Visible : Visibility.Collapsed; } private void ShortcutConflictBtn_Click(object sender, RoutedEventArgs e) { - // TO DO: Handle the button click event to show the shortcut conflicts window. + if (AllHotkeyConflictsData == null || !HasConflicts) + { + return; + } + + // Log telemetry event when user clicks the shortcut conflict button + PowerToysTelemetry.Log.WriteEvent(new ShortcutConflictControlClickedEvent() + { + ConflictCount = this.ConflictCount, + }); + + // Create and show the new window instead of dialog + var conflictWindow = new ShortcutConflictWindow(); + + // Show the window + conflictWindow.Activate(); } } } diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/ShortcutConflictWindow.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/ShortcutConflictWindow.xaml new file mode 100644 index 0000000000..46f8d4f962 --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/ShortcutConflictWindow.xaml @@ -0,0 +1,176 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/ShortcutConflictWindow.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/ShortcutConflictWindow.xaml.cs new file mode 100644 index 0000000000..5bcc282261 --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/ShortcutConflictWindow.xaml.cs @@ -0,0 +1,91 @@ +// 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 CommunityToolkit.WinUI.Controls; +using Microsoft.PowerToys.Settings.UI.Helpers; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Settings.UI.Library.Helpers; +using Microsoft.PowerToys.Settings.UI.Library.HotkeyConflicts; +using Microsoft.PowerToys.Settings.UI.Services; +using Microsoft.PowerToys.Settings.UI.ViewModels; +using Microsoft.PowerToys.Settings.UI.Views; +using Microsoft.UI; +using Microsoft.UI.Windowing; +using Microsoft.UI.Xaml; +using Windows.Graphics; +using WinUIEx; + +namespace Microsoft.PowerToys.Settings.UI.SettingsXAML.Controls.Dashboard +{ + public sealed partial class ShortcutConflictWindow : WindowEx + { + public ShortcutConflictViewModel DataContext { get; } + + public ShortcutConflictViewModel ViewModel { get; private set; } + + public ShortcutConflictWindow() + { + var settingsUtils = new SettingsUtils(); + ViewModel = new ShortcutConflictViewModel( + settingsUtils, + SettingsRepository.GetInstance(settingsUtils), + ShellPage.SendDefaultIPCMessage); + + DataContext = ViewModel; + InitializeComponent(); + + this.Activated += Window_Activated_SetIcon; + + // Set localized window title + var resourceLoader = ResourceLoaderInstance.ResourceLoader; + this.ExtendsContentIntoTitleBar = true; + + this.Title = resourceLoader.GetString("ShortcutConflictWindow_Title"); + this.CenterOnScreen(); + + ViewModel.OnPageLoaded(); + } + + private void CenterOnScreen() + { + var displayArea = DisplayArea.GetFromWindowId(this.AppWindow.Id, DisplayAreaFallback.Nearest); + if (displayArea != null) + { + var windowSize = this.AppWindow.Size; + var centeredPosition = new PointInt32 + { + X = (displayArea.WorkArea.Width - windowSize.Width) / 2, + Y = (displayArea.WorkArea.Height - windowSize.Height) / 2, + }; + this.AppWindow.Move(centeredPosition); + } + } + + private void SettingsCard_Click(object sender, RoutedEventArgs e) + { + if (sender is SettingsCard settingsCard && + settingsCard.DataContext is ModuleHotkeyData moduleData) + { + var moduleType = moduleData.ModuleType; + NavigationService.Navigate(ModuleHelper.GetModulePageType(moduleType)); + this.Close(); + } + } + + private void WindowEx_Closed(object sender, WindowEventArgs args) + { + ViewModel?.Dispose(); + } + + private void Window_Activated_SetIcon(object sender, WindowActivatedEventArgs args) + { + // Set window icon + var hWnd = WinRT.Interop.WindowNative.GetWindowHandle(this); + WindowId windowId = Win32Interop.GetWindowIdFromWindow(hWnd); + AppWindow appWindow = AppWindow.GetFromWindowId(windowId); + appWindow.SetIcon("Assets\\Settings\\icon.ico"); + } + } +} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/KeyVisual/KeyVisual.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Controls/KeyVisual/KeyVisual.xaml index 9ec7f4a2ec..931286ceaf 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Controls/KeyVisual/KeyVisual.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/KeyVisual/KeyVisual.xaml @@ -188,4 +188,4 @@ - + \ No newline at end of file diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutControl.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutControl.xaml index 72cb4a3c55..d81be4aa6c 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutControl.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutControl.xaml @@ -3,6 +3,7 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls" + xmlns:converters="using:Microsoft.PowerToys.Settings.UI.Converters" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" x:Name="LayoutRoot" @@ -39,6 +40,7 @@ Content="{Binding}" CornerRadius="{StaticResource ControlCornerRadius}" FontWeight="SemiBold" + IsInvalid="{Binding ElementName=LayoutRoot, Path=HasConflict}" IsTabStop="False" Style="{StaticResource AccentKeyVisualStyle}" /> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutControl.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutControl.xaml.cs index c75017300c..5b21743d5f 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutControl.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutControl.xaml.cs @@ -3,18 +3,33 @@ // See the LICENSE file in the project root for more information. using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; using CommunityToolkit.WinUI; using Microsoft.PowerToys.Settings.UI.Helpers; using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Settings.UI.Library.HotkeyConflicts; +using Microsoft.PowerToys.Settings.UI.Library.Telemetry.Events; +using Microsoft.PowerToys.Settings.UI.Services; +using Microsoft.PowerToys.Settings.UI.Views; +using Microsoft.PowerToys.Telemetry; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Automation; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Input; +using Microsoft.UI.Xaml.Media; using Microsoft.Windows.ApplicationModel.Resources; using Windows.System; namespace Microsoft.PowerToys.Settings.UI.Controls { + public enum ShortcutControlSource + { + SettingsPage, + ConflictWindow, + } + public sealed partial class ShortcutControl : UserControl, IDisposable { private readonly UIntPtr ignoreKeyEventFlag = (UIntPtr)0x5555; @@ -33,8 +48,12 @@ namespace Microsoft.PowerToys.Settings.UI.Controls public static readonly DependencyProperty IsActiveProperty = DependencyProperty.Register("Enabled", typeof(bool), typeof(ShortcutControl), null); public static readonly DependencyProperty HotkeySettingsProperty = DependencyProperty.Register("HotkeySettings", typeof(HotkeySettings), typeof(ShortcutControl), null); - public static readonly DependencyProperty AllowDisableProperty = DependencyProperty.Register("AllowDisable", typeof(bool), typeof(ShortcutControl), new PropertyMetadata(false, OnAllowDisableChanged)); + public static readonly DependencyProperty HasConflictProperty = DependencyProperty.Register("HasConflict", typeof(bool), typeof(ShortcutControl), new PropertyMetadata(false, OnHasConflictChanged)); + public static readonly DependencyProperty TooltipProperty = DependencyProperty.Register("Tooltip", typeof(string), typeof(ShortcutControl), new PropertyMetadata(null, OnTooltipChanged)); + + // Dependency property to track the source/context of the ShortcutControl + public static readonly DependencyProperty SourceProperty = DependencyProperty.Register("Source", typeof(ShortcutControlSource), typeof(ShortcutControl), new PropertyMetadata(ShortcutControlSource.SettingsPage)); private static ResourceLoader resourceLoader = Helpers.ResourceLoaderInstance.ResourceLoader; @@ -58,6 +77,69 @@ namespace Microsoft.PowerToys.Settings.UI.Controls description.Text = text; } + private static void OnHasConflictChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var control = d as ShortcutControl; + if (control == null) + { + return; + } + + control.UpdateKeyVisualStyles(); + + // Check if conflict was resolved (had conflict before, no conflict now) + var oldValue = (bool)(e.OldValue ?? false); + var newValue = (bool)(e.NewValue ?? false); + + // General conflict resolution telemetry (for all sources) + if (oldValue && !newValue) + { + // Determine the actual source based on the control's context + var actualSource = DetermineControlSource(control); + + // Conflict was resolved - send general telemetry + PowerToysTelemetry.Log.WriteEvent(new ShortcutConflictResolvedEvent() + { + Source = actualSource.ToString(), + }); + } + } + + private static ShortcutControlSource DetermineControlSource(ShortcutControl control) + { + // Walk up the visual tree to find the parent window/container + DependencyObject parent = control; + while (parent != null) + { + parent = VisualTreeHelper.GetParent(parent); + + // Check if we're in a ShortcutConflictWindow + if (parent != null && parent.GetType().Name == "ShortcutConflictWindow") + { + return ShortcutControlSource.ConflictWindow; + } + + if (parent != null && (parent.GetType().Name == "MainWindow" || parent.GetType().Name == "ShellPage")) + { + return ShortcutControlSource.SettingsPage; + } + } + + // Fallback to the explicitly set value or default + return ShortcutControlSource.ConflictWindow; + } + + private static void OnTooltipChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var control = d as ShortcutControl; + if (control == null) + { + return; + } + + control.UpdateTooltip(); + } + private ShortcutDialogContentControl c = new ShortcutDialogContentControl(); private ContentDialog shortcutDialog; @@ -67,6 +149,24 @@ namespace Microsoft.PowerToys.Settings.UI.Controls set => SetValue(AllowDisableProperty, value); } + public bool HasConflict + { + get => (bool)GetValue(HasConflictProperty); + set => SetValue(HasConflictProperty, value); + } + + public string Tooltip + { + get => (string)GetValue(TooltipProperty); + set => SetValue(TooltipProperty, value); + } + + public ShortcutControlSource Source + { + get => (ShortcutControlSource)GetValue(SourceProperty); + set => SetValue(SourceProperty, value); + } + public bool Enabled { get @@ -101,14 +201,54 @@ namespace Microsoft.PowerToys.Settings.UI.Controls { if (hotkeySettings != value) { + // Unsubscribe from old settings + if (hotkeySettings != null) + { + hotkeySettings.PropertyChanged -= OnHotkeySettingsPropertyChanged; + } + hotkeySettings = value; SetValue(HotkeySettingsProperty, value); + + // Subscribe to new settings + if (hotkeySettings != null) + { + hotkeySettings.PropertyChanged += OnHotkeySettingsPropertyChanged; + + // Update UI based on conflict properties + UpdateConflictStatusFromHotkeySettings(); + } + SetKeys(); - c.Keys = HotkeySettings.GetKeysList(); + c.Keys = HotkeySettings?.GetKeysList(); } } } + private void OnHotkeySettingsPropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(HotkeySettings.HasConflict) || + e.PropertyName == nameof(HotkeySettings.ConflictDescription)) + { + UpdateConflictStatusFromHotkeySettings(); + } + } + + private void UpdateConflictStatusFromHotkeySettings() + { + if (hotkeySettings != null) + { + // Update the ShortcutControl's conflict properties from HotkeySettings + HasConflict = hotkeySettings.HasConflict; + Tooltip = hotkeySettings.HasConflict ? hotkeySettings.ConflictDescription : null; + } + else + { + HasConflict = false; + Tooltip = null; + } + } + public ShortcutControl() { InitializeComponent(); @@ -136,6 +276,29 @@ namespace Microsoft.PowerToys.Settings.UI.Controls OnAllowDisableChanged(this, null); } + private void UpdateKeyVisualStyles() + { + if (PreviewKeysControl?.ItemsSource != null) + { + // Force refresh of the ItemsControl to update KeyVisual styles + var items = PreviewKeysControl.ItemsSource; + PreviewKeysControl.ItemsSource = null; + PreviewKeysControl.ItemsSource = items; + } + } + + private void UpdateTooltip() + { + if (!string.IsNullOrEmpty(Tooltip)) + { + ToolTipService.SetToolTip(EditButton, Tooltip); + } + else + { + ToolTipService.SetToolTip(EditButton, null); + } + } + private void ShortcutControl_Unloaded(object sender, RoutedEventArgs e) { shortcutDialog.PrimaryButtonClick -= ShortcutDialog_PrimaryButtonClick; @@ -147,6 +310,12 @@ namespace Microsoft.PowerToys.Settings.UI.Controls App.GetSettingsWindow().Activated -= ShortcutDialog_SettingsWindow_Activated; } + // Unsubscribe from HotkeySettings property changes + if (hotkeySettings != null) + { + hotkeySettings.PropertyChanged -= OnHotkeySettingsPropertyChanged; + } + // Dispose the HotkeySettingsControlHook object to terminate the hook threads when the textbox is unloaded hook?.Dispose(); @@ -168,6 +337,9 @@ namespace Microsoft.PowerToys.Settings.UI.Controls { App.GetSettingsWindow().Activated += ShortcutDialog_SettingsWindow_Activated; } + + // Initialize tooltip when loaded + UpdateTooltip(); } private void KeyEventHandler(int key, bool matchValue, int matchValueCode) @@ -302,6 +474,8 @@ namespace Microsoft.PowerToys.Settings.UI.Controls KeyEventHandler(key, true, key); c.Keys = internalSettings.GetKeysList(); + c.ConflictMessage = string.Empty; + c.HasConflict = false; if (internalSettings.GetKeysList().Count == 0) { @@ -336,12 +510,74 @@ namespace Microsoft.PowerToys.Settings.UI.Controls else { EnableKeys(); + if (lastValidSettings.IsValid()) + { + if (string.Equals(lastValidSettings.ToString(), hotkeySettings.ToString(), StringComparison.OrdinalIgnoreCase)) + { + c.HasConflict = hotkeySettings.HasConflict; + c.ConflictMessage = hotkeySettings.ConflictDescription; + } + else + { + // Check for conflicts with the new hotkey settings + CheckForConflicts(lastValidSettings); + } + } } } c.IsWarningAltGr = internalSettings.Ctrl && internalSettings.Alt && !internalSettings.Win && (internalSettings.Code > 0); } + private void CheckForConflicts(HotkeySettings settings) + { + void UpdateUIForConflict(bool hasConflict, HotkeyConflictResponse hotkeyConflictResponse) + { + DispatcherQueue.TryEnqueue(() => + { + if (hasConflict) + { + // Build conflict message from all conflicts - only show module names + var conflictingModules = new HashSet(); + + foreach (var conflict in hotkeyConflictResponse.AllConflicts) + { + if (!string.IsNullOrEmpty(conflict.ModuleName)) + { + conflictingModules.Add(conflict.ModuleName); + } + } + + if (conflictingModules.Count > 0) + { + var moduleNames = conflictingModules.ToArray(); + var conflictMessage = moduleNames.Length == 1 + ? $"Conflict detected with {moduleNames[0]}" + : $"Conflicts detected with: {string.Join(", ", moduleNames)}"; + + c.ConflictMessage = conflictMessage; + } + else + { + c.ConflictMessage = "Conflict detected with unknown module"; + } + + c.HasConflict = true; + } + else + { + c.ConflictMessage = string.Empty; + c.HasConflict = false; + } + }); + } + + HotkeyConflictHelper.CheckHotkeyConflict( + settings, + ShellPage.SendDefaultIPCMessage, + UpdateUIForConflict); + } + private void EnableKeys() { shortcutDialog.IsPrimaryButtonEnabled = true; @@ -416,6 +652,9 @@ namespace Microsoft.PowerToys.Settings.UI.Controls c.Keys = null; c.Keys = HotkeySettings.GetKeysList(); + c.HasConflict = hotkeySettings.HasConflict; + c.ConflictMessage = hotkeySettings.ConflictDescription; + // 92 means the Win key. The logic is: warning should be visible if the shortcut contains Alt AND contains Ctrl AND NOT contains Win. // Additional key must be present, as this is a valid, previously used shortcut shown at dialog open. Check for presence of non-modifier-key is not necessary therefore c.IsWarningAltGr = c.Keys.Contains("Ctrl") && c.Keys.Contains("Alt") && !c.Keys.Contains(92); @@ -434,16 +673,32 @@ namespace Microsoft.PowerToys.Settings.UI.Controls lastValidSettings = hotkeySettings; shortcutDialog.Hide(); + + // Send RequestAllConflicts IPC to update the UI after changed hotkey settings. + GlobalHotkeyConflictManager.Instance?.RequestAllConflicts(); } private void ShortcutDialog_PrimaryButtonClick(ContentDialog sender, ContentDialogButtonClickEventArgs args) { if (ComboIsValid(lastValidSettings)) { - HotkeySettings = lastValidSettings with { }; + if (c.HasConflict) + { + lastValidSettings = lastValidSettings with { HasConflict = true }; + } + else + { + lastValidSettings = lastValidSettings with { HasConflict = false }; + } + + HotkeySettings = lastValidSettings; } SetKeys(); + + // Send RequestAllConflicts IPC to update the UI after changed hotkey settings. + GlobalHotkeyConflictManager.Instance?.RequestAllConflicts(); + shortcutDialog.Hide(); } @@ -520,7 +775,7 @@ namespace Microsoft.PowerToys.Settings.UI.Controls private void SetKeys() { - var keys = HotkeySettings.GetKeysList(); + var keys = HotkeySettings?.GetKeysList(); if (keys != null && keys.Count > 0) { diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutDialogContentControl.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutDialogContentControl.xaml index da982289e7..13033344ab 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutDialogContentControl.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutDialogContentControl.xaml @@ -63,6 +63,13 @@ IsOpen="{Binding ElementName=ShortcutContentControl, Path=IsWarningAltGr, Mode=OneWay}" IsTabStop="{Binding ElementName=ShortcutContentControl, Path=IsWarningAltGr, Mode=OneWay}" Severity="Warning" /> + - + \ No newline at end of file diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutDialogContentControl.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutDialogContentControl.xaml.cs index 5d44f7c451..8907f12415 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutDialogContentControl.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutDialogContentControl.xaml.cs @@ -11,6 +11,24 @@ namespace Microsoft.PowerToys.Settings.UI.Controls { public sealed partial class ShortcutDialogContentControl : UserControl { + public static readonly DependencyProperty KeysProperty = DependencyProperty.Register("Keys", typeof(List), typeof(ShortcutDialogContentControl), new PropertyMetadata(default(string))); + public static readonly DependencyProperty IsErrorProperty = DependencyProperty.Register("IsError", typeof(bool), typeof(ShortcutDialogContentControl), new PropertyMetadata(false)); + public static readonly DependencyProperty IsWarningAltGrProperty = DependencyProperty.Register("IsWarningAltGr", typeof(bool), typeof(ShortcutDialogContentControl), new PropertyMetadata(false)); + public static readonly DependencyProperty HasConflictProperty = DependencyProperty.Register("HasConflict", typeof(bool), typeof(ShortcutDialogContentControl), new PropertyMetadata(false)); + public static readonly DependencyProperty ConflictMessageProperty = DependencyProperty.Register("ConflictMessage", typeof(string), typeof(ShortcutDialogContentControl), new PropertyMetadata(string.Empty)); + + public bool HasConflict + { + get => (bool)GetValue(HasConflictProperty); + set => SetValue(HasConflictProperty, value); + } + + public string ConflictMessage + { + get => (string)GetValue(ConflictMessageProperty); + set => SetValue(ConflictMessageProperty, value); + } + public ShortcutDialogContentControl() { this.InitializeComponent(); @@ -22,22 +40,16 @@ namespace Microsoft.PowerToys.Settings.UI.Controls set { SetValue(KeysProperty, value); } } - public static readonly DependencyProperty KeysProperty = DependencyProperty.Register("Keys", typeof(List), typeof(SettingsPageControl), new PropertyMetadata(default(string))); - public bool IsError { get => (bool)GetValue(IsErrorProperty); set => SetValue(IsErrorProperty, value); } - public static readonly DependencyProperty IsErrorProperty = DependencyProperty.Register("IsError", typeof(bool), typeof(ShortcutDialogContentControl), new PropertyMetadata(false)); - public bool IsWarningAltGr { get => (bool)GetValue(IsWarningAltGrProperty); set => SetValue(IsWarningAltGrProperty, value); } - - public static readonly DependencyProperty IsWarningAltGrProperty = DependencyProperty.Register("IsWarningAltGr", typeof(bool), typeof(ShortcutDialogContentControl), new PropertyMetadata(false)); } } diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutWithTextLabelControl.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutWithTextLabelControl.xaml index 78d95a4c3b..ea3be0bff8 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutWithTextLabelControl.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutWithTextLabelControl.xaml @@ -16,6 +16,7 @@ + IsTabStop="False" + Style="{StaticResource DefaultKeyVisualStyle}" /> + + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutWithTextLabelControl.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutWithTextLabelControl.xaml.cs index ed18669eba..c3829e3984 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutWithTextLabelControl.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutWithTextLabelControl.xaml.cs @@ -17,7 +17,7 @@ namespace Microsoft.PowerToys.Settings.UI.Controls set { SetValue(TextProperty, value); } } - public static readonly DependencyProperty TextProperty = DependencyProperty.Register("Text", typeof(string), typeof(ShortcutWithTextLabelControl), new PropertyMetadata(default(string))); + public static readonly DependencyProperty TextProperty = DependencyProperty.Register(nameof(Text), typeof(string), typeof(ShortcutWithTextLabelControl), new PropertyMetadata(default(string))); public List Keys { @@ -25,11 +25,40 @@ namespace Microsoft.PowerToys.Settings.UI.Controls set { SetValue(KeysProperty, value); } } - public static readonly DependencyProperty KeysProperty = DependencyProperty.Register("Keys", typeof(List), typeof(ShortcutWithTextLabelControl), new PropertyMetadata(default(string))); + public static readonly DependencyProperty KeysProperty = DependencyProperty.Register(nameof(Keys), typeof(List), typeof(ShortcutWithTextLabelControl), new PropertyMetadata(default(string))); + + public LabelPlacement LabelPlacement + { + get { return (LabelPlacement)GetValue(LabelPlacementProperty); } + set { SetValue(LabelPlacementProperty, value); } + } + + public static readonly DependencyProperty LabelPlacementProperty = DependencyProperty.Register(nameof(LabelPlacement), typeof(LabelPlacement), typeof(ShortcutWithTextLabelControl), new PropertyMetadata(defaultValue: LabelPlacement.After, OnIsLabelPlacementChanged)); public ShortcutWithTextLabelControl() { this.InitializeComponent(); } + + private static void OnIsLabelPlacementChanged(DependencyObject d, DependencyPropertyChangedEventArgs newValue) + { + if (d is ShortcutWithTextLabelControl labelControl) + { + if (labelControl.LabelPlacement == LabelPlacement.Before) + { + VisualStateManager.GoToState(labelControl, "LabelBefore", true); + } + else + { + VisualStateManager.GoToState(labelControl, "LabelAfter", true); + } + } + } + } + + public enum LabelPlacement + { + Before, + After, } } diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeOverview.xaml b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeOverview.xaml index b04c800bca..20815cd81c 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeOverview.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeOverview.xaml @@ -20,33 +20,56 @@ - + + Margin="0,24,0,0" + Style="{StaticResource BodyStrongTextBlockStyle}" /> + + + + + + + - - - + + + + + + + + + - - - - - - - + + + + + + + - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + \ No newline at end of file diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeWhatsNew.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeWhatsNew.xaml.cs index 1b2524eee8..15fcea6452 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeWhatsNew.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeWhatsNew.xaml.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; +using System.ComponentModel; using System.Diagnostics; using System.Linq; using System.Net; @@ -17,9 +18,11 @@ using CommunityToolkit.WinUI.UI.Controls; using global::PowerToys.GPOWrapper; using ManagedCommon; using Microsoft.PowerToys.Settings.UI.Helpers; +using Microsoft.PowerToys.Settings.UI.Library.HotkeyConflicts; using Microsoft.PowerToys.Settings.UI.OOBE.Enums; using Microsoft.PowerToys.Settings.UI.OOBE.ViewModel; using Microsoft.PowerToys.Settings.UI.SerializationContext; +using Microsoft.PowerToys.Settings.UI.Services; using Microsoft.PowerToys.Settings.UI.Views; using Microsoft.PowerToys.Telemetry; using Microsoft.UI.Xaml.Controls; @@ -27,12 +30,54 @@ using Microsoft.UI.Xaml.Navigation; namespace Microsoft.PowerToys.Settings.UI.OOBE.Views { - public sealed partial class OobeWhatsNew : Page + public sealed partial class OobeWhatsNew : Page, INotifyPropertyChanged { public OobePowerToysModule ViewModel { get; set; } + private AllHotkeyConflictsData _allHotkeyConflictsData = new AllHotkeyConflictsData(); + public bool ShowDataDiagnosticsInfoBar => GetShowDataDiagnosticsInfoBar(); + public AllHotkeyConflictsData AllHotkeyConflictsData + { + get => _allHotkeyConflictsData; + set + { + if (_allHotkeyConflictsData != value) + { + _allHotkeyConflictsData = value; + OnPropertyChanged(nameof(AllHotkeyConflictsData)); + OnPropertyChanged(nameof(HasConflicts)); + } + } + } + + public bool HasConflicts + { + get + { + if (AllHotkeyConflictsData == null) + { + return false; + } + + int count = 0; + if (AllHotkeyConflictsData.InAppConflicts != null) + { + count += AllHotkeyConflictsData.InAppConflicts.Count; + } + + if (AllHotkeyConflictsData.SystemConflicts != null) + { + count += AllHotkeyConflictsData.SystemConflicts.Count; + } + + return count > 0; + } + } + + public event PropertyChangedEventHandler PropertyChanged; + /// /// Initializes a new instance of the class. /// @@ -40,7 +85,27 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views { this.InitializeComponent(); ViewModel = new OobePowerToysModule(OobeShellPage.OobeShellHandler.Modules[(int)PowerToysModules.WhatsNew]); - DataContext = ViewModel; + DataContext = this; + + // Subscribe to hotkey conflict updates + if (GlobalHotkeyConflictManager.Instance != null) + { + GlobalHotkeyConflictManager.Instance.ConflictsUpdated += OnConflictsUpdated; + GlobalHotkeyConflictManager.Instance.RequestAllConflicts(); + } + } + + private void OnConflictsUpdated(object sender, AllHotkeyConflictsEventArgs e) + { + this.DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Normal, () => + { + AllHotkeyConflictsData = e.Conflicts ?? new AllHotkeyConflictsData(); + }); + } + + private void OnPropertyChanged(string propertyName) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } private bool GetShowDataDiagnosticsInfoBar() @@ -184,6 +249,12 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views protected override void OnNavigatedFrom(NavigationEventArgs e) { ViewModel.LogClosingModuleEvent(); + + // Unsubscribe from conflict updates when leaving the page + if (GlobalHotkeyConflictManager.Instance != null) + { + GlobalHotkeyConflictManager.Instance.ConflictsUpdated -= OnConflictsUpdated; + } } private void ReleaseNotesMarkdown_LinkClicked(object sender, LinkClickedEventArgs e) diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OobeWindow.xaml b/src/settings-ui/Settings.UI/SettingsXAML/OobeWindow.xaml index 4a5f4233de..f277350fbc 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OobeWindow.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/OobeWindow.xaml @@ -14,4 +14,4 @@ - + \ No newline at end of file diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPaste.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPaste.xaml.cs index a395ac767b..8442262688 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPaste.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPaste.xaml.cs @@ -31,6 +31,8 @@ namespace Microsoft.PowerToys.Settings.UI.Views ShellPage.SendDefaultIPCMessage); DataContext = ViewModel; InitializeComponent(); + + Loaded += (s, e) => ViewModel.OnPageLoaded(); } public void RefreshEnabledState() diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/AlwaysOnTopPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/AlwaysOnTopPage.xaml.cs index b38fffc59e..2e22da3120 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/AlwaysOnTopPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/AlwaysOnTopPage.xaml.cs @@ -19,6 +19,8 @@ namespace Microsoft.PowerToys.Settings.UI.Views ViewModel = new AlwaysOnTopViewModel(settingsUtils, SettingsRepository.GetInstance(settingsUtils), SettingsRepository.GetInstance(settingsUtils), ShellPage.SendDefaultIPCMessage); DataContext = ViewModel; InitializeComponent(); + + Loaded += (s, e) => ViewModel.OnPageLoaded(); } public void RefreshEnabledState() diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/CmdPalPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/CmdPalPage.xaml.cs index a9a016b80e..fb3a97e309 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/CmdPalPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/CmdPalPage.xaml.cs @@ -26,6 +26,7 @@ namespace Microsoft.PowerToys.Settings.UI.Views ShellPage.SendDefaultIPCMessage, DispatcherQueue); DataContext = ViewModel; + Loaded += (s, e) => ViewModel.OnPageLoaded(); InitializeComponent(); } diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/ColorPickerPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/ColorPickerPage.xaml.cs index 37e6ffd47c..ce0f723633 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/ColorPickerPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/ColorPickerPage.xaml.cs @@ -35,6 +35,8 @@ namespace Microsoft.PowerToys.Settings.UI.Views ShellPage.SendDefaultIPCMessage); DataContext = ViewModel; InitializeComponent(); + + Loaded += (s, e) => ViewModel.OnPageLoaded(); } /// diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/CropAndLockPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/CropAndLockPage.xaml.cs index 66e3652da8..d769650dd1 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/CropAndLockPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/CropAndLockPage.xaml.cs @@ -19,6 +19,8 @@ namespace Microsoft.PowerToys.Settings.UI.Views ViewModel = new CropAndLockViewModel(settingsUtils, SettingsRepository.GetInstance(settingsUtils), SettingsRepository.GetInstance(settingsUtils), ShellPage.SendDefaultIPCMessage); DataContext = ViewModel; InitializeComponent(); + + Loaded += (s, e) => ViewModel.OnPageLoaded(); } public void RefreshEnabledState() diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/DashboardPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/DashboardPage.xaml index 80adc56c0b..e5b800cda1 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/DashboardPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/DashboardPage.xaml @@ -133,7 +133,7 @@ Grid.Column="1" Orientation="Horizontal" Spacing="16"> - + diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/DashboardPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/DashboardPage.xaml.cs index 2d6cf95bae..bf792e2b75 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/DashboardPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/DashboardPage.xaml.cs @@ -39,6 +39,8 @@ namespace Microsoft.PowerToys.Settings.UI.Views ViewModel = new DashboardViewModel( SettingsRepository.GetInstance(settingsUtils), ShellPage.SendDefaultIPCMessage); DataContext = ViewModel; + + Loaded += (s, e) => ViewModel.OnPageLoaded(); } public void RefreshEnabledState() diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/FancyZonesPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/FancyZonesPage.xaml.cs index c224c42683..61865c89fa 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/FancyZonesPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/FancyZonesPage.xaml.cs @@ -19,6 +19,7 @@ namespace Microsoft.PowerToys.Settings.UI.Views var settingsUtils = new SettingsUtils(); ViewModel = new FancyZonesViewModel(settingsUtils, SettingsRepository.GetInstance(settingsUtils), SettingsRepository.GetInstance(settingsUtils), ShellPage.SendDefaultIPCMessage); DataContext = ViewModel; + Loaded += (s, e) => ViewModel.OnPageLoaded(); } private void OpenColorsSettings_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/GeneralPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/GeneralPage.xaml index da5bcd7e0c..1a3c640ddf 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/GeneralPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/GeneralPage.xaml @@ -267,7 +267,10 @@ - + ViewModel.OnPageLoaded(); } public void RefreshEnabledState() diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/MouseUtilsPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/MouseUtilsPage.xaml index 0ba74ca164..01e9f8e740 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/MouseUtilsPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/MouseUtilsPage.xaml @@ -363,6 +363,27 @@ + + + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/MouseUtilsPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/MouseUtilsPage.xaml.cs index 2a0cfa536f..ab3e8192ac 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/MouseUtilsPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/MouseUtilsPage.xaml.cs @@ -48,6 +48,8 @@ namespace Microsoft.PowerToys.Settings.UI.Views InitializeComponent(); this.MouseUtils_MouseJump_Panel.ViewModel = ViewModel; + + Loaded += (s, e) => ViewModel.OnPageLoaded(); } public void RefreshEnabledState() diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/MouseWithoutBordersPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/MouseWithoutBordersPage.xaml.cs index f29056245f..a2e16ea987 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/MouseWithoutBordersPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/MouseWithoutBordersPage.xaml.cs @@ -47,6 +47,8 @@ namespace Microsoft.PowerToys.Settings.UI.Views DataContext = ViewModel; InitializeComponent(); + + Loaded += (s, e) => ViewModel.OnPageLoaded(); } private void OnConfigFileUpdate() diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/PeekPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/PeekPage.xaml.cs index 24ca93208a..91adfa9a2e 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/PeekPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/PeekPage.xaml.cs @@ -23,6 +23,7 @@ namespace Microsoft.PowerToys.Settings.UI.Views DispatcherQueue); DataContext = ViewModel; InitializeComponent(); + Loaded += (s, e) => ViewModel.OnPageLoaded(); } public void RefreshEnabledState() diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerLauncherPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerLauncherPage.xaml.cs index f02327caa8..d8adcdc5a4 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerLauncherPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerLauncherPage.xaml.cs @@ -40,6 +40,7 @@ namespace Microsoft.PowerToys.Settings.UI.Views PowerLauncherSettings settings = SettingsRepository.GetInstance(settingsUtils)?.SettingsConfig; ViewModel = new PowerLauncherViewModel(settings, SettingsRepository.GetInstance(settingsUtils), SendDefaultIPCMessageTimed, App.IsDarkTheme); DataContext = ViewModel; + _ = Helper.GetFileWatcher(PowerLauncherSettings.ModuleName, "settings.json", () => { if (Environment.TickCount < _lastIPCMessageSentTick + 500) @@ -79,6 +80,8 @@ namespace Microsoft.PowerToys.Settings.UI.Views searchTypePreferencesOptions.Add(Tuple.Create(loader.GetString("PowerLauncher_SearchTypePreference_ApplicationName"), "application_name")); searchTypePreferencesOptions.Add(Tuple.Create(loader.GetString("PowerLauncher_SearchTypePreference_StringInApplication"), "string_in_application")); searchTypePreferencesOptions.Add(Tuple.Create(loader.GetString("PowerLauncher_SearchTypePreference_ExecutableName"), "executable_name")); + + Loaded += (s, e) => ViewModel.OnPageLoaded(); } private void OpenColorsSettings_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerOcrPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerOcrPage.xaml.cs index 07b999fce0..7acd547abe 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerOcrPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerOcrPage.xaml.cs @@ -23,6 +23,7 @@ namespace Microsoft.PowerToys.Settings.UI.Views ShellPage.SendDefaultIPCMessage); DataContext = ViewModel; InitializeComponent(); + Loaded += (s, e) => ViewModel.OnPageLoaded(); } private void TextExtractor_ComboBox_Loaded(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/ShellPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/ShellPage.xaml index d1a4c8ea8b..0fc0f7c6a5 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/ShellPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/ShellPage.xaml @@ -1,4 +1,4 @@ - - - - - - + - - - + @@ -309,5 +289,12 @@ + diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/ShellPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/ShellPage.xaml.cs index 4ed3faff9a..57abe04119 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/ShellPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/ShellPage.xaml.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation +// 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. @@ -7,6 +7,7 @@ using System.Collections.Generic; using System.Linq; using ManagedCommon; using Microsoft.PowerToys.Settings.UI.Helpers; +using Microsoft.PowerToys.Settings.UI.Library; using Microsoft.PowerToys.Settings.UI.Services; using Microsoft.PowerToys.Settings.UI.ViewModels; using Microsoft.UI.Windowing; @@ -113,7 +114,7 @@ namespace Microsoft.PowerToys.Settings.UI.Views /// /// Gets view model. /// - public ShellViewModel ViewModel { get; } = new ShellViewModel(); + public ShellViewModel ViewModel { get; } /// /// Gets a collection of functions that handle IPC responses. @@ -134,6 +135,8 @@ namespace Microsoft.PowerToys.Settings.UI.Views { InitializeComponent(); + var settingsUtils = new SettingsUtils(); + ViewModel = new ShellViewModel(SettingsRepository.GetInstance(settingsUtils)); DataContext = ViewModel; ShellHandler = this; ViewModel.Initialize(shellFrame, navigationView, KeyboardAccelerators); @@ -141,6 +144,7 @@ namespace Microsoft.PowerToys.Settings.UI.Views // NL moved navigation to general page to the moment when the window is first activated (to not make flyout window disappear) // shellFrame.Navigate(typeof(GeneralPage)); IPCResponseHandleList.Add(ReceiveMessage); + Services.IPCResponseService.Instance.RegisterForIPC(); SetTitleBar(); if (_navViewParentLookup.Count > 0) @@ -460,17 +464,22 @@ namespace Microsoft.PowerToys.Settings.UI.Views navigationView.IsPaneOpen = !navigationView.IsPaneOpen; } - private void ExitPTItem_Tapped(object sender, RoutedEventArgs e) + private async void Close_Tapped(object sender, Microsoft.UI.Xaml.Input.TappedRoutedEventArgs e) + { + await CloseDialog.ShowAsync(); + } + + private void CloseDialog_Click(ContentDialog sender, ContentDialogButtonClickEventArgs args) { const string ptTrayIconWindowClass = "PToyTrayIconWindow"; // Defined in runner/tray_icon.h - const nuint ID_EXIT_MENU_COMMAND = 40001; // Generated resource from runner/runner.base.rc + const nuint ID_CLOSE_MENU_COMMAND = 40001; // Generated resource from runner/runner.base.rc // Exit the XAML application Application.Current.Exit(); // Invoke the exit command from the tray icon IntPtr hWnd = NativeMethods.FindWindow(ptTrayIconWindowClass, ptTrayIconWindowClass); - NativeMethods.SendMessage(hWnd, NativeMethods.WM_COMMAND, ID_EXIT_MENU_COMMAND, 0); + NativeMethods.SendMessage(hWnd, NativeMethods.WM_COMMAND, ID_CLOSE_MENU_COMMAND, 0); } } } diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/ShortcutGuidePage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/ShortcutGuidePage.xaml.cs index 750007595a..21b72f10ff 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/ShortcutGuidePage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/ShortcutGuidePage.xaml.cs @@ -20,6 +20,8 @@ namespace Microsoft.PowerToys.Settings.UI.Views var settingsUtils = new SettingsUtils(); ViewModel = new ShortcutGuideViewModel(settingsUtils, SettingsRepository.GetInstance(settingsUtils), SettingsRepository.GetInstance(settingsUtils), ShellPage.SendDefaultIPCMessage); DataContext = ViewModel; + + Loaded += (s, e) => ViewModel.OnPageLoaded(); } private void OpenColorsSettings_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/WorkspacesPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/WorkspacesPage.xaml.cs index 52814104c7..1c3905a406 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/WorkspacesPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/WorkspacesPage.xaml.cs @@ -19,6 +19,7 @@ namespace Microsoft.PowerToys.Settings.UI.Views ViewModel = new WorkspacesViewModel(settingsUtils, SettingsRepository.GetInstance(settingsUtils), SettingsRepository.GetInstance(settingsUtils), ShellPage.SendDefaultIPCMessage); DataContext = ViewModel; InitializeComponent(); + Loaded += (s, e) => ViewModel.OnPageLoaded(); } public void RefreshEnabledState() diff --git a/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw b/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw index c31076bb83..8834d22600 100644 --- a/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw +++ b/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw @@ -1,4 +1,4 @@ - +