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="FluentAvaloniaUI" Version="2.2.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.Hosting" Version="9.0.0" />
|
||||
<!--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 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<List<ResultViewModel>> QueryPluginAsync(PluginPair plugin, Query query, CancellationToken token)
|
||||
{
|
||||
var resultList = new List<ResultViewModel>();
|
||||
|
||||
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]
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// The underlying plugin result. Used for executing actions and accessing additional properties.
|
||||
/// </summary>
|
||||
|
|
@ -38,4 +44,13 @@ public partial class ResultViewModel : ObservableObject
|
|||
/// Gets the query suggestion text for autocomplete, if available.
|
||||
/// </summary>
|
||||
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.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;
|
||||
|
||||
/// <summary>
|
||||
/// ViewModel for the results list.
|
||||
/// Uses DynamicData SourceList for automatic sorting by score.
|
||||
/// </summary>
|
||||
public partial class ResultsViewModel : ObservableObject
|
||||
public partial class ResultsViewModel : ObservableObject, IDisposable
|
||||
{
|
||||
private readonly Settings _settings;
|
||||
|
||||
[ObservableProperty]
|
||||
private ObservableCollection<ResultViewModel> _results = new();
|
||||
private readonly SourceList<ResultViewModel> _sourceList = new();
|
||||
private readonly ReadOnlyObservableCollection<ResultViewModel> _results;
|
||||
private readonly IDisposable _subscription;
|
||||
|
||||
[ObservableProperty]
|
||||
private ResultViewModel? _selectedItem;
|
||||
|
|
@ -24,6 +28,12 @@ public partial class ResultsViewModel : ObservableObject
|
|||
[ObservableProperty]
|
||||
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 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<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)
|
||||
{
|
||||
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();
|
||||
}
|
||||
|
||||
/// <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: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">
|
||||
|
||||
<!-- Enable virtualization for better performance -->
|
||||
<ListBox.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<VirtualizingStackPanel />
|
||||
</ItemsPanelTemplate>
|
||||
</ListBox.ItemsPanel>
|
||||
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ResultViewModel">
|
||||
<Grid Classes="resultItemGrid" ColumnDefinitions="Auto,*,Auto">
|
||||
|
|
@ -29,10 +37,10 @@
|
|||
<!-- Icon and Text -->
|
||||
<Grid Grid.Column="1" ColumnDefinitions="Auto,*" Margin="6,0,0,0">
|
||||
|
||||
<!-- Icon -->
|
||||
<!-- Icon (async loaded via ^ stream operator) -->
|
||||
<Image Grid.Column="0"
|
||||
Classes="resultIcon"
|
||||
Source="{Binding IconPath, TargetNullValue={x:Null}}"
|
||||
Source="{Binding Image^, FallbackValue={x:Static helper:ImageLoader.DefaultImage}}"
|
||||
IsVisible="{Binding ShowIcon}"
|
||||
RenderOptions.BitmapInterpolationMode="HighQuality" />
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue