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 ) )
{
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 ;
}
}
}