Skip to content
7 changes: 7 additions & 0 deletions Flow.Launcher/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1125,6 +1125,13 @@ private void QueryTextBox_TextChanged1(object sender, TextChangedEventArgs e)
{
var textBox = (TextBox)sender;
_viewModel.QueryText = textBox.Text;

// Reset Index when query null
if (string.IsNullOrEmpty(textBox.Text))
{
_viewModel.Results.ResetSelectedIndex();
}

_viewModel.Query(_settings.SearchQueryResultsWithDelay);
}

Expand Down
122 changes: 62 additions & 60 deletions Flow.Launcher/ViewModel/MainViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ public partial class MainViewModel : BaseModel, ISavable, IDisposable
private bool _isQueryRunning;
private Query _lastQuery;
private string _queryTextBeforeLeaveResults;
private bool _blockQueryExecution = false;

private readonly FlowLauncherJsonStorage<History> _historyItemsStorage;
private readonly FlowLauncherJsonStorage<UserSelectedRecord> _userSelectedRecordStorage;
Expand Down Expand Up @@ -638,35 +639,44 @@ private void DecreaseMaxResult()
/// </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)
public void ChangeQueryText(string queryText, bool isReQuery = false, bool suppressQueryExecution = false)
{
_ = ChangeQueryTextAsync(queryText, isReQuery);
_ = ChangeQueryTextAsync(queryText, isReQuery, suppressQueryExecution);
}

