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

2340 lines
87 KiB
C#

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Globalization;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Threading;
using CommunityToolkit.Mvvm.DependencyInjection;
using CommunityToolkit.Mvvm.Input;
using Flow.Launcher.Core.Plugin;
using Flow.Launcher.Helper;
using Flow.Launcher.Infrastructure;
using Flow.Launcher.Infrastructure.DialogJump;
using Flow.Launcher.Infrastructure.Hotkey;
using Flow.Launcher.Infrastructure.Storage;
using Flow.Launcher.Infrastructure.UserSettings;
using Flow.Launcher.Plugin;
using Flow.Launcher.Plugin.SharedCommands;
using Flow.Launcher.Storage;
using iNKORE.UI.WPF.Modern;
using Microsoft.VisualStudio.Threading;
namespace Flow.Launcher.ViewModel
{
public partial class MainViewModel : BaseModel, ISavable, IDisposable, IResultUpdateRegister
{
#region Private Fields
private static readonly string ClassName = nameof(MainViewModel);
private bool _isQueryRunning;
private Query _lastQuery;
private bool _previousIsHomeQuery;
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;
private readonly FlowLauncherJsonStorageTopMostRecord _topMostRecord;
private readonly History _history;
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
private ChannelWriter<ResultsForUpdate> _resultsUpdateChannelWriter;
private Task _resultsViewUpdateTask;
private readonly IReadOnlyList<Result> _emptyResult = new List<Result>();
private readonly IReadOnlyList<DialogJumpResult> _emptyDialogJumpResult = new List<DialogJumpResult>();
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
public MainViewModel()
{
_queryTextBeforeLeaveResults = "";
_queryText = "";
_lastQuery = new Query();
_ignoredQueryText = null; // null as invalid value
Settings = Ioc.Default.GetRequiredService<Settings>();
Settings.PropertyChanged += (_, args) =>
{
switch (args.PropertyName)
{
case nameof(Settings.WindowSize):
OnPropertyChanged(nameof(MainWindowWidth));
break;
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;
case nameof(Settings.AlwaysStartEn):
OnPropertyChanged(nameof(StartWithEnglishMode));
break;
case nameof(Settings.OpenResultModifiers):
OnPropertyChanged(nameof(OpenResultCommandModifiers));
break;
case nameof(Settings.PreviewHotkey):
OnPropertyChanged(nameof(PreviewHotkey));
break;
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;
case nameof(Settings.SelectNextItemHotkey):
OnPropertyChanged(nameof(SelectNextItemHotkey));
break;
case nameof(Settings.SelectNextItemHotkey2):
OnPropertyChanged(nameof(SelectNextItemHotkey2));
break;
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;
case nameof(Settings.SettingWindowHotkey):
OnPropertyChanged(nameof(SettingWindowHotkey));
break;
case nameof(Settings.OpenHistoryHotkey):
OnPropertyChanged(nameof(OpenHistoryHotkey));
break;
}
};
_historyItemsStorage = new FlowLauncherJsonStorage<History>();
_userSelectedRecordStorage = new FlowLauncherJsonStorage<UserSelectedRecord>();
_topMostRecord = new FlowLauncherJsonStorageTopMostRecord();
_history = _historyItemsStorage.Load();
_history.PopulateHistoryFromLegacyHistory();
_userSelectedRecord = _userSelectedRecordStorage.Load();
ContextMenu = new ResultsViewModel(Settings, this)
{
LeftClickResultCommand = OpenResultCommand,
RightClickResultCommand = LoadContextMenuCommand,
IsPreviewOn = Settings.AlwaysPreview
};
Results = new ResultsViewModel(Settings, this)
{
LeftClickResultCommand = OpenResultCommand,
RightClickResultCommand = LoadContextMenuCommand,
IsPreviewOn = Settings.AlwaysPreview
};
History = new ResultsViewModel(Settings, this)
{
LeftClickResultCommand = OpenResultCommand,
RightClickResultCommand = LoadContextMenuCommand,
IsPreviewOn = Settings.AlwaysPreview
};
_selectedResults = Results;
Results.PropertyChanged += (o, args) =>
{
switch (args.PropertyName)
{
case nameof(Results.SelectedItem):
_selectedItemFromQueryResults = true;
PreviewSelectedItem = Results.SelectedItem;
_ = UpdatePreviewAsync();
break;
}
};
History.PropertyChanged += (o, args) =>
{
switch (args.PropertyName)
{
case nameof(History.SelectedItem):
_selectedItemFromQueryResults = false;
PreviewSelectedItem = History.SelectedItem;
_ = UpdatePreviewAsync();
break;
}
};
RegisterViewUpdate();
_ = RegisterClockAndDateUpdateAsync();
ThemeManager.Current.ActualApplicationThemeChanged += ThemeManager_ActualApplicationThemeChanged;
}
private void ThemeManager_ActualApplicationThemeChanged(ThemeManager sender, object args)
{
ActualApplicationThemeChanged?.Invoke(
Application.Current,
new ActualApplicationThemeChangedEventArgs()
{
IsDark = sender.ActualApplicationTheme == ApplicationTheme.Dark
});
}
private void RegisterViewUpdate()
{
var resultUpdateChannel = Channel.CreateUnbounded<ResultsForUpdate>();
_resultsUpdateChannelWriter = resultUpdateChannel.Writer;
_resultsViewUpdateTask =
Task.Run(UpdateActionAsync).ContinueWith(continueAction,
CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted, TaskScheduler.Default);
async Task UpdateActionAsync()
{
var queue = new Dictionary<string, ResultsForUpdate>();
var channelReader = resultUpdateChannel.Reader;
// it is not supposed to be false because it won't be complete
while (await channelReader.WaitToReadAsync())
{
await Task.Delay(20);
while (channelReader.TryRead(out var item))
{
if (!item.Token.IsCancellationRequested)
{
// Indicate if to clear existing results so to show only ones from plugins with action keywords
var query = item.Query;
var currentIsHomeQuery = query.IsHomeQuery;
var shouldClearExistingResults = ShouldClearExistingResultsForQuery(query, currentIsHomeQuery);
_lastQuery = item.Query;
_previousIsHomeQuery = currentIsHomeQuery;
// If the queue already has the item, we need to pass the shouldClearExistingResults flag
if (queue.TryGetValue(item.ID, out var existingItem))
{
item.ShouldClearExistingResults = shouldClearExistingResults || existingItem.ShouldClearExistingResults;
}
else
{
item.ShouldClearExistingResults = shouldClearExistingResults;
}
queue[item.ID] = item;
}
}
UpdateResultView(queue.Values);
queue.Clear();
}
if (!_disposed)
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}");
_resultsViewUpdateTask =
Task.Run(UpdateActionAsync).ContinueWith(continueAction, CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted, TaskScheduler.Default);
#endif
}
}
public void RegisterResultsUpdatedEvent(PluginPair pair)
{
if (pair.Plugin is not IResultUpdated plugin) return;
plugin.ResultsUpdated += (s, e) =>
{
if (e.Query.RawQuery != QueryText || e.Token.IsCancellationRequested)
{
return;
}
var token = e.Token == default ? _updateToken : e.Token;
IReadOnlyList<Result> resultsCopy;
if (e.Results == null)
{
resultsCopy = _emptyResult;
}
else
{
// make a clone to avoid possible issue that plugin will also change the list and items when updating view model
resultsCopy = DeepCloneResults(e.Results, false, 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;
App.API.LogDebug(ClassName, $"Update results for plugin <{pair.Metadata.Name}>");
if (!_resultsUpdateChannelWriter.TryWrite(new ResultsForUpdate(resultsCopy, pair.Metadata, e.Query,
token)))
{
App.API.LogError(ClassName, "Unable to add item to Result Update Queue");
}
};
}
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(Localize.success(),
Localize.completedSuccessfully());
}
[RelayCommand]
private void LoadHistory()
{
if (QueryResultsSelected())
{
SelectedResults = History;
History.SelectedIndex = _history.LastOpenedHistoryItems.Count - 1;
}
else
{
SelectedResults = Results;
}
}
[RelayCommand]
public void ReQuery()
{
if (QueryResultsSelected())
{
// When we are re-querying, we should not delay the query
_ = QueryResultsAsync(false, isReQuery: true);
}
}
public void ReQuery(bool reselect)
{
BackToQueryResults();
// When we are re-querying, we should not delay the query
_ = QueryResultsAsync(false, isReQuery: true, reSelect: reselect);
}
[RelayCommand]
public void ReverseHistory()
{
var historyItems = _history.LastOpenedHistoryItems;
if (historyItems.Count > 0)
{
ChangeQueryText(historyItems[^lastHistoryIndex].Query);
if (lastHistoryIndex < historyItems.Count)
{
lastHistoryIndex++;
}
}
}
[RelayCommand]
public void ForwardHistory()
{
var historyItems = _history.LastOpenedHistoryItems;
if (historyItems.Count > 0)
{
ChangeQueryText(historyItems[^lastHistoryIndex].Query);
if (lastHistoryIndex > 1)
{
lastHistoryIndex--;
}
}
}
[RelayCommand]
private void LoadContextMenu()
{
// For Dialog Jump and right click mode, we need to navigate to the path
if (_isDialogJump && Settings.DialogJumpResultBehaviour == DialogJumpResultBehaviours.RightClick)
{
if (SelectedResults.SelectedItem != null && DialogWindowHandle != nint.Zero)
{
var result = SelectedResults.SelectedItem.Result;
if (result is DialogJumpResult dialogJumpResult)
{
Win32Helper.SetForegroundWindow(DialogWindowHandle);
_ = Task.Run(() => DialogJump.JumpToPathAsync(DialogWindowHandle, dialogJumpResult.DialogJumpPath));
}
}
return;
}
// For query mode, we load context menu
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;
}
}
[RelayCommand]
private void Backspace(object index)
{
var query = QueryBuilder.Build(QueryText.Trim(), PluginManager.GetNonGlobalPlugins());
// GetPreviousExistingDirectory does not require trailing '\', otherwise will return empty string
var path = FilesFolders.GetPreviousExistingDirectory((_) => true, query.Search.TrimEnd('\\'));
var actionKeyword = string.IsNullOrEmpty(query.ActionKeyword) ? string.Empty : $"{query.ActionKeyword} ";
ChangeQueryText($"{actionKeyword}{path}");
}
[RelayCommand]
private void AutocompleteQuery()
{
var result = SelectedResults.SelectedItem?.Result;
if (result != null && QueryResultsSelected()) // SelectedItem returns null if selection is empty.
{
var autoCompleteText = result.Title;
if (!string.IsNullOrEmpty(result.AutoCompleteText))
{
autoCompleteText = result.AutoCompleteText;
}
else if (!string.IsNullOrEmpty(SelectedResults.SelectedItem?.QuerySuggestionText))
{
//var defaultSuggestion = SelectedResults.SelectedItem.QuerySuggestionText;
//// check if result.actionkeywordassigned is empty
//if (!string.IsNullOrEmpty(result.ActionKeywordAssigned))
//{
// autoCompleteText = $"{result.ActionKeywordAssigned} {defaultSuggestion}";
//}
autoCompleteText = SelectedResults.SelectedItem.QuerySuggestionText;
}
var specialKeyState = GlobalHotkey.CheckModifiers();
if (specialKeyState.ShiftPressed)
{
autoCompleteText = result.SubTitle;
}
ChangeQueryText(autoCompleteText);
}
}
[RelayCommand]
private async Task OpenResultAsync(string index)
{
// Must check query results selected before executing the action
var queryResultsSelected = QueryResultsSelected();
var results = SelectedResults;
if (index is not null)
{
results.SelectedIndex = int.Parse(index);
}
var result = results.SelectedItem?.Result;
if (result == null)
{
return;
}
// For Dialog Jump and left click mode, we need to navigate to the path
if (_isDialogJump && Settings.DialogJumpResultBehaviour == DialogJumpResultBehaviours.LeftClick)
{
if (result is DialogJumpResult dialogJumpResult)
{
Win32Helper.SetForegroundWindow(DialogWindowHandle);
_ = Task.Run(() => DialogJump.JumpToPathAsync(DialogWindowHandle, dialogJumpResult.DialogJumpPath));
}
else
{
App.API.LogError(ClassName, "DialogJumpResult expected but got a different result type.");
}
}
// For query mode, we execute the result
else
{
var hideWindow = await result.ExecuteAsync(new ActionContext
{
// not null means pressing modifier key + number, should ignore the modifier key
SpecialKeyState = index is not null ? SpecialKeyState.Default : GlobalHotkey.CheckModifiers()
}).ConfigureAwait(false);
if (hideWindow)
{
Hide();
}
}
// Record user selected result for result ranking
_userSelectedRecord.Add(result);
// Add item to history only if it is from results but not context menu or history
if (queryResultsSelected)
{
_history.Add(result);
lastHistoryIndex = 1;
}
}
private static IReadOnlyList<Result> DeepCloneResults(IReadOnlyList<Result> results, bool isDialogJump, CancellationToken token = default)
{
var resultsCopy = new List<Result>();
if (isDialogJump)
{
foreach (var result in results.ToList())
{
if (token.IsCancellationRequested) break;
var resultCopy = ((DialogJumpResult)result).Clone();
resultsCopy.Add(resultCopy);
}
}
else
{
foreach (var result in results.ToList())
{
if (token.IsCancellationRequested) break;
var resultCopy = result.Clone();
resultsCopy.Add(resultCopy);
}
}
return resultsCopy;
}
#endregion
#region BasicCommands
[RelayCommand]
private void OpenSetting()
{
App.API.OpenSettingDialog();
}
[RelayCommand]
private void SelectHelp()
{
App.API.OpenUrl("https://www.flowlauncher.com/docs/#/usage-tips");
}
[RelayCommand]
private void SelectFirstResult()
{
SelectedResults.SelectFirstResult();
}
[RelayCommand]
private void SelectLastResult()
{
SelectedResults.SelectLastResult();
}
[RelayCommand]
private void SelectPrevPage()
{
SelectedResults.SelectPrevPage();
}
[RelayCommand]
private void SelectNextPage()
{
SelectedResults.SelectNextPage();
}
[RelayCommand]
private void SelectPrevItem()
{
var historyItems = _history.LastOpenedHistoryItems;
if (QueryResultsSelected() // Results selected
&& string.IsNullOrEmpty(QueryText) // No input
&& 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.
&& historyItems.Count > 0) // Have history items
{
lastHistoryIndex = 1;
ReverseHistory();
}
else
{
SelectedResults.SelectPrevResult();
}
}
[RelayCommand]
private void SelectNextItem()
{
SelectedResults.SelectNextResult();
}
[RelayCommand]
private void Esc()
{
if (!QueryResultsSelected())
{
SelectedResults = Results;
}
else
{
Hide();
}
}
public void BackToQueryResults()
{
if (!QueryResultsSelected())
{
SelectedResults = Results;
}
}
[RelayCommand]
public void ToggleGameMode()
{
GameModeStatus = !GameModeStatus;
}
[RelayCommand]
public void CopyAlternative()
{
var result = Results.SelectedItem?.Result?.CopyText;
if (result != null)
{
App.API.CopyToClipboard(result, directCopy: false);
}
}
#endregion
#region ViewModel Properties
public Settings Settings { get; }
public string ClockText { get; private set; }
public string DateText { get; private set; }
public ResultsViewModel Results { get; private set; }
public ResultsViewModel ContextMenu { get; private set; }
public ResultsViewModel History { get; private set; }
public bool GameModeStatus { get; set; } = false;
private string _queryText;
public string QueryText
{
get => _queryText;
set
{
_queryText = value;
OnPropertyChanged();
}
}
[RelayCommand]
private void IncreaseWidth()
{
MainWindowWidth += 100;
Settings.WindowLeft -= 50;
OnPropertyChanged(nameof(MainWindowWidth));
}
[RelayCommand]
private void DecreaseWidth()
{
if (MainWindowWidth - 100 < 400 || MainWindowWidth == 400)
{
MainWindowWidth = 400;
}
else
{
MainWindowWidth -= 100;
Settings.WindowLeft += 50;
}
OnPropertyChanged(nameof(MainWindowWidth));
}
[RelayCommand]
private void IncreaseMaxResult()
{
if (Settings.MaxResultsToShow == 17)
return;
Settings.MaxResultsToShow += 1;
}
[RelayCommand]
private void DecreaseMaxResult()
{
if (Settings.MaxResultsToShow == 2)
return;
Settings.MaxResultsToShow -= 1;
}
/// <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)
{
// 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
if (!Application.Current.Dispatcher.CheckAccess())
{
await Application.Current.Dispatcher.InvokeAsync(() => ChangeQueryTextAsync(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
await QueryAsync(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
await QueryAsync(false, isReQuery: true);
}
QueryTextCursorMovedToEnd = true;
}
public bool LastQuerySelected { get; set; }
// This is not a reliable indicator of the cursor's position, it is manually set for a specific purpose.
public bool QueryTextCursorMovedToEnd { get; set; }
private ResultsViewModel _selectedResults;
private ResultsViewModel SelectedResults
{
get => _selectedResults;
set
{
var isReturningFromQueryResults = QueryResultsSelected();
var isReturningFromContextMenu = ContextMenuSelected();
var isReturningFromHistory = HistorySelected();
_selectedResults = value;
if (QueryResultsSelected())
{
Results.Visibility = Visibility.Visible;
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);
}
// 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
{
Results.Visibility = Visibility.Collapsed;
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
QueryText = string.Empty;
// When we are changing query because selected results are changed to history or context menu,
// we should not delay the query
Query(false);
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;
}
}
}
}
}
public Visibility ShowCustomizedPreview
=> InternalPreviewVisible && PreviewSelectedItem?.Result.PreviewPanel != null ? Visibility.Visible : Visibility.Collapsed;
public UserControl CustomizedPreviewControl
=> ShowCustomizedPreview == Visibility.Visible ? PreviewSelectedItem?.Result.PreviewPanel.Value : null;
public Visibility ProgressBarVisibility { get; set; }
public Visibility MainWindowVisibility { get; set; }
// 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;
public event ActualApplicationThemeChangedEventHandler ActualApplicationThemeChanged;
public Visibility ClockPanelVisibility { get; set; }
public Visibility SearchIconVisibility { get; set; }
public double ClockPanelOpacity { get; set; } = 1;
public double SearchIconOpacity { get; set; } = 1;
private string _placeholderText;
public string PlaceholderText
{
get => string.IsNullOrEmpty(_placeholderText) ? Localize.queryTextBoxPlaceholder() : _placeholderText;
set
{
_placeholderText = value;
OnPropertyChanged();
}
}
public double MainWindowWidth
{
get => Settings.WindowSize;
set
{
if (!MainWindowVisibilityStatus) return;
Settings.WindowSize = value;
}
}
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;
public string PluginIconPath { get; set; } = null;
public string OpenResultCommandModifiers => Settings.OpenResultModifiers;
private static string VerifyOrSetDefaultHotkey(string hotkey, string defaultHotkey)
{
try
{
var converter = new KeyGestureConverter();
var key = (KeyGesture)converter.ConvertFromString(hotkey);
}
catch (Exception e) when (e is NotSupportedException || e is InvalidEnumArgumentException)
{
return defaultHotkey;
}
return hotkey;
}
public string PreviewHotkey => VerifyOrSetDefaultHotkey(Settings.PreviewHotkey, "F1");
public string AutoCompleteHotkey => VerifyOrSetDefaultHotkey(Settings.AutoCompleteHotkey, "Ctrl+Tab");
public string AutoCompleteHotkey2 => VerifyOrSetDefaultHotkey(Settings.AutoCompleteHotkey2, "");
public string SelectNextItemHotkey => VerifyOrSetDefaultHotkey(Settings.SelectNextItemHotkey, "Tab");
public string SelectNextItemHotkey2 => VerifyOrSetDefaultHotkey(Settings.SelectNextItemHotkey2, "");
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");
public string SettingWindowHotkey => VerifyOrSetDefaultHotkey(Settings.SettingWindowHotkey, "Ctrl+I");
public string OpenHistoryHotkey => VerifyOrSetDefaultHotkey(Settings.OpenHistoryHotkey, "Ctrl+H");
public string CycleHistoryUpHotkey => VerifyOrSetDefaultHotkey(Settings.CycleHistoryUpHotkey, "Alt+Up");
public string CycleHistoryDownHotkey => VerifyOrSetDefaultHotkey(Settings.CycleHistoryDownHotkey, "Alt+Down");
public bool StartWithEnglishMode => Settings.AlwaysStartEn;
#endregion
#region Preview
private static readonly int ResultAreaColumnPreviewShown = 1;
private static readonly int ResultAreaColumnPreviewHidden = 3;
private bool? _selectedItemFromQueryResults;
private ResultViewModel _previewSelectedItem;
public ResultViewModel PreviewSelectedItem
{
get => _previewSelectedItem;
set
{
_previewSelectedItem = value;
OnPropertyChanged();
}
}
public bool InternalPreviewVisible
{
get
{
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;
#endif
}
}
public int ResultAreaColumn { get; set; } = ResultAreaColumnPreviewShown;
// This is not a reliable indicator of whether external preview is visible due to the
// ability of manually closing/exiting the external preview program which, does not inform flow that
// preview is no longer available.
public bool ExternalPreviewVisible { get; private set; }
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();
_ = OpenExternalPreviewAsync(path);
break;
case true
when !CanExternalPreviewSelectedResult(out var _):
if (ExternalPreviewVisible)
await CloseExternalPreviewAsync();
ShowInternalPreview();
break;
case false:
ShowInternalPreview();
break;
}
}
private void HidePreview()
{
if (PluginManager.UseExternalPreview())
_ = CloseExternalPreviewAsync();
if (InternalPreviewVisible)
HideInternalPreview();
}
[RelayCommand]
private void TogglePreview()
{
if (InternalPreviewVisible || ExternalPreviewVisible)
{
HidePreview();
}
else
{
_ = ShowPreviewAsync();
}
}
private async Task OpenExternalPreviewAsync(string path, bool sendFailToast = true)
{
await PluginManager.OpenExternalPreviewAsync(path, sendFailToast).ConfigureAwait(false);
ExternalPreviewVisible = true;
}
private async Task CloseExternalPreviewAsync()
{
await PluginManager.CloseExternalPreviewAsync().ConfigureAwait(false);
ExternalPreviewVisible = false;
}
private static async Task SwitchExternalPreviewAsync(string path, bool sendFailToast = true)
{
await PluginManager.SwitchExternalPreviewAsync(path, sendFailToast).ConfigureAwait(false);
}
private void ShowInternalPreview()
{
ResultAreaColumn = ResultAreaColumnPreviewShown;
PreviewSelectedItem?.LoadPreviewImage();
}
private void HideInternalPreview()
{
ResultAreaColumn = ResultAreaColumnPreviewHidden;
}
public void ResetPreview()
{
switch (Settings.AlwaysPreview)
{
case true
when PluginManager.AllowAlwaysPreview() && CanExternalPreviewSelectedResult(out var path):
_ = OpenExternalPreviewAsync(path);
break;
case true:
ShowInternalPreview();
break;
case false:
HidePreview();
break;
}
}
private async Task UpdatePreviewAsync()
{
switch (PluginManager.UseExternalPreview())
{
case true
when CanExternalPreviewSelectedResult(out var path):
if (ExternalPreviewVisible)
{
_ = SwitchExternalPreviewAsync(path, false);
}
else if (InternalPreviewVisible)
{
HideInternalPreview();
_ = OpenExternalPreviewAsync(path);
}
break;
case true
when !CanExternalPreviewSelectedResult(out var _):
if (ExternalPreviewVisible)
{
await CloseExternalPreviewAsync();
ShowInternalPreview();
}
break;
case false
when InternalPreviewVisible:
PreviewSelectedItem?.LoadPreviewImage();
break;
}
}
private bool CanExternalPreviewSelectedResult(out string path)
{
path = QueryResultsPreviewed() ? Results.SelectedItem?.Result?.Preview.FilePath : string.Empty;
return !string.IsNullOrEmpty(path);
}
private bool QueryResultsPreviewed()
{
var previewed = PreviewSelectedItem == Results.SelectedItem;
return previewed;
}
#endregion
#region Query
public void QueryResults()
{
_ = QueryResultsAsync(false);
}
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();
}
}
private async Task QueryAsync(bool searchDelay, bool isReQuery = false)
{
if (QueryResultsSelected())
{
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.
{
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));
}
if (!string.IsNullOrEmpty(query))
{
var filtered = results.Select(x => x.Clone()).Where
(
r =>
{
var match = App.API.FuzzySearch(query, r.Title);
if (!match.IsSearchPrecisionScoreMet())
{
match = App.API.FuzzySearch(query, r.SubTitle);
}
if (!match.IsSearchPrecisionScoreMet()) return false;
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();
var results = GetHistoryItems(_history.LastOpenedHistoryItems);
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 List<Result> GetHistoryItems(IEnumerable<LastOpenedHistoryItem> historyItems)
{
var results = new List<Result>();
if (Settings.HistoryStyle == HistoryStyle.Query)
{
foreach (var h in historyItems)
{
var result = new Result
{
Title = Localize.executeQuery(h.Query),
SubTitle = Localize.lastExecuteTime(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);
}
}
else
{
foreach (var h in historyItems)
{
var result = new Result
{
Title = string.IsNullOrEmpty(h.Title) ? // Old migrated history items have no title
Localize.executeQuery(h.Query) :
h.Title,
SubTitle = Localize.lastExecuteTime(h.ExecutedDateTime),
IcoPath = Settings.ShowBadges ? h.IcoPath : Constant.HistoryIcon,
BadgeIcoPath = Settings.ShowBadges ? Constant.HistoryIcon : h.IcoPath,
ShowBadge = Settings.ShowBadges,
OriginQuery = new Query { RawQuery = h.Query },
AsyncAction = async c =>
{
var reflectResult = await ResultHelper.PopulateResultsAsync(h);
if (reflectResult != null)
{
// Record the user selected record for result ranking
_userSelectedRecord.Add(reflectResult);
// Since some actions may need to hide the Flow window to execute
// So let us populate the results of them
return await reflectResult.ExecuteAsync(c);
}
// If we cannot get the result, fallback to re-query
App.API.BackToQueryResults();
App.API.ChangeQuery(h.Query);
return false;
},
Glyph = Settings.ShowBadges ?
h.Glyph is not null ? h.Glyph : null
: 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();
App.API.LogDebug(ClassName, $"Start query with text: <{QueryText}>");
var query = await ConstructQueryAsync(QueryText, Settings.CustomShortcuts, Settings.BuiltinShortcuts);
if (query == null) // shortcut expanded
{
ClearResults();
return;
}
App.API.LogDebug(ClassName, $"Start query with ActionKeyword <{query.ActionKeyword}> and RawQuery <{query.RawQuery}>");
var currentIsHomeQuery = query.IsHomeQuery;
var currentIsDialogJump = _isDialogJump;
// Do not show home page for Dialog Jump window
if (currentIsHomeQuery && currentIsDialogJump)
{
ClearResults();
return;
}
_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;
if (currentCancellationToken.IsCancellationRequested) return;
// Update the query's IsReQuery property to true if this is a re-query
query.IsReQuery = isReQuery;
ICollection<PluginPair> plugins = Array.Empty<PluginPair>();
if (currentIsHomeQuery)
{
if (Settings.ShowHomePage)
{
plugins = PluginManager.ValidPluginsForHomeQuery();
}
PluginIconPath = null;
PluginIconSource = null;
SearchIconVisibility = Visibility.Visible;
}
else
{
plugins = PluginManager.ValidPluginsForQuery(query, currentIsDialogJump);
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))
{
// Wait 15 millisecond for query change in global query
// if query changes, return so that it won't be calculated
await Task.Delay(15, currentCancellationToken);
if (currentCancellationToken.IsCancellationRequested) return;
}*/
_ = 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 (_isQueryRunning)
{
ProgressBarVisibility = Visibility.Visible;
}
},
currentCancellationToken,
TaskContinuationOptions.NotOnCanceled,
TaskScheduler.Default);
// plugins are ICollection, meaning LINQ will get the Count and preallocate Array
Task[] tasks;
if (currentIsHomeQuery)
{
if (ShouldClearExistingResultsForNonQuery(plugins))
{
Results.Clear();
App.API.LogDebug(ClassName, $"Existing results are cleared for non-query");
}
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
{
// 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
}
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 (!currentCancellationToken.IsCancellationRequested)
{
// update to hidden if this is still the current query
ProgressBarVisibility = Visibility.Hidden;
}
// Local function
void ClearResults()
{
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;
}
async Task QueryTaskAsync(PluginPair plugin, CancellationToken token)
{
App.API.LogDebug(ClassName, $"Wait for querying plugin <{plugin.Metadata.Name}>");
if (searchDelay && !currentIsHomeQuery) // Do not delay for home query
{
var searchDelayTime = plugin.Metadata.SearchDelayTime ?? Settings.SearchDelayTime;
await Task.Delay(searchDelayTime, token);
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();
IReadOnlyList<Result> results = currentIsDialogJump ?
await PluginManager.QueryDialogJumpForPluginAsync(plugin, query, token) :
currentIsHomeQuery ?
await PluginManager.QueryHomeForPluginAsync(plugin, query, token) :
await PluginManager.QueryForPluginAsync(plugin, query, token);
if (token.IsCancellationRequested) return;
IReadOnlyList<Result> resultsCopy;
if (results == null)
{
resultsCopy = currentIsDialogJump ? _emptyDialogJumpResult : _emptyResult;
}
else
{
// make a copy of results to avoid possible issue that FL changes some properties of the records, like score, etc.
resultsCopy = DeepCloneResults(results, currentIsDialogJump, token);
}
foreach (var result in resultsCopy)
{
if (string.IsNullOrEmpty(result.BadgeIcoPath))
{
result.BadgeIcoPath = plugin.Metadata.IcoPath;
}
}
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.LastOpenedHistoryItems.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, reSelect)))
{
App.API.LogError(ClassName, "Unable to add item to Result Update Queue");
}
}
}
private async Task<Query> ConstructQueryAsync(string queryText, IEnumerable<CustomShortcutModel> customShortcuts,
IEnumerable<BaseBuiltinShortcutModel> builtInShortcuts)
{
if (string.IsNullOrWhiteSpace(queryText))
{
return QueryBuilder.Build(string.Empty, PluginManager.GetNonGlobalPlugins());
}
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))
{
if (queryBuilder.Equals(shortcut.Key))
{
queryBuilder.Replace(shortcut.Key, shortcut.Expand());
}
queryBuilder.Replace('@' + shortcut.Key, shortcut.Expand());
}
// Applying builtin shortcuts
await BuildQueryAsync(builtInShortcuts, queryBuilder, queryBuilderTmp);
return QueryBuilder.Build(queryBuilder.ToString().Trim(), PluginManager.GetNonGlobalPlugins());
}
private async Task BuildQueryAsync(IEnumerable<BaseBuiltinShortcutModel> builtInShortcuts,
StringBuilder queryBuilder, StringBuilder queryBuilderTmp)
{
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));
}
}
/// <summary>
/// Determines whether the existing search results should be cleared based on the current query and the previous query type.
/// 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)
{
// If previous or current results are from home query, we need to clear them
if (_previousIsHomeQuery || currentIsHomeQuery)
{
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)
{
App.API.LogDebug(ClassName, $"Existing results should be cleared for query");
return true;
}
return false;
}
/// <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)))
{
App.API.LogDebug(ClassName, $"Existing results should be cleared for non-query");
return true;
}
return false;
}
private Result ContextMenuTopMost(Result result)
{
Result menu;
if (_topMostRecord.IsTopMost(result))
{
menu = new Result
{
Title = Localize.cancelTopMostInThisQuery(),
IcoPath = "Images\\down.png",
PluginDirectory = Constant.ProgramDirectory,
Action = _ =>
{
_topMostRecord.Remove(result);
App.API.ShowMsg(Localize.success());
App.API.ReQuery();
return false;
},
Glyph = new GlyphInfo(FontFamily: "/Resources/#Segoe Fluent Icons", Glyph: "\uE74B"),
OriginQuery = result.OriginQuery
};
}
else
{
menu = new Result
{
Title = Localize.setAsTopMostInThisQuery(),
IcoPath = "Images\\up.png",
PluginDirectory = Constant.ProgramDirectory,
Action = _ =>
{
_topMostRecord.AddOrUpdate(result);
App.API.ShowMsg(Localize.success());
App.API.ReQuery();
return false;
},
Glyph = new GlyphInfo(FontFamily: "/Resources/#Segoe Fluent Icons", Glyph: "\uE74A"),
OriginQuery = result.OriginQuery
};
}
return menu;
}
private static Result ContextMenuPluginInfo(Result result)
{
var id = result.PluginID;
var metadata = PluginManager.GetPluginForId(id).Metadata;
var translator = App.API;
var author = Localize.author();
var website = Localize.website();
var version = Localize.version();
var plugin = Localize.plugin();
var title = $"{plugin}: {metadata.Name}";
var icon = metadata.IcoPath;
var subtitle = $"{author} {metadata.Author}";
var menu = new Result
{
Title = title,
IcoPath = icon,
SubTitle = subtitle,
PluginDirectory = metadata.PluginDirectory,
Action = _ =>
{
App.API.OpenUrl(metadata.Website);
return true;
},
OriginQuery = result.OriginQuery
};
return menu;
}
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;
}
internal bool ResultsSelected(ResultsViewModel results)
{
var selected = SelectedResults == results;
return selected;
}
#endregion
#region Hotkey
public void ToggleFlowLauncher()
{
if (!MainWindowVisibilityStatus)
{
Show();
}
else
{
Hide();
}
}
/// <summary>
/// Checks if Flow Launcher should ignore any hotkeys
/// </summary>
public bool ShouldIgnoreHotkeys()
{
return Settings.IgnoreHotkeysOnFullscreen && Win32Helper.IsForegroundWindowFullscreen() || GameModeStatus;
}
#endregion
#region Dialog Jump
public nint DialogWindowHandle { get; private set; } = nint.Zero;
private bool _isDialogJump = false;
private bool _previousMainWindowVisibilityStatus;
private CancellationTokenSource _dialogJumpSource;
public void InitializeVisibilityStatus(bool visibilityStatus)
{
_previousMainWindowVisibilityStatus = visibilityStatus;
}
public bool IsDialogJumpWindowUnderDialog()
{
return _isDialogJump && DialogJump.DialogJumpWindowPosition == DialogJumpWindowPositions.UnderDialog;
}
public async Task SetupDialogJumpAsync(nint handle)
{
if (handle == nint.Zero) return;
// Only set flag & reset window once for one file dialog
var dialogWindowHandleChanged = false;
if (DialogWindowHandle != handle)
{
DialogWindowHandle = handle;
_previousMainWindowVisibilityStatus = MainWindowVisibilityStatus;
_isDialogJump = true;
dialogWindowHandleChanged = true;
// If don't give a time, Positioning will be weird
await Task.Delay(300);
}
// If handle is cleared, which means the dialog is closed, clear Dialog Jump state
if (DialogWindowHandle == nint.Zero)
{
_isDialogJump = false;
return;
}
// Initialize Dialog Jump window
if (MainWindowVisibilityStatus)
{
if (dialogWindowHandleChanged)
{
// Only update the position
Application.Current?.Dispatcher.Invoke(() =>
{
(Application.Current?.MainWindow as MainWindow)?.UpdatePosition();
});
_ = ResetWindowAsync();
}
}
else
{
if (DialogJump.DialogJumpWindowPosition == DialogJumpWindowPositions.UnderDialog)
{
// We wait for window to be reset before showing it because if window has results,
// showing it before resetting will cause flickering when results are clearing
if (dialogWindowHandleChanged)
{
await ResetWindowAsync();
}
Show();
}
else
{
if (dialogWindowHandleChanged)
{
_ = ResetWindowAsync();
}
}
}
if (DialogJump.DialogJumpWindowPosition == DialogJumpWindowPositions.UnderDialog)
{
// Cancel the previous Dialog Jump task
_dialogJumpSource?.Cancel();
// Create a new cancellation token source
_dialogJumpSource = new CancellationTokenSource();
_ = Task.Run(() =>
{
try
{
// Check task cancellation
if (_dialogJumpSource.Token.IsCancellationRequested) return;
// Check dialog handle
if (DialogWindowHandle == nint.Zero) return;
// Wait 150ms to check if Dialog Jump window gets the focus
var timeOut = !SpinWait.SpinUntil(() => !Win32Helper.IsForegroundWindow(DialogWindowHandle), 150);
if (timeOut) return;
// Bring focus back to the dialog
Win32Helper.SetForegroundWindow(DialogWindowHandle);
}
catch (Exception e)
{
App.API.LogException(ClassName, "Failed to focus on dialog window", e);
}
});
}
}
#pragma warning disable VSTHRD100 // Avoid async void methods
public async void ResetDialogJump()
{
// Cache original dialog window handle
var dialogWindowHandle = DialogWindowHandle;
// Reset the Dialog Jump state
DialogWindowHandle = nint.Zero;
_isDialogJump = false;
// If dialog window handle is not set, we should not reset the main window visibility
if (dialogWindowHandle == nint.Zero) return;
if (_previousMainWindowVisibilityStatus != MainWindowVisibilityStatus)
{
// We wait for window to be reset before showing it because if window has results,
// showing it before resetting will cause flickering when results are clearing
await ResetWindowAsync();
// Show or hide to change visibility
if (_previousMainWindowVisibilityStatus)
{
Show();
}
else
{
Hide(false);
}
}
else
{
if (_previousMainWindowVisibilityStatus)
{
// Only update the position
Application.Current?.Dispatcher.Invoke(() =>
{
(Application.Current?.MainWindow as MainWindow)?.UpdatePosition();
});
_ = ResetWindowAsync();
}
else
{
_ = ResetWindowAsync();
}
}
}
#pragma warning restore VSTHRD100 // Avoid async void methods
public void HideDialogJump()
{
if (DialogWindowHandle != nint.Zero)
{
if (DialogJump.DialogJumpWindowPosition == DialogJumpWindowPositions.UnderDialog)
{
// Warning: Main window is already in foreground
// This is because if you click popup menus in other applications to hide Dialog Jump window,
// they can steal focus before showing main window
if (MainWindowVisibilityStatus)
{
Hide();
}
}
}
}
// Reset index & preview & selected results & query text
private async Task ResetWindowAsync()
{
lastHistoryIndex = 1;
if (ExternalPreviewVisible)
{
await CloseExternalPreviewAsync();
}
if (!QueryResultsSelected())
{
SelectedResults = Results;
}
await ChangeQueryTextAsync(string.Empty, true);
}
#endregion
#region Public Methods
#pragma warning disable VSTHRD100 // Avoid async void methods
public void Show()
{
// When application is exiting, we should not show the main window
if (App.LoadingOrExiting) return;
// When application is exiting, the Application.Current will be null
Application.Current?.Dispatcher.Invoke(() =>
{
// 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 && !_isDialogJump) ? 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);
}
}
public async void Hide(bool reset = true)
{
if (reset)
{
lastHistoryIndex = 1;
if (ExternalPreviewVisible)
{
await CloseExternalPreviewAsync();
}
BackToQueryResults();
switch (Settings.LastQueryMode)
{
case LastQueryMode.Empty:
await ChangeQueryTextAsync(string.Empty);
break;
case LastQueryMode.Preserved:
case LastQueryMode.Selected:
LastQuerySelected = Settings.LastQueryMode == LastQueryMode.Preserved;
break;
case LastQueryMode.ActionKeywordPreserved:
case LastQueryMode.ActionKeywordSelected:
var newQuery = _lastQuery.ActionKeyword;
if (!string.IsNullOrEmpty(newQuery))
newQuery += " ";
await ChangeQueryTextAsync(newQuery);
if (Settings.LastQueryMode == LastQueryMode.ActionKeywordSelected)
LastQuerySelected = false;
break;
}
}
// When application is exiting, the Application.Current will be null
Application.Current?.Dispatcher.Invoke(() =>
{
// 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 && !_isDialogJump) ? 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);
// Update WPF properties
MainWindowVisibilityStatus = false;
MainWindowVisibility = Visibility.Collapsed;
VisibilityChanged?.Invoke(this, new VisibilityChangedEventArgs { IsVisible = false });
}
#pragma warning restore VSTHRD100 // Avoid async void methods
/// <summary>
/// Save history, user selected records and top most records
/// </summary>
public void Save()
{
_historyItemsStorage.Save();
_userSelectedRecordStorage.Save();
_topMostRecord.Save();
}
/// <summary>
/// To avoid deadlock, this method should not be called from main thread
/// </summary>
public void UpdateResultView(ICollection<ResultsForUpdate> resultsForUpdates)
{
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
foreach (var metaResults in resultsForUpdates)
{
foreach (var result in metaResults.Results)
{
var deviationIndex = _topMostRecord.GetTopMostIndex(result);
if (deviationIndex != -1)
{
// 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;
}
else
{
var priorityScore = metaResults.Metadata.Priority * 150;
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;
}
}
}
}
}
// it should be the same for all results
bool reSelect = resultsForUpdates.First().ReSelectFirstResult;
Results.AddResults(resultsForUpdates, token, reSelect);
}
[System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "<Pending>")]
public void FocusQueryTextBox()
{
// When application is exiting, the Application.Current will be null
Application.Current?.Dispatcher.Invoke(() =>
{
// When application is exiting, the Application.Current will be null
if (Application.Current?.MainWindow is MainWindow window)
{
window.QueryTextBox.Focus();
Keyboard.Focus(window.QueryTextBox);
}
});
}
#endregion
#region IDisposable
private bool _disposed = false;
protected virtual void Dispose(bool disposing)
{
if (!_disposed)
{
if (disposing)
{
_updateSource?.Dispose();
_dialogJumpSource?.Dispose();
_resultsUpdateChannelWriter?.Complete();
if (_resultsViewUpdateTask?.IsCompleted == true)
{
_resultsViewUpdateTask.Dispose();
}
ThemeManager.Current.ActualApplicationThemeChanged -= ThemeManager_ActualApplicationThemeChanged;
_disposed = true;
}
}
}
public void Dispose()
{
// Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
#endregion
}
}