From 51d88a63b198b680a53c976ef42f31730f21ed07 Mon Sep 17 00:00:00 2001 From: Etienne Baudoux Date: Sun, 5 Dec 2021 19:02:44 -0800 Subject: [PATCH] Moved search results to AutoSuggestedBox instead of NavigationView (#105) --- .../DevToys/Api/Tools/MatchedToolProvider.cs | 11 ++- .../ExtendedObservableCollection.cs | 40 +++++++++ .../impl/DevToys/Core/ToolProviderFactory.cs | 47 +++++----- src/dev/impl/DevToys/DevToys.csproj | 1 + src/dev/impl/DevToys/LanguageManager.cs | 5 ++ .../Models/NoResultFoundMockToolProvider.cs | 30 +++++++ .../impl/DevToys/Strings/cs-CZ/MainPage.resw | 3 + .../impl/DevToys/Strings/en-US/MainPage.resw | 3 + .../impl/DevToys/Strings/fr-FR/MainPage.resw | 3 + .../impl/DevToys/Strings/pl-PL/MainPage.resw | 3 + .../impl/DevToys/Strings/ru-RU/MainPage.resw | 3 + .../impl/DevToys/Strings/zh-CN/MainPage.resw | 3 + .../DevToys/ViewModels/MainPageViewModel.cs | 86 ++++++++++++++++++- .../Formatters/FormattersGroupToolProvider.cs | 2 +- src/dev/impl/DevToys/Views/MainPage.xaml | 44 ++++++---- 15 files changed, 235 insertions(+), 49 deletions(-) create mode 100644 src/dev/impl/DevToys/Models/NoResultFoundMockToolProvider.cs diff --git a/src/dev/impl/DevToys/Api/Tools/MatchedToolProvider.cs b/src/dev/impl/DevToys/Api/Tools/MatchedToolProvider.cs index 309ce81810..149b4e3d04 100644 --- a/src/dev/impl/DevToys/Api/Tools/MatchedToolProvider.cs +++ b/src/dev/impl/DevToys/Api/Tools/MatchedToolProvider.cs @@ -34,8 +34,11 @@ public MatchSpan[] MatchedSpans set { _matchedSpans = value; - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(AnyMatchedSpan))); - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(MatchedSpans))); + ThreadHelper.RunOnUIThreadAsync(() => + { + RaisePropertyChanged(nameof(AnyMatchedSpan)); + RaisePropertyChanged(nameof(MatchedSpans)); + }).Forget(); } } @@ -73,11 +76,11 @@ public IReadOnlyList ChildrenTools public event PropertyChangedEventHandler? PropertyChanged; - public MatchedToolProvider(ToolProviderMetadata metadata, IToolProvider toolProvider, MatchSpan[]? matchedSpans = null) + public MatchedToolProvider(ToolProviderMetadata metadata, IToolProvider toolProvider) { Metadata = Arguments.NotNull(metadata, nameof(metadata)); ToolProvider = Arguments.NotNull(toolProvider, nameof(toolProvider)); - MatchedSpans = matchedSpans ?? Array.Empty(); + MatchedSpans = Array.Empty(); } internal async Task UpdateIsRecommendedAsync(string clipboardContent) diff --git a/src/dev/impl/DevToys/Core/Collections/ExtendedObservableCollection.cs b/src/dev/impl/DevToys/Core/Collections/ExtendedObservableCollection.cs index cc911b5fa5..6e54bc466a 100644 --- a/src/dev/impl/DevToys/Core/Collections/ExtendedObservableCollection.cs +++ b/src/dev/impl/DevToys/Core/Collections/ExtendedObservableCollection.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Collections.Specialized; +using System.Linq; using DevToys.Shared.Core; namespace DevToys.Core.Collections @@ -23,5 +24,44 @@ internal void AddRange(IEnumerable collection) OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); } + + /// + /// Update the difference between the current items in the collection and the . + /// + internal void Update(IEnumerable newItems) + { + // First, remove the items that aren't part of the new list items. + var oldToolsMenuItems = this.ToList(); + for (int i = 0; i < oldToolsMenuItems.Count; i++) + { + T item = oldToolsMenuItems[i]; + if (!newItems.Contains(item)) + { + Remove(item); + } + } + + // Then: + // 1. If an item from newItems already exist in the collection, but at a different position, move it to the desired index. + // 2. If an item from newItems doesn't exist in the collection, insert it with respect of the position of older items in the collection. + int insertionIndex = 0; + foreach (T? item in newItems) + { + int indexOfItemInOldMenu = IndexOf(item); + if (indexOfItemInOldMenu > -1) + { + if (indexOfItemInOldMenu != insertionIndex) + { + Move(indexOfItemInOldMenu, insertionIndex); + } + } + else + { + Insert(insertionIndex, item); + } + + insertionIndex++; + } + } } } diff --git a/src/dev/impl/DevToys/Core/ToolProviderFactory.cs b/src/dev/impl/DevToys/Core/ToolProviderFactory.cs index bfa6daa729..01ef9cf482 100644 --- a/src/dev/impl/DevToys/Core/ToolProviderFactory.cs +++ b/src/dev/impl/DevToys/Core/ToolProviderFactory.cs @@ -53,9 +53,7 @@ public async Task> SearchToolsAsync(string sear return SortTools( - SearchTools( - GetAllTools(), - searchQueries) + SearchTools(searchQueries) .ToList()); } @@ -104,38 +102,37 @@ private async void OnAppSuspending(object sender, Windows.ApplicationModel.Suspe await CleanupAsync(); } - private IEnumerable SearchTools(IEnumerable providers, string[]? searchQueries) + private IEnumerable SearchTools(string[]? searchQueries) { if (searchQueries is not null) { - foreach (MatchedToolProvider provider in providers) + foreach (MatchedToolProvider provider in GetAllTools()) { - var matches = new List(); - - foreach (string? query in searchQueries) + if (provider.ChildrenTools.Count == 0) // do not search groups. { - int i = 0; - while (i < provider.ToolProvider.DisplayName?.Length && i > -1) + var matches = new List(); + + foreach (string? query in searchQueries) { - int matchIndex = provider.ToolProvider.DisplayName.IndexOf(query, i, StringComparison.OrdinalIgnoreCase); - if (matchIndex > -1) + int i = 0; + while (i < provider.ToolProvider.DisplayName?.Length && i > -1) { - matches.Add(new MatchSpan(matchIndex, query.Length)); - i = matchIndex + query.Length; + int matchIndex = provider.ToolProvider.DisplayName.IndexOf(query, i, StringComparison.OrdinalIgnoreCase); + if (matchIndex > -1) + { + matches.Add(new MatchSpan(matchIndex, query.Length)); + i = matchIndex + query.Length; + } + + i++; } - - i++; } - } - if (matches.Count > 0) - { - // Return a new MatchedToolProvider with the matches. - yield return - new MatchedToolProvider( - provider.Metadata, - provider.ToolProvider, - matches.ToArray()); + if (matches.Count > 0) + { + provider.MatchedSpans = matches.ToArray(); + yield return provider; + } } } } diff --git a/src/dev/impl/DevToys/DevToys.csproj b/src/dev/impl/DevToys/DevToys.csproj index 89b67e6c08..f5a0befc80 100644 --- a/src/dev/impl/DevToys/DevToys.csproj +++ b/src/dev/impl/DevToys/DevToys.csproj @@ -26,6 +26,7 @@ + diff --git a/src/dev/impl/DevToys/LanguageManager.cs b/src/dev/impl/DevToys/LanguageManager.cs index d752954c1f..7c1eb96c24 100644 --- a/src/dev/impl/DevToys/LanguageManager.cs +++ b/src/dev/impl/DevToys/LanguageManager.cs @@ -792,6 +792,11 @@ public string GetFormattedNotificationReleaseNoteTitle(string? param0) /// public string Search => _resources.GetString("Search"); + /// + /// Gets the resource SearchNoResultsFound. + /// + public string SearchNoResultsFound => _resources.GetString("SearchNoResultsFound"); + /// /// Gets the resource WindowTitle. /// diff --git a/src/dev/impl/DevToys/Models/NoResultFoundMockToolProvider.cs b/src/dev/impl/DevToys/Models/NoResultFoundMockToolProvider.cs new file mode 100644 index 0000000000..7ab10e237f --- /dev/null +++ b/src/dev/impl/DevToys/Models/NoResultFoundMockToolProvider.cs @@ -0,0 +1,30 @@ +#nullable enable + +using System; +using System.ComponentModel; +using DevToys.Api.Tools; + +namespace DevToys.Models +{ + internal sealed class NoResultFoundMockToolProvider : IToolProvider + { + public string DisplayName => LanguageManager.Instance.MainPage.SearchNoResultsFound; + + public string AccessibleName => LanguageManager.Instance.MainPage.SearchNoResultsFound; + + public object IconSource => null!; + + public event PropertyChangedEventHandler? PropertyChanged; + + public bool CanBeTreatedByTool(string data) + { + return false; + } + + public IToolViewModel CreateTool() + { + // TODO: Show a page indicating "No results match your search". + throw new NotSupportedException(); + } + } +} diff --git a/src/dev/impl/DevToys/Strings/cs-CZ/MainPage.resw b/src/dev/impl/DevToys/Strings/cs-CZ/MainPage.resw index 76de2b531e..d82ad61781 100644 --- a/src/dev/impl/DevToys/Strings/cs-CZ/MainPage.resw +++ b/src/dev/impl/DevToys/Strings/cs-CZ/MainPage.resw @@ -151,6 +151,9 @@ Hledat nástroje... + + No results found + DevToys diff --git a/src/dev/impl/DevToys/Strings/en-US/MainPage.resw b/src/dev/impl/DevToys/Strings/en-US/MainPage.resw index c9fc56044b..3c76d96431 100644 --- a/src/dev/impl/DevToys/Strings/en-US/MainPage.resw +++ b/src/dev/impl/DevToys/Strings/en-US/MainPage.resw @@ -151,6 +151,9 @@ Type to search for tools... + + No results found + DevToys diff --git a/src/dev/impl/DevToys/Strings/fr-FR/MainPage.resw b/src/dev/impl/DevToys/Strings/fr-FR/MainPage.resw index 4beebd8465..d71baa785e 100644 --- a/src/dev/impl/DevToys/Strings/fr-FR/MainPage.resw +++ b/src/dev/impl/DevToys/Strings/fr-FR/MainPage.resw @@ -151,6 +151,9 @@ Taper pour recherche un outil... + + Aucun résultat trouvé + DevToys diff --git a/src/dev/impl/DevToys/Strings/pl-PL/MainPage.resw b/src/dev/impl/DevToys/Strings/pl-PL/MainPage.resw index e2990c1717..5854ac1749 100644 --- a/src/dev/impl/DevToys/Strings/pl-PL/MainPage.resw +++ b/src/dev/impl/DevToys/Strings/pl-PL/MainPage.resw @@ -151,6 +151,9 @@ Wpisz, aby wyszukać narzędzia... + + No results found + DevToys diff --git a/src/dev/impl/DevToys/Strings/ru-RU/MainPage.resw b/src/dev/impl/DevToys/Strings/ru-RU/MainPage.resw index b7b4f1b814..35f5a48ea8 100644 --- a/src/dev/impl/DevToys/Strings/ru-RU/MainPage.resw +++ b/src/dev/impl/DevToys/Strings/ru-RU/MainPage.resw @@ -151,6 +151,9 @@ Найти инструмент + + No results found + DevToys diff --git a/src/dev/impl/DevToys/Strings/zh-CN/MainPage.resw b/src/dev/impl/DevToys/Strings/zh-CN/MainPage.resw index 45b27ee6d4..876aab6601 100644 --- a/src/dev/impl/DevToys/Strings/zh-CN/MainPage.resw +++ b/src/dev/impl/DevToys/Strings/zh-CN/MainPage.resw @@ -151,6 +151,9 @@ 搜索工具… + + No results found + DevToys diff --git a/src/dev/impl/DevToys/ViewModels/MainPageViewModel.cs b/src/dev/impl/DevToys/ViewModels/MainPageViewModel.cs index 2721c10641..4d1743d933 100644 --- a/src/dev/impl/DevToys/ViewModels/MainPageViewModel.cs +++ b/src/dev/impl/DevToys/ViewModels/MainPageViewModel.cs @@ -18,6 +18,7 @@ using DevToys.Core.Settings; using DevToys.Core.Threading; using DevToys.Messages; +using DevToys.Models; using DevToys.Shared.Core; using DevToys.Shared.Core.Threading; using Microsoft.Toolkit.Mvvm.ComponentModel; @@ -120,6 +121,11 @@ internal string? SearchQuery } } + /// + /// Gets or sets the list of items to displayed in the Search Box after a search. + /// + internal ExtendedObservableCollection SearchResults { get; } = new(); + /// /// Gets whether the window is in Compact Overlay mode or not. /// @@ -184,6 +190,8 @@ public MainPageViewModel( OpenToolInNewWindowCommand = new AsyncRelayCommand(ExecuteOpenToolInNewWindowCommandAsync); ChangeViewModeCommand = new AsyncRelayCommand(ExecuteChangeViewModeCommandAsync); + SearchBoxTextChangedCommand = new AsyncRelayCommand(ExecuteSearchBoxTextChangedCommandAsync); + SearchBoxQuerySubmittedCommand = new RelayCommand(ExecuteSearchBoxQuerySubmittedCommand); _menuInitializationTask = BuildMenuAsync(); @@ -227,6 +235,80 @@ await ThreadHelper.RunOnUIThreadAsync(() => #endregion + #region SearchBoxTextChangedCommand + + public IAsyncRelayCommand SearchBoxTextChangedCommand { get; } + + private async Task ExecuteSearchBoxTextChangedCommandAsync(Windows.UI.Xaml.Controls.AutoSuggestBoxTextChangedEventArgs? parameters) + { + Arguments.NotNull(parameters, nameof(parameters)); + + await TaskScheduler.Default; + + MatchedToolProvider[]? searchResult = null; + + if (parameters!.Reason == Windows.UI.Xaml.Controls.AutoSuggestionBoxTextChangeReason.UserInput) + { + string? searchQuery = SearchQuery; + if (!string.IsNullOrEmpty(searchQuery)) + { + IEnumerable matchedTools + = await _toolProviderFactory.SearchToolsAsync(searchQuery!).ConfigureAwait(false); + + if (matchedTools.Any()) + { + searchResult = matchedTools.ToArray(); + } + else + { + searchResult = new[] + { + new MatchedToolProvider(new ToolProviderMetadata(), new NoResultFoundMockToolProvider()) + }; + } + } + } + + await ThreadHelper.RunOnUIThreadAsync(() => + { + if (searchResult is null) + { + SearchResults.Clear(); + } + else + { + SearchResults.Update(searchResult); + } + }); + } + + #endregion + + #region SearchBoxQuerySubmittedCommand + + public IRelayCommand SearchBoxQuerySubmittedCommand { get; } + + private void ExecuteSearchBoxQuerySubmittedCommand(Windows.UI.Xaml.Controls.AutoSuggestBoxQuerySubmittedEventArgs? parameters) + { + Arguments.NotNull(parameters, nameof(parameters)); + + if (string.IsNullOrEmpty(parameters!.QueryText)) + { + // Nothing has been search. Do nothing. + return; + } + + if (parameters.ChosenSuggestion is null or NoResultFoundMockToolProvider) + { + // TODO: Show a page indicating "No results match your search". + return; + } + + SetSelectedMenuItem((MatchedToolProvider)parameters.ChosenSuggestion!, clipboardContentData: null); + } + + #endregion + /// /// Invoked when the Page is loaded and becomes the current source of a parent Frame. /// @@ -309,8 +391,8 @@ private async Task BuildMenuAsync() try { - IEnumerable tools = await _toolProviderFactory.GetToolsTreeAsync(); - IEnumerable footerTools = await _toolProviderFactory.GetFooterToolsAsync(); + IEnumerable tools = await _toolProviderFactory.GetToolsTreeAsync().ConfigureAwait(false); + IEnumerable footerTools = await _toolProviderFactory.GetFooterToolsAsync().ConfigureAwait(false); await ThreadHelper.RunOnUIThreadAsync( ThreadPriority.Low, diff --git a/src/dev/impl/DevToys/ViewModels/Tools/Text/Formatters/FormattersGroupToolProvider.cs b/src/dev/impl/DevToys/ViewModels/Tools/Text/Formatters/FormattersGroupToolProvider.cs index 0c74837d89..5288ad476d 100644 --- a/src/dev/impl/DevToys/ViewModels/Tools/Text/Formatters/FormattersGroupToolProvider.cs +++ b/src/dev/impl/DevToys/ViewModels/Tools/Text/Formatters/FormattersGroupToolProvider.cs @@ -13,7 +13,7 @@ namespace DevToys.ViewModels.Tools [Export(typeof(IToolProvider))] [Name(InternalName)] [Parent(TextGroupToolProvider.InternalName)] - [ProtocolName("text")] + [ProtocolName("formatters")] [Order(2)] internal sealed class FormattersGroupToolProvider : ToolProviderBase, IToolProvider { diff --git a/src/dev/impl/DevToys/Views/MainPage.xaml b/src/dev/impl/DevToys/Views/MainPage.xaml index d04ee69e8b..e1026e0f7d 100644 --- a/src/dev/impl/DevToys/Views/MainPage.xaml +++ b/src/dev/impl/DevToys/Views/MainPage.xaml @@ -10,7 +10,7 @@ xmlns:converters="using:DevToys.UI.Converters" xmlns:ex="using:DevToys.UI.Extensions" xmlns:muxc="using:Microsoft.UI.Xaml.Controls" - xmlns:viewmanagement="using:Windows.UI.ViewManagement" + xmlns:viewmanagement="using:Windows.UI.ViewManagement" xmlns:i="using:Microsoft.Xaml.Interactivity" xmlns:core="using:Microsoft.Xaml.Interactions.Core" mc:Ignorable="d" NavigationCacheMode="Enabled" muxc:BackdropMaterial.ApplyToRootOrPageBackground="True"> @@ -188,13 +188,37 @@ KeyboardAcceleratorPlacementMode="Hidden" QueryIcon="Find" PlaceholderText="{x:Bind ViewModel.Strings.Search}" - Text="{x:Bind ViewModel.SearchQuery, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"> + Text="{x:Bind ViewModel.SearchQuery, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" + ItemsSource="{x:Bind ViewModel.SearchResults, Mode=OneWay}" + TextMemberPath="ToolProvider.DisplayName"> + + + + + + + + + + + + + + + + + @@ -228,21 +252,7 @@ HorizontalAlignment="Stretch" TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" - Text="{x:Bind ToolProvider.DisplayName}" - Visibility="{x:Bind AnyMatchedSpan, Converter={StaticResource InvertedBooleanToVisibilityConverter}}"/> - - - - - + Text="{x:Bind ToolProvider.DisplayName}"/>