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;
43 namespace wrench_menu_controller {
44 const CGFloat kWrenchBubblePointOffsetY = 6;
47 using base::UserMetricsAction;
49 @interface WrenchMenuController (Private)
51 - (void)adjustPositioning;
52 - (void)performCommandDispatch:(NSNumber*)tag;
53 - (NSButton*)zoomDisplay;
54 - (void)menu:(NSMenu*)menu willHighlightItem:(NSMenuItem*)item;
55 - (void)removeAllItems:(NSMenu*)menu;
56 - (NSMenu*)recentTabsSubmenu;
57 - (RecentTabsSubMenuModel*)recentTabsMenuModel;
58 - (int)maxWidthForMenuModel:(ui::MenuModel*)model
59 modelIndex:(int)modelIndex;
62 namespace WrenchMenuControllerInternal {
64 // A C++ delegate that handles the accelerators in the wrench menu.
65 class AcceleratorDelegate : public ui::AcceleratorProvider {
67 bool GetAcceleratorForCommandId(int command_id,
68 ui::Accelerator* out_accelerator) override {
69 AcceleratorsCocoa* keymap = AcceleratorsCocoa::GetInstance();
70 const ui::Accelerator* accelerator =
71 keymap->GetAcceleratorForCommand(command_id);
74 *out_accelerator = *accelerator;
79 class ZoomLevelObserver {
81 ZoomLevelObserver(WrenchMenuController* controller,
82 ui_zoom::ZoomEventManager* manager)
83 : controller_(controller) {
84 subscription_ = manager->AddZoomLevelChangedCallback(
85 base::Bind(&ZoomLevelObserver::OnZoomLevelChanged,
86 base::Unretained(this)));
89 ~ZoomLevelObserver() {}
92 void OnZoomLevelChanged(const content::HostZoomMap::ZoomLevelChange& change) {
93 WrenchMenuModel* wrenchMenuModel = [controller_ wrenchMenuModel];
94 wrenchMenuModel->UpdateZoomControls();
95 const base::string16 level =
96 wrenchMenuModel->GetLabelForCommandId(IDC_ZOOM_PERCENT_DISPLAY);
97 [[controller_ zoomDisplay] setTitle:SysUTF16ToNSString(level)];
100 scoped_ptr<content::HostZoomMap::Subscription> subscription_;
102 WrenchMenuController* controller_; // Weak; owns this.
104 DISALLOW_COPY_AND_ASSIGN(ZoomLevelObserver);
107 } // namespace WrenchMenuControllerInternal
109 @implementation WrenchMenuController
111 - (id)initWithBrowser:(Browser*)browser {
112 if ((self = [super init])) {
114 observer_.reset(new WrenchMenuControllerInternal::ZoomLevelObserver(
116 ui_zoom::ZoomEventManager::GetForBrowserContext(browser->profile())));
117 acceleratorDelegate_.reset(
118 new WrenchMenuControllerInternal::AcceleratorDelegate());
125 [self browserWillBeDestroyed];
129 - (void)browserWillBeDestroyed {
130 // This method indicates imminent destruction. Destroy owned objects that hold
131 // a weak Browser*, or pass this call onto reference counted objects.
132 recentTabsMenuModelDelegate_.reset();
133 [self setModel:nullptr];
134 wrenchMenuModel_.reset();
135 buttonViewController_.reset();
136 // ZoomLevelObserver holds a subscription to ZoomEventManager, which is
137 // user-data on the BrowserContext. The BrowserContext may be destroyed soon
138 // if Chrome is quitting. In any case, |observer_| should not be needed at
142 [browserActionsController_ browserWillBeDestroyed];
147 - (void)addItemToMenu:(NSMenu*)menu
148 atIndex:(NSInteger)index
149 fromModel:(ui::MenuModel*)model {
150 // Non-button item types should be built as normal items, with the exception
151 // of the extensions overflow menu.
152 int command_id = model->GetCommandIdAt(index);
153 if (model->GetTypeAt(index) != ui::MenuModel::TYPE_BUTTON_ITEM &&
154 command_id != IDC_EXTENSIONS_OVERFLOW_MENU) {
155 [super addItemToMenu:menu
161 // Handle the special-cased menu items.
162 base::scoped_nsobject<NSMenuItem> customItem(
163 [[NSMenuItem alloc] initWithTitle:@"" action:nil keyEquivalent:@""]);
164 MenuTrackedRootView* view = nil;
165 switch (command_id) {
166 case IDC_EXTENSIONS_OVERFLOW_MENU: {
167 browserActionsMenuItem_ = customItem.get();
168 view = [buttonViewController_ toolbarActionsOverflowItem];
169 BrowserActionsContainerView* containerView =
170 [buttonViewController_ overflowActionsContainerView];
172 // The overflow browser actions container can't function properly without
173 // a main counterpart, so if the browser window hasn't initialized, abort.
174 // (This is fine because we re-populate the wrench menu each time before
176 if (!browser_->window())
179 BrowserActionsController* mainController =
180 [[[BrowserWindowController browserWindowControllerForWindow:browser_->
181 window()->GetNativeWindow()] toolbarController]
182 browserActionsController];
183 browserActionsController_.reset(
184 [[BrowserActionsController alloc]
185 initWithBrowser:browser_
186 containerView:containerView
187 mainController:mainController]);
191 view = [buttonViewController_ editItem];
194 view = [buttonViewController_ zoomItem];
201 [customItem setView:view];
202 [view setMenuItem:customItem];
203 [self adjustPositioning];
204 [menu insertItem:customItem.get() atIndex:index];
207 - (BOOL)validateUserInterfaceItem:(id<NSValidatedUserInterfaceItem>)item {
208 const BOOL enabled = [super validateUserInterfaceItem:item];
210 NSMenuItem* menuItem = (id)item;
211 ui::MenuModel* model =
212 static_cast<ui::MenuModel*>(
213 [[menuItem representedObject] pointerValue]);
215 // The section headers in the recent tabs submenu should be bold and black if
216 // a font list is specified for the items (bold is already applied in the
217 // |MenuController| as the font list returned by |GetLabelFontListAt| is
219 if (model && model == [self recentTabsMenuModel]) {
220 if (model->GetLabelFontListAt([item tag])) {
221 DCHECK([menuItem attributedTitle]);
222 base::scoped_nsobject<NSMutableAttributedString> title(
223 [[NSMutableAttributedString alloc]
224 initWithAttributedString:[menuItem attributedTitle]]);
225 [title addAttribute:NSForegroundColorAttributeName
226 value:[NSColor blackColor]
227 range:NSMakeRange(0, [title length])];
228 [menuItem setAttributedTitle:title.get()];
230 // Not a section header. Add a tooltip with the title and the URL.
232 base::string16 title;
233 if ([self recentTabsMenuModel]->GetURLAndTitleForItemAtIndex(
234 [item tag], &url, &title)) {
235 [menuItem setToolTip:
236 cocoa_l10n_util::TooltipForURLAndTitle(
237 base::SysUTF8ToNSString(url), base::SysUTF16ToNSString(title))];
245 - (NSMenu*)bookmarkSubMenu {
246 NSString* title = l10n_util::GetNSStringWithFixup(IDS_BOOKMARKS_MENU);
247 return [[[self menu] itemWithTitle:title] submenu];
250 - (void)updateBookmarkSubMenu {
251 NSMenu* bookmarkMenu = [self bookmarkSubMenu];
252 DCHECK(bookmarkMenu);
254 bookmarkMenuBridge_.reset(
255 new BookmarkMenuBridge([self wrenchMenuModel]->browser()->profile(),
259 - (void)updateBrowserActionsSubmenu {
260 MenuTrackedRootView* view =
261 [buttonViewController_ toolbarActionsOverflowItem];
262 BrowserActionsContainerView* containerView =
263 [buttonViewController_ overflowActionsContainerView];
265 // Find the preferred container size for the menu width.
266 int menuWidth = [[self menu] size].width;
267 int maxContainerWidth = menuWidth - kLeftPadding - kRightPadding;
268 // Don't let the menu change sizes on us. (We lift this restriction every time
269 // the menu updates, so if something changes, this won't leave us with an
271 [[self menu] setMinimumWidth:menuWidth];
272 gfx::Size preferredContainerSize =
273 [browserActionsController_ sizeForOverflowWidth:maxContainerWidth];
275 // Set the origins and preferred size for the container.
276 // View hierarchy is as follows (from parent > child):
277 // |view| > |anonymous view| > containerView. We have to set the origin
278 // and size of each for it display properly.
279 // The parent views each have a size of the full width of the menu, so we can
280 // properly position the container.
281 NSSize parentSize = NSMakeSize(menuWidth, preferredContainerSize.height());
282 [view setFrameSize:parentSize];
283 [[containerView superview] setFrameSize:parentSize];
285 // The container view gets its preferred size.
286 [containerView setFrameSize:NSMakeSize(preferredContainerSize.width(),
287 preferredContainerSize.height())];
288 [browserActionsController_ update];
290 [view setFrameOrigin:NSZeroPoint];
291 [[containerView superview] setFrameOrigin:NSZeroPoint];
292 [containerView setFrameOrigin:NSMakePoint(kLeftPadding, 0)];
295 - (void)menuWillOpen:(NSMenu*)menu {
296 [super menuWillOpen:menu];
298 NSString* title = base::SysUTF16ToNSString(
299 [self wrenchMenuModel]->GetLabelForCommandId(IDC_ZOOM_PERCENT_DISPLAY));
300 [[[buttonViewController_ zoomItem] viewWithTag:IDC_ZOOM_PERCENT_DISPLAY]
302 content::RecordAction(UserMetricsAction("ShowAppMenu"));
304 NSImage* icon = [self wrenchMenuModel]->browser()->window()->IsFullscreen() ?
305 [NSImage imageNamed:NSImageNameExitFullScreenTemplate] :
306 [NSImage imageNamed:NSImageNameEnterFullScreenTemplate];
307 [[buttonViewController_ zoomFullScreen] setImage:icon];
310 - (void)menuNeedsUpdate:(NSMenu*)menu {
311 // First empty out the menu and create a new model.
312 [self removeAllItems:menu];
314 [menu setMinimumWidth:0];
316 // Create a new menu, which cannot be swapped because the tracking is about to
317 // start, so simply copy the items.
318 NSMenu* newMenu = [self menuFromModel:model_];
319 NSArray* itemArray = [newMenu itemArray];
320 [self removeAllItems:newMenu];
321 for (NSMenuItem* item in itemArray) {
325 [self updateRecentTabsSubmenu];
326 [self updateBookmarkSubMenu];
327 [self updateBrowserActionsSubmenu];
330 // Used to dispatch commands from the Wrench menu. The custom items within the
331 // menu cannot be hooked up directly to First Responder because the window in
332 // which the controls reside is not the BrowserWindowController, but a
333 // NSCarbonMenuWindow; this screws up the typical |-commandDispatch:| system.
334 - (IBAction)dispatchWrenchMenuCommand:(id)sender {
335 NSInteger tag = [sender tag];
336 if (sender == [buttonViewController_ zoomPlus] ||
337 sender == [buttonViewController_ zoomMinus]) {
338 // Do a direct dispatch rather than scheduling on the outermost run loop,
339 // which would not get hit until after the menu had closed.
340 [self performCommandDispatch:[NSNumber numberWithInt:tag]];
342 // The zoom buttons should not close the menu if opened sticky.
343 if ([sender respondsToSelector:@selector(isTracking)] &&
344 [sender performSelector:@selector(isTracking)]) {
345 [menu_ cancelTracking];
348 // The custom views within the Wrench menu are abnormal and keep the menu
349 // open after a target-action. Close the menu manually.
350 [menu_ cancelTracking];
352 // Executing certain commands from the nested run loop of the menu can lead
353 // to wonky behavior (e.g. http://crbug.com/49716). To avoid this, schedule
354 // the dispatch on the outermost run loop.
355 [self performSelector:@selector(performCommandDispatch:)
356 withObject:[NSNumber numberWithInt:tag]
361 // Used to perform the actual dispatch on the outermost runloop.
362 - (void)performCommandDispatch:(NSNumber*)tag {
363 [self wrenchMenuModel]->ExecuteCommand([tag intValue], 0);
366 - (WrenchMenuModel*)wrenchMenuModel {
367 // Don't use |wrenchMenuModel_| so that a test can override the generic one.
368 return static_cast<WrenchMenuModel*>(model_);
371 - (void)updateRecentTabsSubmenu {
372 ui::MenuModel* model = [self recentTabsMenuModel];
374 recentTabsMenuModelDelegate_.reset(
375 new RecentTabsMenuModelDelegate(model, [self recentTabsSubmenu]));
379 - (BrowserActionsController*)browserActionsController {
380 return browserActionsController_.get();
383 - (void)createModel {
385 recentTabsMenuModelDelegate_.reset();
386 wrenchMenuModel_.reset(
387 new WrenchMenuModel(acceleratorDelegate_.get(), browser_));
388 [self setModel:wrenchMenuModel_.get()];
390 buttonViewController_.reset(
391 [[WrenchMenuButtonViewController alloc] initWithController:self]);
392 [buttonViewController_ view];
394 // See comment in containerSuperviewFrameChanged:.
395 NSView* containerSuperview =
396 [[buttonViewController_ overflowActionsContainerView] superview];
397 [containerSuperview setPostsFrameChangedNotifications:YES];
400 // Fit the localized strings into the Cut/Copy/Paste control, then resize the
401 // whole menu item accordingly.
402 - (void)adjustPositioning {
403 const CGFloat kButtonPadding = 12;
406 // Go through the three buttons from right-to-left, adjusting the size to fit
407 // the localized strings while keeping them all aligned on their horizontal
409 NSButton* views[] = {
410 [buttonViewController_ editPaste],
411 [buttonViewController_ editCopy],
412 [buttonViewController_ editCut]
414 for (size_t i = 0; i < arraysize(views); ++i) {
415 NSButton* button = views[i];
416 CGFloat originalWidth = NSWidth([button frame]);
418 // Do not let |-sizeToFit| change the height of the button.
419 NSSize size = [button frame].size;
421 size.width = [button frame].size.width + kButtonPadding;
422 [button setFrameSize:size];
424 CGFloat newWidth = size.width;
425 delta += newWidth - originalWidth;
427 NSRect frame = [button frame];
428 frame.origin.x -= delta;
429 [button setFrame:frame];
432 // Resize the menu item by the total amound the buttons changed so that the
433 // spacing between the buttons and the title remains the same.
434 NSRect itemFrame = [[buttonViewController_ editItem] frame];
435 itemFrame.size.width += delta;
436 [[buttonViewController_ editItem] setFrame:itemFrame];
438 // Also resize the superview of the buttons, which is an NSView used to slide
439 // when the item title is too big and GTM resizes it.
440 NSRect parentFrame = [[[buttonViewController_ editCut] superview] frame];
441 parentFrame.size.width += delta;
442 parentFrame.origin.x -= delta;
443 [[[buttonViewController_ editCut] superview] setFrame:parentFrame];
446 - (NSButton*)zoomDisplay {
447 return [buttonViewController_ zoomDisplay];
450 - (void)menu:(NSMenu*)menu willHighlightItem:(NSMenuItem*)item {
451 if (browserActionsController_.get()) {
452 [browserActionsController_ setFocusedInOverflow:
453 (item == browserActionsMenuItem_)];
457 // -[NSMenu removeAllItems] is only available on 10.6+.
458 - (void)removeAllItems:(NSMenu*)menu {
459 while ([menu numberOfItems]) {
460 [menu removeItemAtIndex:0];
464 - (NSMenu*)recentTabsSubmenu {
465 NSString* title = l10n_util::GetNSStringWithFixup(IDS_RECENT_TABS_MENU);
466 return [[[self menu] itemWithTitle:title] submenu];
469 // The recent tabs menu model is recognized by the existence of either the
470 // kRecentlyClosedHeaderCommandId or the kDisabledRecentlyClosedHeaderCommandId.
471 - (RecentTabsSubMenuModel*)recentTabsMenuModel {
473 // Start searching at the wrench menu model level, |model| will be updated
474 // only if the command we're looking for is found in one of the [sub]menus.
475 ui::MenuModel* model = [self wrenchMenuModel];
476 if (ui::MenuModel::GetModelAndIndexForCommandId(
477 RecentTabsSubMenuModel::kRecentlyClosedHeaderCommandId, &model,
479 return static_cast<RecentTabsSubMenuModel*>(model);
481 if (ui::MenuModel::GetModelAndIndexForCommandId(
482 RecentTabsSubMenuModel::kDisabledRecentlyClosedHeaderCommandId,
484 return static_cast<RecentTabsSubMenuModel*>(model);
489 // This overrdies the parent class to return a custom width for recent tabs
491 - (int)maxWidthForMenuModel:(ui::MenuModel*)model
492 modelIndex:(int)modelIndex {
493 RecentTabsSubMenuModel* recentTabsMenuModel = [self recentTabsMenuModel];
494 if (recentTabsMenuModel && recentTabsMenuModel == model) {
495 return recentTabsMenuModel->GetMaxWidthForItemAtIndex(modelIndex);
500 @end // @implementation WrenchMenuController
502 ////////////////////////////////////////////////////////////////////////////////
504 @interface WrenchMenuButtonViewController ()
505 - (void)containerSuperviewFrameChanged:(NSNotification*)notification;
508 @implementation WrenchMenuButtonViewController
510 @synthesize editItem = editItem_;
511 @synthesize editCut = editCut_;
512 @synthesize editCopy = editCopy_;
513 @synthesize editPaste = editPaste_;
514 @synthesize zoomItem = zoomItem_;
515 @synthesize zoomPlus = zoomPlus_;
516 @synthesize zoomDisplay = zoomDisplay_;
517 @synthesize zoomMinus = zoomMinus_;
518 @synthesize zoomFullScreen = zoomFullScreen_;
519 @synthesize toolbarActionsOverflowItem = toolbarActionsOverflowItem_;
520 @synthesize overflowActionsContainerView = overflowActionsContainerView_;
522 - (id)initWithController:(WrenchMenuController*)controller {
523 if ((self = [super initWithNibName:@"WrenchMenu"
524 bundle:base::mac::FrameworkBundle()])) {
525 controller_ = controller;
526 [[NSNotificationCenter defaultCenter]
528 selector:@selector(containerSuperviewFrameChanged:)
529 name:NSViewFrameDidChangeNotification
530 object:[overflowActionsContainerView_ superview]];
536 [[NSNotificationCenter defaultCenter] removeObserver:self];
540 - (IBAction)dispatchWrenchMenuCommand:(id)sender {
541 [controller_ dispatchWrenchMenuCommand:sender];
544 - (void)containerSuperviewFrameChanged:(NSNotification*)notification {
545 // AppKit menus were probably never designed with a view like the browser
546 // actions container in mind, and, as a result, we come across a few oddities.
547 // One of these is that the container's superview will, on some versions of
548 // OSX, change frame position sometime after the the menu begins tracking
549 // (and thus, after all our ability to adjust it normally). Throw in the
550 // towel, and simply don't let the frame move from where it's supposed to be.
551 // TODO(devlin): Yet another Cocoa hack. It'd be good to find a workaround,
552 // but unlikely unless we replace the Cocoa menu implementation.
553 NSView* containerSuperview = [overflowActionsContainerView_ superview];
554 if (NSMinX([containerSuperview frame]) != 0)
555 [containerSuperview setFrameOrigin:NSZeroPoint];
558 @end // @implementation WrenchMenuButtonViewController