Merge branch 'dev' into multiple_topmost

This commit is contained in:
Jack Ye 2025-05-10 10:34:06 +08:00 committed by GitHub
commit c64f3dfb30
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 914 additions and 237 deletions

View file

@ -25,6 +25,7 @@ namespace Flow.Launcher.Core.Plugin
private static readonly string ClassName = nameof(PluginManager);
private static IEnumerable<PluginPair> _contextMenuPlugins;
private static IEnumerable<PluginPair> _homePlugins;
public static List<PluginPair> AllPlugins { get; private set; }
public static readonly HashSet<PluginPair> GlobalPlugins = new();
@ -220,6 +221,7 @@ namespace Flow.Launcher.Core.Plugin
{
API.LogException(ClassName, $"Fail to Init plugin: {pair.Metadata.Name}", e);
pair.Metadata.Disabled = true;
pair.Metadata.HomeDisabled = true;
failedPlugins.Enqueue(pair);
}
}));
@ -227,6 +229,8 @@ namespace Flow.Launcher.Core.Plugin
await Task.WhenAll(InitTasks);
_contextMenuPlugins = GetPluginsForInterface<IContextMenu>();
_homePlugins = GetPluginsForInterface<IAsyncHomeQuery>();
foreach (var plugin in AllPlugins)
{
// set distinct on each plugin's action keywords helps only firing global(*) and action keywords once where a plugin
@ -274,6 +278,11 @@ namespace Flow.Launcher.Core.Plugin
};
}
public static ICollection<PluginPair> ValidPluginsForHomeQuery()
{
return _homePlugins.ToList();
}
public static async Task<List<Result>> QueryForPluginAsync(PluginPair pair, Query query, CancellationToken token)
{
var results = new List<Result>();
@ -318,6 +327,36 @@ namespace Flow.Launcher.Core.Plugin
return results;
}
public static async Task<List<Result>> QueryHomeForPluginAsync(PluginPair pair, Query query, CancellationToken token)
{
var results = new List<Result>();
var metadata = pair.Metadata;
try
{
var milliseconds = await API.StopwatchLogDebugAsync(ClassName, $"Cost for {metadata.Name}",
async () => results = await ((IAsyncHomeQuery)pair.Plugin).HomeQueryAsync(token).ConfigureAwait(false));
token.ThrowIfCancellationRequested();
if (results == null)
return null;
UpdatePluginMetadata(results, metadata, query);
token.ThrowIfCancellationRequested();
}
catch (OperationCanceledException)
{
// null will be fine since the results will only be added into queue if the token hasn't been cancelled
return null;
}
catch (Exception e)
{
API.LogException(ClassName, $"Failed to query home for plugin: {metadata.Name}", e);
return null;
}
return results;
}
public static void UpdatePluginMetadata(IReadOnlyList<Result> results, PluginMetadata metadata, Query query)
{
foreach (var r in results)
@ -378,6 +417,11 @@ namespace Flow.Launcher.Core.Plugin
return results;
}
public static bool IsHomePlugin(string id)
{
return _homePlugins.Any(p => p.Metadata.ID == id);
}
public static bool ActionKeywordRegistered(string actionKeyword)
{
// this method is only checking for action keywords (defined as not '*') registration

View file

@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using Flow.Launcher.Plugin;
@ -8,10 +8,23 @@ namespace Flow.Launcher.Core.Plugin
{
public static Query Build(string text, Dictionary<string, PluginPair> nonGlobalPlugins)
{
// home query
if (string.IsNullOrEmpty(text))
{
return new Query()
{
Search = string.Empty,
RawQuery = string.Empty,
SearchTerms = Array.Empty<string>(),
ActionKeyword = string.Empty
};
}
// replace multiple white spaces with one white space
var terms = text.Split(Query.TermSeparator, StringSplitOptions.RemoveEmptyEntries);
if (terms.Length == 0)
{ // nothing was typed
{
// nothing was typed
return null;
}
@ -21,19 +34,21 @@ namespace Flow.Launcher.Core.Plugin
string[] searchTerms;
if (nonGlobalPlugins.TryGetValue(possibleActionKeyword, out var pluginPair) && !pluginPair.Metadata.Disabled)
{ // use non global plugin for query
{
// use non global plugin for query
actionKeyword = possibleActionKeyword;
search = terms.Length > 1 ? rawQuery[(actionKeyword.Length + 1)..].TrimStart() : string.Empty;
searchTerms = terms[1..];
}
else
{ // non action keyword
{
// non action keyword
actionKeyword = string.Empty;
search = rawQuery.TrimStart();
searchTerms = terms;
}
return new Query ()
return new Query()
{
Search = search,
RawQuery = rawQuery,
@ -42,4 +57,4 @@ namespace Flow.Launcher.Core.Plugin
};
}
}
}
}

View file

@ -76,7 +76,7 @@ namespace Flow.Launcher.Core.Resource
{
_api.LogError(ClassName, "Current theme resource not found. Initializing with default theme.");
_oldTheme = Constant.DefaultTheme;
};
}
}
#endregion
@ -126,7 +126,7 @@ namespace Flow.Launcher.Core.Resource
// 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 @@ namespace Flow.Launcher.Core.Resource
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 @@ namespace Flow.Launcher.Core.Resource
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 @@ namespace Flow.Launcher.Core.Resource
// First, find the setters to remove and store them in a list
var settersToRemove = style.Setters
.OfType<Setter>()
.Where(setter =>
.Where(setter =>
setter.Property == Control.FontFamilyProperty ||
setter.Property == Control.FontStyleProperty ||
setter.Property == Control.FontWeightProperty ||
@ -227,18 +227,18 @@ namespace Flow.Launcher.Core.Resource
{
var settersToRemove = style.Setters
.OfType<Setter>()
.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 @@ namespace Flow.Launcher.Core.Resource
// Retrieve theme resource always use the resource with font settings applied.
var resourceDict = GetResourceDictionary(theme);
UpdateResourceDictionary(resourceDict);
_settings.Theme = theme;

View file

@ -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
LOCALE_TRANSIENT_KEYBOARD4

View file

@ -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<string> Expand { get; set; } = () => { return ""; };
public override bool Equals(object obj)
{
return obj is ShortcutBaseModel other &&
@ -22,16 +22,14 @@ namespace Flow.Launcher.Infrastructure.UserSettings
}
}
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 @@ namespace Flow.Launcher.Infrastructure.UserSettings
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<string> 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<string> 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<string> Expand { get; set; } = () => { return string.Empty; };
public BuiltinShortcutModel(string key, string description, Func<string> expand) : base(key, description)
{
Expand = expand ?? (() => { return string.Empty; });
}
}
public class AsyncBuiltinShortcutModel : BaseBuiltinShortcutModel
{
[JsonIgnore]
public Func<Task<string>> ExpandAsync { get; set; } = () => { return Task.FromResult(string.Empty); };
public AsyncBuiltinShortcutModel(string key, string description, Func<Task<string>> expandAsync) : base(key, description)
{
ExpandAsync = expandAsync ?? (() => { return Task.FromResult(string.Empty); });
}
}
#endregion
}

View file

@ -67,6 +67,7 @@ namespace Flow.Launcher.Infrastructure.UserSettings
metadata.Disabled = settings.Disabled;
metadata.Priority = settings.Priority;
metadata.SearchDelayTime = settings.SearchDelayTime;
metadata.HomeDisabled = settings.HomeDisabled;
}
else
{
@ -79,6 +80,7 @@ namespace Flow.Launcher.Infrastructure.UserSettings
DefaultActionKeywords = metadata.ActionKeywords, // metadata provides default values
ActionKeywords = metadata.ActionKeywords, // use default value
Disabled = metadata.Disabled,
HomeDisabled = metadata.HomeDisabled,
Priority = metadata.Priority,
DefaultSearchDelayTime = metadata.SearchDelayTime, // metadata provides default values
SearchDelayTime = metadata.SearchDelayTime, // use default value
@ -128,5 +130,6 @@ namespace Flow.Launcher.Infrastructure.UserSettings
/// Used only to save the state of the plugin in settings
/// </summary>
public bool Disabled { get; set; }
public bool HomeDisabled { get; set; }
}
}

View file

@ -158,6 +158,24 @@ namespace Flow.Launcher.Infrastructure.UserSettings
}
}
}
private bool _showHomePage { get; set; } = true;
public bool ShowHomePage
{
get => _showHomePage;
set
{
if (_showHomePage != value)
{
_showHomePage = value;
OnPropertyChanged();
}
}
}
public bool ShowHistoryResultsForHomePage { get; set; } = false;
public int MaxHistoryResultsToShowForHomePage { get; set; } = 5;
public int CustomExplorerIndex { get; set; } = 0;
[JsonIgnore]
@ -312,9 +330,9 @@ namespace Flow.Launcher.Infrastructure.UserSettings
public ObservableCollection<CustomShortcutModel> CustomShortcuts { get; set; } = new ObservableCollection<CustomShortcutModel>();
[JsonIgnore]
public ObservableCollection<BuiltinShortcutModel> BuiltinShortcuts { get; set; } = new()
public ObservableCollection<BaseBuiltinShortcutModel> 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)
};

View file

