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 @@ - +