Settings: Settings search fixes (#41381)

<!-- 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
Fix 3 issues

<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [x] Closes: #41369, #41374, #41380
- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **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/0e0df9fb-5aca-4b26-9d53-e6ddc49cab04

---------

Co-authored-by: Niels Laute <niels.laute@live.nl>
Co-authored-by: Jiří Polášek <me@jiripolasek.com>
This commit is contained in:
Kai Tao
2025-08-27 14:51:36 +08:00
committed by GitHub
parent bb6b36af3f
commit 7455d63bb5
7 changed files with 349 additions and 157 deletions

View File

@@ -19,6 +19,13 @@ namespace Microsoft.PowerToys.Tools.XamlIndexBuilder
"ShellPage.xaml",
};
// Hardcoded panel-to-page mapping (temporary until generic panel host mapping is needed)
// Key: panel file base name (without .xaml), Value: owning page base name
private static readonly Dictionary<string, string> PanelPageMapping = new(StringComparer.OrdinalIgnoreCase)
{
{ "MouseJumpPanel", "MouseUtilsPage" },
};
private static JsonSerializerOptions serializeOption = new()
{
WriteIndented = true,
@@ -33,32 +40,117 @@ namespace Microsoft.PowerToys.Tools.XamlIndexBuilder
Environment.Exit(1);
}
string xamlDirectory = args[0];
string xamlRootDirectory = args[0];
string outputFile = args[1];
if (!Directory.Exists(xamlDirectory))
if (!Directory.Exists(xamlRootDirectory))
{
Debug.WriteLine($"Error: Directory '{xamlDirectory}' does not exist.");
Debug.WriteLine($"Error: Directory '{xamlRootDirectory}' does not exist.");
Environment.Exit(1);
}
try
{
var searchableElements = new List<SettingEntry>();
var xamlFiles = Directory.GetFiles(xamlDirectory, "*.xaml", SearchOption.AllDirectories);
var processedFiles = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var xamlFile in xamlFiles)
void ScanDirectory(string root)
{
var fileName = Path.GetFileName(xamlFile);
if (ExcludedXamlFiles.Contains(fileName))
if (!Directory.Exists(root))
{
// Skip ShellPage.xaml as it contains many elements not relevant for search
continue;
return;
}
Debug.WriteLine($"Processing: {fileName}");
var elements = ExtractSearchableElements(xamlFile);
searchableElements.AddRange(elements);
Debug.WriteLine($"[XamlIndexBuilder] Scanning root: {root}");
var xamlFilesLocal = Directory.GetFiles(root, "*.xaml", SearchOption.AllDirectories);
foreach (var xamlFile in xamlFilesLocal)
{
var fullPath = Path.GetFullPath(xamlFile);
if (processedFiles.Contains(fullPath))
{
continue; // already handled (can happen if overlapping directories)
}
var fileName = Path.GetFileName(xamlFile);
if (ExcludedXamlFiles.Contains(fileName))
{
continue; // explicitly excluded
}
Debug.WriteLine($"Processing: {fileName}");
var elements = ExtractSearchableElements(xamlFile);
// Apply hardcoded panel mapping override
var baseName = Path.GetFileNameWithoutExtension(xamlFile);
if (PanelPageMapping.TryGetValue(baseName, out var hostPage))
{
for (int i = 0; i < elements.Count; i++)
{
var entry = elements[i];
entry.PageTypeName = hostPage;
elements[i] = entry;
}
}
searchableElements.AddRange(elements);
processedFiles.Add(fullPath);
}
}
// Scan well-known subdirectories under the provided root
var subDirs = new[] { "Views", "Panels" };
foreach (var sub in subDirs)
{
ScanDirectory(Path.Combine(xamlRootDirectory, sub));
}
// Fallback: also scan root directly (in case some XAML lives at root level)
ScanDirectory(xamlRootDirectory);
// -----------------------------------------------------------------------------
// Explicit include section: add specific XAML files that we always want indexed
// even if future logic excludes them or they live outside typical scan patterns.
// Add future files to the ExplicitExtraXamlFiles array below.
// -----------------------------------------------------------------------------
string[] explicitExtraXamlFiles = new[]
{
"MouseJumpPanel.xaml", // Mouse Jump settings panel
};
foreach (var extraFileName in explicitExtraXamlFiles)
{
try
{
var matches = Directory.GetFiles(xamlRootDirectory, extraFileName, SearchOption.AllDirectories);
foreach (var match in matches)
{
var full = Path.GetFullPath(match);
if (processedFiles.Contains(full))
{
continue; // already processed in general scan
}
Debug.WriteLine($"Processing (explicit include): {extraFileName}");
var elements = ExtractSearchableElements(full);
var baseName = Path.GetFileNameWithoutExtension(full);
if (PanelPageMapping.TryGetValue(baseName, out var hostPage))
{
for (int i = 0; i < elements.Count; i++)
{
var entry = elements[i];
entry.PageTypeName = hostPage;
elements[i] = entry;
}
}
searchableElements.AddRange(elements);
processedFiles.Add(full);
}
}
catch (Exception ex)
{
Debug.WriteLine($"Explicit include failed for {extraFileName}: {ex.Message}");
}
}
searchableElements = searchableElements.OrderBy(e => e.PageTypeName).ThenBy(e => e.ElementName).ToList();
@@ -97,15 +189,15 @@ namespace Microsoft.PowerToys.Tools.XamlIndexBuilder
.Where(e => e.Name.LocalName == "SettingsPageControl")
.Where(e => e.Attribute(x + "Uid") != null);
// Extract SettingsCard elements
// Extract SettingsCard elements (support both Name and x:Name)
var settingsElements = doc.Descendants()
.Where(e => e.Name.LocalName == "SettingsCard")
.Where(e => e.Attribute("Name") != null || e.Attribute(x + "Uid") != null);
.Where(e => e.Attribute("Name") != null || e.Attribute(x + "Name") != null || e.Attribute(x + "Uid") != null);
// Extract SettingsExpander elements
// Extract SettingsExpander elements (support both Name and x:Name)
var settingsExpanderElements = doc.Descendants()
.Where(e => e.Name.LocalName == "SettingsExpander")
.Where(e => e.Attribute("Name") != null || e.Attribute(x + "Uid") != null);
.Where(e => e.Attribute("Name") != null || e.Attribute(x + "Name") != null || e.Attribute(x + "Uid") != null);
// Process SettingsPageControl elements
foreach (var element in settingsPageElements)
@@ -185,8 +277,12 @@ namespace Microsoft.PowerToys.Tools.XamlIndexBuilder
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;
if (string.IsNullOrEmpty(name))
{
name = element.Attribute(x + "Name")?.Value;
}
return name;
}
@@ -211,6 +307,11 @@ namespace Microsoft.PowerToys.Tools.XamlIndexBuilder
if (expanderParent?.Name.LocalName == "SettingsExpander")
{
var expanderName = expanderParent.Attribute("Name")?.Value;
if (string.IsNullOrEmpty(expanderName))
{
expanderName = expanderParent.Attribute(x + "Name")?.Value;
}
if (!string.IsNullOrEmpty(expanderName))
{
return expanderName;
@@ -221,6 +322,11 @@ namespace Microsoft.PowerToys.Tools.XamlIndexBuilder
{
// Direct child of SettingsExpander
var expanderName = current.Attribute("Name")?.Value;
if (string.IsNullOrEmpty(expanderName))
{
expanderName = current.Attribute(x + "Name")?.Value;
}
if (!string.IsNullOrEmpty(expanderName))
{
return expanderName;

View File

@@ -29,16 +29,15 @@
<!-- 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>
<XamlRootDir Condition="'$(XamlRootDir)' == ''">$(MSBuildProjectDirectory)\..\Settings.UI\SettingsXAML</XamlRootDir>
<XamlRootDir Condition="'$(XamlViewsDir)' != ''">$([System.IO.Path]::GetDirectoryName('$(XamlViewsDir)'))</XamlRootDir>
<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;" />
<Message Importance="high" Text="[XamlIndexBuilder] Generating search index. Root='$(XamlRootDir)'; Out='$(GeneratedJsonFile)'; Tool='$(TargetPath)'; DotNet='$(DotNetExe)'." />
<Exec Command="&quot;$(DotNetExe)&quot; &quot;$(TargetPath)&quot; &quot;$(XamlRootDir)&quot; &quot;$(GeneratedJsonFile)&quot;" />
</Target>
</Project>

View File

@@ -80,6 +80,9 @@ public abstract partial class NavigablePage : Page
return;
}
// Attempt to set keyboard focus so that screen readers announce the element and keyboard users land directly on it.
TrySetFocus(target);
// Get the visual and compositor
var visual = ElementCompositionPreview.GetElementVisual(target);
var compositor = visual.Compositor;
@@ -113,9 +116,129 @@ public abstract partial class NavigablePage : Page
ElementCompositionPreview.SetElementChildVisual(target, null);
}
private static void TrySetFocus(FrameworkElement target)
{
try
{
// Prefer Control.Focus when available.
if (target is Control ctrl)
{
// Ensure it can receive focus.
if (!ctrl.IsTabStop)
{
ctrl.IsTabStop = true;
}
ctrl.Focus(FocusState.Programmatic);
}
// Target is not a Control. Find first focusable descendant Control.
var focusCandidate = FindFirstFocusableDescendant(target);
if (focusCandidate != null)
{
if (!focusCandidate.IsTabStop)
{
focusCandidate.IsTabStop = true;
}
focusCandidate.Focus(FocusState.Programmatic);
return;
}
// Fallback: attempt to focus parent control if no descendant found.
if (target.Parent is Control parent)
{
if (!parent.IsTabStop)
{
parent.IsTabStop = true;
}
parent.Focus(FocusState.Programmatic);
}
}
catch
{
// Swallow focus exceptions; not critical. Could log if logging enabled.
// Leave the default focus as it is.
}
}
private static Control FindFirstFocusableDescendant(FrameworkElement root)
{
if (root == null)
{
return null;
}
var queue = new System.Collections.Generic.Queue<DependencyObject>();
queue.Enqueue(root);
while (queue.Count > 0)
{
var current = queue.Dequeue();
if (current is Control c && c.IsEnabled && c.Visibility == Visibility.Visible)
{
return c;
}
int count = VisualTreeHelper.GetChildrenCount(current);
for (int i = 0; i < count; i++)
{
queue.Enqueue(VisualTreeHelper.GetChild(current, i));
}
}
return null;
}
protected FrameworkElement FindElementByName(string name)
{
var element = this.FindName(name) as FrameworkElement;
return element;
if (element != null)
{
return element;
}
if (this.Content is DependencyObject root)
{
var found = FindInDescendants(root, name);
if (found != null)
{
return found;
}
}
return null;
}
private static FrameworkElement FindInDescendants(DependencyObject root, string name)
{
if (root == null || string.IsNullOrEmpty(name))
{
return null;
}
var queue = new System.Collections.Generic.Queue<DependencyObject>();
queue.Enqueue(root);
while (queue.Count > 0)
{
var current = queue.Dequeue();
if (current is FrameworkElement fe)
{
var local = fe.FindName(name) as FrameworkElement;
if (local != null)
{
return local;
}
}
int count = VisualTreeHelper.GetChildrenCount(current);
for (int i = 0; i < count; i++)
{
var child = VisualTreeHelper.GetChild(current, i);
queue.Enqueue(child);
}
}
return null;
}
}

View File

@@ -181,9 +181,6 @@
</Page>
</ItemGroup>
<!-- Removed nested publish/exec and copy targets. -->
<!-- Build XamlIndexBuilder before compiling Settings to ensure the search index exists without taking a project reference. -->
<Target Name="BuildXamlIndexBeforeSettings" BeforeTargets="CoreCompile">
<Message Importance="high" Text="[Settings] Building XamlIndexBuilder prior to compile. Views='$(MSBuildProjectDirectory)\SettingsXAML\Views' Out='$(GeneratedJsonFile)'" />
<MSBuild Projects="..\Settings.UI.XamlIndexBuilder\Settings.UI.XamlIndexBuilder.csproj" Targets="Build" Properties="Configuration=$(Configuration);Platform=Any CPU;TargetFramework=net9.0;XamlViewsDir=$(MSBuildProjectDirectory)\SettingsXAML\Views;GeneratedJsonFile=$(GeneratedJsonFile)" />

View File

@@ -24,6 +24,7 @@
<controls:SettingsGroup x:Uid="MouseUtils_MouseJump">
<tkcontrols:SettingsCard
x:Name="MouseUtilsEnableMouseJump"
x:Uid="MouseUtils_Enable_MouseJump"
HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/MouseJump.png}"
IsEnabled="{x:Bind ViewModel.IsJumpEnabledGpoConfigured, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}">
@@ -42,6 +43,7 @@
</InfoBar>
<tkcontrols:SettingsCard
x:Name="MouseUtilsMouseJumpActivationShortcut"
x:Uid="MouseUtils_MouseJump_ActivationShortcut"
HeaderIcon="{ui:FontIcon Glyph=&#xEDA7;}"
IsEnabled="{x:Bind ViewModel.IsMouseJumpEnabled, Mode=OneWay}">
@@ -126,6 +128,7 @@
</tkcontrols:SettingsCard>
<tkcontrols:SettingsExpander
x:Name="MouseUtilsMouseJumpAppearance"
x:Uid="MouseUtils_MouseJump_Appearance"
HeaderIcon="{ui:FontIcon Glyph=&#xEB3C;}"
IsEnabled="{x:Bind ViewModel.IsMouseJumpEnabled, Mode=OneWay}"

