diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt index de9c5a062b..ef580c9681 100644 --- a/.github/actions/spell-check/expect.txt +++ b/.github/actions/spell-check/expect.txt @@ -1024,6 +1024,8 @@ MYICON NAMECHANGE namespaceanddescendants nao +Navigatable +NavigatablePage NCACTIVATE ncc NCCALCSIZE @@ -1501,6 +1503,7 @@ SETRULES SETSCREENSAVEACTIVE SETSTICKYKEYS SETTEXT +settingscard SETTINGCHANGE SETTINGSCHANGED settingsheader @@ -1758,11 +1761,13 @@ transcodetomp transicc TRAYMOUSEMESSAGE triaging +Tru trl trx tsa tskill tstoi +tweakable TWF tymed TYPEKEYBOARD diff --git a/.gitignore b/.gitignore index a1a09282a8..8859e53742 100644 --- a/.gitignore +++ b/.gitignore @@ -355,5 +355,8 @@ src/common/Telemetry/*.etl # MSBuildCache /MSBuildCacheLogs/ +# PowerToys Settings generated search index (legacy location) and obj outputs +/src/settings-ui/Settings.UI/Assets/Settings/search.index.json + # PowerToysInstaller Build Temp Files -installer/*/*.wxs.bk +installer/*/*.wxs.bk \ No newline at end of file diff --git a/.pipelines/ESRPSigning_core.json b/.pipelines/ESRPSigning_core.json index 6f8f817492..9cb1fcb7d5 100644 --- a/.pipelines/ESRPSigning_core.json +++ b/.pipelines/ESRPSigning_core.json @@ -28,6 +28,8 @@ "PowerToys.GPOWrapperProjection.dll", "PowerToys.AllExperiments.dll", + "Common.Search.dll", + "PowerToys.AlwaysOnTop.exe", "PowerToys.AlwaysOnTopModuleInterface.dll", @@ -280,6 +282,7 @@ "Mono.Cecil.Pdb.dll", "Mono.Cecil.Rocks.dll", "Newtonsoft.Json.dll", + "CommunityToolkit.WinUI.Controls.TitleBar.dll", "NLog.dll", "HtmlAgilityPack.dll", diff --git a/Directory.Packages.props b/Directory.Packages.props index 649c927a64..4c09a60664 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -23,6 +23,7 @@ + diff --git a/NOTICE.md b/NOTICE.md index bedc11379d..9861a58f4a 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -1499,6 +1499,7 @@ SOFTWARE. - CoenM.ImageSharp.ImageHash - CommunityToolkit.Common - CommunityToolkit.Labs.WinUI.Controls.MarkdownTextBlock +- CommunityToolkit.Labs.WinUI.TitleBar - CommunityToolkit.Mvvm - CommunityToolkit.WinUI.Animations - CommunityToolkit.WinUI.Collections diff --git a/PowerToys.sln b/PowerToys.sln index 2b6d42305f..cedc7f05b4 100644 --- a/PowerToys.sln +++ b/PowerToys.sln @@ -712,6 +712,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Preview.BgcodePreviewHandle EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Preview.BgcodeThumbnailProvider.UnitTests", "src\modules\previewpane\UnitTests-BgcodeThumbnailProvider\Preview.BgcodeThumbnailProvider.UnitTests.csproj", "{61CBF221-9452-4934-B685-146285E080D7}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Common.Search", "src\common\Common.Search\Common.Search.csproj", "{38F187B2-6638-5A40-072F-DBE5E54070A0}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Settings.UI.XamlIndexBuilder", "src\settings-ui\Settings.UI.XamlIndexBuilder\Settings.UI.XamlIndexBuilder.csproj", "{DA0744BC-E822-680E-9CEB-D0FBA903A8EE}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MouseUtils.UITests", "src\modules\MouseUtils\MouseUtils.UITests\MouseUtils.UITests.csproj", "{4E0AE3A4-2EE0-44D7-A2D0-8769977254A1}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Workspaces.Editor.UITests", "src\modules\Workspaces\WorkspacesEditorUITest\Workspaces.Editor.UITests.csproj", "{43E779F3-D83C-48B1-BA8D-1912DBD76FC9}" @@ -2707,6 +2711,30 @@ Global {43E779F3-D83C-48B1-BA8D-1912DBD76FC9}.Release|ARM64.Build.0 = Release|ARM64 {43E779F3-D83C-48B1-BA8D-1912DBD76FC9}.Release|x64.ActiveCfg = Release|x64 {43E779F3-D83C-48B1-BA8D-1912DBD76FC9}.Release|x64.Build.0 = Release|x64 + {38F187B2-6638-5A40-072F-DBE5E54070A0}.Debug|ARM64.ActiveCfg = Debug|ARM64 + {38F187B2-6638-5A40-072F-DBE5E54070A0}.Debug|ARM64.Build.0 = Debug|ARM64 + {38F187B2-6638-5A40-072F-DBE5E54070A0}.Debug|x64.ActiveCfg = Debug|x64 + {38F187B2-6638-5A40-072F-DBE5E54070A0}.Debug|x64.Build.0 = Debug|x64 + {38F187B2-6638-5A40-072F-DBE5E54070A0}.Release|ARM64.ActiveCfg = Release|ARM64 + {38F187B2-6638-5A40-072F-DBE5E54070A0}.Release|ARM64.Build.0 = Release|ARM64 + {38F187B2-6638-5A40-072F-DBE5E54070A0}.Release|x64.ActiveCfg = Release|x64 + {38F187B2-6638-5A40-072F-DBE5E54070A0}.Release|x64.Build.0 = Release|x64 + {DA0744BC-E822-680E-9CEB-D0FBA903A8EE}.Debug|ARM64.ActiveCfg = Debug|ARM64 + {DA0744BC-E822-680E-9CEB-D0FBA903A8EE}.Debug|ARM64.Build.0 = Debug|ARM64 + {DA0744BC-E822-680E-9CEB-D0FBA903A8EE}.Debug|x64.ActiveCfg = Debug|x64 + {DA0744BC-E822-680E-9CEB-D0FBA903A8EE}.Debug|x64.Build.0 = Debug|x64 + {DA0744BC-E822-680E-9CEB-D0FBA903A8EE}.Release|ARM64.ActiveCfg = Release|ARM64 + {DA0744BC-E822-680E-9CEB-D0FBA903A8EE}.Release|ARM64.Build.0 = Release|ARM64 + {DA0744BC-E822-680E-9CEB-D0FBA903A8EE}.Release|x64.ActiveCfg = Release|x64 + {DA0744BC-E822-680E-9CEB-D0FBA903A8EE}.Release|x64.Build.0 = Release|x64 + {0217E86E-3476-9946-DE8E-9D200CEBD47A}.Debug|ARM64.ActiveCfg = Debug|ARM64 + {0217E86E-3476-9946-DE8E-9D200CEBD47A}.Debug|ARM64.Build.0 = Debug|ARM64 + {0217E86E-3476-9946-DE8E-9D200CEBD47A}.Debug|x64.ActiveCfg = Debug|x64 + {0217E86E-3476-9946-DE8E-9D200CEBD47A}.Debug|x64.Build.0 = Debug|x64 + {0217E86E-3476-9946-DE8E-9D200CEBD47A}.Release|ARM64.ActiveCfg = Release|ARM64 + {0217E86E-3476-9946-DE8E-9D200CEBD47A}.Release|ARM64.Build.0 = Release|ARM64 + {0217E86E-3476-9946-DE8E-9D200CEBD47A}.Release|x64.ActiveCfg = Release|x64 + {0217E86E-3476-9946-DE8E-9D200CEBD47A}.Release|x64.Build.0 = Release|x64 {2CF78CF7-8FEB-4BE1-9591-55FA25B48FC6}.Debug|ARM64.ActiveCfg = Debug|ARM64 {2CF78CF7-8FEB-4BE1-9591-55FA25B48FC6}.Debug|ARM64.Build.0 = Debug|ARM64 {2CF78CF7-8FEB-4BE1-9591-55FA25B48FC6}.Debug|x64.ActiveCfg = Debug|x64 @@ -2900,6 +2928,7 @@ Global {D1D6BC88-09AE-4FB4-AD24-5DED46A791DD} = {4574FDD0-F61D-4376-98BF-E5A1262C11EC} {F9C68EDF-AC74-4B77-9AF1-005D9C9F6A99} = {D1D6BC88-09AE-4FB4-AD24-5DED46A791DD} {9C6A7905-72D4-4BF5-B256-ABFDAEF68AE9} = {264B412F-DB8B-4CF8-A74B-96998B183045} + {1AFB6476-670D-4E80-A464-657E01DFF482} = {557C4636-D7E1-4838-A504-7D19B725EE95} {1A066C63-64B3-45F8-92FE-664E1CCE8077} = {1AFB6476-670D-4E80-A464-657E01DFF482} {5CCC8468-DEC8-4D36-99D4-5C891BEBD481} = {D1D6BC88-09AE-4FB4-AD24-5DED46A791DD} {89E20BCE-EB9C-46C8-8B50-E01A82E6FDC3} = {4574FDD0-F61D-4376-98BF-E5A1262C11EC} @@ -3167,6 +3196,8 @@ Global {61CBF221-9452-4934-B685-146285E080D7} = {6B01F1CF-F4DB-48B5-BFE7-0BF576C1D704} {4E0AE3A4-2EE0-44D7-A2D0-8769977254A1} = {2C318EC3-BA86-4372-B1BC-DB0F33C208B2} {43E779F3-D83C-48B1-BA8D-1912DBD76FC9} = {68328142-5B31-4715-BCBB-7B6345EE0971} + {38F187B2-6638-5A40-072F-DBE5E54070A0} = {1AFB6476-670D-4E80-A464-657E01DFF482} + {DA0744BC-E822-680E-9CEB-D0FBA903A8EE} = {C3081D9A-1586-441A-B5F4-ED815B3719C1} {2CF78CF7-8FEB-4BE1-9591-55FA25B48FC6} = {1AFB6476-670D-4E80-A464-657E01DFF482} {14AFD976-B4D2-49D0-9E6C-AA93CC061B8A} = {1AFB6476-670D-4E80-A464-657E01DFF482} {9D3F3793-EFE3-4525-8782-238015DABA62} = {66E1534A-1587-42B2-912F-45C994D32904} diff --git a/doc/specs/search-result.png b/doc/specs/search-result.png new file mode 100644 index 0000000000..4acde037e5 Binary files /dev/null and b/doc/specs/search-result.png differ diff --git a/doc/specs/settings-search.md b/doc/specs/settings-search.md new file mode 100644 index 0000000000..e99bb448b6 --- /dev/null +++ b/doc/specs/settings-search.md @@ -0,0 +1,233 @@ +# PowerToys Settings – Search Index (Hard-sealed) + +## 1. What to index + +This section describes the current structure of the settings pages in PowerToys. All user-facing settings are contained in the content of . The logical and visual structure of settings follows a nested layout as shown below: + +```css +SettingsPageControl + └─ SettingsGroup + └─ [SettingsExpander] + └─ SettingsCard +``` +* Each SettingsGroup defines a functional section within a settings page. + +* An optional SettingsExpander may be used to further organize related settings inside a group. + +* Each actual setting is represented by a SettingsCard, which contains one user-tweakable control or a group of closely related controls. + +>Note: Not all SettingsCard are necessarily wrapped in a SettingsExpander; they can exist directly under a SettingsGroup. + +> For indexing purposes, we are specifically targeting all SettingsCard elements. These are the smallest units of user interaction and correspond to individual configurable settings. + +> There could be setting item in expander, so we also need to index expander items as well. + +### Module +Module is a primary type that needs to be indexed, for modules, we need to index the 'ModuleTitle' and the 'ModuleDescription'. +So these two should be passed in by x:Uid and binding with a key. + + +### SettingsCard/SettingsExpander + +Each SettingsCard/SettingsExpander should have an x:Uid for localization and indexing. The associated display strings are defined in the .resw files: + +{x:Uid}.Header – The visible label/title of the setting. +{x:Uid}.Description – (optional) The tooltip or explanatory text. + +The index should be built around these SettingsCard elements and their x:Uid-bound resources, as they represent the actual settings users will search for. + +--- + +## 2. How to Navigate + +### Entry +```csharp +enum EntryType +{ + SettingsPage, + SettingsCard, + SettingsExpander, +} + +public class SearchableElementMetadata +{ + public string PageName { get; set; } // Used to navigate to a specific page + public EntryType Type { get; set; } // Used to know how should we navigate(As a page, a settingscard or an expander?) + public string ParentElementName { get; set; } + public string ElementName { get; set; } + public string ElementUid { get; set; } + public string Icon { get; set; } +} +``` + +### Navigation +The steps for navigate to an item: +* Navigate among pages +* [optional] Expand the expander if setting entry is inside an expander +* [optional] Navigate within page + +> Use page name for navigation: +```csharp +Type GetPageTypeFromPageName(string PageName) +{ + var assembly = typeof(GeneralPage).Assembly; + return assembly.GetType($"Microsoft.PowerToys.Settings.UI.Views.{PageName}"); +} + +NavigationService.Navigate(PageType, ElementName,ParentElementName); +``` + +> Use ElementName and ParentElementName for in page navigation: +```csharp +Page.OnNavigateTo(ElementName, ParentElementName){ + var element = this.FindName(name) as FrameworkElement; + var parentElement = this.FindName(ParentElementName) as FrameworkElement; + + if(parentElement) { + expander = (Expander)parentElement; + if(expander){ + expander.Expand(); + } + + // https://learn.microsoft.com/en-us/uwp/api/windows.ui.xaml.uielement.startbringintoview?view=winrt-26100 + element.StartBringIntoView(); + } +} +``` + +## 3. Runtime Search +When user start typing for an entry, e.g. shortcut or 快捷键(cn version of shortcut), +we need to go through all the entries to see if an entry matches the search text. + +A naive approach will be try to match all the localized text one by one and see if they match. +Total entry is within thousand(To fill in an exact number), performance is acceptable now. +```csharp +// Match +query = UserInput(); +matched = {}; + +indexes = BuildIndex(); + +foreach(var entry in indexes) { + if(entry.Match(query)) { + matched.Add(entry); + } +} +``` + +And we don't intend to introduce complexity on the match algorithm discussion, so let's use powertoys FuzzMatch impl for now. +```csharp +MatchResult Match(this Entry entry, string query) { + return FuzzMatch(entry.DisplayedText, query); +} + +struct MatchResult{ + int Score; + bool Result; +} +``` + +## 4. Search Result Page +![search result page](./search-result.png) +After we got matched items, map these items to a search result page according to spec. +```c# +ObservableCollection ModuleResult; +ObservableCollection GroupedSettingsResults; + +public class SettingsGroup : INotifyPropertyChanged +{ + private string _groupName; + private ObservableCollection _settings; + public string GroupName + { + get => _groupName; + set + { + _groupName = value; + OnPropertyChanged(); + } + } + public ObservableCollection Settings + { + get => _settings; + set + { + _settings = value; + OnPropertyChanged(); + } + } + public event PropertyChangedEventHandler PropertyChanged; + protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } +} +``` + +## 5. How to do Index +### Runtime index or build time index? +Now We need to build all the entries in our settings. + +Most of the entry properties are static, and in runtime, the `SettingsCard` is compiled into native winUI3 controls (I suppose, please correct here if it's wrong), it's hard to locate all the `SettingsCard`, and performance is terrible if we do search for all the pages' elements. + +### Build time indexing +We can rely on xaml file parsing to get all the SettingsCard Entries. +And we don't want xaml file to be brought into production bundle. +Use a project for parsing and bring that index file into production bundle is a solution. +```csproj + + + $(MSBuildProjectDirectory)\..\Settings.UI.XamlIndexBuilder\bin\$(Configuration)\net8.0\XamlIndexBuilder.exe + $(MSBuildProjectDirectory)\Views + $(MSBuildProjectDirectory)\Services\searchable_elements.json + + + +``` +```csharp +for(xamlFile in xamlFiles){ + var doc = Load(xamlFile); + var elements = doc.Descendants(); + + foreach(var element in elements){ + if(element.Name == "SettingsCard") { + var entry = new Entry{ + ElementName = element.Attribute["Name"], + PageName = FileName, + Type = "SettingsCard", + ElementUid = element.Attribute["Uid"], + DisplayedText = "", + } + + var parent = element.GetParent(); + if(parent.Name == "SettingsExpander"){ + entry.ParentElementName = parent.Attribute["Name"]; + } + } + } +} +``` +Runtime index loading: +``` +var entries = LoadEntriesFromFile(); +foreach(var entry in entries){ + entry.DisplayedText = ResourceLoader.GetString(entry.Uid); +} +``` +So now we have all the entries and entry properties. + +## Overall flow: + +![search workflow](./workflow.png) + + +## 6. Corner cases - that have not addressed yet + +1. CmdPal page is not in scope of this effort, that needs additional effort&design to launch and search within cmdpal settings page. + +2. Go back button + +3. Dynamic constructed settings page + - Shortcut guide, with visibility converter + - advanced paste dynamically configured setting items + - powertoys run's extensions \ No newline at end of file diff --git a/doc/specs/workflow.png b/doc/specs/workflow.png new file mode 100644 index 0000000000..6920644fc5 Binary files /dev/null and b/doc/specs/workflow.png differ diff --git a/src/common/Common.Search/Common.Search.csproj b/src/common/Common.Search/Common.Search.csproj new file mode 100644 index 0000000000..b057bd658a --- /dev/null +++ b/src/common/Common.Search/Common.Search.csproj @@ -0,0 +1,8 @@ + + + + + + enable + + diff --git a/src/common/Common.Search/FuzzSearch/MatchOption.cs b/src/common/Common.Search/FuzzSearch/MatchOption.cs new file mode 100644 index 0000000000..9f9f573f42 --- /dev/null +++ b/src/common/Common.Search/FuzzSearch/MatchOption.cs @@ -0,0 +1,10 @@ +// 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 Common.Search.FuzzSearch; + +public class MatchOption +{ + public bool IgnoreCase { get; set; } = true; +} diff --git a/src/common/Common.Search/FuzzSearch/MatchResult.cs b/src/common/Common.Search/FuzzSearch/MatchResult.cs new file mode 100644 index 0000000000..f448f449a7 --- /dev/null +++ b/src/common/Common.Search/FuzzSearch/MatchResult.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. + +namespace Common.Search.FuzzSearch; + +public class MatchResult +{ + /// + /// The raw calculated search score without any search precision filtering applied. + /// + private int _rawScore; + + public MatchResult(bool success, SearchPrecisionScore searchPrecision) + { + Success = success; + SearchPrecision = searchPrecision; + } + + public MatchResult(bool success, SearchPrecisionScore searchPrecision, List matchData, int rawScore) + { + Success = success; + SearchPrecision = searchPrecision; + MatchData = matchData; + RawScore = rawScore; + } + + public bool Success { get; set; } + + /// + /// Gets the final score of the match result with search precision filters applied. + /// + public int Score { get; private set; } + + public int RawScore + { + get => _rawScore; + + set + { + _rawScore = value; + Score = ScoreAfterSearchPrecisionFilter(_rawScore); + } + } + + /// + /// Gets matched data to highlight. + /// + public List MatchData { get; private set; } = new(); + + public SearchPrecisionScore SearchPrecision { get; set; } + + public bool IsSearchPrecisionScoreMet() + { + return IsSearchPrecisionScoreMet(_rawScore); + } + + private bool IsSearchPrecisionScoreMet(int rawScore) + { + return rawScore >= (int)SearchPrecision; + } + + private int ScoreAfterSearchPrecisionFilter(int rawScore) + { + return IsSearchPrecisionScoreMet(rawScore) ? rawScore : 0; + } +} diff --git a/src/common/Common.Search/FuzzSearch/SearchPrecisionScore.cs b/src/common/Common.Search/FuzzSearch/SearchPrecisionScore.cs new file mode 100644 index 0000000000..5f1917f14c --- /dev/null +++ b/src/common/Common.Search/FuzzSearch/SearchPrecisionScore.cs @@ -0,0 +1,12 @@ +// 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 Common.Search.FuzzSearch; + +public enum SearchPrecisionScore +{ + Regular = 50, + Low = 20, + None = 0, +} diff --git a/src/common/Common.Search/FuzzSearch/StringMatcher.cs b/src/common/Common.Search/FuzzSearch/StringMatcher.cs new file mode 100644 index 0000000000..be702bb8d9 --- /dev/null +++ b/src/common/Common.Search/FuzzSearch/StringMatcher.cs @@ -0,0 +1,272 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Globalization; + +namespace Common.Search.FuzzSearch; + +public class StringMatcher +{ + public StringMatcher() + { + } + + private static readonly char[] Separator = [' ']; + + /// + /// Current method: + /// Character matching + substring matching; + /// 1. Query search string is split into substrings, separator is whitespace. + /// 2. Check each query substring's characters against full compare string, + /// 3. if a character in the substring is matched, loop back to verify the previous character. + /// 4. If previous character also matches, and is the start of the substring, update list. + /// 5. Once the previous character is verified, move on to the next character in the query substring. + /// 6. Move onto the next substring's characters until all substrings are checked. + /// 7. Consider success and move onto scoring if every char or substring without whitespaces matched + /// + public static MatchResult FuzzyMatch(string query, string stringToCompare, MatchOption opt = null) + { + opt = opt ?? new MatchOption(); + + if (string.IsNullOrEmpty(stringToCompare)) + { + return new MatchResult(false, SearchPrecisionScore.Regular); + } + + SearchPrecisionScore score = SearchPrecisionScore.Regular; + + var bestResult = new MatchResult(false, score); + + for (int startIndex = 0; startIndex < stringToCompare.Length; startIndex++) + { + MatchResult result = FuzzyMatch(query, stringToCompare, opt, startIndex); + if (result.Success && (!bestResult.Success || result.Score > bestResult.Score)) + { + bestResult = result; + } + } + + return bestResult; + } + + private static MatchResult FuzzyMatch(string query, string stringToCompare, MatchOption opt, int startIndex) + { + if (string.IsNullOrEmpty(stringToCompare) || string.IsNullOrEmpty(query)) + { + return new MatchResult(false, SearchPrecisionScore.Regular); + } + + ArgumentNullException.ThrowIfNull(opt); + + query = query.Trim(); + + // Using InvariantCulture since this is internal + var fullStringToCompareWithoutCase = opt.IgnoreCase ? stringToCompare.ToUpper(CultureInfo.InvariantCulture) : stringToCompare; + var queryWithoutCase = opt.IgnoreCase ? query.ToUpper(CultureInfo.InvariantCulture) : query; + + var querySubstrings = queryWithoutCase.Split(Separator, StringSplitOptions.RemoveEmptyEntries); + int currentQuerySubstringIndex = 0; + var currentQuerySubstring = querySubstrings[currentQuerySubstringIndex]; + var currentQuerySubstringCharacterIndex = 0; + + var firstMatchIndex = -1; + var firstMatchIndexInWord = -1; + var lastMatchIndex = 0; + bool allQuerySubstringsMatched = false; + bool matchFoundInPreviousLoop = false; + bool allSubstringsContainedInCompareString = true; + + var indexList = new List(); + List spaceIndices = new List(); + + for (var compareStringIndex = startIndex; compareStringIndex < fullStringToCompareWithoutCase.Length; compareStringIndex++) + { + // To maintain a list of indices which correspond to spaces in the string to compare + // To populate the list only for the first query substring + if (fullStringToCompareWithoutCase[compareStringIndex].Equals(' ') && currentQuerySubstringIndex == 0) + { + spaceIndices.Add(compareStringIndex); + } + + bool compareResult; + if (opt.IgnoreCase) + { + var fullStringToCompare = fullStringToCompareWithoutCase[compareStringIndex].ToString(); + var querySubstring = currentQuerySubstring[currentQuerySubstringCharacterIndex].ToString(); +#pragma warning disable CA1309 // Use ordinal string comparison (We are looking for a fuzzy match here) + compareResult = string.Compare(fullStringToCompare, querySubstring, CultureInfo.CurrentCulture, CompareOptions.IgnoreCase | CompareOptions.IgnoreNonSpace) != 0; +#pragma warning restore CA1309 // Use ordinal string comparison + } + else + { + compareResult = fullStringToCompareWithoutCase[compareStringIndex] != currentQuerySubstring[currentQuerySubstringCharacterIndex]; + } + + if (compareResult) + { + matchFoundInPreviousLoop = false; + continue; + } + + if (firstMatchIndex < 0) + { + // first matched char will become the start of the compared string + firstMatchIndex = compareStringIndex; + } + + if (currentQuerySubstringCharacterIndex == 0) + { + // first letter of current word + matchFoundInPreviousLoop = true; + firstMatchIndexInWord = compareStringIndex; + } + else if (!matchFoundInPreviousLoop) + { + // we want to verify that there is not a better match if this is not a full word + // in order to do so we need to verify all previous chars are part of the pattern + var startIndexToVerify = compareStringIndex - currentQuerySubstringCharacterIndex; + + if (AllPreviousCharsMatched(startIndexToVerify, currentQuerySubstringCharacterIndex, fullStringToCompareWithoutCase, currentQuerySubstring)) + { + matchFoundInPreviousLoop = true; + + // if it's the beginning character of the first query substring that is matched then we need to update start index + firstMatchIndex = currentQuerySubstringIndex == 0 ? startIndexToVerify : firstMatchIndex; + + indexList = GetUpdatedIndexList(startIndexToVerify, currentQuerySubstringCharacterIndex, firstMatchIndexInWord, indexList); + } + } + + lastMatchIndex = compareStringIndex + 1; + indexList.Add(compareStringIndex); + + currentQuerySubstringCharacterIndex++; + + // if finished looping through every character in the current substring + if (currentQuerySubstringCharacterIndex == currentQuerySubstring.Length) + { + // if any of the substrings was not matched then consider as all are not matched + allSubstringsContainedInCompareString = matchFoundInPreviousLoop && allSubstringsContainedInCompareString; + + currentQuerySubstringIndex++; + + allQuerySubstringsMatched = AllQuerySubstringsMatched(currentQuerySubstringIndex, querySubstrings.Length); + if (allQuerySubstringsMatched) + { + break; + } + + // otherwise move to the next query substring + currentQuerySubstring = querySubstrings[currentQuerySubstringIndex]; + currentQuerySubstringCharacterIndex = 0; + } + } + + // proceed to calculate score if every char or substring without whitespaces matched + if (allQuerySubstringsMatched) + { + var nearestSpaceIndex = CalculateClosestSpaceIndex(spaceIndices, firstMatchIndex); + var score = CalculateSearchScore(query, stringToCompare, firstMatchIndex - nearestSpaceIndex - 1, lastMatchIndex - firstMatchIndex, allSubstringsContainedInCompareString); + + return new MatchResult(true, SearchPrecisionScore.Regular, indexList, score); + } + + return new MatchResult(false, SearchPrecisionScore.Regular); + } + + // To get the index of the closest space which precedes the first matching index + private static int CalculateClosestSpaceIndex(List spaceIndices, int firstMatchIndex) + { + if (spaceIndices.Count == 0) + { + return -1; + } + else + { + return spaceIndices.OrderBy(item => firstMatchIndex - item).Where(item => firstMatchIndex > item).FirstOrDefault(-1); + } + } + + private static bool AllPreviousCharsMatched(int startIndexToVerify, int currentQuerySubstringCharacterIndex, string fullStringToCompareWithoutCase, string currentQuerySubstring) + { + var allMatch = true; + for (int indexToCheck = 0; indexToCheck < currentQuerySubstringCharacterIndex; indexToCheck++) + { + if (fullStringToCompareWithoutCase[startIndexToVerify + indexToCheck] != + currentQuerySubstring[indexToCheck]) + { + allMatch = false; + } + } + + return allMatch; + } + + private static List GetUpdatedIndexList(int startIndexToVerify, int currentQuerySubstringCharacterIndex, int firstMatchIndexInWord, List indexList) + { + var updatedList = new List(); + + indexList.RemoveAll(x => x >= firstMatchIndexInWord); + + updatedList.AddRange(indexList); + + for (int indexToCheck = 0; indexToCheck < currentQuerySubstringCharacterIndex; indexToCheck++) + { + updatedList.Add(startIndexToVerify + indexToCheck); + } + + return updatedList; + } + + private static bool AllQuerySubstringsMatched(int currentQuerySubstringIndex, int querySubstringsLength) + { + return currentQuerySubstringIndex >= querySubstringsLength; + } + + private static int CalculateSearchScore(string query, string stringToCompare, int firstIndex, int matchLen, bool allSubstringsContainedInCompareString) + { + // A match found near the beginning of a string is scored more than a match found near the end + // A match is scored more if the characters in the patterns are closer to each other, + // while the score is lower if they are more spread out + + // The length of the match is assigned a larger weight factor. + const int matchLenWeightFactor = 2; + + var score = 100 * (query.Length + 1) * matchLenWeightFactor / (1 + firstIndex + (matchLenWeightFactor * (matchLen + 1))); + + // A match with less characters assigning more weights + if (stringToCompare.Length - query.Length < 5) + { + score += 20; + } + else if (stringToCompare.Length - query.Length < 10) + { + score += 10; + } + + if (allSubstringsContainedInCompareString) + { + int count = query.Count(c => !char.IsWhiteSpace(c)); + int threshold = 4; + if (count <= threshold) + { + score += count * 10; + } + else + { + score += (threshold * 10) + ((count - threshold) * 5); + } + } + +#pragma warning disable CA1309 // Use ordinal string comparison (Using CurrentCultureIgnoreCase since this relates to queries input by user) + if (string.Equals(query, stringToCompare, StringComparison.CurrentCultureIgnoreCase)) + { + var bonusForExactMatch = 10; + score += bonusForExactMatch; + } +#pragma warning restore CA1309 // Use ordinal string comparison + + return score; + } +} diff --git a/src/common/Common.Search/GlobalSuppressions.cs b/src/common/Common.Search/GlobalSuppressions.cs new file mode 100644 index 0000000000..c1ccfc6882 --- /dev/null +++ b/src/common/Common.Search/GlobalSuppressions.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. + +// This file is used by Code Analysis to maintain SuppressMessage +// attributes that are applied to this project. +// Project-level suppressions either have no target or are given +// a specific target and scoped to a namespace, type, member, etc. +using System.Diagnostics.CodeAnalysis; + +[assembly: SuppressMessage("StyleCop.CSharp.NamingRules", "SA1309:Field names should not begin with underscore", Justification = "coding style", Scope = "member", Target = "~F:Common.Search.MatchResult._rawScore")] +[assembly: SuppressMessage("StyleCop.CSharp.NamingRules", "SA1309:Field names should not begin with underscore", Justification = "coding style", Scope = "member", Target = "~F:Common.Search.StringMatcher._defaultMatchOption")] +[assembly: SuppressMessage("StyleCop.CSharp.NamingRules", "SA1309:Field names should not begin with underscore", Justification = "coding style", Scope = "member", Target = "~F:Common.Search.StringMatcher._instance")] +[assembly: SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1101:Prefix local calls with this", Justification = "coding style", Scope = "member", Target = "~M:Common.Search.MatchResult.#ctor(System.Boolean,Common.Search.SearchPrecisionScore)")] +[assembly: SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1101:Prefix local calls with this", Justification = "coding style", Scope = "member", Target = "~M:Common.Search.MatchResult.#ctor(System.Boolean,Common.Search.SearchPrecisionScore,System.Collections.Generic.List{System.Int32},System.Int32)")] +[assembly: SuppressMessage("Compiler", "CS8618:Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.", Justification = "Coding style", Scope = "member", Target = "~F:Common.Search.StringMatcher._instance")] +[assembly: SuppressMessage("StyleCop.CSharp.OrderingRules", "SA1201:Elements should appear in the correct order", Justification = "coding style", Scope = "member", Target = "~F:Common.Search.StringMatcher._instance")] +[assembly: SuppressMessage("StyleCop.CSharp.OrderingRules", "SA1201:Elements should appear in the correct order", Justification = "coding style", Scope = "member", Target = "~F:Common.Search.StringMatcher.Separator")] +[assembly: SuppressMessage("StyleCop.CSharp.OrderingRules", "SA1201:Elements should appear in the correct order", Justification = "coding style", Scope = "member", Target = "~M:Common.Search.StringMatcher.#ctor")] +[assembly: SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1101:Prefix local calls with this", Justification = "coding style", Scope = "member", Target = "~M:Common.Search.StringMatcher.FuzzyMatch(System.String,System.String)~Common.Search.MatchResult")] +[assembly: SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1101:Prefix local calls with this", Justification = "coding style", Scope = "member", Target = "~M:Common.Search.StringMatcher.FuzzyMatch(System.String,System.String,Common.Search.MatchOption)~Common.Search.MatchResult")] +[assembly: SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1407:Arithmetic expressions should declare precedence", Justification = "migrate from stable code", Scope = "member", Target = "~M:Common.Search.StringMatcher.CalculateSearchScore(System.String,System.String,System.Int32,System.Int32,System.Boolean)~System.Int32")] +[assembly: SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1101:Prefix local calls with this", Justification = "migrate from stable code", Scope = "member", Target = "~M:Common.Search.StringMatcher.FuzzyMatch(System.String,System.String,Common.Search.MatchOption,System.Int32)~Common.Search.MatchResult")] +[assembly: SuppressMessage("StyleCop.CSharp.OrderingRules", "SA1204:Static elements should appear before instance elements", Justification = "migrate from stable code", Scope = "member", Target = "~M:Common.Search.StringMatcher.CalculateClosestSpaceIndex(System.Collections.Generic.List{System.Int32},System.Int32)~System.Int32")] diff --git a/src/common/Common.Search/stylecop.json b/src/common/Common.Search/stylecop.json new file mode 100644 index 0000000000..a338998bbc --- /dev/null +++ b/src/common/Common.Search/stylecop.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://raw.githubusercontent.com/DotNetAnalyzers/StyleCopAnalyzers/master/StyleCop.Analyzers/StyleCop.Analyzers/Settings/stylecop.schema.json", + "settings": { + "documentationRules": { + "companyName": "Microsoft Corporation", + "copyrightText": "Copyright (c) {companyName}\r\nThe {companyName} licenses this file to you under the MIT license.\r\nSee the LICENSE file in the project root for more information.", + "xmlHeader": false, + "headerDecoration": "", + "fileNamingConvention": "metadata", + "documentInterfaces": false, + "documentExposedElements": false, + "documentInternalElements": false + }, + "layoutRules": { + "newlineAtEndOfFile": "require" + }, + "orderingRules": { + "usingDirectivesPlacement": "outsideNamespace", + "systemUsingDirectivesFirst": true + } + } +} \ No newline at end of file diff --git a/src/settings-ui/Settings.UI.Library/SettingEntry.cs b/src/settings-ui/Settings.UI.Library/SettingEntry.cs new file mode 100644 index 0000000000..8f5f6ed0da --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/SettingEntry.cs @@ -0,0 +1,44 @@ +// 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 Settings.UI.Library +{ + public enum EntryType + { + SettingsPage, + SettingsCard, + SettingsExpander, + } + + public struct SettingEntry + { + public EntryType Type { get; set; } + + public string Header { get; set; } + + public string PageTypeName { get; set; } + + public string ElementName { get; set; } + + public string ElementUid { get; set; } + + public string ParentElementName { get; set; } + + public string Description { get; set; } + + public string Icon { get; set; } + + public SettingEntry(EntryType type, string header, string pageTypeName, string elementName, string elementUid, string parentElementName = null, string description = null, string icon = null) + { + Type = type; + Header = header; + PageTypeName = pageTypeName; + ElementName = elementName; + ElementUid = elementUid; + ParentElementName = parentElementName; + Description = description; + Icon = icon; + } + } +} diff --git a/src/settings-ui/Settings.UI.XamlIndexBuilder/ModuleIconResolver.cs b/src/settings-ui/Settings.UI.XamlIndexBuilder/ModuleIconResolver.cs new file mode 100644 index 0000000000..43c3ed2b05 --- /dev/null +++ b/src/settings-ui/Settings.UI.XamlIndexBuilder/ModuleIconResolver.cs @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.IO; +using System.Linq; +using System.Xml.Linq; + +namespace Microsoft.PowerToys.Tools.XamlIndexBuilder +{ + public static class ModuleIconResolver + { + // Hardcoded page-level overrides for module -> icon path + private static readonly System.Collections.Generic.Dictionary FileNameOverrides = new System.Collections.Generic.Dictionary(System.StringComparer.OrdinalIgnoreCase) + { + // Example overrides; expand as needed + { "FancyZonesPage.xaml", "/Assets/Settings/Icons/FancyZones.png" }, + { "FileLocksmithPage.xaml", "/Assets/Settings/Icons/FileLocksmith.png" }, + { "CmdNotFoundPage.xaml", "/Assets/Settings/Icons/CommandNotFound.png" }, + { "PowerLauncherPage.xaml", "/Assets/Settings/Icons/PowerToysRun.png" }, + }; + + // Contract: + // - Input: absolute path to the module XAML file (e.g., FancyZonesPage.xaml) + // - Output: app-relative icon path (e.g., "/Assets/Settings/Icons/FancyZones.png"), or null if not found + // - Strategy: take the first SettingsCard under the page and read its HeaderIcon value + public static string ResolveIconFromFirstSettingsCard(string xamlFilePath) + { + if (string.IsNullOrWhiteSpace(xamlFilePath)) + { + return null; + } + + try + { + var doc = XDocument.Load(xamlFilePath); + + // Prefer looking inside SettingsPageControl.ModuleContent to avoid picking cards in Resources/DataTemplates + var pageControl = doc.Descendants().FirstOrDefault(e => e.Name.LocalName == "SettingsPageControl"); + + if (pageControl != null) + { + // Locate the property element + var moduleContent = pageControl + .Elements() + .FirstOrDefault(e => e.Name.LocalName.EndsWith(".ModuleContent", System.StringComparison.OrdinalIgnoreCase)) + ?? pageControl + .Descendants() + .FirstOrDefault(e => e.Name.LocalName.EndsWith(".ModuleContent", System.StringComparison.OrdinalIgnoreCase)); + + if (moduleContent != null) + { + // Find the first SettingsCard under ModuleContent and try to read its HeaderIcon + var firstCardUnderModule = moduleContent + .Descendants() + .FirstOrDefault(e => e.Name.LocalName == "SettingsCard"); + + if (firstCardUnderModule != null) + { + var icon = Program.ExtractIconValue(firstCardUnderModule); + if (!string.IsNullOrWhiteSpace(icon)) + { + return icon; + } + } + } + } + + // Fallback to hardcoded overrides by file name + var fileName = Path.GetFileName(xamlFilePath); + if (!string.IsNullOrEmpty(fileName) && FileNameOverrides.TryGetValue(fileName, out var overrideIcon)) + { + return overrideIcon; + } + + return null; + } + catch + { + // Non-fatal: let caller decide fallback + return null; + } + } + } +} diff --git a/src/settings-ui/Settings.UI.XamlIndexBuilder/Program.cs b/src/settings-ui/Settings.UI.XamlIndexBuilder/Program.cs new file mode 100644 index 0000000000..d3ebad0ff7 --- /dev/null +++ b/src/settings-ui/Settings.UI.XamlIndexBuilder/Program.cs @@ -0,0 +1,314 @@ +// 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.IO; +using System.Linq; +using System.Text.Json; +using System.Xml.Linq; + +namespace Microsoft.PowerToys.Tools.XamlIndexBuilder +{ + public class Program + { + private static readonly HashSet ExcludedXamlFiles = new(StringComparer.OrdinalIgnoreCase) + { + "ShellPage.xaml", + }; + + private static JsonSerializerOptions serializeOption = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + public static void Main(string[] args) + { + if (args.Length < 2) + { + Debug.WriteLine("Usage: XamlIndexBuilder "); + Environment.Exit(1); + } + + string xamlDirectory = args[0]; + string outputFile = args[1]; + + if (!Directory.Exists(xamlDirectory)) + { + Debug.WriteLine($"Error: Directory '{xamlDirectory}' does not exist."); + Environment.Exit(1); + } + + try + { + var searchableElements = new List(); + var xamlFiles = Directory.GetFiles(xamlDirectory, "*.xaml", SearchOption.AllDirectories); + + foreach (var xamlFile in xamlFiles) + { + var fileName = Path.GetFileName(xamlFile); + if (ExcludedXamlFiles.Contains(fileName)) + { + // Skip ShellPage.xaml as it contains many elements not relevant for search + continue; + } + + Debug.WriteLine($"Processing: {fileName}"); + var elements = ExtractSearchableElements(xamlFile); + searchableElements.AddRange(elements); + } + + searchableElements = searchableElements.OrderBy(e => e.PageTypeName).ThenBy(e => e.ElementName).ToList(); + + string json = JsonSerializer.Serialize(searchableElements, serializeOption); + File.WriteAllText(outputFile, json); + + Debug.WriteLine($"Successfully generated index with {searchableElements.Count} elements."); + Debug.WriteLine($"Output written to: {outputFile}"); + } + catch (Exception ex) + { + Debug.WriteLine($"Error: {ex.Message}"); + Environment.Exit(1); + } + } + + public static List ExtractSearchableElements(string xamlFile) + { + var elements = new List(); + string pageName = Path.GetFileNameWithoutExtension(xamlFile); + + try + { + // Load XAML as XML + var doc = XDocument.Load(xamlFile); + + // Define namespaces + XNamespace x = "http://schemas.microsoft.com/winfx/2006/xaml"; + XNamespace controls = "http://schemas.microsoft.com/winfx/2006/xaml/presentation"; + XNamespace labs = "using:CommunityToolkit.Labs.WinUI"; + XNamespace winui = "using:CommunityToolkit.WinUI.UI.Controls"; + + // Extract SettingsPageControl elements + var settingsPageElements = doc.Descendants() + .Where(e => e.Name.LocalName == "SettingsPageControl") + .Where(e => e.Attribute(x + "Uid") != null); + + // Extract SettingsCard elements + var settingsElements = doc.Descendants() + .Where(e => e.Name.LocalName == "SettingsCard") + .Where(e => e.Attribute("Name") != null || e.Attribute(x + "Uid") != null); + + // Extract SettingsExpander elements + var settingsExpanderElements = doc.Descendants() + .Where(e => e.Name.LocalName == "SettingsExpander") + .Where(e => e.Attribute("Name") != null || e.Attribute(x + "Uid") != null); + + // Process SettingsPageControl elements + foreach (var element in settingsPageElements) + { + var elementUid = GetElementUid(element, x); + + // Prefer the first SettingsCard.HeaderIcon as the module icon + var moduleImageSource = ModuleIconResolver.ResolveIconFromFirstSettingsCard(xamlFile); + + if (!string.IsNullOrEmpty(elementUid)) + { + elements.Add(new SettingEntry + { + PageTypeName = pageName, + Type = EntryType.SettingsPage, + ParentElementName = string.Empty, + ElementName = string.Empty, + ElementUid = elementUid, + Icon = moduleImageSource, + }); + } + } + + // Process SettingsCard elements + foreach (var element in settingsElements) + { + var elementName = GetElementName(element, x); + var elementUid = GetElementUid(element, x); + var headerIcon = ExtractIconValue(element); + + if (!string.IsNullOrEmpty(elementName) || !string.IsNullOrEmpty(elementUid)) + { + var parentElementName = GetParentElementName(element, x); + + elements.Add(new SettingEntry + { + PageTypeName = pageName, + Type = EntryType.SettingsCard, + ParentElementName = parentElementName, + ElementName = elementName, + ElementUid = elementUid, + Icon = headerIcon, + }); + } + } + + // Process SettingsExpander elements + foreach (var element in settingsExpanderElements) + { + var elementName = GetElementName(element, x); + var elementUid = GetElementUid(element, x); + var headerIcon = ExtractIconValue(element); + + if (!string.IsNullOrEmpty(elementName) || !string.IsNullOrEmpty(elementUid)) + { + var parentElementName = GetParentElementName(element, x); + + elements.Add(new SettingEntry + { + PageTypeName = pageName, + Type = EntryType.SettingsExpander, + ParentElementName = parentElementName, + ElementName = elementName, + ElementUid = elementUid, + Icon = headerIcon, + }); + } + } + } + catch (Exception ex) + { + Debug.WriteLine($"Error processing {xamlFile}: {ex.Message}"); + } + + return elements; + } + + public static string GetElementName(XElement element, XNamespace x) + { + // Get Name attribute (we call it ElementName in our indexing system) + var name = element.Attribute("Name")?.Value; + return name; + } + + public static string GetElementUid(XElement element, XNamespace x) + { + // Try x:Uid + var uid = element.Attribute(x + "Uid")?.Value; + return uid; + } + + public static string GetParentElementName(XElement element, XNamespace x) + { + // Look for parent SettingsExpander + var current = element.Parent; + while (current != null) + { + // Check if we're inside a SettingsExpander.Items or just directly inside SettingsExpander + if (current.Name.LocalName == "Items") + { + // Check if the parent of Items is SettingsExpander + var expanderParent = current.Parent; + if (expanderParent?.Name.LocalName == "SettingsExpander") + { + var expanderName = expanderParent.Attribute("Name")?.Value; + if (!string.IsNullOrEmpty(expanderName)) + { + return expanderName; + } + } + } + else if (current.Name.LocalName == "SettingsExpander") + { + // Direct child of SettingsExpander + var expanderName = current.Attribute("Name")?.Value; + if (!string.IsNullOrEmpty(expanderName)) + { + return expanderName; + } + } + + current = current.Parent; + } + + return string.Empty; + } + + public static string ExtractIconValue(XElement element) + { + var headerIconAttribute = element.Attribute("HeaderIcon")?.Value; + + if (string.IsNullOrEmpty(headerIconAttribute)) + { + // Try nested property element: ... + var headerIconProperty = element.Elements() + .FirstOrDefault(e => e.Name.LocalName.EndsWith(".HeaderIcon", StringComparison.OrdinalIgnoreCase)); + + if (headerIconProperty != null) + { + // Prefer explicit icon elements within the HeaderIcon property + var pathIcon = headerIconProperty.Descendants().FirstOrDefault(d => d.Name.LocalName == "PathIcon"); + if (pathIcon != null) + { + var dataAttr = pathIcon.Attribute("Data")?.Value; + if (!string.IsNullOrWhiteSpace(dataAttr)) + { + return dataAttr.Trim(); + } + } + + var fontIcon = headerIconProperty.Descendants().FirstOrDefault(d => d.Name.LocalName == "FontIcon"); + if (fontIcon != null) + { + var glyphAttr = fontIcon.Attribute("Glyph")?.Value; + if (!string.IsNullOrWhiteSpace(glyphAttr)) + { + return glyphAttr.Trim(); + } + } + + var bitmapIcon = headerIconProperty.Descendants().FirstOrDefault(d => d.Name.LocalName == "BitmapIcon"); + if (bitmapIcon != null) + { + var sourceAttr = bitmapIcon.Attribute("Source")?.Value; + if (!string.IsNullOrWhiteSpace(sourceAttr)) + { + return sourceAttr.Trim(); + } + } + } + + return null; + } + + // Parse different icon markup extensions + // Example: {ui:BitmapIcon Source=/Assets/Settings/Icons/AlwaysOnTop.png} + if (headerIconAttribute.Contains("BitmapIcon") && headerIconAttribute.Contains("Source=")) + { + var sourceStart = headerIconAttribute.IndexOf("Source=", StringComparison.OrdinalIgnoreCase) + "Source=".Length; + var sourceEnd = headerIconAttribute.IndexOf('}', sourceStart); + if (sourceEnd == -1) + { + sourceEnd = headerIconAttribute.Length; + } + + return headerIconAttribute.Substring(sourceStart, sourceEnd - sourceStart).Trim(); + } + + // Example: {ui:FontIcon Glyph=} + if (headerIconAttribute.Contains("FontIcon") && headerIconAttribute.Contains("Glyph=")) + { + var glyphStart = headerIconAttribute.IndexOf("Glyph=", StringComparison.OrdinalIgnoreCase) + "Glyph=".Length; + var glyphEnd = headerIconAttribute.IndexOf('}', glyphStart); + if (glyphEnd == -1) + { + glyphEnd = headerIconAttribute.Length; + } + + return headerIconAttribute.Substring(glyphStart, glyphEnd - glyphStart).Trim(); + } + + // If it doesn't match known patterns, return the original value + return headerIconAttribute; + } + } +} diff --git a/src/settings-ui/Settings.UI.XamlIndexBuilder/SettingEntry.cs b/src/settings-ui/Settings.UI.XamlIndexBuilder/SettingEntry.cs new file mode 100644 index 0000000000..0a95f8e816 --- /dev/null +++ b/src/settings-ui/Settings.UI.XamlIndexBuilder/SettingEntry.cs @@ -0,0 +1,44 @@ +// 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.PowerToys.Tools.XamlIndexBuilder +{ + public enum EntryType + { + SettingsPage, + SettingsCard, + SettingsExpander, + } + + public struct SettingEntry + { + public EntryType Type { get; set; } + + public string Header { get; set; } + + public string PageTypeName { get; set; } + + public string ElementName { get; set; } + + public string ElementUid { get; set; } + + public string ParentElementName { get; set; } + + public string Description { get; set; } + + public string Icon { get; set; } + + public SettingEntry(EntryType type, string header, string pageTypeName, string elementName, string elementUid, string parentElementName = null, string description = null, string icon = null) + { + Type = type; + Header = header; + PageTypeName = pageTypeName; + ElementName = elementName; + ElementUid = elementUid; + ParentElementName = parentElementName; + Description = description; + Icon = icon; + } + } +} diff --git a/src/settings-ui/Settings.UI.XamlIndexBuilder/Settings.UI.XamlIndexBuilder.csproj b/src/settings-ui/Settings.UI.XamlIndexBuilder/Settings.UI.XamlIndexBuilder.csproj new file mode 100644 index 0000000000..ef37b3eca9 --- /dev/null +++ b/src/settings-ui/Settings.UI.XamlIndexBuilder/Settings.UI.XamlIndexBuilder.csproj @@ -0,0 +1,44 @@ + + + + + + net9.0 + Exe + Microsoft.PowerToys.Tools.XamlIndexBuilder + XamlIndexBuilder + + false + false + AnyCPU + + + $(MSBuildProjectDirectory)\obj\XamlIndexBuilder\$(Configuration)\ + + + + + + + + + + + + + + + + + dotnet + $(MSBuildProjectDirectory)\..\Settings.UI\SettingsXAML\Views + $(MSBuildProjectDirectory)\..\Settings.UI\Assets\Settings\search.index.json + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/Converters/IconConverter.cs b/src/settings-ui/Settings.UI/Converters/IconConverter.cs new file mode 100644 index 0000000000..45ba530e02 --- /dev/null +++ b/src/settings-ui/Settings.UI/Converters/IconConverter.cs @@ -0,0 +1,133 @@ +// 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.Controls; +using Microsoft.UI.Xaml.Data; +using Microsoft.UI.Xaml.Markup; +using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Media.Imaging; + +namespace Microsoft.PowerToys.Settings.UI.Converters +{ + public partial class IconConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, string language) + { + if (value is not string iconValue || string.IsNullOrEmpty(iconValue)) + { + // Return a default icon based on the parameter + var defaultGlyph = parameter?.ToString() ?? "\uE8B7"; // Default gear icon + return new FontIcon { Glyph = defaultGlyph }; + } + + // Check if it's a single Unicode character (most common case after JSON deserialization) + if (iconValue.Length == 1) + { + return new FontIcon { Glyph = iconValue }; + } + + // Handle HTML numeric character references, e.g. "" or "" + if (iconValue.StartsWith("&#", StringComparison.Ordinal) && iconValue.EndsWith(';')) + { + var inner = iconValue.Substring(2, iconValue.Length - 3); // strip &# and ; + try + { + string glyph; + if (inner.StartsWith("x", StringComparison.OrdinalIgnoreCase)) + { + var hex = inner.Substring(1); + if (int.TryParse(hex, System.Globalization.NumberStyles.HexNumber, null, out int codePointHex)) + { + glyph = char.ConvertFromUtf32(codePointHex); + return new FontIcon { Glyph = glyph }; + } + } + else if (int.TryParse(inner, out int codePointDec)) + { + glyph = char.ConvertFromUtf32(codePointDec); + return new FontIcon { Glyph = glyph }; + } + } + catch + { + // fall through to other handlers + } + } + + if (iconValue.StartsWith("\\u", StringComparison.OrdinalIgnoreCase) && iconValue.Length == 6) + { + var hexPart = iconValue.Substring(2); // Remove \u + if (int.TryParse(hexPart, System.Globalization.NumberStyles.HexNumber, null, out int codePoint)) + { + var unicodeChar = char.ConvertFromUtf32(codePoint); + return new FontIcon { Glyph = unicodeChar }; + } + } + + // Check if it's an image path + if (iconValue.Contains('/') || iconValue.Contains('\\') || iconValue.Contains(".png", StringComparison.OrdinalIgnoreCase) || iconValue.Contains(".jpg", StringComparison.OrdinalIgnoreCase) || iconValue.Contains(".ico", StringComparison.OrdinalIgnoreCase) || iconValue.Contains(".svg", StringComparison.OrdinalIgnoreCase)) + { + // Handle different path formats + var imagePath = iconValue; + + // Convert ms-appx:/// paths to local paths + if (imagePath.StartsWith("ms-appx:///", StringComparison.OrdinalIgnoreCase)) + { + imagePath = imagePath.Substring("ms-appx:///".Length); + } + + // Ensure path starts with / + if (!imagePath.StartsWith('/')) + { + imagePath = "/" + imagePath; + } + + var uri = new Uri($"ms-appx://{imagePath}"); + + if (imagePath.EndsWith(".svg", StringComparison.OrdinalIgnoreCase)) + { + // Render SVG using ImageIcon + SvgImageSource + return new ImageIcon + { + Source = new SvgImageSource(uri), + }; + } + else + { + return new BitmapIcon + { + UriSource = uri, + ShowAsMonochrome = false, + }; + } + } + + // Try to interpret as raw SVG path data (PathIcon.Data) + // Many of our XAML PathIcon usages (e.g., AdvancedPastePage) provide a Data string like "M128 766q0-42 ...". + // If parsing succeeds, render it as a PathIcon. + try + { + var geometryObj = XamlBindingHelper.ConvertValue(typeof(Geometry), iconValue); + if (geometryObj is Geometry geometry) + { + return new PathIcon { Data = geometry }; + } + } + catch + { + // Ignore parse errors and fall back below. + } + + // If all else fails, return default icon + var fallbackGlyph = parameter?.ToString() ?? "\uE8B7"; + return new FontIcon { Glyph = fallbackGlyph }; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/settings-ui/Settings.UI/Converters/SearchSuggestionTemplateSelector.cs b/src/settings-ui/Settings.UI/Converters/SearchSuggestionTemplateSelector.cs new file mode 100644 index 0000000000..11320baaea --- /dev/null +++ b/src/settings-ui/Settings.UI/Converters/SearchSuggestionTemplateSelector.cs @@ -0,0 +1,40 @@ +// 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.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Settings.UI.ViewModels; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace Microsoft.PowerToys.Settings.UI.Converters; + +public sealed partial class SearchSuggestionTemplateSelector : DataTemplateSelector +{ + public DataTemplate DefaultSuggestionTemplate { get; set; } + + public DataTemplate NoResultsSuggestionTemplate { get; set; } + + public DataTemplate ShowAllSuggestionTemplate { get; set; } + + protected override DataTemplate SelectTemplateCore(object item, DependencyObject container) + { + if (item is SuggestionItem suggestionItem) + { + if (suggestionItem.IsNoResults) + { + return NoResultsSuggestionTemplate; + } + + if (suggestionItem.IsShowAll) + { + return ShowAllSuggestionTemplate ?? NoResultsSuggestionTemplate ?? DefaultSuggestionTemplate; + } + + return DefaultSuggestionTemplate; + } + + return DefaultSuggestionTemplate; + } +} diff --git a/src/settings-ui/Settings.UI/Converters/UriToImageConverter.cs b/src/settings-ui/Settings.UI/Converters/UriToImageConverter.cs new file mode 100644 index 0000000000..dc079f5d01 --- /dev/null +++ b/src/settings-ui/Settings.UI/Converters/UriToImageConverter.cs @@ -0,0 +1,25 @@ +// 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.UI.Xaml.Data; +using Microsoft.UI.Xaml.Media.Imaging; + +namespace Microsoft.PowerToys.Settings.UI.Converters +{ + public sealed partial class UriToImageSourceConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, string language) + { + return value is Uri uri ? new BitmapImage(uri) : null; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + => throw new NotImplementedException(); + } +} diff --git a/src/settings-ui/Settings.UI/Helpers/NavigatablePage.cs b/src/settings-ui/Settings.UI/Helpers/NavigatablePage.cs new file mode 100644 index 0000000000..126b4f2105 --- /dev/null +++ b/src/settings-ui/Settings.UI/Helpers/NavigatablePage.cs @@ -0,0 +1,144 @@ +// 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.Numerics; +using System.Threading.Tasks; +using CommunityToolkit.WinUI.Controls; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Hosting; +using Microsoft.UI.Xaml.Media; + +namespace Microsoft.PowerToys.Settings.UI.Helpers; + +public abstract partial class NavigatablePage : Page +{ + private const int ExpandWaitDuration = 500; + private const int AnimationDuration = 1000; + + private NavigationParams _pendingNavigationParams; + + public NavigatablePage() + { + Loaded += OnPageLoaded; + } + + protected override void OnNavigatedTo(Microsoft.UI.Xaml.Navigation.NavigationEventArgs e) + { + base.OnNavigatedTo(e); + + // Handle both old string parameter and new NavigationParams + if (e.Parameter is NavigationParams navParams) + { + _pendingNavigationParams = navParams; + } + else if (e.Parameter is string elementKey) + { + _pendingNavigationParams = new NavigationParams(elementKey); + } + } + + private async void OnPageLoaded(object sender, RoutedEventArgs e) + { + if (_pendingNavigationParams != null && !string.IsNullOrEmpty(_pendingNavigationParams.ElementName)) + { + // First, expand parent if specified + if (!string.IsNullOrEmpty(_pendingNavigationParams.ParentElementName)) + { + var parentElement = FindElementByName(_pendingNavigationParams.ParentElementName); + if (parentElement is SettingsExpander expander) + { + expander.IsExpanded = true; + + // Give time for the expander to animate + await Task.Delay(ExpandWaitDuration); + } + } + + // Then find and navigate to the target element + var target = FindElementByName(_pendingNavigationParams.ElementName); + + target?.StartBringIntoView(new BringIntoViewOptions + { + VerticalOffset = -20, + AnimationDesired = true, + }); + + await OnTargetElementNavigatedAsync(target, _pendingNavigationParams.ElementName); + + _pendingNavigationParams = null; + } + } + + protected virtual async Task OnTargetElementNavigatedAsync(FrameworkElement target, string elementKey) + { + if (target == null) + { + return; + } + + // Get the visual and compositor + var visual = ElementCompositionPreview.GetElementVisual(target); + var compositor = visual.Compositor; + + // Create a subtle glow effect using drop shadow + var dropShadow = compositor.CreateDropShadow(); + dropShadow.Color = Microsoft.UI.Colors.Gray; + dropShadow.BlurRadius = 8f; + dropShadow.Opacity = 0f; + dropShadow.Offset = new Vector3(0, 0, 0); + + var spriteVisual = compositor.CreateSpriteVisual(); + spriteVisual.Size = new Vector2((float)target.ActualWidth + 16, (float)target.ActualHeight + 16); + spriteVisual.Shadow = dropShadow; + spriteVisual.Offset = new Vector3(-8, -8, 0); + + // Insert the shadow visual behind the target element + ElementCompositionPreview.SetElementChildVisual(target, spriteVisual); + + // Create a simple fade in/out animation + var fadeAnimation = compositor.CreateScalarKeyFrameAnimation(); + fadeAnimation.InsertKeyFrame(0f, 0f); + fadeAnimation.InsertKeyFrame(0.5f, 0.3f); + fadeAnimation.InsertKeyFrame(1f, 0f); + fadeAnimation.Duration = TimeSpan.FromMilliseconds(AnimationDuration); + + dropShadow.StartAnimation("Opacity", fadeAnimation); + + if (target is Control ctrl) + { + // TODO: ability to adjust brush color and animation from settings. + var originalBackground = ctrl.Background; + + var highlightBrush = new SolidColorBrush(); + var grayColor = Microsoft.UI.Colors.Gray; + grayColor.A = 50; // Very subtle transparency + highlightBrush.Color = grayColor; + + // Apply the highlight + ctrl.Background = highlightBrush; + + // Wait for animation to complete + await Task.Delay(AnimationDuration); + + // Restore original background + ctrl.Background = originalBackground; + } + else + { + // For non-control elements, just wait for the glow animation + await Task.Delay(AnimationDuration); + } + + // Clean up the shadow visual + ElementCompositionPreview.SetElementChildVisual(target, null); + } + + protected FrameworkElement FindElementByName(string name) + { + var element = this.FindName(name) as FrameworkElement; + return element; + } +} diff --git a/src/settings-ui/Settings.UI/Helpers/NavigationParams.cs b/src/settings-ui/Settings.UI/Helpers/NavigationParams.cs new file mode 100644 index 0000000000..30c27343ce --- /dev/null +++ b/src/settings-ui/Settings.UI/Helpers/NavigationParams.cs @@ -0,0 +1,18 @@ +// 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.PowerToys.Settings.UI.Helpers; + +public class NavigationParams +{ + public string ElementName { get; set; } + + public string ParentElementName { get; set; } + + public NavigationParams(string elementName, string parentElementName = null) + { + ElementName = elementName; + ParentElementName = parentElementName; + } +} diff --git a/src/settings-ui/Settings.UI/PowerToys.Settings.csproj b/src/settings-ui/Settings.UI/PowerToys.Settings.csproj index 13aef6df63..aaa1c2bdc7 100644 --- a/src/settings-ui/Settings.UI/PowerToys.Settings.csproj +++ b/src/settings-ui/Settings.UI/PowerToys.Settings.csproj @@ -1,161 +1,193 @@  - - - + + + - - WinExe - Microsoft.PowerToys.Settings.UI - app.manifest - true - true - None - false - false - Assets\Settings\icon.ico - true - - ..\..\..\$(Platform)\$(Configuration)\WinUI3Apps - - PowerToys.Settings.pri - - - - - - - - - - - - - - + + WinExe + Microsoft.PowerToys.Settings.UI + app.manifest + true + true + None + false + false + Assets\Settings\icon.ico + true + + ..\..\..\$(Platform)\$(Configuration)\WinUI3Apps + + PowerToys.Settings.pri + + + + + + + + + + + + + + - - - PowerToys.GPOWrapper;PowerToys.ZoomItSettingsInterop - $(OutDir) - false - + + + PowerToys.GPOWrapper;PowerToys.ZoomItSettingsInterop + $(OutDir) + false + - - - - - - - - - + + + + + + + + + - - - + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + + + - - - - - - - - - - - - + + + + + + + + + + + + + - - - - - VSTHRD002;VSTHRD110;VSTHRD100;VSTHRD200;VSTHRD101 - + + + $(MSBuildProjectDirectory)\Assets\Settings\search.index.json + - - - Always - - + - - - Always - - - Always - - - Always - - - Always - - - Always - - - MSBuild:Compile - - - MSBuild:Compile - - - MSBuild:Compile - - + + + + + VSTHRD002;VSTHRD110;VSTHRD100;VSTHRD200;VSTHRD101 + - - - $(DefaultXamlRuntime) - - - $(DefaultXamlRuntime) - - - $(DefaultXamlRuntime) - - + - + + + + Always + + + + Microsoft.PowerToys.Settings.UI.Assets.search.index.json + + + + + + Always + + + + + + Always + + + Always + + + Always + + + Always + + + Always + + + MSBuild:Compile + + + MSBuild:Compile + + + MSBuild:Compile + + + + + + $(DefaultXamlRuntime) + + + $(DefaultXamlRuntime) + + + $(DefaultXamlRuntime) + + + + + + + + + + + \ No newline at end of file diff --git a/src/settings-ui/Settings.UI/Services/SearchIndexService.cs b/src/settings-ui/Settings.UI/Services/SearchIndexService.cs new file mode 100644 index 0000000000..65915fed7b --- /dev/null +++ b/src/settings-ui/Settings.UI/Services/SearchIndexService.cs @@ -0,0 +1,338 @@ +// 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.Concurrent; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Common.Search.FuzzSearch; +using Microsoft.PowerToys.Settings.UI.Helpers; +using Microsoft.PowerToys.Settings.UI.Views; +using Microsoft.Windows.ApplicationModel.Resources; +using Settings.UI.Library; + +namespace Microsoft.PowerToys.Settings.UI.Services +{ + public static class SearchIndexService + { + private static readonly object _lockObject = new(); + private static readonly Dictionary _pageNameCache = []; + private static readonly Dictionary _normalizedTextCache = new(); + private static readonly Dictionary _pageTypeCache = new(); + private static ImmutableArray _index = []; + private static bool _isIndexBuilt; + private static bool _isIndexBuilding; + private const string PrebuiltIndexResourceName = "Microsoft.PowerToys.Settings.UI.Assets.search.index.json"; + private static JsonSerializerOptions _serializerOptions = new() { PropertyNameCaseInsensitive = true }; + + public static ImmutableArray Index + { + get + { + lock (_lockObject) + { + return _index; + } + } + } + + public static bool IsIndexReady + { + get + { + lock (_lockObject) + { + return _isIndexBuilt; + } + } + } + + public static void BuildIndex() + { + lock (_lockObject) + { + if (_isIndexBuilt || _isIndexBuilding) + { + return; + } + + _isIndexBuilding = true; + + // Clear caches on rebuild + _normalizedTextCache.Clear(); + _pageTypeCache.Clear(); + } + + try + { + var builder = ImmutableArray.CreateBuilder(); + LoadIndexFromPrebuiltData(builder); + + lock (_lockObject) + { + _index = builder.ToImmutable(); + _isIndexBuilt = true; + _isIndexBuilding = false; + } + } + catch (Exception ex) + { + Debug.WriteLine($"[SearchIndexService] CRITICAL ERROR building search index: {ex.Message}\n{ex.StackTrace}"); + lock (_lockObject) + { + _isIndexBuilding = false; + _isIndexBuilt = false; + } + } + } + + private static void LoadIndexFromPrebuiltData(ImmutableArray.Builder builder) + { + var assembly = Assembly.GetExecutingAssembly(); + var resourceLoader = ResourceLoaderInstance.ResourceLoader; + SettingEntry[] metadataList; + + Debug.WriteLine($"[SearchIndexService] Attempting to load prebuilt index from: {PrebuiltIndexResourceName}"); + + try + { + using Stream stream = assembly.GetManifestResourceStream(PrebuiltIndexResourceName); + if (stream == null) + { + Debug.WriteLine($"[SearchIndexService] ERROR: Embedded resource '{PrebuiltIndexResourceName}' not found. Ensure it's correctly embedded and the name matches."); + return; + } + + using StreamReader reader = new(stream); + string json = reader.ReadToEnd(); + if (string.IsNullOrWhiteSpace(json)) + { + Debug.WriteLine("[SearchIndexService] ERROR: Embedded resource was empty."); + return; + } + + metadataList = JsonSerializer.Deserialize(json, _serializerOptions); + } + catch (Exception ex) + { + Debug.WriteLine($"[SearchIndexService] ERROR: Failed to load or deserialize prebuilt index: {ex.Message}"); + return; + } + + if (metadataList == null || metadataList.Length == 0) + { + Debug.WriteLine("[SearchIndexService] Prebuilt index is empty or deserialization failed."); + return; + } + + foreach (ref var metadata in metadataList.AsSpan()) + { + if (metadata.Type == EntryType.SettingsPage) + { + (metadata.Header, metadata.Description) = GetLocalizedModuleTitleAndDescription(resourceLoader, metadata.ElementUid); + } + else + { + (metadata.Header, metadata.Description) = GetLocalizedSettingHeaderAndDescription(resourceLoader, metadata.ElementUid); + } + + if (string.IsNullOrEmpty(metadata.Header)) + { + continue; + } + + builder.Add(metadata); + + // Cache the page name mapping for SettingsPage entries + if (metadata.Type == EntryType.SettingsPage && !string.IsNullOrEmpty(metadata.Header)) + { + _pageNameCache[metadata.PageTypeName] = metadata.Header; + } + } + + Debug.WriteLine($"[SearchIndexService] Finished loading index. Total entries: {builder.Count}"); + } + + private static (string Header, string Description) GetLocalizedSettingHeaderAndDescription(ResourceLoader resourceLoader, string elementUid) + { + string header = GetString(resourceLoader, $"{elementUid}/Header"); + string description = GetString(resourceLoader, $"{elementUid}/Description"); + + if (string.IsNullOrEmpty(header)) + { + Debug.WriteLine($"[SearchIndexService] WARNING: No header localization found for ElementUid: '{elementUid}'"); + } + + return (header, description); + } + + private static (string Title, string Description) GetLocalizedModuleTitleAndDescription(ResourceLoader resourceLoader, string elementUid) + { + string title = GetString(resourceLoader, $"{elementUid}/ModuleTitle"); + string description = GetString(resourceLoader, $"{elementUid}/ModuleDescription"); + + return (title, description); + } + + private static string GetString(ResourceLoader rl, string key) + { + try + { + string value = rl.GetString(key); + return string.IsNullOrWhiteSpace(value) ? string.Empty : value; + } + catch (Exception) + { + return string.Empty; + } + } + + public static List Search(string query) + { + return Search(query, CancellationToken.None); + } + + public static List Search(string query, CancellationToken token) + { + if (string.IsNullOrWhiteSpace(query)) + { + return []; + } + + var currentIndex = Index; + if (currentIndex.IsEmpty) + { + Debug.WriteLine("[SearchIndexService] Search called but index is empty."); + return []; + } + + var normalizedQuery = NormalizeString(query); + var bag = new ConcurrentBag<(SettingEntry Hit, double Score)>(); + var po = new ParallelOptions + { + CancellationToken = token, + MaxDegreeOfParallelism = Math.Max(1, Environment.ProcessorCount - 1), + }; + + try + { + Parallel.ForEach(currentIndex, po, entry => + { + var (headerNorm, descNorm) = GetNormalizedTexts(entry); + var captionScoreResult = StringMatcher.FuzzyMatch(normalizedQuery, headerNorm); + double score = captionScoreResult.Score; + + if (!string.IsNullOrEmpty(descNorm)) + { + var descriptionScoreResult = StringMatcher.FuzzyMatch(normalizedQuery, descNorm); + if (descriptionScoreResult.Success) + { + score = Math.Max(score, descriptionScoreResult.Score * 0.8); + } + } + + if (score > 0) + { + var pageType = GetPageTypeFromName(entry.PageTypeName); + if (pageType != null) + { + bag.Add((entry, score)); + } + } + }); + } + catch (OperationCanceledException) + { + return []; + } + + return bag + .OrderByDescending(r => r.Score) + .Select(r => r.Hit) + .ToList(); + } + + private static Type GetPageTypeFromName(string pageTypeName) + { + if (string.IsNullOrEmpty(pageTypeName)) + { + return null; + } + + lock (_lockObject) + { + if (_pageTypeCache.TryGetValue(pageTypeName, out var cached)) + { + return cached; + } + + var assembly = typeof(GeneralPage).Assembly; + var type = assembly.GetType($"Microsoft.PowerToys.Settings.UI.Views.{pageTypeName}"); + _pageTypeCache[pageTypeName] = type; + return type; + } + } + + private static (string HeaderNorm, string DescNorm) GetNormalizedTexts(SettingEntry entry) + { + if (entry.ElementUid == null && entry.Header == null) + { + return (NormalizeString(entry.Header), NormalizeString(entry.Description)); + } + + var key = entry.ElementUid ?? $"{entry.PageTypeName}|{entry.ElementName}"; + lock (_lockObject) + { + if (_normalizedTextCache.TryGetValue(key, out var cached)) + { + return cached; + } + } + + var headerNorm = NormalizeString(entry.Header); + var descNorm = NormalizeString(entry.Description); + lock (_lockObject) + { + _normalizedTextCache[key] = (headerNorm, descNorm); + } + + return (headerNorm, descNorm); + } + + private static string NormalizeString(string input) + { + if (string.IsNullOrEmpty(input)) + { + return string.Empty; + } + + var normalized = input.ToLowerInvariant().Normalize(NormalizationForm.FormKD); + var stringBuilder = new StringBuilder(); + foreach (var c in normalized) + { + var unicodeCategory = CharUnicodeInfo.GetUnicodeCategory(c); + if (unicodeCategory != UnicodeCategory.NonSpacingMark) + { + stringBuilder.Append(c); + } + } + + return stringBuilder.ToString(); + } + + public static string GetLocalizedPageName(string pageTypeName) + { + return _pageNameCache.TryGetValue(pageTypeName, out string cachedName) ? cachedName : string.Empty; + } + } +} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/App.xaml b/src/settings-ui/Settings.UI/SettingsXAML/App.xaml index 2de03f636f..ea2a585b8b 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/App.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/App.xaml @@ -49,8 +49,12 @@ TrueValue="Collapsed" /> + - + 2 diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/SettingsPageControl/SettingsPageControl.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Controls/SettingsPageControl/SettingsPageControl.xaml index 118c9b7ca5..20af0c6629 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Controls/SettingsPageControl/SettingsPageControl.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/SettingsPageControl/SettingsPageControl.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" xmlns:tk7controls="using:CommunityToolkit.WinUI.UI.Controls" @@ -11,6 +12,7 @@ 1000 1020 + @@ -38,10 +40,7 @@ - + @@ -52,14 +51,12 @@ - - - - - + CornerRadius="{StaticResource OverlayCornerRadius}" + Visibility="{x:Bind ModuleImageSource, Converter={StaticResource EmptyObjectToObjectConverter}}"> + @@ -73,7 +70,8 @@ x:Name="PrimaryLinksControl" Margin="0,8,0,0" IsTabStop="False" - ItemsSource="{x:Bind PrimaryLinks}"> + ItemsSource="{x:Bind PrimaryLinks}" + Visibility="{x:Bind PrimaryLinks.Count, Converter={StaticResource DoubleToVisibilityConverter}}"> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/SettingsPageControl/SettingsPageControl.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Controls/SettingsPageControl/SettingsPageControl.xaml.cs index dca1fbae8e..6e5fef091f 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Controls/SettingsPageControl/SettingsPageControl.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/SettingsPageControl/SettingsPageControl.xaml.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.ObjectModel; using Microsoft.UI.Xaml; @@ -30,9 +31,9 @@ namespace Microsoft.PowerToys.Settings.UI.Controls set => SetValue(ModuleDescriptionProperty, value); } - public string ModuleImageSource + public Uri ModuleImageSource { - get => (string)GetValue(ModuleImageSourceProperty); + get => (Uri)GetValue(ModuleImageSourceProperty); set => SetValue(ModuleImageSourceProperty, value); } @@ -60,13 +61,13 @@ namespace Microsoft.PowerToys.Settings.UI.Controls set { SetValue(ModuleContentProperty, value); } } - public static readonly DependencyProperty ModuleTitleProperty = DependencyProperty.Register("ModuleTitle", typeof(string), typeof(SettingsPageControl), new PropertyMetadata(default(string))); - public static readonly DependencyProperty ModuleDescriptionProperty = DependencyProperty.Register("ModuleDescription", typeof(string), typeof(SettingsPageControl), new PropertyMetadata(default(string))); - public static readonly DependencyProperty ModuleImageSourceProperty = DependencyProperty.Register("ModuleImageSource", typeof(string), typeof(SettingsPageControl), new PropertyMetadata(default(string))); - public static readonly DependencyProperty PrimaryLinksProperty = DependencyProperty.Register("PrimaryLinks", typeof(ObservableCollection), typeof(SettingsPageControl), new PropertyMetadata(new ObservableCollection())); - public static readonly DependencyProperty SecondaryLinksHeaderProperty = DependencyProperty.Register("SecondaryLinksHeader", typeof(string), typeof(SettingsPageControl), new PropertyMetadata(default(string))); - public static readonly DependencyProperty SecondaryLinksProperty = DependencyProperty.Register("SecondaryLinks", typeof(ObservableCollection), typeof(SettingsPageControl), new PropertyMetadata(new ObservableCollection())); - public static readonly DependencyProperty ModuleContentProperty = DependencyProperty.Register("ModuleContent", typeof(object), typeof(SettingsPageControl), new PropertyMetadata(new Grid())); + public static readonly DependencyProperty ModuleTitleProperty = DependencyProperty.Register(nameof(ModuleTitle), typeof(string), typeof(SettingsPageControl), new PropertyMetadata(defaultValue: null)); + public static readonly DependencyProperty ModuleDescriptionProperty = DependencyProperty.Register(nameof(ModuleDescription), typeof(string), typeof(SettingsPageControl), new PropertyMetadata(defaultValue: null)); + public static readonly DependencyProperty ModuleImageSourceProperty = DependencyProperty.Register(nameof(ModuleImageSource), typeof(Uri), typeof(SettingsPageControl), new PropertyMetadata(null)); + public static readonly DependencyProperty PrimaryLinksProperty = DependencyProperty.Register(nameof(PrimaryLinks), typeof(ObservableCollection), typeof(SettingsPageControl), new PropertyMetadata(new ObservableCollection())); + public static readonly DependencyProperty SecondaryLinksHeaderProperty = DependencyProperty.Register(nameof(SecondaryLinksHeader), typeof(string), typeof(SettingsPageControl), new PropertyMetadata(default(string))); + public static readonly DependencyProperty SecondaryLinksProperty = DependencyProperty.Register(nameof(SecondaryLinks), typeof(ObservableCollection), typeof(SettingsPageControl), new PropertyMetadata(new ObservableCollection())); + public static readonly DependencyProperty ModuleContentProperty = DependencyProperty.Register(nameof(ModuleContent), typeof(object), typeof(SettingsPageControl), new PropertyMetadata(new Grid())); private void UserControl_Loaded(object sender, RoutedEventArgs e) { diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Flyout/LaunchPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Flyout/LaunchPage.xaml index a5e6f2de40..07b6a00c21 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Flyout/LaunchPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Flyout/LaunchPage.xaml @@ -21,7 +21,6 @@ - diff --git a/src/settings-ui/Settings.UI/SettingsXAML/MainWindow.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/MainWindow.xaml.cs index 1b6a33ad4d..90b2577268 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/MainWindow.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/MainWindow.xaml.cs @@ -3,7 +3,6 @@ // See the LICENSE file in the project root for more information. using System; - using ManagedCommon; using Microsoft.PowerLauncher.Telemetry; using Microsoft.PowerToys.Settings.UI.Helpers; @@ -14,6 +13,7 @@ using Microsoft.UI; using Microsoft.UI.Windowing; using Microsoft.UI.Xaml; using Windows.Data.Json; +using WinRT.Interop; using WinUIEx; namespace Microsoft.PowerToys.Settings.UI @@ -104,7 +104,7 @@ namespace Microsoft.PowerToys.Settings.UI { if (App.GetOobeWindow() == null) { - App.SetOobeWindow(new OobeWindow(Microsoft.PowerToys.Settings.UI.OOBE.Enums.PowerToysModules.Overview)); + App.SetOobeWindow(new OobeWindow(OOBE.Enums.PowerToysModules.Overview)); } App.GetOobeWindow().Activate(); @@ -115,7 +115,7 @@ namespace Microsoft.PowerToys.Settings.UI { if (App.GetOobeWindow() == null) { - App.SetOobeWindow(new OobeWindow(Microsoft.PowerToys.Settings.UI.OOBE.Enums.PowerToysModules.WhatsNew)); + App.SetOobeWindow(new OobeWindow(OOBE.Enums.PowerToysModules.WhatsNew)); } else { @@ -160,6 +160,7 @@ namespace Microsoft.PowerToys.Settings.UI }); this.InitializeComponent(); + SetAppTitleBar(); // receive IPC Message App.IPCMessageReceivedCallback = (string msg) => @@ -186,6 +187,13 @@ namespace Microsoft.PowerToys.Settings.UI PowerToysTelemetry.Log.WriteEvent(new SettingsBootEvent() { BootTimeMs = bootTime.ElapsedMilliseconds }); } + private void SetAppTitleBar() + { + // We need to assign the window here so it can configure the custom title bar area correctly. + shellPage.TitleBar.Window = this; + WindowHelpers.ForceTopBorder1PixelInsetOnWindows10(WindowNative.GetWindowHandle(this)); + } + public void NavigateToSection(System.Type type) { ShellPage.Navigate(type); diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPaste.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPastePage.xaml similarity index 92% rename from src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPaste.xaml rename to src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPastePage.xaml index 7aa8176621..58552b1c00 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPaste.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPastePage.xaml @@ -1,9 +1,10 @@ - - + @@ -37,7 +38,7 @@ - + @@ -46,6 +47,7 @@ Orientation="Vertical" Spacing="2"> @@ -61,7 +63,6 @@ - - + @@ -96,15 +100,16 @@ - @@ -120,19 +125,22 @@ - + - + - @@ -141,25 +149,27 @@ Click="AddCustomActionButton_Click" Style="{ThemeResource AccentButtonStyle}" /> - + - + - + - + - - - + - @@ -275,8 +290,8 @@ - @@ -306,7 +323,6 @@ - - - @@ -411,4 +425,4 @@ - + diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPaste.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPastePage.xaml.cs similarity index 98% rename from src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPaste.xaml.cs rename to src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPastePage.xaml.cs index 8442262688..095cb7167f 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPaste.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPastePage.xaml.cs @@ -15,7 +15,7 @@ using Microsoft.UI.Xaml.Controls; namespace Microsoft.PowerToys.Settings.UI.Views { - public sealed partial class AdvancedPastePage : Page, IRefreshablePage + public sealed partial class AdvancedPastePage : NavigatablePage, IRefreshablePage { private AdvancedPasteViewModel ViewModel { get; set; } diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/AlwaysOnTopPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/AlwaysOnTopPage.xaml index 1424b256a3..0bd34a3d57 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/AlwaysOnTopPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/AlwaysOnTopPage.xaml @@ -1,9 +1,10 @@ - @@ -35,6 +37,7 @@ @@ -49,31 +52,42 @@ - + - + - + @@ -121,4 +136,4 @@ - + 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 2e22da3120..d82ccb1917 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/AlwaysOnTopPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/AlwaysOnTopPage.xaml.cs @@ -9,7 +9,7 @@ using Microsoft.UI.Xaml.Controls; namespace Microsoft.PowerToys.Settings.UI.Views { - public sealed partial class AlwaysOnTopPage : Page, IRefreshablePage + public sealed partial class AlwaysOnTopPage : NavigatablePage, IRefreshablePage { private AlwaysOnTopViewModel ViewModel { get; set; } diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/AwakePage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/AwakePage.xaml index f4a190ab46..5ccd1e4413 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/AwakePage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/AwakePage.xaml @@ -1,10 +1,11 @@ - - + - + @@ -42,7 +44,10 @@ - + @@ -52,21 +57,23 @@ - + - + @@ -112,4 +119,4 @@ - + diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/AwakePage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/AwakePage.xaml.cs index 3399f425cc..cd085a2053 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/AwakePage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/AwakePage.xaml.cs @@ -17,7 +17,7 @@ using PowerToys.GPOWrapper; namespace Microsoft.PowerToys.Settings.UI.Views { - public sealed partial class AwakePage : Page, IRefreshablePage + public sealed partial class AwakePage : NavigatablePage, IRefreshablePage { private readonly string _appName = "Awake"; private readonly SettingsUtils _settingsUtils; diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/CmdNotFoundPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/CmdNotFoundPage.xaml index 34305e3529..aad05be69e 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/CmdNotFoundPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/CmdNotFoundPage.xaml @@ -1,9 +1,10 @@ - @@ -84,7 +86,7 @@ - + @@ -117,7 +119,7 @@ - + @@ -168,4 +170,4 @@ - + diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/CmdNotFoundPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/CmdNotFoundPage.xaml.cs index 8afa34700e..6f406d669c 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/CmdNotFoundPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/CmdNotFoundPage.xaml.cs @@ -2,12 +2,13 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using Microsoft.PowerToys.Settings.UI.Helpers; using Microsoft.PowerToys.Settings.UI.ViewModels; using Microsoft.UI.Xaml.Controls; namespace Microsoft.PowerToys.Settings.UI.Views { - public sealed partial class CmdNotFoundPage : Page + public sealed partial class CmdNotFoundPage : NavigatablePage { private CmdNotFoundViewModel ViewModel { get; set; } diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/CmdPalPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/CmdPalPage.xaml index 9e7420d84d..23b41af139 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/CmdPalPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/CmdPalPage.xaml @@ -1,9 +1,10 @@ - @@ -27,7 +29,10 @@ Severity="Informational" /> - + - + 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 fb3a97e309..1cc7e411d3 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/CmdPalPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/CmdPalPage.xaml.cs @@ -13,7 +13,7 @@ using Microsoft.UI.Xaml.Controls; namespace Microsoft.PowerToys.Settings.UI.Views { - public sealed partial class CmdPalPage : Page, IRefreshablePage + public sealed partial class CmdPalPage : NavigatablePage, IRefreshablePage { private CmdPalViewModel ViewModel { get; set; } diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/ColorPickerPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/ColorPickerPage.xaml index 7ac03ead81..66b5c2d404 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/ColorPickerPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/ColorPickerPage.xaml @@ -1,9 +1,10 @@ - @@ -38,11 +40,17 @@ - + - + @@ -50,25 +58,35 @@ - + - + - + @@ -80,7 +98,10 @@ - + - + - + - + - + - + @@ -230,6 +272,7 @@ @@ -256,4 +299,4 @@ - \ No newline at end of file + \ No newline at end of file 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 61865c89fa..93296699f6 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/FancyZonesPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/FancyZonesPage.xaml.cs @@ -9,7 +9,7 @@ using Microsoft.UI.Xaml.Controls; namespace Microsoft.PowerToys.Settings.UI.Views { - public sealed partial class FancyZonesPage : Page, IRefreshablePage + public sealed partial class FancyZonesPage : NavigatablePage, IRefreshablePage { private FancyZonesViewModel ViewModel { get; set; } diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/FileLocksmithPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/FileLocksmithPage.xaml index 21de1910c7..7f3741df41 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/FileLocksmithPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/FileLocksmithPage.xaml @@ -1,9 +1,10 @@ - @@ -32,7 +34,7 @@ - + @@ -51,4 +53,4 @@ - + diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/FileLocksmithPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/FileLocksmithPage.xaml.cs index 4d4ffbf416..3e0a642718 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/FileLocksmithPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/FileLocksmithPage.xaml.cs @@ -9,7 +9,7 @@ using Microsoft.UI.Xaml.Controls; namespace Microsoft.PowerToys.Settings.UI.Views { - public sealed partial class FileLocksmithPage : Page, IRefreshablePage + public sealed partial class FileLocksmithPage : NavigatablePage, IRefreshablePage { private FileLocksmithViewModel ViewModel { get; set; } diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/GeneralPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/GeneralPage.xaml index 1a3c640ddf..7658d11237 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/GeneralPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/GeneralPage.xaml @@ -1,19 +1,21 @@ - - + + - + @@ -209,6 +211,7 @@ - + - + @@ -262,10 +271,12 @@ - + - - +