diff --git a/src/DynamoCore/Search/NodeSearchModel.cs b/src/DynamoCore/Search/NodeSearchModel.cs index 34b11c008a9..be6579300a8 100644 --- a/src/DynamoCore/Search/NodeSearchModel.cs +++ b/src/DynamoCore/Search/NodeSearchModel.cs @@ -233,8 +233,8 @@ internal string ProcessNodeCategory(string category, ref SearchElementGroup grou internal IEnumerable Search(string search, LuceneSearchUtility luceneSearchUtility) { - - if (luceneSearchUtility != null) + if (luceneSearchUtility == null) return null; + lock (luceneSearchUtility) { //The DirectoryReader and IndexSearcher have to be assigned after commiting indexing changes and before executing the Searcher.Search() method, otherwise new indexed info won't be reflected luceneSearchUtility.dirReader = luceneSearchUtility.writer != null ? luceneSearchUtility.writer.GetReader(applyAllDeletes: true) : DirectoryReader.Open(luceneSearchUtility.indexDir); @@ -277,7 +277,6 @@ internal IEnumerable Search(string search, LuceneSearchUtilit } return candidates; } - return null; } internal NodeSearchElement FindModelForNodeNameAndCategory(string nodeName, string nodeCategory, string parameters) diff --git a/src/DynamoCoreWpf/Controls/IncanvasSearchControl.xaml.cs b/src/DynamoCoreWpf/Controls/IncanvasSearchControl.xaml.cs index b8f40090618..9c0eda18473 100644 --- a/src/DynamoCoreWpf/Controls/IncanvasSearchControl.xaml.cs +++ b/src/DynamoCoreWpf/Controls/IncanvasSearchControl.xaml.cs @@ -70,15 +70,18 @@ private void OnMouseLeftButtonUp(object sender, MouseButtonEventArgs e) private void ExecuteSearchElement(ListBoxItem listBoxItem) { var searchElement = listBoxItem.DataContext as NodeSearchElementViewModel; - if (searchElement != null) - { - searchElement.Position = ViewModel.InCanvasSearchPosition; - searchElement.ClickedCommand?.Execute(null); - Analytics.TrackEvent( - Dynamo.Logging.Actions.Select, - Dynamo.Logging.Categories.InCanvasSearchOperations, - searchElement.FullName); - } + ExecuteSearchElement(searchElement); + } + + private void ExecuteSearchElement(NodeSearchElementViewModel searchElement) + { + if (searchElement == null) return; + searchElement.Position = ViewModel.InCanvasSearchPosition; + searchElement.ClickedCommand?.Execute(null); + Analytics.TrackEvent( + Dynamo.Logging.Actions.Select, + Dynamo.Logging.Categories.InCanvasSearchOperations, + searchElement.FullName); } private void OnMouseEnter(object sender, MouseEventArgs e) @@ -187,11 +190,22 @@ private void OnInCanvasSearchKeyDown(object sender, KeyEventArgs e) OnRequestShowInCanvasSearch(ShowHideFlags.Hide); break; case Key.Enter: - if (HighlightedItem != null && ViewModel.CurrentMode != SearchViewModel.ViewMode.LibraryView) + ViewModel.AfterLastPendingSearch(() => { - ExecuteSearchElement(HighlightedItem); - OnRequestShowInCanvasSearch(ShowHideFlags.Hide); - } + Dispatcher.BeginInvoke(() => + { + var searchElement = HighlightedItem?.DataContext as NodeSearchElementViewModel; + + //if dropdown hasn't yet fully loaded lets assume the user wants the first element + searchElement ??= ViewModel.FilteredResults.FirstOrDefault(); + + if (searchElement != null && ViewModel.CurrentMode != SearchViewModel.ViewMode.LibraryView) + { + ExecuteSearchElement(searchElement); + OnRequestShowInCanvasSearch(ShowHideFlags.Hide); + } + }, DispatcherPriority.Input); + }); break; case Key.Up: index = MoveToNextMember(false, members, highlightedMember); diff --git a/src/DynamoCoreWpf/DynamoCoreWpf.csproj b/src/DynamoCoreWpf/DynamoCoreWpf.csproj index 7d80fe710f0..407febd8a7d 100644 --- a/src/DynamoCoreWpf/DynamoCoreWpf.csproj +++ b/src/DynamoCoreWpf/DynamoCoreWpf.csproj @@ -1,4 +1,4 @@ - + true @@ -331,6 +331,7 @@ + @@ -405,6 +406,7 @@ + diff --git a/src/DynamoCoreWpf/PublicAPI.Unshipped.txt b/src/DynamoCoreWpf/PublicAPI.Unshipped.txt index 8105ae556f7..a0ec46d091f 100644 --- a/src/DynamoCoreWpf/PublicAPI.Unshipped.txt +++ b/src/DynamoCoreWpf/PublicAPI.Unshipped.txt @@ -2867,6 +2867,7 @@ Dynamo.ViewModels.RequestBitmapSourceHandler Dynamo.ViewModels.RequestOpenDocumentationLinkHandler Dynamo.ViewModels.RequestPackagePublishDialogHandler Dynamo.ViewModels.SearchViewModel +Dynamo.ViewModels.SearchViewModel.AfterLastPendingSearch(System.Action action) -> void Dynamo.ViewModels.SearchViewModel.BrowserRootCategories.get -> System.Collections.ObjectModel.ObservableCollection Dynamo.ViewModels.SearchViewModel.BrowserVisibility.get -> bool Dynamo.ViewModels.SearchViewModel.BrowserVisibility.set -> void diff --git a/src/DynamoCoreWpf/Utilities/JobDebouncer.cs b/src/DynamoCoreWpf/Utilities/JobDebouncer.cs new file mode 100644 index 00000000000..e718fe53698 --- /dev/null +++ b/src/DynamoCoreWpf/Utilities/JobDebouncer.cs @@ -0,0 +1,48 @@ +using System; + +namespace Dynamo.Wpf.Utilities +{ + /// + /// Thread-safe utility class to enqueue jobs into a serial queue of tasks executed in a background thread. + /// No debouncing delay by executing jobs as soon as possible. + /// At any moment, any optional job has to wait for at most one pending optional job to finish. + /// + internal static class JobDebouncer + { + internal class DebounceQueueToken + { + public long LastOptionalExecutionId = 0; + public SerialQueue SerialQueue = new(); + }; + /// + /// Action is guaranteed to run at most once for every call, and exactly once after the last call. + /// Execution is sequential, and optional jobs that share a with a newer optional job will be ignored. + /// + /// + /// + /// + internal static void EnqueueOptionalJobAsync(Action job, DebounceQueueToken token) + { + lock (token) + { + token.LastOptionalExecutionId++; + var myExecutionId = token.LastOptionalExecutionId; + token.SerialQueue.DispatchAsync(() => + { + if (myExecutionId < token.LastOptionalExecutionId) return; + job(); + }); + } + } + internal static void EnqueueMandatoryJobAsync(Action job, DebounceQueueToken token) + { + lock (token) + { + token.SerialQueue.DispatchAsync(() => + { + job(); + }); + } + } + } +} diff --git a/src/DynamoCoreWpf/Utilities/SerialQueue.cs b/src/DynamoCoreWpf/Utilities/SerialQueue.cs new file mode 100644 index 00000000000..1df16147eab --- /dev/null +++ b/src/DynamoCoreWpf/Utilities/SerialQueue.cs @@ -0,0 +1,80 @@ +//https://github.com/gentlee/SerialQueue/blob/master/SerialQueue/SerialQueue.cs +using System; +using System.Threading; + +namespace Dynamo.Wpf.Utilities +{ + internal class SerialQueue + { + class LinkedListNode(Action action) + { + public readonly Action Action = action; + public LinkedListNode Next; + } + + public event Action UnhandledException = delegate { }; + + private LinkedListNode _queueFirst; + private LinkedListNode _queueLast; + private bool _isRunning = false; + + public void DispatchAsync(Action action) + { + var newNode = new LinkedListNode(action); + + lock (this) + { + if (_queueFirst == null) + { + _queueFirst = newNode; + _queueLast = newNode; + + if (!_isRunning) + { + _isRunning = true; + ThreadPool.QueueUserWorkItem(Run); + } + } + else + { + _queueLast!.Next = newNode; + _queueLast = newNode; + } + } + } + + private void Run(object _) + { + while (true) + { + LinkedListNode firstNode; + + lock (this) + { + if (_queueFirst == null) + { + _isRunning = false; + return; + } + firstNode = _queueFirst; + _queueFirst = null; + _queueLast = null; + } + + while (firstNode != null) + { + var action = firstNode.Action; + firstNode = firstNode.Next; + try + { + action(); + } + catch (Exception error) + { + UnhandledException.Invoke(action, error); + } + } + } + } + } +} diff --git a/src/DynamoCoreWpf/ViewModels/Search/SearchViewModel.cs b/src/DynamoCoreWpf/ViewModels/Search/SearchViewModel.cs index 4526b1f3afc..eb2869ae7b9 100644 --- a/src/DynamoCoreWpf/ViewModels/Search/SearchViewModel.cs +++ b/src/DynamoCoreWpf/ViewModels/Search/SearchViewModel.cs @@ -7,6 +7,7 @@ using System.Text.RegularExpressions; using System.Windows; using System.Windows.Media; +using System.Windows.Threading; using Dynamo.Configuration; using Dynamo.Engine; using Dynamo.Graph.Nodes; @@ -18,6 +19,7 @@ using Dynamo.UI; using Dynamo.Utilities; using Dynamo.Wpf.Services; +using Dynamo.Wpf.Utilities; using Dynamo.Wpf.ViewModels; namespace Dynamo.ViewModels @@ -861,14 +863,10 @@ private static string MakeFullyQualifiedName(string path, string addition) /// internal void SearchAndUpdateResults() { - searchResults.Clear(); - if (!String.IsNullOrEmpty(SearchText.Trim())) { SearchAndUpdateResults(SearchText); } - - RaisePropertyChanged("IsAnySearchResult"); } /// @@ -886,13 +884,20 @@ public void SearchAndUpdateResults(string query) //Passing the second parameter as true will search using Lucene.NET var foundNodes = Search(query); - searchResults = new List(foundNodes); - FilteredResults = searchResults; + //Unit tests don't have an Application.Current + (Application.Current?.Dispatcher ?? Dispatcher.CurrentDispatcher).Invoke(() => + { + searchResults = new List(foundNodes); + + FilteredResults = searchResults; + + UpdateSearchCategories(); - UpdateSearchCategories(); + RaisePropertyChanged("FilteredResults"); - RaisePropertyChanged("FilteredResults"); + RaisePropertyChanged("IsAnySearchResult"); + }); } /// @@ -1164,9 +1169,15 @@ public void OnSearchElementClicked(NodeModel nodeModel, Point position) #region Commands + private static readonly JobDebouncer.DebounceQueueToken DebounceQueueToken = new(); public void Search(object parameter) { - SearchAndUpdateResults(); + JobDebouncer.EnqueueOptionalJobAsync(SearchAndUpdateResults, DebounceQueueToken); + } + + public void AfterLastPendingSearch(Action action) + { + JobDebouncer.EnqueueMandatoryJobAsync(action, DebounceQueueToken); } internal bool CanSearch(object parameter) diff --git a/src/LibraryViewExtensionWebView2/Handlers/SearchResultDataProvider.cs b/src/LibraryViewExtensionWebView2/Handlers/SearchResultDataProvider.cs index 396aa8d919c..d3f2fbd238c 100644 --- a/src/LibraryViewExtensionWebView2/Handlers/SearchResultDataProvider.cs +++ b/src/LibraryViewExtensionWebView2/Handlers/SearchResultDataProvider.cs @@ -47,7 +47,7 @@ public SearchResultDataProvider(NodeSearchModel model, IconResourceProvider icon public override Stream GetResource(string searchText, out string extension) { var text = Uri.UnescapeDataString(searchText); - var elements = model.Search(text, LuceneSearch.LuceneUtilityNodeSearch); + var elements = string.IsNullOrWhiteSpace(text) ? new List() : model.Search(text, LuceneSearch.LuceneUtilityNodeSearch); extension = "json"; return GetNodeItemDataStream(elements, true); } diff --git a/src/LibraryViewExtensionWebView2/LibraryViewController.cs b/src/LibraryViewExtensionWebView2/LibraryViewController.cs index b85a8ca17a5..c6d9b541fb2 100644 --- a/src/LibraryViewExtensionWebView2/LibraryViewController.cs +++ b/src/LibraryViewExtensionWebView2/LibraryViewController.cs @@ -461,10 +461,17 @@ private void Browser_KeyDown(object sender, KeyEventArgs e) var synteticEventData = new Dictionary { - [Enum.GetName(typeof(ModifiersJS), e.KeyboardDevice.Modifiers)] = "true", ["key"] = e.Key.ToString() }; + foreach(ModifiersJS modifier in Enum.GetValues(typeof(ModifiersJS))) + { + if (((int)e.KeyboardDevice.Modifiers & (int)modifier) != 0) + { + synteticEventData[Enum.GetName(typeof(ModifiersJS), modifier)] = "true"; + } + } + _ = ExecuteScriptFunctionAsync(browser, "eventDispatcher", synteticEventData); } diff --git a/src/LibraryViewExtensionWebView2/ScriptingObject.cs b/src/LibraryViewExtensionWebView2/ScriptingObject.cs index 5320554130c..fff378ea118 100644 --- a/src/LibraryViewExtensionWebView2/ScriptingObject.cs +++ b/src/LibraryViewExtensionWebView2/ScriptingObject.cs @@ -3,6 +3,9 @@ using System.IO; using System.Linq; using System.Runtime.InteropServices; +using System.Windows; +using System.Windows.Threading; +using Dynamo.Wpf.Utilities; using Newtonsoft.Json; using Newtonsoft.Json.Linq; @@ -46,6 +49,8 @@ public string GetBase64StringFromPath(string iconurl) return $"data:image/{ext};base64, {iconAsBase64}"; } + private static readonly JobDebouncer.DebounceQueueToken DebounceQueueToken = new(); + /// /// This method will receive any message sent from javascript and execute a specific code according to the message /// @@ -98,12 +103,19 @@ internal void Notify(string dataFromjs) { var data = simpleRPCPayload["data"] as string; var extension = string.Empty; - var searchStream = controller.searchResultDataProvider.GetResource(data, out extension); - var searchReader = new StreamReader(searchStream); - var results = searchReader.ReadToEnd(); - //send back results to librarie.js - LibraryViewController.ExecuteScriptFunctionAsync(controller.browser, "completeSearch", results); - searchReader.Dispose(); + + var dispatcher = Application.Current?.Dispatcher ?? Dispatcher.CurrentDispatcher; + JobDebouncer.EnqueueOptionalJobAsync(() => { + var searchStream = controller.searchResultDataProvider.GetResource(data, out extension); + var searchReader = new StreamReader(searchStream); + var results = searchReader.ReadToEnd(); + dispatcher.Invoke(() => + { + //send back results to librarie.js + LibraryViewController.ExecuteScriptFunctionAsync(controller.browser, "completeSearch", results); + searchReader.Dispose(); + }); + }, DebounceQueueToken); } //When the html
that contains the sample package is clicked then we will be moved to the next Step in the Guide else if (funcName == "NextStep") diff --git a/test/DynamoCoreWpfTests/CoreUITests.cs b/test/DynamoCoreWpfTests/CoreUITests.cs index 7bdfeb586a4..4954f32fc92 100644 --- a/test/DynamoCoreWpfTests/CoreUITests.cs +++ b/test/DynamoCoreWpfTests/CoreUITests.cs @@ -974,6 +974,48 @@ public void InCanvasSearchTextChangeTriggersOneSearchCommand() Assert.AreEqual(count, 1); } + [Test] + [TestCase(0)] + [TestCase(50)] + [TestCase(100)] + [Category("UnitTests")] + public async Task InCanvasSearchTextChangedBurst(int keyStrokeDelayMs) + { + string fullNodeName = "Number Slider"; + var currentWs = View.ChildOfType(); + + // open context menu + RightClick(currentWs.zoomBorder); + + // show in-canvas search + ViewModel.CurrentSpaceViewModel.ShowInCanvasSearchCommand.Execute(ShowHideFlags.Show); + + var searchControl = currentWs.ChildrenOfType().Select(x => x?.Child as InCanvasSearchControl).Where(c => c != null).FirstOrDefault(); + Assert.IsNotNull(searchControl); + + DispatcherUtil.DoEvents(); + + var vm = searchControl.DataContext as SearchViewModel; + + for (var i = 1; i <= fullNodeName.Length; ++i) + { + vm.SearchText = fullNodeName[..i]; + vm.SearchCommand.Execute(null); + if (keyStrokeDelayMs > 0) + { + Thread.Sleep(keyStrokeDelayMs); + } + } + var tcs = new TaskCompletionSource(); + //we should only get the "Number Slider" node if we process up to the very last "r". + //otherwise ("Number Slide") we should get "Number". This wasn't added to the test because it would be fragile to future Lucene weight updates. + vm.AfterLastPendingSearch(() => { + tcs.SetResult(); + }); + await tcs.Task; + Assert.AreEqual(fullNodeName, vm.FilteredResults?.FirstOrDefault()?.Name); + } + [Test] public void WarningShowsWhenSavingWithLinterWarningsOrErrors() {