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