diff --git a/Flow.Launcher.Avalonia/Flow.Launcher.Avalonia.csproj b/Flow.Launcher.Avalonia/Flow.Launcher.Avalonia.csproj
index 5972fd030..f79c53587 100644
--- a/Flow.Launcher.Avalonia/Flow.Launcher.Avalonia.csproj
+++ b/Flow.Launcher.Avalonia/Flow.Launcher.Avalonia.csproj
@@ -37,6 +37,7 @@
+
diff --git a/Flow.Launcher.Avalonia/Helper/ImageLoader.cs b/Flow.Launcher.Avalonia/Helper/ImageLoader.cs
new file mode 100644
index 000000000..ac232d033
--- /dev/null
+++ b/Flow.Launcher.Avalonia/Helper/ImageLoader.cs
@@ -0,0 +1,448 @@
+using System;
+using System.Collections.Concurrent;
+using System.IO;
+using System.Net.Http;
+using System.Runtime.InteropServices;
+using System.Threading.Tasks;
+using Avalonia;
+using Avalonia.Media;
+using Avalonia.Media.Imaging;
+using Avalonia.Platform.Storage;
+using Flow.Launcher.Infrastructure.Logger;
+
+namespace Flow.Launcher.Avalonia.Helper;
+
+///
+/// Avalonia-compatible image loader with caching support.
+/// Loads images from file paths, URLs, and data URIs.
+/// Uses Windows Shell API for exe/ico thumbnails.
+///
+public static class ImageLoader
+{
+ private static readonly string ClassName = nameof(ImageLoader);
+
+ // Thread-safe cache
+ private static readonly ConcurrentDictionary _cache = new();
+ private static readonly HttpClient _httpClient = new();
+
+ // Default image (lazy loaded)
+ private static IImage? _defaultImage;
+
+ // Image file extensions that Avalonia can load directly
+ private static readonly string[] DirectLoadExtensions = [".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp"];
+
+ ///
+ /// Default image shown when no icon is available.
+ ///
+ public static IImage? DefaultImage => _defaultImage ??= LoadDefaultImage();
+
+ ///
+ /// Load an image from the given path asynchronously.
+ /// Supports local files, HTTP/HTTPS URLs, and data URIs.
+ /// Use with Avalonia's ^ binding operator: {Binding Image^}
+ ///
+ public static Task LoadAsync(string? path)
+ {
+ if (string.IsNullOrWhiteSpace(path))
+ return Task.FromResult(DefaultImage);
+
+ // Check cache first - return immediately without Task.Run overhead
+ if (_cache.TryGetValue(path, out var cached))
+ return Task.FromResult(cached);
+
+ // Load on background thread to avoid blocking UI
+ return Task.Run(() => LoadCore(path));
+ }
+
+ ///
+ /// Core loading logic - runs on thread pool when not cached.
+ ///
+ private static async Task LoadCore(string path)
+ {
+ // Double-check cache (another thread may have loaded it)
+ if (_cache.TryGetValue(path, out var cached))
+ return cached;
+
+ try
+ {
+ IImage? image = null;
+
+ if (path.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
+ path.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
+ {
+ image = await LoadFromUrlAsync(path);
+ }
+ else if (path.StartsWith("data:image", StringComparison.OrdinalIgnoreCase))
+ {
+ image = LoadFromDataUri(path);
+ }
+ else if (File.Exists(path))
+ {
+ image = LoadFromFile(path);
+ }
+ else if (Directory.Exists(path))
+ {
+ // Folder - get shell icon
+ image = LoadShellThumbnail(path);
+ }
+
+ // Cache the result (even if null, to avoid repeated attempts)
+ image ??= DefaultImage;
+ _cache.TryAdd(path, image);
+
+ return image;
+ }
+ catch (Exception ex)
+ {
+ Log.Debug(ClassName, $"Failed to load image: {path}, Error: {ex.Message}");
+ _cache.TryAdd(path, DefaultImage);
+ return DefaultImage;
+ }
+ }
+
+ private static IImage? LoadFromFile(string path)
+ {
+ try
+ {
+ var ext = Path.GetExtension(path).ToLowerInvariant();
+
+ // For standard image formats, load directly
+ if (Array.Exists(DirectLoadExtensions, e => e == ext))
+ {
+ using var stream = File.OpenRead(path);
+ return new Bitmap(stream);
+ }
+
+ // For exe, dll, ico, lnk, and other files - use Windows Shell API
+ return LoadShellThumbnail(path);
+ }
+ catch (Exception ex)
+ {
+ Log.Debug(ClassName, $"Failed to load file: {path}, Error: {ex.Message}");
+ return null;
+ }
+ }
+
+ ///
+ /// Load thumbnail/icon using Windows Shell API (IShellItemImageFactory).
+ /// Works for exe, dll, ico, folders, and any file type.
+ ///
+ private static IImage? LoadShellThumbnail(string path, int size = 64)
+ {
+ try
+ {
+ var hr = SHCreateItemFromParsingName(path, IntPtr.Zero, typeof(IShellItemImageFactory).GUID, out var shellItem);
+ if (hr != 0 || shellItem == null)
+ {
+ Log.Debug(ClassName, $"SHCreateItemFromParsingName failed for {path}, hr={hr}");
+ return null;
+ }
+
+ try
+ {
+ var imageFactory = (IShellItemImageFactory)shellItem;
+ var sz = new SIZE { cx = size, cy = size };
+
+ // Try to get thumbnail, fall back to icon
+ hr = imageFactory.GetImage(sz, SIIGBF.SIIGBF_BIGGERSIZEOK, out var hBitmap);
+ if (hr != 0 || hBitmap == IntPtr.Zero)
+ {
+ // Fallback to icon only
+ hr = imageFactory.GetImage(sz, SIIGBF.SIIGBF_ICONONLY, out hBitmap);
+ }
+
+ if (hr != 0 || hBitmap == IntPtr.Zero)
+ {
+ Log.Debug(ClassName, $"GetImage failed for {path}, hr={hr}");
+ return null;
+ }
+
+ try
+ {
+ return ConvertHBitmapToAvaloniaBitmap(hBitmap);
+ }
+ finally
+ {
+ DeleteObject(hBitmap);
+ }
+ }
+ finally
+ {
+ Marshal.ReleaseComObject(shellItem);
+ }
+ }
+ catch (Exception ex)
+ {
+ Log.Debug(ClassName, $"Failed to load shell thumbnail: {path}, Error: {ex.Message}");
+ return null;
+ }
+ }
+
+ ///
+ /// Convert Windows HBITMAP to Avalonia Bitmap.
+ ///
+ private static Bitmap? ConvertHBitmapToAvaloniaBitmap(IntPtr hBitmap)
+ {
+ // Get bitmap info
+ var bmp = new BITMAP();
+ if (GetObject(hBitmap, Marshal.SizeOf(), ref bmp) == 0)
+ return null;
+
+ var width = bmp.bmWidth;
+ var height = bmp.bmHeight;
+
+ // Create BITMAPINFO for DIB (top-down, 32-bit BGRA)
+ var bmi = new BITMAPINFO
+ {
+ bmiHeader = new BITMAPINFOHEADER
+ {
+ biSize = (uint)Marshal.SizeOf(),
+ biWidth = width,
+ biHeight = -height, // Negative = top-down DIB
+ biPlanes = 1,
+ biBitCount = 32,
+ biCompression = 0 // BI_RGB
+ }
+ };
+
+ // Allocate buffer for pixel data
+ var stride = width * 4;
+ var bufferSize = stride * height;
+ var buffer = new byte[bufferSize];
+
+ // Get the device context and extract DIB bits
+ var hdc = CreateCompatibleDC(IntPtr.Zero);
+ try
+ {
+ if (GetDIBits(hdc, hBitmap, 0, (uint)height, buffer, ref bmi, 0) == 0)
+ return null;
+
+ // Analyze alpha channel to determine if image has transparency
+ bool hasTransparent = false;
+ bool hasOpaque = false;
+ bool hasPartialAlpha = false;
+
+ for (int i = 3; i < bufferSize; i += 4)
+ {
+ byte a = buffer[i];
+ if (a == 0) hasTransparent = true;
+ else if (a == 255) hasOpaque = true;
+ else hasPartialAlpha = true;
+
+ // Early exit once we know it has alpha
+ if (hasPartialAlpha || (hasTransparent && hasOpaque))
+ break;
+ }
+
+ bool hasAlpha = hasPartialAlpha || (hasTransparent && hasOpaque);
+
+ // If no alpha channel data, set all alpha to 255 (fully opaque)
+ if (!hasAlpha)
+ {
+ for (int i = 3; i < bufferSize; i += 4)
+ {
+ buffer[i] = 255;
+ }
+ }
+
+ // Create Avalonia bitmap from pixel data
+ // Use Unpremul - this correctly renders transparent icons without white borders
+ var bitmap = new WriteableBitmap(
+ new PixelSize(width, height),
+ new Vector(96, 96),
+ global::Avalonia.Platform.PixelFormat.Bgra8888,
+ global::Avalonia.Platform.AlphaFormat.Unpremul);
+
+ using (var fb = bitmap.Lock())
+ {
+ Marshal.Copy(buffer, 0, fb.Address, bufferSize);
+ }
+
+ return bitmap;
+ }
+ finally
+ {
+ DeleteDC(hdc);
+ }
+ }
+
+ private static async Task LoadFromUrlAsync(string url)
+ {
+ try
+ {
+ using var response = await _httpClient.GetAsync(url);
+ response.EnsureSuccessStatusCode();
+
+ await using var stream = await response.Content.ReadAsStreamAsync();
+ using var memoryStream = new MemoryStream();
+ await stream.CopyToAsync(memoryStream);
+ memoryStream.Position = 0;
+
+ return new Bitmap(memoryStream);
+ }
+ catch (Exception ex)
+ {
+ Log.Debug(ClassName, $"Failed to load URL: {url}, Error: {ex.Message}");
+ return null;
+ }
+ }
+
+ private static IImage? LoadFromDataUri(string dataUri)
+ {
+ try
+ {
+ // Parse data URI: data:image/png;base64,xxxxx
+ var commaIndex = dataUri.IndexOf(',');
+ if (commaIndex < 0)
+ return null;
+
+ var base64Data = dataUri.Substring(commaIndex + 1);
+ var imageData = Convert.FromBase64String(base64Data);
+
+ using var stream = new MemoryStream(imageData);
+ return new Bitmap(stream);
+ }
+ catch (Exception ex)
+ {
+ Log.Debug(ClassName, $"Failed to parse data URI: {ex.Message}");
+ return null;
+ }
+ }
+
+ private static IImage? LoadDefaultImage()
+ {
+ try
+ {
+ var appDir = AppDomain.CurrentDomain.BaseDirectory;
+
+ // Try PNG first
+ var defaultIconPath = Path.Combine(appDir, "Images", "app.png");
+ if (File.Exists(defaultIconPath))
+ {
+ using var stream = File.OpenRead(defaultIconPath);
+ return new Bitmap(stream);
+ }
+
+ // Try ICO via shell
+ defaultIconPath = Path.Combine(appDir, "Images", "app.ico");
+ if (File.Exists(defaultIconPath))
+ {
+ return LoadShellThumbnail(defaultIconPath);
+ }
+ }
+ catch (Exception ex)
+ {
+ Log.Debug(ClassName, $"Failed to load default image: {ex.Message}");
+ }
+
+ return null;
+ }
+
+ ///
+ /// Try to get a cached image without loading.
+ ///
+ public static bool TryGetCached(string? path, out IImage? image)
+ {
+ if (!string.IsNullOrWhiteSpace(path) && _cache.TryGetValue(path, out image))
+ return true;
+ image = null;
+ return false;
+ }
+
+ ///
+ /// Clear the image cache.
+ ///
+ public static void ClearCache() => _cache.Clear();
+
+ #region Windows Shell API P/Invoke
+
+ [DllImport("shell32.dll", CharSet = CharSet.Unicode, PreserveSig = true)]
+ private static extern int SHCreateItemFromParsingName(
+ [MarshalAs(UnmanagedType.LPWStr)] string pszPath,
+ IntPtr pbc,
+ [MarshalAs(UnmanagedType.LPStruct)] Guid riid,
+ [MarshalAs(UnmanagedType.Interface)] out object ppv);
+
+ [ComImport]
+ [Guid("bcc18b79-ba16-442f-80c4-8a59c30c463b")]
+ [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
+ private interface IShellItemImageFactory
+ {
+ [PreserveSig]
+ int GetImage(SIZE size, SIIGBF flags, out IntPtr phbm);
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct SIZE
+ {
+ public int cx;
+ public int cy;
+ }
+
+ [Flags]
+ private enum SIIGBF
+ {
+ SIIGBF_RESIZETOFIT = 0x00,
+ SIIGBF_BIGGERSIZEOK = 0x01,
+ SIIGBF_MEMORYONLY = 0x02,
+ SIIGBF_ICONONLY = 0x04,
+ SIIGBF_THUMBNAILONLY = 0x08,
+ SIIGBF_INCACHEONLY = 0x10
+ }
+
+ [DllImport("gdi32.dll")]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool DeleteObject(IntPtr hObject);
+
+ [DllImport("gdi32.dll")]
+ private static extern IntPtr CreateCompatibleDC(IntPtr hdc);
+
+ [DllImport("gdi32.dll")]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool DeleteDC(IntPtr hdc);
+
+ [DllImport("gdi32.dll")]
+ private static extern int GetObject(IntPtr hgdiobj, int cbBuffer, ref BITMAP lpvObject);
+
+ [DllImport("gdi32.dll")]
+ private static extern int GetDIBits(IntPtr hdc, IntPtr hbmp, uint uStartScan, uint cScanLines,
+ [Out] byte[] lpvBits, ref BITMAPINFO lpbi, uint uUsage);
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct BITMAP
+ {
+ public int bmType;
+ public int bmWidth;
+ public int bmHeight;
+ public int bmWidthBytes;
+ public ushort bmPlanes;
+ public ushort bmBitsPixel;
+ public IntPtr bmBits;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct BITMAPINFOHEADER
+ {
+ public uint biSize;
+ public int biWidth;
+ public int biHeight;
+ public ushort biPlanes;
+ public ushort biBitCount;
+ public uint biCompression;
+ public uint biSizeImage;
+ public int biXPelsPerMeter;
+ public int biYPelsPerMeter;
+ public uint biClrUsed;
+ public uint biClrImportant;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct BITMAPINFO
+ {
+ public BITMAPINFOHEADER bmiHeader;
+ [MarshalAs(UnmanagedType.ByValArray, SizeConst = 1)]
+ public uint[] bmiColors;
+ }
+
+ #endregion
+}
diff --git a/Flow.Launcher.Avalonia/ViewModel/MainViewModel.cs b/Flow.Launcher.Avalonia/ViewModel/MainViewModel.cs
index 8fa372339..c87df5c3e 100644
--- a/Flow.Launcher.Avalonia/ViewModel/MainViewModel.cs
+++ b/Flow.Launcher.Avalonia/ViewModel/MainViewModel.cs
@@ -104,7 +104,8 @@ public partial class MainViewModel : ObservableObject
var token = _queryTokenSource.Token;
var queryText = QueryText.Trim();
- if (string.IsNullOrWhiteSpace(queryText) || !_pluginsReady)
+ // Only clear results when query is empty
+ if (string.IsNullOrWhiteSpace(queryText))
{
Results.Clear();
HasResults = false;
@@ -112,60 +113,78 @@ public partial class MainViewModel : ObservableObject
return;
}
+ if (!_pluginsReady)
+ {
+ IsQueryRunning = false;
+ return;
+ }
+
IsQueryRunning = true;
try
{
var query = QueryBuilder.Build(queryText, PluginManager.NonGlobalPlugins);
- if (query == null) { Results.Clear(); HasResults = false; return; }
+ if (query == null) { HasResults = false; return; }
var plugins = PluginManager.ValidPluginsForQuery(query, dialogJump: false)
.Where(p => !p.Metadata.Disabled).ToList();
- if (plugins.Count == 0) { Results.Clear(); HasResults = false; return; }
-
- Results.Clear();
+ if (plugins.Count == 0) { HasResults = false; return; }
+ // Query all plugins in parallel and collect results
var tasks = plugins.Select(p => QueryPluginAsync(p, query, token));
- await Task.WhenAll(tasks);
+ var pluginResults = await Task.WhenAll(tasks);
- if (!token.IsCancellationRequested)
+ if (token.IsCancellationRequested) return;
+
+ // Flatten, sort by score, take top N, and replace all at once
+ var allResults = pluginResults
+ .SelectMany(r => r)
+ .OrderByDescending(r => r.Score)
+ .Take(_settings.MaxResultsToShow)
+ .ToList();
+
+ // Replace results with minimal UI updates (EditDiff)
+ await global::Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() =>
+ {
+ Results.ReplaceResults(allResults);
HasResults = Results.Results.Count > 0;
+ });
}
catch (OperationCanceledException) { }
catch (Exception e) { Log.Exception(ClassName, "Query error", e); }
finally { if (!token.IsCancellationRequested) IsQueryRunning = false; }
}
- private async Task QueryPluginAsync(PluginPair plugin, Query query, CancellationToken token)
+ private async Task> QueryPluginAsync(PluginPair plugin, Query query, CancellationToken token)
{
+ var resultList = new List();
+
try
{
var delay = plugin.Metadata.SearchDelayTime ?? _settings.SearchDelayTime;
if (delay > 0) await Task.Delay(delay, token);
- if (token.IsCancellationRequested) return;
+ if (token.IsCancellationRequested) return resultList;
var results = await PluginManager.QueryForPluginAsync(plugin, query, token);
- if (token.IsCancellationRequested || results == null || results.Count == 0) return;
+ if (token.IsCancellationRequested || results == null || results.Count == 0) return resultList;
- await global::Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() =>
+ foreach (var r in results)
{
- foreach (var r in results.OrderByDescending(r => r.Score).Take(_settings.MaxResultsToShow))
+ resultList.Add(new ResultViewModel
{
- if (token.IsCancellationRequested) return;
- Results.AddResult(new ResultViewModel
- {
- Title = r.Title ?? "",
- SubTitle = r.SubTitle ?? "",
- IconPath = r.IcoPath ?? plugin.Metadata.IcoPath ?? "",
- PluginResult = r
- });
- }
- HasResults = Results.Results.Count > 0;
- });
+ Title = r.Title ?? "",
+ SubTitle = r.SubTitle ?? "",
+ IconPath = r.IcoPath ?? plugin.Metadata.IcoPath ?? "",
+ Score = r.Score,
+ PluginResult = r
+ });
+ }
}
catch (OperationCanceledException) { }
catch (Exception e) { Log.Exception(ClassName, $"Plugin {plugin.Metadata.Name} error", e); }
+
+ return resultList;
}
[RelayCommand]
diff --git a/Flow.Launcher.Avalonia/ViewModel/ResultViewModel.cs b/Flow.Launcher.Avalonia/ViewModel/ResultViewModel.cs
index 324f317a1..56fa55fb4 100644
--- a/Flow.Launcher.Avalonia/ViewModel/ResultViewModel.cs
+++ b/Flow.Launcher.Avalonia/ViewModel/ResultViewModel.cs
@@ -1,4 +1,7 @@
+using System.Threading.Tasks;
+using Avalonia.Media;
using CommunityToolkit.Mvvm.ComponentModel;
+using Flow.Launcher.Avalonia.Helper;
using Flow.Launcher.Infrastructure.UserSettings;
using Flow.Launcher.Plugin;
@@ -24,6 +27,9 @@ public partial class ResultViewModel : ObservableObject
[ObservableProperty]
private Settings? _settings;
+ [ObservableProperty]
+ private int _score;
+
///
/// The underlying plugin result. Used for executing actions and accessing additional properties.
///
@@ -38,4 +44,13 @@ public partial class ResultViewModel : ObservableObject
/// Gets the query suggestion text for autocomplete, if available.
///
public string? QuerySuggestionText => PluginResult?.AutoCompleteText;
+
+ // Cached task for the image - created once per IconPath
+ private Task? _imageTask;
+
+ ///
+ /// The icon image task. Use with Avalonia's ^ stream binding operator.
+ /// Returns a cached task to avoid re-loading on every property access.
+ ///
+ public Task Image => _imageTask ??= ImageLoader.LoadAsync(IconPath);
}
diff --git a/Flow.Launcher.Avalonia/ViewModel/ResultsViewModel.cs b/Flow.Launcher.Avalonia/ViewModel/ResultsViewModel.cs
index 1a006f363..1880a652c 100644
--- a/Flow.Launcher.Avalonia/ViewModel/ResultsViewModel.cs
+++ b/Flow.Launcher.Avalonia/ViewModel/ResultsViewModel.cs
@@ -1,19 +1,23 @@
using System;
+using System.Collections.Generic;
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
+using DynamicData;
+using DynamicData.Binding;
using Flow.Launcher.Infrastructure.UserSettings;
namespace Flow.Launcher.Avalonia.ViewModel;
///
/// ViewModel for the results list.
+/// Uses DynamicData SourceList for automatic sorting by score.
///
-public partial class ResultsViewModel : ObservableObject
+public partial class ResultsViewModel : ObservableObject, IDisposable
{
private readonly Settings _settings;
-
- [ObservableProperty]
- private ObservableCollection _results = new();
+ private readonly SourceList _sourceList = new();
+ private readonly ReadOnlyObservableCollection _results;
+ private readonly IDisposable _subscription;
[ObservableProperty]
private ResultViewModel? _selectedItem;
@@ -24,6 +28,12 @@ public partial class ResultsViewModel : ObservableObject
[ObservableProperty]
private bool _isVisible = true;
+ ///
+ /// Sorted results collection bound to the UI.
+ /// Automatically sorted by Score descending.
+ ///
+ public ReadOnlyObservableCollection Results => _results;
+
public Settings Settings => _settings;
public int MaxHeight => (int)(_settings.MaxResultsToShow * _settings.ItemHeightSize);
@@ -31,61 +41,120 @@ public partial class ResultsViewModel : ObservableObject
public ResultsViewModel(Settings settings)
{
_settings = settings;
+
+ // Connect SourceList to sorted ReadOnlyObservableCollection
+ _subscription = _sourceList.Connect()
+ .Sort(SortExpressionComparer.Descending(r => r.Score))
+ .Bind(out _results)
+ .Subscribe();
+ }
+
+ ///
+ /// Replace all results with new ones using EditDiff to minimize UI updates.
+ /// Items with matching Title+SubTitle are kept, reducing flickering.
+ ///
+ public void ReplaceResults(IEnumerable newResults)
+ {
+ foreach (var r in newResults)
+ {
+ r.Settings = _settings;
+ }
+
+ // EditDiff calculates minimal changes needed
+ _sourceList.EditDiff(newResults, ResultViewModelComparer.Instance);
+
+ // Select first item after replacement
+ if (_results.Count > 0)
+ {
+ SelectedIndex = 0;
+ SelectedItem = _results[0];
+ }
+ else
+ {
+ SelectedItem = null;
+ SelectedIndex = -1;
+ }
}
public void AddResult(ResultViewModel result)
{
result.Settings = _settings;
- Results.Add(result);
-
+ _sourceList.Add(result);
+
// Select first item if nothing selected
- if (SelectedItem == null && Results.Count > 0)
+ if (SelectedItem == null && _results.Count > 0)
{
SelectedIndex = 0;
- SelectedItem = Results[0];
+ SelectedItem = _results[0];
}
}
public void Clear()
{
- Results.Clear();
+ _sourceList.Clear();
SelectedItem = null;
SelectedIndex = -1;
}
public void SelectNextItem()
{
- if (Results.Count == 0) return;
-
+ if (_results.Count == 0) return;
+
var newIndex = SelectedIndex + 1;
- if (newIndex >= Results.Count)
+ if (newIndex >= _results.Count)
{
newIndex = 0; // Wrap to beginning
}
-
+
SelectedIndex = newIndex;
- SelectedItem = Results[newIndex];
+ SelectedItem = _results[newIndex];
}
public void SelectPrevItem()
{
- if (Results.Count == 0) return;
-
+ if (_results.Count == 0) return;
+
var newIndex = SelectedIndex - 1;
if (newIndex < 0)
{
- newIndex = Results.Count - 1; // Wrap to end
+ newIndex = _results.Count - 1; // Wrap to end
}
-
+
SelectedIndex = newIndex;
- SelectedItem = Results[newIndex];
+ SelectedItem = _results[newIndex];
}
partial void OnSelectedIndexChanged(int value)
{
- if (value >= 0 && value < Results.Count)
+ if (value >= 0 && value < _results.Count)
{
- SelectedItem = Results[value];
+ SelectedItem = _results[value];
+ }
+ }
+
+ public void Dispose()
+ {
+ _subscription.Dispose();
+ _sourceList.Dispose();
+ }
+
+ ///
+ /// Comparer for EditDiff - considers results equal if Title and SubTitle match.
+ ///
+ private class ResultViewModelComparer : IEqualityComparer
+ {
+ public static readonly ResultViewModelComparer Instance = new();
+
+ public bool Equals(ResultViewModel? x, ResultViewModel? y)
+ {
+ if (ReferenceEquals(x, y)) return true;
+ if (x is null || y is null) return false;
+ return x.Title == y.Title && x.SubTitle == y.SubTitle;
+ }
+
+ public int GetHashCode(ResultViewModel obj)
+ {
+ return HashCode.Combine(obj.Title, obj.SubTitle);
}
}
}
diff --git a/Flow.Launcher.Avalonia/Views/ResultListBox.axaml b/Flow.Launcher.Avalonia/Views/ResultListBox.axaml
index 144fc8e18..e643cec4d 100644
--- a/Flow.Launcher.Avalonia/Views/ResultListBox.axaml
+++ b/Flow.Launcher.Avalonia/Views/ResultListBox.axaml
@@ -3,6 +3,7 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:Flow.Launcher.Avalonia.ViewModel"
+ xmlns:helper="using:Flow.Launcher.Avalonia.Helper"
mc:Ignorable="d" d:DesignWidth="580" d:DesignHeight="300"
x:Class="Flow.Launcher.Avalonia.Views.ResultListBox"
x:DataType="vm:ResultsViewModel">
@@ -17,6 +18,13 @@
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
ScrollViewer.VerticalScrollBarVisibility="Auto">
+
+
+
+
+
+
+
@@ -29,10 +37,10 @@
-
+