Flow.Launcher/Flow.Launcher/ViewModel/MainViewModel.cs

1962 lines
72 KiB
C#
Raw Normal View History

using System;
using System.Collections.Generic;
2025-03-17 01:44:02 +00:00
using System.ComponentModel;
using System.Globalization;
2016-06-22 23:22:41 +00:00
using System.Linq;
2025-03-17 01:44:02 +00:00
using System.Text;
using System.Threading;
2025-03-17 01:44:02 +00:00
using System.Threading.Channels;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Input;
2025-03-17 01:44:02 +00:00
using System.Windows.Media;
using System.Windows.Threading;
2025-03-17 01:44:02 +00:00
using CommunityToolkit.Mvvm.DependencyInjection;
using CommunityToolkit.Mvvm.Input;
2020-04-21 09:12:17 +00:00
using Flow.Launcher.Core.Plugin;
using Flow.Launcher.Infrastructure;
using Flow.Launcher.Infrastructure.Hotkey;
using Flow.Launcher.Infrastructure.Storage;
using Flow.Launcher.Infrastructure.UserSettings;
using Flow.Launcher.Plugin;
2020-04-23 10:10:20 +00:00
using Flow.Launcher.Plugin.SharedCommands;
2020-04-21 09:12:17 +00:00
using Flow.Launcher.Storage;
2021-06-11 04:40:07 +00:00
using Microsoft.VisualStudio.Threading;
2021-10-14 04:34:46 +00:00
2020-04-21 09:12:17 +00:00
namespace Flow.Launcher.ViewModel
{
public partial class MainViewModel : BaseModel, ISavable, IDisposable
{
#region Private Fields
2025-04-13 09:50:44 +00:00
private static readonly string ClassName = nameof(MainViewModel);
2019-12-13 22:17:05 +00:00
private bool _isQueryRunning;
private Query _lastQuery;
private bool _previousIsHomeQuery;
private string _queryTextBeforeLeaveResults;
2025-05-06 05:58:28 +00:00
private string _ignoredQueryText; // Used to ignore query text change when switching between context menu and query results
2020-04-21 12:16:10 +00:00
private readonly FlowLauncherJsonStorage<History> _historyItemsStorage;
private readonly FlowLauncherJsonStorage<UserSelectedRecord> _userSelectedRecordStorage;
2025-05-01 03:06:52 +00:00
private readonly FlowLauncherJsonStorageTopMostRecord _topMostRecord;
private readonly History _history;
2024-05-19 15:16:56 +00:00
private int lastHistoryIndex = 1;
private readonly UserSelectedRecord _userSelectedRecord;
private CancellationTokenSource _updateSource; // Used to cancel old query flows
private CancellationToken _updateToken; // Used to avoid ObjectDisposedException of _updateSource.Token
2021-02-15 18:12:28 +00:00
private ChannelWriter<ResultsForUpdate> _resultsUpdateChannelWriter;
private Task _resultsViewUpdateTask;
2025-03-26 02:30:47 +00:00
private readonly IReadOnlyList<Result> _emptyResult = new List<Result>();
2025-05-03 14:09:25 +00:00
private readonly PluginMetadata _historyMetadata = new()
{
2025-05-05 00:44:08 +00:00
ID = "298303A65D128A845D28A7B83B3968C2", // ID is for identifying the update plugin in UpdateActionAsync
2025-05-05 00:35:20 +00:00
Priority = 0 // Priority is for calculating scores in UpdateResultView
2025-05-03 14:09:25 +00:00
};
#endregion
#region Constructor
public MainViewModel()
{
_queryTextBeforeLeaveResults = "";
_queryText = "";
_lastQuery = new Query();
2025-05-06 05:58:28 +00:00
_ignoredQueryText = null; // null as invalid value
Settings = Ioc.Default.GetRequiredService<Settings>();
2022-10-12 04:01:50 +00:00
Settings.PropertyChanged += (_, args) =>
{
switch (args.PropertyName)
{
2022-11-24 07:29:05 +00:00
case nameof(Settings.WindowSize):
OnPropertyChanged(nameof(MainWindowWidth));
break;
2024-05-14 07:54:15 +00:00
case nameof(Settings.WindowHeightSize):
OnPropertyChanged(nameof(MainWindowHeight));
break;
case nameof(Settings.QueryBoxFontSize):
OnPropertyChanged(nameof(QueryBoxFontSize));
break;
case nameof(Settings.ItemHeightSize):
OnPropertyChanged(nameof(ItemHeightSize));
break;
case nameof(Settings.ResultItemFontSize):
OnPropertyChanged(nameof(ResultItemFontSize));
break;
case nameof(Settings.ResultSubItemFontSize):
OnPropertyChanged(nameof(ResultSubItemFontSize));
break;
2022-12-20 14:35:07 +00:00
case nameof(Settings.AlwaysStartEn):
OnPropertyChanged(nameof(StartWithEnglishMode));
break;
case nameof(Settings.OpenResultModifiers):
OnPropertyChanged(nameof(OpenResultCommandModifiers));
break;
2022-12-22 16:58:26 +00:00
case nameof(Settings.PreviewHotkey):
2022-12-27 17:09:32 +00:00
OnPropertyChanged(nameof(PreviewHotkey));
2022-12-22 16:58:26 +00:00
break;
2024-04-13 09:47:37 +00:00
case nameof(Settings.AutoCompleteHotkey):
OnPropertyChanged(nameof(AutoCompleteHotkey));
break;
case nameof(Settings.CycleHistoryUpHotkey):
OnPropertyChanged(nameof(CycleHistoryUpHotkey));
break;
case nameof(Settings.CycleHistoryDownHotkey):
OnPropertyChanged(nameof(CycleHistoryDownHotkey));
break;
case nameof(Settings.AutoCompleteHotkey2):
OnPropertyChanged(nameof(AutoCompleteHotkey2));
break;
2024-04-15 12:52:15 +00:00
case nameof(Settings.SelectNextItemHotkey):
OnPropertyChanged(nameof(SelectNextItemHotkey));
break;
case nameof(Settings.SelectNextItemHotkey2):
OnPropertyChanged(nameof(SelectNextItemHotkey2));
break;
2024-04-15 12:52:15 +00:00
case nameof(Settings.SelectPrevItemHotkey):
OnPropertyChanged(nameof(SelectPrevItemHotkey));
break;
case nameof(Settings.SelectPrevItemHotkey2):
OnPropertyChanged(nameof(SelectPrevItemHotkey2));
break;
case nameof(Settings.SelectNextPageHotkey):
OnPropertyChanged(nameof(SelectNextPageHotkey));
break;
case nameof(Settings.SelectPrevPageHotkey):
OnPropertyChanged(nameof(SelectPrevPageHotkey));
break;
case nameof(Settings.OpenContextMenuHotkey):
OnPropertyChanged(nameof(OpenContextMenuHotkey));
break;
2024-04-18 07:01:26 +00:00
case nameof(Settings.SettingWindowHotkey):
OnPropertyChanged(nameof(SettingWindowHotkey));
break;
}
};
2020-04-21 12:16:10 +00:00
_historyItemsStorage = new FlowLauncherJsonStorage<History>();
_userSelectedRecordStorage = new FlowLauncherJsonStorage<UserSelectedRecord>();
2025-05-01 03:06:52 +00:00
_topMostRecord = new FlowLauncherJsonStorageTopMostRecord();
_history = _historyItemsStorage.Load();
_userSelectedRecord = _userSelectedRecordStorage.Load();
2025-05-13 02:36:46 +00:00
ContextMenu = new ResultsViewModel(Settings, this)
{
LeftClickResultCommand = OpenResultCommand,
RightClickResultCommand = LoadContextMenuCommand,
IsPreviewOn = Settings.AlwaysPreview
};
2025-05-13 02:36:46 +00:00
Results = new ResultsViewModel(Settings, this)
{
LeftClickResultCommand = OpenResultCommand,
RightClickResultCommand = LoadContextMenuCommand,
IsPreviewOn = Settings.AlwaysPreview
};
2025-05-13 02:36:46 +00:00
History = new ResultsViewModel(Settings, this)
{
LeftClickResultCommand = OpenResultCommand,
RightClickResultCommand = LoadContextMenuCommand,
IsPreviewOn = Settings.AlwaysPreview
};
_selectedResults = Results;
2025-03-30 10:03:43 +00:00
Results.PropertyChanged += (o, args) =>
{
switch (args.PropertyName)
{
case nameof(Results.SelectedItem):
2025-03-22 15:33:04 +00:00
_selectedItemFromQueryResults = true;
PreviewSelectedItem = Results.SelectedItem;
2025-03-26 02:07:35 +00:00
_ = UpdatePreviewAsync();
2025-03-22 15:33:04 +00:00
break;
}
};
2025-03-30 10:03:43 +00:00
History.PropertyChanged += (o, args) =>
2025-03-22 15:33:04 +00:00
{
switch (args.PropertyName)
{
case nameof(History.SelectedItem):
_selectedItemFromQueryResults = false;
PreviewSelectedItem = History.SelectedItem;
2025-03-26 02:07:35 +00:00
_ = UpdatePreviewAsync();
break;
}
};
RegisterViewUpdate();
2022-12-23 03:47:06 +00:00
_ = RegisterClockAndDateUpdateAsync();
}
private void RegisterViewUpdate()
{
2021-02-15 18:12:28 +00:00
var resultUpdateChannel = Channel.CreateUnbounded<ResultsForUpdate>();
_resultsUpdateChannelWriter = resultUpdateChannel.Writer;
_resultsViewUpdateTask =
2025-03-17 02:07:40 +00:00
Task.Run(UpdateActionAsync).ContinueWith(continueAction, CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted, TaskScheduler.Default);
2025-03-17 02:07:40 +00:00
async Task UpdateActionAsync()
{
var queue = new Dictionary<string, ResultsForUpdate>();
2021-02-15 18:12:28 +00:00
var channelReader = resultUpdateChannel.Reader;
2021-02-15 09:58:15 +00:00
// it is not supposed to be false because it won't be complete
2021-02-15 18:12:28 +00:00
while (await channelReader.WaitToReadAsync())
{
2020-11-21 04:01:57 +00:00
await Task.Delay(20);
2021-02-15 18:12:28 +00:00
while (channelReader.TryRead(out var item))
{
if (!item.Token.IsCancellationRequested)
queue[item.ID] = item;
}
UpdateResultView(queue.Values);
2021-02-15 10:19:30 +00:00
queue.Clear();
}
2021-02-15 09:58:15 +00:00
if (!_disposed)
2025-04-13 09:50:44 +00:00
App.API.LogError(ClassName, "Unexpected ResultViewUpdate ends");
}
void continueAction(Task t)
{
#if DEBUG
throw t.Exception;
#else
App.API.LogError(ClassName, $"Error happen in task dealing with viewupdate for results. {t.Exception}");
2021-06-11 04:40:07 +00:00
_resultsViewUpdateTask =
2025-03-17 02:25:02 +00:00
Task.Run(UpdateActionAsync).ContinueWith(continueAction, CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted, TaskScheduler.Default);
#endif
}
}
2025-03-21 06:46:39 +00:00
public void RegisterResultsUpdatedEvent()
{
foreach (var pair in PluginManager.GetPluginsForInterface<IResultUpdated>())
{
2021-01-12 23:35:48 +00:00
var plugin = (IResultUpdated)pair.Plugin;
plugin.ResultsUpdated += (s, e) =>
{
if (e.Query.RawQuery != QueryText || e.Token.IsCancellationRequested)
{
return;
}
var token = e.Token == default ? _updateToken : e.Token;
2025-02-20 10:06:15 +00:00
// 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);
foreach (var result in resultsCopy)
{
if (string.IsNullOrEmpty(result.BadgeIcoPath))
{
result.BadgeIcoPath = pair.Metadata.IcoPath;
}
}
PluginManager.UpdatePluginMetadata(resultsCopy, pair.Metadata, e.Query);
if (token.IsCancellationRequested) return;
if (!_resultsUpdateChannelWriter.TryWrite(new ResultsForUpdate(resultsCopy, pair.Metadata, e.Query,
token)))
{
2025-04-13 09:50:44 +00:00
App.API.LogError(ClassName, "Unable to add item to Result Update Queue");
2021-01-12 23:35:48 +00:00
}
};
}
}
2025-03-19 10:22:43 +00:00
private async Task RegisterClockAndDateUpdateAsync()
{
var timer = new PeriodicTimer(TimeSpan.FromSeconds(1));
// ReSharper disable once MethodSupportsCancellation
while (await timer.WaitForNextTickAsync().ConfigureAwait(false))
{
if (Settings.UseClock)
ClockText = DateTime.Now.ToString(Settings.TimeFormat, CultureInfo.CurrentCulture);
if (Settings.UseDate)
DateText = DateTime.Now.ToString(Settings.DateFormat, CultureInfo.CurrentCulture);
}
}
[RelayCommand]
private async Task ReloadPluginDataAsync()
{
Hide();
await PluginManager.ReloadDataAsync().ConfigureAwait(false);
App.API.ShowMsg(App.API.GetTranslation("success"),
2025-03-17 02:07:40 +00:00
App.API.GetTranslation("completedSuccessfully"));
}
[RelayCommand]
private void LoadHistory()
{
2025-03-26 02:07:35 +00:00
if (QueryResultsSelected())
2016-02-12 09:20:46 +00:00
{
SelectedResults = History;
History.SelectedIndex = _history.Items.Count - 1;
}
else
{
SelectedResults = Results;
}
}
2016-02-12 06:21:12 +00:00
[RelayCommand]
public void ReQuery()
{
2025-03-26 02:07:35 +00:00
if (QueryResultsSelected())
{
2025-03-31 05:04:41 +00:00
// When we are re-querying, we should not delay the query
2025-03-21 05:34:52 +00:00
_ = QueryResultsAsync(false, isReQuery: true);
}
}
public void ReQuery(bool reselect)
{
BackToQueryResults();
2025-03-31 05:04:41 +00:00
// When we are re-querying, we should not delay the query
2025-03-21 05:34:52 +00:00
_ = QueryResultsAsync(false, isReQuery: true, reSelect: reselect);
}
[RelayCommand]
public void ReverseHistory()
{
if (_history.Items.Count > 0)
{
2025-03-30 10:03:43 +00:00
ChangeQueryText(_history.Items[^lastHistoryIndex].Query);
2024-05-19 15:16:56 +00:00
if (lastHistoryIndex < _history.Items.Count)
{
2024-05-19 15:16:56 +00:00
lastHistoryIndex++;
}
}
}
[RelayCommand]
public void ForwardHistory()
{
if (_history.Items.Count > 0)
{
2025-03-30 10:03:43 +00:00
ChangeQueryText(_history.Items[^lastHistoryIndex].Query);
2024-05-19 15:16:56 +00:00
if (lastHistoryIndex > 1)
{
2024-05-19 15:16:56 +00:00
lastHistoryIndex--;
}
}
}
[RelayCommand]
private void LoadContextMenu()
{
2025-03-26 02:07:35 +00:00
if (QueryResultsSelected())
{
// When switch to ContextMenu from QueryResults, but no item being chosen, should do nothing
// i.e. Shift+Enter/Ctrl+O right after Alt + Space should do nothing
if (SelectedResults.SelectedItem != null)
SelectedResults = ContextMenu;
}
else
{
SelectedResults = Results;
}
}
2016-02-12 06:21:12 +00:00
[RelayCommand]
private void Backspace(object index)
{
var query = QueryBuilder.Build(QueryText.Trim(), PluginManager.NonGlobalPlugins);
2016-02-12 06:21:12 +00:00
// GetPreviousExistingDirectory does not require trailing '\', otherwise will return empty string
var path = FilesFolders.GetPreviousExistingDirectory((_) => true, query.Search.TrimEnd('\\'));
2016-02-12 06:21:12 +00:00
var actionKeyword = string.IsNullOrEmpty(query.ActionKeyword) ? string.Empty : $"{query.ActionKeyword} ";
2016-02-12 06:21:12 +00:00
ChangeQueryText($"{actionKeyword}{path}");
}
[RelayCommand]
private void AutocompleteQuery()
{
var result = SelectedResults.SelectedItem?.Result;
2025-03-26 02:07:35 +00:00
if (result != null && QueryResultsSelected()) // SelectedItem returns null if selection is empty.
2016-02-12 09:20:46 +00:00
{
var autoCompleteText = result.Title;
if (!string.IsNullOrEmpty(result.AutoCompleteText))
{
autoCompleteText = result.AutoCompleteText;
}
else if (!string.IsNullOrEmpty(SelectedResults.SelectedItem?.QuerySuggestionText))
2016-02-12 06:21:12 +00:00
{
2025-03-19 10:22:43 +00:00
//var defaultSuggestion = SelectedResults.SelectedItem.QuerySuggestionText;
//// check if result.actionkeywordassigned is empty
//if (!string.IsNullOrEmpty(result.ActionKeywordAssigned))
//{
// autoCompleteText = $"{result.ActionKeywordAssigned} {defaultSuggestion}";
//}
2024-03-20 05:47:14 +00:00
autoCompleteText = SelectedResults.SelectedItem.QuerySuggestionText;
}
var specialKeyState = GlobalHotkey.CheckModifiers();
if (specialKeyState.ShiftPressed)
{
autoCompleteText = result.SubTitle;
}
2016-02-12 06:21:12 +00:00
ChangeQueryText(autoCompleteText);
}
}
2022-12-23 03:46:12 +00:00
[RelayCommand]
2022-11-22 17:24:35 +00:00
private async Task OpenResultAsync(string index)
{
var results = SelectedResults;
2022-11-22 17:24:35 +00:00
if (index is not null)
{
results.SelectedIndex = int.Parse(index);
}
2024-03-20 05:47:14 +00:00
var result = results.SelectedItem?.Result;
if (result == null)
{
return;
}
2024-03-20 05:47:14 +00:00
var hideWindow = await result.ExecuteAsync(new ActionContext
2025-03-17 02:07:40 +00:00
{
// not null means pressing modifier key + number, should ignore the modifier key
SpecialKeyState = index is not null ? SpecialKeyState.Default : GlobalHotkey.CheckModifiers()
})
.ConfigureAwait(false);
2025-03-26 02:07:35 +00:00
if (QueryResultsSelected())
{
_userSelectedRecord.Add(result);
2025-05-11 08:53:44 +00:00
_history.Add(result.OriginQuery.RawQuery);
lastHistoryIndex = 1;
}
2023-04-03 06:09:36 +00:00
if (hideWindow)
{
Hide();
}
}
2025-02-20 10:06:15 +00:00
private static IReadOnlyList<Result> DeepCloneResults(IReadOnlyList<Result> results, CancellationToken token = default)
{
var resultsCopy = new List<Result>();
foreach (var result in results.ToList())
{
if (token.IsCancellationRequested)
{
break;
}
var resultCopy = result.Clone();
resultsCopy.Add(resultCopy);
}
return resultsCopy;
}
2023-04-23 11:09:58 +00:00
#endregion
2023-04-22 06:12:12 +00:00
#region BasicCommands
[RelayCommand]
private void OpenSetting()
{
App.API.OpenSettingDialog();
}
[RelayCommand]
private void SelectHelp()
{
App.API.OpenUrl("https://www.flowlauncher.com/docs/#/usage-tips");
}
2022-01-19 20:43:00 +00:00
[RelayCommand]
private void SelectFirstResult()
{
SelectedResults.SelectFirstResult();
}
[RelayCommand]
private void SelectLastResult()
{
SelectedResults.SelectLastResult();
}
[RelayCommand]
private void SelectPrevPage()
{
SelectedResults.SelectPrevPage();
}
[RelayCommand]
private void SelectNextPage()
{
SelectedResults.SelectNextPage();
}
2021-12-03 16:59:40 +00:00
[RelayCommand]
private void SelectPrevItem()
{
if (QueryResultsSelected() // Results selected
&& string.IsNullOrEmpty(QueryText) // No input
2025-05-11 08:16:29 +00:00
&& Results.Visibility != Visibility.Visible // No items in result list, e.g. when home page is off and no query text is entered, therefore the view is collapsed.
&& _history.Items.Count > 0) // Have history items
{
2024-05-19 15:16:56 +00:00
lastHistoryIndex = 1;
ReverseHistory();
}
else
{
SelectedResults.SelectPrevResult();
}
}
2016-02-12 09:20:46 +00:00
[RelayCommand]
private void SelectNextItem()
{
SelectedResults.SelectNextResult();
}
2021-02-12 03:45:31 +00:00
[RelayCommand]
private void Esc()
{
2025-03-26 02:07:35 +00:00
if (!QueryResultsSelected())
{
SelectedResults = Results;
}
else
2021-02-12 03:45:31 +00:00
{
Hide();
}
}
public void BackToQueryResults()
{
2025-03-26 02:07:35 +00:00
if (!QueryResultsSelected())
{
SelectedResults = Results;
}
}
2022-12-23 06:14:53 +00:00
[RelayCommand]
public void ToggleGameMode()
{
GameModeStatus = !GameModeStatus;
}
2024-05-27 07:09:22 +00:00
2024-03-18 10:11:25 +00:00
[RelayCommand]
public void CopyAlternative()
{
var result = Results.SelectedItem?.Result?.CopyText;
if (result != null)
{
App.API.CopyToClipboard(result, directCopy: false);
}
}
#endregion
#region ViewModel Properties
2022-10-12 04:01:50 +00:00
public Settings Settings { get; }
2022-11-19 09:54:34 +00:00
public string ClockText { get; private set; }
2022-10-12 04:01:50 +00:00
public string DateText { get; private set; }
public ResultsViewModel Results { get; private set; }
public ResultsViewModel ContextMenu { get; private set; }
public ResultsViewModel History { get; private set; }
2022-12-23 06:14:53 +00:00
public bool GameModeStatus { get; set; } = false;
2021-11-19 06:43:47 +00:00
private string _queryText;
public string QueryText
{
get => _queryText;
set
{
_queryText = value;
OnPropertyChanged();
}
}
2020-05-04 22:41:24 +00:00
[RelayCommand]
private void IncreaseWidth()
{
2025-03-29 08:03:41 +00:00
MainWindowWidth += 100;
Settings.WindowLeft -= 50;
OnPropertyChanged(nameof(MainWindowWidth));
}
[RelayCommand]
private void DecreaseWidth()
{
2025-03-29 08:03:41 +00:00
if (MainWindowWidth - 100 < 400 || MainWindowWidth == 400)
2022-10-13 04:57:42 +00:00
{
2025-03-29 08:03:41 +00:00
MainWindowWidth = 400;
2022-10-13 04:57:42 +00:00
}
else
2022-10-25 12:45:06 +00:00
{
2025-03-29 08:03:41 +00:00
MainWindowWidth -= 100;
Settings.WindowLeft += 50;
2022-10-13 04:57:42 +00:00
}
2024-03-20 05:47:14 +00:00
OnPropertyChanged(nameof(MainWindowWidth));
}
[RelayCommand]
private void IncreaseMaxResult()
{
2022-10-25 12:45:06 +00:00
if (Settings.MaxResultsToShow == 17)
2022-10-14 09:46:53 +00:00
return;
2022-10-25 12:45:06 +00:00
Settings.MaxResultsToShow += 1;
}
[RelayCommand]
private void DecreaseMaxResult()
{
2022-10-25 12:45:06 +00:00
if (Settings.MaxResultsToShow == 2)
2022-10-14 09:46:53 +00:00
return;
2022-10-25 12:45:06 +00:00
Settings.MaxResultsToShow -= 1;
}
2016-07-20 22:37:06 +00:00
/// <summary>
/// we need move cursor to end when we manually changed query
/// but we don't want to move cursor to end when query is updated from TextBox
/// </summary>
/// <param name="queryText"></param>
/// <param name="isReQuery">Force query even when Query Text doesn't change</param>
public void ChangeQueryText(string queryText, bool isReQuery = false)
2016-07-20 22:37:06 +00:00
{
// 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>
/// Async version of <see cref="ChangeQueryText"/>
/// </summary>
private async Task ChangeQueryTextAsync(string queryText, bool isReQuery = false)
{
// Must check access so that we will not block the UI thread which causes window visibility issue
2025-03-28 03:56:44 +00:00
if (!Application.Current.Dispatcher.CheckAccess())
{
await Application.Current.Dispatcher.InvokeAsync(() => ChangeQueryTextAsync(queryText, isReQuery));
2025-03-28 03:56:44 +00:00
return;
}
2025-03-28 04:00:23 +00:00
if (QueryText != queryText)
{
2025-03-30 04:39:18 +00:00
// Change query text first
2025-03-28 04:00:23 +00:00
QueryText = queryText;
2025-03-30 04:32:32 +00:00
// When we are changing query from codes, we should not delay the query
await QueryAsync(false, isReQuery: false);
2025-03-30 04:39:18 +00:00
2025-03-28 04:00:23 +00:00
// set to false so the subsequent set true triggers
// PropertyChanged and MoveQueryTextToEnd is called
QueryTextCursorMovedToEnd = false;
}
else if (isReQuery)
{
2025-03-31 05:04:41 +00:00
// When we are re-querying, we should not delay the query
2025-03-30 04:32:32 +00:00
await QueryAsync(false, isReQuery: true);
2025-03-28 04:00:23 +00:00
}
2024-03-20 05:47:14 +00:00
2025-03-28 04:00:23 +00:00
QueryTextCursorMovedToEnd = true;
2016-07-20 22:37:06 +00:00
}
public bool LastQuerySelected { get; set; }
// This is not a reliable indicator of the cursor's position, it is manually set for a specific purpose.
2016-07-20 22:37:06 +00:00
public bool QueryTextCursorMovedToEnd { get; set; }
private ResultsViewModel _selectedResults;
private ResultsViewModel SelectedResults
{
2025-03-22 15:33:04 +00:00
get => _selectedResults;
set
{
2025-03-26 02:07:35 +00:00
var isReturningFromQueryResults = QueryResultsSelected();
var isReturningFromContextMenu = ContextMenuSelected();
2025-03-22 15:33:04 +00:00
var isReturningFromHistory = HistorySelected();
_selectedResults = value;
2025-03-26 02:07:35 +00:00
if (QueryResultsSelected())
{
2025-03-25 11:36:39 +00:00
Results.Visibility = Visibility.Visible;
2023-01-09 15:56:48 +00:00
ContextMenu.Visibility = Visibility.Collapsed;
History.Visibility = Visibility.Collapsed;
// QueryText setter (used in ChangeQueryText) runs the query again, resetting the selected
// result from the one that was selected before going into the context menu to the first result.
// The code below correctly restores QueryText and puts the text caret at the end without
// running the query again when returning from the context menu.
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;
}
else
{
ChangeQueryText(_queryTextBeforeLeaveResults);
}
2025-03-22 15:33:04 +00:00
// If we are returning from history and we have not set select item yet,
// we need to clear the preview selected item
if (isReturningFromHistory && _selectedItemFromQueryResults.HasValue && (!_selectedItemFromQueryResults.Value))
{
PreviewSelectedItem = null;
}
}
else
{
2023-01-09 15:56:48 +00:00
Results.Visibility = Visibility.Collapsed;
2025-03-25 11:36:39 +00:00
if (HistorySelected())
{
ContextMenu.Visibility = Visibility.Collapsed;
History.Visibility = Visibility.Visible;
}
else
{
ContextMenu.Visibility = Visibility.Visible;
History.Visibility = Visibility.Collapsed;
}
_queryTextBeforeLeaveResults = QueryText;
// Because of Fody's optimization
// setter won't be called when property value is not changed.
// so we need manually call Query()
// http://stackoverflow.com/posts/25895769/revisions
2025-03-21 05:34:52 +00:00
QueryText = string.Empty;
2025-03-30 04:39:18 +00:00
// When we are changing query because selected results are changed to history or context menu,
// we should not delay the query
2025-03-21 05:34:52 +00:00
Query(false);
2025-03-22 15:42:14 +00:00
if (HistorySelected())
{
// If we are returning from query results and we have not set select item yet,
// we need to clear the preview selected item
if (isReturningFromQueryResults && _selectedItemFromQueryResults.HasValue && _selectedItemFromQueryResults.Value)
{
PreviewSelectedItem = null;
}
}
}
}
}
2016-05-23 21:17:38 +00:00
public Visibility ProgressBarVisibility { get; set; }
public Visibility MainWindowVisibility { get; set; }
2025-03-30 10:03:43 +00:00
// This is to be used for determining the visibility status of the main window instead of MainWindowVisibility
// because it is more accurate and reliable representation than using Visibility as a condition check
public bool MainWindowVisibilityStatus { get; set; } = true;
public event VisibilityChangedEventHandler VisibilityChanged;
2025-03-28 07:52:31 +00:00
public Visibility ClockPanelVisibility { get; set; }
2021-12-17 12:48:29 +00:00
public Visibility SearchIconVisibility { get; set; }
2025-03-28 07:52:31 +00:00
public double ClockPanelOpacity { get; set; } = 1;
public double SearchIconOpacity { get; set; } = 1;
2021-12-17 12:48:29 +00:00
2025-03-29 07:08:49 +00:00
private string _placeholderText;
public string PlaceholderText
{
2025-03-29 07:48:52 +00:00
get => string.IsNullOrEmpty(_placeholderText) ? App.API.GetTranslation("queryTextBoxPlaceholder") : _placeholderText;
2025-03-29 07:08:49 +00:00
set
{
2025-03-29 07:48:52 +00:00
_placeholderText = value;
2025-03-29 07:08:49 +00:00
OnPropertyChanged();
}
}
public double MainWindowWidth
{
2022-10-25 12:45:06 +00:00
get => Settings.WindowSize;
set
{
if (!MainWindowVisibilityStatus) return;
Settings.WindowSize = value;
}
}
2024-05-14 07:54:15 +00:00
public double MainWindowHeight
{
get => Settings.WindowHeightSize;
set => Settings.WindowHeightSize = value;
}
public double QueryBoxFontSize
{
get => Settings.QueryBoxFontSize;
set => Settings.QueryBoxFontSize = value;
}
public double ItemHeightSize
{
get => Settings.ItemHeightSize;
set => Settings.ItemHeightSize = value;
}
public double ResultItemFontSize
{
get => Settings.ResultItemFontSize;
set => Settings.ResultItemFontSize = value;
}
public double ResultSubItemFontSize
{
get => Settings.ResultSubItemFontSize;
set => Settings.ResultSubItemFontSize = value;
}
public ImageSource PluginIconSource { get; private set; } = null;
2021-12-17 11:23:19 +00:00
public string PluginIconPath { get; set; } = null;
2022-12-22 14:57:15 +00:00
public string OpenResultCommandModifiers => Settings.OpenResultModifiers;
2025-03-19 10:22:43 +00:00
private static string VerifyOrSetDefaultHotkey(string hotkey, string defaultHotkey)
2024-03-20 05:47:14 +00:00
{
try
{
var converter = new KeyGestureConverter();
var key = (KeyGesture)converter.ConvertFromString(hotkey);
}
catch (Exception e) when (e is NotSupportedException || e is InvalidEnumArgumentException)
2024-04-13 09:47:37 +00:00
{
return defaultHotkey;
2024-04-13 09:47:37 +00:00
}
return hotkey;
2024-04-13 09:47:37 +00:00
}
2024-05-27 07:09:22 +00:00
public string PreviewHotkey => VerifyOrSetDefaultHotkey(Settings.PreviewHotkey, "F1");
2024-04-15 13:28:07 +00:00
public string AutoCompleteHotkey => VerifyOrSetDefaultHotkey(Settings.AutoCompleteHotkey, "Ctrl+Tab");
public string AutoCompleteHotkey2 => VerifyOrSetDefaultHotkey(Settings.AutoCompleteHotkey2, "");
2024-04-15 12:52:15 +00:00
public string SelectNextItemHotkey => VerifyOrSetDefaultHotkey(Settings.SelectNextItemHotkey, "Tab");
public string SelectNextItemHotkey2 => VerifyOrSetDefaultHotkey(Settings.SelectNextItemHotkey2, "");
2024-04-15 13:28:07 +00:00
public string SelectPrevItemHotkey => VerifyOrSetDefaultHotkey(Settings.SelectPrevItemHotkey, "Shift+Tab");
public string SelectPrevItemHotkey2 => VerifyOrSetDefaultHotkey(Settings.SelectPrevItemHotkey2, "");
public string SelectNextPageHotkey => VerifyOrSetDefaultHotkey(Settings.SelectNextPageHotkey, "");
public string SelectPrevPageHotkey => VerifyOrSetDefaultHotkey(Settings.SelectPrevPageHotkey, "");
public string OpenContextMenuHotkey => VerifyOrSetDefaultHotkey(Settings.OpenContextMenuHotkey, "Ctrl+O");
2024-04-18 07:01:26 +00:00
public string SettingWindowHotkey => VerifyOrSetDefaultHotkey(Settings.SettingWindowHotkey, "Ctrl+I");
public string CycleHistoryUpHotkey => VerifyOrSetDefaultHotkey(Settings.CycleHistoryUpHotkey, "Alt+Up");
public string CycleHistoryDownHotkey => VerifyOrSetDefaultHotkey(Settings.CycleHistoryDownHotkey, "Alt+Down");
2024-04-13 09:47:37 +00:00
2022-12-20 14:35:07 +00:00
public bool StartWithEnglishMode => Settings.AlwaysStartEn;
#endregion
2023-04-22 06:12:12 +00:00
#region Preview
2025-03-26 02:30:47 +00:00
private static readonly int ResultAreaColumnPreviewShown = 1;
private static readonly int ResultAreaColumnPreviewHidden = 3;
2025-03-22 15:33:04 +00:00
2025-03-26 02:30:47 +00:00
private bool? _selectedItemFromQueryResults;
2025-03-26 02:30:47 +00:00
private ResultViewModel _previewSelectedItem;
2025-03-22 15:33:04 +00:00
public ResultViewModel PreviewSelectedItem
{
2025-03-26 02:30:47 +00:00
get => _previewSelectedItem;
set
{
2025-03-26 02:30:47 +00:00
_previewSelectedItem = value;
OnPropertyChanged();
}
}
public bool InternalPreviewVisible
2023-04-22 06:12:12 +00:00
{
get
2023-04-22 06:17:05 +00:00
{
if (ResultAreaColumn == ResultAreaColumnPreviewShown)
return true;
if (ResultAreaColumn == ResultAreaColumnPreviewHidden)
return false;
#if DEBUG
throw new NotImplementedException("ResultAreaColumn should match ResultAreaColumnPreviewShown/ResultAreaColumnPreviewHidden value");
#else
App.API.LogError(ClassName, "ResultAreaColumnPreviewHidden/ResultAreaColumnPreviewShown int value not implemented", "InternalPreviewVisible");
return false;
2025-03-17 02:07:40 +00:00
#endif
}
}
public int ResultAreaColumn { get; set; } = ResultAreaColumnPreviewShown;
// This is not a reliable indicator of whether external preview is visible due to the
2024-05-30 11:49:44 +00:00
// ability of manually closing/exiting the external preview program which, does not inform flow that
// preview is no longer available.
2025-03-26 02:07:35 +00:00
public bool ExternalPreviewVisible { get; private set; }
2025-03-26 02:07:35 +00:00
private async Task ShowPreviewAsync()
{
var useExternalPreview = PluginManager.UseExternalPreview();
switch (useExternalPreview)
{
case true
when CanExternalPreviewSelectedResult(out var path):
// Internal preview may still be on when user switches to external
if (InternalPreviewVisible)
HideInternalPreview();
2025-03-26 02:07:35 +00:00
_ = OpenExternalPreviewAsync(path);
break;
case true
when !CanExternalPreviewSelectedResult(out var _):
if (ExternalPreviewVisible)
2025-03-26 02:07:35 +00:00
await CloseExternalPreviewAsync();
ShowInternalPreview();
break;
case false:
ShowInternalPreview();
break;
}
}
private void HidePreview()
{
if (PluginManager.UseExternalPreview())
2025-03-26 02:07:35 +00:00
_ = CloseExternalPreviewAsync();
if (InternalPreviewVisible)
HideInternalPreview();
}
[RelayCommand]
private void TogglePreview()
{
2024-05-31 07:02:54 +00:00
if (InternalPreviewVisible || ExternalPreviewVisible)
2023-04-22 09:06:38 +00:00
{
2024-05-31 07:02:54 +00:00
HidePreview();
}
else
{
2025-03-26 02:07:35 +00:00
_ = ShowPreviewAsync();
2023-04-22 09:06:38 +00:00
}
}
2025-03-26 02:07:35 +00:00
private async Task OpenExternalPreviewAsync(string path, bool sendFailToast = true)
2023-04-22 09:06:38 +00:00
{
2025-03-26 02:07:35 +00:00
await PluginManager.OpenExternalPreviewAsync(path, sendFailToast).ConfigureAwait(false);
ExternalPreviewVisible = true;
}
2025-03-26 02:07:35 +00:00
private async Task CloseExternalPreviewAsync()
{
2025-03-26 02:07:35 +00:00
await PluginManager.CloseExternalPreviewAsync().ConfigureAwait(false);
ExternalPreviewVisible = false;
}
2023-07-11 12:08:22 +00:00
2025-03-26 02:07:35 +00:00
private static async Task SwitchExternalPreviewAsync(string path, bool sendFailToast = true)
{
2025-03-26 02:07:35 +00:00
await PluginManager.SwitchExternalPreviewAsync(path, sendFailToast).ConfigureAwait(false);
2023-04-22 09:06:38 +00:00
}
2020-05-03 17:17:16 +00:00
2023-04-22 08:55:27 +00:00
private void ShowInternalPreview()
2023-04-22 06:12:12 +00:00
{
ResultAreaColumn = ResultAreaColumnPreviewShown;
2025-03-22 15:33:04 +00:00
PreviewSelectedItem?.LoadPreviewImage();
2023-04-22 06:12:12 +00:00
}
2023-04-22 08:55:27 +00:00
private void HideInternalPreview()
2023-04-22 06:12:12 +00:00
{
ResultAreaColumn = ResultAreaColumnPreviewHidden;
2023-04-22 06:12:12 +00:00
}
public void ResetPreview()
{
switch (Settings.AlwaysPreview)
2023-04-22 06:12:12 +00:00
{
case true
when PluginManager.AllowAlwaysPreview() && CanExternalPreviewSelectedResult(out var path):
2025-03-26 02:07:35 +00:00
_ = OpenExternalPreviewAsync(path);
break;
case true:
ShowInternalPreview();
break;
case false:
HidePreview();
break;
2023-04-22 06:12:12 +00:00
}
}
2025-03-26 02:07:35 +00:00
private async Task UpdatePreviewAsync()
2023-04-22 06:12:12 +00:00
{
switch (PluginManager.UseExternalPreview())
{
case true
when CanExternalPreviewSelectedResult(out var path):
if (ExternalPreviewVisible)
{
2025-03-26 02:07:35 +00:00
_ = SwitchExternalPreviewAsync(path, false);
}
else if (InternalPreviewVisible)
{
HideInternalPreview();
2025-03-26 02:07:35 +00:00
_ = OpenExternalPreviewAsync(path);
}
break;
case true
when !CanExternalPreviewSelectedResult(out var _):
if (ExternalPreviewVisible)
{
2025-03-26 02:07:35 +00:00
await CloseExternalPreviewAsync();
ShowInternalPreview();
}
break;
case false
when InternalPreviewVisible:
2025-03-22 15:33:04 +00:00
PreviewSelectedItem?.LoadPreviewImage();
break;
}
2023-04-22 06:17:05 +00:00
}
private bool CanExternalPreviewSelectedResult(out string path)
{
2025-03-26 02:07:35 +00:00
path = QueryResultsPreviewed() ? Results.SelectedItem?.Result?.Preview.FilePath : string.Empty;
2023-04-22 09:06:38 +00:00
return !string.IsNullOrEmpty(path);
}
2025-03-26 02:07:35 +00:00
private bool QueryResultsPreviewed()
{
var previewed = PreviewSelectedItem == Results.SelectedItem;
return previewed;
}
2020-05-04 22:41:24 +00:00
#endregion
2022-12-23 03:46:12 +00:00
#region Query
public void QueryResults()
{
_ = QueryResultsAsync(false);
}
2025-03-26 08:57:37 +00:00
public void Query(bool searchDelay, bool isReQuery = false)
{
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();
}
}
2025-03-30 04:07:04 +00:00
private async Task QueryAsync(bool searchDelay, bool isReQuery = false)
{
2025-03-26 02:07:35 +00:00
if (QueryResultsSelected())
{
2025-03-30 01:38:59 +00:00
await QueryResultsAsync(searchDelay, isReQuery);
}
else if (ContextMenuSelected())
{
QueryContextMenu();
}
else if (HistorySelected())
{
QueryHistory();
}
}
private void QueryContextMenu()
{
const string id = "Context Menu ID";
var query = QueryText.ToLower().Trim();
ContextMenu.Clear();
var selected = Results.SelectedItem?.Result;
if (selected != null) // SelectedItem returns null if selection is empty.
{
2025-05-04 01:14:57 +00:00
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));
2025-05-11 08:53:44 +00:00
results.Add(ContextMenuPluginInfo(selected));
2025-05-04 01:14:57 +00:00
}
if (!string.IsNullOrEmpty(query))
{
2024-02-04 16:41:42 +00:00
var filtered = results.Select(x => x.Clone()).Where
(
2021-01-17 10:10:36 +00:00
r =>
{
var match = App.API.FuzzySearch(query, r.Title);
2021-01-17 10:10:36 +00:00
if (!match.IsSearchPrecisionScoreMet())
{
match = App.API.FuzzySearch(query, r.SubTitle);
2021-01-17 10:10:36 +00:00
}
if (!match.IsSearchPrecisionScoreMet()) return false;
2021-01-17 10:10:36 +00:00
r.Score = match.Score;
return true;
}).ToList();
ContextMenu.AddResults(filtered, id);
}
else
{
ContextMenu.AddResults(results, id);
}
}
}
private void QueryHistory()
{
const string id = "Query History ID";
var query = QueryText.ToLower().Trim();
History.Clear();
2025-05-04 08:37:15 +00:00
var results = GetHistoryItems(_history.Items);
if (!string.IsNullOrEmpty(query))
{
var filtered = results.Where
(
r => App.API.FuzzySearch(query, r.Title).IsSearchPrecisionScoreMet() ||
App.API.FuzzySearch(query, r.SubTitle).IsSearchPrecisionScoreMet()
).ToList();
History.AddResults(filtered, id);
}
else
{
History.AddResults(results, id);
}
}
private static List<Result> GetHistoryItems(IEnumerable<HistoryItem> historyItems)
{
var results = new List<Result>();
2025-05-04 08:37:15 +00:00
foreach (var h in historyItems)
{
2025-03-17 02:07:40 +00:00
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,
2024-03-20 05:47:14 +00:00
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);
}
2025-05-04 08:37:15 +00:00
return results;
}
2025-03-21 05:34:52 +00:00
private async Task QueryResultsAsync(bool searchDelay, bool isReQuery = false, bool reSelect = true)
{
_updateSource?.Cancel();
2025-05-04 14:05:57 +00:00
App.API.LogDebug(ClassName, $"Start query with text: <{QueryText}>");
2025-05-04 14:04:01 +00:00
var query = await ConstructQueryAsync(QueryText, Settings.CustomShortcuts, Settings.BuiltinShortcuts);
2025-03-21 01:57:15 +00:00
if (query == null) // shortcut expanded
{
2025-05-04 14:04:01 +00:00
App.API.LogDebug(ClassName, $"Clear query results");
2025-05-04 01:38:35 +00:00
// Hide and clear results again because running query may show and add some results
Results.Visibility = Visibility.Collapsed;
Results.Clear();
2025-05-04 01:38:35 +00:00
// Reset plugin icon
PluginIconPath = null;
PluginIconSource = null;
SearchIconVisibility = Visibility.Visible;
2025-05-04 01:38:35 +00:00
// Hide progress bar again because running query may set this to visible
ProgressBarVisibility = Visibility.Hidden;
return;
}
2025-05-04 14:04:01 +00:00
App.API.LogDebug(ClassName, $"Start query with ActionKeyword <{query.ActionKeyword}> and RawQuery <{query.RawQuery}>");
var currentIsHomeQuery = query.RawQuery == string.Empty;
2025-05-06 11:35:40 +00:00
_updateSource?.Dispose();
var currentUpdateSource = new CancellationTokenSource();
_updateSource = currentUpdateSource;
var currentCancellationToken = _updateSource.Token;
_updateToken = currentCancellationToken;
ProgressBarVisibility = Visibility.Hidden;
_isQueryRunning = true;
// Switch to ThreadPool thread
await TaskScheduler.Default;
2025-05-06 11:35:40 +00:00
if (currentCancellationToken.IsCancellationRequested) return;
// Update the query's IsReQuery property to true if this is a re-query
2025-05-04 01:38:35 +00:00
query.IsReQuery = isReQuery;
2025-05-04 01:38:35 +00:00
ICollection<PluginPair> plugins = Array.Empty<PluginPair>();
if (currentIsHomeQuery)
{
2025-05-04 01:38:35 +00:00
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;
}
}
App.API.LogDebug(ClassName, $"Valid <{plugins.Count}> plugins: {string.Join(" ", plugins.Select(x => $"<{x.Metadata.Name}>"))}");
// Do not wait for performance improvement
/*if (string.IsNullOrEmpty(query.ActionKeyword))
2021-06-11 04:40:07 +00:00
{
2025-04-11 08:13:55 +00:00
// Wait 15 millisecond for query change in global query
2021-06-11 04:40:07 +00:00
// if query changes, return so that it won't be calculated
2025-05-06 11:35:40 +00:00
await Task.Delay(15, currentCancellationToken);
if (currentCancellationToken.IsCancellationRequested) return;
}*/
2021-06-11 04:40:07 +00:00
2025-05-06 11:35:40 +00:00
_ = Task.Delay(200, currentCancellationToken).ContinueWith(_ =>
{
2025-03-21 05:34:52 +00:00
// 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 (_isQueryRunning)
2025-03-21 05:34:52 +00:00
{
ProgressBarVisibility = Visibility.Visible;
}
2025-03-21 02:05:48 +00:00
},
2025-05-06 11:35:40 +00:00
currentCancellationToken,
2025-03-21 02:05:48 +00:00
TaskContinuationOptions.NotOnCanceled,
TaskScheduler.Default);
2019-12-03 22:15:46 +00:00
2025-03-30 10:03:43 +00:00
// plugins are ICollection, meaning LINQ will get the Count and preallocate Array
2025-05-03 14:09:25 +00:00
Task[] tasks;
if (currentIsHomeQuery)
2021-06-11 04:40:07 +00:00
{
if (ShouldClearExistingResultsForNonQuery(plugins))
2025-05-17 11:08:18 +00:00
{
Results.Clear();
2025-05-17 11:08:18 +00:00
App.API.LogDebug(ClassName, $"Existing results are cleared for non-query");
}
2025-05-04 01:38:35 +00:00
tasks = plugins.Select(plugin => plugin.Metadata.HomeDisabled switch
2025-05-03 14:09:25 +00:00
{
2025-05-06 11:35:40 +00:00
false => QueryTaskAsync(plugin, currentCancellationToken),
2025-05-03 14:09:25 +00:00
true => Task.CompletedTask
2025-05-04 01:38:35 +00:00
}).ToArray();
2025-05-03 14:09:25 +00:00
2025-05-04 01:38:35 +00:00
// Query history results for home page firstly so it will be put on top of the results
2025-05-03 14:09:25 +00:00
if (Settings.ShowHistoryResultsForHomePage)
{
2025-05-06 11:35:40 +00:00
QueryHistoryTask(currentCancellationToken);
2025-05-03 14:09:25 +00:00
}
}
else
{
tasks = plugins.Select(plugin => plugin.Metadata.Disabled switch
{
2025-05-06 11:35:40 +00:00
false => QueryTaskAsync(plugin, currentCancellationToken),
2025-05-03 14:09:25 +00:00
true => Task.CompletedTask
}).ToArray();
}
2021-06-11 04:40:07 +00:00
try
{
// Check the code, WhenAll will translate all type of IEnumerable or Collection to Array, so make an array at first
await Task.WhenAll(tasks);
}
catch (OperationCanceledException)
{
// nothing to do here
}
2021-01-10 03:13:46 +00:00
2025-05-06 11:35:40 +00:00
if (currentCancellationToken.IsCancellationRequested) return;
2021-06-11 04:40:07 +00:00
// this should happen once after all queries are done so progress bar should continue
// until the end of all querying
_isQueryRunning = false;
2025-05-06 11:35:40 +00:00
if (!currentCancellationToken.IsCancellationRequested)
2021-06-11 04:40:07 +00:00
{
// update to hidden if this is still the current query
ProgressBarVisibility = Visibility.Hidden;
}
2021-06-11 04:40:07 +00:00
// Local function
2025-03-30 10:03:43 +00:00
async Task QueryTaskAsync(PluginPair plugin, CancellationToken token)
2021-06-11 04:40:07 +00:00
{
2025-05-04 14:04:01 +00:00
App.API.LogDebug(ClassName, $"Wait for querying plugin <{plugin.Metadata.Name}>");
if (searchDelay && !currentIsHomeQuery) // Do not delay for home query
2025-03-21 05:34:52 +00:00
{
var searchDelayTime = plugin.Metadata.SearchDelayTime ?? Settings.SearchDelayTime;
2025-03-30 14:40:46 +00:00
await Task.Delay(searchDelayTime, token);
2025-03-21 05:34:52 +00:00
if (token.IsCancellationRequested) return;
2025-03-21 05:34:52 +00:00
}
// Since it is wrapped within a ThreadPool Thread, the synchronous context is null
2021-06-11 04:40:07 +00:00
// Task.Yield will force it to run in ThreadPool
await Task.Yield();
var results = currentIsHomeQuery ?
2025-05-04 02:28:20 +00:00
await PluginManager.QueryHomeForPluginAsync(plugin, query, token) :
await PluginManager.QueryForPluginAsync(plugin, query, token);
if (token.IsCancellationRequested) return;
IReadOnlyList<Result> resultsCopy;
if (results == null)
{
resultsCopy = _emptyResult;
}
else
{
// make a copy of results to avoid possible issue that FL changes some properties of the records, like score, etc.
2025-03-21 02:29:45 +00:00
resultsCopy = DeepCloneResults(results, token);
}
2021-06-11 04:40:07 +00:00
2025-04-11 07:48:49 +00:00
foreach (var result in resultsCopy)
2025-04-11 07:21:11 +00:00
{
2025-04-11 07:38:00 +00:00
if (string.IsNullOrEmpty(result.BadgeIcoPath))
2025-04-11 07:21:11 +00:00
{
2025-04-11 07:38:00 +00:00
result.BadgeIcoPath = plugin.Metadata.IcoPath;
2025-04-11 07:21:11 +00:00
}
}
if (token.IsCancellationRequested) return;
2025-05-04 14:04:01 +00:00
App.API.LogDebug(ClassName, $"Update results for plugin <{plugin.Metadata.Name}>");
// Indicate if to clear existing results so to show only ones from plugins with action keywords
var shouldClearExistingResults = ShouldClearExistingResultsForQuery(query, currentIsHomeQuery);
_lastQuery = query;
_previousIsHomeQuery = currentIsHomeQuery;
if (!_resultsUpdateChannelWriter.TryWrite(new ResultsForUpdate(resultsCopy, plugin.Metadata, query,
token, reSelect, shouldClearExistingResults)))
2021-06-11 04:40:07 +00:00
{
2025-04-13 09:50:44 +00:00
App.API.LogError(ClassName, "Unable to add item to Result Update Queue");
2021-06-11 04:40:07 +00:00
}
}
2025-05-03 14:09:25 +00:00
2025-05-06 11:35:40 +00:00
void QueryHistoryTask(CancellationToken token)
2025-05-03 14:09:25 +00:00
{
// Select last history results and revert its order to make sure last history results are on top
2025-05-04 08:37:15 +00:00
var historyItems = _history.Items.TakeLast(Settings.MaxHistoryResultsToShowForHomePage).Reverse();
2025-05-03 14:09:25 +00:00
2025-05-04 08:37:15 +00:00
var results = GetHistoryItems(historyItems);
2025-05-03 14:09:25 +00:00
2025-05-06 11:35:40 +00:00
if (token.IsCancellationRequested) return;
2025-05-03 14:09:25 +00:00
2025-05-05 00:27:51 +00:00
App.API.LogDebug(ClassName, $"Update results for history");
// Indicate if to clear existing results so to show only ones from plugins with action keywords
var shouldClearExistingResults = ShouldClearExistingResultsForQuery(query, currentIsHomeQuery);
_lastQuery = query;
_previousIsHomeQuery = currentIsHomeQuery;
2025-05-04 03:05:23 +00:00
if (!_resultsUpdateChannelWriter.TryWrite(new ResultsForUpdate(results, _historyMetadata, query,
token, reSelect, shouldClearExistingResults)))
2025-05-03 14:09:25 +00:00
{
App.API.LogError(ClassName, "Unable to add item to Result Update Queue");
}
}
2016-02-12 06:21:12 +00:00
}
private async Task<Query> ConstructQueryAsync(string queryText, IEnumerable<CustomShortcutModel> customShortcuts,
IEnumerable<BaseBuiltinShortcutModel> builtInShortcuts)
{
2022-11-08 04:35:28 +00:00
if (string.IsNullOrWhiteSpace(queryText))
{
2025-05-04 01:38:35 +00:00
return QueryBuilder.Build(string.Empty, PluginManager.NonGlobalPlugins);
2022-11-08 04:35:28 +00:00
}
var queryBuilder = new StringBuilder(queryText);
var queryBuilderTmp = new StringBuilder(queryText);
2024-03-27 02:56:10 +00:00
// Sorting order is important here, the reason is for matching longest shortcut by default
2024-02-24 18:10:25 +00:00
foreach (var shortcut in customShortcuts.OrderByDescending(x => x.Key.Length))
{
2022-10-13 11:49:44 +00:00
if (queryBuilder.Equals(shortcut.Key))
{
2022-10-13 11:49:44 +00:00
queryBuilder.Replace(shortcut.Key, shortcut.Expand());
}
2022-10-13 11:49:44 +00:00
queryBuilder.Replace('@' + shortcut.Key, shortcut.Expand());
}
// Applying builtin shortcuts
await BuildQueryAsync(builtInShortcuts, queryBuilder, queryBuilderTmp);
return QueryBuilder.Build(queryBuilder.ToString().Trim(), PluginManager.NonGlobalPlugins);
}
private async Task BuildQueryAsync(IEnumerable<BaseBuiltinShortcutModel> builtInShortcuts,
StringBuilder queryBuilder, StringBuilder queryBuilderTmp)
{
var customExpanded = queryBuilder.ToString();
2023-01-12 09:06:23 +00:00
var queryChanged = false;
foreach (var shortcut in builtInShortcuts)
2022-10-13 11:49:44 +00:00
{
try
{
if (customExpanded.Contains(shortcut.Key))
{
string expansion;
if (shortcut is BuiltinShortcutModel syncShortcut)
2023-01-12 09:06:23 +00:00
{
expansion = syncShortcut.Expand();
2023-01-12 09:06:23 +00:00
}
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);
}
}
2025-05-02 07:06:20 +00:00
// Show expanded builtin shortcuts
if (queryChanged)
{
2025-05-02 07:06:20 +00:00
// 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));
}
}
/// <summary>
/// Determines whether the existing search results should be cleared based on the current query and the previous query type.
2025-05-17 11:08:18 +00:00
/// This is used to indicate to QueryTaskAsync or QueryHistoryTask whether to clear results. If both QueryTaskAsync and QueryHistoryTask
/// are not called then use ShouldClearExistingResultsForNonQuery instead.
/// This method needed because of the design that treats plugins with action keywords and global action keywords separately. Results are gathered
/// either from plugins with matching action keywords or global action keyword, but not both. So when the current results are from plugins
/// with a matching action keyword and a new result set comes from a new query with the global action keyword, the existing results need to be cleared,
/// and vice versa. The same applies to home page query results.
///
/// There is no need to clear results from global action keyword if a new set of results comes along that is also from global action keywords.
/// This is because the removal of obsolete results is handled in ResultsViewModel.NewResults(ICollection<ResultsForUpdate>).
/// </summary>
/// <param name="query">The current query.</param>
/// <param name="currentIsHomeQuery">A flag indicating if the current query is a home query.</param>
/// <returns>True if the existing results should be cleared, false otherwise.</returns>
private bool ShouldClearExistingResultsForQuery(Query query, bool currentIsHomeQuery)
2019-12-13 22:17:05 +00:00
{
// If previous or current results are from home query, we need to clear them
if (_previousIsHomeQuery || currentIsHomeQuery)
{
2025-05-17 11:08:18 +00:00
App.API.LogDebug(ClassName, $"Existing results should be cleared for query");
return true;
}
// If the last and current query are not home query type, we need to check the action keyword
if (_lastQuery?.ActionKeyword != query?.ActionKeyword)
2019-12-13 22:17:05 +00:00
{
2025-05-17 11:08:18 +00:00
App.API.LogDebug(ClassName, $"Existing results should be cleared for query");
return true;
}
return false;
}
2025-05-17 11:08:18 +00:00
/// <summary>
/// Determines whether existing results should be cleared for non-query calls.
/// A non-query call is where QueryTaskAsync and QueryHistoryTask methods are both not called.
/// QueryTaskAsync and QueryHistoryTask both handle result updating (clearing if required) so directly calling
/// Results.Clear() is not required. However when both are not called, we need to directly clear results and this
/// method determines on the condition when clear results should happen.
/// </summary>
/// <param name="plugins">The collection of plugins to check.</param>
/// <returns>True if existing results should be cleared, false otherwise.</returns>
private bool ShouldClearExistingResultsForNonQuery(ICollection<PluginPair> plugins)
{
if (!Settings.ShowHistoryResultsForHomePage && (plugins.Count == 0 || plugins.All(x => x.Metadata.HomeDisabled == true)))
{
2025-05-17 11:08:18 +00:00
App.API.LogDebug(ClassName, $"Existing results should be cleared for non-query");
return true;
2019-12-13 22:17:05 +00:00
}
return false;
2019-12-13 22:17:05 +00:00
}
private Result ContextMenuTopMost(Result result)
{
Result menu;
if (_topMostRecord.IsTopMost(result))
{
menu = new Result
{
2025-03-17 02:07:40 +00:00
Title = App.API.GetTranslation("cancelTopMostInThisQuery"),
IcoPath = "Images\\down.png",
PluginDirectory = Constant.ProgramDirectory,
Action = _ =>
{
_topMostRecord.Remove(result);
2025-03-17 02:07:40 +00:00
App.API.ShowMsg(App.API.GetTranslation("success"));
App.API.ReQuery();
return false;
},
2025-05-11 08:53:44 +00:00
Glyph = new GlyphInfo(FontFamily: "/Resources/#Segoe Fluent Icons", Glyph: "\uE74B"),
OriginQuery = result.OriginQuery
};
}
else
{
menu = new Result
{
2025-03-17 02:07:40 +00:00
Title = App.API.GetTranslation("setAsTopMostInThisQuery"),
IcoPath = "Images\\up.png",
PluginDirectory = Constant.ProgramDirectory,
Action = _ =>
{
_topMostRecord.AddOrUpdate(result);
2025-03-17 02:07:40 +00:00
App.API.ShowMsg(App.API.GetTranslation("success"));
App.API.ReQuery();
return false;
},
2025-05-11 08:53:44 +00:00
Glyph = new GlyphInfo(FontFamily: "/Resources/#Segoe Fluent Icons", Glyph: "\uE74A"),
OriginQuery = result.OriginQuery
};
}
return menu;
}
2025-05-11 08:53:44 +00:00
private static Result ContextMenuPluginInfo(Result result)
{
2025-05-11 08:53:44 +00:00
var id = result.PluginID;
var metadata = PluginManager.GetPluginForId(id).Metadata;
2025-03-17 02:07:40 +00:00
var translator = App.API;
var author = translator.GetTranslation("author");
var website = translator.GetTranslation("website");
var version = translator.GetTranslation("version");
var plugin = translator.GetTranslation("plugin");
var title = $"{plugin}: {metadata.Name}";
var icon = metadata.IcoPath;
2021-12-06 10:10:34 +00:00
var subtitle = $"{author} {metadata.Author}";
var menu = new Result
{
Title = title,
IcoPath = icon,
SubTitle = subtitle,
PluginDirectory = metadata.PluginDirectory,
2022-01-23 21:16:17 +00:00
Action = _ =>
{
App.API.OpenUrl(metadata.Website);
return true;
2025-05-11 08:53:44 +00:00
},
OriginQuery = result.OriginQuery
};
return menu;
}
2025-03-26 02:07:35 +00:00
internal bool QueryResultsSelected()
{
var selected = SelectedResults == Results;
return selected;
}
private bool ContextMenuSelected()
{
var selected = SelectedResults == ContextMenu;
return selected;
}
private bool HistorySelected()
{
var selected = SelectedResults == History;
return selected;
}
2025-05-13 02:36:46 +00:00
internal bool ResultsSelected(ResultsViewModel results)
{
var selected = SelectedResults == results;
return selected;
}
2022-12-23 03:46:12 +00:00
#endregion
#region Hotkey
2020-05-03 17:17:16 +00:00
public void ToggleFlowLauncher()
{
if (!MainWindowVisibilityStatus)
{
Show();
}
else
{
Hide();
}
}
2025-03-26 02:30:47 +00:00
/// <summary>
/// Checks if Flow Launcher should ignore any hotkeys
/// </summary>
public bool ShouldIgnoreHotkeys()
{
return Settings.IgnoreHotkeysOnFullscreen && Win32Helper.IsForegroundWindowFullscreen() || GameModeStatus;
}
#endregion
#region Public Methods
#pragma warning disable VSTHRD100 // Avoid async void methods
2025-03-28 04:00:23 +00:00
public void Show()
{
// When application is exiting, we should not show the main window
if (App.Exiting) return;
2025-03-30 07:01:41 +00:00
// When application is exiting, the Application.Current will be null
2025-03-30 06:59:30 +00:00
Application.Current?.Dispatcher.Invoke(() =>
{
2025-03-30 07:01:41 +00:00
// When application is exiting, the Application.Current will be null
if (Application.Current?.MainWindow is MainWindow mainWindow)
{
// 📌 Remove DWM Cloak (Make the window visible normally)
Win32Helper.DWMSetCloakForWindow(mainWindow, false);
// Set clock and search icon opacity
var opacity = Settings.UseAnimation ? 0.0 : 1.0;
ClockPanelOpacity = opacity;
SearchIconOpacity = opacity;
// Set clock and search icon visibility
ClockPanelVisibility = string.IsNullOrEmpty(QueryText) ? Visibility.Visible : Visibility.Collapsed;
if (PluginIconSource != null)
{
SearchIconOpacity = 0.0;
}
else
{
SearchIconVisibility = Visibility.Visible;
}
}
}, DispatcherPriority.Render);
// Update WPF properties
MainWindowVisibility = Visibility.Visible;
MainWindowVisibilityStatus = true;
VisibilityChanged?.Invoke(this, new VisibilityChangedEventArgs { IsVisible = true });
// Switch keyboard layout
if (StartWithEnglishMode)
{
Win32Helper.SwitchToEnglishKeyboardLayout(true);
}
2025-03-19 10:22:43 +00:00
}
2025-03-18 15:09:02 +00:00
2025-03-06 05:57:02 +00:00
public async void Hide()
{
lastHistoryIndex = 1;
2025-03-06 05:57:02 +00:00
if (ExternalPreviewVisible)
2025-03-17 02:07:40 +00:00
{
2025-03-26 02:07:35 +00:00
await CloseExternalPreviewAsync();
2025-03-17 02:07:40 +00:00
}
2023-04-22 09:16:02 +00:00
BackToQueryResults();
2024-03-20 05:47:14 +00:00
2025-03-06 05:57:02 +00:00
switch (Settings.LastQueryMode)
{
2025-03-26 16:14:13 +00:00
case LastQueryMode.Empty:
await ChangeQueryTextAsync(string.Empty);
2025-03-26 16:14:13 +00:00
break;
2025-03-06 05:57:02 +00:00
case LastQueryMode.Preserved:
case LastQueryMode.Selected:
LastQuerySelected = Settings.LastQueryMode == LastQueryMode.Preserved;
2025-03-06 05:57:02 +00:00
break;
case LastQueryMode.ActionKeywordPreserved:
case LastQueryMode.ActionKeywordSelected:
var newQuery = _lastQuery?.ActionKeyword;
2025-03-06 05:57:02 +00:00
if (!string.IsNullOrEmpty(newQuery))
newQuery += " ";
await ChangeQueryTextAsync(newQuery);
2025-03-06 05:57:02 +00:00
if (Settings.LastQueryMode == LastQueryMode.ActionKeywordSelected)
LastQuerySelected = false;
2025-03-06 05:57:02 +00:00
break;
}
2025-03-30 07:01:41 +00:00
// When application is exiting, the Application.Current will be null
2025-03-30 06:59:30 +00:00
Application.Current?.Dispatcher.Invoke(() =>
{
2025-03-30 07:01:41 +00:00
// When application is exiting, the Application.Current will be null
if (Application.Current?.MainWindow is MainWindow mainWindow)
{
// Set clock and search icon opacity
var opacity = Settings.UseAnimation ? 0.0 : 1.0;
ClockPanelOpacity = opacity;
SearchIconOpacity = opacity;
// Set clock and search icon visibility
ClockPanelVisibility = Visibility.Hidden;
SearchIconVisibility = Visibility.Hidden;
// Force UI update
mainWindow.ClockPanel.UpdateLayout();
mainWindow.SearchIcon.UpdateLayout();
// 📌 Apply DWM Cloak (Completely hide the window)
Win32Helper.DWMSetCloakForWindow(mainWindow, true);
}
}, DispatcherPriority.Render);
// Switch keyboard layout
if (StartWithEnglishMode)
{
Win32Helper.RestorePreviousKeyboardLayout();
}
// Delay for a while to make sure clock will not flicker
await Task.Delay(50);
2025-03-18 15:09:02 +00:00
// Update WPF properties
2025-02-24 06:31:41 +00:00
MainWindowVisibilityStatus = false;
MainWindowVisibility = Visibility.Collapsed;
VisibilityChanged?.Invoke(this, new VisibilityChangedEventArgs { IsVisible = false });
}
2025-03-17 02:07:40 +00:00
#pragma warning restore VSTHRD100 // Avoid async void methods
/// <summary>
2025-03-26 02:30:47 +00:00
/// Save history, user selected records and top most records
/// </summary>
public void Save()
{
_historyItemsStorage.Save();
_userSelectedRecordStorage.Save();
2025-05-01 03:06:52 +00:00
_topMostRecord.Save();
}
/// <summary>
2025-03-30 10:03:43 +00:00
/// To avoid deadlock, this method should not be called from main thread
/// </summary>
2024-03-20 05:47:14 +00:00
public void UpdateResultView(ICollection<ResultsForUpdate> resultsForUpdates)
{
2020-11-16 00:32:11 +00:00
if (!resultsForUpdates.Any())
return;
CancellationToken token;
try
{
// Don't know why sometimes even resultsForUpdates is empty, the method won't return;
token = resultsForUpdates.Select(r => r.Token).Distinct().SingleOrDefault();
}
#if DEBUG
catch
{
throw new ArgumentException("Unacceptable token");
}
#else
catch
{
token = default;
}
#endif
2020-11-16 00:32:11 +00:00
2021-01-13 01:43:15 +00:00
foreach (var metaResults in resultsForUpdates)
{
2021-01-13 01:43:15 +00:00
foreach (var result in metaResults.Results)
{
var deviationIndex = _topMostRecord.GetTopMostIndex(result);
if (deviationIndex != -1)
2021-01-13 01:43:15 +00:00
{
// Adjust the score based on the result's position in the top-most list.
// A lower deviationIndex (closer to the top) results in a higher score.
result.Score = Result.MaxScore - deviationIndex;
2021-01-13 01:43:15 +00:00
}
2025-02-20 10:06:15 +00:00
else
2021-01-13 01:43:15 +00:00
{
var priorityScore = metaResults.Metadata.Priority * 150;
2025-02-22 07:59:38 +00:00
if (result.AddSelectedCount)
{
if ((long)result.Score + _userSelectedRecord.GetSelectedCount(result) + priorityScore > Result.MaxScore)
{
result.Score = Result.MaxScore;
}
else
{
result.Score += _userSelectedRecord.GetSelectedCount(result) + priorityScore;
}
}
else
{
if ((long)result.Score + priorityScore > Result.MaxScore)
{
result.Score = Result.MaxScore;
}
else
{
result.Score += priorityScore;
}
}
2021-01-13 01:43:15 +00:00
}
}
}
2024-03-20 05:47:14 +00:00
// it should be the same for all results
bool reSelect = resultsForUpdates.First().ReSelectFirstResult;
Results.AddResults(resultsForUpdates, token, reSelect);
}
#endregion
#region IDisposable
private bool _disposed = false;
protected virtual void Dispose(bool disposing)
{
if (!_disposed)
{
if (disposing)
{
_updateSource?.Dispose();
_resultsUpdateChannelWriter?.Complete();
if (_resultsViewUpdateTask?.IsCompleted == true)
{
_resultsViewUpdateTask.Dispose();
}
_disposed = true;
}
}
}
public void Dispose()
{
2025-03-22 05:26:12 +00:00
// Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
#endregion
2016-02-12 09:20:46 +00:00
}
}