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>
This commit is contained in:
Kai Tao
2025-08-25 21:23:07 +08:00
committed by GitHub
parent 64dc8e0f27
commit 4ad951eb56
99 changed files with 3734 additions and 558 deletions

View File

@@ -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<string, string> FileNameOverrides = new System.Collections.Generic.Dictionary<string, string>(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 <SettingsPageControl.ModuleContent>
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;
}
}
}
}

View File

@@ -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<string> 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 <xaml-directory> <output-json-file>");
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<SettingEntry>();
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<SettingEntry> ExtractSearchableElements(string xamlFile)
{
var elements = new List<SettingEntry>();
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: <SettingsCard.HeaderIcon> ... </SettingsCard.HeaderIcon>
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=&#xEDA7;}
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;
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -0,0 +1,44 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Import common props to satisfy repo audit; override problematic bits below for this console tool. -->
<Import Project="..\..\Common.Dotnet.CsWinRT.props" />
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<OutputType>Exe</OutputType>
<RootNamespace>Microsoft.PowerToys.Tools.XamlIndexBuilder</RootNamespace>
<AssemblyName>XamlIndexBuilder</AssemblyName>
<!-- Platform-agnostic: framework-dependent DLL executed via dotnet -->
<SelfContained>false</SelfContained>
<UseAppHost>false</UseAppHost>
<PlatformTarget>AnyCPU</PlatformTarget>
<RuntimeIdentifier></RuntimeIdentifier>
<!-- Keep tool output out of product scan paths to avoid deps.json audit conflicts -->
<OutputPath>$(MSBuildProjectDirectory)\obj\XamlIndexBuilder\$(Configuration)\</OutputPath>
</PropertyGroup>
<!-- Remove CsWinRT package introduced by common props; not needed for this tool and causes Windows metadata errors -->
<ItemGroup>
<PackageReference Remove="Microsoft.Windows.CsWinRT" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="System.Text.Json" />
<PackageReference Include="System.CommandLine" />
</ItemGroup>
<!-- Remove UI library reference to avoid pulling WindowsDesktop runtime (WindowsBase) -->
<PropertyGroup>
<!-- Fallback to dotnet if not provided by the environment -->
<DotNetExe Condition="'$(DotNetExe)' == ''">dotnet</DotNetExe>
<XamlViewsDir Condition="'$(XamlViewsDir)' == ''">$(MSBuildProjectDirectory)\..\Settings.UI\SettingsXAML\Views</XamlViewsDir>
<GeneratedJsonFile Condition="'$(GeneratedJsonFile)' == ''">$(MSBuildProjectDirectory)\..\Settings.UI\Assets\Settings\search.index.json</GeneratedJsonFile>
</PropertyGroup>
<Target Name="GenerateSearchIndexSelf" AfterTargets="Build">
<RemoveDir Directories="$(MSBuildProjectDirectory)\obj\ARM64;$(MSBuildProjectDirectory)\obj\x64;$(MSBuildProjectDirectory)\bin" />
<MakeDir Directories="$([System.IO.Path]::GetDirectoryName('$(GeneratedJsonFile)'))" />
<Message Importance="high" Text="[XamlIndexBuilder] Generating search index. Views='$(XamlViewsDir)'; Out='$(GeneratedJsonFile)'; Tool='$(TargetPath)'; DotNet='$(DotNetExe)'." />
<!-- Execute via dotnet so host architecture doesn't need to match -->
<Exec Command="&quot;$(DotNetExe)&quot; &quot;$(TargetPath)&quot; &quot;$(XamlViewsDir)&quot; &quot;$(GeneratedJsonFile)&quot;" />
</Target>
</Project>