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}">
+
+
+
+
+
+
+