1 // Copyright (c) 2011 The Chromium Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style license that can be
3 // found in the LICENSE file.
5 #import "chrome/browser/ui/cocoa/wrench_menu/wrench_menu_controller.h"
7 #include "base/basictypes.h"
8 #include "base/mac/bundle_locations.h"
9 #include "base/strings/string16.h"
10 #include "base/strings/sys_string_conversions.h"
11 #include "chrome/app/chrome_command_ids.h"
12 #import "chrome/browser/app_controller_mac.h"
13 #include "chrome/browser/profiles/profile.h"
14 #include "chrome/browser/ui/browser.h"
15 #include "chrome/browser/ui/browser_window.h"
16 #import "chrome/browser/ui/cocoa/accelerators_cocoa.h"
17 #import "chrome/browser/ui/cocoa/bookmarks/bookmark_menu_bridge.h"
18 #import "chrome/browser/ui/cocoa/bookmarks/bookmark_menu_cocoa_controller.h"
19 #import "chrome/browser/ui/cocoa/browser_window_controller.h"
20 #import "chrome/browser/ui/cocoa/encoding_menu_controller_delegate_mac.h"
21 #import "chrome/browser/ui/cocoa/extensions/browser_actions_container_view.h"
22 #import "chrome/browser/ui/cocoa/extensions/browser_actions_controller.h"
23 #import "chrome/browser/ui/cocoa/l10n_util.h"
24 #import "chrome/browser/ui/cocoa/toolbar/toolbar_controller.h"
25 #import "chrome/browser/ui/cocoa/wrench_menu/menu_tracked_root_view.h"
26 #import "chrome/browser/ui/cocoa/wrench_menu/recent_tabs_menu_model_delegate.h"
27 #include "chrome/browser/ui/toolbar/recent_tabs_sub_menu_model.h"
28 #include "chrome/browser/ui/toolbar/wrench_menu_model.h"
29 #include "chrome/grit/generated_resources.h"
30 #include "components/ui/zoom/zoom_event_manager.h"
31 #include "content/public/browser/user_metrics.h"
32 #include "ui/base/l10n/l10n_util.h"
33 #include "ui/base/models/menu_model.h"
34 #include "ui/gfx/geometry/size.h"
37 // Padding amounts on the left/right of a custom menu item (like the browser
38 // actions overflow container).
39 const int kLeftPadding = 16;
40 const int kRightPadding = 10;
42 // In *very* extreme cases, it's possible that there are so many overflowed
43 // actions, we won't be able to show them all. Cap the height so that the
44 // overflow won't make the menu larger than the height of the screen.
45 // Note: With this height, we can show 104 actions. Less than 0.0002% of our
46 // users will be affected.
47 const int kMaxOverflowContainerHeight = 416;
50 namespace wrench_menu_controller {
51 const CGFloat kWrenchBubblePointOffsetY = 6;
54 using base::UserMetricsAction;
56 @interface WrenchMenuController (Private)
58 - (void)adjustPositioning;
59 - (void)performCommandDispatch:(NSNumber*)tag;
60 - (NSButton*)zoomDisplay;
61 - (void)menu:(NSMenu*)menu willHighlightItem:(NSMenuItem*)item;
62 - (void)removeAllItems:(NSMenu*)menu;
63 - (NSMenu*)recentTabsSubmenu;
64 - (RecentTabsSubMenuModel*)recentTabsMenuModel;
65 - (int)maxWidthForMenuModel:(ui::MenuModel*)model
66 modelIndex:(int)modelIndex;
69 namespace WrenchMenuControllerInternal {
71 // A C++ delegate that handles the accelerators in the wrench menu.
72 class AcceleratorDelegate : public ui::AcceleratorProvider {
74 bool GetAcceleratorForCommandId(int command_id,
75 ui::Accelerator* out_accelerator) override {
76 AcceleratorsCocoa* keymap = AcceleratorsCocoa::GetInstance();
77 const ui::Accelerator* accelerator =
78 keymap->GetAcceleratorForCommand(command_id);
81 *out_accelerator = *accelerator;
86 class ZoomLevelObserver {
88 ZoomLevelObserver(WrenchMenuController* controller,
89 ui_zoom::ZoomEventManager* manager)
90 : controller_(controller) {
91 subscription_ = manager->AddZoomLevelChangedCallback(
92 base::Bind(&ZoomLevelObserver::OnZoomLevelChanged,
93 base::Unretained(this)));
96 ~ZoomLevelObserver() {}
99 void OnZoomLevelChanged(const content::HostZoomMap::ZoomLevelChange& change) {
100 WrenchMenuModel* wrenchMenuModel = [controller_ wrenchMenuModel];
101 wrenchMenuModel->UpdateZoomControls();
102 const base::string16 level =
103 wrenchMenuModel->GetLabelForCommandId(IDC_ZOOM_PERCENT_DISPLAY);
104 [[controller_ zoomDisplay] setTitle:SysUTF16ToNSString(level)];
107 scoped_ptr<content::HostZoomMap::Subscription> subscription_;
109 WrenchMenuController* controller_; // Weak; owns this.
111 DISALLOW_COPY_AND_ASSIGN(ZoomLevelObserver);
114 } // namespace WrenchMenuControllerInternal
116 @implementation WrenchMenuController
118 - (id)initWithBrowser:(Browser*)browser {
119 if ((self = [super init])) {
121 observer_.reset(new WrenchMenuControllerInternal::ZoomLevelObserver(
123 ui_zoom::ZoomEventManager::GetForBrowserContext(browser->profile())));
124 acceleratorDelegate_.reset(
125 new WrenchMenuControllerInternal::AcceleratorDelegate());
132 [self browserWillBeDestroyed];
136 - (void)browserWillBeDestroyed {
137 // This method indicates imminent destruction. Destroy owned objects that hold
138 // a weak Browser*, or pass this call onto reference counted objects.
139 recentTabsMenuModelDelegate_.reset();
140 [self setModel:nullptr];
141 wrenchMenuModel_.reset();
142 buttonViewController_.reset();
143 // ZoomLevelObserver holds a subscription to ZoomEventManager, which is
144 // user-data on the BrowserContext. The BrowserContext may be destroyed soon
145 // if Chrome is quitting. In any case, |observer_| should not be needed at
149 [browserActionsController_ browserWillBeDestroyed];
154 - (void)addItemToMenu:(NSMenu*)menu
155 atIndex:(NSInteger)index
156 fromModel:(ui::MenuModel*)model {
157 // Non-button item types should be built as normal items, with the exception
158 // of the extensions overflow menu.
159 int command_id = model->GetCommandIdAt(index);
160 if (model->GetTypeAt(index) != ui::MenuModel::TYPE_BUTTON_ITEM &&
161 command_id != IDC_EXTENSIONS_OVERFLOW_MENU) {
162 [super addItemToMenu:menu
168 // Handle the special-cased menu items.
169 base::scoped_nsobject<NSMenuItem> customItem(
170 [[NSMenuItem alloc] initWithTitle:@"" action:nil keyEquivalent:@""]);
171 MenuTrackedRootView* view = nil;
172 switch (command_id) {
173 case IDC_EXTENSIONS_OVERFLOW_MENU: {
174 browserActionsMenuItem_ = customItem.get();
175 view = [buttonViewController_ toolbarActionsOverflowItem];
176 BrowserActionsContainerView* containerView =
177 [buttonViewController_ overflowActionsContainerView];
179 // The overflow browser actions container can't function properly without
180 // a main counterpart, so if the browser window hasn't initialized, abort.
181 // (This is fine because we re-populate the wrench menu each time before
183 if (!browser_->window())
186 BrowserActionsController* mainController =
187 [[[BrowserWindowController browserWindowControllerForWindow:browser_->
188 window()->GetNativeWindow()] toolbarController]
189 browserActionsController];
190 browserActionsController_.reset(
191 [[BrowserActionsController alloc]
192 initWithBrowser:browser_
193 containerView:containerView
194 mainController:mainController]);
198 view = [buttonViewController_ editItem];
201 view = [buttonViewController_ zoomItem];
208 [customItem setView:view];
209 [view setMenuItem:customItem];
210 [self adjustPositioning];
211 [menu insertItem:customItem.get() atIndex:index];
214 - (BOOL)validateUserInterfaceItem:(id<NSValidatedUserInterfaceItem>)item {
215 const BOOL enabled = [super validateUserInterfaceItem:item];
217 NSMenuItem* menuItem = (id)item;
218 ui::MenuModel* model =
219 static_cast<ui::MenuModel*>(
220 [[menuItem representedObject] pointerValue]);
222 // The section headers in the recent tabs submenu should be bold and black if
223 // a font list is specified for the items (bold is already applied in the
224 // |MenuController| as the font list returned by |GetLabelFontListAt| is
226 if (model && model == [self recentTabsMenuModel]) {
227 if (model->GetLabelFontListAt([item tag])) {
228 DCHECK([menuItem attributedTitle]);
229 base::scoped_nsobject<NSMutableAttributedString> title(
230 [[NSMutableAttributedString alloc]
231 initWithAttributedString:[menuItem attributedTitle]]);
232 [title addAttribute:NSForegroundColorAttributeName
233 value:[NSColor blackColor]
234 range:NSMakeRange(0, [title length])];
235 [menuItem setAttributedTitle:title.get()];
237 // Not a section header. Add a tooltip with the title and the URL.
239 base::string16 title;
240 if ([self recentTabsMenuModel]->GetURLAndTitleForItemAtIndex(
241 [item tag], &url, &title)) {
242 [menuItem setToolTip:
243 cocoa_l10n_util::TooltipForURLAndTitle(
244 base::SysUTF8ToNSString(url), base::SysUTF16ToNSString(title))];
252 - (NSMenu*)bookmarkSubMenu {
253 NSString* title = l10n_util::GetNSStringWithFixup(IDS_BOOKMARKS_MENU);
254 return [[[self menu] itemWithTitle:title] submenu];
257 - (void)updateBookmarkSubMenu {
258 NSMenu* bookmarkMenu = [self bookmarkSubMenu];
259 DCHECK(bookmarkMenu);
261 bookmarkMenuBridge_.reset(
262 new BookmarkMenuBridge([self wrenchMenuModel]->browser()->profile(),
266 - (void)updateBrowserActionsSubmenu {
267 MenuTrackedRootView* view =
268 [buttonViewController_ toolbarActionsOverflowItem];
269 BrowserActionsContainerView* containerView =
270 [buttonViewController_ overflowActionsContainerView];
272 // Find the preferred container size for the menu width.
273 int menuWidth = [[self menu] size].width;
274 int maxContainerWidth = menuWidth - kLeftPadding - kRightPadding;
275 // Don't let the menu change sizes on us. (We lift this restriction every time
276 // the menu updates, so if something changes, this won't leave us with an
278 [[self menu] setMinimumWidth:menuWidth];
279 gfx::Size preferredContainerSize =
280 [browserActionsController_ sizeForOverflowWidth:maxContainerWidth];
282 // Set the origins and preferred size for the container.
283 // View hierarchy is as follows (from parent > child):
284 // |view| > |anonymous view| > containerView. We have to set the origin
285 // and size of each for it display properly.
286 // The parent views each have a size of the full width of the menu, so we can
287 // properly position the container.
288 NSSize parentSize = NSMakeSize(menuWidth,
289 std::min(preferredContainerSize.height(),
290 kMaxOverflowContainerHeight));
291 [view setFrameSize:parentSize];
292 [[containerView superview] setFrameSize:parentSize];
294 // The container view gets its preferred size.
295 [containerView setFrameSize:NSMakeSize(preferredContainerSize.width(),
296 preferredContainerSize.height())];
297 [browserActionsController_ update];
299 [view setFrameOrigin:NSZeroPoint];
300 [[containerView superview] setFrameOrigin:NSZeroPoint];
301 [containerView setFrameOrigin:NSMakePoint(kLeftPadding, 0)];
304 - (void)menuWillOpen:(NSMenu*)menu {
305 [super menuWillOpen:menu];
307 NSString* title = base::SysUTF16ToNSString(
308 [self wrenchMenuModel]->GetLabelForCommandId(IDC_ZOOM_PERCENT_DISPLAY));
309 [[[buttonViewController_ zoomItem] viewWithTag:IDC_ZOOM_PERCENT_DISPLAY]
311 content::RecordAction(UserMetricsAction("ShowAppMenu"));
313 NSImage* icon = [self wrenchMenuModel]->browser()->window()->IsFullscreen() ?
314 [NSImage imageNamed:NSImageNameExitFullScreenTemplate] :
315 [NSImage imageNamed:NSImageNameEnterFullScreenTemplate];
316 [[buttonViewController_ zoomFullScreen] setImage:icon];
319 - (void)menuNeedsUpdate:(NSMenu*)menu {
320 // First empty out the menu and create a new model.
321 [self removeAllItems:menu];
323 [menu setMinimumWidth:0];
325 // Create a new menu, which cannot be swapped because the tracking is about to
326 // start, so simply copy the items.
327 NSMenu* newMenu = [self menuFromModel:model_];
328 NSArray* itemArray = [newMenu itemArray];
329 [self removeAllItems:newMenu];
330 for (NSMenuItem* item in itemArray) {
334 [self updateRecentTabsSubmenu];
335 [self updateBookmarkSubMenu];
336 [self updateBrowserActionsSubmenu];
339 // Used to dispatch commands from the Wrench menu. The custom items within the
340 // menu cannot be hooked up directly to First Responder because the window in
341 // which the controls reside is not the BrowserWindowController, but a
342 // NSCarbonMenuWindow; this screws up the typical |-commandDispatch:| system.
343 - (IBAction)dispatchWrenchMenuCommand:(id)sender {
344 NSInteger tag = [sender tag];
345 if (sender == [buttonViewController_ zoomPlus] ||
346 sender == [buttonViewController_ zoomMinus]) {
347 // Do a direct dispatch rather than scheduling on the outermost run loop,
348 // which would not get hit until after the menu had closed.
349 [self performCommandDispatch:[NSNumber numberWithInt:tag]];
351 // The zoom buttons should not close the menu if opened sticky.
352 if ([sender respondsToSelector:@selector(isTracking)] &&
353 [sender performSelector:@selector(isTracking)]) {
354 [menu_ cancelTracking];
357 // The custom views within the Wrench menu are abnormal and keep the menu
358 // open after a target-action. Close the menu manually.
359 [menu_ cancelTracking];
361 // Executing certain commands from the nested run loop of the menu can lead
362 // to wonky behavior (e.g. http://crbug.com/49716). To avoid this, schedule
363 // the dispatch on the outermost run loop.
364 [self performSelector:@selector(performCommandDispatch:)
365 withObject:[NSNumber numberWithInt:tag]
370 // Used to perform the actual dispatch on the outermost runloop.
371 - (void)performCommandDispatch:(NSNumber*)tag {
372 [self wrenchMenuModel]->ExecuteCommand([tag intValue], 0);
375 - (WrenchMenuModel*)wrenchMenuModel {
376 // Don't use |wrenchMenuModel_| so that a test can override the generic one.
377 return static_cast<WrenchMenuModel*>(model_);
380 - (void)updateRecentTabsSubmenu {
381 ui::MenuModel* model = [self recentTabsMenuModel];
383 recentTabsMenuModelDelegate_.reset(
384 new RecentTabsMenuModelDelegate(model, [self recentTabsSubmenu]));
388 - (BrowserActionsController*)browserActionsController {
389 return browserActionsController_.get();
392 - (void)createModel {
394 recentTabsMenuModelDelegate_.reset();
395 wrenchMenuModel_.reset(
396 new WrenchMenuModel(acceleratorDelegate_.get(), browser_));
397 [self setModel:wrenchMenuModel_.get()];
399 buttonViewController_.reset(
400 [[WrenchMenuButtonViewController alloc] initWithController:self]);
401 [buttonViewController_ view];
403 // See comment in containerSuperviewFrameChanged:.
404 NSView* containerSuperview =
405 [[buttonViewController_ overflowActionsContainerView] superview];
406 [containerSuperview setPostsFrameChangedNotifications:YES];
409 // Fit the localized strings into the Cut/Copy/Paste control, then resize the
410 // whole menu item accordingly.
411 - (void)adjustPositioning {
412 const CGFloat kButtonPadding = 12;
415 // Go through the three buttons from right-to-left, adjusting the size to fit
416 // the localized strings while keeping them all aligned on their horizontal
418 NSButton* views[] = {
419 [buttonViewController_ editPaste],
420 [buttonViewController_ editCopy],
421 [buttonViewController_ editCut]
423 for (size_t i = 0; i < arraysize(views); ++i) {
424 NSButton* button = views[i];
425 CGFloat originalWidth = NSWidth([button frame]);
427 // Do not let |-sizeToFit| change the height of the button.
428 NSSize size = [button frame].size;
430 size.width = [button frame].size.width + kButtonPadding;
431 [button setFrameSize:size];
433 CGFloat newWidth = size.width;
434 delta += newWidth - originalWidth;
436 NSRect frame = [button frame];
437 frame.origin.x -= delta;
438 [button setFrame:frame];
441 // Resize the menu item by the total amound the buttons changed so that the
442 // spacing between the buttons and the title remains the same.
443 NSRect itemFrame = [[buttonViewController_ editItem] frame];
444 itemFrame.size.width += delta;
445 [[buttonViewController_ editItem] setFrame:itemFrame];
447 // Also resize the superview of the buttons, which is an NSView used to slide
448 // when the item title is too big and GTM resizes it.
449 NSRect parentFrame = [[[buttonViewController_ editCut] superview] frame];
450 parentFrame.size.width += delta;
451 parentFrame.origin.x -= delta;
452 [[[buttonViewController_ editCut] superview] setFrame:parentFrame];
455 - (NSButton*)zoomDisplay {
456 return [buttonViewController_ zoomDisplay];
459 - (void)menu:(NSMenu*)menu willHighlightItem:(NSMenuItem*)item {
460 if (browserActionsController_.get()) {
461 [browserActionsController_ setFocusedInOverflow:
462 (item == browserActionsMenuItem_)];
466 // -[NSMenu removeAllItems] is only available on 10.6+.
467 - (void)removeAllItems:(NSMenu*)menu {
468 while ([menu numberOfItems]) {
469 [menu removeItemAtIndex:0];
473 - (NSMenu*)recentTabsSubmenu {
474 NSString* title = l10n_util::GetNSStringWithFixup(IDS_RECENT_TABS_MENU);
475 return [[[self menu] itemWithTitle:title] submenu];
478 // The recent tabs menu model is recognized by the existence of either the
479 // kRecentlyClosedHeaderCommandId or the kDisabledRecentlyClosedHeaderCommandId.
480 - (RecentTabsSubMenuModel*)recentTabsMenuModel {
482 // Start searching at the wrench menu model level, |model| will be updated
483 // only if the command we're looking for is found in one of the [sub]menus.
484 ui::MenuModel* model = [self wrenchMenuModel];
485 if (ui::MenuModel::GetModelAndIndexForCommandId(
486 RecentTabsSubMenuModel::kRecentlyClosedHeaderCommandId, &model,
488 return static_cast<RecentTabsSubMenuModel*>(model);
490 if (ui::MenuModel::GetModelAndIndexForCommandId(
491 RecentTabsSubMenuModel::kDisabledRecentlyClosedHeaderCommandId,
493 return static_cast<RecentTabsSubMenuModel*>(model);
498 // This overrdies the parent class to return a custom width for recent tabs
500 - (int)maxWidthForMenuModel:(ui::MenuModel*)model
501 modelIndex:(int)modelIndex {
502 RecentTabsSubMenuModel* recentTabsMenuModel = [self recentTabsMenuModel];
503 if (recentTabsMenuModel && recentTabsMenuModel == model) {
504 return recentTabsMenuModel->GetMaxWidthForItemAtIndex(modelIndex);
509 @end // @implementation WrenchMenuController
511 ////////////////////////////////////////////////////////////////////////////////
513 @interface WrenchMenuButtonViewController ()
514 - (void)containerSuperviewFrameChanged:(NSNotification*)notification;
517 @implementation WrenchMenuButtonViewController
519 @synthesize editItem = editItem_;
520 @synthesize editCut = editCut_;
521 @synthesize editCopy = editCopy_;
522 @synthesize editPaste = editPaste_;
523 @synthesize zoomItem = zoomItem_;
524 @synthesize zoomPlus = zoomPlus_;
525 @synthesize zoomDisplay = zoomDisplay_;
526 @synthesize zoomMinus = zoomMinus_;
527 @synthesize zoomFullScreen = zoomFullScreen_;
528 @synthesize toolbarActionsOverflowItem = toolbarActionsOverflowItem_;
529 @synthesize overflowActionsContainerView = overflowActionsContainerView_;
531 - (id)initWithController:(WrenchMenuController*)controller {
532 if ((self = [super initWithNibName:@"WrenchMenu"
533 bundle:base::mac::FrameworkBundle()])) {
534 controller_ = controller;
535 [[NSNotificationCenter defaultCenter]
537 selector:@selector(containerSuperviewFrameChanged:)
538 name:NSViewFrameDidChangeNotification
539 object:[overflowActionsContainerView_ superview]];
545 [[NSNotificationCenter defaultCenter] removeObserver:self];
549 - (IBAction)dispatchWrenchMenuCommand:(id)sender {
550 [controller_ dispatchWrenchMenuCommand:sender];
553 - (void)containerSuperviewFrameChanged:(NSNotification*)notification {
554 // AppKit menus were probably never designed with a view like the browser
555 // actions container in mind, and, as a result, we come across a few oddities.
556 // One of these is that the container's superview will, on some versions of
557 // OSX, change frame position sometime after the the menu begins tracking
558 // (and thus, after all our ability to adjust it normally). Throw in the
559 // towel, and simply don't let the frame move from where it's supposed to be.
560 // TODO(devlin): Yet another Cocoa hack. It'd be good to find a workaround,
561 // but unlikely unless we replace the Cocoa menu implementation.
562 NSView* containerSuperview = [overflowActionsContainerView_ superview];
563 if (NSMinX([containerSuperview frame]) != 0)
564 [containerSuperview setFrameOrigin:NSZeroPoint];
567 @end // @implementation WrenchMenuButtonViewController