@ -5,6 +5,8 @@ using System.Diagnostics;
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 @@ namespace Flow.Launcher.Infrastructure
#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<T> StartSTATaskAsync<T>(Func<T> func)
{
var taskCompletionSource = new TaskCompletionSource<T>();
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";

View file

@ -0,0 +1,23 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Flow.Launcher.Plugin
{
/// <summary>
/// Asynchronous Query Model for Flow Launcher When Query Text is Empty
/// </summary>
public interface IAsyncHomeQuery : IFeatures
{
/// <summary>
/// Asynchronous Querying When Query Text is Empty
/// </summary>
/// <para>
/// If the Querying method requires high IO transmission
/// or performing CPU intense jobs (performing better with cancellation), please use this IAsyncHomeQuery interface
/// </para>
/// <param name="token">Cancel when querying job is obsolete</param>
/// <returns></returns>
Task<List<Result>> HomeQueryAsync(CancellationToken token);
}
}

View file

@ -0,0 +1,28 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Flow.Launcher.Plugin
{
/// <summary>
/// Synchronous Query Model for Flow Launcher When Query Text is Empty
/// <para>
/// If the Querying method requires high IO transmission
/// or performing CPU intense jobs (performing better with cancellation), please try the IAsyncHomeQuery interface
/// </para>
/// </summary>
public interface IHomeQuery : IAsyncHomeQuery
{
/// <summary>
/// Querying When Query Text is Empty
/// <para>
/// This method will be called within a Task.Run,
/// so please avoid synchronously wait for long.
/// </para>
/// </summary>
/// <returns></returns>
List<Result> HomeQuery();
Task<List<Result>> IAsyncHomeQuery.HomeQueryAsync(CancellationToken token) => Task.Run(HomeQuery);
}
}

View file

@ -471,8 +471,11 @@ namespace Flow.Launcher.Plugin
public Task<bool> UpdatePluginManifestAsync(bool usePrimaryUrlOnly = false, CancellationToken token = default);
/// <summary>
/// Get the plugin manifest
/// Get the plugin manifest.
/// </summary>
/// <remarks>
/// If Flow cannot get manifest data, this could be null
/// </remarks>
/// <returns></returns>
public IReadOnlyList<UserPlugin> GetPluginManifest();

View file

@ -50,6 +50,11 @@ namespace Flow.Launcher.Plugin
/// </summary>
public bool Disabled { get; set; }
/// <summary>
/// Whether plugin is disabled in home query.
/// </summary>
public bool HomeDisabled { get; set; }
/// <summary>
/// Plugin execute file path.
/// </summary>

View file

@ -8,7 +8,8 @@ namespace Flow.Launcher.Plugin
public class Query
{
/// <summary>
/// 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.
/// </summary>
public string RawQuery { get; internal init; }
@ -63,10 +64,10 @@ namespace Flow.Launcher.Plugin
/// </remarks>
[JsonIgnore]
public string FirstSearch => SplitSearch(0);
[JsonIgnore]
private string _secondToEndSearch;
/// <summary>
/// strings from second search (including) to last search
/// </summary>

View file

@ -23,6 +23,7 @@ using Flow.Launcher.SettingPages.ViewModels;
using Flow.Launcher.ViewModel;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.VisualStudio.Threading;
namespace Flow.Launcher
{
@ -198,7 +199,11 @@ namespace Flow.Launcher
Current.MainWindow = _mainWindow;
Current.MainWindow.Title = Constant.FlowLauncher;
// Main windows needs initialized before theme change because of blur settings
// Initialize hotkey mapper instantly after main window is created because
// it will steal focus from main window which causes window hide
HotKeyMapper.Initialize();
// Initialize theme for main window
Ioc.Default.GetRequiredService<Theme>().ChangeTheme();
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
@ -239,6 +244,7 @@ namespace Flow.Launcher
}
}
[Conditional("RELEASE")]
private void AutoUpdates()
{
_ = Task.Run(async () =>
@ -296,13 +302,12 @@ namespace Flow.Launcher
[Conditional("RELEASE")]
private static void RegisterAppDomainExceptions()
{
AppDomain.CurrentDomain.UnhandledException += ErrorReporting.UnhandledExceptionHandle;
AppDomain.CurrentDomain.UnhandledException += ErrorReporting.UnhandledException;
}
/// <summary>
/// Let exception throw as normal is better for Debug
/// </summary>
[Conditional("RELEASE")]
private static void RegisterTaskSchedulerUnhandledException()
{
TaskScheduler.UnobservedTaskException += ErrorReporting.TaskSchedulerUnobservedTaskException;

View file

@ -28,18 +28,18 @@ public class DataWebRequestFactory : IWebRequestCreate
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 class DataWebRequestFactory : IWebRequestCreate
public override long ContentLength
{
get { return _data.Length; }
get => _data.Length;
set
{
throw new NotSupportedException();

View file

@ -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()

View file

@ -126,6 +126,11 @@
<system:String x:Key="KoreanImeOpenLinkButton">Open</system:String>
<system:String x:Key="KoreanImeRegistry">Use Previous Korean IME</system:String>
<system:String x:Key="KoreanImeRegistryTooltip">You can change the Previous Korean IME settings directly from here</system:String>
<system:String x:Key="homePage">Home Page</system:String>
<system:String x:Key="homePageToolTip">Show home page results when query text is empty.</system:String>
<system:String x:Key="historyResultsForHomePage">Show History Results in Home Page</system:String>
<system:String x:Key="historyResultsCountForHomePage">Maximum History Results Shown in Home Page</system:String>
<system:String x:Key="homeToggleBoxToolTip">This can only be edited if plugin supports Home feature and Home Page is enabled.</system:String>
<!-- Setting Plugin -->
<system:String x:Key="searchplugin">Search Plugin</system:String>
@ -148,6 +153,7 @@
<system:String x:Key="DisplayModeOnOff">Enabled</system:String>
<system:String x:Key="DisplayModePriority">Priority</system:String>
<system:String x:Key="DisplayModeSearchDelay">Search Delay</system:String>
<system:String x:Key="DisplayModeHomeOnOff">Home Page</system:String>
<system:String x:Key="currentPriority">Current Priority</system:String>
<system:String x:Key="newPriority">New Priority</system:String>
<system:String x:Key="priority">Priority</system:String>
@ -394,12 +400,17 @@
<system:String x:Key="newActionKeywordsSameAsOld">This new Action Keyword is the same as old, please choose a different one</system:String>
<system:String x:Key="success">Success</system:String>
<system:String x:Key="completedSuccessfully">Completed successfully</system:String>
<system:String x:Key="failedToCopy">Failed to copy</system:String>
<system:String x:Key="actionkeyword_tips">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.</system:String>
<!-- Search Delay Settings Dialog -->
<system:String x:Key="searchDelayTimeTitle">Search Delay Time Setting</system:String>
<system:String x:Key="searchDelayTimeTips">Input the search delay time in ms you like to use for the plugin. Input empty if you don't want to specify any, and the plugin will use default search delay time.</system:String>
<!-- Search Delay Settings Dialog -->
<system:String x:Key="homeTitle">Home Page</system:String>
<system:String x:Key="homeTips">Enable the plugin home page state if you like to show the plugin results when query is empty.</system:String>
<!-- Custom Query Hotkey Dialog -->
<system:String x:Key="customeQueryHotkeyTitle">Custom Query Hotkey</system:String>
<system:String x:Key="customeQueryHotkeyTips">Press a custom hotkey to open Flow Launcher and input the specified query automatically.</system:String>

View file

@ -16,7 +16,6 @@ using System.Windows.Threading;
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;
@ -167,9 +166,6 @@ namespace Flow.Launcher
// Force update position
UpdatePosition();
// Refresh frame
await _theme.RefreshFrameAsync();
// Initialize resize mode after refreshing frame
SetupResizeMode();
@ -182,9 +178,6 @@ namespace Flow.Launcher
// 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) =>
@ -281,6 +274,12 @@ namespace Flow.Launcher
case nameof(Settings.SettingWindowFont):
InitializeContextMenu();
break;
case nameof(Settings.ShowHomePage):
if (_viewModel.QueryResultsSelected() && string.IsNullOrEmpty(_viewModel.QueryText))
{
_viewModel.QueryResults();
}
break;
}
};
@ -296,6 +295,12 @@ namespace Flow.Launcher
DependencyPropertyDescriptor
.FromProperty(VisibilityProperty, typeof(StackPanel))
.AddValueChanged(History, (s, e) => UpdateClockPanelVisibility());
// Initialize query state
if (_settings.ShowHomePage && string.IsNullOrEmpty(_viewModel.QueryText))
{
_viewModel.QueryResults();
}
}
private async void OnClosing(object sender, CancelEventArgs e)

View file

@ -40,7 +40,7 @@ namespace Flow.Launcher
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<Theme>();
@ -69,8 +69,7 @@ namespace Flow.Launcher
_mainVM.ChangeQueryText(query, requery);
}
#pragma warning disable VSTHRD100 // Avoid async void methods
[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD100:Avoid async void methods", Justification = "<Pending>")]
public async void RestartApp()
{
_mainVM.Hide();
@ -89,8 +88,6 @@ namespace Flow.Launcher
UpdateManager.RestartApp(Constant.ApplicationFileName);
}
#pragma warning restore VSTHRD100 // Avoid async void methods
public void ShowMainWindow() => _mainVM.Show();
public void HideMainWindow() => _mainVM.Hide();
@ -145,37 +142,92 @@ namespace Flow.Launcher
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 = "<Pending>")]
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 (showDefaultNotification)
ShowMsg(
$"{GetTranslation("copy")} {GetTranslation("textTitle")}",
GetTranslation("completedSuccessfully"));
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"));
}
}
}
private static async Task<Exception> 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;
public void StopLoadingBar() => _mainVM.ProgressBarVisibility = Visibility.Collapsed;

