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:
Hongtao Zhang 2026-01-15 00:26:27 -08:00
parent 065dc191ce
commit f3d3f80db8
6 changed files with 606 additions and 46 deletions

View file

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

View 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
}

View file

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

View file

@ -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);
}

View file

@ -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);
}
}
}

View file

@ -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" />