Browse Source

MacOS file type filter in native file dialog (#12899)

* Introduce IFilePickerFileTypes to have more control over file types in the native backend

* Update samples page for dialogs

* Rename to IAvnFilePickerFileTypes

* WIP

* Fix disabled popup

* Explicitly dispose AvnString and AvnStringArray + GetNSArrayOfStringsAndRelease

* Fix potential crash

---------

Co-authored-by: Jumar Macato <16554748+jmacato@users.noreply.github.com>
release/11.0.5-rc1
Max Katz 3 years ago
committed by Steven Kirk
parent
commit
27b3f04ec7
  1. 2
      native/Avalonia.Native/src/OSX/AvnString.h
  2. 32
      native/Avalonia.Native/src/OSX/AvnString.mm
  3. 339
      native/Avalonia.Native/src/OSX/SystemDialogs.mm
  4. 10
      samples/ControlCatalog/Pages/DialogsPage.xaml
  5. 66
      samples/ControlCatalog/Pages/DialogsPage.xaml.cs
  6. 3
      src/Avalonia.Base/Platform/Storage/FilePickerFileType.cs
  7. 1
      src/Avalonia.Base/Platform/Storage/FilePickerFileTypes.cs
  8. 27
      src/Avalonia.Native/AvnString.cs
  9. 85
      src/Avalonia.Native/SystemDialogs.cs
  10. 17
      src/Avalonia.Native/avn.idl
  11. 2
      src/Browser/Avalonia.Browser/Storage/BrowserStorageProvider.cs
  12. 10
      src/iOS/Avalonia.iOS/Storage/IOSStorageProvider.cs

2
native/Avalonia.Native/src/OSX/AvnString.h

@ -15,4 +15,6 @@ extern IAvnStringArray* CreateAvnStringArray(NSArray<NSURL*>* array);
extern IAvnStringArray* CreateAvnStringArray(NSString* string); extern IAvnStringArray* CreateAvnStringArray(NSString* string);
extern IAvnString* CreateByteArray(void* data, int len); extern IAvnString* CreateByteArray(void* data, int len);
extern NSString* GetNSStringAndRelease(IAvnString* s); extern NSString* GetNSStringAndRelease(IAvnString* s);
extern NSString* GetNSStringWithoutRelease(IAvnString* s);
extern NSArray<NSString*>* GetNSArrayOfStringsAndRelease(IAvnStringArray* array);
#endif /* AvnString_h */ #endif /* AvnString_h */

32
native/Avalonia.Native/src/OSX/AvnString.mm

@ -169,3 +169,35 @@ NSString* GetNSStringAndRelease(IAvnString* s)
return result; return result;
} }
NSString* GetNSStringWithoutRelease(IAvnString* s)
{
NSString* result = nil;
if (s != nullptr)
{
char* p;
if (s->Pointer((void**)&p) == S_OK && p != nullptr)
result = [NSString stringWithUTF8String:p];
}
return result;
}
NSArray<NSString*>* GetNSArrayOfStringsAndRelease(IAvnStringArray* array)
{
auto output = [NSMutableArray array];
if (array)
{
IAvnString* arrayItem;
for (int i = 0; i < array->GetCount(); i++)
{
if (array->Get(i, &arrayItem) == 0) {
NSString* ext = GetNSStringAndRelease(arrayItem);
[output addObject:ext];
}
}
array->Release();
}
return output;
}

339
native/Avalonia.Native/src/OSX/SystemDialogs.mm