View file

@ -100,10 +100,19 @@
ToolTipService.InitialShowDelay="0"
ToolTipService.ShowOnDisabled="True"
Value="{Binding PluginSearchDelayTime, Mode=TwoWay}" />
</StackPanel>
<!-- Put OnOffControl after PriorityControl & SearchDelayControl so that it can display correctly -->
<ui:ToggleSwitch
x:Name="HomeOnOffControl"
Margin="0 0 8 0"
IsEnabled="{Binding HomeEnabled}"
IsOn="{Binding PluginHomeState}"
OffContent="{DynamicResource disable}"
OnContent="{DynamicResource enable}"
ToolTip="{DynamicResource homeToggleBoxToolTip}"
Visibility="{Binding DataContext.IsHomeOnOffSelected, RelativeSource={RelativeSource AncestorType=ListBox}, Converter={StaticResource BooleanToVisibilityConverter}}" />
<ui:ToggleSwitch
x:Name="OnOffControl"
Margin="0 0 8 0"

View file

@ -154,11 +154,22 @@ public partial class SettingsPaneGeneralViewModel : BaseModel
{
Settings.SearchDelayTime = value;
OnPropertyChanged();
OnPropertyChanged(nameof(SearchDelayTimeDisplay));
}
}
}
public string SearchDelayTimeDisplay => $"{SearchDelayTimeValue}ms";
public int MaxHistoryResultsToShowValue
{
get => Settings.MaxHistoryResultsToShowForHomePage;
set
{
if (Settings.MaxHistoryResultsToShowForHomePage != value)
{
Settings.MaxHistoryResultsToShowForHomePage = value;
OnPropertyChanged();
}
}
}
private void UpdateEnumDropdownLocalizations()
{

View file

@ -79,8 +79,8 @@ public partial class SettingsPanePluginStoreViewModel : BaseModel
}
}
public IList<PluginStoreItemViewModel> ExternalPlugins =>
App.API.GetPluginManifest()?.Select(p => new PluginStoreItemViewModel(p))
public IList<PluginStoreItemViewModel> 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)

View file

@ -80,18 +80,42 @@ public partial class SettingsPanePluginsViewModel : BaseModel
}
}
private bool _isHomeOnOffSelected;
public bool IsHomeOnOffSelected
{
get => _isHomeOnOffSelected;
set
{
if (_isHomeOnOffSelected != value)
{
_isHomeOnOffSelected = value;
OnPropertyChanged();
}
}
}
public SettingsPanePluginsViewModel(Settings settings)
{
_settings = settings;
UpdateEnumDropdownLocalizations();
}
public string FilterText { get; set; } = string.Empty;
private string filterText = string.Empty;
public string FilterText
{
get => filterText;
set
{
if (filterText != value)
{
filterText = value;
OnPropertyChanged();
}
}
}
public PluginViewModel? SelectedPlugin { get; set; }
private IEnumerable<PluginViewModel>? _pluginViewModels;
private IEnumerable<PluginViewModel> PluginViewModels => _pluginViewModels ??= PluginManager.AllPlugins
private IList<PluginViewModel>? _pluginViewModels;
public IList<PluginViewModel> PluginViewModels => _pluginViewModels ??= PluginManager.AllPlugins
.OrderBy(plugin => plugin.Metadata.Disabled)
.ThenBy(plugin => plugin.Metadata.Name)
.Select(plugin => new PluginViewModel
@ -102,13 +126,12 @@ public partial class SettingsPanePluginsViewModel : BaseModel
.Where(plugin => plugin.PluginSettingsObject != null)
.ToList();
public List<PluginViewModel> 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)
@ -143,6 +166,18 @@ public partial class SettingsPanePluginsViewModel : BaseModel
{
Text = (string)Application.Current.Resources["searchDelayTimeTips"],
TextWrapping = TextWrapping.Wrap
},
new TextBlock
{
Text = (string)Application.Current.Resources["homeTitle"],
FontSize = 18,
Margin = new Thickness(0, 24, 0, 10),
TextWrapping = TextWrapping.Wrap
},
new TextBlock
{
Text = (string)Application.Current.Resources["homeTips"],
TextWrapping = TextWrapping.Wrap
}
}
},
@ -167,16 +202,25 @@ public partial class SettingsPanePluginsViewModel : BaseModel
IsOnOffSelected = false;
IsPrioritySelected = true;
IsSearchDelaySelected = false;
IsHomeOnOffSelected = false;
break;
case DisplayMode.SearchDelay:
IsOnOffSelected = false;
IsPrioritySelected = false;
IsSearchDelaySelected = true;
IsHomeOnOffSelected = false;
break;
case DisplayMode.HomeOnOff:
IsOnOffSelected = false;
IsPrioritySelected = false;
IsSearchDelaySelected = false;
IsHomeOnOffSelected = true;
break;
default:
IsOnOffSelected = true;
IsPrioritySelected = false;
IsSearchDelaySelected = false;
IsHomeOnOffSelected = false;
break;
}
}
@ -186,5 +230,6 @@ public enum DisplayMode
{
OnOff,
Priority,
SearchDelay
SearchDelay,
HomeOnOff
}

