From 4f6bd4f52c605dcaafa19e5d047a5f7b84d3cc70 Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Tue, 14 Jul 2026 16:13:53 -0400 Subject: [PATCH 1/2] Add settings search to the title-bar box --- .../Infrastructure/SettingsAnchor.cs | 94 ++++++ .../Infrastructure/SettingsSearchIndex.cs | 282 ++++++++++++++++++ .../ViewModels/MainWindowViewModel.cs | 67 ++++- src/UniGetUI.Avalonia/Views/MainWindow.axaml | 39 +++ .../Views/MainWindow.axaml.cs | 22 +- .../Pages/SettingsPages/Administrator.axaml | 24 +- .../Views/Pages/SettingsPages/Backup.axaml | 21 +- .../Pages/SettingsPages/Experimental.axaml | 18 +- .../Views/Pages/SettingsPages/General.axaml | 24 +- .../Pages/SettingsPages/ISettingsPage.cs | 11 + .../Pages/SettingsPages/Interface_P.axaml | 12 +- .../Views/Pages/SettingsPages/Internet.axaml | 12 +- .../Pages/SettingsPages/Notifications.axaml | 18 +- .../Pages/SettingsPages/Operations.axaml | 6 +- .../SettingsPages/SettingsBasePage.axaml.cs | 31 +- .../Views/Pages/SettingsPages/Updates.axaml | 18 +- 16 files changed, 642 insertions(+), 57 deletions(-) create mode 100644 src/UniGetUI.Avalonia/Infrastructure/SettingsAnchor.cs create mode 100644 src/UniGetUI.Avalonia/Infrastructure/SettingsSearchIndex.cs diff --git a/src/UniGetUI.Avalonia/Infrastructure/SettingsAnchor.cs b/src/UniGetUI.Avalonia/Infrastructure/SettingsAnchor.cs new file mode 100644 index 0000000000..0192c04ad5 --- /dev/null +++ b/src/UniGetUI.Avalonia/Infrastructure/SettingsAnchor.cs @@ -0,0 +1,94 @@ +using Avalonia; +using Avalonia.Animation; +using Avalonia.Animation.Easings; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Interactivity; +using Avalonia.Media; +using Avalonia.Styling; +using Avalonia.Threading; +using Avalonia.VisualTree; + +namespace UniGetUI.Avalonia.Infrastructure; + +/// +/// Scrolls a named control inside a settings sub-page into view and briefly highlights it, +/// so a search result lands the user on the exact setting they typed. +/// +public static class SettingsAnchor +{ + public static void ScrollToAndHighlight(Control root, string anchorName) + { + // The page may still be sliding into the transitioning frame; wait until it's attached + // and laid out so BringIntoView has real bounds to work with. + if (root.IsLoaded) + { + Dispatcher.UIThread.Post(() => Run(root, anchorName), DispatcherPriority.Loaded); + return; + } + + void OnLoaded(object? sender, RoutedEventArgs e) + { + root.Loaded -= OnLoaded; + Dispatcher.UIThread.Post(() => Run(root, anchorName), DispatcherPriority.Loaded); + } + + root.Loaded += OnLoaded; + } + + private static void Run(Control root, string anchorName) + { + var target = root.GetVisualDescendants() + .OfType() + .FirstOrDefault(c => c.Name == anchorName); + if (target is null) return; + + // A SettingsCard's visible surface is an inner Border.settings-card inset 40px per side; + // adorn that so the outline hugs the card rather than the full-width control wrapper. + Control highlightTarget = target.GetVisualDescendants() + .OfType() + .FirstOrDefault(b => b.Classes.Contains("settings-card")) + ?? target; + + highlightTarget.BringIntoView(); + Highlight(highlightTarget); + } + + private static void Highlight(Control target) + { + var layer = AdornerLayer.GetAdornerLayer(target); + if (layer is null) return; + + Color accent = target.TryFindResource("SystemAccentColor", target.ActualThemeVariant, out var res) + && res is Color c ? c : Colors.DodgerBlue; + + var adorner = new Border + { + BorderBrush = new SolidColorBrush(accent), + BorderThickness = new Thickness(2), + CornerRadius = target is Border b ? b.CornerRadius : new CornerRadius(8), + IsHitTestVisible = false, + }; + AdornerLayer.SetAdornedElement(adorner, target); + layer.Children.Add(adorner); + + void Remove() { if (layer.Children.Contains(adorner)) layer.Children.Remove(adorner); } + + // A gentle pulse that holds, then fades out and removes itself. + var fade = new Animation + { + Duration = TimeSpan.FromMilliseconds(1800), + Easing = new CubicEaseOut(), + FillMode = FillMode.Forward, + Children = + { + new KeyFrame { Cue = new Cue(0d), Setters = { new Setter(Visual.OpacityProperty, 1d) } }, + new KeyFrame { Cue = new Cue(0.55d), Setters = { new Setter(Visual.OpacityProperty, 1d) } }, + new KeyFrame { Cue = new Cue(1d), Setters = { new Setter(Visual.OpacityProperty, 0d) } }, + }, + }; + _ = fade.RunAsync(adorner).ContinueWith( + _ => Dispatcher.UIThread.Post(Remove), + TaskScheduler.Default); + } +} diff --git a/src/UniGetUI.Avalonia/Infrastructure/SettingsSearchIndex.cs b/src/UniGetUI.Avalonia/Infrastructure/SettingsSearchIndex.cs new file mode 100644 index 0000000000..4b826a8871 --- /dev/null +++ b/src/UniGetUI.Avalonia/Infrastructure/SettingsSearchIndex.cs @@ -0,0 +1,282 @@ +using System.Globalization; +using System.Text; +using UniGetUI.Avalonia.Views.Pages.SettingsPages; +using UniGetUI.Core.Tools; +using UniGetUI.PackageEngine; +using UniGetUI.PackageEngine.Interfaces; + +namespace UniGetUI.Avalonia.Infrastructure; + +/// +/// A single searchable destination inside the settings/managers tree. +/// +public sealed class SettingsSearchResult +{ + public required string Title { get; init; } // translated section/setting name + public required string PageTitle { get; init; } // translated breadcrumb (containing page) + public Type? PageType { get; init; } // settings sub-page to open + public IPackageManager? Manager { get; init; } // or, a package manager to open + public string? Anchor { get; init; } // x:Name of the control to scroll to +} + +/// +/// Hand-authored index that maps a typed query to a settings destination. Titles are the same +/// English source strings used by in the AXAML, so the +/// index matches in the user's language; the English keywords keep those terms searchable in any +/// locale. +/// +public static class SettingsSearchIndex +{ + private sealed record Entry(string Title, string[] Keywords, Type PageType, string? Anchor); + + // Order matters only as a tie-break for equally-ranked matches. + private static readonly Entry[] Entries = + [ + // ── General ────────────────────────────────────────────────────────── + // ── General ────────────────────────────────────────────────────────── + new("UniGetUI display language:", ["language", "lang", "locale", "translation", "idioma"], typeof(General), "LanguageSelector"), + new("Update UniGetUI automatically", ["app update", "auto update", "self update"], typeof(General), "GeneralUpdaterCard"), + new("Install prerelease versions of UniGetUI", ["beta", "prerelease", "preview"], typeof(General), "EnableUniGetUIBetaCard"), + new("Show the release notes after UniGetUI is updated", ["release notes", "changelog"], typeof(General), "ReleaseNotesOnUpdateCard"), + new("Manage telemetry settings", ["telemetry", "usage data", "analytics"], typeof(General), "GeneralTelemetryCard"), + new("Hide my username from the logs", ["privacy", "redact", "username", "logs"], typeof(General), "GeneralPrivacyCard"), + new("Import settings from a local file", ["import settings"], typeof(General), "GeneralImportCard"), + new("Export settings to a local file", ["export settings"], typeof(General), "ExportSettingsCard"), + new("Reset UniGetUI", ["reset", "factory reset", "reset settings"], typeof(General), "ResetSettingsCard"), + new("General preferences", ["general"], typeof(General), null), + + // ── User interface ─────────────────────────────────────────────────── + new("Application theme:", ["appearance", "theme", "dark", "light", "mica"], typeof(Interface_P), "ThemeSelector"), + new("UniGetUI startup page:", ["startup page", "default page", "home page", "landing page"], typeof(Interface_P), "StartupPageSelector"), + new("Navigation menu:", ["sidebar", "nav menu", "navigation", "layout", "docked", "overlay"], typeof(Interface_P), "NavMenuModeSelector"), + new("Automatically switch to software rendering when Windows has no hardware GPU", ["rendering", "gpu", "software rendering"], typeof(Interface_P), "InterfaceRenderingCard"), + new("Close UniGetUI to the system tray", ["system tray", "background", "minimize to tray"], typeof(Interface_P), "DisableSystemTrayCard"), + new("Manage UniGetUI autostart behaviour", ["autostart", "run at login", "startup"], typeof(Interface_P), "EditAutostartSettings"), + new("Show package icons on package lists", ["package icons"], typeof(Interface_P), "InterfacePackageListsCard"), + new("Clear the icon cache", ["icon cache", "clear cache", "cache size"], typeof(Interface_P), "ResetIconCache"), + new("Select upgradable packages by default", ["select updates", "select upgradable"], typeof(Interface_P), "SelectUpgradableCard"), + new("User interface preferences", ["interface", "ui"], typeof(Interface_P), null), + + // ── Notifications ──────────────────────────────────────────────────── + new("Enable UniGetUI notifications", ["notifications", "enable notifications"], typeof(Notifications), "NotificationsMainCard"), + new("Show an in-app summary toast when a batch of operations finishes", ["summary toast", "batch summary"], typeof(Notifications), "SummaryToastCard"), + new("Show a notification when there are available updates", ["update notification"], typeof(Notifications), "NotificationTypesCard"), + new("Show a silent notification when an operation is running", ["progress notification", "silent notification"], typeof(Notifications), "ProgressNotificationCard"), + new("Show a notification when an operation fails", ["error notification", "failure notification"], typeof(Notifications), "ErrorNotificationCard"), + new("Show a notification when an operation finishes successfully", ["success notification"], typeof(Notifications), "SuccessNotificationCard"), + new("Notification preferences", ["notifications"], typeof(Notifications), null), + + // ── Updates ────────────────────────────────────────────────────────── + new("Check for package updates periodically", ["check for updates", "periodically"], typeof(Updates), "UpdatesCheckingCard"), + new("Check for updates every:", ["update frequency", "update interval"], typeof(Updates), "UpdatesCheckIntervalSelector"), + new("Install available updates automatically", ["automatic updates", "auto install updates"], typeof(Updates), "UpdatesAutomaticCard"), + new("Do not automatically install updates when the network connection is metered", ["metered connection"], typeof(Updates), "AUPMeteredCard"), + new("Do not automatically install updates when the device runs on battery", ["battery"], typeof(Updates), "AUPBatteryCard"), + new("Do not automatically install updates when the battery saver is on", ["battery saver"], typeof(Updates), "AUPBatterySaverCard"), + new("Minimum age for updates", ["minimum age", "update security"], typeof(Updates), "MinimumUpdateAgeSelector"), + new("Custom minimum age (days)", ["custom age"], typeof(Updates), "MinimumUpdateAgeCustomInput"), + new("Warn me when the installer URL host changes between the installed version and the new version (WinGet only)", ["installer host", "url host"], typeof(Updates), "InstallerHostWarningCard"), + new("Package update preferences", ["updates"], typeof(Updates), null), + + // ── Operations ─────────────────────────────────────────────────────── + new("Choose how many operations should be performed in parallel", ["parallel", "concurrency"], typeof(Operations), "ParallelOperationCount"), + new("Clear successful operations from the operation list after a 5 second delay", ["clear successful", "maintain installs"], typeof(Operations), "ClearSuccessfulOpsCard"), + new("Try to kill the processes that refuse to close when requested to", ["kill processes"], typeof(Operations), "KillProcessesCard"), + new("Ask to delete desktop shortcuts created during an install or upgrade.", ["desktop shortcuts", "shortcut remover"], typeof(Operations), "AskToDeleteNewDesktopShortcuts"), + new("Package operation preferences", ["operations"], typeof(Operations), null), + + // ── Internet ───────────────────────────────────────────────────────── + new("Connect the internet using a custom proxy", ["proxy"], typeof(Internet), "ProxyCard"), + new("Proxy URL", ["proxy url"], typeof(Internet), "ProxyURLCard"), + new("Authenticate to the proxy with a user and a password", ["proxy authentication", "proxy password"], typeof(Internet), "ProxyAuthCard"), + new("Wait for the device to be connected to the internet before attempting to do tasks that require internet connectivity.", ["wait for internet", "connection"], typeof(Internet), "InternetOtherCard"), + new("Internet connection settings", ["internet", "proxy"], typeof(Internet), null), + + // ── Backup ─────────────────────────────────────────────────────────── + new("Log in with GitHub", ["github login", "sign in"], typeof(Backup), "CloudBackupCard"), + new("Periodically perform a cloud backup of the installed packages", ["cloud backup", "gist"], typeof(Backup), "CloudBackupCard"), + new("Perform a cloud backup now", ["cloud backup now"], typeof(Backup), "CloudBackupNowCard"), + new("Restore a backup from the cloud", ["restore cloud backup"], typeof(Backup), "RestoreCloudCard"), + new("Periodically perform a local backup of the installed packages", ["local backup"], typeof(Backup), "LocalBackupCard"), + new("Perform a local backup now", ["local backup now"], typeof(Backup), "BackupNowButton_LOCAL"), + new("Change backup output directory", ["backup directory", "backup folder"], typeof(Backup), "BackupDirectoryCard"), + new("Set a custom backup file name", ["backup file name"], typeof(Backup), "BackupFileNameCard"), + new("Add a timestamp to the backup file names", ["backup timestamp"], typeof(Backup), "BackupTimestampCard"), + new("Package backup", ["backup"], typeof(Backup), null), + + // ── Administrator ──────────────────────────────────────────────────── + new("Ask for administrator privileges once for each batch of operations", ["administrator", "admin rights", "elevation", "uac", "batch"], typeof(Administrator), "AdminElevationCard"), + new("Ask only once for administrator privileges", ["admin once", "cache admin rights"], typeof(Administrator), "CacheAdminOnceCard"), + new("Prohibit any kind of Elevation via UniGetUI Elevator or GSudo", ["prohibit elevation", "no elevation"], typeof(Administrator), "ProhibitElevationCard"), + new("Allow custom command-line arguments", ["command line arguments", "cli arguments"], typeof(Administrator), "AdminRestrictionsOpsCard"), + new("Ignore custom pre-install and post-install commands when importing packages from a bundle", ["pre-install commands", "post-install commands"], typeof(Administrator), "PrePostCommandCard"), + new("Allow changing the paths for package manager executables", ["manager paths", "executable path"], typeof(Administrator), "AdminManagerPathsCard"), + new("Allow importing custom command-line arguments when importing packages from a bundle", ["import cli arguments"], typeof(Administrator), "ImportCLIArgsCard"), + new("Allow importing custom pre-install and post-install commands when importing packages from a bundle", ["import commands"], typeof(Administrator), "ImportPrePostCard"), + new("Administrator rights and other dangerous settings", ["administrator", "dangerous"], typeof(Administrator), null), + + // ── Experimental ───────────────────────────────────────────────────── + new("Show UniGetUI's version and build number on the titlebar.", ["version number", "titlebar", "build number"], typeof(Experimental), "ShowVersionNumberOnTitlebar"), + new("Enable background api (UniGetUI Widgets and Sharing, port 7058)", ["api", "widgets", "sharing", "port"], typeof(Experimental), "BackgroundApiCard"), + new("Disable the 1-minute timeout for package-related operations", ["timeout"], typeof(Experimental), "DisableTimeoutCard"), + new("Use installed GSudo instead of UniGetUI Elevator", ["gsudo", "elevator"], typeof(Experimental), "ForceGSudoCard"), + new("Use a custom icon and screenshot database URL", ["icon database", "screenshot database"], typeof(Experimental), "IconDatabaseURLCard"), + new("Enable background CPU Usage optimizations (see Pull Request #3278)", ["cpu", "optimizations"], typeof(Experimental), "CpuOptimizationsCard"), + new("Perform integrity checks at startup", ["integrity checks"], typeof(Experimental), "IntegrityChecksCard"), + new("When batch installing packages from a bundle, install also packages that are already installed", ["bundle install installed"], typeof(Experimental), "InstallInstalledBundleCard"), + new("Experimental settings and developer options", ["experimental", "developer"], typeof(Experimental), null), + ]; + + private const int NoWordMatch = 100; // a single query word matched nothing + private const int NoMatch = int.MaxValue; + + public static IReadOnlyList Search(string query, int limit = 8) + { + var queryWords = Tokenize(query); + if (queryWords.Count == 0) return []; + + var scored = new List<(int score, SettingsSearchResult result)>(); + + foreach (var e in Entries) + { + int score = Rank(queryWords, CoreTools.Translate(e.Title), e.Title, e.Keywords); + if (score < NoMatch) + scored.Add((score, new SettingsSearchResult + { + Title = CleanTitle(CoreTools.Translate(e.Title)), + PageTitle = PageTitle(e.PageType), + PageType = e.PageType, + Anchor = e.Anchor, + })); + } + + foreach (var manager in PEInterface.Managers) + { + int score = Rank(queryWords, manager.DisplayName, manager.DisplayName, [manager.Name]); + if (score < NoMatch) + scored.Add((score, new SettingsSearchResult + { + Title = manager.DisplayName, + PageTitle = CoreTools.Translate("Package manager preferences"), + Manager = manager, + })); + } + + return scored + .OrderBy(x => x.score) + .ThenBy(x => x.result.Title, StringComparer.CurrentCultureIgnoreCase) + .Take(limit) + .Select(x => x.result) + .ToList(); + } + + // Token-based match: every query word is scored independently against the entry's title words + // (translated + English source) and keyword words. Entries where at least one query word matches + // are ranked by (words that matched nothing) first, then match quality — so a superset query like + // "application themes" still finds "Application theme", and plurals like "themes" match "theme". + // Lower is better; NoMatch means nothing matched. + private static int Rank(List queryWords, string translatedTitle, string englishTitle, string[] keywords) + { + var titleWords = Tokenize(translatedTitle); + foreach (var w in Tokenize(englishTitle)) + if (!titleWords.Contains(w)) titleWords.Add(w); + + var keywordWords = new List(); + foreach (var kw in keywords) + keywordWords.AddRange(Tokenize(kw)); + + int unmatched = 0; + int quality = 0; + foreach (var word in queryWords) + { + int t = WordScore(word, titleWords); + int k = WordScore(word, keywordWords); + // Keyword hits rank a touch below title hits, but still count as matches. + int best = Math.Min(t, k >= NoWordMatch ? NoWordMatch : k + 3); + if (best >= NoWordMatch) unmatched++; + else quality += best; + } + + if (unmatched == queryWords.Count) return NoMatch; // nothing matched at all + return unmatched * 1000 + quality; + } + + // Best match of one query word against a set of words. 0 exact, 1 prefix, 2 substring. + private static int WordScore(string word, List words) + { + // Treat a trailing-'s' plural as its singular so "themes" matches "theme". + string singular = word.Length > 3 && word.EndsWith('s') ? word[..^1] : word; + + int best = NoWordMatch; + foreach (var w in words) + { + best = Math.Min(best, ScorePair(word, w)); + if (singular != word) best = Math.Min(best, ScorePair(singular, w)); + if (best == 0) break; + } + return best; + } + + private static int ScorePair(string word, string candidate) + { + if (candidate == word) return 0; + if (candidate.StartsWith(word, StringComparison.Ordinal)) return 1; + if (word.StartsWith(candidate, StringComparison.Ordinal) && candidate.Length >= 3) return 1; + if (candidate.Contains(word, StringComparison.Ordinal)) return 2; + return NoWordMatch; + } + + // Some card labels end with a colon (e.g. "Application theme:"); keep it on the source string + // (needed for the translation lookup) but drop it from what the dropdown shows. Handles the + // full-width colon used by some locales too. + private static string CleanTitle(string title) => title.TrimEnd(' ', '\t', ':', ':'); + + // Split into normalized alphanumeric words (drops 1-char noise and punctuation). + private static List Tokenize(string value) + { + var result = new List(); + string norm = Normalize(value); + var sb = new StringBuilder(); + foreach (char c in norm) + { + if (char.IsLetterOrDigit(c)) sb.Append(c); + else if (sb.Length > 0) Flush(); + } + Flush(); + return result; + + void Flush() + { + if (sb.Length >= 2) result.Add(sb.ToString()); + sb.Clear(); + } + } + + private static string PageTitle(Type pageType) => CoreTools.Translate(pageType.Name switch + { + nameof(General) => "General preferences", + nameof(Interface_P) => "User interface preferences", + nameof(Notifications) => "Notification preferences", + nameof(Updates) => "Package update preferences", + nameof(Operations) => "Package operation preferences", + nameof(Internet) => "Internet connection settings", + nameof(Backup) => "Package backup", + nameof(Administrator) => "Administrator rights and other dangerous settings", + nameof(Experimental) => "Experimental settings and developer options", + _ => "UniGetUI Settings", + }); + + // Case- and diacritic-insensitive so "idioma"/"proxy"/"café" all match regardless of accents. + private static string Normalize(string value) + { + string trimmed = value.Trim(); + if (trimmed.Length == 0) return ""; + + var sb = new StringBuilder(trimmed.Length); + foreach (char c in trimmed.Normalize(NormalizationForm.FormD)) + if (CharUnicodeInfo.GetUnicodeCategory(c) != UnicodeCategory.NonSpacingMark) + sb.Append(c); + + return sb.ToString().Normalize(NormalizationForm.FormC).ToLowerInvariant(); + } +} diff --git a/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs index 195743ca80..743bb1eb0b 100644 --- a/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs @@ -189,6 +189,57 @@ partial void OnGlobalSearchTextChanged(string value) if (_syncingSearch) return; if (CurrentPageContent is AbstractPackagesPage page) page.ViewModel.GlobalQueryText = value; + else if (CurrentPageContent is SettingsBasePage) + UpdateSettingsSuggestions(value); + } + + // ─── Settings search suggestions ─────────────────────────────────────────── + public ObservableCollection SettingsSuggestions { get; } = new(); + + [ObservableProperty] + private bool _isSuggestionsOpen; + + [ObservableProperty] + private int _selectedSuggestionIndex = -1; + + private void UpdateSettingsSuggestions(string query) + { + SettingsSuggestions.Clear(); + foreach (var r in SettingsSearchIndex.Search(query)) + SettingsSuggestions.Add(r); + + SelectedSuggestionIndex = SettingsSuggestions.Count > 0 ? 0 : -1; + IsSuggestionsOpen = SettingsSuggestions.Count > 0; + } + + public void MoveSuggestionSelection(int delta) + { + if (SettingsSuggestions.Count == 0) return; + int next = SelectedSuggestionIndex + delta; + SelectedSuggestionIndex = Math.Clamp(next, 0, SettingsSuggestions.Count - 1); + } + + public void CloseSuggestions() + { + IsSuggestionsOpen = false; + SelectedSuggestionIndex = -1; + SettingsSuggestions.Clear(); + } + + [RelayCommand] + public void SelectSuggestion(SettingsSearchResult? result) + { + if (result is null) return; + + _syncingSearch = true; + GlobalSearchText = ""; + _syncingSearch = false; + CloseSuggestions(); + + if (result.Manager is not null) + OpenManagerSettings(result.Manager); + else if (result.PageType is not null) + OpenSettingsPage(result.PageType, result.Anchor); } private void SubscribeToPageViewModel(AbstractPackagesPage? page) @@ -594,6 +645,8 @@ public void NavigateTo(PageType newPage_t, bool toHistory = true) (newPage as AbstractPackagesPage)?.FilterPackages(); (newPage as IEnterLeaveListener)?.OnEnter(); + CloseSuggestions(); + if (newPage is ISearchBoxPage newSPage) { SubscribeToPageViewModel(newPage as AbstractPackagesPage); @@ -660,10 +713,10 @@ public void OpenManagerSettings(IPackageManager? manager = null) if (manager is not null) ManagersPage?.NavigateTo(manager); } - public void OpenSettingsPage(Type page) + public void OpenSettingsPage(Type page, string? anchor = null) { NavigateTo(PageType.Settings); - SettingsPage?.NavigateTo(page); + SettingsPage?.NavigateTo(page, anchor); } public void ShowHelp(string uriAttachment = "") @@ -713,6 +766,16 @@ private void HandleNotificationActivation(string action) [RelayCommand] public void SubmitGlobalSearch() { + // On settings/managers the box drives the suggestion dropdown: jump to the highlighted + // result (falling back to the top one). + if (CurrentPageContent is SettingsBasePage) + { + if (SettingsSuggestions.Count == 0) return; + int i = SelectedSuggestionIndex >= 0 ? SelectedSuggestionIndex : 0; + SelectSuggestion(SettingsSuggestions[i]); + return; + } + if (CurrentPageContent is ISearchBoxPage page) page.SearchBox_QuerySubmitted(this, EventArgs.Empty); } diff --git a/src/UniGetUI.Avalonia/Views/MainWindow.axaml b/src/UniGetUI.Avalonia/Views/MainWindow.axaml index a3c019a995..229868562d 100644 --- a/src/UniGetUI.Avalonia/Views/MainWindow.axaml +++ b/src/UniGetUI.Avalonia/Views/MainWindow.axaml @@ -538,6 +538,45 @@ + + + + + + + + + + + + + + + + diff --git a/src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs b/src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs index c13f1b5cff..764326106c 100644 --- a/src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs +++ b/src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs @@ -1246,8 +1246,28 @@ private void TitleBar_PointerCaptureLost(object? sender, PointerCaptureLostEvent private void SearchBox_KeyDown(object? sender, KeyEventArgs e) { - if (e.Key == Key.Enter) + // While the settings-search dropdown is open, drive it from the box: arrows move the + // highlight, Enter jumps to it, Escape dismisses. + if (ViewModel.IsSuggestionsOpen) + { + switch (e.Key) + { + case Key.Down: ViewModel.MoveSuggestionSelection(1); e.Handled = true; return; + case Key.Up: ViewModel.MoveSuggestionSelection(-1); e.Handled = true; return; + case Key.Escape: ViewModel.CloseSuggestions(); e.Handled = true; return; + case Key.Enter: ViewModel.SubmitGlobalSearch(); e.Handled = true; return; + } + } + else if (e.Key == Key.Enter) + { ViewModel.SubmitGlobalSearch(); + } + } + + private void SuggestionsList_Tapped(object? sender, TappedEventArgs e) + { + if (SuggestionsList.SelectedItem is SettingsSearchResult result) + ViewModel.SelectSuggestion(result); } // ─── Public navigation API ──────────────────────────────────────────────── diff --git a/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Administrator.axaml b/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Administrator.axaml index 949aa71e34..293862141d 100644 --- a/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Administrator.axaml +++ b/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Administrator.axaml @@ -59,14 +59,16 @@ Margin="44,32,4,8" automation:AutomationProperties.HeadingLevel="2"/> - - - - - - - - - - - - @@ -159,7 +163,8 @@ - - - - - - - - - diff --git a/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/General.axaml b/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/General.axaml index 403a2dbd43..ebaf063213 100644 --- a/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/General.axaml +++ b/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/General.axaml @@ -28,18 +28,21 @@ Margin="44,32,4,8" automation:AutomationProperties.HeadingLevel="2"/> - - - @@ -50,7 +53,8 @@ Margin="44,32,4,8" automation:AutomationProperties.HeadingLevel="2"/> - - - - - ? NavigationRequested; + + // Scroll to (and highlight) a named control on this page — used by settings search. + // Every implementer is a Control, so the shared default handles it; no per-page code needed. + void ScrollToAnchor(string anchor) + { + if (this is Control control) + SettingsAnchor.ScrollToAndHighlight(control, anchor); + } } diff --git a/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Interface_P.axaml b/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Interface_P.axaml index 0d6fcd6572..ed6cccfb67 100644 --- a/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Interface_P.axaml +++ b/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Interface_P.axaml @@ -39,7 +39,8 @@ automation:AutomationProperties.HeadingLevel="2" IsVisible="{Binding IsWindows}"/> - - @@ -75,7 +77,8 @@ Margin="44,32,4,8" automation:AutomationProperties.HeadingLevel="2"/> - @@ -97,7 +100,8 @@ - diff --git a/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Internet.axaml b/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Internet.axaml index 7cf8d544c7..af6ee58995 100644 --- a/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Internet.axaml +++ b/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Internet.axaml @@ -18,13 +18,15 @@ Margin="44,32,4,8" automation:AutomationProperties.HeadingLevel="2"/> - - - - diff --git a/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Notifications.axaml b/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Notifications.axaml index 86d75283c3..968119aa91 100644 --- a/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Notifications.axaml +++ b/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Notifications.axaml @@ -27,14 +27,16 @@ Foreground="OrangeRed" IsVisible="{Binding IsSystemTrayWarningVisible}"/> - - @@ -48,23 +50,27 @@ Margin="44,32,4,8" automation:AutomationProperties.HeadingLevel="2"/> - - - - - - diff --git a/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/SettingsBasePage.axaml.cs b/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/SettingsBasePage.axaml.cs index 8657356cdb..a0d4075672 100644 --- a/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/SettingsBasePage.axaml.cs +++ b/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/SettingsBasePage.axaml.cs @@ -2,6 +2,7 @@ using UniGetUI.Avalonia.Infrastructure; using UniGetUI.Avalonia.ViewModels; using UniGetUI.Avalonia.ViewModels.Pages.SettingsPages; +using UniGetUI.Core.Tools; using UniGetUI.PackageEngine.Interfaces; namespace UniGetUI.Avalonia.Views.Pages.SettingsPages; @@ -10,7 +11,7 @@ namespace UniGetUI.Avalonia.Views.Pages.SettingsPages; /// Avalonia MVVM equivalent of the old code-behind-only SettingsBasePage. /// Hosts a manual navigation stack of ISettingsPage UserControls. /// -public partial class SettingsBasePage : UserControl, IInnerNavigationPage, IEnterLeaveListener +public partial class SettingsBasePage : UserControl, IInnerNavigationPage, IEnterLeaveListener, ISearchBoxPage { private SettingsBasePageViewModel VM => (SettingsBasePageViewModel)DataContext!; @@ -168,6 +169,17 @@ public void OnEnter() public void OnLeave() => ResetToHomepage(); + // ── ISearchBoxPage ──────────────────────────────────────────────────── + // The title-bar box searches the settings/managers index. Suggestions and submit are driven + // by MainWindowViewModel; this page only enables the box (via the interface) and names it. + // The query isn't persisted across navigation, so QueryBackup is a no-op. + public string QueryBackup { get => ""; set { } } + + public string SearchBoxPlaceholder => + CoreTools.Translate(_isManagers ? "Search package managers" : "Search settings"); + + public void SearchBox_QuerySubmitted(object? sender, EventArgs? e) { } + // ── IInnerNavigationPage extra overloads ────────────────────────────── public void NavigateTo(IPackageManager manager) @@ -178,12 +190,25 @@ public void NavigateTo(IPackageManager manager) NavigateToPage(new PackageManagerPage(manager)); } - public void NavigateTo(Type page) + public void NavigateTo(Type page, string? anchor = null) { + // Already on the requested page (e.g. searching within it) — just scroll, don't recreate. + if (_currentContent?.GetType() == page) + { + if (anchor is not null && _currentContent is ISettingsPage current) + current.ScrollToAnchor(anchor); + return; + } + if (_currentContent is not null) _history.Push(_currentContent); var target = CreatePageForType(page); - if (target is not null) NavigateToPage(target); + if (target is not null) + { + NavigateToPage(target); + if (anchor is not null && target is ISettingsPage sp) + sp.ScrollToAnchor(anchor); + } } // ── Helpers ─────────────────────────────────────────────────────────── diff --git a/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Updates.axaml b/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Updates.axaml index 6d74a18c0c..64d02a8d2f 100644 --- a/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Updates.axaml +++ b/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Updates.axaml @@ -19,7 +19,8 @@ Margin="44,32,4,8" automation:AutomationProperties.HeadingLevel="2"/> - @@ -38,26 +39,30 @@ Margin="44,32,4,8" automation:AutomationProperties.HeadingLevel="2"/> - - - - - From 746464b65b590faa86c8df84a699572d89331c64 Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Wed, 15 Jul 2026 10:52:23 -0400 Subject: [PATCH 2/2] Index the new "Show illustrations on package lists" setting --- src/UniGetUI.Avalonia/Infrastructure/SettingsSearchIndex.cs | 1 + .../Views/Pages/SettingsPages/Interface_P.axaml | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/UniGetUI.Avalonia/Infrastructure/SettingsSearchIndex.cs b/src/UniGetUI.Avalonia/Infrastructure/SettingsSearchIndex.cs index 4b826a8871..16bf233aa4 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/SettingsSearchIndex.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/SettingsSearchIndex.cs @@ -53,6 +53,7 @@ private sealed record Entry(string Title, string[] Keywords, Type PageType, stri new("Close UniGetUI to the system tray", ["system tray", "background", "minimize to tray"], typeof(Interface_P), "DisableSystemTrayCard"), new("Manage UniGetUI autostart behaviour", ["autostart", "run at login", "startup"], typeof(Interface_P), "EditAutostartSettings"), new("Show package icons on package lists", ["package icons"], typeof(Interface_P), "InterfacePackageListsCard"), + new("Show illustrations on package lists", ["illustrations", "package illustrations"], typeof(Interface_P), "PackageIllustrationsCard"), new("Clear the icon cache", ["icon cache", "clear cache", "cache size"], typeof(Interface_P), "ResetIconCache"), new("Select upgradable packages by default", ["select updates", "select upgradable"], typeof(Interface_P), "SelectUpgradableCard"), new("User interface preferences", ["interface", "ui"], typeof(Interface_P), null), diff --git a/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Interface_P.axaml b/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Interface_P.axaml index ed6cccfb67..0433593b65 100644 --- a/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Interface_P.axaml +++ b/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/Interface_P.axaml @@ -83,7 +83,8 @@ StateChangedCommand="{Binding ShowRestartRequiredCommand}" CornerRadius="8,8,0,0"/> -