@ -1,9 +1,73 @@
#include "common.h" #include "common.h"
#include "AvnString.h"
#include "INSWindowHolder.h" #include "INSWindowHolder.h"
#import <UniformTypeIdentifiers/UniformTypeIdentifiers.h> #import <UniformTypeIdentifiers/UniformTypeIdentifiers.h>
const int kFileTypePopupTag = 10975;
// Target for NSPopupButton control in file dialog's accessory view.
// ExtensionDropdownHandler is copied from Chromium MIT code of select_file_dialog_bridge
@interface ExtensionDropdownHandler : NSObject {
@private
// The file dialog to which this target object corresponds. Weak reference
// since the dialog_ will stay alive longer than this object.
NSSavePanel* _dialog;
// Two ivars serving the same purpose. While `_fileTypeLists` is for pre-macOS
// 11, and contains NSStrings with UTType identifiers, `_fileUTTypeLists` is
// for macOS 11 and later, and contains UTTypes.
NSArray<NSArray<NSString*>*>* __strong _fileTypeLists;
NSArray<NSArray<UTType*>*>* __strong _fileUTTypeLists
API_AVAILABLE(macos(11.0));
}
- (instancetype)initWithDialog:(NSSavePanel*)dialog
fileTypeLists:(NSArray<NSArray<NSString*>*>*)fileTypeLists;
- (instancetype)initWithDialog:(NSSavePanel*)dialog
fileUTTypeLists:(NSArray<NSArray<UTType*>*>*)fileUTTypeLists
API_AVAILABLE(macos(11.0));
- (void)popupAction:(id)sender;
@end
@implementation ExtensionDropdownHandler
- (instancetype)initWithDialog:(NSSavePanel*)dialog
fileTypeLists:(NSArray<NSArray<NSString*>*>*)fileTypeLists {
if ((self = [super init])) {
_dialog = dialog;
_fileTypeLists = fileTypeLists;
}
return self;
}
- (instancetype)initWithDialog:(NSSavePanel*)dialog
fileUTTypeLists:(NSArray<NSArray<UTType*>*>*)fileUTTypeLists
API_AVAILABLE(macos(11.0)) {
if ((self = [super init])) {
_dialog = dialog;
_fileUTTypeLists = fileUTTypeLists;
}
return self;
}
- (void)popupAction:(id)sender {
NSUInteger index = [sender indexOfSelectedItem];
if (@available(macOS 11, *)) {
_dialog.allowedContentTypes = [_fileUTTypeLists objectAtIndex:index];
} else {
_dialog.allowedFileTypes = [_fileTypeLists objectAtIndex:index];
}
}
@end
class SystemDialogs : public ComSingleObject<IAvnSystemDialogs, &IID_IAvnSystemDialogs> class SystemDialogs : public ComSingleObject<IAvnSystemDialogs, &IID_IAvnSystemDialogs>
{ {
ExtensionDropdownHandler* __strong _extension_dropdown_handler;
public: public:
FORWARD_IUNKNOWN() FORWARD_IUNKNOWN()
virtual void SelectFolderDialog (IAvnWindow* parentWindowHandle, virtual void SelectFolderDialog (IAvnWindow* parentWindowHandle,
@ -88,7 +152,7 @@ public:
const char* title, const char* title,
const char* initialDirectory, const char* initialDirectory,
const char* initialFile, const char* initialFile,
const char* filters) override IAvnFilePickerFileTypes* filters) override
{ {
@autoreleasepool @autoreleasepool
{ {
@ -113,25 +177,7 @@ public:
panel.nameFieldStringValue = [NSString stringWithUTF8String:initialFile]; panel.nameFieldStringValue = [NSString stringWithUTF8String:initialFile];
} }
if(filters != nullptr) SetAccessoryView(panel, filters, false);
{
auto filtersString = [NSString stringWithUTF8String:filters];
if(filtersString.length > 0)
{
auto allowedTypes = [filtersString componentsSeparatedByString:@";"];
// Prefer allowedContentTypes if available
if (@available(macOS 11.0, *))
{
panel.allowedContentTypes = ConvertToUTType(allowedTypes);
}
else
{
panel.allowedFileTypes = allowedTypes;
}
}
}
auto handler = ^(NSModalResponse result) { auto handler = ^(NSModalResponse result) {
if(result == NSFileHandlingPanelOKButton) if(result == NSFileHandlingPanelOKButton)
@ -187,7 +233,7 @@ public:
const char* title, const char* title,
const char* initialDirectory, const char* initialDirectory,
const char* initialFile, const char* initialFile,
const char* filters) override IAvnFilePickerFileTypes* filters) override
{ {
@autoreleasepool @autoreleasepool
{ {
@ -210,28 +256,7 @@ public:
panel.nameFieldStringValue = [NSString stringWithUTF8String:initialFile]; panel.nameFieldStringValue = [NSString stringWithUTF8String:initialFile];
} }
if(filters != nullptr) SetAccessoryView(panel, filters, true);
{
auto filtersString = [NSString stringWithUTF8String:filters];
if(filtersString.length > 0)
{
auto allowedTypes = [filtersString componentsSeparatedByString:@";"];
// Prefer allowedContentTypes if available
if (@available(macOS 11.0, *))
{
panel.allowedContentTypes = ConvertToUTType(allowedTypes);
}
else
{
panel.allowedFileTypes = allowedTypes;
}
panel.allowsOtherFileTypes = false;
panel.extensionHidden = false;
}
}
auto handler = ^(NSModalResponse result) { auto handler = ^(NSModalResponse result) {
if(result == NSFileHandlingPanelOKButton) if(result == NSFileHandlingPanelOKButton)
@ -240,9 +265,9 @@ public:
auto url = [panel URL]; auto url = [panel URL];
auto string = [url path]; auto string = [url path];
strings[0] = (void*)[string UTF8String]; strings[0] = (void*)[string UTF8String];
events->OnCompleted(1, &strings[0]); events->OnCompleted(1, &strings[0]);
[panel orderOut:panel]; [panel orderOut:panel];
@ -274,31 +299,221 @@ public:
} }
private: private:
NSMutableArray* ConvertToUTType(NSArray<NSString*>* allowedTypes) NSView* CreateAccessoryView() {
// The label. Add attributes per-OS to match the labels that macOS uses.
NSTextField* label = [NSTextField labelWithString:@"File format"];
label.translatesAutoresizingMaskIntoConstraints = NO;
label.textColor = NSColor.secondaryLabelColor;
if (@available(macOS 11.0, *)) {
label.font = [NSFont systemFontOfSize:[NSFont smallSystemFontSize]];
}
// The popup.
NSPopUpButton* popup = [[NSPopUpButton alloc] initWithFrame:NSZeroRect
pullsDown:NO];
popup.translatesAutoresizingMaskIntoConstraints = NO;
popup.tag = kFileTypePopupTag;
[popup setAutoenablesItems:NO];
// A view to group the label and popup together. The top-level view used as
// the accessory view will be stretched horizontally to match the width of
// the dialog, and the label and popup need to be grouped together as one
// view to do centering within it, so use a view to group the label and
// popup.
NSView* group = [[NSView alloc] initWithFrame:NSZeroRect];
group.translatesAutoresizingMaskIntoConstraints = NO;
[group addSubview:label];
[group addSubview:popup];
// This top-level view will be forced by the system to have the width of the
// save dialog.
NSView* view = [[NSView alloc] initWithFrame:NSZeroRect];
view.translatesAutoresizingMaskIntoConstraints = NO;
[view addSubview:group];
NSMutableArray* constraints = [NSMutableArray array];
// The required constraints for the group, instantiated top-to-bottom:
// ┌───────────────────┐
// │ ↕︎ │
// │ ↔︎ label ↔︎ popup ↔︎ │
// │ ↕︎ │
// └───────────────────┘
// Top.
[constraints
addObject:[popup.topAnchor constraintEqualToAnchor:group.topAnchor
constant:10]];
// Leading.
[constraints
addObject:[label.leadingAnchor constraintEqualToAnchor:group.leadingAnchor
constant:10]];
// Horizontal and vertical baseline between the label and popup.
CGFloat labelPopupPadding;
if (@available(macOS 11.0, *)) {
labelPopupPadding = 8;
} else {
labelPopupPadding = 5;
}
[constraints addObject:[popup.leadingAnchor
constraintEqualToAnchor:label.trailingAnchor
constant:labelPopupPadding]];
[constraints
addObject:[popup.firstBaselineAnchor
constraintEqualToAnchor:label.firstBaselineAnchor]];
// Trailing.
[constraints addObject:[group.trailingAnchor
constraintEqualToAnchor:popup.trailingAnchor
constant:10]];
// Bottom.
[constraints
addObject:[group.bottomAnchor constraintEqualToAnchor:popup.bottomAnchor
constant:10]];
// Then the constraints centering the group in the accessory view. Vertical
// spacing is fully specified, but as the horizontal size of the accessory
// view will be forced to conform to the save dialog, only specify horizontal
// centering.
// ┌──────────────┐
// │ ↕︎ │
// │ ↔group↔︎ │
// │ ↕︎ │
// └──────────────┘
// Top.
[constraints
addObject:[group.topAnchor constraintEqualToAnchor:view.topAnchor]];
// Centering.
[constraints addObject:[group.centerXAnchor
constraintEqualToAnchor:view.centerXAnchor]];
// Bottom.
[constraints
addObject:[view.bottomAnchor constraintEqualToAnchor:group.bottomAnchor]];
[NSLayoutConstraint activateConstraints:constraints];
return view;
}
void SetAccessoryView(NSSavePanel* panel,
IAvnFilePickerFileTypes* filters,
bool is_save_panel)
{ {
auto originalCount = [allowedTypes count]; NSView* accessory_view = CreateAccessoryView();
auto mapped = [[NSMutableArray alloc] init];
NSPopUpButton* popup = [accessory_view viewWithTag:kFileTypePopupTag];
if (@available(macOS 11.0, *)) NSMutableArray<NSArray<NSString*>*>* file_type_lists = [NSMutableArray array];
NSMutableArray* file_uttype_lists = [NSMutableArray array];
int default_extension_index = -1;
for (int i = 0; i < filters->GetCount(); i++)
{ {
for (int i = 0; i < originalCount; i++) NSString* type_description = GetNSStringAndRelease(filters->GetName(i));
{ [popup addItemWithTitle:type_description];
auto utTypeStr = allowedTypes[i];
auto utType = [UTType typeWithIdentifier:utTypeStr]; // If any type is included, enable allowsOtherFileTypes, and skip this filter on save panel.
if (utType == nil) if (filters->IsAnyType(i)) {
{ panel.allowsOtherFileTypes = YES;
utType = [UTType typeWithMIMEType:utTypeStr]; }
// If default extension is specified, auto select it later.
if (filters->IsDefaultType(i)) {
default_extension_index = i;
}
IAvnStringArray* array;
// Prefer types priority of: file ext -> apple type id -> mime.
// On macOS 10 we only support file extensions.
if (@available(macOS 11, *)) {
NSMutableArray* file_uttype_array = [NSMutableArray array];
bool typeCompleted = false;
if (filters->IsAnyType(i)) {
UTType* type = [UTType typeWithIdentifier:@"public.item"];
[file_uttype_array addObject:type];
typeCompleted = true;
} }
if (utType != nil) if (!typeCompleted && filters->GetExtensions(i, &array) == 0) {
{ for (NSString* ext in GetNSArrayOfStringsAndRelease(array))
[mapped addObject:utType]; {
UTType* type = [UTType typeWithFilenameExtension:ext];
if (type && ![file_uttype_array containsObject:type]) {
[file_uttype_array addObject:type];
typeCompleted = true;
}
}
}
if (!typeCompleted && filters->GetAppleUniformTypeIdentifiers(i, &array) == 0) {
for (NSString* ext in GetNSArrayOfStringsAndRelease(array))
{
UTType* type = [UTType typeWithIdentifier:ext];
if (type && ![file_uttype_array containsObject:type]) {
[file_uttype_array addObject:type];
typeCompleted = true;
}
}
}
if (!typeCompleted && filters->GetMimeTypes(i, &array) == 0) {
for (NSString* ext in GetNSArrayOfStringsAndRelease(array))
{
UTType* type = [UTType typeWithMIMEType:ext];
if (type && ![file_uttype_array containsObject:type]) {
[file_uttype_array addObject:type];
typeCompleted = true;
}
}
} }
[file_uttype_lists addObject:file_uttype_array];
} else {
NSMutableArray<NSString*>* file_type_array = [NSMutableArray array];
if (filters->IsAnyType(i)) {
[file_type_array addObject:@"*.*"];
}
else if (filters->GetExtensions(i, &array) == 0) {
for (NSString* ext in GetNSArrayOfStringsAndRelease(array))
{
if (![file_type_array containsObject:ext]) {
[file_type_array addObject:ext];
}
}
}
[file_type_lists addObject:file_type_array];
} }
} }
if ([file_uttype_lists count] == 0 && [file_type_lists count] == 0)
return;
return mapped; if (@available(macOS 11, *))
} _extension_dropdown_handler = [[ExtensionDropdownHandler alloc] initWithDialog:panel
fileUTTypeLists:file_uttype_lists];
else
_extension_dropdown_handler = [[ExtensionDropdownHandler alloc] initWithDialog:panel
fileTypeLists:file_type_lists];
[popup setTarget: _extension_dropdown_handler];
[popup setAction: @selector(popupAction:)];
if (default_extension_index != -1) {
[popup selectItemAtIndex:default_extension_index];
} else {
// Select the first item.
[popup selectItemAtIndex:0];
}
[_extension_dropdown_handler popupAction:popup];
if (popup.numberOfItems > 0) {
panel.accessoryView = accessory_view;
}
};
}; };
extern IAvnSystemDialogs* CreateSystemDialogs() extern IAvnSystemDialogs* CreateSystemDialogs()

10
samples/ControlCatalog/Pages/DialogsPage.xaml

@ -22,7 +22,15 @@
<TextBlock Margin="0,20,0,0" Text="Pickers:" /> <TextBlock Margin="0,20,0,0" Text="Pickers:" />
<CheckBox Name="UseFilters">Use filters</CheckBox> <Label Target="FilterSelector" Content="Filter" />
<ComboBox Name="FilterSelector" SelectedIndex="0">
<ComboBoxItem>None</ComboBoxItem>
<ComboBoxItem>All + TXT + BinLog</ComboBoxItem>
<ComboBoxItem>Binlog</ComboBoxItem>
<ComboBoxItem>TXT extension only</ComboBoxItem>
<ComboBoxItem>TXT mime only</ComboBoxItem>
<ComboBoxItem>TXT apple type id only</ComboBoxItem>
</ComboBox>
<Expander Header="FilePicker API"> <Expander Header="FilePicker API">
<StackPanel Spacing="4"> <StackPanel Spacing="4">
<CheckBox Name="ForceManaged">Force managed dialog</CheckBox> <CheckBox Name="ForceManaged">Force managed dialog</CheckBox>

66
samples/ControlCatalog/Pages/DialogsPage.xaml.cs

@ -59,37 +59,49 @@ namespace ControlCatalog.Pages
List<FileDialogFilter> GetFilters() List<FileDialogFilter> GetFilters()
{ {
if (this.Get<CheckBox>("UseFilters").IsChecked != true) return GetFileTypes()?.Select(f => new FileDialogFilter
return new List<FileDialogFilter>(); {
return new List<FileDialogFilter> Name = f.Name, Extensions = f.Patterns!.ToList()
{ }).ToList() ?? new List<FileDialogFilter>();
new FileDialogFilter
{
Name = "Text files (.txt)", Extensions = new List<string> {"txt"}
},
new FileDialogFilter
{
Name = "All files",
Extensions = new List<string> {"*"}
}
};
} }
List<FilePickerFileType>? GetFileTypes() List<FilePickerFileType>? GetFileTypes()
{ {
if (this.Get<CheckBox>("UseFilters").IsChecked != true) var selectedItem = (this.Get<ComboBox>("FilterSelector").SelectedItem as ComboBoxItem)?.Content
return null; ?? "None";
return new List<FilePickerFileType>
{ var binLogType = new FilePickerFileType("Binary Log")
FilePickerFileTypes.All, {
FilePickerFileTypes.TextPlain, Patterns = new[] { "*.binlog", "*.buildlog" },
new("Binary Log") MimeTypes = new[] { "application/binlog", "application/buildlog" },
{ AppleUniformTypeIdentifiers = new[] { "public.data" }
Patterns = new[] { "*.binlog", "*.buildlog" }, };
MimeTypes = new[] { "application/binlog", "application/buildlog" },
AppleUniformTypeIdentifiers = new []{ "public.data" } return selectedItem switch
} {
}; "All + TXT + BinLog" => new List<FilePickerFileType>
{
FilePickerFileTypes.All, FilePickerFileTypes.TextPlain, binLogType
},
"Binlog" => new List<FilePickerFileType> { binLogType },
"TXT extension only" => new List<FilePickerFileType>
{
new("TXT") { Patterns = FilePickerFileTypes.TextPlain.Patterns }
},
"TXT mime only" => new List<FilePickerFileType>
{
new("TXT") { MimeTypes = FilePickerFileTypes.TextPlain.MimeTypes }
},
"TXT apple type id only" => new List<FilePickerFileType>
{
new("TXT")
{
AppleUniformTypeIdentifiers =
FilePickerFileTypes.TextPlain.AppleUniformTypeIdentifiers
}
},
_ => null
};
} }
this.Get<Button>("OpenFile").Click += async delegate this.Get<Button>("OpenFile").Click += async delegate

3
src/Avalonia.Base/Platform/Storage/FilePickerFileType.cs

@ -47,10 +47,11 @@ public sealed class FilePickerFileType
internal IReadOnlyList<string>? TryGetExtensions() internal IReadOnlyList<string>? TryGetExtensions()
{ {
// Converts random glob pattern to a simple extension name. // Converts random glob pattern to a simple extension name.
// GetExtension should be sufficient here. // Path.GetExtension should be sufficient here,
// Only exception is "*.*proj" patterns that should be filtered as well. // Only exception is "*.*proj" patterns that should be filtered as well.
return Patterns?.Select(Path.GetExtension) return Patterns?.Select(Path.GetExtension)
.Where(e => !string.IsNullOrEmpty(e) && !e.Contains('*') && e.StartsWith(".")) .Where(e => !string.IsNullOrEmpty(e) && !e.Contains('*') && e.StartsWith("."))
.Select(e => e!.TrimStart('.'))
.ToArray()!; .ToArray()!;
} }
} }

1
src/Avalonia.Base/Platform/Storage/FilePickerFileTypes.cs

@ -8,6 +8,7 @@ public static class FilePickerFileTypes
public static FilePickerFileType All { get; } = new("All") public static FilePickerFileType All { get; } = new("All")
{ {
Patterns = new[] { "*.*" }, Patterns = new[] { "*.*" },
AppleUniformTypeIdentifiers = new[] { "public.item" },
MimeTypes = new[] { "*/*" } MimeTypes = new[] { "*/*" }
}; };

27
src/Avalonia.Native/AvnString.cs

@ -1,4 +1,6 @@
using System; using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Text; using System.Text;
@ -15,7 +17,7 @@ namespace Avalonia.Native.Interop
string[] ToStringArray(); string[] ToStringArray();
} }
internal class AvnString : NativeCallbackBase, IAvnString internal sealed class AvnString : NativeCallbackBase, IAvnString
{ {
private IntPtr _native; private IntPtr _native;
private int _nativeLen; private int _nativeLen;
@ -61,6 +63,29 @@ namespace Avalonia.Native.Interop
} }
} }
} }
internal sealed class AvnStringArray : NativeCallbackBase, IAvnStringArray
{
private readonly IAvnString[] _items;
public AvnStringArray(IEnumerable<string> items)
{
_items = items.Select(s => s.ToAvnString()).ToArray();
}
public string[] ToStringArray() => _items.Select(n => n.String).ToArray();
public uint Count => (uint)_items.Length;
public IAvnString Get(uint index) => _items[(int)index];
protected override void Destroyed()
{
foreach (var item in _items)
{
item.Dispose();
}
}
}
} }
namespace Avalonia.Native.Interop.Impl namespace Avalonia.Native.Interop.Impl
{ {

85
src/Avalonia.Native/SystemDialogs.cs

@ -32,6 +32,7 @@ namespace Avalonia.Native
public override async Task<IReadOnlyList<IStorageFile>> OpenFilePickerAsync(FilePickerOpenOptions options) public override async Task<IReadOnlyList<IStorageFile>> OpenFilePickerAsync(FilePickerOpenOptions options)
{ {
using var events = new SystemDialogEvents(); using var events = new SystemDialogEvents();
using var fileTypes = new FilePickerFileTypesWrapper(options.FileTypeFilter, null);
var suggestedDirectory = options.SuggestedStartLocation?.TryGetLocalPath() ?? string.Empty; var suggestedDirectory = options.SuggestedStartLocation?.TryGetLocalPath() ?? string.Empty;
@ -41,7 +42,7 @@ namespace Avalonia.Native
options.Title ?? string.Empty, options.Title ?? string.Empty,
suggestedDirectory, suggestedDirectory,
string.Empty, string.Empty,
PrepareFilterParameter(options.FileTypeFilter)); fileTypes);
var result = await events.Task.ConfigureAwait(false); var result = await events.Task.ConfigureAwait(false);
@ -52,6 +53,7 @@ namespace Avalonia.Native
public override async Task<IStorageFile?> SaveFilePickerAsync(FilePickerSaveOptions options) public override async Task<IStorageFile?> SaveFilePickerAsync(FilePickerSaveOptions options)
{ {
using var events = new SystemDialogEvents(); using var events = new SystemDialogEvents();
using var fileTypes = new FilePickerFileTypesWrapper(options.FileTypeChoices, options.DefaultExtension);
var suggestedDirectory = options.SuggestedStartLocation?.TryGetLocalPath() ?? string.Empty; var suggestedDirectory = options.SuggestedStartLocation?.TryGetLocalPath() ?? string.Empty;
@ -60,7 +62,7 @@ namespace Avalonia.Native
options.Title ?? string.Empty, options.Title ?? string.Empty,
suggestedDirectory, suggestedDirectory,
options.SuggestedFileName ?? string.Empty, options.SuggestedFileName ?? string.Empty,
PrepareFilterParameter(options.FileTypeChoices)); fileTypes);
var result = await events.Task.ConfigureAwait(false); var result = await events.Task.ConfigureAwait(false);
return result.FirstOrDefault() is string file return result.FirstOrDefault() is string file
@ -80,26 +82,69 @@ namespace Avalonia.Native
return result?.Select(f => new BclStorageFolder(new DirectoryInfo(f))).ToArray() return result?.Select(f => new BclStorageFolder(new DirectoryInfo(f))).ToArray()
?? Array.Empty<IStorageFolder>(); ?? Array.Empty<IStorageFolder>();
} }
}
internal class FilePickerFileTypesWrapper : NativeCallbackBase, IAvnFilePickerFileTypes
{
private readonly IReadOnlyList<FilePickerFileType>? _types;
private readonly string? _defaultExtension;
private readonly List<IDisposable> _disposables;
private static string PrepareFilterParameter(IReadOnlyList<FilePickerFileType>? fileTypes) public FilePickerFileTypesWrapper(
IReadOnlyList<FilePickerFileType>? types,
string? defaultExtension)
{ {
return string.Join(";", _types = types;
fileTypes?.SelectMany(f => _defaultExtension = defaultExtension;
{ _disposables = new List<IDisposable>();
// On the native side we will try to parse identifiers or mimetypes. }
if (f.AppleUniformTypeIdentifiers?.Any() == true)
{ public int Count => _types?.Count ?? 0;
return f.AppleUniformTypeIdentifiers;
} public int IsDefaultType(int index) => (_defaultExtension is not null &&
else if (f.MimeTypes?.Any() == true) _types![index].TryGetExtensions()?.Any(ext => _defaultExtension.EndsWith(ext)) == true).AsComBool();
{
// MacOS doesn't accept "all" type, so it's pointless to pass it. public int IsAnyType(int index) =>
return f.MimeTypes.Where(t => t != "*/*"); (_types![index].Patterns?.Contains("*.*") == true || _types[index].MimeTypes?.Contains("*.*") == true)
} .AsComBool();
return Array.Empty<string>(); public IAvnString GetName(int index)
}) ?? {
Array.Empty<string>()); return EnsureDisposable(_types![index].Name.ToAvnString());
}
public IAvnStringArray GetPatterns(int index)
{
return EnsureDisposable(new AvnStringArray(_types![index].Patterns ?? Array.Empty<string>()));
}
public IAvnStringArray GetExtensions(int index)
{
return EnsureDisposable(new AvnStringArray(_types![index].TryGetExtensions() ?? Array.Empty<string>()));
}
public IAvnStringArray GetMimeTypes(int index)
{
return EnsureDisposable(new AvnStringArray(_types![index].MimeTypes ?? Array.Empty<string>()));
}
public IAvnStringArray GetAppleUniformTypeIdentifiers(int index)
{
return EnsureDisposable(new AvnStringArray(_types![index].AppleUniformTypeIdentifiers ?? Array.Empty<string>()));
}
protected override void Destroyed()
{
foreach (var disposable in _disposables)
{
disposable.Dispose();
}
}
private T EnsureDisposable<T>(T input) where T : IDisposable
{
_disposables.Add(input);
return input;
} }
} }

