diff --git a/Nuclex.Avalonia (netstandard-2.0).csproj b/Nuclex.Avalonia (netstandard-2.0).csproj index db26abd..7dfec96 100644 --- a/Nuclex.Avalonia (netstandard-2.0).csproj +++ b/Nuclex.Avalonia (netstandard-2.0).csproj @@ -23,7 +23,7 @@ - + diff --git a/Properties/AssemblyInfo.cs b/Properties/AssemblyInfo.cs index b8b75ee..4841033 100644 --- a/Properties/AssemblyInfo.cs +++ b/Properties/AssemblyInfo.cs @@ -47,4 +47,4 @@ using System.Runtime.InteropServices; // Build Number // Revision // -[assembly: AssemblyVersion("1.3.3")] +[assembly: AssemblyVersion("1.4.0")] diff --git a/Source/CommonDialogs/AvaloniaFileSelector.cs b/Source/CommonDialogs/AvaloniaFileSelector.cs new file mode 100644 index 0000000..fc9d89f --- /dev/null +++ b/Source/CommonDialogs/AvaloniaFileSelector.cs @@ -0,0 +1,232 @@ +#region Apache License 2.0 +/* +Nuclex Foundation libraries for .NET +Copyright (C) 2002-2025 Markus Ewald / Nuclex Development Labs + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +#endregion // Apache License 2.0 + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +using Avalonia.Controls; +using Avalonia.Platform.Storage; + +namespace Nuclex.Avalonia.CommonDialogs { + + /// Displays the file picked dialog using the Avalonia interfaces + /// + /// The interface is used to determine the topmost + /// window of your application. so you either need to implement said interface or + /// build an MVVM application based on the class which + /// will automatically provide an implementation of this interface and track which + /// dialog (or main window) of the application is active. + /// + public class AvaloniaFileDialogs : IFilePickerService, IDirectoryPickerService { + + /// Initialzies a new Avalonia-based file selector service + /// + /// Used to determine the currently active window to which the file selector + /// dialog will be parented + /// + public AvaloniaFileDialogs(IActiveWindowTracker activeWindowTracker) { + this.activeWindowTracker = activeWindowTracker; + } + + /// Shows a file selector asking the user where to save a file + /// + /// Caption to use for the dialog asking the user for a save location + /// + /// File types the user can choose to save as + /// + /// A task that will provide the pre-opened file for saving or null if + /// the user has canceled the file picker dialog + /// + public Task AskForSaveLocationAsync( + string caption, params FilePickerFileType[] fileTypes + ) { + var options = new FilePickerSaveOptions() { + Title = caption, + ShowOverwritePrompt = true, + FileTypeChoices = fileTypes, + }; + + string? firstExtensionMentioned = findFirstExtensionMentioned(fileTypes); + if(firstExtensionMentioned != null) { + options.DefaultExtension = firstExtensionMentioned; + } + + // The currently active window / view in which the user must have clicked + // on some kind of button or menu that triggered the open file(s) dialog + Window? askingWindow = this.activeWindowTracker.ActiveWindow; + if(askingWindow == null) { + throw new InvalidOperationException( + "Active window tracker did not provide an active Avalonia window. " + + "Was the file selector perhaps used in a headless app or in a unit test?" + ); + } + + // Show the Avalonia file picker dialog. Avalonia will return null if + // the user cancels the dialog, which matches our own interface contract. + return askingWindow.StorageProvider.SaveFilePickerAsync(options); + } + + /// Shows a file selector asking the user for a file to open + /// + /// Caption to use for the dialog asking the user to select a file + /// + /// File types the list is filtered for by default + /// + /// A task that will provide the pre-opened file for readiong or null if + /// the user has canceled the file picker dialog + /// + public async Task AskForFileToOpeAsync( + string caption, params FilePickerFileType[] fileTypes + ) { + var options = new FilePickerOpenOptions() { + Title = caption, + AllowMultiple = false, + FileTypeFilter = fileTypes + }; + + // The currently active window / view in which the user must have clicked + // on some kind of button or menu that triggered the open file(s) dialog + Window? askingWindow = this.activeWindowTracker.ActiveWindow; + if(askingWindow == null) { + throw new InvalidOperationException( + "Active window tracker did not provide an active Avalonia window. " + + "Was the file selector perhaps used in a headless app or in a unit test?" + ); + } + + // Show the Avalonia file picker dialog. Avalonia will return an empty list + // if the user cancels the dialog, but we want to be explicit, so if + // the list is either null (defensive programming) or contains no items, + // we will explicitly return a null result instead of an empty list. + IReadOnlyList selectedFile = ( + await askingWindow.StorageProvider.OpenFilePickerAsync(options) + ); + if((selectedFile == null) || (selectedFile.Count != 1)) { + return null; + } else { + return selectedFile[0]; + } + } + + /// Shows a file selector asking the user for a file to open + /// + /// Caption to use for the dialog asking the user to select a file + /// + /// File types the list is filtered for by default + /// + /// A task that will provide the pre-opened file for readiong or null if + /// the user has canceled the file picker dialog + /// + public async Task?> AskForFilesToOpenAsync( + string caption, params FilePickerFileType[] fileTypes + ) { + var options = new FilePickerOpenOptions() { + Title = caption, + AllowMultiple = true, + FileTypeFilter = fileTypes + }; + + // The currently active window / view in which the user must have clicked + // on some kind of button or menu that triggered the open file(s) dialog + Window? askingWindow = this.activeWindowTracker.ActiveWindow; + if(askingWindow == null) { + throw new InvalidOperationException( + "Active window tracker did not provide an active Avalonia window. " + + "Was the file selector perhaps used in a headless app or in a unit test?" + ); + } + + // Show the Avalonia file picker dialog. Avalonia will return an empty list + // if the user cancels the dialog, but we want to be explicit, so if + // the list is either null (defensive programming) or contains no items, + // we will explicitly return a null result instead of an empty list. + IReadOnlyList selectedFiles = ( + await askingWindow.StorageProvider.OpenFilePickerAsync(options) + ); + if((selectedFiles == null) || (selectedFiles.Count == 0)) { + return null; + } else { + return selectedFiles; + } + } + + /// Asks the user to select a directory + /// A task that will provide the directory the user has selected + public async Task AskForDirectory(string caption) { + var options = new FolderPickerOpenOptions() { + Title = caption, + AllowMultiple = false + }; + + // The currently active window / view in which the user must have clicked + // on some kind of button or menu that triggered the open directory dialog + Window? askingWindow = this.activeWindowTracker.ActiveWindow; + if(askingWindow == null) { + throw new InvalidOperationException( + "Active window tracker did not provide an active Avalonia window. " + + "Was the directory selector perhaps used in a headless app or in a unit test?" + ); + } + + // Show the Avalonia folder picker dialog. Avalonia will return an empty list + // if the user cancels the dialog, but we want to be explicit, so if + // the list is either null (defensive programming) or contains no items, + // we will explicitly return a null result instead of an empty list. + IReadOnlyList selectedFolders = ( + await askingWindow.StorageProvider.OpenFolderPickerAsync(options) + ); + if((selectedFolders == null) || (selectedFolders.Count != 1)) { + return null; + } else { + return selectedFolders[0]; + } + } + + /// Looks for the first file extension mentioned in the filters + /// Filters to search for the first mentioned extension + /// The first extension mentioned or null if none are mentioned + private static string? findFirstExtensionMentioned(FilePickerFileType[] fileTypes) { + for(int index = 0; index < fileTypes.Length; ++index) { + IReadOnlyList? patterns = fileTypes[index].Patterns; + if(patterns != null) { + int count = patterns.Count; + for(int patternIndex = 0; patternIndex < count; ++patternIndex) { + if(!string.IsNullOrEmpty(patterns[patternIndex])) { + string pattern = patterns[patternIndex]; + int finalDotIndex = pattern.LastIndexOf('.'); + if(finalDotIndex != -1) { + return patterns[patternIndex].Substring(finalDotIndex + 1); + } + } + } // for each pattern index + } // if patterns set + } // for each file type index + + return null; + } + + /// + /// Provides the active window the file picked dialog should become a child of + /// + private readonly IActiveWindowTracker activeWindowTracker; + + } + +} // namespace Nuclex.Avalonia.CommonDialogs diff --git a/Source/CommonDialogs/IDirectoryPickerService.cs b/Source/CommonDialogs/IDirectoryPickerService.cs new file mode 100644 index 0000000..0bbe43e --- /dev/null +++ b/Source/CommonDialogs/IDirectoryPickerService.cs @@ -0,0 +1,36 @@ +#region Apache License 2.0 +/* +Nuclex Foundation libraries for .NET +Copyright (C) 2002-2025 Markus Ewald / Nuclex Development Labs + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +#endregion // Apache License 2.0 + +using System; +using System.Threading.Tasks; + +using Avalonia.Platform.Storage; + +namespace Nuclex.Avalonia.CommonDialogs { + + /// Service that asks the user to pick a directory + public interface IDirectoryPickerService { + + /// Asks the user to select a directory + /// A task that will provide the directory the user has selected + Task AskForDirectory(string caption); + + } + +} // namespace Nuclex.Avalonia.CommonDialogs diff --git a/Source/CommonDialogs/IFilePickerService.cs b/Source/CommonDialogs/IFilePickerService.cs new file mode 100644 index 0000000..d55180b --- /dev/null +++ b/Source/CommonDialogs/IFilePickerService.cs @@ -0,0 +1,76 @@ +#region Apache License 2.0 +/* +Nuclex Foundation libraries for .NET +Copyright (C) 2002-2025 Markus Ewald / Nuclex Development Labs + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +#endregion // Apache License 2.0 + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +using Avalonia.Platform.Storage; + +namespace Nuclex.Avalonia.CommonDialogs { + + /// Service that asks the user to pick a file to open or to save + /// + /// All methods here work with Avalonia's interface to + /// support web and mobile platforms. + /// + public interface IFilePickerService { + + /// Shows a file selector asking the user where to save a file + /// + /// Caption to use for the dialog asking the user for a save location + /// + /// File types the user can choose to save as + /// + /// A task that will provide the pre-opened file for saving or null if + /// the user has canceled the file picker dialog + /// + Task AskForSaveLocationAsync( + string caption, params FilePickerFileType[] fileTypes + ); + + /// Shows a file selector asking the user for a file to open + /// + /// Caption to use for the dialog asking the user to select a file + /// + /// File types the list is filtered for by default + /// + /// A task that will provide the pre-opened file for readiong or null if + /// the user has canceled the file picker dialog + /// + Task AskForFileToOpeAsync( + string caption, params FilePickerFileType[] fileTypes + ); + + /// Shows a file selector asking the user for a file to open + /// + /// Caption to use for the dialog asking the user to select a file + /// + /// File types the list is filtered for by default + /// + /// A task that will provide the pre-opened file for readiong or null if + /// the user has canceled the file picker dialog + /// + Task?> AskForFilesToOpenAsync( + string caption, params FilePickerFileType[] fileTypes + ); + + } + +} // namespace Nuclex.Avalonia.CommonDialogs