View file

@ -217,17 +217,46 @@
Title="{DynamicResource searchDelayTime}"
Sub="{DynamicResource searchDelayTimeToolTip}"
Type="InsideFit">
<StackPanel Orientation="Horizontal">
<ui:NumberBox
Width="120"
Margin="0 0 0 0"
Maximum="1000"
Minimum="0"
SmallChange="10"
SpinButtonPlacementMode="Compact"
ValidationMode="InvalidInputOverwritten"
Value="{Binding SearchDelayTimeValue}" />
</StackPanel>
<ui:NumberBox
Width="120"
Margin="0 0 0 0"
Maximum="1000"
Minimum="0"
SmallChange="10"
SpinButtonPlacementMode="Compact"
ValidationMode="InvalidInputOverwritten"
Value="{Binding SearchDelayTimeValue}" />
</cc:Card>
</cc:ExCard>
<cc:Card
Title="{DynamicResource homePage}"
Margin="0 14 0 0"
Icon="&#xE80F;"
Sub="{DynamicResource homePageToolTip}">
<ui:ToggleSwitch
IsOn="{Binding Settings.ShowHomePage}"
OffContent="{DynamicResource disable}"
OnContent="{DynamicResource enable}" />
</cc:Card>
<cc:ExCard Title="{DynamicResource historyResultsForHomePage}" Icon="&#xE81C;">
<cc:ExCard.SideContent>
<ui:ToggleSwitch
IsOn="{Binding Settings.ShowHistoryResultsForHomePage}"
OffContent="{DynamicResource disable}"
OnContent="{DynamicResource enable}" />
</cc:ExCard.SideContent>
<cc:Card Title="{DynamicResource historyResultsCountForHomePage}" Type="InsideFit">
<ui:NumberBox
Width="120"
Margin="0 0 0 0"
Maximum="100"
Minimum="0"
SmallChange="5"
SpinButtonPlacementMode="Compact"
ValidationMode="InvalidInputOverwritten"
Value="{Binding MaxHistoryResultsToShowValue}" />
</cc:Card>
</cc:ExCard>

View file

@ -15,6 +15,12 @@
FocusManager.FocusedElement="{Binding ElementName=PluginFilterTextbox}"
KeyDown="SettingsPanePlugins_OnKeyDown"
mc:Ignorable="d">
<ui:Page.Resources>
<CollectionViewSource
x:Key="PluginCollectionView"
Filter="PluginCollectionView_OnFilter"
Source="{Binding PluginViewModels}" />
</ui:Page.Resources>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="73" />
@ -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"

View file

@ -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 @@ public partial class SettingsPanePlugins
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);
}
}

View file