View File

@@ -4,7 +4,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@@ -142,8 +141,7 @@ namespace Microsoft.PowerToys.Settings.UI.Views
private const int SearchDebounceMs = 500;
private bool _disposed;
// Tracing id for correlating logs of a single search interaction
private static long _searchTraceIdCounter;
// Removed trace id counter per cleanup
/// <summary>
/// Initializes a new instance of the <see cref="ShellPage"/> class.
@@ -443,25 +441,11 @@ namespace Microsoft.PowerToys.Settings.UI.Views
private void ShellPage_Loaded(object sender, RoutedEventArgs e)
{
Logger.LogDebug("[Search][Index] Scheduling BuildIndex...");
var swIndex = Stopwatch.StartNew();
Task.Run(() =>
{
Logger.LogDebug("[Search][Index] BuildIndex started");
SearchIndexService.BuildIndex();
})
.ContinueWith(t =>
{
swIndex.Stop();
if (t.IsFaulted)
{
Logger.LogDebug($"[Search][Index] BuildIndex FAILED after {swIndex.ElapsedMilliseconds} ms: {t.Exception?.Flatten().InnerException?.Message}");
}
else
{
Logger.LogDebug($"[Search][Index] BuildIndex completed in {swIndex.ElapsedMilliseconds} ms.");
}
});
.ContinueWith(_ => { });
}
private void NavigationView_DisplayModeChanged(NavigationView sender, NavigationViewDisplayModeChangedEventArgs args)
@@ -512,10 +496,6 @@ namespace Microsoft.PowerToys.Settings.UI.Views
var query = sender.Text?.Trim() ?? string.Empty;
var traceId = Interlocked.Increment(ref _searchTraceIdCounter);
var swOverall = Stopwatch.StartNew();
Logger.LogDebug($"[Search][TextChanged][{traceId}] start. query='{query}'");
// Debounce: cancel previous pending search
_searchDebounceCts?.Cancel();
_searchDebounceCts?.Dispose();
@@ -528,7 +508,6 @@ namespace Microsoft.PowerToys.Settings.UI.Views
sender.IsSuggestionListOpen = false;
_lastSearchResults.Clear();
_lastQueryText = string.Empty;
Logger.LogDebug($"[Search][TextChanged][{traceId}] empty query. end");
return;
}
@@ -538,14 +517,11 @@ namespace Microsoft.PowerToys.Settings.UI.Views
}
catch (TaskCanceledException)
{
// A newer keystroke arrived; abandon this run
Logger.LogDebug($"[Search][TextChanged][{traceId}] debounce canceled at +{swOverall.ElapsedMilliseconds} ms");
return;
return; // debounce canceled
}
if (token.IsCancellationRequested)
{
Logger.LogDebug($"[Search][TextChanged][{traceId}] token canceled post-debounce at +{swOverall.ElapsedMilliseconds} ms");
return;
}
@@ -554,106 +530,25 @@ namespace Microsoft.PowerToys.Settings.UI.Views
try
{
// If the token is already canceled before scheduling, the task won't start.
var swSearch = Stopwatch.StartNew();
Logger.LogDebug($"[Search][TextChanged][{traceId}] dispatch search...");
results = await Task.Run(() => SearchIndexService.Search(query, token), token);
swSearch.Stop();
Logger.LogDebug($"[Search][TextChanged][{traceId}] search done in {swSearch.ElapsedMilliseconds} ms. results={results?.Count ?? 0}");
}
catch (OperationCanceledException)
{
Logger.LogDebug($"[Search][TextChanged][{traceId}] search canceled at +{swOverall.ElapsedMilliseconds} ms");
return;
}
if (token.IsCancellationRequested)
{
Logger.LogDebug($"[Search][TextChanged][{traceId}] token canceled after search at +{swOverall.ElapsedMilliseconds} ms");
return;
}
_lastSearchResults = results;
_lastQueryText = query;
List<SuggestionItem> top;
if (results.Count == 0)
{
// Explicit no-results row
var rl = ResourceLoaderInstance.ResourceLoader;
var noResultsPrefix = rl.GetString("Shell_Search_NoResults");
if (string.IsNullOrEmpty(noResultsPrefix))
{
noResultsPrefix = "No results for";
}
var top = BuildSuggestionItems(query, results);
var headerText = $"{noResultsPrefix} '{query}'";
top =
[
new()
{
Header = headerText,
IsNoResults = true,
},
];
Logger.LogDebug($"[Search][TextChanged][{traceId}] no results -> added placeholder item (count={top.Count})");
}
else
{
// Project top 5 suggestions
var swProject = Stopwatch.StartNew();
top = [.. results.Take(5)
.Select(e =>
{
string subtitle = string.Empty;
if (e.Type != EntryType.SettingsPage)
{
var swSubtitle = Stopwatch.StartNew();
subtitle = SearchIndexService.GetLocalizedPageName(e.PageTypeName);
if (string.IsNullOrEmpty(subtitle))
{
// Fallback: look up the module title from the in-memory index
var swFallback = Stopwatch.StartNew();
subtitle = SearchIndexService.Index
.Where(x => x.Type == EntryType.SettingsPage && x.PageTypeName == e.PageTypeName)
.Select(x => x.Header)
.FirstOrDefault() ?? string.Empty;
swFallback.Stop();
Logger.LogDebug($"[Search][TextChanged][{traceId}] fallback subtitle for '{e.PageTypeName}' took {swFallback.ElapsedMilliseconds} ms");
}
swSubtitle.Stop();
Logger.LogDebug($"[Search][TextChanged][{traceId}] subtitle for '{e.PageTypeName}' took {swSubtitle.ElapsedMilliseconds} ms");
}
return new SuggestionItem
{
Header = e.Header,
Icon = e.Icon,
PageTypeName = e.PageTypeName,
ElementName = e.ElementName,
ParentElementName = e.ParentElementName,
Subtitle = subtitle,
IsShowAll = false,
};
})];
swProject.Stop();
Logger.LogDebug($"[Search][TextChanged][{traceId}] project suggestions took {swProject.ElapsedMilliseconds} ms. topCount={top.Count}");
if (results.Count > 5)
{
// Add a tail item to show all results if there are more than 5
top.Add(new SuggestionItem { IsShowAll = true });
Logger.LogDebug($"[Search][TextChanged][{traceId}] added 'Show all results' item");
}
}
var swUi = Stopwatch.StartNew();
sender.ItemsSource = top;
sender.IsSuggestionListOpen = top.Count > 0;
swUi.Stop();
swOverall.Stop();
Logger.LogDebug($"[Search][TextChanged][{traceId}] UI update took {swUi.ElapsedMilliseconds} ms. total={swOverall.ElapsedMilliseconds} ms");
}
private void SearchBox_SuggestionChosen(AutoSuggestBox sender, AutoSuggestBoxSuggestionChosenEventArgs args)
@@ -714,19 +609,93 @@ namespace Microsoft.PowerToys.Settings.UI.Views
private void SearchBox_GotFocus(object sender, RoutedEventArgs e)
{
// do not prompt unless search for text.
return;
var box = sender as AutoSuggestBox;
var current = box?.Text?.Trim() ?? string.Empty;
if (string.IsNullOrEmpty(current))
{
return; // nothing to restore
}
// If current text matches last query and we have results, reconstruct the suggestion list.
if (string.Equals(current, _lastQueryText, StringComparison.Ordinal) && _lastSearchResults?.Count > 0)
{
try
{
var top = BuildSuggestionItems(current, _lastSearchResults);
box.ItemsSource = top;
box.IsSuggestionListOpen = top.Count > 0;
}
catch (Exception ex)
{
Logger.LogError($"Error restoring suggestion list {ex.Message}");
}
}
}
// Centralized suggestion projection logic used by TextChanged & GotFocus restore.
private List<SuggestionItem> BuildSuggestionItems(string query, List<SettingEntry> results)
{
results ??= new();
if (results.Count == 0)
{
var rl = ResourceLoaderInstance.ResourceLoader;
var noResultsPrefix = rl.GetString("Shell_Search_NoResults");
if (string.IsNullOrEmpty(noResultsPrefix))
{
noResultsPrefix = "No results for";
}
var headerText = $"{noResultsPrefix} '{query}'";
return new List<SuggestionItem>
{
new()
{
Header = headerText,
IsNoResults = true,
},
};
}
var list = results.Take(5).Select(e =>
{
string subtitle = string.Empty;
if (e.Type != EntryType.SettingsPage)
{
subtitle = SearchIndexService.GetLocalizedPageName(e.PageTypeName);
if (string.IsNullOrEmpty(subtitle))
{
subtitle = SearchIndexService.Index
.Where(x => x.Type == EntryType.SettingsPage && x.PageTypeName == e.PageTypeName)
.Select(x => x.Header)
.FirstOrDefault() ?? string.Empty;
}
}
return new SuggestionItem
{
Header = e.Header,
Icon = e.Icon,
PageTypeName = e.PageTypeName,
ElementName = e.ElementName,
ParentElementName = e.ParentElementName,
Subtitle = subtitle,
IsShowAll = false,
};
}).ToList();
if (results.Count > 5)
{
list.Add(new SuggestionItem { IsShowAll = true });
}
return list;
}
private async void SearchBox_QuerySubmitted(AutoSuggestBox sender, AutoSuggestBoxQuerySubmittedEventArgs args)
{
var swSubmit = Stopwatch.StartNew();
Logger.LogDebug("[Search][Submit] start");
// If a suggestion is selected, navigate directly
if (args.ChosenSuggestion is SuggestionItem chosen)
{
Logger.LogDebug($"[Search][Submit] chosen suggestion -> navigate to {chosen.PageTypeName} element={chosen.ElementName ?? "<page>"}");
NavigateFromSuggestion(chosen);
return;
}
@@ -734,7 +703,6 @@ namespace Microsoft.PowerToys.Settings.UI.Views
var queryText = (args.QueryText ?? _lastQueryText)?.Trim();
if (string.IsNullOrWhiteSpace(queryText))
{
Logger.LogDebug("[Search][Submit] empty query -> navigate Dashboard");
NavigationService.Navigate<DashboardPage>();
return;
}
@@ -742,21 +710,10 @@ namespace Microsoft.PowerToys.Settings.UI.Views
// Prefer cached results (from live search); if empty, perform a fresh search
var matched = _lastSearchResults?.Count > 0 && string.Equals(_lastQueryText, queryText, StringComparison.Ordinal)
? _lastSearchResults
: await Task.Run(() =>
{
var sw = Stopwatch.StartNew();
Logger.LogDebug($"[Search][Submit] background search for '{queryText}'...");
var r = SearchIndexService.Search(queryText);
sw.Stop();
Logger.LogDebug($"[Search][Submit] background search done in {sw.ElapsedMilliseconds} ms. results={r?.Count ?? 0}");
return r;
});
: await Task.Run(() => SearchIndexService.Search(queryText));
var searchParams = new SearchResultsNavigationParams(queryText, matched);
Logger.LogDebug($"[Search][Submit] navigate to SearchResultsPage (results={matched?.Count ?? 0})");
NavigationService.Navigate<SearchResultsPage>(searchParams);
swSubmit.Stop();
Logger.LogDebug($"[Search][Submit] total {swSubmit.ElapsedMilliseconds} ms");
}
public void Dispose()

