From 917da2e07eca1ce9ade9cfc694cc0623d362c275 Mon Sep 17 00:00:00 2001 From: rluengen Date: Tue, 19 Aug 2025 13:53:41 -0700 Subject: [PATCH 1/7] Remove all explicit dependencies from the toolkit and extensions api on WinAppSDK (#41261) This pull request removes the dependencies from the toolkit and the SDK on WinAppSDK and WebView2. This allows clients of these APIs to have their own version dependencies. ## PR Checklist - [ X] Closes: #41235 - [ X] **Communication:** I've discussed this with core contributors already. If the work hasn't been agreed, this work might be rejected - [ X] **Tests:** Added/updated and all pass - [ X] **Localization:** All end-user-facing strings can be localized - [ X] **Dev docs:** Added/updated - [ X] **New binaries:** Added on the required places - [ X] [JSON for signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json) for new binaries - [ X] [WXS for installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs) for new binaries and localization folder - [ X] [YML for CI pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml) for new test projects - [ X] [YML for signed pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml) - [ X] **Documentation updated:** If checked, please file a pull request on [our docs repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys) and link it here: #xxx Co-authored-by: Ross Luengen --- .../Pages/ProfilesListPage.cs | 23 ------------------- .../TerminalPackage.cs | 20 ---------------- ...t.CommandPalette.Extensions.Toolkit.csproj | 2 -- ...icrosoft.CommandPalette.Extensions.vcxproj | 2 +- .../packages.config | 2 -- 5 files changed, 1 insertion(+), 48 deletions(-) diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Pages/ProfilesListPage.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Pages/ProfilesListPage.cs index b426e96914..7e9d2cec31 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Pages/ProfilesListPage.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Pages/ProfilesListPage.cs @@ -8,7 +8,6 @@ using Microsoft.CmdPal.Ext.WindowsTerminal.Helpers; using Microsoft.CmdPal.Ext.WindowsTerminal.Properties; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; -using Microsoft.UI.Xaml.Media.Imaging; namespace Microsoft.CmdPal.Ext.WindowsTerminal.Pages; @@ -16,7 +15,6 @@ internal sealed partial class ProfilesListPage : ListPage { private readonly TerminalQuery _terminalQuery = new(); private readonly SettingsManager _terminalSettings; - private readonly Dictionary _logoCache = []; private bool showHiddenProfiles; private bool openNewTab; @@ -54,14 +52,6 @@ internal sealed partial class ProfilesListPage : ListPage MoreCommands = [ new CommandContextItem(new LaunchProfileAsAdminCommand(profile.Terminal.AppUserModelId, profile.Name, openNewTab, openQuake)), ], - - // Icon = () => GetLogo(profile.Terminal), - // Action = _ => - // { - // Launch(profile.Terminal.AppUserModelId, profile.Name); - // return true; - // }, - // ContextData = profile, #pragma warning restore SA1108 }); } @@ -70,17 +60,4 @@ internal sealed partial class ProfilesListPage : ListPage } public override IListItem[] GetItems() => Query().ToArray(); - - private BitmapImage GetLogo(TerminalPackage terminal) - { - var aumid = terminal.AppUserModelId; - - if (!_logoCache.TryGetValue(aumid, out var value)) - { - value = terminal.GetLogo(); - _logoCache.Add(aumid, value); - } - - return value; - } } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/TerminalPackage.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/TerminalPackage.cs index 1b2cec7e3d..3301028da1 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/TerminalPackage.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/TerminalPackage.cs @@ -5,7 +5,6 @@ using System; using System.IO; using ManagedCommon; -using Microsoft.UI.Xaml.Media.Imaging; // using Wox.Infrastructure.Image; namespace Microsoft.CmdPal.Ext.WindowsTerminal; @@ -30,23 +29,4 @@ public class TerminalPackage SettingsPath = settingsPath; LogoPath = logoPath; } - - public BitmapImage GetLogo() - { - var image = new BitmapImage(); - - if (File.Exists(LogoPath)) - { - using var fileStream = File.OpenRead(LogoPath); - image.SetSource(fileStream.AsRandomAccessStream()); - } - else - { - // Not using wox anymore, TODO: find the right new way to handle this - // image.UriSource = new Uri(ImageLoader.ErrorIconPath); - Logger.LogError($"Logo file not found: {LogoPath}"); - } - - return image; - } } diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Microsoft.CommandPalette.Extensions.Toolkit.csproj b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Microsoft.CommandPalette.Extensions.Toolkit.csproj index 6217cd25b6..f5f8f2ccbc 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Microsoft.CommandPalette.Extensions.Toolkit.csproj +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Microsoft.CommandPalette.Extensions.Toolkit.csproj @@ -41,8 +41,6 @@ all runtime; build; native; contentfiles; analyzers - - diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions/Microsoft.CommandPalette.Extensions.vcxproj b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions/Microsoft.CommandPalette.Extensions.vcxproj index e620524c77..983c1594a4 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions/Microsoft.CommandPalette.Extensions.vcxproj +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions/Microsoft.CommandPalette.Extensions.vcxproj @@ -180,4 +180,4 @@ - + \ No newline at end of file diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions/packages.config b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions/packages.config index fc2bc5a5df..6a99b79e23 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions/packages.config +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions/packages.config @@ -1,7 +1,5 @@  - - \ No newline at end of file From ce4d8dc11ee7c22de37314b022c9c78dfae7aeca Mon Sep 17 00:00:00 2001 From: Mike Griese Date: Tue, 19 Aug 2025 16:02:38 -0500 Subject: [PATCH 2/7] CmdPal: Clean up ListItemViewModels when we no longer need them (#41169) _We already fixed one leak, yes, but what about second leak?_ We already clean up `ListItemViewModel`s for a page when the page is navigated away from. However, if the page updates it's items, we would never actually `Cleanup` the old items. We'd just lose them, and never unregister their event handlers. The objects would just leak forever. This builds on the work in #41166, to do two things: * Cleanup items that were removed from our list, when we actually update `Items`. This involved a change to `Toolkit.ListHelpers`, to let us know which items were removed from the list during `InPlaceUpdateList` * Cleanup items that are thrown out when we cancel a FetchItems. Those items were constructed, and might have registered event handlers, even if we never actually put them into `Items`. _Targets #41166_ Closes #39837 Tested with the evil sample from #41158, and loading thousands and thousands of items no longer causes us to leak memory like we're Deepwater Horizon. --- .../ListViewModel.cs | 28 ++++++++++++++++--- .../ListHelpers.cs | 22 +++++++++++++++ 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/ListViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/ListViewModel.cs index c461943f8a..10fc9445ad 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/ListViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/ListViewModel.cs @@ -141,6 +141,9 @@ public partial class ListViewModel : PageViewModel, IDisposable // see 9806fe5d8 for the last commit that had this with sections _isFetching = true; + // Collect all the items into new viewmodels + Collection newViewModels = []; + try { // Check for cancellation before starting expensive operations @@ -151,9 +154,6 @@ public partial class ListViewModel : PageViewModel, IDisposable // Check for cancellation after getting items from extension cancellationToken.ThrowIfCancellationRequested(); - // Collect all the items into new viewmodels - Collection newViewModels = []; - // TODO we can probably further optimize this by also keeping a // HashSet of every ExtensionObject we currently have, and only // building new viewmodels for the ones we haven't already built. @@ -187,11 +187,22 @@ public partial class ListViewModel : PageViewModel, IDisposable // Check for cancellation before updating the list cancellationToken.ThrowIfCancellationRequested(); + List removedItems = []; lock (_listLock) { // Now that we have new ViewModels for everything from the // extension, smartly update our list of VMs - ListHelpers.InPlaceUpdateList(Items, newViewModels); + ListHelpers.InPlaceUpdateList(Items, newViewModels, out removedItems); + + // DO NOT ThrowIfCancellationRequested AFTER THIS! If you do, + // you'll clean up list items that we've now transferred into + // .Items + } + + // If we removed items, we need to clean them up, to remove our event handlers + foreach (var removedItem in removedItems) + { + removedItem.SafeCleanup(); } // TODO: Iterate over everything in Items, and prune items from the @@ -200,6 +211,15 @@ public partial class ListViewModel : PageViewModel, IDisposable catch (OperationCanceledException) { // Cancellation is expected, don't treat as error + + // However, if we were cancelled, we didn't actually add these items to + // our Items list. Before we release them to the GC, make sure we clean + // them up + foreach (var vm in newViewModels) + { + vm.SafeCleanup(); + } + return; } catch (Exception ex) diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ListHelpers.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ListHelpers.cs index 4f004ae78e..39f7c087b0 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ListHelpers.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ListHelpers.cs @@ -65,12 +65,32 @@ public partial class ListHelpers public static void InPlaceUpdateList(IList original, IEnumerable newContents) where T : class { + InPlaceUpdateList(original, newContents, out _); + } + + /// + /// Modifies the contents of `original` in-place, to match those of + /// `newContents`. The canonical use being: + /// ```cs + /// ListHelpers.InPlaceUpdateList(FilteredItems, FilterList(ItemsToFilter, TextToFilterOn)); + /// ``` + /// + /// Any type that can be compared for equality + /// Collection to modify + /// The enumerable which `original` should match + /// List of items that were removed from the original collection + public static void InPlaceUpdateList(IList original, IEnumerable newContents, out List removedItems) + where T : class + { + removedItems = []; + // we're not changing newContents - stash this so we don't re-evaluate it every time var numberOfNew = newContents.Count(); // Short circuit - new contents should just be empty if (numberOfNew == 0) { + removedItems.AddRange(original); original.Clear(); return; } @@ -92,6 +112,7 @@ public partial class ListHelpers for (var k = i; k < j; k++) { // This item from the original list was not in the new list. Remove it. + removedItems.Add(original[i]); original.RemoveAt(i); } @@ -120,6 +141,7 @@ public partial class ListHelpers while (original.Count > numberOfNew) { // RemoveAtEnd + removedItems.Add(original[original.Count - 1]); original.RemoveAt(original.Count - 1); } From 75526b9580d4965a92c66fcd46a86d2c0b22895d Mon Sep 17 00:00:00 2001 From: Shawn Yuan <128874481+shuaiyuanxx@users.noreply.github.com> Date: Wed, 20 Aug 2025 09:31:52 +0800 Subject: [PATCH 3/7] [Feature] PowerToys hotkey conflict detection (#41029) ## Summary of the Pull Request Implements comprehensive hotkey conflict detection and resolution system for PowerToys, providing real-time conflict checking and centralized management interface. ## PR Checklist - [ ] **Closes:** #xxx - [x] **Communication:** I've discussed this with core contributors already. If the work hasn't been agreed, this work might be rejected - [ ] **Tests:** Added/updated and all pass - [x] **Localization:** All end-user-facing strings can be localized - [x] **Dev docs:** Added/updated - [ ] **New binaries:** Added on the required places - [ ] [JSON for signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json) for new binaries - [ ] [WXS for installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs) for new binaries and localization folder - [ ] [YML for CI pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml) for new test projects - [ ] [YML for signed pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml) - [x] **Documentation updated:** If checked, please file a pull request on [our docs repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys) and link it here: [Shortcut conflict detction dev spec](https://github.com/MicrosoftDocs/windows-dev-docs/pull/5519) ## TODO Lists - [x] Add real-time hotkey validation functionality to the hotkey dialog - [x] Immediately detect conflicts and update shortcut conflict status after applying new shortcuts - [x] Return conflict list from runner hotkey conflict detector for conflict checking. - [x] Implement the Tooltip for every shortcut control - [x] Add dialog UI for showing all the shortcut conflicts - [x] Support changing shortcut directly inside the shortcut conflict window/dialog, no need to nav to the settings page. - [x] Redesign the `ShortcutConflictDialogContentControl` to align with the spec - [x] Add navigating and changing hotkey auctionability to the `ShortcutConflictDialogContentControl` - [x] Add telemetry. Impemented in [another PR](https://github.com/shuaiyuanxx/PowerToys/pull/47) ## Shortcut Conflict Support Modules ![image](https://github.com/user-attachments/assets/3915174e-d1e7-4f86-8835-2a1bafcc85c9)
Demo videos https://github.com/user-attachments/assets/476d992c-c6ca-4bcd-a3f2-b26cc612d1b9 https://github.com/user-attachments/assets/1c1a2537-de54-4db2-bdbf-6f1908ff1ce7 https://github.com/user-attachments/assets/9c992254-fc2b-402c-beec-20fceef25e6b https://github.com/user-attachments/assets/d66abc1c-b8bf-45f8-a552-ec989dab310f
## Detailed Description of the Pull Request / Additional comments ## Validation Steps Performed Manually validation performed. --------- Signed-off-by: Shawn Yuan Signed-off-by: Shuai Yuan Co-authored-by: Niels Laute --- .github/actions/spell-check/expect.txt | 32 ++ .../core/settings/settings-implementation.md | 35 ++ .../AdvancedPasteModuleInterface/dllmain.cpp | 48 +- .../ModuleInterface/dllmain.cpp | 55 ++ .../interface/powertoy_module_interface.h | 32 +- src/runner/centralized_hotkeys.h | 4 +- src/runner/general_settings.cpp | 7 + src/runner/hotkey_conflict_detector.cpp | 471 ++++++++++++++++++ src/runner/hotkey_conflict_detector.h | 100 ++++ src/runner/powertoy_module.cpp | 22 +- src/runner/powertoy_module.h | 9 + src/runner/runner.vcxproj | 2 + src/runner/runner.vcxproj.filters | 6 + src/runner/settings_window.cpp | 74 +++ .../AdvancedPasteAdditionalAction.cs | 18 +- .../AdvancedPasteAdditionalActions.cs | 17 +- .../AdvancedPasteCustomAction.cs | 21 +- .../AdvancedPasteSettings.cs | 64 ++- .../AlwaysOnTopSettings.cs | 21 +- .../ColorPickerSettings.cs | 19 +- .../ColorPickerSettingsVersion1.cs | 1 - .../CropAndLockSettings.cs | 25 +- .../FindMyMouseSettings.cs | 21 +- .../Helpers/HotkeyAccessor.cs | 34 ++ .../HotkeyConflicts/AllHotkeyConflictsData.cs | 19 + .../AllHotkeyConflictsEventArgs.cs | 22 + .../HotkeyConflictGroupData.cs | 21 + .../HotkeyConflicts/HotkeyConflictInfo.cs | 23 + .../HotkeyConflicts/HotkeyData.cs | 71 +++ .../HotkeyConflicts/ModuleConflictsData.cs | 21 + .../HotkeyConflicts/ModuleHotkeyData.cs | 84 ++++ .../Settings.UI.Library/HotkeySettings.cs | 63 ++- .../Interfaces/IHotkeyConfig.cs | 17 + .../MeasureToolSettings.cs | 21 +- .../MouseHighlighterSettings.cs | 21 +- .../Settings.UI.Library/MouseJumpSettings.cs | 21 +- .../MousePointerCrosshairsSettings.cs | 21 +- .../MouseWithoutBordersSettings.cs | 33 +- .../Settings.UI.Library/PeekSettings.cs | 21 +- .../PowerLauncherSettings.cs | 20 +- .../Settings.UI.Library/PowerOcrSettings.cs | 21 +- .../Settings.UI.Library/SettingsFactory.cs | 197 ++++++++ .../ShortcutGuideSettings.cs | 21 +- .../Settings.UI.Library/WorkspacesSettings.cs | 21 +- .../PowerLauncherViewModelTest.cs | 36 +- .../Converters/BoolToConflictTypeConverter.cs | 27 + .../Helpers/HotkeyConflictHelper.cs | 73 +++ .../Helpers/HotkeyConflictResponse.cs | 22 + .../SourceGenerationContextContext.cs | 14 +- .../Services/GlobalHotkeyConflictManager.cs | 121 +++++ .../Services/IPCResponseService.cs | 199 ++++++++ .../Settings.UI/SettingsXAML/App.xaml.cs | 20 +- .../Dashboard/ShortcutConflictControl.xaml | 8 +- .../Dashboard/ShortcutConflictControl.xaml.cs | 117 ++++- .../Dashboard/ShortcutConflictWindow.xaml | 176 +++++++ .../Dashboard/ShortcutConflictWindow.xaml.cs | 91 ++++ .../Controls/KeyVisual/KeyVisual.xaml | 2 +- .../ShortcutControl/ShortcutControl.xaml | 2 + .../ShortcutControl/ShortcutControl.xaml.cs | 204 +++++++- .../ShortcutDialogContentControl.xaml | 9 +- .../ShortcutDialogContentControl.xaml.cs | 24 +- .../ShortcutWithTextLabelControl.xaml | 16 +- .../ShortcutWithTextLabelControl.xaml.cs | 33 +- .../SettingsXAML/OOBE/Views/OobeOverview.xaml | 53 +- .../OOBE/Views/OobeOverview.xaml.cs | 196 +++++++- .../SettingsXAML/OOBE/Views/OobeWhatsNew.xaml | 300 ++++++----- .../OOBE/Views/OobeWhatsNew.xaml.cs | 75 ++- .../Settings.UI/SettingsXAML/OobeWindow.xaml | 2 +- .../SettingsXAML/Views/AdvancedPaste.xaml.cs | 2 + .../Views/AlwaysOnTopPage.xaml.cs | 2 + .../SettingsXAML/Views/CmdPalPage.xaml.cs | 1 + .../Views/ColorPickerPage.xaml.cs | 2 + .../Views/CropAndLockPage.xaml.cs | 2 + .../SettingsXAML/Views/DashboardPage.xaml | 2 +- .../SettingsXAML/Views/DashboardPage.xaml.cs | 2 + .../SettingsXAML/Views/FancyZonesPage.xaml.cs | 1 + .../Views/MeasureToolPage.xaml.cs | 2 + .../SettingsXAML/Views/MouseUtilsPage.xaml.cs | 2 + .../Views/MouseWithoutBordersPage.xaml.cs | 2 + .../SettingsXAML/Views/PeekPage.xaml.cs | 1 + .../Views/PowerLauncherPage.xaml.cs | 3 + .../SettingsXAML/Views/PowerOcrPage.xaml.cs | 1 + .../SettingsXAML/Views/ShellPage.xaml.cs | 1 + .../Views/ShortcutGuidePage.xaml.cs | 2 + .../SettingsXAML/Views/WorkspacesPage.xaml.cs | 1 + .../Settings.UI/Strings/en-us/Resources.resw | 73 ++- .../ViewModels/AdvancedPasteViewModel.cs | 77 ++- .../ViewModels/AlwaysOnTopViewModel.cs | 16 +- .../Settings.UI/ViewModels/CmdPalViewModel.cs | 16 +- .../ViewModels/ColorPickerViewModel.cs | 38 +- .../ViewModels/CropAndLockViewModel.cs | 17 +- .../ViewModels/DashboardViewModel.cs | 40 +- .../ViewModels/FancyZonesViewModel.cs | 25 +- .../ViewModels/MeasureToolViewModel.cs | 17 +- .../ViewModels/MouseUtilsViewModel.cs | 23 +- .../MouseUtilsViewModel_MouseJump.cs | 3 +- .../MouseWithoutBordersViewModel.cs | 95 +++- .../ViewModels/PageViewModelBase.cs | 251 ++++++++++ .../Settings.UI/ViewModels/PeekViewModel.cs | 35 +- .../ViewModels/PowerLauncherViewModel.cs | 17 +- .../ViewModels/PowerOcrViewModel.cs | 32 +- .../ViewModels/ShortcutConflictViewModel.cs | 384 ++++++++++++++ .../ViewModels/ShortcutGuideViewModel.cs | 22 +- .../ViewModels/WorkspacesViewModel.cs | 17 +- 104 files changed, 4578 insertions(+), 366 deletions(-) create mode 100644 src/runner/hotkey_conflict_detector.cpp create mode 100644 src/runner/hotkey_conflict_detector.h create mode 100644 src/settings-ui/Settings.UI.Library/Helpers/HotkeyAccessor.cs create mode 100644 src/settings-ui/Settings.UI.Library/HotkeyConflicts/AllHotkeyConflictsData.cs create mode 100644 src/settings-ui/Settings.UI.Library/HotkeyConflicts/AllHotkeyConflictsEventArgs.cs create mode 100644 src/settings-ui/Settings.UI.Library/HotkeyConflicts/HotkeyConflictGroupData.cs create mode 100644 src/settings-ui/Settings.UI.Library/HotkeyConflicts/HotkeyConflictInfo.cs create mode 100644 src/settings-ui/Settings.UI.Library/HotkeyConflicts/HotkeyData.cs create mode 100644 src/settings-ui/Settings.UI.Library/HotkeyConflicts/ModuleConflictsData.cs create mode 100644 src/settings-ui/Settings.UI.Library/HotkeyConflicts/ModuleHotkeyData.cs create mode 100644 src/settings-ui/Settings.UI.Library/Interfaces/IHotkeyConfig.cs create mode 100644 src/settings-ui/Settings.UI.Library/SettingsFactory.cs create mode 100644 src/settings-ui/Settings.UI/Converters/BoolToConflictTypeConverter.cs create mode 100644 src/settings-ui/Settings.UI/Helpers/HotkeyConflictHelper.cs create mode 100644 src/settings-ui/Settings.UI/Helpers/HotkeyConflictResponse.cs create mode 100644 src/settings-ui/Settings.UI/Services/GlobalHotkeyConflictManager.cs create mode 100644 src/settings-ui/Settings.UI/Services/IPCResponseService.cs create mode 100644 src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/ShortcutConflictWindow.xaml create mode 100644 src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/ShortcutConflictWindow.xaml.cs create mode 100644 src/settings-ui/Settings.UI/ViewModels/PageViewModelBase.cs create mode 100644 src/settings-ui/Settings.UI/ViewModels/ShortcutConflictViewModel.cs diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt index 7e460dba2f..7ea012fe0e 100644 --- a/.github/actions/spell-check/expect.txt +++ b/.github/actions/spell-check/expect.txt @@ -25,6 +25,8 @@ ADMINS adml admx advancedpaste +advancedpasteui +advancedpasteuishortcut advfirewall AFeature affordances @@ -40,6 +42,7 @@ ALLINPUT Allman Allmodule ALLOWUNDO +allpc ALLVIEW ALPHATYPE AModifier @@ -629,6 +632,7 @@ HKCU hkey HKLM HKM +hkmng HKPD HKU HMD @@ -646,7 +650,11 @@ Hostx hotfixes hotkeycontrol HOTKEYF +hotkeylockmachine +hotkeyreconnect hotkeys +hotkeyswitch +hotkeytoggleeasymouse hotlight hotspot HPAINTBUFFER @@ -704,9 +712,12 @@ IMAGERESIZERCONTEXTMENU IMAGERESIZEREXT imageresizerinput imageresizersettings +imagetotext +imagetotextshortcut imagingdevices ime imgflip +inapp inbox INCONTACT Indo @@ -789,6 +800,7 @@ keyvault KILLFOCUS killrunner kmph +kvp Kybd lastcodeanalysissucceeded LASTEXITCODE @@ -827,6 +839,7 @@ localappdata localpackage LOCALSYSTEM LOCATIONCHANGE +LOCKMACHINE LOCKTYPE LOGFONT LOGFONTW @@ -912,6 +925,7 @@ MDL mdtext mdtxt mdwn +measuretool meme memicmp MENUITEMINFO @@ -961,6 +975,7 @@ MOUSEHWHEEL MOUSEINPUT mousejump mousepointer +mousepointercrosshairs mouseutils MOVESIZEEND MOVESIZESTART @@ -1161,6 +1176,18 @@ PARENTRELATIVEFORADDRESSBAR PARENTRELATIVEPARSING parray PARTIALCONFIRMATIONDIALOGTITLE +pasteashtmlfile +pasteashtmlfileshortcut +pasteasjson +pasteasjsonshortcut +pasteasmarkdown +pasteasmarkdownshortcut +pasteasplaintext +pasteasplaintextshortcut +pasteaspngfile +pasteaspngfileshortcut +pasteastxtfile +pasteastxtfileshortcut PATCOPY PATHMUSTEXIST PATINVERT @@ -1228,6 +1255,7 @@ Pomodoro Popups POPUPWINDOW POSITIONITEM +powerocr POWERRENAMECONTEXTMENU powerrenameinput POWERRENAMETEST @@ -1368,6 +1396,7 @@ Removelnk renamable RENAMEONCOLLISION reparented +reparenthotkey reparenting reportfileaccesses requery @@ -1687,6 +1716,7 @@ THH THICKFRAME THISCOMPONENT throughs +thumbnailhotkey TILEDWINDOW TILLSON timedate @@ -1701,6 +1731,7 @@ tlb tlbimp tlc TNP +TOGGLEEASYMOUSE Toolhelp toolkitconverters toolwindow @@ -1714,6 +1745,7 @@ tracelogging tracerpt trackbar trafficmanager +transcodetomp transicc TRAYMOUSEMESSAGE triaging 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/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/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/interface/powertoy_module_interface.h b/src/modules/interface/powertoy_module_interface.h index b569552659..b88763d1a3 100644 --- a/src/modules/interface/powertoy_module_interface.h +++ b/src/modules/interface/powertoy_module_interface.h @@ -45,14 +45,44 @@ public: bool shift = false; bool alt = false; unsigned char key = 0; + // The id is used to identify the hotkey in the module. The order in module interface should be the same as in the settings. + int id = 0; + // Currently, this is only used by AdvancedPaste to determine if the hotkey is shown in the settings. + bool isShown = true; - std::strong_ordering operator<=>(const Hotkey&) const = default; + std::strong_ordering operator<=>(const Hotkey& other) const + { + // Compare bool fields first + if (auto cmp = (win <=> other.win); cmp != 0) + return cmp; + if (auto cmp = (ctrl <=> other.ctrl); cmp != 0) + return cmp; + if (auto cmp = (shift <=> other.shift); cmp != 0) + return cmp; + if (auto cmp = (alt <=> other.alt); cmp != 0) + return cmp; + + // Compare key value only + return key <=> other.key; + + // Note: Deliberately NOT comparing 'name' field + } + + bool operator==(const Hotkey& other) const + { + return win == other.win && + ctrl == other.ctrl && + shift == other.shift && + alt == other.alt && + key == other.key; + } }; struct HotkeyEx { WORD modifiersMask = 0; WORD vkCode = 0; + int id = 0; }; /* Returns the localized name of the PowerToy*/ 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/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/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/MousePointerCrosshairsSettings.cs b/src/settings-ui/Settings.UI.Library/MousePointerCrosshairsSettings.cs index 2658a2adec..81b3eadca4 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,21 @@ 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"), + }; + + 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/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/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..25643e0c64 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,122 @@ 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.SettingsXAML.Controls.Dashboard; 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; + + 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; + } + + 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; + } + + // 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..3e3df56690 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,9 +3,15 @@ // 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.Services; +using Microsoft.PowerToys.Settings.UI.Views; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Automation; using Microsoft.UI.Xaml.Controls; @@ -33,8 +39,9 @@ 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)); private static ResourceLoader resourceLoader = Helpers.ResourceLoaderInstance.ResourceLoader; @@ -58,6 +65,28 @@ 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(); + } + + 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 +96,18 @@ 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 bool Enabled { get @@ -101,14 +142,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 +217,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 +251,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 +278,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 +415,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 +451,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 +593,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 +614,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 +716,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/MeasureToolPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/MeasureToolPage.xaml.cs index f48bc7cd5a..795e8a87cb 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/MeasureToolPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/MeasureToolPage.xaml.cs @@ -26,6 +26,8 @@ namespace Microsoft.PowerToys.Settings.UI.Views DataContext = ViewModel; InitializeComponent(); + + Loaded += (s, e) => ViewModel.OnPageLoaded(); } public void RefreshEnabledState() 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.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/ShellPage.xaml.cs index 4ed3faff9a..11835ceeb2 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/ShellPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/ShellPage.xaml.cs @@ -141,6 +141,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) 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..17bb9267b6 100644 --- a/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw +++ b/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw @@ -2049,18 +2049,27 @@ Take a moment to preview the various utilities listed or view our comprehensive Diagnostics & feedback helps us to improve PowerToys and keep it secure, up to date, and working as expected. + + Shortcut conflict detection + + + Shortcuts configured by PowerToys are conflicting. + + + Shortcuts configured by PowerToys are conflicting + + + No conflicts found + + + All shortcuts function correctly + View more diagnostic data settings Learn more about the information PowerToys logs & how it gets used - - Diagnostic data - - - Helps us make PowerToys faster, more stable, and better over time - Turn on diagnostic data to help us improve PowerToys? @@ -5127,6 +5136,58 @@ To record a specific window, enter the hotkey with the Alt key in the opposite m Back key + + This shortcut is already in use by another utility. + + + This shortcut is already in use by a default system shortcut. + + + PowerToys shortcut conflicts + + + PowerToys shortcut conflicts + + + Conflicting shortcuts may cause unexpected behavior. Edit them here or go to the module settings to update them. + + + Conflicts found for + + + System + + + Windows system shortcut + + + This shortcut can't be changed. + + + This shortcut is used by Windows and can't be changed. + + + No conflicts detected + + + All shortcuts function correctly + + + Resolve conflicts + + + Shortcut conflicts + + + No conflicts found + + + 1 conflict found + + + {0} conflicts found + {0} is replaced with the number of conflicts + No leading spaces diff --git a/src/settings-ui/Settings.UI/ViewModels/AdvancedPasteViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/AdvancedPasteViewModel.cs index 289eec97b8..0fdf2ca940 100644 --- a/src/settings-ui/Settings.UI/ViewModels/AdvancedPasteViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/AdvancedPasteViewModel.cs @@ -13,8 +13,8 @@ using System.Reflection; using System.Text.Json; using System.Text.Json.Serialization; using System.Timers; - using global::PowerToys.GPOWrapper; +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.Interfaces; @@ -24,15 +24,16 @@ using Windows.Security.Credentials; namespace Microsoft.PowerToys.Settings.UI.ViewModels { - public partial class AdvancedPasteViewModel : Observable, IDisposable + public partial class AdvancedPasteViewModel : PageViewModelBase { private static readonly HashSet WarnHotkeys = ["Ctrl + V", "Ctrl + Shift + V"]; - - private bool disposedValue; + private bool _disposed; // Delay saving of settings in order to avoid calling save multiple times and hitting file in use exception. If there is no other request to save settings in given interval, we proceed to save it; otherwise, we schedule saving it after this interval private const int SaveSettingsDelayInMs = 500; + protected override string ModuleName => AdvancedPasteSettings.ModuleName; + private GeneralSettings GeneralSettingsConfig { get; set; } private readonly ISettingsUtils _settingsUtils; @@ -98,6 +99,36 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels UpdateCustomActionsCanMoveUpDown(); } + public override Dictionary GetAllHotkeySettings() + { + var hotkeySettings = new List + { + PasteAsPlainTextShortcut, + AdvancedPasteUIShortcut, + PasteAsMarkdownShortcut, + PasteAsJsonShortcut, + }; + + foreach (var action in _additionalActions.GetAllActions()) + { + if (action is AdvancedPasteAdditionalAction additionalAction) + { + hotkeySettings.Add(additionalAction.Shortcut); + } + } + + // Custom actions do not have localization header, just use the action name. + foreach (var customAction in _customActions) + { + hotkeySettings.Add(customAction.Shortcut); + } + + return new Dictionary + { + [ModuleName] = hotkeySettings.ToArray(), + }; + } + private void InitializeEnabledValue() { _enabledGpoRuleConfiguration = GPOWrapper.GetConfiguredAdvancedPasteEnabledValue(); @@ -264,9 +295,8 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels if (_advancedPasteSettings.Properties.AdvancedPasteUIShortcut != value) { _advancedPasteSettings.Properties.AdvancedPasteUIShortcut = value ?? AdvancedPasteProperties.DefaultAdvancedPasteUIShortcut; - OnPropertyChanged(nameof(AdvancedPasteUIShortcut)); OnPropertyChanged(nameof(IsConflictingCopyShortcut)); - + OnPropertyChanged(nameof(AdvancedPasteUIShortcut)); SaveAndNotifySettings(); } } @@ -280,9 +310,8 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels if (_advancedPasteSettings.Properties.PasteAsPlainTextShortcut != value) { _advancedPasteSettings.Properties.PasteAsPlainTextShortcut = value ?? AdvancedPasteProperties.DefaultPasteAsPlainTextShortcut; - OnPropertyChanged(nameof(PasteAsPlainTextShortcut)); OnPropertyChanged(nameof(IsConflictingCopyShortcut)); - + OnPropertyChanged(nameof(PasteAsPlainTextShortcut)); SaveAndNotifySettings(); } } @@ -296,9 +325,8 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels if (_advancedPasteSettings.Properties.PasteAsMarkdownShortcut != value) { _advancedPasteSettings.Properties.PasteAsMarkdownShortcut = value ?? new HotkeySettings(); - OnPropertyChanged(nameof(PasteAsMarkdownShortcut)); OnPropertyChanged(nameof(IsConflictingCopyShortcut)); - + OnPropertyChanged(nameof(PasteAsMarkdownShortcut)); SaveAndNotifySettings(); } } @@ -312,9 +340,8 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels if (_advancedPasteSettings.Properties.PasteAsJsonShortcut != value) { _advancedPasteSettings.Properties.PasteAsJsonShortcut = value ?? new HotkeySettings(); - OnPropertyChanged(nameof(PasteAsJsonShortcut)); OnPropertyChanged(nameof(IsConflictingCopyShortcut)); - + OnPropertyChanged(nameof(PasteAsJsonShortcut)); SaveAndNotifySettings(); } } @@ -399,23 +426,31 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels OnPropertyChanged(nameof(ShowClipboardHistoryIsGpoConfiguredInfoBar)); } - protected virtual void Dispose(bool disposing) + protected override void Dispose(bool disposing) { - if (!disposedValue) + if (!_disposed) { if (disposing) { - _delayedTimer.Dispose(); + _delayedTimer?.Dispose(); + + foreach (var action in _additionalActions.GetAllActions()) + { + action.PropertyChanged -= OnAdditionalActionPropertyChanged; + } + + foreach (var customAction in _customActions) + { + customAction.PropertyChanged -= OnCustomActionPropertyChanged; + } + + _customActions.CollectionChanged -= OnCustomActionsCollectionChanged; } - disposedValue = true; + _disposed = true; } - } - public void Dispose() - { - Dispose(disposing: true); - GC.SuppressFinalize(this); + base.Dispose(disposing); } internal void DisableAI() diff --git a/src/settings-ui/Settings.UI/ViewModels/AlwaysOnTopViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/AlwaysOnTopViewModel.cs index 789ef92dfc..d9be787e70 100644 --- a/src/settings-ui/Settings.UI/ViewModels/AlwaysOnTopViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/AlwaysOnTopViewModel.cs @@ -3,11 +3,13 @@ // See the LICENSE file in the project root for more information. using System; +using System.Collections.Generic; using System.Globalization; using System.Runtime.CompilerServices; using System.Text.Json; using global::PowerToys.GPOWrapper; using ManagedCommon; +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.Interfaces; @@ -16,8 +18,10 @@ using Microsoft.PowerToys.Settings.UI.SerializationContext; namespace Microsoft.PowerToys.Settings.UI.ViewModels { - public partial class AlwaysOnTopViewModel : Observable + public partial class AlwaysOnTopViewModel : PageViewModelBase { + protected override string ModuleName => AlwaysOnTopSettings.ModuleName; + private ISettingsUtils SettingsUtils { get; set; } private GeneralSettings GeneralSettingsConfig { get; set; } @@ -75,6 +79,16 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } + public override Dictionary GetAllHotkeySettings() + { + var hotkeysDict = new Dictionary + { + [ModuleName] = [Hotkey], + }; + + return hotkeysDict; + } + public bool IsEnabled { get => _isEnabled; diff --git a/src/settings-ui/Settings.UI/ViewModels/CmdPalViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/CmdPalViewModel.cs index 07806bf31a..6d7b2a0bae 100644 --- a/src/settings-ui/Settings.UI/ViewModels/CmdPalViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/CmdPalViewModel.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information. using System; +using System.Collections.Generic; using System.IO; using System.IO.Abstractions; using System.Linq; @@ -11,6 +12,7 @@ using System.Text.Json; using System.Text.RegularExpressions; using global::PowerToys.GPOWrapper; using ManagedCommon; +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.Interfaces; @@ -21,8 +23,10 @@ using Windows.Management.Deployment; namespace Microsoft.PowerToys.Settings.UI.ViewModels { - public class CmdPalViewModel : Observable + public class CmdPalViewModel : PageViewModelBase { + protected override string ModuleName => "CmdPal"; + private GpoRuleConfigured _enabledGpoRuleConfiguration; private bool _isEnabled; private HotkeySettings _hotkey; @@ -88,6 +92,16 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } + public override Dictionary GetAllHotkeySettings() + { + var hotkeysDict = new Dictionary + { + [ModuleName] = [Hotkey], + }; + + return hotkeysDict; + } + public bool IsEnabled { get => _isEnabled; diff --git a/src/settings-ui/Settings.UI/ViewModels/ColorPickerViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/ColorPickerViewModel.cs index f3084c05e8..5ea84d2caf 100644 --- a/src/settings-ui/Settings.UI/ViewModels/ColorPickerViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/ColorPickerViewModel.cs @@ -9,9 +9,9 @@ using System.Globalization; using System.Linq; using System.Text.Json; using System.Timers; - using global::PowerToys.GPOWrapper; using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Helpers; using Microsoft.PowerToys.Settings.UI.Library; using Microsoft.PowerToys.Settings.UI.Library.Enumerations; using Microsoft.PowerToys.Settings.UI.Library.Helpers; @@ -20,9 +20,11 @@ using Microsoft.PowerToys.Settings.UI.SerializationContext; namespace Microsoft.PowerToys.Settings.UI.ViewModels { - public partial class ColorPickerViewModel : Observable, IDisposable + public partial class ColorPickerViewModel : PageViewModelBase { - private bool disposedValue; + protected override string ModuleName => ColorPickerSettings.ModuleName; + + private bool _disposed; // Delay saving of settings in order to avoid calling save multiple times and hitting file in use exception. If there is no other request to save settings in given interval, we proceed to save it; otherwise, we schedule saving it after this interval private const int SaveSettingsDelayInMs = 500; @@ -87,6 +89,16 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } + public override Dictionary GetAllHotkeySettings() + { + var hotkeysDict = new Dictionary + { + [ModuleName] = [ActivationShortcut], + }; + + return hotkeysDict; + } + public bool IsEnabled { get => _isEnabled; @@ -409,23 +421,25 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels OnPropertyChanged(nameof(IsEnabled)); } - protected virtual void Dispose(bool disposing) + protected override void Dispose(bool disposing) { - if (!disposedValue) + if (!_disposed) { if (disposing) { - _delayedTimer.Dispose(); + _delayedTimer?.Dispose(); + foreach (var colorFormat in ColorFormats) + { + colorFormat.PropertyChanged -= ColorFormat_PropertyChanged; + } + + ColorFormats.CollectionChanged -= ColorFormats_CollectionChanged; } - disposedValue = true; + _disposed = true; } - } - public void Dispose() - { - Dispose(disposing: true); - GC.SuppressFinalize(this); + base.Dispose(disposing); } internal ColorFormatModel GetNewColorFormatModel() diff --git a/src/settings-ui/Settings.UI/ViewModels/CropAndLockViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/CropAndLockViewModel.cs index dc5f6846ef..e5e8a6383a 100644 --- a/src/settings-ui/Settings.UI/ViewModels/CropAndLockViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/CropAndLockViewModel.cs @@ -3,11 +3,12 @@ // See the LICENSE file in the project root for more information. using System; +using System.Collections.Generic; using System.Globalization; using System.Runtime.CompilerServices; using System.Text.Json; - using global::PowerToys.GPOWrapper; +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.Interfaces; @@ -16,8 +17,10 @@ using Microsoft.PowerToys.Settings.UI.SerializationContext; namespace Microsoft.PowerToys.Settings.UI.ViewModels { - public partial class CropAndLockViewModel : Observable + public partial class CropAndLockViewModel : PageViewModelBase { + protected override string ModuleName => CropAndLockSettings.ModuleName; + private ISettingsUtils SettingsUtils { get; set; } private GeneralSettings GeneralSettingsConfig { get; set; } @@ -66,6 +69,16 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } + public override Dictionary GetAllHotkeySettings() + { + var hotkeysDict = new Dictionary + { + [ModuleName] = [ReparentActivationShortcut, ThumbnailActivationShortcut], + }; + + return hotkeysDict; + } + public bool IsEnabled { get => _isEnabled; diff --git a/src/settings-ui/Settings.UI/ViewModels/DashboardViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/DashboardViewModel.cs index 8dd97c85fa..7b62732e87 100644 --- a/src/settings-ui/Settings.UI/ViewModels/DashboardViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/DashboardViewModel.cs @@ -7,6 +7,7 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.IO.Abstractions; using System.Linq; +using System.Threading.Tasks; using System.Windows.Threading; using CommunityToolkit.WinUI.Controls; using global::PowerToys.GPOWrapper; @@ -14,6 +15,7 @@ using ManagedCommon; 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.Library.Interfaces; using Microsoft.PowerToys.Settings.UI.Library.Utilities; using Microsoft.PowerToys.Settings.UI.Services; @@ -23,8 +25,10 @@ using Microsoft.UI.Xaml.Controls; namespace Microsoft.PowerToys.Settings.UI.ViewModels { - public partial class DashboardViewModel : Observable + public partial class DashboardViewModel : PageViewModelBase { + protected override string ModuleName => "Dashboard"; + private const string JsonFileType = ".json"; private Dispatcher dispatcher; @@ -36,6 +40,20 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels public ObservableCollection ActionModules { get; set; } = new ObservableCollection(); + private AllHotkeyConflictsData _allHotkeyConflictsData = new AllHotkeyConflictsData(); + + public AllHotkeyConflictsData AllHotkeyConflictsData + { + get => _allHotkeyConflictsData; + set + { + if (Set(ref _allHotkeyConflictsData, value)) + { + OnPropertyChanged(); + } + } + } + public string PowerToysVersion { get @@ -66,6 +84,20 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels GetShortcutModules(); } + protected override void OnConflictsUpdated(object sender, AllHotkeyConflictsEventArgs e) + { + dispatcher.BeginInvoke(() => + { + AllHotkeyConflictsData = e.Conflicts ?? new AllHotkeyConflictsData(); + }); + } + + private void RequestConflictData() + { + // Request current conflicts data + GlobalHotkeyConflictManager.Instance?.RequestAllConflicts(); + } + private void AddDashboardListItem(ModuleType moduleType) { GpoRuleConfigured gpo = ModuleHelper.GetModuleGpoConfiguration(moduleType); @@ -93,6 +125,9 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels var settings = NewPlusViewModel.LoadSettings(settingsUtils); NewPlusViewModel.CopyTemplateExamples(settings.Properties.TemplateLocation.Value); } + + // Request updated conflicts after module state change + RequestConflictData(); } public void ModuleEnabledChangedOnSettingsPage() @@ -102,6 +137,9 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels GetShortcutModules(); OnPropertyChanged(nameof(ShortcutModules)); + + // Request updated conflicts after module state change + RequestConflictData(); } catch (Exception ex) { diff --git a/src/settings-ui/Settings.UI/ViewModels/FancyZonesViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/FancyZonesViewModel.cs index cd8ace4703..0f0ba98d11 100644 --- a/src/settings-ui/Settings.UI/ViewModels/FancyZonesViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/FancyZonesViewModel.cs @@ -3,9 +3,11 @@ // See the LICENSE file in the project root for more information. using System; +using System.Collections.Generic; using System.Runtime.CompilerServices; using global::PowerToys.GPOWrapper; using ManagedCommon; +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.Interfaces; @@ -13,14 +15,14 @@ using Microsoft.PowerToys.Settings.UI.Library.ViewModels.Commands; namespace Microsoft.PowerToys.Settings.UI.ViewModels { - public partial class FancyZonesViewModel : Observable + public partial class FancyZonesViewModel : PageViewModelBase { - private SettingsUtils SettingsUtils { get; set; } + protected override string ModuleName => FancyZonesSettings.ModuleName; + + private ISettingsUtils SettingsUtils { get; set; } private GeneralSettings GeneralSettingsConfig { get; set; } - private const string ModuleName = FancyZonesSettings.ModuleName; - public ButtonClickCommand LaunchEditorEventHandler { get; set; } private FancyZonesSettings Settings { get; set; } @@ -44,7 +46,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels Positional = 2, } - public FancyZonesViewModel(SettingsUtils settingsUtils, ISettingsRepository settingsRepository, ISettingsRepository moduleSettingsRepository, Func ipcMSGCallBackFunc, string configFileSubfolder = "") + public FancyZonesViewModel(ISettingsUtils settingsUtils, ISettingsRepository settingsRepository, ISettingsRepository moduleSettingsRepository, Func ipcMSGCallBackFunc, string configFileSubfolder = "") { ArgumentNullException.ThrowIfNull(settingsUtils); @@ -88,8 +90,9 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels _excludedApps = Settings.Properties.FancyzonesExcludedApps.Value; _systemTheme = Settings.Properties.FancyzonesSystemTheme.Value; _showZoneNumber = Settings.Properties.FancyzonesShowZoneNumber.Value; - EditorHotkey = Settings.Properties.FancyzonesEditorHotkey.Value; _windowSwitching = Settings.Properties.FancyzonesWindowSwitching.Value; + + EditorHotkey = Settings.Properties.FancyzonesEditorHotkey.Value; NextTabHotkey = Settings.Properties.FancyzonesNextTabHotkey.Value; PrevTabHotkey = Settings.Properties.FancyzonesPrevTabHotkey.Value; @@ -134,6 +137,16 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } + public override Dictionary GetAllHotkeySettings() + { + var hotkeysDict = new Dictionary + { + [ModuleName] = [EditorHotkey, NextTabHotkey, PrevTabHotkey], + }; + + return hotkeysDict; + } + private GpoRuleConfigured _enabledGpoRuleConfiguration; private bool _enabledStateIsGPOConfigured; private bool _isEnabled; diff --git a/src/settings-ui/Settings.UI/ViewModels/MeasureToolViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/MeasureToolViewModel.cs index ea66fd58dd..023cc06032 100644 --- a/src/settings-ui/Settings.UI/ViewModels/MeasureToolViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/MeasureToolViewModel.cs @@ -3,11 +3,12 @@ // See the LICENSE file in the project root for more information. using System; +using System.Collections.Generic; using System.Globalization; using System.Runtime.CompilerServices; using System.Text.Json; - using global::PowerToys.GPOWrapper; +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.Interfaces; @@ -15,8 +16,10 @@ using Microsoft.PowerToys.Settings.UI.SerializationContext; namespace Microsoft.PowerToys.Settings.UI.ViewModels { - public partial class MeasureToolViewModel : Observable + public partial class MeasureToolViewModel : PageViewModelBase { + protected override string ModuleName => MeasureToolSettings.ModuleName; + private ISettingsUtils SettingsUtils { get; set; } private GeneralSettings GeneralSettingsConfig { get; set; } @@ -59,6 +62,16 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } + public override Dictionary GetAllHotkeySettings() + { + var hotkeysDict = new Dictionary + { + [ModuleName] = [ActivationShortcut], + }; + + return hotkeysDict; + } + public bool IsEnabled { get => _isEnabled; diff --git a/src/settings-ui/Settings.UI/ViewModels/MouseUtilsViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/MouseUtilsViewModel.cs index 110f682164..a3adc16e62 100644 --- a/src/settings-ui/Settings.UI/ViewModels/MouseUtilsViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/MouseUtilsViewModel.cs @@ -3,10 +3,11 @@ // See the LICENSE file in the project root for more information. using System; +using System.Collections.Generic; using System.ComponentModel; using System.Runtime.CompilerServices; - using global::PowerToys.GPOWrapper; +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.Interfaces; @@ -14,8 +15,10 @@ using Microsoft.PowerToys.Settings.Utilities; namespace Microsoft.PowerToys.Settings.UI.ViewModels { - public partial class MouseUtilsViewModel : Observable + public partial class MouseUtilsViewModel : PageViewModelBase { + protected override string ModuleName => "MouseUtils"; + private ISettingsUtils SettingsUtils { get; set; } private GeneralSettings GeneralSettingsConfig { get; set; } @@ -101,7 +104,8 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels _mousePointerCrosshairsAutoActivate = MousePointerCrosshairsSettingsConfig.Properties.AutoActivate.Value; int isEnabled = 0; - NativeMethods.SystemParametersInfo(NativeMethods.SPI_GETCLIENTAREAANIMATION, 0, ref isEnabled, 0); + + Utilities.NativeMethods.SystemParametersInfo(Utilities.NativeMethods.SPI_GETCLIENTAREAANIMATION, 0, ref isEnabled, 0); _isAnimationEnabledBySystem = isEnabled != 0; // set the callback functions value to handle outgoing IPC message. @@ -149,6 +153,19 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } + public override Dictionary GetAllHotkeySettings() + { + var hotkeysDict = new Dictionary + { + [FindMyMouseSettings.ModuleName] = [FindMyMouseActivationShortcut], + [MouseHighlighterSettings.ModuleName] = [MouseHighlighterActivationShortcut], + [MousePointerCrosshairsSettings.ModuleName] = [MousePointerCrosshairsActivationShortcut], + [MouseJumpSettings.ModuleName] = [MouseJumpActivationShortcut], + }; + + return hotkeysDict; + } + public bool IsFindMyMouseEnabled { get => _isFindMyMouseEnabled; diff --git a/src/settings-ui/Settings.UI/ViewModels/MouseUtilsViewModel_MouseJump.cs b/src/settings-ui/Settings.UI/ViewModels/MouseUtilsViewModel_MouseJump.cs index e3a6f8f136..2ccd510bc9 100644 --- a/src/settings-ui/Settings.UI/ViewModels/MouseUtilsViewModel_MouseJump.cs +++ b/src/settings-ui/Settings.UI/ViewModels/MouseUtilsViewModel_MouseJump.cs @@ -25,7 +25,7 @@ using MouseJump.Common.Models.Styles; namespace Microsoft.PowerToys.Settings.UI.ViewModels { - public partial class MouseUtilsViewModel : Observable + public partial class MouseUtilsViewModel : PageViewModelBase { private GpoRuleConfigured _jumpEnabledGpoRuleConfiguration; private bool _jumpEnabledStateIsGPOConfigured; @@ -37,6 +37,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels { ArgumentNullException.ThrowIfNull(mouseJumpSettingsRepository); this.MouseJumpSettingsConfig = mouseJumpSettingsRepository.SettingsConfig; + this.MouseJumpSettingsConfig.Properties.ThumbnailSize.PropertyChanged += this.MouseJumpThumbnailSizePropertyChanged; } diff --git a/src/settings-ui/Settings.UI/ViewModels/MouseWithoutBordersViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/MouseWithoutBordersViewModel.cs index 2420ffccfd..496a8712a1 100644 --- a/src/settings-ui/Settings.UI/ViewModels/MouseWithoutBordersViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/MouseWithoutBordersViewModel.cs @@ -13,7 +13,6 @@ using System.Runtime.CompilerServices; using System.Text.Json; using System.Threading; using System.Threading.Tasks; - using global::PowerToys.GPOWrapper; using ManagedCommon; using Microsoft.PowerToys.Settings.UI.Helpers; @@ -30,8 +29,10 @@ using Windows.ApplicationModel.DataTransfer; namespace Microsoft.PowerToys.Settings.UI.ViewModels { - public partial class MouseWithoutBordersViewModel : Observable, IDisposable + public partial class MouseWithoutBordersViewModel : PageViewModelBase, IDisposable { + protected override string ModuleName => MouseWithoutBordersSettings.ModuleName; + // These should be in the same order as the ComboBoxItems in MouseWithoutBordersPage.xaml switch machine shortcut options private readonly int[] _switchBetweenMachineShortcutOptions = { @@ -43,18 +44,18 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels private readonly Lock _machineMatrixStringLock = new(); private static readonly Dictionary StatusColors = new Dictionary() -{ - { SocketStatus.NA, new SolidColorBrush(ColorHelper.FromArgb(0, 0x71, 0x71, 0x71)) }, - { SocketStatus.Resolving, new SolidColorBrush(Colors.Yellow) }, - { SocketStatus.Connecting, new SolidColorBrush(Colors.Orange) }, - { SocketStatus.Handshaking, new SolidColorBrush(Colors.Blue) }, - { SocketStatus.Error, new SolidColorBrush(Colors.Red) }, - { SocketStatus.ForceClosed, new SolidColorBrush(Colors.Purple) }, - { SocketStatus.InvalidKey, new SolidColorBrush(Colors.Brown) }, - { SocketStatus.Timeout, new SolidColorBrush(Colors.Pink) }, - { SocketStatus.SendError, new SolidColorBrush(Colors.Maroon) }, - { SocketStatus.Connected, new SolidColorBrush(Colors.Green) }, -}; + { + { SocketStatus.NA, new SolidColorBrush(ColorHelper.FromArgb(0, 0x71, 0x71, 0x71)) }, + { SocketStatus.Resolving, new SolidColorBrush(Colors.Yellow) }, + { SocketStatus.Connecting, new SolidColorBrush(Colors.Orange) }, + { SocketStatus.Handshaking, new SolidColorBrush(Colors.Blue) }, + { SocketStatus.Error, new SolidColorBrush(Colors.Red) }, + { SocketStatus.ForceClosed, new SolidColorBrush(Colors.Purple) }, + { SocketStatus.InvalidKey, new SolidColorBrush(Colors.Brown) }, + { SocketStatus.Timeout, new SolidColorBrush(Colors.Pink) }, + { SocketStatus.SendError, new SolidColorBrush(Colors.Maroon) }, + { SocketStatus.Connected, new SolidColorBrush(Colors.Green) }, + }; private bool _connectFieldsVisible; @@ -545,6 +546,20 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels _policyDefinedIpMappingRulesIsGPOConfigured = !string.IsNullOrWhiteSpace(_policyDefinedIpMappingRulesGPOData); } + public override Dictionary GetAllHotkeySettings() + { + var hotkeysDict = new Dictionary + { + [ModuleName] = [ + ToggleEasyMouseShortcut, + LockMachinesShortcut, + HotKeySwitch2AllPC, + ReconnectShortcut], + }; + + return hotkeysDict; + } + private void LoadViewModelFromSettings(MouseWithoutBordersSettings moduleSettings) { ArgumentNullException.ThrowIfNull(moduleSettings); @@ -998,6 +1013,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels { Settings.Properties.ToggleEasyMouseShortcut = value ?? MouseWithoutBordersProperties.DefaultHotKeyToggleEasyMouse; NotifyPropertyChanged(); + NotifyModuleUpdatedSettings(); } } } @@ -1013,6 +1029,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels Settings.Properties.LockMachineShortcut = value; Settings.Properties.LockMachineShortcut = value ?? MouseWithoutBordersProperties.DefaultHotKeyLockMachine; NotifyPropertyChanged(); + NotifyModuleUpdatedSettings(); } } } @@ -1028,6 +1045,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels Settings.Properties.ReconnectShortcut = value; Settings.Properties.ReconnectShortcut = value ?? MouseWithoutBordersProperties.DefaultHotKeyReconnect; NotifyPropertyChanged(); + NotifyModuleUpdatedSettings(); } } } @@ -1043,6 +1061,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels Settings.Properties.Switch2AllPCShortcut = value; Settings.Properties.Switch2AllPCShortcut = value ?? MouseWithoutBordersProperties.DefaultHotKeySwitch2AllPC; NotifyPropertyChanged(); + NotifyModuleUpdatedSettings(); } } } @@ -1201,11 +1220,11 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels private void NotifyModuleUpdatedSettings() { SendConfigMSG( - string.Format( - CultureInfo.InvariantCulture, - "{{ \"powertoys\": {{ \"{0}\": {1} }} }}", - MouseWithoutBordersSettings.ModuleName, - JsonSerializer.Serialize(Settings, SourceGenerationContextContext.Default.MouseWithoutBordersSettings))); + string.Format( + CultureInfo.InvariantCulture, + "{{ \"powertoys\": {{ \"{0}\": {1} }} }}", + MouseWithoutBordersSettings.ModuleName, + JsonSerializer.Serialize(Settings, SourceGenerationContextContext.Default.MouseWithoutBordersSettings))); } public void NotifyUpdatedSettings() @@ -1241,9 +1260,43 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels Clipboard.SetContent(data); } - public void Dispose() + protected override void Dispose(bool disposing) { - GC.SuppressFinalize(this); + if (disposing) + { + // Cancel the cancellation token source + _cancellationTokenSource?.Cancel(); + _cancellationTokenSource?.Dispose(); + + // Wait for the machine polling task to complete + try + { + _machinePollingThreadTask?.Wait(TimeSpan.FromSeconds(1)); + } + catch (AggregateException) + { + // Task was cancelled, which is expected + } + + // Dispose the named pipe stream + try + { + syncHelperStream?.Dispose(); + } + catch (Exception ex) + { + Logger.LogError($"Error disposing sync helper stream: {ex}"); + } + finally + { + syncHelperStream = null; + } + + // Dispose the semaphore + _ipcSemaphore?.Dispose(); + } + + base.Dispose(disposing); } internal void UninstallService() diff --git a/src/settings-ui/Settings.UI/ViewModels/PageViewModelBase.cs b/src/settings-ui/Settings.UI/ViewModels/PageViewModelBase.cs new file mode 100644 index 0000000000..78b66d6470 --- /dev/null +++ b/src/settings-ui/Settings.UI/ViewModels/PageViewModelBase.cs @@ -0,0 +1,251 @@ +// 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.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +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; + +namespace Microsoft.PowerToys.Settings.UI.ViewModels +{ + public abstract class PageViewModelBase : Observable, IDisposable + { + private readonly Dictionary _hotkeyConflictStatus = new Dictionary(); + private readonly Dictionary _hotkeyConflictTooltips = new Dictionary(); + private bool _disposed; + + protected abstract string ModuleName { get; } + + protected PageViewModelBase() + { + if (GlobalHotkeyConflictManager.Instance != null) + { + GlobalHotkeyConflictManager.Instance.ConflictsUpdated += OnConflictsUpdated; + } + } + + public virtual void OnPageLoaded() + { + Debug.WriteLine($"=== PAGE LOADED: {ModuleName} ==="); + GlobalHotkeyConflictManager.Instance?.RequestAllConflicts(); + } + + /// + /// Handles updates to hotkey conflicts for the module. This method is called when the + /// raises the ConflictsUpdated event. + /// + /// The source of the event, typically the instance. + /// An object containing details about the hotkey conflicts. + /// + /// Derived classes can override this method to provide custom handling for hotkey conflicts. + /// Ensure that the overridden method maintains the expected behavior of processing and logging conflict data. + /// + protected virtual void OnConflictsUpdated(object sender, AllHotkeyConflictsEventArgs e) + { + UpdateHotkeyConflictStatus(e.Conflicts); + var allHotkeySettings = GetAllHotkeySettings(); + + void UpdateConflictProperties() + { + if (allHotkeySettings != null) + { + foreach (KeyValuePair kvp in allHotkeySettings) + { + var module = kvp.Key; + var hotkeySettingsList = kvp.Value; + + for (int i = 0; i < hotkeySettingsList.Length; i++) + { + var key = $"{module.ToLowerInvariant()}_{i}"; + hotkeySettingsList[i].HasConflict = GetHotkeyConflictStatus(key); + hotkeySettingsList[i].ConflictDescription = GetHotkeyConflictTooltip(key); + } + } + } + } + + _ = Task.Run(() => + { + try + { + var settingsWindow = App.GetSettingsWindow(); + settingsWindow.DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Normal, UpdateConflictProperties); + } + catch + { + UpdateConflictProperties(); + } + }); + } + + public virtual Dictionary GetAllHotkeySettings() + { + return null; + } + + protected ModuleConflictsData GetModuleRelatedConflicts(AllHotkeyConflictsData allConflicts) + { + var moduleConflicts = new ModuleConflictsData(); + + if (allConflicts.InAppConflicts != null) + { + foreach (var conflict in allConflicts.InAppConflicts) + { + if (IsModuleInvolved(conflict)) + { + moduleConflicts.InAppConflicts.Add(conflict); + } + } + } + + if (allConflicts.SystemConflicts != null) + { + foreach (var conflict in allConflicts.SystemConflicts) + { + if (IsModuleInvolved(conflict)) + { + moduleConflicts.SystemConflicts.Add(conflict); + } + } + } + + return moduleConflicts; + } + + private void ProcessMouseUtilsConflictGroup(HotkeyConflictGroupData conflict, HashSet mouseUtilsModules, bool isSysConflict) + { + // Check if any of the modules in this conflict are MouseUtils submodules + var involvedMouseUtilsModules = conflict.Modules + .Where(module => mouseUtilsModules.Contains(module.ModuleName)) + .ToList(); + + if (involvedMouseUtilsModules.Count != 0) + { + // For each involved MouseUtils module, mark the hotkey as having a conflict + foreach (var module in involvedMouseUtilsModules) + { + string hotkeyKey = $"{module.ModuleName.ToLowerInvariant()}_{module.HotkeyID}"; + _hotkeyConflictStatus[hotkeyKey] = true; + _hotkeyConflictTooltips[hotkeyKey] = isSysConflict + ? ResourceLoaderInstance.ResourceLoader.GetString("SysHotkeyConflictTooltipText") + : ResourceLoaderInstance.ResourceLoader.GetString("InAppHotkeyConflictTooltipText"); + } + } + } + + protected virtual void UpdateHotkeyConflictStatus(AllHotkeyConflictsData allConflicts) + { + _hotkeyConflictStatus.Clear(); + _hotkeyConflictTooltips.Clear(); + + // Since MouseUtils in Settings consolidates four modules: Find My Mouse, Mouse Highlighter, Mouse Pointer Crosshairs, and Mouse Jump + // We need to handle this case separately here. + if (string.Equals(ModuleName, "MouseUtils", StringComparison.OrdinalIgnoreCase)) + { + var mouseUtilsModules = new HashSet + { + FindMyMouseSettings.ModuleName, + MouseHighlighterSettings.ModuleName, + MousePointerCrosshairsSettings.ModuleName, + MouseJumpSettings.ModuleName, + }; + + // Process in-app conflicts + foreach (var conflict in allConflicts.InAppConflicts) + { + ProcessMouseUtilsConflictGroup(conflict, mouseUtilsModules, false); + } + + // Process system conflicts + foreach (var conflict in allConflicts.SystemConflicts) + { + ProcessMouseUtilsConflictGroup(conflict, mouseUtilsModules, true); + } + } + else + { + if (allConflicts.InAppConflicts.Count > 0) + { + foreach (var conflictGroup in allConflicts.InAppConflicts) + { + foreach (var conflict in conflictGroup.Modules) + { + if (string.Equals(conflict.ModuleName, ModuleName, StringComparison.OrdinalIgnoreCase)) + { + var keyName = $"{conflict.ModuleName.ToLowerInvariant()}_{conflict.HotkeyID}"; + _hotkeyConflictStatus[keyName] = true; + _hotkeyConflictTooltips[keyName] = ResourceLoaderInstance.ResourceLoader.GetString("InAppHotkeyConflictTooltipText"); + } + } + } + } + + if (allConflicts.SystemConflicts.Count > 0) + { + foreach (var conflictGroup in allConflicts.SystemConflicts) + { + foreach (var conflict in conflictGroup.Modules) + { + if (string.Equals(conflict.ModuleName, ModuleName, StringComparison.OrdinalIgnoreCase)) + { + var keyName = $"{conflict.ModuleName.ToLowerInvariant()}_{conflict.HotkeyID}"; + _hotkeyConflictStatus[keyName] = true; + _hotkeyConflictTooltips[keyName] = ResourceLoaderInstance.ResourceLoader.GetString("SysHotkeyConflictTooltipText"); + } + } + } + } + } + } + + protected virtual bool GetHotkeyConflictStatus(string key) + { + return _hotkeyConflictStatus.ContainsKey(key) && _hotkeyConflictStatus[key]; + } + + protected virtual string GetHotkeyConflictTooltip(string key) + { + return _hotkeyConflictTooltips.TryGetValue(key, out string value) ? value : null; + } + + private bool IsModuleInvolved(HotkeyConflictGroupData conflict) + { + if (conflict.Modules == null) + { + return false; + } + + return conflict.Modules.Any(module => + string.Equals(module.ModuleName, ModuleName, StringComparison.OrdinalIgnoreCase)); + } + + public virtual void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (!_disposed) + { + if (disposing) + { + if (GlobalHotkeyConflictManager.Instance != null) + { + GlobalHotkeyConflictManager.Instance.ConflictsUpdated -= OnConflictsUpdated; + } + } + + _disposed = true; + } + } + } +} diff --git a/src/settings-ui/Settings.UI/ViewModels/PeekViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/PeekViewModel.cs index a96a1aeec5..3688e2e14d 100644 --- a/src/settings-ui/Settings.UI/ViewModels/PeekViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/PeekViewModel.cs @@ -3,13 +3,14 @@ // See the LICENSE file in the project root for more information. using System; +using System.Collections.Generic; using System.Globalization; using System.IO; using System.IO.Abstractions; using System.Text.Json; - using global::PowerToys.GPOWrapper; using ManagedCommon; +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.Interfaces; @@ -20,10 +21,14 @@ using Settings.UI.Library; namespace Microsoft.PowerToys.Settings.UI.ViewModels { - public class PeekViewModel : Observable, IDisposable + public class PeekViewModel : PageViewModelBase { + protected override string ModuleName => PeekSettings.ModuleName; + private bool _isEnabled; + private bool _disposed; + private bool _settingsUpdating; private GeneralSettings GeneralSettingsConfig { get; set; } @@ -59,6 +64,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels // Load the application-specific settings, including preview items. _peekSettings = _settingsUtils.GetSettingsOrDefault(PeekSettings.ModuleName); _peekPreviewSettings = _settingsUtils.GetSettingsOrDefault(PeekSettings.ModuleName, PeekPreviewSettings.FileName); + SetupSettingsFileWatcher(); InitializeEnabledValue(); @@ -118,6 +124,16 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } + public override Dictionary GetAllHotkeySettings() + { + var hotkeysDict = new Dictionary + { + [ModuleName] = [ActivationShortcut], + }; + + return hotkeysDict; + } + public bool IsEnabled { get => _isEnabled; @@ -302,11 +318,20 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels OnPropertyChanged(nameof(IsEnabled)); } - public void Dispose() + protected override void Dispose(bool disposing) { - _watcher?.Dispose(); + if (!_disposed) + { + if (disposing) + { + _watcher?.Dispose(); + _watcher = null; + } - GC.SuppressFinalize(this); + _disposed = true; + } + + base.Dispose(disposing); } } } diff --git a/src/settings-ui/Settings.UI/ViewModels/PowerLauncherViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/PowerLauncherViewModel.cs index 8c02d58319..31efe28260 100644 --- a/src/settings-ui/Settings.UI/ViewModels/PowerLauncherViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/PowerLauncherViewModel.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information. using System; +using System.Collections.Generic; using System.Collections.ObjectModel; using System.ComponentModel; using System.Globalization; @@ -10,9 +11,9 @@ using System.Linq; using System.Runtime.CompilerServices; using System.Text.Json; using System.Windows.Input; - using global::PowerToys.GPOWrapper; using ManagedCommon; +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.Interfaces; @@ -21,7 +22,7 @@ using Microsoft.PowerToys.Settings.UI.SerializationContext; namespace Microsoft.PowerToys.Settings.UI.ViewModels { - public partial class PowerLauncherViewModel : Observable + public partial class PowerLauncherViewModel : PageViewModelBase, IDisposable { private int _themeIndex; private int _monitorPositionIndex; @@ -37,6 +38,8 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels public delegate void SendCallback(PowerLauncherSettings settings); + protected override string ModuleName => PowerLauncherSettings.ModuleName; + private readonly SendCallback callback; private readonly Func isDark; @@ -122,6 +125,16 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } + public override Dictionary GetAllHotkeySettings() + { + var hotkeysDict = new Dictionary + { + [ModuleName] = [OpenPowerLauncher], + }; + + return hotkeysDict; + } + private void OnPluginInfoChange(object sender, PropertyChangedEventArgs e) { if ( diff --git a/src/settings-ui/Settings.UI/ViewModels/PowerOcrViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/PowerOcrViewModel.cs index fced94ad06..cb67dfc237 100644 --- a/src/settings-ui/Settings.UI/ViewModels/PowerOcrViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/PowerOcrViewModel.cs @@ -11,6 +11,7 @@ using System.Text.Json; using System.Timers; using global::PowerToys.GPOWrapper; using ManagedCommon; +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.Interfaces; @@ -20,9 +21,11 @@ using Windows.Media.Ocr; namespace Microsoft.PowerToys.Settings.UI.ViewModels { - public partial class PowerOcrViewModel : Observable, IDisposable + public partial class PowerOcrViewModel : PageViewModelBase { - private bool disposedValue; + protected override string ModuleName => PowerOcrSettings.ModuleName; + + private bool _disposed; // Delay saving of settings in order to avoid calling save multiple times and hitting file in use exception. If there is no other request to save settings in given interval, we proceed to save it; otherwise, we schedule saving it after this interval private const int SaveSettingsDelayInMs = 500; @@ -114,6 +117,16 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } + public override Dictionary GetAllHotkeySettings() + { + var hotkeysDict = new Dictionary + { + [ModuleName] = [ActivationShortcut], + }; + + return hotkeysDict; + } + public bool IsEnabled { get => _isEnabled; @@ -246,23 +259,20 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels OnPropertyChanged(nameof(IsEnabled)); } - protected virtual void Dispose(bool disposing) + protected override void Dispose(bool disposing) { - if (!disposedValue) + if (!_disposed) { if (disposing) { - _delayedTimer.Dispose(); + _delayedTimer?.Dispose(); + _delayedTimer = null; } - disposedValue = true; + _disposed = true; } - } - public void Dispose() - { - Dispose(disposing: true); - GC.SuppressFinalize(this); + base.Dispose(disposing); } public string SnippingToolInfoBarMargin diff --git a/src/settings-ui/Settings.UI/ViewModels/ShortcutConflictViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/ShortcutConflictViewModel.cs new file mode 100644 index 0000000000..b489d29fca --- /dev/null +++ b/src/settings-ui/Settings.UI/ViewModels/ShortcutConflictViewModel.cs @@ -0,0 +1,384 @@ +// 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Globalization; +using System.Linq; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using System.Windows; +using System.Windows.Threading; +using ManagedCommon; +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.Library.Interfaces; +using Microsoft.PowerToys.Settings.UI.SerializationContext; +using Microsoft.PowerToys.Settings.UI.Services; +using Microsoft.Windows.ApplicationModel.Resources; + +namespace Microsoft.PowerToys.Settings.UI.ViewModels +{ + public class ShortcutConflictViewModel : PageViewModelBase + { + private readonly SettingsFactory _settingsFactory; + private readonly Func _ipcMSGCallBackFunc; + private readonly Dispatcher _dispatcher; + + private bool _disposed; + private AllHotkeyConflictsData _conflictsData = new(); + private ObservableCollection _conflictItems = new(); + private ResourceLoader resourceLoader; + + public ShortcutConflictViewModel( + ISettingsUtils settingsUtils, + ISettingsRepository settingsRepository, + Func ipcMSGCallBackFunc) + { + _dispatcher = Dispatcher.CurrentDispatcher; + _ipcMSGCallBackFunc = ipcMSGCallBackFunc ?? throw new ArgumentNullException(nameof(ipcMSGCallBackFunc)); + resourceLoader = ResourceLoaderInstance.ResourceLoader; + + // Create SettingsFactory + _settingsFactory = new SettingsFactory(settingsUtils ?? throw new ArgumentNullException(nameof(settingsUtils))); + } + + public AllHotkeyConflictsData ConflictsData + { + get => _conflictsData; + set + { + if (Set(ref _conflictsData, value)) + { + UpdateConflictItems(); + } + } + } + + public ObservableCollection ConflictItems + { + get => _conflictItems; + private set => Set(ref _conflictItems, value); + } + + protected override string ModuleName => "ShortcutConflictsWindow"; + + private IHotkeyConfig GetModuleSettings(string moduleKey) + { + try + { + // MouseWithoutBorders and Peek settings may be changed by the logic in the utility as machines connect. + // We need to get a fresh version every time instead of using a repository. + if (string.Equals(moduleKey, MouseWithoutBordersSettings.ModuleName, StringComparison.OrdinalIgnoreCase) || + string.Equals(moduleKey, PeekSettings.ModuleName, StringComparison.OrdinalIgnoreCase)) + { + return _settingsFactory.GetFreshSettings(moduleKey); + } + + // For other modules, get the settings from SettingsRepository + return _settingsFactory.GetSettings(moduleKey); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Error loading settings for {moduleKey}: {ex.Message}"); + return null; + } + } + + protected override void OnConflictsUpdated(object sender, AllHotkeyConflictsEventArgs e) + { + _dispatcher.BeginInvoke(() => + { + ConflictsData = e.Conflicts ?? new AllHotkeyConflictsData(); + }); + } + + private void UpdateConflictItems() + { + var items = new ObservableCollection(); + + ProcessConflicts(ConflictsData?.InAppConflicts, false, items); + ProcessConflicts(ConflictsData?.SystemConflicts, true, items); + + ConflictItems = items; + OnPropertyChanged(nameof(ConflictItems)); + } + + private void ProcessConflicts(IEnumerable conflicts, bool isSystemConflict, ObservableCollection items) + { + if (conflicts == null) + { + return; + } + + foreach (var conflict in conflicts) + { + ProcessConflictGroup(conflict, isSystemConflict); + items.Add(conflict); + } + } + + private void ProcessConflictGroup(HotkeyConflictGroupData conflict, bool isSystemConflict) + { + foreach (var module in conflict.Modules) + { + SetupModuleData(module, isSystemConflict); + } + } + + private void SetupModuleData(ModuleHotkeyData module, bool isSystemConflict) + { + try + { + var settings = GetModuleSettings(module.ModuleName); + var allHotkeyAccessors = settings.GetAllHotkeyAccessors(); + var hotkeyAccessor = allHotkeyAccessors[module.HotkeyID]; + + if (hotkeyAccessor != null) + { + // Get current hotkey settings (fresh from file) using the accessor's getter + module.HotkeySettings = hotkeyAccessor.Value; + + // Set header using localization key + module.Header = GetHotkeyLocalizationHeader(module.ModuleName, module.HotkeyID, hotkeyAccessor.LocalizationHeaderKey); + module.IsSystemConflict = isSystemConflict; + + // Set module display info + var moduleType = settings.GetModuleType(); + module.ModuleType = moduleType; + var displayName = resourceLoader.GetString(ModuleHelper.GetModuleLabelResourceName(moduleType)); + module.DisplayName = displayName; + module.IconPath = ModuleHelper.GetModuleTypeFluentIconName(moduleType); + + if (module.HotkeySettings != null) + { + SetConflictProperties(module.HotkeySettings, isSystemConflict); + } + + module.PropertyChanged -= OnModuleHotkeyDataPropertyChanged; + module.PropertyChanged += OnModuleHotkeyDataPropertyChanged; + } + else + { + System.Diagnostics.Debug.WriteLine($"Could not find hotkey accessor for {module.ModuleName}.{module.HotkeyID}"); + } + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Error setting up module data for {module.ModuleName}: {ex.Message}"); + } + } + + private void SetConflictProperties(HotkeySettings settings, bool isSystemConflict) + { + settings.HasConflict = true; + settings.IsSystemConflict = isSystemConflict; + } + + private void OnModuleHotkeyDataPropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (sender is ModuleHotkeyData moduleData && e.PropertyName == nameof(ModuleHotkeyData.HotkeySettings)) + { + UpdateModuleHotkeySettings(moduleData.ModuleName, moduleData.HotkeyID, moduleData.HotkeySettings); + } + } + + private void UpdateModuleHotkeySettings(string moduleName, int hotkeyID, HotkeySettings newHotkeySettings) + { + try + { + var settings = GetModuleSettings(moduleName); + var accessors = settings.GetAllHotkeyAccessors(); + + var hotkeyAccessor = accessors[hotkeyID]; + + // Use the accessor's setter to update the hotkey settings + hotkeyAccessor.Value = newHotkeySettings; + + if (settings is ISettingsConfig settingsConfig) + { + // No need to save settings here, the runner will call module interface to save it + // SaveSettingsToFile(settings); + + // Send IPC notification using the same format as other ViewModels + SendConfigMSG(settingsConfig, moduleName); + } + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Error updating hotkey settings for {moduleName}.{hotkeyID}: {ex.Message}"); + } + } + + private void SaveModuleSettingsAndNotify(string moduleName) + { + try + { + var settings = GetModuleSettings(moduleName); + + if (settings is ISettingsConfig settingsConfig) + { + // No need to save settings here, the runner will call module interface to save it + // SaveSettingsToFile(settings); + + // Send IPC notification using the same format as other ViewModels + SendConfigMSG(settingsConfig, moduleName); + + System.Diagnostics.Debug.WriteLine($"Saved settings and sent IPC notification for module: {moduleName}"); + } + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Error saving settings and notifying for {moduleName}: {ex.Message}"); + } + } + + private void SaveSettingsToFile(IHotkeyConfig settings) + { + try + { + // Get the repository for this settings type using reflection + var settingsType = settings.GetType(); + var repositoryMethod = typeof(SettingsFactory).GetMethod("GetRepository"); + if (repositoryMethod != null) + { + var genericMethod = repositoryMethod.MakeGenericMethod(settingsType); + var repository = genericMethod.Invoke(_settingsFactory, null); + + if (repository != null) + { + var saveMethod = repository.GetType().GetMethod("SaveSettingsToFile"); + saveMethod?.Invoke(repository, null); + System.Diagnostics.Debug.WriteLine($"Saved settings to file for type: {settingsType.Name}"); + } + } + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Error saving settings to file: {ex.Message}"); + } + } + + /// + /// Sends IPC notification using the same format as other ViewModels + /// + private void SendConfigMSG(ISettingsConfig settingsConfig, string moduleName) + { + try + { + var jsonTypeInfo = GetJsonTypeInfo(settingsConfig.GetType()); + var serializedSettings = jsonTypeInfo != null + ? JsonSerializer.Serialize(settingsConfig, jsonTypeInfo) + : JsonSerializer.Serialize(settingsConfig); + + var ipcMessage = string.Format( + CultureInfo.InvariantCulture, + "{{ \"powertoys\": {{ \"{0}\": {1} }} }}", + moduleName, + serializedSettings); + + var result = _ipcMSGCallBackFunc(ipcMessage); + System.Diagnostics.Debug.WriteLine($"Sent IPC notification for {moduleName}, result: {result}"); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Error sending IPC notification for {moduleName}: {ex.Message}"); + } + } + + private JsonTypeInfo GetJsonTypeInfo(Type settingsType) + { + try + { + var contextType = typeof(SourceGenerationContextContext); + var defaultProperty = contextType.GetProperty("Default", BindingFlags.Public | BindingFlags.Static); + var defaultContext = defaultProperty?.GetValue(null) as JsonSerializerContext; + + if (defaultContext != null) + { + var typeInfoProperty = contextType.GetProperties(BindingFlags.Public | BindingFlags.Instance) + .FirstOrDefault(p => p.PropertyType.IsGenericType && + p.PropertyType.GetGenericTypeDefinition() == typeof(JsonTypeInfo<>) && + p.PropertyType.GetGenericArguments()[0] == settingsType); + + return typeInfoProperty?.GetValue(defaultContext) as JsonTypeInfo; + } + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Error getting JsonTypeInfo for {settingsType.Name}: {ex.Message}"); + } + + return null; + } + + private string GetHotkeyLocalizationHeader(string moduleName, int hotkeyID, string headerKey) + { + // Handle AdvancedPaste custom actions + if (string.Equals(moduleName, AdvancedPasteSettings.ModuleName, StringComparison.OrdinalIgnoreCase) + && hotkeyID > 9) + { + return headerKey; + } + + try + { + return resourceLoader.GetString($"{headerKey}/Header"); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Error getting hotkey header for {moduleName}.{hotkeyID}: {ex.Message}"); + return headerKey; // Return the key itself as fallback + } + } + + protected override void Dispose(bool disposing) + { + if (!_disposed) + { + if (disposing) + { + UnsubscribeFromEvents(); + } + + _disposed = true; + } + + base.Dispose(disposing); + } + + private void UnsubscribeFromEvents() + { + try + { + if (ConflictItems != null) + { + foreach (var conflictGroup in ConflictItems) + { + if (conflictGroup?.Modules != null) + { + foreach (var module in conflictGroup.Modules) + { + if (module != null) + { + module.PropertyChanged -= OnModuleHotkeyDataPropertyChanged; + } + } + } + } + } + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Error unsubscribing from events: {ex.Message}"); + } + } + } +} diff --git a/src/settings-ui/Settings.UI/ViewModels/ShortcutGuideViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/ShortcutGuideViewModel.cs index 6ae2dd0746..1f25f02dfb 100644 --- a/src/settings-ui/Settings.UI/ViewModels/ShortcutGuideViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/ShortcutGuideViewModel.cs @@ -3,25 +3,29 @@ // See the LICENSE file in the project root for more information. using System; +using System.Collections.Generic; +using System.Globalization; using System.Runtime.CompilerServices; - +using System.Text.Json; using global::PowerToys.GPOWrapper; +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.Interfaces; +using Microsoft.PowerToys.Settings.UI.SerializationContext; namespace Microsoft.PowerToys.Settings.UI.ViewModels { - public partial class ShortcutGuideViewModel : Observable + public partial class ShortcutGuideViewModel : PageViewModelBase { + protected override string ModuleName => ShortcutGuideSettings.ModuleName; + private ISettingsUtils SettingsUtils { get; set; } private GeneralSettings GeneralSettingsConfig { get; set; } private ShortcutGuideSettings Settings { get; set; } - private const string ModuleName = ShortcutGuideSettings.ModuleName; - private Func SendConfigMSG { get; } private string _settingsConfigFileFolder = string.Empty; @@ -79,6 +83,16 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } + public override Dictionary GetAllHotkeySettings() + { + var hotkeysDict = new Dictionary + { + [ModuleName] = [OpenShortcutGuide], + }; + + return hotkeysDict; + } + private GpoRuleConfigured _enabledGpoRuleConfiguration; private bool _enabledStateIsGPOConfigured; private bool _isEnabled; diff --git a/src/settings-ui/Settings.UI/ViewModels/WorkspacesViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/WorkspacesViewModel.cs index e24b2ce597..2c05c79358 100644 --- a/src/settings-ui/Settings.UI/ViewModels/WorkspacesViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/WorkspacesViewModel.cs @@ -3,11 +3,12 @@ // See the LICENSE file in the project root for more information. using System; +using System.Collections.Generic; using System.Globalization; using System.Runtime.CompilerServices; using System.Text.Json; - using global::PowerToys.GPOWrapper; +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.Interfaces; @@ -16,8 +17,10 @@ using Microsoft.PowerToys.Settings.UI.SerializationContext; namespace Microsoft.PowerToys.Settings.UI.ViewModels { - public partial class WorkspacesViewModel : Observable + public partial class WorkspacesViewModel : PageViewModelBase { + protected override string ModuleName => WorkspacesSettings.ModuleName; + private ISettingsUtils SettingsUtils { get; set; } private GeneralSettings GeneralSettingsConfig { get; set; } @@ -75,6 +78,16 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } + public override Dictionary GetAllHotkeySettings() + { + var hotkeysDict = new Dictionary + { + [ModuleName] = [Hotkey], + }; + + return hotkeysDict; + } + public bool IsEnabled { get => _isEnabled; From 3bc746d0ffbae2c4daaa6bc5dcc199a2a27c2e23 Mon Sep 17 00:00:00 2001 From: Yu Leng <42196638+moooyo@users.noreply.github.com> Date: Wed, 20 Aug 2025 16:25:46 +0800 Subject: [PATCH 4/7] [CmdPal][UnitTests] Add/Migrate unit test for Apps and Bookmarks extension (#41238) ## Summary of the Pull Request 1. Create Apps and Bookmarks ut project. 2. Refactor Apps and Bookmarks. And some interface in these extensions to add a abstraction layer for testing purpose. New interface list: * ISettingsInterface * IUWPApplication * IAppCache * IBookmarkDataSource 3. Add/Migrate some test case for Apps and Bookmarks extension ## PR Checklist - [x] Closes: #41239 #41240 - [x] **Communication:** I've discussed this with core contributors already. If the work hasn't been agreed, this work might be rejected - [x] **Tests:** Added/updated and all pass - [ ] **Localization:** All end-user-facing strings can be localized - [ ] **Dev docs:** Added/updated - [ ] **New binaries:** Added on the required places - [ ] [JSON for signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json) for new binaries - [ ] [WXS for installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs) for new binaries and localization folder - [ ] [YML for CI pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml) for new test projects - [ ] [YML for signed pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml) - [ ] **Documentation updated:** If checked, please file a pull request on [our docs repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys) and link it here: #xxx ## Detailed Description of the Pull Request / Additional comments ## Validation Steps Performed --------- Co-authored-by: Yu Leng Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/actions/spell-check/expect.txt | 2 + PowerToys.sln | 22 + .../AllAppsCommandProviderTests.cs | 119 ++++ .../AllAppsPageTests.cs | 97 ++++ .../AppsTestBase.cs | 67 +++ ...Microsoft.CmdPal.Ext.Apps.UnitTests.csproj | 23 + .../MockAppCache.cs | 113 ++++ .../MockUWPApplication.cs | 140 +++++ .../QueryTests.cs | 45 ++ .../Settings.cs | 58 ++ .../TestDataHelper.cs | 128 +++++ .../BookmarkDataTests.cs | 42 ++ .../BookmarkJsonParserTests.cs | 535 ++++++++++++++++++ .../BookmarksCommandProviderTests.cs | 137 +++++ ...soft.CmdPal.Ext.Bookmarks.UnitTests.csproj | 23 + .../MockBookmarkDataSource.cs | 24 + .../QueryTests.cs | 55 ++ .../Settings.cs | 28 + .../AllAppsCommandProvider.cs | 13 +- .../Microsoft.CmdPal.Ext.Apps/AllAppsPage.cs | 16 +- .../AllAppsSettings.cs | 3 +- .../ext/Microsoft.CmdPal.Ext.Apps/AppCache.cs | 4 +- .../Helpers/ISettingsInterface.cs | 22 + .../Microsoft.CmdPal.Ext.Apps/IAppCache.cs | 37 ++ .../Programs/IUWPApplication.cs | 43 ++ .../Programs/UWPApplication.cs | 4 +- .../Properties/AssemblyInfo.cs | 7 + .../Storage/PackageRepository.cs | 2 +- .../BookmarkJsonParser.cs | 45 ++ .../Bookmarks.cs | 30 - .../BookmarksCommandProvider.cs | 27 +- .../FileBookmarkDataSource.cs | 49 ++ .../IBookmarkDataSource.cs | 11 + .../Properties/AssemblyInfo.cs | 7 + 34 files changed, 1927 insertions(+), 51 deletions(-) create mode 100644 src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/AllAppsCommandProviderTests.cs create mode 100644 src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/AllAppsPageTests.cs create mode 100644 src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/AppsTestBase.cs create mode 100644 src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/Microsoft.CmdPal.Ext.Apps.UnitTests.csproj create mode 100644 src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/MockAppCache.cs create mode 100644 src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/MockUWPApplication.cs create mode 100644 src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/QueryTests.cs create mode 100644 src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/Settings.cs create mode 100644 src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/TestDataHelper.cs create mode 100644 src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkDataTests.cs create mode 100644 src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkJsonParserTests.cs create mode 100644 src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarksCommandProviderTests.cs create mode 100644 src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests.csproj create mode 100644 src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/MockBookmarkDataSource.cs create mode 100644 src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/QueryTests.cs create mode 100644 src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/Settings.cs create mode 100644 src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Helpers/ISettingsInterface.cs create mode 100644 src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/IAppCache.cs create mode 100644 src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/IUWPApplication.cs create mode 100644 src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Properties/AssemblyInfo.cs create mode 100644 src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarkJsonParser.cs create mode 100644 src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/FileBookmarkDataSource.cs create mode 100644 src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/IBookmarkDataSource.cs create mode 100644 src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Properties/AssemblyInfo.cs diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt index 7ea012fe0e..9911ff6d81 100644 --- a/.github/actions/spell-check/expect.txt +++ b/.github/actions/spell-check/expect.txt @@ -771,6 +771,7 @@ istep ith ITHUMBNAIL IUI +IUWP IWIC jfif jgeosdfsdsgmkedfgdfgdfgbkmhcgcflmi @@ -1646,6 +1647,7 @@ STYLECHANGED STYLECHANGING subkeys sublang +Subdomain SUBMODULEUPDATE subresource Superbar diff --git a/PowerToys.sln b/PowerToys.sln index 00986aae29..6033ca1481 100644 --- a/PowerToys.sln +++ b/PowerToys.sln @@ -788,6 +788,10 @@ 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 Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|ARM64 = Debug|ARM64 @@ -2850,6 +2854,22 @@ 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 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -3161,6 +3181,8 @@ 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} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {C3A2F9D1-7930-4EF4-A6FC-7EE0A99821D0} 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/ext/Microsoft.CmdPal.Ext.Apps/AllAppsCommandProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AllAppsCommandProvider.cs index 3dadba9749..d6e9693a69 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AllAppsCommandProvider.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AllAppsCommandProvider.cs @@ -19,16 +19,23 @@ public partial class AllAppsCommandProvider : CommandProvider public static readonly AllAppsPage Page = new(); + private readonly AllAppsPage _page; private readonly CommandItem _listItem; public AllAppsCommandProvider() + : this(Page) { + } + + public AllAppsCommandProvider(AllAppsPage page) + { + _page = page ?? throw new ArgumentNullException(nameof(page)); Id = WellKnownId; DisplayName = Resources.installed_apps; Icon = Icons.AllAppsIcon; Settings = AllAppsSettings.Instance.Settings; - _listItem = new(Page) + _listItem = new(_page) { Subtitle = Resources.search_installed_apps, MoreCommands = [new CommandContextItem(AllAppsSettings.Instance.Settings.SettingsPage)], @@ -38,11 +45,11 @@ public partial class AllAppsCommandProvider : CommandProvider PinnedAppsManager.Instance.PinStateChanged += OnPinStateChanged; } - public override ICommandItem[] TopLevelCommands() => [_listItem, ..Page.GetPinnedApps()]; + public override ICommandItem[] TopLevelCommands() => [_listItem, .._page.GetPinnedApps()]; public ICommandItem? LookupApp(string displayName) { - var items = Page.GetItems(); + var items = _page.GetItems(); // We're going to do this search in two directions: // First, is this name a substring of any app... diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AllAppsPage.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AllAppsPage.cs index 35cac8b01c..68b77ce728 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AllAppsPage.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AllAppsPage.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; using System.Collections.Generic; using System.Diagnostics; using System.Linq; @@ -19,13 +20,20 @@ namespace Microsoft.CmdPal.Ext.Apps; public sealed partial class AllAppsPage : ListPage { private readonly Lock _listLock = new(); + private readonly IAppCache _appCache; private AppItem[] allApps = []; private AppListItem[] unpinnedApps = []; private AppListItem[] pinnedApps = []; public AllAppsPage() + : this(AppCache.Instance.Value) { + } + + public AllAppsPage(IAppCache appCache) + { + _appCache = appCache ?? throw new ArgumentNullException(nameof(appCache)); this.Name = Resources.all_apps; this.Icon = Icons.AllAppsIcon; this.ShowDetails = true; @@ -59,7 +67,7 @@ public sealed partial class AllAppsPage : ListPage private void BuildListItems() { - if (allApps.Length == 0 || AppCache.Instance.Value.ShouldReload()) + if (allApps.Length == 0 || _appCache.ShouldReload()) { lock (_listLock) { @@ -75,7 +83,7 @@ public sealed partial class AllAppsPage : ListPage this.IsLoading = false; - AppCache.Instance.Value.ResetReloadFlag(); + _appCache.ResetReloadFlag(); stopwatch.Stop(); Logger.LogTrace($"{nameof(AllAppsPage)}.{nameof(BuildListItems)} took: {stopwatch.ElapsedMilliseconds} ms"); @@ -85,11 +93,11 @@ public sealed partial class AllAppsPage : ListPage private AppItem[] GetAllApps() { - var uwpResults = AppCache.Instance.Value.UWPs + var uwpResults = _appCache.UWPs .Where((application) => application.Enabled) .Select(app => app.ToAppItem()); - var win32Results = AppCache.Instance.Value.Win32s + var win32Results = _appCache.Win32s .Where((application) => application.Enabled && application.Valid) .Select(app => app.ToAppItem()); diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AllAppsSettings.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AllAppsSettings.cs index d585f3cd6c..bc49611d45 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AllAppsSettings.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AllAppsSettings.cs @@ -5,13 +5,14 @@ using System; using System.Collections.Generic; using System.IO; +using Microsoft.CmdPal.Ext.Apps.Helpers; using Microsoft.CmdPal.Ext.Apps.Programs; using Microsoft.CmdPal.Ext.Apps.Properties; using Microsoft.CommandPalette.Extensions.Toolkit; namespace Microsoft.CmdPal.Ext.Apps; -public class AllAppsSettings : JsonSettingsManager +public class AllAppsSettings : JsonSettingsManager, ISettingsInterface { private static readonly string _namespace = "apps"; diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AppCache.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AppCache.cs index 746bfdfe9d..48beaec1ff 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AppCache.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AppCache.cs @@ -12,7 +12,7 @@ using Microsoft.CmdPal.Ext.Apps.Utils; namespace Microsoft.CmdPal.Ext.Apps; -public sealed partial class AppCache : IDisposable +public sealed partial class AppCache : IAppCache, IDisposable { private Win32ProgramFileSystemWatchers _win32ProgramRepositoryHelper; @@ -24,7 +24,7 @@ public sealed partial class AppCache : IDisposable public IList Win32s => _win32ProgramRepository.Items; - public IList UWPs => _packageRepository.Items; + public IList UWPs => _packageRepository.Items; public static readonly Lazy Instance = new(() => new()); diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Helpers/ISettingsInterface.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Helpers/ISettingsInterface.cs new file mode 100644 index 0000000000..b6328f3c10 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Helpers/ISettingsInterface.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.Collections.Generic; + +namespace Microsoft.CmdPal.Ext.Apps.Helpers; + +public interface ISettingsInterface +{ + public bool EnableStartMenuSource { get; } + + public bool EnableDesktopSource { get; } + + public bool EnableRegistrySource { get; } + + public bool EnablePathEnvironmentVariableSource { get; } + + public List ProgramSuffixes { get; } + + public List RunCommandSuffixes { get; } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/IAppCache.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/IAppCache.cs new file mode 100644 index 0000000000..0a84230a88 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/IAppCache.cs @@ -0,0 +1,37 @@ +// 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; + +/// +/// Interface for application cache that provides access to Win32 and UWP applications. +/// +public interface IAppCache : IDisposable +{ + /// + /// Gets the collection of Win32 programs. + /// + IList Win32s { get; } + + /// + /// Gets the collection of UWP applications. + /// + IList UWPs { get; } + + /// + /// Determines whether the cache should be reloaded. + /// + /// True if cache should be reloaded, false otherwise. + bool ShouldReload(); + + /// + /// Resets the reload flag. + /// + void ResetReloadFlag(); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/IUWPApplication.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/IUWPApplication.cs new file mode 100644 index 0000000000..775bcaab4a --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/IUWPApplication.cs @@ -0,0 +1,43 @@ +// 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; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.Apps.Programs; + +/// +/// Interface for UWP applications to enable testing and mocking +/// +public interface IUWPApplication : IProgram +{ + string AppListEntry { get; set; } + + string DisplayName { get; set; } + + string UserModelId { get; set; } + + string BackgroundColor { get; set; } + + string EntryPoint { get; set; } + + bool CanRunElevated { get; set; } + + string LogoPath { get; set; } + + LogoType LogoType { get; set; } + + UWP Package { get; set; } + + string LocationLocalized { get; } + + string GetAppIdentifier(); + + List GetCommands(); + + void UpdateLogoPath(Utils.Theme theme); + + AppItem ToAppItem(); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/UWPApplication.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/UWPApplication.cs index 484dc162ee..23b428447b 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/UWPApplication.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/UWPApplication.cs @@ -22,7 +22,7 @@ using Theme = Microsoft.CmdPal.Ext.Apps.Utils.Theme; namespace Microsoft.CmdPal.Ext.Apps.Programs; [Serializable] -public class UWPApplication : IProgram +public class UWPApplication : IUWPApplication { private static readonly IFileSystem FileSystem = new FileSystem(); private static readonly IPath Path = FileSystem.Path; @@ -517,7 +517,7 @@ public class UWPApplication : IProgram } } - internal AppItem ToAppItem() + public AppItem ToAppItem() { var app = this; var iconPath = app.LogoType != LogoType.Error ? app.LogoPath : string.Empty; diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Properties/AssemblyInfo.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..b0c7ecb93e --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/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.Apps.UnitTests")] diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Storage/PackageRepository.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Storage/PackageRepository.cs index 3a12958f1e..2c53a649b9 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Storage/PackageRepository.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Storage/PackageRepository.cs @@ -15,7 +15,7 @@ namespace Microsoft.CmdPal.Ext.Apps.Storage; /// A repository for storing packaged applications such as UWP apps or appx packaged desktop apps. /// This repository will also monitor for changes to the PackageCatalog and update the repository accordingly /// -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")] From e0428eef1dcfad91a42ba5156180312b1f729a1a Mon Sep 17 00:00:00 2001 From: Yu Leng <42196638+moooyo@users.noreply.github.com> Date: Wed, 20 Aug 2025 16:26:14 +0800 Subject: [PATCH 5/7] [CmdPal] Add WinAppSDK dependency in SamplePageExtension And ProcessMonitorExtension (#41274) ## Summary of the Pull Request To be honest, I don't know why we need it. But without this dependency, I can not deploy in my local env. How to repro: 1. Pull main branch. 2. Git clean -xfd (clean up the output path) 3. Click deploy in the VS ## PR Checklist - [ ] Closes: #xxx - [ ] **Communication:** I've discussed this with core contributors already. If the work hasn't been agreed, this work might be rejected - [ ] **Tests:** Added/updated and all pass - [ ] **Localization:** All end-user-facing strings can be localized - [ ] **Dev docs:** Added/updated - [ ] **New binaries:** Added on the required places - [ ] [JSON for signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json) for new binaries - [ ] [WXS for installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs) for new binaries and localization folder - [ ] [YML for CI pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml) for new test projects - [ ] [YML for signed pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml) - [ ] **Documentation updated:** If checked, please file a pull request on [our docs repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys) and link it here: #xxx ## Detailed Description of the Pull Request / Additional comments ## Validation Steps Performed Co-authored-by: Yu Leng --- .../ProcessMonitorExtension/ProcessMonitorExtension.csproj | 4 ++++ .../ext/SamplePagesExtension/SamplePagesExtension.csproj | 1 + 2 files changed, 5 insertions(+) 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 @@ + + + + ## Summary of the Pull Request 1. Preserve Adaptive Card action types during trimming using DynamicDependency 2. Revert PR https://github.com/microsoft/PowerToys/pull/41010 ## PR Checklist - [x] Closes: #40979 - [ ] **Communication:** I've discussed this with core contributors already. If the work hasn't been agreed, this work might be rejected - [ ] **Tests:** Added/updated and all pass - [ ] **Localization:** All end-user-facing strings can be localized - [ ] **Dev docs:** Added/updated - [ ] **New binaries:** Added on the required places - [ ] [JSON for signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json) for new binaries - [ ] [WXS for installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs) for new binaries and localization folder - [ ] [YML for CI pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml) for new test projects - [ ] [YML for signed pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml) - [ ] **Documentation updated:** If checked, please file a pull request on [our docs repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys) and link it here: #xxx ## Detailed Description of the Pull Request / Additional comments ## Validation Steps Performed --- .../ContentFormViewModel.cs | 120 ++++-------------- .../Pages/SampleContentPage.cs | 5 - 2 files changed, 26 insertions(+), 99 deletions(-) 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/ext/SamplePagesExtension/Pages/SampleContentPage.cs b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleContentPage.cs index 3d5b49f61d..0584d96ee6 100644 --- a/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleContentPage.cs +++ b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleContentPage.cs @@ -225,11 +225,6 @@ internal sealed partial class SampleContentForm : FormContent } ] } - }, - { - "type": "Action.OpenUrl", - "title": "Action.OpenUrl", - "url": "https://adaptivecards.microsoft.com/" } ] } From e1086726ec455b6b7e985379477b10bced821e26 Mon Sep 17 00:00:00 2001 From: Pedro Lamas Date: Wed, 20 Aug 2025 10:20:14 +0100 Subject: [PATCH 7/7] Fixes bgcode handlers registration (#40985) ## Summary of the Pull Request ## PR Checklist - [X] Closes: #30352 - [ ] **Communication:** I've discussed this with core contributors already. If the work hasn't been agreed, this work might be rejected - [ ] **Tests:** Added/updated and all pass - [ ] **Localization:** All end-user-facing strings can be localized - [ ] **Dev docs:** Added/updated - [ ] **New binaries:** Added on the required places - [ ] [JSON for signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json) for new binaries - [ ] [WXS for installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs) for new binaries and localization folder - [ ] [YML for CI pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml) for new test projects - [ ] [YML for signed pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml) - [ ] **Documentation updated:** If checked, please file a pull request on [our docs repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys) and link it here: #xxx ## Detailed Description of the Pull Request / Additional comments This is a follow up on #38667 and specifically addresses some of the comments that GitHub Copilot review pointed out. ## Validation Steps Performed (Manual validation only) --- src/modules/previewpane/powerpreview/CLSID.h | 1 + tools/BugReportTool/BugReportTool/RegistryUtils.cpp | 2 ++ 2 files changed, 3 insertions(+) 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/tools/BugReportTool/BugReportTool/RegistryUtils.cpp b/tools/BugReportTool/BugReportTool/RegistryUtils.cpp index eb576690e8..10913a55bd 100644 --- a/tools/BugReportTool/BugReportTool/RegistryUtils.cpp +++ b/tools/BugReportTool/BugReportTool/RegistryUtils.cpp @@ -35,6 +35,8 @@ namespace { HKEY_CLASSES_ROOT, L".qoi\\shellex\\{E357FCCD-A995-4576-B01F-234630154E96}" }, { HKEY_CLASSES_ROOT, L".gcode\\shellex\\{8895b1c6-b41f-4c1c-a562-0d564250836f}" }, { HKEY_CLASSES_ROOT, L".gcode\\shellex\\{E357FCCD-A995-4576-B01F-234630154E96}" }, + { HKEY_CLASSES_ROOT, L".bgcode\\shellex\\{8895b1c6-b41f-4c1c-a562-0d564250836f}" }, + { HKEY_CLASSES_ROOT, L".bgcode\\shellex\\{E357FCCD-A995-4576-B01F-234630154E96}" }, { HKEY_CLASSES_ROOT, L".stl\\shellex\\{E357FCCD-A995-4576-B01F-234630154E96}" } };