gecko-dev/widget/cocoa/nsTouchBar.mm

605 lines
22 KiB
Plaintext

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
#include "nsTouchBar.h"
#include <objc/runtime.h>
#include "mozilla/MacStringHelpers.h"
#include "nsArrayUtils.h"
#include "nsCocoaUtils.h"
#include "nsDirectoryServiceDefs.h"
#include "nsIArray.h"
#include "nsTouchBarInputIcon.h"
#include "nsWidgetsCID.h"
@implementation nsTouchBar
// Used to tie action strings to buttons.
static char sIdentifierAssociationKey;
// The default space between inputs, used where layout is not automatic.
static const uint32_t kInputSpacing = 8;
// The width of buttons in Apple's Share ScrollView. We use this in our
// ScrollViews to give them a native appearance.
static const uint32_t kScrollViewButtonWidth = 144;
static const uint32_t kInputIconSize = 16;
// The system default width for Touch Bar inputs is 128px. This is double.
#define MAIN_BUTTON_WIDTH 256
#pragma mark - NSTouchBarDelegate
- (instancetype)init {
return [self initWithInputs:nil];
}
- (instancetype)initWithInputs:(NSMutableArray<TouchBarInput*>*)aInputs {
if ((self = [super init])) {
mTouchBarHelper = do_GetService(NS_TOUCHBARHELPER_CID);
if (!mTouchBarHelper) {
NS_ERROR("Unable to create Touch Bar Helper.");
return nil;
}
self.delegate = self;
self.mappedLayoutItems = [NSMutableDictionary dictionary];
self.customizationAllowedItemIdentifiers = @[];
if (!aInputs) {
// This customization identifier is how users' custom layouts are saved by macOS.
// If this changes, all users' layouts would be reset to the default layout.
self.customizationIdentifier =
[kTouchBarBaseIdentifier stringByAppendingPathExtension:@"defaultbar"];
nsCOMPtr<nsIArray> allItems;
nsresult rv = mTouchBarHelper->GetAllItems(getter_AddRefs(allItems));
if (NS_FAILED(rv) || !allItems) {
return nil;
}
uint32_t itemCount = 0;
allItems->GetLength(&itemCount);
// This is copied to self.customizationAllowedItemIdentifiers.
// Required since [self.mappedItems allKeys] does not preserve order.
// One slot is added for the spacer item.
NSMutableArray* orderedIdentifiers = [NSMutableArray arrayWithCapacity:itemCount + 1];
for (uint32_t i = 0; i < itemCount; ++i) {
nsCOMPtr<nsITouchBarInput> input = do_QueryElementAt(allItems, i);
if (!input) {
continue;
}
TouchBarInput* convertedInput;
NSTouchBarItemIdentifier newInputIdentifier =
[TouchBarInput nativeIdentifierWithXPCOM:input];
if (!newInputIdentifier) {
continue;
}
// If there is already an input in mappedLayoutItems with this identifier,
// that means updateItem fired before this initialization. The input
// cached by updateItem is more current, so we should use that one.
if (self.mappedLayoutItems[newInputIdentifier]) {
convertedInput = self.mappedLayoutItems[newInputIdentifier];
} else {
convertedInput = [[TouchBarInput alloc] initWithXPCOM:input];
// Add new input to dictionary for lookup of properties in delegate.
self.mappedLayoutItems[[convertedInput nativeIdentifier]] = convertedInput;
}
orderedIdentifiers[i] = [convertedInput nativeIdentifier];
}
[orderedIdentifiers addObject:@"NSTouchBarItemIdentifierFlexibleSpace"];
self.customizationAllowedItemIdentifiers = [orderedIdentifiers copy];
NSArray* defaultItemIdentifiers = @[
[TouchBarInput nativeIdentifierWithType:@"button" withKey:@"back"],
[TouchBarInput nativeIdentifierWithType:@"button" withKey:@"forward"],
[TouchBarInput nativeIdentifierWithType:@"button" withKey:@"reload"],
[TouchBarInput nativeIdentifierWithType:@"mainButton" withKey:@"open-location"],
[TouchBarInput nativeIdentifierWithType:@"button" withKey:@"new-tab"],
[TouchBarInput shareScrubberIdentifier], [TouchBarInput searchPopoverIdentifier]
];
self.defaultItemIdentifiers = [defaultItemIdentifiers copy];
} else {
NSMutableArray* defaultItemIdentifiers = [NSMutableArray arrayWithCapacity:[aInputs count]];
for (TouchBarInput* input in aInputs) {
self.mappedLayoutItems[[input nativeIdentifier]] = input;
[defaultItemIdentifiers addObject:[input nativeIdentifier]];
}
self.defaultItemIdentifiers = [defaultItemIdentifiers copy];
}
}
return self;
}
- (void)dealloc {
for (NSTouchBarItemIdentifier identifier in self.mappedLayoutItems) {
NSTouchBarItem* item = [self itemForIdentifier:identifier];
if (!item) {
continue;
}
if ([item isKindOfClass:[NSPopoverTouchBarItem class]]) {
[(NSPopoverTouchBarItem*)item setCollapsedRepresentationImage:nil];
[(nsTouchBar*)[(NSPopoverTouchBarItem*)item popoverTouchBar] release];
} else if ([[item view] isKindOfClass:[NSScrollView class]]) {
[[(NSScrollView*)[item view] documentView] release];
[(NSScrollView*)[item view] release];
}
[item release];
}
[self.defaultItemIdentifiers release];
[self.customizationAllowedItemIdentifiers release];
[self.scrollViewButtons removeAllObjects];
[self.scrollViewButtons release];
[self.mappedLayoutItems removeAllObjects];
[self.mappedLayoutItems release];
[super dealloc];
}
- (NSTouchBarItem*)touchBar:(NSTouchBar*)aTouchBar
makeItemForIdentifier:(NSTouchBarItemIdentifier)aIdentifier {
if (!mTouchBarHelper) {
return nil;
}
TouchBarInput* input = self.mappedLayoutItems[aIdentifier];
if (!input) {
return nil;
}
if ([input baseType] == TouchBarInputBaseType::kScrubber) {
// We check the identifier rather than the baseType here as a special case.
if (![aIdentifier isEqualToString:[TouchBarInput shareScrubberIdentifier]]) {
// We're only supporting the Share scrubber for now.
return nil;
}
return [self makeShareScrubberForIdentifier:aIdentifier];
}
if ([input baseType] == TouchBarInputBaseType::kPopover) {
NSPopoverTouchBarItem* newPopoverItem =
[[NSPopoverTouchBarItem alloc] initWithIdentifier:aIdentifier];
[newPopoverItem setCustomizationLabel:[input title]];
// We initialize popoverTouchBar here because we only allow setting this
// property on popover creation. Updating popoverTouchBar for every update
// of the popover item would be very expensive.
newPopoverItem.popoverTouchBar = [[nsTouchBar alloc] initWithInputs:[input children]];
[self updatePopover:newPopoverItem withIdentifier:[input nativeIdentifier]];
return newPopoverItem;
}
// Our new item, which will be initialized depending on aIdentifier.
NSCustomTouchBarItem* newItem = [[NSCustomTouchBarItem alloc] initWithIdentifier:aIdentifier];
[newItem setCustomizationLabel:[input title]];
if ([input baseType] == TouchBarInputBaseType::kScrollView) {
[self updateScrollView:newItem withIdentifier:[input nativeIdentifier]];
return newItem;
} else if ([input baseType] == TouchBarInputBaseType::kLabel) {
NSTextField* label = [NSTextField labelWithString:@""];
[self updateLabel:label withIdentifier:[input nativeIdentifier]];
newItem.view = label;
return newItem;
}
// The cases of a button or main button require the same setup.
NSButton* button = [NSButton buttonWithTitle:@"" target:self action:@selector(touchBarAction:)];
newItem.view = button;
if ([input baseType] == TouchBarInputBaseType::kButton &&
![[input type] hasPrefix:@"scrollView"]) {
[self updateButton:newItem withIdentifier:[input nativeIdentifier]];
} else if ([input baseType] == TouchBarInputBaseType::kMainButton) {
[self updateMainButton:newItem withIdentifier:[input nativeIdentifier]];
}
return newItem;
}
- (bool)updateItem:(TouchBarInput*)aInput {
if (!mTouchBarHelper) {
return false;
}
NSTouchBarItem* item = [self itemForIdentifier:[aInput nativeIdentifier]];
// If we can't immediately find item, there are three possibilities:
// * It is a button in a ScrollView, or
// * It is contained within a popover, or
// * It simply does not exist.
// We check for each possibility here.
if (!self.mappedLayoutItems[[aInput nativeIdentifier]]) {
if ([self maybeUpdateScrollViewChild:aInput]) {
return true;
}
if ([self maybeUpdatePopoverChild:aInput]) {
return true;
}
return false;
}
// Update our canonical copy of the input.
[self replaceMappedLayoutItem:aInput];
if ([aInput baseType] == TouchBarInputBaseType::kButton) {
[(NSCustomTouchBarItem*)item setCustomizationLabel:[aInput title]];
[self updateButton:(NSCustomTouchBarItem*)item withIdentifier:[aInput nativeIdentifier]];
} else if ([aInput baseType] == TouchBarInputBaseType::kMainButton) {
[(NSCustomTouchBarItem*)item setCustomizationLabel:[aInput title]];
[self updateMainButton:(NSCustomTouchBarItem*)item withIdentifier:[aInput nativeIdentifier]];
} else if ([aInput baseType] == TouchBarInputBaseType::kScrollView) {
[(NSCustomTouchBarItem*)item setCustomizationLabel:[aInput title]];
[self updateScrollView:(NSCustomTouchBarItem*)item withIdentifier:[aInput nativeIdentifier]];
} else if ([aInput baseType] == TouchBarInputBaseType::kPopover) {
[(NSPopoverTouchBarItem*)item setCustomizationLabel:[aInput title]];
[self updatePopover:(NSPopoverTouchBarItem*)item withIdentifier:[aInput nativeIdentifier]];
for (TouchBarInput* child in [aInput children]) {
[(nsTouchBar*)[(NSPopoverTouchBarItem*)item popoverTouchBar] updateItem:child];
}
} else if ([aInput baseType] == TouchBarInputBaseType::kLabel) {
[self updateLabel:(NSTextField*)item.view withIdentifier:[aInput nativeIdentifier]];
}
return true;
}
- (bool)maybeUpdatePopoverChild:(TouchBarInput*)aInput {
for (NSTouchBarItemIdentifier identifier in self.mappedLayoutItems) {
TouchBarInput* potentialPopover = self.mappedLayoutItems[identifier];
if ([potentialPopover baseType] != TouchBarInputBaseType::kPopover) {
continue;
}
NSTouchBarItem* popover = [self itemForIdentifier:[potentialPopover nativeIdentifier]];
if (popover) {
if ([(nsTouchBar*)[(NSPopoverTouchBarItem*)popover popoverTouchBar] updateItem:aInput]) {
return true;
}
}
}
return false;
}
- (bool)maybeUpdateScrollViewChild:(TouchBarInput*)aInput {
NSCustomTouchBarItem* scrollViewButton = self.scrollViewButtons[[aInput nativeIdentifier]];
if (scrollViewButton) {
// ScrollView buttons are similar to mainButtons except for their width.
[self updateMainButton:scrollViewButton withIdentifier:[aInput nativeIdentifier]];
NSButton* button = (NSButton*)scrollViewButton.view;
uint32_t buttonSize = MAX(button.attributedTitle.size.width + kInputIconSize + kInputSpacing,
kScrollViewButtonWidth);
[[button widthAnchor] constraintGreaterThanOrEqualToConstant:buttonSize].active = YES;
}
// Updating the TouchBarInput* in the ScrollView's mChildren array.
for (NSTouchBarItemIdentifier identifier in self.mappedLayoutItems) {
TouchBarInput* potentialScrollView = self.mappedLayoutItems[identifier];
if ([potentialScrollView baseType] != TouchBarInputBaseType::kScrollView) {
continue;
}
for (uint32_t i = 0; i < [[potentialScrollView children] count]; ++i) {
TouchBarInput* child = [potentialScrollView children][i];
if (![[child nativeIdentifier] isEqualToString:[aInput nativeIdentifier]]) {
continue;
}
[[potentialScrollView children] replaceObjectAtIndex:i withObject:aInput];
[child release];
return true;
}
}
return false;
}
- (void)replaceMappedLayoutItem:(TouchBarInput*)aItem {
[self.mappedLayoutItems[[aItem nativeIdentifier]] release];
self.mappedLayoutItems[[aItem nativeIdentifier]] = aItem;
}
- (void)updateButton:(NSCustomTouchBarItem*)aButton
withIdentifier:(NSTouchBarItemIdentifier)aIdentifier {
if (!aButton || !aIdentifier) {
return;
}
TouchBarInput* input = self.mappedLayoutItems[aIdentifier];
if (!input) {
return;
}
NSButton* button = (NSButton*)[aButton view];
button.title = [input title];
if ([input imageURI]) {
[button setImagePosition:NSImageOnly];
[self loadIconForInput:input forItem:aButton];
// Because we are hiding the title, NSAccessibility also does not get it.
// Therefore, set an accessibility label as alternative text for image-only buttons.
[button setAccessibilityLabel:[input title]];
}
[button setEnabled:![input isDisabled]];
if ([input color]) {
button.bezelColor = [input color];
}
objc_setAssociatedObject(button, &sIdentifierAssociationKey, aIdentifier,
OBJC_ASSOCIATION_RETAIN);
}
- (void)updateMainButton:(NSCustomTouchBarItem*)aMainButton
withIdentifier:(NSTouchBarItemIdentifier)aIdentifier {
if (!aMainButton || !aIdentifier) {
return;
}
TouchBarInput* input = self.mappedLayoutItems[aIdentifier];
if (!input) {
return;
}
[self updateButton:aMainButton withIdentifier:aIdentifier];
NSButton* button = (NSButton*)[aMainButton view];
// If empty, string is still being localized. Display a blank input instead.
if ([[input title] isEqualToString:@""]) {
[button setImagePosition:NSNoImage];
} else {
[button setImagePosition:NSImageLeft];
}
button.imageHugsTitle = YES;
[button.widthAnchor constraintGreaterThanOrEqualToConstant:MAIN_BUTTON_WIDTH].active = YES;
[button setContentHuggingPriority:1.0 forOrientation:NSLayoutConstraintOrientationHorizontal];
}
- (void)updatePopover:(NSPopoverTouchBarItem*)aPopoverItem
withIdentifier:(NSTouchBarItemIdentifier)aIdentifier {
if (!aPopoverItem || !aIdentifier) {
return;
}
TouchBarInput* input = self.mappedLayoutItems[aIdentifier];
if (!input) {
return;
}
aPopoverItem.showsCloseButton = YES;
if ([input imageURI]) {
[self loadIconForInput:input forItem:aPopoverItem];
} else if ([input title]) {
aPopoverItem.collapsedRepresentationLabel = [input title];
}
// Special handling to show/hide the search popover if the Urlbar is focused.
if ([[input nativeIdentifier] isEqualToString:[TouchBarInput searchPopoverIdentifier]]) {
// We can reach this code during window shutdown. We only want to toggle
// showPopover if we are in a normal running state.
if (!mTouchBarHelper) {
return;
}
bool urlbarIsFocused = false;
mTouchBarHelper->GetIsUrlbarFocused(&urlbarIsFocused);
if (urlbarIsFocused) {
[aPopoverItem showPopover:self];
}
}
}
- (void)updateScrollView:(NSCustomTouchBarItem*)aScrollViewItem
withIdentifier:(NSTouchBarItemIdentifier)aIdentifier {
if (!aScrollViewItem || !aIdentifier) {
return;
}
TouchBarInput* input = self.mappedLayoutItems[aIdentifier];
if (!input || ![input children]) {
return;
}
NSMutableDictionary* constraintViews = [NSMutableDictionary dictionary];
NSView* documentView = [[NSView alloc] initWithFrame:NSZeroRect];
NSString* layoutFormat = @"H:|-8-";
NSSize size = NSMakeSize(kInputSpacing, 30);
// Layout strings allow only alphanumeric characters. We will use this
// NSCharacterSet to strip illegal characters.
NSCharacterSet* charactersToRemove = [[NSCharacterSet alphanumericCharacterSet] invertedSet];
for (TouchBarInput* childInput in [input children]) {
if ([childInput baseType] != TouchBarInputBaseType::kButton) {
continue;
}
[self replaceMappedLayoutItem:childInput];
NSCustomTouchBarItem* newItem =
[[NSCustomTouchBarItem alloc] initWithIdentifier:[childInput nativeIdentifier]];
NSButton* button = [NSButton buttonWithTitle:[childInput title]
target:self
action:@selector(touchBarAction:)];
newItem.view = button;
// ScrollView buttons are similar to mainButtons except for their width.
[self updateMainButton:newItem withIdentifier:[childInput nativeIdentifier]];
uint32_t buttonSize = MAX(button.attributedTitle.size.width + kInputIconSize + kInputSpacing,
kScrollViewButtonWidth);
[[button widthAnchor] constraintGreaterThanOrEqualToConstant:buttonSize].active = YES;
NSCustomTouchBarItem* tempItem = self.scrollViewButtons[[childInput nativeIdentifier]];
self.scrollViewButtons[[childInput nativeIdentifier]] = newItem;
[tempItem release];
button.translatesAutoresizingMaskIntoConstraints = NO;
[documentView addSubview:button];
NSString* layoutKey = [[[childInput nativeIdentifier]
componentsSeparatedByCharactersInSet:charactersToRemove] componentsJoinedByString:@""];
// Iteratively create our layout string.
layoutFormat =
[layoutFormat stringByAppendingString:[NSString stringWithFormat:@"[%@]-8-", layoutKey]];
[constraintViews setObject:button forKey:layoutKey];
size.width += kInputSpacing + buttonSize;
}
layoutFormat = [layoutFormat stringByAppendingString:[NSString stringWithFormat:@"|"]];
NSArray* hConstraints =
[NSLayoutConstraint constraintsWithVisualFormat:layoutFormat
options:NSLayoutFormatAlignAllCenterY
metrics:nil
views:constraintViews];
NSScrollView* scrollView =
[[NSScrollView alloc] initWithFrame:CGRectMake(0, 0, size.width, size.height)];
[documentView setFrame:NSMakeRect(0, 0, size.width, size.height)];
[NSLayoutConstraint activateConstraints:hConstraints];
scrollView.documentView = documentView;
aScrollViewItem.view = scrollView;
}
- (void)updateLabel:(NSTextField*)aLabel withIdentifier:(NSTouchBarItemIdentifier)aIdentifier {
if (!aLabel || !aIdentifier) {
return;
}
TouchBarInput* input = self.mappedLayoutItems[aIdentifier];
if (!input || ![input title]) {
return;
}
[aLabel setStringValue:[input title]];
}
- (NSTouchBarItem*)makeShareScrubberForIdentifier:(NSTouchBarItemIdentifier)aIdentifier {
TouchBarInput* input = self.mappedLayoutItems[aIdentifier];
// System-default share menu
NSSharingServicePickerTouchBarItem* servicesItem =
[[NSSharingServicePickerTouchBarItem alloc] initWithIdentifier:aIdentifier];
// buttonImage needs to be set to nil while we wait for our icon to load.
// Otherwise, the default Apple share icon is automatically loaded.
servicesItem.buttonImage = nil;
[self loadIconForInput:input forItem:servicesItem];
servicesItem.delegate = self;
return servicesItem;
}
- (void)showPopover:(TouchBarInput*)aPopover showing:(bool)aShowing {
if (!aPopover) {
return;
}
NSPopoverTouchBarItem* popoverItem =
(NSPopoverTouchBarItem*)[self itemForIdentifier:[aPopover nativeIdentifier]];
if (!popoverItem) {
return;
}
if (aShowing) {
[popoverItem showPopover:self];
} else {
[popoverItem dismissPopover:self];
}
}
- (void)touchBarAction:(id)aSender {
NSTouchBarItemIdentifier identifier =
objc_getAssociatedObject(aSender, &sIdentifierAssociationKey);
if (!identifier || [identifier isEqualToString:@""]) {
return;
}
TouchBarInput* input = self.mappedLayoutItems[identifier];
if (!input) {
return;
}
nsCOMPtr<nsITouchBarInputCallback> callback = [input callback];
if (!callback) {
NSLog(@"Touch Bar action attempted with no valid callback! Identifier: %@",
[input nativeIdentifier]);
return;
}
callback->OnCommand();
}
- (void)loadIconForInput:(TouchBarInput*)aInput forItem:(NSTouchBarItem*)aItem {
if (!aInput || ![aInput imageURI] || !aItem || !mTouchBarHelper) {
return;
}
RefPtr<nsTouchBarInputIcon> icon = [aInput icon];
if (!icon) {
RefPtr<Document> document;
nsresult rv = mTouchBarHelper->GetDocument(getter_AddRefs(document));
if (NS_FAILED(rv) || !document) {
return;
}
icon = new nsTouchBarInputIcon(document, aInput, aItem);
[aInput setIcon:icon];
}
icon->SetupIcon([aInput imageURI]);
}
- (void)releaseJSObjects {
mTouchBarHelper = nil;
for (NSTouchBarItemIdentifier identifier in self.mappedLayoutItems) {
TouchBarInput* input = self.mappedLayoutItems[identifier];
if (!input) {
continue;
}
// Childless popovers contain the default Touch Bar as its popoverTouchBar.
// We check for [input children] since the default Touch Bar contains a
// popover (search-popover), so this would infinitely loop if there was no check.
if ([input baseType] == TouchBarInputBaseType::kPopover && [input children]) {
NSTouchBarItem* item = [self itemForIdentifier:identifier];
[(nsTouchBar*)[(NSPopoverTouchBarItem*)item popoverTouchBar] releaseJSObjects];
}
[input releaseJSObjects];
}
}
#pragma mark - NSSharingServicePickerTouchBarItemDelegate
- (NSArray*)itemsForSharingServicePickerTouchBarItem:
(NSSharingServicePickerTouchBarItem*)aPickerTouchBarItem {
NSURL* urlToShare = nil;
NSString* titleToShare = @"";
nsAutoString url;
nsAutoString title;
if (mTouchBarHelper) {
nsresult rv = mTouchBarHelper->GetActiveUrl(url);
if (!NS_FAILED(rv)) {
urlToShare = [NSURL URLWithString:nsCocoaUtils::ToNSString(url)];
// NSURL URLWithString returns nil if the URL is invalid. At this point,
// it is too late to simply shut down the share menu, so we default to
// about:blank if the share button is clicked when the URL is invalid.
if (urlToShare == nil) {
urlToShare = [NSURL URLWithString:@"about:blank"];
}
}
rv = mTouchBarHelper->GetActiveTitle(title);
if (!NS_FAILED(rv)) {
titleToShare = nsCocoaUtils::ToNSString(title);
}
}
return @[ urlToShare, titleToShare ];
}
- (NSArray<NSSharingService*>*)sharingServicePicker:(NSSharingServicePicker*)aSharingServicePicker
sharingServicesForItems:(NSArray*)aItems
proposedSharingServices:(NSArray<NSSharingService*>*)aProposedServices {
// redundant services
NSArray* excludedServices = @[
@"com.apple.share.System.add-to-safari-reading-list",
];
NSArray* sharingServices = [aProposedServices
filteredArrayUsingPredicate:[NSPredicate
predicateWithFormat:@"NOT (name IN %@)", excludedServices]];
return sharingServices;
}
@end