|
| 1 | +using System; |
| 2 | +using System.Collections.Generic; |
| 3 | +using System.IO; |
| 4 | +using Avalonia; |
| 5 | +using Avalonia.Controls; |
| 6 | +using Avalonia.Controls.Primitives; |
| 7 | +using Avalonia.Input; |
| 8 | +using Avalonia.Interactivity; |
| 9 | +using Avalonia.Media; |
| 10 | +using Avalonia.Media.Imaging; |
| 11 | +using Avalonia.Platform.Storage; |
| 12 | + |
| 13 | +namespace HandyControl.Controls; |
| 14 | + |
| 15 | +/// <summary> |
| 16 | +/// A control that allows selecting an image file via a file picker dialog. |
| 17 | +/// Displays the selected image preview inside a configurable frame. |
| 18 | +/// </summary> |
| 19 | +public class ImageSelector : TemplatedControl |
| 20 | +{ |
| 21 | + public static readonly RoutedEvent<RoutedEventArgs> ImageSelectedEvent = |
| 22 | + RoutedEvent.Register<ImageSelector, RoutedEventArgs>(nameof(ImageSelected), RoutingStrategies.Bubble); |
| 23 | + |
| 24 | + public static readonly RoutedEvent<RoutedEventArgs> ImageUnselectedEvent = |
| 25 | + RoutedEvent.Register<ImageSelector, RoutedEventArgs>(nameof(ImageUnselected), RoutingStrategies.Bubble); |
| 26 | + |
| 27 | + public event EventHandler<RoutedEventArgs>? ImageSelected |
| 28 | + { |
| 29 | + add => AddHandler(ImageSelectedEvent, value); |
| 30 | + remove => RemoveHandler(ImageSelectedEvent, value); |
| 31 | + } |
| 32 | + |
| 33 | + public event EventHandler<RoutedEventArgs>? ImageUnselected |
| 34 | + { |
| 35 | + add => AddHandler(ImageUnselectedEvent, value); |
| 36 | + remove => RemoveHandler(ImageUnselectedEvent, value); |
| 37 | + } |
| 38 | + |
| 39 | + static ImageSelector() |
| 40 | + { |
| 41 | + FocusableProperty.OverrideDefaultValue<ImageSelector>(true); |
| 42 | + } |
| 43 | + |
| 44 | + protected override void OnPointerPressed(PointerPressedEventArgs e) |
| 45 | + { |
| 46 | + base.OnPointerPressed(e); |
| 47 | + if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) |
| 48 | + { |
| 49 | + SwitchImage(); |
| 50 | + } |
| 51 | + } |
| 52 | + |
| 53 | + private async void SwitchImage() |
| 54 | + { |
| 55 | + if (!HasValue) |
| 56 | + { |
| 57 | + var topLevel = TopLevel.GetTopLevel(this); |
| 58 | + if (topLevel == null) return; |
| 59 | + |
| 60 | + var patterns = ParseFilterPatterns(Filter); |
| 61 | + |
| 62 | + var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions |
| 63 | + { |
| 64 | + AllowMultiple = false, |
| 65 | + FileTypeFilter = |
| 66 | + [ |
| 67 | + new FilePickerFileType("Images") { Patterns = patterns }, |
| 68 | + ], |
| 69 | + }); |
| 70 | + |
| 71 | + if (files.Count > 0) |
| 72 | + { |
| 73 | + var file = files[0]; |
| 74 | + var localPath = file.TryGetLocalPath(); |
| 75 | + HasValue = true; |
| 76 | + |
| 77 | + try |
| 78 | + { |
| 79 | + await using var stream = await file.OpenReadAsync(); |
| 80 | + var bitmap = new Bitmap(stream); |
| 81 | + PreviewBrush = new ImageBrush(bitmap) { Stretch = Stretch }; |
| 82 | + } |
| 83 | + catch |
| 84 | + { |
| 85 | + PreviewBrush = null; |
| 86 | + } |
| 87 | + |
| 88 | + if (localPath != null) |
| 89 | + Uri = new Uri(localPath); |
| 90 | + ToolTip.SetTip(this, file.Name); |
| 91 | + RaiseEvent(new RoutedEventArgs(ImageSelectedEvent)); |
| 92 | + } |
| 93 | + } |
| 94 | + else |
| 95 | + { |
| 96 | + HasValue = false; |
| 97 | + PreviewBrush = null; |
| 98 | + Uri = null; |
| 99 | + ToolTip.SetTip(this, null); |
| 100 | + RaiseEvent(new RoutedEventArgs(ImageUnselectedEvent)); |
| 101 | + } |
| 102 | + } |
| 103 | + |
| 104 | + // ── Properties ── |
| 105 | + |
| 106 | + public static readonly StyledProperty<Stretch> StretchProperty = |
| 107 | + AvaloniaProperty.Register<ImageSelector, Stretch>(nameof(Stretch)); |
| 108 | + |
| 109 | + public Stretch Stretch |
| 110 | + { |
| 111 | + get => GetValue(StretchProperty); |
| 112 | + set => SetValue(StretchProperty, value); |
| 113 | + } |
| 114 | + |
| 115 | + public static readonly DirectProperty<ImageSelector, Uri?> UriProperty = |
| 116 | + AvaloniaProperty.RegisterDirect<ImageSelector, Uri?>(nameof(Uri), o => o.Uri); |
| 117 | + |
| 118 | + private Uri? _uri; |
| 119 | + |
| 120 | + public Uri? Uri |
| 121 | + { |
| 122 | + get => _uri; |
| 123 | + private set => SetAndRaise(UriProperty, ref _uri, value); |
| 124 | + } |
| 125 | + |
| 126 | + public static readonly DirectProperty<ImageSelector, IBrush?> PreviewBrushProperty = |
| 127 | + AvaloniaProperty.RegisterDirect<ImageSelector, IBrush?>(nameof(PreviewBrush), o => o.PreviewBrush); |
| 128 | + |
| 129 | + private IBrush? _previewBrush; |
| 130 | + |
| 131 | + public IBrush? PreviewBrush |
| 132 | + { |
| 133 | + get => _previewBrush; |
| 134 | + private set => SetAndRaise(PreviewBrushProperty, ref _previewBrush, value); |
| 135 | + } |
| 136 | + public static readonly StyledProperty<double> StrokeThicknessProperty = |
| 137 | + AvaloniaProperty.Register<ImageSelector, double>(nameof(StrokeThickness), 1.0); |
| 138 | + |
| 139 | + public double StrokeThickness |
| 140 | + { |
| 141 | + get => GetValue(StrokeThicknessProperty); |
| 142 | + set => SetValue(StrokeThicknessProperty, value); |
| 143 | + } |
| 144 | + |
| 145 | + public static readonly StyledProperty<Avalonia.Collections.AvaloniaList<double>?> StrokeDashArrayProperty = |
| 146 | + AvaloniaProperty.Register<ImageSelector, Avalonia.Collections.AvaloniaList<double>?>(nameof(StrokeDashArray)); |
| 147 | + |
| 148 | + public Avalonia.Collections.AvaloniaList<double>? StrokeDashArray |
| 149 | + { |
| 150 | + get => GetValue(StrokeDashArrayProperty); |
| 151 | + set => SetValue(StrokeDashArrayProperty, value); |
| 152 | + } |
| 153 | + |
| 154 | + public static readonly StyledProperty<string> DefaultExtProperty = |
| 155 | + AvaloniaProperty.Register<ImageSelector, string>(nameof(DefaultExt), ".jpg"); |
| 156 | + |
| 157 | + public string DefaultExt |
| 158 | + { |
| 159 | + get => GetValue(DefaultExtProperty); |
| 160 | + set => SetValue(DefaultExtProperty, value); |
| 161 | + } |
| 162 | + |
| 163 | + public static readonly StyledProperty<string> FilterProperty = |
| 164 | + AvaloniaProperty.Register<ImageSelector, string>(nameof(Filter), "(.jpg)|*.jpg|(.png)|*.png"); |
| 165 | + |
| 166 | + public string Filter |
| 167 | + { |
| 168 | + get => GetValue(FilterProperty); |
| 169 | + set => SetValue(FilterProperty, value); |
| 170 | + } |
| 171 | + |
| 172 | + /// <summary> |
| 173 | + /// Parses a WPF-style filter string like "(.jpg)|*.jpg|(.png)|*.png" |
| 174 | + /// into individual glob patterns for the cross-platform file picker. |
| 175 | + /// </summary> |
| 176 | + private static List<string> ParseFilterPatterns(string? filter) |
| 177 | + { |
| 178 | + var patterns = new List<string>(); |
| 179 | + |
| 180 | + if (string.IsNullOrWhiteSpace(filter)) |
| 181 | + { |
| 182 | + // Sensible cross-platform defaults with case variations |
| 183 | + patterns.AddRange(["*.jpg", "*.jpeg", "*.png", "*.bmp", "*.gif"]); |
| 184 | + patterns.AddRange(["*.JPG", "*.JPEG", "*.PNG", "*.BMP", "*.GIF"]); |
| 185 | + return patterns; |
| 186 | + } |
| 187 | + |
| 188 | + // WPF format: "Description|*.ext|Description|*.ext" |
| 189 | + var parts = filter.Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); |
| 190 | + |
| 191 | + foreach (var part in parts) |
| 192 | + { |
| 193 | + // Skip display-name segments (e.g. "(.jpg)"); only keep pattern segments |
| 194 | + if (part.Contains('*') || part.Contains('?')) |
| 195 | + { |
| 196 | + // Split multi-pattern entries like "*.jpg;*.png" |
| 197 | + foreach (var p in part.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) |
| 198 | + { |
| 199 | + var trimmed = p.Trim(); |
| 200 | + if (!string.IsNullOrEmpty(trimmed) && !patterns.Contains(trimmed)) |
| 201 | + { |
| 202 | + patterns.Add(trimmed); |
| 203 | + // Add uppercase variant for case-sensitive platforms (macOS) |
| 204 | + var upper = trimmed.ToUpperInvariant(); |
| 205 | + if (upper != trimmed && !patterns.Contains(upper)) |
| 206 | + patterns.Add(upper); |
| 207 | + } |
| 208 | + } |
| 209 | + } |
| 210 | + } |
| 211 | + |
| 212 | + // Fallback if parsing produced nothing |
| 213 | + if (patterns.Count == 0) |
| 214 | + { |
| 215 | + patterns.AddRange(["*.jpg", "*.jpeg", "*.png", "*.bmp", "*.gif", |
| 216 | + "*.JPG", "*.JPEG", "*.PNG", "*.BMP", "*.GIF"]); |
| 217 | + } |
| 218 | + |
| 219 | + return patterns; |
| 220 | + } |
| 221 | + |
| 222 | + public static readonly DirectProperty<ImageSelector, bool> HasValueProperty = |
| 223 | + AvaloniaProperty.RegisterDirect<ImageSelector, bool>(nameof(HasValue), o => o.HasValue); |
| 224 | + |
| 225 | + private bool _hasValue; |
| 226 | + |
| 227 | + public bool HasValue |
| 228 | + { |
| 229 | + get => _hasValue; |
| 230 | + private set => SetAndRaise(HasValueProperty, ref _hasValue, value); |
| 231 | + } |
| 232 | +} |
0 commit comments