Browse Source

Impl `SuggestedFileType` for Save/OpenFilePicker (#20026)

* new SuggestedFileType API

* Add impl for Win32 SuggestedFileType

* Impl suggestedFileType for GTK

* DBus impl

* Impl API for mac

* Add sample for suggested file filter

---------

Co-authored-by: Emmanuel Hansen <[email protected]>
Jumar Macato 2 weeks ago
parent
commit
80223ca7d4

+ 6 - 0
samples/ControlCatalog/Pages/DialogsPage.xaml

@@ -31,6 +31,12 @@
       <ComboBoxItem>TXT mime only</ComboBoxItem>
       <ComboBoxItem>TXT apple type id only</ComboBoxItem>
     </ComboBox>
+    <StackPanel Orientation="Horizontal" Spacing="8">
+      <CheckBox Name="UseSuggestedFilter">Use SuggestedFileType</CheckBox>
+      <ComboBox Name="SuggestedFilterSelector" MinWidth="160">
+        <ComboBoxItem>First filter</ComboBoxItem>
+      </ComboBox>
+    </StackPanel>
     <Expander Header="FilePicker API">
       <StackPanel Spacing="4">
         <CheckBox Name="ForceManaged">Force managed dialog</CheckBox>

+ 69 - 3
samples/ControlCatalog/Pages/DialogsPage.xaml.cs

@@ -37,6 +37,8 @@ namespace ControlCatalog.Pages
             var openedFileContent = OpenedFileContent;
             var openMultiple = OpenMultiple;
             var currentFolderBox = CurrentFolderBox;
+            var useSuggestedFilter = UseSuggestedFilter;
+            var suggestedFilterSelector = SuggestedFilterSelector;
 
             currentFolderBox.TextChanged += async (sender, args) =>
             {
@@ -76,7 +78,7 @@ namespace ControlCatalog.Pages
                 }).ToList() ?? new List<FileDialogFilter>();
             }
 
-            List<FilePickerFileType>? GetFileTypes()
+            List<FilePickerFileType>? BuildFileTypes()
             {
                 var selectedItem = (FilterSelector.SelectedItem as ComboBoxItem)?.Content
                     ?? "None";
@@ -115,6 +117,64 @@ namespace ControlCatalog.Pages
                 };
             }
 
