From efeda9d3102d8832e2346ce1a5106b583f30a468 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20S=CC=8Colte=CC=81s?= Date: Mon, 3 Mar 2025 23:11:11 +0100 Subject: [PATCH 1/3] Add StorageProvider actions --- .../StorageProvider/FileFilterParser.cs | 47 +++++++ .../StorageProvider/OpenFilePickerAction.cs | 108 +++++++++++++++ .../StorageProvider/OpenFolderPickerAction.cs | 90 +++++++++++++ .../StorageProvider/PickerActionBase.cs | 54 ++++++++ .../StorageProvider/SaveFilePickerAction.cs | 124 ++++++++++++++++++ 5 files changed, 423 insertions(+) create mode 100644 src/Avalonia.Xaml.Interactions/StorageProvider/FileFilterParser.cs create mode 100644 src/Avalonia.Xaml.Interactions/StorageProvider/OpenFilePickerAction.cs create mode 100644 src/Avalonia.Xaml.Interactions/StorageProvider/OpenFolderPickerAction.cs create mode 100644 src/Avalonia.Xaml.Interactions/StorageProvider/PickerActionBase.cs create mode 100644 src/Avalonia.Xaml.Interactions/StorageProvider/SaveFilePickerAction.cs diff --git a/src/Avalonia.Xaml.Interactions/StorageProvider/FileFilterParser.cs b/src/Avalonia.Xaml.Interactions/StorageProvider/FileFilterParser.cs new file mode 100644 index 000000000..616d97ef2 --- /dev/null +++ b/src/Avalonia.Xaml.Interactions/StorageProvider/FileFilterParser.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Avalonia.Platform.Storage; + +namespace Avalonia.Xaml.Interactions.Core; + +internal static class FileFilterParser +{ + public static List? ConvertToFilePickerFileType(string filter) + { + if (string.IsNullOrWhiteSpace(filter)) + { + return null; + } + + var parts = filter.Split('|'); + if (parts.Length % 2 != 0) + { + return null; + } + + var fileTypes = new List(); + + for (var i = 0; i < parts.Length; i += 2) + { + var description = parts[i]; + var patternPart = parts[i + 1]; + var index = description.IndexOf(" (", StringComparison.Ordinal); + if (index > 0) + { + description = description.Substring(0, index); + } + + description = description.Trim(); + + var patterns = patternPart + .Split([';'], StringSplitOptions.RemoveEmptyEntries) + .Select(p => p.Trim()) + .ToList(); + + fileTypes.Add(new FilePickerFileType(description) { Patterns = patterns }); + } + + return fileTypes; + } +} diff --git a/src/Avalonia.Xaml.Interactions/StorageProvider/OpenFilePickerAction.cs b/src/Avalonia.Xaml.Interactions/StorageProvider/OpenFilePickerAction.cs new file mode 100644 index 000000000..3aab6f2a3 --- /dev/null +++ b/src/Avalonia.Xaml.Interactions/StorageProvider/OpenFilePickerAction.cs @@ -0,0 +1,108 @@ +using System.Threading.Tasks; +using Avalonia.Controls; +using Avalonia.Platform.Storage; +using Avalonia.Threading; +using Avalonia.VisualTree; + +namespace Avalonia.Xaml.Interactions.Core; + +/// +/// An action that will open a file picker dialog. +/// +public class OpenFilePickerAction : PickerActionBase +{ + /// + /// Identifies the avalonia property. + /// + public static readonly StyledProperty AllowMultipleProperty = + AvaloniaProperty.Register(nameof(AllowMultiple)); + + /// + /// Identifies the avalonia property. + /// + public static readonly StyledProperty FileTypeFilterProperty = + AvaloniaProperty.Register(nameof(FileTypeFilter)); + + /// + /// Gets or sets an option indicating whether open picker allows users to select multiple files. This is an avalonia property. + /// + public bool AllowMultiple + { + get => GetValue(AllowMultipleProperty); + set => SetValue(AllowMultipleProperty, value); + } + + /// + /// Gets or sets the collection of file types that the file open picker displays. This is an avalonia property. + /// + public string? FileTypeFilter + { + get => GetValue(FileTypeFilterProperty); + set => SetValue(FileTypeFilterProperty, value); + } + + /// + /// Initializes a new instance of the class. + /// + public OpenFilePickerAction() + { + PassEventArgsToCommand = true; + } + + /// + /// Executes the action. + /// + /// The that is passed to the action by the behavior. Generally this is or a target object. + /// The value of this parameter is determined by the caller. + /// True if the command is successfully executed; else false. + public override object Execute(object? sender, object? parameter) + { + if (sender is not Visual visual) + { + return false; + } + + Dispatcher.UIThread.InvokeAsync(async () => await OpenFilePickerAsync(visual)); + + return true; + } + + private async Task OpenFilePickerAsync(Visual visual) + { + if (IsEnabled != true || Command is null) + { + return; + } + + var storageProvider = (visual.GetVisualRoot() as TopLevel)?.StorageProvider; + if (storageProvider is null) + { + return; + } + + var files = await storageProvider.OpenFilePickerAsync(new FilePickerOpenOptions + { + Title = Title, + SuggestedStartLocation = SuggestedStartLocation, + SuggestedFileName = SuggestedFileName, + AllowMultiple = AllowMultiple, + FileTypeFilter = FileTypeFilter is not null + ? FileFilterParser.ConvertToFilePickerFileType(FileTypeFilter) + : null + }); + + if (files.Count <= 0) + { + return; + } + + var resolvedParameter = ResolveParameter(files); + + if (!Command.CanExecute(resolvedParameter)) + { + return; + } + + Command.Execute(resolvedParameter); + } +} diff --git a/src/Avalonia.Xaml.Interactions/StorageProvider/OpenFolderPickerAction.cs b/src/Avalonia.Xaml.Interactions/StorageProvider/OpenFolderPickerAction.cs new file mode 100644 index 000000000..c3a06e940 --- /dev/null +++ b/src/Avalonia.Xaml.Interactions/StorageProvider/OpenFolderPickerAction.cs @@ -0,0 +1,90 @@ +using System.Threading.Tasks; +using Avalonia.Controls; +using Avalonia.Platform.Storage; +using Avalonia.Threading; +using Avalonia.VisualTree; + +namespace Avalonia.Xaml.Interactions.Core; + +/// +/// An action that will open a folder picker dialog. +/// +public class OpenFolderPickerAction : PickerActionBase +{ + /// + /// Identifies the avalonia property. + /// + public static readonly StyledProperty AllowMultipleProperty = + AvaloniaProperty.Register(nameof(AllowMultiple)); + + /// + /// Gets or sets an option indicating whether open picker allows users to select multiple folders. This is an avalonia property. + /// + public bool AllowMultiple + { + get => GetValue(AllowMultipleProperty); + set => SetValue(AllowMultipleProperty, value); + } + + /// + /// Initializes a new instance of the class. + /// + public OpenFolderPickerAction() + { + PassEventArgsToCommand = true; + } + + /// + /// Executes the action. + /// + /// The that is passed to the action by the behavior. Generally this is or a target object. + /// The value of this parameter is determined by the caller. + /// True if the command is successfully executed; else false. + public override object Execute(object? sender, object? parameter) + { + if (sender is not Visual visual) + { + return false; + } + + Dispatcher.UIThread.InvokeAsync(async () => await OpenFolderPickerAsync(visual)); + + return true; + } + + private async Task OpenFolderPickerAsync(Visual visual) + { + if (IsEnabled != true || Command is null) + { + return; + } + + var storageProvider = (visual.GetVisualRoot() as TopLevel)?.StorageProvider; + if (storageProvider is null) + { + return; + } + + var folders = await storageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions + { + Title = Title, + SuggestedStartLocation = SuggestedStartLocation, + SuggestedFileName = SuggestedFileName, + AllowMultiple = AllowMultiple + }); + + if (folders.Count <= 0) + { + return; + } + + var resolvedParameter = ResolveParameter(folders); + + if (!Command.CanExecute(resolvedParameter)) + { + return; + } + + Command.Execute(resolvedParameter); + } +} diff --git a/src/Avalonia.Xaml.Interactions/StorageProvider/PickerActionBase.cs b/src/Avalonia.Xaml.Interactions/StorageProvider/PickerActionBase.cs new file mode 100644 index 000000000..9912abc17 --- /dev/null +++ b/src/Avalonia.Xaml.Interactions/StorageProvider/PickerActionBase.cs @@ -0,0 +1,54 @@ +using Avalonia.Platform.Storage; + +namespace Avalonia.Xaml.Interactions.Core; + +/// +/// Base class for picker actions. +/// +public abstract class PickerActionBase : InvokeCommandActionBase +{ + /// + /// Identifies the avalonia property. + /// + public static readonly StyledProperty TitleProperty = + AvaloniaProperty.Register(nameof(Title)); + + /// + /// Identifies the avalonia property. + /// + public static readonly StyledProperty SuggestedStartLocationProperty = + AvaloniaProperty.Register(nameof(SuggestedStartLocation)); + + /// + /// Identifies the avalonia property. + /// + public static readonly StyledProperty SuggestedFileNameProperty = + AvaloniaProperty.Register(nameof(SuggestedFileName)); + + /// + /// Gets or sets the text that appears in the title bar of a picker. This is an avalonia property. + /// + public string? Title + { + get => GetValue(TitleProperty); + set => SetValue(TitleProperty, value); + } + + /// + /// Gets or sets the initial location where the file open picker looks for files to present to the user. This is an avalonia property. + /// + public IStorageFolder? SuggestedStartLocation + { + get => GetValue(SuggestedStartLocationProperty); + set => SetValue(SuggestedStartLocationProperty, value); + } + + /// + /// Gets or sets the file name that the file picker suggests to the user. This is an avalonia property. + /// + public string? SuggestedFileName + { + get => GetValue(SuggestedFileNameProperty); + set => SetValue(SuggestedFileNameProperty, value); + } +} diff --git a/src/Avalonia.Xaml.Interactions/StorageProvider/SaveFilePickerAction.cs b/src/Avalonia.Xaml.Interactions/StorageProvider/SaveFilePickerAction.cs new file mode 100644 index 000000000..b8109bb01 --- /dev/null +++ b/src/Avalonia.Xaml.Interactions/StorageProvider/SaveFilePickerAction.cs @@ -0,0 +1,124 @@ +using System.Threading.Tasks; +using Avalonia.Controls; +using Avalonia.Platform.Storage; +using Avalonia.Threading; +using Avalonia.VisualTree; + +namespace Avalonia.Xaml.Interactions.Core; + +/// +/// An action that will open a file picker dialog. +/// +public class SaveFilePickerAction : PickerActionBase +{ + /// + /// Identifies the avalonia property. + /// + public static readonly StyledProperty DefaultExtensionProperty = + AvaloniaProperty.Register(nameof(DefaultExtension)); + + /// + /// Identifies the avalonia property. + /// + public static readonly StyledProperty FileTypeChoicesProperty = + AvaloniaProperty.Register(nameof(FileTypeChoices)); + + /// + /// Identifies the avalonia property. + /// + public static readonly StyledProperty ShowOverwritePromptProperty = + AvaloniaProperty.Register(nameof(ShowOverwritePrompt)); + + /// + /// Gets or sets the default extension to be used to save the file. This is an avalonia property. + /// + public string? DefaultExtension + { + get => GetValue(DefaultExtensionProperty); + set => SetValue(DefaultExtensionProperty, value); + } + + /// + /// Gets or sets the collection of valid file types that the user can choose to assign to a file. This is an avalonia property. + /// + public string? FileTypeChoices + { + get => GetValue(FileTypeChoicesProperty); + set => SetValue(FileTypeChoicesProperty, value); + } + + /// + /// Gets or sets a value indicating whether file open picker displays a warning if the user specifies the name of a file that already exists. This is an avalonia property. + /// + public bool? ShowOverwritePrompt + { + get => GetValue(ShowOverwritePromptProperty); + set => SetValue(ShowOverwritePromptProperty, value); + } + + /// + /// Initializes a new instance of the class. + /// + public SaveFilePickerAction() + { + PassEventArgsToCommand = true; + } + + /// + /// Executes the action. + /// + /// The that is passed to the action by the behavior. Generally this is or a target object. + /// The value of this parameter is determined by the caller. + /// True if the command is successfully executed; else false. + public override object Execute(object? sender, object? parameter) + { + if (sender is not Visual visual) + { + return false; + } + + Dispatcher.UIThread.InvokeAsync(async () => await SaveFilePickerAsync(visual)); + + return true; + } + + private async Task SaveFilePickerAsync(Visual visual) + { + if (IsEnabled != true || Command is null) + { + return; + } + + var storageProvider = (visual.GetVisualRoot() as TopLevel)?.StorageProvider; + if (storageProvider is null) + { + return; + } + + var file = await storageProvider.SaveFilePickerAsync(new FilePickerSaveOptions + { + Title = Title, + SuggestedStartLocation = SuggestedStartLocation, + SuggestedFileName = SuggestedFileName, + DefaultExtension = DefaultExtension, + FileTypeChoices = FileTypeChoices is not null + ? FileFilterParser.ConvertToFilePickerFileType(FileTypeChoices) + : null, + ShowOverwritePrompt = ShowOverwritePrompt, + }); + + if (file is null) + { + return; + } + + var resolvedParameter = ResolveParameter(file); + + if (!Command.CanExecute(resolvedParameter)) + { + return; + } + + Command.Execute(resolvedParameter); + } +} From e3adfb52f255ac75d030483c4ecf04083a72886e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20S=CC=8Colte=CC=81s?= Date: Mon, 3 Mar 2025 23:27:01 +0100 Subject: [PATCH 2/3] Move to folders --- .../StorageProvider/{ => Core}/PickerActionBase.cs | 0 .../StorageProvider/{ => Utilities}/FileFilterParser.cs | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename src/Avalonia.Xaml.Interactions/StorageProvider/{ => Core}/PickerActionBase.cs (100%) rename src/Avalonia.Xaml.Interactions/StorageProvider/{ => Utilities}/FileFilterParser.cs (100%) diff --git a/src/Avalonia.Xaml.Interactions/StorageProvider/PickerActionBase.cs b/src/Avalonia.Xaml.Interactions/StorageProvider/Core/PickerActionBase.cs similarity index 100% rename from src/Avalonia.Xaml.Interactions/StorageProvider/PickerActionBase.cs rename to src/Avalonia.Xaml.Interactions/StorageProvider/Core/PickerActionBase.cs diff --git a/src/Avalonia.Xaml.Interactions/StorageProvider/FileFilterParser.cs b/src/Avalonia.Xaml.Interactions/StorageProvider/Utilities/FileFilterParser.cs similarity index 100% rename from src/Avalonia.Xaml.Interactions/StorageProvider/FileFilterParser.cs rename to src/Avalonia.Xaml.Interactions/StorageProvider/Utilities/FileFilterParser.cs From 2daa806ae3d1aa8a12c67e02e6484b714bbc71ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20S=CC=8Colte=CC=81s?= Date: Mon, 3 Mar 2025 23:27:09 +0100 Subject: [PATCH 3/3] Add converters --- .../StorageFileToReadStreamConverter.cs | 35 +++++++++++++++++++ .../StorageFileToWriteStreamConverter.cs | 35 +++++++++++++++++++ .../Converters/StorageItemToPathConverter.cs | 35 +++++++++++++++++++ 3 files changed, 105 insertions(+) create mode 100644 src/Avalonia.Xaml.Interactions/StorageProvider/Converters/StorageFileToReadStreamConverter.cs create mode 100644 src/Avalonia.Xaml.Interactions/StorageProvider/Converters/StorageFileToWriteStreamConverter.cs create mode 100644 src/Avalonia.Xaml.Interactions/StorageProvider/Converters/StorageItemToPathConverter.cs diff --git a/src/Avalonia.Xaml.Interactions/StorageProvider/Converters/StorageFileToReadStreamConverter.cs b/src/Avalonia.Xaml.Interactions/StorageProvider/Converters/StorageFileToReadStreamConverter.cs new file mode 100644 index 000000000..a28dd3d09 --- /dev/null +++ b/src/Avalonia.Xaml.Interactions/StorageProvider/Converters/StorageFileToReadStreamConverter.cs @@ -0,0 +1,35 @@ +using System; +using System.Globalization; +using Avalonia.Data; +using Avalonia.Data.Converters; +using Avalonia.Platform.Storage; + +namespace Avalonia.Xaml.Interactions.Core; + +/// +/// Converts a to a read stream. +/// +public class StorageFileToReadStreamConverter : IValueConverter +{ + /// + /// Gets a static instance of . + /// + public static StorageFileToReadStreamConverter Instance { get; } = new (); + + /// + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is IStorageFile storageFile) + { + return storageFile.OpenReadAsync(); + } + + return BindingOperations.DoNothing; + } + + /// + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + return BindingOperations.DoNothing; + } +} diff --git a/src/Avalonia.Xaml.Interactions/StorageProvider/Converters/StorageFileToWriteStreamConverter.cs b/src/Avalonia.Xaml.Interactions/StorageProvider/Converters/StorageFileToWriteStreamConverter.cs new file mode 100644 index 000000000..8f91db6bf --- /dev/null +++ b/src/Avalonia.Xaml.Interactions/StorageProvider/Converters/StorageFileToWriteStreamConverter.cs @@ -0,0 +1,35 @@ +using System; +using System.Globalization; +using Avalonia.Data; +using Avalonia.Data.Converters; +using Avalonia.Platform.Storage; + +namespace Avalonia.Xaml.Interactions.Core; + +/// +/// Converts a to a path. +/// +public class StorageFileToWriteStreamConverter : IValueConverter +{ + /// + /// Gets a static instance of . + /// + public static StorageFileToWriteStreamConverter Instance { get; } = new (); + + /// + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is IStorageFile storageFile) + { + return storageFile.OpenWriteAsync(); + } + + return BindingOperations.DoNothing; + } + + /// + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + return BindingOperations.DoNothing; + } +} diff --git a/src/Avalonia.Xaml.Interactions/StorageProvider/Converters/StorageItemToPathConverter.cs b/src/Avalonia.Xaml.Interactions/StorageProvider/Converters/StorageItemToPathConverter.cs new file mode 100644 index 000000000..86f19e014 --- /dev/null +++ b/src/Avalonia.Xaml.Interactions/StorageProvider/Converters/StorageItemToPathConverter.cs @@ -0,0 +1,35 @@ +using System; +using System.Globalization; +using Avalonia.Data; +using Avalonia.Data.Converters; +using Avalonia.Platform.Storage; + +namespace Avalonia.Xaml.Interactions.Core; + +/// +/// Converts a to a path. +/// +public class StorageItemToPathConverter : IValueConverter +{ + /// + /// Gets a static instance of . + /// + public static StorageItemToPathConverter Instance { get; } = new (); + + /// + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is IStorageItem storageItem) + { + return storageItem.Path; + } + + return BindingOperations.DoNothing; + } + + /// + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + return BindingOperations.DoNothing; + } +}