17
src/Avalonia.Native/avn.idl

@ -695,14 +695,27 @@ interface IAvnSystemDialogs : IUnknown
[const] char* title, [const] char* title,
[const] char* initialDirectory, [const] char* initialDirectory,
[const] char* initialFile, [const] char* initialFile,
[const] char* filters); IAvnFilePickerFileTypes* filters);
void SaveFileDialog(IAvnWindow* parentWindowHandle, void SaveFileDialog(IAvnWindow* parentWindowHandle,
IAvnSystemDialogEvents* events, IAvnSystemDialogEvents* events,
[const] char* title, [const] char* title,
[const] char* initialDirectory, [const] char* initialDirectory,
[const] char* initialFile, [const] char* initialFile,
[const] char* filters); IAvnFilePickerFileTypes* filters);
}
[uuid(4d7ab7db-a111-406f-abeb-11cb6aa033d5)]
interface IAvnFilePickerFileTypes : IUnknown
{
int GetCount();
bool IsDefaultType(int index);
bool IsAnyType(int index);
IAvnString* GetName(int index);
HRESULT GetPatterns(int index, IAvnStringArray**ppv);
HRESULT GetExtensions(int index, IAvnStringArray**ppv);
HRESULT GetMimeTypes(int index, IAvnStringArray**ppv);
HRESULT GetAppleUniformTypeIdentifiers(int index, IAvnStringArray**ppv);
} }
[uuid(9a52bc7a-d8c7-4230-8d34-704a0b70a933)] [uuid(9a52bc7a-d8c7-4230-8d34-704a0b70a933)]