View File

@@ -126,14 +126,21 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
{
var assembly = Assembly.GetExecutingAssembly();
var assemblyName = new AssemblyName(assembly.FullName ?? throw new InvalidOperationException());
// Build the fully-qualified manifest resource name. Historically, subtle casing differences
// (e.g. folder names or the assembly name) caused exact (case-sensitive) lookup failures on
// some developer machines when the embedded resource's actual name differed only by case.
// Manifest resource name comparison here does not need to be case-sensitive, so we resolve
// the actual name using an OrdinalIgnoreCase match, then use the real casing for the stream.
var resourceName = $"Microsoft.{assemblyName.Name}.{filename.Replace("/", ".")}";
var resourceNames = assembly.GetManifestResourceNames();
if (!resourceNames.Contains(resourceName))
var actualResourceName = resourceNames.FirstOrDefault(n => string.Equals(n, resourceName, StringComparison.OrdinalIgnoreCase));
if (actualResourceName is null)
{
throw new InvalidOperationException($"Embedded resource '{resourceName}' does not exist.");
throw new InvalidOperationException($"Embedded resource '{resourceName}' (case-insensitive) does not exist.");
}
var stream = assembly.GetManifestResourceStream(resourceName)
var stream = assembly.GetManifestResourceStream(actualResourceName)
?? throw new InvalidOperationException();
var image = (Bitmap)Image.FromStream(stream);
return image;