@ -33,7 +33,9 @@ namespace Flow.Launcher.ViewModel
private bool _isQueryRunning;
private Query _lastQuery;
private bool _lastIsHomeQuery;
private string _queryTextBeforeLeaveResults;
private string _ignoredQueryText; // Used to ignore query text change when switching between context menu and query results
private readonly FlowLauncherJsonStorage<History> _historyItemsStorage;
private readonly FlowLauncherJsonStorage<UserSelectedRecord> _userSelectedRecordStorage;
@ -42,14 +44,20 @@ namespace Flow.Launcher.ViewModel
private int lastHistoryIndex = 1;
private readonly UserSelectedRecord _userSelectedRecord;
private CancellationTokenSource _updateSource;
private CancellationToken _updateToken;
private CancellationTokenSource _updateSource; // Used to cancel old query flows
private CancellationToken _updateToken; // Used to avoid ObjectDisposedException of _updateSource.Token
private ChannelWriter<ResultsForUpdate> _resultsUpdateChannelWriter;
private Task _resultsViewUpdateTask;
private readonly IReadOnlyList<Result> _emptyResult = new List<Result>();
private readonly PluginMetadata _historyMetadata = new()
{
ID = "298303A65D128A845D28A7B83B3968C2", // ID is for identifying the update plugin in UpdateActionAsync
Priority = 0 // Priority is for calculating scores in UpdateResultView
};
#endregion
#region Constructor
@ -59,6 +67,7 @@ namespace Flow.Launcher.ViewModel
_queryTextBeforeLeaveResults = "";
_queryText = "";
_lastQuery = new Query();
_ignoredQueryText = null; // null as invalid value
Settings = Ioc.Default.GetRequiredService<Settings>();
Settings.PropertyChanged += (_, args) =>
@ -253,8 +262,11 @@ namespace Flow.Launcher.ViewModel
}
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");
}
@ -638,7 +650,31 @@ namespace Flow.Launcher.ViewModel
/// <param name="isReQuery">Force query even when Query Text doesn't change</param>
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;
}
/// <summary>
@ -646,10 +682,10 @@ namespace Flow.Launcher.ViewModel
/// </summary>
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;
}
@ -702,6 +738,9 @@ namespace Flow.Launcher.ViewModel
if (isReturningFromContextMenu)
{
_queryText = _queryTextBeforeLeaveResults;
// When executing OnPropertyChanged, QueryTextBox_TextChanged1 and Query will be called
// So we need to ignore it so that we will not call Query again
_ignoredQueryText = _queryText;
OnPropertyChanged(nameof(QueryText));
QueryTextCursorMovedToEnd = true;
}
@ -751,8 +790,6 @@ namespace Flow.Launcher.ViewModel
}
}
}
_selectedResults.Visibility = Visibility.Visible;
}
}
@ -1046,9 +1083,39 @@ namespace Flow.Launcher.ViewModel
#region Query
public void QueryResults()
{
_ = QueryResultsAsync(false);
}
public void Query(bool searchDelay, bool isReQuery = false)
{
_ = QueryAsync(searchDelay, isReQuery);
if (_ignoredQueryText != null)
{
if (_ignoredQueryText == QueryText)
{
_ignoredQueryText = null;
return;
}
else
{
// If _ignoredQueryText does not match current QueryText, we should still execute Query
_ignoredQueryText = null;
}
}
if (QueryResultsSelected())
{
_ = QueryResultsAsync(searchDelay, isReQuery);
}
else if (ContextMenuSelected())
{
QueryContextMenu();
}
else if (HistorySelected())
{
QueryHistory();
}
}
private async Task QueryAsync(bool searchDelay, bool isReQuery = false)
@ -1077,9 +1144,20 @@ namespace Flow.Launcher.ViewModel
if (selected != null) // SelectedItem returns null if selection is empty.
{
var results = PluginManager.GetContextMenusForPlugin(selected);
results.Add(ContextMenuTopMost(selected));
results.Add(ContextMenuPluginInfo(selected.PluginID));
List<Result> results;
if (selected.PluginID == null) // SelectedItem from history in home page.
{
results = new()
{
ContextMenuTopMost(selected)
};
}
else
{
results = PluginManager.GetContextMenusForPlugin(selected);
results.Add(ContextMenuTopMost(selected));
results.Add(ContextMenuPluginInfo(selected.PluginID));
}
if (!string.IsNullOrEmpty(query))
{
@ -1113,31 +1191,7 @@ namespace Flow.Launcher.ViewModel
var query = QueryText.ToLower().Trim();
History.Clear();
var results = new List<Result>();
foreach (var h in _history.Items)
{
var title = App.API.GetTranslation("executeQuery");
var time = App.API.GetTranslation("lastExecuteTime");
var result = new Result
{
Title = string.Format(title, h.Query),
SubTitle = string.Format(time, h.ExecutedDateTime),
IcoPath = "Images\\history.png",
Preview = new Result.PreviewInfo
{
PreviewImagePath = Constant.HistoryIcon,
Description = string.Format(time, h.ExecutedDateTime)
},
OriginQuery = new Query { RawQuery = h.Query },
Action = _ =>
{
SelectedResults = Results;
App.API.ChangeQuery(h.Query);
return false;
}
};
results.Add(result);
}
var results = GetHistoryItems(_history.Items);
if (!string.IsNullOrEmpty(query))
{
@ -1154,41 +1208,68 @@ namespace Flow.Launcher.ViewModel
}
}
private static List<Result> GetHistoryItems(IEnumerable<HistoryItem> historyItems)
{
var results = new List<Result>();
foreach (var h in historyItems)
{
var title = App.API.GetTranslation("executeQuery");
var time = App.API.GetTranslation("lastExecuteTime");
var result = new Result
{
Title = string.Format(title, h.Query),
SubTitle = string.Format(time, h.ExecutedDateTime),
IcoPath = Constant.HistoryIcon,
OriginQuery = new Query { RawQuery = h.Query },
Action = _ =>
{
App.API.BackToQueryResults();
App.API.ChangeQuery(h.Query);
return false;
},
Glyph = new GlyphInfo(FontFamily: "/Resources/#Segoe Fluent Icons", Glyph: "\uE81C")
};
results.Add(result);
}
return results;
}
private async Task QueryResultsAsync(bool searchDelay, bool isReQuery = false, bool reSelect = true)
{
_updateSource?.Cancel();
var query = ConstructQuery(QueryText, Settings.CustomShortcuts, Settings.BuiltinShortcuts);
App.API.LogDebug(ClassName, $"Start query with text: <{QueryText}>");
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();
App.API.LogDebug(ClassName, $"Clear query results");
// 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;
}
App.API.LogDebug(ClassName, $"Start query with ActionKeyword <{query.ActionKeyword}> and RawQuery <{query.RawQuery}>");
var isHomeQuery = query.RawQuery == string.Empty;
_updateSource?.Dispose();
var currentUpdateSource = new CancellationTokenSource();
_updateSource = currentUpdateSource;
_updateToken = _updateSource.Token;
var currentCancellationToken = _updateSource.Token;
_updateToken = currentCancellationToken;
ProgressBarVisibility = Visibility.Hidden;
_isQueryRunning = true;
@ -1196,45 +1277,96 @@ namespace Flow.Launcher.ViewModel
// Switch to ThreadPool thread
await TaskScheduler.Default;
if (_updateSource.Token.IsCancellationRequested)
return;
if (currentCancellationToken.IsCancellationRequested) return;
// Update the query's IsReQuery property to true if this is a re-query
query.IsReQuery = isReQuery;
// handle the exclusiveness of plugin using action keyword
RemoveOldQueryResults(query);
RemoveOldQueryResults(query, isHomeQuery);
_lastQuery = query;
_lastIsHomeQuery = isHomeQuery;
if (string.IsNullOrEmpty(query.ActionKeyword))
ICollection<PluginPair> plugins = Array.Empty<PluginPair>();
if (isHomeQuery)
{
if (Settings.ShowHomePage)
{
plugins = PluginManager.ValidPluginsForHomeQuery();
}
PluginIconPath = null;
PluginIconSource = null;
SearchIconVisibility = Visibility.Visible;
}
else
{
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;
}
}
var validPluginNames = plugins.Select(x => $"<{x.Metadata.Name}>");
App.API.LogDebug(ClassName, $"Valid <{plugins.Count}> plugins: {string.Join(" ", validPluginNames)}");
// 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;
}
await Task.Delay(15, currentCancellationToken);
if (currentCancellationToken.IsCancellationRequested) return;
}*/
_ = Task.Delay(200, _updateSource.Token).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 (!_updateSource.Token.IsCancellationRequested && _isQueryRunning)
if (_isQueryRunning)
{
ProgressBarVisibility = Visibility.Visible;
}
},
_updateSource.Token,
currentCancellationToken,
TaskContinuationOptions.NotOnCanceled,
TaskScheduler.Default);
// plugins are ICollection, meaning LINQ will get the Count and preallocate Array
var tasks = plugins.Select(plugin => plugin.Metadata.Disabled switch
Task[] tasks;
if (isHomeQuery)
{
false => QueryTaskAsync(plugin, _updateSource.Token),
true => Task.CompletedTask
}).ToArray();
tasks = plugins.Select(plugin => plugin.Metadata.HomeDisabled switch
{
false => QueryTaskAsync(plugin, currentCancellationToken),
true => Task.CompletedTask
}).ToArray();
// Query history results for home page firstly so it will be put on top of the results
if (Settings.ShowHistoryResultsForHomePage)
{
QueryHistoryTask(currentCancellationToken);
}
}
else
{
tasks = plugins.Select(plugin => plugin.Metadata.Disabled switch
{
false => QueryTaskAsync(plugin, currentCancellationToken),
true => Task.CompletedTask
}).ToArray();
}
try
{
@ -1246,13 +1378,13 @@ namespace Flow.Launcher.ViewModel
// nothing to do here
}
if (_updateSource.Token.IsCancellationRequested)
return;
if (currentCancellationToken.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)
if (!currentCancellationToken.IsCancellationRequested)
{
// update to hidden if this is still the current query
ProgressBarVisibility = Visibility.Hidden;
@ -1261,24 +1393,26 @@ namespace Flow.Launcher.ViewModel
// Local function
async Task QueryTaskAsync(PluginPair plugin, CancellationToken token)
{
if (searchDelay)
App.API.LogDebug(ClassName, $"Wait for querying plugin <{plugin.Metadata.Name}>");
if (searchDelay && !isHomeQuery) // Do not delay for home query
{
var searchDelayTime = plugin.Metadata.SearchDelayTime ?? Settings.SearchDelayTime;
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
// Task.Yield will force it to run in ThreadPool
await Task.Yield();
var results = await PluginManager.QueryForPluginAsync(plugin, query, token);
var results = isHomeQuery ?
await PluginManager.QueryHomeForPluginAsync(plugin, query, token) :
await PluginManager.QueryForPluginAsync(plugin, query, token);
if (token.IsCancellationRequested)
return;
if (token.IsCancellationRequested) return;
IReadOnlyList<Result> resultsCopy;
if (results == null)
@ -1299,24 +1433,46 @@ namespace Flow.Launcher.ViewModel
}
}
if (token.IsCancellationRequested) return;
App.API.LogDebug(ClassName, $"Update results for plugin <{plugin.Metadata.Name}>");
if (!_resultsUpdateChannelWriter.TryWrite(new ResultsForUpdate(resultsCopy, plugin.Metadata, query,
token, reSelect)))
{
App.API.LogError(ClassName, "Unable to add item to Result Update Queue");
}
}
void QueryHistoryTask(CancellationToken token)
{
// Select last history results and revert its order to make sure last history results are on top
var historyItems = _history.Items.TakeLast(Settings.MaxHistoryResultsToShowForHomePage).Reverse();
var results = GetHistoryItems(historyItems);
if (token.IsCancellationRequested) return;
App.API.LogDebug(ClassName, $"Update results for history");
if (!_resultsUpdateChannelWriter.TryWrite(new ResultsForUpdate(results, _historyMetadata, query,
token)))
{
App.API.LogError(ClassName, "Unable to add item to Result Update Queue");
}
}
}
private Query ConstructQuery(string queryText, IEnumerable<CustomShortcutModel> customShortcuts,
IEnumerable<BuiltinShortcutModel> builtInShortcuts)
private async Task<Query> ConstructQueryAsync(string queryText, IEnumerable<CustomShortcutModel> customShortcuts,
IEnumerable<BaseBuiltinShortcutModel> builtInShortcuts)
{
if (string.IsNullOrWhiteSpace(queryText))
{
return null;
return QueryBuilder.Build(string.Empty, PluginManager.NonGlobalPlugins);
}
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))
@ -1329,42 +1485,78 @@ namespace Flow.Launcher.ViewModel
queryBuilder.Replace('@' + shortcut.Key, shortcut.Expand());
}
string customExpanded = queryBuilder.ToString();
// Applying builtin shortcuts
await BuildQueryAsync(builtInShortcuts, queryBuilder, queryBuilderTmp);
Application.Current.Dispatcher.Invoke(() =>
{
foreach (var shortcut in builtInShortcuts)
{
try
{
if (customExpanded.Contains(shortcut.Key))
{
var expansion = shortcut.Expand();
queryBuilder.Replace(shortcut.Key, expansion);
queryBuilderTmp.Replace(shortcut.Key, expansion);
}
}
catch (Exception e)
{
App.API.LogException(ClassName,
$"Error when expanding shortcut {shortcut.Key}",
e);
}
}
});
// show expanded builtin shortcuts
// use private field to avoid infinite recursion
_queryText = queryBuilderTmp.ToString();
var query = QueryBuilder.Build(queryBuilder.ToString().Trim(), PluginManager.NonGlobalPlugins);
return query;
return QueryBuilder.Build(queryBuilder.ToString().Trim(), PluginManager.NonGlobalPlugins);
}
private void RemoveOldQueryResults(Query query)
private async Task BuildQueryAsync(IEnumerable<BaseBuiltinShortcutModel> builtInShortcuts,
StringBuilder queryBuilder, StringBuilder queryBuilderTmp)
{
if (_lastQuery?.ActionKeyword != query?.ActionKeyword)
var customExpanded = queryBuilder.ToString();
var queryChanged = false;
foreach (var shortcut in builtInShortcuts)
{
try
{
if (customExpanded.Contains(shortcut.Key))
{
string expansion;
if (shortcut is BuiltinShortcutModel syncShortcut)
{
expansion = syncShortcut.Expand();
}
else if (shortcut is AsyncBuiltinShortcutModel asyncShortcut)
{
expansion = await asyncShortcut.ExpandAsync();
}
else
{
continue;
}
queryBuilder.Replace(shortcut.Key, expansion);
queryBuilderTmp.Replace(shortcut.Key, expansion);
queryChanged = true;
}
}
catch (Exception e)
{
App.API.LogException(ClassName, $"Error when expanding shortcut {shortcut.Key}", e);
}
}
// Show expanded builtin shortcuts
if (queryChanged)
{
// Use private field to avoid infinite recursion
_queryText = queryBuilderTmp.ToString();
// When executing OnPropertyChanged, QueryTextBox_TextChanged1 and Query will be called
// So we need to ignore it so that we will not call Query again
_ignoredQueryText = _queryText;
OnPropertyChanged(nameof(QueryText));
}
}
private void RemoveOldQueryResults(Query query, bool isHomeQuery)
{
// If last and current query are home query, we don't need to clear the results
if (_lastIsHomeQuery && isHomeQuery)
{
return;
}
// If last or current query is home query, we need to clear the results
else if (_lastIsHomeQuery || isHomeQuery)
{
App.API.LogDebug(ClassName, $"Remove old results");
Results.Clear();
}
// If last and current query are not home query, we need to check action keyword
else if (_lastQuery?.ActionKeyword != query?.ActionKeyword)
{
App.API.LogDebug(ClassName, $"Remove old results");
Results.Clear();
}
}
@ -1385,7 +1577,8 @@ namespace Flow.Launcher.ViewModel
App.API.ShowMsg(App.API.GetTranslation("success"));
App.API.ReQuery();
return false;
}
},
Glyph = new GlyphInfo(FontFamily: "/Resources/#Segoe Fluent Icons", Glyph: "\uE74B")
};
}
else
@ -1394,7 +1587,6 @@ namespace Flow.Launcher.ViewModel
{
Title = App.API.GetTranslation("setAsTopMostInThisQuery"),
IcoPath = "Images\\up.png",
Glyph = new GlyphInfo(FontFamily: "/Resources/#Segoe Fluent Icons", Glyph: "\xeac2"),
PluginDirectory = Constant.ProgramDirectory,
Action = _ =>
{
@ -1402,7 +1594,8 @@ namespace Flow.Launcher.ViewModel
App.API.ShowMsg(App.API.GetTranslation("success"));
App.API.ReQuery();
return false;
}
},
Glyph = new GlyphInfo(FontFamily: "/Resources/#Segoe Fluent Icons", Glyph: "\uE74A")
};
}
@ -1487,6 +1680,9 @@ namespace Flow.Launcher.ViewModel
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(() =>
{
@ -1535,10 +1731,7 @@ namespace Flow.Launcher.ViewModel
await CloseExternalPreviewAsync();
}
if (!QueryResultsSelected())
{
SelectedResults = Results;
}
BackToQueryResults();
switch (Settings.LastQueryMode)
{
@ -1551,7 +1744,7 @@ namespace Flow.Launcher.ViewModel
break;
case LastQueryMode.ActionKeywordPreserved:
case LastQueryMode.ActionKeywordSelected:
var newQuery = _lastQuery.ActionKeyword;
var newQuery = _lastQuery?.ActionKeyword;
if (!string.IsNullOrEmpty(newQuery))
newQuery += " ";

View file

@ -75,6 +75,16 @@ namespace Flow.Launcher.ViewModel
}
}
public bool PluginHomeState
{
get => !PluginPair.Metadata.HomeDisabled;
set
{
PluginPair.Metadata.HomeDisabled = !value;
PluginSettingsObject.HomeDisabled = !value;
}
}
public bool IsExpanded
{
get => _isExpanded;
@ -154,6 +164,7 @@ namespace Flow.Launcher.ViewModel
public Infrastructure.UserSettings.Plugin PluginSettingsObject{ get; init; }
public bool SearchDelayEnabled => Settings.SearchQueryResultsWithDelay;
public string DefaultSearchDelay => Settings.SearchDelayTime.ToString();
public bool HomeEnabled => Settings.ShowHomePage && PluginManager.IsHomePlugin(PluginPair.Metadata.ID);
public void OnActionKeywordsTextChanged()
{

View file

@ -3,21 +3,33 @@ using System.Linq;
namespace Flow.Launcher.Plugin.PluginIndicator
{
public class Main : IPlugin, IPluginI18n
public class Main : IPlugin, IPluginI18n, IHomeQuery
{
internal PluginInitContext Context { get; private set; }
public List<Result> Query(Query query)
{
return QueryResults(query);
}
public List<Result> HomeQuery()
{
return QueryResults();
}
private List<Result> QueryResults(Query query = null)
{
var nonGlobalPlugins = GetNonGlobalPlugins();
var querySearch = query?.Search ?? string.Empty;
var results =
from keyword in nonGlobalPlugins.Keys
let plugin = nonGlobalPlugins[keyword].Metadata
let keywordSearchResult = Context.API.FuzzySearch(query.Search, keyword)
let searchResult = keywordSearchResult.IsSearchPrecisionScoreMet() ? keywordSearchResult : Context.API.FuzzySearch(query.Search, plugin.Name)
let keywordSearchResult = Context.API.FuzzySearch(querySearch, keyword)
let searchResult = keywordSearchResult.IsSearchPrecisionScoreMet() ? keywordSearchResult : Context.API.FuzzySearch(querySearch, plugin.Name)
let score = searchResult.Score
where (searchResult.IsSearchPrecisionScoreMet()
|| string.IsNullOrEmpty(query.Search)) // To list all available action keywords
|| string.IsNullOrEmpty(querySearch)) // To list all available action keywords
&& !plugin.Disabled
select new Result
{