mirror of
https://github.com/Flow-Launcher/Flow.Launcher.git
synced 2026-03-11 08:54:32 +00:00
Add DynamicData sorting, result persistence, and Windows Shell icon loading
- Use DynamicData SourceList with automatic sorting by score descending - Add ReplaceResults() with EditDiff for minimal UI updates (reduces flickering) - Keep previous results visible while typing until new results arrive - Add ImageLoader with Windows Shell API (IShellItemImageFactory) for exe/ico icons - Use AlphaFormat.Unpremul to correctly render transparent icons without white borders - Query all plugins in parallel and merge/sort results globally
This commit is contained in:
parent
065dc191ce
commit
f3d3f80db8
6 changed files with 606 additions and 46 deletions
|
|
@ -37,6 +37,7 @@
|
||||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.2.3" />
|
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.2.3" />
|
||||||
<PackageReference Include="FluentAvaloniaUI" Version="2.2.0" />
|
<PackageReference Include="FluentAvaloniaUI" Version="2.2.0" />
|
||||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
|
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
|
||||||
|
<PackageReference Include="DynamicData" Version="9.0.4" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.0" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.0" />
|
||||||
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
|
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
|
||||||
|
|
|
||||||
448
Flow.Launcher.Avalonia/Helper/ImageLoader.cs
Normal file
448
Flow.Launcher.Avalonia/Helper/ImageLoader.cs
Normal file
|
|
@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Avalonia-compatible image loader with caching support.
|
||||||
|
/// Loads images from file paths, URLs, and data URIs.
|
||||||
|
/// Uses Windows Shell API for exe/ico thumbnails.
|
||||||
|
/// </summary>
|
||||||
|
public static class ImageLoader
|
||||||
|
{
|
||||||
|
private static readonly string ClassName = nameof(ImageLoader);
|
||||||
|
|
||||||
|
// Thread-safe cache
|
||||||
|
private static readonly ConcurrentDictionary<string, IImage?> _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"];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Default image shown when no icon is available.
|
||||||
|
/// </summary>
|
||||||
|
public static IImage? DefaultImage => _defaultImage ??= LoadDefaultImage();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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^}
|
||||||
|
/// </summary>
|
||||||
|
public static Task<IImage?> 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Core loading logic - runs on thread pool when not cached.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<IImage?> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Load thumbnail/icon using Windows Shell API (IShellItemImageFactory).
|
||||||
|
/// Works for exe, dll, ico, folders, and any file type.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Convert Windows HBITMAP to Avalonia Bitmap.
|
||||||
|
/// </summary>
|
||||||
|
private static Bitmap? ConvertHBitmapToAvaloniaBitmap(IntPtr hBitmap)
|
||||||
|
{
|
||||||
|
// Get bitmap info
|
||||||
|
var bmp = new BITMAP();
|
||||||
|
if (GetObject(hBitmap, Marshal.SizeOf<BITMAP>(), 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<BITMAPINFOHEADER>(),
|
||||||
|
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<IImage?> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Try to get a cached image without loading.
|
||||||
|
/// </summary>
|
||||||
|
public static bool TryGetCached(string? path, out IImage? image)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(path) && _cache.TryGetValue(path, out image))
|
||||||
|
return true;
|
||||||
|
image = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clear the image cache.
|
||||||
|
/// </summary>
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
@ -104,7 +104,8 @@ public partial class MainViewModel : ObservableObject
|
||||||
var token = _queryTokenSource.Token;
|
var token = _queryTokenSource.Token;
|
||||||
var queryText = QueryText.Trim();
|
var queryText = QueryText.Trim();
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(queryText) || !_pluginsReady)
|
// Only clear results when query is empty
|
||||||
|
if (string.IsNullOrWhiteSpace(queryText))
|
||||||
{
|
{
|
||||||
Results.Clear();
|
Results.Clear();
|
||||||
HasResults = false;
|
HasResults = false;
|
||||||
|
|
@ -112,60 +113,78 @@ public partial class MainViewModel : ObservableObject
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!_pluginsReady)
|
||||||
|
{
|
||||||
|
IsQueryRunning = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
IsQueryRunning = true;
|
IsQueryRunning = true;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var query = QueryBuilder.Build(queryText, PluginManager.NonGlobalPlugins);
|
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)
|
var plugins = PluginManager.ValidPluginsForQuery(query, dialogJump: false)
|
||||||
.Where(p => !p.Metadata.Disabled).ToList();
|
.Where(p => !p.Metadata.Disabled).ToList();
|
||||||
|
|
||||||
if (plugins.Count == 0) { Results.Clear(); HasResults = false; return; }
|
if (plugins.Count == 0) { HasResults = false; return; }
|
||||||
|
|
||||||
Results.Clear();
|
|
||||||
|
|
||||||
|
// Query all plugins in parallel and collect results
|
||||||
var tasks = plugins.Select(p => QueryPluginAsync(p, query, token));
|
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;
|
HasResults = Results.Results.Count > 0;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) { }
|
catch (OperationCanceledException) { }
|
||||||
catch (Exception e) { Log.Exception(ClassName, "Query error", e); }
|
catch (Exception e) { Log.Exception(ClassName, "Query error", e); }
|
||||||
finally { if (!token.IsCancellationRequested) IsQueryRunning = false; }
|
finally { if (!token.IsCancellationRequested) IsQueryRunning = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task QueryPluginAsync(PluginPair plugin, Query query, CancellationToken token)
|
private async Task<List<ResultViewModel>> QueryPluginAsync(PluginPair plugin, Query query, CancellationToken token)
|
||||||
{
|
{
|
||||||
|
var resultList = new List<ResultViewModel>();
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var delay = plugin.Metadata.SearchDelayTime ?? _settings.SearchDelayTime;
|
var delay = plugin.Metadata.SearchDelayTime ?? _settings.SearchDelayTime;
|
||||||
if (delay > 0) await Task.Delay(delay, token);
|
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);
|
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;
|
Title = r.Title ?? "",
|
||||||
Results.AddResult(new ResultViewModel
|
SubTitle = r.SubTitle ?? "",
|
||||||
{
|
IconPath = r.IcoPath ?? plugin.Metadata.IcoPath ?? "",
|
||||||
Title = r.Title ?? "",
|
Score = r.Score,
|
||||||
SubTitle = r.SubTitle ?? "",
|
PluginResult = r
|
||||||
IconPath = r.IcoPath ?? plugin.Metadata.IcoPath ?? "",
|
});
|
||||||
PluginResult = r
|
}
|
||||||
});
|
|
||||||
}
|
|
||||||
HasResults = Results.Results.Count > 0;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) { }
|
catch (OperationCanceledException) { }
|
||||||
catch (Exception e) { Log.Exception(ClassName, $"Plugin {plugin.Metadata.Name} error", e); }
|
catch (Exception e) { Log.Exception(ClassName, $"Plugin {plugin.Metadata.Name} error", e); }
|
||||||
|
|
||||||
|
return resultList;
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Avalonia.Media;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using Flow.Launcher.Avalonia.Helper;
|
||||||
using Flow.Launcher.Infrastructure.UserSettings;
|
using Flow.Launcher.Infrastructure.UserSettings;
|
||||||
using Flow.Launcher.Plugin;
|
using Flow.Launcher.Plugin;
|
||||||
|
|
||||||
|
|
@ -24,6 +27,9 @@ public partial class ResultViewModel : ObservableObject
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private Settings? _settings;
|
private Settings? _settings;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private int _score;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The underlying plugin result. Used for executing actions and accessing additional properties.
|
/// The underlying plugin result. Used for executing actions and accessing additional properties.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -38,4 +44,13 @@ public partial class ResultViewModel : ObservableObject
|
||||||
/// Gets the query suggestion text for autocomplete, if available.
|
/// Gets the query suggestion text for autocomplete, if available.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? QuerySuggestionText => PluginResult?.AutoCompleteText;
|
public string? QuerySuggestionText => PluginResult?.AutoCompleteText;
|
||||||
|
|
||||||
|
// Cached task for the image - created once per IconPath
|
||||||
|
private Task<IImage?>? _imageTask;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The icon image task. Use with Avalonia's ^ stream binding operator.
|
||||||
|
/// Returns a cached task to avoid re-loading on every property access.
|
||||||
|
/// </summary>
|
||||||
|
public Task<IImage?> Image => _imageTask ??= ImageLoader.LoadAsync(IconPath);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,23 @@
|
||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using DynamicData;
|
||||||
|
using DynamicData.Binding;
|
||||||
using Flow.Launcher.Infrastructure.UserSettings;
|
using Flow.Launcher.Infrastructure.UserSettings;
|
||||||
|
|
||||||
namespace Flow.Launcher.Avalonia.ViewModel;
|
namespace Flow.Launcher.Avalonia.ViewModel;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// ViewModel for the results list.
|
/// ViewModel for the results list.
|
||||||
|
/// Uses DynamicData SourceList for automatic sorting by score.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class ResultsViewModel : ObservableObject
|
public partial class ResultsViewModel : ObservableObject, IDisposable
|
||||||
{
|
{
|
||||||
private readonly Settings _settings;
|
private readonly Settings _settings;
|
||||||
|
private readonly SourceList<ResultViewModel> _sourceList = new();
|
||||||
[ObservableProperty]
|
private readonly ReadOnlyObservableCollection<ResultViewModel> _results;
|
||||||
private ObservableCollection<ResultViewModel> _results = new();
|
private readonly IDisposable _subscription;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private ResultViewModel? _selectedItem;
|
private ResultViewModel? _selectedItem;
|
||||||
|
|
@ -24,6 +28,12 @@ public partial class ResultsViewModel : ObservableObject
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool _isVisible = true;
|
private bool _isVisible = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sorted results collection bound to the UI.
|
||||||
|
/// Automatically sorted by Score descending.
|
||||||
|
/// </summary>
|
||||||
|
public ReadOnlyObservableCollection<ResultViewModel> Results => _results;
|
||||||
|
|
||||||
public Settings Settings => _settings;
|
public Settings Settings => _settings;
|
||||||
|
|
||||||
public int MaxHeight => (int)(_settings.MaxResultsToShow * _settings.ItemHeightSize);
|
public int MaxHeight => (int)(_settings.MaxResultsToShow * _settings.ItemHeightSize);
|
||||||
|
|
@ -31,61 +41,120 @@ public partial class ResultsViewModel : ObservableObject
|
||||||
public ResultsViewModel(Settings settings)
|
public ResultsViewModel(Settings settings)
|
||||||
{
|
{
|
||||||
_settings = settings;
|
_settings = settings;
|
||||||
|
|
||||||
|
// Connect SourceList to sorted ReadOnlyObservableCollection
|
||||||
|
_subscription = _sourceList.Connect()
|
||||||
|
.Sort(SortExpressionComparer<ResultViewModel>.Descending(r => r.Score))
|
||||||
|
.Bind(out _results)
|
||||||
|
.Subscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Replace all results with new ones using EditDiff to minimize UI updates.
|
||||||
|
/// Items with matching Title+SubTitle are kept, reducing flickering.
|
||||||
|
/// </summary>
|
||||||
|
public void ReplaceResults(IEnumerable<ResultViewModel> 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)
|
public void AddResult(ResultViewModel result)
|
||||||
{
|
{
|
||||||
result.Settings = _settings;
|
result.Settings = _settings;
|
||||||
Results.Add(result);
|
_sourceList.Add(result);
|
||||||
|
|
||||||
// Select first item if nothing selected
|
// Select first item if nothing selected
|
||||||
if (SelectedItem == null && Results.Count > 0)
|
if (SelectedItem == null && _results.Count > 0)
|
||||||
{
|
{
|
||||||
SelectedIndex = 0;
|
SelectedIndex = 0;
|
||||||
SelectedItem = Results[0];
|
SelectedItem = _results[0];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Clear()
|
public void Clear()
|
||||||
{
|
{
|
||||||
Results.Clear();
|
_sourceList.Clear();
|
||||||
SelectedItem = null;
|
SelectedItem = null;
|
||||||
SelectedIndex = -1;
|
SelectedIndex = -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SelectNextItem()
|
public void SelectNextItem()
|
||||||
{
|
{
|
||||||
if (Results.Count == 0) return;
|
if (_results.Count == 0) return;
|
||||||
|
|
||||||
var newIndex = SelectedIndex + 1;
|
var newIndex = SelectedIndex + 1;
|
||||||
if (newIndex >= Results.Count)
|
if (newIndex >= _results.Count)
|
||||||
{
|
{
|
||||||
newIndex = 0; // Wrap to beginning
|
newIndex = 0; // Wrap to beginning
|
||||||
}
|
}
|
||||||
|
|
||||||
SelectedIndex = newIndex;
|
SelectedIndex = newIndex;
|
||||||
SelectedItem = Results[newIndex];
|
SelectedItem = _results[newIndex];
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SelectPrevItem()
|
public void SelectPrevItem()
|
||||||
{
|
{
|
||||||
if (Results.Count == 0) return;
|
if (_results.Count == 0) return;
|
||||||
|
|
||||||
var newIndex = SelectedIndex - 1;
|
var newIndex = SelectedIndex - 1;
|
||||||
if (newIndex < 0)
|
if (newIndex < 0)
|
||||||
{
|
{
|
||||||
newIndex = Results.Count - 1; // Wrap to end
|
newIndex = _results.Count - 1; // Wrap to end
|
||||||
}
|
}
|
||||||
|
|
||||||
SelectedIndex = newIndex;
|
SelectedIndex = newIndex;
|
||||||
SelectedItem = Results[newIndex];
|
SelectedItem = _results[newIndex];
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnSelectedIndexChanged(int value)
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Comparer for EditDiff - considers results equal if Title and SubTitle match.
|
||||||
|
/// </summary>
|
||||||
|
private class ResultViewModelComparer : IEqualityComparer<ResultViewModel>
|
||||||
|
{
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
xmlns:vm="using:Flow.Launcher.Avalonia.ViewModel"
|
xmlns:vm="using:Flow.Launcher.Avalonia.ViewModel"
|
||||||
|
xmlns:helper="using:Flow.Launcher.Avalonia.Helper"
|
||||||
mc:Ignorable="d" d:DesignWidth="580" d:DesignHeight="300"
|
mc:Ignorable="d" d:DesignWidth="580" d:DesignHeight="300"
|
||||||
x:Class="Flow.Launcher.Avalonia.Views.ResultListBox"
|
x:Class="Flow.Launcher.Avalonia.Views.ResultListBox"
|
||||||
x:DataType="vm:ResultsViewModel">
|
x:DataType="vm:ResultsViewModel">
|
||||||
|
|
@ -17,6 +18,13 @@
|
||||||
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
|
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
|
||||||
ScrollViewer.VerticalScrollBarVisibility="Auto">
|
ScrollViewer.VerticalScrollBarVisibility="Auto">
|
||||||
|
|
||||||
|
<!-- Enable virtualization for better performance -->
|
||||||
|
<ListBox.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<VirtualizingStackPanel />
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</ListBox.ItemsPanel>
|
||||||
|
|
||||||
<ListBox.ItemTemplate>
|
<ListBox.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:ResultViewModel">
|
<DataTemplate x:DataType="vm:ResultViewModel">
|
||||||
<Grid Classes="resultItemGrid" ColumnDefinitions="Auto,*,Auto">
|
<Grid Classes="resultItemGrid" ColumnDefinitions="Auto,*,Auto">
|
||||||
|
|
@ -29,10 +37,10 @@
|
||||||
<!-- Icon and Text -->
|
<!-- Icon and Text -->
|
||||||
<Grid Grid.Column="1" ColumnDefinitions="Auto,*" Margin="6,0,0,0">
|
<Grid Grid.Column="1" ColumnDefinitions="Auto,*" Margin="6,0,0,0">
|
||||||
|
|
||||||
<!-- Icon -->
|
<!-- Icon (async loaded via ^ stream operator) -->
|
||||||
<Image Grid.Column="0"
|
<Image Grid.Column="0"
|
||||||
Classes="resultIcon"
|
Classes="resultIcon"
|
||||||
Source="{Binding IconPath, TargetNullValue={x:Null}}"
|
Source="{Binding Image^, FallbackValue={x:Static helper:ImageLoader.DefaultImage}}"
|
||||||
IsVisible="{Binding ShowIcon}"
|
IsVisible="{Binding ShowIcon}"
|
||||||
RenderOptions.BitmapInterpolationMode="HighQuality" />
|
RenderOptions.BitmapInterpolationMode="HighQuality" />
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue