diff --git a/src/settings-ui/Settings.UI.XamlIndexBuilder/Program.cs b/src/settings-ui/Settings.UI.XamlIndexBuilder/Program.cs index d3ebad0ff7..125e49c186 100644 --- a/src/settings-ui/Settings.UI.XamlIndexBuilder/Program.cs +++ b/src/settings-ui/Settings.UI.XamlIndexBuilder/Program.cs @@ -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 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(); - var xamlFiles = Directory.GetFiles(xamlDirectory, "*.xaml", SearchOption.AllDirectories); + var processedFiles = new HashSet(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; diff --git a/src/settings-ui/Settings.UI.XamlIndexBuilder/Settings.UI.XamlIndexBuilder.csproj b/src/settings-ui/Settings.UI.XamlIndexBuilder/Settings.UI.XamlIndexBuilder.csproj index ef37b3eca9..f8138b6bfa 100644 --- a/src/settings-ui/Settings.UI.XamlIndexBuilder/Settings.UI.XamlIndexBuilder.csproj +++ b/src/settings-ui/Settings.UI.XamlIndexBuilder/Settings.UI.XamlIndexBuilder.csproj @@ -29,16 +29,15 @@ - dotnet - $(MSBuildProjectDirectory)\..\Settings.UI\SettingsXAML\Views + $(MSBuildProjectDirectory)\..\Settings.UI\SettingsXAML + $([System.IO.Path]::GetDirectoryName('$(XamlViewsDir)')) $(MSBuildProjectDirectory)\..\Settings.UI\Assets\Settings\search.index.json - - - + + diff --git a/src/settings-ui/Settings.UI/Helpers/NavigablePage.cs b/src/settings-ui/Settings.UI/Helpers/NavigablePage.cs index 742d55dd62..51c89875ab 100644 --- a/src/settings-ui/Settings.UI/Helpers/NavigablePage.cs +++ b/src/settings-ui/Settings.UI/Helpers/NavigablePage.cs @@ -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(); + 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(); + 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; } } diff --git a/src/settings-ui/Settings.UI/PowerToys.Settings.csproj b/src/settings-ui/Settings.UI/PowerToys.Settings.csproj index 655011e074..18ee80a5bf 100644 --- a/src/settings-ui/Settings.UI/PowerToys.Settings.csproj +++ b/src/settings-ui/Settings.UI/PowerToys.Settings.csproj @@ -181,9 +181,6 @@ - - - diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Panels/MouseJumpPanel.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Panels/MouseJumpPanel.xaml index 22d996efb2..478cd994cc 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Panels/MouseJumpPanel.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Panels/MouseJumpPanel.xaml @@ -24,6 +24,7 @@ @@ -42,6 +43,7 @@ @@ -126,6 +128,7 @@ /// Initializes a new instance of the 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 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 BuildSuggestionItems(string query, List 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 + { + 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 ?? ""}"); 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(); 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(searchParams); - swSubmit.Stop(); - Logger.LogDebug($"[Search][Submit] total {swSubmit.ElapsedMilliseconds} ms"); } public void Dispose() diff --git a/src/settings-ui/Settings.UI/ViewModels/MouseUtilsViewModel_MouseJump.cs b/src/settings-ui/Settings.UI/ViewModels/MouseUtilsViewModel_MouseJump.cs index 2ccd510bc9..e954976f2e 100644 --- a/src/settings-ui/Settings.UI/ViewModels/MouseUtilsViewModel_MouseJump.cs +++ b/src/settings-ui/Settings.UI/ViewModels/MouseUtilsViewModel_MouseJump.cs @@ -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;