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:
Hongtao Zhang 2026-01-18 13:13:05 -08:00
parent 1d945654c1
commit 1209ca5194
7 changed files with 241 additions and 47 deletions

View file

@ -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&lt;int&gt; 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;
}
}

View 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);
}
}
}
}
}

View file

@ -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" />

View file

@ -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;
}
}

View file

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

View file

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

View file

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