| // Copyright 2013 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| #include "content/app_shim_remote_cocoa/web_menu_runner_mac.h" |
| |
| #include <AppKit/AppKit.h> |
| #include <Foundation/Foundation.h> |
| #include <objc/runtime.h> |
| #include <stddef.h> |
| |
| #include <optional> |
| |
| #include "base/base64.h" |
| #include "base/mac/mac_util.h" |
| #include "base/strings/sys_string_conversions.h" |
| |
| namespace { |
| |
| // A key to attach a MenuWasRunCallbackHolder to the NSView*. |
| static const char kMenuWasRunCallbackKey = 0; |
| |
| } // namespace |
| |
| @interface MenuWasRunCallbackHolder : NSObject |
| @property MenuWasRunCallback callback; |
| @end |
| |
| @implementation MenuWasRunCallbackHolder |
| @synthesize callback = _callback; |
| @end |
| |
| @implementation WebMenuRunner { |
| // The native menu. |
| NSMenu* __strong _menu; |
| |
| // The index of the selected menu item. |
| std::optional<int> _selectedMenuItemIndex; |
| |
| // The font size being used for the menu. |
| CGFloat _fontSize; |
| |
| // Whether the menu should be displayed right-aligned. |
| BOOL _rightAligned; |
| } |
| |
| - (id)initWithItems:(const std::vector<blink::mojom::MenuItemPtr>&)items |
| fontSize:(CGFloat)fontSize |
| rightAligned:(BOOL)rightAligned { |
| if ((self = [super init])) { |
| _menu = [[NSMenu alloc] initWithTitle:@""]; |
| _menu.autoenablesItems = NO; |
| _fontSize = fontSize; |
| _rightAligned = rightAligned; |
| for (const auto& item : items) { |
| [self addItem:item]; |
| } |
| } |
| return self; |
| } |
| |
| - (void)addItem:(const blink::mojom::MenuItemPtr&)item { |
| if (item->type == blink::mojom::MenuItem::Type::kSeparator) { |
| [_menu addItem:[NSMenuItem separatorItem]]; |
| return; |
| } |
| |
| std::string label = item->label.value_or(""); |
| NSString* title = base::SysUTF8ToNSString(label); |
| // https://crbug.com/40726719: SysUTF8ToNSString will return nil if the bits |
| // that it is passed cannot be turned into a CFString. If this nil value is |
| // passed to -[NSMenuItem addItemWithTitle:action:keyEquivalent:], Chromium |
| // will crash. Therefore, for debugging, if the result is nil, substitute in |
| // the raw bytes, encoded for safety in base64, to allow for investigation. |
| if (!title) { |
| title = base::SysUTF8ToNSString(base::Base64Encode(label)); |
| } |
| |
| // TODO(https://crbug.com/389084419): Figure out how to handle |
| // blink::mojom::MenuItem::Type::kGroup items. This should use the macOS 14+ |
| // support for section headers, but popup menus have to resize themselves to |
| // match the scale of the page, and there's no good way (currently) to get the |
| // font used for section header items in order to scale it and set it. |
| NSMenuItem* menuItem = [_menu addItemWithTitle:title |
| action:@selector(menuItemSelected:) |
| keyEquivalent:@""]; |
| |
| if (item->tool_tip.has_value()) { |
| menuItem.toolTip = base::SysUTF8ToNSString(item->tool_tip.value()); |
| } |
| menuItem.enabled = |
| item->enabled && item->type != blink::mojom::MenuItem::Type::kGroup; |
| menuItem.target = self; |
| |
| // Set various alignment/language attributes. |
| NSMutableDictionary* attrs = [NSMutableDictionary dictionary]; |
| NSMutableParagraphStyle* paragraphStyle = |
| [[NSMutableParagraphStyle alloc] init]; |
| paragraphStyle.alignment = |
| _rightAligned ? NSTextAlignmentRight : NSTextAlignmentLeft; |
| NSWritingDirection writingDirection = |
| item->text_direction == base::i18n::RIGHT_TO_LEFT |
| ? NSWritingDirectionRightToLeft |
| : NSWritingDirectionLeftToRight; |
| paragraphStyle.baseWritingDirection = writingDirection; |
| paragraphStyle.lineBreakMode = NSLineBreakByTruncatingTail; |
| attrs[NSParagraphStyleAttributeName] = paragraphStyle; |
| |
| if (item->has_text_direction_override) { |
| attrs[NSWritingDirectionAttributeName] = |
| @[ @(long{writingDirection} | NSWritingDirectionOverride) ]; |
| } |
| |
| attrs[NSFontAttributeName] = [NSFont menuFontOfSize:_fontSize]; |
| |
| NSAttributedString* attrTitle = |
| [[NSAttributedString alloc] initWithString:title attributes:attrs]; |
| menuItem.attributedTitle = attrTitle; |
| |
| // Set the title as well as the attributed title here. The attributed title |
| // will be displayed in the menu, but type-ahead will use the non-attributed |
| // string that doesn't contain any leading or trailing whitespace. |
| // |
| // This is the approach that WebKit uses; see PopupMenuMac::populate(): |
| // https://github.com/search?q=repo%3AWebKit/WebKit%20PopupMenuMac%3A%3Apopulate&type=code |
| NSCharacterSet* whitespaceSet = NSCharacterSet.whitespaceCharacterSet; |
| menuItem.title = [title stringByTrimmingCharactersInSet:whitespaceSet]; |
| |
| menuItem.tag = _menu.numberOfItems - 1; |
| } |
| |
| - (std::optional<int>)selectedMenuItemIndex { |
| return _selectedMenuItemIndex; |
| } |
| |
| - (void)menuItemSelected:(id)sender { |
| _selectedMenuItemIndex = [sender tag]; |
| } |
| |
| - (void)runMenuInView:(NSView*)view |
| withBounds:(NSRect)bounds |
| initialIndex:(int)index { |
| // In a testing situation, make the callback and early-exit. |
| MenuWasRunCallbackHolder* holder = |
| objc_getAssociatedObject(view, &kMenuWasRunCallbackKey); |
| if (holder) { |
| holder.callback.Run(view, bounds, index); |
| return; |
| } |
| |
| // Using NSPopUpButtonCell in this way is not SPI, but there is new(er) API to |
| // show a pop-up menu in a way that avoids the hassle of instantiating a cell |
| // just to use its innards. |
| // |
| // However, that API, -[NSMenu popUpMenuPositioningItem:atLocation:inView:], |
| // is broken and displays menus that are the incorrect width and which |
| // improperly truncate their contents (see https://crbug.com/401443090). |
| // |
| // This has been filed as FB16843355. TODO(https://crbug.com/389067059): When |
| // this FB is resolved, switch to the new API by relanding an adapted version |
| // of https://crrev.com/c/6173642. |
| // |
| // In addition, note that there are web pages that use popups with a font size |
| // of 0. When relanding, font size will likely play a part in the calculation |
| // of the menu position of the reland, so be sure to not regress menu |
| // positioning in that case (https://crbug.com/404294118). |
| |
| // Set up the button cell, converting to NSView coordinates. The menu is |
| // positioned such that the currently selected menu item appears over the |
| // popup button, which is the expected Mac popup menu behavior. |
| NSPopUpButtonCell* cell = [[NSPopUpButtonCell alloc] initTextCell:@"" |
| pullsDown:NO]; |
| cell.menu = _menu; |
| // Use -selectItemWithTag: so if the index is out-of-bounds nothing bad |
| // happens. |
| [cell selectItemWithTag:index]; |
| |
| if (_rightAligned) { |
| cell.userInterfaceLayoutDirection = |
| NSUserInterfaceLayoutDirectionRightToLeft; |
| _menu.userInterfaceLayoutDirection = |
| NSUserInterfaceLayoutDirectionRightToLeft; |
| } |
| |
| // When popping up a menu near the Dock, Cocoa restricts the menu size to not |
| // overlap the Dock, with a scroll arrow. At a certain point, though, this |
| // doesn't work, so the menu is repositioned, so that the current item can be |
| // selected without mouse-tracking selecting a different item immediately. |
| // |
| // Unfortunately, in that situation, the cell will try to reposition the menu |
| // relative to the view passed in, as it believes that the view is the |
| // NSPopUpButton control. However, `view` is the view containing the entire |
| // web page, so if it were to be passed in, the menu would be repositioned |
| // relative to that, and would end up being wildly misplaced. |
| // |
| // Therefore, set up a fake "control" view corresponding to the visual bounds |
| // of the HTML element, so that if the menu needs to be repositioned, it is |
| // repositioned relative to that. |
| NSView* fakeControlView = [[NSView alloc] initWithFrame:bounds]; |
| [view addSubview:fakeControlView]; |
| |
| // Display the menu. |
| [cell attachPopUpWithFrame:fakeControlView.bounds inView:fakeControlView]; |
| [cell performClickWithFrame:fakeControlView.bounds inView:fakeControlView]; |
| |
| [fakeControlView removeFromSuperview]; |
| } |
| |
| - (void)cancelSynchronously { |
| [_menu cancelTrackingWithoutAnimation]; |
| |
| // Starting with macOS 14, menus were reimplemented with Cocoa (rather than |
| // with the old Carbon). However, in macOS 14, with that reimplementation came |
| // a bug whereupon using -cancelTrackingWithoutAnimation did not consistently |
| // immediately cancel the tracking, and left associated state remaining |
| // uncleared for an indeterminate amount of time. If a new tracking session |
| // began before that state was cleared, an NSInternalInconsistencyException |
| // was thrown. See the discussion on https://crbug.com/40939221 and |
| // FB13320260. |
| // |
| // On macOS 14, therefore, when cancelling synchronously, clear out that state |
| // so that a new tracking session can begin immediately. |
| // |
| // With macOS 15, these global state methods moved from being class methods on |
| // NSPopupMenuWindow to being instance methods on NSMenuTrackingSession, so |
| // this workaround is inapplicable. |
| if (base::mac::MacOSMajorVersion() == 14) { |
| // When running a menu tracking session, the instances of |
| // NSMenuTrackingSession make calls to class methods of NSPopupMenuWindow: |
| // |
| // -[NSMenuTrackingSession sendBeginTrackingNotifications] |
| // -> +[NSPopupMenuWindow enableWindowReuse] |
| // and |
| // -[NSMenuTrackingSession sendEndTrackingNotifications] |
| // -> +[NSPopupMenuWindow disableWindowReusePurgingCache] |
| // |
| // +enableWindowReuse populates the _NSContextMenuWindowReuseSet global, and |
| // +disableWindowReusePurgingCache walks the set, clears out some state |
| // inside of each item, and then nils out the global, preparing for the next |
| // call to +enableWindowReuse. |
| // |
| // +disableWindowReusePurgingCache can be called directly here, as it's |
| // idempotent enough. |
| |
| Class popupMenuWindowClass = NSClassFromString(@"NSPopupMenuWindow"); |
| if ([popupMenuWindowClass |
| respondsToSelector:@selector(disableWindowReusePurgingCache)]) { |
| [popupMenuWindowClass |
| performSelector:@selector(disableWindowReusePurgingCache)]; |
| } |
| } |
| } |
| |
| + (void)registerForTestingMenuRunCallback:(MenuWasRunCallback)callback |
| forView:(NSView*)view { |
| MenuWasRunCallbackHolder* holder = [[MenuWasRunCallbackHolder alloc] init]; |
| holder.callback = callback; |
| objc_setAssociatedObject(view, &kMenuWasRunCallbackKey, holder, |
| OBJC_ASSOCIATION_RETAIN); |
| } |
| |
| + (void)unregisterForTestingMenuRunCallbackForView:(NSView*)view { |
| objc_setAssociatedObject(view, &kMenuWasRunCallbackKey, nil, |
| OBJC_ASSOCIATION_RETAIN); |
| } |
| |
| @end // WebMenuRunner |