// Copyright 2023 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#import "components/remote_cocoa/app_shim/menu_controller_cocoa_delegate_impl.h"
#include "base/apple/bridging.h"
#include "base/apple/foundation_util.h"
#include "base/logging.h"
#import "base/message_loop/message_pump_apple.h"
#import "skia/ext/skia_utils_mac.h"
#import "ui/base/cocoa/cocoa_base_utils.h"
#include "ui/base/interaction/element_tracker_mac.h"
#include "ui/base/l10n/l10n_util_mac.h"
#include "ui/base/models/menu_model.h"
#include "ui/gfx/mac/coordinate_conversion.h"
#include "ui/gfx/platform_font_mac.h"
#include "ui/strings/grit/ui_strings.h"
namespace {
constexpr CGFloat kIPHDotSize = 6;
NSImage* NewTagImage(const remote_cocoa::mojom::MenuControllerParams& params) {
// 1. Make the attributed string.
NSString* badge_text = l10n_util::GetNSString(IDS_NEW_BADGE);
NSColor* badge_text_color =
skia::SkColorToSRGBNSColor(params.badge_text_color);
NSDictionary* badge_attrs = @{
NSFontAttributeName :
base::apple::CFToNSPtrCast(params.badge_font.GetCTFont()),
NSForegroundColorAttributeName : badge_text_color,
};
NSMutableAttributedString* badge_attr_string =
[[NSMutableAttributedString alloc] initWithString:badge_text
attributes:badge_attrs];
// 2. Calculate the size required.
NSSize text_size = [badge_attr_string size];
NSSize canvas_size =
NSMakeSize(trunc(text_size.width) + 2 * params.badge_internal_padding +
2 * params.badge_horizontal_margin,
fmax(trunc(text_size.height), params.badge_min_height));
// 3. Craft the image.
return [NSImage
imageWithSize:canvas_size
flipped:NO
drawingHandler:^(NSRect dest_rect) {
NSRect badge_frame =
NSInsetRect(dest_rect, params.badge_horizontal_margin, 0);
NSBezierPath* rounded_badge_rect =
[NSBezierPath bezierPathWithRoundedRect:badge_frame
xRadius:params.badge_radius
yRadius:params.badge_radius];
NSColor* badge_color = skia::SkColorToSRGBNSColor(params.badge_color);
[badge_color set];
[rounded_badge_rect fill];
// Place the text rect at the center of the badge frame.
NSPoint badge_text_location =
NSMakePoint(NSMinX(badge_frame) +
(badge_frame.size.width - text_size.width) / 2.0,
NSMinY(badge_frame) +
(badge_frame.size.height - text_size.height) / 2.0);
[badge_attr_string drawAtPoint:badge_text_location];
return YES;
}];
}
NSImage* IPHDotImage(const remote_cocoa::mojom::MenuControllerParams& params) {
// Embed horizontal centering space as NSMenuItem will otherwise left-align
// it.
return [NSImage
imageWithSize:NSMakeSize(2 * kIPHDotSize, kIPHDotSize)
flipped:NO
drawingHandler:^(NSRect dest_rect) {
NSBezierPath* dot_path = [NSBezierPath
bezierPathWithOvalInRect:NSMakeRect(kIPHDotSize / 2, 0, kIPHDotSize,
kIPHDotSize)];
NSColor* dot_color = skia::SkColorToSRGBNSColor(params.iph_dot_color);
[dot_color set];
[dot_path fill];
return YES;
}];
}
} // namespace
// --- Private API begin ---
// In macOS 13 and earlier, the internals of menus are handled by HI Toolbox,
// and the bridge to that code is NSCarbonMenuImpl. While in reality this is a
// class, abstract its method that is used by this code into a protocol.
@protocol CrNSCarbonMenuImpl <NSObject>
// Highlights the menu item at the provided index.
- (void)highlightItemAtIndex:(NSInteger)index;
@end
@interface NSMenu (Impl)
// Returns the impl. (If called on macOS 14 this would return a subclass of
// NSCocoaMenuImpl, but private API use is not needed on macOS 14.)
- (id<CrNSCarbonMenuImpl>)_menuImpl;
// Returns the bounds of the entire menu in screen coordinate space. Available
// on both Carbon and Cocoa impls, but always (incorrectly) returns a zero
// origin with the Cocoa impl. Therefore, do not use with macOS 14 or later.
- (CGRect)_boundsIfOpen;
@end
// --- Private API end ---
@implementation MenuControllerCocoaDelegateImpl {
NSMutableArray* __strong _menuObservers;
remote_cocoa::mojom::MenuControllerParamsPtr _params;
}
- (instancetype)initWithParams:
(remote_cocoa::mojom::MenuControllerParamsPtr)params {
if (self = [super init]) {
_menuObservers = [[NSMutableArray alloc] init];
_params = std::move(params);
}
return self;
}
- (void)dealloc {
for (NSObject* obj in _menuObservers) {
[NSNotificationCenter.defaultCenter removeObserver:obj];
}
}
- (void)controllerWillAddItem:(NSMenuItem*)menuItem
fromModel:(ui::MenuModel*)model
atIndex:(size_t)index {
if (model->IsNewFeatureAt(index)) {
NSTextAttachment* attachment = [[NSTextAttachment alloc] initWithData:nil
ofType:nil];
attachment.image = NewTagImage(*_params);
NSSize newTagSize = attachment.image.size;
// The baseline offset of the badge image to the menu text baseline.
const int kBadgeBaselineOffset = -3;
attachment.bounds = NSMakeRect(0, kBadgeBaselineOffset, newTagSize.width,
newTagSize.height);
NSMutableAttributedString* attrTitle =
[[NSMutableAttributedString alloc] initWithString:menuItem.title];
[attrTitle
appendAttributedString:[NSAttributedString
attributedStringWithAttachment:attachment]];
menuItem.attributedTitle = attrTitle;
}
if (model->IsAlertedAt(index)) {
NSImage* iphDotImage = IPHDotImage(*_params);
menuItem.onStateImage = iphDotImage;
menuItem.offStateImage = iphDotImage;
menuItem.mixedStateImage = iphDotImage;
}
}
- (void)controllerWillAddMenu:(NSMenu*)menu fromModel:(ui::MenuModel*)model {
std::optional<size_t> alertedIndex;
// A map containing elements that need to be tracked, mapping from their
// identifiers to their indexes in the menu.
std::map<ui::ElementIdentifier, NSInteger> elementIds;
for (size_t i = 0; i < model->GetItemCount(); ++i) {
if (model->IsAlertedAt(i)) {
CHECK(!alertedIndex.has_value())
<< "Mac menu code can only alert for one item in a menu";
alertedIndex = i;
}
const ui::ElementIdentifier identifier = model->GetElementIdentifierAt(i);
if (identifier) {
elementIds.emplace(identifier, base::checked_cast<NSInteger>(i));
}
}
// A weak reference to the menu for the two blocks. This shouldn't be
// necessary, as there aren't any references back that make a retain cycle,
// but it's hard to be fully convinced that such a cycle isn't possible now or
// in the future with updates.
__weak NSMenu* weakMenu = menu;
if (alertedIndex.has_value() || !elementIds.empty()) {
__block bool menuShown = false;
auto shownCallback = ^(NSNotification* note) {
NSMenu* strongMenu = weakMenu;
if (!strongMenu) {
return;
}
if (@available(macOS 14.0, *)) {
// Ensure that only notifications for the correct internal menu view
// class trigger this.
if (![[note.object className] isEqual:@"NSContextMenuItemView"]) {
return;
}
// Ensure that the bounds for all the needed menu items are available.
// In testing, this was always true even for the first notification, so
// this is not expected to fail and is included for paranoia.
for (auto [elementId, index] : elementIds) {
NSRect frame = [strongMenu itemAtIndex:index].accessibilityFrame;
if (NSWidth(frame) < 10) {
return;
}
}
}
// These notifications will fire more than once; only process the first
// time.
if (menuShown) {
return;
}
menuShown = true;
if (alertedIndex.has_value()) {
const auto index = base::checked_cast<NSInteger>(alertedIndex.value());
if (@available(macOS 14.0, *)) {
[strongMenu itemAtIndex:index].accessibilitySelected = true;
} else {
[strongMenu._menuImpl highlightItemAtIndex:index];
}
}
if (@available(macOS 14.0, *)) {
for (auto [elementId, index] : elementIds) {
NSRect frame = [strongMenu itemAtIndex:index].accessibilityFrame;
ui::ElementTrackerMac::GetInstance()->NotifyMenuItemShown(
strongMenu, elementId, gfx::ScreenRectFromNSRect(frame));
}
} else {
// macOS 13 and earlier use the old Carbon Menu Manager, and getting the
// bounds of menus is pretty wackadoodle.
//
// Because menus are implemented in Carbon, the only notification that
// can be relied upon is `NSMenuDidBeginTrackingNotification`, but that
// is fired before layout is done. Therefore, spin the event loop once.
// This practically guarantees that the menu is on screen and can be
// queried for size.
dispatch_after(
dispatch_time(DISPATCH_TIME_NOW, NSEC_PER_MSEC),
dispatch_get_main_queue(), ^{
gfx::Rect bounds =
gfx::ScreenRectFromNSRect(strongMenu._boundsIfOpen);
for (auto [elementId, index] : elementIds) {
ui::ElementTrackerMac::GetInstance()->NotifyMenuItemShown(
strongMenu, elementId, bounds);
}
});
};
};
// Register for a notification to get a callback when the menu is shown.
// `NSMenuDidBeginTrackingNotification` might seem ideal, but it fires very
// early in menu tracking, before layout happens.
if (@available(macOS 14.0, *)) {
// With macOS 14+, menus are implemented with Cocoa. Because all the menu-
// specific notifications fire very early, before layout, rely on the
// NSViewFrameDidChangeNotification being fired for a specific menu
// implementation class.
[_menuObservers
addObject:[NSNotificationCenter.defaultCenter
addObserverForName:NSViewFrameDidChangeNotification
object:nil
queue:nil
usingBlock:shownCallback]];
} else {
// Before macOS 14, menus were implemented with Carbon and only the basic
// notifications were hooked up. Therefore, as much as
// `NSMenuDidBeginTrackingNotification` is not ideal, register for it, and
// play `dispatch_after` games, above.
[_menuObservers
addObject:[NSNotificationCenter.defaultCenter
addObserverForName:NSMenuDidBeginTrackingNotification
object:menu
queue:nil
usingBlock:shownCallback]];
}
}
if (!elementIds.empty()) {
auto hiddenCallback = ^(NSNotification* note) {
NSMenu* strongMenu = weakMenu;
if (!strongMenu) {
return;
}
// We expect to see the following order of events:
// - element shown
// - element activated (optional)
// - element hidden
// However, the code that detects menu item activation is called *after*
// the current callback. To make sure the events happen in the right order
// we'll defer processing of element hidden events until the end of the
// current system event queue.
dispatch_after(
dispatch_time(DISPATCH_TIME_NOW, NSEC_PER_MSEC),
dispatch_get_main_queue(), ^{
for (auto [elementId, index] : elementIds) {
ui::ElementTrackerMac::GetInstance()->NotifyMenuItemHidden(
strongMenu, elementId);
}
});
};
[_menuObservers
addObject:[NSNotificationCenter.defaultCenter
addObserverForName:NSMenuDidEndTrackingNotification
object:menu
queue:nil
usingBlock:hiddenCallback]];
}
}
@end