diff --git a/native/Avalonia.Native/src/OSX/AvnString.h b/native/Avalonia.Native/src/OSX/AvnString.h index 3b750b11db..958e665180 100644 --- a/native/Avalonia.Native/src/OSX/AvnString.h +++ b/native/Avalonia.Native/src/OSX/AvnString.h @@ -15,4 +15,6 @@ extern IAvnStringArray* CreateAvnStringArray(NSArray* array); extern IAvnStringArray* CreateAvnStringArray(NSString* string); extern IAvnString* CreateByteArray(void* data, int len); extern NSString* GetNSStringAndRelease(IAvnString* s); +extern NSString* GetNSStringWithoutRelease(IAvnString* s); +extern NSArray* GetNSArrayOfStringsAndRelease(IAvnStringArray* array); #endif /* AvnString_h */ diff --git a/native/Avalonia.Native/src/OSX/AvnString.mm b/native/Avalonia.Native/src/OSX/AvnString.mm index 5e50068c51..78a40b215e 100644 --- a/native/Avalonia.Native/src/OSX/AvnString.mm +++ b/native/Avalonia.Native/src/OSX/AvnString.mm @@ -169,3 +169,35 @@ NSString* GetNSStringAndRelease(IAvnString* s) 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* 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; +} diff --git a/native/Avalonia.Native/src/OSX/SystemDialogs.mm b/native/Avalonia.Native/src/OSX/SystemDialogs.mm index e133a5d31f..c09464af4f 100644 --- a/native/Avalonia.Native/src/OSX/SystemDialogs.mm +++ b/native/Avalonia.Native/src/OSX/SystemDialogs.mm @@ -1,9 +1,73 @@ #include "common.h" +#include "AvnString.h" #include "INSWindowHolder.h" #import +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*>* __strong _fileTypeLists; + NSArray*>* __strong _fileUTTypeLists + API_AVAILABLE(macos(11.0)); +} + +- (instancetype)initWithDialog:(NSSavePanel*)dialog + fileTypeLists:(NSArray*>*)fileTypeLists; + +- (instancetype)initWithDialog:(NSSavePanel*)dialog + fileUTTypeLists:(NSArray*>*)fileUTTypeLists + API_AVAILABLE(macos(11.0)); + +- (void)popupAction:(id)sender; +@end + + +@implementation ExtensionDropdownHandler + +- (instancetype)initWithDialog:(NSSavePanel*)dialog + fileTypeLists:(NSArray*>*)fileTypeLists { + if ((self = [super init])) { + _dialog = dialog; + _fileTypeLists = fileTypeLists; + } + return self; +} + +- (instancetype)initWithDialog:(NSSavePanel*)dialog + fileUTTypeLists:(NSArray*>*)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 { + ExtensionDropdownHandler* __strong _extension_dropdown_handler; + public: FORWARD_IUNKNOWN() virtual void SelectFolderDialog (IAvnWindow* parentWindowHandle, @@ -88,7 +152,7 @@ public: const char* title, const char* initialDirectory, const char* initialFile, - const char* filters) override + IAvnFilePickerFileTypes* filters) override { @autoreleasepool { @@ -113,25 +177,7 @@ public: panel.nameFieldStringValue = [NSString stringWithUTF8String:initialFile]; } - if(filters != nullptr) - { - 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; - } - } - } + SetAccessoryView(panel, filters, false); auto handler = ^(NSModalResponse result) { if(result == NSFileHandlingPanelOKButton) @@ -187,7 +233,7 @@ public: const char* title, const char* initialDirectory, const char* initialFile, - const char* filters) override + IAvnFilePickerFileTypes* filters) override { @autoreleasepool { @@ -210,28 +256,7 @@ public: panel.nameFieldStringValue = [NSString stringWithUTF8String:initialFile]; } - if(filters != nullptr) - { - 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; - } - } + SetAccessoryView(panel, filters, true); auto handler = ^(NSModalResponse result) { if(result == NSFileHandlingPanelOKButton) @@ -240,9 +265,9 @@ public: auto url = [panel URL]; - auto string = [url path]; + auto string = [url path]; strings[0] = (void*)[string UTF8String]; - + events->OnCompleted(1, &strings[0]); [panel orderOut:panel]; @@ -274,31 +299,221 @@ public: } private: - NSMutableArray* ConvertToUTType(NSArray* 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]; - auto mapped = [[NSMutableArray alloc] init]; + NSView* accessory_view = CreateAccessoryView(); + + NSPopUpButton* popup = [accessory_view viewWithTag:kFileTypePopupTag]; - if (@available(macOS 11.0, *)) + NSMutableArray*>* 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++) - { - auto utTypeStr = allowedTypes[i]; - auto utType = [UTType typeWithIdentifier:utTypeStr]; - if (utType == nil) - { - utType = [UTType typeWithMIMEType:utTypeStr]; + NSString* type_description = GetNSStringAndRelease(filters->GetName(i)); + [popup addItemWithTitle:type_description]; + + // If any type is included, enable allowsOtherFileTypes, and skip this filter on save panel. + if (filters->IsAnyType(i)) { + panel.allowsOtherFileTypes = YES; + } + // 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) - { - [mapped addObject:utType]; + if (!typeCompleted && filters->GetExtensions(i, &array) == 0) { + for (NSString* ext in GetNSArrayOfStringsAndRelease(array)) + { + 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* 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() diff --git a/samples/ControlCatalog/Pages/DialogsPage.xaml b/samples/ControlCatalog/Pages/DialogsPage.xaml index 980b210aaa..2c1a774273 100644 --- a/samples/ControlCatalog/Pages/DialogsPage.xaml +++ b/samples/ControlCatalog/Pages/DialogsPage.xaml @@ -22,7 +22,15 @@ - Use filters +