diff --git a/Flow.Launcher.Infrastructure/Image/ImageCache.cs b/Flow.Launcher.Infrastructure/Image/ImageCache.cs index b1c09024f25..bb7ec681781 100644 --- a/Flow.Launcher.Infrastructure/Image/ImageCache.cs +++ b/Flow.Launcher.Infrastructure/Image/ImageCache.cs @@ -73,8 +73,7 @@ public ImageSource this[string path] public bool ContainsKey(string key) { - var contains = Data.ContainsKey(key) && Data[key] != null; - return contains; + return Data.ContainsKey(key) && Data[key].imageSource != null; } public int CacheSize() diff --git a/Flow.Launcher.Infrastructure/Logger/Log.cs b/Flow.Launcher.Infrastructure/Logger/Log.cs index 91eeb183d2b..94132b27f18 100644 --- a/Flow.Launcher.Infrastructure/Logger/Log.cs +++ b/Flow.Launcher.Infrastructure/Logger/Log.cs @@ -50,14 +50,18 @@ private static bool FormatValid(string message) return valid; } - + [MethodImpl(MethodImplOptions.Synchronized)] public static void Exception(string className, string message, System.Exception exception, [CallerMemberName] string methodName = "") { +#if DEBUG + throw exception; +#else var classNameWithMethod = CheckClassAndMessageAndReturnFullClassWithMethod(className, message, methodName); ExceptionInternal(classNameWithMethod, message, exception); +#endif } private static string CheckClassAndMessageAndReturnFullClassWithMethod(string className, string message, diff --git a/Flow.Launcher/ResultListBox.xaml b/Flow.Launcher/ResultListBox.xaml index 072196605a6..2f9d06d814e 100644 --- a/Flow.Launcher/ResultListBox.xaml +++ b/Flow.Launcher/ResultListBox.xaml @@ -9,7 +9,7 @@ d:DataContext="{d:DesignInstance vm:ResultsViewModel}" MaxHeight="{Binding MaxHeight}" SelectedIndex="{Binding SelectedIndex, Mode=TwoWay}" - SelectedItem="{Binding SelectedItem, Mode=OneWayToSource}" + SelectedItem="{Binding SelectedItem, Mode=TwoWay}" HorizontalContentAlignment="Stretch" ItemsSource="{Binding Results}" Margin="{Binding Margin}" Visibility="{Binding Visbility}" diff --git a/Flow.Launcher/ViewModel/MainViewModel.cs b/Flow.Launcher/ViewModel/MainViewModel.cs index a062f59dc01..8195c745aa5 100644 --- a/Flow.Launcher/ViewModel/MainViewModel.cs +++ b/Flow.Launcher/ViewModel/MainViewModel.cs @@ -1,7 +1,5 @@ using System; using System.Collections.Generic; -using System.ComponentModel; -using System.Diagnostics; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -19,9 +17,8 @@ using Flow.Launcher.Plugin; using Flow.Launcher.Plugin.SharedCommands; using Flow.Launcher.Storage; -using System.Windows.Media; -using Flow.Launcher.Infrastructure.Image; using Flow.Launcher.Infrastructure.Logger; +using System.Threading.Tasks.Dataflow; namespace Flow.Launcher.ViewModel { @@ -48,6 +45,8 @@ public class MainViewModel : BaseModel, ISavable private bool _saved; private readonly Internationalization _translator = InternationalizationManager.Instance; + private BufferBlock _resultsUpdateQueue; + private Task _resultsViewUpdateTask; #endregion @@ -75,8 +74,11 @@ public MainViewModel(Settings settings) _selectedResults = Results; InitializeKeyCommands(); + + RegisterViewUpdate(); RegisterResultsUpdatedEvent(); + SetHotkey(_settings.Hotkey, OnHotkey); SetCustomPluginHotkey(); SetOpenResultModifiers(); @@ -89,15 +91,51 @@ private void RegisterResultsUpdatedEvent() var plugin = (IResultUpdated) pair.Plugin; plugin.ResultsUpdated += (s, e) => { - Task.Run(() => - { - PluginManager.UpdatePluginMetadata(e.Results, pair.Metadata, e.Query); - UpdateResultView(e.Results, pair.Metadata, e.Query); - }, _updateToken); + PluginManager.UpdatePluginMetadata(e.Results, pair.Metadata, e.Query); + if (e.Query.Search == _lastQuery.Search) + _resultsUpdateQueue.Post(new ResultsForUpdate(e.Results, pair.Metadata, e.Query, _updateToken)); }; } } + private void RegisterViewUpdate() + { + _resultsUpdateQueue = new BufferBlock(); + _resultsViewUpdateTask = + Task.Run(updateAction).ContinueWith(continueAction, TaskContinuationOptions.OnlyOnFaulted); + + + async Task updateAction() + { + var queue = new Dictionary(); + while (await _resultsUpdateQueue.OutputAvailableAsync()) + { + queue.Clear(); + await Task.Delay(20); + while (_resultsUpdateQueue.TryReceive(out var item)) + { + if (!item.Token.IsCancellationRequested) + queue[item.ID] = item; + } + + UpdateResultView(queue.Values); + } + } + + ; + + void continueAction(Task t) + { +#if DEBUG + throw t.Exception; +#else + Log.Error($"Error happen in task dealing with viewupdate for results. {t.Exception}"); + _resultsViewUpdateTask = + Task.Run(updateAction).ContinueWith(continueAction, TaskContinuationOptions.OnlyOnFaulted); +#endif + } + } + private void InitializeKeyCommands() { @@ -195,12 +233,13 @@ private void InitializeKeyCommands() public ResultsViewModel Results { get; private set; } public ResultsViewModel ContextMenu { get; private set; } public ResultsViewModel History { get; private set; } + private string _lastQueryText; private string _queryText; public string QueryText { - get { return _queryText; } + get => _queryText; set { _queryText = value; @@ -315,9 +354,20 @@ private void QueryContextMenu() { var filtered = results.Where ( - r => StringMatcher.FuzzySearch(query, r.Title).IsSearchPrecisionScoreMet() - || StringMatcher.FuzzySearch(query, r.SubTitle).IsSearchPrecisionScoreMet() - ).ToList(); + r => + { + var match = StringMatcher.FuzzySearch(query, r.Title); + if (!match.IsSearchPrecisionScoreMet()) + { + match = StringMatcher.FuzzySearch(query, r.SubTitle); + } + + if (!match.IsSearchPrecisionScoreMet()) return false; + + r.Score = match.Score; + return true; + + }).ToList(); ContextMenu.AddResults(filtered, id); } else @@ -371,112 +421,128 @@ private void QueryHistory() private void QueryResults() { - if (!string.IsNullOrEmpty(QueryText)) - { - _updateSource?.Cancel(); - var currentUpdateSource = new CancellationTokenSource(); - _updateSource = currentUpdateSource; - var currentCancellationToken = _updateSource.Token; - _updateToken = currentCancellationToken; - - ProgressBarVisibility = Visibility.Hidden; - _isQueryRunning = true; - var query = QueryBuilder.Build(QueryText.Trim(), PluginManager.NonGlobalPlugins); - if (query != null) + _updateSource?.Cancel(); + + if (string.IsNullOrWhiteSpace(QueryText)) + { + Results.Clear(); + Results.Visbility = Visibility.Collapsed; + return; + } + + _updateSource?.Dispose(); + + var currentUpdateSource = new CancellationTokenSource(); + _updateSource = currentUpdateSource; + var currentCancellationToken = _updateSource.Token; + _updateToken = currentCancellationToken; + + ProgressBarVisibility = Visibility.Hidden; + _isQueryRunning = true; + + var query = QueryBuilder.Build(QueryText.Trim(), PluginManager.NonGlobalPlugins); + + // handle the exclusiveness of plugin using action keyword + RemoveOldQueryResults(query); + + _lastQuery = query; + + var plugins = PluginManager.ValidPluginsForQuery(query); + + Task.Run(async () => { - // handle the exclusiveness of plugin using action keyword - RemoveOldQueryResults(query); + if (query.ActionKeyword == Plugin.Query.GlobalPluginWildcardSign) + { + // Wait 45 millisecond for query change in global query + // if query changes, return so that it won't be calculated + await Task.Delay(45, currentCancellationToken); + if (currentCancellationToken.IsCancellationRequested) + return; + } - _lastQuery = query; - Task.Delay(200, currentCancellationToken).ContinueWith(_ => + _ = Task.Delay(200, currentCancellationToken).ContinueWith(_ => { // start the progress bar if query takes more than 200 ms and this is the current running query and it didn't finish yet - if (currentUpdateSource == _updateSource && _isQueryRunning) + if (!currentCancellationToken.IsCancellationRequested && _isQueryRunning) { ProgressBarVisibility = Visibility.Visible; } }, currentCancellationToken); - var plugins = PluginManager.ValidPluginsForQuery(query); - Task.Run(async () => + Task[] tasks = new Task[plugins.Count]; + try { - // so looping will stop once it was cancelled - - Task[] tasks = new Task[plugins.Count]; - try + for (var i = 0; i < plugins.Count; i++) { - for (var i = 0; i < plugins.Count; i++) + if (!plugins[i].Metadata.Disabled) { - if (!plugins[i].Metadata.Disabled) - { - tasks[i] = QueryTask(plugins[i], query, currentCancellationToken); - } - else - { - tasks[i] = Task.CompletedTask; // Avoid Null - } + tasks[i] = QueryTask(plugins[i]); + } + else + { + tasks[i] = Task.CompletedTask; // Avoid Null } - - // Check the code, WhenAll will translate all type of IEnumerable or Collection to Array, so make an array at first - await Task.WhenAll(tasks); - } - catch (OperationCanceledException) - { - // nothing to do here } - // this should happen once after all queries are done so progress bar should continue - // until the end of all querying - _isQueryRunning = false; - if (!currentCancellationToken.IsCancellationRequested) - { - // update to hidden if this is still the current query - ProgressBarVisibility = Visibility.Hidden; - } + // Check the code, WhenAll will translate all type of IEnumerable or Collection to Array, so make an array at first + await Task.WhenAll(tasks); + } + catch (OperationCanceledException) + { + // nothing to do here + } - // Local function - async Task QueryTask(PluginPair plugin, Query query, CancellationToken token) - { - // Since it is wrapped within a Task.Run, the synchronous context is null - // Task.Yield will force it to run in ThreadPool - await Task.Yield(); + if (currentCancellationToken.IsCancellationRequested) + return; - var results = await PluginManager.QueryForPlugin(plugin, query, token); - if (!currentCancellationToken.IsCancellationRequested) - UpdateResultView(results, plugin.Metadata, query); - } - }, currentCancellationToken).ContinueWith( - t => Log.Exception("|MainViewModel|Plugins Query Exceptions", t.Exception), - TaskContinuationOptions.OnlyOnFaulted); - } - } - else - { - Results.Clear(); - Results.Visbility = Visibility.Collapsed; - } + // this should happen once after all queries are done so progress bar should continue + // until the end of all querying + _isQueryRunning = false; + if (!currentCancellationToken.IsCancellationRequested) + { + // update to hidden if this is still the current query + ProgressBarVisibility = Visibility.Hidden; + } + + // Local function + async Task QueryTask(PluginPair plugin) + { + // Since it is wrapped within a Task.Run, the synchronous context is null + // Task.Yield will force it to run in ThreadPool + await Task.Yield(); + + var results = await PluginManager.QueryForPlugin(plugin, query, currentCancellationToken); + if (!currentCancellationToken.IsCancellationRequested) + _resultsUpdateQueue.Post(new ResultsForUpdate(results, plugin.Metadata, query, + currentCancellationToken)); + } + }, currentCancellationToken) + .ContinueWith(t => Log.Exception("|MainViewModel|Plugins Query Exceptions", t.Exception), + TaskContinuationOptions.OnlyOnFaulted); } + private void RemoveOldQueryResults(Query query) { string lastKeyword = _lastQuery.ActionKeyword; + string keyword = query.ActionKeyword; if (string.IsNullOrEmpty(lastKeyword)) { if (!string.IsNullOrEmpty(keyword)) { - Results.RemoveResultsExcept(PluginManager.NonGlobalPlugins[keyword].Metadata); + Results.KeepResultsFor(PluginManager.NonGlobalPlugins[keyword].Metadata); } } else { if (string.IsNullOrEmpty(keyword)) { - Results.RemoveResultsFor(PluginManager.NonGlobalPlugins[lastKeyword].Metadata); + Results.KeepResultsExcept(PluginManager.NonGlobalPlugins[lastKeyword].Metadata); } else if (lastKeyword != keyword) { - Results.RemoveResultsExcept(PluginManager.NonGlobalPlugins[keyword].Metadata); + Results.KeepResultsFor(PluginManager.NonGlobalPlugins[keyword].Metadata); } } } @@ -554,7 +620,6 @@ private bool ContextMenuSelected() return selected; } - private bool HistorySelected() { var selected = SelectedResults == History; @@ -683,30 +748,47 @@ public void Save() /// /// To avoid deadlock, this method should not called from main thread /// - public void UpdateResultView(List list, PluginMetadata metadata, Query originQuery) + public void UpdateResultView(IEnumerable resultsForUpdates) { - foreach (var result in list) + if (!resultsForUpdates.Any()) + return; + CancellationToken token; + + try { - if (_topMostRecord.IsTopMost(result)) - { - result.Score = int.MaxValue; - } - else - { - var priorityScore = metadata.Priority * 150; - result.Score += _userSelectedRecord.GetSelectedCount(result) * 5 + priorityScore; - } + // Don't know why sometimes even resultsForUpdates is empty, the method won't return; + token = resultsForUpdates.Select(r => r.Token).Distinct().SingleOrDefault(); } - - if (originQuery.RawQuery == _lastQuery.RawQuery) +#if DEBUG + catch { - Results.AddResults(list, metadata.ID); + throw new ArgumentException("Unacceptable token"); } +#else + catch + { + token = default; + } +#endif + - if (Results.Visbility != Visibility.Visible && list.Count > 0) + foreach (var metaResults in resultsForUpdates) { - Results.Visbility = Visibility.Visible; + foreach (var result in metaResults.Results) + { + if (_topMostRecord.IsTopMost(result)) + { + result.Score = int.MaxValue; + } + else + { + var priorityScore = metaResults.Metadata.Priority * 150; + result.Score += _userSelectedRecord.GetSelectedCount(result) * 5 + priorityScore; + } + } } + + Results.AddResults(resultsForUpdates, token); } #endregion diff --git a/Flow.Launcher/ViewModel/ResultViewModel.cs b/Flow.Launcher/ViewModel/ResultViewModel.cs index 4c65f2b9fbf..c91bbb1074f 100644 --- a/Flow.Launcher/ViewModel/ResultViewModel.cs +++ b/Flow.Launcher/ViewModel/ResultViewModel.cs @@ -1,9 +1,7 @@ using System; -using System.Threading; using System.Threading.Tasks; using System.Windows; using System.Windows.Media; -using Flow.Launcher.Infrastructure; using Flow.Launcher.Infrastructure.Image; using Flow.Launcher.Infrastructure.Logger; using Flow.Launcher.Infrastructure.UserSettings; @@ -106,14 +104,10 @@ private async ValueTask SetImage() } if (ImageLoader.CacheContainImage(imagePath)) - { // will get here either when icoPath has value\icon delegate is null\when had exception in delegate return ImageLoader.Load(imagePath); - } - else - { - return await Task.Run(() => ImageLoader.Load(imagePath)); - } + + return await Task.Run(() => ImageLoader.Load(imagePath)); } public Result Result { get; } diff --git a/Flow.Launcher/ViewModel/ResultsForUpdate.cs b/Flow.Launcher/ViewModel/ResultsForUpdate.cs new file mode 100644 index 00000000000..be48f53c16b --- /dev/null +++ b/Flow.Launcher/ViewModel/ResultsForUpdate.cs @@ -0,0 +1,35 @@ +using Flow.Launcher.Plugin; +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading; + +namespace Flow.Launcher.ViewModel +{ + public class ResultsForUpdate + { + public List Results { get; } + + public PluginMetadata Metadata { get; } + public string ID { get; } + + public Query Query { get; } + public CancellationToken Token { get; } + + public ResultsForUpdate(List results, string resultID, CancellationToken token) + { + Results = results; + ID = resultID; + Token = token; + } + + public ResultsForUpdate(List results, PluginMetadata metadata, Query query, CancellationToken token) + { + Results = results; + Metadata = metadata; + Query = query; + Token = token; + ID = metadata.ID; + } + } +} diff --git a/Flow.Launcher/ViewModel/ResultsViewModel.cs b/Flow.Launcher/ViewModel/ResultsViewModel.cs index d3085418062..1b8dd602dbc 100644 --- a/Flow.Launcher/ViewModel/ResultsViewModel.cs +++ b/Flow.Launcher/ViewModel/ResultsViewModel.cs @@ -1,7 +1,10 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Collections.Specialized; using System.Linq; +using System.Threading; +using System.Threading.Tasks; using System.Windows; using System.Windows.Controls; using System.Windows.Data; @@ -17,7 +20,6 @@ public class ResultsViewModel : BaseModel public ResultCollection Results { get; } - private readonly object _addResultsLock = new object(); private readonly object _collectionLock = new object(); private readonly Settings _settings; private int MaxResults => _settings?.MaxResultsToShow ?? 6; @@ -116,17 +118,20 @@ public void SelectFirstResult() public void Clear() { - Results.Clear(); + lock (_collectionLock) + Results.RemoveAll(); } - public void RemoveResultsExcept(PluginMetadata metadata) + public void KeepResultsFor(PluginMetadata metadata) { - Results.RemoveAll(r => r.Result.PluginID != metadata.ID); + lock (_collectionLock) + Results.Update(Results.Where(r => r.Result.PluginID == metadata.ID).ToList()); } - public void RemoveResultsFor(PluginMetadata metadata) + public void KeepResultsExcept(PluginMetadata metadata) { - Results.RemoveAll(r => r.Result.PluginID == metadata.ID); + lock (_collectionLock) + Results.Update(Results.Where(r => r.Result.PluginID != metadata.ID).ToList()); } /// @@ -134,70 +139,99 @@ public void RemoveResultsFor(PluginMetadata metadata) /// public void AddResults(List newRawResults, string resultId) { - lock (_addResultsLock) + lock (_collectionLock) { var newResults = NewResults(newRawResults, resultId); - // update UI in one run, so it can avoid UI flickering - Results.Update(newResults); + // https://social.msdn.microsoft.com/Forums/vstudio/en-US/5ff71969-f183-4744-909d-50f7cd414954/binding-a-tabcontrols-selectedindex-not-working?forum=wpf + // fix selected index flow + var updateTask = Task.Run(() => + { + // update UI in one run, so it can avoid UI flickering - if (Results.Count > 0) + Results.Update(newResults); + if (Results.Any()) + SelectedItem = Results[0]; + }); + if (!updateTask.Wait(300)) { + updateTask.Dispose(); + throw new TimeoutException("Update result use too much time."); + } + + } + + if (Visbility != Visibility.Visible && Results.Count > 0) + { + Margin = new Thickness { Top = 8 }; + SelectedIndex = 0; + Visbility = Visibility.Visible; + } + else + { + Margin = new Thickness { Top = 0 }; + Visbility = Visibility.Collapsed; + } + } + /// + /// To avoid deadlock, this method should not called from main thread + /// + public void AddResults(IEnumerable resultsForUpdates, CancellationToken token) + { + var newResults = NewResults(resultsForUpdates); + if (token.IsCancellationRequested) + return; + lock (_collectionLock) + { + // update UI in one run, so it can avoid UI flickering + + Results.Update(newResults, token); + if (Results.Any()) + SelectedItem = Results[0]; + } + + switch (Visbility) + { + case Visibility.Collapsed when Results.Count > 0: Margin = new Thickness { Top = 8 }; SelectedIndex = 0; - } - else - { + Visbility = Visibility.Visible; + break; + case Visibility.Visible when Results.Count == 0: Margin = new Thickness { Top = 0 }; - } + Visbility = Visibility.Collapsed; + break; } + } private List NewResults(List newRawResults, string resultId) { - var results = Results.ToList(); - var newResults = newRawResults.Select(r => new ResultViewModel(r, _settings)).ToList(); - var oldResults = results.Where(r => r.Result.PluginID == resultId).ToList(); - - // Find the same results in A (old results) and B (new newResults) - var sameResults = oldResults - .Where(t1 => newResults.Any(x => x.Result.Equals(t1.Result))) - .ToList(); + if (newRawResults.Count == 0) + return Results.ToList(); - // remove result of relative complement of B in A - foreach (var result in oldResults.Except(sameResults)) - { - results.Remove(result); - } + var results = Results as IEnumerable; - // update result with B's score and index position - foreach (var sameResult in sameResults) - { - int oldIndex = results.IndexOf(sameResult); - int oldScore = results[oldIndex].Result.Score; - var newResult = newResults[newResults.IndexOf(sameResult)]; - int newScore = newResult.Result.Score; - if (newScore != oldScore) - { - var oldResult = results[oldIndex]; + var newResults = newRawResults.Select(r => new ResultViewModel(r, _settings)).ToList(); - oldResult.Result.Score = newScore; - oldResult.Result.OriginQuery = newResult.Result.OriginQuery; + return results.Where(r => r.Result.PluginID != resultId) + .Concat(results.Intersect(newResults).Union(newResults)) + .OrderByDescending(r => r.Result.Score) + .ToList(); + } - results.RemoveAt(oldIndex); - int newIndex = InsertIndexOf(newScore, results); - results.Insert(newIndex, oldResult); - } - } + private List NewResults(IEnumerable resultsForUpdates) + { + if (!resultsForUpdates.Any()) + return Results.ToList(); - // insert result in relative complement of A in B - foreach (var result in newResults.Except(sameResults)) - { - int newIndex = InsertIndexOf(result.Result.Score, results); - results.Insert(newIndex, result); - } + var results = Results as IEnumerable; - return results; + return results.Where(r => r != null && !resultsForUpdates.Any(u => u.Metadata.ID == r.Result.PluginID)) + .Concat( + resultsForUpdates.SelectMany(u => u.Results, (u, r) => new ResultViewModel(r, _settings))) + .OrderByDescending(rv => rv.Result.Score) + .ToList(); } #endregion @@ -234,58 +268,71 @@ private static void FormattedTextPropertyChanged(DependencyObject d, DependencyP public class ResultCollection : ObservableCollection { + private long editTime = 0; + + private bool _suppressNotifying = false; + + private CancellationToken _token; - public void RemoveAll(Predicate predicate) + protected override void OnCollectionChanged(NotifyCollectionChangedEventArgs e) { - CheckReentrancy(); + if (!_suppressNotifying) + { + base.OnCollectionChanged(e); + } + } - for (int i = Count - 1; i >= 0; i--) + public void BulkAddRange(IEnumerable resultViews) + { + // suppress notifying before adding all element + _suppressNotifying = true; + foreach (var item in resultViews) + { + Add(item); + } + _suppressNotifying = false; + // manually update event + // wpf use directx / double buffered already, so just reset all won't cause ui flickering + if (_token.IsCancellationRequested) + return; + OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); + } + public void AddRange(IEnumerable Items) + { + foreach (var item in Items) { - if (predicate(this[i])) - { - RemoveAt(i); - } + if (_token.IsCancellationRequested) + return; + Add(item); } } + public void RemoveAll() + { + ClearItems(); + } /// /// Update the results collection with new results, try to keep identical results /// /// - public void Update(List newItems) + public void Update(List newItems, CancellationToken token = default) { - int newCount = newItems.Count; - int oldCount = Items.Count; - int location = newCount > oldCount ? oldCount : newCount; - - for (int i = 0; i < location; i++) - { - ResultViewModel oldResult = this[i]; - ResultViewModel newResult = newItems[i]; - if (!oldResult.Equals(newResult)) - { // result is not the same update it in the current index - this[i] = newResult; - } - else if (oldResult.Result.Score != newResult.Result.Score) - { - this[i].Result.Score = newResult.Result.Score; - } - } - + _token = token; + if (Count == 0 && newItems.Count == 0 || _token.IsCancellationRequested) + return; - if (newCount >= oldCount) + if (editTime < 10 || newItems.Count < 30) { - for (int i = oldCount; i < newCount; i++) - { - Add(newItems[i]); - } + if (Count != 0) ClearItems(); + AddRange(newItems); + editTime++; + return; } else { - for (int i = oldCount - 1; i >= newCount; i--) - { - RemoveAt(i); - } + Clear(); + BulkAddRange(newItems); + editTime++; } } }