From 1209ca51942948d94d0f6a8624262f15c11b56bf Mon Sep 17 00:00:00 2001 From: Hongtao Zhang Date: Sun, 18 Jan 2026 13:13:05 -0800 Subject: [PATCH] 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 --- .../Converters/CommonConverters.cs | 52 ++++++- .../Helper/TextBlockHelper.cs | 57 ++++++++ Flow.Launcher.Avalonia/MainWindow.axaml | 3 +- .../ViewModel/MainViewModel.cs | 132 ++++++++++++++---- .../ViewModel/ResultViewModel.cs | 7 + .../ViewModel/ResultsViewModel.cs | 12 +- .../Views/ResultListBox.axaml | 25 +++- 7 files changed, 241 insertions(+), 47 deletions(-) create mode 100644 Flow.Launcher.Avalonia/Helper/TextBlockHelper.cs diff --git a/Flow.Launcher.Avalonia/Converters/CommonConverters.cs b/Flow.Launcher.Avalonia/Converters/CommonConverters.cs index 22b6349f3..f0eccbccf 100644 --- a/Flow.Launcher.Avalonia/Converters/CommonConverters.cs +++ b/Flow.Launcher.Avalonia/Converters/CommonConverters.cs @@ -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; /// -/// 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. /// public class HighlightTextConverter : IMultiValueConverter { public object? Convert(IList 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 { Count: > 0 } highlightData) + return new InlineCollection { new Run(text) }; + + var inlines = new InlineCollection(); + var highlightSet = new HashSet(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; } } diff --git a/Flow.Launcher.Avalonia/Helper/TextBlockHelper.cs b/Flow.Launcher.Avalonia/Helper/TextBlockHelper.cs new file mode 100644 index 000000000..32ba97d6e --- /dev/null +++ b/Flow.Launcher.Avalonia/Helper/TextBlockHelper.cs @@ -0,0 +1,57 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Documents; + +namespace Flow.Launcher.Avalonia.Helper; + +/// +/// Attached properties for TextBlock to enable binding Inlines from converters. +/// +public static class TextBlockHelper +{ + /// + /// Attached property for setting formatted text with highlights on a TextBlock. + /// Bind to this with a MultiBinding + HighlightTextConverter to get highlighted search results. + /// + public static readonly AttachedProperty FormattedTextProperty = + AvaloniaProperty.RegisterAttached( + "FormattedText", + typeof(TextBlockHelper)); + + static TextBlockHelper() + { + FormattedTextProperty.Changed.AddClassHandler(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); + } + } + } + } +} diff --git a/Flow.Launcher.Avalonia/MainWindow.axaml b/Flow.Launcher.Avalonia/MainWindow.axaml index 43baeb64f..374e6e843 100644 --- a/Flow.Launcher.Avalonia/MainWindow.axaml +++ b/Flow.Launcher.Avalonia/MainWindow.axaml @@ -86,7 +86,8 @@ IsVisible="{Binding ShowResultsArea}" /> - + diff --git a/Flow.Launcher.Avalonia/ViewModel/MainViewModel.cs b/Flow.Launcher.Avalonia/ViewModel/MainViewModel.cs index df7e83483..65be609ab 100644 --- a/Flow.Launcher.Avalonia/ViewModel/MainViewModel.cs +++ b/Flow.Launcher.Avalonia/ViewModel/MainViewModel.cs @@ -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 _resultsUpdateChannel; + private readonly ChannelWriter _resultsUpdateChannelWriter; + private readonly Task _resultsViewUpdateTask; + public event Action? HideRequested; public event Action? ShowRequested; @@ -76,8 +82,9 @@ public partial class MainViewModel : ObservableObject /// /// 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. /// - 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(); + _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)); + } + + /// + /// Background task that processes result updates with debouncing. + /// Waits 20ms to batch multiple plugin completions into a single UI update. + /// + 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(); @@ -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 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> 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(); } } + +/// +/// Represents a batch of results from a plugin for UI update. +/// Used for channel-based debouncing. +/// +internal readonly struct ResultsForUpdate +{ + public IReadOnlyList Results { get; } + public CancellationToken Token { get; } + + public ResultsForUpdate(IReadOnlyList results, CancellationToken token) + { + Results = results; + Token = token; + } +} diff --git a/Flow.Launcher.Avalonia/ViewModel/ResultViewModel.cs b/Flow.Launcher.Avalonia/ViewModel/ResultViewModel.cs index 632ecb18c..650e55996 100644 --- a/Flow.Launcher.Avalonia/ViewModel/ResultViewModel.cs +++ b/Flow.Launcher.Avalonia/ViewModel/ResultViewModel.cs @@ -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? _titleHighlightData; + + [ObservableProperty] + private IList? _subTitleHighlightData; + /// /// The underlying plugin result. Used for executing actions and accessing additional properties. /// diff --git a/Flow.Launcher.Avalonia/ViewModel/ResultsViewModel.cs b/Flow.Launcher.Avalonia/ViewModel/ResultsViewModel.cs index 1880a652c..32bd7a16c 100644 --- a/Flow.Launcher.Avalonia/ViewModel/ResultsViewModel.cs +++ b/Flow.Launcher.Avalonia/ViewModel/ResultsViewModel.cs @@ -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 } /// - /// 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. /// public void ReplaceResults(IEnumerable 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) diff --git a/Flow.Launcher.Avalonia/Views/ResultListBox.axaml b/Flow.Launcher.Avalonia/Views/ResultListBox.axaml index b07f98abe..8c780cc52 100644 --- a/Flow.Launcher.Avalonia/Views/ResultListBox.axaml +++ b/Flow.Launcher.Avalonia/Views/ResultListBox.axaml @@ -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"> + + + + + ToolTip.Tip="{Binding Title}"> + + + + + + + + ToolTip.Tip="{Binding SubTitle}"> + + + + + + +