mirror of
https://github.com/Flow-Launcher/Flow.Launcher.git
synced 2026-03-11 08:54:32 +00:00
Fix flickering & add result highlighting
- Implemented channel-based debouncing (20ms) in MainViewModel to fix result flickering (matches WPF behavior) - Added ResultForUpdate struct and ProcessResultUpdatesAsync for batching updates - Added HighlightTextConverter and TextBlockHelper for bolding matched query terms - Updated ResultListBox to display highlighted title and subtitle
This commit is contained in:
parent
1d945654c1
commit
1209ca5194
7 changed files with 241 additions and 47 deletions
|
|
@ -1,26 +1,64 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Documents;
|
||||
using Avalonia.Data.Converters;
|
||||
using Avalonia.Media;
|
||||
|
||||
namespace Flow.Launcher.Avalonia.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// Converts text with highlight ranges to formatted text with bold highlights.
|
||||
/// This is a simplified version - full implementation would use Avalonia's TextDecorations.
|
||||
/// Converts text with highlight indices to InlineCollection with bold highlights.
|
||||
/// Usage: MultiBinding with [0]=text string, [1]=List<int> of character indices to highlight.
|
||||
/// </summary>
|
||||
public class HighlightTextConverter : IMultiValueConverter
|
||||
{
|
||||
public object? Convert(IList<object?> values, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
// For now, just return the plain text
|
||||
// Full implementation would create formatted inline text with highlights
|
||||
if (values.Count >= 1 && values[0] is string text)
|
||||
if (values.Count < 1 || values[0] is not string text || string.IsNullOrEmpty(text))
|
||||
return new InlineCollection { new Run(string.Empty) };
|
||||
|
||||
// If no highlight data, return plain text as single Run
|
||||
if (values.Count < 2 || values[1] is not IList<int> { Count: > 0 } highlightData)
|
||||
return new InlineCollection { new Run(text) };
|
||||
|
||||
var inlines = new InlineCollection();
|
||||
var highlightSet = new HashSet<int>(highlightData);
|
||||
|
||||
// Build runs by grouping consecutive characters with same highlight state
|
||||
var currentRun = new System.Text.StringBuilder();
|
||||
var currentIsHighlight = highlightSet.Contains(0);
|
||||
|
||||
for (var i = 0; i < text.Length; i++)
|
||||
{
|
||||
return text;
|
||||
var shouldHighlight = highlightSet.Contains(i);
|
||||
|
||||
if (shouldHighlight != currentIsHighlight && currentRun.Length > 0)
|
||||
{
|
||||
// Flush current run
|
||||
inlines.Add(CreateRun(currentRun.ToString(), currentIsHighlight));
|
||||
currentRun.Clear();
|
||||
currentIsHighlight = shouldHighlight;
|
||||
}
|
||||
|
||||
currentRun.Append(text[i]);
|
||||
}
|
||||
return string.Empty;
|
||||
|
||||
// Flush final run
|
||||
if (currentRun.Length > 0)
|
||||
inlines.Add(CreateRun(currentRun.ToString(), currentIsHighlight));
|
||||
|
||||
return inlines;
|
||||
}
|
||||
|
||||
private static Run CreateRun(string text, bool isHighlight)
|
||||
{
|
||||
var run = new Run(text);
|
||||
if (isHighlight)
|
||||
run.FontWeight = FontWeight.Bold;
|
||||
return run;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
57
Flow.Launcher.Avalonia/Helper/TextBlockHelper.cs
Normal file
57
Flow.Launcher.Avalonia/Helper/TextBlockHelper.cs
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Documents;
|
||||
|
||||
namespace Flow.Launcher.Avalonia.Helper;
|
||||
|
||||
/// <summary>
|
||||
/// Attached properties for TextBlock to enable binding Inlines from converters.
|
||||
/// </summary>
|
||||
public static class TextBlockHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Attached property for setting formatted text with highlights on a TextBlock.
|
||||
/// Bind to this with a MultiBinding + HighlightTextConverter to get highlighted search results.
|
||||
/// </summary>
|
||||
public static readonly AttachedProperty<InlineCollection?> FormattedTextProperty =
|
||||
AvaloniaProperty.RegisterAttached<TextBlock, InlineCollection?>(
|
||||
"FormattedText",
|
||||
typeof(TextBlockHelper));
|
||||
|
||||
static TextBlockHelper()
|
||||
{
|
||||
FormattedTextProperty.Changed.AddClassHandler<TextBlock>(OnFormattedTextChanged);
|
||||
}
|
||||
|
||||
public static InlineCollection? GetFormattedText(TextBlock textBlock)
|
||||
=> textBlock.GetValue(FormattedTextProperty);
|
||||
|
||||
public static void SetFormattedText(TextBlock textBlock, InlineCollection? value)
|
||||
=> textBlock.SetValue(FormattedTextProperty, value);
|
||||
|
||||
private static void OnFormattedTextChanged(TextBlock textBlock, AvaloniaPropertyChangedEventArgs e)
|
||||
{
|
||||
textBlock.Inlines?.Clear();
|
||||
|
||||
if (e.NewValue is InlineCollection inlines)
|
||||
{
|
||||
// We need to copy the inlines because they can only belong to one parent
|
||||
foreach (var inline in inlines)
|
||||
{
|
||||
if (inline is Run run)
|
||||
{
|
||||
var newRun = new Run(run.Text)
|
||||
{
|
||||
FontWeight = run.FontWeight
|
||||
};
|
||||
textBlock.Inlines?.Add(newRun);
|
||||
}
|
||||
else
|
||||
{
|
||||
// For other inline types, add directly (may need enhancement)
|
||||
textBlock.Inlines?.Add(inline);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -86,7 +86,8 @@
|
|||
IsVisible="{Binding ShowResultsArea}" />
|
||||
|
||||
<!-- Results Area -->
|
||||
<Border Name="ResultAreaBorder" Classes="resultAreaBorder">
|
||||
<Border Name="ResultAreaBorder" Classes="resultAreaBorder"
|
||||
IsVisible="{Binding ShowResultsArea}">
|
||||
<Grid Name="ResultPreviewArea">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" MinWidth="80" />
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ using System.Collections.Concurrent;
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
|
|
@ -34,6 +35,11 @@ public partial class MainViewModel : ObservableObject
|
|||
private CancellationTokenSource? _queryTokenSource;
|
||||
private bool _pluginsReady;
|
||||
|
||||
// Channel-based debouncing for result updates (matches WPF approach)
|
||||
private readonly Channel<ResultsForUpdate> _resultsUpdateChannel;
|
||||
private readonly ChannelWriter<ResultsForUpdate> _resultsUpdateChannelWriter;
|
||||
private readonly Task _resultsViewUpdateTask;
|
||||
|
||||
public event Action? HideRequested;
|
||||
public event Action? ShowRequested;
|
||||
|
||||
|
|
@ -76,8 +82,9 @@ public partial class MainViewModel : ObservableObject
|
|||
|
||||
/// <summary>
|
||||
/// Whether to show the results/context menu area (separator + list).
|
||||
/// Based on whether we have a non-empty query - NOT on collection count to prevent flickering.
|
||||
/// </summary>
|
||||
public bool ShowResultsArea => HasResults || IsContextMenuViewActive;
|
||||
public bool ShowResultsArea => !string.IsNullOrWhiteSpace(QueryText) || ContextMenu.Results.Count > 0;
|
||||
|
||||
public Settings Settings => _settings;
|
||||
|
||||
|
|
@ -86,6 +93,11 @@ public partial class MainViewModel : ObservableObject
|
|||
_settings = settings;
|
||||
_results = new ResultsViewModel(settings);
|
||||
_contextMenu = new ResultsViewModel(settings);
|
||||
|
||||
// Initialize channel-based debouncing for result updates
|
||||
_resultsUpdateChannel = Channel.CreateUnbounded<ResultsForUpdate>();
|
||||
_resultsUpdateChannelWriter = _resultsUpdateChannel.Writer;
|
||||
_resultsViewUpdateTask = Task.Run(ProcessResultUpdatesAsync);
|
||||
|
||||
_results.PropertyChanged += (s, e) =>
|
||||
{
|
||||
|
|
@ -102,6 +114,51 @@ public partial class MainViewModel : ObservableObject
|
|||
PreviewSelectedItem = _contextMenu.SelectedItem;
|
||||
}
|
||||
};
|
||||
|
||||
// Subscribe to context menu collection changes for ShowResultsArea (context menu still uses count)
|
||||
((System.Collections.Specialized.INotifyCollectionChanged)_contextMenu.Results).CollectionChanged += (s, e) => OnPropertyChanged(nameof(ShowResultsArea));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Background task that processes result updates with debouncing.
|
||||
/// Waits 20ms to batch multiple plugin completions into a single UI update.
|
||||
/// </summary>
|
||||
private async Task ProcessResultUpdatesAsync()
|
||||
{
|
||||
var channelReader = _resultsUpdateChannel.Reader;
|
||||
|
||||
while (await channelReader.WaitToReadAsync())
|
||||
{
|
||||
// Wait 20ms to allow multiple plugin results to arrive
|
||||
await Task.Delay(20);
|
||||
|
||||
// Get the latest snapshot from the channel (discard intermediate ones)
|
||||
ResultsForUpdate? latestUpdate = null;
|
||||
|
||||
while (channelReader.TryRead(out var update))
|
||||
{
|
||||
if (!update.Token.IsCancellationRequested)
|
||||
{
|
||||
latestUpdate = update;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply batched update on UI thread
|
||||
if (latestUpdate.HasValue && !latestUpdate.Value.Token.IsCancellationRequested)
|
||||
{
|
||||
var update = latestUpdate.Value;
|
||||
var sortedResults = update.Results
|
||||
.OrderByDescending(r => r.Score)
|
||||
.ToList();
|
||||
|
||||
await global::Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
if (update.Token.IsCancellationRequested) return;
|
||||
Results.ReplaceResults(sortedResults);
|
||||
HasResults = Results.Results.Count > 0;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
partial void OnActiveViewChanged(ActiveView value)
|
||||
|
|
@ -113,17 +170,17 @@ public partial class MainViewModel : ObservableObject
|
|||
PreviewSelectedItem = value == ActiveView.Results ? Results.SelectedItem : ContextMenu.SelectedItem;
|
||||
}
|
||||
|
||||
partial void OnIsQueryRunningChanged(bool value)
|
||||
{
|
||||
// ShowResultsArea no longer depends on IsQueryRunning - it uses QueryText instead
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
public void TogglePreview()
|
||||
{
|
||||
IsPreviewOn = !IsPreviewOn;
|
||||
}
|
||||
|
||||
partial void OnHasResultsChanged(bool value)
|
||||
{
|
||||
OnPropertyChanged(nameof(ShowResultsArea));
|
||||
}
|
||||
|
||||
public void OnPluginsReady()
|
||||
{
|
||||
_pluginsReady = true;
|
||||
|
|
@ -184,7 +241,12 @@ public partial class MainViewModel : ObservableObject
|
|||
ContextMenu.Clear();
|
||||
}
|
||||
|
||||
partial void OnQueryTextChanged(string value) => _ = QueryAsync();
|
||||
partial void OnQueryTextChanged(string value)
|
||||
{
|
||||
// Notify ShowResultsArea when query text changes (it depends on QueryText)
|
||||
OnPropertyChanged(nameof(ShowResultsArea));
|
||||
_ = QueryAsync();
|
||||
}
|
||||
|
||||
private async Task QueryAsync()
|
||||
{
|
||||
|
|
@ -213,12 +275,22 @@ public partial class MainViewModel : ObservableObject
|
|||
try
|
||||
{
|
||||
var query = QueryBuilder.Build(queryText, PluginManager.NonGlobalPlugins);
|
||||
if (query == null) { HasResults = false; return; }
|
||||
if (query == null)
|
||||
{
|
||||
Results.Clear();
|
||||
HasResults = false;
|
||||
return;
|
||||
}
|
||||
|
||||
var plugins = PluginManager.ValidPluginsForQuery(query, dialogJump: false)
|
||||
.Where(p => !p.Metadata.Disabled).ToList();
|
||||
|
||||
if (plugins.Count == 0) { HasResults = false; return; }
|
||||
if (plugins.Count == 0)
|
||||
{
|
||||
Results.Clear();
|
||||
HasResults = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Use a thread-safe collection to accumulate results from all plugins
|
||||
var allResults = new ConcurrentBag<ResultViewModel>();
|
||||
|
|
@ -235,10 +307,10 @@ public partial class MainViewModel : ObservableObject
|
|||
allResults.Add(r);
|
||||
}
|
||||
|
||||
// Update UI with current accumulated results (progressive update)
|
||||
// Update UI with current accumulated results (progressive update via channel)
|
||||
if (!token.IsCancellationRequested)
|
||||
{
|
||||
await UpdateResultsOnUIThread(allResults, token);
|
||||
_resultsUpdateChannelWriter.TryWrite(new ResultsForUpdate(allResults.ToList(), token));
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -247,7 +319,7 @@ public partial class MainViewModel : ObservableObject
|
|||
// Final update after all plugins complete
|
||||
if (!token.IsCancellationRequested)
|
||||
{
|
||||
await UpdateResultsOnUIThread(allResults, token);
|
||||
_resultsUpdateChannelWriter.TryWrite(new ResultsForUpdate(allResults.ToList(), token));
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
|
|
@ -255,23 +327,6 @@ public partial class MainViewModel : ObservableObject
|
|||
finally { if (!token.IsCancellationRequested) IsQueryRunning = false; }
|
||||
}
|
||||
|
||||
private async Task UpdateResultsOnUIThread(ConcurrentBag<ResultViewModel> allResults, CancellationToken token)
|
||||
{
|
||||
if (token.IsCancellationRequested) return;
|
||||
|
||||
var sortedResults = allResults
|
||||
.OrderByDescending(r => r.Score)
|
||||
.Take(_settings.MaxResultsToShow)
|
||||
.ToList();
|
||||
|
||||
await global::Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
if (token.IsCancellationRequested) return;
|
||||
Results.ReplaceResults(sortedResults);
|
||||
HasResults = Results.Results.Count > 0;
|
||||
});
|
||||
}
|
||||
|
||||
private Task<List<ResultViewModel>> QueryPluginAsync(PluginPair plugin, Query query, CancellationToken token)
|
||||
{
|
||||
// Run entirely on thread pool to avoid blocking UI if plugin has synchronous code
|
||||
|
|
@ -297,7 +352,8 @@ public partial class MainViewModel : ObservableObject
|
|||
IconPath = r.IcoPath ?? plugin.Metadata.IcoPath ?? "",
|
||||
Score = r.Score,
|
||||
PluginResult = r,
|
||||
Glyph = r.Glyph
|
||||
Glyph = r.Glyph,
|
||||
TitleHighlightData = r.TitleHighlightData
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -414,3 +470,19 @@ public partial class MainViewModel : ObservableObject
|
|||
Results.SelectPrevItem();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a batch of results from a plugin for UI update.
|
||||
/// Used for channel-based debouncing.
|
||||
/// </summary>
|
||||
internal readonly struct ResultsForUpdate
|
||||
{
|
||||
public IReadOnlyList<ResultViewModel> Results { get; }
|
||||
public CancellationToken Token { get; }
|
||||
|
||||
public ResultsForUpdate(IReadOnlyList<ResultViewModel> results, CancellationToken token)
|
||||
{
|
||||
Results = results;
|
||||
Token = token;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Media;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
|
@ -30,6 +31,12 @@ public partial class ResultViewModel : ObservableObject
|
|||
[ObservableProperty]
|
||||
private int _score;
|
||||
|
||||
[ObservableProperty]
|
||||
private IList<int>? _titleHighlightData;
|
||||
|
||||
[ObservableProperty]
|
||||
private IList<int>? _subTitleHighlightData;
|
||||
|
||||
/// <summary>
|
||||
/// The underlying plugin result. Used for executing actions and accessing additional properties.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using DynamicData;
|
||||
using DynamicData.Binding;
|
||||
|
|
@ -50,18 +51,19 @@ public partial class ResultsViewModel : ObservableObject, IDisposable
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replace all results with new ones using EditDiff to minimize UI updates.
|
||||
/// Items with matching Title+SubTitle are kept, reducing flickering.
|
||||
/// Replace all results with new ones using atomic Edit to prevent flickering.
|
||||
/// Edit batches changes and fires only one notification at the end.
|
||||
/// </summary>
|
||||
public void ReplaceResults(IEnumerable<ResultViewModel> newResults)
|
||||
{
|
||||
foreach (var r in newResults)
|
||||
var resultsList = newResults.ToList();
|
||||
foreach (var r in resultsList)
|
||||
{
|
||||
r.Settings = _settings;
|
||||
}
|
||||
|
||||
// EditDiff calculates minimal changes needed
|
||||
_sourceList.EditDiff(newResults, ResultViewModelComparer.Instance);
|
||||
// EditDiff calculates minimal changes needed - items with same Title+SubTitle are kept
|
||||
_sourceList.EditDiff(resultsList, ResultViewModelComparer.Instance);
|
||||
|
||||
// Select first item after replacement
|
||||
if (_results.Count > 0)
|
||||
|
|
|
|||
|
|
@ -4,9 +4,14 @@
|
|||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:vm="using:Flow.Launcher.Avalonia.ViewModel"
|
||||
xmlns:helper="using:Flow.Launcher.Avalonia.Helper"
|
||||
xmlns:converters="using:Flow.Launcher.Avalonia.Converters"
|
||||
mc:Ignorable="d" d:DesignWidth="580" d:DesignHeight="300"
|
||||
x:Class="Flow.Launcher.Avalonia.Views.ResultListBox"
|
||||
x:DataType="vm:ResultsViewModel">
|
||||
|
||||
<UserControl.Resources>
|
||||
<converters:HighlightTextConverter x:Key="HighlightTextConverter" />
|
||||
</UserControl.Resources>
|
||||
|
||||
<ListBox Name="ResultsList"
|
||||
Classes="resultListBox"
|
||||
|
|
@ -64,14 +69,26 @@
|
|||
|
||||
<TextBlock Grid.Row="0"
|
||||
Classes="resultTitle"
|
||||
Text="{Binding Title}"
|
||||
ToolTip.Tip="{Binding Title}" />
|
||||
ToolTip.Tip="{Binding Title}">
|
||||
<helper:TextBlockHelper.FormattedText>
|
||||
<MultiBinding Converter="{StaticResource HighlightTextConverter}">
|
||||
<Binding Path="Title" />
|
||||
<Binding Path="TitleHighlightData" />
|
||||
</MultiBinding>
|
||||
</helper:TextBlockHelper.FormattedText>
|
||||
</TextBlock>
|
||||
|
||||
<TextBlock Grid.Row="1"
|
||||
Classes="resultSubTitle"
|
||||
Text="{Binding SubTitle}"
|
||||
IsVisible="{Binding ShowSubTitle}"
|
||||
ToolTip.Tip="{Binding SubTitle}" />
|
||||
ToolTip.Tip="{Binding SubTitle}">
|
||||
<helper:TextBlockHelper.FormattedText>
|
||||
<MultiBinding Converter="{StaticResource HighlightTextConverter}">
|
||||
<Binding Path="SubTitle" />
|
||||
<Binding Path="SubTitleHighlightData" />
|
||||
</MultiBinding>
|
||||
</helper:TextBlockHelper.FormattedText>
|
||||
</TextBlock>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue