// Copyright 2013 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. #import "ui/base/cocoa/menu_controller.h" #include "base/cancelable_callback.h" #include "base/logging.h" #include "base/mac/bind_objc_block.h" #include "base/strings/sys_string_conversions.h" #include "base/threading/thread_task_runner_handle.h" #include "ui/base/accelerators/accelerator.h" #include "ui/base/accelerators/platform_accelerator_cocoa.h" #include "ui/base/l10n/l10n_util_mac.h" #include "ui/base/models/simple_menu_model.h" #import "ui/events/event_utils.h" #include "ui/gfx/font_list.h" #include "ui/gfx/image/image.h" #include "ui/gfx/text_elider.h" #include "ui/strings/grit/ui_strings.h" namespace { // Called when an empty submenu is created. This inserts a menu item labeled // "(empty)" into the submenu. Matches Windows behavior. NSMenu* MakeEmptySubmenu() { base::scoped_nsobject submenu([[NSMenu alloc] initWithTitle:@""]); NSString* empty_menu_title = l10n_util::GetNSString(IDS_APP_MENU_EMPTY_SUBMENU); [submenu addItemWithTitle:empty_menu_title action:NULL keyEquivalent:@""]; [[submenu itemAtIndex:0] setEnabled:NO]; return submenu.autorelease(); } // Called when adding a submenu to the menu and checks if the submenu, via its // |model|, has visible child items. bool MenuHasVisibleItems(const ui::MenuModel* model) { int count = model->GetItemCount(); for (int index = 0; index < count; index++) { if (model->IsVisibleAt(index)) return true; } return false; } } // namespace NSString* const kMenuControllerMenuWillOpenNotification = @"MenuControllerMenuWillOpen"; NSString* const kMenuControllerMenuDidCloseNotification = @"MenuControllerMenuDidClose"; // Internal methods. @interface MenuControllerCocoa () // Adds a separator item at the given index. As the separator doesn't need // anything from the model, this method doesn't need the model index as the // other method below does. - (void)addSeparatorToMenu:(NSMenu*)menu atIndex:(int)index; // Called via a private API hook shortly after the event that selects a menu // item arrives. - (void)itemWillBeSelected:(NSMenuItem*)sender; // Called when the user chooses a particular menu item. AppKit sends this only // after the menu has fully faded out. |sender| is the menu item chosen. - (void)itemSelected:(id)sender; // Called by the posted task to selected an item during menu fade out. // |uiEventFlags| are the ui::EventFlags captured from the triggering NSEvent. - (void)itemSelected:(id)sender uiEventFlags:(int)uiEventFlags; @end @interface ResponsiveNSMenuItem : NSMenuItem @end @implementation MenuControllerCocoa { BOOL useWithPopUpButtonCell_; // If YES, 0th item is blank BOOL isMenuOpen_; BOOL postItemSelectedAsTask_; std::unique_ptr postedItemSelectedTask_; } @synthesize model = model_; @synthesize useWithPopUpButtonCell = useWithPopUpButtonCell_; @synthesize postItemSelectedAsTask = postItemSelectedAsTask_; + (base::string16)elideMenuTitle:(const base::string16&)title toWidth:(int)width { NSFont* nsfont = [NSFont menuBarFontOfSize:0]; // 0 means "default" return gfx::ElideText(title, gfx::FontList(gfx::Font(nsfont)), width, gfx::ELIDE_TAIL, gfx::Typesetter::NATIVE); } - (id)init { self = [super init]; return self; } - (id)initWithModel:(ui::MenuModel*)model useWithPopUpButtonCell:(BOOL)useWithCell { if ((self = [super init])) { model_ = model; useWithPopUpButtonCell_ = useWithCell; [self menu]; } return self; } - (void)dealloc { [menu_ setDelegate:nil]; // Close the menu if it is still open. This could happen if a tab gets closed // while its context menu is still open. [self cancel]; model_ = NULL; [super dealloc]; } - (void)cancel { if (isMenuOpen_) { [menu_ cancelTracking]; model_->MenuWillClose(); isMenuOpen_ = NO; } } - (NSMenu*)menuFromModel:(ui::MenuModel*)model { NSMenu* menu = [[[NSMenu alloc] initWithTitle:@""] autorelease]; const int count = model->GetItemCount(); for (int index = 0; index < count; index++) { if (model->GetTypeAt(index) == ui::MenuModel::TYPE_SEPARATOR) [self addSeparatorToMenu:menu atIndex:index]; else [self addItemToMenu:menu atIndex:index fromModel:model]; } return menu; } - (int)maxWidthForMenuModel:(ui::MenuModel*)model modelIndex:(int)modelIndex { return -1; } - (void)addSeparatorToMenu:(NSMenu*)menu atIndex:(int)index { NSMenuItem* separator = [NSMenuItem separatorItem]; [menu insertItem:separator atIndex:index]; } - (void)addItemToMenu:(NSMenu*)menu atIndex:(NSInteger)index fromModel:(ui::MenuModel*)model { base::string16 label16 = model->GetLabelAt(index); int maxWidth = [self maxWidthForMenuModel:model modelIndex:index]; if (maxWidth != -1) label16 = [MenuControllerCocoa elideMenuTitle:label16 toWidth:maxWidth]; NSString* label = l10n_util::FixUpWindowsStyleLabel(label16); base::scoped_nsobject item([[ResponsiveNSMenuItem alloc] initWithTitle:label action:@selector(itemSelected:) keyEquivalent:@""]); // If the menu item has an icon, set it. gfx::Image icon; if (model->GetIconAt(index, &icon) && !icon.IsEmpty()) [item setImage:icon.ToNSImage()]; ui::MenuModel::ItemType type = model->GetTypeAt(index); if (type == ui::MenuModel::TYPE_SUBMENU && model->IsVisibleAt(index)) { ui::MenuModel* submenuModel = model->GetSubmenuModelAt(index); // If there are visible items, recursively build the submenu. NSMenu* submenu = MenuHasVisibleItems(submenuModel) ? [self menuFromModel:submenuModel] : MakeEmptySubmenu(); [item setTarget:nil]; [item setAction:nil]; [item setSubmenu:submenu]; } else { // The MenuModel works on indexes so we can't just set the command id as the // tag like we do in other menus. Also set the represented object to be // the model so hierarchical menus check the correct index in the correct // model. Setting the target to |self| allows this class to participate // in validation of the menu items. [item setTag:index]; [item setTarget:self]; NSValue* modelObject = [NSValue valueWithPointer:model]; [item setRepresentedObject:modelObject]; // Retains |modelObject|. ui::Accelerator accelerator; if (model->GetAcceleratorAt(index, &accelerator)) { const ui::PlatformAcceleratorCocoa* platformAccelerator = static_cast( accelerator.platform_accelerator()); if (platformAccelerator) { [item setKeyEquivalent:platformAccelerator->characters()]; [item setKeyEquivalentModifierMask: platformAccelerator->modifier_mask()]; } } } [menu insertItem:item atIndex:index]; } - (BOOL)validateUserInterfaceItem:(id)item { SEL action = [item action]; if (action != @selector(itemSelected:)) return NO; NSInteger modelIndex = [item tag]; ui::MenuModel* model = static_cast( [[(id)item representedObject] pointerValue]); DCHECK(model); if (model) { BOOL checked = model->IsItemCheckedAt(modelIndex); DCHECK([(id)item isKindOfClass:[NSMenuItem class]]); [(id)item setState:(checked ? NSOnState : NSOffState)]; [(id)item setHidden:(!model->IsVisibleAt(modelIndex))]; if (model->IsItemDynamicAt(modelIndex)) { // Update the label and the icon. NSString* label = l10n_util::FixUpWindowsStyleLabel(model->GetLabelAt(modelIndex)); [(id)item setTitle:label]; gfx::Image icon; model->GetIconAt(modelIndex, &icon); [(id)item setImage:icon.IsEmpty() ? nil : icon.ToNSImage()]; } const gfx::FontList* font_list = model->GetLabelFontListAt(modelIndex); if (font_list) { NSDictionary *attributes = [NSDictionary dictionaryWithObject:font_list->GetPrimaryFont(). GetNativeFont() forKey:NSFontAttributeName]; base::scoped_nsobject title( [[NSAttributedString alloc] initWithString:[(id)item title] attributes:attributes]); [(id)item setAttributedTitle:title.get()]; } return model->IsEnabledAt(modelIndex); } return NO; } - (void)itemWillBeSelected:(NSMenuItem*)sender { if (postItemSelectedAsTask_ && [sender action] == @selector(itemSelected:) && [[sender target] respondsToSelector:@selector(itemSelected:uiEventFlags:)]) { const int uiEventFlags = ui::EventFlagsFromNative([NSApp currentEvent]); // Take care here to retain |menu_| in the block, but not |self|. Since the // block may run before -menuDidClose:, a release of the MenuControllerCocoa // will think the menu is open, and invoke -cancel. So if the delegate is // bad (see below), and decides to release the MenuControllerCocoa in its // menu action, ensure the -dealloc happens there. To do otherwise risks // |model_| being deleted when it is used in -cancel, whereas that is less // likely if the -cancel happens in the delegate method. NSMenu* menu = menu_; postedItemSelectedTask_ = std::make_unique(base::BindBlock(^{ id target = [sender target]; if ([target respondsToSelector:@selector(itemSelected:uiEventFlags:)]) [target itemSelected:sender uiEventFlags:uiEventFlags]; else NOTREACHED(); // Ensure consumers that use -postItemSelectedAsTask:YES have not // destroyed the MenuControllerCocoa in the menu action. AppKit will // still send messages to [item target] (the MenuControllerCocoa), and // the target can not be set to nil here since that prevents re-use of // the menu for well-behaved consumers. CHECK([menu delegate]); // Note: set to nil in -dealloc. })); base::ThreadTaskRunnerHandle::Get()->PostTask( FROM_HERE, postedItemSelectedTask_->callback()); } } - (void)itemSelected:(id)sender { // A task created in -itemWillBeSelected: may or may not have run. If not, put // it on the stack before running it, in case it destroys |self|. if (auto pendingTask = std::move(postedItemSelectedTask_)) { if (!pendingTask->IsCancelled()) pendingTask->callback().Run(); } else { [self itemSelected:sender uiEventFlags:ui::EventFlagsFromNative([NSApp currentEvent])]; } } - (void)itemSelected:(id)sender uiEventFlags:(int)uiEventFlags { // Cancel any posted task, but don't reset it, so that the correct path is // taken in -itemSelected:. if (postedItemSelectedTask_) postedItemSelectedTask_->Cancel(); NSInteger modelIndex = [sender tag]; ui::MenuModel* model = static_cast( [[sender representedObject] pointerValue]); DCHECK(model); if (model) model->ActivatedAt(modelIndex, uiEventFlags); // Note: |self| may be destroyed by the call to ActivatedAt(). } - (NSMenu*)menu { if (!menu_ && model_) { menu_.reset([[self menuFromModel:model_] retain]); [menu_ setDelegate:self]; // If this is to be used with a NSPopUpButtonCell, add an item at the 0th // position that's empty. Doing it after the menu has been constructed won't // complicate creation logic, and since the tags are model indexes, they // are unaffected by the extra item. if (useWithPopUpButtonCell_) { base::scoped_nsobject blankItem( [[NSMenuItem alloc] initWithTitle:@"" action:nil keyEquivalent:@""]); [menu_ insertItem:blankItem atIndex:0]; } } return menu_.get(); } - (BOOL)isMenuOpen { return isMenuOpen_; } - (void)menuWillOpen:(NSMenu*)menu { isMenuOpen_ = YES; model_->MenuWillShow(); [[NSNotificationCenter defaultCenter] postNotificationName:kMenuControllerMenuWillOpenNotification object:self]; } - (void)menuDidClose:(NSMenu*)menu { if (isMenuOpen_) { model_->MenuWillClose(); isMenuOpen_ = NO; } [[NSNotificationCenter defaultCenter] postNotificationName:kMenuControllerMenuDidCloseNotification object:self]; } @end @interface NSMenuItem (Private) // Private method which is invoked very soon after the event that activates a // menu item is received. AppKit then spends 300ms or so flashing the menu item, // and fading out the menu, in private run loop modes. - (void)_sendItemSelectedNote; @end @implementation ResponsiveNSMenuItem - (void)_sendItemSelectedNote { if ([[self target] respondsToSelector:@selector(itemWillBeSelected:)]) [[self target] itemWillBeSelected:self]; [super _sendItemSelectedNote]; } @end