diff --git a/src/Avalonia/HandyControlDemo_Avalonia/Data/DemoInfo.json b/src/Avalonia/HandyControlDemo_Avalonia/Data/DemoInfo.json
index 340fe691..99a09b72 100644
--- a/src/Avalonia/HandyControlDemo_Avalonia/Data/DemoInfo.json
+++ b/src/Avalonia/HandyControlDemo_Avalonia/Data/DemoInfo.json
@@ -64,7 +64,8 @@
[ "Badge", "BadgeDemo", "Brush.CheckDot", "", "DataDisplay" ],
[ "Shield", "ShieldDemo", "Brush.SplitKeyValuePair", "", "DataDisplay" ],
[ "ImageBlock", "ImageBlockDemo", "Brush.ImageStack", "", "DataDisplay" ],
- [ "AnimationPath", "AnimationPathDemo", "Brush.Path", "", "DataDisplay" ]
+ [ "AnimationPath", "AnimationPathDemo", "Brush.Path", "", "DataDisplay" ],
+ [ "ImageSelector", "ImageSelectorDemo", "Brush.Image", "", "DataEntry" ]
]
}
]
diff --git a/src/Avalonia/HandyControlDemo_Avalonia/UserControl/Controls/ImageSelectorDemo.axaml b/src/Avalonia/HandyControlDemo_Avalonia/UserControl/Controls/ImageSelectorDemo.axaml
new file mode 100644
index 00000000..f349fa00
--- /dev/null
+++ b/src/Avalonia/HandyControlDemo_Avalonia/UserControl/Controls/ImageSelectorDemo.axaml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Avalonia/HandyControlDemo_Avalonia/UserControl/Controls/ImageSelectorDemo.axaml.cs b/src/Avalonia/HandyControlDemo_Avalonia/UserControl/Controls/ImageSelectorDemo.axaml.cs
new file mode 100644
index 00000000..2f18360f
--- /dev/null
+++ b/src/Avalonia/HandyControlDemo_Avalonia/UserControl/Controls/ImageSelectorDemo.axaml.cs
@@ -0,0 +1,9 @@
+namespace HandyControlDemo.UserControl;
+
+public partial class ImageSelectorDemo : Avalonia.Controls.UserControl
+{
+ public ImageSelectorDemo()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/src/Avalonia/HandyControl_Avalonia/Controls/Input/ImageSelector.cs b/src/Avalonia/HandyControl_Avalonia/Controls/Input/ImageSelector.cs
new file mode 100644
index 00000000..58729b9e
--- /dev/null
+++ b/src/Avalonia/HandyControl_Avalonia/Controls/Input/ImageSelector.cs
@@ -0,0 +1,232 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Media;
+using Avalonia.Media.Imaging;
+using Avalonia.Platform.Storage;
+
+namespace HandyControl.Controls;
+
+///
+/// A control that allows selecting an image file via a file picker dialog.
+/// Displays the selected image preview inside a configurable frame.
+///
+public class ImageSelector : TemplatedControl
+{
+ public static readonly RoutedEvent ImageSelectedEvent =
+ RoutedEvent.Register(nameof(ImageSelected), RoutingStrategies.Bubble);
+
+ public static readonly RoutedEvent ImageUnselectedEvent =
+ RoutedEvent.Register(nameof(ImageUnselected), RoutingStrategies.Bubble);
+
+ public event EventHandler? ImageSelected
+ {
+ add => AddHandler(ImageSelectedEvent, value);
+ remove => RemoveHandler(ImageSelectedEvent, value);
+ }
+
+ public event EventHandler? ImageUnselected
+ {
+ add => AddHandler(ImageUnselectedEvent, value);
+ remove => RemoveHandler(ImageUnselectedEvent, value);
+ }
+
+ static ImageSelector()
+ {
+ FocusableProperty.OverrideDefaultValue(true);
+ }
+
+ protected override void OnPointerPressed(PointerPressedEventArgs e)
+ {
+ base.OnPointerPressed(e);
+ if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
+ {
+ SwitchImage();
+ }
+ }
+
+ private async void SwitchImage()
+ {
+ if (!HasValue)
+ {
+ var topLevel = TopLevel.GetTopLevel(this);
+ if (topLevel == null) return;
+
+ var patterns = ParseFilterPatterns(Filter);
+
+ var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
+ {
+ AllowMultiple = false,
+ FileTypeFilter =
+ [
+ new FilePickerFileType("Images") { Patterns = patterns },
+ ],
+ });
+
+ if (files.Count > 0)
+ {
+ var file = files[0];
+ var localPath = file.TryGetLocalPath();
+ HasValue = true;
+
+ try
+ {
+ await using var stream = await file.OpenReadAsync();
+ var bitmap = new Bitmap(stream);
+ PreviewBrush = new ImageBrush(bitmap) { Stretch = Stretch };
+ }
+ catch
+ {
+ PreviewBrush = null;
+ }
+
+ if (localPath != null)
+ Uri = new Uri(localPath);
+ ToolTip.SetTip(this, file.Name);
+ RaiseEvent(new RoutedEventArgs(ImageSelectedEvent));
+ }
+ }
+ else
+ {
+ HasValue = false;
+ PreviewBrush = null;
+ Uri = null;
+ ToolTip.SetTip(this, null);
+ RaiseEvent(new RoutedEventArgs(ImageUnselectedEvent));
+ }
+ }
+
+ // ── Properties ──
+
+ public static readonly StyledProperty StretchProperty =
+ AvaloniaProperty.Register(nameof(Stretch));
+
+ public Stretch Stretch
+ {
+ get => GetValue(StretchProperty);
+ set => SetValue(StretchProperty, value);
+ }
+
+ public static readonly DirectProperty UriProperty =
+ AvaloniaProperty.RegisterDirect(nameof(Uri), o => o.Uri);
+
+ private Uri? _uri;
+
+ public Uri? Uri
+ {
+ get => _uri;
+ private set => SetAndRaise(UriProperty, ref _uri, value);
+ }
+
+ public static readonly DirectProperty PreviewBrushProperty =
+ AvaloniaProperty.RegisterDirect(nameof(PreviewBrush), o => o.PreviewBrush);
+
+ private IBrush? _previewBrush;
+
+ public IBrush? PreviewBrush
+ {
+ get => _previewBrush;
+ private set => SetAndRaise(PreviewBrushProperty, ref _previewBrush, value);
+ }
+ public static readonly StyledProperty StrokeThicknessProperty =
+ AvaloniaProperty.Register(nameof(StrokeThickness), 1.0);
+
+ public double StrokeThickness
+ {
+ get => GetValue(StrokeThicknessProperty);
+ set => SetValue(StrokeThicknessProperty, value);
+ }
+
+ public static readonly StyledProperty?> StrokeDashArrayProperty =
+ AvaloniaProperty.Register?>(nameof(StrokeDashArray));
+
+ public Avalonia.Collections.AvaloniaList? StrokeDashArray
+ {
+ get => GetValue(StrokeDashArrayProperty);
+ set => SetValue(StrokeDashArrayProperty, value);
+ }
+
+ public static readonly StyledProperty DefaultExtProperty =
+ AvaloniaProperty.Register(nameof(DefaultExt), ".jpg");
+
+ public string DefaultExt
+ {
+ get => GetValue(DefaultExtProperty);
+ set => SetValue(DefaultExtProperty, value);
+ }
+
+ public static readonly StyledProperty FilterProperty =
+ AvaloniaProperty.Register(nameof(Filter), "(.jpg)|*.jpg|(.png)|*.png");
+
+ public string Filter
+ {
+ get => GetValue(FilterProperty);
+ set => SetValue(FilterProperty, value);
+ }
+
+ ///
+ /// Parses a WPF-style filter string like "(.jpg)|*.jpg|(.png)|*.png"
+ /// into individual glob patterns for the cross-platform file picker.
+ ///
+ private static List ParseFilterPatterns(string? filter)
+ {
+ var patterns = new List();
+
+ if (string.IsNullOrWhiteSpace(filter))
+ {
+ // Sensible cross-platform defaults with case variations
+ patterns.AddRange(["*.jpg", "*.jpeg", "*.png", "*.bmp", "*.gif"]);
+ patterns.AddRange(["*.JPG", "*.JPEG", "*.PNG", "*.BMP", "*.GIF"]);
+ return patterns;
+ }
+
+ // WPF format: "Description|*.ext|Description|*.ext"
+ var parts = filter.Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+
+ foreach (var part in parts)
+ {
+ // Skip display-name segments (e.g. "(.jpg)"); only keep pattern segments
+ if (part.Contains('*') || part.Contains('?'))
+ {
+ // Split multi-pattern entries like "*.jpg;*.png"
+ foreach (var p in part.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
+ {
+ var trimmed = p.Trim();
+ if (!string.IsNullOrEmpty(trimmed) && !patterns.Contains(trimmed))
+ {
+ patterns.Add(trimmed);
+ // Add uppercase variant for case-sensitive platforms (macOS)
+ var upper = trimmed.ToUpperInvariant();
+ if (upper != trimmed && !patterns.Contains(upper))
+ patterns.Add(upper);
+ }
+ }
+ }
+ }
+
+ // Fallback if parsing produced nothing
+ if (patterns.Count == 0)
+ {
+ patterns.AddRange(["*.jpg", "*.jpeg", "*.png", "*.bmp", "*.gif",
+ "*.JPG", "*.JPEG", "*.PNG", "*.BMP", "*.GIF"]);
+ }
+
+ return patterns;
+ }
+
+ public static readonly DirectProperty HasValueProperty =
+ AvaloniaProperty.RegisterDirect(nameof(HasValue), o => o.HasValue);
+
+ private bool _hasValue;
+
+ public bool HasValue
+ {
+ get => _hasValue;
+ private set => SetAndRaise(HasValueProperty, ref _hasValue, value);
+ }
+}
diff --git a/src/Avalonia/HandyControl_Avalonia/Themes/Styles/ImageSelector.axaml b/src/Avalonia/HandyControl_Avalonia/Themes/Styles/ImageSelector.axaml
new file mode 100644
index 00000000..fd759bad
--- /dev/null
+++ b/src/Avalonia/HandyControl_Avalonia/Themes/Styles/ImageSelector.axaml
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Avalonia/HandyControl_Avalonia/Themes/Styles/MergedStyles.axaml b/src/Avalonia/HandyControl_Avalonia/Themes/Styles/MergedStyles.axaml
index 41d7c4dd..fb280f45 100644
--- a/src/Avalonia/HandyControl_Avalonia/Themes/Styles/MergedStyles.axaml
+++ b/src/Avalonia/HandyControl_Avalonia/Themes/Styles/MergedStyles.axaml
@@ -76,8 +76,9 @@
-
+
+