Files
PowerToys/src/settings-ui/Settings.UI/Services/SearchIndexService.cs

339 lines
12 KiB
C#
Raw Normal View History

Setting search (#41285) <!-- Enter a brief description/summary of your PR here. What does it fix/what does it change/how was it tested (even manually, if necessary)? --> ## Summary of the Pull Request <!-- Please review the items on the PR checklist before submitting--> ## 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 - [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 <!-- Provide a more detailed description of the PR, other things fixed, or any additional comments/features here --> ## Detailed Description of the Pull Request / Additional comments <!-- Describe how you validated the behavior. Add automated tests wherever possible, but list manual validation steps taken as well --> ## Validation Steps Performed Localized search: <img width="1576" height="480" alt="image" src="https://github.com/user-attachments/assets/dd6e5e9f-419b-40b1-b796-f0799481ecfc" /> ## AI summary This pull request introduces infrastructure and code to support search functionality for PowerToys settings, including a new search index specification, a dedicated search library, and updates to the solution configuration. The main changes are the addition of a spec describing how settings should be indexed and navigated, the creation of a new `Common.Search` project with a fuzz search implementation, and updates to the solution file to include these new components. **Settings Search Feature Implementation** * Documentation: * Added a detailed specification (`settings-search.md`) describing the structure of PowerToys settings pages, how to index settings, navigation logic, runtime search, result grouping, build-time indexing strategy, and corner cases. * New Search Library: * Added the new `Common.Search` project to the solution, including its project file and implementation of a fuzz search service (`FuzzSearchService<T>`), match options, match results, and search precision scoring. [[1]](diffhunk://#diff-ddc06fa41e4e723e54181b0cb85cdd00f57f75725d51ceefa242d4d651a9a363R1-R8) [[2]](diffhunk://#diff-1a2ca29fc33bcccf338a7843a040ca2c31ba821e8cab7064fab0dbb1224d454cR1-R39) [[3]](diffhunk://#diff-242764d948b795f39653a84d9b6bfcdc52730100deab2e3a0995be95bb8e7868R1-R10) [[4]](diffhunk://#diff-61e525491ed916ebd65dabb66dd4f5dc720320d7e295ef1e0bd6d506ea0f7df6R1-R67) [[5]](diffhunk://#diff-a775f6de2e8d42982829b4161668f49dedbbd9dcbb05ce20003de7e62275c57aR1-R12) * Solution Configuration: * Updated `PowerToys.sln` to include `Common.Search` and `Settings.UI.XamlIndexBuilder` projects, and configured their build settings for various platforms and mapped project dependencies. [[1]](diffhunk://#diff-ca837ce490070b91656ffffe31cbad8865ba9174e0f020231f77baf35ff3f811R714-R716) [[2]](diffhunk://#diff-ca837ce490070b91656ffffe31cbad8865ba9174e0f020231f77baf35ff3f811R2704-R2727) [[3]](diffhunk://#diff-ca837ce490070b91656ffffe31cbad8865ba9174e0f020231f77baf35ff3f811R2889) [[4]](diffhunk://#diff-ca837ce490070b91656ffffe31cbad8865ba9174e0f020231f77baf35ff3f811R3157-R3158) **Spell-check Dictionary Updates** * Added new terms related to navigation and settings UI components (such as `Navigatable`, `NavigatablePage`, `settingscard`, `Tru`, `tweakable`) to the spell-check dictionary to support the new search and indexing features. [[1]](diffhunk://#diff-5dcab162c1b233a49973ae010f2b88c7ec4844382abd705e6154685e62bd5c4dR1020-R1021) [[2]](diffhunk://#diff-5dcab162c1b233a49973ae010f2b88c7ec4844382abd705e6154685e62bd5c4dR1498) [[3]](diffhunk://#diff-5dcab162c1b233a49973ae010f2b88c7ec4844382abd705e6154685e62bd5c4dR1755-R1761) --------- Co-authored-by: Niels Laute <niels.laute@live.nl> Co-authored-by: Gordon Lam (SH) <yeelam@microsoft.com> Co-authored-by: Gordon Lam <73506701+yeelam-gordon@users.noreply.github.com>
2025-08-25 21:23:07 +08:00
// 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<string, string> _pageNameCache = [];
private static readonly Dictionary<string, (string HeaderNorm, string DescNorm)> _normalizedTextCache = new();
private static readonly Dictionary<string, Type> _pageTypeCache = new();
private static ImmutableArray<SettingEntry> _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<SettingEntry> 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<SettingEntry>();
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<SettingEntry>.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<SettingEntry[]>(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))
{
Settings: Search fancy zone settings and swallow the ctrl+f event (#41437) <!-- Enter a brief description/summary of your PR here. What does it fix/what does it change/how was it tested (even manually, if necessary)? --> ## Summary of the Pull Request This pull request primarily improves the maintainability and robustness of the FancyZones settings UI by assigning unique `Name` attributes to `SettingsCard` controls in the `FancyZonesPage.xaml` file. Additionally, it enhances the logic for retrieving element UIDs and improves fallback behavior for missing localizations. There is also a minor usability fix in the shell page to prevent unintended navigation when focusing the search box. **FancyZones settings UI improvements:** * Added unique `Name` attributes to all `tkcontrols:SettingsCard` elements in `FancyZonesPage.xaml` to facilitate easier referencing and future maintainability. This affects all relevant settings groups and controls in the file. [[1]](diffhunk://#diff-93623d4db1d295dde0ef793053c9db0d9f673d753b787ad12fd71b8e9e40a79fL73-R85) [[2]](diffhunk://#diff-93623d4db1d295dde0ef793053c9db0d9f673d753b787ad12fd71b8e9e40a79fL109-R109) [[3]](diffhunk://#diff-93623d4db1d295dde0ef793053c9db0d9f673d753b787ad12fd71b8e9e40a79fL121-R121) [[4]](diffhunk://#diff-93623d4db1d295dde0ef793053c9db0d9f673d753b787ad12fd71b8e9e40a79fL167-R188) [[5]](diffhunk://#diff-93623d4db1d295dde0ef793053c9db0d9f673d753b787ad12fd71b8e9e40a79fL202-R202) [[6]](diffhunk://#diff-93623d4db1d295dde0ef793053c9db0d9f673d753b787ad12fd71b8e9e40a79fL251-R251) [[7]](diffhunk://#diff-93623d4db1d295dde0ef793053c9db0d9f673d753b787ad12fd71b8e9e40a79fL265-R265) [[8]](diffhunk://#diff-93623d4db1d295dde0ef793053c9db0d9f673d753b787ad12fd71b8e9e40a79fL280-R280) **Element UID retrieval and localization:** * Improved `GetElementUid` in `Program.cs` to fall back to the first child's `x:Uid` if the element itself lacks one, increasing robustness when parsing XAML. * Updated `GetLocalizedSettingHeaderAndD` in `SearchIndexService.cs` to provide a fallback for missing localizations by trying the `"{elementUid}/Content"` resource key. **Shell page usability:** * Modified `CtrlF_Invoked` in `ShellPage.xaml.cs` to mark the event as handled, preventing unintended navigation when the search box is focused with Ctrl+F. <!-- Please review the items on the PR checklist before submitting--> ## 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 - [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 <!-- Provide a more detailed description of the PR, other things fixed, or any additional comments/features here --> ## Detailed Description of the Pull Request / Additional comments <!-- Describe how you validated the behavior. Add automated tests wherever possible, but list manual validation steps taken as well --> ## Validation Steps Performed https://github.com/user-attachments/assets/9cf15605-1114-4c6d-923c-d05c2733a274
2025-08-28 11:18:58 +08:00
header = GetString(resourceLoader, $"{elementUid}/Content");
Setting search (#41285) <!-- Enter a brief description/summary of your PR here. What does it fix/what does it change/how was it tested (even manually, if necessary)? --> ## Summary of the Pull Request <!-- Please review the items on the PR checklist before submitting--> ## 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 - [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 <!-- Provide a more detailed description of the PR, other things fixed, or any additional comments/features here --> ## Detailed Description of the Pull Request / Additional comments <!-- Describe how you validated the behavior. Add automated tests wherever possible, but list manual validation steps taken as well --> ## Validation Steps Performed Localized search: <img width="1576" height="480" alt="image" src="https://github.com/user-attachments/assets/dd6e5e9f-419b-40b1-b796-f0799481ecfc" /> ## AI summary This pull request introduces infrastructure and code to support search functionality for PowerToys settings, including a new search index specification, a dedicated search library, and updates to the solution configuration. The main changes are the addition of a spec describing how settings should be indexed and navigated, the creation of a new `Common.Search` project with a fuzz search implementation, and updates to the solution file to include these new components. **Settings Search Feature Implementation** * Documentation: * Added a detailed specification (`settings-search.md`) describing the structure of PowerToys settings pages, how to index settings, navigation logic, runtime search, result grouping, build-time indexing strategy, and corner cases. * New Search Library: * Added the new `Common.Search` project to the solution, including its project file and implementation of a fuzz search service (`FuzzSearchService<T>`), match options, match results, and search precision scoring. [[1]](diffhunk://#diff-ddc06fa41e4e723e54181b0cb85cdd00f57f75725d51ceefa242d4d651a9a363R1-R8) [[2]](diffhunk://#diff-1a2ca29fc33bcccf338a7843a040ca2c31ba821e8cab7064fab0dbb1224d454cR1-R39) [[3]](diffhunk://#diff-242764d948b795f39653a84d9b6bfcdc52730100deab2e3a0995be95bb8e7868R1-R10) [[4]](diffhunk://#diff-61e525491ed916ebd65dabb66dd4f5dc720320d7e295ef1e0bd6d506ea0f7df6R1-R67) [[5]](diffhunk://#diff-a775f6de2e8d42982829b4161668f49dedbbd9dcbb05ce20003de7e62275c57aR1-R12) * Solution Configuration: * Updated `PowerToys.sln` to include `Common.Search` and `Settings.UI.XamlIndexBuilder` projects, and configured their build settings for various platforms and mapped project dependencies. [[1]](diffhunk://#diff-ca837ce490070b91656ffffe31cbad8865ba9174e0f020231f77baf35ff3f811R714-R716) [[2]](diffhunk://#diff-ca837ce490070b91656ffffe31cbad8865ba9174e0f020231f77baf35ff3f811R2704-R2727) [[3]](diffhunk://#diff-ca837ce490070b91656ffffe31cbad8865ba9174e0f020231f77baf35ff3f811R2889) [[4]](diffhunk://#diff-ca837ce490070b91656ffffe31cbad8865ba9174e0f020231f77baf35ff3f811R3157-R3158) **Spell-check Dictionary Updates** * Added new terms related to navigation and settings UI components (such as `Navigatable`, `NavigatablePage`, `settingscard`, `Tru`, `tweakable`) to the spell-check dictionary to support the new search and indexing features. [[1]](diffhunk://#diff-5dcab162c1b233a49973ae010f2b88c7ec4844382abd705e6154685e62bd5c4dR1020-R1021) [[2]](diffhunk://#diff-5dcab162c1b233a49973ae010f2b88c7ec4844382abd705e6154685e62bd5c4dR1498) [[3]](diffhunk://#diff-5dcab162c1b233a49973ae010f2b88c7ec4844382abd705e6154685e62bd5c4dR1755-R1761) --------- Co-authored-by: Niels Laute <niels.laute@live.nl> Co-authored-by: Gordon Lam (SH) <yeelam@microsoft.com> Co-authored-by: Gordon Lam <73506701+yeelam-gordon@users.noreply.github.com>
2025-08-25 21:23:07 +08:00
}
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<SettingEntry> Search(string query)
{
return Search(query, CancellationToken.None);
}
public static List<SettingEntry> 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;
}
}
}