2
src/Browser/Avalonia.Browser/Storage/BrowserStorageProvider.cs

@ -147,7 +147,7 @@ internal class BrowserStorageProvider : IStorageProvider
{ {
var types = input? var types = input?
.Where(t => t.MimeTypes?.Any() == true && t != FilePickerFileTypes.All) .Where(t => t.MimeTypes?.Any() == true && t != FilePickerFileTypes.All)
.Select(t => StorageHelper.CreateAcceptType(t.Name, t.MimeTypes!.ToArray(), t.TryGetExtensions()?.ToArray())) .Select(t => StorageHelper.CreateAcceptType(t.Name, t.MimeTypes!.ToArray(), t.TryGetExtensions()?.Select(e => "." + e).ToArray()))
.ToArray(); .ToArray();
if (types?.Length == 0) if (types?.Length == 0)
{ {

10
src/iOS/Avalonia.iOS/Storage/IOSStorageProvider.cs

@ -39,17 +39,17 @@ internal class IOSStorageProvider : IStorageProvider
{ {
// We check for OS version outside of the lambda, it's safe. // We check for OS version outside of the lambda, it's safe.
#pragma warning disable CA1416 #pragma warning disable CA1416
if (f.AppleUniformTypeIdentifiers?.Any() == true) if (f.TryGetExtensions() is { } extensions && extensions.Any())
{ {
return f.AppleUniformTypeIdentifiers.Select(id => UTType.CreateFromIdentifier(id)); return extensions.Select(UTType.CreateFromExtension);
} }
if (f.TryGetExtensions() is { } extensions && extensions.Any()) if (f.AppleUniformTypeIdentifiers?.Any() == true)
{ {
return extensions.Select(id => UTType.CreateFromExtension(id.TrimStart('.'))); return f.AppleUniformTypeIdentifiers.Select(UTType.CreateFromIdentifier);
} }
if (f.MimeTypes?.Any() == true) if (f.MimeTypes?.Any() == true)
{ {
return f.MimeTypes.Select(id => UTType.CreateFromMimeType(id)); return f.MimeTypes.Select(UTType.CreateFromMimeType);
} }
return Array.Empty<UTType>(); return Array.Empty<UTType>();
#pragma warning restore CA1416 #pragma warning restore CA1416

Loading…
Cancel
Save