Files
PowerToys/src/settings-ui/Settings.UI/Services/SearchIndexService.cs
Kai Tao ef6f4b2c3d 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

339 lines
12 KiB
C#

// 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))
{
header = GetString(resourceLoader, $"{elementUid}/Content");
}
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;
}
}
}