diff --git a/Flow.Launcher.Core/Plugin/QueryBuilder.cs b/Flow.Launcher.Core/Plugin/QueryBuilder.cs index 3dc7877acc2..0ef3f30f5e1 100644 --- a/Flow.Launcher.Core/Plugin/QueryBuilder.cs +++ b/Flow.Launcher.Core/Plugin/QueryBuilder.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using Flow.Launcher.Plugin; @@ -33,7 +33,7 @@ public static Query Build(string text, Dictionary nonGlobalP searchTerms = terms; } - return new Query () + return new Query() { Search = search, RawQuery = rawQuery, @@ -42,4 +42,4 @@ public static Query Build(string text, Dictionary nonGlobalP }; } } -} \ No newline at end of file +} diff --git a/Flow.Launcher.Core/Resource/Theme.cs b/Flow.Launcher.Core/Resource/Theme.cs index a46932c6af4..059359694b4 100644 --- a/Flow.Launcher.Core/Resource/Theme.cs +++ b/Flow.Launcher.Core/Resource/Theme.cs @@ -76,7 +76,7 @@ public Theme(IPublicAPI publicAPI, Settings settings) { _api.LogError(ClassName, "Current theme resource not found. Initializing with default theme."); _oldTheme = Constant.DefaultTheme; - }; + } } #endregion @@ -126,7 +126,7 @@ public void UpdateFonts() // Load a ResourceDictionary for the specified theme. var themeName = _settings.Theme; var dict = GetThemeResourceDictionary(themeName); - + // Apply font settings to the theme resource. ApplyFontSettings(dict); UpdateResourceDictionary(dict); @@ -152,11 +152,11 @@ private void ApplyFontSettings(ResourceDictionary dict) var fontStyle = FontHelper.GetFontStyleFromInvariantStringOrNormal(_settings.QueryBoxFontStyle); var fontWeight = FontHelper.GetFontWeightFromInvariantStringOrNormal(_settings.QueryBoxFontWeight); var fontStretch = FontHelper.GetFontStretchFromInvariantStringOrNormal(_settings.QueryBoxFontStretch); - + SetFontProperties(queryBoxStyle, fontFamily, fontStyle, fontWeight, fontStretch, true); SetFontProperties(querySuggestionBoxStyle, fontFamily, fontStyle, fontWeight, fontStretch, false); } - + if (dict["ItemTitleStyle"] is Style resultItemStyle && dict["ItemTitleSelectedStyle"] is Style resultItemSelectedStyle && dict["ItemHotkeyStyle"] is Style resultHotkeyItemStyle && @@ -172,7 +172,7 @@ private void ApplyFontSettings(ResourceDictionary dict) SetFontProperties(resultHotkeyItemStyle, fontFamily, fontStyle, fontWeight, fontStretch, false); SetFontProperties(resultHotkeyItemSelectedStyle, fontFamily, fontStyle, fontWeight, fontStretch, false); } - + if (dict["ItemSubTitleStyle"] is Style resultSubItemStyle && dict["ItemSubTitleSelectedStyle"] is Style resultSubItemSelectedStyle) { @@ -197,7 +197,7 @@ private static void SetFontProperties(Style style, FontFamily fontFamily, FontSt // First, find the setters to remove and store them in a list var settersToRemove = style.Setters .OfType() - .Where(setter => + .Where(setter => setter.Property == Control.FontFamilyProperty || setter.Property == Control.FontStyleProperty || setter.Property == Control.FontWeightProperty || @@ -227,18 +227,18 @@ private static void SetFontProperties(Style style, FontFamily fontFamily, FontSt { var settersToRemove = style.Setters .OfType() - .Where(setter => + .Where(setter => setter.Property == TextBlock.FontFamilyProperty || setter.Property == TextBlock.FontStyleProperty || setter.Property == TextBlock.FontWeightProperty || setter.Property == TextBlock.FontStretchProperty) .ToList(); - + foreach (var setter in settersToRemove) { style.Setters.Remove(setter); } - + style.Setters.Add(new Setter(TextBlock.FontFamilyProperty, fontFamily)); style.Setters.Add(new Setter(TextBlock.FontStyleProperty, fontStyle)); style.Setters.Add(new Setter(TextBlock.FontWeightProperty, fontWeight)); @@ -421,7 +421,7 @@ public bool ChangeTheme(string theme = null) // Retrieve theme resource – always use the resource with font settings applied. var resourceDict = GetResourceDictionary(theme); - + UpdateResourceDictionary(resourceDict); _settings.Theme = theme; diff --git a/Flow.Launcher.Infrastructure/NativeMethods.txt b/Flow.Launcher.Infrastructure/NativeMethods.txt index 18b20602213..0e50420b0e0 100644 --- a/Flow.Launcher.Infrastructure/NativeMethods.txt +++ b/Flow.Launcher.Infrastructure/NativeMethods.txt @@ -43,6 +43,9 @@ MONITORINFOEXW WM_ENTERSIZEMOVE WM_EXITSIZEMOVE +OleInitialize +OleUninitialize + GetKeyboardLayout GetWindowThreadProcessId ActivateKeyboardLayout @@ -53,4 +56,4 @@ INPUTLANGCHANGE_FORWARD LOCALE_TRANSIENT_KEYBOARD1 LOCALE_TRANSIENT_KEYBOARD2 LOCALE_TRANSIENT_KEYBOARD3 -LOCALE_TRANSIENT_KEYBOARD4 \ No newline at end of file +LOCALE_TRANSIENT_KEYBOARD4 diff --git a/Flow.Launcher.Infrastructure/UserSettings/CustomShortcutModel.cs b/Flow.Launcher.Infrastructure/UserSettings/CustomShortcutModel.cs index 71020369a60..2d15b54c5be 100644 --- a/Flow.Launcher.Infrastructure/UserSettings/CustomShortcutModel.cs +++ b/Flow.Launcher.Infrastructure/UserSettings/CustomShortcutModel.cs @@ -1,15 +1,15 @@ using System; using System.Text.Json.Serialization; +using System.Threading.Tasks; namespace Flow.Launcher.Infrastructure.UserSettings { + #region Base + public abstract class ShortcutBaseModel { public string Key { get; set; } - [JsonIgnore] - public Func Expand { get; set; } = () => { return ""; }; - public override bool Equals(object obj) { return obj is ShortcutBaseModel other && @@ -22,16 +22,14 @@ public override int GetHashCode() } } - public class CustomShortcutModel : ShortcutBaseModel + public class BaseCustomShortcutModel : ShortcutBaseModel { public string Value { get; set; } - [JsonConstructorAttribute] - public CustomShortcutModel(string key, string value) + public BaseCustomShortcutModel(string key, string value) { Key = key; Value = value; - Expand = () => { return Value; }; } public void Deconstruct(out string key, out string value) @@ -40,26 +38,69 @@ public void Deconstruct(out string key, out string value) value = Value; } - public static implicit operator (string Key, string Value)(CustomShortcutModel shortcut) + public static implicit operator (string Key, string Value)(BaseCustomShortcutModel shortcut) { return (shortcut.Key, shortcut.Value); } - public static implicit operator CustomShortcutModel((string Key, string Value) shortcut) + public static implicit operator BaseCustomShortcutModel((string Key, string Value) shortcut) { - return new CustomShortcutModel(shortcut.Key, shortcut.Value); + return new BaseCustomShortcutModel(shortcut.Key, shortcut.Value); } } - public class BuiltinShortcutModel : ShortcutBaseModel + public class BaseBuiltinShortcutModel : ShortcutBaseModel { public string Description { get; set; } - public BuiltinShortcutModel(string key, string description, Func expand) + public BaseBuiltinShortcutModel(string key, string description) { Key = key; Description = description; - Expand = expand ?? (() => { return ""; }); } } + + #endregion + + #region Custom Shortcut + + public class CustomShortcutModel : BaseCustomShortcutModel + { + [JsonIgnore] + public Func Expand { get; set; } = () => { return string.Empty; }; + + [JsonConstructor] + public CustomShortcutModel(string key, string value) : base(key, value) + { + Expand = () => { return Value; }; + } + } + + #endregion + + #region Builtin Shortcut + + public class BuiltinShortcutModel : BaseBuiltinShortcutModel + { + [JsonIgnore] + public Func Expand { get; set; } = () => { return string.Empty; }; + + public BuiltinShortcutModel(string key, string description, Func expand) : base(key, description) + { + Expand = expand ?? (() => { return string.Empty; }); + } + } + + public class AsyncBuiltinShortcutModel : BaseBuiltinShortcutModel + { + [JsonIgnore] + public Func> ExpandAsync { get; set; } = () => { return Task.FromResult(string.Empty); }; + + public AsyncBuiltinShortcutModel(string key, string description, Func> expandAsync) : base(key, description) + { + ExpandAsync = expandAsync ?? (() => { return Task.FromResult(string.Empty); }); + } + } + + #endregion } diff --git a/Flow.Launcher.Infrastructure/UserSettings/Settings.cs b/Flow.Launcher.Infrastructure/UserSettings/Settings.cs index 1cccc38d938..b7a1d1f6367 100644 --- a/Flow.Launcher.Infrastructure/UserSettings/Settings.cs +++ b/Flow.Launcher.Infrastructure/UserSettings/Settings.cs @@ -312,9 +312,9 @@ public bool KeepMaxResults public ObservableCollection CustomShortcuts { get; set; } = new ObservableCollection(); [JsonIgnore] - public ObservableCollection BuiltinShortcuts { get; set; } = new() + public ObservableCollection BuiltinShortcuts { get; set; } = new() { - new BuiltinShortcutModel("{clipboard}", "shortcut_clipboard_description", Clipboard.GetText), + new AsyncBuiltinShortcutModel("{clipboard}", "shortcut_clipboard_description", () => Win32Helper.StartSTATaskAsync(Clipboard.GetText)), new BuiltinShortcutModel("{active_explorer_path}", "shortcut_active_explorer_path", FileExplorerHelper.GetActiveExplorerPath) }; diff --git a/Flow.Launcher.Infrastructure/Win32Helper.cs b/Flow.Launcher.Infrastructure/Win32Helper.cs index 2788060eb4c..783ade14ebe 100644 --- a/Flow.Launcher.Infrastructure/Win32Helper.cs +++ b/Flow.Launcher.Infrastructure/Win32Helper.cs @@ -5,6 +5,8 @@ using System.Globalization; using System.Linq; using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; using System.Windows; using System.Windows.Interop; using System.Windows.Markup; @@ -337,6 +339,78 @@ internal static HWND GetWindowHandle(Window window, bool ensure = false) #endregion + #region STA Thread + + /* + Inspired by https://github.com/files-community/Files code on STA Thread handling. + */ + + public static Task StartSTATaskAsync(Action action) + { + var taskCompletionSource = new TaskCompletionSource(); + Thread thread = new(() => + { + PInvoke.OleInitialize(); + + try + { + action(); + taskCompletionSource.SetResult(); + } + catch (System.Exception ex) + { + taskCompletionSource.SetException(ex); + } + finally + { + PInvoke.OleUninitialize(); + } + }) + { + IsBackground = true, + Priority = ThreadPriority.Normal + }; + + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + + return taskCompletionSource.Task; + } + + public static Task StartSTATaskAsync(Func func) + { + var taskCompletionSource = new TaskCompletionSource(); + + Thread thread = new(() => + { + PInvoke.OleInitialize(); + + try + { + taskCompletionSource.SetResult(func()); + } + catch (System.Exception ex) + { + taskCompletionSource.SetException(ex); + } + finally + { + PInvoke.OleUninitialize(); + } + }) + { + IsBackground = true, + Priority = ThreadPriority.Normal + }; + + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + + return taskCompletionSource.Task; + } + + #endregion + #region Keyboard Layout private const string UserProfileRegistryPath = @"Control Panel\International\User Profile"; diff --git a/Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs b/Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs index 31580fbe80a..d4eb02a909e 100644 --- a/Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs +++ b/Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs @@ -471,8 +471,11 @@ public interface IPublicAPI public Task UpdatePluginManifestAsync(bool usePrimaryUrlOnly = false, CancellationToken token = default); /// - /// Get the plugin manifest + /// Get the plugin manifest. /// + /// + /// If Flow cannot get manifest data, this could be null + /// /// public IReadOnlyList GetPluginManifest(); diff --git a/Flow.Launcher.Plugin/Query.cs b/Flow.Launcher.Plugin/Query.cs index 913dc31ae65..c3eede4c6b6 100644 --- a/Flow.Launcher.Plugin/Query.cs +++ b/Flow.Launcher.Plugin/Query.cs @@ -8,7 +8,8 @@ namespace Flow.Launcher.Plugin public class Query { /// - /// Raw query, this includes action keyword if it has + /// Raw query, this includes action keyword if it has. + /// It has handled buildin custom query shortkeys and build-in shortcuts, and it trims the whitespace. /// We didn't recommend use this property directly. You should always use Search property. /// public string RawQuery { get; internal init; } @@ -63,10 +64,10 @@ public class Query /// [JsonIgnore] public string FirstSearch => SplitSearch(0); - + [JsonIgnore] private string _secondToEndSearch; - + /// /// strings from second search (including) to last search /// diff --git a/Flow.Launcher/App.xaml.cs b/Flow.Launcher/App.xaml.cs index 1b57d5cbe5c..402812a92d7 100644 --- a/Flow.Launcher/App.xaml.cs +++ b/Flow.Launcher/App.xaml.cs @@ -23,6 +23,7 @@ using Flow.Launcher.ViewModel; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Microsoft.VisualStudio.Threading; namespace Flow.Launcher { @@ -198,6 +199,10 @@ await API.StopwatchLogInfoAsync(ClassName, "Startup cost", async () => Current.MainWindow = _mainWindow; Current.MainWindow.Title = Constant.FlowLauncher; + // Initialize hotkey mapper instantly after main window is created because + // it will steal focus from main window which causes window hide + HotKeyMapper.Initialize(); + // Main windows needs initialized before theme change because of blur settings Ioc.Default.GetRequiredService().ChangeTheme(); @@ -239,6 +244,7 @@ private void AutoStartup() } } + [Conditional("RELEASE")] private void AutoUpdates() { _ = Task.Run(async () => @@ -296,13 +302,12 @@ private void RegisterDispatcherUnhandledException() [Conditional("RELEASE")] private static void RegisterAppDomainExceptions() { - AppDomain.CurrentDomain.UnhandledException += ErrorReporting.UnhandledExceptionHandle; + AppDomain.CurrentDomain.UnhandledException += ErrorReporting.UnhandledException; } /// /// Let exception throw as normal is better for Debug /// - [Conditional("RELEASE")] private static void RegisterTaskSchedulerUnhandledException() { TaskScheduler.UnobservedTaskException += ErrorReporting.TaskSchedulerUnobservedTaskException; diff --git a/Flow.Launcher/Helper/DataWebRequestFactory.cs b/Flow.Launcher/Helper/DataWebRequestFactory.cs index 2e72ee240fd..db198ede4e7 100644 --- a/Flow.Launcher/Helper/DataWebRequestFactory.cs +++ b/Flow.Launcher/Helper/DataWebRequestFactory.cs @@ -28,18 +28,18 @@ class DataWebResponse : WebResponse public DataWebResponse(Uri uri) { - string uriString = uri.AbsoluteUri; + var uriString = uri.AbsoluteUri; - int commaIndex = uriString.IndexOf(','); - var headers = uriString.Substring(0, commaIndex).Split(';'); + var commaIndex = uriString.IndexOf(','); + var headers = uriString[..commaIndex].Split(';'); _contentType = headers[0]; - string dataString = uriString.Substring(commaIndex + 1); + var dataString = uriString[(commaIndex + 1)..]; _data = Convert.FromBase64String(dataString); } public override string ContentType { - get { return _contentType; } + get => _contentType; set { throw new NotSupportedException(); @@ -48,7 +48,7 @@ public override string ContentType public override long ContentLength { - get { return _data.Length; } + get => _data.Length; set { throw new NotSupportedException(); diff --git a/Flow.Launcher/Helper/ErrorReporting.cs b/Flow.Launcher/Helper/ErrorReporting.cs index b1ddba7179a..aa810ba651a 100644 --- a/Flow.Launcher/Helper/ErrorReporting.cs +++ b/Flow.Launcher/Helper/ErrorReporting.cs @@ -1,4 +1,5 @@ using System; +using System.Runtime.CompilerServices; using System.Threading.Tasks; using System.Windows; using System.Windows.Threading; @@ -10,33 +11,34 @@ namespace Flow.Launcher.Helper; public static class ErrorReporting { - private static void Report(Exception e) + private static void Report(Exception e, [CallerMemberName] string methodName = "UnHandledException") { - var logger = LogManager.GetLogger("UnHandledException"); + var logger = LogManager.GetLogger(methodName); logger.Fatal(ExceptionFormatter.FormatExcpetion(e)); var reportWindow = new ReportWindow(e); reportWindow.Show(); } - public static void UnhandledExceptionHandle(object sender, UnhandledExceptionEventArgs e) + public static void UnhandledException(object sender, UnhandledExceptionEventArgs e) { - //handle non-ui thread exceptions + // handle non-ui thread exceptions Report((Exception)e.ExceptionObject); } public static void DispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e) { - //handle ui thread exceptions + // handle ui thread exceptions Report(e.Exception); - //prevent application exist, so the user can copy prompted error info + // prevent application exist, so the user can copy prompted error info e.Handled = true; } public static void TaskSchedulerUnobservedTaskException(object sender, UnobservedTaskExceptionEventArgs e) { - //handle unobserved task exceptions + // handle unobserved task exceptions on UI thread Application.Current.Dispatcher.Invoke(() => Report(e.Exception)); - //prevent application exit, so the user can copy the prompted error info + // prevent application exit, so the user can copy the prompted error info + e.SetObserved(); } public static string RuntimeInfo() diff --git a/Flow.Launcher/Languages/en.xaml b/Flow.Launcher/Languages/en.xaml index d3252cb314c..ca0ed33b514 100644 --- a/Flow.Launcher/Languages/en.xaml +++ b/Flow.Launcher/Languages/en.xaml @@ -394,6 +394,7 @@ This new Action Keyword is the same as old, please choose a different one Success Completed successfully + Failed to copy Enter the action keywords you like to use to start the plugin and use whitespace to divide them. Use * if you don't want to specify any, and the plugin will be triggered without any action keywords. diff --git a/Flow.Launcher/MainWindow.xaml.cs b/Flow.Launcher/MainWindow.xaml.cs index 10aff050ee8..ae7b098a206 100644 --- a/Flow.Launcher/MainWindow.xaml.cs +++ b/Flow.Launcher/MainWindow.xaml.cs @@ -16,7 +16,6 @@ using CommunityToolkit.Mvvm.DependencyInjection; using Flow.Launcher.Core.Plugin; using Flow.Launcher.Core.Resource; -using Flow.Launcher.Helper; using Flow.Launcher.Infrastructure; using Flow.Launcher.Infrastructure.Hotkey; using Flow.Launcher.Infrastructure.Image; @@ -182,9 +181,6 @@ private async void OnLoaded(object sender, RoutedEventArgs _) // Set the initial state of the QueryTextBoxCursorMovedToEnd property // Without this part, when shown for the first time, switching the context menu does not move the cursor to the end. _viewModel.QueryTextCursorMovedToEnd = false; - - // Initialize hotkey mapper after window is loaded - HotKeyMapper.Initialize(); // View model property changed event _viewModel.PropertyChanged += (o, e) => diff --git a/Flow.Launcher/PublicAPIInstance.cs b/Flow.Launcher/PublicAPIInstance.cs index 3abc57b8a81..5b8e8c9af89 100644 --- a/Flow.Launcher/PublicAPIInstance.cs +++ b/Flow.Launcher/PublicAPIInstance.cs @@ -40,7 +40,7 @@ public class PublicAPIInstance : IPublicAPI, IRemovable private readonly Settings _settings; private readonly MainViewModel _mainVM; - // Must use getter to access Application.Current.Resources.MergedDictionaries so earlier + // Must use getter to avoid accessing Application.Current.Resources.MergedDictionaries so earlier in theme constructor private Theme _theme; private Theme Theme => _theme ??= Ioc.Default.GetRequiredService(); @@ -69,8 +69,7 @@ public void ChangeQuery(string query, bool requery = false) _mainVM.ChangeQueryText(query, requery); } -#pragma warning disable VSTHRD100 // Avoid async void methods - + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD100:Avoid async void methods", Justification = "")] public async void RestartApp() { _mainVM.Hide(); @@ -89,8 +88,6 @@ public async void RestartApp() UpdateManager.RestartApp(Constant.ApplicationFileName); } -#pragma warning restore VSTHRD100 // Avoid async void methods - public void ShowMainWindow() => _mainVM.Show(); public void HideMainWindow() => _mainVM.Hide(); @@ -145,35 +142,90 @@ public void ShellRun(string cmd, string filename = "cmd.exe") ShellCommand.Execute(startInfo); } - public void CopyToClipboard(string stringToCopy, bool directCopy = false, bool showDefaultNotification = true) + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD100:Avoid async void methods", Justification = "")] + public async void CopyToClipboard(string stringToCopy, bool directCopy = false, bool showDefaultNotification = true) { if (string.IsNullOrEmpty(stringToCopy)) + { return; + } var isFile = File.Exists(stringToCopy); if (directCopy && (isFile || Directory.Exists(stringToCopy))) { - var paths = new StringCollection + // Sometimes the clipboard is locked and cannot be accessed, + // we need to retry a few times before giving up + var exception = await RetryActionOnSTAThreadAsync(() => + { + var paths = new StringCollection { stringToCopy }; - Clipboard.SetFileDropList(paths); - - if (showDefaultNotification) - ShowMsg( - $"{GetTranslation("copy")} {(isFile ? GetTranslation("fileTitle") : GetTranslation("folderTitle"))}", - GetTranslation("completedSuccessfully")); + Clipboard.SetFileDropList(paths); + }); + + if (exception == null) + { + if (showDefaultNotification) + { + ShowMsg( + $"{GetTranslation("copy")} {(isFile ? GetTranslation("fileTitle") : GetTranslation("folderTitle"))}", + GetTranslation("completedSuccessfully")); + } + } + else + { + LogException(nameof(PublicAPIInstance), "Failed to copy file/folder to clipboard", exception); + ShowMsgError(GetTranslation("failedToCopy")); + } } else { - Clipboard.SetDataObject(stringToCopy); + // Sometimes the clipboard is locked and cannot be accessed, + // we need to retry a few times before giving up + var exception = await RetryActionOnSTAThreadAsync(() => + { + // We should use SetText instead of SetDataObject to avoid the clipboard being locked by other applications + Clipboard.SetText(stringToCopy); + }); + + if (exception == null) + { + if (showDefaultNotification) + { + ShowMsg( + $"{GetTranslation("copy")} {GetTranslation("textTitle")}", + GetTranslation("completedSuccessfully")); + } + } + else + { + LogException(nameof(PublicAPIInstance), "Failed to copy text to clipboard", exception); + ShowMsgError(GetTranslation("failedToCopy")); + } + } + } - if (showDefaultNotification) - ShowMsg( - $"{GetTranslation("copy")} {GetTranslation("textTitle")}", - GetTranslation("completedSuccessfully")); + private static async Task RetryActionOnSTAThreadAsync(Action action, int retryCount = 6, int retryDelay = 150) + { + for (var i = 0; i < retryCount; i++) + { + try + { + await Win32Helper.StartSTATaskAsync(action).ConfigureAwait(false); + break; + } + catch (Exception e) + { + if (i == retryCount - 1) + { + return e; + } + await Task.Delay(retryDelay); + } } + return null; } public void StartLoadingBar() => _mainVM.ProgressBarVisibility = Visibility.Visible; diff --git a/Flow.Launcher/SettingPages/ViewModels/SettingsPanePluginStoreViewModel.cs b/Flow.Launcher/SettingPages/ViewModels/SettingsPanePluginStoreViewModel.cs index 249e4dd424e..07df0682dbb 100644 --- a/Flow.Launcher/SettingPages/ViewModels/SettingsPanePluginStoreViewModel.cs +++ b/Flow.Launcher/SettingPages/ViewModels/SettingsPanePluginStoreViewModel.cs @@ -79,8 +79,8 @@ public bool ShowExecutable } } - public IList ExternalPlugins => - App.API.GetPluginManifest()?.Select(p => new PluginStoreItemViewModel(p)) + public IList ExternalPlugins => App.API.GetPluginManifest()? + .Select(p => new PluginStoreItemViewModel(p)) .OrderByDescending(p => p.Category == PluginStoreItemViewModel.NewRelease) .ThenByDescending(p => p.Category == PluginStoreItemViewModel.RecentlyUpdated) .ThenByDescending(p => p.Category == PluginStoreItemViewModel.None) diff --git a/Flow.Launcher/SettingPages/ViewModels/SettingsPanePluginsViewModel.cs b/Flow.Launcher/SettingPages/ViewModels/SettingsPanePluginsViewModel.cs index de7cf15c399..abb314355b4 100644 --- a/Flow.Launcher/SettingPages/ViewModels/SettingsPanePluginsViewModel.cs +++ b/Flow.Launcher/SettingPages/ViewModels/SettingsPanePluginsViewModel.cs @@ -86,12 +86,22 @@ public SettingsPanePluginsViewModel(Settings settings) UpdateEnumDropdownLocalizations(); } - public string FilterText { get; set; } = string.Empty; - - public PluginViewModel? SelectedPlugin { get; set; } + private string filterText = string.Empty; + public string FilterText + { + get => filterText; + set + { + if (filterText != value) + { + filterText = value; + OnPropertyChanged(); + } + } + } - private IEnumerable? _pluginViewModels; - private IEnumerable PluginViewModels => _pluginViewModels ??= PluginManager.AllPlugins + private IList? _pluginViewModels; + public IList PluginViewModels => _pluginViewModels ??= PluginManager.AllPlugins .OrderBy(plugin => plugin.Metadata.Disabled) .ThenBy(plugin => plugin.Metadata.Name) .Select(plugin => new PluginViewModel @@ -102,13 +112,12 @@ public SettingsPanePluginsViewModel(Settings settings) .Where(plugin => plugin.PluginSettingsObject != null) .ToList(); - public List FilteredPluginViewModels => PluginViewModels - .Where(v => - string.IsNullOrEmpty(FilterText) || - App.API.FuzzySearch(FilterText, v.PluginPair.Metadata.Name).IsSearchPrecisionScoreMet() || - App.API.FuzzySearch(FilterText, v.PluginPair.Metadata.Description).IsSearchPrecisionScoreMet() - ) - .ToList(); + public bool SatisfiesFilter(PluginViewModel plugin) + { + return string.IsNullOrEmpty(FilterText) || + App.API.FuzzySearch(FilterText, plugin.PluginPair.Metadata.Name).IsSearchPrecisionScoreMet() || + App.API.FuzzySearch(FilterText, plugin.PluginPair.Metadata.Description).IsSearchPrecisionScoreMet(); + } [RelayCommand] private async Task OpenHelperAsync(Button button) diff --git a/Flow.Launcher/SettingPages/Views/SettingsPanePlugins.xaml b/Flow.Launcher/SettingPages/Views/SettingsPanePlugins.xaml index bb742f800e0..52d77f91418 100644 --- a/Flow.Launcher/SettingPages/Views/SettingsPanePlugins.xaml +++ b/Flow.Launcher/SettingPages/Views/SettingsPanePlugins.xaml @@ -15,6 +15,12 @@ FocusManager.FocusedElement="{Binding ElementName=PluginFilterTextbox}" KeyDown="SettingsPanePlugins_OnKeyDown" mc:Ignorable="d"> + + + @@ -115,10 +121,9 @@ Background="{DynamicResource Color01B}" FontSize="14" ItemContainerStyle="{StaticResource PluginList}" - ItemsSource="{Binding FilteredPluginViewModels}" + ItemsSource="{Binding Source={StaticResource PluginCollectionView}}" ScrollViewer.CanContentScroll="False" ScrollViewer.HorizontalScrollBarVisibility="Disabled" - SelectedItem="{Binding SelectedPlugin}" SnapsToDevicePixels="True" Style="{DynamicResource PluginListStyle}" VirtualizingPanel.ScrollUnit="Pixel" diff --git a/Flow.Launcher/SettingPages/Views/SettingsPanePlugins.xaml.cs b/Flow.Launcher/SettingPages/Views/SettingsPanePlugins.xaml.cs index 97574b3ce48..e9490804ab5 100644 --- a/Flow.Launcher/SettingPages/Views/SettingsPanePlugins.xaml.cs +++ b/Flow.Launcher/SettingPages/Views/SettingsPanePlugins.xaml.cs @@ -1,7 +1,10 @@ -using System.Windows.Input; +using System.ComponentModel; +using System.Windows.Data; +using System.Windows.Input; using System.Windows.Navigation; using CommunityToolkit.Mvvm.DependencyInjection; using Flow.Launcher.SettingPages.ViewModels; +using Flow.Launcher.ViewModel; namespace Flow.Launcher.SettingPages.Views; @@ -17,12 +20,38 @@ protected override void OnNavigatedTo(NavigationEventArgs e) DataContext = _viewModel; InitializeComponent(); } + _viewModel.PropertyChanged += ViewModel_PropertyChanged; base.OnNavigatedTo(e); } + private void ViewModel_PropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(SettingsPanePluginsViewModel.FilterText)) + { + ((CollectionViewSource)FindResource("PluginCollectionView")).View.Refresh(); + } + } + + protected override void OnNavigatingFrom(NavigatingCancelEventArgs e) + { + _viewModel.PropertyChanged -= ViewModel_PropertyChanged; + base.OnNavigatingFrom(e); + } + private void SettingsPanePlugins_OnKeyDown(object sender, KeyEventArgs e) { if (e.Key is not Key.F || Keyboard.Modifiers is not ModifierKeys.Control) return; PluginFilterTextbox.Focus(); } + + private void PluginCollectionView_OnFilter(object sender, FilterEventArgs e) + { + if (e.Item is not PluginViewModel plugin) + { + e.Accepted = false; + return; + } + + e.Accepted = _viewModel.SatisfiesFilter(plugin); + } } diff --git a/Flow.Launcher/ViewModel/MainViewModel.cs b/Flow.Launcher/ViewModel/MainViewModel.cs index 00675149b41..2f1ed0f5103 100644 --- a/Flow.Launcher/ViewModel/MainViewModel.cs +++ b/Flow.Launcher/ViewModel/MainViewModel.cs @@ -43,8 +43,7 @@ public partial class MainViewModel : BaseModel, ISavable, IDisposable private readonly UserSelectedRecord _userSelectedRecord; private readonly TopMostRecord _topMostRecord; - private CancellationTokenSource _updateSource; - private CancellationToken _updateToken; + private CancellationTokenSource _updateSource; // Used to cancel old query flows private ChannelWriter _resultsUpdateChannelWriter; private Task _resultsViewUpdateTask; @@ -241,7 +240,7 @@ public void RegisterResultsUpdatedEvent() return; } - var token = e.Token == default ? _updateToken : e.Token; + var token = e.Token == default ? _updateSource.Token : e.Token; // make a clone to avoid possible issue that plugin will also change the list and items when updating view model var resultsCopy = DeepCloneResults(e.Results, token); @@ -255,8 +254,11 @@ public void RegisterResultsUpdatedEvent() } PluginManager.UpdatePluginMetadata(resultsCopy, pair.Metadata, e.Query); - if (!_resultsUpdateChannelWriter.TryWrite(new ResultsForUpdate(resultsCopy, pair.Metadata, e.Query, - token))) + + if (token.IsCancellationRequested) return; + + if (!_resultsUpdateChannelWriter.TryWrite(new ResultsForUpdate(resultsCopy, pair.Metadata, e.Query, + token))) { App.API.LogError(ClassName, "Unable to add item to Result Update Queue"); } @@ -640,7 +642,31 @@ private void DecreaseMaxResult() /// Force query even when Query Text doesn't change public void ChangeQueryText(string queryText, bool isReQuery = false) { - _ = ChangeQueryTextAsync(queryText, isReQuery); + // Must check access so that we will not block the UI thread which causes window visibility issue + if (!Application.Current.Dispatcher.CheckAccess()) + { + Application.Current.Dispatcher.Invoke(() => ChangeQueryText(queryText, isReQuery)); + return; + } + + if (QueryText != queryText) + { + // Change query text first + QueryText = queryText; + // When we are changing query from codes, we should not delay the query + Query(false, isReQuery: false); + + // set to false so the subsequent set true triggers + // PropertyChanged and MoveQueryTextToEnd is called + QueryTextCursorMovedToEnd = false; + } + else if (isReQuery) + { + // When we are re-querying, we should not delay the query + Query(false, isReQuery: true); + } + + QueryTextCursorMovedToEnd = true; } /// @@ -648,10 +674,10 @@ public void ChangeQueryText(string queryText, bool isReQuery = false) /// private async Task ChangeQueryTextAsync(string queryText, bool isReQuery = false) { - // Must check access so that we will not block the UI thread which cause window visibility issue + // Must check access so that we will not block the UI thread which causes window visibility issue if (!Application.Current.Dispatcher.CheckAccess()) { - await Application.Current.Dispatcher.InvokeAsync(() => ChangeQueryText(queryText, isReQuery)); + await Application.Current.Dispatcher.InvokeAsync(() => ChangeQueryTextAsync(queryText, isReQuery)); return; } @@ -1050,7 +1076,18 @@ private bool QueryResultsPreviewed() public void Query(bool searchDelay, bool isReQuery = false) { - _ = QueryAsync(searchDelay, isReQuery); + if (QueryResultsSelected()) + { + _ = QueryResultsAsync(searchDelay, isReQuery); + } + else if (ContextMenuSelected()) + { + QueryContextMenu(); + } + else if (HistorySelected()) + { + QueryHistory(); + } } private async Task QueryAsync(bool searchDelay, bool isReQuery = false) @@ -1160,37 +1197,25 @@ private async Task QueryResultsAsync(bool searchDelay, bool isReQuery = false, b { _updateSource?.Cancel(); - var query = ConstructQuery(QueryText, Settings.CustomShortcuts, Settings.BuiltinShortcuts); - - var plugins = PluginManager.ValidPluginsForQuery(query); + var query = await ConstructQueryAsync(QueryText, Settings.CustomShortcuts, Settings.BuiltinShortcuts); - if (query == null || plugins.Count == 0) // shortcut expanded + if (query == null) // shortcut expanded { - Results.Clear(); + // Hide and clear results again because running query may show and add some results Results.Visibility = Visibility.Collapsed; + Results.Clear(); + + // Reset plugin icon PluginIconPath = null; PluginIconSource = null; SearchIconVisibility = Visibility.Visible; + + // Hide progress bar again because running query may set this to visible + ProgressBarVisibility = Visibility.Hidden; return; } - else if (plugins.Count == 1) - { - PluginIconPath = plugins.Single().Metadata.IcoPath; - PluginIconSource = await App.API.LoadImageAsync(PluginIconPath); - SearchIconVisibility = Visibility.Hidden; - } - else - { - PluginIconPath = null; - PluginIconSource = null; - SearchIconVisibility = Visibility.Visible; - } - - _updateSource?.Dispose(); - var currentUpdateSource = new CancellationTokenSource(); - _updateSource = currentUpdateSource; - _updateToken = _updateSource.Token; + _updateSource = new CancellationTokenSource(); ProgressBarVisibility = Visibility.Hidden; _isQueryRunning = true; @@ -1198,8 +1223,7 @@ private async Task QueryResultsAsync(bool searchDelay, bool isReQuery = false, b // Switch to ThreadPool thread await TaskScheduler.Default; - if (_updateSource.Token.IsCancellationRequested) - return; + if (_updateSource.Token.IsCancellationRequested) return; // Update the query's IsReQuery property to true if this is a re-query query.IsReQuery = isReQuery; @@ -1209,19 +1233,35 @@ private async Task QueryResultsAsync(bool searchDelay, bool isReQuery = false, b _lastQuery = query; - if (string.IsNullOrEmpty(query.ActionKeyword)) + var plugins = PluginManager.ValidPluginsForQuery(query); + + if (plugins.Count == 1) + { + PluginIconPath = plugins.Single().Metadata.IcoPath; + PluginIconSource = await App.API.LoadImageAsync(PluginIconPath); + SearchIconVisibility = Visibility.Hidden; + } + else + { + PluginIconPath = null; + PluginIconSource = null; + SearchIconVisibility = Visibility.Visible; + } + + // Do not wait for performance improvement + /*if (string.IsNullOrEmpty(query.ActionKeyword)) { // Wait 15 millisecond for query change in global query // if query changes, return so that it won't be calculated await Task.Delay(15, _updateSource.Token); if (_updateSource.Token.IsCancellationRequested) return; - } + }*/ _ = Task.Delay(200, _updateSource.Token).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 (!_updateSource.Token.IsCancellationRequested && _isQueryRunning) + if (_isQueryRunning) { ProgressBarVisibility = Visibility.Visible; } @@ -1248,12 +1288,12 @@ private async Task QueryResultsAsync(bool searchDelay, bool isReQuery = false, b // nothing to do here } - if (_updateSource.Token.IsCancellationRequested) - return; + if (_updateSource.Token.IsCancellationRequested) return; // this should happen once after all queries are done so progress bar should continue // until the end of all querying _isQueryRunning = false; + if (!_updateSource.Token.IsCancellationRequested) { // update to hidden if this is still the current query @@ -1269,8 +1309,7 @@ async Task QueryTaskAsync(PluginPair plugin, CancellationToken token) await Task.Delay(searchDelayTime, token); - if (token.IsCancellationRequested) - return; + if (token.IsCancellationRequested) return; } // Since it is wrapped within a ThreadPool Thread, the synchronous context is null @@ -1279,8 +1318,7 @@ async Task QueryTaskAsync(PluginPair plugin, CancellationToken token) var results = await PluginManager.QueryForPluginAsync(plugin, query, token); - if (token.IsCancellationRequested) - return; + if (token.IsCancellationRequested) return; IReadOnlyList resultsCopy; if (results == null) @@ -1301,6 +1339,8 @@ async Task QueryTaskAsync(PluginPair plugin, CancellationToken token) } } + if (token.IsCancellationRequested) return; + if (!_resultsUpdateChannelWriter.TryWrite(new ResultsForUpdate(resultsCopy, plugin.Metadata, query, token, reSelect))) { @@ -1309,16 +1349,16 @@ async Task QueryTaskAsync(PluginPair plugin, CancellationToken token) } } - private Query ConstructQuery(string queryText, IEnumerable customShortcuts, - IEnumerable builtInShortcuts) + private async Task ConstructQueryAsync(string queryText, IEnumerable customShortcuts, + IEnumerable builtInShortcuts) { if (string.IsNullOrWhiteSpace(queryText)) { return null; } - StringBuilder queryBuilder = new(queryText); - StringBuilder queryBuilderTmp = new(queryText); + var queryBuilder = new StringBuilder(queryText); + var queryBuilderTmp = new StringBuilder(queryText); // Sorting order is important here, the reason is for matching longest shortcut by default foreach (var shortcut in customShortcuts.OrderByDescending(x => x.Key.Length)) @@ -1331,36 +1371,56 @@ private Query ConstructQuery(string queryText, IEnumerable queryBuilder.Replace('@' + shortcut.Key, shortcut.Expand()); } - string customExpanded = queryBuilder.ToString(); + // Applying builtin shortcuts + await BuildQueryAsync(builtInShortcuts, queryBuilder, queryBuilderTmp); + + return QueryBuilder.Build(queryBuilder.ToString().Trim(), PluginManager.NonGlobalPlugins); + } + + private async Task BuildQueryAsync(IEnumerable builtInShortcuts, + StringBuilder queryBuilder, StringBuilder queryBuilderTmp) + { + var customExpanded = queryBuilder.ToString(); + + var queryChanged = false; - Application.Current.Dispatcher.Invoke(() => + foreach (var shortcut in builtInShortcuts) { - foreach (var shortcut in builtInShortcuts) + try { - try + if (customExpanded.Contains(shortcut.Key)) { - if (customExpanded.Contains(shortcut.Key)) + string expansion; + if (shortcut is BuiltinShortcutModel syncShortcut) { - var expansion = shortcut.Expand(); - queryBuilder.Replace(shortcut.Key, expansion); - queryBuilderTmp.Replace(shortcut.Key, expansion); + expansion = syncShortcut.Expand(); } - } - catch (Exception e) - { - App.API.LogException(ClassName, - $"Error when expanding shortcut {shortcut.Key}", - e); + else if (shortcut is AsyncBuiltinShortcutModel asyncShortcut) + { + expansion = await asyncShortcut.ExpandAsync(); + } + else + { + continue; + } + queryBuilder.Replace(shortcut.Key, expansion); + queryBuilderTmp.Replace(shortcut.Key, expansion); + queryChanged = true; } } - }); - - // show expanded builtin shortcuts - // use private field to avoid infinite recursion - _queryText = queryBuilderTmp.ToString(); + catch (Exception e) + { + App.API.LogException(ClassName, $"Error when expanding shortcut {shortcut.Key}", e); + } + } - var query = QueryBuilder.Build(queryBuilder.ToString().Trim(), PluginManager.NonGlobalPlugins); - return query; + if (queryChanged) + { + // show expanded builtin shortcuts + // use private field to avoid infinite recursion + _queryText = queryBuilderTmp.ToString(); + OnPropertyChanged(nameof(QueryText)); + } } private void RemoveOldQueryResults(Query query) @@ -1489,6 +1549,9 @@ public bool ShouldIgnoreHotkeys() public void Show() { + // When application is exiting, we should not show the main window + if (App.Exiting) return; + // When application is exiting, the Application.Current will be null Application.Current?.Dispatcher.Invoke(() => {