mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-04-09 20:57:22 +02:00
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:
@@ -19,6 +19,13 @@ namespace Microsoft.PowerToys.Tools.XamlIndexBuilder
|
|||||||
"ShellPage.xaml",
|
"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()
|
private static JsonSerializerOptions serializeOption = new()
|
||||||
{
|
{
|
||||||
WriteIndented = true,
|
WriteIndented = true,
|
||||||
@@ -33,32 +40,117 @@ namespace Microsoft.PowerToys.Tools.XamlIndexBuilder
|
|||||||
Environment.Exit(1);
|
Environment.Exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
string xamlDirectory = args[0];
|
string xamlRootDirectory = args[0];
|
||||||
string outputFile = args[1];
|
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);
|
Environment.Exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var searchableElements = new List<SettingEntry>();
|
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 (!Directory.Exists(root))
|
||||||
if (ExcludedXamlFiles.Contains(fileName))
|
|
||||||
{
|
{
|
||||||
// Skip ShellPage.xaml as it contains many elements not relevant for search
|
return;
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Debug.WriteLine($"Processing: {fileName}");
|
Debug.WriteLine($"[XamlIndexBuilder] Scanning root: {root}");
|
||||||
var elements = ExtractSearchableElements(xamlFile);
|
var xamlFilesLocal = Directory.GetFiles(root, "*.xaml", SearchOption.AllDirectories);
|
||||||
searchableElements.AddRange(elements);
|
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();
|
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.Name.LocalName == "SettingsPageControl")
|
||||||
.Where(e => e.Attribute(x + "Uid") != null);
|
.Where(e => e.Attribute(x + "Uid") != null);
|
||||||
|
|
||||||
// Extract SettingsCard elements
|
// Extract SettingsCard elements (support both Name and x:Name)
|
||||||
var settingsElements = doc.Descendants()
|
var settingsElements = doc.Descendants()
|
||||||
.Where(e => e.Name.LocalName == "SettingsCard")
|
.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()
|
var settingsExpanderElements = doc.Descendants()
|
||||||
.Where(e => e.Name.LocalName == "SettingsExpander")
|
.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
|
// Process SettingsPageControl elements
|
||||||
foreach (var element in settingsPageElements)
|
foreach (var element in settingsPageElements)
|
||||||
@@ -185,8 +277,12 @@ namespace Microsoft.PowerToys.Tools.XamlIndexBuilder
|
|||||||
|
|
||||||
public static string GetElementName(XElement element, XNamespace x)
|
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;
|
var name = element.Attribute("Name")?.Value;
|
||||||
|
if (string.IsNullOrEmpty(name))
|
||||||
|
{
|
||||||
|
name = element.Attribute(x + "Name")?.Value;
|
||||||
|
}
|
||||||
|
|
||||||
return name;
|
return name;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,6 +307,11 @@ namespace Microsoft.PowerToys.Tools.XamlIndexBuilder
|
|||||||
if (expanderParent?.Name.LocalName == "SettingsExpander")
|
if (expanderParent?.Name.LocalName == "SettingsExpander")
|
||||||
{
|
{
|
||||||
var expanderName = expanderParent.Attribute("Name")?.Value;
|
var expanderName = expanderParent.Attribute("Name")?.Value;
|
||||||
|
if (string.IsNullOrEmpty(expanderName))
|
||||||
|
{
|
||||||
|
expanderName = expanderParent.Attribute(x + "Name")?.Value;
|
||||||
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(expanderName))
|
if (!string.IsNullOrEmpty(expanderName))
|
||||||
{
|
{
|
||||||
return expanderName;
|
return expanderName;
|
||||||
@@ -221,6 +322,11 @@ namespace Microsoft.PowerToys.Tools.XamlIndexBuilder
|
|||||||
{
|
{
|
||||||
// Direct child of SettingsExpander
|
// Direct child of SettingsExpander
|
||||||
var expanderName = current.Attribute("Name")?.Value;
|
var expanderName = current.Attribute("Name")?.Value;
|
||||||
|
if (string.IsNullOrEmpty(expanderName))
|
||||||
|
{
|
||||||
|
expanderName = current.Attribute(x + "Name")?.Value;
|
||||||
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(expanderName))
|
if (!string.IsNullOrEmpty(expanderName))
|
||||||
{
|
{
|
||||||
return expanderName;
|
return expanderName;
|
||||||
|
|||||||
@@ -29,16 +29,15 @@
|
|||||||
<!-- Remove UI library reference to avoid pulling WindowsDesktop runtime (WindowsBase) -->
|
<!-- Remove UI library reference to avoid pulling WindowsDesktop runtime (WindowsBase) -->
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<!-- Fallback to dotnet if not provided by the environment -->
|
|
||||||
<DotNetExe Condition="'$(DotNetExe)' == ''">dotnet</DotNetExe>
|
<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>
|
<GeneratedJsonFile Condition="'$(GeneratedJsonFile)' == ''">$(MSBuildProjectDirectory)\..\Settings.UI\Assets\Settings\search.index.json</GeneratedJsonFile>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<Target Name="GenerateSearchIndexSelf" AfterTargets="Build">
|
<Target Name="GenerateSearchIndexSelf" AfterTargets="Build">
|
||||||
<RemoveDir Directories="$(MSBuildProjectDirectory)\obj\ARM64;$(MSBuildProjectDirectory)\obj\x64;$(MSBuildProjectDirectory)\bin" />
|
<RemoveDir Directories="$(MSBuildProjectDirectory)\obj\ARM64;$(MSBuildProjectDirectory)\obj\x64;$(MSBuildProjectDirectory)\bin" />
|
||||||
<MakeDir Directories="$([System.IO.Path]::GetDirectoryName('$(GeneratedJsonFile)'))" />
|
<MakeDir Directories="$([System.IO.Path]::GetDirectoryName('$(GeneratedJsonFile)'))" />
|
||||||
<Message Importance="high" Text="[XamlIndexBuilder] Generating search index. Views='$(XamlViewsDir)'; Out='$(GeneratedJsonFile)'; Tool='$(TargetPath)'; DotNet='$(DotNetExe)'." />
|
<Message Importance="high" Text="[XamlIndexBuilder] Generating search index. Root='$(XamlRootDir)'; Out='$(GeneratedJsonFile)'; Tool='$(TargetPath)'; DotNet='$(DotNetExe)'." />
|
||||||
<!-- Execute via dotnet so host architecture doesn't need to match -->
|
<Exec Command=""$(DotNetExe)" "$(TargetPath)" "$(XamlRootDir)" "$(GeneratedJsonFile)"" />
|
||||||
<Exec Command=""$(DotNetExe)" "$(TargetPath)" "$(XamlViewsDir)" "$(GeneratedJsonFile)"" />
|
|
||||||
</Target>
|
</Target>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -80,6 +80,9 @@ public abstract partial class NavigablePage : Page
|
|||||||
return;
|
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
|
// Get the visual and compositor
|
||||||
var visual = ElementCompositionPreview.GetElementVisual(target);
|
var visual = ElementCompositionPreview.GetElementVisual(target);
|
||||||
var compositor = visual.Compositor;
|
var compositor = visual.Compositor;
|
||||||
@@ -113,9 +116,129 @@ public abstract partial class NavigablePage : Page
|
|||||||
ElementCompositionPreview.SetElementChildVisual(target, null);
|
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)
|
protected FrameworkElement FindElementByName(string name)
|
||||||
{
|
{
|
||||||
var element = this.FindName(name) as FrameworkElement;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,9 +181,6 @@
|
|||||||
</Page>
|
</Page>
|
||||||
</ItemGroup>
|
</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">
|
<Target Name="BuildXamlIndexBeforeSettings" BeforeTargets="CoreCompile">
|
||||||
<Message Importance="high" Text="[Settings] Building XamlIndexBuilder prior to compile. Views='$(MSBuildProjectDirectory)\SettingsXAML\Views' Out='$(GeneratedJsonFile)'" />
|
<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)" />
|
<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)" />
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
<controls:SettingsGroup x:Uid="MouseUtils_MouseJump">
|
<controls:SettingsGroup x:Uid="MouseUtils_MouseJump">
|
||||||
|
|
||||||
<tkcontrols:SettingsCard
|
<tkcontrols:SettingsCard
|
||||||
|
x:Name="MouseUtilsEnableMouseJump"
|
||||||
x:Uid="MouseUtils_Enable_MouseJump"
|
x:Uid="MouseUtils_Enable_MouseJump"
|
||||||
HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/MouseJump.png}"
|
HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/MouseJump.png}"
|
||||||
IsEnabled="{x:Bind ViewModel.IsJumpEnabledGpoConfigured, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}">
|
IsEnabled="{x:Bind ViewModel.IsJumpEnabledGpoConfigured, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}">
|
||||||
@@ -42,6 +43,7 @@
|
|||||||
</InfoBar>
|
</InfoBar>
|
||||||
|
|
||||||
<tkcontrols:SettingsCard
|
<tkcontrols:SettingsCard
|
||||||
|
x:Name="MouseUtilsMouseJumpActivationShortcut"
|
||||||
x:Uid="MouseUtils_MouseJump_ActivationShortcut"
|
x:Uid="MouseUtils_MouseJump_ActivationShortcut"
|
||||||
HeaderIcon="{ui:FontIcon Glyph=}"
|
HeaderIcon="{ui:FontIcon Glyph=}"
|
||||||
IsEnabled="{x:Bind ViewModel.IsMouseJumpEnabled, Mode=OneWay}">
|
IsEnabled="{x:Bind ViewModel.IsMouseJumpEnabled, Mode=OneWay}">
|
||||||
@@ -126,6 +128,7 @@
|
|||||||
</tkcontrols:SettingsCard>
|
</tkcontrols:SettingsCard>
|
||||||
|
|
||||||
<tkcontrols:SettingsExpander
|
<tkcontrols:SettingsExpander
|
||||||
|
x:Name="MouseUtilsMouseJumpAppearance"
|
||||||
x:Uid="MouseUtils_MouseJump_Appearance"
|
x:Uid="MouseUtils_MouseJump_Appearance"
|
||||||
HeaderIcon="{ui:FontIcon Glyph=}"
|
HeaderIcon="{ui:FontIcon Glyph=}"
|
||||||
IsEnabled="{x:Bind ViewModel.IsMouseJumpEnabled, Mode=OneWay}"
|
IsEnabled="{x:Bind ViewModel.IsMouseJumpEnabled, Mode=OneWay}"
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Diagnostics;
|
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
@@ -142,8 +141,7 @@ namespace Microsoft.PowerToys.Settings.UI.Views
|
|||||||
private const int SearchDebounceMs = 500;
|
private const int SearchDebounceMs = 500;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
|
||||||
// Tracing id for correlating logs of a single search interaction
|
// Removed trace id counter per cleanup
|
||||||
private static long _searchTraceIdCounter;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="ShellPage"/> class.
|
/// 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)
|
private void ShellPage_Loaded(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
Logger.LogDebug("[Search][Index] Scheduling BuildIndex...");
|
|
||||||
var swIndex = Stopwatch.StartNew();
|
|
||||||
Task.Run(() =>
|
Task.Run(() =>
|
||||||
{
|
{
|
||||||
Logger.LogDebug("[Search][Index] BuildIndex started");
|
|
||||||
SearchIndexService.BuildIndex();
|
SearchIndexService.BuildIndex();
|
||||||
})
|
})
|
||||||
.ContinueWith(t =>
|
.ContinueWith(_ => { });
|
||||||
{
|
|
||||||
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.");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void NavigationView_DisplayModeChanged(NavigationView sender, NavigationViewDisplayModeChangedEventArgs args)
|
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 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
|
// Debounce: cancel previous pending search
|
||||||
_searchDebounceCts?.Cancel();
|
_searchDebounceCts?.Cancel();
|
||||||
_searchDebounceCts?.Dispose();
|
_searchDebounceCts?.Dispose();
|
||||||
@@ -528,7 +508,6 @@ namespace Microsoft.PowerToys.Settings.UI.Views
|
|||||||
sender.IsSuggestionListOpen = false;
|
sender.IsSuggestionListOpen = false;
|
||||||
_lastSearchResults.Clear();
|
_lastSearchResults.Clear();
|
||||||
_lastQueryText = string.Empty;
|
_lastQueryText = string.Empty;
|
||||||
Logger.LogDebug($"[Search][TextChanged][{traceId}] empty query. end");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -538,14 +517,11 @@ namespace Microsoft.PowerToys.Settings.UI.Views
|
|||||||
}
|
}
|
||||||
catch (TaskCanceledException)
|
catch (TaskCanceledException)
|
||||||
{
|
{
|
||||||
// A newer keystroke arrived; abandon this run
|
return; // debounce canceled
|
||||||
Logger.LogDebug($"[Search][TextChanged][{traceId}] debounce canceled at +{swOverall.ElapsedMilliseconds} ms");
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (token.IsCancellationRequested)
|
if (token.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
Logger.LogDebug($"[Search][TextChanged][{traceId}] token canceled post-debounce at +{swOverall.ElapsedMilliseconds} ms");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -554,106 +530,25 @@ namespace Microsoft.PowerToys.Settings.UI.Views
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
// If the token is already canceled before scheduling, the task won't start.
|
// 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);
|
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)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
Logger.LogDebug($"[Search][TextChanged][{traceId}] search canceled at +{swOverall.ElapsedMilliseconds} ms");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (token.IsCancellationRequested)
|
if (token.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
Logger.LogDebug($"[Search][TextChanged][{traceId}] token canceled after search at +{swOverall.ElapsedMilliseconds} ms");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_lastSearchResults = results;
|
_lastSearchResults = results;
|
||||||
_lastQueryText = query;
|
_lastQueryText = query;
|
||||||
|
|
||||||
List<SuggestionItem> top;
|
var top = BuildSuggestionItems(query, results);
|
||||||
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 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.ItemsSource = top;
|
||||||
sender.IsSuggestionListOpen = top.Count > 0;
|
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)
|
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)
|
private void SearchBox_GotFocus(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
// do not prompt unless search for text.
|
var box = sender as AutoSuggestBox;
|
||||||
return;
|
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)
|
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 a suggestion is selected, navigate directly
|
||||||
if (args.ChosenSuggestion is SuggestionItem chosen)
|
if (args.ChosenSuggestion is SuggestionItem chosen)
|
||||||
{
|
{
|
||||||
Logger.LogDebug($"[Search][Submit] chosen suggestion -> navigate to {chosen.PageTypeName} element={chosen.ElementName ?? "<page>"}");
|
|
||||||
NavigateFromSuggestion(chosen);
|
NavigateFromSuggestion(chosen);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -734,7 +703,6 @@ namespace Microsoft.PowerToys.Settings.UI.Views
|
|||||||
var queryText = (args.QueryText ?? _lastQueryText)?.Trim();
|
var queryText = (args.QueryText ?? _lastQueryText)?.Trim();
|
||||||
if (string.IsNullOrWhiteSpace(queryText))
|
if (string.IsNullOrWhiteSpace(queryText))
|
||||||
{
|
{
|
||||||
Logger.LogDebug("[Search][Submit] empty query -> navigate Dashboard");
|
|
||||||
NavigationService.Navigate<DashboardPage>();
|
NavigationService.Navigate<DashboardPage>();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -742,21 +710,10 @@ namespace Microsoft.PowerToys.Settings.UI.Views
|
|||||||
// Prefer cached results (from live search); if empty, perform a fresh search
|
// Prefer cached results (from live search); if empty, perform a fresh search
|
||||||
var matched = _lastSearchResults?.Count > 0 && string.Equals(_lastQueryText, queryText, StringComparison.Ordinal)
|
var matched = _lastSearchResults?.Count > 0 && string.Equals(_lastQueryText, queryText, StringComparison.Ordinal)
|
||||||
? _lastSearchResults
|
? _lastSearchResults
|
||||||
: await Task.Run(() =>
|
: await Task.Run(() => SearchIndexService.Search(queryText));
|
||||||
{
|
|
||||||
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;
|
|
||||||
});
|
|
||||||
|
|
||||||
var searchParams = new SearchResultsNavigationParams(queryText, matched);
|
var searchParams = new SearchResultsNavigationParams(queryText, matched);
|
||||||
Logger.LogDebug($"[Search][Submit] navigate to SearchResultsPage (results={matched?.Count ?? 0})");
|
|
||||||
NavigationService.Navigate<SearchResultsPage>(searchParams);
|
NavigationService.Navigate<SearchResultsPage>(searchParams);
|
||||||
swSubmit.Stop();
|
|
||||||
Logger.LogDebug($"[Search][Submit] total {swSubmit.ElapsedMilliseconds} ms");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
|
|||||||
@@ -126,14 +126,21 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
|||||||
{
|
{
|
||||||
var assembly = Assembly.GetExecutingAssembly();
|
var assembly = Assembly.GetExecutingAssembly();
|
||||||
var assemblyName = new AssemblyName(assembly.FullName ?? throw new InvalidOperationException());
|
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 resourceName = $"Microsoft.{assemblyName.Name}.{filename.Replace("/", ".")}";
|
||||||
var resourceNames = assembly.GetManifestResourceNames();
|
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();
|
?? throw new InvalidOperationException();
|
||||||
var image = (Bitmap)Image.FromStream(stream);
|
var image = (Bitmap)Image.FromStream(stream);
|
||||||
return image;
|
return image;
|
||||||
|
|||||||
Reference in New Issue
Block a user