1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267
|
// 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
|