+            List<FilePickerFileType>? GetFileTypes()
+            {
+                var types = BuildFileTypes();
+                UpdateSuggestedFilterSelector(types);
+                return types;
+            }
+
+            void UpdateSuggestedFilterSelector(IReadOnlyList<FilePickerFileType>? types)
+            {
+                var previouslySelected = (suggestedFilterSelector.SelectedItem as ComboBoxItem)?.Tag as FilePickerFileType;
+                suggestedFilterSelector.Items.Clear();
+                suggestedFilterSelector.Items.Add(new ComboBoxItem { Content = "First filter", Tag = null });
+
+                var desiredIndex = 0;
+                if (types is { Count: > 0 })
+                {
+                    for (var i = 0; i < types.Count; i++)
+                    {
+                        var type = types[i];
+                        var item = new ComboBoxItem { Content = type.Name, Tag = type };
+                        suggestedFilterSelector.Items.Add(item);
+
+                        if (previouslySelected is not null && ReferenceEquals(previouslySelected, type))
+                        {
+                            desiredIndex = i + 1;
+                        }
+                    }
+                }
+
+                suggestedFilterSelector.SelectedIndex = desiredIndex;
+            }
+
+            FilePickerFileType? GetSuggestedFileType(IReadOnlyList<FilePickerFileType>? types)
+            {
+                if (useSuggestedFilter.IsChecked == true && types is { Count: > 0 })
+                {
+                    if (suggestedFilterSelector.SelectedItem is ComboBoxItem { Tag: FilePickerFileType selectedType }
+                        && types.Any(t => ReferenceEquals(t, selectedType)))
+                    {
+                        return selectedType;
+                    }
+
+                    return types.FirstOrDefault();
+                }
+
+                return null;
+            }
+
+            void UpdateSuggestedFilterSelectorState() =>
+                suggestedFilterSelector.IsEnabled = useSuggestedFilter.IsChecked == true;
+
+            useSuggestedFilter.Checked += (_, _) => UpdateSuggestedFilterSelectorState();
+            useSuggestedFilter.Unchecked += (_, _) => UpdateSuggestedFilterSelectorState();
+            UpdateSuggestedFilterSelectorState();
+
+            FilterSelector.SelectionChanged += (_, _) => UpdateSuggestedFilterSelector(BuildFileTypes());
+            UpdateSuggestedFilterSelector(BuildFileTypes());
+
             OpenFile.Click += async delegate
             {
                 // Almost guaranteed to exist
@@ -229,10 +289,12 @@ namespace ControlCatalog.Pages
 
             OpenFilePicker.Click += async delegate
             {
+                var fileTypes = GetFileTypes();
                 var result = await GetStorageProvider().OpenFilePickerAsync(new FilePickerOpenOptions()
                 {
                     Title = "Open file",
-                    FileTypeFilter = GetFileTypes(),
+                    FileTypeFilter = fileTypes,
+                    SuggestedFileType = GetSuggestedFileType(fileTypes),
                     SuggestedFileName = "FileName",
                     SuggestedStartLocation = lastSelectedDirectory,
                     AllowMultiple = openMultiple.IsChecked == true
@@ -243,10 +305,12 @@ namespace ControlCatalog.Pages
             SaveFilePicker.Click += async delegate
             {
                 var fileTypes = GetFileTypes();
+                var suggestedType = GetSuggestedFileType(fileTypes);
                 var file = await GetStorageProvider().SaveFilePickerAsync(new FilePickerSaveOptions()
                 {
                     Title = "Save file",
                     FileTypeChoices = fileTypes,
+                    SuggestedFileType = suggestedType,
                     SuggestedStartLocation = lastSelectedDirectory,
                     SuggestedFileName = "FileName",
                     ShowOverwritePrompt = true
@@ -278,10 +342,12 @@ namespace ControlCatalog.Pages
             };
             SaveFilePickerWithResult.Click += async delegate
             {
+                var saveFileTypes = new[] { FilePickerFileTypes.Json, FilePickerFileTypes.Xml };
                 var result = await GetStorageProvider().SaveFilePickerWithResultAsync(new FilePickerSaveOptions()
                 {
                     Title = "Save file",
-                    FileTypeChoices = [FilePickerFileTypes.Json, FilePickerFileTypes.Xml],
+                    FileTypeChoices = saveFileTypes,
+                    SuggestedFileType = GetSuggestedFileType(saveFileTypes),
                     SuggestedStartLocation = lastSelectedDirectory,
                     SuggestedFileName = "FileName",
                     ShowOverwritePrompt = true

+ 9 - 0
src/Avalonia.Base/Platform/Storage/FilePickerOpenOptions.cs

@@ -7,6 +7,15 @@ namespace Avalonia.Platform.Storage;
 /// </summary>
 public class FilePickerOpenOptions : PickerOptions
 {
+    /// <summary>
+    /// Gets or sets the file type that should be preselected when the dialog is opened.
+    /// </summary>
+    /// <remarks>
+    /// This value should reference one of the items in <see cref="FileTypeChoices"/>.
+    /// If not set, the first file type in <see cref="FileTypeChoices"/> may be selected by default.
+    /// </remarks>
+    public FilePickerFileType? SuggestedFileType { get; set; }
+
     /// <summary>
     /// Gets or sets an option indicating whether open picker allows users to select multiple files.
     /// </summary>

+ 9 - 0
src/Avalonia.Base/Platform/Storage/FilePickerSaveOptions.cs

@@ -7,6 +7,15 @@ namespace Avalonia.Platform.Storage;
 /// </summary>
 public class FilePickerSaveOptions : PickerOptions
 {
+    /// <summary>
+    /// Gets or sets the file type that should be preselected when the dialog is opened.
+    /// </summary>
+    /// <remarks>
+    /// This value should reference one of the items in <see cref="FileTypeChoices"/>.
+    /// If not set, the first file type in <see cref="FileTypeChoices"/> may be selected by default.
+    /// </remarks>
+    public FilePickerFileType? SuggestedFileType { get; set; }
+
     /// <summary>
     /// Gets or sets the default extension to be used to save the file.
     /// </summary>

+ 27 - 4
src/Avalonia.FreeDesktop/DBusSystemDialog.cs

@@ -63,8 +63,13 @@ namespace Avalonia.FreeDesktop
             ObjectPath objectPath;
             var chooserOptions = new Dictionary<string, VariantValue>();
 
-            if (TryParseFilters(options.FileTypeFilter, out var filters))
+            if (TryParseFilters(options.FileTypeFilter, options.SuggestedFileType, out var filters,
+                    out var currentFilter))
+            {
                 chooserOptions.Add("filters", filters);
+                if (currentFilter is { } filter)
+                    chooserOptions.Add("current_filter", filter);
+            }
 
             if (options.SuggestedStartLocation?.TryGetLocalPath() is { } folderPath)
                 chooserOptions.Add("current_folder", VariantValue.Array(Encoding.UTF8.GetBytes(folderPath + "\0")));
@@ -106,8 +111,13 @@ namespace Avalonia.FreeDesktop
             var parentWindow = $"x11:{_handle.Handle:X}";
             ObjectPath objectPath;
             var chooserOptions = new Dictionary<string, VariantValue>();
-            if (TryParseFilters(options.FileTypeChoices, out var filters))
+            if (TryParseFilters(options.FileTypeChoices, options.SuggestedFileType, out var filters,
+                    out var currentFilter))
+            {
                 chooserOptions.Add("filters", filters);
+                if (currentFilter is { } filter)
+                    chooserOptions.Add("current_filter", filter);
+            }
 
             if (options.SuggestedFileName is { } currentName)
                 chooserOptions.Add("current_name", VariantValue.String(currentName));
@@ -203,7 +213,10 @@ namespace Avalonia.FreeDesktop
                 .Select(static path => new BclStorageFolder(new DirectoryInfo(path))).ToList();
         }
 
-        private static bool TryParseFilters(IReadOnlyList<FilePickerFileType>? fileTypes, out VariantValue result)
+        private static bool TryParseFilters(IReadOnlyList<FilePickerFileType>? fileTypes,
+            FilePickerFileType? suggestedFileType,
+            out VariantValue result,
+            out VariantValue? currentFilter)
         {
             const uint GlobStyle = 0u;
             const uint MimeStyle = 1u;
@@ -212,10 +225,12 @@ namespace Avalonia.FreeDesktop
             if (fileTypes is null)
             {
                 result = default;
+                currentFilter = null;
                 return false;
             }
 
             var filters = new Array<Struct<string, Array<Struct<uint, string>>>>();
+            currentFilter = null;
 
             foreach (var fileType in fileTypes)
             {
@@ -228,7 +243,15 @@ namespace Avalonia.FreeDesktop
                 else
                     continue;
 
-                filters.Add(Struct.Create(fileType.Name, new Array<Struct<uint, string>>(extensions)));
+                var filterStruct = Struct.Create(fileType.Name, new Array<Struct<uint, string>>(extensions));
+                filters.Add(filterStruct);
+
+                if (suggestedFileType is not null && ReferenceEquals(fileType, suggestedFileType))
+                {
+                    currentFilter = VariantValue.Struct(
+                        VariantValue.String(filterStruct.Item1),
+                        filterStruct.Item2.AsVariantValue());
+                }
             }
 
             result = filters.AsVariantValue();

+ 15 - 5
src/Avalonia.Native/StorageProviderApi.cs

@@ -155,7 +155,7 @@ internal class StorageProviderApi(IAvnStorageProvider native, bool sandboxEnable
 
     public async Task<IReadOnlyList<IStorageFile>> OpenFileDialog(TopLevelImpl? topLevel, FilePickerOpenOptions options)
     {
-        using var fileTypes = new FilePickerFileTypesWrapper(options.FileTypeFilter, null);
+        using var fileTypes = new FilePickerFileTypesWrapper(options.FileTypeFilter, null, options.SuggestedFileType);
         var suggestedDirectory = options.SuggestedStartLocation?.Path.AbsoluteUri ?? string.Empty;
 
         var (items, _) = await OpenDialogAsync(events =>
@@ -174,7 +174,7 @@ internal class StorageProviderApi(IAvnStorageProvider native, bool sandboxEnable
 
     public async Task<(IStorageFile? file, FilePickerFileType? selectedType)> SaveFileDialog(TopLevelImpl? topLevel, FilePickerSaveOptions options)
     {
-        using var fileTypes = new FilePickerFileTypesWrapper(options.FileTypeChoices, options.DefaultExtension);
+        using var fileTypes = new FilePickerFileTypesWrapper(options.FileTypeChoices, options.DefaultExtension, options.SuggestedFileType);
         var suggestedDirectory = options.SuggestedStartLocation?.Path.AbsoluteUri ?? string.Empty;
 
         var (items, selectedFilterIndex) = await OpenDialogAsync(events =>
@@ -237,15 +237,25 @@ internal class StorageProviderApi(IAvnStorageProvider native, bool sandboxEnable
 
     internal class FilePickerFileTypesWrapper(
         IReadOnlyList<FilePickerFileType>? types,
-        string? defaultExtension)
+        string? defaultExtension,
+        FilePickerFileType? suggestedType)
         : NativeCallbackBase, IAvnFilePickerFileTypes
     {
         private readonly List<IDisposable> _disposables = new();
 
         public int Count => types?.Count ?? 0;
 
-        public int IsDefaultType(int index) => (defaultExtension is not null &&
-            types![index].TryGetExtensions()?.Any(defaultExtension.EndsWith) == true).AsComBool();
+        public int IsDefaultType(int index)
+        {
+            if (types is null)
+                return false.AsComBool();
+
+            if (suggestedType is not null && ReferenceEquals(types[index], suggestedType))
+                return true.AsComBool();
+
+            return (defaultExtension is not null &&
+                    types[index].TryGetExtensions()?.Any(defaultExtension.EndsWith) == true).AsComBool();
+        }
 
         public int IsAnyType(int index) =>
             (types![index].Patterns?.Contains("*.*") == true || types[index].MimeTypes?.Contains("*.*") == true)

+ 2 - 0
src/Avalonia.X11/NativeDialogs/Gtk.cs

@@ -101,6 +101,8 @@ namespace Avalonia.X11.NativeDialogs
 
         [DllImport(GtkName)]
         public static extern IntPtr gtk_file_chooser_get_filter(IntPtr chooser);
+        [DllImport(GtkName)]
+        public static extern void gtk_file_chooser_set_filter(IntPtr chooser, IntPtr filter);
         
         [DllImport(GtkName)]
         public static extern void gtk_widget_realize(IntPtr gtkWidget);

+ 11 - 5
src/Avalonia.X11/NativeDialogs/GtkNativeFileDialogs.cs

@@ -40,7 +40,7 @@ namespace Avalonia.X11.NativeDialogs
             return await await RunOnGlibThread(async () =>
             {
                 var (files, _) = await ShowDialog(options.Title, _window, GtkFileChooserAction.Open,
-                        options.AllowMultiple, options.SuggestedStartLocation, null, options.FileTypeFilter, null, false)
+                        options.AllowMultiple, options.SuggestedStartLocation, null, options.SuggestedFileType, options.FileTypeFilter, null, false)
                     .ConfigureAwait(false);
                 return files?.Where(f => File.Exists(f)).Select(f => new BclStorageFile(new FileInfo(f))).ToArray() ??
                        Array.Empty<IStorageFile>();
@@ -53,7 +53,7 @@ namespace Avalonia.X11.NativeDialogs
             {
                 var (folders, _) = await ShowDialog(options.Title, _window, GtkFileChooserAction.SelectFolder,
                         options.AllowMultiple, options.SuggestedStartLocation, null,
-                        null, null, false)
+                        null, null, null, false)
                     .ConfigureAwait(false);
                 return folders?.Select(f => new BclStorageFolder(new DirectoryInfo(f))).ToArray() ??
                        Array.Empty<IStorageFolder>();
@@ -65,7 +65,7 @@ namespace Avalonia.X11.NativeDialogs
             return await await RunOnGlibThread(async () =>
             {
                 var (files, _) = await ShowDialog(options.Title, _window, GtkFileChooserAction.Save,
-                        false, options.SuggestedStartLocation, options.SuggestedFileName, options.FileTypeChoices,
+                        false, options.SuggestedStartLocation, options.SuggestedFileName,options.SuggestedFileType, options.FileTypeChoices,
                         options.DefaultExtension, options.ShowOverwritePrompt ?? false)
                     .ConfigureAwait(false);
                 return files?.FirstOrDefault() is { } file
@@ -79,7 +79,7 @@ namespace Avalonia.X11.NativeDialogs
             return await await RunOnGlibThread(async () =>
             {
                 var (files, selectedFilter) = await ShowDialog(options.Title, _window, GtkFileChooserAction.Save,
-                        false, options.SuggestedStartLocation, options.SuggestedFileName, options.FileTypeChoices,
+                        false, options.SuggestedStartLocation, options.SuggestedFileName, options.SuggestedFileType, options.FileTypeChoices, 
                         options.DefaultExtension, options.ShowOverwritePrompt ?? false)
                     .ConfigureAwait(false);
                 var file = files?.FirstOrDefault() is { } path
@@ -92,7 +92,7 @@ namespace Avalonia.X11.NativeDialogs
 
         private unsafe Task<(string[]? files, FilePickerFileType? selectedFilter)> ShowDialog(string? title,
             IWindowImpl parent, GtkFileChooserAction action,
-            bool multiSelect, IStorageFolder? initialFolder, string? initialFileName,
+            bool multiSelect, IStorageFolder? initialFolder, string? initialFileName, FilePickerFileType? suggestedFileType,
             IEnumerable<FilePickerFileType>? filters, string? defaultExtension, bool overwritePrompt)
         {
             IntPtr dlg;
@@ -165,8 +165,14 @@ namespace Avalonia.X11.NativeDialogs
                         }
 
                         gtk_file_chooser_add_filter(dlg, filter);
+
+                        if (suggestedFileType != null && suggestedFileType == f)
+                        {
+                            gtk_file_chooser_set_filter(dlg, filter);
+                        }
                     }
                 }
+
             }
 
             disposables = new List<IDisposable>

+ 13 - 4
src/Windows/Avalonia.Win32/Win32StorageProvider.cs

@@ -4,6 +4,7 @@ using System.IO;
 using System.ComponentModel;
 using System.Runtime.InteropServices;
 using System.Threading.Tasks;
+using Avalonia.Controls.Utils;
 using Avalonia.Platform.Storage;
 using Avalonia.Platform.Storage.FileIO;
 using Avalonia.Win32.Interop;
@@ -33,7 +34,7 @@ namespace Avalonia.Win32
             var (folders, _) = await ShowFilePicker(
                 true, true,
                 options.AllowMultiple, false,
-                options.Title, options.SuggestedFileName, options.SuggestedStartLocation, null, null,
+                options.Title, options.SuggestedFileName, null, options.SuggestedStartLocation, null, null,
                 f => new BclStorageFolder(new DirectoryInfo(f)))
                 .ConfigureAwait(false);
             return folders;
@@ -44,7 +45,7 @@ namespace Avalonia.Win32
             var (files, _) = await ShowFilePicker(
                 true, false,
                 options.AllowMultiple, false,
-                options.Title, options.SuggestedFileName, options.SuggestedStartLocation,
+                options.Title, options.SuggestedFileName, options.SuggestedFileType, options.SuggestedStartLocation,
                 null, options.FileTypeFilter,
                 f => new BclStorageFile(new FileInfo(f)))
                 .ConfigureAwait(false);
@@ -56,7 +57,7 @@ namespace Avalonia.Win32
             var (files, _) = await ShowFilePicker(
                 false, false,
                 false, options.ShowOverwritePrompt,
-                options.Title, options.SuggestedFileName, options.SuggestedStartLocation,
+                options.Title, options.SuggestedFileName, options.SuggestedFileType, options.SuggestedStartLocation,
                 options.DefaultExtension, options.FileTypeChoices,
                 f => new BclStorageFile(new FileInfo(f)))
                 .ConfigureAwait(false);
@@ -68,7 +69,7 @@ namespace Avalonia.Win32
             var (files, index) = await ShowFilePicker(
                     false, false,
                     false, options.ShowOverwritePrompt,
-                    options.Title, options.SuggestedFileName, options.SuggestedStartLocation,
+                    options.Title, options.SuggestedFileName, options.SuggestedFileType, options.SuggestedStartLocation,
                     options.DefaultExtension, options.FileTypeChoices,
                     f => new BclStorageFile(new FileInfo(f)))
                 .ConfigureAwait(false);
@@ -88,6 +89,7 @@ namespace Avalonia.Win32
             bool? showOverwritePrompt,
             string? title,
             string? suggestedFileName,
+            FilePickerFileType? suggestedFileType,
             IStorageFolder? folder,
             string? defaultExtension,
             IReadOnlyList<FilePickerFileType>? filters,
@@ -118,6 +120,7 @@ namespace Avalonia.Win32
                     {
                         options &= ~FILEOPENDIALOGOPTIONS.FOS_OVERWRITEPROMPT;
                     }
+
                     frm.SetOptions(options);
 
                     defaultExtension ??= string.Empty;
@@ -152,6 +155,12 @@ namespace Avalonia.Win32
                         }
                     }
 
+                    if (suggestedFileType != null &&
+                        filters?.IndexOf(suggestedFileType) is { } fi and > -1)
+                    { 
+                        frm.SetFileTypeIndex((uint)(fi + 1));
+                    }
+
                     if (folder?.TryGetLocalPath() is { } folderPath)
                     {
                         var riid = UnmanagedMethods.ShellIds.IShellItem;