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

content / app_shim_remote_cocoa / web_menu_runner_mac.mm [blame]

// 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 <stddef.h>

#include "base/base64.h"
#include "base/strings/sys_string_conversions.h"

@interface WebMenuRunner (PrivateAPI)

// Worker function used during initialization.
- (void)addItem:(const blink::mojom::MenuItemPtr&)item;

// A callback for the menu controller object to call when an item is selected
// from the menu. This is not called if the menu is dismissed without a
// selection.
- (void)menuItemSelected:(id)sender;

@end  // WebMenuRunner (PrivateAPI)

@implementation WebMenuRunner {
  // The native menu control.
  NSMenu* __strong _menu;

  // A flag set to YES if a menu item was chosen, or NO if the menu was
  // dismissed without selecting an item.
  BOOL _menuItemWasChosen;

  // The index of the selected menu item.
  int _index;

  // 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;
    _index = -1;
    _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;
  }

  NSString* title = base::SysUTF8ToNSString(item->label.value_or(""));
  // https://crbug.com/1140620: 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(*item->label));
  }
  NSMenuItem* menuItem = [_menu addItemWithTitle:title
                                          action:@selector(menuItemSelected:)
                                   keyEquivalent:@""];
  if (item->tool_tip.has_value()) {
    NSString* toolTip = base::SysUTF8ToNSString(item->tool_tip.value());
    [menuItem setToolTip:toolTip];
  }
  [menuItem setEnabled:(item->enabled &&
                        item->type != blink::mojom::MenuItem::Type::kGroup)];
  [menuItem setTarget:self];

  // Set various alignment/language attributes.
  NSMutableDictionary* attrs = [[NSMutableDictionary alloc] initWithCapacity:3];
  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 setTitle:[title stringByTrimmingCharactersInSet:whitespaceSet]];

  [menuItem setTag:[_menu numberOfItems] - 1];
}

// Reflects the result of the user's interaction with the popup menu. If NO, the
// menu was dismissed without the user choosing an item, which can happen if the
// user clicked outside the menu region or hit the escape key. If YES, the user
// selected an item from the menu.
- (BOOL)menuItemWasChosen {
  return _menuItemWasChosen;
}

- (void)menuItemSelected:(id)sender {
  _menuItemWasChosen = YES;
}

- (void)runMenuInView:(NSView*)view
           withBounds:(NSRect)bounds
         initialIndex:(int)index {
  // 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;
  // We use selectItemWithTag below 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.  Below a
  // certain point this doesn't work.  At that point the menu is
  // popped up above the element, so that the current item can be
  // selected without mouse-tracking selecting a different item
  // immediately.
  //
  // Unfortunately, instead of popping up above the passed |bounds|,
  // it pops up above the bounds of the view passed to inView:.  Use a
  // dummy view to fake this out.
  NSView* dummyView = [[NSView alloc] initWithFrame:bounds];
  [view addSubview:dummyView];

  // Display the menu, and set a flag if a menu item was chosen.
  [cell attachPopUpWithFrame:dummyView.bounds inView:dummyView];
  [cell performClickWithFrame:dummyView.bounds inView:dummyView];

  [dummyView removeFromSuperview];

  if ([self menuItemWasChosen])
    _index = [cell indexOfSelectedItem];
}

- (void)cancelSynchronously {
  [_menu cancelTrackingWithoutAnimation];

  // Starting with macOS 14, menus were reimplemented with Cocoa (rather than
  // with the old Carbon). However, with that reimplementation came a bug
  // whereupon using -cancelTrackingWithoutAnimation does not consistently
  // immediately cancel the tracking, and leaves associated state remaining
  // uncleared for an indeterminate amount of time. If a new tracking session is
  // begun before that state is cleared, an NSInternalInconsistencyException is
  // thrown. See the discussion on https://crbug.com/1497774 and FB13320260.
  // Therefore, on macOS 14+, clear out that state so that a new tracking
  // session can begin immediately.
  if (@available(macOS 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)];
    }
  }
}

- (int)indexOfSelectedItem {
  return _index;
}

@end  // WebMenuRunner