/// <summary>
/// Async version of <see cref="ChangeQueryText"/>
/// </summary>
private async Task ChangeQueryTextAsync(string queryText, bool isReQuery = false)
private async Task ChangeQueryTextAsync(string queryText, bool isReQuery = false, bool suppressQueryExecution = false)
{
// Must check access so that we will not block the UI thread which cause window visibility issue
if (!Application.Current.Dispatcher.CheckAccess())
{
await Application.Current.Dispatcher.InvokeAsync(() => ChangeQueryText(queryText, isReQuery));
await Application.Current.Dispatcher.InvokeAsync(() => ChangeQueryText(queryText, isReQuery, suppressQueryExecution));
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);

if (string.IsNullOrEmpty(queryText))
{
Results.ResetSelectedIndex();
}

// Check if we are in the process of changing query text
if (!suppressQueryExecution)
{
// 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)
else if (isReQuery && !suppressQueryExecution)
{
// When we are re-querying, we should not delay the query
await QueryAsync(false, isReQuery: true);
Expand All @@ -681,6 +691,8 @@ private async Task ChangeQueryTextAsync(string queryText, bool isReQuery = false
public bool QueryTextCursorMovedToEnd { get; set; }

private ResultsViewModel _selectedResults;
private ResultViewModel _lastSelectedResultItem;
private int _lastSelectedResultIndex = -1;

private ResultsViewModel SelectedResults
{
Expand All @@ -690,6 +702,14 @@ private ResultsViewModel SelectedResults
var isReturningFromQueryResults = QueryResultsSelected();
var isReturningFromContextMenu = ContextMenuSelected();
var isReturningFromHistory = HistorySelected();

// save the index of the selected index before changing the selected results
if (QueryResultsSelected() && value != Results)
{
_lastSelectedResultItem = Results.SelectedItem;
_lastSelectedResultIndex = Results.SelectedIndex;
}

_selectedResults = value;
if (QueryResultsSelected())
{
Expand All @@ -701,11 +721,31 @@ private ResultsViewModel SelectedResults
// 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)
if (isReturningFromContextMenu && _lastSelectedResultItem != null)
{
_queryText = _queryTextBeforeLeaveResults;
OnPropertyChanged(nameof(QueryText));
QueryTextCursorMovedToEnd = true;
try
{
_blockQueryExecution = true;
// Set querytext without running the query for keep index. if not use this, index will be 0.
_queryText = _queryTextBeforeLeaveResults;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not just change QueryText

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I noted this in the PR description:

If you simply update the queryText, the index resets to 0 when the results are refreshed. The flow goes like this:

  1. Remember the previously selected item
  2. Open the context menu (which clears the query box text)
  3. Close the context menu (the previous query text is restored)

Even if the selected index is correctly remembered and restored at step 3, it becomes pointless because restoring the queryText causes the index to reset to 0 again.

OnPropertyChanged(nameof(QueryText));
QueryTextCursorMovedToEnd = true;
}
finally
{
_blockQueryExecution = false;
}
// restore selected item
if (Results.Results.Contains(_lastSelectedResultItem))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would this be slow for large results? And I am curious when will Results not contains the last selected result item? Why not just use _lastSelectedIndex

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK. Modified it to work using only the index.

{
Results.SelectedItem = _lastSelectedResultItem;
Results.SelectedIndex = Results.Results.IndexOf(_lastSelectedResultItem);
}
else if (_lastSelectedResultIndex >= 0 && _lastSelectedResultIndex < Results.Results.Count)
{
Results.SelectedIndex = _lastSelectedResultIndex;
Results.SelectedItem = Results.Results[_lastSelectedResultIndex];
}
}
else
{
Expand Down Expand Up @@ -1055,6 +1095,8 @@ public void Query(bool searchDelay, bool isReQuery = false)

private async Task QueryAsync(bool searchDelay, bool isReQuery = false)
{
if (_blockQueryExecution)
return;
if (QueryResultsSelected())
{
await QueryResultsAsync(searchDelay, isReQuery);
Expand Down Expand Up @@ -1620,67 +1662,27 @@ public void Save()
/// </summary>
public void UpdateResultView(ICollection<ResultsForUpdate> resultsForUpdates)
{
if (!resultsForUpdates.Any())
if (resultsForUpdates == null || !resultsForUpdates.Any())
return;

// Block the query execution when Open ContextMenu is called
if (!QueryResultsSelected())
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand here. Query shouldn't be executed when context menu is called? It will only call QueryContextMenu?

Copy link
Contributor Author

@onesounds onesounds Apr 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I already mentioned this in the PR description. If you open the context menu while results are still loading, both the context menu and the results panel end up being shown once the loading finishes. Please read the PR description before review.

return;
CancellationToken token;

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)
{
if (_topMostRecord.IsTopMost(result))
{
result.Score = Result.MaxScore;
}
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
// Reselect the first result if the first result is selected
bool reSelect = resultsForUpdates.First().ReSelectFirstResult;

// Update results while remembering the currently selected item
Results.AddResults(resultsForUpdates, token, reSelect);
}

Expand Down
95 changes: 86 additions & 9 deletions Flow.Launcher/ViewModel/ResultsViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ public class ResultsViewModel : BaseModel
private readonly object _collectionLock = new();
private readonly Settings _settings;
private int MaxResults => _settings?.MaxResultsToShow ?? 6;

private ResultViewModel _lastSelectedItem;
private int _lastSelectedIndex = -1;

public ResultsViewModel()
{
Expand Down Expand Up @@ -170,6 +173,19 @@ public void KeepResultsExcept(PluginMetadata metadata)
Results.Update(Results.Where(r => r.Result.PluginID != metadata.ID).ToList());
}

public void ResetSelectedIndex()
{
_lastSelectedIndex = 0;
_lastSelectedItem = null; // prevent accidental reselection of stale item
if (Results.Any())
{
SelectedIndex = 0;
SelectedItem = Results[0];
}
OnPropertyChanged(nameof(SelectedIndex));
OnPropertyChanged(nameof(SelectedItem));
}

/// <summary>
/// To avoid deadlock, this method should not called from main thread
/// </summary>
Expand All @@ -184,36 +200,97 @@ public void AddResults(List<Result> newRawResults, string resultId)
/// </summary>
public void AddResults(ICollection<ResultsForUpdate> resultsForUpdates, CancellationToken token, bool reselect = true)
{
if (resultsForUpdates == null || !resultsForUpdates.Any())
return;

// Save the currently selected item
ResultViewModel lastSelectedItem = null;
int lastSelectedIndex = -1;

Application.Current.Dispatcher.Invoke(() =>
{
if (SelectedItem != null)
{
lastSelectedItem = SelectedItem;
lastSelectedIndex = SelectedIndex;
}
});

_lastSelectedItem = lastSelectedItem;
_lastSelectedIndex = lastSelectedIndex;

// Generate new results
var newResults = NewResults(resultsForUpdates);

if (token.IsCancellationRequested)
return;

// Update results (includes logic for restoring selection)
UpdateResults(newResults, reselect, token);
}

private void UpdateResults(List<ResultViewModel> newResults, bool reselect = true, CancellationToken token = default)
private void UpdateResults(List<ResultViewModel> newResults, bool reselect = true,
CancellationToken token = default)
{
lock (_collectionLock)
{
// update UI in one run, so it can avoid UI flickering
// Update previous results and UI
Results.Update(newResults, token);

// Only perform selection logic if reselect is true
if (reselect && Results.Any())
SelectedItem = Results[0];
{
// If a previously selected item exists and still remains in the list, reselect it
if (_lastSelectedItem != null && Results.Contains(_lastSelectedItem))
{
SelectedItem = _lastSelectedItem;
SelectedIndex = Results.IndexOf(_lastSelectedItem);
}
// If previous index is still within valid range, use that index
else if (_lastSelectedIndex >= 0 && _lastSelectedIndex < Results.Count)
{
SelectedIndex = _lastSelectedIndex;
SelectedItem = Results[SelectedIndex];
}
// If nothing else is valid, select the first item
else if (Results.Count > 0)
{
SelectedItem = Results[0];
SelectedIndex = 0;
}
}
}

// If no item is selected, select the first one
if (Results.Count > 0 && (SelectedIndex == -1 || SelectedItem == null))
{
SelectedIndex = 0;
SelectedItem = Results[0];
}

switch (Visibility)
// Visibility update - fix for related issue
if (Results.Count > 0)
{
case Visibility.Collapsed when Results.Count > 0:
// 1. Always ensure index is valid when there are results
if (SelectedIndex == -1 || SelectedItem == null)
{
SelectedIndex = 0;
SelectedItem = Results[0];
}

// 2. Update visibility
if (Visibility == Visibility.Collapsed)
{
Visibility = Visibility.Visible;
break;
case Visibility.Visible when Results.Count == 0:
Visibility = Visibility.Collapsed;
break;
}
}
else if (Visibility == Visibility.Visible && Results.Count == 0)
{
Visibility = Visibility.Collapsed;
}
}


private List<ResultViewModel> NewResults(List<Result> newRawResults, string resultId)
{
if (newRawResults.Count == 0)
Expand Down
Loading